Skip to content

fix(stream): stop a stale join reply from double-firing onSubscribed - #2018

Closed
bayrakdarerdem wants to merge 1 commit into
ProjectOpenSea:mainfrom
bayrakdarerdem:fix/phoenix-stale-join-reply-double-fires-onsubscribed
Closed

bayrakdarerdem wants to merge 1 commit into
ProjectOpenSea:mainfrom
bayrakdarerdem:fix/phoenix-stale-join-reply-double-fires-onsubscribed

Conversation

@bayrakdarerdem

Copy link
Copy Markdown
Contributor

When a second subscriber on a topic asks for a wider event filter than the current join covers, widenFilterIfNeeded re-joins with the union of both filters. If this happens before the first join's reply has come back from the server (very reachable: two subscribe() calls made back to back land well within a normal network round trip), sendJoin overwrote subscription.joinRef with the new join's ref but left the old join's entry sitting in pendingReplies.

When the server's reply for that superseded join arrives later, handleReply still finds it there and invokes its handler, the success/error callback closure from the original sendJoin call. That handler sets subscription.joined = true and fires onSubscribed (or onSubscribeError) for every callback registered on the topic, a second time for what is one logical subscription. The same applies to a channel rejoin after phx_error/phx_close while the original join was still unanswered, since sendJoin is the single call site both paths share.

Fix: before sendJoin overwrites a subscription's joinRef, cancel any pending reply still keyed to the old one, via a small cancelPendingReply helper that clears the timer and removes the map entry without invoking the handler.

Added a regression test: two subscribe() calls with different eventTypes fire back to back so the first join's reply is still pending when the second triggers the widen, the current join is answered normally, then the superseded join's reply arrives late. Confirmed the test fails without the fix (onSubscribed calls go from 1 to 2 per callback when the stale reply lands) and passes with it.

Ran the full suite locally (46/46 in transport.spec.ts, 1279 passed overall), biome check, and check-types: all clean.

When a second subscriber on a topic asks for a wider event filter than
the current join covers, widenFilterIfNeeded re-joins with the union of
both filters. If this happens before the first join's reply has come
back from the server (a very reachable timing: two subscribe() calls
made back to back land well within a typical network round trip),
sendJoin overwrote subscription.joinRef with the new join's ref but
left the old join's entry sitting in pendingReplies.

When the server's reply for that superseded join arrives later,
handleReply still finds it there and invokes its handler, which is the
success/error callback closure from the original sendJoin call. That
handler runs subscription.joined = true and fires onSubscribed (or
onSubscribeError) for every callback registered on the topic, a second
time for one logical subscription. The same applies to a channel
rejoin after phx_error/phx_close while the original join was still
unanswered, since sendJoin is the single call site both paths share.

Fix: before sendJoin overwrites a subscription's joinRef, cancel any
pending reply still keyed to the old one, via a small
cancelPendingReply helper that clears the timer and removes the map
entry without invoking the handler.

Added a regression test: two subscribe() calls with different
eventTypes fire back to back (so the first join's reply is still
pending when the second triggers the widen), the current join is
answered normally, and then the superseded join's reply arrives late.
Confirmed the test fails without the fix (onSubscribed calls go from 1
to 2 per callback when the stale reply lands) and passes with it.

Ran the full suite (1279 passed), biome check, and check-types on the
changed files: all clean.
@ryanio

ryanio commented Sep 30, 2026

Copy link
Copy Markdown
Collaborator

Thanks @bayrakdarerdem, this is a real bug with a clear write-up and a good regression test. This repo is a read-only mirror, so we've recreated the fix in our internal monorepo with you credited as co-author on the commit. It ships in the upcoming @opensea/sdk 12.11.1.

We added one thing: unsubscribeTopic had the same gap. A join answered after the caller had already unsubscribed still fired onSubscribed, so the fix cancels the pending reply there too, with its own test.

Closing this PR since the change now lives in the monorepo. Thanks again!

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.

2 participants