Repository navigation
Description
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_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: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: ... return
But order_group_updates IS 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_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/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 (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:
from kalshi.perps.ws.sequence import PERPS_SEQUENCED_CHANNELS # already importing PerpsSequenceTracker, SequenceGap
if (
msg_type in PERPS_SEQUENCED_CHANNELS
and isinstance(sid, int)
and self._sub_mgr is not None
and self._sub_mgr.get_subscription_by_sid(sid) is None
):
logger.debug(
"Dropping stale %s for unmapped sid=%d (post-teardown race)",
msg_type, sid,
)
returnThis 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_framedrops an unmapped-sidorder_group_updatesframe before it reachestrack_sync(perps client). - The seq tracker's
_last_seqdoes NOT contain an entry for the unmapped sid after such a frame. - Equivalent change applied to the prediction-API
kalshi/ws/client.pypath (and its overclaiming comment corrected). - Regression test (perps): after
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. - 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. - Deferred because the headline PR perps: full Perps (margin) API — REST + WebSocket + SCM/Klear (#387) #403 review fixes (orderbook stale-sid race, klear auth, rest/models) shipped in f8cc960/da953b0/f2ef973; this lower-frequency sequenced-channel edge was carried over.
- Part of the perps EPIC perps: [EPIC] Perps (margin) API — full SDK implementation tracker #387. The fix is a 1-line predicate change per client plus tests; keep
_ORDERBOOK_TYPESfor the apply-seam branches.
Activity
ws: generalize stale-sid gate to order_group_updates + retrieve dead …