@blockcast/mmt-container 2.2.0-main.986a21fc9782 → 2.2.0-main.a5b276c121d4

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 (54) hide show
  1. package/dist/browser-runtime.js +696 -139
  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 +26 -3
  30. package/dist/multicast-manager.d.ts.map +1 -1
  31. package/dist/multicast-manager.js +517 -113
  32. package/dist/multicast-manager.js.map +2 -2
  33. package/dist/multicast-transport.js +2 -2
  34. package/dist/multicast-transport.js.map +2 -2
  35. package/dist/no-modules.js +114 -11
  36. package/dist/no-modules.js.map +2 -2
  37. package/dist/repair-fetch.d.ts +36 -8
  38. package/dist/repair-fetch.d.ts.map +1 -1
  39. package/dist/repair-fetch.js +285 -26
  40. package/dist/repair-fetch.js.map +3 -3
  41. package/dist/track-session.d.ts.map +1 -1
  42. package/dist/track-session.js +1 -0
  43. package/dist/track-session.js.map +2 -2
  44. package/dist/wasm-mmtp-container-parser.js +114 -11
  45. package/dist/wasm-mmtp-container-parser.js.map +2 -2
  46. package/package.json +5 -5
  47. package/src/fec-manager.ts +156 -26
  48. package/src/index.ts +2 -0
  49. package/src/iso-block-locator.ts +114 -31
  50. package/src/mmtp-container-parser.ts +181 -10
  51. package/src/mmtp-router.ts +231 -58
  52. package/src/multicast-manager.ts +118 -14
  53. package/src/repair-fetch.ts +363 -49
  54. package/src/track-session.ts +3 -0
@@ -851,6 +851,17 @@ function readRepairFecPayloadIdFields(mmtpPacket, parsedHeader) {
851
851
  dataOffset: off + REPAIR_FEC_PID_SIZE
852
852
  };
853
853
  }
854
+ function extractRepairFecPayloadId(mmtpPacket) {
855
+ const fields = readRepairFecPayloadIdFields(mmtpPacket);
856
+ if (!fields)
857
+ return null;
858
+ return {
859
+ ssStart: fields.ssStart,
860
+ rsbLength: fields.rsbLength,
861
+ rsId: fields.rsId,
862
+ ssbLength: fields.ssbLength
863
+ };
864
+ }
854
865
  function parseMoqRepairFrame(frame) {
855
866
  return moqRepairFrameFrom(frame, readRepairFecPayloadIdFields(frame));
856
867
  }
@@ -8625,9 +8636,9 @@ async function openSubscription(rx, openedPromise, options, packetIds) {
8625
8636
  } catch (error) {
8626
8637
  throw new MulticastError("subscription-failed", "MulticastReceiver did not open", error);
8627
8638
  }
8628
- if (rx.integrityScheme !== INTEGRITY_SCHEME) {
8639
+ if (opened.integrityScheme !== INTEGRITY_SCHEME) {
8629
8640
  await rx.close();
8630
- throw unsupported(`integrity scheme ${String(rx.integrityScheme)} is not ${INTEGRITY_SCHEME}`);
8641
+ throw unsupported(`integrity scheme ${String(opened.integrityScheme)} is not ${INTEGRITY_SCHEME}`);
8631
8642
  }
8632
8643
  const events = new EventTarget();
8633
8644
  let state = "connected";
@@ -12435,6 +12446,7 @@ var TrackSession = class {
12435
12446
  this.#state = "idle";
12436
12447
  this.#subscription = null;
12437
12448
  this.#disarmFec();
12449
+ this.#unsuppress();
12438
12450
  }
12439
12451
  // -----------------------------------------------------------------------
12440
12452
  // Internal
@@ -12928,6 +12940,15 @@ var IsoBlockLocator = class _IsoBlockLocator {
12928
12940
  this.#heldEvicted = 0;
12929
12941
  /** Pending holes (see `deferIncompleteGaps`): unstated, sources still held. */
12930
12942
  this.#holes = [];
12943
+ /** Holes the horizon passed while pending: sources held until closed. */
12944
+ this.#expired = [];
12945
+ /** Of `#expired`, those `takeExpiredHoles` has not handed out yet. */
12946
+ this.#newlyExpired = [];
12947
+ /**
12948
+ * Spans closed from expired holes: behind the horizon, so kept only for
12949
+ * `locate` while their sources are fed, until the next retirement.
12950
+ */
12951
+ this.#closedBehind = [];
12931
12952
  /**
12932
12953
  * Keep a gap that is missing a source as a pending hole rather than a span,
12933
12954
  * so a repair FETCHed for it (draft-ramadan-moq-fec §7.5) can still state
@@ -12951,6 +12972,9 @@ var IsoBlockLocator = class _IsoBlockLocator {
12951
12972
  #newest;
12952
12973
  #newestSource;
12953
12974
  #holes;
12975
+ #expired;
12976
+ #newlyExpired;
12977
+ #closedBehind;
12954
12978
  static #checkedRetention(retentionBlocks) {
12955
12979
  if (!Number.isSafeInteger(retentionBlocks) || retentionBlocks <= 0) {
12956
12980
  throw new Error(
@@ -13020,17 +13044,29 @@ var IsoBlockLocator = class _IsoBlockLocator {
13020
13044
  }
13021
13045
  /** The known block or span containing `ssId`, with its ESI. */
13022
13046
  locate(ssId) {
13023
- for (let i = this.#blocks.length - 1; i >= 0; i--) {
13024
- const block = this.#blocks[i];
13025
- if (isoBlockContains(block.start, block.length, ssId)) {
13026
- return { block, esi: serial32Distance(block.start, ssId) };
13047
+ for (const blocks of [this.#blocks, this.#closedBehind]) {
13048
+ for (let i = blocks.length - 1; i >= 0; i--) {
13049
+ const block = blocks[i];
13050
+ if (isoBlockContains(block.start, block.length, ssId)) {
13051
+ return { block, esi: serial32Distance(block.start, ssId) };
13052
+ }
13027
13053
  }
13028
13054
  }
13029
13055
  return null;
13030
13056
  }
13031
- /** Number of pending holes. */
13057
+ /** Number of holes not closed yet, pending or past the horizon. */
13032
13058
  get holeCount() {
13033
- return this.#holes.length;
13059
+ return this.#holes.length + this.#expired.length;
13060
+ }
13061
+ /**
13062
+ * The pending holes the horizon has passed since the last call, in the
13063
+ * order it passed them. Each is no longer pending: no repair states a block
13064
+ * in it (that repair is `stale`, as for a span the horizon retired) and the
13065
+ * release gate does not wait for it. Its owner must close it
13066
+ * (`closeHolesWithin`), which turns it into a span that is not kept here.
13067
+ */
13068
+ takeExpiredHoles() {
13069
+ return this.#newlyExpired.splice(0);
13034
13070
  }
13035
13071
  /** The stated block (never a span) ending exactly at `end`. */
13036
13072
  blockEndingAt(end) {
@@ -13086,9 +13122,11 @@ var IsoBlockLocator = class _IsoBlockLocator {
13086
13122
  /**
13087
13123
  * Apply one repair symbol's `(SS_Start, SSB_length)`. The first repair of a
13088
13124
  * block states its range and places every held source in it; later repair
13089
- * symbols of the same block change nothing. A range that contradicts a
13090
- * known block (a different length, or an overlap with another block or a
13091
- * span) fails closed: the repair symbol is dropped and no block changes.
13125
+ * symbols of the same block change nothing. A repair whose range is exactly
13126
+ * a repair-less span states that span's one block. A range that contradicts
13127
+ * a known block (a different length, or an overlap with another block or
13128
+ * with part of a span) fails closed: the repair symbol is dropped and no
13129
+ * block changes.
13092
13130
  * A newly stated block retires behind its SS_Start (see the class note).
13093
13131
  * A block so far past `newest` that stating it would retire the position
13094
13132
  * `newest` is refused as `ahead`. When `newest` is a placed block's SS_Start
@@ -13107,7 +13145,16 @@ var IsoBlockLocator = class _IsoBlockLocator {
13107
13145
  const known = this.blockAt(start);
13108
13146
  if (known !== void 0) {
13109
13147
  if (known.span) {
13110
- return { status: "overlap", reason: `repair block ${start}+${length} falls inside a repair-less span` };
13148
+ if (known.length === length) {
13149
+ const block2 = { start, length, span: false };
13150
+ this.#blocks[this.#blocks.indexOf(known)] = block2;
13151
+ return { status: "placed", block: block2, placed: [] };
13152
+ }
13153
+ return length < known.length ? {
13154
+ status: "overlap",
13155
+ reason: `repair block ${start}+${length} is only the first block of repair-less span ${known.start}+${known.length}`,
13156
+ ofSpan: known
13157
+ } : { status: "overlap", reason: `repair block ${start}+${length} runs past repair-less span ${known.start}+${known.length}` };
13111
13158
  }
13112
13159
  if (known.length !== length) {
13113
13160
  return {
@@ -13175,18 +13222,28 @@ var IsoBlockLocator = class _IsoBlockLocator {
13175
13222
  return span === void 0 ? { status: "placed", block, placed } : { status: "placed", block, placed, span };
13176
13223
  }
13177
13224
  /**
13178
- * Turn every pending hole inside `[start, start + length)` into a
13179
- * repair-less span, claiming its held sources: its range FETCH ended, so
13180
- * nothing more will state its blocks. Returns the spans, in SS_ID order.
13225
+ * Turn every hole inside `[start, start + length)` into a repair-less span,
13226
+ * claiming its held sources: its range FETCH ended, so nothing more will
13227
+ * state its blocks. A hole the horizon passed (`takeExpiredHoles`) leaves a
13228
+ * span that is not kept among the blocks, as the horizon would retire it at
13229
+ * once; only `locate` finds it, until the next retirement, so its sources
13230
+ * can be fed. Returns the spans, in SS_ID order.
13181
13231
  */
13182
13232
  closeHolesWithin(start, length) {
13183
13233
  const closed = [];
13184
- for (const hole of [...this.#holes]) {
13185
- if (!isoBlockContains(start, length, hole.start)) continue;
13186
- this.#holes.splice(this.#holes.indexOf(hole), 1);
13187
- const spanBlock = { start: hole.start, length: hole.length, span: true };
13188
- closed.push({ block: spanBlock, placed: this.#claim(spanBlock) });
13189
- this.#blocks.push(spanBlock);
13234
+ for (const holes of [this.#holes, this.#expired]) {
13235
+ for (const hole of [...holes]) {
13236
+ if (!isoBlockContains(start, length, hole.start)) continue;
13237
+ holes.splice(holes.indexOf(hole), 1);
13238
+ const spanBlock = { start: hole.start, length: hole.length, span: true };
13239
+ closed.push({ block: spanBlock, placed: this.#claim(spanBlock) });
13240
+ if (holes === this.#holes) {
13241
+ this.#blocks.push(spanBlock);
13242
+ } else {
13243
+ this.#closedBehind.push(spanBlock);
13244
+ if (this.#newlyExpired.includes(hole)) this.#newlyExpired.splice(this.#newlyExpired.indexOf(hole), 1);
13245
+ }
13246
+ }
13190
13247
  }
13191
13248
  return closed.sort((x, y) => serial32Distance(start, x.block.start) - serial32Distance(start, y.block.start));
13192
13249
  }
@@ -13200,15 +13257,17 @@ var IsoBlockLocator = class _IsoBlockLocator {
13200
13257
  return void 0;
13201
13258
  }
13202
13259
  /**
13203
- * Retire every block and span whose last SS_ID, and every held source,
13204
- * lies more than `retentionBlocks` blocks behind `position`, with the shared
13205
- * lower-bound metric (`isoBlocksBehindLowerBound`) every ISO interleave
13206
- * horizon uses. Returns the retired block starts.
13260
+ * Retire every block and span whose last SS_ID, and every held source
13261
+ * outside a hole, lies more than `retentionBlocks` blocks behind
13262
+ * `position`, with the shared lower-bound metric (`isoBlocksBehindLowerBound`)
13263
+ * every ISO interleave horizon uses; a pending hole so far behind expires
13264
+ * (`takeExpiredHoles`). Returns the retired block starts.
13207
13265
  */
13208
13266
  retireBehind(position) {
13209
13267
  this.observe(position);
13210
13268
  const horizon = this.#retentionBlocks;
13211
13269
  const retired = [];
13270
+ this.#closedBehind.length = 0;
13212
13271
  for (let i = this.#blocks.length - 1; i >= 0; i--) {
13213
13272
  const block = this.#blocks[i];
13214
13273
  const last = serial32Add(block.start, block.length - 1);
@@ -13220,10 +13279,15 @@ var IsoBlockLocator = class _IsoBlockLocator {
13220
13279
  for (let i = this.#holes.length - 1; i >= 0; i--) {
13221
13280
  const hole = this.#holes[i];
13222
13281
  const last = serial32Add(hole.start, hole.length - 1);
13223
- if (isoBlocksBehindLowerBound(last, position, this.#kMax) > horizon) this.#holes.splice(i, 1);
13282
+ if (isoBlocksBehindLowerBound(last, position, this.#kMax) <= horizon) continue;
13283
+ this.#holes.splice(i, 1);
13284
+ this.#expired.push(hole);
13285
+ this.#newlyExpired.push(hole);
13224
13286
  }
13225
13287
  for (const ssId of [...this.#held.keys()]) {
13226
- if (isoBlocksBehindLowerBound(ssId, position, this.#kMax) > horizon) this.#held.delete(ssId);
13288
+ 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))) {
13289
+ this.#held.delete(ssId);
13290
+ }
13227
13291
  }
13228
13292
  return retired;
13229
13293
  }
@@ -13231,6 +13295,9 @@ var IsoBlockLocator = class _IsoBlockLocator {
13231
13295
  reset() {
13232
13296
  this.#blocks.length = 0;
13233
13297
  this.#holes.length = 0;
13298
+ this.#expired.length = 0;
13299
+ this.#newlyExpired.length = 0;
13300
+ this.#closedBehind.length = 0;
13234
13301
  this.#held.clear();
13235
13302
  this.#coverageEnd = void 0;
13236
13303
  this.#newest = void 0;
@@ -13948,6 +14015,10 @@ var FECManager = class _FECManager {
13948
14015
  // 'pending' forever, which would park every later frame of its stream in
13949
14016
  // the in-order release gate. Each entry owns its handle; the callback
13950
14017
  // checks it still owns the key, so a settled or reset key is never touched.
14018
+ /**
14019
+ * Per owed Worker cleanup: the bound on its acknowledgement, when its block
14020
+ * was due, and when its cleanup was asked for (see `onCleanupComplete`).
14021
+ */
13951
14022
  this.#pendingWorkerCleanupBounds = /* @__PURE__ */ new Map();
13952
14023
  // Source symbol count per track-scoped block — marks complete when K symbols arrive.
13953
14024
  this.#blockSourceCounts = /* @__PURE__ */ new Map();
@@ -13964,6 +14035,9 @@ var FECManager = class _FECManager {
13964
14035
  // SSB_length source arrivals and recoverable from SSB_length received
13965
14036
  // symbols; waiting for K fails every one of them.
13966
14037
  this.#blockAuthoredLengths = /* @__PURE__ */ new Map();
14038
+ // Keys whose authored length is a repair-less span's (#openIsoSpan), until
14039
+ // a repair states the block there or the span resolves.
14040
+ this.#isoSpanKeys = /* @__PURE__ */ new Set();
13967
14041
  this.#ssbLengthConflictCount = 0;
13968
14042
  this.#blockRepairRsIds = /* @__PURE__ */ new Map();
13969
14043
  // RSB_length per block (C.5.3 Repair FEC Payload ID): how many repair
@@ -14161,7 +14235,10 @@ var FECManager = class _FECManager {
14161
14235
  // arrives via onBlockRecovered callback.
14162
14236
  this.onBlockStarted = null;
14163
14237
  this.onBlockComplete = null;
14164
- /** A block reached its fail bound. `blockClass` is its class at failure. */
14238
+ /**
14239
+ * A block reached its fail bound. `blockClass` is its class at failure.
14240
+ * `span` is set when what failed is a repair-less span starting at `sbn`.
14241
+ */
14165
14242
  this.onBlockFailed = null;
14166
14243
  /**
14167
14244
  * Fired once per stream when its Worker decoder stops being routable.
@@ -14203,6 +14280,8 @@ var FECManager = class _FECManager {
14203
14280
  * for it, which it must be.
14204
14281
  */
14205
14282
  this.#onUndelimitedLoss = null;
14283
+ /** Set with the loss handler: true takes an expired hole to close (`closeIsoHoles`). */
14284
+ this.#onIsoHoleExpired = null;
14206
14285
  /** A block every symbol of which vanished was created by SBN evidence. */
14207
14286
  this.onBlockVanished = null;
14208
14287
  /** A block reached a terminal state (completed, recovered, or failed). */
@@ -14247,6 +14326,7 @@ var FECManager = class _FECManager {
14247
14326
  #blockSourceEsis;
14248
14327
  #blockRebuiltEsis;
14249
14328
  #blockAuthoredLengths;
14329
+ #isoSpanKeys;
14250
14330
  #ssbLengthConflictCount;
14251
14331
  #blockRepairRsIds;
14252
14332
  #blockRsbLengths;
@@ -14313,6 +14393,7 @@ var FECManager = class _FECManager {
14313
14393
  #decoderUnavailableEvents;
14314
14394
  #repairSymbolSizeMismatchCount;
14315
14395
  #onUndelimitedLoss;
14396
+ #onIsoHoleExpired;
14316
14397
  #encodeTrackName(catalogTrackName) {
14317
14398
  let encoded = this.#encodedTrackNames.get(catalogTrackName);
14318
14399
  if (encoded === void 0) {
@@ -14784,7 +14865,10 @@ var FECManager = class _FECManager {
14784
14865
  timer.handle = null;
14785
14866
  this.#requestRepair(sbn, packetId, catalogTrackName);
14786
14867
  };
14787
- timer.handle = setTimeout(onFire, Math.max(0, deadlineMs - performance.now()));
14868
+ timer.handle = setTimeout(
14869
+ onFire,
14870
+ this.#receiptAtMs === void 0 ? Math.max(0, deadlineMs - performance.now()) : this.#receiptTimerDelayMs(deadlineMs, 0)
14871
+ );
14788
14872
  this.#repairDeadlines.set(key, timer);
14789
14873
  }
14790
14874
  #requestRepair(sbn, packetId, catalogTrackName, settledByOrder = false) {
@@ -14928,8 +15012,14 @@ var FECManager = class _FECManager {
14928
15012
  * Vanished blocks are not enumerated here: their starts are unknown until a
14929
15013
  * repair states them. The SS_IDs between two stated blocks become one
14930
15014
  * repair-less span when the later block's repair arrives (placeIsoRepair).
15015
+ *
15016
+ * A `held` source arrived before its block was known and is fed only now
15017
+ * that a repair, a span or a closed hole placed it: how far its block start
15018
+ * then lies behind the frontier measures how long the receiver held it, not
15019
+ * where the stream was, so only its SS_ID and packet_sequence_number judge
15020
+ * it (BLO-52085).
14931
15021
  */
14932
- #noteSbnSeen(sbn, packetId, catalogTrackName, symbol, packetSequenceNumber, ssId = sbn) {
15022
+ #noteSbnSeen(sbn, packetId, catalogTrackName, symbol, packetSequenceNumber, ssId = sbn, held = false) {
14933
15023
  const streamKey = this.#streamKey(packetId, catalogTrackName);
14934
15024
  const now = performance.now();
14935
15025
  const previous = (symbol === "source" ? this.#sourceFrontiers : this.#highestSbn).get(streamKey);
@@ -14968,7 +15058,7 @@ var FECManager = class _FECManager {
14968
15058
  return sent <= 2147483647 && serial32Distance(from.ssId, to.ssId) <= sent + config.k;
14969
15059
  };
14970
15060
  if (!this.#blockPrecedes(previous.sbn, sbn)) {
14971
- 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))) {
15061
+ 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))) {
14972
15062
  discontinuity();
14973
15063
  return;
14974
15064
  }
@@ -15230,17 +15320,21 @@ var FECManager = class _FECManager {
15230
15320
  this.#blockWaitDeadlinesMs.delete(key);
15231
15321
  this.#repairHoldUntilMs.delete(key);
15232
15322
  this.#traceLifecycle("timeout-queued", sbn, packetId, catalogTrackName);
15233
- this.#finalizeExpiredBlock(sbn, packetId, catalogTrackName);
15323
+ this.#finalizeExpiredBlock(sbn, packetId, catalogTrackName, currentDeadlineMs ?? firedAtMs);
15234
15324
  };
15235
- timer.handle = setTimeout(onFire, Math.max(1, Math.ceil(deadlineMs - nowMs)));
15325
+ timer.handle = setTimeout(
15326
+ onFire,
15327
+ this.#receiptAtMs === void 0 ? Math.max(1, Math.ceil(deadlineMs - nowMs)) : this.#receiptTimerDelayMs(deadlineMs, 1)
15328
+ );
15236
15329
  this.#activeBlocks.set(key, timer);
15237
15330
  this.#indexBlockKey(key, sbn, packetId);
15238
15331
  }
15239
15332
  /**
15240
15333
  * Hand a block whose delivery is over to its decoder for finalization: the
15241
- * datagram deadline fired, or the ordered relay moved past it.
15334
+ * datagram deadline fired, or the ordered relay moved past it. `dueAtMs`
15335
+ * is when it was due: its deadline, or now.
15242
15336
  */
15243
- #finalizeExpiredBlock(sbn, packetId, catalogTrackName) {
15337
+ #finalizeExpiredBlock(sbn, packetId, catalogTrackName, dueAtMs = performance.now()) {
15244
15338
  const key = this.#blockKey(sbn, packetId, catalogTrackName);
15245
15339
  if (this.#materializedBlocks.has(key) && !this.#blockPathCounts.has(key)) {
15246
15340
  this.#failBlock(sbn, packetId, catalogTrackName);
@@ -15256,7 +15350,7 @@ var FECManager = class _FECManager {
15256
15350
  if (workerClient) {
15257
15351
  this.#pendingWorkerCleanups.add(key);
15258
15352
  this.#pendingWorkerCleanupClients.set(key, workerClient);
15259
- this.#boundOwedCleanup(key, packetId, workerClient);
15353
+ this.#boundOwedCleanup(key, packetId, workerClient, dueAtMs);
15260
15354
  workerClient.cleanup(sbn);
15261
15355
  } else {
15262
15356
  const decoder = this.#decoderForPacket(packetId, catalogTrackName)?.client;
@@ -15570,6 +15664,7 @@ var FECManager = class _FECManager {
15570
15664
  resetMap(this.#blockSourceEsis);
15571
15665
  resetMap(this.#blockRebuiltEsis);
15572
15666
  resetMap(this.#blockAuthoredLengths);
15667
+ resetSet(this.#isoSpanKeys);
15573
15668
  resetMap(this.#blockRepairRsIds);
15574
15669
  resetMap(this.#blockRsbLengths);
15575
15670
  resetMap(this.#heldLayerStates);
@@ -15695,6 +15790,7 @@ var FECManager = class _FECManager {
15695
15790
  dropMap(this.#blockSourceEsis);
15696
15791
  dropMap(this.#blockRebuiltEsis);
15697
15792
  dropMap(this.#blockAuthoredLengths);
15793
+ dropSet(this.#isoSpanKeys);
15698
15794
  dropMap(this.#blockRepairRsIds);
15699
15795
  dropMap(this.#blockRsbLengths);
15700
15796
  dropMap(this.#blockPathCounts);
@@ -16037,8 +16133,9 @@ var FECManager = class _FECManager {
16037
16133
  return true;
16038
16134
  }
16039
16135
  /**
16040
- * Drop a block's repair hold: its FETCH ended without delivering. The block
16041
- * falls back to its multicast wait, which may already have passed.
16136
+ * Drop a block's repair hold: its FETCH ended without delivering, or brought
16137
+ * back only a repair that cannot complete it. The block falls back to its
16138
+ * multicast wait, which may already have passed.
16042
16139
  */
16043
16140
  releaseRepairHold(sbn, packetId, catalogTrackName) {
16044
16141
  const key = this.#blockKey(sbn, packetId, catalogTrackName);
@@ -16176,6 +16273,7 @@ var FECManager = class _FECManager {
16176
16273
  const locator = this.#growingIsoLocatorFor(packetId, catalogTrackName);
16177
16274
  const evictedBefore = locator.heldEvicted;
16178
16275
  const held = locator.hold(ssId, source);
16276
+ this.#settleExpiredIsoHoles(locator, packetId, catalogTrackName);
16179
16277
  if (held && source.source === "multicast") this.#noteMulticastSource(locator, ssId, packetId, catalogTrackName);
16180
16278
  const evicted = locator.heldEvicted - evictedBefore;
16181
16279
  if (evicted > 0) {
@@ -16197,17 +16295,20 @@ var FECManager = class _FECManager {
16197
16295
  * contradicts a known block is refused (counted) and must be dropped.
16198
16296
  */
16199
16297
  placeIsoRepair(packetId, catalogTrackName, ssStart, ssbLength) {
16200
- const placement = this.#growingIsoLocatorFor(packetId, catalogTrackName).placeRepair(ssStart, ssbLength);
16298
+ const locator = this.#growingIsoLocatorFor(packetId, catalogTrackName);
16299
+ const placement = locator.placeRepair(ssStart, ssbLength);
16300
+ this.#settleExpiredIsoHoles(locator, packetId, catalogTrackName);
16201
16301
  if (placement.status !== "placed") {
16202
16302
  this.#isoRepairRefusedCount++;
16203
16303
  if (this.#isoRepairRefusedCount <= 5 || this.#isoRepairRefusedCount % 100 === 0) {
16204
16304
  console.warn(`[FECManager] packetId=${packetId} iso-ssbg1-v1 repair refused (${placement.status}): ${placement.reason}`);
16205
16305
  }
16306
+ if (placement.ofSpan !== void 0) this.releaseRepairHold(placement.ofSpan.start, packetId, catalogTrackName);
16206
16307
  return placement;
16207
16308
  }
16309
+ this.#isoSpanKeys.delete(this.#blockKey(placement.block.start, packetId, catalogTrackName));
16208
16310
  if (placement.span !== void 0) this.#openIsoSpan(placement.span, packetId, catalogTrackName);
16209
16311
  if (placement.hole !== void 0) {
16210
- const locator = this.#isoLocatorFor(packetId, catalogTrackName);
16211
16312
  const hole = placement.hole.block;
16212
16313
  const previous = locator.blockEndingAt(hole.start);
16213
16314
  const loss = {
@@ -16228,17 +16329,29 @@ var FECManager = class _FECManager {
16228
16329
  * `handler`, instead of a repair-less span; null restores spans. Set by the
16229
16330
  * range repair scheduler; applies to every stream from its next gap. The
16230
16331
  * handler and the deferral are one setting, so no hole is ever created with
16231
- * nobody to close it.
16332
+ * nobody to close it. `onExpired` is told of each pending hole the stream
16333
+ * runs past the locator's horizon (BLO-52085): returning true, it closes
16334
+ * the hole (`closeIsoHoles`), so the span it leaves still fails and is
16335
+ * reported; otherwise the hole is discarded with its sources.
16232
16336
  */
16233
- setUndelimitedLossHandler(handler) {
16337
+ setUndelimitedLossHandler(handler, onExpired = null) {
16234
16338
  this.#onUndelimitedLoss = handler;
16339
+ this.#onIsoHoleExpired = handler === null ? null : onExpired;
16235
16340
  this.#deferIsoGaps = handler !== null;
16236
16341
  for (const locator of this.#isoLocators.values()) locator.deferIncompleteGaps = this.#deferIsoGaps;
16237
16342
  }
16343
+ /** Hand each hole the locator's horizon passed to its owner, or discard it (setUndelimitedLossHandler). */
16344
+ #settleExpiredIsoHoles(locator, packetId, catalogTrackName) {
16345
+ for (const hole of locator.takeExpiredHoles()) {
16346
+ const expired = { packetId, catalogTrackName, holeStart: hole.start, holeLength: hole.length };
16347
+ if (this.#onIsoHoleExpired?.(expired) !== true) locator.closeHolesWithin(hole.start, hole.length);
16348
+ }
16349
+ }
16238
16350
  /**
16239
- * The range repair for a hole ended: every hole still pending inside
16240
- * `[start, start + length)` becomes a repair-less span. Returns those spans
16241
- * with the held sources they placed, for the caller to feed (MmtpRouter).
16351
+ * The range repair for a hole ended: every hole inside `[start, start +
16352
+ * length)`, pending or expired, becomes a repair-less span. Returns those
16353
+ * spans with the held sources they placed, for the caller to feed
16354
+ * (MmtpRouter).
16242
16355
  */
16243
16356
  closeIsoHoles(packetId, catalogTrackName, start, length) {
16244
16357
  const locator = this.#isoLocators.get(this.#streamKey(packetId, catalogTrackName));
@@ -16257,7 +16370,10 @@ var FECManager = class _FECManager {
16257
16370
  */
16258
16371
  #openIsoSpan(span, packetId, catalogTrackName) {
16259
16372
  const key = this.#blockKey(span.block.start, packetId, catalogTrackName);
16260
- if (!this.#blockAuthoredLengths.has(key)) this.#blockAuthoredLengths.set(key, span.block.length);
16373
+ if (!this.#blockAuthoredLengths.has(key)) {
16374
+ this.#blockAuthoredLengths.set(key, span.block.length);
16375
+ this.#isoSpanKeys.add(key);
16376
+ }
16261
16377
  if (span.placed.length === 0) this.#materializeBlock(span.block.start, packetId, catalogTrackName, true);
16262
16378
  }
16263
16379
  /**
@@ -16598,11 +16714,14 @@ var FECManager = class _FECManager {
16598
16714
  });
16599
16715
  client.onCleanupComplete((sbn) => {
16600
16716
  if (this.#releasedWorkerClients.has(client)) return;
16601
- const cleanupKeys = [...this.#pendingWorkerCleanups].filter((key) => {
16717
+ const owed = [...this.#pendingWorkerCleanups].filter((key) => this.#pendingWorkerCleanupClients.get(key) === client);
16718
+ const cleanupKeys = owed.filter((key) => {
16602
16719
  const block = this.#parseBlockKey(key);
16603
- return block.packetId === packetId && block.sbn === sbn && this.#pendingWorkerCleanupClients.get(key) === client;
16720
+ return block.packetId === packetId && block.sbn === sbn;
16604
16721
  });
16605
- for (const key of cleanupKeys) this.#settleOwedCleanup(key, "cleanup-ack");
16722
+ const askedAtMs = Math.max(...cleanupKeys.map((key) => this.#pendingWorkerCleanupBounds.get(key)?.askedAtMs ?? -Infinity));
16723
+ const due = owed.filter((key) => !cleanupKeys.includes(key) && (this.#pendingWorkerCleanupBounds.get(key)?.dueAtMs ?? Infinity) <= askedAtMs);
16724
+ for (const key of [...cleanupKeys, ...due]) this.#settleOwedCleanup(key, "cleanup-ack");
16606
16725
  });
16607
16726
  client.onError((msg, code) => {
16608
16727
  const requestedKind = code ?? msg;
@@ -16672,9 +16791,11 @@ var FECManager = class _FECManager {
16672
16791
  * fail deadline already passed, so failing it is the decision that deadline
16673
16792
  * made. A late ack then finds nothing owed and is a no-op.
16674
16793
  */
16675
- #boundOwedCleanup(key, packetId, client) {
16794
+ #boundOwedCleanup(key, packetId, client, dueAtMs) {
16676
16795
  this.#clearOwedCleanupBound(key);
16677
16796
  const bound = {
16797
+ dueAtMs,
16798
+ askedAtMs: performance.now(),
16678
16799
  handle: setTimeout(() => {
16679
16800
  if (this.#pendingWorkerCleanupBounds.get(key) !== bound) return;
16680
16801
  this.#pendingWorkerCleanupBounds.delete(key);
@@ -17245,12 +17366,13 @@ var FECManager = class _FECManager {
17245
17366
  * the inactivity deadline on each symbol, records the symbol's in-band
17246
17367
  * classification evidence, and marks the block clean when all of its
17247
17368
  * authored source symbols arrived: the SSB_length a repair symbol declared,
17248
- * otherwise K.
17369
+ * otherwise K. `held` marks a source held unplaced since it arrived and
17370
+ * placed only now (see #noteSbnSeen).
17249
17371
  *
17250
17372
  * Throws for a packetId that was never configured, so a classified block
17251
17373
  * never reads the manager-global K or timeout.
17252
17374
  */
17253
- trackBlock(sbn, packetId, catalogTrackName, esi, evidence) {
17375
+ trackBlock(sbn, packetId, catalogTrackName, esi, evidence, held = false) {
17254
17376
  const trackConfig = this.#trackConfigs.get(packetId);
17255
17377
  if (trackConfig === void 0) {
17256
17378
  throw new Error(
@@ -17266,7 +17388,8 @@ var FECManager = class _FECManager {
17266
17388
  catalogTrackName,
17267
17389
  "source",
17268
17390
  evidence.packetSequenceNumber,
17269
- Number.isInteger(esi) ? sbn + esi >>> 0 : sbn
17391
+ Number.isInteger(esi) ? sbn + esi >>> 0 : sbn,
17392
+ held
17270
17393
  );
17271
17394
  const authoredLength = this.#blockAuthoredLengths.get(key) ?? k;
17272
17395
  if (Number.isInteger(esi) && esi >= authoredLength) {
@@ -17321,6 +17444,7 @@ var FECManager = class _FECManager {
17321
17444
  this.#blockSourceEsis.delete(key);
17322
17445
  this.#blockRebuiltEsis.delete(key);
17323
17446
  this.#blockAuthoredLengths.delete(key);
17447
+ this.#isoSpanKeys.delete(key);
17324
17448
  this.#blockRepairRsIds.delete(key);
17325
17449
  this.#blockRsbLengths.delete(key);
17326
17450
  this.#markTerminal(key, sbn, packetId, true);
@@ -17351,6 +17475,19 @@ var FECManager = class _FECManager {
17351
17475
  #receiptNow() {
17352
17476
  return this.#receiptAtMs ?? performance.now();
17353
17477
  }
17478
+ /**
17479
+ * The whole-ms delay of a timer for `deadlineMs` armed inside a receipt
17480
+ * scope: counted from the receipt time, as the timer of a symbol fed when
17481
+ * it was received was, less the whole ms since. So it fires on the tick
17482
+ * that timer would have, never later than a delay counted from now, and
17483
+ * its handler still re-arms a timer that fires before its deadline.
17484
+ * Counted from now, a deadline set from the receipt time is rounded up
17485
+ * from another fraction of a ms (BLO-52085).
17486
+ */
17487
+ #receiptTimerDelayMs(deadlineMs, minMs) {
17488
+ const receivedAtMs = this.#receiptAtMs;
17489
+ return Math.max(minMs, Math.ceil(deadlineMs - receivedAtMs) - Math.ceil(performance.now() - receivedAtMs));
17490
+ }
17354
17491
  /**
17355
17492
  * A held packet admitted late arms from its (earlier) receipt time; that
17356
17493
  * never pulls in a deadline later activity already armed. Outside a
@@ -17399,6 +17536,7 @@ var FECManager = class _FECManager {
17399
17536
  }
17400
17537
  #failBlock(sbn, packetId, catalogTrackName) {
17401
17538
  const key = this.#blockKey(sbn, packetId, catalogTrackName);
17539
+ const span = this.#isoSpanKeys.delete(key) ? { length: this.#blockAuthoredLengths.get(key) ?? 0, sources: this.#blockSourceCounts.get(key) ?? 0 } : void 0;
17402
17540
  this.#traceLifecycle("failed", sbn, packetId, catalogTrackName);
17403
17541
  this.#clearRepairDeadline(key);
17404
17542
  this.#clearBlockTimer(key);
@@ -17418,7 +17556,8 @@ var FECManager = class _FECManager {
17418
17556
  this.#markPathBlockComplete(sbn, packetId, catalogTrackName);
17419
17557
  this.#fecStats.blocksFailed++;
17420
17558
  this.#statsForPacket(packetId).blocksFailed++;
17421
- this.onBlockFailed?.(sbn, packetId, catalogTrackName, blockClass);
17559
+ if (span === void 0) this.onBlockFailed?.(sbn, packetId, catalogTrackName, blockClass);
17560
+ else this.onBlockFailed?.(sbn, packetId, catalogTrackName, blockClass, span);
17422
17561
  this.onBlockResolved?.(sbn, packetId, catalogTrackName);
17423
17562
  }
17424
17563
  /**
@@ -17449,6 +17588,7 @@ var FECManager = class _FECManager {
17449
17588
  this.#blockSourceEsis.delete(key);
17450
17589
  this.#blockRebuiltEsis.delete(key);
17451
17590
  this.#blockAuthoredLengths.delete(key);
17591
+ this.#isoSpanKeys.delete(key);
17452
17592
  this.#blockRepairRsIds.delete(key);
17453
17593
  this.#blockRsbLengths.delete(key);
17454
17594
  this.#clearBlockTimer(key);
@@ -17541,6 +17681,7 @@ var FECManager = class _FECManager {
17541
17681
  this.#blockSourceEsis.clear();
17542
17682
  this.#blockRebuiltEsis.clear();
17543
17683
  this.#blockAuthoredLengths.clear();
17684
+ this.#isoSpanKeys.clear();
17544
17685
  this.#blockRepairRsIds.clear();
17545
17686
  this.#blockRsbLengths.clear();
17546
17687
  this.#heldLayerStates.clear();
@@ -18442,6 +18583,9 @@ function authoredGap(before, after) {
18442
18583
  function ssIdAfter(a, b) {
18443
18584
  return (a - b | 0) > 0;
18444
18585
  }
18586
+ function holdsRebuilt(stream) {
18587
+ return stream.buf.some(({ meta }) => meta.fecSbnFirst !== void 0);
18588
+ }
18445
18589
  var FecReleaseSequencer = class {
18446
18590
  #streams = /* @__PURE__ */ new Map();
18447
18591
  #gate;
@@ -18504,6 +18648,7 @@ var FecReleaseSequencer = class {
18504
18648
  stream.lastSbn = void 0;
18505
18649
  stream.discontinuity = false;
18506
18650
  stream.rapSsId = void 0;
18651
+ stream.sourced = false;
18507
18652
  }
18508
18653
  if (stream.last !== null && compareFrameOrder(frame, stream.last) <= 0) {
18509
18654
  this.#recordStat("droppedStale");
@@ -18530,6 +18675,7 @@ var FecReleaseSequencer = class {
18530
18675
  if (compareFrameOrder(stream.buf[mid], frame) < 0) lo = mid + 1;
18531
18676
  else hi = mid;
18532
18677
  }
18678
+ if (stream.buf[lo]?.passed === true) frame.passed = true;
18533
18679
  stream.buf.splice(lo, 0, frame);
18534
18680
  this.#drainUnlessDeferred(stream);
18535
18681
  }
@@ -18565,6 +18711,41 @@ var FecReleaseSequencer = class {
18565
18711
  }
18566
18712
  return false;
18567
18713
  }
18714
+ /**
18715
+ * These streams' sink is being replaced by one that has seen no source
18716
+ * frame yet. First, from a stream that holds a frame the overlay rebuilt,
18717
+ * release to the sink being replaced the frames the unanchored path had
18718
+ * already released to it (#cursorPassed): there they waited in its queue,
18719
+ * which its replacement flushes. A stream that holds none is not drained:
18720
+ * it holds nothing the anchor kept back, and a drain would still force out
18721
+ * a frame held past the hold limit, which before the anchor waited for the
18722
+ * next push. Then treat the streams as unsourced until a source frame is
18723
+ * released.
18724
+ */
18725
+ unsource(packetId, catalogTrackName) {
18726
+ for (const stream of this.#streams.values()) {
18727
+ if (stream.packetId === packetId && (catalogTrackName === void 0 || stream.catalogTrackName === catalogTrackName)) {
18728
+ if (stream.draining === 0 && holdsRebuilt(stream)) {
18729
+ if (this.#deferDepth > 0) this.#deferredStreams.add(stream);
18730
+ else this.#drain(stream, true);
18731
+ }
18732
+ stream.sourced = false;
18733
+ }
18734
+ }
18735
+ }
18736
+ /**
18737
+ * Whether a stream of `packetId` holds a frame the keyframe overlay rebuilt
18738
+ * (FrameMeta.fecSbnFirst). Without `catalogTrackName`, every stream of the
18739
+ * packetId.
18740
+ */
18741
+ holdsRebuilt(packetId, catalogTrackName) {
18742
+ for (const stream of this.#streams.values()) {
18743
+ if (stream.packetId !== packetId) continue;
18744
+ if (catalogTrackName !== void 0 && stream.catalogTrackName !== catalogTrackName) continue;
18745
+ if (holdsRebuilt(stream)) return true;
18746
+ }
18747
+ return false;
18748
+ }
18568
18749
  /** Re-evaluate held gaps after FEC state changed. */
18569
18750
  poke(streamKey) {
18570
18751
  if (streamKey !== void 0) {
@@ -18656,16 +18837,32 @@ var FecReleaseSequencer = class {
18656
18837
  last: null,
18657
18838
  lastSbn: void 0,
18658
18839
  discontinuity: false,
18659
- rapSsId: void 0
18840
+ rapSsId: void 0,
18841
+ sourced: false,
18842
+ draining: 0
18660
18843
  };
18661
18844
  this.#streams.set(streamKey, stream);
18662
18845
  }
18663
18846
  return stream;
18664
18847
  }
18665
- #drain(stream) {
18848
+ /** `asUnanchored`: release overlay-rebuilt frames when unanchored did (#cursorPassed). */
18849
+ #drain(stream, asUnanchored = false) {
18850
+ stream.draining++;
18851
+ try {
18852
+ this.#drainLoop(stream, asUnanchored);
18853
+ } finally {
18854
+ stream.draining--;
18855
+ }
18856
+ }
18857
+ #drainLoop(stream, asUnanchored) {
18666
18858
  while (stream.buf.length > 0 && stream.last !== null) {
18667
18859
  const head = stream.buf[0];
18668
18860
  const last = stream.last;
18861
+ if (compareFrameOrder(head, last) <= 0) {
18862
+ stream.buf.shift();
18863
+ this.#release(stream, head, void 0);
18864
+ continue;
18865
+ }
18669
18866
  if (stream.discontinuity) {
18670
18867
  stream.discontinuity = false;
18671
18868
  stream.buf.shift();
@@ -18679,9 +18876,10 @@ var FecReleaseSequencer = class {
18679
18876
  }
18680
18877
  const headSbn = head.meta.fecSbn;
18681
18878
  const from = stream.lastSbn ?? headSbn;
18682
- const to = headSbn ?? stream.lastSbn;
18879
+ const to = head.meta.fecSbnFirst ?? headSbn ?? stream.lastSbn;
18683
18880
  let lost = [];
18684
18881
  const unanchored = from === void 0 && to === void 0;
18882
+ let unresolved = false;
18685
18883
  if (from !== void 0 && to !== void 0) {
18686
18884
  const resolved = this.#gate.resolvedLoss(
18687
18885
  stream.packetId,
@@ -18690,7 +18888,8 @@ var FecReleaseSequencer = class {
18690
18888
  Math.max(from, to),
18691
18889
  from
18692
18890
  );
18693
- if (resolved === null) {
18891
+ unresolved = resolved === null;
18892
+ if (unresolved && !this.#cursorPassed(stream, asUnanchored)) {
18694
18893
  const heldMs = performance.now() - head.arrivedAtMs;
18695
18894
  if (heldMs <= this.#gate.holdLimitMs(stream.packetId, stream.catalogTrackName)) return;
18696
18895
  this.#recordStat("forcedWindowFlushes");
@@ -18698,22 +18897,56 @@ var FecReleaseSequencer = class {
18698
18897
  this.#release(stream, head, this.#gate.decide(last, head, [], true));
18699
18898
  continue;
18700
18899
  }
18701
- lost = resolved;
18900
+ lost = resolved ?? [];
18702
18901
  }
18703
18902
  stream.buf.shift();
18704
18903
  this.#release(
18705
18904
  stream,
18706
18905
  head,
18707
- lost.length > 0 || unanchored || authoredGap(last.meta, head.meta) ? this.#gate.decide(last, head, lost, false) : void 0
18906
+ lost.length > 0 || unanchored || unresolved || authoredGap(last.meta, head.meta) ? this.#gate.decide(last, head, lost, false) : void 0
18708
18907
  );
18709
18908
  }
18710
18909
  }
18910
+ /**
18911
+ * An overlay-rebuilt frame (fecSbnFirst) waits no longer than the BLO-53342
18912
+ * source cursor holds it unanchored, and neither does a later frame behind
18913
+ * it. Unanchored, a frame that waits on no block (fecSbnFirst set, or no
18914
+ * fecSbn) left the gate once every frame ahead of it had: mark it `passed`,
18915
+ * like each frame that arrived in front of it since (stale there). The head
18916
+ * then goes, decided as it would be unanchored, once a frame the base path
18917
+ * delivered (a source frame, or one base FEC recovered) is buffered after a
18918
+ * marked one: the base path has passed that one, so a frame still missing
18919
+ * before it can only come from a repair. It also goes while no source frame
18920
+ * has been released, when the direct path shows a recovered frame at once.
18921
+ * And it goes at once while a staged switch decides on when frames arrive
18922
+ * (FecReleaseGate.releaseUnanchored), and when unsource releases into a
18923
+ * sink being replaced: unanchored, it had already reached the sink.
18924
+ * The scan also writes: the `passed` marks it sets stay on the frames,
18925
+ * and push marks a frame it inserts in front of a marked one from them.
18926
+ */
18927
+ #cursorPassed(stream, asUnanchored) {
18928
+ let at2 = -1;
18929
+ for (let i = 0; i < stream.buf.length; i++) {
18930
+ const frame = stream.buf[i];
18931
+ if (frame.meta.fecSbnFirst !== void 0 || frame.meta.fecSbn === void 0) {
18932
+ frame.passed = true;
18933
+ if (at2 < 0) at2 = i;
18934
+ } else if (frame.passed !== true) break;
18935
+ }
18936
+ if (at2 < 0) return false;
18937
+ return asUnanchored || !stream.sourced || this.#gate.releaseUnanchored?.(stream.packetId, stream.catalogTrackName) === true || stream.buf.some(({ meta }, i) => i > at2 && meta.fecSbnFirst === void 0 && (meta.recovered !== true || meta.fecSbn !== void 0));
18938
+ }
18711
18939
  #release(stream, frame, decision) {
18940
+ const stale = stream.last !== null && compareFrameOrder(frame, stream.last) <= 0;
18941
+ if (stale) this.#recordStat("droppedStale");
18712
18942
  if (decision) frame.meta.gapBefore = decision;
18713
18943
  stream.last = frame;
18714
- if (frame.meta.fecSbn !== void 0) stream.lastSbn = frame.meta.fecSbn;
18944
+ if (frame.meta.fecSbn !== void 0 && (frame.meta.fecSbnFirst === void 0 || stream.lastSbn !== void 0)) {
18945
+ stream.lastSbn = frame.meta.fecSbn;
18946
+ }
18947
+ if (frame.meta.recovered !== true) stream.sourced = true;
18715
18948
  this.#gate.onReleased?.(frame.data, frame.meta);
18716
- this.#emit(frame.data, frame.meta);
18949
+ if (!stale) this.#emit(frame.data, frame.meta);
18717
18950
  }
18718
18951
  };
18719
18952
  var FMPU_BOX_TYPES = /* @__PURE__ */ new Set(["ftyp", "moov", "moof"]);
@@ -19039,6 +19272,14 @@ var MmtpContainerParser = class _MmtpContainerParser {
19039
19272
  fecReleaseRapAbove(packetId, catalogTrackName, ssId) {
19040
19273
  return this.#fecSequencer?.rapAbove(packetId, catalogTrackName, ssId) ?? false;
19041
19274
  }
19275
+ /** See FecReleaseSequencer.unsource. */
19276
+ unsourceFecRelease(packetId, catalogTrackName) {
19277
+ this.#fecSequencer?.unsource(packetId, catalogTrackName);
19278
+ }
19279
+ /** See FecReleaseSequencer.holdsRebuilt. */
19280
+ fecReleaseHoldsRebuilt(packetId, catalogTrackName) {
19281
+ return this.#fecSequencer?.holdsRebuilt(packetId, catalogTrackName) ?? false;
19282
+ }
19042
19283
  /** Re-evaluate held gaps after FEC block state changed. */
19043
19284
  pokeFecRelease(packetId, catalogTrackName) {
19044
19285
  if (!this.#fecSequencer) return;
@@ -19365,7 +19606,8 @@ var MmtpContainerParser = class _MmtpContainerParser {
19365
19606
  info,
19366
19607
  resolver,
19367
19608
  options?.recovered === true,
19368
- options?.fecSbn
19609
+ options?.fecSbn,
19610
+ options?.fecSbnFirst
19369
19611
  );
19370
19612
  if (deferred.record === void 0) this.#countDeferredOutcome(deferred.reason);
19371
19613
  }
@@ -19458,7 +19700,8 @@ var MmtpContainerParser = class _MmtpContainerParser {
19458
19700
  info,
19459
19701
  resolver,
19460
19702
  options?.recovered === true,
19461
- options?.fecSbn
19703
+ options?.fecSbn,
19704
+ options?.fecSbnFirst
19462
19705
  );
19463
19706
  if (outcome.record !== void 0) return outcome.record;
19464
19707
  return this.#drop(outcome.reason);
@@ -19478,7 +19721,7 @@ var MmtpContainerParser = class _MmtpContainerParser {
19478
19721
  * attribution exists to keep readable. Leaving the choice at the call site
19479
19722
  * is what keeps both true.
19480
19723
  */
19481
- #emitCombinedSample(combined, info, resolver, recovered, fecSbn) {
19724
+ #emitCombinedSample(combined, info, resolver, recovered, fecSbn, fecSbnFirst) {
19482
19725
  const timing = resolver.sampleTiming(
19483
19726
  combined.mpuSequenceNumber,
19484
19727
  combined.movieFragmentSequenceNumber,
@@ -19495,6 +19738,7 @@ var MmtpContainerParser = class _MmtpContainerParser {
19495
19738
  );
19496
19739
  if (!meta) return { reason: "combined-meta-build-failed" };
19497
19740
  if (fecSbn !== void 0) meta.fecSbn = fecSbn;
19741
+ if (fecSbnFirst !== void 0) meta.fecSbnFirst = fecSbnFirst;
19498
19742
  if (!resolver.consumeSample(
19499
19743
  combined.mpuSequenceNumber,
19500
19744
  combined.movieFragmentSequenceNumber,
@@ -23011,6 +23255,7 @@ function unstampedBaseSsId(offset, unstampedOffset, headerPsn) {
23011
23255
  var OVERLAY_ROUTED_LIMIT = 1024;
23012
23256
  var OVERLAY_PENDING_LIMIT = 256;
23013
23257
  var OVERLAY_OFFSET_MPUS = 16;
23258
+ var OVERLAY_REBUILT_LIMIT = 256;
23014
23259
  var OVERLAY_MOQ_HORIZON_BLOCKS = 64;
23015
23260
  function assertStreamIdentity(role, stream) {
23016
23261
  if (stream === void 0 || !Number.isInteger(stream.packetId) || stream.packetId <= MMTP_SIGNALING_PACKET_ID || typeof stream.catalogTrackName !== "string" || stream.catalogTrackName.length === 0) {
@@ -23187,6 +23432,17 @@ var MmtpRouter = class {
23187
23432
  * an MPU no member of which arrived).
23188
23433
  */
23189
23434
  #unstampedSsIdSeqOffsetByStream = /* @__PURE__ */ new Map();
23435
+ /**
23436
+ * The same, shown by the overlay alone, before any base recovery did: a
23437
+ * packet it rebuilt whose base copy carries its protected psn
23438
+ * (#feedOverlaySource), or whose protected psn passes overlayBaseSsId's
23439
+ * test at that offset (#routeOverlayRecovered). A base SS_ID placed by it
23440
+ * anchors the release gate only (BLO-52085 D3): fed to the base, it would
23441
+ * make the base drop a late base copy of a keyframe the overlay never
23442
+ * finished, and lift a block's late-source cut, which nothing before
23443
+ * this offset did.
23444
+ */
23445
+ #overlayUnstampedSsIdSeqOffsetByStream = /* @__PURE__ */ new Map();
23190
23446
  /**
23191
23447
  * Streams whose multicast leg delivered a RAP-flagged source packet that was
23192
23448
  * not parsed, typically because its overlap copy was parsed first
@@ -23464,13 +23720,18 @@ var MmtpRouter = class {
23464
23720
  this.#onDeltaBlockCompleteCb?.(packetId, false);
23465
23721
  }
23466
23722
  };
23467
- this.#fecManager.onBlockFailed = (sbn, packetId, catalogTrackName, blockClass) => {
23723
+ this.#fecManager.onBlockFailed = (sbn, packetId, catalogTrackName, blockClass, span) => {
23468
23724
  this.#mseAssemblerFor(packetId, catalogTrackName)?.failBlock(sbn);
23469
23725
  if (this.#isCatalogTrackUnambiguous(packetId, catalogTrackName) && this.#packetIdRoles.get(packetId) === "delta") {
23470
23726
  this.#onDeltaBlockCompleteCb?.(packetId, true);
23471
23727
  }
23472
- onBlockFailed?.(sbn, packetId, catalogTrackName, blockClass);
23473
- this.#onBlockFailed?.(sbn, packetId, catalogTrackName, blockClass);
23728
+ if (span === void 0) {
23729
+ onBlockFailed?.(sbn, packetId, catalogTrackName, blockClass);
23730
+ this.#onBlockFailed?.(sbn, packetId, catalogTrackName, blockClass);
23731
+ } else {
23732
+ onBlockFailed?.(sbn, packetId, catalogTrackName, blockClass, span);
23733
+ this.#onBlockFailed?.(sbn, packetId, catalogTrackName, blockClass, span);
23734
+ }
23474
23735
  };
23475
23736
  }
23476
23737
  }
@@ -23515,8 +23776,9 @@ var MmtpRouter = class {
23515
23776
  }
23516
23777
  /**
23517
23778
  * FEC-recovered source symbols whose reconstructed header named a
23518
- * packet_sequence_number other than the wire sequence their SS_ID maps to:
23519
- * a hop re-stamped the sequence after the sender protected the packet.
23779
+ * packet_sequence_number other than the wire sequence their SS_ID maps to,
23780
+ * or, rebuilt by the overlay, than their base copy carries: a hop
23781
+ * re-stamped the sequence after the sender protected the packet.
23520
23782
  */
23521
23783
  get restampedRecoveredSequenceCount() {
23522
23784
  return this.#restampedRecoveredSeqs;
@@ -23695,6 +23957,7 @@ var MmtpRouter = class {
23695
23957
  enableMseAssembler(packetId, config, catalogTrackName) {
23696
23958
  const assemblerKey = this.#mseAssemblerKey(packetId, catalogTrackName);
23697
23959
  const previous = this.#mseAssemblers.get(assemblerKey);
23960
+ this.#parser.unsourceFecRelease(packetId, catalogTrackName);
23698
23961
  if (previous) {
23699
23962
  previous.flush();
23700
23963
  previous.dispose();
@@ -23845,7 +24108,8 @@ var MmtpRouter = class {
23845
24108
  fec,
23846
24109
  k,
23847
24110
  offsets: /* @__PURE__ */ new Map(),
23848
- moqBlocks: /* @__PURE__ */ new Set()
24111
+ moqBlocks: /* @__PURE__ */ new Set(),
24112
+ rebuilt: /* @__PURE__ */ new Map()
23849
24113
  };
23850
24114
  this.#overlays.set(repairPacketId, inst);
23851
24115
  return () => {
@@ -23886,6 +24150,7 @@ var MmtpRouter = class {
23886
24150
  inst.last = void 0;
23887
24151
  inst.offsets.clear();
23888
24152
  inst.moqBlocks.clear();
24153
+ inst.rebuilt.clear();
23889
24154
  }
23890
24155
  #overlaysForSource(sourcePacketId, catalogTrackName) {
23891
24156
  return [...this.#overlays.values()].filter(({ leg }) => leg.sourcePacketId === sourcePacketId && (catalogTrackName === void 0 || leg.sourceCatalogTrackName === void 0 || leg.sourceCatalogTrackName === catalogTrackName));
@@ -23938,13 +24203,23 @@ var MmtpRouter = class {
23938
24203
  * base protects: the packet minus its 4-byte SS_ID trailer, extension
23939
24204
  * included (7.1, 7.4.3). `baseSsId` is that trailer's SS_ID, when the base
23940
24205
  * FEC reads it.
24206
+ *
24207
+ * The base copy of a packet an overlay rebuilt with no base SS_ID shows
24208
+ * what a base recovery of it would (#routeProcessedFecSymbols, BLO-52085):
24209
+ * its wire packet_sequence_number equal to the protected one rebuilt shows
24210
+ * the stream unstamped at the SS_ID offset it states, for the release gate
24211
+ * (#overlayUnstampedSsIdSeqOffsetByStream); any other shows a hop that
24212
+ * re-stamps it, which is counted (restampedRecoveredSequenceCount).
23941
24213
  */
23942
24214
  #feedOverlaySource(packetId, catalogTrackName, packet, header, sequenceNumber, rapFlag, source, baseSsId) {
23943
24215
  if (this.#overlays.size === 0) return;
23944
24216
  const ssId = keyframeOverlaySsId(header);
23945
24217
  if (ssId === void 0) return;
24218
+ let rebuiltPsn;
23946
24219
  for (const inst of this.#overlaysForSource(packetId, catalogTrackName)) {
23947
24220
  this.#noteOverlaySsId(inst, ssId, sequenceNumber);
24221
+ rebuiltPsn ??= inst.rebuilt.get(ssId);
24222
+ inst.rebuilt.delete(ssId);
23948
24223
  const identity = MmtpContainerParser.sourcePacketIdentity(packet, header);
23949
24224
  if (identity !== null) {
23950
24225
  inst.offsets.delete(identity.mpuSequenceNumber);
@@ -23974,6 +24249,14 @@ var MmtpRouter = class {
23974
24249
  const recovered = fec.feedSourceSymbol(pid, ssId, symbolData, name);
23975
24250
  if (recovered && !alreadyCompleted) this.#routeOverlayRecovered(inst, coords.sbn, recovered);
23976
24251
  }
24252
+ if (rebuiltPsn === void 0 || this.#classifyPacketId(packetId) === null) return;
24253
+ const streamKey = this.#mediaStreamKey(packetId, catalogTrackName);
24254
+ if (rebuiltPsn !== sequenceNumber) {
24255
+ this.#restampedRecoveredSeqs++;
24256
+ } else if (baseSsId !== void 0) {
24257
+ const offset = this.#ssIdSeqOffsetByStream.get(streamKey);
24258
+ if (offset === baseSsId - sequenceNumber >>> 0) this.#overlayUnstampedSsIdSeqOffsetByStream.set(streamKey, offset);
24259
+ }
23977
24260
  }
23978
24261
  #noteOverlayMoqBlock(inst, blockStart) {
23979
24262
  inst.moqBlocks.add(blockStart);
@@ -24007,11 +24290,16 @@ var MmtpRouter = class {
24007
24290
  }
24008
24291
  /**
24009
24292
  * Route an overlay-recovered block's packets as source media of the
24010
- * overlay's source (7.2: an overlay coordinate yields no base SS_ID). No
24011
- * base-SS_ID derivation runs: no release-gate anchor, no parse ledger, no
24012
- * base re-stamp, no block advance. Each packet's wire sequence is restored
24013
- * from its keyframe's own offset; without one, a stream the base never
24014
- * re-stamped keeps the header's sequence and any other drops the packet.
24293
+ * overlay's source (7.2: an overlay coordinate yields no base SS_ID). A
24294
+ * packet whose base SS_ID is determined independently (overlayBaseSsId,
24295
+ * unstampedBaseSsId) enters the release gate at that SS_ID, as a packet
24296
+ * base FEC recovered does, so it waits behind base frames still in flight,
24297
+ * though not for its own MPU's packets (FrameMeta.fecSbnFirst) and no
24298
+ * longer than unanchored; one whose base SS_ID is undetermined has no
24299
+ * release-gate anchor. No parse ledger, no base re-stamp, no block
24300
+ * advance. Each packet's wire sequence is restored from its keyframe's own
24301
+ * offset; without one, a stream the base never re-stamped keeps the
24302
+ * header's sequence and any other drops the packet.
24015
24303
  */
24016
24304
  #routeOverlayRecovered(inst, blockStart, recovered) {
24017
24305
  const { fec, repairPacketId: pid, streamName: name } = inst;
@@ -24032,40 +24320,65 @@ var MmtpRouter = class {
24032
24320
  const restamped = this.#restampedStreams.has(streamKey);
24033
24321
  const restored = /* @__PURE__ */ new Set();
24034
24322
  const baseSsIds = /* @__PURE__ */ new Map();
24323
+ const gateOnly = /* @__PURE__ */ new Set();
24035
24324
  const symbols = result.symbols.flatMap((sym) => {
24325
+ const overlaySsId = serial32Add(blockStart, sym.index);
24036
24326
  const anchor = inst.offsets.get(sym.fragment.mpuSequenceNumber);
24037
24327
  if (anchor !== void 0) {
24038
- const overlaySsId = serial32Add(blockStart, sym.index);
24039
24328
  const packetSequenceNumber = overlaySsId - anchor.psnOffset >>> 0;
24040
24329
  restored.add(packetSequenceNumber);
24041
24330
  const out = { ...sym, packetSequenceNumber };
24042
24331
  const baseSsId = overlayBaseSsId(anchor, overlaySsId, packetSequenceNumber, sym.packetSequenceNumber);
24043
- if (baseSsId !== void 0) baseSsIds.set(out, baseSsId);
24332
+ if (baseSsId !== void 0) {
24333
+ baseSsIds.set(sym.index, baseSsId);
24334
+ const offset = this.#ssIdSeqOffsetByStream.get(streamKey);
24335
+ if (!this.#sourceSeqReceived(sourcePacketId, packetSequenceNumber, sourceName) && offset === baseSsId - packetSequenceNumber >>> 0) {
24336
+ this.#overlayUnstampedSsIdSeqOffsetByStream.set(streamKey, offset);
24337
+ }
24338
+ }
24044
24339
  return [out];
24045
24340
  }
24046
24341
  if (!restamped) {
24047
- const baseSsId = unstampedBaseSsId(
24048
- this.#ssIdSeqOffsetByStream.get(streamKey),
24049
- this.#unstampedSsIdSeqOffsetByStream.get(streamKey),
24050
- sym.packetSequenceNumber
24051
- );
24052
- if (baseSsId !== void 0) baseSsIds.set(sym, baseSsId);
24342
+ const offset = this.#ssIdSeqOffsetByStream.get(streamKey);
24343
+ const fedSsId = unstampedBaseSsId(offset, this.#unstampedSsIdSeqOffsetByStream.get(streamKey), sym.packetSequenceNumber);
24344
+ const baseSsId = fedSsId ?? unstampedBaseSsId(offset, this.#overlayUnstampedSsIdSeqOffsetByStream.get(streamKey), sym.packetSequenceNumber);
24345
+ if (baseSsId !== void 0) {
24346
+ baseSsIds.set(sym.index, baseSsId);
24347
+ if (fedSsId === void 0) gateOnly.add(sym.index);
24348
+ } else {
24349
+ inst.rebuilt.set(overlaySsId, sym.packetSequenceNumber);
24350
+ if (inst.rebuilt.size > OVERLAY_REBUILT_LIMIT) inst.rebuilt.delete(inst.rebuilt.keys().next().value);
24351
+ }
24053
24352
  return [sym];
24054
24353
  }
24055
24354
  this.#overlayStats.unsequencedDropped++;
24056
24355
  return [];
24057
24356
  });
24058
24357
  this.#overlayStats.recovered++;
24358
+ const mpuFirst = /* @__PURE__ */ new Map();
24359
+ for (const sym of symbols) {
24360
+ const ssId = baseSsIds.get(sym.index);
24361
+ const mpu = sym.fragment.mpuSequenceNumber;
24362
+ const first = mpuFirst.get(mpu);
24363
+ if (ssId !== void 0 && (first === void 0 || serial32Precedes(ssId, first))) mpuFirst.set(mpu, ssId);
24364
+ }
24365
+ const firstSsIds = /* @__PURE__ */ new Map();
24366
+ for (const sym of symbols) {
24367
+ if (baseSsIds.has(sym.index)) firstSsIds.set(sym.index, mpuFirst.get(sym.fragment.mpuSequenceNumber));
24368
+ }
24059
24369
  this.#routeProcessedFecSymbols(blockStart, { ...result, symbols }, sourcePacketId, sourceName, {
24060
- deliveryProvenance: inst.moqBlocks.has(blockStart) ? "mixed" : "multicast"
24370
+ deliveryProvenance: inst.moqBlocks.has(blockStart) ? "mixed" : "multicast",
24371
+ baseSsIds,
24372
+ firstSsIds
24061
24373
  });
24062
24374
  this.#noteOverlayRecoveredRaps(sourcePacketId, sourceName, streamKey, symbols.filter((sym) => restored.has(sym.packetSequenceNumber)));
24063
- this.#feedOverlayRecoveredToBase(inst, blockStart, symbols, baseSsIds);
24375
+ this.#feedOverlayRecoveredToBase(inst, blockStart, symbols, gateOnly.size === 0 ? baseSsIds : new Map([...baseSsIds].filter(([index]) => !gateOnly.has(index))));
24064
24376
  }
24065
24377
  /**
24066
24378
  * Joint decoding (BLO-52085). A packet the overlay rebuilt is a source of
24067
24379
  * its base block too, once its base SS_ID is determined (overlayBaseSsId,
24068
- * from a received member of its MPU, else unstampedBaseSsId),
24380
+ * from a received member of its MPU, else unstampedBaseSsId at an offset a
24381
+ * base recovery showed; `baseSsIds` holds no other),
24069
24382
  * so it is fed to the base decoder as a rebuilt source
24070
24383
  * (FECManager.feedRebuiltSourceSymbol): the base block has one erasure
24071
24384
  * fewer to solve, and its repair can recover the block's other packets.
@@ -24091,7 +24404,7 @@ var MmtpRouter = class {
24091
24404
  const feeds = [];
24092
24405
  for (const sym of symbols) {
24093
24406
  if (duplicates.sourceSeqs.has(sym.packetSequenceNumber)) continue;
24094
- const ssId = baseSsIds.get(sym);
24407
+ const ssId = baseSsIds.get(sym.index);
24095
24408
  if (ssId === void 0) {
24096
24409
  this.#overlayStats.baseUndetermined++;
24097
24410
  continue;
@@ -24820,7 +25133,7 @@ var MmtpRouter = class {
24820
25133
  catalogTrackName
24821
25134
  )) continue;
24822
25135
  if (fec.isTrackConfigured?.(ownerPacketId) !== false) {
24823
- fec.trackBlock(blockStart, ownerPacketId, catalogTrackName, esi, payload.evidence);
25136
+ fec.trackBlock(blockStart, ownerPacketId, catalogTrackName, esi, payload.evidence, true);
24824
25137
  this.#requestFecPoke(ownerPacketId, catalogTrackName);
24825
25138
  }
24826
25139
  if (payload.oversized) continue;
@@ -24908,6 +25221,7 @@ var MmtpRouter = class {
24908
25221
  this.#latestVideoSeqByStream.delete(streamKey);
24909
25222
  this.#ssIdSeqOffsetByStream.delete(streamKey);
24910
25223
  this.#unstampedSsIdSeqOffsetByStream.delete(streamKey);
25224
+ this.#overlayUnstampedSsIdSeqOffsetByStream.delete(streamKey);
24911
25225
  this.#multicastRapTwinByStream.delete(streamKey);
24912
25226
  this.#lastDirectSourceVideoTsByStream.delete(streamKey);
24913
25227
  this.#lastDirectEmittedVideoTsByStream.delete(streamKey);
@@ -25034,6 +25348,12 @@ var MmtpRouter = class {
25034
25348
  * the outgoing stream stops. The hold is bounded by the incoming stream's
25035
25349
  * own RAP cadence, so no timer is involved.
25036
25350
  *
25351
+ * While the switch is staged, the FEC release gate lets a keyframe the
25352
+ * overlay rebuilt reach it, on either side, when the keyframe did before
25353
+ * the gate anchored it (FecReleaseGate.releaseUnanchored): the switch
25354
+ * decides on what each side delivered, and the commit discards the gate's
25355
+ * frames of the outgoing stream.
25356
+ *
25037
25357
  * The commit is one synchronous transition inside the frame path: the
25038
25358
  * outgoing stream's already-admitted frames are released (its queued FEC
25039
25359
  * gap fills only up to the RAP, since recovery can queue a frame ahead of
@@ -25084,6 +25404,9 @@ var MmtpRouter = class {
25084
25404
  droppedIncomingFrames: 0
25085
25405
  };
25086
25406
  this.#staged = state;
25407
+ if (this.#parser.fecReleaseHoldsRebuilt(from.packetId, from.catalogTrackName)) {
25408
+ this.#requestFecPoke(from.packetId, from.catalogTrackName);
25409
+ }
25087
25410
  return {
25088
25411
  cancel: () => {
25089
25412
  if (this.#staged !== state) return false;
@@ -25166,7 +25489,17 @@ var MmtpRouter = class {
25166
25489
  */
25167
25490
  #routeParsedFrame(data, meta) {
25168
25491
  if (this.#classifyPacketId(meta.packetId) === null) return;
25169
- if (this.#staged !== null && this.#interceptSwitchFrame(data, meta)) return;
25492
+ if (this.#staged !== null) {
25493
+ let consumed = false;
25494
+ this.#fecPokeDepth++;
25495
+ try {
25496
+ consumed = this.#interceptSwitchFrame(data, meta);
25497
+ } finally {
25498
+ this.#fecPokeDepth--;
25499
+ this.#flushPendingFecPokes();
25500
+ }
25501
+ if (consumed) return;
25502
+ }
25170
25503
  this.#deliverParsedFrame(data, meta);
25171
25504
  }
25172
25505
  #commitStreamSwitch(state, rap, reason) {
@@ -25318,12 +25651,16 @@ var MmtpRouter = class {
25318
25651
  this.#parser.deferFecRelease(work);
25319
25652
  } finally {
25320
25653
  this.#fecPokeDepth--;
25321
- if (this.#fecPokeDepth === 0 && this.#pendingFecPokes.size > 0 && !this.#disposed) {
25322
- const pokes = [...this.#pendingFecPokes.values()];
25323
- this.#pendingFecPokes.clear();
25324
- for (const poke of pokes) {
25325
- this.#parser.pokeFecRelease(poke.packetId, poke.catalogTrackName);
25326
- }
25654
+ this.#flushPendingFecPokes();
25655
+ }
25656
+ }
25657
+ /** Run the pokes requested while #fecPokeDepth was raised, once it is 0. */
25658
+ #flushPendingFecPokes() {
25659
+ if (this.#fecPokeDepth === 0 && this.#pendingFecPokes.size > 0 && !this.#disposed) {
25660
+ const pokes = [...this.#pendingFecPokes.values()];
25661
+ this.#pendingFecPokes.clear();
25662
+ for (const poke of pokes) {
25663
+ this.#parser.pokeFecRelease(poke.packetId, poke.catalogTrackName);
25327
25664
  }
25328
25665
  }
25329
25666
  }
@@ -25401,6 +25738,14 @@ var MmtpRouter = class {
25401
25738
  onRap: (packetId, catalogTrackName, ssId) => {
25402
25739
  for (const observer of this.#fecRapObservers) observer(packetId, catalogTrackName, ssId);
25403
25740
  },
25741
+ // A staged switch commits on when each side's frames reach it
25742
+ // (#interceptSwitchFrame): both sides' overlay keyframes reach it when
25743
+ // they did unanchored.
25744
+ releaseUnanchored: (packetId, catalogTrackName) => {
25745
+ const staged = this.#staged;
25746
+ const stream = { packetId, ...catalogTrackName === void 0 ? {} : { catalogTrackName } };
25747
+ return staged !== null && (isStream(stream, staged.from) || isStream(stream, staged.to));
25748
+ },
25404
25749
  onReleased: (data, meta) => {
25405
25750
  if (meta.trackType !== "video" || meta.mpuSequenceNumber === void 0) return;
25406
25751
  const timing = meta.authoredTiming;
@@ -25851,7 +26196,8 @@ var MmtpRouter = class {
25851
26196
  source: parserOptions?.source,
25852
26197
  deliveryProvenance: parserOptions?.deliveryProvenance,
25853
26198
  catalogTrackName: parserOptions?.catalogTrackName,
25854
- ...parserOptions?.fecSbn === void 0 ? {} : { fecSbn: parserOptions.fecSbn }
26199
+ ...parserOptions?.fecSbn === void 0 ? {} : { fecSbn: parserOptions.fecSbn },
26200
+ ...parserOptions?.fecSbnFirst === void 0 ? {} : { fecSbnFirst: parserOptions.fecSbnFirst }
25855
26201
  };
25856
26202
  if (parsed) this.#parser.feedParsed(packet, parsed, feedOptions);
25857
26203
  else this.#parser.feed(packet, feedOptions);
@@ -26317,7 +26663,12 @@ var MmtpRouter = class {
26317
26663
  #routeProcessedFecSymbols(blockId, result, ownerPacketId, catalogTrackName, overlay) {
26318
26664
  if (ownerPacketId == null) return;
26319
26665
  if (this.#classifyPacketId(ownerPacketId) === null) return;
26320
- const fecSbnOf = (index) => overlay ? {} : { fecSbn: this.#fecAnchorForRecovered(blockId, index) };
26666
+ const fecSbnOf = (index) => {
26667
+ if (!overlay) return { fecSbn: this.#fecAnchorForRecovered(blockId, index) };
26668
+ const fecSbn = overlay.baseSsIds.get(index);
26669
+ const fecSbnFirst = overlay.firstSsIds.get(index);
26670
+ return fecSbn === void 0 || fecSbnFirst === void 0 ? {} : { fecSbn, fecSbnFirst };
26671
+ };
26321
26672
  const streamKey = this.#mediaStreamKey(ownerPacketId, catalogTrackName);
26322
26673
  const ssIdSeqOffset = overlay ? void 0 : this.#ssIdSeqOffsetByStream.get(streamKey);
26323
26674
  if (ssIdSeqOffset !== void 0) {
@@ -28113,6 +28464,12 @@ function isNonNegativeSafeInteger(value) {
28113
28464
  function isSymbolCounts(value) {
28114
28465
  return isRecord(value) && hasExactKeys(value, ["multicast", "moq"]) && isNonNegativeSafeInteger(value["multicast"]) && isNonNegativeSafeInteger(value["moq"]);
28115
28466
  }
28467
+ function isSpanFailure(value) {
28468
+ if (!isRecord(value) || !hasExactKeys(value, ["length", "sources"])) return false;
28469
+ const length = value["length"];
28470
+ const sources = value["sources"];
28471
+ return Number.isSafeInteger(length) && length > 0 && isNonNegativeSafeInteger(sources) && sources < length;
28472
+ }
28116
28473
  function isNullableNonNegative(value) {
28117
28474
  return value === null || typeof value === "number" && Number.isFinite(value) && value >= 0;
28118
28475
  }
@@ -28132,7 +28489,10 @@ function isRepairLifecycleEvent(value) {
28132
28489
  case "recovered":
28133
28490
  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"]);
28134
28491
  case "failed":
28135
- return hasExactKeys(value, ["version", "type", "block", "blockClass", "symbols"]) && isRepairBlockIdentity(block) && BLOCK_CLASSES.has(value["blockClass"]) && isSymbolCounts(value["symbols"]);
28492
+ return hasExactKeys(
28493
+ value,
28494
+ "span" in value ? ["version", "type", "block", "blockClass", "symbols", "span"] : ["version", "type", "block", "blockClass", "symbols"]
28495
+ ) && isRepairBlockIdentity(block) && BLOCK_CLASSES.has(value["blockClass"]) && isSymbolCounts(value["symbols"]) && (!("span" in value) || isSpanFailure(value["span"]));
28136
28496
  case "repair-preempted":
28137
28497
  case "repair-deferred":
28138
28498
  return hasExactKeys(value, ["version", "type", "block"]) && isRepairBlockIdentity(block);
@@ -28341,6 +28701,8 @@ var MulticastManager = class {
28341
28701
  /** Live joinRepairEndpoints() releases → the repair owners they registered,
28342
28702
  * re-applied after configureMultiTrack() clears catalog-derived owners. */
28343
28703
  #repairReleases = /* @__PURE__ */ new Map();
28704
+ /** Protected repair flows owned by each live repair-endpoint release. */
28705
+ #repairProvenanceFlows = /* @__PURE__ */ new Map();
28344
28706
  /** Each source's catalog keyframe overlay, from its `ok` binding. */
28345
28707
  #overlayBindings = /* @__PURE__ */ new Map();
28346
28708
  /** Installed overlay decoders (`setKeyframeOverlayDecoder`), by source. */
@@ -28398,7 +28760,7 @@ var MulticastManager = class {
28398
28760
  onPublisherRestart: (packetId, catalogTrackName) => {
28399
28761
  this.#resetStreamOutsideRouter(packetId, catalogTrackName);
28400
28762
  },
28401
- onBlockFailed: (sbn, packetId, catalogTrackName, blockClass) => {
28763
+ onBlockFailed: (sbn, packetId, catalogTrackName, blockClass, span) => {
28402
28764
  const pathCount = this.#fec.blockPathCount(
28403
28765
  sbn,
28404
28766
  packetId,
@@ -28416,7 +28778,8 @@ var MulticastManager = class {
28416
28778
  symbols: {
28417
28779
  multicast: pathCount?.multicastSymbols ?? 0,
28418
28780
  moq: pathCount?.moqSymbols ?? 0
28419
- }
28781
+ },
28782
+ ...span === void 0 ? {} : { span }
28420
28783
  });
28421
28784
  },
28422
28785
  onFrameGap: (decision) => {
@@ -29006,6 +29369,9 @@ var MulticastManager = class {
29006
29369
  this.#transport.onFailback((reason) => {
29007
29370
  const joined = this.#provenanceJoin;
29008
29371
  if (joined !== null) this.#router.unprotectProvenanceFlow(joined.flow);
29372
+ for (const flows of this.#repairProvenanceFlows.values()) {
29373
+ for (const flow of flows) this.#router.unprotectProvenanceFlow(flow);
29374
+ }
29009
29375
  this.#onFailback?.(reason);
29010
29376
  });
29011
29377
  }
@@ -29040,6 +29406,12 @@ var MulticastManager = class {
29040
29406
  "[MulticastManager] join: provenance flow (S, G, port) must match sourceAddress ?? senderAddress, groupAddress and port"
29041
29407
  );
29042
29408
  }
29409
+ if (provenanceFlow !== void 0) {
29410
+ const flow = multicastProvenanceFlowKey(provenanceFlow);
29411
+ if ([...this.#repairProvenanceFlows.values()].some((flows) => flows.includes(flow))) {
29412
+ throw new TypeError(`[MulticastManager] join: provenance flow already registered by a repair endpoint: ${flow}`);
29413
+ }
29414
+ }
29043
29415
  const staged = this.#renditionSwitch;
29044
29416
  if (staged !== null) this.#endRenditionSwitch(staged, { reason: "superseded" }, false);
29045
29417
  this.configureFec(config);
@@ -29047,8 +29419,8 @@ var MulticastManager = class {
29047
29419
  const selectedPacketIds = config.packetIds?.length ? new Set(config.packetIds) : null;
29048
29420
  const catalogTracks = config.tracks?.filter(({ packetId }) => selectedPacketIds === null || selectedPacketIds.has(packetId));
29049
29421
  const ingressTracks = catalogTracks ? catalogTracks : selectedPacketIds === null ? void 0 : [...selectedPacketIds].map((packetId) => ({ name: "", packetId }));
29050
- this.#releaseProvenanceJoin();
29051
29422
  const provenance = config.provenance;
29423
+ this.#releaseProvenanceJoin();
29052
29424
  const protectedTracks = ingressTracks ?? provenance?.flow.tracks;
29053
29425
  const ingress = protectedTracks === void 0 ? void 0 : {
29054
29426
  tracks: Object.freeze(
@@ -29101,8 +29473,13 @@ var MulticastManager = class {
29101
29473
  * feeds the flow's receiver; media datagrams are held until one admits them.
29102
29474
  */
29103
29475
  #joinProvenance(provenance, ingress) {
29476
+ this.#router.registerProvenanceFlow(ingress, this.#provenanceReceiver(provenance));
29477
+ this.#hookFailback();
29478
+ this.#provenanceJoin = { flow: ingress.flow };
29479
+ }
29480
+ #provenanceReceiver(provenance) {
29104
29481
  const { flow, auth } = provenance;
29105
- const receiver = new BcProvenanceV2Receiver({
29482
+ return new BcProvenanceV2Receiver({
29106
29483
  ...provenance.receiver,
29107
29484
  // The caller's ctx when it passes one, else the catalog's: an explicit
29108
29485
  // `catalogCtx: undefined` must not switch off the 7.2.4 ctx check.
@@ -29119,9 +29496,6 @@ var MulticastManager = class {
29119
29496
  maxAuthDelayMs: flow.maxAuthDelayMs,
29120
29497
  manifestTimerMs: flow.manifestTimerMs
29121
29498
  });
29122
- this.#router.registerProvenanceFlow(ingress, receiver);
29123
- this.#hookFailback();
29124
- this.#provenanceJoin = { flow: ingress.flow };
29125
29499
  }
29126
29500
  /** Un-protect: drop the flow's hold queues (no expiry counted). */
29127
29501
  #releaseProvenanceJoin() {
@@ -29152,8 +29526,25 @@ var MulticastManager = class {
29152
29526
  * callback delivers must come from a subscription opened on
29153
29527
  * `options.gateway`. A callback that also merges another source (a MoQ or
29154
29528
  * WebSocket repair feed) must not name a native provider here.
29529
+ *
29530
+ * An endpoint's `provenance` makes its group a protected flow of its own
29531
+ * (multicast-02 7.2.7), registered here and released with the group; its
29532
+ * records ride in band on the group's port. On the user-agent path a
29533
+ * flow-less repair group of a protected track is set aside, never read, so
29534
+ * a player joining a protected catalog's repair groups passes it.
29155
29535
  */
29156
29536
  joinRepairEndpoints(endpoints, provider, options) {
29537
+ for (const { provenance, flow, source, group, port: port2 } of endpoints) {
29538
+ if (provenance === void 0) continue;
29539
+ if (flow !== void 0) {
29540
+ throw new TypeError("[MulticastManager] joinRepairEndpoints: an endpoint names provenance or flow, never both");
29541
+ }
29542
+ if (!sameIpAddress(source, provenance.flow.source) || !sameIpAddress(group, provenance.flow.groupAddress) || port2 !== provenance.flow.port) {
29543
+ throw new RangeError(
29544
+ "[MulticastManager] joinRepairEndpoints: provenance flow (S, G, port) must match the endpoint's source, group and port"
29545
+ );
29546
+ }
29547
+ }
29157
29548
  const releases = [];
29158
29549
  let released = false;
29159
29550
  const safely = (each) => {
@@ -29163,17 +29554,36 @@ var MulticastManager = class {
29163
29554
  console.warn("[MulticastManager] repair group release failed", error);
29164
29555
  }
29165
29556
  };
29557
+ const usedFlows = /* @__PURE__ */ new Set();
29558
+ const preparedProvenance = endpoints.map((endpoint) => {
29559
+ if (endpoint.provenance === void 0) return void 0;
29560
+ const flow = multicastProvenanceFlowKey(endpoint.provenance.flow);
29561
+ if (usedFlows.has(flow) || this.#provenanceJoin?.flow === flow || [...this.#repairProvenanceFlows.values()].some((flows) => flows.includes(flow))) {
29562
+ throw new TypeError(`[MulticastManager] joinRepairEndpoints: provenance flow already registered: ${flow}`);
29563
+ }
29564
+ usedFlows.add(flow);
29565
+ return { flow, receiver: this.#provenanceReceiver(endpoint.provenance) };
29566
+ });
29567
+ const provenanceFlows = [];
29166
29568
  const repairOwners = endpoints.flatMap((endpoint) => endpoint.repairOwners.map((owner) => owner.instance !== "keyframe" ? owner : {
29167
29569
  ...owner,
29168
29570
  ownerTrackName: endpoint.tracks.find((t) => t.packetId === owner.ownerPacketId)?.name,
29169
29571
  repairTrackName: endpoint.tracks.find((t) => t.packetId === owner.packetId)?.name
29170
29572
  }));
29171
29573
  this.#applyRepairOwners(repairOwners);
29172
- for (const endpoint of endpoints) {
29574
+ for (const [index, endpoint] of endpoints.entries()) {
29575
+ const flow = endpoint.provenance === void 0 ? endpoint.flow : multicastProvenanceFlowKey(endpoint.provenance.flow);
29173
29576
  const ingress = {
29174
29577
  tracks: Object.freeze(endpoint.tracks.map(({ name, packetId }) => Object.freeze({ name, packetId }))),
29175
- ...endpoint.flow === void 0 ? {} : { flow: endpoint.flow }
29578
+ ...flow === void 0 ? {} : { flow }
29176
29579
  };
29580
+ if (endpoint.provenance !== void 0) {
29581
+ const prepared = preparedProvenance[index];
29582
+ if (prepared === void 0) throw new Error("[MulticastManager] repair provenance preparation missing");
29583
+ this.#router.registerProvenanceFlow(ingress, prepared.receiver);
29584
+ provenanceFlows.push(prepared.flow);
29585
+ releases.push(() => this.#router.releaseProvenanceFlow(flow));
29586
+ }
29177
29587
  let sender;
29178
29588
  const feed = (packet, sourceAddress, ingressToFeed) => {
29179
29589
  if (released) return;
@@ -29203,7 +29613,7 @@ var MulticastManager = class {
29203
29613
  packetIds: endpoint.packetIds,
29204
29614
  tracks: endpoint.tracks.map(({ name, packetId }) => ({ name, packetId })),
29205
29615
  // A protected repair group's records ride in band on its port (7.2.3).
29206
- ...endpoint.flow === void 0 ? {} : { protected: true }
29616
+ ...flow === void 0 ? {} : { protected: true }
29207
29617
  }, ingress).then((joined) => {
29208
29618
  if (!joined && !released) fail(transport.lastJoinOutcome);
29209
29619
  });
@@ -29212,9 +29622,12 @@ var MulticastManager = class {
29212
29622
  if (released) return;
29213
29623
  released = true;
29214
29624
  this.#repairReleases.delete(release);
29625
+ this.#repairProvenanceFlows.delete(release);
29215
29626
  for (const each of releases.splice(0)) safely(each);
29216
29627
  };
29217
29628
  this.#repairReleases.set(release, repairOwners);
29629
+ this.#repairProvenanceFlows.set(release, provenanceFlows);
29630
+ if (provenanceFlows.length) this.#hookFailback();
29218
29631
  return release;
29219
29632
  }
29220
29633
  #applyRepairOwners(owners) {
@@ -29796,10 +30209,12 @@ var MulticastManager = class {
29796
30209
  * Register the range repair scheduler (draft-ramadan-moq-fec §7.5): from now
29797
30210
  * on an iso-ssbg1-v1 gap missing a source and every repair is kept as a
29798
30211
  * pending hole and reported here, and `closeIsoHoles` must be called for it
29799
- * once its range FETCH ends (or none is sent).
30212
+ * once its range FETCH ends (or none is sent). `onExpired` is told of a
30213
+ * pending hole the stream runs past the locator's horizon: returning true,
30214
+ * the caller closes it (`closeIsoHoles`); otherwise it is discarded.
29800
30215
  */
29801
- onUndelimitedLoss(cb) {
29802
- this.#fec.setUndelimitedLossHandler(cb);
30216
+ onUndelimitedLoss(cb, onExpired) {
30217
+ this.#fec.setUndelimitedLossHandler(cb, onExpired ?? null);
29803
30218
  }
29804
30219
  /**
29805
30220
  * Report each stream whose FEC fails closed at runtime (fec-01 6.3.9), e.g.
@@ -30035,7 +30450,8 @@ var MulticastManager = class {
30035
30450
  "stream" in event ? { ...event, stream: Object.freeze({ ...event.stream }) } : {
30036
30451
  ...event,
30037
30452
  block: Object.freeze({ ...event.block }),
30038
- ..."symbols" in event ? { symbols: Object.freeze({ ...event.symbols }) } : {}
30453
+ ..."symbols" in event ? { symbols: Object.freeze({ ...event.symbols }) } : {},
30454
+ ..."span" in event && event.span !== void 0 ? { span: Object.freeze({ ...event.span }) } : {}
30039
30455
  }
30040
30456
  );
30041
30457
  if (this.#logRepairLifecycle) {
@@ -32707,6 +33123,7 @@ var RepairLayerSubscriptions = class {
32707
33123
 
32708
33124
  // src/repair-fetch.ts
32709
33125
  var MOQT_PRIORITY_MAX = 255;
33126
+ var WALK_LEAD_MS = 3;
32710
33127
  var SOURCE_MEDIA_PRIORITY_MAX = REPAIR_PRIORITY_MIN - 1;
32711
33128
  function isMoqtPriority(value) {
32712
33129
  return Number.isSafeInteger(value) && value >= 0 && value <= MOQT_PRIORITY_MAX;
@@ -32757,8 +33174,17 @@ function wireRepairFetch(mc, opts) {
32757
33174
  mc.noteRepairScheduling(type, entry.sbn, entry.packetId, entry.catalogTrackName);
32758
33175
  };
32759
33176
  const clearEntry = (entry) => {
32760
- if (entry.range) mc.closeIsoHoles(entry.packetId, entry.catalogTrackName, entry.sbn, entry.range.holeLength);
32761
- else clearRepair(entry.sbn, entry.packetId, entry.catalogTrackName);
33177
+ const { range } = entry;
33178
+ if (range) {
33179
+ atReport(range, () => mc.closeIsoHoles(entry.packetId, entry.catalogTrackName, entry.sbn, range.holeLength));
33180
+ } else {
33181
+ clearRepair(entry.sbn, entry.packetId, entry.catalogTrackName);
33182
+ }
33183
+ };
33184
+ const atReport = (range, work) => {
33185
+ const fec = mc.fecManager;
33186
+ if (range.groups !== null || fec.withReceiptTime === void 0) work();
33187
+ else fec.withReceiptTime(range.reportedAt, work);
32762
33188
  };
32763
33189
  const releaseHold = (entry) => {
32764
33190
  if (entry.range) return;
@@ -32825,21 +33251,42 @@ function wireRepairFetch(mc, opts) {
32825
33251
  const admit = (entry, now) => {
32826
33252
  const budgetMs = entry.deadlineAt - now;
32827
33253
  const rttMs = readRtt();
32828
- const reason = !entry.range && !groupCanComplete(entry.sbn, entry.packetId, entry.catalogTrackName) ? "group-cannot-complete" : rttMs === null ? "rtt-unmeasured" : rttMs < budgetMs ? null : "rtt-exceeds-budget";
33254
+ const reason = refusal(entry, now, rttMs);
32829
33255
  if (reason === null) return rttMs;
32830
33256
  skip(entry, reason, rttMs, budgetMs);
32831
33257
  return null;
32832
33258
  };
33259
+ const refusal = (entry, now, rttMs) => !entry.range && !groupCanComplete(entry.sbn, entry.packetId, entry.catalogTrackName) ? "group-cannot-complete" : rttMs === null ? "rtt-unmeasured" : rttMs < entry.deadlineAt - now ? null : "rtt-exceeds-budget";
32833
33260
  const retire = (entry) => {
32834
33261
  if (entries.get(entry.key) === entry) entries.delete(entry.key);
32835
33262
  if (entry.expiry !== void 0) clearTimeout(entry.expiry);
32836
33263
  };
32837
- const outstandingBelow = (priority) => {
33264
+ const holdsBack = (entry, priority, videoWalks = true) => {
33265
+ if (entry.priority >= priority) return false;
33266
+ const { walk } = entry;
33267
+ if (walk === void 0) return true;
33268
+ if (entry.priority !== 0 || !videoWalks) return false;
33269
+ return entry.running === null || walk.group !== null && !walk.group.controller.signal.aborted;
33270
+ };
33271
+ const outstandingBelow = (priority, videoWalks = true) => {
32838
33272
  for (const entry of entries.values()) {
32839
- if (entry.priority < priority) return true;
33273
+ if (holdsBack(entry, priority, videoWalks)) return true;
32840
33274
  }
32841
33275
  return false;
32842
33276
  };
33277
+ const yieldVideoWalks = (queued) => {
33278
+ const now = performance.now();
33279
+ const rttMs = readRtt();
33280
+ const due = queued.some((entry) => entry.priority !== 0 && entry.walk === void 0 && !outstandingBelow(entry.priority, false) && refusal(entry, now, rttMs) === null);
33281
+ if (!due) return;
33282
+ for (const entry of entries.values()) {
33283
+ const walk = entry.walk;
33284
+ if (entry.priority !== 0 || walk === void 0 || walk.group === null) continue;
33285
+ if (walk.group.controller.signal.aborted) continue;
33286
+ walk.yielded = true;
33287
+ walk.group.controller.abort();
33288
+ }
33289
+ };
32843
33290
  const preemptBestEffort = () => {
32844
33291
  for (const entry of entries.values()) {
32845
33292
  if (entry.priority === 0 || !entry.running) continue;
@@ -32868,7 +33315,8 @@ function wireRepairFetch(mc, opts) {
32868
33315
  }
32869
33316
  const rttMs = admit(entry, now);
32870
33317
  if (rttMs === null) return;
32871
- if (entry.priority === 0) preemptBestEffort();
33318
+ if (entry.priority === 0 && entry.walk === void 0) preemptBestEffort();
33319
+ if (entry.range === void 0) leaveWalkedGroup(entry);
32872
33320
  if (counted) entry.attempts++;
32873
33321
  mc.noteRepairFetchSent?.(entry.priority, entry.sbn, entry.packetId, entry.catalogTrackName, {
32874
33322
  rttMs,
@@ -32890,6 +33338,14 @@ function wireRepairFetch(mc, opts) {
32890
33338
  }
32891
33339
  void execute(entry, run);
32892
33340
  };
33341
+ const leaveWalkedGroup = (block) => {
33342
+ for (const entry of entries.values()) {
33343
+ const group = entry.walk?.group;
33344
+ if (group?.sbn === block.sbn && entry.packetId === block.packetId && entry.catalogTrackName === block.catalogTrackName) {
33345
+ group.controller.abort();
33346
+ }
33347
+ }
33348
+ };
32893
33349
  const dispatch = () => {
32894
33350
  if (disposed) return;
32895
33351
  const now = performance.now();
@@ -32914,6 +33370,7 @@ function wireRepairFetch(mc, opts) {
32914
33370
  for (const entry of queued) {
32915
33371
  if (entry.priority === 0) start(entry);
32916
33372
  }
33373
+ yieldVideoWalks(queued);
32917
33374
  if (outstandingBelow(1)) return;
32918
33375
  for (const entry of queued) {
32919
33376
  if (entry.priority === 1) start(entry);
@@ -32940,6 +33397,16 @@ function wireRepairFetch(mc, opts) {
32940
33397
  }
32941
33398
  return counts.sources + counts.repairGroupLength >= counts.authoredLength;
32942
33399
  };
33400
+ const groupServed = (sbn, packetId, catalogTrackName) => {
33401
+ if (mc.fecManager.blockSymbolCounts === void 0) return false;
33402
+ const counts = symbolCounts(sbn, packetId, catalogTrackName);
33403
+ if (counts === void 0 || counts.authoredLength === void 0) return true;
33404
+ return counts.sources + counts.repairs >= counts.authoredLength || !groupCanComplete(sbn, packetId, catalogTrackName);
33405
+ };
33406
+ const statedEnd = (sbn, packetId, catalogTrackName) => {
33407
+ const length = symbolCounts(sbn, packetId, catalogTrackName)?.authoredLength;
33408
+ return length === void 0 ? void 0 : serial32Add(sbn, length);
33409
+ };
32943
33410
  const finish = (entry, run, fed, error) => {
32944
33411
  if (run.stopped !== null || entry.running !== run || entries.get(entry.key) !== entry) {
32945
33412
  return;
@@ -32984,27 +33451,81 @@ function wireRepairFetch(mc, opts) {
32984
33451
  signal: run.controller.signal,
32985
33452
  subscriberPriority: entry.subscriberPriority
32986
33453
  };
33454
+ const { range } = entry;
33455
+ const feed = (packet) => {
33456
+ if (entry.catalogTrackName === void 0) {
33457
+ mc.processRepairFromMoQ(packet, entry.packetId);
33458
+ } else {
33459
+ mc.processRepairFromMoQ(packet, entry.packetId, entry.catalogTrackName);
33460
+ }
33461
+ };
32987
33462
  try {
32988
- const packets = entry.range ? opts.rangeTransport({
33463
+ const packets = range === void 0 ? opts.transport({ ...common, sbn: entry.sbn }) : entry.walk !== void 0 ? walkHole(entry, entry.walk, range, common) : opts.rangeTransport({
32989
33464
  ...common,
32990
- startGroup: entry.range.startGroup,
32991
- endGroup: entry.range.endGroup,
32992
- lostSsId: entry.range.lostSsId
32993
- }) : opts.transport({ ...common, sbn: entry.sbn });
33465
+ startGroup: range.groups.startGroup,
33466
+ endGroup: range.groups.endGroup,
33467
+ lostSsId: range.lostSsId
33468
+ });
32994
33469
  for await (const packet of packets) {
32995
33470
  if (run.stopped !== null || entry.running !== run) break;
32996
33471
  fed++;
32997
- if (entry.catalogTrackName === void 0) {
32998
- mc.processRepairFromMoQ(packet, entry.packetId);
32999
- } else {
33000
- mc.processRepairFromMoQ(packet, entry.packetId, entry.catalogTrackName);
33001
- }
33472
+ if (range === void 0) feed(packet);
33473
+ else atReport(range, () => feed(packet));
33002
33474
  }
33003
33475
  finish(entry, run, fed, void 0);
33004
33476
  } catch (error) {
33005
33477
  finish(entry, run, fed, error ?? new Error("repair transport rejected"));
33006
33478
  }
33007
33479
  };
33480
+ async function* walkHole(entry, walk, hole, common) {
33481
+ const lostAt = serial32Distance(entry.sbn, hole.lostSsId);
33482
+ for (let first = true; !common.signal.aborted; first = false) {
33483
+ if (!first && entry.priority !== 0 && outstandingBelow(1)) {
33484
+ stop(entry, "preempted");
33485
+ note("repair-preempted", entry);
33486
+ return;
33487
+ }
33488
+ if (!first && entry.priority === 0) {
33489
+ dispatch();
33490
+ if (common.signal.aborted) return;
33491
+ }
33492
+ const sbn = walk.next;
33493
+ let next;
33494
+ if (entries.get(requestKey(sbn, entry.packetId, entry.catalogTrackName))?.running == null) {
33495
+ const rsIds = /* @__PURE__ */ new Set();
33496
+ const group = new AbortController();
33497
+ const cancel = () => group.abort();
33498
+ common.signal.addEventListener("abort", cancel, { once: true });
33499
+ walk.group = { sbn, controller: group };
33500
+ try {
33501
+ for await (const packet of opts.transport({ ...common, sbn, signal: group.signal })) {
33502
+ yield packet;
33503
+ const id = extractRepairFecPayloadId(packet);
33504
+ if (id === null || id.ssStart !== sbn || id.ssbLength === 0) continue;
33505
+ next ??= serial32Add(sbn, id.ssbLength);
33506
+ rsIds.add(id.rsId);
33507
+ if (rsIds.size >= id.rsbLength || groupServed(sbn, entry.packetId, entry.catalogTrackName)) {
33508
+ group.abort();
33509
+ break;
33510
+ }
33511
+ }
33512
+ } catch (error) {
33513
+ if (!group.signal.aborted || common.signal.aborted) throw error;
33514
+ } finally {
33515
+ common.signal.removeEventListener("abort", cancel);
33516
+ walk.group = null;
33517
+ }
33518
+ if (walk.yielded) {
33519
+ walk.yielded = false;
33520
+ continue;
33521
+ }
33522
+ }
33523
+ next ??= statedEnd(sbn, entry.packetId, entry.catalogTrackName);
33524
+ if (next === void 0) return;
33525
+ if (serial32Distance(entry.sbn, next) > lostAt) return;
33526
+ walk.next = next;
33527
+ }
33528
+ }
33008
33529
  const removeResetObserver = mc.addTrackResetObserver((packetId, catalogTrackName) => {
33009
33530
  for (const entry of [...entries.values()]) {
33010
33531
  if (entry.packetId !== packetId || entry.catalogTrackName !== catalogTrackName) continue;
@@ -33200,7 +33721,37 @@ function wireRepairFetch(mc, opts) {
33200
33721
  throw error;
33201
33722
  }
33202
33723
  };
33203
- if (opts.rangeTransport !== void 0) {
33724
+ const trackKind = (packetId) => {
33725
+ try {
33726
+ return mc.fecManager.trackKindFor?.(packetId);
33727
+ } catch {
33728
+ return void 0;
33729
+ }
33730
+ };
33731
+ const walkDeadlineAt = (packetId, now, deadlineAt) => {
33732
+ let repairDeadlineMs;
33733
+ try {
33734
+ repairDeadlineMs = mc.fecManager.repairDeadlineMsForPacket?.(packetId);
33735
+ } catch {
33736
+ repairDeadlineMs = void 0;
33737
+ }
33738
+ if (repairDeadlineMs === void 0 || !Number.isFinite(repairDeadlineMs)) return deadlineAt;
33739
+ return Math.min(deadlineAt, now + repairDeadlineMs - WALK_LEAD_MS);
33740
+ };
33741
+ const onHoleExpired = (hole) => {
33742
+ if (disposed) return false;
33743
+ const entry = [...entries.values()].find((candidate) => candidate.walk !== void 0 && candidate.packetId === hole.packetId && candidate.catalogTrackName === hole.catalogTrackName && serial32Distance(candidate.sbn, hole.holeStart) < candidate.range.holeLength);
33744
+ if (entry === void 0) return false;
33745
+ queueMicrotask(() => {
33746
+ if (entries.get(entry.key) !== entry) return;
33747
+ retire(entry);
33748
+ stop(entry, "expired");
33749
+ clearEntry(entry);
33750
+ dispatch();
33751
+ });
33752
+ return true;
33753
+ };
33754
+ if (mc.onUndelimitedLoss !== void 0 && mc.closeIsoHoles !== void 0) {
33204
33755
  mc.onUndelimitedLoss((loss) => {
33205
33756
  const { packetId, catalogTrackName, holeStart, holeLength } = loss;
33206
33757
  const close = () => mc.closeIsoHoles(packetId, catalogTrackName, holeStart, holeLength);
@@ -33208,9 +33759,11 @@ function wireRepairFetch(mc, opts) {
33208
33759
  const blockProfile = blockProfileOf(packetId);
33209
33760
  const target = opts.resolveTarget(packetId, catalogTrackName);
33210
33761
  if (!target) return close();
33211
- const priority = repairPriorityForClass("unknown");
33212
- const range = rangeFor(loss, target.repairTrack);
33213
- if (range === null) {
33762
+ const walked = opts.rangeTransport === void 0;
33763
+ const holeClass = walked && trackKind(packetId) === "audio" ? "audio" : "unknown";
33764
+ const priority = repairPriorityForClass(holeClass);
33765
+ const groups = walked ? null : rangeFor(loss, target.repairTrack);
33766
+ if (!walked && groups === null) {
33214
33767
  close();
33215
33768
  mc.noteRepairSkipped("range-no-anchor", { rttMs: readRtt(), budgetMs: 0, priority }, holeStart, packetId, catalogTrackName);
33216
33769
  return;
@@ -33221,8 +33774,10 @@ function wireRepairFetch(mc, opts) {
33221
33774
  packetId,
33222
33775
  ...catalogTrackName === void 0 ? {} : { catalogTrackName }
33223
33776
  };
33224
- const wire = wirePriority(identity, "unknown");
33225
- const deadline = presentationDeadlineAt(holeStart, packetId, catalogTrackName, performance.now(), "unknown");
33777
+ const wire = wirePriority(identity, holeClass);
33778
+ if (wire === null) return close();
33779
+ const now = performance.now();
33780
+ const deadline = presentationDeadlineAt(holeStart, packetId, catalogTrackName, now, holeClass);
33226
33781
  if (deadline === null) {
33227
33782
  close();
33228
33783
  mc.noteRepairSkipped(
@@ -33234,6 +33789,7 @@ function wireRepairFetch(mc, opts) {
33234
33789
  );
33235
33790
  return;
33236
33791
  }
33792
+ const deadlineAt = walked ? walkDeadlineAt(packetId, now, deadline.deadlineAt) : deadline.deadlineAt;
33237
33793
  const entry = {
33238
33794
  key: JSON.stringify(["range", packetId, catalogTrackName ?? null, holeStart]),
33239
33795
  ...identity,
@@ -33241,15 +33797,16 @@ function wireRepairFetch(mc, opts) {
33241
33797
  blockProfile,
33242
33798
  priority,
33243
33799
  subscriberPriority: wire.subscriberPriority,
33244
- deadlineAt: deadline.deadlineAt,
33245
- playoutDeadlineAt: deadline.deadlineAt - deadline.extensionMs,
33800
+ deadlineAt,
33801
+ playoutDeadlineAt: deadlineAt - deadline.extensionMs,
33246
33802
  anchorAt: deadline.anchorAt,
33247
33803
  delayMs: deadline.delayMs,
33248
33804
  bound: deadline.bound,
33249
33805
  order: enqueueOrder++,
33250
33806
  attempts: 0,
33251
33807
  running: null,
33252
- range: { ...range, lostSsId: loss.lostSsId, holeLength }
33808
+ range: { lostSsId: loss.lostSsId, holeLength, groups, reportedAt: now },
33809
+ ...walked ? { walk: { next: holeStart, group: null, yielded: false } } : {}
33253
33810
  };
33254
33811
  const previous = entries.get(entry.key);
33255
33812
  if (previous) {
@@ -33259,7 +33816,7 @@ function wireRepairFetch(mc, opts) {
33259
33816
  entries.set(entry.key, entry);
33260
33817
  entry.expiry = setTimeout(dispatch, Math.max(0, entry.deadlineAt - performance.now()) + 1);
33261
33818
  dispatch();
33262
- });
33819
+ }, onHoleExpired);
33263
33820
  }
33264
33821
  const removeRapObserver = mc.addRapCompleteObserver?.((packetId) => {
33265
33822
  queueMicrotask(() => {