@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
@@ -22,7 +22,7 @@ import {
22
22
  type FecBlockProfile,
23
23
  type MMTPHeader,
24
24
  } from "@blockcast/mmt-base";
25
- import type { IsoHeldSource } from "./fec-manager.js";
25
+ import type { IsoHeldSource, WasmFecDecoder } from "./fec-manager.js";
26
26
  import type { IsoPlacedSource, IsoRepairPlacement } from "./iso-block-locator.js";
27
27
  import { isMfuReassemblyError } from "./mfu-reassembler.js";
28
28
  import type {
@@ -67,10 +67,8 @@ import {
67
67
  type DuplicateHorizon,
68
68
  } from "./duplicate-ledger.js";
69
69
  import type {
70
- ManifestAuthMode,
71
- ManifestObjectIdentity,
72
- ManifestTrackContext,
73
- ManifestVerifier,
70
+ BcProvenanceV2Receiver,
71
+ BcpV2RecordPath,
74
72
  } from "@blockcast/mmt-manifest-verify";
75
73
 
76
74
  // ─── Constants ─────────────────────────────────────────────────────
@@ -177,6 +175,12 @@ export type RoutedFrameMeta = {
177
175
 
178
176
  export interface MmtpIngressContext {
179
177
  tracks: readonly Readonly<{ name: string; packetId: number }>[];
178
+ /**
179
+ * The bc-provenance v2 flow (draft-ramadan-moq-multicast-01 7.2.7) that
180
+ * delivered the packet, when its endpoint is protected: the key a receiver
181
+ * was registered under with `registerProvenanceFlow`.
182
+ */
183
+ flow?: string;
180
184
  }
181
185
 
182
186
  /** One physical stream: an MMTP packet ID and the catalog track it carries. */
@@ -513,6 +517,32 @@ const NO_BLOCK_SPAN_HORIZON: DuplicateHorizon = Object.freeze({
513
517
  });
514
518
 
515
519
  const DIRECT_RECOVERED_VIDEO_QUEUE_LIMIT = 512;
520
+
521
+ /**
522
+ * A governed block's admitted symbols, as fed to its live decoder, and the
523
+ * unlisted repair pooled for it: what an unlisted-assisted decode runs on
524
+ * (7.2.7 step 2). Keyed by ESI / RS_ID.
525
+ */
526
+ interface AssistBlock {
527
+ readonly ssbLength: number;
528
+ readonly sources: Map<number, Uint8Array>;
529
+ readonly repairs: Map<number, Uint8Array>;
530
+ readonly pooled: Map<number, Uint8Array>;
531
+ }
532
+ // ponytail: FIFO over blocks (they arrive in order); an LRU if blocks interleave.
533
+ const ASSIST_BLOCK_LIMIT = 64;
534
+ /**
535
+ * Failed unlisted-assisted decodes per block, kept apart from the block record
536
+ * so evicting and reopening a record never resets the cap.
537
+ * ponytail: one LRU shared by every governed owner/track, refreshed only on a
538
+ * failure: a block may fail twice more once 256 other failing blocks (any
539
+ * track) have aged it out. Per-track ledgers if a multi-track flood matters.
540
+ */
541
+ const ASSIST_FAILURE_LEDGER_LIMIT = 4 * ASSIST_BLOCK_LIMIT;
542
+ /** 7.2.7 step 2: at most two unlisted-assisted decodes may fail per block. */
543
+ const ASSIST_FAILED_DECODES_MAX = 2;
544
+ const assistKey = (ownerPacketId: number, catalogTrackName: string | undefined, blockStart: number) =>
545
+ `${ownerPacketId}/${catalogTrackName ?? ""}/${blockStart}`;
516
546
  const DIRECT_RECOVERED_AUDIO_QUEUE_LIMIT = 512;
517
547
 
518
548
  type ParserDiagnostics = {
@@ -558,6 +588,9 @@ export interface FECManagerLike {
558
588
  catalogTrackName?: string,
559
589
  ): Uint8Array | null;
560
590
 
591
+ /** How long ago the stream's oldest open block with an FEC deadline took its first symbol (optional). */
592
+ oldestOpenBlockAgeMs?(packetId: number, catalogTrackName: string): number | undefined;
593
+
561
594
  /** A block's RSB_length, from one of its repair symbols (optional). */
562
595
  noteRepairGroupLength?(sbn: number, packetId: number, rsbLength: number, catalogTrackName?: string): void;
563
596
 
@@ -703,6 +736,14 @@ export interface FECManagerLike {
703
736
  catalogTrackName: string | undefined,
704
737
  ssId: number,
705
738
  ): { sbn: number; esi: number } | null;
739
+ /** Begin a fresh decode epoch for one stream, dropping what it holds (FECManager.resetTrack). */
740
+ resetTrack?(packetId: number, catalogTrackName: string): void;
741
+ /**
742
+ * Run `fn` with `receivedAtMs` (performance.now() clock) as the receipt
743
+ * time of the packet it feeds: a block it opens takes that ts, and its
744
+ * deadlines run from it (fec-01 9, a datagram held and admitted later).
745
+ */
746
+ withReceiptTime?(receivedAtMs: number, fn: () => void): void;
706
747
  /** iso-ssbg1-v1: hold a first-arrival source until a repair states its block. */
707
748
  holdIsoSource?(
708
749
  packetId: number,
@@ -804,26 +845,6 @@ export interface FECManagerLike {
804
845
 
805
846
  // ─── MmtpRouter ─────────────────────────────────────────────────────
806
847
 
807
- export interface ManifestIdentityResolutionInput {
808
- packetId: number;
809
- sbn: number;
810
- esi: number;
811
- /**
812
- * The packet's SS_ID and its MPU's mpu_sequence_number. Under
813
- * iso-ssbg1-v1 blocks vary in length, so a packet's media group comes from
814
- * its own MPU header, never from SBN arithmetic (BLO-35597 D2a).
815
- */
816
- ssId?: number;
817
- mpuSequenceNumber?: number;
818
- catalogTrackName?: string;
819
- recovered: boolean;
820
- sourceSymbolFormat: "full-mmtp-packet" | "fec-padded-recovery";
821
- }
822
-
823
- export type ManifestIdentityResolver = (
824
- input: ManifestIdentityResolutionInput,
825
- ) => ManifestObjectIdentity | null;
826
-
827
848
  /**
828
849
  * Classification evidence carried by one first-arrival network source
829
850
  * packet: the MMTP header R bit and the MPU / data-unit identity it states
@@ -860,26 +881,30 @@ export class MmtpRouter {
860
881
  readonly #fecManager: FECManagerLike | null;
861
882
  #defaultFecMapper: FecBlockMapper | null;
862
883
  #fecMappersByPacketId = new Map<number, FecBlockMapper>();
863
- readonly #manifestVerifier: ManifestVerifier | null;
864
- readonly #ownsManifestVerifier: boolean;
865
- readonly #resolveManifestIdentity:
866
- | ((
867
- packetId: number,
868
- sbn: number,
869
- esi: number,
870
- catalogTrackName?: string,
871
- ) => ManifestObjectIdentity | null)
872
- | null;
873
- readonly #resolveManifestIdentityForRoute: ManifestIdentityResolver | null;
874
- #manifestRecoveryQueues = new Map<
884
+ /** Protected flows: one 7.2.7 receiver each, and the ingress its datagrams arrive with. */
885
+ readonly #provenance = new Map<
875
886
  string,
876
- Array<{
877
- blockId: number;
878
- result: ProcessFecBlockResult;
879
- ownerPacketId: number;
880
- catalogTrackName?: string;
881
- }>
887
+ { receiver: BcProvenanceV2Receiver; ingress: MmtpIngressContext; protected?: boolean }
882
888
  >();
889
+ #onFlowProtected: ((flow: string) => void) | null = null;
890
+ #unadmittedListedDrops = 0;
891
+ #unlistedRepairSetAside = 0;
892
+ readonly #assistBlocks = new Map<string, AssistBlock>();
893
+ readonly #assistFailures = new Map<string, number>();
894
+ #scratchDecoderFactory: ((ownerPacketId: number, catalogTrackName: string | undefined) => WasmFecDecoder | null) | null = null;
895
+ #unlistedAssistedRecoveries = 0;
896
+ #unlistedAssistedRefusals = 0;
897
+ /** The ingress a held datagram arrived with; the receiver returns the same bytes on release. */
898
+ readonly #heldIngress = new WeakMap<Uint8Array, MmtpIngressContext>();
899
+ #unicastRepair = false;
900
+ /** How to route a held unicast (MoQ) datagram once a record admits it. */
901
+ readonly #heldReplay = new WeakMap<Uint8Array, () => void>();
902
+ /** When a held datagram was received: admitted later, it keeps that receipt time (fec-01 9). */
903
+ readonly #heldAt = new WeakMap<Uint8Array, number>();
904
+ /** The flow whose receiver admitted a stream's source datagrams, by packetId then catalog track name: recovered packets answer to it. */
905
+ readonly #provenanceStreamFlow = new Map<number, Map<string | undefined, string>>();
906
+ #unregisteredFlowDrops = 0;
907
+ #ambiguousRecoveredDrops = 0;
883
908
 
884
909
  // Temp state set by #processMMTP before calling parser.feed(),
885
910
  // read by the parser's onFrame callback for CMAF assembly + dedup.
@@ -986,6 +1011,7 @@ export class MmtpRouter {
986
1011
  | null = null;
987
1012
  /** Typed observer of every gap decision the release gate makes. */
988
1013
  readonly #onFrameGap: ((decision: FrameGapDecision) => void) | null;
1014
+ readonly #fecRapObservers = new Set<(packetId: number, catalogTrackName: string | undefined, ssId: number) => void>();
989
1015
  // Pokes requested while a packet is being processed are deferred to its
990
1016
  // end: a synchronous recovery completes its block BEFORE routing the
991
1017
  // recovered frames, and releasing a held gap in between would strand them.
@@ -1056,16 +1082,6 @@ export class MmtpRouter {
1056
1082
  * Pass a reader when its inputs are measured later; see
1057
1083
  * setKeyframeLostTimeoutMs. */
1058
1084
  keyframeLostTimeoutMs?: number | (() => number);
1059
- manifestVerifier?: ManifestVerifier;
1060
- /** Existing behavior is router ownership; shared browser plans opt into caller ownership. */
1061
- manifestVerifierOwnership?: "caller" | "router";
1062
- resolveManifestIdentity?: (
1063
- packetId: number,
1064
- sbn: number,
1065
- esi: number,
1066
- catalogTrackName?: string,
1067
- ) => ManifestObjectIdentity | null;
1068
- resolveManifestIdentityForRoute?: ManifestIdentityResolver;
1069
1085
  /** Internal terminal-failure sink owned by the composing transport. */
1070
1086
  onBlockFailed?: (
1071
1087
  sbn: number,
@@ -1088,10 +1104,6 @@ export class MmtpRouter {
1088
1104
  this.#fecManager = config?.fecManager ?? null;
1089
1105
  this.#onFrameGap = config?.onFrameGap ?? null;
1090
1106
  this.#defaultFecMapper = config?.fecMapper ?? null;
1091
- this.#manifestVerifier = config?.manifestVerifier ?? null;
1092
- this.#ownsManifestVerifier = config?.manifestVerifierOwnership !== "caller";
1093
- this.#resolveManifestIdentity = config?.resolveManifestIdentity ?? null;
1094
- this.#resolveManifestIdentityForRoute = config?.resolveManifestIdentityForRoute ?? null;
1095
1107
 
1096
1108
  // Create MMTP parser — handles MFU extraction, reassembly, keyframe detection.
1097
1109
  // Its MFUReassembler is the single reassembler for BOTH the source path
@@ -1822,32 +1834,385 @@ export class MmtpRouter {
1822
1834
  });
1823
1835
  }
1824
1836
 
1825
- registerManifestTrack(context: ManifestTrackContext): void {
1826
- if (!this.#manifestVerifier) throw new Error("manifest verifier is not configured");
1827
- this.#manifestVerifier.registerTrack(context);
1837
+ /**
1838
+ * Protect a flow (draft-ramadan-moq-multicast-01 7.2.7): every multicast
1839
+ * datagram whose ingress names `ingress.flow`, source and repair alike, is
1840
+ * admitted by `receiver` before anything reads it, and an unadmitted one is
1841
+ * held or dropped. The caller owns the receiver's configuration; the router
1842
+ * releases it on `releaseProvenanceFlow` and on dispose.
1843
+ */
1844
+ registerProvenanceFlow(
1845
+ ingress: MmtpIngressContext & { flow: string },
1846
+ receiver: BcProvenanceV2Receiver,
1847
+ ): void {
1848
+ this.#provenance.get(ingress.flow)?.receiver.release();
1849
+ this.#provenance.set(ingress.flow, { receiver, ingress });
1850
+ receiver.keepPendingAgeMs = () => this.#oldestOpenBlockAgeMs(ingress.flow);
1851
+ }
1852
+
1853
+ /**
1854
+ * Declare that this receiver can request unicast repair (a repair FETCH
1855
+ * scheduler is wired): a protected flow then keeps the pending entries of
1856
+ * its open blocks for the repair to consume (7.2.7 step 2).
1857
+ */
1858
+ setUnicastRepair(enabled: boolean): void {
1859
+ this.#unicastRepair = enabled;
1860
+ }
1861
+
1862
+ #oldestOpenBlockAgeMs(flow: string): number | undefined {
1863
+ const entry = this.#provenance.get(flow);
1864
+ if (!this.#unicastRepair || !entry) return undefined;
1865
+ let age: number | undefined;
1866
+ for (const { name, packetId } of entry.ingress.tracks) {
1867
+ const a = name ? this.#fecManager?.oldestOpenBlockAgeMs?.(packetId, name) : undefined;
1868
+ if (a !== undefined && (age === undefined || a > age)) age = a;
1869
+ }
1870
+ return age;
1871
+ }
1872
+
1873
+ /**
1874
+ * Replace a registered flow's ingress (a rendition switch staged, committed
1875
+ * or cancelled) while keeping its receiver; a no-op for an unregistered flow.
1876
+ */
1877
+ updateProvenanceFlowIngress(ingress: MmtpIngressContext & { flow: string }): void {
1878
+ const entry = this.#provenance.get(ingress.flow);
1879
+ if (!entry) return;
1880
+ // A protected flow newly listing a track drops its MoQ copies from now
1881
+ // on (7.2.7). An instance already governed holds only admitted symbols,
1882
+ // which stay admitted (multicast 7.2.7, 1720-1729): only its pooled
1883
+ // unlisted repair is discarded. An instance governed from now on holds
1884
+ // unadmitted symbols, so it is discarded whole.
1885
+ const added = entry.protected
1886
+ ? ingress.tracks.filter(({ name, packetId }) =>
1887
+ name && !entry.ingress.tracks.some((t) => t.name === name && t.packetId === packetId))
1888
+ : [];
1889
+ const governed = added.map(({ packetId }) =>
1890
+ [...(this.#repairOwnersByPacketId.get(packetId) ?? [packetId])].some((owner) => this.#governs(owner)));
1891
+ this.#provenance.set(ingress.flow, { ...entry, ingress });
1892
+ added.forEach(({ name, packetId }, i) => {
1893
+ // ponytail: discards every block's pool, not only the newly listed track's symbols.
1894
+ if (governed[i]) for (const block of this.#assistBlocks.values()) block.pooled.clear();
1895
+ else this.#fecManager?.resetTrack?.(packetId, name!);
1896
+ });
1897
+ }
1898
+
1899
+ /**
1900
+ * Called once per protection of a flow: at the admission that makes this
1901
+ * router's consumers protected under it (7.2.7), after their unadmitted
1902
+ * holdings of its listed tracks are discarded. Again after an unprotect.
1903
+ */
1904
+ onProvenanceFlowProtected(cb: ((flow: string) => void) | null): void {
1905
+ this.#onFlowProtected = cb;
1906
+ }
1907
+
1908
+ /**
1909
+ * The receiver ceased to hold the flow's endpoint on failover (7.2.7): drop
1910
+ * its state and hold queues (no expiry counted) and un-protect its tracks,
1911
+ * which then take MoQ again. The registration stays, so multicast resuming
1912
+ * protects the flow anew at its next admission.
1913
+ */
1914
+ unprotectProvenanceFlow(flow: string): void {
1915
+ const entry = this.#provenance.get(flow);
1916
+ if (!entry) return;
1917
+ entry.receiver.release();
1918
+ entry.protected = false;
1919
+ // ponytail: drops every governed block's record, not only this flow's; per-flow if joins multiply.
1920
+ this.#assistBlocks.clear();
1921
+ this.#assistFailures.clear();
1828
1922
  }
1829
1923
 
1830
- feedManifestFrame(trackName: string, frame: Uint8Array): void {
1831
- this.#manifestVerifier?.ingestManifestFrame(trackName, frame);
1924
+ /** MoQ packets of a track a protected flow lists, dropped on its unicast path: duplicate, overflow, or listed by more than one flow. */
1925
+ get unadmittedListedDropCount(): number {
1926
+ return this.#unadmittedListedDrops;
1832
1927
  }
1833
1928
 
1834
- setManifestAuthMode(mode: ManifestAuthMode): void {
1835
- this.#manifestVerifier?.setMode(mode);
1836
- if (mode !== "enforce") {
1837
- for (const queue of this.#manifestRecoveryQueues.values()) {
1838
- for (const item of queue) {
1839
- this.#routeProcessedFecSymbols(
1840
- item.blockId,
1841
- item.result,
1842
- item.ownerPacketId,
1843
- item.catalogTrackName,
1844
- );
1845
- }
1929
+ /** MoQ repair symbols of an unlisted track kept out of a governed FEC instance (7.2.7 step 2). */
1930
+ get unlistedRepairSetAsideCount(): number {
1931
+ return this.#unlistedRepairSetAside;
1932
+ }
1933
+
1934
+ /** Governed blocks recovered with the help of unlisted repair (7.2.7 step 2). */
1935
+ get unlistedAssistedRecoveryCount(): number {
1936
+ return this.#unlistedAssistedRecoveries;
1937
+ }
1938
+
1939
+ /** Unlisted-assisted decodes refused because two already failed for the block (7.2.7 step 2). */
1940
+ get unlistedAssistedRefusalCount(): number {
1941
+ return this.#unlistedAssistedRefusals;
1942
+ }
1943
+
1944
+ /** Unlisted repair symbols pooled now, across every governed block (7.2.7 step 2). */
1945
+ get unlistedPoolSymbolCount(): number {
1946
+ let n = 0;
1947
+ for (const block of this.#assistBlocks.values()) n += block.pooled.size;
1948
+ return n;
1949
+ }
1950
+
1951
+ /**
1952
+ * Wire the decoder an unlisted-assisted decode runs in: a fresh, configured
1953
+ * decoder of the owner's track, used once and discarded, never the live one.
1954
+ * Without it unlisted repair on a governed instance is set aside and dropped.
1955
+ */
1956
+ setScratchFecDecoderFactory(
1957
+ factory: ((ownerPacketId: number, catalogTrackName: string | undefined) => WasmFecDecoder | null) | null,
1958
+ ): void {
1959
+ this.#scratchDecoderFactory = factory;
1960
+ if (!factory) {
1961
+ this.#assistBlocks.clear();
1962
+ this.#assistFailures.clear();
1963
+ }
1964
+ }
1965
+
1966
+ /** Whether a protected flow lists `ownerPacketId`: its FEC instance is governed (7.2.7). */
1967
+ #governs(ownerPacketId: number): boolean {
1968
+ for (const entry of this.#provenance.values()) {
1969
+ if (entry.protected && ingressHasPacketId(entry.ingress, ownerPacketId)) return true;
1970
+ }
1971
+ return false;
1972
+ }
1973
+
1974
+ /**
1975
+ * A MoQ repair symbol no protected flow lists, owned by a source track one
1976
+ * does: its FEC instance is governed (7.2.7 step 2) and takes only admitted
1977
+ * symbols, so the symbol is set aside: pooled for an unlisted-assisted
1978
+ * decode of a block admitted repair already delimits, else dropped. It
1979
+ * never states a block, so the live locator and decoder are unchanged.
1980
+ * Un-listing needs no hook: an unprotected or released flow lists nothing.
1981
+ */
1982
+ #setAsideUnlistedRepair(
1983
+ ownerPacketId: number,
1984
+ catalogTrackName: string | undefined,
1985
+ repair: { ssStart: number; ssbLength: number; rsbLength: number; rsId: number; symbolData: Uint8Array },
1986
+ ): boolean {
1987
+ if (!this.#governs(ownerPacketId)) return false;
1988
+ this.#unlistedRepairSetAside++;
1989
+ const key = assistKey(ownerPacketId, catalogTrackName, repair.ssStart);
1990
+ const block = this.#assistBlocks.get(key);
1991
+ // Pooled only up to the block's length: more never closes a shortfall.
1992
+ if (
1993
+ block !== undefined &&
1994
+ block.ssbLength === repair.ssbLength &&
1995
+ !block.repairs.has(repair.rsId) &&
1996
+ block.pooled.size < block.ssbLength &&
1997
+ repair.symbolData.byteLength === this.#symbolSizeForPacket(ownerPacketId) &&
1998
+ this.#fecManager?.admitRepairBlockLength?.(ownerPacketId, repair.ssbLength, repair.rsbLength, repair.rsId) !== false
1999
+ ) {
2000
+ block.pooled.set(repair.rsId, repair.symbolData);
2001
+ this.#tryAssistedDecode(key, block, ownerPacketId, catalogTrackName, repair.ssStart);
2002
+ }
2003
+ return true;
2004
+ }
2005
+
2006
+ /**
2007
+ * An admitted repair placed a governed block (its SSB_length delimits it):
2008
+ * open the block's record, which the symbols its live decoder takes join.
2009
+ */
2010
+ #openAssistBlock(ownerPacketId: number, catalogTrackName: string | undefined, blockStart: number, ssbLength: number): void {
2011
+ const key = assistKey(ownerPacketId, catalogTrackName, blockStart);
2012
+ if (!this.#scratchDecoderFactory || this.#assistBlocks.has(key) || !this.#governs(ownerPacketId)) return;
2013
+ this.#assistBlocks.set(key, { ssbLength, sources: new Map(), repairs: new Map(), pooled: new Map() });
2014
+ for (const oldest of this.#assistBlocks.keys()) {
2015
+ if (this.#assistBlocks.size <= ASSIST_BLOCK_LIMIT) break;
2016
+ this.#assistBlocks.delete(oldest);
2017
+ }
2018
+ }
2019
+
2020
+ /** A symbol fed to an open governed block's live decoder that did not recover it. */
2021
+ #noteAssistAdmitted(
2022
+ ownerPacketId: number,
2023
+ catalogTrackName: string | undefined,
2024
+ blockStart: number,
2025
+ symbol: { esi: number } | { rsId: number },
2026
+ data: Uint8Array,
2027
+ ): void {
2028
+ const key = assistKey(ownerPacketId, catalogTrackName, blockStart);
2029
+ const block = this.#assistBlocks.get(key);
2030
+ if (block === undefined) return;
2031
+ if ("rsId" in symbol) {
2032
+ block.repairs.set(symbol.rsId, data);
2033
+ block.pooled.delete(symbol.rsId); // an admitted copy wins over an unlisted one
2034
+ } else {
2035
+ block.sources.set(symbol.esi, data);
2036
+ }
2037
+ this.#tryAssistedDecode(key, block, ownerPacketId, catalogTrackName, blockStart);
2038
+ }
2039
+
2040
+ /**
2041
+ * 7.2.7 step 2, phase two: a governed block its admitted symbols cannot
2042
+ * decode (the live decoder already had them all) is decoded in a scratch
2043
+ * decoder from its admitted symbols plus pooled unlisted repair, only as
2044
+ * many as close the shortfall. What it recovers is released only through
2045
+ * admission (#routeRecoveredBlock), so the live decoder is never touched and
2046
+ * a refused recovery leaves the block to fail at its FEC deadline. At most
2047
+ * two such decodes may fail per block; a third is refused.
2048
+ */
2049
+ #tryAssistedDecode(
2050
+ key: string,
2051
+ block: AssistBlock,
2052
+ ownerPacketId: number,
2053
+ catalogTrackName: string | undefined,
2054
+ blockStart: number,
2055
+ ): void {
2056
+ const fec = this.#fecManager;
2057
+ const admitted = block.sources.size + block.repairs.size;
2058
+ if (!fec || block.pooled.size === 0) return;
2059
+ if (admitted >= block.ssbLength || admitted + block.pooled.size < block.ssbLength) return;
2060
+ if (fec.isBlockCompleted(blockStart, ownerPacketId, catalogTrackName)) {
2061
+ this.#assistBlocks.delete(key);
2062
+ return;
2063
+ }
2064
+ if ((this.#assistFailures.get(key) ?? 0) >= ASSIST_FAILED_DECODES_MAX) {
2065
+ this.#unlistedAssistedRefusals++;
2066
+ return;
2067
+ }
2068
+ const decoder = this.#scratchDecoderFactory?.(ownerPacketId, catalogTrackName);
2069
+ if (!decoder) return;
2070
+ let recovered: Uint8Array | null = null;
2071
+ const fed: number[] = [];
2072
+ try {
2073
+ for (const [esi, data] of block.sources) decoder.add_source(serial32Add(blockStart, esi), data, 0);
2074
+ for (const [rsId, data] of block.repairs) {
2075
+ recovered ??= decoder.add_repair(blockStart, block.ssbLength, rsId, data, 0);
2076
+ }
2077
+ // ponytail: exactly the shortfall; a RaptorQ decoder short of K+overhead
2078
+ // fails here, burning one of the two decodes on fresh pooled symbols.
2079
+ for (const [rsId, data] of block.pooled) {
2080
+ if (recovered || fed.length >= block.ssbLength - admitted) break;
2081
+ fed.push(rsId);
2082
+ recovered = decoder.add_repair(blockStart, block.ssbLength, rsId, data, 0);
2083
+ }
2084
+ } catch {
2085
+ recovered = null;
2086
+ } finally {
2087
+ (decoder as { free?(): void }).free?.();
2088
+ }
2089
+ if (recovered && this.#routeRecoveredBlock(blockStart, recovered, ownerPacketId, catalogTrackName, () => {
2090
+ fec.cancelBlockTimeout(blockStart, ownerPacketId, catalogTrackName);
2091
+ fec.countRecovery(ownerPacketId, blockStart, catalogTrackName);
2092
+ fec.markBlockCompleted(blockStart, ownerPacketId, catalogTrackName);
2093
+ })) {
2094
+ this.#unlistedAssistedRecoveries++;
2095
+ this.#assistBlocks.delete(key);
2096
+ return;
2097
+ }
2098
+ // Any failed decode (null, throw, or refused recovery) drops the pooled
2099
+ // symbols it fed: the shortfall cap would otherwise re-feed the same
2100
+ // prefix and the retry fails the same way.
2101
+ for (const rsId of fed) block.pooled.delete(rsId);
2102
+ const failed = (this.#assistFailures.get(key) ?? 0) + 1;
2103
+ this.#assistFailures.delete(key);
2104
+ this.#assistFailures.set(key, failed);
2105
+ for (const oldest of this.#assistFailures.keys()) {
2106
+ if (this.#assistFailures.size <= ASSIST_FAILURE_LEDGER_LIMIT) break;
2107
+ this.#assistFailures.delete(oldest);
2108
+ }
2109
+ }
2110
+
2111
+ /**
2112
+ * The admission that makes this router protected under `flow` (7.2.7):
2113
+ * its FEC instances discard what they hold of the flow's listed tracks so
2114
+ * no unadmitted symbol stands for an admitted one, then notify. Before it
2115
+ * nothing of the flow was admitted, so every such symbol is unadmitted.
2116
+ * The playback parse ledger is kept: it records MoQ copies already played,
2117
+ * not holdings, and clearing it re-emits that media (audio twice).
2118
+ * The FEC reset is the whole 7.2.7 discard here: no keyframe-overlay
2119
+ * instance exists to hold anything (repair-layers.ts is the catalog
2120
+ * contract only, no decoder), and packet_id 0 is never a listed track
2121
+ * (`ingressHasPacketId`), so nothing of it is held for the flow.
2122
+ */
2123
+ #noteFlowAdmitted(flow: string): void {
2124
+ const entry = this.#provenance.get(flow);
2125
+ if (!entry || entry.protected) return;
2126
+ entry.protected = true;
2127
+ for (const { name, packetId } of entry.ingress.tracks) {
2128
+ if (name) this.#fecManager?.resetTrack?.(packetId, name);
2129
+ }
2130
+ this.#onFlowProtected?.(flow);
2131
+ }
2132
+
2133
+ /**
2134
+ * Admit a MoQ datagram of `packetId` on the unicast path of the protected
2135
+ * flow that lists it (7.2.7). `unlisted`: no protected flow lists it, so it
2136
+ * is read as before. A held datagram is routed by `replay` when a record
2137
+ * admits it; one more than one protected flow lists is dropped. So is an
2138
+ * overlap copy: it never enters FEC accounting, so admitting it would leave
2139
+ * its multicast twin a duplicate and the block one symbol short.
2140
+ */
2141
+ #admitUnicast(
2142
+ packetId: number,
2143
+ packet: Uint8Array,
2144
+ replay: () => void,
2145
+ overlapCopy = false,
2146
+ ): "unlisted" | "admitted" | "held" | "dropped" {
2147
+ let receiver: BcProvenanceV2Receiver | undefined;
2148
+ for (const entry of this.#provenance.values()) {
2149
+ if (!entry.protected || !ingressHasPacketId(entry.ingress, packetId)) continue;
2150
+ if (receiver || overlapCopy) {
2151
+ this.#unadmittedListedDrops++;
2152
+ return "dropped";
1846
2153
  }
1847
- this.#manifestRecoveryQueues.clear();
2154
+ receiver = entry.receiver;
2155
+ }
2156
+ if (!receiver) return "unlisted";
2157
+ const { verdict } = receiver.onDatagram(packet, "unicast");
2158
+ if (verdict === "admitted") return "admitted";
2159
+ if (verdict === "held") {
2160
+ this.#heldReplay.set(packet, replay);
2161
+ this.#heldAt.set(packet, performance.now());
2162
+ return "held";
2163
+ }
2164
+ this.#unadmittedListedDrops++;
2165
+ return "dropped";
2166
+ }
2167
+
2168
+ /**
2169
+ * Feed one bc-provenance record of `flow`: a datagram from `manifestPort`
2170
+ * (`multicast`) or an object of the manifest track (`mirror`). Held
2171
+ * datagrams the record admits are routed before this returns.
2172
+ */
2173
+ feedProvenanceRecord(flow: string, record: Uint8Array, path: BcpV2RecordPath): string {
2174
+ const entry = this.#provenance.get(flow);
2175
+ if (!entry || this.#disposed) return "unregistered";
2176
+ const result = entry.receiver.onRecord(record, path);
2177
+ if (result.admitted.length > 0) {
2178
+ this.#noteFlowAdmitted(flow);
2179
+ this.#withDeferredFecPokes(() => {
2180
+ for (const datagram of result.admitted) {
2181
+ const route = () => {
2182
+ const replay = this.#heldReplay.get(datagram);
2183
+ if (replay) replay();
2184
+ else this.#processMMTP(datagram, "multicast", true, this.#heldIngress.get(datagram) ?? entry.ingress, false, true);
2185
+ };
2186
+ // A datagram held on a miss and admitted now keeps its receipt time
2187
+ // as the block's ts (fec-01 9): its FEC deadline runs from then.
2188
+ const heldAt = this.#heldAt.get(datagram);
2189
+ if (heldAt !== undefined && this.#fecManager?.withReceiptTime) this.#fecManager.withReceiptTime(heldAt, route);
2190
+ else route();
2191
+ }
2192
+ });
2193
+ }
2194
+ return result.verdict;
2195
+ }
2196
+
2197
+ /** The receiver ceased to hold the flow: its hold queues are dropped, not expired. */
2198
+ releaseProvenanceFlow(flow: string): void {
2199
+ this.#provenance.get(flow)?.receiver.release();
2200
+ this.#provenance.delete(flow);
2201
+ for (const byName of this.#provenanceStreamFlow.values()) {
2202
+ for (const [name, bound] of byName) if (bound === flow) byName.delete(name);
1848
2203
  }
1849
2204
  }
1850
2205
 
2206
+ /** Multicast datagrams dropped because their ingress names a flow with no registered receiver. */
2207
+ get unregisteredFlowDropCount(): number {
2208
+ return this.#unregisteredFlowDrops;
2209
+ }
2210
+
2211
+ /** Recovered packets dropped because more than one protected flow could own their stream. */
2212
+ get ambiguousRecoveredDropCount(): number {
2213
+ return this.#ambiguousRecoveredDrops;
2214
+ }
2215
+
1851
2216
  /**
1852
2217
  * Main entry point: feed a raw MMTP packet for processing.
1853
2218
  *
@@ -1865,12 +2230,11 @@ export class MmtpRouter {
1865
2230
  feedPacket(
1866
2231
  packet: Uint8Array,
1867
2232
  source: "multicast" | "moq",
1868
- manifestIdentity?: ManifestObjectIdentity,
1869
2233
  ingress?: MmtpIngressContext,
1870
2234
  ): void {
1871
2235
  if (this.#disposed) return;
1872
2236
  this.#withDeferredFecPokes(() => {
1873
- this.#processMMTP(packet, source, manifestIdentity, false, ingress);
2237
+ this.#processMMTP(packet, source, false, ingress);
1874
2238
  });
1875
2239
  }
1876
2240
 
@@ -1906,7 +2270,7 @@ export class MmtpRouter {
1906
2270
  feedOverlapCopy(packet: Uint8Array, ingress?: MmtpIngressContext): void {
1907
2271
  if (this.#disposed) return;
1908
2272
  this.#withDeferredFecPokes(() => {
1909
- this.#processMMTP(packet, "moq", undefined, false, ingress, true);
2273
+ this.#processMMTP(packet, "moq", false, ingress, true);
1910
2274
  });
1911
2275
  }
1912
2276
 
@@ -1931,6 +2295,7 @@ export class MmtpRouter {
1931
2295
  ownerPacketIdHint: number | undefined,
1932
2296
  catalogTrackName: string | undefined,
1933
2297
  repairTrackName: string | undefined,
2298
+ admitted = false,
1934
2299
  ): void {
1935
2300
  // One header parse per packet, as on the multicast repair branch: mmt-fec's
1936
2301
  // parseRepairPacket() parsed it once more before this parse (BLO-36599).
@@ -1961,6 +2326,17 @@ export class MmtpRouter {
1961
2326
  );
1962
2327
  }
1963
2328
 
2329
+ // Admission keys on the repair track's own packet id: a repair layer no
2330
+ // protected flow lists is an unlisted track (7.2.7), not a datagram of the
2331
+ // flow that lists its owner, where it would be held until expiry. The
2332
+ // flow listing it governs the owner too: a repair flow also lists its
2333
+ // owners' source tracks (#provenanceFor).
2334
+ if (ownerPacketId != null && !admitted) {
2335
+ const admit = this.#admitUnicast(repairPacketId, packet, () =>
2336
+ this.#feedRepairPacket(packet, ownerPacketIdHint, catalogTrackName, repairTrackName, true));
2337
+ if (admit === "held" || admit === "dropped") return;
2338
+ if (admit === "unlisted" && this.#fecManager && this.#setAsideUnlistedRepair(ownerPacketId, catalogTrackName, parsed)) return;
2339
+ }
1964
2340
  if (ownerPacketId != null && this.#fecManager) {
1965
2341
  this.#moqRepairPidCounts[repairPacketId] =
1966
2342
  (this.#moqRepairPidCounts[repairPacketId] || 0) + 1;
@@ -2122,6 +2498,7 @@ export class MmtpRouter {
2122
2498
  const placement = fec.placeIsoRepair(ownerPacketId, catalogTrackName, repair.ssStart, repair.ssbLength);
2123
2499
  if (placement.status !== "placed") return;
2124
2500
  const blockStart = placement.block.start;
2501
+ this.#openAssistBlock(ownerPacketId, catalogTrackName, blockStart, repair.ssbLength);
2125
2502
  if (placement.span !== undefined) {
2126
2503
  this.#replayIsoSources(ownerPacketId, catalogTrackName, placement.span.block.start, placement.span.placed);
2127
2504
  }
@@ -2147,10 +2524,13 @@ export class MmtpRouter {
2147
2524
  catalogTrackName,
2148
2525
  );
2149
2526
  if (recovered && !alreadyCompleted) {
2150
- fec.cancelBlockTimeout(blockStart, ownerPacketId, catalogTrackName);
2151
- fec.countRecovery(ownerPacketId, blockStart, catalogTrackName);
2152
- fec.markBlockCompleted(blockStart, ownerPacketId, catalogTrackName);
2153
- this.#routeRecoveredBlock(blockStart, recovered, ownerPacketId, catalogTrackName);
2527
+ this.#routeRecoveredBlock(blockStart, recovered, ownerPacketId, catalogTrackName, () => {
2528
+ fec.cancelBlockTimeout(blockStart, ownerPacketId, catalogTrackName);
2529
+ fec.countRecovery(ownerPacketId, blockStart, catalogTrackName);
2530
+ fec.markBlockCompleted(blockStart, ownerPacketId, catalogTrackName);
2531
+ });
2532
+ } else if (!recovered) {
2533
+ this.#noteAssistAdmitted(ownerPacketId, catalogTrackName, blockStart, { rsId: repair.rsId }, repair.symbolData);
2154
2534
  }
2155
2535
  }
2156
2536
 
@@ -2202,11 +2582,13 @@ export class MmtpRouter {
2202
2582
  if (payload.oversized) continue;
2203
2583
  const alreadyCompleted = fec.isBlockCompleted(blockStart, ownerPacketId, catalogTrackName);
2204
2584
  const recovered = fec.feedSourceSymbol(ownerPacketId, ssId, payload.symbolData, catalogTrackName);
2585
+ if (!recovered) this.#noteAssistAdmitted(ownerPacketId, catalogTrackName, blockStart, { esi }, payload.symbolData);
2205
2586
  if (!recovered || alreadyCompleted) continue;
2206
- fec.cancelBlockTimeout(blockStart, ownerPacketId, catalogTrackName);
2207
- fec.countRecovery(ownerPacketId, blockStart, catalogTrackName);
2208
- fec.markBlockCompleted(blockStart, ownerPacketId, catalogTrackName);
2209
- this.#routeRecoveredBlock(blockStart, recovered, ownerPacketId, catalogTrackName);
2587
+ this.#routeRecoveredBlock(blockStart, recovered, ownerPacketId, catalogTrackName, () => {
2588
+ fec.cancelBlockTimeout(blockStart, ownerPacketId, catalogTrackName);
2589
+ fec.countRecovery(ownerPacketId, blockStart, catalogTrackName);
2590
+ fec.markBlockCompleted(blockStart, ownerPacketId, catalogTrackName);
2591
+ });
2210
2592
  }
2211
2593
  }
2212
2594
 
@@ -2244,10 +2626,14 @@ export class MmtpRouter {
2244
2626
  data: Uint8Array,
2245
2627
  packetId: number,
2246
2628
  catalogTrackName?: string,
2247
- ): void {
2629
+ ): boolean {
2248
2630
  // A Worker-recovered block arrives here whole: defer the gate's drains
2249
2631
  // until all its frames are in, as the in-band recovery path does.
2250
- this.#withDeferredFecPokes(() => this.#routeRecoveredBlock(blockId, data, packetId, catalogTrackName));
2632
+ let recovered = false;
2633
+ this.#withDeferredFecPokes(() => {
2634
+ recovered = this.#routeRecoveredBlock(blockId, data, packetId, catalogTrackName);
2635
+ });
2636
+ return recovered;
2251
2637
  }
2252
2638
 
2253
2639
  /** Route a single recovered source symbol through the parser + dedup pipeline.
@@ -2258,10 +2644,10 @@ export class MmtpRouter {
2258
2644
  packetId: number,
2259
2645
  esi = 0,
2260
2646
  catalogTrackName?: string,
2261
- ): void {
2647
+ ): boolean {
2262
2648
  const blockProfile = this.#blockProfileFor(packetId);
2263
2649
  const result = processFecSymbol(data, { ownerPacketId: packetId, index: esi, blockProfile });
2264
- this.#routeVerifiedFecSymbols(blockId, result, packetId, catalogTrackName);
2650
+ return this.#routeVerifiedFecSymbols(blockId, result, packetId, catalogTrackName);
2265
2651
  }
2266
2652
 
2267
2653
  // ── Keyframe timer cancellation ─────────────────────────────────
@@ -2298,6 +2684,9 @@ export class MmtpRouter {
2298
2684
  }
2299
2685
  }
2300
2686
  this.#parser.resetTrack(packetId, catalogTrackName);
2687
+ const assistPrefix = assistKey(packetId, catalogTrackName, 0).slice(0, -1);
2688
+ for (const key of this.#assistBlocks.keys()) if (key.startsWith(assistPrefix)) this.#assistBlocks.delete(key);
2689
+ for (const key of this.#assistFailures.keys()) if (key.startsWith(assistPrefix)) this.#assistFailures.delete(key);
2301
2690
  const streamKey = this.#mediaStreamKey(packetId, catalogTrackName);
2302
2691
  this.#trackSynced.delete(streamKey);
2303
2692
  this.#droppedPreSyncVideo.delete(streamKey);
@@ -2310,7 +2699,6 @@ export class MmtpRouter {
2310
2699
  this.#lastDirectSourceAudioCursorByStream.delete(streamKey);
2311
2700
  this.#lastDirectEmittedAudioCursorByStream.delete(streamKey);
2312
2701
  this.#pendingDirectRecoveredAudioByStream.delete(streamKey);
2313
- this.#manifestRecoveryQueues.delete(streamKey);
2314
2702
  this.#deliveredVideoHighWaterByStream.delete(streamKey);
2315
2703
  this.#stream(packetId, catalogTrackName).duplicates.clear();
2316
2704
  const assemblersToReset = new Set<CmafBlockAssembler>();
@@ -2362,7 +2750,6 @@ export class MmtpRouter {
2362
2750
  this.#lastDirectSourceAudioCursorByStream.delete(key);
2363
2751
  this.#lastDirectEmittedAudioCursorByStream.delete(key);
2364
2752
  this.#pendingDirectRecoveredAudioByStream.delete(key);
2365
- this.#manifestRecoveryQueues.delete(key);
2366
2753
  this.#trackInitDelivered.delete(key);
2367
2754
  this.#parserDiagnostics.delete(key);
2368
2755
  this.#pendingFecPokes.delete(key);
@@ -2658,8 +3045,9 @@ export class MmtpRouter {
2658
3045
  this.#parserDiagnostics.clear();
2659
3046
  this.#catalogTrackNamesByPacketId.clear();
2660
3047
  this.#repairOwnersByPacketId.clear();
2661
- if (this.#ownsManifestVerifier) this.#manifestVerifier?.dispose();
2662
- this.#manifestRecoveryQueues.clear();
3048
+ for (const { receiver } of this.#provenance.values()) receiver.release();
3049
+ this.#provenance.clear();
3050
+ this.#provenanceStreamFlow.clear();
2663
3051
  for (const assembler of this.#mseAssemblers.values()) assembler.dispose();
2664
3052
  this.#mseAssemblers.clear();
2665
3053
  }
@@ -2778,6 +3166,28 @@ export class MmtpRouter {
2778
3166
  return this.#parser.fecReleaseEarliestHeldAtMs(packetId, catalogTrackName);
2779
3167
  }
2780
3168
 
3169
+ /**
3170
+ * Whether a complete RAP above SS_ID `ssId` entered this stream's FEC
3171
+ * release gate (see FecReleaseSequencer.rapAbove).
3172
+ */
3173
+ fecReleaseRapAbove(packetId: number, catalogTrackName: string | undefined, ssId: number): boolean {
3174
+ return this.#parser.fecReleaseRapAbove(packetId, catalogTrackName, ssId);
3175
+ }
3176
+
3177
+ /**
3178
+ * Observe each complete RAP entering an FEC release gate, with the SS_ID
3179
+ * its frame completed in. Called synchronously from the frame push: an
3180
+ * observer that drives FEC state must defer that work.
3181
+ */
3182
+ addFecRapObserver(
3183
+ observer: (packetId: number, catalogTrackName: string | undefined, ssId: number) => void,
3184
+ ): () => void {
3185
+ this.#fecRapObservers.add(observer);
3186
+ return () => {
3187
+ this.#fecRapObservers.delete(observer);
3188
+ };
3189
+ }
3190
+
2781
3191
  #requestFecPoke(packetId: number, catalogTrackName: string | undefined): void {
2782
3192
  if (this.#disposed) return;
2783
3193
  if (this.#fecPokeDepth > 0) {
@@ -2828,6 +3238,10 @@ export class MmtpRouter {
2828
3238
  this.#onFrameGap?.(decision);
2829
3239
  return decision;
2830
3240
  },
3241
+ isRap: (data, meta) => this.#isRapForGap(data, meta),
3242
+ onRap: (packetId, catalogTrackName, ssId) => {
3243
+ for (const observer of this.#fecRapObservers) observer(packetId, catalogTrackName, ssId);
3244
+ },
2831
3245
  onReleased: (data, meta) => {
2832
3246
  if (meta.trackType !== "video" || meta.mpuSequenceNumber === undefined) return;
2833
3247
  const timing = meta.authoredTiming;
@@ -3013,7 +3427,6 @@ export class MmtpRouter {
3013
3427
  #processMMTP(
3014
3428
  packet: Uint8Array,
3015
3429
  source: "multicast" | "moq",
3016
- manifestIdentity?: ManifestObjectIdentity,
3017
3430
  manifestAdmitted = false,
3018
3431
  ingress?: MmtpIngressContext,
3019
3432
  overlapCopy = false,
@@ -3062,57 +3475,43 @@ export class MmtpRouter {
3062
3475
  this.#classifyPacketId(info.packetId) !== null &&
3063
3476
  this.#configuredBlockProfile(info.packetId) === undefined;
3064
3477
  if (fecUnreadable && !manifestAdmitted) this.#noteUnreadableFecPayloadId(info.packetId, "source");
3478
+ if (source === "multicast" && manifestAdmitted && !info.isRepair && ingress?.flow !== undefined) {
3479
+ let byName = this.#provenanceStreamFlow.get(info.packetId);
3480
+ if (!byName) this.#provenanceStreamFlow.set(info.packetId, (byName = new Map()));
3481
+ byName.set(catalogTrackNameForPacket(ingress, info.packetId), ingress.flow);
3482
+ }
3065
3483
 
3066
- if (!info.isRepair && this.#manifestVerifier && !manifestAdmitted) {
3067
- let identity = manifestIdentity;
3068
- if (!identity && info.fecPayloadId && !fecUnreadable && (this.#resolveManifestIdentityForRoute || this.#resolveManifestIdentity)) {
3069
- // A source resolves with its block coordinates (SS_Start, ESI), the
3070
- // pair a recovered symbol of the same block resolves with. SS_ID alone
3071
- // locates no block (C.5.3): until a repair states this source's block
3072
- // it has none, its identity stays absent, and the verifier's
3073
- // missing-identity policy below applies.
3074
- const catalogTrackName = catalogTrackNameForPacket(ingress, info.packetId);
3075
- const coordinates = this.#locateFecSource(info.packetId, catalogTrackName, info.fecPayloadId.ssId);
3076
- identity = coordinates === null ? undefined : (this.#resolveManifestIdentityForRoute
3077
- ? this.#resolveManifestIdentityForRoute({
3078
- packetId: info.packetId,
3079
- sbn: coordinates.sbn,
3080
- esi: coordinates.esi,
3081
- ...(catalogTrackName === undefined ? {} : { catalogTrackName }),
3082
- recovered: false,
3083
- sourceSymbolFormat: "full-mmtp-packet",
3084
- })
3085
- : this.#resolveManifestIdentity!(
3086
- info.packetId,
3087
- coordinates.sbn,
3088
- coordinates.esi,
3089
- catalogTrackName,
3090
- )) ?? undefined;
3091
- }
3092
- if (identity) {
3093
- identity = {
3094
- ...identity,
3095
- packetId: info.packetId,
3096
- ...(info.fecPayloadId ? { ssId: info.fecPayloadId.ssId } : {}),
3097
- };
3098
- const authenticated = info.fecPayloadId ? packet.subarray(0, packet.length - 4) : packet;
3099
- // A `stall` admission quarantines the packet and releases it later,
3100
- // after the stream has moved on; a synchronous release happens inside
3101
- // admitObject, before this flag is set.
3102
- let quarantined = false;
3103
- const admission = this.#manifestVerifier.admitObject(identity, authenticated, () => {
3104
- if (!this.#disposed) {
3105
- this.#processMMTP(packet, source, identity, true, ingress, overlapCopy, quarantined);
3106
- }
3107
- });
3108
- quarantined = admission.action === "stall";
3484
+ // A protected flow admits every datagram, repair included, before it is
3485
+ // read (7.2.7): an unadmitted repair symbol never reaches the decoder.
3486
+ if (source === "multicast" && !manifestAdmitted && ingress?.flow !== undefined) {
3487
+ const protectedFlow = this.#provenance.get(ingress.flow);
3488
+ // Named but not registered (before registration, after release): the
3489
+ // flow is protected, so nothing admits this datagram. Fail closed.
3490
+ if (!protectedFlow) {
3491
+ this.#unregisteredFlowDrops++;
3492
+ debugRouter(`[MmtpRouter] Dropping multicast packet pid=${info.packetId}: flow ${ingress.flow} has no receiver`);
3109
3493
  return;
3110
3494
  }
3111
- if (
3112
- this.#manifestVerifier.config.mode === "enforce" &&
3113
- this.#manifestVerifier.config.onMissingManifest === "stall"
3114
- ) {
3115
- console.warn(`[MmtpRouter] manifest identity absent; stalling packetId=${info.packetId}`);
3495
+ // Held: released by a record (feedProvenanceRecord) or expired. Duplicate / overflow: dropped.
3496
+ const { verdict } = protectedFlow.receiver.onDatagram(packet, "multicast");
3497
+ if (verdict === "admitted") {
3498
+ this.#noteFlowAdmitted(ingress.flow);
3499
+ this.#processMMTP(packet, source, true, ingress, overlapCopy);
3500
+ }
3501
+ else if (verdict === "held") {
3502
+ this.#heldIngress.set(packet, ingress!);
3503
+ this.#heldAt.set(packet, performance.now());
3504
+ }
3505
+ return;
3506
+ }
3507
+
3508
+ // A MoQ packet of a track a protected flow lists is a datagram of that
3509
+ // flow on its unicast path (7.2.7): admitted before it is read, or held.
3510
+ if (source === "moq" && !manifestAdmitted) {
3511
+ const admit = this.#admitUnicast(info.packetId, packet, () =>
3512
+ this.#processMMTP(packet, source, true, ingress, overlapCopy, true), overlapCopy);
3513
+ if (admit !== "unlisted") {
3514
+ if (admit === "admitted") this.#processMMTP(packet, source, true, ingress, overlapCopy);
3116
3515
  return;
3117
3516
  }
3118
3517
  }
@@ -3127,6 +3526,11 @@ export class MmtpRouter {
3127
3526
  const ownerPacketId = this.#resolveRepairOwnerPacketId(info.packetId, ingress);
3128
3527
  if (ownerPacketId != null && this.#fecManager) {
3129
3528
  if (hasAmbiguousCatalogTrackName(ingress, ownerPacketId)) return;
3529
+ // An unadmitted MoQ symbol reaching here is unlisted (#admitUnicast).
3530
+ if (
3531
+ source === "moq" && !manifestAdmitted &&
3532
+ this.#setAsideUnlistedRepair(ownerPacketId, catalogTrackNameForPacket(ingress, ownerPacketId), repair)
3533
+ ) return;
3130
3534
  this.#repairPidCounts[info.packetId] = (this.#repairPidCounts[info.packetId] || 0) + 1;
3131
3535
  const count = this.#repairPidCounts[info.packetId]!;
3132
3536
  if (count <= 2 || count % 500 === 0) {
@@ -3203,8 +3607,8 @@ export class MmtpRouter {
3203
3607
  // Start a fresh epoch for the stream before this packet is looked up.
3204
3608
  // The reset needs the catalog identity, so a stream without one is not
3205
3609
  // checked; an overlap copy is MoQ bytes and says nothing about the
3206
- // multicast publisher, and neither does a packet the manifest verifier
3207
- // quarantined: it predates the horizon because it waited, not because the
3610
+ // multicast publisher, and neither does a packet a provenance hold queue
3611
+ // released: it predates the horizon because it waited, not because the
3208
3612
  // publisher restarted.
3209
3613
  if (
3210
3614
  source === "multicast" &&
@@ -3433,37 +3837,30 @@ export class MmtpRouter {
3433
3837
  sourceFec.symbolData,
3434
3838
  catalogTrackName,
3435
3839
  );
3436
- if (recovered) {
3437
- if (!alreadyCompleted && this.#classifyPacketId(info.packetId) !== null) {
3438
- if (catalogTrackName === undefined) {
3439
- this.#fecManager.cancelBlockTimeout(sourceFec.sbn, info.packetId);
3440
- } else {
3441
- this.#fecManager.cancelBlockTimeout(
3442
- sourceFec.sbn,
3443
- info.packetId,
3444
- catalogTrackName,
3445
- );
3446
- }
3447
- }
3448
- // Only route recovered blocks that needed actual repair.
3449
- if (!alreadyCompleted) {
3450
- this.#fecManager.countRecovery(info.packetId, sourceFec.sbn, catalogTrackName);
3840
+ if (!recovered) {
3841
+ this.#noteAssistAdmitted(
3842
+ info.packetId, catalogTrackName, sourceFec.sbn,
3843
+ { esi: serial32Distance(sourceFec.sbn, sourceFec.ssId) }, sourceFec.symbolData,
3844
+ );
3845
+ }
3846
+ // Only route recovered blocks that needed actual repair; complete
3847
+ // one only once admission has released its recovered packets.
3848
+ if (recovered && !alreadyCompleted) {
3849
+ const fec = this.#fecManager;
3850
+ const sbn = sourceFec.sbn;
3851
+ const packetId = info.packetId;
3852
+ const tracked = this.#classifyPacketId(packetId) !== null;
3853
+ this.#routeRecoveredBlock(sbn, recovered, packetId, catalogTrackName, () => {
3451
3854
  if (catalogTrackName === undefined) {
3452
- this.#fecManager.markBlockCompleted(sourceFec.sbn, info.packetId);
3855
+ if (tracked) fec.cancelBlockTimeout(sbn, packetId);
3856
+ fec.countRecovery(packetId, sbn, catalogTrackName);
3857
+ fec.markBlockCompleted(sbn, packetId);
3453
3858
  } else {
3454
- this.#fecManager.markBlockCompleted(
3455
- sourceFec.sbn,
3456
- info.packetId,
3457
- catalogTrackName,
3458
- );
3859
+ if (tracked) fec.cancelBlockTimeout(sbn, packetId, catalogTrackName);
3860
+ fec.countRecovery(packetId, sbn, catalogTrackName);
3861
+ fec.markBlockCompleted(sbn, packetId, catalogTrackName);
3459
3862
  }
3460
- this.#routeRecoveredBlock(
3461
- sourceFec.sbn,
3462
- recovered,
3463
- info.packetId,
3464
- catalogTrackName,
3465
- );
3466
- }
3863
+ });
3467
3864
  }
3468
3865
  }
3469
3866
  }
@@ -4068,7 +4465,8 @@ export class MmtpRouter {
4068
4465
  recovered: Uint8Array,
4069
4466
  ownerPacketId?: number,
4070
4467
  catalogTrackName?: string,
4071
- ): void {
4468
+ complete?: () => void,
4469
+ ): boolean {
4072
4470
  // MMTP path: use processFecBlock for MMTP-wrapped recovery.
4073
4471
  // Recovered symbols are individual MMTP packets that may be fragments
4074
4472
  // (FI=1/2/3) of a larger MFU. Route through the reassembler so
@@ -4092,118 +4490,83 @@ export class MmtpRouter {
4092
4490
  ownerPacketId,
4093
4491
  blockProfile,
4094
4492
  });
4095
- this.#routeVerifiedFecSymbols(blockId, result, ownerPacketId, catalogTrackName);
4493
+ return this.#routeVerifiedFecSymbols(blockId, result, ownerPacketId, catalogTrackName, complete);
4096
4494
  }
4097
4495
 
4496
+ /**
4497
+ * A recovered source packet of a protected flow is admitted on its own
4498
+ * digest: its bytes with the SS_ID it was sent with (SS_Start + ESI)
4499
+ * appended are the datagram the record listed (7.2.7). One that no record
4500
+ * lists is dropped; holding it would let it out later as a multicast arrival.
4501
+ * Returns whether the decode recovered the block: every packet it yields
4502
+ * that this router has not parsed already is admitted. A block one of whose
4503
+ * recovered packets is refused is unrecovered (7.2.7): its caller leaves it
4504
+ * open, so it reaches its FEC deadline and fails by class. Always true
4505
+ * outside a protected flow.
4506
+ */
4098
4507
  #routeVerifiedFecSymbols(
4099
4508
  blockId: number,
4100
4509
  result: ProcessFecBlockResult,
4101
4510
  ownerPacketId?: number,
4102
4511
  catalogTrackName?: string,
4103
- ): void {
4104
- if (!this.#manifestVerifier || ownerPacketId == null) {
4105
- this.#routeProcessedFecSymbols(blockId, result, ownerPacketId, catalogTrackName);
4106
- return;
4107
- }
4108
- if (!this.#resolveManifestIdentity && !this.#resolveManifestIdentityForRoute) {
4109
- console.warn(`[MmtpRouter] manifest identity resolver unavailable; recovered media is fail-open for packetId=${ownerPacketId} block=${blockId}`);
4110
- this.#routeProcessedFecSymbols(blockId, result, ownerPacketId, catalogTrackName);
4111
- return;
4512
+ complete?: () => void,
4513
+ ): boolean {
4514
+ const receiver = ownerPacketId == null ? undefined : this.#provenanceFor(ownerPacketId, catalogTrackName);
4515
+ let recovered = true;
4516
+ if (receiver) {
4517
+ const parsed = this.#stream(ownerPacketId!, catalogTrackName).duplicates.parsedSymbols;
4518
+ result = {
4519
+ ...result,
4520
+ symbols: result.symbols.filter((sym) => {
4521
+ const ssId = this.#fecAnchorForRecovered(blockId, sym.index);
4522
+ // A received copy was admitted on arrival; its recovered twin is a duplicate.
4523
+ if (parsed.has(ssId)) return false;
4524
+ let admitted = false;
4525
+ if (receiver === "ambiguous") {
4526
+ this.#ambiguousRecoveredDrops++;
4527
+ } else {
4528
+ const wire = new Uint8Array(sym.rawSymbol.byteLength + 4);
4529
+ wire.set(sym.rawSymbol);
4530
+ new DataView(wire.buffer).setUint32(sym.rawSymbol.byteLength, ssId);
4531
+ admitted = receiver.admitRecovered(wire);
4532
+ }
4533
+ if (!admitted) recovered = false;
4534
+ return admitted;
4535
+ }),
4536
+ };
4112
4537
  }
4113
- const queueKey = this.#mediaStreamKey(ownerPacketId, catalogTrackName);
4114
- const queue = this.#manifestRecoveryQueues.get(queueKey) ?? [];
4115
- queue.push({ blockId, result, ownerPacketId, catalogTrackName });
4116
- this.#manifestRecoveryQueues.set(queueKey, queue);
4117
- if (queue.length > 1) return;
4118
- const drain = (): void => {
4119
- const next = queue[0];
4120
- if (!next) {
4121
- this.#manifestRecoveryQueues.delete(queueKey);
4122
- return;
4123
- }
4124
- this.#admitRecoveredResult(
4125
- next.blockId,
4126
- next.result,
4127
- next.ownerPacketId,
4128
- next.catalogTrackName,
4129
- () => {
4130
- queue.shift();
4131
- drain();
4132
- },
4133
- );
4134
- };
4135
- drain();
4538
+ if (recovered) complete?.();
4539
+ this.#routeProcessedFecSymbols(blockId, result, ownerPacketId, catalogTrackName);
4540
+ return recovered;
4136
4541
  }
4137
4542
 
4138
- #admitRecoveredResult(
4139
- blockId: number,
4140
- result: ProcessFecBlockResult,
4141
- ownerPacketId: number,
4543
+ /**
4544
+ * The receiver a recovered packet of `packetId` answers to: the flow that
4545
+ * admitted the stream's source datagrams. Before any was admitted, the one
4546
+ * flow whose registration lists the stream; more than one (a repair flow
4547
+ * also lists its owners' source tracks) cannot be told apart, so none.
4548
+ */
4549
+ #provenanceFor(
4550
+ packetId: number,
4142
4551
  catalogTrackName: string | undefined,
4143
- done: () => void,
4144
- ): void {
4145
- const verifier = this.#manifestVerifier;
4146
- const resolveIdentity = this.#resolveManifestIdentity;
4147
- const resolveIdentityForRoute = this.#resolveManifestIdentityForRoute;
4148
- if (!verifier) {
4149
- this.#routeProcessedFecSymbols(blockId, result, ownerPacketId, catalogTrackName);
4150
- done();
4151
- return;
4152
- }
4153
- if (!resolveIdentity && !resolveIdentityForRoute) throw new Error("manifest identity resolver disappeared during admission");
4154
- const symbols = result.symbols;
4155
- const admitNext = (index: number): void => {
4156
- const symbol = symbols[index];
4157
- if (!symbol) {
4158
- done();
4159
- return;
4160
- }
4161
- const identity = resolveIdentityForRoute
4162
- ? resolveIdentityForRoute({
4163
- packetId: ownerPacketId,
4164
- sbn: blockId,
4165
- esi: symbol.index,
4166
- ...(catalogTrackName === undefined ? {} : { catalogTrackName }),
4167
- recovered: true,
4168
- sourceSymbolFormat: "fec-padded-recovery",
4169
- })
4170
- : resolveIdentity!(ownerPacketId, blockId, symbol.index, catalogTrackName);
4171
- if (!identity) {
4172
- console.warn(`[MmtpRouter] manifest identity unresolved; recovered symbol is fail-open for packetId=${ownerPacketId} block=${blockId} esi=${symbol.index}`);
4173
- this.#routeProcessedFecSymbols(
4174
- blockId,
4175
- { ...result, symbols: [symbol] },
4176
- ownerPacketId,
4177
- catalogTrackName,
4178
- );
4179
- admitNext(index + 1);
4180
- return;
4552
+ ): BcProvenanceV2Receiver | "ambiguous" | undefined {
4553
+ // A caller that names no track matches every name the packet id was admitted under.
4554
+ const byName = this.#provenanceStreamFlow.get(packetId);
4555
+ const flows = new Set(
4556
+ catalogTrackName === undefined || !byName
4557
+ ? byName?.values()
4558
+ : byName.has(catalogTrackName) ? [byName.get(catalogTrackName)!] : [],
4559
+ );
4560
+ if (flows.size > 1) return "ambiguous";
4561
+ for (const bound of flows) return this.#provenance.get(bound)?.receiver ?? "ambiguous";
4562
+ let match: BcProvenanceV2Receiver | undefined;
4563
+ for (const { receiver, ingress } of this.#provenance.values()) {
4564
+ if (ingress.tracks.some((t) => t.packetId === packetId && (catalogTrackName === undefined || t.name === catalogTrackName))) {
4565
+ if (match) return "ambiguous";
4566
+ match = receiver;
4181
4567
  }
4182
- const admission = verifier.admitObject(
4183
- {
4184
- ...identity,
4185
- packetId: ownerPacketId,
4186
- },
4187
- symbol.rawSymbol,
4188
- () => {
4189
- if (!this.#disposed) {
4190
- this.#routeProcessedFecSymbols(
4191
- blockId,
4192
- { ...result, symbols: [symbol] },
4193
- ownerPacketId,
4194
- catalogTrackName,
4195
- );
4196
- }
4197
- admitNext(index + 1);
4198
- },
4199
- // A stalled symbol that is later discarded instead of played must still
4200
- // advance this loop; enforce mode never calls the release callback for
4201
- // it (BLO-30095).
4202
- () => admitNext(index + 1),
4203
- );
4204
- if (admission.action === "drop") admitNext(index + 1);
4205
- };
4206
- admitNext(0);
4568
+ }
4569
+ return match;
4207
4570
  }
4208
4571
 
4209
4572
  #routeProcessedFecSymbols(