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.
Files changed (70) 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 +2 -1
  5. package/dist/livekit-client.e2ee.worker.mjs.map +1 -1
  6. package/dist/livekit-client.esm.mjs +480 -150
  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 +2 -1
  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/room/PCTransport.d.ts +49 -2
  15. package/dist/src/room/PCTransport.d.ts.map +1 -1
  16. package/dist/src/room/RTCEngine.d.ts +20 -1
  17. package/dist/src/room/RTCEngine.d.ts.map +1 -1
  18. package/dist/src/room/Room.d.ts +2 -0
  19. package/dist/src/room/Room.d.ts.map +1 -1
  20. package/dist/src/room/errors.d.ts.map +1 -1
  21. package/dist/src/room/participant/LocalParticipant.d.ts +1 -1
  22. package/dist/src/room/token-source/TokenSource.d.ts +19 -10
  23. package/dist/src/room/token-source/TokenSource.d.ts.map +1 -1
  24. package/dist/src/room/track/LocalVideoTrack.d.ts +12 -1
  25. package/dist/src/room/track/LocalVideoTrack.d.ts.map +1 -1
  26. package/dist/src/room/track/RemoteTrack.d.ts +3 -0
  27. package/dist/src/room/track/RemoteTrack.d.ts.map +1 -1
  28. package/dist/src/room/track/Track.d.ts.map +1 -1
  29. package/dist/src/room/utils.d.ts +15 -0
  30. package/dist/src/room/utils.d.ts.map +1 -1
  31. package/dist/src/test/promiseState.d.ts +12 -0
  32. package/dist/src/test/promiseState.d.ts.map +1 -0
  33. package/dist/src/test/signalToken.d.ts.map +1 -1
  34. package/dist/src/utils/AsyncQueue.d.ts +3 -3
  35. package/dist/src/utils/AsyncQueue.d.ts.map +1 -1
  36. package/dist/ts4.2/room/PCTransport.d.ts +49 -2
  37. package/dist/ts4.2/room/RTCEngine.d.ts +20 -1
  38. package/dist/ts4.2/room/Room.d.ts +2 -0
  39. package/dist/ts4.2/room/participant/LocalParticipant.d.ts +1 -1
  40. package/dist/ts4.2/room/token-source/TokenSource.d.ts +17 -8
  41. package/dist/ts4.2/room/track/LocalVideoTrack.d.ts +12 -1
  42. package/dist/ts4.2/room/track/RemoteTrack.d.ts +3 -0
  43. package/dist/ts4.2/room/utils.d.ts +15 -0
  44. package/dist/ts4.2/test/promiseState.d.ts +12 -0
  45. package/dist/ts4.2/utils/AsyncQueue.d.ts +3 -3
  46. package/package.json +13 -12
  47. package/src/api/SignalClient.e2e.test.ts +16 -9
  48. package/src/e2ee/utils.ts +1 -1
  49. package/src/room/PCTransport.test.ts +243 -1
  50. package/src/room/PCTransport.ts +181 -80
  51. package/src/room/RTCEngine.test.ts +339 -2
  52. package/src/room/RTCEngine.ts +152 -11
  53. package/src/room/Room.test.ts +134 -3
  54. package/src/room/Room.ts +52 -17
  55. package/src/room/data-stream/incoming/IncomingDataStreamManager.ts +1 -1
  56. package/src/room/data-stream/incoming/StreamReader.ts +1 -1
  57. package/src/room/errors.ts +1 -2
  58. package/src/room/token-source/TokenSource.ts +25 -12
  59. package/src/room/track/LocalVideoTrack.test.ts +105 -2
  60. package/src/room/track/LocalVideoTrack.ts +36 -10
  61. package/src/room/track/RemoteTrack.test.ts +144 -0
  62. package/src/room/track/RemoteTrack.ts +39 -12
  63. package/src/room/track/Track.ts +2 -1
  64. package/src/room/utils.test.ts +71 -2
  65. package/src/room/utils.ts +52 -0
  66. package/src/test/promiseState.ts +23 -0
  67. package/src/test/signalServerSetup.ts +2 -1
  68. package/src/test/signalToken.ts +17 -13
  69. package/src/type-polyfills/header-extensions.d.ts +13 -0
  70. package/src/utils/AsyncQueue.ts +3 -3
@@ -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 (this.fullReconnectOnNext) {
1297
+ if (fullReconnect) {
1181
1298
  await this.restartConnection();
1182
1299
  } else {
1183
1300
  await this.resumeConnection(reason);
1184
1301
  }
1185
1302
  this.clearPendingReconnect();
1186
- this.fullReconnectOnNext = false;
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
- // cannot resume
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(this.pcManager.currentState)) {
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
 
@@ -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
+ });
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 (this.state === ConnectionState.Connecting || this.state === ConnectionState.Reconnecting) {
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
- let streamId = parts[1];
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
- if (streamId && streamId.startsWith('TR')) trackId = streamId;
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 !== pub.track.streamState) {
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.recreateEngine();
2585
- this.handleDisconnect(
2586
- this.options.stopLocalTrackOnUnpublish,
2587
- DisconnectReason.STATE_MISMATCH,
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', { fatal: true });
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', { fatal: true });
207
+ const decoder = new TextDecoder('utf-8');
208
208
  const signal = this.signal;
209
209
 
210
210
  const cleanup = () => {
@@ -248,8 +248,7 @@ export class PublishTrackError extends LivekitError {
248
248
  }
249
249
 
250
250
  export type RequestErrorReason =
251
- | Exclude<RequestResponse_Reason, RequestResponse_Reason.OK>
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';