@blockcast/fec-worker 0.3.0 → 0.4.0-main.86f861ac235a
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 +15 -20
- package/dist/block-order.d.ts +3 -19
- package/dist/block-order.d.ts.map +1 -1
- package/dist/block-order.js +3 -33
- package/dist/block-order.js.map +1 -1
- package/dist/fec-worker-client.d.ts +6 -16
- package/dist/fec-worker-client.d.ts.map +1 -1
- package/dist/fec-worker-client.js +10 -38
- package/dist/fec-worker-client.js.map +1 -1
- package/dist/fec-worker-types.d.ts +47 -101
- package/dist/fec-worker-types.d.ts.map +1 -1
- package/dist/fec-worker-types.js +2 -2
- package/dist/fec-worker.js +26 -187
- package/dist/fec-worker.js.map +2 -2
- package/package.json +4 -7
- package/src/block-order.ts +3 -37
- package/src/fec-worker-client.ts +8 -40
- package/src/fec-worker-types.ts +47 -101
- package/src/fec-worker.ts +49 -454
package/src/fec-worker.ts
CHANGED
|
@@ -22,8 +22,6 @@ import type {
|
|
|
22
22
|
} from "./fec-worker-types.js";
|
|
23
23
|
import { toOwnedArrayBuffer } from "./transfer.js";
|
|
24
24
|
import {
|
|
25
|
-
blockPrecedes,
|
|
26
|
-
blocksBehind,
|
|
27
25
|
isoBlockContains,
|
|
28
26
|
isoBlocksBehind,
|
|
29
27
|
isoBlocksOverlap,
|
|
@@ -36,10 +34,9 @@ import {
|
|
|
36
34
|
// The decoder derives SBN/ESI internally — do not pre-compute.
|
|
37
35
|
interface WasmFecDecoder {
|
|
38
36
|
/**
|
|
39
|
-
* ISO 23008-1 §C.5.2: ss_id is the flat 32-bit Source Symbol ID
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
* holds it until a repair names the block containing it.
|
|
37
|
+
* ISO 23008-1 §C.5.2: ss_id is the flat 32-bit Source Symbol ID; data is
|
|
38
|
+
* the protected packet, unpadded. The decoder builds the ssbg_mode1 symbol
|
|
39
|
+
* and holds it until a repair names the block containing it.
|
|
43
40
|
*/
|
|
44
41
|
add_source(
|
|
45
42
|
ss_id: number,
|
|
@@ -47,11 +44,9 @@ interface WasmFecDecoder {
|
|
|
47
44
|
timestamp: number,
|
|
48
45
|
): Uint8Array | undefined;
|
|
49
46
|
/**
|
|
50
|
-
* ISO 23008-1 §C.5.3
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
* ESI = ssb_length + rs_id; a recovered block is ssb_length x T bytes of
|
|
54
|
-
* ssbg_mode1 symbols.
|
|
47
|
+
* ISO 23008-1 §C.5.3: the block is `[ss_start, ss_start + ssb_length)`
|
|
48
|
+
* with K' = ssb_length and repair ESI = ssb_length + rs_id; a recovered
|
|
49
|
+
* block is ssb_length x T bytes of ssbg_mode1 symbols.
|
|
55
50
|
*/
|
|
56
51
|
add_repair(
|
|
57
52
|
ss_start: number,
|
|
@@ -68,14 +63,12 @@ interface WasmFecDecoder {
|
|
|
68
63
|
*/
|
|
69
64
|
cleanup_blocks_detailed(sbns: Uint32Array): void;
|
|
70
65
|
/**
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
*
|
|
77
|
-
* more than interleaveDepth `isoBlocksBehind` the live SS_ID; returns the
|
|
78
|
-
* SS_Starts of the blocks it removed.
|
|
66
|
+
* Remove decoder blocks, and held unplaced sources, more than
|
|
67
|
+
* interleaveDepth `isoBlocksBehind` the live SS_ID; returns the SS_Starts
|
|
68
|
+
* of the blocks it removed. It runs the decoder's `retire_unplaced` sweep
|
|
69
|
+
* itself, so the Worker never calls that separately: synchronizeDecoderHorizon
|
|
70
|
+
* calls this after every source and repair it accepts, which is what keeps
|
|
71
|
+
* the decoder's unplaced pool bounded when a block's repairs never arrive.
|
|
79
72
|
*/
|
|
80
73
|
cleanup_by_ss_start(current: number): Uint32Array;
|
|
81
74
|
/** The block profile the decoder was constructed with. */
|
|
@@ -104,9 +97,6 @@ interface WasmBindings {
|
|
|
104
97
|
|
|
105
98
|
let wasmDecoder: WasmFecDecoder | null = null;
|
|
106
99
|
let config: FecTrackConfig | null = null;
|
|
107
|
-
let altaEnabled = false;
|
|
108
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
109
|
-
let altaVerifier: any = null; // JsAltaVerifier instance (loaded from ALTA WASM)
|
|
110
100
|
let snapshotTimer: ReturnType<typeof setInterval> | null = null;
|
|
111
101
|
// blockUpdate events are posted only after a client subscribes (BLO-36599).
|
|
112
102
|
let blockUpdatesSubscribed = false;
|
|
@@ -137,20 +127,6 @@ let repairSymbols = 0;
|
|
|
137
127
|
let blocksComplete = 0;
|
|
138
128
|
let blocksRecovered = 0;
|
|
139
129
|
let blocksFailed = 0;
|
|
140
|
-
let altaVerified = 0;
|
|
141
|
-
let altaFailed = 0; // Crypto-confirmed tamper (MAC / sig / hash chain mismatch)
|
|
142
|
-
const altaFailedDetails: Array<{
|
|
143
|
-
seq: number;
|
|
144
|
-
error: string;
|
|
145
|
-
authLen: number;
|
|
146
|
-
payloadLen: number;
|
|
147
|
-
path: string; // "direct" | "recovered" | "pending-retry"
|
|
148
|
-
}> = [];
|
|
149
|
-
let altaPending = 0; // Unverifiable but not tamper (no trailer, verifier not ready, anchor missing)
|
|
150
|
-
let altaPendingEvictedCap = 0; // Incoming "pending" packets rejected because the queue was at cap after stale-sweep (flood indicator)
|
|
151
|
-
let altaPendingEvictedTtl = 0; // Deferred-queue entries dropped by TTL (retry window expired)
|
|
152
|
-
let altaError = 0; // verifyDetached threw — infrastructure flake, not tamper
|
|
153
|
-
let altaInitFailed = false; // ALTA configured but WASM init failed; altaEnabled was forced off
|
|
154
130
|
let relayBlocksVerified = 0;
|
|
155
131
|
let relayBlocksFailed = 0;
|
|
156
132
|
let liveSbn = 0;
|
|
@@ -188,35 +164,6 @@ let sourceUnplacedRetired = 0;
|
|
|
188
164
|
// Recovery timing
|
|
189
165
|
const recoveryTimes: number[] = [];
|
|
190
166
|
|
|
191
|
-
// ── Deferred ALTA verification queue (08-07 Path 3) ───────────────────────
|
|
192
|
-
//
|
|
193
|
-
// When an ALTA packet arrives whose MAC references point at sequences not yet
|
|
194
|
-
// in the verifier's ring buffer, we can't anchor trust — verify returns "No
|
|
195
|
-
// trust anchor". Typical cause: a predecessor packet was lost and hasn't been
|
|
196
|
-
// FEC-recovered yet. Rather than drop these, hold them in a bounded FIFO and
|
|
197
|
-
// retry verification after every successful verify (direct or FEC-recovered)
|
|
198
|
-
// that seeds the ring buffer with new raw bytes.
|
|
199
|
-
//
|
|
200
|
-
// Zero-copy (1B-B): the queue entry stores a Uint8Array view into the
|
|
201
|
-
// original `data` ArrayBuffer. JS GC keeps the buffer alive via the view
|
|
202
|
-
// reference; no bytes are copied on queue.
|
|
203
|
-
//
|
|
204
|
-
// Timeout entries (TTL exceeded) and cap-rejections count as `altaPending`
|
|
205
|
-
// (2C-B): they're not tamper indicators, they're just packets that couldn't
|
|
206
|
-
// be authenticated within the receive window — either loss or, in the cap
|
|
207
|
-
// case, a flood of unanchorable arrivals (real or attacker-driven) that
|
|
208
|
-
// exceeded the queue budget. See enqueuePendingVerify for the rate-limit
|
|
209
|
-
// protocol.
|
|
210
|
-
interface PendingVerify {
|
|
211
|
-
seq: number;
|
|
212
|
-
auth: Uint8Array;
|
|
213
|
-
payload: Uint8Array;
|
|
214
|
-
arrivalTs: number;
|
|
215
|
-
}
|
|
216
|
-
const pendingVerify = new Map<number, PendingVerify>();
|
|
217
|
-
let pendingVerifyCap = 0;
|
|
218
|
-
let pendingVerifyTtlMs = 0;
|
|
219
|
-
|
|
220
167
|
// --- Helpers ---
|
|
221
168
|
|
|
222
169
|
function emit(event: FecWorkerEvent, transfer?: Transferable[]): void {
|
|
@@ -241,7 +188,6 @@ function clearBlockTracking(): void {
|
|
|
241
188
|
acceptedSourceEsis.clear();
|
|
242
189
|
acceptedRepairEsis.clear();
|
|
243
190
|
unplacedSources.clear();
|
|
244
|
-
pendingVerify.clear();
|
|
245
191
|
recoveryTimes.length = 0;
|
|
246
192
|
liveSbn = 0;
|
|
247
193
|
liveSbnKnown = false;
|
|
@@ -255,15 +201,9 @@ function clearBlockTracking(): void {
|
|
|
255
201
|
*/
|
|
256
202
|
function isRetiredSbn(sbn: number): boolean {
|
|
257
203
|
if (config === null || !liveSbnKnown) return false;
|
|
258
|
-
//
|
|
259
|
-
//
|
|
260
|
-
return config.
|
|
261
|
-
? isoBlocksBehind(sbn, liveSbn, config.k) > config.interleaveDepth
|
|
262
|
-
: blocksBehind(sbn, liveSbn, config.k) > config.interleaveDepth;
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
function isIsoProfile(): boolean {
|
|
266
|
-
return config?.blockProfile === "iso-ssbg1-v1";
|
|
204
|
+
// `sbn` is an SS_ID (a block's SS_Start or a source's own SS_ID) and
|
|
205
|
+
// liveSbn the live SS_ID; K is only the maximum block length.
|
|
206
|
+
return isoBlocksBehind(sbn, liveSbn, config.k) > config.interleaveDepth;
|
|
267
207
|
}
|
|
268
208
|
|
|
269
209
|
/**
|
|
@@ -291,9 +231,7 @@ function findIsoBlock(ssId: number): FecBlockSnapshot | undefined {
|
|
|
291
231
|
* retired, after which the SS_ID retirement check refuses every member.
|
|
292
232
|
*/
|
|
293
233
|
function isRetiredBlock(key: number, block: FecBlockSnapshot): boolean {
|
|
294
|
-
return
|
|
295
|
-
? isRetiredSbn((key + isoKnownLength(block) - 1) % SS_ID_SPAN)
|
|
296
|
-
: isRetiredSbn(key);
|
|
234
|
+
return isRetiredSbn((key + isoKnownLength(block) - 1) % SS_ID_SPAN);
|
|
297
235
|
}
|
|
298
236
|
|
|
299
237
|
/**
|
|
@@ -310,15 +248,10 @@ function trimBlockRetention(): void {
|
|
|
310
248
|
}
|
|
311
249
|
}
|
|
312
250
|
|
|
313
|
-
/** Advance the live position: an
|
|
251
|
+
/** Advance the live position: an SS_ID / SS_Start, in serial order. */
|
|
314
252
|
function advanceMediaHorizon(sbn: number): void {
|
|
315
253
|
if (!config) return;
|
|
316
|
-
if (liveSbnKnown)
|
|
317
|
-
const ahead = config.blockProfile === "iso-ssbg1-v1"
|
|
318
|
-
? serialPrecedes(liveSbn, sbn)
|
|
319
|
-
: blockPrecedes(liveSbn, sbn, config.k);
|
|
320
|
-
if (!ahead) return;
|
|
321
|
-
}
|
|
254
|
+
if (liveSbnKnown && !serialPrecedes(liveSbn, sbn)) return;
|
|
322
255
|
liveSbn = sbn;
|
|
323
256
|
liveSbnKnown = true;
|
|
324
257
|
trimBlockRetention();
|
|
@@ -336,10 +269,7 @@ function markBlockTerminal(
|
|
|
336
269
|
|
|
337
270
|
function synchronizeDecoderHorizon(): void {
|
|
338
271
|
if (!wasmDecoder || !liveSbnKnown) return;
|
|
339
|
-
const
|
|
340
|
-
const expired = iso
|
|
341
|
-
? wasmDecoder.cleanup_by_ss_start(liveSbn)
|
|
342
|
-
: wasmDecoder.cleanup_by_sbn(liveSbn);
|
|
272
|
+
const expired = wasmDecoder.cleanup_by_ss_start(liveSbn);
|
|
343
273
|
for (const sbn of expired) {
|
|
344
274
|
const block = blocks.get(sbn);
|
|
345
275
|
if (!block || isTerminalBlock(block)) {
|
|
@@ -350,7 +280,7 @@ function synchronizeDecoderHorizon(): void {
|
|
|
350
280
|
blocksFailed++;
|
|
351
281
|
emitBlockUpdate(block);
|
|
352
282
|
}
|
|
353
|
-
|
|
283
|
+
retireUnplacedSources();
|
|
354
284
|
trimBlockRetention();
|
|
355
285
|
}
|
|
356
286
|
|
|
@@ -373,163 +303,6 @@ function retireUnplacedSources(): void {
|
|
|
373
303
|
}
|
|
374
304
|
}
|
|
375
305
|
|
|
376
|
-
/**
|
|
377
|
-
* Run detached ALTA verify against the current verifier. Four-state result:
|
|
378
|
-
* - "valid": crypto passed, packet authentic. Caller increments altaVerified.
|
|
379
|
-
* - "failed": crypto confirmed tamper (MAC/sig mismatch, hash chain mismatch).
|
|
380
|
-
* Caller increments altaFailed. Not retryable.
|
|
381
|
-
* - "pending": anchor missing (MAC refs not in buffer, no signature, not
|
|
382
|
-
* early-warmup). Caller may enqueue for retry after the next
|
|
383
|
-
* successful verify seeds the ring buffer.
|
|
384
|
-
* - "error": verifier threw — WASM trap, unmarshal panic, OOM. Caller
|
|
385
|
-
* increments altaError (infrastructure flake, not tamper).
|
|
386
|
-
* The distinction matters for ops alerting: a spike in
|
|
387
|
-
* altaError indicates a runtime problem, not an attack.
|
|
388
|
-
*/
|
|
389
|
-
let _lastVerifyError = "";
|
|
390
|
-
function runDetachedVerify(
|
|
391
|
-
auth: Uint8Array,
|
|
392
|
-
payload: Uint8Array,
|
|
393
|
-
): "valid" | "failed" | "pending" | "error" {
|
|
394
|
-
if (!altaVerifier) return "pending";
|
|
395
|
-
try {
|
|
396
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
397
|
-
const r: any = altaVerifier.verifyDetached(auth, payload);
|
|
398
|
-
if (r.valid) {
|
|
399
|
-
_lastVerifyError = "";
|
|
400
|
-
return "valid";
|
|
401
|
-
}
|
|
402
|
-
const err = (r.error as string | undefined) ?? "";
|
|
403
|
-
_lastVerifyError = err;
|
|
404
|
-
// The bootstrap-friendly verifier returns this specific error string when
|
|
405
|
-
// the only obstacle to verification is a missing anchor (see alta-rs
|
|
406
|
-
// verifier.rs verify()). Distinguishing this from tamper cases keeps us
|
|
407
|
-
// from queuing packets that are definitively invalid.
|
|
408
|
-
if (err.startsWith("No trust anchor")) return "pending";
|
|
409
|
-
return "failed";
|
|
410
|
-
} catch (e) {
|
|
411
|
-
// Ratelimit: first ~N occurrences get logged. A sustained exception
|
|
412
|
-
// stream indicates a real problem — the altaError counter is the
|
|
413
|
-
// durable signal.
|
|
414
|
-
if (altaError < 5) {
|
|
415
|
-
console.warn("[FecWorker] verifyDetached threw:", e);
|
|
416
|
-
}
|
|
417
|
-
return "error";
|
|
418
|
-
}
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
/** Extract u32 BE sequence number from the ALTA authenticator header (offset 0..3). */
|
|
422
|
-
function seqFromAuth(auth: Uint8Array): number {
|
|
423
|
-
return (
|
|
424
|
-
((auth[0]! << 24) | (auth[1]! << 16) | (auth[2]! << 8) | auth[3]!) >>> 0
|
|
425
|
-
);
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
/**
|
|
429
|
-
* Enqueue a packet whose ALTA verification returned "pending" (anchor
|
|
430
|
-
* missing). Dedupes by sequence number.
|
|
431
|
-
*
|
|
432
|
-
* Flood resistance (08-08 review): when the queue is at cap we do NOT
|
|
433
|
-
* evict the oldest entry. The oldest entry is typically the most
|
|
434
|
-
* load-bearing one — it's the packet whose anchor everything else in the
|
|
435
|
-
* queue is waiting on. Evict-oldest-on-insert let an attacker emitting a
|
|
436
|
-
* stream of "No trust anchor" packets flood out legitimate entries and
|
|
437
|
-
* prevent them from ever anchoring. Instead:
|
|
438
|
-
* 1. First try to free stale TTL'd entries (they'd be evicted on next
|
|
439
|
-
* drain anyway — pulling that forward is free).
|
|
440
|
-
* 2. If still full, reject the NEW entry. altaPendingEvictedCap counts
|
|
441
|
-
* these rejections (rate-limit trigger).
|
|
442
|
-
*
|
|
443
|
-
* Stores Uint8Array views into the original data + auth buffers — no copy.
|
|
444
|
-
* The views keep their underlying ArrayBuffers alive via GC references.
|
|
445
|
-
*/
|
|
446
|
-
function enqueuePendingVerify(
|
|
447
|
-
auth: Uint8Array,
|
|
448
|
-
payload: Uint8Array,
|
|
449
|
-
): void {
|
|
450
|
-
const seq = seqFromAuth(auth);
|
|
451
|
-
if (pendingVerify.has(seq)) return;
|
|
452
|
-
|
|
453
|
-
if (pendingVerify.size >= pendingVerifyCap) {
|
|
454
|
-
// Step 1: sweep stale entries so legitimate traffic isn't penalized
|
|
455
|
-
// by TTL'd junk that happens to still occupy slots.
|
|
456
|
-
const now = performance.now();
|
|
457
|
-
for (const [staleSeq, entry] of pendingVerify) {
|
|
458
|
-
if (now - entry.arrivalTs > pendingVerifyTtlMs) {
|
|
459
|
-
pendingVerify.delete(staleSeq);
|
|
460
|
-
altaPendingEvictedTtl++;
|
|
461
|
-
}
|
|
462
|
-
}
|
|
463
|
-
// Step 2: if the queue is still at cap it's a genuine flood —
|
|
464
|
-
// reject the new entry rather than evict an existing one. The
|
|
465
|
-
// rejected packet counts as altaPendingEvictedCap (which is
|
|
466
|
-
// surfaced up through stats for rate-limit alerting).
|
|
467
|
-
if (pendingVerify.size >= pendingVerifyCap) {
|
|
468
|
-
altaPendingEvictedCap++;
|
|
469
|
-
return;
|
|
470
|
-
}
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
pendingVerify.set(seq, {
|
|
474
|
-
seq,
|
|
475
|
-
auth,
|
|
476
|
-
payload,
|
|
477
|
-
arrivalTs: performance.now(),
|
|
478
|
-
});
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
/**
|
|
482
|
-
* Retry every queued entry. Called after every successful verify (direct
|
|
483
|
-
* source or FEC-recovered) — new raw bytes in the verifier's ring buffer may
|
|
484
|
-
* unblock previously-pending MAC references.
|
|
485
|
-
*
|
|
486
|
-
* Three outcomes per entry:
|
|
487
|
-
* - "valid" → remove from queue, altaVerified++
|
|
488
|
-
* - "failed" → remove from queue, altaFailed++ (tamper on retry)
|
|
489
|
-
* - "pending" → keep in queue, unless TTL expired (then evict as altaPending)
|
|
490
|
-
*
|
|
491
|
-
* Iteration order: Map preserves insertion order, so we drain oldest-first.
|
|
492
|
-
* No-op when the queue is empty or the verifier isn't ready.
|
|
493
|
-
*/
|
|
494
|
-
function drainPendingVerify(): void {
|
|
495
|
-
if (pendingVerify.size === 0 || !altaVerifier) return;
|
|
496
|
-
const now = performance.now();
|
|
497
|
-
// Collect keys to avoid mutating Map during iteration
|
|
498
|
-
const keys = Array.from(pendingVerify.keys());
|
|
499
|
-
for (const seq of keys) {
|
|
500
|
-
const entry = pendingVerify.get(seq);
|
|
501
|
-
if (!entry) continue;
|
|
502
|
-
if (now - entry.arrivalTs > pendingVerifyTtlMs) {
|
|
503
|
-
pendingVerify.delete(seq);
|
|
504
|
-
altaPendingEvictedTtl++;
|
|
505
|
-
continue;
|
|
506
|
-
}
|
|
507
|
-
const result = runDetachedVerify(entry.auth, entry.payload);
|
|
508
|
-
if (result === "valid") {
|
|
509
|
-
pendingVerify.delete(seq);
|
|
510
|
-
altaVerified++;
|
|
511
|
-
} else if (result === "failed") {
|
|
512
|
-
pendingVerify.delete(seq);
|
|
513
|
-
altaFailed++;
|
|
514
|
-
if (altaFailedDetails.length < 16) {
|
|
515
|
-
altaFailedDetails.push({
|
|
516
|
-
seq: entry.seq,
|
|
517
|
-
error: _lastVerifyError,
|
|
518
|
-
authLen: entry.auth.length,
|
|
519
|
-
payloadLen: entry.payload.length,
|
|
520
|
-
path: "pending-retry",
|
|
521
|
-
});
|
|
522
|
-
}
|
|
523
|
-
} else if (result === "error") {
|
|
524
|
-
// Infrastructure failure — remove from queue, count as altaError.
|
|
525
|
-
// Keeping it around would just re-throw on every subsequent drain.
|
|
526
|
-
pendingVerify.delete(seq);
|
|
527
|
-
altaError++;
|
|
528
|
-
}
|
|
529
|
-
// "pending" → leave in queue for next drain
|
|
530
|
-
}
|
|
531
|
-
}
|
|
532
|
-
|
|
533
306
|
/**
|
|
534
307
|
* Look up or create a block. `isoSsbLength` creates an iso-ssbg1-v1 block,
|
|
535
308
|
* whose K' is its SSB_length (C.5.3); without it the block has the catalog K.
|
|
@@ -548,8 +321,9 @@ function getOrCreateBlock(sbn: number, isoSsbLength?: number): FecBlockSnapshot
|
|
|
548
321
|
repairCount: config.repairCount,
|
|
549
322
|
firstSymbolTs: now,
|
|
550
323
|
lastSymbolTs: now,
|
|
324
|
+
// A positive interleaveMs makes this the catalog completion deadline;
|
|
325
|
+
// zero makes deliveryWindowMs a receiver-local cleanup bound.
|
|
551
326
|
deadlineTs: now + config.deliveryWindowMs,
|
|
552
|
-
altaVerified: 0,
|
|
553
327
|
relayBlockSigValid: null,
|
|
554
328
|
};
|
|
555
329
|
if (isoSsbLength !== undefined) block.ssbLength = isoSsbLength;
|
|
@@ -583,8 +357,10 @@ function buildStatsSnapshot(): FecTrackStats {
|
|
|
583
357
|
|
|
584
358
|
// Cap block array to interleave window
|
|
585
359
|
const maxBlocks = Math.max(config.interleaveDepth * 3, 24);
|
|
360
|
+
// Newest first in SS_ID serial order, so the cap keeps the newest blocks
|
|
361
|
+
// across the 2^32 wrap too: every block start precedes the live SS_ID.
|
|
586
362
|
const blockArray = Array.from(blocks.values())
|
|
587
|
-
.sort((a, b) =>
|
|
363
|
+
.sort((a, b) => serialDistance(a.sbn, liveSbn) - serialDistance(b.sbn, liveSbn))
|
|
588
364
|
.slice(0, maxBlocks);
|
|
589
365
|
|
|
590
366
|
return {
|
|
@@ -600,15 +376,6 @@ function buildStatsSnapshot(): FecTrackStats {
|
|
|
600
376
|
(blocksRecovered / (blocksRecovered + blocksFailed)) * 100,
|
|
601
377
|
) || 0
|
|
602
378
|
: 0,
|
|
603
|
-
altaVerified,
|
|
604
|
-
altaFailed,
|
|
605
|
-
altaPending,
|
|
606
|
-
altaPendingEvictedCap,
|
|
607
|
-
altaPendingEvictedTtl,
|
|
608
|
-
altaError,
|
|
609
|
-
altaInitFailed,
|
|
610
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
611
|
-
altaFailedDetails: altaFailedDetails.slice() as any,
|
|
612
379
|
relayBlocksVerified,
|
|
613
380
|
relayBlocksFailed,
|
|
614
381
|
liveSbn,
|
|
@@ -620,7 +387,7 @@ function buildStatsSnapshot(): FecTrackStats {
|
|
|
620
387
|
sourceDroppedRetired,
|
|
621
388
|
sourceDroppedTerminal,
|
|
622
389
|
sourceDroppedDuplicate,
|
|
623
|
-
|
|
390
|
+
sourceUnplacedRetired,
|
|
624
391
|
decoder,
|
|
625
392
|
};
|
|
626
393
|
}
|
|
@@ -662,33 +429,23 @@ function emitRecoveredSymbols(
|
|
|
662
429
|
return;
|
|
663
430
|
}
|
|
664
431
|
|
|
665
|
-
const iso = config.blockProfile === "iso-ssbg1-v1";
|
|
666
432
|
for (let i = 0; i < ssbLength; i++) {
|
|
667
433
|
const symbol = recoveredBlock.subarray(i * symbolSize, (i + 1) * symbolSize);
|
|
668
434
|
// ssbg_mode1 symbols are emitted whole: the router trims them by their
|
|
669
435
|
// length field. A field past T-2 or non-zero padding means the
|
|
670
436
|
// recovered symbol is not what was sent, so it is refused, not trimmed.
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
continue;
|
|
680
|
-
}
|
|
437
|
+
const invalid = invalidSsbg1Symbol(symbol, symbolSize);
|
|
438
|
+
if (invalid !== null) {
|
|
439
|
+
emit({
|
|
440
|
+
type: "error",
|
|
441
|
+
code: "recovered-ssbg1-symbol-invalid",
|
|
442
|
+
message: `[FecWorker] Dropping recovered ssbg_mode1 symbol (ssStart=${sbn}, esi=${i}, SSB_length=${ssbLength}): ${invalid}`,
|
|
443
|
+
});
|
|
444
|
+
continue;
|
|
681
445
|
}
|
|
682
446
|
|
|
683
|
-
// Source authentication now runs once at router admission in
|
|
684
|
-
// @blockcast/mmt-manifest-verify. The worker only records that this
|
|
685
|
-
// legacy ALTA-enabled symbol bypassed hot-path verification.
|
|
686
|
-
const recoveredAltaVerified: boolean | null = null;
|
|
687
|
-
if (altaEnabled) altaPending++;
|
|
688
|
-
|
|
689
447
|
const buf = toOwnedArrayBuffer(symbol);
|
|
690
448
|
const meta: FrameMeta = {
|
|
691
|
-
altaVerified: recoveredAltaVerified,
|
|
692
449
|
recovered: true,
|
|
693
450
|
sbn,
|
|
694
451
|
esi: i,
|
|
@@ -857,7 +614,6 @@ function feedIsoSource(
|
|
|
857
614
|
advanceMediaHorizon(cmd.ssId);
|
|
858
615
|
unplacedSources.set(cmd.ssId, performance.now());
|
|
859
616
|
sourceSymbols++;
|
|
860
|
-
if (altaEnabled) altaPending++;
|
|
861
617
|
// No repair has named this source's block, so the decoder cannot have
|
|
862
618
|
// one to recover; data here is a Worker/decoder disagreement that
|
|
863
619
|
// cannot be attributed to a block, and is refused rather than guessed.
|
|
@@ -893,7 +649,6 @@ function feedIsoSource(
|
|
|
893
649
|
block.sourceReceived++;
|
|
894
650
|
block.lastSymbolTs = performance.now();
|
|
895
651
|
sourceSymbols++;
|
|
896
|
-
if (altaEnabled) altaPending++;
|
|
897
652
|
settleSourceArrival(key, block, result);
|
|
898
653
|
}
|
|
899
654
|
|
|
@@ -990,12 +745,12 @@ async function handleConfigure(
|
|
|
990
745
|
const blockProfile: unknown = cmd.config.blockProfile;
|
|
991
746
|
if (blockProfile === undefined) {
|
|
992
747
|
throw new Error(
|
|
993
|
-
'[FecWorker] configure: blockProfile is required: name the FEC block profile ("
|
|
748
|
+
'[FecWorker] configure: blockProfile is required: name the FEC block profile ("iso-ssbg1-v1")',
|
|
994
749
|
);
|
|
995
750
|
}
|
|
996
|
-
if (blockProfile !== "
|
|
751
|
+
if (blockProfile !== "iso-ssbg1-v1") {
|
|
997
752
|
throw new Error(
|
|
998
|
-
`[FecWorker] configure: blockProfile must be "
|
|
753
|
+
`[FecWorker] configure: blockProfile must be "iso-ssbg1-v1" (got ${JSON.stringify(blockProfile)})`,
|
|
999
754
|
);
|
|
1000
755
|
}
|
|
1001
756
|
if (
|
|
@@ -1010,10 +765,11 @@ async function handleConfigure(
|
|
|
1010
765
|
}
|
|
1011
766
|
if (
|
|
1012
767
|
!Number.isFinite(cmd.config.interleaveMs) ||
|
|
1013
|
-
cmd.config.interleaveMs
|
|
768
|
+
!Number.isSafeInteger(cmd.config.interleaveMs) ||
|
|
769
|
+
cmd.config.interleaveMs < 0
|
|
1014
770
|
) {
|
|
1015
771
|
throw new Error(
|
|
1016
|
-
`[FecWorker] configure: interleaveMs must be finite
|
|
772
|
+
`[FecWorker] configure: interleaveMs must be a finite non-negative integer (got ${cmd.config.interleaveMs})`,
|
|
1017
773
|
);
|
|
1018
774
|
}
|
|
1019
775
|
if (
|
|
@@ -1061,50 +817,6 @@ async function handleConfigure(
|
|
|
1061
817
|
verifyDecoderProfile(decoder, blockProfile);
|
|
1062
818
|
wasmDecoder = decoder;
|
|
1063
819
|
config = cmd.config;
|
|
1064
|
-
altaEnabled = !!cmd.altaPublicKey;
|
|
1065
|
-
|
|
1066
|
-
// Size the deferred-verify queue strictly from catalog-derived FEC
|
|
1067
|
-
// parameters. CLAUDE.md bans Math.max(x, floor) magic — if catalog
|
|
1068
|
-
// values are zero/missing, throw at init rather than silently applying
|
|
1069
|
-
// a hardcoded floor. Consumers of an undersized queue cap would see
|
|
1070
|
-
// altaPendingEvictedCap spike with no actionable signal.
|
|
1071
|
-
// Cap: 1.5× interleave depth — wide enough to hold one full FEC recovery
|
|
1072
|
-
// window plus jitter, narrow enough to bound memory.
|
|
1073
|
-
pendingVerifyCap = Math.ceil(config.interleaveDepth * 1.5);
|
|
1074
|
-
// TTL: 1.5× block duration (K symbols at one per interleave tick) —
|
|
1075
|
-
// after this many ms, any unresolved entry has missed its FEC window.
|
|
1076
|
-
pendingVerifyTtlMs = Math.ceil(config.interleaveMs * config.k * 1.5);
|
|
1077
|
-
|
|
1078
|
-
// Initialize ALTA WASM verifier if public key and WASM module provided (D-05, D-09)
|
|
1079
|
-
altaVerifier = null;
|
|
1080
|
-
altaInitFailed = false;
|
|
1081
|
-
if (cmd.altaPublicKey && cmd.altaWasmModule) {
|
|
1082
|
-
try {
|
|
1083
|
-
// Load ALTA JS glue (wasm-bindgen output from wasm-pack build).
|
|
1084
|
-
// Mirrors the RaptorQ WASM pattern: initSync({ module }) then construct.
|
|
1085
|
-
// Dynamic import path matches the IWA static asset layout.
|
|
1086
|
-
// Cast via string to prevent TypeScript from attempting static resolution
|
|
1087
|
-
// of a runtime-only URL (same pattern as fec-worker-entry.js loading).
|
|
1088
|
-
const altaGlueUrl = '/wasm/alta/alta_rs.js' as string;
|
|
1089
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1090
|
-
const altaGlue: any = await import(/* webpackIgnore: true */ altaGlueUrl);
|
|
1091
|
-
altaGlue.initSync({ module: cmd.altaWasmModule });
|
|
1092
|
-
const keyBytes = new Uint8Array(cmd.altaPublicKey);
|
|
1093
|
-
altaVerifier = new altaGlue.JsAltaVerifier(keyBytes);
|
|
1094
|
-
} catch (e) {
|
|
1095
|
-
// T-08-15: WASM init failure must NOT silently look like "not-ready-yet".
|
|
1096
|
-
// Force altaEnabled = false so handleFeedSource takes the disabled
|
|
1097
|
-
// branch instead of continuously bumping altaPending under the
|
|
1098
|
-
// misleading "verifier not yet ready" comment. altaInitFailed
|
|
1099
|
-
// flag in the snapshot tells consumers this is an error state, not
|
|
1100
|
-
// an opt-out.
|
|
1101
|
-
console.warn('[FecWorker] ALTA WASM init failed, verification disabled:', e);
|
|
1102
|
-
altaVerifier = null;
|
|
1103
|
-
altaEnabled = false;
|
|
1104
|
-
altaInitFailed = true;
|
|
1105
|
-
}
|
|
1106
|
-
}
|
|
1107
|
-
|
|
1108
820
|
wasmDecoder.configure_params(config.k, config.symbolSize);
|
|
1109
821
|
|
|
1110
822
|
// Tell the client which optional commands this Worker accepts, so it can
|
|
@@ -1134,51 +846,7 @@ function handleFeedSource(
|
|
|
1134
846
|
throw new Error("[FecWorker] feedSource: invalid source symbol");
|
|
1135
847
|
}
|
|
1136
848
|
|
|
1137
|
-
|
|
1138
|
-
if (config.blockProfile === "iso-ssbg1-v1") {
|
|
1139
|
-
feedIsoSource(cmd, view, wasmDecoder, config);
|
|
1140
|
-
return;
|
|
1141
|
-
}
|
|
1142
|
-
|
|
1143
|
-
// Derive SBN locally for block-level accounting. WASM derives the same
|
|
1144
|
-
// value internally (ISO 23008-1 §C.5.2: SBN = floor(SS_ID / K)).
|
|
1145
|
-
const sbn = config.k > 0 ? Math.floor(cmd.ssId / config.k) : cmd.ssId;
|
|
1146
|
-
const esi = cmd.ssId % config.k;
|
|
1147
|
-
if (isRetiredSbn(sbn)) {
|
|
1148
|
-
sourceDroppedRetired++;
|
|
1149
|
-
return;
|
|
1150
|
-
}
|
|
1151
|
-
const existingBlock = blocks.get(sbn);
|
|
1152
|
-
if (existingBlock && isTerminalBlock(existingBlock)) {
|
|
1153
|
-
sourceDroppedTerminal++;
|
|
1154
|
-
return;
|
|
1155
|
-
}
|
|
1156
|
-
if (acceptedSourceEsis.get(sbn)?.has(esi)) {
|
|
1157
|
-
sourceDroppedDuplicate++;
|
|
1158
|
-
return;
|
|
1159
|
-
}
|
|
1160
|
-
|
|
1161
|
-
const result = wasmDecoder.add_source(cmd.ssId, view, cmd.ts);
|
|
1162
|
-
advanceMediaHorizon(sbn);
|
|
1163
|
-
let sourceEsis = acceptedSourceEsis.get(sbn);
|
|
1164
|
-
if (!sourceEsis) {
|
|
1165
|
-
sourceEsis = new Set<number>();
|
|
1166
|
-
acceptedSourceEsis.set(sbn, sourceEsis);
|
|
1167
|
-
}
|
|
1168
|
-
sourceEsis.add(esi);
|
|
1169
|
-
|
|
1170
|
-
// Update block state
|
|
1171
|
-
const block = getOrCreateBlock(sbn);
|
|
1172
|
-
block.sourceReceived++;
|
|
1173
|
-
block.lastSymbolTs = performance.now();
|
|
1174
|
-
sourceSymbols++;
|
|
1175
|
-
|
|
1176
|
-
// The FEC worker must remain decode-only on the per-symbol hot path. ALTA
|
|
1177
|
-
// WASM is retained temporarily for low-rate signaling users, but media is
|
|
1178
|
-
// authenticated by the canonical manifest verifier at router admission.
|
|
1179
|
-
if (altaEnabled) altaPending++;
|
|
1180
|
-
|
|
1181
|
-
settleSourceArrival(sbn, block, result);
|
|
849
|
+
feedIsoSource(cmd, new Uint8Array(cmd.data), wasmDecoder, config);
|
|
1182
850
|
}
|
|
1183
851
|
|
|
1184
852
|
function handleFeedRepair(
|
|
@@ -1190,17 +858,14 @@ function handleFeedRepair(
|
|
|
1190
858
|
if (!config) {
|
|
1191
859
|
throw new Error("[FecWorker] feedRepair: config missing");
|
|
1192
860
|
}
|
|
1193
|
-
// k-padded-v1 blocks start at multiples of K and repair ESI = K + RS_ID;
|
|
1194
861
|
// iso-ssbg1-v1 blocks start at any SS_ID and repair ESI = SSB_length + RS_ID.
|
|
1195
|
-
const iso = config.blockProfile === "iso-ssbg1-v1";
|
|
1196
862
|
if (
|
|
1197
863
|
!isUint32(cmd.ssStart) ||
|
|
1198
864
|
!isUint32(cmd.ssbLength) ||
|
|
1199
865
|
cmd.ssbLength <= 0 ||
|
|
1200
866
|
cmd.ssbLength > config.k ||
|
|
1201
|
-
(!iso && cmd.ssStart % config.k !== 0) ||
|
|
1202
867
|
!isUint32(cmd.rsId) ||
|
|
1203
|
-
|
|
868
|
+
cmd.ssbLength + cmd.rsId > UINT24_MAX ||
|
|
1204
869
|
!(cmd.data instanceof ArrayBuffer)
|
|
1205
870
|
) {
|
|
1206
871
|
throw new Error("[FecWorker] feedRepair: invalid repair symbol");
|
|
@@ -1218,51 +883,7 @@ function handleFeedRepair(
|
|
|
1218
883
|
}
|
|
1219
884
|
return;
|
|
1220
885
|
}
|
|
1221
|
-
|
|
1222
|
-
feedIsoRepair(cmd, view, wasmDecoder);
|
|
1223
|
-
return;
|
|
1224
|
-
}
|
|
1225
|
-
const sbn = Math.floor(cmd.ssStart / config.k);
|
|
1226
|
-
const repairEsi = config.k + cmd.rsId;
|
|
1227
|
-
// The repair path's mirror drops stay silent and uncounted: BLO-35597
|
|
1228
|
-
// defect 2 is a source-symbol shortfall, so only the source side needed
|
|
1229
|
-
// evidence. The asymmetry is deliberate, not an oversight — extend these
|
|
1230
|
-
// the same way if a repair-side shortfall ever needs attributing.
|
|
1231
|
-
if (isRetiredSbn(sbn)) return;
|
|
1232
|
-
const existingBlock = blocks.get(sbn);
|
|
1233
|
-
if (existingBlock && isTerminalBlock(existingBlock)) return;
|
|
1234
|
-
if (acceptedRepairEsis.get(sbn)?.has(repairEsi)) return;
|
|
1235
|
-
// One block has one authored SSB_length; a repair symbol naming another
|
|
1236
|
-
// is dropped before it reaches the decoder (which rejects it too).
|
|
1237
|
-
if (existingBlock?.ssbLength !== undefined && existingBlock.ssbLength !== cmd.ssbLength) {
|
|
1238
|
-
emit({
|
|
1239
|
-
type: "error",
|
|
1240
|
-
code: "repair-ssb-length-conflict",
|
|
1241
|
-
message: `[FecWorker] Dropping repair symbol whose SSB_length ${cmd.ssbLength} conflicts with ${existingBlock.ssbLength} (sbn=${sbn})`,
|
|
1242
|
-
});
|
|
1243
|
-
return;
|
|
1244
|
-
}
|
|
1245
|
-
|
|
1246
|
-
const result = wasmDecoder.add_repair(
|
|
1247
|
-
cmd.ssStart,
|
|
1248
|
-
cmd.ssbLength,
|
|
1249
|
-
cmd.rsId,
|
|
1250
|
-
view,
|
|
1251
|
-
cmd.ts,
|
|
1252
|
-
);
|
|
1253
|
-
advanceMediaHorizon(sbn);
|
|
1254
|
-
let repairEsis = acceptedRepairEsis.get(sbn);
|
|
1255
|
-
if (!repairEsis) {
|
|
1256
|
-
repairEsis = new Set<number>();
|
|
1257
|
-
acceptedRepairEsis.set(sbn, repairEsis);
|
|
1258
|
-
}
|
|
1259
|
-
repairEsis.add(repairEsi);
|
|
1260
|
-
const block = getOrCreateBlock(sbn);
|
|
1261
|
-
block.ssbLength = cmd.ssbLength;
|
|
1262
|
-
block.repairReceived++;
|
|
1263
|
-
block.lastSymbolTs = performance.now();
|
|
1264
|
-
repairSymbols++;
|
|
1265
|
-
settleRepairArrival(sbn, block, cmd.ssbLength, result);
|
|
886
|
+
feedIsoRepair(cmd, view, wasmDecoder);
|
|
1266
887
|
}
|
|
1267
888
|
|
|
1268
889
|
function handleRelayBlockSig(
|
|
@@ -1271,8 +892,8 @@ function handleRelayBlockSig(
|
|
|
1271
892
|
// No-op AUTH stub: store sig on block, mark as not-yet-verified
|
|
1272
893
|
const block = blocks.get(cmd.sbn);
|
|
1273
894
|
if (block) {
|
|
1274
|
-
//
|
|
1275
|
-
//
|
|
895
|
+
// Relay block signatures are not verified here; source provenance is the
|
|
896
|
+
// signed per-group manifest checked at router admission.
|
|
1276
897
|
block.relayBlockSigValid = null;
|
|
1277
898
|
emitBlockUpdate(block);
|
|
1278
899
|
}
|
|
@@ -1315,10 +936,9 @@ function handleCleanup(
|
|
|
1315
936
|
continue;
|
|
1316
937
|
}
|
|
1317
938
|
currentBlock ??= getOrCreateBlock(sbn);
|
|
1318
|
-
//
|
|
1319
|
-
//
|
|
1320
|
-
|
|
1321
|
-
const authoredLength = isIsoProfile() ? currentBlock.ssbLength : currentBlock.k;
|
|
939
|
+
// Compare with the block's SSB_length; a key no repair declared has
|
|
940
|
+
// none, so a block the Worker never knew can only have failed.
|
|
941
|
+
const authoredLength = currentBlock.ssbLength;
|
|
1322
942
|
if (authoredLength !== undefined && currentBlock.sourceReceived >= authoredLength) {
|
|
1323
943
|
markBlockTerminal(currentBlock, "complete");
|
|
1324
944
|
blocksComplete++;
|
|
@@ -1343,29 +963,12 @@ function handleDispose(): void {
|
|
|
1343
963
|
snapshotTimer = null;
|
|
1344
964
|
}
|
|
1345
965
|
|
|
1346
|
-
// Free ALTA verifier WASM object (MUST call to release WASM memory —
|
|
1347
|
-
// Worker.terminate() fires immediately after dispose, so the FinalizationRegistry
|
|
1348
|
-
// callback has no chance to run and this memory would leak without explicit free)
|
|
1349
|
-
if (altaVerifier) {
|
|
1350
|
-
try {
|
|
1351
|
-
altaVerifier.free();
|
|
1352
|
-
} catch (_e) {
|
|
1353
|
-
// Best-effort — verifier may already be freed
|
|
1354
|
-
}
|
|
1355
|
-
altaVerifier = null;
|
|
1356
|
-
}
|
|
1357
|
-
|
|
1358
966
|
// Free WASM decoder (MUST call to release WASM memory)
|
|
1359
967
|
if (wasmDecoder) {
|
|
1360
968
|
wasmDecoder.free();
|
|
1361
969
|
wasmDecoder = null;
|
|
1362
970
|
}
|
|
1363
971
|
|
|
1364
|
-
// Clear deferred-verify queue so pinned ArrayBuffers drop.
|
|
1365
|
-
pendingVerify.clear();
|
|
1366
|
-
pendingVerifyCap = 0;
|
|
1367
|
-
pendingVerifyTtlMs = 0;
|
|
1368
|
-
|
|
1369
972
|
config = null;
|
|
1370
973
|
clearBlockTracking();
|
|
1371
974
|
}
|
|
@@ -1392,13 +995,6 @@ function handleReset(): void {
|
|
|
1392
995
|
blocksComplete = 0;
|
|
1393
996
|
blocksRecovered = 0;
|
|
1394
997
|
blocksFailed = 0;
|
|
1395
|
-
altaVerified = 0;
|
|
1396
|
-
altaFailed = 0;
|
|
1397
|
-
altaFailedDetails.length = 0;
|
|
1398
|
-
altaPending = 0;
|
|
1399
|
-
altaPendingEvictedCap = 0;
|
|
1400
|
-
altaPendingEvictedTtl = 0;
|
|
1401
|
-
altaError = 0;
|
|
1402
998
|
relayBlocksVerified = 0;
|
|
1403
999
|
relayBlocksFailed = 0;
|
|
1404
1000
|
repairSymbolSizeMismatch = 0;
|
|
@@ -1448,8 +1044,7 @@ async function dispatchCommand(cmd: FecWorkerCommand): Promise<void> {
|
|
|
1448
1044
|
try {
|
|
1449
1045
|
switch (cmd.type) {
|
|
1450
1046
|
case "configure":
|
|
1451
|
-
// handleConfigure
|
|
1452
|
-
// Await it so feed/cleanup messages cannot run against a partially
|
|
1047
|
+
// Await handleConfigure so feed/cleanup messages cannot run against a partially
|
|
1453
1048
|
// configured decoder.
|
|
1454
1049
|
await handleConfigure(cmd);
|
|
1455
1050
|
break;
|