livekit-client 2.22.1 → 2.22.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/dist/livekit-client.e2ee.worker.js +1 -1
  2. package/dist/livekit-client.e2ee.worker.js.map +1 -1
  3. package/dist/livekit-client.e2ee.worker.mjs +177 -39
  4. package/dist/livekit-client.e2ee.worker.mjs.map +1 -1
  5. package/dist/livekit-client.esm.mjs +85 -60
  6. package/dist/livekit-client.esm.mjs.map +1 -1
  7. package/dist/livekit-client.umd.js +1 -1
  8. package/dist/livekit-client.umd.js.map +1 -1
  9. package/dist/src/api/SignalClient.d.ts.map +1 -1
  10. package/dist/src/e2ee/E2eeManager.d.ts +7 -0
  11. package/dist/src/e2ee/E2eeManager.d.ts.map +1 -1
  12. package/dist/src/e2ee/constants.d.ts +5 -0
  13. package/dist/src/e2ee/constants.d.ts.map +1 -1
  14. package/dist/src/e2ee/types.d.ts +9 -2
  15. package/dist/src/e2ee/types.d.ts.map +1 -1
  16. package/dist/src/e2ee/worker/FrameCryptor.d.ts +42 -1
  17. package/dist/src/e2ee/worker/FrameCryptor.d.ts.map +1 -1
  18. package/dist/src/room/PCTransportManager.d.ts +12 -0
  19. package/dist/src/room/PCTransportManager.d.ts.map +1 -1
  20. package/dist/src/room/data-stream/incoming/StreamReader.d.ts +17 -17
  21. package/dist/src/room/data-stream/incoming/StreamReader.d.ts.map +1 -1
  22. package/dist/ts4.2/e2ee/E2eeManager.d.ts +7 -0
  23. package/dist/ts4.2/e2ee/constants.d.ts +5 -0
  24. package/dist/ts4.2/e2ee/types.d.ts +9 -2
  25. package/dist/ts4.2/e2ee/worker/FrameCryptor.d.ts +42 -1
  26. package/dist/ts4.2/room/PCTransportManager.d.ts +12 -0
  27. package/dist/ts4.2/room/data-stream/incoming/StreamReader.d.ts +17 -17
  28. package/package.json +1 -1
  29. package/src/api/SignalClient.ts +2 -1
  30. package/src/e2ee/E2eeManager.ts +38 -14
  31. package/src/e2ee/constants.ts +6 -0
  32. package/src/e2ee/subscriberBlackScreen.test.ts +544 -0
  33. package/src/e2ee/types.ts +9 -2
  34. package/src/e2ee/worker/FrameCryptor.race.test.ts +9 -26
  35. package/src/e2ee/worker/FrameCryptor.test.ts +0 -1
  36. package/src/e2ee/worker/FrameCryptor.ts +185 -52
  37. package/src/e2ee/worker/e2ee.worker.ts +38 -6
  38. package/src/room/PCTransportManager.test.ts +35 -0
  39. package/src/room/PCTransportManager.ts +12 -4
  40. package/src/room/data-stream/incoming/IncomingDataStreamManager.test.ts +171 -0
  41. package/src/room/data-stream/incoming/IncomingDataStreamManager.ts +17 -18
  42. package/src/room/data-stream/incoming/StreamReader.ts +20 -50
package/src/e2ee/types.ts CHANGED
@@ -53,7 +53,6 @@ export interface EncodeMessage extends BaseMessage {
53
53
  writableStream: WritableStream;
54
54
  trackId: string;
55
55
  codec?: VideoCodec;
56
- isReuse: boolean;
57
56
  /**
58
57
  * Whether the published track advertises packet trailer features.
59
58
  * When false, the cryptor skips the per-frame trailer extraction path
@@ -80,7 +79,15 @@ export interface UpdateCodecMessage extends BaseMessage {
80
79
  data: {
81
80
  participantIdentity: string;
82
81
  trackId: string;
83
- codec: VideoCodec;
82
+ /** undefined for audio tracks */
83
+ codec?: VideoCodec;
84
+ /**
85
+ * trackId this receiver's pipeline was previously set up for, set when a
86
+ * transceiver gets reused for a new track. Lets the worker find the cryptor
87
+ * that still owns the (already transferred) encoded streams and re-point it,
88
+ * rather than creating a fresh cryptor with no pipeline at all.
89
+ */
90
+ previousTrackId?: string;
84
91
  hasPacketTrailer: boolean;
85
92
  };
86
93
  }
@@ -68,8 +68,8 @@ describe('FrameCryptor Race Conditions', () => {
68
68
  vitest.useRealTimers();
69
69
  });
70
70
 
71
- describe('Race Condition 1: setupTransform with isReuse does not update trackId', () => {
72
- it('should update trackId even when returning early on reuse', async () => {
71
+ describe('Race Condition 1: setupTransform on transceiver reuse updates trackId', () => {
72
+ it('should update trackId when the transform is replaced', async () => {
73
73
  const { cryptor, keys } = createCryptor('participant1');
74
74
  await keys.setKey(await createKeyMaterialFromString('key1'), 0);
75
75
 
@@ -82,7 +82,6 @@ describe('FrameCryptor Race Conditions', () => {
82
82
  new ReadableStream(input1),
83
83
  new WritableStream(output1),
84
84
  'track1',
85
- false,
86
85
  undefined,
87
86
  );
88
87
 
@@ -91,18 +90,16 @@ describe('FrameCryptor Race Conditions', () => {
91
90
  const input2 = new TestUnderlyingSource<RTCEncodedVideoFrame>();
92
91
  const output2 = new TestUnderlyingSink<RTCEncodedVideoFrame>();
93
92
 
94
- // Second setup with isReuse=true and different trackId 'track2'
93
+ // Second setup with a different trackId 'track2'
95
94
  // This simulates transceiver reuse for a new track
96
95
  cryptor.setupTransform(
97
96
  'encode',
98
97
  new ReadableStream(input2),
99
98
  new WritableStream(output2),
100
99
  'track2',
101
- true, // isReuse = true
102
100
  undefined,
103
101
  );
104
102
 
105
- // BUG: trackId should be updated to 'track2' but remains 'track1'
106
103
  expect(cryptor.getTrackId()).toBe('track2');
107
104
  });
108
105
  });
@@ -120,7 +117,6 @@ describe('FrameCryptor Race Conditions', () => {
120
117
  new ReadableStream(input),
121
118
  new WritableStream(output),
122
119
  'track1',
123
- false,
124
120
  undefined,
125
121
  );
126
122
 
@@ -147,7 +143,6 @@ describe('FrameCryptor Race Conditions', () => {
147
143
  new ReadableStream(input2),
148
144
  new WritableStream(output2),
149
145
  'track2',
150
- false,
151
146
  undefined,
152
147
  );
153
148
 
@@ -198,7 +193,6 @@ describe('FrameCryptor Race Conditions', () => {
198
193
  new ReadableStream(input1),
199
194
  new WritableStream(output1),
200
195
  'track1',
201
- false,
202
196
  undefined,
203
197
  );
204
198
 
@@ -213,7 +207,6 @@ describe('FrameCryptor Race Conditions', () => {
213
207
  new ReadableStream(input2),
214
208
  new WritableStream(output2),
215
209
  'track2',
216
- false,
217
210
  undefined,
218
211
  );
219
212
 
@@ -244,7 +237,6 @@ describe('FrameCryptor Race Conditions', () => {
244
237
  new ReadableStream(input1),
245
238
  new WritableStream(output1),
246
239
  'track1',
247
- false,
248
240
  undefined,
249
241
  );
250
242
 
@@ -257,13 +249,12 @@ describe('FrameCryptor Race Conditions', () => {
257
249
  const input2 = new TestUnderlyingSource<RTCEncodedVideoFrame>();
258
250
  const output2 = new TestUnderlyingSink<RTCEncodedVideoFrame>();
259
251
 
260
- // This should create a new transform since isTransformActive should be false
252
+ // This should create a new transform
261
253
  cryptor.setupTransform(
262
254
  'encode',
263
255
  new ReadableStream(input2),
264
256
  new WritableStream(output2),
265
257
  'track2',
266
- true, // isReuse=true
267
258
  undefined,
268
259
  );
269
260
 
@@ -274,7 +265,7 @@ describe('FrameCryptor Race Conditions', () => {
274
265
  expect(cryptor.getTrackId()).toBe('track2');
275
266
  });
276
267
 
277
- it('should handle race between pipe completion and new setupTransform with isReuse', async () => {
268
+ it('should handle race between pipe completion and a new setupTransform', async () => {
278
269
  const { cryptor, keys } = createCryptor('participant1');
279
270
  await keys.setKey(await createKeyMaterialFromString('key1'), 0);
280
271
 
@@ -286,11 +277,10 @@ describe('FrameCryptor Race Conditions', () => {
286
277
  new ReadableStream(input1),
287
278
  new WritableStream(output1),
288
279
  'track1',
289
- false,
290
280
  undefined,
291
281
  );
292
282
 
293
- // Immediately call with isReuse=true (simulating quick reuse detection)
283
+ // Immediately set up again (simulating a quick resubscribe)
294
284
  const input2 = new TestUnderlyingSource<RTCEncodedVideoFrame>();
295
285
  const output2 = new TestUnderlyingSink<RTCEncodedVideoFrame>();
296
286
 
@@ -298,8 +288,7 @@ describe('FrameCryptor Race Conditions', () => {
298
288
  'encode',
299
289
  new ReadableStream(input2),
300
290
  new WritableStream(output2),
301
- 'track2',
302
- true, // Should return early
291
+ 'track2', // Should return early
303
292
  undefined,
304
293
  );
305
294
 
@@ -308,7 +297,7 @@ describe('FrameCryptor Race Conditions', () => {
308
297
 
309
298
  await vitest.advanceTimersToNextTimerAsync();
310
299
 
311
- // Now try to setup with isReuse=true again after first pipe completed
300
+ // Now set up again after the first pipe completed
312
301
  const input3 = new TestUnderlyingSource<RTCEncodedVideoFrame>();
313
302
  const output3 = new TestUnderlyingSink<RTCEncodedVideoFrame>();
314
303
 
@@ -317,7 +306,6 @@ describe('FrameCryptor Race Conditions', () => {
317
306
  new ReadableStream(input3),
318
307
  new WritableStream(output3),
319
308
  'track3',
320
- true,
321
309
  undefined,
322
310
  );
323
311
 
@@ -345,7 +333,6 @@ describe('FrameCryptor Race Conditions', () => {
345
333
  new ReadableStream(input),
346
334
  new WritableStream(output),
347
335
  'track1',
348
- false,
349
336
  undefined,
350
337
  );
351
338
 
@@ -431,7 +418,6 @@ describe('FrameCryptor Race Conditions', () => {
431
418
  new ReadableStream(input),
432
419
  new WritableStream(output),
433
420
  'track1',
434
- false,
435
421
  undefined,
436
422
  );
437
423
 
@@ -469,7 +455,6 @@ describe('FrameCryptor Race Conditions', () => {
469
455
  new ReadableStream(input),
470
456
  new WritableStream(output),
471
457
  'track1',
472
- false,
473
458
  'vp8',
474
459
  );
475
460
 
@@ -509,7 +494,6 @@ describe('FrameCryptor Race Conditions', () => {
509
494
  new ReadableStream(input1),
510
495
  new WritableStream(output1),
511
496
  'track1',
512
- false,
513
497
  undefined,
514
498
  );
515
499
 
@@ -534,11 +518,10 @@ describe('FrameCryptor Race Conditions', () => {
534
518
  new ReadableStream(input2),
535
519
  new WritableStream(output2),
536
520
  'track2',
537
- true, // isReuse
538
521
  undefined,
539
522
  );
540
523
 
541
- // Track ID should be updated even with isReuse
524
+ // Track ID should be updated
542
525
  expect(cryptor.getTrackId()).toBe('track2');
543
526
  expect(cryptor.getParticipantIdentity()).toBe('participant2');
544
527
  });
@@ -110,7 +110,6 @@ function prepareParticipantTest(
110
110
  new ReadableStream(input),
111
111
  new WritableStream(output),
112
112
  'testTrack',
113
- false,
114
113
  undefined,
115
114
  packetTrailer,
116
115
  );
@@ -84,6 +84,22 @@ export class FrameCryptor extends BaseFrameCryptor {
84
84
 
85
85
  private currentTransform?: TransformerInfo;
86
86
 
87
+ /**
88
+ * The encoded streams this cryptor was last set up with, retained beyond the
89
+ * lifetime of {@link currentTransform}.
90
+ *
91
+ * The main thread transfers a receiver's encoded streams to the worker once and
92
+ * cannot transfer them again (a transferred stream is locked), and
93
+ * `createEncodedStreams()` may only be called once per receiver. So when a
94
+ * transceiver is reused, or when a pipe dies underneath us, re-establishing the
95
+ * transform is only possible from here. See {@link ensureTransform}.
96
+ */
97
+ private retainedStreams?: {
98
+ readable: ReadableStream<RTCEncodedVideoFrame | RTCEncodedAudioFrame>;
99
+ writable: WritableStream<RTCEncodedVideoFrame | RTCEncodedAudioFrame>;
100
+ operation: 'encode' | 'decode';
101
+ };
102
+
87
103
  /**
88
104
  * Whether the subscribed track advertises packet trailer features.
89
105
  * When false, we skip the per-frame trailer extraction path entirely
@@ -108,6 +124,11 @@ export class FrameCryptor extends BaseFrameCryptor {
108
124
 
109
125
  private readonly ERROR_WINDOW_MS = 60000; // 1 minute window
110
126
 
127
+ private undecryptedTrackTimeout?: ReturnType<typeof setTimeout>;
128
+
129
+ /** grace period for a teardown or a resubscribe to land before we report a stalled track */
130
+ private readonly UNDECRYPTED_TRACK_GRACE_MS = 2000;
131
+
111
132
  /**
112
133
  * Tracks (participant, trackId, payloadType) tuples for which we've already logged a NALU
113
134
  * fallback, so a persistent bad state doesn't flood the console (Firefox doesn't filter debug).
@@ -167,10 +188,13 @@ export class FrameCryptor extends BaseFrameCryptor {
167
188
  unsetParticipant() {
168
189
  workerLogger.debug('unsetting participant', this.logContext);
169
190
 
170
- if (this.currentTransform) {
171
- this.currentTransform = undefined;
172
- }
173
-
191
+ // NOTE: deliberately does not clear `currentTransform`. Nothing here cancels
192
+ // the pipe, so the transform really is still running and clearing it would
193
+ // desync our bookkeeping from reality -- which previously let a later
194
+ // resubscribe skip setup while frames kept flowing through a cryptor with no
195
+ // participant assigned.
196
+ clearTimeout(this.undecryptedTrackTimeout);
197
+ this.undecryptedTrackTimeout = undefined;
174
198
  this.participantIdentity = undefined;
175
199
  this.lastErrorTimestamp = new Map();
176
200
  this.errorCounts = new Map();
@@ -192,6 +216,78 @@ export class FrameCryptor extends BaseFrameCryptor {
192
216
  return this.trackId;
193
217
  }
194
218
 
219
+ /**
220
+ * Re-point this cryptor at a new trackId, keeping its (already transferred)
221
+ * encoded streams. Used when a transceiver is reused for a new track.
222
+ */
223
+ setTrackId(trackId: string) {
224
+ if (this.trackId === trackId) {
225
+ return;
226
+ }
227
+ workerLogger.debug('re-pointing cryptor at new trackId', {
228
+ ...this.logContext,
229
+ newTrackId: trackId,
230
+ });
231
+ this.trackId = trackId;
232
+ }
233
+
234
+ hasActiveTransform() {
235
+ return !!this.currentTransform;
236
+ }
237
+
238
+ /**
239
+ * A track that is subscribed and known to be encrypted, but has no transform to
240
+ * decrypt it, will never produce a decodable frame again. That state used to be
241
+ * completely silent (a black tile and endless PLIs), so report it.
242
+ *
243
+ * Deferred, because on teardown the encoded streams routinely close before the
244
+ * 'removeTransform' message arrives -- checking immediately would cry wolf on
245
+ * every unsubscribe.
246
+ *
247
+ * Only meaningful while decoding: a sender's pipe closing on unpublish is
248
+ * routine and has no 'removeTransform' equivalent to quiet it down.
249
+ */
250
+ private scheduleUndecryptedTrackWatchdog(operation: 'encode' | 'decode') {
251
+ clearTimeout(this.undecryptedTrackTimeout);
252
+ this.undecryptedTrackTimeout = undefined;
253
+ if (operation !== 'decode') {
254
+ return;
255
+ }
256
+ this.undecryptedTrackTimeout = setTimeout(() => {
257
+ if (this.currentTransform || !this.isEnabled()) {
258
+ return;
259
+ }
260
+ workerLogger.warn('encrypted track has no active decrypt transform', this.logContext);
261
+ this.emitThrottledError(
262
+ new CryptorError(
263
+ `no active decrypt transform for encrypted track ${this.trackId}`,
264
+ CryptorErrorReason.InternalError,
265
+ this.participantIdentity,
266
+ ),
267
+ );
268
+ }, this.UNDECRYPTED_TRACK_GRACE_MS);
269
+ }
270
+
271
+ /**
272
+ * Re-establish the transform if it is gone while we still own the encoded
273
+ * streams, e.g. after a pipe died on its own (the swallowed
274
+ * 'Destination stream closed') or when a reused transceiver never got a new
275
+ * pipeline. Without this the frames pile up in a readable nobody reads and the
276
+ * decoder starves, which shows up as a permanently black tile.
277
+ */
278
+ ensureTransform() {
279
+ if (this.currentTransform) {
280
+ return true;
281
+ }
282
+ if (!this.retainedStreams || this.trackId === undefined) {
283
+ workerLogger.warn('no streams retained, cannot re-establish transform', this.logContext);
284
+ return false;
285
+ }
286
+ const { readable, writable, operation } = this.retainedStreams;
287
+ workerLogger.info('re-establishing transform', { ...this.logContext, operation });
288
+ return this.setupTransform(operation, readable, writable, this.trackId);
289
+ }
290
+
195
291
  /**
196
292
  * Update the video codec used by the mediaStreamTrack
197
293
  * @param codec
@@ -227,7 +323,6 @@ export class FrameCryptor extends BaseFrameCryptor {
227
323
  readable: ReadableStream<RTCEncodedVideoFrame | RTCEncodedAudioFrame>,
228
324
  writable: WritableStream<RTCEncodedVideoFrame | RTCEncodedAudioFrame>,
229
325
  trackId: string,
230
- isReuse: boolean,
231
326
  codec?: VideoCodec,
232
327
  frameMetadata?: FrameMetadataPublishOptions,
233
328
  ) {
@@ -243,27 +338,17 @@ export class FrameCryptor extends BaseFrameCryptor {
243
338
  operation,
244
339
  passedTrackId: trackId,
245
340
  codec,
246
- isReuse,
247
341
  hasCurrentTransform: !!this.currentTransform,
248
342
  ...this.logContext,
249
343
  });
250
344
 
251
- // Always update trackId, even on reuse
252
345
  this.trackId = trackId;
253
346
 
254
- // If we're reusing and have an active transform skip setup
255
- if (
256
- isReuse &&
257
- this.currentTransform &&
258
- readable === this.currentTransform.readable &&
259
- writable === this.currentTransform.writable
260
- ) {
261
- workerLogger.debug('reusing existing transform', {
262
- ...this.logContext,
263
- trackId,
264
- });
265
- return;
266
- }
347
+ // Retain the streams so we can rebuild the pipe later even if this cryptor
348
+ // gets detached from its participant in between (see `ensureTransform`).
349
+ this.retainedStreams = { readable, writable, operation };
350
+
351
+ clearTimeout(this.undecryptedTrackTimeout);
267
352
 
268
353
  const symbol = Symbol('transform');
269
354
 
@@ -281,33 +366,54 @@ export class FrameCryptor extends BaseFrameCryptor {
281
366
  symbol,
282
367
  };
283
368
 
284
- readable
285
- .pipeThrough(transformStream)
286
- .pipeTo(writable)
287
- .catch((e) => {
288
- if (e instanceof TypeError && e.message === 'Destination stream closed') {
289
- // this can happen when subscriptions happen in quick successions, but doesn't influence functionality
290
- workerLogger.debug('destination stream closed');
291
- } else {
292
- workerLogger.warn('transform error', { error: e, ...this.logContext });
293
- this.emit(
294
- CryptorEvent.Error,
295
- e instanceof CryptorError
296
- ? e
297
- : new CryptorError(e.message, undefined, this.participantIdentity),
298
- );
299
- }
300
- })
301
- .finally(() => {
302
- // Only clear currentTransform if it's still the same one we started
303
- if (this.currentTransform?.symbol === symbol) {
304
- workerLogger.debug('transform completed', {
305
- ...this.logContext,
306
- trackId,
307
- });
308
- this.currentTransform = undefined;
309
- }
310
- });
369
+ // pipeThrough/pipeTo throw synchronously on an already locked stream, which
370
+ // would bypass the .catch below and leave us with no transform at all.
371
+ try {
372
+ readable
373
+ .pipeThrough(transformStream)
374
+ .pipeTo(writable)
375
+ .catch((e) => {
376
+ if (e instanceof TypeError && e.message === 'Destination stream closed') {
377
+ // this can happen when subscriptions happen in quick successions, but doesn't influence functionality
378
+ workerLogger.debug('destination stream closed');
379
+ } else {
380
+ workerLogger.warn('transform error', { error: e, ...this.logContext });
381
+ this.emit(
382
+ CryptorEvent.Error,
383
+ e instanceof CryptorError
384
+ ? e
385
+ : new CryptorError(e.message, undefined, this.participantIdentity),
386
+ );
387
+ }
388
+ })
389
+ .finally(() => {
390
+ // Only clear currentTransform if it's still the same one we started
391
+ if (this.currentTransform?.symbol === symbol) {
392
+ workerLogger.debug('transform completed', {
393
+ ...this.logContext,
394
+ trackId,
395
+ });
396
+ this.currentTransform = undefined;
397
+ }
398
+ // A pipe ending while the track is still assigned to a participant that
399
+ // publishes encrypted media means we have silently stopped decrypting.
400
+ this.scheduleUndecryptedTrackWatchdog(operation);
401
+ });
402
+ } catch (e: any) {
403
+ if (this.currentTransform?.symbol === symbol) {
404
+ this.currentTransform = undefined;
405
+ }
406
+ workerLogger.error('failed to set up transform', { error: e, ...this.logContext });
407
+ this.emit(
408
+ CryptorEvent.Error,
409
+ e instanceof CryptorError
410
+ ? e
411
+ : new CryptorError(e.message, CryptorErrorReason.InternalError, this.participantIdentity),
412
+ );
413
+ return false;
414
+ }
415
+
416
+ return true;
311
417
  }
312
418
 
313
419
  setSifTrailer(trailer: NonSharedUint8Array) {
@@ -539,11 +645,38 @@ export class FrameCryptor extends BaseFrameCryptor {
539
645
  }
540
646
  }
541
647
 
542
- if (
543
- !this.isEnabled() ||
544
- // skip for decryption for empty dtx frames
545
- encodedFrame.data.byteLength === 0
546
- ) {
648
+ // skip decryption for empty dtx frames
649
+ if (encodedFrame.data.byteLength === 0) {
650
+ return controller.enqueue(encodedFrame);
651
+ }
652
+
653
+ const encryptionEnabled = this.isEnabled();
654
+
655
+ if (encryptionEnabled === undefined) {
656
+ // Either way we drop: forwarding would hand ciphertext straight to the
657
+ // decoder, which renders as a black frame and logs nothing.
658
+ if (this.participantIdentity === undefined) {
659
+ // The pipe outlived its subscription. Expected on unsubscribe/disconnect:
660
+ // we deliberately leave the pipe running so a reused transceiver can be
661
+ // re-pointed at a new track, so in-flight frames still arrive here for a
662
+ // moment. Not worth reporting -- the watchdog covers a track that stays
663
+ // subscribed without a transform.
664
+ workerLogger.debug('dropping frame for unassigned cryptor', this.logContext);
665
+ return;
666
+ }
667
+ // A live subscription whose encryption state we were never told about.
668
+ this.emitThrottledError(
669
+ new CryptorError(
670
+ `encryption state unknown for track ${this.trackId}, dropping frame`,
671
+ CryptorErrorReason.InternalError,
672
+ this.participantIdentity,
673
+ ),
674
+ );
675
+ return;
676
+ }
677
+
678
+ if (!encryptionEnabled) {
679
+ // participant is known to publish unencrypted media
547
680
  return controller.enqueue(encodedFrame);
548
681
  }
549
682
 
@@ -71,7 +71,6 @@ onmessage = (ev) => {
71
71
  data.readableStream,
72
72
  data.writableStream,
73
73
  data.trackId,
74
- data.isReuse,
75
74
  data.codec,
76
75
  );
77
76
  break;
@@ -83,7 +82,6 @@ onmessage = (ev) => {
83
82
  data.readableStream,
84
83
  data.writableStream,
85
84
  data.trackId,
86
- data.isReuse,
87
85
  data.codec,
88
86
  data.packetTrailer, // wire format still uses 'packetTrailer' field name
89
87
  );
@@ -163,15 +161,32 @@ onmessage = (ev) => {
163
161
  unsetCryptorParticipant(data.trackId, data.participantIdentity);
164
162
  break;
165
163
  case 'updateCodec':
166
- const trackCryptor = getTrackCryptor(data.participantIdentity, data.trackId);
167
- trackCryptor.setVideoCodec(data.codec);
164
+ const trackCryptor = getTrackCryptor(
165
+ data.participantIdentity,
166
+ data.trackId,
167
+ data.previousTrackId,
168
+ );
169
+ if (data.codec) {
170
+ trackCryptor.setVideoCodec(data.codec);
171
+ }
168
172
  trackCryptor.setHasFrameMetadata(data.hasPacketTrailer);
169
173
  workerLogger.info('updated codec', {
170
174
  participantIdentity: data.participantIdentity,
171
175
  trackId: data.trackId,
176
+ previousTrackId: data.previousTrackId,
172
177
  codec: data.codec,
173
178
  hasPacketTrailer: data.hasPacketTrailer,
174
179
  });
180
+ // Sent on transceiver reuse, where the main thread cannot hand us a new
181
+ // pipeline. If the previous one died in the meantime we have to rebuild it
182
+ // here or no frame ever gets decrypted again.
183
+ if (data.previousTrackId !== undefined && !trackCryptor.ensureTransform()) {
184
+ workerLogger.error('could not re-establish transform for reused receiver', {
185
+ participantIdentity: data.participantIdentity,
186
+ trackId: data.trackId,
187
+ previousTrackId: data.previousTrackId,
188
+ });
189
+ }
175
190
  break;
176
191
  case 'setRTPMap':
177
192
  // this is only used for the local participant
@@ -210,8 +225,26 @@ async function handleRatchetRequest(data: RatchetRequestMessage['data']) {
210
225
  }
211
226
  }
212
227
 
213
- function getTrackCryptor(participantIdentity: string, trackId: string) {
228
+ function getTrackCryptor(participantIdentity: string, trackId: string, previousTrackId?: string) {
214
229
  let cryptors = participantCryptors.filter((c) => c.getTrackId() === trackId);
230
+
231
+ if (cryptors.length === 0 && previousTrackId !== undefined && previousTrackId !== trackId) {
232
+ // A reused transceiver carries a new trackId, but the cryptor that owns this
233
+ // receiver's encoded streams is still keyed on the old one. Re-point it instead
234
+ // of creating a fresh cryptor that has no pipeline (and leaving the old one
235
+ // piping frames with no participant assigned).
236
+ const previous = participantCryptors.filter((c) => c.getTrackId() === previousTrackId);
237
+ if (previous.length > 0) {
238
+ workerLogger.info('reusing cryptor from previous trackId', {
239
+ participantIdentity,
240
+ trackId,
241
+ previousTrackId,
242
+ });
243
+ previous[0].setTrackId(trackId);
244
+ cryptors = previous;
245
+ }
246
+ }
247
+
215
248
  if (cryptors.length > 1) {
216
249
  const debugInfo = cryptors
217
250
  .map((c) => {
@@ -351,7 +384,6 @@ if (self.RTCTransformEvent) {
351
384
  transformer.readable,
352
385
  transformer.writable,
353
386
  trackId,
354
- false,
355
387
  codec,
356
388
  kind === 'encode' ? options.packetTrailer : undefined,
357
389
  );
@@ -279,3 +279,38 @@ describe('PCTransportManager.negotiate', () => {
279
279
  await expect(p).resolves.toBeUndefined();
280
280
  });
281
281
  });
282
+
283
+ describe('PCTransportManager.triggerIceRestart', () => {
284
+ let originalRTCPeerConnection: unknown;
285
+
286
+ beforeEach(() => {
287
+ originalRTCPeerConnection = (globalThis as unknown as { RTCPeerConnection?: unknown })
288
+ .RTCPeerConnection;
289
+ (globalThis as unknown as { RTCPeerConnection: unknown }).RTCPeerConnection = StubPC;
290
+ });
291
+
292
+ afterEach(() => {
293
+ (globalThis as unknown as { RTCPeerConnection: unknown }).RTCPeerConnection =
294
+ originalRTCPeerConnection;
295
+ });
296
+
297
+ /**
298
+ * The subscriber must keep applying remote candidates across a reconnect.
299
+ *
300
+ * Putting it into `restartingIce` would queue them until a new remote description arrives —
301
+ * but the server only re-offers the subscriber when the reconnect moved us to another node,
302
+ * so on an ordinary signal-only resume nothing would ever flush that queue, and the
303
+ * transport would stop adopting new network paths for the rest of the session. The server
304
+ * also never sends candidates ahead of the offer that introduces them, so queueing buys
305
+ * nothing in exchange.
306
+ */
307
+ it('does not stop the subscriber applying remote candidates', async () => {
308
+ const manager = new PCTransportManager('subscriber-primary', {});
309
+ const publisher = new FakePublisher();
310
+ (manager as unknown as { publisher: FakePublisher }).publisher = publisher;
311
+
312
+ await manager.triggerIceRestart();
313
+
314
+ expect(manager.subscriber?.restartingIce).toBe(false);
315
+ });
316
+ });
@@ -162,12 +162,20 @@ export class PCTransportManager {
162
162
  this.updateState();
163
163
  }
164
164
 
165
+ /**
166
+ * Restarts ICE on the transports that need it. Only the publisher: the server restarts the
167
+ * subscriber's ICE itself and follows with a fresh offer.
168
+ *
169
+ * The subscriber deliberately does NOT enter `restartingIce` here. Queueing its remote
170
+ * candidates would guard against candidates for a new generation arriving before the offer
171
+ * that introduces it, but the server does not send them in that order -- on a same-node
172
+ * resume it buffers them until the offer has gone out, and on a reconnect that lands on
173
+ * another node it withholds subscriber candidates until immediately before creating the
174
+ * offer. Setting the flag only risks withholding candidates during the window that decides
175
+ * whether the reconnect succeeded.
176
+ */
165
177
  async triggerIceRestart() {
166
178
  this.iceLog.warn('triggering ICE restart');
167
- if (this.subscriber) {
168
- this.subscriber.restartingIce = true;
169
- }
170
- // only restart publisher if it's needed
171
179
  if (this.needsPublisher) {
172
180
  await this.createAndSendPublisherOffer({ iceRestart: true });
173
181
  }