Skip to content

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

Closed

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-29 — PERPS_SEQUENCED_CHANNELS = {"orderbook_delta", "orderbook_snapshot", "order_group_updates"}
  • 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):

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

Activity

  1. added
    bugSomething isn't working
    wsWebSocket-related
    perpsPerps / margin (perpetual futures) API
    on Jun 5, 2026
  2. added a commit that references this issue on Jun 5, 2026
    a6961e4
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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