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.
- package/dist/livekit-client.e2ee.worker.js +1 -1
- package/dist/livekit-client.e2ee.worker.js.map +1 -1
- package/dist/livekit-client.e2ee.worker.mjs +69 -7
- package/dist/livekit-client.e2ee.worker.mjs.map +1 -1
- package/dist/livekit-client.esm.mjs +1133 -289
- package/dist/livekit-client.esm.mjs.map +1 -1
- package/dist/livekit-client.fm.worker.js.map +1 -1
- package/dist/livekit-client.fm.worker.mjs.map +1 -1
- package/dist/livekit-client.umd.js +1 -1
- package/dist/livekit-client.umd.js.map +1 -1
- package/dist/src/api/SignalClient.d.ts.map +1 -1
- package/dist/src/api/WebSocketStream.d.ts +1 -0
- package/dist/src/api/WebSocketStream.d.ts.map +1 -1
- package/dist/src/api/utils.d.ts +1 -1
- package/dist/src/api/utils.d.ts.map +1 -1
- package/dist/src/e2ee/E2eeManager.d.ts.map +1 -1
- package/dist/src/e2ee/worker/FrameCryptor.d.ts.map +1 -1
- package/dist/src/e2ee/worker/naluUtils.d.ts +12 -1
- package/dist/src/e2ee/worker/naluUtils.d.ts.map +1 -1
- package/dist/src/index.d.ts +2 -2
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/room/DefaultReconnectPolicy.d.ts.map +1 -1
- package/dist/src/room/PCTransport.d.ts +26 -4
- package/dist/src/room/PCTransport.d.ts.map +1 -1
- package/dist/src/room/RTCEngine.d.ts +8 -0
- package/dist/src/room/RTCEngine.d.ts.map +1 -1
- package/dist/src/room/Room.d.ts +53 -0
- package/dist/src/room/Room.d.ts.map +1 -1
- package/dist/src/room/data-channel/DataChannelManager.d.ts +9 -8
- package/dist/src/room/data-channel/DataChannelManager.d.ts.map +1 -1
- package/dist/src/room/data-channel/FlowControlledDataChannel.d.ts +25 -12
- package/dist/src/room/data-channel/FlowControlledDataChannel.d.ts.map +1 -1
- package/dist/src/room/data-channel/LossyDataChannel.d.ts.map +1 -1
- package/dist/src/room/data-channel/ReliableDataChannel.d.ts.map +1 -1
- package/dist/src/room/data-stream/incoming/IncomingDataStreamManager.d.ts.map +1 -1
- package/dist/src/room/participant/LocalParticipant.d.ts +4 -0
- package/dist/src/room/participant/LocalParticipant.d.ts.map +1 -1
- package/dist/src/room/participant/Participant.d.ts +1 -1
- package/dist/src/room/participant/Participant.d.ts.map +1 -1
- package/dist/src/room/rpc/utils.d.ts.map +1 -1
- package/dist/src/room/track/LocalAudioTrack.d.ts +24 -1
- package/dist/src/room/track/LocalAudioTrack.d.ts.map +1 -1
- package/dist/src/room/track/LocalTrack.d.ts +18 -0
- package/dist/src/room/track/LocalTrack.d.ts.map +1 -1
- package/dist/src/room/track/LocalVideoTrack.d.ts +7 -0
- package/dist/src/room/track/LocalVideoTrack.d.ts.map +1 -1
- package/dist/src/room/track/RemoteAudioTrack.d.ts +6 -0
- package/dist/src/room/track/RemoteAudioTrack.d.ts.map +1 -1
- package/dist/src/room/track/processor/types.d.ts +11 -0
- package/dist/src/room/track/processor/types.d.ts.map +1 -1
- package/dist/src/room/track/utils.d.ts.map +1 -1
- package/dist/src/room/utils.d.ts +28 -1
- package/dist/src/room/utils.d.ts.map +1 -1
- package/dist/ts4.2/api/WebSocketStream.d.ts +1 -0
- package/dist/ts4.2/api/utils.d.ts +1 -1
- package/dist/ts4.2/e2ee/worker/naluUtils.d.ts +12 -1
- package/dist/ts4.2/index.d.ts +2 -2
- package/dist/ts4.2/room/PCTransport.d.ts +26 -4
- package/dist/ts4.2/room/RTCEngine.d.ts +8 -0
- package/dist/ts4.2/room/Room.d.ts +53 -0
- package/dist/ts4.2/room/data-channel/DataChannelManager.d.ts +9 -8
- package/dist/ts4.2/room/data-channel/FlowControlledDataChannel.d.ts +25 -12
- package/dist/ts4.2/room/participant/LocalParticipant.d.ts +4 -0
- package/dist/ts4.2/room/participant/Participant.d.ts +1 -1
- package/dist/ts4.2/room/track/LocalAudioTrack.d.ts +24 -1
- package/dist/ts4.2/room/track/LocalTrack.d.ts +18 -0
- package/dist/ts4.2/room/track/LocalVideoTrack.d.ts +7 -0
- package/dist/ts4.2/room/track/RemoteAudioTrack.d.ts +6 -0
- package/dist/ts4.2/room/track/processor/types.d.ts +11 -0
- package/dist/ts4.2/room/utils.d.ts +28 -1
- package/package.json +1 -1
- package/src/api/SignalClient.test.ts +234 -0
- package/src/api/SignalClient.ts +58 -23
- package/src/api/WebSocketStream.test.ts +11 -0
- package/src/api/WebSocketStream.ts +22 -1
- package/src/api/utils.ts +1 -1
- package/src/e2ee/E2eeManager.test.ts +43 -0
- package/src/e2ee/E2eeManager.ts +6 -2
- package/src/e2ee/worker/FrameCryptor.test.ts +212 -90
- package/src/e2ee/worker/FrameCryptor.ts +21 -4
- package/src/e2ee/worker/naluUtils.test.ts +123 -0
- package/src/e2ee/worker/naluUtils.ts +57 -2
- package/src/index.ts +2 -0
- package/src/room/DefaultReconnectPolicy.test.ts +58 -0
- package/src/room/DefaultReconnectPolicy.ts +14 -2
- package/src/room/PCTransport.test.ts +144 -2
- package/src/room/PCTransport.ts +151 -9
- package/src/room/RTCEngine.test.ts +234 -2
- package/src/room/RTCEngine.ts +102 -7
- package/src/room/Room.test.ts +116 -0
- package/src/room/Room.ts +286 -74
- package/src/room/data-channel/DataChannelManager.test.ts +24 -7
- package/src/room/data-channel/DataChannelManager.ts +43 -38
- package/src/room/data-channel/FlowControlledDataChannel.test.ts +60 -18
- package/src/room/data-channel/FlowControlledDataChannel.ts +40 -22
- package/src/room/data-channel/LossyDataChannel.test.ts +38 -0
- package/src/room/data-channel/LossyDataChannel.ts +10 -1
- package/src/room/data-channel/ReliableDataChannel.test.ts +72 -0
- package/src/room/data-channel/ReliableDataChannel.ts +16 -5
- package/src/room/data-stream/incoming/IncomingDataStreamManager.test.ts +31 -0
- package/src/room/data-stream/incoming/IncomingDataStreamManager.ts +11 -0
- package/src/room/participant/LocalParticipant.test.ts +136 -1
- package/src/room/participant/LocalParticipant.ts +66 -17
- package/src/room/participant/Participant.ts +6 -3
- package/src/room/participant/restartOnDefaultAudioDevice.test.ts +177 -0
- package/src/room/rpc/utils.test.ts +32 -0
- package/src/room/rpc/utils.ts +7 -0
- package/src/room/track/LocalAudioTrack.ts +101 -10
- package/src/room/track/LocalTrack.ts +70 -13
- package/src/room/track/LocalVideoTrack.ts +7 -0
- package/src/room/track/RemoteAudioTrack.ts +56 -11
- package/src/room/track/processor/types.ts +11 -0
- package/src/room/track/utils.test.ts +5 -0
- package/src/room/track/utils.ts +29 -5
- package/src/room/utils.test.ts +126 -0
- 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('
|
|
79
|
-
const { channel } = makeChannel(
|
|
80
|
-
|
|
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
|
|
71
|
-
* parked waiters — their events would never fire again on the abandoned object — and
|
|
72
|
-
* fresh controller
|
|
73
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
125
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
146
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
+
});
|