@blockcast/fec-worker 0.1.0 → 0.3.0-main.d9354616fdf2

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