Context
Surfaced by the multi-LLM review of PR #403 (review item "F2"). Both WebSocket layers (prediction kalshi/ws and perps kalshi/perps/ws) cooperatively park the recv loop while a control command is in flight, and _wait_for_response only stashes the non-matching frames it reads during that window when _stashing is True — which is set only by reconnect/gap-recovery paths, not by ordinary subscribe/unsubscribe/update_subscription.
Problem
When a control command runs, _do_subscribe (and the perps equivalent) pause the recv loop so _wait_for_response can own connection.recv():
kalshi/ws/client.py:727-754 — async with self._subscribe_lock: await self._pause_recv_loop(); sub = await self._sub_mgr.subscribe(...).
- The perps client uses the identical mechanism:
_subscribe_lock + cooperative recv-loop pause (kalshi/perps/ws/client.py:112 and surrounding pause/resume logic).
While the loop is parked, every frame the server sends is read by _wait_for_response. Any frame whose id does not match the command id is treated as "non-matching" and, because _stashing is False on the normal command path, falls into the discard branch:
- Prediction:
kalshi/ws/channels.py:218-228 — matches id, else if self._stashing: _maybe_stash(...) else: logger.debug("Discarding non-matching frame during subscribe: type=%s", ...).
- Perps:
kalshi/perps/ws/channels.py:168-187 — same structure, else: logger.debug("Discarding non-matching frame during command: type=%s", ...).
_stashing is set True only inside resubscribe_all (kalshi/ws/channels.py:418, kalshi/perps/ws/channels.py:430) and resubscribe_one (kalshi/ws/channels.py:502). So a delta/trade/ticker frame for an already-active subscription that happens to arrive interleaved with a subscribe/unsubscribe/update ack is silently dropped — it is never routed to the dispatcher or applied to the local orderbook.
Concrete failure: a user holds an active orderbook_delta subscription, then issues a second subscribe (or an update_subscription) on the same connection. During the brief command window, an orderbook delta for the existing sub lands and is discarded. The local orderbook is now missing that delta → a sequence gap / silent orderbook desync on the existing subscription. The window is small (one command round-trip, default 5s timeout) and only matters on a shared connection with active high-volume channels, which is why severity is low — but the corruption is silent (debug-level log only).
This is the documented, intentional default: tests/ws/test_channels.py:459 (test_stashing_flag_off_by_default) asserts _stashing is False with the rationale "Default is off so normal subscribe paths don't accumulate stale state." So this is a design tradeoff shared by both layers, not a perps regression, and was not changed by the recent fix commits (f8cc960/da953b0/f2ef973).
Proposed fix
This is a decision, not a clear bug. Two options:
- Document and accept (cheapest). Keep current behavior; add a short doc note on the command-window drop so consumers know a desync is possible on a busy shared connection and that gap-recovery (
resubscribe_one) will heal it. No code change.
- Always-stash + drain on recv-loop resume (safe fix, applied to BOTH layers). Enable stashing for the duration of every command (not just resubscribe), and have the client drain
take_stash() back through the dispatcher when it resumes the recv loop — exactly the mechanism already used after resubscribe_all. This closes the window without inventing new machinery. To avoid the "stale state" concern the test cites, the stash must be drained on every resume (success or failure), not left to accumulate.
Whichever is chosen, apply it symmetrically to kalshi/ws/channels.py + kalshi/ws/client.py and kalshi/perps/ws/channels.py + kalshi/perps/ws/client.py so the two WS implementations do not diverge. Do not make a perps-only change.
Acceptance criteria
Notes
Context
Surfaced by the multi-LLM review of PR #403 (review item "F2"). Both WebSocket layers (prediction
kalshi/wsand perpskalshi/perps/ws) cooperatively park the recv loop while a control command is in flight, and_wait_for_responseonly stashes the non-matching frames it reads during that window when_stashingis True — which is set only by reconnect/gap-recovery paths, not by ordinarysubscribe/unsubscribe/update_subscription.Problem
When a control command runs,
_do_subscribe(and the perps equivalent) pause the recv loop so_wait_for_responsecan ownconnection.recv():kalshi/ws/client.py:727-754—async with self._subscribe_lock: await self._pause_recv_loop(); sub = await self._sub_mgr.subscribe(...)._subscribe_lock+ cooperative recv-loop pause (kalshi/perps/ws/client.py:112and surrounding pause/resume logic).While the loop is parked, every frame the server sends is read by
_wait_for_response. Any frame whoseiddoes not match the command id is treated as "non-matching" and, because_stashingis False on the normal command path, falls into the discard branch:kalshi/ws/channels.py:218-228— matchesid, elseif self._stashing: _maybe_stash(...) else: logger.debug("Discarding non-matching frame during subscribe: type=%s", ...).kalshi/perps/ws/channels.py:168-187— same structure,else: logger.debug("Discarding non-matching frame during command: type=%s", ...)._stashingis set True only insideresubscribe_all(kalshi/ws/channels.py:418,kalshi/perps/ws/channels.py:430) andresubscribe_one(kalshi/ws/channels.py:502). So adelta/trade/tickerframe for an already-active subscription that happens to arrive interleaved with a subscribe/unsubscribe/update ack is silently dropped — it is never routed to the dispatcher or applied to the local orderbook.Concrete failure: a user holds an active
orderbook_deltasubscription, then issues a secondsubscribe(or anupdate_subscription) on the same connection. During the brief command window, an orderbook delta for the existing sub lands and is discarded. The local orderbook is now missing that delta → a sequence gap / silent orderbook desync on the existing subscription. The window is small (one command round-trip, default 5s timeout) and only matters on a shared connection with active high-volume channels, which is why severity is low — but the corruption is silent (debug-level log only).This is the documented, intentional default:
tests/ws/test_channels.py:459(test_stashing_flag_off_by_default) asserts_stashing is Falsewith the rationale "Default is off so normal subscribe paths don't accumulate stale state." So this is a design tradeoff shared by both layers, not a perps regression, and was not changed by the recent fix commits (f8cc960/da953b0/f2ef973).Proposed fix
This is a decision, not a clear bug. Two options:
resubscribe_one) will heal it. No code change.take_stash()back through the dispatcher when it resumes the recv loop — exactly the mechanism already used afterresubscribe_all. This closes the window without inventing new machinery. To avoid the "stale state" concern the test cites, the stash must be drained on every resume (success or failure), not left to accumulate.Whichever is chosen, apply it symmetrically to
kalshi/ws/channels.py+kalshi/ws/client.pyandkalshi/perps/ws/channels.py+kalshi/perps/ws/client.pyso the two WS implementations do not diverge. Do not make a perps-only change.Acceptance criteria
_wait_for_responsestashes non-matching frames carrying an int sid during normal subscribe/unsubscribe/update windows, in bothkalshi/ws/channels.pyandkalshi/perps/ws/channels.py.take_stash()through the dispatcher when resuming the recv loop after a normal command (kalshi/ws/client.py_do_subscribeand the perps equivalent), with the stash drained on both the success and failure paths.test_stashing_flag_off_by_defaultso the new invariant is the documented one._wait_for_response(both layers) describing the command-window drop and that gap recovery heals it; no behavior change.Notes
resubscribe_onegap-recovery path will eventually correct once a bad sequence number is observed._stashingmachinery andtake_stash()drain already exist for the reconnect path (WS: data frames arriving during reconnect+resubscribe are dropped instead of stashed #176/WS: resubscribe_one stash is never drained — frames captured during gap recovery are dropped #254), so option 2 reuses proven code rather than adding a new mechanism.