@blockcast/fec-worker 0.1.0 → 0.3.0-main.1cd3cdea89da

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 (41) hide show
  1. package/README.md +28 -23
  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 +23 -20
  7. package/dist/fec-worker-client.d.ts.map +1 -1
  8. package/dist/fec-worker-client.js +102 -54
  9. package/dist/fec-worker-client.js.map +1 -1
  10. package/dist/fec-worker-types.d.ts +162 -88
  11. package/dist/fec-worker-types.d.ts.map +1 -1
  12. package/dist/fec-worker-types.js +2 -2
  13. package/dist/fec-worker.js +328 -195
  14. package/dist/fec-worker.js.map +4 -4
  15. package/dist/index.d.ts +1 -1
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js.map +1 -1
  18. package/dist/shred-fec-worker.js +23 -2
  19. package/dist/shred-fec-worker.js.map +2 -2
  20. package/dist/shred-worker-types.d.ts +16 -1
  21. package/dist/shred-worker-types.d.ts.map +1 -1
  22. package/dist/shred-worker-types.js +16 -1
  23. package/dist/shred-worker-types.js.map +1 -1
  24. package/package.json +10 -8
  25. package/src/block-order.ts +59 -0
  26. package/src/fec-worker-client.ts +107 -56
  27. package/src/fec-worker-types.ts +161 -84
  28. package/src/fec-worker.ts +517 -468
  29. package/src/index.ts +3 -0
  30. package/src/shred-fec-worker.ts +34 -3
  31. package/src/shred-worker-types.ts +16 -1
  32. package/src/__tests__/fec-worker-alta.test.ts +0 -231
  33. package/src/__tests__/fec-worker-cleanup-integration.test.ts +0 -250
  34. package/src/__tests__/fec-worker-pending-queue.test.ts +0 -315
  35. package/src/__tests__/fec-worker-repair-size.test.ts +0 -1029
  36. package/src/__tests__/fec-worker-wasm-contract.test.ts +0 -84
  37. package/src/__tests__/shred-fec-worker.test.ts +0 -448
  38. package/src/fec-worker-client.test.ts +0 -530
  39. package/src/fec-worker-types.test.ts +0 -400
  40. package/src/shred-worker-client.test.ts +0 -182
  41. 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,27 +90,30 @@ 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) ---
71
97
 
72
98
  let wasmDecoder: WasmFecDecoder | null = null;
73
99
  let config: FecTrackConfig | null = null;
74
- let altaEnabled = false;
75
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
76
- let altaVerifier: any = null; // JsAltaVerifier instance (loaded from ALTA WASM)
77
100
  let snapshotTimer: ReturnType<typeof setInterval> | null = null;
101
+ // blockUpdate events are posted only after a client subscribes (BLO-36599).
102
+ let blockUpdatesSubscribed = false;
78
103
 
79
104
  // Block tracking
80
105
  const blocks = new Map<number, FecBlockSnapshot>();
81
106
  const acceptedSourceEsis = new Map<number, Set<number>>();
82
107
  const acceptedRepairEsis = new Map<number, Set<number>>();
108
+ // iso-ssbg1-v1: sources fed to the decoder before any repair named a block
109
+ // containing them, keyed by SS_ID with their arrival time. Map order is
110
+ // arrival order, which the horizon sweep relies on (retireUnplacedSources).
111
+ const unplacedSources = new Map<number, number>();
83
112
  const UINT32_MAX = 0xffff_ffff;
84
113
  const UINT24_MAX = 0xff_ffff;
114
+ const SS_ID_SPAN = 0x1_0000_0000;
115
+ // ISO/IEC 23008-1 C.3.3 ssbg_mode1: the two-octet protected-packet length field.
116
+ const SSBG1_LENGTH_FIELD_BYTES = 2;
85
117
  // ISO/IEC 23008-1 AL-FEC signaling carries interleave_depth in one byte.
86
118
  const MAX_INTERLEAVE_DEPTH = 0xff;
87
119
  // Rust stores T as u16 and this decoder profile fixes RFC 6330 Al=8.
@@ -95,62 +127,43 @@ let repairSymbols = 0;
95
127
  let blocksComplete = 0;
96
128
  let blocksRecovered = 0;
97
129
  let blocksFailed = 0;
98
- let greenFillFrames = 0;
99
- let altaVerified = 0;
100
- let altaFailed = 0; // Crypto-confirmed tamper (MAC / sig / hash chain mismatch)
101
- const altaFailedDetails: Array<{
102
- seq: number;
103
- error: string;
104
- authLen: number;
105
- payloadLen: number;
106
- path: string; // "direct" | "recovered" | "pending-retry"
107
- }> = [];
108
- let altaPending = 0; // Unverifiable but not tamper (no trailer, verifier not ready, anchor missing)
109
- let altaPendingEvictedCap = 0; // Incoming "pending" packets rejected because the queue was at cap after stale-sweep (flood indicator)
110
- let altaPendingEvictedTtl = 0; // Deferred-queue entries dropped by TTL (retry window expired)
111
- let altaError = 0; // verifyDetached threw — infrastructure flake, not tamper
112
- let altaInitFailed = false; // ALTA configured but WASM init failed; altaEnabled was forced off
113
130
  let relayBlocksVerified = 0;
114
131
  let relayBlocksFailed = 0;
115
132
  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;
133
+ // Whether liveSbn has been set by a symbol of this decoder epoch. Block order
134
+ // is serial on the 32-bit SS_ID (block-order.ts, BLO-35597 iso:5), where 0 is
135
+ // a real position, so "nothing seen yet" needs its own state.
136
+ let liveSbnKnown = false;
120
137
  let repairSymbolSizeMismatch = 0;
138
+ // Source symbols dropped before WASM, by cause. All three sites below were
139
+ // silent, which left the "block settles at exactly K-1 source symbols" shape
140
+ // (BLO-35597 defect 2) with no evidence to attribute it to. A retired drop
141
+ // means a late symbol past retention; a terminal drop means the block had
142
+ // already settled — `state !== "collecting"`, so complete OR recovered OR
143
+ // FAILED, and the failed arm is the one this issue is chasing (a block aged out
144
+ // by synchronizeDecoderHorizon stays in `blocks` until its SBN retires, so a
145
+ // late symbol for a short block lands here); a duplicate drop means a repeated
146
+ // (SBN, ESI), which on this wire is usually a plain duplicate packet — the
147
+ // router's ledger has deliberate gaps — and only sometimes an SBN/ESI
148
+ // derivation collision.
149
+ //
150
+ // These narrow the cause; they do not uniquely attribute it. A symbol LOST IN
151
+ // TRANSIT never reaches handleFeedSource and increments nothing, so all three
152
+ // reading zero on a short block is NOT evidence of a publisher defect — wire
153
+ // loss is the null hypothesis here, and it produces an identical shape. The
154
+ // discriminator for that residual arm is `block.ssbLength` (set at the repair
155
+ // symbol's declared SSB_length, conflict-checked on arrival):
156
+ // ssbLength < k → genuinely short authored block
157
+ // ssbLength === k, sourceReceived < k → loss or a still-late symbol
158
+ // With no repair symbol yet seen, ssbLength is undefined and both stay open.
159
+ let sourceDroppedRetired = 0;
160
+ let sourceDroppedTerminal = 0;
161
+ let sourceDroppedDuplicate = 0;
162
+ let sourceUnplacedRetired = 0;
121
163
 
122
164
  // Recovery timing
123
165
  const recoveryTimes: number[] = [];
124
166
 
125
- // ── Deferred ALTA verification queue (08-07 Path 3) ───────────────────────
126
- //
127
- // When an ALTA packet arrives whose MAC references point at sequences not yet
128
- // in the verifier's ring buffer, we can't anchor trust — verify returns "No
129
- // trust anchor". Typical cause: a predecessor packet was lost and hasn't been
130
- // FEC-recovered yet. Rather than drop these, hold them in a bounded FIFO and
131
- // retry verification after every successful verify (direct or FEC-recovered)
132
- // that seeds the ring buffer with new raw bytes.
133
- //
134
- // Zero-copy (1B-B): the queue entry stores a Uint8Array view into the
135
- // original `data` ArrayBuffer. JS GC keeps the buffer alive via the view
136
- // reference; no bytes are copied on queue.
137
- //
138
- // Timeout entries (TTL exceeded) and cap-rejections count as `altaPending`
139
- // (2C-B): they're not tamper indicators, they're just packets that couldn't
140
- // be authenticated within the receive window — either loss or, in the cap
141
- // case, a flood of unanchorable arrivals (real or attacker-driven) that
142
- // exceeded the queue budget. See enqueuePendingVerify for the rate-limit
143
- // protocol.
144
- interface PendingVerify {
145
- seq: number;
146
- auth: Uint8Array;
147
- payload: Uint8Array;
148
- arrivalTs: number;
149
- }
150
- const pendingVerify = new Map<number, PendingVerify>();
151
- let pendingVerifyCap = 0;
152
- let pendingVerifyTtlMs = 0;
153
-
154
167
  // --- Helpers ---
155
168
 
156
169
  function emit(event: FecWorkerEvent, transfer?: Transferable[]): void {
@@ -174,10 +187,51 @@ function clearBlockTracking(): void {
174
187
  blocks.clear();
175
188
  acceptedSourceEsis.clear();
176
189
  acceptedRepairEsis.clear();
177
- pendingVerify.clear();
190
+ unplacedSources.clear();
178
191
  recoveryTimes.length = 0;
179
192
  liveSbn = 0;
180
- retiredSbnFrontier = -1;
193
+ liveSbnKnown = false;
194
+ }
195
+
196
+ /**
197
+ * Whether `sbn` has fallen behind the decoder's retained interleave window:
198
+ * more than `interleaveDepth` blocks behind the live SBN. liveSbn only moves
199
+ * forward within a decoder epoch and configure() resets both, so this is the
200
+ * monotonic retirement frontier the decoder horizon implies.
201
+ */
202
+ function isRetiredSbn(sbn: number): boolean {
203
+ if (config === null || !liveSbnKnown) return false;
204
+ // `sbn` is an SS_ID (a block's SS_Start or a source's own SS_ID) and
205
+ // liveSbn the live SS_ID; K is only the maximum block length.
206
+ return isoBlocksBehind(sbn, liveSbn, config.k) > config.interleaveDepth;
207
+ }
208
+
209
+ /**
210
+ * iso-ssbg1-v1: the SS_IDs a known block certainly covers. Every block a
211
+ * repair created has its SSB_length; a cleanup tombstone for a block no
212
+ * repair declared certainly holds only its own SS_Start.
213
+ */
214
+ function isoKnownLength(block: FecBlockSnapshot): number {
215
+ return block.ssbLength ?? 1;
216
+ }
217
+
218
+ /** iso-ssbg1-v1: the known block whose SS_ID range contains `ssId` (ranges never overlap). */
219
+ function findIsoBlock(ssId: number): FecBlockSnapshot | undefined {
220
+ for (const block of blocks.values()) {
221
+ if (isoBlockContains(block.sbn, isoKnownLength(block), ssId)) return block;
222
+ }
223
+ return undefined;
224
+ }
225
+
226
+ /**
227
+ * Whether a terminal block's diagnostics may be deleted. Under iso-ssbg1-v1 a
228
+ * source carries no block identity, so a deleted block cannot refuse a late
229
+ * source of its own: that source would be admitted as unplaced and reopen the
230
+ * block in the decoder. The tombstone therefore stays until its LAST SS_ID is
231
+ * retired, after which the SS_ID retirement check refuses every member.
232
+ */
233
+ function isRetiredBlock(key: number, block: FecBlockSnapshot): boolean {
234
+ return isRetiredSbn((key + isoKnownLength(block) - 1) % SS_ID_SPAN);
181
235
  }
182
236
 
183
237
  /**
@@ -188,17 +242,18 @@ function clearBlockTracking(): void {
188
242
  */
189
243
  function trimBlockRetention(): void {
190
244
  for (const [sbn, block] of blocks) {
191
- if (!isTerminalBlock(block) || sbn > retiredSbnFrontier) continue;
245
+ if (!isTerminalBlock(block) || !isRetiredBlock(sbn, block)) continue;
192
246
  blocks.delete(sbn);
193
247
  retireAcceptedIds(sbn);
194
248
  }
195
249
  }
196
250
 
251
+ /** Advance the live position: an SS_ID / SS_Start, in serial order. */
197
252
  function advanceMediaHorizon(sbn: number): void {
198
- if (!config || sbn <= liveSbn) return;
253
+ if (!config) return;
254
+ if (liveSbnKnown && !serialPrecedes(liveSbn, sbn)) return;
199
255
  liveSbn = sbn;
200
- const firstRetainedSbn = Math.max(0, liveSbn - config.interleaveDepth);
201
- retiredSbnFrontier = Math.max(retiredSbnFrontier, firstRetainedSbn - 1);
256
+ liveSbnKnown = true;
202
257
  trimBlockRetention();
203
258
  }
204
259
 
@@ -213,8 +268,8 @@ function markBlockTerminal(
213
268
  }
214
269
 
215
270
  function synchronizeDecoderHorizon(): void {
216
- if (!wasmDecoder) return;
217
- const expired = wasmDecoder.cleanup_by_sbn(liveSbn);
271
+ if (!wasmDecoder || !liveSbnKnown) return;
272
+ const expired = wasmDecoder.cleanup_by_ss_start(liveSbn);
218
273
  for (const sbn of expired) {
219
274
  const block = blocks.get(sbn);
220
275
  if (!block || isTerminalBlock(block)) {
@@ -225,167 +280,34 @@ function synchronizeDecoderHorizon(): void {
225
280
  blocksFailed++;
226
281
  emitBlockUpdate(block);
227
282
  }
283
+ retireUnplacedSources();
228
284
  trimBlockRetention();
229
285
  }
230
286
 
231
287
  /**
232
- * Run detached ALTA verify against the current verifier. Four-state result:
233
- * - "valid": crypto passed, packet authentic. Caller increments altaVerified.
234
- * - "failed": crypto confirmed tamper (MAC/sig mismatch, hash chain mismatch).
235
- * Caller increments altaFailed. Not retryable.
236
- * - "pending": anchor missing (MAC refs not in buffer, no signature, not
237
- * early-warmup). Caller may enqueue for retry after the next
238
- * successful verify seeds the ring buffer.
239
- * - "error": verifier threw — WASM trap, unmarshal panic, OOM. Caller
240
- * increments altaError (infrastructure flake, not tamper).
241
- * The distinction matters for ops alerting: a spike in
242
- * altaError indicates a runtime problem, not an attack.
288
+ * iso-ssbg1-v1: retire unplaced sources behind the horizon, by the same
289
+ * isoBlocksBehind metric as a block start, sweeping in arrival order and
290
+ * stopping at the first live one (amortised O(1) per symbol). A source that
291
+ * arrived out of order behind a newer live one is retired when it reaches the
292
+ * front; until then nothing can place it, because any repair range holding it
293
+ * starts at or before it and is itself retired. One the live position moved
294
+ * at least half the SS_ID space past no longer precedes it serially; it is
295
+ * retired too rather than pinning the sweep.
243
296
  */
244
- let _lastVerifyError = "";
245
- function runDetachedVerify(
246
- auth: Uint8Array,
247
- payload: Uint8Array,
248
- ): "valid" | "failed" | "pending" | "error" {
249
- if (!altaVerifier) return "pending";
250
- try {
251
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
252
- const r: any = altaVerifier.verifyDetached(auth, payload);
253
- if (r.valid) {
254
- _lastVerifyError = "";
255
- return "valid";
256
- }
257
- const err = (r.error as string | undefined) ?? "";
258
- _lastVerifyError = err;
259
- // The bootstrap-friendly verifier returns this specific error string when
260
- // the only obstacle to verification is a missing anchor (see alta-rs
261
- // verifier.rs verify()). Distinguishing this from tamper cases keeps us
262
- // from queuing packets that are definitively invalid.
263
- if (err.startsWith("No trust anchor")) return "pending";
264
- return "failed";
265
- } catch (e) {
266
- // Ratelimit: first ~N occurrences get logged. A sustained exception
267
- // stream indicates a real problem — the altaError counter is the
268
- // durable signal.
269
- if (altaError < 5) {
270
- console.warn("[FecWorker] verifyDetached threw:", e);
271
- }
272
- return "error";
297
+ function retireUnplacedSources(): void {
298
+ for (const ssId of unplacedSources.keys()) {
299
+ const unreachable = ssId !== liveSbn && !serialPrecedes(ssId, liveSbn);
300
+ if (!unreachable && !isRetiredSbn(ssId)) break;
301
+ unplacedSources.delete(ssId);
302
+ sourceUnplacedRetired++;
273
303
  }
274
304
  }
275
305
 
276
- /** Extract u32 BE sequence number from the ALTA authenticator header (offset 0..3). */
277
- function seqFromAuth(auth: Uint8Array): number {
278
- return (
279
- ((auth[0]! << 24) | (auth[1]! << 16) | (auth[2]! << 8) | auth[3]!) >>> 0
280
- );
281
- }
282
-
283
306
  /**
284
- * Enqueue a packet whose ALTA verification returned "pending" (anchor
285
- * missing). Dedupes by sequence number.
286
- *
287
- * Flood resistance (08-08 review): when the queue is at cap we do NOT
288
- * evict the oldest entry. The oldest entry is typically the most
289
- * load-bearing one — it's the packet whose anchor everything else in the
290
- * queue is waiting on. Evict-oldest-on-insert let an attacker emitting a
291
- * stream of "No trust anchor" packets flood out legitimate entries and
292
- * prevent them from ever anchoring. Instead:
293
- * 1. First try to free stale TTL'd entries (they'd be evicted on next
294
- * drain anyway — pulling that forward is free).
295
- * 2. If still full, reject the NEW entry. altaPendingEvictedCap counts
296
- * these rejections (rate-limit trigger).
297
- *
298
- * Stores Uint8Array views into the original data + auth buffers — no copy.
299
- * The views keep their underlying ArrayBuffers alive via GC references.
307
+ * Look up or create a block. `isoSsbLength` creates an iso-ssbg1-v1 block,
308
+ * whose K' is its SSB_length (C.5.3); without it the block has the catalog K.
300
309
  */
301
- function enqueuePendingVerify(
302
- auth: Uint8Array,
303
- payload: Uint8Array,
304
- ): void {
305
- const seq = seqFromAuth(auth);
306
- if (pendingVerify.has(seq)) return;
307
-
308
- if (pendingVerify.size >= pendingVerifyCap) {
309
- // Step 1: sweep stale entries so legitimate traffic isn't penalized
310
- // by TTL'd junk that happens to still occupy slots.
311
- const now = performance.now();
312
- for (const [staleSeq, entry] of pendingVerify) {
313
- if (now - entry.arrivalTs > pendingVerifyTtlMs) {
314
- pendingVerify.delete(staleSeq);
315
- altaPendingEvictedTtl++;
316
- }
317
- }
318
- // Step 2: if the queue is still at cap it's a genuine flood —
319
- // reject the new entry rather than evict an existing one. The
320
- // rejected packet counts as altaPendingEvictedCap (which is
321
- // surfaced up through stats for rate-limit alerting).
322
- if (pendingVerify.size >= pendingVerifyCap) {
323
- altaPendingEvictedCap++;
324
- return;
325
- }
326
- }
327
-
328
- pendingVerify.set(seq, {
329
- seq,
330
- auth,
331
- payload,
332
- arrivalTs: performance.now(),
333
- });
334
- }
335
-
336
- /**
337
- * Retry every queued entry. Called after every successful verify (direct
338
- * source or FEC-recovered) — new raw bytes in the verifier's ring buffer may
339
- * unblock previously-pending MAC references.
340
- *
341
- * Three outcomes per entry:
342
- * - "valid" → remove from queue, altaVerified++
343
- * - "failed" → remove from queue, altaFailed++ (tamper on retry)
344
- * - "pending" → keep in queue, unless TTL expired (then evict as altaPending)
345
- *
346
- * Iteration order: Map preserves insertion order, so we drain oldest-first.
347
- * No-op when the queue is empty or the verifier isn't ready.
348
- */
349
- function drainPendingVerify(): void {
350
- if (pendingVerify.size === 0 || !altaVerifier) return;
351
- const now = performance.now();
352
- // Collect keys to avoid mutating Map during iteration
353
- const keys = Array.from(pendingVerify.keys());
354
- for (const seq of keys) {
355
- const entry = pendingVerify.get(seq);
356
- if (!entry) continue;
357
- if (now - entry.arrivalTs > pendingVerifyTtlMs) {
358
- pendingVerify.delete(seq);
359
- altaPendingEvictedTtl++;
360
- continue;
361
- }
362
- const result = runDetachedVerify(entry.auth, entry.payload);
363
- if (result === "valid") {
364
- pendingVerify.delete(seq);
365
- altaVerified++;
366
- } else if (result === "failed") {
367
- pendingVerify.delete(seq);
368
- altaFailed++;
369
- if (altaFailedDetails.length < 16) {
370
- altaFailedDetails.push({
371
- seq: entry.seq,
372
- error: _lastVerifyError,
373
- authLen: entry.auth.length,
374
- payloadLen: entry.payload.length,
375
- path: "pending-retry",
376
- });
377
- }
378
- } else if (result === "error") {
379
- // Infrastructure failure — remove from queue, count as altaError.
380
- // Keeping it around would just re-throw on every subsequent drain.
381
- pendingVerify.delete(seq);
382
- altaError++;
383
- }
384
- // "pending" → leave in queue for next drain
385
- }
386
- }
387
-
388
- function getOrCreateBlock(sbn: number): FecBlockSnapshot {
310
+ function getOrCreateBlock(sbn: number, isoSsbLength?: number): FecBlockSnapshot {
389
311
  let block = blocks.get(sbn);
390
312
  if (!block) {
391
313
  if (!config) throw new Error("[FecWorker] not configured");
@@ -395,14 +317,16 @@ function getOrCreateBlock(sbn: number): FecBlockSnapshot {
395
317
  state: "collecting",
396
318
  sourceReceived: 0,
397
319
  repairReceived: 0,
398
- k: config.k,
320
+ k: isoSsbLength ?? config.k,
399
321
  repairCount: config.repairCount,
400
322
  firstSymbolTs: now,
401
323
  lastSymbolTs: now,
324
+ // A positive interleaveMs makes this the catalog completion deadline;
325
+ // zero makes deliveryWindowMs a receiver-local cleanup bound.
402
326
  deadlineTs: now + config.deliveryWindowMs,
403
- altaVerified: 0,
404
327
  relayBlockSigValid: null,
405
328
  };
329
+ if (isoSsbLength !== undefined) block.ssbLength = isoSsbLength;
406
330
  blocks.set(sbn, block);
407
331
  trimBlockRetention();
408
332
  }
@@ -433,8 +357,10 @@ function buildStatsSnapshot(): FecTrackStats {
433
357
 
434
358
  // Cap block array to interleave window
435
359
  const maxBlocks = Math.max(config.interleaveDepth * 3, 24);
360
+ // Newest first in SS_ID serial order, so the cap keeps the newest blocks
361
+ // across the 2^32 wrap too: every block start precedes the live SS_ID.
436
362
  const blockArray = Array.from(blocks.values())
437
- .sort((a, b) => b.sbn - a.sbn)
363
+ .sort((a, b) => serialDistance(a.sbn, liveSbn) - serialDistance(b.sbn, liveSbn))
438
364
  .slice(0, maxBlocks);
439
365
 
440
366
  return {
@@ -444,22 +370,12 @@ function buildStatsSnapshot(): FecTrackStats {
444
370
  blocksComplete,
445
371
  blocksRecovered,
446
372
  blocksFailed,
447
- greenFillFrames,
448
373
  recoveryRate:
449
374
  blocksComplete + blocksRecovered > 0
450
375
  ? Math.round(
451
376
  (blocksRecovered / (blocksRecovered + blocksFailed)) * 100,
452
377
  ) || 0
453
378
  : 0,
454
- altaVerified,
455
- altaFailed,
456
- altaPending,
457
- altaPendingEvictedCap,
458
- altaPendingEvictedTtl,
459
- altaError,
460
- altaInitFailed,
461
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
462
- altaFailedDetails: altaFailedDetails.slice() as any,
463
379
  relayBlocksVerified,
464
380
  relayBlocksFailed,
465
381
  liveSbn,
@@ -468,6 +384,10 @@ function buildStatsSnapshot(): FecTrackStats {
468
384
  avgRecoveryMs: Math.round(avgRecoveryMs * 100) / 100,
469
385
  maxRecoveryMs: Math.round(maxRecoveryMs * 100) / 100,
470
386
  repairSymbolSizeMismatch,
387
+ sourceDroppedRetired,
388
+ sourceDroppedTerminal,
389
+ sourceDroppedDuplicate,
390
+ sourceUnplacedRetired,
471
391
  decoder,
472
392
  };
473
393
  }
@@ -478,6 +398,7 @@ function emitSnapshot(): void {
478
398
  }
479
399
 
480
400
  function emitBlockUpdate(block: FecBlockSnapshot): void {
401
+ if (!blockUpdatesSubscribed) return;
481
402
  emit({ type: "blockUpdate", block: { ...block } });
482
403
  }
483
404
 
@@ -496,35 +417,57 @@ function emitRecoveredSymbols(
496
417
  });
497
418
  return;
498
419
  }
499
- if (recoveredBlock.byteLength < symbolSize * config.k) {
420
+ // The decoder returns only the block's authored source symbols
421
+ // (SSB_length x T); the encoder's zero padding is not media.
422
+ const ssbLength = block.ssbLength ?? config.k;
423
+ if (recoveredBlock.byteLength !== symbolSize * ssbLength) {
500
424
  emit({
501
425
  type: "error",
502
426
  code: "recovered-block-too-short",
503
- message: `[FecWorker] recovered block too short: got ${recoveredBlock.byteLength}B, expected at least ${symbolSize * config.k}B`,
427
+ message: `[FecWorker] recovered block has the wrong length: got ${recoveredBlock.byteLength}B, expected ${symbolSize * ssbLength}B (SSB_length=${ssbLength})`,
504
428
  });
505
429
  return;
506
430
  }
507
431
 
508
- for (let i = 0; i < config.k; i++) {
432
+ for (let i = 0; i < ssbLength; i++) {
509
433
  const symbol = recoveredBlock.subarray(i * symbolSize, (i + 1) * symbolSize);
510
-
511
- // Source authentication now runs once at router admission in
512
- // @blockcast/mmt-manifest-verify. The worker only records that this
513
- // legacy ALTA-enabled symbol bypassed hot-path verification.
514
- const recoveredAltaVerified: boolean | null = null;
515
- if (altaEnabled) altaPending++;
434
+ // ssbg_mode1 symbols are emitted whole: the router trims them by their
435
+ // length field. A field past T-2 or non-zero padding means the
436
+ // recovered symbol is not what was sent, so it is refused, not trimmed.
437
+ const invalid = invalidSsbg1Symbol(symbol, symbolSize);
438
+ if (invalid !== null) {
439
+ emit({
440
+ type: "error",
441
+ code: "recovered-ssbg1-symbol-invalid",
442
+ message: `[FecWorker] Dropping recovered ssbg_mode1 symbol (ssStart=${sbn}, esi=${i}, SSB_length=${ssbLength}): ${invalid}`,
443
+ });
444
+ continue;
445
+ }
516
446
 
517
447
  const buf = toOwnedArrayBuffer(symbol);
518
448
  const meta: FrameMeta = {
519
- altaVerified: recoveredAltaVerified,
520
449
  recovered: true,
521
450
  sbn,
522
451
  esi: i,
452
+ ssbLength,
523
453
  };
524
454
  emit({ type: "frame", data: buf, meta }, [buf]);
525
455
  }
526
456
  }
527
457
 
458
+ /** Why a T-byte ssbg_mode1 symbol is malformed (C.3.3), or null when it is well formed. */
459
+ function invalidSsbg1Symbol(symbol: Uint8Array, symbolSize: number): string | null {
460
+ const length = (symbol[0]! << 8) | symbol[1]!;
461
+ const maxLength = symbolSize - SSBG1_LENGTH_FIELD_BYTES;
462
+ if (length > maxLength) {
463
+ return `length field ${length} exceeds T-2 = ${maxLength}`;
464
+ }
465
+ for (let j = SSBG1_LENGTH_FIELD_BYTES + length; j < symbolSize; j++) {
466
+ if (symbol[j] !== 0) return `non-zero padding at byte ${j} after a ${length}-byte packet`;
467
+ }
468
+ return null;
469
+ }
470
+
528
471
  function startSnapshotTimer(): void {
529
472
  if (snapshotTimer !== null) clearInterval(snapshotTimer);
530
473
  snapshotTimer = setInterval(() => emitSnapshot(), 200);
@@ -541,6 +484,251 @@ function resolveWasmBindings(): WasmBindings {
541
484
  return bindings;
542
485
  }
543
486
 
487
+ /**
488
+ * Refuse a decoder that does not run the configured block profile: one that
489
+ * ignored or predates the constructor argument would apply one profile's
490
+ * block math to the other's wire.
491
+ */
492
+ function verifyDecoderProfile(decoder: WasmFecDecoder, expected: FecBlockProfile): void {
493
+ let actual: unknown;
494
+ try {
495
+ actual = decoder.get_block_profile();
496
+ } catch (err) {
497
+ decoder.free();
498
+ throw new Error(
499
+ `[FecWorker] configure: decoder cannot report its block profile (${err instanceof Error ? err.message : String(err)})`,
500
+ );
501
+ }
502
+ if (actual !== expected) {
503
+ decoder.free();
504
+ throw new Error(
505
+ `[FecWorker] configure: decoder runs block profile ${JSON.stringify(actual)}, not the configured "${expected}"`,
506
+ );
507
+ }
508
+ }
509
+
510
+ function acceptId(ids: Map<number, Set<number>>, key: number, id: number): void {
511
+ let set = ids.get(key);
512
+ if (!set) {
513
+ set = new Set<number>();
514
+ ids.set(key, set);
515
+ }
516
+ set.add(id);
517
+ }
518
+
519
+ /** A block the decoder returned: record its recovery and emit its source symbols. */
520
+ function recordRecovered(key: number, block: FecBlockSnapshot, result: Uint8Array): void {
521
+ const recovered = new Uint8Array(toOwnedArrayBuffer(result));
522
+ const recoveryMs = performance.now() - block.firstSymbolTs;
523
+ recoveryTimes.push(recoveryMs);
524
+ // Cap recovery times array at 1000 entries
525
+ if (recoveryTimes.length > 1000) recoveryTimes.shift();
526
+
527
+ if (block.state === "collecting") {
528
+ markBlockTerminal(block, "recovered");
529
+ blocksRecovered++;
530
+ emitBlockUpdate(block);
531
+ }
532
+ emitRecoveredSymbols(key, recovered, block);
533
+ }
534
+
535
+ /** Settle a block after one of its source symbols was accepted. */
536
+ function settleSourceArrival(
537
+ key: number,
538
+ block: FecBlockSnapshot,
539
+ result: Uint8Array | undefined,
540
+ ): void {
541
+ // Check if block is complete (all authored source symbols received:
542
+ // SSB_length once a repair symbol declared it, otherwise K)
543
+ const authoredLength = block.ssbLength ?? block.k;
544
+ if (
545
+ block.sourceReceived >= authoredLength &&
546
+ block.state === "collecting"
547
+ ) {
548
+ markBlockTerminal(block, "complete");
549
+ blocksComplete++;
550
+ emitBlockUpdate(block);
551
+ }
552
+
553
+ // A source-triggered return can be either clean block completion or recovery
554
+ // of earlier missing symbols after repair arrived. Clean completion is
555
+ // already routed by the source path, so only emit when the block did not
556
+ // reach K source arrivals.
557
+ if (result && result.byteLength > 0 && block.sourceReceived < authoredLength) {
558
+ recordRecovered(key, block, result);
559
+ }
560
+ synchronizeDecoderHorizon();
561
+ }
562
+
563
+ /** Settle a block after one of its repair symbols was accepted. */
564
+ function settleRepairArrival(
565
+ key: number,
566
+ block: FecBlockSnapshot,
567
+ ssbLength: number,
568
+ result: Uint8Array | undefined,
569
+ ): void {
570
+ // Every authored source symbol already arrived: knowing SSB_length makes
571
+ // the block clean. The decoder's return is the source already routed.
572
+ // Otherwise, returned data is the recovered source block: its SSB_length
573
+ // fixed-width authored symbols concatenated. Split it before emitting so
574
+ // MmtpRouter gets the same one-symbol shape as a direct source packet.
575
+ if (block.sourceReceived >= ssbLength && block.state === "collecting") {
576
+ markBlockTerminal(block, "complete");
577
+ blocksComplete++;
578
+ emitBlockUpdate(block);
579
+ } else if (result && result.byteLength > 0) {
580
+ recordRecovered(key, block, result);
581
+ }
582
+ synchronizeDecoderHorizon();
583
+ }
584
+
585
+ /**
586
+ * iso-ssbg1-v1 source admission. A source inside a known block is accounted
587
+ * at ESI = SS_ID - SS_Start. One no repair has placed yet is held unplaced,
588
+ * deduplicated by SS_ID, and still fed to the decoder, which holds it too,
589
+ * until a repair names a range containing it.
590
+ */
591
+ function feedIsoSource(
592
+ cmd: Extract<FecWorkerCommand, { type: "feedSource" }>,
593
+ view: Uint8Array,
594
+ decoder: WasmFecDecoder,
595
+ cfg: FecTrackConfig,
596
+ ): void {
597
+ const maxPacketLength = cfg.symbolSize - SSBG1_LENGTH_FIELD_BYTES;
598
+ if (view.byteLength > maxPacketLength) {
599
+ throw new Error(
600
+ `[FecWorker] feedSource: iso-ssbg1-v1 protected packet of ${view.byteLength}B exceeds T-2 = ${maxPacketLength}B`,
601
+ );
602
+ }
603
+ const block = findIsoBlock(cmd.ssId);
604
+ if (!block) {
605
+ if (isRetiredSbn(cmd.ssId)) {
606
+ sourceDroppedRetired++;
607
+ return;
608
+ }
609
+ if (unplacedSources.has(cmd.ssId)) {
610
+ sourceDroppedDuplicate++;
611
+ return;
612
+ }
613
+ const result = decoder.add_source(cmd.ssId, view, cmd.ts);
614
+ advanceMediaHorizon(cmd.ssId);
615
+ unplacedSources.set(cmd.ssId, performance.now());
616
+ sourceSymbols++;
617
+ // No repair has named this source's block, so the decoder cannot have
618
+ // one to recover; data here is a Worker/decoder disagreement that
619
+ // cannot be attributed to a block, and is refused rather than guessed.
620
+ if (result && result.byteLength > 0) {
621
+ emit({
622
+ type: "error",
623
+ code: "recovered-block-unplaced",
624
+ message: `[FecWorker] Dropping a ${result.byteLength}B recovered block returned for SS_ID ${cmd.ssId}, which no known block contains`,
625
+ });
626
+ }
627
+ synchronizeDecoderHorizon();
628
+ return;
629
+ }
630
+
631
+ const key = block.sbn;
632
+ if (isRetiredSbn(key)) {
633
+ sourceDroppedRetired++;
634
+ return;
635
+ }
636
+ if (isTerminalBlock(block)) {
637
+ sourceDroppedTerminal++;
638
+ return;
639
+ }
640
+ const esi = serialDistance(key, cmd.ssId);
641
+ if (acceptedSourceEsis.get(key)?.has(esi)) {
642
+ sourceDroppedDuplicate++;
643
+ return;
644
+ }
645
+
646
+ const result = decoder.add_source(cmd.ssId, view, cmd.ts);
647
+ advanceMediaHorizon(cmd.ssId);
648
+ acceptId(acceptedSourceEsis, key, esi);
649
+ block.sourceReceived++;
650
+ block.lastSymbolTs = performance.now();
651
+ sourceSymbols++;
652
+ settleSourceArrival(key, block, result);
653
+ }
654
+
655
+ /** iso-ssbg1-v1: a known block sharing an SS_ID with `[ssStart, ssStart + ssbLength)`. */
656
+ function findOverlappingIsoBlock(
657
+ ssStart: number,
658
+ ssbLength: number,
659
+ ): FecBlockSnapshot | undefined {
660
+ for (const block of blocks.values()) {
661
+ if (isoBlocksOverlap(block.sbn, isoKnownLength(block), ssStart, ssbLength)) return block;
662
+ }
663
+ return undefined;
664
+ }
665
+
666
+ /**
667
+ * iso-ssbg1-v1: create the block a repair first named, with K' = SSB_length,
668
+ * and move every unplaced source in its range into its accounting.
669
+ */
670
+ function placeIsoBlock(ssStart: number, ssbLength: number): FecBlockSnapshot {
671
+ const block = getOrCreateBlock(ssStart, ssbLength);
672
+ for (let esi = 0; esi < ssbLength; esi++) {
673
+ const ssId = (ssStart + esi) % SS_ID_SPAN;
674
+ const arrivalTs = unplacedSources.get(ssId);
675
+ if (arrivalTs === undefined) continue;
676
+ unplacedSources.delete(ssId);
677
+ acceptId(acceptedSourceEsis, ssStart, esi);
678
+ block.sourceReceived++;
679
+ if (arrivalTs < block.firstSymbolTs) block.firstSymbolTs = arrivalTs;
680
+ }
681
+ return block;
682
+ }
683
+
684
+ /**
685
+ * iso-ssbg1-v1 repair admission. The block is `[SS_Start, SS_Start +
686
+ * SSB_length)` keyed by SS_Start, with repair ESI = SSB_length + RS_ID. A
687
+ * known block's SSB_length is checked before deduplication because the
688
+ * dedup key depends on it.
689
+ */
690
+ function feedIsoRepair(
691
+ cmd: Extract<FecWorkerCommand, { type: "feedRepair" }>,
692
+ view: Uint8Array,
693
+ decoder: WasmFecDecoder,
694
+ ): void {
695
+ const key = cmd.ssStart;
696
+ if (isRetiredSbn(key)) return;
697
+ const existingBlock = blocks.get(key);
698
+ if (existingBlock && isTerminalBlock(existingBlock)) return;
699
+ if (existingBlock) {
700
+ if (existingBlock.ssbLength !== cmd.ssbLength) {
701
+ emit({
702
+ type: "error",
703
+ code: "repair-ssb-length-conflict",
704
+ message: `[FecWorker] Dropping repair symbol whose SSB_length ${cmd.ssbLength} conflicts with ${existingBlock.ssbLength} (ssStart=${key})`,
705
+ });
706
+ return;
707
+ }
708
+ } else {
709
+ const overlapped = findOverlappingIsoBlock(key, cmd.ssbLength);
710
+ if (overlapped) {
711
+ emit({
712
+ type: "error",
713
+ code: "repair-block-overlap",
714
+ message: `[FecWorker] Dropping repair symbol whose block (ssStart=${key}, SSB_length=${cmd.ssbLength}) overlaps known block (ssStart=${overlapped.sbn}, SSB_length=${isoKnownLength(overlapped)})`,
715
+ });
716
+ return;
717
+ }
718
+ }
719
+ const repairEsi = cmd.ssbLength + cmd.rsId;
720
+ if (acceptedRepairEsis.get(key)?.has(repairEsi)) return;
721
+
722
+ const result = decoder.add_repair(key, cmd.ssbLength, cmd.rsId, view, cmd.ts);
723
+ advanceMediaHorizon(key);
724
+ const block = existingBlock ?? placeIsoBlock(key, cmd.ssbLength);
725
+ acceptId(acceptedRepairEsis, key, repairEsi);
726
+ block.repairReceived++;
727
+ block.lastSymbolTs = performance.now();
728
+ repairSymbols++;
729
+ settleRepairArrival(key, block, cmd.ssbLength, result);
730
+ }
731
+
544
732
  // --- Command handlers ---
545
733
 
546
734
  async function handleConfigure(
@@ -552,6 +740,19 @@ async function handleConfigure(
552
740
  if (!cmd.config) {
553
741
  throw new Error("[FecWorker] configure: config required");
554
742
  }
743
+ // The block profile is the one discriminator for two wire-identical block
744
+ // maths, so it is required and never defaulted (BLO-35597).
745
+ const blockProfile: unknown = cmd.config.blockProfile;
746
+ if (blockProfile === undefined) {
747
+ throw new Error(
748
+ '[FecWorker] configure: blockProfile is required: name the FEC block profile ("iso-ssbg1-v1")',
749
+ );
750
+ }
751
+ if (blockProfile !== "iso-ssbg1-v1") {
752
+ throw new Error(
753
+ `[FecWorker] configure: blockProfile must be "iso-ssbg1-v1" (got ${JSON.stringify(blockProfile)})`,
754
+ );
755
+ }
555
756
  if (
556
757
  !Number.isFinite(cmd.config.interleaveDepth) ||
557
758
  !Number.isInteger(cmd.config.interleaveDepth) ||
@@ -564,10 +765,11 @@ async function handleConfigure(
564
765
  }
565
766
  if (
566
767
  !Number.isFinite(cmd.config.interleaveMs) ||
567
- cmd.config.interleaveMs <= 0
768
+ !Number.isSafeInteger(cmd.config.interleaveMs) ||
769
+ cmd.config.interleaveMs < 0
568
770
  ) {
569
771
  throw new Error(
570
- `[FecWorker] configure: interleaveMs must be finite and > 0 (got ${cmd.config.interleaveMs})`,
772
+ `[FecWorker] configure: interleaveMs must be a finite non-negative integer (got ${cmd.config.interleaveMs})`,
571
773
  );
572
774
  }
573
775
  if (
@@ -611,57 +813,16 @@ async function handleConfigure(
611
813
 
612
814
  // Decoder policy and geometry are explicit at the epoch boundary. The WASM
613
815
  // module has no guessed K/T/interleave slow path.
614
- wasmDecoder = new wasm.MmtFecDecoder(
615
- cmd.config.interleaveDepth,
616
- cmd.config.trackType === "video",
617
- );
816
+ const decoder = new wasm.MmtFecDecoder(cmd.config.interleaveDepth, blockProfile);
817
+ verifyDecoderProfile(decoder, blockProfile);
818
+ wasmDecoder = decoder;
618
819
  config = cmd.config;
619
- altaEnabled = !!cmd.altaPublicKey;
620
-
621
- // Size the deferred-verify queue strictly from catalog-derived FEC
622
- // parameters. CLAUDE.md bans Math.max(x, floor) magic — if catalog
623
- // values are zero/missing, throw at init rather than silently applying
624
- // a hardcoded floor. Consumers of an undersized queue cap would see
625
- // altaPendingEvictedCap spike with no actionable signal.
626
- // Cap: 1.5× interleave depth — wide enough to hold one full FEC recovery
627
- // window plus jitter, narrow enough to bound memory.
628
- pendingVerifyCap = Math.ceil(config.interleaveDepth * 1.5);
629
- // TTL: 1.5× block duration (K symbols at one per interleave tick) —
630
- // after this many ms, any unresolved entry has missed its FEC window.
631
- pendingVerifyTtlMs = Math.ceil(config.interleaveMs * config.k * 1.5);
632
-
633
- // Initialize ALTA WASM verifier if public key and WASM module provided (D-05, D-09)
634
- altaVerifier = null;
635
- altaInitFailed = false;
636
- if (cmd.altaPublicKey && cmd.altaWasmModule) {
637
- try {
638
- // Load ALTA JS glue (wasm-bindgen output from wasm-pack build).
639
- // Mirrors the RaptorQ WASM pattern: initSync({ module }) then construct.
640
- // Dynamic import path matches the IWA static asset layout.
641
- // Cast via string to prevent TypeScript from attempting static resolution
642
- // of a runtime-only URL (same pattern as fec-worker-entry.js loading).
643
- const altaGlueUrl = '/wasm/alta/alta_rs.js' as string;
644
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
645
- const altaGlue: any = await import(/* webpackIgnore: true */ altaGlueUrl);
646
- altaGlue.initSync({ module: cmd.altaWasmModule });
647
- const keyBytes = new Uint8Array(cmd.altaPublicKey);
648
- altaVerifier = new altaGlue.JsAltaVerifier(keyBytes);
649
- } catch (e) {
650
- // T-08-15: WASM init failure must NOT silently look like "not-ready-yet".
651
- // Force altaEnabled = false so handleFeedSource takes the disabled
652
- // branch instead of continuously bumping altaPending under the
653
- // misleading "verifier not yet ready" comment. altaInitFailed
654
- // flag in the snapshot tells consumers this is an error state, not
655
- // an opt-out.
656
- console.warn('[FecWorker] ALTA WASM init failed, verification disabled:', e);
657
- altaVerifier = null;
658
- altaEnabled = false;
659
- altaInitFailed = true;
660
- }
661
- }
662
-
663
820
  wasmDecoder.configure_params(config.k, config.symbolSize);
664
821
 
822
+ // Tell the client which optional commands this Worker accepts, so it can
823
+ // batch symbols without risking a Worker that predates the batch command.
824
+ emit({ type: "capabilities", commands: ["feedSymbols", "subscribeBlockUpdates"] });
825
+
665
826
  // Start periodic snapshot emission (200ms floor)
666
827
  startSnapshotTimer();
667
828
 
@@ -685,66 +846,8 @@ function handleFeedSource(
685
846
  throw new Error("[FecWorker] feedSource: invalid source symbol");
686
847
  }
687
848
 
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
- }
849
+ feedIsoSource(cmd, new Uint8Array(cmd.data), wasmDecoder, config);
850
+ }
748
851
 
749
852
  function handleFeedRepair(
750
853
  cmd: Extract<FecWorkerCommand, { type: "feedRepair" }>,
@@ -755,12 +858,12 @@ function handleFeedRepair(
755
858
  if (!config) {
756
859
  throw new Error("[FecWorker] feedRepair: config missing");
757
860
  }
861
+ // iso-ssbg1-v1 blocks start at any SS_ID and repair ESI = SSB_length + RS_ID.
758
862
  if (
759
863
  !isUint32(cmd.ssStart) ||
760
864
  !isUint32(cmd.ssbLength) ||
761
865
  cmd.ssbLength <= 0 ||
762
- cmd.ssbLength !== config.k ||
763
- cmd.ssStart % cmd.ssbLength !== 0 ||
866
+ cmd.ssbLength > config.k ||
764
867
  !isUint32(cmd.rsId) ||
765
868
  cmd.ssbLength + cmd.rsId > UINT24_MAX ||
766
869
  !(cmd.data instanceof ArrayBuffer)
@@ -780,50 +883,8 @@ function handleFeedRepair(
780
883
  }
781
884
  return;
782
885
  }
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
- }
886
+ feedIsoRepair(cmd, view, wasmDecoder);
887
+ }
827
888
 
828
889
  function handleRelayBlockSig(
829
890
  cmd: Extract<FecWorkerCommand, { type: "relayBlockSig" }>,
@@ -831,8 +892,8 @@ function handleRelayBlockSig(
831
892
  // No-op AUTH stub: store sig on block, mark as not-yet-verified
832
893
  const block = blocks.get(cmd.sbn);
833
894
  if (block) {
834
- // Real verification deferred to ALTA milestone
835
- // For now, mark as null (not verified) — will be wired when ALTA is implemented
895
+ // Relay block signatures are not verified here; source provenance is the
896
+ // signed per-group manifest checked at router admission.
836
897
  block.relayBlockSigValid = null;
837
898
  emitBlockUpdate(block);
838
899
  }
@@ -849,27 +910,25 @@ function handleCleanup(
849
910
  throw new Error("[FecWorker] cleanup: invalid SBNs");
850
911
  }
851
912
  const sbns = [...new Set(cmd.sbns)].sort((a, b) => a - b);
852
- const pendingSbns = sbns.filter((sbn) => sbn > retiredSbnFrontier);
913
+ const pendingSbns = sbns.filter((sbn) => !isRetiredSbn(sbn));
853
914
  try {
854
915
  if (!wasmDecoder) {
855
916
  throw new Error("[FecWorker] cleanup: not configured");
856
917
  }
857
918
 
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();
919
+ // Call WASM cleanup only after all earlier symbol commands have drained,
920
+ // and only for the exact SBNs the manager's deadlines named; command
921
+ // coalescing must not infer ownership from the largest timed-out SBN.
922
+ if (pendingSbns.length > 0) {
923
+ wasmDecoder.cleanup_blocks_detailed(Uint32Array.from(pendingSbns));
924
+ }
865
925
  // Manager deadlines are block-scoped and may expire a newer SBN while an
866
926
  // older interleaved block is still live. Only accepted media progression
867
927
  // advances the shared JS/Rust horizon; cleanup creates exact tombstones.
868
928
 
869
929
  // Rust already finalized every SBN behind the media frontier. For each
870
930
  // 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.
931
+ // so JS never retains state for a decoder block that is gone.
873
932
  for (const sbn of pendingSbns) {
874
933
  let currentBlock = blocks.get(sbn);
875
934
  if (currentBlock && isTerminalBlock(currentBlock)) {
@@ -877,7 +936,10 @@ function handleCleanup(
877
936
  continue;
878
937
  }
879
938
  currentBlock ??= getOrCreateBlock(sbn);
880
- if (currentBlock.sourceReceived >= currentBlock.k) {
939
+ // Compare with the block's SSB_length; a key no repair declared has
940
+ // none, so a block the Worker never knew can only have failed.
941
+ const authoredLength = currentBlock.ssbLength;
942
+ if (authoredLength !== undefined && currentBlock.sourceReceived >= authoredLength) {
881
943
  markBlockTerminal(currentBlock, "complete");
882
944
  blocksComplete++;
883
945
  } else {
@@ -886,12 +948,6 @@ function handleCleanup(
886
948
  }
887
949
  emitBlockUpdate(currentBlock);
888
950
  }
889
-
890
- const greenFills = decodeCleanupRecords(cleanupRecords);
891
- for (const { sbn, data } of greenFills) {
892
- greenFillFrames++;
893
- emit({ type: "greenFill", sbn, data }, [data]);
894
- }
895
951
  } finally {
896
952
  // Message ordering makes this an acknowledgement that every source/repair
897
953
  // command posted before cleanup has completed. The main thread must not
@@ -900,29 +956,6 @@ function handleCleanup(
900
956
  }
901
957
  }
902
958
 
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
959
  function handleDispose(): void {
927
960
  // Clear snapshot timer (MUST clear to prevent leaks)
928
961
  if (snapshotTimer !== null) {
@@ -930,29 +963,12 @@ function handleDispose(): void {
930
963
  snapshotTimer = null;
931
964
  }
932
965
 
933
- // Free ALTA verifier WASM object (MUST call to release WASM memory —
934
- // Worker.terminate() fires immediately after dispose, so the FinalizationRegistry
935
- // callback has no chance to run and this memory would leak without explicit free)
936
- if (altaVerifier) {
937
- try {
938
- altaVerifier.free();
939
- } catch (_e) {
940
- // Best-effort — verifier may already be freed
941
- }
942
- altaVerifier = null;
943
- }
944
-
945
966
  // Free WASM decoder (MUST call to release WASM memory)
946
967
  if (wasmDecoder) {
947
968
  wasmDecoder.free();
948
969
  wasmDecoder = null;
949
970
  }
950
971
 
951
- // Clear deferred-verify queue so pinned ArrayBuffers drop.
952
- pendingVerify.clear();
953
- pendingVerifyCap = 0;
954
- pendingVerifyTtlMs = 0;
955
-
956
972
  config = null;
957
973
  clearBlockTracking();
958
974
  }
@@ -979,29 +995,56 @@ function handleReset(): void {
979
995
  blocksComplete = 0;
980
996
  blocksRecovered = 0;
981
997
  blocksFailed = 0;
982
- greenFillFrames = 0;
983
- altaVerified = 0;
984
- altaFailed = 0;
985
- altaFailedDetails.length = 0;
986
- altaPending = 0;
987
- altaPendingEvictedCap = 0;
988
- altaPendingEvictedTtl = 0;
989
- altaError = 0;
990
998
  relayBlocksVerified = 0;
991
999
  relayBlocksFailed = 0;
992
1000
  repairSymbolSizeMismatch = 0;
1001
+ sourceDroppedRetired = 0;
1002
+ sourceDroppedTerminal = 0;
1003
+ sourceDroppedDuplicate = 0;
1004
+ sourceUnplacedRetired = 0;
993
1005
  emit({ type: "resetComplete" });
994
1006
  if (config) emitSnapshot();
995
1007
  }
996
1008
 
1009
+ /**
1010
+ * A batch of symbols fed in one page task (BLO-36599). Each record is
1011
+ * handled exactly as its single-symbol command would be, in order, and a
1012
+ * failing record reports the same `command-failed` error that command would
1013
+ * without stopping the records after it.
1014
+ */
1015
+ function handleFeedSymbols(
1016
+ cmd: Extract<FecWorkerCommand, { type: "feedSymbols" }>,
1017
+ ): void {
1018
+ if (!Array.isArray(cmd.symbols)) {
1019
+ throw new Error("[FecWorker] feedSymbols: symbols must be an array");
1020
+ }
1021
+ for (const symbol of cmd.symbols) {
1022
+ try {
1023
+ if (symbol?.type === "feedSource") handleFeedSource(symbol);
1024
+ else if (symbol?.type === "feedRepair") handleFeedRepair(symbol);
1025
+ else {
1026
+ throw new Error(
1027
+ `[FecWorker] feedSymbols: unknown symbol type ${String((symbol as { type?: unknown } | null)?.type)}`,
1028
+ );
1029
+ }
1030
+ } catch (err) {
1031
+ emit({
1032
+ type: "error",
1033
+ code: "command-failed",
1034
+ message:
1035
+ err instanceof Error ? err.message : `[FecWorker] ${String(err)}`,
1036
+ });
1037
+ }
1038
+ }
1039
+ }
1040
+
997
1041
  // --- Main message handler ---
998
1042
 
999
1043
  async function dispatchCommand(cmd: FecWorkerCommand): Promise<void> {
1000
1044
  try {
1001
1045
  switch (cmd.type) {
1002
1046
  case "configure":
1003
- // handleConfigure is async (ALTA WASM init uses dynamic import).
1004
- // Await it so feed/cleanup messages cannot run against a partially
1047
+ // Await handleConfigure so feed/cleanup messages cannot run against a partially
1005
1048
  // configured decoder.
1006
1049
  await handleConfigure(cmd);
1007
1050
  break;
@@ -1011,6 +1054,9 @@ async function dispatchCommand(cmd: FecWorkerCommand): Promise<void> {
1011
1054
  case "feedRepair":
1012
1055
  handleFeedRepair(cmd);
1013
1056
  break;
1057
+ case "feedSymbols":
1058
+ handleFeedSymbols(cmd);
1059
+ break;
1014
1060
  case "relayBlockSig":
1015
1061
  handleRelayBlockSig(cmd);
1016
1062
  break;
@@ -1020,6 +1066,9 @@ async function dispatchCommand(cmd: FecWorkerCommand): Promise<void> {
1020
1066
  case "reset":
1021
1067
  handleReset();
1022
1068
  break;
1069
+ case "subscribeBlockUpdates":
1070
+ blockUpdatesSubscribed = true;
1071
+ break;
1023
1072
  case "dispose":
1024
1073
  handleDispose();
1025
1074
  break;