Skip to content

ws: data frames for active subs are dropped during normal subscribe/unsubscribe/update command windows #405

Description

@TexasCoding

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-754async 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:

  1. 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.
  2. 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

  • Decision recorded on this issue: option 1 (document) or option 2 (always-stash + drain).
  • If option 2: _wait_for_response stashes non-matching frames carrying an int sid during normal subscribe/unsubscribe/update windows, in both kalshi/ws/channels.py and kalshi/perps/ws/channels.py.
  • If option 2: the client drains take_stash() through the dispatcher when resuming the recv loop after a normal command (kalshi/ws/client.py _do_subscribe and the perps equivalent), with the stash drained on both the success and failure paths.
  • Regression test (both layers): with an active subscription, feed a data frame for its sid interleaved before the command ack during a normal subscribe/unsubscribe/update; assert the frame reaches the dispatcher / orderbook (option 2) — and update/replace test_stashing_flag_off_by_default so the new invariant is the documented one.
  • If option 1: a docstring/doc note on _wait_for_response (both layers) describing the command-window drop and that gap recovery heals it; no behavior change.

Notes

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestwsWebSocket-related

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions