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,237 @@
|
|
|
1
|
+
import type { PCTransportManager } from '../PCTransportManager';
|
|
2
|
+
import { FlowControlledDataChannel } from './FlowControlledDataChannel';
|
|
3
|
+
import { LossyDataChannel } from './LossyDataChannel';
|
|
4
|
+
import { ReliableDataChannel } from './ReliableDataChannel';
|
|
5
|
+
import { DataChannelKind, dataChannelHighWaterMark, dataChannelLowWaterMark } from './types';
|
|
6
|
+
|
|
7
|
+
const lossyDataChannelLabel = '_lossy';
|
|
8
|
+
const reliableDataChannelLabel = '_reliable';
|
|
9
|
+
const dataTrackDataChannelLabel = '_data_track';
|
|
10
|
+
|
|
11
|
+
export interface DataChannelManagerOptions {
|
|
12
|
+
/** Whether the owning engine has been closed — a closed engine rejects headroom waiters. */
|
|
13
|
+
isEngineClosed: () => boolean;
|
|
14
|
+
/**
|
|
15
|
+
* Whether a reconnect attempt is underway: reliable sends defer to the resume replay and lossy
|
|
16
|
+
* sends are skipped while this is true.
|
|
17
|
+
*/
|
|
18
|
+
isReconnecting: () => boolean;
|
|
19
|
+
onDataMessage: (message: MessageEvent) => void;
|
|
20
|
+
onDataTrackMessage: (message: MessageEvent) => void;
|
|
21
|
+
onDataError: (event: Event) => void;
|
|
22
|
+
onChannelClose: (kind: DataChannelKind) => void;
|
|
23
|
+
/** A channel's buffer crossed its low-water mark (debounced). Drives DCBufferStatusChanged. */
|
|
24
|
+
onBufferStatusChanged: (kind: DataChannelKind, isLow: boolean) => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Owns the engine's data channels: the three flow-controlled publisher channel wrappers (which
|
|
29
|
+
* live for the engine's lifetime and have RTCDataChannel handles attached/detached as peer
|
|
30
|
+
* connections come and go) plus the subscriber-side receive handles adopted by label.
|
|
31
|
+
*
|
|
32
|
+
* Handle turnover goes through {@link FlowControlledDataChannel.attach}/`detach`, which reject
|
|
33
|
+
* parked headroom waiters as a built-in — there is no separate invalidation step to forget.
|
|
34
|
+
*/
|
|
35
|
+
export class DataChannelManager {
|
|
36
|
+
readonly reliable: ReliableDataChannel;
|
|
37
|
+
|
|
38
|
+
readonly lossy: LossyDataChannel;
|
|
39
|
+
|
|
40
|
+
readonly dataTrack: LossyDataChannel;
|
|
41
|
+
|
|
42
|
+
private reliableSub?: RTCDataChannel;
|
|
43
|
+
|
|
44
|
+
private lossySub?: RTCDataChannel;
|
|
45
|
+
|
|
46
|
+
private dataTrackSub?: RTCDataChannel;
|
|
47
|
+
|
|
48
|
+
private opts: DataChannelManagerOptions;
|
|
49
|
+
|
|
50
|
+
constructor(opts: DataChannelManagerOptions) {
|
|
51
|
+
this.opts = opts;
|
|
52
|
+
const flowControlOptions = (kind: DataChannelKind) => ({
|
|
53
|
+
kind,
|
|
54
|
+
lowWaterMark: dataChannelLowWaterMark(kind),
|
|
55
|
+
highWaterMark: dataChannelHighWaterMark(kind),
|
|
56
|
+
isEngineClosed: opts.isEngineClosed,
|
|
57
|
+
onBufferStatusChanged: (isLow: boolean) => opts.onBufferStatusChanged(kind, isLow),
|
|
58
|
+
});
|
|
59
|
+
this.reliable = new ReliableDataChannel({
|
|
60
|
+
...flowControlOptions(DataChannelKind.RELIABLE),
|
|
61
|
+
isDeferringSends: opts.isReconnecting,
|
|
62
|
+
});
|
|
63
|
+
this.lossy = new LossyDataChannel({
|
|
64
|
+
...flowControlOptions(DataChannelKind.LOSSY),
|
|
65
|
+
// Classic lossy user data: a stale packet is worthless, so drop instead of queueing.
|
|
66
|
+
bufferFullBehavior: 'drop',
|
|
67
|
+
shouldSkipSends: opts.isReconnecting,
|
|
68
|
+
});
|
|
69
|
+
this.dataTrack = new LossyDataChannel({
|
|
70
|
+
...flowControlOptions(DataChannelKind.DATA_TRACK_LOSSY),
|
|
71
|
+
// Data tracks backpressure the producer instead — it decides what to skip at frame
|
|
72
|
+
// granularity rather than the engine dropping arbitrary chunks out of frames.
|
|
73
|
+
bufferFullBehavior: 'wait',
|
|
74
|
+
shouldSkipSends: opts.isReconnecting,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The flow-control wrapper for `kind`. */
|
|
79
|
+
channelFor(kind: DataChannelKind): FlowControlledDataChannel {
|
|
80
|
+
switch (kind) {
|
|
81
|
+
case DataChannelKind.RELIABLE:
|
|
82
|
+
return this.reliable;
|
|
83
|
+
case DataChannelKind.LOSSY:
|
|
84
|
+
return this.lossy;
|
|
85
|
+
case DataChannelKind.DATA_TRACK_LOSSY:
|
|
86
|
+
return this.dataTrack;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The raw RTCDataChannel handle for `kind`, publisher side by default. */
|
|
91
|
+
getHandle(kind: DataChannelKind, subscriber: boolean = false): RTCDataChannel | undefined {
|
|
92
|
+
if (!subscriber) {
|
|
93
|
+
return this.channelFor(kind).channelHandle;
|
|
94
|
+
}
|
|
95
|
+
switch (kind) {
|
|
96
|
+
case DataChannelKind.RELIABLE:
|
|
97
|
+
return this.reliableSub;
|
|
98
|
+
case DataChannelKind.LOSSY:
|
|
99
|
+
return this.lossySub;
|
|
100
|
+
case DataChannelKind.DATA_TRACK_LOSSY:
|
|
101
|
+
return this.dataTrackSub;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
get hasPublisherChannels(): boolean {
|
|
106
|
+
return Boolean(
|
|
107
|
+
this.reliable.channelHandle || this.lossy.channelHandle || this.dataTrack.channelHandle,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Creates the three publisher data channels on the given transport, wires their handlers, and
|
|
113
|
+
* attaches them to the wrappers — attaching rejects any waiters still parked on replaced
|
|
114
|
+
* channel objects.
|
|
115
|
+
*/
|
|
116
|
+
createPublisherChannels(pcManager: PCTransportManager) {
|
|
117
|
+
// clear old data channel callbacks if recreate
|
|
118
|
+
for (const channel of [this.lossy, this.reliable, this.dataTrack]) {
|
|
119
|
+
const old = channel.channelHandle;
|
|
120
|
+
if (old) {
|
|
121
|
+
old.onmessage = null;
|
|
122
|
+
old.onerror = null;
|
|
123
|
+
old.onclose = null;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const wire = (
|
|
128
|
+
channel: FlowControlledDataChannel,
|
|
129
|
+
dc: RTCDataChannel,
|
|
130
|
+
onMessage: (message: MessageEvent) => void,
|
|
131
|
+
) => {
|
|
132
|
+
// also handle messages over the pub channel, for backwards compatibility
|
|
133
|
+
dc.onmessage = onMessage;
|
|
134
|
+
// handle datachannel errors
|
|
135
|
+
dc.onerror = this.opts.onDataError;
|
|
136
|
+
// detect unexpected publisher data channel closes
|
|
137
|
+
dc.onclose = () => this.opts.onChannelClose(channel.kind);
|
|
138
|
+
// set up dc buffer threshold - if this is not set, it will default to 0
|
|
139
|
+
dc.bufferedAmountLowThreshold = channel.lowWaterMark;
|
|
140
|
+
// handle buffer amount low events
|
|
141
|
+
dc.onbufferedamountlow = () => channel.refreshBufferStatus();
|
|
142
|
+
channel.attach(dc);
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
wire(
|
|
146
|
+
this.lossy,
|
|
147
|
+
pcManager.createPublisherDataChannel(lossyDataChannelLabel, {
|
|
148
|
+
ordered: false,
|
|
149
|
+
maxRetransmits: 0,
|
|
150
|
+
}),
|
|
151
|
+
this.opts.onDataMessage,
|
|
152
|
+
);
|
|
153
|
+
wire(
|
|
154
|
+
this.reliable,
|
|
155
|
+
pcManager.createPublisherDataChannel(reliableDataChannelLabel, {
|
|
156
|
+
ordered: true,
|
|
157
|
+
}),
|
|
158
|
+
this.opts.onDataMessage,
|
|
159
|
+
);
|
|
160
|
+
wire(
|
|
161
|
+
this.dataTrack,
|
|
162
|
+
pcManager.createPublisherDataChannel(dataTrackDataChannelLabel, {
|
|
163
|
+
ordered: false,
|
|
164
|
+
maxRetransmits: 0,
|
|
165
|
+
}),
|
|
166
|
+
this.opts.onDataTrackMessage,
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
this.lossy.startThresholdTuning();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Adopts a subscriber-side data channel by label, wiring the matching receive handler.
|
|
174
|
+
* Returns false for labels this manager doesn't own.
|
|
175
|
+
*/
|
|
176
|
+
adoptSubscriberChannel(channel: RTCDataChannel): boolean {
|
|
177
|
+
let handler: (message: MessageEvent) => void;
|
|
178
|
+
if (channel.label === reliableDataChannelLabel) {
|
|
179
|
+
this.reliableSub = channel;
|
|
180
|
+
handler = this.opts.onDataMessage;
|
|
181
|
+
} else if (channel.label === lossyDataChannelLabel) {
|
|
182
|
+
this.lossySub = channel;
|
|
183
|
+
handler = this.opts.onDataMessage;
|
|
184
|
+
} else if (channel.label === dataTrackDataChannelLabel) {
|
|
185
|
+
this.dataTrackSub = channel;
|
|
186
|
+
handler = this.opts.onDataTrackMessage;
|
|
187
|
+
} else {
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
channel.onmessage = handler;
|
|
191
|
+
return true;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Tears down all channels for a peer-connection cleanup: rejects parked waiters (detach — the
|
|
196
|
+
* spec allows `pc.close()` to transition channels to 'closed' without firing events, so waiting
|
|
197
|
+
* for browser close events is not an option), strips handlers, closes the handles, and resets
|
|
198
|
+
* the reliable session state.
|
|
199
|
+
*/
|
|
200
|
+
teardown() {
|
|
201
|
+
const dcCleanup = (dc: RTCDataChannel | undefined) => {
|
|
202
|
+
if (!dc) {
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Detach the data channel handlers before closing anything. Closing a peer connection tears
|
|
207
|
+
// down the SCTP transport, which can dispatch `error`/`close` events on the still-open data
|
|
208
|
+
// channels; if our handlers are still attached at that point, handleDataError logs a spurious
|
|
209
|
+
// "Unknown DataChannel error" during an otherwise graceful disconnect. Removing the handlers
|
|
210
|
+
// before dc.close()/pcManager.close() makes this deterministic regardless of how/when the
|
|
211
|
+
// browser dispatches those teardown events. See livekit/client-sdk-js#1953.
|
|
212
|
+
dc.onbufferedamountlow = null;
|
|
213
|
+
dc.onclose = null;
|
|
214
|
+
dc.onclosing = null;
|
|
215
|
+
dc.onerror = null;
|
|
216
|
+
dc.onmessage = null;
|
|
217
|
+
dc.onopen = null;
|
|
218
|
+
|
|
219
|
+
dc.close();
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
for (const channel of [this.lossy, this.reliable, this.dataTrack]) {
|
|
223
|
+
const dc = channel.channelHandle;
|
|
224
|
+
channel.detach('peer connections cleaned up');
|
|
225
|
+
dcCleanup(dc);
|
|
226
|
+
}
|
|
227
|
+
dcCleanup(this.lossySub);
|
|
228
|
+
dcCleanup(this.reliableSub);
|
|
229
|
+
dcCleanup(this.dataTrackSub);
|
|
230
|
+
this.lossySub = undefined;
|
|
231
|
+
this.reliableSub = undefined;
|
|
232
|
+
this.dataTrackSub = undefined;
|
|
233
|
+
|
|
234
|
+
// Full teardown starts the session over: drop replay state and restart sequencing.
|
|
235
|
+
this.reliable.reset();
|
|
236
|
+
}
|
|
237
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { UnexpectedConnectionState } from '../errors';
|
|
3
|
+
import { FlowControlledDataChannel } from './FlowControlledDataChannel';
|
|
4
|
+
import { DataChannelKind } from './types';
|
|
5
|
+
|
|
6
|
+
class FakeDataChannel extends EventTarget {
|
|
7
|
+
bufferedAmount = 0;
|
|
8
|
+
|
|
9
|
+
bufferedAmountLowThreshold = 64;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const tick = () => new Promise<void>((resolve) => setTimeout(resolve, 0));
|
|
13
|
+
|
|
14
|
+
// Pass `dc: null` for a handle-less channel; omit it to get a fresh one. `null` (not `undefined`)
|
|
15
|
+
// is deliberate — a destructuring default fills in on `undefined`, so only `null` passes through.
|
|
16
|
+
function makeChannel({
|
|
17
|
+
dc = new FakeDataChannel(),
|
|
18
|
+
engineClosed = false,
|
|
19
|
+
}: { dc?: FakeDataChannel | null; engineClosed?: boolean } = {}) {
|
|
20
|
+
const state = { engineClosed };
|
|
21
|
+
const onBufferStatusChanged = vi.fn();
|
|
22
|
+
const channel = new FlowControlledDataChannel({
|
|
23
|
+
kind: DataChannelKind.RELIABLE,
|
|
24
|
+
lowWaterMark: 64,
|
|
25
|
+
highWaterMark: 1024,
|
|
26
|
+
isEngineClosed: () => state.engineClosed,
|
|
27
|
+
onBufferStatusChanged,
|
|
28
|
+
});
|
|
29
|
+
if (dc) {
|
|
30
|
+
channel.attach(dc as unknown as RTCDataChannel);
|
|
31
|
+
}
|
|
32
|
+
return { channel, dc: dc as FakeDataChannel, state, onBufferStatusChanged };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe('FlowControlledDataChannel', () => {
|
|
36
|
+
describe('refreshBufferStatus', () => {
|
|
37
|
+
it('notifies only on a change and in both directions', () => {
|
|
38
|
+
const { channel, dc, onBufferStatusChanged } = makeChannel();
|
|
39
|
+
|
|
40
|
+
// Starts "low" (empty buffer); a refresh that stays low emits nothing.
|
|
41
|
+
channel.refreshBufferStatus();
|
|
42
|
+
expect(onBufferStatusChanged).not.toHaveBeenCalled();
|
|
43
|
+
|
|
44
|
+
// Cross above the low mark → one not-low notification.
|
|
45
|
+
dc.bufferedAmount = 512;
|
|
46
|
+
channel.refreshBufferStatus();
|
|
47
|
+
channel.refreshBufferStatus(); // debounced: no second fire
|
|
48
|
+
expect(onBufferStatusChanged.mock.calls).toEqual([[false]]);
|
|
49
|
+
|
|
50
|
+
// Drain back below → one low notification.
|
|
51
|
+
dc.bufferedAmount = 0;
|
|
52
|
+
channel.refreshBufferStatus();
|
|
53
|
+
expect(onBufferStatusChanged.mock.calls).toEqual([[false], [true]]);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('is a no-op without a handle (no throw, no notification)', () => {
|
|
57
|
+
const { channel, onBufferStatusChanged } = makeChannel({ dc: null });
|
|
58
|
+
expect(() => channel.refreshBufferStatus()).not.toThrow();
|
|
59
|
+
expect(onBufferStatusChanged).not.toHaveBeenCalled();
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('reports watermark status against the given channel', () => {
|
|
64
|
+
const { channel, dc } = makeChannel();
|
|
65
|
+
const handle = dc as unknown as RTCDataChannel;
|
|
66
|
+
dc.bufferedAmount = 0;
|
|
67
|
+
expect(channel.isBelowHighWaterMark(handle)).toBe(true);
|
|
68
|
+
expect(channel.isBelowLowWaterMark(handle)).toBe(true);
|
|
69
|
+
|
|
70
|
+
dc.bufferedAmount = 512; // between low (64) and high (1024)
|
|
71
|
+
expect(channel.isBelowHighWaterMark(handle)).toBe(true);
|
|
72
|
+
expect(channel.isBelowLowWaterMark(handle)).toBe(false);
|
|
73
|
+
|
|
74
|
+
dc.bufferedAmount = 2048;
|
|
75
|
+
expect(channel.isBelowHighWaterMark(handle)).toBe(false);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('waiting for headroom without a handle rejects with a connection error', async () => {
|
|
79
|
+
const { channel } = makeChannel({ dc: null });
|
|
80
|
+
await expect(channel.waitForHeadroomWithLock()).rejects.toBeInstanceOf(
|
|
81
|
+
UnexpectedConnectionState,
|
|
82
|
+
);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('resolves immediately while below the high-water mark', async () => {
|
|
86
|
+
const { channel, dc } = makeChannel();
|
|
87
|
+
dc.bufferedAmount = 1024;
|
|
88
|
+
await expect(channel.waitForHeadroomWithLock()).resolves.toBeUndefined();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('parks above the high-water mark and resumes on bufferedamountlow', async () => {
|
|
92
|
+
const { channel, dc } = makeChannel();
|
|
93
|
+
dc.bufferedAmount = 2048;
|
|
94
|
+
const resolved = vi.fn();
|
|
95
|
+
const wait = channel.waitForHeadroomWithLock().then(resolved);
|
|
96
|
+
await tick();
|
|
97
|
+
expect(resolved).not.toHaveBeenCalled();
|
|
98
|
+
|
|
99
|
+
dc.bufferedAmount = 32;
|
|
100
|
+
dc.dispatchEvent(new Event('bufferedamountlow'));
|
|
101
|
+
await wait;
|
|
102
|
+
expect(resolved).toHaveBeenCalled();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('rejects a parked waiter when the channel closes', async () => {
|
|
106
|
+
const { channel, dc } = makeChannel();
|
|
107
|
+
dc.bufferedAmount = 2048;
|
|
108
|
+
const wait = channel.waitForHeadroomWithLock();
|
|
109
|
+
wait.catch(() => {});
|
|
110
|
+
await tick();
|
|
111
|
+
|
|
112
|
+
dc.dispatchEvent(new Event('close'));
|
|
113
|
+
await expect(wait).rejects.toBeInstanceOf(UnexpectedConnectionState);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('rejects a parked waiter on invalidateWaiters and recovers with a fresh controller', async () => {
|
|
117
|
+
const { channel, dc } = makeChannel();
|
|
118
|
+
dc.bufferedAmount = 2048;
|
|
119
|
+
const wait = channel.waitForHeadroomWithLock();
|
|
120
|
+
wait.catch(() => {});
|
|
121
|
+
await tick();
|
|
122
|
+
|
|
123
|
+
channel.invalidateWaiters('channel replaced');
|
|
124
|
+
await expect(wait).rejects.toBeInstanceOf(UnexpectedConnectionState);
|
|
125
|
+
|
|
126
|
+
// Fresh controller: the gate is usable again and the lock was released.
|
|
127
|
+
dc.bufferedAmount = 0;
|
|
128
|
+
await expect(channel.waitForHeadroomWithLock()).resolves.toBeUndefined();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('rejects immediately when the engine is closed', async () => {
|
|
132
|
+
const { channel, state } = makeChannel();
|
|
133
|
+
state.engineClosed = true;
|
|
134
|
+
await expect(channel.waitForHeadroomWithLock()).rejects.toBeInstanceOf(
|
|
135
|
+
UnexpectedConnectionState,
|
|
136
|
+
);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('serializes waiters FIFO through the headroom lock', async () => {
|
|
140
|
+
const { channel, dc } = makeChannel();
|
|
141
|
+
dc.bufferedAmount = 2048;
|
|
142
|
+
const order: number[] = [];
|
|
143
|
+
const first = channel.waitForHeadroomWithLock().then(() => order.push(1));
|
|
144
|
+
const second = channel.waitForHeadroomWithLock().then(() => order.push(2));
|
|
145
|
+
await tick();
|
|
146
|
+
|
|
147
|
+
// One drain event wakes the head waiter; the second re-checks under the lock and, with the
|
|
148
|
+
// buffer now low, proceeds right after — strictly in arrival order.
|
|
149
|
+
dc.bufferedAmount = 0;
|
|
150
|
+
dc.dispatchEvent(new Event('bufferedamountlow'));
|
|
151
|
+
await Promise.all([first, second]);
|
|
152
|
+
expect(order).toEqual([1, 2]);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
@@ -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
|
+
}
|