Skip to content

Release 11.5.0.0 - #186

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

Platonenkov merged 1 commit into
releasefrom
dev

Conversation

@Platonenkov

Copy link
Copy Markdown
Collaborator

Promotes dev to release for 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. 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.

  • six new exception types, all deriving from the ones thrown today;
  • ConnectionStatusInfo.StopReason, on the notification that says the client stopped;
  • WaitForConnectionOutcomeAsync, answering with a value rather than an exception, on Connection, XrplClient and IXrplClient;
  • the wait no longer polls — it sleeps on a signal completed by the one funnel every connection state passes through;
  • a broken OnConnected handler is no longer reported as a disconnect the consumer performed, and a swept request names the transition that swept it;
  • StopAfterMaxAttempts now actually stops the client — a defect that predates this change and was verified against unmodified 11.4.0;
  • ConnectionManager repaired rather than left alone.

Full notes in CHANGES.md; design notes and the history of every decision in specs/2026-09-09-connection-outcome-api.md.

Versions

Xrpl 11.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, so Xrpl.AddressCodec stays at 10.9.0.0, Xrpl.BinaryCodec at 11.0.1.0 and Xrpl.Keypairs at 10.9.0.0. A Xrpl package 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 catch by base type is unaffected, while exact-type checks, catch when filters and ThrowsExactly assertions change; and the new interface member has a default implementation, which is only visible through an interface-typed reference, so XrplClient carries its own as well.

Verification

  • merge queue on the merge result: unit, integration and build-and-lint all green;
  • locally on the merged tree: unit 1329/1329, integration 346/346 against rippled 3.3.0;
  • the Blazor WebAssembly stand through connect, server switch, subscriptions, socket drop, reconnect with subscription restore, terminal give-up, recovery from it, and a consumer disconnect;
  • Tests/TestsClients/ConnectionLifecycleSample through 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.md carries ## 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.

)

* 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
Platonenkov merged commit cab057b into release Sep 13, 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.

2 participants