@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.
- package/README.md +213 -0
- package/dist/.build-stamp +0 -0
- package/dist/fec-worker-client.d.ts +80 -0
- package/dist/fec-worker-client.d.ts.map +1 -0
- package/dist/fec-worker-client.js +270 -0
- package/dist/fec-worker-client.js.map +1 -0
- package/dist/fec-worker-types.d.ts +252 -0
- package/dist/fec-worker-types.d.ts.map +1 -0
- package/dist/fec-worker-types.js +11 -0
- package/dist/fec-worker-types.js.map +1 -0
- package/dist/fec-worker.d.ts +14 -0
- package/dist/fec-worker.d.ts.map +1 -0
- package/dist/fec-worker.js +565 -0
- package/dist/fec-worker.js.map +7 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/shred-fec-worker.d.ts +20 -0
- package/dist/shred-fec-worker.d.ts.map +1 -0
- package/dist/shred-fec-worker.js +349 -0
- package/dist/shred-fec-worker.js.map +7 -0
- package/dist/shred-worker-client.d.ts +104 -0
- package/dist/shred-worker-client.d.ts.map +1 -0
- package/dist/shred-worker-client.js +181 -0
- package/dist/shred-worker-client.js.map +1 -0
- package/dist/shred-worker-types.d.ts +179 -0
- package/dist/shred-worker-types.d.ts.map +1 -0
- package/dist/shred-worker-types.js +47 -0
- package/dist/shred-worker-types.js.map +1 -0
- package/dist/transfer.d.ts +9 -0
- package/dist/transfer.d.ts.map +1 -0
- package/dist/transfer.js +13 -0
- package/dist/transfer.js.map +1 -0
- package/package.json +54 -0
- package/src/__tests__/fec-worker-alta.test.ts +231 -0
- package/src/__tests__/fec-worker-cleanup-integration.test.ts +250 -0
- package/src/__tests__/fec-worker-pending-queue.test.ts +315 -0
- package/src/__tests__/fec-worker-repair-size.test.ts +1029 -0
- package/src/__tests__/fec-worker-wasm-contract.test.ts +84 -0
- package/src/__tests__/shred-fec-worker.test.ts +448 -0
- package/src/fec-worker-client.test.ts +530 -0
- package/src/fec-worker-client.ts +309 -0
- package/src/fec-worker-types.test.ts +400 -0
- package/src/fec-worker-types.ts +268 -0
- package/src/fec-worker.ts +1048 -0
- package/src/index.ts +32 -0
- package/src/shred-fec-worker.ts +558 -0
- package/src/shred-worker-client.test.ts +182 -0
- package/src/shred-worker-client.ts +222 -0
- package/src/shred-worker-types.ts +194 -0
- package/src/transfer.test.ts +24 -0
- 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
|
+
});
|