@blockcast/fec-worker 0.1.0 → 0.3.0-main.0bde85f7082f
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 +17 -7
- package/dist/block-order.d.ts +38 -0
- package/dist/block-order.d.ts.map +1 -0
- package/dist/block-order.js +78 -0
- package/dist/block-order.js.map +1 -0
- package/dist/fec-worker-client.d.ts +20 -7
- package/dist/fec-worker-client.d.ts.map +1 -1
- package/dist/fec-worker-client.js +106 -30
- package/dist/fec-worker-client.js.map +1 -1
- package/dist/fec-worker-types.d.ts +153 -25
- package/dist/fec-worker-types.d.ts.map +1 -1
- package/dist/fec-worker.js +382 -88
- package/dist/fec-worker.js.map +4 -4
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/shred-fec-worker.js +23 -2
- package/dist/shred-fec-worker.js.map +2 -2
- package/dist/shred-worker-types.d.ts +16 -1
- package/dist/shred-worker-types.d.ts.map +1 -1
- package/dist/shred-worker-types.js +16 -1
- package/dist/shred-worker-types.js.map +1 -1
- package/package.json +8 -3
- package/src/block-order.ts +93 -0
- package/src/fec-worker-client.ts +115 -32
- package/src/fec-worker-types.ts +152 -21
- package/src/fec-worker.ts +583 -129
- package/src/index.ts +3 -0
- package/src/shred-fec-worker.ts +34 -3
- package/src/shred-worker-types.ts +16 -1
- package/src/__tests__/fec-worker-alta.test.ts +0 -231
- package/src/__tests__/fec-worker-cleanup-integration.test.ts +0 -250
- package/src/__tests__/fec-worker-pending-queue.test.ts +0 -315
- package/src/__tests__/fec-worker-repair-size.test.ts +0 -1029
- package/src/__tests__/fec-worker-wasm-contract.test.ts +0 -84
- package/src/__tests__/shred-fec-worker.test.ts +0 -448
- package/src/fec-worker-client.test.ts +0 -530
- package/src/fec-worker-types.test.ts +0 -400
- package/src/shred-worker-client.test.ts +0 -182
- package/src/transfer.test.ts +0 -24
package/dist/fec-worker.js
CHANGED
|
@@ -7,17 +7,58 @@ function toOwnedArrayBuffer(view) {
|
|
|
7
7
|
return copy.buffer;
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
+
// src/block-order.ts
|
|
11
|
+
var SPAN = 4294967296;
|
|
12
|
+
var HALF = 2147483648;
|
|
13
|
+
function blockStart(sbn, k) {
|
|
14
|
+
return sbn * k % SPAN;
|
|
15
|
+
}
|
|
16
|
+
function precedingDistance(a, b, k) {
|
|
17
|
+
const distance = (blockStart(b, k) - blockStart(a, k) + SPAN) % SPAN;
|
|
18
|
+
return distance !== 0 && distance < HALF ? distance : null;
|
|
19
|
+
}
|
|
20
|
+
function blockPrecedes(a, b, k) {
|
|
21
|
+
return precedingDistance(a, b, k) !== null;
|
|
22
|
+
}
|
|
23
|
+
function blocksBehind(a, b, k) {
|
|
24
|
+
const distance = precedingDistance(a, b, k);
|
|
25
|
+
return distance === null ? 0 : Math.ceil(distance / k);
|
|
26
|
+
}
|
|
27
|
+
function serialDistance(from, to) {
|
|
28
|
+
return (to - from + SPAN) % SPAN;
|
|
29
|
+
}
|
|
30
|
+
function serialPrecedes(a, b) {
|
|
31
|
+
const distance = serialDistance(a, b);
|
|
32
|
+
return distance !== 0 && distance < HALF;
|
|
33
|
+
}
|
|
34
|
+
function isoBlockContains(ssStart, ssbLength, ssId) {
|
|
35
|
+
return serialDistance(ssStart, ssId) < ssbLength;
|
|
36
|
+
}
|
|
37
|
+
function isoBlocksOverlap(aStart, aLength, bStart, bLength) {
|
|
38
|
+
return isoBlockContains(aStart, aLength, bStart) || isoBlockContains(bStart, bLength, aStart);
|
|
39
|
+
}
|
|
40
|
+
function isoBlocksBehind(a, b, kMax) {
|
|
41
|
+
if (!Number.isSafeInteger(kMax) || kMax <= 0) {
|
|
42
|
+
throw new Error(`iso-ssbg1-v1 block horizon needs a positive maximum K (got ${kMax})`);
|
|
43
|
+
}
|
|
44
|
+
return serialPrecedes(a, b) ? Math.floor(serialDistance(a, b) / kMax) : 0;
|
|
45
|
+
}
|
|
46
|
+
|
|
10
47
|
// src/fec-worker.ts
|
|
11
48
|
var wasmDecoder = null;
|
|
12
49
|
var config = null;
|
|
13
50
|
var altaEnabled = false;
|
|
14
51
|
var altaVerifier = null;
|
|
15
52
|
var snapshotTimer = null;
|
|
53
|
+
var blockUpdatesSubscribed = false;
|
|
16
54
|
var blocks = /* @__PURE__ */ new Map();
|
|
17
55
|
var acceptedSourceEsis = /* @__PURE__ */ new Map();
|
|
18
56
|
var acceptedRepairEsis = /* @__PURE__ */ new Map();
|
|
57
|
+
var unplacedSources = /* @__PURE__ */ new Map();
|
|
19
58
|
var UINT32_MAX = 4294967295;
|
|
20
59
|
var UINT24_MAX = 16777215;
|
|
60
|
+
var SS_ID_SPAN = 4294967296;
|
|
61
|
+
var SSBG1_LENGTH_FIELD_BYTES = 2;
|
|
21
62
|
var MAX_INTERLEAVE_DEPTH = 255;
|
|
22
63
|
var MAX_SYMBOL_SIZE = 65528;
|
|
23
64
|
var MAX_RAPTORQ_SOURCE_SYMBOLS_PER_BLOCK = 56403;
|
|
@@ -26,7 +67,6 @@ var repairSymbols = 0;
|
|
|
26
67
|
var blocksComplete = 0;
|
|
27
68
|
var blocksRecovered = 0;
|
|
28
69
|
var blocksFailed = 0;
|
|
29
|
-
var greenFillFrames = 0;
|
|
30
70
|
var altaVerified = 0;
|
|
31
71
|
var altaFailed = 0;
|
|
32
72
|
var altaFailedDetails = [];
|
|
@@ -38,8 +78,12 @@ var altaInitFailed = false;
|
|
|
38
78
|
var relayBlocksVerified = 0;
|
|
39
79
|
var relayBlocksFailed = 0;
|
|
40
80
|
var liveSbn = 0;
|
|
41
|
-
var
|
|
81
|
+
var liveSbnKnown = false;
|
|
42
82
|
var repairSymbolSizeMismatch = 0;
|
|
83
|
+
var sourceDroppedRetired = 0;
|
|
84
|
+
var sourceDroppedTerminal = 0;
|
|
85
|
+
var sourceDroppedDuplicate = 0;
|
|
86
|
+
var sourceUnplacedRetired = 0;
|
|
43
87
|
var recoveryTimes = [];
|
|
44
88
|
var pendingVerify = /* @__PURE__ */ new Map();
|
|
45
89
|
var pendingVerifyCap = 0;
|
|
@@ -61,23 +105,46 @@ function clearBlockTracking() {
|
|
|
61
105
|
blocks.clear();
|
|
62
106
|
acceptedSourceEsis.clear();
|
|
63
107
|
acceptedRepairEsis.clear();
|
|
108
|
+
unplacedSources.clear();
|
|
64
109
|
pendingVerify.clear();
|
|
65
110
|
recoveryTimes.length = 0;
|
|
66
111
|
liveSbn = 0;
|
|
67
|
-
|
|
112
|
+
liveSbnKnown = false;
|
|
113
|
+
}
|
|
114
|
+
function isRetiredSbn(sbn) {
|
|
115
|
+
if (config === null || !liveSbnKnown) return false;
|
|
116
|
+
return config.blockProfile === "iso-ssbg1-v1" ? isoBlocksBehind(sbn, liveSbn, config.k) > config.interleaveDepth : blocksBehind(sbn, liveSbn, config.k) > config.interleaveDepth;
|
|
117
|
+
}
|
|
118
|
+
function isIsoProfile() {
|
|
119
|
+
return config?.blockProfile === "iso-ssbg1-v1";
|
|
120
|
+
}
|
|
121
|
+
function isoKnownLength(block) {
|
|
122
|
+
return block.ssbLength ?? 1;
|
|
123
|
+
}
|
|
124
|
+
function findIsoBlock(ssId) {
|
|
125
|
+
for (const block of blocks.values()) {
|
|
126
|
+
if (isoBlockContains(block.sbn, isoKnownLength(block), ssId)) return block;
|
|
127
|
+
}
|
|
128
|
+
return void 0;
|
|
129
|
+
}
|
|
130
|
+
function isRetiredBlock(key, block) {
|
|
131
|
+
return isIsoProfile() ? isRetiredSbn((key + isoKnownLength(block) - 1) % SS_ID_SPAN) : isRetiredSbn(key);
|
|
68
132
|
}
|
|
69
133
|
function trimBlockRetention() {
|
|
70
134
|
for (const [sbn, block] of blocks) {
|
|
71
|
-
if (!isTerminalBlock(block) || sbn
|
|
135
|
+
if (!isTerminalBlock(block) || !isRetiredBlock(sbn, block)) continue;
|
|
72
136
|
blocks.delete(sbn);
|
|
73
137
|
retireAcceptedIds(sbn);
|
|
74
138
|
}
|
|
75
139
|
}
|
|
76
140
|
function advanceMediaHorizon(sbn) {
|
|
77
|
-
if (!config
|
|
141
|
+
if (!config) return;
|
|
142
|
+
if (liveSbnKnown) {
|
|
143
|
+
const ahead = config.blockProfile === "iso-ssbg1-v1" ? serialPrecedes(liveSbn, sbn) : blockPrecedes(liveSbn, sbn, config.k);
|
|
144
|
+
if (!ahead) return;
|
|
145
|
+
}
|
|
78
146
|
liveSbn = sbn;
|
|
79
|
-
|
|
80
|
-
retiredSbnFrontier = Math.max(retiredSbnFrontier, firstRetainedSbn - 1);
|
|
147
|
+
liveSbnKnown = true;
|
|
81
148
|
trimBlockRetention();
|
|
82
149
|
}
|
|
83
150
|
function markBlockTerminal(block, state) {
|
|
@@ -87,8 +154,9 @@ function markBlockTerminal(block, state) {
|
|
|
87
154
|
trimBlockRetention();
|
|
88
155
|
}
|
|
89
156
|
function synchronizeDecoderHorizon() {
|
|
90
|
-
if (!wasmDecoder) return;
|
|
91
|
-
const
|
|
157
|
+
if (!wasmDecoder || !liveSbnKnown) return;
|
|
158
|
+
const iso = isIsoProfile();
|
|
159
|
+
const expired = iso ? wasmDecoder.cleanup_by_ss_start(liveSbn) : wasmDecoder.cleanup_by_sbn(liveSbn);
|
|
92
160
|
for (const sbn of expired) {
|
|
93
161
|
const block = blocks.get(sbn);
|
|
94
162
|
if (!block || isTerminalBlock(block)) {
|
|
@@ -99,9 +167,18 @@ function synchronizeDecoderHorizon() {
|
|
|
99
167
|
blocksFailed++;
|
|
100
168
|
emitBlockUpdate(block);
|
|
101
169
|
}
|
|
170
|
+
if (iso) retireUnplacedSources();
|
|
102
171
|
trimBlockRetention();
|
|
103
172
|
}
|
|
104
|
-
function
|
|
173
|
+
function retireUnplacedSources() {
|
|
174
|
+
for (const ssId of unplacedSources.keys()) {
|
|
175
|
+
const unreachable = ssId !== liveSbn && !serialPrecedes(ssId, liveSbn);
|
|
176
|
+
if (!unreachable && !isRetiredSbn(ssId)) break;
|
|
177
|
+
unplacedSources.delete(ssId);
|
|
178
|
+
sourceUnplacedRetired++;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
function getOrCreateBlock(sbn, isoSsbLength) {
|
|
105
182
|
let block = blocks.get(sbn);
|
|
106
183
|
if (!block) {
|
|
107
184
|
if (!config) throw new Error("[FecWorker] not configured");
|
|
@@ -111,7 +188,7 @@ function getOrCreateBlock(sbn) {
|
|
|
111
188
|
state: "collecting",
|
|
112
189
|
sourceReceived: 0,
|
|
113
190
|
repairReceived: 0,
|
|
114
|
-
k: config.k,
|
|
191
|
+
k: isoSsbLength ?? config.k,
|
|
115
192
|
repairCount: config.repairCount,
|
|
116
193
|
firstSymbolTs: now,
|
|
117
194
|
lastSymbolTs: now,
|
|
@@ -119,6 +196,7 @@ function getOrCreateBlock(sbn) {
|
|
|
119
196
|
altaVerified: 0,
|
|
120
197
|
relayBlockSigValid: null
|
|
121
198
|
};
|
|
199
|
+
if (isoSsbLength !== void 0) block.ssbLength = isoSsbLength;
|
|
122
200
|
blocks.set(sbn, block);
|
|
123
201
|
trimBlockRetention();
|
|
124
202
|
}
|
|
@@ -147,7 +225,6 @@ function buildStatsSnapshot() {
|
|
|
147
225
|
blocksComplete,
|
|
148
226
|
blocksRecovered,
|
|
149
227
|
blocksFailed,
|
|
150
|
-
greenFillFrames,
|
|
151
228
|
recoveryRate: blocksComplete + blocksRecovered > 0 ? Math.round(
|
|
152
229
|
blocksRecovered / (blocksRecovered + blocksFailed) * 100
|
|
153
230
|
) || 0 : 0,
|
|
@@ -168,6 +245,10 @@ function buildStatsSnapshot() {
|
|
|
168
245
|
avgRecoveryMs: Math.round(avgRecoveryMs * 100) / 100,
|
|
169
246
|
maxRecoveryMs: Math.round(maxRecoveryMs * 100) / 100,
|
|
170
247
|
repairSymbolSizeMismatch,
|
|
248
|
+
sourceDroppedRetired,
|
|
249
|
+
sourceDroppedTerminal,
|
|
250
|
+
sourceDroppedDuplicate,
|
|
251
|
+
...config.blockProfile === "iso-ssbg1-v1" ? { sourceUnplacedRetired } : {},
|
|
171
252
|
decoder
|
|
172
253
|
};
|
|
173
254
|
}
|
|
@@ -176,6 +257,7 @@ function emitSnapshot() {
|
|
|
176
257
|
emit({ type: "snapshot", stats: buildStatsSnapshot() });
|
|
177
258
|
}
|
|
178
259
|
function emitBlockUpdate(block) {
|
|
260
|
+
if (!blockUpdatesSubscribed) return;
|
|
179
261
|
emit({ type: "blockUpdate", block: { ...block } });
|
|
180
262
|
}
|
|
181
263
|
function emitRecoveredSymbols(sbn, recoveredBlock, block) {
|
|
@@ -189,16 +271,29 @@ function emitRecoveredSymbols(sbn, recoveredBlock, block) {
|
|
|
189
271
|
});
|
|
190
272
|
return;
|
|
191
273
|
}
|
|
192
|
-
|
|
274
|
+
const ssbLength = block.ssbLength ?? config.k;
|
|
275
|
+
if (recoveredBlock.byteLength !== symbolSize * ssbLength) {
|
|
193
276
|
emit({
|
|
194
277
|
type: "error",
|
|
195
278
|
code: "recovered-block-too-short",
|
|
196
|
-
message: `[FecWorker] recovered block
|
|
279
|
+
message: `[FecWorker] recovered block has the wrong length: got ${recoveredBlock.byteLength}B, expected ${symbolSize * ssbLength}B (SSB_length=${ssbLength})`
|
|
197
280
|
});
|
|
198
281
|
return;
|
|
199
282
|
}
|
|
200
|
-
|
|
283
|
+
const iso = config.blockProfile === "iso-ssbg1-v1";
|
|
284
|
+
for (let i = 0; i < ssbLength; i++) {
|
|
201
285
|
const symbol = recoveredBlock.subarray(i * symbolSize, (i + 1) * symbolSize);
|
|
286
|
+
if (iso) {
|
|
287
|
+
const invalid = invalidSsbg1Symbol(symbol, symbolSize);
|
|
288
|
+
if (invalid !== null) {
|
|
289
|
+
emit({
|
|
290
|
+
type: "error",
|
|
291
|
+
code: "recovered-ssbg1-symbol-invalid",
|
|
292
|
+
message: `[FecWorker] Dropping recovered ssbg_mode1 symbol (ssStart=${sbn}, esi=${i}, SSB_length=${ssbLength}): ${invalid}`
|
|
293
|
+
});
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
202
297
|
const recoveredAltaVerified = null;
|
|
203
298
|
if (altaEnabled) altaPending++;
|
|
204
299
|
const buf = toOwnedArrayBuffer(symbol);
|
|
@@ -206,11 +301,23 @@ function emitRecoveredSymbols(sbn, recoveredBlock, block) {
|
|
|
206
301
|
altaVerified: recoveredAltaVerified,
|
|
207
302
|
recovered: true,
|
|
208
303
|
sbn,
|
|
209
|
-
esi: i
|
|
304
|
+
esi: i,
|
|
305
|
+
ssbLength
|
|
210
306
|
};
|
|
211
307
|
emit({ type: "frame", data: buf, meta }, [buf]);
|
|
212
308
|
}
|
|
213
309
|
}
|
|
310
|
+
function invalidSsbg1Symbol(symbol, symbolSize) {
|
|
311
|
+
const length = symbol[0] << 8 | symbol[1];
|
|
312
|
+
const maxLength = symbolSize - SSBG1_LENGTH_FIELD_BYTES;
|
|
313
|
+
if (length > maxLength) {
|
|
314
|
+
return `length field ${length} exceeds T-2 = ${maxLength}`;
|
|
315
|
+
}
|
|
316
|
+
for (let j = SSBG1_LENGTH_FIELD_BYTES + length; j < symbolSize; j++) {
|
|
317
|
+
if (symbol[j] !== 0) return `non-zero padding at byte ${j} after a ${length}-byte packet`;
|
|
318
|
+
}
|
|
319
|
+
return null;
|
|
320
|
+
}
|
|
214
321
|
function startSnapshotTimer() {
|
|
215
322
|
if (snapshotTimer !== null) clearInterval(snapshotTimer);
|
|
216
323
|
snapshotTimer = setInterval(() => emitSnapshot(), 200);
|
|
@@ -224,6 +331,175 @@ function resolveWasmBindings() {
|
|
|
224
331
|
}
|
|
225
332
|
return bindings;
|
|
226
333
|
}
|
|
334
|
+
function verifyDecoderProfile(decoder, expected) {
|
|
335
|
+
let actual;
|
|
336
|
+
try {
|
|
337
|
+
actual = decoder.get_block_profile();
|
|
338
|
+
} catch (err) {
|
|
339
|
+
decoder.free();
|
|
340
|
+
throw new Error(
|
|
341
|
+
`[FecWorker] configure: decoder cannot report its block profile (${err instanceof Error ? err.message : String(err)})`
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
if (actual !== expected) {
|
|
345
|
+
decoder.free();
|
|
346
|
+
throw new Error(
|
|
347
|
+
`[FecWorker] configure: decoder runs block profile ${JSON.stringify(actual)}, not the configured "${expected}"`
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
function acceptId(ids, key, id) {
|
|
352
|
+
let set = ids.get(key);
|
|
353
|
+
if (!set) {
|
|
354
|
+
set = /* @__PURE__ */ new Set();
|
|
355
|
+
ids.set(key, set);
|
|
356
|
+
}
|
|
357
|
+
set.add(id);
|
|
358
|
+
}
|
|
359
|
+
function recordRecovered(key, block, result) {
|
|
360
|
+
const recovered = new Uint8Array(toOwnedArrayBuffer(result));
|
|
361
|
+
const recoveryMs = performance.now() - block.firstSymbolTs;
|
|
362
|
+
recoveryTimes.push(recoveryMs);
|
|
363
|
+
if (recoveryTimes.length > 1e3) recoveryTimes.shift();
|
|
364
|
+
if (block.state === "collecting") {
|
|
365
|
+
markBlockTerminal(block, "recovered");
|
|
366
|
+
blocksRecovered++;
|
|
367
|
+
emitBlockUpdate(block);
|
|
368
|
+
}
|
|
369
|
+
emitRecoveredSymbols(key, recovered, block);
|
|
370
|
+
}
|
|
371
|
+
function settleSourceArrival(key, block, result) {
|
|
372
|
+
const authoredLength = block.ssbLength ?? block.k;
|
|
373
|
+
if (block.sourceReceived >= authoredLength && block.state === "collecting") {
|
|
374
|
+
markBlockTerminal(block, "complete");
|
|
375
|
+
blocksComplete++;
|
|
376
|
+
emitBlockUpdate(block);
|
|
377
|
+
}
|
|
378
|
+
if (result && result.byteLength > 0 && block.sourceReceived < authoredLength) {
|
|
379
|
+
recordRecovered(key, block, result);
|
|
380
|
+
}
|
|
381
|
+
synchronizeDecoderHorizon();
|
|
382
|
+
}
|
|
383
|
+
function settleRepairArrival(key, block, ssbLength, result) {
|
|
384
|
+
if (block.sourceReceived >= ssbLength && block.state === "collecting") {
|
|
385
|
+
markBlockTerminal(block, "complete");
|
|
386
|
+
blocksComplete++;
|
|
387
|
+
emitBlockUpdate(block);
|
|
388
|
+
} else if (result && result.byteLength > 0) {
|
|
389
|
+
recordRecovered(key, block, result);
|
|
390
|
+
}
|
|
391
|
+
synchronizeDecoderHorizon();
|
|
392
|
+
}
|
|
393
|
+
function feedIsoSource(cmd, view, decoder, cfg) {
|
|
394
|
+
const maxPacketLength = cfg.symbolSize - SSBG1_LENGTH_FIELD_BYTES;
|
|
395
|
+
if (view.byteLength > maxPacketLength) {
|
|
396
|
+
throw new Error(
|
|
397
|
+
`[FecWorker] feedSource: iso-ssbg1-v1 protected packet of ${view.byteLength}B exceeds T-2 = ${maxPacketLength}B`
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
const block = findIsoBlock(cmd.ssId);
|
|
401
|
+
if (!block) {
|
|
402
|
+
if (isRetiredSbn(cmd.ssId)) {
|
|
403
|
+
sourceDroppedRetired++;
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
if (unplacedSources.has(cmd.ssId)) {
|
|
407
|
+
sourceDroppedDuplicate++;
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
const result2 = decoder.add_source(cmd.ssId, view, cmd.ts);
|
|
411
|
+
advanceMediaHorizon(cmd.ssId);
|
|
412
|
+
unplacedSources.set(cmd.ssId, performance.now());
|
|
413
|
+
sourceSymbols++;
|
|
414
|
+
if (altaEnabled) altaPending++;
|
|
415
|
+
if (result2 && result2.byteLength > 0) {
|
|
416
|
+
emit({
|
|
417
|
+
type: "error",
|
|
418
|
+
code: "recovered-block-unplaced",
|
|
419
|
+
message: `[FecWorker] Dropping a ${result2.byteLength}B recovered block returned for SS_ID ${cmd.ssId}, which no known block contains`
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
synchronizeDecoderHorizon();
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
const key = block.sbn;
|
|
426
|
+
if (isRetiredSbn(key)) {
|
|
427
|
+
sourceDroppedRetired++;
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
if (isTerminalBlock(block)) {
|
|
431
|
+
sourceDroppedTerminal++;
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
const esi = serialDistance(key, cmd.ssId);
|
|
435
|
+
if (acceptedSourceEsis.get(key)?.has(esi)) {
|
|
436
|
+
sourceDroppedDuplicate++;
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
const result = decoder.add_source(cmd.ssId, view, cmd.ts);
|
|
440
|
+
advanceMediaHorizon(cmd.ssId);
|
|
441
|
+
acceptId(acceptedSourceEsis, key, esi);
|
|
442
|
+
block.sourceReceived++;
|
|
443
|
+
block.lastSymbolTs = performance.now();
|
|
444
|
+
sourceSymbols++;
|
|
445
|
+
if (altaEnabled) altaPending++;
|
|
446
|
+
settleSourceArrival(key, block, result);
|
|
447
|
+
}
|
|
448
|
+
function findOverlappingIsoBlock(ssStart, ssbLength) {
|
|
449
|
+
for (const block of blocks.values()) {
|
|
450
|
+
if (isoBlocksOverlap(block.sbn, isoKnownLength(block), ssStart, ssbLength)) return block;
|
|
451
|
+
}
|
|
452
|
+
return void 0;
|
|
453
|
+
}
|
|
454
|
+
function placeIsoBlock(ssStart, ssbLength) {
|
|
455
|
+
const block = getOrCreateBlock(ssStart, ssbLength);
|
|
456
|
+
for (let esi = 0; esi < ssbLength; esi++) {
|
|
457
|
+
const ssId = (ssStart + esi) % SS_ID_SPAN;
|
|
458
|
+
const arrivalTs = unplacedSources.get(ssId);
|
|
459
|
+
if (arrivalTs === void 0) continue;
|
|
460
|
+
unplacedSources.delete(ssId);
|
|
461
|
+
acceptId(acceptedSourceEsis, ssStart, esi);
|
|
462
|
+
block.sourceReceived++;
|
|
463
|
+
if (arrivalTs < block.firstSymbolTs) block.firstSymbolTs = arrivalTs;
|
|
464
|
+
}
|
|
465
|
+
return block;
|
|
466
|
+
}
|
|
467
|
+
function feedIsoRepair(cmd, view, decoder) {
|
|
468
|
+
const key = cmd.ssStart;
|
|
469
|
+
if (isRetiredSbn(key)) return;
|
|
470
|
+
const existingBlock = blocks.get(key);
|
|
471
|
+
if (existingBlock && isTerminalBlock(existingBlock)) return;
|
|
472
|
+
if (existingBlock) {
|
|
473
|
+
if (existingBlock.ssbLength !== cmd.ssbLength) {
|
|
474
|
+
emit({
|
|
475
|
+
type: "error",
|
|
476
|
+
code: "repair-ssb-length-conflict",
|
|
477
|
+
message: `[FecWorker] Dropping repair symbol whose SSB_length ${cmd.ssbLength} conflicts with ${existingBlock.ssbLength} (ssStart=${key})`
|
|
478
|
+
});
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
} else {
|
|
482
|
+
const overlapped = findOverlappingIsoBlock(key, cmd.ssbLength);
|
|
483
|
+
if (overlapped) {
|
|
484
|
+
emit({
|
|
485
|
+
type: "error",
|
|
486
|
+
code: "repair-block-overlap",
|
|
487
|
+
message: `[FecWorker] Dropping repair symbol whose block (ssStart=${key}, SSB_length=${cmd.ssbLength}) overlaps known block (ssStart=${overlapped.sbn}, SSB_length=${isoKnownLength(overlapped)})`
|
|
488
|
+
});
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
const repairEsi = cmd.ssbLength + cmd.rsId;
|
|
493
|
+
if (acceptedRepairEsis.get(key)?.has(repairEsi)) return;
|
|
494
|
+
const result = decoder.add_repair(key, cmd.ssbLength, cmd.rsId, view, cmd.ts);
|
|
495
|
+
advanceMediaHorizon(key);
|
|
496
|
+
const block = existingBlock ?? placeIsoBlock(key, cmd.ssbLength);
|
|
497
|
+
acceptId(acceptedRepairEsis, key, repairEsi);
|
|
498
|
+
block.repairReceived++;
|
|
499
|
+
block.lastSymbolTs = performance.now();
|
|
500
|
+
repairSymbols++;
|
|
501
|
+
settleRepairArrival(key, block, cmd.ssbLength, result);
|
|
502
|
+
}
|
|
227
503
|
async function handleConfigure(cmd) {
|
|
228
504
|
if (!cmd.wasmModule) {
|
|
229
505
|
throw new Error("[FecWorker] configure: wasmModule required");
|
|
@@ -231,6 +507,17 @@ async function handleConfigure(cmd) {
|
|
|
231
507
|
if (!cmd.config) {
|
|
232
508
|
throw new Error("[FecWorker] configure: config required");
|
|
233
509
|
}
|
|
510
|
+
const blockProfile = cmd.config.blockProfile;
|
|
511
|
+
if (blockProfile === void 0) {
|
|
512
|
+
throw new Error(
|
|
513
|
+
'[FecWorker] configure: blockProfile is required: name the FEC block profile ("k-padded-v1" or "iso-ssbg1-v1")'
|
|
514
|
+
);
|
|
515
|
+
}
|
|
516
|
+
if (blockProfile !== "k-padded-v1" && blockProfile !== "iso-ssbg1-v1") {
|
|
517
|
+
throw new Error(
|
|
518
|
+
`[FecWorker] configure: blockProfile must be "k-padded-v1" or "iso-ssbg1-v1" (got ${JSON.stringify(blockProfile)})`
|
|
519
|
+
);
|
|
520
|
+
}
|
|
234
521
|
if (!Number.isFinite(cmd.config.interleaveDepth) || !Number.isInteger(cmd.config.interleaveDepth) || cmd.config.interleaveDepth <= 0 || cmd.config.interleaveDepth > MAX_INTERLEAVE_DEPTH) {
|
|
235
522
|
throw new Error(
|
|
236
523
|
`[FecWorker] configure: interleaveDepth must be an integer in 1..${MAX_INTERLEAVE_DEPTH} (got ${cmd.config.interleaveDepth})`
|
|
@@ -261,10 +548,9 @@ async function handleConfigure(cmd) {
|
|
|
261
548
|
wasmDecoder?.free();
|
|
262
549
|
wasmDecoder = null;
|
|
263
550
|
clearBlockTracking();
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
);
|
|
551
|
+
const decoder = new wasm.MmtFecDecoder(cmd.config.interleaveDepth, blockProfile);
|
|
552
|
+
verifyDecoderProfile(decoder, blockProfile);
|
|
553
|
+
wasmDecoder = decoder;
|
|
268
554
|
config = cmd.config;
|
|
269
555
|
altaEnabled = !!cmd.altaPublicKey;
|
|
270
556
|
pendingVerifyCap = Math.ceil(config.interleaveDepth * 1.5);
|
|
@@ -289,6 +575,7 @@ async function handleConfigure(cmd) {
|
|
|
289
575
|
}
|
|
290
576
|
}
|
|
291
577
|
wasmDecoder.configure_params(config.k, config.symbolSize);
|
|
578
|
+
emit({ type: "capabilities", commands: ["feedSymbols", "subscribeBlockUpdates"] });
|
|
292
579
|
startSnapshotTimer();
|
|
293
580
|
emitSnapshot();
|
|
294
581
|
}
|
|
@@ -303,12 +590,25 @@ function handleFeedSource(cmd) {
|
|
|
303
590
|
throw new Error("[FecWorker] feedSource: invalid source symbol");
|
|
304
591
|
}
|
|
305
592
|
const view = new Uint8Array(cmd.data);
|
|
593
|
+
if (config.blockProfile === "iso-ssbg1-v1") {
|
|
594
|
+
feedIsoSource(cmd, view, wasmDecoder, config);
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
306
597
|
const sbn = config.k > 0 ? Math.floor(cmd.ssId / config.k) : cmd.ssId;
|
|
307
598
|
const esi = cmd.ssId % config.k;
|
|
308
|
-
if (sbn
|
|
599
|
+
if (isRetiredSbn(sbn)) {
|
|
600
|
+
sourceDroppedRetired++;
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
309
603
|
const existingBlock = blocks.get(sbn);
|
|
310
|
-
if (existingBlock && isTerminalBlock(existingBlock))
|
|
311
|
-
|
|
604
|
+
if (existingBlock && isTerminalBlock(existingBlock)) {
|
|
605
|
+
sourceDroppedTerminal++;
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
if (acceptedSourceEsis.get(sbn)?.has(esi)) {
|
|
609
|
+
sourceDroppedDuplicate++;
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
312
612
|
const result = wasmDecoder.add_source(cmd.ssId, view, cmd.ts);
|
|
313
613
|
advanceMediaHorizon(sbn);
|
|
314
614
|
let sourceEsis = acceptedSourceEsis.get(sbn);
|
|
@@ -322,24 +622,7 @@ function handleFeedSource(cmd) {
|
|
|
322
622
|
block.lastSymbolTs = performance.now();
|
|
323
623
|
sourceSymbols++;
|
|
324
624
|
if (altaEnabled) altaPending++;
|
|
325
|
-
|
|
326
|
-
markBlockTerminal(block, "complete");
|
|
327
|
-
blocksComplete++;
|
|
328
|
-
emitBlockUpdate(block);
|
|
329
|
-
}
|
|
330
|
-
if (result && result.byteLength > 0 && block.sourceReceived < block.k) {
|
|
331
|
-
const recovered = new Uint8Array(toOwnedArrayBuffer(result));
|
|
332
|
-
const recoveryMs = performance.now() - block.firstSymbolTs;
|
|
333
|
-
recoveryTimes.push(recoveryMs);
|
|
334
|
-
if (recoveryTimes.length > 1e3) recoveryTimes.shift();
|
|
335
|
-
if (block.state === "collecting") {
|
|
336
|
-
markBlockTerminal(block, "recovered");
|
|
337
|
-
blocksRecovered++;
|
|
338
|
-
emitBlockUpdate(block);
|
|
339
|
-
}
|
|
340
|
-
emitRecoveredSymbols(sbn, recovered, block);
|
|
341
|
-
}
|
|
342
|
-
synchronizeDecoderHorizon();
|
|
625
|
+
settleSourceArrival(sbn, block, result);
|
|
343
626
|
}
|
|
344
627
|
function handleFeedRepair(cmd) {
|
|
345
628
|
if (!wasmDecoder) {
|
|
@@ -348,7 +631,8 @@ function handleFeedRepair(cmd) {
|
|
|
348
631
|
if (!config) {
|
|
349
632
|
throw new Error("[FecWorker] feedRepair: config missing");
|
|
350
633
|
}
|
|
351
|
-
|
|
634
|
+
const iso = config.blockProfile === "iso-ssbg1-v1";
|
|
635
|
+
if (!isUint32(cmd.ssStart) || !isUint32(cmd.ssbLength) || cmd.ssbLength <= 0 || cmd.ssbLength > config.k || !iso && cmd.ssStart % config.k !== 0 || !isUint32(cmd.rsId) || (iso ? cmd.ssbLength : config.k) + cmd.rsId > UINT24_MAX || !(cmd.data instanceof ArrayBuffer)) {
|
|
352
636
|
throw new Error("[FecWorker] feedRepair: invalid repair symbol");
|
|
353
637
|
}
|
|
354
638
|
const view = new Uint8Array(cmd.data);
|
|
@@ -363,12 +647,24 @@ function handleFeedRepair(cmd) {
|
|
|
363
647
|
}
|
|
364
648
|
return;
|
|
365
649
|
}
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
650
|
+
if (iso) {
|
|
651
|
+
feedIsoRepair(cmd, view, wasmDecoder);
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
const sbn = Math.floor(cmd.ssStart / config.k);
|
|
655
|
+
const repairEsi = config.k + cmd.rsId;
|
|
656
|
+
if (isRetiredSbn(sbn)) return;
|
|
369
657
|
const existingBlock = blocks.get(sbn);
|
|
370
658
|
if (existingBlock && isTerminalBlock(existingBlock)) return;
|
|
371
659
|
if (acceptedRepairEsis.get(sbn)?.has(repairEsi)) return;
|
|
660
|
+
if (existingBlock?.ssbLength !== void 0 && existingBlock.ssbLength !== cmd.ssbLength) {
|
|
661
|
+
emit({
|
|
662
|
+
type: "error",
|
|
663
|
+
code: "repair-ssb-length-conflict",
|
|
664
|
+
message: `[FecWorker] Dropping repair symbol whose SSB_length ${cmd.ssbLength} conflicts with ${existingBlock.ssbLength} (sbn=${sbn})`
|
|
665
|
+
});
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
372
668
|
const result = wasmDecoder.add_repair(
|
|
373
669
|
cmd.ssStart,
|
|
374
670
|
cmd.ssbLength,
|
|
@@ -384,22 +680,11 @@ function handleFeedRepair(cmd) {
|
|
|
384
680
|
}
|
|
385
681
|
repairEsis.add(repairEsi);
|
|
386
682
|
const block = getOrCreateBlock(sbn);
|
|
683
|
+
block.ssbLength = cmd.ssbLength;
|
|
387
684
|
block.repairReceived++;
|
|
388
685
|
block.lastSymbolTs = performance.now();
|
|
389
686
|
repairSymbols++;
|
|
390
|
-
|
|
391
|
-
const recovered = new Uint8Array(toOwnedArrayBuffer(result));
|
|
392
|
-
const recoveryMs = performance.now() - block.firstSymbolTs;
|
|
393
|
-
recoveryTimes.push(recoveryMs);
|
|
394
|
-
if (recoveryTimes.length > 1e3) recoveryTimes.shift();
|
|
395
|
-
if (block.state === "collecting") {
|
|
396
|
-
markBlockTerminal(block, "recovered");
|
|
397
|
-
blocksRecovered++;
|
|
398
|
-
emitBlockUpdate(block);
|
|
399
|
-
}
|
|
400
|
-
emitRecoveredSymbols(sbn, recovered, block);
|
|
401
|
-
}
|
|
402
|
-
synchronizeDecoderHorizon();
|
|
687
|
+
settleRepairArrival(sbn, block, cmd.ssbLength, result);
|
|
403
688
|
}
|
|
404
689
|
function handleRelayBlockSig(cmd) {
|
|
405
690
|
const block = blocks.get(cmd.sbn);
|
|
@@ -413,20 +698,23 @@ function handleCleanup(cmd) {
|
|
|
413
698
|
throw new Error("[FecWorker] cleanup: invalid SBNs");
|
|
414
699
|
}
|
|
415
700
|
const sbns = [...new Set(cmd.sbns)].sort((a, b) => a - b);
|
|
416
|
-
const pendingSbns = sbns.filter((sbn) => sbn
|
|
701
|
+
const pendingSbns = sbns.filter((sbn) => !isRetiredSbn(sbn));
|
|
417
702
|
try {
|
|
418
703
|
if (!wasmDecoder) {
|
|
419
704
|
throw new Error("[FecWorker] cleanup: not configured");
|
|
420
705
|
}
|
|
421
|
-
|
|
706
|
+
if (pendingSbns.length > 0) {
|
|
707
|
+
wasmDecoder.cleanup_blocks_detailed(Uint32Array.from(pendingSbns));
|
|
708
|
+
}
|
|
422
709
|
for (const sbn of pendingSbns) {
|
|
423
710
|
let currentBlock = blocks.get(sbn);
|
|
424
711
|
if (currentBlock && isTerminalBlock(currentBlock)) {
|
|
425
712
|
retireAcceptedIds(sbn);
|
|
426
713
|
continue;
|
|
427
714
|
}
|
|
428
|
-
currentBlock
|
|
429
|
-
|
|
715
|
+
currentBlock ??= getOrCreateBlock(sbn);
|
|
716
|
+
const authoredLength = isIsoProfile() ? currentBlock.ssbLength : currentBlock.k;
|
|
717
|
+
if (authoredLength !== void 0 && currentBlock.sourceReceived >= authoredLength) {
|
|
430
718
|
markBlockTerminal(currentBlock, "complete");
|
|
431
719
|
blocksComplete++;
|
|
432
720
|
} else {
|
|
@@ -435,35 +723,10 @@ function handleCleanup(cmd) {
|
|
|
435
723
|
}
|
|
436
724
|
emitBlockUpdate(currentBlock);
|
|
437
725
|
}
|
|
438
|
-
const greenFills = decodeCleanupRecords(cleanupRecords);
|
|
439
|
-
for (const { sbn, data } of greenFills) {
|
|
440
|
-
greenFillFrames++;
|
|
441
|
-
emit({ type: "greenFill", sbn, data }, [data]);
|
|
442
|
-
}
|
|
443
726
|
} finally {
|
|
444
727
|
emit({ type: "cleanupComplete", sbns });
|
|
445
728
|
}
|
|
446
729
|
}
|
|
447
|
-
function decodeCleanupRecords(framed) {
|
|
448
|
-
const records = [];
|
|
449
|
-
const view = new DataView(framed.buffer, framed.byteOffset, framed.byteLength);
|
|
450
|
-
let offset = 0;
|
|
451
|
-
while (offset < framed.byteLength) {
|
|
452
|
-
if (framed.byteLength - offset < 8) {
|
|
453
|
-
throw new Error("[FecWorker] cleanup_blocks_detailed: truncated record header");
|
|
454
|
-
}
|
|
455
|
-
const sbn = view.getUint32(offset, false);
|
|
456
|
-
const length = view.getUint32(offset + 4, false);
|
|
457
|
-
offset += 8;
|
|
458
|
-
if (length > framed.byteLength - offset) {
|
|
459
|
-
throw new Error("[FecWorker] cleanup_blocks_detailed: truncated record payload");
|
|
460
|
-
}
|
|
461
|
-
const data = toOwnedArrayBuffer(framed.subarray(offset, offset + length));
|
|
462
|
-
records.push({ sbn, data });
|
|
463
|
-
offset += length;
|
|
464
|
-
}
|
|
465
|
-
return records;
|
|
466
|
-
}
|
|
467
730
|
function handleDispose() {
|
|
468
731
|
if (snapshotTimer !== null) {
|
|
469
732
|
clearInterval(snapshotTimer);
|
|
@@ -504,7 +767,6 @@ function handleReset() {
|
|
|
504
767
|
blocksComplete = 0;
|
|
505
768
|
blocksRecovered = 0;
|
|
506
769
|
blocksFailed = 0;
|
|
507
|
-
greenFillFrames = 0;
|
|
508
770
|
altaVerified = 0;
|
|
509
771
|
altaFailed = 0;
|
|
510
772
|
altaFailedDetails.length = 0;
|
|
@@ -515,9 +777,35 @@ function handleReset() {
|
|
|
515
777
|
relayBlocksVerified = 0;
|
|
516
778
|
relayBlocksFailed = 0;
|
|
517
779
|
repairSymbolSizeMismatch = 0;
|
|
780
|
+
sourceDroppedRetired = 0;
|
|
781
|
+
sourceDroppedTerminal = 0;
|
|
782
|
+
sourceDroppedDuplicate = 0;
|
|
783
|
+
sourceUnplacedRetired = 0;
|
|
518
784
|
emit({ type: "resetComplete" });
|
|
519
785
|
if (config) emitSnapshot();
|
|
520
786
|
}
|
|
787
|
+
function handleFeedSymbols(cmd) {
|
|
788
|
+
if (!Array.isArray(cmd.symbols)) {
|
|
789
|
+
throw new Error("[FecWorker] feedSymbols: symbols must be an array");
|
|
790
|
+
}
|
|
791
|
+
for (const symbol of cmd.symbols) {
|
|
792
|
+
try {
|
|
793
|
+
if (symbol?.type === "feedSource") handleFeedSource(symbol);
|
|
794
|
+
else if (symbol?.type === "feedRepair") handleFeedRepair(symbol);
|
|
795
|
+
else {
|
|
796
|
+
throw new Error(
|
|
797
|
+
`[FecWorker] feedSymbols: unknown symbol type ${String(symbol?.type)}`
|
|
798
|
+
);
|
|
799
|
+
}
|
|
800
|
+
} catch (err) {
|
|
801
|
+
emit({
|
|
802
|
+
type: "error",
|
|
803
|
+
code: "command-failed",
|
|
804
|
+
message: err instanceof Error ? err.message : `[FecWorker] ${String(err)}`
|
|
805
|
+
});
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
}
|
|
521
809
|
async function dispatchCommand(cmd) {
|
|
522
810
|
try {
|
|
523
811
|
switch (cmd.type) {
|
|
@@ -530,6 +818,9 @@ async function dispatchCommand(cmd) {
|
|
|
530
818
|
case "feedRepair":
|
|
531
819
|
handleFeedRepair(cmd);
|
|
532
820
|
break;
|
|
821
|
+
case "feedSymbols":
|
|
822
|
+
handleFeedSymbols(cmd);
|
|
823
|
+
break;
|
|
533
824
|
case "relayBlockSig":
|
|
534
825
|
handleRelayBlockSig(cmd);
|
|
535
826
|
break;
|
|
@@ -539,6 +830,9 @@ async function dispatchCommand(cmd) {
|
|
|
539
830
|
case "reset":
|
|
540
831
|
handleReset();
|
|
541
832
|
break;
|
|
833
|
+
case "subscribeBlockUpdates":
|
|
834
|
+
blockUpdatesSubscribed = true;
|
|
835
|
+
break;
|
|
542
836
|
case "dispose":
|
|
543
837
|
handleDispose();
|
|
544
838
|
break;
|