livekit-client 2.20.1 → 2.20.2
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 +33 -7
- package/dist/livekit-client.e2ee.worker.mjs.map +1 -1
- package/dist/livekit-client.esm.mjs +589 -163
- 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 +33 -7
- package/dist/livekit-client.fm.worker.mjs.map +1 -1
- package/dist/livekit-client.umd.js +1 -1
- package/dist/livekit-client.umd.js.map +1 -1
- package/dist/src/api/SignalClient.d.ts.map +1 -1
- package/dist/src/logger.d.ts +3 -1
- package/dist/src/logger.d.ts.map +1 -1
- package/dist/src/room/PCTransport.d.ts +2 -0
- package/dist/src/room/PCTransport.d.ts.map +1 -1
- package/dist/src/room/PCTransportManager.d.ts +1 -0
- package/dist/src/room/PCTransportManager.d.ts.map +1 -1
- package/dist/src/room/RTCEngine.d.ts +45 -4
- package/dist/src/room/RTCEngine.d.ts.map +1 -1
- package/dist/src/room/RegionUrlProvider.d.ts.map +1 -1
- package/dist/src/room/Room.d.ts.map +1 -1
- package/dist/src/room/data-track/incoming/IncomingDataTrackManager.d.ts +7 -0
- package/dist/src/room/data-track/incoming/IncomingDataTrackManager.d.ts.map +1 -1
- package/dist/src/room/data-track/outgoing/OutgoingDataTrackManager.d.ts.map +1 -1
- package/dist/src/room/participant/LocalParticipant.d.ts.map +1 -1
- package/dist/src/room/participant/Participant.d.ts.map +1 -1
- package/dist/src/room/participant/publishUtils.d.ts +7 -0
- package/dist/src/room/participant/publishUtils.d.ts.map +1 -1
- package/dist/src/room/track/LocalAudioTrack.d.ts +1 -0
- package/dist/src/room/track/LocalAudioTrack.d.ts.map +1 -1
- package/dist/src/room/track/Track.d.ts.map +1 -1
- package/dist/src/room/track/options.d.ts +11 -1
- package/dist/src/room/track/options.d.ts.map +1 -1
- package/dist/src/test/signalToken.d.ts +26 -0
- package/dist/src/test/signalToken.d.ts.map +1 -0
- package/dist/src/utils/dataPacketBuffer.d.ts +18 -0
- package/dist/src/utils/dataPacketBuffer.d.ts.map +1 -1
- package/dist/ts4.2/logger.d.ts +3 -1
- package/dist/ts4.2/room/PCTransport.d.ts +2 -0
- package/dist/ts4.2/room/PCTransportManager.d.ts +1 -0
- package/dist/ts4.2/room/RTCEngine.d.ts +45 -4
- package/dist/ts4.2/room/data-track/incoming/IncomingDataTrackManager.d.ts +7 -0
- package/dist/ts4.2/room/participant/publishUtils.d.ts +7 -0
- package/dist/ts4.2/room/track/LocalAudioTrack.d.ts +1 -0
- package/dist/ts4.2/room/track/options.d.ts +11 -1
- package/dist/ts4.2/test/signalToken.d.ts +26 -0
- package/dist/ts4.2/utils/dataPacketBuffer.d.ts +18 -0
- package/package.json +7 -3
- package/src/api/SignalClient.e2e.test.ts +264 -0
- package/src/api/SignalClient.ts +34 -2
- package/src/logger.ts +2 -0
- package/src/room/PCTransport.ts +66 -31
- package/src/room/PCTransportManager.ts +10 -6
- package/src/room/RTCEngine.test.ts +275 -2
- package/src/room/RTCEngine.ts +276 -71
- package/src/room/RegionUrlProvider.ts +4 -2
- package/src/room/Room.ts +7 -1
- package/src/room/data-track/incoming/IncomingDataTrackManager.test.ts +303 -0
- package/src/room/data-track/incoming/IncomingDataTrackManager.ts +70 -8
- package/src/room/data-track/outgoing/OutgoingDataTrackManager.ts +2 -0
- package/src/room/errors.ts +4 -4
- package/src/room/participant/LocalParticipant.ts +16 -6
- package/src/room/participant/Participant.ts +5 -0
- package/src/room/participant/publishUtils.ts +14 -11
- package/src/room/track/LocalAudioTrack.ts +16 -0
- package/src/room/track/Track.ts +10 -6
- package/src/room/track/options.ts +11 -1
- package/src/test/signalServerSetup.ts +151 -0
- package/src/test/signalToken.ts +59 -0
- package/src/test/vitest-context.d.ts +10 -0
- package/src/utils/dataPacketBuffer.test.ts +100 -0
- package/src/utils/dataPacketBuffer.ts +46 -1
package/src/room/PCTransport.ts
CHANGED
|
@@ -15,15 +15,24 @@ interface TrackBitrateInfo {
|
|
|
15
15
|
transceiver?: RTCRtpTransceiver;
|
|
16
16
|
codec: string;
|
|
17
17
|
maxbr: number;
|
|
18
|
+
isScreenShare?: boolean;
|
|
18
19
|
}
|
|
19
20
|
|
|
20
|
-
/*
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
21
|
+
/*
|
|
22
|
+
* Video codecs use a very low bitrate at the beginning and increase slowly by
|
|
23
|
+
* the bandwidth estimator until they reach the target bitrate. The process commonly
|
|
24
|
+
* costs more than 10 seconds causing subscribers to get blurry video at the first
|
|
25
|
+
* few seconds. We use x-google-start-bitrate to hint the BWE to start higher.
|
|
26
|
+
*
|
|
27
|
+
* Why 90%: Gives ~10% headroom for bandwidth estimation while starting close to target.
|
|
28
|
+
* Why same for all codecs: Target bitrate already accounts for codec efficiency
|
|
29
|
+
* (e.g., users set lower targets for VP9/AV1 knowing they're more efficient).
|
|
30
|
+
* Why cap at 1 Mbps: Prevents BWE from starting too aggressively on high bitrate tracks.
|
|
31
|
+
*/
|
|
32
|
+
const startBitrateMultiplier = 0.9;
|
|
33
|
+
|
|
34
|
+
/** Maximum x-google-start-bitrate in kbps. 1 Mbps prevents BWE from starting too aggressively. */
|
|
35
|
+
const maxStartBitrateKbps = 1000;
|
|
27
36
|
|
|
28
37
|
const debounceInterval = 20;
|
|
29
38
|
|
|
@@ -53,6 +62,8 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
|
|
|
53
62
|
|
|
54
63
|
private log = log;
|
|
55
64
|
|
|
65
|
+
private iceLog = log;
|
|
66
|
+
|
|
56
67
|
private loggerOptions: LoggerOptions;
|
|
57
68
|
|
|
58
69
|
private ddExtID = 0;
|
|
@@ -95,8 +106,12 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
|
|
|
95
106
|
|
|
96
107
|
constructor(config?: RTCConfiguration, loggerOptions: LoggerOptions = {}) {
|
|
97
108
|
super();
|
|
98
|
-
this.log = getLogger(loggerOptions.loggerName ?? LoggerNames.PCTransport);
|
|
99
109
|
this.loggerOptions = loggerOptions;
|
|
110
|
+
this.log = getLogger(
|
|
111
|
+
loggerOptions.loggerName ?? LoggerNames.PCTransport,
|
|
112
|
+
() => this.logContext,
|
|
113
|
+
);
|
|
114
|
+
this.iceLog = getLogger(LoggerNames.ICE, () => this.logContext);
|
|
100
115
|
this.config = config;
|
|
101
116
|
this._pc = this.createPC();
|
|
102
117
|
this.offerLock = new Mutex();
|
|
@@ -107,24 +122,33 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
|
|
|
107
122
|
|
|
108
123
|
pc.onicecandidate = (ev) => {
|
|
109
124
|
if (!ev.candidate) return;
|
|
125
|
+
this.iceLog.debug('local ICE candidate gathered', { candidate: ev.candidate.candidate });
|
|
110
126
|
this.onIceCandidate?.(ev.candidate);
|
|
111
127
|
};
|
|
112
128
|
pc.onicecandidateerror = (ev) => {
|
|
129
|
+
this.iceLog.debug('ICE candidate error', { event: ev });
|
|
113
130
|
this.onIceCandidateError?.(ev);
|
|
114
131
|
};
|
|
115
132
|
|
|
116
133
|
pc.oniceconnectionstatechange = () => {
|
|
134
|
+
this.iceLog.debug(`ICE connection state: ${pc.iceConnectionState}`);
|
|
117
135
|
this.onIceConnectionStateChange?.(pc.iceConnectionState);
|
|
118
136
|
};
|
|
119
137
|
|
|
120
138
|
pc.onsignalingstatechange = () => {
|
|
139
|
+
this.log.debug(`signaling state: ${pc.signalingState}`);
|
|
121
140
|
this.onSignalingStatechange?.(pc.signalingState);
|
|
122
141
|
};
|
|
123
142
|
|
|
124
143
|
pc.onconnectionstatechange = () => {
|
|
144
|
+
this.log.debug(`connection state: ${pc.connectionState}`);
|
|
125
145
|
this.onConnectionStateChange?.(pc.connectionState);
|
|
126
146
|
};
|
|
127
147
|
pc.ondatachannel = (ev) => {
|
|
148
|
+
this.log.debug('data channel opened by peer', {
|
|
149
|
+
label: ev.channel.label,
|
|
150
|
+
id: ev.channel.id,
|
|
151
|
+
});
|
|
128
152
|
this.onDataChannel?.(ev);
|
|
129
153
|
};
|
|
130
154
|
pc.ontrack = (ev) => {
|
|
@@ -150,6 +174,9 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
|
|
|
150
174
|
if (this.pc.remoteDescription && !this.restartingIce) {
|
|
151
175
|
return this.pc.addIceCandidate(candidate);
|
|
152
176
|
}
|
|
177
|
+
this.iceLog.debug('queuing remote ICE candidate until remote description applied', {
|
|
178
|
+
pendingCount: this.pendingCandidates.length + 1,
|
|
179
|
+
});
|
|
153
180
|
this.pendingCandidates.push(candidate);
|
|
154
181
|
}
|
|
155
182
|
|
|
@@ -161,7 +188,6 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
|
|
|
161
188
|
offerId !== this.latestOfferId
|
|
162
189
|
) {
|
|
163
190
|
this.log.warn('ignoring answer for old offer', {
|
|
164
|
-
...this.logContext,
|
|
165
191
|
offerId,
|
|
166
192
|
latestOfferId: this.latestOfferId,
|
|
167
193
|
});
|
|
@@ -180,7 +206,7 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
|
|
|
180
206
|
sdpParsed.media.forEach((media) => {
|
|
181
207
|
ensureIPAddrMatchVersion(media);
|
|
182
208
|
});
|
|
183
|
-
this.log.debug('setting pending initial offer before processing answer'
|
|
209
|
+
this.log.debug('setting pending initial offer before processing answer');
|
|
184
210
|
await this.setMungedSDP(initialOffer, write(sdpParsed));
|
|
185
211
|
}
|
|
186
212
|
const sdpParsed = parse(sd.sdp ?? '');
|
|
@@ -249,6 +275,11 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
|
|
|
249
275
|
}
|
|
250
276
|
await this.setMungedSDP(sd, mungedSDP, true);
|
|
251
277
|
|
|
278
|
+
if (this.pendingCandidates.length > 0) {
|
|
279
|
+
this.iceLog.debug('flushing queued ICE candidates', {
|
|
280
|
+
count: this.pendingCandidates.length,
|
|
281
|
+
});
|
|
282
|
+
}
|
|
252
283
|
this.pendingCandidates.forEach((candidate) => {
|
|
253
284
|
this.pc.addIceCandidate(candidate);
|
|
254
285
|
});
|
|
@@ -298,10 +329,7 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
|
|
|
298
329
|
const unlock = await this.offerLock.lock();
|
|
299
330
|
try {
|
|
300
331
|
if (this.pc.signalingState !== 'stable') {
|
|
301
|
-
this.log.warn(
|
|
302
|
-
'signaling state is not stable, cannot create initial offer',
|
|
303
|
-
this.logContext,
|
|
304
|
-
);
|
|
332
|
+
this.log.warn('signaling state is not stable, cannot create initial offer');
|
|
305
333
|
return;
|
|
306
334
|
}
|
|
307
335
|
const offerId = this.latestOfferId + 1;
|
|
@@ -328,7 +356,7 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
|
|
|
328
356
|
}
|
|
329
357
|
|
|
330
358
|
if (options?.iceRestart) {
|
|
331
|
-
this.
|
|
359
|
+
this.iceLog.debug('restarting ICE');
|
|
332
360
|
this.restartingIce = true;
|
|
333
361
|
}
|
|
334
362
|
|
|
@@ -345,21 +373,21 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
|
|
|
345
373
|
await this._pc.setRemoteDescription(currentSD);
|
|
346
374
|
} else {
|
|
347
375
|
this.renegotiate = true;
|
|
348
|
-
this.log.debug('requesting renegotiation'
|
|
376
|
+
this.log.debug('requesting renegotiation');
|
|
349
377
|
return;
|
|
350
378
|
}
|
|
351
379
|
} else if (!this._pc || this._pc.signalingState === 'closed') {
|
|
352
|
-
this.log.warn('could not createOffer with closed peer connection'
|
|
380
|
+
this.log.warn('could not createOffer with closed peer connection');
|
|
353
381
|
return;
|
|
354
382
|
}
|
|
355
383
|
|
|
356
384
|
// actually negotiate
|
|
357
|
-
this.log.debug('starting to negotiate'
|
|
385
|
+
this.log.debug('starting to negotiate');
|
|
358
386
|
// increase the offer id at the start to ensure the offer is always > 0 so that we can use 0 as a default value for legacy behavior
|
|
359
387
|
const offerId = this.latestOfferId + 1;
|
|
360
388
|
this.latestOfferId = offerId;
|
|
361
389
|
const offer = await this.pc.createOffer(options);
|
|
362
|
-
this.log.debug('original offer', { sdp: offer.sdp
|
|
390
|
+
this.log.debug('original offer', { sdp: offer.sdp });
|
|
363
391
|
|
|
364
392
|
const sdpParsed = parse(offer.sdp ?? '');
|
|
365
393
|
sdpParsed.media.forEach((media) => {
|
|
@@ -390,14 +418,18 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
|
|
|
390
418
|
}
|
|
391
419
|
|
|
392
420
|
// mung sdp for bitrate setting that can't apply by sendEncoding
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
const startBitrate =
|
|
421
|
+
// Use 90% of target bitrate, capped at 1 Mbps for camera to prevent BWE from starting too aggressively
|
|
422
|
+
// Screen share is not capped since text/UI clarity requires high bitrate from the start
|
|
423
|
+
// TODO: dynamically adjust start bitrate based on network conditions (e.g., use previous BWE estimate)
|
|
424
|
+
const calculatedStartBitrate = Math.round(trackbr.maxbr * startBitrateMultiplier);
|
|
425
|
+
const startBitrate = trackbr.isScreenShare
|
|
426
|
+
? calculatedStartBitrate
|
|
427
|
+
: Math.min(calculatedStartBitrate, maxStartBitrateKbps);
|
|
398
428
|
|
|
429
|
+
let fmtpFound = false;
|
|
399
430
|
for (const fmtp of media.fmtp) {
|
|
400
431
|
if (fmtp.payload === codecPayload) {
|
|
432
|
+
fmtpFound = true;
|
|
401
433
|
// if another track's fmtp already is set, we cannot override the bitrate
|
|
402
434
|
// this has the unfortunate consequence of being forced to use the
|
|
403
435
|
// initial track's bitrate for all tracks
|
|
@@ -407,6 +439,13 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
|
|
|
407
439
|
break;
|
|
408
440
|
}
|
|
409
441
|
}
|
|
442
|
+
// VP8 and some codecs may not have an existing fmtp line - create one
|
|
443
|
+
if (!fmtpFound) {
|
|
444
|
+
media.fmtp.push({
|
|
445
|
+
payload: codecPayload,
|
|
446
|
+
config: `x-google-start-bitrate=${startBitrate}`,
|
|
447
|
+
});
|
|
448
|
+
}
|
|
410
449
|
return true;
|
|
411
450
|
});
|
|
412
451
|
}
|
|
@@ -424,7 +463,6 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
|
|
|
424
463
|
}
|
|
425
464
|
if (this.latestOfferId > offerId) {
|
|
426
465
|
this.log.warn('latestOfferId mismatch', {
|
|
427
|
-
...this.logContext,
|
|
428
466
|
latestOfferId: this.latestOfferId,
|
|
429
467
|
offerId,
|
|
430
468
|
});
|
|
@@ -576,6 +614,7 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
|
|
|
576
614
|
if (!this._pc) {
|
|
577
615
|
return;
|
|
578
616
|
}
|
|
617
|
+
this.log.debug('closing peer connection');
|
|
579
618
|
this.pendingInitialOffer = undefined;
|
|
580
619
|
this._pc.close();
|
|
581
620
|
this._pc.onconnectionstatechange = null;
|
|
@@ -597,10 +636,7 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
|
|
|
597
636
|
if (munged) {
|
|
598
637
|
sd.sdp = munged;
|
|
599
638
|
try {
|
|
600
|
-
this.log.debug(
|
|
601
|
-
`setting munged ${remote ? 'remote' : 'local'} description`,
|
|
602
|
-
this.logContext,
|
|
603
|
-
);
|
|
639
|
+
this.log.debug(`setting munged ${remote ? 'remote' : 'local'} description`);
|
|
604
640
|
if (remote) {
|
|
605
641
|
await this.pc.setRemoteDescription(sd);
|
|
606
642
|
} else {
|
|
@@ -609,7 +645,6 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
|
|
|
609
645
|
return;
|
|
610
646
|
} catch (e) {
|
|
611
647
|
this.log.warn(`not able to set ${sd.type}, falling back to unmodified sdp`, {
|
|
612
|
-
...this.logContext,
|
|
613
648
|
error: e,
|
|
614
649
|
mungedSdp: munged,
|
|
615
650
|
originalSdp,
|
|
@@ -642,7 +677,7 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
|
|
|
642
677
|
if (!remote && this.pc.remoteDescription) {
|
|
643
678
|
fields.remoteSdp = this.pc.remoteDescription;
|
|
644
679
|
}
|
|
645
|
-
this.log.error(`unable to set ${sd.type}`, {
|
|
680
|
+
this.log.error(`unable to set ${sd.type}`, { fields });
|
|
646
681
|
throw new NegotiationError(msg);
|
|
647
682
|
}
|
|
648
683
|
}
|
|
@@ -65,6 +65,8 @@ export class PCTransportManager {
|
|
|
65
65
|
|
|
66
66
|
private log = log;
|
|
67
67
|
|
|
68
|
+
private iceLog = log;
|
|
69
|
+
|
|
68
70
|
private loggerOptions: LoggerOptions;
|
|
69
71
|
|
|
70
72
|
private _mode: PCMode;
|
|
@@ -74,8 +76,9 @@ export class PCTransportManager {
|
|
|
74
76
|
}
|
|
75
77
|
|
|
76
78
|
constructor(mode: PCMode, loggerOptions: LoggerOptions, rtcConfig?: RTCConfiguration) {
|
|
77
|
-
this.log = getLogger(loggerOptions.loggerName ?? LoggerNames.PCManager);
|
|
78
79
|
this.loggerOptions = loggerOptions;
|
|
80
|
+
this.log = getLogger(loggerOptions.loggerName ?? LoggerNames.PCManager, () => this.logContext);
|
|
81
|
+
this.iceLog = getLogger(LoggerNames.ICE, () => this.logContext);
|
|
79
82
|
|
|
80
83
|
this.isPublisherConnectionRequired = mode !== 'subscriber-primary';
|
|
81
84
|
this.isSubscriberConnectionRequired = mode === 'subscriber-primary';
|
|
@@ -151,7 +154,7 @@ export class PCTransportManager {
|
|
|
151
154
|
publisher.removeTrack(sender);
|
|
152
155
|
}
|
|
153
156
|
} catch (e) {
|
|
154
|
-
this.log.warn('could not removeTrack', {
|
|
157
|
+
this.log.warn('could not removeTrack', { error: e });
|
|
155
158
|
}
|
|
156
159
|
}
|
|
157
160
|
}
|
|
@@ -160,6 +163,7 @@ export class PCTransportManager {
|
|
|
160
163
|
}
|
|
161
164
|
|
|
162
165
|
async triggerIceRestart() {
|
|
166
|
+
this.iceLog.warn('triggering ICE restart');
|
|
163
167
|
if (this.subscriber) {
|
|
164
168
|
this.subscriber.restartingIce = true;
|
|
165
169
|
}
|
|
@@ -170,6 +174,7 @@ export class PCTransportManager {
|
|
|
170
174
|
}
|
|
171
175
|
|
|
172
176
|
async addIceCandidate(candidate: RTCIceCandidateInit, target: SignalTarget) {
|
|
177
|
+
this.iceLog.debug('adding remote ICE candidate', { target, candidate });
|
|
173
178
|
if (target === SignalTarget.PUBLISHER) {
|
|
174
179
|
await this.publisher.addIceCandidate(candidate);
|
|
175
180
|
} else {
|
|
@@ -179,7 +184,6 @@ export class PCTransportManager {
|
|
|
179
184
|
|
|
180
185
|
async createSubscriberAnswerFromOffer(sd: RTCSessionDescriptionInit, offerId: number) {
|
|
181
186
|
this.log.debug('received server offer', {
|
|
182
|
-
...this.logContext,
|
|
183
187
|
RTCSdpType: sd.type,
|
|
184
188
|
sdp: sd.sdp,
|
|
185
189
|
signalingState: this.subscriber?.getSignallingState().toString(),
|
|
@@ -200,6 +204,7 @@ export class PCTransportManager {
|
|
|
200
204
|
}
|
|
201
205
|
|
|
202
206
|
updateConfiguration(config: RTCConfiguration, iceRestart?: boolean) {
|
|
207
|
+
this.log.debug('updating rtc configuration', { iceRestart });
|
|
203
208
|
this.publisher.setConfiguration(config);
|
|
204
209
|
this.subscriber?.setConfiguration(config);
|
|
205
210
|
if (iceRestart) {
|
|
@@ -215,7 +220,7 @@ export class PCTransportManager {
|
|
|
215
220
|
this.publisher.getConnectionState() !== 'connected' &&
|
|
216
221
|
this.publisher.getConnectionState() !== 'connecting'
|
|
217
222
|
) {
|
|
218
|
-
this.log.debug('negotiation required, start negotiating'
|
|
223
|
+
this.log.debug('negotiation required, start negotiating');
|
|
219
224
|
this.publisher.negotiate();
|
|
220
225
|
}
|
|
221
226
|
await Promise.all(
|
|
@@ -368,7 +373,6 @@ export class PCTransportManager {
|
|
|
368
373
|
`pc state change: from ${PCTransportState[previousState]} to ${
|
|
369
374
|
PCTransportState[this.state]
|
|
370
375
|
}`,
|
|
371
|
-
this.logContext,
|
|
372
376
|
);
|
|
373
377
|
this.onStateChange?.(
|
|
374
378
|
this.state,
|
|
@@ -390,7 +394,7 @@ export class PCTransportManager {
|
|
|
390
394
|
|
|
391
395
|
return new Promise<void>(async (resolve, reject) => {
|
|
392
396
|
const abortHandler = () => {
|
|
393
|
-
this.log.warn('abort transport connection'
|
|
397
|
+
this.log.warn('abort transport connection');
|
|
394
398
|
CriticalTimers.clearTimeout(connectTimeout);
|
|
395
399
|
|
|
396
400
|
reject(ConnectionError.cancelled('room connection has been cancelled'));
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { DataPacket, DataPacket_Kind, UserPacket } from '@livekit/protocol';
|
|
2
2
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import type { DataPacketBuffer, DataPacketItem } from '../utils/dataPacketBuffer';
|
|
3
4
|
import RTCEngine, { DataChannelKind } from './RTCEngine';
|
|
4
5
|
import { roomOptionDefaults } from './defaults';
|
|
5
|
-
import { PublishDataError } from './errors';
|
|
6
|
+
import { PublishDataError, UnexpectedConnectionState } from './errors';
|
|
6
7
|
|
|
7
8
|
describe('RTCEngine', () => {
|
|
8
9
|
const originalRTCRtpSender = window.RTCRtpSender;
|
|
@@ -230,7 +231,7 @@ describe('RTCEngine', () => {
|
|
|
230
231
|
const send = vi.fn();
|
|
231
232
|
Object.assign(engine as unknown as Record<string, unknown>, {
|
|
232
233
|
ensurePublisherConnected: vi.fn().mockResolvedValue(undefined),
|
|
233
|
-
|
|
234
|
+
waitForBufferHeadroomWithLock: vi.fn().mockResolvedValue(undefined),
|
|
234
235
|
updateAndEmitDCBufferStatus: vi.fn(),
|
|
235
236
|
dataChannelForKind: vi.fn(() => ({ send })),
|
|
236
237
|
pcManager: {
|
|
@@ -298,6 +299,278 @@ describe('RTCEngine', () => {
|
|
|
298
299
|
});
|
|
299
300
|
});
|
|
300
301
|
|
|
302
|
+
class FakeDataChannel extends EventTarget {
|
|
303
|
+
bufferedAmount = 0;
|
|
304
|
+
|
|
305
|
+
bufferedAmountLowThreshold = 64 * 1024;
|
|
306
|
+
|
|
307
|
+
send = vi.fn();
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const tick = () => new Promise<void>((resolve) => setTimeout(resolve, 0));
|
|
311
|
+
|
|
312
|
+
describe('resendReliableMessagesForResume', () => {
|
|
313
|
+
it('does not let a concurrent reliable send interleave into the resume replay', async () => {
|
|
314
|
+
const engine = new RTCEngine(roomOptionDefaults);
|
|
315
|
+
const dc = new FakeDataChannel();
|
|
316
|
+
Object.assign(engine as unknown as Record<string, unknown>, {
|
|
317
|
+
_isClosed: false,
|
|
318
|
+
ensurePublisherConnected: vi.fn().mockResolvedValue(undefined),
|
|
319
|
+
dataChannelForKind: vi.fn(() => dc),
|
|
320
|
+
pcManager: {
|
|
321
|
+
getMaxPublisherMessageSize: vi.fn(() => 64 * 1024 - 1),
|
|
322
|
+
},
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
// Two messages queued for replay, and a full buffer so the XX its first send.
|
|
326
|
+
const replayed1 = new Uint8Array([1]);
|
|
327
|
+
const replayed2 = new Uint8Array([2]);
|
|
328
|
+
const buffer = (
|
|
329
|
+
engine as unknown as { reliableMessageBuffer: { push: (item: DataPacketItem) => void } }
|
|
330
|
+
).reliableMessageBuffer;
|
|
331
|
+
buffer.push({ data: replayed1, sequence: 1, sent: true });
|
|
332
|
+
buffer.push({ data: replayed2, sequence: 2, sent: true });
|
|
333
|
+
dc.bufferedAmount = 2 * 1024 * 1024; // above the reliable high-water mark
|
|
334
|
+
|
|
335
|
+
const replay = (
|
|
336
|
+
engine as unknown as { resendReliableMessagesForResume: (seq: number) => Promise<void> }
|
|
337
|
+
).resendReliableMessagesForResume(0);
|
|
338
|
+
await tick();
|
|
339
|
+
|
|
340
|
+
// A send racing the replay: its sequence is assigned immediately, but it must not hit the
|
|
341
|
+
// wire before the replayed (lower-sequence) messages, or receivers discard those as dupes.
|
|
342
|
+
const concurrentSend = engine.sendDataPacket(
|
|
343
|
+
new DataPacket({
|
|
344
|
+
kind: DataPacket_Kind.RELIABLE,
|
|
345
|
+
value: { case: 'user', value: new UserPacket({ payload: new Uint8Array([3]) }) },
|
|
346
|
+
}),
|
|
347
|
+
DataChannelKind.RELIABLE,
|
|
348
|
+
);
|
|
349
|
+
await tick();
|
|
350
|
+
|
|
351
|
+
// Buffer drains: the replay must finish its whole batch before the concurrent send.
|
|
352
|
+
dc.bufferedAmount = 0;
|
|
353
|
+
dc.dispatchEvent(new Event('bufferedamountlow'));
|
|
354
|
+
await Promise.all([replay, concurrentSend]);
|
|
355
|
+
|
|
356
|
+
expect(dc.send).toHaveBeenCalledTimes(3);
|
|
357
|
+
expect(dc.send.mock.calls[0][0]).toBe(replayed1);
|
|
358
|
+
expect(dc.send.mock.calls[1][0]).toBe(replayed2);
|
|
359
|
+
expect(dc.send.mock.calls[2][0]).not.toBe(replayed1);
|
|
360
|
+
expect(dc.send.mock.calls[2][0]).not.toBe(replayed2);
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it('transmits a packet deferred mid-replay instead of marking it sent without sending', async () => {
|
|
364
|
+
const engine = new RTCEngine(roomOptionDefaults);
|
|
365
|
+
const dc = new FakeDataChannel();
|
|
366
|
+
Object.assign(engine as unknown as Record<string, unknown>, {
|
|
367
|
+
_isClosed: false,
|
|
368
|
+
// Reconnect still active: a send arriving during replay takes the deferral path.
|
|
369
|
+
attemptingReconnect: true,
|
|
370
|
+
ensurePublisherConnected: vi.fn().mockResolvedValue(undefined),
|
|
371
|
+
dataChannelForKind: vi.fn(() => dc),
|
|
372
|
+
pcManager: {
|
|
373
|
+
getMaxPublisherMessageSize: vi.fn(() => 64 * 1024 - 1),
|
|
374
|
+
},
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
const buffer = (engine as unknown as { reliableMessageBuffer: DataPacketBuffer })
|
|
378
|
+
.reliableMessageBuffer;
|
|
379
|
+
const replayed = new Uint8Array([1]);
|
|
380
|
+
buffer.push({ data: replayed, sequence: 1, sent: true });
|
|
381
|
+
// Full buffer so replay parks on waitForBufferHeadroomWithLock before its first send.
|
|
382
|
+
dc.bufferedAmount = 2 * 1024 * 1024;
|
|
383
|
+
|
|
384
|
+
const replay = (
|
|
385
|
+
engine as unknown as { resendReliableMessagesForResume: (seq: number) => Promise<void> }
|
|
386
|
+
).resendReliableMessagesForResume(0);
|
|
387
|
+
await tick();
|
|
388
|
+
|
|
389
|
+
// A reliable send arrives while replay is parked; attemptingReconnect defers it into the
|
|
390
|
+
// buffer as sent:false, after the replay's first drain pass already started.
|
|
391
|
+
const deferred = new Uint8Array([2]);
|
|
392
|
+
const deferredSend = engine.sendDataPacket(
|
|
393
|
+
new DataPacket({
|
|
394
|
+
kind: DataPacket_Kind.RELIABLE,
|
|
395
|
+
value: { case: 'user', value: new UserPacket({ payload: deferred }) },
|
|
396
|
+
}),
|
|
397
|
+
DataChannelKind.RELIABLE,
|
|
398
|
+
);
|
|
399
|
+
await tick();
|
|
400
|
+
|
|
401
|
+
dc.bufferedAmount = 0;
|
|
402
|
+
dc.dispatchEvent(new Event('bufferedamountlow'));
|
|
403
|
+
await Promise.all([replay, deferredSend]);
|
|
404
|
+
|
|
405
|
+
// Pre-fix, replay sends only its snapshot ([replayed]) and blanket-marks the deferred packet
|
|
406
|
+
// sent without ever transmitting it — one send. The drain loop must transmit both (the
|
|
407
|
+
// deferred packet is serialized through sendDataPacket, so it's distinct from `replayed`),
|
|
408
|
+
// leaving nothing unsent for a later align to strand.
|
|
409
|
+
const sent = dc.send.mock.calls.map(([d]) => d);
|
|
410
|
+
expect(sent).toHaveLength(2);
|
|
411
|
+
expect(sent[0]).toBe(replayed);
|
|
412
|
+
expect(sent[1]).not.toBe(replayed);
|
|
413
|
+
expect(buffer.getUnsent()).toHaveLength(0);
|
|
414
|
+
});
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
describe('reliable sends during teardown windows', () => {
|
|
418
|
+
const makePacket = (byte: number) =>
|
|
419
|
+
new DataPacket({
|
|
420
|
+
kind: DataPacket_Kind.RELIABLE,
|
|
421
|
+
value: { case: 'user', value: new UserPacket({ payload: new Uint8Array([byte]) }) },
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
function stubEngine(engine: RTCEngine, dc: FakeDataChannel) {
|
|
425
|
+
Object.assign(engine as unknown as Record<string, unknown>, {
|
|
426
|
+
_isClosed: false,
|
|
427
|
+
ensurePublisherConnected: vi.fn().mockResolvedValue(undefined),
|
|
428
|
+
dataChannelForKind: vi.fn(() => dc),
|
|
429
|
+
pcManager: {
|
|
430
|
+
getMaxPublisherMessageSize: vi.fn(() => 64 * 1024 - 1),
|
|
431
|
+
},
|
|
432
|
+
});
|
|
433
|
+
return (engine as unknown as { reliableMessageBuffer: DataPacketBuffer })
|
|
434
|
+
.reliableMessageBuffer;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
it('resolves and queues the packet for replay when the wait is torn down transiently', async () => {
|
|
438
|
+
const engine = new RTCEngine(roomOptionDefaults);
|
|
439
|
+
const dc = new FakeDataChannel();
|
|
440
|
+
const buffer = stubEngine(engine, dc);
|
|
441
|
+
|
|
442
|
+
// Park the send on a full buffer, then invalidate the channel (reconnect/replacement).
|
|
443
|
+
dc.bufferedAmount = 2 * 1024 * 1024;
|
|
444
|
+
const send = engine.sendDataPacket(makePacket(1), DataChannelKind.RELIABLE);
|
|
445
|
+
await tick();
|
|
446
|
+
(
|
|
447
|
+
engine as unknown as { invalidateDataChannelWaiters: (reason: string) => void }
|
|
448
|
+
).invalidateDataChannelWaiters('data channels recreated');
|
|
449
|
+
|
|
450
|
+
// The send must not surface the teardown — the packet is queued for the resume replay.
|
|
451
|
+
await expect(send).resolves.toBeUndefined();
|
|
452
|
+
expect(dc.send).not.toHaveBeenCalled();
|
|
453
|
+
expect(buffer.length).toBe(1);
|
|
454
|
+
expect(buffer.getAll()[0].sent).toBe(false);
|
|
455
|
+
|
|
456
|
+
// The replay then delivers it.
|
|
457
|
+
dc.bufferedAmount = 0;
|
|
458
|
+
await (
|
|
459
|
+
engine as unknown as { resendReliableMessagesForResume: (seq: number) => Promise<void> }
|
|
460
|
+
).resendReliableMessagesForResume(0);
|
|
461
|
+
expect(dc.send).toHaveBeenCalledTimes(1);
|
|
462
|
+
expect(buffer.getAll()[0].sent).toBe(true);
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
it('still rejects when the engine is closed while waiting', async () => {
|
|
466
|
+
const engine = new RTCEngine(roomOptionDefaults);
|
|
467
|
+
const dc = new FakeDataChannel();
|
|
468
|
+
stubEngine(engine, dc);
|
|
469
|
+
|
|
470
|
+
dc.bufferedAmount = 2 * 1024 * 1024;
|
|
471
|
+
const send = engine.sendDataPacket(makePacket(1), DataChannelKind.RELIABLE);
|
|
472
|
+
await tick();
|
|
473
|
+
Object.assign(engine as unknown as Record<string, unknown>, { _isClosed: true });
|
|
474
|
+
(
|
|
475
|
+
engine as unknown as { invalidateDataChannelWaiters: (reason: string) => void }
|
|
476
|
+
).invalidateDataChannelWaiters('engine closed');
|
|
477
|
+
|
|
478
|
+
await expect(send).rejects.toBeInstanceOf(UnexpectedConnectionState);
|
|
479
|
+
expect(dc.send).not.toHaveBeenCalled();
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
it('queues without waiting while a reconnect attempt is in progress', async () => {
|
|
483
|
+
const engine = new RTCEngine(roomOptionDefaults);
|
|
484
|
+
const dc = new FakeDataChannel();
|
|
485
|
+
const buffer = stubEngine(engine, dc);
|
|
486
|
+
Object.assign(engine as unknown as Record<string, unknown>, { attemptingReconnect: true });
|
|
487
|
+
|
|
488
|
+
// Even with a full buffer, the send resolves immediately instead of parking.
|
|
489
|
+
dc.bufferedAmount = 2 * 1024 * 1024;
|
|
490
|
+
await expect(
|
|
491
|
+
engine.sendDataPacket(makePacket(1), DataChannelKind.RELIABLE),
|
|
492
|
+
).resolves.toBeUndefined();
|
|
493
|
+
expect(dc.send).not.toHaveBeenCalled();
|
|
494
|
+
expect(buffer.length).toBe(1);
|
|
495
|
+
expect(buffer.getAll()[0].sent).toBe(false);
|
|
496
|
+
});
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
describe('sendLossyBytes', () => {
|
|
500
|
+
it('ensures the publisher is connected before sending (direct data-track path)', async () => {
|
|
501
|
+
const engine = new RTCEngine(roomOptionDefaults);
|
|
502
|
+
const dc = new FakeDataChannel();
|
|
503
|
+
// The channel only becomes available once the publisher connection has been established —
|
|
504
|
+
// mirroring the lazily negotiated publisher case that Room's packetAvailable path hits.
|
|
505
|
+
let connected = false;
|
|
506
|
+
const ensurePublisherConnected = vi.fn(async () => {
|
|
507
|
+
connected = true;
|
|
508
|
+
});
|
|
509
|
+
Object.assign(engine as unknown as Record<string, unknown>, {
|
|
510
|
+
_isClosed: false,
|
|
511
|
+
ensurePublisherConnected,
|
|
512
|
+
dataChannelForKind: vi.fn(() => (connected ? dc : undefined)),
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
await engine.sendLossyBytes(new Uint8Array([1]), DataChannelKind.DATA_TRACK_LOSSY, 'wait');
|
|
516
|
+
|
|
517
|
+
expect(ensurePublisherConnected).toHaveBeenCalledWith(DataChannelKind.DATA_TRACK_LOSSY);
|
|
518
|
+
expect(dc.send).toHaveBeenCalledTimes(1);
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
it('only counts LOSSY bytes into the byterate stat that tunes the lossy drop threshold', async () => {
|
|
522
|
+
const engine = new RTCEngine(roomOptionDefaults);
|
|
523
|
+
const dc = new FakeDataChannel();
|
|
524
|
+
Object.assign(engine as unknown as Record<string, unknown>, {
|
|
525
|
+
_isClosed: false,
|
|
526
|
+
ensurePublisherConnected: vi.fn().mockResolvedValue(undefined),
|
|
527
|
+
dataChannelForKind: vi.fn(() => dc),
|
|
528
|
+
});
|
|
529
|
+
const stat = () =>
|
|
530
|
+
(engine as unknown as { lossyDataStatCurrentBytes: number }).lossyDataStatCurrentBytes;
|
|
531
|
+
|
|
532
|
+
// Data-track traffic must not move the stat — it would inflate the LOSSY channel's
|
|
533
|
+
// dynamically tuned drop threshold with traffic that channel never carries.
|
|
534
|
+
await engine.sendLossyBytes(new Uint8Array(1000), DataChannelKind.DATA_TRACK_LOSSY, 'wait');
|
|
535
|
+
expect(stat()).toBe(0);
|
|
536
|
+
|
|
537
|
+
await engine.sendLossyBytes(new Uint8Array(100), DataChannelKind.LOSSY, 'drop');
|
|
538
|
+
expect(stat()).toBe(100);
|
|
539
|
+
});
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
describe('waitForBufferHeadroomWithLock', () => {
|
|
543
|
+
it('rejects parked waiters and releases the lock when the data channels are invalidated', async () => {
|
|
544
|
+
const engine = new RTCEngine(roomOptionDefaults);
|
|
545
|
+
const dc = new FakeDataChannel();
|
|
546
|
+
Object.assign(engine as unknown as Record<string, unknown>, {
|
|
547
|
+
_isClosed: false,
|
|
548
|
+
dataChannelForKind: vi.fn(() => dc),
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
// Park a waiter: buffer above the reliable high-water mark, holding the headroom lock.
|
|
552
|
+
dc.bufferedAmount = 2 * 1024 * 1024;
|
|
553
|
+
const parked = engine.waitForBufferHeadroomWithLock(DataChannelKind.RELIABLE);
|
|
554
|
+
// Swallow the expected rejection so it can't surface as unhandled before we assert on it.
|
|
555
|
+
parked.catch(() => {});
|
|
556
|
+
await tick();
|
|
557
|
+
|
|
558
|
+
// The channel object gets abandoned (e.g. createDataChannels on the Safari resume path).
|
|
559
|
+
(
|
|
560
|
+
engine as unknown as { invalidateDataChannelWaiters: (reason: string) => void }
|
|
561
|
+
).invalidateDataChannelWaiters('data channels recreated');
|
|
562
|
+
|
|
563
|
+
await expect(parked).rejects.toBeInstanceOf(UnexpectedConnectionState);
|
|
564
|
+
|
|
565
|
+
// The lock must be free again: a wait against the fresh, drained channel resolves instead
|
|
566
|
+
// of queueing forever behind the stranded waiter.
|
|
567
|
+
dc.bufferedAmount = 0;
|
|
568
|
+
await expect(
|
|
569
|
+
engine.waitForBufferHeadroomWithLock(DataChannelKind.RELIABLE),
|
|
570
|
+
).resolves.toBeUndefined();
|
|
571
|
+
});
|
|
572
|
+
});
|
|
573
|
+
|
|
301
574
|
describe('handleDataChannelClose', () => {
|
|
302
575
|
function stubCloseEnv(
|
|
303
576
|
engine: RTCEngine,
|