@stream-io/video-client 1.59.1 → 1.61.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 (62) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/index.browser.es.js +744 -156
  3. package/dist/index.browser.es.js.map +1 -1
  4. package/dist/index.cjs.js +744 -155
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.d.ts +1 -0
  7. package/dist/index.es.js +744 -156
  8. package/dist/index.es.js.map +1 -1
  9. package/dist/src/Call.d.ts +34 -8
  10. package/dist/src/coordinator/connection/types.d.ts +16 -0
  11. package/dist/src/events/call.d.ts +0 -11
  12. package/dist/src/gen/coordinator/index.d.ts +73 -0
  13. package/dist/src/reporting/ClientEventReporter.d.ts +11 -1
  14. package/dist/src/ringing/RingStatePoller.d.ts +46 -0
  15. package/dist/src/ringing/RingTimeout.d.ts +27 -0
  16. package/dist/src/ringing/index.d.ts +4 -0
  17. package/dist/src/ringing/reconcileRingState.d.ts +18 -0
  18. package/dist/src/ringing/resolveOwnRingOutcome.d.ts +32 -0
  19. package/dist/src/rtc/Publisher.d.ts +18 -0
  20. package/dist/src/rtc/TransceiverCache.d.ts +10 -0
  21. package/dist/src/rtc/e2ee/E2EEManager.d.ts +10 -1
  22. package/dist/src/stats/utils.d.ts +6 -0
  23. package/dist/src/store/CallState.d.ts +10 -1
  24. package/dist/src/types.d.ts +30 -1
  25. package/index.ts +1 -0
  26. package/package.json +1 -1
  27. package/src/Call.ts +272 -108
  28. package/src/StreamSfuClient.ts +6 -2
  29. package/src/__tests__/Call.autodrop.test.ts +116 -64
  30. package/src/__tests__/Call.getRingState.test.ts +87 -0
  31. package/src/__tests__/Call.lifecycle.test.ts +498 -5
  32. package/src/__tests__/Call.ringSettled.test.ts +71 -0
  33. package/src/__tests__/Call.ringingLifecycle.test.ts +242 -0
  34. package/src/__tests__/StreamSfuClient.test.ts +48 -0
  35. package/src/coordinator/connection/types.ts +19 -0
  36. package/src/events/__tests__/call.test.ts +2 -217
  37. package/src/events/call.ts +1 -73
  38. package/src/events/callEventHandlers.ts +12 -23
  39. package/src/gen/coordinator/index.ts +67 -0
  40. package/src/reporting/ClientEventReporter.ts +30 -4
  41. package/src/reporting/__tests__/ClientEventReporter.test.ts +184 -7
  42. package/src/ringing/RingStatePoller.ts +177 -0
  43. package/src/ringing/RingTimeout.ts +90 -0
  44. package/src/ringing/__tests__/RingStatePoller.test.ts +462 -0
  45. package/src/ringing/__tests__/RingTimeout.test.ts +199 -0
  46. package/src/ringing/__tests__/reconcileRingState.test.ts +330 -0
  47. package/src/ringing/__tests__/resolveOwnRingOutcome.test.ts +103 -0
  48. package/src/ringing/index.ts +4 -0
  49. package/src/ringing/reconcileRingState.ts +108 -0
  50. package/src/ringing/resolveOwnRingOutcome.ts +58 -0
  51. package/src/rtc/Publisher.ts +66 -12
  52. package/src/rtc/TransceiverCache.ts +17 -0
  53. package/src/rtc/__tests__/Publisher.test.ts +209 -0
  54. package/src/rtc/__tests__/mocks/webrtc.mocks.ts +1 -0
  55. package/src/rtc/e2ee/E2EEManager.ts +10 -1
  56. package/src/rtc/e2ee/EncryptionManager.ts +1 -1
  57. package/src/rtc/e2ee/SPEC.md +1 -1
  58. package/src/stats/__tests__/utils.test.ts +33 -0
  59. package/src/stats/utils.ts +15 -0
  60. package/src/store/CallState.ts +25 -0
  61. package/src/store/__tests__/CallState.test.ts +59 -0
  62. package/src/types.ts +37 -1
package/src/Call.ts CHANGED
@@ -50,6 +50,7 @@ import {
50
50
  EndCallResponse,
51
51
  GetCallReportResponse,
52
52
  GetCallResponse,
53
+ GetCallRingStateResponse,
53
54
  GetCallSessionParticipantStatsDetailsResponse,
54
55
  GetOrCreateCallRequest,
55
56
  GetOrCreateCallResponse,
@@ -152,7 +153,7 @@ import {
152
153
  StatsReporter,
153
154
  Tracer,
154
155
  } from './stats';
155
- import type { ClientEventReporter, JoinReason } from './reporting';
156
+ import type { ClientEventReporter, JoinReason, JoinSource } from './reporting';
156
157
  import { AudioBindingsWatchdog } from './helpers/AudioBindingsWatchdog';
157
158
  import { BlockedAudioTracker } from './helpers/BlockedAudioTracker';
158
159
  import { TrackSubscriptionManager } from './helpers/TrackSubscriptionManager';
@@ -160,6 +161,7 @@ import { DynascaleManager } from './helpers/DynascaleManager';
160
161
  import { createFirstVideoFrameDetector } from './helpers/firstVideoFrame';
161
162
  import { ViewportTracker } from './helpers/ViewportTracker';
162
163
  import { PermissionsContext } from './permissions';
164
+ import { RingStatePoller, RingTimeout, resolveOwnRingOutcome } from './ringing';
163
165
  import { CallTypes } from './CallType';
164
166
  import { StreamClient } from './coordinator/connection/client';
165
167
  import { retryInterval, sleep } from './coordinator/connection/utils';
@@ -302,7 +304,8 @@ export class Call {
302
304
  private statsReporter?: StatsReporter;
303
305
  private sfuStatsReporter?: SfuStatsReporter;
304
306
  private lastStatsOptions?: StatsOptions;
305
- private dropTimeout: ReturnType<typeof setTimeout> | undefined;
307
+ private ringTimeout: RingTimeout | undefined;
308
+ private ringStatePoller: RingStatePoller | undefined;
306
309
 
307
310
  private readonly clientStore: StreamVideoWriteableStateStore;
308
311
  public readonly streamClient: StreamClient;
@@ -537,33 +540,20 @@ export class Call {
537
540
  createSubscription(this.state.session$, (session) => {
538
541
  if (!this.ringing) return;
539
542
 
540
- const receiverId = this.clientStore.connectedUser?.id;
541
- if (!receiverId) return;
542
-
543
- const isAcceptedByMe = Boolean(session?.accepted_by[receiverId]);
544
- const isRejectedByMe = Boolean(session?.rejected_by[receiverId]);
545
-
546
- if (isAcceptedByMe || isRejectedByMe) {
547
- this.cancelAutoDrop();
548
- }
549
-
550
- const isAcceptedElsewhere =
551
- isAcceptedByMe && this.state.callingState === CallingState.RINGING;
543
+ const { settledByMe, leaveReason } = resolveOwnRingOutcome({
544
+ session,
545
+ currentUserId: this.currentUserId,
546
+ callingState: this.state.callingState,
547
+ });
548
+ if (settledByMe) this.cancelAutoDrop();
549
+ if (!leaveReason || hasPending(this.joinLeaveConcurrencyTag)) return;
552
550
 
553
- if (
554
- (isAcceptedElsewhere || isRejectedByMe) &&
555
- !hasPending(this.joinLeaveConcurrencyTag)
556
- ) {
557
- globalThis.streamRNVideoSDK?.callingX?.endCall(
558
- this,
559
- isAcceptedElsewhere ? 'answeredElsewhere' : 'rejected',
551
+ globalThis.streamRNVideoSDK?.callingX?.endCall(this, leaveReason);
552
+ this.leave().catch(() => {
553
+ this.logger.error(
554
+ 'Could not leave a call that was accepted or rejected elsewhere',
560
555
  );
561
- this.leave().catch(() => {
562
- this.logger.error(
563
- 'Could not leave a call that was accepted or rejected elsewhere',
564
- );
565
- });
566
- }
556
+ });
567
557
  }),
568
558
  );
569
559
  };
@@ -608,6 +598,7 @@ export class Call {
608
598
  this.state.setCallingState(CallingState.RINGING);
609
599
  }
610
600
  this.scheduleAutoDrop();
601
+ this.scheduleRingStatePolling();
611
602
  this.leaveCallHooks.add(registerRingingCallEventHandlers(this));
612
603
  }
613
604
  };
@@ -713,6 +704,13 @@ export class Call {
713
704
  throw new Error('Cannot leave call that has already been left.');
714
705
  }
715
706
 
707
+ // before the first await: the calling state stays RINGING well into the
708
+ // teardown, so pause both watchdogs before they can race this leave. They
709
+ // keep their deadlines, so a failed leave resumes them without handing the
710
+ // ring another window.
711
+ this.ringTimeout?.pause();
712
+ this.ringStatePoller?.pause();
713
+
716
714
  await withoutConcurrency(this.joinLeaveConcurrencyTag, async () => {
717
715
  const callingState = this.state.callingState;
718
716
 
@@ -796,6 +794,11 @@ export class Call {
796
794
  await this.dynascaleManager?.dispose();
797
795
 
798
796
  this.state.setCallingState(CallingState.LEFT);
797
+ // `ringingSubject` is cleared further down, so this still reads true for
798
+ // a call that was ringing.
799
+ if (this.ringing) {
800
+ globalThis.streamRNVideoSDK?.ringingCallLifecycle?.onLeave(this);
801
+ }
799
802
  this.state.setParticipants([]);
800
803
  this.state.dispose();
801
804
 
@@ -821,6 +824,7 @@ export class Call {
821
824
  this.unifiedSessionId = undefined;
822
825
  this.ringingSubject.next(false);
823
826
  this.cancelAutoDrop();
827
+ this.cancelRingStatePolling();
824
828
  this.clientStore.unregisterCall(this);
825
829
 
826
830
  globalThis.streamRNVideoSDK?.callManager.stop({
@@ -863,6 +867,17 @@ export class Call {
863
867
  this.logger.warn('Failed to dispose media engine', err);
864
868
  });
865
869
  }
870
+ }).catch((err) => {
871
+ if (
872
+ !hasPending(this.joinLeaveConcurrencyTag) &&
873
+ this.state.callingState === CallingState.RINGING
874
+ ) {
875
+ // resume, never re-arm: a fresh watchdog would restart the ring
876
+ // window this leave was already most of the way through
877
+ this.ringTimeout?.start();
878
+ this.ringStatePoller?.resume();
879
+ }
880
+ throw err;
866
881
  });
867
882
  };
868
883
 
@@ -973,12 +988,14 @@ export class Call {
973
988
  members_limit?: number;
974
989
  video?: boolean;
975
990
  }): Promise<GetCallResponse> => {
991
+ const getLeaveGeneration = this.leaveGeneration;
976
992
  await this.setup();
977
993
 
978
994
  const response = await this.streamClient.get<GetCallResponse>(
979
995
  this.streamClientBasePath,
980
996
  params,
981
997
  );
998
+ if (this.leaveGeneration !== getLeaveGeneration) return response;
982
999
 
983
1000
  this.updateFromCallStateResponse(response);
984
1001
 
@@ -1056,6 +1073,27 @@ export class Call {
1056
1073
  );
1057
1074
  };
1058
1075
 
1076
+ /**
1077
+ * Returns who accepted, rejected or missed the ring for a call session.
1078
+ * Safe to poll: it performs no writes and emits no events.
1079
+ *
1080
+ * @param callSessionId the call session to read. Defaults to the current one.
1081
+ * Pass it explicitly to read a session that has already ended, as ending a
1082
+ * call clears its current session.
1083
+ */
1084
+ getRingState = async (
1085
+ callSessionId?: string,
1086
+ ): Promise<GetCallRingStateResponse> => {
1087
+ const sessionId = callSessionId ?? this.state.session?.id;
1088
+ if (!sessionId) {
1089
+ throw new Error('Cannot read the ring state: the call has no session');
1090
+ }
1091
+ return this.streamClient.get<GetCallRingStateResponse>(
1092
+ `${this.streamClientBasePath}/ring_state`,
1093
+ { call_session_id: sessionId },
1094
+ );
1095
+ };
1096
+
1059
1097
  /**
1060
1098
  * A shortcut for {@link Call.get} with `notify` parameter set to `true`.
1061
1099
  * Will send a `call.notification` event to the call members.
@@ -1104,6 +1142,10 @@ export class Call {
1104
1142
  /**
1105
1143
  * Will start to watch for call related WebSocket events and initiate a call session with the server.
1106
1144
  *
1145
+ * One instance is one call flow: discard it after leaving, cancelling, or a
1146
+ * failed join, and create a fresh one for a later flow. Reconnection inside a
1147
+ * live call is handled here and needs no new instance.
1148
+ *
1107
1149
  * @returns a promise which resolves once the call join-flow has finished.
1108
1150
  */
1109
1151
  join = singleFlight(
@@ -1112,19 +1154,30 @@ export class Call {
1112
1154
  joinResponseTimeout,
1113
1155
  rpcRequestTimeout,
1114
1156
  allowOwnTracksLoopback = false,
1157
+ joinSource,
1115
1158
  ...data
1116
1159
  }: JoinCallData & {
1117
1160
  maxJoinRetries?: number;
1118
1161
  joinResponseTimeout?: number;
1119
1162
  rpcRequestTimeout?: number;
1120
1163
  allowOwnTracksLoopback?: boolean;
1164
+ joinSource?: JoinSource;
1121
1165
  } = {}): Promise<void> => {
1122
1166
  const callingState = this.state.callingState;
1123
1167
 
1168
+ // Ahead of the failure boundary below on purpose: a duplicate join on a
1169
+ // live call is refused without tearing that call down.
1124
1170
  if ([CallingState.JOINED, CallingState.JOINING].includes(callingState)) {
1125
1171
  throw new Error(`Illegal State: call.join() shall be called only once`);
1126
1172
  }
1127
1173
 
1174
+ // snapshot before the first await: a leave() landing at any point from
1175
+ // here on supersedes this join, and the retry loop must bail out instead
1176
+ // of resurrecting a call that leave() already tore down.
1177
+ const joinLeaveGeneration = this.leaveGeneration;
1178
+ const supersededByLeave = () =>
1179
+ this.leaveGeneration !== joinLeaveGeneration;
1180
+
1128
1181
  // we need this to be set before the callingx.joinCall() is
1129
1182
  // called to avoid registering the test call in the CallKit/Telecom
1130
1183
  this.allowOwnTracksLoopback = allowOwnTracksLoopback;
@@ -1133,35 +1186,67 @@ export class Call {
1133
1186
  this.ringingSubject.next(true);
1134
1187
  }
1135
1188
 
1189
+ // A ringing call is joined by the SDK rather than by app code, so React
1190
+ // Native prepares it from in here - there is no earlier point at which the
1191
+ // app holds the call. Read after `data.ring` above, which is what makes an
1192
+ // outgoing call ringing in the first place.
1193
+ const ringingLifecycle = this.ringing
1194
+ ? globalThis.streamRNVideoSDK?.ringingCallLifecycle
1195
+ : undefined;
1136
1196
  const callingX = globalThis.streamRNVideoSDK?.callingX;
1137
- if (callingX) {
1138
- // for Android/iOS, we need to start the call in the callingx library as soon as possible
1139
- await callingX.joinCall(this, this.clientStore.calls);
1140
- }
1197
+ try {
1198
+ if (ringingLifecycle) {
1199
+ await ringingLifecycle.beforeJoin(this);
1200
+ if (supersededByLeave()) {
1201
+ this.logger.debug('Join superseded by leave; not joining');
1202
+ return;
1203
+ }
1204
+ }
1205
+
1206
+ if (callingX) {
1207
+ // for Android/iOS, we need to start the call in the callingx library as soon as possible
1208
+ await callingX.joinCall(
1209
+ this,
1210
+ this.clientStore.calls,
1211
+ supersededByLeave,
1212
+ );
1213
+ if (supersededByLeave()) {
1214
+ this.logger.debug('Join superseded by leave; not setting up');
1215
+ return;
1216
+ }
1217
+ }
1141
1218
 
1142
- await this.setup();
1219
+ await this.setup();
1220
+ if (supersededByLeave()) {
1221
+ this.logger.debug('Join superseded by leave; not registering');
1222
+ return;
1223
+ }
1143
1224
 
1144
- this.clientEventReporter.registerCall(this.cid, {
1145
- callType: this.type,
1146
- callId: this.id,
1147
- getCallSessionId: () => this.state.session?.id ?? '',
1148
- getSfuId: () => this.credentials?.server.edge_name ?? '',
1149
- });
1225
+ this.clientEventReporter.registerCall(this.cid, {
1226
+ callType: this.type,
1227
+ callId: this.id,
1228
+ getCallSessionId: () => this.state.session?.id ?? '',
1229
+ getSfuId: () => this.credentials?.server.edge_name ?? '',
1230
+ });
1150
1231
 
1151
- this.joinResponseTimeout = joinResponseTimeout;
1152
- this.rpcRequestTimeout = rpcRequestTimeout;
1153
- // we will count the number of join failures per SFU.
1154
- // once the number of failures reaches 2, we will piggyback on the `migrating_from`
1155
- // field to force the coordinator to provide us another SFU
1156
- const sfuJoinFailures = new Map<string, number>();
1157
- const joinData: JoinCallData = data;
1158
- maxJoinRetries = Math.max(maxJoinRetries, 1);
1159
- try {
1232
+ this.joinResponseTimeout = joinResponseTimeout;
1233
+ this.rpcRequestTimeout = rpcRequestTimeout;
1234
+ // we will count the number of join failures per SFU.
1235
+ // once the number of failures reaches 2, we will piggyback on the `migrating_from`
1236
+ // field to force the coordinator to provide us another SFU
1237
+ const sfuJoinFailures = new Map<string, number>();
1238
+ const joinData: JoinCallData = data;
1239
+ maxJoinRetries = Math.max(maxJoinRetries, 1);
1160
1240
  await this.clientEventReporter.withJoinLifecycle(
1161
1241
  this.cid,
1162
- 'first-attempt',
1242
+ { joinReason: 'first-attempt', joinSource },
1163
1243
  async () => {
1164
1244
  for (let attempt = 0; attempt < maxJoinRetries; attempt++) {
1245
+ // Also covers a leave that lands during the backoff below.
1246
+ if (supersededByLeave()) {
1247
+ this.logger.debug('Join superseded by leave; not attempting');
1248
+ return;
1249
+ }
1165
1250
  try {
1166
1251
  this.logger.trace(`Joining call (${attempt})`, this.cid);
1167
1252
  await this.doJoin(data);
@@ -1169,6 +1254,10 @@ export class Call {
1169
1254
  delete joinData.migrating_from_list;
1170
1255
  return;
1171
1256
  } catch (err) {
1257
+ if (supersededByLeave()) {
1258
+ this.logger.debug('Join superseded by leave; not retrying');
1259
+ return;
1260
+ }
1172
1261
  this.logger.warn(`Failed to join call (${attempt})`, this.cid);
1173
1262
  if (
1174
1263
  (err instanceof ErrorFromResponse && err.unrecoverable) ||
@@ -1209,6 +1298,9 @@ export class Call {
1209
1298
  );
1210
1299
  } catch (error) {
1211
1300
  callingX?.endCall(this, 'error');
1301
+ // Ends the failed ringing flow and releases what its setup installed.
1302
+ // Never rejects, so `error` is what the caller sees.
1303
+ await ringingLifecycle?.onJoinFailed(this);
1212
1304
  throw error;
1213
1305
  }
1214
1306
  },
@@ -1237,6 +1329,10 @@ export class Call {
1237
1329
  // globals resolve to the call's factory. Idempotent across
1238
1330
  // reconnect/migration attempts.
1239
1331
  await this.ensureMediaFactory();
1332
+ if (supersededByLeave()) {
1333
+ this.logger.debug('Join superseded by leave; not wiring media');
1334
+ return;
1335
+ }
1240
1336
 
1241
1337
  const callingX = globalThis.streamRNVideoSDK?.callingX;
1242
1338
  if (callingX) {
@@ -1264,10 +1360,17 @@ export class Call {
1264
1360
  'CoordinatorJoin',
1265
1361
  () => this.doJoinRequest(data),
1266
1362
  );
1363
+ if (!joinResponse || supersededByLeave()) {
1364
+ this.logger.debug(
1365
+ 'Join superseded by leave; not creating SFU client',
1366
+ );
1367
+ return;
1368
+ }
1267
1369
  this.credentials = joinResponse.credentials;
1268
1370
  statsOptions = joinResponse.stats_options;
1269
1371
  this.lastStatsOptions = statsOptions;
1270
1372
  } catch (error) {
1373
+ if (supersededByLeave()) return;
1271
1374
  // prevent triggering reconnect flow if the state is OFFLINE
1272
1375
  const avoidRestoreState =
1273
1376
  this.state.callingState === CallingState.OFFLINE;
@@ -1322,6 +1425,10 @@ export class Call {
1322
1425
  getGenericSdp('recvonly', dangerouslyForceCodec, subscriberFmtpLine),
1323
1426
  getGenericSdp('sendonly', dangerouslyForceCodec, fmtpLine),
1324
1427
  ]);
1428
+ if (supersededByLeave()) {
1429
+ this.logger.debug('Join superseded by leave; not joining SFU');
1430
+ return;
1431
+ }
1325
1432
  const isReconnecting =
1326
1433
  this.reconnectStrategy !== WebsocketReconnectStrategy.UNSPECIFIED;
1327
1434
  const reconnectDetails = isReconnecting
@@ -1355,6 +1462,12 @@ export class Call {
1355
1462
  source: ParticipantSource.WEBRTC_UNSPECIFIED,
1356
1463
  }),
1357
1464
  );
1465
+ if (supersededByLeave()) {
1466
+ this.logger.debug(
1467
+ 'Join superseded by leave; not applying SFU response',
1468
+ );
1469
+ return;
1470
+ }
1358
1471
 
1359
1472
  this.currentPublishOptions = publishOptions;
1360
1473
  this.fastReconnectDeadlineSeconds = fastReconnectDeadlineSeconds;
@@ -1366,6 +1479,10 @@ export class Call {
1366
1479
  );
1367
1480
  }
1368
1481
  } catch (error) {
1482
+ if (supersededByLeave()) {
1483
+ this.logger.debug('Join superseded by leave; ignoring SFU failure');
1484
+ return;
1485
+ }
1369
1486
  this.logger.warn('Join SFU request failed', error);
1370
1487
  sfuClient.close(
1371
1488
  StreamSfuClient.JOIN_FAILED,
@@ -1377,8 +1494,8 @@ export class Call {
1377
1494
  }
1378
1495
  }
1379
1496
 
1380
- // If the user left while this join was in flight, bail before re-setting JOINED and before
1381
- // peer-connection setup below (both run synchronously after this, so one check covers them).
1497
+ // If the user left while this join was in flight, bail before re-setting JOINED
1498
+ // or starting peer-connection setup below.
1382
1499
  if (supersededByLeave()) {
1383
1500
  this.logger.debug('Join superseded by leave; aborting join flow');
1384
1501
  return;
@@ -1408,6 +1525,10 @@ export class Call {
1408
1525
  unifiedSessionId: this.unifiedSessionId,
1409
1526
  });
1410
1527
  }
1528
+ if (supersededByLeave()) {
1529
+ this.logger.debug('Join superseded by leave; not completing join flow');
1530
+ return;
1531
+ }
1411
1532
 
1412
1533
  // make sure we only track connection timing if we are not calling this method as part of a reconnection flow
1413
1534
  if (!performingRejoin && !performingFastReconnect && !performingMigration) {
@@ -1593,6 +1714,7 @@ export class Call {
1593
1714
  closePreviousInstances: boolean;
1594
1715
  unifiedSessionId: string;
1595
1716
  }) => {
1717
+ const joinLeaveGeneration = this.leaveGeneration;
1596
1718
  const {
1597
1719
  sfuClient,
1598
1720
  connectionConfig,
@@ -1609,10 +1731,12 @@ export class Call {
1609
1731
  // Flush the previous reporter's final sample while its peer connections are
1610
1732
  // still alive, before we dispose them below. Awaits only the sampling step.
1611
1733
  await this.sfuStatsReporter?.flush();
1734
+ if (this.leaveGeneration !== joinLeaveGeneration) return;
1612
1735
  this.sfuStatsReporter?.stop();
1613
1736
  this.sfuStatsReporter = undefined;
1614
1737
  if (closePreviousInstances && this.subscriber) {
1615
1738
  await this.subscriber.dispose();
1739
+ if (this.leaveGeneration !== joinLeaveGeneration) return;
1616
1740
  this.state.removeAllOrphanedTracks();
1617
1741
  }
1618
1742
  const basePeerConnectionOptions: BasePeerConnectionOpts = {
@@ -1659,6 +1783,7 @@ export class Call {
1659
1783
  if (!isAnonymous) {
1660
1784
  if (closePreviousInstances && this.publisher) {
1661
1785
  await this.publisher.dispose();
1786
+ if (this.leaveGeneration !== joinLeaveGeneration) return;
1662
1787
  }
1663
1788
  this.publisher = new Publisher(
1664
1789
  basePeerConnectionOptions,
@@ -1702,9 +1827,19 @@ export class Call {
1702
1827
  *
1703
1828
  * @internal
1704
1829
  * @param data the join call data.
1830
+ * @returns The coordinator response, or undefined if leave superseded the request before it was sent.
1705
1831
  */
1706
- doJoinRequest = async (data?: JoinCallData): Promise<JoinCallResponse> => {
1832
+ doJoinRequest = async (
1833
+ data?: JoinCallData,
1834
+ ): Promise<JoinCallResponse | undefined> => {
1835
+ const joinLeaveGeneration = this.leaveGeneration;
1707
1836
  const location = await this.streamClient.getLocationHint();
1837
+ if (this.leaveGeneration !== joinLeaveGeneration) {
1838
+ this.logger.debug(
1839
+ 'Join superseded by leave; not sending coordinator request',
1840
+ );
1841
+ return;
1842
+ }
1708
1843
  const e2ee = !!this.e2eeManager;
1709
1844
  const request: JoinCallRequest = { ...data, location, e2ee };
1710
1845
  const joinResponse = await this.streamClient.post<
@@ -1712,6 +1847,13 @@ export class Call {
1712
1847
  JoinCallRequest
1713
1848
  >(`${this.streamClientBasePath}/join`, request);
1714
1849
 
1850
+ if (this.leaveGeneration !== joinLeaveGeneration) {
1851
+ this.logger.debug(
1852
+ 'Join superseded by leave; not applying coordinator response',
1853
+ );
1854
+ return joinResponse;
1855
+ }
1856
+
1715
1857
  this.state.updateFromCallResponse(joinResponse.call);
1716
1858
  this.state.setMembers(joinResponse.members);
1717
1859
  this.state.setOwnCapabilities(joinResponse.own_capabilities);
@@ -1726,6 +1868,7 @@ export class Call {
1726
1868
  if (!isReconnecting && this.ringing && !this.isCreatedByMe) {
1727
1869
  // signals other users that I have accepted the incoming call.
1728
1870
  await this.accept();
1871
+ if (this.leaveGeneration !== joinLeaveGeneration) return joinResponse;
1729
1872
  }
1730
1873
 
1731
1874
  if (this.streamClient._hasConnectionID()) {
@@ -1830,6 +1973,7 @@ export class Call {
1830
1973
  callingState === CallingState.JOINING ||
1831
1974
  callingState === CallingState.RECONNECTING ||
1832
1975
  callingState === CallingState.MIGRATING ||
1976
+ callingState === CallingState.LEFT ||
1833
1977
  callingState === CallingState.RECONNECTING_FAILED
1834
1978
  )
1835
1979
  return;
@@ -1840,19 +1984,26 @@ export class Call {
1840
1984
  // `POST /join` per entry) once the call is already healthy again.
1841
1985
  if (hasPending(this.reconnectConcurrencyTag)) return;
1842
1986
 
1987
+ const reconnectLeaveGeneration = this.leaveGeneration;
1988
+ const supersededByLeave = () =>
1989
+ this.leaveGeneration !== reconnectLeaveGeneration;
1843
1990
  return withoutConcurrency(this.reconnectConcurrencyTag, async () => {
1991
+ if (supersededByLeave()) return;
1844
1992
  const reconnectStartTime = Date.now();
1845
1993
  this.reconnectStrategy = strategy;
1846
1994
  this.reconnectReason = reason;
1847
1995
  const sfuRejoinFailures = new Map<string, number>();
1848
1996
 
1849
1997
  const markAsReconnectingFailed = async () => {
1998
+ if (supersededByLeave()) return;
1850
1999
  try {
1851
2000
  // attempt to fetch the call data from the server, as the call
1852
2001
  // state might have changed while we were reconnecting or were offline
1853
2002
  await this.get();
1854
2003
  } finally {
1855
- this.state.setCallingState(CallingState.RECONNECTING_FAILED);
2004
+ if (!supersededByLeave()) {
2005
+ this.state.setCallingState(CallingState.RECONNECTING_FAILED);
2006
+ }
1856
2007
  }
1857
2008
  };
1858
2009
 
@@ -1922,6 +2073,7 @@ export class Call {
1922
2073
  try {
1923
2074
  // wait until the network is available
1924
2075
  await this.networkAvailableTask?.promise;
2076
+ if (supersededByLeave()) return;
1925
2077
 
1926
2078
  this.logger.info(
1927
2079
  `[Reconnect] Reconnecting with strategy ${
@@ -1972,6 +2124,7 @@ export class Call {
1972
2124
  this.consecutiveNegotiationFailures = 0;
1973
2125
  break; // do-while loop, reconnection worked, exit the loop
1974
2126
  } catch (error) {
2127
+ if (supersededByLeave()) return;
1975
2128
  if (attemptedStrategy === WebsocketReconnectStrategy.REJOIN) {
1976
2129
  const failedSfu = this.credentials?.server.edge_name;
1977
2130
  if (failedSfu) {
@@ -2015,6 +2168,7 @@ export class Call {
2015
2168
 
2016
2169
  // exponential backoff with jitter, capped at 5 s
2017
2170
  await sleep(retryInterval(attempt));
2171
+ if (supersededByLeave()) return;
2018
2172
 
2019
2173
  const wasMigrating =
2020
2174
  this.reconnectStrategy === WebsocketReconnectStrategy.MIGRATE;
@@ -2045,6 +2199,7 @@ export class Call {
2045
2199
  );
2046
2200
  }
2047
2201
  } while (
2202
+ !supersededByLeave() &&
2048
2203
  this.state.callingState !== CallingState.JOINED &&
2049
2204
  this.state.callingState !== CallingState.RECONNECTING_FAILED &&
2050
2205
  this.state.callingState !== CallingState.LEFT
@@ -2058,10 +2213,12 @@ export class Call {
2058
2213
  * @internal
2059
2214
  */
2060
2215
  private reconnectFast = async () => {
2216
+ const reconnectLeaveGeneration = this.leaveGeneration;
2061
2217
  const reconnectStartTime = Date.now();
2062
2218
  this.reconnectStrategy = WebsocketReconnectStrategy.FAST;
2063
2219
  this.state.setCallingState(CallingState.RECONNECTING);
2064
2220
  await this.doJoin(this.joinCallData);
2221
+ if (this.leaveGeneration !== reconnectLeaveGeneration) return;
2065
2222
  await this.get(); // fetch the latest call state, as it might have changed
2066
2223
  this.sfuStatsReporter?.sendReconnectionTime(
2067
2224
  WebsocketReconnectStrategy.FAST,
@@ -2074,6 +2231,7 @@ export class Call {
2074
2231
  * @internal
2075
2232
  */
2076
2233
  private reconnectRejoin = async () => {
2234
+ const reconnectLeaveGeneration = this.leaveGeneration;
2077
2235
  const reconnectStartTime = Date.now();
2078
2236
  this.reconnectStrategy = WebsocketReconnectStrategy.REJOIN;
2079
2237
  this.state.setCallingState(CallingState.RECONNECTING);
@@ -2081,10 +2239,14 @@ export class Call {
2081
2239
  this.reconnectReason === ReconnectReason.NETWORK_BACK_ONLINE
2082
2240
  ? 'network-available'
2083
2241
  : 'full-rejoin';
2084
- await this.clientEventReporter.withJoinLifecycle(this.cid, joinReason, () =>
2085
- this.doJoin(this.joinCallData),
2242
+ await this.clientEventReporter.withJoinLifecycle(
2243
+ this.cid,
2244
+ { joinReason },
2245
+ () => this.doJoin(this.joinCallData),
2086
2246
  );
2247
+ if (this.leaveGeneration !== reconnectLeaveGeneration) return;
2087
2248
  await this.restorePublishedTracks();
2249
+ if (this.leaveGeneration !== reconnectLeaveGeneration) return;
2088
2250
  this.restoreSubscribedTracks();
2089
2251
  this.sfuStatsReporter?.sendReconnectionTime(
2090
2252
  WebsocketReconnectStrategy.REJOIN,
@@ -2097,6 +2259,7 @@ export class Call {
2097
2259
  * @internal
2098
2260
  */
2099
2261
  private reconnectMigrate = async () => {
2262
+ const reconnectLeaveGeneration = this.leaveGeneration;
2100
2263
  const reconnectStartTime = Date.now();
2101
2264
  const currentSfuClient = this.sfuClient;
2102
2265
  if (!currentSfuClient) {
@@ -2114,32 +2277,35 @@ export class Call {
2114
2277
  const migrationTask = makeSafePromise(currentSfuClient.enterMigration());
2115
2278
 
2116
2279
  try {
2117
- const currentSfu = currentSfuClient.edgeName;
2118
- await this.clientEventReporter.withJoinLifecycle(
2119
- this.cid,
2120
- 'migration',
2121
- () =>
2122
- this.doJoin({
2123
- ...this.joinCallData,
2124
- migrating_from: currentSfu,
2125
- migrating_from_list: [currentSfu],
2126
- }),
2127
- );
2128
- } finally {
2129
- // cleanup the migration_from field after the migration is complete or failed
2130
- // as we don't want to keep dirty data in the join call data
2131
- delete this.joinCallData?.migrating_from;
2132
- delete this.joinCallData?.migrating_from_list;
2133
- }
2280
+ try {
2281
+ const currentSfu = currentSfuClient.edgeName;
2282
+ await this.clientEventReporter.withJoinLifecycle(
2283
+ this.cid,
2284
+ { joinReason: 'migration' },
2285
+ () =>
2286
+ this.doJoin({
2287
+ ...this.joinCallData,
2288
+ migrating_from: currentSfu,
2289
+ migrating_from_list: [currentSfu],
2290
+ }),
2291
+ );
2292
+ } finally {
2293
+ // cleanup the migration_from field after the migration is complete or failed
2294
+ // as we don't want to keep dirty data in the join call data
2295
+ delete this.joinCallData?.migrating_from;
2296
+ delete this.joinCallData?.migrating_from_list;
2297
+ }
2134
2298
 
2135
- await this.restorePublishedTracks();
2136
- this.restoreSubscribedTracks();
2299
+ if (this.leaveGeneration !== reconnectLeaveGeneration) return;
2300
+ await this.restorePublishedTracks();
2301
+ if (this.leaveGeneration !== reconnectLeaveGeneration) return;
2302
+ this.restoreSubscribedTracks();
2137
2303
 
2138
- try {
2139
2304
  // Wait for the migration to complete, then close the previous SFU client
2140
2305
  // and the peer connection instances. In case of failure, the migration
2141
2306
  // task would throw an error and REJOIN would be attempted.
2142
2307
  await migrationTask();
2308
+ if (this.leaveGeneration !== reconnectLeaveGeneration) return;
2143
2309
 
2144
2310
  // in MIGRATE, we can consider the call as joined only after
2145
2311
  // `participantMigrationComplete` event is received, signaled by
@@ -2473,10 +2639,11 @@ export class Call {
2473
2639
  * Must be called before {@link join} so the RTCPeerConnection can be
2474
2640
  * configured for E2EE.
2475
2641
  *
2476
- * The manager is kept across {@link leave} so a rejoin of this same instance
2477
- * stays encrypted: do not dispose it while this call may be joined again.
2478
- * A disposed manager throws from `encrypt`/`decrypt` rather than silently
2479
- * publishing nothing, so re-attach a fresh one instead of reusing it.
2642
+ * The application owns the manager's lifetime - the SDK never disposes it, and
2643
+ * closing the peer connections does not release it. Discard the manager
2644
+ * together with this call instance once its flow has ended, and create a fresh
2645
+ * pair for a later flow: a disposed manager throws from `encrypt`/`decrypt`
2646
+ * rather than silently publishing nothing.
2480
2647
  *
2481
2648
  * @param e2ee - Any `E2EEManager`. Use `EncryptionManager.create()` for the
2482
2649
  * built-in AES-GCM scheme, or pass your own implementation.
@@ -3114,42 +3281,39 @@ export class Call {
3114
3281
  */
3115
3282
  private scheduleAutoDrop = () => {
3116
3283
  this.cancelAutoDrop();
3117
-
3118
- const settings = this.state.settings;
3119
- if (!settings) return;
3120
- // ignore if the call is not ringing
3121
- if (this.state.callingState !== CallingState.RINGING) return;
3122
-
3123
- const timeoutInMs = this.isCreatedByMe
3124
- ? settings.ring.auto_cancel_timeout_ms
3125
- : settings.ring.incoming_call_timeout_ms;
3126
-
3127
- // 0 means no auto-drop
3128
- if (timeoutInMs <= 0) return;
3129
- this.dropTimeout = setTimeout(() => {
3130
- // the call might have stopped ringing by this point,
3131
- // e.g. it was already accepted and joined
3132
- if (this.state.callingState !== CallingState.RINGING) return;
3133
- this.leave({
3134
- reject: true,
3135
- reason: 'timeout',
3136
- message: `ringing timeout - ${
3137
- this.isCreatedByMe
3138
- ? 'no one accepted'
3139
- : `user didn't interact with incoming call screen`
3140
- }`,
3141
- }).catch((err) => {
3142
- this.logger.error('Failed to drop call', err);
3143
- });
3144
- }, timeoutInMs);
3284
+ this.ringTimeout = new RingTimeout(this);
3285
+ this.ringTimeout.start();
3145
3286
  };
3146
3287
 
3147
3288
  /**
3148
3289
  * Cancels a scheduled auto-drop timeout.
3149
3290
  */
3150
3291
  private cancelAutoDrop = () => {
3151
- clearTimeout(this.dropTimeout);
3152
- this.dropTimeout = undefined;
3292
+ this.ringTimeout?.stop();
3293
+ this.ringTimeout = undefined;
3294
+ };
3295
+
3296
+ /**
3297
+ * Starts polling for the ring outcome. Applicable only to ringing calls the
3298
+ * current user created.
3299
+ */
3300
+ private scheduleRingStatePolling = () => {
3301
+ this.cancelRingStatePolling();
3302
+
3303
+ if (!this.isCreatedByMe) return;
3304
+ const options = this.streamClient.options.ringStatePolling;
3305
+ if (options === false) return;
3306
+
3307
+ this.ringStatePoller = new RingStatePoller(this, options);
3308
+ this.ringStatePoller.start();
3309
+ };
3310
+
3311
+ /**
3312
+ * Cancels the ring state polling.
3313
+ */
3314
+ private cancelRingStatePolling = () => {
3315
+ this.ringStatePoller?.stop();
3316
+ this.ringStatePoller = undefined;
3153
3317
  };
3154
3318
 
3155
3319
  /**