@blockcast/fec-worker 0.3.0 → 0.4.0-main.248efb07e294

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/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
- * `k-padded-v1`: data is the source symbol. `iso-ssbg1-v1`: data is the
41
- * protected packet, unpadded; the decoder builds the ssbg_mode1 symbol and
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. `k-padded-v1`: repair ESI = configured K + rs_id,
51
- * SBN = ss_start / configured K. `iso-ssbg1-v1`: the block is
52
- * `[ss_start, ss_start + ssb_length)` with K' = ssb_length and repair
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,18 +63,18 @@ interface WasmFecDecoder {
68
63
  */
69
64
  cleanup_blocks_detailed(sbns: Uint32Array): void;
70
65
  /**
71
- * `k-padded-v1` only (throws under `iso-ssbg1-v1`): remove decoder blocks
72
- * below its configured live-SBN interleave horizon; returns their SBNs.
73
- */
74
- cleanup_by_sbn(current_sbn: number): Uint32Array;
75
- /**
76
- * `iso-ssbg1-v1` only: remove decoder blocks, and held unplaced sources,
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. */
82
75
  get_block_profile(): string;
76
+ /** The `fec.algorithm` the decoder was constructed with; absent on builds that predate Reed-Solomon. */
77
+ get_algorithm?(): string;
83
78
  flush(): void;
84
79
  pending_blocks(): number;
85
80
  reset_stats(): void;
@@ -97,16 +92,13 @@ interface WasmFecDecoder {
97
92
 
98
93
  interface WasmBindings {
99
94
  initSync(module: { module: WebAssembly.Module }): unknown;
100
- MmtFecDecoder: new (interleaveDepth: number, blockProfile: FecBlockProfile) => WasmFecDecoder;
95
+ MmtFecDecoder: new (interleaveDepth: number, blockProfile: FecBlockProfile, algorithm?: FecScheme) => WasmFecDecoder;
101
96
  }
102
97
 
103
98
  // --- Worker state (local to this Worker instance) ---
104
99
 
105
100
  let wasmDecoder: WasmFecDecoder | null = null;
106
101
  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
102
  let snapshotTimer: ReturnType<typeof setInterval> | null = null;
111
103
  // blockUpdate events are posted only after a client subscribes (BLO-36599).
112
104
  let blockUpdatesSubscribed = false;
@@ -130,6 +122,10 @@ const MAX_INTERLEAVE_DEPTH = 0xff;
130
122
  const MAX_SYMBOL_SIZE = 0xfff8;
131
123
  // RFC 6330 systematic table limit, enforced by the vendored raptorq decoder.
132
124
  const MAX_RAPTORQ_SOURCE_SYMBOLS_PER_BLOCK = 56_403;
125
+ // draft-ramadan-moq-fec-01 4.5: K + P <= 255, and a block has at least one repair.
126
+ const MAX_REED_SOLOMON_SOURCE_SYMBOLS_PER_BLOCK = 254;
127
+
128
+ type FecScheme = "raptorq" | "reed-solomon";
133
129
 
134
130
  // Aggregate counters
135
131
  let sourceSymbols = 0;
@@ -137,20 +133,6 @@ let repairSymbols = 0;
137
133
  let blocksComplete = 0;
138
134
  let blocksRecovered = 0;
139
135
  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
136
  let relayBlocksVerified = 0;
155
137
  let relayBlocksFailed = 0;
156
138
  let liveSbn = 0;
@@ -188,35 +170,6 @@ let sourceUnplacedRetired = 0;
188
170
  // Recovery timing
189
171
  const recoveryTimes: number[] = [];
190
172
 
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
173
  // --- Helpers ---
221
174
 
222
175
  function emit(event: FecWorkerEvent, transfer?: Transferable[]): void {
@@ -241,7 +194,6 @@ function clearBlockTracking(): void {
241
194
  acceptedSourceEsis.clear();
242
195
  acceptedRepairEsis.clear();
243
196
  unplacedSources.clear();
244
- pendingVerify.clear();
245
197
  recoveryTimes.length = 0;
246
198
  liveSbn = 0;
247
199
  liveSbnKnown = false;
@@ -255,15 +207,9 @@ function clearBlockTracking(): void {
255
207
  */
256
208
  function isRetiredSbn(sbn: number): boolean {
257
209
  if (config === null || !liveSbnKnown) return false;
258
- // iso-ssbg1-v1: `sbn` is an SS_ID (a block's SS_Start or a source's own
259
- // SS_ID) and liveSbn the live SS_ID; K is only the maximum block length.
260
- return config.blockProfile === "iso-ssbg1-v1"
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";
210
+ // `sbn` is an SS_ID (a block's SS_Start or a source's own SS_ID) and
211
+ // liveSbn the live SS_ID; K is only the maximum block length.
212
+ return isoBlocksBehind(sbn, liveSbn, config.k) > config.interleaveDepth;
267
213
  }
268
214
 
269
215
  /**
@@ -291,9 +237,7 @@ function findIsoBlock(ssId: number): FecBlockSnapshot | undefined {
291
237
  * retired, after which the SS_ID retirement check refuses every member.
292
238
  */
293
239
  function isRetiredBlock(key: number, block: FecBlockSnapshot): boolean {
294
- return isIsoProfile()
295
- ? isRetiredSbn((key + isoKnownLength(block) - 1) % SS_ID_SPAN)
296
- : isRetiredSbn(key);
240
+ return isRetiredSbn((key + isoKnownLength(block) - 1) % SS_ID_SPAN);
297
241
  }
298
242
 
299
243
  /**
@@ -310,15 +254,10 @@ function trimBlockRetention(): void {
310
254
  }
311
255
  }
312
256
 
313
- /** Advance the live position: an SBN (k-padded-v1) or an SS_ID / SS_Start (iso-ssbg1-v1). */
257
+ /** Advance the live position: an SS_ID / SS_Start, in serial order. */
314
258
  function advanceMediaHorizon(sbn: number): void {
315
259
  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
- }
260
+ if (liveSbnKnown && !serialPrecedes(liveSbn, sbn)) return;
322
261
  liveSbn = sbn;
323
262
  liveSbnKnown = true;
324
263
  trimBlockRetention();
@@ -336,10 +275,7 @@ function markBlockTerminal(
336
275
 
337
276
  function synchronizeDecoderHorizon(): void {
338
277
  if (!wasmDecoder || !liveSbnKnown) return;
339
- const iso = isIsoProfile();
340
- const expired = iso
341
- ? wasmDecoder.cleanup_by_ss_start(liveSbn)
342
- : wasmDecoder.cleanup_by_sbn(liveSbn);
278
+ const expired = wasmDecoder.cleanup_by_ss_start(liveSbn);
343
279
  for (const sbn of expired) {
344
280
  const block = blocks.get(sbn);
345
281
  if (!block || isTerminalBlock(block)) {
@@ -350,7 +286,7 @@ function synchronizeDecoderHorizon(): void {
350
286
  blocksFailed++;
351
287
  emitBlockUpdate(block);
352
288
  }
353
- if (iso) retireUnplacedSources();
289
+ retireUnplacedSources();
354
290
  trimBlockRetention();
355
291
  }
356
292
 
@@ -373,163 +309,6 @@ function retireUnplacedSources(): void {
373
309
  }
374
310
  }
375
311
 
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
312
  /**
534
313
  * Look up or create a block. `isoSsbLength` creates an iso-ssbg1-v1 block,
535
314
  * whose K' is its SSB_length (C.5.3); without it the block has the catalog K.
@@ -548,8 +327,9 @@ function getOrCreateBlock(sbn: number, isoSsbLength?: number): FecBlockSnapshot
548
327
  repairCount: config.repairCount,
549
328
  firstSymbolTs: now,
550
329
  lastSymbolTs: now,
330
+ // A positive interleaveMs makes this the catalog completion deadline;
331
+ // zero makes deliveryWindowMs a receiver-local cleanup bound.
551
332
  deadlineTs: now + config.deliveryWindowMs,
552
- altaVerified: 0,
553
333
  relayBlockSigValid: null,
554
334
  };
555
335
  if (isoSsbLength !== undefined) block.ssbLength = isoSsbLength;
@@ -583,8 +363,10 @@ function buildStatsSnapshot(): FecTrackStats {
583
363
 
584
364
  // Cap block array to interleave window
585
365
  const maxBlocks = Math.max(config.interleaveDepth * 3, 24);
366
+ // Newest first in SS_ID serial order, so the cap keeps the newest blocks
367
+ // across the 2^32 wrap too: every block start precedes the live SS_ID.
586
368
  const blockArray = Array.from(blocks.values())
587
- .sort((a, b) => b.sbn - a.sbn)
369
+ .sort((a, b) => serialDistance(a.sbn, liveSbn) - serialDistance(b.sbn, liveSbn))
588
370
  .slice(0, maxBlocks);
589
371
 
590
372
  return {
@@ -600,15 +382,6 @@ function buildStatsSnapshot(): FecTrackStats {
600
382
  (blocksRecovered / (blocksRecovered + blocksFailed)) * 100,
601
383
  ) || 0
602
384
  : 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
385
  relayBlocksVerified,
613
386
  relayBlocksFailed,
614
387
  liveSbn,
@@ -620,7 +393,7 @@ function buildStatsSnapshot(): FecTrackStats {
620
393
  sourceDroppedRetired,
621
394
  sourceDroppedTerminal,
622
395
  sourceDroppedDuplicate,
623
- ...(config.blockProfile === "iso-ssbg1-v1" ? { sourceUnplacedRetired } : {}),
396
+ sourceUnplacedRetired,
624
397
  decoder,
625
398
  };
626
399
  }
@@ -662,33 +435,23 @@ function emitRecoveredSymbols(
662
435
  return;
663
436
  }
664
437
 
665
- const iso = config.blockProfile === "iso-ssbg1-v1";
666
438
  for (let i = 0; i < ssbLength; i++) {
667
439
  const symbol = recoveredBlock.subarray(i * symbolSize, (i + 1) * symbolSize);
668
440
  // ssbg_mode1 symbols are emitted whole: the router trims them by their
669
441
  // length field. A field past T-2 or non-zero padding means the
670
442
  // recovered symbol is not what was sent, so it is refused, not trimmed.
671
- if (iso) {
672
- const invalid = invalidSsbg1Symbol(symbol, symbolSize);
673
- if (invalid !== null) {
674
- emit({
675
- type: "error",
676
- code: "recovered-ssbg1-symbol-invalid",
677
- message: `[FecWorker] Dropping recovered ssbg_mode1 symbol (ssStart=${sbn}, esi=${i}, SSB_length=${ssbLength}): ${invalid}`,
678
- });
679
- continue;
680
- }
443
+ const invalid = invalidSsbg1Symbol(symbol, symbolSize);
444
+ if (invalid !== null) {
445
+ emit({
446
+ type: "error",
447
+ code: "recovered-ssbg1-symbol-invalid",
448
+ message: `[FecWorker] Dropping recovered ssbg_mode1 symbol (ssStart=${sbn}, esi=${i}, SSB_length=${ssbLength}): ${invalid}`,
449
+ });
450
+ continue;
681
451
  }
682
452
 
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
453
  const buf = toOwnedArrayBuffer(symbol);
690
454
  const meta: FrameMeta = {
691
- altaVerified: recoveredAltaVerified,
692
455
  recovered: true,
693
456
  sbn,
694
457
  esi: i,
@@ -857,7 +620,6 @@ function feedIsoSource(
857
620
  advanceMediaHorizon(cmd.ssId);
858
621
  unplacedSources.set(cmd.ssId, performance.now());
859
622
  sourceSymbols++;
860
- if (altaEnabled) altaPending++;
861
623
  // No repair has named this source's block, so the decoder cannot have
862
624
  // one to recover; data here is a Worker/decoder disagreement that
863
625
  // cannot be attributed to a block, and is refused rather than guessed.
@@ -893,7 +655,6 @@ function feedIsoSource(
893
655
  block.sourceReceived++;
894
656
  block.lastSymbolTs = performance.now();
895
657
  sourceSymbols++;
896
- if (altaEnabled) altaPending++;
897
658
  settleSourceArrival(key, block, result);
898
659
  }
899
660
 
@@ -990,14 +751,17 @@ async function handleConfigure(
990
751
  const blockProfile: unknown = cmd.config.blockProfile;
991
752
  if (blockProfile === undefined) {
992
753
  throw new Error(
993
- '[FecWorker] configure: blockProfile is required: name the FEC block profile ("k-padded-v1" or "iso-ssbg1-v1")',
754
+ '[FecWorker] configure: blockProfile is required: name the FEC block profile ("iso-ssbg1-v1")',
994
755
  );
995
756
  }
996
- if (blockProfile !== "k-padded-v1" && blockProfile !== "iso-ssbg1-v1") {
757
+ if (blockProfile !== "iso-ssbg1-v1") {
997
758
  throw new Error(
998
- `[FecWorker] configure: blockProfile must be "k-padded-v1" or "iso-ssbg1-v1" (got ${JSON.stringify(blockProfile)})`,
759
+ `[FecWorker] configure: blockProfile must be "iso-ssbg1-v1" (got ${JSON.stringify(blockProfile)})`,
999
760
  );
1000
761
  }
762
+ // draft-ramadan-moq-fec-01 4: the two specified schemes. Anything but
763
+ // "reed-solomon" (the legacy "raptor" included) is RaptorQ.
764
+ const algorithm: FecScheme = cmd.config.algorithm === "reed-solomon" ? "reed-solomon" : "raptorq";
1001
765
  if (
1002
766
  !Number.isFinite(cmd.config.interleaveDepth) ||
1003
767
  !Number.isInteger(cmd.config.interleaveDepth) ||
@@ -1010,10 +774,11 @@ async function handleConfigure(
1010
774
  }
1011
775
  if (
1012
776
  !Number.isFinite(cmd.config.interleaveMs) ||
1013
- cmd.config.interleaveMs <= 0
777
+ !Number.isSafeInteger(cmd.config.interleaveMs) ||
778
+ cmd.config.interleaveMs < 0
1014
779
  ) {
1015
780
  throw new Error(
1016
- `[FecWorker] configure: interleaveMs must be finite and > 0 (got ${cmd.config.interleaveMs})`,
781
+ `[FecWorker] configure: interleaveMs must be a finite non-negative integer (got ${cmd.config.interleaveMs})`,
1017
782
  );
1018
783
  }
1019
784
  if (
@@ -1024,23 +789,18 @@ async function handleConfigure(
1024
789
  `[FecWorker] configure: deliveryWindowMs must be > 0 (got ${cmd.config.deliveryWindowMs})`,
1025
790
  );
1026
791
  }
1027
- if (
1028
- !Number.isInteger(cmd.config.k) ||
1029
- cmd.config.k <= 0 ||
1030
- cmd.config.k > MAX_RAPTORQ_SOURCE_SYMBOLS_PER_BLOCK
1031
- ) {
1032
- throw new Error(
1033
- `[FecWorker] configure: k must be an integer in 1..${MAX_RAPTORQ_SOURCE_SYMBOLS_PER_BLOCK} (got ${cmd.config.k})`,
1034
- );
792
+ const maxK = algorithm === "raptorq" ? MAX_RAPTORQ_SOURCE_SYMBOLS_PER_BLOCK : MAX_REED_SOLOMON_SOURCE_SYMBOLS_PER_BLOCK;
793
+ if (!Number.isInteger(cmd.config.k) || cmd.config.k <= 0 || cmd.config.k > maxK) {
794
+ throw new Error(`[FecWorker] configure: ${algorithm} k must be an integer in 1..${maxK} (got ${cmd.config.k})`);
1035
795
  }
1036
796
  if (
1037
797
  !Number.isInteger(cmd.config.symbolSize) ||
1038
798
  cmd.config.symbolSize <= 0 ||
1039
799
  cmd.config.symbolSize > MAX_SYMBOL_SIZE ||
1040
- cmd.config.symbolSize % 8 !== 0
800
+ (algorithm === "raptorq" && cmd.config.symbolSize % 8 !== 0)
1041
801
  ) {
1042
802
  throw new Error(
1043
- `[FecWorker] configure: symbolSize must be an 8-byte-aligned integer in 8..${MAX_SYMBOL_SIZE} (got ${cmd.config.symbolSize})`,
803
+ `[FecWorker] configure: ${algorithm} symbolSize must be ${algorithm === "raptorq" ? "an 8-byte-aligned integer in 8" : "an integer in 1"}..${MAX_SYMBOL_SIZE} (got ${cmd.config.symbolSize})`,
1044
804
  );
1045
805
  }
1046
806
 
@@ -1057,54 +817,16 @@ async function handleConfigure(
1057
817
 
1058
818
  // Decoder policy and geometry are explicit at the epoch boundary. The WASM
1059
819
  // module has no guessed K/T/interleave slow path.
1060
- const decoder = new wasm.MmtFecDecoder(cmd.config.interleaveDepth, blockProfile);
820
+ const decoder = new wasm.MmtFecDecoder(cmd.config.interleaveDepth, blockProfile, algorithm);
1061
821
  verifyDecoderProfile(decoder, blockProfile);
822
+ // A build that predates the algorithm argument ignores it and runs RaptorQ.
823
+ const decoderAlgorithm = decoder.get_algorithm?.() ?? "raptorq";
824
+ if (decoderAlgorithm !== algorithm) {
825
+ decoder.free();
826
+ throw new Error(`[FecWorker] configure: decoder runs ${JSON.stringify(decoderAlgorithm)}, not the configured "${algorithm}"`);
827
+ }
1062
828
  wasmDecoder = decoder;
1063
829
  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
830
  wasmDecoder.configure_params(config.k, config.symbolSize);
1109
831
 
1110
832
  // Tell the client which optional commands this Worker accepts, so it can
@@ -1134,51 +856,7 @@ function handleFeedSource(
1134
856
  throw new Error("[FecWorker] feedSource: invalid source symbol");
1135
857
  }
1136
858
 
1137
- const view = new Uint8Array(cmd.data);
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);
859
+ feedIsoSource(cmd, new Uint8Array(cmd.data), wasmDecoder, config);
1182
860
  }
1183
861
 
1184
862
  function handleFeedRepair(
@@ -1190,17 +868,14 @@ function handleFeedRepair(
1190
868
  if (!config) {
1191
869
  throw new Error("[FecWorker] feedRepair: config missing");
1192
870
  }
1193
- // k-padded-v1 blocks start at multiples of K and repair ESI = K + RS_ID;
1194
871
  // 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
872
  if (
1197
873
  !isUint32(cmd.ssStart) ||
1198
874
  !isUint32(cmd.ssbLength) ||
1199
875
  cmd.ssbLength <= 0 ||
1200
876
  cmd.ssbLength > config.k ||
1201
- (!iso && cmd.ssStart % config.k !== 0) ||
1202
877
  !isUint32(cmd.rsId) ||
1203
- (iso ? cmd.ssbLength : config.k) + cmd.rsId > UINT24_MAX ||
878
+ cmd.ssbLength + cmd.rsId > UINT24_MAX ||
1204
879
  !(cmd.data instanceof ArrayBuffer)
1205
880
  ) {
1206
881
  throw new Error("[FecWorker] feedRepair: invalid repair symbol");
@@ -1218,51 +893,7 @@ function handleFeedRepair(
1218
893
  }
1219
894
  return;
1220
895
  }
1221
- if (iso) {
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);
896
+ feedIsoRepair(cmd, view, wasmDecoder);
1266
897
  }
1267
898
 
1268
899
  function handleRelayBlockSig(
@@ -1271,8 +902,8 @@ function handleRelayBlockSig(
1271
902
  // No-op AUTH stub: store sig on block, mark as not-yet-verified
1272
903
  const block = blocks.get(cmd.sbn);
1273
904
  if (block) {
1274
- // Real verification deferred to ALTA milestone
1275
- // For now, mark as null (not verified) — will be wired when ALTA is implemented
905
+ // Relay block signatures are not verified here; source provenance is the
906
+ // signed per-group manifest checked at router admission.
1276
907
  block.relayBlockSigValid = null;
1277
908
  emitBlockUpdate(block);
1278
909
  }
@@ -1315,10 +946,9 @@ function handleCleanup(
1315
946
  continue;
1316
947
  }
1317
948
  currentBlock ??= getOrCreateBlock(sbn);
1318
- // k-padded-v1 compares with K, unchanged. iso-ssbg1-v1 compares with
1319
- // the block's SSB_length; a key no repair declared has none, so a
1320
- // block the Worker never knew can only have failed.
1321
- const authoredLength = isIsoProfile() ? currentBlock.ssbLength : currentBlock.k;
949
+ // Compare with the block's SSB_length; a key no repair declared has
950
+ // none, so a block the Worker never knew can only have failed.
951
+ const authoredLength = currentBlock.ssbLength;
1322
952
  if (authoredLength !== undefined && currentBlock.sourceReceived >= authoredLength) {
1323
953
  markBlockTerminal(currentBlock, "complete");
1324
954
  blocksComplete++;
@@ -1343,29 +973,12 @@ function handleDispose(): void {
1343
973
  snapshotTimer = null;
1344
974
  }
1345
975
 
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
976
  // Free WASM decoder (MUST call to release WASM memory)
1359
977
  if (wasmDecoder) {
1360
978
  wasmDecoder.free();
1361
979
  wasmDecoder = null;
1362
980
  }
1363
981
 
1364
- // Clear deferred-verify queue so pinned ArrayBuffers drop.
1365
- pendingVerify.clear();
1366
- pendingVerifyCap = 0;
1367
- pendingVerifyTtlMs = 0;
1368
-
1369
982
  config = null;
1370
983
  clearBlockTracking();
1371
984
  }
@@ -1392,13 +1005,6 @@ function handleReset(): void {
1392
1005
  blocksComplete = 0;
1393
1006
  blocksRecovered = 0;
1394
1007
  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
1008
  relayBlocksVerified = 0;
1403
1009
  relayBlocksFailed = 0;
1404
1010
  repairSymbolSizeMismatch = 0;
@@ -1448,8 +1054,7 @@ async function dispatchCommand(cmd: FecWorkerCommand): Promise<void> {
1448
1054
  try {
1449
1055
  switch (cmd.type) {
1450
1056
  case "configure":
1451
- // handleConfigure is async (ALTA WASM init uses dynamic import).
1452
- // Await it so feed/cleanup messages cannot run against a partially
1057
+ // Await handleConfigure so feed/cleanup messages cannot run against a partially
1453
1058
  // configured decoder.
1454
1059
  await handleConfigure(cmd);
1455
1060
  break;