From 40be437c17ab99f0c478b53cac0bdf1628303938 Mon Sep 17 00:00:00 2001 From: Aleksandr Platonenkov Date: Wed, 12 Aug 2026 12:57:00 -0300 Subject: [PATCH 1/2] =?UTF-8?q?fix(tests):=20=D0=BC=D0=BE=D0=BA-=D1=81?= =?UTF-8?q?=D0=B5=D1=80=D0=B2=D0=B5=D1=80=20=D0=BF=D0=B5=D1=80=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D0=B0=D0=B2=D0=B0=D0=BB=20=D0=BF=D1=80=D0=B8=D0=BD=D0=B8?= =?UTF-8?q?=D0=BC=D0=B0=D1=82=D1=8C=20=D1=81=D0=BE=D0=B5=D0=B4=D0=B8=D0=BD?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=20?= =?UTF-8?q?=D0=BE=D0=B4=D0=BD=D0=BE=D0=B3=D0=BE=20=D0=BE=D0=B1=D1=80=D1=8B?= =?UTF-8?q?=D0=B2=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Причина флейка TestConcurrentChangeServerKeepsClientRecoverable: unit упал на push-прогоне dev, тогда как тот же коммит в PR-прогоне прошёл. Server.connectionCallback вызывал BeginAccept последней строкой try-блока, поэтому любое исключение выше по телу навсегда обрывало приём соединений. Пир, рвущий соединение во время handshake — ровно то, что порождают конкурентные ChangeServer/Disconnect — глушил мок именно так. Со стороны всё выглядело исправно: слушающий сокет оставался связан, порт числился занятым, TCP-соединения устанавливались ядром, и клиент видел сервер, который принял подключение и молчит. Отсюда 2m06s у упавшего прогона против 10s у обычного и сообщение, обвиняющее клиент в том, что он «не дошёл до живого сервера». - BeginAccept перезапускается безусловно после колбэка, чем бы тот ни кончился; ObjectDisposedException обрабатывается отдельно и без перезапуска — он означает, что Stop() закрыл listener - сокет, чей handshake упал, закрывается, а не течёт до конца процесса - TestUMockRippledAcceptLoop фиксирует поведение: обрыв на handshake (RST через LingerOption(true, 0)) поодиночке и десять подряд, затем обычный клиент, который обязан подключиться. На старом колбэке оба падают, после фикса проходят за 0.6s - TestUtils.MockCompletesHandshake: проба реальным WS-upgrade. Упавший reconnect-тест теперь сообщает, отвечает ли мок, — глухой сервер больше не будет прочитан как баг клиента. Проба покрыта на живом моке, свободном порту и слушающем сокете, который никогда не принимает --- CHANGES.md | 6 + .../Client/TestUMockRippledAcceptLoop.cs | 194 ++++++++++++++++++ .../Client/TestUReconnectSessionRaces.cs | 17 +- Tests/Xrpl.Tests/MockRippled/Server.cs | 48 ++++- Tests/Xrpl.Tests/TestUtils.cs | 47 ++++- 5 files changed, 299 insertions(+), 13 deletions(-) create mode 100644 Tests/Xrpl.Tests/Client/TestUMockRippledAcceptLoop.cs diff --git a/CHANGES.md b/CHANGES.md index 57b2c455..d008a853 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -7,6 +7,12 @@ * **DynamicMPT: enabling a capability moved from a field to transaction flags.** The old `MPTokenIssuanceSet.MutableFlags = tmfMPTSet*` no longer exists; a capability is now enabled through `Flags = tfMPTSetCanLock | tfMPTSetRequireAuth | tfMPTSetCanEscrow | tfMPTSetCanTrade | tfMPTSetCanTransfer | tfMPTSetCanClawback | tfMPTSetCanHoldConfidentialBalance` (0x04–0x100), added to `MPTokenIssuanceSetFlags` next to the existing `tfMPTLock`/`tfMPTUnlock`. `ImmutableFlags` on the same transaction now does the opposite job — it freezes capabilities and fields, OR-ed into the ledger object, never cleared * **Sponsor: `SponsorshipSet` takes deltas, not absolute values.** `FeeAmount` and `RemainingOwnerCount` are fields of the `Sponsorship` **ledger object** only; the transaction carries `FeeAmountDelta` (`Amount`, nth 34) and `RemainingOwnerCountDelta` (**`Int32`**, nth 2) — signed changes applied to what the object already holds. Sending the old fields is not a semantic mismatch but a hard parse error: `STObject::applyTemplate` rejects any field outside the format with `invalidTransaction — Field 'FeeAmount' found in disallowed location`, which is what 13 of the 17 failures were. `SponsorshipSet.FeeAmount`/`RemainingOwnerCount` become `FeeAmountDelta` (`Currency`) / `RemainingOwnerCountDelta` (`int?`, signed — a negative delta reclaims budget); `LOSponsorship` is unchanged, it already matched the object. Client-side validation follows `SponsorshipSet::preflight`: a delta must be non-zero, `FeeAmountDelta` must be XRP, and `tfDeleteObject` may not carry any of the three modification fields * `definitions.json` + the three generated `Field.*` partials carry the renamed and the two new fields; `Common.TryGetInt32` was added for the signed delta, the codec already had `Int32Type` +* **The mock server went deaf after one aborted connection** — the cause of the `TestConcurrentChangeServerKeepsClientRecoverable` flake that failed a `unit` run on `dev` while the same commit passed in its PR run. `Server.connectionCallback` re-armed `BeginAccept` as the **last statement of its try block**, so any throw earlier in the callback ended the accept loop for good. A peer that resets the connection during the handshake — exactly what concurrent `ChangeServer`/`Disconnect` calls produce — took the mock down that way. Nothing looked broken: the listen socket stayed bound, the port still read as taken and TCP connects still completed at the kernel level, so the client saw a server that accepted its connection and then never spoke. Every later client hung until its own connect timeout, which is why the failing run took 2m06s where a passing one takes 10s, and why the assertion blamed the client for not reaching "a server that is up" while the server was the one that had stopped serving: + * `BeginAccept` is now re-armed unconditionally after the callback, whatever happened inside it. `ObjectDisposedException` is separated out and *not* re-armed — that one means `Stop()` closed the listener, so there is genuinely nothing left to accept on + * a socket whose handshake threw is closed instead of leaking for the process lifetime + * `TestUMockRippledAcceptLoop` pins it: a connection reset mid-handshake (`LingerOption(true, 0)`, the RST the CI log shows), single and ten in a row, then a normal client that must still connect. Both fail on the old callback — 8s and 2s, with the same `An error has occured while trying to accept a connecting client` line as CI — and pass in 0.6s after the fix + * the flake itself never reproduced locally in 12 consecutive runs, so the evidence is that deterministic test rather than a statistic +* **A failing reconnect test now says which side broke** — `TestUtils.MockCompletesHandshake` probes a mock with a real WebSocket upgrade and reports whether it answers `101`. `TestConcurrentChangeServerKeepsClientRecoverable` runs it before failing and puts the answer in the message, so a deaf mock cannot be read as a client bug again. A plain TCP connect would not do: against a bound-but-not-accepting socket it succeeds and proves nothing, which is the trap this whole failure sat in. The probe is itself covered against a serving mock, a free port and a listener that never accepts. * **The nightly pin now has a watcher** — `nightly-pin-watch.yml`, weekly. The pin is what `definitions-watch` sees as "develop", so leaving it in place quietly narrows that check to whatever rippled looked like when the pin was last touched; the two 3.3.0 renames above sat undetected behind a pin from 11 July. Dropping the pin is not an option — the nightly build timestamp shrank from 14 to 12 digits mid-2026, so Debian version ordering ranks old builds above new ones and an unpinned install gets a stale binary: * `.ci-config/bump-nightly-pin.sh` does the move: newest `xrpld` build from the nightly apt channel, `ARG XRPLD_VERSION` rewritten, `rippled.batchv11.cfg` regenerated from the develop commit **encoded in that version string** — config and binary cannot drift apart, which is the failure mode the old manual two-step invited. `--check` reports the pin, the newest build and the pin's age without touching anything. Both timestamp formats are compared by their common `YYYYMMDDHHMM` prefix * the workflow bumps only once the pin is older than `MAX_PIN_AGE_DAYS` (21) — nightly publishes several builds a day, and a weekly PR would be noise rather than signal; `workflow_dispatch` takes a `force` input for the exceptions. It then builds and starts the stand on the new pin and requires the AMM sentinel amendment to come up enabled at genesis, which is what proves the regenerated config was accepted rather than silently ignored, and attaches the definitions diff against the new build to the PR body — a `node-only` field there is the SDK being behind develop, reported instead of hidden diff --git a/Tests/Xrpl.Tests/Client/TestUMockRippledAcceptLoop.cs b/Tests/Xrpl.Tests/Client/TestUMockRippledAcceptLoop.cs new file mode 100644 index 00000000..8cabf8f2 --- /dev/null +++ b/Tests/Xrpl.Tests/Client/TestUMockRippledAcceptLoop.cs @@ -0,0 +1,194 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Sockets; +using System.Threading.Tasks; + +using Xrpl.Client; + +namespace Xrpl.Tests +{ + /// + /// Guards the mock server's accept loop against a single bad connection taking it down. + /// + /// + /// The mock re-arms BeginAccept as the last statement of its accept callback, so any + /// throw earlier in that callback — a peer that resets the connection before or during the + /// WebSocket handshake — used to end the loop for good. The listen socket stayed bound, so the + /// port still looked taken and connects still completed at the TCP level, but nothing was ever + /// accepted again: every later client hung until its own connect timeout. + /// + /// That is not a hypothetical. It is what made flaky + /// on CI: concurrent ChangeServer/Disconnect calls abort half-open connections, one of those + /// aborts silenced the mock, and the assertion at the end of the test then blamed the client + /// for not reaching "a server that is up" — while the server was in fact deaf. + /// + [TestClass] + public class TestUMockRippledAcceptLoop + { + private CreateMockRippled _mock; + private int _port; + + private static Dictionary ServerInfoResponse() => new Dictionary + { + { "type", "response" }, + { "status", "success" }, + { "result", new Dictionary + { + { "info", new Dictionary + { + { "build_version", "test-mock" }, + { "complete_ledgers", "1-1" }, + { "server_state", "full" }, + } + }, + } + }, + }; + + [TestInitialize] + public void MyTestInitialize() + { + _port = TestUtils.GetFreePort(); + _mock = new CreateMockRippled(_port) { suppressOutput = true }; + _mock.AddResponse("server_info", ServerInfoResponse()); + _mock.Start(); + } + + [TestCleanup] + public void MyTestCleanup() => _mock?.Stop(); + + /// + /// Resets a connection while the mock is in its accept callback, then requires the mock to + /// still serve the next client. + /// + [TestMethod] + public async Task TestAbortedHandshakeLeavesTheMockAccepting() + { + // A zero linger time makes Close() send RST rather than FIN, so the mock's blocking + // Receive of the handshake fails with "connection reset by peer" — the exact throw + // seen in the CI log of the flaky run. + using (Socket rude = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp)) + { + rude.LingerState = new LingerOption(true, 0); + await rude.ConnectAsync(IPAddress.Loopback, _port); + rude.Close(); + } + + // Give the mock a moment to run its callback and (before the fix) fall out of it. + await Task.Delay(TimeSpan.FromMilliseconds(200)); + + XrplClient client = new XrplClient($"ws://127.0.0.1:{_port}", new XrplClient.ClientOptions + { + RequestPolicy = RequestFailurePolicy.ImmediateFail, + ConnectionAcquisitionTimeout = TimeSpan.FromSeconds(5), + ConnectionAttemptTimeout = TimeSpan.FromSeconds(3), + UseCustomPing = false, + }); + + try + { + await client.Connect(); + + Assert.IsTrue( + client.connection.IsConnected(), + "The mock stopped accepting after one aborted connection — its accept loop was not re-armed."); + } + finally + { + try + { + await client.Disconnect(); + } + catch (Exception) + { + // Cleanup must not mask the assertion above. + } + } + } + + /// + /// The handshake probe the reconnect tests use to attribute a failure must actually tell + /// a serving mock from a deaf one — a probe that always says "alive" would be worse than + /// none, since it would confirm the wrong suspect. + /// + [TestMethod] + public void TestHandshakeProbeTellsAServingMockFromADeafOne() + { + TimeSpan timeout = TimeSpan.FromSeconds(2); + + Assert.IsTrue( + TestUtils.MockCompletesHandshake(_port, timeout), + "A running mock must answer the probe with a 101 upgrade."); + + Assert.IsFalse( + TestUtils.MockCompletesHandshake(TestUtils.GetFreePort(), timeout), + "Nothing listens on that port, so the probe must report it as not serving."); + + // The case the probe exists for: a socket that is bound and listening but never + // accepts. The TCP connect still succeeds — which is why a plain connect check proves + // nothing — and only the missing handshake reveals that the server is deaf. + TcpListener deaf = new TcpListener(IPAddress.Loopback, 0); + deaf.Start(); + try + { + int deafPort = ((IPEndPoint)deaf.LocalEndpoint).Port; + Assert.IsFalse( + TestUtils.MockCompletesHandshake(deafPort, timeout), + "A listening socket that never accepts must be reported as not serving."); + } + finally + { + deaf.Stop(); + } + } + + /// + /// The same guarantee under repetition: a run of aborted connections must not degrade the + /// mock, since the reconnect tests abort several in a row. + /// + [TestMethod] + public async Task TestRepeatedAbortedHandshakesLeaveTheMockAccepting() + { + for (int i = 0; i < 10; i++) + { + using Socket rude = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + rude.LingerState = new LingerOption(true, 0); + await rude.ConnectAsync(IPAddress.Loopback, _port); + rude.Close(); + } + + await Task.Delay(TimeSpan.FromMilliseconds(200)); + + XrplClient client = new XrplClient($"ws://127.0.0.1:{_port}", new XrplClient.ClientOptions + { + RequestPolicy = RequestFailurePolicy.ImmediateFail, + ConnectionAcquisitionTimeout = TimeSpan.FromSeconds(5), + ConnectionAttemptTimeout = TimeSpan.FromSeconds(3), + UseCustomPing = false, + }); + + try + { + await client.Connect(); + + Assert.IsTrue( + client.connection.IsConnected(), + "The mock stopped accepting after a run of aborted connections."); + } + finally + { + try + { + await client.Disconnect(); + } + catch (Exception) + { + // Cleanup must not mask the assertion above. + } + } + } + } +} diff --git a/Tests/Xrpl.Tests/Client/TestUReconnectSessionRaces.cs b/Tests/Xrpl.Tests/Client/TestUReconnectSessionRaces.cs index 1ce93c07..56a8e4ed 100644 --- a/Tests/Xrpl.Tests/Client/TestUReconnectSessionRaces.cs +++ b/Tests/Xrpl.Tests/Client/TestUReconnectSessionRaces.cs @@ -1,4 +1,4 @@ -using Microsoft.VisualStudio.TestTools.UnitTesting; +using Microsoft.VisualStudio.TestTools.UnitTesting; using System; using System.Collections.Generic; @@ -161,10 +161,17 @@ public async Task TestConcurrentChangeServerKeepsClientRecoverable() await Task.Delay(TimeSpan.FromMilliseconds(100)); } - Assert.IsTrue( - _client.connection.IsConnected(), - "After concurrent ChangeServer calls the client could not reach a server that is up — " + - "the reconnect session was left disposed or orphaned."); + if (!_client.connection.IsConnected()) + { + // Say which side failed. A mock whose accept loop died still holds the port, so + // "the server is up" is an assumption worth checking before blaming the client — + // this test spent a CI run being read as a client bug for exactly that reason. + bool mockServing = TestUtils.MockCompletesHandshake(_port, TimeSpan.FromSeconds(2)); + Assert.Fail( + "After concurrent ChangeServer calls the client could not reach a server that is up — " + + "the reconnect session was left disposed or orphaned. " + + $"(mock still completes a handshake: {mockServing})"); + } } /// diff --git a/Tests/Xrpl.Tests/MockRippled/Server.cs b/Tests/Xrpl.Tests/MockRippled/Server.cs index 4f8fdef5..9af307f5 100644 --- a/Tests/Xrpl.Tests/MockRippled/Server.cs +++ b/Tests/Xrpl.Tests/MockRippled/Server.cs @@ -254,10 +254,14 @@ public void Stop() /// The async operation state private void connectionCallback(IAsyncResult AsyncResult) { + // Held until ownership passes to the MockClient, so a handshake that throws + // half-way closes the socket instead of leaking it for the process lifetime. + Socket clientSocket = null; + try { // Gets the client thats trying to connect to the server - Socket clientSocket = GetSocket().EndAccept(AsyncResult); + clientSocket = GetSocket().EndAccept(AsyncResult); // Read the handshake updgrade request byte[] handshakeBuffer = new byte[1024]; @@ -267,25 +271,55 @@ private void connectionCallback(IAsyncResult AsyncResult) string requestKey = Helpers.GetHandshakeRequestKey(Encoding.Default.GetString(handshakeBuffer)); string hanshakeResponse = Helpers.GetHandshakeResponse(Helpers.HashKey(requestKey)); - // Send the handshake updgrade response to the connecting client + // Send the handshake updgrade response to the connecting client clientSocket.Send(Encoding.Default.GetBytes(hanshakeResponse)); - // Create a new client object and add + // Create a new client object and add // it to the list of connected clients MockClient client = new MockClient(this, clientSocket); + clientSocket = null; _clients.Add(client); - // Call the event when a client has connected to the listen server + // Call the event when a client has connected to the listen server if (OnClientConnected == null) throw new Exception("Server error: event OnClientConnected is not bound!"); OnClientConnected(this, new OnClientConnectedHandler(client)); + } + catch (ObjectDisposedException) + { + // Stop() closed the listen socket: nothing left to accept on, and re-arming + // below would only throw again. + return; + } + catch (Exception Exception) + { + Debug.WriteLine("An error has occured while trying to accept a connecting client.\n\n{0}", Exception.Message); - // Start to accept incomming connections again - GetSocket().BeginAccept(connectionCallback, null); + try + { + clientSocket?.Close(); + } + catch (Exception) + { + // The peer is already gone; nothing to salvage. + } + } + // Re-arm unconditionally. This call used to be the last statement of the try block, + // so a peer that reset the connection during the handshake ended the accept loop for + // good: the listen socket stayed bound — the port still looked taken and TCP connects + // still completed — while nothing was ever accepted again, and every later client hung + // until its own connect timeout. One bad connection must not deafen the mock. + try + { + GetSocket().BeginAccept(connectionCallback, null); + } + catch (ObjectDisposedException) + { + // Stop() ran while this callback was in flight. } catch (Exception Exception) { - Debug.WriteLine("An error has occured while trying to accept a connecting client.\n\n{0}", Exception.Message); + Debug.WriteLine("An error has occured while re-arming the accept loop.\n\n{0}", Exception.Message); } } diff --git a/Tests/Xrpl.Tests/TestUtils.cs b/Tests/Xrpl.Tests/TestUtils.cs index 746e7d2c..a0fdf567 100644 --- a/Tests/Xrpl.Tests/TestUtils.cs +++ b/Tests/Xrpl.Tests/TestUtils.cs @@ -1,10 +1,11 @@ - + // https://github.com/XRPLF/xrpl.js/blob/main/packages/xrpl/test/testUtils.ts using System; using System.Collections.Concurrent; using System.Net; using System.Net.Sockets; +using System.Text; namespace Xrpl.Tests { @@ -48,6 +49,50 @@ static public int GetFreePort() "GetFreePort: could not obtain an unclaimed loopback port after 50 attempts"); } + /// + /// Whether a mock server on still completes a WebSocket handshake. + /// + /// + /// A dead accept loop leaves the listen socket bound, so a plain TCP connect still + /// succeeds and proves nothing — only an answered handshake shows the mock is serving. + /// Tests use this to say whether a connection failure was the client's doing or the + /// mock's, instead of blaming the client for a server that went deaf. + /// + static public bool MockCompletesHandshake(int port, TimeSpan timeout) + { + try + { + using TcpClient probe = new TcpClient(); + if (!probe.ConnectAsync(IPAddress.Loopback, port).Wait(timeout)) + { + return false; + } + + NetworkStream stream = probe.GetStream(); + stream.WriteTimeout = (int)timeout.TotalMilliseconds; + stream.ReadTimeout = (int)timeout.TotalMilliseconds; + + // The mock reads the key by offset from "Sec-WebSocket-Key: ", so the header must + // carry a full 24-character value like a real client sends. + byte[] request = Encoding.ASCII.GetBytes( + "GET / HTTP/1.1\r\n" + + "Host: 127.0.0.1\r\n" + + "Upgrade: websocket\r\n" + + "Connection: Upgrade\r\n" + + "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n" + + "Sec-WebSocket-Version: 13\r\n\r\n"); + stream.Write(request, 0, request.Length); + + byte[] buffer = new byte[256]; + int read = stream.Read(buffer, 0, buffer.Length); + return read > 0 && Encoding.ASCII.GetString(buffer, 0, read).Contains("101"); + } + catch (Exception) + { + return false; + } + } + /// /// Whether can still be bound on loopback right now. Tests that /// hold a port across an await use this to fail fast with a clear reason instead of From d8fc02e23f4469963c087c1fd60f7964b06b15c7 Mon Sep 17 00:00:00 2001 From: Aleksandr Platonenkov Date: Wed, 12 Aug 2026 13:05:39 -0300 Subject: [PATCH 2/2] =?UTF-8?q?docs(changes):=20=D1=83=D0=B1=D1=80=D0=B0?= =?UTF-8?q?=D1=82=D1=8C=20=D0=B7=D0=B0=D0=BF=D0=B8=D1=81=D1=8C=20=D0=BE=20?= =?UTF-8?q?=D1=84=D0=B8=D0=BA=D1=81=D0=B5=20=D0=BC=D0=BE=D0=BA-=D1=81?= =?UTF-8?q?=D0=B5=D1=80=D0=B2=D0=B5=D1=80=D0=B0=20=D0=B8=D0=B7=20changelog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Правка чисто в тестовой инфраструктуре: код SDK не менялся, потребителю пакета изменение не видно, поэтому в changelog релиза ему места нет. Разбор причины остаётся в описании PR и в комментариях к самому коду. --- CHANGES.md | 6 ------ 1 file changed, 6 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index d008a853..57b2c455 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -7,12 +7,6 @@ * **DynamicMPT: enabling a capability moved from a field to transaction flags.** The old `MPTokenIssuanceSet.MutableFlags = tmfMPTSet*` no longer exists; a capability is now enabled through `Flags = tfMPTSetCanLock | tfMPTSetRequireAuth | tfMPTSetCanEscrow | tfMPTSetCanTrade | tfMPTSetCanTransfer | tfMPTSetCanClawback | tfMPTSetCanHoldConfidentialBalance` (0x04–0x100), added to `MPTokenIssuanceSetFlags` next to the existing `tfMPTLock`/`tfMPTUnlock`. `ImmutableFlags` on the same transaction now does the opposite job — it freezes capabilities and fields, OR-ed into the ledger object, never cleared * **Sponsor: `SponsorshipSet` takes deltas, not absolute values.** `FeeAmount` and `RemainingOwnerCount` are fields of the `Sponsorship` **ledger object** only; the transaction carries `FeeAmountDelta` (`Amount`, nth 34) and `RemainingOwnerCountDelta` (**`Int32`**, nth 2) — signed changes applied to what the object already holds. Sending the old fields is not a semantic mismatch but a hard parse error: `STObject::applyTemplate` rejects any field outside the format with `invalidTransaction — Field 'FeeAmount' found in disallowed location`, which is what 13 of the 17 failures were. `SponsorshipSet.FeeAmount`/`RemainingOwnerCount` become `FeeAmountDelta` (`Currency`) / `RemainingOwnerCountDelta` (`int?`, signed — a negative delta reclaims budget); `LOSponsorship` is unchanged, it already matched the object. Client-side validation follows `SponsorshipSet::preflight`: a delta must be non-zero, `FeeAmountDelta` must be XRP, and `tfDeleteObject` may not carry any of the three modification fields * `definitions.json` + the three generated `Field.*` partials carry the renamed and the two new fields; `Common.TryGetInt32` was added for the signed delta, the codec already had `Int32Type` -* **The mock server went deaf after one aborted connection** — the cause of the `TestConcurrentChangeServerKeepsClientRecoverable` flake that failed a `unit` run on `dev` while the same commit passed in its PR run. `Server.connectionCallback` re-armed `BeginAccept` as the **last statement of its try block**, so any throw earlier in the callback ended the accept loop for good. A peer that resets the connection during the handshake — exactly what concurrent `ChangeServer`/`Disconnect` calls produce — took the mock down that way. Nothing looked broken: the listen socket stayed bound, the port still read as taken and TCP connects still completed at the kernel level, so the client saw a server that accepted its connection and then never spoke. Every later client hung until its own connect timeout, which is why the failing run took 2m06s where a passing one takes 10s, and why the assertion blamed the client for not reaching "a server that is up" while the server was the one that had stopped serving: - * `BeginAccept` is now re-armed unconditionally after the callback, whatever happened inside it. `ObjectDisposedException` is separated out and *not* re-armed — that one means `Stop()` closed the listener, so there is genuinely nothing left to accept on - * a socket whose handshake threw is closed instead of leaking for the process lifetime - * `TestUMockRippledAcceptLoop` pins it: a connection reset mid-handshake (`LingerOption(true, 0)`, the RST the CI log shows), single and ten in a row, then a normal client that must still connect. Both fail on the old callback — 8s and 2s, with the same `An error has occured while trying to accept a connecting client` line as CI — and pass in 0.6s after the fix - * the flake itself never reproduced locally in 12 consecutive runs, so the evidence is that deterministic test rather than a statistic -* **A failing reconnect test now says which side broke** — `TestUtils.MockCompletesHandshake` probes a mock with a real WebSocket upgrade and reports whether it answers `101`. `TestConcurrentChangeServerKeepsClientRecoverable` runs it before failing and puts the answer in the message, so a deaf mock cannot be read as a client bug again. A plain TCP connect would not do: against a bound-but-not-accepting socket it succeeds and proves nothing, which is the trap this whole failure sat in. The probe is itself covered against a serving mock, a free port and a listener that never accepts. * **The nightly pin now has a watcher** — `nightly-pin-watch.yml`, weekly. The pin is what `definitions-watch` sees as "develop", so leaving it in place quietly narrows that check to whatever rippled looked like when the pin was last touched; the two 3.3.0 renames above sat undetected behind a pin from 11 July. Dropping the pin is not an option — the nightly build timestamp shrank from 14 to 12 digits mid-2026, so Debian version ordering ranks old builds above new ones and an unpinned install gets a stale binary: * `.ci-config/bump-nightly-pin.sh` does the move: newest `xrpld` build from the nightly apt channel, `ARG XRPLD_VERSION` rewritten, `rippled.batchv11.cfg` regenerated from the develop commit **encoded in that version string** — config and binary cannot drift apart, which is the failure mode the old manual two-step invited. `--check` reports the pin, the newest build and the pin's age without touching anything. Both timestamp formats are compared by their common `YYYYMMDDHHMM` prefix * the workflow bumps only once the pin is older than `MAX_PIN_AGE_DAYS` (21) — nightly publishes several builds a day, and a weekly PR would be noise rather than signal; `workflow_dispatch` takes a `force` input for the exceptions. It then builds and starts the stand on the new pin and requires the AMM sentinel amendment to come up enabled at genesis, which is what proves the regenerated config was accepted rather than silently ignored, and attaches the definitions diff against the new build to the PR body — a `node-only` field there is the SDK being behind develop, reported instead of hidden