livekit-client 2.21.0 → 2.22.1
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 +189 -2
- package/dist/livekit-client.e2ee.worker.mjs.map +1 -1
- package/dist/livekit-client.esm.mjs +2243 -270
- 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 +189 -2
- 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 +25 -1
- package/dist/src/api/SignalClient.d.ts.map +1 -1
- package/dist/src/api/SignalClientStateMachine.d.ts +85 -0
- package/dist/src/api/SignalClientStateMachine.d.ts.map +1 -0
- package/dist/src/api/WebSocketStream.d.ts.map +1 -1
- package/dist/src/api/utils.d.ts.map +1 -1
- package/dist/src/index.d.ts +3 -2
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/logger.d.ts +2 -1
- package/dist/src/logger.d.ts.map +1 -1
- package/dist/src/options.d.ts +22 -0
- package/dist/src/options.d.ts.map +1 -1
- package/dist/src/room/PCTransport.d.ts +51 -3
- package/dist/src/room/PCTransport.d.ts.map +1 -1
- package/dist/src/room/RTCEngine.d.ts +27 -3
- package/dist/src/room/RTCEngine.d.ts.map +1 -1
- package/dist/src/room/Room.d.ts +10 -1
- package/dist/src/room/Room.d.ts.map +1 -1
- package/dist/src/room/errors.d.ts.map +1 -1
- package/dist/src/room/events.d.ts +1 -1
- package/dist/src/room/participant/LocalParticipant.d.ts +1 -1
- package/dist/src/room/statsSummary.d.ts +13 -0
- package/dist/src/room/statsSummary.d.ts.map +1 -0
- 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/token-source/utils.d.ts.map +1 -1
- package/dist/src/room/track/LocalAudioTrack.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 +10 -0
- 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/src/utils/machineInspector.d.ts +54 -0
- package/dist/src/utils/machineInspector.d.ts.map +1 -0
- package/dist/ts4.2/api/SignalClient.d.ts +25 -1
- package/dist/ts4.2/api/SignalClientStateMachine.d.ts +85 -0
- package/dist/ts4.2/index.d.ts +3 -2
- package/dist/ts4.2/logger.d.ts +2 -1
- package/dist/ts4.2/options.d.ts +22 -0
- package/dist/ts4.2/room/PCTransport.d.ts +51 -3
- package/dist/ts4.2/room/RTCEngine.d.ts +27 -3
- package/dist/ts4.2/room/Room.d.ts +10 -1
- package/dist/ts4.2/room/events.d.ts +1 -1
- package/dist/ts4.2/room/participant/LocalParticipant.d.ts +1 -1
- package/dist/ts4.2/room/statsSummary.d.ts +13 -0
- 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/track/Track.d.ts +10 -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/dist/ts4.2/utils/machineInspector.d.ts +54 -0
- package/package.json +19 -12
- package/src/api/SignalClient.e2e.test.ts +16 -9
- package/src/api/SignalClient.test.ts +320 -8
- package/src/api/SignalClient.ts +260 -82
- package/src/api/SignalClientStateMachine.test.ts +472 -0
- package/src/api/SignalClientStateMachine.ts +180 -0
- package/src/api/WebSocketStream.ts +19 -3
- package/src/api/utils.test.ts +20 -1
- package/src/api/utils.ts +5 -0
- package/src/e2ee/utils.ts +1 -1
- package/src/index.ts +5 -0
- package/src/logger.ts +1 -0
- package/src/options.ts +24 -0
- package/src/room/PCTransport.test.ts +243 -1
- package/src/room/PCTransport.ts +183 -81
- package/src/room/RTCEngine.test.ts +339 -2
- package/src/room/RTCEngine.ts +168 -18
- package/src/room/Room.test.ts +134 -3
- package/src/room/Room.ts +122 -22
- 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/events.ts +1 -1
- package/src/room/statsSummary.ts +187 -0
- package/src/room/token-source/TokenSource.ts +25 -12
- package/src/room/token-source/test-tokens.ts +20 -0
- package/src/room/token-source/utils.test.ts +27 -0
- package/src/room/token-source/utils.ts +12 -5
- package/src/room/track/LocalAudioTrack.ts +9 -3
- 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 +29 -1
- package/src/room/utils.test.ts +94 -2
- package/src/room/utils.ts +53 -1
- 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/utils/machineInspector.ts +90 -0
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) =>
|
|
@@ -416,7 +432,12 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
|
|
|
416
432
|
}
|
|
417
433
|
}
|
|
418
434
|
|
|
419
|
-
|
|
435
|
+
/**
|
|
436
|
+
* @param reason why the session is ending, recorded by the signal lifecycle. Worth passing
|
|
437
|
+
* wherever the caller knows more than "someone called close" — the server's leave reason, or
|
|
438
|
+
* having given up on reconnecting.
|
|
439
|
+
*/
|
|
440
|
+
async close(reason?: string) {
|
|
420
441
|
const unlock = await this.closingLock.lock();
|
|
421
442
|
if (this.isClosed) {
|
|
422
443
|
unlock();
|
|
@@ -429,9 +450,10 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
|
|
|
429
450
|
this.removeAllListeners();
|
|
430
451
|
this.deregisterOnLineListener();
|
|
431
452
|
this.clearPendingReconnect();
|
|
453
|
+
this.clearLostQualityTimeout();
|
|
432
454
|
this.cleanupLossyDataStats();
|
|
433
455
|
await this.cleanupPeerConnections();
|
|
434
|
-
await this.cleanupClient();
|
|
456
|
+
await this.cleanupClient(reason);
|
|
435
457
|
} finally {
|
|
436
458
|
unlock();
|
|
437
459
|
}
|
|
@@ -442,6 +464,8 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
|
|
|
442
464
|
|
|
443
465
|
await this.pcManager?.close();
|
|
444
466
|
this.pcManager = undefined;
|
|
467
|
+
// the connecting timestamp belongs to the transports we just tore down
|
|
468
|
+
this.transportConnectingSince = undefined;
|
|
445
469
|
|
|
446
470
|
this.reliableReceivedState.clear();
|
|
447
471
|
}
|
|
@@ -450,8 +474,8 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
|
|
|
450
474
|
this.lossyChannel.stopThresholdTuning();
|
|
451
475
|
}
|
|
452
476
|
|
|
453
|
-
async cleanupClient() {
|
|
454
|
-
await this.client.close();
|
|
477
|
+
async cleanupClient(reason?: string) {
|
|
478
|
+
await this.client.close(true, reason);
|
|
455
479
|
this.client.resetCallbacks();
|
|
456
480
|
// Any in-flight addTrack requests are orphaned by the signal reconnect — the new session
|
|
457
481
|
// won't deliver `trackPublishedResponse` for them, so reject the pending resolvers and
|
|
@@ -468,7 +492,7 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
|
|
|
468
492
|
throw new TrackInvalidError('a track with the same ID has already been published');
|
|
469
493
|
}
|
|
470
494
|
return new Promise<TrackInfo>((resolve, reject) => {
|
|
471
|
-
const publicationTimeout = setTimeout(() => {
|
|
495
|
+
const publicationTimeout = CriticalTimers.setTimeout(() => {
|
|
472
496
|
delete this.pendingTrackResolvers[req.cid];
|
|
473
497
|
reject(
|
|
474
498
|
ConnectionError.timeout('publication of local track timed out, no response from server'),
|
|
@@ -476,11 +500,11 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
|
|
|
476
500
|
}, 10_000);
|
|
477
501
|
this.pendingTrackResolvers[req.cid] = {
|
|
478
502
|
resolve: (info: TrackInfo) => {
|
|
479
|
-
clearTimeout(publicationTimeout);
|
|
503
|
+
CriticalTimers.clearTimeout(publicationTimeout);
|
|
480
504
|
resolve(info);
|
|
481
505
|
},
|
|
482
506
|
reject: () => {
|
|
483
|
-
clearTimeout(publicationTimeout);
|
|
507
|
+
CriticalTimers.clearTimeout(publicationTimeout);
|
|
484
508
|
reject(new Error('Cancelled publication by calling unpublish'));
|
|
485
509
|
},
|
|
486
510
|
};
|
|
@@ -565,6 +589,16 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
|
|
|
565
589
|
this.pcManager.onStateChange = async (connectionState, publisherState, subscriberState) => {
|
|
566
590
|
this.log.debug(`primary PC state changed ${connectionState}`);
|
|
567
591
|
|
|
592
|
+
// Record when the primary transport actually entered CONNECTING so
|
|
593
|
+
// verifyTransport() can bound how long we tolerate it. Deriving it from the
|
|
594
|
+
// real transition (this handler only fires on state changes) rather than from
|
|
595
|
+
// observation time keeps it from going stale across peer-connection rebuilds.
|
|
596
|
+
if (connectionState === PCTransportState.CONNECTING) {
|
|
597
|
+
this.transportConnectingSince = Date.now();
|
|
598
|
+
} else {
|
|
599
|
+
this.transportConnectingSince = undefined;
|
|
600
|
+
}
|
|
601
|
+
|
|
568
602
|
if (['closed', 'disconnected', 'failed'].includes(publisherState)) {
|
|
569
603
|
// reset publisher connection promise
|
|
570
604
|
this.publisherConnectionPromise = undefined;
|
|
@@ -722,7 +756,7 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
|
|
|
722
756
|
switch (leave.action) {
|
|
723
757
|
case LeaveRequest_Action.DISCONNECT:
|
|
724
758
|
this.emit(EngineEvent.Disconnected, leave?.reason);
|
|
725
|
-
this.close();
|
|
759
|
+
this.close(`server leave: ${DisconnectReason[leave.reason] ?? leave.reason}`);
|
|
726
760
|
break;
|
|
727
761
|
case LeaveRequest_Action.RECONNECT:
|
|
728
762
|
this.fullReconnectOnNext = true;
|
|
@@ -816,8 +850,15 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
|
|
|
816
850
|
for (let i: number = 0; i < numAudios; i++) {
|
|
817
851
|
this.pcManager?.addPublisherTransceiverOfKind('audio', transceiverInit);
|
|
818
852
|
}
|
|
853
|
+
// media only arrives on these sections when there is no subscriber connection to arrive on
|
|
854
|
+
const receivesMedia = this.pcManager?.mode === 'publisher-only';
|
|
819
855
|
for (let i: number = 0; i < numVideos; i++) {
|
|
820
|
-
this.pcManager?.addPublisherTransceiverOfKind('video', transceiverInit);
|
|
856
|
+
const transceiver = this.pcManager?.addPublisherTransceiverOfKind('video', transceiverInit);
|
|
857
|
+
if (receivesMedia && transceiver) {
|
|
858
|
+
const negotiated = negotiateDependencyDescriptor(transceiver);
|
|
859
|
+
|
|
860
|
+
this.log.debug('dependency descriptor negotiated for received video', { negotiated });
|
|
861
|
+
}
|
|
821
862
|
}
|
|
822
863
|
}
|
|
823
864
|
|
|
@@ -1095,7 +1136,7 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
|
|
|
1095
1136
|
if (!opts.videoCodec) {
|
|
1096
1137
|
return;
|
|
1097
1138
|
}
|
|
1098
|
-
track.setSimulcastTrackSender(opts.videoCodec, transceiver.sender);
|
|
1139
|
+
await track.setSimulcastTrackSender(opts.videoCodec, transceiver.sender);
|
|
1099
1140
|
return transceiver.sender;
|
|
1100
1141
|
}
|
|
1101
1142
|
|
|
@@ -1125,7 +1166,7 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
|
|
|
1125
1166
|
`could not recover connection after ${this.reconnectAttempts} attempts, ${duration}ms. giving up`,
|
|
1126
1167
|
);
|
|
1127
1168
|
this.emit(EngineEvent.Disconnected);
|
|
1128
|
-
this.close();
|
|
1169
|
+
this.close(`gave up reconnecting after ${this.reconnectAttempts} attempts, ${duration}ms`);
|
|
1129
1170
|
};
|
|
1130
1171
|
|
|
1131
1172
|
const duration = Date.now() - this.reconnectStart;
|
|
@@ -1157,6 +1198,73 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
|
|
|
1157
1198
|
);
|
|
1158
1199
|
};
|
|
1159
1200
|
|
|
1201
|
+
/**
|
|
1202
|
+
* A sustained local `LOST` while connected and publishing means the server isn't receiving
|
|
1203
|
+
* our media, so force a full reconnect; any non-`LOST` value cancels a pending trigger.
|
|
1204
|
+
*/
|
|
1205
|
+
private handleLocalConnectionQuality(update: ConnectionQualityUpdate) {
|
|
1206
|
+
if (!this.participantSid) {
|
|
1207
|
+
return;
|
|
1208
|
+
}
|
|
1209
|
+
const localUpdate = update.updates.find((u) => u.participantSid === this.participantSid);
|
|
1210
|
+
if (!localUpdate) {
|
|
1211
|
+
return;
|
|
1212
|
+
}
|
|
1213
|
+
if (localUpdate.quality === ProtoConnectionQuality.LOST) {
|
|
1214
|
+
this.scheduleLostQualityReconnect();
|
|
1215
|
+
} else {
|
|
1216
|
+
this.clearLostQualityTimeout();
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
private scheduleLostQualityReconnect() {
|
|
1221
|
+
if (this.lostQualityTimeout) {
|
|
1222
|
+
// already counting down towards a reconnect
|
|
1223
|
+
return;
|
|
1224
|
+
}
|
|
1225
|
+
this.lostQualityTimeout = CriticalTimers.setTimeout(() => {
|
|
1226
|
+
this.lostQualityTimeout = undefined;
|
|
1227
|
+
if (this._isClosed || this.pcState !== PCState.Connected || this.attemptingReconnect) {
|
|
1228
|
+
return;
|
|
1229
|
+
}
|
|
1230
|
+
if (!this.hasActivePublisherSenders()) {
|
|
1231
|
+
return;
|
|
1232
|
+
}
|
|
1233
|
+
this.log.warn(
|
|
1234
|
+
'local connection quality lost while publishing, triggering full reconnect',
|
|
1235
|
+
this.logContext,
|
|
1236
|
+
);
|
|
1237
|
+
this.fullReconnectOnNext = true;
|
|
1238
|
+
this.handleDisconnect('connection quality lost', ReconnectReason.RR_PUBLISHER_FAILED);
|
|
1239
|
+
}, connectionQualityLostTimeout);
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
private clearLostQualityTimeout() {
|
|
1243
|
+
if (this.lostQualityTimeout) {
|
|
1244
|
+
CriticalTimers.clearTimeout(this.lostQualityTimeout);
|
|
1245
|
+
this.lostQualityTimeout = undefined;
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
/** Whether the publisher currently has any sender with a live track. */
|
|
1250
|
+
private hasActivePublisherSenders(): boolean {
|
|
1251
|
+
return (
|
|
1252
|
+
this.pcManager?.publisher
|
|
1253
|
+
.getSenders()
|
|
1254
|
+
.some((sender) => !!sender.track && sender.track.readyState === 'live') ?? false
|
|
1255
|
+
);
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
/**
|
|
1259
|
+
* Forces a full reconnect while keeping the engine (and its saved credentials) alive. Used by
|
|
1260
|
+
* Room's connection-reconcile safety net when the transport silently died but we looked connected.
|
|
1261
|
+
* @internal
|
|
1262
|
+
*/
|
|
1263
|
+
reconnect(reason: ReconnectReason = ReconnectReason.RR_UNKNOWN) {
|
|
1264
|
+
this.fullReconnectOnNext = true;
|
|
1265
|
+
this.handleDisconnect('reconcile', reason);
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1160
1268
|
private async attemptReconnect(reason?: ReconnectReason) {
|
|
1161
1269
|
if (this._isClosed) {
|
|
1162
1270
|
return;
|
|
@@ -1166,6 +1274,12 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
|
|
|
1166
1274
|
this.log.warn('already attempting reconnect, returning early');
|
|
1167
1275
|
return;
|
|
1168
1276
|
}
|
|
1277
|
+
|
|
1278
|
+
// A pending Lost-quality countdown belongs to the session we're now leaving; cancel it so
|
|
1279
|
+
// it can't fire against the reconnected session before the server has evaluated it. (A resume
|
|
1280
|
+
// keeps the peer connections, so cleanupPeerConnections wouldn't cover this path.)
|
|
1281
|
+
this.clearLostQualityTimeout();
|
|
1282
|
+
|
|
1169
1283
|
if (
|
|
1170
1284
|
this.clientConfiguration?.resumeConnection === ClientConfigSetting.DISABLED ||
|
|
1171
1285
|
// signaling state could change to closed due to hardware sleep
|
|
@@ -1175,15 +1289,23 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
|
|
|
1175
1289
|
this.fullReconnectOnNext = true;
|
|
1176
1290
|
}
|
|
1177
1291
|
|
|
1292
|
+
// Consume the flag up front: capture whether this attempt is a full reconnect, then reset
|
|
1293
|
+
// it. From here on a `true` value unambiguously represents a *new* full-reconnect request
|
|
1294
|
+
// that arrived while this attempt was running (e.g. a server RECONNECT leave), which the
|
|
1295
|
+
// finally block dispatches — for both the resume and full-reconnect paths.
|
|
1296
|
+
const fullReconnect = this.fullReconnectOnNext;
|
|
1297
|
+
this.fullReconnectOnNext = false;
|
|
1298
|
+
|
|
1299
|
+
let succeeded = false;
|
|
1178
1300
|
try {
|
|
1179
1301
|
this.attemptingReconnect = true;
|
|
1180
|
-
if (
|
|
1302
|
+
if (fullReconnect) {
|
|
1181
1303
|
await this.restartConnection();
|
|
1182
1304
|
} else {
|
|
1183
1305
|
await this.resumeConnection(reason);
|
|
1184
1306
|
}
|
|
1185
1307
|
this.clearPendingReconnect();
|
|
1186
|
-
|
|
1308
|
+
succeeded = true;
|
|
1187
1309
|
} catch (e) {
|
|
1188
1310
|
this.reconnectAttempts += 1;
|
|
1189
1311
|
let recoverable = true;
|
|
@@ -1191,8 +1313,9 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
|
|
|
1191
1313
|
this.log.debug('received unrecoverable error', { error: e });
|
|
1192
1314
|
// unrecoverable
|
|
1193
1315
|
recoverable = false;
|
|
1194
|
-
} else if (!(e instanceof SignalReconnectError)) {
|
|
1195
|
-
//
|
|
1316
|
+
} else if (fullReconnect || !(e instanceof SignalReconnectError)) {
|
|
1317
|
+
// a failed full reconnect stays a full reconnect; a failed resume can only be
|
|
1318
|
+
// resumed again for a signal-level error, otherwise it escalates
|
|
1196
1319
|
this.fullReconnectOnNext = true;
|
|
1197
1320
|
}
|
|
1198
1321
|
|
|
@@ -1205,10 +1328,22 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
|
|
|
1205
1328
|
}ms. giving up`,
|
|
1206
1329
|
);
|
|
1207
1330
|
this.emit(EngineEvent.Disconnected);
|
|
1208
|
-
await this.close(
|
|
1331
|
+
await this.close(
|
|
1332
|
+
`gave up reconnecting after ${this.reconnectAttempts} attempts, ${
|
|
1333
|
+
Date.now() - this.reconnectStart
|
|
1334
|
+
}ms`,
|
|
1335
|
+
);
|
|
1209
1336
|
}
|
|
1210
1337
|
} finally {
|
|
1211
1338
|
this.attemptingReconnect = false;
|
|
1339
|
+
|
|
1340
|
+
// A full reconnect requested while this attempt was running (e.g. a `RECONNECT` leave
|
|
1341
|
+
// during a resume or a restart) that a successful attempt didn't act on; dispatch it now
|
|
1342
|
+
// (the failure path already retries).
|
|
1343
|
+
if (succeeded && this.fullReconnectOnNext && !this._isClosed) {
|
|
1344
|
+
this.log.debug('full reconnect requested during in-progress attempt, dispatching');
|
|
1345
|
+
this.handleDisconnect('reconnect');
|
|
1346
|
+
}
|
|
1212
1347
|
}
|
|
1213
1348
|
}
|
|
1214
1349
|
|
|
@@ -1591,11 +1726,12 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
|
|
|
1591
1726
|
if (!this.pcManager) {
|
|
1592
1727
|
return false;
|
|
1593
1728
|
}
|
|
1729
|
+
const state = this.pcManager.currentState;
|
|
1594
1730
|
const allowedConnectionStates: PCTransportState[] = [
|
|
1595
1731
|
PCTransportState.CONNECTING,
|
|
1596
1732
|
PCTransportState.CONNECTED,
|
|
1597
1733
|
];
|
|
1598
|
-
if (!allowedConnectionStates.includes(
|
|
1734
|
+
if (!allowedConnectionStates.includes(state)) {
|
|
1599
1735
|
return false;
|
|
1600
1736
|
}
|
|
1601
1737
|
|
|
@@ -1603,6 +1739,20 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
|
|
|
1603
1739
|
if (!this.client.ws || this.client.ws.readyState === WebSocket.CLOSED) {
|
|
1604
1740
|
return false;
|
|
1605
1741
|
}
|
|
1742
|
+
|
|
1743
|
+
// A transport stuck in CONNECTING never reaches CONNECTED nor reports FAILED, so it would
|
|
1744
|
+
// otherwise look healthy forever; bound how long we tolerate it. The entry time is recorded
|
|
1745
|
+
// in the pcManager state-change handler (see configure()), so this is a pure read — an
|
|
1746
|
+
// unrecorded CONNECTING fails open rather than measuring against a stale timestamp.
|
|
1747
|
+
if (
|
|
1748
|
+
state === PCTransportState.CONNECTING &&
|
|
1749
|
+
this.transportConnectingSince !== undefined &&
|
|
1750
|
+
Date.now() - this.transportConnectingSince > this.peerConnectionTimeout
|
|
1751
|
+
) {
|
|
1752
|
+
this.log.warn('transport stuck in connecting state', this.logContext);
|
|
1753
|
+
return false;
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1606
1756
|
return true;
|
|
1607
1757
|
}
|
|
1608
1758
|
|
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
|
+
});
|