livekit-client 2.20.1 → 2.20.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/dist/livekit-client.e2ee.worker.js +1 -1
  2. package/dist/livekit-client.e2ee.worker.js.map +1 -1
  3. package/dist/livekit-client.e2ee.worker.mjs +33 -7
  4. package/dist/livekit-client.e2ee.worker.mjs.map +1 -1
  5. package/dist/livekit-client.esm.mjs +589 -163
  6. package/dist/livekit-client.esm.mjs.map +1 -1
  7. package/dist/livekit-client.fm.worker.js +1 -1
  8. package/dist/livekit-client.fm.worker.js.map +1 -1
  9. package/dist/livekit-client.fm.worker.mjs +33 -7
  10. package/dist/livekit-client.fm.worker.mjs.map +1 -1
  11. package/dist/livekit-client.umd.js +1 -1
  12. package/dist/livekit-client.umd.js.map +1 -1
  13. package/dist/src/api/SignalClient.d.ts.map +1 -1
  14. package/dist/src/logger.d.ts +3 -1
  15. package/dist/src/logger.d.ts.map +1 -1
  16. package/dist/src/room/PCTransport.d.ts +2 -0
  17. package/dist/src/room/PCTransport.d.ts.map +1 -1
  18. package/dist/src/room/PCTransportManager.d.ts +1 -0
  19. package/dist/src/room/PCTransportManager.d.ts.map +1 -1
  20. package/dist/src/room/RTCEngine.d.ts +45 -4
  21. package/dist/src/room/RTCEngine.d.ts.map +1 -1
  22. package/dist/src/room/RegionUrlProvider.d.ts.map +1 -1
  23. package/dist/src/room/Room.d.ts.map +1 -1
  24. package/dist/src/room/data-track/incoming/IncomingDataTrackManager.d.ts +7 -0
  25. package/dist/src/room/data-track/incoming/IncomingDataTrackManager.d.ts.map +1 -1
  26. package/dist/src/room/data-track/outgoing/OutgoingDataTrackManager.d.ts.map +1 -1
  27. package/dist/src/room/participant/LocalParticipant.d.ts.map +1 -1
  28. package/dist/src/room/participant/Participant.d.ts.map +1 -1
  29. package/dist/src/room/participant/publishUtils.d.ts +7 -0
  30. package/dist/src/room/participant/publishUtils.d.ts.map +1 -1
  31. package/dist/src/room/track/LocalAudioTrack.d.ts +1 -0
  32. package/dist/src/room/track/LocalAudioTrack.d.ts.map +1 -1
  33. package/dist/src/room/track/Track.d.ts.map +1 -1
  34. package/dist/src/room/track/options.d.ts +11 -1
  35. package/dist/src/room/track/options.d.ts.map +1 -1
  36. package/dist/src/test/signalToken.d.ts +26 -0
  37. package/dist/src/test/signalToken.d.ts.map +1 -0
  38. package/dist/src/utils/dataPacketBuffer.d.ts +18 -0
  39. package/dist/src/utils/dataPacketBuffer.d.ts.map +1 -1
  40. package/dist/ts4.2/logger.d.ts +3 -1
  41. package/dist/ts4.2/room/PCTransport.d.ts +2 -0
  42. package/dist/ts4.2/room/PCTransportManager.d.ts +1 -0
  43. package/dist/ts4.2/room/RTCEngine.d.ts +45 -4
  44. package/dist/ts4.2/room/data-track/incoming/IncomingDataTrackManager.d.ts +7 -0
  45. package/dist/ts4.2/room/participant/publishUtils.d.ts +7 -0
  46. package/dist/ts4.2/room/track/LocalAudioTrack.d.ts +1 -0
  47. package/dist/ts4.2/room/track/options.d.ts +11 -1
  48. package/dist/ts4.2/test/signalToken.d.ts +26 -0
  49. package/dist/ts4.2/utils/dataPacketBuffer.d.ts +18 -0
  50. package/package.json +7 -3
  51. package/src/api/SignalClient.e2e.test.ts +264 -0
  52. package/src/api/SignalClient.ts +34 -2
  53. package/src/logger.ts +2 -0
  54. package/src/room/PCTransport.ts +66 -31
  55. package/src/room/PCTransportManager.ts +10 -6
  56. package/src/room/RTCEngine.test.ts +275 -2
  57. package/src/room/RTCEngine.ts +276 -71
  58. package/src/room/RegionUrlProvider.ts +4 -2
  59. package/src/room/Room.ts +7 -1
  60. package/src/room/data-track/incoming/IncomingDataTrackManager.test.ts +303 -0
  61. package/src/room/data-track/incoming/IncomingDataTrackManager.ts +70 -8
  62. package/src/room/data-track/outgoing/OutgoingDataTrackManager.ts +2 -0
  63. package/src/room/errors.ts +4 -4
  64. package/src/room/participant/LocalParticipant.ts +16 -6
  65. package/src/room/participant/Participant.ts +5 -0
  66. package/src/room/participant/publishUtils.ts +14 -11
  67. package/src/room/track/LocalAudioTrack.ts +16 -0
  68. package/src/room/track/Track.ts +10 -6
  69. package/src/room/track/options.ts +11 -1
  70. package/src/test/signalServerSetup.ts +151 -0
  71. package/src/test/signalToken.ts +59 -0
  72. package/src/test/vitest-context.d.ts +10 -0
  73. package/src/utils/dataPacketBuffer.test.ts +100 -0
  74. package/src/utils/dataPacketBuffer.ts +46 -1
@@ -15,15 +15,24 @@ interface TrackBitrateInfo {
15
15
  transceiver?: RTCRtpTransceiver;
16
16
  codec: string;
17
17
  maxbr: number;
18
+ isScreenShare?: boolean;
18
19
  }
19
20
 
20
- /* The svc codec (av1/vp9) would use a very low bitrate at the begining and
21
- increase slowly by the bandwidth estimator until it reach the target bitrate. The
22
- process commonly cost more than 10 seconds cause subscriber will get blur video at
23
- the first few seconds. So we use a 70% of target bitrate here as the start bitrate to
24
- eliminate this issue.
25
- */
26
- const startBitrateForSVC = 0.7;
21
+ /*
22
+ * Video codecs use a very low bitrate at the beginning and increase slowly by
23
+ * the bandwidth estimator until they reach the target bitrate. The process commonly
24
+ * costs more than 10 seconds causing subscribers to get blurry video at the first
25
+ * few seconds. We use x-google-start-bitrate to hint the BWE to start higher.
26
+ *
27
+ * Why 90%: Gives ~10% headroom for bandwidth estimation while starting close to target.
28
+ * Why same for all codecs: Target bitrate already accounts for codec efficiency
29
+ * (e.g., users set lower targets for VP9/AV1 knowing they're more efficient).
30
+ * Why cap at 1 Mbps: Prevents BWE from starting too aggressively on high bitrate tracks.
31
+ */
32
+ const startBitrateMultiplier = 0.9;
33
+
34
+ /** Maximum x-google-start-bitrate in kbps. 1 Mbps prevents BWE from starting too aggressively. */
35
+ const maxStartBitrateKbps = 1000;
27
36
 
28
37
  const debounceInterval = 20;
29
38
 
@@ -53,6 +62,8 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
53
62
 
54
63
  private log = log;
55
64
 
65
+ private iceLog = log;
66
+
56
67
  private loggerOptions: LoggerOptions;
57
68
 
58
69
  private ddExtID = 0;
@@ -95,8 +106,12 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
95
106
 
96
107
  constructor(config?: RTCConfiguration, loggerOptions: LoggerOptions = {}) {
97
108
  super();
98
- this.log = getLogger(loggerOptions.loggerName ?? LoggerNames.PCTransport);
99
109
  this.loggerOptions = loggerOptions;
110
+ this.log = getLogger(
111
+ loggerOptions.loggerName ?? LoggerNames.PCTransport,
112
+ () => this.logContext,
113
+ );
114
+ this.iceLog = getLogger(LoggerNames.ICE, () => this.logContext);
100
115
  this.config = config;
101
116
  this._pc = this.createPC();
102
117
  this.offerLock = new Mutex();
@@ -107,24 +122,33 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
107
122
 
108
123
  pc.onicecandidate = (ev) => {
109
124
  if (!ev.candidate) return;
125
+ this.iceLog.debug('local ICE candidate gathered', { candidate: ev.candidate.candidate });
110
126
  this.onIceCandidate?.(ev.candidate);
111
127
  };
112
128
  pc.onicecandidateerror = (ev) => {
129
+ this.iceLog.debug('ICE candidate error', { event: ev });
113
130
  this.onIceCandidateError?.(ev);
114
131
  };
115
132
 
116
133
  pc.oniceconnectionstatechange = () => {
134
+ this.iceLog.debug(`ICE connection state: ${pc.iceConnectionState}`);
117
135
  this.onIceConnectionStateChange?.(pc.iceConnectionState);
118
136
  };
119
137
 
120
138
  pc.onsignalingstatechange = () => {
139
+ this.log.debug(`signaling state: ${pc.signalingState}`);
121
140
  this.onSignalingStatechange?.(pc.signalingState);
122
141
  };
123
142
 
124
143
  pc.onconnectionstatechange = () => {
144
+ this.log.debug(`connection state: ${pc.connectionState}`);
125
145
  this.onConnectionStateChange?.(pc.connectionState);
126
146
  };
127
147
  pc.ondatachannel = (ev) => {
148
+ this.log.debug('data channel opened by peer', {
149
+ label: ev.channel.label,
150
+ id: ev.channel.id,
151
+ });
128
152
  this.onDataChannel?.(ev);
129
153
  };
130
154
  pc.ontrack = (ev) => {
@@ -150,6 +174,9 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
150
174
  if (this.pc.remoteDescription && !this.restartingIce) {
151
175
  return this.pc.addIceCandidate(candidate);
152
176
  }
177
+ this.iceLog.debug('queuing remote ICE candidate until remote description applied', {
178
+ pendingCount: this.pendingCandidates.length + 1,
179
+ });
153
180
  this.pendingCandidates.push(candidate);
154
181
  }
155
182
 
@@ -161,7 +188,6 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
161
188
  offerId !== this.latestOfferId
162
189
  ) {
163
190
  this.log.warn('ignoring answer for old offer', {
164
- ...this.logContext,
165
191
  offerId,
166
192
  latestOfferId: this.latestOfferId,
167
193
  });
@@ -180,7 +206,7 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
180
206
  sdpParsed.media.forEach((media) => {
181
207
  ensureIPAddrMatchVersion(media);
182
208
  });
183
- this.log.debug('setting pending initial offer before processing answer', this.logContext);
209
+ this.log.debug('setting pending initial offer before processing answer');
184
210
  await this.setMungedSDP(initialOffer, write(sdpParsed));
185
211
  }
186
212
  const sdpParsed = parse(sd.sdp ?? '');
@@ -249,6 +275,11 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
249
275
  }
250
276
  await this.setMungedSDP(sd, mungedSDP, true);
251
277
 
278
+ if (this.pendingCandidates.length > 0) {
279
+ this.iceLog.debug('flushing queued ICE candidates', {
280
+ count: this.pendingCandidates.length,
281
+ });
282
+ }
252
283
  this.pendingCandidates.forEach((candidate) => {
253
284
  this.pc.addIceCandidate(candidate);
254
285
  });
@@ -298,10 +329,7 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
298
329
  const unlock = await this.offerLock.lock();
299
330
  try {
300
331
  if (this.pc.signalingState !== 'stable') {
301
- this.log.warn(
302
- 'signaling state is not stable, cannot create initial offer',
303
- this.logContext,
304
- );
332
+ this.log.warn('signaling state is not stable, cannot create initial offer');
305
333
  return;
306
334
  }
307
335
  const offerId = this.latestOfferId + 1;
@@ -328,7 +356,7 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
328
356
  }
329
357
 
330
358
  if (options?.iceRestart) {
331
- this.log.debug('restarting ICE', this.logContext);
359
+ this.iceLog.debug('restarting ICE');
332
360
  this.restartingIce = true;
333
361
  }
334
362
 
@@ -345,21 +373,21 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
345
373
  await this._pc.setRemoteDescription(currentSD);
346
374
  } else {
347
375
  this.renegotiate = true;
348
- this.log.debug('requesting renegotiation', { ...this.logContext });
376
+ this.log.debug('requesting renegotiation');
349
377
  return;
350
378
  }
351
379
  } else if (!this._pc || this._pc.signalingState === 'closed') {
352
- this.log.warn('could not createOffer with closed peer connection', this.logContext);
380
+ this.log.warn('could not createOffer with closed peer connection');
353
381
  return;
354
382
  }
355
383
 
356
384
  // actually negotiate
357
- this.log.debug('starting to negotiate', this.logContext);
385
+ this.log.debug('starting to negotiate');
358
386
  // increase the offer id at the start to ensure the offer is always > 0 so that we can use 0 as a default value for legacy behavior
359
387
  const offerId = this.latestOfferId + 1;
360
388
  this.latestOfferId = offerId;
361
389
  const offer = await this.pc.createOffer(options);
362
- this.log.debug('original offer', { sdp: offer.sdp, ...this.logContext });
390
+ this.log.debug('original offer', { sdp: offer.sdp });
363
391
 
364
392
  const sdpParsed = parse(offer.sdp ?? '');
365
393
  sdpParsed.media.forEach((media) => {
@@ -390,14 +418,18 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
390
418
  }
391
419
 
392
420
  // mung sdp for bitrate setting that can't apply by sendEncoding
393
- if (!isSVCCodec(trackbr.codec)) {
394
- return true;
395
- }
396
-
397
- const startBitrate = Math.round(trackbr.maxbr * startBitrateForSVC);
421
+ // Use 90% of target bitrate, capped at 1 Mbps for camera to prevent BWE from starting too aggressively
422
+ // Screen share is not capped since text/UI clarity requires high bitrate from the start
423
+ // TODO: dynamically adjust start bitrate based on network conditions (e.g., use previous BWE estimate)
424
+ const calculatedStartBitrate = Math.round(trackbr.maxbr * startBitrateMultiplier);
425
+ const startBitrate = trackbr.isScreenShare
426
+ ? calculatedStartBitrate
427
+ : Math.min(calculatedStartBitrate, maxStartBitrateKbps);
398
428
 
429
+ let fmtpFound = false;
399
430
  for (const fmtp of media.fmtp) {
400
431
  if (fmtp.payload === codecPayload) {
432
+ fmtpFound = true;
401
433
  // if another track's fmtp already is set, we cannot override the bitrate
402
434
  // this has the unfortunate consequence of being forced to use the
403
435
  // initial track's bitrate for all tracks
@@ -407,6 +439,13 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
407
439
  break;
408
440
  }
409
441
  }
442
+ // VP8 and some codecs may not have an existing fmtp line - create one
443
+ if (!fmtpFound) {
444
+ media.fmtp.push({
445
+ payload: codecPayload,
446
+ config: `x-google-start-bitrate=${startBitrate}`,
447
+ });
448
+ }
410
449
  return true;
411
450
  });
412
451
  }
@@ -424,7 +463,6 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
424
463
  }
425
464
  if (this.latestOfferId > offerId) {
426
465
  this.log.warn('latestOfferId mismatch', {
427
- ...this.logContext,
428
466
  latestOfferId: this.latestOfferId,
429
467
  offerId,
430
468
  });
@@ -576,6 +614,7 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
576
614
  if (!this._pc) {
577
615
  return;
578
616
  }
617
+ this.log.debug('closing peer connection');
579
618
  this.pendingInitialOffer = undefined;
580
619
  this._pc.close();
581
620
  this._pc.onconnectionstatechange = null;
@@ -597,10 +636,7 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
597
636
  if (munged) {
598
637
  sd.sdp = munged;
599
638
  try {
600
- this.log.debug(
601
- `setting munged ${remote ? 'remote' : 'local'} description`,
602
- this.logContext,
603
- );
639
+ this.log.debug(`setting munged ${remote ? 'remote' : 'local'} description`);
604
640
  if (remote) {
605
641
  await this.pc.setRemoteDescription(sd);
606
642
  } else {
@@ -609,7 +645,6 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
609
645
  return;
610
646
  } catch (e) {
611
647
  this.log.warn(`not able to set ${sd.type}, falling back to unmodified sdp`, {
612
- ...this.logContext,
613
648
  error: e,
614
649
  mungedSdp: munged,
615
650
  originalSdp,
@@ -642,7 +677,7 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
642
677
  if (!remote && this.pc.remoteDescription) {
643
678
  fields.remoteSdp = this.pc.remoteDescription;
644
679
  }
645
- this.log.error(`unable to set ${sd.type}`, { ...this.logContext, fields });
680
+ this.log.error(`unable to set ${sd.type}`, { fields });
646
681
  throw new NegotiationError(msg);
647
682
  }
648
683
  }
@@ -65,6 +65,8 @@ export class PCTransportManager {
65
65
 
66
66
  private log = log;
67
67
 
68
+ private iceLog = log;
69
+
68
70
  private loggerOptions: LoggerOptions;
69
71
 
70
72
  private _mode: PCMode;
@@ -74,8 +76,9 @@ export class PCTransportManager {
74
76
  }
75
77
 
76
78
  constructor(mode: PCMode, loggerOptions: LoggerOptions, rtcConfig?: RTCConfiguration) {
77
- this.log = getLogger(loggerOptions.loggerName ?? LoggerNames.PCManager);
78
79
  this.loggerOptions = loggerOptions;
80
+ this.log = getLogger(loggerOptions.loggerName ?? LoggerNames.PCManager, () => this.logContext);
81
+ this.iceLog = getLogger(LoggerNames.ICE, () => this.logContext);
79
82
 
80
83
  this.isPublisherConnectionRequired = mode !== 'subscriber-primary';
81
84
  this.isSubscriberConnectionRequired = mode === 'subscriber-primary';
@@ -151,7 +154,7 @@ export class PCTransportManager {
151
154
  publisher.removeTrack(sender);
152
155
  }
153
156
  } catch (e) {
154
- this.log.warn('could not removeTrack', { ...this.logContext, error: e });
157
+ this.log.warn('could not removeTrack', { error: e });
155
158
  }
156
159
  }
157
160
  }
@@ -160,6 +163,7 @@ export class PCTransportManager {
160
163
  }
161
164
 
162
165
  async triggerIceRestart() {
166
+ this.iceLog.warn('triggering ICE restart');
163
167
  if (this.subscriber) {
164
168
  this.subscriber.restartingIce = true;
165
169
  }
@@ -170,6 +174,7 @@ export class PCTransportManager {
170
174
  }
171
175
 
172
176
  async addIceCandidate(candidate: RTCIceCandidateInit, target: SignalTarget) {
177
+ this.iceLog.debug('adding remote ICE candidate', { target, candidate });
173
178
  if (target === SignalTarget.PUBLISHER) {
174
179
  await this.publisher.addIceCandidate(candidate);
175
180
  } else {
@@ -179,7 +184,6 @@ export class PCTransportManager {
179
184
 
180
185
  async createSubscriberAnswerFromOffer(sd: RTCSessionDescriptionInit, offerId: number) {
181
186
  this.log.debug('received server offer', {
182
- ...this.logContext,
183
187
  RTCSdpType: sd.type,
184
188
  sdp: sd.sdp,
185
189
  signalingState: this.subscriber?.getSignallingState().toString(),
@@ -200,6 +204,7 @@ export class PCTransportManager {
200
204
  }
201
205
 
202
206
  updateConfiguration(config: RTCConfiguration, iceRestart?: boolean) {
207
+ this.log.debug('updating rtc configuration', { iceRestart });
203
208
  this.publisher.setConfiguration(config);
204
209
  this.subscriber?.setConfiguration(config);
205
210
  if (iceRestart) {
@@ -215,7 +220,7 @@ export class PCTransportManager {
215
220
  this.publisher.getConnectionState() !== 'connected' &&
216
221
  this.publisher.getConnectionState() !== 'connecting'
217
222
  ) {
218
- this.log.debug('negotiation required, start negotiating', this.logContext);
223
+ this.log.debug('negotiation required, start negotiating');
219
224
  this.publisher.negotiate();
220
225
  }
221
226
  await Promise.all(
@@ -368,7 +373,6 @@ export class PCTransportManager {
368
373
  `pc state change: from ${PCTransportState[previousState]} to ${
369
374
  PCTransportState[this.state]
370
375
  }`,
371
- this.logContext,
372
376
  );
373
377
  this.onStateChange?.(
374
378
  this.state,
@@ -390,7 +394,7 @@ export class PCTransportManager {
390
394
 
391
395
  return new Promise<void>(async (resolve, reject) => {
392
396
  const abortHandler = () => {
393
- this.log.warn('abort transport connection', this.logContext);
397
+ this.log.warn('abort transport connection');
394
398
  CriticalTimers.clearTimeout(connectTimeout);
395
399
 
396
400
  reject(ConnectionError.cancelled('room connection has been cancelled'));
@@ -1,8 +1,9 @@
1
1
  import { DataPacket, DataPacket_Kind, UserPacket } from '@livekit/protocol';
2
2
  import { afterEach, describe, expect, it, vi } from 'vitest';
3
+ import type { DataPacketBuffer, DataPacketItem } from '../utils/dataPacketBuffer';
3
4
  import RTCEngine, { DataChannelKind } from './RTCEngine';
4
5
  import { roomOptionDefaults } from './defaults';
5
- import { PublishDataError } from './errors';
6
+ import { PublishDataError, UnexpectedConnectionState } from './errors';
6
7
 
7
8
  describe('RTCEngine', () => {
8
9
  const originalRTCRtpSender = window.RTCRtpSender;
@@ -230,7 +231,7 @@ describe('RTCEngine', () => {
230
231
  const send = vi.fn();
231
232
  Object.assign(engine as unknown as Record<string, unknown>, {
232
233
  ensurePublisherConnected: vi.fn().mockResolvedValue(undefined),
233
- waitForBufferStatusLow: vi.fn().mockResolvedValue(undefined),
234
+ waitForBufferHeadroomWithLock: vi.fn().mockResolvedValue(undefined),
234
235
  updateAndEmitDCBufferStatus: vi.fn(),
235
236
  dataChannelForKind: vi.fn(() => ({ send })),
236
237
  pcManager: {
@@ -298,6 +299,278 @@ describe('RTCEngine', () => {
298
299
  });
299
300
  });
300
301
 
302
+ class FakeDataChannel extends EventTarget {
303
+ bufferedAmount = 0;
304
+
305
+ bufferedAmountLowThreshold = 64 * 1024;
306
+
307
+ send = vi.fn();
308
+ }
309
+
310
+ const tick = () => new Promise<void>((resolve) => setTimeout(resolve, 0));
311
+
312
+ describe('resendReliableMessagesForResume', () => {
313
+ it('does not let a concurrent reliable send interleave into the resume replay', async () => {
314
+ const engine = new RTCEngine(roomOptionDefaults);
315
+ const dc = new FakeDataChannel();
316
+ Object.assign(engine as unknown as Record<string, unknown>, {
317
+ _isClosed: false,
318
+ ensurePublisherConnected: vi.fn().mockResolvedValue(undefined),
319
+ dataChannelForKind: vi.fn(() => dc),
320
+ pcManager: {
321
+ getMaxPublisherMessageSize: vi.fn(() => 64 * 1024 - 1),
322
+ },
323
+ });
324
+
325
+ // Two messages queued for replay, and a full buffer so the XX its first send.
326
+ const replayed1 = new Uint8Array([1]);
327
+ const replayed2 = new Uint8Array([2]);
328
+ const buffer = (
329
+ engine as unknown as { reliableMessageBuffer: { push: (item: DataPacketItem) => void } }
330
+ ).reliableMessageBuffer;
331
+ buffer.push({ data: replayed1, sequence: 1, sent: true });
332
+ buffer.push({ data: replayed2, sequence: 2, sent: true });
333
+ dc.bufferedAmount = 2 * 1024 * 1024; // above the reliable high-water mark
334
+
335
+ const replay = (
336
+ engine as unknown as { resendReliableMessagesForResume: (seq: number) => Promise<void> }
337
+ ).resendReliableMessagesForResume(0);
338
+ await tick();
339
+
340
+ // A send racing the replay: its sequence is assigned immediately, but it must not hit the
341
+ // wire before the replayed (lower-sequence) messages, or receivers discard those as dupes.
342
+ const concurrentSend = engine.sendDataPacket(
343
+ new DataPacket({
344
+ kind: DataPacket_Kind.RELIABLE,
345
+ value: { case: 'user', value: new UserPacket({ payload: new Uint8Array([3]) }) },
346
+ }),
347
+ DataChannelKind.RELIABLE,
348
+ );
349
+ await tick();
350
+
351
+ // Buffer drains: the replay must finish its whole batch before the concurrent send.
352
+ dc.bufferedAmount = 0;
353
+ dc.dispatchEvent(new Event('bufferedamountlow'));
354
+ await Promise.all([replay, concurrentSend]);
355
+
356
+ expect(dc.send).toHaveBeenCalledTimes(3);
357
+ expect(dc.send.mock.calls[0][0]).toBe(replayed1);
358
+ expect(dc.send.mock.calls[1][0]).toBe(replayed2);
359
+ expect(dc.send.mock.calls[2][0]).not.toBe(replayed1);
360
+ expect(dc.send.mock.calls[2][0]).not.toBe(replayed2);
361
+ });
362
+
363
+ it('transmits a packet deferred mid-replay instead of marking it sent without sending', async () => {
364
+ const engine = new RTCEngine(roomOptionDefaults);
365
+ const dc = new FakeDataChannel();
366
+ Object.assign(engine as unknown as Record<string, unknown>, {
367
+ _isClosed: false,
368
+ // Reconnect still active: a send arriving during replay takes the deferral path.
369
+ attemptingReconnect: true,
370
+ ensurePublisherConnected: vi.fn().mockResolvedValue(undefined),
371
+ dataChannelForKind: vi.fn(() => dc),
372
+ pcManager: {
373
+ getMaxPublisherMessageSize: vi.fn(() => 64 * 1024 - 1),
374
+ },
375
+ });
376
+
377
+ const buffer = (engine as unknown as { reliableMessageBuffer: DataPacketBuffer })
378
+ .reliableMessageBuffer;
379
+ const replayed = new Uint8Array([1]);
380
+ buffer.push({ data: replayed, sequence: 1, sent: true });
381
+ // Full buffer so replay parks on waitForBufferHeadroomWithLock before its first send.
382
+ dc.bufferedAmount = 2 * 1024 * 1024;
383
+
384
+ const replay = (
385
+ engine as unknown as { resendReliableMessagesForResume: (seq: number) => Promise<void> }
386
+ ).resendReliableMessagesForResume(0);
387
+ await tick();
388
+
389
+ // A reliable send arrives while replay is parked; attemptingReconnect defers it into the
390
+ // buffer as sent:false, after the replay's first drain pass already started.
391
+ const deferred = new Uint8Array([2]);
392
+ const deferredSend = engine.sendDataPacket(
393
+ new DataPacket({
394
+ kind: DataPacket_Kind.RELIABLE,
395
+ value: { case: 'user', value: new UserPacket({ payload: deferred }) },
396
+ }),
397
+ DataChannelKind.RELIABLE,
398
+ );
399
+ await tick();
400
+
401
+ dc.bufferedAmount = 0;
402
+ dc.dispatchEvent(new Event('bufferedamountlow'));
403
+ await Promise.all([replay, deferredSend]);
404
+
405
+ // Pre-fix, replay sends only its snapshot ([replayed]) and blanket-marks the deferred packet
406
+ // sent without ever transmitting it — one send. The drain loop must transmit both (the
407
+ // deferred packet is serialized through sendDataPacket, so it's distinct from `replayed`),
408
+ // leaving nothing unsent for a later align to strand.
409
+ const sent = dc.send.mock.calls.map(([d]) => d);
410
+ expect(sent).toHaveLength(2);
411
+ expect(sent[0]).toBe(replayed);
412
+ expect(sent[1]).not.toBe(replayed);
413
+ expect(buffer.getUnsent()).toHaveLength(0);
414
+ });
415
+ });
416
+
417
+ describe('reliable sends during teardown windows', () => {
418
+ const makePacket = (byte: number) =>
419
+ new DataPacket({
420
+ kind: DataPacket_Kind.RELIABLE,
421
+ value: { case: 'user', value: new UserPacket({ payload: new Uint8Array([byte]) }) },
422
+ });
423
+
424
+ function stubEngine(engine: RTCEngine, dc: FakeDataChannel) {
425
+ Object.assign(engine as unknown as Record<string, unknown>, {
426
+ _isClosed: false,
427
+ ensurePublisherConnected: vi.fn().mockResolvedValue(undefined),
428
+ dataChannelForKind: vi.fn(() => dc),
429
+ pcManager: {
430
+ getMaxPublisherMessageSize: vi.fn(() => 64 * 1024 - 1),
431
+ },
432
+ });
433
+ return (engine as unknown as { reliableMessageBuffer: DataPacketBuffer })
434
+ .reliableMessageBuffer;
435
+ }
436
+
437
+ it('resolves and queues the packet for replay when the wait is torn down transiently', async () => {
438
+ const engine = new RTCEngine(roomOptionDefaults);
439
+ const dc = new FakeDataChannel();
440
+ const buffer = stubEngine(engine, dc);
441
+
442
+ // Park the send on a full buffer, then invalidate the channel (reconnect/replacement).
443
+ dc.bufferedAmount = 2 * 1024 * 1024;
444
+ const send = engine.sendDataPacket(makePacket(1), DataChannelKind.RELIABLE);
445
+ await tick();
446
+ (
447
+ engine as unknown as { invalidateDataChannelWaiters: (reason: string) => void }
448
+ ).invalidateDataChannelWaiters('data channels recreated');
449
+
450
+ // The send must not surface the teardown — the packet is queued for the resume replay.
451
+ await expect(send).resolves.toBeUndefined();
452
+ expect(dc.send).not.toHaveBeenCalled();
453
+ expect(buffer.length).toBe(1);
454
+ expect(buffer.getAll()[0].sent).toBe(false);
455
+
456
+ // The replay then delivers it.
457
+ dc.bufferedAmount = 0;
458
+ await (
459
+ engine as unknown as { resendReliableMessagesForResume: (seq: number) => Promise<void> }
460
+ ).resendReliableMessagesForResume(0);
461
+ expect(dc.send).toHaveBeenCalledTimes(1);
462
+ expect(buffer.getAll()[0].sent).toBe(true);
463
+ });
464
+
465
+ it('still rejects when the engine is closed while waiting', async () => {
466
+ const engine = new RTCEngine(roomOptionDefaults);
467
+ const dc = new FakeDataChannel();
468
+ stubEngine(engine, dc);
469
+
470
+ dc.bufferedAmount = 2 * 1024 * 1024;
471
+ const send = engine.sendDataPacket(makePacket(1), DataChannelKind.RELIABLE);
472
+ await tick();
473
+ Object.assign(engine as unknown as Record<string, unknown>, { _isClosed: true });
474
+ (
475
+ engine as unknown as { invalidateDataChannelWaiters: (reason: string) => void }
476
+ ).invalidateDataChannelWaiters('engine closed');
477
+
478
+ await expect(send).rejects.toBeInstanceOf(UnexpectedConnectionState);
479
+ expect(dc.send).not.toHaveBeenCalled();
480
+ });
481
+
482
+ it('queues without waiting while a reconnect attempt is in progress', async () => {
483
+ const engine = new RTCEngine(roomOptionDefaults);
484
+ const dc = new FakeDataChannel();
485
+ const buffer = stubEngine(engine, dc);
486
+ Object.assign(engine as unknown as Record<string, unknown>, { attemptingReconnect: true });
487
+
488
+ // Even with a full buffer, the send resolves immediately instead of parking.
489
+ dc.bufferedAmount = 2 * 1024 * 1024;
490
+ await expect(
491
+ engine.sendDataPacket(makePacket(1), DataChannelKind.RELIABLE),
492
+ ).resolves.toBeUndefined();
493
+ expect(dc.send).not.toHaveBeenCalled();
494
+ expect(buffer.length).toBe(1);
495
+ expect(buffer.getAll()[0].sent).toBe(false);
496
+ });
497
+ });
498
+
499
+ describe('sendLossyBytes', () => {
500
+ it('ensures the publisher is connected before sending (direct data-track path)', async () => {
501
+ const engine = new RTCEngine(roomOptionDefaults);
502
+ const dc = new FakeDataChannel();
503
+ // The channel only becomes available once the publisher connection has been established —
504
+ // mirroring the lazily negotiated publisher case that Room's packetAvailable path hits.
505
+ let connected = false;
506
+ const ensurePublisherConnected = vi.fn(async () => {
507
+ connected = true;
508
+ });
509
+ Object.assign(engine as unknown as Record<string, unknown>, {
510
+ _isClosed: false,
511
+ ensurePublisherConnected,
512
+ dataChannelForKind: vi.fn(() => (connected ? dc : undefined)),
513
+ });
514
+
515
+ await engine.sendLossyBytes(new Uint8Array([1]), DataChannelKind.DATA_TRACK_LOSSY, 'wait');
516
+
517
+ expect(ensurePublisherConnected).toHaveBeenCalledWith(DataChannelKind.DATA_TRACK_LOSSY);
518
+ expect(dc.send).toHaveBeenCalledTimes(1);
519
+ });
520
+
521
+ it('only counts LOSSY bytes into the byterate stat that tunes the lossy drop threshold', async () => {
522
+ const engine = new RTCEngine(roomOptionDefaults);
523
+ const dc = new FakeDataChannel();
524
+ Object.assign(engine as unknown as Record<string, unknown>, {
525
+ _isClosed: false,
526
+ ensurePublisherConnected: vi.fn().mockResolvedValue(undefined),
527
+ dataChannelForKind: vi.fn(() => dc),
528
+ });
529
+ const stat = () =>
530
+ (engine as unknown as { lossyDataStatCurrentBytes: number }).lossyDataStatCurrentBytes;
531
+
532
+ // Data-track traffic must not move the stat — it would inflate the LOSSY channel's
533
+ // dynamically tuned drop threshold with traffic that channel never carries.
534
+ await engine.sendLossyBytes(new Uint8Array(1000), DataChannelKind.DATA_TRACK_LOSSY, 'wait');
535
+ expect(stat()).toBe(0);
536
+
537
+ await engine.sendLossyBytes(new Uint8Array(100), DataChannelKind.LOSSY, 'drop');
538
+ expect(stat()).toBe(100);
539
+ });
540
+ });
541
+
542
+ describe('waitForBufferHeadroomWithLock', () => {
543
+ it('rejects parked waiters and releases the lock when the data channels are invalidated', async () => {
544
+ const engine = new RTCEngine(roomOptionDefaults);
545
+ const dc = new FakeDataChannel();
546
+ Object.assign(engine as unknown as Record<string, unknown>, {
547
+ _isClosed: false,
548
+ dataChannelForKind: vi.fn(() => dc),
549
+ });
550
+
551
+ // Park a waiter: buffer above the reliable high-water mark, holding the headroom lock.
552
+ dc.bufferedAmount = 2 * 1024 * 1024;
553
+ const parked = engine.waitForBufferHeadroomWithLock(DataChannelKind.RELIABLE);
554
+ // Swallow the expected rejection so it can't surface as unhandled before we assert on it.
555
+ parked.catch(() => {});
556
+ await tick();
557
+
558
+ // The channel object gets abandoned (e.g. createDataChannels on the Safari resume path).
559
+ (
560
+ engine as unknown as { invalidateDataChannelWaiters: (reason: string) => void }
561
+ ).invalidateDataChannelWaiters('data channels recreated');
562
+
563
+ await expect(parked).rejects.toBeInstanceOf(UnexpectedConnectionState);
564
+
565
+ // The lock must be free again: a wait against the fresh, drained channel resolves instead
566
+ // of queueing forever behind the stranded waiter.
567
+ dc.bufferedAmount = 0;
568
+ await expect(
569
+ engine.waitForBufferHeadroomWithLock(DataChannelKind.RELIABLE),
570
+ ).resolves.toBeUndefined();
571
+ });
572
+ });
573
+
301
574
  describe('handleDataChannelClose', () => {
302
575
  function stubCloseEnv(
303
576
  engine: RTCEngine,