Skip to content

fix: ignore callbacks from a signal socket that was already replaced - #1211

Open
hiroshihorie wants to merge 4 commits into
hiroshi/complete-disconnect-on-socket-closefrom
hiroshi/signal-socket-generation
Open

hiroshihorie wants to merge 4 commits into
hiroshi/complete-disconnect-on-socket-closefrom
hiroshi/signal-socket-generation

Conversation

@hiroshihorie

@hiroshihorie hiroshihorie commented Sep 16, 2026 •

Copy link
Copy Markdown
Member

Stacked on #1208.

Problem

A real socket reports its close a few milliseconds after the SDK disposes it (websocket/io.dart onDone, websocket/web.dart onClose). SignalClient routed that report through the same handler as a live server close. If it landed while the next connection was being established, it set the signal state back to disconnected and the engine, no longer closed, started a reconnect against a healthy socket. The window is small, a disconnect() followed immediately by connect(), or any full reconnect, but it is real. #1208 makes disconnect() return sooner, so this is the right time to close it.

Fix

  • Each socket's onData, onDispose and onError callbacks carry the generation of the socket they were created for. cleanUp() and connect() bump the generation. A callback from an older generation is dropped with a log line.
  • The signalReconnect and fullReconnect debug scenarios used to call signalClient.cleanUp() and rely on the late close to make the engine recover. That path is now dropped by design, so they call a new internal simulateSignalDrop() which disposes the socket and raises the disconnect explicitly. Same observable behaviour.
  • The mock socket now reports its close after dispose() with a configurable latency, like the real implementations, so the race is reproducible.

Tests

test/core/stale_socket_close_test.dart:

  • disconnect, connect again before the old socket reports its close, the stale close arrives during the new session: the room stays connected and no reconnect or disconnect event is emitted. Fails without the guard.
  • the signalReconnect debug scenario still re-opens the signal connection as a resume.

Full suite 430 passing.

🤖 Generated with Claude Code

Update: the counter is now _socketId (ef6abd1), since it only decides whether a callback came from the current socket. Where the description above says generation, it means this id.

A real socket reports its close a few milliseconds after dispose()
returns. That report went through the same handler as a live close, so
when it landed during the next connect it reset the signal state to
disconnected and started a reconnect against a socket that was fine.
Each socket's callbacks now carry a generation, bumped on cleanUp() and
on every connect, and a callback from an older generation is dropped.

The signalReconnect and fullReconnect debug scenarios relied on that
late close to trigger the engine's recovery. They now call
simulateSignalDrop(), which disposes the socket and raises the
disconnect explicitly.

The mock socket reports its close after dispose() like the real ones, so
the race is reproducible in tests.
…ocket

cleanUp() now ignores the close the disposed socket reports afterwards, so
callers that relied on that late close to make the engine recover stopped
working. The connection checker's reconnect check always reported that no
reconnect was attempted, and the disconnectSignalOnResume debug actions never
started the resume the server is waiting for.

Both now call simulateSignalDrop(), like the signalReconnect and fullReconnect
scenarios already do.
@hiroshihorie
hiroshihorie marked this pull request as ready for review October 16, 2026 21:56

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Devin Review: 1 flag

Not posted on this PR by your GitHub settings — view it in Devin Review. (Configure)

Devin Review

@hiroshihorie
hiroshihorie added this pull request to stack #1242 October 16, 2026 21:59
_socketGeneration becomes _socketId. The value is compared to decide whether a
callback came from the current socket, which an id describes more directly than
a generation. No behavior change.

This branch has not been deployed

No deployments
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