@blockcast/fec-worker 0.1.0 → 0.3.0-main.2515e5ca4858

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 +19 -9
  2. package/dist/block-order.d.ts +22 -0
  3. package/dist/block-order.d.ts.map +1 -0
  4. package/dist/block-order.js +48 -0
  5. package/dist/block-order.js.map +1 -0
  6. package/dist/fec-worker-client.d.ts +19 -7
  7. package/dist/fec-worker-client.d.ts.map +1 -1
  8. package/dist/fec-worker-client.js +105 -30
  9. package/dist/fec-worker-client.js.map +1 -1
  10. package/dist/fec-worker-types.d.ts +146 -25
  11. package/dist/fec-worker-types.d.ts.map +1 -1
  12. package/dist/fec-worker.js +326 -127
  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 +59 -0
  25. package/src/fec-worker-client.ts +114 -32
  26. package/src/fec-worker-types.ts +145 -21
  27. package/src/fec-worker.ts +518 -180
  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,36 @@ 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
+ isoBlockContains,
26
+ isoBlocksBehind,
27
+ isoBlocksOverlap,
28
+ serialDistance,
29
+ serialPrecedes,
30
+ } from "./block-order.js";
23
31
 
24
32
  // --- WASM binding types (from mmt-wasm) ---
25
33
  // Signatures MUST match a freshly generated external mmt_wasm.d.ts exactly.
26
34
  // The decoder derives SBN/ESI internally — do not pre-compute.
27
35
  interface WasmFecDecoder {
28
- /** ISO 23008-1 §C.5.2: ss_id is the flat 32-bit Source Symbol ID. */
36
+ /**
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.
40
+ */
29
41
  add_source(
30
42
  ss_id: number,
31
43
  data: Uint8Array,
32
44
  timestamp: number,
33
45
  ): Uint8Array | undefined;
34
- /** ISO 23008-1 §C.5.3: repair ESI = ssb_length + rs_id, SBN = ss_start / ssb_length. */
46
+ /**
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.
50
+ */
35
51
  add_repair(
36
52
  ss_start: number,
37
53
  ssb_length: number,
@@ -40,10 +56,23 @@ interface WasmFecDecoder {
40
56
  timestamp: number,
41
57
  ): Uint8Array | undefined;
42
58
  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. */
46
- cleanup_by_sbn(current_sbn: number): Uint32Array;
59
+ /**
60
+ * Retire the named blocks (last-chance decode + stats), driven by the
61
+ * manager's exact deadlines. Nothing is synthesised for a lost block:
62
+ * concealment is the sink's hold-last-good-frame.
63
+ */
64
+ cleanup_blocks_detailed(sbns: Uint32Array): void;
65
+ /**
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.
72
+ */
73
+ cleanup_by_ss_start(current: number): Uint32Array;
74
+ /** The block profile the decoder was constructed with. */
75
+ get_block_profile(): string;
47
76
  flush(): void;
48
77
  pending_blocks(): number;
49
78
  reset_stats(): void;
@@ -61,10 +90,7 @@ interface WasmFecDecoder {
61
90
 
62
91
  interface WasmBindings {
63
92
  initSync(module: { module: WebAssembly.Module }): unknown;
64
- MmtFecDecoder: new (
65
- interleaveDepth: number,
66
- greenFillEnabled: boolean,
67
- ) => WasmFecDecoder;
93
+ MmtFecDecoder: new (interleaveDepth: number, blockProfile: FecBlockProfile) => WasmFecDecoder;
68
94
  }
69
95
 
70
96
  // --- Worker state (local to this Worker instance) ---
@@ -75,13 +101,22 @@ let altaEnabled = false;
75
101
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
76
102
  let altaVerifier: any = null; // JsAltaVerifier instance (loaded from ALTA WASM)
77
103
  let snapshotTimer: ReturnType<typeof setInterval> | null = null;
104
+ // blockUpdate events are posted only after a client subscribes (BLO-36599).
105
+ let blockUpdatesSubscribed = false;
78
106
 
79
107
  // Block tracking
80
108
  const blocks = new Map<number, FecBlockSnapshot>();
81
109
  const acceptedSourceEsis = new Map<number, Set<number>>();
82
110
  const acceptedRepairEsis = new Map<number, Set<number>>();
111
+ // iso-ssbg1-v1: sources fed to the decoder before any repair named a block
112
+ // containing them, keyed by SS_ID with their arrival time. Map order is
113
+ // arrival order, which the horizon sweep relies on (retireUnplacedSources).
114
+ const unplacedSources = new Map<number, number>();
83
115
  const UINT32_MAX = 0xffff_ffff;
84
116
  const UINT24_MAX = 0xff_ffff;
117
+ const SS_ID_SPAN = 0x1_0000_0000;
118
+ // ISO/IEC 23008-1 C.3.3 ssbg_mode1: the two-octet protected-packet length field.
119
+ const SSBG1_LENGTH_FIELD_BYTES = 2;
85
120
  // ISO/IEC 23008-1 AL-FEC signaling carries interleave_depth in one byte.
86
121
  const MAX_INTERLEAVE_DEPTH = 0xff;
87
122
  // Rust stores T as u16 and this decoder profile fixes RFC 6330 Al=8.
@@ -95,7 +130,6 @@ let repairSymbols = 0;
95
130
  let blocksComplete = 0;
96
131
  let blocksRecovered = 0;
97
132
  let blocksFailed = 0;
98
- let greenFillFrames = 0;
99
133
  let altaVerified = 0;
100
134
  let altaFailed = 0; // Crypto-confirmed tamper (MAC / sig / hash chain mismatch)
101
135
  const altaFailedDetails: Array<{
@@ -113,11 +147,36 @@ let altaInitFailed = false; // ALTA configured but WASM init failed; altaEnab
113
147
  let relayBlocksVerified = 0;
114
148
  let relayBlocksFailed = 0;
115
149
  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;
150
+ // Whether liveSbn has been set by a symbol of this decoder epoch. Block order
151
+ // is serial on the 32-bit SS_ID (block-order.ts, BLO-35597 iso:5), where 0 is
152
+ // a real position, so "nothing seen yet" needs its own state.
153
+ let liveSbnKnown = false;
120
154
  let repairSymbolSizeMismatch = 0;
155
+ // Source symbols dropped before WASM, by cause. All three sites below were
156
+ // silent, which left the "block settles at exactly K-1 source symbols" shape
157
+ // (BLO-35597 defect 2) with no evidence to attribute it to. A retired drop
158
+ // means a late symbol past retention; a terminal drop means the block had
159
+ // already settled — `state !== "collecting"`, so complete OR recovered OR
160
+ // FAILED, and the failed arm is the one this issue is chasing (a block aged out
161
+ // by synchronizeDecoderHorizon stays in `blocks` until its SBN retires, so a
162
+ // late symbol for a short block lands here); a duplicate drop means a repeated
163
+ // (SBN, ESI), which on this wire is usually a plain duplicate packet — the
164
+ // router's ledger has deliberate gaps — and only sometimes an SBN/ESI
165
+ // derivation collision.
166
+ //
167
+ // These narrow the cause; they do not uniquely attribute it. A symbol LOST IN
168
+ // TRANSIT never reaches handleFeedSource and increments nothing, so all three
169
+ // reading zero on a short block is NOT evidence of a publisher defect — wire
170
+ // loss is the null hypothesis here, and it produces an identical shape. The
171
+ // discriminator for that residual arm is `block.ssbLength` (set at the repair
172
+ // symbol's declared SSB_length, conflict-checked on arrival):
173
+ // ssbLength < k → genuinely short authored block
174
+ // ssbLength === k, sourceReceived < k → loss or a still-late symbol
175
+ // With no repair symbol yet seen, ssbLength is undefined and both stay open.
176
+ let sourceDroppedRetired = 0;
177
+ let sourceDroppedTerminal = 0;
178
+ let sourceDroppedDuplicate = 0;
179
+ let sourceUnplacedRetired = 0;
121
180
 
122
181
  // Recovery timing
123
182
  const recoveryTimes: number[] = [];
@@ -174,10 +233,52 @@ function clearBlockTracking(): void {
174
233
  blocks.clear();
175
234
  acceptedSourceEsis.clear();
176
235
  acceptedRepairEsis.clear();
236
+ unplacedSources.clear();
177
237
  pendingVerify.clear();
178
238
  recoveryTimes.length = 0;
179
239
  liveSbn = 0;
180
- retiredSbnFrontier = -1;
240
+ liveSbnKnown = false;
241
+ }
242
+
243
+ /**
244
+ * Whether `sbn` has fallen behind the decoder's retained interleave window:
245
+ * more than `interleaveDepth` blocks behind the live SBN. liveSbn only moves
246
+ * forward within a decoder epoch and configure() resets both, so this is the
247
+ * monotonic retirement frontier the decoder horizon implies.
248
+ */
249
+ function isRetiredSbn(sbn: number): boolean {
250
+ if (config === null || !liveSbnKnown) return false;
251
+ // `sbn` is an SS_ID (a block's SS_Start or a source's own SS_ID) and
252
+ // liveSbn the live SS_ID; K is only the maximum block length.
253
+ return isoBlocksBehind(sbn, liveSbn, config.k) > config.interleaveDepth;
254
+ }
255
+
256
+ /**
257
+ * iso-ssbg1-v1: the SS_IDs a known block certainly covers. Every block a
258
+ * repair created has its SSB_length; a cleanup tombstone for a block no
259
+ * repair declared certainly holds only its own SS_Start.
260
+ */
261
+ function isoKnownLength(block: FecBlockSnapshot): number {
262
+ return block.ssbLength ?? 1;
263
+ }
264
+
265
+ /** iso-ssbg1-v1: the known block whose SS_ID range contains `ssId` (ranges never overlap). */
266
+ function findIsoBlock(ssId: number): FecBlockSnapshot | undefined {
267
+ for (const block of blocks.values()) {
268
+ if (isoBlockContains(block.sbn, isoKnownLength(block), ssId)) return block;
269
+ }
270
+ return undefined;
271
+ }
272
+
273
+ /**
274
+ * Whether a terminal block's diagnostics may be deleted. Under iso-ssbg1-v1 a
275
+ * source carries no block identity, so a deleted block cannot refuse a late
276
+ * source of its own: that source would be admitted as unplaced and reopen the
277
+ * block in the decoder. The tombstone therefore stays until its LAST SS_ID is
278
+ * retired, after which the SS_ID retirement check refuses every member.
279
+ */
280
+ function isRetiredBlock(key: number, block: FecBlockSnapshot): boolean {
281
+ return isRetiredSbn((key + isoKnownLength(block) - 1) % SS_ID_SPAN);
181
282
  }
182
283
 
183
284
  /**
@@ -188,17 +289,18 @@ function clearBlockTracking(): void {
188
289
  */
189
290
  function trimBlockRetention(): void {
190
291
  for (const [sbn, block] of blocks) {
191
- if (!isTerminalBlock(block) || sbn > retiredSbnFrontier) continue;
292
+ if (!isTerminalBlock(block) || !isRetiredBlock(sbn, block)) continue;
192
293
  blocks.delete(sbn);
193
294
  retireAcceptedIds(sbn);
194
295
  }
195
296
  }
196
297
 
298
+ /** Advance the live position: an SS_ID / SS_Start, in serial order. */
197
299
  function advanceMediaHorizon(sbn: number): void {
198
- if (!config || sbn <= liveSbn) return;
300
+ if (!config) return;
301
+ if (liveSbnKnown && !serialPrecedes(liveSbn, sbn)) return;
199
302
  liveSbn = sbn;
200
- const firstRetainedSbn = Math.max(0, liveSbn - config.interleaveDepth);
201
- retiredSbnFrontier = Math.max(retiredSbnFrontier, firstRetainedSbn - 1);
303
+ liveSbnKnown = true;
202
304
  trimBlockRetention();
203
305
  }
204
306
 
@@ -213,8 +315,8 @@ function markBlockTerminal(
213
315
  }
214
316
 
215
317
  function synchronizeDecoderHorizon(): void {
216
- if (!wasmDecoder) return;
217
- const expired = wasmDecoder.cleanup_by_sbn(liveSbn);
318
+ if (!wasmDecoder || !liveSbnKnown) return;
319
+ const expired = wasmDecoder.cleanup_by_ss_start(liveSbn);
218
320
  for (const sbn of expired) {
219
321
  const block = blocks.get(sbn);
220
322
  if (!block || isTerminalBlock(block)) {
@@ -225,9 +327,29 @@ function synchronizeDecoderHorizon(): void {
225
327
  blocksFailed++;
226
328
  emitBlockUpdate(block);
227
329
  }
330
+ retireUnplacedSources();
228
331
  trimBlockRetention();
229
332
  }
230
333
 
334
+ /**
335
+ * iso-ssbg1-v1: retire unplaced sources behind the horizon, by the same
336
+ * isoBlocksBehind metric as a block start, sweeping in arrival order and
337
+ * stopping at the first live one (amortised O(1) per symbol). A source that
338
+ * arrived out of order behind a newer live one is retired when it reaches the
339
+ * front; until then nothing can place it, because any repair range holding it
340
+ * starts at or before it and is itself retired. One the live position moved
341
+ * at least half the SS_ID space past no longer precedes it serially; it is
342
+ * retired too rather than pinning the sweep.
343
+ */
344
+ function retireUnplacedSources(): void {
345
+ for (const ssId of unplacedSources.keys()) {
346
+ const unreachable = ssId !== liveSbn && !serialPrecedes(ssId, liveSbn);
347
+ if (!unreachable && !isRetiredSbn(ssId)) break;
348
+ unplacedSources.delete(ssId);
349
+ sourceUnplacedRetired++;
350
+ }
351
+ }
352
+
231
353
  /**
232
354
  * Run detached ALTA verify against the current verifier. Four-state result:
233
355
  * - "valid": crypto passed, packet authentic. Caller increments altaVerified.
@@ -385,7 +507,11 @@ function drainPendingVerify(): void {
385
507
  }
386
508
  }
387
509
 
388
- function getOrCreateBlock(sbn: number): FecBlockSnapshot {
510
+ /**
511
+ * Look up or create a block. `isoSsbLength` creates an iso-ssbg1-v1 block,
512
+ * whose K' is its SSB_length (C.5.3); without it the block has the catalog K.
513
+ */
514
+ function getOrCreateBlock(sbn: number, isoSsbLength?: number): FecBlockSnapshot {
389
515
  let block = blocks.get(sbn);
390
516
  if (!block) {
391
517
  if (!config) throw new Error("[FecWorker] not configured");
@@ -395,7 +521,7 @@ function getOrCreateBlock(sbn: number): FecBlockSnapshot {
395
521
  state: "collecting",
396
522
  sourceReceived: 0,
397
523
  repairReceived: 0,
398
- k: config.k,
524
+ k: isoSsbLength ?? config.k,
399
525
  repairCount: config.repairCount,
400
526
  firstSymbolTs: now,
401
527
  lastSymbolTs: now,
@@ -403,6 +529,7 @@ function getOrCreateBlock(sbn: number): FecBlockSnapshot {
403
529
  altaVerified: 0,
404
530
  relayBlockSigValid: null,
405
531
  };
532
+ if (isoSsbLength !== undefined) block.ssbLength = isoSsbLength;
406
533
  blocks.set(sbn, block);
407
534
  trimBlockRetention();
408
535
  }
@@ -433,8 +560,10 @@ function buildStatsSnapshot(): FecTrackStats {
433
560
 
434
561
  // Cap block array to interleave window
435
562
  const maxBlocks = Math.max(config.interleaveDepth * 3, 24);
563
+ // Newest first in SS_ID serial order, so the cap keeps the newest blocks
564
+ // across the 2^32 wrap too: every block start precedes the live SS_ID.
436
565
  const blockArray = Array.from(blocks.values())
437
- .sort((a, b) => b.sbn - a.sbn)
566
+ .sort((a, b) => serialDistance(a.sbn, liveSbn) - serialDistance(b.sbn, liveSbn))
438
567
  .slice(0, maxBlocks);
439
568
 
440
569
  return {
@@ -444,7 +573,6 @@ function buildStatsSnapshot(): FecTrackStats {
444
573
  blocksComplete,
445
574
  blocksRecovered,
446
575
  blocksFailed,
447
- greenFillFrames,
448
576
  recoveryRate:
449
577
  blocksComplete + blocksRecovered > 0
450
578
  ? Math.round(
@@ -468,6 +596,10 @@ function buildStatsSnapshot(): FecTrackStats {
468
596
  avgRecoveryMs: Math.round(avgRecoveryMs * 100) / 100,
469
597
  maxRecoveryMs: Math.round(maxRecoveryMs * 100) / 100,
470
598
  repairSymbolSizeMismatch,
599
+ sourceDroppedRetired,
600
+ sourceDroppedTerminal,
601
+ sourceDroppedDuplicate,
602
+ sourceUnplacedRetired,
471
603
  decoder,
472
604
  };
473
605
  }
@@ -478,6 +610,7 @@ function emitSnapshot(): void {
478
610
  }
479
611
 
480
612
  function emitBlockUpdate(block: FecBlockSnapshot): void {
613
+ if (!blockUpdatesSubscribed) return;
481
614
  emit({ type: "blockUpdate", block: { ...block } });
482
615
  }
483
616
 
@@ -496,17 +629,32 @@ function emitRecoveredSymbols(
496
629
  });
497
630
  return;
498
631
  }
499
- if (recoveredBlock.byteLength < symbolSize * config.k) {
632
+ // The decoder returns only the block's authored source symbols
633
+ // (SSB_length x T); the encoder's zero padding is not media.
634
+ const ssbLength = block.ssbLength ?? config.k;
635
+ if (recoveredBlock.byteLength !== symbolSize * ssbLength) {
500
636
  emit({
501
637
  type: "error",
502
638
  code: "recovered-block-too-short",
503
- message: `[FecWorker] recovered block too short: got ${recoveredBlock.byteLength}B, expected at least ${symbolSize * config.k}B`,
639
+ message: `[FecWorker] recovered block has the wrong length: got ${recoveredBlock.byteLength}B, expected ${symbolSize * ssbLength}B (SSB_length=${ssbLength})`,
504
640
  });
505
641
  return;
506
642
  }
507
643
 
508
- for (let i = 0; i < config.k; i++) {
644
+ for (let i = 0; i < ssbLength; i++) {
509
645
  const symbol = recoveredBlock.subarray(i * symbolSize, (i + 1) * symbolSize);
646
+ // ssbg_mode1 symbols are emitted whole: the router trims them by their
647
+ // length field. A field past T-2 or non-zero padding means the
648
+ // recovered symbol is not what was sent, so it is refused, not trimmed.
649
+ const invalid = invalidSsbg1Symbol(symbol, symbolSize);
650
+ if (invalid !== null) {
651
+ emit({
652
+ type: "error",
653
+ code: "recovered-ssbg1-symbol-invalid",
654
+ message: `[FecWorker] Dropping recovered ssbg_mode1 symbol (ssStart=${sbn}, esi=${i}, SSB_length=${ssbLength}): ${invalid}`,
655
+ });
656
+ continue;
657
+ }
510
658
 
511
659
  // Source authentication now runs once at router admission in
512
660
  // @blockcast/mmt-manifest-verify. The worker only records that this
@@ -520,11 +668,25 @@ function emitRecoveredSymbols(
520
668
  recovered: true,
521
669
  sbn,
522
670
  esi: i,
671
+ ssbLength,
523
672
  };
524
673
  emit({ type: "frame", data: buf, meta }, [buf]);
525
674
  }
526
675
  }
527
676
 
677
+ /** Why a T-byte ssbg_mode1 symbol is malformed (C.3.3), or null when it is well formed. */
678
+ function invalidSsbg1Symbol(symbol: Uint8Array, symbolSize: number): string | null {
679
+ const length = (symbol[0]! << 8) | symbol[1]!;
680
+ const maxLength = symbolSize - SSBG1_LENGTH_FIELD_BYTES;
681
+ if (length > maxLength) {
682
+ return `length field ${length} exceeds T-2 = ${maxLength}`;
683
+ }
684
+ for (let j = SSBG1_LENGTH_FIELD_BYTES + length; j < symbolSize; j++) {
685
+ if (symbol[j] !== 0) return `non-zero padding at byte ${j} after a ${length}-byte packet`;
686
+ }
687
+ return null;
688
+ }
689
+
528
690
  function startSnapshotTimer(): void {
529
691
  if (snapshotTimer !== null) clearInterval(snapshotTimer);
530
692
  snapshotTimer = setInterval(() => emitSnapshot(), 200);
@@ -541,6 +703,253 @@ function resolveWasmBindings(): WasmBindings {
541
703
  return bindings;
542
704
  }
543
705
 
706
+ /**
707
+ * Refuse a decoder that does not run the configured block profile: one that
708
+ * ignored or predates the constructor argument would apply one profile's
709
+ * block math to the other's wire.
710
+ */
711
+ function verifyDecoderProfile(decoder: WasmFecDecoder, expected: FecBlockProfile): void {
712
+ let actual: unknown;
713
+ try {
714
+ actual = decoder.get_block_profile();
715
+ } catch (err) {
716
+ decoder.free();
717
+ throw new Error(
718
+ `[FecWorker] configure: decoder cannot report its block profile (${err instanceof Error ? err.message : String(err)})`,
719
+ );
720
+ }
721
+ if (actual !== expected) {
722
+ decoder.free();
723
+ throw new Error(
724
+ `[FecWorker] configure: decoder runs block profile ${JSON.stringify(actual)}, not the configured "${expected}"`,
725
+ );
726
+ }
727
+ }
728
+
729
+ function acceptId(ids: Map<number, Set<number>>, key: number, id: number): void {
730
+ let set = ids.get(key);
731
+ if (!set) {
732
+ set = new Set<number>();
733
+ ids.set(key, set);
734
+ }
735
+ set.add(id);
736
+ }
737
+
738
+ /** A block the decoder returned: record its recovery and emit its source symbols. */
739
+ function recordRecovered(key: number, block: FecBlockSnapshot, result: Uint8Array): void {
740
+ const recovered = new Uint8Array(toOwnedArrayBuffer(result));
741
+ const recoveryMs = performance.now() - block.firstSymbolTs;
742
+ recoveryTimes.push(recoveryMs);
743
+ // Cap recovery times array at 1000 entries
744
+ if (recoveryTimes.length > 1000) recoveryTimes.shift();
745
+
746
+ if (block.state === "collecting") {
747
+ markBlockTerminal(block, "recovered");
748
+ blocksRecovered++;
749
+ emitBlockUpdate(block);
750
+ }
751
+ emitRecoveredSymbols(key, recovered, block);
752
+ }
753
+
754
+ /** Settle a block after one of its source symbols was accepted. */
755
+ function settleSourceArrival(
756
+ key: number,
757
+ block: FecBlockSnapshot,
758
+ result: Uint8Array | undefined,
759
+ ): void {
760
+ // Check if block is complete (all authored source symbols received:
761
+ // SSB_length once a repair symbol declared it, otherwise K)
762
+ const authoredLength = block.ssbLength ?? block.k;
763
+ if (
764
+ block.sourceReceived >= authoredLength &&
765
+ block.state === "collecting"
766
+ ) {
767
+ markBlockTerminal(block, "complete");
768
+ blocksComplete++;
769
+ emitBlockUpdate(block);
770
+ }
771
+
772
+ // A source-triggered return can be either clean block completion or recovery
773
+ // of earlier missing symbols after repair arrived. Clean completion is
774
+ // already routed by the source path, so only emit when the block did not
775
+ // reach K source arrivals.
776
+ if (result && result.byteLength > 0 && block.sourceReceived < authoredLength) {
777
+ recordRecovered(key, block, result);
778
+ }
779
+ synchronizeDecoderHorizon();
780
+ }
781
+
782
+ /** Settle a block after one of its repair symbols was accepted. */
783
+ function settleRepairArrival(
784
+ key: number,
785
+ block: FecBlockSnapshot,
786
+ ssbLength: number,
787
+ result: Uint8Array | undefined,
788
+ ): void {
789
+ // Every authored source symbol already arrived: knowing SSB_length makes
790
+ // the block clean. The decoder's return is the source already routed.
791
+ // Otherwise, returned data is the recovered source block: its SSB_length
792
+ // fixed-width authored symbols concatenated. Split it before emitting so
793
+ // MmtpRouter gets the same one-symbol shape as a direct source packet.
794
+ if (block.sourceReceived >= ssbLength && block.state === "collecting") {
795
+ markBlockTerminal(block, "complete");
796
+ blocksComplete++;
797
+ emitBlockUpdate(block);
798
+ } else if (result && result.byteLength > 0) {
799
+ recordRecovered(key, block, result);
800
+ }
801
+ synchronizeDecoderHorizon();
802
+ }
803
+
804
+ /**
805
+ * iso-ssbg1-v1 source admission. A source inside a known block is accounted
806
+ * at ESI = SS_ID - SS_Start. One no repair has placed yet is held unplaced,
807
+ * deduplicated by SS_ID, and still fed to the decoder, which holds it too,
808
+ * until a repair names a range containing it.
809
+ */
810
+ function feedIsoSource(
811
+ cmd: Extract<FecWorkerCommand, { type: "feedSource" }>,
812
+ view: Uint8Array,
813
+ decoder: WasmFecDecoder,
814
+ cfg: FecTrackConfig,
815
+ ): void {
816
+ const maxPacketLength = cfg.symbolSize - SSBG1_LENGTH_FIELD_BYTES;
817
+ if (view.byteLength > maxPacketLength) {
818
+ throw new Error(
819
+ `[FecWorker] feedSource: iso-ssbg1-v1 protected packet of ${view.byteLength}B exceeds T-2 = ${maxPacketLength}B`,
820
+ );
821
+ }
822
+ const block = findIsoBlock(cmd.ssId);
823
+ if (!block) {
824
+ if (isRetiredSbn(cmd.ssId)) {
825
+ sourceDroppedRetired++;
826
+ return;
827
+ }
828
+ if (unplacedSources.has(cmd.ssId)) {
829
+ sourceDroppedDuplicate++;
830
+ return;
831
+ }
832
+ const result = decoder.add_source(cmd.ssId, view, cmd.ts);
833
+ advanceMediaHorizon(cmd.ssId);
834
+ unplacedSources.set(cmd.ssId, performance.now());
835
+ sourceSymbols++;
836
+ if (altaEnabled) altaPending++;
837
+ // No repair has named this source's block, so the decoder cannot have
838
+ // one to recover; data here is a Worker/decoder disagreement that
839
+ // cannot be attributed to a block, and is refused rather than guessed.
840
+ if (result && result.byteLength > 0) {
841
+ emit({
842
+ type: "error",
843
+ code: "recovered-block-unplaced",
844
+ message: `[FecWorker] Dropping a ${result.byteLength}B recovered block returned for SS_ID ${cmd.ssId}, which no known block contains`,
845
+ });
846
+ }
847
+ synchronizeDecoderHorizon();
848
+ return;
849
+ }
850
+
851
+ const key = block.sbn;
852
+ if (isRetiredSbn(key)) {
853
+ sourceDroppedRetired++;
854
+ return;
855
+ }
856
+ if (isTerminalBlock(block)) {
857
+ sourceDroppedTerminal++;
858
+ return;
859
+ }
860
+ const esi = serialDistance(key, cmd.ssId);
861
+ if (acceptedSourceEsis.get(key)?.has(esi)) {
862
+ sourceDroppedDuplicate++;
863
+ return;
864
+ }
865
+
866
+ const result = decoder.add_source(cmd.ssId, view, cmd.ts);
867
+ advanceMediaHorizon(cmd.ssId);
868
+ acceptId(acceptedSourceEsis, key, esi);
869
+ block.sourceReceived++;
870
+ block.lastSymbolTs = performance.now();
871
+ sourceSymbols++;
872
+ if (altaEnabled) altaPending++;
873
+ settleSourceArrival(key, block, result);
874
+ }
875
+
876
+ /** iso-ssbg1-v1: a known block sharing an SS_ID with `[ssStart, ssStart + ssbLength)`. */
877
+ function findOverlappingIsoBlock(
878
+ ssStart: number,
879
+ ssbLength: number,
880
+ ): FecBlockSnapshot | undefined {
881
+ for (const block of blocks.values()) {
882
+ if (isoBlocksOverlap(block.sbn, isoKnownLength(block), ssStart, ssbLength)) return block;
883
+ }
884
+ return undefined;
885
+ }
886
+
887
+ /**
888
+ * iso-ssbg1-v1: create the block a repair first named, with K' = SSB_length,
889
+ * and move every unplaced source in its range into its accounting.
890
+ */
891
+ function placeIsoBlock(ssStart: number, ssbLength: number): FecBlockSnapshot {
892
+ const block = getOrCreateBlock(ssStart, ssbLength);
893
+ for (let esi = 0; esi < ssbLength; esi++) {
894
+ const ssId = (ssStart + esi) % SS_ID_SPAN;
895
+ const arrivalTs = unplacedSources.get(ssId);
896
+ if (arrivalTs === undefined) continue;
897
+ unplacedSources.delete(ssId);
898
+ acceptId(acceptedSourceEsis, ssStart, esi);
899
+ block.sourceReceived++;
900
+ if (arrivalTs < block.firstSymbolTs) block.firstSymbolTs = arrivalTs;
901
+ }
902
+ return block;
903
+ }
904
+
905
+ /**
906
+ * iso-ssbg1-v1 repair admission. The block is `[SS_Start, SS_Start +
907
+ * SSB_length)` keyed by SS_Start, with repair ESI = SSB_length + RS_ID. A
908
+ * known block's SSB_length is checked before deduplication because the
909
+ * dedup key depends on it.
910
+ */
911
+ function feedIsoRepair(
912
+ cmd: Extract<FecWorkerCommand, { type: "feedRepair" }>,
913
+ view: Uint8Array,
914
+ decoder: WasmFecDecoder,
915
+ ): void {
916
+ const key = cmd.ssStart;
917
+ if (isRetiredSbn(key)) return;
918
+ const existingBlock = blocks.get(key);
919
+ if (existingBlock && isTerminalBlock(existingBlock)) return;
920
+ if (existingBlock) {
921
+ if (existingBlock.ssbLength !== cmd.ssbLength) {
922
+ emit({
923
+ type: "error",
924
+ code: "repair-ssb-length-conflict",
925
+ message: `[FecWorker] Dropping repair symbol whose SSB_length ${cmd.ssbLength} conflicts with ${existingBlock.ssbLength} (ssStart=${key})`,
926
+ });
927
+ return;
928
+ }
929
+ } else {
930
+ const overlapped = findOverlappingIsoBlock(key, cmd.ssbLength);
931
+ if (overlapped) {
932
+ emit({
933
+ type: "error",
934
+ code: "repair-block-overlap",
935
+ message: `[FecWorker] Dropping repair symbol whose block (ssStart=${key}, SSB_length=${cmd.ssbLength}) overlaps known block (ssStart=${overlapped.sbn}, SSB_length=${isoKnownLength(overlapped)})`,
936
+ });
937
+ return;
938
+ }
939
+ }
940
+ const repairEsi = cmd.ssbLength + cmd.rsId;
941
+ if (acceptedRepairEsis.get(key)?.has(repairEsi)) return;
942
+
943
+ const result = decoder.add_repair(key, cmd.ssbLength, cmd.rsId, view, cmd.ts);
944
+ advanceMediaHorizon(key);
945
+ const block = existingBlock ?? placeIsoBlock(key, cmd.ssbLength);
946
+ acceptId(acceptedRepairEsis, key, repairEsi);
947
+ block.repairReceived++;
948
+ block.lastSymbolTs = performance.now();
949
+ repairSymbols++;
950
+ settleRepairArrival(key, block, cmd.ssbLength, result);
951
+ }
952
+
544
953
  // --- Command handlers ---
545
954
 
546
955
  async function handleConfigure(
@@ -552,6 +961,19 @@ async function handleConfigure(
552
961
  if (!cmd.config) {
553
962
  throw new Error("[FecWorker] configure: config required");
554
963
  }
964
+ // The block profile is the one discriminator for two wire-identical block
965
+ // maths, so it is required and never defaulted (BLO-35597).
966
+ const blockProfile: unknown = cmd.config.blockProfile;
967
+ if (blockProfile === undefined) {
968
+ throw new Error(
969
+ '[FecWorker] configure: blockProfile is required: name the FEC block profile ("iso-ssbg1-v1")',
970
+ );
971
+ }
972
+ if (blockProfile !== "iso-ssbg1-v1") {
973
+ throw new Error(
974
+ `[FecWorker] configure: blockProfile must be "iso-ssbg1-v1" (got ${JSON.stringify(blockProfile)})`,
975
+ );
976
+ }
555
977
  if (
556
978
  !Number.isFinite(cmd.config.interleaveDepth) ||
557
979
  !Number.isInteger(cmd.config.interleaveDepth) ||
@@ -611,10 +1033,9 @@ async function handleConfigure(
611
1033
 
612
1034
  // Decoder policy and geometry are explicit at the epoch boundary. The WASM
613
1035
  // module has no guessed K/T/interleave slow path.
614
- wasmDecoder = new wasm.MmtFecDecoder(
615
- cmd.config.interleaveDepth,
616
- cmd.config.trackType === "video",
617
- );
1036
+ const decoder = new wasm.MmtFecDecoder(cmd.config.interleaveDepth, blockProfile);
1037
+ verifyDecoderProfile(decoder, blockProfile);
1038
+ wasmDecoder = decoder;
618
1039
  config = cmd.config;
619
1040
  altaEnabled = !!cmd.altaPublicKey;
620
1041
 
@@ -662,6 +1083,10 @@ async function handleConfigure(
662
1083
 
663
1084
  wasmDecoder.configure_params(config.k, config.symbolSize);
664
1085
 
1086
+ // Tell the client which optional commands this Worker accepts, so it can
1087
+ // batch symbols without risking a Worker that predates the batch command.
1088
+ emit({ type: "capabilities", commands: ["feedSymbols", "subscribeBlockUpdates"] });
1089
+
665
1090
  // Start periodic snapshot emission (200ms floor)
666
1091
  startSnapshotTimer();
667
1092
 
@@ -685,66 +1110,8 @@ function handleFeedSource(
685
1110
  throw new Error("[FecWorker] feedSource: invalid source symbol");
686
1111
  }
687
1112
 
688
- const view = new Uint8Array(cmd.data);
689
-
690
- // Derive SBN locally for block-level accounting. WASM derives the same
691
- // value internally (ISO 23008-1 §C.5.2: SBN = floor(SS_ID / K)).
692
- const sbn = config.k > 0 ? Math.floor(cmd.ssId / config.k) : cmd.ssId;
693
- const esi = cmd.ssId % config.k;
694
- if (sbn <= retiredSbnFrontier) return;
695
- const existingBlock = blocks.get(sbn);
696
- if (existingBlock && isTerminalBlock(existingBlock)) return;
697
- if (acceptedSourceEsis.get(sbn)?.has(esi)) return;
698
-
699
- const result = wasmDecoder.add_source(cmd.ssId, view, cmd.ts);
700
- advanceMediaHorizon(sbn);
701
- let sourceEsis = acceptedSourceEsis.get(sbn);
702
- if (!sourceEsis) {
703
- sourceEsis = new Set<number>();
704
- acceptedSourceEsis.set(sbn, sourceEsis);
705
- }
706
- sourceEsis.add(esi);
707
-
708
- // Update block state
709
- const block = getOrCreateBlock(sbn);
710
- block.sourceReceived++;
711
- block.lastSymbolTs = performance.now();
712
- sourceSymbols++;
713
-
714
- // The FEC worker must remain decode-only on the per-symbol hot path. ALTA
715
- // WASM is retained temporarily for low-rate signaling users, but media is
716
- // authenticated by the canonical manifest verifier at router admission.
717
- if (altaEnabled) altaPending++;
718
-
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
- }
1113
+ feedIsoSource(cmd, new Uint8Array(cmd.data), wasmDecoder, config);
1114
+ }
748
1115
 
749
1116
  function handleFeedRepair(
750
1117
  cmd: Extract<FecWorkerCommand, { type: "feedRepair" }>,
@@ -755,12 +1122,12 @@ function handleFeedRepair(
755
1122
  if (!config) {
756
1123
  throw new Error("[FecWorker] feedRepair: config missing");
757
1124
  }
1125
+ // iso-ssbg1-v1 blocks start at any SS_ID and repair ESI = SSB_length + RS_ID.
758
1126
  if (
759
1127
  !isUint32(cmd.ssStart) ||
760
1128
  !isUint32(cmd.ssbLength) ||
761
1129
  cmd.ssbLength <= 0 ||
762
- cmd.ssbLength !== config.k ||
763
- cmd.ssStart % cmd.ssbLength !== 0 ||
1130
+ cmd.ssbLength > config.k ||
764
1131
  !isUint32(cmd.rsId) ||
765
1132
  cmd.ssbLength + cmd.rsId > UINT24_MAX ||
766
1133
  !(cmd.data instanceof ArrayBuffer)
@@ -780,50 +1147,8 @@ function handleFeedRepair(
780
1147
  }
781
1148
  return;
782
1149
  }
783
- const sbn = Math.floor(cmd.ssStart / cmd.ssbLength);
784
- const repairEsi = cmd.ssbLength + cmd.rsId;
785
- if (sbn <= retiredSbnFrontier) return;
786
- const existingBlock = blocks.get(sbn);
787
- if (existingBlock && isTerminalBlock(existingBlock)) return;
788
- if (acceptedRepairEsis.get(sbn)?.has(repairEsi)) return;
789
-
790
- const result = wasmDecoder.add_repair(
791
- cmd.ssStart,
792
- cmd.ssbLength,
793
- cmd.rsId,
794
- view,
795
- cmd.ts,
796
- );
797
- advanceMediaHorizon(sbn);
798
- let repairEsis = acceptedRepairEsis.get(sbn);
799
- if (!repairEsis) {
800
- repairEsis = new Set<number>();
801
- acceptedRepairEsis.set(sbn, repairEsis);
802
- }
803
- repairEsis.add(repairEsi);
804
- const block = getOrCreateBlock(sbn);
805
- block.repairReceived++;
806
- block.lastSymbolTs = performance.now();
807
- 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
- }
1150
+ feedIsoRepair(cmd, view, wasmDecoder);
1151
+ }
827
1152
 
828
1153
  function handleRelayBlockSig(
829
1154
  cmd: Extract<FecWorkerCommand, { type: "relayBlockSig" }>,
@@ -849,27 +1174,25 @@ function handleCleanup(
849
1174
  throw new Error("[FecWorker] cleanup: invalid SBNs");
850
1175
  }
851
1176
  const sbns = [...new Set(cmd.sbns)].sort((a, b) => a - b);
852
- const pendingSbns = sbns.filter((sbn) => sbn > retiredSbnFrontier);
1177
+ const pendingSbns = sbns.filter((sbn) => !isRetiredSbn(sbn));
853
1178
  try {
854
1179
  if (!wasmDecoder) {
855
1180
  throw new Error("[FecWorker] cleanup: not configured");
856
1181
  }
857
1182
 
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();
1183
+ // Call WASM cleanup only after all earlier symbol commands have drained,
1184
+ // and only for the exact SBNs the manager's deadlines named; command
1185
+ // coalescing must not infer ownership from the largest timed-out SBN.
1186
+ if (pendingSbns.length > 0) {
1187
+ wasmDecoder.cleanup_blocks_detailed(Uint32Array.from(pendingSbns));
1188
+ }
865
1189
  // Manager deadlines are block-scoped and may expire a newer SBN while an
866
1190
  // older interleaved block is still live. Only accepted media progression
867
1191
  // advances the shared JS/Rust horizon; cleanup creates exact tombstones.
868
1192
 
869
1193
  // Rust already finalized every SBN behind the media frontier. For each
870
1194
  // 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.
1195
+ // so JS never retains state for a decoder block that is gone.
873
1196
  for (const sbn of pendingSbns) {
874
1197
  let currentBlock = blocks.get(sbn);
875
1198
  if (currentBlock && isTerminalBlock(currentBlock)) {
@@ -877,7 +1200,10 @@ function handleCleanup(
877
1200
  continue;
878
1201
  }
879
1202
  currentBlock ??= getOrCreateBlock(sbn);
880
- if (currentBlock.sourceReceived >= currentBlock.k) {
1203
+ // Compare with the block's SSB_length; a key no repair declared has
1204
+ // none, so a block the Worker never knew can only have failed.
1205
+ const authoredLength = currentBlock.ssbLength;
1206
+ if (authoredLength !== undefined && currentBlock.sourceReceived >= authoredLength) {
881
1207
  markBlockTerminal(currentBlock, "complete");
882
1208
  blocksComplete++;
883
1209
  } else {
@@ -886,12 +1212,6 @@ function handleCleanup(
886
1212
  }
887
1213
  emitBlockUpdate(currentBlock);
888
1214
  }
889
-
890
- const greenFills = decodeCleanupRecords(cleanupRecords);
891
- for (const { sbn, data } of greenFills) {
892
- greenFillFrames++;
893
- emit({ type: "greenFill", sbn, data }, [data]);
894
- }
895
1215
  } finally {
896
1216
  // Message ordering makes this an acknowledgement that every source/repair
897
1217
  // command posted before cleanup has completed. The main thread must not
@@ -900,29 +1220,6 @@ function handleCleanup(
900
1220
  }
901
1221
  }
902
1222
 
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
1223
  function handleDispose(): void {
927
1224
  // Clear snapshot timer (MUST clear to prevent leaks)
928
1225
  if (snapshotTimer !== null) {
@@ -979,7 +1276,6 @@ function handleReset(): void {
979
1276
  blocksComplete = 0;
980
1277
  blocksRecovered = 0;
981
1278
  blocksFailed = 0;
982
- greenFillFrames = 0;
983
1279
  altaVerified = 0;
984
1280
  altaFailed = 0;
985
1281
  altaFailedDetails.length = 0;
@@ -990,10 +1286,46 @@ function handleReset(): void {
990
1286
  relayBlocksVerified = 0;
991
1287
  relayBlocksFailed = 0;
992
1288
  repairSymbolSizeMismatch = 0;
1289
+ sourceDroppedRetired = 0;
1290
+ sourceDroppedTerminal = 0;
1291
+ sourceDroppedDuplicate = 0;
1292
+ sourceUnplacedRetired = 0;
993
1293
  emit({ type: "resetComplete" });
994
1294
  if (config) emitSnapshot();
995
1295
  }
996
1296
 
1297
+ /**
1298
+ * A batch of symbols fed in one page task (BLO-36599). Each record is
1299
+ * handled exactly as its single-symbol command would be, in order, and a
1300
+ * failing record reports the same `command-failed` error that command would
1301
+ * without stopping the records after it.
1302
+ */
1303
+ function handleFeedSymbols(
1304
+ cmd: Extract<FecWorkerCommand, { type: "feedSymbols" }>,
1305
+ ): void {
1306
+ if (!Array.isArray(cmd.symbols)) {
1307
+ throw new Error("[FecWorker] feedSymbols: symbols must be an array");
1308
+ }
1309
+ for (const symbol of cmd.symbols) {
1310
+ try {
1311
+ if (symbol?.type === "feedSource") handleFeedSource(symbol);
1312
+ else if (symbol?.type === "feedRepair") handleFeedRepair(symbol);
1313
+ else {
1314
+ throw new Error(
1315
+ `[FecWorker] feedSymbols: unknown symbol type ${String((symbol as { type?: unknown } | null)?.type)}`,
1316
+ );
1317
+ }
1318
+ } catch (err) {
1319
+ emit({
1320
+ type: "error",
1321
+ code: "command-failed",
1322
+ message:
1323
+ err instanceof Error ? err.message : `[FecWorker] ${String(err)}`,
1324
+ });
1325
+ }
1326
+ }
1327
+ }
1328
+
997
1329
  // --- Main message handler ---
998
1330
 
999
1331
  async function dispatchCommand(cmd: FecWorkerCommand): Promise<void> {
@@ -1011,6 +1343,9 @@ async function dispatchCommand(cmd: FecWorkerCommand): Promise<void> {
1011
1343
  case "feedRepair":
1012
1344
  handleFeedRepair(cmd);
1013
1345
  break;
1346
+ case "feedSymbols":
1347
+ handleFeedSymbols(cmd);
1348
+ break;
1014
1349
  case "relayBlockSig":
1015
1350
  handleRelayBlockSig(cmd);
1016
1351
  break;
@@ -1020,6 +1355,9 @@ async function dispatchCommand(cmd: FecWorkerCommand): Promise<void> {
1020
1355
  case "reset":
1021
1356
  handleReset();
1022
1357
  break;
1358
+ case "subscribeBlockUpdates":
1359
+ blockUpdatesSubscribed = true;
1360
+ break;
1023
1361
  case "dispose":
1024
1362
  handleDispose();
1025
1363
  break;