livekit-client 2.21.0 → 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 +2 -1
- package/dist/livekit-client.e2ee.worker.mjs.map +1 -1
- package/dist/livekit-client.esm.mjs +480 -150
- 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 +2 -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/room/PCTransport.d.ts +49 -2
- package/dist/src/room/PCTransport.d.ts.map +1 -1
- package/dist/src/room/RTCEngine.d.ts +20 -1
- package/dist/src/room/RTCEngine.d.ts.map +1 -1
- package/dist/src/room/Room.d.ts +2 -0
- package/dist/src/room/Room.d.ts.map +1 -1
- package/dist/src/room/errors.d.ts.map +1 -1
- package/dist/src/room/participant/LocalParticipant.d.ts +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/utils.d.ts +15 -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/ts4.2/room/PCTransport.d.ts +49 -2
- package/dist/ts4.2/room/RTCEngine.d.ts +20 -1
- package/dist/ts4.2/room/Room.d.ts +2 -0
- package/dist/ts4.2/room/participant/LocalParticipant.d.ts +1 -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/utils.d.ts +15 -0
- package/dist/ts4.2/test/promiseState.d.ts +12 -0
- package/dist/ts4.2/utils/AsyncQueue.d.ts +3 -3
- package/package.json +13 -12
- package/src/api/SignalClient.e2e.test.ts +16 -9
- package/src/e2ee/utils.ts +1 -1
- package/src/room/PCTransport.test.ts +243 -1
- package/src/room/PCTransport.ts +181 -80
- package/src/room/RTCEngine.test.ts +339 -2
- package/src/room/RTCEngine.ts +152 -11
- package/src/room/Room.test.ts +134 -3
- package/src/room/Room.ts +52 -17
- package/src/room/data-stream/incoming/IncomingDataStreamManager.ts +1 -1
- package/src/room/data-stream/incoming/StreamReader.ts +1 -1
- package/src/room/errors.ts +1 -2
- 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/utils.test.ts +71 -2
- package/src/room/utils.ts +52 -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/room/RTCEngine.ts
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
LeaveRequest_Action,
|
|
19
19
|
MediaSectionsRequirement,
|
|
20
20
|
ParticipantInfo,
|
|
21
|
+
ConnectionQuality as ProtoConnectionQuality,
|
|
21
22
|
PublishDataTrackResponse,
|
|
22
23
|
ReconnectReason,
|
|
23
24
|
type ReconnectResponse,
|
|
@@ -100,6 +101,7 @@ import {
|
|
|
100
101
|
isVideoCodec,
|
|
101
102
|
isVideoTrack,
|
|
102
103
|
isWeb,
|
|
104
|
+
negotiateDependencyDescriptor,
|
|
103
105
|
sleep,
|
|
104
106
|
supportsAddTrack,
|
|
105
107
|
supportsTransceiver,
|
|
@@ -108,6 +110,12 @@ import {
|
|
|
108
110
|
|
|
109
111
|
const minReconnectWait = 2 * 1000;
|
|
110
112
|
const leaveReconnect = 'leave-reconnect';
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* How long local connection quality must stay `LOST` while connected and publishing before we
|
|
116
|
+
* force a full reconnect — `LOST` is the server's verdict that it isn't receiving our media.
|
|
117
|
+
*/
|
|
118
|
+
const connectionQualityLostTimeout = 10 * 1000;
|
|
111
119
|
const reliabeReceiveStateTTL = 30_000;
|
|
112
120
|
|
|
113
121
|
const initialMediaSectionsAudio = 3;
|
|
@@ -246,6 +254,12 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
|
|
|
246
254
|
/** used to indicate whether the browser is currently waiting to reconnect */
|
|
247
255
|
private isWaitingForNetworkReconnect: boolean = false;
|
|
248
256
|
|
|
257
|
+
/** set while the local participant's connection quality is `LOST`; forces a full reconnect on timeout */
|
|
258
|
+
private lostQualityTimeout?: ReturnType<typeof setTimeout>;
|
|
259
|
+
|
|
260
|
+
/** timestamp (ms) the primary transport entered `CONNECTING`, used to bound how long we tolerate it */
|
|
261
|
+
private transportConnectingSince?: number;
|
|
262
|
+
|
|
249
263
|
constructor(private options: InternalRoomOptions) {
|
|
250
264
|
super();
|
|
251
265
|
this.log = getLogger(options.loggerName ?? LoggerNames.Engine, () => this.logContext);
|
|
@@ -271,8 +285,10 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
|
|
|
271
285
|
|
|
272
286
|
this.client.onParticipantUpdate = (updates) =>
|
|
273
287
|
this.emit(EngineEvent.ParticipantUpdate, updates);
|
|
274
|
-
this.client.onConnectionQuality = (update) =>
|
|
288
|
+
this.client.onConnectionQuality = (update) => {
|
|
289
|
+
this.handleLocalConnectionQuality(update);
|
|
275
290
|
this.emit(EngineEvent.ConnectionQualityUpdate, update);
|
|
291
|
+
};
|
|
276
292
|
this.client.onRoomUpdate = (update) => this.emit(EngineEvent.RoomUpdate, update);
|
|
277
293
|
this.client.onSubscriptionError = (resp) => this.emit(EngineEvent.SubscriptionError, resp);
|
|
278
294
|
this.client.onSubscriptionPermissionUpdate = (update) =>
|
|
@@ -429,6 +445,7 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
|
|
|
429
445
|
this.removeAllListeners();
|
|
430
446
|
this.deregisterOnLineListener();
|
|
431
447
|
this.clearPendingReconnect();
|
|
448
|
+
this.clearLostQualityTimeout();
|
|
432
449
|
this.cleanupLossyDataStats();
|
|
433
450
|
await this.cleanupPeerConnections();
|
|
434
451
|
await this.cleanupClient();
|
|
@@ -442,6 +459,8 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
|
|
|
442
459
|
|
|
443
460
|
await this.pcManager?.close();
|
|
444
461
|
this.pcManager = undefined;
|
|
462
|
+
// the connecting timestamp belongs to the transports we just tore down
|
|
463
|
+
this.transportConnectingSince = undefined;
|
|
445
464
|
|
|
446
465
|
this.reliableReceivedState.clear();
|
|
447
466
|
}
|
|
@@ -468,7 +487,7 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
|
|
|
468
487
|
throw new TrackInvalidError('a track with the same ID has already been published');
|
|
469
488
|
}
|
|
470
489
|
return new Promise<TrackInfo>((resolve, reject) => {
|
|
471
|
-
const publicationTimeout = setTimeout(() => {
|
|
490
|
+
const publicationTimeout = CriticalTimers.setTimeout(() => {
|
|
472
491
|
delete this.pendingTrackResolvers[req.cid];
|
|
473
492
|
reject(
|
|
474
493
|
ConnectionError.timeout('publication of local track timed out, no response from server'),
|
|
@@ -476,11 +495,11 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
|
|
|
476
495
|
}, 10_000);
|
|
477
496
|
this.pendingTrackResolvers[req.cid] = {
|
|
478
497
|
resolve: (info: TrackInfo) => {
|
|
479
|
-
clearTimeout(publicationTimeout);
|
|
498
|
+
CriticalTimers.clearTimeout(publicationTimeout);
|
|
480
499
|
resolve(info);
|
|
481
500
|
},
|
|
482
501
|
reject: () => {
|
|
483
|
-
clearTimeout(publicationTimeout);
|
|
502
|
+
CriticalTimers.clearTimeout(publicationTimeout);
|
|
484
503
|
reject(new Error('Cancelled publication by calling unpublish'));
|
|
485
504
|
},
|
|
486
505
|
};
|
|
@@ -565,6 +584,16 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
|
|
|
565
584
|
this.pcManager.onStateChange = async (connectionState, publisherState, subscriberState) => {
|
|
566
585
|
this.log.debug(`primary PC state changed ${connectionState}`);
|
|
567
586
|
|
|
587
|
+
// Record when the primary transport actually entered CONNECTING so
|
|
588
|
+
// verifyTransport() can bound how long we tolerate it. Deriving it from the
|
|
589
|
+
// real transition (this handler only fires on state changes) rather than from
|
|
590
|
+
// observation time keeps it from going stale across peer-connection rebuilds.
|
|
591
|
+
if (connectionState === PCTransportState.CONNECTING) {
|
|
592
|
+
this.transportConnectingSince = Date.now();
|
|
593
|
+
} else {
|
|
594
|
+
this.transportConnectingSince = undefined;
|
|
595
|
+
}
|
|
596
|
+
|
|
568
597
|
if (['closed', 'disconnected', 'failed'].includes(publisherState)) {
|
|
569
598
|
// reset publisher connection promise
|
|
570
599
|
this.publisherConnectionPromise = undefined;
|
|
@@ -816,8 +845,15 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
|
|
|
816
845
|
for (let i: number = 0; i < numAudios; i++) {
|
|
817
846
|
this.pcManager?.addPublisherTransceiverOfKind('audio', transceiverInit);
|
|
818
847
|
}
|
|
848
|
+
// media only arrives on these sections when there is no subscriber connection to arrive on
|
|
849
|
+
const receivesMedia = this.pcManager?.mode === 'publisher-only';
|
|
819
850
|
for (let i: number = 0; i < numVideos; i++) {
|
|
820
|
-
this.pcManager?.addPublisherTransceiverOfKind('video', transceiverInit);
|
|
851
|
+
const transceiver = this.pcManager?.addPublisherTransceiverOfKind('video', transceiverInit);
|
|
852
|
+
if (receivesMedia && transceiver) {
|
|
853
|
+
const negotiated = negotiateDependencyDescriptor(transceiver);
|
|
854
|
+
|
|
855
|
+
this.log.debug('dependency descriptor negotiated for received video', { negotiated });
|
|
856
|
+
}
|
|
821
857
|
}
|
|
822
858
|
}
|
|
823
859
|
|
|
@@ -1095,7 +1131,7 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
|
|
|
1095
1131
|
if (!opts.videoCodec) {
|
|
1096
1132
|
return;
|
|
1097
1133
|
}
|
|
1098
|
-
track.setSimulcastTrackSender(opts.videoCodec, transceiver.sender);
|
|
1134
|
+
await track.setSimulcastTrackSender(opts.videoCodec, transceiver.sender);
|
|
1099
1135
|
return transceiver.sender;
|
|
1100
1136
|
}
|
|
1101
1137
|
|
|
@@ -1157,6 +1193,73 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
|
|
|
1157
1193
|
);
|
|
1158
1194
|
};
|
|
1159
1195
|
|
|
1196
|
+
/**
|
|
1197
|
+
* A sustained local `LOST` while connected and publishing means the server isn't receiving
|
|
1198
|
+
* our media, so force a full reconnect; any non-`LOST` value cancels a pending trigger.
|
|
1199
|
+
*/
|
|
1200
|
+
private handleLocalConnectionQuality(update: ConnectionQualityUpdate) {
|
|
1201
|
+
if (!this.participantSid) {
|
|
1202
|
+
return;
|
|
1203
|
+
}
|
|
1204
|
+
const localUpdate = update.updates.find((u) => u.participantSid === this.participantSid);
|
|
1205
|
+
if (!localUpdate) {
|
|
1206
|
+
return;
|
|
1207
|
+
}
|
|
1208
|
+
if (localUpdate.quality === ProtoConnectionQuality.LOST) {
|
|
1209
|
+
this.scheduleLostQualityReconnect();
|
|
1210
|
+
} else {
|
|
1211
|
+
this.clearLostQualityTimeout();
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
private scheduleLostQualityReconnect() {
|
|
1216
|
+
if (this.lostQualityTimeout) {
|
|
1217
|
+
// already counting down towards a reconnect
|
|
1218
|
+
return;
|
|
1219
|
+
}
|
|
1220
|
+
this.lostQualityTimeout = CriticalTimers.setTimeout(() => {
|
|
1221
|
+
this.lostQualityTimeout = undefined;
|
|
1222
|
+
if (this._isClosed || this.pcState !== PCState.Connected || this.attemptingReconnect) {
|
|
1223
|
+
return;
|
|
1224
|
+
}
|
|
1225
|
+
if (!this.hasActivePublisherSenders()) {
|
|
1226
|
+
return;
|
|
1227
|
+
}
|
|
1228
|
+
this.log.warn(
|
|
1229
|
+
'local connection quality lost while publishing, triggering full reconnect',
|
|
1230
|
+
this.logContext,
|
|
1231
|
+
);
|
|
1232
|
+
this.fullReconnectOnNext = true;
|
|
1233
|
+
this.handleDisconnect('connection quality lost', ReconnectReason.RR_PUBLISHER_FAILED);
|
|
1234
|
+
}, connectionQualityLostTimeout);
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
private clearLostQualityTimeout() {
|
|
1238
|
+
if (this.lostQualityTimeout) {
|
|
1239
|
+
CriticalTimers.clearTimeout(this.lostQualityTimeout);
|
|
1240
|
+
this.lostQualityTimeout = undefined;
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
/** Whether the publisher currently has any sender with a live track. */
|
|
1245
|
+
private hasActivePublisherSenders(): boolean {
|
|
1246
|
+
return (
|
|
1247
|
+
this.pcManager?.publisher
|
|
1248
|
+
.getSenders()
|
|
1249
|
+
.some((sender) => !!sender.track && sender.track.readyState === 'live') ?? false
|
|
1250
|
+
);
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
/**
|
|
1254
|
+
* Forces a full reconnect while keeping the engine (and its saved credentials) alive. Used by
|
|
1255
|
+
* Room's connection-reconcile safety net when the transport silently died but we looked connected.
|
|
1256
|
+
* @internal
|
|
1257
|
+
*/
|
|
1258
|
+
reconnect(reason: ReconnectReason = ReconnectReason.RR_UNKNOWN) {
|
|
1259
|
+
this.fullReconnectOnNext = true;
|
|
1260
|
+
this.handleDisconnect('reconcile', reason);
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1160
1263
|
private async attemptReconnect(reason?: ReconnectReason) {
|
|
1161
1264
|
if (this._isClosed) {
|
|
1162
1265
|
return;
|
|
@@ -1166,6 +1269,12 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
|
|
|
1166
1269
|
this.log.warn('already attempting reconnect, returning early');
|
|
1167
1270
|
return;
|
|
1168
1271
|
}
|
|
1272
|
+
|
|
1273
|
+
// A pending Lost-quality countdown belongs to the session we're now leaving; cancel it so
|
|
1274
|
+
// it can't fire against the reconnected session before the server has evaluated it. (A resume
|
|
1275
|
+
// keeps the peer connections, so cleanupPeerConnections wouldn't cover this path.)
|
|
1276
|
+
this.clearLostQualityTimeout();
|
|
1277
|
+
|
|
1169
1278
|
if (
|
|
1170
1279
|
this.clientConfiguration?.resumeConnection === ClientConfigSetting.DISABLED ||
|
|
1171
1280
|
// signaling state could change to closed due to hardware sleep
|
|
@@ -1175,15 +1284,23 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
|
|
|
1175
1284
|
this.fullReconnectOnNext = true;
|
|
1176
1285
|
}
|
|
1177
1286
|
|
|
1287
|
+
// Consume the flag up front: capture whether this attempt is a full reconnect, then reset
|
|
1288
|
+
// it. From here on a `true` value unambiguously represents a *new* full-reconnect request
|
|
1289
|
+
// that arrived while this attempt was running (e.g. a server RECONNECT leave), which the
|
|
1290
|
+
// finally block dispatches — for both the resume and full-reconnect paths.
|
|
1291
|
+
const fullReconnect = this.fullReconnectOnNext;
|
|
1292
|
+
this.fullReconnectOnNext = false;
|
|
1293
|
+
|
|
1294
|
+
let succeeded = false;
|
|
1178
1295
|
try {
|
|
1179
1296
|
this.attemptingReconnect = true;
|
|
1180
|
-
if (
|
|
1297
|
+
if (fullReconnect) {
|
|
1181
1298
|
await this.restartConnection();
|
|
1182
1299
|
} else {
|
|
1183
1300
|
await this.resumeConnection(reason);
|
|
1184
1301
|
}
|
|
1185
1302
|
this.clearPendingReconnect();
|
|
1186
|
-
|
|
1303
|
+
succeeded = true;
|
|
1187
1304
|
} catch (e) {
|
|
1188
1305
|
this.reconnectAttempts += 1;
|
|
1189
1306
|
let recoverable = true;
|
|
@@ -1191,8 +1308,9 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
|
|
|
1191
1308
|
this.log.debug('received unrecoverable error', { error: e });
|
|
1192
1309
|
// unrecoverable
|
|
1193
1310
|
recoverable = false;
|
|
1194
|
-
} else if (!(e instanceof SignalReconnectError)) {
|
|
1195
|
-
//
|
|
1311
|
+
} else if (fullReconnect || !(e instanceof SignalReconnectError)) {
|
|
1312
|
+
// a failed full reconnect stays a full reconnect; a failed resume can only be
|
|
1313
|
+
// resumed again for a signal-level error, otherwise it escalates
|
|
1196
1314
|
this.fullReconnectOnNext = true;
|
|
1197
1315
|
}
|
|
1198
1316
|
|
|
@@ -1209,6 +1327,14 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
|
|
|
1209
1327
|
}
|
|
1210
1328
|
} finally {
|
|
1211
1329
|
this.attemptingReconnect = false;
|
|
1330
|
+
|
|
1331
|
+
// A full reconnect requested while this attempt was running (e.g. a `RECONNECT` leave
|
|
1332
|
+
// during a resume or a restart) that a successful attempt didn't act on; dispatch it now
|
|
1333
|
+
// (the failure path already retries).
|
|
1334
|
+
if (succeeded && this.fullReconnectOnNext && !this._isClosed) {
|
|
1335
|
+
this.log.debug('full reconnect requested during in-progress attempt, dispatching');
|
|
1336
|
+
this.handleDisconnect('reconnect');
|
|
1337
|
+
}
|
|
1212
1338
|
}
|
|
1213
1339
|
}
|
|
1214
1340
|
|
|
@@ -1591,11 +1717,12 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
|
|
|
1591
1717
|
if (!this.pcManager) {
|
|
1592
1718
|
return false;
|
|
1593
1719
|
}
|
|
1720
|
+
const state = this.pcManager.currentState;
|
|
1594
1721
|
const allowedConnectionStates: PCTransportState[] = [
|
|
1595
1722
|
PCTransportState.CONNECTING,
|
|
1596
1723
|
PCTransportState.CONNECTED,
|
|
1597
1724
|
];
|
|
1598
|
-
if (!allowedConnectionStates.includes(
|
|
1725
|
+
if (!allowedConnectionStates.includes(state)) {
|
|
1599
1726
|
return false;
|
|
1600
1727
|
}
|
|
1601
1728
|
|
|
@@ -1603,6 +1730,20 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
|
|
|
1603
1730
|
if (!this.client.ws || this.client.ws.readyState === WebSocket.CLOSED) {
|
|
1604
1731
|
return false;
|
|
1605
1732
|
}
|
|
1733
|
+
|
|
1734
|
+
// A transport stuck in CONNECTING never reaches CONNECTED nor reports FAILED, so it would
|
|
1735
|
+
// otherwise look healthy forever; bound how long we tolerate it. The entry time is recorded
|
|
1736
|
+
// in the pcManager state-change handler (see configure()), so this is a pure read — an
|
|
1737
|
+
// unrecorded CONNECTING fails open rather than measuring against a stale timestamp.
|
|
1738
|
+
if (
|
|
1739
|
+
state === PCTransportState.CONNECTING &&
|
|
1740
|
+
this.transportConnectingSince !== undefined &&
|
|
1741
|
+
Date.now() - this.transportConnectingSince > this.peerConnectionTimeout
|
|
1742
|
+
) {
|
|
1743
|
+
this.log.warn('transport stuck in connecting state', this.logContext);
|
|
1744
|
+
return false;
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1606
1747
|
return true;
|
|
1607
1748
|
}
|
|
1608
1749
|
|
package/src/room/Room.test.ts
CHANGED
|
@@ -1,8 +1,22 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
ClientInfo_Capability,
|
|
3
|
+
JoinResponse,
|
|
4
|
+
StreamState as ProtoStreamState,
|
|
5
|
+
StreamStateUpdate,
|
|
6
|
+
SubscriptionError,
|
|
7
|
+
SubscriptionResponse,
|
|
8
|
+
TrackInfo,
|
|
9
|
+
TrackType,
|
|
10
|
+
} from '@livekit/protocol';
|
|
2
11
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
-
import
|
|
12
|
+
import MockMediaStreamTrack from '../test/MockMediaStreamTrack';
|
|
13
|
+
import Room, { ConnectionState } from './Room';
|
|
4
14
|
import { roomConnectOptionDefaults, roomOptionDefaults } from './defaults';
|
|
5
|
-
import { RoomEvent } from './events';
|
|
15
|
+
import { EngineEvent, ParticipantEvent, RoomEvent } from './events';
|
|
16
|
+
import RemoteParticipant from './participant/RemoteParticipant';
|
|
17
|
+
import RemoteTrackPublication from './track/RemoteTrackPublication';
|
|
18
|
+
import RemoteVideoTrack from './track/RemoteVideoTrack';
|
|
19
|
+
import { Track } from './track/Track';
|
|
6
20
|
|
|
7
21
|
describe('Active device switch', () => {
|
|
8
22
|
it('updates devices correctly', async () => {
|
|
@@ -190,3 +204,120 @@ describe('Room lifecycle', () => {
|
|
|
190
204
|
}
|
|
191
205
|
});
|
|
192
206
|
});
|
|
207
|
+
|
|
208
|
+
describe('remote track subscription', () => {
|
|
209
|
+
it('does not replay a deferred ontrack after the subscription failed', () => {
|
|
210
|
+
const room = new Room();
|
|
211
|
+
room.state = ConnectionState.Connecting;
|
|
212
|
+
|
|
213
|
+
const trackSid = 'TR_h264';
|
|
214
|
+
const mediaTrack = { id: trackSid } as MediaStreamTrack;
|
|
215
|
+
const stream = {
|
|
216
|
+
id: `PA_remote|${trackSid}`,
|
|
217
|
+
getTracks: () => [mediaTrack],
|
|
218
|
+
} as unknown as MediaStream;
|
|
219
|
+
const receiver = {} as RTCRtpReceiver;
|
|
220
|
+
|
|
221
|
+
room.engine.emit(EngineEvent.MediaTrackAdded, mediaTrack, stream, receiver);
|
|
222
|
+
|
|
223
|
+
const replay = vi.spyOn(
|
|
224
|
+
room as unknown as {
|
|
225
|
+
onTrackAdded(
|
|
226
|
+
mediaTrack: MediaStreamTrack,
|
|
227
|
+
stream: MediaStream,
|
|
228
|
+
receiver: RTCRtpReceiver,
|
|
229
|
+
): void;
|
|
230
|
+
},
|
|
231
|
+
'onTrackAdded',
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
room.engine.emit(
|
|
235
|
+
EngineEvent.SubscriptionError,
|
|
236
|
+
new SubscriptionResponse({
|
|
237
|
+
trackSid,
|
|
238
|
+
err: SubscriptionError.SE_CODEC_UNSUPPORTED,
|
|
239
|
+
}),
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
room.state = ConnectionState.Connected;
|
|
243
|
+
room.emit(RoomEvent.Connected);
|
|
244
|
+
|
|
245
|
+
expect(replay).not.toHaveBeenCalled();
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
describe('stream state updates', () => {
|
|
250
|
+
const participantSid = 'PA_remote';
|
|
251
|
+
const participantIdentity = 'remote-user';
|
|
252
|
+
const trackSid = 'TR_video';
|
|
253
|
+
|
|
254
|
+
function setupSubscribedTrack() {
|
|
255
|
+
const room = new Room();
|
|
256
|
+
room.state = ConnectionState.Connected;
|
|
257
|
+
|
|
258
|
+
const participant = new RemoteParticipant(
|
|
259
|
+
room.engine.client,
|
|
260
|
+
participantSid,
|
|
261
|
+
participantIdentity,
|
|
262
|
+
);
|
|
263
|
+
const publication = new RemoteTrackPublication(
|
|
264
|
+
Track.Kind.Video,
|
|
265
|
+
new TrackInfo({ sid: trackSid, type: TrackType.VIDEO, name: 'camera' }),
|
|
266
|
+
true,
|
|
267
|
+
);
|
|
268
|
+
publication.setTrack(
|
|
269
|
+
new RemoteVideoTrack(new MockMediaStreamTrack(), trackSid, undefined as never, {}),
|
|
270
|
+
);
|
|
271
|
+
participant.trackPublications.set(trackSid, publication);
|
|
272
|
+
|
|
273
|
+
(
|
|
274
|
+
room as unknown as { remoteParticipants: Map<string, RemoteParticipant> }
|
|
275
|
+
).remoteParticipants.set(participantIdentity, participant);
|
|
276
|
+
(room as unknown as { sidToIdentity: Map<string, string> }).sidToIdentity.set(
|
|
277
|
+
participantSid,
|
|
278
|
+
participantIdentity,
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
return { room, participant, publication };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function pushStreamState(room: Room, state: ProtoStreamState) {
|
|
285
|
+
(
|
|
286
|
+
room as unknown as { handleStreamStateUpdate: (update: StreamStateUpdate) => void }
|
|
287
|
+
).handleStreamStateUpdate(
|
|
288
|
+
new StreamStateUpdate({
|
|
289
|
+
streamStates: [{ participantSid, trackSid, state }],
|
|
290
|
+
}),
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
it('emits TrackStreamStateChanged when the SFU pauses a subscribed track', () => {
|
|
295
|
+
const { room, participant, publication } = setupSubscribedTrack();
|
|
296
|
+
|
|
297
|
+
const roomEvents = vi.fn();
|
|
298
|
+
const participantEvents = vi.fn();
|
|
299
|
+
room.on(RoomEvent.TrackStreamStateChanged, roomEvents);
|
|
300
|
+
participant.on(ParticipantEvent.TrackStreamStateChanged, participantEvents);
|
|
301
|
+
|
|
302
|
+
pushStreamState(room, ProtoStreamState.PAUSED);
|
|
303
|
+
|
|
304
|
+
expect(publication.track?.streamState).toBe(Track.StreamState.Paused);
|
|
305
|
+
expect(roomEvents).toHaveBeenCalledWith(publication, Track.StreamState.Paused, participant);
|
|
306
|
+
expect(participantEvents).toHaveBeenCalledWith(publication, Track.StreamState.Paused);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it('does not emit TrackStreamStateChanged when the state is unchanged', () => {
|
|
310
|
+
const { room, participant } = setupSubscribedTrack();
|
|
311
|
+
|
|
312
|
+
const roomEvents = vi.fn();
|
|
313
|
+
const participantEvents = vi.fn();
|
|
314
|
+
room.on(RoomEvent.TrackStreamStateChanged, roomEvents);
|
|
315
|
+
participant.on(ParticipantEvent.TrackStreamStateChanged, participantEvents);
|
|
316
|
+
|
|
317
|
+
// The track starts Active; a redundant ACTIVE update must stay silent.
|
|
318
|
+
pushStreamState(room, ProtoStreamState.ACTIVE);
|
|
319
|
+
|
|
320
|
+
expect(roomEvents).not.toHaveBeenCalled();
|
|
321
|
+
expect(participantEvents).not.toHaveBeenCalled();
|
|
322
|
+
});
|
|
323
|
+
});
|
package/src/room/Room.ts
CHANGED
|
@@ -113,6 +113,7 @@ import {
|
|
|
113
113
|
Future,
|
|
114
114
|
createDummyVideoStreamTrack,
|
|
115
115
|
extractChatMessage,
|
|
116
|
+
extractTrackSid,
|
|
116
117
|
extractTranscriptionSegments,
|
|
117
118
|
getDisconnectReasonFromConnectionError,
|
|
118
119
|
getEmptyAudioStreamTrack,
|
|
@@ -218,6 +219,8 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
|
|
|
218
219
|
|
|
219
220
|
private isResuming: boolean = false;
|
|
220
221
|
|
|
222
|
+
private pendingTrackAddedCallbacks = new Map<Track.SID, Set<() => void>>();
|
|
223
|
+
|
|
221
224
|
/**
|
|
222
225
|
* map to store first point in time when a particular transcription segment was received
|
|
223
226
|
*/
|
|
@@ -636,10 +639,10 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
|
|
|
636
639
|
this.emitBufferedEvents();
|
|
637
640
|
})
|
|
638
641
|
.on(EngineEvent.SignalResumed, () => {
|
|
639
|
-
this.bufferedEvents = [];
|
|
640
642
|
if (this.state === ConnectionState.Reconnecting || this.isResuming) {
|
|
641
643
|
this.sendSyncState();
|
|
642
644
|
}
|
|
645
|
+
this.emitBufferedEvents();
|
|
643
646
|
})
|
|
644
647
|
.on(EngineEvent.Restarting, this.handleRestarting)
|
|
645
648
|
.on(EngineEvent.Restarted, this.handleRestarted)
|
|
@@ -1588,24 +1591,38 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
|
|
|
1588
1591
|
// at that time, ICE connectivity has not been established so the track is not
|
|
1589
1592
|
// technically subscribed.
|
|
1590
1593
|
// We'll defer these events until when the room is connected or eventually disconnected.
|
|
1591
|
-
if (
|
|
1594
|
+
if ([ConnectionState.Connecting, ConnectionState.Reconnecting].includes(this.state)) {
|
|
1595
|
+
const pendingTrackSid = extractTrackSid(mediaTrack, stream);
|
|
1596
|
+
this.log.debug('deferring on track for later', {
|
|
1597
|
+
mediaTrackId: mediaTrack.id,
|
|
1598
|
+
mediaStreamId: stream.id,
|
|
1599
|
+
tracksInStream: stream.getTracks().map((track) => track.id),
|
|
1600
|
+
});
|
|
1592
1601
|
const reconnectedHandler = () => {
|
|
1593
|
-
this.log.debug('deferring on track for later', {
|
|
1594
|
-
mediaTrackId: mediaTrack.id,
|
|
1595
|
-
mediaStreamId: stream.id,
|
|
1596
|
-
tracksInStream: stream.getTracks().map((track) => track.id),
|
|
1597
|
-
});
|
|
1598
|
-
this.onTrackAdded(mediaTrack, stream, receiver);
|
|
1599
1602
|
cleanup();
|
|
1603
|
+
this.onTrackAdded(mediaTrack, stream, receiver);
|
|
1600
1604
|
};
|
|
1601
1605
|
const cleanup = () => {
|
|
1602
1606
|
this.off(RoomEvent.Reconnected, reconnectedHandler);
|
|
1603
1607
|
this.off(RoomEvent.Connected, reconnectedHandler);
|
|
1604
1608
|
this.off(RoomEvent.Disconnected, cleanup);
|
|
1609
|
+
if (pendingTrackSid) {
|
|
1610
|
+
const pendingCallbacks = this.pendingTrackAddedCallbacks.get(pendingTrackSid);
|
|
1611
|
+
pendingCallbacks?.delete(cleanup);
|
|
1612
|
+
if (pendingCallbacks?.size === 0) {
|
|
1613
|
+
this.pendingTrackAddedCallbacks.delete(pendingTrackSid);
|
|
1614
|
+
}
|
|
1615
|
+
}
|
|
1605
1616
|
};
|
|
1606
1617
|
this.once(RoomEvent.Reconnected, reconnectedHandler);
|
|
1607
1618
|
this.once(RoomEvent.Connected, reconnectedHandler);
|
|
1608
1619
|
this.once(RoomEvent.Disconnected, cleanup);
|
|
1620
|
+
if (pendingTrackSid) {
|
|
1621
|
+
const pendingCallbacks =
|
|
1622
|
+
this.pendingTrackAddedCallbacks.get(pendingTrackSid) ?? new Set<() => void>();
|
|
1623
|
+
pendingCallbacks.add(cleanup);
|
|
1624
|
+
this.pendingTrackAddedCallbacks.set(pendingTrackSid, pendingCallbacks);
|
|
1625
|
+
}
|
|
1609
1626
|
return;
|
|
1610
1627
|
}
|
|
1611
1628
|
if (this.state === ConnectionState.Disconnected) {
|
|
@@ -1618,11 +1635,10 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
|
|
|
1618
1635
|
}
|
|
1619
1636
|
const parts = unpackStreamId(stream.id);
|
|
1620
1637
|
const participantSid = parts[0];
|
|
1621
|
-
|
|
1622
|
-
let trackId = mediaTrack.id;
|
|
1638
|
+
const streamId = parts[1];
|
|
1623
1639
|
// firefox will get streamId (pID|trackId) instead of (pID|streamId) as it doesn't support sync tracks by stream
|
|
1624
1640
|
// and generates its own track id instead of infer from sdp track id.
|
|
1625
|
-
|
|
1641
|
+
let trackId = extractTrackSid(mediaTrack, stream) ?? mediaTrack.id;
|
|
1626
1642
|
|
|
1627
1643
|
if (participantSid === this.localParticipant.sid) {
|
|
1628
1644
|
this.log.warn('tried to create RemoteParticipant for local participant');
|
|
@@ -1691,6 +1707,10 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
|
|
|
1691
1707
|
}
|
|
1692
1708
|
}
|
|
1693
1709
|
|
|
1710
|
+
private cancelPendingTrackAdded(trackSid: Track.SID) {
|
|
1711
|
+
this.pendingTrackAddedCallbacks.get(trackSid)?.forEach((cleanup) => cleanup());
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1694
1714
|
private handleLocalTrackSubscribed(subscribedSid: string) {
|
|
1695
1715
|
const findPublication = () =>
|
|
1696
1716
|
this.localParticipant
|
|
@@ -2008,8 +2028,9 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
|
|
|
2008
2028
|
return;
|
|
2009
2029
|
}
|
|
2010
2030
|
const newStreamState = Track.streamStateFromProto(streamState.state);
|
|
2031
|
+
const prevStreamState = pub.track.streamState;
|
|
2011
2032
|
pub.track.setStreamState(newStreamState);
|
|
2012
|
-
if (newStreamState !==
|
|
2033
|
+
if (newStreamState !== prevStreamState) {
|
|
2013
2034
|
participant.emit(ParticipantEvent.TrackStreamStateChanged, pub, pub.track.streamState);
|
|
2014
2035
|
this.emitWhenConnected(
|
|
2015
2036
|
RoomEvent.TrackStreamStateChanged,
|
|
@@ -2035,6 +2056,8 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
|
|
|
2035
2056
|
};
|
|
2036
2057
|
|
|
2037
2058
|
private handleSubscriptionError = (update: SubscriptionResponse) => {
|
|
2059
|
+
this.cancelPendingTrackAdded(update.trackSid);
|
|
2060
|
+
|
|
2038
2061
|
const participant = Array.from(this.remoteParticipants.values()).find((p) =>
|
|
2039
2062
|
p.trackPublications.has(update.trackSid),
|
|
2040
2063
|
);
|
|
@@ -2409,6 +2432,7 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
|
|
|
2409
2432
|
},
|
|
2410
2433
|
)
|
|
2411
2434
|
.on(ParticipantEvent.TrackUnpublished, (publication: RemoteTrackPublication) => {
|
|
2435
|
+
this.cancelPendingTrackAdded(publication.trackSid);
|
|
2412
2436
|
this.emit(RoomEvent.TrackUnpublished, publication, participant);
|
|
2413
2437
|
})
|
|
2414
2438
|
.on(
|
|
@@ -2581,11 +2605,20 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
|
|
|
2581
2605
|
: undefined,
|
|
2582
2606
|
});
|
|
2583
2607
|
if (consecutiveFailures >= 3) {
|
|
2584
|
-
this.
|
|
2585
|
-
this.
|
|
2586
|
-
|
|
2587
|
-
|
|
2588
|
-
|
|
2608
|
+
this.clearConnectionReconcile();
|
|
2609
|
+
if (this.engine && !this.engine.isClosed) {
|
|
2610
|
+
// The transport silently died while we still looked connected. Try a full reconnect
|
|
2611
|
+
// (keeps the room alive; the engine falls back to Disconnected if it ultimately fails).
|
|
2612
|
+
this.log.warn('detected connection state mismatch, attempting full reconnect');
|
|
2613
|
+
this.engine.reconnect();
|
|
2614
|
+
} else {
|
|
2615
|
+
// No usable engine to reconnect with; tear down.
|
|
2616
|
+
this.recreateEngine();
|
|
2617
|
+
this.handleDisconnect(
|
|
2618
|
+
this.options.stopLocalTrackOnUnpublish,
|
|
2619
|
+
DisconnectReason.STATE_MISMATCH,
|
|
2620
|
+
);
|
|
2621
|
+
}
|
|
2589
2622
|
}
|
|
2590
2623
|
} else {
|
|
2591
2624
|
consecutiveFailures = 0;
|
|
@@ -2607,7 +2640,9 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
|
|
|
2607
2640
|
this.log.info(`connection state changed: ${this.state} -> ${state}`);
|
|
2608
2641
|
this.state = state;
|
|
2609
2642
|
this.incomingDataStreamManager.setConnected(state === ConnectionState.Connected);
|
|
2643
|
+
|
|
2610
2644
|
this.emit(RoomEvent.ConnectionStateChanged, this.state);
|
|
2645
|
+
|
|
2611
2646
|
return true;
|
|
2612
2647
|
}
|
|
2613
2648
|
|
|
@@ -535,7 +535,7 @@ function bytesToChunks(streamId: string): TransformStream<Uint8Array, DataStream
|
|
|
535
535
|
* synthesized text chunk decodes independently. The `flush` emits the decoder's trailing bytes.
|
|
536
536
|
*/
|
|
537
537
|
function bytesToDecodedUtf8(streamId: string): TransformStream<Uint8Array, DataStream_Chunk> {
|
|
538
|
-
const decoder = new TextDecoder('utf-8'
|
|
538
|
+
const decoder = new TextDecoder('utf-8');
|
|
539
539
|
const encoder = new TextEncoder();
|
|
540
540
|
|
|
541
541
|
let outIndex = 0;
|
|
@@ -204,7 +204,7 @@ export class TextStreamReader extends BaseStreamReader<TextStreamInfo> {
|
|
|
204
204
|
// Suppress unhandled rejection on reader.closed — errors are
|
|
205
205
|
// already propagated through reader.read() to the consumer.
|
|
206
206
|
reader.closed.catch(() => {});
|
|
207
|
-
const decoder = new TextDecoder('utf-8'
|
|
207
|
+
const decoder = new TextDecoder('utf-8');
|
|
208
208
|
const signal = this.signal;
|
|
209
209
|
|
|
210
210
|
const cleanup = () => {
|
package/src/room/errors.ts
CHANGED
|
@@ -248,8 +248,7 @@ export class PublishTrackError extends LivekitError {
|
|
|
248
248
|
}
|
|
249
249
|
|
|
250
250
|
export type RequestErrorReason =
|
|
251
|
-
|
|
252
|
-
| 'TimeoutError';
|
|
251
|
+
Exclude<RequestResponse_Reason, RequestResponse_Reason.OK> | 'TimeoutError';
|
|
253
252
|
|
|
254
253
|
export class SignalRequestError extends LivekitReasonedError<RequestErrorReason> {
|
|
255
254
|
readonly name = 'SignalRequestError';
|