Skip to content

Implement RTN23 idle-transport detection and the recovery spec points around it - #1331

Merged
AndyTWF merged 13 commits into
mainfrom
claude/rtn23-spec-research-ca5ed0
Sep 10, 2026
Merged

Implement RTN23 idle-transport detection and the recovery spec points around it#1331
AndyTWF merged 13 commits into
mainfrom
claude/rtn23-spec-research-ca5ed0

Conversation

@AndyTWF

@AndyTWF AndyTWF commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

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 — protocol stays at 6 upstream and this SDK still sends v=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)

  • RTN23a — disconnect a transport idle longer than maxIdleInterval + realtimeRequestTimeout. The monitor was measuring against connectionStateTtl.
  • RTN23b — send heartbeats=true so protocol HEARTBEATs are contractual rather than an undocumented server default.
  • CD2hmaxIdleInterval parsed from connectionDetails; previously never read.
  • TO3l11realtimeRequestTimeout is now a public, validated ClientOptions property; it was internal.

Corrected

  • RTN15c6 / RTN15c7 / RTN16f — continuation is now detected positively; a connection cleared per RTN15g reconnects with a new connectionId and no error, which the old test read as a continuation.
  • RTN19a1 / RTN19a2 — serials kept on a successful resume, renumbered on a failed one, and publish order preserved across the requeue.
  • RTN7b / RTN7d / RTN7e — RTL6c2 queue failed alongside RTL6c1; failures now report the reason for the state change.
  • RTN24 — a connection update no longer churns channels (was 4 spurious protocol messages per reauth).
  • RTL3d / RTL3d1 — reattach unconditional on entering CONNECTED, and ordered before external listeners.
  • RTL3b / RTL3c / RTN11b / RTP5a — channels detach when the connection goes away, so presence members no longer survive a close.
  • RTL5l — a channel detaches immediately whenever the connection is not CONNECTED.
  • RTL11 — queued presence messages fail with an ErrorInfo rather than a bare exception.
  • RTL15b2channelSerial no longer cleared on SUSPENDED.
  • RTN14dretryIn reports the delay actually waited.
  • RTN14e — the suspend deadline is evaluated on every path into DISCONNECTED, including the token/auth paths that never suspended.
  • RTB1 / RTB1a / RTB1b — backoff coefficients, jitter and the connectionStateTtl clamp.
  • RTN15a / RTN15h3 — immediate reconnect for a non-token DISCONNECTED.
  • RTN17 / RTN17jwhich attempts consult the fallback domains: now all of them, timer-driven ones included. Previously host selection was skipped for those, so once the immediate retry budget was spent every attempt was pinned to the primary and a client whose primary datacenter was down could never reach a fallback at all. The immediate retry is also bounded (was ~65 attempts/sec) and connectivity is checked once per attempt rather than twice.
  • RTN17j — the connectivity probe is now scoped to what the clause covers, "the use of an alternative host", and taken only when the chosen host is a fallback. It used to gate the immediate retry itself, so a reconnect that 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 — up to disconnectedRetryTimeout of 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.
  • RTN17i — separately, what counts as a fallback-worthy failure has narrowed. A transport dropping out of CONNECTED is no longer grounds to move off the primary domain, so the first reconnect after a transient network blip returns to the primary instead of a fallback datacenter — RTN17i requires the primary be preferred "even if a previous connection attempt to that endpoint has failed", and RTN17f admits an exception only through RSC15l1 ("host unresolvable or unreachable"), which describes an attempt that never landed. A drop out of CONNECTED says the opposite: the host answered a moment ago. The fallbacks are deferred by one attempt, not lost — if the datacenter really has gone, that reconnect fails at connect time and the attempt after it moves off the primary. A server-sent DISCONNECTED carrying a 500–504 status is untouched by this, since RTN17f1 makes that fallback-worthy however it arrives.
  • RTN11d — full reinitialisation on connect() from CLOSED, FAILED or CLOSING.
  • RTN12b — close timeout is realtimeRequestTimeout, not a hardcoded 1s.
  • RTN8d / RTN9d — key and id cleared only in the terminal states the clauses name, cleared before the state change is emitted, and cleared even when a transition throws.
  • RTN21connectionDetails overrides only the attributes it carries.
  • RTN24 / RTN19a — an 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 by msgSerial. Invisible to callers, but this PR reworks exactly that path.
  • RSA4c / RSA4c1 — the auth callback invocation is bounded, and cause is set per the clause.

Adopted from specification 6.1.0

  • RTN14h, replacing RTN15g — the client always attempts a resume and lets the server decide whether continuity survives, rather than discarding its connection state once connectionStateTtl has 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.
  • RTN27c — a reconnect from DISCONNECTED is now genuinely a resume attempt; clearing the key on every failed attempt had made that false from the second attempt onwards.
  • RTN15g1 / RTN15g2 / RTN15g3 and RTL4j / RTL4j1 / RTL4j2 — deleted at 6.1.0. The TTL freshness check and ATTACH_RESUME go with them; channelSerial (RTL4c1, kept across a suspend by RTL15b2) already carries the continuity signal.
  • RTN16g3, replacing RTN16g2createRecoveryKey() now returns a key while SUSPENDED, where it previously returned empty. RTN8d/RTN9d 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 over. RTN16g3 comes from ably/specification#511, which tombstones RTN16g2 and drops SUSPENDED from the states where createRecoveryKey() 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

  • RTN27d used to call SUSPENDED a 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 left SUSPENDED undescribed.

Behaviour changes

No public API changed, but these are visible to callers:

  • authCallback / authUrl are now bounded by realtimeRequestTimeout (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.
  • HeartbeatMonitorDelay rejects values below 1ms — previously accepted, but 0 hot-looped and negatives silently killed idle detection, so no working configuration is affected.
  • Connection.Key and Id read as null during a CLOSED/FAILED state-change callback — they are now cleared before the event is emitted (RTN8d/RTN9d), and retained through SUSPENDED.
  • connectionId no longer changes after a long disconnection — the client always attempts a resume now (RTN14h). Use ChannelStateChangedEventArgs.Resumed to detect a fresh connection.
  • createRecoveryKey() returns a key while SUSPENDED, where it previously returned empty (RTN16g3).
  • Some ErrorInfo messages and codes changed — affects code that matches on them.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added configurable realtime request timeout validation.
    • Added server-provided idle intervals and protocol heartbeats, with caller overrides preserved.
    • Improved connection recovery, channel reattachment, serial continuity, and suspended-state recovery.
    • Added immediate reconnect handling and fallback connectivity checks.
  • Bug Fixes

    • Auth callbacks now respect request timeouts and report underlying failure details.
    • Queued callbacks continue processing after an application callback error.
    • Retry and close timers now reflect configured timeout values accurately.
    • Improved connection and channel state handling during failures and transitions.

@coderabbitai

coderabbitai Bot commented Sep 2, 2026

Copy link
Copy Markdown

Review Change StackReview Change Stack

Note

Reviews paused

It 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 reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Advanced

Run ID: ddfec4aa-e457-4ef4-acd1-77a242e0dc09

📥 Commits

Reviewing files that changed from the base of the PR and between e5ea962 and 2120a9e.

📒 Files selected for processing (7)
  • src/IO.Ably.Shared/Realtime/Workflows/RealtimeState.cs
  • src/IO.Ably.Shared/Realtime/Workflows/RealtimeWorkflow.cs
  • src/IO.Ably.Shared/Transport/AttemptFailedState.cs
  • src/IO.Ably.Shared/Transport/ConnectionAttemptsInfo.cs
  • src/IO.Ably.Shared/Transport/ConnectionManager.cs
  • src/IO.Ably.Tests.Shared/Realtime/ConnectionSpecs/ConnectionFallbackSpecs.cs
  • src/IO.Ably.Tests.Shared/Realtime/RealtimeWorkflowSpecs.cs

Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review.


Walkthrough

The change adds server-provided idle limits, timeout validation, bounded authentication callbacks, revised reconnect handling, connection continuity, safer queue cleanup, and updated channel transitions.

Changes

Realtime lifecycle changes

Layer / File(s) Summary
Idle, timeout, and authentication contracts
src/IO.Ably.Shared/..., src/IO.Ably.Shared.MsgPack/..., src/IO.Ably.Tests.Shared/...
ConnectionDetails and ConnectionInfo carry MaxIdleInterval. RealtimeRequestTimeout enforces timer-safe bounds. Auth callbacks use the configured timeout. Protocol heartbeats are requested by default and remain caller-overridable.
Connection retry and state workflow
src/IO.Ably.Shared/Realtime/Workflows/..., src/IO.Ably.Shared/Transport/..., src/IO.Ably.Tests.Shared/Realtime/...
Immediate retries are counted and bounded. Retry delays report actual timer values and respect state TTLs. Connection continuity, message queues, state transitions, and teardown behavior are updated.
Channel and presence lifecycle
src/IO.Ably.Shared/Realtime/..., src/IO.Ably.Tests.Shared/Realtime/...
Suspended channels retain serials and recovery keys. Channel transitions handle reconnect, close, suspend, and detach states. Presence callbacks no longer stop queue cleanup.
Lifecycle regression coverage
src/IO.Ably.Tests.Shared/Realtime/..., src/IO.Ably.Tests.Shared/Infrastructure/...
Tests cover serialization, timeout limits, retry timing, fallback checks, recovery keys, idle monitoring, channel transitions, connection continuity, and fake transport creation.

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
Loading

Suggested reviewers: sacoo7

Merge Risk: 🔵 Low · up to 2120a

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)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 12.57% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 175 functions across 36 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies RTN23 idle-transport detection and the related connection-recovery changes, which are the main objectives of the pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/rtn23-spec-research-ca5ed0

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.

❤️ Share

A rabbit checks the heartbeat glow
Keys stay safe through suspended nights
Queues clear when callbacks fail
Reconnect paths follow the state
Tests guard each changing route
The workflow hops back into line

Comment @coderabbitai help to get the list of available commands.

@github-actions
github-actions Bot temporarily deployed to staging/pull/1331/features September 2, 2026 07:47 Inactive
@AndyTWF
AndyTWF force-pushed the claude/rtn23-spec-research-ca5ed0 branch from 25cd43d to 8cbc9f1 Compare September 2, 2026 07:49
@github-actions
github-actions Bot temporarily deployed to staging/pull/1331/features September 2, 2026 07:50 Inactive
@AndyTWF
AndyTWF force-pushed the claude/rtn23-spec-research-ca5ed0 branch from 8cbc9f1 to c69332e Compare September 2, 2026 08:07
@github-actions
github-actions Bot temporarily deployed to staging/pull/1331/features September 2, 2026 08:08 Inactive
@AndyTWF
AndyTWF force-pushed the claude/rtn23-spec-research-ca5ed0 branch from c69332e to ae27072 Compare September 2, 2026 08:18
@github-actions
github-actions Bot temporarily deployed to staging/pull/1331/features September 2, 2026 08:19 Inactive
@AndyTWF
AndyTWF force-pushed the claude/rtn23-spec-research-ca5ed0 branch from ae27072 to 646f53f Compare September 2, 2026 08:34
@github-actions
github-actions Bot temporarily deployed to staging/pull/1331/features September 2, 2026 08:34 Inactive
@AndyTWF
AndyTWF force-pushed the claude/rtn23-spec-research-ca5ed0 branch from 646f53f to 2378962 Compare September 2, 2026 08:55
@github-actions
github-actions Bot temporarily deployed to staging/pull/1331/features September 2, 2026 08:56 Inactive
@AndyTWF
AndyTWF force-pushed the claude/rtn23-spec-research-ca5ed0 branch from 2378962 to a7a34ef Compare September 2, 2026 13:43
@github-actions
github-actions Bot temporarily deployed to staging/pull/1331/features September 2, 2026 13:44 Inactive
@AndyTWF
AndyTWF force-pushed the claude/rtn23-spec-research-ca5ed0 branch from a7a34ef to 16e1857 Compare September 2, 2026 13:54
@github-actions
github-actions Bot temporarily deployed to staging/pull/1331/features September 2, 2026 13:55 Inactive
@AndyTWF
AndyTWF force-pushed the claude/rtn23-spec-research-ca5ed0 branch from 16e1857 to 5d5f5d5 Compare September 2, 2026 13:55
@github-actions
github-actions Bot temporarily deployed to staging/pull/1331/features September 2, 2026 13:56 Inactive
@AndyTWF
AndyTWF force-pushed the claude/rtn23-spec-research-ca5ed0 branch from 5d5f5d5 to 02af272 Compare September 2, 2026 14:04
@github-actions
github-actions Bot temporarily deployed to staging/pull/1331/features September 2, 2026 14:05 Inactive
@github-actions
github-actions Bot temporarily deployed to staging/pull/1331/features September 2, 2026 15:02 Inactive
@AndyTWF
AndyTWF force-pushed the claude/rtn23-spec-research-ca5ed0 branch from dcc5d6f to 45091e9 Compare September 2, 2026 15:40
@github-actions
github-actions Bot temporarily deployed to staging/pull/1331/features September 2, 2026 15:41 Inactive
@AndyTWF
AndyTWF force-pushed the claude/rtn23-spec-research-ca5ed0 branch from 45091e9 to d6ea2b8 Compare September 3, 2026 09:37
@github-actions
github-actions Bot temporarily deployed to staging/pull/1331/features September 3, 2026 09:38 Inactive
@github-actions
github-actions Bot temporarily deployed to staging/pull/1331/features September 3, 2026 09:53 Inactive
@AndyTWF
AndyTWF requested a review from sacOO7 September 3, 2026 10:13
@AndyTWF
AndyTWF marked this pull request as ready for review September 3, 2026 10:13
@AndyTWF
AndyTWF force-pushed the claude/rtn23-spec-research-ca5ed0 branch from 8ba9920 to ed44219 Compare September 3, 2026 10:13
@AndyTWF
AndyTWF requested a review from ttypic September 3, 2026 10:14
@AndyTWF
AndyTWF force-pushed the claude/rtn23-spec-research-ca5ed0 branch from 47bc2a0 to 65103e0 Compare September 10, 2026 11:37
@github-actions
github-actions Bot temporarily deployed to staging/pull/1331/features September 10, 2026 11:38 Inactive
@github-actions
github-actions Bot temporarily deployed to staging/pull/1331/features September 10, 2026 12:35 Inactive
@AndyTWF
AndyTWF force-pushed the claude/rtn23-spec-research-ca5ed0 branch from 16b3e72 to db28d80 Compare September 10, 2026 12:46
@github-actions
github-actions Bot temporarily deployed to staging/pull/1331/features September 10, 2026 12:47 Inactive
@AndyTWF
AndyTWF force-pushed the claude/rtn23-spec-research-ca5ed0 branch from db28d80 to 34de2f6 Compare September 10, 2026 14:18
@github-actions
github-actions Bot temporarily deployed to staging/pull/1331/features September 10, 2026 14:19 Inactive
@AndyTWF
AndyTWF force-pushed the claude/rtn23-spec-research-ca5ed0 branch from 34de2f6 to a0b5887 Compare September 10, 2026 14:51
@github-actions
github-actions Bot temporarily deployed to staging/pull/1331/features September 10, 2026 14:52 Inactive
@AndyTWF
AndyTWF force-pushed the claude/rtn23-spec-research-ca5ed0 branch from a0b5887 to 21aa208 Compare September 10, 2026 15:35
@github-actions
github-actions Bot temporarily deployed to staging/pull/1331/features September 10, 2026 15:36 Inactive
@AndyTWF
AndyTWF requested a review from sacOO7 September 10, 2026 15:58

@sacOO7 sacOO7 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

AndyTWF and others added 13 commits September 10, 2026 17:03
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>
@AndyTWF
AndyTWF force-pushed the claude/rtn23-spec-research-ca5ed0 branch from 21aa208 to ffb5cf1 Compare September 10, 2026 16:03
@AndyTWF
AndyTWF merged commit 0958db6 into main Sep 10, 2026
16 checks passed
@AndyTWF
AndyTWF deleted the claude/rtn23-spec-research-ca5ed0 branch September 10, 2026 16:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

3 participants