11import type BaseClient from 'common/lib/client/baseclient' ;
22import type RealtimeChannel from 'common/lib/client/realtimechannel' ;
3+ import type ErrorInfo from 'common/lib/types/errorinfo' ;
34import type EventEmitter from 'common/lib/util/eventemitter' ;
45import type * as API from '../../../ably' ;
56import type { ChannelState , StatusSubscription } from '../../../ably' ;
@@ -19,6 +20,17 @@ export enum ObjectsEvent {
1920 synced = 'synced' ,
2021}
2122
23+ /**
24+ * Internal-only signals emitted on `_eventEmitterInternal` (never on `_eventEmitterPublic`), so they
25+ * are not observable through the public `RealtimeObject#on` API.
26+ */
27+ enum ObjectsInternalEvent {
28+ // RTO23c1 / RTO20e1 - emitted when the channel transitions into DETACHED/SUSPENDED/FAILED, so that
29+ // parked objects-sync waiters (get()/publishAndApply) can fail. Carries the channel state and its
30+ // errorReason as emit arguments.
31+ syncWaitFailed = 'syncWaitFailed' ,
32+ }
33+
2234/** @spec RTO22 */
2335export enum ObjectsOperationSource {
2436 local = 'local' ,
@@ -39,6 +51,35 @@ const StateToEventsMap: Record<ObjectsState, ObjectsEvent | undefined> = {
3951
4052export type ObjectsEventCallback = ( ) => void ;
4153
54+ /**
55+ * Remediation for a `get()` sync wait failing (RTO23c1). The rejection is recoverable, but the
56+ * recovery differs per state: `ensureAttached` at `get()` entry re-attaches a DETACHED channel
57+ * itself and proceeds through SUSPENDED (the SDK re-attaches when the connection recovers), so a
58+ * plain retry suffices for those two states, whereas from FAILED `get()` rejects at entry (90001)
59+ * until the channel is explicitly re-attached.
60+ */
61+ function getSyncWaitFailureRemediation ( state : ChannelState ) : string {
62+ switch ( state ) {
63+ case 'detached' :
64+ return 'Retry channel.object.get(). The retried call re-attaches the channel and waits for a fresh objects sync.' ;
65+ case 'suspended' :
66+ return 'Retry channel.object.get() once the channel re-attaches. The SDK re-attaches suspended channels automatically when the connection recovers, or call channel.attach() to retry now.' ;
67+ default :
68+ return 'Inspect the cause for the underlying failure. Call channel.attach() to recover the channel, then retry channel.object.get(). Calling channel.object.get() on a failed channel without re-attaching first rejects immediately.' ;
69+ }
70+ }
71+
72+ /**
73+ * Remediation for a `publishAndApply` sync wait failing (RTO20e1), reached via the public mutation
74+ * APIs (`LiveMap.set`/`remove`, `LiveCounter.increment`/`decrement`, batch). The operation was
75+ * published and ACKed before the wait started, so it is persisted server-side and must not be
76+ * retried; only the local optimistic apply failed, and the local object converges on the next
77+ * successful attach and objects sync. State-independent, unlike the `get()` remediation.
78+ */
79+ function publishSyncWaitFailureRemediation ( ) : string {
80+ return 'Do not retry the operation. It was already published and acknowledged by Ably, so retrying would apply it twice. The local object converges automatically on the next successful attach and objects sync. Inspect the cause and channel.errorReason for why the channel left the attached state.' ;
81+ }
82+
4283export class RealtimeObject {
4384 gcGracePeriod : number ;
4485
@@ -91,9 +132,9 @@ export class RealtimeObject {
91132 // implicit attach before proceeding
92133 await this . _channel . ensureAttached ( ) ;
93134
94- // if we're not synced yet, wait for sync sequence to finish before returning root
135+ // RTO23c - if we're not synced yet, wait for sync sequence to finish before returning root
95136 if ( this . _state !== ObjectsState . synced ) {
96- await this . _eventEmitterInternal . once ( ObjectsEvent . synced ) ; // RTO1c
137+ await this . _waitForSyncedOrChannelFailure ( 'the object could not be retrieved' , getSyncWaitFailureRemediation ) ; // RTO23c1
97138 }
98139
99140 const pathObject = new DefaultPathObject ( this , this . _objectsPool . getRoot ( ) , [ ] ) ;
@@ -234,24 +275,32 @@ export class RealtimeObject {
234275 * @spec RTO4 - handling of the `ATTACHED` transition
235276 * @spec RTO27 - manage the stored objects data across non-`ATTACHED` transitions
236277 */
237- actOnChannelState ( state : ChannelState , hasObjects ?: boolean ) : void {
278+ actOnChannelState ( state : ChannelState , hasObjects ?: boolean , reason ?: ErrorInfo | null ) : void {
238279 switch ( state ) {
239280 case 'attached' :
240281 // RTO4 - ATTACHED is handled by onAttached (the sync lifecycle); it is outside RTO27's scope
241282 this . onAttached ( hasObjects ) ;
242283 break ;
243284
244285 case 'detached' :
286+ case 'suspended' :
245287 case 'failed' :
246- // RTO27a - the actual current state of Objects data is unknown in these states, so clear it
247- // without emitting update events (RTO27a1); the objects themselves remain in the pool.
248- this . _objectsPool . clearObjectsData ( false ) ; // RTO27a1
249- this . _syncObjectsPool . clear ( ) ; // RTO27a2
288+ // RTO23c1 / RTO20e1 - fail any parked objects-sync waiters (get()/publishAndApply) before the
289+ // RTO27a data clearing below (drain-then-clear, matching ably-cocoa). The channel's errorReason
290+ // is not yet assigned when notifyState invokes this handler, so it is passed in as `reason`.
291+ // Emitted unconditionally; a no-op when no waiter is parked.
292+ this . _eventEmitterInternal . emit ( ObjectsInternalEvent . syncWaitFailed , state , reason ) ;
293+
294+ if ( state !== 'suspended' ) {
295+ // RTO27a - the actual current state of Objects data is unknown in DETACHED/FAILED, so clear it
296+ // without emitting update events (RTO27a1); the objects themselves remain in the pool.
297+ this . _objectsPool . clearObjectsData ( false ) ; // RTO27a1
298+ this . _syncObjectsPool . clear ( ) ; // RTO27a2
299+ }
300+ // RTO27b - SUSPENDED (and every unlisted state: INITIALIZED, ATTACHING, DETACHING) retains the
301+ // objects data unchanged. For SUSPENDED in particular the connection may still recover, so the
302+ // retained data remains a valid best-effort local copy.
250303 break ;
251-
252- // RTO27b - every other state (SUSPENDED, INITIALIZED, ATTACHING, DETACHING) is intentionally
253- // not handled here: the objects data is retained unchanged. For SUSPENDED in particular, the
254- // connection may still recover, so the retained data remains a valid best-effort local copy.
255304 }
256305 }
257306
@@ -356,30 +405,10 @@ export class RealtimeObject {
356405 `waiting for sync to complete before applying ${ syntheticMessages . length } message(s); channel=${ this . _channel . name } ` ,
357406 ) ;
358407
359- await new Promise < void > ( ( resolve , reject ) => {
360- const cleanup = ( ) => {
361- this . _eventEmitterInternal . off ( onSynced ) ;
362- this . _channel . internalStateChanges . off ( onChannelState ) ;
363- } ;
364- const onSynced = ( ) => {
365- cleanup ( ) ;
366- resolve ( ) ;
367- } ;
368- // RTO20e1
369- const onChannelState = ( ) => {
370- cleanup ( ) ;
371- reject (
372- new this . _client . ErrorInfo (
373- `the operation could not be applied locally due to the channel entering the ${ this . _channel . state } state whilst waiting for objects sync to complete` ,
374- 92008 ,
375- 400 ,
376- this . _channel . errorReason || undefined ,
377- ) ,
378- ) ;
379- } ;
380- this . _eventEmitterInternal . once ( ObjectsEvent . synced , onSynced ) ;
381- this . _channel . internalStateChanges . once ( [ 'detached' , 'suspended' , 'failed' ] , onChannelState ) ;
382- } ) ;
408+ await this . _waitForSyncedOrChannelFailure (
409+ 'the operation could not be applied locally' ,
410+ publishSyncWaitFailureRemediation ,
411+ ) ; // RTO20e1
383412 }
384413
385414 // RTO20f - Apply synthetic messages
@@ -597,6 +626,48 @@ export class RealtimeObject {
597626 }
598627 }
599628
629+ /**
630+ * Waits for the objects sync state to reach SYNCED, rejecting with a 92008 error if the channel
631+ * first transitions into DETACHED/SUSPENDED/FAILED (signalled by `actOnChannelState` via the
632+ * internal `syncWaitFailed` event). Shared by `get()` (RTO23c1) and `publishAndApply` (RTO20e1),
633+ * which differ in the error message's `failureDescription` prefix and in their caller-specific
634+ * `remediation` (resolved per failure state, since the recovery advice depends on it); the error's
635+ * code (92008), statusCode (400), and cause (the channel's errorReason) are mandated identically
636+ * by both spec points, which say nothing about remediation. The cause is the state-change
637+ * `reason` — the same error `notifyState` assigns to `RealtimeChannel.errorReason`; on a
638+ * reason-less transition (e.g. a clean detach) the cause is deliberately absent rather than a
639+ * stale prior errorReason. Both listeners are removed on either outcome (no leaks).
640+ */
641+ private _waitForSyncedOrChannelFailure (
642+ failureDescription : string ,
643+ remediation : ( state : ChannelState ) => string ,
644+ ) : Promise < void > {
645+ return new Promise < void > ( ( resolve , reject ) => {
646+ const cleanup = ( ) => {
647+ this . _eventEmitterInternal . off ( ObjectsEvent . synced , onSynced ) ;
648+ this . _eventEmitterInternal . off ( ObjectsInternalEvent . syncWaitFailed , onChannelFailure ) ;
649+ } ;
650+ const onSynced = ( ) => {
651+ cleanup ( ) ;
652+ resolve ( ) ;
653+ } ;
654+ const onChannelFailure = ( state : ChannelState , reason ?: ErrorInfo | null ) => {
655+ cleanup ( ) ;
656+ reject (
657+ new this . _client . ErrorInfo ( {
658+ message : `${ failureDescription } due to the channel entering the ${ state } state whilst waiting for objects sync to complete` ,
659+ code : 92008 ,
660+ statusCode : 400 ,
661+ cause : reason || undefined ,
662+ remediation : remediation ( state ) ,
663+ } ) ,
664+ ) ;
665+ } ;
666+ this . _eventEmitterInternal . once ( ObjectsEvent . synced , onSynced ) ;
667+ this . _eventEmitterInternal . once ( ObjectsInternalEvent . syncWaitFailed , onChannelFailure ) ;
668+ } ) ;
669+ }
670+
600671 private _stateChange ( state : ObjectsState ) : void {
601672 if ( this . _state === state ) {
602673 return ;
0 commit comments