livekit-client 2.22.1 → 2.22.3

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 (88) 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 +663 -476
  4. package/dist/livekit-client.e2ee.worker.mjs.map +1 -1
  5. package/dist/livekit-client.esm.mjs +480 -157
  6. package/dist/livekit-client.esm.mjs.map +1 -1
  7. package/dist/livekit-client.fm.worker.js +1 -1
  8. package/dist/livekit-client.fm.worker.js.map +1 -1
  9. package/dist/livekit-client.fm.worker.mjs +8 -1
  10. package/dist/livekit-client.fm.worker.mjs.map +1 -1
  11. package/dist/livekit-client.umd.js +1 -1
  12. package/dist/livekit-client.umd.js.map +1 -1
  13. package/dist/src/api/SignalClient.d.ts.map +1 -1
  14. package/dist/src/api/WebSocketStream.d.ts.map +1 -1
  15. package/dist/src/api/utils.d.ts +1 -0
  16. package/dist/src/api/utils.d.ts.map +1 -1
  17. package/dist/src/e2ee/E2eeManager.d.ts +33 -0
  18. package/dist/src/e2ee/E2eeManager.d.ts.map +1 -1
  19. package/dist/src/e2ee/constants.d.ts +5 -0
  20. package/dist/src/e2ee/constants.d.ts.map +1 -1
  21. package/dist/src/e2ee/types.d.ts +24 -3
  22. package/dist/src/e2ee/types.d.ts.map +1 -1
  23. package/dist/src/e2ee/worker/DataCryptor.d.ts.map +1 -1
  24. package/dist/src/e2ee/worker/ErrorRateLimiter.d.ts +21 -0
  25. package/dist/src/e2ee/worker/ErrorRateLimiter.d.ts.map +1 -0
  26. package/dist/src/e2ee/worker/FrameCryptor.d.ts +43 -19
  27. package/dist/src/e2ee/worker/FrameCryptor.d.ts.map +1 -1
  28. package/dist/src/logger.d.ts +4 -0
  29. package/dist/src/logger.d.ts.map +1 -1
  30. package/dist/src/room/PCTransportManager.d.ts +12 -0
  31. package/dist/src/room/PCTransportManager.d.ts.map +1 -1
  32. package/dist/src/room/RTCEngine.d.ts +1 -0
  33. package/dist/src/room/RTCEngine.d.ts.map +1 -1
  34. package/dist/src/room/data-stream/incoming/StreamReader.d.ts +17 -17
  35. package/dist/src/room/data-stream/incoming/StreamReader.d.ts.map +1 -1
  36. package/dist/src/room/participant/LocalParticipant.d.ts.map +1 -1
  37. package/dist/src/room/participant/publishUtils.d.ts +16 -0
  38. package/dist/src/room/participant/publishUtils.d.ts.map +1 -1
  39. package/dist/src/room/track/LocalVideoTrack.d.ts +7 -0
  40. package/dist/src/room/track/LocalVideoTrack.d.ts.map +1 -1
  41. package/dist/src/room/track/options.d.ts +1 -1
  42. package/dist/src/room/utils.d.ts +34 -0
  43. package/dist/src/room/utils.d.ts.map +1 -1
  44. package/dist/ts4.2/api/utils.d.ts +1 -0
  45. package/dist/ts4.2/e2ee/E2eeManager.d.ts +33 -0
  46. package/dist/ts4.2/e2ee/constants.d.ts +5 -0
  47. package/dist/ts4.2/e2ee/types.d.ts +24 -3
  48. package/dist/ts4.2/e2ee/worker/ErrorRateLimiter.d.ts +21 -0
  49. package/dist/ts4.2/e2ee/worker/FrameCryptor.d.ts +43 -19
  50. package/dist/ts4.2/logger.d.ts +4 -0
  51. package/dist/ts4.2/room/PCTransportManager.d.ts +12 -0
  52. package/dist/ts4.2/room/RTCEngine.d.ts +1 -0
  53. package/dist/ts4.2/room/data-stream/incoming/StreamReader.d.ts +17 -17
  54. package/dist/ts4.2/room/participant/publishUtils.d.ts +16 -0
  55. package/dist/ts4.2/room/track/LocalVideoTrack.d.ts +7 -0
  56. package/dist/ts4.2/room/track/options.d.ts +1 -1
  57. package/dist/ts4.2/room/utils.d.ts +34 -0
  58. package/package.json +1 -1
  59. package/src/api/SignalClient.ts +2 -1
  60. package/src/api/WebSocketStream.ts +3 -8
  61. package/src/api/utils.ts +10 -0
  62. package/src/e2ee/E2eeManager.test.ts +196 -0
  63. package/src/e2ee/E2eeManager.ts +150 -31
  64. package/src/e2ee/constants.ts +6 -0
  65. package/src/e2ee/subscriberBlackScreen.test.ts +544 -0
  66. package/src/e2ee/types.ts +28 -3
  67. package/src/e2ee/worker/DataCryptor.ts +2 -1
  68. package/src/e2ee/worker/ErrorRateLimiter.test.ts +53 -0
  69. package/src/e2ee/worker/ErrorRateLimiter.ts +52 -0
  70. package/src/e2ee/worker/FrameCryptor.race.test.ts +9 -26
  71. package/src/e2ee/worker/FrameCryptor.test.ts +0 -1
  72. package/src/e2ee/worker/FrameCryptor.ts +202 -119
  73. package/src/e2ee/worker/e2ee.worker.ts +72 -17
  74. package/src/logger.ts +22 -0
  75. package/src/room/PCTransportManager.test.ts +35 -0
  76. package/src/room/PCTransportManager.ts +12 -4
  77. package/src/room/RTCEngine.ts +28 -7
  78. package/src/room/Room.ts +1 -1
  79. package/src/room/data-stream/incoming/IncomingDataStreamManager.test.ts +171 -0
  80. package/src/room/data-stream/incoming/IncomingDataStreamManager.ts +17 -18
  81. package/src/room/data-stream/incoming/StreamReader.ts +20 -50
  82. package/src/room/participant/LocalParticipant.ts +30 -14
  83. package/src/room/participant/publishUtils.test.ts +133 -0
  84. package/src/room/participant/publishUtils.ts +54 -19
  85. package/src/room/track/LocalVideoTrack.ts +15 -5
  86. package/src/room/track/options.ts +1 -1
  87. package/src/room/utils.test.ts +87 -0
  88. package/src/room/utils.ts +59 -0
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Per-key rate limiter for repeated errors. Prevents log/emit floods and the
3
+ * unbounded map growth that a per-event log would cause when a broken key
4
+ * keeps producing failures.
5
+ */
6
+ export class ErrorRateLimiter {
7
+ private lastAt: Map<string, number> = new Map();
8
+
9
+ private counts: Map<string, number> = new Map();
10
+
11
+ constructor(
12
+ private readonly throttleMs: number = 1000,
13
+ private readonly windowMs: number = 60_000,
14
+ private readonly maxPerWindow: number = 5,
15
+ ) {}
16
+
17
+ reset() {
18
+ this.lastAt.clear();
19
+ this.counts.clear();
20
+ }
21
+
22
+ countFor(key: string): number {
23
+ return this.counts.get(key) ?? 0;
24
+ }
25
+
26
+ /**
27
+ * Returns true if the caller should emit for this key. Invokes `onSuppress`
28
+ * exactly once per window when the per-window limit is first crossed.
29
+ */
30
+ shouldEmit(key: string, onSuppress?: () => void): boolean {
31
+ const now = Date.now();
32
+ const last = this.lastAt.get(key) ?? 0;
33
+ const count = this.counts.get(key) ?? 0;
34
+
35
+ if (now - last > this.windowMs) {
36
+ this.counts.set(key, 0);
37
+ this.lastAt.set(key, now);
38
+ return true;
39
+ }
40
+ if (now - last < this.throttleMs) return false;
41
+ if (count >= this.maxPerWindow) {
42
+ if (count === this.maxPerWindow) {
43
+ onSuppress?.();
44
+ this.counts.set(key, count + 1);
45
+ }
46
+ return false;
47
+ }
48
+ this.lastAt.set(key, now);
49
+ this.counts.set(key, count + 1);
50
+ return true;
51
+ }
52
+ }
@@ -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
  );
@@ -1,6 +1,7 @@
1
1
  // TODO code inspired by https://github.com/webrtc/samples/blob/gh-pages/src/content/insertable-streams/endtoend-encryption/js/worker.js
2
2
  import { EventEmitter } from 'events';
3
3
  import type TypedEventEmitter from 'typed-emitter';
4
+ import { getErrorDescription } from '../../api/utils';
4
5
  import {
5
6
  appendPacketTrailerToEncodedFrame,
6
7
  processPacketTrailer,
@@ -22,6 +23,7 @@ import type {
22
23
  RatchetResult,
23
24
  } from '../types';
24
25
  import { deriveKeys, isVideoFrame, needsRbspUnescaping, parseRbsp, writeRbsp } from '../utils';
26
+ import { ErrorRateLimiter } from './ErrorRateLimiter';
25
27
  import type { ParticipantKeyHandler } from './ParticipantKeyHandler';
26
28
  import { processNALUsForEncryption } from './naluUtils';
27
29
  import { identifySifPayload } from './sifPayload';
@@ -84,6 +86,22 @@ export class FrameCryptor extends BaseFrameCryptor {
84
86
 
85
87
  private currentTransform?: TransformerInfo;
86
88
 
89
+ /**
90
+ * The encoded streams this cryptor was last set up with, retained beyond the
91
+ * lifetime of {@link currentTransform}.
92
+ *
93
+ * The main thread transfers a receiver's encoded streams to the worker once and
94
+ * cannot transfer them again (a transferred stream is locked), and
95
+ * `createEncodedStreams()` may only be called once per receiver. So when a
96
+ * transceiver is reused, or when a pipe dies underneath us, re-establishing the
97
+ * transform is only possible from here. See {@link ensureTransform}.
98
+ */
99
+ private retainedStreams?: {
100
+ readable: ReadableStream<RTCEncodedVideoFrame | RTCEncodedAudioFrame>;
101
+ writable: WritableStream<RTCEncodedVideoFrame | RTCEncodedAudioFrame>;
102
+ operation: 'encode' | 'decode';
103
+ };
104
+
87
105
  /**
88
106
  * Whether the subscribed track advertises packet trailer features.
89
107
  * When false, we skip the per-frame trailer extraction path entirely
@@ -95,18 +113,12 @@ export class FrameCryptor extends BaseFrameCryptor {
95
113
 
96
114
  private frameMetadataFrameId = 0;
97
115
 
98
- /**
99
- * Throttling mechanism for decryption errors to prevent memory leaks
100
- */
101
- private lastErrorTimestamp: Map<string, number> = new Map();
102
-
103
- private errorCounts: Map<string, number> = new Map();
116
+ private errorLimiter = new ErrorRateLimiter();
104
117
 
105
- private readonly ERROR_THROTTLE_MS = 1000; // Emit error at most once per second
118
+ private undecryptedTrackTimeout?: ReturnType<typeof setTimeout>;
106
119
 
107
- private readonly MAX_ERRORS_PER_MINUTE = 5; // Maximum errors to emit per minute per key
108
-
109
- private readonly ERROR_WINDOW_MS = 60000; // 1 minute window
120
+ /** grace period for a teardown or a resubscribe to land before we report a stalled track */
121
+ private readonly UNDECRYPTED_TRACK_GRACE_MS = 2000;
110
122
 
111
123
  /**
112
124
  * Tracks (participant, trackId, payloadType) tuples for which we've already logged a NALU
@@ -167,13 +179,15 @@ export class FrameCryptor extends BaseFrameCryptor {
167
179
  unsetParticipant() {
168
180
  workerLogger.debug('unsetting participant', this.logContext);
169
181
 
170
- if (this.currentTransform) {
171
- this.currentTransform = undefined;
172
- }
173
-
182
+ // NOTE: deliberately does not clear `currentTransform`. Nothing here cancels
183
+ // the pipe, so the transform really is still running and clearing it would
184
+ // desync our bookkeeping from reality -- which previously let a later
185
+ // resubscribe skip setup while frames kept flowing through a cryptor with no
186
+ // participant assigned.
187
+ clearTimeout(this.undecryptedTrackTimeout);
188
+ this.undecryptedTrackTimeout = undefined;
174
189
  this.participantIdentity = undefined;
175
- this.lastErrorTimestamp = new Map();
176
- this.errorCounts = new Map();
190
+ this.errorLimiter.reset();
177
191
  }
178
192
 
179
193
  isEnabled() {
@@ -192,6 +206,78 @@ export class FrameCryptor extends BaseFrameCryptor {
192
206
  return this.trackId;
193
207
  }
194
208
 
209
+ /**
210
+ * Re-point this cryptor at a new trackId, keeping its (already transferred)
211
+ * encoded streams. Used when a transceiver is reused for a new track.
212
+ */
213
+ setTrackId(trackId: string) {
214
+ if (this.trackId === trackId) {
215
+ return;
216
+ }
217
+ workerLogger.debug('re-pointing cryptor at new trackId', {
218
+ ...this.logContext,
219
+ newTrackId: trackId,
220
+ });
221
+ this.trackId = trackId;
222
+ }
223
+
224
+ hasActiveTransform() {
225
+ return !!this.currentTransform;
226
+ }
227
+
228
+ /**
229
+ * A track that is subscribed and known to be encrypted, but has no transform to
230
+ * decrypt it, will never produce a decodable frame again. That state used to be
231
+ * completely silent (a black tile and endless PLIs), so report it.
232
+ *
233
+ * Deferred, because on teardown the encoded streams routinely close before the
234
+ * 'removeTransform' message arrives -- checking immediately would cry wolf on
235
+ * every unsubscribe.
236
+ *
237
+ * Only meaningful while decoding: a sender's pipe closing on unpublish is
238
+ * routine and has no 'removeTransform' equivalent to quiet it down.
239
+ */
240
+ private scheduleUndecryptedTrackWatchdog(operation: 'encode' | 'decode') {
241
+ clearTimeout(this.undecryptedTrackTimeout);
242
+ this.undecryptedTrackTimeout = undefined;
243
+ if (operation !== 'decode') {
244
+ return;
245
+ }
246
+ this.undecryptedTrackTimeout = setTimeout(() => {
247
+ if (this.currentTransform || !this.isEnabled()) {
248
+ return;
249
+ }
250
+ workerLogger.warn('encrypted track has no active decrypt transform', this.logContext);
251
+ this.emitThrottledError(
252
+ new CryptorError(
253
+ `no active decrypt transform for encrypted track ${this.trackId}`,
254
+ CryptorErrorReason.InternalError,
255
+ this.participantIdentity,
256
+ ),
257
+ );
258
+ }, this.UNDECRYPTED_TRACK_GRACE_MS);
259
+ }
260
+
261
+ /**
262
+ * Re-establish the transform if it is gone while we still own the encoded
263
+ * streams, e.g. after a pipe died on its own (the swallowed
264
+ * 'Destination stream closed') or when a reused transceiver never got a new
265
+ * pipeline. Without this the frames pile up in a readable nobody reads and the
266
+ * decoder starves, which shows up as a permanently black tile.
267
+ */
268
+ ensureTransform() {
269
+ if (this.currentTransform) {
270
+ return true;
271
+ }
272
+ if (!this.retainedStreams || this.trackId === undefined) {
273
+ workerLogger.warn('no streams retained, cannot re-establish transform', this.logContext);
274
+ return false;
275
+ }
276
+ const { readable, writable, operation } = this.retainedStreams;
277
+ workerLogger.info('re-establishing transform', { ...this.logContext, operation });
278
+ return this.setupTransform(operation, readable, writable, this.trackId);
279
+ }
280
+
195
281
  /**
196
282
  * Update the video codec used by the mediaStreamTrack
197
283
  * @param codec
@@ -227,7 +313,6 @@ export class FrameCryptor extends BaseFrameCryptor {
227
313
  readable: ReadableStream<RTCEncodedVideoFrame | RTCEncodedAudioFrame>,
228
314
  writable: WritableStream<RTCEncodedVideoFrame | RTCEncodedAudioFrame>,
229
315
  trackId: string,
230
- isReuse: boolean,
231
316
  codec?: VideoCodec,
232
317
  frameMetadata?: FrameMetadataPublishOptions,
233
318
  ) {
@@ -243,27 +328,17 @@ export class FrameCryptor extends BaseFrameCryptor {
243
328
  operation,
244
329
  passedTrackId: trackId,
245
330
  codec,
246
- isReuse,
247
331
  hasCurrentTransform: !!this.currentTransform,
248
332
  ...this.logContext,
249
333
  });
250
334
 
251
- // Always update trackId, even on reuse
252
335
  this.trackId = trackId;
253
336
 
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
- }
337
+ // Retain the streams so we can rebuild the pipe later even if this cryptor
338
+ // gets detached from its participant in between (see `ensureTransform`).
339
+ this.retainedStreams = { readable, writable, operation };
340
+
341
+ clearTimeout(this.undecryptedTrackTimeout);
267
342
 
268
343
  const symbol = Symbol('transform');
269
344
 
@@ -281,98 +356,79 @@ export class FrameCryptor extends BaseFrameCryptor {
281
356
  symbol,
282
357
  };
283
358
 
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
- });
311
- }
312
-
313
- setSifTrailer(trailer: NonSharedUint8Array) {
314
- workerLogger.debug('setting SIF trailer', { ...this.logContext, trailer });
315
- this.sifTrailer = trailer;
316
- }
317
-
318
- /**
319
- * Checks if we should emit an error based on throttling rules to prevent memory leaks
320
- * @param errorKey - unique key identifying the error context
321
- * @returns true if the error should be emitted, false otherwise
322
- */
323
- private shouldEmitError(errorKey: string): boolean {
324
- const now = Date.now();
325
- const lastErrorTime = this.lastErrorTimestamp.get(errorKey) ?? 0;
326
- const errorCount = this.errorCounts.get(errorKey) ?? 0;
327
-
328
- // Reset count if we're in a new time window
329
- if (now - lastErrorTime > this.ERROR_WINDOW_MS) {
330
- this.errorCounts.set(errorKey, 0);
331
- this.lastErrorTimestamp.set(errorKey, now);
332
- return true;
333
- }
334
-
335
- // Check if we've exceeded the throttle time
336
- if (now - lastErrorTime < this.ERROR_THROTTLE_MS) {
337
- return false;
338
- }
339
-
340
- // Check if we've exceeded the max errors per window
341
- if (errorCount >= this.MAX_ERRORS_PER_MINUTE) {
342
- // Only log a warning once when hitting the limit
343
- if (errorCount === this.MAX_ERRORS_PER_MINUTE) {
344
- workerLogger.warn(`Suppressing further decryption errors for ${this.participantIdentity}`, {
345
- ...this.logContext,
346
- errorKey,
359
+ // pipeThrough/pipeTo throw synchronously on an already locked stream, which
360
+ // would bypass the .catch below and leave us with no transform at all.
361
+ try {
362
+ readable
363
+ .pipeThrough(transformStream)
364
+ .pipeTo(writable)
365
+ .catch((e) => {
366
+ if (e instanceof TypeError && e.message === 'Destination stream closed') {
367
+ // this can happen when subscriptions happen in quick successions, but doesn't influence functionality
368
+ workerLogger.debug('destination stream closed');
369
+ } else {
370
+ workerLogger.warn('transform error', { error: e, ...this.logContext });
371
+ this.emit(
372
+ CryptorEvent.Error,
373
+ e instanceof CryptorError
374
+ ? e
375
+ : new CryptorError(e.message, undefined, this.participantIdentity),
376
+ );
377
+ }
378
+ })
379
+ .finally(() => {
380
+ // Only clear currentTransform if it's still the same one we started
381
+ if (this.currentTransform?.symbol === symbol) {
382
+ workerLogger.debug('transform completed', {
383
+ ...this.logContext,
384
+ trackId,
385
+ });
386
+ this.currentTransform = undefined;
387
+ }
388
+ // A pipe ending while the track is still assigned to a participant that
389
+ // publishes encrypted media means we have silently stopped decrypting.
390
+ this.scheduleUndecryptedTrackWatchdog(operation);
347
391
  });
348
- this.errorCounts.set(errorKey, errorCount + 1);
392
+ } catch (e: any) {
393
+ if (this.currentTransform?.symbol === symbol) {
394
+ this.currentTransform = undefined;
349
395
  }
396
+ workerLogger.error('failed to set up transform', { error: e, ...this.logContext });
397
+ this.emit(
398
+ CryptorEvent.Error,
399
+ e instanceof CryptorError
400
+ ? e
401
+ : new CryptorError(e.message, CryptorErrorReason.InternalError, this.participantIdentity),
402
+ );
350
403
  return false;
351
404
  }
352
405
 
353
- // Update tracking
354
- this.lastErrorTimestamp.set(errorKey, now);
355
- this.errorCounts.set(errorKey, errorCount + 1);
356
406
  return true;
357
407
  }
358
408
 
359
- /**
360
- * Emits a throttled error to prevent memory leaks from repeated decryption failures
361
- * @param error - the CryptorError to emit
362
- */
409
+ setSifTrailer(trailer: NonSharedUint8Array) {
410
+ workerLogger.debug('setting SIF trailer', { ...this.logContext, trailer });
411
+ this.sifTrailer = trailer;
412
+ }
413
+
363
414
  private emitThrottledError(error: CryptorError) {
364
415
  const errorKey = `${this.participantIdentity}-${error.reason}-decrypt`;
416
+ const emit = this.errorLimiter.shouldEmit(errorKey, () => {
417
+ workerLogger.warn(`Suppressing further decryption errors for ${this.participantIdentity}`, {
418
+ ...this.logContext,
419
+ errorKey,
420
+ });
421
+ });
422
+ if (!emit) return;
365
423
 
366
- if (this.shouldEmitError(errorKey)) {
367
- const errorCount = this.errorCounts.get(errorKey) ?? 0;
368
- if (errorCount > 1) {
369
- workerLogger.debug(`Decryption error (${errorCount} occurrences in window)`, {
370
- ...this.logContext,
371
- reason: CryptorErrorReason[error.reason],
372
- });
373
- }
374
- this.emit(CryptorEvent.Error, error);
424
+ const count = this.errorLimiter.countFor(errorKey);
425
+ if (count > 1) {
426
+ workerLogger.debug(`Decryption error (${count} occurrences in window)`, {
427
+ ...this.logContext,
428
+ reason: CryptorErrorReason[error.reason],
429
+ });
375
430
  }
431
+ this.emit(CryptorEvent.Error, error);
376
432
  }
377
433
 
378
434
  /**
@@ -481,7 +537,7 @@ export class FrameCryptor extends BaseFrameCryptor {
481
537
  return controller.enqueue(encodedFrame);
482
538
  } catch (e: any) {
483
539
  // TODO: surface this to the app.
484
- workerLogger.error(e);
540
+ workerLogger.error(`error while encrypting`, { ...this.logContext, error: e });
485
541
  }
486
542
  } else {
487
543
  workerLogger.debug('failed to encrypt, emitting error', this.logContext);
@@ -539,11 +595,38 @@ export class FrameCryptor extends BaseFrameCryptor {
539
595
  }
540
596
  }
541
597
 
542
- if (
543
- !this.isEnabled() ||
544
- // skip for decryption for empty dtx frames
545
- encodedFrame.data.byteLength === 0
546
- ) {
598
+ // skip decryption for empty dtx frames
599
+ if (encodedFrame.data.byteLength === 0) {
600
+ return controller.enqueue(encodedFrame);
601
+ }
602
+
603
+ const encryptionEnabled = this.isEnabled();
604
+
605
+ if (encryptionEnabled === undefined) {
606
+ // Either way we drop: forwarding would hand ciphertext straight to the
607
+ // decoder, which renders as a black frame and logs nothing.
608
+ if (this.participantIdentity === undefined) {
609
+ // The pipe outlived its subscription. Expected on unsubscribe/disconnect:
610
+ // we deliberately leave the pipe running so a reused transceiver can be
611
+ // re-pointed at a new track, so in-flight frames still arrive here for a
612
+ // moment. Not worth reporting -- the watchdog covers a track that stays
613
+ // subscribed without a transform.
614
+ workerLogger.debug('dropping frame for unassigned cryptor', this.logContext);
615
+ return;
616
+ }
617
+ // A live subscription whose encryption state we were never told about.
618
+ this.emitThrottledError(
619
+ new CryptorError(
620
+ `encryption state unknown for track ${this.trackId}, dropping frame`,
621
+ CryptorErrorReason.InternalError,
622
+ this.participantIdentity,
623
+ ),
624
+ );
625
+ return;
626
+ }
627
+
628
+ if (!encryptionEnabled) {
629
+ // participant is known to publish unencrypted media
547
630
  return controller.enqueue(encodedFrame);
548
631
  }
549
632
 
@@ -725,7 +808,7 @@ export class FrameCryptor extends BaseFrameCryptor {
725
808
  }
726
809
  } else {
727
810
  throw new CryptorError(
728
- `Decryption failed: ${error.message}`,
811
+ `Decryption failed: ${getErrorDescription(error, 'decryption')}`,
729
812
  CryptorErrorReason.InvalidKey,
730
813
  this.participantIdentity,
731
814
  );