Skip to content

Check single-subscription channels before queueing, not during replay - #319

Open
pucedoteth wants to merge 1 commit into
hyperliquid-dex:masterfrom
pucedoteth:fix-queued-duplicate-subscription
Open

Check single-subscription channels before queueing, not during replay#319
pucedoteth wants to merge 1 commit into
hyperliquid-dex:masterfrom
pucedoteth:fix-queued-duplicate-subscription

Conversation

@pucedoteth

Copy link
Copy Markdown

What

userEvents and orderUpdates cannot be multiplexed, and subscribe rejects a second one with NotImplementedError. That check only ran on the connected path, so subscribing twice before the socket opens is accepted and queued, then rejected later while on_open replays the queue.

Why it matters

The exception escapes inside the websocket callback, where the caller cannot catch it, and it aborts the replay loop. Every subscription queued behind the duplicate is silently dropped.

Against master:

ws_manager.subscribe({"type": "userEvents"}, cb)             # queued
ws_manager.subscribe({"type": "userEvents"}, cb)             # queued, no error
ws_manager.subscribe({"type": "l2Book", "coin": "ETH"}, cb)  # queued

ws_manager.on_open(None)
# NotImplementedError: Cannot subscribe to userEvents multiple times
#   subscribe frames sent to the server: 1
#   l2Book:eth registered:                False

The ETH order book feed is never subscribed and never reported as missing.

The same two calls after the socket is open raise at the call site:

ws_manager.subscribe({"type": "userEvents"}, cb)
ws_manager.subscribe({"type": "userEvents"}, cb)
# NotImplementedError raised here, catchable, nothing else affected

So identical user code either raises where it is written, or loses an unrelated market-data feed, depending only on whether the socket happened to be open yet. Info(skip_ws=False) subscribes during construction, so the queued path is the common one at startup.

How

Run the check in subscribe for both paths, counting queued entries as well as active ones, so the duplicate is refused where it is requested.

on_open now takes the queue before replaying it. subscribe consults that list, so it has to be drained first, and leaving entries in place would also replay them again on any later on_open.

Behaviour on the connected path is unchanged, and channels that do multiplex still accept several callbacks on the same identifier.

Tests

Added tests/websocket_manager_test.py, covering the duplicate on both paths, the dropped-subscription case, queue replay and clearing, and multiplexing. The stub socket means nothing connects.

Against the unmodified websocket_manager.py:

FAILED test_duplicate_single_subscription_raises_while_queued
FAILED test_rejected_duplicate_does_not_drop_later_subscriptions
FAILED test_on_open_replays_and_clears_the_queue
3 failed, 2 passed

The two that pass either way are the connected-path duplicate and the multiplexing case, so the change is scoped to the broken path. With the fix the full suite is green: 41 passed.

`userEvents` and `orderUpdates` cannot be multiplexed, and `subscribe` rejects a
second one with `NotImplementedError`. That check only ran on the connected
path, so subscribing twice before the socket opened was accepted, queued, and
only rejected later while `on_open` replayed the queue.

The exception then escapes inside the websocket callback, where the caller
cannot catch it, and it aborts the replay loop. Every subscription queued behind
the duplicate is silently dropped:

    ws_manager.subscribe({"type": "userEvents"}, cb)      # queued
    ws_manager.subscribe({"type": "userEvents"}, cb)      # queued, no error
    ws_manager.subscribe({"type": "l2Book", "coin": "ETH"}, cb)
    # on_open -> NotImplementedError on the second entry
    #   frames sent to the server: 1
    #   l2Book:eth registered:     False

The same two calls after the socket is open raise at the call site, so identical
user code either raises where it is written or loses an unrelated market data
feed, depending only on connection timing.

Run the check in `subscribe` for both paths, counting queued entries as well as
active ones, so the duplicate is refused where it is requested. `on_open` now
takes the queue before replaying it: `subscribe` consults that list, and leaving
entries in place would also replay them again on a later `on_open`.

Behaviour on the connected path is unchanged, and channels that do multiplex
still accept several callbacks.

Tests: `tests/websocket_manager_test.py` covers the duplicate on both paths, the
dropped-subscription case, queue replay and clearing, and multiplexing. Against
the unmodified file three of the five fail; the two that pass either way are the
connected-path duplicate and the multiplexing case.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.

1 participant