@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.
- package/README.md +17 -7
- package/dist/block-order.d.ts +38 -0
- package/dist/block-order.d.ts.map +1 -0
- package/dist/block-order.js +78 -0
- package/dist/block-order.js.map +1 -0
- package/dist/fec-worker-client.d.ts +20 -7
- package/dist/fec-worker-client.d.ts.map +1 -1
- package/dist/fec-worker-client.js +106 -30
- package/dist/fec-worker-client.js.map +1 -1
- package/dist/fec-worker-types.d.ts +153 -25
- package/dist/fec-worker-types.d.ts.map +1 -1
- package/dist/fec-worker.js +382 -88
- package/dist/fec-worker.js.map +4 -4
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/shred-fec-worker.js +23 -2
- package/dist/shred-fec-worker.js.map +2 -2
- package/dist/shred-worker-types.d.ts +16 -1
- package/dist/shred-worker-types.d.ts.map +1 -1
- package/dist/shred-worker-types.js +16 -1
- package/dist/shred-worker-types.js.map +1 -1
- package/package.json +8 -3
- package/src/block-order.ts +93 -0
- package/src/fec-worker-client.ts +115 -32
- package/src/fec-worker-types.ts +152 -21
- package/src/fec-worker.ts +583 -129
- package/src/index.ts +3 -0
- package/src/shred-fec-worker.ts +34 -3
- package/src/shred-worker-types.ts +16 -1
- package/src/__tests__/fec-worker-alta.test.ts +0 -231
- package/src/__tests__/fec-worker-cleanup-integration.test.ts +0 -250
- package/src/__tests__/fec-worker-pending-queue.test.ts +0 -315
- package/src/__tests__/fec-worker-repair-size.test.ts +0 -1029
- package/src/__tests__/fec-worker-wasm-contract.test.ts +0 -84
- package/src/__tests__/shred-fec-worker.test.ts +0 -448
- package/src/fec-worker-client.test.ts +0 -530
- package/src/fec-worker-types.test.ts +0 -400
- package/src/shred-worker-client.test.ts +0 -182
- package/src/transfer.test.ts +0 -24
package/src/index.ts
CHANGED
package/src/shred-fec-worker.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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
|
-
});
|