A frame that arrives on a retiring socket is processed as if it belonged to the current session, because the message path never learns which session it came from.
The path
Session identity is captured and threaded into every lifecycle callback (connection.cs, around the socket wiring):
var capturedSession = newSession;
...
await OnceOpen(connectedSocket, capturedSession.SessionId);
await OnConnectionFailed(e, errorSocket, capturedSession.SessionId);
await OnceClose(code, closeDescription, closingSocket, capturedSession.SessionId);
Those checks work — _activeSession.SessionId == sessionId guards the lifecycle handlers.
The message callback is the exception:
ws.OnBinaryMessage(async (m, ws) =>
{
await IOnMessageFastPath(m); // no session identity
});
IOnMessageFastPath(string, byte[]) takes no session id, and neither does EnqueueStreamMessage(byte[]) below it. So a frame is written to whatever _streamMessageChannel is current when it arrives.
Why a late frame is possible
ChangeServer and the reconnect loop retire the old socket without waiting for it:
_ = RetireOldSessionAsync(oldSession, oldSocket); // fire-and-forget
await ConnectInternalAsync(ct); // new session, new channel
and the retirement itself closes gracefully:
await oldSocket.InitiateGracefulCloseAsync().ConfigureAwait(false);
A graceful close is a handshake, not an instant teardown, and the old socket's OnBinaryMessage handler stays attached throughout. Between the new session opening its channel and the old socket finishing its close, anything the old server sends lands in the new session's queue.
What that costs
For a reconnect to the same server the frames are stale but consistent — a duplicate or an out-of-date event.
For ChangeServer it is worse, because the destination can be a different network. Switching mainnet → testnet, a handler that believes it is now on testnet can receive the tail of mainnet's stream: transactions for accounts that do not exist there, ledger indexes from an unrelated chain. Nothing marks them as belonging to the previous connection.
Since the queue is bounded, such frames also occupy slots that current events would otherwise use — immaterial at the default capacity of 10 000, but not free.
Not introduced by the WASM change
This predates #111 and is independent of it: dev before that change calls IOnMessageFastPath(m) from the same callback with no session identity, and the non-browser path has always written straight into the current channel. Removing the browser bypass neither created the window nor widened it — it only made the two platforms behave the same way, including here.
What to do
Thread session identity through the message path the way the lifecycle callbacks already do, and drop frames whose session is no longer active before they reach the channel:
ws.OnBinaryMessage closes over capturedSession.SessionId — the value is already in scope at the wiring site.
IOnMessageFastPath and EnqueueStreamMessage carry it down.
- Compare against
_activeSession.SessionId before writing, matching the existing guards.
A test can drive this without a real socket: connect, hold a reference to the session's socket, call ChangeServer, then feed a frame through the old socket's callback and assert no handler sees it.
Found by review of #111.
A frame that arrives on a retiring socket is processed as if it belonged to the current session, because the message path never learns which session it came from.
The path
Session identity is captured and threaded into every lifecycle callback (
connection.cs, around the socket wiring):Those checks work —
_activeSession.SessionId == sessionIdguards the lifecycle handlers.The message callback is the exception:
IOnMessageFastPath(string, byte[])takes no session id, and neither doesEnqueueStreamMessage(byte[])below it. So a frame is written to whatever_streamMessageChannelis current when it arrives.Why a late frame is possible
ChangeServerand the reconnect loop retire the old socket without waiting for it:and the retirement itself closes gracefully:
A graceful close is a handshake, not an instant teardown, and the old socket's
OnBinaryMessagehandler stays attached throughout. Between the new session opening its channel and the old socket finishing its close, anything the old server sends lands in the new session's queue.What that costs
For a reconnect to the same server the frames are stale but consistent — a duplicate or an out-of-date event.
For
ChangeServerit is worse, because the destination can be a different network. Switching mainnet → testnet, a handler that believes it is now on testnet can receive the tail of mainnet's stream: transactions for accounts that do not exist there, ledger indexes from an unrelated chain. Nothing marks them as belonging to the previous connection.Since the queue is bounded, such frames also occupy slots that current events would otherwise use — immaterial at the default capacity of 10 000, but not free.
Not introduced by the WASM change
This predates #111 and is independent of it:
devbefore that change callsIOnMessageFastPath(m)from the same callback with no session identity, and the non-browser path has always written straight into the current channel. Removing the browser bypass neither created the window nor widened it — it only made the two platforms behave the same way, including here.What to do
Thread session identity through the message path the way the lifecycle callbacks already do, and drop frames whose session is no longer active before they reach the channel:
ws.OnBinaryMessagecloses overcapturedSession.SessionId— the value is already in scope at the wiring site.IOnMessageFastPathandEnqueueStreamMessagecarry it down._activeSession.SessionIdbefore writing, matching the existing guards.A test can drive this without a real socket: connect, hold a reference to the session's socket, call
ChangeServer, then feed a frame through the old socket's callback and assert no handler sees it.Found by review of #111.