Skip to content

v11.5.0

Choose a tag to compare

@github-actions github-actions released this 13 Sep 23:01
cab057b
  • What happened to the connection is readable from the type, instead of the message text (the follow-up to #179). 11.4.0 made the behaviour correct - one owner per transition, 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 the only way to tell "the consumer disconnected the client" from "this endpoint is not answering" was to classify by message text - which the release notes of 11.3.2.0 told consumers not to do, while the library gave them no type capable of it.
    • six new exception types, all deriving from the ones thrown today, so no catch clause changes meaning and no task changes status: ClientDisconnectedException and ReconnectExhaustedException (attempts spent, budget configured), RequestRefusedException for a request the caller asked not to have wait, ConnectHandlerFailedException (how many times the handler failed, and the handler's own exception), ConnectionClosedPermanentlyException for a node that closed with a code this client does not retry after, and NotConnectingException for a client with no attempt in progress. 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 calling Disconnect() itself, so every point reading the permanently-disconnected flag answered "the client has been disconnected" - for a client that is down because its own handler is broken, where the node is answering and failing over would leave a healthy server. The cause now travels with the disconnect, written in the same critical section as the flag it qualifies. The two existing tests on that path made the defect plain: the same broken handler produced one type when it failed immediately and another when it failed a moment later
    • a request swept while the connection moved says which transition swept it, and where the client went. This is the failure consumers meet most often, and it arrived as a bare OperationCanceledException reading "Connection was intentionally closed", indistinguishable from a cancellation of their own. Sweeps caused by the connection failing on its own - a network drop, a close being processed - deliberately keep the plain cancellation: that choice is what keeps an ordinary network drop out of consumers' critical logs, and the reason now reaches them on the status stream instead
    • ConnectionStatusInfo.StopReason says why the client stopped, and only when it stopped: the first handshake failure against a server that is not up is announced and then retried, so it names no reason - whether one is named is decided by the same condition that decides whether the retry happens. A consumer reading any reason as terminal would otherwise fail over to another server while this one was still being dialled.
    • ConnectionStatusInfo.StopReason says why the client stopped. Disconnected is announced from ten places and they differed only in text, so "still trying" against "gave up" was derivable only from the absence of ReconnectInfo - which is also what a client that never had a loop looks like. The reason goes on the notification rather than into ReconnectInfo, so Reconnect != null keeps its one meaning
    • WaitForConnectionOutcomeAsync answers "did it come back?" with a value rather than an exception, on Connection, on XrplClient and on IXrplClient - where the wait was previously unreachable except through the connection object. ConnectionWaitOutcome names the case rather than folding "timed out", "gave up" and "nothing is running" into one false. HasConnectionAsync is untouched: adding a CancellationToken overload beside it would make argument-less calls ambiguous at the call site (CS0121)
    • the wait no longer polls. It slept 100 ms at a time, which is slower than the event it waits for and, on a single-threaded host such as Blazor WebAssembly, more expensive than it looks - browser timers are throttled in a hidden tab, so a wait the event would satisfy at once stretched into seconds. It now sleeps on a signal completed by the one funnel every connection state passes through. Every check it made per pass is unchanged, so the answers are identical; the unit suite runs 43 s to 30 s
    • ChangeServer reads the network id the way Connect does. Connect has carried that read across a teardown since 11.4.0, because a socket really does open for a moment before a failing handler brings it down; ChangeServer read it once, directly, so a connection that needed a second attempt failed the switch
    • ConnectionManager is fixed rather than left alone. The readiness signal above is deliberately not built on it - it releases waiters when a connection is retired, and a retirement has to carry a waiting request over to the new connection rather than fail it - but it is public, reachable as client.connection.connectionManager, and notified from nine places on the connection's own threads. Nothing inside the SDK awaits it, so its defects had never shown: a waiter resumed inside ResolveAllAwaiting, which is called from inside OnceOpen before the OnConnected handler, and a registration landing during a notification either threw "Collection was modified" or was dropped and never resumed. The list is guarded, waiters are released outside the lock and resume asynchronously, completions are TrySet*, and a cancellation is TrySetCanceled rather than a faulted task
    • StopAfterMaxAttempts now actually stops the client. Found while writing a test for one of the paths above, and present since before this change: a client that spent its reconnect budget announced Disconnected and then ran a second full series from attempt #1, announcing it again. The loop's exit clears the two fields that say a sequence is running for this generation, which is exactly what "none is running" looks like, so the close of the attempt that failed last was indistinguishable from the close that began the whole thing - and, with the cancellation source already released, started a fresh sequence with the counter at zero. The generation that gave up is now recorded and refused a new loop. Keyed by generation rather than flagged, so nothing has to reset it: generations only increase, and a Connect() or ChangeServer begins a new one - which is when asking again is the consumer's decision. A client that stopped on its own still answers the consumer asking, and that is asserted alongside
    • the wait and the status stream cannot disagree about how the connection ended. Three ways they could, all found by the cold review of this change and all in the new code. The wait asked "is anything in progress?" before it asked "did something end?", and after a sequence ends there is no socket, no cancellation source and a Disconnected state - which is also exactly what a client nobody has called Connect() on looks like: a consumer who heard ReconnectExhausted on the status stream and confirmed it on the wait before failing over was told NotConnecting, and the outcome this whole change exists to deliver was reachable only by a caller who happened to be parked already. 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, parked again, and was told a whole acquisition timeout later that the connection "was not established in time"; ConnectionWaitOutcome.ClosedPermanently and ConnectionClosedPermanentlyException are its counterpart. And the exhaustion the loop records, the permanent close, and the generation all used 0 for "none", which is the generation of a brand-new client - so the fields answered their own question with "yes" until the entry check above happened to mask it
    • one ownership check instead of three and a missing one. A status announcement that speaks for a single transition of the connection is only true while that transition still owns it, and the check sat at the call sites: three had it, one did not, and the missing one was invisible for as long as the deduplication happened to swallow what it let through. Making the stop reason a change in its own right stopped it swallowing. The check now lives inside the one funnel every announcement passes through, in the same critical section that publishes the state - which also closes the window where a caller read "the sequence is still running", lost the processor, and published a RestoringConnection after the loop had ended the sequence and announced the ending
    • a wait whose timeout is an exact multiple of the system clock tick - 15.625 ms, so one second and the default five minutes both are - spun on the connection's lock for the rest of the tick instead of timing out, because the deadline comparison was strict while the remaining time was already zero
    • every reason the client can stop for now has one outcome, under the same name, and one record behind both. Each ending used to leave its own residue for a caller to recognise - a flag for the consumer's own disconnect, a generation for a spent reconnect budget - so an ending that left none was invisible to everything except the status stream, and a caller parked in the wait sat out its whole timeout to be told the connection "was not established in time" about a connection it had already been told was over. Two such endings were found, in three places. ConnectionStopReason is now recorded where the announcement is published, by the one method every status passes through, and the wait, its outcome value and the check every request makes all read that one record - so the three ways of asking cannot come to know different things. A request issued after the endpoint gave up used to answer "no connection attempt in progress. Call Connect() first", the one distinction the exception family exists to draw. The correspondence is asserted over the enums themselves rather than a list, which is how the last mismatch was found: ConnectionStopReason.UserDisconnected had been paired with a ConnectionWaitOutcome.Disconnected, and the outcome is renamed to match
    • every value of both enums is asserted by a test, and one value was deleted for failing to be. Coverage was measured rather than assumed, and three holes came out of it: two endings whose exception was pinned but whose outcome value was not, and ConnectionStopReason.InitialConnectionFailed, which no test named because nothing can produce it. It is unreachable by construction - 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 - and that was measured, not argued: the branch was instrumented and the whole suite run twice, once on the reason and once on willReconnect itself, for zero hits in 1328 tests. A value no consumer can observe invites a branch that never runs, so it is gone before release rather than kept as a defensive one; adding an enum member later is not a breaking change, and removing one is. 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
    • a runnable sample of the whole thing, Tests/TestsClients/ConnectionLifecycleSample. It needs no wallet and no funded account, because the subject is the connection: it produces each ending in turn - nothing in progress, an endpoint that is down, a request 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 - printing the status stream each one produced and then the type the caller got, with one line per ending saying what a consumer is supposed to do about it. dotnet run --project Tests/TestsClients/ConnectionLifecycleSample against the CI stand, or pass any node's URL
    • a client that has given up no longer calls itself connected. The give-up path announces the ending, then rejects the requests in flight - which is what resumes the caller inside Connect() - and only then disconnects, so between the second step and the third the socket is still installed and still open. The wait began with a fast IsConnected() check and preferred "connected" to any ending, so a consumer was told the connection was up one instant after being told why it was over. The fast path is gone, the endings are read whether or not a socket happens to be open, and a recorded ending outranks a socket on its way out; an established connection clears the record, which is the counterpart. In the same window the cause of the ending was not yet recorded, so the answer named a consumer disconnect - the one thing that had not happened - and it is now recorded before the ending is announced rather than after. Found by CI: the whole suite passed on the development machine, where the socket usually closed in time
    • Connect() no longer reports success off a socket that is on its way out. It opened with a fast path on IsConnected() alone, and in the window above the socket is still open - so a consumer reacting to a notification whose message ends "Call Connect() to retry" was told they were already connected, and nothing was started. The fast path now asks what the wait asks, under the same lock: an open socket, no permanent disconnect, and no recorded ending. Found by CodeRabbit on the commit that fixed the defect above
    • the ownership guard reached the four announcements that still lacked it. A review found one - the reconnect loop's own catch, which checks ownership a line above the announcement rather than in the same critical section, so a takeover landing between the two overwrote the winner's state with a RestoringConnection from a loop that had already been superseded. Rather than fix the instance, every announcement in the file was audited: the fast reconnect's two, the handler-failure path that has not given up yet, and the one reported. Announcements that speak for the client as a whole rather than one transition - Connecting from the takeover that just took it, the Disconnect() paths, Connected from a connection that just opened - carry no generation by design
    • the ownership guard is now the compiler's job. After the audit above, every status announcement carried its generation, so the parameter became required rather than optional. This is the only change of the set that closes the class instead of an instance: five reviews found five sites of it on this branch, one at a time. It can no longer be omitted - only passed wrongly, which is a smaller mistake and a visible one at the call site. A Connected that really happened is exempt from the spent-sequence seal, because suppressing it would leave the ending standing over a client whose socket is carrying traffic
    • pinned by 46 tests, each asserting a type or a value and never a message. Four of them 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 after a close that took over nothing, and a retry filter that cannot tell the client's own teardown from a peer operation reports a different failure depending on timing