livekit-client 2.20.1 → 2.20.2

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 (74) 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 +33 -7
  4. package/dist/livekit-client.e2ee.worker.mjs.map +1 -1
  5. package/dist/livekit-client.esm.mjs +589 -163
  6. package/dist/livekit-client.esm.mjs.map +1 -1
  7. package/dist/livekit-client.fm.worker.js +1 -1
  8. package/dist/livekit-client.fm.worker.js.map +1 -1
  9. package/dist/livekit-client.fm.worker.mjs +33 -7
  10. package/dist/livekit-client.fm.worker.mjs.map +1 -1
  11. package/dist/livekit-client.umd.js +1 -1
  12. package/dist/livekit-client.umd.js.map +1 -1
  13. package/dist/src/api/SignalClient.d.ts.map +1 -1
  14. package/dist/src/logger.d.ts +3 -1
  15. package/dist/src/logger.d.ts.map +1 -1
  16. package/dist/src/room/PCTransport.d.ts +2 -0
  17. package/dist/src/room/PCTransport.d.ts.map +1 -1
  18. package/dist/src/room/PCTransportManager.d.ts +1 -0
  19. package/dist/src/room/PCTransportManager.d.ts.map +1 -1
  20. package/dist/src/room/RTCEngine.d.ts +45 -4
  21. package/dist/src/room/RTCEngine.d.ts.map +1 -1
  22. package/dist/src/room/RegionUrlProvider.d.ts.map +1 -1
  23. package/dist/src/room/Room.d.ts.map +1 -1
  24. package/dist/src/room/data-track/incoming/IncomingDataTrackManager.d.ts +7 -0
  25. package/dist/src/room/data-track/incoming/IncomingDataTrackManager.d.ts.map +1 -1
  26. package/dist/src/room/data-track/outgoing/OutgoingDataTrackManager.d.ts.map +1 -1
  27. package/dist/src/room/participant/LocalParticipant.d.ts.map +1 -1
  28. package/dist/src/room/participant/Participant.d.ts.map +1 -1
  29. package/dist/src/room/participant/publishUtils.d.ts +7 -0
  30. package/dist/src/room/participant/publishUtils.d.ts.map +1 -1
  31. package/dist/src/room/track/LocalAudioTrack.d.ts +1 -0
  32. package/dist/src/room/track/LocalAudioTrack.d.ts.map +1 -1
  33. package/dist/src/room/track/Track.d.ts.map +1 -1
  34. package/dist/src/room/track/options.d.ts +11 -1
  35. package/dist/src/room/track/options.d.ts.map +1 -1
  36. package/dist/src/test/signalToken.d.ts +26 -0
  37. package/dist/src/test/signalToken.d.ts.map +1 -0
  38. package/dist/src/utils/dataPacketBuffer.d.ts +18 -0
  39. package/dist/src/utils/dataPacketBuffer.d.ts.map +1 -1
  40. package/dist/ts4.2/logger.d.ts +3 -1
  41. package/dist/ts4.2/room/PCTransport.d.ts +2 -0
  42. package/dist/ts4.2/room/PCTransportManager.d.ts +1 -0
  43. package/dist/ts4.2/room/RTCEngine.d.ts +45 -4
  44. package/dist/ts4.2/room/data-track/incoming/IncomingDataTrackManager.d.ts +7 -0
  45. package/dist/ts4.2/room/participant/publishUtils.d.ts +7 -0
  46. package/dist/ts4.2/room/track/LocalAudioTrack.d.ts +1 -0
  47. package/dist/ts4.2/room/track/options.d.ts +11 -1
  48. package/dist/ts4.2/test/signalToken.d.ts +26 -0
  49. package/dist/ts4.2/utils/dataPacketBuffer.d.ts +18 -0
  50. package/package.json +7 -3
  51. package/src/api/SignalClient.e2e.test.ts +264 -0
  52. package/src/api/SignalClient.ts +34 -2
  53. package/src/logger.ts +2 -0
  54. package/src/room/PCTransport.ts +66 -31
  55. package/src/room/PCTransportManager.ts +10 -6
  56. package/src/room/RTCEngine.test.ts +275 -2
  57. package/src/room/RTCEngine.ts +276 -71
  58. package/src/room/RegionUrlProvider.ts +4 -2
  59. package/src/room/Room.ts +7 -1
  60. package/src/room/data-track/incoming/IncomingDataTrackManager.test.ts +303 -0
  61. package/src/room/data-track/incoming/IncomingDataTrackManager.ts +70 -8
  62. package/src/room/data-track/outgoing/OutgoingDataTrackManager.ts +2 -0
  63. package/src/room/errors.ts +4 -4
  64. package/src/room/participant/LocalParticipant.ts +16 -6
  65. package/src/room/participant/Participant.ts +5 -0
  66. package/src/room/participant/publishUtils.ts +14 -11
  67. package/src/room/track/LocalAudioTrack.ts +16 -0
  68. package/src/room/track/Track.ts +10 -6
  69. package/src/room/track/options.ts +11 -1
  70. package/src/test/signalServerSetup.ts +151 -0
  71. package/src/test/signalToken.ts +59 -0
  72. package/src/test/vitest-context.d.ts +10 -0
  73. package/src/utils/dataPacketBuffer.test.ts +100 -0
  74. package/src/utils/dataPacketBuffer.ts +46 -1
@@ -44,6 +44,7 @@ import {
44
44
  type UserPacket,
45
45
  } from '@livekit/protocol';
46
46
  import { EventEmitter } from 'events';
47
+ import type { Throws } from '@livekit/throws-transformer/throws';
47
48
  import type { MediaAttributes } from 'sdp-transform';
48
49
  import type TypedEventEmitter from 'typed-emitter';
49
50
  import type { SignalOptions } from '../api/SignalClient';
@@ -91,7 +92,6 @@ import type { TrackPublishOptions, VideoCodec } from './track/options';
91
92
  import { getTrackPublicationInfo } from './track/utils';
92
93
  import type { LoggerOptions } from './types';
93
94
  import {
94
- Future,
95
95
  isPublisherOfferWithJoinSupported,
96
96
  isReactNative,
97
97
  isVideoCodec,
@@ -109,8 +109,17 @@ const dataTrackDataChannel = '_data_track';
109
109
  const minReconnectWait = 2 * 1000;
110
110
  const leaveReconnect = 'leave-reconnect';
111
111
  const reliabeReceiveStateTTL = 30_000;
112
- const lossyDataChannelBufferThresholdMin = 8 * 1024;
113
- const lossyDataChannelBufferThresholdMax = 256 * 1024;
112
+
113
+ // Two-watermark flow control for the reliable and data-track channels. Senders fill the buffer
114
+ // freely up to the high-water mark; once it's exceeded they block until the browser's
115
+ // `bufferedamountlow` event (which we arm at the low-water mark) signals the buffer has drained.
116
+ // The gap between the marks keeps the SCTP send buffer saturated while we refill, so throughput
117
+ // isn't starved, while the high-water mark bounds the buffer well below the level that would abort
118
+ // the channel (see livekit/client-sdk-js#1995).
119
+ const reliableDataChannelWaterMarkLow = 64 * 1024;
120
+ const reliableDataChannelWaterMarkHigh = 1024 * 1024;
121
+ const lossyDataChannelWaterMarkLow = 8 * 1024;
122
+ const lossyDataChannelWaterMarkHigh = 256 * 1024;
114
123
 
115
124
  const initialMediaSectionsAudio = 3;
116
125
  const initialMediaSectionsVideo = 3;
@@ -262,8 +271,6 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
262
271
  /** used to indicate whether the browser is currently waiting to reconnect */
263
272
  private isWaitingForNetworkReconnect: boolean = false;
264
273
 
265
- private bufferStatusLowClosingFuture = new Future<never, UnexpectedConnectionState>();
266
-
267
274
  constructor(private options: InternalRoomOptions) {
268
275
  super();
269
276
  this.log = getLogger(options.loggerName ?? LoggerNames.Engine, () => this.logContext);
@@ -297,13 +304,6 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
297
304
  this.client.onParticipantUpdate = (updates) =>
298
305
  this.emit(EngineEvent.ParticipantUpdate, updates);
299
306
  this.client.onJoined = (joinResponse) => this.emit(EngineEvent.Joined, joinResponse);
300
-
301
- this.on(EngineEvent.Closing, () => {
302
- this.bufferStatusLowClosingFuture.reject?.(new UnexpectedConnectionState('engine closed'));
303
- });
304
- // Swallow the rejection at the source so it doesn't surface as an unhandled promise rejection
305
- // when no waitForBufferStatusLow callers are attached.
306
- this.bufferStatusLowClosingFuture.promise.catch(() => {});
307
307
  }
308
308
 
309
309
  /** @internal */
@@ -458,6 +458,11 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
458
458
  }
459
459
 
460
460
  async cleanupPeerConnections() {
461
+ // Reject parked headroom waiters up front: closing the peer connection is allowed (per spec)
462
+ // to transition data channels to 'closed' without firing events, which would otherwise strand
463
+ // a waiter holding the headroom lock.
464
+ this.invalidateDataChannelWaiters('peer connections cleaned up');
465
+
461
466
  const dcCleanup = (dc: RTCDataChannel | undefined) => {
462
467
  if (!dc) {
463
468
  return;
@@ -885,6 +890,11 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
885
890
  return;
886
891
  }
887
892
 
893
+ // Waiters parked on the old channel objects would never see another event from them once they
894
+ // are replaced below — reject them so the headroom lock is released and queued senders
895
+ // re-check against the new channels.
896
+ this.invalidateDataChannelWaiters('data channels recreated');
897
+
888
898
  // clear old data channel callbacks if recreate
889
899
  if (this.lossyDC) {
890
900
  this.lossyDC.onmessage = null;
@@ -930,10 +940,10 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
930
940
  this.reliableDC.onclose = this.handleDataChannelClose(DataChannelKind.RELIABLE);
931
941
  this.dataTrackDC.onclose = this.handleDataChannelClose(DataChannelKind.DATA_TRACK_LOSSY);
932
942
 
933
- // set up dc buffer threshold, set to 64kB (otherwise 0 by default)
934
- this.lossyDC.bufferedAmountLowThreshold = 65535;
935
- this.reliableDC.bufferedAmountLowThreshold = 65535;
936
- this.dataTrackDC.bufferedAmountLowThreshold = 65535;
943
+ // set up dc buffer threshold - if this is not set, it will default to 0
944
+ this.lossyDC.bufferedAmountLowThreshold = lossyDataChannelWaterMarkLow;
945
+ this.reliableDC.bufferedAmountLowThreshold = reliableDataChannelWaterMarkLow;
946
+ this.dataTrackDC.bufferedAmountLowThreshold = lossyDataChannelWaterMarkLow;
937
947
 
938
948
  // handle buffer amount low events
939
949
  this.lossyDC.onbufferedamountlow = () => this.handleBufferedAmountLow(DataChannelKind.LOSSY);
@@ -952,8 +962,8 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
952
962
  // control buffered latency to ~100ms
953
963
  const threshold = this.lossyDataStatByterate / 10;
954
964
  dc.bufferedAmountLowThreshold = Math.min(
955
- Math.max(threshold, lossyDataChannelBufferThresholdMin),
956
- lossyDataChannelBufferThresholdMax,
965
+ Math.max(threshold, lossyDataChannelWaterMarkLow),
966
+ lossyDataChannelWaterMarkHigh,
957
967
  );
958
968
  }
959
969
  }, 1000);
@@ -1505,7 +1515,12 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
1505
1515
  }
1506
1516
 
1507
1517
  if (res?.lastMessageSeq) {
1508
- this.resendReliableMessagesForResume(res.lastMessageSeq);
1518
+ this.resendReliableMessagesForResume(res.lastMessageSeq).catch((error) => {
1519
+ this.log.warn('failed to resend reliable messages after resume', {
1520
+ ...this.logContext,
1521
+ error,
1522
+ });
1523
+ });
1509
1524
  }
1510
1525
 
1511
1526
  // resume success
@@ -1621,21 +1636,43 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
1621
1636
  case DataChannelKind.DATA_TRACK_LOSSY:
1622
1637
  return this.sendLossyBytes(msg, kind);
1623
1638
 
1624
- case DataChannelKind.RELIABLE:
1639
+ case DataChannelKind.RELIABLE: {
1640
+ if (this.attemptingReconnect) {
1641
+ // A reconnect is already underway — queue for the resume replay instead of parking on a
1642
+ // channel that is being torn down. The send resolves; delivery is deferred to the replay.
1643
+ this.reliableMessageBuffer.push({ data: msg, sequence: packet.sequence, sent: false });
1644
+ return;
1645
+ }
1646
+
1625
1647
  const dc = this.dataChannelForKind(kind);
1626
1648
  if (dc) {
1627
- await this.waitForBufferStatusLow(kind);
1628
- this.reliableMessageBuffer.push({ data: msg, sequence: packet.sequence });
1649
+ try {
1650
+ await this.waitForBufferHeadroomWithLock(kind);
1651
+ } catch (error) {
1652
+ if (this.isClosed) {
1653
+ // No replay is coming after an engine close — surface the failure.
1654
+ throw error;
1655
+ }
1656
+ // Transient teardown (the channel closed or was replaced while we waited): the
1657
+ // reliable channel promises delivery across resume, so queue the packet for the
1658
+ // replay instead of rejecting a send the app can't meaningfully retry.
1659
+ this.reliableMessageBuffer.push({ data: msg, sequence: packet.sequence, sent: false });
1660
+ return;
1661
+ }
1629
1662
 
1630
1663
  if (this.attemptingReconnect) {
1664
+ // A reconnect began while we waited for headroom — same deal as above.
1665
+ this.reliableMessageBuffer.push({ data: msg, sequence: packet.sequence, sent: false });
1631
1666
  return;
1632
1667
  }
1633
1668
 
1669
+ this.reliableMessageBuffer.push({ data: msg, sequence: packet.sequence, sent: true });
1634
1670
  dc.send(msg);
1635
1671
  }
1636
1672
 
1637
1673
  this.updateAndEmitDCBufferStatus(kind);
1638
1674
  break;
1675
+ }
1639
1676
  }
1640
1677
  }
1641
1678
 
@@ -1643,42 +1680,62 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
1643
1680
  async sendLossyBytes(
1644
1681
  bytes: NonSharedUint8Array,
1645
1682
  kind: Exclude<DataChannelKind, DataChannelKind.RELIABLE>,
1646
- bufferStatusLowBehavior: 'drop' | 'wait' = 'drop',
1683
+ bufferStatusFullBehavior: 'drop' | 'wait' = 'drop',
1647
1684
  ) {
1648
- // make sure we do have a data connection
1685
+ // Make sure we do have a data connection. This matters most for the direct data-track path
1686
+ // (Room's packetAvailable handler), which doesn't go through sendDataPacket: it both waits for
1687
+ // the channel to be open and — on lazily negotiated publisher connections — is what kicks the
1688
+ // negotiation off in the first place. The call is memoized, so the steady-state cost is one
1689
+ // await on an already-resolved promise.
1649
1690
  await this.ensurePublisherConnected(kind);
1650
1691
 
1651
1692
  const dc = this.dataChannelForKind(kind);
1652
- if (dc) {
1653
- if (!this.isBufferStatusLow(kind)) {
1693
+ try {
1694
+ if (dc) {
1654
1695
  // Depending on the exact circumstance that data is being sent, either drop or wait for the
1655
- // buffer status to not be low before continuing.
1656
- switch (bufferStatusLowBehavior) {
1696
+ // buffer to drain below the high-water mark before continuing.
1697
+ switch (bufferStatusFullBehavior) {
1657
1698
  case 'wait':
1658
- await this.waitForBufferStatusLow(kind);
1699
+ if (!this.isBelowHighWaterMark(kind)) {
1700
+ await this.waitForBufferHeadroomWithLock(kind);
1701
+ }
1659
1702
  break;
1660
1703
  case 'drop':
1661
- // this.log.warn(`dropping lossy data channel message`, this.logContext);
1662
- // Drop messages to reduce latency
1663
- this.lossyDataDropCount += 1;
1664
- if (this.lossyDataDropCount % 100 === 0) {
1665
- this.log.warn(
1666
- `dropping lossy data channel messages, total dropped: ${this.lossyDataDropCount}`,
1667
- );
1704
+ // we check against the actual threshold on the DC here, as it is dynamic for the lossy DC
1705
+ if (!this.isBelowLowWaterMark(kind)) {
1706
+ // Drop messages to reduce latency
1707
+ this.lossyDataDropCount += 1;
1708
+ if (this.lossyDataDropCount % 100 === 0) {
1709
+ this.log.warn(
1710
+ `dropping lossy data channel messages, total dropped: ${this.lossyDataDropCount}`,
1711
+ );
1712
+ }
1713
+ return;
1668
1714
  }
1669
- return;
1670
1715
  }
1671
- }
1672
- this.lossyDataStatCurrentBytes += bytes.byteLength;
1716
+ if (kind === DataChannelKind.LOSSY) {
1717
+ // The byterate stat tunes the LOSSY channel's dynamic drop threshold; counting data-track
1718
+ // bytes here would inflate it and let the lossy channel buffer far more latency than the
1719
+ // ~100ms the tuning targets.
1720
+ this.lossyDataStatCurrentBytes += bytes.byteLength;
1721
+ }
1673
1722
 
1674
- if (this.attemptingReconnect) {
1675
- return;
1723
+ if (this.attemptingReconnect) {
1724
+ return;
1725
+ }
1726
+
1727
+ dc.send(bytes);
1676
1728
  }
1677
1729
 
1678
- dc.send(bytes);
1730
+ this.updateAndEmitDCBufferStatus(kind);
1731
+ } catch (error: unknown) {
1732
+ // ensure same surface behaviour as before with missing data channel being silently ignored, just log an error message for clarity
1733
+ if (error instanceof TypeError) {
1734
+ this.log.error(error);
1735
+ } else {
1736
+ throw error;
1737
+ }
1679
1738
  }
1680
-
1681
- this.updateAndEmitDCBufferStatus(kind);
1682
1739
  }
1683
1740
 
1684
1741
  private async resendReliableMessagesForResume(lastMessageSeq: number) {
@@ -1686,9 +1743,36 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
1686
1743
  const dc = this.dataChannelForKind(DataChannelKind.RELIABLE);
1687
1744
  if (dc) {
1688
1745
  this.reliableMessageBuffer.popToSequence(lastMessageSeq);
1689
- this.reliableMessageBuffer.getAll().forEach((msg) => {
1690
- dc.send(msg.data);
1691
- });
1746
+ // Hold the headroom lock across the whole replay. Releasing it between messages would let a
1747
+ // concurrent send — whose (newer) sequence was already assigned in sendDataPacket before it
1748
+ // queued on the lock — hit the wire mid-replay, and receivers would then discard the
1749
+ // remaining lower-sequence resent messages as duplicates.
1750
+ const unlock = await this.getBufferHeadroomLock(DataChannelKind.RELIABLE).lock();
1751
+ try {
1752
+ // Everything left after the ack cutoff must be re-handed to the current channel.
1753
+ this.reliableMessageBuffer.markAllUnsent();
1754
+ // Drain in passes, re-scanning the live buffer each time: a send that arrives (deferred,
1755
+ // sent:false) during our own awaits appends after this pass started, so we pick it up on
1756
+ // the next one. We mark each packet only once we've actually handed it to the channel — a
1757
+ // blanket "mark all sent" would flip such a late arrival to sent without transmitting it,
1758
+ // and a later alignBufferedAmount would then drop it for good. If the loop throws
1759
+ // mid-drain, unsent entries keep their flag and the next replay picks them up.
1760
+ for (
1761
+ let batch = this.reliableMessageBuffer.getUnsent();
1762
+ batch.length > 0;
1763
+ batch = this.reliableMessageBuffer.getUnsent()
1764
+ ) {
1765
+ for (const item of batch) {
1766
+ // Respect flow control on resume too, so a large resend doesn't overflow the buffer.
1767
+ // We already hold the lock across the whole replay, so use the lock-free wait.
1768
+ await this.waitForBufferHeadroomWithoutLock(DataChannelKind.RELIABLE);
1769
+ dc.send(item.data);
1770
+ this.reliableMessageBuffer.markSent(item);
1771
+ }
1772
+ }
1773
+ } finally {
1774
+ unlock();
1775
+ }
1692
1776
  }
1693
1777
  this.updateAndEmitDCBufferStatus(DataChannelKind.RELIABLE);
1694
1778
  }
@@ -1700,39 +1784,160 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
1700
1784
  this.reliableMessageBuffer.alignBufferedAmount(dc.bufferedAmount);
1701
1785
  }
1702
1786
  }
1703
-
1704
- const status = this.isBufferStatusLow(kind);
1705
- if (typeof status !== 'undefined' && status !== this.dcBufferStatus.get(kind)) {
1706
- this.dcBufferStatus.set(kind, status);
1707
- this.emit(EngineEvent.DCBufferStatusChanged, status, kind);
1787
+ try {
1788
+ const status = this.isBelowLowWaterMark(kind);
1789
+ if (typeof status !== 'undefined' && status !== this.dcBufferStatus.get(kind)) {
1790
+ this.dcBufferStatus.set(kind, status);
1791
+ this.emit(EngineEvent.DCBufferStatusChanged, status, kind);
1792
+ }
1793
+ } catch (e) {
1794
+ this.log.warn('could not update buffer status', { error: e });
1708
1795
  }
1709
1796
  };
1710
1797
 
1711
- private isBufferStatusLow = (kind: DataChannelKind): boolean | undefined => {
1798
+ /**
1799
+ * Whether the send buffer has room to accept more data (the send gate). Senders proceed while
1800
+ * this is true and block once it goes false.
1801
+ */
1802
+ private isBelowHighWaterMark = (kind: DataChannelKind): Throws<boolean, TypeError> => {
1712
1803
  const dc = this.dataChannelForKind(kind);
1713
- if (dc) {
1714
- return dc.bufferedAmount <= dc.bufferedAmountLowThreshold;
1804
+ if (!dc) {
1805
+ throw new TypeError(`Could not get data channel for kind ${kind}`);
1806
+ }
1807
+ // because RTCDatachannel has no high water mark built in we read the statically defined versions as constants
1808
+ const highMark =
1809
+ kind === DataChannelKind.RELIABLE
1810
+ ? reliableDataChannelWaterMarkHigh
1811
+ : lossyDataChannelWaterMarkHigh;
1812
+ return dc.bufferedAmount <= highMark;
1813
+ };
1814
+
1815
+ /**
1816
+ * Whether the send buffer has drained to its low-water mark. Drives the public
1817
+ * {@link EngineEvent.DCBufferStatusChanged} event.
1818
+ */
1819
+ private isBelowLowWaterMark = (kind: DataChannelKind): Throws<boolean, TypeError> => {
1820
+ const dc = this.dataChannelForKind(kind);
1821
+ if (!dc) {
1822
+ throw new TypeError(`Could not get data channel for kind ${kind}`);
1715
1823
  }
1824
+ // because RTCDatachannel has the threshold built in we can read it dynamically to account for changing thresholds over time
1825
+ return dc.bufferedAmount <= dc.bufferedAmountLowThreshold;
1716
1826
  };
1717
1827
 
1718
- async waitForBufferStatusLow(kind: DataChannelKind) {
1719
- return new TypedPromise<void, UnexpectedConnectionState>(async (resolve, reject) => {
1720
- if (this.isClosed) {
1721
- reject(new UnexpectedConnectionState('engine closed'));
1722
- }
1723
- if (this.isBufferStatusLow(kind)) {
1828
+ /** Per-kind lock serializing senders that have to wait for the buffer to drain. */
1829
+ private waitForBufferHeadroomLocks = new Map<DataChannelKind, Mutex>();
1830
+
1831
+ /**
1832
+ * Per-kind AbortController that cancels parked headroom waiters whenever a kind's channel object
1833
+ * stops being current (replaced by createDataChannels or torn down by cleanupPeerConnections). A
1834
+ * waiter is parked on the channel object it captured at wait entry; if that object is abandoned
1835
+ * its events may never fire again, so the abort is what rejects the waiter and releases the
1836
+ * headroom lock instead of stranding every future sender behind it.
1837
+ */
1838
+ private waiterAbortControllers = new Map<DataChannelKind, AbortController>();
1839
+
1840
+ private waiterAbortSignal(kind: DataChannelKind): AbortSignal {
1841
+ let controller = this.waiterAbortControllers.get(kind);
1842
+ if (!controller) {
1843
+ controller = new AbortController();
1844
+ this.waiterAbortControllers.set(kind, controller);
1845
+ }
1846
+ return controller.signal;
1847
+ }
1848
+
1849
+ /** Rejects all parked headroom waiters (all kinds); the next waiter gets a fresh controller. */
1850
+ private invalidateDataChannelWaiters(reason: string) {
1851
+ for (const controller of this.waiterAbortControllers.values()) {
1852
+ controller.abort(reason);
1853
+ }
1854
+ this.waiterAbortControllers.clear();
1855
+ }
1856
+
1857
+ /** Acquires the per-kind headroom lock, resolving with the unlock function. */
1858
+ private getBufferHeadroomLock(kind: DataChannelKind): Mutex {
1859
+ let lock = this.waitForBufferHeadroomLocks.get(kind);
1860
+ if (!lock) {
1861
+ lock = new Mutex();
1862
+ this.waitForBufferHeadroomLocks.set(kind, lock);
1863
+ }
1864
+ return lock;
1865
+ }
1866
+
1867
+ /**
1868
+ * Acquires the `kind` headroom lock, waits until the send buffer has room, then releases. The
1869
+ * common single-send entry point. Callers that need to hold the lock across several sends (the
1870
+ * resume replay) acquire it via {@link getBufferHeadroomLock} and call
1871
+ * {@link waitForBufferHeadroomWithoutLock} per message instead.
1872
+ */
1873
+ async waitForBufferHeadroomWithLock(kind: DataChannelKind) {
1874
+ const unlock = await this.getBufferHeadroomLock(kind).lock();
1875
+ try {
1876
+ await this.waitForBufferHeadroomWithoutLock(kind);
1877
+ } finally {
1878
+ unlock();
1879
+ }
1880
+ }
1881
+
1882
+ /**
1883
+ * Waits until the send buffer for `kind` is at or below the high-water mark (draining, if
1884
+ * needed, via the `bufferedamountlow` event). Does no locking of its own — the caller must
1885
+ * already hold the kind's headroom lock (via {@link getBufferHeadroomLock}). The resume replay
1886
+ * holds the lock across its whole batch and calls this per message so no other sender can
1887
+ * interleave; the single-send path goes through {@link waitForBufferHeadroomWithLock}.
1888
+ *
1889
+ * Serializing through the per-kind lock means that, once the buffer drains, queued callers
1890
+ * refill it one at a time (up to the high-water mark) rather than all at once and overflowing
1891
+ * the SCTP send buffer (see livekit/client-sdk-js#1995).
1892
+ */
1893
+ private async waitForBufferHeadroomWithoutLock(
1894
+ kind: DataChannelKind,
1895
+ ): Promise<Throws<void, UnexpectedConnectionState | TypeError>> {
1896
+ if (this.isClosed) {
1897
+ throw new UnexpectedConnectionState('engine closed');
1898
+ }
1899
+ if (this.isBelowHighWaterMark(kind)) {
1900
+ return;
1901
+ }
1902
+ const dc = this.dataChannelForKind(kind);
1903
+ if (!dc) {
1904
+ throw new UnexpectedConnectionState(`DataChannel not found, kind: ${kind}`);
1905
+ }
1906
+ const abortSignal = this.waiterAbortSignal(kind);
1907
+ await new TypedPromise<void, UnexpectedConnectionState>((resolve, reject) => {
1908
+ const onBufferedAmountLow = () => {
1909
+ cleanup();
1724
1910
  resolve();
1725
- } else {
1726
- const dc = this.dataChannelForKind(kind);
1727
- if (!dc) {
1728
- reject(new UnexpectedConnectionState(`DataChannel not found, kind: ${kind}`));
1729
- return;
1730
- }
1731
- this.bufferStatusLowClosingFuture.promise.catch((e) => reject(e));
1732
- dc.addEventListener('bufferedamountlow', () => resolve(), {
1733
- once: true,
1734
- });
1911
+ };
1912
+ const onDCClose = () => {
1913
+ cleanup();
1914
+ reject(
1915
+ new UnexpectedConnectionState(`DataChannel ${kind} closed while draining the buffer`),
1916
+ );
1917
+ };
1918
+ const onAbort = () => {
1919
+ cleanup();
1920
+ reject(
1921
+ new UnexpectedConnectionState(
1922
+ `DataChannel ${kind} was replaced or torn down while waiting for headroom`,
1923
+ ),
1924
+ );
1925
+ };
1926
+ const cleanup = () => {
1927
+ dc.removeEventListener('bufferedamountlow', onBufferedAmountLow);
1928
+ dc.removeEventListener('close', onDCClose);
1929
+ abortSignal.removeEventListener('abort', onAbort);
1930
+ };
1931
+ if (abortSignal.aborted) {
1932
+ onAbort();
1933
+ return;
1735
1934
  }
1935
+ dc.addEventListener('bufferedamountlow', onBufferedAmountLow);
1936
+ // Proxy along any error caused by the data channel closing while we wait.
1937
+ dc.addEventListener('close', onDCClose);
1938
+ // The channel object we're parked on can be abandoned without ever firing another event
1939
+ // (e.g. createDataChannels replacing it); the abort is our way out.
1940
+ abortSignal.addEventListener('abort', onAbort);
1736
1941
  });
1737
1942
  }
1738
1943
 
@@ -1,9 +1,11 @@
1
1
  import { Mutex } from '@livekit/mutex';
2
2
  import type { RegionInfo, RegionSettings } from '@livekit/protocol';
3
- import log from '../logger';
3
+ import { LoggerNames, getLogger } from '../logger';
4
4
  import { ConnectionError, ConnectionErrorReason } from './errors';
5
5
  import { extractMaxAgeFromRequestHeaders, isCloud } from './utils';
6
6
 
7
+ const log = getLogger(LoggerNames.Region);
8
+
7
9
  export const DEFAULT_MAX_AGE_MS = 5_000;
8
10
  export const STOP_REFETCH_DELAY_MS = 30_000;
9
11
 
@@ -229,7 +231,7 @@ export class RegionUrlProvider {
229
231
  if (regionsLeft.length > 0) {
230
232
  const nextRegion = regionsLeft[0];
231
233
  this.attemptedRegions.push(nextRegion);
232
- log.debug(`next region: ${nextRegion.region}`);
234
+ log.info(`switching to region: ${nextRegion.region}`, { region: nextRegion.region });
233
235
  return nextRegion.url;
234
236
  } else {
235
237
  return null;
package/src/room/Room.ts CHANGED
@@ -1383,7 +1383,13 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
1383
1383
  await Promise.all([
1384
1384
  this.acquireAudioContext(),
1385
1385
  ...elements.map((e) => {
1386
- e.muted = false;
1386
+ // when webAudioMix is enabled, attached elements are deliberately kept muted by
1387
+ // RemoteAudioTrack.attach() and audio is routed through the web audio graph instead.
1388
+ // unmuting them here would cause double playback on platforms where element.volume
1389
+ // has no effect (e.g. iOS Safari)
1390
+ if (!this.options.webAudioMix) {
1391
+ e.muted = false;
1392
+ }
1387
1393
  return e.play();
1388
1394
  }),
1389
1395
  ]);