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
@@ -1,6 +1,12 @@
1
- import { DataPacket, DataPacket_Kind, UserPacket } from '@livekit/protocol';
2
- import { afterEach, describe, expect, it, vi } from 'vitest';
3
- import type { DataPacketBuffer, DataPacketItem } from '../utils/dataPacketBuffer';
1
+ import {
2
+ DataPacket,
3
+ DataPacket_Kind,
4
+ ConnectionQuality as ProtoConnectionQuality,
5
+ UserPacket,
6
+ } from '@livekit/protocol';
7
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
8
+ import type { DataPacketBuffer } from '../utils/dataPacketBuffer';
9
+ import { PCTransportState } from './PCTransportManager';
4
10
  import RTCEngine, { DataChannelKind } from './RTCEngine';
5
11
  import { roomOptionDefaults } from './defaults';
6
12
  import { PublishDataError, UnexpectedConnectionState } from './errors';
@@ -228,17 +234,16 @@ describe('RTCEngine', () => {
228
234
  engine: RTCEngine,
229
235
  maxDataPacketSize: number = MAX_DATA_PACKET_SIZE,
230
236
  ) {
231
- const send = vi.fn();
237
+ const dc = new FakeDataChannel();
232
238
  Object.assign(engine as unknown as Record<string, unknown>, {
239
+ _isClosed: false,
233
240
  ensurePublisherConnected: vi.fn().mockResolvedValue(undefined),
234
- waitForBufferHeadroomWithLock: vi.fn().mockResolvedValue(undefined),
235
- updateAndEmitDCBufferStatus: vi.fn(),
236
- dataChannelForKind: vi.fn(() => ({ send })),
237
241
  pcManager: {
238
242
  getMaxPublisherMessageSize: vi.fn(() => maxDataPacketSize),
239
243
  },
240
244
  });
241
- return send;
245
+ attachFakeChannel(engine, 'reliableChannel', dc);
246
+ return dc.send;
242
247
  }
243
248
 
244
249
  it('rejects packets larger than the max data packet size', async () => {
@@ -309,6 +314,23 @@ describe('RTCEngine', () => {
309
314
 
310
315
  const tick = () => new Promise<void>((resolve) => setTimeout(resolve, 0));
311
316
 
317
+ /** The reliable channel's private replay buffer — reached through casts, as tests do for engine privates. */
318
+ const reliableBuffer = (engine: RTCEngine) =>
319
+ (engine as unknown as { reliableChannel: { messageBuffer: DataPacketBuffer } }).reliableChannel
320
+ .messageBuffer;
321
+
322
+ type ChannelField = 'reliableChannel' | 'lossyChannel' | 'dataTrackChannel';
323
+ const engineChannel = (engine: RTCEngine, field: ChannelField) =>
324
+ (
325
+ engine as unknown as Record<
326
+ ChannelField,
327
+ { attach(dc: RTCDataChannel): void; invalidateWaiters(reason: string): void }
328
+ >
329
+ )[field];
330
+ /** Attach a fake handle to one of the engine's flow-control wrappers. */
331
+ const attachFakeChannel = (engine: RTCEngine, field: ChannelField, dc: FakeDataChannel) =>
332
+ engineChannel(engine, field).attach(dc as unknown as RTCDataChannel);
333
+
312
334
  describe('resendReliableMessagesForResume', () => {
313
335
  it('does not let a concurrent reliable send interleave into the resume replay', async () => {
314
336
  const engine = new RTCEngine(roomOptionDefaults);
@@ -316,18 +338,17 @@ describe('RTCEngine', () => {
316
338
  Object.assign(engine as unknown as Record<string, unknown>, {
317
339
  _isClosed: false,
318
340
  ensurePublisherConnected: vi.fn().mockResolvedValue(undefined),
319
- dataChannelForKind: vi.fn(() => dc),
320
341
  pcManager: {
321
342
  getMaxPublisherMessageSize: vi.fn(() => 64 * 1024 - 1),
322
343
  },
323
344
  });
345
+ attachFakeChannel(engine, 'reliableChannel', dc);
324
346
 
325
- // Two messages queued for replay, and a full buffer so the XX its first send.
347
+ // Two messages queued for replay, and a full buffer so the replay parks on
348
+ // waitForBufferHeadroom before its first send.
326
349
  const replayed1 = new Uint8Array([1]);
327
350
  const replayed2 = new Uint8Array([2]);
328
- const buffer = (
329
- engine as unknown as { reliableMessageBuffer: { push: (item: DataPacketItem) => void } }
330
- ).reliableMessageBuffer;
351
+ const buffer = reliableBuffer(engine);
331
352
  buffer.push({ data: replayed1, sequence: 1, sent: true });
332
353
  buffer.push({ data: replayed2, sequence: 2, sent: true });
333
354
  dc.bufferedAmount = 2 * 1024 * 1024; // above the reliable high-water mark
@@ -359,59 +380,6 @@ describe('RTCEngine', () => {
359
380
  expect(dc.send.mock.calls[2][0]).not.toBe(replayed1);
360
381
  expect(dc.send.mock.calls[2][0]).not.toBe(replayed2);
361
382
  });
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
383
  });
416
384
 
417
385
  describe('reliable sends during teardown windows', () => {
@@ -425,13 +393,12 @@ describe('RTCEngine', () => {
425
393
  Object.assign(engine as unknown as Record<string, unknown>, {
426
394
  _isClosed: false,
427
395
  ensurePublisherConnected: vi.fn().mockResolvedValue(undefined),
428
- dataChannelForKind: vi.fn(() => dc),
429
396
  pcManager: {
430
397
  getMaxPublisherMessageSize: vi.fn(() => 64 * 1024 - 1),
431
398
  },
432
399
  });
433
- return (engine as unknown as { reliableMessageBuffer: DataPacketBuffer })
434
- .reliableMessageBuffer;
400
+ attachFakeChannel(engine, 'reliableChannel', dc);
401
+ return reliableBuffer(engine);
435
402
  }
436
403
 
437
404
  it('resolves and queues the packet for replay when the wait is torn down transiently', async () => {
@@ -443,9 +410,7 @@ describe('RTCEngine', () => {
443
410
  dc.bufferedAmount = 2 * 1024 * 1024;
444
411
  const send = engine.sendDataPacket(makePacket(1), DataChannelKind.RELIABLE);
445
412
  await tick();
446
- (
447
- engine as unknown as { invalidateDataChannelWaiters: (reason: string) => void }
448
- ).invalidateDataChannelWaiters('data channels recreated');
413
+ engineChannel(engine, 'reliableChannel').invalidateWaiters('data channels recreated');
449
414
 
450
415
  // The send must not surface the teardown — the packet is queued for the resume replay.
451
416
  await expect(send).resolves.toBeUndefined();
@@ -471,9 +436,7 @@ describe('RTCEngine', () => {
471
436
  const send = engine.sendDataPacket(makePacket(1), DataChannelKind.RELIABLE);
472
437
  await tick();
473
438
  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');
439
+ engineChannel(engine, 'reliableChannel').invalidateWaiters('engine closed');
477
440
 
478
441
  await expect(send).rejects.toBeInstanceOf(UnexpectedConnectionState);
479
442
  expect(dc.send).not.toHaveBeenCalled();
@@ -496,78 +459,80 @@ describe('RTCEngine', () => {
496
459
  });
497
460
  });
498
461
 
499
- describe('sendLossyBytes', () => {
462
+ describe('sendDataTrackFrame', () => {
500
463
  it('ensures the publisher is connected before sending (direct data-track path)', async () => {
501
464
  const engine = new RTCEngine(roomOptionDefaults);
502
465
  const dc = new FakeDataChannel();
503
466
  // The channel only becomes available once the publisher connection has been established —
504
467
  // mirroring the lazily negotiated publisher case that Room's packetAvailable path hits.
505
- let connected = false;
506
468
  const ensurePublisherConnected = vi.fn(async () => {
507
- connected = true;
469
+ attachFakeChannel(engine, 'dataTrackChannel', dc);
508
470
  });
509
471
  Object.assign(engine as unknown as Record<string, unknown>, {
510
472
  _isClosed: false,
511
473
  ensurePublisherConnected,
512
- dataChannelForKind: vi.fn(() => (connected ? dc : undefined)),
513
474
  });
514
475
 
515
- await engine.sendLossyBytes(new Uint8Array([1]), DataChannelKind.DATA_TRACK_LOSSY, 'wait');
476
+ await engine.sendDataTrackFrame(new Uint8Array([1]));
516
477
 
517
478
  expect(ensurePublisherConnected).toHaveBeenCalledWith(DataChannelKind.DATA_TRACK_LOSSY);
518
479
  expect(dc.send).toHaveBeenCalledTimes(1);
519
480
  });
520
481
 
521
- it('only counts LOSSY bytes into the byterate stat that tunes the lossy drop threshold', async () => {
482
+ it('keeps each channel’s byterate stat isolated from the other’s traffic', async () => {
522
483
  const engine = new RTCEngine(roomOptionDefaults);
523
484
  const dc = new FakeDataChannel();
524
485
  Object.assign(engine as unknown as Record<string, unknown>, {
525
486
  _isClosed: false,
526
487
  ensurePublisherConnected: vi.fn().mockResolvedValue(undefined),
527
- dataChannelForKind: vi.fn(() => dc),
528
488
  });
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);
489
+ attachFakeChannel(engine, 'lossyChannel', dc);
490
+ attachFakeChannel(engine, 'dataTrackChannel', dc);
491
+ const lossyStat = () =>
492
+ (engine as unknown as { lossyChannel: { statCurrentBytes: number } }).lossyChannel
493
+ .statCurrentBytes;
494
+
495
+ // Data-track traffic (sendDataTrackFrame → data-track channel) must not move the LOSSY channel's
496
+ // stat — it would inflate the lossy channel's dynamically tuned drop threshold with traffic
497
+ // that channel never carries.
498
+ await engine.sendDataTrackFrame(new Uint8Array(1000));
499
+ expect(lossyStat()).toBe(0);
500
+
501
+ // A plain lossy publishData packet goes through sendDataPacket → lossy channel.
502
+ const lossyPacket = new DataPacket({
503
+ kind: DataPacket_Kind.LOSSY,
504
+ value: { case: 'user', value: new UserPacket({ payload: new Uint8Array(100) }) },
505
+ });
506
+ await engine.sendDataPacket(lossyPacket, DataChannelKind.LOSSY);
507
+ expect(lossyStat()).toBeGreaterThan(0);
539
508
  });
540
509
  });
541
510
 
542
- describe('waitForBufferHeadroomWithLock', () => {
511
+ describe('waitForBufferHeadroom', () => {
543
512
  it('rejects parked waiters and releases the lock when the data channels are invalidated', async () => {
544
513
  const engine = new RTCEngine(roomOptionDefaults);
545
514
  const dc = new FakeDataChannel();
546
515
  Object.assign(engine as unknown as Record<string, unknown>, {
547
516
  _isClosed: false,
548
- dataChannelForKind: vi.fn(() => dc),
549
517
  });
518
+ attachFakeChannel(engine, 'reliableChannel', dc);
550
519
 
551
520
  // Park a waiter: buffer above the reliable high-water mark, holding the headroom lock.
552
521
  dc.bufferedAmount = 2 * 1024 * 1024;
553
- const parked = engine.waitForBufferHeadroomWithLock(DataChannelKind.RELIABLE);
522
+ const parked = engine.waitForBufferHeadroom(DataChannelKind.RELIABLE);
554
523
  // Swallow the expected rejection so it can't surface as unhandled before we assert on it.
555
524
  parked.catch(() => {});
556
525
  await tick();
557
526
 
558
527
  // 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');
528
+ engineChannel(engine, 'reliableChannel').invalidateWaiters('data channels recreated');
562
529
 
563
530
  await expect(parked).rejects.toBeInstanceOf(UnexpectedConnectionState);
564
531
 
565
532
  // The lock must be free again: a wait against the fresh, drained channel resolves instead
566
533
  // of queueing forever behind the stranded waiter.
567
534
  dc.bufferedAmount = 0;
568
- await expect(
569
- engine.waitForBufferHeadroomWithLock(DataChannelKind.RELIABLE),
570
- ).resolves.toBeUndefined();
535
+ await expect(engine.waitForBufferHeadroom(DataChannelKind.RELIABLE)).resolves.toBeUndefined();
571
536
  });
572
537
  });
573
538
 
@@ -623,4 +588,335 @@ describe('RTCEngine', () => {
623
588
  expect(error).not.toHaveBeenCalled();
624
589
  });
625
590
  });
591
+
592
+ describe('local connection quality Lost handling', () => {
593
+ // The engine reacts to the server's own verdict: a sustained local `LOST` while
594
+ // connected and publishing means our media isn't reaching the server, so it forces
595
+ // a full reconnect. (A genuine `LOST` can't be produced from a browser page — any
596
+ // live sender keeps RTCP flowing — so the behavior is unit tested here rather than
597
+ // in the e2e suite.) `connectionQualityLostTimeout` in RTCEngine.ts is 5s.
598
+ const LOST_TIMEOUT_MS = 10_000;
599
+ const LOCAL_SID = 'PA_local';
600
+
601
+ beforeEach(() => {
602
+ vi.useFakeTimers();
603
+ });
604
+
605
+ afterEach(() => {
606
+ vi.useRealTimers();
607
+ });
608
+
609
+ /** An engine primed to satisfy the reconnect guard: connected, publishing, not closed. */
610
+ function primeEngine(overrides: { activeSenders?: boolean; pcState?: number } = {}) {
611
+ const engine = new RTCEngine(roomOptionDefaults);
612
+ const internals = engine as unknown as {
613
+ _isClosed: boolean;
614
+ participantSid: string;
615
+ // PCState is a private enum; Connected is 1, Reconnecting is 3.
616
+ pcState: number;
617
+ attemptingReconnect: boolean;
618
+ pcManager: unknown;
619
+ handleDisconnect: (connection: string, reason?: number) => void;
620
+ handleLocalConnectionQuality: (update: unknown) => void;
621
+ };
622
+ internals._isClosed = false;
623
+ internals.participantSid = LOCAL_SID;
624
+ internals.pcState = overrides.pcState ?? 1; // PCState.Connected
625
+ internals.attemptingReconnect = false;
626
+ internals.pcManager = {
627
+ publisher: {
628
+ getSenders: () =>
629
+ overrides.activeSenders === false ? [] : [{ track: { readyState: 'live' } }],
630
+ },
631
+ };
632
+ const handleDisconnect = vi.fn();
633
+ internals.handleDisconnect = handleDisconnect;
634
+ return { engine, internals, handleDisconnect };
635
+ }
636
+
637
+ function qualityUpdate(sid: string, quality: ProtoConnectionQuality) {
638
+ return { updates: [{ participantSid: sid, quality }] };
639
+ }
640
+
641
+ it('forces a full reconnect after a sustained local Lost while publishing', () => {
642
+ const { engine, internals, handleDisconnect } = primeEngine();
643
+
644
+ internals.handleLocalConnectionQuality(qualityUpdate(LOCAL_SID, ProtoConnectionQuality.LOST));
645
+
646
+ // still pending — the reconnect only fires once the timeout elapses
647
+ expect(engine.fullReconnectOnNext).toBe(false);
648
+ expect(handleDisconnect).not.toHaveBeenCalled();
649
+
650
+ vi.advanceTimersByTime(LOST_TIMEOUT_MS);
651
+
652
+ expect(engine.fullReconnectOnNext).toBe(true);
653
+ expect(handleDisconnect).toHaveBeenCalledTimes(1);
654
+ });
655
+
656
+ it('cancels the pending reconnect when quality recovers before the timeout', () => {
657
+ const { engine, internals, handleDisconnect } = primeEngine();
658
+
659
+ internals.handleLocalConnectionQuality(qualityUpdate(LOCAL_SID, ProtoConnectionQuality.LOST));
660
+ vi.advanceTimersByTime(LOST_TIMEOUT_MS / 2);
661
+ internals.handleLocalConnectionQuality(
662
+ qualityUpdate(LOCAL_SID, ProtoConnectionQuality.EXCELLENT),
663
+ );
664
+ vi.advanceTimersByTime(LOST_TIMEOUT_MS);
665
+
666
+ expect(engine.fullReconnectOnNext).toBe(false);
667
+ expect(handleDisconnect).not.toHaveBeenCalled();
668
+ });
669
+
670
+ it('does not reconnect on Lost when there are no active publisher senders', () => {
671
+ const { engine, internals, handleDisconnect } = primeEngine({ activeSenders: false });
672
+
673
+ internals.handleLocalConnectionQuality(qualityUpdate(LOCAL_SID, ProtoConnectionQuality.LOST));
674
+ vi.advanceTimersByTime(LOST_TIMEOUT_MS);
675
+
676
+ expect(engine.fullReconnectOnNext).toBe(false);
677
+ expect(handleDisconnect).not.toHaveBeenCalled();
678
+ });
679
+
680
+ it('does not reconnect on Lost when the pc is not connected', () => {
681
+ const { engine, internals, handleDisconnect } = primeEngine({ pcState: 3 }); // Reconnecting
682
+
683
+ internals.handleLocalConnectionQuality(qualityUpdate(LOCAL_SID, ProtoConnectionQuality.LOST));
684
+ vi.advanceTimersByTime(LOST_TIMEOUT_MS);
685
+
686
+ expect(engine.fullReconnectOnNext).toBe(false);
687
+ expect(handleDisconnect).not.toHaveBeenCalled();
688
+ });
689
+
690
+ it('ignores Lost quality reported for other participants', () => {
691
+ const { engine, internals, handleDisconnect } = primeEngine();
692
+
693
+ internals.handleLocalConnectionQuality(
694
+ qualityUpdate('PA_other', ProtoConnectionQuality.LOST),
695
+ );
696
+ vi.advanceTimersByTime(LOST_TIMEOUT_MS);
697
+
698
+ expect(engine.fullReconnectOnNext).toBe(false);
699
+ expect(handleDisconnect).not.toHaveBeenCalled();
700
+ });
701
+ });
702
+
703
+ describe('reconnect requested mid-attempt', () => {
704
+ // A full reconnect requested while a resume is already in flight (e.g. a server
705
+ // RECONNECT leave racing the resume) sets `fullReconnectOnNext` mid-attempt. A
706
+ // successful resume must not swallow it: it survives and is dispatched afterwards.
707
+ interface ReconnectInternals {
708
+ _isClosed: boolean;
709
+ attemptingReconnect: boolean;
710
+ clientConfiguration: unknown;
711
+ pcManager: unknown;
712
+ resumeConnection: (reason?: number) => Promise<void>;
713
+ restartConnection: (regionUrl?: string) => Promise<void>;
714
+ clearPendingReconnect: () => void;
715
+ handleDisconnect: (connection: string, reason?: number) => void;
716
+ attemptReconnect: (reason?: number) => Promise<void>;
717
+ }
718
+
719
+ function primeEngine() {
720
+ const engine = new RTCEngine(roomOptionDefaults);
721
+ const internals = engine as unknown as ReconnectInternals;
722
+ internals._isClosed = false;
723
+ internals.attemptingReconnect = false;
724
+ // avoid the "resume disabled / pcManager is NEW -> force full reconnect" escalation
725
+ internals.clientConfiguration = undefined;
726
+ internals.pcManager = { currentState: PCTransportState.CONNECTED };
727
+ internals.clearPendingReconnect = vi.fn();
728
+ const handleDisconnect = vi.fn();
729
+ internals.handleDisconnect = handleDisconnect;
730
+ const restartConnection = vi.fn(async () => {});
731
+ internals.restartConnection = restartConnection;
732
+ return { engine, internals, handleDisconnect, restartConnection };
733
+ }
734
+
735
+ it('dispatches a full reconnect when a resume succeeds but one was requested mid-attempt', async () => {
736
+ const { engine, internals, handleDisconnect, restartConnection } = primeEngine();
737
+ engine.fullReconnectOnNext = false;
738
+ // the resume succeeds, but a RECONNECT leave arrives while it is in flight
739
+ internals.resumeConnection = vi.fn(async () => {
740
+ engine.fullReconnectOnNext = true;
741
+ });
742
+
743
+ await internals.attemptReconnect();
744
+
745
+ expect(internals.resumeConnection).toHaveBeenCalledTimes(1);
746
+ expect(restartConnection).not.toHaveBeenCalled();
747
+ // the mid-attempt request survived the successful resume and was dispatched
748
+ expect(engine.fullReconnectOnNext).toBe(true);
749
+ expect(handleDisconnect).toHaveBeenCalledTimes(1);
750
+ expect(handleDisconnect).toHaveBeenCalledWith('reconnect');
751
+ });
752
+
753
+ it('does not dispatch a follow-up after an ordinary successful resume', async () => {
754
+ const { engine, internals, handleDisconnect, restartConnection } = primeEngine();
755
+ engine.fullReconnectOnNext = false;
756
+ internals.resumeConnection = vi.fn(async () => {});
757
+
758
+ await internals.attemptReconnect();
759
+
760
+ expect(internals.resumeConnection).toHaveBeenCalledTimes(1);
761
+ expect(restartConnection).not.toHaveBeenCalled();
762
+ expect(engine.fullReconnectOnNext).toBe(false);
763
+ expect(handleDisconnect).not.toHaveBeenCalled();
764
+ });
765
+
766
+ it('clears the flag and does not re-dispatch after a successful full reconnect', async () => {
767
+ const { engine, internals, handleDisconnect, restartConnection } = primeEngine();
768
+ engine.fullReconnectOnNext = true; // enters as a full reconnect
769
+ internals.resumeConnection = vi.fn(async () => {});
770
+
771
+ await internals.attemptReconnect();
772
+
773
+ expect(restartConnection).toHaveBeenCalledTimes(1);
774
+ expect(internals.resumeConnection).not.toHaveBeenCalled();
775
+ expect(engine.fullReconnectOnNext).toBe(false);
776
+ expect(handleDisconnect).not.toHaveBeenCalled();
777
+ });
778
+
779
+ it('dispatches a follow-up when a full reconnect succeeds but one was requested mid-attempt', async () => {
780
+ const { engine, internals, handleDisconnect, restartConnection } = primeEngine();
781
+ engine.fullReconnectOnNext = true; // enters as a full reconnect
782
+ // a new RECONNECT request arrives while restartConnection is running
783
+ restartConnection.mockImplementationOnce(async () => {
784
+ engine.fullReconnectOnNext = true;
785
+ });
786
+
787
+ await internals.attemptReconnect();
788
+
789
+ expect(restartConnection).toHaveBeenCalledTimes(1);
790
+ // the mid-restart request survived the successful full reconnect and was dispatched
791
+ expect(engine.fullReconnectOnNext).toBe(true);
792
+ expect(handleDisconnect).toHaveBeenCalledTimes(1);
793
+ expect(handleDisconnect).toHaveBeenCalledWith('reconnect');
794
+ });
795
+
796
+ it('does not add a dispatch on top of the failure path retry', async () => {
797
+ const { engine, internals, handleDisconnect } = primeEngine();
798
+ engine.fullReconnectOnNext = false;
799
+ // resume fails after a mid-attempt request; the catch path schedules the retry
800
+ internals.resumeConnection = vi.fn(async () => {
801
+ engine.fullReconnectOnNext = true;
802
+ throw new Error('resume failed');
803
+ });
804
+
805
+ await internals.attemptReconnect();
806
+
807
+ // exactly one dispatch (from the catch), not a second one from the finally
808
+ expect(handleDisconnect).toHaveBeenCalledTimes(1);
809
+ });
810
+ });
811
+
812
+ describe('verifyTransport stuck-connecting bound', () => {
813
+ interface VerifyInternals {
814
+ pcManager: unknown;
815
+ client: unknown;
816
+ transportConnectingSince?: number;
817
+ }
818
+
819
+ function primeEngine(currentState: PCTransportState) {
820
+ const engine = new RTCEngine(roomOptionDefaults);
821
+ const internals = engine as unknown as VerifyInternals;
822
+ internals.pcManager = { currentState };
823
+ internals.client = { ws: { readyState: WebSocket.OPEN } };
824
+ return { engine, internals };
825
+ }
826
+
827
+ it('reports the transport stuck when connecting longer than peerConnectionTimeout', () => {
828
+ const { engine, internals } = primeEngine(PCTransportState.CONNECTING);
829
+ internals.transportConnectingSince = Date.now() - (engine.peerConnectionTimeout + 1_000);
830
+
831
+ expect(engine.verifyTransport()).toBe(false);
832
+ });
833
+
834
+ it('tolerates a transport still within the connecting window', () => {
835
+ const { engine, internals } = primeEngine(PCTransportState.CONNECTING);
836
+ internals.transportConnectingSince = Date.now();
837
+
838
+ expect(engine.verifyTransport()).toBe(true);
839
+ });
840
+
841
+ it('fails open (and does not record a timestamp) when connecting is untracked', () => {
842
+ // verifyTransport is a pure read now: an unrecorded CONNECTING must not be treated as
843
+ // stuck, and the method must not seed a timestamp that could later leak across teardown.
844
+ const { engine, internals } = primeEngine(PCTransportState.CONNECTING);
845
+ internals.transportConnectingSince = undefined;
846
+
847
+ expect(engine.verifyTransport()).toBe(true);
848
+ expect(internals.transportConnectingSince).toBeUndefined();
849
+ });
850
+
851
+ it('does not measure a stale connecting timestamp while connected', () => {
852
+ const { engine, internals } = primeEngine(PCTransportState.CONNECTED);
853
+ // a leftover timestamp must not affect the CONNECTED verdict, and stays for the
854
+ // state-change handler to clear rather than being mutated here
855
+ internals.transportConnectingSince = Date.now() - 10 * engine.peerConnectionTimeout;
856
+
857
+ expect(engine.verifyTransport()).toBe(true);
858
+ });
859
+ });
860
+
861
+ describe('Lost-quality countdown across reconnects', () => {
862
+ // A Lost-quality countdown armed by the previous session must not survive a reconnect
863
+ // and fire against the new session before the server has re-evaluated it.
864
+ const LOST_TIMEOUT_MS = 5_000;
865
+
866
+ beforeEach(() => {
867
+ vi.useFakeTimers();
868
+ });
869
+
870
+ afterEach(() => {
871
+ vi.useRealTimers();
872
+ });
873
+
874
+ it('cancels a pending Lost countdown when a reconnect attempt begins', async () => {
875
+ const engine = new RTCEngine(roomOptionDefaults);
876
+ const internals = engine as unknown as {
877
+ _isClosed: boolean;
878
+ participantSid: string;
879
+ pcState: number;
880
+ attemptingReconnect: boolean;
881
+ clientConfiguration: unknown;
882
+ pcManager: unknown;
883
+ lostQualityTimeout?: ReturnType<typeof setTimeout>;
884
+ resumeConnection: (reason?: number) => Promise<void>;
885
+ restartConnection: () => Promise<void>;
886
+ clearPendingReconnect: () => void;
887
+ handleDisconnect: (connection: string, reason?: number) => void;
888
+ handleLocalConnectionQuality: (update: unknown) => void;
889
+ attemptReconnect: (reason?: number) => Promise<void>;
890
+ };
891
+ internals._isClosed = false;
892
+ internals.participantSid = 'PA_local';
893
+ internals.pcState = 1; // PCState.Connected — the guards the countdown checks would pass
894
+ internals.attemptingReconnect = false;
895
+ internals.clientConfiguration = undefined;
896
+ internals.pcManager = {
897
+ currentState: PCTransportState.CONNECTED,
898
+ publisher: { getSenders: () => [{ track: { readyState: 'live' } }] },
899
+ };
900
+ internals.clearPendingReconnect = vi.fn();
901
+ const handleDisconnect = vi.fn();
902
+ internals.handleDisconnect = handleDisconnect;
903
+ internals.resumeConnection = vi.fn(async () => {});
904
+ internals.restartConnection = vi.fn(async () => {});
905
+
906
+ // a LOST verdict from the (soon-to-be-previous) session arms the countdown
907
+ internals.handleLocalConnectionQuality({
908
+ updates: [{ participantSid: 'PA_local', quality: ProtoConnectionQuality.LOST }],
909
+ });
910
+ expect(internals.lostQualityTimeout).toBeDefined();
911
+
912
+ // a reconnect begins and completes (resume) before the countdown elapses
913
+ engine.fullReconnectOnNext = false;
914
+ await internals.attemptReconnect();
915
+
916
+ // the stale countdown was cancelled and cannot fire against the reconnected session
917
+ expect(internals.lostQualityTimeout).toBeUndefined();
918
+ vi.advanceTimersByTime(LOST_TIMEOUT_MS);
919
+ expect(handleDisconnect).not.toHaveBeenCalled();
920
+ });
921
+ });
626
922
  });