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
15 changes: 15 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,20 @@
# Changes

## 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.
* the fix clears the socket reference before the sweep. Moving it ahead of the first `await`, as the issue proposed, is not enough: `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. A fifth retirement path the issue did not list is covered too
* `ImmediateFail` now refuses such a request at once with `NotConnectedException`; `WaitForConnection` carries it over to the new connection. A request whose send fails because the connection went away between the check and the send is rejected rather than left pending for `RequestTimeout`
* for consumers: retry logic that recognised this failure by the `TimeoutException` it used to produce now sees `NotConnectedException` (or `OperationCanceledException`, for a request that was in flight when the switch began) immediately. Classify by type rather than by message
* the regression test hands the sweep a continuation that runs synchronously and asserts which socket the follow-up saw

* **`ChangeServer`, the fast reconnect and `Disconnect` no longer stall a single-threaded host for two seconds.** Stopping the stream-message processor blocked the calling thread on its reader task, with a two-second cap. On Blazor WebAssembly the reader's continuation needs the very thread that was blocked, so the cap was always reached: every server switch froze the UI for two seconds, and a wake-from-background reconnect is such a switch. The stop is awaited now, after the request sweep, and the reader is gone in milliseconds. Measured on the WebAssembly test client: 2000 ms to 5-390 ms per switch.

* **A ping-triggered reconnect no longer waits three seconds for itself.** `RetireCurrentSessionAndReconnectAsync` runs inside the ping check that calls it, and waited for the ping to finish before retiring the session - its own, whose flag could not clear until it returned. Every reconnect the health check started paid the full `WaitForPingToFinishAsync` timeout before announcing that the session had ended. The wait now recognises the ping it runs in. `RestoringConnection` to `OnSessionEnded` on the stand: 6 s to 20 ms.

* **A reconnect the loop finished is not reconnected a second time.** 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 client that was connected and started a second loop, whose first attempt retired the live socket and opened another. Consumers saw two `OnConnected` per recovery, with a spurious `RestoringConnection` between them, and restored their subscriptions twice. A connected client is now recognised as settled, and the loop no longer retires a socket that is open when its turn comes.
* pinned by a test that 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

## 11.3.1.0 05/09/2026

* **`FundWallet` no longer reports success for a wallet the faucet never paid** (#174). The starting balance was read before the faucet was asked, and any failure to read it left it at zero; the wait then asked whether the balance had risen above that zero, so a wallet that already held funds satisfied the test and the call returned `Funded` with the balance the account had all along. Found three times independently while cold-reviewing the previous release, by three reviewers on two models.
Expand Down
271 changes: 271 additions & 0 deletions Tests/Xrpl.Tests/Client/TestUFastReconnectSettling.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,271 @@
using Microsoft.VisualStudio.TestTools.UnitTesting;

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Threading.Tasks;

using Xrpl.Client;

namespace Xrpl.Tests
{
/// <summary>
/// The fast-reconnect path (<c>RetireCurrentSessionAndReconnectAsync</c>) runs inside the ping
/// check that triggers it. Two things followed from that and went unnoticed because the path
/// only shows its timing on a live node.
/// </summary>
/// <remarks>
/// <para>
/// It waited for the ping to finish - its own ping - and so waited out the whole
/// <c>WaitForPingToFinishAsync</c> timeout (3 s) on every ping-triggered reconnect.
/// </para>
/// <para>
/// And when its own connection attempt failed at the socket, the failure callback started the
/// reconnect loop on the same cancellation source; the loop connected first, <c>OnceOpen</c>
/// retired the source, the fast reconnect's wait came back cancelled, and its catch read that
/// as a failure: it started a second loop, whose first attempt retired the live socket and
/// opened another. One reconnect became two, with <c>RestoringConnection</c> reported on a
/// client that was connected.
/// </para>
/// </remarks>
[TestClass]
public class TestUFastReconnectSettling
{
private XrplClient _client;

private static Dictionary<string, object> ServerInfoResponse() => new Dictionary<string, object>
{
{ "type", "response" },
{ "status", "success" },
{ "result", new Dictionary<string, object>
{
{ "info", new Dictionary<string, object>
{
{ "build_version", "test-mock" },
{ "complete_ledgers", "1-1" },
{ "server_state", "full" },
}
},
}
},
};

private static Dictionary<string, object> EmptyResponse() => new Dictionary<string, object>
{
{ "type", "response" },
{ "status", "success" },
{ "result", new Dictionary<string, object>() },
};

/// <summary>
/// The two knobs that make the inactivity path reachable in under a second, plus a reconnect
/// backoff short enough for a whole failed-then-succeeded sequence to fit in a test.
/// </summary>
private static XrplClient.ClientOptions FastReconnectOptions() => new XrplClient.ClientOptions
{
RequestPolicy = RequestFailurePolicy.ImmediateFail,
ReconnectBaseDelay = TimeSpan.FromMilliseconds(100),
ReconnectMaxDelay = TimeSpan.FromMilliseconds(500),
ConnectionAcquisitionTimeout = TimeSpan.FromSeconds(5),
ConnectionAttemptTimeout = TimeSpan.FromSeconds(3),
UseCustomPing = true,
HealthCheckInterval = TimeSpan.FromMilliseconds(200),
InactivityTimeout = TimeSpan.FromMilliseconds(500),
};

[TestCleanup]
public async Task MyTestCleanup()
{
if (_client != null)
{
try
{
await _client.Disconnect();
}
catch
{
// Cleanup is not an assertion.
}

_client = null;
}
}

private static async Task WaitUntilAsync(Func<bool> condition, TimeSpan timeout, string what)
{
Stopwatch clock = Stopwatch.StartNew();
while (!condition())
{
Assert.IsTrue(clock.Elapsed < timeout, $"Timed out after {timeout.TotalSeconds:F0}s waiting for: {what}");
await Task.Delay(50);
}
}

/// <summary>
/// From the moment the health check hands a silent connection to the fast-reconnect path
/// to the moment the old session is announced as ended, nothing has to wait for anything:
/// the requests are swept, the socket is retired in the background. Three seconds in that
/// gap is <c>WaitForPingToFinishAsync</c> timing out on the ping this path runs inside.
/// </summary>
[TestMethod]
public async Task TestFastReconnectDoesNotWaitOutItsOwnPingTimeout()
{
using SilentOnPingServer server = new SilentOnPingServer();
_client = new XrplClient(server.Url, FastReconnectOptions());

Stopwatch clock = Stopwatch.StartNew();
long restoringAt = -1;
long sessionEndedAt = -1;
object gate = new object();

_client.OnConnectionStatus += info =>
{
if (info.ConnectionState != XrpConnectionState.RestoringConnection)
{
return;
}

lock (gate)
{
if (restoringAt < 0)
{
restoringAt = clock.ElapsedMilliseconds;
}
}
};

_client.OnSessionEnded += (reason, description) =>
{
lock (gate)
{
if (sessionEndedAt < 0)
{
sessionEndedAt = clock.ElapsedMilliseconds;
}
}

return Task.CompletedTask;
};

await _client.Connect();
Assert.IsTrue(_client.connection.IsConnected(), "Precondition: the client must be connected.");

await WaitUntilAsync(
() => { lock (gate) { return sessionEndedAt >= 0; } },
TimeSpan.FromSeconds(15),
"the silent connection to be retired by the fast-reconnect path");

long gap;
lock (gate)
{
Assert.IsTrue(restoringAt >= 0, "RestoringConnection must have been reported before the session ended.");
gap = sessionEndedAt - restoringAt;
}

Assert.IsTrue(
gap < 2000,
$"Retiring the session took {gap}ms after RestoringConnection was reported. The fast-reconnect " +
"path waited out WaitForPingToFinishAsync's timeout for the ping check it is itself running in.");
}

/// <summary>
/// A fast reconnect whose own attempt fails at the socket hands the sequence to the reconnect
/// loop on the same source. When that loop connects, the fast reconnect is done - it must not
/// read the cancellation of its wait as a failure and start reconnecting a connected client.
/// </summary>
[TestMethod]
public async Task TestReconnectLoopSettlingAFastReconnectDoesNotReconnectAgain()
{
SilentOnPingServer silentServer = new SilentOnPingServer();
CreateMockRippled replacement = null;
int port = silentServer.Port;

try
{
_client = new XrplClient(silentServer.Url, FastReconnectOptions());

TaskCompletionSource<bool> loopIsDelaying = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
object gate = new object();
bool retired = false;
int connectedAfterRetire = 0;
WebSocketClient socketAfterRetire = null;
List<string> restoringAfterConnected = new List<string>();

_client.OnConnectionStatus += info =>
{
lock (gate)
{
switch (info.ConnectionState)
{
case XrpConnectionState.RestoringConnection when !retired:
// The health check just handed the silent connection to the
// fast-reconnect path, and this handler runs before its attempt
// starts. Take the server down now, so that attempt fails at the
// socket and the sequence falls to the reconnect loop.
retired = true;
silentServer.Dispose();
break;

case XrpConnectionState.RestoringConnection when connectedAfterRetire > 0:
restoringAfterConnected.Add(info.Message);
break;

case XrpConnectionState.RestoringConnection
when info.Message.StartsWith("Reconnecting in", StringComparison.Ordinal):
// The loop's first, immediate attempt failed too; it is now waiting
// out a backoff, which is the window to bring a server up in.
loopIsDelaying.TrySetResult(true);
break;

case XrpConnectionState.Connected when retired:
connectedAfterRetire++;
socketAfterRetire ??= _client.connection.ws;
break;
}
}
};

await _client.Connect();
Assert.IsTrue(_client.connection.IsConnected(), "Precondition: the client must be connected.");

Task finished = await Task.WhenAny(loopIsDelaying.Task, Task.Delay(TimeSpan.FromSeconds(15)));
Assert.AreSame(loopIsDelaying.Task, finished, "The reconnect loop never reached a delayed attempt.");

replacement = new CreateMockRippled(port) { suppressOutput = true };
replacement.AddResponse("server_info", ServerInfoResponse());
replacement.AddResponse("ping", EmptyResponse());
replacement.Start();

await WaitUntilAsync(
() => { lock (gate) { return connectedAfterRetire > 0; } },
TimeSpan.FromSeconds(15),
"the reconnect loop to connect to the replacement server");

// Long enough for a second loop's first attempt (CalcBackoff(1) = 2 x base delay)
// to have retired the socket and reconnected, had one been started.
await Task.Delay(TimeSpan.FromSeconds(3));

lock (gate)
{
Assert.AreEqual(
0,
restoringAfterConnected.Count,
"RestoringConnection was reported on a connected client: " + string.Join(" | ", restoringAfterConnected));
Assert.AreEqual(
1,
connectedAfterRetire,
"The client connected more than once: the fast reconnect started a second loop after the first one had already connected.");
Assert.AreSame(
socketAfterRetire,
_client.connection.ws,
"The socket the loop opened was retired and replaced by a reconnect nobody needed.");
}
}
finally
{
replacement?.Stop();
silentServer.Dispose();
}
}
}
}
Loading
Loading