Skip to content

Commit 0beac8e

Browse files
Merge pull request #2167 from ably/fix-rtc10-channel-off
Fix RTC10 violation: `channel.off()` could break attach and detach
2 parents 2a62f01 + 216420c commit 0beac8e

File tree

3 files changed

+89
-33
lines changed

3 files changed

+89
-33
lines changed

src/common/lib/client/realtimechannel.ts

Lines changed: 40 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import ChannelStateChange from './channelstatechange';
99
import ErrorInfo, { PartialErrorInfo } from '../types/errorinfo';
1010
import * as API from '../../../../ably';
1111
import ConnectionManager from '../transport/connectionmanager';
12-
import ConnectionStateChange from './connectionstatechange';
1312
import { StandardCallback } from '../../types/utils';
1413
import BaseRealtime from './baserealtime';
1514
import { ChannelOptions } from '../../types/channel';
@@ -87,7 +86,20 @@ class RealtimeChannel extends EventEmitter {
8786
protocolMessageChannelSerial?: string | null;
8887
decodeFailureRecoveryInProgress: null | boolean;
8988
};
90-
_allChannelChanges: EventEmitter;
89+
/**
90+
* Emits an 'attached' event (with no payload) whenever an ATTACHED protocol
91+
* message is received from the server. Used by setOptions (RTL16a) to know
92+
* when the server has confirmed new channel options.
93+
*/
94+
_attachedReceived: EventEmitter;
95+
/**
96+
* Internal event emitter for channel state changes, not affected by public
97+
* off() calls. Exists to satisfy RTC10: the client library should never
98+
* register internal listeners with the public EventEmitter in such a way
99+
* that a user calling off() would result in the library not working as
100+
* expected.
101+
*/
102+
internalStateChanges: EventEmitter;
91103
params?: Record<string, any>;
92104
modes: API.ChannelMode[] | undefined;
93105
stateTimer?: number | NodeJS.Timeout | null;
@@ -127,9 +139,8 @@ class RealtimeChannel extends EventEmitter {
127139
protocolMessageChannelSerial: null,
128140
decodeFailureRecoveryInProgress: null,
129141
};
130-
/* Only differences between this and the public event emitter is that this emits an
131-
* update event for all ATTACHEDs, whether resumed or not */
132-
this._allChannelChanges = new EventEmitter(this.logger);
142+
this._attachedReceived = new EventEmitter(this.logger);
143+
this.internalStateChanges = new EventEmitter(this.logger);
133144

134145
if (client.options.plugins?.Push) {
135146
this._push = new client.options.plugins.Push.PushChannel(this);
@@ -155,6 +166,12 @@ class RealtimeChannel extends EventEmitter {
155166
return this._object; // RTL27a
156167
}
157168

169+
// Override of EventEmitter method
170+
emit(event: string, ...args: unknown[]) {
171+
super.emit(event, ...args);
172+
this.internalStateChanges.emit(event, ...args);
173+
}
174+
158175
invalidStateError(): ErrorInfo {
159176
return new ErrorInfo(
160177
'Channel operation failed as channel state is ' + this.state,
@@ -189,23 +206,21 @@ class RealtimeChannel extends EventEmitter {
189206
* rejecting messages until we have confirmation that the options have changed,
190207
* which would unnecessarily lose message continuity. */
191208
this.attachImpl();
192-
return new Promise((resolve, reject) => {
193-
// Ignore 'attaching' -- could be just due to to a resume & reattach, should not
194-
// call back setOptions until we're definitely attached with the new options (or
195-
// else in a terminal state)
196-
this._allChannelChanges.once(
197-
['attached', 'update', 'detached', 'failed'],
198-
function (this: { event: string }, stateChange: ConnectionStateChange) {
199-
switch (this.event) {
200-
case 'update':
201-
case 'attached':
202-
resolve();
203-
break;
204-
default:
205-
reject(stateChange.reason);
206-
}
207-
},
208-
);
209+
return new Promise<void>((resolve, reject) => {
210+
const cleanup = () => {
211+
this._attachedReceived.off(onAttached);
212+
this.internalStateChanges.off(onFailure);
213+
};
214+
const onAttached = () => {
215+
cleanup();
216+
resolve();
217+
};
218+
const onFailure = (stateChange: ChannelStateChange) => {
219+
cleanup();
220+
reject(stateChange.reason);
221+
};
222+
this._attachedReceived.once('attached', onAttached);
223+
this.internalStateChanges.once(['detached', 'failed'], onFailure);
209224
});
210225
}
211226
}
@@ -344,7 +359,7 @@ class RealtimeChannel extends EventEmitter {
344359
this.requestState('attaching', attachReason);
345360
}
346361

347-
this.once(function (this: { event: string }, stateChange: ChannelStateChange) {
362+
this.internalStateChanges.once(function (this: { event: string }, stateChange: ChannelStateChange) {
348363
switch (this.event) {
349364
case 'attached':
350365
callback?.(null, stateChange);
@@ -409,7 +424,7 @@ class RealtimeChannel extends EventEmitter {
409424
// eslint-disable-next-line no-fallthrough
410425
case 'detaching':
411426
return new Promise((resolve, reject) => {
412-
this.once(function (this: { event: string }, stateChange: ChannelStateChange) {
427+
this.internalStateChanges.once(function (this: { event: string }, stateChange: ChannelStateChange) {
413428
switch (this.event) {
414429
case 'detached':
415430
resolve();
@@ -556,6 +571,7 @@ class RealtimeChannel extends EventEmitter {
556571
const hasPresence = message.hasFlag('HAS_PRESENCE');
557572
const hasBacklog = message.hasFlag('HAS_BACKLOG');
558573
const hasObjects = message.hasFlag('HAS_OBJECTS');
574+
this._attachedReceived.emit('attached');
559575
if (this.state === 'attached') {
560576
if (!resumed) {
561577
// we have lost continuity.
@@ -569,7 +585,6 @@ class RealtimeChannel extends EventEmitter {
569585
}
570586
}
571587
const change = new ChannelStateChange(this.state, this.state, resumed, hasBacklog, message.error);
572-
this._allChannelChanges.emit('update', change);
573588
if (!resumed || this.channelOptions.updateOnAttached) {
574589
this.emit('update', change);
575590
}
@@ -848,7 +863,6 @@ class RealtimeChannel extends EventEmitter {
848863
}
849864

850865
this.state = state;
851-
this._allChannelChanges.emit(state, change);
852866
this.emit(state, change);
853867
}
854868

test/common/modules/private_api_recorder.js

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,8 +67,7 @@ define(['test/support/output_directory_paths'], function (outputDirectoryPaths)
6767
'call.transport.send',
6868
'delete.auth.authOptions.requestHeaders',
6969
'deserialize.recoveryKey',
70-
'listen.channel._allChannelChanges.attached',
71-
'listen.channel._allChannelChanges.update',
70+
'listen.channel._attachedReceived.attached',
7271
'listen.connectionManager.connectiondetails',
7372
'listen.connectionManager.transport.active',
7473
'listen.connectionManager.transport.pending',

test/realtime/channel.test.js

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -753,8 +753,8 @@ define(['ably', 'shared_helper', 'async', 'chai'], function (Ably, Helper, async
753753
},
754754
function (cb) {
755755
var channelUpdated = false;
756-
helper.recordPrivateApi('listen.channel._allChannelChanges.update');
757-
channel._allChannelChanges.on(['update'], function () {
756+
helper.recordPrivateApi('listen.channel._attachedReceived.attached');
757+
channel._attachedReceived.on('attached', function () {
758758
channelUpdated = true;
759759
});
760760

@@ -778,9 +778,8 @@ define(['ably', 'shared_helper', 'async', 'chai'], function (Ably, Helper, async
778778
},
779779
function (cb) {
780780
var channelUpdated = false;
781-
helper.recordPrivateApi('listen.channel._allChannelChanges.update');
782-
helper.recordPrivateApi('listen.channel._allChannelChanges.attached');
783-
channel._allChannelChanges.on(['attached', 'update'], function () {
781+
helper.recordPrivateApi('listen.channel._attachedReceived.attached');
782+
channel._attachedReceived.on('attached', function () {
784783
channelUpdated = true;
785784
});
786785

@@ -2016,5 +2015,49 @@ define(['ably', 'shared_helper', 'async', 'chai'], function (Ably, Helper, async
20162015

20172016
await helper.closeAndFinishAsync(realtime);
20182017
});
2018+
2019+
/**
2020+
* Regression test: calling channel.off() while attaching should not
2021+
* prevent the attach() promise from resolving.
2022+
*
2023+
* @specpartial RTC10
2024+
*/
2025+
it('attach resolves even if channel.off() is called while attaching', async function () {
2026+
const helper = this.test.helper;
2027+
const realtime = helper.AblyRealtime();
2028+
2029+
await helper.monitorConnectionThenCloseAndFinishAsync(async () => {
2030+
const channel = realtime.channels.get('attach_off_regression');
2031+
2032+
const attachPromise = channel.attach();
2033+
expect(channel.state).to.equal('attaching');
2034+
channel.off();
2035+
2036+
await attachPromise;
2037+
}, realtime);
2038+
});
2039+
2040+
/**
2041+
* Regression test: calling channel.off() while detaching should not
2042+
* prevent the detach() promise from resolving.
2043+
*
2044+
* @specpartial RTC10
2045+
*/
2046+
it('detach resolves even if channel.off() is called while detaching', async function () {
2047+
const helper = this.test.helper;
2048+
const realtime = helper.AblyRealtime();
2049+
2050+
await helper.monitorConnectionThenCloseAndFinishAsync(async () => {
2051+
const channel = realtime.channels.get('detach_off_regression');
2052+
2053+
await channel.attach();
2054+
2055+
const detachPromise = channel.detach();
2056+
expect(channel.state).to.equal('detaching');
2057+
channel.off();
2058+
2059+
await detachPromise;
2060+
}, realtime);
2061+
});
20192062
});
20202063
});

0 commit comments

Comments
 (0)