@blockcast/fec-worker 0.1.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 (53) hide show
  1. package/README.md +213 -0
  2. package/dist/.build-stamp +0 -0
  3. package/dist/fec-worker-client.d.ts +80 -0
  4. package/dist/fec-worker-client.d.ts.map +1 -0
  5. package/dist/fec-worker-client.js +270 -0
  6. package/dist/fec-worker-client.js.map +1 -0
  7. package/dist/fec-worker-types.d.ts +252 -0
  8. package/dist/fec-worker-types.d.ts.map +1 -0
  9. package/dist/fec-worker-types.js +11 -0
  10. package/dist/fec-worker-types.js.map +1 -0
  11. package/dist/fec-worker.d.ts +14 -0
  12. package/dist/fec-worker.d.ts.map +1 -0
  13. package/dist/fec-worker.js +565 -0
  14. package/dist/fec-worker.js.map +7 -0
  15. package/dist/index.d.ts +10 -0
  16. package/dist/index.d.ts.map +1 -0
  17. package/dist/index.js +8 -0
  18. package/dist/index.js.map +1 -0
  19. package/dist/shred-fec-worker.d.ts +20 -0
  20. package/dist/shred-fec-worker.d.ts.map +1 -0
  21. package/dist/shred-fec-worker.js +349 -0
  22. package/dist/shred-fec-worker.js.map +7 -0
  23. package/dist/shred-worker-client.d.ts +104 -0
  24. package/dist/shred-worker-client.d.ts.map +1 -0
  25. package/dist/shred-worker-client.js +181 -0
  26. package/dist/shred-worker-client.js.map +1 -0
  27. package/dist/shred-worker-types.d.ts +179 -0
  28. package/dist/shred-worker-types.d.ts.map +1 -0
  29. package/dist/shred-worker-types.js +47 -0
  30. package/dist/shred-worker-types.js.map +1 -0
  31. package/dist/transfer.d.ts +9 -0
  32. package/dist/transfer.d.ts.map +1 -0
  33. package/dist/transfer.js +13 -0
  34. package/dist/transfer.js.map +1 -0
  35. package/package.json +54 -0
  36. package/src/__tests__/fec-worker-alta.test.ts +231 -0
  37. package/src/__tests__/fec-worker-cleanup-integration.test.ts +250 -0
  38. package/src/__tests__/fec-worker-pending-queue.test.ts +315 -0
  39. package/src/__tests__/fec-worker-repair-size.test.ts +1029 -0
  40. package/src/__tests__/fec-worker-wasm-contract.test.ts +84 -0
  41. package/src/__tests__/shred-fec-worker.test.ts +448 -0
  42. package/src/fec-worker-client.test.ts +530 -0
  43. package/src/fec-worker-client.ts +309 -0
  44. package/src/fec-worker-types.test.ts +400 -0
  45. package/src/fec-worker-types.ts +268 -0
  46. package/src/fec-worker.ts +1048 -0
  47. package/src/index.ts +32 -0
  48. package/src/shred-fec-worker.ts +558 -0
  49. package/src/shred-worker-client.test.ts +182 -0
  50. package/src/shred-worker-client.ts +222 -0
  51. package/src/shred-worker-types.ts +194 -0
  52. package/src/transfer.test.ts +24 -0
  53. package/src/transfer.ts +12 -0
@@ -0,0 +1,1029 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import type { FecWorkerCommand, FecWorkerEvent, FecTrackConfig } from "../fec-worker-types.js";
3
+
4
+ const EMPTY_WASM_MODULE = new WebAssembly.Module(
5
+ new Uint8Array([0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00]),
6
+ );
7
+ const UINT32_MAX = 0xffff_ffff;
8
+ const UINT24_MAX = 0xff_ffff;
9
+ const MAX_INTERLEAVE_DEPTH = 0xff;
10
+ const MAX_SYMBOL_SIZE = 0xfff8;
11
+ const MAX_RAPTORQ_SOURCE_SYMBOLS_PER_BLOCK = 56_403;
12
+
13
+ const BASE_CONFIG: FecTrackConfig = {
14
+ codec: "avc1.64001f",
15
+ trackName: "video",
16
+ trackType: "video",
17
+ algorithm: "raptorq",
18
+ k: 4,
19
+ repairCount: 2,
20
+ symbolSize: 8,
21
+ interleaveDepth: 4,
22
+ interleaveMs: 100,
23
+ deliveryWindowMs: 200,
24
+ fecMode: "subframe",
25
+ altaEnabled: false,
26
+ relayBlockSigEnabled: false,
27
+ };
28
+
29
+ function nextTick(): Promise<void> {
30
+ return new Promise((resolve) => setTimeout(resolve, 0));
31
+ }
32
+
33
+ async function setupWorker(options: {
34
+ addRepairResult?: Uint8Array;
35
+ cleanupBlocksDetailedResult?: Uint8Array;
36
+ cleanupBySbn?: (currentSbn: number) => Uint32Array;
37
+ config?: Partial<FecTrackConfig>;
38
+ } = {}) {
39
+ const events: FecWorkerEvent[] = [];
40
+ const addSource = vi.fn(() => undefined);
41
+ const addRepair = vi.fn(() => options.addRepairResult);
42
+ let greenFillEnabled = false;
43
+ const cleanupBlocksDetailed = vi.fn(
44
+ () => greenFillEnabled
45
+ ? options.cleanupBlocksDetailedResult ?? new Uint8Array()
46
+ : new Uint8Array(),
47
+ );
48
+ const cleanupBySbn = vi.fn(
49
+ options.cleanupBySbn ?? (() => new Uint32Array()),
50
+ );
51
+ const decoderConstructed = vi.fn();
52
+ const configureParams = vi.fn();
53
+ const flushDecoder = vi.fn();
54
+
55
+ class MockDecoder {
56
+ constructor(interleaveDepth: number, enableGreenFill: boolean) {
57
+ greenFillEnabled = enableGreenFill;
58
+ decoderConstructed(interleaveDepth, enableGreenFill);
59
+ }
60
+
61
+ add_source = addSource;
62
+ add_repair = addRepair;
63
+ configure_params = configureParams;
64
+ cleanup_blocks_detailed = cleanupBlocksDetailed;
65
+ cleanup_by_sbn = cleanupBySbn;
66
+ flush = flushDecoder;
67
+ pending_blocks = vi.fn(() => 0);
68
+ reset_stats = vi.fn();
69
+ get_stats = vi.fn(() => ({
70
+ source_packets: 0n,
71
+ repair_packets: 0n,
72
+ blocks_complete: 0n,
73
+ blocks_recovered: 0n,
74
+ blocks_failed: 0n,
75
+ bytes_recovered: 0n,
76
+ recovery_rate: () => 0,
77
+ }));
78
+ free = vi.fn();
79
+ }
80
+
81
+ const worker = {
82
+ __MMT_WASM_BINDINGS__: {
83
+ initSync: vi.fn(),
84
+ MmtFecDecoder: MockDecoder,
85
+ },
86
+ onmessage: undefined as ((event: MessageEvent<FecWorkerCommand>) => void) | undefined,
87
+ postMessage: vi.fn((event: FecWorkerEvent) => {
88
+ events.push(event);
89
+ }),
90
+ };
91
+
92
+ vi.stubGlobal("self", worker);
93
+ vi.resetModules();
94
+ await import("../fec-worker.ts");
95
+
96
+ const post = async (cmd: FecWorkerCommand) => {
97
+ worker.onmessage?.({ data: cmd } as MessageEvent<FecWorkerCommand>);
98
+ await nextTick();
99
+ await nextTick();
100
+ };
101
+
102
+ await post({
103
+ type: "configure",
104
+ config: { ...BASE_CONFIG, ...options.config },
105
+ wasmModule: EMPTY_WASM_MODULE,
106
+ });
107
+
108
+ return {
109
+ addSource,
110
+ addRepair,
111
+ cleanupBlocksDetailed,
112
+ cleanupBySbn,
113
+ decoderConstructed,
114
+ configureParams,
115
+ flush: flushDecoder,
116
+ events,
117
+ post,
118
+ };
119
+ }
120
+
121
+ function frameCleanupRecords(
122
+ records: Array<{ sbn: number; data: Uint8Array }>,
123
+ ): Uint8Array {
124
+ const size = records.reduce((total, record) => total + 8 + record.data.byteLength, 0);
125
+ const framed = new Uint8Array(size);
126
+ const view = new DataView(framed.buffer);
127
+ let offset = 0;
128
+ for (const record of records) {
129
+ view.setUint32(offset, record.sbn, false);
130
+ view.setUint32(offset + 4, record.data.byteLength, false);
131
+ offset += 8;
132
+ framed.set(record.data, offset);
133
+ offset += record.data.byteLength;
134
+ }
135
+ return framed;
136
+ }
137
+
138
+ afterEach(() => {
139
+ vi.unstubAllGlobals();
140
+ });
141
+
142
+ describe("FEC Worker repair symbol sizing", () => {
143
+ it("keeps WASM green fill enabled for video tracks", async () => {
144
+ const worker = await setupWorker();
145
+ try {
146
+ expect(worker.decoderConstructed).toHaveBeenCalledWith(
147
+ BASE_CONFIG.interleaveDepth,
148
+ true,
149
+ );
150
+ } finally {
151
+ await worker.post({ type: "dispose" });
152
+ }
153
+ });
154
+
155
+ it("does not emit H.264 green fill for failed audio blocks", async () => {
156
+ const worker = await setupWorker({
157
+ config: {
158
+ codec: "mp4a.40.2",
159
+ trackName: "audio",
160
+ trackType: "audio",
161
+ },
162
+ cleanupBlocksDetailedResult: frameCleanupRecords([
163
+ { sbn: 16, data: new Uint8Array([0, 0, 0, 1, 0x0d]) },
164
+ ]),
165
+ });
166
+ try {
167
+ expect(worker.decoderConstructed).toHaveBeenCalledWith(
168
+ BASE_CONFIG.interleaveDepth,
169
+ false,
170
+ );
171
+
172
+ await worker.post({ type: "cleanup", sbns: [16] });
173
+
174
+ expect(worker.events.some((event) => event.type === "greenFill")).toBe(false);
175
+ expect(worker.events).toContainEqual({
176
+ type: "cleanupComplete",
177
+ sbns: [16],
178
+ });
179
+ } finally {
180
+ await worker.post({ type: "dispose" });
181
+ }
182
+ });
183
+
184
+ it("drops malformed repair symbols before calling WASM add_repair", async () => {
185
+ const worker = await setupWorker();
186
+ try {
187
+ await worker.post({
188
+ type: "feedRepair",
189
+ ssStart: 8,
190
+ ssbLength: 4,
191
+ rsId: 0,
192
+ data: new Uint8Array([1, 2, 3]).buffer,
193
+ ts: 1000,
194
+ });
195
+
196
+ expect(worker.addRepair).not.toHaveBeenCalled();
197
+ expect(worker.events.some((event) =>
198
+ event.type === "error" &&
199
+ event.message.includes("Dropping repair symbol with invalid size"),
200
+ )).toBe(true);
201
+ await new Promise((resolve) => setTimeout(resolve, 220));
202
+ const snapshot = worker.events.filter((event) => event.type === "snapshot").at(-1);
203
+ expect(snapshot?.type).toBe("snapshot");
204
+ if (snapshot?.type === "snapshot") {
205
+ expect(snapshot.stats.repairSymbols).toBe(0);
206
+ expect(snapshot.stats.blocks).toEqual([]);
207
+ expect(snapshot.stats.repairSymbolSizeMismatch).toBe(1);
208
+ }
209
+ } finally {
210
+ await worker.post({ type: "dispose" });
211
+ }
212
+ });
213
+
214
+ it("deduplicates direct source and repair commands before decoder and counters", async () => {
215
+ const worker = await setupWorker();
216
+ try {
217
+ const source = {
218
+ type: "feedSource" as const,
219
+ ssId: 8,
220
+ data: new Uint8Array(BASE_CONFIG.symbolSize).buffer,
221
+ ts: 1000,
222
+ };
223
+ const repair = {
224
+ type: "feedRepair" as const,
225
+ ssStart: 8,
226
+ ssbLength: BASE_CONFIG.k,
227
+ rsId: 0,
228
+ data: new Uint8Array(BASE_CONFIG.symbolSize).buffer,
229
+ ts: 1001,
230
+ };
231
+ await worker.post(source);
232
+ await worker.post({...source, data: source.data.slice(0)});
233
+ await worker.post(repair);
234
+ await worker.post({...repair, data: repair.data.slice(0)});
235
+
236
+ expect(worker.addSource).toHaveBeenCalledOnce();
237
+ expect(worker.addRepair).toHaveBeenCalledOnce();
238
+ await new Promise((resolve) => setTimeout(resolve, 220));
239
+ const snapshot = worker.events.filter((event) => event.type === "snapshot").at(-1);
240
+ expect(snapshot?.type).toBe("snapshot");
241
+ if (snapshot?.type === "snapshot") {
242
+ expect(snapshot.stats.sourceSymbols).toBe(1);
243
+ expect(snapshot.stats.repairSymbols).toBe(1);
244
+ expect(snapshot.stats.blocks).toEqual([
245
+ expect.objectContaining({sbn: 2, sourceReceived: 1, repairReceived: 1}),
246
+ ]);
247
+ }
248
+ } finally {
249
+ await worker.post({ type: "dispose" });
250
+ }
251
+ });
252
+
253
+ it("rejects noncanonical repair aliases and deduplicates canonical decoder identity", async () => {
254
+ const worker = await setupWorker();
255
+ try {
256
+ const data = () => new Uint8Array(BASE_CONFIG.symbolSize).buffer;
257
+ await worker.post({
258
+ type: "feedRepair",
259
+ ssStart: 8,
260
+ ssbLength: 4,
261
+ rsId: 1,
262
+ data: data(),
263
+ ts: 1000,
264
+ });
265
+ await worker.post({
266
+ type: "feedRepair",
267
+ ssStart: 9,
268
+ ssbLength: 4,
269
+ rsId: 1,
270
+ data: data(),
271
+ ts: 1001,
272
+ });
273
+ await worker.post({
274
+ type: "feedRepair",
275
+ ssStart: 6,
276
+ ssbLength: 3,
277
+ rsId: 2,
278
+ data: data(),
279
+ ts: 1002,
280
+ });
281
+
282
+ expect(worker.addRepair).toHaveBeenCalledOnce();
283
+ expect(worker.addRepair).toHaveBeenCalledWith(
284
+ 8,
285
+ 4,
286
+ 1,
287
+ expect.any(Uint8Array),
288
+ 1000,
289
+ );
290
+ expect(worker.events).toContainEqual(
291
+ expect.objectContaining({
292
+ type: "error",
293
+ code: "command-failed",
294
+ message: "[FecWorker] feedRepair: invalid repair symbol",
295
+ }),
296
+ );
297
+ } finally {
298
+ await worker.post({ type: "dispose" });
299
+ }
300
+ });
301
+
302
+ it("rejects repair geometry that disagrees with the configured fixed K", async () => {
303
+ const worker = await setupWorker({ config: { k: 4 } });
304
+ try {
305
+ for (const ssId of [6, 7, 8]) {
306
+ await worker.post({
307
+ type: "feedSource",
308
+ ssId,
309
+ data: new Uint8Array(BASE_CONFIG.symbolSize).buffer,
310
+ ts: 1000 + ssId,
311
+ });
312
+ }
313
+ await worker.post({
314
+ type: "feedRepair",
315
+ ssStart: 6,
316
+ ssbLength: 3,
317
+ rsId: 0,
318
+ data: new Uint8Array(BASE_CONFIG.symbolSize).buffer,
319
+ ts: 1010,
320
+ });
321
+
322
+ expect(worker.addSource).toHaveBeenCalledTimes(3);
323
+ expect(worker.addRepair).not.toHaveBeenCalled();
324
+ expect(worker.events).toContainEqual(
325
+ expect.objectContaining({
326
+ type: "error",
327
+ code: "command-failed",
328
+ message: "[FecWorker] feedRepair: invalid repair symbol",
329
+ }),
330
+ );
331
+ await new Promise((resolve) => setTimeout(resolve, 220));
332
+ const snapshot = worker.events.filter((event) => event.type === "snapshot").at(-1);
333
+ expect(snapshot?.type).toBe("snapshot");
334
+ if (snapshot?.type === "snapshot") {
335
+ expect(snapshot.stats.blocks).toHaveLength(2);
336
+ expect(snapshot.stats.blocks).toEqual(expect.arrayContaining([
337
+ expect.objectContaining({ sbn: 1, sourceReceived: 2, repairReceived: 0 }),
338
+ expect.objectContaining({ sbn: 2, sourceReceived: 1, repairReceived: 0 }),
339
+ ]));
340
+ }
341
+ } finally {
342
+ await worker.post({ type: "dispose" });
343
+ }
344
+ });
345
+
346
+ it("accepts UINT32_MAX source IDs and rejects values that would wrap in WASM", async () => {
347
+ const worker = await setupWorker();
348
+ try {
349
+ await worker.post({
350
+ type: "feedSource",
351
+ ssId: UINT32_MAX,
352
+ data: new Uint8Array(BASE_CONFIG.symbolSize).buffer,
353
+ ts: 1000,
354
+ });
355
+ await worker.post({
356
+ type: "feedSource",
357
+ ssId: UINT32_MAX + 1,
358
+ data: new Uint8Array(BASE_CONFIG.symbolSize).buffer,
359
+ ts: 1001,
360
+ });
361
+
362
+ expect(worker.addSource).toHaveBeenCalledOnce();
363
+ expect(worker.addSource).toHaveBeenCalledWith(
364
+ UINT32_MAX,
365
+ expect.any(Uint8Array),
366
+ 1000,
367
+ );
368
+ expect(worker.events).toContainEqual(
369
+ expect.objectContaining({
370
+ type: "error",
371
+ code: "command-failed",
372
+ message: "[FecWorker] feedSource: invalid source symbol",
373
+ }),
374
+ );
375
+ } finally {
376
+ await worker.post({ type: "dispose" });
377
+ }
378
+ });
379
+
380
+ it("enforces the RaptorQ allocation and MMTP repair-coordinate bounds", async () => {
381
+ const worker = await setupWorker({
382
+ config: { k: MAX_RAPTORQ_SOURCE_SYMBOLS_PER_BLOCK },
383
+ });
384
+ try {
385
+ const ssStart = Math.floor(
386
+ UINT32_MAX / MAX_RAPTORQ_SOURCE_SYMBOLS_PER_BLOCK,
387
+ ) * MAX_RAPTORQ_SOURCE_SYMBOLS_PER_BLOCK;
388
+ const valid = {
389
+ type: "feedRepair" as const,
390
+ ssStart,
391
+ ssbLength: MAX_RAPTORQ_SOURCE_SYMBOLS_PER_BLOCK,
392
+ rsId: UINT24_MAX - MAX_RAPTORQ_SOURCE_SYMBOLS_PER_BLOCK,
393
+ data: new Uint8Array(BASE_CONFIG.symbolSize).buffer,
394
+ ts: 1000,
395
+ };
396
+ await worker.post(valid);
397
+ for (const invalid of [
398
+ { ...valid, ssStart: UINT32_MAX + 1 },
399
+ { ...valid, ssStart: ssStart + 1 },
400
+ {
401
+ ...valid,
402
+ ssbLength: MAX_RAPTORQ_SOURCE_SYMBOLS_PER_BLOCK + 1,
403
+ },
404
+ { ...valid, rsId: valid.rsId + 1 },
405
+ ]) {
406
+ await worker.post({ ...invalid, data: invalid.data.slice(0) });
407
+ }
408
+
409
+ expect(worker.addRepair).toHaveBeenCalledOnce();
410
+ expect(worker.addRepair).toHaveBeenCalledWith(
411
+ ssStart,
412
+ MAX_RAPTORQ_SOURCE_SYMBOLS_PER_BLOCK,
413
+ UINT24_MAX - MAX_RAPTORQ_SOURCE_SYMBOLS_PER_BLOCK,
414
+ expect.any(Uint8Array),
415
+ 1000,
416
+ );
417
+ expect(
418
+ worker.events.filter(
419
+ (event) =>
420
+ event.type === "error" &&
421
+ event.message === "[FecWorker] feedRepair: invalid repair symbol",
422
+ ),
423
+ ).toHaveLength(4);
424
+ } finally {
425
+ await worker.post({ type: "dispose" });
426
+ }
427
+ });
428
+
429
+ it("rejects catalog K above the RaptorQ source-block allocation limit", async () => {
430
+ const worker = await setupWorker();
431
+ try {
432
+ await worker.post({
433
+ type: "configure",
434
+ config: {
435
+ ...BASE_CONFIG,
436
+ k: MAX_RAPTORQ_SOURCE_SYMBOLS_PER_BLOCK + 1,
437
+ },
438
+ wasmModule: EMPTY_WASM_MODULE,
439
+ });
440
+
441
+ expect(worker.events).toContainEqual(
442
+ expect.objectContaining({
443
+ type: "error",
444
+ code: "command-failed",
445
+ message: expect.stringContaining(
446
+ `k must be an integer in 1..${MAX_RAPTORQ_SOURCE_SYMBOLS_PER_BLOCK}`,
447
+ ),
448
+ }),
449
+ );
450
+ } finally {
451
+ await worker.post({ type: "dispose" });
452
+ }
453
+ });
454
+
455
+ it("rejects invalid interleave geometry before constructing a decoder", async () => {
456
+ const worker = await setupWorker({
457
+ config: { interleaveDepth: MAX_INTERLEAVE_DEPTH },
458
+ });
459
+ try {
460
+ for (const config of [
461
+ { interleaveDepth: Number.NaN },
462
+ { interleaveDepth: Number.POSITIVE_INFINITY },
463
+ { interleaveDepth: 1.5 },
464
+ { interleaveDepth: MAX_INTERLEAVE_DEPTH + 1 },
465
+ { interleaveDepth: UINT32_MAX + 1 },
466
+ { interleaveMs: Number.NaN },
467
+ { interleaveMs: Number.POSITIVE_INFINITY },
468
+ ]) {
469
+ await worker.post({
470
+ type: "configure",
471
+ config: { ...BASE_CONFIG, ...config },
472
+ wasmModule: EMPTY_WASM_MODULE,
473
+ });
474
+ }
475
+
476
+ expect(worker.decoderConstructed).toHaveBeenCalledOnce();
477
+ expect(worker.decoderConstructed).toHaveBeenCalledWith(
478
+ MAX_INTERLEAVE_DEPTH,
479
+ true,
480
+ );
481
+ expect(
482
+ worker.events.filter(
483
+ (event) => event.type === "error" && event.code === "command-failed",
484
+ ),
485
+ ).toHaveLength(7);
486
+ } finally {
487
+ await worker.post({ type: "dispose" });
488
+ }
489
+ });
490
+
491
+ it("bounds symbolSize to the Rust u16 contract before decoder construction", async () => {
492
+ const worker = await setupWorker({
493
+ config: { symbolSize: MAX_SYMBOL_SIZE },
494
+ });
495
+ try {
496
+ expect(worker.configureParams).toHaveBeenCalledWith(
497
+ BASE_CONFIG.k,
498
+ MAX_SYMBOL_SIZE,
499
+ );
500
+ for (const symbolSize of [
501
+ 0,
502
+ 1.5,
503
+ MAX_SYMBOL_SIZE - 1,
504
+ MAX_SYMBOL_SIZE + 1,
505
+ Number.NaN,
506
+ Number.POSITIVE_INFINITY,
507
+ ]) {
508
+ await worker.post({
509
+ type: "configure",
510
+ config: { ...BASE_CONFIG, symbolSize },
511
+ wasmModule: EMPTY_WASM_MODULE,
512
+ });
513
+ }
514
+
515
+ expect(worker.decoderConstructed).toHaveBeenCalledOnce();
516
+ expect(worker.configureParams).toHaveBeenCalledOnce();
517
+ expect(
518
+ worker.events.filter(
519
+ (event) => event.type === "error" && event.code === "command-failed",
520
+ ),
521
+ ).toHaveLength(6);
522
+ } finally {
523
+ await worker.post({ type: "dispose" });
524
+ }
525
+ });
526
+
527
+ it("configures exact catalog geometry before admitting symbols", async () => {
528
+ const worker = await setupWorker({ config: { k: 2, symbolSize: 8 } });
529
+ try {
530
+ expect(worker.configureParams).toHaveBeenCalledWith(2, 8);
531
+ await worker.post({
532
+ type: "feedSource",
533
+ ssId: 0,
534
+ data: new Uint8Array(8).buffer,
535
+ ts: 1000,
536
+ });
537
+ await worker.post({ type: "reset" });
538
+ expect(worker.flush).toHaveBeenCalledOnce();
539
+ expect(worker.events).toContainEqual({ type: "resetComplete" });
540
+ } finally {
541
+ await worker.post({ type: "dispose" });
542
+ }
543
+ });
544
+
545
+ it("keeps evicted clean-block identities retired behind the decoder horizon", async () => {
546
+ const worker = await setupWorker({
547
+ config: { k: 1, interleaveDepth: 1 },
548
+ });
549
+ try {
550
+ for (let ssId = 0; ssId < 25; ssId++) {
551
+ await worker.post({
552
+ type: "feedSource",
553
+ ssId,
554
+ data: new Uint8Array(BASE_CONFIG.symbolSize).buffer,
555
+ ts: 1000 + ssId,
556
+ });
557
+ }
558
+ expect(worker.addSource).toHaveBeenCalledTimes(25);
559
+
560
+ await worker.post({
561
+ type: "feedSource",
562
+ ssId: 24,
563
+ data: new Uint8Array(BASE_CONFIG.symbolSize).buffer,
564
+ ts: 2000,
565
+ });
566
+ expect(worker.addSource).toHaveBeenCalledTimes(25);
567
+
568
+ await worker.post({
569
+ type: "feedSource",
570
+ ssId: 0,
571
+ data: new Uint8Array(BASE_CONFIG.symbolSize).buffer,
572
+ ts: 2001,
573
+ });
574
+ expect(worker.addSource).toHaveBeenCalledTimes(25);
575
+ } finally {
576
+ await worker.post({ type: "dispose" });
577
+ }
578
+ });
579
+
580
+ it("accepts out-of-order media inside the retained decoder horizon", async () => {
581
+ const worker = await setupWorker({
582
+ config: { k: 2, interleaveDepth: 4 },
583
+ });
584
+ try {
585
+ await worker.post({
586
+ type: "feedSource",
587
+ ssId: 200,
588
+ data: new Uint8Array(BASE_CONFIG.symbolSize).buffer,
589
+ ts: 1000,
590
+ });
591
+ for (let sbn = 96; sbn < 100; sbn++) {
592
+ await worker.post({
593
+ type: "feedSource",
594
+ ssId: sbn * 2,
595
+ data: new Uint8Array(BASE_CONFIG.symbolSize).buffer,
596
+ ts: 1001 + sbn - 96,
597
+ });
598
+ }
599
+ expect(worker.addSource).toHaveBeenCalledTimes(5);
600
+
601
+ await worker.post({
602
+ type: "feedSource",
603
+ ssId: 200,
604
+ data: new Uint8Array(BASE_CONFIG.symbolSize).buffer,
605
+ ts: 2000,
606
+ });
607
+
608
+ expect(worker.addSource).toHaveBeenCalledTimes(5);
609
+ expect(worker.cleanupBySbn).toHaveBeenLastCalledWith(100);
610
+ } finally {
611
+ await worker.post({ type: "dispose" });
612
+ }
613
+ });
614
+
615
+ it("keeps a lower interleaved block live when a higher SBN expires first", async () => {
616
+ const worker = await setupWorker({
617
+ config: { k: 2, interleaveDepth: 4 },
618
+ });
619
+ try {
620
+ await worker.post({
621
+ type: "feedSource",
622
+ ssId: 190,
623
+ data: new Uint8Array(BASE_CONFIG.symbolSize).buffer,
624
+ ts: 1000,
625
+ });
626
+ await worker.post({ type: "cleanup", sbns: [100] });
627
+ await worker.post({
628
+ type: "feedSource",
629
+ ssId: 191,
630
+ data: new Uint8Array(BASE_CONFIG.symbolSize).buffer,
631
+ ts: 1001,
632
+ });
633
+ await new Promise((resolve) => setTimeout(resolve, 220));
634
+
635
+ expect(worker.cleanupBlocksDetailed).toHaveBeenCalledWith(Uint32Array.of(100));
636
+ expect(worker.cleanupBySbn).toHaveBeenCalledTimes(2);
637
+ expect(worker.cleanupBySbn).toHaveBeenLastCalledWith(95);
638
+ expect(worker.addSource).toHaveBeenCalledTimes(2);
639
+ const snapshot = worker.events.filter((event) => event.type === "snapshot").at(-1);
640
+ expect(snapshot?.type).toBe("snapshot");
641
+ if (snapshot?.type === "snapshot") {
642
+ expect(snapshot.stats.sourceSymbols).toBe(2);
643
+ expect(snapshot.stats.blocksComplete).toBe(1);
644
+ expect(snapshot.stats.blocksFailed).toBe(1);
645
+ expect(snapshot.stats.blocks).toHaveLength(2);
646
+ expect(snapshot.stats.blocks).toEqual(expect.arrayContaining([
647
+ expect.objectContaining({ sbn: 95, state: "complete" }),
648
+ expect.objectContaining({ sbn: 100, state: "failed" }),
649
+ ]));
650
+ }
651
+ } finally {
652
+ await worker.post({ type: "dispose" });
653
+ }
654
+ });
655
+
656
+ it("does not recreate or recount a block already retired by the media horizon", async () => {
657
+ const worker = await setupWorker({
658
+ config: { k: 2, interleaveDepth: 1 },
659
+ cleanupBySbn: (currentSbn) =>
660
+ currentSbn === 2 ? Uint32Array.of(0) : new Uint32Array(),
661
+ });
662
+ try {
663
+ await worker.post({
664
+ type: "feedSource",
665
+ ssId: 0,
666
+ data: new Uint8Array(BASE_CONFIG.symbolSize).buffer,
667
+ ts: 1000,
668
+ });
669
+ await worker.post({
670
+ type: "feedSource",
671
+ ssId: 4,
672
+ data: new Uint8Array(BASE_CONFIG.symbolSize).buffer,
673
+ ts: 1001,
674
+ });
675
+ await worker.post({ type: "cleanup", sbns: [0] });
676
+ await new Promise((resolve) => setTimeout(resolve, 220));
677
+
678
+ expect(worker.cleanupBlocksDetailed).not.toHaveBeenCalled();
679
+ expect(worker.events).toContainEqual({ type: "cleanupComplete", sbns: [0] });
680
+ const retiredUpdates = worker.events.filter(
681
+ (event) => event.type === "blockUpdate" && event.block.sbn === 0,
682
+ );
683
+ expect(retiredUpdates).toHaveLength(1);
684
+ const snapshot = worker.events.filter((event) => event.type === "snapshot").at(-1);
685
+ expect(snapshot?.type).toBe("snapshot");
686
+ if (snapshot?.type === "snapshot") {
687
+ expect(snapshot.stats.blocksFailed).toBe(1);
688
+ expect(snapshot.stats.blocks).toEqual([
689
+ expect.objectContaining({ sbn: 2, state: "collecting" }),
690
+ ]);
691
+ }
692
+ } finally {
693
+ await worker.post({ type: "dispose" });
694
+ }
695
+ });
696
+
697
+ it("does not reopen a high finalized SBN after retiring older horizon tombstones", async () => {
698
+ const worker = await setupWorker({
699
+ config: { k: 2, interleaveDepth: 1 },
700
+ });
701
+ try {
702
+ for (const ssId of [200, 201]) {
703
+ await worker.post({
704
+ type: "feedSource",
705
+ ssId,
706
+ data: new Uint8Array(BASE_CONFIG.symbolSize).buffer,
707
+ ts: 1000 + ssId,
708
+ });
709
+ }
710
+ for (let sbn = 0; sbn < 24; sbn++) {
711
+ await worker.post({ type: "cleanup", sbns: [sbn] });
712
+ }
713
+ await worker.post({
714
+ type: "feedSource",
715
+ ssId: 200,
716
+ data: new Uint8Array(BASE_CONFIG.symbolSize).buffer,
717
+ ts: 2000,
718
+ });
719
+ await new Promise((resolve) => setTimeout(resolve, 220));
720
+
721
+ expect(worker.addSource).toHaveBeenCalledTimes(2);
722
+ expect(worker.events.some((event) => event.type === "frame")).toBe(false);
723
+ const snapshot = worker.events.filter((event) => event.type === "snapshot").at(-1);
724
+ expect(snapshot?.type).toBe("snapshot");
725
+ if (snapshot?.type === "snapshot") {
726
+ expect(snapshot.stats.sourceSymbols).toBe(2);
727
+ expect(snapshot.stats.blocks).toEqual([
728
+ expect.objectContaining({ sbn: 100, state: "complete" }),
729
+ ]);
730
+ }
731
+ } finally {
732
+ await worker.post({ type: "dispose" });
733
+ }
734
+ });
735
+
736
+ it("clears block tombstones and accepted IDs before reconfiguration", async () => {
737
+ const worker = await setupWorker({ config: { k: 1 } });
738
+ try {
739
+ const source = {
740
+ type: "feedSource" as const,
741
+ ssId: 7,
742
+ data: new Uint8Array(BASE_CONFIG.symbolSize).buffer,
743
+ ts: 1000,
744
+ };
745
+ const repair = {
746
+ type: "feedRepair" as const,
747
+ ssStart: 7,
748
+ ssbLength: 1,
749
+ rsId: 0,
750
+ data: new Uint8Array(BASE_CONFIG.symbolSize).buffer,
751
+ ts: 999,
752
+ };
753
+ await worker.post(repair);
754
+ await worker.post(source);
755
+ await worker.post({ ...source, data: source.data.slice(0) });
756
+ await worker.post({ ...repair, data: repair.data.slice(0) });
757
+ expect(worker.addSource).toHaveBeenCalledOnce();
758
+ expect(worker.addRepair).toHaveBeenCalledOnce();
759
+
760
+ await worker.post({
761
+ type: "configure",
762
+ config: { ...BASE_CONFIG, k: 1 },
763
+ wasmModule: EMPTY_WASM_MODULE,
764
+ });
765
+ const configuredSnapshot = worker.events
766
+ .filter((event) => event.type === "snapshot")
767
+ .at(-1);
768
+ expect(configuredSnapshot?.type).toBe("snapshot");
769
+ if (configuredSnapshot?.type === "snapshot") {
770
+ expect(configuredSnapshot.stats.blocks).toEqual([]);
771
+ }
772
+
773
+ await worker.post({ ...repair, data: repair.data.slice(0), ts: 1999 });
774
+ await worker.post({ ...source, data: source.data.slice(0), ts: 2000 });
775
+ expect(worker.addSource).toHaveBeenCalledTimes(2);
776
+ expect(worker.addRepair).toHaveBeenCalledTimes(2);
777
+ } finally {
778
+ await worker.post({ type: "dispose" });
779
+ }
780
+ });
781
+
782
+ it("ignores forged path labels on raw Worker commands", async () => {
783
+ const worker = await setupWorker();
784
+ try {
785
+ await worker.post({
786
+ type: "feedSource",
787
+ ssId: 8,
788
+ data: new Uint8Array(BASE_CONFIG.symbolSize).buffer,
789
+ ts: 1000,
790
+ source: "multicast",
791
+ } as unknown as FecWorkerCommand);
792
+ await worker.post({
793
+ type: "feedRepair",
794
+ ssStart: 8,
795
+ ssbLength: BASE_CONFIG.k,
796
+ rsId: 0,
797
+ data: new Uint8Array(BASE_CONFIG.symbolSize).buffer,
798
+ ts: 1001,
799
+ source: "moq",
800
+ } as unknown as FecWorkerCommand);
801
+
802
+ await new Promise((resolve) => setTimeout(resolve, 220));
803
+ const snapshot = worker.events.filter((event) => event.type === "snapshot").at(-1);
804
+ expect(snapshot?.type).toBe("snapshot");
805
+ if (snapshot?.type === "snapshot") {
806
+ const block = snapshot.stats.blocks.find((candidate) => candidate.sbn === 2);
807
+ expect(block).not.toHaveProperty("multicastSymbols");
808
+ expect(block).not.toHaveProperty("moqSymbols");
809
+ }
810
+ } finally {
811
+ await worker.post({ type: "dispose" });
812
+ }
813
+ });
814
+
815
+ it("passes exact-size repair symbols to WASM add_repair", async () => {
816
+ const worker = await setupWorker();
817
+ try {
818
+ const data = new Uint8Array(BASE_CONFIG.symbolSize);
819
+ await worker.post({
820
+ type: "feedRepair",
821
+ ssStart: 8,
822
+ ssbLength: 4,
823
+ rsId: 1,
824
+ data: data.buffer,
825
+ ts: 1000,
826
+ });
827
+
828
+ expect(worker.addRepair).toHaveBeenCalledOnce();
829
+ expect(worker.addRepair).toHaveBeenCalledWith(
830
+ 8,
831
+ 4,
832
+ 1,
833
+ expect.any(Uint8Array),
834
+ 1000,
835
+ );
836
+ } finally {
837
+ await worker.post({ type: "dispose" });
838
+ }
839
+ });
840
+
841
+ it("identifies each recovered source-symbol slot in frame metadata", async () => {
842
+ const worker = await setupWorker({
843
+ addRepairResult: new Uint8Array(BASE_CONFIG.k * BASE_CONFIG.symbolSize),
844
+ });
845
+ try {
846
+ await worker.post({
847
+ type: "feedRepair",
848
+ ssStart: 8,
849
+ ssbLength: BASE_CONFIG.k,
850
+ rsId: 0,
851
+ data: new Uint8Array(BASE_CONFIG.symbolSize).buffer,
852
+ ts: 1000,
853
+ });
854
+
855
+ const frames = worker.events.filter((event) => event.type === "frame");
856
+ expect(frames).toHaveLength(BASE_CONFIG.k);
857
+ expect(frames.map((event) => event.type === "frame" ? event.meta : null)).toEqual([
858
+ expect.objectContaining({ recovered: true, sbn: 2, esi: 0 }),
859
+ expect.objectContaining({ recovered: true, sbn: 2, esi: 1 }),
860
+ expect.objectContaining({ recovered: true, sbn: 2, esi: 2 }),
861
+ expect.objectContaining({ recovered: true, sbn: 2, esi: 3 }),
862
+ ]);
863
+ } finally {
864
+ await worker.post({ type: "dispose" });
865
+ }
866
+ });
867
+
868
+ it("cleanup finalizes partial JS state and rejects late duplicate or unique symbols", async () => {
869
+ const worker = await setupWorker();
870
+ try {
871
+ await worker.post({
872
+ type: "feedSource",
873
+ ssId: 8,
874
+ data: new Uint8Array([1, 2, 3, 4]).buffer,
875
+ ts: 1000,
876
+ });
877
+ await worker.post({ type: "cleanup", sbns: [2] });
878
+ await worker.post({
879
+ type: "feedSource",
880
+ ssId: 8,
881
+ data: new Uint8Array([1, 2, 3, 4]).buffer,
882
+ ts: 1001,
883
+ });
884
+ await worker.post({
885
+ type: "feedSource",
886
+ ssId: 9,
887
+ data: new Uint8Array([1, 2, 3, 4]).buffer,
888
+ ts: 1002,
889
+ });
890
+ await new Promise((resolve) => setTimeout(resolve, 220));
891
+ expect(worker.events).toContainEqual({ type: "cleanupComplete", sbns: [2] });
892
+ expect(worker.addSource).toHaveBeenCalledOnce();
893
+
894
+ const snapshots = worker.events.filter((event) => event.type === "snapshot");
895
+ const last = snapshots.at(-1);
896
+ expect(last?.type).toBe("snapshot");
897
+ if (last?.type !== "snapshot") return;
898
+ expect(last.stats.blocksFailed).toBe(1);
899
+ expect(
900
+ last.stats.blocks.find((block) => block.sbn === 2)?.state,
901
+ ).toBe("failed");
902
+ } finally {
903
+ await worker.post({ type: "dispose" });
904
+ }
905
+ });
906
+
907
+ it("cleanup tombstones an unseen SBN before later source or repair admission", async () => {
908
+ const worker = await setupWorker();
909
+ try {
910
+ await worker.post({ type: "cleanup", sbns: [2] });
911
+ await worker.post({
912
+ type: "feedSource",
913
+ ssId: 8,
914
+ data: new Uint8Array(BASE_CONFIG.symbolSize).buffer,
915
+ ts: 1000,
916
+ });
917
+ await worker.post({
918
+ type: "feedRepair",
919
+ ssStart: 8,
920
+ ssbLength: BASE_CONFIG.k,
921
+ rsId: 0,
922
+ data: new Uint8Array(BASE_CONFIG.symbolSize).buffer,
923
+ ts: 1001,
924
+ });
925
+ await new Promise((resolve) => setTimeout(resolve, 220));
926
+
927
+ expect(worker.cleanupBlocksDetailed).toHaveBeenCalledWith(Uint32Array.of(2));
928
+ expect(worker.events).toContainEqual({ type: "cleanupComplete", sbns: [2] });
929
+ expect(worker.addSource).not.toHaveBeenCalled();
930
+ expect(worker.addRepair).not.toHaveBeenCalled();
931
+ const snapshot = worker.events.filter((event) => event.type === "snapshot").at(-1);
932
+ expect(snapshot?.type).toBe("snapshot");
933
+ if (snapshot?.type === "snapshot") {
934
+ expect(snapshot.stats.blocksFailed).toBe(1);
935
+ expect(snapshot.stats.blocks).toEqual([
936
+ expect.objectContaining({
937
+ sbn: 2,
938
+ state: "failed",
939
+ sourceReceived: 0,
940
+ repairReceived: 0,
941
+ }),
942
+ ]);
943
+ }
944
+ } finally {
945
+ await worker.post({ type: "dispose" });
946
+ }
947
+ });
948
+
949
+ it("rejects every non-uint32 cleanup SBN without WASM, JS, or acknowledgement side effects", async () => {
950
+ const worker = await setupWorker();
951
+ try {
952
+ await worker.post({
953
+ type: "feedSource",
954
+ ssId: 8,
955
+ data: new Uint8Array(BASE_CONFIG.symbolSize).buffer,
956
+ ts: 1000,
957
+ });
958
+ for (const sbn of [UINT32_MAX + 1, -1, 1.5, Number.NaN, Number.POSITIVE_INFINITY]) {
959
+ await worker.post({
960
+ type: "cleanup",
961
+ sbns: [sbn],
962
+ } as FecWorkerCommand);
963
+ }
964
+
965
+ expect(worker.cleanupBlocksDetailed).not.toHaveBeenCalled();
966
+ expect(worker.events.some((event) => event.type === "cleanupComplete")).toBe(false);
967
+ expect(
968
+ worker.events.filter(
969
+ (event) =>
970
+ event.type === "error" &&
971
+ event.message === "[FecWorker] cleanup: invalid SBNs",
972
+ ),
973
+ ).toHaveLength(5);
974
+
975
+ await worker.post({
976
+ type: "feedSource",
977
+ ssId: 9,
978
+ data: new Uint8Array(BASE_CONFIG.symbolSize).buffer,
979
+ ts: 1001,
980
+ });
981
+ expect(worker.addSource).toHaveBeenCalledTimes(2);
982
+ await new Promise((resolve) => setTimeout(resolve, 220));
983
+ const snapshot = worker.events.filter((event) => event.type === "snapshot").at(-1);
984
+ expect(snapshot?.type).toBe("snapshot");
985
+ if (snapshot?.type === "snapshot") {
986
+ expect(snapshot.stats.blocksFailed).toBe(0);
987
+ expect(snapshot.stats.blocks).toEqual([
988
+ expect.objectContaining({ sbn: 2, sourceReceived: 2, state: "collecting" }),
989
+ ]);
990
+ }
991
+ } finally {
992
+ await worker.post({ type: "dispose" });
993
+ }
994
+ });
995
+
996
+ it("emits per-SBN green fill records from one coalesced cleanup pass", async () => {
997
+ const fill16 = new Uint8Array([0, 0, 0, 1, 0x0d]);
998
+ const fill18 = new Uint8Array([0, 0, 0, 1, 0x0d]);
999
+ const worker = await setupWorker({
1000
+ cleanupBlocksDetailedResult: frameCleanupRecords([
1001
+ { sbn: 16, data: fill16 },
1002
+ { sbn: 18, data: fill18 },
1003
+ ]),
1004
+ });
1005
+ try {
1006
+ await worker.post({ type: "cleanup", sbns: [18, 16, 18] });
1007
+
1008
+ expect(worker.cleanupBlocksDetailed).toHaveBeenCalledOnce();
1009
+ expect(worker.cleanupBlocksDetailed).toHaveBeenCalledWith(
1010
+ Uint32Array.from([16, 18]),
1011
+ );
1012
+ const fills = worker.events.filter((event) => event.type === "greenFill");
1013
+ expect(fills).toHaveLength(2);
1014
+ expect(fills.map((event) => event.type === "greenFill" ? event.sbn : null))
1015
+ .toEqual([16, 18]);
1016
+ expect(
1017
+ fills.map((event) =>
1018
+ event.type === "greenFill" ? [...new Uint8Array(event.data)] : null,
1019
+ ),
1020
+ ).toEqual([[...fill16], [...fill18]]);
1021
+ expect(worker.events).toContainEqual({
1022
+ type: "cleanupComplete",
1023
+ sbns: [16, 18],
1024
+ });
1025
+ } finally {
1026
+ await worker.post({ type: "dispose" });
1027
+ }
1028
+ });
1029
+ });