livekit-client 2.20.0 → 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 (155) 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 +41 -11
  4. package/dist/livekit-client.e2ee.worker.mjs.map +1 -1
  5. package/dist/livekit-client.esm.mjs +744 -178
  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 +41 -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/e2ee/E2eeManager.d.ts +1 -0
  15. package/dist/src/e2ee/E2eeManager.d.ts.map +1 -1
  16. package/dist/src/e2ee/types.d.ts +1 -0
  17. package/dist/src/e2ee/types.d.ts.map +1 -1
  18. package/dist/src/e2ee/utils.d.ts +1 -0
  19. package/dist/src/e2ee/utils.d.ts.map +1 -1
  20. package/dist/src/e2ee/worker/DataCryptor.d.ts +1 -0
  21. package/dist/src/e2ee/worker/DataCryptor.d.ts.map +1 -1
  22. package/dist/src/e2ee/worker/FrameCryptor.d.ts +1 -0
  23. package/dist/src/e2ee/worker/FrameCryptor.d.ts.map +1 -1
  24. package/dist/src/e2ee/worker/naluUtils.d.ts +1 -0
  25. package/dist/src/e2ee/worker/naluUtils.d.ts.map +1 -1
  26. package/dist/src/e2ee/worker/sifPayload.d.ts +1 -0
  27. package/dist/src/e2ee/worker/sifPayload.d.ts.map +1 -1
  28. package/dist/src/frameMetadata/frameMetadata.d.ts +1 -0
  29. package/dist/src/frameMetadata/frameMetadata.d.ts.map +1 -1
  30. package/dist/src/frameMetadata/types.d.ts +1 -0
  31. package/dist/src/frameMetadata/types.d.ts.map +1 -1
  32. package/dist/src/logger.d.ts +3 -1
  33. package/dist/src/logger.d.ts.map +1 -1
  34. package/dist/src/room/PCTransport.d.ts +42 -1
  35. package/dist/src/room/PCTransport.d.ts.map +1 -1
  36. package/dist/src/room/PCTransportManager.d.ts +1 -0
  37. package/dist/src/room/PCTransportManager.d.ts.map +1 -1
  38. package/dist/src/room/RTCEngine.d.ts +46 -4
  39. package/dist/src/room/RTCEngine.d.ts.map +1 -1
  40. package/dist/src/room/RegionUrlProvider.d.ts.map +1 -1
  41. package/dist/src/room/Room.d.ts +1 -0
  42. package/dist/src/room/Room.d.ts.map +1 -1
  43. package/dist/src/room/data-track/depacketizer.d.ts.map +1 -1
  44. package/dist/src/room/data-track/frame.d.ts +1 -0
  45. package/dist/src/room/data-track/frame.d.ts.map +1 -1
  46. package/dist/src/room/data-track/incoming/IncomingDataTrackManager.d.ts +7 -0
  47. package/dist/src/room/data-track/incoming/IncomingDataTrackManager.d.ts.map +1 -1
  48. package/dist/src/room/data-track/outgoing/OutgoingDataTrackManager.d.ts.map +1 -1
  49. package/dist/src/room/data-track/outgoing/types.d.ts +1 -0
  50. package/dist/src/room/data-track/outgoing/types.d.ts.map +1 -1
  51. package/dist/src/room/data-track/packet/extensions.d.ts +1 -0
  52. package/dist/src/room/data-track/packet/extensions.d.ts.map +1 -1
  53. package/dist/src/room/data-track/packet/index.d.ts +1 -0
  54. package/dist/src/room/data-track/packet/index.d.ts.map +1 -1
  55. package/dist/src/room/data-track/packet/serializable.d.ts +1 -0
  56. package/dist/src/room/data-track/packet/serializable.d.ts.map +1 -1
  57. package/dist/src/room/participant/LocalParticipant.d.ts +1 -0
  58. package/dist/src/room/participant/LocalParticipant.d.ts.map +1 -1
  59. package/dist/src/room/participant/Participant.d.ts +1 -0
  60. package/dist/src/room/participant/Participant.d.ts.map +1 -1
  61. package/dist/src/room/participant/publishUtils.d.ts +7 -0
  62. package/dist/src/room/participant/publishUtils.d.ts.map +1 -1
  63. package/dist/src/room/track/LocalAudioTrack.d.ts +1 -0
  64. package/dist/src/room/track/LocalAudioTrack.d.ts.map +1 -1
  65. package/dist/src/room/track/Track.d.ts +1 -0
  66. package/dist/src/room/track/Track.d.ts.map +1 -1
  67. package/dist/src/room/track/options.d.ts +11 -1
  68. package/dist/src/room/track/options.d.ts.map +1 -1
  69. package/dist/src/room/track/record.d.ts.map +1 -1
  70. package/dist/src/room/utils.d.ts +1 -0
  71. package/dist/src/room/utils.d.ts.map +1 -1
  72. package/dist/src/test/signalToken.d.ts +26 -0
  73. package/dist/src/test/signalToken.d.ts.map +1 -0
  74. package/dist/src/type-polyfills/non-shared-typed-arrays.d.ts +2 -0
  75. package/dist/src/type-polyfills/non-shared-typed-arrays.d.ts.map +1 -0
  76. package/dist/src/utils/dataPacketBuffer.d.ts +19 -0
  77. package/dist/src/utils/dataPacketBuffer.d.ts.map +1 -1
  78. package/dist/ts4.2/e2ee/E2eeManager.d.ts +1 -0
  79. package/dist/ts4.2/e2ee/types.d.ts +1 -0
  80. package/dist/ts4.2/e2ee/utils.d.ts +1 -0
  81. package/dist/ts4.2/e2ee/worker/DataCryptor.d.ts +1 -0
  82. package/dist/ts4.2/e2ee/worker/FrameCryptor.d.ts +1 -0
  83. package/dist/ts4.2/e2ee/worker/naluUtils.d.ts +1 -0
  84. package/dist/ts4.2/e2ee/worker/sifPayload.d.ts +1 -0
  85. package/dist/ts4.2/frameMetadata/frameMetadata.d.ts +1 -0
  86. package/dist/ts4.2/frameMetadata/types.d.ts +1 -0
  87. package/dist/ts4.2/logger.d.ts +3 -1
  88. package/dist/ts4.2/room/PCTransport.d.ts +42 -1
  89. package/dist/ts4.2/room/PCTransportManager.d.ts +1 -0
  90. package/dist/ts4.2/room/RTCEngine.d.ts +46 -4
  91. package/dist/ts4.2/room/Room.d.ts +1 -0
  92. package/dist/ts4.2/room/data-track/frame.d.ts +1 -0
  93. package/dist/ts4.2/room/data-track/incoming/IncomingDataTrackManager.d.ts +7 -0
  94. package/dist/ts4.2/room/data-track/outgoing/types.d.ts +1 -0
  95. package/dist/ts4.2/room/data-track/packet/extensions.d.ts +1 -0
  96. package/dist/ts4.2/room/data-track/packet/index.d.ts +1 -0
  97. package/dist/ts4.2/room/data-track/packet/serializable.d.ts +1 -0
  98. package/dist/ts4.2/room/participant/LocalParticipant.d.ts +1 -0
  99. package/dist/ts4.2/room/participant/Participant.d.ts +1 -0
  100. package/dist/ts4.2/room/participant/publishUtils.d.ts +7 -0
  101. package/dist/ts4.2/room/track/LocalAudioTrack.d.ts +1 -0
  102. package/dist/ts4.2/room/track/Track.d.ts +1 -0
  103. package/dist/ts4.2/room/track/options.d.ts +11 -1
  104. package/dist/ts4.2/room/utils.d.ts +1 -0
  105. package/dist/ts4.2/test/signalToken.d.ts +26 -0
  106. package/dist/ts4.2/type-polyfills/non-shared-typed-arrays.d.ts +2 -0
  107. package/dist/ts4.2/utils/dataPacketBuffer.d.ts +19 -0
  108. package/package.json +8 -4
  109. package/src/api/SignalClient.e2e.test.ts +264 -0
  110. package/src/api/SignalClient.ts +35 -2
  111. package/src/e2ee/E2eeManager.ts +3 -1
  112. package/src/e2ee/types.ts +1 -0
  113. package/src/e2ee/utils.ts +1 -0
  114. package/src/e2ee/worker/DataCryptor.ts +1 -0
  115. package/src/e2ee/worker/FrameCryptor.test.ts +1 -0
  116. package/src/e2ee/worker/FrameCryptor.ts +1 -0
  117. package/src/e2ee/worker/ParticipantKeyHandler.test.ts +1 -0
  118. package/src/e2ee/worker/e2ee.worker.ts +1 -0
  119. package/src/e2ee/worker/naluUtils.ts +1 -0
  120. package/src/e2ee/worker/sifPayload.ts +1 -0
  121. package/src/frameMetadata/frameMetadata.test.ts +83 -0
  122. package/src/frameMetadata/frameMetadata.ts +8 -0
  123. package/src/frameMetadata/types.ts +1 -0
  124. package/src/logger.ts +2 -0
  125. package/src/room/PCTransport.test.ts +150 -0
  126. package/src/room/PCTransport.ts +179 -31
  127. package/src/room/PCTransportManager.ts +10 -6
  128. package/src/room/RTCEngine.test.ts +275 -2
  129. package/src/room/RTCEngine.ts +277 -71
  130. package/src/room/RegionUrlProvider.ts +4 -2
  131. package/src/room/Room.ts +8 -1
  132. package/src/room/data-track/depacketizer.ts +1 -0
  133. package/src/room/data-track/frame.ts +1 -0
  134. package/src/room/data-track/incoming/IncomingDataTrackManager.test.ts +303 -0
  135. package/src/room/data-track/incoming/IncomingDataTrackManager.ts +70 -8
  136. package/src/room/data-track/outgoing/OutgoingDataTrackManager.ts +2 -0
  137. package/src/room/data-track/outgoing/types.ts +1 -0
  138. package/src/room/data-track/packet/extensions.ts +1 -0
  139. package/src/room/data-track/packet/index.ts +1 -0
  140. package/src/room/data-track/packet/serializable.ts +1 -0
  141. package/src/room/errors.ts +4 -4
  142. package/src/room/participant/LocalParticipant.ts +17 -6
  143. package/src/room/participant/Participant.ts +6 -0
  144. package/src/room/participant/publishUtils.ts +14 -11
  145. package/src/room/track/LocalAudioTrack.ts +16 -0
  146. package/src/room/track/Track.ts +11 -6
  147. package/src/room/track/options.ts +11 -1
  148. package/src/room/track/record.ts +1 -0
  149. package/src/room/utils.ts +1 -0
  150. package/src/test/signalServerSetup.ts +151 -0
  151. package/src/test/signalToken.ts +59 -0
  152. package/src/test/vitest-context.d.ts +10 -0
  153. package/src/type-polyfills/{non-shared-typed-arrays.d.ts → non-shared-typed-arrays.ts} +6 -1
  154. package/src/utils/dataPacketBuffer.test.ts +100 -0
  155. package/src/utils/dataPacketBuffer.ts +48 -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';
@@ -61,6 +62,7 @@ import {
61
62
  } from '../frameMetadata/utils';
62
63
  import log, { LoggerNames, getLogger } from '../logger';
63
64
  import type { InternalRoomOptions } from '../options';
65
+ import type { NonSharedUint8Array } from '../type-polyfills/non-shared-typed-arrays';
64
66
  import TypedPromise from '../utils/TypedPromise';
65
67
  import { DataPacketBuffer } from '../utils/dataPacketBuffer';
66
68
  import { TTLMap } from '../utils/ttlmap';
@@ -90,7 +92,6 @@ import type { TrackPublishOptions, VideoCodec } from './track/options';
90
92
  import { getTrackPublicationInfo } from './track/utils';
91
93
  import type { LoggerOptions } from './types';
92
94
  import {
93
- Future,
94
95
  isPublisherOfferWithJoinSupported,
95
96
  isReactNative,
96
97
  isVideoCodec,
@@ -108,8 +109,17 @@ const dataTrackDataChannel = '_data_track';
108
109
  const minReconnectWait = 2 * 1000;
109
110
  const leaveReconnect = 'leave-reconnect';
110
111
  const reliabeReceiveStateTTL = 30_000;
111
- const lossyDataChannelBufferThresholdMin = 8 * 1024;
112
- 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;
113
123
 
114
124
  const initialMediaSectionsAudio = 3;
115
125
  const initialMediaSectionsVideo = 3;
@@ -261,8 +271,6 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
261
271
  /** used to indicate whether the browser is currently waiting to reconnect */
262
272
  private isWaitingForNetworkReconnect: boolean = false;
263
273
 
264
- private bufferStatusLowClosingFuture = new Future<never, UnexpectedConnectionState>();
265
-
266
274
  constructor(private options: InternalRoomOptions) {
267
275
  super();
268
276
  this.log = getLogger(options.loggerName ?? LoggerNames.Engine, () => this.logContext);
@@ -296,13 +304,6 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
296
304
  this.client.onParticipantUpdate = (updates) =>
297
305
  this.emit(EngineEvent.ParticipantUpdate, updates);
298
306
  this.client.onJoined = (joinResponse) => this.emit(EngineEvent.Joined, joinResponse);
299
-
300
- this.on(EngineEvent.Closing, () => {
301
- this.bufferStatusLowClosingFuture.reject?.(new UnexpectedConnectionState('engine closed'));
302
- });
303
- // Swallow the rejection at the source so it doesn't surface as an unhandled promise rejection
304
- // when no waitForBufferStatusLow callers are attached.
305
- this.bufferStatusLowClosingFuture.promise.catch(() => {});
306
307
  }
307
308
 
308
309
  /** @internal */
@@ -457,6 +458,11 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
457
458
  }
458
459
 
459
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
+
460
466
  const dcCleanup = (dc: RTCDataChannel | undefined) => {
461
467
  if (!dc) {
462
468
  return;
@@ -884,6 +890,11 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
884
890
  return;
885
891
  }
886
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
+
887
898
  // clear old data channel callbacks if recreate
888
899
  if (this.lossyDC) {
889
900
  this.lossyDC.onmessage = null;
@@ -929,10 +940,10 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
929
940
  this.reliableDC.onclose = this.handleDataChannelClose(DataChannelKind.RELIABLE);
930
941
  this.dataTrackDC.onclose = this.handleDataChannelClose(DataChannelKind.DATA_TRACK_LOSSY);
931
942
 
932
- // set up dc buffer threshold, set to 64kB (otherwise 0 by default)
933
- this.lossyDC.bufferedAmountLowThreshold = 65535;
934
- this.reliableDC.bufferedAmountLowThreshold = 65535;
935
- 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;
936
947
 
937
948
  // handle buffer amount low events
938
949
  this.lossyDC.onbufferedamountlow = () => this.handleBufferedAmountLow(DataChannelKind.LOSSY);
@@ -951,8 +962,8 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
951
962
  // control buffered latency to ~100ms
952
963
  const threshold = this.lossyDataStatByterate / 10;
953
964
  dc.bufferedAmountLowThreshold = Math.min(
954
- Math.max(threshold, lossyDataChannelBufferThresholdMin),
955
- lossyDataChannelBufferThresholdMax,
965
+ Math.max(threshold, lossyDataChannelWaterMarkLow),
966
+ lossyDataChannelWaterMarkHigh,
956
967
  );
957
968
  }
958
969
  }, 1000);
@@ -1504,7 +1515,12 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
1504
1515
  }
1505
1516
 
1506
1517
  if (res?.lastMessageSeq) {
1507
- 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
+ });
1508
1524
  }
1509
1525
 
1510
1526
  // resume success
@@ -1620,21 +1636,43 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
1620
1636
  case DataChannelKind.DATA_TRACK_LOSSY:
1621
1637
  return this.sendLossyBytes(msg, kind);
1622
1638
 
1623
- 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
+
1624
1647
  const dc = this.dataChannelForKind(kind);
1625
1648
  if (dc) {
1626
- await this.waitForBufferStatusLow(kind);
1627
- 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
+ }
1628
1662
 
1629
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 });
1630
1666
  return;
1631
1667
  }
1632
1668
 
1669
+ this.reliableMessageBuffer.push({ data: msg, sequence: packet.sequence, sent: true });
1633
1670
  dc.send(msg);
1634
1671
  }
1635
1672
 
1636
1673
  this.updateAndEmitDCBufferStatus(kind);
1637
1674
  break;
1675
+ }
1638
1676
  }
1639
1677
  }
1640
1678
 
@@ -1642,42 +1680,62 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
1642
1680
  async sendLossyBytes(
1643
1681
  bytes: NonSharedUint8Array,
1644
1682
  kind: Exclude<DataChannelKind, DataChannelKind.RELIABLE>,
1645
- bufferStatusLowBehavior: 'drop' | 'wait' = 'drop',
1683
+ bufferStatusFullBehavior: 'drop' | 'wait' = 'drop',
1646
1684
  ) {
1647
- // 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.
1648
1690
  await this.ensurePublisherConnected(kind);
1649
1691
 
1650
1692
  const dc = this.dataChannelForKind(kind);
1651
- if (dc) {
1652
- if (!this.isBufferStatusLow(kind)) {
1693
+ try {
1694
+ if (dc) {
1653
1695
  // Depending on the exact circumstance that data is being sent, either drop or wait for the
1654
- // buffer status to not be low before continuing.
1655
- switch (bufferStatusLowBehavior) {
1696
+ // buffer to drain below the high-water mark before continuing.
1697
+ switch (bufferStatusFullBehavior) {
1656
1698
  case 'wait':
1657
- await this.waitForBufferStatusLow(kind);
1699
+ if (!this.isBelowHighWaterMark(kind)) {
1700
+ await this.waitForBufferHeadroomWithLock(kind);
1701
+ }
1658
1702
  break;
1659
1703
  case 'drop':
1660
- // this.log.warn(`dropping lossy data channel message`, this.logContext);
1661
- // Drop messages to reduce latency
1662
- this.lossyDataDropCount += 1;
1663
- if (this.lossyDataDropCount % 100 === 0) {
1664
- this.log.warn(
1665
- `dropping lossy data channel messages, total dropped: ${this.lossyDataDropCount}`,
1666
- );
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;
1667
1714
  }
1668
- return;
1669
1715
  }
1670
- }
1671
- 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
+ }
1672
1722
 
1673
- if (this.attemptingReconnect) {
1674
- return;
1723
+ if (this.attemptingReconnect) {
1724
+ return;
1725
+ }
1726
+
1727
+ dc.send(bytes);
1675
1728
  }
1676
1729
 
1677
- 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
+ }
1678
1738
  }
1679
-
1680
- this.updateAndEmitDCBufferStatus(kind);
1681
1739
  }
1682
1740
 
1683
1741
  private async resendReliableMessagesForResume(lastMessageSeq: number) {
@@ -1685,9 +1743,36 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
1685
1743
  const dc = this.dataChannelForKind(DataChannelKind.RELIABLE);
1686
1744
  if (dc) {
1687
1745
  this.reliableMessageBuffer.popToSequence(lastMessageSeq);
1688
- this.reliableMessageBuffer.getAll().forEach((msg) => {
1689
- dc.send(msg.data);
1690
- });
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
+ }
1691
1776
  }
1692
1777
  this.updateAndEmitDCBufferStatus(DataChannelKind.RELIABLE);
1693
1778
  }
@@ -1699,39 +1784,160 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
1699
1784
  this.reliableMessageBuffer.alignBufferedAmount(dc.bufferedAmount);
1700
1785
  }
1701
1786
  }
1702
-
1703
- const status = this.isBufferStatusLow(kind);
1704
- if (typeof status !== 'undefined' && status !== this.dcBufferStatus.get(kind)) {
1705
- this.dcBufferStatus.set(kind, status);
1706
- 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 });
1707
1795
  }
1708
1796
  };
1709
1797
 
1710
- 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> => {
1711
1803
  const dc = this.dataChannelForKind(kind);
1712
- if (dc) {
1713
- 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}`);
1714
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;
1715
1826
  };
1716
1827
 
1717
- async waitForBufferStatusLow(kind: DataChannelKind) {
1718
- return new TypedPromise<void, UnexpectedConnectionState>(async (resolve, reject) => {
1719
- if (this.isClosed) {
1720
- reject(new UnexpectedConnectionState('engine closed'));
1721
- }
1722
- 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();
1723
1910
  resolve();
1724
- } else {
1725
- const dc = this.dataChannelForKind(kind);
1726
- if (!dc) {
1727
- reject(new UnexpectedConnectionState(`DataChannel not found, kind: ${kind}`));
1728
- return;
1729
- }
1730
- this.bufferStatusLowClosingFuture.promise.catch((e) => reject(e));
1731
- dc.addEventListener('bufferedamountlow', () => resolve(), {
1732
- once: true,
1733
- });
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;
1734
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);
1735
1941
  });
1736
1942
  }
1737
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
@@ -46,6 +46,7 @@ import type {
46
46
  RoomConnectOptions,
47
47
  RoomOptions,
48
48
  } from '../options';
49
+ import type { NonSharedUint8Array } from '../type-polyfills/non-shared-typed-arrays';
49
50
  import TypedPromise from '../utils/TypedPromise';
50
51
  import { getBrowser } from '../utils/browserParser';
51
52
  import { CLIENT_PROTOCOL_DEFAULT } from '../version';
@@ -1382,7 +1383,13 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
1382
1383
  await Promise.all([
1383
1384
  this.acquireAudioContext(),
1384
1385
  ...elements.map((e) => {
1385
- 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
+ }
1386
1393
  return e.play();
1387
1394
  }),
1388
1395
  ]);
@@ -1,5 +1,6 @@
1
1
  import { type Throws } from '@livekit/throws-transformer/throws';
2
2
  import { LoggerNames, getLogger } from '../../logger';
3
+ import type { NonSharedUint8Array } from '../../type-polyfills/non-shared-typed-arrays';
3
4
  import { LivekitReasonedError } from '../errors';
4
5
  import { type DataTrackFrameInternal } from './frame';
5
6
  import { DataTrackPacket, FrameMarker } from './packet';
@@ -1,3 +1,4 @@
1
+ import type { NonSharedUint8Array } from '../../type-polyfills/non-shared-typed-arrays';
1
2
  import { DataTrackExtensions, DataTrackUserTimestampExtension } from './packet/extensions';
2
3
 
3
4
  /** A pair of payload bytes and packet extensions which can be fed into a {@link DataTrackPacketizer}. */