@blockcast/mmt-container 0.5.0 → 1.0.0-main.3cb7359ea316

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 (136) hide show
  1. package/README.md +21 -16
  2. package/dist/browser-runtime.js +1024 -1078
  3. package/dist/browser-runtime.js.map +4 -4
  4. package/dist/carp-catalog.d.ts +33 -87
  5. package/dist/carp-catalog.d.ts.map +1 -1
  6. package/dist/carp-catalog.js +12 -434
  7. package/dist/carp-catalog.js.map +4 -4
  8. package/dist/catalog-fec-params.d.ts +48 -161
  9. package/dist/catalog-fec-params.d.ts.map +1 -1
  10. package/dist/catalog-fec-params.js +567 -592
  11. package/dist/catalog-fec-params.js.map +4 -4
  12. package/dist/catalog-network-source.js +6 -2
  13. package/dist/catalog-network-source.js.map +3 -3
  14. package/dist/delivery-catalog.js +12 -4
  15. package/dist/delivery-catalog.js.map +4 -4
  16. package/dist/delta-fallback-config.d.ts +21 -26
  17. package/dist/delta-fallback-config.d.ts.map +1 -1
  18. package/dist/delta-fallback-config.js +582 -24
  19. package/dist/delta-fallback-config.js.map +4 -4
  20. package/dist/fec-manager.js +6 -2
  21. package/dist/fec-manager.js.map +3 -3
  22. package/dist/fec-state.d.ts +5 -43
  23. package/dist/fec-state.d.ts.map +1 -1
  24. package/dist/fec-state.js +529 -34
  25. package/dist/fec-state.js.map +4 -4
  26. package/dist/fec-worker-setup.js +6 -2
  27. package/dist/fec-worker-setup.js.map +3 -3
  28. package/dist/held-layer-admission.js +6 -2
  29. package/dist/held-layer-admission.js.map +3 -3
  30. package/dist/index.d.ts +3 -3
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +5 -39
  33. package/dist/index.js.map +2 -2
  34. package/dist/iso-block-locator.js +6 -2
  35. package/dist/iso-block-locator.js.map +3 -3
  36. package/dist/mfu-reassembler.js +6 -2
  37. package/dist/mfu-reassembler.js.map +3 -3
  38. package/dist/mmtp-container-parser.js +6 -2
  39. package/dist/mmtp-container-parser.js.map +3 -3
  40. package/dist/mmtp-container-parser.no-modules.js +551 -452
  41. package/dist/mmtp-container-parser.no-modules.js.map +4 -4
  42. package/dist/mmtp-decode-pipeline.js +6 -2
  43. package/dist/mmtp-decode-pipeline.js.map +3 -3
  44. package/dist/mmtp-fec-session.d.ts +7 -6
  45. package/dist/mmtp-fec-session.d.ts.map +1 -1
  46. package/dist/mmtp-fec-session.js +13 -9
  47. package/dist/mmtp-fec-session.js.map +3 -3
  48. package/dist/mmtp-router.d.ts +7 -16
  49. package/dist/mmtp-router.d.ts.map +1 -1
  50. package/dist/mmtp-router.js +19 -29
  51. package/dist/mmtp-router.js.map +3 -3
  52. package/dist/multicast-auth-catalog.js +7 -3
  53. package/dist/multicast-auth-catalog.js.map +3 -3
  54. package/dist/multicast-input.js +7 -3
  55. package/dist/multicast-input.js.map +3 -3
  56. package/dist/multicast-manager.d.ts +29 -32
  57. package/dist/multicast-manager.d.ts.map +1 -1
  58. package/dist/multicast-manager.js +682 -253
  59. package/dist/multicast-manager.js.map +4 -4
  60. package/dist/multicast-transport.d.ts +23 -2
  61. package/dist/multicast-transport.d.ts.map +1 -1
  62. package/dist/multicast-transport.js +57 -16
  63. package/dist/multicast-transport.js.map +4 -4
  64. package/dist/multicast-unwrap.js +6 -2
  65. package/dist/multicast-unwrap.js.map +3 -3
  66. package/dist/no-modules.js +551 -452
  67. package/dist/no-modules.js.map +4 -4
  68. package/dist/phase14-catalog-override.d.ts +10 -5
  69. package/dist/phase14-catalog-override.d.ts.map +1 -1
  70. package/dist/phase14-catalog-override.js +35 -11
  71. package/dist/phase14-catalog-override.js.map +2 -2
  72. package/dist/repair-fetch.d.ts +3 -1
  73. package/dist/repair-fetch.d.ts.map +1 -1
  74. package/dist/repair-fetch.js +16 -3
  75. package/dist/repair-fetch.js.map +3 -3
  76. package/dist/repair-layers.d.ts +0 -123
  77. package/dist/repair-layers.d.ts.map +1 -1
  78. package/dist/repair-layers.js +1 -349
  79. package/dist/repair-layers.js.map +3 -3
  80. package/dist/signaling.js +6 -2
  81. package/dist/signaling.js.map +3 -3
  82. package/dist/timestamp-codec.js +6 -2
  83. package/dist/timestamp-codec.js.map +3 -3
  84. package/dist/wasm-mfu-reassembler.js +6 -2
  85. package/dist/wasm-mfu-reassembler.js.map +3 -3
  86. package/dist/wasm-mmtp-container-parser.js +6 -2
  87. package/dist/wasm-mmtp-container-parser.js.map +3 -3
  88. package/package.json +9 -5
  89. package/schemas/catalog.schema.json +53 -58
  90. package/src/carp-catalog.ts +39 -229
  91. package/src/catalog-fec-params.ts +130 -493
  92. package/src/delta-fallback-config.ts +63 -50
  93. package/src/fec-state.ts +7 -62
  94. package/src/index.ts +10 -30
  95. package/src/mmtp-fec-session.ts +11 -10
  96. package/src/mmtp-router.ts +14 -29
  97. package/src/multicast-manager.ts +89 -121
  98. package/src/multicast-transport.ts +65 -12
  99. package/src/phase14-catalog-override.ts +67 -16
  100. package/src/repair-fetch.ts +27 -3
  101. package/src/repair-layers.ts +7 -520
  102. package/test-vectors/catalog/README.md +39 -67
  103. package/test-vectors/catalog/fec-01-runtime/README.md +15 -0
  104. package/test-vectors/catalog/{negative/fec-block-profile-retired.json → fec-01-runtime/fec-iso-ssbg1.json} +1 -1
  105. package/test-vectors/catalog/fec-01-runtime/fec-layered-repair.json +106 -0
  106. package/test-vectors/catalog/{negative/fec-block-profile-unknown.json → fec-01-runtime/fec-multicast.json} +2 -1
  107. package/test-vectors/catalog/fec-01-runtime/multicast-native-ssm-no-network-source.json +44 -0
  108. package/test-vectors/catalog/negative/fec-block-profile-missing.json +24 -11
  109. package/test-vectors/catalog/negative/fec-iso-symbol-size-below-floor.json +13 -8
  110. package/test-vectors/catalog/negative/fec-reed-solomon-iso-symbol-size-13.json +13 -8
  111. package/test-vectors/catalog/negative/fec-repair-layer-geometry.json +20 -10
  112. package/test-vectors/catalog/negative/fec-repair-layer-missing-symbols.json +20 -10
  113. package/test-vectors/catalog/negative/fec-repair-overlay-source-symbols-zero.json +25 -17
  114. package/test-vectors/catalog/negative/fec-repair-symbols-zero.json +13 -8
  115. package/test-vectors/catalog/negative/fec-source-symbols-zero.json +13 -8
  116. package/test-vectors/catalog/negative/fec-symbol-size-zero.json +13 -8
  117. package/test-vectors/catalog/negative/raptorq-source-symbols-over-max.json +18 -9
  118. package/test-vectors/catalog/negative/raptorq-unaligned-symbol.json +13 -8
  119. package/test-vectors/catalog/positive/fec-algorithm-unimplemented.json +14 -9
  120. package/test-vectors/catalog/positive/fec-iso-ssbg1.json +25 -12
  121. package/test-vectors/catalog/positive/fec-layered-repair.json +79 -35
  122. package/test-vectors/catalog/positive/fec-multicast.json +26 -13
  123. package/test-vectors/catalog/positive/fec-reed-solomon-layered.json +29 -18
  124. package/test-vectors/catalog/positive/multicast-draft01-4-1-example.json +28 -20
  125. package/test-vectors/catalog/positive/multicast-draft01-7-2-6-example.json +14 -10
  126. package/test-vectors/catalog/positive/multicast-native-ssm-no-network-source.json +22 -12
  127. package/test-vectors/catalog/section-codes.json +13 -22
  128. package/test-vectors/catalog-timing.json +2 -2
  129. package/test-vectors/catalog-validation.json +30 -243
  130. package/test-vectors/catalog/negative/fec-enhancement-repair-removed.json +0 -28
  131. package/test-vectors/catalog/negative/fec-reed-solomon-k-plus-p-over-255.json +0 -39
  132. package/test-vectors/catalog/negative/fec-reed-solomon-overlay-over-255.json +0 -40
  133. package/test-vectors/catalog/negative/fec-repair-multiple-depends.json +0 -32
  134. package/test-vectors/catalog/negative/fec-repair-overlay-with-layer.json +0 -34
  135. package/test-vectors/catalog/negative/fec-repair-priority-out-of-band.json +0 -24
  136. package/test-vectors/catalog/negative/fec-repair-source-symbols-without-scope.json +0 -33
@@ -8,7 +8,7 @@
8
8
  * - MulticastTransport — SSM join/socket/watchdog
9
9
  */
10
10
 
11
- import { parseFecBlockProfile, type FecBlockProfile } from "@blockcast/mmt-base";
11
+ import { parseFecBlockProfile, resolveFecBindings, type FecBlockProfile, type FecSourceBinding } from "@blockcast/mmt-base";
12
12
  import {
13
13
  FECManager,
14
14
  MIN_INTERLEAVE_DEPTH_MS,
@@ -79,12 +79,9 @@ import {
79
79
  hasSwitchingSetRole,
80
80
  parseTrackRole,
81
81
  validateSwitchingSet,
82
- isFecCatalogConfig,
83
- isFecCatalogGeometry,
84
- type FecCatalogConfig,
85
82
  type TrackRole,
86
83
  } from "./carp-catalog.js";
87
- import { catalogFecUnavailable, type TrackFecUnavailable } from "./catalog-fec-params.js";
84
+ import type { TrackFecUnavailable } from "./catalog-fec-params.js";
88
85
 
89
86
  /**
90
87
  * One dedicated repair group to join beside the media join: the shape
@@ -110,11 +107,13 @@ export interface MulticastRepairEndpoint {
110
107
  /**
111
108
  * Opens one repair-group subscription through the caller's own provider (a
112
109
  * hang bridge, for one): delivers each packet to `onPacket` and returns (or
113
- * resolves to) the call that releases it.
110
+ * resolves to) the call that releases it. Pass each datagram's sender as
111
+ * `sourceAddress` when known: the group then admits only its first sender,
112
+ * as MulticastTransport does (`packetsDroppedForeignSender`).
114
113
  */
115
114
  export type MulticastRepairSubscribe = (
116
115
  endpoint: MulticastRepairEndpoint,
117
- onPacket: (packet: Uint8Array) => void,
116
+ onPacket: (packet: Uint8Array, sourceAddress?: string) => void,
118
117
  ) => (() => void) | Promise<() => void>;
119
118
 
120
119
  /**
@@ -1092,10 +1091,12 @@ export interface MultiTrackCatalogTrack {
1092
1091
  /** `"fec-repair"` marks a self-describing repair track (draft-ramadan-moq-fec
1093
1092
  * §6.3). Absent on media tracks and on naming-era catalogs. */
1094
1093
  packaging?: string;
1095
- /** The one source track this repair track carries repair symbols for. Layers
1096
- * 1..n and a keyframe overlay are reachable only through `depends`; layer 0
1097
- * is additionally named by the source's `fec.repairTrack`. */
1098
- depends?: string[];
1094
+ /** The source track this repair track carries repair symbols for
1095
+ * (draft-ramadan-moq-fec-catalog-00 3.2), with `instanceId` naming the
1096
+ * instance (default 0). Layer 0 is also named by the instance's
1097
+ * `repairTrack`. */
1098
+ source?: string;
1099
+ instanceId?: number;
1099
1100
  /** Base layer index. Absent on a keyframe overlay, which is a separate FEC
1100
1101
  * instance rather than a member of the layer chain. */
1101
1102
  repairLayer?: number;
@@ -1229,47 +1230,40 @@ function altGroupKey(track: MultiTrackCatalogTrack): string {
1229
1230
  }
1230
1231
 
1231
1232
  /**
1232
- * Every repair track of each source: layer 0 named by the source's
1233
- * `fec.repairTrack`, plus every self-describing `packaging: "fec-repair"` track
1234
- * whose `depends` names that source (layers 1..n and a keyframe overlay), per
1235
- * draft-ramadan-moq-fec §6.3.
1236
- *
1237
- * Membership comes from the explicit pointer and from `depends`, never from a
1238
- * name suffix, so a publisher-chosen name like "parity-alpha" resolves.
1239
- *
1240
- * This deliberately does not call `resolveRepairLayers`: that validates the
1241
- * full layer contract and requires `packaging: "mmtp"` on the source plus a
1242
- * declared layer-0 repair track, neither of which a `MultiTrackCatalog`
1243
- * carries. Running it here would throw on every naming-era catalog. Ownership
1244
- * registration needs membership only; RS_ID offsets and layer ordering are the
1245
- * decoder's concern, not the router's.
1233
+ * Every repair track each source names or is named by: each `fec` instance's
1234
+ * `repairTrack`, plus every `packaging: "fec-repair"` track whose `source`
1235
+ * names it (draft-ramadan-moq-fec-catalog-00 3.2). Membership only, so a
1236
+ * source whose FEC is not `ok` still keeps its legs roled `repair`; which legs
1237
+ * get an owner is the source's binding's (`resolveFecBindings`). Never from a
1238
+ * name suffix.
1246
1239
  */
1247
1240
  function repairTrackNamesBySource(
1248
1241
  tracks: readonly MultiTrackCatalogTrack[],
1249
1242
  ): Map<string, Set<string>> {
1250
1243
  const bySource = new Map<string, Set<string>>();
1251
- const add = (source: string, repair: string): void => {
1252
- if (!source || !repair) return;
1244
+ const add = (source: unknown, repair: unknown): void => {
1245
+ if (typeof source !== "string" || typeof repair !== "string" || !source || !repair) return;
1253
1246
  const names = bySource.get(source) ?? new Set<string>();
1254
1247
  names.add(repair);
1255
1248
  bySource.set(source, names);
1256
1249
  };
1257
1250
  for (const track of tracks) {
1258
- // The explicit pointer counts whether or not the source's FEC config is
1259
- // usable (an unimplemented block profile leaves the track without FEC,
1260
- // not without its repair track): ownership is membership, not
1261
- // decodability, so no isFecCatalogConfig gate here.
1262
- const repairTrack = typeof track.fec === "object" && track.fec !== null
1263
- ? (track.fec as { readonly repairTrack?: unknown }).repairTrack
1264
- : undefined;
1265
- if (typeof repairTrack === "string") add(track.name, repairTrack);
1266
- if (track.packaging === "fec-repair" && Array.isArray(track.depends) && track.depends.length === 1) {
1267
- add(track.depends[0]!, track.name);
1251
+ if (Array.isArray(track.fec)) {
1252
+ for (const instance of track.fec) add(track.name, (instance as { repairTrack?: unknown } | null)?.repairTrack);
1268
1253
  }
1254
+ if (track.packaging === "fec-repair") add(track.source, track.name);
1269
1255
  }
1270
1256
  return bySource;
1271
1257
  }
1272
1258
 
1259
+ /** The repair tracks an `ok` binding joins: base layers, then the overlay. */
1260
+ function boundRepairNames(binding: FecSourceBinding | undefined): string[] {
1261
+ if (binding?.status !== "ok") return [];
1262
+ const names = binding.layers.map((layer) => layer.repairTrackName);
1263
+ if (binding.overlay) names.push(binding.overlay.repairTrackName);
1264
+ return names;
1265
+ }
1266
+
1273
1267
  export class MulticastManager {
1274
1268
  readonly #fec: FECManager;
1275
1269
  readonly #router: MmtpRouter;
@@ -2032,10 +2026,6 @@ export class MulticastManager {
2032
2026
  onTrackPacket(packetId: number, cb: TrackPacketCallback) {
2033
2027
  this.#router.onTrackPacket(packetId, cb);
2034
2028
  }
2035
- /** @deprecated Use onTrackInit(packetId, cb); see MmtpRouter.onInitSegment. */
2036
- onInitSegment(cb: (data: Uint8Array) => void) {
2037
- this.#router.onInitSegment(cb);
2038
- }
2039
2029
  onTrackInit(packetId: number, cb: (data: Uint8Array) => void) {
2040
2030
  this.#router.onTrackInit(packetId, cb);
2041
2031
  }
@@ -2321,8 +2311,13 @@ export class MulticastManager {
2321
2311
  tracks: Object.freeze(endpoint.tracks.map(({ name, packetId }) => Object.freeze({ name, packetId }))),
2322
2312
  ...(endpoint.flow === undefined ? {} : { flow: endpoint.flow }),
2323
2313
  };
2324
- const onPacket = (packet: Uint8Array) => {
2325
- if (!released) this.#router.feedPacket(packet, "multicast", ingress);
2314
+ let sender: string | undefined;
2315
+ const onPacket = (packet: Uint8Array, sourceAddress?: string) => {
2316
+ if (released) return;
2317
+ // The media join's sender owns the repair too; this group's own first
2318
+ // sender stands in only until the media join has one.
2319
+ if (sourceAddress !== undefined && sourceAddress !== (this.#transport.sender ?? (sender ??= sourceAddress))) return;
2320
+ this.#router.feedPacket(packet, "multicast", ingress);
2326
2321
  };
2327
2322
  const fail = (error: unknown) =>
2328
2323
  console.warn(`[MulticastManager] repair group ${endpoint.group}:${endpoint.port} not joined`, error);
@@ -2332,7 +2327,10 @@ export class MulticastManager {
2332
2327
  .then((release) => (released ? safely(release) : releases.push(release)), fail);
2333
2328
  continue;
2334
2329
  }
2335
- const transport = new MulticastTransport((packet) => onPacket(packet), { gateway: provider });
2330
+ const transport = new MulticastTransport((packet) => onPacket(packet), {
2331
+ gateway: provider,
2332
+ lockedTo: () => this.#transport.sender,
2333
+ });
2336
2334
  releases.push(() => transport.dispose());
2337
2335
  void transport.join({
2338
2336
  sourceAddress: endpoint.source,
@@ -2663,8 +2661,8 @@ export class MulticastManager {
2663
2661
  * transport attribution in FEC path counters.
2664
2662
  * The `router` accessor above is already public.
2665
2663
  */
2666
- feedPacket(data: ArrayBuffer | Uint8Array, ingress?: MmtpIngressContext) {
2667
- this.#transport.feedPacket(data, ingress);
2664
+ feedPacket(data: ArrayBuffer | Uint8Array, ingress?: MmtpIngressContext, sourceAddress?: string) {
2665
+ this.#transport.feedPacket(data, ingress, sourceAddress);
2668
2666
  }
2669
2667
 
2670
2668
  /**
@@ -2718,20 +2716,18 @@ export class MulticastManager {
2718
2716
  * D-17: Builds altGroup → track-name index for getSwitchingSet() (repair legs excluded).
2719
2717
  * D-26: Registers packetId → TrackRole on MmtpRouter for block-complete fanout.
2720
2718
  *
2721
- * FEC availability is per track (draft-ramadan-moq-fec §5.1). A media track
2722
- * whose `fec` names no implemented `blockProfile` (only `iso-ssbg1-v1` is
2723
- * implemented; the retired `k-padded-v1` is not), or an `iso-ssbg1-v1` span
2724
- * it cannot use, is consumed without FEC: it gets no decoder, and none of
2725
- * its repair tracks (layer 0 by `fec.repairTrack`, further layers and the
2726
- * overlay by `depends`) is given an owner, so the router drops their packets
2727
- * before any decoder. Those legs keep the `repair` role and their owner's
2728
- * switching set. Neither the call nor any other track's FEC fails on that
2729
- * account; `trackFecUnavailable(name)` reports the reason. FEC a previous
2730
- * call configured on a packetId this call does not is released
2731
- * (FECManager.releasePacket); frames its release gate held are delivered in
2732
- * order, each gap stalled. FEC wired by hand (setFecDecoder) is left alone.
2733
- * Geometry a decoder could not configure (K, P, T, `repairTrack`,
2734
- * algorithm) still throws.
2719
+ * FEC is per track and per instance (`resolveFecBindings`,
2720
+ * draft-ramadan-moq-fec-catalog-00 array, fec-core-00 10.3). A media track
2721
+ * whose base instance is not `ok` (an invalid instance, a profile other than
2722
+ * `iso-ssbg1-v1`, a draft-01 object `fec`) is consumed without FEC: it gets
2723
+ * no decoder, and none of its repair tracks is given an owner, so the router
2724
+ * drops their packets before any decoder. Those legs keep the `repair` role
2725
+ * and their owner's switching set. Neither the call nor any other track's
2726
+ * FEC fails on that account; `trackFecUnavailable(name)` reports the reason.
2727
+ * FEC a previous call configured on a packetId this call does not is
2728
+ * released (FECManager.releasePacket); frames its release gate held are
2729
+ * delivered in order, each gap stalled. FEC wired by hand (setFecDecoder) is
2730
+ * left alone.
2735
2731
  *
2736
2732
  * @param catalog - Multi-track catalog (tracks array with name, altGroup, fec, packetId).
2737
2733
  * @param wasmDecoderSource - One WASM FEC decoder for a single FEC stream, or
@@ -2756,44 +2752,27 @@ export class MulticastManager {
2756
2752
  // mis-roled or rejected on its name, and would land in its own altGroup
2757
2753
  // rather than its owner's.
2758
2754
  const repairNamesBySource = repairTrackNamesBySource(catalog.tracks);
2759
- // §5.1: classify each media track's FEC before roles are resolved. A track
2760
- // whose FEC is unavailable still names its layer 0 by `fec.repairTrack`;
2761
- // that pointer is a catalog fact about the leg, not about the profile, so
2762
- // the leg stays roled `repair` in its owner's group rather than falling
2763
- // through to its name. It is never given an owner below.
2764
- const declaredRepairNames = new Set<string>();
2765
- for (const names of repairNamesBySource.values()) {
2766
- for (const name of names) declaredRepairNames.add(name);
2767
- }
2755
+ // FEC per source, per instance (fec-core-00 10.3). A configureMultiTrack
2756
+ // catalog is MMTP by construction, so a media track that states no
2757
+ // `packaging` is read as `mmtp`.
2758
+ const bindings = resolveFecBindings({
2759
+ tracks: catalog.tracks.map((track) =>
2760
+ track.packaging === undefined ? { ...track, packaging: "mmtp" } : track,
2761
+ ),
2762
+ });
2768
2763
  const fecUnavailableByTrack = new Map<string, TrackFecUnavailable>();
2769
- for (let i = 0; i < catalog.tracks.length; i++) {
2770
- const track = catalog.tracks[i]!;
2771
- if (
2772
- track.fec === undefined ||
2773
- track.packaging === "fec-repair" ||
2774
- declaredRepairNames.has(track.name)
2775
- ) {
2776
- continue;
2777
- }
2778
- const fecUnavailable = catalogFecUnavailable(track.fec, `$.tracks[${i}].fec`);
2779
- if (fecUnavailable === undefined) continue;
2780
- fecUnavailableByTrack.set(track.name, fecUnavailable);
2781
- const layerZero = (track.fec as { repairTrack?: unknown }).repairTrack;
2782
- if (typeof layerZero === "string" && layerZero.length > 0) {
2783
- const names = repairNamesBySource.get(track.name) ?? new Set<string>();
2784
- names.add(layerZero);
2785
- repairNamesBySource.set(track.name, names);
2786
- }
2787
- }
2788
- // Every repair leg of a source without FEC goes unowned, including one a
2789
- // source with FEC also names: the router could not tell the two sources'
2790
- // symbols apart on it, and the unavailable source's must reach no decoder.
2791
- const unownedRepairNames = new Set<string>();
2792
- for (const sourceName of fecUnavailableByTrack.keys()) {
2793
- for (const repairName of repairNamesBySource.get(sourceName) ?? []) {
2794
- unownedRepairNames.add(repairName);
2795
- }
2796
- }
2764
+ for (const binding of bindings.values()) {
2765
+ if (binding.status === "unavailable") fecUnavailableByTrack.set(binding.track, binding.reason);
2766
+ }
2767
+ // Only the legs an `ok` binding joins get an owner. Every other named leg
2768
+ // (a source without FEC, a dropped overlay, a leg two sources name) keeps
2769
+ // its `repair` role and reaches no decoder.
2770
+ const ownedRepairNames = new Set<string>();
2771
+ const claimed = new Map<string, number>();
2772
+ for (const binding of bindings.values()) {
2773
+ for (const name of boundRepairNames(binding)) claimed.set(name, (claimed.get(name) ?? 0) + 1);
2774
+ }
2775
+ for (const [name, count] of claimed) if (count === 1) ownedRepairNames.add(name);
2797
2776
  const repairOwnerGroup = new Map<string, string>();
2798
2777
  for (const track of catalog.tracks) {
2799
2778
  for (const repairName of repairNamesBySource.get(track.name) ?? []) {
@@ -2854,17 +2833,8 @@ export class MulticastManager {
2854
2833
  const fecTrackIndexes: number[] = [];
2855
2834
  for (let i = 0; i < catalog.tracks.length; i++) {
2856
2835
  const track = catalog.tracks[i]!;
2857
- if (resolvedRoles[i] === "repair" || track.fec === undefined) continue;
2858
- // Malformed geometry fails the call whatever the profile says; an
2859
- // unavailable profile or span only costs this track its FEC (§5.1).
2860
- const unavailable = fecUnavailableByTrack.has(track.name);
2861
- if (!(unavailable ? isFecCatalogGeometry(track.fec) : isFecCatalogConfig(track.fec))) {
2862
- throw new Error(
2863
- `[MulticastManager] invalid fec config for track '${track.name}'`,
2864
- );
2865
- }
2866
- if (unavailable) continue;
2867
- if (track.packetId !== undefined) fecTrackIndexes.push(i);
2836
+ if (resolvedRoles[i] === "repair" || track.packetId === undefined) continue;
2837
+ if (bindings.get(track.name)?.status === "ok") fecTrackIndexes.push(i);
2868
2838
  }
2869
2839
 
2870
2840
  // Stateful decoders cannot span physical streams: resetting one stream
@@ -2966,15 +2936,16 @@ export class MulticastManager {
2966
2936
 
2967
2937
  // D-07: Apply per-track FEC config for non-repair tracks. A track whose
2968
2938
  // FEC is unavailable (§5.1) is consumed like a track without `fec`.
2969
- if (track.fec !== undefined && !fecUnavailableByTrack.has(track.name)) {
2970
- const fecCfg = track.fec as FecCatalogConfig;
2939
+ const binding = bindings.get(track.name);
2940
+ if (binding?.status === "ok") {
2941
+ const fecCfg = binding.base;
2971
2942
  // Every repair layer and the keyframe overlay route to this source's
2972
2943
  // decoder, not only layer 0. A layer whose packetId had no registered
2973
2944
  // owner would have its repair symbols dropped as an unknown owner, and
2974
2945
  // two sources whose RS_ID ranges overlap would be indistinguishable.
2975
2946
  if (track.packetId !== undefined) {
2976
- for (const repairName of repairNamesBySource.get(track.name) ?? []) {
2977
- if (unownedRepairNames.has(repairName)) continue;
2947
+ for (const repairName of boundRepairNames(binding)) {
2948
+ if (!ownedRepairNames.has(repairName)) continue;
2978
2949
  const repairTrack = catalogTracksByName.get(repairName);
2979
2950
  if (repairTrack?.packetId !== undefined) {
2980
2951
  this.#router.setRepairPacketIdOwner(repairTrack.packetId, track.packetId);
@@ -2988,7 +2959,7 @@ export class MulticastManager {
2988
2959
  // have no FEC in Phase 14 (D-26 covers video base+delta only). Callers that need
2989
2960
  // audio FEC can call setFecDecoder() directly after configureMultiTrack().
2990
2961
  const blockProfile = parseFecBlockProfile(
2991
- (fecCfg as { blockProfile?: unknown }).blockProfile,
2962
+ fecCfg.blockProfile,
2992
2963
  `tracks[${JSON.stringify(track.name)}].fec.blockProfile`,
2993
2964
  );
2994
2965
  this.#fec.setFecDecoder(
@@ -3021,14 +2992,11 @@ export class MulticastManager {
3021
2992
 
3022
2993
  /**
3023
2994
  * Why `configureMultiTrack` consumes the named catalog track without FEC
3024
- * although its `fec` block declares one (draft-ramadan-moq-fec §5.1): no
3025
- * implemented `blockProfile` (`track_fec_block_profile_unimplemented`, e.g.
3026
- * the retired `k-padded-v1`; `track_fec_block_profile` when absent), or an
3027
- * invalid `iso-ssbg1-v1` span (`track_fec`). Absent/zero spans keep in-band
3028
- * decoding enabled. The same reason the hang
3029
- * resolver reports (`catalogFecUnavailable`). Undefined for every other
3030
- * track, and for every track before the first call; each call replaces the
3031
- * previous call's answers.
2995
+ * although it declares some: its base instance is not `ok`, with the
2996
+ * validator's code and path (`TrackFecUnavailable`), the same reason the
2997
+ * hang resolver reports (`catalogTrackFecUnavailable`). Undefined for every
2998
+ * other track, and for every track before the first call; each call
2999
+ * replaces the previous call's answers.
3032
3000
  */
3033
3001
  trackFecUnavailable(trackName: string): TrackFecUnavailable | undefined {
3034
3002
  return this.#fecUnavailableByTrack.get(trackName);
@@ -133,6 +133,15 @@ export interface MulticastTransportStats {
133
133
  * `droppedPacketIds` slot.
134
134
  */
135
135
  packetsDroppedSignaling: number
136
+ /**
137
+ * From a sender other than the one this join locked to (BLO-50098). An
138
+ * unprotected ASM subscription admits any source, so two senders can share
139
+ * one (G, port, packet_id); the first sender whose packet is accepted owns
140
+ * the sub-flow until the next join, and every other sender is dropped here,
141
+ * before any gap, duplicate or FEC ledger sees it. A join or a silence
142
+ * failback releases the lock.
143
+ */
144
+ packetsDroppedForeignSender: number
136
145
  }
137
146
 
138
147
  /**
@@ -197,6 +206,12 @@ function hasMulticastGateway(): boolean {
197
206
  return typeof getMulticastGlobals().multicast?.subscribe === 'function'
198
207
  }
199
208
 
209
+ /** The `bytes` event's sender (multicast-contract 1.3.0); undefined when unknown. */
210
+ function senderOf(event: CustomEvent<unknown>): string | undefined {
211
+ const sourceAddress = (event as { sourceAddress?: unknown }).sourceAddress
212
+ return typeof sourceAddress === 'string' ? sourceAddress : undefined
213
+ }
214
+
200
215
  /**
201
216
  * The packet one `bytes` event (or one element of a `packets` batch) carries,
202
217
  * as a view of the provider's own bytes. A Uint8Array is used as it is; the
@@ -293,6 +308,7 @@ export class MulticastTransport<TPacketContext = never> {
293
308
  packetsDroppedUnregisteredPid: 0,
294
309
  packetsDroppedUnparseable: 0,
295
310
  packetsDroppedSignaling: 0,
311
+ packetsDroppedForeignSender: 0,
296
312
  }
297
313
  // join() calls by how they resolved; see joinStats.
298
314
  #joinsSucceeded = 0
@@ -317,6 +333,11 @@ export class MulticastTransport<TPacketContext = never> {
317
333
  // per packet once a count outgrows the small-integer range.
318
334
  readonly #acceptedBytesByPacketId = new Map<number, { bytes: number }>()
319
335
  #packetIds: ReadonlySet<number> | null = null
336
+ // The sender S this join's accepted packets came from, once known; see
337
+ // packetsDroppedForeignSender. Cleared by every join and by a silence
338
+ // failback, so a replacement publisher is not locked out.
339
+ #sender: string | undefined = undefined
340
+ readonly #lockedTo: (() => string | undefined) | undefined
320
341
  // What the live join delivers with each accepted packet. A field, not a
321
342
  // listener closure, so an in-place admission change (setContext) applies
322
343
  // from the next packet on without rejoining.
@@ -340,12 +361,18 @@ export class MulticastTransport<TPacketContext = never> {
340
361
  // The provider to subscribe through; window.multicast when unset.
341
362
  readonly #gateway: WindowMulticastGateway | undefined
342
363
 
364
+ /**
365
+ * `lockedTo` reads the sender another transport is locked to (a repair
366
+ * group follows its media join, whose publisher sends both). While it reads
367
+ * a sender, this join admits only that one; until then its own lock applies.
368
+ */
343
369
  constructor(
344
370
  onPacket: (packet: Uint8Array, context?: TPacketContext) => void,
345
- options: { gateway?: WindowMulticastGateway } = {},
371
+ options: { gateway?: WindowMulticastGateway; lockedTo?: () => string | undefined } = {},
346
372
  ) {
347
373
  this.#onPacket = onPacket
348
374
  this.#gateway = options.gateway
375
+ this.#lockedTo = options.lockedTo
349
376
  }
350
377
 
351
378
  static isAvailable(): boolean {
@@ -356,6 +383,7 @@ export class MulticastTransport<TPacketContext = never> {
356
383
  const generation = ++this.#joinGeneration
357
384
  this.#releaseSubscription()
358
385
  this.#dataPlaneLive = false
386
+ this.#sender = undefined
359
387
 
360
388
  if (this.#disposed) return this.#declined(generation, 'disposed')
361
389
  const gateway = this.#gateway ?? getMulticastGlobals().multicast
@@ -411,7 +439,13 @@ export class MulticastTransport<TPacketContext = never> {
411
439
  // one DOM event and one liveness read per batch instead of per packet
412
440
  // (BLO-36599). Every packet still goes through the same filter, counters
413
441
  // and callback, in arrival order, as a view of the provider's bytes.
414
- const packetListener: PacketListener = subscriptionDeliversPacketBatches(subscription)
442
+ // A batch carries no per-packet sender, so an any-source join, the one
443
+ // join that can see two senders, listens to `bytes` (BLO-50098). That
444
+ // costs batching on every any-source join, including one on a pre-1.3.0
445
+ // provider that sends no sender and so gets no lock for it; keep it
446
+ // unless the provider can say it carries the sender.
447
+ const anySource = !config.sourceAddress && !config.senderAddress && !config.atsc3
448
+ const packetListener: PacketListener = !anySource && subscriptionDeliversPacketBatches(subscription)
415
449
  ? {
416
450
  type: 'packets',
417
451
  listener: (event) => {
@@ -423,7 +457,7 @@ export class MulticastTransport<TPacketContext = never> {
423
457
  type: 'bytes',
424
458
  listener: (event) => {
425
459
  if (!live()) return
426
- if (this.#deliver(event.detail)) this.#resetWatchdog()
460
+ if (this.#deliver(event.detail, senderOf(event))) this.#resetWatchdog()
427
461
  },
428
462
  }
429
463
  this.#packetListener = packetListener
@@ -546,13 +580,13 @@ export class MulticastTransport<TPacketContext = never> {
546
580
  * One packet from the subscription: filter, count, and hand it to the
547
581
  * callback. Returns whether it was delivered; the caller notes liveness.
548
582
  */
549
- #deliver(detail: unknown): boolean {
583
+ #deliver(detail: unknown, sourceAddress?: string): boolean {
550
584
  const packet = normalizeBytesEventDetail(detail)
551
585
  if (!packet) {
552
586
  this.#stats.packetsDroppedUnparseable++
553
587
  return false
554
588
  }
555
- if (!this.#acceptPacket(packet)) return false
589
+ if (!this.#acceptPacket(packet, sourceAddress)) return false
556
590
  // Read per packet: a callback that changes the admission (an in-place
557
591
  // rendition switch committing on this very packet) takes effect from the
558
592
  // next packet, including the rest of a batch.
@@ -604,11 +638,15 @@ export class MulticastTransport<TPacketContext = never> {
604
638
  }
605
639
  }
606
640
 
607
- /** Feed a raw packet from an external source (e.g., AMT tunnel or ASM subscription). */
608
- feedPacket(data: ArrayBuffer | Uint8Array, context?: TPacketContext): void {
641
+ /**
642
+ * Feed a raw packet from an external source (e.g., AMT tunnel or ASM
643
+ * subscription). `sourceAddress` is the datagram's sender when the source
644
+ * knows it; see packetsDroppedForeignSender.
645
+ */
646
+ feedPacket(data: ArrayBuffer | Uint8Array, context?: TPacketContext, sourceAddress?: string): void {
609
647
  if (this.#disposed) return
610
648
  const packet = data instanceof Uint8Array ? data : new Uint8Array(data)
611
- if (!this.#acceptPacket(packet)) return
649
+ if (!this.#acceptPacket(packet, sourceAddress)) return
612
650
  this.#onPacket(packet, context)
613
651
  this.#resetWatchdog()
614
652
  }
@@ -778,6 +816,11 @@ export class MulticastTransport<TPacketContext = never> {
778
816
  return this.#withdrawnPacketIdDrops
779
817
  }
780
818
 
819
+ /** The sender this join is locked to; undefined until one is known. */
820
+ get sender(): string | undefined {
821
+ return this.#sender
822
+ }
823
+
781
824
  /** Whether a subscription is live: joined, and not yet released or replaced. */
782
825
  get joined(): boolean {
783
826
  return this.#subscription !== null
@@ -953,7 +996,13 @@ export class MulticastTransport<TPacketContext = never> {
953
996
  }
954
997
 
955
998
  /** The ingress filter; an accepted packet is counted here, once. */
956
- #acceptPacket(packet: Uint8Array): boolean {
999
+ #acceptPacket(packet: Uint8Array, sourceAddress?: string): boolean {
1000
+ // Absent means unknown (a pre-1.3.0 provider, a tuner): never a new sender.
1001
+ const owner = this.#lockedTo?.() ?? this.#sender
1002
+ if (sourceAddress !== undefined && owner !== undefined && sourceAddress !== owner) {
1003
+ this.#stats.packetsDroppedForeignSender++
1004
+ return false
1005
+ }
957
1006
  // This early read is intentional: rejected PIDs must not affect page
958
1007
  // delivery, receive statistics, or the failback liveness watchdog. It
959
1008
  // refuses exactly what parseMMTPHeader refuses but builds no header
@@ -963,7 +1012,7 @@ export class MulticastTransport<TPacketContext = never> {
963
1012
  // Without an allow-list there is no filter to enforce, so an unparseable
964
1013
  // packet keeps flowing to the router as before.
965
1014
  if (!this.#packetIds) {
966
- this.#countAccepted(packet, null)
1015
+ this.#countAccepted(packet, null, sourceAddress)
967
1016
  return true
968
1017
  }
969
1018
  this.#stats.packetsDroppedUnparseable++
@@ -977,7 +1026,7 @@ export class MulticastTransport<TPacketContext = never> {
977
1026
  return false
978
1027
  }
979
1028
  if (!this.#packetIds || this.#packetIds.has(packetId)) {
980
- this.#countAccepted(packet, packetId)
1029
+ this.#countAccepted(packet, packetId, sourceAddress)
981
1030
  return true
982
1031
  }
983
1032
  this.#recordUnregisteredPidDrop(packetId)
@@ -985,7 +1034,8 @@ export class MulticastTransport<TPacketContext = never> {
985
1034
  }
986
1035
 
987
1036
  /** Receive counters for one accepted packet; `packetId` is null when it has none. */
988
- #countAccepted(packet: Uint8Array, packetId: number | null): void {
1037
+ #countAccepted(packet: Uint8Array, packetId: number | null, sourceAddress: string | undefined): void {
1038
+ this.#sender ??= sourceAddress
989
1039
  this.#stats.packetsReceived++
990
1040
  this.#stats.bytesReceived += packet.byteLength
991
1041
  if (packetId === null) return
@@ -1067,6 +1117,9 @@ export class MulticastTransport<TPacketContext = never> {
1067
1117
  console.warn(
1068
1118
  `[MulticastTransport] No packets for ${this.#failbackWindowMs}ms; failing back to MoQ`,
1069
1119
  )
1120
+ // The sub-flow's owner went silent: a sender that resumes after this,
1121
+ // the same one or a replacement publisher, owns the sub-flow anew.
1122
+ this.#sender = undefined
1070
1123
  this.#onFailback({ kind: 'silence', windowMs: this.#failbackWindowMs })
1071
1124
  }, delayMs)
1072
1125
  }