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.
Files changed (116) hide show
  1. package/README.md +10 -10
  2. package/dist/livekit-client.e2ee.worker.js +1 -1
  3. package/dist/livekit-client.e2ee.worker.js.map +1 -1
  4. package/dist/livekit-client.e2ee.worker.mjs +189 -2
  5. package/dist/livekit-client.e2ee.worker.mjs.map +1 -1
  6. package/dist/livekit-client.esm.mjs +2243 -270
  7. package/dist/livekit-client.esm.mjs.map +1 -1
  8. package/dist/livekit-client.fm.worker.js +1 -1
  9. package/dist/livekit-client.fm.worker.js.map +1 -1
  10. package/dist/livekit-client.fm.worker.mjs +189 -2
  11. package/dist/livekit-client.fm.worker.mjs.map +1 -1
  12. package/dist/livekit-client.umd.js +1 -1
  13. package/dist/livekit-client.umd.js.map +1 -1
  14. package/dist/src/api/SignalClient.d.ts +25 -1
  15. package/dist/src/api/SignalClient.d.ts.map +1 -1
  16. package/dist/src/api/SignalClientStateMachine.d.ts +85 -0
  17. package/dist/src/api/SignalClientStateMachine.d.ts.map +1 -0
  18. package/dist/src/api/WebSocketStream.d.ts.map +1 -1
  19. package/dist/src/api/utils.d.ts.map +1 -1
  20. package/dist/src/index.d.ts +3 -2
  21. package/dist/src/index.d.ts.map +1 -1
  22. package/dist/src/logger.d.ts +2 -1
  23. package/dist/src/logger.d.ts.map +1 -1
  24. package/dist/src/options.d.ts +22 -0
  25. package/dist/src/options.d.ts.map +1 -1
  26. package/dist/src/room/PCTransport.d.ts +51 -3
  27. package/dist/src/room/PCTransport.d.ts.map +1 -1
  28. package/dist/src/room/RTCEngine.d.ts +27 -3
  29. package/dist/src/room/RTCEngine.d.ts.map +1 -1
  30. package/dist/src/room/Room.d.ts +10 -1
  31. package/dist/src/room/Room.d.ts.map +1 -1
  32. package/dist/src/room/errors.d.ts.map +1 -1
  33. package/dist/src/room/events.d.ts +1 -1
  34. package/dist/src/room/participant/LocalParticipant.d.ts +1 -1
  35. package/dist/src/room/statsSummary.d.ts +13 -0
  36. package/dist/src/room/statsSummary.d.ts.map +1 -0
  37. package/dist/src/room/token-source/TokenSource.d.ts +19 -10
  38. package/dist/src/room/token-source/TokenSource.d.ts.map +1 -1
  39. package/dist/src/room/token-source/utils.d.ts.map +1 -1
  40. package/dist/src/room/track/LocalAudioTrack.d.ts.map +1 -1
  41. package/dist/src/room/track/LocalVideoTrack.d.ts +12 -1
  42. package/dist/src/room/track/LocalVideoTrack.d.ts.map +1 -1
  43. package/dist/src/room/track/RemoteTrack.d.ts +3 -0
  44. package/dist/src/room/track/RemoteTrack.d.ts.map +1 -1
  45. package/dist/src/room/track/Track.d.ts +10 -0
  46. package/dist/src/room/track/Track.d.ts.map +1 -1
  47. package/dist/src/room/utils.d.ts +15 -0
  48. package/dist/src/room/utils.d.ts.map +1 -1
  49. package/dist/src/test/promiseState.d.ts +12 -0
  50. package/dist/src/test/promiseState.d.ts.map +1 -0
  51. package/dist/src/test/signalToken.d.ts.map +1 -1
  52. package/dist/src/utils/AsyncQueue.d.ts +3 -3
  53. package/dist/src/utils/AsyncQueue.d.ts.map +1 -1
  54. package/dist/src/utils/machineInspector.d.ts +54 -0
  55. package/dist/src/utils/machineInspector.d.ts.map +1 -0
  56. package/dist/ts4.2/api/SignalClient.d.ts +25 -1
  57. package/dist/ts4.2/api/SignalClientStateMachine.d.ts +85 -0
  58. package/dist/ts4.2/index.d.ts +3 -2
  59. package/dist/ts4.2/logger.d.ts +2 -1
  60. package/dist/ts4.2/options.d.ts +22 -0
  61. package/dist/ts4.2/room/PCTransport.d.ts +51 -3
  62. package/dist/ts4.2/room/RTCEngine.d.ts +27 -3
  63. package/dist/ts4.2/room/Room.d.ts +10 -1
  64. package/dist/ts4.2/room/events.d.ts +1 -1
  65. package/dist/ts4.2/room/participant/LocalParticipant.d.ts +1 -1
  66. package/dist/ts4.2/room/statsSummary.d.ts +13 -0
  67. package/dist/ts4.2/room/token-source/TokenSource.d.ts +17 -8
  68. package/dist/ts4.2/room/track/LocalVideoTrack.d.ts +12 -1
  69. package/dist/ts4.2/room/track/RemoteTrack.d.ts +3 -0
  70. package/dist/ts4.2/room/track/Track.d.ts +10 -0
  71. package/dist/ts4.2/room/utils.d.ts +15 -0
  72. package/dist/ts4.2/test/promiseState.d.ts +12 -0
  73. package/dist/ts4.2/utils/AsyncQueue.d.ts +3 -3
  74. package/dist/ts4.2/utils/machineInspector.d.ts +54 -0
  75. package/package.json +19 -12
  76. package/src/api/SignalClient.e2e.test.ts +16 -9
  77. package/src/api/SignalClient.test.ts +320 -8
  78. package/src/api/SignalClient.ts +260 -82
  79. package/src/api/SignalClientStateMachine.test.ts +472 -0
  80. package/src/api/SignalClientStateMachine.ts +180 -0
  81. package/src/api/WebSocketStream.ts +19 -3
  82. package/src/api/utils.test.ts +20 -1
  83. package/src/api/utils.ts +5 -0
  84. package/src/e2ee/utils.ts +1 -1
  85. package/src/index.ts +5 -0
  86. package/src/logger.ts +1 -0
  87. package/src/options.ts +24 -0
  88. package/src/room/PCTransport.test.ts +243 -1
  89. package/src/room/PCTransport.ts +183 -81
  90. package/src/room/RTCEngine.test.ts +339 -2
  91. package/src/room/RTCEngine.ts +168 -18
  92. package/src/room/Room.test.ts +134 -3
  93. package/src/room/Room.ts +122 -22
  94. package/src/room/data-stream/incoming/IncomingDataStreamManager.ts +1 -1
  95. package/src/room/data-stream/incoming/StreamReader.ts +1 -1
  96. package/src/room/errors.ts +1 -2
  97. package/src/room/events.ts +1 -1
  98. package/src/room/statsSummary.ts +187 -0
  99. package/src/room/token-source/TokenSource.ts +25 -12
  100. package/src/room/token-source/test-tokens.ts +20 -0
  101. package/src/room/token-source/utils.test.ts +27 -0
  102. package/src/room/token-source/utils.ts +12 -5
  103. package/src/room/track/LocalAudioTrack.ts +9 -3
  104. package/src/room/track/LocalVideoTrack.test.ts +105 -2
  105. package/src/room/track/LocalVideoTrack.ts +36 -10
  106. package/src/room/track/RemoteTrack.test.ts +144 -0
  107. package/src/room/track/RemoteTrack.ts +39 -12
  108. package/src/room/track/Track.ts +29 -1
  109. package/src/room/utils.test.ts +94 -2
  110. package/src/room/utils.ts +53 -1
  111. package/src/test/promiseState.ts +23 -0
  112. package/src/test/signalServerSetup.ts +2 -1
  113. package/src/test/signalToken.ts +17 -13
  114. package/src/type-polyfills/header-extensions.d.ts +13 -0
  115. package/src/utils/AsyncQueue.ts +3 -3
  116. package/src/utils/machineInspector.ts +90 -0
@@ -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
- async close() {
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 (this.fullReconnectOnNext) {
1302
+ if (fullReconnect) {
1181
1303
  await this.restartConnection();
1182
1304
  } else {
1183
1305
  await this.resumeConnection(reason);
1184
1306
  }
1185
1307
  this.clearPendingReconnect();
1186
- this.fullReconnectOnNext = false;
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
- // cannot resume
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(this.pcManager.currentState)) {
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
 
@@ -1,8 +1,22 @@
1
- import { ClientInfo_Capability, JoinResponse } from '@livekit/protocol';
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 Room from './Room';
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
+ });