livekit-client 2.20.2 → 2.22.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 (142) hide show
  1. package/README.md +10 -10
  2. package/dist/livekit-client.e2ee.worker.js +1 -1
  3. package/dist/livekit-client.e2ee.worker.js.map +1 -1
  4. package/dist/livekit-client.e2ee.worker.mjs +6 -1
  5. package/dist/livekit-client.e2ee.worker.mjs.map +1 -1
  6. package/dist/livekit-client.esm.mjs +2588 -1056
  7. package/dist/livekit-client.esm.mjs.map +1 -1
  8. package/dist/livekit-client.fm.worker.js +1 -1
  9. package/dist/livekit-client.fm.worker.js.map +1 -1
  10. package/dist/livekit-client.fm.worker.mjs +6 -1
  11. package/dist/livekit-client.fm.worker.mjs.map +1 -1
  12. package/dist/livekit-client.umd.js +1 -1
  13. package/dist/livekit-client.umd.js.map +1 -1
  14. package/dist/src/index.d.ts +1 -1
  15. package/dist/src/index.d.ts.map +1 -1
  16. package/dist/src/room/PCTransport.d.ts +49 -2
  17. package/dist/src/room/PCTransport.d.ts.map +1 -1
  18. package/dist/src/room/RTCEngine.d.ts +48 -65
  19. package/dist/src/room/RTCEngine.d.ts.map +1 -1
  20. package/dist/src/room/Room.d.ts +6 -0
  21. package/dist/src/room/Room.d.ts.map +1 -1
  22. package/dist/src/room/data-channel/DataChannelManager.d.ts +62 -0
  23. package/dist/src/room/data-channel/DataChannelManager.d.ts.map +1 -0
  24. package/dist/src/room/data-channel/FlowControlledDataChannel.d.ts +95 -0
  25. package/dist/src/room/data-channel/FlowControlledDataChannel.d.ts.map +1 -0
  26. package/dist/src/room/data-channel/LossyDataChannel.d.ts +40 -0
  27. package/dist/src/room/data-channel/LossyDataChannel.d.ts.map +1 -0
  28. package/dist/src/room/data-channel/ReliableDataChannel.d.ts +56 -0
  29. package/dist/src/room/data-channel/ReliableDataChannel.d.ts.map +1 -0
  30. package/dist/src/room/data-channel/types.d.ts +12 -0
  31. package/dist/src/room/data-channel/types.d.ts.map +1 -0
  32. package/dist/src/room/data-stream/compression.d.ts +49 -0
  33. package/dist/src/room/data-stream/compression.d.ts.map +1 -0
  34. package/dist/src/room/data-stream/constants.d.ts +18 -0
  35. package/dist/src/room/data-stream/constants.d.ts.map +1 -0
  36. package/dist/src/room/data-stream/incoming/IncomingDataStreamManager.d.ts +4 -0
  37. package/dist/src/room/data-stream/incoming/IncomingDataStreamManager.d.ts.map +1 -1
  38. package/dist/src/room/data-stream/incoming/StreamReader.d.ts.map +1 -1
  39. package/dist/src/room/data-stream/outgoing/OutgoingDataStreamManager.d.ts +49 -2
  40. package/dist/src/room/data-stream/outgoing/OutgoingDataStreamManager.d.ts.map +1 -1
  41. package/dist/src/room/data-stream/outgoing/header-utils.d.ts +18 -0
  42. package/dist/src/room/data-stream/outgoing/header-utils.d.ts.map +1 -0
  43. package/dist/src/room/errors.d.ts +3 -1
  44. package/dist/src/room/errors.d.ts.map +1 -1
  45. package/dist/src/room/participant/LocalParticipant.d.ts +10 -2
  46. package/dist/src/room/participant/LocalParticipant.d.ts.map +1 -1
  47. package/dist/src/room/participant/RemoteParticipant.d.ts +11 -2
  48. package/dist/src/room/participant/RemoteParticipant.d.ts.map +1 -1
  49. package/dist/src/room/rpc/client/RpcClientManager.d.ts.map +1 -1
  50. package/dist/src/room/rpc/server/RpcServerManager.d.ts.map +1 -1
  51. package/dist/src/room/token-source/TokenSource.d.ts +19 -10
  52. package/dist/src/room/token-source/TokenSource.d.ts.map +1 -1
  53. package/dist/src/room/track/LocalVideoTrack.d.ts +12 -1
  54. package/dist/src/room/track/LocalVideoTrack.d.ts.map +1 -1
  55. package/dist/src/room/track/RemoteTrack.d.ts +3 -0
  56. package/dist/src/room/track/RemoteTrack.d.ts.map +1 -1
  57. package/dist/src/room/track/Track.d.ts.map +1 -1
  58. package/dist/src/room/types.d.ts +17 -0
  59. package/dist/src/room/types.d.ts.map +1 -1
  60. package/dist/src/room/utils.d.ts +28 -0
  61. package/dist/src/room/utils.d.ts.map +1 -1
  62. package/dist/src/test/promiseState.d.ts +12 -0
  63. package/dist/src/test/promiseState.d.ts.map +1 -0
  64. package/dist/src/test/signalToken.d.ts.map +1 -1
  65. package/dist/src/utils/AsyncQueue.d.ts +3 -3
  66. package/dist/src/utils/AsyncQueue.d.ts.map +1 -1
  67. package/dist/src/version.d.ts +5 -1
  68. package/dist/src/version.d.ts.map +1 -1
  69. package/dist/ts4.2/index.d.ts +1 -1
  70. package/dist/ts4.2/room/PCTransport.d.ts +49 -2
  71. package/dist/ts4.2/room/RTCEngine.d.ts +48 -65
  72. package/dist/ts4.2/room/Room.d.ts +6 -0
  73. package/dist/ts4.2/room/data-channel/DataChannelManager.d.ts +62 -0
  74. package/dist/ts4.2/room/data-channel/FlowControlledDataChannel.d.ts +95 -0
  75. package/dist/ts4.2/room/data-channel/LossyDataChannel.d.ts +41 -0
  76. package/dist/ts4.2/room/data-channel/ReliableDataChannel.d.ts +57 -0
  77. package/dist/ts4.2/room/data-channel/types.d.ts +12 -0
  78. package/dist/ts4.2/room/data-stream/compression.d.ts +49 -0
  79. package/dist/ts4.2/room/data-stream/constants.d.ts +18 -0
  80. package/dist/ts4.2/room/data-stream/incoming/IncomingDataStreamManager.d.ts +4 -0
  81. package/dist/ts4.2/room/data-stream/outgoing/OutgoingDataStreamManager.d.ts +49 -2
  82. package/dist/ts4.2/room/data-stream/outgoing/header-utils.d.ts +18 -0
  83. package/dist/ts4.2/room/errors.d.ts +3 -1
  84. package/dist/ts4.2/room/participant/LocalParticipant.d.ts +10 -2
  85. package/dist/ts4.2/room/participant/RemoteParticipant.d.ts +11 -1
  86. package/dist/ts4.2/room/token-source/TokenSource.d.ts +17 -8
  87. package/dist/ts4.2/room/track/LocalVideoTrack.d.ts +12 -1
  88. package/dist/ts4.2/room/track/RemoteTrack.d.ts +3 -0
  89. package/dist/ts4.2/room/types.d.ts +17 -0
  90. package/dist/ts4.2/room/utils.d.ts +28 -0
  91. package/dist/ts4.2/test/promiseState.d.ts +12 -0
  92. package/dist/ts4.2/utils/AsyncQueue.d.ts +3 -3
  93. package/dist/ts4.2/version.d.ts +5 -1
  94. package/package.json +14 -13
  95. package/src/api/SignalClient.e2e.test.ts +16 -9
  96. package/src/e2ee/utils.ts +1 -1
  97. package/src/index.ts +2 -0
  98. package/src/room/PCTransport.test.ts +243 -1
  99. package/src/room/PCTransport.ts +181 -80
  100. package/src/room/RTCEngine.test.ts +397 -101
  101. package/src/room/RTCEngine.ts +244 -545
  102. package/src/room/Room.test.ts +138 -4
  103. package/src/room/Room.ts +92 -25
  104. package/src/room/data-channel/DataChannelManager.test.ts +152 -0
  105. package/src/room/data-channel/DataChannelManager.ts +237 -0
  106. package/src/room/data-channel/FlowControlledDataChannel.test.ts +154 -0
  107. package/src/room/data-channel/FlowControlledDataChannel.ts +216 -0
  108. package/src/room/data-channel/LossyDataChannel.test.ts +118 -0
  109. package/src/room/data-channel/LossyDataChannel.ts +125 -0
  110. package/src/room/data-channel/ReliableDataChannel.test.ts +158 -0
  111. package/src/room/data-channel/ReliableDataChannel.ts +153 -0
  112. package/src/room/data-channel/types.ts +30 -0
  113. package/src/room/data-stream/compression.ts +117 -0
  114. package/src/room/data-stream/constants.ts +18 -0
  115. package/src/room/data-stream/incoming/IncomingDataStreamManager.test.ts +1601 -0
  116. package/src/room/data-stream/incoming/IncomingDataStreamManager.ts +438 -86
  117. package/src/room/data-stream/incoming/StreamReader.ts +9 -3
  118. package/src/room/data-stream/outgoing/OutgoingDataStreamManager.test.ts +1292 -0
  119. package/src/room/data-stream/outgoing/OutgoingDataStreamManager.ts +489 -116
  120. package/src/room/data-stream/outgoing/header-utils.ts +87 -0
  121. package/src/room/errors.ts +7 -2
  122. package/src/room/participant/LocalParticipant.ts +13 -0
  123. package/src/room/participant/RemoteParticipant.ts +19 -5
  124. package/src/room/rpc/client/RpcClientManager.test.ts +20 -27
  125. package/src/room/rpc/client/RpcClientManager.ts +1 -4
  126. package/src/room/rpc/server/RpcServerManager.test.ts +24 -23
  127. package/src/room/rpc/server/RpcServerManager.ts +1 -3
  128. package/src/room/token-source/TokenSource.ts +25 -12
  129. package/src/room/track/LocalVideoTrack.test.ts +105 -2
  130. package/src/room/track/LocalVideoTrack.ts +36 -10
  131. package/src/room/track/RemoteTrack.test.ts +144 -0
  132. package/src/room/track/RemoteTrack.ts +39 -12
  133. package/src/room/track/Track.ts +2 -1
  134. package/src/room/types.ts +18 -0
  135. package/src/room/utils.test.ts +71 -2
  136. package/src/room/utils.ts +119 -0
  137. package/src/test/promiseState.ts +23 -0
  138. package/src/test/signalServerSetup.ts +2 -1
  139. package/src/test/signalToken.ts +17 -13
  140. package/src/type-polyfills/header-extensions.d.ts +13 -0
  141. package/src/utils/AsyncQueue.ts +3 -3
  142. package/src/version.ts +5 -1
@@ -18,6 +18,7 @@ import {
18
18
  LeaveRequest_Action,
19
19
  MediaSectionsRequirement,
20
20
  ParticipantInfo,
21
+ ConnectionQuality as ProtoConnectionQuality,
21
22
  PublishDataTrackResponse,
22
23
  ReconnectReason,
23
24
  type ReconnectResponse,
@@ -44,7 +45,6 @@ import {
44
45
  type UserPacket,
45
46
  } from '@livekit/protocol';
46
47
  import { EventEmitter } from 'events';
47
- import type { Throws } from '@livekit/throws-transformer/throws';
48
48
  import type { MediaAttributes } from 'sdp-transform';
49
49
  import type TypedEventEmitter from 'typed-emitter';
50
50
  import type { SignalOptions } from '../api/SignalClient';
@@ -64,11 +64,15 @@ import log, { LoggerNames, getLogger } from '../logger';
64
64
  import type { InternalRoomOptions } from '../options';
65
65
  import type { NonSharedUint8Array } from '../type-polyfills/non-shared-typed-arrays';
66
66
  import TypedPromise from '../utils/TypedPromise';
67
- import { DataPacketBuffer } from '../utils/dataPacketBuffer';
68
67
  import { TTLMap } from '../utils/ttlmap';
69
68
  import PCTransport, { PCEvents } from './PCTransport';
70
69
  import { PCTransportManager, PCTransportState } from './PCTransportManager';
71
70
  import type { ReconnectContext, ReconnectPolicy } from './ReconnectPolicy';
71
+ import { DataChannelManager } from './data-channel/DataChannelManager';
72
+ import type { FlowControlledDataChannel } from './data-channel/FlowControlledDataChannel';
73
+ import type { LossyDataChannel } from './data-channel/LossyDataChannel';
74
+ import type { ReliableDataChannel } from './data-channel/ReliableDataChannel';
75
+ import { DataChannelKind } from './data-channel/types';
72
76
  import { DataTrackInfo } from './data-track/types';
73
77
  import { roomConnectOptionDefaults } from './defaults';
74
78
  import {
@@ -97,29 +101,22 @@ import {
97
101
  isVideoCodec,
98
102
  isVideoTrack,
99
103
  isWeb,
104
+ negotiateDependencyDescriptor,
100
105
  sleep,
101
106
  supportsAddTrack,
102
107
  supportsTransceiver,
103
108
  toHttpUrl,
104
109
  } from './utils';
105
110
 
106
- const lossyDataChannel = '_lossy';
107
- const reliableDataChannel = '_reliable';
108
- const dataTrackDataChannel = '_data_track';
109
111
  const minReconnectWait = 2 * 1000;
110
112
  const leaveReconnect = 'leave-reconnect';
111
- const reliabeReceiveStateTTL = 30_000;
112
113
 
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
+ /**
115
+ * How long local connection quality must stay `LOST` while connected and publishing before we
116
+ * force a full reconnect `LOST` is the server's verdict that it isn't receiving our media.
117
+ */
118
+ const connectionQualityLostTimeout = 10 * 1000;
119
+ const reliabeReceiveStateTTL = 30_000;
123
120
 
124
121
  const initialMediaSectionsAudio = 3;
125
122
  const initialMediaSectionsVideo = 3;
@@ -132,11 +129,7 @@ enum PCState {
132
129
  Closed,
133
130
  }
134
131
 
135
- export enum DataChannelKind {
136
- RELIABLE = DataPacket_Kind.RELIABLE,
137
- LOSSY = DataPacket_Kind.LOSSY,
138
- DATA_TRACK_LOSSY = 2,
139
- }
132
+ export { DataChannelKind };
140
133
 
141
134
  // Default data-channel max message size (bytes), used when the remote SDP
142
135
  // answer does not advertise an `a=max-message-size` attribute (RFC 8841).
@@ -180,22 +173,24 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
180
173
  return !!this.reconnectTimeout;
181
174
  }
182
175
 
183
- private lossyDC?: RTCDataChannel;
184
-
185
- // @ts-ignore noUnusedLocals
186
- private lossyDCSub?: RTCDataChannel;
187
-
188
- private reliableDC?: RTCDataChannel;
189
-
190
- // @ts-ignore noUnusedLocals
191
- private reliableDCSub?: RTCDataChannel;
176
+ /**
177
+ * Owns the data channels: the three flow-controlled publisher wrappers (engine-lifetime; the
178
+ * RTCDataChannel handles underneath are attached/detached as peer connections come and go, with
179
+ * waiter invalidation built into the turnover) plus the subscriber-side receive handles.
180
+ */
181
+ private dataChannels: DataChannelManager;
192
182
 
193
- private dataTrackDC?: RTCDataChannel;
183
+ private get reliableChannel(): ReliableDataChannel {
184
+ return this.dataChannels.reliable;
185
+ }
194
186
 
195
- // @ts-ignore noUnusedLocals
196
- private dataTrackDCSub?: RTCDataChannel;
187
+ private get lossyChannel(): LossyDataChannel {
188
+ return this.dataChannels.lossy;
189
+ }
197
190
 
198
- private dcBufferStatus: Map<DataChannelKind, boolean>;
191
+ private get dataTrackChannel(): LossyDataChannel {
192
+ return this.dataChannels.dataTrack;
193
+ }
199
194
 
200
195
  private subscriberPrimary: boolean = false;
201
196
 
@@ -252,25 +247,19 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
252
247
 
253
248
  private publisherConnectionPromise: Promise<void> | undefined;
254
249
 
255
- private reliableDataSequence: number = 1;
256
-
257
- private reliableMessageBuffer = new DataPacketBuffer();
258
-
259
250
  private reliableReceivedState: TTLMap<string, number> = new TTLMap(reliabeReceiveStateTTL);
260
251
 
261
- private lossyDataStatCurrentBytes: number = 0;
262
-
263
- private lossyDataStatByterate: number = 0;
264
-
265
- private lossyDataStatInterval: ReturnType<typeof setInterval> | undefined;
266
-
267
- private lossyDataDropCount: number = 0;
268
-
269
252
  private midToTrackId: { [key: string]: string } = {};
270
253
 
271
254
  /** used to indicate whether the browser is currently waiting to reconnect */
272
255
  private isWaitingForNetworkReconnect: boolean = false;
273
256
 
257
+ /** set while the local participant's connection quality is `LOST`; forces a full reconnect on timeout */
258
+ private lostQualityTimeout?: ReturnType<typeof setTimeout>;
259
+
260
+ /** timestamp (ms) the primary transport entered `CONNECTING`, used to bound how long we tolerate it */
261
+ private transportConnectingSince?: number;
262
+
274
263
  constructor(private options: InternalRoomOptions) {
275
264
  super();
276
265
  this.log = getLogger(options.loggerName ?? LoggerNames.Engine, () => this.logContext);
@@ -283,16 +272,23 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
283
272
  this.reconnectPolicy = this.options.reconnectPolicy;
284
273
  this.closingLock = new Mutex();
285
274
  this.dataProcessLock = new Mutex();
286
- this.dcBufferStatus = new Map([
287
- [DataChannelKind.RELIABLE, true],
288
- [DataChannelKind.LOSSY, true],
289
- [DataChannelKind.DATA_TRACK_LOSSY, true],
290
- ]);
275
+ this.dataChannels = new DataChannelManager({
276
+ isEngineClosed: () => this.isClosed,
277
+ isReconnecting: () => this.attemptingReconnect,
278
+ onDataMessage: (message) => this.handleDataMessage(message),
279
+ onDataTrackMessage: (message) => this.handleDataTrackMessage(message),
280
+ onDataError: (event) => this.handleDataError(event),
281
+ onChannelClose: (kind) => this.handleDataChannelClose(kind)(),
282
+ onBufferStatusChanged: (kind, isLow) =>
283
+ this.emit(EngineEvent.DCBufferStatusChanged, isLow, kind),
284
+ });
291
285
 
292
286
  this.client.onParticipantUpdate = (updates) =>
293
287
  this.emit(EngineEvent.ParticipantUpdate, updates);
294
- this.client.onConnectionQuality = (update) =>
288
+ this.client.onConnectionQuality = (update) => {
289
+ this.handleLocalConnectionQuality(update);
295
290
  this.emit(EngineEvent.ConnectionQualityUpdate, update);
291
+ };
296
292
  this.client.onRoomUpdate = (update) => this.emit(EngineEvent.RoomUpdate, update);
297
293
  this.client.onSubscriptionError = (resp) => this.emit(EngineEvent.SubscriptionError, resp);
298
294
  this.client.onSubscriptionPermissionUpdate = (update) =>
@@ -449,6 +445,7 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
449
445
  this.removeAllListeners();
450
446
  this.deregisterOnLineListener();
451
447
  this.clearPendingReconnect();
448
+ this.clearLostQualityTimeout();
452
449
  this.cleanupLossyDataStats();
453
450
  await this.cleanupPeerConnections();
454
451
  await this.cleanupClient();
@@ -458,60 +455,18 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
458
455
  }
459
456
 
460
457
  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
-
466
- const dcCleanup = (dc: RTCDataChannel | undefined) => {
467
- if (!dc) {
468
- return;
469
- }
470
-
471
- // Detach the data channel handlers before closing anything. Closing a peer connection tears
472
- // down the SCTP transport, which can dispatch `error`/`close` events on the still-open data
473
- // channels; if our handlers are still attached at that point, handleDataError logs a spurious
474
- // "Unknown DataChannel error" during an otherwise graceful disconnect. Removing the handlers
475
- // before dc.close()/pcManager.close() makes this deterministic regardless of how/when the
476
- // browser dispatches those teardown events. See livekit/client-sdk-js#1953.
477
- dc.onbufferedamountlow = null;
478
- dc.onclose = null;
479
- dc.onclosing = null;
480
- dc.onerror = null;
481
- dc.onmessage = null;
482
- dc.onopen = null;
483
-
484
- dc.close();
485
- };
486
- dcCleanup(this.lossyDC);
487
- dcCleanup(this.lossyDCSub);
488
- dcCleanup(this.reliableDC);
489
- dcCleanup(this.reliableDCSub);
490
- dcCleanup(this.dataTrackDC);
491
- dcCleanup(this.dataTrackDCSub);
458
+ this.dataChannels.teardown();
492
459
 
493
460
  await this.pcManager?.close();
494
461
  this.pcManager = undefined;
462
+ // the connecting timestamp belongs to the transports we just tore down
463
+ this.transportConnectingSince = undefined;
495
464
 
496
- this.lossyDC = undefined;
497
- this.lossyDCSub = undefined;
498
- this.reliableDC = undefined;
499
- this.reliableDCSub = undefined;
500
- this.dataTrackDC = undefined;
501
- this.dataTrackDCSub = undefined;
502
- this.reliableMessageBuffer = new DataPacketBuffer();
503
- this.reliableDataSequence = 1;
504
465
  this.reliableReceivedState.clear();
505
466
  }
506
467
 
507
468
  cleanupLossyDataStats() {
508
- this.lossyDataStatByterate = 0;
509
- this.lossyDataStatCurrentBytes = 0;
510
- if (this.lossyDataStatInterval) {
511
- clearInterval(this.lossyDataStatInterval);
512
- this.lossyDataStatInterval = undefined;
513
- }
514
- this.lossyDataDropCount = 0;
469
+ this.lossyChannel.stopThresholdTuning();
515
470
  }
516
471
 
517
472
  async cleanupClient() {
@@ -532,7 +487,7 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
532
487
  throw new TrackInvalidError('a track with the same ID has already been published');
533
488
  }
534
489
  return new Promise<TrackInfo>((resolve, reject) => {
535
- const publicationTimeout = setTimeout(() => {
490
+ const publicationTimeout = CriticalTimers.setTimeout(() => {
536
491
  delete this.pendingTrackResolvers[req.cid];
537
492
  reject(
538
493
  ConnectionError.timeout('publication of local track timed out, no response from server'),
@@ -540,11 +495,11 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
540
495
  }, 10_000);
541
496
  this.pendingTrackResolvers[req.cid] = {
542
497
  resolve: (info: TrackInfo) => {
543
- clearTimeout(publicationTimeout);
498
+ CriticalTimers.clearTimeout(publicationTimeout);
544
499
  resolve(info);
545
500
  },
546
501
  reject: () => {
547
- clearTimeout(publicationTimeout);
502
+ CriticalTimers.clearTimeout(publicationTimeout);
548
503
  reject(new Error('Cancelled publication by calling unpublish'));
549
504
  },
550
505
  };
@@ -580,7 +535,7 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
580
535
  }
581
536
 
582
537
  get dataSubscriberReadyState(): string | undefined {
583
- return this.reliableDCSub?.readyState;
538
+ return this.dataChannelForKind(DataChannelKind.RELIABLE, true)?.readyState;
584
539
  }
585
540
 
586
541
  async getConnectedServerAddress(): Promise<string | undefined> {
@@ -629,6 +584,16 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
629
584
  this.pcManager.onStateChange = async (connectionState, publisherState, subscriberState) => {
630
585
  this.log.debug(`primary PC state changed ${connectionState}`);
631
586
 
587
+ // Record when the primary transport actually entered CONNECTING so
588
+ // verifyTransport() can bound how long we tolerate it. Deriving it from the
589
+ // real transition (this handler only fires on state changes) rather than from
590
+ // observation time keeps it from going stale across peer-connection rebuilds.
591
+ if (connectionState === PCTransportState.CONNECTING) {
592
+ this.transportConnectingSince = Date.now();
593
+ } else {
594
+ this.transportConnectingSince = undefined;
595
+ }
596
+
632
597
  if (['closed', 'disconnected', 'failed'].includes(publisherState)) {
633
598
  // reset publisher connection promise
634
599
  this.publisherConnectionPromise = undefined;
@@ -880,8 +845,15 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
880
845
  for (let i: number = 0; i < numAudios; i++) {
881
846
  this.pcManager?.addPublisherTransceiverOfKind('audio', transceiverInit);
882
847
  }
848
+ // media only arrives on these sections when there is no subscriber connection to arrive on
849
+ const receivesMedia = this.pcManager?.mode === 'publisher-only';
883
850
  for (let i: number = 0; i < numVideos; i++) {
884
- this.pcManager?.addPublisherTransceiverOfKind('video', transceiverInit);
851
+ const transceiver = this.pcManager?.addPublisherTransceiverOfKind('video', transceiverInit);
852
+ if (receivesMedia && transceiver) {
853
+ const negotiated = negotiateDependencyDescriptor(transceiver);
854
+
855
+ this.log.debug('dependency descriptor negotiated for received video', { negotiated });
856
+ }
885
857
  }
886
858
  }
887
859
 
@@ -890,121 +862,39 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
890
862
  return;
891
863
  }
892
864
 
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
-
898
- // clear old data channel callbacks if recreate
899
- if (this.lossyDC) {
900
- this.lossyDC.onmessage = null;
901
- this.lossyDC.onerror = null;
902
- this.lossyDC.onclose = null;
903
- }
904
- if (this.reliableDC) {
905
- this.reliableDC.onmessage = null;
906
- this.reliableDC.onerror = null;
907
- this.reliableDC.onclose = null;
908
- }
909
- if (this.dataTrackDC) {
910
- this.dataTrackDC.onmessage = null;
911
- this.dataTrackDC.onerror = null;
912
- this.dataTrackDC.onclose = null;
913
- }
914
-
915
- // create data channels
916
- this.lossyDC = this.pcManager.createPublisherDataChannel(lossyDataChannel, {
917
- ordered: false,
918
- maxRetransmits: 0,
919
- });
920
- this.reliableDC = this.pcManager.createPublisherDataChannel(reliableDataChannel, {
921
- ordered: true,
922
- });
923
- this.dataTrackDC = this.pcManager.createPublisherDataChannel(dataTrackDataChannel, {
924
- ordered: false,
925
- maxRetransmits: 0,
926
- });
927
-
928
- // also handle messages over the pub channel, for backwards compatibility
929
- this.lossyDC.onmessage = this.handleDataMessage;
930
- this.reliableDC.onmessage = this.handleDataMessage;
931
- this.dataTrackDC.onmessage = this.handleDataTrackMessage;
932
-
933
- // handle datachannel errors
934
- this.lossyDC.onerror = this.handleDataError;
935
- this.reliableDC.onerror = this.handleDataError;
936
- this.dataTrackDC.onerror = this.handleDataError;
937
-
938
- // detect unexpected publisher data channel closes
939
- this.lossyDC.onclose = this.handleDataChannelClose(DataChannelKind.LOSSY);
940
- this.reliableDC.onclose = this.handleDataChannelClose(DataChannelKind.RELIABLE);
941
- this.dataTrackDC.onclose = this.handleDataChannelClose(DataChannelKind.DATA_TRACK_LOSSY);
942
-
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;
947
-
948
- // handle buffer amount low events
949
- this.lossyDC.onbufferedamountlow = () => this.handleBufferedAmountLow(DataChannelKind.LOSSY);
950
- this.reliableDC.onbufferedamountlow = () =>
951
- this.handleBufferedAmountLow(DataChannelKind.RELIABLE);
952
- this.dataTrackDC.onbufferedamountlow = () =>
953
- this.handleBufferedAmountLow(DataChannelKind.DATA_TRACK_LOSSY);
954
-
955
- this.cleanupLossyDataStats();
956
- this.lossyDataStatInterval = setInterval(() => {
957
- this.lossyDataStatByterate = this.lossyDataStatCurrentBytes;
958
- this.lossyDataStatCurrentBytes = 0;
959
-
960
- const dc = this.dataChannelForKind(DataChannelKind.LOSSY);
961
- if (dc) {
962
- // control buffered latency to ~100ms
963
- const threshold = this.lossyDataStatByterate / 10;
964
- dc.bufferedAmountLowThreshold = Math.min(
965
- Math.max(threshold, lossyDataChannelWaterMarkLow),
966
- lossyDataChannelWaterMarkHigh,
967
- );
968
- }
969
- }, 1000);
865
+ this.dataChannels.createPublisherChannels(this.pcManager);
970
866
  }
971
867
 
972
868
  private handleDataChannel = async ({ channel }: RTCDataChannelEvent) => {
973
869
  if (!channel) {
974
870
  return;
975
871
  }
976
- let handler;
977
- if (channel.label === reliableDataChannel) {
978
- this.reliableDCSub = channel;
979
- handler = this.handleDataMessage;
980
- } else if (channel.label === lossyDataChannel) {
981
- this.lossyDCSub = channel;
982
- handler = this.handleDataMessage;
983
- } else if (channel.label === dataTrackDataChannel) {
984
- this.dataTrackDCSub = channel;
985
- handler = this.handleDataTrackMessage;
986
- } else {
987
- return;
872
+ if (this.dataChannels.adoptSubscriberChannel(channel)) {
873
+ this.log.debug(`on data channel ${channel.id}, ${channel.label}`);
988
874
  }
989
- this.log.debug(`on data channel ${channel.id}, ${channel.label}`);
990
- channel.onmessage = handler;
991
875
  };
992
876
 
877
+ /** Normalizes an incoming data-channel message into bytes, or logs and returns undefined. */
878
+ private async decodeDataMessage(message: MessageEvent): Promise<Uint8Array | undefined> {
879
+ if (message.data instanceof ArrayBuffer) {
880
+ return new Uint8Array(message.data);
881
+ }
882
+ if (message.data instanceof Blob) {
883
+ return new Uint8Array(await message.data.arrayBuffer());
884
+ }
885
+ this.log.error('unsupported data type', { data: message.data });
886
+ return undefined;
887
+ }
888
+
993
889
  private handleDataMessage = async (message: MessageEvent) => {
994
890
  // make sure to respect incoming data message order by processing message events one after the other
995
891
  const unlock = await this.dataProcessLock.lock();
996
892
  try {
997
- // decode
998
- let buffer: ArrayBuffer | undefined;
999
- if (message.data instanceof ArrayBuffer) {
1000
- buffer = message.data;
1001
- } else if (message.data instanceof Blob) {
1002
- buffer = await message.data.arrayBuffer();
1003
- } else {
1004
- this.log.error('unsupported data type', { data: message.data });
893
+ const bytes = await this.decodeDataMessage(message);
894
+ if (!bytes) {
1005
895
  return;
1006
896
  }
1007
- const dp = DataPacket.fromBinary(new Uint8Array(buffer));
897
+ const dp = DataPacket.fromBinary(bytes);
1008
898
 
1009
899
  if (dp.sequence > 0 && dp.participantSid !== '') {
1010
900
  const lastSeq = this.reliableReceivedState.get(dp.participantSid);
@@ -1053,18 +943,11 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
1053
943
  };
1054
944
 
1055
945
  private handleDataTrackMessage = async (message: MessageEvent) => {
1056
- // Decode / normalize into a common format
1057
- let buffer: ArrayBuffer | undefined;
1058
- if (message.data instanceof ArrayBuffer) {
1059
- buffer = message.data;
1060
- } else if (message.data instanceof Blob) {
1061
- buffer = await message.data.arrayBuffer();
1062
- } else {
1063
- this.log.error('unsupported data type', { data: message.data });
946
+ const bytes = await this.decodeDataMessage(message);
947
+ if (!bytes) {
1064
948
  return;
1065
949
  }
1066
-
1067
- this.emit('dataTrackPacketReceived', new Uint8Array(buffer));
950
+ this.emit('dataTrackPacketReceived', bytes);
1068
951
  };
1069
952
 
1070
953
  private handleDataError = (event: Event) => {
@@ -1103,10 +986,6 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
1103
986
  }
1104
987
  };
1105
988
 
1106
- private handleBufferedAmountLow = (channelKind: DataChannelKind) => {
1107
- this.updateAndEmitDCBufferStatus(channelKind);
1108
- };
1109
-
1110
989
  async createSender(
1111
990
  track: LocalTrack,
1112
991
  opts: TrackPublishOptions,
@@ -1252,7 +1131,7 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
1252
1131
  if (!opts.videoCodec) {
1253
1132
  return;
1254
1133
  }
1255
- track.setSimulcastTrackSender(opts.videoCodec, transceiver.sender);
1134
+ await track.setSimulcastTrackSender(opts.videoCodec, transceiver.sender);
1256
1135
  return transceiver.sender;
1257
1136
  }
1258
1137
 
@@ -1314,6 +1193,73 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
1314
1193
  );
1315
1194
  };
1316
1195
 
1196
+ /**
1197
+ * A sustained local `LOST` while connected and publishing means the server isn't receiving
1198
+ * our media, so force a full reconnect; any non-`LOST` value cancels a pending trigger.
1199
+ */
1200
+ private handleLocalConnectionQuality(update: ConnectionQualityUpdate) {
1201
+ if (!this.participantSid) {
1202
+ return;
1203
+ }
1204
+ const localUpdate = update.updates.find((u) => u.participantSid === this.participantSid);
1205
+ if (!localUpdate) {
1206
+ return;
1207
+ }
1208
+ if (localUpdate.quality === ProtoConnectionQuality.LOST) {
1209
+ this.scheduleLostQualityReconnect();
1210
+ } else {
1211
+ this.clearLostQualityTimeout();
1212
+ }
1213
+ }
1214
+
1215
+ private scheduleLostQualityReconnect() {
1216
+ if (this.lostQualityTimeout) {
1217
+ // already counting down towards a reconnect
1218
+ return;
1219
+ }
1220
+ this.lostQualityTimeout = CriticalTimers.setTimeout(() => {
1221
+ this.lostQualityTimeout = undefined;
1222
+ if (this._isClosed || this.pcState !== PCState.Connected || this.attemptingReconnect) {
1223
+ return;
1224
+ }
1225
+ if (!this.hasActivePublisherSenders()) {
1226
+ return;
1227
+ }
1228
+ this.log.warn(
1229
+ 'local connection quality lost while publishing, triggering full reconnect',
1230
+ this.logContext,
1231
+ );
1232
+ this.fullReconnectOnNext = true;
1233
+ this.handleDisconnect('connection quality lost', ReconnectReason.RR_PUBLISHER_FAILED);
1234
+ }, connectionQualityLostTimeout);
1235
+ }
1236
+
1237
+ private clearLostQualityTimeout() {
1238
+ if (this.lostQualityTimeout) {
1239
+ CriticalTimers.clearTimeout(this.lostQualityTimeout);
1240
+ this.lostQualityTimeout = undefined;
1241
+ }
1242
+ }
1243
+
1244
+ /** Whether the publisher currently has any sender with a live track. */
1245
+ private hasActivePublisherSenders(): boolean {
1246
+ return (
1247
+ this.pcManager?.publisher
1248
+ .getSenders()
1249
+ .some((sender) => !!sender.track && sender.track.readyState === 'live') ?? false
1250
+ );
1251
+ }
1252
+
1253
+ /**
1254
+ * Forces a full reconnect while keeping the engine (and its saved credentials) alive. Used by
1255
+ * Room's connection-reconcile safety net when the transport silently died but we looked connected.
1256
+ * @internal
1257
+ */
1258
+ reconnect(reason: ReconnectReason = ReconnectReason.RR_UNKNOWN) {
1259
+ this.fullReconnectOnNext = true;
1260
+ this.handleDisconnect('reconcile', reason);
1261
+ }
1262
+
1317
1263
  private async attemptReconnect(reason?: ReconnectReason) {
1318
1264
  if (this._isClosed) {
1319
1265
  return;
@@ -1323,6 +1269,12 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
1323
1269
  this.log.warn('already attempting reconnect, returning early');
1324
1270
  return;
1325
1271
  }
1272
+
1273
+ // A pending Lost-quality countdown belongs to the session we're now leaving; cancel it so
1274
+ // it can't fire against the reconnected session before the server has evaluated it. (A resume
1275
+ // keeps the peer connections, so cleanupPeerConnections wouldn't cover this path.)
1276
+ this.clearLostQualityTimeout();
1277
+
1326
1278
  if (
1327
1279
  this.clientConfiguration?.resumeConnection === ClientConfigSetting.DISABLED ||
1328
1280
  // signaling state could change to closed due to hardware sleep
@@ -1332,15 +1284,23 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
1332
1284
  this.fullReconnectOnNext = true;
1333
1285
  }
1334
1286
 
1287
+ // Consume the flag up front: capture whether this attempt is a full reconnect, then reset
1288
+ // it. From here on a `true` value unambiguously represents a *new* full-reconnect request
1289
+ // that arrived while this attempt was running (e.g. a server RECONNECT leave), which the
1290
+ // finally block dispatches — for both the resume and full-reconnect paths.
1291
+ const fullReconnect = this.fullReconnectOnNext;
1292
+ this.fullReconnectOnNext = false;
1293
+
1294
+ let succeeded = false;
1335
1295
  try {
1336
1296
  this.attemptingReconnect = true;
1337
- if (this.fullReconnectOnNext) {
1297
+ if (fullReconnect) {
1338
1298
  await this.restartConnection();
1339
1299
  } else {
1340
1300
  await this.resumeConnection(reason);
1341
1301
  }
1342
1302
  this.clearPendingReconnect();
1343
- this.fullReconnectOnNext = false;
1303
+ succeeded = true;
1344
1304
  } catch (e) {
1345
1305
  this.reconnectAttempts += 1;
1346
1306
  let recoverable = true;
@@ -1348,8 +1308,9 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
1348
1308
  this.log.debug('received unrecoverable error', { error: e });
1349
1309
  // unrecoverable
1350
1310
  recoverable = false;
1351
- } else if (!(e instanceof SignalReconnectError)) {
1352
- // cannot resume
1311
+ } else if (fullReconnect || !(e instanceof SignalReconnectError)) {
1312
+ // a failed full reconnect stays a full reconnect; a failed resume can only be
1313
+ // resumed again for a signal-level error, otherwise it escalates
1353
1314
  this.fullReconnectOnNext = true;
1354
1315
  }
1355
1316
 
@@ -1366,6 +1327,14 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
1366
1327
  }
1367
1328
  } finally {
1368
1329
  this.attemptingReconnect = false;
1330
+
1331
+ // A full reconnect requested while this attempt was running (e.g. a `RECONNECT` leave
1332
+ // during a resume or a restart) that a successful attempt didn't act on; dispatch it now
1333
+ // (the failure path already retries).
1334
+ if (succeeded && this.fullReconnectOnNext && !this._isClosed) {
1335
+ this.log.debug('full reconnect requested during in-progress attempt, dispatching');
1336
+ this.handleDisconnect('reconnect');
1337
+ }
1369
1338
  }
1370
1339
  }
1371
1340
 
@@ -1510,7 +1479,8 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
1510
1479
 
1511
1480
  // recreate publish datachannel if it's id is null
1512
1481
  // (for safari https://bugs.webkit.org/show_bug.cgi?id=184688)
1513
- if (this.reliableDC?.readyState === 'open' && this.reliableDC.id === null) {
1482
+ const reliableDC = this.dataChannelForKind(DataChannelKind.RELIABLE);
1483
+ if (reliableDC?.readyState === 'open' && reliableDC.id === null) {
1514
1484
  this.createDataChannels();
1515
1485
  }
1516
1486
 
@@ -1586,7 +1556,11 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
1586
1556
  }
1587
1557
 
1588
1558
  /* @internal */
1589
- async sendDataPacket(packet: DataPacket, kind: DataChannelKind) {
1559
+ async sendDataPacket(
1560
+ packet: DataPacket,
1561
+ /** Data-track frames don't come through here — they're sent pre-serialized via {@link sendDataTrackFrame }. */
1562
+ kind: Exclude<DataChannelKind, DataChannelKind.DATA_TRACK_LOSSY>,
1563
+ ) {
1590
1564
  // make sure we do have a data connection
1591
1565
  await this.ensurePublisherConnected(kind);
1592
1566
 
@@ -1608,8 +1582,7 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
1608
1582
  }
1609
1583
 
1610
1584
  if (kind === DataChannelKind.RELIABLE) {
1611
- packet.sequence = this.reliableDataSequence;
1612
- this.reliableDataSequence += 1;
1585
+ packet.sequence = this.reliableChannel.nextSequence();
1613
1586
  }
1614
1587
 
1615
1588
  const msg = packet.toBinary() as Uint8Array<ArrayBuffer>;
@@ -1631,314 +1604,46 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
1631
1604
  );
1632
1605
  }
1633
1606
 
1634
- switch (kind) {
1635
- case DataChannelKind.LOSSY:
1636
- case DataChannelKind.DATA_TRACK_LOSSY:
1637
- return this.sendLossyBytes(msg, kind);
1638
-
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
-
1647
- const dc = this.dataChannelForKind(kind);
1648
- if (dc) {
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
- }
1662
-
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 });
1666
- return;
1667
- }
1668
-
1669
- this.reliableMessageBuffer.push({ data: msg, sequence: packet.sequence, sent: true });
1670
- dc.send(msg);
1671
- }
1672
-
1673
- this.updateAndEmitDCBufferStatus(kind);
1674
- break;
1675
- }
1676
- }
1677
- }
1678
-
1679
- /* @internal */
1680
- async sendLossyBytes(
1681
- bytes: NonSharedUint8Array,
1682
- kind: Exclude<DataChannelKind, DataChannelKind.RELIABLE>,
1683
- bufferStatusFullBehavior: 'drop' | 'wait' = 'drop',
1684
- ) {
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.
1690
- await this.ensurePublisherConnected(kind);
1691
-
1692
- const dc = this.dataChannelForKind(kind);
1693
- try {
1694
- if (dc) {
1695
- // Depending on the exact circumstance that data is being sent, either drop or wait for the
1696
- // buffer to drain below the high-water mark before continuing.
1697
- switch (bufferStatusFullBehavior) {
1698
- case 'wait':
1699
- if (!this.isBelowHighWaterMark(kind)) {
1700
- await this.waitForBufferHeadroomWithLock(kind);
1701
- }
1702
- break;
1703
- case 'drop':
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;
1714
- }
1715
- }
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
- }
1722
-
1723
- if (this.attemptingReconnect) {
1724
- return;
1725
- }
1726
-
1727
- dc.send(bytes);
1728
- }
1729
-
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
- }
1738
- }
1739
- }
1740
-
1741
- private async resendReliableMessagesForResume(lastMessageSeq: number) {
1742
- await this.ensurePublisherConnected(DataChannelKind.RELIABLE);
1743
- const dc = this.dataChannelForKind(DataChannelKind.RELIABLE);
1744
- if (dc) {
1745
- this.reliableMessageBuffer.popToSequence(lastMessageSeq);
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
- }
1776
- }
1777
- this.updateAndEmitDCBufferStatus(DataChannelKind.RELIABLE);
1778
- }
1779
-
1780
- private updateAndEmitDCBufferStatus = (kind: DataChannelKind) => {
1607
+ // The full-buffer policy (drop for lossy, wait/replay for reliable) is the channel's own, as
1608
+ // is emitting the buffer-status change once the send settles.
1781
1609
  if (kind === DataChannelKind.RELIABLE) {
1782
- const dc = this.dataChannelForKind(kind);
1783
- if (dc) {
1784
- this.reliableMessageBuffer.alignBufferedAmount(dc.bufferedAmount);
1785
- }
1786
- }
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 });
1795
- }
1796
- };
1797
-
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> => {
1803
- const dc = this.dataChannelForKind(kind);
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}`);
1610
+ await this.reliableChannel.send(msg, packet.sequence);
1611
+ } else {
1612
+ await this.lossyChannel.send(msg);
1823
1613
  }
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;
1826
- };
1827
-
1828
- /** Per-kind lock serializing senders that have to wait for the buffer to drain. */
1829
- private waitForBufferHeadroomLocks = new Map<DataChannelKind, Mutex>();
1614
+ }
1830
1615
 
1831
1616
  /**
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.
1617
+ * Sends pre-serialized bytes on the data-track channel. This is the one send path that doesn't
1618
+ * go through {@link sendDataPacket} Room's `packetAvailable` handler calls it directly with
1619
+ * bytes the data-track pipeline already serialized.
1620
+ *
1621
+ * @internal
1837
1622
  */
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;
1623
+ async sendDataTrackFrame(bytes: NonSharedUint8Array) {
1624
+ // Make sure we do have a data connection: on lazily negotiated publisher connections this is
1625
+ // what kicks negotiation off, and it waits for the channel to open. Memoized, so the
1626
+ // steady-state cost is one await on an already-resolved promise.
1627
+ await this.ensurePublisherConnected(DataChannelKind.DATA_TRACK_LOSSY);
1628
+ await this.dataTrackChannel.send(bytes);
1847
1629
  }
1848
1630
 
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();
1631
+ private async resendReliableMessagesForResume(lastMessageSeq: number) {
1632
+ await this.ensurePublisherConnected(DataChannelKind.RELIABLE);
1633
+ await this.reliableChannel.replay(lastMessageSeq);
1855
1634
  }
1856
1635
 
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;
1636
+ /** The flow-control gate for `kind` see {@link FlowControlledDataChannel}. */
1637
+ private flowControlFor(kind: DataChannelKind): FlowControlledDataChannel {
1638
+ return this.dataChannels.channelFor(kind);
1865
1639
  }
1866
1640
 
1867
1641
  /**
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.
1642
+ * Resolves once the caller may send on the `kind` channel see
1643
+ * {@link FlowControlledDataChannel.waitForHeadroomWithLock}.
1872
1644
  */
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();
1910
- resolve();
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;
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);
1941
- });
1645
+ async waitForBufferHeadroom(kind: DataChannelKind) {
1646
+ return this.flowControlFor(kind).waitForHeadroomWithLock();
1942
1647
  }
1943
1648
 
1944
1649
  /**
@@ -2012,11 +1717,12 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
2012
1717
  if (!this.pcManager) {
2013
1718
  return false;
2014
1719
  }
1720
+ const state = this.pcManager.currentState;
2015
1721
  const allowedConnectionStates: PCTransportState[] = [
2016
1722
  PCTransportState.CONNECTING,
2017
1723
  PCTransportState.CONNECTED,
2018
1724
  ];
2019
- if (!allowedConnectionStates.includes(this.pcManager.currentState)) {
1725
+ if (!allowedConnectionStates.includes(state)) {
2020
1726
  return false;
2021
1727
  }
2022
1728
 
@@ -2024,6 +1730,20 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
2024
1730
  if (!this.client.ws || this.client.ws.readyState === WebSocket.CLOSED) {
2025
1731
  return false;
2026
1732
  }
1733
+
1734
+ // A transport stuck in CONNECTING never reaches CONNECTED nor reports FAILED, so it would
1735
+ // otherwise look healthy forever; bound how long we tolerate it. The entry time is recorded
1736
+ // in the pcManager state-change handler (see configure()), so this is a pure read — an
1737
+ // unrecorded CONNECTING fails open rather than measuring against a stale timestamp.
1738
+ if (
1739
+ state === PCTransportState.CONNECTING &&
1740
+ this.transportConnectingSince !== undefined &&
1741
+ Date.now() - this.transportConnectingSince > this.peerConnectionTimeout
1742
+ ) {
1743
+ this.log.warn('transport stuck in connecting state', this.logContext);
1744
+ return false;
1745
+ }
1746
+
2027
1747
  return true;
2028
1748
  }
2029
1749
 
@@ -2040,9 +1760,7 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
2040
1760
  // don't negotiate without any transceivers or data channel, it will generate sdp without ice frag then negotiate failed
2041
1761
  if (
2042
1762
  this.pcManager.publisher.getTransceivers().length == 0 &&
2043
- !this.lossyDC &&
2044
- !this.reliableDC &&
2045
- !this.dataTrackDC
1763
+ !this.dataChannels.hasPublisherChannels
2046
1764
  ) {
2047
1765
  this.createDataChannels();
2048
1766
  }
@@ -2091,26 +1809,7 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
2091
1809
  }
2092
1810
 
2093
1811
  dataChannelForKind(kind: DataChannelKind, sub?: boolean): RTCDataChannel | undefined {
2094
- switch (kind) {
2095
- case DataChannelKind.RELIABLE:
2096
- if (!sub) {
2097
- return this.reliableDC;
2098
- } else {
2099
- return this.reliableDCSub;
2100
- }
2101
- case DataChannelKind.LOSSY:
2102
- if (!sub) {
2103
- return this.lossyDC;
2104
- } else {
2105
- return this.lossyDCSub;
2106
- }
2107
- case DataChannelKind.DATA_TRACK_LOSSY:
2108
- if (!sub) {
2109
- return this.dataTrackDC;
2110
- } else {
2111
- return this.dataTrackDCSub;
2112
- }
2113
- }
1812
+ return this.dataChannels.getHandle(kind, sub);
2114
1813
  }
2115
1814
 
2116
1815
  /** @internal */