@stream-io/video-client 1.54.0 → 1.55.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/index.browser.es.js +9641 -8767
  3. package/dist/index.browser.es.js.map +1 -1
  4. package/dist/index.cjs.js +9638 -8764
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.es.js +9639 -8765
  7. package/dist/index.es.js.map +1 -1
  8. package/dist/src/Call.d.ts +13 -1
  9. package/dist/src/StreamSfuClient.d.ts +11 -3
  10. package/dist/src/coordinator/connection/connection.d.ts +1 -1
  11. package/dist/src/gen/google/protobuf/struct.d.ts +3 -1
  12. package/dist/src/gen/google/protobuf/timestamp.d.ts +3 -1
  13. package/dist/src/gen/video/sfu/event/events.d.ts +22 -1
  14. package/dist/src/gen/video/sfu/models/models.d.ts +4 -0
  15. package/dist/src/gen/video/sfu/signal_rpc/signal.client.d.ts +23 -2
  16. package/dist/src/reporting/ClientEventReporter.d.ts +1 -0
  17. package/dist/src/rtc/BasePeerConnection.d.ts +2 -12
  18. package/dist/src/rtc/IceTrickleBuffer.d.ts +41 -3
  19. package/dist/src/rtc/Publisher.d.ts +5 -2
  20. package/dist/src/rtc/Subscriber.d.ts +8 -0
  21. package/dist/src/rtc/helpers/iceCandiates.d.ts +12 -0
  22. package/dist/src/rtc/types.d.ts +2 -0
  23. package/dist/src/stats/SfuStatsReporter.d.ts +32 -1
  24. package/dist/src/stats/rtc/StatsTracer.d.ts +38 -8
  25. package/dist/src/stats/rtc/Tracer.d.ts +9 -2
  26. package/dist/src/stats/rtc/types.d.ts +10 -4
  27. package/package.json +5 -3
  28. package/src/Call.ts +83 -35
  29. package/src/StreamSfuClient.ts +36 -21
  30. package/src/__tests__/StreamSfuClient.test.ts +159 -1
  31. package/src/__tests__/StreamVideoClient.api.test.ts +123 -97
  32. package/src/coordinator/connection/__tests__/connection.test.ts +22 -0
  33. package/src/coordinator/connection/connection.ts +8 -5
  34. package/src/gen/google/protobuf/struct.ts +7 -12
  35. package/src/gen/google/protobuf/timestamp.ts +6 -7
  36. package/src/gen/video/sfu/event/events.ts +22 -25
  37. package/src/gen/video/sfu/models/models.ts +10 -1
  38. package/src/gen/video/sfu/signal_rpc/signal.client.ts +24 -29
  39. package/src/helpers/MediaPlaybackWatchdog.ts +1 -0
  40. package/src/helpers/__tests__/browsers.test.ts +12 -12
  41. package/src/helpers/browsers.ts +5 -5
  42. package/src/reporting/ClientEventReporter.ts +17 -12
  43. package/src/reporting/__tests__/ClientEventReporter.test.ts +52 -0
  44. package/src/rtc/BasePeerConnection.ts +15 -34
  45. package/src/rtc/IceTrickleBuffer.ts +105 -12
  46. package/src/rtc/Publisher.ts +23 -19
  47. package/src/rtc/Subscriber.ts +97 -36
  48. package/src/rtc/__tests__/Call.reconnect.test.ts +45 -45
  49. package/src/rtc/__tests__/IceTrickleBuffer.test.ts +127 -0
  50. package/src/rtc/__tests__/Publisher.test.ts +2 -31
  51. package/src/rtc/__tests__/Subscriber.test.ts +271 -20
  52. package/src/rtc/helpers/__tests__/iceCandiates.test.ts +88 -0
  53. package/src/rtc/helpers/degradationPreference.ts +1 -0
  54. package/src/rtc/helpers/iceCandiates.ts +35 -0
  55. package/src/rtc/helpers/sdp.ts +3 -2
  56. package/src/rtc/helpers/tracks.ts +2 -0
  57. package/src/rtc/types.ts +2 -0
  58. package/src/stats/SfuStatsReporter.ts +149 -49
  59. package/src/stats/__tests__/SfuStatsReporter.test.ts +235 -0
  60. package/src/stats/rtc/StatsTracer.ts +90 -32
  61. package/src/stats/rtc/Tracer.ts +23 -2
  62. package/src/stats/rtc/__tests__/StatsTracer.test.ts +213 -6
  63. package/src/stats/rtc/__tests__/Tracer.test.ts +34 -0
  64. package/src/stats/rtc/types.ts +11 -4
package/src/Call.ts CHANGED
@@ -325,6 +325,7 @@ export class Call {
325
325
  private joinResponseTimeout?: number;
326
326
  private rpcRequestTimeout?: number;
327
327
  private joinCallData?: JoinCallData;
328
+ private allowOwnTracksLoopback = false;
328
329
  private hasJoinedOnce = false;
329
330
  private deviceSettingsAppliedOnce = false;
330
331
  private credentials?: Credentials;
@@ -740,7 +741,9 @@ export class Call {
740
741
 
741
742
  const leaveReason = message ?? reason ?? 'user is leaving the call';
742
743
  this.tracer.trace('call.leaveReason', leaveReason);
743
- this.sfuStatsReporter?.flush();
744
+ // await the final sample so it's captured from the still-live peer
745
+ // connections (disposed below); the send itself stays best-effort.
746
+ await this.sfuStatsReporter?.flush();
744
747
  this.sfuStatsReporter?.stop();
745
748
  this.sfuStatsReporter = undefined;
746
749
  this.lastStatsOptions = undefined;
@@ -787,6 +790,7 @@ export class Call {
787
790
  this.leaveCallHooks.forEach((hook) => hook());
788
791
  this.initialized = false;
789
792
  this.hasJoinedOnce = false;
793
+ this.allowOwnTracksLoopback = false;
790
794
  this.unifiedSessionId = undefined;
791
795
  this.ringingSubject.next(false);
792
796
  this.cancelAutoDrop();
@@ -830,6 +834,37 @@ export class Call {
830
834
  return this.clientStore.connectedUser?.id;
831
835
  }
832
836
 
837
+ /**
838
+ * A flag indicating whether self-subscription is enabled for the call.
839
+ */
840
+ get isOwnTracksLoopbackAllowed() {
841
+ return this.allowOwnTracksLoopback;
842
+ }
843
+
844
+ /**
845
+ * The largest video publish dimension across the current publish options.
846
+ *
847
+ * @internal
848
+ */
849
+ getMaxVideoPublishDimension = (): VideoDimension | undefined => {
850
+ if (!this.currentPublishOptions) return undefined;
851
+ let maxDimension: VideoDimension | undefined;
852
+ let maxArea = 0;
853
+ for (const opt of this.currentPublishOptions) {
854
+ if (opt.trackType !== TrackType.VIDEO) continue;
855
+
856
+ const dim = opt.videoDimension;
857
+ if (!dim || !dim.width || !dim.height) continue;
858
+
859
+ const area = dim.width * dim.height;
860
+ if (area > maxArea) {
861
+ maxDimension = dim;
862
+ maxArea = area;
863
+ }
864
+ }
865
+ return maxDimension;
866
+ };
867
+
833
868
  /**
834
869
  * A flag indicating whether the call was created by the current user.
835
870
  */
@@ -1042,11 +1077,13 @@ export class Call {
1042
1077
  maxJoinRetries = 3,
1043
1078
  joinResponseTimeout,
1044
1079
  rpcRequestTimeout,
1080
+ allowOwnTracksLoopback = false,
1045
1081
  ...data
1046
1082
  }: JoinCallData & {
1047
1083
  maxJoinRetries?: number;
1048
1084
  joinResponseTimeout?: number;
1049
1085
  rpcRequestTimeout?: number;
1086
+ allowOwnTracksLoopback?: boolean;
1050
1087
  } = {}): Promise<void> => {
1051
1088
  const callingState = this.state.callingState;
1052
1089
 
@@ -1054,9 +1091,14 @@ export class Call {
1054
1091
  throw new Error(`Illegal State: call.join() shall be called only once`);
1055
1092
  }
1056
1093
 
1094
+ // we need this to be set before the callingx.joinCall() is
1095
+ // called to avoid registering the test call in the CallKit/Telecom
1096
+ this.allowOwnTracksLoopback = allowOwnTracksLoopback;
1097
+
1057
1098
  if (data?.ring) {
1058
1099
  this.ringingSubject.next(true);
1059
1100
  }
1101
+
1060
1102
  const callingX = globalThis.streamRNVideoSDK?.callingX;
1061
1103
  if (callingX) {
1062
1104
  // for Android/iOS, we need to start the call in the callingx library as soon as possible
@@ -1291,23 +1333,9 @@ export class Call {
1291
1333
  // when performing fast reconnect, or when we reuse the same SFU client,
1292
1334
  // (ws remained healthy), we just need to restore the ICE connection
1293
1335
  if (performingFastReconnect) {
1294
- // The SFU automatically issues an ICE restart on the subscriber,
1295
- // so we only need to decide about the publisher. If the publisher's
1296
- // peer connection is still stable (ICE still connected end-to-end),
1297
- // the signal WebSocket drop was the only problem — the new WS alone
1298
- // is enough, and restarting ICE would add unnecessary SDP/ICE churn.
1299
- const publisherIsStable = this.publisher?.isStable() ?? true;
1300
- const includePublisher =
1301
- !!this.publisher?.isPublishing() && !publisherIsStable;
1302
- if (!includePublisher && this.publisher?.isPublishing()) {
1303
- this.logger.info(
1304
- '[Reconnect] FAST: skipping publisher ICE restart, publisher PC is stable',
1305
- );
1306
- }
1307
- await this.restoreICE(sfuClient, {
1308
- includeSubscriber: false,
1309
- includePublisher,
1310
- });
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 });
1311
1339
  } else {
1312
1340
  const connectionConfig = toRtcConfiguration(this.credentials.ice_servers);
1313
1341
  await this.initPublisherAndSubscriber({
@@ -1506,6 +1534,11 @@ export class Call {
1506
1534
  enable_rtc_stats: enableTracing,
1507
1535
  reporting_interval_ms: reportingIntervalMs,
1508
1536
  } = statsOptions;
1537
+ // Flush the previous reporter's final sample while its peer connections are
1538
+ // still alive, before we dispose them below. Awaits only the sampling step.
1539
+ await this.sfuStatsReporter?.flush();
1540
+ this.sfuStatsReporter?.stop();
1541
+ this.sfuStatsReporter = undefined;
1509
1542
  if (closePreviousInstances && this.subscriber) {
1510
1543
  await this.subscriber.dispose();
1511
1544
  }
@@ -1553,7 +1586,13 @@ export class Call {
1553
1586
  if (closePreviousInstances && this.publisher) {
1554
1587
  await this.publisher.dispose();
1555
1588
  }
1556
- this.publisher = new Publisher(basePeerConnectionOptions, publishOptions);
1589
+ this.publisher = new Publisher(
1590
+ basePeerConnectionOptions,
1591
+ publishOptions,
1592
+ {
1593
+ selfSubEnabled: this.allowOwnTracksLoopback,
1594
+ },
1595
+ );
1557
1596
  }
1558
1597
 
1559
1598
  this.statsReporter?.stop();
@@ -1568,8 +1607,6 @@ export class Call {
1568
1607
  }
1569
1608
 
1570
1609
  this.tracer.setEnabled(enableTracing);
1571
- this.sfuStatsReporter?.flush();
1572
- this.sfuStatsReporter?.stop();
1573
1610
  if (statsOptions?.reporting_interval_ms > 0) {
1574
1611
  this.sfuStatsReporter = new SfuStatsReporter(sfuClient, {
1575
1612
  clientDetails,
@@ -1637,6 +1674,10 @@ export class Call {
1637
1674
  reason: string,
1638
1675
  ) => {
1639
1676
  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;
1640
1681
  const { callingState } = this.state;
1641
1682
  if (
1642
1683
  // SFU WS closed before we finished current join,
@@ -1677,11 +1718,12 @@ export class Call {
1677
1718
  strategy: WebsocketReconnectStrategy,
1678
1719
  reason: ReconnectReason,
1679
1720
  ): Promise<void> => {
1721
+ const { callingState } = this.state;
1680
1722
  if (
1681
- this.state.callingState === CallingState.JOINING ||
1682
- this.state.callingState === CallingState.RECONNECTING ||
1683
- this.state.callingState === CallingState.MIGRATING ||
1684
- this.state.callingState === CallingState.RECONNECTING_FAILED
1723
+ callingState === CallingState.JOINING ||
1724
+ callingState === CallingState.RECONNECTING ||
1725
+ callingState === CallingState.MIGRATING ||
1726
+ callingState === CallingState.RECONNECTING_FAILED
1685
1727
  )
1686
1728
  return;
1687
1729
 
@@ -2093,10 +2135,17 @@ export class Call {
2093
2135
  } else {
2094
2136
  if (!this.networkAvailableTask) return;
2095
2137
  this.logger.debug('[Reconnect] Going online');
2096
- this.sfuClient?.close(
2097
- StreamSfuClient.DISPOSE_OLD_SOCKET,
2098
- 'Closing WS to reconnect after going online',
2099
- );
2138
+ // Only discard the socket when no reconnect is already in flight. A
2139
+ // running reconnect owns the SFU-client lifecycle (`doJoin` closes
2140
+ // the previous client once the new session is up). Closing here would
2141
+ // tear down the socket it's mid-join on, leaving the in-flight rejoin
2142
+ // to spin on SIGNAL_LOST.
2143
+ if (!hasPending(this.reconnectConcurrencyTag)) {
2144
+ this.sfuClient?.close(
2145
+ StreamSfuClient.DISPOSE_OLD_SOCKET,
2146
+ 'Closing WS to reconnect after going online',
2147
+ );
2148
+ }
2100
2149
  // we went online, release the previous waiters and reset the state
2101
2150
  this.networkAvailableTask?.resolve();
2102
2151
  this.networkAvailableTask = undefined;
@@ -2450,9 +2499,8 @@ export class Call {
2450
2499
  */
2451
2500
  muteSelf = (type: TrackMuteType) => {
2452
2501
  const myUserId = this.currentUserId;
2453
- if (myUserId) {
2454
- return this.muteUser(myUserId, type);
2455
- }
2502
+ if (!myUserId) return undefined;
2503
+ return this.muteUser(myUserId, type);
2456
2504
  };
2457
2505
 
2458
2506
  /**
@@ -2470,9 +2518,9 @@ export class Call {
2470
2518
  }
2471
2519
  }
2472
2520
 
2473
- if (userIdsToMute.length > 0) {
2474
- return this.muteUser(userIdsToMute, type);
2475
- }
2521
+ return userIdsToMute.length > 0
2522
+ ? this.muteUser(userIdsToMute, type)
2523
+ : undefined;
2476
2524
  };
2477
2525
 
2478
2526
  /**
@@ -164,13 +164,24 @@ export class StreamSfuClient {
164
164
  */
165
165
  isClosingClean = false;
166
166
 
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
+
167
175
  private readonly rpc: SignalServerClient;
168
176
  private keepAliveInterval?: number;
169
- private connectionCheckTimeout?: NodeJS.Timeout;
177
+ private connectionCheckInterval?: number;
170
178
  private migrateAwayTimeout?: NodeJS.Timeout;
171
179
  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;
174
185
  private readonly tracer?: Tracer;
175
186
  private readonly unsubscribeIceTrickle: () => void;
176
187
  private readonly unsubscribeNetworkChanged: () => void;
@@ -209,7 +220,7 @@ export class StreamSfuClient {
209
220
  /**
210
221
  * The error code used when the SFU connection is unhealthy.
211
222
  * 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`).
213
224
  */
214
225
  static ERROR_CONNECTION_UNHEALTHY = 4001;
215
226
  /**
@@ -311,7 +322,7 @@ export class StreamSfuClient {
311
322
  endpoint: `${this.credentials.server.ws_endpoint}?${new URLSearchParams(params).toString()}`,
312
323
  tracer: this.tracer,
313
324
  onMessage: (message) => {
314
- this.lastMessageTimestamp = new Date();
325
+ this.lastMessageTimestamp = Date.now();
315
326
  this.scheduleConnectionCheck();
316
327
  const eventKind = message.eventPayload.oneofKind;
317
328
  if (eventsToTrace[eventKind]) {
@@ -336,7 +347,7 @@ export class StreamSfuClient {
336
347
  this.signalWs.addEventListener('open', onOpen);
337
348
 
338
349
  this.signalWs.addEventListener('close', (e) => {
339
- this.handleWebSocketClose(e);
350
+ this.notifySignalClose(`${e.code} ${e.reason ?? ''}`);
340
351
  // Normally, this shouldn't have any effect, because WS should never emit 'close'
341
352
  // before emitting 'open'. However, stranger things have happened, and we don't
342
353
  // want to leave signalReady in a pending state.
@@ -371,11 +382,13 @@ export class StreamSfuClient {
371
382
  return this.joinResponseTask.promise;
372
383
  }
373
384
 
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());
379
392
  };
380
393
 
381
394
  close = (code: number = StreamSfuClient.NORMAL_CLOSURE, reason?: string) => {
@@ -392,7 +405,9 @@ export class StreamSfuClient {
392
405
  ) {
393
406
  this.logger.debug(`Closing SFU WS connection: ${code} - ${reason}`);
394
407
  ws.close(code, `js-client: ${reason}`);
395
- ws.removeEventListener('close', this.handleWebSocketClose);
408
+ }
409
+ if (!this.isClosingClean) {
410
+ this.notifySignalClose(`${code} ${reason ?? ''}`);
396
411
  }
397
412
  this.dispose(reason);
398
413
  };
@@ -401,8 +416,9 @@ export class StreamSfuClient {
401
416
  this.logger.debug('Disposing SFU client');
402
417
  this.unsubscribeIceTrickle();
403
418
  this.unsubscribeNetworkChanged();
404
- clearInterval(this.keepAliveInterval);
405
- clearTimeout(this.connectionCheckTimeout);
419
+ const timers = getTimers();
420
+ timers.clearInterval(this.keepAliveInterval);
421
+ timers.clearInterval(this.connectionCheckInterval);
406
422
  clearTimeout(this.migrateAwayTimeout);
407
423
  this.abortController.abort();
408
424
  this.migrationTask?.resolve();
@@ -697,7 +713,7 @@ export class StreamSfuClient {
697
713
  return;
698
714
  }
699
715
  this.logger.debug(`Sending message to: ${this.edgeName}`, msgJson);
700
- this.signalWs.send(SfuRequest.toBinary(message));
716
+ this.signalWs.send(SfuRequest.toBinary(message) as Uint8Array<ArrayBuffer>);
701
717
  };
702
718
 
703
719
  private keepAlive = () => {
@@ -711,12 +727,11 @@ export class StreamSfuClient {
711
727
  };
712
728
 
713
729
  private scheduleConnectionCheck = () => {
714
- clearTimeout(this.connectionCheckTimeout);
715
- this.connectionCheckTimeout = setTimeout(() => {
730
+ const timers = getTimers();
731
+ timers.clearInterval(this.connectionCheckInterval);
732
+ this.connectionCheckInterval = timers.setInterval(() => {
716
733
  if (this.lastMessageTimestamp) {
717
- const timeSinceLastMessage =
718
- new Date().getTime() - this.lastMessageTimestamp.getTime();
719
-
734
+ const timeSinceLastMessage = Date.now() - this.lastMessageTimestamp;
720
735
  if (timeSinceLastMessage > this.unhealthyTimeoutInMs) {
721
736
  this.close(
722
737
  StreamSfuClient.ERROR_CONNECTION_UNHEALTHY,
@@ -724,6 +739,6 @@ export class StreamSfuClient {
724
739
  );
725
740
  }
726
741
  }
727
- }, this.unhealthyTimeoutInMs);
742
+ }, this.connectionCheckIntervalInMs);
728
743
  };
729
744
  }
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
2
  import { StreamSfuClient } from '../StreamSfuClient';
3
3
  import { Dispatcher } from '../rtc';
4
4
  import { StreamClient } from '../coordinator/connection/client';
5
+ import { getTimers } from '../timers';
5
6
 
6
7
  /**
7
8
  * Minimal `WebSocket` stub used to drive `StreamSfuClient.close()` while the
@@ -38,9 +39,13 @@ class CapturingWebSocket {
38
39
  this.closeArgs = { code, reason };
39
40
  this.readyState = CapturingWebSocket.CLOSED;
40
41
  }
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
+ }
41
46
  }
42
47
 
43
- const buildSfuClient = () => {
48
+ const buildSfuClient = (onSignalClose?: (reason: string) => void) => {
44
49
  const dispatcher = new Dispatcher();
45
50
  const streamClient = new StreamClient('test-key');
46
51
  return new StreamSfuClient({
@@ -59,9 +64,109 @@ const buildSfuClient = () => {
59
64
  },
60
65
  tag: 'test',
61
66
  enableTracing: false,
67
+ onSignalClose,
62
68
  });
63
69
  };
64
70
 
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
+
65
170
  describe('StreamSfuClient.close()', () => {
66
171
  beforeEach(() => {
67
172
  CapturingWebSocket.instances = [];
@@ -164,6 +269,59 @@ describe('StreamSfuClient.close()', () => {
164
269
  });
165
270
  });
166
271
 
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
+
167
325
  describe('StreamSfuClient.leaveAndClose()', () => {
168
326
  beforeEach(() => {
169
327
  CapturingWebSocket.instances = [];