@blockcast/mmt-container 0.4.1 → 0.5.0-main.34c7b9c2cfa0

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 (87) hide show
  1. package/README.md +2 -2
  2. package/dist/browser-runtime.js +3769 -364
  3. package/dist/browser-runtime.js.map +4 -4
  4. package/dist/cmcd-producer.d.ts +5 -1
  5. package/dist/cmcd-producer.d.ts.map +1 -1
  6. package/dist/cmcd-producer.js.map +2 -2
  7. package/dist/delivery-catalog.js +18 -2
  8. package/dist/delivery-catalog.js.map +2 -2
  9. package/dist/fec-manager.d.ts +21 -16
  10. package/dist/fec-manager.d.ts.map +1 -1
  11. package/dist/fec-manager.js +197 -80
  12. package/dist/fec-manager.js.map +3 -3
  13. package/dist/held-layer-admission.d.ts +58 -0
  14. package/dist/held-layer-admission.d.ts.map +1 -0
  15. package/dist/held-layer-admission.js +105 -0
  16. package/dist/held-layer-admission.js.map +7 -0
  17. package/dist/index.d.ts +1 -1
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js.map +2 -2
  20. package/dist/mmtp-container-parser.d.ts +10 -0
  21. package/dist/mmtp-container-parser.d.ts.map +1 -1
  22. package/dist/mmtp-container-parser.js +31 -1
  23. package/dist/mmtp-container-parser.js.map +2 -2
  24. package/dist/mmtp-container-parser.no-modules.js +2713 -1024
  25. package/dist/mmtp-container-parser.no-modules.js.map +4 -4
  26. package/dist/mmtp-decode-pipeline.js +31 -1
  27. package/dist/mmtp-decode-pipeline.js.map +2 -2
  28. package/dist/mmtp-fec-session.d.ts +36 -3
  29. package/dist/mmtp-fec-session.d.ts.map +1 -1
  30. package/dist/mmtp-fec-session.js +114 -5
  31. package/dist/mmtp-fec-session.js.map +3 -3
  32. package/dist/mmtp-router.d.ts +96 -29
  33. package/dist/mmtp-router.d.ts.map +1 -1
  34. package/dist/mmtp-router.js +522 -205
  35. package/dist/mmtp-router.js.map +2 -2
  36. package/dist/multicast-auth-catalog.d.ts +10 -2
  37. package/dist/multicast-auth-catalog.d.ts.map +1 -1
  38. package/dist/multicast-auth-catalog.js +422 -22
  39. package/dist/multicast-auth-catalog.js.map +4 -4
  40. package/dist/multicast-manager.d.ts +78 -11
  41. package/dist/multicast-manager.d.ts.map +1 -1
  42. package/dist/multicast-manager.js +3663 -333
  43. package/dist/multicast-manager.js.map +4 -4
  44. package/dist/multicast-transport.d.ts +4 -1
  45. package/dist/multicast-transport.d.ts.map +1 -1
  46. package/dist/multicast-transport.js +20 -3
  47. package/dist/multicast-transport.js.map +2 -2
  48. package/dist/no-modules.d.ts +1 -2
  49. package/dist/no-modules.d.ts.map +1 -1
  50. package/dist/no-modules.js +2699 -1008
  51. package/dist/no-modules.js.map +4 -4
  52. package/dist/repair-fetch.d.ts +50 -10
  53. package/dist/repair-fetch.d.ts.map +1 -1
  54. package/dist/repair-fetch.js +62 -20
  55. package/dist/repair-fetch.js.map +2 -2
  56. package/dist/wasm-mmtp-container-parser.js +31 -1
  57. package/dist/wasm-mmtp-container-parser.js.map +2 -2
  58. package/package.json +7 -15
  59. package/src/cmcd-producer.ts +6 -1
  60. package/src/fec-manager.ts +147 -131
  61. package/src/held-layer-admission.ts +161 -0
  62. package/src/index.ts +6 -9
  63. package/src/mmtp-container-parser.ts +48 -0
  64. package/src/mmtp-fec-session.ts +76 -3
  65. package/src/mmtp-router.ts +637 -274
  66. package/src/multicast-auth-catalog.ts +81 -8
  67. package/src/multicast-manager.ts +274 -49
  68. package/src/multicast-transport.ts +6 -2
  69. package/src/no-modules.ts +1 -6
  70. package/src/repair-fetch.ts +154 -34
  71. package/test-vectors/catalog/negative/multicast-auth-padded-key.json +0 -1
  72. package/test-vectors/catalog/negative/multicast-auth-reused-track-format.json +0 -1
  73. package/test-vectors/catalog/negative/multicast-auth-root-dotted-key.json +0 -1
  74. package/test-vectors/catalog/negative/multicast-auth-trailing-pad-bits.json +0 -1
  75. package/test-vectors/repair-policy-v1.json +113 -0
  76. package/dist/manifest-auth-plan.d.ts +0 -31
  77. package/dist/manifest-auth-plan.d.ts.map +0 -1
  78. package/dist/manifest-auth-plan.js +0 -426
  79. package/dist/manifest-auth-plan.js.map +0 -7
  80. package/dist/source-auth.d.ts +0 -2
  81. package/dist/source-auth.d.ts.map +0 -1
  82. package/dist/source-auth.js +0 -3912
  83. package/dist/source-auth.js.map +0 -7
  84. package/src/manifest-auth-plan.ts +0 -55
  85. package/src/source-auth.ts +0 -1
  86. package/test-vectors/generate-source-auth-fixture.mjs +0 -114
  87. package/test-vectors/source-auth-v1.json +0 -61
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blockcast/mmt-container",
3
- "version": "0.4.1",
3
+ "version": "0.5.0-main.34c7b9c2cfa0",
4
4
  "description": "MMTP/LOC container parsing for MMT over MoQ and SSM",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -14,10 +14,6 @@
14
14
  "import": "./dist/browser-runtime.js",
15
15
  "types": "./dist/index.d.ts"
16
16
  },
17
- "./source-auth": {
18
- "import": "./dist/source-auth.js",
19
- "types": "./dist/source-auth.d.ts"
20
- },
21
17
  "./mfu": {
22
18
  "import": "./dist/mfu-reassembler.js",
23
19
  "types": "./dist/mfu-reassembler.d.ts"
@@ -102,10 +98,6 @@
102
98
  "import": "./dist/multicast-auth-catalog.js",
103
99
  "types": "./dist/multicast-auth-catalog.d.ts"
104
100
  },
105
- "./manifest-auth-plan": {
106
- "import": "./dist/manifest-auth-plan.js",
107
- "types": "./dist/manifest-auth-plan.d.ts"
108
- },
109
101
  "./catalog-validator": {
110
102
  "import": "./dist/catalog-validator.js",
111
103
  "types": "./dist/catalog-validator.d.ts"
@@ -149,7 +141,8 @@
149
141
  "./catalog.schema.json": "./schemas/catalog.schema.json",
150
142
  "./test-vectors/catalog-validation.json": "./test-vectors/catalog-validation.json",
151
143
  "./test-vectors/align.json": "./test-vectors/align.json",
152
- "./test-vectors/catalog-timing.json": "./test-vectors/catalog-timing.json"
144
+ "./test-vectors/catalog-timing.json": "./test-vectors/catalog-timing.json",
145
+ "./test-vectors/repair-policy-v1.json": "./test-vectors/repair-policy-v1.json"
153
146
  },
154
147
  "files": [
155
148
  "dist",
@@ -166,17 +159,16 @@
166
159
  "build": "tsc --emitDeclarationOnly && node build.mjs",
167
160
  "dev": "tsc --watch",
168
161
  "clean": "rm -rf dist",
169
- "bench:source-auth": "node bench/source-auth-wasm.mjs",
170
162
  "bench:amt-hot-path": "node --expose-gc --max-semi-space-size=64 bench/amt-hot-path.mjs",
171
163
  "test:catalog-validator-public-api": "node test-vectors/catalog-validator-public-api.mjs",
172
164
  "test": "vitest run",
173
165
  "test:watch": "vitest"
174
166
  },
175
167
  "dependencies": {
176
- "@blockcast/fec-worker": "^0.4.1",
177
- "@blockcast/mmt-base": "^0.4.1",
178
- "@blockcast/mmt-fec": "^0.4.1",
179
- "@blockcast/mmt-manifest-verify": "^0.4.1",
168
+ "@blockcast/fec-worker": "0.5.0-main.34c7b9c2cfa0",
169
+ "@blockcast/mmt-base": "0.5.0-main.34c7b9c2cfa0",
170
+ "@blockcast/mmt-fec": "0.5.0-main.34c7b9c2cfa0",
171
+ "@blockcast/mmt-manifest-verify": "0.5.0-main.34c7b9c2cfa0",
180
172
  "@noble/curves": "^1.9.7",
181
173
  "@noble/hashes": "^1.8.0",
182
174
  "ajv": "^8.17.1",
@@ -14,13 +14,18 @@
14
14
  */
15
15
 
16
16
  import type { FecTrackStats } from '@blockcast/fec-worker'
17
- import type { ManifestVerificationStats } from '@blockcast/mmt-manifest-verify'
18
17
  import type { FecFailClosedReason } from './fec-manager.js'
19
18
 
20
19
  // ---------------------------------------------------------------------------
21
20
  // Types
22
21
  // ---------------------------------------------------------------------------
23
22
 
23
+ /** Source-auth counters for CMCD: records verified, and drops per reason. */
24
+ export interface ManifestVerificationStats {
25
+ verified: number
26
+ failures: Record<string, number>
27
+ }
28
+
24
29
  /** Transport state snapshot, built by the player wiring layer from SocketPool. */
25
30
  export interface TransportSnapshot {
26
31
  transport: 'native' | 'amt' | 'moq'
@@ -46,6 +46,7 @@ import {
46
46
  type TrackKind,
47
47
  } from './block-policy.js'
48
48
 
49
+ import { HeldLayerAdmission, heldLayerAnchorHorizon, type FecFailClosedReason } from './held-layer-admission.js'
49
50
  import type { MmtpDeliveryProvenance } from './mfu-reassembler.js'
50
51
  import type {
51
52
  FecWorkerClient,
@@ -291,21 +292,7 @@ export interface FecRepairLayer {
291
292
  repairSymbols: number
292
293
  }
293
294
 
294
- /**
295
- * Why a source track's FEC failed closed at runtime (fec-01 6.3.9): a repair
296
- * packet stated an RSB_length below a held layer's total B_i on that layer's
297
- * own repair track (`below_own_track_total`), or at or after the anchor, the
298
- * earliest block in SS_Start serial order whose repair carried >= B_i
299
- * (`below_anchored_total`). Repair for the track is refused from then on.
300
- */
301
- export interface FecFailClosedReason {
302
- code: 'below_own_track_total' | 'below_anchored_total'
303
- layer: number
304
- total: number
305
- rsbLength: number
306
- ssStart: number
307
- anchor?: number
308
- }
295
+ export type { FecFailClosedReason } from './held-layer-admission.js'
309
296
 
310
297
  /** A {@link FecFailClosedReason} with the stream it closed, as reported to the fail-closed handler. */
311
298
  export interface FecFailClosedEvent extends FecFailClosedReason {
@@ -313,26 +300,6 @@ export interface FecFailClosedEvent extends FecFailClosedReason {
313
300
  catalogTrackName: string | undefined
314
301
  }
315
302
 
316
- interface HeldLayerState {
317
- layer: number
318
- repairTrackName: string
319
- total: number
320
- /** Earliest (serial order) block start whose repair carried >= total. */
321
- anchor?: number
322
- /** Latest (serial order) block start whose repair carried < total. */
323
- latestBelow?: number
324
- /** RSB_length that latestBelow's repair carried, for the fail-closed reason. */
325
- latestBelowRsb?: number
326
- /** Newest block start seen; anchor and latestBelow are retired behind it. */
327
- newest?: number
328
- /**
329
- * The anchor fell behind the horizon, so no block that can still arrive
330
- * precedes it: every later packet must carry >= total. Its serial is no
331
- * longer compared, so SS_Start wrapping past it cannot misorder it.
332
- */
333
- settled?: boolean
334
- }
335
-
336
303
  interface ResolvedFECTrackConfig {
337
304
  blockProfile?: FecBlockProfile
338
305
  algorithm?: 'raptorq' | 'reed-solomon'
@@ -544,7 +511,7 @@ export class FECManager {
544
511
  // symbols its repair group carries. Same lifecycle as #blockRepairRsIds.
545
512
  #blockRsbLengths = new Map<string, number>()
546
513
  /** fec-01 6.3.9 anchor state per stream key, one entry per held layer. */
547
- #heldLayerStates = new Map<string, HeldLayerState[]>()
514
+ #heldLayerStates = new Map<string, HeldLayerAdmission>()
548
515
  /** Streams whose FEC failed closed at runtime (fec-01 6.3.9). */
549
516
  #fecFailClosed = new Map<string, FecFailClosedReason>()
550
517
  #onFecFailClosed: ((event: FecFailClosedEvent) => void) | null = null
@@ -583,6 +550,9 @@ export class FECManager {
583
550
  // Receiver-local first-accepted-symbol time per block, bounded like
584
551
  // #blockPathCounts (see blockAnchorAtMs).
585
552
  #blockAnchorsMs = new Map<string, number>()
553
+ // The receipt time of the packet being fed, when it is not now: a datagram
554
+ // held on a miss and admitted later (withReceiptTime).
555
+ #receiptAtMs: number | undefined = undefined
586
556
  // Exact catalog identity observed on accepted symbols for each FEC block.
587
557
  // Conflicting identities collapse to null so recovery fails closed.
588
558
  #blockCatalogTrackNames = new Map<string, string | null>()
@@ -630,6 +600,14 @@ export class FECManager {
630
600
  // Worker recovery emits all K source symbols. Track recovery once per block
631
601
  // while routing only ESI slots that were absent from the direct source path.
632
602
  #workerRecoveredBlocks = new Set<string>()
603
+ // Worker-recovered blocks a recovered packet of which admission refused,
604
+ // until the decode's final symbol (7.2.7: the block is unrecovered).
605
+ #refusedRecoveries = new Set<string>()
606
+ // Worker-recovered blocks: the next ESI the decode's ordered emit owes. The
607
+ // Worker emits every ESI 0..SSB_length-1 in order and drops only a symbol it
608
+ // refuses (a malformed ssbg_mode1 symbol), so a skipped ESI is a dropped
609
+ // recovered packet and the decode recovered nothing.
610
+ #workerRecoveredNextEsi = new Map<string, number>()
633
611
 
634
612
  // How the multicast leg delivers symbols (see MulticastSymbolDelivery). The
635
613
  // provider declares it per transport join; absence keeps datagram semantics.
@@ -833,7 +811,14 @@ export class FECManager {
833
811
  | null = null
834
812
 
835
813
  // Worker-path callbacks — async recovery and stats from FecWorkerClient.
836
- onBlockRecovered: ((sbn: number, packetId: number, data: ArrayBuffer, meta: FrameMeta, catalogTrackName?: string) => void) | null = null
814
+ /**
815
+ * A recovered source symbol. Return false when admission refused the
816
+ * packet it carries (bc-provenance v2, draft-ramadan-moq-multicast-01
817
+ * 7.2.7): the block is then unrecovered and stays open to its FEC deadline.
818
+ */
819
+ onBlockRecovered:
820
+ | ((sbn: number, packetId: number, data: ArrayBuffer, meta: FrameMeta, catalogTrackName?: string) => boolean | void)
821
+ | null = null
837
822
  onFecStatsUpdate: ((packetId: number, stats: FecTrackStats) => void) | null = null
838
823
 
839
824
  #encodeTrackName(catalogTrackName: string): string {
@@ -978,6 +963,8 @@ export class FECManager {
978
963
  this.#completedBlocks.delete(record.key)
979
964
  this.#cleanlyCompletedBlocks.delete(record.key)
980
965
  this.#workerRecoveredBlocks.delete(record.key)
966
+ this.#refusedRecoveries.delete(record.key)
967
+ this.#workerRecoveredNextEsi.delete(record.key)
981
968
  // The policy's terminal ledgers describe the same terminal state and
982
969
  // retire with it: a failed block must never outlive its failure record
983
970
  // as 'complete' (that would hide a loss from the release gate).
@@ -1444,7 +1431,7 @@ export class FECManager {
1444
1431
  catalogTrackName: string | undefined,
1445
1432
  delayMs: number,
1446
1433
  ): void {
1447
- const deadlineMs = performance.now() + delayMs
1434
+ const deadlineMs = this.#notEarlierUnderReceipt(this.#receiptNow() + delayMs, this.#repairDeadlineAtMs.get(key))
1448
1435
  this.#repairDeadlineAtMs.set(key, deadlineMs)
1449
1436
  const armed = this.#repairDeadlines.get(key)
1450
1437
  if (armed?.handle != null) {
@@ -1464,7 +1451,7 @@ export class FECManager {
1464
1451
  timer.handle = null
1465
1452
  this.#requestRepair(sbn, packetId, catalogTrackName)
1466
1453
  }
1467
- timer.handle = setTimeout(onFire, delayMs)
1454
+ timer.handle = setTimeout(onFire, Math.max(0, deadlineMs - performance.now()))
1468
1455
  this.#repairDeadlines.set(key, timer)
1469
1456
  }
1470
1457
 
@@ -1668,6 +1655,12 @@ export class FECManager {
1668
1655
  ssId = sbn,
1669
1656
  ): void {
1670
1657
  const streamKey = this.#streamKey(packetId, catalogTrackName)
1658
+ // Arrival at FEC, not receipt (withReceiptTime): this bounds how far SS_ID
1659
+ // can plausibly move between symbols this instance has seen, and a held
1660
+ // datagram admitted late reached it no earlier than now. A block's ts is
1661
+ // its anchor (#blockAnchorsMs), which does take the receipt time.
1662
+ // Receipt times are also not monotonic: a datagram held from t0 and
1663
+ // admitted after one received at t1 > t0 would move the frontier back.
1671
1664
  const now = performance.now()
1672
1665
  const previous = (symbol === 'source' ? this.#sourceFrontiers : this.#highestSbn).get(streamKey)
1673
1666
  const anchor = this.#sourceAnchors.get(streamKey)
@@ -2017,7 +2010,10 @@ export class FECManager {
2017
2010
  delayMs: number,
2018
2011
  ): void {
2019
2012
  const key = this.#blockKey(sbn, packetId, catalogTrackName)
2020
- const waitAtMs = performance.now() + Math.max(1, Math.ceil(delayMs))
2013
+ const waitAtMs = this.#notEarlierUnderReceipt(
2014
+ this.#receiptNow() + Math.max(1, Math.ceil(delayMs)),
2015
+ this.#blockWaitDeadlinesMs.get(key),
2016
+ )
2021
2017
  // A held block's symbols (its own FETCH's repair symbols included) never
2022
2018
  // carry its wait past the hold: the hold is its presentation deadline.
2023
2019
  const holdAtMs = this.#repairHoldUntilMs.get(key)
@@ -2534,6 +2530,8 @@ export class FECManager {
2534
2530
  resetSet(this.#cleanlyCompletedBlocks)
2535
2531
  resetSet(this.#repairRequestedBlocks)
2536
2532
  resetSet(this.#workerRecoveredBlocks)
2533
+ resetSet(this.#refusedRecoveries)
2534
+ resetMap(this.#workerRecoveredNextEsi)
2537
2535
  resetSet(this.#startupAlignedPackets)
2538
2536
  resetSet(this.#startupPartialBlocks)
2539
2537
  resetSet(this.#startupRepairReadyPackets)
@@ -2668,6 +2666,8 @@ export class FECManager {
2668
2666
  dropSet(this.#cleanlyCompletedBlocks)
2669
2667
  dropSet(this.#repairRequestedBlocks)
2670
2668
  dropSet(this.#workerRecoveredBlocks)
2669
+ dropSet(this.#refusedRecoveries)
2670
+ dropMap(this.#workerRecoveredNextEsi)
2671
2671
  dropSet(this.#startupAlignedPackets)
2672
2672
  dropSet(this.#startupPartialBlocks)
2673
2673
  dropSet(this.#startupRepairReadyPackets)
@@ -2832,7 +2832,10 @@ export class FECManager {
2832
2832
  // The block's absolute anchor (fecBlockDeadlineMs): the receiver-local
2833
2833
  // time of its first accepted symbol, from which its presentation deadline
2834
2834
  // is measured.
2835
- if (!this.#blockAnchorsMs.has(blockKey)) this.#blockAnchorsMs.set(blockKey, performance.now())
2835
+ // A datagram held and admitted later keeps its receipt time (fec-01 9).
2836
+ const receivedAtMs = this.#receiptNow()
2837
+ const anchorMs = this.#blockAnchorsMs.get(blockKey)
2838
+ if (anchorMs === undefined || receivedAtMs < anchorMs) this.#blockAnchorsMs.set(blockKey, receivedAtMs)
2836
2839
  if (source === 'multicast') counts.multicastSymbols++
2837
2840
  else counts.moqSymbols++
2838
2841
  this.#blockPathCounts.set(blockKey, counts)
@@ -3023,6 +3026,18 @@ export class FECManager {
3023
3026
  blockAnchorAtMs(sbn: number, packetId: number, catalogTrackName?: string): number | undefined {
3024
3027
  return this.#blockAnchorsMs.get(this.#blockKey(sbn, packetId, catalogTrackName))
3025
3028
  }
3029
+ /**
3030
+ * How long ago (ms) the oldest open block of the stream, one with an FEC
3031
+ * deadline, took its first accepted symbol; undefined with none open.
3032
+ */
3033
+ oldestOpenBlockAgeMs(packetId: number, catalogTrackName: string): number | undefined {
3034
+ let oldest = Infinity
3035
+ for (const key of this.#activeBlocks.keys()) {
3036
+ if (!this.#belongsToStream(key, packetId, catalogTrackName)) continue
3037
+ oldest = Math.min(oldest, this.#blockAnchorsMs.get(key) ?? Infinity)
3038
+ }
3039
+ return oldest === Infinity ? undefined : performance.now() - oldest
3040
+ }
3026
3041
  /** Absolute fail deadline of a still-open block, or undefined. */
3027
3042
  blockDeadlineAtMs(sbn: number, packetId: number, catalogTrackName?: string): number | undefined {
3028
3043
  return this.#activeBlockDeadlinesMs.get(this.#blockKey(sbn, packetId, catalogTrackName))
@@ -3686,23 +3701,22 @@ export class FECManager {
3686
3701
  if (!this.#claimDecoderBlock(client, meta.sbn, recoveredCatalogTrackName)) return
3687
3702
  const blockKey = this.#blockKey(meta.sbn, packetId, recoveredCatalogTrackName)
3688
3703
  const cleanlyCompleted = this.#cleanlyCompletedBlocks.has(blockKey)
3704
+ if (esi !== (this.#workerRecoveredNextEsi.get(blockKey) ?? 0)) {
3705
+ this.#refusedRecoveries.add(blockKey)
3706
+ if (this.#refusedRecoveries.size > 1000) dropOldest(this.#refusedRecoveries, 500)
3707
+ }
3708
+ if (esi >= meta.ssbLength! - 1) {
3709
+ // Safe to forget the block here: the Worker never re-emits a
3710
+ // terminal block (packages/fec-worker/src/fec-worker.ts guards
3711
+ // source and repair feeds with isTerminalBlock).
3712
+ this.#workerRecoveredNextEsi.delete(blockKey)
3713
+ } else {
3714
+ this.#workerRecoveredNextEsi.set(blockKey, esi + 1)
3715
+ // Eviction fails closed: an evicted mid-block entry reads 0 and
3716
+ // refuses the rest of that decode rather than admitting a gap.
3717
+ if (this.#workerRecoveredNextEsi.size > 1000) dropOldest(this.#workerRecoveredNextEsi, 500)
3718
+ }
3689
3719
  if (!cleanlyCompleted) {
3690
- if (!this.#workerRecoveredBlocks.has(blockKey)) {
3691
- this.#traceLifecycle(
3692
- 'recovered',
3693
- meta.sbn,
3694
- packetId,
3695
- recoveredCatalogTrackName,
3696
- )
3697
- this.#workerRecoveredBlocks.add(blockKey)
3698
- this.#fecStats.blocksRecovered++
3699
- this.#statsForPacket(packetId).blocksRecovered++
3700
- if (this.#workerRecoveredBlocks.size > 1000) {
3701
- dropOldest(this.#workerRecoveredBlocks, 500)
3702
- }
3703
- this.#noteBlockRepaired(blockKey, meta.sbn, packetId, recoveredCatalogTrackName)
3704
- }
3705
-
3706
3720
  // RaptorQ returns the full K-symbol block, not only erased symbols.
3707
3721
  // The direct path already parsed every source ESI in #seenSymbols;
3708
3722
  // routing those symbols again injects duplicate MFU fragments and can
@@ -3710,23 +3724,46 @@ export class FECManager {
3710
3724
  const symbolKey = `${blockKey}:${esi}`
3711
3725
  if (!this.#seenSymbols.has(symbolKey)) {
3712
3726
  this.#seenSymbols.add(symbolKey)
3713
- this.onBlockRecovered?.(
3727
+ const admitted = this.onBlockRecovered?.(
3714
3728
  meta.sbn,
3715
3729
  packetId,
3716
3730
  data,
3717
3731
  meta,
3718
3732
  recoveredCatalogTrackName,
3719
3733
  )
3734
+ if (admitted === false) {
3735
+ this.#refusedRecoveries.add(blockKey)
3736
+ if (this.#refusedRecoveries.size > 1000) dropOldest(this.#refusedRecoveries, 500)
3737
+ }
3720
3738
  }
3721
3739
  }
3722
3740
 
3723
3741
  // The worker emits recovered source symbols in ESI order. Complete the
3724
3742
  // block only after its final authored slot so assemblers receive every
3725
3743
  // missing symbol before their completion callback flushes buffered media.
3744
+ // A decode one of whose recovered packets admission refused recovered
3745
+ // nothing (7.2.7): the block stays open and fails at its FEC deadline.
3746
+ const refused = esi >= meta.ssbLength! - 1 && this.#refusedRecoveries.delete(blockKey)
3726
3747
  if (
3727
3748
  esi >= meta.ssbLength! - 1 &&
3749
+ !refused &&
3728
3750
  !this.#completedBlocks.has(blockKey)
3729
3751
  ) {
3752
+ if (!cleanlyCompleted && !this.#workerRecoveredBlocks.has(blockKey)) {
3753
+ this.#traceLifecycle(
3754
+ 'recovered',
3755
+ meta.sbn,
3756
+ packetId,
3757
+ recoveredCatalogTrackName,
3758
+ )
3759
+ this.#workerRecoveredBlocks.add(blockKey)
3760
+ this.#fecStats.blocksRecovered++
3761
+ this.#statsForPacket(packetId).blocksRecovered++
3762
+ if (this.#workerRecoveredBlocks.size > 1000) {
3763
+ dropOldest(this.#workerRecoveredBlocks, 500)
3764
+ }
3765
+ this.#noteBlockRepaired(blockKey, meta.sbn, packetId, recoveredCatalogTrackName)
3766
+ }
3730
3767
  this.cancelBlockTimeout(meta.sbn, packetId, recoveredCatalogTrackName)
3731
3768
  if (recoveredCatalogTrackName === undefined) {
3732
3769
  this.onBlockComplete?.(meta.sbn, packetId)
@@ -4106,77 +4143,26 @@ export class FECManager {
4106
4143
  const config = this.#trackConfigs.get(packetId)
4107
4144
  const layers = config?.repairLayers
4108
4145
  if (layers === undefined || layers.length === 0) return true
4109
- if (repairTrackName !== undefined && !layers.some((l) => l.repairTrackName === repairTrackName)) {
4110
- return false
4146
+ let admission = this.#heldLayerStates.get(streamKey)
4147
+ if (admission === undefined) {
4148
+ admission = new HeldLayerAdmission(layers, config!.heldLayers)
4149
+ this.#heldLayerStates.set(streamKey, admission)
4111
4150
  }
4112
- let states = this.#heldLayerStates.get(streamKey)
4113
- if (states === undefined) {
4114
- const held = new Set(config!.heldLayers ?? [0])
4115
- states = layers
4116
- .filter((l) => held.has(l.layer))
4117
- .map((l) => ({ layer: l.layer, repairTrackName: l.repairTrackName, total: l.rsIdOffset + l.repairSymbols }))
4118
- this.#heldLayerStates.set(streamKey, states)
4119
- }
4120
- const horizon = this.#layerAnchorHorizon(packetId)
4121
- // A repair may run ahead of the media, but one more than `horizon` past the
4122
- // source frontier must not move the held-layer state: advancing `newest` on
4123
- // it would retire every real anchor and skip this check until traffic caught
4124
- // up. Such a repair is still judged below the total: it is on its own track,
4125
- // or after any anchor by definition. With no anchor yet it says nothing.
4126
- // It is still admitted: in iso the frontier only moves when a repair places
4127
- // held sources, so dropping it would stall recovery after a long outage.
4128
- // IsoBlockLocator.placeRepair may still refuse the same repair as `ahead`:
4129
- // it measures from `newest`, which held sources move, not from this frontier.
4130
- const frontier = this.#sourceFrontiers.get(streamKey)?.sbn
4131
- const farAhead =
4132
- frontier !== undefined && serial32Precedes(frontier, ssStart) && serial32Distance(frontier, ssStart) > horizon
4133
- for (const state of states) {
4134
- let code: FecFailClosedReason['code'] | undefined
4135
- if (farAhead) {
4136
- if (rsbLength < state.total) {
4137
- if (state.layer > 0 && repairTrackName === state.repairTrackName) code = 'below_own_track_total'
4138
- else if (state.anchor !== undefined || state.settled) code = 'below_anchored_total'
4139
- }
4140
- } else {
4141
- // Serials more than `horizon` behind the newest block are retired, so
4142
- // every serial still compared sits inside one half-window and SS_Start
4143
- // wrapping on a long-running stream cannot reorder them.
4144
- if (state.newest === undefined || serial32Precedes(state.newest, ssStart)) state.newest = ssStart
4145
- const stale = (s: number) => serial32Distance(s, state.newest!) > horizon
4146
- if (state.latestBelow !== undefined && stale(state.latestBelow)) {
4147
- delete state.latestBelow
4148
- delete state.latestBelowRsb
4149
- }
4150
- if (state.anchor !== undefined && stale(state.anchor)) state.settled = true
4151
- if (rsbLength < state.total) {
4152
- if (state.layer > 0 && repairTrackName === state.repairTrackName) code = 'below_own_track_total'
4153
- else if (stale(ssStart)) continue // past retention: undecodable, anchors nothing
4154
- else if (state.settled || (state.anchor !== undefined && !serial32Precedes(ssStart, state.anchor))) code = 'below_anchored_total'
4155
- else if (state.latestBelow === undefined || serial32Precedes(state.latestBelow, ssStart)) {
4156
- state.latestBelow = ssStart
4157
- state.latestBelowRsb = rsbLength
4158
- }
4159
- } else if (!state.settled && !stale(ssStart)) {
4160
- if (state.anchor === undefined || serial32Precedes(ssStart, state.anchor)) state.anchor = ssStart
4161
- if (state.latestBelow !== undefined && !serial32Precedes(state.latestBelow, state.anchor)) {
4162
- code = 'below_anchored_total'
4163
- ssStart = state.latestBelow
4164
- rsbLength = state.latestBelowRsb!
4165
- }
4166
- }
4167
- }
4168
- if (code !== undefined) {
4169
- const reason: FecFailClosedReason = {
4170
- code, layer: state.layer, total: state.total, rsbLength, ssStart,
4171
- ...(state.anchor === undefined ? {} : { anchor: state.anchor }),
4172
- }
4173
- this.#fecFailClosed.set(streamKey, reason)
4174
- this.#onFecFailClosed?.({ ...reason, packetId, catalogTrackName })
4175
- console.warn(
4176
- `[FECManager] FEC failed closed (fec-01 6.3.9) packetId=${packetId} track=${catalogTrackName ?? '-'}: ${JSON.stringify(reason)}`,
4177
- )
4178
- return false
4179
- }
4151
+ const verdict = admission.admit(
4152
+ repairTrackName,
4153
+ ssStart,
4154
+ rsbLength,
4155
+ this.#layerAnchorHorizon(packetId),
4156
+ this.#sourceFrontiers.get(streamKey)?.sbn,
4157
+ )
4158
+ if (verdict === 'discard') return false
4159
+ if (verdict !== undefined) {
4160
+ this.#fecFailClosed.set(streamKey, verdict)
4161
+ this.#onFecFailClosed?.({ ...verdict, packetId, catalogTrackName })
4162
+ console.warn(
4163
+ `[FECManager] FEC failed closed (fec-01 6.3.9) packetId=${packetId} track=${catalogTrackName ?? '-'}: ${JSON.stringify(verdict)}`,
4164
+ )
4165
+ return false
4180
4166
  }
4181
4167
  return true
4182
4168
  }
@@ -4187,11 +4173,9 @@ export class FECManager {
4187
4173
  * times K, capped at 2^30 so compared serials stay inside one half-window.
4188
4174
  */
4189
4175
  #layerAnchorHorizon(packetId: number): number {
4190
- const cap = 0x40000000
4191
4176
  let blocks: number | undefined
4192
4177
  try { blocks = this.isoRetentionBlocksForPacket(packetId) } catch { blocks = undefined }
4193
- const k = this.#trackConfigs.get(packetId)?.k ?? 0
4194
- return blocks === undefined || k <= 0 ? cap : Math.min(cap, blocks * k)
4178
+ return heldLayerAnchorHorizon(blocks, this.#trackConfigs.get(packetId)?.k ?? 0)
4195
4179
  }
4196
4180
 
4197
4181
  /**
@@ -4542,6 +4526,36 @@ export class FECManager {
4542
4526
  queueMicrotask(() => this.#syncDecoderBlockOwners.delete(key))
4543
4527
  }
4544
4528
 
4529
+ /**
4530
+ * Run `fn` with `receivedAtMs` (performance.now() clock) as the receipt
4531
+ * time of what it feeds. Within a consumer the bc-provenance profile
4532
+ * governs, a datagram held on a miss and admitted later keeps its receipt
4533
+ * time (fec-01 9): it anchors its block's ts and arms the block's
4534
+ * deadlines from then, never later than they are already armed.
4535
+ */
4536
+ withReceiptTime(receivedAtMs: number, fn: () => void): void {
4537
+ const outer = this.#receiptAtMs
4538
+ this.#receiptAtMs = receivedAtMs
4539
+ try {
4540
+ fn()
4541
+ } finally {
4542
+ this.#receiptAtMs = outer
4543
+ }
4544
+ }
4545
+
4546
+ #receiptNow(): number {
4547
+ return this.#receiptAtMs ?? performance.now()
4548
+ }
4549
+
4550
+ /**
4551
+ * A held packet admitted late arms from its (earlier) receipt time; that
4552
+ * never pulls in a deadline later activity already armed. Outside a
4553
+ * receipt scope a re-arm replaces the deadline, as a narrower bound may.
4554
+ */
4555
+ #notEarlierUnderReceipt(deadlineMs: number, armedMs: number | undefined): number {
4556
+ return this.#receiptAtMs === undefined || armedMs === undefined ? deadlineMs : Math.max(deadlineMs, armedMs)
4557
+ }
4558
+
4545
4559
  /** Refresh deadlines after accepted block activity. */
4546
4560
  noteBlockActivity(
4547
4561
  sbn: number,
@@ -4778,6 +4792,8 @@ export class FECManager {
4778
4792
  this.#cleanlyCompletedBlocks.clear()
4779
4793
  this.#repairRequestedBlocks.clear()
4780
4794
  this.#workerRecoveredBlocks.clear()
4795
+ this.#refusedRecoveries.clear()
4796
+ this.#workerRecoveredNextEsi.clear()
4781
4797
  this.#syncDecoderBlockOwners.clear()
4782
4798
  this.#startupAlignedPackets.clear()
4783
4799
  this.#startupPartialBlocks.clear()