Skip to content

ws: stale-sid gate for order_group_updates + _stop exception retrieval (#411, #413)#415

Merged
TexasCoding merged 1 commit into
mainfrom
fix/perps-ws-stale-sid-411-413
Jun 5, 2026
Merged

ws: stale-sid gate for order_group_updates + _stop exception retrieval (#411, #413)#415
TexasCoding merged 1 commit into
mainfrom
fix/perps-ws-stale-sid-411-413

Conversation

@TexasCoding

Copy link
Copy Markdown
Owner

Cluster A of the post-review follow-ups: two WS correctness bugs + the #405 in-code documentation.

#411 — stale-sid drop gate missed order_group_updates

The recv-loop stale-sid drop gate in _process_frame fired only for _ORDERBOOK_TYPES, but order_group_updates is also sequenced (its envelope carries a required seq). So a stale order_group_updates frame for an unmapped/recycled sid skipped the drop and reached track_sync(sid, seq), re-arming the watermark for a dead sid and manufacturing a spurious sequence gap on a later subscription to that sid.

Fix: renamed _ORDERBOOK_TYPES_SEQUENCED_TYPES (= orderbook_snapshot, orderbook_delta, order_group_updates) and gate on it. Non-sequenced channels (ticker/trade/fill/user_orders) carry no seq and are handled at the dispatch layer, so they're unaffected.

#413_stop() left a dead recv-task's exception unretrieved

Prediction KalshiWebSocket._stop() skipped its recv-task block when the task was already done(), so a recv loop that exited with an exception (e.g. a permanent close code) left the stored exception unretrieved → asyncio logs "Task exception was never retrieved" on GC.

Fix: added an elif self._recv_task is not None: … self._recv_task.exception() branch. ⚠️ This fixes the released prediction WS (the perps PerpsWebSocket._stop() already received the same fix in the merged perps PR).

#405 — documented in-code (already closed by-design)

Added a comment at the _wait_for_response discard site explaining the rationale: frames are stashed during resubscribe_all (rebuilding a book with no baseline) but discarded during normal commands, where a dropped sequenced frame surfaces as a sequence gap and is recovered — not silently lost.

Verification

🤖 Generated with Claude Code

…recv-task exception

Closes #411, closes #413. Also documents #405 (closed by-design) in-code.

#411 — the recv-loop stale-sid drop gate fired only for orderbook frame types,
but `order_group_updates` is also sequenced (carries a required `seq`). A stale
order_group_updates frame for an unmapped/recycled sid therefore skipped the
drop and reached `track_sync`, re-arming the watermark for a dead sid and
manufacturing a spurious gap on a later subscription. Renamed `_ORDERBOOK_TYPES`
→ `_SEQUENCED_TYPES` (adds `order_group_updates`) and gate on it.

#413 — prediction `KalshiWebSocket._stop()` skipped its recv-task block when the
task was already done, so a recv loop that exited with an exception (e.g. a
permanent close code) left the exception unretrieved → asyncio "Task exception
was never retrieved" on GC. Added an `elif` that retrieves it. (This fixes the
released prediction WS; the perps `PerpsWebSocket._stop()` already got the same
fix.)

#405 — added an in-code comment at the `_wait_for_response` discard site
explaining the by-design rationale (resubscribe stashes; normal commands rely on
sequence-gap detection), per the issue's "keep by-design + document" resolution.

Tests: stale order_group_updates for an unmapped sid does not arm the watermark;
_stop retrieves a dead recv-task's exception (verified the test fails without the
fix). mypy strict + ruff + 549 WS/drift tests green.
@claude

claude Bot commented Jun 5, 2026

Copy link
Copy Markdown

Code Review — ws: stale-sid gate + _stop exception retrieval (#411, #413, #405)

Overall: Both correctness fixes are well-reasoned and the regression tests are non-vacuous. A few points below.


#411_SEQUENCED_TYPES gate (kalshi/perps/ws/client.py)

Correct. order_group_updates carries seq and advances track_sync, so it must participate in the stale-sid gate. The rename to _SEQUENCED_TYPES is semantically accurate.

Minor: _SEQUENCED_TYPES is a tuple used only for in membership. A frozenset is marginally more explicit about set semantics:

_SEQUENCED_TYPES: frozenset[str] = frozenset({"orderbook_snapshot", "orderbook_delta", "order_group_updates"})

With 3 elements the perf difference is nil, but it signals "this is a membership set, not an ordered sequence." Take it or leave it.

Scope question (not a blocker): kalshi/ws/client.py (prediction WS) also subscribes to order_group_updates (lines ~622, ~827 on main). That client has no _ORDERBOOK_TYPES-style stale-sid gate at all — presumably it handles sequencing differently at the dispatch layer. Worth confirming there's no analogous gap, but the PR description implies the architectures diverge intentionally.


#413_stop() elif branch (kalshi/ws/client.py)

Correct. The existing if not self._recv_task.done() branch only cancelled a live task; a task that had already exited with an exception fell through with its exception unretrieved. The new elif self._recv_task is not None branch calls .exception() under contextlib.suppress (handles the CancelledError edge), then the shared cleanup nils _recv_task. Logic is tight.


#405 — comment in kalshi/perps/ws/channels.py

Style issue: CLAUDE.md says "Never write multi-paragraph docstrings or multi-line comment blocks — one short line max." The added comment is 7 lines:

# By design (#405): non-matching data frames are stashed only
# during resubscribe_all (rebuilding a book from scratch, where a
# lost frame would corrupt the fresh book with no baseline to
# gap-detect against). During a normal command the book is
# already established, so a dropped sequenced frame surfaces as a
# sequence gap on the next frame and is recovered — not silently
# lost. Discarding here avoids accumulating stale state.

The WHY is genuinely non-obvious and worth keeping, but it should be a single terse line, e.g.:

# Stashed during resubscribe_all only; during normal commands a dropped frame surfaces as a gap and recovers (#405).

Tests

test_stale_order_group_updates_for_unmapped_sid_dropped — Good regression. Directly calls _process_frame on an unmapped sid and asserts seq_tracker.peek() stays None. Non-vacuous by design.

TestIssue413StopRetrievesException — Structurally sound. A couple of minor observations:

  1. await asyncio.sleep(0) to drain the task is correct for this single-hop coroutine, but await asyncio.wait({task}) is more explicit about intent.
  2. The test validates absence of "never retrieved" in the asyncio log after gc.collect(). This works because CPython asyncio logs at ERROR in its exception handler — but it's worth a one-line comment explaining why logging.ERROR is the right level here (not WARNING), so future readers don't accidentally "fix" it.
# asyncio logs "Task exception was never retrieved" at ERROR via its exception handler
with caplog.at_level(logging.ERROR, logger="asyncio"):

Summary

Issue Severity
7-line comment block in channels.py violates CLAUDE.md style Minor
_SEQUENCED_TYPES as tuple vs frozenset Trivial
Test: asyncio.sleep(0) vs explicit asyncio.wait Trivial
Test: unexplained logging.ERROR level Trivial
Confirm prediction WS has no analogous order_group_updates gap Informational

The correctness of both fixes is solid and the tests are meaningful. The only real ask is condensing the channels.py comment to stay within project conventions.

@TexasCoding
TexasCoding merged commit a6961e4 into main Jun 5, 2026
5 checks passed
@TexasCoding
TexasCoding deleted the fix/perps-ws-stale-sid-411-413 branch June 5, 2026 17:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant