@blockcast/fec-worker 0.1.0 → 0.3.0-main.d9354616fdf2

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 (40) hide show
  1. package/README.md +17 -7
  2. package/dist/block-order.d.ts +38 -0
  3. package/dist/block-order.d.ts.map +1 -0
  4. package/dist/block-order.js +78 -0
  5. package/dist/block-order.js.map +1 -0
  6. package/dist/fec-worker-client.d.ts +20 -7
  7. package/dist/fec-worker-client.d.ts.map +1 -1
  8. package/dist/fec-worker-client.js +106 -30
  9. package/dist/fec-worker-client.js.map +1 -1
  10. package/dist/fec-worker-types.d.ts +153 -25
  11. package/dist/fec-worker-types.d.ts.map +1 -1
  12. package/dist/fec-worker.js +382 -88
  13. package/dist/fec-worker.js.map +4 -4
  14. package/dist/index.d.ts +1 -1
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js.map +1 -1
  17. package/dist/shred-fec-worker.js +23 -2
  18. package/dist/shred-fec-worker.js.map +2 -2
  19. package/dist/shred-worker-types.d.ts +16 -1
  20. package/dist/shred-worker-types.d.ts.map +1 -1
  21. package/dist/shred-worker-types.js +16 -1
  22. package/dist/shred-worker-types.js.map +1 -1
  23. package/package.json +8 -3
  24. package/src/block-order.ts +93 -0
  25. package/src/fec-worker-client.ts +115 -32
  26. package/src/fec-worker-types.ts +152 -21
  27. package/src/fec-worker.ts +583 -129
  28. package/src/index.ts +3 -0
  29. package/src/shred-fec-worker.ts +34 -3
  30. package/src/shred-worker-types.ts +16 -1
  31. package/src/__tests__/fec-worker-alta.test.ts +0 -231
  32. package/src/__tests__/fec-worker-cleanup-integration.test.ts +0 -250
  33. package/src/__tests__/fec-worker-pending-queue.test.ts +0 -315
  34. package/src/__tests__/fec-worker-repair-size.test.ts +0 -1029
  35. package/src/__tests__/fec-worker-wasm-contract.test.ts +0 -84
  36. package/src/__tests__/shred-fec-worker.test.ts +0 -448
  37. package/src/fec-worker-client.test.ts +0 -530
  38. package/src/fec-worker-types.test.ts +0 -400
  39. package/src/shred-worker-client.test.ts +0 -182
  40. package/src/transfer.test.ts +0 -24
package/src/index.ts CHANGED
@@ -6,8 +6,11 @@
6
6
 
7
7
  export type {
8
8
  FecWorkerCommand,
9
+ FecSymbolCommand,
10
+ FecWorkerOptionalCommand,
9
11
  FecWorkerEvent,
10
12
  FecWorkerErrorCode,
13
+ FecBlockProfile,
11
14
  FecTrackConfig,
12
15
  FecTrackStats,
13
16
  FecBlockSnapshot,
@@ -28,6 +28,7 @@ import type {
28
28
  } from "./shred-worker-types.js";
29
29
  import {
30
30
  SHRED_WIRE_VERSION,
31
+ SHRED_WIRE_VERSION_FULL,
31
32
  SHRED_HEADER_LEN,
32
33
  SHRED_FLAG_IS_CODING,
33
34
  SHRED_FLAG_DATA_COMPLETE,
@@ -216,10 +217,33 @@ function postStats(): void {
216
217
 
217
218
  // --- Wire parse + decode ----------------------------------------------------
218
219
 
220
+ /**
221
+ * The v3 erasure-shard view of a v4 body (a full canonical shred), or null when
222
+ * the body is not a shred the forwarder could have emitted. Mirrors the
223
+ * forwarder's `parse_shred()` checks and `erasure_shard()` slice exactly, so a
224
+ * v4 frame reaches `add_shred` byte-identical to its v3 twin. A view, not a
225
+ * copy: the hot path allocates nothing extra for v4.
226
+ */
227
+ function erasureShardOfFullShred(shred: Uint8Array, isCoding: boolean): Uint8Array | null {
228
+ if (shred.byteLength !== (isCoding ? 1228 : 1203)) return null;
229
+ const variant = shred[64];
230
+ const family = variant & 0xf0;
231
+ const variantIsCoding = family === 0x60 || family === 0x70;
232
+ const variantIsData = family === 0x90 || family === 0xb0;
233
+ if (isCoding ? !variantIsCoding : !variantIsData) return null;
234
+ const resigned = family === 0xb0 || family === 0x70;
235
+ const trailer = 32 + (variant & 0x0f) * 20 + (resigned ? 64 : 0);
236
+ const start = isCoding ? 89 : 64;
237
+ const end = shred.byteLength - trailer;
238
+ if (end <= start) return null;
239
+ return shred.subarray(start, end);
240
+ }
241
+
219
242
  /**
220
243
  * Parse one raw shred frame and feed the decoder. Mirrors the deployed player's
221
244
  * `onShredBytes` (v3 wire, version-guarded) so the off-thread path is
222
- * bit-identical to the proven on-thread path. Emits a `recovered` event when a
245
+ * bit-identical to the proven on-thread path, and accepts v4 by reducing its
246
+ * full shred to that same v3 erasure shard. Emits a `recovered` event when a
223
247
  * set completes directly.
224
248
  */
225
249
  function ingestFrame(frame: Uint8Array): void {
@@ -228,7 +252,8 @@ function ingestFrame(frame: Uint8Array): void {
228
252
  return;
229
253
  }
230
254
  const dv = new DataView(frame.buffer, frame.byteOffset, frame.byteLength);
231
- if (dv.getUint8(0) !== SHRED_WIRE_VERSION) {
255
+ const version = dv.getUint8(0);
256
+ if (version !== SHRED_WIRE_VERSION && version !== SHRED_WIRE_VERSION_FULL) {
232
257
  // Forwarder/worker wire-version skew — drop rather than misframe every shred.
233
258
  stats.versionMismatch++;
234
259
  return;
@@ -242,7 +267,13 @@ function ingestFrame(frame: Uint8Array): void {
242
267
  const numData = dv.getUint8(18);
243
268
  const numCoding = dv.getUint8(19);
244
269
  const sendTsUs = dv.getBigUint64(20, true);
245
- const payload = frame.subarray(SHRED_HEADER_LEN);
270
+ const body = frame.subarray(SHRED_HEADER_LEN);
271
+ const payload =
272
+ version === SHRED_WIRE_VERSION ? body : erasureShardOfFullShred(body, isCoding);
273
+ if (payload === null) {
274
+ stats.malformed++;
275
+ return;
276
+ }
246
277
 
247
278
  stats.shredsRx++;
248
279
  if (isCoding) stats.repairShreds++;
@@ -28,7 +28,7 @@
28
28
  * path is bit-identical to the proven on-thread path.
29
29
  *
30
30
  * v3 (28-byte header):
31
- * [0] version u8 (== 3; non-3 frames are dropped, not misframed)
31
+ * [0] version u8 (3 or 4; any other version is dropped, not misframed)
32
32
  * [1-8] slot u64 LE
33
33
  * [9-12] fecSetIndex u32 LE
34
34
  * [13-16] localIndex u32 LE (data: 0..numData; coding: numData+position)
@@ -41,8 +41,23 @@
41
41
  * DATA_COMPLETE (last data shred in the set) is passed to `add_shred` as its
42
42
  * `lastInSet` argument — the WASM uses it to infer numData for a set that
43
43
  * received only data shreds (no coding).
44
+ *
45
+ * v4 has the same 28-byte header, but its body is the full canonical Agave
46
+ * shred (signature, headers, erasure shard, chained Merkle root, proof, and
47
+ * the retransmitter signature on resigned variants) — the form a validator TVU
48
+ * accepts (BLO-41383). The worker recovers the exact v3 erasure-shard view
49
+ * from it with the forwarder's own `erasure_shard()` slice, so both versions
50
+ * reach `add_shred` byte-identical:
51
+ * variant = shred[64]; data 0x9_/0xb_, coding 0x6_/0x7_ (chained merkle;
52
+ * 0xb_/0x7_ resigned), must agree with IS_CODING_SHRED
53
+ * length = 1203 (data) / 1228 (coding), exactly
54
+ * shard start = 64 (data: after the signature) / 89 (coding headers)
55
+ * shard end = length - (32 root + 20 * (variant & 0x0f) proof
56
+ * + 64 retransmitter signature if resigned)
44
57
  */
45
58
  export const SHRED_WIRE_VERSION = 3;
59
+ /** Wire version whose body is the full canonical shred (see above). */
60
+ export const SHRED_WIRE_VERSION_FULL = 4;
46
61
  export const SHRED_HEADER_LEN = 28;
47
62
  export const SHRED_FLAG_DATA_COMPLETE = 0x01;
48
63
  export const SHRED_FLAG_IS_CODING = 0x02;
@@ -1,231 +0,0 @@
1
- /**
2
- * Unit tests for ALTA verification behavior in FEC Worker.
3
- *
4
- * These tests validate the verification logic rules from D-05:
5
- * - altaVerified increments on valid packets
6
- * - altaFailed increments on invalid packets
7
- * - Packets are NEVER dropped regardless of ALTA outcome (count + play)
8
- * - FrameMeta.altaVerified reflects actual result (true/false/null)
9
- *
10
- * The FEC Worker runs in DedicatedWorkerGlobalScope. We test the
11
- * verification logic by directly simulating the branching behavior
12
- * documented in fec-worker.ts (lines ~236-263).
13
- *
14
- * Note: Full Worker integration tests (with postMessage) require a DOM
15
- * environment. These unit tests focus on the verification logic rules only.
16
- */
17
-
18
- import { describe, it, expect, vi } from "vitest";
19
-
20
- // --- Mock JsAltaVerifier interface (mirrors alta_rs.d.ts) ---
21
-
22
- interface MockAltaVerifyResult {
23
- valid: boolean;
24
- sequence_number: number;
25
- payload?: Uint8Array;
26
- error?: string | null;
27
- }
28
-
29
- interface MockJsAltaVerifier {
30
- verify(packet: Uint8Array): MockAltaVerifyResult;
31
- free(): void;
32
- }
33
-
34
- function makeVerifier(valid: boolean, throws?: boolean): MockJsAltaVerifier {
35
- return {
36
- verify(_packet: Uint8Array): MockAltaVerifyResult {
37
- if (throws) throw new Error("WASM error");
38
- return {
39
- valid,
40
- sequence_number: 1,
41
- payload: valid ? new Uint8Array(100) : undefined,
42
- error: valid ? null : "invalid signature",
43
- };
44
- },
45
- free: vi.fn(),
46
- };
47
- }
48
-
49
- // --- Simulate the fec-worker verification logic ---
50
- // Extracted from fec-worker.ts handleFeedSource() body.
51
- // This mirrors the real implementation for unit-testability without a Worker context.
52
-
53
- interface VerifyState {
54
- altaEnabled: boolean;
55
- altaVerifier: MockJsAltaVerifier | null;
56
- altaVerified: number;
57
- altaFailed: number;
58
- }
59
-
60
- interface VerifyResult {
61
- symbolAltaVerified: boolean | null;
62
- packetDropped: boolean; // Must always be false (D-05)
63
- }
64
-
65
- function simulateVerification(
66
- state: VerifyState,
67
- packet: Uint8Array,
68
- ): VerifyResult {
69
- let symbolAltaVerified: boolean | null = null;
70
- let packetDropped = false;
71
-
72
- if (state.altaEnabled && state.altaVerifier) {
73
- try {
74
- const result = state.altaVerifier.verify(packet);
75
- if (result.valid) {
76
- state.altaVerified++;
77
- symbolAltaVerified = true;
78
- } else {
79
- state.altaFailed++;
80
- symbolAltaVerified = false;
81
- // D-05: count + play — do NOT return or drop the packet
82
- }
83
- } catch (_e) {
84
- state.altaFailed++;
85
- symbolAltaVerified = false;
86
- // WASM error: packet still passes through (D-05)
87
- }
88
- } else if (state.altaEnabled && !state.altaVerifier) {
89
- // Public key present but WASM init failed — not a tamper indicator
90
- symbolAltaVerified = null;
91
- }
92
- // If not altaEnabled: symbolAltaVerified stays null
93
-
94
- return { symbolAltaVerified, packetDropped };
95
- }
96
-
97
- // --- Tests ---
98
-
99
- describe("ALTA verification in FEC Worker (D-05 compliance)", () => {
100
- const packet = new Uint8Array(1320);
101
-
102
- it("Case 1: altaEnabled=false — no verification attempted, counters stay 0", () => {
103
- const state: VerifyState = {
104
- altaEnabled: false,
105
- altaVerifier: makeVerifier(true),
106
- altaVerified: 0,
107
- altaFailed: 0,
108
- };
109
- const result = simulateVerification(state, packet);
110
- expect(result.symbolAltaVerified).toBeNull();
111
- expect(result.packetDropped).toBe(false);
112
- expect(state.altaVerified).toBe(0);
113
- expect(state.altaFailed).toBe(0);
114
- });
115
-
116
- it("Case 2: altaEnabled=true, valid packet — altaVerified increments", () => {
117
- const state: VerifyState = {
118
- altaEnabled: true,
119
- altaVerifier: makeVerifier(true),
120
- altaVerified: 0,
121
- altaFailed: 0,
122
- };
123
- const result = simulateVerification(state, packet);
124
- expect(result.symbolAltaVerified).toBe(true);
125
- expect(result.packetDropped).toBe(false);
126
- expect(state.altaVerified).toBe(1);
127
- expect(state.altaFailed).toBe(0);
128
- });
129
-
130
- it("Case 3: altaEnabled=true, invalid packet — altaFailed increments, packet NOT dropped (D-05)", () => {
131
- const state: VerifyState = {
132
- altaEnabled: true,
133
- altaVerifier: makeVerifier(false),
134
- altaVerified: 0,
135
- altaFailed: 0,
136
- };
137
- const result = simulateVerification(state, packet);
138
- expect(result.symbolAltaVerified).toBe(false);
139
- expect(result.packetDropped).toBe(false); // D-05: count + play
140
- expect(state.altaVerified).toBe(0);
141
- expect(state.altaFailed).toBe(1);
142
- });
143
-
144
- it("Case 4: altaEnabled=true, altaVerifier=null (WASM init failed) — no crash, no altaFailed increment", () => {
145
- const state: VerifyState = {
146
- altaEnabled: true,
147
- altaVerifier: null, // WASM init failed
148
- altaVerified: 0,
149
- altaFailed: 0,
150
- };
151
- const result = simulateVerification(state, packet);
152
- // altaVerified is null (unverified due to init failure, not tamper)
153
- expect(result.symbolAltaVerified).toBeNull();
154
- expect(result.packetDropped).toBe(false);
155
- expect(state.altaVerified).toBe(0);
156
- // CRITICAL: altaFailed must NOT increment for init failures (not a tamper indicator)
157
- expect(state.altaFailed).toBe(0);
158
- });
159
-
160
- it("Case 5: altaVerifier.verify() throws — altaFailed increments, packet still passes (D-05)", () => {
161
- const state: VerifyState = {
162
- altaEnabled: true,
163
- altaVerifier: makeVerifier(true, /* throws */ true),
164
- altaVerified: 0,
165
- altaFailed: 0,
166
- };
167
- const result = simulateVerification(state, packet);
168
- expect(result.symbolAltaVerified).toBe(false);
169
- expect(result.packetDropped).toBe(false); // D-05: even on WASM error
170
- expect(state.altaVerified).toBe(0);
171
- expect(state.altaFailed).toBe(1);
172
- });
173
-
174
- it("Case 6: FrameMeta.altaVerified reflects actual result (true/false/null)", () => {
175
- // Valid packet → true
176
- const stateValid: VerifyState = { altaEnabled: true, altaVerifier: makeVerifier(true), altaVerified: 0, altaFailed: 0 };
177
- expect(simulateVerification(stateValid, packet).symbolAltaVerified).toBe(true);
178
-
179
- // Invalid packet → false
180
- const stateInvalid: VerifyState = { altaEnabled: true, altaVerifier: makeVerifier(false), altaVerified: 0, altaFailed: 0 };
181
- expect(simulateVerification(stateInvalid, packet).symbolAltaVerified).toBe(false);
182
-
183
- // ALTA not enabled → null
184
- const stateDisabled: VerifyState = { altaEnabled: false, altaVerifier: makeVerifier(true), altaVerified: 0, altaFailed: 0 };
185
- expect(simulateVerification(stateDisabled, packet).symbolAltaVerified).toBeNull();
186
-
187
- // ALTA enabled but WASM init failed → null (not false)
188
- const stateNoWasm: VerifyState = { altaEnabled: true, altaVerifier: null, altaVerified: 0, altaFailed: 0 };
189
- expect(simulateVerification(stateNoWasm, packet).symbolAltaVerified).toBeNull();
190
- });
191
-
192
- it("Multiple valid packets — altaVerified accumulates correctly", () => {
193
- const state: VerifyState = {
194
- altaEnabled: true,
195
- altaVerifier: makeVerifier(true),
196
- altaVerified: 0,
197
- altaFailed: 0,
198
- };
199
- for (let i = 0; i < 32; i++) {
200
- simulateVerification(state, packet);
201
- }
202
- expect(state.altaVerified).toBe(32);
203
- expect(state.altaFailed).toBe(0);
204
- });
205
-
206
- it("Mixed valid/invalid packets — counters track independently", () => {
207
- const state: VerifyState = {
208
- altaEnabled: true,
209
- altaVerifier: null as unknown as MockJsAltaVerifier,
210
- altaVerified: 0,
211
- altaFailed: 0,
212
- };
213
- // 20 valid, 5 invalid, 3 throws
214
- let callCount = 0;
215
- state.altaVerifier = {
216
- verify(_p: Uint8Array): MockAltaVerifyResult {
217
- callCount++;
218
- if (callCount <= 20) return { valid: true, sequence_number: callCount };
219
- if (callCount <= 25) return { valid: false, sequence_number: callCount, error: "tampered" };
220
- throw new Error("WASM failure");
221
- },
222
- free: vi.fn(),
223
- };
224
-
225
- for (let i = 0; i < 28; i++) {
226
- simulateVerification(state, packet);
227
- }
228
- expect(state.altaVerified).toBe(20);
229
- expect(state.altaFailed).toBe(8); // 5 invalid + 3 throws
230
- });
231
- });
@@ -1,250 +0,0 @@
1
- import { afterEach, describe, expect, it, vi } from "vitest";
2
- import { FecWorkerClient } from "../fec-worker-client.js";
3
- import type {
4
- FecWorkerCommand,
5
- FecWorkerEvent,
6
- FecTrackConfig,
7
- } from "../fec-worker-types.js";
8
-
9
- const EMPTY_WASM_MODULE = new WebAssembly.Module(
10
- new Uint8Array([0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00]),
11
- );
12
-
13
- const 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
- function frameCleanupRecords(
34
- records: Array<{ sbn: number; data: Uint8Array }>,
35
- ): Uint8Array {
36
- const size = records.reduce((total, record) => total + 8 + record.data.byteLength, 0);
37
- const framed = new Uint8Array(size);
38
- const view = new DataView(framed.buffer);
39
- let offset = 0;
40
- for (const record of records) {
41
- view.setUint32(offset, record.sbn, false);
42
- view.setUint32(offset + 4, record.data.byteLength, false);
43
- offset += 8;
44
- framed.set(record.data, offset);
45
- offset += record.data.byteLength;
46
- }
47
- return framed;
48
- }
49
-
50
- afterEach(() => {
51
- vi.unstubAllGlobals();
52
- });
53
-
54
- describe("FEC worker cleanup integration", () => {
55
- it("keeps per-SBN green-fill ownership through a coalesced client/worker round trip", async () => {
56
- const fill16 = new Uint8Array([0, 0, 0, 1, 0x0d]);
57
- const fill18 = new Uint8Array([0, 0, 0, 1, 0x0d]);
58
- let cleanupPass = 0;
59
- const cleanupBlocksDetailed = vi.fn((sbns: Uint32Array) => {
60
- cleanupPass++;
61
- return cleanupPass === 1
62
- ? new Uint8Array()
63
- : frameCleanupRecords([
64
- ...Array.from(sbns, (sbn) => ({
65
- sbn,
66
- data: sbn === 16 ? fill16 : fill18,
67
- })),
68
- ]);
69
- });
70
-
71
- class MockDecoder {
72
- static last: MockDecoder | undefined;
73
- constructor() {
74
- MockDecoder.last = this;
75
- }
76
- add_source = vi.fn(() => undefined);
77
- add_repair = vi.fn(() => undefined);
78
- configure_params = vi.fn();
79
- cleanup_blocks_detailed = cleanupBlocksDetailed;
80
- cleanup_by_sbn = vi.fn(() => new Uint32Array());
81
- flush = vi.fn();
82
- pending_blocks = vi.fn(() => 0);
83
- reset_stats = vi.fn();
84
- get_stats = vi.fn(() => ({
85
- source_packets: 0n,
86
- repair_packets: 0n,
87
- blocks_complete: 0n,
88
- blocks_recovered: 0n,
89
- blocks_failed: 0n,
90
- bytes_recovered: 0n,
91
- recovery_rate: () => 0,
92
- }));
93
- free = vi.fn();
94
- }
95
-
96
- let workerOnMessage:
97
- | ((event: MessageEvent<FecWorkerCommand>) => void)
98
- | undefined;
99
- const workerCommands: FecWorkerCommand[] = [];
100
- const pageWorker = {
101
- onmessage: null as ((event: MessageEvent<FecWorkerEvent>) => void) | null,
102
- onerror: null as ((event: ErrorEvent) => void) | null,
103
- onmessageerror: null as ((event: MessageEvent) => void) | null,
104
- postMessage: vi.fn((command: FecWorkerCommand) => {
105
- workerCommands.push(command);
106
- workerOnMessage?.({ data: command } as MessageEvent<FecWorkerCommand>);
107
- }),
108
- terminate: vi.fn(),
109
- };
110
- const workerScope = {
111
- __MMT_WASM_BINDINGS__: {
112
- initSync: vi.fn(),
113
- MmtFecDecoder: MockDecoder,
114
- },
115
- get onmessage() {
116
- return workerOnMessage;
117
- },
118
- set onmessage(handler) {
119
- workerOnMessage = handler;
120
- },
121
- postMessage: vi.fn((event: FecWorkerEvent) => {
122
- pageWorker.onmessage?.({ data: event } as MessageEvent<FecWorkerEvent>);
123
- }),
124
- };
125
-
126
- vi.stubGlobal("self", workerScope);
127
- vi.resetModules();
128
- await import("../fec-worker.ts");
129
-
130
- const client = new FecWorkerClient(pageWorker as unknown as Worker);
131
- const completed: number[] = [];
132
- const fills: Array<{ sbn: number; data: number[] }> = [];
133
- client.onCleanupComplete((sbn) => completed.push(sbn));
134
- client.onGreenFill((sbn, data) => {
135
- fills.push({ sbn, data: [...new Uint8Array(data)] });
136
- });
137
-
138
- client.configure(CONFIG, EMPTY_WASM_MODULE);
139
- await nextTick();
140
- await nextTick();
141
- client.cleanup(15);
142
- client.cleanup(16);
143
- client.cleanup(18);
144
- for (let i = 0; i < 6; i++) await nextTick();
145
-
146
- expect(
147
- workerCommands
148
- .filter((command) => command.type === "cleanup")
149
- .map((command) => command.type === "cleanup" ? command.sbns : []),
150
- ).toEqual([[15], [16, 18]]);
151
- expect(cleanupBlocksDetailed).toHaveBeenCalledTimes(2);
152
- expect(Array.from(cleanupBlocksDetailed.mock.calls[0]![0])).toEqual([15]);
153
- expect(Array.from(cleanupBlocksDetailed.mock.calls[1]![0])).toEqual([16, 18]);
154
- expect(fills).toEqual([
155
- { sbn: 16, data: [...fill16] },
156
- { sbn: 18, data: [...fill18] },
157
- ]);
158
- expect(completed).toEqual([15, 16, 18]);
159
-
160
- client.dispose();
161
- await nextTick();
162
- await nextTick();
163
- });
164
-
165
- it("keeps reset quarantined after surfacing a decoder reset failure", async () => {
166
- class MockDecoder {
167
- static last: MockDecoder | undefined;
168
- constructor() {
169
- MockDecoder.last = this;
170
- }
171
- add_source = vi.fn(() => undefined);
172
- add_repair = vi.fn(() => undefined);
173
- configure_params = vi.fn();
174
- flush = vi.fn(() => {
175
- throw new Error("flush failed");
176
- });
177
- pending_blocks = vi.fn(() => 0);
178
- reset_stats = vi.fn();
179
- get_stats = vi.fn(() => ({
180
- source_packets: 0n,
181
- repair_packets: 0n,
182
- blocks_complete: 0n,
183
- blocks_recovered: 0n,
184
- blocks_failed: 0n,
185
- bytes_recovered: 0n,
186
- recovery_rate: () => 0,
187
- }));
188
- free = vi.fn();
189
- }
190
-
191
- let workerOnMessage:
192
- | ((event: MessageEvent<FecWorkerCommand>) => void)
193
- | undefined;
194
- const pageWorker = {
195
- onmessage: null as ((event: MessageEvent<FecWorkerEvent>) => void) | null,
196
- onerror: null as ((event: ErrorEvent) => void) | null,
197
- onmessageerror: null as ((event: MessageEvent) => void) | null,
198
- postMessage: vi.fn((command: FecWorkerCommand) => {
199
- workerOnMessage?.({ data: command } as MessageEvent<FecWorkerCommand>);
200
- }),
201
- terminate: vi.fn(),
202
- };
203
- const workerScope = {
204
- __MMT_WASM_BINDINGS__: {
205
- initSync: vi.fn(),
206
- MmtFecDecoder: MockDecoder,
207
- },
208
- get onmessage() {
209
- return workerOnMessage;
210
- },
211
- set onmessage(handler) {
212
- workerOnMessage = handler;
213
- },
214
- postMessage: vi.fn((event: FecWorkerEvent) => {
215
- pageWorker.onmessage?.({ data: event } as MessageEvent<FecWorkerEvent>);
216
- }),
217
- };
218
-
219
- vi.stubGlobal("self", workerScope);
220
- vi.resetModules();
221
- await import("../fec-worker.ts");
222
-
223
- const client = new FecWorkerClient(pageWorker as unknown as Worker);
224
- const errors: Array<{ message: string; code: string | undefined }> = [];
225
- const snapshots: unknown[] = [];
226
- client.onError((message, code) => errors.push({ message, code }));
227
- client.onSnapshot((stats) => snapshots.push(stats));
228
-
229
- client.configure(CONFIG, EMPTY_WASM_MODULE);
230
- await nextTick();
231
- await nextTick();
232
- snapshots.length = 0;
233
-
234
- client.reset();
235
- for (let i = 0; i < 4; i++) await nextTick();
236
-
237
- expect(MockDecoder.last?.flush).toHaveBeenCalledOnce();
238
- expect(errors).toEqual([
239
- {
240
- message: "[FecWorker] reset failed: flush failed",
241
- code: "reset-failed",
242
- },
243
- ]);
244
- expect(snapshots).toHaveLength(0);
245
-
246
- client.dispose();
247
- await nextTick();
248
- await nextTick();
249
- });
250
- });