Skip to content

fix(connection): a transition of the connection has one owner - #181

Merged
Platonenkov merged 5 commits into
devfrom
claude/connection-transition-owner-691f22
Sep 7, 2026
Merged

fix(connection): a transition of the connection has one owner#181
Platonenkov merged 5 commits into
devfrom
claude/connection-transition-owner-691f22

Conversation

@Platonenkov

@Platonenkov Platonenkov commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

Closes #179 - the follow-up to #178.

What changed

Every operation that moves the connection 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 (Connection._generation). A consumer command (Connect, ChangeServer, Disconnect, DisconnectAndWaitAsync) and the health check's fast reconnect begin one through TakeOver, which takes 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 (absorbing _disconnectLock and _reconnectStateLock). The socket callbacks, the reconnect loop and the failed-OnConnected-handler 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, including one created after the takeover.

The four windows in the issue, each closed by that one mechanism:

  1. Reconnect loop exit vs OnceClose - the loop releases its claim (_reconnectLoopGeneration, replacing the task reference and its IsCompleted check) under the same lock OnceClose decides under, with the socket re-checked there.
  2. Send vs retirement - SendRequestAsync pairs the socket read with the send under the retirement lock; WebSocketClient.SendMessageAsync returns a Task, and a failed write rejects the request instead of leaving it to RequestTimeout.
  3. Fast reconnect vs a consumer that moves the client from the status handler - the session and socket are captured in the takeover, before the notification; the path stands down if a handler took over.
  4. ChangeServer overriding a concurrent Disconnect() - it stands down and reports NotConnectedException; superseded by another ChangeServer/Connect() it reports OperationCanceledException.

Loose ends: NotConnectedException has a default message and the ImmediateFail refusal names the policy; WebSocketClient.SendMessage no longer calls Connect() on a socket that is not open, and messages are serialized whole on the socket.

Found by the cold review of this change, fixed with it

Two passes, five independent readers given the diff with no description of its purpose. Four older defects on the same paths, two of them reproducible at dev:

  • OnceOpen reported Connected and started a ping timer after a Disconnect() from the OnConnected handler.
  • Connect() after Disconnect() left _isIntentionalDisconnect set: a server that was down read as "closed permanently", no reconnect. The flag now follows the generation.
  • A handshake cancelled by a takeover left its attempt timer firing OnConnectionFailed forever.
  • Connect() over a closing socket announced no session end and swept no requests.

Plus one regression of the first draft (awaiting the send before the promise, which would have held a caller past its timeout on a stalled write) - the send is now observed, not awaited.

Tests

New TestUConnectionTransitionOwner (8 tests) issues the second operation from a callback the first one runs, which lands it inside the first one's yields deterministically; 6 of them fail on dev. New TestUWebSocketClientSend (2) pins the send semantics; CloseAfterHandshakeServer closes each connection the moment its handshake completes. The reconnect-loop window is exercised in its reachable form only, as the test remarks say.

Unit suite: 1278 / 1278 on net10.0 (1268 existing + 10 new); library builds on net8.0/9.0/10.0.

Version

Xrpl → 11.4.0.0 (minor: contract changes in ChangeServer and WebSocketClient.SendMessage). Design notes in specs/2026-09-06-connection-transition-owner-design.md.

Added after a third cold-review pass and a live run of the Blazor test client

  • Pass 3 (fable), five more findings, all fixed (ee5dff0c): a ChangeServer or fast reconnect overtaken before it announced the session it retired still announces it; Disconnect() announces UserDisconnected itself (a Connect() right after it used to silence the close callback); a takeover without a socket takes no session; the fast reconnect stands down when the loop it handed the sequence to gave up (StopAfterMaxAttempts meant nothing before); _disconnectTcs is installed only for a socket whose close will be reported.
  • Blazor client, six live scenarios (switch, drop, silence, switch while reconnecting, disconnect/connect, dead server then live) - all recover with one Connected and restored subscriptions. Two WASM-only defects came out of it and are fixed here (06ec9768): a failure of an established connection was reported twice (as "Initial connection failed" and as the close), and a cancelled handshake was reported a second time because the browser throws WebSocketException rather than OperationCanceledException. Pinned by MalformedFrameServer where the .NET runtime can reach it.
  • Docs (afd8db9b): UseCheckHealth/InactivityTimeout now say that inactivity detection needs UseCustomPing, which is what the code has always done and why.

Unit suite: 1283 / 1283.

Summary by CodeRabbit

  • Bug Fixes

    • Improved reliability when connecting, disconnecting, switching servers, reconnecting, and handling health checks concurrently.
    • Prevented stale connection attempts, duplicate reconnects, duplicate failure notifications, and lingering sockets.
    • WebSocket sends now fail promptly on unavailable connections, avoid message interleaving, and report errors consistently.
    • Improved cancellation, handshake, browser, timer, and session-ended event handling.
    • Clarified error messages for disconnected clients.
  • Documentation

    • Expanded guidance for connection lifecycle behavior, cancellation, server changes, reconnection, and failure outcomes.
  • Tests

    • Added extensive regression coverage for connection races, malformed frames, reconnect behavior, and WebSocket sending.

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.
…nsition

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.
…ocs 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.
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.
@Platonenkov

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Sep 7, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai

coderabbitai Bot commented Sep 7, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Essentials

Run ID: 725a2215-ffce-4601-b4e7-260862b8e401

📥 Commits

Reviewing files that changed from the base of the PR and between 7c6ee29 and 06ec976.

📒 Files selected for processing (14)
  • CHANGES.md
  • Tests/Xrpl.Tests/Client/CloseAfterHandshakeServer.cs
  • Tests/Xrpl.Tests/Client/MalformedFrameServer.cs
  • Tests/Xrpl.Tests/Client/TestUConnectionTransitionOwner.cs
  • Tests/Xrpl.Tests/Client/TestUReconnectSessionRaces.cs
  • Tests/Xrpl.Tests/Client/TestUWebSocketClientSend.cs
  • Tests/Xrpl.Tests/Client/WebSocketTestServerBase.cs
  • Xrpl/Client/ConnectionSession.cs
  • Xrpl/Client/Exceptions/XrplException.cs
  • Xrpl/Client/IXrplClient.cs
  • Xrpl/Client/WebSocketClient.cs
  • Xrpl/Client/connection.cs
  • Xrpl/Xrpl.csproj
  • specs/2026-09-06-connection-transition-owner-design.md

Included review availability: 2 reviews are currently available. Your included PR review attempts over the past 7 days set your current allowance at 3 reviews per hour.


📝 Walkthrough

Walkthrough

Version 11.4.0.0 adds generation-based ownership for concurrent connection transitions. It updates socket send and failure handling, adds session generation metadata, documents lifecycle outcomes, and adds regression tests for races, reconnects, cancellations, and closed-socket sends.

Changes

Connection ownership and contracts

Layer / File(s) Summary
Transition ownership contract
Xrpl/Client/ConnectionSession.cs, Xrpl/Client/Exceptions/XrplException.cs, Xrpl/Client/IXrplClient.cs, specs/..., CHANGES.md, Xrpl/Xrpl.csproj
Sessions now store connection generations. Exception defaults and inner-exception handling are defined. Connect and ChangeServer lifecycle outcomes are documented. The package version is 11.4.0.0.
Serialized socket sends and failure handling
Xrpl/Client/WebSocketClient.cs
Sends now return observable tasks, reject unavailable sockets without reconnecting, serialize complete messages, and classify cancellation and established-connection failures consistently.
Lifecycle race and transport regression coverage
Tests/Xrpl.Tests/Client/*
Tests cover transition supersession, callback races, reconnect behavior, handshake cancellation, session-end events, malformed frames, immediate-fail requests, and unavailable-socket sends.

Estimated code review effort: 4 (Complex) | ~45 minutes

Merge Risk: ⚪ Minimal · up to 06ec9

No actionable merge-blocking risk remains after the supplied checks and regression coverage.

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant TransitionLock
  participant WebSocketClient
  participant SessionEvents
  Client->>TransitionLock: start connection transition
  TransitionLock->>WebSocketClient: create or retire socket for generation
  WebSocketClient-->>Client: return connection or send result
  Client->>SessionEvents: report session end or connection failure
  SessionEvents-->>Client: invoke consumer callback
  Client->>TransitionLock: validate callback-driven transition
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 48.98% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 49 functions across 10 files. (3 skipped:… Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the main change: assigning one owner to connection transitions. The wording is slightly awkward but remains specific and relevant.
Linked Issues check ✅ Passed The changes address the objectives in issue #179. They introduce generation-based ownership and transition locking, handle superseding operations and socket cleanup, close the reconnect and send races…
Out of Scope Changes check ✅ Passed The changes remain within issue #179. The added tests, WebSocket failure handling, exception update, documentation, and version update support the connection-transition ownership work and its stated l…
Full details: Docstring Coverage

Explanation

Docstring coverage is 48.98% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 49 functions across 10 files. (3 skipped: 3 unsupported.)

  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/connection-transition-owner-691f22

Comment @coderabbitai help to get the list of available commands.

@Platonenkov
Platonenkov added this pull request to the merge queue Sep 7, 2026
Merged via the queue into dev with commit d4345f2 Sep 7, 2026
4 checks passed
@Platonenkov
Platonenkov deleted the claude/connection-transition-owner-691f22 branch September 7, 2026 12:53
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.

Connection transitions have no owner: four races between concurrent operations, and two loose ends from #178

1 participant