Implement RTN23 idle-transport detection and the recovery spec points around it - #1331
Conversation
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Advanced Run ID: 📒 Files selected for processing (7)
Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review. WalkthroughThe change adds server-provided idle limits, timeout validation, bounded authentication callbacks, revised reconnect handling, connection continuity, safer queue cleanup, and updated channel transitions. ChangesRealtime lifecycle changes
Estimated code review effort: 5 (Critical) | ~120 minutes Sequence Diagram(s)sequenceDiagram
participant Client
participant RealtimeWorkflow
participant ConnectionState
participant Transport
Client->>RealtimeWorkflow: queue heartbeat monitor tick
RealtimeWorkflow->>ConnectionState: read connection and idle state
RealtimeWorkflow->>Transport: request disconnect after idle threshold
Transport->>RealtimeWorkflow: report disconnected state
RealtimeWorkflow->>RealtimeWorkflow: schedule bounded reconnect
Suggested reviewers: Merge Risk: 🔵 Low · up to Heartbeat monitoring may still arm for a transport that did not request protocol heartbeats, risking false idle disconnects. 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. A rabbit checks the heartbeat glow Comment |
25cd43d to
8cbc9f1
Compare
8cbc9f1 to
c69332e
Compare
c69332e to
ae27072
Compare
ae27072 to
646f53f
Compare
646f53f to
2378962
Compare
2378962 to
a7a34ef
Compare
a7a34ef to
16e1857
Compare
16e1857 to
5d5f5d5
Compare
5d5f5d5 to
02af272
Compare
dcc5d6f to
45091e9
Compare
45091e9 to
d6ea2b8
Compare
8ba9920 to
ed44219
Compare
47bc2a0 to
65103e0
Compare
16b3e72 to
db28d80
Compare
db28d80 to
34de2f6
Compare
34de2f6 to
a0b5887
Compare
a0b5887 to
21aa208
Compare
RTN23a - implemented. The monitor measured idleness against connectionStateTtl, so a silently dead transport took 120s to notice instead of ~25s. RTN23b - implemented. heartbeats=true was never sent, so protocol HEARTBEATs relied on an undocumented server default that heartbeats=false would switch off. CD2h - implemented. maxIdleInterval was never parsed from connectionDetails. Scoped to the transport that carried it, so a new transport does not inherit it and an RTN24 update does not withdraw it. RTN21 - fixed. A CONNECTED with no connectionDetails emptied the connectionKey, leaving a live connection with nothing to resume with. RTN15g2 - completed, incidentally. Parsing maxIdleInterval supplies the term the freshness window was missing: the measure is now the gap between the last sign of activity and the sum of connectionStateTtl and maxIdleInterval, not connectionStateTtl alone. Note the clause is deleted as of spec 6.1.0 and replaced by RTN14h, which requires a resume to be attempted regardless of how long it has been - that is not adopted here, and is coupled to the SUSPENDED key clearing in RTN8d/RTN9d. Widening the window does move behaviour toward RTN14h, since connection state is discarded less often than before. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…inue RTN15c6, RTN15c7 - fixed. Continuation required an error on the message, so an RTN15g clear - which returns a new connectionId and no error - read as a continuation and the client kept counting while the server restarted at zero. RTN19a1, RTN19a2 - fixed. Renumbering left the stale WaitingForAck entries behind, so the next ACK matched them too and callbacks ran twice; the requeue appended rather than prepended, reversing publish order on the wire. RTN7e - fixed. Only the RTL6c1 queue was failed, not RTL6c2, so a publish made while disconnected got no callback at all. RTN7b - hardened. A failed transport write could leave one message in both queues. RTN16f - a successful recover keeps the counter it adopted. RTN24, RTN19a - an RTN24 update no longer redrives the queues at all. RTN19a is scoped to "when a transport is disconnected for any reason", and an update - a reauth, typically - disconnects nothing: the transport that will ACK the in-flight messages is the one they went out on. Resending put a duplicate of each on the wire for Ably to discard by msgSerial. Invisible to callers, and the old code did the same, but this commit reworks exactly this path and ably-js does not redrain on a CONNECTED received while already connected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…nection goes away RTL3d - fixed. The reattach was gated on connectionId having changed, which RTN15g empties before CONNECTING, so the channel stayed locally ATTACHED on a new connection with no server-side attachment - permanently silent, no error. RTL3d1 - fixed. The reattach ran after external listeners had seen CONNECTED. RTN24 - fixed. An update churned channels: four spurious protocol messages per reauth, plus UPDATE events RTL2g does not permit. RTN11b, RTL3b, RTP5a - fixed. Channels never passed through DETACHED on a close, so presence members from the abandoned connection survived into the next one. RTL3c - fixed. A DETACHING channel was left stranded when the connection suspended. RTL5l - fixed. Enumerating the non-connected states let DISCONNECTED through, where the DETACH was queued for the next connection and the callback never fired. RTL11 - fixed. A null reason faulted the task with a bare Exception. RTL15b2 - fixed. channelSerial was cleared on SUSPENDED. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ect path RTN14d - fixed. retryIn reported the nominal disconnectedRetryTimeout while the timer was started with the RTB1 delay, and reported a wait at all on the skipAttach path where there is none. RTB1a - fixed. The attempt count read `?? 0 + 1`, which C# parses as `?? (0 + 1)`, so a non-null collection was never incremented. RTB1 - fixed. The connectionStateTtl clamp subtracted unguarded operands and overflowed on a backward clock step. RTN14e - fixed. Only the two connection-attempt handlers checked the deadline, and the token and auth retry paths pass through neither, so a client whose token source kept failing never suspended. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…retry RTN17j - fixed. The immediate retry was unbounded: each failed attempt produced another qualifying DISCONNECTED, so RTB1 was never reached and retryIn told the application it would wait while the client retried as fast as the connectivity check allowed. Now bounded by the number of domains there are to traverse. RTN17, RTN17i - fixed. Host selection skipped GetHost for timer-driven retries, so once the retry budget was spent every attempt was pinned to the primary and a client whose primary datacenter was down could never reach a fallback. RTN17j - the connectivity answer is carried on the CONNECTING command, so a cycle takes one check rather than two serialised on the reader thread. RTN15a, RTN15h3 - fixed. The immediate reconnect recognised only an exception or a 500-504 status, which a plain DISCONNECTED carries neither of. Token errors are excluded, since RTN15h2 owns those and already reconnects. This changes what it takes to observe a client sitting in DISCONNECTED, so the RTN12d sandbox case is reworked with it. That test forced DISCONNECTED from CONNECTED and expected the client to stay put - which is precisely the case this grants an immediate reconnect to, and against a healthy sandbox the reconnect succeeds, so close() was racing a client that had already gone back to CONNECTED. It now fails every reconnect attempt, which is what a client genuinely stuck in DISCONNECTED looks like and the only situation where RTN12d has a retry to abort at all. RTN14e - fixed. A transport dropping out of CONNECTED recorded no failed attempt: entering CONNECTED clears the attempt collection, and the exception path discarded the failure whenever it was empty, so FirstAttempt stayed null and the suspend clock started late. RTN17i - that same drop is deliberately held back from host selection. RTN17f admits an exception through RSC15l1, "host unresolvable or unreachable", which describes an attempt that never landed; a transport falling out of CONNECTED says the opposite, and RTN17i requires the primary be preferred "even if a previous connection attempt to that endpoint has failed". Counting it would send the first resume after a transient blip to another datacenter while the primary was most likely healthy - and pay a distant datacenter's latency to do it. The reconnect still goes out: if the datacenter really has gone, that attempt fails at connect time, which is eligible, so the fallbacks are one attempt away rather than skipped. A server-sent DISCONNECTED carrying a 500-504 status is untouched: it reaches the ErrorInfo overload with no exception, so RTN17f1 still makes it fallback-worthy. ably-js draws the same line, setting forceFallbackHost only for a statusCode above 500 and rebuilding each attempt's candidate list primary-first. One consequence to note: the instant retry budget is domain count, so the first unit is now spent on the primary and the last fallback is reached on an RTB1 timer rather than instantly. Every domain is still reachable, just not all of them within the instant path. Divergences, all deliberate: - The host list is not swept within a single attempt, as ably-js does. RTN17i's first sentence favours it. - The immediate retry is bounded by domain count and then hands over to RTB1. ably-js instead rate-limits it to one per second and never stops granting it. - The immediate retry stays gated on the connectivity check, as it was before this change. RTN15h3 mandates it unconditionally and RTN17j scopes the check to fallback use, so with no internet we wait out RTB1 where RTN15h3 says reconnect. Pre-existing, widened here to the RTN15h3 case. ably-js does not gate on it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ardown RTN11d - fixed. Only the channel half ran, so Connection.errorReason, msgSerial and the channel's channelSerial all survived into the new connection. RTN11b - fixed. connect() while CLOSING applied no part of RTN11d. RTN12b - fixed. The close timeout was a hardcoded 1s, not realtimeRequestTimeout, so on a slow link close() could reach CLOSED unacknowledged. TO3l11 - implemented. realtimeRequestTimeout was internal and could not be set. Validated at both ends; the upper bound is Int32.MaxValue ms, the tightest limit across every timer sink on every framework this package ships. RSA4c - fixed. The bound was applied to the task the callback returned, so a callback whose body runs synchronously was never bounded at all. RSA4c1 - fixed. cause was set as InnerException, which is not the spec's field. RTN8d, RTN9d - fixed. A throwing transition skipped the key clear and the transport teardown, leaving a terminal state holding a resumable key and a live transport. Also carries RealtimeWorkflowSpecs.cs in full: its additions are one contiguous insertion of complete test classes and cannot be split across the commits whose spec points they cover. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
RTN14h - implemented. Replaces RTN15g as of specification 6.1.0. The client discarded its connection state once connectionStateTtl had passed and reconnected fresh, throwing away a resume the server would still have honoured. DF1a settles the scope - the ttl is no longer used to decide whether to resume at all - so the gate is general rather than SUSPENDED-only, which is how ably-js reads it too. RTN27c - fixed. DISCONNECTED is a state where "if the library was previously connected, the next connect attempt will be an RTN15b resume attempt". Clearing the key on every failed attempt made that false from the second attempt onwards. RTN8d, RTN9d - fixed twice over. Both list only CLOSED, CLOSING and FAILED, so SUSPENDED must keep the key and id; it cleared them. And all three of the states they do name cleared after SetState, which is what emits the state change - inline, with no SynchronizationContext installed - so the application was told it had reached a terminal state while Connection.Key still read as a resumable key. The clear now happens before the transition, which also stops it depending on a finally. Only a listener reading during the transition could observe this, so it was inherited rather than introduced here. RTN15g1, RTN15g2, RTN15g3 - deleted at 6.1.0. HasConnectionStateTtlPassed and its tests go with them. The reattach RTN15g3 asked for is already unconditional under RTL3d. RTL4j, RTL4j1, RTL4j2 - deleted at 6.1.0; SDKs need not set ATTACH_RESUME. Safe only because RTL4c1 already sends channelSerial on ATTACH and RTL15b2 keeps it across a suspend, so the reattach still carries a continuity signal. The Flag constant stays, per TR3f. The now-dead clearConnectionKey plumbing goes entirely - from SetDisconnectedStateCommand and SetSuspendedStateCommand, whose violation it was, and from SetConnectingStateCommand and HandleConnectingErrorCommand, where it no longer does anything. Leaving it would let the violation back in unnoticed, and leave a retired mechanism looking load-bearing. ConnectionClosingState was its last caller, for RTN11b/RTN11d's clean connection, and needs it no longer: entering CLOSING now clears key and id for RTN8d/RTN9d before the emit, and the single reader processes that before it can reach ClosingState.Connect(), so the following CONNECTED finds no id to match and restarts the serial sequence under RTN15c7 regardless. HandleConnectingErrorCommand's copy was never read by any handler. RTN27d - the 6.1.0 edit left it describing the old model. It called SUSPENDED a state whose "next connect attempt is a clean connection (not a resume attempt)", which RTN14h, RTN8d, RTN9d and DF1a between them contradict; the commit that made those changes did not touch RTN27 at all. ably/specification#511 has since amended the clause in place and dropped that sentence, so this commit follows RTN27d rather than deviating from it. Amending in place is the right shape for RTN27, which declares its states "mutually exclusive and exhaustive" - tombstoning the clause would have left SUSPENDED undescribed. ably-js retains the key in SUSPENDED too, citing RTN8d/RTN9d and RTN14h for it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
RTN16g3 - implemented. Replaces RTN16g2, which listed SUSPENDED among the states where createRecoveryKey returns null. RTN8d and RTN9d now keep the connectionKey through SUSPENDED because RTN14h always attempts a resume, so the connection is still recoverable there and the key has to be available to hand to another client. Withholding it left the SDK holding a usable recovery key it would not surface, in the one prolonged-outage state where handing recovery over is most useful. RTN16i - fixed alongside it. GetChannelSerials filtered on ChannelState.Attached, and RTL3c puts every channel into SUSPENDED when the connection suspends, so the key would have gone out with no channelSerials at all - connection continuity without message continuity, and nothing to tell the caller. Gated on the serial instead, as ably-js does; RTL15b2 already keeps it through SUSPENDED. RTN16g3 comes from ably/specification#511, which tombstones RTN16g2 and drops SUSPENDED from the states where createRecoveryKey returns null. Written here before that merged, because it is the direct consequence of RTN14h in the previous commit and shipping the two apart would mean two behaviour changes for callers instead of one. ably-js has behaved this way since 2.27.0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
RTN15b, RTN15c6 - covered. The universal test suite specifies both halves of a successful resume in one case, realtime/unit/RTN15b/successful-resume-0: the reconnect carries the connectionKey in the resume query param, and the server signals success by answering with the same connectionId. WhenTransportCloses_ShouldResumeConnection already pins the first half, but feeds back a CONNECTED with no connectionId, so nothing pinned the second - and neither did the RTN15c6 work in this branch, which is about the message serial sequence rather than connection identity. Tagged with that UTS id, so the case is claimed by a test that implements all of it.
A UTS tag is a machine-readable claim that a test implements a specific case, so a tag on a test that asserts something else makes the suite report coverage it does not have. The tags added earlier were placed by reading each case's requirement table, which is a summary; the binding part is its Assertions block. Read against those, most of them over-claimed. Strengthened to assert what their case asserts: - RTL5l detach-attached-when-disconnected-1: no DETACH on the wire, not just an empty outbound queue. - RTN7e error-represents-reason-4: the publisher's error agrees with Connection.ErrorReason, which is the point of the clause. - RTN16f recover-initializes-msgserial-0: the serial on a published frame, not only the internal counter. - RTN15c7 failed-resume-new-id-0: new id, updated key, errorReason and still CONNECTED. Now routed through DISCONNECTED, as a refused resume actually arrives - reconnecting from CONNECTED trips UpdateState's same-state early return and the error never reaches Connection.ErrorReason. - RTN15h3 non-token-error-resume-0: followed through to CONNECTED, checking the resume went out and the id survived. The clause is "reconnect with a resume attempt" and only the reconnect half was covered. - RTN14h resume-after-ttl-0: every reconnection attempt carries the resume, not just the most recent one. RTN23a idle-timeout-reconnect-1 asserts the whole cycle - two attempts, an ordered state sequence, a new connectionId - so it moved to a new test that drives it. The existing test still pins what the monitor decides, which is worth keeping but is not that case. RTB1 disconnected-retry-delay-0 asserts the coefficient sequence and its cap across five retries. ReconnectionStrategyTest already did exactly that and predates this branch, so the tag moved there; the single-retry StartTimer test cannot exercise the curve, because FakeConnectionContext has no client and the attempt count is always one. Two tags removed rather than fixed: - realtime/proxy/RTN14h/resume-after-ttl-expiry-0 needs the fault-injecting proxy harness, which this repo does not have. - the second realtime/unit/RTN16f/recover-initializes-msgserial-0 - the case has no failure path, and ids must be unique per uts/docs/writing-test-specs.md. FakeTransportFactory gains CreatedTransports, since LastCreatedTransport cannot answer how many attempts were made or whether each carried a resume.
TO3l11 - the lower bound was zero, which let a positive sub-millisecond value through. CountdownTimer hands the delay to System.Threading.Timer as (int)TotalMilliseconds, so anything under a millisecond truncates to a zero delay timer - the same hot loop a literal zero produces, and just as quiet. The bound is now one millisecond. RTN23b - the heartbeats guard was answered once and cached for the client's lifetime, on the belief that TransportParams is fixed at construction. It is not: ClientOptions.TransportParams is a mutable dictionary the client keeps a reference to, and TransportParams.Create reads it afresh for every transport. A cached answer can therefore describe a param the current transport never sent, arming the monitor against heartbeats nobody asked for or standing it down while they are being sent. Recomputed per tick; the cost is a scan of a dictionary that is normally empty. RTN23a - HeartbeatMonitorDelay is the granularity of idle detection but was an unvalidated public int, and the monitor driving it is a fire-and-forget loop with nothing observing the task. So a value it cannot wait on took detection out for the life of the client: zero is a hot loop queueing a command per scheduler tick, and minus one is Timeout.Infinite, which Task.Delay accepts as genuine infinity - the monitor ticks once and is then silent for good, nothing thrown and nothing logged. Below minus one it throws inside the loop, faulting the task just as quietly. The setter now rejects anything under a millisecond, matching the sibling knob above, and the loop body is wrapped so a monitor that stops for any reason we did not foresee says so instead of being inferred later from a connection that never notices it is dead. Rejecting rather than clamping because none of the three values can be what a caller meant, so silently substituting one would hide the mistake rather than surface it. RTN14h - the sandbox test named for a past-ttl reconnect was not reaching one. A live endpoint reconnects inside the ttl, so an implementation that restored the old RTN15g gate would still have passed. It now holds the attempts in CONNECTING until the ttl is spent and the client suspends - SUSPENDED being the state RTN14h names - then lets them complete, and asserts the key and id survive that. The connectionId is deliberately no longer asserted either way: whether the server still honours the resume after the ttl is its decision, and asserting it made the test depend on server retention rather than on client behaviour. Also states, on both sides, why RTN11d's connection level reset covers CLOSED and FAILED but not CLOSING: RTN11b asks only that channels be reinitialised from CLOSING, and the operations table maps that column to RTN11b rather than RTN11d. The previous wording said RTN11b "routes connect() through RTN11d", which reads as all of it.
The guard asked ClientOptions.TransportParams whether protocol heartbeats had been requested. That is the wrong source: the params are rebuilt for every transport, and the dictionary is public and mutable, so a caller changing it retuned the monitor for a transport already on the wire. Armed against one that went out with heartbeats=false, the monitor measures against pings ClientWebSocket cannot see and disconnects a healthy connection; stood down against one that did ask, it never detects a dead one. ConnectionManager.CreateTransport now records the answer from the params the transport is actually built with, read after the merge, and the monitor consults that. Being per transport is the whole point, so it is stored per transport on the connection state. This deletes ComputeProtocolHeartbeatsNotRequested, whose job was to predict what DictionaryExtensions.Merge would do to the caller's entry - including the case-insensitive key match that lets "Heartbeats" displace ours while Ably reads neither. Asking the merged result answers all of that directly, so the reimplementation and its case analysis go, and the warning moves to transport creation where it fires once per transport rather than once per client.
RTN17j - fixed, in the two places the check was doing a job that is not its own. The clause covers "the use of an alternative host", nothing wider. It gated the immediate retry itself, which is the divergence the earlier "Consult the fallback hosts on every attempt" commit declared and left in place. That cost the attempt twice over: a reconnect which was going to stay on the primary waited out a probe nothing asked for, and a probe failing while the realtime endpoint was fine cancelled the reconnect outright - deferring it to the RTB1 timer, up to disconnectedRetryTimeout of downtime where RTN15h3 says reconnect now. The grant and the host decision are now separate: RTN15a and RTN15h3 decide whether to retry at all, and the check runs only if the host chosen for that retry turns out to be a fallback. ably-js draws the line in the same place - checkConnectivity sits inside its fallback handler, and the primary attempt takes no precheck. And the token renewal path took no check at all. HandleConnectingTokenError builds a transport directly rather than queueing a CONNECTING - the connection is already in that state, and the renewed token has to be picked up by the next transport rather than by a re-transition - so it reached GetHost without the CONNECTING handler's gate and could open a transport against another datacenter on the strength of an earlier failure. Both paths now share one gated decision, ChooseHostForNextAttempt. One check per cycle now falls out of scoping the check correctly rather than needing an answer passed between commands, so SetConnectingStateCommand.ConnectivityConfirmed and the test that pinned its lifetime go with it. The instant retry is also queued now rather than returned. A returned command is processed inside the same batch one level deeper, and with the probe no longer vetoing the retry an endpoint that fails synchronously - a transport whose connect throws - recursed DISCONNECTED -> CONNECTING -> DISCONNECTED within that batch until the command loop's nesting guard tripped at six levels. That throw is logged and swallowed by the outer catch, so the batch was abandoned and the connection left in CONNECTING with no transport and no timer, never reaching RTB1 or the RTN14e deadline. Queueing restarts the level count, so the traversal is bounded by the instant retry budget, which is what is meant to bound it. Found by the live WhenInternetConnectionIsLost_WithoutOSNotification test and now also pinned by a unit test, for which FakeTransport gains ThrowOnConnect. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
21aa208 to
ffb5cf1
Compare
Implements RTN23 — dead-transport detection — and the recovery-path spec points that
auditing the constant it had been misusing turned up. Detection latency drops from 120s
to ~25s.
To keep the change set bounded, this is deliberately not a general spec update. Nothing outside the connection-recovery paths above is touched, and the protocol version is unchanged —
protocolstays at 6 upstream and this SDK still sendsv=2.Where a clause on those paths has since been superseded, I've brought that one clause forward rather than implement the version it replaced. That is the only reason RTN14h appears here: the pre-6.1.0 resume semantics it replaces were the direct cause of several of the bugs above, so preserving them would have meant knowingly implementing behaviour the spec has already retired. Fast-forwarding is scoped to the clauses these fixes already have their hands on, not pursued for its own sake.
PUB-3824
Implemented (not previously present)
maxIdleInterval+realtimeRequestTimeout. The monitor was measuring againstconnectionStateTtl.heartbeats=trueso protocol HEARTBEATs are contractual rather than an undocumented server default.maxIdleIntervalparsed fromconnectionDetails; previously never read.realtimeRequestTimeoutis now a public, validatedClientOptionsproperty; it wasinternal.Corrected
connectionIdand no error, which the old test read as a continuation.ErrorInforather than a bare exception.channelSerialno longer cleared on SUSPENDED.retryInreports the delay actually waited.connectionStateTtlclamp.disconnectedRetryTimeoutof downtime where RTN15h3 says reconnect now. The token-renewal path took no probe at all and could open a transport against another datacenter on the strength of an earlier failure; both paths now share one gated decision. ably-js draws the line in the same place.DISCONNECTEDcarrying a 500–504 status is untouched by this, since RTN17f1 makes that fallback-worthy however it arrives.connect()from CLOSED, FAILED or CLOSING.realtimeRequestTimeout, not a hardcoded 1s.connectionDetailsoverrides only the attributes it carries.UPDATE(a reauth, typically) no longer redrives the message queues. RTN19a is scoped to "when a transport is disconnected for any reason", and an update disconnects nothing: the transport that will ACK the in-flight messages is the one they went out on, so resending put a duplicate of each on the wire for Ably to discard bymsgSerial. Invisible to callers, but this PR reworks exactly that path.causeis set per the clause.Adopted from specification 6.1.0
connectionStateTtlhas passed. Verified against the live sandbox: a resume is still honoured well beyond the advertised TTL, so the old gate was throwing away resumes the server would have accepted.DISCONNECTEDis now genuinely a resume attempt; clearing the key on every failed attempt had made that false from the second attempt onwards.ATTACH_RESUMEgo with them;channelSerial(RTL4c1, kept across a suspend by RTL15b2) already carries the continuity signal.createRecoveryKey()now returns a key whileSUSPENDED, where it previously returned empty. RTN8d/RTN9d keep theconnectionKeythroughSUSPENDEDbecause RTN14h always attempts a resume, so the connection is still recoverable there and the key has to be available to hand over. RTN16g3 comes from ably/specification#511, which tombstones RTN16g2 and dropsSUSPENDEDfrom the states wherecreateRecoveryKey()returns null; that merged while this branch was in review. Written before it merged because it is the direct consequence of RTN14h above, and splitting the two would mean two behaviour changes for callers instead of one. ably-js has behaved this way since 2.27.0.A clause the 6.1.0 edit had missed, since corrected
SUSPENDEDa state whose next connect attempt is "a clean connection (not a resume attempt)", which RTN14h, RTN8d, RTN9d and DF1a contradict between them — spec 6.1.0 moved resumability to the server without touching RTN27. ably/specification#511 has since amended the clause in place and dropped that sentence, so this PR follows RTN27d rather than deviating from it. Amending in place is the right shape here: RTN27 declares its states "mutually exclusive and exhaustive", so tombstoning the clause would have leftSUSPENDEDundescribed.Behaviour changes
No public API changed, but these are visible to callers:
authCallback/authUrlare now bounded byrealtimeRequestTimeout(default 10s) — previously unbounded. A callback that took longer and still succeeded will now fail the auth attempt (RSA4c). The only item here that can break a working deployment.HeartbeatMonitorDelayrejects values below 1ms — previously accepted, but0hot-looped and negatives silently killed idle detection, so no working configuration is affected.Connection.KeyandIdread as null during aCLOSED/FAILEDstate-change callback — they are now cleared before the event is emitted (RTN8d/RTN9d), and retained throughSUSPENDED.connectionIdno longer changes after a long disconnection — the client always attempts a resume now (RTN14h). UseChannelStateChangedEventArgs.Resumedto detect a fresh connection.createRecoveryKey()returns a key whileSUSPENDED, where it previously returned empty (RTN16g3).ErrorInfomessages and codes changed — affects code that matches on them.🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Bug Fixes