Skip to content

Fix connection ownership and session announcement issues - #184

Merged
Platonenkov merged 2 commits into
releasefrom
dev
Sep 9, 2026
Merged

Fix connection ownership and session announcement issues#184
Platonenkov merged 2 commits into
releasefrom
dev

Conversation

@Platonenkov

Copy link
Copy Markdown
Collaborator

No description provided.

Platonenkov and others added 2 commits September 7, 2026 12:34
* fix(connection)!: a transition of the connection has one owner

Every operation that moves the connection - ChangeServer, Connect, Disconnect,
DisconnectAndWaitAsync, the health check's fast reconnect, the reconnect loop and
the failed-OnConnected-handler path - decided for itself what happened to the
socket, and two of them running at once were reconciled by ReferenceEquals(ws, ...)
checks placed after whichever await somebody had noticed. #178 added three such
checks and its review found the next window each time.

The connection now carries a generation. A consumer command and the fast reconnect
begin one (TakeOver), taking the session, the socket, the reconnect loop, the ping
timer and the message processor out of their fields in a single critical section
under _transitionLock, which absorbs _disconnectLock and _reconnectStateLock. The
socket callbacks, the loop and the handler-failure path continue the generation of
the socket they run for. An operation that finds the generation moved on stands
down after every await and every consumer callback; Disconnect() wins against
anything in flight, and an attempt it overtook closes the socket it opened.

The four windows of #179 are closed by that one mechanism:

- ChangeServer no longer overrides a Disconnect() that landed in one of its yields;
  it reports NotConnectedException. A later ChangeServer or Connect() supersedes it
  with OperationCanceledException.
- The fast reconnect captures its session and socket in the takeover, before the
  RestoringConnection notification, and stands down if a handler moved the client.
- The reconnect loop releases its claim under the same lock OnceClose asks under,
  with the socket re-checked there (_reconnectLoopGeneration replaces the task
  reference and its IsCompleted check).
- A request is written under the lock the retirement takes the socket under:
  SendRequestAsync pairs the socket read with the send.

The two loose ends from #178: NotConnectedException carries a default message and
the ImmediateFail refusal names the policy; WebSocketClient.SendMessage no longer
calls Connect() on a socket that is not open, SendMessageAsync returns a Task that
faults when the message could not be written, and messages are serialized whole on
the socket.

Found by the cold review of this change and fixed with it, because the diff
rewrites the paths they live on: OnceOpen reported Connected and started a ping
timer after a Disconnect() from the OnConnected handler; Connect() after a
Disconnect() left _isIntentionalDisconnect set, so a failed handshake read as a
user disconnect and nothing reconnected; a handshake cancelled by a takeover left
its attempt timer firing forever; Connect() over a closing socket announced no
session end and swept no requests.

Closes #179.

* fix(connection): a retired session is announced whatever wins the transition

Third cold-review pass, on the committed branch. Five findings, all on the paths
this change rewrites, three of them regressions of the first commit:

- ChangeServer and the fast reconnect, superseded by a Disconnect() before they
  reached NotifySessionEndedAsync, never announced the session they had retired:
  the retirement silences the socket's own close callback, and the disconnect
  does not know the session. Both announce on the way out now.
- Disconnect() announces UserDisconnected itself. Left to the close callback
  alone, a Connect() issued right after installs a new session before the old
  socket's close is processed, and the callback files it as a stale session.
- A takeover that finds no socket takes no session (DetachLocked): the session
  belongs to whoever took the socket. Connect() after Disconnect() used to
  retire it and announce ConnectionLost for an end that was the disconnect's.
- With StopAfterMaxAttempts, the fast reconnect's catch started a second full
  series after the loop it had handed the sequence to ran out of attempts and
  reported Disconnected. A NotConnectedException from its wait means the client
  gave up, and the catch stands down on it. Reachable at dev.
- _disconnectTcs was installed for a socket still in its handshake, which
  nobody completes; the next DisconnectAndWaitAsync waited out its timeout.
  Installed only for a socket whose close will be reported. Reachable at dev.

The loop-survival test asserted the outcome before the server's close frame
could arrive; it waits for the outcome now.

* docs(connection): the inactivity check needs UseCustomPing, and the docs now say so

UseCheckHealth promised a reconnect after sixty seconds without inbound data,
and InactivityTimeout described silence as "the only signal available without
sending traffic". The inactivity check has always run only with UseCustomPing
enabled, and deliberately: an idle connection with no subscriptions receives
nothing by design, so silence without keepalive pings would declare a healthy
socket dead every InactivityTimeout. The behaviour stays; both docs now say what
it is and why. Found by driving the Blazor test client, whose settings disable
UseCustomPing, through a connection that stayed open and went silent.

* fix(websocket): a failure of an established connection is reported once

Two things the Blazor test client showed, neither reachable from the .NET unit
suite, where a cancelled handshake throws OperationCanceledException and a
dropped connection arrives as a network error.

The receive loop routed a failure that was not a network error - a frame the
protocol forbids, or in the browser any failure at all, since its
ClientWebSocket says nothing recognisable - through the connection-error
callback as well as the close callback. The first is written for a handshake
that failed: it announced "Initial connection failed" for a connection that
had been up and in use, with an OnDisconnect carrying no code, and the second
reported the real close. The close callback is the only reporter now
(ReportFailureAsCloseAsync): it classifies the failure, announces the session
end once and starts the reconnect. In the browser a WebSocketException on an
open socket is a network drop - the transport going away is the one failure
it has - and an exception with no message is described by its error code.
OnConnectionFailed, should anything still reach it for an opened session,
says "Connection lost" rather than "Initial connection failed".

A handshake this side cancelled is not reported twice. The connect-attempt
timer and a takeover cancel the socket after reporting; the browser's
cancelled ConnectAsync throws WebSocketException ("ConnectFailure") rather
than OperationCanceledException, which reached the connection-error callback
as a second failure of the same attempt.

Pinned by MalformedFrameServer, which answers the first request with a frame
carrying a reserved opcode: one OnDisconnect, one OnSessionEnded, no "Initial
connection failed", and the loop brings the client back. Two on the old code.

* docs(changelog): 11.4.0.0 is dated the day it is finished, not the day it was opened
…#182)

Co-authored-by: github-merge-queue <github-merge-queue@users.noreply.github.com>
@Platonenkov
Platonenkov merged commit 244a5f7 into release Sep 9, 2026
10 checks passed
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