@blockcast/fec-worker 0.1.0-main.ce3c8aeb7f68 → 0.1.0-main.d0bb31684c09

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
@@ -20,6 +20,7 @@ import type {
20
20
  FrameMeta,
21
21
  } from "./fec-worker-types.js";
22
22
  import { toOwnedArrayBuffer } from "./transfer.js";
23
+ import { blockPrecedes, blocksBehind } from "./block-order.js";
23
24
 
24
25
  // --- WASM binding types (from mmt-wasm) ---
25
26
  // Signatures MUST match a freshly generated external mmt_wasm.d.ts exactly.
@@ -40,8 +41,12 @@ interface WasmFecDecoder {
40
41
  timestamp: number,
41
42
  ): Uint8Array | undefined;
42
43
  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;
44
+ /**
45
+ * Retire the named blocks (last-chance decode + stats), driven by the
46
+ * manager's exact deadlines. Nothing is synthesised for a lost block:
47
+ * concealment is the sink's hold-last-good-frame.
48
+ */
49
+ cleanup_blocks_detailed(sbns: Uint32Array): void;
45
50
  /** Remove decoder blocks below its configured live-SBN interleave horizon. */
46
51
  cleanup_by_sbn(current_sbn: number): Uint32Array;
47
52
  flush(): void;
@@ -61,10 +66,7 @@ interface WasmFecDecoder {
61
66
 
62
67
  interface WasmBindings {
63
68
  initSync(module: { module: WebAssembly.Module }): unknown;
64
- MmtFecDecoder: new (
65
- interleaveDepth: number,
66
- greenFillEnabled: boolean,
67
- ) => WasmFecDecoder;
69
+ MmtFecDecoder: new (interleaveDepth: number) => WasmFecDecoder;
68
70
  }
69
71
 
70
72
  // --- Worker state (local to this Worker instance) ---
@@ -97,7 +99,6 @@ let repairSymbols = 0;
97
99
  let blocksComplete = 0;
98
100
  let blocksRecovered = 0;
99
101
  let blocksFailed = 0;
100
- let greenFillFrames = 0;
101
102
  let altaVerified = 0;
102
103
  let altaFailed = 0; // Crypto-confirmed tamper (MAC / sig / hash chain mismatch)
103
104
  const altaFailedDetails: Array<{
@@ -115,11 +116,35 @@ let altaInitFailed = false; // ALTA configured but WASM init failed; altaEnab
115
116
  let relayBlocksVerified = 0;
116
117
  let relayBlocksFailed = 0;
117
118
  let liveSbn = 0;
118
- // Highest SBN that has fallen strictly below the decoder's retained
119
- // interleave window. Numeric ordering is per decoder epoch; reset/configure
120
- // starts a new epoch before the 32-bit source-symbol counter can wrap.
121
- let retiredSbnFrontier = -1;
119
+ // Whether liveSbn has been set by a symbol of this decoder epoch. Block order
120
+ // is serial on the 32-bit SS_ID (block-order.ts, BLO-35597 iso:5), where 0 is
121
+ // a real position, so "nothing seen yet" needs its own state.
122
+ let liveSbnKnown = false;
122
123
  let repairSymbolSizeMismatch = 0;
124
+ // Source symbols dropped before WASM, by cause. All three sites below were
125
+ // silent, which left the "block settles at exactly K-1 source symbols" shape
126
+ // (BLO-35597 defect 2) with no evidence to attribute it to. A retired drop
127
+ // means a late symbol past retention; a terminal drop means the block had
128
+ // already settled — `state !== "collecting"`, so complete OR recovered OR
129
+ // FAILED, and the failed arm is the one this issue is chasing (a block aged out
130
+ // by synchronizeDecoderHorizon stays in `blocks` until its SBN retires, so a
131
+ // late symbol for a short block lands here); a duplicate drop means a repeated
132
+ // (SBN, ESI), which on this wire is usually a plain duplicate packet — the
133
+ // router's ledger has deliberate gaps — and only sometimes an SBN/ESI
134
+ // derivation collision.
135
+ //
136
+ // These narrow the cause; they do not uniquely attribute it. A symbol LOST IN
137
+ // TRANSIT never reaches handleFeedSource and increments nothing, so all three
138
+ // reading zero on a short block is NOT evidence of a publisher defect — wire
139
+ // loss is the null hypothesis here, and it produces an identical shape. The
140
+ // discriminator for that residual arm is `block.ssbLength` (set at the repair
141
+ // symbol's declared SSB_length, conflict-checked on arrival):
142
+ // ssbLength < k → genuinely short authored block
143
+ // ssbLength === k, sourceReceived < k → loss or a still-late symbol
144
+ // With no repair symbol yet seen, ssbLength is undefined and both stay open.
145
+ let sourceDroppedRetired = 0;
146
+ let sourceDroppedTerminal = 0;
147
+ let sourceDroppedDuplicate = 0;
123
148
 
124
149
  // Recovery timing
125
150
  const recoveryTimes: number[] = [];
@@ -179,7 +204,18 @@ function clearBlockTracking(): void {
179
204
  pendingVerify.clear();
180
205
  recoveryTimes.length = 0;
181
206
  liveSbn = 0;
182
- retiredSbnFrontier = -1;
207
+ liveSbnKnown = false;
208
+ }
209
+
210
+ /**
211
+ * Whether `sbn` has fallen behind the decoder's retained interleave window:
212
+ * more than `interleaveDepth` blocks behind the live SBN. liveSbn only moves
213
+ * forward within a decoder epoch and configure() resets both, so this is the
214
+ * monotonic retirement frontier the decoder horizon implies.
215
+ */
216
+ function isRetiredSbn(sbn: number): boolean {
217
+ return config !== null && liveSbnKnown &&
218
+ blocksBehind(sbn, liveSbn, config.k) > config.interleaveDepth;
183
219
  }
184
220
 
185
221
  /**
@@ -190,17 +226,16 @@ function clearBlockTracking(): void {
190
226
  */
191
227
  function trimBlockRetention(): void {
192
228
  for (const [sbn, block] of blocks) {
193
- if (!isTerminalBlock(block) || sbn > retiredSbnFrontier) continue;
229
+ if (!isTerminalBlock(block) || !isRetiredSbn(sbn)) continue;
194
230
  blocks.delete(sbn);
195
231
  retireAcceptedIds(sbn);
196
232
  }
197
233
  }
198
234
 
199
235
  function advanceMediaHorizon(sbn: number): void {
200
- if (!config || sbn <= liveSbn) return;
236
+ if (!config || (liveSbnKnown && !blockPrecedes(liveSbn, sbn, config.k))) return;
201
237
  liveSbn = sbn;
202
- const firstRetainedSbn = Math.max(0, liveSbn - config.interleaveDepth);
203
- retiredSbnFrontier = Math.max(retiredSbnFrontier, firstRetainedSbn - 1);
238
+ liveSbnKnown = true;
204
239
  trimBlockRetention();
205
240
  }
206
241
 
@@ -215,7 +250,7 @@ function markBlockTerminal(
215
250
  }
216
251
 
217
252
  function synchronizeDecoderHorizon(): void {
218
- if (!wasmDecoder) return;
253
+ if (!wasmDecoder || !liveSbnKnown) return;
219
254
  const expired = wasmDecoder.cleanup_by_sbn(liveSbn);
220
255
  for (const sbn of expired) {
221
256
  const block = blocks.get(sbn);
@@ -446,7 +481,6 @@ function buildStatsSnapshot(): FecTrackStats {
446
481
  blocksComplete,
447
482
  blocksRecovered,
448
483
  blocksFailed,
449
- greenFillFrames,
450
484
  recoveryRate:
451
485
  blocksComplete + blocksRecovered > 0
452
486
  ? Math.round(
@@ -470,6 +504,9 @@ function buildStatsSnapshot(): FecTrackStats {
470
504
  avgRecoveryMs: Math.round(avgRecoveryMs * 100) / 100,
471
505
  maxRecoveryMs: Math.round(maxRecoveryMs * 100) / 100,
472
506
  repairSymbolSizeMismatch,
507
+ sourceDroppedRetired,
508
+ sourceDroppedTerminal,
509
+ sourceDroppedDuplicate,
473
510
  decoder,
474
511
  };
475
512
  }
@@ -618,10 +655,7 @@ async function handleConfigure(
618
655
 
619
656
  // Decoder policy and geometry are explicit at the epoch boundary. The WASM
620
657
  // module has no guessed K/T/interleave slow path.
621
- wasmDecoder = new wasm.MmtFecDecoder(
622
- cmd.config.interleaveDepth,
623
- cmd.config.trackType === "video",
624
- );
658
+ wasmDecoder = new wasm.MmtFecDecoder(cmd.config.interleaveDepth);
625
659
  config = cmd.config;
626
660
  altaEnabled = !!cmd.altaPublicKey;
627
661
 
@@ -702,10 +736,19 @@ function handleFeedSource(
702
736
  // value internally (ISO 23008-1 §C.5.2: SBN = floor(SS_ID / K)).
703
737
  const sbn = config.k > 0 ? Math.floor(cmd.ssId / config.k) : cmd.ssId;
704
738
  const esi = cmd.ssId % config.k;
705
- if (sbn <= retiredSbnFrontier) return;
739
+ if (isRetiredSbn(sbn)) {
740
+ sourceDroppedRetired++;
741
+ return;
742
+ }
706
743
  const existingBlock = blocks.get(sbn);
707
- if (existingBlock && isTerminalBlock(existingBlock)) return;
708
- if (acceptedSourceEsis.get(sbn)?.has(esi)) return;
744
+ if (existingBlock && isTerminalBlock(existingBlock)) {
745
+ sourceDroppedTerminal++;
746
+ return;
747
+ }
748
+ if (acceptedSourceEsis.get(sbn)?.has(esi)) {
749
+ sourceDroppedDuplicate++;
750
+ return;
751
+ }
709
752
 
710
753
  const result = wasmDecoder.add_source(cmd.ssId, view, cmd.ts);
711
754
  advanceMediaHorizon(sbn);
@@ -795,7 +838,11 @@ function handleFeedRepair(
795
838
  }
796
839
  const sbn = Math.floor(cmd.ssStart / config.k);
797
840
  const repairEsi = config.k + cmd.rsId;
798
- if (sbn <= retiredSbnFrontier) return;
841
+ // The repair path's mirror drops stay silent and uncounted: BLO-35597
842
+ // defect 2 is a source-symbol shortfall, so only the source side needed
843
+ // evidence. The asymmetry is deliberate, not an oversight — extend these
844
+ // the same way if a repair-side shortfall ever needs attributing.
845
+ if (isRetiredSbn(sbn)) return;
799
846
  const existingBlock = blocks.get(sbn);
800
847
  if (existingBlock && isTerminalBlock(existingBlock)) return;
801
848
  if (acceptedRepairEsis.get(sbn)?.has(repairEsi)) return;
@@ -879,27 +926,25 @@ function handleCleanup(
879
926
  throw new Error("[FecWorker] cleanup: invalid SBNs");
880
927
  }
881
928
  const sbns = [...new Set(cmd.sbns)].sort((a, b) => a - b);
882
- const pendingSbns = sbns.filter((sbn) => sbn > retiredSbnFrontier);
929
+ const pendingSbns = sbns.filter((sbn) => !isRetiredSbn(sbn));
883
930
  try {
884
931
  if (!wasmDecoder) {
885
932
  throw new Error("[FecWorker] cleanup: not configured");
886
933
  }
887
934
 
888
- // Call WASM cleanup only after all earlier symbol commands have drained.
889
- // The detailed API carries the true owner of every fill emitted by a
890
- // single cleanup pass; command coalescing must not infer ownership from
891
- // the largest timed-out SBN.
892
- const cleanupRecords = pendingSbns.length > 0
893
- ? wasmDecoder.cleanup_blocks_detailed(Uint32Array.from(pendingSbns))
894
- : new Uint8Array();
935
+ // Call WASM cleanup only after all earlier symbol commands have drained,
936
+ // and only for the exact SBNs the manager's deadlines named; command
937
+ // coalescing must not infer ownership from the largest timed-out SBN.
938
+ if (pendingSbns.length > 0) {
939
+ wasmDecoder.cleanup_blocks_detailed(Uint32Array.from(pendingSbns));
940
+ }
895
941
  // Manager deadlines are block-scoped and may expire a newer SBN while an
896
942
  // older interleaved block is still live. Only accepted media progression
897
943
  // advances the shared JS/Rust horizon; cleanup creates exact tombstones.
898
944
 
899
945
  // Rust already finalized every SBN behind the media frontier. For each
900
946
  // remaining exact request, mirror the block-scoped WASM boundary immediately
901
- // so JS never retains state for a decoder block that is gone, even if decoding
902
- // the trusted cleanup framing were to fail.
947
+ // so JS never retains state for a decoder block that is gone.
903
948
  for (const sbn of pendingSbns) {
904
949
  let currentBlock = blocks.get(sbn);
905
950
  if (currentBlock && isTerminalBlock(currentBlock)) {
@@ -916,12 +961,6 @@ function handleCleanup(
916
961
  }
917
962
  emitBlockUpdate(currentBlock);
918
963
  }
919
-
920
- const greenFills = decodeCleanupRecords(cleanupRecords);
921
- for (const { sbn, data } of greenFills) {
922
- greenFillFrames++;
923
- emit({ type: "greenFill", sbn, data }, [data]);
924
- }
925
964
  } finally {
926
965
  // Message ordering makes this an acknowledgement that every source/repair
927
966
  // command posted before cleanup has completed. The main thread must not
@@ -930,29 +969,6 @@ function handleCleanup(
930
969
  }
931
970
  }
932
971
 
933
- function decodeCleanupRecords(
934
- framed: Uint8Array,
935
- ): Array<{ sbn: number; data: ArrayBuffer }> {
936
- const records: Array<{ sbn: number; data: ArrayBuffer }> = [];
937
- const view = new DataView(framed.buffer, framed.byteOffset, framed.byteLength);
938
- let offset = 0;
939
- while (offset < framed.byteLength) {
940
- if (framed.byteLength - offset < 8) {
941
- throw new Error("[FecWorker] cleanup_blocks_detailed: truncated record header");
942
- }
943
- const sbn = view.getUint32(offset, false);
944
- const length = view.getUint32(offset + 4, false);
945
- offset += 8;
946
- if (length > framed.byteLength - offset) {
947
- throw new Error("[FecWorker] cleanup_blocks_detailed: truncated record payload");
948
- }
949
- const data = toOwnedArrayBuffer(framed.subarray(offset, offset + length));
950
- records.push({ sbn, data });
951
- offset += length;
952
- }
953
- return records;
954
- }
955
-
956
972
  function handleDispose(): void {
957
973
  // Clear snapshot timer (MUST clear to prevent leaks)
958
974
  if (snapshotTimer !== null) {
@@ -1009,7 +1025,6 @@ function handleReset(): void {
1009
1025
  blocksComplete = 0;
1010
1026
  blocksRecovered = 0;
1011
1027
  blocksFailed = 0;
1012
- greenFillFrames = 0;
1013
1028
  altaVerified = 0;
1014
1029
  altaFailed = 0;
1015
1030
  altaFailedDetails.length = 0;
@@ -1020,6 +1035,9 @@ function handleReset(): void {
1020
1035
  relayBlocksVerified = 0;
1021
1036
  relayBlocksFailed = 0;
1022
1037
  repairSymbolSizeMismatch = 0;
1038
+ sourceDroppedRetired = 0;
1039
+ sourceDroppedTerminal = 0;
1040
+ sourceDroppedDuplicate = 0;
1023
1041
  emit({ type: "resetComplete" });
1024
1042
  if (config) emitSnapshot();
1025
1043
  }