diff --git a/packages/fxa-settings/src/lib/channels/pairing-channel.test.ts b/packages/fxa-settings/src/lib/channels/pairing-channel.test.ts index 8f6696b811f..bb50a993714 100644 --- a/packages/fxa-settings/src/lib/channels/pairing-channel.test.ts +++ b/packages/fxa-settings/src/lib/channels/pairing-channel.test.ts @@ -21,6 +21,7 @@ let mockChannel: { removeEventListener: jest.Mock; _channelId?: string; _channelKey?: Uint8Array; + _connection?: unknown; }; jest.mock( @@ -250,6 +251,23 @@ describe('PairingChannelClient', () => { await expect(client.close()).resolves.toBeUndefined(); expect(client.isConnected).toBe(false); }); + + // A socket error disconnects the channel without a matching `close` event, + // and the integrations tear down in response — so close() runs against a + // channel the package has already unwired. + it('skips the underlying close once the socket is gone', async () => { + await client.open(SERVER, CHAN, VALID_KEY); + getMockHandler('error')( + new CustomEvent('error', { detail: new Error('ws fail') }) + ); + mockChannel._connection = null; + + await expect(client.close()).resolves.toBeUndefined(); + + expect(mockChannel.close).not.toHaveBeenCalled(); + expect(mockChannel.removeEventListener).toHaveBeenCalled(); + expect(client.isConnected).toBe(false); + }); }); describe('incoming messages', () => { diff --git a/packages/fxa-settings/src/lib/channels/pairing-channel.ts b/packages/fxa-settings/src/lib/channels/pairing-channel.ts index 153b4343c62..5ef11204a0e 100644 --- a/packages/fxa-settings/src/lib/channels/pairing-channel.ts +++ b/packages/fxa-settings/src/lib/channels/pairing-channel.ts @@ -138,6 +138,11 @@ type PairingChannelSocket = { removeEventListener(type: string, listener: EventListener): void; _channelId?: string; _channelKey?: Uint8Array; + /** + * fxa-pairing-channel's own handle on the socket. It is nulled out when the + * socket goes away, and the package's `close()` dereferences it unguarded. + */ + _connection?: unknown; }; export class PairingChannelClient extends EventTarget { @@ -275,6 +280,16 @@ export class PairingChannelClient extends EventTarget { const ch = this.channel; this.channel = null; this.removeChannelListeners(ch); + + // An `error` from the socket leaves the channel disconnected without a + // matching `close`, so teardown still arrives here holding a dead channel. + // There is no socket left to shut down, and asking the package to close one + // throws. An absent `_connection` is not the same signal as a null one: + // only null means the package has torn the socket down. + if (ch._connection === null) { + return; + } + try { console.info('Closing channel ', ch._channelId) await ch.close();