livekit-client 2.21.0 → 2.22.1

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 (116) 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 +189 -2
  5. package/dist/livekit-client.e2ee.worker.mjs.map +1 -1
  6. package/dist/livekit-client.esm.mjs +2243 -270
  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 +189 -2
  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/api/SignalClient.d.ts +25 -1
  15. package/dist/src/api/SignalClient.d.ts.map +1 -1
  16. package/dist/src/api/SignalClientStateMachine.d.ts +85 -0
  17. package/dist/src/api/SignalClientStateMachine.d.ts.map +1 -0
  18. package/dist/src/api/WebSocketStream.d.ts.map +1 -1
  19. package/dist/src/api/utils.d.ts.map +1 -1
  20. package/dist/src/index.d.ts +3 -2
  21. package/dist/src/index.d.ts.map +1 -1
  22. package/dist/src/logger.d.ts +2 -1
  23. package/dist/src/logger.d.ts.map +1 -1
  24. package/dist/src/options.d.ts +22 -0
  25. package/dist/src/options.d.ts.map +1 -1
  26. package/dist/src/room/PCTransport.d.ts +51 -3
  27. package/dist/src/room/PCTransport.d.ts.map +1 -1
  28. package/dist/src/room/RTCEngine.d.ts +27 -3
  29. package/dist/src/room/RTCEngine.d.ts.map +1 -1
  30. package/dist/src/room/Room.d.ts +10 -1
  31. package/dist/src/room/Room.d.ts.map +1 -1
  32. package/dist/src/room/errors.d.ts.map +1 -1
  33. package/dist/src/room/events.d.ts +1 -1
  34. package/dist/src/room/participant/LocalParticipant.d.ts +1 -1
  35. package/dist/src/room/statsSummary.d.ts +13 -0
  36. package/dist/src/room/statsSummary.d.ts.map +1 -0
  37. package/dist/src/room/token-source/TokenSource.d.ts +19 -10
  38. package/dist/src/room/token-source/TokenSource.d.ts.map +1 -1
  39. package/dist/src/room/token-source/utils.d.ts.map +1 -1
  40. package/dist/src/room/track/LocalAudioTrack.d.ts.map +1 -1
  41. package/dist/src/room/track/LocalVideoTrack.d.ts +12 -1
  42. package/dist/src/room/track/LocalVideoTrack.d.ts.map +1 -1
  43. package/dist/src/room/track/RemoteTrack.d.ts +3 -0
  44. package/dist/src/room/track/RemoteTrack.d.ts.map +1 -1
  45. package/dist/src/room/track/Track.d.ts +10 -0
  46. package/dist/src/room/track/Track.d.ts.map +1 -1
  47. package/dist/src/room/utils.d.ts +15 -0
  48. package/dist/src/room/utils.d.ts.map +1 -1
  49. package/dist/src/test/promiseState.d.ts +12 -0
  50. package/dist/src/test/promiseState.d.ts.map +1 -0
  51. package/dist/src/test/signalToken.d.ts.map +1 -1
  52. package/dist/src/utils/AsyncQueue.d.ts +3 -3
  53. package/dist/src/utils/AsyncQueue.d.ts.map +1 -1
  54. package/dist/src/utils/machineInspector.d.ts +54 -0
  55. package/dist/src/utils/machineInspector.d.ts.map +1 -0
  56. package/dist/ts4.2/api/SignalClient.d.ts +25 -1
  57. package/dist/ts4.2/api/SignalClientStateMachine.d.ts +85 -0
  58. package/dist/ts4.2/index.d.ts +3 -2
  59. package/dist/ts4.2/logger.d.ts +2 -1
  60. package/dist/ts4.2/options.d.ts +22 -0
  61. package/dist/ts4.2/room/PCTransport.d.ts +51 -3
  62. package/dist/ts4.2/room/RTCEngine.d.ts +27 -3
  63. package/dist/ts4.2/room/Room.d.ts +10 -1
  64. package/dist/ts4.2/room/events.d.ts +1 -1
  65. package/dist/ts4.2/room/participant/LocalParticipant.d.ts +1 -1
  66. package/dist/ts4.2/room/statsSummary.d.ts +13 -0
  67. package/dist/ts4.2/room/token-source/TokenSource.d.ts +17 -8
  68. package/dist/ts4.2/room/track/LocalVideoTrack.d.ts +12 -1
  69. package/dist/ts4.2/room/track/RemoteTrack.d.ts +3 -0
  70. package/dist/ts4.2/room/track/Track.d.ts +10 -0
  71. package/dist/ts4.2/room/utils.d.ts +15 -0
  72. package/dist/ts4.2/test/promiseState.d.ts +12 -0
  73. package/dist/ts4.2/utils/AsyncQueue.d.ts +3 -3
  74. package/dist/ts4.2/utils/machineInspector.d.ts +54 -0
  75. package/package.json +19 -12
  76. package/src/api/SignalClient.e2e.test.ts +16 -9
  77. package/src/api/SignalClient.test.ts +320 -8
  78. package/src/api/SignalClient.ts +260 -82
  79. package/src/api/SignalClientStateMachine.test.ts +472 -0
  80. package/src/api/SignalClientStateMachine.ts +180 -0
  81. package/src/api/WebSocketStream.ts +19 -3
  82. package/src/api/utils.test.ts +20 -1
  83. package/src/api/utils.ts +5 -0
  84. package/src/e2ee/utils.ts +1 -1
  85. package/src/index.ts +5 -0
  86. package/src/logger.ts +1 -0
  87. package/src/options.ts +24 -0
  88. package/src/room/PCTransport.test.ts +243 -1
  89. package/src/room/PCTransport.ts +183 -81
  90. package/src/room/RTCEngine.test.ts +339 -2
  91. package/src/room/RTCEngine.ts +168 -18
  92. package/src/room/Room.test.ts +134 -3
  93. package/src/room/Room.ts +122 -22
  94. package/src/room/data-stream/incoming/IncomingDataStreamManager.ts +1 -1
  95. package/src/room/data-stream/incoming/StreamReader.ts +1 -1
  96. package/src/room/errors.ts +1 -2
  97. package/src/room/events.ts +1 -1
  98. package/src/room/statsSummary.ts +187 -0
  99. package/src/room/token-source/TokenSource.ts +25 -12
  100. package/src/room/token-source/test-tokens.ts +20 -0
  101. package/src/room/token-source/utils.test.ts +27 -0
  102. package/src/room/token-source/utils.ts +12 -5
  103. package/src/room/track/LocalAudioTrack.ts +9 -3
  104. package/src/room/track/LocalVideoTrack.test.ts +105 -2
  105. package/src/room/track/LocalVideoTrack.ts +36 -10
  106. package/src/room/track/RemoteTrack.test.ts +144 -0
  107. package/src/room/track/RemoteTrack.ts +39 -12
  108. package/src/room/track/Track.ts +29 -1
  109. package/src/room/utils.test.ts +94 -2
  110. package/src/room/utils.ts +53 -1
  111. package/src/test/promiseState.ts +23 -0
  112. package/src/test/signalServerSetup.ts +2 -1
  113. package/src/test/signalToken.ts +17 -13
  114. package/src/type-polyfills/header-extensions.d.ts +13 -0
  115. package/src/utils/AsyncQueue.ts +3 -3
  116. package/src/utils/machineInspector.ts +90 -0
@@ -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
  });