@blockcast/mmt-container 2.2.0-main.c4fa878f4ff4 → 2.2.0-main.e75bbd7576a6

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 (73) hide show
  1. package/README.md +29 -17
  2. package/dist/browser-runtime.js +703 -175
  3. package/dist/browser-runtime.js.map +3 -3
  4. package/dist/catalog-fec-params.d.ts +17 -1
  5. package/dist/catalog-fec-params.d.ts.map +1 -1
  6. package/dist/catalog-fec-params.js +15 -0
  7. package/dist/catalog-fec-params.js.map +2 -2
  8. package/dist/cmcd-producer.d.ts +60 -47
  9. package/dist/cmcd-producer.d.ts.map +1 -1
  10. package/dist/cmcd-producer.js +138 -109
  11. package/dist/cmcd-producer.js.map +2 -2
  12. package/dist/delivery-catalog.js +26 -16
  13. package/dist/delivery-catalog.js.map +2 -2
  14. package/dist/delta-fallback-config.js.map +1 -1
  15. package/dist/fec-manager.d.ts +33 -1
  16. package/dist/fec-manager.d.ts.map +1 -1
  17. package/dist/fec-manager.js +222 -6
  18. package/dist/fec-manager.js.map +2 -2
  19. package/dist/fec-worker-setup.d.ts +1 -1
  20. package/dist/fec-worker-setup.js +1 -1
  21. package/dist/fec-worker-setup.js.map +1 -1
  22. package/dist/index.d.ts +3 -3
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +5 -1
  25. package/dist/index.js.map +2 -2
  26. package/dist/iso-block-locator.d.ts +9 -0
  27. package/dist/iso-block-locator.d.ts.map +1 -1
  28. package/dist/iso-block-locator.js +17 -0
  29. package/dist/iso-block-locator.js.map +2 -2
  30. package/dist/iso-bmff-timing.d.ts +10 -1
  31. package/dist/iso-bmff-timing.d.ts.map +1 -1
  32. package/dist/iso-bmff-timing.js +36 -2
  33. package/dist/iso-bmff-timing.js.map +2 -2
  34. package/dist/media-router.js.map +1 -1
  35. package/dist/mmtp-container-parser.d.ts +37 -2
  36. package/dist/mmtp-container-parser.d.ts.map +1 -1
  37. package/dist/mmtp-container-parser.js +86 -9
  38. package/dist/mmtp-container-parser.js.map +2 -2
  39. package/dist/mmtp-container-parser.no-modules.js +89 -11
  40. package/dist/mmtp-container-parser.no-modules.js.map +2 -2
  41. package/dist/mmtp-decode-pipeline.js +85 -9
  42. package/dist/mmtp-decode-pipeline.js.map +2 -2
  43. package/dist/mmtp-router.d.ts +18 -1
  44. package/dist/mmtp-router.d.ts.map +1 -1
  45. package/dist/mmtp-router.js +304 -39
  46. package/dist/mmtp-router.js.map +2 -2
  47. package/dist/multicast-manager.js +526 -45
  48. package/dist/multicast-manager.js.map +2 -2
  49. package/dist/multicast-transport.js +26 -16
  50. package/dist/multicast-transport.js.map +2 -2
  51. package/dist/no-modules.js +85 -9
  52. package/dist/no-modules.js.map +2 -2
  53. package/dist/repair-layer-subscriptions.d.ts +6 -2
  54. package/dist/repair-layer-subscriptions.d.ts.map +1 -1
  55. package/dist/repair-layer-subscriptions.js +6 -3
  56. package/dist/repair-layer-subscriptions.js.map +2 -2
  57. package/dist/track-session.d.ts +1 -1
  58. package/dist/track-session.js.map +1 -1
  59. package/dist/wasm-mmtp-container-parser.js +85 -9
  60. package/dist/wasm-mmtp-container-parser.js.map +2 -2
  61. package/package.json +8 -5
  62. package/schemas/catalog.schema.json +4 -0
  63. package/src/catalog-fec-params.ts +36 -1
  64. package/src/cmcd-producer.ts +189 -179
  65. package/src/fec-manager.ts +277 -6
  66. package/src/fec-worker-setup.ts +2 -2
  67. package/src/index.ts +7 -2
  68. package/src/iso-block-locator.ts +19 -0
  69. package/src/iso-bmff-timing.ts +40 -1
  70. package/src/mmtp-container-parser.ts +96 -8
  71. package/src/mmtp-router.ts +350 -13
  72. package/src/repair-layer-subscriptions.ts +13 -5
  73. package/src/track-session.ts +1 -1
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../src/transport-pool.ts", "../src/track-session.ts"],
4
- "sourcesContent": ["/**\n * TransportPool \u2014 ref-counted subscription management for per-track transport.\n *\n * Tracks which consumers (identified by trackName) hold references to which\n * subscriptions (identified by a string key). When the last consumer releases\n * a subscription, it is closed.\n */\n\n/** Transport subscription mode \u2014 matches TrackState in the state machine. */\nexport type SubscriptionMode = 'multicast' | 'amt' | 'moq'\n\n/** Direct Socket health report (IWA transport). */\nexport interface DirectSocketHealth {\n connected: boolean\n bytesReceived: number\n packetsReceived: number\n errors: number\n}\n\n/** Subscription handle returned by acquireSubscription. */\nexport interface Subscription {\n key: string\n mode: SubscriptionMode\n consumers: ReadonlySet<string>\n}\n\n/** Stats for a subscription. */\nexport interface SubscriptionStats {\n activeSubscriptions: number\n totalAcquires: number\n totalReleases: number\n}\n\n/** Options for acquiring a subscription. */\nexport interface AcquireOptions {\n /** Subscription key (e.g., \"(S,G,port)\" for multicast). */\n key: string\n /** Transport mode. */\n mode: SubscriptionMode\n /** Consumer identity (trackName). */\n consumer: string\n}\n\nexport interface TransportPoolOptions {\n /** Maximum concurrent subscriptions. 0 = unlimited. */\n maxSubscriptions?: number\n}\n\ntype FailedCallback = (sub: { key: string; consumers: Set<string>; error: unknown }) => void\n\nexport class TransportPool {\n readonly #subs = new Map<string, Subscription>()\n readonly #options: TransportPoolOptions\n #failedCb: FailedCallback | null = null\n #stats: SubscriptionStats = { activeSubscriptions: 0, totalAcquires: 0, totalReleases: 0 }\n\n constructor(options?: TransportPoolOptions) {\n this.#options = options ?? {}\n }\n\n /** Register callback for subscription failures. */\n onSubscriptionFailed(cb: FailedCallback): void {\n this.#failedCb = cb\n }\n\n /** Acquire a subscription for a consumer. Returns the subscription. */\n acquireSubscription(opts: AcquireOptions): Subscription\n acquireSubscription(key: string, consumer: string): Subscription\n acquireSubscription(keyOrOpts: string | AcquireOptions, consumer?: string): Subscription {\n const key = typeof keyOrOpts === 'string' ? keyOrOpts : keyOrOpts.key\n const cons = typeof keyOrOpts === 'string' ? consumer! : keyOrOpts.consumer\n const mode: SubscriptionMode = typeof keyOrOpts === 'object' ? keyOrOpts.mode : 'moq'\n\n let sub = this.#subs.get(key)\n if (!sub) {\n sub = { key, mode, consumers: new Set() }\n this.#subs.set(key, sub)\n this.#stats.activeSubscriptions++\n }\n ;(sub.consumers as Set<string>).add(cons)\n this.#stats.totalAcquires++\n return sub\n }\n\n /** Release a subscription for a consumer. Closes if last consumer. */\n releaseSubscription(key: string, consumer: string): void {\n const sub = this.#subs.get(key)\n if (!sub) return\n ;(sub.consumers as Set<string>).delete(consumer)\n this.#stats.totalReleases++\n if (sub.consumers.size === 0) {\n this.#subs.delete(key)\n this.#stats.activeSubscriptions--\n }\n }\n\n /** Release all subscriptions held by a consumer. */\n releaseAllForConsumer(consumer: string): void {\n for (const [key, sub] of this.#subs) {\n ;(sub.consumers as Set<string>).delete(consumer)\n if (sub.consumers.size === 0) {\n this.#subs.delete(key)\n this.#stats.activeSubscriptions--\n }\n }\n }\n\n /** Report a subscription failure. */\n reportFailure(key: string, error: unknown): void {\n const sub = this.#subs.get(key)\n if (sub) this.#failedCb?.({ key, consumers: sub.consumers as Set<string>, error })\n }\n\n /** Get stats. */\n get stats(): Readonly<SubscriptionStats> { return this.#stats }\n\n /** Clean up all subscriptions. */\n dispose(): void {\n this.#subs.clear()\n this.#failedCb = null\n this.#stats.activeSubscriptions = 0\n }\n\n /** Number of active subscriptions. */\n get size(): number {\n return this.#subs.size\n }\n}\n", "/**\n * TrackSession \u2014 per-track transport state machine.\n * SessionManager \u2014 orchestrates all sessions + TransportPool.\n *\n * Implements Patent Claim 9 (draft-ramadan-moq-fec APPLICATION-1):\n * (a) subscribe unicast\n * (b) simultaneously join multicast\n * (c) render from unicast during join\n * (d) activate multicast on RAP, suppress unicast\n * (e) cross-transport FEC repair during transition\n * (f) hysteresis before switching away from multicast\n * (g) re-subscribe unicast on sustained failure\n *\n * PIM: manages transport state without inspecting packet contents.\n */\n\nimport {\n TransportPool,\n type Subscription,\n type SubscriptionMode,\n type AcquireOptions,\n type DirectSocketHealth,\n type TransportPoolOptions,\n} from './transport-pool.js'\nimport { fecBlockBudgetMs } from '@blockcast/mmt-fec'\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport type TrackState =\n | 'idle'\n | 'unicast'\n | 'unicast-fallback'\n | 'joining'\n | 'multicast'\n | 'amt'\n | 'failing'\n\nexport interface TrackSessionConfig {\n trackName: string\n trackType: 'video' | 'audio'\n packetId: number\n /** FEC interleave depth from catalog (ms). Used for hysteresis timer. */\n fecInterleaveMs: number\n /** Explicit catalog jitter/reorder allowance in ms. */\n fecReorderToleranceMs?: number\n /** FEC K from catalog. Used for audio worklet latency calculation. */\n fecK: number\n /** FEC repair symbols per block (P). NOT symbol size. */\n fecRepairCount: number\n /**\n * Repair symbols per block this receiver actually joined: the sum of\n * `repairSymbols` over the joined BASE layers, not a count of layers\n * (draft-ramadan-moq-fec 6.3). A keyframe overlay is a separate FEC instance\n * and is not included.\n *\n * This is runtime join state, not catalog geometry: a receiver on a clean\n * path joins layer 0 only while a lossy one adds layers, so the catalog's\n * total P is not what this receiver is receiving. Diagnostics that report P\n * (the worker snapshot, CMCD `x-bc-fec-p`) must read this.\n *\n * Absent when the caller joins no layers explicitly, in which case P_0 is\n * what was joined. That is an identity on a single-layer track, not a\n * catalog default: with one layer, P_0 is the total.\n */\n joinedRepairCount?: number\n /** FEC symbol size in bytes (T). Required for WASM decoder OTI. */\n fecSymbolSize: number\n /** MoQ repair track name from catalog FEC config. */\n repairTrackName?: string\n /** Frame samples from initData parsing (audio-init-parser.ts). */\n frameSamples?: number\n /** Audio sample rate from catalog. */\n sampleRate?: number\n /** Ordered transport preference: best first. */\n preferredTransports: SubscriptionMode[]\n /** Subscription key for multicast/AMT: \"(S,G,port)\". */\n multicastKey?: string\n /** MoQ track name for unicast subscription. */\n moqTrackName?: string\n /** AMT relay address (if AMT is in preference chain). */\n amtRelay?: string\n /** MoQ relay URL (for WebTransport subscription). */\n moqRelayUrl?: string\n}\n\n// ---------------------------------------------------------------------------\n// PIM callbacks (protocol-independent \u2014 transport state only)\n// ---------------------------------------------------------------------------\n\nexport interface SessionManagerCallbacks {\n /** Fired on every transport state transition. */\n onStateChange?(trackName: string, from: TrackState, to: TrackState): void\n /** Fired to suppress/unsuppress unicast for this track (transport arbitration). */\n onSuppress?(trackName: string, suppressed: boolean): void\n /**\n * Fired when a track sat in `joining` for SessionManager.takeoverDeadlineMs\n * without a RAP (BLO-9413/D9). The session has already returned to `unicast`\n * (nothing was suppressed, so nothing to restore) and it keeps its FEC\n * decoder armed, because the multicast socket is still joined and the router\n * still admits the stream's symbols. The host should abandon the router-side\n * attempt (e.g. MediaRouter.failbackToMoq()) so the router stops waiting for\n * a keyframe that is never coming; it must not dispose the decoder here. The\n * decoder is released on the next transport edge (unsubscribe, failbackAll,\n * or transportFailed falling back to unicast) through fec-teardown.\n */\n onTakeoverTimeout?(trackName: string): void\n}\n\n// ---------------------------------------------------------------------------\n// MMTP application-layer extension (FEC lifecycle, media resets)\n// ---------------------------------------------------------------------------\n\n/**\n * MMTP track events (discriminated union).\n * Each protocol layer defines its own event union \u2014 MMTP events cover\n * media resets, latency, and FEC lifecycle.\n */\nexport type MmtpTrackEvent =\n | { type: 'reset' }\n | { type: 'latency'; latencyMs: number }\n | { type: 'fec-setup' }\n | { type: 'fec-teardown' }\n\n/**\n * MMTP application-layer extension of SessionManagerCallbacks.\n * Single onTrackEvent dispatches all MMTP-specific events.\n * PIM consumers use the base SessionManagerCallbacks (no onTrackEvent).\n *\n * IWA can define its own subinterface with its own TrackEvent union\n * (e.g., IwaTrackEvent for socket health, AMT state) using the same pattern.\n */\nexport interface MmtpSessionCallbacks extends SessionManagerCallbacks {\n onTrackEvent?(trackName: string, event: MmtpTrackEvent, config: TrackSessionConfig): void\n}\n\n/**\n * Loss-hysteresis window covering the signaled FEC block span plus jitter.\n *\n * `fecK` no longer feeds the math; it is still validated as a catalog\n * well-formedness guard and kept in the signature for positional API compat\n * (removal is a follow-up with the next cross-repo pin bump).\n */\nexport function computeFecLossWindowMs(fecInterleaveMs: number, fecK: number, jitterBufferMs?: number): number {\n return computeFecDeliveryWindowMs({\n interleaveDepthMs: fecInterleaveMs,\n sourceSymbols: fecK,\n jitterBufferMs,\n })\n}\n\nexport interface FecDeliveryWindowConfig {\n interleaveDepthMs: number\n sourceSymbols: number\n repairSymbols?: number\n symbolSize?: number\n mediaUnitsPerSecond?: number\n bandwidthBps?: number\n /**\n * Explicit jitter/reorder allowance in ms, added once to the complete span.\n * Callers pass their signaled catalog allowance, never a local default.\n */\n jitterBufferMs?: number\n}\n\n/**\n * The catalog interleaveDepthMs is the complete FEC block span, including the\n * repair tail. K, P, cadence, and bandwidth must not expand it again. Geometry\n * fields remain accepted for existing callers, but do not feed deadline math.\n */\nexport function computeFecDeliveryWindowMs(\n config: FecDeliveryWindowConfig,\n): number {\n const fecK = config.sourceSymbols\n if (!Number.isFinite(fecK) || fecK <= 0) {\n throw new Error(\n `[TrackSession] fecK=${fecK}. ` +\n `Derive a positive FEC source-symbol count from the stream catalog.`\n )\n }\n return fecSpanDeliveryWindowMs(config.interleaveDepthMs, config.jitterBufferMs)\n}\n\n/**\n * The delivery window of a \u00A75.1 span alone. Every window is this; K only\n * validates a track's geometry in computeFecDeliveryWindowMs, so a caller\n * with a span and no K (FECManager's stream-global envelope) uses this\n * rather than invent one.\n */\nexport function fecSpanDeliveryWindowMs(interleaveDepthMs: number, jitterBufferMs?: number): number {\n if (!Number.isFinite(interleaveDepthMs) || interleaveDepthMs <= 0) {\n throw new Error(\n `[TrackSession] fecInterleaveMs=${interleaveDepthMs}. ` +\n `Derive a positive FEC interleave window from the stream catalog.`\n )\n }\n // Absence stays absent; it does not authorize a synthesized reorder budget.\n if (jitterBufferMs === undefined) return Math.ceil(interleaveDepthMs)\n return fecBlockBudgetMs(interleaveDepthMs, jitterBufferMs)\n}\n\n/** Convert the shared FEC delivery deadline into an integer media timescale. */\nexport function computeFecDeliveryWindowMediaUnits(\n config: FecDeliveryWindowConfig,\n timestampTimescale: number,\n): number {\n if (!Number.isFinite(timestampTimescale) || timestampTimescale <= 0) {\n throw new Error(\n `[TrackSession] timestampTimescale=${timestampTimescale}. ` +\n 'Derive a positive media timescale from the container.',\n )\n }\n const deliveryWindow = Math.ceil(\n computeFecDeliveryWindowMs(config) * timestampTimescale / 1000,\n )\n if (!Number.isSafeInteger(deliveryWindow)) {\n throw new Error('[TrackSession] FEC delivery window exceeds the media timestamp range.')\n }\n return deliveryWindow\n}\n\n// ---------------------------------------------------------------------------\n// TrackSession\n// ---------------------------------------------------------------------------\n\nexport class TrackSession {\n readonly config: TrackSessionConfig\n #state: TrackState = 'idle'\n #subscription: Subscription | null = null\n #hysteresisTimer: ReturnType<typeof setTimeout> | null = null\n #joinDeadlineTimer: ReturnType<typeof setTimeout> | null = null\n #previousPrimary: SubscriptionMode | null = null\n #manager: SessionManager\n // Single suppression authority flag (BLO-9413 failback coherence): every\n // suppress this session fires is recorded here, and every path that leaves\n // a suppressed state unwinds through #unsuppress(). Before this flag,\n // unsubscribe() only un-suppressed from multicast/amt \u2014 a session torn down\n // from `failing` (exactly where a starving track sits) was re-subscribed to\n // unicast with the host's media still muted: MoQ playing into a dead sink.\n #suppressFired = false\n // Single FEC authority flag: set by the fec-setup edges (joinStarted,\n // enterUnicastFallback), cleared by the fec-teardown edge in #transition.\n // Setup and teardown are paired by construction, so a takeover timeout that\n // parks the session in `unicast` keeps the decoder the still-joined\n // transport is feeding, and the later unsubscribe still tears it down.\n #fecArmed = false\n\n constructor(config: TrackSessionConfig, manager: SessionManager) {\n this.config = config\n this.#manager = manager\n }\n\n // -----------------------------------------------------------------------\n // State\n // -----------------------------------------------------------------------\n\n get state(): TrackState { return this.#state }\n get subscription(): Subscription | null { return this.#subscription }\n\n /** True while this session's onSuppress(true) is outstanding (no matching\n * onSuppress(false) yet). Test/assertion surface for failback coherence. */\n get isSuppressed(): boolean { return this.#suppressFired }\n\n /** Current active transport mode, or null if idle. */\n get transport(): SubscriptionMode | null {\n switch (this.#state) {\n case 'multicast': return 'multicast'\n case 'amt': return 'amt'\n case 'unicast': return 'moq'\n case 'unicast-fallback': return 'moq' // SSM bridge absent \u2014 MoQ unicast is the only source\n case 'joining': return 'moq' // MoQ still primary during join\n case 'failing': return this.#previousPrimary\n default: return null\n }\n }\n\n // -----------------------------------------------------------------------\n // Computed (no magic numbers)\n // -----------------------------------------------------------------------\n\n /** Audio worklet latency: max(fecInterleaveMs, frameDurationMs * fecK).\n * Returns undefined if frame duration unknown (no initData). */\n get audioLatencyMs(): number | undefined {\n const fd = this.frameDurationMs\n if (fd === undefined) {\n return this.config.fecInterleaveMs > 0 ? this.config.fecInterleaveMs : undefined\n }\n return Math.max(this.config.fecInterleaveMs, fd * this.config.fecK)\n }\n\n /** Frame duration in ms, derived from frameSamples + sampleRate. */\n get frameDurationMs(): number | undefined {\n const { frameSamples, sampleRate } = this.config\n if (!frameSamples || !sampleRate || sampleRate <= 0) return undefined\n return (frameSamples / sampleRate) * 1000\n }\n\n // -----------------------------------------------------------------------\n // Transitions (Patent Claim 9)\n // -----------------------------------------------------------------------\n\n /** idle \u2192 unicast: player subscribes to this track. */\n subscribe(): void {\n if (this.#state !== 'idle') return\n this.#transition('unicast')\n\n // Emit audio latency on first subscribe\n if (this.config.trackType === 'audio') {\n const latency = this.audioLatencyMs\n if (latency !== undefined) {\n this.#manager.mmtp?.onTrackEvent?.(this.config.trackName, { type: 'latency', latencyMs: latency }, this.config)\n }\n }\n }\n\n /** unicast \u2192 joining: multicast/AMT capability detected, join in progress. */\n joinStarted(): void {\n if (this.#state !== 'unicast') return\n this.#transition('joining')\n // FEC decoders must be ready before multicast data arrives (Claim 9e:\n // cross-transport repair). A session re-joining after a takeover timeout\n // still owns its decoder, so fec-setup fires once per FEC epoch.\n this.#armFec()\n }\n\n /**\n * any non-fallback state \u2192 unicast-fallback. Used when the SSM bridge is\n * absent (vanilla Chromium / no extension) and the host falls back to a\n * MoQ unicast subscription per track. Terminal-ish: activateMulticast,\n * failbackAll, and transportFailed are no-ops while in this state.\n *\n * Fires fec-setup so the per-track repair worker spins up \u2014 repair packets\n * still flow over the MoQ repair-track subscription, so FEC source+repair\n * can converge as in the multicast path.\n *\n * Audio latency is NOT re-emitted here: subscribe() already fires latency\n * once on the idle \u2192 unicast edge, and enterUnicastFallback is only ever\n * reached from a non-idle state (SessionManager skips idle sessions).\n */\n enterUnicastFallback(): void {\n if (this.#state === 'unicast-fallback') return\n this.#clearHysteresis()\n this.#transition('unicast-fallback')\n // Suppress the redundant unicast media decoder. On the no-bridge path the\n // host feeds MoQ-unicast source packets into MulticastManager (the render\n // consumer); a second base-media subscribe (the @moq/watch decoder) would\n // split the single connection's QUIC flow-control budget and stall delivery\n // (BLO-8969). Fire suppress here, on the fallback edge, so the decoder is\n // gated off before it subscribes rather than after the relay forward task\n // has already wedged.\n this.#suppress()\n this.#armFec()\n }\n\n /**\n * unicast-fallback \u2192 unicast. Called when the SSM bridge appears mid-session\n * (extension installed after fallback engaged). Host first aborts the fallback\n * drainers, then transitions sessions back to unicast so the regular\n * multicast/AMT preference chain can take over via joinStarted().\n */\n exitUnicastFallback(): void {\n if (this.#state !== 'unicast-fallback') return\n this.#transition('unicast')\n // Un-suppress the unicast decoder: the bridge appeared mid-session, so the\n // host tears down the fallback drainers and the regular preference chain\n // (joinStarted -> joining -> rapReceived) will re-suppress on multicast RAP.\n this.#unsuppress()\n }\n\n /**\n * joining \u2192 multicast or amt: RAP received on the joined transport (Claim 9d).\n * Resets audio worklet, then suppresses MoQ.\n */\n rapReceived(mode: 'multicast' | 'amt'): void {\n if (this.#state !== 'joining') return\n this.#previousPrimary = mode\n this.#transition(mode)\n if (this.config.trackType === 'audio') {\n this.#manager.mmtp?.onTrackEvent?.(this.config.trackName, { type: 'reset' }, this.config)\n }\n this.#suppress()\n }\n\n /** multicast/amt \u2192 failing: loss detected, start hysteresis timer (Claim 9f). */\n lossDetected(): void {\n if (this.#state !== 'multicast' && this.#state !== 'amt') return\n\n // A zero/absent catalog span carries no elapsed FEC deadline. The stall\n // detector that calls this method already supplied the generic loss\n // evidence, so there is no FEC hysteresis window to derive here. Follow\n // the existing transport-failure edge immediately instead of inventing a\n // local timeout for a catalog value that explicitly supplied none.\n if (this.config.fecInterleaveMs === 0 || this.config.fecInterleaveMs === undefined) {\n this.transportFailed()\n return\n }\n\n this.#previousPrimary = this.#state as SubscriptionMode\n this.#transition('failing')\n\n // Hysteresis timer: sustained loss before failback (Claim 9f). Use the\n // same complete span plus explicit allowance as FECManager and workers.\n const hysteresisMs = computeFecLossWindowMs(\n this.config.fecInterleaveMs,\n this.config.fecK,\n this.config.fecReorderToleranceMs,\n )\n this.#hysteresisTimer = setTimeout(() => {\n this.hysteresisExpired()\n }, hysteresisMs)\n }\n\n /** failing \u2192 previous primary: loss recovered within hysteresis window. */\n lossRecovered(): void {\n if (this.#state !== 'failing') return\n this.#clearHysteresis()\n if (this.#previousPrimary) {\n this.#transition(this.#previousPrimary as TrackState)\n }\n }\n\n /**\n * Transport failed (AMT relay down, multicast interface gone).\n * Try next transport in preference chain.\n */\n transportFailed(): void {\n this.#clearHysteresis()\n // Unicast-fallback is terminal: the host owns the subscription and\n // SessionManager transport machinery does not arbitrate it.\n if (this.#state === 'unicast-fallback') return\n const current = this.#previousPrimary ?? this.transport\n const prefs = this.config.preferredTransports\n const currentIdx = current ? prefs.indexOf(current) : -1\n const nextMode = prefs[currentIdx + 1]\n\n if (nextMode !== undefined && nextMode !== 'moq') {\n // Next is multicast or AMT: start joining. `joining` means MoQ is\n // primary again (see transport getter), so if the failed transport had\n // suppressed unicast, restore it for the join window or the viewer\n // watches black video until the next RAP (which may never come).\n // The chain position moves with the attempt: a join that times out\n // parks the session in `unicast` with this transport recorded, so the\n // next failure continues past it rather than re-trying the transport\n // that already failed and re-firing onTakeoverTimeout on every lap.\n this.#previousPrimary = nextMode\n this.#transition('joining')\n this.#unsuppress()\n return\n }\n // MoQ is next, or the chain is exhausted: unicast either way (Claim 9g).\n // For a session parked in `unicast` with its decoder retained after a\n // takeover timeout, this transition is a self-edge and fires no teardown,\n // so the release is explicit. It is the \"transportFailed falling back to\n // unicast\" release path the onTakeoverTimeout doc names, and #disarmFec is\n // idempotent, so it is a no-op when the transition already fired the\n // teardown. The audio reset is not flag-guarded, so it is tied to the edge\n // itself: a session already resting in unicast changed no transport and\n // owes the host no reset, however many pool subscriptions fail for it.\n const from = this.#state\n this.#previousPrimary = null\n this.#transition('unicast')\n this.#disarmFec()\n this.#unsuppress()\n if (from !== 'unicast' && this.config.trackType === 'audio') {\n this.#manager.mmtp?.onTrackEvent?.(this.config.trackName, { type: 'reset' }, this.config)\n }\n }\n\n /** Hysteresis expired \u2192 failback to next transport (Claim 9g). */\n hysteresisExpired(): void {\n if (this.#state !== 'failing') return\n this.#clearHysteresis()\n this.transportFailed()\n }\n\n /** \u2192 idle: player unsubscribes / tab close / cleanup. */\n unsubscribe(): void {\n this.#clearHysteresis()\n if (this.#state !== 'idle') {\n this.#transition('idle')\n // Unwind any outstanding suppression regardless of which state we left.\n // The old `wasActive` check (multicast/amt only) leaked suppression when\n // tearing down from `failing` or `unicast-fallback` \u2014 failbackAll()'s\n // unsubscribe\u2192subscribe cycle then re-entered unicast with the host's\n // media still muted (BLO-9413 freeze: \"failing back\" logged, video dead).\n this.#unsuppress()\n }\n }\n\n /** True when SessionManager.enterUnicastFallback put this track into the\n * host-driven unicast subscription mode. Host can iterate sessions to\n * decide whether to keep its fallback drainer alive across MediaRouter\n * arbitration events. */\n get isUnicastFallback(): boolean {\n return this.#state === 'unicast-fallback'\n }\n\n /**\n * True while this track owns a FEC decoder (fec-setup fired, fec-teardown\n * not yet). Stays true across a takeover timeout that parked the session in\n * `unicast`; SessionManager.failbackAll uses it to release such sessions.\n */\n get isFecArmed(): boolean {\n return this.#fecArmed\n }\n\n /**\n * Start the deadline a `joining` entry deferred while the join was inactive\n * (SessionManager.joinActive). The track gets the FULL deadline from here,\n * because that is when media can first arrive. No-op outside `joining` and\n * when a deadline is already running, so it never shortens or restarts one.\n *\n * The `joinActive` guard is what makes this safe to call from anywhere: a\n * track parked in `joining` with a deferred (null) timer satisfies the other\n * two, so without it a host calling this while the join is still inactive\n * would arm the exact deadline the gate exists to defer -- silently\n * reinstating BLO-34289. It is a no-op for the internal caller, which writes\n * `#joinActive = true` before iterating the sessions.\n */\n armDeferredJoinDeadline(): void {\n if (this.#state !== 'joining' || this.#joinDeadlineTimer !== null) return\n if (!this.#manager.joinActive) return\n this.#armJoinDeadline(this.#manager.takeoverDeadlineMs)\n }\n\n dispose(): void {\n this.#clearHysteresis()\n this.#clearJoinDeadline()\n this.#state = 'idle'\n this.#subscription = null\n // Release through the teardown edge, not a bare assignment: the\n // retained-FEC park makes `unicast` + isFecArmed a resting state, and\n // SessionManager.dispose() calls this directly (removeTrack() reaches it\n // via unsubscribe(), which has already disarmed). A bare assignment there\n // drops the host's decoder without a fec-teardown -- a leaked Worker.\n this.#disarmFec()\n }\n\n // -----------------------------------------------------------------------\n // Internal\n // -----------------------------------------------------------------------\n\n #transition(to: TrackState, opts?: { retainFec?: boolean }): void {\n const from = this.#state\n if (from === to) return\n // Takeover deadline (BLO-9413/D9): every entry into `joining` arms it\n // (joinStarted AND transportFailed-failover both route through here);\n // every exit cancels it. Without this, a transport that accepts the\n // subscribe but never delivers a RAP parks the session in `joining`\n // forever (observed live in the Phase-0 headless repro). The value is\n // resolved before any state write, so the one residual failure -- a\n // manager whose deadline was never set at all, which addTrack() refuses --\n // leaves the session in its previous state rather than half-transitioned.\n // Invalid values never reach here: SessionManager's setter refuses them.\n // The read is unconditional even when `joinActive` defers the arm, so a\n // manager without a deadline still fails closed on every joining edge.\n const joinDeadlineMs = to === 'joining' ? this.#manager.takeoverDeadlineMs : undefined\n this.#state = to\n if (from === 'joining') this.#clearJoinDeadline()\n if (joinDeadlineMs !== undefined && this.#manager.joinActive) {\n this.#armJoinDeadline(joinDeadlineMs)\n }\n // FEC teardown: entering a state without a FEC decoder releases the one\n // this track armed, unless the caller keeps it (takeover timeout: the\n // transport is still joined and still feeding the decoder).\n const fecActive = (s: TrackState) =>\n s === 'joining' ||\n s === 'multicast' ||\n s === 'amt' ||\n s === 'failing' ||\n s === 'unicast-fallback'\n if (!fecActive(to) && !opts?.retainFec) this.#disarmFec()\n this.#manager.callbacks?.onStateChange?.(this.config.trackName, from, to)\n }\n\n #clearHysteresis(): void {\n if (this.#hysteresisTimer !== null) {\n clearTimeout(this.#hysteresisTimer)\n this.#hysteresisTimer = null\n }\n }\n\n #armJoinDeadline(deadlineMs: number): void {\n this.#clearJoinDeadline()\n if (deadlineMs === 0) return // a declared 0 disables the deadline\n this.#joinDeadlineTimer = setTimeout(() => {\n this.#joinDeadlineTimer = null\n if (this.#state !== 'joining') return\n // No RAP within the deadline: return to unicast for arbitration only.\n // MoQ was never suppressed in `joining`, so there is nothing to restore\n // and this edge stays silent on onSuppress (asserted in tests). The\n // multicast socket is still joined and the router still admits this\n // stream's symbols, so the FEC decoder stays armed; the next transport\n // edge releases it.\n this.#transition('unicast', { retainFec: true })\n this.#manager.callbacks?.onTakeoverTimeout?.(this.config.trackName)\n }, deadlineMs)\n }\n\n #clearJoinDeadline(): void {\n if (this.#joinDeadlineTimer !== null) {\n clearTimeout(this.#joinDeadlineTimer)\n this.#joinDeadlineTimer = null\n }\n }\n\n /** Fire fec-setup exactly once per FEC epoch. */\n #armFec(): void {\n if (this.#fecArmed) return\n this.#fecArmed = true\n this.#manager.mmtp?.onTrackEvent?.(this.config.trackName, { type: 'fec-setup' }, this.config)\n }\n\n /** Fire fec-teardown for an armed decoder; no-op when none is armed. */\n #disarmFec(): void {\n if (!this.#fecArmed) return\n this.#fecArmed = false\n this.#manager.mmtp?.onTrackEvent?.(this.config.trackName, { type: 'fec-teardown' }, this.config)\n }\n\n /** Fire onSuppress(true) exactly once per suppression epoch. */\n #suppress(): void {\n if (this.#suppressFired) return\n this.#suppressFired = true\n this.#manager.callbacks?.onSuppress?.(this.config.trackName, true)\n }\n\n /** Unwind an outstanding suppression; no-op when none is outstanding. */\n #unsuppress(): void {\n if (!this.#suppressFired) return\n this.#suppressFired = false\n this.#manager.callbacks?.onSuppress?.(this.config.trackName, false)\n }\n}\n\n// ---------------------------------------------------------------------------\n// SessionManager\n// ---------------------------------------------------------------------------\n\n/**\n * Largest takeover deadline setTimeout represents faithfully. setTimeout\n * coerces its delay to a signed 32-bit int, so a larger value fires on the\n * next tick (Node also warns TimeoutOverflowWarning) instead of after the\n * window the host asked for -- silently inverting the feature. Rejecting it\n * keeps the classic units bug (seconds-as-ms, a wrong timescale) loud.\n */\nexport const MAX_TAKEOVER_DEADLINE_MS = 2_147_483_647\n\n/**\n * How long `joinActive` may stay false before the library says so. The deferral\n * is unbounded by construction -- that is the feature -- so a host that never\n * flips it back parks every track in `joining` with no timer at all: exactly the\n * forever-join the takeover deadline exists to prevent, only silent. MoQ stays\n * unsuppressed so the viewer sees video, but onTakeoverTimeout never fires, the\n * FEC decoder stays armed, and the preference chain never advances past the\n * transport that failed. It looks like a slow join. A host is one un-awaited\n * rejection between `false` and `true` away from it, so the library complains\n * rather than trusting every future host to get all of its exit paths right.\n *\n * Generous against a genuinely slow subscribe -- a bridge reconnect or AMT relay\n * handshake is seconds -- so anything still unsettled here is broken, not slow.\n */\nexport const JOIN_DEFER_WARN_MS = 30_000\n\nexport class SessionManager {\n readonly pool: TransportPool\n readonly sessions = new Map<string, TrackSession>()\n callbacks: SessionManagerCallbacks | null = null\n #takeoverDeadlineMs: number | undefined = undefined\n #joinActive = true\n #joinDeferWarnTimer: ReturnType<typeof setTimeout> | null = null\n\n /**\n * Takeover deadline (BLO-9413/D9): the time a session may sit in `joining`\n * without a RAP before returning to `unicast` and firing onTakeoverTimeout.\n * MoQ is never suppressed during `joining`, so expiry abandons the takeover\n * without a playback glitch. The host derives it from the catalog (RAP\n * cadence from groupDurationTicks/timescale plus the signaled FEC delivery\n * window or reorder tolerance) and sets it before addTrack(). There is no\n * default: an unset value fails addTrack() closed, and reading the getter\n * before assignment throws the same way, so no host ever holds a\n * `number | undefined` it could coalesce back into a default.\n * `hasTakeoverDeadline` answers whether one was admitted. A declared 0\n * disables the deadline. The value is an integer number of milliseconds:\n * setTimeout truncates fractions, so a host that divides ticks by a\n * timescale must round before assigning.\n *\n * Assignment validates, so the diagnostic lands at the host's own call site.\n * That is what bounds the blast radius: `joining` is entered from\n * joinStarted(), from the transportFailed() failover branch -- itself\n * reachable from a setTimeout via hysteresisExpired(), where no caller could\n * catch a throw -- and from the activateMulticast()/activateAmt()/pool-failure\n * loops, where a throw on one session would abandon its siblings mid-loop\n * (the split-brain those loops exist to prevent). Refusing a bad value at\n * assignment means no host mutation can make an already-admitted session\n * throw out of a transport edge.\n */\n get takeoverDeadlineMs(): number {\n return this.#requireTakeoverDeadlineMs()\n }\n\n set takeoverDeadlineMs(value: number) {\n if (!Number.isInteger(value) || value < 0 || value > MAX_TAKEOVER_DEADLINE_MS) {\n throw new Error(\n `[SessionManager] takeoverDeadlineMs must be an integer from 0 to ${MAX_TAKEOVER_DEADLINE_MS} (0 disables it, larger values overflow setTimeout); got ${String(value)}`,\n )\n }\n this.#takeoverDeadlineMs = value\n }\n\n /** True once the host has admitted a deadline; a declared 0 counts. */\n get hasTakeoverDeadline(): boolean {\n return this.#takeoverDeadlineMs !== undefined\n }\n\n /**\n * Whether the transport the joining tracks are waiting on is actually\n * subscribed. The takeover deadline is a media-arrival budget and media\n * cannot arrive before the join exists, so while this is false, entering\n * `joining` arms no deadline; flipping it true starts the FULL deadline for\n * every track already parked there.\n *\n * Default true, which is the arm-on-`joining` behaviour every host had\n * before: a host that only calls joinStarted() after its own join resolves\n * (moqtail-private's player, post-BLO-33339) is already at the right edge and\n * needs no gate. A host that arms joinStarted() up front \u2014 to have FEC\n * decoders ready before the first datagram \u2014 sets this false before addTrack()\n * and true when its subscribe settles. Charging a slow subscribe (bridge\n * reconnect, AMT relay handshake) against the media budget expired the\n * deadline before the subscription existed and abandoned the join on the\n * happy path. BLO-34289.\n *\n * The deadline is a media budget (group cadence plus FEC delivery window) and\n * budgets nothing for reaching the stream, so ideally it starts at\n * MulticastTransport.onDataPlaneLive, the join's first accepted packet. Over\n * AMT that is measurably later than a settled subscribe: the subscribe\n * settles once the gateway has sent its Membership Update, and the relay's\n * upstream join plus a path RTT (0.4-0.8 s measured to the prod relay) still\n * precede the first packet. But RFC 7450 gives a gateway no signal that the\n * relay has traffic for its (S,G), so a join whose source never sends never\n * reaches that edge. Starting the deadline there is therefore sound only when\n * the provider bounds the join phase itself, with a typed subscription error\n * for a join that delivers nothing (MulticastTransport forwards it to\n * onFailback). A host whose provider makes no such guarantee flips this true\n * when the subscribe settles and accepts that join latency is charged to the\n * media budget; holding it until the first packet would park a silent join\n * in `joining` forever.\n *\n * The flag tracks the host's primary join, not per-track transport state. A\n * track that fails over to the next transport in its own chain while this is\n * false (transportFailed's failover branch also enters `joining`) has that\n * deadline gated on the primary join activating, not on its own transport.\n * Under the documented usage -- false once at setup, true once the subscribe\n * settles -- the two coincide.\n *\n * The gate is on the ENTRY into `joining`, and false cannot take back a\n * deadline already running: the `true -> false` edge arms the warn below and\n * clears nothing. So set it false BEFORE the `joining` entry it is meant to\n * gate. A host that instead flipped it false after a `transportFailed()`\n * failover had already entered `joining` would leave the full deadline\n * counting against a subscribe that had not begun -- BLO-34289 again, on the\n * failover edge. Clearing running deadlines on that edge is deliberately NOT\n * done: this flag is manager-wide, so one transport's brief false would\n * cancel an unrelated track's live deadline and then re-arm it at FULL length\n * on the next activation (armDeferredJoinDeadline sees `joining` + a null\n * timer and cannot tell a cleared deadline from a deferred one). Every\n * unrelated transport blip would restart the whole budget, so under repeated\n * failover a track that should have timed out is extended indefinitely and\n * never advances. Only a false that never returns true loses the timer\n * outright, and JOIN_DEFER_WARN_MS already covers that.\n *\n * Leaving this false is not free: see JOIN_DEFER_WARN_MS.\n */\n get joinActive(): boolean {\n return this.#joinActive\n }\n\n set joinActive(active: boolean) {\n const wasActive = this.#joinActive\n this.#joinActive = active\n if (!active) {\n // Edge-triggered: a host re-declaring false has not restarted its\n // subscribe, and total deferral is what the warning is about.\n if (wasActive) this.#armJoinDeferWarn()\n return\n }\n this.#clearJoinDeferWarn()\n if (wasActive) return\n for (const [, session] of this.sessions) session.armDeferredJoinDeadline()\n }\n\n #armJoinDeferWarn(): void {\n this.#joinDeferWarnTimer = setTimeout(() => {\n this.#joinDeferWarnTimer = null\n const deferred = [...this.sessions.values()].filter(s => s.state === 'joining').length\n console.warn(\n `[SessionManager] joinActive has been false for ${JOIN_DEFER_WARN_MS} ms and ${deferred} track(s) sit in 'joining' with no takeover deadline. Set joinActive = true when the subscribe settles, on every exit path including failures, or these tracks never time out, never release their FEC decoders, and never advance to the next transport.`,\n )\n }, JOIN_DEFER_WARN_MS)\n }\n\n #clearJoinDeferWarn(): void {\n if (this.#joinDeferWarnTimer === null) return\n clearTimeout(this.#joinDeferWarnTimer)\n this.#joinDeferWarnTimer = null\n }\n\n /** Narrow to MMTP callbacks (onTrackEvent). Safe \u2014 optional chaining handles PIM-only consumers. */\n get mmtp(): MmtpSessionCallbacks | null {\n return this.callbacks as MmtpSessionCallbacks | null\n }\n\n constructor(poolOptions?: TransportPoolOptions) {\n this.pool = new TransportPool(poolOptions)\n\n // Wire pool failure \u2192 per-track failover\n this.pool.onSubscriptionFailed(sub => {\n for (const trackName of sub.consumers) {\n const session = this.sessions.get(trackName)\n if (session) session.transportFailed()\n }\n })\n }\n\n // -----------------------------------------------------------------------\n // Track management\n // -----------------------------------------------------------------------\n\n /**\n * The admitted takeover deadline. Throws only when the host never set one,\n * every other invalid value being refused by the setter, and returns a\n * declared 0 as 0. The getter and addTrack() both route through it, so a\n * session cannot exist without an admitted deadline, which is what makes\n * the #transition() read unable to throw on an already-admitted session.\n */\n #requireTakeoverDeadlineMs(): number {\n const value = this.#takeoverDeadlineMs\n if (value === undefined) {\n throw new Error(\n '[SessionManager] takeoverDeadlineMs is unset; set a catalog-derived takeover deadline (0 disables it) before adding tracks',\n )\n }\n return value\n }\n\n /** Add a track session from catalog config. */\n addTrack(config: TrackSessionConfig): TrackSession {\n if (this.sessions.has(config.trackName)) {\n throw new Error(`[SessionManager] Track \"${config.trackName}\" already exists`)\n }\n this.#requireTakeoverDeadlineMs()\n const session = new TrackSession(config, this)\n this.sessions.set(config.trackName, session)\n return session\n }\n\n /** Remove a track session. Releases subscription with grace period. */\n removeTrack(trackName: string): void {\n const session = this.sessions.get(trackName)\n if (!session) return\n session.unsubscribe()\n\n // Release pool subscription\n const subKey = session.config.multicastKey ?? session.config.moqTrackName\n if (subKey) this.pool.releaseSubscription(subKey, trackName)\n\n session.dispose()\n this.sessions.delete(trackName)\n }\n\n // -----------------------------------------------------------------------\n // Atomic multi-track operations\n // -----------------------------------------------------------------------\n\n /**\n * All tracks with multicast RAP \u2192 multicast state (atomic).\n * Called when MediaRouter fires onSourceSwitch('multicast').\n * Prevents split-brain between video and audio.\n *\n * A takeover deadline can return a track from `joining` to `unicast` while\n * the multicast socket keeps delivering packets and the track keeps its FEC\n * decoder armed. If a valid RAP arrives later, that late-RAP edge suppresses\n * unicast and promotes multicast without a second fec-setup.\n */\n activateMulticast(): void {\n for (const [, session] of this.sessions) {\n if (session.state === 'unicast') {\n session.joinStarted()\n }\n if (session.state === 'joining') {\n session.rapReceived('multicast')\n }\n }\n }\n\n /**\n * All tracks with AMT RAP \u2192 amt state (atomic).\n */\n activateAmt(): void {\n for (const [, session] of this.sessions) {\n if (session.state === 'unicast') {\n session.joinStarted()\n }\n if (session.state === 'joining') {\n session.rapReceived('amt')\n }\n }\n }\n\n /**\n * All tracks \u2192 unicast (failback, atomic).\n * Called when MulticastManager fires onFailback().\n * unicast-fallback sessions are skipped \u2014 the host owns those subscriptions\n * and they must drain uninterrupted across transport-state events.\n *\n * Coherence invariant (BLO-9413): after this returns, every non-fallback\n * session is `unicast` (or `idle`), unsuppressed, and without a FEC decoder.\n * unsubscribe() unwinds any outstanding suppression regardless of the state\n * it tears down from (`failing` included; see TrackSession.#suppressFired),\n * and a session that a takeover timeout parked in `unicast` with its decoder\n * still armed is cycled too, because this transport edge is what releases\n * that decoder (unsubscribe fires fec-teardown).\n */\n failbackAll(): void {\n for (const [, session] of this.sessions) {\n const parkedWithFec = session.state === 'unicast' && session.isFecArmed\n if (\n parkedWithFec ||\n (session.state !== 'idle' &&\n session.state !== 'unicast' &&\n session.state !== 'unicast-fallback')\n ) {\n session.unsubscribe()\n session.subscribe() // re-enter unicast\n }\n }\n }\n\n /**\n * All tracks \u2192 unicast-fallback (atomic).\n * Called by the host when the multicast gateway is absent and\n * the player will subscribe to source tracks over MoQ unicast and feed\n * packets via MulticastManager.feedUnicastSource() (or manager.router\n * directly). Sessions in this state stay terminal until the host calls\n * unsubscribe() / disposes the SessionManager.\n */\n enterUnicastFallback(): void {\n for (const [, session] of this.sessions) {\n if (session.state === 'idle') continue\n session.enterUnicastFallback()\n }\n }\n\n /**\n * True if any session is currently in unicast-fallback. Hosts use this to\n * gate `multicastTrack.close()` on failback events \u2014 closing the shared\n * broadcast would kill the host's fallback drainers.\n */\n hasActiveFallback(): boolean {\n for (const [, session] of this.sessions) {\n if (session.isUnicastFallback) return true\n }\n return false\n }\n\n /**\n * All fallback tracks \u2192 unicast (atomic).\n * Called when the SSM bridge appears mid-session and the host has aborted\n * the fallback drainers. The session re-enters the regular preference chain\n * via joinStarted() (issued by the host immediately after).\n */\n exitUnicastFallback(): void {\n for (const [, session] of this.sessions) {\n if (session.isUnicastFallback) session.exitUnicastFallback()\n }\n }\n\n // -----------------------------------------------------------------------\n // Cleanup lifecycle\n // -----------------------------------------------------------------------\n\n /** Tab closed \u2014 release all subscriptions for this tab (grace period). */\n tabClosed(tabId: string): void {\n // All tracks owned by this tab \u2192 idle\n for (const [trackName, session] of this.sessions) {\n session.unsubscribe()\n this.pool.releaseAllForConsumer(trackName)\n }\n }\n\n /** Bridge WebTransport connection dropped \u2014 same as tab close. */\n bridgeDisconnected(): void {\n this.tabClosed('bridge')\n }\n\n /**\n * Track selection changed (user or ABR).\n * Old track released (grace period), new track subscribed.\n */\n trackSelectionChanged(oldTrackName: string, newConfig: TrackSessionConfig): void {\n this.removeTrack(oldTrackName)\n const session = this.addTrack(newConfig)\n session.subscribe()\n }\n\n // -----------------------------------------------------------------------\n // Query\n // -----------------------------------------------------------------------\n\n /** Get a track session by name. */\n getTrack(trackName: string): TrackSession | undefined {\n return this.sessions.get(trackName)\n }\n\n // -----------------------------------------------------------------------\n // Cleanup\n // -----------------------------------------------------------------------\n\n dispose(): void {\n this.#clearJoinDeferWarn()\n for (const [, session] of this.sessions) {\n session.dispose()\n }\n this.sessions.clear()\n this.pool.dispose()\n this.callbacks = null\n }\n}\n"],
4
+ "sourcesContent": ["/**\n * TransportPool \u2014 ref-counted subscription management for per-track transport.\n *\n * Tracks which consumers (identified by trackName) hold references to which\n * subscriptions (identified by a string key). When the last consumer releases\n * a subscription, it is closed.\n */\n\n/** Transport subscription mode \u2014 matches TrackState in the state machine. */\nexport type SubscriptionMode = 'multicast' | 'amt' | 'moq'\n\n/** Direct Socket health report (IWA transport). */\nexport interface DirectSocketHealth {\n connected: boolean\n bytesReceived: number\n packetsReceived: number\n errors: number\n}\n\n/** Subscription handle returned by acquireSubscription. */\nexport interface Subscription {\n key: string\n mode: SubscriptionMode\n consumers: ReadonlySet<string>\n}\n\n/** Stats for a subscription. */\nexport interface SubscriptionStats {\n activeSubscriptions: number\n totalAcquires: number\n totalReleases: number\n}\n\n/** Options for acquiring a subscription. */\nexport interface AcquireOptions {\n /** Subscription key (e.g., \"(S,G,port)\" for multicast). */\n key: string\n /** Transport mode. */\n mode: SubscriptionMode\n /** Consumer identity (trackName). */\n consumer: string\n}\n\nexport interface TransportPoolOptions {\n /** Maximum concurrent subscriptions. 0 = unlimited. */\n maxSubscriptions?: number\n}\n\ntype FailedCallback = (sub: { key: string; consumers: Set<string>; error: unknown }) => void\n\nexport class TransportPool {\n readonly #subs = new Map<string, Subscription>()\n readonly #options: TransportPoolOptions\n #failedCb: FailedCallback | null = null\n #stats: SubscriptionStats = { activeSubscriptions: 0, totalAcquires: 0, totalReleases: 0 }\n\n constructor(options?: TransportPoolOptions) {\n this.#options = options ?? {}\n }\n\n /** Register callback for subscription failures. */\n onSubscriptionFailed(cb: FailedCallback): void {\n this.#failedCb = cb\n }\n\n /** Acquire a subscription for a consumer. Returns the subscription. */\n acquireSubscription(opts: AcquireOptions): Subscription\n acquireSubscription(key: string, consumer: string): Subscription\n acquireSubscription(keyOrOpts: string | AcquireOptions, consumer?: string): Subscription {\n const key = typeof keyOrOpts === 'string' ? keyOrOpts : keyOrOpts.key\n const cons = typeof keyOrOpts === 'string' ? consumer! : keyOrOpts.consumer\n const mode: SubscriptionMode = typeof keyOrOpts === 'object' ? keyOrOpts.mode : 'moq'\n\n let sub = this.#subs.get(key)\n if (!sub) {\n sub = { key, mode, consumers: new Set() }\n this.#subs.set(key, sub)\n this.#stats.activeSubscriptions++\n }\n ;(sub.consumers as Set<string>).add(cons)\n this.#stats.totalAcquires++\n return sub\n }\n\n /** Release a subscription for a consumer. Closes if last consumer. */\n releaseSubscription(key: string, consumer: string): void {\n const sub = this.#subs.get(key)\n if (!sub) return\n ;(sub.consumers as Set<string>).delete(consumer)\n this.#stats.totalReleases++\n if (sub.consumers.size === 0) {\n this.#subs.delete(key)\n this.#stats.activeSubscriptions--\n }\n }\n\n /** Release all subscriptions held by a consumer. */\n releaseAllForConsumer(consumer: string): void {\n for (const [key, sub] of this.#subs) {\n ;(sub.consumers as Set<string>).delete(consumer)\n if (sub.consumers.size === 0) {\n this.#subs.delete(key)\n this.#stats.activeSubscriptions--\n }\n }\n }\n\n /** Report a subscription failure. */\n reportFailure(key: string, error: unknown): void {\n const sub = this.#subs.get(key)\n if (sub) this.#failedCb?.({ key, consumers: sub.consumers as Set<string>, error })\n }\n\n /** Get stats. */\n get stats(): Readonly<SubscriptionStats> { return this.#stats }\n\n /** Clean up all subscriptions. */\n dispose(): void {\n this.#subs.clear()\n this.#failedCb = null\n this.#stats.activeSubscriptions = 0\n }\n\n /** Number of active subscriptions. */\n get size(): number {\n return this.#subs.size\n }\n}\n", "/**\n * TrackSession \u2014 per-track transport state machine.\n * SessionManager \u2014 orchestrates all sessions + TransportPool.\n *\n * Implements Patent Claim 9 (draft-ramadan-moq-fec APPLICATION-1):\n * (a) subscribe unicast\n * (b) simultaneously join multicast\n * (c) render from unicast during join\n * (d) activate multicast on RAP, suppress unicast\n * (e) cross-transport FEC repair during transition\n * (f) hysteresis before switching away from multicast\n * (g) re-subscribe unicast on sustained failure\n *\n * PIM: manages transport state without inspecting packet contents.\n */\n\nimport {\n TransportPool,\n type Subscription,\n type SubscriptionMode,\n type AcquireOptions,\n type DirectSocketHealth,\n type TransportPoolOptions,\n} from './transport-pool.js'\nimport { fecBlockBudgetMs } from '@blockcast/mmt-fec'\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport type TrackState =\n | 'idle'\n | 'unicast'\n | 'unicast-fallback'\n | 'joining'\n | 'multicast'\n | 'amt'\n | 'failing'\n\nexport interface TrackSessionConfig {\n trackName: string\n trackType: 'video' | 'audio'\n packetId: number\n /** FEC interleave depth from catalog (ms). Used for hysteresis timer. */\n fecInterleaveMs: number\n /** Explicit catalog jitter/reorder allowance in ms. */\n fecReorderToleranceMs?: number\n /** FEC K from catalog. Used for audio worklet latency calculation. */\n fecK: number\n /** FEC repair symbols per block (P). NOT symbol size. */\n fecRepairCount: number\n /**\n * Repair symbols per block this receiver actually joined: the sum of\n * `repairSymbols` over the joined BASE layers, not a count of layers\n * (draft-ramadan-moq-fec 6.3). A keyframe overlay is a separate FEC instance\n * and is not included.\n *\n * This is runtime join state, not catalog geometry: a receiver on a clean\n * path joins layer 0 only while a lossy one adds layers, so the catalog's\n * total P is not what this receiver is receiving. Diagnostics that report P\n * (the worker snapshot, CMCD `com.blockcast-fp`) must read this.\n *\n * Absent when the caller joins no layers explicitly, in which case P_0 is\n * what was joined. That is an identity on a single-layer track, not a\n * catalog default: with one layer, P_0 is the total.\n */\n joinedRepairCount?: number\n /** FEC symbol size in bytes (T). Required for WASM decoder OTI. */\n fecSymbolSize: number\n /** MoQ repair track name from catalog FEC config. */\n repairTrackName?: string\n /** Frame samples from initData parsing (audio-init-parser.ts). */\n frameSamples?: number\n /** Audio sample rate from catalog. */\n sampleRate?: number\n /** Ordered transport preference: best first. */\n preferredTransports: SubscriptionMode[]\n /** Subscription key for multicast/AMT: \"(S,G,port)\". */\n multicastKey?: string\n /** MoQ track name for unicast subscription. */\n moqTrackName?: string\n /** AMT relay address (if AMT is in preference chain). */\n amtRelay?: string\n /** MoQ relay URL (for WebTransport subscription). */\n moqRelayUrl?: string\n}\n\n// ---------------------------------------------------------------------------\n// PIM callbacks (protocol-independent \u2014 transport state only)\n// ---------------------------------------------------------------------------\n\nexport interface SessionManagerCallbacks {\n /** Fired on every transport state transition. */\n onStateChange?(trackName: string, from: TrackState, to: TrackState): void\n /** Fired to suppress/unsuppress unicast for this track (transport arbitration). */\n onSuppress?(trackName: string, suppressed: boolean): void\n /**\n * Fired when a track sat in `joining` for SessionManager.takeoverDeadlineMs\n * without a RAP (BLO-9413/D9). The session has already returned to `unicast`\n * (nothing was suppressed, so nothing to restore) and it keeps its FEC\n * decoder armed, because the multicast socket is still joined and the router\n * still admits the stream's symbols. The host should abandon the router-side\n * attempt (e.g. MediaRouter.failbackToMoq()) so the router stops waiting for\n * a keyframe that is never coming; it must not dispose the decoder here. The\n * decoder is released on the next transport edge (unsubscribe, failbackAll,\n * or transportFailed falling back to unicast) through fec-teardown.\n */\n onTakeoverTimeout?(trackName: string): void\n}\n\n// ---------------------------------------------------------------------------\n// MMTP application-layer extension (FEC lifecycle, media resets)\n// ---------------------------------------------------------------------------\n\n/**\n * MMTP track events (discriminated union).\n * Each protocol layer defines its own event union \u2014 MMTP events cover\n * media resets, latency, and FEC lifecycle.\n */\nexport type MmtpTrackEvent =\n | { type: 'reset' }\n | { type: 'latency'; latencyMs: number }\n | { type: 'fec-setup' }\n | { type: 'fec-teardown' }\n\n/**\n * MMTP application-layer extension of SessionManagerCallbacks.\n * Single onTrackEvent dispatches all MMTP-specific events.\n * PIM consumers use the base SessionManagerCallbacks (no onTrackEvent).\n *\n * IWA can define its own subinterface with its own TrackEvent union\n * (e.g., IwaTrackEvent for socket health, AMT state) using the same pattern.\n */\nexport interface MmtpSessionCallbacks extends SessionManagerCallbacks {\n onTrackEvent?(trackName: string, event: MmtpTrackEvent, config: TrackSessionConfig): void\n}\n\n/**\n * Loss-hysteresis window covering the signaled FEC block span plus jitter.\n *\n * `fecK` no longer feeds the math; it is still validated as a catalog\n * well-formedness guard and kept in the signature for positional API compat\n * (removal is a follow-up with the next cross-repo pin bump).\n */\nexport function computeFecLossWindowMs(fecInterleaveMs: number, fecK: number, jitterBufferMs?: number): number {\n return computeFecDeliveryWindowMs({\n interleaveDepthMs: fecInterleaveMs,\n sourceSymbols: fecK,\n jitterBufferMs,\n })\n}\n\nexport interface FecDeliveryWindowConfig {\n interleaveDepthMs: number\n sourceSymbols: number\n repairSymbols?: number\n symbolSize?: number\n mediaUnitsPerSecond?: number\n bandwidthBps?: number\n /**\n * Explicit jitter/reorder allowance in ms, added once to the complete span.\n * Callers pass their signaled catalog allowance, never a local default.\n */\n jitterBufferMs?: number\n}\n\n/**\n * The catalog interleaveDepthMs is the complete FEC block span, including the\n * repair tail. K, P, cadence, and bandwidth must not expand it again. Geometry\n * fields remain accepted for existing callers, but do not feed deadline math.\n */\nexport function computeFecDeliveryWindowMs(\n config: FecDeliveryWindowConfig,\n): number {\n const fecK = config.sourceSymbols\n if (!Number.isFinite(fecK) || fecK <= 0) {\n throw new Error(\n `[TrackSession] fecK=${fecK}. ` +\n `Derive a positive FEC source-symbol count from the stream catalog.`\n )\n }\n return fecSpanDeliveryWindowMs(config.interleaveDepthMs, config.jitterBufferMs)\n}\n\n/**\n * The delivery window of a \u00A75.1 span alone. Every window is this; K only\n * validates a track's geometry in computeFecDeliveryWindowMs, so a caller\n * with a span and no K (FECManager's stream-global envelope) uses this\n * rather than invent one.\n */\nexport function fecSpanDeliveryWindowMs(interleaveDepthMs: number, jitterBufferMs?: number): number {\n if (!Number.isFinite(interleaveDepthMs) || interleaveDepthMs <= 0) {\n throw new Error(\n `[TrackSession] fecInterleaveMs=${interleaveDepthMs}. ` +\n `Derive a positive FEC interleave window from the stream catalog.`\n )\n }\n // Absence stays absent; it does not authorize a synthesized reorder budget.\n if (jitterBufferMs === undefined) return Math.ceil(interleaveDepthMs)\n return fecBlockBudgetMs(interleaveDepthMs, jitterBufferMs)\n}\n\n/** Convert the shared FEC delivery deadline into an integer media timescale. */\nexport function computeFecDeliveryWindowMediaUnits(\n config: FecDeliveryWindowConfig,\n timestampTimescale: number,\n): number {\n if (!Number.isFinite(timestampTimescale) || timestampTimescale <= 0) {\n throw new Error(\n `[TrackSession] timestampTimescale=${timestampTimescale}. ` +\n 'Derive a positive media timescale from the container.',\n )\n }\n const deliveryWindow = Math.ceil(\n computeFecDeliveryWindowMs(config) * timestampTimescale / 1000,\n )\n if (!Number.isSafeInteger(deliveryWindow)) {\n throw new Error('[TrackSession] FEC delivery window exceeds the media timestamp range.')\n }\n return deliveryWindow\n}\n\n// ---------------------------------------------------------------------------\n// TrackSession\n// ---------------------------------------------------------------------------\n\nexport class TrackSession {\n readonly config: TrackSessionConfig\n #state: TrackState = 'idle'\n #subscription: Subscription | null = null\n #hysteresisTimer: ReturnType<typeof setTimeout> | null = null\n #joinDeadlineTimer: ReturnType<typeof setTimeout> | null = null\n #previousPrimary: SubscriptionMode | null = null\n #manager: SessionManager\n // Single suppression authority flag (BLO-9413 failback coherence): every\n // suppress this session fires is recorded here, and every path that leaves\n // a suppressed state unwinds through #unsuppress(). Before this flag,\n // unsubscribe() only un-suppressed from multicast/amt \u2014 a session torn down\n // from `failing` (exactly where a starving track sits) was re-subscribed to\n // unicast with the host's media still muted: MoQ playing into a dead sink.\n #suppressFired = false\n // Single FEC authority flag: set by the fec-setup edges (joinStarted,\n // enterUnicastFallback), cleared by the fec-teardown edge in #transition.\n // Setup and teardown are paired by construction, so a takeover timeout that\n // parks the session in `unicast` keeps the decoder the still-joined\n // transport is feeding, and the later unsubscribe still tears it down.\n #fecArmed = false\n\n constructor(config: TrackSessionConfig, manager: SessionManager) {\n this.config = config\n this.#manager = manager\n }\n\n // -----------------------------------------------------------------------\n // State\n // -----------------------------------------------------------------------\n\n get state(): TrackState { return this.#state }\n get subscription(): Subscription | null { return this.#subscription }\n\n /** True while this session's onSuppress(true) is outstanding (no matching\n * onSuppress(false) yet). Test/assertion surface for failback coherence. */\n get isSuppressed(): boolean { return this.#suppressFired }\n\n /** Current active transport mode, or null if idle. */\n get transport(): SubscriptionMode | null {\n switch (this.#state) {\n case 'multicast': return 'multicast'\n case 'amt': return 'amt'\n case 'unicast': return 'moq'\n case 'unicast-fallback': return 'moq' // SSM bridge absent \u2014 MoQ unicast is the only source\n case 'joining': return 'moq' // MoQ still primary during join\n case 'failing': return this.#previousPrimary\n default: return null\n }\n }\n\n // -----------------------------------------------------------------------\n // Computed (no magic numbers)\n // -----------------------------------------------------------------------\n\n /** Audio worklet latency: max(fecInterleaveMs, frameDurationMs * fecK).\n * Returns undefined if frame duration unknown (no initData). */\n get audioLatencyMs(): number | undefined {\n const fd = this.frameDurationMs\n if (fd === undefined) {\n return this.config.fecInterleaveMs > 0 ? this.config.fecInterleaveMs : undefined\n }\n return Math.max(this.config.fecInterleaveMs, fd * this.config.fecK)\n }\n\n /** Frame duration in ms, derived from frameSamples + sampleRate. */\n get frameDurationMs(): number | undefined {\n const { frameSamples, sampleRate } = this.config\n if (!frameSamples || !sampleRate || sampleRate <= 0) return undefined\n return (frameSamples / sampleRate) * 1000\n }\n\n // -----------------------------------------------------------------------\n // Transitions (Patent Claim 9)\n // -----------------------------------------------------------------------\n\n /** idle \u2192 unicast: player subscribes to this track. */\n subscribe(): void {\n if (this.#state !== 'idle') return\n this.#transition('unicast')\n\n // Emit audio latency on first subscribe\n if (this.config.trackType === 'audio') {\n const latency = this.audioLatencyMs\n if (latency !== undefined) {\n this.#manager.mmtp?.onTrackEvent?.(this.config.trackName, { type: 'latency', latencyMs: latency }, this.config)\n }\n }\n }\n\n /** unicast \u2192 joining: multicast/AMT capability detected, join in progress. */\n joinStarted(): void {\n if (this.#state !== 'unicast') return\n this.#transition('joining')\n // FEC decoders must be ready before multicast data arrives (Claim 9e:\n // cross-transport repair). A session re-joining after a takeover timeout\n // still owns its decoder, so fec-setup fires once per FEC epoch.\n this.#armFec()\n }\n\n /**\n * any non-fallback state \u2192 unicast-fallback. Used when the SSM bridge is\n * absent (vanilla Chromium / no extension) and the host falls back to a\n * MoQ unicast subscription per track. Terminal-ish: activateMulticast,\n * failbackAll, and transportFailed are no-ops while in this state.\n *\n * Fires fec-setup so the per-track repair worker spins up \u2014 repair packets\n * still flow over the MoQ repair-track subscription, so FEC source+repair\n * can converge as in the multicast path.\n *\n * Audio latency is NOT re-emitted here: subscribe() already fires latency\n * once on the idle \u2192 unicast edge, and enterUnicastFallback is only ever\n * reached from a non-idle state (SessionManager skips idle sessions).\n */\n enterUnicastFallback(): void {\n if (this.#state === 'unicast-fallback') return\n this.#clearHysteresis()\n this.#transition('unicast-fallback')\n // Suppress the redundant unicast media decoder. On the no-bridge path the\n // host feeds MoQ-unicast source packets into MulticastManager (the render\n // consumer); a second base-media subscribe (the @moq/watch decoder) would\n // split the single connection's QUIC flow-control budget and stall delivery\n // (BLO-8969). Fire suppress here, on the fallback edge, so the decoder is\n // gated off before it subscribes rather than after the relay forward task\n // has already wedged.\n this.#suppress()\n this.#armFec()\n }\n\n /**\n * unicast-fallback \u2192 unicast. Called when the SSM bridge appears mid-session\n * (extension installed after fallback engaged). Host first aborts the fallback\n * drainers, then transitions sessions back to unicast so the regular\n * multicast/AMT preference chain can take over via joinStarted().\n */\n exitUnicastFallback(): void {\n if (this.#state !== 'unicast-fallback') return\n this.#transition('unicast')\n // Un-suppress the unicast decoder: the bridge appeared mid-session, so the\n // host tears down the fallback drainers and the regular preference chain\n // (joinStarted -> joining -> rapReceived) will re-suppress on multicast RAP.\n this.#unsuppress()\n }\n\n /**\n * joining \u2192 multicast or amt: RAP received on the joined transport (Claim 9d).\n * Resets audio worklet, then suppresses MoQ.\n */\n rapReceived(mode: 'multicast' | 'amt'): void {\n if (this.#state !== 'joining') return\n this.#previousPrimary = mode\n this.#transition(mode)\n if (this.config.trackType === 'audio') {\n this.#manager.mmtp?.onTrackEvent?.(this.config.trackName, { type: 'reset' }, this.config)\n }\n this.#suppress()\n }\n\n /** multicast/amt \u2192 failing: loss detected, start hysteresis timer (Claim 9f). */\n lossDetected(): void {\n if (this.#state !== 'multicast' && this.#state !== 'amt') return\n\n // A zero/absent catalog span carries no elapsed FEC deadline. The stall\n // detector that calls this method already supplied the generic loss\n // evidence, so there is no FEC hysteresis window to derive here. Follow\n // the existing transport-failure edge immediately instead of inventing a\n // local timeout for a catalog value that explicitly supplied none.\n if (this.config.fecInterleaveMs === 0 || this.config.fecInterleaveMs === undefined) {\n this.transportFailed()\n return\n }\n\n this.#previousPrimary = this.#state as SubscriptionMode\n this.#transition('failing')\n\n // Hysteresis timer: sustained loss before failback (Claim 9f). Use the\n // same complete span plus explicit allowance as FECManager and workers.\n const hysteresisMs = computeFecLossWindowMs(\n this.config.fecInterleaveMs,\n this.config.fecK,\n this.config.fecReorderToleranceMs,\n )\n this.#hysteresisTimer = setTimeout(() => {\n this.hysteresisExpired()\n }, hysteresisMs)\n }\n\n /** failing \u2192 previous primary: loss recovered within hysteresis window. */\n lossRecovered(): void {\n if (this.#state !== 'failing') return\n this.#clearHysteresis()\n if (this.#previousPrimary) {\n this.#transition(this.#previousPrimary as TrackState)\n }\n }\n\n /**\n * Transport failed (AMT relay down, multicast interface gone).\n * Try next transport in preference chain.\n */\n transportFailed(): void {\n this.#clearHysteresis()\n // Unicast-fallback is terminal: the host owns the subscription and\n // SessionManager transport machinery does not arbitrate it.\n if (this.#state === 'unicast-fallback') return\n const current = this.#previousPrimary ?? this.transport\n const prefs = this.config.preferredTransports\n const currentIdx = current ? prefs.indexOf(current) : -1\n const nextMode = prefs[currentIdx + 1]\n\n if (nextMode !== undefined && nextMode !== 'moq') {\n // Next is multicast or AMT: start joining. `joining` means MoQ is\n // primary again (see transport getter), so if the failed transport had\n // suppressed unicast, restore it for the join window or the viewer\n // watches black video until the next RAP (which may never come).\n // The chain position moves with the attempt: a join that times out\n // parks the session in `unicast` with this transport recorded, so the\n // next failure continues past it rather than re-trying the transport\n // that already failed and re-firing onTakeoverTimeout on every lap.\n this.#previousPrimary = nextMode\n this.#transition('joining')\n this.#unsuppress()\n return\n }\n // MoQ is next, or the chain is exhausted: unicast either way (Claim 9g).\n // For a session parked in `unicast` with its decoder retained after a\n // takeover timeout, this transition is a self-edge and fires no teardown,\n // so the release is explicit. It is the \"transportFailed falling back to\n // unicast\" release path the onTakeoverTimeout doc names, and #disarmFec is\n // idempotent, so it is a no-op when the transition already fired the\n // teardown. The audio reset is not flag-guarded, so it is tied to the edge\n // itself: a session already resting in unicast changed no transport and\n // owes the host no reset, however many pool subscriptions fail for it.\n const from = this.#state\n this.#previousPrimary = null\n this.#transition('unicast')\n this.#disarmFec()\n this.#unsuppress()\n if (from !== 'unicast' && this.config.trackType === 'audio') {\n this.#manager.mmtp?.onTrackEvent?.(this.config.trackName, { type: 'reset' }, this.config)\n }\n }\n\n /** Hysteresis expired \u2192 failback to next transport (Claim 9g). */\n hysteresisExpired(): void {\n if (this.#state !== 'failing') return\n this.#clearHysteresis()\n this.transportFailed()\n }\n\n /** \u2192 idle: player unsubscribes / tab close / cleanup. */\n unsubscribe(): void {\n this.#clearHysteresis()\n if (this.#state !== 'idle') {\n this.#transition('idle')\n // Unwind any outstanding suppression regardless of which state we left.\n // The old `wasActive` check (multicast/amt only) leaked suppression when\n // tearing down from `failing` or `unicast-fallback` \u2014 failbackAll()'s\n // unsubscribe\u2192subscribe cycle then re-entered unicast with the host's\n // media still muted (BLO-9413 freeze: \"failing back\" logged, video dead).\n this.#unsuppress()\n }\n }\n\n /** True when SessionManager.enterUnicastFallback put this track into the\n * host-driven unicast subscription mode. Host can iterate sessions to\n * decide whether to keep its fallback drainer alive across MediaRouter\n * arbitration events. */\n get isUnicastFallback(): boolean {\n return this.#state === 'unicast-fallback'\n }\n\n /**\n * True while this track owns a FEC decoder (fec-setup fired, fec-teardown\n * not yet). Stays true across a takeover timeout that parked the session in\n * `unicast`; SessionManager.failbackAll uses it to release such sessions.\n */\n get isFecArmed(): boolean {\n return this.#fecArmed\n }\n\n /**\n * Start the deadline a `joining` entry deferred while the join was inactive\n * (SessionManager.joinActive). The track gets the FULL deadline from here,\n * because that is when media can first arrive. No-op outside `joining` and\n * when a deadline is already running, so it never shortens or restarts one.\n *\n * The `joinActive` guard is what makes this safe to call from anywhere: a\n * track parked in `joining` with a deferred (null) timer satisfies the other\n * two, so without it a host calling this while the join is still inactive\n * would arm the exact deadline the gate exists to defer -- silently\n * reinstating BLO-34289. It is a no-op for the internal caller, which writes\n * `#joinActive = true` before iterating the sessions.\n */\n armDeferredJoinDeadline(): void {\n if (this.#state !== 'joining' || this.#joinDeadlineTimer !== null) return\n if (!this.#manager.joinActive) return\n this.#armJoinDeadline(this.#manager.takeoverDeadlineMs)\n }\n\n dispose(): void {\n this.#clearHysteresis()\n this.#clearJoinDeadline()\n this.#state = 'idle'\n this.#subscription = null\n // Release through the teardown edge, not a bare assignment: the\n // retained-FEC park makes `unicast` + isFecArmed a resting state, and\n // SessionManager.dispose() calls this directly (removeTrack() reaches it\n // via unsubscribe(), which has already disarmed). A bare assignment there\n // drops the host's decoder without a fec-teardown -- a leaked Worker.\n this.#disarmFec()\n }\n\n // -----------------------------------------------------------------------\n // Internal\n // -----------------------------------------------------------------------\n\n #transition(to: TrackState, opts?: { retainFec?: boolean }): void {\n const from = this.#state\n if (from === to) return\n // Takeover deadline (BLO-9413/D9): every entry into `joining` arms it\n // (joinStarted AND transportFailed-failover both route through here);\n // every exit cancels it. Without this, a transport that accepts the\n // subscribe but never delivers a RAP parks the session in `joining`\n // forever (observed live in the Phase-0 headless repro). The value is\n // resolved before any state write, so the one residual failure -- a\n // manager whose deadline was never set at all, which addTrack() refuses --\n // leaves the session in its previous state rather than half-transitioned.\n // Invalid values never reach here: SessionManager's setter refuses them.\n // The read is unconditional even when `joinActive` defers the arm, so a\n // manager without a deadline still fails closed on every joining edge.\n const joinDeadlineMs = to === 'joining' ? this.#manager.takeoverDeadlineMs : undefined\n this.#state = to\n if (from === 'joining') this.#clearJoinDeadline()\n if (joinDeadlineMs !== undefined && this.#manager.joinActive) {\n this.#armJoinDeadline(joinDeadlineMs)\n }\n // FEC teardown: entering a state without a FEC decoder releases the one\n // this track armed, unless the caller keeps it (takeover timeout: the\n // transport is still joined and still feeding the decoder).\n const fecActive = (s: TrackState) =>\n s === 'joining' ||\n s === 'multicast' ||\n s === 'amt' ||\n s === 'failing' ||\n s === 'unicast-fallback'\n if (!fecActive(to) && !opts?.retainFec) this.#disarmFec()\n this.#manager.callbacks?.onStateChange?.(this.config.trackName, from, to)\n }\n\n #clearHysteresis(): void {\n if (this.#hysteresisTimer !== null) {\n clearTimeout(this.#hysteresisTimer)\n this.#hysteresisTimer = null\n }\n }\n\n #armJoinDeadline(deadlineMs: number): void {\n this.#clearJoinDeadline()\n if (deadlineMs === 0) return // a declared 0 disables the deadline\n this.#joinDeadlineTimer = setTimeout(() => {\n this.#joinDeadlineTimer = null\n if (this.#state !== 'joining') return\n // No RAP within the deadline: return to unicast for arbitration only.\n // MoQ was never suppressed in `joining`, so there is nothing to restore\n // and this edge stays silent on onSuppress (asserted in tests). The\n // multicast socket is still joined and the router still admits this\n // stream's symbols, so the FEC decoder stays armed; the next transport\n // edge releases it.\n this.#transition('unicast', { retainFec: true })\n this.#manager.callbacks?.onTakeoverTimeout?.(this.config.trackName)\n }, deadlineMs)\n }\n\n #clearJoinDeadline(): void {\n if (this.#joinDeadlineTimer !== null) {\n clearTimeout(this.#joinDeadlineTimer)\n this.#joinDeadlineTimer = null\n }\n }\n\n /** Fire fec-setup exactly once per FEC epoch. */\n #armFec(): void {\n if (this.#fecArmed) return\n this.#fecArmed = true\n this.#manager.mmtp?.onTrackEvent?.(this.config.trackName, { type: 'fec-setup' }, this.config)\n }\n\n /** Fire fec-teardown for an armed decoder; no-op when none is armed. */\n #disarmFec(): void {\n if (!this.#fecArmed) return\n this.#fecArmed = false\n this.#manager.mmtp?.onTrackEvent?.(this.config.trackName, { type: 'fec-teardown' }, this.config)\n }\n\n /** Fire onSuppress(true) exactly once per suppression epoch. */\n #suppress(): void {\n if (this.#suppressFired) return\n this.#suppressFired = true\n this.#manager.callbacks?.onSuppress?.(this.config.trackName, true)\n }\n\n /** Unwind an outstanding suppression; no-op when none is outstanding. */\n #unsuppress(): void {\n if (!this.#suppressFired) return\n this.#suppressFired = false\n this.#manager.callbacks?.onSuppress?.(this.config.trackName, false)\n }\n}\n\n// ---------------------------------------------------------------------------\n// SessionManager\n// ---------------------------------------------------------------------------\n\n/**\n * Largest takeover deadline setTimeout represents faithfully. setTimeout\n * coerces its delay to a signed 32-bit int, so a larger value fires on the\n * next tick (Node also warns TimeoutOverflowWarning) instead of after the\n * window the host asked for -- silently inverting the feature. Rejecting it\n * keeps the classic units bug (seconds-as-ms, a wrong timescale) loud.\n */\nexport const MAX_TAKEOVER_DEADLINE_MS = 2_147_483_647\n\n/**\n * How long `joinActive` may stay false before the library says so. The deferral\n * is unbounded by construction -- that is the feature -- so a host that never\n * flips it back parks every track in `joining` with no timer at all: exactly the\n * forever-join the takeover deadline exists to prevent, only silent. MoQ stays\n * unsuppressed so the viewer sees video, but onTakeoverTimeout never fires, the\n * FEC decoder stays armed, and the preference chain never advances past the\n * transport that failed. It looks like a slow join. A host is one un-awaited\n * rejection between `false` and `true` away from it, so the library complains\n * rather than trusting every future host to get all of its exit paths right.\n *\n * Generous against a genuinely slow subscribe -- a bridge reconnect or AMT relay\n * handshake is seconds -- so anything still unsettled here is broken, not slow.\n */\nexport const JOIN_DEFER_WARN_MS = 30_000\n\nexport class SessionManager {\n readonly pool: TransportPool\n readonly sessions = new Map<string, TrackSession>()\n callbacks: SessionManagerCallbacks | null = null\n #takeoverDeadlineMs: number | undefined = undefined\n #joinActive = true\n #joinDeferWarnTimer: ReturnType<typeof setTimeout> | null = null\n\n /**\n * Takeover deadline (BLO-9413/D9): the time a session may sit in `joining`\n * without a RAP before returning to `unicast` and firing onTakeoverTimeout.\n * MoQ is never suppressed during `joining`, so expiry abandons the takeover\n * without a playback glitch. The host derives it from the catalog (RAP\n * cadence from groupDurationTicks/timescale plus the signaled FEC delivery\n * window or reorder tolerance) and sets it before addTrack(). There is no\n * default: an unset value fails addTrack() closed, and reading the getter\n * before assignment throws the same way, so no host ever holds a\n * `number | undefined` it could coalesce back into a default.\n * `hasTakeoverDeadline` answers whether one was admitted. A declared 0\n * disables the deadline. The value is an integer number of milliseconds:\n * setTimeout truncates fractions, so a host that divides ticks by a\n * timescale must round before assigning.\n *\n * Assignment validates, so the diagnostic lands at the host's own call site.\n * That is what bounds the blast radius: `joining` is entered from\n * joinStarted(), from the transportFailed() failover branch -- itself\n * reachable from a setTimeout via hysteresisExpired(), where no caller could\n * catch a throw -- and from the activateMulticast()/activateAmt()/pool-failure\n * loops, where a throw on one session would abandon its siblings mid-loop\n * (the split-brain those loops exist to prevent). Refusing a bad value at\n * assignment means no host mutation can make an already-admitted session\n * throw out of a transport edge.\n */\n get takeoverDeadlineMs(): number {\n return this.#requireTakeoverDeadlineMs()\n }\n\n set takeoverDeadlineMs(value: number) {\n if (!Number.isInteger(value) || value < 0 || value > MAX_TAKEOVER_DEADLINE_MS) {\n throw new Error(\n `[SessionManager] takeoverDeadlineMs must be an integer from 0 to ${MAX_TAKEOVER_DEADLINE_MS} (0 disables it, larger values overflow setTimeout); got ${String(value)}`,\n )\n }\n this.#takeoverDeadlineMs = value\n }\n\n /** True once the host has admitted a deadline; a declared 0 counts. */\n get hasTakeoverDeadline(): boolean {\n return this.#takeoverDeadlineMs !== undefined\n }\n\n /**\n * Whether the transport the joining tracks are waiting on is actually\n * subscribed. The takeover deadline is a media-arrival budget and media\n * cannot arrive before the join exists, so while this is false, entering\n * `joining` arms no deadline; flipping it true starts the FULL deadline for\n * every track already parked there.\n *\n * Default true, which is the arm-on-`joining` behaviour every host had\n * before: a host that only calls joinStarted() after its own join resolves\n * (moqtail-private's player, post-BLO-33339) is already at the right edge and\n * needs no gate. A host that arms joinStarted() up front \u2014 to have FEC\n * decoders ready before the first datagram \u2014 sets this false before addTrack()\n * and true when its subscribe settles. Charging a slow subscribe (bridge\n * reconnect, AMT relay handshake) against the media budget expired the\n * deadline before the subscription existed and abandoned the join on the\n * happy path. BLO-34289.\n *\n * The deadline is a media budget (group cadence plus FEC delivery window) and\n * budgets nothing for reaching the stream, so ideally it starts at\n * MulticastTransport.onDataPlaneLive, the join's first accepted packet. Over\n * AMT that is measurably later than a settled subscribe: the subscribe\n * settles once the gateway has sent its Membership Update, and the relay's\n * upstream join plus a path RTT (0.4-0.8 s measured to the prod relay) still\n * precede the first packet. But RFC 7450 gives a gateway no signal that the\n * relay has traffic for its (S,G), so a join whose source never sends never\n * reaches that edge. Starting the deadline there is therefore sound only when\n * the provider bounds the join phase itself, with a typed subscription error\n * for a join that delivers nothing (MulticastTransport forwards it to\n * onFailback). A host whose provider makes no such guarantee flips this true\n * when the subscribe settles and accepts that join latency is charged to the\n * media budget; holding it until the first packet would park a silent join\n * in `joining` forever.\n *\n * The flag tracks the host's primary join, not per-track transport state. A\n * track that fails over to the next transport in its own chain while this is\n * false (transportFailed's failover branch also enters `joining`) has that\n * deadline gated on the primary join activating, not on its own transport.\n * Under the documented usage -- false once at setup, true once the subscribe\n * settles -- the two coincide.\n *\n * The gate is on the ENTRY into `joining`, and false cannot take back a\n * deadline already running: the `true -> false` edge arms the warn below and\n * clears nothing. So set it false BEFORE the `joining` entry it is meant to\n * gate. A host that instead flipped it false after a `transportFailed()`\n * failover had already entered `joining` would leave the full deadline\n * counting against a subscribe that had not begun -- BLO-34289 again, on the\n * failover edge. Clearing running deadlines on that edge is deliberately NOT\n * done: this flag is manager-wide, so one transport's brief false would\n * cancel an unrelated track's live deadline and then re-arm it at FULL length\n * on the next activation (armDeferredJoinDeadline sees `joining` + a null\n * timer and cannot tell a cleared deadline from a deferred one). Every\n * unrelated transport blip would restart the whole budget, so under repeated\n * failover a track that should have timed out is extended indefinitely and\n * never advances. Only a false that never returns true loses the timer\n * outright, and JOIN_DEFER_WARN_MS already covers that.\n *\n * Leaving this false is not free: see JOIN_DEFER_WARN_MS.\n */\n get joinActive(): boolean {\n return this.#joinActive\n }\n\n set joinActive(active: boolean) {\n const wasActive = this.#joinActive\n this.#joinActive = active\n if (!active) {\n // Edge-triggered: a host re-declaring false has not restarted its\n // subscribe, and total deferral is what the warning is about.\n if (wasActive) this.#armJoinDeferWarn()\n return\n }\n this.#clearJoinDeferWarn()\n if (wasActive) return\n for (const [, session] of this.sessions) session.armDeferredJoinDeadline()\n }\n\n #armJoinDeferWarn(): void {\n this.#joinDeferWarnTimer = setTimeout(() => {\n this.#joinDeferWarnTimer = null\n const deferred = [...this.sessions.values()].filter(s => s.state === 'joining').length\n console.warn(\n `[SessionManager] joinActive has been false for ${JOIN_DEFER_WARN_MS} ms and ${deferred} track(s) sit in 'joining' with no takeover deadline. Set joinActive = true when the subscribe settles, on every exit path including failures, or these tracks never time out, never release their FEC decoders, and never advance to the next transport.`,\n )\n }, JOIN_DEFER_WARN_MS)\n }\n\n #clearJoinDeferWarn(): void {\n if (this.#joinDeferWarnTimer === null) return\n clearTimeout(this.#joinDeferWarnTimer)\n this.#joinDeferWarnTimer = null\n }\n\n /** Narrow to MMTP callbacks (onTrackEvent). Safe \u2014 optional chaining handles PIM-only consumers. */\n get mmtp(): MmtpSessionCallbacks | null {\n return this.callbacks as MmtpSessionCallbacks | null\n }\n\n constructor(poolOptions?: TransportPoolOptions) {\n this.pool = new TransportPool(poolOptions)\n\n // Wire pool failure \u2192 per-track failover\n this.pool.onSubscriptionFailed(sub => {\n for (const trackName of sub.consumers) {\n const session = this.sessions.get(trackName)\n if (session) session.transportFailed()\n }\n })\n }\n\n // -----------------------------------------------------------------------\n // Track management\n // -----------------------------------------------------------------------\n\n /**\n * The admitted takeover deadline. Throws only when the host never set one,\n * every other invalid value being refused by the setter, and returns a\n * declared 0 as 0. The getter and addTrack() both route through it, so a\n * session cannot exist without an admitted deadline, which is what makes\n * the #transition() read unable to throw on an already-admitted session.\n */\n #requireTakeoverDeadlineMs(): number {\n const value = this.#takeoverDeadlineMs\n if (value === undefined) {\n throw new Error(\n '[SessionManager] takeoverDeadlineMs is unset; set a catalog-derived takeover deadline (0 disables it) before adding tracks',\n )\n }\n return value\n }\n\n /** Add a track session from catalog config. */\n addTrack(config: TrackSessionConfig): TrackSession {\n if (this.sessions.has(config.trackName)) {\n throw new Error(`[SessionManager] Track \"${config.trackName}\" already exists`)\n }\n this.#requireTakeoverDeadlineMs()\n const session = new TrackSession(config, this)\n this.sessions.set(config.trackName, session)\n return session\n }\n\n /** Remove a track session. Releases subscription with grace period. */\n removeTrack(trackName: string): void {\n const session = this.sessions.get(trackName)\n if (!session) return\n session.unsubscribe()\n\n // Release pool subscription\n const subKey = session.config.multicastKey ?? session.config.moqTrackName\n if (subKey) this.pool.releaseSubscription(subKey, trackName)\n\n session.dispose()\n this.sessions.delete(trackName)\n }\n\n // -----------------------------------------------------------------------\n // Atomic multi-track operations\n // -----------------------------------------------------------------------\n\n /**\n * All tracks with multicast RAP \u2192 multicast state (atomic).\n * Called when MediaRouter fires onSourceSwitch('multicast').\n * Prevents split-brain between video and audio.\n *\n * A takeover deadline can return a track from `joining` to `unicast` while\n * the multicast socket keeps delivering packets and the track keeps its FEC\n * decoder armed. If a valid RAP arrives later, that late-RAP edge suppresses\n * unicast and promotes multicast without a second fec-setup.\n */\n activateMulticast(): void {\n for (const [, session] of this.sessions) {\n if (session.state === 'unicast') {\n session.joinStarted()\n }\n if (session.state === 'joining') {\n session.rapReceived('multicast')\n }\n }\n }\n\n /**\n * All tracks with AMT RAP \u2192 amt state (atomic).\n */\n activateAmt(): void {\n for (const [, session] of this.sessions) {\n if (session.state === 'unicast') {\n session.joinStarted()\n }\n if (session.state === 'joining') {\n session.rapReceived('amt')\n }\n }\n }\n\n /**\n * All tracks \u2192 unicast (failback, atomic).\n * Called when MulticastManager fires onFailback().\n * unicast-fallback sessions are skipped \u2014 the host owns those subscriptions\n * and they must drain uninterrupted across transport-state events.\n *\n * Coherence invariant (BLO-9413): after this returns, every non-fallback\n * session is `unicast` (or `idle`), unsuppressed, and without a FEC decoder.\n * unsubscribe() unwinds any outstanding suppression regardless of the state\n * it tears down from (`failing` included; see TrackSession.#suppressFired),\n * and a session that a takeover timeout parked in `unicast` with its decoder\n * still armed is cycled too, because this transport edge is what releases\n * that decoder (unsubscribe fires fec-teardown).\n */\n failbackAll(): void {\n for (const [, session] of this.sessions) {\n const parkedWithFec = session.state === 'unicast' && session.isFecArmed\n if (\n parkedWithFec ||\n (session.state !== 'idle' &&\n session.state !== 'unicast' &&\n session.state !== 'unicast-fallback')\n ) {\n session.unsubscribe()\n session.subscribe() // re-enter unicast\n }\n }\n }\n\n /**\n * All tracks \u2192 unicast-fallback (atomic).\n * Called by the host when the multicast gateway is absent and\n * the player will subscribe to source tracks over MoQ unicast and feed\n * packets via MulticastManager.feedUnicastSource() (or manager.router\n * directly). Sessions in this state stay terminal until the host calls\n * unsubscribe() / disposes the SessionManager.\n */\n enterUnicastFallback(): void {\n for (const [, session] of this.sessions) {\n if (session.state === 'idle') continue\n session.enterUnicastFallback()\n }\n }\n\n /**\n * True if any session is currently in unicast-fallback. Hosts use this to\n * gate `multicastTrack.close()` on failback events \u2014 closing the shared\n * broadcast would kill the host's fallback drainers.\n */\n hasActiveFallback(): boolean {\n for (const [, session] of this.sessions) {\n if (session.isUnicastFallback) return true\n }\n return false\n }\n\n /**\n * All fallback tracks \u2192 unicast (atomic).\n * Called when the SSM bridge appears mid-session and the host has aborted\n * the fallback drainers. The session re-enters the regular preference chain\n * via joinStarted() (issued by the host immediately after).\n */\n exitUnicastFallback(): void {\n for (const [, session] of this.sessions) {\n if (session.isUnicastFallback) session.exitUnicastFallback()\n }\n }\n\n // -----------------------------------------------------------------------\n // Cleanup lifecycle\n // -----------------------------------------------------------------------\n\n /** Tab closed \u2014 release all subscriptions for this tab (grace period). */\n tabClosed(tabId: string): void {\n // All tracks owned by this tab \u2192 idle\n for (const [trackName, session] of this.sessions) {\n session.unsubscribe()\n this.pool.releaseAllForConsumer(trackName)\n }\n }\n\n /** Bridge WebTransport connection dropped \u2014 same as tab close. */\n bridgeDisconnected(): void {\n this.tabClosed('bridge')\n }\n\n /**\n * Track selection changed (user or ABR).\n * Old track released (grace period), new track subscribed.\n */\n trackSelectionChanged(oldTrackName: string, newConfig: TrackSessionConfig): void {\n this.removeTrack(oldTrackName)\n const session = this.addTrack(newConfig)\n session.subscribe()\n }\n\n // -----------------------------------------------------------------------\n // Query\n // -----------------------------------------------------------------------\n\n /** Get a track session by name. */\n getTrack(trackName: string): TrackSession | undefined {\n return this.sessions.get(trackName)\n }\n\n // -----------------------------------------------------------------------\n // Cleanup\n // -----------------------------------------------------------------------\n\n dispose(): void {\n this.#clearJoinDeferWarn()\n for (const [, session] of this.sessions) {\n session.dispose()\n }\n this.sessions.clear()\n this.pool.dispose()\n this.callbacks = null\n }\n}\n"],
5
5
  "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -4851,9 +4851,21 @@ var IsoBmffTimingResolver = class {
4851
4851
  }
4852
4852
  #track = null;
4853
4853
  #fragments = /* @__PURE__ */ new Map();
4854
+ /** First sample of each pending fragment, kept in `#delivered` with its sample count. */
4855
+ #firstSamples = /* @__PURE__ */ new Map();
4856
+ /**
4857
+ * First sample and sample count of the newest fragments whose every slot was
4858
+ * cleared. Abandonment counts: `releaseSample` clears a slot too, which
4859
+ * matches the combiner, since it retires an abandoned sample as well.
4860
+ */
4861
+ #delivered = /* @__PURE__ */ new Map();
4854
4862
  #pendingSamples = 0;
4855
4863
  #evictedSamples = 0;
4856
4864
  #lastMovieFragmentRejection = null;
4865
+ /** True once an FT=0 has bound the track that FT=1 timing is read against. */
4866
+ get initialized() {
4867
+ return this.#track !== null;
4868
+ }
4857
4869
  get pendingSampleCount() {
4858
4870
  return this.#pendingSamples;
4859
4871
  }
@@ -4877,7 +4889,14 @@ var IsoBmffTimingResolver = class {
4877
4889
  this.#track = timing;
4878
4890
  return true;
4879
4891
  }
4880
- addMovieFragment(mpuSequenceNumber, data, additionalSampleLimit = this.maxPendingSamples) {
4892
+ /**
4893
+ * `recovered` marks an FEC-recovered copy. A recovered block hands back
4894
+ * every FT=1 it holds, the received ones included, so a recovered copy of a
4895
+ * fragment whose samples are all done is a duplicate: admitting it again
4896
+ * would charge stale timing and could displace the live MPU's. A source
4897
+ * copy is still admitted again (a rendition switch re-arms timing that way).
4898
+ */
4899
+ addMovieFragment(mpuSequenceNumber, data, additionalSampleLimit = this.maxPendingSamples, recovered = false) {
4881
4900
  this.#lastMovieFragmentRejection = null;
4882
4901
  if (!Number.isInteger(additionalSampleLimit) || additionalSampleLimit < 0) {
4883
4902
  this.#lastMovieFragmentRejection = "invalid";
@@ -4903,6 +4922,8 @@ var IsoBmffTimingResolver = class {
4903
4922
  (sample, index) => sample !== null && sameSample(sample, parsed.samples[index])
4904
4923
  );
4905
4924
  }
4925
+ const delivered = recovered ? this.#delivered.get(key) : void 0;
4926
+ if (delivered && delivered.sampleCount === parsed.samples.length && sameSample(delivered.first, parsed.samples[0])) return true;
4906
4927
  let freed = 0;
4907
4928
  while (this.#fragments.size > 0 && (this.#pendingSamples + parsed.samples.length > this.maxPendingSamples || parsed.samples.length - freed > additionalSampleLimit)) {
4908
4929
  freed += this.#evictOldestFragment();
@@ -4916,6 +4937,7 @@ var IsoBmffTimingResolver = class {
4916
4937
  return false;
4917
4938
  }
4918
4939
  this.#fragments.set(key, [...parsed.samples]);
4940
+ this.#firstSamples.set(key, parsed.samples[0]);
4919
4941
  this.#pendingSamples += parsed.samples.length;
4920
4942
  return true;
4921
4943
  }
@@ -4973,6 +4995,7 @@ var IsoBmffTimingResolver = class {
4973
4995
  let live = 0;
4974
4996
  for (const sample of samples) if (sample !== null) live++;
4975
4997
  this.#fragments.delete(oldest);
4998
+ this.#firstSamples.delete(oldest);
4976
4999
  this.#pendingSamples -= live;
4977
5000
  this.#evictedSamples += live;
4978
5001
  return live;
@@ -4988,12 +5011,23 @@ var IsoBmffTimingResolver = class {
4988
5011
  if (!samples || !samples[index]) return false;
4989
5012
  samples[index] = null;
4990
5013
  this.#pendingSamples--;
4991
- if (samples.every((value) => value === null)) this.#fragments.delete(key);
5014
+ if (samples.every((value) => value === null)) {
5015
+ this.#fragments.delete(key);
5016
+ const first = this.#firstSamples.get(key);
5017
+ this.#firstSamples.delete(key);
5018
+ this.#delivered.delete(key);
5019
+ this.#delivered.set(key, { first, sampleCount: samples.length });
5020
+ if (this.#delivered.size > this.maxPendingSamples) {
5021
+ this.#delivered.delete(this.#delivered.keys().next().value);
5022
+ }
5023
+ }
4992
5024
  return true;
4993
5025
  }
4994
5026
  reset() {
4995
5027
  this.#track = null;
4996
5028
  this.#fragments.clear();
5029
+ this.#firstSamples.clear();
5030
+ this.#delivered.clear();
4997
5031
  this.#pendingSamples = 0;
4998
5032
  }
4999
5033
  };
@@ -5475,6 +5509,12 @@ var MmtpContainerParser = class _MmtpContainerParser {
5475
5509
  this.#reclaimedTimingSamples = 0;
5476
5510
  this.#evictedTimingSamples = 0;
5477
5511
  this.#rejectedMovieFragments = 0;
5512
+ this.#recoveredHeadsAwaitingTiming = {
5513
+ held: 0,
5514
+ expired: 0,
5515
+ discarded: 0,
5516
+ superseded: 0
5517
+ };
5478
5518
  this.#sequencers = /* @__PURE__ */ new Map();
5479
5519
  this.#fecGate = null;
5480
5520
  this.#fecSequencer = null;
@@ -5583,6 +5623,7 @@ var MmtpContainerParser = class _MmtpContainerParser {
5583
5623
  #reclaimedTimingSamples;
5584
5624
  #evictedTimingSamples;
5585
5625
  #rejectedMovieFragments;
5626
+ #recoveredHeadsAwaitingTiming;
5586
5627
  #sequencers;
5587
5628
  #fecGate;
5588
5629
  #fecSequencer;
@@ -5703,6 +5744,14 @@ var MmtpContainerParser = class _MmtpContainerParser {
5703
5744
  rejectedMovieFragments: this.#rejectedMovieFragments
5704
5745
  };
5705
5746
  }
5747
+ /** Recovered heads parked for FT=1 timing (BLO-53369). Survives dispose(). */
5748
+ get recoveredHeadsAwaitingTiming() {
5749
+ let pending = 0;
5750
+ for (const { awaitingTiming } of this.#timingStreams.values()) {
5751
+ for (const head of awaitingTiming) if (head.options?.recovered) pending++;
5752
+ }
5753
+ return { ...this.#recoveredHeadsAwaitingTiming, pending };
5754
+ }
5706
5755
  get reorderStats() {
5707
5756
  let bufferedFrames = this.#fecSequencer?.buffered ?? 0;
5708
5757
  for (const { sequencer } of this.#sequencers.values()) bufferedFrames += sequencer.buffered;
@@ -5876,7 +5925,7 @@ var MmtpContainerParser = class _MmtpContainerParser {
5876
5925
  const now = Date.now();
5877
5926
  for (const { awaitingTiming } of this.#timingStreams.values()) {
5878
5927
  while (awaitingTiming.length > 0 && now - awaitingTiming[0].arrivedAt > timeoutMs) {
5879
- awaitingTiming.shift();
5928
+ this.#leaveAwaitingTiming(awaitingTiming.shift(), "expired");
5880
5929
  }
5881
5930
  }
5882
5931
  }
@@ -5994,11 +6043,16 @@ var MmtpContainerParser = class _MmtpContainerParser {
5994
6043
  if (mpu.fragmentationIndicator !== 0 || mpu.fragmentCounter !== 0)
5995
6044
  return this.#drop("fragment-unexpected-fragmentation");
5996
6045
  const resolver2 = this.#timingResolver(streamKey, info.packetId, false);
5997
- if (!resolver2) return this.#drop("fragment-no-resolver");
6046
+ if (!resolver2?.initialized) return this.#drop("fragment-no-resolver");
5998
6047
  const pendingBefore = resolver2.pendingSampleCount;
5999
6048
  const evictedBefore = resolver2.evictedSampleCount;
6000
6049
  const remaining = this.#maxPendingTimingSamples - this.#pendingTimingSamples;
6001
- const admitted = resolver2.addMovieFragment(mpu.mpuSequenceNumber, data, remaining);
6050
+ const admitted = resolver2.addMovieFragment(
6051
+ mpu.mpuSequenceNumber,
6052
+ data,
6053
+ remaining,
6054
+ options?.recovered === true
6055
+ );
6002
6056
  this.#pendingTimingSamples += resolver2.pendingSampleCount - pendingBefore;
6003
6057
  this.#evictedTimingSamples += resolver2.evictedSampleCount - evictedBefore;
6004
6058
  if (!admitted) {
@@ -6018,7 +6072,7 @@ var MmtpContainerParser = class _MmtpContainerParser {
6018
6072
  };
6019
6073
  }
6020
6074
  if (fragmentType !== 2) return this.#drop("unknown-fragment-type");
6021
- const resolver = this.#timingResolver(streamKey, info.packetId, false);
6075
+ const resolver = this.#timingResolver(streamKey, info.packetId, true);
6022
6076
  if (!resolver) return this.#drop("mfu-no-resolver");
6023
6077
  if (!Number.isSafeInteger(data.byteLength) || data.byteLength <= 0)
6024
6078
  return this.#drop("mfu-empty-payload");
@@ -6039,6 +6093,7 @@ var MmtpContainerParser = class _MmtpContainerParser {
6039
6093
  mpuSequenceNumber: mpu.mpuSequenceNumber,
6040
6094
  movieFragmentSequenceNumber: mfuDuHeader.movieFragmentSequenceNumber,
6041
6095
  sampleNumber: mfuDuHeader.sampleNumber,
6096
+ sequenceNumber: info.sequenceNumber,
6042
6097
  arrivedAt: Date.now()
6043
6098
  });
6044
6099
  return this.#drop("mfu-sample-timing-missing");
@@ -6362,6 +6417,10 @@ var MmtpContainerParser = class _MmtpContainerParser {
6362
6417
  }
6363
6418
  this.#emitFrame(data, meta);
6364
6419
  }
6420
+ /** Whether FT=1 metadata already names this sample, without claiming it. */
6421
+ hasAuthoredTimingFor(packetId, mpuSequenceNumber, movieFragmentSequenceNumber, sampleNumber, catalogTrackName) {
6422
+ return this.#timingResolver(this.#streamKey(packetId, catalogTrackName), packetId, false)?.sampleTiming(mpuSequenceNumber, movieFragmentSequenceNumber, sampleNumber) != null;
6423
+ }
6365
6424
  /**
6366
6425
  * Claim and return the authored timing for a recovered sample. Recovery does
6367
6426
  * not pass through the ordinary `feed()` completion path, so it must retire
@@ -6423,6 +6482,9 @@ var MmtpContainerParser = class _MmtpContainerParser {
6423
6482
  for (const { sequencer } of this.#sequencers.values()) sequencer.discard();
6424
6483
  this.#sequencers.clear();
6425
6484
  this.#fecSequencer?.discard();
6485
+ for (const { awaitingTiming } of this.#timingStreams.values()) {
6486
+ for (const head of awaitingTiming) this.#leaveAwaitingTiming(head, "discarded");
6487
+ }
6426
6488
  this.#timingStreams.clear();
6427
6489
  this.#pendingTimingSamples = 0;
6428
6490
  this.#reliableMoqStartupGate.clear();
@@ -6441,10 +6503,23 @@ var MmtpContainerParser = class _MmtpContainerParser {
6441
6503
  return resolver;
6442
6504
  }
6443
6505
  #awaitTiming(streamKey, head) {
6444
- const awaitingTiming = this.#timingStreams.get(streamKey)?.awaitingTiming;
6445
- if (!awaitingTiming) return;
6506
+ const state = this.#timingStreams.get(streamKey);
6507
+ if (!state) return;
6508
+ if (!head.options?.recovered) {
6509
+ const kept = state.awaitingTiming.filter((queued) => !queued.options?.recovered || queued.sequenceNumber !== head.sequenceNumber);
6510
+ this.#recoveredHeadsAwaitingTiming.superseded += state.awaitingTiming.length - kept.length;
6511
+ state.awaitingTiming = kept;
6512
+ }
6513
+ const awaitingTiming = state.awaitingTiming;
6446
6514
  awaitingTiming.push(head);
6447
- if (awaitingTiming.length > MAX_AWAITING_TIMING_HEADS) awaitingTiming.shift();
6515
+ if (head.options?.recovered) this.#recoveredHeadsAwaitingTiming.held++;
6516
+ if (awaitingTiming.length > MAX_AWAITING_TIMING_HEADS) {
6517
+ this.#leaveAwaitingTiming(awaitingTiming.shift(), "expired");
6518
+ }
6519
+ }
6520
+ /** Count a recovered head leaving the queue other than by replay or its source copy. */
6521
+ #leaveAwaitingTiming(head, outcome) {
6522
+ if (head.options?.recovered) this.#recoveredHeadsAwaitingTiming[outcome]++;
6448
6523
  }
6449
6524
  /**
6450
6525
  * Feed again the heads whose timing the FT=1 just admitted declares. That
@@ -6472,6 +6547,7 @@ var MmtpContainerParser = class _MmtpContainerParser {
6472
6547
  const state = this.#timingStreams.get(streamKey);
6473
6548
  if (!state) return;
6474
6549
  this.#timingStreams.delete(streamKey);
6550
+ for (const head of state.awaitingTiming) this.#leaveAwaitingTiming(head, "discarded");
6475
6551
  this.#pendingTimingSamples -= state.resolver.pendingSampleCount;
6476
6552
  if (this.#pendingTimingSamples < 0) {
6477
6553
  throw new Error("[MmtpContainerParser] pending timing sample accounting underflow");