@blockcast/fec-worker 0.1.0-main.c71423b8a4e4 → 0.1.0-main.ca0fdf0ae087

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.
@@ -10,6 +10,7 @@
10
10
 
11
11
  import type {
12
12
  FecWorkerCommand,
13
+ FecSymbolCommand,
13
14
  FecWorkerEvent,
14
15
  FecTrackConfig,
15
16
  FecTrackStats,
@@ -23,7 +24,6 @@ export class FecWorkerClient {
23
24
  #onFrame: ((data: ArrayBuffer, meta: FrameMeta) => void) | null = null;
24
25
  #onSnapshot: ((stats: FecTrackStats) => void) | null = null;
25
26
  #onBlockUpdate: ((block: FecBlockSnapshot) => void) | null = null;
26
- #onGreenFill: ((sbn: number, data: ArrayBuffer) => void) | null = null;
27
27
  #onCleanupComplete: ((sbn: number) => void) | null = null;
28
28
  #onError: ((message: string, code?: FecWorkerErrorCode) => void) | null = null;
29
29
  #cleanupInFlight: Set<number> | null = null;
@@ -31,6 +31,20 @@ export class FecWorkerClient {
31
31
  #pendingResets = 0;
32
32
  #resetFailed = false;
33
33
  #disposed = false;
34
+ // Symbols fed during the current task, sent as one feedSymbols message
35
+ // when the task's microtasks run (BLO-36599). A packet stream delivers many
36
+ // symbols per task, and each postMessage is a structured-clone round trip
37
+ // on the page thread; the batch boundary is the task, not a size or timer.
38
+ #pendingSymbols: FecSymbolCommand[] = [];
39
+ #pendingTransfer: Transferable[] = [];
40
+ #flushQueued = false;
41
+ // Batching starts only once the Worker announced it accepts feedSymbols.
42
+ #batchSymbols = false;
43
+ // The Worker posts blockUpdate events only to a client that subscribed,
44
+ // which this client does once a callback is registered and the Worker
45
+ // announced the command (BLO-36599).
46
+ #blockUpdatesAnnounced = false;
47
+ #blockUpdatesSubscribed = false;
34
48
 
35
49
  /**
36
50
  * Create a FecWorkerClient.
@@ -66,6 +80,7 @@ export class FecWorkerClient {
66
80
  altaWasmModule?: WebAssembly.Module,
67
81
  ): void {
68
82
  if (this.#disposed) throw new Error("[FecWorkerClient] disposed");
83
+ this.#flushSymbols();
69
84
  const cmd: FecWorkerCommand = {
70
85
  type: "configure",
71
86
  config,
@@ -84,7 +99,8 @@ export class FecWorkerClient {
84
99
 
85
100
  /**
86
101
  * Feed a source symbol to the Worker. data ArrayBuffer is transferred (zero-copy).
87
- * Caller must not access data after this call — it is detached.
102
+ * Caller must not access data after this call — it is transferred with the
103
+ * task's symbol batch, before any later command of this client.
88
104
  *
89
105
  * Optional `alta` carries the detached ALTA authenticator bytes and the
90
106
  * byte offset in `data` where the signed payload ends. The auth buffer is
@@ -98,18 +114,22 @@ export class FecWorkerClient {
98
114
  alta?: { auth: ArrayBuffer; payloadEnd: number },
99
115
  ): void {
100
116
  if (this.#disposed) throw new Error("[FecWorkerClient] feedSource on a disposed client");
101
- const cmd: FecWorkerCommand = alta
102
- ? {
117
+ if (alta) {
118
+ this.#queueSymbol(
119
+ {
103
120
  type: "feedSource",
104
121
  ssId,
105
122
  data,
106
123
  ts,
107
124
  altaAuth: alta.auth,
108
125
  altaPayloadEnd: alta.payloadEnd,
109
- }
110
- : { type: "feedSource", ssId, data, ts };
111
- const transfer: Transferable[] = alta ? [data, alta.auth] : [data];
112
- this.#worker.postMessage(cmd, transfer);
126
+ },
127
+ data,
128
+ alta.auth,
129
+ );
130
+ } else {
131
+ this.#queueSymbol({ type: "feedSource", ssId, data, ts }, data);
132
+ }
113
133
  }
114
134
 
115
135
  /**
@@ -129,16 +149,38 @@ export class FecWorkerClient {
129
149
  ts: number,
130
150
  ): void {
131
151
  if (this.#disposed) throw new Error("[FecWorkerClient] feedRepair on a disposed client");
152
+ this.#queueSymbol({ type: "feedRepair", ssStart, ssbLength, rsId, data, ts }, data);
153
+ }
154
+
155
+ #queueSymbol(symbol: FecSymbolCommand, data: ArrayBuffer, auth?: ArrayBuffer): void {
156
+ if (!this.#batchSymbols) {
157
+ this.#worker.postMessage(symbol, auth ? [data, auth] : [data]);
158
+ return;
159
+ }
160
+ this.#pendingSymbols.push(symbol);
161
+ this.#pendingTransfer.push(data);
162
+ if (auth) this.#pendingTransfer.push(auth);
163
+ if (this.#flushQueued) return;
164
+ this.#flushQueued = true;
165
+ queueMicrotask(() => {
166
+ this.#flushQueued = false;
167
+ this.#flushSymbols();
168
+ });
169
+ }
170
+
171
+ /**
172
+ * Send the symbols fed so far. Every other command calls this first, so a
173
+ * cleanup barrier, reset or signature still follows all earlier symbols.
174
+ */
175
+ #flushSymbols(): void {
176
+ if (this.#pendingSymbols.length === 0 || this.#disposed) return;
177
+ const symbols = this.#pendingSymbols;
178
+ const transfer = this.#pendingTransfer;
179
+ this.#pendingSymbols = [];
180
+ this.#pendingTransfer = [];
132
181
  this.#worker.postMessage(
133
- {
134
- type: "feedRepair",
135
- ssStart,
136
- ssbLength,
137
- rsId,
138
- data,
139
- ts,
140
- } satisfies FecWorkerCommand,
141
- [data],
182
+ { type: "feedSymbols", symbols } satisfies FecWorkerCommand,
183
+ transfer,
142
184
  );
143
185
  }
144
186
 
@@ -147,6 +189,7 @@ export class FecWorkerClient {
147
189
  */
148
190
  relayBlockSig(sbn: number, sig: ArrayBuffer, hash: ArrayBuffer): void {
149
191
  if (this.#disposed) throw new Error("[FecWorkerClient] relayBlockSig on a disposed client");
192
+ this.#flushSymbols();
150
193
  this.#worker.postMessage(
151
194
  { type: "relayBlockSig", sbn, sig, hash } satisfies FecWorkerCommand,
152
195
  [sig, hash],
@@ -178,6 +221,7 @@ export class FecWorkerClient {
178
221
  /** Reset this track's decoder epoch while preserving Worker configuration. */
179
222
  reset(): void {
180
223
  if (this.#disposed) throw new Error("[FecWorkerClient] reset on a disposed client");
224
+ this.#flushSymbols();
181
225
  this.#pendingResets++;
182
226
  this.#cleanupInFlight = null;
183
227
  this.#queuedCleanupSbns.clear();
@@ -185,6 +229,9 @@ export class FecWorkerClient {
185
229
  }
186
230
 
187
231
  #postCleanup(sbns: Set<number>): void {
232
+ // The cleanup is a queue-drain barrier: every symbol fed before it is
233
+ // posted must reach the Worker first.
234
+ this.#flushSymbols();
188
235
  this.#cleanupInFlight = sbns;
189
236
  const orderedSbns = [...sbns].sort((a, b) => a - b);
190
237
  this.#worker.postMessage(
@@ -202,14 +249,28 @@ export class FecWorkerClient {
202
249
  this.#onSnapshot = cb;
203
250
  }
204
251
 
205
- /** Register callback for individual block state changes. */
252
+ /**
253
+ * Register callback for individual block state changes. The Worker posts
254
+ * them only once asked, so registering subscribes (after the Worker
255
+ * announces `subscribeBlockUpdates`). An older Worker that does not
256
+ * announce it posts them unasked.
257
+ */
206
258
  onBlockUpdate(cb: (block: FecBlockSnapshot) => void): void {
207
259
  this.#onBlockUpdate = cb;
260
+ this.#subscribeBlockUpdates();
208
261
  }
209
262
 
210
- /** Register callback for green-fill frames. */
211
- onGreenFill(cb: (sbn: number, data: ArrayBuffer) => void): void {
212
- this.#onGreenFill = cb;
263
+ #subscribeBlockUpdates(): void {
264
+ if (
265
+ this.#disposed ||
266
+ this.#blockUpdatesSubscribed ||
267
+ !this.#blockUpdatesAnnounced ||
268
+ !this.#onBlockUpdate
269
+ ) return;
270
+ this.#blockUpdatesSubscribed = true;
271
+ this.#worker.postMessage(
272
+ { type: "subscribeBlockUpdates" } satisfies FecWorkerCommand,
273
+ );
213
274
  }
214
275
 
215
276
  /** Register callback fired after the Worker drains all commands queued before cleanup. */
@@ -239,6 +300,9 @@ export class FecWorkerClient {
239
300
  dispose(): void {
240
301
  if (this.#disposed) return;
241
302
  this.#disposed = true;
303
+ // A terminated Worker decodes nothing; symbols still batched are dropped.
304
+ this.#pendingSymbols = [];
305
+ this.#pendingTransfer = [];
242
306
  this.#cleanupInFlight = null;
243
307
  this.#queuedCleanupSbns.clear();
244
308
  this.#worker.postMessage({ type: "dispose" } satisfies FecWorkerCommand);
@@ -246,6 +310,16 @@ export class FecWorkerClient {
246
310
  }
247
311
 
248
312
  #handleEvent(event: FecWorkerEvent): void {
313
+ if (event.type === "capabilities") {
314
+ if (Array.isArray(event.commands) && event.commands.includes("feedSymbols")) {
315
+ this.#batchSymbols = true;
316
+ }
317
+ if (Array.isArray(event.commands) && event.commands.includes("subscribeBlockUpdates")) {
318
+ this.#blockUpdatesAnnounced = true;
319
+ this.#subscribeBlockUpdates();
320
+ }
321
+ return;
322
+ }
249
323
  if (event.type === "resetComplete") {
250
324
  this.#pendingResets = Math.max(0, this.#pendingResets - 1);
251
325
  this.#resetFailed = false;
@@ -271,9 +345,6 @@ export class FecWorkerClient {
271
345
  case "blockUpdate":
272
346
  this.#onBlockUpdate?.(event.block);
273
347
  break;
274
- case "greenFill":
275
- this.#onGreenFill?.(event.sbn, event.data);
276
- break;
277
348
  case "cleanupComplete":
278
349
  for (const sbn of event.sbns) {
279
350
  this.#onCleanupComplete?.(sbn);
@@ -57,13 +57,15 @@ export interface FecBlockSnapshot {
57
57
  /** Source block number */
58
58
  sbn: number;
59
59
  /** Current block state */
60
- state: "collecting" | "complete" | "recovered" | "failed" | "green-fill";
60
+ state: "collecting" | "complete" | "recovered" | "failed";
61
61
  /** Number of source symbols received */
62
62
  sourceReceived: number;
63
63
  /** Number of repair symbols received */
64
64
  repairReceived: number;
65
- /** Expected source symbols (K) */
65
+ /** Configured source-block geometry (K) */
66
66
  k: number;
67
+ /** Authored source symbols (SSB_length) once a repair symbol declared it */
68
+ ssbLength?: number;
67
69
  /** Expected repair symbols (P) */
68
70
  repairCount: number;
69
71
  /** Timestamp of first symbol arrival (ms) */
@@ -97,8 +99,6 @@ export interface FecTrackStats {
97
99
  blocksRecovered: number;
98
100
  /** Blocks that failed (unrecoverable) */
99
101
  blocksFailed: number;
100
- /** Frames replaced with green-fill */
101
- greenFillFrames: number;
102
102
  /** Recovery success rate 0-100 */
103
103
  recoveryRate: number;
104
104
 
@@ -168,7 +168,9 @@ export interface FecTrackStats {
168
168
  export type FecWorkerErrorCode =
169
169
  | "recovered-symbol-size-unconfigured"
170
170
  | "recovered-block-too-short"
171
+ | "recovered-symbol-ssb-length-invalid"
171
172
  | "repair-symbol-size-mismatch"
173
+ | "repair-ssb-length-conflict"
172
174
  | "reset-failed"
173
175
  | "unknown-command"
174
176
  | "command-failed"
@@ -185,8 +187,14 @@ export interface FrameMeta {
185
187
  recovered: boolean;
186
188
  /** Source block number this frame belongs to */
187
189
  sbn: number;
188
- /** Encoding Symbol ID within the recovered source block (0..K-1). */
190
+ /** Encoding Symbol ID within the recovered source block (0..ssbLength-1). */
189
191
  esi?: number;
192
+ /**
193
+ * Authored source-symbol count of the recovered block (the repair
194
+ * symbols' SSB_length, 1..K). Recovered frames are ESI 0..ssbLength-1;
195
+ * ESIs ssbLength..K-1 are encoder zero padding and are never emitted.
196
+ */
197
+ ssbLength?: number;
190
198
  }
191
199
 
192
200
  // --- Worker commands (main -> worker) ---
@@ -241,6 +249,17 @@ export type FecWorkerCommand =
241
249
  data: ArrayBuffer;
242
250
  ts: number;
243
251
  }
252
+ | {
253
+ /**
254
+ * Every source and repair symbol the page fed in one task, in feed
255
+ * order, sharing one transfer list. The client sends symbols this
256
+ * way instead of one message per symbol (BLO-36599); the Worker
257
+ * handles each record exactly like the single-symbol command of the
258
+ * same `type`, and a record that fails does not stop the others.
259
+ */
260
+ type: "feedSymbols";
261
+ symbols: FecSymbolCommand[];
262
+ }
244
263
  | {
245
264
  type: "relayBlockSig";
246
265
  sbn: number;
@@ -253,8 +272,21 @@ export type FecWorkerCommand =
253
272
  sbns: number[];
254
273
  }
255
274
  | { type: "reset" }
275
+ /**
276
+ * Ask the Worker to post a blockUpdate on every block transition. Without
277
+ * it the Worker posts none: each one is a structured clone and a task on
278
+ * the page thread, and the player does not consume them (BLO-36599).
279
+ * Sent only to a Worker that announced it in `capabilities`.
280
+ */
281
+ | { type: "subscribeBlockUpdates" }
256
282
  | { type: "dispose" };
257
283
 
284
+ /** One symbol record of a `feedSymbols` batch. */
285
+ export type FecSymbolCommand = Extract<
286
+ FecWorkerCommand,
287
+ { type: "feedSource" } | { type: "feedRepair" }
288
+ >;
289
+
258
290
  // --- Worker events (worker -> main) ---
259
291
 
260
292
  /** Discriminated union of all events emitted from Worker to main thread. */
@@ -262,7 +294,16 @@ export type FecWorkerEvent =
262
294
  | { type: "frame"; data: ArrayBuffer; meta: FrameMeta }
263
295
  | { type: "snapshot"; stats: FecTrackStats }
264
296
  | { type: "blockUpdate"; block: FecBlockSnapshot }
265
- | { type: "greenFill"; sbn: number; data: ArrayBuffer }
266
297
  | { type: "cleanupComplete"; sbns: number[] }
267
298
  | { type: "resetComplete" }
299
+ /**
300
+ * Optional commands this Worker accepts, posted once it is configured.
301
+ * The client uses `feedSymbols` only after the Worker names it, so a page
302
+ * that meets an older cached Worker bundle keeps sending one command per
303
+ * symbol instead of a batch that Worker would reject (BLO-36599).
304
+ */
305
+ | { type: "capabilities"; commands: FecWorkerOptionalCommand[] }
268
306
  | { type: "error"; message: string; code?: FecWorkerErrorCode };
307
+
308
+ /** Commands a client may send only after the Worker announced them. */
309
+ export type FecWorkerOptionalCommand = "feedSymbols" | "subscribeBlockUpdates";
package/src/fec-worker.ts CHANGED
@@ -40,8 +40,12 @@ interface WasmFecDecoder {
40
40
  timestamp: number,
41
41
  ): Uint8Array | undefined;
42
42
  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;
43
+ /**
44
+ * Retire the named blocks (last-chance decode + stats), driven by the
45
+ * manager's exact deadlines. Nothing is synthesised for a lost block:
46
+ * concealment is the sink's hold-last-good-frame.
47
+ */
48
+ cleanup_blocks_detailed(sbns: Uint32Array): void;
45
49
  /** Remove decoder blocks below its configured live-SBN interleave horizon. */
46
50
  cleanup_by_sbn(current_sbn: number): Uint32Array;
47
51
  flush(): void;
@@ -61,10 +65,7 @@ interface WasmFecDecoder {
61
65
 
62
66
  interface WasmBindings {
63
67
  initSync(module: { module: WebAssembly.Module }): unknown;
64
- MmtFecDecoder: new (
65
- interleaveDepth: number,
66
- greenFillEnabled: boolean,
67
- ) => WasmFecDecoder;
68
+ MmtFecDecoder: new (interleaveDepth: number) => WasmFecDecoder;
68
69
  }
69
70
 
70
71
  // --- Worker state (local to this Worker instance) ---
@@ -75,6 +76,8 @@ let altaEnabled = false;
75
76
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
76
77
  let altaVerifier: any = null; // JsAltaVerifier instance (loaded from ALTA WASM)
77
78
  let snapshotTimer: ReturnType<typeof setInterval> | null = null;
79
+ // blockUpdate events are posted only after a client subscribes (BLO-36599).
80
+ let blockUpdatesSubscribed = false;
78
81
 
79
82
  // Block tracking
80
83
  const blocks = new Map<number, FecBlockSnapshot>();
@@ -95,7 +98,6 @@ let repairSymbols = 0;
95
98
  let blocksComplete = 0;
96
99
  let blocksRecovered = 0;
97
100
  let blocksFailed = 0;
98
- let greenFillFrames = 0;
99
101
  let altaVerified = 0;
100
102
  let altaFailed = 0; // Crypto-confirmed tamper (MAC / sig / hash chain mismatch)
101
103
  const altaFailedDetails: Array<{
@@ -444,7 +446,6 @@ function buildStatsSnapshot(): FecTrackStats {
444
446
  blocksComplete,
445
447
  blocksRecovered,
446
448
  blocksFailed,
447
- greenFillFrames,
448
449
  recoveryRate:
449
450
  blocksComplete + blocksRecovered > 0
450
451
  ? Math.round(
@@ -478,6 +479,7 @@ function emitSnapshot(): void {
478
479
  }
479
480
 
480
481
  function emitBlockUpdate(block: FecBlockSnapshot): void {
482
+ if (!blockUpdatesSubscribed) return;
481
483
  emit({ type: "blockUpdate", block: { ...block } });
482
484
  }
483
485
 
@@ -496,16 +498,19 @@ function emitRecoveredSymbols(
496
498
  });
497
499
  return;
498
500
  }
499
- if (recoveredBlock.byteLength < symbolSize * config.k) {
501
+ // The decoder returns only the block's authored source symbols
502
+ // (SSB_length x T); the encoder's zero padding is not media.
503
+ const ssbLength = block.ssbLength ?? config.k;
504
+ if (recoveredBlock.byteLength !== symbolSize * ssbLength) {
500
505
  emit({
501
506
  type: "error",
502
507
  code: "recovered-block-too-short",
503
- message: `[FecWorker] recovered block too short: got ${recoveredBlock.byteLength}B, expected at least ${symbolSize * config.k}B`,
508
+ message: `[FecWorker] recovered block has the wrong length: got ${recoveredBlock.byteLength}B, expected ${symbolSize * ssbLength}B (SSB_length=${ssbLength})`,
504
509
  });
505
510
  return;
506
511
  }
507
512
 
508
- for (let i = 0; i < config.k; i++) {
513
+ for (let i = 0; i < ssbLength; i++) {
509
514
  const symbol = recoveredBlock.subarray(i * symbolSize, (i + 1) * symbolSize);
510
515
 
511
516
  // Source authentication now runs once at router admission in
@@ -520,6 +525,7 @@ function emitRecoveredSymbols(
520
525
  recovered: true,
521
526
  sbn,
522
527
  esi: i,
528
+ ssbLength,
523
529
  };
524
530
  emit({ type: "frame", data: buf, meta }, [buf]);
525
531
  }
@@ -611,10 +617,7 @@ async function handleConfigure(
611
617
 
612
618
  // Decoder policy and geometry are explicit at the epoch boundary. The WASM
613
619
  // module has no guessed K/T/interleave slow path.
614
- wasmDecoder = new wasm.MmtFecDecoder(
615
- cmd.config.interleaveDepth,
616
- cmd.config.trackType === "video",
617
- );
620
+ wasmDecoder = new wasm.MmtFecDecoder(cmd.config.interleaveDepth);
618
621
  config = cmd.config;
619
622
  altaEnabled = !!cmd.altaPublicKey;
620
623
 
@@ -662,6 +665,10 @@ async function handleConfigure(
662
665
 
663
666
  wasmDecoder.configure_params(config.k, config.symbolSize);
664
667
 
668
+ // Tell the client which optional commands this Worker accepts, so it can
669
+ // batch symbols without risking a Worker that predates the batch command.
670
+ emit({ type: "capabilities", commands: ["feedSymbols", "subscribeBlockUpdates"] });
671
+
665
672
  // Start periodic snapshot emission (200ms floor)
666
673
  startSnapshotTimer();
667
674
 
@@ -716,9 +723,11 @@ function handleFeedSource(
716
723
  // authenticated by the canonical manifest verifier at router admission.
717
724
  if (altaEnabled) altaPending++;
718
725
 
719
- // Check if block is complete (all K source symbols received)
726
+ // Check if block is complete (all authored source symbols received:
727
+ // SSB_length once a repair symbol declared it, otherwise K)
728
+ const authoredLength = block.ssbLength ?? block.k;
720
729
  if (
721
- block.sourceReceived >= block.k &&
730
+ block.sourceReceived >= authoredLength &&
722
731
  block.state === "collecting"
723
732
  ) {
724
733
  markBlockTerminal(block, "complete");
@@ -730,7 +739,7 @@ function handleFeedSource(
730
739
  // of earlier missing symbols after repair arrived. Clean completion is
731
740
  // already routed by the source path, so only emit when the block did not
732
741
  // reach K source arrivals.
733
- if (result && result.byteLength > 0 && block.sourceReceived < block.k) {
742
+ if (result && result.byteLength > 0 && block.sourceReceived < authoredLength) {
734
743
  const recovered = new Uint8Array(toOwnedArrayBuffer(result));
735
744
  const recoveryMs = performance.now() - block.firstSymbolTs;
736
745
  recoveryTimes.push(recoveryMs);
@@ -786,6 +795,16 @@ function handleFeedRepair(
786
795
  const existingBlock = blocks.get(sbn);
787
796
  if (existingBlock && isTerminalBlock(existingBlock)) return;
788
797
  if (acceptedRepairEsis.get(sbn)?.has(repairEsi)) return;
798
+ // One block has one authored SSB_length; a repair symbol naming another
799
+ // is dropped before it reaches the decoder (which rejects it too).
800
+ if (existingBlock?.ssbLength !== undefined && existingBlock.ssbLength !== cmd.ssbLength) {
801
+ emit({
802
+ type: "error",
803
+ code: "repair-ssb-length-conflict",
804
+ message: `[FecWorker] Dropping repair symbol whose SSB_length ${cmd.ssbLength} conflicts with ${existingBlock.ssbLength} (sbn=${sbn})`,
805
+ });
806
+ return;
807
+ }
789
808
 
790
809
  const result = wasmDecoder.add_repair(
791
810
  cmd.ssStart,
@@ -802,13 +821,20 @@ function handleFeedRepair(
802
821
  }
803
822
  repairEsis.add(repairEsi);
804
823
  const block = getOrCreateBlock(sbn);
824
+ block.ssbLength = cmd.ssbLength;
805
825
  block.repairReceived++;
806
826
  block.lastSymbolTs = performance.now();
807
827
  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) {
828
+ // Every authored source symbol already arrived: knowing SSB_length makes
829
+ // the block clean. The decoder's return is the source already routed.
830
+ // Otherwise, returned data is the recovered source block: its SSB_length
831
+ // fixed-width authored symbols concatenated. Split it before emitting so
832
+ // MmtpRouter gets the same one-symbol shape as a direct source packet.
833
+ if (block.sourceReceived >= cmd.ssbLength && block.state === "collecting") {
834
+ markBlockTerminal(block, "complete");
835
+ blocksComplete++;
836
+ emitBlockUpdate(block);
837
+ } else if (result && result.byteLength > 0) {
812
838
  const recovered = new Uint8Array(toOwnedArrayBuffer(result));
813
839
  const recoveryMs = performance.now() - block.firstSymbolTs;
814
840
  recoveryTimes.push(recoveryMs);
@@ -855,21 +881,19 @@ function handleCleanup(
855
881
  throw new Error("[FecWorker] cleanup: not configured");
856
882
  }
857
883
 
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();
884
+ // Call WASM cleanup only after all earlier symbol commands have drained,
885
+ // and only for the exact SBNs the manager's deadlines named; command
886
+ // coalescing must not infer ownership from the largest timed-out SBN.
887
+ if (pendingSbns.length > 0) {
888
+ wasmDecoder.cleanup_blocks_detailed(Uint32Array.from(pendingSbns));
889
+ }
865
890
  // Manager deadlines are block-scoped and may expire a newer SBN while an
866
891
  // older interleaved block is still live. Only accepted media progression
867
892
  // advances the shared JS/Rust horizon; cleanup creates exact tombstones.
868
893
 
869
894
  // Rust already finalized every SBN behind the media frontier. For each
870
895
  // 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.
896
+ // so JS never retains state for a decoder block that is gone.
873
897
  for (const sbn of pendingSbns) {
874
898
  let currentBlock = blocks.get(sbn);
875
899
  if (currentBlock && isTerminalBlock(currentBlock)) {
@@ -886,12 +910,6 @@ function handleCleanup(
886
910
  }
887
911
  emitBlockUpdate(currentBlock);
888
912
  }
889
-
890
- const greenFills = decodeCleanupRecords(cleanupRecords);
891
- for (const { sbn, data } of greenFills) {
892
- greenFillFrames++;
893
- emit({ type: "greenFill", sbn, data }, [data]);
894
- }
895
913
  } finally {
896
914
  // Message ordering makes this an acknowledgement that every source/repair
897
915
  // command posted before cleanup has completed. The main thread must not
@@ -900,29 +918,6 @@ function handleCleanup(
900
918
  }
901
919
  }
902
920
 
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
921
  function handleDispose(): void {
927
922
  // Clear snapshot timer (MUST clear to prevent leaks)
928
923
  if (snapshotTimer !== null) {
@@ -979,7 +974,6 @@ function handleReset(): void {
979
974
  blocksComplete = 0;
980
975
  blocksRecovered = 0;
981
976
  blocksFailed = 0;
982
- greenFillFrames = 0;
983
977
  altaVerified = 0;
984
978
  altaFailed = 0;
985
979
  altaFailedDetails.length = 0;
@@ -994,6 +988,38 @@ function handleReset(): void {
994
988
  if (config) emitSnapshot();
995
989
  }
996
990
 
991
+ /**
992
+ * A batch of symbols fed in one page task (BLO-36599). Each record is
993
+ * handled exactly as its single-symbol command would be, in order, and a
994
+ * failing record reports the same `command-failed` error that command would
995
+ * without stopping the records after it.
996
+ */
997
+ function handleFeedSymbols(
998
+ cmd: Extract<FecWorkerCommand, { type: "feedSymbols" }>,
999
+ ): void {
1000
+ if (!Array.isArray(cmd.symbols)) {
1001
+ throw new Error("[FecWorker] feedSymbols: symbols must be an array");
1002
+ }
1003
+ for (const symbol of cmd.symbols) {
1004
+ try {
1005
+ if (symbol?.type === "feedSource") handleFeedSource(symbol);
1006
+ else if (symbol?.type === "feedRepair") handleFeedRepair(symbol);
1007
+ else {
1008
+ throw new Error(
1009
+ `[FecWorker] feedSymbols: unknown symbol type ${String((symbol as { type?: unknown } | null)?.type)}`,
1010
+ );
1011
+ }
1012
+ } catch (err) {
1013
+ emit({
1014
+ type: "error",
1015
+ code: "command-failed",
1016
+ message:
1017
+ err instanceof Error ? err.message : `[FecWorker] ${String(err)}`,
1018
+ });
1019
+ }
1020
+ }
1021
+ }
1022
+
997
1023
  // --- Main message handler ---
998
1024
 
999
1025
  async function dispatchCommand(cmd: FecWorkerCommand): Promise<void> {
@@ -1011,6 +1037,9 @@ async function dispatchCommand(cmd: FecWorkerCommand): Promise<void> {
1011
1037
  case "feedRepair":
1012
1038
  handleFeedRepair(cmd);
1013
1039
  break;
1040
+ case "feedSymbols":
1041
+ handleFeedSymbols(cmd);
1042
+ break;
1014
1043
  case "relayBlockSig":
1015
1044
  handleRelayBlockSig(cmd);
1016
1045
  break;
@@ -1020,6 +1049,9 @@ async function dispatchCommand(cmd: FecWorkerCommand): Promise<void> {
1020
1049
  case "reset":
1021
1050
  handleReset();
1022
1051
  break;
1052
+ case "subscribeBlockUpdates":
1053
+ blockUpdatesSubscribed = true;
1054
+ break;
1023
1055
  case "dispose":
1024
1056
  handleDispose();
1025
1057
  break;
package/src/index.ts CHANGED
@@ -6,6 +6,8 @@
6
6
 
7
7
  export type {
8
8
  FecWorkerCommand,
9
+ FecSymbolCommand,
10
+ FecWorkerOptionalCommand,
9
11
  FecWorkerEvent,
10
12
  FecWorkerErrorCode,
11
13
  FecTrackConfig,