@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
@@ -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
+ }
package/src/index.ts CHANGED
@@ -39,15 +39,11 @@ export {
39
39
  export { isCompleteFmp4DataUnit } from './fmp4-completion.js'
40
40
 
41
41
 
42
- // source-auth (authenticated-manifest / Merkle) is intentionally NOT re-exported
43
- // from the eager index barrel. It statically imports blake3 from @noble/hashes,
44
- // and re-exporting it here pulls that bare specifier into every browser consumer
45
- // of @blockcast/mmt-container (the moq-lib / shred / moqtail players load the
46
- // barrel as raw ESM via an import map, where @noble/hashes has no mapping →
47
- // "Failed to resolve module specifier". No browser player uses source-auth.)
48
- // Consumers that need it import the dedicated subpath: '@blockcast/mmt-container/source-auth'.
49
- // The same rule covers the @blockcast/mmt-manifest-verify surface (ManifestVerifier et al.):
50
- // import it from that package (or the subpath above) directly, never from this barrel.
42
+ // The bc-provenance v2 surface (@blockcast/mmt-manifest-verify) is intentionally
43
+ // NOT re-exported from the eager index barrel: it imports @noble/curves and
44
+ // @noble/hashes, bare specifiers the moq-lib / shred / moqtail players (which
45
+ // load the barrel as raw ESM via an import map) cannot resolve. Import it from
46
+ // that package directly. The router's provenance hooks take it by type only.
51
47
 
52
48
  // H.264/AVC + H.265/HEVC Codec Utilities
53
49
  export {
@@ -653,6 +649,7 @@ export {
653
649
  type RepairPriorityReason,
654
650
  type RepairSendMeasurement,
655
651
  type RepairSkipReason,
652
+ type RepairStopReason,
656
653
  type RepairSubscribeCallback,
657
654
  type WireRepairFetchOptions,
658
655
  } from './repair-fetch.js'
@@ -234,6 +234,14 @@ export interface FecReleaseGate {
234
234
  ): FrameGapDecision
235
235
  /** Every frame released in order from a gated stream. */
236
236
  onReleased?(data: Uint8Array, meta: FrameMeta): void
237
+ /**
238
+ * Whether a complete frame is a RAP (draft-ramadan-moq-fec 11.4.2). Asked
239
+ * for each video frame with a Source FEC Payload ID that enters the gate,
240
+ * held or released; absent, no frame counts as a RAP.
241
+ */
242
+ isRap?(data: Uint8Array, meta: FrameMeta): boolean
243
+ /** A complete RAP entered the gate at SS_ID `ssId` (see `isRap`). */
244
+ onRap?(packetId: number, catalogTrackName: string | undefined, ssId: number): void
237
245
  }
238
246
 
239
247
  /** Typed ISO-BMFF timing-sample budget counters. See `timingBudgetStats`. */
@@ -692,6 +700,15 @@ function authoredGap(before: FrameMeta, after: FrameMeta): boolean {
692
700
  return b.decodeTime > a.decodeTime + BigInt(a.durationTicks)
693
701
  }
694
702
 
703
+ /**
704
+ * Serial-number order on the uint32 SS_ID (RFC 1982 style): `a` follows `b`
705
+ * within half the space, so a 2^32 wrap does not read a post-wrap block as
706
+ * below a pre-wrap RAP.
707
+ */
708
+ function ssIdAfter(a: number, b: number): boolean {
709
+ return ((a - b) | 0) > 0
710
+ }
711
+
695
712
  interface GatedFrame extends PendingFrame {
696
713
  /** performance.now() at push; measures the hold against the gate's limit. */
697
714
  arrivedAtMs: number
@@ -704,6 +721,8 @@ interface GatedStream {
704
721
  last: PendingFrame | null
705
722
  lastSbn: number | undefined
706
723
  discontinuity: boolean
724
+ /** Latest (serial order) SS_ID of a complete RAP that entered the gate since the last reset. */
725
+ rapSsId: number | undefined
707
726
  }
708
727
 
709
728
  /**
@@ -797,11 +816,17 @@ class FecReleaseSequencer {
797
816
  stream.last = null
798
817
  stream.lastSbn = undefined
799
818
  stream.discontinuity = false
819
+ stream.rapSsId = undefined
800
820
  }
801
821
  if (stream.last !== null && compareFrameOrder(frame, stream.last) <= 0) {
802
822
  this.#recordStat('droppedStale')
803
823
  return
804
824
  }
825
+ const ssId = meta.fecSbn
826
+ if (meta.trackType === 'video' && ssId !== undefined && this.#gate.isRap?.(data, meta) === true) {
827
+ if (stream.rapSsId === undefined || ssIdAfter(ssId, stream.rapSsId)) stream.rapSsId = ssId
828
+ this.#gate.onRap?.(stream.packetId, stream.catalogTrackName, ssId)
829
+ }
805
830
  if (stream.last === null) {
806
831
  // The first release establishes decoder sync: video must start on a
807
832
  // RAP; earlier deltas are undecodable and are dropped.
@@ -841,6 +866,23 @@ class FecReleaseSequencer {
841
866
  return earliest
842
867
  }
843
868
 
869
+ /**
870
+ * Whether a complete RAP above SS_ID `ssId` entered the gate of a stream of
871
+ * `packetId` (draft-ramadan-moq-fec 11.4.2: repair of a block below it is
872
+ * moot). Its SS_ID is the one its frame completed in; a frame's packets
873
+ * hold consecutive SS_IDs in authored order, so a complete RAP with a
874
+ * packet above `ssId` has every packet of it at or above the first SS_ID
875
+ * any loss at or below `ssId` could have taken.
876
+ */
877
+ rapAbove(packetId: number, catalogTrackName: string | undefined, ssId: number): boolean {
878
+ for (const stream of this.#streams.values()) {
879
+ if (stream.packetId !== packetId) continue
880
+ if (catalogTrackName !== undefined && stream.catalogTrackName !== catalogTrackName) continue
881
+ if (stream.rapSsId !== undefined && ssIdAfter(stream.rapSsId, ssId)) return true
882
+ }
883
+ return false
884
+ }
885
+
844
886
  /** Re-evaluate held gaps after FEC state changed. */
845
887
  poke(streamKey?: string): void {
846
888
  if (streamKey !== undefined) {
@@ -946,6 +988,7 @@ class FecReleaseSequencer {
946
988
  last: null,
947
989
  lastSbn: undefined,
948
990
  discontinuity: false,
991
+ rapSsId: undefined,
949
992
  }
950
993
  this.#streams.set(streamKey, stream)
951
994
  }
@@ -1346,6 +1389,11 @@ export class MmtpContainerParser {
1346
1389
  return this.#fecSequencer?.earliestHeldArrivalMs(packetId, catalogTrackName)
1347
1390
  }
1348
1391
 
1392
+ /** See FecReleaseSequencer.rapAbove; false when no stream is FEC-gated. */
1393
+ fecReleaseRapAbove(packetId: number, catalogTrackName: string | undefined, ssId: number): boolean {
1394
+ return this.#fecSequencer?.rapAbove(packetId, catalogTrackName, ssId) ?? false
1395
+ }
1396
+
1349
1397
  /** Re-evaluate held gaps after FEC block state changed. */
1350
1398
  pokeFecRelease(packetId?: number, catalogTrackName?: string): void {
1351
1399
  if (!this.#fecSequencer) return
@@ -18,6 +18,11 @@
18
18
  * The retention is derived from the catalog's `fecBlockBudgetMs`, and a
19
19
  * catalog missing either input throws rather than defaulting.
20
20
  *
21
+ * Every repair packet runs the fec-01 6.3.9 held-layer check (layer 0, B_0 =
22
+ * catalog `fec.repairSymbols`) through the same HeldLayerAdmission FECManager
23
+ * uses. A packet below B_0 at or after the anchor fails the session closed:
24
+ * `stats.failClosed` reports why, and every later repair is refused.
25
+ *
21
26
  * Every repair packet that cannot be used is counted under the reason it was
22
27
  * refused, and every block ends under exactly one outcome, so a stream whose
23
28
  * FEC is doing nothing can be told apart from one with nothing to repair.
@@ -37,6 +42,14 @@ import {
37
42
  type FecBlockProfile,
38
43
  } from '@blockcast/mmt-base'
39
44
  import { deriveFecRetentionBlocks, fecBlockBudgetMs } from '@blockcast/mmt-fec'
45
+ import {
46
+ HeldLayerAdmission,
47
+ heldLayerAnchorHorizon,
48
+ type FecFailClosedReason,
49
+ type HeldLayerInput,
50
+ } from './held-layer-admission.js'
51
+
52
+ export type { FecFailClosedReason, HeldLayerInput } from './held-layer-admission.js'
40
53
 
41
54
  /** The mmt-wasm `MmtFecDecoder` members the session drives. */
42
55
  export interface MmtpFecSessionDecoder {
@@ -67,6 +80,10 @@ export interface MmtpFecSessionCatalogFec {
67
80
  algorithm?: unknown
68
81
  blockProfile?: unknown
69
82
  sourceSymbols?: unknown
83
+ /** P_0: layer 0's repair symbols per block, the fec-01 6.3.9 total B_0. */
84
+ repairSymbols?: unknown
85
+ /** Layer 0's repair track; names it for addRepair()'s `repairTrackName`. */
86
+ repairTrack?: unknown
70
87
  symbolSize?: unknown
71
88
  interleaveDepthMs?: unknown
72
89
  reorderToleranceMs?: unknown
@@ -77,6 +94,14 @@ export interface MmtpFecSessionConfig {
77
94
  fec: MmtpFecSessionCatalogFec
78
95
  /** Builds the RaptorQ decoder, normally `wasm_bindgen.MmtFecDecoder`. */
79
96
  Decoder: MmtpFecSessionDecoderConstructor
97
+ /**
98
+ * The source's repair layers as the catalog resolves them
99
+ * (`TrackFecParams.repairLayers`), for a caller that subscribes more than
100
+ * layer 0. Absent: layer 0 alone, B_0 = `fec.repairSymbols`.
101
+ */
102
+ repairLayers?: readonly HeldLayerInput[]
103
+ /** The layers the caller holds (fec-01 6.3.9). Absent: [0]. */
104
+ heldLayers?: readonly number[]
80
105
  }
81
106
 
82
107
  /**
@@ -139,6 +164,16 @@ export interface MmtpFecSessionStats {
139
164
  * contributed nothing to its block and do not make it mixed.
140
165
  */
141
166
  mixedPath: MmtpFecSessionMixedPathStats
167
+ /**
168
+ * fec-01 6.3.9: null until a repair packet at or after the anchor (the
169
+ * earliest block, in SS_Start serial order, whose repair carried
170
+ * RSB_length >= B_0) states an RSB_length below B_0; then why. Terminal:
171
+ * every later repair is refused under `repair:fail-closed` (the packet that
172
+ * failed it is counted there too), and block state stays as it was. Sources
173
+ * are still taken and can still complete a block from repair admitted
174
+ * before the failure.
175
+ */
176
+ failClosed: FecFailClosedReason | null
142
177
  }
143
178
 
144
179
  interface BlockState {
@@ -160,6 +195,8 @@ export class MmtpFecSession {
160
195
  readonly blockProfile: FecBlockProfile
161
196
  readonly algorithm: 'raptorq' | 'reed-solomon'
162
197
  readonly sourceSymbols: number
198
+ /** P_0 = B_0, the held total every repair packet is checked against. */
199
+ readonly repairSymbols: number
163
200
  readonly symbolSize: number
164
201
  /** The decoder's retention, in blocks, derived from the catalog budget. */
165
202
  readonly retentionBlocks: number
@@ -178,6 +215,9 @@ export class MmtpFecSession {
178
215
  /** The newest SS_ID when the remembered SS_IDs were last pruned. */
179
216
  #prunedAt: number | undefined
180
217
  #repairPacketId: number | undefined
218
+ #newestSource: number | undefined
219
+ #heldLayers: HeldLayerAdmission
220
+ #anchorHorizon: number
181
221
  #stats: MmtpFecSessionStats = {
182
222
  sourceSymbols: 0,
183
223
  repairSymbols: 0,
@@ -191,11 +231,12 @@ export class MmtpFecSession {
191
231
  sourceSymbolsByPath: { moq: 0, multicast: 0 },
192
232
  repairSymbolsByPath: { moq: 0, multicast: 0 },
193
233
  mixedPath: { clean: 0, recovered: 0, failed: 0, released: 0 },
234
+ failClosed: null,
194
235
  }
195
236
 
196
237
  /**
197
238
  * @throws when the catalog block lacks or misstates a field the session
198
- * needs: the block profile, K, T, `interleaveDepthMs` or
239
+ * needs: the block profile, K, P_0 (`repairSymbols`), T, `interleaveDepthMs` or
199
240
  * `reorderToleranceMs`. None is defaulted.
200
241
  */
201
242
  constructor(config: MmtpFecSessionConfig) {
@@ -209,6 +250,7 @@ export class MmtpFecSession {
209
250
  }
210
251
  this.algorithm = fec.algorithm === 'reed-solomon' ? 'reed-solomon' : 'raptorq'
211
252
  this.sourceSymbols = requirePositiveInteger(fec.sourceSymbols, 'fec.sourceSymbols')
253
+ this.repairSymbols = requirePositiveInteger(fec.repairSymbols, 'fec.repairSymbols')
212
254
  this.symbolSize = requirePositiveInteger(fec.symbolSize, 'fec.symbolSize')
213
255
  const interleaveDepthMs = requireNumber(fec.interleaveDepthMs, 'fec.interleaveDepthMs')
214
256
  const reorderToleranceMs = requireNumber(fec.reorderToleranceMs, 'fec.reorderToleranceMs')
@@ -216,6 +258,18 @@ export class MmtpFecSession {
216
258
  fecBlockBudgetMs(interleaveDepthMs, reorderToleranceMs),
217
259
  interleaveDepthMs,
218
260
  )
261
+ this.#heldLayers = new HeldLayerAdmission(
262
+ config.repairLayers ?? [
263
+ {
264
+ layer: 0,
265
+ repairTrackName: typeof fec.repairTrack === 'string' ? fec.repairTrack : '',
266
+ rsIdOffset: 0,
267
+ repairSymbols: this.repairSymbols,
268
+ },
269
+ ],
270
+ config.heldLayers,
271
+ )
272
+ this.#anchorHorizon = heldLayerAnchorHorizon(this.retentionBlocks, this.sourceSymbols)
219
273
  if (typeof config.Decoder !== 'function') {
220
274
  throw new Error('MmtpFecSession: a decoder constructor (wasm_bindgen.MmtFecDecoder) is required')
221
275
  }
@@ -241,6 +295,7 @@ export class MmtpFecSession {
241
295
  sourceSymbolsByPath: { ...this.#stats.sourceSymbolsByPath },
242
296
  repairSymbolsByPath: { ...this.#stats.repairSymbolsByPath },
243
297
  mixedPath: { ...this.#stats.mixedPath },
298
+ failClosed: this.#stats.failClosed && { ...this.#stats.failClosed },
244
299
  }
245
300
  }
246
301
 
@@ -266,6 +321,7 @@ export class MmtpFecSession {
266
321
  this.#stats.sourceSymbols++
267
322
  this.#stats.sourceSymbolsByPath[path]++
268
323
  this.#noteNewest(ssId)
324
+ if (this.#newestSource === undefined || serial32Precedes(this.#newestSource, ssId)) this.#newestSource = ssId
269
325
 
270
326
  const start = this.#blockStartOf(ssId)
271
327
  const block = start === undefined ? undefined : this.#blocks.get(start)
@@ -298,14 +354,17 @@ export class MmtpFecSession {
298
354
  * Feeds one object of the repair track: a whole MMTP repair packet (ISO/IEC
299
355
  * 23008-1 C.5.3). A repair track carries one repair flow, so the first
300
356
  * repair packet fixes its packet ID and a packet of any other is refused.
301
- * `path` is where it was received, as for addSource().
357
+ * `path` is where it was received, as for addSource(). `repairTrackName`
358
+ * is the repair track it was received on, for the 6.3.9 own-track rule; a
359
+ * track the layers do not list is refused as `repair:unlisted-track`.
302
360
  *
303
361
  * @returns the protected packets this repair symbol recovered, as for
304
362
  * addSource().
305
363
  */
306
- addRepair(packet: Uint8Array, path: MmtpFecSessionPath = 'moq'): Uint8Array[] {
364
+ addRepair(packet: Uint8Array, path: MmtpFecSessionPath = 'moq', repairTrackName?: string): Uint8Array[] {
307
365
  const decoder = this.#decoder
308
366
  if (!decoder) return this.#drop('repair:released')
367
+ if (this.#stats.failClosed) return this.#drop('repair:fail-closed')
309
368
  const pathBit = PATH_BITS[path]
310
369
  if (!pathBit) return this.#drop('repair:unknown-path')
311
370
  let header
@@ -328,6 +387,20 @@ export class MmtpFecSession {
328
387
  if (this.algorithm === 'reed-solomon' && frame.ssbLength + frame.rsbLength > 255) {
329
388
  return this.#drop('repair:rs-block-length')
330
389
  }
390
+ // fec-01 6.3.9, after the 4.5 discards and the size check, before a block
391
+ // is placed. Without a track name only the anchored rule (b) applies.
392
+ const verdict = this.#heldLayers.admit(
393
+ repairTrackName,
394
+ frame.ssStart,
395
+ frame.rsbLength,
396
+ this.#anchorHorizon,
397
+ this.#newestSource,
398
+ )
399
+ if (verdict === 'discard') return this.#drop('repair:unlisted-track')
400
+ if (verdict !== undefined) {
401
+ this.#stats.failClosed = verdict
402
+ return this.#drop('repair:fail-closed')
403
+ }
331
404
 
332
405
  const start = frame.ssStart
333
406
  let block = this.#blocks.get(start)