@blockcast/fec-worker 0.1.0 → 0.3.0-main.10140e847083
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 +28 -23
- 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 +23 -20
- package/dist/fec-worker-client.d.ts.map +1 -1
- package/dist/fec-worker-client.js +102 -54
- package/dist/fec-worker-client.js.map +1 -1
- package/dist/fec-worker-types.d.ts +162 -88
- package/dist/fec-worker-types.d.ts.map +1 -1
- package/dist/fec-worker-types.js +2 -2
- package/dist/fec-worker.js +328 -195
- 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 +10 -8
- package/src/block-order.ts +59 -0
- package/src/fec-worker-client.ts +107 -56
- package/src/fec-worker-types.ts +161 -84
- package/src/fec-worker.ts +517 -468
- 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,42 @@ 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
|
-
var altaEnabled = false;
|
|
14
|
-
var altaVerifier = null;
|
|
15
36
|
var snapshotTimer = null;
|
|
37
|
+
var blockUpdatesSubscribed = false;
|
|
16
38
|
var blocks = /* @__PURE__ */ new Map();
|
|
17
39
|
var acceptedSourceEsis = /* @__PURE__ */ new Map();
|
|
18
40
|
var acceptedRepairEsis = /* @__PURE__ */ new Map();
|
|
41
|
+
var unplacedSources = /* @__PURE__ */ new Map();
|
|
19
42
|
var UINT32_MAX = 4294967295;
|
|
20
43
|
var UINT24_MAX = 16777215;
|
|
44
|
+
var SS_ID_SPAN = 4294967296;
|
|
45
|
+
var SSBG1_LENGTH_FIELD_BYTES = 2;
|
|
21
46
|
var MAX_INTERLEAVE_DEPTH = 255;
|
|
22
47
|
var MAX_SYMBOL_SIZE = 65528;
|
|
23
48
|
var MAX_RAPTORQ_SOURCE_SYMBOLS_PER_BLOCK = 56403;
|
|
@@ -26,24 +51,16 @@ var repairSymbols = 0;
|
|
|
26
51
|
var blocksComplete = 0;
|
|
27
52
|
var blocksRecovered = 0;
|
|
28
53
|
var blocksFailed = 0;
|
|
29
|
-
var greenFillFrames = 0;
|
|
30
|
-
var altaVerified = 0;
|
|
31
|
-
var altaFailed = 0;
|
|
32
|
-
var altaFailedDetails = [];
|
|
33
|
-
var altaPending = 0;
|
|
34
|
-
var altaPendingEvictedCap = 0;
|
|
35
|
-
var altaPendingEvictedTtl = 0;
|
|
36
|
-
var altaError = 0;
|
|
37
|
-
var altaInitFailed = false;
|
|
38
54
|
var relayBlocksVerified = 0;
|
|
39
55
|
var relayBlocksFailed = 0;
|
|
40
56
|
var liveSbn = 0;
|
|
41
|
-
var
|
|
57
|
+
var liveSbnKnown = false;
|
|
42
58
|
var repairSymbolSizeMismatch = 0;
|
|
59
|
+
var sourceDroppedRetired = 0;
|
|
60
|
+
var sourceDroppedTerminal = 0;
|
|
61
|
+
var sourceDroppedDuplicate = 0;
|
|
62
|
+
var sourceUnplacedRetired = 0;
|
|
43
63
|
var recoveryTimes = [];
|
|
44
|
-
var pendingVerify = /* @__PURE__ */ new Map();
|
|
45
|
-
var pendingVerifyCap = 0;
|
|
46
|
-
var pendingVerifyTtlMs = 0;
|
|
47
64
|
function emit(event, transfer) {
|
|
48
65
|
self.postMessage(event, transfer ?? []);
|
|
49
66
|
}
|
|
@@ -61,23 +78,39 @@ function clearBlockTracking() {
|
|
|
61
78
|
blocks.clear();
|
|
62
79
|
acceptedSourceEsis.clear();
|
|
63
80
|
acceptedRepairEsis.clear();
|
|
64
|
-
|
|
81
|
+
unplacedSources.clear();
|
|
65
82
|
recoveryTimes.length = 0;
|
|
66
83
|
liveSbn = 0;
|
|
67
|
-
|
|
84
|
+
liveSbnKnown = false;
|
|
85
|
+
}
|
|
86
|
+
function isRetiredSbn(sbn) {
|
|
87
|
+
if (config === null || !liveSbnKnown) return false;
|
|
88
|
+
return isoBlocksBehind(sbn, liveSbn, config.k) > config.interleaveDepth;
|
|
89
|
+
}
|
|
90
|
+
function isoKnownLength(block) {
|
|
91
|
+
return block.ssbLength ?? 1;
|
|
92
|
+
}
|
|
93
|
+
function findIsoBlock(ssId) {
|
|
94
|
+
for (const block of blocks.values()) {
|
|
95
|
+
if (isoBlockContains(block.sbn, isoKnownLength(block), ssId)) return block;
|
|
96
|
+
}
|
|
97
|
+
return void 0;
|
|
98
|
+
}
|
|
99
|
+
function isRetiredBlock(key, block) {
|
|
100
|
+
return isRetiredSbn((key + isoKnownLength(block) - 1) % SS_ID_SPAN);
|
|
68
101
|
}
|
|
69
102
|
function trimBlockRetention() {
|
|
70
103
|
for (const [sbn, block] of blocks) {
|
|
71
|
-
if (!isTerminalBlock(block) || sbn
|
|
104
|
+
if (!isTerminalBlock(block) || !isRetiredBlock(sbn, block)) continue;
|
|
72
105
|
blocks.delete(sbn);
|
|
73
106
|
retireAcceptedIds(sbn);
|
|
74
107
|
}
|
|
75
108
|
}
|
|
76
109
|
function advanceMediaHorizon(sbn) {
|
|
77
|
-
if (!config
|
|
110
|
+
if (!config) return;
|
|
111
|
+
if (liveSbnKnown && !serialPrecedes(liveSbn, sbn)) return;
|
|
78
112
|
liveSbn = sbn;
|
|
79
|
-
|
|
80
|
-
retiredSbnFrontier = Math.max(retiredSbnFrontier, firstRetainedSbn - 1);
|
|
113
|
+
liveSbnKnown = true;
|
|
81
114
|
trimBlockRetention();
|
|
82
115
|
}
|
|
83
116
|
function markBlockTerminal(block, state) {
|
|
@@ -87,8 +120,8 @@ function markBlockTerminal(block, state) {
|
|
|
87
120
|
trimBlockRetention();
|
|
88
121
|
}
|
|
89
122
|
function synchronizeDecoderHorizon() {
|
|
90
|
-
if (!wasmDecoder) return;
|
|
91
|
-
const expired = wasmDecoder.
|
|
123
|
+
if (!wasmDecoder || !liveSbnKnown) return;
|
|
124
|
+
const expired = wasmDecoder.cleanup_by_ss_start(liveSbn);
|
|
92
125
|
for (const sbn of expired) {
|
|
93
126
|
const block = blocks.get(sbn);
|
|
94
127
|
if (!block || isTerminalBlock(block)) {
|
|
@@ -99,9 +132,18 @@ function synchronizeDecoderHorizon() {
|
|
|
99
132
|
blocksFailed++;
|
|
100
133
|
emitBlockUpdate(block);
|
|
101
134
|
}
|
|
135
|
+
retireUnplacedSources();
|
|
102
136
|
trimBlockRetention();
|
|
103
137
|
}
|
|
104
|
-
function
|
|
138
|
+
function retireUnplacedSources() {
|
|
139
|
+
for (const ssId of unplacedSources.keys()) {
|
|
140
|
+
const unreachable = ssId !== liveSbn && !serialPrecedes(ssId, liveSbn);
|
|
141
|
+
if (!unreachable && !isRetiredSbn(ssId)) break;
|
|
142
|
+
unplacedSources.delete(ssId);
|
|
143
|
+
sourceUnplacedRetired++;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
function getOrCreateBlock(sbn, isoSsbLength) {
|
|
105
147
|
let block = blocks.get(sbn);
|
|
106
148
|
if (!block) {
|
|
107
149
|
if (!config) throw new Error("[FecWorker] not configured");
|
|
@@ -111,14 +153,16 @@ function getOrCreateBlock(sbn) {
|
|
|
111
153
|
state: "collecting",
|
|
112
154
|
sourceReceived: 0,
|
|
113
155
|
repairReceived: 0,
|
|
114
|
-
k: config.k,
|
|
156
|
+
k: isoSsbLength ?? config.k,
|
|
115
157
|
repairCount: config.repairCount,
|
|
116
158
|
firstSymbolTs: now,
|
|
117
159
|
lastSymbolTs: now,
|
|
160
|
+
// A positive interleaveMs makes this the catalog completion deadline;
|
|
161
|
+
// zero makes deliveryWindowMs a receiver-local cleanup bound.
|
|
118
162
|
deadlineTs: now + config.deliveryWindowMs,
|
|
119
|
-
altaVerified: 0,
|
|
120
163
|
relayBlockSigValid: null
|
|
121
164
|
};
|
|
165
|
+
if (isoSsbLength !== void 0) block.ssbLength = isoSsbLength;
|
|
122
166
|
blocks.set(sbn, block);
|
|
123
167
|
trimBlockRetention();
|
|
124
168
|
}
|
|
@@ -139,7 +183,7 @@ function buildStatsSnapshot() {
|
|
|
139
183
|
const avgRecoveryMs = recoveryTimes.length > 0 ? recoveryTimes.reduce((a, b) => a + b, 0) / recoveryTimes.length : 0;
|
|
140
184
|
const maxRecoveryMs = recoveryTimes.length > 0 ? Math.max(...recoveryTimes) : 0;
|
|
141
185
|
const maxBlocks = Math.max(config.interleaveDepth * 3, 24);
|
|
142
|
-
const blockArray = Array.from(blocks.values()).sort((a, b) =>
|
|
186
|
+
const blockArray = Array.from(blocks.values()).sort((a, b) => serialDistance(a.sbn, liveSbn) - serialDistance(b.sbn, liveSbn)).slice(0, maxBlocks);
|
|
143
187
|
return {
|
|
144
188
|
config,
|
|
145
189
|
sourceSymbols,
|
|
@@ -147,19 +191,9 @@ function buildStatsSnapshot() {
|
|
|
147
191
|
blocksComplete,
|
|
148
192
|
blocksRecovered,
|
|
149
193
|
blocksFailed,
|
|
150
|
-
greenFillFrames,
|
|
151
194
|
recoveryRate: blocksComplete + blocksRecovered > 0 ? Math.round(
|
|
152
195
|
blocksRecovered / (blocksRecovered + blocksFailed) * 100
|
|
153
196
|
) || 0 : 0,
|
|
154
|
-
altaVerified,
|
|
155
|
-
altaFailed,
|
|
156
|
-
altaPending,
|
|
157
|
-
altaPendingEvictedCap,
|
|
158
|
-
altaPendingEvictedTtl,
|
|
159
|
-
altaError,
|
|
160
|
-
altaInitFailed,
|
|
161
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
162
|
-
altaFailedDetails: altaFailedDetails.slice(),
|
|
163
197
|
relayBlocksVerified,
|
|
164
198
|
relayBlocksFailed,
|
|
165
199
|
liveSbn,
|
|
@@ -168,6 +202,10 @@ function buildStatsSnapshot() {
|
|
|
168
202
|
avgRecoveryMs: Math.round(avgRecoveryMs * 100) / 100,
|
|
169
203
|
maxRecoveryMs: Math.round(maxRecoveryMs * 100) / 100,
|
|
170
204
|
repairSymbolSizeMismatch,
|
|
205
|
+
sourceDroppedRetired,
|
|
206
|
+
sourceDroppedTerminal,
|
|
207
|
+
sourceDroppedDuplicate,
|
|
208
|
+
sourceUnplacedRetired,
|
|
171
209
|
decoder
|
|
172
210
|
};
|
|
173
211
|
}
|
|
@@ -176,6 +214,7 @@ function emitSnapshot() {
|
|
|
176
214
|
emit({ type: "snapshot", stats: buildStatsSnapshot() });
|
|
177
215
|
}
|
|
178
216
|
function emitBlockUpdate(block) {
|
|
217
|
+
if (!blockUpdatesSubscribed) return;
|
|
179
218
|
emit({ type: "blockUpdate", block: { ...block } });
|
|
180
219
|
}
|
|
181
220
|
function emitRecoveredSymbols(sbn, recoveredBlock, block) {
|
|
@@ -189,28 +228,47 @@ function emitRecoveredSymbols(sbn, recoveredBlock, block) {
|
|
|
189
228
|
});
|
|
190
229
|
return;
|
|
191
230
|
}
|
|
192
|
-
|
|
231
|
+
const ssbLength = block.ssbLength ?? config.k;
|
|
232
|
+
if (recoveredBlock.byteLength !== symbolSize * ssbLength) {
|
|
193
233
|
emit({
|
|
194
234
|
type: "error",
|
|
195
235
|
code: "recovered-block-too-short",
|
|
196
|
-
message: `[FecWorker] recovered block
|
|
236
|
+
message: `[FecWorker] recovered block has the wrong length: got ${recoveredBlock.byteLength}B, expected ${symbolSize * ssbLength}B (SSB_length=${ssbLength})`
|
|
197
237
|
});
|
|
198
238
|
return;
|
|
199
239
|
}
|
|
200
|
-
for (let i = 0; i <
|
|
240
|
+
for (let i = 0; i < ssbLength; i++) {
|
|
201
241
|
const symbol = recoveredBlock.subarray(i * symbolSize, (i + 1) * symbolSize);
|
|
202
|
-
const
|
|
203
|
-
if (
|
|
242
|
+
const invalid = invalidSsbg1Symbol(symbol, symbolSize);
|
|
243
|
+
if (invalid !== null) {
|
|
244
|
+
emit({
|
|
245
|
+
type: "error",
|
|
246
|
+
code: "recovered-ssbg1-symbol-invalid",
|
|
247
|
+
message: `[FecWorker] Dropping recovered ssbg_mode1 symbol (ssStart=${sbn}, esi=${i}, SSB_length=${ssbLength}): ${invalid}`
|
|
248
|
+
});
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
204
251
|
const buf = toOwnedArrayBuffer(symbol);
|
|
205
252
|
const meta = {
|
|
206
|
-
altaVerified: recoveredAltaVerified,
|
|
207
253
|
recovered: true,
|
|
208
254
|
sbn,
|
|
209
|
-
esi: i
|
|
255
|
+
esi: i,
|
|
256
|
+
ssbLength
|
|
210
257
|
};
|
|
211
258
|
emit({ type: "frame", data: buf, meta }, [buf]);
|
|
212
259
|
}
|
|
213
260
|
}
|
|
261
|
+
function invalidSsbg1Symbol(symbol, symbolSize) {
|
|
262
|
+
const length = symbol[0] << 8 | symbol[1];
|
|
263
|
+
const maxLength = symbolSize - SSBG1_LENGTH_FIELD_BYTES;
|
|
264
|
+
if (length > maxLength) {
|
|
265
|
+
return `length field ${length} exceeds T-2 = ${maxLength}`;
|
|
266
|
+
}
|
|
267
|
+
for (let j = SSBG1_LENGTH_FIELD_BYTES + length; j < symbolSize; j++) {
|
|
268
|
+
if (symbol[j] !== 0) return `non-zero padding at byte ${j} after a ${length}-byte packet`;
|
|
269
|
+
}
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
214
272
|
function startSnapshotTimer() {
|
|
215
273
|
if (snapshotTimer !== null) clearInterval(snapshotTimer);
|
|
216
274
|
snapshotTimer = setInterval(() => emitSnapshot(), 200);
|
|
@@ -224,6 +282,173 @@ function resolveWasmBindings() {
|
|
|
224
282
|
}
|
|
225
283
|
return bindings;
|
|
226
284
|
}
|
|
285
|
+
function verifyDecoderProfile(decoder, expected) {
|
|
286
|
+
let actual;
|
|
287
|
+
try {
|
|
288
|
+
actual = decoder.get_block_profile();
|
|
289
|
+
} catch (err) {
|
|
290
|
+
decoder.free();
|
|
291
|
+
throw new Error(
|
|
292
|
+
`[FecWorker] configure: decoder cannot report its block profile (${err instanceof Error ? err.message : String(err)})`
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
if (actual !== expected) {
|
|
296
|
+
decoder.free();
|
|
297
|
+
throw new Error(
|
|
298
|
+
`[FecWorker] configure: decoder runs block profile ${JSON.stringify(actual)}, not the configured "${expected}"`
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
function acceptId(ids, key, id) {
|
|
303
|
+
let set = ids.get(key);
|
|
304
|
+
if (!set) {
|
|
305
|
+
set = /* @__PURE__ */ new Set();
|
|
306
|
+
ids.set(key, set);
|
|
307
|
+
}
|
|
308
|
+
set.add(id);
|
|
309
|
+
}
|
|
310
|
+
function recordRecovered(key, block, result) {
|
|
311
|
+
const recovered = new Uint8Array(toOwnedArrayBuffer(result));
|
|
312
|
+
const recoveryMs = performance.now() - block.firstSymbolTs;
|
|
313
|
+
recoveryTimes.push(recoveryMs);
|
|
314
|
+
if (recoveryTimes.length > 1e3) recoveryTimes.shift();
|
|
315
|
+
if (block.state === "collecting") {
|
|
316
|
+
markBlockTerminal(block, "recovered");
|
|
317
|
+
blocksRecovered++;
|
|
318
|
+
emitBlockUpdate(block);
|
|
319
|
+
}
|
|
320
|
+
emitRecoveredSymbols(key, recovered, block);
|
|
321
|
+
}
|
|
322
|
+
function settleSourceArrival(key, block, result) {
|
|
323
|
+
const authoredLength = block.ssbLength ?? block.k;
|
|
324
|
+
if (block.sourceReceived >= authoredLength && block.state === "collecting") {
|
|
325
|
+
markBlockTerminal(block, "complete");
|
|
326
|
+
blocksComplete++;
|
|
327
|
+
emitBlockUpdate(block);
|
|
328
|
+
}
|
|
329
|
+
if (result && result.byteLength > 0 && block.sourceReceived < authoredLength) {
|
|
330
|
+
recordRecovered(key, block, result);
|
|
331
|
+
}
|
|
332
|
+
synchronizeDecoderHorizon();
|
|
333
|
+
}
|
|
334
|
+
function settleRepairArrival(key, block, ssbLength, result) {
|
|
335
|
+
if (block.sourceReceived >= ssbLength && block.state === "collecting") {
|
|
336
|
+
markBlockTerminal(block, "complete");
|
|
337
|
+
blocksComplete++;
|
|
338
|
+
emitBlockUpdate(block);
|
|
339
|
+
} else if (result && result.byteLength > 0) {
|
|
340
|
+
recordRecovered(key, block, result);
|
|
341
|
+
}
|
|
342
|
+
synchronizeDecoderHorizon();
|
|
343
|
+
}
|
|
344
|
+
function feedIsoSource(cmd, view, decoder, cfg) {
|
|
345
|
+
const maxPacketLength = cfg.symbolSize - SSBG1_LENGTH_FIELD_BYTES;
|
|
346
|
+
if (view.byteLength > maxPacketLength) {
|
|
347
|
+
throw new Error(
|
|
348
|
+
`[FecWorker] feedSource: iso-ssbg1-v1 protected packet of ${view.byteLength}B exceeds T-2 = ${maxPacketLength}B`
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
const block = findIsoBlock(cmd.ssId);
|
|
352
|
+
if (!block) {
|
|
353
|
+
if (isRetiredSbn(cmd.ssId)) {
|
|
354
|
+
sourceDroppedRetired++;
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
if (unplacedSources.has(cmd.ssId)) {
|
|
358
|
+
sourceDroppedDuplicate++;
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
const result2 = decoder.add_source(cmd.ssId, view, cmd.ts);
|
|
362
|
+
advanceMediaHorizon(cmd.ssId);
|
|
363
|
+
unplacedSources.set(cmd.ssId, performance.now());
|
|
364
|
+
sourceSymbols++;
|
|
365
|
+
if (result2 && result2.byteLength > 0) {
|
|
366
|
+
emit({
|
|
367
|
+
type: "error",
|
|
368
|
+
code: "recovered-block-unplaced",
|
|
369
|
+
message: `[FecWorker] Dropping a ${result2.byteLength}B recovered block returned for SS_ID ${cmd.ssId}, which no known block contains`
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
synchronizeDecoderHorizon();
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
const key = block.sbn;
|
|
376
|
+
if (isRetiredSbn(key)) {
|
|
377
|
+
sourceDroppedRetired++;
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
if (isTerminalBlock(block)) {
|
|
381
|
+
sourceDroppedTerminal++;
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
const esi = serialDistance(key, cmd.ssId);
|
|
385
|
+
if (acceptedSourceEsis.get(key)?.has(esi)) {
|
|
386
|
+
sourceDroppedDuplicate++;
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
const result = decoder.add_source(cmd.ssId, view, cmd.ts);
|
|
390
|
+
advanceMediaHorizon(cmd.ssId);
|
|
391
|
+
acceptId(acceptedSourceEsis, key, esi);
|
|
392
|
+
block.sourceReceived++;
|
|
393
|
+
block.lastSymbolTs = performance.now();
|
|
394
|
+
sourceSymbols++;
|
|
395
|
+
settleSourceArrival(key, block, result);
|
|
396
|
+
}
|
|
397
|
+
function findOverlappingIsoBlock(ssStart, ssbLength) {
|
|
398
|
+
for (const block of blocks.values()) {
|
|
399
|
+
if (isoBlocksOverlap(block.sbn, isoKnownLength(block), ssStart, ssbLength)) return block;
|
|
400
|
+
}
|
|
401
|
+
return void 0;
|
|
402
|
+
}
|
|
403
|
+
function placeIsoBlock(ssStart, ssbLength) {
|
|
404
|
+
const block = getOrCreateBlock(ssStart, ssbLength);
|
|
405
|
+
for (let esi = 0; esi < ssbLength; esi++) {
|
|
406
|
+
const ssId = (ssStart + esi) % SS_ID_SPAN;
|
|
407
|
+
const arrivalTs = unplacedSources.get(ssId);
|
|
408
|
+
if (arrivalTs === void 0) continue;
|
|
409
|
+
unplacedSources.delete(ssId);
|
|
410
|
+
acceptId(acceptedSourceEsis, ssStart, esi);
|
|
411
|
+
block.sourceReceived++;
|
|
412
|
+
if (arrivalTs < block.firstSymbolTs) block.firstSymbolTs = arrivalTs;
|
|
413
|
+
}
|
|
414
|
+
return block;
|
|
415
|
+
}
|
|
416
|
+
function feedIsoRepair(cmd, view, decoder) {
|
|
417
|
+
const key = cmd.ssStart;
|
|
418
|
+
if (isRetiredSbn(key)) return;
|
|
419
|
+
const existingBlock = blocks.get(key);
|
|
420
|
+
if (existingBlock && isTerminalBlock(existingBlock)) return;
|
|
421
|
+
if (existingBlock) {
|
|
422
|
+
if (existingBlock.ssbLength !== cmd.ssbLength) {
|
|
423
|
+
emit({
|
|
424
|
+
type: "error",
|
|
425
|
+
code: "repair-ssb-length-conflict",
|
|
426
|
+
message: `[FecWorker] Dropping repair symbol whose SSB_length ${cmd.ssbLength} conflicts with ${existingBlock.ssbLength} (ssStart=${key})`
|
|
427
|
+
});
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
} else {
|
|
431
|
+
const overlapped = findOverlappingIsoBlock(key, cmd.ssbLength);
|
|
432
|
+
if (overlapped) {
|
|
433
|
+
emit({
|
|
434
|
+
type: "error",
|
|
435
|
+
code: "repair-block-overlap",
|
|
436
|
+
message: `[FecWorker] Dropping repair symbol whose block (ssStart=${key}, SSB_length=${cmd.ssbLength}) overlaps known block (ssStart=${overlapped.sbn}, SSB_length=${isoKnownLength(overlapped)})`
|
|
437
|
+
});
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
const repairEsi = cmd.ssbLength + cmd.rsId;
|
|
442
|
+
if (acceptedRepairEsis.get(key)?.has(repairEsi)) return;
|
|
443
|
+
const result = decoder.add_repair(key, cmd.ssbLength, cmd.rsId, view, cmd.ts);
|
|
444
|
+
advanceMediaHorizon(key);
|
|
445
|
+
const block = existingBlock ?? placeIsoBlock(key, cmd.ssbLength);
|
|
446
|
+
acceptId(acceptedRepairEsis, key, repairEsi);
|
|
447
|
+
block.repairReceived++;
|
|
448
|
+
block.lastSymbolTs = performance.now();
|
|
449
|
+
repairSymbols++;
|
|
450
|
+
settleRepairArrival(key, block, cmd.ssbLength, result);
|
|
451
|
+
}
|
|
227
452
|
async function handleConfigure(cmd) {
|
|
228
453
|
if (!cmd.wasmModule) {
|
|
229
454
|
throw new Error("[FecWorker] configure: wasmModule required");
|
|
@@ -231,14 +456,25 @@ async function handleConfigure(cmd) {
|
|
|
231
456
|
if (!cmd.config) {
|
|
232
457
|
throw new Error("[FecWorker] configure: config required");
|
|
233
458
|
}
|
|
459
|
+
const blockProfile = cmd.config.blockProfile;
|
|
460
|
+
if (blockProfile === void 0) {
|
|
461
|
+
throw new Error(
|
|
462
|
+
'[FecWorker] configure: blockProfile is required: name the FEC block profile ("iso-ssbg1-v1")'
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
if (blockProfile !== "iso-ssbg1-v1") {
|
|
466
|
+
throw new Error(
|
|
467
|
+
`[FecWorker] configure: blockProfile must be "iso-ssbg1-v1" (got ${JSON.stringify(blockProfile)})`
|
|
468
|
+
);
|
|
469
|
+
}
|
|
234
470
|
if (!Number.isFinite(cmd.config.interleaveDepth) || !Number.isInteger(cmd.config.interleaveDepth) || cmd.config.interleaveDepth <= 0 || cmd.config.interleaveDepth > MAX_INTERLEAVE_DEPTH) {
|
|
235
471
|
throw new Error(
|
|
236
472
|
`[FecWorker] configure: interleaveDepth must be an integer in 1..${MAX_INTERLEAVE_DEPTH} (got ${cmd.config.interleaveDepth})`
|
|
237
473
|
);
|
|
238
474
|
}
|
|
239
|
-
if (!Number.isFinite(cmd.config.interleaveMs) || cmd.config.interleaveMs
|
|
475
|
+
if (!Number.isFinite(cmd.config.interleaveMs) || !Number.isSafeInteger(cmd.config.interleaveMs) || cmd.config.interleaveMs < 0) {
|
|
240
476
|
throw new Error(
|
|
241
|
-
`[FecWorker] configure: interleaveMs must be finite
|
|
477
|
+
`[FecWorker] configure: interleaveMs must be a finite non-negative integer (got ${cmd.config.interleaveMs})`
|
|
242
478
|
);
|
|
243
479
|
}
|
|
244
480
|
if (!Number.isFinite(cmd.config.deliveryWindowMs) || cmd.config.deliveryWindowMs <= 0) {
|
|
@@ -261,34 +497,12 @@ async function handleConfigure(cmd) {
|
|
|
261
497
|
wasmDecoder?.free();
|
|
262
498
|
wasmDecoder = null;
|
|
263
499
|
clearBlockTracking();
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
);
|
|
500
|
+
const decoder = new wasm.MmtFecDecoder(cmd.config.interleaveDepth, blockProfile);
|
|
501
|
+
verifyDecoderProfile(decoder, blockProfile);
|
|
502
|
+
wasmDecoder = decoder;
|
|
268
503
|
config = cmd.config;
|
|
269
|
-
altaEnabled = !!cmd.altaPublicKey;
|
|
270
|
-
pendingVerifyCap = Math.ceil(config.interleaveDepth * 1.5);
|
|
271
|
-
pendingVerifyTtlMs = Math.ceil(config.interleaveMs * config.k * 1.5);
|
|
272
|
-
altaVerifier = null;
|
|
273
|
-
altaInitFailed = false;
|
|
274
|
-
if (cmd.altaPublicKey && cmd.altaWasmModule) {
|
|
275
|
-
try {
|
|
276
|
-
const altaGlueUrl = "/wasm/alta/alta_rs.js";
|
|
277
|
-
const altaGlue = await import(
|
|
278
|
-
/* webpackIgnore: true */
|
|
279
|
-
altaGlueUrl
|
|
280
|
-
);
|
|
281
|
-
altaGlue.initSync({ module: cmd.altaWasmModule });
|
|
282
|
-
const keyBytes = new Uint8Array(cmd.altaPublicKey);
|
|
283
|
-
altaVerifier = new altaGlue.JsAltaVerifier(keyBytes);
|
|
284
|
-
} catch (e) {
|
|
285
|
-
console.warn("[FecWorker] ALTA WASM init failed, verification disabled:", e);
|
|
286
|
-
altaVerifier = null;
|
|
287
|
-
altaEnabled = false;
|
|
288
|
-
altaInitFailed = true;
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
504
|
wasmDecoder.configure_params(config.k, config.symbolSize);
|
|
505
|
+
emit({ type: "capabilities", commands: ["feedSymbols", "subscribeBlockUpdates"] });
|
|
292
506
|
startSnapshotTimer();
|
|
293
507
|
emitSnapshot();
|
|
294
508
|
}
|
|
@@ -302,44 +516,7 @@ function handleFeedSource(cmd) {
|
|
|
302
516
|
if (!isUint32(cmd.ssId) || !(cmd.data instanceof ArrayBuffer)) {
|
|
303
517
|
throw new Error("[FecWorker] feedSource: invalid source symbol");
|
|
304
518
|
}
|
|
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();
|
|
519
|
+
feedIsoSource(cmd, new Uint8Array(cmd.data), wasmDecoder, config);
|
|
343
520
|
}
|
|
344
521
|
function handleFeedRepair(cmd) {
|
|
345
522
|
if (!wasmDecoder) {
|
|
@@ -348,7 +525,7 @@ function handleFeedRepair(cmd) {
|
|
|
348
525
|
if (!config) {
|
|
349
526
|
throw new Error("[FecWorker] feedRepair: config missing");
|
|
350
527
|
}
|
|
351
|
-
if (!isUint32(cmd.ssStart) || !isUint32(cmd.ssbLength) || cmd.ssbLength <= 0 || cmd.ssbLength
|
|
528
|
+
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
529
|
throw new Error("[FecWorker] feedRepair: invalid repair symbol");
|
|
353
530
|
}
|
|
354
531
|
const view = new Uint8Array(cmd.data);
|
|
@@ -363,43 +540,7 @@ function handleFeedRepair(cmd) {
|
|
|
363
540
|
}
|
|
364
541
|
return;
|
|
365
542
|
}
|
|
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();
|
|
543
|
+
feedIsoRepair(cmd, view, wasmDecoder);
|
|
403
544
|
}
|
|
404
545
|
function handleRelayBlockSig(cmd) {
|
|
405
546
|
const block = blocks.get(cmd.sbn);
|
|
@@ -413,20 +554,23 @@ function handleCleanup(cmd) {
|
|
|
413
554
|
throw new Error("[FecWorker] cleanup: invalid SBNs");
|
|
414
555
|
}
|
|
415
556
|
const sbns = [...new Set(cmd.sbns)].sort((a, b) => a - b);
|
|
416
|
-
const pendingSbns = sbns.filter((sbn) => sbn
|
|
557
|
+
const pendingSbns = sbns.filter((sbn) => !isRetiredSbn(sbn));
|
|
417
558
|
try {
|
|
418
559
|
if (!wasmDecoder) {
|
|
419
560
|
throw new Error("[FecWorker] cleanup: not configured");
|
|
420
561
|
}
|
|
421
|
-
|
|
562
|
+
if (pendingSbns.length > 0) {
|
|
563
|
+
wasmDecoder.cleanup_blocks_detailed(Uint32Array.from(pendingSbns));
|
|
564
|
+
}
|
|
422
565
|
for (const sbn of pendingSbns) {
|
|
423
566
|
let currentBlock = blocks.get(sbn);
|
|
424
567
|
if (currentBlock && isTerminalBlock(currentBlock)) {
|
|
425
568
|
retireAcceptedIds(sbn);
|
|
426
569
|
continue;
|
|
427
570
|
}
|
|
428
|
-
currentBlock
|
|
429
|
-
|
|
571
|
+
currentBlock ??= getOrCreateBlock(sbn);
|
|
572
|
+
const authoredLength = currentBlock.ssbLength;
|
|
573
|
+
if (authoredLength !== void 0 && currentBlock.sourceReceived >= authoredLength) {
|
|
430
574
|
markBlockTerminal(currentBlock, "complete");
|
|
431
575
|
blocksComplete++;
|
|
432
576
|
} else {
|
|
@@ -435,54 +579,19 @@ function handleCleanup(cmd) {
|
|
|
435
579
|
}
|
|
436
580
|
emitBlockUpdate(currentBlock);
|
|
437
581
|
}
|
|
438
|
-
const greenFills = decodeCleanupRecords(cleanupRecords);
|
|
439
|
-
for (const { sbn, data } of greenFills) {
|
|
440
|
-
greenFillFrames++;
|
|
441
|
-
emit({ type: "greenFill", sbn, data }, [data]);
|
|
442
|
-
}
|
|
443
582
|
} finally {
|
|
444
583
|
emit({ type: "cleanupComplete", sbns });
|
|
445
584
|
}
|
|
446
585
|
}
|
|
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
586
|
function handleDispose() {
|
|
468
587
|
if (snapshotTimer !== null) {
|
|
469
588
|
clearInterval(snapshotTimer);
|
|
470
589
|
snapshotTimer = null;
|
|
471
590
|
}
|
|
472
|
-
if (altaVerifier) {
|
|
473
|
-
try {
|
|
474
|
-
altaVerifier.free();
|
|
475
|
-
} catch (_e) {
|
|
476
|
-
}
|
|
477
|
-
altaVerifier = null;
|
|
478
|
-
}
|
|
479
591
|
if (wasmDecoder) {
|
|
480
592
|
wasmDecoder.free();
|
|
481
593
|
wasmDecoder = null;
|
|
482
594
|
}
|
|
483
|
-
pendingVerify.clear();
|
|
484
|
-
pendingVerifyCap = 0;
|
|
485
|
-
pendingVerifyTtlMs = 0;
|
|
486
595
|
config = null;
|
|
487
596
|
clearBlockTracking();
|
|
488
597
|
}
|
|
@@ -504,20 +613,38 @@ function handleReset() {
|
|
|
504
613
|
blocksComplete = 0;
|
|
505
614
|
blocksRecovered = 0;
|
|
506
615
|
blocksFailed = 0;
|
|
507
|
-
greenFillFrames = 0;
|
|
508
|
-
altaVerified = 0;
|
|
509
|
-
altaFailed = 0;
|
|
510
|
-
altaFailedDetails.length = 0;
|
|
511
|
-
altaPending = 0;
|
|
512
|
-
altaPendingEvictedCap = 0;
|
|
513
|
-
altaPendingEvictedTtl = 0;
|
|
514
|
-
altaError = 0;
|
|
515
616
|
relayBlocksVerified = 0;
|
|
516
617
|
relayBlocksFailed = 0;
|
|
517
618
|
repairSymbolSizeMismatch = 0;
|
|
619
|
+
sourceDroppedRetired = 0;
|
|
620
|
+
sourceDroppedTerminal = 0;
|
|
621
|
+
sourceDroppedDuplicate = 0;
|
|
622
|
+
sourceUnplacedRetired = 0;
|
|
518
623
|
emit({ type: "resetComplete" });
|
|
519
624
|
if (config) emitSnapshot();
|
|
520
625
|
}
|
|
626
|
+
function handleFeedSymbols(cmd) {
|
|
627
|
+
if (!Array.isArray(cmd.symbols)) {
|
|
628
|
+
throw new Error("[FecWorker] feedSymbols: symbols must be an array");
|
|
629
|
+
}
|
|
630
|
+
for (const symbol of cmd.symbols) {
|
|
631
|
+
try {
|
|
632
|
+
if (symbol?.type === "feedSource") handleFeedSource(symbol);
|
|
633
|
+
else if (symbol?.type === "feedRepair") handleFeedRepair(symbol);
|
|
634
|
+
else {
|
|
635
|
+
throw new Error(
|
|
636
|
+
`[FecWorker] feedSymbols: unknown symbol type ${String(symbol?.type)}`
|
|
637
|
+
);
|
|
638
|
+
}
|
|
639
|
+
} catch (err) {
|
|
640
|
+
emit({
|
|
641
|
+
type: "error",
|
|
642
|
+
code: "command-failed",
|
|
643
|
+
message: err instanceof Error ? err.message : `[FecWorker] ${String(err)}`
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
}
|
|
521
648
|
async function dispatchCommand(cmd) {
|
|
522
649
|
try {
|
|
523
650
|
switch (cmd.type) {
|
|
@@ -530,6 +657,9 @@ async function dispatchCommand(cmd) {
|
|
|
530
657
|
case "feedRepair":
|
|
531
658
|
handleFeedRepair(cmd);
|
|
532
659
|
break;
|
|
660
|
+
case "feedSymbols":
|
|
661
|
+
handleFeedSymbols(cmd);
|
|
662
|
+
break;
|
|
533
663
|
case "relayBlockSig":
|
|
534
664
|
handleRelayBlockSig(cmd);
|
|
535
665
|
break;
|
|
@@ -539,6 +669,9 @@ async function dispatchCommand(cmd) {
|
|
|
539
669
|
case "reset":
|
|
540
670
|
handleReset();
|
|
541
671
|
break;
|
|
672
|
+
case "subscribeBlockUpdates":
|
|
673
|
+
blockUpdatesSubscribed = true;
|
|
674
|
+
break;
|
|
542
675
|
case "dispose":
|
|
543
676
|
handleDispose();
|
|
544
677
|
break;
|