@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.
- package/CHANGELOG.md +14 -0
- package/dist/index.browser.es.js +9641 -8767
- package/dist/index.browser.es.js.map +1 -1
- package/dist/index.cjs.js +9638 -8764
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.es.js +9639 -8765
- package/dist/index.es.js.map +1 -1
- package/dist/src/Call.d.ts +13 -1
- package/dist/src/StreamSfuClient.d.ts +11 -3
- package/dist/src/coordinator/connection/connection.d.ts +1 -1
- package/dist/src/gen/google/protobuf/struct.d.ts +3 -1
- package/dist/src/gen/google/protobuf/timestamp.d.ts +3 -1
- package/dist/src/gen/video/sfu/event/events.d.ts +22 -1
- package/dist/src/gen/video/sfu/models/models.d.ts +4 -0
- package/dist/src/gen/video/sfu/signal_rpc/signal.client.d.ts +23 -2
- package/dist/src/reporting/ClientEventReporter.d.ts +1 -0
- package/dist/src/rtc/BasePeerConnection.d.ts +2 -12
- package/dist/src/rtc/IceTrickleBuffer.d.ts +41 -3
- package/dist/src/rtc/Publisher.d.ts +5 -2
- package/dist/src/rtc/Subscriber.d.ts +8 -0
- package/dist/src/rtc/helpers/iceCandiates.d.ts +12 -0
- package/dist/src/rtc/types.d.ts +2 -0
- package/dist/src/stats/SfuStatsReporter.d.ts +32 -1
- package/dist/src/stats/rtc/StatsTracer.d.ts +38 -8
- package/dist/src/stats/rtc/Tracer.d.ts +9 -2
- package/dist/src/stats/rtc/types.d.ts +10 -4
- package/package.json +5 -3
- package/src/Call.ts +83 -35
- package/src/StreamSfuClient.ts +36 -21
- package/src/__tests__/StreamSfuClient.test.ts +159 -1
- package/src/__tests__/StreamVideoClient.api.test.ts +123 -97
- package/src/coordinator/connection/__tests__/connection.test.ts +22 -0
- package/src/coordinator/connection/connection.ts +8 -5
- package/src/gen/google/protobuf/struct.ts +7 -12
- package/src/gen/google/protobuf/timestamp.ts +6 -7
- package/src/gen/video/sfu/event/events.ts +22 -25
- package/src/gen/video/sfu/models/models.ts +10 -1
- package/src/gen/video/sfu/signal_rpc/signal.client.ts +24 -29
- package/src/helpers/MediaPlaybackWatchdog.ts +1 -0
- package/src/helpers/__tests__/browsers.test.ts +12 -12
- package/src/helpers/browsers.ts +5 -5
- package/src/reporting/ClientEventReporter.ts +17 -12
- package/src/reporting/__tests__/ClientEventReporter.test.ts +52 -0
- package/src/rtc/BasePeerConnection.ts +15 -34
- package/src/rtc/IceTrickleBuffer.ts +105 -12
- package/src/rtc/Publisher.ts +23 -19
- package/src/rtc/Subscriber.ts +97 -36
- package/src/rtc/__tests__/Call.reconnect.test.ts +45 -45
- package/src/rtc/__tests__/IceTrickleBuffer.test.ts +127 -0
- package/src/rtc/__tests__/Publisher.test.ts +2 -31
- package/src/rtc/__tests__/Subscriber.test.ts +271 -20
- package/src/rtc/helpers/__tests__/iceCandiates.test.ts +88 -0
- package/src/rtc/helpers/degradationPreference.ts +1 -0
- package/src/rtc/helpers/iceCandiates.ts +35 -0
- package/src/rtc/helpers/sdp.ts +3 -2
- package/src/rtc/helpers/tracks.ts +2 -0
- package/src/rtc/types.ts +2 -0
- package/src/stats/SfuStatsReporter.ts +149 -49
- package/src/stats/__tests__/SfuStatsReporter.test.ts +235 -0
- package/src/stats/rtc/StatsTracer.ts +90 -32
- package/src/stats/rtc/Tracer.ts +23 -2
- package/src/stats/rtc/__tests__/StatsTracer.test.ts +213 -6
- package/src/stats/rtc/__tests__/Tracer.test.ts +34 -0
- 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
|
-
|
|
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
|
-
//
|
|
1295
|
-
//
|
|
1296
|
-
|
|
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(
|
|
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
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
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
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2474
|
-
|
|
2475
|
-
|
|
2521
|
+
return userIdsToMute.length > 0
|
|
2522
|
+
? this.muteUser(userIdsToMute, type)
|
|
2523
|
+
: undefined;
|
|
2476
2524
|
};
|
|
2477
2525
|
|
|
2478
2526
|
/**
|
package/src/StreamSfuClient.ts
CHANGED
|
@@ -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
|
|
177
|
+
private connectionCheckInterval?: number;
|
|
170
178
|
private migrateAwayTimeout?: NodeJS.Timeout;
|
|
171
179
|
private readonly pingIntervalInMs = 5 * 1000;
|
|
172
|
-
private readonly unhealthyTimeoutInMs =
|
|
173
|
-
private
|
|
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 (`
|
|
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 =
|
|
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.
|
|
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
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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
|
-
|
|
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
|
-
|
|
405
|
-
|
|
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
|
-
|
|
715
|
-
this.
|
|
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.
|
|
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 = [];
|