@stream-io/video-client 1.60.0 → 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.
@@ -301,6 +301,10 @@ export declare class Call {
301
301
  /**
302
302
  * Will start to watch for call related WebSocket events and initiate a call session with the server.
303
303
  *
304
+ * One instance is one call flow: discard it after leaving, cancelling, or a
305
+ * failed join, and create a fresh one for a later flow. Reconnection inside a
306
+ * live call is handled here and needs no new instance.
307
+ *
304
308
  * @returns a promise which resolves once the call join-flow has finished.
305
309
  */
306
310
  join: (args_0?: (JoinCallData & {
@@ -355,8 +359,9 @@ export declare class Call {
355
359
  *
356
360
  * @internal
357
361
  * @param data the join call data.
362
+ * @returns The coordinator response, or undefined if leave superseded the request before it was sent.
358
363
  */
359
- doJoinRequest: (data?: JoinCallData) => Promise<JoinCallResponse>;
364
+ doJoinRequest: (data?: JoinCallData) => Promise<JoinCallResponse | undefined>;
360
365
  /**
361
366
  * Whether the per-call media engine currently exists. True from join until leave.
362
367
  *
@@ -484,10 +489,11 @@ export declare class Call {
484
489
  * Must be called before {@link join} so the RTCPeerConnection can be
485
490
  * configured for E2EE.
486
491
  *
487
- * The manager is kept across {@link leave} so a rejoin of this same instance
488
- * stays encrypted: do not dispose it while this call may be joined again.
489
- * A disposed manager throws from `encrypt`/`decrypt` rather than silently
490
- * publishing nothing, so re-attach a fresh one instead of reusing it.
492
+ * The application owns the manager's lifetime - the SDK never disposes it, and
493
+ * closing the peer connections does not release it. Discard the manager
494
+ * together with this call instance once its flow has ended, and create a fresh
495
+ * pair for a later flow: a disposed manager throws from `encrypt`/`decrypt`
496
+ * rather than silently publishing nothing.
491
497
  *
492
498
  * @param e2ee - Any `E2EEManager`. Use `EncryptionManager.create()` for the
493
499
  * built-in AES-GCM scheme, or pass your own implementation.
@@ -37,6 +37,24 @@ export declare class Publisher extends BasePeerConnection {
37
37
  * Adds a new transceiver carrying the given track to the peer connection.
38
38
  */
39
39
  private addTransceiver;
40
+ /**
41
+ * Attaches the encryptor, applies sender parameters, then attaches the track.
42
+ *
43
+ * `encrypt` must run before the first await: Chrome short-circuits a sender
44
+ * whose encoded streams aren't created in the task that created it, and the
45
+ * track goes on last so the sender has no media until both steps succeed.
46
+ */
47
+ private initEncryptedSender;
48
+ private setDegradationPreference;
49
+ /**
50
+ * Discards a sender whose E2EE initialization failed.
51
+ *
52
+ * A partially initialized sender may carry no transform, so publish() must
53
+ * not reuse it: retiring it from the cache is what keeps a later publish or
54
+ * ICE restart from putting cleartext on the wire. Cleanup cannot throw, so
55
+ * the initialization failure is what reaches the caller.
56
+ */
57
+ private retireTransceiver;
40
58
  /**
41
59
  * Updates the transceiver with the given track and track type.
42
60
  */
@@ -14,6 +14,16 @@ export declare class TransceiverCache {
14
14
  * Adds a transceiver to the cache.
15
15
  */
16
16
  add: (bundle: PublishBundle) => void;
17
+ /**
18
+ * Retires a failed sender from the reusable cache.
19
+ *
20
+ * A transceiver that never reached an m-section also leaves the creation
21
+ * history: once stopped, it is left out of every later offer, so counting
22
+ * it would shift the `mid` index of every transceiver added after it. One
23
+ * that is already associated keeps its slot, since its m-section survives
24
+ * as a rejected one.
25
+ */
26
+ remove: (transceiver: RTCRtpTransceiver) => void;
17
27
  /**
18
28
  * Gets the transceiver for the given publish option.
19
29
  */
@@ -5,7 +5,16 @@
5
5
  * attaching their own encoded transform in these two methods.
6
6
  */
7
7
  export interface E2EEManager {
8
- /** `trackType` only groups perf stats, keeping a camera and screen share apart. */
8
+ /**
9
+ * Synchronously attach a transform that encrypts the sender's outgoing frames.
10
+ * Must support a sender without a track, and throw if attachment fails.
11
+ *
12
+ * `sender.track` is always null here: the track is attached only after this
13
+ * returns, so implementations must not read it. Use `codec` and `trackType`
14
+ * instead.
15
+ *
16
+ * `trackType` only groups perf stats, keeping a camera and screen share apart.
17
+ */
9
18
  encrypt(sender: RTCRtpSender, codec?: string, trackType?: string): void;
10
19
  /** `trackType` only groups perf stats, keeping a peer's audio and video apart. */
11
20
  decrypt(receiver: RTCRtpReceiver, userId: string, trackType?: string): void;
@@ -375,14 +375,43 @@ type StreamRNVideoSDKEndCallReason =
375
375
  /** Unknown or unspecified disconnect reason. */
376
376
  | 'unknown';
377
377
  type StreamRNVideoSDKCallingX = {
378
- joinCall: (call: Call, activeCalls: Call[]) => Promise<void>;
378
+ /**
379
+ * @param isCancelled - polled around the bridge's waits: registration is
380
+ * skipped, or undone, when it returns true. Supplied by the join attempt
381
+ * because the call's own state cannot distinguish a fresh join starting
382
+ * from `LEFT` from an abandoned one that reached `LEFT` while waiting.
383
+ */
384
+ joinCall: (call: Call, activeCalls: Call[], isCancelled?: () => boolean) => Promise<void>;
379
385
  endCall: (call: Call, reason?: StreamRNVideoSDKEndCallReason) => Promise<void>;
380
386
  registerOutgoingCall: (call: Call) => Promise<void>;
381
387
  wireAudioEngineSubscription: () => void;
382
388
  unwireAudioEngineSubscription: () => void;
383
389
  };
390
+ /**
391
+ * React Native's preparation and cleanup for a ringing call's join.
392
+ *
393
+ * A ringing call is joined by the SDK rather than by app code, so there is no
394
+ * point at which the app holds the call and can still set it up. These are that
395
+ * point, and the matching release.
396
+ */
397
+ type StreamRNVideoSDKRingingCallLifecycle = {
398
+ /**
399
+ * Runs the app's pre-join setup and resolves once it is done, bounded by a
400
+ * deadline. Rejecting fails the join closed rather than joining with nothing
401
+ * installed.
402
+ */
403
+ beforeJoin: (call: Call) => Promise<void>;
404
+ /**
405
+ * The join failed terminally. Ends the ringing flow and releases what the
406
+ * setup installed. Never rejects, so the original join error survives.
407
+ */
408
+ onJoinFailed: (call: Call) => Promise<void>;
409
+ /** The call has ended; release whatever the pre-join hook installed. */
410
+ onLeave: (call: Call) => void;
411
+ };
384
412
  export type StreamRNVideoSDKGlobals = {
385
413
  callingX: StreamRNVideoSDKCallingX;
414
+ ringingCallLifecycle: StreamRNVideoSDKRingingCallLifecycle;
386
415
  callManager: {
387
416
  /**
388
417
  * Sets up the in call manager.
package/index.ts CHANGED
@@ -26,6 +26,7 @@ export * from './src/helpers/sound-detector';
26
26
  export * from './src/helpers/loopback';
27
27
  export * from './src/helpers/MediaStreamRecorder';
28
28
  export * from './src/helpers/participantUtils';
29
+ export * from './src/helpers/TypedEventEmitter';
29
30
  export * from './src/rtc/e2ee/E2EEManager';
30
31
  export * from './src/rtc/e2ee/EncryptionManager';
31
32
  export * as Browsers from './src/helpers/browsers';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stream-io/video-client",
3
- "version": "1.60.0",
3
+ "version": "1.61.0",
4
4
  "main": "dist/index.cjs.js",
5
5
  "module": "dist/index.es.js",
6
6
  "browser": "dist/index.browser.es.js",
package/src/Call.ts CHANGED
@@ -794,6 +794,11 @@ export class Call {
794
794
  await this.dynascaleManager?.dispose();
795
795
 
796
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
+ }
797
802
  this.state.setParticipants([]);
798
803
  this.state.dispose();
799
804
 
@@ -983,12 +988,14 @@ export class Call {
983
988
  members_limit?: number;
984
989
  video?: boolean;
985
990
  }): Promise<GetCallResponse> => {
991
+ const getLeaveGeneration = this.leaveGeneration;
986
992
  await this.setup();
987
993
 
988
994
  const response = await this.streamClient.get<GetCallResponse>(
989
995
  this.streamClientBasePath,
990
996
  params,
991
997
  );
998
+ if (this.leaveGeneration !== getLeaveGeneration) return response;
992
999
 
993
1000
  this.updateFromCallStateResponse(response);
994
1001
 
@@ -1135,6 +1142,10 @@ export class Call {
1135
1142
  /**
1136
1143
  * Will start to watch for call related WebSocket events and initiate a call session with the server.
1137
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
+ *
1138
1149
  * @returns a promise which resolves once the call join-flow has finished.
1139
1150
  */
1140
1151
  join = singleFlight(
@@ -1154,10 +1165,19 @@ export class Call {
1154
1165
  } = {}): Promise<void> => {
1155
1166
  const callingState = this.state.callingState;
1156
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.
1157
1170
  if ([CallingState.JOINED, CallingState.JOINING].includes(callingState)) {
1158
1171
  throw new Error(`Illegal State: call.join() shall be called only once`);
1159
1172
  }
1160
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
+
1161
1181
  // we need this to be set before the callingx.joinCall() is
1162
1182
  // called to avoid registering the test call in the CallKit/Telecom
1163
1183
  this.allowOwnTracksLoopback = allowOwnTracksLoopback;
@@ -1166,35 +1186,67 @@ export class Call {
1166
1186
  this.ringingSubject.next(true);
1167
1187
  }
1168
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;
1169
1196
  const callingX = globalThis.streamRNVideoSDK?.callingX;
1170
- if (callingX) {
1171
- // for Android/iOS, we need to start the call in the callingx library as soon as possible
1172
- await callingX.joinCall(this, this.clientStore.calls);
1173
- }
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
+ }
1174
1205
 
1175
- await this.setup();
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
+ }
1176
1218
 
1177
- this.clientEventReporter.registerCall(this.cid, {
1178
- callType: this.type,
1179
- callId: this.id,
1180
- getCallSessionId: () => this.state.session?.id ?? '',
1181
- getSfuId: () => this.credentials?.server.edge_name ?? '',
1182
- });
1219
+ await this.setup();
1220
+ if (supersededByLeave()) {
1221
+ this.logger.debug('Join superseded by leave; not registering');
1222
+ return;
1223
+ }
1183
1224
 
1184
- this.joinResponseTimeout = joinResponseTimeout;
1185
- this.rpcRequestTimeout = rpcRequestTimeout;
1186
- // we will count the number of join failures per SFU.
1187
- // once the number of failures reaches 2, we will piggyback on the `migrating_from`
1188
- // field to force the coordinator to provide us another SFU
1189
- const sfuJoinFailures = new Map<string, number>();
1190
- const joinData: JoinCallData = data;
1191
- maxJoinRetries = Math.max(maxJoinRetries, 1);
1192
- try {
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
+ });
1231
+
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);
1193
1240
  await this.clientEventReporter.withJoinLifecycle(
1194
1241
  this.cid,
1195
1242
  { joinReason: 'first-attempt', joinSource },
1196
1243
  async () => {
1197
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
+ }
1198
1250
  try {
1199
1251
  this.logger.trace(`Joining call (${attempt})`, this.cid);
1200
1252
  await this.doJoin(data);
@@ -1202,6 +1254,10 @@ export class Call {
1202
1254
  delete joinData.migrating_from_list;
1203
1255
  return;
1204
1256
  } catch (err) {
1257
+ if (supersededByLeave()) {
1258
+ this.logger.debug('Join superseded by leave; not retrying');
1259
+ return;
1260
+ }
1205
1261
  this.logger.warn(`Failed to join call (${attempt})`, this.cid);
1206
1262
  if (
1207
1263
  (err instanceof ErrorFromResponse && err.unrecoverable) ||
@@ -1242,6 +1298,9 @@ export class Call {
1242
1298
  );
1243
1299
  } catch (error) {
1244
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);
1245
1304
  throw error;
1246
1305
  }
1247
1306
  },
@@ -1270,6 +1329,10 @@ export class Call {
1270
1329
  // globals resolve to the call's factory. Idempotent across
1271
1330
  // reconnect/migration attempts.
1272
1331
  await this.ensureMediaFactory();
1332
+ if (supersededByLeave()) {
1333
+ this.logger.debug('Join superseded by leave; not wiring media');
1334
+ return;
1335
+ }
1273
1336
 
1274
1337
  const callingX = globalThis.streamRNVideoSDK?.callingX;
1275
1338
  if (callingX) {
@@ -1297,10 +1360,17 @@ export class Call {
1297
1360
  'CoordinatorJoin',
1298
1361
  () => this.doJoinRequest(data),
1299
1362
  );
1363
+ if (!joinResponse || supersededByLeave()) {
1364
+ this.logger.debug(
1365
+ 'Join superseded by leave; not creating SFU client',
1366
+ );
1367
+ return;
1368
+ }
1300
1369
  this.credentials = joinResponse.credentials;
1301
1370
  statsOptions = joinResponse.stats_options;
1302
1371
  this.lastStatsOptions = statsOptions;
1303
1372
  } catch (error) {
1373
+ if (supersededByLeave()) return;
1304
1374
  // prevent triggering reconnect flow if the state is OFFLINE
1305
1375
  const avoidRestoreState =
1306
1376
  this.state.callingState === CallingState.OFFLINE;
@@ -1355,6 +1425,10 @@ export class Call {
1355
1425
  getGenericSdp('recvonly', dangerouslyForceCodec, subscriberFmtpLine),
1356
1426
  getGenericSdp('sendonly', dangerouslyForceCodec, fmtpLine),
1357
1427
  ]);
1428
+ if (supersededByLeave()) {
1429
+ this.logger.debug('Join superseded by leave; not joining SFU');
1430
+ return;
1431
+ }
1358
1432
  const isReconnecting =
1359
1433
  this.reconnectStrategy !== WebsocketReconnectStrategy.UNSPECIFIED;
1360
1434
  const reconnectDetails = isReconnecting
@@ -1388,6 +1462,12 @@ export class Call {
1388
1462
  source: ParticipantSource.WEBRTC_UNSPECIFIED,
1389
1463
  }),
1390
1464
  );
1465
+ if (supersededByLeave()) {
1466
+ this.logger.debug(
1467
+ 'Join superseded by leave; not applying SFU response',
1468
+ );
1469
+ return;
1470
+ }
1391
1471
 
1392
1472
  this.currentPublishOptions = publishOptions;
1393
1473
  this.fastReconnectDeadlineSeconds = fastReconnectDeadlineSeconds;
@@ -1399,6 +1479,10 @@ export class Call {
1399
1479
  );
1400
1480
  }
1401
1481
  } catch (error) {
1482
+ if (supersededByLeave()) {
1483
+ this.logger.debug('Join superseded by leave; ignoring SFU failure');
1484
+ return;
1485
+ }
1402
1486
  this.logger.warn('Join SFU request failed', error);
1403
1487
  sfuClient.close(
1404
1488
  StreamSfuClient.JOIN_FAILED,
@@ -1410,8 +1494,8 @@ export class Call {
1410
1494
  }
1411
1495
  }
1412
1496
 
1413
- // If the user left while this join was in flight, bail before re-setting JOINED and before
1414
- // 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.
1415
1499
  if (supersededByLeave()) {
1416
1500
  this.logger.debug('Join superseded by leave; aborting join flow');
1417
1501
  return;
@@ -1441,6 +1525,10 @@ export class Call {
1441
1525
  unifiedSessionId: this.unifiedSessionId,
1442
1526
  });
1443
1527
  }
1528
+ if (supersededByLeave()) {
1529
+ this.logger.debug('Join superseded by leave; not completing join flow');
1530
+ return;
1531
+ }
1444
1532
 
1445
1533
  // make sure we only track connection timing if we are not calling this method as part of a reconnection flow
1446
1534
  if (!performingRejoin && !performingFastReconnect && !performingMigration) {
@@ -1626,6 +1714,7 @@ export class Call {
1626
1714
  closePreviousInstances: boolean;
1627
1715
  unifiedSessionId: string;
1628
1716
  }) => {
1717
+ const joinLeaveGeneration = this.leaveGeneration;
1629
1718
  const {
1630
1719
  sfuClient,
1631
1720
  connectionConfig,
@@ -1642,10 +1731,12 @@ export class Call {
1642
1731
  // Flush the previous reporter's final sample while its peer connections are
1643
1732
  // still alive, before we dispose them below. Awaits only the sampling step.
1644
1733
  await this.sfuStatsReporter?.flush();
1734
+ if (this.leaveGeneration !== joinLeaveGeneration) return;
1645
1735
  this.sfuStatsReporter?.stop();
1646
1736
  this.sfuStatsReporter = undefined;
1647
1737
  if (closePreviousInstances && this.subscriber) {
1648
1738
  await this.subscriber.dispose();
1739
+ if (this.leaveGeneration !== joinLeaveGeneration) return;
1649
1740
  this.state.removeAllOrphanedTracks();
1650
1741
  }
1651
1742
  const basePeerConnectionOptions: BasePeerConnectionOpts = {
@@ -1692,6 +1783,7 @@ export class Call {
1692
1783
  if (!isAnonymous) {
1693
1784
  if (closePreviousInstances && this.publisher) {
1694
1785
  await this.publisher.dispose();
1786
+ if (this.leaveGeneration !== joinLeaveGeneration) return;
1695
1787
  }
1696
1788
  this.publisher = new Publisher(
1697
1789
  basePeerConnectionOptions,
@@ -1735,9 +1827,19 @@ export class Call {
1735
1827
  *
1736
1828
  * @internal
1737
1829
  * @param data the join call data.
1830
+ * @returns The coordinator response, or undefined if leave superseded the request before it was sent.
1738
1831
  */
1739
- doJoinRequest = async (data?: JoinCallData): Promise<JoinCallResponse> => {
1832
+ doJoinRequest = async (
1833
+ data?: JoinCallData,
1834
+ ): Promise<JoinCallResponse | undefined> => {
1835
+ const joinLeaveGeneration = this.leaveGeneration;
1740
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
+ }
1741
1843
  const e2ee = !!this.e2eeManager;
1742
1844
  const request: JoinCallRequest = { ...data, location, e2ee };
1743
1845
  const joinResponse = await this.streamClient.post<
@@ -1745,6 +1847,13 @@ export class Call {
1745
1847
  JoinCallRequest
1746
1848
  >(`${this.streamClientBasePath}/join`, request);
1747
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
+
1748
1857
  this.state.updateFromCallResponse(joinResponse.call);
1749
1858
  this.state.setMembers(joinResponse.members);
1750
1859
  this.state.setOwnCapabilities(joinResponse.own_capabilities);
@@ -1759,6 +1868,7 @@ export class Call {
1759
1868
  if (!isReconnecting && this.ringing && !this.isCreatedByMe) {
1760
1869
  // signals other users that I have accepted the incoming call.
1761
1870
  await this.accept();
1871
+ if (this.leaveGeneration !== joinLeaveGeneration) return joinResponse;
1762
1872
  }
1763
1873
 
1764
1874
  if (this.streamClient._hasConnectionID()) {
@@ -1863,6 +1973,7 @@ export class Call {
1863
1973
  callingState === CallingState.JOINING ||
1864
1974
  callingState === CallingState.RECONNECTING ||
1865
1975
  callingState === CallingState.MIGRATING ||
1976
+ callingState === CallingState.LEFT ||
1866
1977
  callingState === CallingState.RECONNECTING_FAILED
1867
1978
  )
1868
1979
  return;
@@ -1873,19 +1984,26 @@ export class Call {
1873
1984
  // `POST /join` per entry) once the call is already healthy again.
1874
1985
  if (hasPending(this.reconnectConcurrencyTag)) return;
1875
1986
 
1987
+ const reconnectLeaveGeneration = this.leaveGeneration;
1988
+ const supersededByLeave = () =>
1989
+ this.leaveGeneration !== reconnectLeaveGeneration;
1876
1990
  return withoutConcurrency(this.reconnectConcurrencyTag, async () => {
1991
+ if (supersededByLeave()) return;
1877
1992
  const reconnectStartTime = Date.now();
1878
1993
  this.reconnectStrategy = strategy;
1879
1994
  this.reconnectReason = reason;
1880
1995
  const sfuRejoinFailures = new Map<string, number>();
1881
1996
 
1882
1997
  const markAsReconnectingFailed = async () => {
1998
+ if (supersededByLeave()) return;
1883
1999
  try {
1884
2000
  // attempt to fetch the call data from the server, as the call
1885
2001
  // state might have changed while we were reconnecting or were offline
1886
2002
  await this.get();
1887
2003
  } finally {
1888
- this.state.setCallingState(CallingState.RECONNECTING_FAILED);
2004
+ if (!supersededByLeave()) {
2005
+ this.state.setCallingState(CallingState.RECONNECTING_FAILED);
2006
+ }
1889
2007
  }
1890
2008
  };
1891
2009
 
@@ -1955,6 +2073,7 @@ export class Call {
1955
2073
  try {
1956
2074
  // wait until the network is available
1957
2075
  await this.networkAvailableTask?.promise;
2076
+ if (supersededByLeave()) return;
1958
2077
 
1959
2078
  this.logger.info(
1960
2079
  `[Reconnect] Reconnecting with strategy ${
@@ -2005,6 +2124,7 @@ export class Call {
2005
2124
  this.consecutiveNegotiationFailures = 0;
2006
2125
  break; // do-while loop, reconnection worked, exit the loop
2007
2126
  } catch (error) {
2127
+ if (supersededByLeave()) return;
2008
2128
  if (attemptedStrategy === WebsocketReconnectStrategy.REJOIN) {
2009
2129
  const failedSfu = this.credentials?.server.edge_name;
2010
2130
  if (failedSfu) {
@@ -2048,6 +2168,7 @@ export class Call {
2048
2168
 
2049
2169
  // exponential backoff with jitter, capped at 5 s
2050
2170
  await sleep(retryInterval(attempt));
2171
+ if (supersededByLeave()) return;
2051
2172
 
2052
2173
  const wasMigrating =
2053
2174
  this.reconnectStrategy === WebsocketReconnectStrategy.MIGRATE;
@@ -2078,6 +2199,7 @@ export class Call {
2078
2199
  );
2079
2200
  }
2080
2201
  } while (
2202
+ !supersededByLeave() &&
2081
2203
  this.state.callingState !== CallingState.JOINED &&
2082
2204
  this.state.callingState !== CallingState.RECONNECTING_FAILED &&
2083
2205
  this.state.callingState !== CallingState.LEFT
@@ -2091,10 +2213,12 @@ export class Call {
2091
2213
  * @internal
2092
2214
  */
2093
2215
  private reconnectFast = async () => {
2216
+ const reconnectLeaveGeneration = this.leaveGeneration;
2094
2217
  const reconnectStartTime = Date.now();
2095
2218
  this.reconnectStrategy = WebsocketReconnectStrategy.FAST;
2096
2219
  this.state.setCallingState(CallingState.RECONNECTING);
2097
2220
  await this.doJoin(this.joinCallData);
2221
+ if (this.leaveGeneration !== reconnectLeaveGeneration) return;
2098
2222
  await this.get(); // fetch the latest call state, as it might have changed
2099
2223
  this.sfuStatsReporter?.sendReconnectionTime(
2100
2224
  WebsocketReconnectStrategy.FAST,
@@ -2107,6 +2231,7 @@ export class Call {
2107
2231
  * @internal
2108
2232
  */
2109
2233
  private reconnectRejoin = async () => {
2234
+ const reconnectLeaveGeneration = this.leaveGeneration;
2110
2235
  const reconnectStartTime = Date.now();
2111
2236
  this.reconnectStrategy = WebsocketReconnectStrategy.REJOIN;
2112
2237
  this.state.setCallingState(CallingState.RECONNECTING);
@@ -2119,7 +2244,9 @@ export class Call {
2119
2244
  { joinReason },
2120
2245
  () => this.doJoin(this.joinCallData),
2121
2246
  );
2247
+ if (this.leaveGeneration !== reconnectLeaveGeneration) return;
2122
2248
  await this.restorePublishedTracks();
2249
+ if (this.leaveGeneration !== reconnectLeaveGeneration) return;
2123
2250
  this.restoreSubscribedTracks();
2124
2251
  this.sfuStatsReporter?.sendReconnectionTime(
2125
2252
  WebsocketReconnectStrategy.REJOIN,
@@ -2132,6 +2259,7 @@ export class Call {
2132
2259
  * @internal
2133
2260
  */
2134
2261
  private reconnectMigrate = async () => {
2262
+ const reconnectLeaveGeneration = this.leaveGeneration;
2135
2263
  const reconnectStartTime = Date.now();
2136
2264
  const currentSfuClient = this.sfuClient;
2137
2265
  if (!currentSfuClient) {
@@ -2149,32 +2277,35 @@ export class Call {
2149
2277
  const migrationTask = makeSafePromise(currentSfuClient.enterMigration());
2150
2278
 
2151
2279
  try {
2152
- const currentSfu = currentSfuClient.edgeName;
2153
- await this.clientEventReporter.withJoinLifecycle(
2154
- this.cid,
2155
- { joinReason: 'migration' },
2156
- () =>
2157
- this.doJoin({
2158
- ...this.joinCallData,
2159
- migrating_from: currentSfu,
2160
- migrating_from_list: [currentSfu],
2161
- }),
2162
- );
2163
- } finally {
2164
- // cleanup the migration_from field after the migration is complete or failed
2165
- // as we don't want to keep dirty data in the join call data
2166
- delete this.joinCallData?.migrating_from;
2167
- delete this.joinCallData?.migrating_from_list;
2168
- }
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
+ }
2169
2298
 
2170
- await this.restorePublishedTracks();
2171
- this.restoreSubscribedTracks();
2299
+ if (this.leaveGeneration !== reconnectLeaveGeneration) return;
2300
+ await this.restorePublishedTracks();
2301
+ if (this.leaveGeneration !== reconnectLeaveGeneration) return;
2302
+ this.restoreSubscribedTracks();
2172
2303
 
2173
- try {
2174
2304
  // Wait for the migration to complete, then close the previous SFU client
2175
2305
  // and the peer connection instances. In case of failure, the migration
2176
2306
  // task would throw an error and REJOIN would be attempted.
2177
2307
  await migrationTask();
2308
+ if (this.leaveGeneration !== reconnectLeaveGeneration) return;
2178
2309
 
2179
2310
  // in MIGRATE, we can consider the call as joined only after
2180
2311
  // `participantMigrationComplete` event is received, signaled by
@@ -2508,10 +2639,11 @@ export class Call {
2508
2639
  * Must be called before {@link join} so the RTCPeerConnection can be
2509
2640
  * configured for E2EE.
2510
2641
  *
2511
- * The manager is kept across {@link leave} so a rejoin of this same instance
2512
- * stays encrypted: do not dispose it while this call may be joined again.
2513
- * A disposed manager throws from `encrypt`/`decrypt` rather than silently
2514
- * 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.
2515
2647
  *
2516
2648
  * @param e2ee - Any `E2EEManager`. Use `EncryptionManager.create()` for the
2517
2649
  * built-in AES-GCM scheme, or pass your own implementation.