@blockcast/fec-worker 0.1.0 → 0.3.0-main.2515e5ca4858
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 +19 -9
- package/dist/block-order.d.ts +22 -0
- package/dist/block-order.d.ts.map +1 -0
- package/dist/block-order.js +48 -0
- package/dist/block-order.js.map +1 -0
- package/dist/fec-worker-client.d.ts +19 -7
- package/dist/fec-worker-client.d.ts.map +1 -1
- package/dist/fec-worker-client.js +105 -30
- package/dist/fec-worker-client.js.map +1 -1
- package/dist/fec-worker-types.d.ts +146 -25
- package/dist/fec-worker-types.d.ts.map +1 -1
- package/dist/fec-worker.js +326 -127
- 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 +59 -0
- package/src/fec-worker-client.ts +114 -32
- package/src/fec-worker-types.ts +145 -21
- package/src/fec-worker.ts +518 -180
- 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,44 @@ 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 serialDistance(from, to) {
|
|
14
|
+
return (to - from + SPAN) % SPAN;
|
|
15
|
+
}
|
|
16
|
+
function serialPrecedes(a, b) {
|
|
17
|
+
const distance = serialDistance(a, b);
|
|
18
|
+
return distance !== 0 && distance < HALF;
|
|
19
|
+
}
|
|
20
|
+
function isoBlockContains(ssStart, ssbLength, ssId) {
|
|
21
|
+
return serialDistance(ssStart, ssId) < ssbLength;
|
|
22
|
+
}
|
|
23
|
+
function isoBlocksOverlap(aStart, aLength, bStart, bLength) {
|
|
24
|
+
return isoBlockContains(aStart, aLength, bStart) || isoBlockContains(bStart, bLength, aStart);
|
|
25
|
+
}
|
|
26
|
+
function isoBlocksBehind(a, b, kMax) {
|
|
27
|
+
if (!Number.isSafeInteger(kMax) || kMax <= 0) {
|
|
28
|
+
throw new Error(`iso-ssbg1-v1 block horizon needs a positive maximum K (got ${kMax})`);
|
|
29
|
+
}
|
|
30
|
+
return serialPrecedes(a, b) ? Math.floor(serialDistance(a, b) / kMax) : 0;
|
|
31
|
+
}
|
|
32
|
+
|
|
10
33
|
// src/fec-worker.ts
|
|
11
34
|
var wasmDecoder = null;
|
|
12
35
|
var config = null;
|
|
13
36
|
var altaEnabled = false;
|
|
14
37
|
var altaVerifier = null;
|
|
15
38
|
var snapshotTimer = null;
|
|
39
|
+
var blockUpdatesSubscribed = false;
|
|
16
40
|
var blocks = /* @__PURE__ */ new Map();
|
|
17
41
|
var acceptedSourceEsis = /* @__PURE__ */ new Map();
|
|
18
42
|
var acceptedRepairEsis = /* @__PURE__ */ new Map();
|
|
43
|
+
var unplacedSources = /* @__PURE__ */ new Map();
|
|
19
44
|
var UINT32_MAX = 4294967295;
|
|
20
45
|
var UINT24_MAX = 16777215;
|
|
46
|
+
var SS_ID_SPAN = 4294967296;
|
|
47
|
+
var SSBG1_LENGTH_FIELD_BYTES = 2;
|
|
21
48
|
var MAX_INTERLEAVE_DEPTH = 255;
|
|
22
49
|
var MAX_SYMBOL_SIZE = 65528;
|
|
23
50
|
var MAX_RAPTORQ_SOURCE_SYMBOLS_PER_BLOCK = 56403;
|
|
@@ -26,7 +53,6 @@ var repairSymbols = 0;
|
|
|
26
53
|
var blocksComplete = 0;
|
|
27
54
|
var blocksRecovered = 0;
|
|
28
55
|
var blocksFailed = 0;
|
|
29
|
-
var greenFillFrames = 0;
|
|
30
56
|
var altaVerified = 0;
|
|
31
57
|
var altaFailed = 0;
|
|
32
58
|
var altaFailedDetails = [];
|
|
@@ -38,8 +64,12 @@ var altaInitFailed = false;
|
|
|
38
64
|
var relayBlocksVerified = 0;
|
|
39
65
|
var relayBlocksFailed = 0;
|
|
40
66
|
var liveSbn = 0;
|
|
41
|
-
var
|
|
67
|
+
var liveSbnKnown = false;
|
|
42
68
|
var repairSymbolSizeMismatch = 0;
|
|
69
|
+
var sourceDroppedRetired = 0;
|
|
70
|
+
var sourceDroppedTerminal = 0;
|
|
71
|
+
var sourceDroppedDuplicate = 0;
|
|
72
|
+
var sourceUnplacedRetired = 0;
|
|
43
73
|
var recoveryTimes = [];
|
|
44
74
|
var pendingVerify = /* @__PURE__ */ new Map();
|
|
45
75
|
var pendingVerifyCap = 0;
|
|
@@ -61,23 +91,40 @@ function clearBlockTracking() {
|
|
|
61
91
|
blocks.clear();
|
|
62
92
|
acceptedSourceEsis.clear();
|
|
63
93
|
acceptedRepairEsis.clear();
|
|
94
|
+
unplacedSources.clear();
|
|
64
95
|
pendingVerify.clear();
|
|
65
96
|
recoveryTimes.length = 0;
|
|
66
97
|
liveSbn = 0;
|
|
67
|
-
|
|
98
|
+
liveSbnKnown = false;
|
|
99
|
+
}
|
|
100
|
+
function isRetiredSbn(sbn) {
|
|
101
|
+
if (config === null || !liveSbnKnown) return false;
|
|
102
|
+
return isoBlocksBehind(sbn, liveSbn, config.k) > config.interleaveDepth;
|
|
103
|
+
}
|
|
104
|
+
function isoKnownLength(block) {
|
|
105
|
+
return block.ssbLength ?? 1;
|
|
106
|
+
}
|
|
107
|
+
function findIsoBlock(ssId) {
|
|
108
|
+
for (const block of blocks.values()) {
|
|
109
|
+
if (isoBlockContains(block.sbn, isoKnownLength(block), ssId)) return block;
|
|
110
|
+
}
|
|
111
|
+
return void 0;
|
|
112
|
+
}
|
|
113
|
+
function isRetiredBlock(key, block) {
|
|
114
|
+
return isRetiredSbn((key + isoKnownLength(block) - 1) % SS_ID_SPAN);
|
|
68
115
|
}
|
|
69
116
|
function trimBlockRetention() {
|
|
70
117
|
for (const [sbn, block] of blocks) {
|
|
71
|
-
if (!isTerminalBlock(block) || sbn
|
|
118
|
+
if (!isTerminalBlock(block) || !isRetiredBlock(sbn, block)) continue;
|
|
72
119
|
blocks.delete(sbn);
|
|
73
120
|
retireAcceptedIds(sbn);
|
|
74
121
|
}
|
|
75
122
|
}
|
|
76
123
|
function advanceMediaHorizon(sbn) {
|
|
77
|
-
if (!config
|
|
124
|
+
if (!config) return;
|
|
125
|
+
if (liveSbnKnown && !serialPrecedes(liveSbn, sbn)) return;
|
|
78
126
|
liveSbn = sbn;
|
|
79
|
-
|
|
80
|
-
retiredSbnFrontier = Math.max(retiredSbnFrontier, firstRetainedSbn - 1);
|
|
127
|
+
liveSbnKnown = true;
|
|
81
128
|
trimBlockRetention();
|
|
82
129
|
}
|
|
83
130
|
function markBlockTerminal(block, state) {
|
|
@@ -87,8 +134,8 @@ function markBlockTerminal(block, state) {
|
|
|
87
134
|
trimBlockRetention();
|
|
88
135
|
}
|
|
89
136
|
function synchronizeDecoderHorizon() {
|
|
90
|
-
if (!wasmDecoder) return;
|
|
91
|
-
const expired = wasmDecoder.
|
|
137
|
+
if (!wasmDecoder || !liveSbnKnown) return;
|
|
138
|
+
const expired = wasmDecoder.cleanup_by_ss_start(liveSbn);
|
|
92
139
|
for (const sbn of expired) {
|
|
93
140
|
const block = blocks.get(sbn);
|
|
94
141
|
if (!block || isTerminalBlock(block)) {
|
|
@@ -99,9 +146,18 @@ function synchronizeDecoderHorizon() {
|
|
|
99
146
|
blocksFailed++;
|
|
100
147
|
emitBlockUpdate(block);
|
|
101
148
|
}
|
|
149
|
+
retireUnplacedSources();
|
|
102
150
|
trimBlockRetention();
|
|
103
151
|
}
|
|
104
|
-
function
|
|
152
|
+
function retireUnplacedSources() {
|
|
153
|
+
for (const ssId of unplacedSources.keys()) {
|
|
154
|
+
const unreachable = ssId !== liveSbn && !serialPrecedes(ssId, liveSbn);
|
|
155
|
+
if (!unreachable && !isRetiredSbn(ssId)) break;
|
|
156
|
+
unplacedSources.delete(ssId);
|
|
157
|
+
sourceUnplacedRetired++;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
function getOrCreateBlock(sbn, isoSsbLength) {
|
|
105
161
|
let block = blocks.get(sbn);
|
|
106
162
|
if (!block) {
|
|
107
163
|
if (!config) throw new Error("[FecWorker] not configured");
|
|
@@ -111,7 +167,7 @@ function getOrCreateBlock(sbn) {
|
|
|
111
167
|
state: "collecting",
|
|
112
168
|
sourceReceived: 0,
|
|
113
169
|
repairReceived: 0,
|
|
114
|
-
k: config.k,
|
|
170
|
+
k: isoSsbLength ?? config.k,
|
|
115
171
|
repairCount: config.repairCount,
|
|
116
172
|
firstSymbolTs: now,
|
|
117
173
|
lastSymbolTs: now,
|
|
@@ -119,6 +175,7 @@ function getOrCreateBlock(sbn) {
|
|
|
119
175
|
altaVerified: 0,
|
|
120
176
|
relayBlockSigValid: null
|
|
121
177
|
};
|
|
178
|
+
if (isoSsbLength !== void 0) block.ssbLength = isoSsbLength;
|
|
122
179
|
blocks.set(sbn, block);
|
|
123
180
|
trimBlockRetention();
|
|
124
181
|
}
|
|
@@ -139,7 +196,7 @@ function buildStatsSnapshot() {
|
|
|
139
196
|
const avgRecoveryMs = recoveryTimes.length > 0 ? recoveryTimes.reduce((a, b) => a + b, 0) / recoveryTimes.length : 0;
|
|
140
197
|
const maxRecoveryMs = recoveryTimes.length > 0 ? Math.max(...recoveryTimes) : 0;
|
|
141
198
|
const maxBlocks = Math.max(config.interleaveDepth * 3, 24);
|
|
142
|
-
const blockArray = Array.from(blocks.values()).sort((a, b) =>
|
|
199
|
+
const blockArray = Array.from(blocks.values()).sort((a, b) => serialDistance(a.sbn, liveSbn) - serialDistance(b.sbn, liveSbn)).slice(0, maxBlocks);
|
|
143
200
|
return {
|
|
144
201
|
config,
|
|
145
202
|
sourceSymbols,
|
|
@@ -147,7 +204,6 @@ function buildStatsSnapshot() {
|
|
|
147
204
|
blocksComplete,
|
|
148
205
|
blocksRecovered,
|
|
149
206
|
blocksFailed,
|
|
150
|
-
greenFillFrames,
|
|
151
207
|
recoveryRate: blocksComplete + blocksRecovered > 0 ? Math.round(
|
|
152
208
|
blocksRecovered / (blocksRecovered + blocksFailed) * 100
|
|
153
209
|
) || 0 : 0,
|
|
@@ -168,6 +224,10 @@ function buildStatsSnapshot() {
|
|
|
168
224
|
avgRecoveryMs: Math.round(avgRecoveryMs * 100) / 100,
|
|
169
225
|
maxRecoveryMs: Math.round(maxRecoveryMs * 100) / 100,
|
|
170
226
|
repairSymbolSizeMismatch,
|
|
227
|
+
sourceDroppedRetired,
|
|
228
|
+
sourceDroppedTerminal,
|
|
229
|
+
sourceDroppedDuplicate,
|
|
230
|
+
sourceUnplacedRetired,
|
|
171
231
|
decoder
|
|
172
232
|
};
|
|
173
233
|
}
|
|
@@ -176,6 +236,7 @@ function emitSnapshot() {
|
|
|
176
236
|
emit({ type: "snapshot", stats: buildStatsSnapshot() });
|
|
177
237
|
}
|
|
178
238
|
function emitBlockUpdate(block) {
|
|
239
|
+
if (!blockUpdatesSubscribed) return;
|
|
179
240
|
emit({ type: "blockUpdate", block: { ...block } });
|
|
180
241
|
}
|
|
181
242
|
function emitRecoveredSymbols(sbn, recoveredBlock, block) {
|
|
@@ -189,16 +250,26 @@ function emitRecoveredSymbols(sbn, recoveredBlock, block) {
|
|
|
189
250
|
});
|
|
190
251
|
return;
|
|
191
252
|
}
|
|
192
|
-
|
|
253
|
+
const ssbLength = block.ssbLength ?? config.k;
|
|
254
|
+
if (recoveredBlock.byteLength !== symbolSize * ssbLength) {
|
|
193
255
|
emit({
|
|
194
256
|
type: "error",
|
|
195
257
|
code: "recovered-block-too-short",
|
|
196
|
-
message: `[FecWorker] recovered block
|
|
258
|
+
message: `[FecWorker] recovered block has the wrong length: got ${recoveredBlock.byteLength}B, expected ${symbolSize * ssbLength}B (SSB_length=${ssbLength})`
|
|
197
259
|
});
|
|
198
260
|
return;
|
|
199
261
|
}
|
|
200
|
-
for (let i = 0; i <
|
|
262
|
+
for (let i = 0; i < ssbLength; i++) {
|
|
201
263
|
const symbol = recoveredBlock.subarray(i * symbolSize, (i + 1) * symbolSize);
|
|
264
|
+
const invalid = invalidSsbg1Symbol(symbol, symbolSize);
|
|
265
|
+
if (invalid !== null) {
|
|
266
|
+
emit({
|
|
267
|
+
type: "error",
|
|
268
|
+
code: "recovered-ssbg1-symbol-invalid",
|
|
269
|
+
message: `[FecWorker] Dropping recovered ssbg_mode1 symbol (ssStart=${sbn}, esi=${i}, SSB_length=${ssbLength}): ${invalid}`
|
|
270
|
+
});
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
202
273
|
const recoveredAltaVerified = null;
|
|
203
274
|
if (altaEnabled) altaPending++;
|
|
204
275
|
const buf = toOwnedArrayBuffer(symbol);
|
|
@@ -206,11 +277,23 @@ function emitRecoveredSymbols(sbn, recoveredBlock, block) {
|
|
|
206
277
|
altaVerified: recoveredAltaVerified,
|
|
207
278
|
recovered: true,
|
|
208
279
|
sbn,
|
|
209
|
-
esi: i
|
|
280
|
+
esi: i,
|
|
281
|
+
ssbLength
|
|
210
282
|
};
|
|
211
283
|
emit({ type: "frame", data: buf, meta }, [buf]);
|
|
212
284
|
}
|
|
213
285
|
}
|
|
286
|
+
function invalidSsbg1Symbol(symbol, symbolSize) {
|
|
287
|
+
const length = symbol[0] << 8 | symbol[1];
|
|
288
|
+
const maxLength = symbolSize - SSBG1_LENGTH_FIELD_BYTES;
|
|
289
|
+
if (length > maxLength) {
|
|
290
|
+
return `length field ${length} exceeds T-2 = ${maxLength}`;
|
|
291
|
+
}
|
|
292
|
+
for (let j = SSBG1_LENGTH_FIELD_BYTES + length; j < symbolSize; j++) {
|
|
293
|
+
if (symbol[j] !== 0) return `non-zero padding at byte ${j} after a ${length}-byte packet`;
|
|
294
|
+
}
|
|
295
|
+
return null;
|
|
296
|
+
}
|
|
214
297
|
function startSnapshotTimer() {
|
|
215
298
|
if (snapshotTimer !== null) clearInterval(snapshotTimer);
|
|
216
299
|
snapshotTimer = setInterval(() => emitSnapshot(), 200);
|
|
@@ -224,6 +307,175 @@ function resolveWasmBindings() {
|
|
|
224
307
|
}
|
|
225
308
|
return bindings;
|
|
226
309
|
}
|
|
310
|
+
function verifyDecoderProfile(decoder, expected) {
|
|
311
|
+
let actual;
|
|
312
|
+
try {
|
|
313
|
+
actual = decoder.get_block_profile();
|
|
314
|
+
} catch (err) {
|
|
315
|
+
decoder.free();
|
|
316
|
+
throw new Error(
|
|
317
|
+
`[FecWorker] configure: decoder cannot report its block profile (${err instanceof Error ? err.message : String(err)})`
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
if (actual !== expected) {
|
|
321
|
+
decoder.free();
|
|
322
|
+
throw new Error(
|
|
323
|
+
`[FecWorker] configure: decoder runs block profile ${JSON.stringify(actual)}, not the configured "${expected}"`
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
function acceptId(ids, key, id) {
|
|
328
|
+
let set = ids.get(key);
|
|
329
|
+
if (!set) {
|
|
330
|
+
set = /* @__PURE__ */ new Set();
|
|
331
|
+
ids.set(key, set);
|
|
332
|
+
}
|
|
333
|
+
set.add(id);
|
|
334
|
+
}
|
|
335
|
+
function recordRecovered(key, block, result) {
|
|
336
|
+
const recovered = new Uint8Array(toOwnedArrayBuffer(result));
|
|
337
|
+
const recoveryMs = performance.now() - block.firstSymbolTs;
|
|
338
|
+
recoveryTimes.push(recoveryMs);
|
|
339
|
+
if (recoveryTimes.length > 1e3) recoveryTimes.shift();
|
|
340
|
+
if (block.state === "collecting") {
|
|
341
|
+
markBlockTerminal(block, "recovered");
|
|
342
|
+
blocksRecovered++;
|
|
343
|
+
emitBlockUpdate(block);
|
|
344
|
+
}
|
|
345
|
+
emitRecoveredSymbols(key, recovered, block);
|
|
346
|
+
}
|
|
347
|
+
function settleSourceArrival(key, block, result) {
|
|
348
|
+
const authoredLength = block.ssbLength ?? block.k;
|
|
349
|
+
if (block.sourceReceived >= authoredLength && block.state === "collecting") {
|
|
350
|
+
markBlockTerminal(block, "complete");
|
|
351
|
+
blocksComplete++;
|
|
352
|
+
emitBlockUpdate(block);
|
|
353
|
+
}
|
|
354
|
+
if (result && result.byteLength > 0 && block.sourceReceived < authoredLength) {
|
|
355
|
+
recordRecovered(key, block, result);
|
|
356
|
+
}
|
|
357
|
+
synchronizeDecoderHorizon();
|
|
358
|
+
}
|
|
359
|
+
function settleRepairArrival(key, block, ssbLength, result) {
|
|
360
|
+
if (block.sourceReceived >= ssbLength && block.state === "collecting") {
|
|
361
|
+
markBlockTerminal(block, "complete");
|
|
362
|
+
blocksComplete++;
|
|
363
|
+
emitBlockUpdate(block);
|
|
364
|
+
} else if (result && result.byteLength > 0) {
|
|
365
|
+
recordRecovered(key, block, result);
|
|
366
|
+
}
|
|
367
|
+
synchronizeDecoderHorizon();
|
|
368
|
+
}
|
|
369
|
+
function feedIsoSource(cmd, view, decoder, cfg) {
|
|
370
|
+
const maxPacketLength = cfg.symbolSize - SSBG1_LENGTH_FIELD_BYTES;
|
|
371
|
+
if (view.byteLength > maxPacketLength) {
|
|
372
|
+
throw new Error(
|
|
373
|
+
`[FecWorker] feedSource: iso-ssbg1-v1 protected packet of ${view.byteLength}B exceeds T-2 = ${maxPacketLength}B`
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
const block = findIsoBlock(cmd.ssId);
|
|
377
|
+
if (!block) {
|
|
378
|
+
if (isRetiredSbn(cmd.ssId)) {
|
|
379
|
+
sourceDroppedRetired++;
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
if (unplacedSources.has(cmd.ssId)) {
|
|
383
|
+
sourceDroppedDuplicate++;
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
const result2 = decoder.add_source(cmd.ssId, view, cmd.ts);
|
|
387
|
+
advanceMediaHorizon(cmd.ssId);
|
|
388
|
+
unplacedSources.set(cmd.ssId, performance.now());
|
|
389
|
+
sourceSymbols++;
|
|
390
|
+
if (altaEnabled) altaPending++;
|
|
391
|
+
if (result2 && result2.byteLength > 0) {
|
|
392
|
+
emit({
|
|
393
|
+
type: "error",
|
|
394
|
+
code: "recovered-block-unplaced",
|
|
395
|
+
message: `[FecWorker] Dropping a ${result2.byteLength}B recovered block returned for SS_ID ${cmd.ssId}, which no known block contains`
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
synchronizeDecoderHorizon();
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
const key = block.sbn;
|
|
402
|
+
if (isRetiredSbn(key)) {
|
|
403
|
+
sourceDroppedRetired++;
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
if (isTerminalBlock(block)) {
|
|
407
|
+
sourceDroppedTerminal++;
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
const esi = serialDistance(key, cmd.ssId);
|
|
411
|
+
if (acceptedSourceEsis.get(key)?.has(esi)) {
|
|
412
|
+
sourceDroppedDuplicate++;
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
const result = decoder.add_source(cmd.ssId, view, cmd.ts);
|
|
416
|
+
advanceMediaHorizon(cmd.ssId);
|
|
417
|
+
acceptId(acceptedSourceEsis, key, esi);
|
|
418
|
+
block.sourceReceived++;
|
|
419
|
+
block.lastSymbolTs = performance.now();
|
|
420
|
+
sourceSymbols++;
|
|
421
|
+
if (altaEnabled) altaPending++;
|
|
422
|
+
settleSourceArrival(key, block, result);
|
|
423
|
+
}
|
|
424
|
+
function findOverlappingIsoBlock(ssStart, ssbLength) {
|
|
425
|
+
for (const block of blocks.values()) {
|
|
426
|
+
if (isoBlocksOverlap(block.sbn, isoKnownLength(block), ssStart, ssbLength)) return block;
|
|
427
|
+
}
|
|
428
|
+
return void 0;
|
|
429
|
+
}
|
|
430
|
+
function placeIsoBlock(ssStart, ssbLength) {
|
|
431
|
+
const block = getOrCreateBlock(ssStart, ssbLength);
|
|
432
|
+
for (let esi = 0; esi < ssbLength; esi++) {
|
|
433
|
+
const ssId = (ssStart + esi) % SS_ID_SPAN;
|
|
434
|
+
const arrivalTs = unplacedSources.get(ssId);
|
|
435
|
+
if (arrivalTs === void 0) continue;
|
|
436
|
+
unplacedSources.delete(ssId);
|
|
437
|
+
acceptId(acceptedSourceEsis, ssStart, esi);
|
|
438
|
+
block.sourceReceived++;
|
|
439
|
+
if (arrivalTs < block.firstSymbolTs) block.firstSymbolTs = arrivalTs;
|
|
440
|
+
}
|
|
441
|
+
return block;
|
|
442
|
+
}
|
|
443
|
+
function feedIsoRepair(cmd, view, decoder) {
|
|
444
|
+
const key = cmd.ssStart;
|
|
445
|
+
if (isRetiredSbn(key)) return;
|
|
446
|
+
const existingBlock = blocks.get(key);
|
|
447
|
+
if (existingBlock && isTerminalBlock(existingBlock)) return;
|
|
448
|
+
if (existingBlock) {
|
|
449
|
+
if (existingBlock.ssbLength !== cmd.ssbLength) {
|
|
450
|
+
emit({
|
|
451
|
+
type: "error",
|
|
452
|
+
code: "repair-ssb-length-conflict",
|
|
453
|
+
message: `[FecWorker] Dropping repair symbol whose SSB_length ${cmd.ssbLength} conflicts with ${existingBlock.ssbLength} (ssStart=${key})`
|
|
454
|
+
});
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
} else {
|
|
458
|
+
const overlapped = findOverlappingIsoBlock(key, cmd.ssbLength);
|
|
459
|
+
if (overlapped) {
|
|
460
|
+
emit({
|
|
461
|
+
type: "error",
|
|
462
|
+
code: "repair-block-overlap",
|
|
463
|
+
message: `[FecWorker] Dropping repair symbol whose block (ssStart=${key}, SSB_length=${cmd.ssbLength}) overlaps known block (ssStart=${overlapped.sbn}, SSB_length=${isoKnownLength(overlapped)})`
|
|
464
|
+
});
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
const repairEsi = cmd.ssbLength + cmd.rsId;
|
|
469
|
+
if (acceptedRepairEsis.get(key)?.has(repairEsi)) return;
|
|
470
|
+
const result = decoder.add_repair(key, cmd.ssbLength, cmd.rsId, view, cmd.ts);
|
|
471
|
+
advanceMediaHorizon(key);
|
|
472
|
+
const block = existingBlock ?? placeIsoBlock(key, cmd.ssbLength);
|
|
473
|
+
acceptId(acceptedRepairEsis, key, repairEsi);
|
|
474
|
+
block.repairReceived++;
|
|
475
|
+
block.lastSymbolTs = performance.now();
|
|
476
|
+
repairSymbols++;
|
|
477
|
+
settleRepairArrival(key, block, cmd.ssbLength, result);
|
|
478
|
+
}
|
|
227
479
|
async function handleConfigure(cmd) {
|
|
228
480
|
if (!cmd.wasmModule) {
|
|
229
481
|
throw new Error("[FecWorker] configure: wasmModule required");
|
|
@@ -231,6 +483,17 @@ async function handleConfigure(cmd) {
|
|
|
231
483
|
if (!cmd.config) {
|
|
232
484
|
throw new Error("[FecWorker] configure: config required");
|
|
233
485
|
}
|
|
486
|
+
const blockProfile = cmd.config.blockProfile;
|
|
487
|
+
if (blockProfile === void 0) {
|
|
488
|
+
throw new Error(
|
|
489
|
+
'[FecWorker] configure: blockProfile is required: name the FEC block profile ("iso-ssbg1-v1")'
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
if (blockProfile !== "iso-ssbg1-v1") {
|
|
493
|
+
throw new Error(
|
|
494
|
+
`[FecWorker] configure: blockProfile must be "iso-ssbg1-v1" (got ${JSON.stringify(blockProfile)})`
|
|
495
|
+
);
|
|
496
|
+
}
|
|
234
497
|
if (!Number.isFinite(cmd.config.interleaveDepth) || !Number.isInteger(cmd.config.interleaveDepth) || cmd.config.interleaveDepth <= 0 || cmd.config.interleaveDepth > MAX_INTERLEAVE_DEPTH) {
|
|
235
498
|
throw new Error(
|
|
236
499
|
`[FecWorker] configure: interleaveDepth must be an integer in 1..${MAX_INTERLEAVE_DEPTH} (got ${cmd.config.interleaveDepth})`
|
|
@@ -261,10 +524,9 @@ async function handleConfigure(cmd) {
|
|
|
261
524
|
wasmDecoder?.free();
|
|
262
525
|
wasmDecoder = null;
|
|
263
526
|
clearBlockTracking();
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
);
|
|
527
|
+
const decoder = new wasm.MmtFecDecoder(cmd.config.interleaveDepth, blockProfile);
|
|
528
|
+
verifyDecoderProfile(decoder, blockProfile);
|
|
529
|
+
wasmDecoder = decoder;
|
|
268
530
|
config = cmd.config;
|
|
269
531
|
altaEnabled = !!cmd.altaPublicKey;
|
|
270
532
|
pendingVerifyCap = Math.ceil(config.interleaveDepth * 1.5);
|
|
@@ -289,6 +551,7 @@ async function handleConfigure(cmd) {
|
|
|
289
551
|
}
|
|
290
552
|
}
|
|
291
553
|
wasmDecoder.configure_params(config.k, config.symbolSize);
|
|
554
|
+
emit({ type: "capabilities", commands: ["feedSymbols", "subscribeBlockUpdates"] });
|
|
292
555
|
startSnapshotTimer();
|
|
293
556
|
emitSnapshot();
|
|
294
557
|
}
|
|
@@ -302,44 +565,7 @@ function handleFeedSource(cmd) {
|
|
|
302
565
|
if (!isUint32(cmd.ssId) || !(cmd.data instanceof ArrayBuffer)) {
|
|
303
566
|
throw new Error("[FecWorker] feedSource: invalid source symbol");
|
|
304
567
|
}
|
|
305
|
-
|
|
306
|
-
const sbn = config.k > 0 ? Math.floor(cmd.ssId / config.k) : cmd.ssId;
|
|
307
|
-
const esi = cmd.ssId % config.k;
|
|
308
|
-
if (sbn <= retiredSbnFrontier) return;
|
|
309
|
-
const existingBlock = blocks.get(sbn);
|
|
310
|
-
if (existingBlock && isTerminalBlock(existingBlock)) return;
|
|
311
|
-
if (acceptedSourceEsis.get(sbn)?.has(esi)) return;
|
|
312
|
-
const result = wasmDecoder.add_source(cmd.ssId, view, cmd.ts);
|
|
313
|
-
advanceMediaHorizon(sbn);
|
|
314
|
-
let sourceEsis = acceptedSourceEsis.get(sbn);
|
|
315
|
-
if (!sourceEsis) {
|
|
316
|
-
sourceEsis = /* @__PURE__ */ new Set();
|
|
317
|
-
acceptedSourceEsis.set(sbn, sourceEsis);
|
|
318
|
-
}
|
|
319
|
-
sourceEsis.add(esi);
|
|
320
|
-
const block = getOrCreateBlock(sbn);
|
|
321
|
-
block.sourceReceived++;
|
|
322
|
-
block.lastSymbolTs = performance.now();
|
|
323
|
-
sourceSymbols++;
|
|
324
|
-
if (altaEnabled) altaPending++;
|
|
325
|
-
if (block.sourceReceived >= block.k && block.state === "collecting") {
|
|
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();
|
|
568
|
+
feedIsoSource(cmd, new Uint8Array(cmd.data), wasmDecoder, config);
|
|
343
569
|
}
|
|
344
570
|
function handleFeedRepair(cmd) {
|
|
345
571
|
if (!wasmDecoder) {
|
|
@@ -348,7 +574,7 @@ function handleFeedRepair(cmd) {
|
|
|
348
574
|
if (!config) {
|
|
349
575
|
throw new Error("[FecWorker] feedRepair: config missing");
|
|
350
576
|
}
|
|
351
|
-
if (!isUint32(cmd.ssStart) || !isUint32(cmd.ssbLength) || cmd.ssbLength <= 0 || cmd.ssbLength
|
|
577
|
+
if (!isUint32(cmd.ssStart) || !isUint32(cmd.ssbLength) || cmd.ssbLength <= 0 || cmd.ssbLength > config.k || !isUint32(cmd.rsId) || cmd.ssbLength + cmd.rsId > UINT24_MAX || !(cmd.data instanceof ArrayBuffer)) {
|
|
352
578
|
throw new Error("[FecWorker] feedRepair: invalid repair symbol");
|
|
353
579
|
}
|
|
354
580
|
const view = new Uint8Array(cmd.data);
|
|
@@ -363,43 +589,7 @@ function handleFeedRepair(cmd) {
|
|
|
363
589
|
}
|
|
364
590
|
return;
|
|
365
591
|
}
|
|
366
|
-
|
|
367
|
-
const repairEsi = cmd.ssbLength + cmd.rsId;
|
|
368
|
-
if (sbn <= retiredSbnFrontier) return;
|
|
369
|
-
const existingBlock = blocks.get(sbn);
|
|
370
|
-
if (existingBlock && isTerminalBlock(existingBlock)) return;
|
|
371
|
-
if (acceptedRepairEsis.get(sbn)?.has(repairEsi)) return;
|
|
372
|
-
const result = wasmDecoder.add_repair(
|
|
373
|
-
cmd.ssStart,
|
|
374
|
-
cmd.ssbLength,
|
|
375
|
-
cmd.rsId,
|
|
376
|
-
view,
|
|
377
|
-
cmd.ts
|
|
378
|
-
);
|
|
379
|
-
advanceMediaHorizon(sbn);
|
|
380
|
-
let repairEsis = acceptedRepairEsis.get(sbn);
|
|
381
|
-
if (!repairEsis) {
|
|
382
|
-
repairEsis = /* @__PURE__ */ new Set();
|
|
383
|
-
acceptedRepairEsis.set(sbn, repairEsis);
|
|
384
|
-
}
|
|
385
|
-
repairEsis.add(repairEsi);
|
|
386
|
-
const block = getOrCreateBlock(sbn);
|
|
387
|
-
block.repairReceived++;
|
|
388
|
-
block.lastSymbolTs = performance.now();
|
|
389
|
-
repairSymbols++;
|
|
390
|
-
if (result && result.byteLength > 0) {
|
|
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();
|
|
592
|
+
feedIsoRepair(cmd, view, wasmDecoder);
|
|
403
593
|
}
|
|
404
594
|
function handleRelayBlockSig(cmd) {
|
|
405
595
|
const block = blocks.get(cmd.sbn);
|
|
@@ -413,20 +603,23 @@ function handleCleanup(cmd) {
|
|
|
413
603
|
throw new Error("[FecWorker] cleanup: invalid SBNs");
|
|
414
604
|
}
|
|
415
605
|
const sbns = [...new Set(cmd.sbns)].sort((a, b) => a - b);
|
|
416
|
-
const pendingSbns = sbns.filter((sbn) => sbn
|
|
606
|
+
const pendingSbns = sbns.filter((sbn) => !isRetiredSbn(sbn));
|
|
417
607
|
try {
|
|
418
608
|
if (!wasmDecoder) {
|
|
419
609
|
throw new Error("[FecWorker] cleanup: not configured");
|
|
420
610
|
}
|
|
421
|
-
|
|
611
|
+
if (pendingSbns.length > 0) {
|
|
612
|
+
wasmDecoder.cleanup_blocks_detailed(Uint32Array.from(pendingSbns));
|
|
613
|
+
}
|
|
422
614
|
for (const sbn of pendingSbns) {
|
|
423
615
|
let currentBlock = blocks.get(sbn);
|
|
424
616
|
if (currentBlock && isTerminalBlock(currentBlock)) {
|
|
425
617
|
retireAcceptedIds(sbn);
|
|
426
618
|
continue;
|
|
427
619
|
}
|
|
428
|
-
currentBlock
|
|
429
|
-
|
|
620
|
+
currentBlock ??= getOrCreateBlock(sbn);
|
|
621
|
+
const authoredLength = currentBlock.ssbLength;
|
|
622
|
+
if (authoredLength !== void 0 && currentBlock.sourceReceived >= authoredLength) {
|
|
430
623
|
markBlockTerminal(currentBlock, "complete");
|
|
431
624
|
blocksComplete++;
|
|
432
625
|
} else {
|
|
@@ -435,35 +628,10 @@ function handleCleanup(cmd) {
|
|
|
435
628
|
}
|
|
436
629
|
emitBlockUpdate(currentBlock);
|
|
437
630
|
}
|
|
438
|
-
const greenFills = decodeCleanupRecords(cleanupRecords);
|
|
439
|
-
for (const { sbn, data } of greenFills) {
|
|
440
|
-
greenFillFrames++;
|
|
441
|
-
emit({ type: "greenFill", sbn, data }, [data]);
|
|
442
|
-
}
|
|
443
631
|
} finally {
|
|
444
632
|
emit({ type: "cleanupComplete", sbns });
|
|
445
633
|
}
|
|
446
634
|
}
|
|
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
635
|
function handleDispose() {
|
|
468
636
|
if (snapshotTimer !== null) {
|
|
469
637
|
clearInterval(snapshotTimer);
|
|
@@ -504,7 +672,6 @@ function handleReset() {
|
|
|
504
672
|
blocksComplete = 0;
|
|
505
673
|
blocksRecovered = 0;
|
|
506
674
|
blocksFailed = 0;
|
|
507
|
-
greenFillFrames = 0;
|
|
508
675
|
altaVerified = 0;
|
|
509
676
|
altaFailed = 0;
|
|
510
677
|
altaFailedDetails.length = 0;
|
|
@@ -515,9 +682,35 @@ function handleReset() {
|
|
|
515
682
|
relayBlocksVerified = 0;
|
|
516
683
|
relayBlocksFailed = 0;
|
|
517
684
|
repairSymbolSizeMismatch = 0;
|
|
685
|
+
sourceDroppedRetired = 0;
|
|
686
|
+
sourceDroppedTerminal = 0;
|
|
687
|
+
sourceDroppedDuplicate = 0;
|
|
688
|
+
sourceUnplacedRetired = 0;
|
|
518
689
|
emit({ type: "resetComplete" });
|
|
519
690
|
if (config) emitSnapshot();
|
|
520
691
|
}
|
|
692
|
+
function handleFeedSymbols(cmd) {
|
|
693
|
+
if (!Array.isArray(cmd.symbols)) {
|
|
694
|
+
throw new Error("[FecWorker] feedSymbols: symbols must be an array");
|
|
695
|
+
}
|
|
696
|
+
for (const symbol of cmd.symbols) {
|
|
697
|
+
try {
|
|
698
|
+
if (symbol?.type === "feedSource") handleFeedSource(symbol);
|
|
699
|
+
else if (symbol?.type === "feedRepair") handleFeedRepair(symbol);
|
|
700
|
+
else {
|
|
701
|
+
throw new Error(
|
|
702
|
+
`[FecWorker] feedSymbols: unknown symbol type ${String(symbol?.type)}`
|
|
703
|
+
);
|
|
704
|
+
}
|
|
705
|
+
} catch (err) {
|
|
706
|
+
emit({
|
|
707
|
+
type: "error",
|
|
708
|
+
code: "command-failed",
|
|
709
|
+
message: err instanceof Error ? err.message : `[FecWorker] ${String(err)}`
|
|
710
|
+
});
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
}
|
|
521
714
|
async function dispatchCommand(cmd) {
|
|
522
715
|
try {
|
|
523
716
|
switch (cmd.type) {
|
|
@@ -530,6 +723,9 @@ async function dispatchCommand(cmd) {
|
|
|
530
723
|
case "feedRepair":
|
|
531
724
|
handleFeedRepair(cmd);
|
|
532
725
|
break;
|
|
726
|
+
case "feedSymbols":
|
|
727
|
+
handleFeedSymbols(cmd);
|
|
728
|
+
break;
|
|
533
729
|
case "relayBlockSig":
|
|
534
730
|
handleRelayBlockSig(cmd);
|
|
535
731
|
break;
|
|
@@ -539,6 +735,9 @@ async function dispatchCommand(cmd) {
|
|
|
539
735
|
case "reset":
|
|
540
736
|
handleReset();
|
|
541
737
|
break;
|
|
738
|
+
case "subscribeBlockUpdates":
|
|
739
|
+
blockUpdatesSubscribed = true;
|
|
740
|
+
break;
|
|
542
741
|
case "dispose":
|
|
543
742
|
handleDispose();
|
|
544
743
|
break;
|