@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,84 @@
1
+ /**
2
+ * Contract tests: FEC Worker ↔ WASM decoder signatures.
3
+ *
4
+ * Prevents the regression fixed in session 08-08: fec-worker.ts used to call
5
+ * wasmDecoder.add_source(sbn, esi, data, ts) // 4 args, wrong
6
+ * wasmDecoder.add_repair(sbn, esi, data, ts) // 4 args, wrong
7
+ * while a fresh external libmmt `mmt_wasm.d.ts` exports
8
+ * add_source(ss_id, data, timestamp) // 3 args
9
+ * add_repair(ss_start, ssb_length, rs_id, data, timestamp) // 5 args
10
+ * The misalignment silently turned symbol bytes into numbers, which
11
+ * wasm-bindgen's passArray8ToWasm0 converted to empty allocations. Result:
12
+ * moqtail's FEC worker received empty symbols for every feed, so
13
+ * blocksRecovered stayed at 0 while repairPackets piled up.
14
+ *
15
+ * These tests pin the protocol fields carried in the worker commands so a
16
+ * future rename/restructure fails loudly at compile time.
17
+ */
18
+
19
+ import { describe, it, expect, assertType } from "vitest";
20
+ import type { FecWorkerCommand } from "../fec-worker-types.js";
21
+
22
+ describe("FEC Worker ↔ WASM signature contract", () => {
23
+ it("feedSource command exposes ssId (flat 32-bit SS_ID) — matches WASM add_source(ss_id, data, timestamp)", () => {
24
+ const cmd: Extract<FecWorkerCommand, { type: "feedSource" }> = {
25
+ type: "feedSource",
26
+ ssId: 42,
27
+ data: new ArrayBuffer(1440),
28
+ ts: 1000,
29
+ };
30
+ // ssId must be a number. Any drift (e.g., renaming back to sbn+esi)
31
+ // breaks this line at compile time.
32
+ assertType<number>(cmd.ssId);
33
+ assertType<ArrayBuffer>(cmd.data);
34
+ assertType<number>(cmd.ts);
35
+ // @ts-expect-error — sbn must not exist on feedSource; that would
36
+ // resurrect the collapsed (sbn, esi) shape that misaligned WASM args.
37
+ const _hasSbn = cmd.sbn;
38
+ // @ts-expect-error — esi must not exist on feedSource either.
39
+ const _hasEsi = cmd.esi;
40
+ void _hasSbn;
41
+ void _hasEsi;
42
+ expect(cmd.ssId).toBe(42);
43
+ });
44
+
45
+ it("feedRepair command exposes ssStart/ssbLength/rsId — matches WASM add_repair(ss_start, ssb_length, rs_id, data, timestamp)", () => {
46
+ const cmd: Extract<FecWorkerCommand, { type: "feedRepair" }> = {
47
+ type: "feedRepair",
48
+ ssStart: 40, // SBN * K (e.g. SBN=10, K=4)
49
+ ssbLength: 4, // K
50
+ rsId: 2, // 3rd repair symbol in the block
51
+ data: new ArrayBuffer(1440),
52
+ ts: 1000,
53
+ };
54
+ assertType<number>(cmd.ssStart);
55
+ assertType<number>(cmd.ssbLength);
56
+ assertType<number>(cmd.rsId);
57
+ assertType<ArrayBuffer>(cmd.data);
58
+ // @ts-expect-error — sbn must not exist on feedRepair; that field was
59
+ // the pre-computed collapse that broke recovery.
60
+ const _hasSbn = cmd.sbn;
61
+ // @ts-expect-error — esi must not exist on feedRepair either.
62
+ const _hasEsi = cmd.esi;
63
+ void _hasSbn;
64
+ void _hasEsi;
65
+ expect(cmd.ssStart).toBe(40);
66
+ expect(cmd.ssbLength).toBe(4);
67
+ expect(cmd.rsId).toBe(2);
68
+ });
69
+
70
+ it("WASM derivation is reversible — (ssStart=SBN*K, ssbLength=K, rsId) round-trips", () => {
71
+ // Pins the WASM-side derivation documented in mmt_wasm.d.ts:
72
+ // SBN = ss_start / ssb_length
73
+ // repair ESI = ssb_length + rs_id
74
+ // If those formulas change, the worker protocol must change with them.
75
+ const k = 4;
76
+ const sbn = 7;
77
+ const ssStart = sbn * k;
78
+ const rsId = 1;
79
+ const derivedSbn = Math.floor(ssStart / k);
80
+ const derivedRepairEsi = k + rsId;
81
+ expect(derivedSbn).toBe(sbn);
82
+ expect(derivedRepairEsi).toBe(5);
83
+ });
84
+ });
@@ -0,0 +1,448 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import type {
3
+ ShredWorkerCommand,
4
+ ShredWorkerEvent,
5
+ ShredWorkerConfig,
6
+ } from "../shred-worker-types.js";
7
+ import {
8
+ SHRED_WIRE_VERSION,
9
+ SHRED_HEADER_LEN,
10
+ SHRED_FLAG_IS_CODING,
11
+ SHRED_FLAG_DATA_COMPLETE,
12
+ } from "../shred-worker-types.js";
13
+
14
+ // --- Synthetic v3 wire frames -----------------------------------------------
15
+
16
+ interface ShredFields {
17
+ version?: number;
18
+ slot?: bigint;
19
+ fecSetIndex?: number;
20
+ localIndex?: number;
21
+ numData?: number;
22
+ numCoding?: number;
23
+ sendTsUs?: bigint;
24
+ isCoding?: boolean;
25
+ dataComplete?: boolean;
26
+ payload?: Uint8Array;
27
+ }
28
+
29
+ /** Build a v3 (28-byte header) shred frame, matching the deployed player wire. */
30
+ function makeV3Frame(f: ShredFields = {}): ArrayBuffer {
31
+ const payload = f.payload ?? new Uint8Array([1, 2, 3, 4]);
32
+ const buf = new Uint8Array(SHRED_HEADER_LEN + payload.byteLength);
33
+ const dv = new DataView(buf.buffer);
34
+ dv.setUint8(0, f.version ?? SHRED_WIRE_VERSION);
35
+ dv.setBigUint64(1, f.slot ?? 100n, true);
36
+ dv.setUint32(9, f.fecSetIndex ?? 0, true);
37
+ dv.setUint32(13, f.localIndex ?? 0, true);
38
+ let flags = 0;
39
+ if (f.dataComplete) flags |= SHRED_FLAG_DATA_COMPLETE;
40
+ if (f.isCoding) flags |= SHRED_FLAG_IS_CODING;
41
+ dv.setUint8(17, flags);
42
+ dv.setUint8(18, f.numData ?? 4);
43
+ dv.setUint8(19, f.numCoding ?? 2);
44
+ dv.setBigUint64(20, f.sendTsUs ?? 0n, true);
45
+ buf.set(payload, SHRED_HEADER_LEN);
46
+ return buf.buffer;
47
+ }
48
+
49
+ function nextTick(): Promise<void> {
50
+ return new Promise((resolve) => setTimeout(resolve, 0));
51
+ }
52
+
53
+ function last<T>(arr: T[]): T | undefined {
54
+ return arr[arr.length - 1];
55
+ }
56
+
57
+ // --- Worker harness (stubbed self + injected shred WASM bindings) -----------
58
+
59
+ interface WorkerHarnessOptions {
60
+ addShredResult?: Uint8Array | null;
61
+ recoverResult?: Uint8Array | null;
62
+ /**
63
+ * When set, the mock ShredFecBuffer exposes `try_recover_all_with_identity`
64
+ * (the identity-carrying drain) returning this set once. Leaving it undefined
65
+ * keeps the method absent so the worker falls back to `try_recover_all`.
66
+ */
67
+ recoverIdentity?: { slot: bigint; fecSetIndex: number; payload: Uint8Array } | null;
68
+ pending?: number;
69
+ config?: Partial<ShredWorkerConfig>;
70
+ /** Inject the legacy 5-arg ShredReassembler instead of the 8-arg ShredFecBuffer. */
71
+ legacyReassembler?: boolean;
72
+ }
73
+
74
+ async function setupWorker(options: WorkerHarnessOptions = {}) {
75
+ const events: ShredWorkerEvent[] = [];
76
+ const addShredCalls: unknown[][] = [];
77
+ const tryRecoverCalls: number[] = [];
78
+ const tryRecoverIdentityCalls: number[] = [];
79
+ const ctorArgs: Array<[bigint, number]> = [];
80
+ const free = vi.fn();
81
+ const recoveredSetFree = vi.fn();
82
+
83
+ // 8-arg RS decoder — Function.length === 8 drives the RS dispatch branch.
84
+ class MockShredFecBuffer {
85
+ free = free;
86
+ constructor(maxSlots: bigint, symbolSize: number) {
87
+ ctorArgs.push([maxSlots, symbolSize]);
88
+ }
89
+ add_shred(
90
+ slot: bigint,
91
+ fecSetIndex: number,
92
+ localIndex: number,
93
+ numData: number,
94
+ numCoding: number,
95
+ isCoding: boolean,
96
+ payload: Uint8Array,
97
+ lastInSet: boolean,
98
+ ): Uint8Array | null {
99
+ addShredCalls.push([
100
+ slot,
101
+ fecSetIndex,
102
+ localIndex,
103
+ numData,
104
+ numCoding,
105
+ isCoding,
106
+ payload,
107
+ lastInSet,
108
+ ]);
109
+ return options.addShredResult ?? null;
110
+ }
111
+ try_recover_all(nowMs: number): Uint8Array | null {
112
+ tryRecoverCalls.push(nowMs);
113
+ return options.recoverResult ?? null;
114
+ }
115
+ pending_fec_sets(): number {
116
+ return options.pending ?? 0;
117
+ }
118
+ }
119
+
120
+ // Only expose the identity drain when the test opts in, so tests that leave
121
+ // `recoverIdentity` undefined exercise the legacy `try_recover_all` fallback
122
+ // (the worker branches on typeof ..._with_identity === "function").
123
+ if (options.recoverIdentity !== undefined) {
124
+ (
125
+ MockShredFecBuffer.prototype as unknown as {
126
+ try_recover_all_with_identity: (nowMs: number) => unknown;
127
+ }
128
+ ).try_recover_all_with_identity = (nowMs: number) => {
129
+ tryRecoverIdentityCalls.push(nowMs);
130
+ const r = options.recoverIdentity;
131
+ if (!r) return null;
132
+ return {
133
+ slot: r.slot,
134
+ fec_set_index: r.fecSetIndex,
135
+ payload: r.payload,
136
+ free: recoveredSetFree,
137
+ };
138
+ };
139
+ }
140
+
141
+ // 5-arg legacy decoder — Function.length === 5 → data-only dispatch branch.
142
+ class MockShredReassembler {
143
+ free = free;
144
+ constructor(maxSlots: bigint, symbolSize: number) {
145
+ ctorArgs.push([maxSlots, symbolSize]);
146
+ }
147
+ add_shred(
148
+ slot: bigint,
149
+ fecSetIndex: number,
150
+ localIndex: number,
151
+ payload: Uint8Array,
152
+ lastInSet: boolean,
153
+ ): Uint8Array | null {
154
+ addShredCalls.push([slot, fecSetIndex, localIndex, payload, lastInSet]);
155
+ return options.addShredResult ?? null;
156
+ }
157
+ pending_fec_sets(): number {
158
+ return options.pending ?? 0;
159
+ }
160
+ }
161
+
162
+ const bindings = options.legacyReassembler
163
+ ? { ShredReassembler: MockShredReassembler }
164
+ : { ShredFecBuffer: MockShredFecBuffer };
165
+
166
+ const workerSelf = {
167
+ __SHRED_WASM_BINDINGS__: bindings,
168
+ onmessage: undefined as
169
+ | ((e: MessageEvent<ShredWorkerCommand>) => void)
170
+ | undefined,
171
+ onmessageerror: undefined as (() => void) | undefined,
172
+ postMessage: vi.fn((event: ShredWorkerEvent) => {
173
+ events.push(event);
174
+ }),
175
+ };
176
+
177
+ vi.stubGlobal("self", workerSelf);
178
+ vi.resetModules();
179
+ await import("../shred-fec-worker.ts");
180
+
181
+ const post = async (cmd: ShredWorkerCommand) => {
182
+ workerSelf.onmessage?.({ data: cmd } as MessageEvent<ShredWorkerCommand>);
183
+ await nextTick();
184
+ await nextTick();
185
+ };
186
+
187
+ // Synchronous dispatch (no await) — for testing back-to-back re-entrant commands.
188
+ const fire = (cmd: ShredWorkerCommand) =>
189
+ workerSelf.onmessage?.({ data: cmd } as MessageEvent<ShredWorkerCommand>);
190
+
191
+ await post({
192
+ type: "configure",
193
+ config: {
194
+ maxSlots: 5000,
195
+ symbolSize: 512,
196
+ statsIntervalMs: 0,
197
+ recoverIntervalMs: 0,
198
+ ...options.config,
199
+ },
200
+ });
201
+
202
+ return {
203
+ events,
204
+ post,
205
+ fire,
206
+ addShredCalls,
207
+ tryRecoverCalls,
208
+ tryRecoverIdentityCalls,
209
+ free,
210
+ recoveredSetFree,
211
+ ctorArgs,
212
+ };
213
+ }
214
+
215
+ afterEach(() => {
216
+ vi.unstubAllGlobals();
217
+ vi.useRealTimers();
218
+ });
219
+
220
+ // --- Tests ------------------------------------------------------------------
221
+
222
+ describe("shred FEC worker (v3 wire)", () => {
223
+ it("constructs the decoder with (BigInt(maxSlots), symbolSize)", async () => {
224
+ const w = await setupWorker({ config: { maxSlots: 5000, symbolSize: 512 } });
225
+ expect(w.ctorArgs).toEqual([[5000n, 512]]);
226
+ await w.post({ type: "dispose" });
227
+ });
228
+
229
+ it("parses a v3 frame and forwards exact fields to add_shred (DATA_COMPLETE → lastInSet)", async () => {
230
+ const w = await setupWorker();
231
+ const payload = new Uint8Array([10, 20, 30]);
232
+ await w.post({
233
+ type: "feedShred",
234
+ frame: makeV3Frame({
235
+ slot: 42n,
236
+ fecSetIndex: 5,
237
+ localIndex: 9,
238
+ numData: 8,
239
+ numCoding: 4,
240
+ isCoding: true,
241
+ dataComplete: true,
242
+ payload,
243
+ }),
244
+ });
245
+ expect(w.addShredCalls).toHaveLength(1);
246
+ const [slot, fecSet, localIdx, numData, numCoding, isCoding, gotPayload, last8] =
247
+ w.addShredCalls[0];
248
+ expect(slot).toBe(42n);
249
+ expect(fecSet).toBe(5);
250
+ expect(localIdx).toBe(9);
251
+ expect(numData).toBe(8);
252
+ expect(numCoding).toBe(4);
253
+ expect(isCoding).toBe(true);
254
+ expect(last8).toBe(true);
255
+ expect(Array.from(gotPayload as Uint8Array)).toEqual([10, 20, 30]);
256
+ await w.post({ type: "dispose" });
257
+ });
258
+
259
+ it("emits a recovered event with metadata when a set completes directly", async () => {
260
+ const recovered = new Uint8Array([0xaa, 0xbb, 0xcc]);
261
+ const w = await setupWorker({ addShredResult: recovered });
262
+ await w.post({
263
+ type: "feedShred",
264
+ frame: makeV3Frame({ slot: 77n, fecSetIndex: 3, sendTsUs: 123456n }),
265
+ });
266
+ const ev = w.events.find((e) => e.type === "recovered");
267
+ if (ev?.type !== "recovered") throw new Error("expected recovered");
268
+ expect(Array.from(new Uint8Array(ev.data))).toEqual([0xaa, 0xbb, 0xcc]);
269
+ expect(ev.meta.slot).toBe(77n);
270
+ expect(ev.meta.fecSetIndex).toBe(3);
271
+ expect(ev.meta.sendTsUs).toBe(123456n);
272
+ expect(ev.meta.recovered).toBe(false);
273
+ await w.post({ type: "dispose" });
274
+ });
275
+
276
+ it("drops a non-v3 frame (version guard) and counts it, not add_shred", async () => {
277
+ const w = await setupWorker({ config: { statsIntervalMs: 10 } });
278
+ await w.post({ type: "feedShred", frame: makeV3Frame({ version: 2 }) });
279
+ expect(w.addShredCalls).toHaveLength(0);
280
+ await new Promise((r) => setTimeout(r, 25));
281
+ const stats = last(w.events.filter((e) => e.type === "stats"));
282
+ if (stats?.type !== "stats") throw new Error("expected stats");
283
+ expect(stats.stats.versionMismatch).toBe(1);
284
+ expect(stats.stats.shredsRx).toBe(0);
285
+ await w.post({ type: "dispose" });
286
+ });
287
+
288
+ it("drops frames shorter than the v3 header and counts them malformed", async () => {
289
+ const w = await setupWorker({ config: { statsIntervalMs: 10 } });
290
+ await w.post({ type: "feedShred", frame: new Uint8Array(10).buffer });
291
+ expect(w.addShredCalls).toHaveLength(0);
292
+ await new Promise((r) => setTimeout(r, 25));
293
+ const stats = last(w.events.filter((e) => e.type === "stats"));
294
+ if (stats?.type !== "stats") throw new Error("expected stats");
295
+ expect(stats.stats.malformed).toBe(1);
296
+ await w.post({ type: "dispose" });
297
+ });
298
+
299
+ it("dispatches to the 5-arg legacy reassembler and drops coding shreds", async () => {
300
+ const w = await setupWorker({ legacyReassembler: true });
301
+ // Data shred → forwarded with 5 args.
302
+ await w.post({ type: "feedShred", frame: makeV3Frame({ isCoding: false }) });
303
+ // Coding shred → dropped by the legacy path (can't use it).
304
+ await w.post({ type: "feedShred", frame: makeV3Frame({ isCoding: true }) });
305
+ expect(w.addShredCalls).toHaveLength(1);
306
+ expect(w.addShredCalls[0]).toHaveLength(5); // 5-arg legacy signature
307
+ await w.post({ type: "dispose" });
308
+ });
309
+
310
+ it("errors when feedShred arrives before configure", async () => {
311
+ const events: ShredWorkerEvent[] = [];
312
+ const workerSelf = {
313
+ __SHRED_WASM_BINDINGS__: {
314
+ ShredFecBuffer: class {
315
+ add_shred() {
316
+ return null;
317
+ }
318
+ try_recover_all() {
319
+ return null;
320
+ }
321
+ pending_fec_sets() {
322
+ return 0;
323
+ }
324
+ },
325
+ },
326
+ onmessage: undefined as
327
+ | ((e: MessageEvent<ShredWorkerCommand>) => void)
328
+ | undefined,
329
+ postMessage: vi.fn((e: ShredWorkerEvent) => events.push(e)),
330
+ };
331
+ vi.stubGlobal("self", workerSelf);
332
+ vi.resetModules();
333
+ await import("../shred-fec-worker.ts");
334
+ workerSelf.onmessage?.({
335
+ data: { type: "feedShred", frame: makeV3Frame() },
336
+ } as MessageEvent<ShredWorkerCommand>);
337
+ await nextTick();
338
+ const err = events.find((e) => e.type === "error");
339
+ expect(err?.type === "error" && err.code).toBe("not-configured");
340
+ });
341
+
342
+ it("recoverAll drives try_recover_all and emits recovered:true", async () => {
343
+ const late = new Uint8Array([1, 2]);
344
+ const w = await setupWorker({ recoverResult: late });
345
+ await w.post({ type: "recoverAll", nowMs: 1000 });
346
+ expect(w.tryRecoverCalls).toEqual([1000]);
347
+ const ev = w.events.find((e) => e.type === "recovered");
348
+ if (ev?.type !== "recovered") throw new Error("expected recovered");
349
+ expect(ev.meta.recovered).toBe(true);
350
+ expect(ev.meta.slot).toBe(0n);
351
+ await w.post({ type: "dispose" });
352
+ });
353
+
354
+ it("recover path attributes ingest-recorded send_ts to the recovered set", async () => {
355
+ const payload = new Uint8Array([9, 8, 7]);
356
+ // Identity drain present: worker prefers it and attributes the send_ts it
357
+ // recorded at ingest for (slot 55, fecSet 2) rather than emitting 0n.
358
+ const w = await setupWorker({
359
+ recoverIdentity: { slot: 55n, fecSetIndex: 2, payload },
360
+ });
361
+ // Feed a data shred for the set so its send_ts is on record; addShredResult
362
+ // defaults to null, so the set does NOT complete inline — it must recover.
363
+ await w.post({
364
+ type: "feedShred",
365
+ frame: makeV3Frame({ slot: 55n, fecSetIndex: 2, sendTsUs: 999000n }),
366
+ });
367
+ expect(w.events.some((e) => e.type === "recovered")).toBe(false);
368
+ await w.post({ type: "recoverAll", nowMs: 2000 });
369
+ expect(w.tryRecoverIdentityCalls).toEqual([2000]);
370
+ expect(w.tryRecoverCalls).toEqual([]); // identity path taken, not the legacy one
371
+ const ev = w.events.find((e) => e.type === "recovered");
372
+ if (ev?.type !== "recovered") throw new Error("expected recovered");
373
+ expect(Array.from(new Uint8Array(ev.data))).toEqual([9, 8, 7]);
374
+ expect(ev.meta.slot).toBe(55n);
375
+ expect(ev.meta.fecSetIndex).toBe(2);
376
+ expect(ev.meta.sendTsUs).toBe(999000n);
377
+ expect(ev.meta.recovered).toBe(true);
378
+ expect(w.recoveredSetFree).toHaveBeenCalledTimes(1); // WASM-owned set freed
379
+ await w.post({ type: "dispose" });
380
+ });
381
+
382
+ it("batch-drains a transferred ReadableStream of frames", async () => {
383
+ const recovered = new Uint8Array([5]);
384
+ const w = await setupWorker({ addShredResult: recovered });
385
+ const frames = [
386
+ makeV3Frame({ slot: 1n }),
387
+ makeV3Frame({ slot: 2n }),
388
+ makeV3Frame({ slot: 3n }),
389
+ ];
390
+ const readable = new ReadableStream<Uint8Array>({
391
+ start(controller) {
392
+ for (const f of frames) controller.enqueue(new Uint8Array(f));
393
+ controller.close();
394
+ },
395
+ });
396
+ await w.post({ type: "attachStream", readable });
397
+ await new Promise((r) => setTimeout(r, 20));
398
+ expect(w.addShredCalls).toHaveLength(3);
399
+ expect(w.events.filter((e) => e.type === "recovered")).toHaveLength(3);
400
+ await w.post({ type: "dispose" });
401
+ });
402
+
403
+ it("emits a coalesced stats heartbeat with counters + slot samples", async () => {
404
+ const w = await setupWorker({ pending: 4, config: { statsIntervalMs: 15 } });
405
+ await w.post({ type: "feedShred", frame: makeV3Frame({ slot: 100n, sendTsUs: 555n, isCoding: false }) });
406
+ await w.post({ type: "feedShred", frame: makeV3Frame({ slot: 100n, isCoding: true }) });
407
+ await w.post({ type: "feedShred", frame: makeV3Frame({ slot: 101n, sendTsUs: 777n, isCoding: false }) });
408
+ await new Promise((r) => setTimeout(r, 30));
409
+ const statsEvents = w.events.filter((e) => e.type === "stats");
410
+ const stats = last(statsEvents);
411
+ if (stats?.type !== "stats") throw new Error("expected stats");
412
+ expect(stats.stats.shredsRx).toBe(3);
413
+ expect(stats.stats.sourceShreds).toBe(2);
414
+ expect(stats.stats.repairShreds).toBe(1);
415
+ expect(stats.stats.pendingFecSets).toBe(4);
416
+ expect(stats.stats.currentSlot).toBe("101");
417
+ // Samples drain per-heartbeat; a consumer aggregates across heartbeats.
418
+ // One sample per distinct slot (first shred of each), not per shred.
419
+ const allSamples = statsEvents.flatMap((e) =>
420
+ e.type === "stats" ? e.stats.slotSamples : [],
421
+ );
422
+ expect(allSamples.map((s) => [s.slot, s.sendTsUs])).toEqual([
423
+ [100n, 555n],
424
+ [101n, 777n],
425
+ ]);
426
+ await w.post({ type: "dispose" });
427
+ });
428
+
429
+ it("frees the decoder on dispose", async () => {
430
+ const w = await setupWorker();
431
+ await w.post({ type: "dispose" });
432
+ expect(w.free).toHaveBeenCalled();
433
+ });
434
+
435
+ it("guards re-entrant configure(): the superseded call bails before constructing a decoder", async () => {
436
+ // After the harness's initial configure (1 ctor), fire two more back-to-back
437
+ // without awaiting. The first of the two is superseded mid-load by the second,
438
+ // so it must bail before `new Ctor` — only the winner constructs (1 more ctor,
439
+ // not 2), and no orphaned timer is armed.
440
+ const w = await setupWorker({ config: { statsIntervalMs: 0, recoverIntervalMs: 0 } });
441
+ const cfg = { maxSlots: 5000, symbolSize: 512, statsIntervalMs: 0, recoverIntervalMs: 0 };
442
+ w.fire({ type: "configure", config: cfg });
443
+ w.fire({ type: "configure", config: cfg });
444
+ await new Promise((r) => setTimeout(r, 10));
445
+ expect(w.ctorArgs).toHaveLength(2); // initial + winner; the superseded call bailed
446
+ await w.post({ type: "dispose" });
447
+ });
448
+ });