livekit-client 2.21.0 → 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 (70) 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 +2 -1
  5. package/dist/livekit-client.e2ee.worker.mjs.map +1 -1
  6. package/dist/livekit-client.esm.mjs +480 -150
  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 +2 -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/room/PCTransport.d.ts +49 -2
  15. package/dist/src/room/PCTransport.d.ts.map +1 -1
  16. package/dist/src/room/RTCEngine.d.ts +20 -1
  17. package/dist/src/room/RTCEngine.d.ts.map +1 -1
  18. package/dist/src/room/Room.d.ts +2 -0
  19. package/dist/src/room/Room.d.ts.map +1 -1
  20. package/dist/src/room/errors.d.ts.map +1 -1
  21. package/dist/src/room/participant/LocalParticipant.d.ts +1 -1
  22. package/dist/src/room/token-source/TokenSource.d.ts +19 -10
  23. package/dist/src/room/token-source/TokenSource.d.ts.map +1 -1
  24. package/dist/src/room/track/LocalVideoTrack.d.ts +12 -1
  25. package/dist/src/room/track/LocalVideoTrack.d.ts.map +1 -1
  26. package/dist/src/room/track/RemoteTrack.d.ts +3 -0
  27. package/dist/src/room/track/RemoteTrack.d.ts.map +1 -1
  28. package/dist/src/room/track/Track.d.ts.map +1 -1
  29. package/dist/src/room/utils.d.ts +15 -0
  30. package/dist/src/room/utils.d.ts.map +1 -1
  31. package/dist/src/test/promiseState.d.ts +12 -0
  32. package/dist/src/test/promiseState.d.ts.map +1 -0
  33. package/dist/src/test/signalToken.d.ts.map +1 -1
  34. package/dist/src/utils/AsyncQueue.d.ts +3 -3
  35. package/dist/src/utils/AsyncQueue.d.ts.map +1 -1
  36. package/dist/ts4.2/room/PCTransport.d.ts +49 -2
  37. package/dist/ts4.2/room/RTCEngine.d.ts +20 -1
  38. package/dist/ts4.2/room/Room.d.ts +2 -0
  39. package/dist/ts4.2/room/participant/LocalParticipant.d.ts +1 -1
  40. package/dist/ts4.2/room/token-source/TokenSource.d.ts +17 -8
  41. package/dist/ts4.2/room/track/LocalVideoTrack.d.ts +12 -1
  42. package/dist/ts4.2/room/track/RemoteTrack.d.ts +3 -0
  43. package/dist/ts4.2/room/utils.d.ts +15 -0
  44. package/dist/ts4.2/test/promiseState.d.ts +12 -0
  45. package/dist/ts4.2/utils/AsyncQueue.d.ts +3 -3
  46. package/package.json +13 -12
  47. package/src/api/SignalClient.e2e.test.ts +16 -9
  48. package/src/e2ee/utils.ts +1 -1
  49. package/src/room/PCTransport.test.ts +243 -1
  50. package/src/room/PCTransport.ts +181 -80
  51. package/src/room/RTCEngine.test.ts +339 -2
  52. package/src/room/RTCEngine.ts +152 -11
  53. package/src/room/Room.test.ts +134 -3
  54. package/src/room/Room.ts +52 -17
  55. package/src/room/data-stream/incoming/IncomingDataStreamManager.ts +1 -1
  56. package/src/room/data-stream/incoming/StreamReader.ts +1 -1
  57. package/src/room/errors.ts +1 -2
  58. package/src/room/token-source/TokenSource.ts +25 -12
  59. package/src/room/track/LocalVideoTrack.test.ts +105 -2
  60. package/src/room/track/LocalVideoTrack.ts +36 -10
  61. package/src/room/track/RemoteTrack.test.ts +144 -0
  62. package/src/room/track/RemoteTrack.ts +39 -12
  63. package/src/room/track/Track.ts +2 -1
  64. package/src/room/utils.test.ts +71 -2
  65. package/src/room/utils.ts +52 -0
  66. package/src/test/promiseState.ts +23 -0
  67. package/src/test/signalServerSetup.ts +2 -1
  68. package/src/test/signalToken.ts +17 -13
  69. package/src/type-polyfills/header-extensions.d.ts +13 -0
  70. package/src/utils/AsyncQueue.ts +3 -3
@@ -36,6 +36,63 @@ const maxStartBitrateKbps = 1000;
36
36
 
37
37
  const debounceInterval = 20;
38
38
 
39
+ /**
40
+ * Applies the configured start bitrate when this media section belongs to `cid`.
41
+ * This SDP munging is used for a bitrate setting that cannot be applied through
42
+ * `RTCRtpEncodingParameters`.
43
+ *
44
+ * Returns `undefined` when the section does not belong to the track, `0` when
45
+ * it does but does not offer the requested codec, and the codec payload when the
46
+ * requested codec is present (whether the bitrate was added or already set).
47
+ *
48
+ * @internal
49
+ */
50
+ export function applyVideoStartBitrate(
51
+ media: MediaDescription,
52
+ cid: string,
53
+ codec: string,
54
+ maxbr: number,
55
+ isScreenShare = false,
56
+ ): number | undefined {
57
+ if (!media.msid?.includes(cid)) {
58
+ return undefined;
59
+ }
60
+
61
+ const codecPayload =
62
+ media.rtp.find((rtp) => rtp.codec.toUpperCase() === codec.toUpperCase())?.payload ?? 0;
63
+ if (codecPayload === 0) {
64
+ return 0;
65
+ }
66
+
67
+ // Use 90% of target bitrate, capped at 1 Mbps for camera to prevent BWE
68
+ // from starting too aggressively. Screen share is not capped since text/UI
69
+ // clarity requires high bitrate from the start.
70
+ // TODO: dynamically adjust start bitrate based on network conditions (e.g., previous BWE estimate)
71
+ const calculatedStartBitrate = Math.round(maxbr * startBitrateMultiplier);
72
+ const startBitrate = isScreenShare
73
+ ? calculatedStartBitrate
74
+ : Math.min(calculatedStartBitrate, maxStartBitrateKbps);
75
+
76
+ const fmtp = media.fmtp.find((entry) => entry.payload === codecPayload);
77
+ if (fmtp) {
78
+ // If another track's fmtp already has a start bitrate, it cannot be
79
+ // overridden here because the payload type is shared across the bundle.
80
+ // This forces every track sharing that payload to use the initial track's
81
+ // start bitrate.
82
+ if (!fmtp.config.includes('x-google-start-bitrate')) {
83
+ fmtp.config += `;x-google-start-bitrate=${startBitrate}`;
84
+ }
85
+ } else {
86
+ // VP8 and some codecs may not have an existing fmtp line.
87
+ media.fmtp.push({
88
+ payload: codecPayload,
89
+ config: `x-google-start-bitrate=${startBitrate}`,
90
+ });
91
+ }
92
+
93
+ return codecPayload;
94
+ }
95
+
39
96
  export const PCEvents = {
40
97
  NegotiationStarted: 'negotiationStarted',
41
98
  NegotiationComplete: 'negotiationComplete',
@@ -368,9 +425,15 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
368
425
  // the only exception to this is when ICE restart is needed
369
426
  const currentSD = this._pc.remoteDescription;
370
427
  if (options?.iceRestart && currentSD) {
371
- // TODO: handle when ICE restart is needed but we don't have a remote description
372
- // the best thing to do is to recreate the peerconnection
428
+ // roll the remote description back in so createOffer produces a valid
429
+ // ICE-restart offer on top of the already-negotiated state
373
430
  await this._pc.setRemoteDescription(currentSD);
431
+ } else if (options?.iceRestart) {
432
+ // ICE restart with no remote description to restart on: `renegotiate` would stall
433
+ // (the pending offer is never answered), so throw for the caller to recreate the PC.
434
+ throw new NegotiationError(
435
+ 'ICE restart requested without a remote description, peer connection must be recreated',
436
+ );
374
437
  } else {
375
438
  this.renegotiate = true;
376
439
  this.log.debug('requesting renegotiation');
@@ -396,56 +459,25 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
396
459
  ensureAudioNackAndStereo(media, ['all'], []);
397
460
  } else if (media.type === 'video') {
398
461
  this.trackBitrates.some((trackbr): boolean => {
399
- if (!media.msid || !trackbr.cid || !media.msid.includes(trackbr.cid)) {
462
+ if (!trackbr.cid) {
400
463
  return false;
401
464
  }
402
465
 
403
- let codecPayload = 0;
404
- media.rtp.some((rtp): boolean => {
405
- if (rtp.codec.toUpperCase() === trackbr.codec.toUpperCase()) {
406
- codecPayload = rtp.payload;
407
- return true;
408
- }
466
+ const codecPayload = applyVideoStartBitrate(
467
+ media,
468
+ trackbr.cid,
469
+ trackbr.codec,
470
+ trackbr.maxbr,
471
+ trackbr.isScreenShare,
472
+ );
473
+ if (codecPayload === undefined) {
409
474
  return false;
410
- });
411
-
412
- if (codecPayload === 0) {
413
- return true;
414
475
  }
415
476
 
416
- if (isSVCCodec(trackbr.codec) && !isSafari()) {
417
- this.ensureVideoDDExtensionForSVC(media, sdpParsed);
477
+ if (codecPayload > 0 && isSVCCodec(trackbr.codec) && !isSafari()) {
478
+ this.ddExtID = ensureVideoDDExtension(media, sdpParsed, this.ddExtID);
418
479
  }
419
480
 
420
- // mung sdp for bitrate setting that can't apply by sendEncoding
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);
428
-
429
- let fmtpFound = false;
430
- for (const fmtp of media.fmtp) {
431
- if (fmtp.payload === codecPayload) {
432
- fmtpFound = true;
433
- // if another track's fmtp already is set, we cannot override the bitrate
434
- // this has the unfortunate consequence of being forced to use the
435
- // initial track's bitrate for all tracks
436
- if (!fmtp.config.includes('x-google-start-bitrate')) {
437
- fmtp.config += `;x-google-start-bitrate=${startBitrate}`;
438
- }
439
- break;
440
- }
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
- }
449
481
  return true;
450
482
  });
451
483
  }
@@ -681,44 +713,110 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
681
713
  throw new NegotiationError(msg);
682
714
  }
683
715
  }
716
+ }
684
717
 
685
- private ensureVideoDDExtensionForSVC(
686
- media: {
687
- type: string;
688
- port: number;
689
- protocol: string;
690
- payloads?: string | undefined;
691
- } & MediaDescription,
692
- sdp: SessionDescription,
693
- ) {
694
- const ddFound = media.ext?.some((ext): boolean => {
695
- if (ext.uri === ddExtensionURI) {
696
- return true;
697
- }
698
- return false;
718
+ /**
719
+ * Adds the AV1 dependency descriptor extension to `media` unless it is already there, and
720
+ * returns the id it is mapped to so callers can pass it back in as `ddExtID` (0 when no id has
721
+ * been chosen yet).
722
+ *
723
+ * A bundle has to map one URI to one id, so an id already in use for the extension anywhere in
724
+ * `sdp` wins over both the cached one and a fresh one: Chrome advertises the extension itself on
725
+ * sections it can send on, and an earlier offer may have munged it into others.
726
+ * @internal
727
+ */
728
+ export function ensureVideoDDExtension(
729
+ media: {
730
+ type: string;
731
+ port: number;
732
+ protocol: string;
733
+ payloads?: string | undefined;
734
+ } & MediaDescription,
735
+ sdp: SessionDescription,
736
+ ddExtID: number,
737
+ ): number {
738
+ const id = ddExtensionIDFor(sdp, ddExtID);
739
+ if (id === undefined) {
740
+ return ddExtID;
741
+ }
742
+
743
+ if (!media.ext?.some((ext) => ext.uri === ddExtensionURI)) {
744
+ media.ext ??= [];
745
+ media.ext.push({
746
+ value: id,
747
+ uri: ddExtensionURI,
699
748
  });
749
+ }
750
+ return id;
751
+ }
700
752
 
701
- if (!ddFound) {
702
- if (this.ddExtID === 0) {
703
- let maxID = 0;
704
- sdp.media.forEach((m) => {
705
- m.ext?.forEach((ext) => {
706
- if (ext.value > maxID) {
707
- maxID = ext.value;
708
- }
709
- });
710
- });
711
- this.ddExtID = maxID + 1;
712
- }
713
- media.ext?.push({
714
- value: this.ddExtID,
715
- uri: ddExtensionURI,
716
- });
753
+ /**
754
+ * The id to map the dependency descriptor to throughout `sdp`, or undefined when no id would be
755
+ * consistent for the whole bundle and the extension therefore has to be left out.
756
+ */
757
+ function ddExtensionIDFor(sdp: SessionDescription, cachedID: number): number | undefined {
758
+ const mapped = mappedExtensionID(sdp, ddExtensionURI);
759
+ if (mapped !== undefined) {
760
+ // Adopting an id that also stands for another URI is what the browser rejects the bundle
761
+ // over, and its own half of the map is not ours to renumber, so give up on this offer.
762
+ return usedForOtherURI(sdp, mapped, ddExtensionURI) ? undefined : mapped;
763
+ }
764
+ // Reusing the id from the last offer keeps the mapping stable across renegotiations, but only
765
+ // while nothing else has taken it: the browser assigns ids to its own extensions without
766
+ // knowing about ours, so an id that was free when we picked it can since have been claimed —
767
+ // typically by the fuller extension set that arrives with the first section we send on.
768
+ if (cachedID !== 0 && !usedForOtherURI(sdp, cachedID, ddExtensionURI)) {
769
+ return cachedID;
770
+ }
771
+ return unusedExtensionID(sdp);
772
+ }
773
+
774
+ /** The id `uri` is mapped to in `sdp`, if any section maps it. */
775
+ function mappedExtensionID(sdp: SessionDescription, uri: string): number | undefined {
776
+ for (const media of sdp.media) {
777
+ const ext = media.ext?.find((candidate) => candidate.uri === uri);
778
+ if (ext) {
779
+ return ext.value;
717
780
  }
718
781
  }
782
+ return undefined;
783
+ }
784
+
785
+ /** Whether `id` stands for anything in `sdp` other than `uri`. */
786
+ function usedForOtherURI(sdp: SessionDescription, id: number, uri: string): boolean {
787
+ return sdp.media.some((media) => media.ext?.some((ext) => ext.value === id && ext.uri !== uri));
788
+ }
789
+
790
+ /**
791
+ * An id no extension in `sdp` uses. Stays above every id in use rather than filling gaps, so it
792
+ * is less likely to be an id the browser goes on to allocate to another extension, and steps
793
+ * over 15, which RFC 8285 reserves.
794
+ */
795
+ function unusedExtensionID(sdp: SessionDescription): number {
796
+ let maxID = 0;
797
+ sdp.media.forEach((media) => {
798
+ media.ext?.forEach((ext) => {
799
+ if (ext.value > maxID) {
800
+ maxID = ext.value;
801
+ }
802
+ });
803
+ });
804
+ return maxID + 1 === 15 ? 16 : maxID + 1;
719
805
  }
720
806
 
721
- function ensureAudioNackAndStereo(
807
+ /**
808
+ * Checks whether an fmtp config declares `param` as an exact, `;`-delimited
809
+ * token. A plain substring check conflates distinct opus parameters — e.g.
810
+ * `stereo=1` is a substring of `sprop-stereo=1` — so `param` must match a whole
811
+ * parameter, not appear anywhere within the config string.
812
+ * @internal
813
+ */
814
+ export function fmtpConfigHasParam(config: string, param: string): boolean {
815
+ return config.split(';').some((entry) => entry.trim() === param);
816
+ }
817
+
818
+ /** @internal */
819
+ export function ensureAudioNackAndStereo(
722
820
  media: {
723
821
  type: string;
724
822
  port: number;
@@ -734,7 +832,8 @@ function ensureAudioNackAndStereo(
734
832
  // found opus codec to add nack fb
735
833
  let opusPayload = 0;
736
834
  media.rtp.some((rtp): boolean => {
737
- if (rtp.codec === 'opus') {
835
+ // rtpmap encoding names are case-insensitive (RFC 4855)
836
+ if (rtp.codec.toLowerCase() === 'opus') {
738
837
  opusPayload = rtp.payload;
739
838
  return true;
740
839
  }
@@ -760,7 +859,7 @@ function ensureAudioNackAndStereo(
760
859
  if (stereoMids.includes(mid) || (stereoMids.length === 1 && stereoMids[0] === 'all')) {
761
860
  media.fmtp.some((fmtp): boolean => {
762
861
  if (fmtp.payload === opusPayload) {
763
- if (!fmtp.config.includes('stereo=1')) {
862
+ if (!fmtpConfigHasParam(fmtp.config, 'stereo=1')) {
764
863
  fmtp.config += ';stereo=1';
765
864
  }
766
865
  return true;
@@ -851,7 +950,8 @@ export function conformBundledCodecFmtp(
851
950
  }
852
951
  }
853
952
 
854
- function extractStereoAndNackAudioFromOffer(offer: RTCSessionDescriptionInit): {
953
+ /** @internal */
954
+ export function extractStereoAndNackAudioFromOffer(offer: RTCSessionDescriptionInit): {
855
955
  stereoMids: string[];
856
956
  nackMids: string[];
857
957
  } {
@@ -863,7 +963,8 @@ function extractStereoAndNackAudioFromOffer(offer: RTCSessionDescriptionInit): {
863
963
  const mid = getMidString(media.mid!);
864
964
  if (media.type === 'audio') {
865
965
  media.rtp.some((rtp): boolean => {
866
- if (rtp.codec === 'opus') {
966
+ // rtpmap encoding names are case-insensitive (RFC 4855)
967
+ if (rtp.codec.toLowerCase() === 'opus') {
867
968
  opusPayload = rtp.payload;
868
969
  return true;
869
970
  }
@@ -876,7 +977,7 @@ function extractStereoAndNackAudioFromOffer(offer: RTCSessionDescriptionInit): {
876
977
 
877
978
  media.fmtp.some((fmtp): boolean => {
878
979
  if (fmtp.payload === opusPayload) {
879
- if (fmtp.config.includes('sprop-stereo=1')) {
980
+ if (fmtpConfigHasParam(fmtp.config, 'sprop-stereo=1')) {
880
981
  stereoMids.push(mid);
881
982
  }
882
983
  return true;
@@ -1,6 +1,12 @@
1
- import { DataPacket, DataPacket_Kind, UserPacket } from '@livekit/protocol';
2
- import { afterEach, describe, expect, it, vi } from 'vitest';
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';
3
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';
@@ -582,4 +588,335 @@ describe('RTCEngine', () => {
582
588
  expect(error).not.toHaveBeenCalled();
583
589
  });
584
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
+ });
585
922
  });