From e17394802f145b1719e4e7266fbf79ea9336ff76 Mon Sep 17 00:00:00 2001 From: shijing xian Date: Mon, 17 Aug 2026 20:53:02 +0800 Subject: [PATCH 1/2] fix: bound the subscriber's ICE-restart window to the resume `triggerIceRestart` set `subscriber.restartingIce = true` on every resume, but only `setRemoteDescription` cleared it -- and the server re-offers the subscriber only when the resume actually moved the participant to a different node. After the far more common signal-only resume no offer arrives, so the flag stayed set for the lifetime of the transport and `addIceCandidate` queued every subsequent remote candidate instead of applying it, leaving the subscriber unable to adopt any new network path the server proposed. Media could then stall until some unrelated negotiation happened to flush the queue. The publisher never had this problem because it sets the same flag alongside an offer it will certainly receive an answer to. The subscriber's is speculative, so it needs an explicit close. Split the speculative flag out of `triggerIceRestart` into `beginSubscriberIceRestart`, so it sits next to the matching `finishSubscriberIceRestart` in the resume and the pairing is visible at the call site. `finishRestartingIce` clears the flag and applies whatever queued behind it; where the server did re-offer, the description has already cleared it and the call is a no-op. The resume wraps both the ICE restart and the reconnect wait in try/finally so every exit closes the window, including a publisher offer that throws before we ever wait. Candidates queued because no remote description exists yet stay queued -- there is still nothing to apply them against. This also drops the flag from the `updateConfiguration(config, iceRestart)` path, which called `triggerIceRestart` fire-and-forget with no close and so had no way to bound the window. Nothing currently passes `iceRestart`, so this removes a trap rather than changing behaviour. Co-Authored-By: Claude Opus 5 (1M context) --- .../bound-subscriber-ice-restart-window.md | 12 ++ src/room/PCTransport.test.ts | 118 +++++++++++++++++- src/room/PCTransport.ts | 37 ++++++ src/room/PCTransportManager.ts | 26 +++- src/room/RTCEngine.ts | 16 ++- 5 files changed, 203 insertions(+), 6 deletions(-) create mode 100644 .changeset/bound-subscriber-ice-restart-window.md diff --git a/.changeset/bound-subscriber-ice-restart-window.md b/.changeset/bound-subscriber-ice-restart-window.md new file mode 100644 index 0000000000..44211f5ca3 --- /dev/null +++ b/.changeset/bound-subscriber-ice-restart-window.md @@ -0,0 +1,12 @@ +--- +'livekit-client': patch +--- + +Fix the subscriber silently buffering remote ICE candidates after a resume + +`triggerIceRestart` marked the subscriber as awaiting a fresh ICE generation on every resume, +but only `setRemoteDescription` cleared it — and the server re-offers the subscriber only when +the resume moved the participant to a different node. After an ordinary signal-only resume no +offer arrives, so the flag stayed set for the lifetime of the transport and every subsequent +remote candidate was queued instead of applied, leaving the subscriber unable to adopt any new +network path the server proposed. diff --git a/src/room/PCTransport.test.ts b/src/room/PCTransport.test.ts index af12f5dbdc..7661076178 100644 --- a/src/room/PCTransport.test.ts +++ b/src/room/PCTransport.test.ts @@ -1,6 +1,6 @@ import { type MediaDescription, parse } from 'sdp-transform'; -import { describe, expect, it } from 'vitest'; -import { +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import PCTransport, { applyVideoStartBitrate, conformBundledCodecFmtp, ensureAudioNackAndStereo, @@ -390,3 +390,117 @@ a=extmap:3 ${ddExtensionURI}`); expect(ddOf(sdp.media, '1')).toBe(12); }); }); + +/** + * Minimal RTCPeerConnection stand-in: enough surface for PCTransport's constructor to attach + * its handlers, plus the candidate/description state these tests assert on. + */ +class StubPC { + remoteDescription: RTCSessionDescription | null = null; + + addIceCandidate = vi.fn(async (_c: RTCIceCandidateInit) => {}); + + onicecandidate: unknown = null; + + onicecandidateerror: unknown = null; + + oniceconnectionstatechange: unknown = null; + + onsignalingstatechange: unknown = null; + + onconnectionstatechange: unknown = null; + + ondatachannel: unknown = null; + + ontrack: unknown = null; +} + +describe('PCTransport.finishRestartingIce', () => { + let originalRTCPeerConnection: unknown; + let stub: StubPC; + + beforeEach(() => { + originalRTCPeerConnection = (globalThis as unknown as { RTCPeerConnection?: unknown }) + .RTCPeerConnection; + stub = new StubPC(); + (globalThis as unknown as { RTCPeerConnection: unknown }).RTCPeerConnection = function () { + return stub; + }; + }); + + afterEach(() => { + (globalThis as unknown as { RTCPeerConnection: unknown }).RTCPeerConnection = + originalRTCPeerConnection; + }); + + /** A transport whose remote description is already applied, as a resume finds it. */ + const negotiated = () => { + stub.remoteDescription = { type: 'offer', sdp: 'x' } as RTCSessionDescription; + return new PCTransport(); + }; + + const candidate = (port: number): RTCIceCandidateInit => ({ + candidate: `candidate:1 1 UDP 2130706431 192.168.1.1 ${port} typ host`, + sdpMid: '0', + sdpMLineIndex: 0, + }); + + it('applies candidates that queued while awaiting an offer that never came', async () => { + const transport = negotiated(); + + // The resume opens the window; candidates must queue rather than be applied to a + // generation that may be on its way out. + transport.restartingIce = true; + await transport.addIceCandidate(candidate(50000)); + expect(stub.addIceCandidate).not.toHaveBeenCalled(); + expect(transport.pendingCandidates).toHaveLength(1); + + // The server never re-offers -- the signal-only resume case -- so closing the window has + // to apply what queued behind it. + transport.finishRestartingIce(); + expect(transport.restartingIce).toBe(false); + expect(transport.pendingCandidates).toHaveLength(0); + expect(stub.addIceCandidate).toHaveBeenCalledTimes(1); + }); + + it('resumes applying later candidates directly', async () => { + const transport = negotiated(); + transport.restartingIce = true; + transport.finishRestartingIce(); + + // This is the user-visible symptom: with the window stuck open, every subsequent network + // path the server proposes is buffered and never used. + await transport.addIceCandidate(candidate(50001)); + expect(stub.addIceCandidate).toHaveBeenCalledTimes(1); + expect(transport.pendingCandidates).toHaveLength(0); + }); + + it('is idempotent and safe when no window is open', async () => { + const transport = negotiated(); + + // Never opened: must not disturb anything. + transport.finishRestartingIce(); + expect(transport.restartingIce).toBe(false); + + // Opened and closed twice: the server's offer may already have closed it. + transport.restartingIce = true; + transport.finishRestartingIce(); + transport.finishRestartingIce(); + expect(transport.restartingIce).toBe(false); + }); + + it('leaves candidates queued while there is still no remote description', async () => { + const transport = new PCTransport(); // no remote description applied + + // This candidate is queued because there is nothing to apply it against, not because of + // an ICE restart, so closing the window must not try to flush it. + await transport.addIceCandidate(candidate(50000)); + expect(transport.pendingCandidates).toHaveLength(1); + + transport.restartingIce = true; + transport.finishRestartingIce(); + + expect(transport.pendingCandidates).toHaveLength(1); + expect(stub.addIceCandidate).not.toHaveBeenCalled(); + }); +}); diff --git a/src/room/PCTransport.ts b/src/room/PCTransport.ts index 5908ff1b0f..27b096abf7 100644 --- a/src/room/PCTransport.ts +++ b/src/room/PCTransport.ts @@ -237,6 +237,43 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter this.pendingCandidates.push(candidate); } + /** + * Ends a `restartingIce` window without a new remote description having arrived, applying + * anything that queued behind it. + * + * {@link setRemoteDescription} already does this when a description does arrive, so this is + * a no-op in that case. It exists for the subscriber, whose `restartingIce` is set + * speculatively by {@link PCTransportManager.triggerIceRestart}: the server only re-offers + * the subscriber when the resume moved us to another node, so after an ordinary + * signal-only resume no description is coming and nothing would otherwise clear the flag. + * Left set, every subsequent remote candidate is buffered instead of applied and the + * transport can no longer adopt any new network path the server proposes. + */ + finishRestartingIce() { + if (!this.restartingIce) { + return; + } + this.restartingIce = false; + + // Anything queued belongs to the generation that is still current, so it can be applied + // exactly as it would have been before the restart began. With no remote description to + // apply them against there is still nothing to do, so leave them queued as + // `addIceCandidate` itself would. + if (!this._pc?.remoteDescription) { + return; + } + + if (this.pendingCandidates.length > 0) { + this.iceLog.debug('applying queued ICE candidates after ICE restart window closed', { + count: this.pendingCandidates.length, + }); + } + this.pendingCandidates.forEach((candidate) => { + this.pc.addIceCandidate(candidate); + }); + this.pendingCandidates = []; + } + async setRemoteDescription(sd: RTCSessionDescriptionInit, offerId: number): Promise { if ( sd.type === 'answer' && diff --git a/src/room/PCTransportManager.ts b/src/room/PCTransportManager.ts index 6982cdae9d..b2346a4390 100644 --- a/src/room/PCTransportManager.ts +++ b/src/room/PCTransportManager.ts @@ -162,11 +162,33 @@ export class PCTransportManager { this.updateState(); } - async triggerIceRestart() { - this.iceLog.warn('triggering ICE restart'); + /** + * Holds the subscriber in "awaiting a fresh ICE generation", so remote candidates queue + * rather than being applied to a generation that may be on its way out. + * + * MUST be paired with {@link finishSubscriberIceRestart} on every exit from the reconnect, + * including failures. Unlike the publisher — which sets the same flag alongside an offer it + * will certainly receive an answer to — this is speculative: the server only re-offers the + * subscriber when the reconnect moved us to a different node, so on the common signal-only + * resume no offer arrives and nothing else ever closes the window. + */ + beginSubscriberIceRestart() { if (this.subscriber) { this.subscriber.restartingIce = true; } + } + + /** + * Closes the window opened by {@link beginSubscriberIceRestart}, applying any candidates + * that queued while it was open. Idempotent, and a no-op when the server's offer already + * closed it. + */ + finishSubscriberIceRestart() { + this.subscriber?.finishRestartingIce(); + } + + async triggerIceRestart() { + this.iceLog.warn('triggering ICE restart'); // only restart publisher if it's needed if (this.needsPublisher) { await this.createAndSendPublisherOffer({ iceRestart: true }); diff --git a/src/room/RTCEngine.ts b/src/room/RTCEngine.ts index 8bafcae0f0..5bab9d5283 100644 --- a/src/room/RTCEngine.ts +++ b/src/room/RTCEngine.ts @@ -1466,9 +1466,21 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit throw new Error('simulated failure'); } - await this.pcManager.triggerIceRestart(); + // Hold the subscriber in "awaiting a fresh ICE generation" for the duration, so remote + // candidates queue rather than being applied to a generation that may be on its way out. + // The server only re-offers the subscriber when this resume moved us to another node, so + // on an ordinary signal-only resume no offer is coming and nothing else would close that + // window; left open, the subscriber silently stops adopting new network paths for the rest + // of the session. `finally` so that every exit closes it, including a publisher offer that + // throws before we ever wait. + this.pcManager.beginSubscriberIceRestart(); + try { + await this.pcManager.triggerIceRestart(); - await this.waitForPCReconnected(); + await this.waitForPCReconnected(); + } finally { + this.pcManager.finishSubscriberIceRestart(); + } // re-check signal connection state before setting engine as resumed if (this.client.currentState !== SignalConnectionState.CONNECTED) { From efdbd39c4f5ac9a9e8eb384a886c5ff75333ca6a Mon Sep 17 00:00:00 2001 From: shijing xian Date: Mon, 17 Aug 2026 22:15:21 +0800 Subject: [PATCH 2/2] fix: stop putting the subscriber into restartingIce on a reconnect Supersedes the bounding approach in this PR's first commit. Tracing the server shows the flag guards a sequence it cannot produce, so removal is both simpler and safer than managing the window. On a same-node resume the server ICE-restarts the subscriber via `clearLocalDescriptionSent`, which buffers its local candidates until after the offer has been sent. On a reconnect that lands on another node the participant starts in `MigrateStateInit`, where subscriber candidates are dropped outright, and that state is only left immediately before the offer is created -- at which point ICE gathering has not started. Either way the offer precedes the candidates it belongs to, so queueing them protects nothing. Against that, setting the flag cost two defects. It outlived the reconnect whenever the server did not re-offer -- the common signal-only resume -- leaving the subscriber queueing every later candidate for the rest of the session. And because it was set after the signalling link had already been reopened, an offer that arrived first would leave the window opening *behind* it, withholding the new generation's candidates during the very wait that decides whether the reconnect succeeded. The publisher keeps its own `restartingIce`: it is set alongside an offer that will certainly be answered, so `setRemoteDescription` always clears it. Co-Authored-By: Claude Opus 5 (1M context) --- .../bound-subscriber-ice-restart-window.md | 11 +- src/room/PCTransport.test.ts | 118 +----------------- src/room/PCTransport.ts | 37 ------ src/room/PCTransportManager.test.ts | 35 ++++++ src/room/PCTransportManager.ts | 32 ++--- src/room/RTCEngine.ts | 16 +-- 6 files changed, 54 insertions(+), 195 deletions(-) diff --git a/.changeset/bound-subscriber-ice-restart-window.md b/.changeset/bound-subscriber-ice-restart-window.md index 44211f5ca3..b9515fcf41 100644 --- a/.changeset/bound-subscriber-ice-restart-window.md +++ b/.changeset/bound-subscriber-ice-restart-window.md @@ -2,11 +2,12 @@ 'livekit-client': patch --- -Fix the subscriber silently buffering remote ICE candidates after a resume +Fix the subscriber silently buffering remote ICE candidates after a reconnect -`triggerIceRestart` marked the subscriber as awaiting a fresh ICE generation on every resume, -but only `setRemoteDescription` cleared it — and the server re-offers the subscriber only when -the resume moved the participant to a different node. After an ordinary signal-only resume no +`triggerIceRestart` put the subscriber into `restartingIce` on every reconnect, but only +`setRemoteDescription` clears that — and the server re-offers the subscriber only when the +reconnect moved the participant to a different node. After an ordinary signal-only resume no offer arrives, so the flag stayed set for the lifetime of the transport and every subsequent remote candidate was queued instead of applied, leaving the subscriber unable to adopt any new -network path the server proposed. +network path the server proposed. The subscriber no longer enters that state: the server does +not send candidates ahead of the offer that introduces them, so queueing them gains nothing. diff --git a/src/room/PCTransport.test.ts b/src/room/PCTransport.test.ts index 7661076178..af12f5dbdc 100644 --- a/src/room/PCTransport.test.ts +++ b/src/room/PCTransport.test.ts @@ -1,6 +1,6 @@ import { type MediaDescription, parse } from 'sdp-transform'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import PCTransport, { +import { describe, expect, it } from 'vitest'; +import { applyVideoStartBitrate, conformBundledCodecFmtp, ensureAudioNackAndStereo, @@ -390,117 +390,3 @@ a=extmap:3 ${ddExtensionURI}`); expect(ddOf(sdp.media, '1')).toBe(12); }); }); - -/** - * Minimal RTCPeerConnection stand-in: enough surface for PCTransport's constructor to attach - * its handlers, plus the candidate/description state these tests assert on. - */ -class StubPC { - remoteDescription: RTCSessionDescription | null = null; - - addIceCandidate = vi.fn(async (_c: RTCIceCandidateInit) => {}); - - onicecandidate: unknown = null; - - onicecandidateerror: unknown = null; - - oniceconnectionstatechange: unknown = null; - - onsignalingstatechange: unknown = null; - - onconnectionstatechange: unknown = null; - - ondatachannel: unknown = null; - - ontrack: unknown = null; -} - -describe('PCTransport.finishRestartingIce', () => { - let originalRTCPeerConnection: unknown; - let stub: StubPC; - - beforeEach(() => { - originalRTCPeerConnection = (globalThis as unknown as { RTCPeerConnection?: unknown }) - .RTCPeerConnection; - stub = new StubPC(); - (globalThis as unknown as { RTCPeerConnection: unknown }).RTCPeerConnection = function () { - return stub; - }; - }); - - afterEach(() => { - (globalThis as unknown as { RTCPeerConnection: unknown }).RTCPeerConnection = - originalRTCPeerConnection; - }); - - /** A transport whose remote description is already applied, as a resume finds it. */ - const negotiated = () => { - stub.remoteDescription = { type: 'offer', sdp: 'x' } as RTCSessionDescription; - return new PCTransport(); - }; - - const candidate = (port: number): RTCIceCandidateInit => ({ - candidate: `candidate:1 1 UDP 2130706431 192.168.1.1 ${port} typ host`, - sdpMid: '0', - sdpMLineIndex: 0, - }); - - it('applies candidates that queued while awaiting an offer that never came', async () => { - const transport = negotiated(); - - // The resume opens the window; candidates must queue rather than be applied to a - // generation that may be on its way out. - transport.restartingIce = true; - await transport.addIceCandidate(candidate(50000)); - expect(stub.addIceCandidate).not.toHaveBeenCalled(); - expect(transport.pendingCandidates).toHaveLength(1); - - // The server never re-offers -- the signal-only resume case -- so closing the window has - // to apply what queued behind it. - transport.finishRestartingIce(); - expect(transport.restartingIce).toBe(false); - expect(transport.pendingCandidates).toHaveLength(0); - expect(stub.addIceCandidate).toHaveBeenCalledTimes(1); - }); - - it('resumes applying later candidates directly', async () => { - const transport = negotiated(); - transport.restartingIce = true; - transport.finishRestartingIce(); - - // This is the user-visible symptom: with the window stuck open, every subsequent network - // path the server proposes is buffered and never used. - await transport.addIceCandidate(candidate(50001)); - expect(stub.addIceCandidate).toHaveBeenCalledTimes(1); - expect(transport.pendingCandidates).toHaveLength(0); - }); - - it('is idempotent and safe when no window is open', async () => { - const transport = negotiated(); - - // Never opened: must not disturb anything. - transport.finishRestartingIce(); - expect(transport.restartingIce).toBe(false); - - // Opened and closed twice: the server's offer may already have closed it. - transport.restartingIce = true; - transport.finishRestartingIce(); - transport.finishRestartingIce(); - expect(transport.restartingIce).toBe(false); - }); - - it('leaves candidates queued while there is still no remote description', async () => { - const transport = new PCTransport(); // no remote description applied - - // This candidate is queued because there is nothing to apply it against, not because of - // an ICE restart, so closing the window must not try to flush it. - await transport.addIceCandidate(candidate(50000)); - expect(transport.pendingCandidates).toHaveLength(1); - - transport.restartingIce = true; - transport.finishRestartingIce(); - - expect(transport.pendingCandidates).toHaveLength(1); - expect(stub.addIceCandidate).not.toHaveBeenCalled(); - }); -}); diff --git a/src/room/PCTransport.ts b/src/room/PCTransport.ts index 27b096abf7..5908ff1b0f 100644 --- a/src/room/PCTransport.ts +++ b/src/room/PCTransport.ts @@ -237,43 +237,6 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter this.pendingCandidates.push(candidate); } - /** - * Ends a `restartingIce` window without a new remote description having arrived, applying - * anything that queued behind it. - * - * {@link setRemoteDescription} already does this when a description does arrive, so this is - * a no-op in that case. It exists for the subscriber, whose `restartingIce` is set - * speculatively by {@link PCTransportManager.triggerIceRestart}: the server only re-offers - * the subscriber when the resume moved us to another node, so after an ordinary - * signal-only resume no description is coming and nothing would otherwise clear the flag. - * Left set, every subsequent remote candidate is buffered instead of applied and the - * transport can no longer adopt any new network path the server proposes. - */ - finishRestartingIce() { - if (!this.restartingIce) { - return; - } - this.restartingIce = false; - - // Anything queued belongs to the generation that is still current, so it can be applied - // exactly as it would have been before the restart began. With no remote description to - // apply them against there is still nothing to do, so leave them queued as - // `addIceCandidate` itself would. - if (!this._pc?.remoteDescription) { - return; - } - - if (this.pendingCandidates.length > 0) { - this.iceLog.debug('applying queued ICE candidates after ICE restart window closed', { - count: this.pendingCandidates.length, - }); - } - this.pendingCandidates.forEach((candidate) => { - this.pc.addIceCandidate(candidate); - }); - this.pendingCandidates = []; - } - async setRemoteDescription(sd: RTCSessionDescriptionInit, offerId: number): Promise { if ( sd.type === 'answer' && diff --git a/src/room/PCTransportManager.test.ts b/src/room/PCTransportManager.test.ts index 5afc291ed4..99da0cd2d6 100644 --- a/src/room/PCTransportManager.test.ts +++ b/src/room/PCTransportManager.test.ts @@ -279,3 +279,38 @@ describe('PCTransportManager.negotiate', () => { await expect(p).resolves.toBeUndefined(); }); }); + +describe('PCTransportManager.triggerIceRestart', () => { + let originalRTCPeerConnection: unknown; + + beforeEach(() => { + originalRTCPeerConnection = (globalThis as unknown as { RTCPeerConnection?: unknown }) + .RTCPeerConnection; + (globalThis as unknown as { RTCPeerConnection: unknown }).RTCPeerConnection = StubPC; + }); + + afterEach(() => { + (globalThis as unknown as { RTCPeerConnection: unknown }).RTCPeerConnection = + originalRTCPeerConnection; + }); + + /** + * The subscriber must keep applying remote candidates across a reconnect. + * + * Putting it into `restartingIce` would queue them until a new remote description arrives — + * but the server only re-offers the subscriber when the reconnect moved us to another node, + * so on an ordinary signal-only resume nothing would ever flush that queue, and the + * transport would stop adopting new network paths for the rest of the session. The server + * also never sends candidates ahead of the offer that introduces them, so queueing buys + * nothing in exchange. + */ + it('does not stop the subscriber applying remote candidates', async () => { + const manager = new PCTransportManager('subscriber-primary', {}); + const publisher = new FakePublisher(); + (manager as unknown as { publisher: FakePublisher }).publisher = publisher; + + await manager.triggerIceRestart(); + + expect(manager.subscriber?.restartingIce).toBe(false); + }); +}); diff --git a/src/room/PCTransportManager.ts b/src/room/PCTransportManager.ts index b2346a4390..43f3d2af34 100644 --- a/src/room/PCTransportManager.ts +++ b/src/room/PCTransportManager.ts @@ -163,33 +163,19 @@ export class PCTransportManager { } /** - * Holds the subscriber in "awaiting a fresh ICE generation", so remote candidates queue - * rather than being applied to a generation that may be on its way out. + * Restarts ICE on the transports that need it. Only the publisher: the server restarts the + * subscriber's ICE itself and follows with a fresh offer. * - * MUST be paired with {@link finishSubscriberIceRestart} on every exit from the reconnect, - * including failures. Unlike the publisher — which sets the same flag alongside an offer it - * will certainly receive an answer to — this is speculative: the server only re-offers the - * subscriber when the reconnect moved us to a different node, so on the common signal-only - * resume no offer arrives and nothing else ever closes the window. + * The subscriber deliberately does NOT enter `restartingIce` here. Queueing its remote + * candidates would guard against candidates for a new generation arriving before the offer + * that introduces it, but the server does not send them in that order -- on a same-node + * resume it buffers them until the offer has gone out, and on a reconnect that lands on + * another node it withholds subscriber candidates until immediately before creating the + * offer. Setting the flag only risks withholding candidates during the window that decides + * whether the reconnect succeeded. */ - beginSubscriberIceRestart() { - if (this.subscriber) { - this.subscriber.restartingIce = true; - } - } - - /** - * Closes the window opened by {@link beginSubscriberIceRestart}, applying any candidates - * that queued while it was open. Idempotent, and a no-op when the server's offer already - * closed it. - */ - finishSubscriberIceRestart() { - this.subscriber?.finishRestartingIce(); - } - async triggerIceRestart() { this.iceLog.warn('triggering ICE restart'); - // only restart publisher if it's needed if (this.needsPublisher) { await this.createAndSendPublisherOffer({ iceRestart: true }); } diff --git a/src/room/RTCEngine.ts b/src/room/RTCEngine.ts index 5bab9d5283..8bafcae0f0 100644 --- a/src/room/RTCEngine.ts +++ b/src/room/RTCEngine.ts @@ -1466,21 +1466,9 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit throw new Error('simulated failure'); } - // Hold the subscriber in "awaiting a fresh ICE generation" for the duration, so remote - // candidates queue rather than being applied to a generation that may be on its way out. - // The server only re-offers the subscriber when this resume moved us to another node, so - // on an ordinary signal-only resume no offer is coming and nothing else would close that - // window; left open, the subscriber silently stops adopting new network paths for the rest - // of the session. `finally` so that every exit closes it, including a publisher offer that - // throws before we ever wait. - this.pcManager.beginSubscriberIceRestart(); - try { - await this.pcManager.triggerIceRestart(); + await this.pcManager.triggerIceRestart(); - await this.waitForPCReconnected(); - } finally { - this.pcManager.finishSubscriberIceRestart(); - } + await this.waitForPCReconnected(); // re-check signal connection state before setting engine as resumed if (this.client.currentState !== SignalConnectionState.CONNECTED) {