@stream-io/video-client 1.61.2 → 1.62.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.
@@ -20,6 +20,8 @@ import { Dispatcher } from '../Dispatcher';
20
20
  import { StreamSfuClient } from '../../StreamSfuClient';
21
21
  import { IceTrickleBuffer } from '../IceTrickleBuffer';
22
22
  import { SfuJoinError } from '../../errors';
23
+ import { settled } from '../../helpers/concurrency';
24
+ import { promiseWithResolvers } from '../../helpers/promise';
23
25
 
24
26
  vi.mock('../../StreamSfuClient', () => ({
25
27
  StreamSfuClient: vi.fn(),
@@ -131,6 +133,69 @@ describe('Call reconnect stopping conditions', () => {
131
133
  expect(call.leave).not.toHaveBeenCalled();
132
134
  });
133
135
 
136
+ describe('long-window backstop', () => {
137
+ /** Spaces consecutive `Date.now()` reads so the short window never trips. */
138
+ const advanceClockOnEveryRead = () => {
139
+ let now = 0;
140
+ vi.spyOn(Date, 'now').mockImplementation(() => (now += 7000));
141
+ };
142
+
143
+ afterEach(() => {
144
+ vi.mocked(Date.now).mockRestore();
145
+ });
146
+
147
+ it('caps slow REJOIN loops that stay under the short window', async () => {
148
+ advanceClockOnEveryRead();
149
+ const rejoinSpy = vi
150
+ .spyOn(
151
+ call as unknown as { reconnectRejoin: () => Promise<void> },
152
+ 'reconnectRejoin',
153
+ )
154
+ .mockRejectedValue(new Error('rejoin failed'));
155
+
156
+ await call['reconnect'](WebsocketReconnectStrategy.REJOIN, 'test');
157
+
158
+ expect(rejoinSpy).toHaveBeenCalledTimes(20);
159
+ expect(call.leave).toHaveBeenCalledWith({
160
+ message: 'rejoin_attempt_limit_exceeded',
161
+ });
162
+ });
163
+
164
+ it('is disabled once setRejoinAttemptLimit is called', async () => {
165
+ advanceClockOnEveryRead();
166
+ call.setRejoinAttemptLimit(10, 120);
167
+ let attempts = 0;
168
+ vi.spyOn(
169
+ call as unknown as { reconnectRejoin: () => Promise<void> },
170
+ 'reconnectRejoin',
171
+ ).mockImplementation(async () => {
172
+ if (++attempts <= 25) throw new Error('rejoin failed');
173
+ call.state.setCallingState(CallingState.JOINED);
174
+ });
175
+
176
+ await call['reconnect'](WebsocketReconnectStrategy.REJOIN, 'test');
177
+
178
+ expect(attempts).toBe(26);
179
+ expect(call.leave).not.toHaveBeenCalled();
180
+ });
181
+
182
+ it('does not count MIGRATE attempts', async () => {
183
+ advanceClockOnEveryRead();
184
+ vi.spyOn(
185
+ call as unknown as { reconnectMigrate: () => Promise<void> },
186
+ 'reconnectMigrate',
187
+ ).mockImplementation(async () => {
188
+ call.state.setCallingState(CallingState.JOINED);
189
+ });
190
+
191
+ for (let i = 0; i < 25; i++) {
192
+ await call['reconnect'](WebsocketReconnectStrategy.MIGRATE, 'test');
193
+ }
194
+
195
+ expect(call.leave).not.toHaveBeenCalled();
196
+ });
197
+ });
198
+
134
199
  it('FAST strategy is NOT counted against the rejoin budget', async () => {
135
200
  call.setRejoinAttemptLimit(2, 60);
136
201
  // stub FAST so it "succeeds" each time (we re-enter by resetting state)
@@ -952,6 +1017,117 @@ describe('Call reconnect wiring (PC event → leave)', () => {
952
1017
  PeerType.SUBSCRIBER,
953
1018
  );
954
1019
  });
1020
+
1021
+ describe('ICE gathering completed without local candidates', () => {
1022
+ const makePublisher = (onNoLocalCandidates = vi.fn()) =>
1023
+ new Publisher(
1024
+ {
1025
+ sfuClient,
1026
+ dispatcher,
1027
+ state: call.state,
1028
+ tag: 'test',
1029
+ enableTracing: false,
1030
+ onNoLocalCandidates,
1031
+ },
1032
+ [],
1033
+ );
1034
+
1035
+ const gather = (publisher: Publisher, candidates: string[]) => {
1036
+ // @ts-expect-error private field
1037
+ publisher['pc'].iceGatheringState = 'gathering';
1038
+ publisher['onIceGatherChange']();
1039
+ for (const candidate of candidates) {
1040
+ publisher['onIceCandidate']({
1041
+ candidate: { candidate, sdpMid: '0', sdpMLineIndex: 0 },
1042
+ } as unknown as RTCPeerConnectionIceEvent);
1043
+ }
1044
+ // @ts-expect-error private field
1045
+ publisher['pc'].iceGatheringState = 'complete';
1046
+ publisher['onIceGatherChange']();
1047
+ };
1048
+
1049
+ beforeEach(() => {
1050
+ sfuClient.iceTrickle = vi.fn().mockResolvedValue(undefined);
1051
+ });
1052
+
1053
+ it('reports a gathering round without candidates', () => {
1054
+ const onNoLocalCandidates = vi.fn();
1055
+ const publisher = makePublisher(onNoLocalCandidates);
1056
+
1057
+ gather(publisher, []);
1058
+
1059
+ expect(onNoLocalCandidates).toHaveBeenCalledWith(
1060
+ PeerType.PUBLISHER_UNSPECIFIED,
1061
+ );
1062
+ expect(publisher.hasNoLocalCandidates()).toBe(true);
1063
+ });
1064
+
1065
+ it('does not report a round that gathered a candidate', () => {
1066
+ const onNoLocalCandidates = vi.fn();
1067
+ const publisher = makePublisher(onNoLocalCandidates);
1068
+
1069
+ gather(publisher, []);
1070
+ gather(publisher, [
1071
+ 'candidate:1 1 udp 2122260223 10.0.0.1 50000 typ host',
1072
+ ]);
1073
+
1074
+ expect(onNoLocalCandidates).toHaveBeenCalledTimes(1);
1075
+ expect(publisher.hasNoLocalCandidates()).toBe(false);
1076
+ });
1077
+
1078
+ it('does not report once ICE has connected', () => {
1079
+ const onNoLocalCandidates = vi.fn();
1080
+ const publisher = makePublisher(onNoLocalCandidates);
1081
+ // @ts-expect-error private field
1082
+ publisher['pc'].iceConnectionState = 'connected';
1083
+ publisher['onIceConnectionStateChange']();
1084
+
1085
+ gather(publisher, []);
1086
+
1087
+ expect(onNoLocalCandidates).not.toHaveBeenCalled();
1088
+ });
1089
+
1090
+ it('does not report after dispose', async () => {
1091
+ const onNoLocalCandidates = vi.fn();
1092
+ const publisher = makePublisher(onNoLocalCandidates);
1093
+ await publisher.dispose();
1094
+
1095
+ gather(publisher, []);
1096
+
1097
+ expect(onNoLocalCandidates).not.toHaveBeenCalled();
1098
+ });
1099
+ });
1100
+
1101
+ it.each([
1102
+ ['reconnecting', CallingState.RECONNECTING, false],
1103
+ ['offline', CallingState.OFFLINE, false],
1104
+ ['restarting ICE', CallingState.JOINED, true],
1105
+ ])(
1106
+ 'records an ICE connection established while %s',
1107
+ (_, callingState, isIceRestarting) => {
1108
+ const onIceConnected = vi.fn();
1109
+ call.state.setCallingState(callingState);
1110
+ const publisher = new Publisher(
1111
+ {
1112
+ sfuClient,
1113
+ dispatcher,
1114
+ state: call.state,
1115
+ tag: 'test',
1116
+ enableTracing: false,
1117
+ onIceConnected,
1118
+ },
1119
+ [],
1120
+ );
1121
+ publisher['isIceRestarting'] = isIceRestarting;
1122
+
1123
+ // @ts-expect-error private field
1124
+ publisher['pc'].iceConnectionState = 'connected';
1125
+ publisher['onIceConnectionStateChange']();
1126
+
1127
+ expect(publisher.hasIceEverConnected()).toBe(true);
1128
+ expect(onIceConnected).toHaveBeenCalledTimes(1);
1129
+ },
1130
+ );
955
1131
  });
956
1132
 
957
1133
  /**
@@ -1028,3 +1204,301 @@ describe('Call.leave() reconnect-state reset', () => {
1028
1204
  expect(call['consecutiveNegotiationFailures']).toBe(0);
1029
1205
  });
1030
1206
  });
1207
+
1208
+ /**
1209
+ * The SFU reports `PARTICIPANT_MEDIA_TRANSPORT_FAILURE` (205) when a peer
1210
+ * connection never establishes its transport, e.g. when the device gathers
1211
+ * no ICE candidates at all. That must count toward the unsupported-network
1212
+ * budget, otherwise the SFU-driven REJOIN loops forever.
1213
+ */
1214
+ describe('Call SFU media transport failure', () => {
1215
+ let call: Call;
1216
+
1217
+ const dispatchSfuError = (code: ErrorCode, tag = 'active') => {
1218
+ call['dispatcher'].dispatch(
1219
+ {
1220
+ eventPayload: {
1221
+ oneofKind: 'error',
1222
+ error: {
1223
+ error: {
1224
+ code,
1225
+ message: 'publisher PC negotiation failed',
1226
+ shouldRetry: false,
1227
+ },
1228
+ reconnectStrategy: WebsocketReconnectStrategy.REJOIN,
1229
+ },
1230
+ },
1231
+ },
1232
+ tag,
1233
+ );
1234
+ };
1235
+
1236
+ const setPeerConnections = (connected: {
1237
+ publisher: boolean;
1238
+ subscriber: boolean;
1239
+ }) => {
1240
+ call['publisher'] = {
1241
+ hasIceEverConnected: () => connected.publisher,
1242
+ } as unknown as Publisher;
1243
+ call['subscriber'] = {
1244
+ hasIceEverConnected: () => connected.subscriber,
1245
+ } as unknown as Subscriber;
1246
+ };
1247
+
1248
+ beforeEach(() => {
1249
+ call = makeCall();
1250
+ vi.spyOn(connectionUtils, 'sleep').mockResolvedValue(undefined);
1251
+ vi.spyOn(call, 'leave').mockResolvedValue(undefined);
1252
+ vi.spyOn(call, 'get').mockResolvedValue({} as never);
1253
+ vi.spyOn(
1254
+ call as unknown as { reconnectRejoin: () => Promise<void> },
1255
+ 'reconnectRejoin',
1256
+ ).mockImplementation(async () => {
1257
+ call.state.setCallingState(CallingState.JOINED);
1258
+ });
1259
+ call['registerReconnectHandlers']();
1260
+ call['sfuClient'] = { tag: 'active' } as unknown as StreamSfuClient;
1261
+ call.state.setCallingState(CallingState.JOINED);
1262
+ });
1263
+
1264
+ afterEach(() => {
1265
+ call['publisher'] = undefined;
1266
+ call['subscriber'] = undefined;
1267
+ call['sfuClient'] = undefined;
1268
+ vi.clearAllMocks();
1269
+ });
1270
+
1271
+ it('leaves with webrtc_unsupported_network after two failures before connect', async () => {
1272
+ call.setMaxIceFailuresWithoutConnect(2);
1273
+ setPeerConnections({ publisher: false, subscriber: false });
1274
+
1275
+ dispatchSfuError(ErrorCode.PARTICIPANT_MEDIA_TRANSPORT_FAILURE);
1276
+ await settled(call['reconnectConcurrencyTag']);
1277
+ expect(call['iceFailuresWithoutConnect']).toBe(1);
1278
+ expect(call.leave).not.toHaveBeenCalled();
1279
+
1280
+ dispatchSfuError(ErrorCode.PARTICIPANT_MEDIA_TRANSPORT_FAILURE);
1281
+ await settled(call['reconnectConcurrencyTag']);
1282
+ expect(call.leave).toHaveBeenCalledWith({
1283
+ message: 'webrtc_unsupported_network',
1284
+ });
1285
+ });
1286
+
1287
+ it('does not count the failure when a peer connection has connected', async () => {
1288
+ call.setMaxIceFailuresWithoutConnect(1);
1289
+ setPeerConnections({ publisher: false, subscriber: true });
1290
+
1291
+ dispatchSfuError(ErrorCode.PARTICIPANT_MEDIA_TRANSPORT_FAILURE);
1292
+ await settled(call['reconnectConcurrencyTag']);
1293
+
1294
+ expect(call['iceFailuresWithoutConnect']).toBe(0);
1295
+ expect(call.leave).not.toHaveBeenCalled();
1296
+ expect(call['reconnectRejoin']).toHaveBeenCalledTimes(1);
1297
+ });
1298
+
1299
+ it('ignores errors from a superseded SFU client', async () => {
1300
+ call.setMaxIceFailuresWithoutConnect(1);
1301
+ setPeerConnections({ publisher: false, subscriber: false });
1302
+ call['sfuClient'] = { tag: '2' } as unknown as StreamSfuClient;
1303
+
1304
+ dispatchSfuError(ErrorCode.PARTICIPANT_MEDIA_TRANSPORT_FAILURE, '1');
1305
+ await settled(call['reconnectConcurrencyTag']);
1306
+ expect(call['reconnectRejoin']).not.toHaveBeenCalled();
1307
+
1308
+ dispatchSfuError(ErrorCode.PARTICIPANT_MEDIA_TRANSPORT_FAILURE, '2');
1309
+ await settled(call['reconnectConcurrencyTag']);
1310
+ expect(call.leave).toHaveBeenCalledWith({
1311
+ message: 'webrtc_unsupported_network',
1312
+ });
1313
+ });
1314
+
1315
+ it('ignores errors while no SFU client is active', async () => {
1316
+ call.setMaxIceFailuresWithoutConnect(1);
1317
+ setPeerConnections({ publisher: false, subscriber: false });
1318
+ call['sfuClient'] = undefined;
1319
+
1320
+ dispatchSfuError(ErrorCode.PARTICIPANT_MEDIA_TRANSPORT_FAILURE, '1');
1321
+ await settled(call['reconnectConcurrencyTag']);
1322
+
1323
+ expect(call['reconnectRejoin']).not.toHaveBeenCalled();
1324
+ expect(call.leave).not.toHaveBeenCalled();
1325
+ });
1326
+
1327
+ it('does not count other SFU errors before connect', async () => {
1328
+ call.setMaxIceFailuresWithoutConnect(1);
1329
+ setPeerConnections({ publisher: false, subscriber: false });
1330
+
1331
+ dispatchSfuError(ErrorCode.PARTICIPANT_SIGNAL_LOST);
1332
+ await settled(call['reconnectConcurrencyTag']);
1333
+
1334
+ expect(call['iceFailuresWithoutConnect']).toBe(0);
1335
+ expect(call.leave).not.toHaveBeenCalled();
1336
+ });
1337
+ });
1338
+
1339
+ /**
1340
+ * A peer connection that gathered no local ICE candidates reports it to
1341
+ * `Call`, which escalates once no join or reconnect is in flight, so the
1342
+ * escalation is neither dropped by `reconnect` nor raced against a join.
1343
+ */
1344
+ describe('Call no-local-candidates escalation', () => {
1345
+ let call: Call;
1346
+
1347
+ const setPeerConnections = (pcs: {
1348
+ connected?: boolean;
1349
+ noLocalCandidates?: boolean;
1350
+ }) => {
1351
+ const fake = {
1352
+ hasIceEverConnected: () => pcs.connected ?? false,
1353
+ hasNoLocalCandidates: () => pcs.noLocalCandidates ?? true,
1354
+ };
1355
+ call['publisher'] = fake as unknown as Publisher;
1356
+ call['subscriber'] = {
1357
+ hasIceEverConnected: () => false,
1358
+ hasNoLocalCandidates: () => false,
1359
+ } as unknown as Subscriber;
1360
+ };
1361
+
1362
+ const flush = async () => {
1363
+ await call['waitForJoinsAndReconnects']();
1364
+ await new Promise<void>((r) => setTimeout(r, 0));
1365
+ await settled(call['reconnectConcurrencyTag']);
1366
+ };
1367
+
1368
+ beforeEach(() => {
1369
+ call = makeCall();
1370
+ vi.spyOn(connectionUtils, 'sleep').mockResolvedValue(undefined);
1371
+ vi.spyOn(call, 'leave').mockResolvedValue(undefined);
1372
+ vi.spyOn(call, 'get').mockResolvedValue({} as never);
1373
+ vi.spyOn(
1374
+ call as unknown as { reconnectRejoin: () => Promise<void> },
1375
+ 'reconnectRejoin',
1376
+ ).mockImplementation(async () => {
1377
+ call.state.setCallingState(CallingState.JOINED);
1378
+ });
1379
+ call.state.setCallingState(CallingState.JOINED);
1380
+ });
1381
+
1382
+ afterEach(() => {
1383
+ call['publisher'] = undefined;
1384
+ call['subscriber'] = undefined;
1385
+ vi.unstubAllGlobals();
1386
+ vi.clearAllMocks();
1387
+ });
1388
+
1389
+ it('escalates right away when the call is idle', async () => {
1390
+ setPeerConnections({});
1391
+
1392
+ call['checkNoLocalCandidates']();
1393
+ await flush();
1394
+
1395
+ expect(call['reconnectRejoin']).toHaveBeenCalledTimes(1);
1396
+ expect(call['iceFailuresWithoutConnect']).toBe(1);
1397
+ });
1398
+
1399
+ it('defers the escalation until an in-flight reconnect settles', async () => {
1400
+ setPeerConnections({});
1401
+ const rejoin = promiseWithResolvers<void>();
1402
+ vi.mocked(call['reconnectRejoin']).mockImplementationOnce(async () => {
1403
+ await rejoin.promise;
1404
+ call.state.setCallingState(CallingState.JOINED);
1405
+ });
1406
+ call['reconnect'](WebsocketReconnectStrategy.REJOIN, 'test');
1407
+
1408
+ call['checkNoLocalCandidates']();
1409
+ await new Promise<void>((r) => setTimeout(r, 0));
1410
+ expect(call['iceFailuresWithoutConnect']).toBe(0);
1411
+
1412
+ rejoin.resolve();
1413
+ await flush();
1414
+
1415
+ expect(call['reconnectRejoin']).toHaveBeenCalledTimes(2);
1416
+ expect(call['iceFailuresWithoutConnect']).toBe(1);
1417
+ });
1418
+
1419
+ it('defers the escalation until an in-flight join settles', async () => {
1420
+ setPeerConnections({});
1421
+ const join = promiseWithResolvers<void>();
1422
+ vi.spyOn(
1423
+ call as unknown as { performJoin: () => Promise<void> },
1424
+ 'performJoin',
1425
+ ).mockImplementation(async () => {
1426
+ await join.promise;
1427
+ });
1428
+ const joining = call['doJoin']();
1429
+
1430
+ call['checkNoLocalCandidates']();
1431
+ await new Promise<void>((r) => setTimeout(r, 0));
1432
+ expect(call['reconnectRejoin']).not.toHaveBeenCalled();
1433
+
1434
+ join.resolve();
1435
+ await joining;
1436
+ await flush();
1437
+
1438
+ expect(call['reconnectRejoin']).toHaveBeenCalledTimes(1);
1439
+ expect(call['iceFailuresWithoutConnect']).toBe(1);
1440
+ });
1441
+
1442
+ it('does not escalate when a peer connection has connected', async () => {
1443
+ setPeerConnections({ connected: true });
1444
+
1445
+ call['checkNoLocalCandidates']();
1446
+ await flush();
1447
+
1448
+ expect(call['reconnectRejoin']).not.toHaveBeenCalled();
1449
+ });
1450
+
1451
+ it('does not escalate when the current peer connections gathered candidates', async () => {
1452
+ setPeerConnections({ noLocalCandidates: false });
1453
+
1454
+ call['checkNoLocalCandidates']();
1455
+ await flush();
1456
+
1457
+ expect(call['reconnectRejoin']).not.toHaveBeenCalled();
1458
+ });
1459
+
1460
+ it('does not escalate while the browser is offline', async () => {
1461
+ setPeerConnections({});
1462
+ vi.stubGlobal('navigator', { onLine: false });
1463
+
1464
+ call['checkNoLocalCandidates']();
1465
+ await flush();
1466
+
1467
+ expect(call['reconnectRejoin']).not.toHaveBeenCalled();
1468
+ });
1469
+
1470
+ it('drops a deferred escalation when the call is left meanwhile', async () => {
1471
+ setPeerConnections({});
1472
+ const join = promiseWithResolvers<void>();
1473
+ vi.spyOn(
1474
+ call as unknown as { performJoin: () => Promise<void> },
1475
+ 'performJoin',
1476
+ ).mockImplementation(async () => {
1477
+ await join.promise;
1478
+ });
1479
+ const joining = call['doJoin']();
1480
+ call['checkNoLocalCandidates']();
1481
+
1482
+ call['leaveGeneration']++;
1483
+ join.resolve();
1484
+ await joining;
1485
+ await flush();
1486
+
1487
+ expect(call['reconnectRejoin']).not.toHaveBeenCalled();
1488
+ });
1489
+
1490
+ it('leaves with webrtc_unsupported_network after two sessions without candidates', async () => {
1491
+ call.setMaxIceFailuresWithoutConnect(2);
1492
+ setPeerConnections({});
1493
+
1494
+ call['checkNoLocalCandidates']();
1495
+ await flush();
1496
+ expect(call.leave).not.toHaveBeenCalled();
1497
+
1498
+ call['checkNoLocalCandidates']();
1499
+ await flush();
1500
+ expect(call.leave).toHaveBeenCalledWith({
1501
+ message: 'webrtc_unsupported_network',
1502
+ });
1503
+ });
1504
+ });
package/src/rtc/types.ts CHANGED
@@ -56,6 +56,13 @@ export type OnReconnectionNeeded = (
56
56
  */
57
57
  export type OnIceConnected = (peerType: PeerType) => void;
58
58
 
59
+ /**
60
+ * Fires when an ICE gathering round completes without producing a single
61
+ * local candidate on a peer connection that never connected. Used by `Call`
62
+ * to detect devices whose WebRTC networking is blocked.
63
+ */
64
+ export type OnNoLocalCandidates = (peerType: PeerType) => void;
65
+
59
66
  /**
60
67
  * Snapshot of the peer connection's ICE and DTLS state surfaced to telemetry
61
68
  * consumers (e.g. `ClientEventReporter`). Fired on every transition of
@@ -96,6 +103,7 @@ export type BasePeerConnectionOpts = {
96
103
  dispatcher: Dispatcher;
97
104
  onReconnectionNeeded?: OnReconnectionNeeded;
98
105
  onIceConnected?: OnIceConnected;
106
+ onNoLocalCandidates?: OnNoLocalCandidates;
99
107
  onPeerConnectionStateChange?: OnPeerConnectionStateChange;
100
108
  onRemoteTrackUnmute?: OnRemoteTrackUnmute;
101
109
  tag: string;