fix(ws): backpressure error in recv loop tears down connection cleanly#367
Conversation
…phan recv loop When the WS recv loop hit KalshiBackpressureError (or KalshiSubscriptionError) it broadcast queue sentinels and broke — but left the websocket connection open, _running=True, and the subscription manager populated. A subsequent subscribe_* on the same KalshiWebSocket instance (the #297 re-entry guard only fires when _connection is None or not _running) then resurrected a fresh recv task on top of the orphaned server-side subscriptions, with frames silently dropped onto the already-closed queues or mis-routed to stale sids without local snapshots. This commit mirrors the #297 reset pattern from _stop() inside the fatal-error branch of _recv_loop: broadcast sentinels, flip _running=False, close the underlying connection (suppressing close failures), and clear _connection / _sub_mgr / _seq_tracker / _orderbook_mgr / _dispatcher and pause state — without awaiting the recv task because we ARE the recv task. _do_subscribe gains an explicit guard that raises a clear RuntimeError when the session is no longer active (replacing a bare assert), so a follow-up subscribe on a torn-down session fails fast instead of detonating an AssertionError or restarting a dead session. The same KalshiWebSocket instance remains reusable for a fresh async with ws.connect() block — the #297 invariant is preserved end-to-end. tests/ws/test_seq_correctness.py: the existing #78 invariant test snapshots _seq_tracker before awaiting the recv task — the tracker is now cleared as part of teardown, so the assertion runs on the detached object. Substantive intent of the test is unchanged. Closes #332
Code Review — PR #367:
|
…cv_task teardown invariant
|
Round-2 review addressed in 1aebf3c: Addressed
Deferred (low-priority follow-up)
Verified: |
Code Review — PR #367:
|
|
Round-2 review addressed in 7ea2a23: closed the |
Code Review — PR #367 (Round 3)Status after Round 2 fixesAll round-2 feedback has been addressed correctly. Quick verification:
Final correctness passThe critical race is handled correctly. When
No double-close, no double-sentinel, no leaked task. ✓
Remaining low-priority itemThe VerdictLGTM. The implementation is correct, the teardown sequence is safe, the |
Summary
When the WS recv loop hit
KalshiBackpressureError(orKalshiSubscriptionError) it broadcast queue sentinels and broke — but left the websocket connection open,_running=True, and the subscription manager populated. A subsequentsubscribe_*on the sameKalshiWebSocketinstance (a documented recovery path; the#297re-entry guard only fires when_connection is None or not _running) then resurrected a fresh recv task on top of the orphaned server-side subscriptions, with frames silently dropped onto the already-closed queues or mis-routed to stale sids without local snapshots.This PR mirrors the
#297reset pattern from_stop()inside the fatal-error branch of_recv_loop: broadcast sentinels, flip_running=False, close the underlying connection (suppressing close failures), and clear_connection/_sub_mgr/_seq_tracker/_orderbook_mgr/_dispatcher/pause-state — without awaiting the recv task because we ARE the recv task._do_subscribegains an explicit guard that raises a clearRuntimeErrorwhen the session is no longer active (replacing a bareassert self._sub_mgr is not None), so a follow-up subscribe on a torn-down session fails fast instead of detonating anAssertionErroror restarting a dead session.The same
KalshiWebSocketinstance remains reusable for a freshasync with ws.connect()block — the#297invariant is preserved end-to-end.Issues closed
KalshiBackpressureErrorin recv loop now closes the WS connection and clears_running+ manager state so the next subscribe either starts a fresh session or fails fastBehavioral changes
KalshiBackpressureError/KalshiSubscriptionErrorin the recv loop, the session is torn down:_connection/_running/manager refs are cleared and the WS is closed. Previously the sameKalshiWebSocketsession would silently accept furthersubscribe_*calls that orphaned server-side state._do_subscribenow raisesRuntimeError("KalshiWebSocket session is not active. …")instead ofAssertionErrorwhen called on a session whose recv loop has torn down. Callers must exit theasync withblock and re-enter for a fresh session.Tests
tests/ws/test_client.py::TestIssue332BackpressureTeardown::test_issue_332_backpressure_closes_ws_cleanly— asserts the recv loop fully clears connection + manager refs and flips_running=Falseafter overflow.tests/ws/test_client.py::TestIssue332BackpressureTeardown::test_issue_332_next_subscribe_after_backpressure_starts_fresh— asserts a follow-upsubscribe_*on the torn-down session raisesRuntimeError, and that the same instance can be reused for a fresh session that successfully streams a ticker frame.tests/ws/test_seq_correctness.py::TestBackpressureDoesNotAdvanceSeq::test_dropped_delta_keeps_seq_watermark_unchanged— consequential update only: snapshot the_seq_trackerreference before awaiting the recv task, since the tracker is now cleared as part of the teardown. The#78invariant assertion is preserved on the detached object.Full
tests/ws/(374 tests) pass, plusruffandmypyclean.Source
Round-3 independent audit closure plan, wave W2 (medium-severity correctness).