@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
@@ -38,12 +38,12 @@ import type {
38
38
  RepairPriorityReason,
39
39
  RepairSendMeasurement,
40
40
  RepairSkipReason,
41
+ RepairStopReason,
41
42
  RepairSubscribeCallback,
42
43
  } from "./repair-fetch.js";
43
44
  import {
44
45
  MmtpRouter,
45
46
  type FrameCallback,
46
- type ManifestIdentityResolver,
47
47
  type MmtpIngressContext,
48
48
  type MmtpStreamIdentity,
49
49
  type StagedStreamSwitch,
@@ -56,8 +56,11 @@ import {
56
56
  type MulticastFailbackReason,
57
57
  type MulticastJoinOutcome,
58
58
  type MulticastJoinStats,
59
+ type MulticastTransportConfig,
59
60
  } from "./multicast-transport.js";
60
61
  import { computeFecDeliveryWindowMs } from "./track-session.js";
62
+ import { BcProvenanceV2Receiver, type BcpV2ReceiverConfig } from "@blockcast/mmt-manifest-verify";
63
+ import type { ResolvedMulticastAuthConfig, ResolvedMulticastAuthFlow } from "./multicast-auth-catalog.js";
61
64
  import { DEFAULT_AMT_RELAY_PORT } from "./catalog-network-source.js";
62
65
  import { deriveFecInterleaveDepth, FecBlockMapper } from "@blockcast/mmt-fec";
63
66
  import type {
@@ -68,10 +71,6 @@ import type {
68
71
  } from "@blockcast/fec-worker";
69
72
  import type { MfuReassemblerBackend } from "./mfu-reassembler.js";
70
73
  import type { MfuReassemblyPolicy } from "./mfu-reassembly-policy.js";
71
- import type {
72
- ManifestObjectIdentity,
73
- ManifestVerifier,
74
- } from "@blockcast/mmt-manifest-verify";
75
74
  import type {
76
75
  BlockAssemblerConfig,
77
76
  CmafBlockSample,
@@ -104,6 +103,8 @@ export interface MulticastRepairEndpoint {
104
103
  repairOwners: ReadonlyArray<{ packetId: number; ownerPacketId: number }>;
105
104
  /** Ingress tracks: the owners' source tracks and the repair tracks. */
106
105
  tracks: MmtpIngressContext["tracks"];
106
+ /** The protected flow (`MmtpRouter.registerProvenanceFlow`) this group's datagrams belong to. */
107
+ flow?: string;
107
108
  }
108
109
 
109
110
  /**
@@ -116,8 +117,32 @@ export type MulticastRepairSubscribe = (
116
117
  onPacket: (packet: Uint8Array) => void,
117
118
  ) => (() => void) | Promise<() => void>;
118
119
 
120
+ /**
121
+ * A protected endpoint's join (draft -01 7.2.7): the keys and flow
122
+ * `resolveMulticastAuthCatalog` resolved for it. The join registers the flow
123
+ * with the router and subscribes `manifestPort` on the same `(S, G)` beside
124
+ * the media, so every media datagram is admitted by a record before it is read.
125
+ */
126
+ export interface MulticastProvenanceJoin {
127
+ auth: Pick<ResolvedMulticastAuthConfig, "keys" | "minEpoch">;
128
+ flow: ResolvedMulticastAuthFlow;
129
+ /** Receiver options beyond the catalog's (ctx, clock, freshness, W, lead). */
130
+ receiver?: Omit<Partial<BcpV2ReceiverConfig>, "endpoint" | "keys" | "minEpoch" | "bandwidth" | "maxAuthDelayMs" | "manifestTimerMs">;
131
+ }
132
+
119
133
  export interface MulticastEndpointConfig {
120
- sourceAddress: string;
134
+ /** SSM source; omitted on ASM. */
135
+ sourceAddress?: string;
136
+ /** ASM sender filter: `auth.senderAddress` of a protected ASM endpoint. Never beside `sourceAddress`. */
137
+ senderAddress?: string;
138
+ /**
139
+ * Present on a protected endpoint: the join admits through bc-provenance v2.
140
+ * From its first admission (`onProtected`) MoQ copies of the listed tracks
141
+ * are dropped until the media transport fails back (silence or a
142
+ * subscription error); the manager un-protects then whether or not a player
143
+ * registered `onFailback`.
144
+ */
145
+ provenance?: MulticastProvenanceJoin;
121
146
  groupAddress: string;
122
147
  port: number;
123
148
  /** Explicit gateway transport policy. Omitted means gateway-selected auto mode. */
@@ -190,15 +215,6 @@ export interface MulticastManagerOptions {
190
215
  reassemblyPolicy?: MfuReassemblyPolicy;
191
216
  reassembler?: MfuReassemblerBackend;
192
217
  maxBufferedMfus?: number;
193
- manifestVerifier?: ManifestVerifier;
194
- manifestVerifierOwnership?: "caller" | "router";
195
- resolveManifestIdentity?: (
196
- packetId: number,
197
- sbn: number,
198
- esi: number,
199
- catalogTrackName?: string,
200
- ) => ManifestObjectIdentity | null;
201
- resolveManifestIdentityForRoute?: ManifestIdentityResolver;
202
218
  /**
203
219
  * Also write every repair lifecycle event to the console as one
204
220
  * `REPAIR_LIFECYCLE_LOG_PREFIX` JSON line. Off unless asked for: the lines
@@ -296,11 +312,13 @@ interface ActiveRenditionSwitch {
296
312
  state: RenditionSwitchState;
297
313
  }
298
314
 
299
- function freezeIngress(tracks: Iterable<Readonly<{ name: string; packetId: number }>>): MmtpIngressContext {
315
+ /** `flow` is the live join's protected flow: every ingress a switch installs keeps it, or the router reads media unauthenticated. */
316
+ function freezeIngress(tracks: Iterable<Readonly<{ name: string; packetId: number }>>, flow: string | undefined): MmtpIngressContext {
300
317
  return Object.freeze({
301
318
  tracks: Object.freeze(
302
319
  [...tracks].map(({ name, packetId }) => Object.freeze({ name, packetId })),
303
320
  ),
321
+ ...(flow === undefined ? {} : { flow }),
304
322
  });
305
323
  }
306
324
 
@@ -341,6 +359,9 @@ interface RepairSymbolCounts {
341
359
  * the deadline) so a harness reads the budget instead of inferring it.
342
360
  * - `repair-preempted` / `repair-deferred`: the scheduler aborted a delta
343
361
  * repair for keyframe work, or never ran it before its block resolved.
362
+ * - `repair-stopped`: the scheduler ended a running repair FETCH (its
363
+ * transport cancels it on the wire) and why; `moot` is a RAP above the
364
+ * block that arrived complete first (draft-ramadan-moq-fec 11.4.2).
344
365
  * - `repair-priority`: the catalog could not supply the SUBSCRIBER_PRIORITY a
345
366
  * repair FETCH must carry (draft-ramadan-moq-fec §11.4.5), so the FETCH was
346
367
  * sent without the parameter (`omitted`, keyframe/unknown blocks) or not
@@ -418,6 +439,12 @@ export type RepairLifecycleEvent =
418
439
  readonly rttMs: number | null;
419
440
  readonly budgetMs: number;
420
441
  }
442
+ | {
443
+ readonly version: 2;
444
+ readonly type: "repair-stopped";
445
+ readonly block: RepairBlockIdentity;
446
+ readonly reason: RepairStopReason;
447
+ }
421
448
  | {
422
449
  readonly version: 2;
423
450
  readonly type: "repair-priority";
@@ -488,6 +515,18 @@ export interface BlockPolicyStats {
488
515
  * §7.5 anchor (no epoch reference, or none it could be extended from).
489
516
  */
490
517
  repairsSkippedRangeNoAnchor: number;
518
+ /**
519
+ * `repairsSkipped` by reason: the block's repair group could not complete
520
+ * it (sources + RSB_length < K'), so no FETCH was sent.
521
+ */
522
+ repairsSkippedGroupCannotComplete: number;
523
+ /**
524
+ * `repairsSkipped` by reason: a keyframe-bearing block lay below a RAP
525
+ * that had already arrived complete (draft-ramadan-moq-fec 11.4.2).
526
+ */
527
+ repairsSkippedMootRap: number;
528
+ /** Running repair FETCHes the scheduler stopped, by reason (`repair-stopped`). */
529
+ repairStops: Record<RepairStopReason, number>;
491
530
  /**
492
531
  * The most recent skip: its reason, the measured RTT (null when
493
532
  * unmeasured), the time that was left to the deadline, and the request's
@@ -549,6 +588,9 @@ function newBlockPolicyStats(
549
588
  repairsSkippedRttExceedsBudget: 0,
550
589
  repairsSkippedPresentationDelayUnavailable: 0,
551
590
  repairsSkippedRangeNoAnchor: 0,
591
+ repairsSkippedGroupCannotComplete: 0,
592
+ repairsSkippedMootRap: 0,
593
+ repairStops: { preempted: 0, reprioritized: 0, expired: 0, resolved: 0, reset: 0, disposed: 0, moot: 0 },
552
594
  lastRepairSkip: null,
553
595
  keyframeRepairFetchesSent: 0,
554
596
  rangeRepairFetchesSent: 0,
@@ -701,6 +743,18 @@ const REPAIR_SKIP_REASONS: ReadonlySet<unknown> = new Set([
701
743
  "rtt-unmeasured",
702
744
  "rtt-exceeds-budget",
703
745
  "presentation-delay-unavailable",
746
+ "range-no-anchor",
747
+ "group-cannot-complete",
748
+ "moot-rap",
749
+ ]);
750
+ const REPAIR_STOP_REASONS: ReadonlySet<unknown> = new Set([
751
+ "preempted",
752
+ "reprioritized",
753
+ "expired",
754
+ "resolved",
755
+ "reset",
756
+ "disposed",
757
+ "moot",
704
758
  ]);
705
759
  const REPAIR_DEADLINE_BOUNDS: ReadonlySet<unknown> = new Set(["wait", "presentation", "keyframe", "retention"]);
706
760
  const GAP_REASONS: Readonly<Record<string, "stall" | "conceal">> = {
@@ -815,6 +869,12 @@ export function isRepairLifecycleEvent(
815
869
  isNullableNonNegative(value["rttMs"]) &&
816
870
  isFiniteNumber(value["budgetMs"])
817
871
  );
872
+ case "repair-stopped":
873
+ return (
874
+ hasExactKeys(value, ["version", "type", "block", "reason"]) &&
875
+ isRepairBlockIdentity(block) &&
876
+ REPAIR_STOP_REASONS.has(value["reason"])
877
+ );
818
878
  case "repair-priority": {
819
879
  const blockClass = value["blockClass"];
820
880
  const action = value["action"];
@@ -1048,7 +1108,11 @@ export interface MultiTrackCatalog {
1048
1108
  tracks: MultiTrackCatalogTrack[];
1049
1109
  }
1050
1110
 
1051
- /** Creates one stateful synchronous FEC decoder per physical catalog stream. */
1111
+ /**
1112
+ * Creates one stateful synchronous FEC decoder per physical catalog stream.
1113
+ * Every call must return a new decoder: a governed instance also calls it for
1114
+ * one-shot scratch decoders, which the router frees after use.
1115
+ */
1052
1116
  export type WasmFecDecoderFactory = (
1053
1117
  track: Readonly<MultiTrackCatalogTrack>,
1054
1118
  ) => WasmFecDecoder;
@@ -1211,6 +1275,10 @@ export class MulticastManager {
1211
1275
  readonly #router: MmtpRouter;
1212
1276
  #hostMfuTimeoutMs: number | null;
1213
1277
  readonly #transport: MulticastTransport<MmtpIngressContext>;
1278
+ /** The protected flow of the current join and its manifestPort subscription. */
1279
+ #provenanceJoin: { flow: string; manifest: MulticastTransport } | null = null;
1280
+ #onFailback: ((reason: MulticastFailbackReason) => void) | null = null;
1281
+ #failbackHooked = false;
1214
1282
  /** Live joinRepairEndpoints() releases → the repair owners they registered,
1215
1283
  * re-applied after configureMultiTrack() clears catalog-derived owners. */
1216
1284
  readonly #repairReleases = new Map<() => void, MulticastRepairEndpoint["repairOwners"]>();
@@ -1276,10 +1344,6 @@ export class MulticastManager {
1276
1344
  reassemblyPolicy: options?.reassemblyPolicy,
1277
1345
  reassembler: options?.reassembler,
1278
1346
  maxBufferedMfus: options?.maxBufferedMfus,
1279
- manifestVerifier: options?.manifestVerifier,
1280
- manifestVerifierOwnership: options?.manifestVerifierOwnership,
1281
- resolveManifestIdentity: options?.resolveManifestIdentity,
1282
- resolveManifestIdentityForRoute: options?.resolveManifestIdentityForRoute,
1283
1347
  // The router has reset its own state for the stream; reset the rest.
1284
1348
  onPublisherRestart: (packetId, catalogTrackName) => {
1285
1349
  this.#resetStreamOutsideRouter(packetId, catalogTrackName);
@@ -1326,7 +1390,7 @@ export class MulticastManager {
1326
1390
  });
1327
1391
  this.#hostMfuTimeoutMs = this.#router.reassembler.timeoutMs;
1328
1392
  this.#transport = new MulticastTransport<MmtpIngressContext>((packet, ingress) => {
1329
- this.#router.feedPacket(packet, "multicast", undefined, ingress);
1393
+ this.#router.feedPacket(packet, "multicast", ingress);
1330
1394
  });
1331
1395
  // Each joined subscription declares how it delivers packets. An ordered
1332
1396
  // relay decides FEC block fate by stream order instead of the datagram
@@ -1343,17 +1407,9 @@ export class MulticastManager {
1343
1407
  const recovered = new Uint8Array(data);
1344
1408
  const catalogTrackName = recoveredTrackName
1345
1409
  ?? this.#fec.catalogTrackNameForBlock(sbn, packetId);
1346
- if (catalogTrackName === undefined) {
1347
- this.#router.routeRecoveredSymbol(sbn, recovered, packetId, meta.esi);
1348
- } else {
1349
- this.#router.routeRecoveredSymbol(
1350
- sbn,
1351
- recovered,
1352
- packetId,
1353
- meta.esi,
1354
- catalogTrackName,
1355
- );
1356
- }
1410
+ return catalogTrackName === undefined
1411
+ ? this.#router.routeRecoveredSymbol(sbn, recovered, packetId, meta.esi)
1412
+ : this.#router.routeRecoveredSymbol(sbn, recovered, packetId, meta.esi, catalogTrackName);
1357
1413
  };
1358
1414
 
1359
1415
  // D-28: release the in-flight repair slot when a repair-requested block
@@ -1438,6 +1494,11 @@ export class MulticastManager {
1438
1494
  get router(): MmtpRouter {
1439
1495
  return this.#router;
1440
1496
  }
1497
+ /** Repair FETCH wiring (wireRepairFetch): this manager can request unicast repair. */
1498
+ setUnicastRepair(enabled: boolean): void {
1499
+ this.#router.setUnicastRepair(enabled);
1500
+ }
1501
+
1441
1502
  get transport(): MulticastTransport<MmtpIngressContext> {
1442
1503
  return this.#transport;
1443
1504
  }
@@ -2028,8 +2089,36 @@ export class MulticastManager {
2028
2089
  onTakeover(cb: () => void) {
2029
2090
  this.#transport.onTakeover(cb);
2030
2091
  }
2092
+ /** Failback un-protects the join's flow (7.2.7 failover), so the fallback's MoQ packets are read. */
2031
2093
  onFailback(cb: (reason: MulticastFailbackReason) => void) {
2032
- this.#transport.onFailback(cb);
2094
+ this.#onFailback = cb;
2095
+ this.#hookFailback();
2096
+ }
2097
+
2098
+ /**
2099
+ * Install the manager's own failback listener on the media transport, which
2100
+ * arms its silence watchdog: a protected join un-protects on failover even
2101
+ * when no player listens, else its tracks' MoQ copies stay dropped.
2102
+ */
2103
+ #hookFailback(): void {
2104
+ if (this.#failbackHooked) return;
2105
+ this.#failbackHooked = true;
2106
+ this.#transport.onFailback((reason) => {
2107
+ const joined = this.#provenanceJoin;
2108
+ if (joined !== null) this.#router.unprotectProvenanceFlow(joined.flow);
2109
+ this.#onFailback?.(reason);
2110
+ });
2111
+ }
2112
+ /**
2113
+ * A protected join's handoff (draft -01 7.2.7), before `onTakeover`: its
2114
+ * first admitted datagram. MoQ copies of the flow's tracks the router still
2115
+ * held unadmitted are discarded, and later ones are dropped. Fires again
2116
+ * after a failback once multicast resumes.
2117
+ */
2118
+ onProtected(cb: (() => void) | null) {
2119
+ this.#router.onProvenanceFlowProtected(cb && ((flow) => {
2120
+ if (this.#provenanceJoin?.flow === flow) cb();
2121
+ }));
2033
2122
  }
2034
2123
  /** See MulticastTransport.onDataPlaneLive: the join's first accepted packet. */
2035
2124
  onDataPlaneLive(cb: () => void) {
@@ -2047,6 +2136,17 @@ export class MulticastManager {
2047
2136
  }
2048
2137
 
2049
2138
  async join(config: MulticastEndpointConfig): Promise<boolean> {
2139
+ const provenanceFlow = config.provenance?.flow;
2140
+ if (
2141
+ provenanceFlow !== undefined &&
2142
+ ((config.sourceAddress ?? config.senderAddress) !== provenanceFlow.source ||
2143
+ config.groupAddress !== provenanceFlow.groupAddress ||
2144
+ config.port !== provenanceFlow.port)
2145
+ ) {
2146
+ throw new RangeError(
2147
+ "[MulticastManager] join: provenance flow (S, G, port) must match sourceAddress ?? senderAddress, groupAddress and port",
2148
+ );
2149
+ }
2050
2150
  // A join replaces the admission a staged switch was built on. The new
2051
2151
  // join's config decides what is admitted, so nothing is restored or
2052
2152
  // retired here: the switch just ends.
@@ -2071,22 +2171,27 @@ export class MulticastManager {
2071
2171
  : selectedPacketIds === null
2072
2172
  ? undefined
2073
2173
  : [...selectedPacketIds].map((packetId) => ({ name: "", packetId }));
2074
- const ingress = ingressTracks === undefined
2174
+ this.#releaseProvenanceJoin();
2175
+ const provenance = config.provenance;
2176
+ const protectedTracks = ingressTracks ?? provenance?.flow.tracks;
2177
+ const ingress: MmtpIngressContext | undefined = protectedTracks === undefined
2075
2178
  ? undefined
2076
2179
  : {
2077
2180
  tracks: Object.freeze(
2078
- ingressTracks.map(({ name, packetId }) =>
2181
+ protectedTracks.map(({ name, packetId }) =>
2079
2182
  Object.freeze({ name, packetId }),
2080
2183
  ),
2081
2184
  ),
2185
+ ...(provenance === undefined ? {} : { flow: multicastProvenanceFlowKey(provenance.flow) }),
2082
2186
  };
2083
2187
  // The single relay inlet: the player/UI override, or the catalog's
2084
2188
  // `multicast.networkSource[{type:"amt"}]` relay, which
2085
2189
  // `endpointToMulticastConfig` (packages/transport
2086
2190
  // multicast-catalog-policy.ts) resolves into `amtRelay` via `findAmtRelay`.
2087
2191
  const amtRelay = config.amtRelay;
2088
- const transportConfig = {
2192
+ const transportConfig: MulticastTransportConfig = {
2089
2193
  sourceAddress: config.sourceAddress,
2194
+ senderAddress: config.senderAddress,
2090
2195
  groupAddress: config.groupAddress,
2091
2196
  port: config.port,
2092
2197
  mode: config.mode,
@@ -2116,9 +2221,70 @@ export class MulticastManager {
2116
2221
  packetIds: config.packetIds,
2117
2222
  tracks: catalogTracks?.map(({ name, packetId }) => ({ name, packetId })),
2118
2223
  };
2119
- return ingress === undefined
2224
+ if (provenance !== undefined) this.#joinProvenance(provenance, ingress as MmtpIngressContext & { flow: string }, transportConfig);
2225
+ const protectedJoin = this.#provenanceJoin;
2226
+ const joined = await (ingress === undefined
2120
2227
  ? this.#transport.join(transportConfig)
2121
- : this.#transport.join(transportConfig, ingress);
2228
+ : this.#transport.join(transportConfig, ingress));
2229
+ // No media: leave the manifest subscription and release the flow now, not at the next join.
2230
+ if (!joined && protectedJoin !== null && this.#provenanceJoin === protectedJoin) this.#releaseProvenanceJoin();
2231
+ return joined;
2232
+ }
2233
+
2234
+ /**
2235
+ * Register the protected flow and subscribe its `manifestPort` on the same
2236
+ * `(S, G)` with its own listener (draft -01 7.2.7), so records reach the
2237
+ * flow's receiver and media datagrams are held until one admits them. A
2238
+ * manifest subscription that fails to join admits nothing: the media is
2239
+ * held and expires, never read unauthenticated.
2240
+ */
2241
+ #joinProvenance(
2242
+ provenance: MulticastProvenanceJoin,
2243
+ ingress: MmtpIngressContext & { flow: string },
2244
+ media: MulticastTransportConfig,
2245
+ ): void {
2246
+ const { flow, auth } = provenance;
2247
+ const receiver = new BcProvenanceV2Receiver({
2248
+ ...provenance.receiver,
2249
+ // The caller's ctx when it passes one, else the catalog's: an explicit
2250
+ // `catalogCtx: undefined` must not switch off the 7.2.4 ctx check.
2251
+ catalogCtx: provenance.receiver?.catalogCtx ?? flow.ctx,
2252
+ endpoint: {
2253
+ sourceAddress: flow.source,
2254
+ groupAddress: flow.groupAddress,
2255
+ port: flow.port,
2256
+ manifestPort: flow.manifestPort,
2257
+ streamId: flow.streamId,
2258
+ },
2259
+ keys: auth.keys,
2260
+ minEpoch: auth.minEpoch,
2261
+ bandwidth: flow.bandwidth,
2262
+ maxAuthDelayMs: flow.maxAuthDelayMs,
2263
+ manifestTimerMs: flow.manifestTimerMs,
2264
+ });
2265
+ this.#router.registerProvenanceFlow(ingress, receiver);
2266
+ this.#hookFailback();
2267
+ const manifest = new MulticastTransport((record) => {
2268
+ if (this.#provenanceJoin?.manifest === manifest) {
2269
+ this.#router.feedProvenanceRecord(ingress.flow, record, "multicast");
2270
+ }
2271
+ });
2272
+ this.#provenanceJoin = { flow: ingress.flow, manifest };
2273
+ const { packetIds: _packetIds, tracks: _tracks, ...path } = media;
2274
+ void manifest.join({ ...path, port: flow.manifestPort }).then((joined) => {
2275
+ if (!joined && this.#provenanceJoin?.manifest === manifest) {
2276
+ console.warn(`[MulticastManager] manifestPort ${flow.groupAddress}:${flow.manifestPort} not joined`, manifest.lastJoinOutcome);
2277
+ }
2278
+ });
2279
+ }
2280
+
2281
+ /** Un-protect: drop the flow's hold queues (no expiry counted) and leave its manifestPort. */
2282
+ #releaseProvenanceJoin(): void {
2283
+ const joined = this.#provenanceJoin;
2284
+ if (joined === null) return;
2285
+ this.#provenanceJoin = null;
2286
+ joined.manifest.dispose();
2287
+ this.#router.releaseProvenanceFlow(joined.flow);
2122
2288
  }
2123
2289
 
2124
2290
  /**
@@ -2153,9 +2319,10 @@ export class MulticastManager {
2153
2319
  for (const endpoint of endpoints) {
2154
2320
  const ingress: MmtpIngressContext = {
2155
2321
  tracks: Object.freeze(endpoint.tracks.map(({ name, packetId }) => Object.freeze({ name, packetId }))),
2322
+ ...(endpoint.flow === undefined ? {} : { flow: endpoint.flow }),
2156
2323
  };
2157
2324
  const onPacket = (packet: Uint8Array) => {
2158
- if (!released) this.#router.feedPacket(packet, "multicast", undefined, ingress);
2325
+ if (!released) this.#router.feedPacket(packet, "multicast", ingress);
2159
2326
  };
2160
2327
  const fail = (error: unknown) =>
2161
2328
  console.warn(`[MulticastManager] repair group ${endpoint.group}:${endpoint.port} not joined`, error);
@@ -2323,7 +2490,7 @@ export class MulticastManager {
2323
2490
  for (const track of [...live.tracks, ...request.tracks]) {
2324
2491
  stagedTracks.set(`${track.packetId}\0${track.name}`, track);
2325
2492
  }
2326
- const stagedIngress = freezeIngress(stagedTracks.values());
2493
+ const stagedIngress = freezeIngress(stagedTracks.values(), live.flow);
2327
2494
  const stagedPacketIds =
2328
2495
  committedPacketIds === undefined || livePacketIds === null
2329
2496
  ? null
@@ -2338,7 +2505,7 @@ export class MulticastManager {
2338
2505
  liveIngress: live,
2339
2506
  livePacketIds,
2340
2507
  widened,
2341
- committedIngress: freezeIngress(request.tracks),
2508
+ committedIngress: freezeIngress(request.tracks, live.flow),
2342
2509
  committedPacketIds,
2343
2510
  router: null,
2344
2511
  state: "staged",
@@ -2352,7 +2519,7 @@ export class MulticastManager {
2352
2519
  let widening: Promise<void> | undefined;
2353
2520
  try {
2354
2521
  if (widened) widening = this.#transport.setPacketIds([...stagedPacketIds!]);
2355
- this.#transport.setContext(stagedIngress);
2522
+ this.#setSwitchIngress(stagedIngress);
2356
2523
  } catch (error) {
2357
2524
  active.router.cancel();
2358
2525
  throw error;
@@ -2374,6 +2541,12 @@ export class MulticastManager {
2374
2541
  };
2375
2542
  }
2376
2543
 
2544
+ /** Route a switch's ingress and keep a protected flow's registration in step (7.2.7). */
2545
+ #setSwitchIngress(ingress: MmtpIngressContext): void {
2546
+ this.#transport.setContext(ingress);
2547
+ if (ingress.flow !== undefined) this.#router.updateProvenanceFlowIngress({ ...ingress, flow: ingress.flow });
2548
+ }
2549
+
2377
2550
  /** The router committed the staged switch (it already retired `from`). */
2378
2551
  #commitRenditionSwitch(active: ActiveRenditionSwitch, commit: StreamSwitchCommit): void {
2379
2552
  if (this.#renditionSwitch !== active) return;
@@ -2381,7 +2554,7 @@ export class MulticastManager {
2381
2554
  active.state = "committed";
2382
2555
  active.router = null;
2383
2556
  if (this.#transport.joined) {
2384
- this.#transport.setContext(active.committedIngress);
2557
+ this.#setSwitchIngress(active.committedIngress);
2385
2558
  if (active.committedPacketIds !== undefined) {
2386
2559
  // The page-side filter narrows now; the provider may keep delivering
2387
2560
  // the retired IDs until it applies the narrower list, and the page
@@ -2429,7 +2602,7 @@ export class MulticastManager {
2429
2602
  active.router = null;
2430
2603
  if (restore) {
2431
2604
  if (this.#transport.joined) {
2432
- this.#transport.setContext(active.liveIngress);
2605
+ this.#setSwitchIngress(active.liveIngress);
2433
2606
  if (active.widened && active.livePacketIds !== null) {
2434
2607
  this.#transport.setPacketIds([...active.livePacketIds]).catch((error: unknown) => {
2435
2608
  console.warn(
@@ -2518,8 +2691,8 @@ export class MulticastManager {
2518
2691
  data: Uint8Array,
2519
2692
  packetId: number,
2520
2693
  catalogTrackName?: string,
2521
- ) {
2522
- this.#router.routeRecoveredBlock(blockId, data, packetId, catalogTrackName);
2694
+ ): boolean {
2695
+ return this.#router.routeRecoveredBlock(blockId, data, packetId, catalogTrackName);
2523
2696
  }
2524
2697
 
2525
2698
  /** Route one Worker-recovered source symbol through the MMTP parser + dedup pipeline. */
@@ -2529,8 +2702,8 @@ export class MulticastManager {
2529
2702
  packetId: number,
2530
2703
  esi = 0,
2531
2704
  catalogTrackName?: string,
2532
- ) {
2533
- this.#router.routeRecoveredSymbol(blockId, data, packetId, esi, catalogTrackName);
2705
+ ): boolean {
2706
+ return this.#router.routeRecoveredSymbol(blockId, data, packetId, esi, catalogTrackName);
2534
2707
  }
2535
2708
 
2536
2709
  // ---------------------------------------------------------------------------
@@ -2719,6 +2892,18 @@ export class MulticastManager {
2719
2892
  }
2720
2893
  }
2721
2894
  }
2895
+ // A factory also supplies the one-shot scratch decoder an unlisted-assisted
2896
+ // decode on a governed instance runs in (7.2.7 step 2); without one the
2897
+ // router drops unlisted repair there.
2898
+ const factory = typeof wasmDecoderSource === "function" ? wasmDecoderSource : null;
2899
+ const fecTracks = fecTrackIndexes.map((i) => catalog.tracks[i]!);
2900
+ this.#router.setScratchFecDecoderFactory(factory && ((ownerPacketId, catalogTrackName) => {
2901
+ const track = fecTracks.find((t) => t.packetId === ownerPacketId && t.name === catalogTrackName);
2902
+ if (!track) return null;
2903
+ const decoder = factory(track);
2904
+ // A memoizing factory would hand back a live decoder the router then frees.
2905
+ return [...fecDecoders.values()].includes(decoder) ? null : decoder;
2906
+ }));
2722
2907
 
2723
2908
  // Reset switching-set index, repair packetId set and FEC availability.
2724
2909
  this.#switchingSets.clear();
@@ -2946,6 +3131,8 @@ export class MulticastManager {
2946
3131
  if (reason === "rtt-unmeasured") stats.repairsSkippedRttUnmeasured++;
2947
3132
  else if (reason === "rtt-exceeds-budget") stats.repairsSkippedRttExceedsBudget++;
2948
3133
  else if (reason === "range-no-anchor") stats.repairsSkippedRangeNoAnchor++;
3134
+ else if (reason === "group-cannot-complete") stats.repairsSkippedGroupCannotComplete++;
3135
+ else if (reason === "moot-rap") stats.repairsSkippedMootRap++;
2949
3136
  else stats.repairsSkippedPresentationDelayUnavailable++;
2950
3137
  stats.lastRepairSkip = {
2951
3138
  reason,
@@ -3027,6 +3214,37 @@ export class MulticastManager {
3027
3214
  });
3028
3215
  }
3029
3216
 
3217
+ /** Record a running repair FETCH the scheduler stopped, and why. */
3218
+ noteRepairStopped(
3219
+ reason: RepairStopReason,
3220
+ sbn: number,
3221
+ packetId: number,
3222
+ catalogTrackName?: string,
3223
+ ): void {
3224
+ this.#statsFor(packetId, catalogTrackName).repairStops[reason]++;
3225
+ this.#emitRepairLifecycle({
3226
+ version: 2,
3227
+ type: "repair-stopped",
3228
+ block: repairBlockIdentity(packetId, sbn, catalogTrackName),
3229
+ reason,
3230
+ });
3231
+ }
3232
+
3233
+ /**
3234
+ * Whether a complete RAP above SS_ID `ssId` reached the stream's in-order
3235
+ * release gate (draft-ramadan-moq-fec 11.4.2: repair below it is moot).
3236
+ */
3237
+ rapCompleteAbove(packetId: number, catalogTrackName: string | undefined, ssId: number): boolean {
3238
+ return this.#router.fecReleaseRapAbove(packetId, catalogTrackName, ssId);
3239
+ }
3240
+
3241
+ /** Observe each complete RAP reaching a release gate (synchronous: defer FEC work). */
3242
+ addRapCompleteObserver(
3243
+ observer: (packetId: number, catalogTrackName: string | undefined, ssId: number) => void,
3244
+ ): () => void {
3245
+ return this.#router.addFecRapObserver(observer);
3246
+ }
3247
+
3030
3248
  /** Catalog/reset epoch of a packet's streams; gap decisions carry it. */
3031
3249
  epochFor(packetId: number): number {
3032
3250
  return this.#fec.epochFor(packetId);
@@ -3043,6 +3261,7 @@ export class MulticastManager {
3043
3261
  if (stats.epoch !== this.#fec.epochFor(stats.packetId)) continue;
3044
3262
  out[key] = {
3045
3263
  ...stats,
3264
+ repairStops: { ...stats.repairStops },
3046
3265
  lastRepairSkip: stats.lastRepairSkip === null ? null : { ...stats.lastRepairSkip },
3047
3266
  };
3048
3267
  }
@@ -3196,6 +3415,7 @@ export class MulticastManager {
3196
3415
  this.#trackResetObservers.clear();
3197
3416
  this.#repairLifecycleObservers.clear();
3198
3417
  this.#transport.dispose();
3418
+ this.#releaseProvenanceJoin();
3199
3419
  for (const release of [...this.#repairReleases.keys()]) release();
3200
3420
  this.#router.dispose();
3201
3421
  this.#fec.dispose();
@@ -3206,3 +3426,8 @@ export class MulticastManager {
3206
3426
  // (wireRepairFetch) drives.
3207
3427
  const repairFetchHost = (manager: MulticastManager): RepairFetchHost => manager;
3208
3428
  void repairFetchHost;
3429
+
3430
+ /** The router's key of a protected flow: its `(S, G, port)`. */
3431
+ export function multicastProvenanceFlowKey(flow: Pick<ResolvedMulticastAuthFlow, "source" | "groupAddress" | "port">): string {
3432
+ return JSON.stringify([flow.source, flow.groupAddress, flow.port]);
3433
+ }
@@ -19,7 +19,10 @@ import {
19
19
  } from '@blockcast/mmt-base'
20
20
 
21
21
  export interface MulticastTransportConfig {
22
- sourceAddress: string
22
+ /** SSM source; omitted on ASM. */
23
+ sourceAddress?: string
24
+ /** ASM sender filter (multicast-contract 1.1.0 `senderAddress`); never beside `sourceAddress`. */
25
+ senderAddress?: string
23
26
  groupAddress: string
24
27
  port: number
25
28
  mode?: 'auto' | 'native' | 'tunnel'
@@ -368,10 +371,11 @@ export class MulticastTransport<TPacketContext = never> {
368
371
  try {
369
372
  const packetIds = config.packetIds?.length ? new Set(config.packetIds) : null
370
373
  console.log(
371
- `[MulticastTransport] window.multicast.subscribe(${config.sourceAddress}, ${config.groupAddress}, ${config.port})`,
374
+ `[MulticastTransport] window.multicast.subscribe(${config.sourceAddress ?? config.senderAddress ?? '*'}, ${config.groupAddress}, ${config.port})`,
372
375
  )
373
376
  const subscribeConfig = toWindowMulticastSubscribeConfig({
374
377
  source: config.sourceAddress,
378
+ senderAddress: config.senderAddress,
375
379
  group: config.groupAddress,
376
380
  port: config.port,
377
381
  mode: config.mode,
package/src/no-modules.ts CHANGED
@@ -19,9 +19,4 @@ export { MmtpToCmaf } from './mmtp-to-cmaf.js'
19
19
  // reach only this classic-script surface, so it has to be exported here as
20
20
  // well as from the barrel (BLO-49943).
21
21
  export { isFmp4InitSegment } from './codec-utils.js'
22
- export { createManifestAuthPlan } from './manifest-auth-plan.js'
23
- export {
24
- ManifestVerifier,
25
- createWebCryptoEd25519Verifier,
26
- manifestBindingId,
27
- } from '@blockcast/mmt-manifest-verify'
22
+ export { BcProvenanceV2Receiver } from '@blockcast/mmt-manifest-verify'