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
@@ -7,8 +7,17 @@ class FakeDataChannel extends EventTarget {
7
7
  bufferedAmount = 0;
8
8
 
9
9
  bufferedAmountLowThreshold = 64;
10
+
11
+ readyState: RTCDataChannelState = 'open';
12
+
13
+ // Like the real thing, close() moves to 'closing' synchronously.
14
+ close = vi.fn(() => {
15
+ this.readyState = 'closing';
16
+ });
10
17
  }
11
18
 
19
+ const handle = (dc: FakeDataChannel) => dc as unknown as RTCDataChannel;
20
+
12
21
  const tick = () => new Promise<void>((resolve) => setTimeout(resolve, 0));
13
22
 
14
23
  // Pass `dc: null` for a handle-less channel; omit it to get a fresh one. `null` (not `undefined`)
@@ -62,37 +71,54 @@ describe('FlowControlledDataChannel', () => {
62
71
 
63
72
  it('reports watermark status against the given channel', () => {
64
73
  const { channel, dc } = makeChannel();
65
- const handle = dc as unknown as RTCDataChannel;
66
74
  dc.bufferedAmount = 0;
67
- expect(channel.isBelowHighWaterMark(handle)).toBe(true);
68
- expect(channel.isBelowLowWaterMark(handle)).toBe(true);
75
+ expect(channel.isBelowHighWaterMark(handle(dc))).toBe(true);
76
+ expect(channel.isBelowLowWaterMark(handle(dc))).toBe(true);
69
77
 
70
78
  dc.bufferedAmount = 512; // between low (64) and high (1024)
71
- expect(channel.isBelowHighWaterMark(handle)).toBe(true);
72
- expect(channel.isBelowLowWaterMark(handle)).toBe(false);
79
+ expect(channel.isBelowHighWaterMark(handle(dc))).toBe(true);
80
+ expect(channel.isBelowLowWaterMark(handle(dc))).toBe(false);
73
81
 
74
82
  dc.bufferedAmount = 2048;
75
- expect(channel.isBelowHighWaterMark(handle)).toBe(false);
83
+ expect(channel.isBelowHighWaterMark(handle(dc))).toBe(false);
76
84
  });
77
85
 
78
- it('waiting for headroom without a handle rejects with a connection error', async () => {
79
- const { channel } = makeChannel({ dc: null });
80
- await expect(channel.waitForHeadroomWithLock()).rejects.toBeInstanceOf(
86
+ it('rejects a wait on a handle that is not open', async () => {
87
+ const { channel, dc } = makeChannel();
88
+ dc.readyState = 'connecting';
89
+ await expect(channel.waitForHeadroomWithLock(handle(dc))).rejects.toBeInstanceOf(
81
90
  UnexpectedConnectionState,
82
91
  );
83
92
  });
84
93
 
94
+ it('rejects a wait queued on the lock once its handle is replaced', async () => {
95
+ const { channel, dc: oldDc } = makeChannel();
96
+ oldDc.bufferedAmount = 2048;
97
+ const parked = channel.waitForHeadroomWithLock(handle(oldDc));
98
+ parked.catch(() => {});
99
+ // Queued behind the parked waiter, so the abort on replacement doesn't reach it — the closed
100
+ // handle does, once it gets the lock.
101
+ const queued = channel.waitForHeadroomWithLock(handle(oldDc));
102
+ queued.catch(() => {});
103
+ await tick();
104
+
105
+ channel.attach(handle(new FakeDataChannel()));
106
+
107
+ await expect(parked).rejects.toBeInstanceOf(UnexpectedConnectionState);
108
+ await expect(queued).rejects.toBeInstanceOf(UnexpectedConnectionState);
109
+ });
110
+
85
111
  it('resolves immediately while below the high-water mark', async () => {
86
112
  const { channel, dc } = makeChannel();
87
113
  dc.bufferedAmount = 1024;
88
- await expect(channel.waitForHeadroomWithLock()).resolves.toBeUndefined();
114
+ await expect(channel.waitForHeadroomWithLock(handle(dc))).resolves.toBeUndefined();
89
115
  });
90
116
 
91
117
  it('parks above the high-water mark and resumes on bufferedamountlow', async () => {
92
118
  const { channel, dc } = makeChannel();
93
119
  dc.bufferedAmount = 2048;
94
120
  const resolved = vi.fn();
95
- const wait = channel.waitForHeadroomWithLock().then(resolved);
121
+ const wait = channel.waitForHeadroomWithLock(handle(dc)).then(resolved);
96
122
  await tick();
97
123
  expect(resolved).not.toHaveBeenCalled();
98
124
 
@@ -105,7 +131,7 @@ describe('FlowControlledDataChannel', () => {
105
131
  it('rejects a parked waiter when the channel closes', async () => {
106
132
  const { channel, dc } = makeChannel();
107
133
  dc.bufferedAmount = 2048;
108
- const wait = channel.waitForHeadroomWithLock();
134
+ const wait = channel.waitForHeadroomWithLock(handle(dc));
109
135
  wait.catch(() => {});
110
136
  await tick();
111
137
 
@@ -116,7 +142,7 @@ describe('FlowControlledDataChannel', () => {
116
142
  it('rejects a parked waiter on invalidateWaiters and recovers with a fresh controller', async () => {
117
143
  const { channel, dc } = makeChannel();
118
144
  dc.bufferedAmount = 2048;
119
- const wait = channel.waitForHeadroomWithLock();
145
+ const wait = channel.waitForHeadroomWithLock(handle(dc));
120
146
  wait.catch(() => {});
121
147
  await tick();
122
148
 
@@ -125,13 +151,29 @@ describe('FlowControlledDataChannel', () => {
125
151
 
126
152
  // Fresh controller: the gate is usable again and the lock was released.
127
153
  dc.bufferedAmount = 0;
128
- await expect(channel.waitForHeadroomWithLock()).resolves.toBeUndefined();
154
+ await expect(channel.waitForHeadroomWithLock(handle(dc))).resolves.toBeUndefined();
155
+ });
156
+
157
+ it('closes the handle it lets go of, on replacement and on detach', () => {
158
+ const { channel, dc: first } = makeChannel();
159
+ const second = new FakeDataChannel();
160
+
161
+ channel.attach(first as unknown as RTCDataChannel); // re-attaching the same handle is a no-op
162
+ expect(first.close).not.toHaveBeenCalled();
163
+
164
+ channel.attach(second as unknown as RTCDataChannel);
165
+ expect(first.close).toHaveBeenCalledTimes(1);
166
+ expect(second.close).not.toHaveBeenCalled();
167
+
168
+ channel.detach();
169
+ expect(second.close).toHaveBeenCalledTimes(1);
170
+ expect(channel.channelHandle).toBeUndefined();
129
171
  });
130
172
 
131
173
  it('rejects immediately when the engine is closed', async () => {
132
- const { channel, state } = makeChannel();
174
+ const { channel, dc, state } = makeChannel();
133
175
  state.engineClosed = true;
134
- await expect(channel.waitForHeadroomWithLock()).rejects.toBeInstanceOf(
176
+ await expect(channel.waitForHeadroomWithLock(handle(dc))).rejects.toBeInstanceOf(
135
177
  UnexpectedConnectionState,
136
178
  );
137
179
  });
@@ -140,8 +182,8 @@ describe('FlowControlledDataChannel', () => {
140
182
  const { channel, dc } = makeChannel();
141
183
  dc.bufferedAmount = 2048;
142
184
  const order: number[] = [];
143
- const first = channel.waitForHeadroomWithLock().then(() => order.push(1));
144
- const second = channel.waitForHeadroomWithLock().then(() => order.push(2));
185
+ const first = channel.waitForHeadroomWithLock(handle(dc)).then(() => order.push(1));
186
+ const second = channel.waitForHeadroomWithLock(handle(dc)).then(() => order.push(2));
145
187
  await tick();
146
188
 
147
189
  // One drain event wakes the head waiter; the second re-checks under the lock and, with the
@@ -30,7 +30,8 @@ export interface FlowControlledDataChannelOptions {
30
30
  * Waiters are parked on the channel object captured at wait entry. If that object stops being
31
31
  * current — replaced or torn down — its events may never fire again, so the owner must call
32
32
  * {@link invalidateWaiters}, which aborts parked waiters (releasing the gate); the next waiter
33
- * gets a fresh controller.
33
+ * gets a fresh controller. {@link attach} and {@link detach} do this, and close the released
34
+ * handle, as part of handle turnover.
34
35
  */
35
36
  export class FlowControlledDataChannel {
36
37
  readonly kind: DataChannelKind;
@@ -67,25 +68,39 @@ export class FlowControlledDataChannel {
67
68
  }
68
69
 
69
70
  /**
70
- * Attaches the channel handle this wrapper controls. Replacing an existing handle rejects
71
- * parked waiters — their events would never fire again on the abandoned object — and installs a
72
- * fresh controller, so queued senders re-check against the new channel. Wrappers outlive their
73
- * handles: this is the one place handle turnover happens, which is what makes stranding a
74
- * waiter structurally impossible.
71
+ * Attaches the channel handle this wrapper controls. Replacing an existing handle closes it and
72
+ * rejects parked waiters — their events would never fire again on the abandoned object — and
73
+ * installs a fresh controller. Wrappers outlive their handles: this is the one place handle
74
+ * turnover happens, so a handle the wrapper lets go of is never left open, and stranding a
75
+ * waiter is structurally impossible.
75
76
  */
76
77
  attach(dc: RTCDataChannel) {
77
- if (this.handle && this.handle !== dc) {
78
- this.invalidateWaiters('data channel replaced');
79
- }
78
+ const old = this.handle;
80
79
  this.handle = dc;
80
+ if (old && old !== dc) {
81
+ this.release(old, 'data channel replaced');
82
+ }
81
83
  }
82
84
 
83
- /** Detaches the handle on teardown, rejecting parked waiters. */
85
+ /** Detaches and closes the handle on teardown, rejecting parked waiters. */
84
86
  detach(reason: string = 'data channel torn down') {
85
- if (this.handle) {
86
- this.invalidateWaiters(reason);
87
- }
87
+ const old = this.handle;
88
88
  this.handle = undefined;
89
+ if (old) {
90
+ this.release(old, reason);
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Closes a handle the wrapper no longer controls. `close()` moves it to `closing` synchronously,
96
+ * so senders still holding it (queued on the headroom lock) fail the open check once they get
97
+ * the lock. Parked waiters are aborted directly instead of relying on the `close` event, which
98
+ * fires asynchronously, if at all (the spec lets `pc.close()` skip it). Strip any handlers that
99
+ * must not observe the close before calling this.
100
+ */
101
+ private release(dc: RTCDataChannel, reason: string) {
102
+ this.invalidateWaiters(reason);
103
+ dc.close();
89
104
  }
90
105
 
91
106
  protected getChannel(): RTCDataChannel | undefined {
@@ -121,30 +136,33 @@ export class FlowControlledDataChannel {
121
136
  }
122
137
 
123
138
  /**
124
- * Resolves once the caller may send on this channel: immediately while the send buffer is at or
125
- * below its high-water mark, otherwise once the buffer has drained to the low-water mark (the
139
+ * Resolves once the caller may send on `dc`: immediately while its send buffer is at or below
140
+ * the high-water mark, otherwise once the buffer has drained to the low-water mark (the
126
141
  * `bufferedamountlow` event). Callers are serialized through the headroom lock so that, when
127
142
  * the buffer drains, they refill it one at a time (up to the high-water mark) rather than all
128
143
  * sending at once and overflowing the SCTP send buffer (see livekit/client-sdk-js#1995). The
129
- * closed/buffer checks run inside the lock so queued callers proceed in FIFO order.
144
+ * checks run inside the lock so queued callers proceed in FIFO order.
145
+ *
146
+ * The caller passes the handle it resolved before queueing, and the wait is against that one
147
+ * handle throughout: if it was replaced meanwhile, it has been closed, so the wait rejects
148
+ * rather than letting the caller send on the replacement out of turn.
130
149
  */
131
- async waitForHeadroomWithLock() {
150
+ async waitForHeadroomWithLock(dc: RTCDataChannel) {
132
151
  const unlock = await this.lockHeadroom();
133
152
  try {
134
- await this.waitForHeadroomWithoutLock();
153
+ await this.waitForHeadroomWithoutLock(dc);
135
154
  } finally {
136
155
  unlock();
137
156
  }
138
157
  }
139
158
 
140
159
  /** Core wait of {@link waitForHeadroomWithLock}. The caller must hold the headroom lock. */
141
- async waitForHeadroomWithoutLock() {
160
+ async waitForHeadroomWithoutLock(dc: RTCDataChannel) {
142
161
  if (this.isEngineClosed()) {
143
162
  throw new UnexpectedConnectionState('engine closed');
144
163
  }
145
- const dc = this.getChannel();
146
- if (!dc) {
147
- throw new UnexpectedConnectionState(`DataChannel not found, kind: ${this.kind}`);
164
+ if (dc.readyState !== 'open') {
165
+ throw new UnexpectedConnectionState(`DataChannel ${this.kind} is ${dc.readyState}`);
148
166
  }
149
167
  if (this.isBelowHighWaterMark(dc)) {
150
168
  return;
@@ -8,6 +8,13 @@ class FakeDataChannel extends EventTarget {
8
8
  bufferedAmountLowThreshold = 64;
9
9
 
10
10
  send = vi.fn();
11
+
12
+ readyState: RTCDataChannelState = 'open';
13
+
14
+ // Like the real thing, close() moves to 'closing' synchronously.
15
+ close = vi.fn(() => {
16
+ this.readyState = 'closing';
17
+ });
11
18
  }
12
19
 
13
20
  // Pass `dc: null` for a handle-less channel; omit it to get a fresh one. `null` (not `undefined`)
@@ -76,6 +83,37 @@ describe('LossyDataChannel', () => {
76
83
  expect(dc.send).toHaveBeenCalledTimes(1);
77
84
  });
78
85
 
86
+ it('wait policy: drops waiting sends when the channel is replaced', async () => {
87
+ const { channel, dc: oldDc } = makeChannel('wait');
88
+ oldDc.bufferedAmount = 2048;
89
+ const parked = channel.send(new Uint8Array([1]));
90
+ const queued = channel.send(new Uint8Array([2]));
91
+ await vi.advanceTimersByTimeAsync(0);
92
+
93
+ const newDc = new FakeDataChannel();
94
+ channel.attach(newDc as unknown as RTCDataChannel);
95
+
96
+ // Both resolve: a dropped frame mustn't reject (the room doesn't handle frame send failures).
97
+ await expect(parked).resolves.toBeUndefined();
98
+ await expect(queued).resolves.toBeUndefined();
99
+ // Not on the abandoned channel, and not on the replacement, which may still be connecting.
100
+ expect(oldDc.send).not.toHaveBeenCalled();
101
+ expect(newDc.send).not.toHaveBeenCalled();
102
+ });
103
+
104
+ it('wait policy: rejects a waiting send when the engine closes', async () => {
105
+ const { channel, dc, state } = makeChannel('wait');
106
+ dc.bufferedAmount = 2048;
107
+ const send = channel.send(new Uint8Array([1]));
108
+ send.catch(() => {});
109
+ await vi.advanceTimersByTimeAsync(0);
110
+
111
+ state.engineClosed = true;
112
+ channel.detach();
113
+
114
+ await expect(send).rejects.toThrow();
115
+ });
116
+
79
117
  it('skips sends while a reconnect is underway', async () => {
80
118
  const { channel, dc, state } = makeChannel('drop');
81
119
  state.skipSends = true;
@@ -55,7 +55,16 @@ export class LossyDataChannel extends FlowControlledDataChannel {
55
55
  switch (this.bufferFullBehavior) {
56
56
  case 'wait':
57
57
  if (!this.isBelowHighWaterMark(dc)) {
58
- await this.waitForHeadroomWithLock();
58
+ try {
59
+ await this.waitForHeadroomWithLock(dc);
60
+ } catch (error) {
61
+ if (this.isEngineClosed()) {
62
+ throw error;
63
+ }
64
+ // The channel closed or was replaced while this send waited. The replacement may
65
+ // still be connecting, so drop the packet, as sends during a reconnect are.
66
+ return;
67
+ }
59
68
  }
60
69
  break;
61
70
  case 'drop':
@@ -9,6 +9,13 @@ class FakeDataChannel extends EventTarget {
9
9
  bufferedAmountLowThreshold = 64;
10
10
 
11
11
  send = vi.fn();
12
+
13
+ readyState: RTCDataChannelState = 'open';
14
+
15
+ // Like the real thing, close() moves to 'closing' synchronously.
16
+ close = vi.fn(() => {
17
+ this.readyState = 'closing';
18
+ });
12
19
  }
13
20
 
14
21
  const tick = () => new Promise<void>((resolve) => setTimeout(resolve, 0));
@@ -77,6 +84,33 @@ describe('ReliableDataChannel', () => {
77
84
  expect(buffer.getAll()[0].sent).toBe(false);
78
85
  });
79
86
 
87
+ it('defers a send queued on the lock to the replay when the channel is replaced, preserving order', async () => {
88
+ const { channel, dc: oldDc, buffer } = makeChannel();
89
+ oldDc.bufferedAmount = 2048; // above high mark → the first send parks
90
+ const parked = channel.send(new Uint8Array([1]), channel.nextSequence());
91
+ // Queues behind the parked send on the headroom lock; replacing the handle rejects only the
92
+ // parked waiter, so this one gets the lock afterwards.
93
+ const queued = channel.send(new Uint8Array([2]), channel.nextSequence());
94
+ await tick();
95
+
96
+ const newDc = new FakeDataChannel();
97
+ channel.attach(newDc as unknown as RTCDataChannel);
98
+
99
+ await expect(parked).resolves.toBeUndefined();
100
+ await expect(queued).resolves.toBeUndefined();
101
+ // Neither goes out ahead of the replay: sending seq 2 now would make receivers discard seq 1
102
+ // once the replay delivers it.
103
+ expect(oldDc.send).not.toHaveBeenCalled();
104
+ expect(newDc.send).not.toHaveBeenCalled();
105
+ expect(buffer.getAll().map(({ sequence, sent }) => ({ sequence, sent }))).toEqual([
106
+ { sequence: 1, sent: false },
107
+ { sequence: 2, sent: false },
108
+ ]);
109
+
110
+ await channel.replay(0);
111
+ expect(newDc.send.mock.calls.map(([data]) => data[0])).toEqual([1, 2]);
112
+ });
113
+
80
114
  it('rejects when the engine is closed while waiting', async () => {
81
115
  const { channel, dc, state } = makeChannel();
82
116
  dc.bufferedAmount = 2048;
@@ -135,6 +169,44 @@ describe('ReliableDataChannel', () => {
135
169
  expect(buffer.getAll().filter((i) => !i.sent)).toHaveLength(0);
136
170
  });
137
171
 
172
+ it('replays on the replacement handle when the channel is replaced while replay waits for the lock', async () => {
173
+ const { channel, dc: oldDc } = makeChannel();
174
+ await channel.send(new Uint8Array([1]), channel.nextSequence());
175
+ oldDc.send.mockClear();
176
+
177
+ const unlock = await channel.lockHeadroom();
178
+ const replay = channel.replay(0);
179
+ await tick();
180
+
181
+ const newDc = new FakeDataChannel();
182
+ channel.attach(newDc as unknown as RTCDataChannel);
183
+ unlock();
184
+ await replay;
185
+
186
+ expect(oldDc.send).not.toHaveBeenCalled();
187
+ expect(newDc.send).toHaveBeenCalledTimes(1);
188
+ });
189
+
190
+ it('stops the replay, keeping the rest unsent, when the channel is replaced mid-replay', async () => {
191
+ const { channel, dc: oldDc, state, buffer } = makeChannel();
192
+ state.deferring = true;
193
+ await channel.send(new Uint8Array([1]), channel.nextSequence());
194
+ await channel.send(new Uint8Array([2]), channel.nextSequence());
195
+ state.deferring = false;
196
+
197
+ oldDc.bufferedAmount = 2048; // the replay parks before its first send
198
+ const replay = channel.replay(0);
199
+ replay.catch(() => {});
200
+ await tick();
201
+
202
+ // A second replacement belongs to another resume, whose replay delivers the backlog.
203
+ channel.attach(new FakeDataChannel() as unknown as RTCDataChannel);
204
+
205
+ await expect(replay).rejects.toBeInstanceOf(UnexpectedConnectionState);
206
+ expect(oldDc.send).not.toHaveBeenCalled();
207
+ expect(buffer.getAll().every((item) => !item.sent)).toBe(true);
208
+ });
209
+
138
210
  it('holds the headroom lock across the whole replay so new sends cannot interleave', async () => {
139
211
  const { channel, dc } = makeChannel();
140
212
  (channel as unknown as { isDeferringSends: () => boolean }).isDeferringSends = () => true;
@@ -1,5 +1,6 @@
1
1
  import type { NonSharedUint8Array } from '../../type-polyfills/non-shared-typed-arrays';
2
2
  import { DataPacketBuffer } from '../../utils/dataPacketBuffer';
3
+ import { UnexpectedConnectionState } from '../errors';
3
4
  import {
4
5
  FlowControlledDataChannel,
5
6
  type FlowControlledDataChannelOptions,
@@ -64,7 +65,7 @@ export class ReliableDataChannel extends FlowControlledDataChannel {
64
65
  }
65
66
 
66
67
  try {
67
- await this.waitForHeadroomWithLock();
68
+ await this.waitForHeadroomWithLock(dc);
68
69
  } catch (error) {
69
70
  if (this.isEngineClosed()) {
70
71
  // No replay is coming after an engine close — surface the failure.
@@ -72,7 +73,9 @@ export class ReliableDataChannel extends FlowControlledDataChannel {
72
73
  }
73
74
  // Transient teardown (the channel closed or was replaced while we waited): the reliable
74
75
  // channel promises delivery across resume, so queue the packet for the replay instead of
75
- // rejecting a send the app can't meaningfully retry.
76
+ // rejecting a send the app can't meaningfully retry. This also keeps the order: a send
77
+ // queued behind one aborted by a replacement fails the same way (the old handle is closed),
78
+ // so it can't reach the replacement ahead of the lower sequence the replay will deliver.
76
79
  this.messageBuffer.push({ data: msg, sequence, sent: false });
77
80
  return;
78
81
  }
@@ -96,13 +99,18 @@ export class ReliableDataChannel extends FlowControlledDataChannel {
96
99
  * receivers would then discard the remaining lower-sequence resent messages as duplicates.
97
100
  */
98
101
  async replay(lastMessageSeq: number) {
99
- const dc = this.getChannel();
100
- if (!dc) {
102
+ if (!this.getChannel()) {
101
103
  return;
102
104
  }
103
105
  this.messageBuffer.popToSequence(lastMessageSeq);
104
106
  const unlock = await this.lockHeadroom();
105
107
  try {
108
+ // Resolved only once the lock is held: the handle can be replaced while the replay waits
109
+ // for it.
110
+ const dc = this.getChannel();
111
+ if (!dc) {
112
+ throw new UnexpectedConnectionState(`DataChannel not found, kind: ${this.kind}`);
113
+ }
106
114
  // Everything left after the ack cutoff must be re-handed to the current channel.
107
115
  this.messageBuffer.markAllUnsent();
108
116
  // Drain in passes, re-scanning the live buffer each time: a send that arrives (deferred,
@@ -118,7 +126,10 @@ export class ReliableDataChannel extends FlowControlledDataChannel {
118
126
  ) {
119
127
  for (const item of batch) {
120
128
  // Respect flow control on resume too, so a large resend doesn't overflow the buffer.
121
- await this.waitForHeadroomWithoutLock();
129
+ // Against the handle resolved under the lock: if it is replaced mid-replay (another
130
+ // resume), it has been closed, so this throws and that resume's replay picks up the
131
+ // entries still flagged unsent.
132
+ await this.waitForHeadroomWithoutLock(dc);
122
133
  dc.send(item.data);
123
134
  this.messageBuffer.markSent(item);
124
135
  }
@@ -679,6 +679,37 @@ describe('IncomingDataStreamManager', () => {
679
679
  'Participant alice unexpectedly disconnected in the middle of sending data',
680
680
  );
681
681
  });
682
+
683
+ it('should error in flight streams when the controllers are cleared on room disconnect', async () => {
684
+ const manager = new IncomingDataStreamManager();
685
+ manager.setConnected(true);
686
+
687
+ const textReaderPromise = new Promise<TextStreamReader>((resolve) => {
688
+ manager.registerTextStreamHandler('my-topic', (reader) => resolve(reader));
689
+ });
690
+ const byteReaderPromise = new Promise<ByteStreamReader>((resolve) => {
691
+ manager.registerByteStreamHandler('my-topic', (reader) => resolve(reader));
692
+ });
693
+
694
+ manager.handleDataStreamPacket(headerPacket('text-1', 'textHeader'), Encryption_Type.NONE);
695
+ manager.handleDataStreamPacket(
696
+ chunkPacket('text-1', 0, new Uint8Array([0x68, 0x69])),
697
+ Encryption_Type.NONE,
698
+ );
699
+ manager.handleDataStreamPacket(headerPacket('byte-1', 'byteHeader'), Encryption_Type.NONE);
700
+ manager.handleDataStreamPacket(
701
+ chunkPacket('byte-1', 0, new Uint8Array([0x01])),
702
+ Encryption_Type.NONE,
703
+ );
704
+
705
+ // Room#handleDisconnect calls this while the streams are still open
706
+ manager.clearControllers();
707
+
708
+ const textReader = await textReaderPromise;
709
+ const byteReader = await byteReaderPromise;
710
+ await expect(textReader.readAll()).rejects.toThrow('Room disconnected');
711
+ await expect(byteReader.readAll()).rejects.toThrow('Room disconnected');
712
+ });
682
713
  });
683
714
 
684
715
  describe('Receiving v2 data streams', () => {
@@ -87,6 +87,17 @@ export default class IncomingDataStreamManager {
87
87
  }
88
88
 
89
89
  clearControllers() {
90
+ // Error any in flight streams so readers do not wait forever for chunks that can no longer arrive.
91
+ const abnormalEndError = new DataStreamError(
92
+ 'Room disconnected in the middle of receiving data',
93
+ DataStreamErrorReason.AbnormalEnd,
94
+ );
95
+ for (const { controller } of this.byteStreamControllers.values()) {
96
+ controller.error(abnormalEndError);
97
+ }
98
+ for (const { controller } of this.textStreamControllers.values()) {
99
+ controller.error(abnormalEndError);
100
+ }
90
101
  this.byteStreamControllers.clear();
91
102
  this.textStreamControllers.clear();
92
103
  this.bufferedPackets = [];
@@ -1,6 +1,12 @@
1
- import { PacketTrailerFeature } from '@livekit/protocol';
1
+ import {
2
+ type AddTrackRequest,
3
+ Encryption_Type,
4
+ PacketTrailerFeature,
5
+ VideoQuality,
6
+ } from '@livekit/protocol';
2
7
  import { describe, expect, it, vi } from 'vitest';
3
8
  import type LocalTrack from '../track/LocalTrack';
9
+ import type LocalVideoTrack from '../track/LocalVideoTrack';
4
10
  import { Track } from '../track/Track';
5
11
  import type { TrackPublishOptions } from '../track/options';
6
12
  import LocalParticipant from './LocalParticipant';
@@ -79,3 +85,132 @@ describe('LocalParticipant frame metadata publish options', () => {
79
85
  expect(participant.log.warn).toHaveBeenCalledOnce();
80
86
  });
81
87
  });
88
+
89
+ type BackupCodecTestParticipant = {
90
+ encryptionType: Encryption_Type;
91
+ trackPublications: Map<string, { track: LocalVideoTrack }>;
92
+ roomOptions: { publishDefaults: TrackPublishOptions };
93
+ engine: {
94
+ isClosed: boolean;
95
+ addTrack: ReturnType<typeof vi.fn>;
96
+ createSimulcastSender: ReturnType<typeof vi.fn>;
97
+ negotiate: ReturnType<typeof vi.fn>;
98
+ };
99
+ log: { warn: ReturnType<typeof vi.fn>; debug: ReturnType<typeof vi.fn> };
100
+ publishAdditionalCodecForTrack: LocalParticipant['publishAdditionalCodecForTrack'];
101
+ };
102
+
103
+ // a resizing processor makes mediaStreamTrack (processed) and dimensions (raw capture) differ
104
+ function makeBackupCodecTrack(
105
+ settings: MediaTrackSettings,
106
+ source = Track.Source.Camera,
107
+ captureSettings = settings,
108
+ ) {
109
+ return {
110
+ isLocal: true,
111
+ kind: Track.Kind.Video,
112
+ sid: 'TR_video',
113
+ source,
114
+ isMuted: false,
115
+ mediaStreamID: 'stream-id',
116
+ mediaStreamTrack: { enabled: true, id: 'primary-cid', getSettings: () => settings },
117
+ get dimensions() {
118
+ const { width, height } = captureSettings;
119
+ return width && height ? { width, height } : undefined;
120
+ },
121
+ addSimulcastTrack: () => ({ mediaStreamTrack: { id: 'backup-cid' } }),
122
+ } as unknown as LocalVideoTrack;
123
+ }
124
+
125
+ function makeBackupCodecParticipant(track: LocalVideoTrack) {
126
+ const participant = Object.create(LocalParticipant.prototype) as BackupCodecTestParticipant;
127
+ // Object.create runs no field initializers, so without this the encryption guard sees
128
+ // undefined and the method returns before publishing anything.
129
+ participant.encryptionType = Encryption_Type.NONE;
130
+ participant.trackPublications = new Map([['TR_video', { track }]]);
131
+ participant.roomOptions = {
132
+ publishDefaults: { videoCodec: 'vp9', backupCodec: { codec: 'vp8' }, simulcast: true },
133
+ };
134
+ participant.engine = {
135
+ isClosed: false,
136
+ addTrack: vi.fn(async () => ({})),
137
+ createSimulcastSender: vi.fn(async () => {}),
138
+ negotiate: vi.fn(async () => {}),
139
+ };
140
+ participant.log = { warn: vi.fn(), debug: vi.fn() };
141
+ return participant;
142
+ }
143
+
144
+ function capturedRequest(participant: BackupCodecTestParticipant): AddTrackRequest {
145
+ expect(participant.engine.addTrack).toHaveBeenCalledOnce();
146
+ return participant.engine.addTrack.mock.calls[0][0] as AddTrackRequest;
147
+ }
148
+
149
+ describe('LocalParticipant.publishAdditionalCodecForTrack', () => {
150
+ it('sets the capture dimensions and the backup codec layers', async () => {
151
+ const track = makeBackupCodecTrack({ width: 1280, height: 720 });
152
+ const participant = makeBackupCodecParticipant(track);
153
+
154
+ await participant.publishAdditionalCodecForTrack(track, 'vp8');
155
+
156
+ const req = capturedRequest(participant);
157
+ expect(req.width).toBe(1280);
158
+ expect(req.height).toBe(720);
159
+ expect(req.layers.length).toBeGreaterThan(0);
160
+ for (const layer of req.layers) {
161
+ expect(layer.width).toBeGreaterThan(0);
162
+ expect(layer.height).toBeGreaterThan(0);
163
+ }
164
+ expect(Math.max(...req.layers.map((layer) => layer.width))).toBe(1280);
165
+ expect(req.simulcastCodecs[0].codec).toBe('vp8');
166
+ expect(req.simulcastCodecs[0].layers).toEqual(req.layers);
167
+ expect(participant.log.warn).not.toHaveBeenCalled();
168
+ });
169
+
170
+ // screenshare disables simulcast for the backup codec, which collapses the encodings to a
171
+ // single unconstrained one and so to a single full size layer
172
+ it('sends one full size layer for a screenshare track', async () => {
173
+ const track = makeBackupCodecTrack({ width: 1280, height: 720 }, Track.Source.ScreenShare);
174
+ const participant = makeBackupCodecParticipant(track);
175
+
176
+ await participant.publishAdditionalCodecForTrack(track, 'vp8');
177
+
178
+ const req = capturedRequest(participant);
179
+ expect(req.layers).toHaveLength(1);
180
+ expect(req.layers[0].width).toBe(1280);
181
+ expect(req.layers[0].height).toBe(720);
182
+ expect(req.layers[0].quality).toBe(VideoQuality.HIGH);
183
+ expect(req.simulcastCodecs[0].layers).toEqual(req.layers);
184
+ expect(participant.log.warn).not.toHaveBeenCalled();
185
+ });
186
+
187
+ it('sizes layers from the processed track when a processor resizes', async () => {
188
+ const track = makeBackupCodecTrack({ width: 640, height: 360 }, Track.Source.Camera, {
189
+ width: 1280,
190
+ height: 720,
191
+ });
192
+ const participant = makeBackupCodecParticipant(track);
193
+
194
+ await participant.publishAdditionalCodecForTrack(track, 'vp8');
195
+
196
+ const req = capturedRequest(participant);
197
+ expect(req.width).toBe(640);
198
+ expect(req.height).toBe(360);
199
+ expect(Math.max(...req.layers.map((layer) => layer.width))).toBe(640);
200
+ expect(req.simulcastCodecs[0].layers).toEqual(req.layers);
201
+ });
202
+
203
+ it('omits layers when the capture dimensions are unknown', async () => {
204
+ const track = makeBackupCodecTrack({});
205
+ const participant = makeBackupCodecParticipant(track);
206
+
207
+ await participant.publishAdditionalCodecForTrack(track, 'vp8');
208
+
209
+ const req = capturedRequest(participant);
210
+ expect(req.width).toBe(0);
211
+ expect(req.height).toBe(0);
212
+ expect(req.layers).toEqual([]);
213
+ expect(req.simulcastCodecs[0].layers).toEqual([]);
214
+ expect(participant.log.warn).toHaveBeenCalledOnce();
215
+ });
216
+ });