Check single-subscription channels before queueing, not during replay - #319
Check single-subscription channels before queueing, not during replay#319pucedoteth wants to merge 2 commits into
Conversation
`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>
koriyoshi2041
left a comment
There was a problem hiding this comment.
There is still a race between on_open and a caller invoking subscribe. on_open sets ws_ready = True and drains self.queued_subscriptions before replaying the local copy. A caller can then register userEvents in active_subscriptions before the websocket thread replays the queued userEvents; the replay raises NotImplementedError and the later queued subscriptions are lost again.
I reproduced this deterministically by replacing the queued list with an iterator that pauses after the swap and before its first yield, then subscribing to userEvents from the caller thread. The opener captured NotImplementedError('Cannot subscribe to userEvents multiple times'), and the queued l2Book was never registered. The current focused tests pass 5/5 and the repository tests pass 41/41, but none schedules a subscribe in that transition window.
Could the ws_ready transition, queue handoff, duplicate check, and active registration be protected by the same lock, or could replay register the handed-off entries without re-entering the public duplicate check? The latter still needs synchronization with concurrent public subscriptions.
The queue handoff in on_open was not atomic with respect to subscribe(). A caller reaching subscribe() after the swap but before the replay registered anything saw an empty queue and an empty active map, so it claimed userEvents for itself. The replay then hit the duplicate check, raised out of on_open, and dropped every subscription queued behind the one that raised -- the same silent loss this PR set out to fix, through a narrower window. Guard ws_ready, queued_subscriptions and active_subscriptions with a reentrant lock. on_open holds it across the whole handoff and replay; subscribe() re-enters it from that same thread. A racing caller now waits for the replay and then sees the genuine duplicate, instead of corrupting it. unsubscribe() takes the lock too: it read-modify-writes active_subscriptions, so a concurrent subscribe's append could be lost. Reported by @koriyoshi2041 in review, reproduced with a queue whose iteration pauses after the handoff, and pinned by test_subscribe_racing_on_open_does_not_drop_the_replay.
|
You're right, and thanks for the precise repro instructions. Reproduced and fixed in 91eab45. I first tried to reproduce by pausing inside the first replayed
The fixI took your first suggestion, one lock over the whole transition. def on_open(self, _ws):
with self.lock:
self.ws_ready = True
queued_subscriptions, self.queued_subscriptions = self.queued_subscriptions, []
for subscription, active_subscription in queued_subscriptions:
self.subscribe(subscription, active_subscription.callback, active_subscription.subscription_id)A racing caller now blocks until the replay finishes and then sees the real duplicate, which is the correct outcome for that caller — it is subscribing to I chose this over "replay without re-entering the duplicate check" because, as you noted, that variant still needs synchronisation, and it would also let a genuine double-queue slip through into
Tests
Removing only the lock and keeping the tests fails exactly that one test: With it, 6 passed in that file and 42 passed across One note on the harness: arming the pause is explicit, because |
koriyoshi2041
left a comment
There was a problem hiding this comment.
Confirmed at 91eab45d: the shared RLock closes the handoff/replay window, and the new deterministic regression preserves the queued l2Book while making the racing duplicate caller fail. I also reran the focused websocket tests (6/6), full repository suite (42/42), and diff hygiene locally. The original concurrency finding is resolved.
What
userEventsandorderUpdatescannot be multiplexed, andsubscriberejects a second one withNotImplementedError. That check only ran on the connected path, so subscribing twice before the socket opens is accepted and queued, then rejected later whileon_openreplays 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: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:
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
subscribefor both paths, counting queued entries as well as active ones, so the duplicate is refused where it is requested.on_opennow takes the queue before replaying it.subscribeconsults that list, so it has to be drained first, and leaving entries in place would also replay them again on any lateron_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: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.