Skip to content

perps: stale-sid drop guard misses order_group_updates (re-arms seq watermark for unmapped sids) #411

Description

@TexasCoding

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-29PERPS_SEQUENCED_CHANNELS = {"orderbook_delta", "orderbook_snapshot", "order_group_updates"}
  • kalshi/perps/ws/models/order_group.py:40-43OrderGroupUpdatesMessage 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):

  1. client.py:356-366msg_type == "order_group_updates" is NOT in _ORDERBOOK_TYPES, so the drop gate is bypassed.
  2. client.py:370-382sid/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").
  3. "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:

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,
    )
    return

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.
  • 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_TYPES for the apply-seam branches.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingperpsPerps / margin (perpetual futures) APIwsWebSocket-related

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions