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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/dist/browser-runtime.js +634 -129
  2. package/dist/browser-runtime.js.map +3 -3
  3. package/dist/delta-fallback-config.js.map +1 -1
  4. package/dist/fec-manager.d.ts +37 -11
  5. package/dist/fec-manager.d.ts.map +1 -1
  6. package/dist/fec-manager.js +181 -52
  7. package/dist/fec-manager.js.map +2 -2
  8. package/dist/fec-worker-setup.js.map +1 -1
  9. package/dist/index.d.ts +1 -1
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js.map +2 -2
  12. package/dist/iso-block-locator.d.ts +39 -13
  13. package/dist/iso-block-locator.d.ts.map +1 -1
  14. package/dist/iso-block-locator.js +80 -25
  15. package/dist/iso-block-locator.js.map +2 -2
  16. package/dist/media-router.js.map +1 -1
  17. package/dist/mmtp-container-parser.d.ts +21 -0
  18. package/dist/mmtp-container-parser.d.ts.map +1 -1
  19. package/dist/mmtp-container-parser.js +114 -11
  20. package/dist/mmtp-container-parser.js.map +2 -2
  21. package/dist/mmtp-container-parser.no-modules.js +113 -12
  22. package/dist/mmtp-container-parser.no-modules.js.map +2 -2
  23. package/dist/mmtp-decode-pipeline.js +114 -11
  24. package/dist/mmtp-decode-pipeline.js.map +2 -2
  25. package/dist/mmtp-router.d.ts +18 -9
  26. package/dist/mmtp-router.d.ts.map +1 -1
  27. package/dist/mmtp-router.js +254 -44
  28. package/dist/mmtp-router.js.map +2 -2
  29. package/dist/multicast-manager.d.ts +13 -3
  30. package/dist/multicast-manager.d.ts.map +1 -1
  31. package/dist/multicast-manager.js +455 -103
  32. package/dist/multicast-manager.js.map +2 -2
  33. package/dist/no-modules.js +114 -11
  34. package/dist/no-modules.js.map +2 -2
  35. package/dist/repair-fetch.d.ts +36 -8
  36. package/dist/repair-fetch.d.ts.map +1 -1
  37. package/dist/repair-fetch.js +285 -26
  38. package/dist/repair-fetch.js.map +3 -3
  39. package/dist/track-session.d.ts.map +1 -1
  40. package/dist/track-session.js +1 -0
  41. package/dist/track-session.js.map +2 -2
  42. package/dist/wasm-mmtp-container-parser.js +114 -11
  43. package/dist/wasm-mmtp-container-parser.js.map +2 -2
  44. package/package.json +5 -5
  45. package/src/fec-manager.ts +156 -26
  46. package/src/index.ts +2 -0
  47. package/src/iso-block-locator.ts +114 -31
  48. package/src/mmtp-container-parser.ts +181 -10
  49. package/src/mmtp-router.ts +231 -58
  50. package/src/multicast-manager.ts +44 -6
  51. package/src/repair-fetch.ts +363 -49
  52. package/src/track-session.ts +3 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blockcast/mmt-container",
3
- "version": "2.2.0-main.75b39a2cdddf",
3
+ "version": "2.2.0-main.7e8a9d13c01c",
4
4
  "description": "MMTP/LOC container parsing for MMT over MoQ and SSM",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -169,10 +169,10 @@
169
169
  "test:watch": "vitest"
170
170
  },
171
171
  "dependencies": {
172
- "@blockcast/fec-worker": "2.2.0-main.75b39a2cdddf",
173
- "@blockcast/mmt-base": "2.2.0-main.75b39a2cdddf",
174
- "@blockcast/mmt-fec": "2.2.0-main.75b39a2cdddf",
175
- "@blockcast/mmt-manifest-verify": "2.2.0-main.75b39a2cdddf",
172
+ "@blockcast/fec-worker": "2.2.0-main.7e8a9d13c01c",
173
+ "@blockcast/mmt-base": "2.2.0-main.7e8a9d13c01c",
174
+ "@blockcast/mmt-fec": "2.2.0-main.7e8a9d13c01c",
175
+ "@blockcast/mmt-manifest-verify": "2.2.0-main.7e8a9d13c01c",
176
176
  "@noble/curves": "^1.9.7",
177
177
  "@noble/hashes": "^1.8.0",
178
178
  "ajv": "^8.17.1",
@@ -213,6 +213,25 @@ export interface UndelimitedIsoLoss {
213
213
  readonly previousBlockStart?: number
214
214
  }
215
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
+
216
235
  /**
217
236
  * How the multicast leg hands its symbols to this manager.
218
237
  *
@@ -522,7 +541,14 @@ export class FECManager {
522
541
  // 'pending' forever, which would park every later frame of its stream in
523
542
  // the in-order release gate. Each entry owns its handle; the callback
524
543
  // checks it still owns the key, so a settled or reset key is never touched.
525
- #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
+ >()
526
552
  // Source symbol count per track-scoped block — marks complete when K symbols arrive.
527
553
  #blockSourceCounts = new Map<string, number>()
528
554
  #blockSourceEsis = new Map<string, Set<number>>()
@@ -538,6 +564,9 @@ export class FECManager {
538
564
  // SSB_length source arrivals and recoverable from SSB_length received
539
565
  // symbols; waiting for K fails every one of them.
540
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>()
541
570
  #ssbLengthConflictCount = 0
542
571
  #blockRepairRsIds = new Map<string, Set<number>>()
543
572
  // RSB_length per block (C.5.3 Repair FEC Payload ID): how many repair
@@ -745,13 +774,17 @@ export class FECManager {
745
774
  onBlockComplete:
746
775
  | ((sbn: number, packetId: number, catalogTrackName?: string) => void)
747
776
  | null = null
748
- /** 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
+ */
749
781
  onBlockFailed:
750
782
  | ((
751
783
  sbn: number,
752
784
  packetId: number,
753
785
  catalogTrackName: string | undefined,
754
786
  blockClass: BlockClass,
787
+ span?: IsoSpanFailure,
755
788
  ) => void)
756
789
  | null = null
757
790
  /**
@@ -835,6 +868,8 @@ export class FECManager {
835
868
  * for it, which it must be.
836
869
  */
837
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
838
873
  /** A block every symbol of which vanished was created by SBN evidence. */
839
874
  onBlockVanished:
840
875
  | ((sbn: number, packetId: number, catalogTrackName: string | undefined) => void)
@@ -1489,7 +1524,12 @@ export class FECManager {
1489
1524
  timer.handle = null
1490
1525
  this.#requestRepair(sbn, packetId, catalogTrackName)
1491
1526
  }
1492
- 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
+ )
1493
1533
  this.#repairDeadlines.set(key, timer)
1494
1534
  }
1495
1535
 
@@ -1683,6 +1723,12 @@ export class FECManager {
1683
1723
  * Vanished blocks are not enumerated here: their starts are unknown until a
1684
1724
  * repair states them. The SS_IDs between two stated blocks become one
1685
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).
1686
1732
  */
1687
1733
  #noteSbnSeen(
1688
1734
  sbn: number,
@@ -1691,6 +1737,7 @@ export class FECManager {
1691
1737
  symbol: 'source' | 'repair',
1692
1738
  packetSequenceNumber?: number,
1693
1739
  ssId = sbn,
1740
+ held = false,
1694
1741
  ): void {
1695
1742
  const streamKey = this.#streamKey(packetId, catalogTrackName)
1696
1743
  // Arrival at FEC, not receipt (withReceiptTime): this bounds how far SS_ID
@@ -1747,7 +1794,7 @@ export class FECManager {
1747
1794
  }
1748
1795
  if (!this.#blockPrecedes(previous.sbn, sbn)) {
1749
1796
  if (symbol === 'source' && window !== null && this.#blockPrecedes(sbn, previous.sbn) && (
1750
- this.#blocksBehind(sbn, previous.sbn, packetId) > this.#isoRetentionBlocks(packetId) ||
1797
+ (!held && this.#blocksBehind(sbn, previous.sbn, packetId) > this.#isoRetentionBlocks(packetId)) ||
1751
1798
  (psn !== undefined && anchor !== undefined && serial32Precedes(ssId, anchor.ssId) &&
1752
1799
  psn !== anchor.psn && !sentInOrder({ ssId, psn }, anchor))
1753
1800
  )) {
@@ -2101,21 +2148,28 @@ export class FECManager {
2101
2148
  this.#blockWaitDeadlinesMs.delete(key)
2102
2149
  this.#repairHoldUntilMs.delete(key)
2103
2150
  this.#traceLifecycle('timeout-queued', sbn, packetId, catalogTrackName)
2104
- this.#finalizeExpiredBlock(sbn, packetId, catalogTrackName)
2151
+ this.#finalizeExpiredBlock(sbn, packetId, catalogTrackName, currentDeadlineMs ?? firedAtMs)
2105
2152
  }
2106
- 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
+ )
2107
2159
  this.#activeBlocks.set(key, timer)
2108
2160
  this.#indexBlockKey(key, sbn, packetId)
2109
2161
  }
2110
2162
 
2111
2163
  /**
2112
2164
  * Hand a block whose delivery is over to its decoder for finalization: the
2113
- * 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.
2114
2167
  */
2115
2168
  #finalizeExpiredBlock(
2116
2169
  sbn: number,
2117
2170
  packetId: number,
2118
2171
  catalogTrackName: string | undefined,
2172
+ dueAtMs = performance.now(),
2119
2173
  ): void {
2120
2174
  const key = this.#blockKey(sbn, packetId, catalogTrackName)
2121
2175
  // A vanished block that never received a symbol holds no decoder state
@@ -2139,7 +2193,7 @@ export class FECManager {
2139
2193
  if (workerClient) {
2140
2194
  this.#pendingWorkerCleanups.add(key)
2141
2195
  this.#pendingWorkerCleanupClients.set(key, workerClient)
2142
- this.#boundOwedCleanup(key, packetId, workerClient)
2196
+ this.#boundOwedCleanup(key, packetId, workerClient, dueAtMs)
2143
2197
  workerClient.cleanup(sbn)
2144
2198
  } else {
2145
2199
  const decoder = this.#decoderForPacket(packetId, catalogTrackName)?.client
@@ -2558,6 +2612,7 @@ export class FECManager {
2558
2612
  resetMap(this.#blockSourceEsis)
2559
2613
  resetMap(this.#blockRebuiltEsis)
2560
2614
  resetMap(this.#blockAuthoredLengths)
2615
+ resetSet(this.#isoSpanKeys)
2561
2616
  resetMap(this.#blockRepairRsIds)
2562
2617
  resetMap(this.#blockRsbLengths)
2563
2618
  resetMap(this.#heldLayerStates)
@@ -2691,6 +2746,7 @@ export class FECManager {
2691
2746
  dropMap(this.#blockSourceEsis)
2692
2747
  dropMap(this.#blockRebuiltEsis)
2693
2748
  dropMap(this.#blockAuthoredLengths)
2749
+ dropSet(this.#isoSpanKeys)
2694
2750
  dropMap(this.#blockRepairRsIds)
2695
2751
  dropMap(this.#blockRsbLengths)
2696
2752
  dropMap(this.#blockPathCounts)
@@ -3122,8 +3178,9 @@ export class FECManager {
3122
3178
  return true
3123
3179
  }
3124
3180
  /**
3125
- * Drop a block's repair hold: its FETCH ended without delivering. The block
3126
- * 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.
3127
3184
  */
3128
3185
  releaseRepairHold(sbn: number, packetId: number, catalogTrackName?: string): void {
3129
3186
  const key = this.#blockKey(sbn, packetId, catalogTrackName)
@@ -3285,6 +3342,7 @@ export class FECManager {
3285
3342
  const locator = this.#growingIsoLocatorFor(packetId, catalogTrackName)
3286
3343
  const evictedBefore = locator.heldEvicted
3287
3344
  const held = locator.hold(ssId, source)
3345
+ this.#settleExpiredIsoHoles(locator, packetId, catalogTrackName)
3288
3346
  if (held && source.source === 'multicast') this.#noteMulticastSource(locator, ssId, packetId, catalogTrackName)
3289
3347
  const evicted = locator.heldEvicted - evictedBefore
3290
3348
  if (evicted > 0) {
@@ -3313,17 +3371,26 @@ export class FECManager {
3313
3371
  ssStart: number,
3314
3372
  ssbLength: number,
3315
3373
  ): IsoRepairPlacement<IsoHeldSource> {
3316
- 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)
3317
3377
  if (placement.status !== 'placed') {
3318
3378
  this.#isoRepairRefusedCount++
3319
3379
  if (this.#isoRepairRefusedCount <= 5 || this.#isoRepairRefusedCount % 100 === 0) {
3320
3380
  console.warn(`[FECManager] packetId=${packetId} iso-ssbg1-v1 repair refused (${placement.status}): ${placement.reason}`)
3321
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)
3322
3387
  return placement
3323
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))
3324
3392
  if (placement.span !== undefined) this.#openIsoSpan(placement.span, packetId, catalogTrackName)
3325
3393
  if (placement.hole !== undefined) {
3326
- const locator = this.#isoLocatorFor(packetId, catalogTrackName)
3327
3394
  const hole = placement.hole.block
3328
3395
  const previous = locator.blockEndingAt(hole.start)
3329
3396
  const loss: UndelimitedIsoLoss = {
@@ -3345,18 +3412,38 @@ export class FECManager {
3345
3412
  * `handler`, instead of a repair-less span; null restores spans. Set by the
3346
3413
  * range repair scheduler; applies to every stream from its next gap. The
3347
3414
  * handler and the deferral are one setting, so no hole is ever created with
3348
- * 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.
3349
3419
  */
3350
- setUndelimitedLossHandler(handler: ((loss: UndelimitedIsoLoss) => void) | null): void {
3420
+ setUndelimitedLossHandler(
3421
+ handler: ((loss: UndelimitedIsoLoss) => void) | null,
3422
+ onExpired: ((hole: ExpiredIsoHole) => boolean) | null = null,
3423
+ ): void {
3351
3424
  this.#onUndelimitedLoss = handler
3425
+ this.#onIsoHoleExpired = handler === null ? null : onExpired
3352
3426
  this.#deferIsoGaps = handler !== null
3353
3427
  for (const locator of this.#isoLocators.values()) locator.deferIncompleteGaps = this.#deferIsoGaps
3354
3428
  }
3355
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
+
3356
3442
  /**
3357
- * The range repair for a hole ended: every hole still pending inside
3358
- * `[start, start + length)` becomes a repair-less span. Returns those spans
3359
- * 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).
3360
3447
  */
3361
3448
  closeIsoHoles(
3362
3449
  packetId: number,
@@ -3385,7 +3472,10 @@ export class FECManager {
3385
3472
  catalogTrackName: string | undefined,
3386
3473
  ): void {
3387
3474
  const key = this.#blockKey(span.block.start, packetId, catalogTrackName)
3388
- 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
+ }
3389
3479
  if (span.placed.length === 0) this.#materializeBlock(span.block.start, packetId, catalogTrackName, true)
3390
3480
  }
3391
3481
 
@@ -3864,13 +3954,27 @@ export class FECManager {
3864
3954
 
3865
3955
  client.onCleanupComplete((sbn: number) => {
3866
3956
  if (this.#releasedWorkerClients.has(client)) return
3867
- const cleanupKeys = [...this.#pendingWorkerCleanups].filter((key) => {
3957
+ const owed = [...this.#pendingWorkerCleanups].filter((key) => this.#pendingWorkerCleanupClients.get(key) === client)
3958
+ const cleanupKeys = owed.filter((key) => {
3868
3959
  const block = this.#parseBlockKey(key)
3869
- return block.packetId === packetId &&
3870
- block.sbn === sbn &&
3871
- this.#pendingWorkerCleanupClients.get(key) === client
3960
+ return block.packetId === packetId && block.sbn === sbn
3872
3961
  })
3873
- 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')
3874
3978
  })
3875
3979
 
3876
3980
  client.onError((msg: string, code?: FecWorkerErrorCode) => {
@@ -3952,9 +4056,11 @@ export class FECManager {
3952
4056
  * fail deadline already passed, so failing it is the decision that deadline
3953
4057
  * made. A late ack then finds nothing owed and is a no-op.
3954
4058
  */
3955
- #boundOwedCleanup(key: string, packetId: number, client: FecWorkerClient): void {
4059
+ #boundOwedCleanup(key: string, packetId: number, client: FecWorkerClient, dueAtMs: number): void {
3956
4060
  this.#clearOwedCleanupBound(key)
3957
4061
  const bound = {
4062
+ dueAtMs,
4063
+ askedAtMs: performance.now(),
3958
4064
  handle: setTimeout(() => {
3959
4065
  if (this.#pendingWorkerCleanupBounds.get(key) !== bound) return
3960
4066
  this.#pendingWorkerCleanupBounds.delete(key)
@@ -4726,7 +4832,8 @@ export class FECManager {
4726
4832
  * the inactivity deadline on each symbol, records the symbol's in-band
4727
4833
  * classification evidence, and marks the block clean when all of its
4728
4834
  * authored source symbols arrived: the SSB_length a repair symbol declared,
4729
- * otherwise K.
4835
+ * otherwise K. `held` marks a source held unplaced since it arrived and
4836
+ * placed only now (see #noteSbnSeen).
4730
4837
  *
4731
4838
  * Throws for a packetId that was never configured, so a classified block
4732
4839
  * never reads the manager-global K or timeout.
@@ -4737,6 +4844,7 @@ export class FECManager {
4737
4844
  catalogTrackName: string | undefined,
4738
4845
  esi: number,
4739
4846
  evidence: SourceSymbolEvidence,
4847
+ held = false,
4740
4848
  ): void {
4741
4849
  const trackConfig = this.#trackConfigs.get(packetId)
4742
4850
  if (trackConfig === undefined) {
@@ -4758,6 +4866,7 @@ export class FECManager {
4758
4866
  'source',
4759
4867
  evidence.packetSequenceNumber,
4760
4868
  Number.isInteger(esi) ? (sbn + esi) >>> 0 : sbn,
4869
+ held,
4761
4870
  )
4762
4871
 
4763
4872
  // A source ESI at or past the block's authored SSB_length contradicts it;
@@ -4837,6 +4946,7 @@ export class FECManager {
4837
4946
  this.#blockSourceEsis.delete(key)
4838
4947
  this.#blockRebuiltEsis.delete(key)
4839
4948
  this.#blockAuthoredLengths.delete(key)
4949
+ this.#isoSpanKeys.delete(key)
4840
4950
  this.#blockRepairRsIds.delete(key)
4841
4951
  this.#blockRsbLengths.delete(key)
4842
4952
  // BLO-3323 L15: track which SBNs reached K source-symbol arrivals so the
@@ -4878,6 +4988,20 @@ export class FECManager {
4878
4988
  return this.#receiptAtMs ?? performance.now()
4879
4989
  }
4880
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
+
4881
5005
  /**
4882
5006
  * A held packet admitted late arms from its (earlier) receipt time; that
4883
5007
  * never pulls in a deadline later activity already armed. Outside a
@@ -4969,6 +5093,9 @@ export class FECManager {
4969
5093
  catalogTrackName?: string,
4970
5094
  ): void {
4971
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
4972
5099
  this.#traceLifecycle('failed', sbn, packetId, catalogTrackName)
4973
5100
  this.#clearRepairDeadline(key)
4974
5101
  this.#clearBlockTimer(key)
@@ -4988,7 +5115,8 @@ export class FECManager {
4988
5115
  this.#markPathBlockComplete(sbn, packetId, catalogTrackName)
4989
5116
  this.#fecStats.blocksFailed++
4990
5117
  this.#statsForPacket(packetId).blocksFailed++
4991
- 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)
4992
5120
  this.onBlockResolved?.(sbn, packetId, catalogTrackName)
4993
5121
  }
4994
5122
 
@@ -5020,6 +5148,7 @@ export class FECManager {
5020
5148
  this.#blockSourceEsis.delete(key)
5021
5149
  this.#blockRebuiltEsis.delete(key)
5022
5150
  this.#blockAuthoredLengths.delete(key)
5151
+ this.#isoSpanKeys.delete(key)
5023
5152
  this.#blockRepairRsIds.delete(key)
5024
5153
  this.#blockRsbLengths.delete(key)
5025
5154
  this.#clearBlockTimer(key)
@@ -5129,6 +5258,7 @@ export class FECManager {
5129
5258
  this.#blockSourceEsis.clear()
5130
5259
  this.#blockRebuiltEsis.clear()
5131
5260
  this.#blockAuthoredLengths.clear()
5261
+ this.#isoSpanKeys.clear()
5132
5262
  this.#blockRepairRsIds.clear()
5133
5263
  this.#blockRsbLengths.clear()
5134
5264
  this.#heldLayerStates.clear()
package/src/index.ts CHANGED
@@ -260,6 +260,8 @@ export {
260
260
  type FecStats,
261
261
  type MulticastSymbolDelivery,
262
262
  type UndelimitedIsoLoss,
263
+ type ExpiredIsoHole,
264
+ type IsoSpanFailure,
263
265
  type FecBlockResolution,
264
266
  type RepairVia,
265
267
  type FecRepairLayer,