livekit-client 2.22.4 → 2.23.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 (116) hide show
  1. package/dist/livekit-client.e2ee.worker.js +1 -1
  2. package/dist/livekit-client.e2ee.worker.js.map +1 -1
  3. package/dist/livekit-client.e2ee.worker.mjs +69 -7
  4. package/dist/livekit-client.e2ee.worker.mjs.map +1 -1
  5. package/dist/livekit-client.esm.mjs +1133 -289
  6. package/dist/livekit-client.esm.mjs.map +1 -1
  7. package/dist/livekit-client.fm.worker.js.map +1 -1
  8. package/dist/livekit-client.fm.worker.mjs.map +1 -1
  9. package/dist/livekit-client.umd.js +1 -1
  10. package/dist/livekit-client.umd.js.map +1 -1
  11. package/dist/src/api/SignalClient.d.ts.map +1 -1
  12. package/dist/src/api/WebSocketStream.d.ts +1 -0
  13. package/dist/src/api/WebSocketStream.d.ts.map +1 -1
  14. package/dist/src/api/utils.d.ts +1 -1
  15. package/dist/src/api/utils.d.ts.map +1 -1
  16. package/dist/src/e2ee/E2eeManager.d.ts.map +1 -1
  17. package/dist/src/e2ee/worker/FrameCryptor.d.ts.map +1 -1
  18. package/dist/src/e2ee/worker/naluUtils.d.ts +12 -1
  19. package/dist/src/e2ee/worker/naluUtils.d.ts.map +1 -1
  20. package/dist/src/index.d.ts +2 -2
  21. package/dist/src/index.d.ts.map +1 -1
  22. package/dist/src/room/DefaultReconnectPolicy.d.ts.map +1 -1
  23. package/dist/src/room/PCTransport.d.ts +26 -4
  24. package/dist/src/room/PCTransport.d.ts.map +1 -1
  25. package/dist/src/room/RTCEngine.d.ts +8 -0
  26. package/dist/src/room/RTCEngine.d.ts.map +1 -1
  27. package/dist/src/room/Room.d.ts +53 -0
  28. package/dist/src/room/Room.d.ts.map +1 -1
  29. package/dist/src/room/data-channel/DataChannelManager.d.ts +9 -8
  30. package/dist/src/room/data-channel/DataChannelManager.d.ts.map +1 -1
  31. package/dist/src/room/data-channel/FlowControlledDataChannel.d.ts +25 -12
  32. package/dist/src/room/data-channel/FlowControlledDataChannel.d.ts.map +1 -1
  33. package/dist/src/room/data-channel/LossyDataChannel.d.ts.map +1 -1
  34. package/dist/src/room/data-channel/ReliableDataChannel.d.ts.map +1 -1
  35. package/dist/src/room/data-stream/incoming/IncomingDataStreamManager.d.ts.map +1 -1
  36. package/dist/src/room/participant/LocalParticipant.d.ts +4 -0
  37. package/dist/src/room/participant/LocalParticipant.d.ts.map +1 -1
  38. package/dist/src/room/participant/Participant.d.ts +1 -1
  39. package/dist/src/room/participant/Participant.d.ts.map +1 -1
  40. package/dist/src/room/rpc/utils.d.ts.map +1 -1
  41. package/dist/src/room/track/LocalAudioTrack.d.ts +24 -1
  42. package/dist/src/room/track/LocalAudioTrack.d.ts.map +1 -1
  43. package/dist/src/room/track/LocalTrack.d.ts +18 -0
  44. package/dist/src/room/track/LocalTrack.d.ts.map +1 -1
  45. package/dist/src/room/track/LocalVideoTrack.d.ts +7 -0
  46. package/dist/src/room/track/LocalVideoTrack.d.ts.map +1 -1
  47. package/dist/src/room/track/RemoteAudioTrack.d.ts +6 -0
  48. package/dist/src/room/track/RemoteAudioTrack.d.ts.map +1 -1
  49. package/dist/src/room/track/processor/types.d.ts +11 -0
  50. package/dist/src/room/track/processor/types.d.ts.map +1 -1
  51. package/dist/src/room/track/utils.d.ts.map +1 -1
  52. package/dist/src/room/utils.d.ts +28 -1
  53. package/dist/src/room/utils.d.ts.map +1 -1
  54. package/dist/ts4.2/api/WebSocketStream.d.ts +1 -0
  55. package/dist/ts4.2/api/utils.d.ts +1 -1
  56. package/dist/ts4.2/e2ee/worker/naluUtils.d.ts +12 -1
  57. package/dist/ts4.2/index.d.ts +2 -2
  58. package/dist/ts4.2/room/PCTransport.d.ts +26 -4
  59. package/dist/ts4.2/room/RTCEngine.d.ts +8 -0
  60. package/dist/ts4.2/room/Room.d.ts +53 -0
  61. package/dist/ts4.2/room/data-channel/DataChannelManager.d.ts +9 -8
  62. package/dist/ts4.2/room/data-channel/FlowControlledDataChannel.d.ts +25 -12
  63. package/dist/ts4.2/room/participant/LocalParticipant.d.ts +4 -0
  64. package/dist/ts4.2/room/participant/Participant.d.ts +1 -1
  65. package/dist/ts4.2/room/track/LocalAudioTrack.d.ts +24 -1
  66. package/dist/ts4.2/room/track/LocalTrack.d.ts +18 -0
  67. package/dist/ts4.2/room/track/LocalVideoTrack.d.ts +7 -0
  68. package/dist/ts4.2/room/track/RemoteAudioTrack.d.ts +6 -0
  69. package/dist/ts4.2/room/track/processor/types.d.ts +11 -0
  70. package/dist/ts4.2/room/utils.d.ts +28 -1
  71. package/package.json +1 -1
  72. package/src/api/SignalClient.test.ts +234 -0
  73. package/src/api/SignalClient.ts +58 -23
  74. package/src/api/WebSocketStream.test.ts +11 -0
  75. package/src/api/WebSocketStream.ts +22 -1
  76. package/src/api/utils.ts +1 -1
  77. package/src/e2ee/E2eeManager.test.ts +43 -0
  78. package/src/e2ee/E2eeManager.ts +6 -2
  79. package/src/e2ee/worker/FrameCryptor.test.ts +212 -90
  80. package/src/e2ee/worker/FrameCryptor.ts +21 -4
  81. package/src/e2ee/worker/naluUtils.test.ts +123 -0
  82. package/src/e2ee/worker/naluUtils.ts +57 -2
  83. package/src/index.ts +2 -0
  84. package/src/room/DefaultReconnectPolicy.test.ts +58 -0
  85. package/src/room/DefaultReconnectPolicy.ts +14 -2
  86. package/src/room/PCTransport.test.ts +144 -2
  87. package/src/room/PCTransport.ts +151 -9
  88. package/src/room/RTCEngine.test.ts +234 -2
  89. package/src/room/RTCEngine.ts +102 -7
  90. package/src/room/Room.test.ts +116 -0
  91. package/src/room/Room.ts +286 -74
  92. package/src/room/data-channel/DataChannelManager.test.ts +24 -7
  93. package/src/room/data-channel/DataChannelManager.ts +43 -38
  94. package/src/room/data-channel/FlowControlledDataChannel.test.ts +60 -18
  95. package/src/room/data-channel/FlowControlledDataChannel.ts +40 -22
  96. package/src/room/data-channel/LossyDataChannel.test.ts +38 -0
  97. package/src/room/data-channel/LossyDataChannel.ts +10 -1
  98. package/src/room/data-channel/ReliableDataChannel.test.ts +72 -0
  99. package/src/room/data-channel/ReliableDataChannel.ts +16 -5
  100. package/src/room/data-stream/incoming/IncomingDataStreamManager.test.ts +31 -0
  101. package/src/room/data-stream/incoming/IncomingDataStreamManager.ts +11 -0
  102. package/src/room/participant/LocalParticipant.test.ts +136 -1
  103. package/src/room/participant/LocalParticipant.ts +66 -17
  104. package/src/room/participant/Participant.ts +6 -3
  105. package/src/room/participant/restartOnDefaultAudioDevice.test.ts +177 -0
  106. package/src/room/rpc/utils.test.ts +32 -0
  107. package/src/room/rpc/utils.ts +7 -0
  108. package/src/room/track/LocalAudioTrack.ts +101 -10
  109. package/src/room/track/LocalTrack.ts +70 -13
  110. package/src/room/track/LocalVideoTrack.ts +7 -0
  111. package/src/room/track/RemoteAudioTrack.ts +56 -11
  112. package/src/room/track/processor/types.ts +11 -0
  113. package/src/room/track/utils.test.ts +5 -0
  114. package/src/room/track/utils.ts +29 -5
  115. package/src/room/utils.test.ts +126 -0
  116. package/src/room/utils.ts +84 -21
@@ -28,6 +28,9 @@ interface TrackBitrateInfo {
28
28
  * Why same for all codecs: Target bitrate already accounts for codec efficiency
29
29
  * (e.g., users set lower targets for VP9/AV1 knowing they're more efficient).
30
30
  * Why cap at 1 Mbps: Prevents BWE from starting too aggressively on high bitrate tracks.
31
+ * The cap is lowered further for connections that were slow to set up, see
32
+ * `computeStartBitrateCap`, or that the browser reports as slow, see
33
+ * `connectionEffectiveTypeStartBitrateCap`.
31
34
  */
32
35
  const startBitrateMultiplier = 0.9;
33
36
 
@@ -35,11 +38,35 @@ const startBitrateMultiplier = 0.9;
35
38
  const maxStartBitrateKbps = 1000;
36
39
 
37
40
  /**
38
- * Minimum target bitrate in kbps for the start bitrate hint. Below this, seeding above the
39
- * real capacity costs more than the ramp it saves, so libwebrtc's default is left in place.
41
+ * Minimum x-google-start-bitrate in kbps: libwebrtc's own default starting estimate. A target
42
+ * below this gets no hint, since seeding above the real capacity costs more than the ramp it
43
+ * saves, and a slow connection is seeded no higher than this.
40
44
  */
41
45
  const minTargetBitrateKbps = 300;
42
46
 
47
+ /**
48
+ * Connection setup times (ms) bounding the ramp in `computeStartBitrateCap`: at or below the
49
+ * first the hint is capped at `maxStartBitrateKbps`, at or above the second at
50
+ * `minTargetBitrateKbps`. Measured on shaped links (CLT-3380): healthy links set up in under
51
+ * 860 ms, a 1 Mbps link with 150 ms RTT took 1.1–1.7 s, a 500 kbps link ~2.3 s at the median,
52
+ * and a 300 kbps link never under 3 s. The first sits above the 1 Mbps link, which a lower seed
53
+ * only slows down, and the second above the 500 kbps link's median, so only links that cannot
54
+ * carry more land at the floor.
55
+ */
56
+ const setupTimeForMaxStartBitrateMs = 1500;
57
+ const setupTimeForMinStartBitrateMs = 3500;
58
+
59
+ /** Cap on the start bitrate hint, in kbps, when the browser reports a `3g` connection. */
60
+ const effectiveType3gStartBitrateKbps = 500;
61
+
62
+ /**
63
+ * The part of the Network Information API (`navigator.connection`) read here. It is Chromium
64
+ * only, so TypeScript's DOM typings do not declare it.
65
+ */
66
+ interface NetworkInformation {
67
+ readonly effectiveType?: 'slow-2g' | '2g' | '3g' | '4g';
68
+ }
69
+
43
70
  const debounceInterval = 20;
44
71
 
45
72
  /**
@@ -71,17 +98,84 @@ export function findTrackCodecPayload(
71
98
  * high-bitrate track; screen share is exempt, because its content needs the bitrate
72
99
  * immediately to stay legible.
73
100
  *
74
- * TODO: adjust dynamically from network conditions (e.g. a previous BWE estimate) rather
75
- * than a fixed cap.
101
+ * The cap is lowered for a connection that was slow to set up (`computeStartBitrateCap`), and
102
+ * for one the browser reports as slow (`connectionEffectiveTypeStartBitrateCap`), whichever is
103
+ * lower. Screen share is exempt from both, however slow the connection.
76
104
  *
77
105
  * @internal
78
106
  */
79
- export function computeTrackStartBitrate(trackbr: TrackBitrateInfo): number | undefined {
107
+ export function computeTrackStartBitrate(
108
+ trackbr: TrackBitrateInfo,
109
+ connectionSetupTimeMs?: number,
110
+ ): number | undefined {
80
111
  if (trackbr.maxbr < minTargetBitrateKbps) {
81
112
  return undefined;
82
113
  }
83
- const calculated = Math.round(trackbr.maxbr * startBitrateMultiplier);
84
- return trackbr.isScreenShare ? calculated : Math.min(calculated, maxStartBitrateKbps);
114
+ const calculated = Math.min(Math.round(trackbr.maxbr * startBitrateMultiplier), trackbr.maxbr);
115
+ if (trackbr.isScreenShare) {
116
+ return calculated;
117
+ }
118
+ const cap = Math.min(
119
+ computeStartBitrateCap(connectionSetupTimeMs),
120
+ connectionEffectiveTypeStartBitrateCap(),
121
+ );
122
+ return Math.min(calculated, cap);
123
+ }
124
+
125
+ /**
126
+ * Cap on the start bitrate hint, in kbps, from the browser's effective connection type
127
+ * (`navigator.connection.effectiveType`, Chromium only): `slow-2g` and `2g` cap at
128
+ * `minTargetBitrateKbps`, `3g` at `effectiveType3gStartBitrateKbps`.
129
+ *
130
+ * It catches links that have low bandwidth but low latency, which set up quickly and so slip
131
+ * past the setup time ramp. `4g`, and browsers without the API, give no guidance and stay at the
132
+ * `maxStartBitrateKbps` ceiling, as `computeStartBitrateCap` does without a setup time. The
133
+ * connection RTT the API also reports is not used: it lags behind network changes, and the setup
134
+ * time already reflects latency.
135
+ */
136
+ function connectionEffectiveTypeStartBitrateCap(): number {
137
+ // A `typeof` guard, since `globalThis` is missing from some supported browsers (Chrome < 71,
138
+ // Firefox < 65, Safari < 12.1) and the build does not polyfill it.
139
+ const connection =
140
+ typeof navigator !== 'undefined'
141
+ ? (navigator as Navigator & { connection?: NetworkInformation }).connection
142
+ : undefined;
143
+ switch (connection?.effectiveType) {
144
+ case 'slow-2g':
145
+ case '2g':
146
+ return minTargetBitrateKbps;
147
+ case '3g':
148
+ return effectiveType3gStartBitrateKbps;
149
+ default:
150
+ return maxStartBitrateKbps;
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Cap on the start bitrate hint, in kbps, from how long the connection took to set up.
156
+ *
157
+ * Connection setup time (signaling join plus ICE/DTLS) is the network signal every platform has
158
+ * before the first video offer, since libwebrtc cannot probe the path until a video sender
159
+ * exists. It grows with round-trip time and loss, which also mark the links where a 1 Mbps
160
+ * seed overshoots, so the cap ramps linearly from `maxStartBitrateKbps` at
161
+ * `setupTimeForMaxStartBitrateMs` down to `minTargetBitrateKbps` at
162
+ * `setupTimeForMinStartBitrateMs`. A video offer built while the connection is still being set
163
+ * up (publishing during connect) passes the time elapsed so far, a lower bound on the setup time.
164
+ * Without any timing the cap stays at the 1 Mbps ceiling.
165
+ */
166
+ function computeStartBitrateCap(connectionSetupTimeMs?: number): number {
167
+ if (connectionSetupTimeMs === undefined) {
168
+ return maxStartBitrateKbps;
169
+ }
170
+ const ramp = Math.min(
171
+ Math.max(
172
+ (connectionSetupTimeMs - setupTimeForMaxStartBitrateMs) /
173
+ (setupTimeForMinStartBitrateMs - setupTimeForMaxStartBitrateMs),
174
+ 0,
175
+ ),
176
+ 1,
177
+ );
178
+ return Math.round(maxStartBitrateKbps - ramp * (maxStartBitrateKbps - minTargetBitrateKbps));
85
179
  }
86
180
 
87
181
  /**
@@ -114,6 +208,7 @@ export function computeTrackStartBitrate(trackbr: TrackBitrateInfo): number | un
114
208
  export function computeConnectionStartBitrate(
115
209
  media: MediaDescription[],
116
210
  trackBitrates: TrackBitrateInfo[],
211
+ connectionSetupTimeMs?: number,
117
212
  ): number | undefined {
118
213
  let connectionStartBitrate: number | undefined;
119
214
  for (const m of media) {
@@ -128,7 +223,8 @@ export function computeConnectionStartBitrate(
128
223
  if (codecPayload === undefined) {
129
224
  continue;
130
225
  }
131
- const startBitrate = codecPayload > 0 ? computeTrackStartBitrate(trackbr) : undefined;
226
+ const startBitrate =
227
+ codecPayload > 0 ? computeTrackStartBitrate(trackbr, connectionSetupTimeMs) : undefined;
132
228
  if (
133
229
  startBitrate !== undefined &&
134
230
  (connectionStartBitrate === undefined || startBitrate > connectionStartBitrate)
@@ -234,6 +330,20 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
234
330
  */
235
331
  private hasAppliedVideoStartBitrate = false;
236
332
 
333
+ /**
334
+ * How long this peer connection took to set up, in ms, recorded once the initial connect
335
+ * succeeds. Lowers the start bitrate hint for a slow connection; see `computeStartBitrateCap`.
336
+ */
337
+ private connectionSetupTimeMs?: number;
338
+
339
+ /**
340
+ * When the connection attempt that built this peer connection began (`performance.now()`).
341
+ * Until `connectionSetupTimeMs` is known, the time elapsed since then is used as a lower bound
342
+ * on the setup time, so a video published during connect is still seeded from it rather than
343
+ * waiting for the connection, which would delay the offer.
344
+ */
345
+ private connectionStartedAt?: number;
346
+
237
347
  remoteStereoMids: string[] = [];
238
348
 
239
349
  remoteNackMids: string[] = [];
@@ -584,7 +694,11 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
584
694
  // published (data channel or audio only) find no target and leave the latch unset.
585
695
  const connectionStartBitrate = this.hasAppliedVideoStartBitrate
586
696
  ? undefined
587
- : computeConnectionStartBitrate(sdpParsed.media, this.trackBitrates);
697
+ : computeConnectionStartBitrate(
698
+ sdpParsed.media,
699
+ this.trackBitrates,
700
+ this.connectionSetupTimeEstimateMs(),
701
+ );
588
702
  let appliedVideoStartBitrate = false;
589
703
  sdpParsed.media.forEach((media) => {
590
704
  ensureIPAddrMatchVersion(media);
@@ -641,6 +755,10 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
641
755
  // one-shot hint once the SDP carrying it has been accepted locally.
642
756
  if (appliedVideoStartBitrate && offer.sdp === mungedSdp) {
643
757
  this.hasAppliedVideoStartBitrate = true;
758
+ this.log.info('applied x-google-start-bitrate', {
759
+ startBitrateKbps: connectionStartBitrate,
760
+ connectionSetupTimeMs: this.connectionSetupTimeEstimateMs(),
761
+ });
644
762
  }
645
763
  this.onOffer(offer, this.latestOfferId);
646
764
  } finally {
@@ -695,6 +813,30 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
695
813
  this.trackBitrates.push(info);
696
814
  }
697
815
 
816
+ /**
817
+ * Record how long this peer connection took to set up. Called once, when the initial connect
818
+ * succeeds; resumes and ICE restarts keep the estimator they have and never call this.
819
+ */
820
+ setConnectionSetupTime(ms: number) {
821
+ this.connectionSetupTimeMs = ms;
822
+ }
823
+
824
+ /** Record when the connection attempt that built this peer connection began. */
825
+ setConnectionStartedAt(startedAt: number) {
826
+ this.connectionStartedAt = startedAt;
827
+ }
828
+
829
+ /** The measured setup time, or while still connecting, the time elapsed so far. */
830
+ private connectionSetupTimeEstimateMs(): number | undefined {
831
+ if (this.connectionSetupTimeMs !== undefined) {
832
+ return this.connectionSetupTimeMs;
833
+ }
834
+ if (this.connectionStartedAt === undefined) {
835
+ return undefined;
836
+ }
837
+ return Math.round(performance.now() - this.connectionStartedAt);
838
+ }
839
+
698
840
  setConfiguration(rtcConfig: RTCConfiguration) {
699
841
  if (!this._pc) {
700
842
  throw new UnexpectedConnectionState('PC closed, cannot configure');
@@ -1,16 +1,22 @@
1
1
  import {
2
2
  DataPacket,
3
3
  DataPacket_Kind,
4
+ JoinResponse,
4
5
  ConnectionQuality as ProtoConnectionQuality,
5
6
  UserPacket,
6
7
  } from '@livekit/protocol';
7
8
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
9
+ import { SignalConnectionState, type SignalOptions } from '../api/SignalClient';
8
10
  import type { DataPacketBuffer } from '../utils/dataPacketBuffer';
11
+ import DefaultReconnectPolicy from './DefaultReconnectPolicy';
9
12
  import { PCTransportState } from './PCTransportManager';
10
13
  import RTCEngine, { DataChannelKind } from './RTCEngine';
14
+ import type { ReconnectContext, ReconnectPolicy } from './ReconnectPolicy';
11
15
  import { roomOptionDefaults } from './defaults';
12
- import { PublishDataError, UnexpectedConnectionState } from './errors';
16
+ import { ConnectionError, PublishDataError, UnexpectedConnectionState } from './errors';
13
17
  import { EngineEvent } from './events';
18
+ import CriticalTimers from './timers';
19
+ import { Future } from './utils';
14
20
 
15
21
  describe('RTCEngine', () => {
16
22
  const originalRTCRtpSender = window.RTCRtpSender;
@@ -310,7 +316,13 @@ describe('RTCEngine', () => {
310
316
 
311
317
  bufferedAmountLowThreshold = 64 * 1024;
312
318
 
319
+ readyState: RTCDataChannelState = 'open';
320
+
313
321
  send = vi.fn();
322
+
323
+ close = vi.fn(() => {
324
+ this.readyState = 'closing';
325
+ });
314
326
  }
315
327
 
316
328
  const tick = () => new Promise<void>((resolve) => setTimeout(resolve, 0));
@@ -537,6 +549,37 @@ describe('RTCEngine', () => {
537
549
  });
538
550
  });
539
551
 
552
+ describe('createDataChannels', () => {
553
+ it('makes the next sender wait for replacement channels to open', () => {
554
+ const engine = new RTCEngine(roomOptionDefaults);
555
+ const pcManager = {
556
+ createPublisherDataChannel: vi.fn((label: string) =>
557
+ Object.assign(new FakeDataChannel(), { label }),
558
+ ),
559
+ };
560
+ const connected = Promise.resolve();
561
+ Object.assign(engine as unknown as Record<string, unknown>, {
562
+ pcManager,
563
+ publisherConnectionPromise: connected,
564
+ });
565
+ const internals = engine as unknown as {
566
+ createDataChannels: () => void;
567
+ publisherConnectionPromise?: Promise<void>;
568
+ dataChannels: { lossy: { stopThresholdTuning: () => void } };
569
+ };
570
+
571
+ // First creation (nothing to replace) keeps the memoized readiness check.
572
+ internals.createDataChannels();
573
+ expect(internals.publisherConnectionPromise).toBe(connected);
574
+
575
+ // Recreation (the Safari null-id resume path) swaps in channels that start out connecting.
576
+ internals.createDataChannels();
577
+ expect(internals.publisherConnectionPromise).toBeUndefined();
578
+
579
+ internals.dataChannels.lossy.stopThresholdTuning();
580
+ });
581
+ });
582
+
540
583
  describe('handleDataChannelClose', () => {
541
584
  function stubCloseEnv(
542
585
  engine: RTCEngine,
@@ -701,6 +744,123 @@ describe('RTCEngine', () => {
701
744
  });
702
745
  });
703
746
 
747
+ describe('closing during a full reconnect', () => {
748
+ interface ReconnectInternals {
749
+ _isClosed: boolean;
750
+ url: string;
751
+ token: string;
752
+ signalOpts: SignalOptions;
753
+ attemptReconnect: () => Promise<void>;
754
+ configure: () => Promise<void>;
755
+ waitForPCReconnected: () => Promise<void>;
756
+ reconnectAbortController?: AbortController;
757
+ }
758
+
759
+ function primeEngine() {
760
+ const engine = new RTCEngine({ ...roomOptionDefaults, singlePeerConnection: false });
761
+ const internals = engine as unknown as ReconnectInternals;
762
+ internals._isClosed = false;
763
+ internals.url = 'wss://test.livekit.io';
764
+ internals.token = 'test-token';
765
+ internals.signalOpts = { autoSubscribe: true, maxRetries: 1, websocketTimeout: 15_000 };
766
+ engine.fullReconnectOnNext = true;
767
+ const configure = vi.spyOn(internals, 'configure').mockResolvedValue();
768
+ vi.spyOn(internals, 'waitForPCReconnected').mockResolvedValue();
769
+ const join = vi
770
+ .spyOn(engine.client, 'join')
771
+ .mockResolvedValue(new JoinResponse({ subscriberPrimary: true }));
772
+ return { engine, internals, configure, join };
773
+ }
774
+
775
+ it('does not join after close completes while reconnect cleanup is pending', async () => {
776
+ const { engine, internals, join } = primeEngine();
777
+ const cleanupStarted = new Future<void, Error>();
778
+ const finishCleanup = new Future<void, Error>();
779
+ vi.spyOn(engine, 'cleanupPeerConnections').mockImplementationOnce(async () => {
780
+ cleanupStarted.resolve?.();
781
+ await finishCleanup.promise;
782
+ });
783
+
784
+ const reconnect = internals.attemptReconnect();
785
+ await cleanupStarted.promise;
786
+ const controller = internals.reconnectAbortController;
787
+ expect(controller).toBeDefined();
788
+ await engine.close();
789
+ expect(controller?.signal.aborted).toBe(true);
790
+ expect(internals.reconnectAbortController).toBeUndefined();
791
+ finishCleanup.resolve?.();
792
+ await reconnect;
793
+
794
+ expect(internals.reconnectAbortController).toBeUndefined();
795
+ expect(join).not.toHaveBeenCalled();
796
+ expect(engine.isClosed).toBe(true);
797
+ });
798
+
799
+ it('does not join another region after close completes during region selection', async () => {
800
+ const { engine, internals, join } = primeEngine();
801
+ join.mockRejectedValue(ConnectionError.internal('signal connection failed'));
802
+ const selectingRegion = new Future<void, Error>();
803
+ const nextRegion = new Future<string | null, Error>();
804
+ const getNextUrl = vi.fn(async () => {
805
+ selectingRegion.resolve?.();
806
+ return nextRegion.promise;
807
+ });
808
+ engine.setRegionStrategy({ getNextUrl, resetAttempts: () => {} });
809
+
810
+ const reconnect = internals.attemptReconnect();
811
+ await selectingRegion.promise;
812
+ await engine.close();
813
+ nextRegion.resolve?.('wss://another-region.livekit.io');
814
+ // A second selection must terminate even on the unfixed implementation.
815
+ getNextUrl.mockResolvedValue(null);
816
+ await reconnect;
817
+
818
+ expect(join).toHaveBeenCalledTimes(1);
819
+ expect(getNextUrl).toHaveBeenCalledTimes(1);
820
+ expect(engine.isClosed).toBe(true);
821
+ });
822
+
823
+ it('does not reopen the engine when a signal join resolves after close', async () => {
824
+ const { engine, internals, configure, join } = primeEngine();
825
+ const joining = new Future<void, Error>();
826
+ const joinResponse = new Future<JoinResponse, Error>();
827
+ join.mockImplementationOnce(async () => {
828
+ joining.resolve?.();
829
+ return joinResponse.promise;
830
+ });
831
+
832
+ const reconnect = internals.attemptReconnect();
833
+ await joining.promise;
834
+ await engine.close();
835
+ joinResponse.resolve?.(new JoinResponse({ subscriberPrimary: true }));
836
+ await reconnect;
837
+
838
+ expect(engine.isClosed).toBe(true);
839
+ expect(configure).not.toHaveBeenCalled();
840
+ expect(join.mock.calls[0][3]?.aborted).toBe(true);
841
+ });
842
+
843
+ it('still completes a full reconnect while the engine is active', async () => {
844
+ const { engine, internals, join } = primeEngine();
845
+ vi.spyOn(engine.client, 'sendLeave').mockResolvedValue();
846
+ vi.spyOn(engine.client, 'currentState', 'get').mockReturnValue(
847
+ SignalConnectionState.CONNECTED,
848
+ );
849
+ const restarted = vi.fn();
850
+ engine.on(EngineEvent.Restarted, restarted);
851
+
852
+ try {
853
+ await internals.attemptReconnect();
854
+
855
+ expect(join).toHaveBeenCalledTimes(1);
856
+ expect(restarted).toHaveBeenCalledTimes(1);
857
+ expect(engine.isClosed).toBe(false);
858
+ } finally {
859
+ await engine.close();
860
+ }
861
+ });
862
+ });
863
+
704
864
  describe('reconnect requested mid-attempt', () => {
705
865
  // A full reconnect requested while a resume is already in flight (e.g. a server
706
866
  // RECONNECT leave racing the resume) sets `fullReconnectOnNext` mid-attempt. A
@@ -711,7 +871,7 @@ describe('RTCEngine', () => {
711
871
  clientConfiguration: unknown;
712
872
  pcManager: unknown;
713
873
  resumeConnection: (reason?: number) => Promise<void>;
714
- restartConnection: (regionUrl?: string) => Promise<void>;
874
+ restartConnection: (abortSignal: AbortSignal, regionUrl?: string) => Promise<void>;
715
875
  clearPendingReconnect: () => void;
716
876
  handleDisconnect: (connection: string, reason?: number) => void;
717
877
  attemptReconnect: (reason?: number) => Promise<void>;
@@ -1003,4 +1163,76 @@ describe('RTCEngine', () => {
1003
1163
  expect(pendingAborts(engine).size).toBe(0);
1004
1164
  });
1005
1165
  });
1166
+
1167
+ describe('server-requested reconnect delay', () => {
1168
+ // A RECONNECT or RESUME leave from the server skips any remaining backoff. It must still go
1169
+ // through the reconnect policy, so that clients told to reconnect together spread out.
1170
+ interface DisconnectInternals {
1171
+ _isClosed: boolean;
1172
+ reconnectAttempts: number;
1173
+ handleDisconnect: (connection: string, reason?: number) => void;
1174
+ }
1175
+
1176
+ function scheduledDelay(
1177
+ nextRetryDelayInMs: ReconnectPolicy['nextRetryDelayInMs'],
1178
+ attempts = 0,
1179
+ ) {
1180
+ const engine = new RTCEngine({
1181
+ ...roomOptionDefaults,
1182
+ reconnectPolicy: { nextRetryDelayInMs },
1183
+ });
1184
+ const internals = engine as unknown as DisconnectInternals;
1185
+ internals._isClosed = false;
1186
+ internals.reconnectAttempts = attempts;
1187
+ const setTimeoutSpy = vi
1188
+ .spyOn(CriticalTimers, 'setTimeout')
1189
+ .mockReturnValue(0 as unknown as ReturnType<typeof setTimeout>);
1190
+ internals.handleDisconnect('leave-reconnect');
1191
+ expect(setTimeoutSpy).toHaveBeenCalledTimes(1);
1192
+ return setTimeoutSpy.mock.calls[0][1];
1193
+ }
1194
+
1195
+ afterEach(() => {
1196
+ vi.restoreAllMocks();
1197
+ });
1198
+
1199
+ it('uses the first retry delay of the policy instead of reconnecting immediately', () => {
1200
+ const nextRetryDelayInMs = vi.fn((context: ReconnectContext) =>
1201
+ context.retryCount === 0 ? 321 : 5000,
1202
+ );
1203
+ expect(scheduledDelay(nextRetryDelayInMs)).toBe(321);
1204
+ });
1205
+
1206
+ it('skips the remaining backoff when the request arrives during a later retry', () => {
1207
+ const nextRetryDelayInMs = vi.fn((context: ReconnectContext) =>
1208
+ context.retryCount === 0 ? 321 : 5000,
1209
+ );
1210
+ expect(scheduledDelay(nextRetryDelayInMs, 3)).toBe(321);
1211
+ });
1212
+
1213
+ it('gives up when the policy stops on the first retry delay', () => {
1214
+ const engine = new RTCEngine({
1215
+ ...roomOptionDefaults,
1216
+ reconnectPolicy: {
1217
+ nextRetryDelayInMs: (context: ReconnectContext) =>
1218
+ context.retryCount === 0 ? null : 5000,
1219
+ },
1220
+ });
1221
+ const internals = engine as unknown as DisconnectInternals;
1222
+ internals._isClosed = false;
1223
+ internals.reconnectAttempts = 3;
1224
+ const setTimeoutSpy = vi.spyOn(CriticalTimers, 'setTimeout');
1225
+ const disconnected = vi.fn();
1226
+ engine.on(EngineEvent.Disconnected, disconnected);
1227
+ internals.handleDisconnect('leave-reconnect');
1228
+ expect(setTimeoutSpy).not.toHaveBeenCalled();
1229
+ expect(disconnected).toHaveBeenCalledTimes(1);
1230
+ });
1231
+
1232
+ it('spreads out clients that use the default policy', () => {
1233
+ const defaultPolicy = new DefaultReconnectPolicy();
1234
+ vi.spyOn(Math, 'random').mockReturnValue(0.5);
1235
+ expect(scheduledDelay((context) => defaultPolicy.nextRetryDelayInMs(context))).toBe(250);
1236
+ });
1237
+ });
1006
1238
  });