@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.
Files changed (41) hide show
  1. package/README.md +28 -23
  2. package/dist/block-order.d.ts +22 -0
  3. package/dist/block-order.d.ts.map +1 -0
  4. package/dist/block-order.js +48 -0
  5. package/dist/block-order.js.map +1 -0
  6. package/dist/fec-worker-client.d.ts +23 -20
  7. package/dist/fec-worker-client.d.ts.map +1 -1
  8. package/dist/fec-worker-client.js +102 -54
  9. package/dist/fec-worker-client.js.map +1 -1
  10. package/dist/fec-worker-types.d.ts +162 -88
  11. package/dist/fec-worker-types.d.ts.map +1 -1
  12. package/dist/fec-worker-types.js +2 -2
  13. package/dist/fec-worker.js +328 -195
  14. package/dist/fec-worker.js.map +4 -4
  15. package/dist/index.d.ts +1 -1
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js.map +1 -1
  18. package/dist/shred-fec-worker.js +23 -2
  19. package/dist/shred-fec-worker.js.map +2 -2
  20. package/dist/shred-worker-types.d.ts +16 -1
  21. package/dist/shred-worker-types.d.ts.map +1 -1
  22. package/dist/shred-worker-types.js +16 -1
  23. package/dist/shred-worker-types.js.map +1 -1
  24. package/package.json +10 -8
  25. package/src/block-order.ts +59 -0
  26. package/src/fec-worker-client.ts +107 -56
  27. package/src/fec-worker-types.ts +161 -84
  28. package/src/fec-worker.ts +517 -468
  29. package/src/index.ts +3 -0
  30. package/src/shred-fec-worker.ts +34 -3
  31. package/src/shred-worker-types.ts +16 -1
  32. package/src/__tests__/fec-worker-alta.test.ts +0 -231
  33. package/src/__tests__/fec-worker-cleanup-integration.test.ts +0 -250
  34. package/src/__tests__/fec-worker-pending-queue.test.ts +0 -315
  35. package/src/__tests__/fec-worker-repair-size.test.ts +0 -1029
  36. package/src/__tests__/fec-worker-wasm-contract.test.ts +0 -84
  37. package/src/__tests__/shred-fec-worker.test.ts +0 -448
  38. package/src/fec-worker-client.test.ts +0 -530
  39. package/src/fec-worker-types.test.ts +0 -400
  40. package/src/shred-worker-client.test.ts +0 -182
  41. package/src/transfer.test.ts +0 -24
@@ -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 retiredSbnFrontier = -1;
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
- pendingVerify.clear();
81
+ unplacedSources.clear();
65
82
  recoveryTimes.length = 0;
66
83
  liveSbn = 0;
67
- retiredSbnFrontier = -1;
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 > retiredSbnFrontier) continue;
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 || sbn <= liveSbn) return;
110
+ if (!config) return;
111
+ if (liveSbnKnown && !serialPrecedes(liveSbn, sbn)) return;
78
112
  liveSbn = sbn;
79
- const firstRetainedSbn = Math.max(0, liveSbn - config.interleaveDepth);
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.cleanup_by_sbn(liveSbn);
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 getOrCreateBlock(sbn) {
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) => b.sbn - a.sbn).slice(0, maxBlocks);
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
- if (recoveredBlock.byteLength < symbolSize * config.k) {
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 too short: got ${recoveredBlock.byteLength}B, expected at least ${symbolSize * config.k}B`
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 < config.k; i++) {
240
+ for (let i = 0; i < ssbLength; i++) {
201
241
  const symbol = recoveredBlock.subarray(i * symbolSize, (i + 1) * symbolSize);
202
- const recoveredAltaVerified = null;
203
- if (altaEnabled) altaPending++;
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 <= 0) {
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 and > 0 (got ${cmd.config.interleaveMs})`
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
- wasmDecoder = new wasm.MmtFecDecoder(
265
- cmd.config.interleaveDepth,
266
- cmd.config.trackType === "video"
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
- const view = new Uint8Array(cmd.data);
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 !== config.k || cmd.ssStart % cmd.ssbLength !== 0 || !isUint32(cmd.rsId) || cmd.ssbLength + cmd.rsId > UINT24_MAX || !(cmd.data instanceof ArrayBuffer)) {
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
- const sbn = Math.floor(cmd.ssStart / cmd.ssbLength);
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 > retiredSbnFrontier);
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
- const cleanupRecords = pendingSbns.length > 0 ? wasmDecoder.cleanup_blocks_detailed(Uint32Array.from(pendingSbns)) : new Uint8Array();
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 ?? (currentBlock = getOrCreateBlock(sbn));
429
- if (currentBlock.sourceReceived >= currentBlock.k) {
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;