@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
@@ -1189,6 +1189,15 @@ var IsoBlockLocator = class _IsoBlockLocator {
1189
1189
  this.#heldEvicted = 0;
1190
1190
  /** Pending holes (see `deferIncompleteGaps`): unstated, sources still held. */
1191
1191
  this.#holes = [];
1192
+ /** Holes the horizon passed while pending: sources held until closed. */
1193
+ this.#expired = [];
1194
+ /** Of `#expired`, those `takeExpiredHoles` has not handed out yet. */
1195
+ this.#newlyExpired = [];
1196
+ /**
1197
+ * Spans closed from expired holes: behind the horizon, so kept only for
1198
+ * `locate` while their sources are fed, until the next retirement.
1199
+ */
1200
+ this.#closedBehind = [];
1192
1201
  /**
1193
1202
  * Keep a gap that is missing a source as a pending hole rather than a span,
1194
1203
  * so a repair FETCHed for it (draft-ramadan-moq-fec §7.5) can still state
@@ -1212,6 +1221,9 @@ var IsoBlockLocator = class _IsoBlockLocator {
1212
1221
  #newest;
1213
1222
  #newestSource;
1214
1223
  #holes;
1224
+ #expired;
1225
+ #newlyExpired;
1226
+ #closedBehind;
1215
1227
  static #checkedRetention(retentionBlocks) {
1216
1228
  if (!Number.isSafeInteger(retentionBlocks) || retentionBlocks <= 0) {
1217
1229
  throw new Error(
@@ -1281,17 +1293,29 @@ var IsoBlockLocator = class _IsoBlockLocator {
1281
1293
  }
1282
1294
  /** The known block or span containing `ssId`, with its ESI. */
1283
1295
  locate(ssId) {
1284
- for (let i = this.#blocks.length - 1; i >= 0; i--) {
1285
- const block = this.#blocks[i];
1286
- if (isoBlockContains(block.start, block.length, ssId)) {
1287
- return { block, esi: serial32Distance(block.start, ssId) };
1296
+ for (const blocks of [this.#blocks, this.#closedBehind]) {
1297
+ for (let i = blocks.length - 1; i >= 0; i--) {
1298
+ const block = blocks[i];
1299
+ if (isoBlockContains(block.start, block.length, ssId)) {
1300
+ return { block, esi: serial32Distance(block.start, ssId) };
1301
+ }
1288
1302
  }
1289
1303
  }
1290
1304
  return null;
1291
1305
  }
1292
- /** Number of pending holes. */
1306
+ /** Number of holes not closed yet, pending or past the horizon. */
1293
1307
  get holeCount() {
1294
- return this.#holes.length;
1308
+ return this.#holes.length + this.#expired.length;
1309
+ }
1310
+ /**
1311
+ * The pending holes the horizon has passed since the last call, in the
1312
+ * order it passed them. Each is no longer pending: no repair states a block
1313
+ * in it (that repair is `stale`, as for a span the horizon retired) and the
1314
+ * release gate does not wait for it. Its owner must close it
1315
+ * (`closeHolesWithin`), which turns it into a span that is not kept here.
1316
+ */
1317
+ takeExpiredHoles() {
1318
+ return this.#newlyExpired.splice(0);
1295
1319
  }
1296
1320
  /** The stated block (never a span) ending exactly at `end`. */
1297
1321
  blockEndingAt(end) {
@@ -1347,9 +1371,11 @@ var IsoBlockLocator = class _IsoBlockLocator {
1347
1371
  /**
1348
1372
  * Apply one repair symbol's `(SS_Start, SSB_length)`. The first repair of a
1349
1373
  * block states its range and places every held source in it; later repair
1350
- * symbols of the same block change nothing. A range that contradicts a
1351
- * known block (a different length, or an overlap with another block or a
1352
- * span) fails closed: the repair symbol is dropped and no block changes.
1374
+ * symbols of the same block change nothing. A repair whose range is exactly
1375
+ * a repair-less span states that span's one block. A range that contradicts
1376
+ * a known block (a different length, or an overlap with another block or
1377
+ * with part of a span) fails closed: the repair symbol is dropped and no
1378
+ * block changes.
1353
1379
  * A newly stated block retires behind its SS_Start (see the class note).
1354
1380
  * A block so far past `newest` that stating it would retire the position
1355
1381
  * `newest` is refused as `ahead`. When `newest` is a placed block's SS_Start
@@ -1368,7 +1394,16 @@ var IsoBlockLocator = class _IsoBlockLocator {
1368
1394
  const known = this.blockAt(start);
1369
1395
  if (known !== void 0) {
1370
1396
  if (known.span) {
1371
- return { status: "overlap", reason: `repair block ${start}+${length} falls inside a repair-less span` };
1397
+ if (known.length === length) {
1398
+ const block2 = { start, length, span: false };
1399
+ this.#blocks[this.#blocks.indexOf(known)] = block2;
1400
+ return { status: "placed", block: block2, placed: [] };
1401
+ }
1402
+ return length < known.length ? {
1403
+ status: "overlap",
1404
+ reason: `repair block ${start}+${length} is only the first block of repair-less span ${known.start}+${known.length}`,
1405
+ ofSpan: known
1406
+ } : { status: "overlap", reason: `repair block ${start}+${length} runs past repair-less span ${known.start}+${known.length}` };
1372
1407
  }
1373
1408
  if (known.length !== length) {
1374
1409
  return {
@@ -1436,18 +1471,28 @@ var IsoBlockLocator = class _IsoBlockLocator {
1436
1471
  return span === void 0 ? { status: "placed", block, placed } : { status: "placed", block, placed, span };
1437
1472
  }
1438
1473
  /**
1439
- * Turn every pending hole inside `[start, start + length)` into a
1440
- * repair-less span, claiming its held sources: its range FETCH ended, so
1441
- * nothing more will state its blocks. Returns the spans, in SS_ID order.
1474
+ * Turn every hole inside `[start, start + length)` into a repair-less span,
1475
+ * claiming its held sources: its range FETCH ended, so nothing more will
1476
+ * state its blocks. A hole the horizon passed (`takeExpiredHoles`) leaves a
1477
+ * span that is not kept among the blocks, as the horizon would retire it at
1478
+ * once; only `locate` finds it, until the next retirement, so its sources
1479
+ * can be fed. Returns the spans, in SS_ID order.
1442
1480
  */
1443
1481
  closeHolesWithin(start, length) {
1444
1482
  const closed = [];
1445
- for (const hole of [...this.#holes]) {
1446
- if (!isoBlockContains(start, length, hole.start)) continue;
1447
- this.#holes.splice(this.#holes.indexOf(hole), 1);
1448
- const spanBlock = { start: hole.start, length: hole.length, span: true };
1449
- closed.push({ block: spanBlock, placed: this.#claim(spanBlock) });
1450
- this.#blocks.push(spanBlock);
1483
+ for (const holes of [this.#holes, this.#expired]) {
1484
+ for (const hole of [...holes]) {
1485
+ if (!isoBlockContains(start, length, hole.start)) continue;
1486
+ holes.splice(holes.indexOf(hole), 1);
1487
+ const spanBlock = { start: hole.start, length: hole.length, span: true };
1488
+ closed.push({ block: spanBlock, placed: this.#claim(spanBlock) });
1489
+ if (holes === this.#holes) {
1490
+ this.#blocks.push(spanBlock);
1491
+ } else {
1492
+ this.#closedBehind.push(spanBlock);
1493
+ if (this.#newlyExpired.includes(hole)) this.#newlyExpired.splice(this.#newlyExpired.indexOf(hole), 1);
1494
+ }
1495
+ }
1451
1496
  }
1452
1497
  return closed.sort((x, y) => serial32Distance(start, x.block.start) - serial32Distance(start, y.block.start));
1453
1498
  }
@@ -1461,15 +1506,17 @@ var IsoBlockLocator = class _IsoBlockLocator {
1461
1506
  return void 0;
1462
1507
  }
1463
1508
  /**
1464
- * Retire every block and span whose last SS_ID, and every held source,
1465
- * lies more than `retentionBlocks` blocks behind `position`, with the shared
1466
- * lower-bound metric (`isoBlocksBehindLowerBound`) every ISO interleave
1467
- * horizon uses. Returns the retired block starts.
1509
+ * Retire every block and span whose last SS_ID, and every held source
1510
+ * outside a hole, lies more than `retentionBlocks` blocks behind
1511
+ * `position`, with the shared lower-bound metric (`isoBlocksBehindLowerBound`)
1512
+ * every ISO interleave horizon uses; a pending hole so far behind expires
1513
+ * (`takeExpiredHoles`). Returns the retired block starts.
1468
1514
  */
1469
1515
  retireBehind(position) {
1470
1516
  this.observe(position);
1471
1517
  const horizon = this.#retentionBlocks;
1472
1518
  const retired = [];
1519
+ this.#closedBehind.length = 0;
1473
1520
  for (let i = this.#blocks.length - 1; i >= 0; i--) {
1474
1521
  const block = this.#blocks[i];
1475
1522
  const last = serial32Add(block.start, block.length - 1);
@@ -1481,10 +1528,15 @@ var IsoBlockLocator = class _IsoBlockLocator {
1481
1528
  for (let i = this.#holes.length - 1; i >= 0; i--) {
1482
1529
  const hole = this.#holes[i];
1483
1530
  const last = serial32Add(hole.start, hole.length - 1);
1484
- if (isoBlocksBehindLowerBound(last, position, this.#kMax) > horizon) this.#holes.splice(i, 1);
1531
+ if (isoBlocksBehindLowerBound(last, position, this.#kMax) <= horizon) continue;
1532
+ this.#holes.splice(i, 1);
1533
+ this.#expired.push(hole);
1534
+ this.#newlyExpired.push(hole);
1485
1535
  }
1486
1536
  for (const ssId of [...this.#held.keys()]) {
1487
- if (isoBlocksBehindLowerBound(ssId, position, this.#kMax) > horizon) this.#held.delete(ssId);
1537
+ 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))) {
1538
+ this.#held.delete(ssId);
1539
+ }
1488
1540
  }
1489
1541
  return retired;
1490
1542
  }
@@ -1492,6 +1544,9 @@ var IsoBlockLocator = class _IsoBlockLocator {
1492
1544
  reset() {
1493
1545
  this.#blocks.length = 0;
1494
1546
  this.#holes.length = 0;
1547
+ this.#expired.length = 0;
1548
+ this.#newlyExpired.length = 0;
1549
+ this.#closedBehind.length = 0;
1495
1550
  this.#held.clear();
1496
1551
  this.#coverageEnd = void 0;
1497
1552
  this.#newest = void 0;
@@ -2178,6 +2233,10 @@ var FECManager = class _FECManager {
2178
2233
  // 'pending' forever, which would park every later frame of its stream in
2179
2234
  // the in-order release gate. Each entry owns its handle; the callback
2180
2235
  // checks it still owns the key, so a settled or reset key is never touched.
2236
+ /**
2237
+ * Per owed Worker cleanup: the bound on its acknowledgement, when its block
2238
+ * was due, and when its cleanup was asked for (see `onCleanupComplete`).
2239
+ */
2181
2240
  this.#pendingWorkerCleanupBounds = /* @__PURE__ */ new Map();
2182
2241
  // Source symbol count per track-scoped block — marks complete when K symbols arrive.
2183
2242
  this.#blockSourceCounts = /* @__PURE__ */ new Map();
@@ -2194,6 +2253,9 @@ var FECManager = class _FECManager {
2194
2253
  // SSB_length source arrivals and recoverable from SSB_length received
2195
2254
  // symbols; waiting for K fails every one of them.
2196
2255
  this.#blockAuthoredLengths = /* @__PURE__ */ new Map();
2256
+ // Keys whose authored length is a repair-less span's (#openIsoSpan), until
2257
+ // a repair states the block there or the span resolves.
2258
+ this.#isoSpanKeys = /* @__PURE__ */ new Set();
2197
2259
  this.#ssbLengthConflictCount = 0;
2198
2260
  this.#blockRepairRsIds = /* @__PURE__ */ new Map();
2199
2261
  // RSB_length per block (C.5.3 Repair FEC Payload ID): how many repair
@@ -2391,7 +2453,10 @@ var FECManager = class _FECManager {
2391
2453
  // arrives via onBlockRecovered callback.
2392
2454
  this.onBlockStarted = null;
2393
2455
  this.onBlockComplete = null;
2394
- /** A block reached its fail bound. `blockClass` is its class at failure. */
2456
+ /**
2457
+ * A block reached its fail bound. `blockClass` is its class at failure.
2458
+ * `span` is set when what failed is a repair-less span starting at `sbn`.
2459
+ */
2395
2460
  this.onBlockFailed = null;
2396
2461
  /**
2397
2462
  * Fired once per stream when its Worker decoder stops being routable.
@@ -2433,6 +2498,8 @@ var FECManager = class _FECManager {
2433
2498
  * for it, which it must be.
2434
2499
  */
2435
2500
  this.#onUndelimitedLoss = null;
2501
+ /** Set with the loss handler: true takes an expired hole to close (`closeIsoHoles`). */
2502
+ this.#onIsoHoleExpired = null;
2436
2503
  /** A block every symbol of which vanished was created by SBN evidence. */
2437
2504
  this.onBlockVanished = null;
2438
2505
  /** A block reached a terminal state (completed, recovered, or failed). */
@@ -2477,6 +2544,7 @@ var FECManager = class _FECManager {
2477
2544
  #blockSourceEsis;
2478
2545
  #blockRebuiltEsis;
2479
2546
  #blockAuthoredLengths;
2547
+ #isoSpanKeys;
2480
2548
  #ssbLengthConflictCount;
2481
2549
  #blockRepairRsIds;
2482
2550
  #blockRsbLengths;
@@ -2543,6 +2611,7 @@ var FECManager = class _FECManager {
2543
2611
  #decoderUnavailableEvents;
2544
2612
  #repairSymbolSizeMismatchCount;
2545
2613
  #onUndelimitedLoss;
2614
+ #onIsoHoleExpired;
2546
2615
  #encodeTrackName(catalogTrackName) {
2547
2616
  let encoded = this.#encodedTrackNames.get(catalogTrackName);
2548
2617
  if (encoded === void 0) {
@@ -3014,7 +3083,10 @@ var FECManager = class _FECManager {
3014
3083
  timer.handle = null;
3015
3084
  this.#requestRepair(sbn, packetId, catalogTrackName);
3016
3085
  };
3017
- timer.handle = setTimeout(onFire, Math.max(0, deadlineMs - performance.now()));
3086
+ timer.handle = setTimeout(
3087
+ onFire,
3088
+ this.#receiptAtMs === void 0 ? Math.max(0, deadlineMs - performance.now()) : this.#receiptTimerDelayMs(deadlineMs, 0)
3089
+ );
3018
3090
  this.#repairDeadlines.set(key, timer);
3019
3091
  }
3020
3092
  #requestRepair(sbn, packetId, catalogTrackName, settledByOrder = false) {
@@ -3158,8 +3230,14 @@ var FECManager = class _FECManager {
3158
3230
  * Vanished blocks are not enumerated here: their starts are unknown until a
3159
3231
  * repair states them. The SS_IDs between two stated blocks become one
3160
3232
  * repair-less span when the later block's repair arrives (placeIsoRepair).
3233
+ *
3234
+ * A `held` source arrived before its block was known and is fed only now
3235
+ * that a repair, a span or a closed hole placed it: how far its block start
3236
+ * then lies behind the frontier measures how long the receiver held it, not
3237
+ * where the stream was, so only its SS_ID and packet_sequence_number judge
3238
+ * it (BLO-52085).
3161
3239
  */
3162
- #noteSbnSeen(sbn, packetId, catalogTrackName, symbol, packetSequenceNumber, ssId = sbn) {
3240
+ #noteSbnSeen(sbn, packetId, catalogTrackName, symbol, packetSequenceNumber, ssId = sbn, held = false) {
3163
3241
  const streamKey = this.#streamKey(packetId, catalogTrackName);
3164
3242
  const now = performance.now();
3165
3243
  const previous = (symbol === "source" ? this.#sourceFrontiers : this.#highestSbn).get(streamKey);
@@ -3198,7 +3276,7 @@ var FECManager = class _FECManager {
3198
3276
  return sent <= 2147483647 && serial32Distance(from.ssId, to.ssId) <= sent + config.k;
3199
3277
  };
3200
3278
  if (!this.#blockPrecedes(previous.sbn, sbn)) {
3201
- if (symbol === "source" && window2 !== 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))) {
3279
+ if (symbol === "source" && window2 !== 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))) {
3202
3280
  discontinuity();
3203
3281
  return;
3204
3282
  }
@@ -3460,17 +3538,21 @@ var FECManager = class _FECManager {
3460
3538
  this.#blockWaitDeadlinesMs.delete(key);
3461
3539
  this.#repairHoldUntilMs.delete(key);
3462
3540
  this.#traceLifecycle("timeout-queued", sbn, packetId, catalogTrackName);
3463
- this.#finalizeExpiredBlock(sbn, packetId, catalogTrackName);
3541
+ this.#finalizeExpiredBlock(sbn, packetId, catalogTrackName, currentDeadlineMs ?? firedAtMs);
3464
3542
  };
3465
- timer.handle = setTimeout(onFire, Math.max(1, Math.ceil(deadlineMs - nowMs)));
3543
+ timer.handle = setTimeout(
3544
+ onFire,
3545
+ this.#receiptAtMs === void 0 ? Math.max(1, Math.ceil(deadlineMs - nowMs)) : this.#receiptTimerDelayMs(deadlineMs, 1)
3546
+ );
3466
3547
  this.#activeBlocks.set(key, timer);
3467
3548
  this.#indexBlockKey(key, sbn, packetId);
3468
3549
  }
3469
3550
  /**
3470
3551
  * Hand a block whose delivery is over to its decoder for finalization: the
3471
- * datagram deadline fired, or the ordered relay moved past it.
3552
+ * datagram deadline fired, or the ordered relay moved past it. `dueAtMs`
3553
+ * is when it was due: its deadline, or now.
3472
3554
  */
3473
- #finalizeExpiredBlock(sbn, packetId, catalogTrackName) {
3555
+ #finalizeExpiredBlock(sbn, packetId, catalogTrackName, dueAtMs = performance.now()) {
3474
3556
  const key = this.#blockKey(sbn, packetId, catalogTrackName);
3475
3557
  if (this.#materializedBlocks.has(key) && !this.#blockPathCounts.has(key)) {
3476
3558
  this.#failBlock(sbn, packetId, catalogTrackName);
@@ -3486,7 +3568,7 @@ var FECManager = class _FECManager {
3486
3568
  if (workerClient) {
3487
3569
  this.#pendingWorkerCleanups.add(key);
3488
3570
  this.#pendingWorkerCleanupClients.set(key, workerClient);
3489
- this.#boundOwedCleanup(key, packetId, workerClient);
3571
+ this.#boundOwedCleanup(key, packetId, workerClient, dueAtMs);
3490
3572
  workerClient.cleanup(sbn);
3491
3573
  } else {
3492
3574
  const decoder = this.#decoderForPacket(packetId, catalogTrackName)?.client;
@@ -3800,6 +3882,7 @@ var FECManager = class _FECManager {
3800
3882
  resetMap(this.#blockSourceEsis);
3801
3883
  resetMap(this.#blockRebuiltEsis);
3802
3884
  resetMap(this.#blockAuthoredLengths);
3885
+ resetSet(this.#isoSpanKeys);
3803
3886
  resetMap(this.#blockRepairRsIds);
3804
3887
  resetMap(this.#blockRsbLengths);
3805
3888
  resetMap(this.#heldLayerStates);
@@ -3925,6 +4008,7 @@ var FECManager = class _FECManager {
3925
4008
  dropMap(this.#blockSourceEsis);
3926
4009
  dropMap(this.#blockRebuiltEsis);
3927
4010
  dropMap(this.#blockAuthoredLengths);
4011
+ dropSet(this.#isoSpanKeys);
3928
4012
  dropMap(this.#blockRepairRsIds);
3929
4013
  dropMap(this.#blockRsbLengths);
3930
4014
  dropMap(this.#blockPathCounts);
@@ -4267,8 +4351,9 @@ var FECManager = class _FECManager {
4267
4351
  return true;
4268
4352
  }
4269
4353
  /**
4270
- * Drop a block's repair hold: its FETCH ended without delivering. The block
4271
- * falls back to its multicast wait, which may already have passed.
4354
+ * Drop a block's repair hold: its FETCH ended without delivering, or brought
4355
+ * back only a repair that cannot complete it. The block falls back to its
4356
+ * multicast wait, which may already have passed.
4272
4357
  */
4273
4358
  releaseRepairHold(sbn, packetId, catalogTrackName) {
4274
4359
  const key = this.#blockKey(sbn, packetId, catalogTrackName);
@@ -4406,6 +4491,7 @@ var FECManager = class _FECManager {
4406
4491
  const locator = this.#growingIsoLocatorFor(packetId, catalogTrackName);
4407
4492
  const evictedBefore = locator.heldEvicted;
4408
4493
  const held = locator.hold(ssId, source);
4494
+ this.#settleExpiredIsoHoles(locator, packetId, catalogTrackName);
4409
4495
  if (held && source.source === "multicast") this.#noteMulticastSource(locator, ssId, packetId, catalogTrackName);
4410
4496
  const evicted = locator.heldEvicted - evictedBefore;
4411
4497
  if (evicted > 0) {
@@ -4427,17 +4513,20 @@ var FECManager = class _FECManager {
4427
4513
  * contradicts a known block is refused (counted) and must be dropped.
4428
4514
  */
4429
4515
  placeIsoRepair(packetId, catalogTrackName, ssStart, ssbLength) {
4430
- const placement = this.#growingIsoLocatorFor(packetId, catalogTrackName).placeRepair(ssStart, ssbLength);
4516
+ const locator = this.#growingIsoLocatorFor(packetId, catalogTrackName);
4517
+ const placement = locator.placeRepair(ssStart, ssbLength);
4518
+ this.#settleExpiredIsoHoles(locator, packetId, catalogTrackName);
4431
4519
  if (placement.status !== "placed") {
4432
4520
  this.#isoRepairRefusedCount++;
4433
4521
  if (this.#isoRepairRefusedCount <= 5 || this.#isoRepairRefusedCount % 100 === 0) {
4434
4522
  console.warn(`[FECManager] packetId=${packetId} iso-ssbg1-v1 repair refused (${placement.status}): ${placement.reason}`);
4435
4523
  }
4524
+ if (placement.ofSpan !== void 0) this.releaseRepairHold(placement.ofSpan.start, packetId, catalogTrackName);
4436
4525
  return placement;
4437
4526
  }
4527
+ this.#isoSpanKeys.delete(this.#blockKey(placement.block.start, packetId, catalogTrackName));
4438
4528
  if (placement.span !== void 0) this.#openIsoSpan(placement.span, packetId, catalogTrackName);
4439
4529
  if (placement.hole !== void 0) {
4440
- const locator = this.#isoLocatorFor(packetId, catalogTrackName);
4441
4530
  const hole = placement.hole.block;
4442
4531
  const previous = locator.blockEndingAt(hole.start);
4443
4532
  const loss = {
@@ -4458,17 +4547,29 @@ var FECManager = class _FECManager {
4458
4547
  * `handler`, instead of a repair-less span; null restores spans. Set by the
4459
4548
  * range repair scheduler; applies to every stream from its next gap. The
4460
4549
  * handler and the deferral are one setting, so no hole is ever created with
4461
- * nobody to close it.
4550
+ * nobody to close it. `onExpired` is told of each pending hole the stream
4551
+ * runs past the locator's horizon (BLO-52085): returning true, it closes
4552
+ * the hole (`closeIsoHoles`), so the span it leaves still fails and is
4553
+ * reported; otherwise the hole is discarded with its sources.
4462
4554
  */
4463
- setUndelimitedLossHandler(handler) {
4555
+ setUndelimitedLossHandler(handler, onExpired = null) {
4464
4556
  this.#onUndelimitedLoss = handler;
4557
+ this.#onIsoHoleExpired = handler === null ? null : onExpired;
4465
4558
  this.#deferIsoGaps = handler !== null;
4466
4559
  for (const locator of this.#isoLocators.values()) locator.deferIncompleteGaps = this.#deferIsoGaps;
4467
4560
  }
4561
+ /** Hand each hole the locator's horizon passed to its owner, or discard it (setUndelimitedLossHandler). */
4562
+ #settleExpiredIsoHoles(locator, packetId, catalogTrackName) {
4563
+ for (const hole of locator.takeExpiredHoles()) {
4564
+ const expired = { packetId, catalogTrackName, holeStart: hole.start, holeLength: hole.length };
4565
+ if (this.#onIsoHoleExpired?.(expired) !== true) locator.closeHolesWithin(hole.start, hole.length);
4566
+ }
4567
+ }
4468
4568
  /**
4469
- * The range repair for a hole ended: every hole still pending inside
4470
- * `[start, start + length)` becomes a repair-less span. Returns those spans
4471
- * with the held sources they placed, for the caller to feed (MmtpRouter).
4569
+ * The range repair for a hole ended: every hole inside `[start, start +
4570
+ * length)`, pending or expired, becomes a repair-less span. Returns those
4571
+ * spans with the held sources they placed, for the caller to feed
4572
+ * (MmtpRouter).
4472
4573
  */
4473
4574
  closeIsoHoles(packetId, catalogTrackName, start, length) {
4474
4575
  const locator = this.#isoLocators.get(this.#streamKey(packetId, catalogTrackName));
@@ -4487,7 +4588,10 @@ var FECManager = class _FECManager {
4487
4588
  */
4488
4589
  #openIsoSpan(span, packetId, catalogTrackName) {
4489
4590
  const key = this.#blockKey(span.block.start, packetId, catalogTrackName);
4490
- if (!this.#blockAuthoredLengths.has(key)) this.#blockAuthoredLengths.set(key, span.block.length);
4591
+ if (!this.#blockAuthoredLengths.has(key)) {
4592
+ this.#blockAuthoredLengths.set(key, span.block.length);
4593
+ this.#isoSpanKeys.add(key);
4594
+ }
4491
4595
  if (span.placed.length === 0) this.#materializeBlock(span.block.start, packetId, catalogTrackName, true);
4492
4596
  }
4493
4597
  /**
@@ -4828,11 +4932,14 @@ var FECManager = class _FECManager {
4828
4932
  });
4829
4933
  client.onCleanupComplete((sbn) => {
4830
4934
  if (this.#releasedWorkerClients.has(client)) return;
4831
- const cleanupKeys = [...this.#pendingWorkerCleanups].filter((key) => {
4935
+ const owed = [...this.#pendingWorkerCleanups].filter((key) => this.#pendingWorkerCleanupClients.get(key) === client);
4936
+ const cleanupKeys = owed.filter((key) => {
4832
4937
  const block = this.#parseBlockKey(key);
4833
- return block.packetId === packetId && block.sbn === sbn && this.#pendingWorkerCleanupClients.get(key) === client;
4938
+ return block.packetId === packetId && block.sbn === sbn;
4834
4939
  });
4835
- for (const key of cleanupKeys) this.#settleOwedCleanup(key, "cleanup-ack");
4940
+ const askedAtMs = Math.max(...cleanupKeys.map((key) => this.#pendingWorkerCleanupBounds.get(key)?.askedAtMs ?? -Infinity));
4941
+ const due = owed.filter((key) => !cleanupKeys.includes(key) && (this.#pendingWorkerCleanupBounds.get(key)?.dueAtMs ?? Infinity) <= askedAtMs);
4942
+ for (const key of [...cleanupKeys, ...due]) this.#settleOwedCleanup(key, "cleanup-ack");
4836
4943
  });
4837
4944
  client.onError((msg, code) => {
4838
4945
  const requestedKind = code ?? msg;
@@ -4902,9 +5009,11 @@ var FECManager = class _FECManager {
4902
5009
  * fail deadline already passed, so failing it is the decision that deadline
4903
5010
  * made. A late ack then finds nothing owed and is a no-op.
4904
5011
  */
4905
- #boundOwedCleanup(key, packetId, client) {
5012
+ #boundOwedCleanup(key, packetId, client, dueAtMs) {
4906
5013
  this.#clearOwedCleanupBound(key);
4907
5014
  const bound = {
5015
+ dueAtMs,
5016
+ askedAtMs: performance.now(),
4908
5017
  handle: setTimeout(() => {
4909
5018
  if (this.#pendingWorkerCleanupBounds.get(key) !== bound) return;
4910
5019
  this.#pendingWorkerCleanupBounds.delete(key);
@@ -5475,12 +5584,13 @@ var FECManager = class _FECManager {
5475
5584
  * the inactivity deadline on each symbol, records the symbol's in-band
5476
5585
  * classification evidence, and marks the block clean when all of its
5477
5586
  * authored source symbols arrived: the SSB_length a repair symbol declared,
5478
- * otherwise K.
5587
+ * otherwise K. `held` marks a source held unplaced since it arrived and
5588
+ * placed only now (see #noteSbnSeen).
5479
5589
  *
5480
5590
  * Throws for a packetId that was never configured, so a classified block
5481
5591
  * never reads the manager-global K or timeout.
5482
5592
  */
5483
- trackBlock(sbn, packetId, catalogTrackName, esi, evidence) {
5593
+ trackBlock(sbn, packetId, catalogTrackName, esi, evidence, held = false) {
5484
5594
  const trackConfig = this.#trackConfigs.get(packetId);
5485
5595
  if (trackConfig === void 0) {
5486
5596
  throw new Error(
@@ -5496,7 +5606,8 @@ var FECManager = class _FECManager {
5496
5606
  catalogTrackName,
5497
5607
  "source",
5498
5608
  evidence.packetSequenceNumber,
5499
- Number.isInteger(esi) ? sbn + esi >>> 0 : sbn
5609
+ Number.isInteger(esi) ? sbn + esi >>> 0 : sbn,
5610
+ held
5500
5611
  );
5501
5612
  const authoredLength = this.#blockAuthoredLengths.get(key) ?? k;
5502
5613
  if (Number.isInteger(esi) && esi >= authoredLength) {
@@ -5551,6 +5662,7 @@ var FECManager = class _FECManager {
5551
5662
  this.#blockSourceEsis.delete(key);
5552
5663
  this.#blockRebuiltEsis.delete(key);
5553
5664
  this.#blockAuthoredLengths.delete(key);
5665
+ this.#isoSpanKeys.delete(key);
5554
5666
  this.#blockRepairRsIds.delete(key);
5555
5667
  this.#blockRsbLengths.delete(key);
5556
5668
  this.#markTerminal(key, sbn, packetId, true);
@@ -5581,6 +5693,19 @@ var FECManager = class _FECManager {
5581
5693
  #receiptNow() {
5582
5694
  return this.#receiptAtMs ?? performance.now();
5583
5695
  }
5696
+ /**
5697
+ * The whole-ms delay of a timer for `deadlineMs` armed inside a receipt
5698
+ * scope: counted from the receipt time, as the timer of a symbol fed when
5699
+ * it was received was, less the whole ms since. So it fires on the tick
5700
+ * that timer would have, never later than a delay counted from now, and
5701
+ * its handler still re-arms a timer that fires before its deadline.
5702
+ * Counted from now, a deadline set from the receipt time is rounded up
5703
+ * from another fraction of a ms (BLO-52085).
5704
+ */
5705
+ #receiptTimerDelayMs(deadlineMs, minMs) {
5706
+ const receivedAtMs = this.#receiptAtMs;
5707
+ return Math.max(minMs, Math.ceil(deadlineMs - receivedAtMs) - Math.ceil(performance.now() - receivedAtMs));
5708
+ }
5584
5709
  /**
5585
5710
  * A held packet admitted late arms from its (earlier) receipt time; that
5586
5711
  * never pulls in a deadline later activity already armed. Outside a
@@ -5629,6 +5754,7 @@ var FECManager = class _FECManager {
5629
5754
  }
5630
5755
  #failBlock(sbn, packetId, catalogTrackName) {
5631
5756
  const key = this.#blockKey(sbn, packetId, catalogTrackName);
5757
+ const span = this.#isoSpanKeys.delete(key) ? { length: this.#blockAuthoredLengths.get(key) ?? 0, sources: this.#blockSourceCounts.get(key) ?? 0 } : void 0;
5632
5758
  this.#traceLifecycle("failed", sbn, packetId, catalogTrackName);
5633
5759
  this.#clearRepairDeadline(key);
5634
5760
  this.#clearBlockTimer(key);
@@ -5648,7 +5774,8 @@ var FECManager = class _FECManager {
5648
5774
  this.#markPathBlockComplete(sbn, packetId, catalogTrackName);
5649
5775
  this.#fecStats.blocksFailed++;
5650
5776
  this.#statsForPacket(packetId).blocksFailed++;
5651
- this.onBlockFailed?.(sbn, packetId, catalogTrackName, blockClass);
5777
+ if (span === void 0) this.onBlockFailed?.(sbn, packetId, catalogTrackName, blockClass);
5778
+ else this.onBlockFailed?.(sbn, packetId, catalogTrackName, blockClass, span);
5652
5779
  this.onBlockResolved?.(sbn, packetId, catalogTrackName);
5653
5780
  }
5654
5781
  /**
@@ -5679,6 +5806,7 @@ var FECManager = class _FECManager {
5679
5806
  this.#blockSourceEsis.delete(key);
5680
5807
  this.#blockRebuiltEsis.delete(key);
5681
5808
  this.#blockAuthoredLengths.delete(key);
5809
+ this.#isoSpanKeys.delete(key);
5682
5810
  this.#blockRepairRsIds.delete(key);
5683
5811
  this.#blockRsbLengths.delete(key);
5684
5812
  this.#clearBlockTimer(key);
@@ -5771,6 +5899,7 @@ var FECManager = class _FECManager {
5771
5899
  this.#blockSourceEsis.clear();
5772
5900
  this.#blockRebuiltEsis.clear();
5773
5901
  this.#blockAuthoredLengths.clear();
5902
+ this.#isoSpanKeys.clear();
5774
5903
  this.#blockRepairRsIds.clear();
5775
5904
  this.#blockRsbLengths.clear();
5776
5905
  this.#heldLayerStates.clear();
@@ -13683,6 +13812,9 @@ function authoredGap(before, after) {
13683
13812
  function ssIdAfter(a, b) {
13684
13813
  return (a - b | 0) > 0;
13685
13814
  }
13815
+ function holdsRebuilt(stream) {
13816
+ return stream.buf.some(({ meta }) => meta.fecSbnFirst !== void 0);
13817
+ }
13686
13818
  var FecReleaseSequencer = class {
13687
13819
  #streams = /* @__PURE__ */ new Map();
13688
13820
  #gate;
@@ -13745,6 +13877,7 @@ var FecReleaseSequencer = class {
13745
13877
  stream.lastSbn = void 0;
13746
13878
  stream.discontinuity = false;
13747
13879
  stream.rapSsId = void 0;
13880
+ stream.sourced = false;
13748
13881
  }
13749
13882
  if (stream.last !== null && compareFrameOrder(frame, stream.last) <= 0) {
13750
13883
  this.#recordStat("droppedStale");
@@ -13771,6 +13904,7 @@ var FecReleaseSequencer = class {
13771
13904
  if (compareFrameOrder(stream.buf[mid], frame) < 0) lo = mid + 1;
13772
13905
  else hi = mid;
13773
13906
  }
13907
+ if (stream.buf[lo]?.passed === true) frame.passed = true;
13774
13908
  stream.buf.splice(lo, 0, frame);
13775
13909
  this.#drainUnlessDeferred(stream);
13776
13910
  }
@@ -13806,6 +13940,41 @@ var FecReleaseSequencer = class {
13806
13940
  }
13807
13941
  return false;
13808
13942
  }
13943
+ /**
13944
+ * These streams' sink is being replaced by one that has seen no source
13945
+ * frame yet. First, from a stream that holds a frame the overlay rebuilt,
13946
+ * release to the sink being replaced the frames the unanchored path had
13947
+ * already released to it (#cursorPassed): there they waited in its queue,
13948
+ * which its replacement flushes. A stream that holds none is not drained:
13949
+ * it holds nothing the anchor kept back, and a drain would still force out
13950
+ * a frame held past the hold limit, which before the anchor waited for the
13951
+ * next push. Then treat the streams as unsourced until a source frame is
13952
+ * released.
13953
+ */
13954
+ unsource(packetId, catalogTrackName) {
13955
+ for (const stream of this.#streams.values()) {
13956
+ if (stream.packetId === packetId && (catalogTrackName === void 0 || stream.catalogTrackName === catalogTrackName)) {
13957
+ if (stream.draining === 0 && holdsRebuilt(stream)) {
13958
+ if (this.#deferDepth > 0) this.#deferredStreams.add(stream);
13959
+ else this.#drain(stream, true);
13960
+ }
13961
+ stream.sourced = false;
13962
+ }
13963
+ }
13964
+ }
13965
+ /**
13966
+ * Whether a stream of `packetId` holds a frame the keyframe overlay rebuilt
13967
+ * (FrameMeta.fecSbnFirst). Without `catalogTrackName`, every stream of the
13968
+ * packetId.
13969
+ */
13970
+ holdsRebuilt(packetId, catalogTrackName) {
13971
+ for (const stream of this.#streams.values()) {
13972
+ if (stream.packetId !== packetId) continue;
13973
+ if (catalogTrackName !== void 0 && stream.catalogTrackName !== catalogTrackName) continue;
13974
+ if (holdsRebuilt(stream)) return true;
13975
+ }
13976
+ return false;
13977
+ }
13809
13978
  /** Re-evaluate held gaps after FEC state changed. */
13810
13979
  poke(streamKey) {
13811
13980
  if (streamKey !== void 0) {
@@ -13897,16 +14066,32 @@ var FecReleaseSequencer = class {
13897
14066
  last: null,
13898
14067
  lastSbn: void 0,
13899
14068
  discontinuity: false,
13900
- rapSsId: void 0
14069
+ rapSsId: void 0,
14070
+ sourced: false,
14071
+ draining: 0
13901
14072
  };
13902
14073
  this.#streams.set(streamKey, stream);
13903
14074
  }
13904
14075
  return stream;
13905
14076
  }
13906
- #drain(stream) {
14077
+ /** `asUnanchored`: release overlay-rebuilt frames when unanchored did (#cursorPassed). */
14078
+ #drain(stream, asUnanchored = false) {
14079
+ stream.draining++;
14080
+ try {
14081
+ this.#drainLoop(stream, asUnanchored);
14082
+ } finally {
14083
+ stream.draining--;
14084
+ }
14085
+ }
14086
+ #drainLoop(stream, asUnanchored) {
13907
14087
  while (stream.buf.length > 0 && stream.last !== null) {
13908
14088
  const head = stream.buf[0];
13909
14089
  const last = stream.last;
14090
+ if (compareFrameOrder(head, last) <= 0) {
14091
+ stream.buf.shift();
14092
+ this.#release(stream, head, void 0);
14093
+ continue;
14094
+ }
13910
14095
  if (stream.discontinuity) {
13911
14096
  stream.discontinuity = false;
13912
14097
  stream.buf.shift();
@@ -13920,9 +14105,10 @@ var FecReleaseSequencer = class {
13920
14105
  }
13921
14106
  const headSbn = head.meta.fecSbn;
13922
14107
  const from = stream.lastSbn ?? headSbn;
13923
- const to = headSbn ?? stream.lastSbn;
14108
+ const to = head.meta.fecSbnFirst ?? headSbn ?? stream.lastSbn;
13924
14109
  let lost = [];
13925
14110
  const unanchored = from === void 0 && to === void 0;
14111
+ let unresolved = false;
13926
14112
  if (from !== void 0 && to !== void 0) {
13927
14113
  const resolved = this.#gate.resolvedLoss(
13928
14114
  stream.packetId,
@@ -13931,7 +14117,8 @@ var FecReleaseSequencer = class {
13931
14117
  Math.max(from, to),
13932
14118
  from
13933
14119
  );
13934
- if (resolved === null) {
14120
+ unresolved = resolved === null;
14121
+ if (unresolved && !this.#cursorPassed(stream, asUnanchored)) {
13935
14122
  const heldMs = performance.now() - head.arrivedAtMs;
13936
14123
  if (heldMs <= this.#gate.holdLimitMs(stream.packetId, stream.catalogTrackName)) return;
13937
14124
  this.#recordStat("forcedWindowFlushes");
@@ -13939,22 +14126,56 @@ var FecReleaseSequencer = class {
13939
14126
  this.#release(stream, head, this.#gate.decide(last, head, [], true));
13940
14127
  continue;
13941
14128
  }
13942
- lost = resolved;
14129
+ lost = resolved ?? [];
13943
14130
  }
13944
14131
  stream.buf.shift();
13945
14132
  this.#release(
13946
14133
  stream,
13947
14134
  head,
13948
- lost.length > 0 || unanchored || authoredGap(last.meta, head.meta) ? this.#gate.decide(last, head, lost, false) : void 0
14135
+ lost.length > 0 || unanchored || unresolved || authoredGap(last.meta, head.meta) ? this.#gate.decide(last, head, lost, false) : void 0
13949
14136
  );
13950
14137
  }
13951
14138
  }
14139
+ /**
14140
+ * An overlay-rebuilt frame (fecSbnFirst) waits no longer than the BLO-53342
14141
+ * source cursor holds it unanchored, and neither does a later frame behind
14142
+ * it. Unanchored, a frame that waits on no block (fecSbnFirst set, or no
14143
+ * fecSbn) left the gate once every frame ahead of it had: mark it `passed`,
14144
+ * like each frame that arrived in front of it since (stale there). The head
14145
+ * then goes, decided as it would be unanchored, once a frame the base path
14146
+ * delivered (a source frame, or one base FEC recovered) is buffered after a
14147
+ * marked one: the base path has passed that one, so a frame still missing
14148
+ * before it can only come from a repair. It also goes while no source frame
14149
+ * has been released, when the direct path shows a recovered frame at once.
14150
+ * And it goes at once while a staged switch decides on when frames arrive
14151
+ * (FecReleaseGate.releaseUnanchored), and when unsource releases into a
14152
+ * sink being replaced: unanchored, it had already reached the sink.
14153
+ * The scan also writes: the `passed` marks it sets stay on the frames,
14154
+ * and push marks a frame it inserts in front of a marked one from them.
14155
+ */
14156
+ #cursorPassed(stream, asUnanchored) {
14157
+ let at = -1;
14158
+ for (let i = 0; i < stream.buf.length; i++) {
14159
+ const frame = stream.buf[i];
14160
+ if (frame.meta.fecSbnFirst !== void 0 || frame.meta.fecSbn === void 0) {
14161
+ frame.passed = true;
14162
+ if (at < 0) at = i;
14163
+ } else if (frame.passed !== true) break;
14164
+ }
14165
+ if (at < 0) return false;
14166
+ return asUnanchored || !stream.sourced || this.#gate.releaseUnanchored?.(stream.packetId, stream.catalogTrackName) === true || stream.buf.some(({ meta }, i) => i > at && meta.fecSbnFirst === void 0 && (meta.recovered !== true || meta.fecSbn !== void 0));
14167
+ }
13952
14168
  #release(stream, frame, decision) {
14169
+ const stale = stream.last !== null && compareFrameOrder(frame, stream.last) <= 0;
14170
+ if (stale) this.#recordStat("droppedStale");
13953
14171
  if (decision) frame.meta.gapBefore = decision;
13954
14172
  stream.last = frame;
13955
- if (frame.meta.fecSbn !== void 0) stream.lastSbn = frame.meta.fecSbn;
14173
+ if (frame.meta.fecSbn !== void 0 && (frame.meta.fecSbnFirst === void 0 || stream.lastSbn !== void 0)) {
14174
+ stream.lastSbn = frame.meta.fecSbn;
14175
+ }
14176
+ if (frame.meta.recovered !== true) stream.sourced = true;
13956
14177
  this.#gate.onReleased?.(frame.data, frame.meta);
13957
- this.#emit(frame.data, frame.meta);
14178
+ if (!stale) this.#emit(frame.data, frame.meta);
13958
14179
  }
13959
14180
  };
13960
14181
  var FMPU_BOX_TYPES = /* @__PURE__ */ new Set(["ftyp", "moov", "moof"]);
@@ -14280,6 +14501,14 @@ var MmtpContainerParser = class _MmtpContainerParser {
14280
14501
  fecReleaseRapAbove(packetId, catalogTrackName, ssId) {
14281
14502
  return this.#fecSequencer?.rapAbove(packetId, catalogTrackName, ssId) ?? false;
14282
14503
  }
14504
+ /** See FecReleaseSequencer.unsource. */
14505
+ unsourceFecRelease(packetId, catalogTrackName) {
14506
+ this.#fecSequencer?.unsource(packetId, catalogTrackName);
14507
+ }
14508
+ /** See FecReleaseSequencer.holdsRebuilt. */
14509
+ fecReleaseHoldsRebuilt(packetId, catalogTrackName) {
14510
+ return this.#fecSequencer?.holdsRebuilt(packetId, catalogTrackName) ?? false;
14511
+ }
14283
14512
  /** Re-evaluate held gaps after FEC block state changed. */
14284
14513
  pokeFecRelease(packetId, catalogTrackName) {
14285
14514
  if (!this.#fecSequencer) return;
@@ -14606,7 +14835,8 @@ var MmtpContainerParser = class _MmtpContainerParser {
14606
14835
  info,
14607
14836
  resolver,
14608
14837
  options?.recovered === true,
14609
- options?.fecSbn
14838
+ options?.fecSbn,
14839
+ options?.fecSbnFirst
14610
14840
  );
14611
14841
  if (deferred.record === void 0) this.#countDeferredOutcome(deferred.reason);
14612
14842
  }
@@ -14699,7 +14929,8 @@ var MmtpContainerParser = class _MmtpContainerParser {
14699
14929
  info,
14700
14930
  resolver,
14701
14931
  options?.recovered === true,
14702
- options?.fecSbn
14932
+ options?.fecSbn,
14933
+ options?.fecSbnFirst
14703
14934
  );
14704
14935
  if (outcome.record !== void 0) return outcome.record;
14705
14936
  return this.#drop(outcome.reason);
@@ -14719,7 +14950,7 @@ var MmtpContainerParser = class _MmtpContainerParser {
14719
14950
  * attribution exists to keep readable. Leaving the choice at the call site
14720
14951
  * is what keeps both true.
14721
14952
  */
14722
- #emitCombinedSample(combined, info, resolver, recovered, fecSbn) {
14953
+ #emitCombinedSample(combined, info, resolver, recovered, fecSbn, fecSbnFirst) {
14723
14954
  const timing = resolver.sampleTiming(
14724
14955
  combined.mpuSequenceNumber,
14725
14956
  combined.movieFragmentSequenceNumber,
@@ -14736,6 +14967,7 @@ var MmtpContainerParser = class _MmtpContainerParser {
14736
14967
  );
14737
14968
  if (!meta) return { reason: "combined-meta-build-failed" };
14738
14969
  if (fecSbn !== void 0) meta.fecSbn = fecSbn;
14970
+ if (fecSbnFirst !== void 0) meta.fecSbnFirst = fecSbnFirst;
14739
14971
  if (!resolver.consumeSample(
14740
14972
  combined.mpuSequenceNumber,
14741
14973
  combined.movieFragmentSequenceNumber,
@@ -18251,6 +18483,7 @@ function unstampedBaseSsId(offset, unstampedOffset, headerPsn) {
18251
18483
  var OVERLAY_ROUTED_LIMIT = 1024;
18252
18484
  var OVERLAY_PENDING_LIMIT = 256;
18253
18485
  var OVERLAY_OFFSET_MPUS = 16;
18486
+ var OVERLAY_REBUILT_LIMIT = 256;
18254
18487
  var OVERLAY_MOQ_HORIZON_BLOCKS = 64;
18255
18488
  function assertStreamIdentity(role, stream) {
18256
18489
  if (stream === void 0 || !Number.isInteger(stream.packetId) || stream.packetId <= MMTP_SIGNALING_PACKET_ID || typeof stream.catalogTrackName !== "string" || stream.catalogTrackName.length === 0) {
@@ -18427,6 +18660,17 @@ var MmtpRouter = class {
18427
18660
  * an MPU no member of which arrived).
18428
18661
  */
18429
18662
  #unstampedSsIdSeqOffsetByStream = /* @__PURE__ */ new Map();
18663
+ /**
18664
+ * The same, shown by the overlay alone, before any base recovery did: a
18665
+ * packet it rebuilt whose base copy carries its protected psn
18666
+ * (#feedOverlaySource), or whose protected psn passes overlayBaseSsId's
18667
+ * test at that offset (#routeOverlayRecovered). A base SS_ID placed by it
18668
+ * anchors the release gate only (BLO-52085 D3): fed to the base, it would
18669
+ * make the base drop a late base copy of a keyframe the overlay never
18670
+ * finished, and lift a block's late-source cut, which nothing before
18671
+ * this offset did.
18672
+ */
18673
+ #overlayUnstampedSsIdSeqOffsetByStream = /* @__PURE__ */ new Map();
18430
18674
  /**
18431
18675
  * Streams whose multicast leg delivered a RAP-flagged source packet that was
18432
18676
  * not parsed, typically because its overlap copy was parsed first
@@ -18704,13 +18948,18 @@ var MmtpRouter = class {
18704
18948
  this.#onDeltaBlockCompleteCb?.(packetId, false);
18705
18949
  }
18706
18950
  };
18707
- this.#fecManager.onBlockFailed = (sbn, packetId, catalogTrackName, blockClass) => {
18951
+ this.#fecManager.onBlockFailed = (sbn, packetId, catalogTrackName, blockClass, span) => {
18708
18952
  this.#mseAssemblerFor(packetId, catalogTrackName)?.failBlock(sbn);
18709
18953
  if (this.#isCatalogTrackUnambiguous(packetId, catalogTrackName) && this.#packetIdRoles.get(packetId) === "delta") {
18710
18954
  this.#onDeltaBlockCompleteCb?.(packetId, true);
18711
18955
  }
18712
- onBlockFailed?.(sbn, packetId, catalogTrackName, blockClass);
18713
- this.#onBlockFailed?.(sbn, packetId, catalogTrackName, blockClass);
18956
+ if (span === void 0) {
18957
+ onBlockFailed?.(sbn, packetId, catalogTrackName, blockClass);
18958
+ this.#onBlockFailed?.(sbn, packetId, catalogTrackName, blockClass);
18959
+ } else {
18960
+ onBlockFailed?.(sbn, packetId, catalogTrackName, blockClass, span);
18961
+ this.#onBlockFailed?.(sbn, packetId, catalogTrackName, blockClass, span);
18962
+ }
18714
18963
  };
18715
18964
  }
18716
18965
  }
@@ -18755,8 +19004,9 @@ var MmtpRouter = class {
18755
19004
  }
18756
19005
  /**
18757
19006
  * FEC-recovered source symbols whose reconstructed header named a
18758
- * packet_sequence_number other than the wire sequence their SS_ID maps to:
18759
- * a hop re-stamped the sequence after the sender protected the packet.
19007
+ * packet_sequence_number other than the wire sequence their SS_ID maps to,
19008
+ * or, rebuilt by the overlay, than their base copy carries: a hop
19009
+ * re-stamped the sequence after the sender protected the packet.
18760
19010
  */
18761
19011
  get restampedRecoveredSequenceCount() {
18762
19012
  return this.#restampedRecoveredSeqs;
@@ -18935,6 +19185,7 @@ var MmtpRouter = class {
18935
19185
  enableMseAssembler(packetId, config, catalogTrackName) {
18936
19186
  const assemblerKey = this.#mseAssemblerKey(packetId, catalogTrackName);
18937
19187
  const previous = this.#mseAssemblers.get(assemblerKey);
19188
+ this.#parser.unsourceFecRelease(packetId, catalogTrackName);
18938
19189
  if (previous) {
18939
19190
  previous.flush();
18940
19191
  previous.dispose();
@@ -19085,7 +19336,8 @@ var MmtpRouter = class {
19085
19336
  fec,
19086
19337
  k,
19087
19338
  offsets: /* @__PURE__ */ new Map(),
19088
- moqBlocks: /* @__PURE__ */ new Set()
19339
+ moqBlocks: /* @__PURE__ */ new Set(),
19340
+ rebuilt: /* @__PURE__ */ new Map()
19089
19341
  };
19090
19342
  this.#overlays.set(repairPacketId, inst);
19091
19343
  return () => {
@@ -19126,6 +19378,7 @@ var MmtpRouter = class {
19126
19378
  inst.last = void 0;
19127
19379
  inst.offsets.clear();
19128
19380
  inst.moqBlocks.clear();
19381
+ inst.rebuilt.clear();
19129
19382
  }
19130
19383
  #overlaysForSource(sourcePacketId, catalogTrackName) {
19131
19384
  return [...this.#overlays.values()].filter(({ leg }) => leg.sourcePacketId === sourcePacketId && (catalogTrackName === void 0 || leg.sourceCatalogTrackName === void 0 || leg.sourceCatalogTrackName === catalogTrackName));
@@ -19178,13 +19431,23 @@ var MmtpRouter = class {
19178
19431
  * base protects: the packet minus its 4-byte SS_ID trailer, extension
19179
19432
  * included (7.1, 7.4.3). `baseSsId` is that trailer's SS_ID, when the base
19180
19433
  * FEC reads it.
19434
+ *
19435
+ * The base copy of a packet an overlay rebuilt with no base SS_ID shows
19436
+ * what a base recovery of it would (#routeProcessedFecSymbols, BLO-52085):
19437
+ * its wire packet_sequence_number equal to the protected one rebuilt shows
19438
+ * the stream unstamped at the SS_ID offset it states, for the release gate
19439
+ * (#overlayUnstampedSsIdSeqOffsetByStream); any other shows a hop that
19440
+ * re-stamps it, which is counted (restampedRecoveredSequenceCount).
19181
19441
  */
19182
19442
  #feedOverlaySource(packetId, catalogTrackName, packet, header, sequenceNumber, rapFlag, source, baseSsId) {
19183
19443
  if (this.#overlays.size === 0) return;
19184
19444
  const ssId = keyframeOverlaySsId(header);
19185
19445
  if (ssId === void 0) return;
19446
+ let rebuiltPsn;
19186
19447
  for (const inst of this.#overlaysForSource(packetId, catalogTrackName)) {
19187
19448
  this.#noteOverlaySsId(inst, ssId, sequenceNumber);
19449
+ rebuiltPsn ??= inst.rebuilt.get(ssId);
19450
+ inst.rebuilt.delete(ssId);
19188
19451
  const identity = MmtpContainerParser.sourcePacketIdentity(packet, header);
19189
19452
  if (identity !== null) {
19190
19453
  inst.offsets.delete(identity.mpuSequenceNumber);
@@ -19214,6 +19477,14 @@ var MmtpRouter = class {
19214
19477
  const recovered = fec.feedSourceSymbol(pid, ssId, symbolData, name);
19215
19478
  if (recovered && !alreadyCompleted) this.#routeOverlayRecovered(inst, coords.sbn, recovered);
19216
19479
  }
19480
+ if (rebuiltPsn === void 0 || this.#classifyPacketId(packetId) === null) return;
19481
+ const streamKey = this.#mediaStreamKey(packetId, catalogTrackName);
19482
+ if (rebuiltPsn !== sequenceNumber) {
19483
+ this.#restampedRecoveredSeqs++;
19484
+ } else if (baseSsId !== void 0) {
19485
+ const offset = this.#ssIdSeqOffsetByStream.get(streamKey);
19486
+ if (offset === baseSsId - sequenceNumber >>> 0) this.#overlayUnstampedSsIdSeqOffsetByStream.set(streamKey, offset);
19487
+ }
19217
19488
  }
19218
19489
  #noteOverlayMoqBlock(inst, blockStart) {
19219
19490
  inst.moqBlocks.add(blockStart);
@@ -19247,11 +19518,16 @@ var MmtpRouter = class {
19247
19518
  }
19248
19519
  /**
19249
19520
  * Route an overlay-recovered block's packets as source media of the
19250
- * overlay's source (7.2: an overlay coordinate yields no base SS_ID). No
19251
- * base-SS_ID derivation runs: no release-gate anchor, no parse ledger, no
19252
- * base re-stamp, no block advance. Each packet's wire sequence is restored
19253
- * from its keyframe's own offset; without one, a stream the base never
19254
- * re-stamped keeps the header's sequence and any other drops the packet.
19521
+ * overlay's source (7.2: an overlay coordinate yields no base SS_ID). A
19522
+ * packet whose base SS_ID is determined independently (overlayBaseSsId,
19523
+ * unstampedBaseSsId) enters the release gate at that SS_ID, as a packet
19524
+ * base FEC recovered does, so it waits behind base frames still in flight,
19525
+ * though not for its own MPU's packets (FrameMeta.fecSbnFirst) and no
19526
+ * longer than unanchored; one whose base SS_ID is undetermined has no
19527
+ * release-gate anchor. No parse ledger, no base re-stamp, no block
19528
+ * advance. Each packet's wire sequence is restored from its keyframe's own
19529
+ * offset; without one, a stream the base never re-stamped keeps the
19530
+ * header's sequence and any other drops the packet.
19255
19531
  */
19256
19532
  #routeOverlayRecovered(inst, blockStart, recovered) {
19257
19533
  const { fec, repairPacketId: pid, streamName: name } = inst;
@@ -19272,40 +19548,65 @@ var MmtpRouter = class {
19272
19548
  const restamped = this.#restampedStreams.has(streamKey);
19273
19549
  const restored = /* @__PURE__ */ new Set();
19274
19550
  const baseSsIds = /* @__PURE__ */ new Map();
19551
+ const gateOnly = /* @__PURE__ */ new Set();
19275
19552
  const symbols = result.symbols.flatMap((sym) => {
19553
+ const overlaySsId = serial32Add(blockStart, sym.index);
19276
19554
  const anchor = inst.offsets.get(sym.fragment.mpuSequenceNumber);
19277
19555
  if (anchor !== void 0) {
19278
- const overlaySsId = serial32Add(blockStart, sym.index);
19279
19556
  const packetSequenceNumber = overlaySsId - anchor.psnOffset >>> 0;
19280
19557
  restored.add(packetSequenceNumber);
19281
19558
  const out = { ...sym, packetSequenceNumber };
19282
19559
  const baseSsId = overlayBaseSsId(anchor, overlaySsId, packetSequenceNumber, sym.packetSequenceNumber);
19283
- if (baseSsId !== void 0) baseSsIds.set(out, baseSsId);
19560
+ if (baseSsId !== void 0) {
19561
+ baseSsIds.set(sym.index, baseSsId);
19562
+ const offset = this.#ssIdSeqOffsetByStream.get(streamKey);
19563
+ if (!this.#sourceSeqReceived(sourcePacketId, packetSequenceNumber, sourceName) && offset === baseSsId - packetSequenceNumber >>> 0) {
19564
+ this.#overlayUnstampedSsIdSeqOffsetByStream.set(streamKey, offset);
19565
+ }
19566
+ }
19284
19567
  return [out];
19285
19568
  }
19286
19569
  if (!restamped) {
19287
- const baseSsId = unstampedBaseSsId(
19288
- this.#ssIdSeqOffsetByStream.get(streamKey),
19289
- this.#unstampedSsIdSeqOffsetByStream.get(streamKey),
19290
- sym.packetSequenceNumber
19291
- );
19292
- if (baseSsId !== void 0) baseSsIds.set(sym, baseSsId);
19570
+ const offset = this.#ssIdSeqOffsetByStream.get(streamKey);
19571
+ const fedSsId = unstampedBaseSsId(offset, this.#unstampedSsIdSeqOffsetByStream.get(streamKey), sym.packetSequenceNumber);
19572
+ const baseSsId = fedSsId ?? unstampedBaseSsId(offset, this.#overlayUnstampedSsIdSeqOffsetByStream.get(streamKey), sym.packetSequenceNumber);
19573
+ if (baseSsId !== void 0) {
19574
+ baseSsIds.set(sym.index, baseSsId);
19575
+ if (fedSsId === void 0) gateOnly.add(sym.index);
19576
+ } else {
19577
+ inst.rebuilt.set(overlaySsId, sym.packetSequenceNumber);
19578
+ if (inst.rebuilt.size > OVERLAY_REBUILT_LIMIT) inst.rebuilt.delete(inst.rebuilt.keys().next().value);
19579
+ }
19293
19580
  return [sym];
19294
19581
  }
19295
19582
  this.#overlayStats.unsequencedDropped++;
19296
19583
  return [];
19297
19584
  });
19298
19585
  this.#overlayStats.recovered++;
19586
+ const mpuFirst = /* @__PURE__ */ new Map();
19587
+ for (const sym of symbols) {
19588
+ const ssId = baseSsIds.get(sym.index);
19589
+ const mpu = sym.fragment.mpuSequenceNumber;
19590
+ const first = mpuFirst.get(mpu);
19591
+ if (ssId !== void 0 && (first === void 0 || serial32Precedes(ssId, first))) mpuFirst.set(mpu, ssId);
19592
+ }
19593
+ const firstSsIds = /* @__PURE__ */ new Map();
19594
+ for (const sym of symbols) {
19595
+ if (baseSsIds.has(sym.index)) firstSsIds.set(sym.index, mpuFirst.get(sym.fragment.mpuSequenceNumber));
19596
+ }
19299
19597
  this.#routeProcessedFecSymbols(blockStart, { ...result, symbols }, sourcePacketId, sourceName, {
19300
- deliveryProvenance: inst.moqBlocks.has(blockStart) ? "mixed" : "multicast"
19598
+ deliveryProvenance: inst.moqBlocks.has(blockStart) ? "mixed" : "multicast",
19599
+ baseSsIds,
19600
+ firstSsIds
19301
19601
  });
19302
19602
  this.#noteOverlayRecoveredRaps(sourcePacketId, sourceName, streamKey, symbols.filter((sym) => restored.has(sym.packetSequenceNumber)));
19303
- this.#feedOverlayRecoveredToBase(inst, blockStart, symbols, baseSsIds);
19603
+ this.#feedOverlayRecoveredToBase(inst, blockStart, symbols, gateOnly.size === 0 ? baseSsIds : new Map([...baseSsIds].filter(([index]) => !gateOnly.has(index))));
19304
19604
  }
19305
19605
  /**
19306
19606
  * Joint decoding (BLO-52085). A packet the overlay rebuilt is a source of
19307
19607
  * its base block too, once its base SS_ID is determined (overlayBaseSsId,
19308
- * from a received member of its MPU, else unstampedBaseSsId),
19608
+ * from a received member of its MPU, else unstampedBaseSsId at an offset a
19609
+ * base recovery showed; `baseSsIds` holds no other),
19309
19610
  * so it is fed to the base decoder as a rebuilt source
19310
19611
  * (FECManager.feedRebuiltSourceSymbol): the base block has one erasure
19311
19612
  * fewer to solve, and its repair can recover the block's other packets.
@@ -19331,7 +19632,7 @@ var MmtpRouter = class {
19331
19632
  const feeds = [];
19332
19633
  for (const sym of symbols) {
19333
19634
  if (duplicates.sourceSeqs.has(sym.packetSequenceNumber)) continue;
19334
- const ssId = baseSsIds.get(sym);
19635
+ const ssId = baseSsIds.get(sym.index);
19335
19636
  if (ssId === void 0) {
19336
19637
  this.#overlayStats.baseUndetermined++;
19337
19638
  continue;
@@ -20060,7 +20361,7 @@ var MmtpRouter = class {
20060
20361
  catalogTrackName
20061
20362
  )) continue;
20062
20363
  if (fec.isTrackConfigured?.(ownerPacketId) !== false) {
20063
- fec.trackBlock(blockStart, ownerPacketId, catalogTrackName, esi, payload.evidence);
20364
+ fec.trackBlock(blockStart, ownerPacketId, catalogTrackName, esi, payload.evidence, true);
20064
20365
  this.#requestFecPoke(ownerPacketId, catalogTrackName);
20065
20366
  }
20066
20367
  if (payload.oversized) continue;
@@ -20148,6 +20449,7 @@ var MmtpRouter = class {
20148
20449
  this.#latestVideoSeqByStream.delete(streamKey);
20149
20450
  this.#ssIdSeqOffsetByStream.delete(streamKey);
20150
20451
  this.#unstampedSsIdSeqOffsetByStream.delete(streamKey);
20452
+ this.#overlayUnstampedSsIdSeqOffsetByStream.delete(streamKey);
20151
20453
  this.#multicastRapTwinByStream.delete(streamKey);
20152
20454
  this.#lastDirectSourceVideoTsByStream.delete(streamKey);
20153
20455
  this.#lastDirectEmittedVideoTsByStream.delete(streamKey);
@@ -20274,6 +20576,12 @@ var MmtpRouter = class {
20274
20576
  * the outgoing stream stops. The hold is bounded by the incoming stream's
20275
20577
  * own RAP cadence, so no timer is involved.
20276
20578
  *
20579
+ * While the switch is staged, the FEC release gate lets a keyframe the
20580
+ * overlay rebuilt reach it, on either side, when the keyframe did before
20581
+ * the gate anchored it (FecReleaseGate.releaseUnanchored): the switch
20582
+ * decides on what each side delivered, and the commit discards the gate's
20583
+ * frames of the outgoing stream.
20584
+ *
20277
20585
  * The commit is one synchronous transition inside the frame path: the
20278
20586
  * outgoing stream's already-admitted frames are released (its queued FEC
20279
20587
  * gap fills only up to the RAP, since recovery can queue a frame ahead of
@@ -20324,6 +20632,9 @@ var MmtpRouter = class {
20324
20632
  droppedIncomingFrames: 0
20325
20633
  };
20326
20634
  this.#staged = state;
20635
+ if (this.#parser.fecReleaseHoldsRebuilt(from.packetId, from.catalogTrackName)) {
20636
+ this.#requestFecPoke(from.packetId, from.catalogTrackName);
20637
+ }
20327
20638
  return {
20328
20639
  cancel: () => {
20329
20640
  if (this.#staged !== state) return false;
@@ -20406,7 +20717,17 @@ var MmtpRouter = class {
20406
20717
  */
20407
20718
  #routeParsedFrame(data, meta) {
20408
20719
  if (this.#classifyPacketId(meta.packetId) === null) return;
20409
- if (this.#staged !== null && this.#interceptSwitchFrame(data, meta)) return;
20720
+ if (this.#staged !== null) {
20721
+ let consumed = false;
20722
+ this.#fecPokeDepth++;
20723
+ try {
20724
+ consumed = this.#interceptSwitchFrame(data, meta);
20725
+ } finally {
20726
+ this.#fecPokeDepth--;
20727
+ this.#flushPendingFecPokes();
20728
+ }
20729
+ if (consumed) return;
20730
+ }
20410
20731
  this.#deliverParsedFrame(data, meta);
20411
20732
  }
20412
20733
  #commitStreamSwitch(state, rap, reason) {
@@ -20558,12 +20879,16 @@ var MmtpRouter = class {
20558
20879
  this.#parser.deferFecRelease(work);
20559
20880
  } finally {
20560
20881
  this.#fecPokeDepth--;
20561
- if (this.#fecPokeDepth === 0 && this.#pendingFecPokes.size > 0 && !this.#disposed) {
20562
- const pokes = [...this.#pendingFecPokes.values()];
20563
- this.#pendingFecPokes.clear();
20564
- for (const poke of pokes) {
20565
- this.#parser.pokeFecRelease(poke.packetId, poke.catalogTrackName);
20566
- }
20882
+ this.#flushPendingFecPokes();
20883
+ }
20884
+ }
20885
+ /** Run the pokes requested while #fecPokeDepth was raised, once it is 0. */
20886
+ #flushPendingFecPokes() {
20887
+ if (this.#fecPokeDepth === 0 && this.#pendingFecPokes.size > 0 && !this.#disposed) {
20888
+ const pokes = [...this.#pendingFecPokes.values()];
20889
+ this.#pendingFecPokes.clear();
20890
+ for (const poke of pokes) {
20891
+ this.#parser.pokeFecRelease(poke.packetId, poke.catalogTrackName);
20567
20892
  }
20568
20893
  }
20569
20894
  }
@@ -20641,6 +20966,14 @@ var MmtpRouter = class {
20641
20966
  onRap: (packetId, catalogTrackName, ssId) => {
20642
20967
  for (const observer of this.#fecRapObservers) observer(packetId, catalogTrackName, ssId);
20643
20968
  },
20969
+ // A staged switch commits on when each side's frames reach it
20970
+ // (#interceptSwitchFrame): both sides' overlay keyframes reach it when
20971
+ // they did unanchored.
20972
+ releaseUnanchored: (packetId, catalogTrackName) => {
20973
+ const staged = this.#staged;
20974
+ const stream = { packetId, ...catalogTrackName === void 0 ? {} : { catalogTrackName } };
20975
+ return staged !== null && (isStream(stream, staged.from) || isStream(stream, staged.to));
20976
+ },
20644
20977
  onReleased: (data, meta) => {
20645
20978
  if (meta.trackType !== "video" || meta.mpuSequenceNumber === void 0) return;
20646
20979
  const timing = meta.authoredTiming;
@@ -21091,7 +21424,8 @@ var MmtpRouter = class {
21091
21424
  source: parserOptions?.source,
21092
21425
  deliveryProvenance: parserOptions?.deliveryProvenance,
21093
21426
  catalogTrackName: parserOptions?.catalogTrackName,
21094
- ...parserOptions?.fecSbn === void 0 ? {} : { fecSbn: parserOptions.fecSbn }
21427
+ ...parserOptions?.fecSbn === void 0 ? {} : { fecSbn: parserOptions.fecSbn },
21428
+ ...parserOptions?.fecSbnFirst === void 0 ? {} : { fecSbnFirst: parserOptions.fecSbnFirst }
21095
21429
  };
21096
21430
  if (parsed) this.#parser.feedParsed(packet, parsed, feedOptions);
21097
21431
  else this.#parser.feed(packet, feedOptions);
@@ -21557,7 +21891,12 @@ var MmtpRouter = class {
21557
21891
  #routeProcessedFecSymbols(blockId, result, ownerPacketId, catalogTrackName, overlay) {
21558
21892
  if (ownerPacketId == null) return;
21559
21893
  if (this.#classifyPacketId(ownerPacketId) === null) return;
21560
- const fecSbnOf = (index) => overlay ? {} : { fecSbn: this.#fecAnchorForRecovered(blockId, index) };
21894
+ const fecSbnOf = (index) => {
21895
+ if (!overlay) return { fecSbn: this.#fecAnchorForRecovered(blockId, index) };
21896
+ const fecSbn = overlay.baseSsIds.get(index);
21897
+ const fecSbnFirst = overlay.firstSsIds.get(index);
21898
+ return fecSbn === void 0 || fecSbnFirst === void 0 ? {} : { fecSbn, fecSbnFirst };
21899
+ };
21561
21900
  const streamKey = this.#mediaStreamKey(ownerPacketId, catalogTrackName);
21562
21901
  const ssIdSeqOffset = overlay ? void 0 : this.#ssIdSeqOffsetByStream.get(streamKey);
21563
21902
  if (ssIdSeqOffset !== void 0) {
@@ -23103,6 +23442,12 @@ function isNonNegativeSafeInteger(value) {
23103
23442
  function isSymbolCounts(value) {
23104
23443
  return isRecord(value) && hasExactKeys(value, ["multicast", "moq"]) && isNonNegativeSafeInteger(value["multicast"]) && isNonNegativeSafeInteger(value["moq"]);
23105
23444
  }
23445
+ function isSpanFailure(value) {
23446
+ if (!isRecord(value) || !hasExactKeys(value, ["length", "sources"])) return false;
23447
+ const length = value["length"];
23448
+ const sources = value["sources"];
23449
+ return Number.isSafeInteger(length) && length > 0 && isNonNegativeSafeInteger(sources) && sources < length;
23450
+ }
23106
23451
  function isNullableNonNegative(value) {
23107
23452
  return value === null || typeof value === "number" && Number.isFinite(value) && value >= 0;
23108
23453
  }
@@ -23122,7 +23467,10 @@ function isRepairLifecycleEvent(value) {
23122
23467
  case "recovered":
23123
23468
  return hasExactKeys(value, ["version", "type", "block", "blockClass", "via", "symbols"]) && isRepairBlockIdentity(block) && BLOCK_CLASSES.has(value["blockClass"]) && (value["via"] === "inband" || value["via"] === "quic") && isSymbolCounts(value["symbols"]);
23124
23469
  case "failed":
23125
- return hasExactKeys(value, ["version", "type", "block", "blockClass", "symbols"]) && isRepairBlockIdentity(block) && BLOCK_CLASSES.has(value["blockClass"]) && isSymbolCounts(value["symbols"]);
23470
+ return hasExactKeys(
23471
+ value,
23472
+ "span" in value ? ["version", "type", "block", "blockClass", "symbols", "span"] : ["version", "type", "block", "blockClass", "symbols"]
23473
+ ) && isRepairBlockIdentity(block) && BLOCK_CLASSES.has(value["blockClass"]) && isSymbolCounts(value["symbols"]) && (!("span" in value) || isSpanFailure(value["span"]));
23126
23474
  case "repair-preempted":
23127
23475
  case "repair-deferred":
23128
23476
  return hasExactKeys(value, ["version", "type", "block"]) && isRepairBlockIdentity(block);
@@ -23388,7 +23736,7 @@ var MulticastManager = class {
23388
23736
  onPublisherRestart: (packetId, catalogTrackName) => {
23389
23737
  this.#resetStreamOutsideRouter(packetId, catalogTrackName);
23390
23738
  },
23391
- onBlockFailed: (sbn, packetId, catalogTrackName, blockClass) => {
23739
+ onBlockFailed: (sbn, packetId, catalogTrackName, blockClass, span) => {
23392
23740
  const pathCount = this.#fec.blockPathCount(
23393
23741
  sbn,
23394
23742
  packetId,
@@ -23406,7 +23754,8 @@ var MulticastManager = class {
23406
23754
  symbols: {
23407
23755
  multicast: pathCount?.multicastSymbols ?? 0,
23408
23756
  moq: pathCount?.moqSymbols ?? 0
23409
- }
23757
+ },
23758
+ ...span === void 0 ? {} : { span }
23410
23759
  });
23411
23760
  },
23412
23761
  onFrameGap: (decision) => {
@@ -24786,10 +25135,12 @@ var MulticastManager = class {
24786
25135
  * Register the range repair scheduler (draft-ramadan-moq-fec §7.5): from now
24787
25136
  * on an iso-ssbg1-v1 gap missing a source and every repair is kept as a
24788
25137
  * pending hole and reported here, and `closeIsoHoles` must be called for it
24789
- * once its range FETCH ends (or none is sent).
25138
+ * once its range FETCH ends (or none is sent). `onExpired` is told of a
25139
+ * pending hole the stream runs past the locator's horizon: returning true,
25140
+ * the caller closes it (`closeIsoHoles`); otherwise it is discarded.
24790
25141
  */
24791
- onUndelimitedLoss(cb) {
24792
- this.#fec.setUndelimitedLossHandler(cb);
25142
+ onUndelimitedLoss(cb, onExpired) {
25143
+ this.#fec.setUndelimitedLossHandler(cb, onExpired ?? null);
24793
25144
  }
24794
25145
  /**
24795
25146
  * Report each stream whose FEC fails closed at runtime (fec-01 6.3.9), e.g.
@@ -25025,7 +25376,8 @@ var MulticastManager = class {
25025
25376
  "stream" in event ? { ...event, stream: Object.freeze({ ...event.stream }) } : {
25026
25377
  ...event,
25027
25378
  block: Object.freeze({ ...event.block }),
25028
- ..."symbols" in event ? { symbols: Object.freeze({ ...event.symbols }) } : {}
25379
+ ..."symbols" in event ? { symbols: Object.freeze({ ...event.symbols }) } : {},
25380
+ ..."span" in event && event.span !== void 0 ? { span: Object.freeze({ ...event.span }) } : {}
25029
25381
  }
25030
25382
  );
25031
25383
  if (this.#logRepairLifecycle) {