@blockcast/fec-worker 0.1.0-main.64a6b356bf43 → 0.1.0-main.6598eedfb039

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/fec-worker.ts CHANGED
@@ -31,7 +31,7 @@ interface WasmFecDecoder {
31
31
  data: Uint8Array,
32
32
  timestamp: number,
33
33
  ): Uint8Array | undefined;
34
- /** ISO 23008-1 §C.5.3: repair ESI = ssb_length + rs_id, SBN = ss_start / ssb_length. */
34
+ /** ISO 23008-1 §C.5.3: repair ESI = configured K + rs_id, SBN = ss_start / configured K. */
35
35
  add_repair(
36
36
  ss_start: number,
37
37
  ssb_length: number,
@@ -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);
@@ -759,10 +768,10 @@ function handleFeedRepair(
759
768
  !isUint32(cmd.ssStart) ||
760
769
  !isUint32(cmd.ssbLength) ||
761
770
  cmd.ssbLength <= 0 ||
762
- cmd.ssbLength !== config.k ||
763
- cmd.ssStart % cmd.ssbLength !== 0 ||
771
+ cmd.ssbLength > config.k ||
772
+ cmd.ssStart % config.k !== 0 ||
764
773
  !isUint32(cmd.rsId) ||
765
- cmd.ssbLength + cmd.rsId > UINT24_MAX ||
774
+ config.k + cmd.rsId > UINT24_MAX ||
766
775
  !(cmd.data instanceof ArrayBuffer)
767
776
  ) {
768
777
  throw new Error("[FecWorker] feedRepair: invalid repair symbol");
@@ -780,12 +789,22 @@ function handleFeedRepair(
780
789
  }
781
790
  return;
782
791
  }
783
- const sbn = Math.floor(cmd.ssStart / cmd.ssbLength);
784
- const repairEsi = cmd.ssbLength + cmd.rsId;
792
+ const sbn = Math.floor(cmd.ssStart / config.k);
793
+ const repairEsi = config.k + cmd.rsId;
785
794
  if (sbn <= retiredSbnFrontier) return;
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,