Skip to content

Heap corruption (0xc0000374) on disconnect: realtime transport stopped after per-context cleanup frees shared state #98

Description

@blurxy

Summary

Intermittent heap corruption (Windows fatal exception: code 0xc0000374, STATUS_HEAP_CORRUPTION) when a multi-instrument TradingSuite (with features=["orderbook"]) disconnects — i.e. on reconnect, restart, or the daily CME session-boundary feed rollover. The process dies hard; a scheduler/supervisor sees a non-zero exit and respawns.

Version: project-x-py 3.5.9 (Windows, Python 3.14). Config: TradingSuite.create(instruments=[...4 index futures...], timeframes=["1min","5min","15min","30min","1hr"], features=["orderbook"]).

Faulthandler stack at the crash

bounded_statistics.py: get_statistics
bounded_statistics.py: _cleanup_counters
bounded_statistics.py: cleanup_bounded_statistics
realtime_data_manager/core.py: cleanup            # gated by `if self.use_bounded_statistics:`
trading_suite.py: _disconnect_single_context

Concurrently, other threads in the same dump are inside realtime_data_manager/data_processing.py _update_timeframe_data / _process_tick_data (i.e. live SignalR tick dispatch).

Root cause (teardown ordering race)

At disconnect, RealtimeDataManager.cleanup() runs while the shared SignalR reader thread is still alive, because the realtime transport is stopped last:

  1. TradingSuite.disconnect()_disconnect_instrument_contexts() runs each context's data.cleanup(), which (a) calls cleanup_bounded_statistics()_cleanup_counters() sweeping the per-counter stats under an asyncio lock, and (b) clears the Polars frames / tick deques.
  2. realtime.disconnect() (which actually stops the SignalR user+market hubs and joins the receive thread) is only awaited after those per-context cleanups.

An asyncio lock does not coordinate the SignalR worker threads, so a trade/quote can be dispatched into a manager whose stats/frames are being torn down → free-during-read → native heap corruption. It surfaces intermittently (only when a tick lands in the teardown window) and always at a disconnect.

Suggested fix

Stop the realtime transport first, so the reader threads are joined before any per-context state is freed:

  • In TradingSuite.disconnect(), await self.realtime.disconnect() (or the equivalent hub-stop) before _disconnect_instrument_contexts() / the per-context data.cleanup(). realtime.disconnect() preserves callbacks/subscriptions, so moving it earlier is state-safe.

A secondary hardening: the disconnect-time cleanup_bounded_statistics() sweep does no useful work at teardown (the counters are about to be GC'd) — skipping it there removes one more concurrent reader of shared state. The periodic in-session cleanup (via the CleanupScheduler) is a separate path and can stay.

Workaround (for anyone else hitting this)

Downstream, we invert the order in our own broker wrapper — await suite.realtime.disconnect() before suite.disconnect() — and separately monkeypatch BoundedStatisticsMixin.cleanup_bounded_statistics to drop the disconnect-time counter sweep. Both eliminate the crash in our testing across repeated reconnects/rollovers.

Happy to open a PR with the reorder if that's useful.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions