Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .ci-config/Dockerfile.nightly
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ RUN install -m 0755 -d /usr/share/keyrings \
# rippled was renamed to xrpld on the develop branch; the nightly channel publishes it as the "xrpld" package.
# The version must be pinned: the timestamp format changed from 14 to 12 digits mid-2026,
# so Debian version ordering ranks old 14-digit builds above the newer 12-digit ones.
ARG XRPLD_VERSION=3.4.0~b0+202608111815.26cc683e-1
ARG XRPLD_VERSION=3.4.0~rc1+202609050006.e3c8996e-1
RUN echo "deb [signed-by=/usr/share/keyrings/ripple-key.gpg] https://repos.ripple.com/repos/rippled-deb jammy nightly" > /etc/apt/sources.list.d/ripple.list \
&& apt-get update \
&& apt-get install -y --no-install-recommends --allow-downgrades "xrpld=${XRPLD_VERSION}" \
Expand Down
4 changes: 4 additions & 0 deletions .ci-config/rippled.batchv11.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ DeepFreeze
DynamicMPT
DynamicNFT
LendingProtocol
LendingProtocolV1_1
MPTokensV1
MPTokensV2
NFTokenMintOffer
Expand All @@ -122,6 +123,7 @@ fixCleanup3_1_3
fixCleanup3_2_0
fixCleanup3_3_0
fixCleanup3_4_0
fixCleanup3_5_0
fixDirectoryLimit
fixEmptyDID
fixEnforceNFTokenTrustline
Expand Down Expand Up @@ -163,6 +165,7 @@ DAF3A6EB04FA5DC51E8E4F23E9B7022B693EFA636F23F22664746C77B5786B23 DeepFreeze
58E92F338758479C06084E1B6BA366BAD8F75E5329A7F0EEAFFFDA51E5106B7F DynamicMPT
C1CE18F2A268E6A849C27B3DE485006771B4C01B2FCEC4F18356FE92ECD6BB74 DynamicNFT
565B90CA1AB2B9D42208ED10884188C64F9E19083DECB9634AAF06EB03299509 LendingProtocol
A360E2BFD775A5B0DCE1C36C16DF31B72735A57584FD163655D2F9564F8E7AC8 LendingProtocolV1_1
950AE2EA4654E47F04AA8739C0B214E242097E802FD372D24047A89AB1F5EC38 MPTokensV1
EE3CF852F0506782D05E65D49E5DCC3D16D50898CD1B646BAE274863401CC3CE NFTokenMintOffer
0F48FF561C709540328F31F1C97FD512ACC8B4E42138A161CB0E21ECA292540B PermissionDelegationV1_1
Expand All @@ -182,6 +185,7 @@ C98D98EE9616ACD36E81FDEB8D41D349BF5F1B41DD64A0ABC1FE9AA5EA267E9C XChainBridge
21B8D2F76F68E11E9C077A43BBBC394136E9987E99DDB73966DD68419467E431 fixCleanup3_2_0
3298D47E1F3A8A24FECAA30F699B8FE1DD234E072834BA099AD8180FFCE0FEC4 fixCleanup3_3_0
98433DD001A5737F773D74F8CA2A25A065089C73B2E611C760BAF369E4FECA76 fixCleanup3_4_0
7300E10109D19BF1E87ACE63D3A79CD4ED6B9851C37D93ED94DE9BD41CF56835 fixCleanup3_5_0
41765F664A8D67FF03DDB1C1A893DE6273690BA340A6C2B07C8D29D0DD013D3A fixDirectoryLimit
755C971C29971C9F20C6F080F2ED96F87884E40AD19554A5EBECDCEC8A1F77FE fixEmptyDID
763C37B352BE8C7A04E810F8E462644C45AFEAD624BF3894A08E5C917CF9FF39 fixEnforceNFTokenTrustline
Expand Down
15 changes: 15 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,20 @@
# Changes

## 11.4.0.0 07/09/2026

* **A transition of the connection has one owner** (#179, the follow-up to #178). Every operation that moves the connection - `ChangeServer`, `Connect`, `Disconnect`, `DisconnectAndWaitAsync`, the health check's fast reconnect, the reconnect loop and the path taken when an `OnConnected` handler fails - used to decide 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 checks were right where they were; the pattern was what did not scale.
* the connection now carries a generation. A consumer command and the fast reconnect begin one, taking the session, the socket, the reconnect loop, the ping timer and the message processor out of their fields in a single critical section; 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 after every consumer callback - and the one that moved it owns the rest. `Disconnect()` wins against anything in flight, and an attempt it overtook closes the socket it opened, whether the takeover found that socket installed or the socket came into being afterwards
* the four windows the issue lists are closed by that one mechanism. A `Disconnect()` landing in one of `ChangeServer`'s yields no longer gets overridden by the switch resetting it and connecting - the client was online after the consumer took it down. A status handler that answers `RestoringConnection` with a `ChangeServer` no longer has its replacement session marked retiring by the fast reconnect that ran the handler. The reconnect loop releases its claim under the same lock the close callback asks under, with the socket re-checked there, so a close processed as the loop exits either sees the loop released or is handled by it - nobody reconnecting is no longer an outcome. And a request is written under the lock the retirement takes the socket under, so a retirement finds it either not yet sent, and refused, or already handed to the socket - it no longer reaches a server the client has left
* for consumers: a `ChangeServer` that a later operation overtook reports it instead of returning success from a server the client is not on - `NotConnectedException` when a `Disconnect()` won, `OperationCanceledException` when another `ChangeServer` or a `Connect()` did. `Connect()` keeps its contract: it returns when the client is connected, wherever a concurrent switch took it, and `OperationCanceledException` still means the caller's own token. Options handed to `ChangeServer` are validated before the old connection is torn down rather than after
* the two loose ends from #178 are tied. `NotConnectedException` thrown bare carries a message that says what it is, and the immediate refusal under `RequestFailurePolicy.ImmediateFail` names the policy - since #178 that is the exception a request issued during a switch gets, where it used to get a `TimeoutException` with "Timeout" in it, and a consumer classifying by text had nothing to recognise. `WebSocketClient.SendMessage` no longer answers a socket that is not open with a `Connect()` - `ConnectAsync` on an already used `ClientWebSocket` throws, the catch disposed the socket and raised `OnConnectionError`, and the send went ahead regardless - and `SendMessageAsync` returns a task that faults when the message could not be written, so the request that owns it is rejected at once rather than left to `RequestTimeout`. Messages are serialized whole on the socket; two concurrent messages larger than the send chunk could interleave their frames before
* `OnSessionEnded` is owed whatever wins. A `ChangeServer` or fast reconnect that a `Disconnect()` overtakes before it announced the session it retired still announces it - the retirement silenced the socket's own close callback, and nothing else knows the session. `Disconnect()` announces `UserDisconnected` itself rather than leaving it to the close callback alone: a `Connect()` issued right after it installs a new session before the old socket's close is processed, and the callback then filed the close as a stale session and said nothing. And a takeover that finds no socket takes no session either - the session belongs to whoever took the socket, and a `Connect()` after a `Disconnect()` used to announce a loss of its own for a session the disconnect was about to announce
* a fast reconnect no longer runs a second full series after the loop gave up. With `StopAfterMaxAttempts`, the loop the fast reconnect's failure started ran out of attempts, reported `Disconnected` and released its source; the fast reconnect's own wait then failed with "failed permanently", which its catch read as one more failure to retry. And a `Disconnect()` that took a handshake still in flight no longer installs a completion source nobody completes - the cancelled handshake reports no close - so the next `DisconnectAndWaitAsync` returns at once instead of waiting out its timeout
* six older defects on the same paths, found by the cold review of this change and fixed with it because the change rewrites the code they live on. `OnceOpen` reported `Connected` and started a ping timer nothing would stop after a `Disconnect()` from inside the `OnConnected` handler. `Connect()` after a `Disconnect()` ran with the intentional-disconnect flag still set, so a server that was down read as "closed permanently" and nothing reconnected - `ChangeServer` was the only path that cleared it, and the flag now follows the generation. A handshake cancelled by a takeover reported nothing, so its attempt timer went on firing `OnConnectionFailed` for the dead socket at every `ConnectionAttemptTimeout`. And `Connect()` over a socket that was closing announced no session end and swept no requests, both of which the close callback would have done had `Connect()` not retired the session underneath it
* a failure of an established connection is reported once. 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 handshake-failure callback as well as the close callback. The first announced "Initial connection failed" for a connection that had been up and in use, with an `OnDisconnect` that carried no code, and the second reported the real close. Now the close callback is the only reporter: it classifies the failure, announces the session end once and starts the reconnect. In the browser a `WebSocketException` on an open socket is classified as 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
* a handshake this side cancelled is not reported a second time. The connect-attempt timer and a takeover cancel the socket after reporting, and in the browser the cancelled `ConnectAsync` throws `WebSocketException` ("ConnectFailure") rather than `OperationCanceledException`, which reached the connection-error callback as a second failure of the same attempt. Both found by driving the Blazor test client through a dropped connection and a connect timeout; neither is reachable from the .NET unit suite, where a cancelled handshake throws `OperationCanceledException` and a dropped connection arrives as a network error
* the documentation of `UseCheckHealth` and `InactivityTimeout` promised more than the code does: it said the health check on its own reconnects after sixty seconds without inbound data, while the inactivity check has always run only with `UseCustomPing` enabled - and deliberately so, since an idle connection with no subscriptions receives nothing by design, and silence without keepalive pings would declare a healthy socket dead every minute. The behaviour stays; the docs now say what it is. Found by driving the Blazor test client through a connection that stayed open and went silent
* pinned by tests that issue the second operation from a callback the first one runs, which lands it inside the first one's yields every time: a `Disconnect()` and a second `ChangeServer` from the session-ended handler of a `ChangeServer`, a `ChangeServer` from the `RestoringConnection` notification of the fast reconnect, a `Disconnect()` from the `OnConnected` handler, a `Connect()` after a `Disconnect()` against a server that comes up later, and a server that closes each connection the moment its handshake completes, so the reconnect loop's success and the close it has to survive arrive together

## 11.3.2.0 06/09/2026

* **A request issued while the client is switching servers no longer hangs until `RequestTimeout`** (#177). Every path that retires a connection - `ChangeServer`, the ping-triggered fast reconnect, `Disconnect`, `DisconnectAndWaitAsync`, and the path taken when an `OnConnected` handler fails - rejected the pending requests first and cleared the socket reference afterwards. The rejection 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. Nothing completed it: the sweep had run, and a failed send is report-only. Forty seconds later it timed out, with the connection healthy for thirty-nine of them.
Expand Down
72 changes: 72 additions & 0 deletions Tests/Xrpl.Tests/Client/CloseAfterHandshakeServer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
using System.Net.Sockets;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;

namespace Xrpl.Tests
{
/// <summary>
/// WebSocket server that closes its first <c>closeFirst</c> connections right after the
/// handshake - a close frame, then the TCP connection - and serves every connection after
/// that, answering each request with the same <c>server_info</c> body.
/// </summary>
/// <remarks>
/// A socket that opens and closes at once is the shape of the reconnect loop's narrowest
/// window: the loop sees its attempt succeed, and the close arrives while it is deciding
/// whether it is done. Whether the client comes back from that depends on the loop and the
/// close callback agreeing on who reconnects, which is what this server is for. The shared
/// mock cannot do it - it serves every connection it accepts.
/// </remarks>
internal sealed class CloseAfterHandshakeServer : WebSocketTestServerBase
{
private const string ServerInfoEnvelope =
"{\"id\":__ID__,\"status\":\"success\",\"type\":\"response\",\"result\":{\"info\":" +
"{\"build_version\":\"test-mock\",\"complete_ledgers\":\"1-1\",\"server_state\":\"full\"}}}";

private readonly int _closeFirst;

private int _connections;

public CloseAfterHandshakeServer(int closeFirst)
{
_closeFirst = closeFirst;
StartAccepting();
}

/// <summary>How many connections completed the handshake so far, closed ones included.</summary>
public int Connections => Volatile.Read(ref _connections);

protected override bool ServesManyClients => true;

protected override async Task ServeAsync(NetworkStream stream)
{
int connection = Interlocked.Increment(ref _connections);
if (connection <= _closeFirst)
{
// A close frame with no status code: FIN + opcode 0x8, empty payload. Returning
// lets the base dispose the connection behind it.
await stream.WriteAsync(new byte[] { 0x88, 0x00 }, Token).ConfigureAwait(false);
await stream.FlushAsync(Token).ConfigureAwait(false);
return;
}

while (!Token.IsCancellationRequested)
{
string request = await ReadTextFrameAsync(stream).ConfigureAwait(false);
if (request == null)
{
return;
}

using JsonDocument document = JsonDocument.Parse(request);
string id = document.RootElement.TryGetProperty("id", out JsonElement requestId)
? requestId.GetRawText()
: "null";

byte[] response = Encoding.UTF8.GetBytes(ServerInfoEnvelope.Replace("__ID__", id));
await WriteFragmentedMessageAsync(stream, response, fragments: 1).ConfigureAwait(false);
}
}
}
}
76 changes: 76 additions & 0 deletions Tests/Xrpl.Tests/Client/MalformedFrameServer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
using System.Net.Sockets;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;

namespace Xrpl.Tests
{
/// <summary>
/// WebSocket server that, on its first <c>poisonFirst</c> connections, answers the first
/// request with a frame the protocol forbids - a reserved opcode - and serves every
/// connection after that normally.
/// </summary>
/// <remarks>
/// A forbidden frame makes the client's <c>ReceiveAsync</c> throw a
/// <see cref="System.Net.WebSockets.WebSocketException"/> that is not a network error, on a
/// connection that was established and in use. That is the failure shape the receive loop used
/// to report twice - once as a handshake-style connection error, once as a close - and this
/// server is how a test gets one on demand. A dropped TCP connection cannot stand in for it:
/// that arrives as a network error and takes the other branch.
/// </remarks>
internal sealed class MalformedFrameServer : WebSocketTestServerBase
{
private const string ServerInfoEnvelope =
"{\"id\":__ID__,\"status\":\"success\",\"type\":\"response\",\"result\":{\"info\":" +
"{\"build_version\":\"test-mock\",\"complete_ledgers\":\"1-1\",\"server_state\":\"full\"}}}";

private readonly int _poisonFirst;

private int _connections;

public MalformedFrameServer(int poisonFirst)
{
_poisonFirst = poisonFirst;
StartAccepting();
}

/// <summary>How many connections completed the handshake so far, poisoned ones included.</summary>
public int Connections => Volatile.Read(ref _connections);

protected override bool ServesManyClients => true;

protected override async Task ServeAsync(NetworkStream stream)
{
int connection = Interlocked.Increment(ref _connections);
bool poison = connection <= _poisonFirst;

while (!Token.IsCancellationRequested)
{
string request = await ReadTextFrameAsync(stream).ConfigureAwait(false);
if (request == null)
{
return;
}

if (poison)
{
// FIN + reserved opcode 0xB, no payload. The client's receive fails on the
// header alone; the connection is then dropped behind it.
await stream.WriteAsync(new byte[] { 0x8B, 0x00 }, Token).ConfigureAwait(false);
await stream.FlushAsync(Token).ConfigureAwait(false);
await Task.Delay(300, Token).ConfigureAwait(false);
return;
}

using JsonDocument document = JsonDocument.Parse(request);
string id = document.RootElement.TryGetProperty("id", out JsonElement requestId)
? requestId.GetRawText()
: "null";

byte[] response = Encoding.UTF8.GetBytes(ServerInfoEnvelope.Replace("__ID__", id));
await WriteFragmentedMessageAsync(stream, response, fragments: 1).ConfigureAwait(false);
}
}
}
}
Loading
Loading