Skip to content

[AIT-1274] LiveObjects: fail get()'s sync wait when the channel enters DETACHED/SUSPENDED/FAILED (RTO23c1) - #2284

Open
sacOO7 wants to merge 2 commits into
mainfrom
liveobjects/rto23c1-get-sync-wait-failure
Open

[AIT-1274] LiveObjects: fail get()'s sync wait when the channel enters DETACHED/SUSPENDED/FAILED (RTO23c1)#2284
sacOO7 wants to merge 2 commits into
mainfrom
liveobjects/rto23c1-get-sync-wait-failure

Conversation

@sacOO7

@sacOO7 sacOO7 commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Problem

RealtimeObject.get() waits for the objects sync state to reach SYNCED before returning (RTO23c), but that wait listened only for the internal synced event. If the channel left a usable state while a get() was parked, the returned promise never settled:

  • a solicited channel.detach() rests the channel in DETACHED with the objects data cleared (RTO27a) — the parked get() hangs until the user happens to re-attach;
  • a channel entering FAILED — including a SUSPENDED channel whose connection then fails terminally (propogateConnectionInterruption maps connection failed → channel failed) — is terminal: the data is cleared, no automatic recovery exists, and the promise can never resolve.

This was also internally inconsistent: publishAndApply already rejects deterministically in the identical wait (RTO20e1, 92008), and get() itself rejects when the channel is already FAILED at entry (ensureAttached, 90001) — but hung when the channel became unusable mid-wait.

The spec now covers this as RTO23c1 (companion PR: ably/specification#514): the parked get() must fail with ErrorInfo code 92008, statusCode 400, and cause set to the channel's errorReason, regardless of the state the channel transitioned from.

Fix

One mechanism, shared by both waiters:

  • actOnChannelState — the plugin's single channel-state entry point, invoked on every transition — now emits an internal-only ObjectsInternalEvent.syncWaitFailed on detached / suspended / failed, before the RTO27a data clearing (drain-then-clear, matching ably-cocoa and ably-java). The event is emitted on _eventEmitterInternal only, so it is not observable through the public RealtimeObject#on() API. Because it hooks the state handler rather than a specific event source, every route into the three states is covered — including SUSPENDEDFAILED.
  • A new _waitForSyncedOrChannelFailure(failureDescription) helper races synced against syncWaitFailed and builds the 92008/400/cause ErrorInfo; get() and publishAndApply differ only in the message prefix mandated by their respective spec points ('the object could not be retrieved' vs 'the operation could not be applied locally'). publishAndApply is refactored onto the helper, replacing its previous internalStateChanges subscription. Both listeners are removed on either outcome.
  • realtimechannel.ts (4 lines): notifyState invokes actOnChannelState before assigning this.errorReason, so the state-change reason is now forwarded as an argument — exactly as the adjacent presence handler (_presence.actOnChannelState(state, hasPresence, reason)) already does. Without this the rejection's cause would be lost.

RTO27 data semantics are unchanged: SUSPENDED still retains the objects data (RTO27b); only DETACHED/FAILED clear it (RTO27a).

Tests

  • The three RTO23c1 UTS unit tests (fails-on-channel-{detached,suspended,failed}-0) previously existed as skipped deviations; the skip guards and the test/uts/deviations.md entry are removed now that the behaviour is implemented. The failed case asserts the cause (cause.code === 90000), which exercises the reason-forwarding.
  • New RTO5a5/RTO5a6 UTS unit tests (objects_pool.test.ts) covering OBJECT_SYNC channelSerial handling: absent serial (single-message sync) and malformed serial treated as absent. The existing implementation already conforms — these add the spec-derived coverage.

All tests derive from the UTS unit spec cases added in ably/specification#514.

Verification

  • test/uts/objects/unit: 325 passing, 0 failing, 0 pending (previously 3 pending deviations).
  • test/uts/realtime/unit channels + presence sweep (for the realtimechannel.ts change): 326 passing, 0 new failures.
  • tsc --noEmit and eslint clean on the touched sources.

Summary by CodeRabbit

  • Bug Fixes
    • Object retrieval and updates now fail promptly with a clear synchronization error when the channel is detached, suspended, or failed.
    • Channel failure details are preserved, improving error diagnosis.
    • Object data is retained during temporary suspension and cleared appropriately after permanent channel failures.
    • Synchronization now completes correctly when incoming messages omit or contain invalid channel sequence information.

sacOO7 added 2 commits August 9, 2026 19:50
A get() parked waiting for the objects sync state to reach SYNCED never
rejected when the channel entered DETACHED/SUSPENDED/FAILED, leaving the
returned promise unsettled forever (e.g. a solicited detach, or a
connection failure moving a SUSPENDED channel to FAILED after RTO27a has
cleared the objects data). publishAndApply already failed deterministically
in the identical wait (RTO20e1); get() had no equivalent.

Fail parked sync waiters from actOnChannelState via a new internal-only
syncWaitFailed event, shared by get() (RTO23c1) and publishAndApply
(RTO20e1) through _waitForSyncedOrChannelFailure, which builds the 92008
ErrorInfo with a caller-specific message prefix. notifyState invokes
actOnChannelState before assigning channel.errorReason, so the state-change
reason is forwarded as an argument (as the presence handler already does)
to preserve the error cause.
RTO5a5: an OBJECT_SYNC with no channelSerial is a single-message sync
(data applied, sync completes SYNCED). RTO5a6: a present-but-malformed
channelSerial (no ':' separator) is handled as if absent per RTO5a5.
Both derive from the corresponding new UTS unit spec cases; the existing
implementation already conforms.
@coderabbitai

coderabbitai Bot commented Aug 9, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

The change propagates channel transition reasons to realtime objects. get() and publishAndApply() now share failure-aware synchronization waits. DETACHED, SUSPENDED, and FAILED transitions reject pending operations with error 92008. Tests cover channel serial handling and state failures.

Changes

Realtime object synchronization

Layer / File(s) Summary
Channel failure propagation
src/common/lib/client/realtimechannel.ts, src/plugins/liveobjects/realtimeobject.ts
notifyState forwards transition reasons. RealtimeObject emits syncWaitFailed for DETACHED, SUSPENDED, and FAILED states. DETACHED and FAILED clear data; SUSPENDED retains it.
Failure-aware sync waits and validation
src/plugins/liveobjects/realtimeobject.ts, test/uts/objects/unit/objects_pool.test.ts, test/uts/objects/unit/realtime_object.test.ts
get() and publishAndApply() use a shared synchronization helper. The helper rejects with 92008 and preserves the transition cause. Tests cover malformed channel serials and all three channel failure states.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant RealtimeChannel
  participant RealtimeObject
  participant SyncWaiter
  RealtimeChannel->>RealtimeObject: notifyState(state, reason)
  RealtimeObject->>SyncWaiter: emit syncWaitFailed(state, reason)
  SyncWaiter-->>RealtimeObject: reject with 92008 and cause
  RealtimeObject-->>RealtimeChannel: return rejected operation
Loading

Possibly related PRs

  • ably/ably-js#2219: Modifies RealtimeObject synchronization waits and 92008 failure handling.
  • ably/ably-js#2256: Addresses publishAndApply synchronization failures and preserves failure cause 90000.
  • ably/ably-js#2278: Changes RealtimeObject.actOnChannelState handling for DETACHED, FAILED, and SUSPENDED states.

Suggested reviewers: umair-ably, vesker, paddybyers

Poem

A rabbit watched the channel change,
And carried reasons through the range.
Sync waits wake, or errors hop,
Suspended keeps its data crop.
DETACHED and FAILED clear the way—
“92008!” thumps the hare today.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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 and specifically summarizes the main change: failing LiveObjects synchronization waits for DETACHED, SUSPENDED, or FAILED channel states.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch liveobjects/rto23c1-get-sync-wait-failure

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

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/plugins/liveobjects/realtimeobject.ts`:
- Around line 621-626: Update the ErrorInfo construction in the object-sync
failure helper to accept caller-specific remediation and include it in the new
ErrorInfo. For the channel.object.get() path, provide remediation that guides
callers without suggesting a retry of publishAndApply(), since its publish
acknowledgment has already completed; preserve existing behavior for other
callers.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 115277c0-a82d-49ce-8682-086112ba3776

📥 Commits

Reviewing files that changed from the base of the PR and between 7fa7dc5 and 6cd821b.

📒 Files selected for processing (4)
  • src/common/lib/client/realtimechannel.ts
  • src/plugins/liveobjects/realtimeobject.ts
  • test/uts/objects/unit/objects_pool.test.ts
  • test/uts/objects/unit/realtime_object.test.ts

Comment on lines +621 to +626
new this._client.ErrorInfo(
`${failureDescription} due to the channel entering the ${state} state whilst waiting for objects sync to complete`,
92008,
400,
reason || undefined,
),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

Add a remediation for the public channel.object.get() failure.

channel.object.get() can reject with this new ErrorInfo, but the error has no remediation. Pass caller-specific remediation into the helper. Do not instruct callers to retry publishAndApply(), because its publish ACK has already completed.

Proposed fix
-      await this._waitForSyncedOrChannelFailure('the object could not be retrieved'); // RTO23c1
+      await this._waitForSyncedOrChannelFailure(
+        'the object could not be retrieved',
+        'Call channel.attach() before retrying channel.object.get().',
+      ); // RTO23c1

-  private _waitForSyncedOrChannelFailure(failureDescription: string): Promise<void> {
+  private _waitForSyncedOrChannelFailure(failureDescription: string, remediation?: string): Promise<void> {
     return new Promise<void>((resolve, reject) => {
       // ...
-          new this._client.ErrorInfo(
-            `${failureDescription} due to the channel entering the ${state} state whilst waiting for objects sync to complete`,
-            92008,
-            400,
-            reason || undefined,
-          ),
+          new this._client.ErrorInfo({
+            message: `${failureDescription} due to the channel entering the ${state} state whilst waiting for objects sync to complete`,
+            code: 92008,
+            statusCode: 400,
+            cause: reason || undefined,
+            remediation,
+          }),

As per coding guidelines, “Add a concrete remediation to every publicly reachable SDK-originating throw site when it provides actionable value beyond the message.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/plugins/liveobjects/realtimeobject.ts` around lines 621 - 626, Update the
ErrorInfo construction in the object-sync failure helper to accept
caller-specific remediation and include it in the new ErrorInfo. For the
channel.object.get() path, provide remediation that guides callers without
suggesting a retry of publishAndApply(), since its publish acknowledgment has
already completed; preserve existing behavior for other callers.

Source: Coding guidelines

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

This PR fixes a LiveObjects RealtimeObject.get() deadlock by ensuring the sync-wait rejects deterministically when the underlying channel becomes unusable (DETACHED/SUSPENDED/FAILED), aligning get() behavior with publishAndApply and the updated RTO23c1/RTO20e1 spec requirements.

Changes:

  • Emit an internal-only syncWaitFailed signal on channel transitions to DETACHED/SUSPENDED/FAILED and race it against synced via a shared _waitForSyncedOrChannelFailure helper.
  • Forward the channel state-change reason into the LiveObjects state handler so the rejection cause can be preserved even before RealtimeChannel.errorReason is assigned.
  • Unskip/add UTS coverage for the new failure behavior and add OBJECT_SYNC channelSerial handling tests.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.

File Description
test/uts/objects/unit/realtime_object.test.ts Adds RTO23c1 tests asserting get() rejects during sync-wait when the channel enters DETACHED/SUSPENDED/FAILED (including cause on FAILED).
test/uts/objects/unit/objects_pool.test.ts Adds RTO5a5/RTO5a6 tests for OBJECT_SYNC behavior when channelSerial is absent or malformed.
src/plugins/liveobjects/realtimeobject.ts Implements internal failure signaling and shared sync-wait helper; refactors get() and publishAndApply to reject on channel failure states with 92008/400 and appropriate cause.
src/common/lib/client/realtimechannel.ts Passes state-change reason into the LiveObjects channel-state handler so failures can propagate accurate causes.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +374 to +381
// RTO5a5 - no channelSerial: the whole sync is contained in this one message, so the
// objects are applied and the sync completes (SYNCED) without waiting for a cursor-empty
// channelSerial (RTO5a4)
ws.active_connection!.send_to_client(
buildObjectSyncMessage(msg.channel, null as any, [
buildObjectState('counter:new@1000', { aaa: 't:0' }, { counter: { count: 99 } }),
]),
);
sacOO7 added a commit to ably/ably-java that referenced this pull request Aug 9, 2026
…nd add their UTS unit tests

- RTO23c1: a get() parked waiting for objects sync now fails when the channel
  enters DETACHED/SUSPENDED/FAILED — ensureSynced routes through the shared
  pendingSyncWaiters, each waiter carrying a caller-specific failure
  description (the object could not be retrieved vs RTO20e1's operation could
  not be applied locally), built into the 92008/400/cause error at the
  failure site.
- RTO5a6: a malformed OBJECT_SYNC channelSerial (no ':' separator) is
  normalized to null so it takes the same branch as an absent serial
  (RTO5a5), with a warning logged.
- Add the five UTS unit tests derived from the new spec cases (3x RTO23c1
  per channel state, RTO5a5, RTO5a6).
- Annotate the implementation sites of the newly specified points (RTO20d4,
  RTLC14c, RTLM22c).

Spec changes: ably/specification#514
Companion ably-js fix: ably/ably-js#2284
@sacOO7 sacOO7 changed the title LiveObjects: fail get()'s sync wait when the channel enters DETACHED/SUSPENDED/FAILED (RTO23c1) [AIT-1274]LiveObjects: fail get()'s sync wait when the channel enters DETACHED/SUSPENDED/FAILED (RTO23c1) Aug 10, 2026
@sacOO7 sacOO7 changed the title [AIT-1274]LiveObjects: fail get()'s sync wait when the channel enters DETACHED/SUSPENDED/FAILED (RTO23c1) [AIT-1274] LiveObjects: fail get()'s sync wait when the channel enters DETACHED/SUSPENDED/FAILED (RTO23c1) Aug 10, 2026
@sacOO7
sacOO7 requested a review from VeskeR August 10, 2026 08:31
@sacOO7 sacOO7 reopened this Aug 20, 2026
@sacOO7
sacOO7 requested a balanced review from Copilot August 21, 2026 11:00

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.

Suppressed comments (2)

test/uts/objects/unit/objects_pool.test.ts:409

  • RTO5a6 also requires a warning for a present-but-malformed channelSerial, but this test only checks that sync completes. _parseSyncChannelSerial currently silently maps this value to undefined (src/plugins/liveobjects/realtimeobject.ts:434-450), so the new test passes while the warning requirement remains unimplemented. Add the warning in the parser and capture/assert it here.
          // RTO5a6 - "malformedserialnocolon" has no ':' separator, so it cannot be parsed per
          // RTO5a1; it must be handled as if the channelSerial were absent (RTO5a5): the objects
          // are applied and the sync completes (SYNCED)
          ws.active_connection!.send_to_client(
            buildObjectSyncMessage(msg.channel, 'malformedserialnocolon', [

src/plugins/liveobjects/realtimeobject.ts:625

  • This SDK-authored 92008 error is reachable from public get() and mutation APIs but has no remediation. These paths need distinct actionable advice: get() can be retried after reattaching, while a mutation must not be blindly retried because its publish has already succeeded. Parameterize the helper with a call-specific remediation and construct ErrorInfo with the options-object form so the remediation is included.
        reject(
          new this._client.ErrorInfo(
            `${failureDescription} due to the channel entering the ${state} state whilst waiting for objects sync to complete`,
            92008,
            400,
            reason || undefined,

@ttypic ttypic 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

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