Release 11.5.0.0 - #186
Merged
Merged
Release 11.5.0.0#186
Conversation
) * feat(connection): read what happened to a connection from the type 11.4.0 made the behaviour of a transition correct - one owner, and an operation that was overtaken says so - but gave the caller no way to read that answer. NotConnectedException carried five different events and OperationCanceledException two, so telling "the consumer disconnected the client" from "this endpoint is not answering" meant classifying by message text, which the release notes of 11.3.2.0 told consumers not to do while the library offered no type capable of it. Five new exception types, all deriving from the ones thrown today, so no catch clause changes meaning and no task changes status: ClientDisconnectedException, ReconnectExhaustedException (attempts spent and the budget configured), RequestRefusedException, ConnectHandlerFailedException (how often the handler failed, and its own exception) and NotConnectingException. ConnectionSupersededException derives from OperationCanceledException and names the transition that took over and where it left the client. A broken OnConnected handler is no longer reported as a disconnect the consumer performed: the give-up path ends by disconnecting itself, and the cause now travels with that disconnect - through the exceptions and through the status stream alike. A request swept while the connection moved says which transition swept it and where the client went. Sweeps caused by the connection failing on its own keep the plain cancellation deliberately, so an ordinary network drop stays out of consumers' critical logs; the reason reaches them through StopReason instead. ConnectionStatusInfo.StopReason says why the client stopped, on the notification that says it stopped. The wait no longer polls every 100 ms: it sleeps on a signal completed by the one funnel every connection state passes through, which on a single-threaded host such as Blazor WebAssembly is the difference between seconds and milliseconds. WaitForConnectionOutcomeAsync answers "did it come back?" with a value rather than an exception, on Connection, XrplClient and IXrplClient. StopAfterMaxAttempts now actually stops the client. Present since before this change: a client that spent its budget announced Disconnected and then ran a second full series from attempt #1. The loop's exit clears the two fields that say a sequence is running for this generation, so the close of the attempt that failed last was indistinguishable from the close that began the whole thing. The generation that gave up is now recorded and refused a new loop - keyed by generation, so nothing has to reset it and a consumer command lifts it. ConnectionManager is fixed rather than left alone: it is public and notified from nine places on the connection's threads, and a waiter resumed inside ResolveAllAwaiting while a registration landing during a notification could be dropped and never resume. ChangeServer reads the network id the way Connect does, carrying the read across a teardown. Pinned by 37 tests. Five exist because they failed first: Task.WhenAll does not lose the subtype unless a faulted task is alongside it; a readiness signal armed only on takeover leaves a waiter spinning; a retry filter that cannot tell the client's own teardown from a peer operation reports a different failure depending on timing; a signal captured after the predicate loses wake-ups; and the status stream ended by contradicting the exception the same failure produced. Verified on the unit suite (1321, three consecutive runs), the integration suite against a real rippled 3.3.0 (346), and the Blazor WebAssembly stand driven through connect, server switch, disconnect, subscription, drop, recovery, terminal give-up and recovery from it. * fix(connection): a failure the client will retry names no stop reason The first handshake failure against a server that is not up announced Disconnected with InitialConnectionFailed and then started the reconnect loop. ConnectionStopReason is defined as why the client stopped, with None for a notification that is not an ending, so a consumer reading any reason as terminal would fail over to another server while this one was still being dialled. Whether a reason is named now reads the same variable that decides whether the retry follows, so the two cannot come to say different things. The reason is still reported where the failure really is terminal. The design notes described the readiness wait as built on ConnectionManager and said the reconnect loop needed no explicit wake; both were true of the design that was abandoned during implementation, not of what was built. Three sections corrected. Raised by CodeRabbit on the pull request. * fix(connection): a spent reconnect sequence stops speaking for the connection Three defects found by a cold review of the branch diff, all of them introduced by this branch rather than inherited. An attempt whose generation had already spent its reconnect budget ran OnConnectionFailed to the end. The loop had announced Disconnected / ReconnectExhausted and stopped; the straggler then announced RestoringConnection with a reconnect block over it and called StartReconnectLoop, which refuses a generation recorded as exhausted. The last status a consumer saw was non-terminal, and nothing was rebuilding the connection to make it true again. OnConnectionFailed now reads the exhausted generation under the transition lock and returns before it announces anything. The intentional-disconnect branch announced "closed permanently" without asking whether the callback still spoke for the connection. Deduplication used to swallow it; making the stop reason a change in its own right let it through, so an attempt timer outliving the Disconnect that cancelled it could overwrite the consumer's last reason with one that does not say who closed the connection. The generation is now read above the branch and the announcement is gated on Owns. Task.WaitAsync refuses a timeout above int.MaxValue milliseconds, about twenty-five days, while WaitForConnectionAsync validates only zero and negative. The polling loop this replaced had no such ceiling, so a long wait became an immediate ArgumentOutOfRangeException. The wait is served in bounded slices, with the deadline re-read at the head of every pass. * fix(connection): the wait and the status stream agree on how the connection ended Five defects found by the second cold-review pass of this branch, all of them in the new code. The wait asked "is anything in progress?" before it asked "did something end?". After a reconnect sequence ends there is no socket, no cancellation source and a Disconnected state, which is also what a client nobody has called Connect() on looks like, so a consumer who heard ReconnectExhausted on the status stream and confirmed it on the wait before failing over was told NotConnecting instead. The question is now asked last of the four terminals and only on the first pass: asked on every pass it would refuse a caller waiting correctly, because a failed first attempt announces Disconnected before it starts the loop. A close code the client does not reconnect after - 1002, 1003, 1007, 1010 - was announced as ClosedPermanently and left the wait nothing to recognise, so a parked caller was woken, found nothing and parked again until the acquisition timeout expired. The generation is recorded under the transition lock before the notification, and ConnectionClosedPermanentlyException and ConnectionWaitOutcome.ClosedPermanently give the stop reason its counterpart. Both generation fields used 0 for "no generation", which is the generation of a client that has not connected yet, so each answered its own question with yes on a brand-new client. Found by the suite while fixing the ordering above. A status announcement that speaks for one transition is true only while that transition still owns the connection. The check sat at the call sites: three had it, the terminal "reconnection stopped" did not, and deduplication hid that until the stop reason became a change in its own right. It now lives in the one funnel every announcement passes through, in the same critical section that publishes the state, which also closes the window between reading that a sequence is still running and publishing a status after it ended. Elapsed time is always a whole number of system clock ticks, so a timeout that is a whole number of them is hit exactly rather than passed. The strict deadline comparison then declined to fire while the remaining time was already zero, and the loop went round again with nothing to await. * fix(connection): one record of how the connection ended, read by every way of asking Four defects found by a third cold-review pass, two reviewers landing on the same shape in two different places: a terminal stop reason announced to the consumer and recorded nowhere, so the wait cannot see it. A parked caller is woken by the announcement, finds nothing that reads as an ending, and parks again on a signal nothing will complete - the whole acquisition timeout to be told the connection was not established in time, about a connection the consumer has already been told was over. With an infinite timeout the wait never returns. That was the third and fourth instance. The previous pass fixed the first two by adding a field per ending, which is what made the next ending invisible in the same way. The fields are gone. SetConnectionState records the reason itself, under the same lock that publishes the state and only for an announcement it actually publishes, and StoppedBecauseLocked is the single place that turns a reason into the type a caller gets. The set is closed by construction: a terminal reason cannot reach a consumer without passing through the method that records it. _reconnectExhaustedGeneration stays beside it, because it also decides whether a new loop may start and is deliberately written before the announcement. InitialConnectionFailed had no exception and no wait outcome; it has both now. The check every request passes through reads the same record. A consumer who had just been told on the status stream that the endpoint spent its reconnect budget, and the same by the wait, was told by a request that they had never connected - the one distinction this exception family exists to draw. The give-up announcement for a broken OnConnected handler was the fourth and last terminal announcement without a generation. Its ownership check has no await after it, but a takeover on another thread can land between the two, and deduplication no longer swallows terminal reasons. The correspondence between the two enums is now asserted over the enums themselves rather than a list, which is how the last mismatch was found: UserDisconnected had been paired with an outcome named Disconnected. The outcome is renamed to match. * fix(connection): delete a stop reason nothing can produce Coverage was measured per enum value rather than assumed, and three holes came out of it. Two were missing assertions: the wait outcome for a consumer's own disconnect and for a broken OnConnected handler were never asserted, though both exceptions were. The outcome is a separate path - the exception is caught and translated - so a wrong mapping there is invisible through the exception. The third was not a hole in the tests. ConnectionStopReason.InitialConnectionFailed had no test anywhere because nothing can produce it. Naming it requires willReconnect to be false, that is an open socket failing without a network drop, while the branch that would name it is reached only for a socket that never opened as a session - and such a socket is always retried, which is the same fact willReconnect reads. An established connection reports its own failure through the close callback, not here. Measured rather than argued: the branch was instrumented and the whole unit suite run twice, once on the reason and once on willReconnect itself. Zero hits in 1328 tests, and the second run also settled the adjacent question - announcing RestoringConnection while starting no loop is governed by the same variable and is equally unreachable. So the reason is deleted, along with the wait outcome and the exception added for it. A value no consumer can observe is worse than no value: it invites a branch that never runs. Adding an enum member later is not a breaking change and removing one is, which makes this the cheaper direction to be wrong in. The ending a consumer gets for a connection that never came up and stopped being retried is ReconnectExhausted, from the loop that stopped retrying it. * feat(samples): a runnable sample of every way a connection can end The repository had no sample of the connection API. Test.ClonsoleApp is a thousand lines of the author's own experiments with a Main of commented-out calls; a consumer cannot read the lifecycle out of it, and nothing demonstrated the endings this release introduces. ConnectionLifecycleSample produces each of them in turn and needs no wallet and no funded account, because the subject is the connection: nothing in progress, an endpoint that is down, a request issued with no connection, a switch to a node that is down and the recovery from it, a consumer disconnect, a broken OnConnected handler, and an operation another one overtook. Each scenario prints the status stream it produced and then the type the caller got, and every type gets one line saying what a consumer is supposed to do about it - fail over, retry, fix your handler, call Connect. Nothing in it reads message text, which is the point of the release. The scenarios that need a server which is not answering bind a loopback port and release it, so only one node is required to run the whole thing. It defaults to 127.0.0.1 rather than localhost deliberately: localhost resolves to ::1 first on a dual-stack host while the stand publishes on IPv4 only, so every connection pays a failed IPv6 attempt. Harmless with the default attempt timeout and fatal with the short ones the sample uses to stay quick - a trap worth not shipping in something people copy from. Also pins recovery from a terminal ending through ChangeServer in the unit suite. It was covered through Connect() and on the WebAssembly stand, not here. * docs(samples): how to run and read the connection lifecycle sample * fix(samples): the supersession scenario shows supersession, and a close with no code reads as a sentence Running the sample on the stand showed it twice. The last scenario had a Disconnect() overtake a switch and was described as demonstrating ConnectionSupersededException. It does not: a Disconnect() that wins reports ClientDisconnectedException, because the reaction a consumer owes it is the opposite one - the client is down because it was asked to be, and failing over would leave a healthy node. The overtaking operation is now a second switch, which is what produces the superseded type, and the scenario prints where the client ended up. The other half of the rule is named in a comment and in the README rather than demonstrated twice. A close carrying no code fell through to the default arm of DescribeClose, where interpolating a null int? produced "Connection closed with code ." - a sentence with a hole in it, reported for the commonest close there is, a peer that went away without a frame. It has its own arm now. No test asserted the old text, which is the point of the release. * fix(solution): one TestsClients folder, one parent for the sample dotnet sln add created a second solution folder of the same name instead of reusing the existing one, and the nesting entry added by hand went to the first. The sample ended up with two configured parents and the solution with two folders called TestsClients. The folder dotnet created, its own nesting entry and the duplicate mapping are gone; the project keeps the parent the other test clients have. * docs(changelog): stamp the release heading for 11.5.0.0 The heading carried 12/09/2026, the day the version was bumped, and the section has taken entries since. The date on a released section is the day it shipped. * fix(connection): a client that has given up does not call itself connected CI failed the unit suite that had passed 1328/1328 locally, on the assertion added a commit earlier that the outcome value names the same case as the exception: Expected:<ConnectHandlerFailed>. Actual:<Connected>. Not a timing flake. The give-up path announces the ending, then rejects the requests in flight - which is what resumes the caller inside Connect(), waiting on the server_info that SetNetworkId sends as soon as the socket opens - and only then disconnects. Between the rejection and the disconnect the socket is still installed and still open. The wait opened with a fast IsConnected() check and, inside the loop, preferred "connected" to any ending, so a consumer was told the connection was up one instant after being told why it was over: an operation reporting success while the state it describes is gone. The local runs passed by luck, because the socket usually closed in time. The fast path is gone; the loop answers the same question one lock later with the rest of the state in hand. The permanently-disconnected flag and the recorded ending are read whether or not a socket happens to be open, and a terminal now outranks a socket on its way out. An established connection clears the record, which is the counterpart that stops an ending outliving the state it describes. The deterministic test for the window uses the terminal notification as its pause point: it is raised before the rejection, so inside the handler the socket is guaranteed open and the ending guaranteed recorded. Its first run showed the second half of the same defect - the answer had become UserDisconnected, because _connectHandlerGaveUp was written by the disconnect two statements later and the window had no cause to read. It is written before the ending is announced now, under the transition lock and with an ownership check, and the disconnect is handed the same value. * fix(connection): Connect() does not report success off a socket on its way out CodeRabbit found the consequence of the previous commit that I had missed, and the report was right about a defect wider than the one it named. Connect() opened with a fast path on IsConnected() alone. In the give-up window for a failing OnConnected handler the socket is still open, and the message that notification carries ends "Call Connect() to retry" - so a consumer doing exactly that got an announcement of Connected, a successful return, and nothing started. The connection was torn down two statements later while they believed they had reconnected. The same path would also have cleared the recorded ending, spoiling the answer for every caller after it. The fast path now asks what the wait asks, under the same lock: an open socket, no permanent disconnect, and no recorded ending for this generation. Anything else takes the ordinary connect path, which is a real transition. No deterministic test, and that is recorded as a boundary rather than left silent. The window only opens inside a consumer status handler, and that handler runs on the thread serving the socket: a blocking Connect() from there never returns, because the new handshake cannot complete while the thread waits for it. Two attempts at such a test hung, at 10 and 20 seconds, and both were deleted - a hanging test is worse than none, and the asynchronous variant would pass on broken code whenever the socket closed first. The decision itself is pinned by TestUAStoppedClientDoesNotCallItselfConnected on the same window; that Connect() consults it is one line of reading. Spec section 15. * fix(connection): the ownership guard reaches the announcements that lacked it A review found one, in a collapsed section of the review body rather than inline: the reconnect loop's own catch announces RestoringConnection with an ownership check a line above rather than in the same critical section, so a takeover landing between the two - a user Disconnect above all - had its state overwritten by a loop that was already superseded. Rather than fix the instance, every SetConnectionState call in the file was audited for whether it carries a generation. Four were of that class: the fast reconnect announcing its own takeover, the same path when its attempt fails, the handler-failure branch that has not given up yet, and the one reported. All four now carry it. The claim in spec 12.3 that the handler give-up was the last site without a generation was true as written - it was about sites announcing a terminal reason, and these four announce RestoringConnection. The class is the same either way, and the audit is what should have been done then instead of after a third report. What still carries no generation does so by design: announcements that speak for the client as a whole rather than one transition - Connecting from the takeover that has just taken it, the Disconnect paths, and Connected from a connection that has just opened. * refactor(connection): a status announcement cannot omit its generation A review disputed the judgement recorded in the previous commit - that three announcement sites speak for the client as a whole and so need no generation - and was right to. Connecting is announced by the takeover that has just taken the generation, which is one transition and not the client; a second takeover landing between the two makes that announcement somebody else's. For the Disconnect paths the consequence is worse than a stale status. The ending is recorded under announcingGeneration ?? _generation, so an untagged announcement filed its stop reason against whatever generation was current when it published - the winner's. A superseded Disconnect stamped UserDisconnected onto the transition that had just taken the connection from it. All of them carry it now: both Connecting announcements, the four disconnect ones, Connected from OnceOpen, and the Connect fast path, which captures the generation in the same lock as the answer rather than reading it at the announcement. With none left untagged the parameter is required rather than optional, and that is the point. This class was found five times on this branch, one site at a time, and each fix closed the site. It cannot be omitted now - only passed wrongly, which is smaller and visible where it happens. Fourteen calls passed severity positionally and stopped compiling; the compiler found them, which was the exercise. A Connected that really happened is exempt from the spent-sequence seal. Suppressing it would leave the ending standing over a client whose socket is carrying traffic, with every caller told the sequence gave up. * docs(spec): correct the ownership description superseded by section 17 Section 16 justified leaving three announcement sites untagged by calling them client-wide. Section 17 disproves that and tags them, so the paragraph contradicted the document it sits in. It now says so and points at 17, kept as a trace of the reasoning rather than as a live description.
Platonenkov
enabled auto-merge
September 13, 2026 22:47
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Promotes
devtoreleasefor 11.5.0.0. One commit: #185.What ships
What happened to a connection is readable from the type, instead of the message text. 11.4.0 made the behaviour of a transition correct — one owner per transition, and an operation that was overtaken says so — but gave the caller no way to read that answer.
NotConnectedExceptioncarried five different events andOperationCanceledExceptiontwo, so telling "the consumer disconnected the client" from "this endpoint is not answering" meant classifying by message text, which the release notes of 11.3.2.0 told consumers not to do.ConnectionStatusInfo.StopReason, on the notification that says the client stopped;WaitForConnectionOutcomeAsync, answering with a value rather than an exception, onConnection,XrplClientandIXrplClient;OnConnectedhandler is no longer reported as a disconnect the consumer performed, and a swept request names the transition that swept it;StopAfterMaxAttemptsnow actually stops the client — a defect that predates this change and was verified against unmodified 11.4.0;ConnectionManagerrepaired rather than left alone.Full notes in
CHANGES.md; design notes and the history of every decision inspecs/2026-09-09-connection-outcome-api.md.Versions
Xrpl11.4.0.0 → 11.5.0.0. Minor: the change is additive.The three base packages are deliberately untouched —
git diff origin/release...origin/dev -- Base/is empty, soXrpl.AddressCodecstays at 10.9.0.0,Xrpl.BinaryCodecat 11.0.1.0 andXrpl.Keypairsat 10.9.0.0. AXrplpackage built at a newer version keeps depending on the already published base packages.Compatibility
Additive, with two things stated rather than hidden: the new types derive from the ones thrown today, so
catchby base type is unaffected, while exact-type checks,catch whenfilters andThrowsExactlyassertions change; and the new interface member has a default implementation, which is only visible through an interface-typed reference, soXrplClientcarries its own as well.Verification
Tests/TestsClients/ConnectionLifecycleSamplethrough all eight endings.Reviewed by three passes of a cold diff review and three CodeRabbit passes. Sixteen defects were found and fixed, every one introduced by the branch rather than inherited.
Note before merging
Merging this publishes to nuget.org, and that is irreversible.
CHANGES.mdcarries## 11.5.0.0 13/09/2026. The repository's convention is that the date on a released section is the day it shipped — see 40ea793, 8708b70 and cb75899. If this merges on a later date, stamp the heading first.