Skip to content

Commit ccf7217

Browse files
Add side effects of more connection states for DETACHING channels
Implements the changes of 34e53e5 from [1]. [1] ably/specification#318
1 parent 6e1317b commit ccf7217

File tree

2 files changed

+171
-11
lines changed

2 files changed

+171
-11
lines changed

src/common/lib/client/baserealtime.ts

Lines changed: 32 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -172,19 +172,40 @@ class Channels extends EventEmitter {
172172
* events) imply connection state changes for any channel which is either
173173
* attached, pending, or will attempt to become attached in the future */
174174
propagateConnectionInterruption(connectionState: string, reason: ErrorInfo) {
175-
const connectionStateToChannelState: Record<string, API.ChannelState> = {
176-
closing: 'detached',
177-
closed: 'detached',
178-
failed: 'failed',
179-
suspended: 'suspended',
180-
};
181-
const fromChannelStates = ['attaching', 'attached', 'detaching', 'suspended'];
182-
const toChannelState = connectionStateToChannelState[connectionState];
183-
184175
for (const channelId in this.all) {
185176
const channel = this.all[channelId];
186-
if (fromChannelStates.includes(channel.state)) {
187-
channel.notifyState(toChannelState, reason);
177+
178+
let toChannelState: API.ChannelState | null = null;
179+
let channelErrorReason: ErrorInfo | null = reason;
180+
181+
switch (connectionState) {
182+
case 'failed':
183+
if (['attaching', 'attached', 'detaching'].includes(channel.state)) {
184+
// RTL3a, RTL3g
185+
toChannelState = 'failed';
186+
}
187+
break;
188+
case 'closed':
189+
if (['attaching', 'attached', 'detaching'].includes(channel.state)) {
190+
// RTL3b, RTL3h
191+
toChannelState = 'detached';
192+
}
193+
break;
194+
case 'suspended':
195+
if (['attaching', 'attached'].includes(channel.state)) {
196+
// RTL3c
197+
toChannelState = 'suspended';
198+
} else if (channel.state === 'detaching') {
199+
// RTL3h
200+
toChannelState = 'detached';
201+
// Don't propagate the error
202+
channelErrorReason = null;
203+
}
204+
break;
205+
}
206+
207+
if (toChannelState !== null) {
208+
channel.notifyState(toChannelState, channelErrorReason);
188209
}
189210
}
190211
}

test/realtime/channel.test.js

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1583,6 +1583,145 @@ define(['ably', 'shared_helper', 'async', 'chai'], function (Ably, Helper, async
15831583
);
15841584
});
15851585

1586+
/** @spec RTL3g **/
1587+
it('detaching_channel_when_connection_enters_failed', async function () {
1588+
// Given: A channel in the DETACHING state
1589+
const helper = this.test.helper;
1590+
const realtime = helper.AblyRealtime({ transports: [helper.bestTransport] });
1591+
const channel = realtime.channels.get('detached_channel_when_connection_enters_failed');
1592+
1593+
await channel.attach();
1594+
1595+
helper.recordPrivateApi('call.connectionManager.activeProtocol.getTransport');
1596+
const transport = realtime.connection.connectionManager.activeProtocol.getTransport();
1597+
const onProtocolMessageOriginal = transport.onProtocolMessage;
1598+
1599+
helper.recordPrivateApi('replace.transport.onProtocolMessage');
1600+
transport.onProtocolMessage = function (msg) {
1601+
if (msg.action === 13) {
1602+
// Drop the incoming DETACHED so that the channel stays in DETACHING
1603+
return;
1604+
}
1605+
1606+
helper.recordPrivateApi('call.transport.onProtocolMessage');
1607+
onProtocolMessageOriginal.call(this, msg);
1608+
};
1609+
1610+
const channelDetachPromise = channel.detach();
1611+
1612+
expect(channel.state).to.equal('detaching');
1613+
1614+
// When: The connection enters FAILED
1615+
const channelFailedPromise = channel.whenState('failed');
1616+
1617+
// Inject a connection-level ERROR to make connection enter FAILED per RTN15i
1618+
helper.recordPrivateApi('call.makeProtocolMessageFromDeserialized');
1619+
helper.recordPrivateApi('call.transport.onProtocolMessage');
1620+
transport.onProtocolMessage(
1621+
createPM({ action: 9, error: { code: 40000, statusCode: 400, message: 'Some error' } }),
1622+
);
1623+
1624+
// Then: The channel transitions to FAILED and the call to `detach()` fails
1625+
await channelFailedPromise;
1626+
1627+
try {
1628+
await channelDetachPromise;
1629+
expect.fail('Expected channel.detach() to throw');
1630+
} catch {
1631+
expect(channel.errorReason.code).to.equal(40000);
1632+
expect(channel.errorReason.statusCode).to.equal(400);
1633+
expect(channel.errorReason.message).to.equal('Some error');
1634+
}
1635+
1636+
// Teardown
1637+
await helper.closeAndFinishAsync(realtime);
1638+
});
1639+
1640+
/** @specpartial RTL3h - Tests the CLOSED case **/
1641+
it('detaching_channel_when_connection_enters_closed', async function () {
1642+
// Given: A channel in the DETACHING state
1643+
const helper = this.test.helper;
1644+
const realtime = helper.AblyRealtime({ transports: [helper.bestTransport] });
1645+
const channel = realtime.channels.get('detached_channel_when_connection_enters_failed');
1646+
1647+
await channel.attach();
1648+
1649+
helper.recordPrivateApi('call.connectionManager.activeProtocol.getTransport');
1650+
const transport = realtime.connection.connectionManager.activeProtocol.getTransport();
1651+
const onProtocolMessageOriginal = transport.onProtocolMessage;
1652+
1653+
helper.recordPrivateApi('replace.transport.onProtocolMessage');
1654+
transport.onProtocolMessage = function (msg) {
1655+
if (msg.action === 13) {
1656+
// Drop the incoming DETACHED so that the channel stays in DETACHING
1657+
return;
1658+
}
1659+
1660+
helper.recordPrivateApi('call.transport.onProtocolMessage');
1661+
onProtocolMessageOriginal.call(this, msg);
1662+
};
1663+
1664+
const channelDetachPromise = channel.detach();
1665+
1666+
expect(channel.state).to.equal('detaching');
1667+
1668+
// When: The connection enters CLOSED
1669+
const channelDetachedPromise = channel.whenState('detached');
1670+
1671+
realtime.close();
1672+
1673+
// Then: The channel transitions to DETACHED and the call to `detach()` succeeds
1674+
await channelDetachedPromise;
1675+
await channelDetachPromise;
1676+
1677+
// Teardown
1678+
await helper.closeAndFinishAsync(realtime);
1679+
});
1680+
1681+
/** @specpartial RTL3h - Tests the SUSPENDED case **/
1682+
it('detaching_channel_when_connection_enters_suspended', async function () {
1683+
// Given: A channel in the DETACHING state
1684+
const helper = this.test.helper;
1685+
const realtime = helper.AblyRealtime({ transports: [helper.bestTransport] });
1686+
const channel = realtime.channels.get('detached_channel_when_connection_enters_failed');
1687+
1688+
await channel.attach();
1689+
1690+
helper.recordPrivateApi('call.connectionManager.activeProtocol.getTransport');
1691+
const transport = realtime.connection.connectionManager.activeProtocol.getTransport();
1692+
const onProtocolMessageOriginal = transport.onProtocolMessage;
1693+
1694+
helper.recordPrivateApi('replace.transport.onProtocolMessage');
1695+
transport.onProtocolMessage = function (msg) {
1696+
if (msg.action === 13) {
1697+
// Drop the incoming DETACHED so that the channel stays in DETACHING
1698+
return;
1699+
}
1700+
1701+
helper.recordPrivateApi('call.transport.onProtocolMessage');
1702+
onProtocolMessageOriginal.call(this, msg);
1703+
};
1704+
1705+
const channelDetachPromise = channel.detach();
1706+
1707+
expect(channel.state).to.equal('detaching');
1708+
1709+
// When: The connection enters SUSPENDED
1710+
const channelDetachedPromise = channel.whenState('detached');
1711+
1712+
await new Promise((resolve) => {
1713+
helper.becomeSuspended(realtime, resolve);
1714+
});
1715+
1716+
// Then: The channel transitions to DETACHED and the call to `detach()` succeeds
1717+
await channelDetachedPromise;
1718+
expect(channel.errorReason).to.be.null;
1719+
await channelDetachPromise;
1720+
1721+
// Teardown
1722+
await helper.closeAndFinishAsync(realtime);
1723+
});
1724+
15861725
/** @spec RTL5i */
15871726
it('attached_while_detaching', function (done) {
15881727
var helper = this.test.helper,

0 commit comments

Comments
 (0)