@blockcast/mmt-container 2.2.0-main.75b39a2cdddf → 2.2.0-main.7e8a9d13c01c

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 (52) hide show
  1. package/dist/browser-runtime.js +634 -129
  2. package/dist/browser-runtime.js.map +3 -3
  3. package/dist/delta-fallback-config.js.map +1 -1
  4. package/dist/fec-manager.d.ts +37 -11
  5. package/dist/fec-manager.d.ts.map +1 -1
  6. package/dist/fec-manager.js +181 -52
  7. package/dist/fec-manager.js.map +2 -2
  8. package/dist/fec-worker-setup.js.map +1 -1
  9. package/dist/index.d.ts +1 -1
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js.map +2 -2
  12. package/dist/iso-block-locator.d.ts +39 -13
  13. package/dist/iso-block-locator.d.ts.map +1 -1
  14. package/dist/iso-block-locator.js +80 -25
  15. package/dist/iso-block-locator.js.map +2 -2
  16. package/dist/media-router.js.map +1 -1
  17. package/dist/mmtp-container-parser.d.ts +21 -0
  18. package/dist/mmtp-container-parser.d.ts.map +1 -1
  19. package/dist/mmtp-container-parser.js +114 -11
  20. package/dist/mmtp-container-parser.js.map +2 -2
  21. package/dist/mmtp-container-parser.no-modules.js +113 -12
  22. package/dist/mmtp-container-parser.no-modules.js.map +2 -2
  23. package/dist/mmtp-decode-pipeline.js +114 -11
  24. package/dist/mmtp-decode-pipeline.js.map +2 -2
  25. package/dist/mmtp-router.d.ts +18 -9
  26. package/dist/mmtp-router.d.ts.map +1 -1
  27. package/dist/mmtp-router.js +254 -44
  28. package/dist/mmtp-router.js.map +2 -2
  29. package/dist/multicast-manager.d.ts +13 -3
  30. package/dist/multicast-manager.d.ts.map +1 -1
  31. package/dist/multicast-manager.js +455 -103
  32. package/dist/multicast-manager.js.map +2 -2
  33. package/dist/no-modules.js +114 -11
  34. package/dist/no-modules.js.map +2 -2
  35. package/dist/repair-fetch.d.ts +36 -8
  36. package/dist/repair-fetch.d.ts.map +1 -1
  37. package/dist/repair-fetch.js +285 -26
  38. package/dist/repair-fetch.js.map +3 -3
  39. package/dist/track-session.d.ts.map +1 -1
  40. package/dist/track-session.js +1 -0
  41. package/dist/track-session.js.map +2 -2
  42. package/dist/wasm-mmtp-container-parser.js +114 -11
  43. package/dist/wasm-mmtp-container-parser.js.map +2 -2
  44. package/package.json +5 -5
  45. package/src/fec-manager.ts +156 -26
  46. package/src/index.ts +2 -0
  47. package/src/iso-block-locator.ts +114 -31
  48. package/src/mmtp-container-parser.ts +181 -10
  49. package/src/mmtp-router.ts +231 -58
  50. package/src/multicast-manager.ts +44 -6
  51. package/src/repair-fetch.ts +363 -49
  52. package/src/track-session.ts +3 -0
@@ -327,6 +327,15 @@ var IsoBlockLocator = class _IsoBlockLocator {
327
327
  this.#heldEvicted = 0;
328
328
  /** Pending holes (see `deferIncompleteGaps`): unstated, sources still held. */
329
329
  this.#holes = [];
330
+ /** Holes the horizon passed while pending: sources held until closed. */
331
+ this.#expired = [];
332
+ /** Of `#expired`, those `takeExpiredHoles` has not handed out yet. */
333
+ this.#newlyExpired = [];
334
+ /**
335
+ * Spans closed from expired holes: behind the horizon, so kept only for
336
+ * `locate` while their sources are fed, until the next retirement.
337
+ */
338
+ this.#closedBehind = [];
330
339
  /**
331
340
  * Keep a gap that is missing a source as a pending hole rather than a span,
332
341
  * so a repair FETCHed for it (draft-ramadan-moq-fec §7.5) can still state
@@ -350,6 +359,9 @@ var IsoBlockLocator = class _IsoBlockLocator {
350
359
  #newest;
351
360
  #newestSource;
352
361
  #holes;
362
+ #expired;
363
+ #newlyExpired;
364
+ #closedBehind;
353
365
  static #checkedRetention(retentionBlocks) {
354
366
  if (!Number.isSafeInteger(retentionBlocks) || retentionBlocks <= 0) {
355
367
  throw new Error(
@@ -419,17 +431,29 @@ var IsoBlockLocator = class _IsoBlockLocator {
419
431
  }
420
432
  /** The known block or span containing `ssId`, with its ESI. */
421
433
  locate(ssId) {
422
- for (let i = this.#blocks.length - 1; i >= 0; i--) {
423
- const block = this.#blocks[i];
424
- if (isoBlockContains(block.start, block.length, ssId)) {
425
- return { block, esi: serial32Distance(block.start, ssId) };
434
+ for (const blocks of [this.#blocks, this.#closedBehind]) {
435
+ for (let i = blocks.length - 1; i >= 0; i--) {
436
+ const block = blocks[i];
437
+ if (isoBlockContains(block.start, block.length, ssId)) {
438
+ return { block, esi: serial32Distance(block.start, ssId) };
439
+ }
426
440
  }
427
441
  }
428
442
  return null;
429
443
  }
430
- /** Number of pending holes. */
444
+ /** Number of holes not closed yet, pending or past the horizon. */
431
445
  get holeCount() {
432
- return this.#holes.length;
446
+ return this.#holes.length + this.#expired.length;
447
+ }
448
+ /**
449
+ * The pending holes the horizon has passed since the last call, in the
450
+ * order it passed them. Each is no longer pending: no repair states a block
451
+ * in it (that repair is `stale`, as for a span the horizon retired) and the
452
+ * release gate does not wait for it. Its owner must close it
453
+ * (`closeHolesWithin`), which turns it into a span that is not kept here.
454
+ */
455
+ takeExpiredHoles() {
456
+ return this.#newlyExpired.splice(0);
433
457
  }
434
458
  /** The stated block (never a span) ending exactly at `end`. */
435
459
  blockEndingAt(end) {
@@ -485,9 +509,11 @@ var IsoBlockLocator = class _IsoBlockLocator {
485
509
  /**
486
510
  * Apply one repair symbol's `(SS_Start, SSB_length)`. The first repair of a
487
511
  * block states its range and places every held source in it; later repair
488
- * symbols of the same block change nothing. A range that contradicts a
489
- * known block (a different length, or an overlap with another block or a
490
- * span) fails closed: the repair symbol is dropped and no block changes.
512
+ * symbols of the same block change nothing. A repair whose range is exactly
513
+ * a repair-less span states that span's one block. A range that contradicts
514
+ * a known block (a different length, or an overlap with another block or
515
+ * with part of a span) fails closed: the repair symbol is dropped and no
516
+ * block changes.
491
517
  * A newly stated block retires behind its SS_Start (see the class note).
492
518
  * A block so far past `newest` that stating it would retire the position
493
519
  * `newest` is refused as `ahead`. When `newest` is a placed block's SS_Start
@@ -506,7 +532,16 @@ var IsoBlockLocator = class _IsoBlockLocator {
506
532
  const known = this.blockAt(start);
507
533
  if (known !== void 0) {
508
534
  if (known.span) {
509
- return { status: "overlap", reason: `repair block ${start}+${length} falls inside a repair-less span` };
535
+ if (known.length === length) {
536
+ const block2 = { start, length, span: false };
537
+ this.#blocks[this.#blocks.indexOf(known)] = block2;
538
+ return { status: "placed", block: block2, placed: [] };
539
+ }
540
+ return length < known.length ? {
541
+ status: "overlap",
542
+ reason: `repair block ${start}+${length} is only the first block of repair-less span ${known.start}+${known.length}`,
543
+ ofSpan: known
544
+ } : { status: "overlap", reason: `repair block ${start}+${length} runs past repair-less span ${known.start}+${known.length}` };
510
545
  }
511
546
  if (known.length !== length) {
512
547
  return {
@@ -574,18 +609,28 @@ var IsoBlockLocator = class _IsoBlockLocator {
574
609
  return span === void 0 ? { status: "placed", block, placed } : { status: "placed", block, placed, span };
575
610
  }
576
611
  /**
577
- * Turn every pending hole inside `[start, start + length)` into a
578
- * repair-less span, claiming its held sources: its range FETCH ended, so
579
- * nothing more will state its blocks. Returns the spans, in SS_ID order.
612
+ * Turn every hole inside `[start, start + length)` into a repair-less span,
613
+ * claiming its held sources: its range FETCH ended, so nothing more will
614
+ * state its blocks. A hole the horizon passed (`takeExpiredHoles`) leaves a
615
+ * span that is not kept among the blocks, as the horizon would retire it at
616
+ * once; only `locate` finds it, until the next retirement, so its sources
617
+ * can be fed. Returns the spans, in SS_ID order.
580
618
  */
581
619
  closeHolesWithin(start, length) {
582
620
  const closed = [];
583
- for (const hole of [...this.#holes]) {
584
- if (!isoBlockContains(start, length, hole.start)) continue;
585
- this.#holes.splice(this.#holes.indexOf(hole), 1);
586
- const spanBlock = { start: hole.start, length: hole.length, span: true };
587
- closed.push({ block: spanBlock, placed: this.#claim(spanBlock) });
588
- this.#blocks.push(spanBlock);
621
+ for (const holes of [this.#holes, this.#expired]) {
622
+ for (const hole of [...holes]) {
623
+ if (!isoBlockContains(start, length, hole.start)) continue;
624
+ holes.splice(holes.indexOf(hole), 1);
625
+ const spanBlock = { start: hole.start, length: hole.length, span: true };
626
+ closed.push({ block: spanBlock, placed: this.#claim(spanBlock) });
627
+ if (holes === this.#holes) {
628
+ this.#blocks.push(spanBlock);
629
+ } else {
630
+ this.#closedBehind.push(spanBlock);
631
+ if (this.#newlyExpired.includes(hole)) this.#newlyExpired.splice(this.#newlyExpired.indexOf(hole), 1);
632
+ }
633
+ }
589
634
  }
590
635
  return closed.sort((x, y) => serial32Distance(start, x.block.start) - serial32Distance(start, y.block.start));
591
636
  }
@@ -599,15 +644,17 @@ var IsoBlockLocator = class _IsoBlockLocator {
599
644
  return void 0;
600
645
  }
601
646
  /**
602
- * Retire every block and span whose last SS_ID, and every held source,
603
- * lies more than `retentionBlocks` blocks behind `position`, with the shared
604
- * lower-bound metric (`isoBlocksBehindLowerBound`) every ISO interleave
605
- * horizon uses. Returns the retired block starts.
647
+ * Retire every block and span whose last SS_ID, and every held source
648
+ * outside a hole, lies more than `retentionBlocks` blocks behind
649
+ * `position`, with the shared lower-bound metric (`isoBlocksBehindLowerBound`)
650
+ * every ISO interleave horizon uses; a pending hole so far behind expires
651
+ * (`takeExpiredHoles`). Returns the retired block starts.
606
652
  */
607
653
  retireBehind(position) {
608
654
  this.observe(position);
609
655
  const horizon = this.#retentionBlocks;
610
656
  const retired = [];
657
+ this.#closedBehind.length = 0;
611
658
  for (let i = this.#blocks.length - 1; i >= 0; i--) {
612
659
  const block = this.#blocks[i];
613
660
  const last = serial32Add(block.start, block.length - 1);
@@ -619,10 +666,15 @@ var IsoBlockLocator = class _IsoBlockLocator {
619
666
  for (let i = this.#holes.length - 1; i >= 0; i--) {
620
667
  const hole = this.#holes[i];
621
668
  const last = serial32Add(hole.start, hole.length - 1);
622
- if (isoBlocksBehindLowerBound(last, position, this.#kMax) > horizon) this.#holes.splice(i, 1);
669
+ if (isoBlocksBehindLowerBound(last, position, this.#kMax) <= horizon) continue;
670
+ this.#holes.splice(i, 1);
671
+ this.#expired.push(hole);
672
+ this.#newlyExpired.push(hole);
623
673
  }
624
674
  for (const ssId of [...this.#held.keys()]) {
625
- if (isoBlocksBehindLowerBound(ssId, position, this.#kMax) > horizon) this.#held.delete(ssId);
675
+ if (isoBlocksBehindLowerBound(ssId, position, this.#kMax) > horizon && !this.#holes.some((hole) => isoBlockContains(hole.start, hole.length, ssId)) && !this.#expired.some((hole) => isoBlockContains(hole.start, hole.length, ssId))) {
676
+ this.#held.delete(ssId);
677
+ }
626
678
  }
627
679
  return retired;
628
680
  }
@@ -630,6 +682,9 @@ var IsoBlockLocator = class _IsoBlockLocator {
630
682
  reset() {
631
683
  this.#blocks.length = 0;
632
684
  this.#holes.length = 0;
685
+ this.#expired.length = 0;
686
+ this.#newlyExpired.length = 0;
687
+ this.#closedBehind.length = 0;
633
688
  this.#held.clear();
634
689
  this.#coverageEnd = void 0;
635
690
  this.#newest = void 0;
@@ -1192,6 +1247,10 @@ var FECManager = class _FECManager {
1192
1247
  // 'pending' forever, which would park every later frame of its stream in
1193
1248
  // the in-order release gate. Each entry owns its handle; the callback
1194
1249
  // checks it still owns the key, so a settled or reset key is never touched.
1250
+ /**
1251
+ * Per owed Worker cleanup: the bound on its acknowledgement, when its block
1252
+ * was due, and when its cleanup was asked for (see `onCleanupComplete`).
1253
+ */
1195
1254
  this.#pendingWorkerCleanupBounds = /* @__PURE__ */ new Map();
1196
1255
  // Source symbol count per track-scoped block — marks complete when K symbols arrive.
1197
1256
  this.#blockSourceCounts = /* @__PURE__ */ new Map();
@@ -1208,6 +1267,9 @@ var FECManager = class _FECManager {
1208
1267
  // SSB_length source arrivals and recoverable from SSB_length received
1209
1268
  // symbols; waiting for K fails every one of them.
1210
1269
  this.#blockAuthoredLengths = /* @__PURE__ */ new Map();
1270
+ // Keys whose authored length is a repair-less span's (#openIsoSpan), until
1271
+ // a repair states the block there or the span resolves.
1272
+ this.#isoSpanKeys = /* @__PURE__ */ new Set();
1211
1273
  this.#ssbLengthConflictCount = 0;
1212
1274
  this.#blockRepairRsIds = /* @__PURE__ */ new Map();
1213
1275
  // RSB_length per block (C.5.3 Repair FEC Payload ID): how many repair
@@ -1405,7 +1467,10 @@ var FECManager = class _FECManager {
1405
1467
  // arrives via onBlockRecovered callback.
1406
1468
  this.onBlockStarted = null;
1407
1469
  this.onBlockComplete = null;
1408
- /** A block reached its fail bound. `blockClass` is its class at failure. */
1470
+ /**
1471
+ * A block reached its fail bound. `blockClass` is its class at failure.
1472
+ * `span` is set when what failed is a repair-less span starting at `sbn`.
1473
+ */
1409
1474
  this.onBlockFailed = null;
1410
1475
  /**
1411
1476
  * Fired once per stream when its Worker decoder stops being routable.
@@ -1447,6 +1512,8 @@ var FECManager = class _FECManager {
1447
1512
  * for it, which it must be.
1448
1513
  */
1449
1514
  this.#onUndelimitedLoss = null;
1515
+ /** Set with the loss handler: true takes an expired hole to close (`closeIsoHoles`). */
1516
+ this.#onIsoHoleExpired = null;
1450
1517
  /** A block every symbol of which vanished was created by SBN evidence. */
1451
1518
  this.onBlockVanished = null;
1452
1519
  /** A block reached a terminal state (completed, recovered, or failed). */
@@ -1491,6 +1558,7 @@ var FECManager = class _FECManager {
1491
1558
  #blockSourceEsis;
1492
1559
  #blockRebuiltEsis;
1493
1560
  #blockAuthoredLengths;
1561
+ #isoSpanKeys;
1494
1562
  #ssbLengthConflictCount;
1495
1563
  #blockRepairRsIds;
1496
1564
  #blockRsbLengths;
@@ -1557,6 +1625,7 @@ var FECManager = class _FECManager {
1557
1625
  #decoderUnavailableEvents;
1558
1626
  #repairSymbolSizeMismatchCount;
1559
1627
  #onUndelimitedLoss;
1628
+ #onIsoHoleExpired;
1560
1629
  #encodeTrackName(catalogTrackName) {
1561
1630
  let encoded = this.#encodedTrackNames.get(catalogTrackName);
1562
1631
  if (encoded === void 0) {
@@ -2028,7 +2097,10 @@ var FECManager = class _FECManager {
2028
2097
  timer.handle = null;
2029
2098
  this.#requestRepair(sbn, packetId, catalogTrackName);
2030
2099
  };
2031
- timer.handle = setTimeout(onFire, Math.max(0, deadlineMs - performance.now()));
2100
+ timer.handle = setTimeout(
2101
+ onFire,
2102
+ this.#receiptAtMs === void 0 ? Math.max(0, deadlineMs - performance.now()) : this.#receiptTimerDelayMs(deadlineMs, 0)
2103
+ );
2032
2104
  this.#repairDeadlines.set(key, timer);
2033
2105
  }
2034
2106
  #requestRepair(sbn, packetId, catalogTrackName, settledByOrder = false) {
@@ -2172,8 +2244,14 @@ var FECManager = class _FECManager {
2172
2244
  * Vanished blocks are not enumerated here: their starts are unknown until a
2173
2245
  * repair states them. The SS_IDs between two stated blocks become one
2174
2246
  * repair-less span when the later block's repair arrives (placeIsoRepair).
2247
+ *
2248
+ * A `held` source arrived before its block was known and is fed only now
2249
+ * that a repair, a span or a closed hole placed it: how far its block start
2250
+ * then lies behind the frontier measures how long the receiver held it, not
2251
+ * where the stream was, so only its SS_ID and packet_sequence_number judge
2252
+ * it (BLO-52085).
2175
2253
  */
2176
- #noteSbnSeen(sbn, packetId, catalogTrackName, symbol, packetSequenceNumber, ssId = sbn) {
2254
+ #noteSbnSeen(sbn, packetId, catalogTrackName, symbol, packetSequenceNumber, ssId = sbn, held = false) {
2177
2255
  const streamKey = this.#streamKey(packetId, catalogTrackName);
2178
2256
  const now = performance.now();
2179
2257
  const previous = (symbol === "source" ? this.#sourceFrontiers : this.#highestSbn).get(streamKey);
@@ -2212,7 +2290,7 @@ var FECManager = class _FECManager {
2212
2290
  return sent <= 2147483647 && serial32Distance(from.ssId, to.ssId) <= sent + config.k;
2213
2291
  };
2214
2292
  if (!this.#blockPrecedes(previous.sbn, sbn)) {
2215
- if (symbol === "source" && window !== null && this.#blockPrecedes(sbn, previous.sbn) && (this.#blocksBehind(sbn, previous.sbn, packetId) > this.#isoRetentionBlocks(packetId) || psn !== void 0 && anchor !== void 0 && serial32Precedes(ssId, anchor.ssId) && psn !== anchor.psn && !sentInOrder({ ssId, psn }, anchor))) {
2293
+ if (symbol === "source" && window !== null && this.#blockPrecedes(sbn, previous.sbn) && (!held && this.#blocksBehind(sbn, previous.sbn, packetId) > this.#isoRetentionBlocks(packetId) || psn !== void 0 && anchor !== void 0 && serial32Precedes(ssId, anchor.ssId) && psn !== anchor.psn && !sentInOrder({ ssId, psn }, anchor))) {
2216
2294
  discontinuity();
2217
2295
  return;
2218
2296
  }
@@ -2474,17 +2552,21 @@ var FECManager = class _FECManager {
2474
2552
  this.#blockWaitDeadlinesMs.delete(key);
2475
2553
  this.#repairHoldUntilMs.delete(key);
2476
2554
  this.#traceLifecycle("timeout-queued", sbn, packetId, catalogTrackName);
2477
- this.#finalizeExpiredBlock(sbn, packetId, catalogTrackName);
2555
+ this.#finalizeExpiredBlock(sbn, packetId, catalogTrackName, currentDeadlineMs ?? firedAtMs);
2478
2556
  };
2479
- timer.handle = setTimeout(onFire, Math.max(1, Math.ceil(deadlineMs - nowMs)));
2557
+ timer.handle = setTimeout(
2558
+ onFire,
2559
+ this.#receiptAtMs === void 0 ? Math.max(1, Math.ceil(deadlineMs - nowMs)) : this.#receiptTimerDelayMs(deadlineMs, 1)
2560
+ );
2480
2561
  this.#activeBlocks.set(key, timer);
2481
2562
  this.#indexBlockKey(key, sbn, packetId);
2482
2563
  }
2483
2564
  /**
2484
2565
  * Hand a block whose delivery is over to its decoder for finalization: the
2485
- * datagram deadline fired, or the ordered relay moved past it.
2566
+ * datagram deadline fired, or the ordered relay moved past it. `dueAtMs`
2567
+ * is when it was due: its deadline, or now.
2486
2568
  */
2487
- #finalizeExpiredBlock(sbn, packetId, catalogTrackName) {
2569
+ #finalizeExpiredBlock(sbn, packetId, catalogTrackName, dueAtMs = performance.now()) {
2488
2570
  const key = this.#blockKey(sbn, packetId, catalogTrackName);
2489
2571
  if (this.#materializedBlocks.has(key) && !this.#blockPathCounts.has(key)) {
2490
2572
  this.#failBlock(sbn, packetId, catalogTrackName);
@@ -2500,7 +2582,7 @@ var FECManager = class _FECManager {
2500
2582
  if (workerClient) {
2501
2583
  this.#pendingWorkerCleanups.add(key);
2502
2584
  this.#pendingWorkerCleanupClients.set(key, workerClient);
2503
- this.#boundOwedCleanup(key, packetId, workerClient);
2585
+ this.#boundOwedCleanup(key, packetId, workerClient, dueAtMs);
2504
2586
  workerClient.cleanup(sbn);
2505
2587
  } else {
2506
2588
  const decoder = this.#decoderForPacket(packetId, catalogTrackName)?.client;
@@ -2814,6 +2896,7 @@ var FECManager = class _FECManager {
2814
2896
  resetMap(this.#blockSourceEsis);
2815
2897
  resetMap(this.#blockRebuiltEsis);
2816
2898
  resetMap(this.#blockAuthoredLengths);
2899
+ resetSet(this.#isoSpanKeys);
2817
2900
  resetMap(this.#blockRepairRsIds);
2818
2901
  resetMap(this.#blockRsbLengths);
2819
2902
  resetMap(this.#heldLayerStates);
@@ -2939,6 +3022,7 @@ var FECManager = class _FECManager {
2939
3022
  dropMap(this.#blockSourceEsis);
2940
3023
  dropMap(this.#blockRebuiltEsis);
2941
3024
  dropMap(this.#blockAuthoredLengths);
3025
+ dropSet(this.#isoSpanKeys);
2942
3026
  dropMap(this.#blockRepairRsIds);
2943
3027
  dropMap(this.#blockRsbLengths);
2944
3028
  dropMap(this.#blockPathCounts);
@@ -3281,8 +3365,9 @@ var FECManager = class _FECManager {
3281
3365
  return true;
3282
3366
  }
3283
3367
  /**
3284
- * Drop a block's repair hold: its FETCH ended without delivering. The block
3285
- * falls back to its multicast wait, which may already have passed.
3368
+ * Drop a block's repair hold: its FETCH ended without delivering, or brought
3369
+ * back only a repair that cannot complete it. The block falls back to its
3370
+ * multicast wait, which may already have passed.
3286
3371
  */
3287
3372
  releaseRepairHold(sbn, packetId, catalogTrackName) {
3288
3373
  const key = this.#blockKey(sbn, packetId, catalogTrackName);
@@ -3420,6 +3505,7 @@ var FECManager = class _FECManager {
3420
3505
  const locator = this.#growingIsoLocatorFor(packetId, catalogTrackName);
3421
3506
  const evictedBefore = locator.heldEvicted;
3422
3507
  const held = locator.hold(ssId, source);
3508
+ this.#settleExpiredIsoHoles(locator, packetId, catalogTrackName);
3423
3509
  if (held && source.source === "multicast") this.#noteMulticastSource(locator, ssId, packetId, catalogTrackName);
3424
3510
  const evicted = locator.heldEvicted - evictedBefore;
3425
3511
  if (evicted > 0) {
@@ -3441,17 +3527,20 @@ var FECManager = class _FECManager {
3441
3527
  * contradicts a known block is refused (counted) and must be dropped.
3442
3528
  */
3443
3529
  placeIsoRepair(packetId, catalogTrackName, ssStart, ssbLength) {
3444
- const placement = this.#growingIsoLocatorFor(packetId, catalogTrackName).placeRepair(ssStart, ssbLength);
3530
+ const locator = this.#growingIsoLocatorFor(packetId, catalogTrackName);
3531
+ const placement = locator.placeRepair(ssStart, ssbLength);
3532
+ this.#settleExpiredIsoHoles(locator, packetId, catalogTrackName);
3445
3533
  if (placement.status !== "placed") {
3446
3534
  this.#isoRepairRefusedCount++;
3447
3535
  if (this.#isoRepairRefusedCount <= 5 || this.#isoRepairRefusedCount % 100 === 0) {
3448
3536
  console.warn(`[FECManager] packetId=${packetId} iso-ssbg1-v1 repair refused (${placement.status}): ${placement.reason}`);
3449
3537
  }
3538
+ if (placement.ofSpan !== void 0) this.releaseRepairHold(placement.ofSpan.start, packetId, catalogTrackName);
3450
3539
  return placement;
3451
3540
  }
3541
+ this.#isoSpanKeys.delete(this.#blockKey(placement.block.start, packetId, catalogTrackName));
3452
3542
  if (placement.span !== void 0) this.#openIsoSpan(placement.span, packetId, catalogTrackName);
3453
3543
  if (placement.hole !== void 0) {
3454
- const locator = this.#isoLocatorFor(packetId, catalogTrackName);
3455
3544
  const hole = placement.hole.block;
3456
3545
  const previous = locator.blockEndingAt(hole.start);
3457
3546
  const loss = {
@@ -3472,17 +3561,29 @@ var FECManager = class _FECManager {
3472
3561
  * `handler`, instead of a repair-less span; null restores spans. Set by the
3473
3562
  * range repair scheduler; applies to every stream from its next gap. The
3474
3563
  * handler and the deferral are one setting, so no hole is ever created with
3475
- * nobody to close it.
3564
+ * nobody to close it. `onExpired` is told of each pending hole the stream
3565
+ * runs past the locator's horizon (BLO-52085): returning true, it closes
3566
+ * the hole (`closeIsoHoles`), so the span it leaves still fails and is
3567
+ * reported; otherwise the hole is discarded with its sources.
3476
3568
  */
3477
- setUndelimitedLossHandler(handler) {
3569
+ setUndelimitedLossHandler(handler, onExpired = null) {
3478
3570
  this.#onUndelimitedLoss = handler;
3571
+ this.#onIsoHoleExpired = handler === null ? null : onExpired;
3479
3572
  this.#deferIsoGaps = handler !== null;
3480
3573
  for (const locator of this.#isoLocators.values()) locator.deferIncompleteGaps = this.#deferIsoGaps;
3481
3574
  }
3575
+ /** Hand each hole the locator's horizon passed to its owner, or discard it (setUndelimitedLossHandler). */
3576
+ #settleExpiredIsoHoles(locator, packetId, catalogTrackName) {
3577
+ for (const hole of locator.takeExpiredHoles()) {
3578
+ const expired = { packetId, catalogTrackName, holeStart: hole.start, holeLength: hole.length };
3579
+ if (this.#onIsoHoleExpired?.(expired) !== true) locator.closeHolesWithin(hole.start, hole.length);
3580
+ }
3581
+ }
3482
3582
  /**
3483
- * The range repair for a hole ended: every hole still pending inside
3484
- * `[start, start + length)` becomes a repair-less span. Returns those spans
3485
- * with the held sources they placed, for the caller to feed (MmtpRouter).
3583
+ * The range repair for a hole ended: every hole inside `[start, start +
3584
+ * length)`, pending or expired, becomes a repair-less span. Returns those
3585
+ * spans with the held sources they placed, for the caller to feed
3586
+ * (MmtpRouter).
3486
3587
  */
3487
3588
  closeIsoHoles(packetId, catalogTrackName, start, length) {
3488
3589
  const locator = this.#isoLocators.get(this.#streamKey(packetId, catalogTrackName));
@@ -3501,7 +3602,10 @@ var FECManager = class _FECManager {
3501
3602
  */
3502
3603
  #openIsoSpan(span, packetId, catalogTrackName) {
3503
3604
  const key = this.#blockKey(span.block.start, packetId, catalogTrackName);
3504
- if (!this.#blockAuthoredLengths.has(key)) this.#blockAuthoredLengths.set(key, span.block.length);
3605
+ if (!this.#blockAuthoredLengths.has(key)) {
3606
+ this.#blockAuthoredLengths.set(key, span.block.length);
3607
+ this.#isoSpanKeys.add(key);
3608
+ }
3505
3609
  if (span.placed.length === 0) this.#materializeBlock(span.block.start, packetId, catalogTrackName, true);
3506
3610
  }
3507
3611
  /**
@@ -3842,11 +3946,14 @@ var FECManager = class _FECManager {
3842
3946
  });
3843
3947
  client.onCleanupComplete((sbn) => {
3844
3948
  if (this.#releasedWorkerClients.has(client)) return;
3845
- const cleanupKeys = [...this.#pendingWorkerCleanups].filter((key) => {
3949
+ const owed = [...this.#pendingWorkerCleanups].filter((key) => this.#pendingWorkerCleanupClients.get(key) === client);
3950
+ const cleanupKeys = owed.filter((key) => {
3846
3951
  const block = this.#parseBlockKey(key);
3847
- return block.packetId === packetId && block.sbn === sbn && this.#pendingWorkerCleanupClients.get(key) === client;
3952
+ return block.packetId === packetId && block.sbn === sbn;
3848
3953
  });
3849
- for (const key of cleanupKeys) this.#settleOwedCleanup(key, "cleanup-ack");
3954
+ const askedAtMs = Math.max(...cleanupKeys.map((key) => this.#pendingWorkerCleanupBounds.get(key)?.askedAtMs ?? -Infinity));
3955
+ const due = owed.filter((key) => !cleanupKeys.includes(key) && (this.#pendingWorkerCleanupBounds.get(key)?.dueAtMs ?? Infinity) <= askedAtMs);
3956
+ for (const key of [...cleanupKeys, ...due]) this.#settleOwedCleanup(key, "cleanup-ack");
3850
3957
  });
3851
3958
  client.onError((msg, code) => {
3852
3959
  const requestedKind = code ?? msg;
@@ -3916,9 +4023,11 @@ var FECManager = class _FECManager {
3916
4023
  * fail deadline already passed, so failing it is the decision that deadline
3917
4024
  * made. A late ack then finds nothing owed and is a no-op.
3918
4025
  */
3919
- #boundOwedCleanup(key, packetId, client) {
4026
+ #boundOwedCleanup(key, packetId, client, dueAtMs) {
3920
4027
  this.#clearOwedCleanupBound(key);
3921
4028
  const bound = {
4029
+ dueAtMs,
4030
+ askedAtMs: performance.now(),
3922
4031
  handle: setTimeout(() => {
3923
4032
  if (this.#pendingWorkerCleanupBounds.get(key) !== bound) return;
3924
4033
  this.#pendingWorkerCleanupBounds.delete(key);
@@ -4489,12 +4598,13 @@ var FECManager = class _FECManager {
4489
4598
  * the inactivity deadline on each symbol, records the symbol's in-band
4490
4599
  * classification evidence, and marks the block clean when all of its
4491
4600
  * authored source symbols arrived: the SSB_length a repair symbol declared,
4492
- * otherwise K.
4601
+ * otherwise K. `held` marks a source held unplaced since it arrived and
4602
+ * placed only now (see #noteSbnSeen).
4493
4603
  *
4494
4604
  * Throws for a packetId that was never configured, so a classified block
4495
4605
  * never reads the manager-global K or timeout.
4496
4606
  */
4497
- trackBlock(sbn, packetId, catalogTrackName, esi, evidence) {
4607
+ trackBlock(sbn, packetId, catalogTrackName, esi, evidence, held = false) {
4498
4608
  const trackConfig = this.#trackConfigs.get(packetId);
4499
4609
  if (trackConfig === void 0) {
4500
4610
  throw new Error(
@@ -4510,7 +4620,8 @@ var FECManager = class _FECManager {
4510
4620
  catalogTrackName,
4511
4621
  "source",
4512
4622
  evidence.packetSequenceNumber,
4513
- Number.isInteger(esi) ? sbn + esi >>> 0 : sbn
4623
+ Number.isInteger(esi) ? sbn + esi >>> 0 : sbn,
4624
+ held
4514
4625
  );
4515
4626
  const authoredLength = this.#blockAuthoredLengths.get(key) ?? k;
4516
4627
  if (Number.isInteger(esi) && esi >= authoredLength) {
@@ -4565,6 +4676,7 @@ var FECManager = class _FECManager {
4565
4676
  this.#blockSourceEsis.delete(key);
4566
4677
  this.#blockRebuiltEsis.delete(key);
4567
4678
  this.#blockAuthoredLengths.delete(key);
4679
+ this.#isoSpanKeys.delete(key);
4568
4680
  this.#blockRepairRsIds.delete(key);
4569
4681
  this.#blockRsbLengths.delete(key);
4570
4682
  this.#markTerminal(key, sbn, packetId, true);
@@ -4595,6 +4707,19 @@ var FECManager = class _FECManager {
4595
4707
  #receiptNow() {
4596
4708
  return this.#receiptAtMs ?? performance.now();
4597
4709
  }
4710
+ /**
4711
+ * The whole-ms delay of a timer for `deadlineMs` armed inside a receipt
4712
+ * scope: counted from the receipt time, as the timer of a symbol fed when
4713
+ * it was received was, less the whole ms since. So it fires on the tick
4714
+ * that timer would have, never later than a delay counted from now, and
4715
+ * its handler still re-arms a timer that fires before its deadline.
4716
+ * Counted from now, a deadline set from the receipt time is rounded up
4717
+ * from another fraction of a ms (BLO-52085).
4718
+ */
4719
+ #receiptTimerDelayMs(deadlineMs, minMs) {
4720
+ const receivedAtMs = this.#receiptAtMs;
4721
+ return Math.max(minMs, Math.ceil(deadlineMs - receivedAtMs) - Math.ceil(performance.now() - receivedAtMs));
4722
+ }
4598
4723
  /**
4599
4724
  * A held packet admitted late arms from its (earlier) receipt time; that
4600
4725
  * never pulls in a deadline later activity already armed. Outside a
@@ -4643,6 +4768,7 @@ var FECManager = class _FECManager {
4643
4768
  }
4644
4769
  #failBlock(sbn, packetId, catalogTrackName) {
4645
4770
  const key = this.#blockKey(sbn, packetId, catalogTrackName);
4771
+ const span = this.#isoSpanKeys.delete(key) ? { length: this.#blockAuthoredLengths.get(key) ?? 0, sources: this.#blockSourceCounts.get(key) ?? 0 } : void 0;
4646
4772
  this.#traceLifecycle("failed", sbn, packetId, catalogTrackName);
4647
4773
  this.#clearRepairDeadline(key);
4648
4774
  this.#clearBlockTimer(key);
@@ -4662,7 +4788,8 @@ var FECManager = class _FECManager {
4662
4788
  this.#markPathBlockComplete(sbn, packetId, catalogTrackName);
4663
4789
  this.#fecStats.blocksFailed++;
4664
4790
  this.#statsForPacket(packetId).blocksFailed++;
4665
- this.onBlockFailed?.(sbn, packetId, catalogTrackName, blockClass);
4791
+ if (span === void 0) this.onBlockFailed?.(sbn, packetId, catalogTrackName, blockClass);
4792
+ else this.onBlockFailed?.(sbn, packetId, catalogTrackName, blockClass, span);
4666
4793
  this.onBlockResolved?.(sbn, packetId, catalogTrackName);
4667
4794
  }
4668
4795
  /**
@@ -4693,6 +4820,7 @@ var FECManager = class _FECManager {
4693
4820
  this.#blockSourceEsis.delete(key);
4694
4821
  this.#blockRebuiltEsis.delete(key);
4695
4822
  this.#blockAuthoredLengths.delete(key);
4823
+ this.#isoSpanKeys.delete(key);
4696
4824
  this.#blockRepairRsIds.delete(key);
4697
4825
  this.#blockRsbLengths.delete(key);
4698
4826
  this.#clearBlockTimer(key);
@@ -4785,6 +4913,7 @@ var FECManager = class _FECManager {
4785
4913
  this.#blockSourceEsis.clear();
4786
4914
  this.#blockRebuiltEsis.clear();
4787
4915
  this.#blockAuthoredLengths.clear();
4916
+ this.#isoSpanKeys.clear();
4788
4917
  this.#blockRepairRsIds.clear();
4789
4918
  this.#blockRsbLengths.clear();
4790
4919
  this.#heldLayerStates.clear();