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.
- package/dist/livekit-client.e2ee.worker.js +1 -1
- package/dist/livekit-client.e2ee.worker.js.map +1 -1
- package/dist/livekit-client.e2ee.worker.mjs +663 -476
- package/dist/livekit-client.e2ee.worker.mjs.map +1 -1
- package/dist/livekit-client.esm.mjs +480 -157
- package/dist/livekit-client.esm.mjs.map +1 -1
- package/dist/livekit-client.fm.worker.js +1 -1
- package/dist/livekit-client.fm.worker.js.map +1 -1
- package/dist/livekit-client.fm.worker.mjs +8 -1
- package/dist/livekit-client.fm.worker.mjs.map +1 -1
- package/dist/livekit-client.umd.js +1 -1
- package/dist/livekit-client.umd.js.map +1 -1
- package/dist/src/api/SignalClient.d.ts.map +1 -1
- package/dist/src/api/WebSocketStream.d.ts.map +1 -1
- package/dist/src/api/utils.d.ts +1 -0
- package/dist/src/api/utils.d.ts.map +1 -1
- package/dist/src/e2ee/E2eeManager.d.ts +33 -0
- package/dist/src/e2ee/E2eeManager.d.ts.map +1 -1
- package/dist/src/e2ee/constants.d.ts +5 -0
- package/dist/src/e2ee/constants.d.ts.map +1 -1
- package/dist/src/e2ee/types.d.ts +24 -3
- package/dist/src/e2ee/types.d.ts.map +1 -1
- package/dist/src/e2ee/worker/DataCryptor.d.ts.map +1 -1
- package/dist/src/e2ee/worker/ErrorRateLimiter.d.ts +21 -0
- package/dist/src/e2ee/worker/ErrorRateLimiter.d.ts.map +1 -0
- package/dist/src/e2ee/worker/FrameCryptor.d.ts +43 -19
- package/dist/src/e2ee/worker/FrameCryptor.d.ts.map +1 -1
- package/dist/src/logger.d.ts +4 -0
- package/dist/src/logger.d.ts.map +1 -1
- package/dist/src/room/PCTransportManager.d.ts +12 -0
- package/dist/src/room/PCTransportManager.d.ts.map +1 -1
- package/dist/src/room/RTCEngine.d.ts +1 -0
- package/dist/src/room/RTCEngine.d.ts.map +1 -1
- package/dist/src/room/data-stream/incoming/StreamReader.d.ts +17 -17
- package/dist/src/room/data-stream/incoming/StreamReader.d.ts.map +1 -1
- package/dist/src/room/participant/LocalParticipant.d.ts.map +1 -1
- package/dist/src/room/participant/publishUtils.d.ts +16 -0
- package/dist/src/room/participant/publishUtils.d.ts.map +1 -1
- package/dist/src/room/track/LocalVideoTrack.d.ts +7 -0
- package/dist/src/room/track/LocalVideoTrack.d.ts.map +1 -1
- package/dist/src/room/track/options.d.ts +1 -1
- package/dist/src/room/utils.d.ts +34 -0
- package/dist/src/room/utils.d.ts.map +1 -1
- package/dist/ts4.2/api/utils.d.ts +1 -0
- package/dist/ts4.2/e2ee/E2eeManager.d.ts +33 -0
- package/dist/ts4.2/e2ee/constants.d.ts +5 -0
- package/dist/ts4.2/e2ee/types.d.ts +24 -3
- package/dist/ts4.2/e2ee/worker/ErrorRateLimiter.d.ts +21 -0
- package/dist/ts4.2/e2ee/worker/FrameCryptor.d.ts +43 -19
- package/dist/ts4.2/logger.d.ts +4 -0
- package/dist/ts4.2/room/PCTransportManager.d.ts +12 -0
- package/dist/ts4.2/room/RTCEngine.d.ts +1 -0
- package/dist/ts4.2/room/data-stream/incoming/StreamReader.d.ts +17 -17
- package/dist/ts4.2/room/participant/publishUtils.d.ts +16 -0
- package/dist/ts4.2/room/track/LocalVideoTrack.d.ts +7 -0
- package/dist/ts4.2/room/track/options.d.ts +1 -1
- package/dist/ts4.2/room/utils.d.ts +34 -0
- package/package.json +1 -1
- package/src/api/SignalClient.ts +2 -1
- package/src/api/WebSocketStream.ts +3 -8
- package/src/api/utils.ts +10 -0
- package/src/e2ee/E2eeManager.test.ts +196 -0
- package/src/e2ee/E2eeManager.ts +150 -31
- package/src/e2ee/constants.ts +6 -0
- package/src/e2ee/subscriberBlackScreen.test.ts +544 -0
- package/src/e2ee/types.ts +28 -3
- package/src/e2ee/worker/DataCryptor.ts +2 -1
- package/src/e2ee/worker/ErrorRateLimiter.test.ts +53 -0
- package/src/e2ee/worker/ErrorRateLimiter.ts +52 -0
- package/src/e2ee/worker/FrameCryptor.race.test.ts +9 -26
- package/src/e2ee/worker/FrameCryptor.test.ts +0 -1
- package/src/e2ee/worker/FrameCryptor.ts +202 -119
- package/src/e2ee/worker/e2ee.worker.ts +72 -17
- package/src/logger.ts +22 -0
- package/src/room/PCTransportManager.test.ts +35 -0
- package/src/room/PCTransportManager.ts +12 -4
- package/src/room/RTCEngine.ts +28 -7
- package/src/room/Room.ts +1 -1
- package/src/room/data-stream/incoming/IncomingDataStreamManager.test.ts +171 -0
- package/src/room/data-stream/incoming/IncomingDataStreamManager.ts +17 -18
- package/src/room/data-stream/incoming/StreamReader.ts +20 -50
- package/src/room/participant/LocalParticipant.ts +30 -14
- package/src/room/participant/publishUtils.test.ts +133 -0
- package/src/room/participant/publishUtils.ts +54 -19
- package/src/room/track/LocalVideoTrack.ts +15 -5
- package/src/room/track/options.ts +1 -1
- package/src/room/utils.test.ts +87 -0
- 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
|
|
72
|
-
it('should update trackId
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
524
|
+
// Track ID should be updated
|
|
542
525
|
expect(cryptor.getTrackId()).toBe('track2');
|
|
543
526
|
expect(cryptor.getParticipantIdentity()).toBe('participant2');
|
|
544
527
|
});
|
|
@@ -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
|
|
118
|
+
private undecryptedTrackTimeout?: ReturnType<typeof setTimeout>;
|
|
106
119
|
|
|
107
|
-
|
|
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
|
-
|
|
171
|
-
|
|
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.
|
|
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
|
-
//
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
e
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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
|
-
|
|
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
|
-
|
|
361
|
-
|
|
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
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
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
|
|
811
|
+
`Decryption failed: ${getErrorDescription(error, 'decryption')}`,
|
|
729
812
|
CryptorErrorReason.InvalidKey,
|
|
730
813
|
this.participantIdentity,
|
|
731
814
|
);
|