@blockcast/mmt-container 0.4.1 → 0.5.0-main.2c1f81817995

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 (72) hide show
  1. package/README.md +2 -2
  2. package/dist/browser-runtime.js +3458 -356
  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 +177 -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.map +1 -1
  18. package/dist/index.js.map +2 -2
  19. package/dist/mmtp-container-parser.no-modules.js +2682 -1023
  20. package/dist/mmtp-container-parser.no-modules.js.map +4 -4
  21. package/dist/mmtp-fec-session.d.ts +36 -3
  22. package/dist/mmtp-fec-session.d.ts.map +1 -1
  23. package/dist/mmtp-fec-session.js +114 -5
  24. package/dist/mmtp-fec-session.js.map +3 -3
  25. package/dist/mmtp-router.d.ts +72 -28
  26. package/dist/mmtp-router.d.ts.map +1 -1
  27. package/dist/mmtp-router.js +321 -203
  28. package/dist/mmtp-router.js.map +2 -2
  29. package/dist/multicast-auth-catalog.d.ts +10 -2
  30. package/dist/multicast-auth-catalog.d.ts.map +1 -1
  31. package/dist/multicast-auth-catalog.js +422 -22
  32. package/dist/multicast-auth-catalog.js.map +4 -4
  33. package/dist/multicast-manager.d.ts +48 -9
  34. package/dist/multicast-manager.d.ts.map +1 -1
  35. package/dist/multicast-manager.js +3406 -340
  36. package/dist/multicast-manager.js.map +4 -4
  37. package/dist/multicast-transport.d.ts +4 -1
  38. package/dist/multicast-transport.d.ts.map +1 -1
  39. package/dist/multicast-transport.js +20 -3
  40. package/dist/multicast-transport.js.map +2 -2
  41. package/dist/no-modules.d.ts +1 -2
  42. package/dist/no-modules.d.ts.map +1 -1
  43. package/dist/no-modules.js +2668 -1007
  44. package/dist/no-modules.js.map +4 -4
  45. package/dist/repair-fetch.d.ts +10 -1
  46. package/dist/repair-fetch.d.ts.map +1 -1
  47. package/dist/repair-fetch.js +5 -2
  48. package/dist/repair-fetch.js.map +2 -2
  49. package/package.json +5 -14
  50. package/src/cmcd-producer.ts +6 -1
  51. package/src/fec-manager.ts +121 -131
  52. package/src/held-layer-admission.ts +161 -0
  53. package/src/index.ts +5 -9
  54. package/src/mmtp-fec-session.ts +76 -3
  55. package/src/mmtp-router.ts +390 -272
  56. package/src/multicast-auth-catalog.ts +81 -8
  57. package/src/multicast-manager.ts +189 -48
  58. package/src/multicast-transport.ts +6 -2
  59. package/src/no-modules.ts +1 -6
  60. package/src/repair-fetch.ts +24 -4
  61. package/dist/manifest-auth-plan.d.ts +0 -31
  62. package/dist/manifest-auth-plan.d.ts.map +0 -1
  63. package/dist/manifest-auth-plan.js +0 -426
  64. package/dist/manifest-auth-plan.js.map +0 -7
  65. package/dist/source-auth.d.ts +0 -2
  66. package/dist/source-auth.d.ts.map +0 -1
  67. package/dist/source-auth.js +0 -3912
  68. package/dist/source-auth.js.map +0 -7
  69. package/src/manifest-auth-plan.ts +0 -55
  70. package/src/source-auth.ts +0 -1
  71. package/test-vectors/generate-source-auth-fixture.mjs +0 -114
  72. 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.2c1f81817995",
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"
@@ -166,17 +158,16 @@
166
158
  "build": "tsc --emitDeclarationOnly && node build.mjs",
167
159
  "dev": "tsc --watch",
168
160
  "clean": "rm -rf dist",
169
- "bench:source-auth": "node bench/source-auth-wasm.mjs",
170
161
  "bench:amt-hot-path": "node --expose-gc --max-semi-space-size=64 bench/amt-hot-path.mjs",
171
162
  "test:catalog-validator-public-api": "node test-vectors/catalog-validator-public-api.mjs",
172
163
  "test": "vitest run",
173
164
  "test:watch": "vitest"
174
165
  },
175
166
  "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",
167
+ "@blockcast/fec-worker": "0.5.0-main.2c1f81817995",
168
+ "@blockcast/mmt-base": "0.5.0-main.2c1f81817995",
169
+ "@blockcast/mmt-fec": "0.5.0-main.2c1f81817995",
170
+ "@blockcast/mmt-manifest-verify": "0.5.0-main.2c1f81817995",
180
171
  "@noble/curves": "^1.9.7",
181
172
  "@noble/hashes": "^1.8.0",
182
173
  "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,9 @@ 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>()
633
606
 
634
607
  // How the multicast leg delivers symbols (see MulticastSymbolDelivery). The
635
608
  // provider declares it per transport join; absence keeps datagram semantics.
@@ -833,7 +806,14 @@ export class FECManager {
833
806
  | null = null
834
807
 
835
808
  // Worker-path callbacks — async recovery and stats from FecWorkerClient.
836
- onBlockRecovered: ((sbn: number, packetId: number, data: ArrayBuffer, meta: FrameMeta, catalogTrackName?: string) => void) | null = null
809
+ /**
810
+ * A recovered source symbol. Return false when admission refused the
811
+ * packet it carries (bc-provenance v2, draft-ramadan-moq-multicast-01
812
+ * 7.2.7): the block is then unrecovered and stays open to its FEC deadline.
813
+ */
814
+ onBlockRecovered:
815
+ | ((sbn: number, packetId: number, data: ArrayBuffer, meta: FrameMeta, catalogTrackName?: string) => boolean | void)
816
+ | null = null
837
817
  onFecStatsUpdate: ((packetId: number, stats: FecTrackStats) => void) | null = null
838
818
 
839
819
  #encodeTrackName(catalogTrackName: string): string {
@@ -978,6 +958,7 @@ export class FECManager {
978
958
  this.#completedBlocks.delete(record.key)
979
959
  this.#cleanlyCompletedBlocks.delete(record.key)
980
960
  this.#workerRecoveredBlocks.delete(record.key)
961
+ this.#refusedRecoveries.delete(record.key)
981
962
  // The policy's terminal ledgers describe the same terminal state and
982
963
  // retire with it: a failed block must never outlive its failure record
983
964
  // as 'complete' (that would hide a loss from the release gate).
@@ -1444,7 +1425,7 @@ export class FECManager {
1444
1425
  catalogTrackName: string | undefined,
1445
1426
  delayMs: number,
1446
1427
  ): void {
1447
- const deadlineMs = performance.now() + delayMs
1428
+ const deadlineMs = this.#notEarlierUnderReceipt(this.#receiptNow() + delayMs, this.#repairDeadlineAtMs.get(key))
1448
1429
  this.#repairDeadlineAtMs.set(key, deadlineMs)
1449
1430
  const armed = this.#repairDeadlines.get(key)
1450
1431
  if (armed?.handle != null) {
@@ -1464,7 +1445,7 @@ export class FECManager {
1464
1445
  timer.handle = null
1465
1446
  this.#requestRepair(sbn, packetId, catalogTrackName)
1466
1447
  }
1467
- timer.handle = setTimeout(onFire, delayMs)
1448
+ timer.handle = setTimeout(onFire, Math.max(0, deadlineMs - performance.now()))
1468
1449
  this.#repairDeadlines.set(key, timer)
1469
1450
  }
1470
1451
 
@@ -1668,6 +1649,10 @@ export class FECManager {
1668
1649
  ssId = sbn,
1669
1650
  ): void {
1670
1651
  const streamKey = this.#streamKey(packetId, catalogTrackName)
1652
+ // Arrival at FEC, not receipt (withReceiptTime): this bounds how far SS_ID
1653
+ // can plausibly move between symbols this instance has seen, and a held
1654
+ // datagram admitted late reached it no earlier than now. A block's ts is
1655
+ // its anchor (#blockAnchorsMs), which does take the receipt time.
1671
1656
  const now = performance.now()
1672
1657
  const previous = (symbol === 'source' ? this.#sourceFrontiers : this.#highestSbn).get(streamKey)
1673
1658
  const anchor = this.#sourceAnchors.get(streamKey)
@@ -2017,7 +2002,10 @@ export class FECManager {
2017
2002
  delayMs: number,
2018
2003
  ): void {
2019
2004
  const key = this.#blockKey(sbn, packetId, catalogTrackName)
2020
- const waitAtMs = performance.now() + Math.max(1, Math.ceil(delayMs))
2005
+ const waitAtMs = this.#notEarlierUnderReceipt(
2006
+ this.#receiptNow() + Math.max(1, Math.ceil(delayMs)),
2007
+ this.#blockWaitDeadlinesMs.get(key),
2008
+ )
2021
2009
  // A held block's symbols (its own FETCH's repair symbols included) never
2022
2010
  // carry its wait past the hold: the hold is its presentation deadline.
2023
2011
  const holdAtMs = this.#repairHoldUntilMs.get(key)
@@ -2534,6 +2522,7 @@ export class FECManager {
2534
2522
  resetSet(this.#cleanlyCompletedBlocks)
2535
2523
  resetSet(this.#repairRequestedBlocks)
2536
2524
  resetSet(this.#workerRecoveredBlocks)
2525
+ resetSet(this.#refusedRecoveries)
2537
2526
  resetSet(this.#startupAlignedPackets)
2538
2527
  resetSet(this.#startupPartialBlocks)
2539
2528
  resetSet(this.#startupRepairReadyPackets)
@@ -2668,6 +2657,7 @@ export class FECManager {
2668
2657
  dropSet(this.#cleanlyCompletedBlocks)
2669
2658
  dropSet(this.#repairRequestedBlocks)
2670
2659
  dropSet(this.#workerRecoveredBlocks)
2660
+ dropSet(this.#refusedRecoveries)
2671
2661
  dropSet(this.#startupAlignedPackets)
2672
2662
  dropSet(this.#startupPartialBlocks)
2673
2663
  dropSet(this.#startupRepairReadyPackets)
@@ -2832,7 +2822,10 @@ export class FECManager {
2832
2822
  // The block's absolute anchor (fecBlockDeadlineMs): the receiver-local
2833
2823
  // time of its first accepted symbol, from which its presentation deadline
2834
2824
  // is measured.
2835
- if (!this.#blockAnchorsMs.has(blockKey)) this.#blockAnchorsMs.set(blockKey, performance.now())
2825
+ // A datagram held and admitted later keeps its receipt time (fec-01 9).
2826
+ const receivedAtMs = this.#receiptNow()
2827
+ const anchorMs = this.#blockAnchorsMs.get(blockKey)
2828
+ if (anchorMs === undefined || receivedAtMs < anchorMs) this.#blockAnchorsMs.set(blockKey, receivedAtMs)
2836
2829
  if (source === 'multicast') counts.multicastSymbols++
2837
2830
  else counts.moqSymbols++
2838
2831
  this.#blockPathCounts.set(blockKey, counts)
@@ -3023,6 +3016,18 @@ export class FECManager {
3023
3016
  blockAnchorAtMs(sbn: number, packetId: number, catalogTrackName?: string): number | undefined {
3024
3017
  return this.#blockAnchorsMs.get(this.#blockKey(sbn, packetId, catalogTrackName))
3025
3018
  }
3019
+ /**
3020
+ * How long ago (ms) the oldest open block of the stream, one with an FEC
3021
+ * deadline, took its first accepted symbol; undefined with none open.
3022
+ */
3023
+ oldestOpenBlockAgeMs(packetId: number, catalogTrackName: string): number | undefined {
3024
+ let oldest = Infinity
3025
+ for (const key of this.#activeBlocks.keys()) {
3026
+ if (!this.#belongsToStream(key, packetId, catalogTrackName)) continue
3027
+ oldest = Math.min(oldest, this.#blockAnchorsMs.get(key) ?? Infinity)
3028
+ }
3029
+ return oldest === Infinity ? undefined : performance.now() - oldest
3030
+ }
3026
3031
  /** Absolute fail deadline of a still-open block, or undefined. */
3027
3032
  blockDeadlineAtMs(sbn: number, packetId: number, catalogTrackName?: string): number | undefined {
3028
3033
  return this.#activeBlockDeadlinesMs.get(this.#blockKey(sbn, packetId, catalogTrackName))
@@ -3687,22 +3692,6 @@ export class FECManager {
3687
3692
  const blockKey = this.#blockKey(meta.sbn, packetId, recoveredCatalogTrackName)
3688
3693
  const cleanlyCompleted = this.#cleanlyCompletedBlocks.has(blockKey)
3689
3694
  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
3695
  // RaptorQ returns the full K-symbol block, not only erased symbols.
3707
3696
  // The direct path already parsed every source ESI in #seenSymbols;
3708
3697
  // routing those symbols again injects duplicate MFU fragments and can
@@ -3710,23 +3699,46 @@ export class FECManager {
3710
3699
  const symbolKey = `${blockKey}:${esi}`
3711
3700
  if (!this.#seenSymbols.has(symbolKey)) {
3712
3701
  this.#seenSymbols.add(symbolKey)
3713
- this.onBlockRecovered?.(
3702
+ const admitted = this.onBlockRecovered?.(
3714
3703
  meta.sbn,
3715
3704
  packetId,
3716
3705
  data,
3717
3706
  meta,
3718
3707
  recoveredCatalogTrackName,
3719
3708
  )
3709
+ if (admitted === false) {
3710
+ this.#refusedRecoveries.add(blockKey)
3711
+ if (this.#refusedRecoveries.size > 1000) dropOldest(this.#refusedRecoveries, 500)
3712
+ }
3720
3713
  }
3721
3714
  }
3722
3715
 
3723
3716
  // The worker emits recovered source symbols in ESI order. Complete the
3724
3717
  // block only after its final authored slot so assemblers receive every
3725
3718
  // missing symbol before their completion callback flushes buffered media.
3719
+ // A decode one of whose recovered packets admission refused recovered
3720
+ // nothing (7.2.7): the block stays open and fails at its FEC deadline.
3721
+ const refused = esi >= meta.ssbLength! - 1 && this.#refusedRecoveries.delete(blockKey)
3726
3722
  if (
3727
3723
  esi >= meta.ssbLength! - 1 &&
3724
+ !refused &&
3728
3725
  !this.#completedBlocks.has(blockKey)
3729
3726
  ) {
3727
+ if (!cleanlyCompleted && !this.#workerRecoveredBlocks.has(blockKey)) {
3728
+ this.#traceLifecycle(
3729
+ 'recovered',
3730
+ meta.sbn,
3731
+ packetId,
3732
+ recoveredCatalogTrackName,
3733
+ )
3734
+ this.#workerRecoveredBlocks.add(blockKey)
3735
+ this.#fecStats.blocksRecovered++
3736
+ this.#statsForPacket(packetId).blocksRecovered++
3737
+ if (this.#workerRecoveredBlocks.size > 1000) {
3738
+ dropOldest(this.#workerRecoveredBlocks, 500)
3739
+ }
3740
+ this.#noteBlockRepaired(blockKey, meta.sbn, packetId, recoveredCatalogTrackName)
3741
+ }
3730
3742
  this.cancelBlockTimeout(meta.sbn, packetId, recoveredCatalogTrackName)
3731
3743
  if (recoveredCatalogTrackName === undefined) {
3732
3744
  this.onBlockComplete?.(meta.sbn, packetId)
@@ -4106,77 +4118,26 @@ export class FECManager {
4106
4118
  const config = this.#trackConfigs.get(packetId)
4107
4119
  const layers = config?.repairLayers
4108
4120
  if (layers === undefined || layers.length === 0) return true
4109
- if (repairTrackName !== undefined && !layers.some((l) => l.repairTrackName === repairTrackName)) {
4110
- return false
4121
+ let admission = this.#heldLayerStates.get(streamKey)
4122
+ if (admission === undefined) {
4123
+ admission = new HeldLayerAdmission(layers, config!.heldLayers)
4124
+ this.#heldLayerStates.set(streamKey, admission)
4111
4125
  }
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
- }
4126
+ const verdict = admission.admit(
4127
+ repairTrackName,
4128
+ ssStart,
4129
+ rsbLength,
4130
+ this.#layerAnchorHorizon(packetId),
4131
+ this.#sourceFrontiers.get(streamKey)?.sbn,
4132
+ )
4133
+ if (verdict === 'discard') return false
4134
+ if (verdict !== undefined) {
4135
+ this.#fecFailClosed.set(streamKey, verdict)
4136
+ this.#onFecFailClosed?.({ ...verdict, packetId, catalogTrackName })
4137
+ console.warn(
4138
+ `[FECManager] FEC failed closed (fec-01 6.3.9) packetId=${packetId} track=${catalogTrackName ?? '-'}: ${JSON.stringify(verdict)}`,
4139
+ )
4140
+ return false
4180
4141
  }
4181
4142
  return true
4182
4143
  }
@@ -4187,11 +4148,9 @@ export class FECManager {
4187
4148
  * times K, capped at 2^30 so compared serials stay inside one half-window.
4188
4149
  */
4189
4150
  #layerAnchorHorizon(packetId: number): number {
4190
- const cap = 0x40000000
4191
4151
  let blocks: number | undefined
4192
4152
  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)
4153
+ return heldLayerAnchorHorizon(blocks, this.#trackConfigs.get(packetId)?.k ?? 0)
4195
4154
  }
4196
4155
 
4197
4156
  /**
@@ -4542,6 +4501,36 @@ export class FECManager {
4542
4501
  queueMicrotask(() => this.#syncDecoderBlockOwners.delete(key))
4543
4502
  }
4544
4503
 
4504
+ /**
4505
+ * Run `fn` with `receivedAtMs` (performance.now() clock) as the receipt
4506
+ * time of what it feeds. Within a consumer the bc-provenance profile
4507
+ * governs, a datagram held on a miss and admitted later keeps its receipt
4508
+ * time (fec-01 9): it anchors its block's ts and arms the block's
4509
+ * deadlines from then, never later than they are already armed.
4510
+ */
4511
+ withReceiptTime(receivedAtMs: number, fn: () => void): void {
4512
+ const outer = this.#receiptAtMs
4513
+ this.#receiptAtMs = receivedAtMs
4514
+ try {
4515
+ fn()
4516
+ } finally {
4517
+ this.#receiptAtMs = outer
4518
+ }
4519
+ }
4520
+
4521
+ #receiptNow(): number {
4522
+ return this.#receiptAtMs ?? performance.now()
4523
+ }
4524
+
4525
+ /**
4526
+ * A held packet admitted late arms from its (earlier) receipt time; that
4527
+ * never pulls in a deadline later activity already armed. Outside a
4528
+ * receipt scope a re-arm replaces the deadline, as a narrower bound may.
4529
+ */
4530
+ #notEarlierUnderReceipt(deadlineMs: number, armedMs: number | undefined): number {
4531
+ return this.#receiptAtMs === undefined || armedMs === undefined ? deadlineMs : Math.max(deadlineMs, armedMs)
4532
+ }
4533
+
4545
4534
  /** Refresh deadlines after accepted block activity. */
4546
4535
  noteBlockActivity(
4547
4536
  sbn: number,
@@ -4778,6 +4767,7 @@ export class FECManager {
4778
4767
  this.#cleanlyCompletedBlocks.clear()
4779
4768
  this.#repairRequestedBlocks.clear()
4780
4769
  this.#workerRecoveredBlocks.clear()
4770
+ this.#refusedRecoveries.clear()
4781
4771
  this.#syncDecoderBlockOwners.clear()
4782
4772
  this.#startupAlignedPackets.clear()
4783
4773
  this.#startupPartialBlocks.clear()
@@ -0,0 +1,161 @@
1
+ /**
2
+ * The fec-01 6.3.9 receiver check (held-layer anchored RSB_length), in one
3
+ * place. FECManager and MmtpFecSession both run their repair packets through
4
+ * HeldLayerAdmission; neither restates the rule.
5
+ */
6
+
7
+ import { serial32Distance, serial32Precedes } from '@blockcast/mmt-base'
8
+
9
+ /**
10
+ * Why a source track's FEC failed closed at runtime (fec-01 6.3.9): a repair
11
+ * packet stated an RSB_length below a held layer's total B_i on that layer's
12
+ * own repair track (`below_own_track_total`), or at or after the anchor, the
13
+ * earliest block in SS_Start serial order whose repair carried >= B_i
14
+ * (`below_anchored_total`). Repair for the track is refused from then on.
15
+ */
16
+ export interface FecFailClosedReason {
17
+ code: 'below_own_track_total' | 'below_anchored_total'
18
+ layer: number
19
+ total: number
20
+ rsbLength: number
21
+ ssStart: number
22
+ anchor?: number
23
+ }
24
+
25
+ /** One repair layer as the catalog states it: B_i = rsIdOffset + repairSymbols. */
26
+ export interface HeldLayerInput {
27
+ layer: number
28
+ repairTrackName: string
29
+ rsIdOffset: number
30
+ repairSymbols: number
31
+ }
32
+
33
+ interface HeldLayerState {
34
+ layer: number
35
+ repairTrackName: string
36
+ total: number
37
+ /** Earliest (serial order) block start whose repair carried >= total. */
38
+ anchor?: number
39
+ /** Latest (serial order) block start whose repair carried < total. */
40
+ latestBelow?: number
41
+ /** RSB_length that latestBelow's repair carried, for the fail-closed reason. */
42
+ latestBelowRsb?: number
43
+ /** Newest block start seen; anchor and latestBelow are retired behind it. */
44
+ newest?: number
45
+ /**
46
+ * The anchor fell behind the horizon, so no block that can still arrive
47
+ * precedes it: every later packet must carry >= total. Its serial is no
48
+ * longer compared, so SS_Start wrapping past it cannot misorder it.
49
+ */
50
+ settled?: boolean
51
+ }
52
+
53
+ /** The largest horizon compared, so every compared serial stays in one half-window. */
54
+ const HORIZON_CAP = 0x40000000
55
+
56
+ /**
57
+ * How far (in SS_Start symbols) behind the newest block a 6.3.9 anchor or
58
+ * below-total block can still matter: the iso retention in blocks times K,
59
+ * capped at 2^30. Unknown inputs give the cap.
60
+ */
61
+ export function heldLayerAnchorHorizon(retentionBlocks: number | undefined, k: number): number {
62
+ return retentionBlocks === undefined || k <= 0 ? HORIZON_CAP : Math.min(HORIZON_CAP, retentionBlocks * k)
63
+ }
64
+
65
+ /**
66
+ * fec-01 6.3.9 anchored state for one FEC instance's held layers. For each
67
+ * held layer i with total B_i = O_i + P_i: the anchor is the earliest block,
68
+ * in SS_Start serial order, whose repair carried RSB_length >= B_i. The
69
+ * instance fails closed on (a) a packet on layer i's own repair track (i > 0)
70
+ * below B_i, or (b) any packet of the instance at or after the anchor below
71
+ * B_i. Blocks before the anchor (pre-append) stay valid at the previous total.
72
+ * The result is not latched here: the caller owns what failing closed means.
73
+ */
74
+ export class HeldLayerAdmission {
75
+ readonly #layerNames: ReadonlySet<string>
76
+ readonly #states: HeldLayerState[]
77
+
78
+ constructor(layers: readonly HeldLayerInput[], heldLayers: readonly number[] = [0]) {
79
+ const held = new Set(heldLayers)
80
+ this.#layerNames = new Set(layers.map((l) => l.repairTrackName))
81
+ this.#states = layers
82
+ .filter((l) => held.has(l.layer))
83
+ .map((l) => ({ layer: l.layer, repairTrackName: l.repairTrackName, total: l.rsIdOffset + l.repairSymbols }))
84
+ }
85
+
86
+ /**
87
+ * Judges one repair packet, after the 4.5 discards and the size check and
88
+ * before it places a block. `repairTrackName` is the subscribed repair
89
+ * track, never inferred from RS_ID; without it only (b) runs. A packet on a
90
+ * repair track the catalog does not list is `'discard'` and anchors nothing.
91
+ * `sourceFrontier` is the newest source SS_ID, if any: a repair more than
92
+ * `horizon` past it is judged without moving the anchored state.
93
+ *
94
+ * @returns undefined to admit, `'discard'`, or the fail-closed reason.
95
+ */
96
+ admit(
97
+ repairTrackName: string | undefined,
98
+ ssStart: number,
99
+ rsbLength: number,
100
+ horizon: number,
101
+ sourceFrontier?: number,
102
+ ): FecFailClosedReason | 'discard' | undefined {
103
+ if (repairTrackName !== undefined && !this.#layerNames.has(repairTrackName)) return 'discard'
104
+ // A repair may run ahead of the media, but one more than `horizon` past the
105
+ // source frontier must not move the held-layer state: advancing `newest` on
106
+ // it would retire every real anchor and skip this check until traffic caught
107
+ // up. Such a repair is still judged below the total: it is on its own track,
108
+ // or after any anchor by definition. With no anchor yet it says nothing.
109
+ // It is still admitted: in iso the frontier only moves when a repair places
110
+ // held sources, so dropping it would stall recovery after a long outage.
111
+ // IsoBlockLocator.placeRepair may still refuse the same repair as `ahead`:
112
+ // it measures from `newest`, which held sources move, not from this frontier.
113
+ const farAhead =
114
+ sourceFrontier !== undefined &&
115
+ serial32Precedes(sourceFrontier, ssStart) &&
116
+ serial32Distance(sourceFrontier, ssStart) > horizon
117
+ for (const state of this.#states) {
118
+ let code: FecFailClosedReason['code'] | undefined
119
+ if (farAhead) {
120
+ if (rsbLength < state.total) {
121
+ if (state.layer > 0 && repairTrackName === state.repairTrackName) code = 'below_own_track_total'
122
+ else if (state.anchor !== undefined || state.settled) code = 'below_anchored_total'
123
+ }
124
+ } else {
125
+ // Serials more than `horizon` behind the newest block are retired, so
126
+ // every serial still compared sits inside one half-window and SS_Start
127
+ // wrapping on a long-running stream cannot reorder them.
128
+ if (state.newest === undefined || serial32Precedes(state.newest, ssStart)) state.newest = ssStart
129
+ const stale = (s: number) => serial32Distance(s, state.newest!) > horizon
130
+ if (state.latestBelow !== undefined && stale(state.latestBelow)) {
131
+ delete state.latestBelow
132
+ delete state.latestBelowRsb
133
+ }
134
+ if (state.anchor !== undefined && stale(state.anchor)) state.settled = true
135
+ if (rsbLength < state.total) {
136
+ if (state.layer > 0 && repairTrackName === state.repairTrackName) code = 'below_own_track_total'
137
+ else if (stale(ssStart)) continue // past retention: undecodable, anchors nothing
138
+ else if (state.settled || (state.anchor !== undefined && !serial32Precedes(ssStart, state.anchor))) code = 'below_anchored_total'
139
+ else if (state.latestBelow === undefined || serial32Precedes(state.latestBelow, ssStart)) {
140
+ state.latestBelow = ssStart
141
+ state.latestBelowRsb = rsbLength
142
+ }
143
+ } else if (!state.settled && !stale(ssStart)) {
144
+ if (state.anchor === undefined || serial32Precedes(ssStart, state.anchor)) state.anchor = ssStart
145
+ if (state.latestBelow !== undefined && !serial32Precedes(state.latestBelow, state.anchor)) {
146
+ code = 'below_anchored_total'
147
+ ssStart = state.latestBelow
148
+ rsbLength = state.latestBelowRsb!
149
+ }
150
+ }
151
+ }
152
+ if (code !== undefined) {
153
+ return {
154
+ code, layer: state.layer, total: state.total, rsbLength, ssStart,
155
+ ...(state.anchor === undefined ? {} : { anchor: state.anchor }),
156
+ }
157
+ }
158
+ }
159
+ return undefined
160
+ }
161
+ }