Fix RTC10 violation: channel.off() could break attach and detach#2167
Fix RTC10 violation: channel.off() could break attach and detach#2167lawrence-forooghian merged 2 commits intomainfrom
channel.off() could break attach and detach#2167Conversation
WalkthroughRealtimeChannel now separates internal state-change notifications from public listeners by introducing Changes
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Poem
🚥 Pre-merge checks | ✅ 3✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches
🧪 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. Comment |
9e6c5a0 to
4f85ce3
Compare
channel.off() could break attach and detach
|
I was actually wondering if this is the case given we use public Also related, we should ideally just remove "unsubscribe all" behavior in the future v3 release. We did the same for chat, and for liveobjects (see https://ably.atlassian.net/wiki/spaces/LOB/pages/4690411539/LODR-056+SDK+remove+offAll+unsubscribeAll+from+LiveObjects+API) |
agreed |
There was a problem hiding this comment.
🧹 Nitpick comments (1)
src/common/lib/client/realtimechannel.ts (1)
91-98: Consider marking_internalStateChangesasprivateto enforce its isolation contract.The JSDoc on lines 91–97 explicitly states this emitter must not be affected by public
off()calls. Declaring it without an access modifier leaves itpublic, meaning any consumer could callchannel._internalStateChanges.off()and silently re-introduce the exact RTC10 regression this PR fixes._allChannelChangesshares this exposure, but its accidental.off()is far less critical than breaking attach/detach resolution.♻️ Proposed change
- _internalStateChanges: EventEmitter; + private _internalStateChanges: EventEmitter;🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/common/lib/client/realtimechannel.ts` around lines 91 - 98, The _internalStateChanges EventEmitter is currently public which allows external callers to call .off() and break attach/detach behavior; change the declaration of _internalStateChanges to be private (e.g., private _internalStateChanges: EventEmitter) and update all internal references in the RealtimeChannel class to use the private field; also consider making _allChannelChanges private as well or providing controlled accessors if external consumers need to observe events—update any tests or usages that referenced these fields directly to use the new accessor or public APIs instead.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Nitpick comments:
In `@src/common/lib/client/realtimechannel.ts`:
- Around line 91-98: The _internalStateChanges EventEmitter is currently public
which allows external callers to call .off() and break attach/detach behavior;
change the declaration of _internalStateChanges to be private (e.g., private
_internalStateChanges: EventEmitter) and update all internal references in the
RealtimeChannel class to use the private field; also consider making
_allChannelChanges private as well or providing controlled accessors if external
consumers need to observe events—update any tests or usages that referenced
these fields directly to use the new accessor or public APIs instead.
VeskeR
left a comment
There was a problem hiding this comment.
Need to also emit on internal emitter here:
ably-js/src/common/lib/client/realtimechannel.ts
Lines 572 to 575 in db6df43
and here:
The _ensureMyMembersPresent is also currently missing this._allChannelChanges.emit call, should fix that too.
At this point we should just have a dedicated method on a RealtimeChannel to emit on all underlying emitters, so we don't forget any in the future
4f85ce3 to
0f6083c
Compare
Thanks — I've added an override of |
VeskeR
left a comment
There was a problem hiding this comment.
Approving, but have a comment regarding property name.
Also, what do you think about adding this._allChannelChanges.emit to the RealtimePresence._ensureMyMembersPresent call here:
It seems like there is no reason not to emit an event there
0f6083c to
eea1338
Compare
I think it's unnecessary given what |
eea1338 to
3d1535a
Compare
I really like this approach. |
|
Tests seem to be failing due to something not related to this PR, raised in https://ably-real-time.slack.com/archives/C09SY1AQGK0/p1771517162529679 |
The attach() and detach() methods used this.once() to listen for state changes on the public EventEmitter. A user calling channel.off() to remove their own listeners would also remove these internal listeners, causing the attach/detach promise to never resolve. Add a dedicated _internalStateChanges EventEmitter that is not affected by public off() calls, and use it for attach and detach. The existing _allChannelChanges emitter would work here but is not used because its contract is specifically designed for setOptions (it unconditionally emits 'update' for all ATTACHEDs, even when the public emitter suppresses the event) and reusing it would couple these consumers to semantics that could change independently. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
_allChannelChanges gave the impression of emitting a sequence that had the channel's normal events as a subsequence, but it didn't — for example, RealtimePresence._ensureMyMembersPresent emits an 'update' on the channel but not on _allChannelChanges. Replace it with _attachedReceived, which emits a void 'attached' event unconditionally whenever an ATTACHED protocol message is received. setOptions (RTL16) now races _attachedReceived against the internalStateChanges emitter (added in 5c1d0ee) for ['detached', 'failed'], mapping directly to the two cases of RTL16a. The other property _allChannelChanges had — being unaffected by public off() calls — is already provided by internalStateChanges too. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
3d1535a to
216420c
Compare
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (1)
src/common/lib/client/realtimechannel.ts (1)
94-102: Naming inconsistency:internalStateChangesvs_attachedReceived
_attachedReceiveduses the underscore prefix convention for internal members, butinternalStateChangesdoes not — and the PR description referred to it as_internalStateChanges. SinceinternalStateChangesis only used withinRealtimeChannelitself and never accessed from tests, consider either:
- Renaming to
_internalStateChangesfor consistency, or- Marking it
private(it doesn't need to bepublic)🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/common/lib/client/realtimechannel.ts` around lines 94 - 102, Rename or make internalStateChanges consistent with private/internal naming: either rename the public field internalStateChanges to _internalStateChanges everywhere it's referenced in RealtimeChannel (including its declaration and all uses) or change its declaration to a private field (private internalStateChanges or private _internalStateChanges as you prefer), and update any constructors or methods that reference internalStateChanges or _attachedReceived to use the new identifier so the class's internal event emitter follows the underscore/private convention.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/common/lib/client/realtimechannel.ts`:
- Around line 142-143: The override of emit() references
this.internalStateChanges but internalStateChanges (and _attachedReceived) are
only initialized after calling this.setOptions(options) and after plugin
constructors receive this, so a plugin or setOptions path that calls this.emit()
can hit undefined; to fix, move the initialization of this._attachedReceived =
new EventEmitter(this.logger) and this.internalStateChanges = new
EventEmitter(this.logger) to run before calling this.setOptions(options) and
before invoking plugin constructors in the constructor, ensuring emit() always
sees a initialized internalStateChanges; update the constructor so the
EventEmitter initializations occur immediately after setting this.logger (or at
start of constructor) and before any calls to setOptions or plugin
instantiation.
- Around line 209-224: The setOptions promise can hang because the failure
listener registered in setOptions only listens for ['detached','failed'] and
misses the 'suspended' transition triggered by timeoutPendingState; update the
failure subscription in setOptions to include 'suspended' (i.e., call
this.internalStateChanges.once(['detached','failed','suspended'], onFailure)) so
onFailure runs, cleanup (this._attachedReceived.off/on removal) happens and the
promise rejects instead of stalling; keep using the existing onAttached,
onFailure, cleanup helpers as defined around _attachedReceived and
internalStateChanges.
---
Nitpick comments:
In `@src/common/lib/client/realtimechannel.ts`:
- Around line 94-102: Rename or make internalStateChanges consistent with
private/internal naming: either rename the public field internalStateChanges to
_internalStateChanges everywhere it's referenced in RealtimeChannel (including
its declaration and all uses) or change its declaration to a private field
(private internalStateChanges or private _internalStateChanges as you prefer),
and update any constructors or methods that reference internalStateChanges or
_attachedReceived to use the new identifier so the class's internal event
emitter follows the underscore/private convention.
The
attach()anddetach()methods usedthis.once()to listen for state changes on the publicEventEmitter. A user callingchannel.off()to remove their own listeners would also remove these internal listeners, causing the attach/detach promise to never resolve. This violates RTC10.Add a dedicated
_internalStateChangesEventEmitterthat is not affected by publicoff()calls, and use it forattachanddetach. The existing_allChannelChangesemitter would work here but is not used because its contract is specifically designed forsetOptions(it unconditionally emitsupdatefor all ATTACHEDs, even when the public emitter suppresses the event) and reusing it would couple these consumers to semantics that could change independently.Summary by CodeRabbit
Bug Fixes
Tests