@blockcast/fec-worker 0.1.0 → 0.3.0-main.288475b64b3e

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 (40) hide show
  1. package/README.md +19 -9
  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 +19 -7
  7. package/dist/fec-worker-client.d.ts.map +1 -1
  8. package/dist/fec-worker-client.js +105 -30
  9. package/dist/fec-worker-client.js.map +1 -1
  10. package/dist/fec-worker-types.d.ts +146 -25
  11. package/dist/fec-worker-types.d.ts.map +1 -1
  12. package/dist/fec-worker.js +326 -127
  13. package/dist/fec-worker.js.map +4 -4
  14. package/dist/index.d.ts +1 -1
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js.map +1 -1
  17. package/dist/shred-fec-worker.js +23 -2
  18. package/dist/shred-fec-worker.js.map +2 -2
  19. package/dist/shred-worker-types.d.ts +16 -1
  20. package/dist/shred-worker-types.d.ts.map +1 -1
  21. package/dist/shred-worker-types.js +16 -1
  22. package/dist/shred-worker-types.js.map +1 -1
  23. package/package.json +8 -3
  24. package/src/block-order.ts +59 -0
  25. package/src/fec-worker-client.ts +114 -32
  26. package/src/fec-worker-types.ts +145 -21
  27. package/src/fec-worker.ts +518 -180
  28. package/src/index.ts +3 -0
  29. package/src/shred-fec-worker.ts +34 -3
  30. package/src/shred-worker-types.ts +16 -1
  31. package/src/__tests__/fec-worker-alta.test.ts +0 -231
  32. package/src/__tests__/fec-worker-cleanup-integration.test.ts +0 -250
  33. package/src/__tests__/fec-worker-pending-queue.test.ts +0 -315
  34. package/src/__tests__/fec-worker-repair-size.test.ts +0 -1029
  35. package/src/__tests__/fec-worker-wasm-contract.test.ts +0 -84
  36. package/src/__tests__/shred-fec-worker.test.ts +0 -448
  37. package/src/fec-worker-client.test.ts +0 -530
  38. package/src/fec-worker-types.test.ts +0 -400
  39. package/src/shred-worker-client.test.ts +0 -182
  40. package/src/transfer.test.ts +0 -24
@@ -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 retiredSbnFrontier = -1;
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
- retiredSbnFrontier = -1;
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 > retiredSbnFrontier) continue;
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 || sbn <= liveSbn) return;
124
+ if (!config) return;
125
+ if (liveSbnKnown && !serialPrecedes(liveSbn, sbn)) return;
78
126
  liveSbn = sbn;
79
- const firstRetainedSbn = Math.max(0, liveSbn - config.interleaveDepth);
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.cleanup_by_sbn(liveSbn);
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 getOrCreateBlock(sbn) {
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) => b.sbn - a.sbn).slice(0, maxBlocks);
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
- if (recoveredBlock.byteLength < symbolSize * config.k) {
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 too short: got ${recoveredBlock.byteLength}B, expected at least ${symbolSize * config.k}B`
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 < config.k; 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
- wasmDecoder = new wasm.MmtFecDecoder(
265
- cmd.config.interleaveDepth,
266
- cmd.config.trackType === "video"
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
- 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();
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 !== config.k || cmd.ssStart % cmd.ssbLength !== 0 || !isUint32(cmd.rsId) || cmd.ssbLength + cmd.rsId > UINT24_MAX || !(cmd.data instanceof ArrayBuffer)) {
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
- 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();
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 > retiredSbnFrontier);
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
- const cleanupRecords = pendingSbns.length > 0 ? wasmDecoder.cleanup_blocks_detailed(Uint32Array.from(pendingSbns)) : new Uint8Array();
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 ?? (currentBlock = getOrCreateBlock(sbn));
429
- if (currentBlock.sourceReceived >= currentBlock.k) {
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;