livekit-client 2.20.2 → 2.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -10
- 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 +6 -1
- package/dist/livekit-client.e2ee.worker.mjs.map +1 -1
- package/dist/livekit-client.esm.mjs +2588 -1056
- package/dist/livekit-client.esm.mjs.map +1 -1
- package/dist/livekit-client.fm.worker.js +1 -1
- package/dist/livekit-client.fm.worker.js.map +1 -1
- package/dist/livekit-client.fm.worker.mjs +6 -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/index.d.ts +1 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/room/PCTransport.d.ts +49 -2
- package/dist/src/room/PCTransport.d.ts.map +1 -1
- package/dist/src/room/RTCEngine.d.ts +48 -65
- package/dist/src/room/RTCEngine.d.ts.map +1 -1
- package/dist/src/room/Room.d.ts +6 -0
- package/dist/src/room/Room.d.ts.map +1 -1
- package/dist/src/room/data-channel/DataChannelManager.d.ts +62 -0
- package/dist/src/room/data-channel/DataChannelManager.d.ts.map +1 -0
- package/dist/src/room/data-channel/FlowControlledDataChannel.d.ts +95 -0
- package/dist/src/room/data-channel/FlowControlledDataChannel.d.ts.map +1 -0
- package/dist/src/room/data-channel/LossyDataChannel.d.ts +40 -0
- package/dist/src/room/data-channel/LossyDataChannel.d.ts.map +1 -0
- package/dist/src/room/data-channel/ReliableDataChannel.d.ts +56 -0
- package/dist/src/room/data-channel/ReliableDataChannel.d.ts.map +1 -0
- package/dist/src/room/data-channel/types.d.ts +12 -0
- package/dist/src/room/data-channel/types.d.ts.map +1 -0
- package/dist/src/room/data-stream/compression.d.ts +49 -0
- package/dist/src/room/data-stream/compression.d.ts.map +1 -0
- package/dist/src/room/data-stream/constants.d.ts +18 -0
- package/dist/src/room/data-stream/constants.d.ts.map +1 -0
- package/dist/src/room/data-stream/incoming/IncomingDataStreamManager.d.ts +4 -0
- package/dist/src/room/data-stream/incoming/IncomingDataStreamManager.d.ts.map +1 -1
- package/dist/src/room/data-stream/incoming/StreamReader.d.ts.map +1 -1
- package/dist/src/room/data-stream/outgoing/OutgoingDataStreamManager.d.ts +49 -2
- package/dist/src/room/data-stream/outgoing/OutgoingDataStreamManager.d.ts.map +1 -1
- package/dist/src/room/data-stream/outgoing/header-utils.d.ts +18 -0
- package/dist/src/room/data-stream/outgoing/header-utils.d.ts.map +1 -0
- package/dist/src/room/errors.d.ts +3 -1
- package/dist/src/room/errors.d.ts.map +1 -1
- package/dist/src/room/participant/LocalParticipant.d.ts +10 -2
- package/dist/src/room/participant/LocalParticipant.d.ts.map +1 -1
- package/dist/src/room/participant/RemoteParticipant.d.ts +11 -2
- package/dist/src/room/participant/RemoteParticipant.d.ts.map +1 -1
- package/dist/src/room/rpc/client/RpcClientManager.d.ts.map +1 -1
- package/dist/src/room/rpc/server/RpcServerManager.d.ts.map +1 -1
- package/dist/src/room/token-source/TokenSource.d.ts +19 -10
- package/dist/src/room/token-source/TokenSource.d.ts.map +1 -1
- package/dist/src/room/track/LocalVideoTrack.d.ts +12 -1
- package/dist/src/room/track/LocalVideoTrack.d.ts.map +1 -1
- package/dist/src/room/track/RemoteTrack.d.ts +3 -0
- package/dist/src/room/track/RemoteTrack.d.ts.map +1 -1
- package/dist/src/room/track/Track.d.ts.map +1 -1
- package/dist/src/room/types.d.ts +17 -0
- package/dist/src/room/types.d.ts.map +1 -1
- package/dist/src/room/utils.d.ts +28 -0
- package/dist/src/room/utils.d.ts.map +1 -1
- package/dist/src/test/promiseState.d.ts +12 -0
- package/dist/src/test/promiseState.d.ts.map +1 -0
- package/dist/src/test/signalToken.d.ts.map +1 -1
- package/dist/src/utils/AsyncQueue.d.ts +3 -3
- package/dist/src/utils/AsyncQueue.d.ts.map +1 -1
- package/dist/src/version.d.ts +5 -1
- package/dist/src/version.d.ts.map +1 -1
- package/dist/ts4.2/index.d.ts +1 -1
- package/dist/ts4.2/room/PCTransport.d.ts +49 -2
- package/dist/ts4.2/room/RTCEngine.d.ts +48 -65
- package/dist/ts4.2/room/Room.d.ts +6 -0
- package/dist/ts4.2/room/data-channel/DataChannelManager.d.ts +62 -0
- package/dist/ts4.2/room/data-channel/FlowControlledDataChannel.d.ts +95 -0
- package/dist/ts4.2/room/data-channel/LossyDataChannel.d.ts +41 -0
- package/dist/ts4.2/room/data-channel/ReliableDataChannel.d.ts +57 -0
- package/dist/ts4.2/room/data-channel/types.d.ts +12 -0
- package/dist/ts4.2/room/data-stream/compression.d.ts +49 -0
- package/dist/ts4.2/room/data-stream/constants.d.ts +18 -0
- package/dist/ts4.2/room/data-stream/incoming/IncomingDataStreamManager.d.ts +4 -0
- package/dist/ts4.2/room/data-stream/outgoing/OutgoingDataStreamManager.d.ts +49 -2
- package/dist/ts4.2/room/data-stream/outgoing/header-utils.d.ts +18 -0
- package/dist/ts4.2/room/errors.d.ts +3 -1
- package/dist/ts4.2/room/participant/LocalParticipant.d.ts +10 -2
- package/dist/ts4.2/room/participant/RemoteParticipant.d.ts +11 -1
- package/dist/ts4.2/room/token-source/TokenSource.d.ts +17 -8
- package/dist/ts4.2/room/track/LocalVideoTrack.d.ts +12 -1
- package/dist/ts4.2/room/track/RemoteTrack.d.ts +3 -0
- package/dist/ts4.2/room/types.d.ts +17 -0
- package/dist/ts4.2/room/utils.d.ts +28 -0
- package/dist/ts4.2/test/promiseState.d.ts +12 -0
- package/dist/ts4.2/utils/AsyncQueue.d.ts +3 -3
- package/dist/ts4.2/version.d.ts +5 -1
- package/package.json +14 -13
- package/src/api/SignalClient.e2e.test.ts +16 -9
- package/src/e2ee/utils.ts +1 -1
- package/src/index.ts +2 -0
- package/src/room/PCTransport.test.ts +243 -1
- package/src/room/PCTransport.ts +181 -80
- package/src/room/RTCEngine.test.ts +397 -101
- package/src/room/RTCEngine.ts +244 -545
- package/src/room/Room.test.ts +138 -4
- package/src/room/Room.ts +92 -25
- package/src/room/data-channel/DataChannelManager.test.ts +152 -0
- package/src/room/data-channel/DataChannelManager.ts +237 -0
- package/src/room/data-channel/FlowControlledDataChannel.test.ts +154 -0
- package/src/room/data-channel/FlowControlledDataChannel.ts +216 -0
- package/src/room/data-channel/LossyDataChannel.test.ts +118 -0
- package/src/room/data-channel/LossyDataChannel.ts +125 -0
- package/src/room/data-channel/ReliableDataChannel.test.ts +158 -0
- package/src/room/data-channel/ReliableDataChannel.ts +153 -0
- package/src/room/data-channel/types.ts +30 -0
- package/src/room/data-stream/compression.ts +117 -0
- package/src/room/data-stream/constants.ts +18 -0
- package/src/room/data-stream/incoming/IncomingDataStreamManager.test.ts +1601 -0
- package/src/room/data-stream/incoming/IncomingDataStreamManager.ts +438 -86
- package/src/room/data-stream/incoming/StreamReader.ts +9 -3
- package/src/room/data-stream/outgoing/OutgoingDataStreamManager.test.ts +1292 -0
- package/src/room/data-stream/outgoing/OutgoingDataStreamManager.ts +489 -116
- package/src/room/data-stream/outgoing/header-utils.ts +87 -0
- package/src/room/errors.ts +7 -2
- package/src/room/participant/LocalParticipant.ts +13 -0
- package/src/room/participant/RemoteParticipant.ts +19 -5
- package/src/room/rpc/client/RpcClientManager.test.ts +20 -27
- package/src/room/rpc/client/RpcClientManager.ts +1 -4
- package/src/room/rpc/server/RpcServerManager.test.ts +24 -23
- package/src/room/rpc/server/RpcServerManager.ts +1 -3
- package/src/room/token-source/TokenSource.ts +25 -12
- package/src/room/track/LocalVideoTrack.test.ts +105 -2
- package/src/room/track/LocalVideoTrack.ts +36 -10
- package/src/room/track/RemoteTrack.test.ts +144 -0
- package/src/room/track/RemoteTrack.ts +39 -12
- package/src/room/track/Track.ts +2 -1
- package/src/room/types.ts +18 -0
- package/src/room/utils.test.ts +71 -2
- package/src/room/utils.ts +119 -0
- package/src/test/promiseState.ts +23 -0
- package/src/test/signalServerSetup.ts +2 -1
- package/src/test/signalToken.ts +17 -13
- package/src/type-polyfills/header-extensions.d.ts +13 -0
- package/src/utils/AsyncQueue.ts +3 -3
- package/src/version.ts +5 -1
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { LossyDataChannel } from './LossyDataChannel';
|
|
3
|
+
import { DataChannelKind } from './types';
|
|
4
|
+
|
|
5
|
+
class FakeDataChannel extends EventTarget {
|
|
6
|
+
bufferedAmount = 0;
|
|
7
|
+
|
|
8
|
+
bufferedAmountLowThreshold = 64;
|
|
9
|
+
|
|
10
|
+
send = vi.fn();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Pass `dc: null` for a handle-less channel; omit it to get a fresh one. `null` (not `undefined`)
|
|
14
|
+
// is deliberate — a destructuring default fills in on `undefined`, so only `null` passes through.
|
|
15
|
+
function makeChannel(
|
|
16
|
+
bufferFullBehavior: 'drop' | 'wait',
|
|
17
|
+
{ dc = new FakeDataChannel() }: { dc?: FakeDataChannel | null } = {},
|
|
18
|
+
) {
|
|
19
|
+
const state = { engineClosed: false, skipSends: false };
|
|
20
|
+
const channel = new LossyDataChannel({
|
|
21
|
+
kind: DataChannelKind.LOSSY,
|
|
22
|
+
lowWaterMark: 64,
|
|
23
|
+
highWaterMark: 1024,
|
|
24
|
+
isEngineClosed: () => state.engineClosed,
|
|
25
|
+
bufferFullBehavior,
|
|
26
|
+
shouldSkipSends: () => state.skipSends,
|
|
27
|
+
});
|
|
28
|
+
if (dc) {
|
|
29
|
+
channel.attach(dc as unknown as RTCDataChannel);
|
|
30
|
+
}
|
|
31
|
+
const stats = channel as unknown as { statCurrentBytes: number; dropCount: number };
|
|
32
|
+
return { channel, dc, state, stats };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe('LossyDataChannel', () => {
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
vi.useFakeTimers();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
afterEach(() => {
|
|
41
|
+
vi.useRealTimers();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('drop policy: sends below the threshold and counts bytes', async () => {
|
|
45
|
+
const { channel, dc, stats } = makeChannel('drop');
|
|
46
|
+
const msg = new Uint8Array(10);
|
|
47
|
+
|
|
48
|
+
await channel.send(msg);
|
|
49
|
+
|
|
50
|
+
expect(dc.send).toHaveBeenCalledWith(msg);
|
|
51
|
+
expect(stats.statCurrentBytes).toBe(10);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('drop policy: discards while the buffer is above the dc threshold', async () => {
|
|
55
|
+
const { channel, dc, stats } = makeChannel('drop');
|
|
56
|
+
dc.bufferedAmount = 128; // above the 64-byte dc threshold
|
|
57
|
+
|
|
58
|
+
await channel.send(new Uint8Array(10));
|
|
59
|
+
|
|
60
|
+
expect(dc.send).not.toHaveBeenCalled();
|
|
61
|
+
expect(stats.dropCount).toBe(1);
|
|
62
|
+
expect(stats.statCurrentBytes).toBe(0);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('wait policy: parks above the high-water mark instead of dropping', async () => {
|
|
66
|
+
const { channel, dc } = makeChannel('wait');
|
|
67
|
+
dc.bufferedAmount = 2048;
|
|
68
|
+
const resolved = vi.fn();
|
|
69
|
+
const send = channel.send(new Uint8Array(10)).then(resolved);
|
|
70
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
71
|
+
expect(resolved).not.toHaveBeenCalled();
|
|
72
|
+
|
|
73
|
+
dc.bufferedAmount = 0;
|
|
74
|
+
dc.dispatchEvent(new Event('bufferedamountlow'));
|
|
75
|
+
await send;
|
|
76
|
+
expect(dc.send).toHaveBeenCalledTimes(1);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('skips sends while a reconnect is underway', async () => {
|
|
80
|
+
const { channel, dc, state } = makeChannel('drop');
|
|
81
|
+
state.skipSends = true;
|
|
82
|
+
|
|
83
|
+
await channel.send(new Uint8Array(10));
|
|
84
|
+
|
|
85
|
+
expect(dc.send).not.toHaveBeenCalled();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('tunes the dc threshold to ~100ms of observed byterate, clamped to the watermarks', async () => {
|
|
89
|
+
const { channel, dc } = makeChannel('drop');
|
|
90
|
+
channel.startThresholdTuning();
|
|
91
|
+
|
|
92
|
+
// 10_000 bytes/second → threshold byterate/10 = 1000, clamped to the 1024 high mark? No —
|
|
93
|
+
// 1000 is within [64, 1024], so it applies as-is.
|
|
94
|
+
await channel.send(new Uint8Array(10_000));
|
|
95
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
96
|
+
expect(dc.bufferedAmountLowThreshold).toBe(1000);
|
|
97
|
+
|
|
98
|
+
// Idle second → byterate 0 → clamps up to the low-water mark.
|
|
99
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
100
|
+
expect(dc.bufferedAmountLowThreshold).toBe(64);
|
|
101
|
+
|
|
102
|
+
channel.stopThresholdTuning();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('stopThresholdTuning halts adjustments and resets stats', async () => {
|
|
106
|
+
const { channel, dc, stats } = makeChannel('drop');
|
|
107
|
+
channel.startThresholdTuning();
|
|
108
|
+
await channel.send(new Uint8Array(500));
|
|
109
|
+
|
|
110
|
+
channel.stopThresholdTuning();
|
|
111
|
+
expect(stats.statCurrentBytes).toBe(0);
|
|
112
|
+
expect(stats.dropCount).toBe(0);
|
|
113
|
+
|
|
114
|
+
const before = dc.bufferedAmountLowThreshold;
|
|
115
|
+
await vi.advanceTimersByTimeAsync(2000);
|
|
116
|
+
expect(dc.bufferedAmountLowThreshold).toBe(before);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import log from '../../logger';
|
|
2
|
+
import type { NonSharedUint8Array } from '../../type-polyfills/non-shared-typed-arrays';
|
|
3
|
+
import CriticalTimers from '../timers';
|
|
4
|
+
import {
|
|
5
|
+
FlowControlledDataChannel,
|
|
6
|
+
type FlowControlledDataChannelOptions,
|
|
7
|
+
} from './FlowControlledDataChannel';
|
|
8
|
+
|
|
9
|
+
export interface LossyDataChannelOptions extends FlowControlledDataChannelOptions {
|
|
10
|
+
/**
|
|
11
|
+
* What to do with a send while the buffer is full: `drop` discards it to keep latency bounded
|
|
12
|
+
* (the classic lossy channel), `wait` backpressures the producer until there is headroom (the
|
|
13
|
+
* data-track channel, whose producer decides what to skip at frame granularity).
|
|
14
|
+
*/
|
|
15
|
+
bufferFullBehavior: 'drop' | 'wait';
|
|
16
|
+
/** Sends are silently discarded while this is true (a reconnect attempt is underway). */
|
|
17
|
+
shouldSkipSends: () => boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A lossy channel: flow control plus a per-instance full-buffer policy.
|
|
22
|
+
*
|
|
23
|
+
* Each instance owns its own byterate stat, drop counter, and (when tuning is started) the
|
|
24
|
+
* dynamic `bufferedAmountLowThreshold` adjustment that keeps the drop gate at roughly 100ms of
|
|
25
|
+
* buffered latency. Keeping these per instance is what prevents one channel's traffic from
|
|
26
|
+
* steering another channel's policy.
|
|
27
|
+
*/
|
|
28
|
+
export class LossyDataChannel extends FlowControlledDataChannel {
|
|
29
|
+
private bufferFullBehavior: 'drop' | 'wait';
|
|
30
|
+
|
|
31
|
+
private shouldSkipSends: () => boolean;
|
|
32
|
+
|
|
33
|
+
private statCurrentBytes = 0;
|
|
34
|
+
|
|
35
|
+
private statByterate = 0;
|
|
36
|
+
|
|
37
|
+
private statInterval: ReturnType<typeof setInterval> | undefined;
|
|
38
|
+
|
|
39
|
+
private dropCount = 0;
|
|
40
|
+
|
|
41
|
+
constructor(opts: LossyDataChannelOptions) {
|
|
42
|
+
super(opts);
|
|
43
|
+
this.bufferFullBehavior = opts.bufferFullBehavior;
|
|
44
|
+
this.shouldSkipSends = opts.shouldSkipSends;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Sends prepared bytes with this channel's full-buffer policy (drop or wait). */
|
|
48
|
+
async send(msg: NonSharedUint8Array) {
|
|
49
|
+
const dc = this.getChannel();
|
|
50
|
+
if (!dc) {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
// Depending on the channel's policy, either drop or wait for the buffer to drain below the
|
|
54
|
+
// high-water mark before continuing.
|
|
55
|
+
switch (this.bufferFullBehavior) {
|
|
56
|
+
case 'wait':
|
|
57
|
+
if (!this.isBelowHighWaterMark(dc)) {
|
|
58
|
+
await this.waitForHeadroomWithLock();
|
|
59
|
+
}
|
|
60
|
+
break;
|
|
61
|
+
case 'drop':
|
|
62
|
+
// We check against the actual threshold on the DC here, as it is tuned dynamically.
|
|
63
|
+
if (!this.isBelowLowWaterMark(dc)) {
|
|
64
|
+
// Drop messages to reduce latency
|
|
65
|
+
this.dropCount += 1;
|
|
66
|
+
if (this.dropCount % 100 === 0) {
|
|
67
|
+
log.warn(`dropping lossy data channel messages, total dropped: ${this.dropCount}`);
|
|
68
|
+
}
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
this.statCurrentBytes += msg.byteLength;
|
|
73
|
+
|
|
74
|
+
if (this.shouldSkipSends()) {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
dc.send(msg);
|
|
80
|
+
this.refreshBufferStatus();
|
|
81
|
+
} catch (error: unknown) {
|
|
82
|
+
// Preserve prior surface behaviour: a send that fails because the channel is closing is
|
|
83
|
+
// logged, not thrown, so lossy/data-track sends don't reject during teardown windows.
|
|
84
|
+
if (error instanceof TypeError) {
|
|
85
|
+
log.error(error);
|
|
86
|
+
} else {
|
|
87
|
+
throw error;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Starts the once-per-second adjustment of the channel's `bufferedAmountLowThreshold` to the
|
|
94
|
+
* observed byterate, keeping the drop gate at roughly 100ms of buffered latency (clamped to
|
|
95
|
+
* the watermarks). Restarts cleanly if already running.
|
|
96
|
+
*/
|
|
97
|
+
startThresholdTuning() {
|
|
98
|
+
this.stopThresholdTuning();
|
|
99
|
+
this.statInterval = CriticalTimers.setInterval(() => {
|
|
100
|
+
this.statByterate = this.statCurrentBytes;
|
|
101
|
+
this.statCurrentBytes = 0;
|
|
102
|
+
|
|
103
|
+
const dc = this.getChannel();
|
|
104
|
+
if (dc) {
|
|
105
|
+
// control buffered latency to ~100ms
|
|
106
|
+
const threshold = this.statByterate / 10;
|
|
107
|
+
dc.bufferedAmountLowThreshold = Math.min(
|
|
108
|
+
Math.max(threshold, this.lowWaterMark),
|
|
109
|
+
this.highWaterMark,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
}, 1000);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Stops the threshold tuning and resets the stats and drop counter. */
|
|
116
|
+
stopThresholdTuning() {
|
|
117
|
+
this.statByterate = 0;
|
|
118
|
+
this.statCurrentBytes = 0;
|
|
119
|
+
if (this.statInterval) {
|
|
120
|
+
CriticalTimers.clearInterval(this.statInterval);
|
|
121
|
+
this.statInterval = undefined;
|
|
122
|
+
}
|
|
123
|
+
this.dropCount = 0;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { UnexpectedConnectionState } from '../errors';
|
|
3
|
+
import { ReliableDataChannel } from './ReliableDataChannel';
|
|
4
|
+
import { DataChannelKind } from './types';
|
|
5
|
+
|
|
6
|
+
class FakeDataChannel extends EventTarget {
|
|
7
|
+
bufferedAmount = 0;
|
|
8
|
+
|
|
9
|
+
bufferedAmountLowThreshold = 64;
|
|
10
|
+
|
|
11
|
+
send = vi.fn();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const tick = () => new Promise<void>((resolve) => setTimeout(resolve, 0));
|
|
15
|
+
|
|
16
|
+
// Pass `dc: null` for a handle-less channel; omit it to get a fresh one. `null` (not `undefined`)
|
|
17
|
+
// is deliberate — a destructuring default fills in on `undefined`, so only `null` passes through.
|
|
18
|
+
function makeChannel({ dc = new FakeDataChannel() }: { dc?: FakeDataChannel | null } = {}) {
|
|
19
|
+
const state = { engineClosed: false, deferring: false };
|
|
20
|
+
const channel = new ReliableDataChannel({
|
|
21
|
+
kind: DataChannelKind.RELIABLE,
|
|
22
|
+
lowWaterMark: 64,
|
|
23
|
+
highWaterMark: 1024,
|
|
24
|
+
isEngineClosed: () => state.engineClosed,
|
|
25
|
+
isDeferringSends: () => state.deferring,
|
|
26
|
+
});
|
|
27
|
+
if (dc) {
|
|
28
|
+
channel.attach(dc as unknown as RTCDataChannel);
|
|
29
|
+
}
|
|
30
|
+
const buffer = (channel as unknown as { messageBuffer: { getAll(): unknown[]; length: number } })
|
|
31
|
+
.messageBuffer as unknown as {
|
|
32
|
+
getAll(): Array<{ data: Uint8Array; sequence: number; sent: boolean }>;
|
|
33
|
+
length: number;
|
|
34
|
+
};
|
|
35
|
+
return { channel, dc, state, buffer };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe('ReliableDataChannel', () => {
|
|
39
|
+
it('hands out monotonic sequences and resets them with the session', () => {
|
|
40
|
+
const { channel } = makeChannel();
|
|
41
|
+
expect(channel.nextSequence()).toBe(1);
|
|
42
|
+
expect(channel.nextSequence()).toBe(2);
|
|
43
|
+
channel.reset();
|
|
44
|
+
expect(channel.nextSequence()).toBe(1);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('sends below the high-water mark and retains the packet for replay as sent', async () => {
|
|
48
|
+
const { channel, dc, buffer } = makeChannel();
|
|
49
|
+
const msg = new Uint8Array([1]);
|
|
50
|
+
|
|
51
|
+
await channel.send(msg, channel.nextSequence());
|
|
52
|
+
|
|
53
|
+
expect(dc.send).toHaveBeenCalledWith(msg);
|
|
54
|
+
expect(buffer.getAll()).toEqual([{ data: msg, sequence: 1, sent: true }]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('queues unsent while sends are deferred (reconnect window)', async () => {
|
|
58
|
+
const { channel, dc, state, buffer } = makeChannel();
|
|
59
|
+
state.deferring = true;
|
|
60
|
+
|
|
61
|
+
await channel.send(new Uint8Array([1]), channel.nextSequence());
|
|
62
|
+
|
|
63
|
+
expect(dc.send).not.toHaveBeenCalled();
|
|
64
|
+
expect(buffer.getAll()[0].sent).toBe(false);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('queues unsent and resolves when the headroom wait is torn down transiently', async () => {
|
|
68
|
+
const { channel, dc, buffer } = makeChannel();
|
|
69
|
+
dc.bufferedAmount = 2048; // above high mark → send parks
|
|
70
|
+
const send = channel.send(new Uint8Array([1]), channel.nextSequence());
|
|
71
|
+
await tick();
|
|
72
|
+
|
|
73
|
+
channel.invalidateWaiters('channel replaced');
|
|
74
|
+
|
|
75
|
+
await expect(send).resolves.toBeUndefined();
|
|
76
|
+
expect(dc.send).not.toHaveBeenCalled();
|
|
77
|
+
expect(buffer.getAll()[0].sent).toBe(false);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('rejects when the engine is closed while waiting', async () => {
|
|
81
|
+
const { channel, dc, state } = makeChannel();
|
|
82
|
+
dc.bufferedAmount = 2048;
|
|
83
|
+
const send = channel.send(new Uint8Array([1]), channel.nextSequence());
|
|
84
|
+
send.catch(() => {});
|
|
85
|
+
await tick();
|
|
86
|
+
|
|
87
|
+
state.engineClosed = true;
|
|
88
|
+
channel.invalidateWaiters('engine closed');
|
|
89
|
+
|
|
90
|
+
await expect(send).rejects.toBeInstanceOf(UnexpectedConnectionState);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('replay drops acked packets, resends the rest in order, and marks them sent', async () => {
|
|
94
|
+
const { channel, dc, buffer } = makeChannel();
|
|
95
|
+
const acked = new Uint8Array([1]);
|
|
96
|
+
const unacked = new Uint8Array([2]);
|
|
97
|
+
await channel.send(acked, channel.nextSequence());
|
|
98
|
+
await channel.send(unacked, channel.nextSequence());
|
|
99
|
+
// A packet queued unsent during the reconnect window joins the replay.
|
|
100
|
+
const queued = new Uint8Array([3]);
|
|
101
|
+
(channel as unknown as { isDeferringSends: () => boolean }).isDeferringSends = () => true;
|
|
102
|
+
await channel.send(queued, 3);
|
|
103
|
+
dc.send.mockClear();
|
|
104
|
+
|
|
105
|
+
await channel.replay(1); // server acked sequence 1
|
|
106
|
+
|
|
107
|
+
expect(dc.send.mock.calls.map(([data]) => data)).toEqual([unacked, queued]);
|
|
108
|
+
expect(buffer.getAll().every((item) => item.sent)).toBe(true);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('transmits a packet deferred mid-replay instead of marking it sent without sending', async () => {
|
|
112
|
+
const { channel, dc, state, buffer } = makeChannel();
|
|
113
|
+
// A packet sent before the disconnect, still buffered (unacked) at resume.
|
|
114
|
+
await channel.send(new Uint8Array([1]), channel.nextSequence());
|
|
115
|
+
dc.send.mockClear();
|
|
116
|
+
|
|
117
|
+
// Replay parks on a full buffer, giving an await window mid-drain.
|
|
118
|
+
dc.bufferedAmount = 2048;
|
|
119
|
+
const replay = channel.replay(0);
|
|
120
|
+
await tick();
|
|
121
|
+
|
|
122
|
+
// A send arrives while replay is parked; the reconnect is still active, so it defers into the
|
|
123
|
+
// buffer as sent:false — after replay's first drain pass already started.
|
|
124
|
+
state.deferring = true;
|
|
125
|
+
await channel.send(new Uint8Array([2]), channel.nextSequence());
|
|
126
|
+
state.deferring = false;
|
|
127
|
+
|
|
128
|
+
dc.bufferedAmount = 0;
|
|
129
|
+
dc.dispatchEvent(new Event('bufferedamountlow'));
|
|
130
|
+
await replay;
|
|
131
|
+
|
|
132
|
+
// The drain loop must transmit both; a blanket mark-all-sent would flip the late arrival to
|
|
133
|
+
// sent without sending it, and a later align would then strand it.
|
|
134
|
+
expect(dc.send.mock.calls.map(([d]) => d[0])).toEqual([1, 2]);
|
|
135
|
+
expect(buffer.getAll().filter((i) => !i.sent)).toHaveLength(0);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('holds the headroom lock across the whole replay so new sends cannot interleave', async () => {
|
|
139
|
+
const { channel, dc } = makeChannel();
|
|
140
|
+
(channel as unknown as { isDeferringSends: () => boolean }).isDeferringSends = () => true;
|
|
141
|
+
await channel.send(new Uint8Array([1]), 1);
|
|
142
|
+
await channel.send(new Uint8Array([2]), 2);
|
|
143
|
+
(channel as unknown as { isDeferringSends: () => boolean }).isDeferringSends = () => false;
|
|
144
|
+
|
|
145
|
+
// Replay parks on a full buffer while a concurrent send races it.
|
|
146
|
+
dc.bufferedAmount = 2048;
|
|
147
|
+
const replay = channel.replay(0);
|
|
148
|
+
await tick();
|
|
149
|
+
const concurrent = channel.send(new Uint8Array([9]), channel.nextSequence());
|
|
150
|
+
await tick();
|
|
151
|
+
|
|
152
|
+
dc.bufferedAmount = 0;
|
|
153
|
+
dc.dispatchEvent(new Event('bufferedamountlow'));
|
|
154
|
+
await Promise.all([replay, concurrent]);
|
|
155
|
+
|
|
156
|
+
expect(dc.send.mock.calls.map(([data]) => data[0])).toEqual([1, 2, 9]);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import type { NonSharedUint8Array } from '../../type-polyfills/non-shared-typed-arrays';
|
|
2
|
+
import { DataPacketBuffer } from '../../utils/dataPacketBuffer';
|
|
3
|
+
import {
|
|
4
|
+
FlowControlledDataChannel,
|
|
5
|
+
type FlowControlledDataChannelOptions,
|
|
6
|
+
} from './FlowControlledDataChannel';
|
|
7
|
+
|
|
8
|
+
export interface ReliableDataChannelOptions extends FlowControlledDataChannelOptions {
|
|
9
|
+
/**
|
|
10
|
+
* Whether sends should currently be deferred to the resume replay instead of hitting the wire
|
|
11
|
+
* (i.e. a reconnect attempt is underway). Read at send time so the reliable channel matches the
|
|
12
|
+
* engine's reconnect state without owning it.
|
|
13
|
+
*/
|
|
14
|
+
isDeferringSends: () => boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The reliable channel: flow control plus delivery-across-resume semantics.
|
|
19
|
+
*
|
|
20
|
+
* Every packet gets a monotonic sequence (stamped into the protobuf by the caller before
|
|
21
|
+
* serialization, via {@link nextSequence}) and is retained in a replay buffer until the channel's
|
|
22
|
+
* `bufferedAmount` confirms it has been handed to the transport. Sends that land in a reconnect
|
|
23
|
+
* window — or whose headroom wait is torn down transiently — are queued unsent and resolve;
|
|
24
|
+
* {@link replay} delivers them (plus any unacked packets) after a resume. Only an engine close
|
|
25
|
+
* rejects, because no replay is coming after that.
|
|
26
|
+
*/
|
|
27
|
+
export class ReliableDataChannel extends FlowControlledDataChannel {
|
|
28
|
+
private messageBuffer = new DataPacketBuffer();
|
|
29
|
+
|
|
30
|
+
private sequence = 1;
|
|
31
|
+
|
|
32
|
+
private isDeferringSends: () => boolean;
|
|
33
|
+
|
|
34
|
+
constructor(opts: ReliableDataChannelOptions) {
|
|
35
|
+
super(opts);
|
|
36
|
+
this.isDeferringSends = opts.isDeferringSends;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Claims the next packet sequence. The caller stamps it into the packet before serialization,
|
|
41
|
+
* then passes it back to {@link send} so the replay buffer stays keyed by wire sequence.
|
|
42
|
+
*/
|
|
43
|
+
nextSequence(): number {
|
|
44
|
+
const sequence = this.sequence;
|
|
45
|
+
this.sequence += 1;
|
|
46
|
+
return sequence;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Sends prepared bytes with reliable semantics. Resolves once the packet has either been handed
|
|
51
|
+
* to the channel or queued for the resume replay; throws only when the engine is closed.
|
|
52
|
+
*/
|
|
53
|
+
async send(msg: NonSharedUint8Array, sequence: number) {
|
|
54
|
+
if (this.isDeferringSends()) {
|
|
55
|
+
// A reconnect is already underway — queue for the resume replay instead of parking on a
|
|
56
|
+
// channel that is being torn down. The send resolves; delivery is deferred to the replay.
|
|
57
|
+
this.messageBuffer.push({ data: msg, sequence, sent: false });
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const dc = this.getChannel();
|
|
62
|
+
if (!dc) {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
await this.waitForHeadroomWithLock();
|
|
68
|
+
} catch (error) {
|
|
69
|
+
if (this.isEngineClosed()) {
|
|
70
|
+
// No replay is coming after an engine close — surface the failure.
|
|
71
|
+
throw error;
|
|
72
|
+
}
|
|
73
|
+
// Transient teardown (the channel closed or was replaced while we waited): the reliable
|
|
74
|
+
// 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
|
+
this.messageBuffer.push({ data: msg, sequence, sent: false });
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (this.isDeferringSends()) {
|
|
81
|
+
// A reconnect began while we waited for headroom — same deal as above.
|
|
82
|
+
this.messageBuffer.push({ data: msg, sequence, sent: false });
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
this.messageBuffer.push({ data: msg, sequence, sent: true });
|
|
87
|
+
dc.send(msg);
|
|
88
|
+
this.refreshBufferStatus();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Replays the buffered backlog after a resume: drops everything the server acked
|
|
93
|
+
* (`lastMessageSeq`), then re-sends the rest in order. The headroom lock is held across the
|
|
94
|
+
* whole replay — releasing it between messages would let a concurrent send (whose newer
|
|
95
|
+
* sequence was already assigned before it queued on the lock) hit the wire mid-replay, and
|
|
96
|
+
* receivers would then discard the remaining lower-sequence resent messages as duplicates.
|
|
97
|
+
*/
|
|
98
|
+
async replay(lastMessageSeq: number) {
|
|
99
|
+
const dc = this.getChannel();
|
|
100
|
+
if (!dc) {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
this.messageBuffer.popToSequence(lastMessageSeq);
|
|
104
|
+
const unlock = await this.lockHeadroom();
|
|
105
|
+
try {
|
|
106
|
+
// Everything left after the ack cutoff must be re-handed to the current channel.
|
|
107
|
+
this.messageBuffer.markAllUnsent();
|
|
108
|
+
// Drain in passes, re-scanning the live buffer each time: a send that arrives (deferred,
|
|
109
|
+
// sent:false) during our own awaits appends after this pass started, so we pick it up on
|
|
110
|
+
// the next one. Mark each packet only once we've actually handed it to the channel — a
|
|
111
|
+
// blanket "mark all sent" would flip such a late arrival to sent without transmitting it,
|
|
112
|
+
// and a later alignBufferedAmount would then drop it for good. If the loop throws
|
|
113
|
+
// mid-drain, unsent entries keep their flag and the next replay picks them up.
|
|
114
|
+
for (
|
|
115
|
+
let batch = this.messageBuffer.getUnsent();
|
|
116
|
+
batch.length > 0;
|
|
117
|
+
batch = this.messageBuffer.getUnsent()
|
|
118
|
+
) {
|
|
119
|
+
for (const item of batch) {
|
|
120
|
+
// Respect flow control on resume too, so a large resend doesn't overflow the buffer.
|
|
121
|
+
await this.waitForHeadroomWithoutLock();
|
|
122
|
+
dc.send(item.data);
|
|
123
|
+
this.messageBuffer.markSent(item);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
} finally {
|
|
127
|
+
unlock();
|
|
128
|
+
}
|
|
129
|
+
this.refreshBufferStatus();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Before recomputing status, trim packets the transport has now delivered — a send or a drain
|
|
134
|
+
* may have acked buffered packets, and the replay buffer is keyed off the channel's buffered
|
|
135
|
+
* bytes.
|
|
136
|
+
*/
|
|
137
|
+
override refreshBufferStatus() {
|
|
138
|
+
const dc = this.channelHandle;
|
|
139
|
+
if (dc) {
|
|
140
|
+
this.messageBuffer.alignBufferedAmount(dc.bufferedAmount);
|
|
141
|
+
}
|
|
142
|
+
super.refreshBufferStatus();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Drops all replay state and restarts sequencing. Only valid on a full reconnect, where the
|
|
147
|
+
* session (and the receivers' sequence tracking) starts over.
|
|
148
|
+
*/
|
|
149
|
+
reset() {
|
|
150
|
+
this.messageBuffer = new DataPacketBuffer();
|
|
151
|
+
this.sequence = 1;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { DataPacket_Kind } from '@livekit/protocol';
|
|
2
|
+
|
|
3
|
+
export enum DataChannelKind {
|
|
4
|
+
RELIABLE = DataPacket_Kind.RELIABLE,
|
|
5
|
+
LOSSY = DataPacket_Kind.LOSSY,
|
|
6
|
+
DATA_TRACK_LOSSY = 2,
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// Two-watermark flow control for the reliable and data-track channels. Senders fill the buffer
|
|
10
|
+
// freely up to the high-water mark; once it's exceeded they block until the browser's
|
|
11
|
+
// `bufferedamountlow` event (which we arm at the low-water mark) signals the buffer has drained.
|
|
12
|
+
// The gap between the marks keeps the SCTP send buffer saturated while we refill, so throughput
|
|
13
|
+
// isn't starved, while the high-water mark bounds the buffer well below the level that would abort
|
|
14
|
+
// the channel (see livekit/client-sdk-js#1995).
|
|
15
|
+
export const reliableDataChannelWaterMarkLow = 64 * 1024;
|
|
16
|
+
export const reliableDataChannelWaterMarkHigh = 1024 * 1024;
|
|
17
|
+
export const lossyDataChannelWaterMarkLow = 8 * 1024;
|
|
18
|
+
export const lossyDataChannelWaterMarkHigh = 256 * 1024;
|
|
19
|
+
|
|
20
|
+
export function dataChannelLowWaterMark(kind: DataChannelKind): number {
|
|
21
|
+
return kind === DataChannelKind.RELIABLE
|
|
22
|
+
? reliableDataChannelWaterMarkLow
|
|
23
|
+
: lossyDataChannelWaterMarkLow;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function dataChannelHighWaterMark(kind: DataChannelKind): number {
|
|
27
|
+
return kind === DataChannelKind.RELIABLE
|
|
28
|
+
? reliableDataChannelWaterMarkHigh
|
|
29
|
+
: lossyDataChannelWaterMarkHigh;
|
|
30
|
+
}
|