Skip to content

polish(ws): release orphan subscribe acks; require snapshot yes/no - #293

Merged
TexasCoding merged 1 commit into
mainfrom
polish/268-ws
May 21, 2026
Merged

TexasCoding merged 1 commit into
mainfrom
polish/268-ws

Conversation

@TexasCoding

Copy link
Copy Markdown
Owner

Closes #268

Part of #273 (post-v2.4 audit), Wave 3.

Two small WS polish items from the post-v2.4 audit:

Item 1 — cancelled mid-subscribe leak. ``SubscriptionManager.subscribe``
sends a ``subscribe`` cmd then awaits ``_wait_for_response``; if the
awaiting task is cancelled between send and ack, the lock releases but
the server still completes the subscribe and emits a ``subscribed``
envelope. That ack lands in ``MessageDispatcher.dispatch`` with no
``sid -> client_id`` mapping installed, was silently dropped, and the
server held an active sid until the WS closed (data frames thereafter
logged "Message for unknown sid"; the leaked sub counted against any
server-side quota).

Detect the orphan in the dispatcher: when a ``subscribed`` control
envelope arrives with a sid not in ``_sub_mgr._sid_to_client``, log a
WARNING and send an ``unsubscribe`` back through the connection to
release the server-side state. Best-effort: a send failure is logged
and swallowed (socket is likely already gone, in which case the server
reaps on close anyway). Sids that already have a mapping (normal-path
race where the mapping landed first) are a no-op.

Item 2 — ``OrderbookSnapshotPayload`` ``yes``/``no`` no longer have a
``default_factory=dict``. A wire frame missing both ``yes_dollars_fp``
and ``no_dollars_fp`` was previously parsing as a fully empty snapshot
— silently resetting the local book to empty on schema drift. Now both
sides are required so a malformed snapshot raises ``ValidationError``,
which pairs with the #241 seq-rollback path so the watermark isn't
silently advanced past the never-delivered frame. (The mutable-default
anti-pattern was already fixed by #263 switching to ``default_factory``;
this drops the default entirely.)

Tests:
- 4 orphan-subscribed dispatcher tests (sends unsubscribe with
  nested/top-level sid, no-op on known sid, send-failure swallowed).
- Snapshot regression: missing ``yes`` / missing ``no`` / missing both
  each raise ``ValidationError``.

Closes #268
@claude

claude Bot commented May 21, 2026

Copy link
Copy Markdown

Code Review — PR #293: polish(ws): release orphan subscribe acks; require snapshot yes/no

Overview

Two related fixes for the cancel-mid-subscribe race (#268):

  1. dispatch.py: New _handle_orphan_subscribed handler catches subscribed acks that escape _wait_for_response (due to task cancellation) and immediately unsubscribes to reclaim the server-side sid.
  2. models/orderbook_delta.py: Makes yes and no required on OrderbookSnapshotPayload so schema drift / partial responses surface as ValidationError rather than silently resetting the local book.

The architectural reasoning is sound: _wait_for_response monopolises the connection recv while it runs, so subscribed acks that reach the dispatcher must be orphans — no race between the mapping installation and the orphan check.


Issues

1. Docstring length violates CLAUDE.md style 🔴

_handle_orphan_subscribed has a 25-line multi-paragraph docstring. CLAUDE.md is explicit:

"Never write multi-paragraph docstrings or multi-line comment blocks — one short line max."

The neighbouring _handle_server_unsubscribe follows this rule. The rationale for the orphan handler (the cancel-race, #268) belongs in the PR/commit message, not in the source. Suggest trimming to ≤ 2 lines:

async def _handle_orphan_subscribed(self, data: dict[str, Any]) -> None:
    """Release a server-side sid whose subscribe ack has no client mapping (#268)."""

2. type: ignore[attr-defined] proliferation in new tests 🟡

The four new test cases monkey-patch _connection, _get_msg_id, and _next_msg_id onto FakeSubManager at runtime, generating 6+ # type: ignore[attr-defined] annotations. These attributes are actually used by the real SubscriptionManager (they live in channels.py), so FakeSubManager is already expected to mirror the real shape. Adding them directly to FakeSubManager would remove all the type: ignores and make the test setup cleaner — mgr._connection = _FakeConnection() would just work.

3. Private attribute reach-through in production code 🟡

_handle_orphan_subscribed accesses three private members of _sub_mgr:

  • self._sub_mgr._sid_to_client
  • self._sub_mgr._get_msg_id()
  • self._sub_mgr._connection.send()

This creates tight coupling to SubscriptionManager internals. A thin public method like sub_mgr.release_orphan_sid(sid) would encapsulate the logic and make it easier to test without monkey-patching. Not a blocker, but worth tracking.

4. Misleading inline comment at the dispatch routing 🟡

# Skip control messages (handled by subscribe/unsubscribe flow)
if msg_type in CONTROL_TYPES:

subscribed is now actively handled here, not just skipped. The comment is no longer accurate. Suggest updating to something like:

# Control-type messages: error/unsubscribed/subscribed handled below; ok falls through to return.

5. Missing test for the no-int-sid debug path 🟢

_handle_orphan_subscribed has a logger.debug(...) branch when neither data["sid"] nor data["msg"]["sid"] yields an int. There's no test exercising this code path (e.g., {"type": "subscribed", "id": 1, "msg": {}} or {"type": "subscribed", "id": 1, "sid": "not-an-int"}). Low priority but improves coverage.


Model Change: OrderbookSnapshotPayload.yes/no now required

The change is justified. An empty side should be sent as "yes_dollars_fp": [] by the server (which _levels_to_dict converts to {}); a missing key is a protocol violation. The None → {} path in _levels_to_dict remains correct for explicit null values. The three validation tests cover the meaningful failure cases.

One edge case to confirm: is there any internal code (serialisation round-trips, test fixtures, direct construction) that constructs OrderbookSnapshotPayload without both sides? A quick grep for OrderbookSnapshotPayload( / model_validate calls would confirm nothing regresses outside the diff.


Test Coverage

The four dispatch tests hit all the important branches: orphan send, noop for known sid, top-level vs. nested sid, and send failure swallowed. Good coverage for the new handler.


Summary

The logic is correct and the architectural reasoning is well-grounded. The main request before merge is trimming the docstring to comply with the project's comment style. The FakeSubManager type-ignore cleanup is worth a follow-up but not blocking.

Verdict: Request changes (docstring length), approve after that is addressed.

@TexasCoding
TexasCoding merged commit e87df48 into main May 21, 2026
5 checks passed
@TexasCoding
TexasCoding deleted the polish/268-ws branch May 21, 2026 13:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Polish bundle: WS small items (cancelled mid-subscribe leak, snapshot empty-array default)

1 participant