Skip to content

Frames from a retiring session are processed as current #112

Description

@Platonenkov

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:

  1. ws.OnBinaryMessage closes over capturedSession.SessionId — the value is already in scope at the wiring site.
  2. IOnMessageFastPath and EnqueueStreamMessage carry it down.
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions