Skip to content

Release 11.3.2.0 - #180

Merged
Platonenkov merged 1 commit into
releasefrom
dev
Sep 6, 2026
Merged

Release 11.3.2.0#180
Platonenkov merged 1 commit into
releasefrom
dev

Conversation

@Platonenkov

Copy link
Copy Markdown
Collaborator

Promotes dev to release, publishing Xrpl 11.3.2.0 to nuget.org. One squashed commit on top of 11.3.1.0: #178, closing #177.

What ships

A request issued while the client is switching servers no longer hangs until RequestTimeout (#177). Every path that retires a connection swept the pending requests first and cleared the socket reference afterwards. The sweep resumes the consumer, and a consumer that issues its next request from there - the second value of a page load, read from the response handler of the first - found the retired socket still installed, passed the connectivity check on it, and was written into it after the sweep that would have rejected it. Forty seconds later it timed out, with the connection healthy for thirty-nine of them.

The window is not where the issue placed it. RequestManager builds its completion sources without RunContinuationsAsynchronously, so on a thread pool the consumer's continuation runs inline, inside the sweep itself, before any await - moving the clear ahead of the first await, as proposed, leaves the regression test red. The socket reference is cleared before the sweep on all five retirement paths, one of which the issue did not list.

Three defects found on the Blazor WebAssembly stand while verifying it, each reproduced there and pinned by a test that was red before the fix:

  • every ChangeServer, fast reconnect and Disconnect stalled a single-threaded host for two seconds - a blocking wait on the stream reader whose continuation needed the very thread that was blocked. Awaited now: 2000 ms → 5–390 ms per switch;
  • a ping-triggered reconnect waited three seconds for its own ping before announcing the session had ended: 6 s → 20 ms;
  • a reconnect the loop had already finished was reconnected again - the fast reconnect read the cancellation of its wait as a failure, reported RestoringConnection on a connected client and started a second loop that retired the live socket. Consumers saw two OnConnected per recovery and restored their subscriptions twice.

From the review, five more windows on the same paths closed: a socket a user Disconnect() took can no longer be installed by a late OnConnect; a stale OnConnected-handler failure no longer tears down the connection that replaced it, counts towards giving up, or gives up on its behalf; a concurrent DisconnectAndWaitAsync waits for the disconnect in progress instead of reporting one that has not finished.

Versions

Only Xrpl moves, 11.3.1.0 → 11.3.2.0. Xrpl.AddressCodec (10.9.0.0), Xrpl.BinaryCodec (11.0.1.0) and Xrpl.Keypairs (10.9.0.0) stay: git diff origin/release...origin/dev -- Base/ is empty. Patch: no contract changes.

Worth knowing before merging

The failure a consumer sees changes type. A request issued during a switch used to fail after 40 s with TimeoutException; with RequestPolicy.ImmediateFail it now fails at once with NotConnectedException, and with WaitForConnection it is carried over to the new connection. Retry logic that recognised the old failure by its type or message needs to know the new one - the changelog says so, and the consuming projects have issues for it. NotConnectedException() on that path carries the runtime's default message; giving it a real one is in #179.

One OnConnected per recovery, not two. Code that de-duplicated subscription restores on its own keeps working; code that relied on the second callback for anything was relying on a bug.

The retirement paths now yield where they used to block. On a single-threaded host, consumer code can run during a ChangeServer or Disconnect at the point the stream reader is let go. Re-entrancy guards a consumer already needed around the connect that follows now matter one await earlier.

Not in this release, collected in #179: the ordering of two concurrent user commands (ChangeServer vs Disconnect), the reconnect loop's ownership hand-off to OnceClose, and the no-await window between reading the socket and sending on it.

Verification

Run Result
Unit suite 1268 of 1268, on every commit of #178
New tests 4 across two files - the switch window (red on the old code, red with the issue's proposed fix, green with this one), the disconnect variant, the retirement gap, the loop-settled fast reconnect
Blazor WebAssembly client, private rippled 3.3.0 in Docker, repeated on a fresh dev server with the served Xrpl.wasm checked against the source mainnet ↔ testnet ↔ local switches; a switch in the middle of a 66-batch paginated account_tx; docker pause → ping retire → unpause inside the in-flight attempt → one Connected; docker stop → 1005 → loop → docker start → recovered; switch to an unreachable address and back over a live loop
Merge queue integration green on the merge group (34063342810)
CodeRabbit five rounds; every finding either fixed or declined in the PR with the reason

fixCleanup3_4_0 remains open and untouched by this release.

…sts (#178)

* fix(connection): retire the session before sweeping the pending requests

A request created while the client was retiring its connection was written
to the socket being retired and hung until RequestTimeout (issue #177).

Every retirement path swept the pending requests with
RejectAllWithCancellation() and only afterwards cleared `ws`. The sweep
resumes consumer continuations - inline on the sweeping thread when there
is no synchronization context, since RequestManager's completion sources
are not created with RunContinuationsAsynchronously - and a consumer that
issued its next request from there passed ShouldBeConnected() on the old
socket, was sent into it after the sweep, and was never completed. Moving
the clear ahead of the first await, as the issue suggested, does not close
that window: the continuation runs inside the sweep itself.

Clear `ws` and mark the session retiring before the sweep on all five
paths: ChangeServer, the ping/network fast reconnect, Disconnect,
DisconnectAndWaitAsync, and the failed-OnConnected-handler path, which the
issue did not list. `ws` is now volatile - it is the gate every request
reads lock-free. A DisconnectedException out of the send no longer leaves
the just-created request pending for RequestTimeout; it is rejected like an
encoding failure and surfaces through the same await.

The regression test pins the ordering with an ExecuteSynchronously
continuation, which is the shape the sweep hands a consumer on a thread
pool: it asserts the socket the follow-up saw is not the retired one, and
that it completes within seconds rather than after RequestTimeout.

* fix(connection): let the retirement paths yield instead of block, and settle a fast reconnect once

Three defects seen on the Blazor WebAssembly stand while verifying #177,
all on the paths that retire a session.

Stopping the message processor blocked the calling thread on the reader
task for up to two seconds. On a single-threaded host the reader's
continuation needs that very thread to observe the completed channel, so
the block never returned early: every ChangeServer, fast reconnect and
Disconnect stalled the UI for the full two seconds. The stop is now
detach-under-lock plus an awaited exit with the same cap, taken after the
request sweep so consumers are released first. Measured on the stand:
2000 ms to 5-164 ms per switch.

RetireCurrentSessionAndReconnectAsync is awaited from inside the ping
check, and only from there, yet it waited for the ping to finish - its
own - and so ran out WaitForPingToFinishAsync's three-second timeout on
every ping-triggered reconnect. An AsyncLocal set by the ping check lets
the wait recognise the ping it runs in. RestoringConnection to the
session-ended notification: 6 s to 20 ms.

When the fast reconnect's own attempt failed at the socket, the failure
callback started the reconnect loop on the same cancellation source; the
loop connected first, OnceOpen retired the source, and the fast
reconnect's wait came back cancelled. Its catch read that as a failure:
it reported RestoringConnection on a connected client and started a
second loop, whose first attempt retired the live socket and opened
another. A connected client is now recognised as settled in that catch,
and the loop itself no longer retires a socket that is open when its
turn comes.

Both timing defects and the double reconnect are pinned by tests on a
silent-on-ping server: the first measures the retirement gap, the second
takes the server down at RestoringConnection so the sequence falls to
the loop, brings a replacement up on the same port, and requires exactly
one connection afterwards.

* chore(release): Xrpl 11.3.2.0

Patch: the connection fixes for #177 and the three defects found while
verifying it change no contract. Only the Xrpl package moves - the base
packages are untouched since 11.3.1.0 and keep their published versions.

* fix(connection): scope the ping-check marker to its connection, and let the switch tests fail as themselves

Review follow-ups on #178.

The AsyncLocal that lets WaitForPingToFinishAsync recognise the ping it
runs in was static. Its value follows the execution context, so a
consumer's OnPing handler that awaited another connection would carry
the flag into that connection and let it skip waiting for its own ping.
It is an instance field now.

The regression tests judged the follow-up request inside a try whose
catch-all also caught the assertion failures, replacing the original
message with an exception-type mismatch. The outcome is captured first
and judged afterwards. The IsNotNull on the response went with it:
XrplResponse is a struct, and completion within the bound is all a
success has to show.

* fix(connection): a socket a user Disconnect() took cannot come back, and a stale handler failure keeps its hands off the live connection

Review follow-ups on #178, all on code adjacent to the retirement paths.

OnceOpen checked only the session. Disconnect() does not retire the
session - OnceClose is what announces a user disconnect - so a handshake
that completed while Disconnect() was running had its OnConnect callback
install the socket, clear the intentional-disconnect tracking, and hand
the close that followed to the reconnect loop as a network drop. The
socket's own marks are the signal now: a socket Disconnect() took, or a
client that is permanently disconnected, is refused.

OnConnectHandlerFailedAsync swept the pending requests, stopped the
message processor and the ping timer before asking whether the failed
socket was still the current one. When a newer connection had replaced
it, that cleanup hit the newer connection. The ownership check comes
first; a stale callback closes its own socket and steps aside.

DisconnectAndWaitAsync returned "already disconnected" at once when a
concurrent call had taken the socket, while that call was still closing
it. It now waits on the disconnect in progress, within its own timeout.

* fix(connection): a stale OnConnected failure is not the current connection's failure

Review follow-up on #178. The ownership check in OnConnectHandlerFailedAsync
sat after the give-up branch, so a delayed failure from a socket that a
newer connection had already replaced still counted towards giving up
and, with StopAfterMaxAttempts on, ran RejectAll and Disconnect() against
the live connection. The check comes first now, read-only: a stale
callback closes its own socket and returns before anything is counted
or torn down. The clearing check under the same lock stays where it was,
for a replacement that lands during the consumer notifications in between.

* fix(connection): the give-up teardown rechecks ownership after notifying

Review follow-up on #178. In OnConnectHandlerFailedAsync the give-up
branch notifies the consumer before it rejects the requests and calls
Disconnect() - deliberately, so the reason reaches the consumer before
Disconnect() moves the state itself. That notification runs consumer
code: a handler that answers "gave up" with a ChangeServer has already
taken the failed socket out of ws and is opening another, and the
teardown would have hit that connection. Ownership is rechecked after
the notification; the three checks in the method now share one helper.

* fix(connection): the handler-failure notification goes out before the socket is taken

Review follow-up on #178. Moving the RestoringConnection notification
behind the clearing ownership check, as the previous round did, left the
consumer's handler running after this path had already taken the socket
out of ws: a ChangeServer from that handler installed a replacement, and
the cleanup that followed - ping timer, sweep, processor, reconnect
state - hit it, with no way to tell from ws alone. The notification now
precedes the clear, so the ownership check that comes with the clear is
the one that sees what the handler did: a ChangeServer retires the socket
itself, the check finds it gone, and this path closes its socket and
steps aside.
@Platonenkov
Platonenkov merged commit 00f533c into release Sep 6, 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