livekit-client 2.20.2 → 2.21.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 +4 -0
- package/dist/livekit-client.e2ee.worker.mjs.map +1 -1
- package/dist/livekit-client.esm.mjs +1993 -791
- 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 +4 -0
- 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/RTCEngine.d.ts +28 -64
- package/dist/src/room/RTCEngine.d.ts.map +1 -1
- package/dist/src/room/Room.d.ts +4 -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 +9 -1
- 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/types.d.ts +17 -0
- package/dist/src/room/types.d.ts.map +1 -1
- package/dist/src/room/utils.d.ts +13 -0
- package/dist/src/room/utils.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/RTCEngine.d.ts +28 -64
- package/dist/ts4.2/room/Room.d.ts +4 -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 +9 -1
- package/dist/ts4.2/room/participant/RemoteParticipant.d.ts +11 -1
- package/dist/ts4.2/room/types.d.ts +17 -0
- package/dist/ts4.2/room/utils.d.ts +13 -0
- package/dist/ts4.2/version.d.ts +5 -1
- package/package.json +2 -2
- package/src/index.ts +2 -0
- package/src/room/RTCEngine.test.ts +58 -99
- package/src/room/RTCEngine.ts +92 -534
- package/src/room/Room.test.ts +4 -1
- package/src/room/Room.ts +40 -8
- 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 +8 -2
- 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 +6 -0
- 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/types.ts +18 -0
- package/src/room/utils.ts +67 -0
- package/src/version.ts +5 -1
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { Mutex } from '@livekit/mutex';
|
|
2
|
+
import TypedPromise from '../../utils/TypedPromise';
|
|
3
|
+
import { UnexpectedConnectionState } from '../errors';
|
|
4
|
+
import type { DataChannelKind } from './types';
|
|
5
|
+
|
|
6
|
+
export interface FlowControlledDataChannelOptions {
|
|
7
|
+
kind: DataChannelKind;
|
|
8
|
+
/** Buffer level (bytes) at which blocked senders resume; armed as `bufferedAmountLowThreshold`. */
|
|
9
|
+
lowWaterMark: number;
|
|
10
|
+
/** Buffer level (bytes) above which senders block until the buffer drains to the low mark. */
|
|
11
|
+
highWaterMark: number;
|
|
12
|
+
/** Whether the owning engine has been closed — a closed engine rejects waiters immediately. */
|
|
13
|
+
isEngineClosed: () => boolean;
|
|
14
|
+
/**
|
|
15
|
+
* Notified when the buffer crosses the low-water mark in either direction (debounced: fires only
|
|
16
|
+
* on an actual change). Drives the engine's public DCBufferStatusChanged event.
|
|
17
|
+
*/
|
|
18
|
+
onBufferStatusChanged?: (isLow: boolean) => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Two-watermark flow control for one data channel kind.
|
|
23
|
+
*
|
|
24
|
+
* Owns the per-kind headroom gate: senders proceed freely while the buffer is at or below the
|
|
25
|
+
* high-water mark and otherwise block — serialized FIFO through a mutex — until the browser's
|
|
26
|
+
* `bufferedamountlow` event (armed at the low-water mark) signals the buffer has drained. The
|
|
27
|
+
* serialization prevents woken senders from all refilling at once and overflowing the SCTP send
|
|
28
|
+
* buffer (see livekit/client-sdk-js#1995).
|
|
29
|
+
*
|
|
30
|
+
* Waiters are parked on the channel object captured at wait entry. If that object stops being
|
|
31
|
+
* current — replaced or torn down — its events may never fire again, so the owner must call
|
|
32
|
+
* {@link invalidateWaiters}, which aborts parked waiters (releasing the gate); the next waiter
|
|
33
|
+
* gets a fresh controller.
|
|
34
|
+
*/
|
|
35
|
+
export class FlowControlledDataChannel {
|
|
36
|
+
readonly kind: DataChannelKind;
|
|
37
|
+
|
|
38
|
+
readonly lowWaterMark: number;
|
|
39
|
+
|
|
40
|
+
readonly highWaterMark: number;
|
|
41
|
+
|
|
42
|
+
protected isEngineClosed: () => boolean;
|
|
43
|
+
|
|
44
|
+
private onBufferStatusChanged?: (isLow: boolean) => void;
|
|
45
|
+
|
|
46
|
+
/** Last emitted low-water status; starts true (an empty buffer is below the mark). */
|
|
47
|
+
private bufferStatusLow = true;
|
|
48
|
+
|
|
49
|
+
private handle?: RTCDataChannel;
|
|
50
|
+
|
|
51
|
+
private headroomLock = new Mutex();
|
|
52
|
+
|
|
53
|
+
/** Cancels parked headroom waiters when the handle is replaced or torn down. */
|
|
54
|
+
private waiterAbortController = new AbortController();
|
|
55
|
+
|
|
56
|
+
constructor(opts: FlowControlledDataChannelOptions) {
|
|
57
|
+
this.kind = opts.kind;
|
|
58
|
+
this.lowWaterMark = opts.lowWaterMark;
|
|
59
|
+
this.highWaterMark = opts.highWaterMark;
|
|
60
|
+
this.isEngineClosed = opts.isEngineClosed;
|
|
61
|
+
this.onBufferStatusChanged = opts.onBufferStatusChanged;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The currently attached RTCDataChannel handle, if any. */
|
|
65
|
+
get channelHandle(): RTCDataChannel | undefined {
|
|
66
|
+
return this.handle;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
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.
|
|
75
|
+
*/
|
|
76
|
+
attach(dc: RTCDataChannel) {
|
|
77
|
+
if (this.handle && this.handle !== dc) {
|
|
78
|
+
this.invalidateWaiters('data channel replaced');
|
|
79
|
+
}
|
|
80
|
+
this.handle = dc;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Detaches the handle on teardown, rejecting parked waiters. */
|
|
84
|
+
detach(reason: string = 'data channel torn down') {
|
|
85
|
+
if (this.handle) {
|
|
86
|
+
this.invalidateWaiters(reason);
|
|
87
|
+
}
|
|
88
|
+
this.handle = undefined;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
protected getChannel(): RTCDataChannel | undefined {
|
|
92
|
+
return this.handle;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Whether the send buffer has room to accept more data (the send gate). Senders proceed while
|
|
97
|
+
* this is true and block once it goes false. Callers resolve the handle (and decide what an
|
|
98
|
+
* absent one means) before asking.
|
|
99
|
+
*/
|
|
100
|
+
isBelowHighWaterMark(dc: RTCDataChannel): boolean {
|
|
101
|
+
// RTCDataChannel has no built-in high-water mark, so we compare against our static mark.
|
|
102
|
+
return dc.bufferedAmount <= this.highWaterMark;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Whether the send buffer has drained to its low-water mark. Drives the engine's public
|
|
107
|
+
* DCBufferStatusChanged event.
|
|
108
|
+
*/
|
|
109
|
+
isBelowLowWaterMark(dc: RTCDataChannel): boolean {
|
|
110
|
+
// Read the channel's own threshold: it is tuned dynamically for the lossy channel.
|
|
111
|
+
return dc.bufferedAmount <= dc.bufferedAmountLowThreshold;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Acquires the headroom lock, resolving with the unlock function. Batch senders (the resume
|
|
116
|
+
* replay) hold it across all of their sends so no other sender can interleave, calling
|
|
117
|
+
* {@link waitForHeadroomWithoutLock} per message to respect flow control within the batch.
|
|
118
|
+
*/
|
|
119
|
+
lockHeadroom(): Promise<() => void> {
|
|
120
|
+
return this.headroomLock.lock();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
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
|
|
126
|
+
* `bufferedamountlow` event). Callers are serialized through the headroom lock so that, when
|
|
127
|
+
* the buffer drains, they refill it one at a time (up to the high-water mark) rather than all
|
|
128
|
+
* 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.
|
|
130
|
+
*/
|
|
131
|
+
async waitForHeadroomWithLock() {
|
|
132
|
+
const unlock = await this.lockHeadroom();
|
|
133
|
+
try {
|
|
134
|
+
await this.waitForHeadroomWithoutLock();
|
|
135
|
+
} finally {
|
|
136
|
+
unlock();
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Core wait of {@link waitForHeadroomWithLock}. The caller must hold the headroom lock. */
|
|
141
|
+
async waitForHeadroomWithoutLock() {
|
|
142
|
+
if (this.isEngineClosed()) {
|
|
143
|
+
throw new UnexpectedConnectionState('engine closed');
|
|
144
|
+
}
|
|
145
|
+
const dc = this.getChannel();
|
|
146
|
+
if (!dc) {
|
|
147
|
+
throw new UnexpectedConnectionState(`DataChannel not found, kind: ${this.kind}`);
|
|
148
|
+
}
|
|
149
|
+
if (this.isBelowHighWaterMark(dc)) {
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
const abortSignal = this.waiterAbortController.signal;
|
|
153
|
+
await new TypedPromise<void, UnexpectedConnectionState>((resolve, reject) => {
|
|
154
|
+
const onBufferedAmountLow = () => {
|
|
155
|
+
cleanup();
|
|
156
|
+
resolve();
|
|
157
|
+
};
|
|
158
|
+
const onDCClose = () => {
|
|
159
|
+
cleanup();
|
|
160
|
+
reject(
|
|
161
|
+
new UnexpectedConnectionState(
|
|
162
|
+
`DataChannel ${this.kind} closed while draining the buffer`,
|
|
163
|
+
),
|
|
164
|
+
);
|
|
165
|
+
};
|
|
166
|
+
const onAbort = () => {
|
|
167
|
+
cleanup();
|
|
168
|
+
reject(
|
|
169
|
+
new UnexpectedConnectionState(
|
|
170
|
+
`DataChannel ${this.kind} was replaced or torn down while waiting for headroom`,
|
|
171
|
+
),
|
|
172
|
+
);
|
|
173
|
+
};
|
|
174
|
+
const cleanup = () => {
|
|
175
|
+
dc.removeEventListener('bufferedamountlow', onBufferedAmountLow);
|
|
176
|
+
dc.removeEventListener('close', onDCClose);
|
|
177
|
+
abortSignal.removeEventListener('abort', onAbort);
|
|
178
|
+
};
|
|
179
|
+
if (abortSignal.aborted) {
|
|
180
|
+
onAbort();
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
dc.addEventListener('bufferedamountlow', onBufferedAmountLow);
|
|
184
|
+
// Proxy along any error caused by the data channel closing while we wait.
|
|
185
|
+
dc.addEventListener('close', onDCClose);
|
|
186
|
+
// The channel object we're parked on can be abandoned without ever firing another event
|
|
187
|
+
// (e.g. the engine recreating channels); the abort is our way out.
|
|
188
|
+
abortSignal.addEventListener('abort', onAbort);
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Rejects all parked headroom waiters; the next waiter gets a fresh controller. */
|
|
193
|
+
invalidateWaiters(reason: string) {
|
|
194
|
+
this.waiterAbortController.abort(reason);
|
|
195
|
+
this.waiterAbortController = new AbortController();
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Recomputes whether the buffer has drained to the low-water mark and, if that changed since the
|
|
200
|
+
* last check, notifies the status listener. Two independent triggers land here: a send (which
|
|
201
|
+
* raises the buffer) and the `bufferedamountlow` drain event (which lowers it) — the latter has
|
|
202
|
+
* no send to hang the work off, which is why this is a shared entry point rather than a tail of
|
|
203
|
+
* `send`.
|
|
204
|
+
*/
|
|
205
|
+
refreshBufferStatus() {
|
|
206
|
+
const dc = this.getChannel();
|
|
207
|
+
if (!dc) {
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
const isLow = this.isBelowLowWaterMark(dc);
|
|
211
|
+
if (isLow !== this.bufferStatusLow) {
|
|
212
|
+
this.bufferStatusLow = isLow;
|
|
213
|
+
this.onBufferStatusChanged?.(isLow);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
@@ -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
|
+
});
|