You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Surfaced by the multi-LLM review of PR #403 (perps/margin API, finding "F6"). The perps recv loop's post-teardown stale-sid drop guard only covers orderbook frame types, but order_group_updates is also a sequenced channel that carries seq, so a late/recycled-sid order_group_updates frame bypasses the drop and still mutates the sequence tracker.
Problem
In kalshi/perps/ws/client.py::_process_frame, the stale-sid drop gate is gated on _ORDERBOOK_TYPES only:
kalshi/perps/ws/client.py:356-366 — the drop block: if msg_type in _ORDERBOOK_TYPES and isinstance(sid, int) and self._sub_mgr is not None and self._sub_mgr.get_subscription_by_sid(sid) is None: ... return
But order_group_updates IS a sequenced data channel:
kalshi/perps/ws/models/order_group.py:40-43 — OrderGroupUpdatesMessage envelope has type: Literal["order_group_updates"], sid: int, seq: int (REQUIRED).
For a stale/unmapped-sid order_group_updates frame (sid reaped by teardown/unsubscribe or recycled by the server):
client.py:356-366 — msg_type == "order_group_updates" is NOT in _ORDERBOOK_TYPES, so the drop gate is bypassed.
client.py:370-382 — sid/seq are present, so the seq-tracking block runs. get_subscription_by_sid(sid) returns None, so channel = "", and the call becomes track_sync(sid, seq, msg_type if msg_type else channel) = track_sync(sid, seq, "order_group_updates").
"order_group_updates" is in PERPS_SEQUENCED_CHANNELS, so should_track is true. In kalshi/ws/sequence.py:67-72, the cleared watermark (last = self._last_seq.get(sid) is None) gets re-armed: self._last_seq[sid] = seq; return True, None.
The dispatcher then drops the frame (no client mapping), but the watermark damage persists. Concrete failure: when that same sid is later reused by a fresh subscription, its first legitimate frame is diffed against the stale watermark seeded by the orphan frame, producing a spurious gap/reset (KalshiSequenceGapError surfaced to the new consumer, local state cleared, forced resubscribe) instead of a clean first-frame init.
Note the prediction-API client has the identical latent gap (kalshi/ws/client.py:447-457 gates only ("orderbook_snapshot", "orderbook_delta")), and its own comment at kalshi/ws/client.py:443-446 claims it gates "ALL downstream processing (seq tracking, validation, apply, dispatch)" — which is only true for orderbook types. The perps client faithfully mirrors that path, so both surfaces share the bug.
Proposed fix
Generalize the stale-sid drop so it covers any sequenced data frame for an unmapped sid, not just orderbook types. In kalshi/perps/ws/client.py, change the gate (lines 356-366) to test membership in the sequenced-channel set instead of _ORDERBOOK_TYPES:
This drops the frame BEFORE track_sync, so the watermark is never re-armed for an unmapped sid. _ORDERBOOK_TYPES is still needed for the apply-seam branches at client.py:396-405, so keep it. Mirror the same change in kalshi/ws/client.py:447-457 (use SEQUENCED_CHANNELS) so the two surfaces stay in lockstep, and update the stale comment that overclaims coverage.
Acceptance criteria
_process_frame drops an unmapped-sid order_group_updates frame before it reaches track_sync (perps client).
The seq tracker's _last_seq does NOT contain an entry for the unmapped sid after such a frame.
Equivalent change applied to the prediction-API kalshi/ws/client.py path (and its overclaiming comment corrected).
Regression test (perps): after reset(sid)/unsubscribe, feed a stale order_group_updates frame for that sid, assert it is dropped and seq_tracker.peek(sid) is None; then a fresh subscription reusing that sid does NOT raise KalshiSequenceGapError on its first frame.
Regression test (equities): mirror of the above against kalshi/ws/client.py.
Notes
Severity is medium, not high: the orphan frame is still dropped at dispatch (no double-apply, no bad data delivered); the harm is a spurious gap/resync on a later sid reuse, which is recoverable but causes an unnecessary resubscribe and a consumer-visible KalshiSequenceGapError. Sid recycling within a single session is the trigger; if the server never reuses a torn-down sid, the leaked watermark entry is merely a small memory leak per orphaned sequenced frame.
Context
Surfaced by the multi-LLM review of PR #403 (perps/margin API, finding "F6"). The perps recv loop's post-teardown stale-sid drop guard only covers orderbook frame types, but
order_group_updatesis also a sequenced channel that carriesseq, so a late/recycled-sidorder_group_updatesframe bypasses the drop and still mutates the sequence tracker.Problem
In
kalshi/perps/ws/client.py::_process_frame, the stale-sid drop gate is gated on_ORDERBOOK_TYPESonly:kalshi/perps/ws/client.py:76—_ORDERBOOK_TYPES = ("orderbook_snapshot", "orderbook_delta")kalshi/perps/ws/client.py:356-366— the drop block:if msg_type in _ORDERBOOK_TYPES and isinstance(sid, int) and self._sub_mgr is not None and self._sub_mgr.get_subscription_by_sid(sid) is None: ... returnBut
order_group_updatesIS a sequenced data channel:kalshi/perps/ws/sequence.py:25-29—PERPS_SEQUENCED_CHANNELS = {"orderbook_delta", "orderbook_snapshot", "order_group_updates"}kalshi/perps/ws/models/order_group.py:40-43—OrderGroupUpdatesMessageenvelope hastype: Literal["order_group_updates"],sid: int,seq: int(REQUIRED).For a stale/unmapped-sid
order_group_updatesframe (sid reaped by teardown/unsubscribe or recycled by the server):client.py:356-366—msg_type == "order_group_updates"is NOT in_ORDERBOOK_TYPES, so the drop gate is bypassed.client.py:370-382—sid/seqare present, so the seq-tracking block runs.get_subscription_by_sid(sid)returnsNone, sochannel = "", and the call becomestrack_sync(sid, seq, msg_type if msg_type else channel)=track_sync(sid, seq, "order_group_updates")."order_group_updates"is inPERPS_SEQUENCED_CHANNELS, soshould_trackis true. Inkalshi/ws/sequence.py:67-72, the cleared watermark (last = self._last_seq.get(sid)isNone) gets re-armed:self._last_seq[sid] = seq; return True, None.The dispatcher then drops the frame (no client mapping), but the watermark damage persists. Concrete failure: when that same sid is later reused by a fresh subscription, its first legitimate frame is diffed against the stale watermark seeded by the orphan frame, producing a spurious gap/reset (
KalshiSequenceGapErrorsurfaced to the new consumer, local state cleared, forced resubscribe) instead of a clean first-frame init.Note the prediction-API client has the identical latent gap (
kalshi/ws/client.py:447-457gates only("orderbook_snapshot", "orderbook_delta")), and its own comment atkalshi/ws/client.py:443-446claims it gates "ALL downstream processing (seq tracking, validation, apply, dispatch)" — which is only true for orderbook types. The perps client faithfully mirrors that path, so both surfaces share the bug.Proposed fix
Generalize the stale-sid drop so it covers any sequenced data frame for an unmapped sid, not just orderbook types. In
kalshi/perps/ws/client.py, change the gate (lines 356-366) to test membership in the sequenced-channel set instead of_ORDERBOOK_TYPES:This drops the frame BEFORE
track_sync, so the watermark is never re-armed for an unmapped sid._ORDERBOOK_TYPESis still needed for the apply-seam branches atclient.py:396-405, so keep it. Mirror the same change inkalshi/ws/client.py:447-457(useSEQUENCED_CHANNELS) so the two surfaces stay in lockstep, and update the stale comment that overclaims coverage.Acceptance criteria
_process_framedrops an unmapped-sidorder_group_updatesframe before it reachestrack_sync(perps client)._last_seqdoes NOT contain an entry for the unmapped sid after such a frame.kalshi/ws/client.pypath (and its overclaiming comment corrected).reset(sid)/unsubscribe, feed a staleorder_group_updatesframe for that sid, assert it is dropped andseq_tracker.peek(sid) is None; then a fresh subscription reusing that sid does NOT raiseKalshiSequenceGapErroron its first frame.kalshi/ws/client.py.Notes
KalshiSequenceGapError. Sid recycling within a single session is the trigger; if the server never reuses a torn-down sid, the leaked watermark entry is merely a small memory leak per orphaned sequenced frame._ORDERBOOK_TYPESfor the apply-seam branches.