Skip to content

Commit 06e5f58

Browse files
authored
fix(client): harden SFU reconnection and ICE-restart recovery (#2285)
### 💡 Overview Four reconnection-robustness changes for the core client. **1. Signal-close revival hardening (`StreamSfuClient`, `Call`).** A wedged signal WebSocket can be detected as dead by two independent sources (the health watchdog and the transport `close` event), which on a stuck socket can arrive seconds apart. Revival is now driven by a one-shot latch so it fires at most once per client, triggers immediately on an unhealthy close instead of waiting for the (possibly long-delayed) `close` event, and ignores closes from a superseded client so a stale socket cannot drive a reconnect after a new client has swapped in. **2. Subscriber negotiation-failure recovery (`Subscriber`).** `Subscriber.negotiate` reset `isIceRestarting` only as the last statement of its happy path, so a negotiation failure left the flag stuck at `true` and disabled all future automatic ICE-restart / reconnect handling on the subscriber. Negotiation is now wrapped in try/catch/finally so the flag always clears; on failure the remote description is rolled back when the peer connection is still awaiting an answer (`have-remote-offer`), the rollback is itself guarded so it can never mask the original error, the failure is traced, and the subscriber escalates via `tryRestartIce` so a failed restart falls through to a reconnect instead of being silently dropped. **3. Revert the stability-gated publisher fast-reconnect skip (`Call`, `BasePeerConnection`).** `main` skips the publisher's ICE restart on a fast reconnect when the publisher PC still looks stable (`isStable()`), to avoid SDP/ICE churn on signal-WS-only drops. In practice that skip can strand a cratered send-side bandwidth estimate: a "stable" publisher PC never restarts ICE, so the network-route-change BWE reset never fires and the encoder stays bitrate-starved after a handover. This restores the earlier behavior (on a fast reconnect the publisher's ICE is restarted unconditionally whenever it is publishing) and removes the now-unused `isStable()` helper. **4. Generation-aware ICE trickle buffer (`IceTrickleBuffer`, `BasePeerConnection`, `Subscriber`).** After an ICE restart the SFU rotates its ICE credentials and trickles candidates for the new generation, but `IceTrickleBuffer` (an unbounded `ReplaySubject`) replayed every buffered candidate on each renegotiation, re-adding superseded-generation candidates against the current generation and contributing to stuck (DTLS-`connecting`) subscribers. The buffer is now generation-aware: each peer connection declares the active generation via `updateActiveGeneration` (derived from the applied remote description's `ice-ufrag`), and the candidate streams emit only the current generation, hold not-yet-applied (future) generations until they become active, and drop superseded ones; candidates with no detectable generation fail open. On a failed subscriber negotiation the buffer is restored to the rolled-back generation. ### 📝 Implementation notes - `StreamSfuClient`: `handleWebSocketClose` becomes `notifySignalClose`, guarded by a `signalClosed` one-shot latch; `close()` notifies on a non-clean close without waiting for the transport event. - `Call.handleSfuSignalClose` ignores closes from any client that is no longer the active `sfuClient`. - `Call` fast reconnect: back to `restoreICE(sfuClient, { includeSubscriber: false })` (publisher restart governed by `includePublisher`'s `true` default); the `isStable()`-gated skip is gone. - `Subscriber.negotiate` wrapped in try/catch/finally; guarded rollback on `have-remote-offer`; `subscriber.negotiationFailed` trace; `tryRestartIce` escalation on failure. - `BasePeerConnection.isStable()` removed (no remaining callers). - `IceTrickleBuffer`: per-peer `ReplaySubject`s replaced by per-peer `CandidateGenerationBuffer`s (mutable store + live `Subject`, exposed as `subscriber` / `publisher`); `updateActiveGeneration(peerType, sdp)` sets the active generation from the remote `ice-ufrag` and evicts superseded candidates. - `BasePeerConnection.addTrickledIceCandidates` declares the active generation from `pc.remoteDescription`, then subscribes; the candidate-string / SDP ufrag helpers (`getCandidateUfrag`, `parseIceUfrag`, `toJSON`) are consolidated in `rtc/helpers/iceCandiates.ts`. - `Subscriber.negotiate` snapshots the committed generation and restores it in the buffer when a negotiation rolls back. - Tests: `StreamSfuClient` revival, `Call` superseded-client guard, `Subscriber` success / failure / rollback / rollback-failure branches; removed the `isStable()` and FAST-skip tests. Added `IceTrickleBuffer` generation emit/hold/drop/fail-open, `iceCandiates` helper, and subscriber trickle wiring + rollback-restore coverage. 🎫 Ticket: https://linear.app/stream/issue/REACT-1016/improve-bandwidth-estimation-ramp-up-and-harden-ice-restarts
1 parent c5803fc commit 06e5f58

15 files changed

Lines changed: 988 additions & 222 deletions

packages/client/src/Call.ts

Lines changed: 12 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1333,23 +1333,9 @@ export class Call {
13331333
// when performing fast reconnect, or when we reuse the same SFU client,
13341334
// (ws remained healthy), we just need to restore the ICE connection
13351335
if (performingFastReconnect) {
1336-
// The SFU automatically issues an ICE restart on the subscriber,
1337-
// so we only need to decide about the publisher. If the publisher's
1338-
// peer connection is still stable (ICE still connected end-to-end),
1339-
// the signal WebSocket drop was the only problem — the new WS alone
1340-
// is enough, and restarting ICE would add unnecessary SDP/ICE churn.
1341-
const publisherIsStable = this.publisher?.isStable() ?? true;
1342-
const includePublisher =
1343-
!!this.publisher?.isPublishing() && !publisherIsStable;
1344-
if (!includePublisher && this.publisher?.isPublishing()) {
1345-
this.logger.info(
1346-
'[Reconnect] FAST: skipping publisher ICE restart, publisher PC is stable',
1347-
);
1348-
}
1349-
await this.restoreICE(sfuClient, {
1350-
includeSubscriber: false,
1351-
includePublisher,
1352-
});
1336+
// the SFU automatically issues an ICE restart on the subscriber
1337+
// we don't have to do it ourselves
1338+
await this.restoreICE(sfuClient, { includeSubscriber: false });
13531339
} else {
13541340
const connectionConfig = toRtcConfiguration(this.credentials.ice_servers);
13551341
await this.initPublisherAndSubscriber({
@@ -1688,6 +1674,10 @@ export class Call {
16881674
reason: string,
16891675
) => {
16901676
this.logger.debug('[Reconnect] SFU signal connection closed');
1677+
// Ignore a close from a superseded client (e.g. an old socket's delayed
1678+
// `onclose` arriving after a reconnection already swapped in a new client).
1679+
// Only the currently active client's death may drive a reconnection.
1680+
if (sfuClient !== this.sfuClient) return;
16911681
const { callingState } = this.state;
16921682
if (
16931683
// SFU WS closed before we finished current join,
@@ -1728,11 +1718,12 @@ export class Call {
17281718
strategy: WebsocketReconnectStrategy,
17291719
reason: ReconnectReason,
17301720
): Promise<void> => {
1721+
const { callingState } = this.state;
17311722
if (
1732-
this.state.callingState === CallingState.JOINING ||
1733-
this.state.callingState === CallingState.RECONNECTING ||
1734-
this.state.callingState === CallingState.MIGRATING ||
1735-
this.state.callingState === CallingState.RECONNECTING_FAILED
1723+
callingState === CallingState.JOINING ||
1724+
callingState === CallingState.RECONNECTING ||
1725+
callingState === CallingState.MIGRATING ||
1726+
callingState === CallingState.RECONNECTING_FAILED
17361727
)
17371728
return;
17381729

packages/client/src/StreamSfuClient.ts

Lines changed: 35 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -164,13 +164,24 @@ export class StreamSfuClient {
164164
*/
165165
isClosingClean = false;
166166

167+
/**
168+
* One-shot latch guarding `onSignalClose`. The signal connection can be
169+
* detected as dead by more than one source (the health watchdog and the
170+
* WebSocket `close` event, which on a wedged socket can arrive seconds
171+
* apart). This ensures revival is triggered at most once per client.
172+
*/
173+
private signalClosed = false;
174+
167175
private readonly rpc: SignalServerClient;
168176
private keepAliveInterval?: number;
169-
private connectionCheckTimeout?: NodeJS.Timeout;
177+
private connectionCheckInterval?: number;
170178
private migrateAwayTimeout?: NodeJS.Timeout;
171179
private readonly pingIntervalInMs = 5 * 1000;
172-
private readonly unhealthyTimeoutInMs = 15 * 1000;
173-
private lastMessageTimestamp?: Date;
180+
private readonly unhealthyTimeoutInMs = this.pingIntervalInMs * 2 + 2 * 1000;
181+
private readonly connectionCheckIntervalInMs = Math.round(
182+
this.unhealthyTimeoutInMs / 3,
183+
);
184+
private lastMessageTimestamp?: number;
174185
private readonly tracer?: Tracer;
175186
private readonly unsubscribeIceTrickle: () => void;
176187
private readonly unsubscribeNetworkChanged: () => void;
@@ -209,7 +220,7 @@ export class StreamSfuClient {
209220
/**
210221
* The error code used when the SFU connection is unhealthy.
211222
* Usually, this means that no message has been received from the SFU for
212-
* a certain amount of time (`connectionCheckTimeout`).
223+
* a certain amount of time (`unhealthyTimeoutInMs`).
213224
*/
214225
static ERROR_CONNECTION_UNHEALTHY = 4001;
215226
/**
@@ -311,7 +322,7 @@ export class StreamSfuClient {
311322
endpoint: `${this.credentials.server.ws_endpoint}?${new URLSearchParams(params).toString()}`,
312323
tracer: this.tracer,
313324
onMessage: (message) => {
314-
this.lastMessageTimestamp = new Date();
325+
this.lastMessageTimestamp = Date.now();
315326
this.scheduleConnectionCheck();
316327
const eventKind = message.eventPayload.oneofKind;
317328
if (eventsToTrace[eventKind]) {
@@ -336,7 +347,7 @@ export class StreamSfuClient {
336347
this.signalWs.addEventListener('open', onOpen);
337348

338349
this.signalWs.addEventListener('close', (e) => {
339-
this.handleWebSocketClose(e);
350+
this.notifySignalClose(`${e.code} ${e.reason ?? ''}`);
340351
// Normally, this shouldn't have any effect, because WS should never emit 'close'
341352
// before emitting 'open'. However, stranger things have happened, and we don't
342353
// want to leave signalReady in a pending state.
@@ -371,11 +382,13 @@ export class StreamSfuClient {
371382
return this.joinResponseTask.promise;
372383
}
373384

374-
private handleWebSocketClose = (e: CloseEvent) => {
375-
this.signalWs.removeEventListener('close', this.handleWebSocketClose);
376-
getTimers().clearInterval(this.keepAliveInterval);
377-
clearTimeout(this.connectionCheckTimeout);
378-
this.onSignalClose?.(`${e.code} ${e.reason}`);
385+
private notifySignalClose = (reason: string) => {
386+
if (this.signalClosed) return;
387+
this.signalClosed = true;
388+
const timers = getTimers();
389+
timers.clearInterval(this.keepAliveInterval);
390+
timers.clearInterval(this.connectionCheckInterval);
391+
this.onSignalClose?.(reason.trim());
379392
};
380393

381394
close = (code: number = StreamSfuClient.NORMAL_CLOSURE, reason?: string) => {
@@ -392,7 +405,9 @@ export class StreamSfuClient {
392405
) {
393406
this.logger.debug(`Closing SFU WS connection: ${code} - ${reason}`);
394407
ws.close(code, `js-client: ${reason}`);
395-
ws.removeEventListener('close', this.handleWebSocketClose);
408+
}
409+
if (!this.isClosingClean) {
410+
this.notifySignalClose(`${code} ${reason ?? ''}`);
396411
}
397412
this.dispose(reason);
398413
};
@@ -401,8 +416,9 @@ export class StreamSfuClient {
401416
this.logger.debug('Disposing SFU client');
402417
this.unsubscribeIceTrickle();
403418
this.unsubscribeNetworkChanged();
404-
clearInterval(this.keepAliveInterval);
405-
clearTimeout(this.connectionCheckTimeout);
419+
const timers = getTimers();
420+
timers.clearInterval(this.keepAliveInterval);
421+
timers.clearInterval(this.connectionCheckInterval);
406422
clearTimeout(this.migrateAwayTimeout);
407423
this.abortController.abort();
408424
this.migrationTask?.resolve();
@@ -711,19 +727,18 @@ export class StreamSfuClient {
711727
};
712728

713729
private scheduleConnectionCheck = () => {
714-
clearTimeout(this.connectionCheckTimeout);
715-
this.connectionCheckTimeout = setTimeout(() => {
730+
const timers = getTimers();
731+
timers.clearInterval(this.connectionCheckInterval);
732+
this.connectionCheckInterval = timers.setInterval(() => {
716733
if (this.lastMessageTimestamp) {
717-
const timeSinceLastMessage =
718-
new Date().getTime() - this.lastMessageTimestamp.getTime();
719-
734+
const timeSinceLastMessage = Date.now() - this.lastMessageTimestamp;
720735
if (timeSinceLastMessage > this.unhealthyTimeoutInMs) {
721736
this.close(
722737
StreamSfuClient.ERROR_CONNECTION_UNHEALTHY,
723738
`SFU connection unhealthy. Didn't receive any message for ${this.unhealthyTimeoutInMs}ms`,
724739
);
725740
}
726741
}
727-
}, this.unhealthyTimeoutInMs);
742+
}, this.connectionCheckIntervalInMs);
728743
};
729744
}

packages/client/src/__tests__/StreamSfuClient.test.ts

Lines changed: 159 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
22
import { StreamSfuClient } from '../StreamSfuClient';
33
import { Dispatcher } from '../rtc';
44
import { StreamClient } from '../coordinator/connection/client';
5+
import { getTimers } from '../timers';
56

67
/**
78
* Minimal `WebSocket` stub used to drive `StreamSfuClient.close()` while the
@@ -38,9 +39,13 @@ class CapturingWebSocket {
3839
this.closeArgs = { code, reason };
3940
this.readyState = CapturingWebSocket.CLOSED;
4041
}
42+
/** Test helper: synchronously fire a registered event (e.g. `close`). */
43+
emit(event: string, payload: unknown) {
44+
this.listeners.get(event)?.forEach((listener) => listener(payload));
45+
}
4146
}
4247

43-
const buildSfuClient = () => {
48+
const buildSfuClient = (onSignalClose?: (reason: string) => void) => {
4449
const dispatcher = new Dispatcher();
4550
const streamClient = new StreamClient('test-key');
4651
return new StreamSfuClient({
@@ -59,9 +64,109 @@ const buildSfuClient = () => {
5964
},
6065
tag: 'test',
6166
enableTracing: false,
67+
onSignalClose,
6268
});
6369
};
6470

71+
describe('StreamSfuClient unhealthy watchdog timer source', () => {
72+
beforeEach(() => {
73+
CapturingWebSocket.instances = [];
74+
vi.stubGlobal('WebSocket', CapturingWebSocket);
75+
});
76+
77+
afterEach(() => {
78+
vi.unstubAllGlobals();
79+
vi.restoreAllMocks();
80+
});
81+
82+
it('arms the unhealthy watchdog on the worker timer, not the main-thread setInterval', () => {
83+
const sfuClient = buildSfuClient();
84+
const workerSetInterval = vi
85+
.spyOn(getTimers(), 'setInterval')
86+
.mockReturnValue(1 as unknown as number);
87+
const mainSetInterval = vi.spyOn(globalThis, 'setInterval');
88+
89+
(
90+
sfuClient as unknown as { scheduleConnectionCheck: () => void }
91+
).scheduleConnectionCheck();
92+
93+
expect(workerSetInterval).toHaveBeenCalledTimes(1);
94+
expect(mainSetInterval).not.toHaveBeenCalled();
95+
96+
sfuClient.close(1000, 'test cleanup');
97+
});
98+
});
99+
100+
describe('StreamSfuClient unhealthy watchdog resilience', () => {
101+
beforeEach(() => {
102+
CapturingWebSocket.instances = [];
103+
vi.stubGlobal('WebSocket', CapturingWebSocket);
104+
});
105+
106+
afterEach(() => {
107+
vi.useRealTimers();
108+
vi.unstubAllGlobals();
109+
vi.restoreAllMocks();
110+
});
111+
112+
it('re-arms the unhealthy watchdog after a check passes (not single-shot)', () => {
113+
vi.useFakeTimers();
114+
const sfuClient = buildSfuClient();
115+
const closeSpy = vi.spyOn(sfuClient, 'close').mockImplementation(() => {});
116+
const c = sfuClient as unknown as {
117+
lastMessageTimestamp?: number;
118+
unhealthyTimeoutInMs: number;
119+
scheduleConnectionCheck: () => void;
120+
};
121+
const window = c.unhealthyTimeoutInMs;
122+
123+
c.lastMessageTimestamp = Date.now();
124+
c.scheduleConnectionCheck();
125+
126+
// At exactly the threshold the connection is still healthy (strict `>`),
127+
// so no poll within the first window closes it. A single-shot watchdog
128+
// armed for the threshold would now be dead.
129+
vi.advanceTimersByTime(window);
130+
expect(closeSpy).not.toHaveBeenCalled();
131+
132+
// No further messages arrive; the self-rescheduling watchdog keeps polling
133+
// and detects the connection as unhealthy on a later tick.
134+
vi.advanceTimersByTime(window);
135+
expect(closeSpy).toHaveBeenCalledWith(
136+
StreamSfuClient.ERROR_CONNECTION_UNHEALTHY,
137+
expect.stringContaining('unhealthy'),
138+
);
139+
});
140+
141+
it('detects an unhealthy connection shortly after the threshold, not a full window later', () => {
142+
vi.useFakeTimers();
143+
const sfuClient = buildSfuClient();
144+
const closeSpy = vi.spyOn(sfuClient, 'close').mockImplementation(() => {});
145+
const c = sfuClient as unknown as {
146+
lastMessageTimestamp?: number;
147+
unhealthyTimeoutInMs: number;
148+
scheduleConnectionCheck: () => void;
149+
};
150+
const window = c.unhealthyTimeoutInMs;
151+
152+
c.lastMessageTimestamp = Date.now();
153+
c.scheduleConnectionCheck();
154+
155+
// healthy up to (and exactly at) the threshold
156+
vi.advanceTimersByTime(window);
157+
expect(closeSpy).not.toHaveBeenCalled();
158+
159+
// the watchdog polls finer than the window, so silence is caught well
160+
// before a second full window elapses (the old period == window design
161+
// could take up to 2x the window to notice).
162+
vi.advanceTimersByTime(window / 2);
163+
expect(closeSpy).toHaveBeenCalledWith(
164+
StreamSfuClient.ERROR_CONNECTION_UNHEALTHY,
165+
expect.stringContaining('unhealthy'),
166+
);
167+
});
168+
});
169+
65170
describe('StreamSfuClient.close()', () => {
66171
beforeEach(() => {
67172
CapturingWebSocket.instances = [];
@@ -164,6 +269,59 @@ describe('StreamSfuClient.close()', () => {
164269
});
165270
});
166271

272+
describe('StreamSfuClient signal-close revival', () => {
273+
beforeEach(() => {
274+
CapturingWebSocket.instances = [];
275+
vi.stubGlobal('WebSocket', CapturingWebSocket);
276+
});
277+
278+
afterEach(() => {
279+
vi.unstubAllGlobals();
280+
vi.clearAllMocks();
281+
});
282+
283+
it('drives revival immediately on an unhealthy close, without waiting for the onclose event', () => {
284+
const onSignalClose = vi.fn();
285+
const sfuClient = buildSfuClient(onSignalClose);
286+
287+
// A wedged socket may fire `onclose` only after the OS TCP timeout. The
288+
// health watchdog closes with ERROR_CONNECTION_UNHEALTHY; revival must
289+
// start now, not when (or if) the transport `close` event arrives.
290+
sfuClient.close(
291+
StreamSfuClient.ERROR_CONNECTION_UNHEALTHY,
292+
'SFU connection unhealthy',
293+
);
294+
295+
expect(onSignalClose).toHaveBeenCalledTimes(1);
296+
});
297+
298+
it('notifies revival only once when the late onclose event follows an unhealthy close', () => {
299+
const onSignalClose = vi.fn();
300+
const sfuClient = buildSfuClient(onSignalClose);
301+
const ws = CapturingWebSocket.instances.at(-1)!;
302+
303+
// watchdog closes the dead socket (revival triggered proactively)...
304+
sfuClient.close(
305+
StreamSfuClient.ERROR_CONNECTION_UNHEALTHY,
306+
'SFU connection unhealthy',
307+
);
308+
// ...then the OS finally surfaces the wedged socket's `close` event.
309+
ws.emit('close', { code: 1006, reason: '' });
310+
311+
expect(onSignalClose).toHaveBeenCalledTimes(1);
312+
});
313+
314+
it('notifies revival when only the onclose event fires (server-initiated close)', () => {
315+
const onSignalClose = vi.fn();
316+
buildSfuClient(onSignalClose);
317+
const ws = CapturingWebSocket.instances.at(-1)!;
318+
319+
ws.emit('close', { code: 1006, reason: '' });
320+
321+
expect(onSignalClose).toHaveBeenCalledTimes(1);
322+
});
323+
});
324+
167325
describe('StreamSfuClient.leaveAndClose()', () => {
168326
beforeEach(() => {
169327
CapturingWebSocket.instances = [];

0 commit comments

Comments
 (0)