@blockcast/mmt-container 2.2.0-main.efb4fa4521af → 2.2.0-main.fb5c7dfbe2de

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 (82) hide show
  1. package/README.md +29 -17
  2. package/dist/browser-runtime.js +1336 -302
  3. package/dist/browser-runtime.js.map +3 -3
  4. package/dist/catalog-fec-params.d.ts +17 -1
  5. package/dist/catalog-fec-params.d.ts.map +1 -1
  6. package/dist/catalog-fec-params.js +15 -0
  7. package/dist/catalog-fec-params.js.map +2 -2
  8. package/dist/cmcd-producer.d.ts +60 -47
  9. package/dist/cmcd-producer.d.ts.map +1 -1
  10. package/dist/cmcd-producer.js +138 -109
  11. package/dist/cmcd-producer.js.map +2 -2
  12. package/dist/delivery-catalog.js +26 -16
  13. package/dist/delivery-catalog.js.map +2 -2
  14. package/dist/delta-fallback-config.js.map +1 -1
  15. package/dist/fec-manager.d.ts +70 -12
  16. package/dist/fec-manager.d.ts.map +1 -1
  17. package/dist/fec-manager.js +403 -58
  18. package/dist/fec-manager.js.map +2 -2
  19. package/dist/fec-worker-setup.d.ts +1 -1
  20. package/dist/fec-worker-setup.js +1 -1
  21. package/dist/fec-worker-setup.js.map +1 -1
  22. package/dist/index.d.ts +4 -4
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +5 -1
  25. package/dist/index.js.map +2 -2
  26. package/dist/iso-block-locator.d.ts +48 -13
  27. package/dist/iso-block-locator.d.ts.map +1 -1
  28. package/dist/iso-block-locator.js +97 -25
  29. package/dist/iso-block-locator.js.map +2 -2
  30. package/dist/iso-bmff-timing.d.ts +10 -1
  31. package/dist/iso-bmff-timing.d.ts.map +1 -1
  32. package/dist/iso-bmff-timing.js +36 -2
  33. package/dist/iso-bmff-timing.js.map +2 -2
  34. package/dist/media-router.js.map +1 -1
  35. package/dist/mmtp-container-parser.d.ts +79 -10
  36. package/dist/mmtp-container-parser.d.ts.map +1 -1
  37. package/dist/mmtp-container-parser.js +207 -26
  38. package/dist/mmtp-container-parser.js.map +2 -2
  39. package/dist/mmtp-container-parser.no-modules.js +209 -29
  40. package/dist/mmtp-container-parser.no-modules.js.map +2 -2
  41. package/dist/mmtp-decode-pipeline.js +206 -26
  42. package/dist/mmtp-decode-pipeline.js.map +2 -2
  43. package/dist/mmtp-router.d.ts +34 -8
  44. package/dist/mmtp-router.d.ts.map +1 -1
  45. package/dist/mmtp-router.js +555 -79
  46. package/dist/mmtp-router.js.map +2 -2
  47. package/dist/multicast-manager.d.ts +13 -3
  48. package/dist/multicast-manager.d.ts.map +1 -1
  49. package/dist/multicast-manager.js +980 -146
  50. package/dist/multicast-manager.js.map +2 -2
  51. package/dist/multicast-transport.js +28 -18
  52. package/dist/multicast-transport.js.map +2 -2
  53. package/dist/no-modules.js +206 -26
  54. package/dist/no-modules.js.map +2 -2
  55. package/dist/repair-fetch.d.ts +36 -8
  56. package/dist/repair-fetch.d.ts.map +1 -1
  57. package/dist/repair-fetch.js +285 -26
  58. package/dist/repair-fetch.js.map +3 -3
  59. package/dist/repair-layer-subscriptions.d.ts +6 -2
  60. package/dist/repair-layer-subscriptions.d.ts.map +1 -1
  61. package/dist/repair-layer-subscriptions.js +6 -3
  62. package/dist/repair-layer-subscriptions.js.map +2 -2
  63. package/dist/track-session.d.ts +1 -1
  64. package/dist/track-session.d.ts.map +1 -1
  65. package/dist/track-session.js +1 -0
  66. package/dist/track-session.js.map +2 -2
  67. package/dist/wasm-mmtp-container-parser.js +206 -26
  68. package/dist/wasm-mmtp-container-parser.js.map +2 -2
  69. package/package.json +8 -5
  70. package/src/catalog-fec-params.ts +36 -1
  71. package/src/cmcd-producer.ts +189 -179
  72. package/src/fec-manager.ts +433 -32
  73. package/src/fec-worker-setup.ts +2 -2
  74. package/src/index.ts +9 -2
  75. package/src/iso-block-locator.ts +133 -31
  76. package/src/iso-bmff-timing.ts +40 -1
  77. package/src/mmtp-container-parser.ts +306 -29
  78. package/src/mmtp-router.ts +548 -38
  79. package/src/multicast-manager.ts +44 -6
  80. package/src/repair-fetch.ts +363 -49
  81. package/src/repair-layer-subscriptions.ts +13 -5
  82. package/src/track-session.ts +4 -1
@@ -22,6 +22,7 @@ import {
22
22
  isoBlockContains,
23
23
  isoBlocksBehindLowerBound,
24
24
  parseFecBlockProfile,
25
+ serial32Add,
25
26
  serial32Distance,
26
27
  serial32Precedes,
27
28
  ssbg1FitsSymbol,
@@ -169,6 +170,17 @@ export interface FecPacketStats {
169
170
 
170
171
  export type FecSymbolSource = 'multicast' | 'moq'
171
172
 
173
+ /**
174
+ * What feedRebuiltSourceSymbol did with a rebuilt source: whether its block
175
+ * took it, and the block the synchronous decoder recovered with it, if any.
176
+ */
177
+ export interface RebuiltSourceFeed {
178
+ readonly taken: boolean
179
+ readonly recovered: Uint8Array | null
180
+ }
181
+
182
+ const NOT_TAKEN: RebuiltSourceFeed = Object.freeze({ taken: false, recovered: null })
183
+
172
184
  /**
173
185
  * One iso-ssbg1-v1 first-arrival source symbol MmtpRouter holds until a repair
174
186
  * symbol states its block: the exact protected packet (no SS_ID, unpadded),
@@ -201,6 +213,25 @@ export interface UndelimitedIsoLoss {
201
213
  readonly previousBlockStart?: number
202
214
  }
203
215
 
216
+ /**
217
+ * A pending hole the stream ran more than the locator's horizon past before
218
+ * its range repair ended (BLO-52085): no repair can state its blocks any
219
+ * more, and the release gate no longer waits for it.
220
+ */
221
+ export type ExpiredIsoHole = Pick<UndelimitedIsoLoss, 'packetId' | 'catalogTrackName' | 'holeStart' | 'holeLength'>
222
+
223
+ /**
224
+ * What a failed iso-ssbg1-v1 repair-less span was: the `length` SS_IDs from
225
+ * its start, between two stated blocks, of which `sources` arrived. No repair
226
+ * stated the blocks inside it, so the receiver cannot tell its complete
227
+ * blocks from the ones that lost a source: the span failed, not the block
228
+ * that starts it.
229
+ */
230
+ export interface IsoSpanFailure {
231
+ readonly length: number
232
+ readonly sources: number
233
+ }
234
+
204
235
  /**
205
236
  * How the multicast leg hands its symbols to this manager.
206
237
  *
@@ -510,10 +541,21 @@ export class FECManager {
510
541
  // 'pending' forever, which would park every later frame of its stream in
511
542
  // the in-order release gate. Each entry owns its handle; the callback
512
543
  // checks it still owns the key, so a settled or reset key is never touched.
513
- #pendingWorkerCleanupBounds = new Map<string, { handle: ReturnType<typeof setTimeout> }>()
544
+ /**
545
+ * Per owed Worker cleanup: the bound on its acknowledgement, when its block
546
+ * was due, and when its cleanup was asked for (see `onCleanupComplete`).
547
+ */
548
+ #pendingWorkerCleanupBounds = new Map<
549
+ string,
550
+ { handle: ReturnType<typeof setTimeout>; dueAtMs: number; askedAtMs: number }
551
+ >()
514
552
  // Source symbol count per track-scoped block — marks complete when K symbols arrive.
515
553
  #blockSourceCounts = new Map<string, number>()
516
554
  #blockSourceEsis = new Map<string, Set<number>>()
555
+ // Source ESIs per block that another FEC instance rebuilt and fed in
556
+ // (feedRebuiltSourceSymbol). Kept apart from #blockSourceEsis: they are
557
+ // not network arrivals and never make a block clean.
558
+ #blockRebuiltEsis = new Map<string, Set<number>>()
517
559
  // Authored SSB_length per block (ISO/IEC 23008-1 §C.5.3), learned from the
518
560
  // first repair symbol. The FFmpeg moq_mmt publisher closes a block every D
519
561
  // media units, usually before it holds K symbols; under iso-ssbg1-v1
@@ -522,6 +564,9 @@ export class FECManager {
522
564
  // SSB_length source arrivals and recoverable from SSB_length received
523
565
  // symbols; waiting for K fails every one of them.
524
566
  #blockAuthoredLengths = new Map<string, number>()
567
+ // Keys whose authored length is a repair-less span's (#openIsoSpan), until
568
+ // a repair states the block there or the span resolves.
569
+ #isoSpanKeys = new Set<string>()
525
570
  #ssbLengthConflictCount = 0
526
571
  #blockRepairRsIds = new Map<string, Set<number>>()
527
572
  // RSB_length per block (C.5.3 Repair FEC Payload ID): how many repair
@@ -644,6 +689,10 @@ export class FECManager {
644
689
  // ── Keyframe-protected repair policy (block-policy.ts) ──
645
690
  // One classifier per physical stream, built from that stream's geometry.
646
691
  #classifiers = new Map<string, BlockClassifier>()
692
+ // A stream without catalog timing has no classifier, so the last RAP it
693
+ // released stands in for one: "an MPU boundary bounds its RAPs" holds until
694
+ // a second RAP is released inside one MPU (BLO-52085).
695
+ #noSpanRaps = new Map<string, { mpu: number; presentationTime: bigint; inside: boolean }>()
647
696
  // Geometry a stream-scoped configuration gave one stream (streamKey). ABR
648
697
  // rungs can share a packetId with a different framerate or GOP, so the
649
698
  // packet-level geometry is only the geometry of a packet whose streams
@@ -725,13 +774,17 @@ export class FECManager {
725
774
  onBlockComplete:
726
775
  | ((sbn: number, packetId: number, catalogTrackName?: string) => void)
727
776
  | null = null
728
- /** A block reached its fail bound. `blockClass` is its class at failure. */
777
+ /**
778
+ * A block reached its fail bound. `blockClass` is its class at failure.
779
+ * `span` is set when what failed is a repair-less span starting at `sbn`.
780
+ */
729
781
  onBlockFailed:
730
782
  | ((
731
783
  sbn: number,
732
784
  packetId: number,
733
785
  catalogTrackName: string | undefined,
734
786
  blockClass: BlockClass,
787
+ span?: IsoSpanFailure,
735
788
  ) => void)
736
789
  | null = null
737
790
  /**
@@ -815,6 +868,8 @@ export class FECManager {
815
868
  * for it, which it must be.
816
869
  */
817
870
  #onUndelimitedLoss: ((loss: UndelimitedIsoLoss) => void) | null = null
871
+ /** Set with the loss handler: true takes an expired hole to close (`closeIsoHoles`). */
872
+ #onIsoHoleExpired: ((hole: ExpiredIsoHole) => boolean) | null = null
818
873
  /** A block every symbol of which vanished was created by SBN evidence. */
819
874
  onBlockVanished:
820
875
  | ((sbn: number, packetId: number, catalogTrackName: string | undefined) => void)
@@ -1469,7 +1524,12 @@ export class FECManager {
1469
1524
  timer.handle = null
1470
1525
  this.#requestRepair(sbn, packetId, catalogTrackName)
1471
1526
  }
1472
- timer.handle = setTimeout(onFire, Math.max(0, deadlineMs - performance.now()))
1527
+ timer.handle = setTimeout(
1528
+ onFire,
1529
+ this.#receiptAtMs === undefined
1530
+ ? Math.max(0, deadlineMs - performance.now())
1531
+ : this.#receiptTimerDelayMs(deadlineMs, 0),
1532
+ )
1473
1533
  this.#repairDeadlines.set(key, timer)
1474
1534
  }
1475
1535
 
@@ -1663,6 +1723,12 @@ export class FECManager {
1663
1723
  * Vanished blocks are not enumerated here: their starts are unknown until a
1664
1724
  * repair states them. The SS_IDs between two stated blocks become one
1665
1725
  * repair-less span when the later block's repair arrives (placeIsoRepair).
1726
+ *
1727
+ * A `held` source arrived before its block was known and is fed only now
1728
+ * that a repair, a span or a closed hole placed it: how far its block start
1729
+ * then lies behind the frontier measures how long the receiver held it, not
1730
+ * where the stream was, so only its SS_ID and packet_sequence_number judge
1731
+ * it (BLO-52085).
1666
1732
  */
1667
1733
  #noteSbnSeen(
1668
1734
  sbn: number,
@@ -1671,6 +1737,7 @@ export class FECManager {
1671
1737
  symbol: 'source' | 'repair',
1672
1738
  packetSequenceNumber?: number,
1673
1739
  ssId = sbn,
1740
+ held = false,
1674
1741
  ): void {
1675
1742
  const streamKey = this.#streamKey(packetId, catalogTrackName)
1676
1743
  // Arrival at FEC, not receipt (withReceiptTime): this bounds how far SS_ID
@@ -1706,6 +1773,7 @@ export class FECManager {
1706
1773
  // The classifier compares authored order with SBN order only within
1707
1774
  // one continuous SBN sequence.
1708
1775
  this.#classifiers.get(streamKey)?.noteSourceDiscontinuity()
1776
+ this.#noSpanRaps.delete(streamKey)
1709
1777
  this.#highestSbn.set(streamKey, { sbn, atMs: now })
1710
1778
  this.#sourceFrontiers.set(streamKey, { sbn, atMs: now })
1711
1779
  if (psn === undefined) this.#sourceAnchors.delete(streamKey)
@@ -1726,7 +1794,7 @@ export class FECManager {
1726
1794
  }
1727
1795
  if (!this.#blockPrecedes(previous.sbn, sbn)) {
1728
1796
  if (symbol === 'source' && window !== null && this.#blockPrecedes(sbn, previous.sbn) && (
1729
- this.#blocksBehind(sbn, previous.sbn, packetId) > this.#isoRetentionBlocks(packetId) ||
1797
+ (!held && this.#blocksBehind(sbn, previous.sbn, packetId) > this.#isoRetentionBlocks(packetId)) ||
1730
1798
  (psn !== undefined && anchor !== undefined && serial32Precedes(ssId, anchor.ssId) &&
1731
1799
  psn !== anchor.psn && !sentInOrder({ ssId, psn }, anchor))
1732
1800
  )) {
@@ -2080,21 +2148,28 @@ export class FECManager {
2080
2148
  this.#blockWaitDeadlinesMs.delete(key)
2081
2149
  this.#repairHoldUntilMs.delete(key)
2082
2150
  this.#traceLifecycle('timeout-queued', sbn, packetId, catalogTrackName)
2083
- this.#finalizeExpiredBlock(sbn, packetId, catalogTrackName)
2151
+ this.#finalizeExpiredBlock(sbn, packetId, catalogTrackName, currentDeadlineMs ?? firedAtMs)
2084
2152
  }
2085
- timer.handle = setTimeout(onFire, Math.max(1, Math.ceil(deadlineMs - nowMs)))
2153
+ timer.handle = setTimeout(
2154
+ onFire,
2155
+ this.#receiptAtMs === undefined
2156
+ ? Math.max(1, Math.ceil(deadlineMs - nowMs))
2157
+ : this.#receiptTimerDelayMs(deadlineMs, 1),
2158
+ )
2086
2159
  this.#activeBlocks.set(key, timer)
2087
2160
  this.#indexBlockKey(key, sbn, packetId)
2088
2161
  }
2089
2162
 
2090
2163
  /**
2091
2164
  * Hand a block whose delivery is over to its decoder for finalization: the
2092
- * datagram deadline fired, or the ordered relay moved past it.
2165
+ * datagram deadline fired, or the ordered relay moved past it. `dueAtMs`
2166
+ * is when it was due: its deadline, or now.
2093
2167
  */
2094
2168
  #finalizeExpiredBlock(
2095
2169
  sbn: number,
2096
2170
  packetId: number,
2097
2171
  catalogTrackName: string | undefined,
2172
+ dueAtMs = performance.now(),
2098
2173
  ): void {
2099
2174
  const key = this.#blockKey(sbn, packetId, catalogTrackName)
2100
2175
  // A vanished block that never received a symbol holds no decoder state
@@ -2118,7 +2193,7 @@ export class FECManager {
2118
2193
  if (workerClient) {
2119
2194
  this.#pendingWorkerCleanups.add(key)
2120
2195
  this.#pendingWorkerCleanupClients.set(key, workerClient)
2121
- this.#boundOwedCleanup(key, packetId, workerClient)
2196
+ this.#boundOwedCleanup(key, packetId, workerClient, dueAtMs)
2122
2197
  workerClient.cleanup(sbn)
2123
2198
  } else {
2124
2199
  const decoder = this.#decoderForPacket(packetId, catalogTrackName)?.client
@@ -2289,6 +2364,9 @@ export class FECManager {
2289
2364
  // sibling's change, but the first stream-scoped geometry on a packet
2290
2365
  // does change the siblings that have none (stream-geometry-absent).
2291
2366
  this.#bumpEpoch(packetId)
2367
+ for (const key of [...this.#noSpanRaps.keys()]) {
2368
+ if (this.#belongsToPacket(key, packetId)) this.#noSpanRaps.delete(key)
2369
+ }
2292
2370
  for (const [key, classifier] of [...this.#classifiers]) {
2293
2371
  if (!this.#belongsToPacket(key, packetId)) continue
2294
2372
  const stream = this.#parseStreamKey(key)
@@ -2532,7 +2610,9 @@ export class FECManager {
2532
2610
  }
2533
2611
  resetMap(this.#blockSourceCounts)
2534
2612
  resetMap(this.#blockSourceEsis)
2613
+ resetMap(this.#blockRebuiltEsis)
2535
2614
  resetMap(this.#blockAuthoredLengths)
2615
+ resetSet(this.#isoSpanKeys)
2536
2616
  resetMap(this.#blockRepairRsIds)
2537
2617
  resetMap(this.#blockRsbLengths)
2538
2618
  resetMap(this.#heldLayerStates)
@@ -2565,6 +2645,7 @@ export class FECManager {
2565
2645
  resetMap(this.#isoLocators)
2566
2646
  resetMap(this.#sourceAnchors)
2567
2647
  this.#classifiers.delete(this.#streamKey(packetId, catalogTrackName))
2648
+ this.#noSpanRaps.delete(this.#streamKey(packetId, catalogTrackName))
2568
2649
  this.#bumpEpoch(packetId)
2569
2650
  for (const [sbn, keys] of this.#blockKeysBySbn) {
2570
2651
  for (const key of keys.keys()) {
@@ -2663,7 +2744,9 @@ export class FECManager {
2663
2744
  dropMap(this.#pendingWorkerCleanupClients)
2664
2745
  dropMap(this.#blockSourceCounts)
2665
2746
  dropMap(this.#blockSourceEsis)
2747
+ dropMap(this.#blockRebuiltEsis)
2666
2748
  dropMap(this.#blockAuthoredLengths)
2749
+ dropSet(this.#isoSpanKeys)
2667
2750
  dropMap(this.#blockRepairRsIds)
2668
2751
  dropMap(this.#blockRsbLengths)
2669
2752
  dropMap(this.#blockPathCounts)
@@ -2679,6 +2762,7 @@ export class FECManager {
2679
2762
  dropMap(this.#isoLocators)
2680
2763
  dropMap(this.#sourceAnchors)
2681
2764
  dropMap(this.#classifiers)
2765
+ dropMap(this.#noSpanRaps)
2682
2766
  dropMap(this.#streamGeometries)
2683
2767
  dropSet(this.#pendingWorkerCleanups)
2684
2768
  dropSet(this.#completedBlocks)
@@ -2881,6 +2965,10 @@ export class FECManager {
2881
2965
  }
2882
2966
  }
2883
2967
 
2968
+ if (isSourceSymbol && source === 'multicast') {
2969
+ this.#noteMulticastSource(this.#isoLocatorFor(packetId, catalogTrackName), (sbn + esi) >>> 0, packetId, catalogTrackName)
2970
+ }
2971
+
2884
2972
  return true
2885
2973
  }
2886
2974
 
@@ -3090,8 +3178,9 @@ export class FECManager {
3090
3178
  return true
3091
3179
  }
3092
3180
  /**
3093
- * Drop a block's repair hold: its FETCH ended without delivering. The block
3094
- * falls back to its multicast wait, which may already have passed.
3181
+ * Drop a block's repair hold: its FETCH ended without delivering, or brought
3182
+ * back only a repair that cannot complete it. The block falls back to its
3183
+ * multicast wait, which may already have passed.
3095
3184
  */
3096
3185
  releaseRepairHold(sbn: number, packetId: number, catalogTrackName?: string): void {
3097
3186
  const key = this.#blockKey(sbn, packetId, catalogTrackName)
@@ -3253,6 +3342,8 @@ export class FECManager {
3253
3342
  const locator = this.#growingIsoLocatorFor(packetId, catalogTrackName)
3254
3343
  const evictedBefore = locator.heldEvicted
3255
3344
  const held = locator.hold(ssId, source)
3345
+ this.#settleExpiredIsoHoles(locator, packetId, catalogTrackName)
3346
+ if (held && source.source === 'multicast') this.#noteMulticastSource(locator, ssId, packetId, catalogTrackName)
3256
3347
  const evicted = locator.heldEvicted - evictedBefore
3257
3348
  if (evicted > 0) {
3258
3349
  const before = this.#isoHeldEvictedCount
@@ -3280,17 +3371,26 @@ export class FECManager {
3280
3371
  ssStart: number,
3281
3372
  ssbLength: number,
3282
3373
  ): IsoRepairPlacement<IsoHeldSource> {
3283
- const placement = this.#growingIsoLocatorFor(packetId, catalogTrackName).placeRepair(ssStart, ssbLength)
3374
+ const locator = this.#growingIsoLocatorFor(packetId, catalogTrackName)
3375
+ const placement = locator.placeRepair(ssStart, ssbLength)
3376
+ this.#settleExpiredIsoHoles(locator, packetId, catalogTrackName)
3284
3377
  if (placement.status !== 'placed') {
3285
3378
  this.#isoRepairRefusedCount++
3286
3379
  if (this.#isoRepairRefusedCount <= 5 || this.#isoRepairRefusedCount % 100 === 0) {
3287
3380
  console.warn(`[FECManager] packetId=${packetId} iso-ssbg1-v1 repair refused (${placement.status}): ${placement.reason}`)
3288
3381
  }
3382
+ // The repair FETCH of a span's first group brought back a shorter block:
3383
+ // the span holds more than one, so that group cannot complete it, and
3384
+ // holding the span (and every frame behind it) for the FETCH buys
3385
+ // nothing. It falls back to its multicast wait.
3386
+ if (placement.ofSpan !== undefined) this.releaseRepairHold(placement.ofSpan.start, packetId, catalogTrackName)
3289
3387
  return placement
3290
3388
  }
3389
+ // A repair states the block at its SS_Start: a span the locator kept
3390
+ // there is now that one block (IsoBlockLocator.placeRepair).
3391
+ this.#isoSpanKeys.delete(this.#blockKey(placement.block.start, packetId, catalogTrackName))
3291
3392
  if (placement.span !== undefined) this.#openIsoSpan(placement.span, packetId, catalogTrackName)
3292
3393
  if (placement.hole !== undefined) {
3293
- const locator = this.#isoLocatorFor(packetId, catalogTrackName)
3294
3394
  const hole = placement.hole.block
3295
3395
  const previous = locator.blockEndingAt(hole.start)
3296
3396
  const loss: UndelimitedIsoLoss = {
@@ -3312,18 +3412,38 @@ export class FECManager {
3312
3412
  * `handler`, instead of a repair-less span; null restores spans. Set by the
3313
3413
  * range repair scheduler; applies to every stream from its next gap. The
3314
3414
  * handler and the deferral are one setting, so no hole is ever created with
3315
- * nobody to close it.
3415
+ * nobody to close it. `onExpired` is told of each pending hole the stream
3416
+ * runs past the locator's horizon (BLO-52085): returning true, it closes
3417
+ * the hole (`closeIsoHoles`), so the span it leaves still fails and is
3418
+ * reported; otherwise the hole is discarded with its sources.
3316
3419
  */
3317
- setUndelimitedLossHandler(handler: ((loss: UndelimitedIsoLoss) => void) | null): void {
3420
+ setUndelimitedLossHandler(
3421
+ handler: ((loss: UndelimitedIsoLoss) => void) | null,
3422
+ onExpired: ((hole: ExpiredIsoHole) => boolean) | null = null,
3423
+ ): void {
3318
3424
  this.#onUndelimitedLoss = handler
3425
+ this.#onIsoHoleExpired = handler === null ? null : onExpired
3319
3426
  this.#deferIsoGaps = handler !== null
3320
3427
  for (const locator of this.#isoLocators.values()) locator.deferIncompleteGaps = this.#deferIsoGaps
3321
3428
  }
3322
3429
 
3430
+ /** Hand each hole the locator's horizon passed to its owner, or discard it (setUndelimitedLossHandler). */
3431
+ #settleExpiredIsoHoles(
3432
+ locator: IsoBlockLocator<IsoHeldSource>,
3433
+ packetId: number,
3434
+ catalogTrackName: string | undefined,
3435
+ ): void {
3436
+ for (const hole of locator.takeExpiredHoles()) {
3437
+ const expired: ExpiredIsoHole = { packetId, catalogTrackName, holeStart: hole.start, holeLength: hole.length }
3438
+ if (this.#onIsoHoleExpired?.(expired) !== true) locator.closeHolesWithin(hole.start, hole.length)
3439
+ }
3440
+ }
3441
+
3323
3442
  /**
3324
- * The range repair for a hole ended: every hole still pending inside
3325
- * `[start, start + length)` becomes a repair-less span. Returns those spans
3326
- * with the held sources they placed, for the caller to feed (MmtpRouter).
3443
+ * The range repair for a hole ended: every hole inside `[start, start +
3444
+ * length)`, pending or expired, becomes a repair-less span. Returns those
3445
+ * spans with the held sources they placed, for the caller to feed
3446
+ * (MmtpRouter).
3327
3447
  */
3328
3448
  closeIsoHoles(
3329
3449
  packetId: number,
@@ -3352,7 +3472,10 @@ export class FECManager {
3352
3472
  catalogTrackName: string | undefined,
3353
3473
  ): void {
3354
3474
  const key = this.#blockKey(span.block.start, packetId, catalogTrackName)
3355
- if (!this.#blockAuthoredLengths.has(key)) this.#blockAuthoredLengths.set(key, span.block.length)
3475
+ if (!this.#blockAuthoredLengths.has(key)) {
3476
+ this.#blockAuthoredLengths.set(key, span.block.length)
3477
+ this.#isoSpanKeys.add(key)
3478
+ }
3356
3479
  if (span.placed.length === 0) this.#materializeBlock(span.block.start, packetId, catalogTrackName, true)
3357
3480
  }
3358
3481
 
@@ -3470,11 +3593,14 @@ export class FECManager {
3470
3593
  /**
3471
3594
  * Whether an MPU boundary still bounds this stream's RAPs (see
3472
3595
  * `BlockClassifier.mpuBoundsRaps`). Gap decisions drop MPU evidence once
3473
- * it does not. A stream with no classifier yet has shown nothing against it.
3596
+ * it does not. A stream with no classifier yet has shown nothing against it,
3597
+ * and neither has a stream without catalog timing until it releases two
3598
+ * RAPs inside one MPU.
3474
3599
  */
3475
3600
  mpuBoundsRapsFor(packetId: number, catalogTrackName?: string): boolean {
3476
- if (!this.#hasCatalogTimingForPacket(packetId)) return false
3477
- return this.#classifiers.get(this.#streamKey(packetId, catalogTrackName))?.mpuBoundsRaps ?? true
3601
+ const streamKey = this.#streamKey(packetId, catalogTrackName)
3602
+ if (!this.#hasCatalogTimingForPacket(packetId)) return this.#noSpanRaps.get(streamKey)?.inside !== true
3603
+ return this.#classifiers.get(streamKey)?.mpuBoundsRaps ?? true
3478
3604
  }
3479
3605
 
3480
3606
  /** A block's class now, or frozen at its resolution. */
@@ -3510,7 +3636,15 @@ export class FECManager {
3510
3636
  presentationTime: bigint,
3511
3637
  mediaTimescale: number,
3512
3638
  ): void {
3513
- if (!this.#trackConfigs.has(packetId) || !this.#hasCatalogTimingForPacket(packetId)) return
3639
+ if (!this.#trackConfigs.has(packetId)) return
3640
+ if (!this.#hasCatalogTimingForPacket(packetId)) {
3641
+ const streamKey = this.#streamKey(packetId, catalogTrackName)
3642
+ const last = this.#noSpanRaps.get(streamKey)
3643
+ const inside = last?.inside === true ||
3644
+ (last?.mpu === mpu && last.presentationTime !== presentationTime)
3645
+ this.#noSpanRaps.set(streamKey, { mpu, presentationTime, inside })
3646
+ return
3647
+ }
3514
3648
  this.#applyClassifierChanges(
3515
3649
  packetId,
3516
3650
  catalogTrackName,
@@ -3820,13 +3954,27 @@ export class FECManager {
3820
3954
 
3821
3955
  client.onCleanupComplete((sbn: number) => {
3822
3956
  if (this.#releasedWorkerClients.has(client)) return
3823
- const cleanupKeys = [...this.#pendingWorkerCleanups].filter((key) => {
3957
+ const owed = [...this.#pendingWorkerCleanups].filter((key) => this.#pendingWorkerCleanupClients.get(key) === client)
3958
+ const cleanupKeys = owed.filter((key) => {
3824
3959
  const block = this.#parseBlockKey(key)
3825
- return block.packetId === packetId &&
3826
- block.sbn === sbn &&
3827
- this.#pendingWorkerCleanupClients.get(key) === client
3960
+ return block.packetId === packetId && block.sbn === sbn
3828
3961
  })
3829
- for (const key of cleanupKeys) this.#settleOwedCleanup(key, 'cleanup-ack')
3962
+ // A cleanup is a queue-drain barrier: the Worker took every symbol fed
3963
+ // before it was asked for first, so every block already due by then
3964
+ // is settled by this acknowledgement too, not one barrier later. The
3965
+ // client sends one barrier at a time, and the blocks a repair walk
3966
+ // stated in one span are due together (BLO-52085). That holds only
3967
+ // while no block is fed a symbol after its dueAtMs, which is its
3968
+ // receipt-scoped deadline, not when feeding stopped: a symbol fed
3969
+ // between that deadline and the block's timer, after another block's
3970
+ // barrier was asked for, would find the block settled (failed) one
3971
+ // barrier early. Walks end before D-28 (walkDeadlineAt), so that window
3972
+ // is the timer's lateness alone; whatever feeds a block past its dueAtMs
3973
+ // must leave it to its own barrier.
3974
+ const askedAtMs = Math.max(...cleanupKeys.map((key) => this.#pendingWorkerCleanupBounds.get(key)?.askedAtMs ?? -Infinity))
3975
+ const due = owed.filter((key) =>
3976
+ !cleanupKeys.includes(key) && (this.#pendingWorkerCleanupBounds.get(key)?.dueAtMs ?? Infinity) <= askedAtMs)
3977
+ for (const key of [...cleanupKeys, ...due]) this.#settleOwedCleanup(key, 'cleanup-ack')
3830
3978
  })
3831
3979
 
3832
3980
  client.onError((msg: string, code?: FecWorkerErrorCode) => {
@@ -3908,9 +4056,11 @@ export class FECManager {
3908
4056
  * fail deadline already passed, so failing it is the decision that deadline
3909
4057
  * made. A late ack then finds nothing owed and is a no-op.
3910
4058
  */
3911
- #boundOwedCleanup(key: string, packetId: number, client: FecWorkerClient): void {
4059
+ #boundOwedCleanup(key: string, packetId: number, client: FecWorkerClient, dueAtMs: number): void {
3912
4060
  this.#clearOwedCleanupBound(key)
3913
4061
  const bound = {
4062
+ dueAtMs,
4063
+ askedAtMs: performance.now(),
3914
4064
  handle: setTimeout(() => {
3915
4065
  if (this.#pendingWorkerCleanupBounds.get(key) !== bound) return
3916
4066
  this.#pendingWorkerCleanupBounds.delete(key)
@@ -4041,7 +4191,22 @@ export class FECManager {
4041
4191
  this.#isoSourceOversizeCount++
4042
4192
  return null
4043
4193
  }
4044
- const sbn = located.block.start
4194
+ return this.#feedDecoderSource(packetId, ssId, symbolData, catalogTrackName, located.block.start)
4195
+ }
4196
+
4197
+ /**
4198
+ * Hand one placed source symbol to the stream's decoder: its Worker, which
4199
+ * answers through onFrame (null here), or its synchronous decoder, whose
4200
+ * recovered block is returned. `sbn` is the SS_Start of the block a repair
4201
+ * stated for `ssId`.
4202
+ */
4203
+ #feedDecoderSource(
4204
+ packetId: number,
4205
+ ssId: number,
4206
+ symbolData: Uint8Array,
4207
+ catalogTrackName: string | undefined,
4208
+ sbn: number,
4209
+ ): Uint8Array | null {
4045
4210
  const blockKey = this.#blockKey(sbn, packetId, catalogTrackName)
4046
4211
  const syncBlockOwner = this.#syncDecoderBlockOwners.get(blockKey)
4047
4212
 
@@ -4086,6 +4251,97 @@ export class FECManager {
4086
4251
  return recovered
4087
4252
  }
4088
4253
 
4254
+ /**
4255
+ * Feed a source symbol of this stream that another FEC instance over the
4256
+ * same packets rebuilt: a keyframe overlay recovery whose base SS_ID the
4257
+ * caller determined (draft-ramadan-moq-mmt-02 7.1, 7.4.4; joint decoding,
4258
+ * BLO-52085). It joins its block's decoder as a received source would, so
4259
+ * it reduces the erasures the block still has to solve, but it is not a
4260
+ * network arrival: no transport accounting, no classification evidence,
4261
+ * no deadline refresh, and it never makes a block clean.
4262
+ *
4263
+ * Only a block a repair stated takes one (a repair-less span has no
4264
+ * decoder block). A block that is terminal, or already holds that source,
4265
+ * does not. The symbol is the exact protected packet, unpadded, as
4266
+ * feedSourceSymbol takes it.
4267
+ *
4268
+ * `recovered` is the block the synchronous decoder completed with it; the
4269
+ * caller routes it and completes the block, as for any recovery. When
4270
+ * every source of the block is then present, received or rebuilt, and no
4271
+ * decoder returned it (a Worker answers asynchronously, and reports such a
4272
+ * block as clean on its own side), the block is completed here as
4273
+ * recovered, with nothing left to route.
4274
+ */
4275
+ feedRebuiltSourceSymbol(
4276
+ packetId: number,
4277
+ ssId: number,
4278
+ symbolData: Uint8Array,
4279
+ catalogTrackName?: string,
4280
+ ): RebuiltSourceFeed {
4281
+ if (!this.#hasBlockProfile(packetId)) return NOT_TAKEN
4282
+ const located = this.#isoLocatorFor(packetId, catalogTrackName).locate(ssId)
4283
+ if (located === null || located.block.span) return NOT_TAKEN
4284
+ if (!ssbg1FitsSymbol(symbolData.byteLength, this.#trackConfig(packetId).symbolSize)) return NOT_TAKEN
4285
+ const sbn = located.block.start
4286
+ const key = this.#blockKey(sbn, packetId, catalogTrackName)
4287
+ if (this.#completedBlocks.has(key) || this.#blockSourceEsis.get(key)?.has(located.esi)) return NOT_TAKEN
4288
+ let rebuilt = this.#blockRebuiltEsis.get(key)
4289
+ if (rebuilt?.has(located.esi)) return NOT_TAKEN
4290
+ if (rebuilt === undefined) {
4291
+ rebuilt = new Set()
4292
+ this.#blockRebuiltEsis.set(key, rebuilt)
4293
+ }
4294
+ const config = this.#trackConfigs.get(packetId)
4295
+ const wasShort = config !== undefined &&
4296
+ this.#lateSourceWaitMs(key, sbn, packetId, catalogTrackName, config) !== undefined
4297
+ rebuilt.add(located.esi)
4298
+ const recovered = this.#feedDecoderSource(packetId, ssId, symbolData, catalogTrackName, sbn)
4299
+ if (recovered === null && !this.#completeWhenSourcesPresent(key, sbn, located.block.length, packetId, catalogTrackName)) {
4300
+ // The late-source cut assumed only a late source could complete this
4301
+ // block; with this source its group can, so it gets a full multicast
4302
+ // wait again, counted from now.
4303
+ // ponytail: from now, not from its sources, so it can close up to one
4304
+ // multicast wait after its uncut deadline; at most once per block, since
4305
+ // a rebuilt source only adds to what the block holds and the cut cannot
4306
+ // apply to it again. Keep the uncut deadline and re-arm to it if a lab
4307
+ // run shows that wait.
4308
+ if (wasShort && this.#activeBlocks.has(key) &&
4309
+ this.#lateSourceWaitMs(key, sbn, packetId, catalogTrackName, config!) === undefined) {
4310
+ this.#armBlockTimeout(sbn, packetId, catalogTrackName, this.#multicastWaitMs(config!))
4311
+ }
4312
+ }
4313
+ return { taken: true, recovered }
4314
+ }
4315
+
4316
+ /**
4317
+ * A block that holds every one of its `length` sources, received or
4318
+ * rebuilt by another instance, with at least one rebuilt, is recovered:
4319
+ * every packet of it was delivered, by its own arrival or by the instance
4320
+ * that rebuilt it, so nothing is routed. Returns whether it completed it.
4321
+ */
4322
+ #completeWhenSourcesPresent(
4323
+ key: string,
4324
+ sbn: number,
4325
+ length: number,
4326
+ packetId: number,
4327
+ catalogTrackName: string | undefined,
4328
+ ): boolean {
4329
+ const rebuilt = this.#blockRebuiltEsis.get(key)
4330
+ if (rebuilt === undefined || this.#completedBlocks.has(key)) return false
4331
+ let present = rebuilt.size
4332
+ for (const esi of this.#blockSourceEsis.get(key) ?? []) if (!rebuilt.has(esi)) present++
4333
+ if (present < length) return false
4334
+ this.#traceLifecycle('recovered', sbn, packetId, catalogTrackName)
4335
+ this.countRecovery(packetId, sbn, catalogTrackName)
4336
+ this.cancelBlockTimeout(sbn, packetId, catalogTrackName)
4337
+ if (catalogTrackName === undefined) {
4338
+ this.onBlockComplete?.(sbn, packetId)
4339
+ } else {
4340
+ this.onBlockComplete?.(sbn, packetId, catalogTrackName)
4341
+ }
4342
+ return true
4343
+ }
4344
+
4089
4345
  /**
4090
4346
  * fec-01 4.5: a reed-solomon repair packet whose SSB_length + RSB_length
4091
4347
  * exceeds 255, or whose RS_ID is not below RSB_length, is discarded with the
@@ -4343,9 +4599,100 @@ export class FECManager {
4343
4599
  this.#completeCleanBlock(blockKey, sbn, packetId, catalogTrackName)
4344
4600
  return null
4345
4601
  }
4602
+ // Its K' is known now: a block this repair shows short of it waits less.
4603
+ if (recovered === null) this.#shortenUnrecoverableWait(blockKey, sbn, packetId, catalogTrackName)
4346
4604
  return recovered
4347
4605
  }
4348
4606
 
4607
+ /**
4608
+ * A first-arrival multicast source, held or placed. A datagram flow keeps
4609
+ * its sources in send order, so one that moves the stream's newest source
4610
+ * position past a block leaves the sources that block still misses
4611
+ * reordered behind it at best: it shortens the wait of every block it
4612
+ * passed that only a late source can complete. MoQ is left out: its streams
4613
+ * can overtake one another, so a MoQ source shows nothing about the sources
4614
+ * sent before it.
4615
+ */
4616
+ #noteMulticastSource(
4617
+ locator: IsoBlockLocator<IsoHeldSource>,
4618
+ ssId: number,
4619
+ packetId: number,
4620
+ catalogTrackName: string | undefined,
4621
+ ): void {
4622
+ const previous = locator.newestSource
4623
+ if (!locator.noteSource(ssId) || previous === undefined) return
4624
+ // The blocks whose last SS_ID lies in [previous, ssId): this source
4625
+ // passed them and the previous newest one had not.
4626
+ for (const block of locator.blocksBetween((previous - 1) >>> 0, ssId)) {
4627
+ if (block.span || !serial32Precedes(serial32Add(block.start, block.length - 1), ssId)) continue
4628
+ this.#shortenUnrecoverableWait(
4629
+ this.#blockKey(block.start, packetId, catalogTrackName),
4630
+ block.start,
4631
+ packetId,
4632
+ catalogTrackName,
4633
+ )
4634
+ }
4635
+ }
4636
+
4637
+ /**
4638
+ * The wait of a block that only a late source can complete (BLO-52085): a
4639
+ * repair stated it, its received sources plus every symbol its repair group
4640
+ * carries (RSB_length) are short of K' (the repair scheduler's
4641
+ * group-cannot-complete), and a later source of the stream has arrived, so
4642
+ * a source it misses is lost or reordered behind that one. The span, which
4643
+ * covers repair symbols paced after its sources, buys it nothing: it waits
4644
+ * the late-symbol allowance alone, the multicast wait less the span (the
4645
+ * catalog tolerance or the measured delivery gap). Undefined for any other
4646
+ * block.
4647
+ */
4648
+ #lateSourceWaitMs(
4649
+ key: string,
4650
+ sbn: number,
4651
+ packetId: number,
4652
+ catalogTrackName: string | undefined,
4653
+ config: ResolvedFECTrackConfig,
4654
+ ): number | undefined {
4655
+ const authoredLength = this.#blockAuthoredLengths.get(key)
4656
+ const rsbLength = this.#blockRsbLengths.get(key)
4657
+ if (authoredLength === undefined || rsbLength === undefined) return undefined
4658
+ // A source another instance rebuilt (feedRebuiltSourceSymbol) is one the
4659
+ // decoder holds: the group need only cover what neither delivered.
4660
+ let present = this.#blockSourceCounts.get(key) ?? 0
4661
+ const received = this.#blockSourceEsis.get(key)
4662
+ for (const esi of this.#blockRebuiltEsis.get(key) ?? []) if (!received?.has(esi)) present++
4663
+ if (present + rsbLength >= authoredLength) return undefined
4664
+ const newestSource = this.#isoLocators.get(this.#streamKey(packetId, catalogTrackName))?.newestSource
4665
+ if (newestSource === undefined || !serial32Precedes(serial32Add(sbn, authoredLength - 1), newestSource)) {
4666
+ return undefined
4667
+ }
4668
+ return this.#multicastWaitMs(config) - config.interleaveDepthMs
4669
+ }
4670
+
4671
+ /**
4672
+ * Bring an open block's wait in to its late-source wait, when it has one.
4673
+ * Never later than it is armed: only a source can complete such a block,
4674
+ * so a repair of its group (paced across the span) must not keep it open.
4675
+ */
4676
+ #shortenUnrecoverableWait(
4677
+ key: string,
4678
+ sbn: number,
4679
+ packetId: number,
4680
+ catalogTrackName: string | undefined,
4681
+ ): void {
4682
+ if (!this.#activeBlocks.has(key)) return
4683
+ const config = this.#trackConfigs.get(packetId)
4684
+ if (config === undefined) return
4685
+ const waitMs = this.#lateSourceWaitMs(key, sbn, packetId, catalogTrackName, config)
4686
+ if (waitMs === undefined) return
4687
+ const armedAtMs = this.#blockWaitDeadlinesMs.get(key)
4688
+ this.#armBlockTimeout(
4689
+ sbn,
4690
+ packetId,
4691
+ catalogTrackName,
4692
+ armedAtMs === undefined ? waitMs : Math.min(waitMs, armedAtMs - this.#receiptNow()),
4693
+ )
4694
+ }
4695
+
4349
4696
  /**
4350
4697
  * Record a block's authored SSB_length from a repair symbol. Returns false,
4351
4698
  * leaving the block unchanged, when the symbol contradicts the block: a
@@ -4485,7 +4832,8 @@ export class FECManager {
4485
4832
  * the inactivity deadline on each symbol, records the symbol's in-band
4486
4833
  * classification evidence, and marks the block clean when all of its
4487
4834
  * authored source symbols arrived: the SSB_length a repair symbol declared,
4488
- * otherwise K.
4835
+ * otherwise K. `held` marks a source held unplaced since it arrived and
4836
+ * placed only now (see #noteSbnSeen).
4489
4837
  *
4490
4838
  * Throws for a packetId that was never configured, so a classified block
4491
4839
  * never reads the manager-global K or timeout.
@@ -4496,6 +4844,7 @@ export class FECManager {
4496
4844
  catalogTrackName: string | undefined,
4497
4845
  esi: number,
4498
4846
  evidence: SourceSymbolEvidence,
4847
+ held = false,
4499
4848
  ): void {
4500
4849
  const trackConfig = this.#trackConfigs.get(packetId)
4501
4850
  if (trackConfig === undefined) {
@@ -4517,6 +4866,7 @@ export class FECManager {
4517
4866
  'source',
4518
4867
  evidence.packetSequenceNumber,
4519
4868
  Number.isInteger(esi) ? (sbn + esi) >>> 0 : sbn,
4869
+ held,
4520
4870
  )
4521
4871
 
4522
4872
  // A source ESI at or past the block's authored SSB_length contradicts it;
@@ -4561,6 +4911,20 @@ export class FECManager {
4561
4911
  this.#completeCleanBlock(key, sbn, packetId, catalogTrackName)
4562
4912
  return
4563
4913
  }
4914
+ // A late arrival can be the last source a block lacked besides those
4915
+ // another instance rebuilt (feedRebuiltSourceSymbol).
4916
+ if (
4917
+ this.#blockRebuiltEsis.has(key) &&
4918
+ this.#completeWhenSourcesPresent(
4919
+ key,
4920
+ sbn,
4921
+ this.#isoLocatorFor(packetId, catalogTrackName).blockAt(sbn)?.length ?? authoredLength,
4922
+ packetId,
4923
+ catalogTrackName,
4924
+ )
4925
+ ) {
4926
+ return
4927
+ }
4564
4928
 
4565
4929
  this.noteBlockActivity(sbn, packetId, catalogTrackName, true)
4566
4930
 
@@ -4580,7 +4944,9 @@ export class FECManager {
4580
4944
  this.cancelBlockTimeout(sbn, packetId, catalogTrackName)
4581
4945
  this.#blockSourceCounts.delete(key)
4582
4946
  this.#blockSourceEsis.delete(key)
4947
+ this.#blockRebuiltEsis.delete(key)
4583
4948
  this.#blockAuthoredLengths.delete(key)
4949
+ this.#isoSpanKeys.delete(key)
4584
4950
  this.#blockRepairRsIds.delete(key)
4585
4951
  this.#blockRsbLengths.delete(key)
4586
4952
  // BLO-3323 L15: track which SBNs reached K source-symbol arrivals so the
@@ -4622,6 +4988,20 @@ export class FECManager {
4622
4988
  return this.#receiptAtMs ?? performance.now()
4623
4989
  }
4624
4990
 
4991
+ /**
4992
+ * The whole-ms delay of a timer for `deadlineMs` armed inside a receipt
4993
+ * scope: counted from the receipt time, as the timer of a symbol fed when
4994
+ * it was received was, less the whole ms since. So it fires on the tick
4995
+ * that timer would have, never later than a delay counted from now, and
4996
+ * its handler still re-arms a timer that fires before its deadline.
4997
+ * Counted from now, a deadline set from the receipt time is rounded up
4998
+ * from another fraction of a ms (BLO-52085).
4999
+ */
5000
+ #receiptTimerDelayMs(deadlineMs: number, minMs: number): number {
5001
+ const receivedAtMs = this.#receiptAtMs!
5002
+ return Math.max(minMs, Math.ceil(deadlineMs - receivedAtMs) - Math.ceil(performance.now() - receivedAtMs))
5003
+ }
5004
+
4625
5005
  /**
4626
5006
  * A held packet admitted late arms from its (earlier) receipt time; that
4627
5007
  * never pulls in a deadline later activity already armed. Outside a
@@ -4693,7 +5073,18 @@ export class FECManager {
4693
5073
  )
4694
5074
  }
4695
5075
 
4696
- this.#armBlockTimeout(sbn, packetId, catalogTrackName, timeoutMs)
5076
+ // A block only a late source can complete waits the late-symbol
5077
+ // allowance from its own sources' activity; nothing else moves it later.
5078
+ if (!sourceProgress && this.#lateSourceWaitMs(key, sbn, packetId, catalogTrackName, trackConfig) !== undefined) {
5079
+ this.#shortenUnrecoverableWait(key, sbn, packetId, catalogTrackName)
5080
+ return
5081
+ }
5082
+ this.#armBlockTimeout(
5083
+ sbn,
5084
+ packetId,
5085
+ catalogTrackName,
5086
+ this.#lateSourceWaitMs(key, sbn, packetId, catalogTrackName, trackConfig) ?? timeoutMs,
5087
+ )
4697
5088
  }
4698
5089
 
4699
5090
  #failBlock(
@@ -4702,6 +5093,9 @@ export class FECManager {
4702
5093
  catalogTrackName?: string,
4703
5094
  ): void {
4704
5095
  const key = this.#blockKey(sbn, packetId, catalogTrackName)
5096
+ const span: IsoSpanFailure | undefined = this.#isoSpanKeys.delete(key)
5097
+ ? { length: this.#blockAuthoredLengths.get(key) ?? 0, sources: this.#blockSourceCounts.get(key) ?? 0 }
5098
+ : undefined
4705
5099
  this.#traceLifecycle('failed', sbn, packetId, catalogTrackName)
4706
5100
  this.#clearRepairDeadline(key)
4707
5101
  this.#clearBlockTimer(key)
@@ -4711,6 +5105,7 @@ export class FECManager {
4711
5105
  }
4712
5106
  this.#blockSourceCounts.delete(key)
4713
5107
  this.#blockSourceEsis.delete(key)
5108
+ this.#blockRebuiltEsis.delete(key)
4714
5109
  this.#blockAuthoredLengths.delete(key)
4715
5110
  this.#blockRepairRsIds.delete(key)
4716
5111
  this.#blockRsbLengths.delete(key)
@@ -4720,7 +5115,8 @@ export class FECManager {
4720
5115
  this.#markPathBlockComplete(sbn, packetId, catalogTrackName)
4721
5116
  this.#fecStats.blocksFailed++
4722
5117
  this.#statsForPacket(packetId).blocksFailed++
4723
- this.onBlockFailed?.(sbn, packetId, catalogTrackName, blockClass)
5118
+ if (span === undefined) this.onBlockFailed?.(sbn, packetId, catalogTrackName, blockClass)
5119
+ else this.onBlockFailed?.(sbn, packetId, catalogTrackName, blockClass, span)
4724
5120
  this.onBlockResolved?.(sbn, packetId, catalogTrackName)
4725
5121
  }
4726
5122
 
@@ -4750,7 +5146,9 @@ export class FECManager {
4750
5146
  this.#retireBlockClass(key, block.sbn, block.packetId, block.catalogTrackName)
4751
5147
  this.#blockSourceCounts.delete(key)
4752
5148
  this.#blockSourceEsis.delete(key)
5149
+ this.#blockRebuiltEsis.delete(key)
4753
5150
  this.#blockAuthoredLengths.delete(key)
5151
+ this.#isoSpanKeys.delete(key)
4754
5152
  this.#blockRepairRsIds.delete(key)
4755
5153
  this.#blockRsbLengths.delete(key)
4756
5154
  this.#clearBlockTimer(key)
@@ -4858,7 +5256,9 @@ export class FECManager {
4858
5256
  this.#blockKeysBySbn.clear()
4859
5257
  this.#blockSourceCounts.clear()
4860
5258
  this.#blockSourceEsis.clear()
5259
+ this.#blockRebuiltEsis.clear()
4861
5260
  this.#blockAuthoredLengths.clear()
5261
+ this.#isoSpanKeys.clear()
4862
5262
  this.#blockRepairRsIds.clear()
4863
5263
  this.#blockRsbLengths.clear()
4864
5264
  this.#heldLayerStates.clear()
@@ -4883,6 +5283,7 @@ export class FECManager {
4883
5283
  this.#blockAnchorsMs.clear()
4884
5284
  this.#blockCatalogTrackNames.clear()
4885
5285
  this.#classifiers.clear()
5286
+ this.#noSpanRaps.clear()
4886
5287
  this.#streamGeometries.clear()
4887
5288
  this.#failedBlocks.clear()
4888
5289
  this.#resolvedBlockClasses.clear()