@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/README.md +8 -4
- package/dist/block-order.d.ts +22 -0
- package/dist/block-order.d.ts.map +1 -0
- package/dist/block-order.js +38 -0
- package/dist/block-order.js.map +1 -0
- package/dist/fec-worker-client.d.ts +0 -2
- package/dist/fec-worker-client.d.ts.map +1 -1
- package/dist/fec-worker-client.js +0 -8
- package/dist/fec-worker-client.js.map +1 -1
- package/dist/fec-worker-types.d.ts +44 -8
- package/dist/fec-worker-types.d.ts.map +1 -1
- package/dist/fec-worker.js +54 -45
- package/dist/fec-worker.js.map +3 -3
- package/package.json +2 -2
- package/src/block-order.ts +42 -0
- package/src/fec-worker-client.ts +0 -9
- package/src/fec-worker-types.ts +44 -5
- package/src/fec-worker.ts +84 -66
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
|
-
/**
|
|
44
|
-
|
|
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
|
-
//
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
let
|
|
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
|
-
|
|
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
|
|
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 ||
|
|
236
|
+
if (!config || (liveSbnKnown && !blockPrecedes(liveSbn, sbn, config.k))) return;
|
|
201
237
|
liveSbn = sbn;
|
|
202
|
-
|
|
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
|
|
739
|
+
if (isRetiredSbn(sbn)) {
|
|
740
|
+
sourceDroppedRetired++;
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
706
743
|
const existingBlock = blocks.get(sbn);
|
|
707
|
-
if (existingBlock && isTerminalBlock(existingBlock))
|
|
708
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
890
|
-
//
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
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
|
|
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
|
}
|