livekit-client 2.22.4 → 2.23.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 (116) hide show
  1. package/dist/livekit-client.e2ee.worker.js +1 -1
  2. package/dist/livekit-client.e2ee.worker.js.map +1 -1
  3. package/dist/livekit-client.e2ee.worker.mjs +69 -7
  4. package/dist/livekit-client.e2ee.worker.mjs.map +1 -1
  5. package/dist/livekit-client.esm.mjs +1133 -289
  6. package/dist/livekit-client.esm.mjs.map +1 -1
  7. package/dist/livekit-client.fm.worker.js.map +1 -1
  8. package/dist/livekit-client.fm.worker.mjs.map +1 -1
  9. package/dist/livekit-client.umd.js +1 -1
  10. package/dist/livekit-client.umd.js.map +1 -1
  11. package/dist/src/api/SignalClient.d.ts.map +1 -1
  12. package/dist/src/api/WebSocketStream.d.ts +1 -0
  13. package/dist/src/api/WebSocketStream.d.ts.map +1 -1
  14. package/dist/src/api/utils.d.ts +1 -1
  15. package/dist/src/api/utils.d.ts.map +1 -1
  16. package/dist/src/e2ee/E2eeManager.d.ts.map +1 -1
  17. package/dist/src/e2ee/worker/FrameCryptor.d.ts.map +1 -1
  18. package/dist/src/e2ee/worker/naluUtils.d.ts +12 -1
  19. package/dist/src/e2ee/worker/naluUtils.d.ts.map +1 -1
  20. package/dist/src/index.d.ts +2 -2
  21. package/dist/src/index.d.ts.map +1 -1
  22. package/dist/src/room/DefaultReconnectPolicy.d.ts.map +1 -1
  23. package/dist/src/room/PCTransport.d.ts +26 -4
  24. package/dist/src/room/PCTransport.d.ts.map +1 -1
  25. package/dist/src/room/RTCEngine.d.ts +8 -0
  26. package/dist/src/room/RTCEngine.d.ts.map +1 -1
  27. package/dist/src/room/Room.d.ts +53 -0
  28. package/dist/src/room/Room.d.ts.map +1 -1
  29. package/dist/src/room/data-channel/DataChannelManager.d.ts +9 -8
  30. package/dist/src/room/data-channel/DataChannelManager.d.ts.map +1 -1
  31. package/dist/src/room/data-channel/FlowControlledDataChannel.d.ts +25 -12
  32. package/dist/src/room/data-channel/FlowControlledDataChannel.d.ts.map +1 -1
  33. package/dist/src/room/data-channel/LossyDataChannel.d.ts.map +1 -1
  34. package/dist/src/room/data-channel/ReliableDataChannel.d.ts.map +1 -1
  35. package/dist/src/room/data-stream/incoming/IncomingDataStreamManager.d.ts.map +1 -1
  36. package/dist/src/room/participant/LocalParticipant.d.ts +4 -0
  37. package/dist/src/room/participant/LocalParticipant.d.ts.map +1 -1
  38. package/dist/src/room/participant/Participant.d.ts +1 -1
  39. package/dist/src/room/participant/Participant.d.ts.map +1 -1
  40. package/dist/src/room/rpc/utils.d.ts.map +1 -1
  41. package/dist/src/room/track/LocalAudioTrack.d.ts +24 -1
  42. package/dist/src/room/track/LocalAudioTrack.d.ts.map +1 -1
  43. package/dist/src/room/track/LocalTrack.d.ts +18 -0
  44. package/dist/src/room/track/LocalTrack.d.ts.map +1 -1
  45. package/dist/src/room/track/LocalVideoTrack.d.ts +7 -0
  46. package/dist/src/room/track/LocalVideoTrack.d.ts.map +1 -1
  47. package/dist/src/room/track/RemoteAudioTrack.d.ts +6 -0
  48. package/dist/src/room/track/RemoteAudioTrack.d.ts.map +1 -1
  49. package/dist/src/room/track/processor/types.d.ts +11 -0
  50. package/dist/src/room/track/processor/types.d.ts.map +1 -1
  51. package/dist/src/room/track/utils.d.ts.map +1 -1
  52. package/dist/src/room/utils.d.ts +28 -1
  53. package/dist/src/room/utils.d.ts.map +1 -1
  54. package/dist/ts4.2/api/WebSocketStream.d.ts +1 -0
  55. package/dist/ts4.2/api/utils.d.ts +1 -1
  56. package/dist/ts4.2/e2ee/worker/naluUtils.d.ts +12 -1
  57. package/dist/ts4.2/index.d.ts +2 -2
  58. package/dist/ts4.2/room/PCTransport.d.ts +26 -4
  59. package/dist/ts4.2/room/RTCEngine.d.ts +8 -0
  60. package/dist/ts4.2/room/Room.d.ts +53 -0
  61. package/dist/ts4.2/room/data-channel/DataChannelManager.d.ts +9 -8
  62. package/dist/ts4.2/room/data-channel/FlowControlledDataChannel.d.ts +25 -12
  63. package/dist/ts4.2/room/participant/LocalParticipant.d.ts +4 -0
  64. package/dist/ts4.2/room/participant/Participant.d.ts +1 -1
  65. package/dist/ts4.2/room/track/LocalAudioTrack.d.ts +24 -1
  66. package/dist/ts4.2/room/track/LocalTrack.d.ts +18 -0
  67. package/dist/ts4.2/room/track/LocalVideoTrack.d.ts +7 -0
  68. package/dist/ts4.2/room/track/RemoteAudioTrack.d.ts +6 -0
  69. package/dist/ts4.2/room/track/processor/types.d.ts +11 -0
  70. package/dist/ts4.2/room/utils.d.ts +28 -1
  71. package/package.json +1 -1
  72. package/src/api/SignalClient.test.ts +234 -0
  73. package/src/api/SignalClient.ts +58 -23
  74. package/src/api/WebSocketStream.test.ts +11 -0
  75. package/src/api/WebSocketStream.ts +22 -1
  76. package/src/api/utils.ts +1 -1
  77. package/src/e2ee/E2eeManager.test.ts +43 -0
  78. package/src/e2ee/E2eeManager.ts +6 -2
  79. package/src/e2ee/worker/FrameCryptor.test.ts +212 -90
  80. package/src/e2ee/worker/FrameCryptor.ts +21 -4
  81. package/src/e2ee/worker/naluUtils.test.ts +123 -0
  82. package/src/e2ee/worker/naluUtils.ts +57 -2
  83. package/src/index.ts +2 -0
  84. package/src/room/DefaultReconnectPolicy.test.ts +58 -0
  85. package/src/room/DefaultReconnectPolicy.ts +14 -2
  86. package/src/room/PCTransport.test.ts +144 -2
  87. package/src/room/PCTransport.ts +151 -9
  88. package/src/room/RTCEngine.test.ts +234 -2
  89. package/src/room/RTCEngine.ts +102 -7
  90. package/src/room/Room.test.ts +116 -0
  91. package/src/room/Room.ts +286 -74
  92. package/src/room/data-channel/DataChannelManager.test.ts +24 -7
  93. package/src/room/data-channel/DataChannelManager.ts +43 -38
  94. package/src/room/data-channel/FlowControlledDataChannel.test.ts +60 -18
  95. package/src/room/data-channel/FlowControlledDataChannel.ts +40 -22
  96. package/src/room/data-channel/LossyDataChannel.test.ts +38 -0
  97. package/src/room/data-channel/LossyDataChannel.ts +10 -1
  98. package/src/room/data-channel/ReliableDataChannel.test.ts +72 -0
  99. package/src/room/data-channel/ReliableDataChannel.ts +16 -5
  100. package/src/room/data-stream/incoming/IncomingDataStreamManager.test.ts +31 -0
  101. package/src/room/data-stream/incoming/IncomingDataStreamManager.ts +11 -0
  102. package/src/room/participant/LocalParticipant.test.ts +136 -1
  103. package/src/room/participant/LocalParticipant.ts +66 -17
  104. package/src/room/participant/Participant.ts +6 -3
  105. package/src/room/participant/restartOnDefaultAudioDevice.test.ts +177 -0
  106. package/src/room/rpc/utils.test.ts +32 -0
  107. package/src/room/rpc/utils.ts +7 -0
  108. package/src/room/track/LocalAudioTrack.ts +101 -10
  109. package/src/room/track/LocalTrack.ts +70 -13
  110. package/src/room/track/LocalVideoTrack.ts +7 -0
  111. package/src/room/track/RemoteAudioTrack.ts +56 -11
  112. package/src/room/track/processor/types.ts +11 -0
  113. package/src/room/track/utils.test.ts +5 -0
  114. package/src/room/track/utils.ts +29 -5
  115. package/src/room/utils.test.ts +126 -0
  116. package/src/room/utils.ts +84 -21
@@ -227,6 +227,8 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
227
227
 
228
228
  private attemptingReconnect: boolean = false;
229
229
 
230
+ private reconnectAbortController?: AbortController;
231
+
230
232
  private reconnectPolicy: ReconnectPolicy;
231
233
 
232
234
  private reconnectTimeout?: ReturnType<typeof setTimeout>;
@@ -236,6 +238,14 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
236
238
  /** keeps track of how often an initial join connection has been tried */
237
239
  private joinAttempts: number = 0;
238
240
 
241
+ /**
242
+ * When the current join attempt began (`performance.now()`), taken at the top of `join` before
243
+ * the signal connection or the peer connections exist. Every attempt (a join retry, or the join
244
+ * inside a full restart) times itself from scratch. Consumed when the primary transport first
245
+ * connects, to hand the publisher its connection setup time; resumes never set it.
246
+ */
247
+ private joinStartedAt?: number;
248
+
239
249
  /** specifies how often an initial join connection is allowed to retry */
240
250
  private maxJoinAttempts: number = 1;
241
251
 
@@ -352,6 +362,7 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
352
362
  this.maxJoinAttempts = opts.maxRetries;
353
363
  try {
354
364
  this.joinAttempts += 1;
365
+ this.joinStartedAt = performance.now();
355
366
 
356
367
  this.setupSignalClientCallbacks();
357
368
  // Whether the initial publisher offer is bundled with the join request. Computed once and
@@ -392,6 +403,10 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
392
403
  useV0Path,
393
404
  offerProto,
394
405
  );
406
+ if (abortSignal?.aborted) {
407
+ this.log.info('join cancelled after the signal join response');
408
+ throw ConnectionError.cancelled('Connection aborted');
409
+ }
395
410
  this._isClosed = false;
396
411
  this.latestJoinResponse = joinResponse;
397
412
  this.participantSid = joinResponse.participant?.sid;
@@ -405,6 +420,10 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
405
420
  // the server's ICE servers and topology, then negotiate separately rather than bundling
406
421
  // the offer with the join.
407
422
  await this.configure(joinResponse, !useV0Path);
423
+ if (abortSignal?.aborted) {
424
+ this.log.info('join cancelled after configuring peer connections');
425
+ throw ConnectionError.cancelled('Connection aborted');
426
+ }
408
427
  if (!useV0Path) {
409
428
  // The V1 first offer must carry the media layout so Firefox binds receive decoders for
410
429
  // subscribed tracks — without it, subscribed audio/video arrive as RTP but
@@ -420,6 +439,12 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
420
439
  }
421
440
  }
422
441
 
442
+ if (this.joinStartedAt !== undefined) {
443
+ // Lets a video published before the transports connect be seeded from the time elapsed
444
+ // so far, rather than delaying its offer until the setup time is measured.
445
+ this.pcManager?.publisher.setConnectionStartedAt(this.joinStartedAt);
446
+ }
447
+
423
448
  this.registerOnLineListener();
424
449
  this.clientConfiguration = joinResponse.clientConfiguration;
425
450
  this.emit(EngineEvent.SignalConnected, joinResponse);
@@ -463,6 +488,8 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
463
488
  * having given up on reconnecting.
464
489
  */
465
490
  async close(reason?: string) {
491
+ this.reconnectAbortController?.abort();
492
+ this.reconnectAbortController = undefined;
466
493
  const unlock = await this.closingLock.lock();
467
494
  if (this.isClosed) {
468
495
  unlock();
@@ -471,6 +498,7 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
471
498
  try {
472
499
  this._isClosed = true;
473
500
  this.joinAttempts = 0;
501
+ this.joinStartedAt = undefined;
474
502
  this.emit(EngineEvent.Closing);
475
503
  this.removeAllListeners();
476
504
  this.deregisterOnLineListener();
@@ -629,6 +657,16 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
629
657
  this.publisherConnectionPromise = undefined;
630
658
  }
631
659
  if (connectionState === PCTransportState.CONNECTED) {
660
+ if (this.joinStartedAt !== undefined) {
661
+ // Connection setup time is the only network signal there is before the first video
662
+ // offer. Set synchronously, so it is in place before `room.connect()` resolves and the
663
+ // app can publish. A full restart joins again and builds new transports, so it is
664
+ // measured again; a resume keeps its estimator and never restarts the timer.
665
+ const setupTimeMs = Math.round(performance.now() - this.joinStartedAt);
666
+ this.joinStartedAt = undefined;
667
+ this.log.info(`connection setup took ${setupTimeMs} ms`);
668
+ this.pcManager?.publisher.setConnectionSetupTime(setupTimeMs);
669
+ }
632
670
  const shouldEmit = this.pcState === PCState.New;
633
671
  this.pcState = PCState.Connected;
634
672
  if (shouldEmit) {
@@ -899,6 +937,12 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
899
937
  return;
900
938
  }
901
939
 
940
+ if (this.dataChannels.hasPublisherChannels) {
941
+ // Replacing live channels (the Safari null-id path in resumeConnection): the new ones start
942
+ // out connecting, so drop the memoized readiness check and make the next sender wait for
943
+ // them to open.
944
+ this.publisherConnectionPromise = undefined;
945
+ }
902
946
  this.dataChannels.createPublisherChannels(this.pcManager);
903
947
  }
904
948
 
@@ -1221,7 +1265,14 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
1221
1265
  return;
1222
1266
  }
1223
1267
  if (connection === leaveReconnect) {
1224
- delay = 0;
1268
+ // The server asked for a reconnect, so skip any remaining backoff. Use the policy's first
1269
+ // retry delay instead of 0 so that clients told to reconnect together don't all reconnect
1270
+ // at the same instant.
1271
+ delay = this.getNextRetryDelay({ elapsedMs: duration, retryCount: 0 });
1272
+ if (delay === null) {
1273
+ disconnect(duration);
1274
+ return;
1275
+ }
1225
1276
  }
1226
1277
 
1227
1278
  this.log.debug(`reconnecting in ${delay}ms`);
@@ -1338,16 +1389,26 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
1338
1389
  this.fullReconnectOnNext = false;
1339
1390
 
1340
1391
  let succeeded = false;
1392
+ const abortController = new AbortController();
1393
+ this.reconnectAbortController = abortController;
1341
1394
  try {
1342
1395
  this.attemptingReconnect = true;
1343
1396
  if (fullReconnect) {
1344
- await this.restartConnection();
1397
+ await this.restartConnection(abortController.signal);
1345
1398
  } else {
1346
1399
  await this.resumeConnection(reason);
1347
1400
  }
1401
+ if (abortController.signal.aborted) {
1402
+ this.log.info('reconnect attempt cancelled, not marking it as succeeded');
1403
+ return;
1404
+ }
1348
1405
  this.clearPendingReconnect();
1349
1406
  succeeded = true;
1350
1407
  } catch (e) {
1408
+ if (abortController.signal.aborted) {
1409
+ this.log.info('reconnect attempt cancelled, not retrying', { error: e });
1410
+ return;
1411
+ }
1351
1412
  this.reconnectAttempts += 1;
1352
1413
  let recoverable = true;
1353
1414
  if (e instanceof UnexpectedConnectionState) {
@@ -1376,6 +1437,10 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
1376
1437
  );
1377
1438
  }
1378
1439
  } finally {
1440
+ // close() may already have cleared this (or a later attempt replaced it); only release our own
1441
+ if (this.reconnectAbortController === abortController) {
1442
+ this.reconnectAbortController = undefined;
1443
+ }
1379
1444
  this.attemptingReconnect = false;
1380
1445
 
1381
1446
  // A full reconnect requested while this attempt was running (e.g. a `RECONNECT` leave
@@ -1399,8 +1464,12 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
1399
1464
  return null;
1400
1465
  }
1401
1466
 
1402
- private async restartConnection(regionUrl?: string) {
1467
+ private async restartConnection(abortSignal: AbortSignal, regionUrl?: string) {
1403
1468
  try {
1469
+ if (abortSignal.aborted) {
1470
+ this.log.info('full reconnect cancelled before starting', { regionUrl });
1471
+ return;
1472
+ }
1404
1473
  if (!this.url || !this.token) {
1405
1474
  // permanent failure, don't attempt reconnection
1406
1475
  throw new UnexpectedConnectionState('could not reconnect, url or token not saved');
@@ -1414,6 +1483,12 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
1414
1483
  }
1415
1484
  await this.cleanupPeerConnections();
1416
1485
  await this.cleanupClient();
1486
+ if (abortSignal.aborted) {
1487
+ this.log.info('full reconnect cancelled after cleaning up the previous session', {
1488
+ regionUrl,
1489
+ });
1490
+ return;
1491
+ }
1417
1492
 
1418
1493
  let joinResponse: JoinResponse;
1419
1494
  try {
@@ -1427,7 +1502,7 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
1427
1502
  regionUrl ?? this.url,
1428
1503
  this.token,
1429
1504
  this.signalOpts,
1430
- undefined,
1505
+ abortSignal,
1431
1506
  !this.options.singlePeerConnection,
1432
1507
  )
1433
1508
  ).joinResponse;
@@ -1437,6 +1512,10 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
1437
1512
  }
1438
1513
  throw new SignalReconnectError();
1439
1514
  }
1515
+ if (abortSignal.aborted) {
1516
+ this.log.info('full reconnect cancelled after the signal join response', { regionUrl });
1517
+ return;
1518
+ }
1440
1519
 
1441
1520
  if (this.shouldFailNext) {
1442
1521
  this.shouldFailNext = false;
@@ -1447,6 +1526,10 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
1447
1526
  this.emit(EngineEvent.SignalRestarted, joinResponse);
1448
1527
 
1449
1528
  await this.waitForPCReconnected();
1529
+ if (abortSignal.aborted) {
1530
+ this.log.info('full reconnect cancelled after peer connections reconnected', { regionUrl });
1531
+ return;
1532
+ }
1450
1533
 
1451
1534
  // re-check signal connection state before setting engine as resumed
1452
1535
  if (this.client.currentState !== SignalConnectionState.CONNECTED) {
@@ -1457,9 +1540,17 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
1457
1540
  // reconnect success
1458
1541
  this.emit(EngineEvent.Restarted);
1459
1542
  } catch (error) {
1460
- const nextRegionUrl = await this.regionStrategy?.getNextUrl();
1543
+ if (abortSignal.aborted) {
1544
+ this.log.info('full reconnect cancelled after a failed attempt', { regionUrl });
1545
+ return;
1546
+ }
1547
+ const nextRegionUrl = await this.regionStrategy?.getNextUrl(abortSignal);
1548
+ if (abortSignal.aborted) {
1549
+ this.log.info('full reconnect cancelled during region selection', { regionUrl });
1550
+ return;
1551
+ }
1461
1552
  if (nextRegionUrl) {
1462
- await this.restartConnection(nextRegionUrl);
1553
+ await this.restartConnection(abortSignal, nextRegionUrl);
1463
1554
  return;
1464
1555
  } else {
1465
1556
  // no more regions to try (or we're not on cloud)
@@ -1693,7 +1784,11 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
1693
1784
  * {@link FlowControlledDataChannel.waitForHeadroomWithLock}.
1694
1785
  */
1695
1786
  async waitForBufferHeadroom(kind: DataChannelKind) {
1696
- return this.flowControlFor(kind).waitForHeadroomWithLock();
1787
+ const dc = this.dataChannelForKind(kind);
1788
+ if (!dc) {
1789
+ throw new UnexpectedConnectionState(`DataChannel not found, kind: ${kind}`);
1790
+ }
1791
+ return this.flowControlFor(kind).waitForHeadroomWithLock(dc);
1697
1792
  }
1698
1793
 
1699
1794
  /**
@@ -347,3 +347,119 @@ describe('stream state updates', () => {
347
347
  expect(participantEvents).not.toHaveBeenCalled();
348
348
  });
349
349
  });
350
+
351
+ describe('audio context ownership', () => {
352
+ class StubAudioContext {
353
+ state: AudioContextState = 'running';
354
+
355
+ closed = false;
356
+
357
+ async close() {
358
+ this.closed = true;
359
+ this.state = 'closed';
360
+ }
361
+
362
+ async resume() {
363
+ this.state = 'running';
364
+ }
365
+ }
366
+
367
+ const created: StubAudioContext[] = [];
368
+
369
+ function stubAudioContext() {
370
+ created.length = 0;
371
+ vi.stubGlobal(
372
+ 'AudioContext',
373
+ class extends StubAudioContext {
374
+ constructor() {
375
+ super();
376
+ created.push(this);
377
+ }
378
+ },
379
+ );
380
+ }
381
+
382
+ /** `acquireAudioContext` and `releaseAudioContext` are private */
383
+ function internals(room: Room) {
384
+ return room as unknown as {
385
+ acquireAudioContext: () => Promise<void>;
386
+ releaseAudioContext: (retained?: unknown[]) => Promise<void>;
387
+ audioContext?: StubAudioContext;
388
+ ownsAudioContext: boolean;
389
+ localParticipant: { setAudioContext: (ctx: AudioContext | undefined) => Promise<void> };
390
+ };
391
+ }
392
+
393
+ afterEach(() => {
394
+ vi.unstubAllGlobals();
395
+ vi.restoreAllMocks();
396
+ });
397
+
398
+ it('closes a context it created for a webAudioMix object that supplies none', async () => {
399
+ stubAudioContext();
400
+ const room = internals(new Room({ webAudioMix: {} }));
401
+
402
+ await room.acquireAudioContext();
403
+ expect(created).toHaveLength(1);
404
+ expect(room.ownsAudioContext).toBe(true);
405
+ expect(room.audioContext).toBe(created[0]);
406
+
407
+ await room.releaseAudioContext();
408
+ expect(created[0].closed).toBe(true);
409
+ expect(room.audioContext).toBeUndefined();
410
+ });
411
+
412
+ it('leaves a supplied context open', async () => {
413
+ stubAudioContext();
414
+ const provided = new StubAudioContext();
415
+ const room = internals(
416
+ new Room({ webAudioMix: { audioContext: provided as unknown as AudioContext } }),
417
+ );
418
+
419
+ await room.acquireAudioContext();
420
+ expect(room.ownsAudioContext).toBe(false);
421
+ expect(created).toHaveLength(0);
422
+
423
+ await room.releaseAudioContext();
424
+ expect(provided.closed).toBe(false);
425
+ });
426
+
427
+ it('closes the context even when a retained track fails to detach', async () => {
428
+ stubAudioContext();
429
+ const room = internals(new Room({ webAudioMix: {} }));
430
+ await room.acquireAudioContext();
431
+
432
+ const retained = { setAudioContext: () => Promise.reject(new Error('detach failed')) };
433
+ await room.releaseAudioContext([retained]);
434
+
435
+ expect(created[0].closed).toBe(true);
436
+ });
437
+
438
+ it('does not build a context for an acquire queued behind a release', async () => {
439
+ stubAudioContext();
440
+ const room = internals(new Room({ webAudioMix: {} }));
441
+ await room.acquireAudioContext();
442
+ expect(created).toHaveLength(1);
443
+
444
+ // `startAudio` reads `audioContextReleased` before it waits on the mutex, so its acquire can
445
+ // already be queued behind a disconnect by the time the flag is set
446
+ const released = room.releaseAudioContext();
447
+ const acquired = room.acquireAudioContext();
448
+ await Promise.all([released, acquired]);
449
+
450
+ expect(created).toHaveLength(1);
451
+ expect(created[0].closed).toBe(true);
452
+ expect(room.audioContext).toBeUndefined();
453
+ });
454
+
455
+ it('closes the outgoing context even when a participant fails to move off it', async () => {
456
+ stubAudioContext();
457
+ const room = internals(new Room({ webAudioMix: {} }));
458
+ await room.acquireAudioContext();
459
+
460
+ vi.spyOn(room.localParticipant, 'setAudioContext').mockRejectedValue(new Error('move failed'));
461
+ await room.releaseAudioContext();
462
+
463
+ expect(created[0].closed).toBe(true);
464
+ });
465
+ });