@blockcast/fec-worker 0.1.0 → 0.3.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.
Files changed (40) hide show
  1. package/README.md +17 -7
  2. package/dist/block-order.d.ts +38 -0
  3. package/dist/block-order.d.ts.map +1 -0
  4. package/dist/block-order.js +78 -0
  5. package/dist/block-order.js.map +1 -0
  6. package/dist/fec-worker-client.d.ts +20 -7
  7. package/dist/fec-worker-client.d.ts.map +1 -1
  8. package/dist/fec-worker-client.js +106 -30
  9. package/dist/fec-worker-client.js.map +1 -1
  10. package/dist/fec-worker-types.d.ts +153 -25
  11. package/dist/fec-worker-types.d.ts.map +1 -1
  12. package/dist/fec-worker.js +382 -88
  13. package/dist/fec-worker.js.map +4 -4
  14. package/dist/index.d.ts +1 -1
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js.map +1 -1
  17. package/dist/shred-fec-worker.js +23 -2
  18. package/dist/shred-fec-worker.js.map +2 -2
  19. package/dist/shred-worker-types.d.ts +16 -1
  20. package/dist/shred-worker-types.d.ts.map +1 -1
  21. package/dist/shred-worker-types.js +16 -1
  22. package/dist/shred-worker-types.js.map +1 -1
  23. package/package.json +8 -3
  24. package/src/block-order.ts +93 -0
  25. package/src/fec-worker-client.ts +115 -32
  26. package/src/fec-worker-types.ts +152 -21
  27. package/src/fec-worker.ts +583 -129
  28. package/src/index.ts +3 -0
  29. package/src/shred-fec-worker.ts +34 -3
  30. package/src/shred-worker-types.ts +16 -1
  31. package/src/__tests__/fec-worker-alta.test.ts +0 -231
  32. package/src/__tests__/fec-worker-cleanup-integration.test.ts +0 -250
  33. package/src/__tests__/fec-worker-pending-queue.test.ts +0 -315
  34. package/src/__tests__/fec-worker-repair-size.test.ts +0 -1029
  35. package/src/__tests__/fec-worker-wasm-contract.test.ts +0 -84
  36. package/src/__tests__/shred-fec-worker.test.ts +0 -448
  37. package/src/fec-worker-client.test.ts +0 -530
  38. package/src/fec-worker-types.test.ts +0 -400
  39. package/src/shred-worker-client.test.ts +0 -182
  40. package/src/transfer.test.ts +0 -24
package/src/fec-worker.ts CHANGED
@@ -18,20 +18,41 @@ import type {
18
18
  FecTrackStats,
19
19
  FecBlockSnapshot,
20
20
  FrameMeta,
21
+ FecBlockProfile,
21
22
  } from "./fec-worker-types.js";
22
23
  import { toOwnedArrayBuffer } from "./transfer.js";
24
+ import {
25
+ blockPrecedes,
26
+ blocksBehind,
27
+ isoBlockContains,
28
+ isoBlocksBehind,
29
+ isoBlocksOverlap,
30
+ serialDistance,
31
+ serialPrecedes,
32
+ } from "./block-order.js";
23
33
 
24
34
  // --- WASM binding types (from mmt-wasm) ---
25
35
  // Signatures MUST match a freshly generated external mmt_wasm.d.ts exactly.
26
36
  // The decoder derives SBN/ESI internally — do not pre-compute.
27
37
  interface WasmFecDecoder {
28
- /** ISO 23008-1 §C.5.2: ss_id is the flat 32-bit Source Symbol ID. */
38
+ /**
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.
43
+ */
29
44
  add_source(
30
45
  ss_id: number,
31
46
  data: Uint8Array,
32
47
  timestamp: number,
33
48
  ): Uint8Array | undefined;
34
- /** ISO 23008-1 §C.5.3: repair ESI = ssb_length + rs_id, SBN = ss_start / ssb_length. */
49
+ /**
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.
55
+ */
35
56
  add_repair(
36
57
  ss_start: number,
37
58
  ssb_length: number,
@@ -40,10 +61,25 @@ interface WasmFecDecoder {
40
61
  timestamp: number,
41
62
  ): Uint8Array | undefined;
42
63
  configure_params(k: number, symbolSize: number): void;
43
- /** Block-scoped counterpart driven by the manager's exact deadlines. */
44
- cleanup_blocks_detailed(sbns: Uint32Array): Uint8Array;
45
- /** Remove decoder blocks below its configured live-SBN interleave horizon. */
64
+ /**
65
+ * Retire the named blocks (last-chance decode + stats), driven by the
66
+ * manager's exact deadlines. Nothing is synthesised for a lost block:
67
+ * concealment is the sink's hold-last-good-frame.
68
+ */
69
+ cleanup_blocks_detailed(sbns: Uint32Array): void;
70
+ /**
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
+ */
46
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.
79
+ */
80
+ cleanup_by_ss_start(current: number): Uint32Array;
81
+ /** The block profile the decoder was constructed with. */
82
+ get_block_profile(): string;
47
83
  flush(): void;
48
84
  pending_blocks(): number;
49
85
  reset_stats(): void;
@@ -61,10 +97,7 @@ interface WasmFecDecoder {
61
97
 
62
98
  interface WasmBindings {
63
99
  initSync(module: { module: WebAssembly.Module }): unknown;
64
- MmtFecDecoder: new (
65
- interleaveDepth: number,
66
- greenFillEnabled: boolean,
67
- ) => WasmFecDecoder;
100
+ MmtFecDecoder: new (interleaveDepth: number, blockProfile: FecBlockProfile) => WasmFecDecoder;
68
101
  }
69
102
 
70
103
  // --- Worker state (local to this Worker instance) ---
@@ -75,13 +108,22 @@ let altaEnabled = false;
75
108
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
76
109
  let altaVerifier: any = null; // JsAltaVerifier instance (loaded from ALTA WASM)
77
110
  let snapshotTimer: ReturnType<typeof setInterval> | null = null;
111
+ // blockUpdate events are posted only after a client subscribes (BLO-36599).
112
+ let blockUpdatesSubscribed = false;
78
113
 
79
114
  // Block tracking
80
115
  const blocks = new Map<number, FecBlockSnapshot>();
81
116
  const acceptedSourceEsis = new Map<number, Set<number>>();
82
117
  const acceptedRepairEsis = new Map<number, Set<number>>();
118
+ // iso-ssbg1-v1: sources fed to the decoder before any repair named a block
119
+ // containing them, keyed by SS_ID with their arrival time. Map order is
120
+ // arrival order, which the horizon sweep relies on (retireUnplacedSources).
121
+ const unplacedSources = new Map<number, number>();
83
122
  const UINT32_MAX = 0xffff_ffff;
84
123
  const UINT24_MAX = 0xff_ffff;
124
+ const SS_ID_SPAN = 0x1_0000_0000;
125
+ // ISO/IEC 23008-1 C.3.3 ssbg_mode1: the two-octet protected-packet length field.
126
+ const SSBG1_LENGTH_FIELD_BYTES = 2;
85
127
  // ISO/IEC 23008-1 AL-FEC signaling carries interleave_depth in one byte.
86
128
  const MAX_INTERLEAVE_DEPTH = 0xff;
87
129
  // Rust stores T as u16 and this decoder profile fixes RFC 6330 Al=8.
@@ -95,7 +137,6 @@ let repairSymbols = 0;
95
137
  let blocksComplete = 0;
96
138
  let blocksRecovered = 0;
97
139
  let blocksFailed = 0;
98
- let greenFillFrames = 0;
99
140
  let altaVerified = 0;
100
141
  let altaFailed = 0; // Crypto-confirmed tamper (MAC / sig / hash chain mismatch)
101
142
  const altaFailedDetails: Array<{
@@ -113,11 +154,36 @@ let altaInitFailed = false; // ALTA configured but WASM init failed; altaEnab
113
154
  let relayBlocksVerified = 0;
114
155
  let relayBlocksFailed = 0;
115
156
  let liveSbn = 0;
116
- // Highest SBN that has fallen strictly below the decoder's retained
117
- // interleave window. Numeric ordering is per decoder epoch; reset/configure
118
- // starts a new epoch before the 32-bit source-symbol counter can wrap.
119
- let retiredSbnFrontier = -1;
157
+ // Whether liveSbn has been set by a symbol of this decoder epoch. Block order
158
+ // is serial on the 32-bit SS_ID (block-order.ts, BLO-35597 iso:5), where 0 is
159
+ // a real position, so "nothing seen yet" needs its own state.
160
+ let liveSbnKnown = false;
120
161
  let repairSymbolSizeMismatch = 0;
162
+ // Source symbols dropped before WASM, by cause. All three sites below were
163
+ // silent, which left the "block settles at exactly K-1 source symbols" shape
164
+ // (BLO-35597 defect 2) with no evidence to attribute it to. A retired drop
165
+ // means a late symbol past retention; a terminal drop means the block had
166
+ // already settled — `state !== "collecting"`, so complete OR recovered OR
167
+ // FAILED, and the failed arm is the one this issue is chasing (a block aged out
168
+ // by synchronizeDecoderHorizon stays in `blocks` until its SBN retires, so a
169
+ // late symbol for a short block lands here); a duplicate drop means a repeated
170
+ // (SBN, ESI), which on this wire is usually a plain duplicate packet — the
171
+ // router's ledger has deliberate gaps — and only sometimes an SBN/ESI
172
+ // derivation collision.
173
+ //
174
+ // These narrow the cause; they do not uniquely attribute it. A symbol LOST IN
175
+ // TRANSIT never reaches handleFeedSource and increments nothing, so all three
176
+ // reading zero on a short block is NOT evidence of a publisher defect — wire
177
+ // loss is the null hypothesis here, and it produces an identical shape. The
178
+ // discriminator for that residual arm is `block.ssbLength` (set at the repair
179
+ // symbol's declared SSB_length, conflict-checked on arrival):
180
+ // ssbLength < k → genuinely short authored block
181
+ // ssbLength === k, sourceReceived < k → loss or a still-late symbol
182
+ // With no repair symbol yet seen, ssbLength is undefined and both stay open.
183
+ let sourceDroppedRetired = 0;
184
+ let sourceDroppedTerminal = 0;
185
+ let sourceDroppedDuplicate = 0;
186
+ let sourceUnplacedRetired = 0;
121
187
 
122
188
  // Recovery timing
123
189
  const recoveryTimes: number[] = [];
@@ -174,10 +240,60 @@ function clearBlockTracking(): void {
174
240
  blocks.clear();
175
241
  acceptedSourceEsis.clear();
176
242
  acceptedRepairEsis.clear();
243
+ unplacedSources.clear();
177
244
  pendingVerify.clear();
178
245
  recoveryTimes.length = 0;
179
246
  liveSbn = 0;
180
- retiredSbnFrontier = -1;
247
+ liveSbnKnown = false;
248
+ }
249
+
250
+ /**
251
+ * Whether `sbn` has fallen behind the decoder's retained interleave window:
252
+ * more than `interleaveDepth` blocks behind the live SBN. liveSbn only moves
253
+ * forward within a decoder epoch and configure() resets both, so this is the
254
+ * monotonic retirement frontier the decoder horizon implies.
255
+ */
256
+ function isRetiredSbn(sbn: number): boolean {
257
+ 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";
267
+ }
268
+
269
+ /**
270
+ * iso-ssbg1-v1: the SS_IDs a known block certainly covers. Every block a
271
+ * repair created has its SSB_length; a cleanup tombstone for a block no
272
+ * repair declared certainly holds only its own SS_Start.
273
+ */
274
+ function isoKnownLength(block: FecBlockSnapshot): number {
275
+ return block.ssbLength ?? 1;
276
+ }
277
+
278
+ /** iso-ssbg1-v1: the known block whose SS_ID range contains `ssId` (ranges never overlap). */
279
+ function findIsoBlock(ssId: number): FecBlockSnapshot | undefined {
280
+ for (const block of blocks.values()) {
281
+ if (isoBlockContains(block.sbn, isoKnownLength(block), ssId)) return block;
282
+ }
283
+ return undefined;
284
+ }
285
+
286
+ /**
287
+ * Whether a terminal block's diagnostics may be deleted. Under iso-ssbg1-v1 a
288
+ * source carries no block identity, so a deleted block cannot refuse a late
289
+ * source of its own: that source would be admitted as unplaced and reopen the
290
+ * block in the decoder. The tombstone therefore stays until its LAST SS_ID is
291
+ * retired, after which the SS_ID retirement check refuses every member.
292
+ */
293
+ function isRetiredBlock(key: number, block: FecBlockSnapshot): boolean {
294
+ return isIsoProfile()
295
+ ? isRetiredSbn((key + isoKnownLength(block) - 1) % SS_ID_SPAN)
296
+ : isRetiredSbn(key);
181
297
  }
182
298
 
183
299
  /**
@@ -188,17 +304,23 @@ function clearBlockTracking(): void {
188
304
  */
189
305
  function trimBlockRetention(): void {
190
306
  for (const [sbn, block] of blocks) {
191
- if (!isTerminalBlock(block) || sbn > retiredSbnFrontier) continue;
307
+ if (!isTerminalBlock(block) || !isRetiredBlock(sbn, block)) continue;
192
308
  blocks.delete(sbn);
193
309
  retireAcceptedIds(sbn);
194
310
  }
195
311
  }
196
312
 
313
+ /** Advance the live position: an SBN (k-padded-v1) or an SS_ID / SS_Start (iso-ssbg1-v1). */
197
314
  function advanceMediaHorizon(sbn: number): void {
198
- if (!config || sbn <= liveSbn) return;
315
+ 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
+ }
199
322
  liveSbn = sbn;
200
- const firstRetainedSbn = Math.max(0, liveSbn - config.interleaveDepth);
201
- retiredSbnFrontier = Math.max(retiredSbnFrontier, firstRetainedSbn - 1);
323
+ liveSbnKnown = true;
202
324
  trimBlockRetention();
203
325
  }
204
326
 
@@ -213,8 +335,11 @@ function markBlockTerminal(
213
335
  }
214
336
 
215
337
  function synchronizeDecoderHorizon(): void {
216
- if (!wasmDecoder) return;
217
- const expired = wasmDecoder.cleanup_by_sbn(liveSbn);
338
+ 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);
218
343
  for (const sbn of expired) {
219
344
  const block = blocks.get(sbn);
220
345
  if (!block || isTerminalBlock(block)) {
@@ -225,9 +350,29 @@ function synchronizeDecoderHorizon(): void {
225
350
  blocksFailed++;
226
351
  emitBlockUpdate(block);
227
352
  }
353
+ if (iso) retireUnplacedSources();
228
354
  trimBlockRetention();
229
355
  }
230
356
 
357
+ /**
358
+ * iso-ssbg1-v1: retire unplaced sources behind the horizon, by the same
359
+ * isoBlocksBehind metric as a block start, sweeping in arrival order and
360
+ * stopping at the first live one (amortised O(1) per symbol). A source that
361
+ * arrived out of order behind a newer live one is retired when it reaches the
362
+ * front; until then nothing can place it, because any repair range holding it
363
+ * starts at or before it and is itself retired. One the live position moved
364
+ * at least half the SS_ID space past no longer precedes it serially; it is
365
+ * retired too rather than pinning the sweep.
366
+ */
367
+ function retireUnplacedSources(): void {
368
+ for (const ssId of unplacedSources.keys()) {
369
+ const unreachable = ssId !== liveSbn && !serialPrecedes(ssId, liveSbn);
370
+ if (!unreachable && !isRetiredSbn(ssId)) break;
371
+ unplacedSources.delete(ssId);
372
+ sourceUnplacedRetired++;
373
+ }
374
+ }
375
+
231
376
  /**
232
377
  * Run detached ALTA verify against the current verifier. Four-state result:
233
378
  * - "valid": crypto passed, packet authentic. Caller increments altaVerified.
@@ -385,7 +530,11 @@ function drainPendingVerify(): void {
385
530
  }
386
531
  }
387
532
 
388
- function getOrCreateBlock(sbn: number): FecBlockSnapshot {
533
+ /**
534
+ * Look up or create a block. `isoSsbLength` creates an iso-ssbg1-v1 block,
535
+ * whose K' is its SSB_length (C.5.3); without it the block has the catalog K.
536
+ */
537
+ function getOrCreateBlock(sbn: number, isoSsbLength?: number): FecBlockSnapshot {
389
538
  let block = blocks.get(sbn);
390
539
  if (!block) {
391
540
  if (!config) throw new Error("[FecWorker] not configured");
@@ -395,7 +544,7 @@ function getOrCreateBlock(sbn: number): FecBlockSnapshot {
395
544
  state: "collecting",
396
545
  sourceReceived: 0,
397
546
  repairReceived: 0,
398
- k: config.k,
547
+ k: isoSsbLength ?? config.k,
399
548
  repairCount: config.repairCount,
400
549
  firstSymbolTs: now,
401
550
  lastSymbolTs: now,
@@ -403,6 +552,7 @@ function getOrCreateBlock(sbn: number): FecBlockSnapshot {
403
552
  altaVerified: 0,
404
553
  relayBlockSigValid: null,
405
554
  };
555
+ if (isoSsbLength !== undefined) block.ssbLength = isoSsbLength;
406
556
  blocks.set(sbn, block);
407
557
  trimBlockRetention();
408
558
  }
@@ -444,7 +594,6 @@ function buildStatsSnapshot(): FecTrackStats {
444
594
  blocksComplete,
445
595
  blocksRecovered,
446
596
  blocksFailed,
447
- greenFillFrames,
448
597
  recoveryRate:
449
598
  blocksComplete + blocksRecovered > 0
450
599
  ? Math.round(
@@ -468,6 +617,10 @@ function buildStatsSnapshot(): FecTrackStats {
468
617
  avgRecoveryMs: Math.round(avgRecoveryMs * 100) / 100,
469
618
  maxRecoveryMs: Math.round(maxRecoveryMs * 100) / 100,
470
619
  repairSymbolSizeMismatch,
620
+ sourceDroppedRetired,
621
+ sourceDroppedTerminal,
622
+ sourceDroppedDuplicate,
623
+ ...(config.blockProfile === "iso-ssbg1-v1" ? { sourceUnplacedRetired } : {}),
471
624
  decoder,
472
625
  };
473
626
  }
@@ -478,6 +631,7 @@ function emitSnapshot(): void {
478
631
  }
479
632
 
480
633
  function emitBlockUpdate(block: FecBlockSnapshot): void {
634
+ if (!blockUpdatesSubscribed) return;
481
635
  emit({ type: "blockUpdate", block: { ...block } });
482
636
  }
483
637
 
@@ -496,17 +650,35 @@ function emitRecoveredSymbols(
496
650
  });
497
651
  return;
498
652
  }
499
- if (recoveredBlock.byteLength < symbolSize * config.k) {
653
+ // The decoder returns only the block's authored source symbols
654
+ // (SSB_length x T); the encoder's zero padding is not media.
655
+ const ssbLength = block.ssbLength ?? config.k;
656
+ if (recoveredBlock.byteLength !== symbolSize * ssbLength) {
500
657
  emit({
501
658
  type: "error",
502
659
  code: "recovered-block-too-short",
503
- message: `[FecWorker] recovered block too short: got ${recoveredBlock.byteLength}B, expected at least ${symbolSize * config.k}B`,
660
+ message: `[FecWorker] recovered block has the wrong length: got ${recoveredBlock.byteLength}B, expected ${symbolSize * ssbLength}B (SSB_length=${ssbLength})`,
504
661
  });
505
662
  return;
506
663
  }
507
664
 
508
- for (let i = 0; i < config.k; i++) {
665
+ const iso = config.blockProfile === "iso-ssbg1-v1";
666
+ for (let i = 0; i < ssbLength; i++) {
509
667
  const symbol = recoveredBlock.subarray(i * symbolSize, (i + 1) * symbolSize);
668
+ // ssbg_mode1 symbols are emitted whole: the router trims them by their
669
+ // length field. A field past T-2 or non-zero padding means the
670
+ // 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
+ }
681
+ }
510
682
 
511
683
  // Source authentication now runs once at router admission in
512
684
  // @blockcast/mmt-manifest-verify. The worker only records that this
@@ -520,11 +692,25 @@ function emitRecoveredSymbols(
520
692
  recovered: true,
521
693
  sbn,
522
694
  esi: i,
695
+ ssbLength,
523
696
  };
524
697
  emit({ type: "frame", data: buf, meta }, [buf]);
525
698
  }
526
699
  }
527
700
 
701
+ /** Why a T-byte ssbg_mode1 symbol is malformed (C.3.3), or null when it is well formed. */
702
+ function invalidSsbg1Symbol(symbol: Uint8Array, symbolSize: number): string | null {
703
+ const length = (symbol[0]! << 8) | symbol[1]!;
704
+ const maxLength = symbolSize - SSBG1_LENGTH_FIELD_BYTES;
705
+ if (length > maxLength) {
706
+ return `length field ${length} exceeds T-2 = ${maxLength}`;
707
+ }
708
+ for (let j = SSBG1_LENGTH_FIELD_BYTES + length; j < symbolSize; j++) {
709
+ if (symbol[j] !== 0) return `non-zero padding at byte ${j} after a ${length}-byte packet`;
710
+ }
711
+ return null;
712
+ }
713
+
528
714
  function startSnapshotTimer(): void {
529
715
  if (snapshotTimer !== null) clearInterval(snapshotTimer);
530
716
  snapshotTimer = setInterval(() => emitSnapshot(), 200);
@@ -541,6 +727,253 @@ function resolveWasmBindings(): WasmBindings {
541
727
  return bindings;
542
728
  }
543
729
 
730
+ /**
731
+ * Refuse a decoder that does not run the configured block profile: one that
732
+ * ignored or predates the constructor argument would apply one profile's
733
+ * block math to the other's wire.
734
+ */
735
+ function verifyDecoderProfile(decoder: WasmFecDecoder, expected: FecBlockProfile): void {
736
+ let actual: unknown;
737
+ try {
738
+ actual = decoder.get_block_profile();
739
+ } catch (err) {
740
+ decoder.free();
741
+ throw new Error(
742
+ `[FecWorker] configure: decoder cannot report its block profile (${err instanceof Error ? err.message : String(err)})`,
743
+ );
744
+ }
745
+ if (actual !== expected) {
746
+ decoder.free();
747
+ throw new Error(
748
+ `[FecWorker] configure: decoder runs block profile ${JSON.stringify(actual)}, not the configured "${expected}"`,
749
+ );
750
+ }
751
+ }
752
+
753
+ function acceptId(ids: Map<number, Set<number>>, key: number, id: number): void {
754
+ let set = ids.get(key);
755
+ if (!set) {
756
+ set = new Set<number>();
757
+ ids.set(key, set);
758
+ }
759
+ set.add(id);
760
+ }
761
+
762
+ /** A block the decoder returned: record its recovery and emit its source symbols. */
763
+ function recordRecovered(key: number, block: FecBlockSnapshot, result: Uint8Array): void {
764
+ const recovered = new Uint8Array(toOwnedArrayBuffer(result));
765
+ const recoveryMs = performance.now() - block.firstSymbolTs;
766
+ recoveryTimes.push(recoveryMs);
767
+ // Cap recovery times array at 1000 entries
768
+ if (recoveryTimes.length > 1000) recoveryTimes.shift();
769
+
770
+ if (block.state === "collecting") {
771
+ markBlockTerminal(block, "recovered");
772
+ blocksRecovered++;
773
+ emitBlockUpdate(block);
774
+ }
775
+ emitRecoveredSymbols(key, recovered, block);
776
+ }
777
+
778
+ /** Settle a block after one of its source symbols was accepted. */
779
+ function settleSourceArrival(
780
+ key: number,
781
+ block: FecBlockSnapshot,
782
+ result: Uint8Array | undefined,
783
+ ): void {
784
+ // Check if block is complete (all authored source symbols received:
785
+ // SSB_length once a repair symbol declared it, otherwise K)
786
+ const authoredLength = block.ssbLength ?? block.k;
787
+ if (
788
+ block.sourceReceived >= authoredLength &&
789
+ block.state === "collecting"
790
+ ) {
791
+ markBlockTerminal(block, "complete");
792
+ blocksComplete++;
793
+ emitBlockUpdate(block);
794
+ }
795
+
796
+ // A source-triggered return can be either clean block completion or recovery
797
+ // of earlier missing symbols after repair arrived. Clean completion is
798
+ // already routed by the source path, so only emit when the block did not
799
+ // reach K source arrivals.
800
+ if (result && result.byteLength > 0 && block.sourceReceived < authoredLength) {
801
+ recordRecovered(key, block, result);
802
+ }
803
+ synchronizeDecoderHorizon();
804
+ }
805
+
806
+ /** Settle a block after one of its repair symbols was accepted. */
807
+ function settleRepairArrival(
808
+ key: number,
809
+ block: FecBlockSnapshot,
810
+ ssbLength: number,
811
+ result: Uint8Array | undefined,
812
+ ): void {
813
+ // Every authored source symbol already arrived: knowing SSB_length makes
814
+ // the block clean. The decoder's return is the source already routed.
815
+ // Otherwise, returned data is the recovered source block: its SSB_length
816
+ // fixed-width authored symbols concatenated. Split it before emitting so
817
+ // MmtpRouter gets the same one-symbol shape as a direct source packet.
818
+ if (block.sourceReceived >= ssbLength && block.state === "collecting") {
819
+ markBlockTerminal(block, "complete");
820
+ blocksComplete++;
821
+ emitBlockUpdate(block);
822
+ } else if (result && result.byteLength > 0) {
823
+ recordRecovered(key, block, result);
824
+ }
825
+ synchronizeDecoderHorizon();
826
+ }
827
+
828
+ /**
829
+ * iso-ssbg1-v1 source admission. A source inside a known block is accounted
830
+ * at ESI = SS_ID - SS_Start. One no repair has placed yet is held unplaced,
831
+ * deduplicated by SS_ID, and still fed to the decoder, which holds it too,
832
+ * until a repair names a range containing it.
833
+ */
834
+ function feedIsoSource(
835
+ cmd: Extract<FecWorkerCommand, { type: "feedSource" }>,
836
+ view: Uint8Array,
837
+ decoder: WasmFecDecoder,
838
+ cfg: FecTrackConfig,
839
+ ): void {
840
+ const maxPacketLength = cfg.symbolSize - SSBG1_LENGTH_FIELD_BYTES;
841
+ if (view.byteLength > maxPacketLength) {
842
+ throw new Error(
843
+ `[FecWorker] feedSource: iso-ssbg1-v1 protected packet of ${view.byteLength}B exceeds T-2 = ${maxPacketLength}B`,
844
+ );
845
+ }
846
+ const block = findIsoBlock(cmd.ssId);
847
+ if (!block) {
848
+ if (isRetiredSbn(cmd.ssId)) {
849
+ sourceDroppedRetired++;
850
+ return;
851
+ }
852
+ if (unplacedSources.has(cmd.ssId)) {
853
+ sourceDroppedDuplicate++;
854
+ return;
855
+ }
856
+ const result = decoder.add_source(cmd.ssId, view, cmd.ts);
857
+ advanceMediaHorizon(cmd.ssId);
858
+ unplacedSources.set(cmd.ssId, performance.now());
859
+ sourceSymbols++;
860
+ if (altaEnabled) altaPending++;
861
+ // No repair has named this source's block, so the decoder cannot have
862
+ // one to recover; data here is a Worker/decoder disagreement that
863
+ // cannot be attributed to a block, and is refused rather than guessed.
864
+ if (result && result.byteLength > 0) {
865
+ emit({
866
+ type: "error",
867
+ code: "recovered-block-unplaced",
868
+ message: `[FecWorker] Dropping a ${result.byteLength}B recovered block returned for SS_ID ${cmd.ssId}, which no known block contains`,
869
+ });
870
+ }
871
+ synchronizeDecoderHorizon();
872
+ return;
873
+ }
874
+
875
+ const key = block.sbn;
876
+ if (isRetiredSbn(key)) {
877
+ sourceDroppedRetired++;
878
+ return;
879
+ }
880
+ if (isTerminalBlock(block)) {
881
+ sourceDroppedTerminal++;
882
+ return;
883
+ }
884
+ const esi = serialDistance(key, cmd.ssId);
885
+ if (acceptedSourceEsis.get(key)?.has(esi)) {
886
+ sourceDroppedDuplicate++;
887
+ return;
888
+ }
889
+
890
+ const result = decoder.add_source(cmd.ssId, view, cmd.ts);
891
+ advanceMediaHorizon(cmd.ssId);
892
+ acceptId(acceptedSourceEsis, key, esi);
893
+ block.sourceReceived++;
894
+ block.lastSymbolTs = performance.now();
895
+ sourceSymbols++;
896
+ if (altaEnabled) altaPending++;
897
+ settleSourceArrival(key, block, result);
898
+ }
899
+
900
+ /** iso-ssbg1-v1: a known block sharing an SS_ID with `[ssStart, ssStart + ssbLength)`. */
901
+ function findOverlappingIsoBlock(
902
+ ssStart: number,
903
+ ssbLength: number,
904
+ ): FecBlockSnapshot | undefined {
905
+ for (const block of blocks.values()) {
906
+ if (isoBlocksOverlap(block.sbn, isoKnownLength(block), ssStart, ssbLength)) return block;
907
+ }
908
+ return undefined;
909
+ }
910
+
911
+ /**
912
+ * iso-ssbg1-v1: create the block a repair first named, with K' = SSB_length,
913
+ * and move every unplaced source in its range into its accounting.
914
+ */
915
+ function placeIsoBlock(ssStart: number, ssbLength: number): FecBlockSnapshot {
916
+ const block = getOrCreateBlock(ssStart, ssbLength);
917
+ for (let esi = 0; esi < ssbLength; esi++) {
918
+ const ssId = (ssStart + esi) % SS_ID_SPAN;
919
+ const arrivalTs = unplacedSources.get(ssId);
920
+ if (arrivalTs === undefined) continue;
921
+ unplacedSources.delete(ssId);
922
+ acceptId(acceptedSourceEsis, ssStart, esi);
923
+ block.sourceReceived++;
924
+ if (arrivalTs < block.firstSymbolTs) block.firstSymbolTs = arrivalTs;
925
+ }
926
+ return block;
927
+ }
928
+
929
+ /**
930
+ * iso-ssbg1-v1 repair admission. The block is `[SS_Start, SS_Start +
931
+ * SSB_length)` keyed by SS_Start, with repair ESI = SSB_length + RS_ID. A
932
+ * known block's SSB_length is checked before deduplication because the
933
+ * dedup key depends on it.
934
+ */
935
+ function feedIsoRepair(
936
+ cmd: Extract<FecWorkerCommand, { type: "feedRepair" }>,
937
+ view: Uint8Array,
938
+ decoder: WasmFecDecoder,
939
+ ): void {
940
+ const key = cmd.ssStart;
941
+ if (isRetiredSbn(key)) return;
942
+ const existingBlock = blocks.get(key);
943
+ if (existingBlock && isTerminalBlock(existingBlock)) return;
944
+ if (existingBlock) {
945
+ if (existingBlock.ssbLength !== cmd.ssbLength) {
946
+ emit({
947
+ type: "error",
948
+ code: "repair-ssb-length-conflict",
949
+ message: `[FecWorker] Dropping repair symbol whose SSB_length ${cmd.ssbLength} conflicts with ${existingBlock.ssbLength} (ssStart=${key})`,
950
+ });
951
+ return;
952
+ }
953
+ } else {
954
+ const overlapped = findOverlappingIsoBlock(key, cmd.ssbLength);
955
+ if (overlapped) {
956
+ emit({
957
+ type: "error",
958
+ code: "repair-block-overlap",
959
+ message: `[FecWorker] Dropping repair symbol whose block (ssStart=${key}, SSB_length=${cmd.ssbLength}) overlaps known block (ssStart=${overlapped.sbn}, SSB_length=${isoKnownLength(overlapped)})`,
960
+ });
961
+ return;
962
+ }
963
+ }
964
+ const repairEsi = cmd.ssbLength + cmd.rsId;
965
+ if (acceptedRepairEsis.get(key)?.has(repairEsi)) return;
966
+
967
+ const result = decoder.add_repair(key, cmd.ssbLength, cmd.rsId, view, cmd.ts);
968
+ advanceMediaHorizon(key);
969
+ const block = existingBlock ?? placeIsoBlock(key, cmd.ssbLength);
970
+ acceptId(acceptedRepairEsis, key, repairEsi);
971
+ block.repairReceived++;
972
+ block.lastSymbolTs = performance.now();
973
+ repairSymbols++;
974
+ settleRepairArrival(key, block, cmd.ssbLength, result);
975
+ }
976
+
544
977
  // --- Command handlers ---
545
978
 
546
979
  async function handleConfigure(
@@ -552,6 +985,19 @@ async function handleConfigure(
552
985
  if (!cmd.config) {
553
986
  throw new Error("[FecWorker] configure: config required");
554
987
  }
988
+ // The block profile is the one discriminator for two wire-identical block
989
+ // maths, so it is required and never defaulted (BLO-35597).
990
+ const blockProfile: unknown = cmd.config.blockProfile;
991
+ if (blockProfile === undefined) {
992
+ throw new Error(
993
+ '[FecWorker] configure: blockProfile is required: name the FEC block profile ("k-padded-v1" or "iso-ssbg1-v1")',
994
+ );
995
+ }
996
+ if (blockProfile !== "k-padded-v1" && blockProfile !== "iso-ssbg1-v1") {
997
+ throw new Error(
998
+ `[FecWorker] configure: blockProfile must be "k-padded-v1" or "iso-ssbg1-v1" (got ${JSON.stringify(blockProfile)})`,
999
+ );
1000
+ }
555
1001
  if (
556
1002
  !Number.isFinite(cmd.config.interleaveDepth) ||
557
1003
  !Number.isInteger(cmd.config.interleaveDepth) ||
@@ -611,10 +1057,9 @@ async function handleConfigure(
611
1057
 
612
1058
  // Decoder policy and geometry are explicit at the epoch boundary. The WASM
613
1059
  // module has no guessed K/T/interleave slow path.
614
- wasmDecoder = new wasm.MmtFecDecoder(
615
- cmd.config.interleaveDepth,
616
- cmd.config.trackType === "video",
617
- );
1060
+ const decoder = new wasm.MmtFecDecoder(cmd.config.interleaveDepth, blockProfile);
1061
+ verifyDecoderProfile(decoder, blockProfile);
1062
+ wasmDecoder = decoder;
618
1063
  config = cmd.config;
619
1064
  altaEnabled = !!cmd.altaPublicKey;
620
1065
 
@@ -662,6 +1107,10 @@ async function handleConfigure(
662
1107
 
663
1108
  wasmDecoder.configure_params(config.k, config.symbolSize);
664
1109
 
1110
+ // Tell the client which optional commands this Worker accepts, so it can
1111
+ // batch symbols without risking a Worker that predates the batch command.
1112
+ emit({ type: "capabilities", commands: ["feedSymbols", "subscribeBlockUpdates"] });
1113
+
665
1114
  // Start periodic snapshot emission (200ms floor)
666
1115
  startSnapshotTimer();
667
1116
 
@@ -686,15 +1135,28 @@ function handleFeedSource(
686
1135
  }
687
1136
 
688
1137
  const view = new Uint8Array(cmd.data);
1138
+ if (config.blockProfile === "iso-ssbg1-v1") {
1139
+ feedIsoSource(cmd, view, wasmDecoder, config);
1140
+ return;
1141
+ }
689
1142
 
690
1143
  // Derive SBN locally for block-level accounting. WASM derives the same
691
1144
  // value internally (ISO 23008-1 §C.5.2: SBN = floor(SS_ID / K)).
692
1145
  const sbn = config.k > 0 ? Math.floor(cmd.ssId / config.k) : cmd.ssId;
693
1146
  const esi = cmd.ssId % config.k;
694
- if (sbn <= retiredSbnFrontier) return;
1147
+ if (isRetiredSbn(sbn)) {
1148
+ sourceDroppedRetired++;
1149
+ return;
1150
+ }
695
1151
  const existingBlock = blocks.get(sbn);
696
- if (existingBlock && isTerminalBlock(existingBlock)) return;
697
- if (acceptedSourceEsis.get(sbn)?.has(esi)) return;
1152
+ if (existingBlock && isTerminalBlock(existingBlock)) {
1153
+ sourceDroppedTerminal++;
1154
+ return;
1155
+ }
1156
+ if (acceptedSourceEsis.get(sbn)?.has(esi)) {
1157
+ sourceDroppedDuplicate++;
1158
+ return;
1159
+ }
698
1160
 
699
1161
  const result = wasmDecoder.add_source(cmd.ssId, view, cmd.ts);
700
1162
  advanceMediaHorizon(sbn);
@@ -716,35 +1178,8 @@ function handleFeedSource(
716
1178
  // authenticated by the canonical manifest verifier at router admission.
717
1179
  if (altaEnabled) altaPending++;
718
1180
 
719
- // Check if block is complete (all K source symbols received)
720
- if (
721
- block.sourceReceived >= block.k &&
722
- block.state === "collecting"
723
- ) {
724
- markBlockTerminal(block, "complete");
725
- blocksComplete++;
726
- emitBlockUpdate(block);
727
- }
728
-
729
- // A source-triggered return can be either clean block completion or recovery
730
- // of earlier missing symbols after repair arrived. Clean completion is
731
- // already routed by the source path, so only emit when the block did not
732
- // reach K source arrivals.
733
- if (result && result.byteLength > 0 && block.sourceReceived < block.k) {
734
- const recovered = new Uint8Array(toOwnedArrayBuffer(result));
735
- const recoveryMs = performance.now() - block.firstSymbolTs;
736
- recoveryTimes.push(recoveryMs);
737
- if (recoveryTimes.length > 1000) recoveryTimes.shift();
738
-
739
- if (block.state === "collecting") {
740
- markBlockTerminal(block, "recovered");
741
- blocksRecovered++;
742
- emitBlockUpdate(block);
743
- }
744
- emitRecoveredSymbols(sbn, recovered, block);
745
- }
746
- synchronizeDecoderHorizon();
747
- }
1181
+ settleSourceArrival(sbn, block, result);
1182
+ }
748
1183
 
749
1184
  function handleFeedRepair(
750
1185
  cmd: Extract<FecWorkerCommand, { type: "feedRepair" }>,
@@ -755,14 +1190,17 @@ function handleFeedRepair(
755
1190
  if (!config) {
756
1191
  throw new Error("[FecWorker] feedRepair: config missing");
757
1192
  }
1193
+ // k-padded-v1 blocks start at multiples of K and repair ESI = K + RS_ID;
1194
+ // iso-ssbg1-v1 blocks start at any SS_ID and repair ESI = SSB_length + RS_ID.
1195
+ const iso = config.blockProfile === "iso-ssbg1-v1";
758
1196
  if (
759
1197
  !isUint32(cmd.ssStart) ||
760
1198
  !isUint32(cmd.ssbLength) ||
761
1199
  cmd.ssbLength <= 0 ||
762
- cmd.ssbLength !== config.k ||
763
- cmd.ssStart % cmd.ssbLength !== 0 ||
1200
+ cmd.ssbLength > config.k ||
1201
+ (!iso && cmd.ssStart % config.k !== 0) ||
764
1202
  !isUint32(cmd.rsId) ||
765
- cmd.ssbLength + cmd.rsId > UINT24_MAX ||
1203
+ (iso ? cmd.ssbLength : config.k) + cmd.rsId > UINT24_MAX ||
766
1204
  !(cmd.data instanceof ArrayBuffer)
767
1205
  ) {
768
1206
  throw new Error("[FecWorker] feedRepair: invalid repair symbol");
@@ -780,12 +1218,30 @@ function handleFeedRepair(
780
1218
  }
781
1219
  return;
782
1220
  }
783
- const sbn = Math.floor(cmd.ssStart / cmd.ssbLength);
784
- const repairEsi = cmd.ssbLength + cmd.rsId;
785
- if (sbn <= retiredSbnFrontier) return;
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;
786
1232
  const existingBlock = blocks.get(sbn);
787
1233
  if (existingBlock && isTerminalBlock(existingBlock)) return;
788
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
+ }
789
1245
 
790
1246
  const result = wasmDecoder.add_repair(
791
1247
  cmd.ssStart,
@@ -802,28 +1258,12 @@ function handleFeedRepair(
802
1258
  }
803
1259
  repairEsis.add(repairEsi);
804
1260
  const block = getOrCreateBlock(sbn);
1261
+ block.ssbLength = cmd.ssbLength;
805
1262
  block.repairReceived++;
806
1263
  block.lastSymbolTs = performance.now();
807
1264
  repairSymbols++;
808
- // If WASM returned data, it is the recovered source block: K fixed-width
809
- // source symbols concatenated. Split it before emitting so MmtpRouter gets
810
- // the same one-symbol shape as a direct source packet.
811
- if (result && result.byteLength > 0) {
812
- const recovered = new Uint8Array(toOwnedArrayBuffer(result));
813
- const recoveryMs = performance.now() - block.firstSymbolTs;
814
- recoveryTimes.push(recoveryMs);
815
- // Cap recovery times array at 1000 entries
816
- if (recoveryTimes.length > 1000) recoveryTimes.shift();
817
-
818
- if (block.state === "collecting") {
819
- markBlockTerminal(block, "recovered");
820
- blocksRecovered++;
821
- emitBlockUpdate(block);
822
- }
823
- emitRecoveredSymbols(sbn, recovered, block);
824
- }
825
- synchronizeDecoderHorizon();
826
- }
1265
+ settleRepairArrival(sbn, block, cmd.ssbLength, result);
1266
+ }
827
1267
 
828
1268
  function handleRelayBlockSig(
829
1269
  cmd: Extract<FecWorkerCommand, { type: "relayBlockSig" }>,
@@ -849,27 +1289,25 @@ function handleCleanup(
849
1289
  throw new Error("[FecWorker] cleanup: invalid SBNs");
850
1290
  }
851
1291
  const sbns = [...new Set(cmd.sbns)].sort((a, b) => a - b);
852
- const pendingSbns = sbns.filter((sbn) => sbn > retiredSbnFrontier);
1292
+ const pendingSbns = sbns.filter((sbn) => !isRetiredSbn(sbn));
853
1293
  try {
854
1294
  if (!wasmDecoder) {
855
1295
  throw new Error("[FecWorker] cleanup: not configured");
856
1296
  }
857
1297
 
858
- // Call WASM cleanup only after all earlier symbol commands have drained.
859
- // The detailed API carries the true owner of every fill emitted by a
860
- // single cleanup pass; command coalescing must not infer ownership from
861
- // the largest timed-out SBN.
862
- const cleanupRecords = pendingSbns.length > 0
863
- ? wasmDecoder.cleanup_blocks_detailed(Uint32Array.from(pendingSbns))
864
- : new Uint8Array();
1298
+ // Call WASM cleanup only after all earlier symbol commands have drained,
1299
+ // and only for the exact SBNs the manager's deadlines named; command
1300
+ // coalescing must not infer ownership from the largest timed-out SBN.
1301
+ if (pendingSbns.length > 0) {
1302
+ wasmDecoder.cleanup_blocks_detailed(Uint32Array.from(pendingSbns));
1303
+ }
865
1304
  // Manager deadlines are block-scoped and may expire a newer SBN while an
866
1305
  // older interleaved block is still live. Only accepted media progression
867
1306
  // advances the shared JS/Rust horizon; cleanup creates exact tombstones.
868
1307
 
869
1308
  // Rust already finalized every SBN behind the media frontier. For each
870
1309
  // remaining exact request, mirror the block-scoped WASM boundary immediately
871
- // so JS never retains state for a decoder block that is gone, even if decoding
872
- // the trusted cleanup framing were to fail.
1310
+ // so JS never retains state for a decoder block that is gone.
873
1311
  for (const sbn of pendingSbns) {
874
1312
  let currentBlock = blocks.get(sbn);
875
1313
  if (currentBlock && isTerminalBlock(currentBlock)) {
@@ -877,7 +1315,11 @@ function handleCleanup(
877
1315
  continue;
878
1316
  }
879
1317
  currentBlock ??= getOrCreateBlock(sbn);
880
- if (currentBlock.sourceReceived >= currentBlock.k) {
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;
1322
+ if (authoredLength !== undefined && currentBlock.sourceReceived >= authoredLength) {
881
1323
  markBlockTerminal(currentBlock, "complete");
882
1324
  blocksComplete++;
883
1325
  } else {
@@ -886,12 +1328,6 @@ function handleCleanup(
886
1328
  }
887
1329
  emitBlockUpdate(currentBlock);
888
1330
  }
889
-
890
- const greenFills = decodeCleanupRecords(cleanupRecords);
891
- for (const { sbn, data } of greenFills) {
892
- greenFillFrames++;
893
- emit({ type: "greenFill", sbn, data }, [data]);
894
- }
895
1331
  } finally {
896
1332
  // Message ordering makes this an acknowledgement that every source/repair
897
1333
  // command posted before cleanup has completed. The main thread must not
@@ -900,29 +1336,6 @@ function handleCleanup(
900
1336
  }
901
1337
  }
902
1338
 
903
- function decodeCleanupRecords(
904
- framed: Uint8Array,
905
- ): Array<{ sbn: number; data: ArrayBuffer }> {
906
- const records: Array<{ sbn: number; data: ArrayBuffer }> = [];
907
- const view = new DataView(framed.buffer, framed.byteOffset, framed.byteLength);
908
- let offset = 0;
909
- while (offset < framed.byteLength) {
910
- if (framed.byteLength - offset < 8) {
911
- throw new Error("[FecWorker] cleanup_blocks_detailed: truncated record header");
912
- }
913
- const sbn = view.getUint32(offset, false);
914
- const length = view.getUint32(offset + 4, false);
915
- offset += 8;
916
- if (length > framed.byteLength - offset) {
917
- throw new Error("[FecWorker] cleanup_blocks_detailed: truncated record payload");
918
- }
919
- const data = toOwnedArrayBuffer(framed.subarray(offset, offset + length));
920
- records.push({ sbn, data });
921
- offset += length;
922
- }
923
- return records;
924
- }
925
-
926
1339
  function handleDispose(): void {
927
1340
  // Clear snapshot timer (MUST clear to prevent leaks)
928
1341
  if (snapshotTimer !== null) {
@@ -979,7 +1392,6 @@ function handleReset(): void {
979
1392
  blocksComplete = 0;
980
1393
  blocksRecovered = 0;
981
1394
  blocksFailed = 0;
982
- greenFillFrames = 0;
983
1395
  altaVerified = 0;
984
1396
  altaFailed = 0;
985
1397
  altaFailedDetails.length = 0;
@@ -990,10 +1402,46 @@ function handleReset(): void {
990
1402
  relayBlocksVerified = 0;
991
1403
  relayBlocksFailed = 0;
992
1404
  repairSymbolSizeMismatch = 0;
1405
+ sourceDroppedRetired = 0;
1406
+ sourceDroppedTerminal = 0;
1407
+ sourceDroppedDuplicate = 0;
1408
+ sourceUnplacedRetired = 0;
993
1409
  emit({ type: "resetComplete" });
994
1410
  if (config) emitSnapshot();
995
1411
  }
996
1412
 
1413
+ /**
1414
+ * A batch of symbols fed in one page task (BLO-36599). Each record is
1415
+ * handled exactly as its single-symbol command would be, in order, and a
1416
+ * failing record reports the same `command-failed` error that command would
1417
+ * without stopping the records after it.
1418
+ */
1419
+ function handleFeedSymbols(
1420
+ cmd: Extract<FecWorkerCommand, { type: "feedSymbols" }>,
1421
+ ): void {
1422
+ if (!Array.isArray(cmd.symbols)) {
1423
+ throw new Error("[FecWorker] feedSymbols: symbols must be an array");
1424
+ }
1425
+ for (const symbol of cmd.symbols) {
1426
+ try {
1427
+ if (symbol?.type === "feedSource") handleFeedSource(symbol);
1428
+ else if (symbol?.type === "feedRepair") handleFeedRepair(symbol);
1429
+ else {
1430
+ throw new Error(
1431
+ `[FecWorker] feedSymbols: unknown symbol type ${String((symbol as { type?: unknown } | null)?.type)}`,
1432
+ );
1433
+ }
1434
+ } catch (err) {
1435
+ emit({
1436
+ type: "error",
1437
+ code: "command-failed",
1438
+ message:
1439
+ err instanceof Error ? err.message : `[FecWorker] ${String(err)}`,
1440
+ });
1441
+ }
1442
+ }
1443
+ }
1444
+
997
1445
  // --- Main message handler ---
998
1446
 
999
1447
  async function dispatchCommand(cmd: FecWorkerCommand): Promise<void> {
@@ -1011,6 +1459,9 @@ async function dispatchCommand(cmd: FecWorkerCommand): Promise<void> {
1011
1459
  case "feedRepair":
1012
1460
  handleFeedRepair(cmd);
1013
1461
  break;
1462
+ case "feedSymbols":
1463
+ handleFeedSymbols(cmd);
1464
+ break;
1014
1465
  case "relayBlockSig":
1015
1466
  handleRelayBlockSig(cmd);
1016
1467
  break;
@@ -1020,6 +1471,9 @@ async function dispatchCommand(cmd: FecWorkerCommand): Promise<void> {
1020
1471
  case "reset":
1021
1472
  handleReset();
1022
1473
  break;
1474
+ case "subscribeBlockUpdates":
1475
+ blockUpdatesSubscribed = true;
1476
+ break;
1023
1477
  case "dispose":
1024
1478
  handleDispose();
1025
1479
  break;