Skip to main content

g2g_core/runtime/
graph_runner.rs

1//! DAG pipeline runner (DESIGN_TODO "DAG runner" D3).
2//!
3//! [`run_graph`] drives an arbitrary multimedia DAG built with [`Graph`]:
4//! whole-graph CSP negotiation via [`solve_graph`] (D2), then one spawned arm
5//! per node over per-edge channels, joined with [`join_all`]. It collapses the
6//! linear + fan-out runner shapes into one entry point.
7//!
8//! Scope: source / transform / sink / tee (fan-out) + muxer (fan-in) + terminal
9//! fan-in (M713). A tee
10//! broadcasts each packet to all its branches via [`MemoryDomain::share`]
11//! (M213): a zero-copy refcount bump for the GPU domains and the shared-CPU
12//! `SystemView`, a deep copy only for owned-CPU `System` bytes. So a GPU-decoded
13//! frame fans out to several consumers (inference + display) with no
14//! device-to-host copy. A muxer node
15//! runs a [`DynMultiInputElement`]: per-input forwarder arms tag each packet
16//! with its pad and feed one muxer arm that combines them, emitting a single
17//! `Eos` after every input ends (the `run_muxer_sink` shape). A terminal fan-in
18//! node ([`Graph::add_fanin_sink`]) runs a `DynMultiInputElement` with NO
19//! downstream (the `run_fanin_session` shape, e.g. a WebRTC session publishing
20//! its inputs): one arm drains the input edges round-robin, delivers per-input
21//! `Eos` flushes, ends once every input has ended, and relays each pad's
22//! reverse signal (PLI / BWE) onto its own in-edge so it reaches the encoder
23//! feeding that pad.
24//!
25//! D4 adds the mid-stream re-solve and the β allocation re-cascade over the
26//! DAG. Each arm gets a per-edge downstream feasibility snapshot at startup
27//! ([`graph_downstream_feasibility`]); on a mid-stream `CapsChanged` a transform
28//! steers its forwarded output toward a downstream-acceptable shape (Caps-α),
29//! and a sink re-solves its input against its declared constraint. A node-keyed
30//! [`GraphCoordinator`] walks the sink's re-derived allocation proposal one hop
31//! upstream per reply via [`ValidatedGraph::in_edges`], resolving through
32//! structural tee nodes; a source terminates the walk. Tee branches re-solve
33//! independently (each broadcast `CapsChanged` lands in its own arm); muxer
34//! inputs re-configure per pad. A muxer's per-pad allocation demand
35//! (`propose_allocation_for_input`) crosses the boundary both at startup
36//! negotiation and mid-stream: a `CapsChanged` on one pad re-cascades the
37//! re-derived proposal up that pad's branch alone (`RecascadeRoute::Pad`),
38//! leaving the other inputs untouched. From there the walk continues *through*
39//! the boundary (M839, [`MuxBeta`]): the merged-output pool is re-derived from
40//! every pad, pushed to the consumer arms, and absorbed by the element, and the
41//! other pads re-cascade only if that moved their demand.
42
43use alloc::boxed::Box;
44use alloc::string::ToString;
45use alloc::vec::Vec;
46use core::pin::Pin;
47
48// The thread-per-arm `ThreadSpawner` uses `std::thread`; `std` is otherwise
49// unused by this (no_std baseline) module.
50#[cfg(all(feature = "std", feature = "multi-thread"))]
51extern crate std;
52
53use crate::aggregator::InputAggregator;
54use crate::bus::{BusHandle, BusMessage};
55use crate::caps::{Caps, CapsSet};
56use crate::clock::{
57    elect_clock, ClockCandidate, ClockPriority, ClockSync, DynAsyncClock, ElectedClock,
58    PipelineClock,
59};
60use crate::controller::{ArmController, ControlTarget, CONTROL_CATEGORY};
61use crate::element::{
62    AsyncElement, BoxFuture, ConfigureOutcome, DynAsyncElement, ElementBound, OutputSink,
63    OutputSinkExt, PushOutcome, Reconfigure,
64};
65use crate::error::G2gError;
66use crate::fanout::{
67    DynMultiOutputSource, MultiInputElement, MultiOutputElement, MultiOutputSink,
68    MultiOutputSinkExt, MultiOutputSource, MultiSenderSink, ReverseChannel,
69};
70use crate::format_element::{CapsConstraint, CapsPreferences};
71use crate::frame::{Frame, PipelinePacket};
72use crate::graph::{FanOutPolicy, Graph, NodeId, NodeKind, ValidatedGraph};
73use crate::memory::{DomainSet, MemoryDomainKind};
74use crate::meta::MetaRequests;
75use crate::property::{PropError, PropValue, PropertySpec};
76use crate::query::{with_meta_demand, AllocationParams, LatencyReport};
77use crate::runtime::channel::{
78    advertise_orientation, bounded, link, link_with_transit, LinkReceiver, LinkSender, Receiver,
79    ReconfigureAnswered, RecvFuture, Sender, SenderSink,
80};
81use crate::runtime::coordinator::{
82    log_caps_forward, log_caps_rejected, realloc_local_dyn, report_nego_failure, ArmDirective,
83};
84use crate::runtime::fanin::{DynMultiInputElement, DynSourceLoop};
85use crate::runtime::instrument::{snapshot_all, ElementProbe, Probe};
86use crate::runtime::join::{join_all, select2, Either};
87use crate::runtime::progress::PipelineProgress;
88use crate::runtime::runner::{
89    re_solve_downstream_dyn_sink, LinkCapacity, NullSink, RunStats, SourceLoop,
90};
91use crate::runtime::solver::{
92    graph_downstream_feasibility, resolve_forward_output, solve_graph_preferred, solve_linear,
93    ForwardResolve, NegotiationFailure, NodeConstraint,
94};
95use crate::runtime::state::{Flow, StateController};
96use crate::runtime::FlightRecorder;
97use crate::runtime::Observer;
98use crate::segment::Segment;
99
100/// Element payload for a [`Graph`] driven by [`run_graph`]. Sources,
101/// transforms/sinks, and muxers implement different traits (a source has no
102/// input pad, a muxer has many), so the payload is an enum the runner matches
103/// on per node kind. A tee carries no element (`Graph::add_tee` takes none).
104///
105/// The `'a` lifetime is the lifetime of the boxed elements. Owned `'static`
106/// elements (the common case, [`GraphNode`]) use `source` / `element` / `muxer`;
107/// the convenience wrappers build a *borrowing* graph over their `&mut` element
108/// references with `source_ref` / `element_ref` / `muxer_ref`, so they can call
109/// `run_graph` without taking ownership and the caller keeps its elements.
110pub enum GraphNodeRef<'a> {
111    Source(Box<dyn DynSourceLoop + 'a>),
112    Element(Box<dyn DynAsyncElement + 'a>),
113    Muxer(Box<dyn DynMultiInputElement + 'a>),
114    /// A terminal fan-out source (M727): 0 inputs, N outputs it generates
115    /// itself (a WebRTC session receiving several tracks). `Graph::add_fanout_src`.
116    FanoutSource(Box<dyn DynMultiOutputSource + 'a>),
117    /// A content-routing demultiplexer: 1 input, N outputs. Structurally a tee
118    /// (its node kind is `Tee(n)`), so it negotiates as a tee at startup, but it
119    /// carries a [`MultiOutputElement`] that routes each packet to a chosen
120    /// output instead of broadcasting, and emits per-output `CapsChanged` so each
121    /// branch retypes from the byte-stream input (M210). `Graph::add_demux`.
122    Demux(Box<dyn DynMultiOutputElement + 'a>),
123}
124
125/// The owning, `'static` graph payload: what most callers build directly.
126pub type GraphNode = GraphNodeRef<'static>;
127
128impl<'a> GraphNodeRef<'a> {
129    /// Box an owned source (`add_source`).
130    pub fn source<S: SourceLoop + 'static>(source: S) -> Self {
131        GraphNodeRef::Source(Box::new(source))
132    }
133
134    /// Box an owned transform or sink (`add_transform` / `add_sink`).
135    pub fn element<E: AsyncElement + 'static>(element: E) -> Self {
136        GraphNodeRef::Element(Box::new(element))
137    }
138
139    /// Box an owned fan-in muxer (`add_muxer`).
140    pub fn muxer<M: MultiInputElement + 'static>(muxer: M) -> Self {
141        GraphNodeRef::Muxer(Box::new(muxer))
142    }
143
144    /// Box an owned terminal fan-out source (`add_fanout_src`).
145    pub fn fanout_source<S: MultiOutputSource + 'static>(source: S) -> Self {
146        GraphNodeRef::FanoutSource(Box::new(source))
147    }
148
149    /// Box a borrowed source, for a borrowing graph (the convenience wrappers).
150    pub fn source_ref(source: &'a mut (dyn DynSourceLoop + 'a)) -> Self {
151        GraphNodeRef::Source(Box::new(source))
152    }
153
154    /// Box a borrowed transform or sink.
155    pub fn element_ref(element: &'a mut (dyn DynAsyncElement + 'a)) -> Self {
156        GraphNodeRef::Element(Box::new(element))
157    }
158
159    /// Box a borrowed fan-in muxer.
160    pub fn muxer_ref(muxer: &'a mut (dyn DynMultiInputElement + 'a)) -> Self {
161        GraphNodeRef::Muxer(Box::new(muxer))
162    }
163
164    /// Box a borrowed terminal fan-out source.
165    pub fn fanout_source_ref(source: &'a mut (dyn DynMultiOutputSource + 'a)) -> Self {
166        GraphNodeRef::FanoutSource(Box::new(source))
167    }
168
169    /// Box an owned fan-out demultiplexer (`add_demux`).
170    pub fn demux<D: MultiOutputElement + 'static>(demux: D) -> Self {
171        GraphNodeRef::Demux(Box::new(demux))
172    }
173
174    /// Box a borrowed fan-out demultiplexer.
175    pub fn demux_ref(demux: &'a mut (dyn DynMultiOutputElement + 'a)) -> Self {
176        GraphNodeRef::Demux(Box::new(demux))
177    }
178
179    /// The element's log category (M179), its short type name, e.g.
180    /// `videotestsrc`. The runner uses it to derive instance names
181    /// (`<category>N`); a DOT dump uses it as the node label before the run
182    /// assigns the suffixed name. Fan-in / fan-out elements don't expose a
183    /// category on their dyn trait (the runner doesn't name them either), so
184    /// they report their structural role.
185    pub fn log_category(&self) -> &'static str {
186        match self {
187            GraphNodeRef::Source(s) => s.log_category(),
188            GraphNodeRef::Element(e) => e.log_category(),
189            GraphNodeRef::Muxer(_) => "mux",
190            GraphNodeRef::FanoutSource(_) => "session-src",
191            GraphNodeRef::Demux(_) => "demux",
192        }
193    }
194
195    /// The memory domain of the frames this node emits on its output pad(s)
196    /// (M285): the source's / element's `output_memory`, surfaced per edge for
197    /// the DOT dump so a GPU / zero-copy link is marked. Fan-in / fan-out
198    /// elements are reported as `System` (their domain is the upstream's; the
199    /// per-edge derivation does not propagate through them yet).
200    pub fn output_memory(&self) -> crate::memory::MemoryDomainKind {
201        match self {
202            GraphNodeRef::Source(s) => s.output_memory(),
203            GraphNodeRef::Element(e) => e.output_memory(),
204            GraphNodeRef::Muxer(_) | GraphNodeRef::FanoutSource(_) | GraphNodeRef::Demux(_) => {
205                crate::memory::MemoryDomainKind::System
206            }
207        }
208    }
209
210    /// The full set of memory domains this node can emit (M351), the
211    /// producer-capability half of the two-sided allocation-domain negotiation.
212    /// A source's / element's `output_domains`; fan-in / fan-out nodes report a
213    /// System singleton (their domain follows the upstream, like
214    /// [`output_memory`](Self::output_memory)).
215    pub fn output_domains(&self) -> crate::memory::DomainSet {
216        match self {
217            GraphNodeRef::Source(s) => s.output_domains(),
218            GraphNodeRef::Element(e) => e.output_domains(),
219            GraphNodeRef::Muxer(_) | GraphNodeRef::FanoutSource(_) | GraphNodeRef::Demux(_) => {
220                crate::memory::DomainSet::only(crate::memory::MemoryDomainKind::System)
221            }
222        }
223    }
224
225    /// The runtime properties this node's element declares, whatever its shape.
226    /// The animated-property check (M882) reads it to validate a
227    /// [`ControlProgram`](crate::ControlProgram) against the element it targets.
228    pub fn properties(&self) -> &'static [PropertySpec] {
229        match self {
230            GraphNodeRef::Source(s) => s.properties(),
231            GraphNodeRef::Element(e) => e.properties(),
232            GraphNodeRef::Muxer(m) => m.properties(),
233            GraphNodeRef::FanoutSource(s) => s.properties(),
234            GraphNodeRef::Demux(d) => d.properties(),
235        }
236    }
237
238    /// The memory domains this node accepts on its input pad(s) (M354), for the
239    /// converter auto-plug and the allocation cascade. A muxer declares one set
240    /// covering every input pad; a terminal fan-out source has no input, so it
241    /// reports [`DomainSet::ALL`] (no requirement), as does a source.
242    pub fn input_domains(&self) -> crate::memory::DomainSet {
243        match self {
244            GraphNodeRef::Element(e) => e.input_domains(),
245            GraphNodeRef::Muxer(m) => m.input_domains(),
246            GraphNodeRef::Demux(d) => d.input_domains(),
247            GraphNodeRef::Source(_) | GraphNodeRef::FanoutSource(_) => {
248                crate::memory::DomainSet::ALL
249            }
250        }
251    }
252}
253
254impl core::fmt::Debug for GraphNodeRef<'_> {
255    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
256        match self {
257            GraphNodeRef::Source(_) => f.write_str("GraphNodeRef::Source(..)"),
258            GraphNodeRef::Element(_) => f.write_str("GraphNodeRef::Element(..)"),
259            GraphNodeRef::Muxer(_) => f.write_str("GraphNodeRef::Muxer(..)"),
260            GraphNodeRef::FanoutSource(_) => f.write_str("GraphNodeRef::FanoutSource(..)"),
261            GraphNodeRef::Demux(_) => f.write_str("GraphNodeRef::Demux(..)"),
262        }
263    }
264}
265
266/// Dyn-safe mirror of [`MultiOutputElement`] for a fan-out demux node in the DAG
267/// runner, the transpose of [`DynMultiInputElement`]. Boxes `process`'s future
268/// and forwards the `Self: Sized` constraint methods. Only the methods the
269/// runner uses are mirrored.
270pub trait DynMultiOutputElement: ElementBound {
271    fn caps_constraint_as_input(&self) -> CapsConstraint<'_>;
272    /// Dyn-safe mirror of [`MultiOutputElement::input_domains`]. Default
273    /// [`DomainSet::ALL`].
274    fn input_domains(&self) -> DomainSet {
275        DomainSet::ALL
276    }
277    fn port_output_caps(&self, port: usize) -> Option<Caps>;
278    fn configure_pipeline(&mut self, absolute_caps: &Caps) -> Result<ConfigureOutcome, G2gError>;
279    fn process<'a>(
280        &'a mut self,
281        packet: PipelinePacket,
282        out: &'a mut dyn MultiOutputSink,
283    ) -> BoxFuture<'a, Result<(), G2gError>>;
284    fn properties(&self) -> &'static [PropertySpec];
285    fn set_property(&mut self, name: &str, value: PropValue) -> Result<(), PropError>;
286    fn get_property(&self, name: &str) -> Option<PropValue>;
287
288    /// Dyn-safe mirror of [`MultiOutputElement::set_instance_name`], so the
289    /// runner can name an erased demux instance for logging.
290    fn set_instance_name(&mut self, _name: alloc::string::String) {}
291
292    /// Dyn-safe mirror of [`MultiOutputElement::set_log_category`].
293    fn set_log_category(&mut self, _category: alloc::string::String) {}
294
295    /// Consume this element into its graph-runner demux arm (M1009), the
296    /// fan-out analog of
297    /// [`DynAsyncElement::drive_transform_arm`](crate::element::DynAsyncElement::drive_transform_arm).
298    /// The blanket impl monomorphizes the arm over the concrete element type,
299    /// so the per-packet `process` future is unboxed. Implementations outside
300    /// the blanket cannot build the runner's [`DemuxArmIo`]; implement
301    /// [`MultiOutputElement`] instead.
302    #[doc(hidden)]
303    fn drive_demux_arm<'s>(self: Box<Self>, io: DemuxArmIo) -> BoxFuture<'s, Result<u64, G2gError>>
304    where
305        Self: 's;
306}
307
308impl<T: MultiOutputElement> DynMultiOutputElement for T {
309    fn caps_constraint_as_input(&self) -> CapsConstraint<'_> {
310        MultiOutputElement::caps_constraint_as_input(self)
311    }
312
313    fn input_domains(&self) -> DomainSet {
314        MultiOutputElement::input_domains(self)
315    }
316
317    fn port_output_caps(&self, port: usize) -> Option<Caps> {
318        MultiOutputElement::port_output_caps(self, port)
319    }
320
321    fn configure_pipeline(&mut self, absolute_caps: &Caps) -> Result<ConfigureOutcome, G2gError> {
322        MultiOutputElement::configure_pipeline(self, absolute_caps)
323    }
324
325    fn process<'a>(
326        &'a mut self,
327        packet: PipelinePacket,
328        out: &'a mut dyn MultiOutputSink,
329    ) -> BoxFuture<'a, Result<(), G2gError>> {
330        Box::pin(MultiOutputElement::process(self, packet, out))
331    }
332
333    fn properties(&self) -> &'static [PropertySpec] {
334        MultiOutputElement::properties(self)
335    }
336
337    fn set_property(&mut self, name: &str, value: PropValue) -> Result<(), PropError> {
338        MultiOutputElement::set_property(self, name, value)
339    }
340
341    fn get_property(&self, name: &str) -> Option<PropValue> {
342        MultiOutputElement::get_property(self, name)
343    }
344
345    fn set_instance_name(&mut self, name: alloc::string::String) {
346        MultiOutputElement::set_instance_name(self, name)
347    }
348
349    fn set_log_category(&mut self, category: alloc::string::String) {
350        MultiOutputElement::set_log_category(self, category)
351    }
352
353    fn drive_demux_arm<'s>(self: Box<Self>, io: DemuxArmIo) -> BoxFuture<'s, Result<u64, G2gError>>
354    where
355        Self: 's,
356    {
357        Box::pin(demux_arm(*self, io))
358    }
359}
360
361/// Private [`MultiOutputElement`] face over an erased demux, so the generic
362/// (monomorphized) arm can drive a `&mut dyn DynMultiOutputElement` graph node
363/// too. Its per-packet process future stays boxed (the element underneath is
364/// erased). The `DynRef` shape, for the fan-out trait.
365struct DemuxRef<'b>(&'b mut (dyn DynMultiOutputElement + 'b));
366
367impl MultiOutputElement for DemuxRef<'_> {
368    type ProcessFuture<'a>
369        = BoxFuture<'a, Result<(), G2gError>>
370    where
371        Self: 'a;
372
373    /// Only reachable by a direct call: the arm drives `process`, and
374    /// `caps_constraint_as_input` below forwards the erased element's own
375    /// constraint rather than routing through here.
376    fn intercept_caps(&self, upstream_caps: &Caps) -> Result<Caps, G2gError> {
377        let upstream = CapsConstraint::LegacySource(upstream_caps.clone());
378        let own = self.0.caps_constraint_as_input();
379        solve_linear(&[&upstream, &own])
380            .map_err(|_| G2gError::CapsMismatch)?
381            .last()
382            .cloned()
383            .ok_or(G2gError::CapsMismatch)
384    }
385
386    fn caps_constraint_as_input(&self) -> CapsConstraint<'_> {
387        self.0.caps_constraint_as_input()
388    }
389
390    fn configure_pipeline(&mut self, absolute_caps: &Caps) -> Result<ConfigureOutcome, G2gError> {
391        self.0.configure_pipeline(absolute_caps)
392    }
393
394    fn process<'a>(
395        &'a mut self,
396        packet: PipelinePacket,
397        out: &'a mut dyn MultiOutputSink,
398    ) -> Self::ProcessFuture<'a> {
399        self.0.process(packet, out)
400    }
401
402    fn input_domains(&self) -> DomainSet {
403        self.0.input_domains()
404    }
405
406    fn port_output_caps(&self, port: usize) -> Option<Caps> {
407        self.0.port_output_caps(port)
408    }
409
410    fn properties(&self) -> &'static [PropertySpec] {
411        self.0.properties()
412    }
413
414    fn set_property(&mut self, name: &str, value: PropValue) -> Result<(), PropError> {
415        self.0.set_property(name, value)
416    }
417
418    fn get_property(&self, name: &str) -> Option<PropValue> {
419        self.0.get_property(name)
420    }
421
422    fn set_instance_name(&mut self, name: alloc::string::String) {
423        self.0.set_instance_name(name)
424    }
425
426    fn set_log_category(&mut self, category: alloc::string::String) {
427        self.0.set_log_category(category)
428    }
429}
430
431/// Forwarding impl so a borrowed `&mut dyn DynMultiOutputElement` can be boxed
432/// into a `Box<dyn DynMultiOutputElement + 'a>` graph node (the borrowing-graph
433/// convenience wrappers). Disjoint from the `MultiOutputElement` blanket above.
434impl<'b> DynMultiOutputElement for &'b mut (dyn DynMultiOutputElement + 'b) {
435    fn caps_constraint_as_input(&self) -> CapsConstraint<'_> {
436        (**self).caps_constraint_as_input()
437    }
438
439    fn input_domains(&self) -> DomainSet {
440        (**self).input_domains()
441    }
442
443    fn port_output_caps(&self, port: usize) -> Option<Caps> {
444        (**self).port_output_caps(port)
445    }
446
447    fn configure_pipeline(&mut self, absolute_caps: &Caps) -> Result<ConfigureOutcome, G2gError> {
448        (**self).configure_pipeline(absolute_caps)
449    }
450
451    fn process<'a>(
452        &'a mut self,
453        packet: PipelinePacket,
454        out: &'a mut dyn MultiOutputSink,
455    ) -> BoxFuture<'a, Result<(), G2gError>> {
456        (**self).process(packet, out)
457    }
458
459    fn properties(&self) -> &'static [PropertySpec] {
460        (**self).properties()
461    }
462
463    fn set_property(&mut self, name: &str, value: PropValue) -> Result<(), PropError> {
464        (**self).set_property(name, value)
465    }
466
467    fn get_property(&self, name: &str) -> Option<PropValue> {
468        (**self).get_property(name)
469    }
470
471    fn set_instance_name(&mut self, name: alloc::string::String) {
472        (**self).set_instance_name(name)
473    }
474
475    fn set_log_category(&mut self, category: alloc::string::String) {
476        (**self).set_log_category(category)
477    }
478
479    fn drive_demux_arm<'s>(self: Box<Self>, io: DemuxArmIo) -> BoxFuture<'s, Result<u64, G2gError>>
480    where
481        Self: 's,
482    {
483        Box::pin(demux_arm(DemuxRef(*self), io))
484    }
485}
486
487/// A β allocation re-cascade report from an arm to the [`GraphCoordinator`].
488/// A sink reports the proposal it re-derived on a mid-stream `CapsChanged`; an
489/// interior transform reports the proposal it re-derived after applying an
490/// upstream directive; a muxer reports the per-pad demands and the merged-output
491/// pool it re-derived while walking a change through itself.
492#[derive(Debug, Clone)]
493struct Recascade {
494    node: NodeId,
495    route: RecascadeRoute,
496    proposal: Option<AllocationParams>,
497}
498
499/// Where the coordinator sends a [`Recascade`]'s proposal.
500#[derive(Debug, Clone, Copy)]
501enum RecascadeRoute {
502    /// One hop upstream through the graph topology, to every arm feeding the
503    /// reporting node (the ordinary single-input transform / sink path).
504    Upstream,
505    /// One hop upstream to exactly this arm. A muxer has many inputs, so a change
506    /// on one pad names the arm feeding *that* branch rather than re-cascading to
507    /// all of them (which the node-keyed `upstream_arms` lookup would do).
508    Pad(NodeId),
509    /// Downstream to this consumer arm, as an
510    /// [`ArmDirective::ProducerAllocation`]: the pool a muxer settled on for its
511    /// merged output, told to the element that reads it (M839).
512    Consumer(NodeId),
513}
514
515/// Producer end of the graph coordinator's control channel, cloned to each
516/// transform and sink arm so it can report a [`Recascade`].
517#[derive(Debug, Clone)]
518pub(crate) struct GraphCoordHandle {
519    tx: Sender<Recascade>,
520}
521
522impl GraphCoordHandle {
523    async fn report(&self, event: Recascade) {
524        let _ = self.tx.send(event).await;
525    }
526}
527
528/// Node-keyed β coordinator for the DAG (the DAG analog of the linear
529/// [`Coordinator`](crate::runtime::coordinator::Coordinator)). It owns one
530/// [`ArmDirective`] sender per interruptible arm (transforms, sinks, muxers),
531/// keyed by node id, plus `upstream_arms`: for each reporting node, the nearest
532/// arm feeding each of its inputs (resolved through structural tee nodes; a
533/// source terminates the walk). On each report it forwards an
534/// `ArmDirective::Recascade` one hop upstream to those arms, which re-derive and
535/// report again, so the cascade walks the DAG without a global lock. A report's
536/// [`RecascadeRoute`] overrides that node-keyed walk: `Pad` names one branch of a
537/// muxer, `Consumer` pushes the muxer's settled output pool downstream instead.
538/// The walk is reactive and non-blocking (`try_send`), so it never wedges the
539/// data plane.
540#[derive(Debug)]
541struct GraphCoordinator {
542    rx: Receiver<Recascade>,
543    arm_ctrl: Vec<Option<Sender<ArmDirective>>>,
544    upstream_arms: Vec<Vec<NodeId>>,
545}
546
547impl GraphCoordinator {
548    fn send_to(&self, node: NodeId, directive: ArmDirective) {
549        if let Some(ctrl) = &self.arm_ctrl[node.0 as usize] {
550            let _ = ctrl.try_send(directive);
551        }
552    }
553
554    async fn run(self) -> u64 {
555        let mut observed = 0u64;
556        while let Some(event) = self.rx.recv().await {
557            observed += 1;
558            if let Some(p) = event.proposal {
559                match event.route {
560                    RecascadeRoute::Pad(t) => self.send_to(t, ArmDirective::Recascade(p)),
561                    RecascadeRoute::Consumer(t) => {
562                        self.send_to(t, ArmDirective::ProducerAllocation(p))
563                    }
564                    RecascadeRoute::Upstream => {
565                        for &u in &self.upstream_arms[event.node.0 as usize] {
566                            self.send_to(u, ArmDirective::Recascade(p));
567                        }
568                    }
569                }
570            }
571        }
572        observed
573    }
574}
575
576/// The nearest upstream interruptible arm feeding `edge_id`: a transform is an
577/// arm; a muxer is one too (M839: it continues the walk per input pad); a
578/// structural tee is skipped to its own single input; a source terminates the β
579/// walk (it is not interruptible).
580fn nearest_upstream_arm<E>(vg: &ValidatedGraph<E>, edge_id: usize) -> Option<NodeId> {
581    let src = vg.edge(edge_id).src.node;
582    match vg.kind(src) {
583        NodeKind::Transform | NodeKind::Muxer(_) => Some(src),
584        NodeKind::Tee(_) => nearest_upstream_arm(vg, vg.in_edges(src)[0]),
585        _ => None,
586    }
587}
588
589/// The arms that read `edge_id`, so a muxer can tell its consumers the pool it
590/// settled on for the merged output (M839). A transform / sink is such an arm; a
591/// structural tee broadcasts, so every branch is one. The walk stops at another
592/// multi-input boundary: the notice concerns one specific input pad, which this
593/// downstream-facing route cannot name.
594fn consumer_arms<E>(vg: &ValidatedGraph<E>, edge_id: usize, out: &mut Vec<NodeId>) {
595    let dst = vg.edge(edge_id).dst.node;
596    match vg.kind(dst) {
597        NodeKind::Transform | NodeKind::Sink => {
598            if !out.contains(&dst) {
599                out.push(dst);
600            }
601        }
602        NodeKind::Tee(_) => {
603            for &oe in vg.out_edges(dst) {
604                consumer_arms(vg, oe, out);
605            }
606        }
607        _ => {}
608    }
609}
610
611/// Build a muxer arm's β state from the solved graph (M839): one slot per input
612/// edge (in `in_edges` order, so it aligns with the arm's `pad_rxs`) carrying that
613/// pad's negotiated caps and the arm feeding its branch, plus the arms reading the
614/// merged output.
615fn mux_beta<E>(
616    vg: &ValidatedGraph<E>,
617    node: NodeId,
618    in_e: &[usize],
619    out_e: usize,
620    solution: &[Caps],
621    coord: GraphCoordHandle,
622) -> MuxBeta {
623    let pads = in_e
624        .iter()
625        .map(|&eid| MuxPad {
626            pad: vg.edge(eid).dst.index as usize,
627            upstream: nearest_upstream_arm(vg, eid),
628            caps: solution[eid].clone(),
629            alloc: None,
630        })
631        .collect();
632    let mut consumers = Vec::new();
633    consumer_arms(vg, out_e, &mut consumers);
634    MuxBeta {
635        node,
636        coord,
637        pads,
638        consumers,
639        out_alloc: None,
640        rounds: 0,
641    }
642}
643
644/// The fan-out policy of the nearest tee on the path from `node` up toward a
645/// source, or `None` if `node` is on a single-producer chain. A node behind a
646/// tee shares its upstream with sibling branches, so it can't reverse-
647/// reconfigure on a rejected mid-stream change: under `FailLoud` it fails the
648/// run (the `run_source_fanout` strict default), under `AllowBranchDrop` it
649/// drops out. A node on a single-producer chain (`run_linear_chain` /
650/// `run_source_transform_sink`) forwards a feasible re-solve and keeps flowing,
651/// but a genuinely infeasible one fails loud too: no runtime producer
652/// renegotiates its output caps, so there is nothing to reverse-reconfigure into.
653fn behind_tee_policy<E>(vg: &ValidatedGraph<E>, node: NodeId) -> Option<FanOutPolicy> {
654    let mut cur = node;
655    loop {
656        let ins = vg.in_edges(cur);
657        if ins.is_empty() {
658            return None;
659        }
660        let src = vg.edge(ins[0]).src.node;
661        if matches!(vg.kind(src), NodeKind::Tee(_)) {
662            return Some(vg.fanout_policy(src));
663        }
664        cur = src;
665    }
666}
667
668/// How a branch arm reacts to a mid-stream `CapsChanged` it cannot negotiate,
669/// derived from its position ([`behind_tee_policy`]).
670#[derive(Clone, Copy, PartialEq, Eq)]
671pub(crate) enum BranchMode {
672    /// Single-producer chain: a feasible mid-stream re-solve is forwarded and
673    /// the chain keeps flowing; a genuinely infeasible one fails the run loud,
674    /// since no runtime producer renegotiates its output caps.
675    Reconfigure,
676    /// Behind a `FailLoud` tee: a rejected change fails the whole run loud.
677    FailLoud,
678    /// Behind an `AllowBranchDrop` tee: a rejected change drops this branch (its
679    /// arm ends Ok) while the siblings keep flowing.
680    Drop,
681}
682
683fn branch_mode<E>(vg: &ValidatedGraph<E>, node: NodeId) -> BranchMode {
684    match behind_tee_policy(vg, node) {
685        None => BranchMode::Reconfigure,
686        Some(FanOutPolicy::FailLoud) => BranchMode::FailLoud,
687        Some(FanOutPolicy::AllowBranchDrop) => BranchMode::Drop,
688    }
689}
690
691/// Name the caps an arm could not re-solve at runtime, at error level on the
692/// caps category (the same channel the negotiation solver narrates a conflict
693/// on), so the run's `CapsMismatch` is diagnosable rather than opaque. The
694/// common cause is a rate / format the chain has no converter for (e.g. a
695/// 44.1 kHz decode reaching a `rate=48000` pin with no `audioresample` to bridge
696/// it). A tee branch cannot reverse-reconfigure its shared upstream; a
697/// single-producer chain has nothing to reverse-reconfigure into either, since
698/// no runtime producer renegotiates its output caps. Either way the refinement
699/// has no solution, so the run fails here rather than flowing stale caps.
700fn report_runtime_caps_conflict(rejected: &Caps) {
701    crate::g2g_error!(
702        crate::log::Target::category(crate::log::CAPS_CATEGORY),
703        "runtime caps {} cannot be re-solved against the downstream chain (no converter bridges the refinement, and no upstream element renegotiates its output caps)",
704        rejected.to_gst_string()
705    );
706}
707
708/// Resolve every node's animated-property program (M882) against the element it
709/// targets, before negotiation and before any frame flows: an unknown property, a
710/// non-numeric one, an empty curve, or a node whose arm has no per-frame hook
711/// fails the run here rather than animating nothing. Returns the resolved
712/// controllers indexed by node id, for the arm loop to take.
713fn resolve_controllers(
714    vg: &mut ValidatedGraph<GraphNodeRef<'_>>,
715    topo: &[NodeId],
716) -> Result<Vec<Option<ArmController>>, G2gError> {
717    let mut resolved: Vec<Option<ArmController>> = (0..vg.node_count()).map(|_| None).collect();
718    for &node in topo {
719        let Some(program) = vg.take_node_control(node) else {
720            continue;
721        };
722        if program.is_empty() {
723            continue;
724        }
725        let name = vg.node_name(node).unwrap_or("<unnamed>");
726        let kind = vg.kind(node);
727        // Only an arm that hands packets to its element one at a time can sample
728        // between frames. A source drives itself (it has no per-frame runner
729        // hook), and a tee carries no element at all.
730        if !matches!(
731            kind,
732            NodeKind::Transform | NodeKind::Sink | NodeKind::Muxer(_)
733        ) {
734            crate::g2g_error!(
735                crate::log::Target::category(CONTROL_CATEGORY),
736                "node {} ({name}) is a {kind:?} and cannot carry animated properties (transform, sink, and fan-in nodes can)",
737                node.0
738            );
739            return Err(G2gError::ControlBinding);
740        }
741        let specs = vg.element(node).map(|e| e.properties()).unwrap_or(&[]);
742        match program.resolve(specs) {
743            Ok(controller) => resolved[node.0 as usize] = Some(controller),
744            Err(fault) => {
745                crate::g2g_error!(
746                    crate::log::Target::category(CONTROL_CATEGORY),
747                    "node {} ({name}): {fault}",
748                    node.0
749                );
750                return Err(G2gError::ControlBinding);
751            }
752        }
753    }
754    Ok(resolved)
755}
756
757/// Sample a controlled element's animated properties at `pts_ns` and set them,
758/// before the element sees the frame that carries that timestamp (M882). A
759/// rejected value fails the run loud, naming the element and the property.
760fn apply_control_at<T: ControlTarget + ?Sized>(
761    control: Option<&ArmController>,
762    target: &mut T,
763    pts_ns: u64,
764    probe: &Probe,
765) -> Result<(), G2gError> {
766    let Some(controller) = control else {
767        return Ok(());
768    };
769    controller.apply(target, pts_ns).map_err(|fault| {
770        let element = probe.as_deref().map(|p| p.name()).unwrap_or("element");
771        crate::g2g_error!(
772            crate::log::Target::category(CONTROL_CATEGORY),
773            "{element} at pts {pts_ns}: {fault}"
774        );
775        G2gError::ControlBinding
776    })
777}
778
779/// [`apply_control_at`] for a packet: a `DataFrame`'s PTS is the sampling time,
780/// and a control packet (caps / segment / flush / tick) carries none, so it
781/// samples nothing.
782fn apply_control<T: ControlTarget + ?Sized>(
783    control: Option<&ArmController>,
784    target: &mut T,
785    packet: &PipelinePacket,
786    probe: &Probe,
787) -> Result<(), G2gError> {
788    match packet {
789        PipelinePacket::DataFrame(frame) => {
790            apply_control_at(control, target, frame.timing.pts_ns, probe)
791        }
792        _ => Ok(()),
793    }
794}
795
796/// A reusable recipe for an owned graph: a builder closure that produces a fresh
797/// [`Graph<GraphNode>`](Graph) each time it is [`instantiate`](Self::instantiate)d.
798///
799/// [`run_graph`] consumes the elements it runs (it `take()`s the boxed payloads
800/// out of the graph), so a graph cannot be run twice. Seek-and-replay (re-run
801/// from the start after a flushing seek), retry-on-error, and A/B benchmarking
802/// all need a *fresh* set of elements per run, because real elements carry state
803/// (a decoder's reference frames, a source's file offset) that cannot simply be
804/// rewound. A template rebuilds them via the closure rather than cloning, which
805/// is cleaner than making `Graph` itself reusable: that would force every element
806/// to be `Clone` or re-initialisable in place, a contract the element traits
807/// deliberately do not impose.
808pub struct GraphTemplate {
809    build: Box<dyn Fn() -> Graph<GraphNode> + Send + Sync>,
810}
811
812impl GraphTemplate {
813    /// Wrap a graph-builder closure. The closure must construct the whole graph
814    /// (nodes + links) from scratch on each call, so each instance gets its own
815    /// elements.
816    pub fn new(build: impl Fn() -> Graph<GraphNode> + Send + Sync + 'static) -> Self {
817        Self {
818            build: Box::new(build),
819        }
820    }
821
822    /// Build a fresh runnable graph. Call once per [`run_graph`] invocation.
823    pub fn instantiate(&self) -> Graph<GraphNode> {
824        (self.build)()
825    }
826}
827
828impl core::fmt::Debug for GraphTemplate {
829    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
830        f.debug_struct("GraphTemplate").finish_non_exhaustive()
831    }
832}
833
834/// Drive an arbitrary DAG to EOS. Negotiates the whole graph at once, then runs
835/// one arm per node over per-edge channels. `link_capacity` accepts a
836/// [`LatencyProfile`](crate::runtime::LatencyProfile) or a `usize` depth.
837///
838/// Reports the M12 stats (latency / clock / allocation) folded over the graph,
839/// the same as the linear runners. The graph payload may own its elements
840/// ([`GraphNode`]) or borrow them ([`GraphNodeRef<'a>`], what the convenience
841/// wrappers build). A non-`System` frame in a tee or a negotiation conflict
842/// fails loud.
843///
844/// When `clock` can sleep on a deadline
845/// ([`PipelineClock::as_ticker`](crate::PipelineClock::as_ticker), which every
846/// [`AsyncClock`](crate::AsyncClock) answers), each fan-in arm also gets a
847/// **deadline tick** (M880): a fan-in element declaring a
848/// [`tick_interval_ns`](crate::MultiInputElement::tick_interval_ns) receives
849/// [`PipelinePacket::Tick`] on that period even while its inputs are silent, so a
850/// compositor can hold its output rate over a stalled pad (zero-order-hold on
851/// that pad's last frame) instead of freezing with it. A clock that only tells
852/// time, or an element that declares no interval, runs untimed.
853pub async fn run_graph<'a, Clk: PipelineClock>(
854    graph: Graph<GraphNodeRef<'a>>,
855    clock: &Clk,
856    link_capacity: impl Into<LinkCapacity>,
857) -> Result<RunStats, G2gError> {
858    run_graph_inner(
859        graph,
860        clock,
861        link_capacity,
862        None,
863        None,
864        None,
865        None,
866        None,
867        None,
868        None,
869    )
870    .await
871}
872
873/// As [`run_graph`], but enforces a memory-domain [`CopyPolicy`](crate::copyplan::CopyPolicy)
874/// as a graph-level contract (M617). After negotiation resolves every link's memory
875/// domain, the copy plan (`crate::copyplan`) is checked against `policy` *before any
876/// frame flows*: a pipeline that must stay zero-copy
877/// ([`CopyPolicy::DenyAll`](crate::copyplan::CopyPolicy::DenyAll)) refuses to start
878/// with [`G2gError::CopyBudget`] if an accidental host round-trip appears, rather than
879/// paying it at runtime. This turns "is this pipeline zero-copy?" from a question
880/// measured after the fact into a guarantee checked at construction. Use
881/// [`copy_plan`] on the same graph for the offending-transfer detail.
882pub async fn run_graph_with_copy_policy<'a, Clk: PipelineClock>(
883    graph: Graph<GraphNodeRef<'a>>,
884    clock: &Clk,
885    link_capacity: impl Into<LinkCapacity>,
886    policy: crate::copyplan::CopyPolicy,
887) -> Result<RunStats, G2gError> {
888    run_graph_inner(
889        graph,
890        clock,
891        link_capacity,
892        None,
893        None,
894        None,
895        Some(policy),
896        None,
897        None,
898        None,
899    )
900    .await
901}
902
903/// Splice memory-domain converters where a producer and consumer cannot agree on
904/// a domain (M354), the structural complement to the M351/M352 in-band domain
905/// negotiation: negotiation settles a *shared* domain when one exists, and this
906/// inserts a converter when one does not. For each original edge `P -> C`, the
907/// producer domain is traced through structural tee/demux nodes back to the real
908/// producer ([`output_domains`](GraphNodeRef::output_domains)); if it shares no
909/// domain with `C`'s [`input_domains`](GraphNodeRef::input_domains), `factory` is
910/// asked for a converter from the producer's preferred domain to one `C` accepts,
911/// and it is spliced onto that edge ([`Graph::insert_on_edge`]).
912///
913/// Converters are caps-transparent (`Identity`), so the subsequent caps solve is
914/// unaffected. `factory` returns `None` when it has no converter for a pair, in
915/// which case the edge is left as-is and the conflict surfaces later as an
916/// [`AllocationConflict`](G2gError::AllocationConflict) (the loud failure M351
917/// already gives). The converter elements live in `g2g-plugins`, so the caller
918/// supplies the factory (the crate layering keeps `g2g-core` converter-agnostic);
919/// `g2g-plugins` provides the CUDA wrapper.
920pub fn auto_plug_domain_converters<'a>(
921    mut graph: Graph<GraphNodeRef<'a>>,
922    factory: &dyn Fn(MemoryDomainKind, MemoryDomainKind) -> Option<GraphNodeRef<'a>>,
923) -> Graph<GraphNodeRef<'a>> {
924    // Snapshot the original edge count: splicing appends nodes and a `K -> C`
925    // edge and only rewires the edge being spliced, so original ids stay valid.
926    let original_edges = graph.edges().len();
927    for e in 0..original_edges {
928        let edge = graph.edges()[e];
929        let producer = traced_output_domains(&graph, edge.src.node);
930        let consumer = graph
931            .element(edge.dst.node)
932            .map(|n| n.input_domains())
933            .unwrap_or(DomainSet::ALL);
934        let (Some(from), Some(to)) = (producer.preferred(), consumer.preferred()) else {
935            continue;
936        };
937        // Nothing to do when the two ends already agree on a domain, unless the
938        // only thing they agree on is system memory while both would rather stay
939        // on the GPU.
940        if !producer.intersect(consumer).is_empty() && !bridges_two_gpus(from, to) {
941            continue;
942        }
943        if let Some(conv) = factory(from, to) {
944            graph.insert_on_edge(e, conv);
945        }
946    }
947    graph
948}
949
950/// Whether a converter beats the domain the two ends would otherwise agree on
951/// (M1017): both prefer to keep the frame on the GPU, but in different GPU
952/// domains, so the only domain they share is system memory. Bridging the two GPU
953/// domains costs a device-to-device copy where agreeing on system memory costs a
954/// download and an upload, so the bridge wins whenever the factory has one (a
955/// CUDA decoder feeding a wgpu display sink is the case this exists for). When
956/// either end prefers system memory, the shared domain is what it asked for and
957/// nothing is spliced.
958fn bridges_two_gpus(from: MemoryDomainKind, to: MemoryDomainKind) -> bool {
959    from != to && !from.is_system() && !to.is_system()
960}
961
962/// Domains the node emits, tracing through structural tee/demux nodes (which
963/// forward their single input's domain) to the real producer. Used by the
964/// converter auto-plug so a tee fed by a GPU decoder reports the GPU domain on
965/// every branch rather than the structural `System` default.
966fn traced_output_domains<'a>(graph: &Graph<GraphNodeRef<'a>>, node: NodeId) -> DomainSet {
967    if let Some(NodeKind::Tee(_)) = graph.node_kind(node) {
968        // A tee/demux is domain-transparent: trace its single input's producer.
969        return match graph.edges().iter().find(|e| e.dst.node == node) {
970            Some(in_edge) => traced_output_domains(graph, in_edge.src.node),
971            None => DomainSet::only(MemoryDomainKind::System),
972        };
973    }
974    graph
975        .element(node)
976        .map(|n| n.output_domains())
977        .unwrap_or(DomainSet::only(MemoryDomainKind::System))
978}
979
980/// As [`run_graph`], but posts pipeline [`BusMessage`](crate::BusMessage)s to
981/// `bus`: a startup `NegotiationFailed`, per transform / sink a `Buffering`
982/// level report each time that element's input link crosses a fill quartile
983/// (M87, interior links M843), and a `DurationChanged` when a source first
984/// reports its duration (M203), so the app can show a buffering indicator, wait
985/// for a full buffer, or size a seek bar.
986pub async fn run_graph_with_bus<'a, Clk: PipelineClock>(
987    graph: Graph<GraphNodeRef<'a>>,
988    clock: &Clk,
989    link_capacity: impl Into<LinkCapacity>,
990    bus: &BusHandle,
991) -> Result<RunStats, G2gError> {
992    run_graph_inner(
993        graph,
994        clock,
995        link_capacity,
996        Some(bus),
997        None,
998        None,
999        None,
1000        None,
1001        None,
1002        None,
1003    )
1004    .await
1005}
1006
1007/// As [`run_graph`], but taps live telemetry into `observer` and (optionally)
1008/// posts events to `bus`, the pairing a dev dashboard consumes: the observer
1009/// carries the graph topology plus per-element `process()` latency / input-link
1010/// fill, readable mid-run via [`Observer::snapshot`](crate::runtime::Observer::snapshot)
1011/// from a concurrent task, while the bus carries the out-of-band events (caps
1012/// changes surface as `Info`/`NegotiationFailed`, plus `Buffering` / `Qos` /
1013/// `Eos` / `Error`). Pass `bus: None` for telemetry only.
1014pub async fn run_graph_observed<'a, Clk: PipelineClock>(
1015    graph: Graph<GraphNodeRef<'a>>,
1016    clock: &Clk,
1017    link_capacity: impl Into<LinkCapacity>,
1018    observer: &Observer,
1019    bus: Option<&BusHandle>,
1020) -> Result<RunStats, G2gError> {
1021    run_graph_inner(
1022        graph,
1023        clock,
1024        link_capacity,
1025        bus,
1026        None,
1027        None,
1028        None,
1029        Some(observer),
1030        None,
1031        None,
1032    )
1033    .await
1034}
1035
1036/// As [`run_graph`], but publishes playback progress into `progress` (M203): the
1037/// sink arm publishes the stream-time [`position`](PipelineProgress::position) of
1038/// every buffer it consumes, and the source arm publishes the
1039/// [`duration`](PipelineProgress::duration) its source reports
1040/// ([`SourceLoop::query_duration`]). The application polls the handle while the
1041/// pipeline runs, the `POSITION` / `DURATION` query analog.
1042///
1043/// `bus` carries the out-of-band messages of [`run_graph_with_bus`] (the
1044/// matching `DurationChanged` push notification, and the `ElementError` naming
1045/// what ended the run); pass `None` for progress alone. A caller that reacts to a
1046/// failure by re-plugging the graph needs both, since the decision is "which
1047/// element failed" plus "had anything been presented yet".
1048pub async fn run_graph_with_progress<'a, Clk: PipelineClock>(
1049    graph: Graph<GraphNodeRef<'a>>,
1050    clock: &Clk,
1051    link_capacity: impl Into<LinkCapacity>,
1052    progress: &PipelineProgress,
1053    bus: Option<&BusHandle>,
1054) -> Result<RunStats, G2gError> {
1055    run_graph_inner(
1056        graph,
1057        clock,
1058        link_capacity,
1059        bus,
1060        None,
1061        Some(progress),
1062        None,
1063        None,
1064        None,
1065        None,
1066    )
1067    .await
1068}
1069
1070/// Coarsen a link fill percent into a 0..=4 quartile band, so an arm posts a
1071/// `Buffering` message on a meaningful level transition (underrun, quarter
1072/// steps, full) rather than on every packet.
1073fn buffering_bucket(percent: u8) -> u8 {
1074    (percent / 25).min(4)
1075}
1076
1077/// Sample an arm's input-link fill and post a `Buffering` level when it crosses
1078/// a quartile band, naming the element the link feeds so an application can tell
1079/// an interior link from the one feeding a sink. `last` carries the band across
1080/// loop iterations.
1081fn report_buffering(
1082    bus: Option<&BusHandle>,
1083    probe: &Probe,
1084    in_rx: &LinkReceiver,
1085    last: &mut Option<u8>,
1086) {
1087    let Some(b) = bus else { return };
1088    let pct = in_rx.fill_percent();
1089    let bucket = buffering_bucket(pct);
1090    if *last != Some(bucket) {
1091        *last = Some(bucket);
1092        b.try_post(BusMessage::Buffering {
1093            percent: pct,
1094            element: probe
1095                .as_deref()
1096                .map(|p| alloc::string::String::from(p.name())),
1097        });
1098    }
1099}
1100
1101/// As [`run_graph`], but driven by a [`StateController`] (M78): every `Sink`
1102/// arm gates on the controller, so the whole DAG (linear / fan-out / fan-in /
1103/// diamond) honors `NULL → READY → PAUSED → PLAYING`. Preroll aggregates: the
1104/// async `Paused` transition completes only when *all* sinks have prerolled
1105/// (the runner calls [`StateController::expect_prerolls`] with the sink count).
1106pub async fn run_graph_stateful<'a, Clk: PipelineClock>(
1107    graph: Graph<GraphNodeRef<'a>>,
1108    clock: &Clk,
1109    link_capacity: impl Into<LinkCapacity>,
1110    state: &StateController,
1111) -> Result<RunStats, G2gError> {
1112    run_graph_inner(
1113        graph,
1114        clock,
1115        link_capacity,
1116        None,
1117        Some(state.clone()),
1118        None,
1119        None,
1120        None,
1121        None,
1122        None,
1123    )
1124    .await
1125}
1126
1127/// As [`run_graph_with_progress`], but with a [`FlightRecorder`] attached
1128/// (M1016): every edge keeps a bounded ring of its most recent packets while the
1129/// run goes, and on failure the caller writes them out with
1130/// [`FlightRecorder::dump_to_dir`] as one replayable recording per edge. An hour
1131/// of live streaming that ends in an error hands back the last moments of
1132/// traffic instead of nothing.
1133///
1134/// `progress` is optional (the recorder does not need it); pass `None` for the
1135/// plain [`run_graph`] behavior plus recording.
1136pub async fn run_graph_recorded<'a, Clk: PipelineClock>(
1137    graph: Graph<GraphNodeRef<'a>>,
1138    clock: &Clk,
1139    link_capacity: impl Into<LinkCapacity>,
1140    progress: Option<&PipelineProgress>,
1141    bus: Option<&BusHandle>,
1142    recorder: &FlightRecorder,
1143) -> Result<RunStats, G2gError> {
1144    run_graph_inner(
1145        graph,
1146        clock,
1147        link_capacity,
1148        bus,
1149        None,
1150        progress,
1151        None,
1152        None,
1153        Some(recorder),
1154        None,
1155    )
1156    .await
1157}
1158
1159/// [`run_graph_observed`] plus the [`run_graph_recorded`] flight recorder, for a
1160/// tool that watches a run live and still wants the last packets when it fails.
1161pub async fn run_graph_observed_recorded<'a, Clk: PipelineClock>(
1162    graph: Graph<GraphNodeRef<'a>>,
1163    clock: &Clk,
1164    link_capacity: impl Into<LinkCapacity>,
1165    observer: &Observer,
1166    bus: Option<&BusHandle>,
1167    recorder: &FlightRecorder,
1168) -> Result<RunStats, G2gError> {
1169    run_graph_inner(
1170        graph,
1171        clock,
1172        link_capacity,
1173        bus,
1174        None,
1175        None,
1176        None,
1177        Some(observer),
1178        Some(recorder),
1179        None,
1180    )
1181    .await
1182}
1183
1184/// As [`run_graph`], but posts a structured
1185/// [`BusMessage::NegotiationFailed`](crate::BusMessage::NegotiationFailed) to
1186/// `bus` on a startup negotiation failure. The convenience wrappers' `_with_bus`
1187/// variants build a borrowing `Graph` and route through here; `run_graph` passes
1188/// `None`.
1189/// Result of negotiating and configuring a graph, shared by the cooperative and
1190/// thread-per-arm drivers: the DAG-wide M12 folds (latency / clock / allocation)
1191/// plus the solved per-edge caps, computed once before the arms take the elements.
1192#[derive(Debug)]
1193struct Prepared {
1194    solution: Vec<Caps>,
1195    feasibility: Vec<Option<CapsSet>>,
1196    latency: LatencyReport,
1197    allocation: Option<AllocationParams>,
1198    clock_priority: ClockPriority,
1199    base_time_ns: u64,
1200    /// Every clock the elements offered, kept so the health monitor can re-elect
1201    /// over the survivors. Empty when no monitor runs.
1202    clock_candidates: Vec<ClockCandidate>,
1203    /// The swappable handle the sinks' [`ClockSync`] points at, so a re-election
1204    /// retargets them. `Some` exactly when the monitor runs.
1205    elected_clock: Option<alloc::sync::Arc<ElectedClock>>,
1206    /// Every node's instance name, indexed by `NodeId` (empty for a structural
1207    /// tee, which carries no element). The arm loop copies these into arm order
1208    /// so a failed run can name the element that raised the error.
1209    names: Vec<alloc::string::String>,
1210}
1211
1212/// Phases 1-3.5 of the graph runner: name instances + mint probes, probe source
1213/// caps, solve + configure the whole DAG, run the allocation cascade, and elect
1214/// the clock. Mutates `vg` (configure / clock-sync / instance names) and returns
1215/// the per-node probes plus the folded [`Prepared`] stats. Shared verbatim by
1216/// [`run_graph_inner`] (cooperative) and [`run_graph_threaded`] (thread-per-arm)
1217/// so both negotiate identically and differ only in how they run the arms.
1218async fn prepare_graph<'a>(
1219    vg: &mut ValidatedGraph<GraphNodeRef<'a>>,
1220    topo: &[NodeId],
1221    state: &Option<StateController>,
1222    bus: Option<&BusHandle>,
1223    clock: &dyn PipelineClock,
1224    observer: Option<&Observer>,
1225    clock_monitor: bool,
1226) -> Result<(Vec<Probe>, Prepared), G2gError> {
1227    let n = vg.node_count();
1228    // M78: tell the controller how many sinks must preroll before the async
1229    // `Paused` transition completes (aggregated `AsyncDone`).
1230    if let Some(sc) = state {
1231        let sinks = topo
1232            .iter()
1233            .filter(|&&n| matches!(vg.kind(n), NodeKind::Sink))
1234            .count();
1235        sc.expect_prerolls(sinks);
1236    }
1237
1238    // M179: give every payload instance (source, transform, sink, muxer, demux,
1239    // session) a log name and log its addition: the launch line's `name=` when it
1240    // set one (M842), else `<category>N` per category (the GStreamer
1241    // `videotestsrc0` convention). Done before negotiation
1242    // so an element's own log lines (e.g. at `configure_pipeline`) already carry
1243    // the instance name. Naming runs whether or not a sink is installed (it is
1244    // cheap; the `g2g_info!` is threshold-gated).
1245    // M399: while naming, mint a measured-latency probe for each interior element
1246    // (Transform / Sink: the nodes with a `process()`), keyed by its instance name.
1247    let mut probes: Vec<Probe> = (0..n).map(|_| None).collect();
1248    // Per-node instance names, captured for the observer tap (empty for unnamed
1249    // structural tee / muxer nodes). Indexed by `NodeId`, like `probes`.
1250    let mut names: Vec<alloc::string::String> = alloc::vec![alloc::string::String::new(); n];
1251    {
1252        let mut namer = crate::log::InstanceNamer::new();
1253        for &node in topo {
1254            // M694: fan-in / fan-out nodes carry a `process()` too, so name and
1255            // probe them alongside transforms / sinks (a plain broadcast tee has
1256            // no element and no `process()`, so it stays unnamed / unprobed).
1257            let category = match vg.kind(node) {
1258                // A terminal fan-in carries a `Muxer` payload but is a session
1259                // sink, not a merging mux; name it accordingly. The fan-out
1260                // source is its receive-side mirror.
1261                NodeKind::FaninSink(_) => "session",
1262                NodeKind::FanoutSrc(_) => "session-src",
1263                _ => match vg.element_mut(node) {
1264                    Some(GraphNodeRef::Source(src)) => src.log_category(),
1265                    Some(GraphNodeRef::Element(elem)) => elem.log_category(),
1266                    Some(GraphNodeRef::Muxer(_)) => "mux",
1267                    Some(GraphNodeRef::FanoutSource(_)) => "session-src",
1268                    Some(GraphNodeRef::Demux(_)) => "demux",
1269                    None => continue, // plain broadcast tee: no element to name
1270                },
1271            };
1272            // M847: a launch line's `log-category=` replaces the type category for
1273            // this instance's own log lines and their filtering. Applied before
1274            // naming so the element's first lines already carry it; naming still
1275            // keys on the type category, so siblings keep counting `<type>N`.
1276            if let Some(over) = vg.node_log_category(node).map(alloc::string::String::from) {
1277                match vg.element_mut(node) {
1278                    Some(GraphNodeRef::Source(src)) => src.set_log_category(over),
1279                    Some(GraphNodeRef::Element(elem)) => elem.set_log_category(over),
1280                    Some(GraphNodeRef::Muxer(mux)) => mux.set_log_category(over),
1281                    Some(GraphNodeRef::FanoutSource(src)) => src.set_log_category(over),
1282                    Some(GraphNodeRef::Demux(demux)) => demux.set_log_category(over),
1283                    None => {}
1284                }
1285            }
1286            let name = namer.add(category, vg.node_name(node));
1287            names[node.0 as usize] = name.clone();
1288            match vg.element_mut(node) {
1289                Some(GraphNodeRef::Source(src)) => src.set_instance_name(name.clone()),
1290                Some(GraphNodeRef::Element(elem)) => elem.set_instance_name(name.clone()),
1291                Some(GraphNodeRef::Muxer(mux)) => mux.set_instance_name(name.clone()),
1292                Some(GraphNodeRef::FanoutSource(src)) => src.set_instance_name(name.clone()),
1293                Some(GraphNodeRef::Demux(demux)) => demux.set_instance_name(name.clone()),
1294                None => {}
1295            }
1296            // Mint a measured-latency probe for every node with a `process()`:
1297            // transforms, sinks, muxers, and demuxers (a demux is a `Tee`-kind
1298            // node whose payload is a `Demux` element).
1299            let has_process = matches!(
1300                vg.kind(node),
1301                NodeKind::Transform | NodeKind::Sink | NodeKind::Muxer(_) | NodeKind::FaninSink(_)
1302            ) || matches!(vg.element(node), Some(GraphNodeRef::Demux(_)));
1303            if has_process {
1304                // M851: only an observed run records per-frame journeys; the
1305                // end-of-run report needs the histograms alone.
1306                probes[node.0 as usize] = Some(if observer.is_some() {
1307                    ElementProbe::with_journeys(name)
1308                } else {
1309                    ElementProbe::new(name)
1310                });
1311            }
1312        }
1313    }
1314
1315    // Dev-tooling tap: hand the observer the topology + a clone of every probe
1316    // `Arc`, so a concurrent task can read live per-element telemetry while the
1317    // arms run. No-op (and zero cost) when no observer was supplied.
1318    if let Some(obs) = observer {
1319        let roles: Vec<crate::runtime::NodeRole> =
1320            (0..n).map(|i| vg.kind(NodeId(i as u32)).into()).collect();
1321        let edges: Vec<crate::runtime::EdgeInfo> = vg
1322            .edges()
1323            .iter()
1324            .map(|e| crate::runtime::EdgeInfo {
1325                from: e.src.node.0 as usize,
1326                to: e.dst.node.0 as usize,
1327                ..Default::default()
1328            })
1329            .collect();
1330        obs.register(names.clone(), roles, probes.clone(), edges);
1331    }
1332
1333    // Phase 1: probe each source's produce set (async) into an owned map,
1334    // releasing the mutable borrow before the constraint phase borrows every
1335    // node. A source that offers alternatives (a camera's pixel formats) hands
1336    // over all of them, so the solve picks the one downstream can take.
1337    let mut source_caps: Vec<Option<CapsSet>> = (0..n).map(|_| None).collect();
1338    for &node in topo {
1339        if matches!(vg.kind(node), NodeKind::Source) {
1340            let GraphNodeRef::Source(src) = vg.element_mut(node).ok_or(G2gError::CapsMismatch)?
1341            else {
1342                return Err(G2gError::CapsMismatch);
1343            };
1344            source_caps[node.0 as usize] = Some(src.produced_caps().await?);
1345        }
1346    }
1347
1348    // Phase 2: build a per-node constraint and solve the whole DAG, and snapshot
1349    // each edge's downstream feasibility (D4) for the mid-stream re-solve. The
1350    // transform/sink constraints borrow their elements immutably (coexisting),
1351    // so both are computed and the borrows released before configure.
1352    let (solution, feasibility): (Vec<Caps>, Vec<Option<CapsSet>>) = {
1353        let constraints = build_node_constraints(vg, &source_caps)?;
1354        let preferences = build_node_preferences(vg);
1355        let solution =
1356            solve_graph_preferred(vg, &constraints, &preferences, &|node| caps_label(vg, node))
1357                .map_err(|f| {
1358                    report_nego_failure(bus, f);
1359                    G2gError::CapsMismatch
1360                })?;
1361        let feasibility = graph_downstream_feasibility(vg, &constraints, &solution);
1362        (solution, feasibility)
1363    };
1364
1365    // Phase 3: configure each element with its negotiated caps. Source nodes
1366    // take their single output edge's caps (no input); transforms and sinks
1367    // take their input edge's caps.
1368    for &node in topo {
1369        match vg.kind(node) {
1370            NodeKind::Source => {
1371                let caps = solution[vg.out_edges(node)[0]].clone();
1372                let GraphNodeRef::Source(src) =
1373                    vg.element_mut(node).ok_or(G2gError::CapsMismatch)?
1374                else {
1375                    return Err(G2gError::CapsMismatch);
1376                };
1377                src.configure_pipeline(&caps)?.reject_refixate()?;
1378            }
1379            NodeKind::Transform | NodeKind::Sink => {
1380                let caps = solution[vg.in_edges(node)[0]].clone();
1381                // A transform also learns its negotiated OUTPUT caps (M185), so a
1382                // caps-driven transform (videoscale fed by a downstream
1383                // capsfilter) can take its target from the solve. Sinks have no
1384                // output edge and skip it.
1385                let out_caps = vg.out_edges(node).first().map(|&eid| solution[eid].clone());
1386                let GraphNodeRef::Element(elem) =
1387                    vg.element_mut(node).ok_or(G2gError::CapsMismatch)?
1388                else {
1389                    return Err(G2gError::CapsMismatch);
1390                };
1391                elem.configure_pipeline(&caps)?.reject_refixate()?;
1392                if let Some(out_caps) = out_caps {
1393                    elem.configure_output(&out_caps)?;
1394                }
1395            }
1396            NodeKind::Tee(_) => {
1397                // A plain (broadcast) tee carries no element. A demux is a
1398                // tee-shaped node carrying a `MultiOutputElement`, configured
1399                // with its single input edge's negotiated caps (the byte stream);
1400                // each branch retypes later via per-output `CapsChanged`.
1401                if matches!(vg.element(node), Some(GraphNodeRef::Demux(_))) {
1402                    let caps = solution[vg.in_edges(node)[0]].clone();
1403                    let GraphNodeRef::Demux(elem) =
1404                        vg.element_mut(node).ok_or(G2gError::CapsMismatch)?
1405                    else {
1406                        return Err(G2gError::CapsMismatch);
1407                    };
1408                    elem.configure_pipeline(&caps)?.reject_refixate()?;
1409                }
1410            }
1411            // A terminal fan-out source has no configure hook: its ports'
1412            // caps were solved from `output_caps` and each downstream sink
1413            // configures from its own in-edge.
1414            NodeKind::FanoutSrc(_) => {}
1415            NodeKind::Muxer(_) | NodeKind::FaninSink(_) => {
1416                // Configure each input pad with its in-edge's negotiated caps
1417                // (a terminal fan-in has no output edge to configure).
1418                let in_edges: Vec<usize> = vg.in_edges(node).to_vec();
1419                for &eid in &in_edges {
1420                    let pad = vg.edge(eid).dst.index as usize;
1421                    let caps = solution[eid].clone();
1422                    let GraphNodeRef::Muxer(elem) =
1423                        vg.element_mut(node).ok_or(G2gError::CapsMismatch)?
1424                    else {
1425                        return Err(G2gError::CapsMismatch);
1426                    };
1427                    elem.configure_pipeline(pad, &caps)?.reject_refixate()?;
1428                }
1429            }
1430        }
1431    }
1432
1433    // Phase 3.5: DAG-wide M12 folds, so the runner reports the same latency /
1434    // clock / allocation the linear runners do (the convenience wrappers reduce
1435    // to thin builders over this). Done before Phase 4 takes the elements.
1436    //
1437    // Allocation cascade in reverse topo order: each element absorbs the
1438    // proposal arriving on its output edge(s) (`configure_allocation`), then
1439    // proposes from its output-link caps; the proposal is stored on its input
1440    // edge(s) for its upstream to absorb. A tee joins its branch proposals
1441    // (most-restrictive intersection, loud failure on a domain conflict) onto
1442    // its single input; a muxer proposes its own per-pad demand onto each input
1443    // edge (the boundary now crosses at startup). The source's absorbed proposal
1444    // is the reported `allocation`. For a linear chain this is byte-for-byte the
1445    // linear runner's sink->source fold.
1446    let allocation = cascade_allocation(vg, topo, &solution)?;
1447
1448    // Latency fold + clock election over every element node (tee is structural;
1449    // a muxer contributes neither, like the fan-in runner).
1450    let mut latencies: Vec<LatencyReport> = Vec::with_capacity(n);
1451    let mut clocks: Vec<Option<ClockCandidate>> = Vec::with_capacity(n);
1452    for &node in topo {
1453        if let Some(l) = element_latency(vg, node) {
1454            latencies.push(l);
1455            clocks.push(element_clock(vg, node));
1456        }
1457    }
1458    let latency = LatencyReport::aggregate(latencies);
1459    let elected = elect_clock(clocks.iter().cloned());
1460    let (clock_priority, base_time_ns) = match &elected {
1461        Some(c) => (c.priority, c.clock.now_ns()),
1462        None => (ClockPriority::SystemFallback, clock.now_ns()),
1463    };
1464
1465    // M1004: when the runner is going to watch the elected clock's health, the
1466    // sinks read it through a swappable handle instead of directly, so a
1467    // re-election after a loss retargets them without reaching the elements
1468    // again (they are already inside their arms by then).
1469    let elected_clock = elected
1470        .as_ref()
1471        .filter(|_| clock_monitor)
1472        .map(|c| alloc::sync::Arc::new(ElectedClock::new(c.clock.clone())));
1473    let clock_candidates: Vec<ClockCandidate> = if clock_monitor {
1474        clocks.into_iter().flatten().collect()
1475    } else {
1476        Vec::new()
1477    };
1478
1479    // Hand the elected clock + base time to every sink so each presents its
1480    // frames at their running-time deadline (PTS pacing), the same as the linear
1481    // runners (M169). Only when a clock was elected; without one the sinks present
1482    // as fast as backpressure allows. A sink node always holds a
1483    // `GraphNodeRef::Element` (not a `Source`), so the match below covers them.
1484    if let Some(c) = &elected {
1485        let sink_clock: alloc::sync::Arc<dyn PipelineClock + Send + Sync> = match &elected_clock {
1486            Some(handle) => handle.clone(),
1487            None => c.clock.clone(),
1488        };
1489        // M176: under a state controller, arm one Playing-transition anchor
1490        // (shared across sinks) so each bases presentation on the play edge,
1491        // not on startup / its preroll frame; without one, the eager base time
1492        // stands. Armed once outside the loop; the anchor is cheaply cloned.
1493        let anchor = state
1494            .as_ref()
1495            .map(|sc| sc.arm_play_anchor(sink_clock.clone()));
1496        for &node in topo {
1497            if matches!(vg.kind(node), NodeKind::Sink) {
1498                if let Some(GraphNodeRef::Element(elem)) = vg.element_mut(node) {
1499                    let sync = match &anchor {
1500                        Some(a) => {
1501                            ClockSync::with_play_anchor(sink_clock.clone(), base_time_ns, a.clone())
1502                        }
1503                        None => ClockSync::new(sink_clock.clone(), base_time_ns),
1504                    };
1505                    elem.set_clock_sync(sync.with_path_latency(latency));
1506                }
1507            }
1508        }
1509    }
1510
1511    Ok((
1512        probes,
1513        Prepared {
1514            solution,
1515            feasibility,
1516            latency,
1517            allocation,
1518            clock_priority,
1519            base_time_ns,
1520            clock_candidates,
1521            elected_clock,
1522            names,
1523        },
1524    ))
1525}
1526
1527/// Per-edge bounded channels plus the D4 β re-cascade coordinator, shared by the
1528/// cooperative and thread-per-arm drivers. Returns the edge sender/receiver
1529/// slots (taken by the arm loop), the shared leaky-link drop counter, the
1530/// per-transform `ArmDirective` receivers, and the coordinator + its handle.
1531struct GraphChannels {
1532    txs: Vec<Option<LinkSender>>,
1533    rxs: Vec<Option<LinkReceiver>>,
1534    dropped: alloc::sync::Arc<spin::Mutex<u64>>,
1535    arm_ctrl_rx: Vec<Option<Receiver<ArmDirective>>>,
1536    coord_handle: GraphCoordHandle,
1537    coordinator: GraphCoordinator,
1538}
1539
1540fn build_channels<'a>(
1541    vg: &ValidatedGraph<GraphNodeRef<'a>>,
1542    topo: &[NodeId],
1543    link_capacity: usize,
1544    instrument: bool,
1545) -> GraphChannels {
1546    let n = vg.node_count();
1547    // Phase 4: one bounded channel per edge, then one arm per node. Each arm
1548    // takes the senders of its outgoing edges and the receivers of its
1549    // incoming edges (a tee holds n senders, a sink one receiver, etc.).
1550    let ne = vg.edge_count();
1551    let mut txs: Vec<Option<LinkSender>> = Vec::with_capacity(ne);
1552    let mut rxs: Vec<Option<LinkReceiver>> = Vec::with_capacity(ne);
1553    // Shared drop counter: leaky links (`LinkPolicy::DropOldest`/`DropNewest`)
1554    // increment it per dropped frame, so the total surfaces in `RunStats`.
1555    let dropped = alloc::sync::Arc::new(spin::Mutex::new(0u64));
1556    for eid in 0..ne {
1557        // A per-edge depth (a `queue max-size-buffers=N`) overrides the graph-wide
1558        // default; most edges leave it `None` and take `link_capacity`.
1559        let cap = vg.edge(eid).capacity.unwrap_or(link_capacity);
1560        // Transit instrumentation only where it is measured + read: `Block` edges
1561        // (no drops -> the stamp ring stays aligned) into a transform/sink arm
1562        // (which pops the stamp). Elsewhere a plain link (zero cost).
1563        let dst_kind = vg.kind(vg.edge(eid).dst.node);
1564        let instr_edge = instrument
1565            && vg.edge(eid).policy == crate::link::LinkPolicy::Block
1566            && matches!(dst_kind, NodeKind::Transform | NodeKind::Sink);
1567        let (mut tx, rx) = if instr_edge {
1568            link_with_transit(cap)
1569        } else {
1570            link(cap)
1571        };
1572        let policy = vg.edge(eid).policy;
1573        tx.set_policy(policy);
1574        if policy != crate::link::LinkPolicy::Block {
1575            tx.set_drop_counter(dropped.clone());
1576        }
1577        // Live per-edge traffic counters, on every edge while an observer is
1578        // attached (unlike transit, they cost nothing to keep aligned).
1579        if instrument {
1580            tx.set_counters(alloc::sync::Arc::new(
1581                crate::runtime::instrument::EdgeCounters::default(),
1582            ));
1583        }
1584        txs.push(Some(tx));
1585        rxs.push(Some(rx));
1586    }
1587
1588    // D4 β coordinator: one `ArmDirective` channel per interruptible arm, plus
1589    // the upstream-arm adjacency the coordinator walks. Transforms take an
1590    // upstream demand; a muxer continues the walk through itself per input pad
1591    // (M839); a sink is the downstream end of a producer's settled pool. When
1592    // every such arm finishes (EOS-driven), the report handles drop and the
1593    // coordinator ends.
1594    let mut arm_ctrl: Vec<Option<Sender<ArmDirective>>> = (0..n).map(|_| None).collect();
1595    let mut arm_ctrl_rx: Vec<Option<Receiver<ArmDirective>>> = (0..n).map(|_| None).collect();
1596    let mut upstream_arms: Vec<Vec<NodeId>> = (0..n).map(|_| Vec::new()).collect();
1597    for &node in topo {
1598        if matches!(
1599            vg.kind(node),
1600            NodeKind::Transform | NodeKind::Sink | NodeKind::Muxer(_)
1601        ) {
1602            let (ctx, crx) = bounded::<ArmDirective>(link_capacity);
1603            arm_ctrl[node.0 as usize] = Some(ctx);
1604            arm_ctrl_rx[node.0 as usize] = Some(crx);
1605        }
1606    }
1607    // β reporters are transforms (after a directive) and sinks (on caps change);
1608    // each forwards to the nearest interior arm feeding its inputs.
1609    for &node in topo {
1610        if matches!(vg.kind(node), NodeKind::Transform | NodeKind::Sink) {
1611            let mut ups: Vec<NodeId> = Vec::new();
1612            for &ie in vg.in_edges(node) {
1613                if let Some(u) = nearest_upstream_arm(vg, ie) {
1614                    if !ups.contains(&u) {
1615                        ups.push(u);
1616                    }
1617                }
1618            }
1619            upstream_arms[node.0 as usize] = ups;
1620        }
1621    }
1622    let (coord_tx, coord_rx) = bounded::<Recascade>(link_capacity);
1623    let coord_handle = GraphCoordHandle { tx: coord_tx };
1624    let coordinator = GraphCoordinator {
1625        rx: coord_rx,
1626        arm_ctrl,
1627        upstream_arms,
1628    };
1629
1630    GraphChannels {
1631        txs,
1632        rxs,
1633        dropped,
1634        arm_ctrl_rx,
1635        coord_handle,
1636        coordinator,
1637    }
1638}
1639
1640/// Start the flight recorder on every edge, through the same per-edge
1641/// content-inspection slot an observer's preview tap uses. Called once the
1642/// channels are built and before any packet flows, so each edge's ring opens
1643/// with its negotiated caps and the element names the run assigned. Generic over
1644/// the node payload because the cooperative runner borrows its elements and the
1645/// thread-per-arm one owns them, while the edges this reads are the same.
1646fn install_flight_recorder<E>(
1647    recorder: &crate::runtime::FlightRecorder,
1648    vg: &ValidatedGraph<E>,
1649    txs: &[Option<LinkSender>],
1650    solution: &[Caps],
1651    names: &[alloc::string::String],
1652) {
1653    for (eid, tx) in txs.iter().enumerate() {
1654        let (Some(tx), Some(caps)) = (tx.as_ref(), solution.get(eid)) else {
1655            continue;
1656        };
1657        let edge = vg.edge(eid);
1658        let label = crate::runtime::flight_recorder::edge_label(
1659            names,
1660            edge.src.node.0 as usize,
1661            edge.dst.node.0 as usize,
1662        );
1663        recorder.record_edge(label, caps, &tx.probe);
1664    }
1665}
1666
1667/// Hand `obs` the per-edge taps that only exist once the channels are built: the
1668/// content-inspection slot, the negotiated caps, and the live traffic counters.
1669/// Aligned with the edge ids registered during `prepare_graph`.
1670fn register_edge_taps(
1671    obs: &Observer,
1672    txs: &[Option<LinkSender>],
1673    solution: &[Caps],
1674    link_capacity: usize,
1675) {
1676    obs.set_link_capacity(link_capacity);
1677    let probes: Vec<crate::runtime::channel::ProbeSlot> = txs
1678        .iter()
1679        .map(|o| o.as_ref().map(|s| s.probe.clone()).unwrap_or_default())
1680        .collect();
1681    let counters: Vec<Option<alloc::sync::Arc<crate::runtime::instrument::EdgeCounters>>> =
1682        txs.iter().map(|o| o.as_ref()?.counters.clone()).collect();
1683    obs.register_edges(probes, solution.to_vec(), counters);
1684}
1685
1686#[allow(clippy::too_many_arguments)]
1687pub(crate) async fn run_graph_inner<'a, Clk: PipelineClock>(
1688    graph: Graph<GraphNodeRef<'a>>,
1689    clock: &'a Clk,
1690    link_capacity: impl Into<LinkCapacity>,
1691    bus: Option<&BusHandle>,
1692    state: Option<StateController>,
1693    progress: Option<&PipelineProgress>,
1694    copy_policy: Option<crate::copyplan::CopyPolicy>,
1695    observer: Option<&Observer>,
1696    recorder: Option<&crate::runtime::FlightRecorder>,
1697    ticker: Option<&'a dyn DynAsyncClock>,
1698) -> Result<RunStats, G2gError> {
1699    // A pipeline clock that can sleep on a deadline is the fan-in tick timer
1700    // (M880), so every entry point ticks without one of its own. An explicit
1701    // `ticker` still wins.
1702    let ticker = ticker.or_else(|| clock.as_ticker());
1703    let link_capacity: usize = link_capacity.into().get();
1704    let mut vg = graph.finish().map_err(|_| G2gError::CapsMismatch)?;
1705    let n = vg.node_count();
1706    if n < 2 {
1707        return Err(G2gError::CapsMismatch);
1708    }
1709    let topo = vg.topo().to_vec();
1710    // M882: animated properties are checked against their elements before
1711    // negotiation, so a bad binding fails before configure opens any device.
1712    let mut controllers = resolve_controllers(&mut vg, &topo)?;
1713
1714    let (
1715        probes,
1716        Prepared {
1717            solution,
1718            feasibility,
1719            latency,
1720            allocation,
1721            clock_priority,
1722            base_time_ns,
1723            clock_candidates,
1724            elected_clock,
1725            names,
1726        },
1727    ) = prepare_graph(
1728        &mut vg,
1729        &topo,
1730        &state,
1731        bus,
1732        clock,
1733        observer,
1734        bus.is_some() && ticker.is_some(),
1735    )
1736    .await?;
1737
1738    // Enforce the memory-domain copy budget (M617) before any frame flows: the graph
1739    // is negotiated, so the per-edge domains are known and the copy plan is exact. A
1740    // zero-copy pipeline (`CopyPolicy::DenyAll`) refuses to start rather than silently
1741    // paying a host round-trip at runtime. Configure side effects already ran (bound
1742    // sockets etc.) and are released when the doomed graph drops.
1743    if let Some(policy) = copy_policy {
1744        let edge_memory: Vec<crate::memory::MemoryDomainKind> = (0..vg.edge_count())
1745            .map(|id| {
1746                let src = vg.edge(id).src.node;
1747                vg.element(src)
1748                    .map(|e| e.output_memory())
1749                    .unwrap_or(crate::memory::MemoryDomainKind::System)
1750            })
1751            .collect();
1752        let plan = copy_plan(&vg, &solution, &edge_memory);
1753        if plan.check(policy).is_err() {
1754            return Err(G2gError::CopyBudget);
1755        }
1756    }
1757
1758    let GraphChannels {
1759        mut txs,
1760        mut rxs,
1761        dropped,
1762        mut arm_ctrl_rx,
1763        coord_handle,
1764        coordinator,
1765    } = build_channels(&vg, &topo, link_capacity, observer.is_some());
1766
1767    // Dev-tooling edge tap: hand the observer each edge's content-inspection slot
1768    // (shared with the arm's `SenderSink`), its negotiated caps, and its live
1769    // traffic counters, so a preview subscriber can sample packets crossing any
1770    // edge and a dashboard can watch its packet / byte / drop totals. No arm
1771    // changes needed; the slot is empty (pass-through) until a subscriber
1772    // installs an interceptor.
1773    if let Some(obs) = observer {
1774        register_edge_taps(obs, &txs, &solution, link_capacity);
1775    }
1776    // Flight recorder: a bounded ring of recent packets per edge, dumped by the
1777    // caller once the run has failed. Same per-edge slot as the observer's
1778    // preview tap, so an unrecorded run stays exactly as cheap as before.
1779    if let Some(rec) = recorder {
1780        install_flight_recorder(rec, &vg, &txs, &solution, &names);
1781    }
1782
1783    let mut arms: Vec<BoxFuture<'a, Result<u64, G2gError>>> = Vec::with_capacity(n + 1);
1784    let mut arm_kinds: Vec<NodeKind> = Vec::with_capacity(n);
1785    // Arm order, not node order: a muxer contributes one forwarder arm per
1786    // input pad plus its own, so an arm index cannot index `names` directly.
1787    let mut arm_names: Vec<alloc::string::String> = Vec::with_capacity(n + 1);
1788
1789    for &node in &topo {
1790        let kind = vg.kind(node);
1791        let in_e: Vec<usize> = vg.in_edges(node).to_vec();
1792        let out_e: Vec<usize> = vg.out_edges(node).to_vec();
1793        let mut in_rxs: Vec<LinkReceiver> = in_e
1794            .iter()
1795            .map(|&e| rxs[e].take().expect("edge rx present"))
1796            .collect();
1797        let mut out_txs: Vec<LinkSender> = out_e
1798            .iter()
1799            .map(|&e| txs[e].take().expect("edge tx present"))
1800            .collect();
1801        let element = vg.take_element(node);
1802
1803        // A muxer contributes N+1 arms (one forwarder per input pad plus the
1804        // muxer arm), so it is built before the single-arm match below.
1805        if let NodeKind::Muxer(_) = kind {
1806            let Some(GraphNodeRef::Muxer(mux)) = element else {
1807                return Err(G2gError::CapsMismatch);
1808            };
1809            let out_tx = out_txs.pop().expect("muxer output edge");
1810            let mux_out_caps = solution[out_e[0]].clone();
1811            let pads: Vec<usize> = in_e
1812                .iter()
1813                .map(|&eid| vg.edge(eid).dst.index as usize)
1814                .collect();
1815            let beta = mux_beta(&vg, node, &in_e, out_e[0], &solution, coord_handle.clone());
1816            let mux_ctrl = arm_ctrl_rx[node.0 as usize].take().expect("muxer ctrl rx");
1817            let input_count = in_rxs.len();
1818            // Each input pad gets its OWN bounded channel feeding the muxer arm,
1819            // which drains them round-robin. A single shared FIFO would let a
1820            // fast input (e.g. a free-running background) monopolize the queue
1821            // and starve a slower real-time input (e.g. a 30 fps camera): the
1822            // camera's overlay would freeze and, worse, its EOS would never
1823            // arrive, hanging the all-inputs-EOS aggregation forever. Forwarders
1824            // are still indexed by pad so a muxer's `process(pad, ..)` keeps its
1825            // per-input geometry straight even if pads link out of order.
1826            let mut pad_rxs: Vec<(usize, Receiver<PipelinePacket>)> =
1827                Vec::with_capacity(input_count);
1828            for (in_rx, pad) in in_rxs.into_iter().zip(pads) {
1829                let (pad_tx, pad_rx) = bounded::<PipelinePacket>(link_capacity);
1830                let fwd: BoxFuture<'a, Result<u64, G2gError>> =
1831                    Box::pin(muxer_forwarder(in_rx, pad_tx));
1832                arms.push(fwd);
1833                arm_kinds.push(kind);
1834                arm_names.push(names[node.0 as usize].clone());
1835                pad_rxs.push((pad, pad_rx));
1836            }
1837            // A muxer can opt into runner-level PTS-ordered delivery (the runner
1838            // merges its inputs by DataFrame PTS); the default drains round-robin
1839            // in arrival order. The hook picks the arm from
1840            // `input_pts_ordered` and monomorphizes it over the element.
1841            let mux_probe = probes[node.0 as usize].clone();
1842            let mux_control = controllers[node.0 as usize].take();
1843            let arm: BoxFuture<'a, Result<u64, G2gError>> = mux.drive_muxer_arm(MuxerArmIo {
1844                parts: MuxerArmParts {
1845                    pad_rxs,
1846                    out_tx,
1847                    input_count,
1848                    current_output: mux_out_caps,
1849                    beta,
1850                    arm_rx: mux_ctrl,
1851                    probe: mux_probe,
1852                    control: mux_control,
1853                },
1854                ticker,
1855            });
1856            arms.push(arm);
1857            arm_kinds.push(kind);
1858            arm_names.push(names[node.0 as usize].clone());
1859            continue;
1860        }
1861
1862        let arm: BoxFuture<'a, Result<u64, G2gError>> = match kind {
1863            NodeKind::Source => {
1864                let Some(GraphNodeRef::Source(src)) = element else {
1865                    return Err(G2gError::CapsMismatch);
1866                };
1867                let out_tx = out_txs.pop().expect("source output edge");
1868                Box::pin(source_arm(src, out_tx, bus.cloned(), progress.cloned()))
1869            }
1870            NodeKind::Transform => {
1871                let Some(GraphNodeRef::Element(elem)) = element else {
1872                    return Err(G2gError::CapsMismatch);
1873                };
1874                let in_rx = in_rxs.pop().expect("transform input edge");
1875                let out_tx = out_txs.pop().expect("transform output edge");
1876                let out_edge = out_e[0];
1877                let arm_rx = arm_ctrl_rx[node.0 as usize]
1878                    .take()
1879                    .expect("transform ctrl rx");
1880                let out_caps = solution[out_edge].clone();
1881                let downstream_feasible = feasibility[out_edge].clone();
1882                elem.drive_transform_arm(TransformArmIo {
1883                    in_rx,
1884                    out_tx,
1885                    arm_rx,
1886                    coord: coord_handle.clone(),
1887                    node,
1888                    out_caps,
1889                    downstream_feasible,
1890                    mode: branch_mode(&vg, node),
1891                    bus: bus.cloned(),
1892                    probe: probes[node.0 as usize].clone(),
1893                    control: controllers[node.0 as usize].take(),
1894                })
1895            }
1896            NodeKind::Sink => {
1897                let Some(GraphNodeRef::Element(elem)) = element else {
1898                    return Err(G2gError::CapsMismatch);
1899                };
1900                let in_rx = in_rxs.pop().expect("sink input edge");
1901                let arm_rx = arm_ctrl_rx[node.0 as usize].take().expect("sink ctrl rx");
1902                advertise_orientation(&in_rx, elem.absorbs_orientation());
1903                elem.drive_sink_arm(SinkArmIo {
1904                    in_rx,
1905                    arm_rx,
1906                    coord: coord_handle.clone(),
1907                    node,
1908                    mode: branch_mode(&vg, node),
1909                    bus: bus.cloned(),
1910                    state: state.clone(),
1911                    progress: progress.cloned(),
1912                    probe: probes[node.0 as usize].clone(),
1913                    control: controllers[node.0 as usize].take(),
1914                })
1915            }
1916            NodeKind::Tee(_) => {
1917                let in_rx = in_rxs.pop().expect("tee input edge");
1918                // A tee-shaped node carrying a demux element routes per-output;
1919                // a plain tee broadcasts. Under `AllowBranchDrop` the broadcast
1920                // tolerates a branch that has dropped out (closed its channel).
1921                let branch_drop = vg.fanout_policy(node) == FanOutPolicy::AllowBranchDrop;
1922                match element {
1923                    Some(GraphNodeRef::Demux(demux)) => demux.drive_demux_arm(DemuxArmIo {
1924                        in_rx,
1925                        out_txs: demux_out_txs_by_port(&vg, node, out_txs),
1926                        probe: probes[node.0 as usize].clone(),
1927                    }),
1928                    _ => Box::pin(tee_arm(in_rx, out_txs, branch_drop)),
1929                }
1930            }
1931            NodeKind::FaninSink(_) => {
1932                let Some(GraphNodeRef::Muxer(session)) = element else {
1933                    return Err(G2gError::CapsMismatch);
1934                };
1935                let (pad_rxs, reverse) = fanin_sink_pads(&vg, node, in_rxs, &*session);
1936                session.drive_fanin_sink_arm(FaninSinkArmIo {
1937                    pad_rxs,
1938                    reverse,
1939                    probe: probes[node.0 as usize].clone(),
1940                })
1941            }
1942            NodeKind::FanoutSrc(_) => {
1943                let Some(GraphNodeRef::FanoutSource(source)) = element else {
1944                    return Err(G2gError::CapsMismatch);
1945                };
1946                let sinks = fanout_src_ports(&vg, node, out_txs);
1947                Box::pin(fanout_src_arm(source, sinks))
1948            }
1949            NodeKind::Muxer(_) => unreachable!("muxer handled above"),
1950        };
1951        arms.push(arm);
1952        arm_kinds.push(kind);
1953        arm_names.push(names[node.0 as usize].clone());
1954    }
1955
1956    // Drop the template handle so the coordinator can end once every arm's
1957    // clone drops; append the coordinator as the final arm. Its result is the
1958    // count of re-cascade events it observed.
1959    drop(coord_handle);
1960    let coord_arm_index = arms.len();
1961    arms.push(Box::pin(async move { Ok(coordinator.run().await) }));
1962
1963    // M1004: with a swappable elected clock installed, the health monitor runs
1964    // alongside the arms and is dropped once they finish (it never ends itself).
1965    let results = match (elected_clock, bus, ticker) {
1966        (Some(handle), Some(b), Some(t)) => {
1967            let monitor = clock_health_monitor(clock_candidates, handle, b.clone(), t);
1968            match select2(join_all(arms), monitor).await {
1969                Either::Left(results) => results,
1970                Either::Right(never) => match never {},
1971            }
1972        }
1973        _ => join_all(arms).await,
1974    };
1975    fold_run_stats(
1976        results,
1977        &arm_kinds,
1978        &arm_names,
1979        coord_arm_index,
1980        &dropped,
1981        &probes,
1982        latency,
1983        allocation,
1984        clock_priority,
1985        base_time_ns,
1986        bus,
1987    )
1988}
1989
1990/// Fold each arm's per-node frame count into the final [`RunStats`], shared by
1991/// the cooperative ([`run_graph_inner`]) and thread-per-arm
1992/// ([`run_graph_threaded`]) drivers, which differ only in how the arms are run
1993/// (one executor vs one OS thread each), not in how their results aggregate.
1994/// `results` is in arm order (source / transform / sink / muxer arms, then the
1995/// coordinator last at `coord_arm_index`); `arm_kinds` labels every arm except
1996/// the coordinator.
1997#[allow(clippy::too_many_arguments)]
1998fn fold_run_stats(
1999    results: Vec<Result<u64, G2gError>>,
2000    arm_kinds: &[NodeKind],
2001    arm_names: &[alloc::string::String],
2002    coord_arm_index: usize,
2003    dropped: &alloc::sync::Arc<spin::Mutex<u64>>,
2004    probes: &[Probe],
2005    latency: LatencyReport,
2006    allocation: Option<AllocationParams>,
2007    clock_priority: ClockPriority,
2008    base_time_ns: u64,
2009    bus: Option<&BusHandle>,
2010) -> Result<RunStats, G2gError> {
2011    // M81: surface a substantive arm error over a secondary `Shutdown` (a real
2012    // error in one node closes links, which surfaces as `Shutdown` on the
2013    // others; reporting the first-in-topo-order error would often mask the
2014    // cause).
2015    let failed = |only_substantive: bool| {
2016        results
2017            .iter()
2018            .enumerate()
2019            .filter_map(|(i, r)| r.as_ref().err().map(|e| (i, e)))
2020            .find(|(_, e)| !only_substantive || **e != G2gError::Shutdown)
2021    };
2022    if let Some((arm, e)) = failed(true).or_else(|| failed(false)) {
2023        let name = arm_names.get(arm).map(|n| n.as_str());
2024        crate::log::report_element_failure(name, e);
2025        // Named on the bus too: the returned error carries no element identity,
2026        // so an application that reacts per element has nothing else to key on.
2027        if let (Some(bus), Some(name)) = (bus, name.filter(|n| !n.is_empty())) {
2028            bus.try_post(crate::bus::BusMessage::ElementError {
2029                element: alloc::string::String::from(name),
2030                error: e.clone(),
2031            });
2032        }
2033        return Err(e.clone());
2034    }
2035    let mut counts = Vec::with_capacity(results.len());
2036    for r in results {
2037        counts.push(r?);
2038    }
2039    let coordinator_events = counts[coord_arm_index];
2040    let mut emitted = 0u64;
2041    let mut consumed = 0u64;
2042    for (kind, &count) in arm_kinds.iter().zip(counts.iter()) {
2043        match kind {
2044            NodeKind::Source | NodeKind::FanoutSrc(_) => emitted += count,
2045            NodeKind::Sink | NodeKind::FaninSink(_) => consumed += count,
2046            _ => {}
2047        }
2048    }
2049
2050    let frames_dropped = *dropped.lock();
2051    // M399: every arm has joined, so its probe is no longer being written; snapshot
2052    // each interior element's measured latency + fill into the report.
2053    let per_element = snapshot_all(probes);
2054    Ok(RunStats {
2055        frames_emitted: emitted,
2056        frames_consumed: consumed,
2057        frames_dropped,
2058        latency,
2059        allocation,
2060        clock_priority,
2061        base_time_ns,
2062        coordinator_events,
2063        per_element,
2064    })
2065}
2066
2067/// A graph arm's future, built and driven entirely on one worker thread. It is
2068/// deliberately **not** `Send`: the thread-per-arm runner never moves a future
2069/// between threads (only the element + channels, all `Send`, cross at setup), so
2070/// an element whose future is `!Send` (a hardware decoder holding a raw context)
2071/// runs unchanged, exactly as under the cooperative runner.
2072#[cfg(all(feature = "std", feature = "multi-thread"))]
2073pub type LocalArmFuture =
2074    core::pin::Pin<alloc::boxed::Box<dyn core::future::Future<Output = Result<u64, G2gError>>>>;
2075
2076/// Executor abstraction for [`run_graph_threaded`]. The runner hands each graph
2077/// node's arm to `spawn_arm` as a `Send` builder closure; the spawner runs the
2078/// builder on a dedicated worker thread and drives the [`LocalArmFuture`] it
2079/// returns to completion there, resolving the returned handle with the arm's
2080/// frame count (or its error). This is the GStreamer streaming-thread model: one
2081/// OS thread per element, so CPU-bound stages (software decode/encode) overlap
2082/// across cores instead of serialising on one cooperative executor.
2083///
2084/// `g2g-core` stays executor-agnostic; `g2g-plugins` supplies a tokio-backed
2085/// `ThreadSpawner`. Only the element and its channels (all `Send`) cross the
2086/// thread boundary; the future stays put, so elements need no `Send` future.
2087#[cfg(all(feature = "std", feature = "multi-thread"))]
2088pub trait GraphSpawner {
2089    /// Run `build` on a worker thread and drive its future there; the returned
2090    /// handle resolves (on the caller thread) once that arm finishes.
2091    fn spawn_arm(
2092        &self,
2093        build: alloc::boxed::Box<dyn FnOnce() -> LocalArmFuture + Send>,
2094    ) -> BoxFuture<'static, Result<u64, G2gError>>;
2095}
2096
2097/// Identifies the calling OS thread for
2098/// [`BusMessage::StreamStatus`](crate::BusMessage::StreamStatus). `ThreadId` has
2099/// no stable numeric form on the MSRV (`as_u64` is unstable), so hash it: only
2100/// equality between an enter and its leave is meaningful.
2101#[cfg(all(feature = "std", feature = "multi-thread"))]
2102fn current_thread_id() -> u64 {
2103    use core::hash::{Hash, Hasher};
2104    let mut hasher = std::collections::hash_map::DefaultHasher::new();
2105    std::thread::current().id().hash(&mut hasher);
2106    hasher.finish()
2107}
2108
2109/// Bracket one arm's builder with its [`BusMessage::StreamStatus`](crate::BusMessage::StreamStatus)
2110/// enter / leave pair. The builder itself runs on the worker thread, so posting
2111/// there names the thread the arm will be driven on; the leave rides on the
2112/// arm's own future so it lands when that arm finishes. Without a bus the
2113/// builder is returned untouched.
2114#[cfg(all(feature = "std", feature = "multi-thread"))]
2115fn with_stream_status(
2116    bus: Option<BusHandle>,
2117    build: alloc::boxed::Box<dyn FnOnce() -> LocalArmFuture + Send>,
2118) -> alloc::boxed::Box<dyn FnOnce() -> LocalArmFuture + Send> {
2119    let Some(bus) = bus else {
2120        return build;
2121    };
2122    alloc::boxed::Box::new(move || -> LocalArmFuture {
2123        let thread_id = current_thread_id();
2124        bus.try_post(BusMessage::StreamStatus {
2125            entered: true,
2126            thread_id,
2127        });
2128        let arm = build();
2129        Box::pin(async move {
2130            let result = arm.await;
2131            bus.try_post(BusMessage::StreamStatus {
2132                entered: false,
2133                thread_id,
2134            });
2135            result
2136        })
2137    })
2138}
2139
2140/// Thread-per-arm sibling of [`run_graph_inner`]: negotiates the graph
2141/// identically (shared [`prepare_graph`] / [`build_channels`] / [`fold_run_stats`]),
2142/// then hands each arm to `spawner` to run on its own OS thread rather than
2143/// cooperatively multiplexing them on the caller's executor. The graph must own
2144/// its elements (`Graph<GraphNode>`, i.e. `'static`) so each arm can move its
2145/// element onto a worker thread. All `vg`-borrowing / reference-typed inputs are
2146/// resolved to owned values *before* each arm's builder closure, since the
2147/// closure runs on another thread and may not borrow `vg`, `bus`, or `progress`.
2148/// `ticker` follows that rule too: the fan-in arm's deadline clock is a shared
2149/// [`Arc`](alloc::sync::Arc), not a borrow, so each muxer builder can carry it
2150/// onto its thread.
2151#[cfg(all(feature = "std", feature = "multi-thread"))]
2152#[allow(clippy::too_many_arguments)]
2153pub(crate) async fn run_graph_threaded_inner<S: GraphSpawner>(
2154    graph: Graph<GraphNode>,
2155    clock: &dyn PipelineClock,
2156    link_capacity: impl Into<LinkCapacity>,
2157    bus: Option<&BusHandle>,
2158    state: Option<StateController>,
2159    progress: Option<&PipelineProgress>,
2160    observer: Option<&Observer>,
2161    recorder: Option<&FlightRecorder>,
2162    ticker: Option<alloc::sync::Arc<dyn DynAsyncClock + Send + Sync>>,
2163    spawner: &S,
2164) -> Result<RunStats, G2gError> {
2165    // Same rule as the cooperative runner (M880), through the shared handle the
2166    // arm threads can own: a pipeline clock that can sleep on a deadline is the
2167    // fan-in tick timer, so every threaded entry point ticks without one of its
2168    // own. An explicit `ticker` still wins.
2169    let ticker = ticker.or_else(|| clock.shared_ticker());
2170    let link_capacity: usize = link_capacity.into().get();
2171    let mut vg = graph.finish().map_err(|_| G2gError::CapsMismatch)?;
2172    let n = vg.node_count();
2173    if n < 2 {
2174        return Err(G2gError::CapsMismatch);
2175    }
2176    let topo = vg.topo().to_vec();
2177    // M882: same pre-negotiation check as the cooperative runner; a resolved
2178    // controller is owned data, so it rides each arm's builder closure onto its
2179    // worker thread like the element itself.
2180    let mut controllers = resolve_controllers(&mut vg, &topo)?;
2181
2182    let (
2183        probes,
2184        Prepared {
2185            solution,
2186            feasibility,
2187            latency,
2188            allocation,
2189            clock_priority,
2190            base_time_ns,
2191            clock_candidates,
2192            elected_clock,
2193            names,
2194        },
2195    ) = prepare_graph(
2196        &mut vg,
2197        &topo,
2198        &state,
2199        bus,
2200        clock,
2201        observer,
2202        bus.is_some() && ticker.is_some(),
2203    )
2204    .await?;
2205
2206    let GraphChannels {
2207        mut txs,
2208        mut rxs,
2209        dropped,
2210        mut arm_ctrl_rx,
2211        coord_handle,
2212        coordinator,
2213    } = build_channels(&vg, &topo, link_capacity, observer.is_some());
2214
2215    // Dev-tooling edge tap: same as the cooperative path.
2216    if let Some(obs) = observer {
2217        register_edge_taps(obs, &txs, &solution, link_capacity);
2218    }
2219    // Flight recorder: installed here, before the arms move onto their threads,
2220    // so each ring is shared with the worker that fills it (see the cooperative
2221    // path for the mechanism).
2222    if let Some(rec) = recorder {
2223        install_flight_recorder(rec, &vg, &txs, &solution, &names);
2224    }
2225
2226    // One `spawn_arm` handle per arm (mirrors the cooperative `arms` vec). Each
2227    // handle resolves on this thread once its worker thread finishes.
2228    let mut handles: Vec<BoxFuture<'static, Result<u64, G2gError>>> = Vec::with_capacity(n + 1);
2229    let mut arm_kinds: Vec<NodeKind> = Vec::with_capacity(n);
2230    // Arm order, not node order: a muxer contributes one forwarder arm per
2231    // input pad plus its own, so an arm index cannot index `names` directly.
2232    let mut arm_names: Vec<alloc::string::String> = Vec::with_capacity(n + 1);
2233
2234    for &node in &topo {
2235        let kind = vg.kind(node);
2236        let in_e: Vec<usize> = vg.in_edges(node).to_vec();
2237        let out_e: Vec<usize> = vg.out_edges(node).to_vec();
2238        let mut in_rxs: Vec<LinkReceiver> = in_e
2239            .iter()
2240            .map(|&e| rxs[e].take().expect("edge rx present"))
2241            .collect();
2242        let mut out_txs: Vec<LinkSender> = out_e
2243            .iter()
2244            .map(|&e| txs[e].take().expect("edge tx present"))
2245            .collect();
2246        let element = vg.take_element(node);
2247
2248        if let NodeKind::Muxer(_) = kind {
2249            let Some(GraphNodeRef::Muxer(mux)) = element else {
2250                return Err(G2gError::CapsMismatch);
2251            };
2252            let out_tx = out_txs.pop().expect("muxer output edge");
2253            let mux_out_caps = solution[out_e[0]].clone();
2254            let pads: Vec<usize> = in_e
2255                .iter()
2256                .map(|&eid| vg.edge(eid).dst.index as usize)
2257                .collect();
2258            let beta = mux_beta(&vg, node, &in_e, out_e[0], &solution, coord_handle.clone());
2259            let mux_ctrl = arm_ctrl_rx[node.0 as usize].take().expect("muxer ctrl rx");
2260            let input_count = in_rxs.len();
2261            let mut pad_rxs: Vec<(usize, Receiver<PipelinePacket>)> =
2262                Vec::with_capacity(input_count);
2263            for (in_rx, pad) in in_rxs.into_iter().zip(pads) {
2264                let (pad_tx, pad_rx) = bounded::<PipelinePacket>(link_capacity);
2265                let build: alloc::boxed::Box<dyn FnOnce() -> LocalArmFuture + Send> =
2266                    alloc::boxed::Box::new(move || -> LocalArmFuture {
2267                        Box::pin(muxer_forwarder(in_rx, pad_tx))
2268                    });
2269                handles.push(spawner.spawn_arm(with_stream_status(bus.cloned(), build)));
2270                arm_kinds.push(kind);
2271                arm_names.push(names[node.0 as usize].clone());
2272                pad_rxs.push((pad, pad_rx));
2273            }
2274            let mux_probe = probes[node.0 as usize].clone();
2275            let mux_ticker = ticker.clone();
2276            let mux_control = controllers[node.0 as usize].take();
2277            let build: alloc::boxed::Box<dyn FnOnce() -> LocalArmFuture + Send> =
2278                alloc::boxed::Box::new(move || -> LocalArmFuture {
2279                    mux.drive_muxer_arm_owned_tick(MuxerArmOwnedTickIo {
2280                        parts: MuxerArmParts {
2281                            pad_rxs,
2282                            out_tx,
2283                            input_count,
2284                            current_output: mux_out_caps,
2285                            beta,
2286                            arm_rx: mux_ctrl,
2287                            probe: mux_probe,
2288                            control: mux_control,
2289                        },
2290                        ticker: mux_ticker,
2291                    })
2292                });
2293            handles.push(spawner.spawn_arm(with_stream_status(bus.cloned(), build)));
2294            arm_kinds.push(kind);
2295            arm_names.push(names[node.0 as usize].clone());
2296            continue;
2297        }
2298
2299        let build: alloc::boxed::Box<dyn FnOnce() -> LocalArmFuture + Send> = match kind {
2300            NodeKind::Source => {
2301                let Some(GraphNodeRef::Source(src)) = element else {
2302                    return Err(G2gError::CapsMismatch);
2303                };
2304                let out_tx = out_txs.pop().expect("source output edge");
2305                let bus_c = bus.cloned();
2306                let prog_c = progress.cloned();
2307                alloc::boxed::Box::new(move || -> LocalArmFuture {
2308                    Box::pin(source_arm(src, out_tx, bus_c, prog_c))
2309                })
2310            }
2311            NodeKind::Transform => {
2312                let Some(GraphNodeRef::Element(elem)) = element else {
2313                    return Err(G2gError::CapsMismatch);
2314                };
2315                let in_rx = in_rxs.pop().expect("transform input edge");
2316                let out_tx = out_txs.pop().expect("transform output edge");
2317                let out_edge = out_e[0];
2318                let arm_rx = arm_ctrl_rx[node.0 as usize]
2319                    .take()
2320                    .expect("transform ctrl rx");
2321                let out_caps = solution[out_edge].clone();
2322                let downstream_feasible = feasibility[out_edge].clone();
2323                let bm = branch_mode(&vg, node);
2324                let bus_c = bus.cloned();
2325                let probe = probes[node.0 as usize].clone();
2326                let ch = coord_handle.clone();
2327                let control = controllers[node.0 as usize].take();
2328                alloc::boxed::Box::new(move || -> LocalArmFuture {
2329                    elem.drive_transform_arm(TransformArmIo {
2330                        in_rx,
2331                        out_tx,
2332                        arm_rx,
2333                        coord: ch,
2334                        node,
2335                        out_caps,
2336                        downstream_feasible,
2337                        mode: bm,
2338                        bus: bus_c,
2339                        probe,
2340                        control,
2341                    })
2342                })
2343            }
2344            NodeKind::Sink => {
2345                let Some(GraphNodeRef::Element(elem)) = element else {
2346                    return Err(G2gError::CapsMismatch);
2347                };
2348                let in_rx = in_rxs.pop().expect("sink input edge");
2349                advertise_orientation(&in_rx, elem.absorbs_orientation());
2350                let bm = branch_mode(&vg, node);
2351                let bus_c = bus.cloned();
2352                let state_c = state.clone();
2353                let prog_c = progress.cloned();
2354                let probe = probes[node.0 as usize].clone();
2355                let ch = coord_handle.clone();
2356                let arm_rx = arm_ctrl_rx[node.0 as usize].take().expect("sink ctrl rx");
2357                let control = controllers[node.0 as usize].take();
2358                alloc::boxed::Box::new(move || -> LocalArmFuture {
2359                    elem.drive_sink_arm(SinkArmIo {
2360                        in_rx,
2361                        arm_rx,
2362                        coord: ch,
2363                        node,
2364                        mode: bm,
2365                        bus: bus_c,
2366                        state: state_c,
2367                        progress: prog_c,
2368                        probe,
2369                        control,
2370                    })
2371                })
2372            }
2373            NodeKind::Tee(_) => {
2374                let in_rx = in_rxs.pop().expect("tee input edge");
2375                let branch_drop = vg.fanout_policy(node) == FanOutPolicy::AllowBranchDrop;
2376                match element {
2377                    Some(GraphNodeRef::Demux(demux)) => {
2378                        let demux_probe = probes[node.0 as usize].clone();
2379                        let out_txs = demux_out_txs_by_port(&vg, node, out_txs);
2380                        alloc::boxed::Box::new(move || -> LocalArmFuture {
2381                            demux.drive_demux_arm(DemuxArmIo {
2382                                in_rx,
2383                                out_txs,
2384                                probe: demux_probe,
2385                            })
2386                        })
2387                    }
2388                    _ => alloc::boxed::Box::new(move || -> LocalArmFuture {
2389                        Box::pin(tee_arm(in_rx, out_txs, branch_drop))
2390                    }),
2391                }
2392            }
2393            NodeKind::FaninSink(_) => {
2394                let Some(GraphNodeRef::Muxer(session)) = element else {
2395                    return Err(G2gError::CapsMismatch);
2396                };
2397                let (pad_rxs, reverse) = fanin_sink_pads(&vg, node, in_rxs, &*session);
2398                let probe = probes[node.0 as usize].clone();
2399                alloc::boxed::Box::new(move || -> LocalArmFuture {
2400                    session.drive_fanin_sink_arm(FaninSinkArmIo {
2401                        pad_rxs,
2402                        reverse,
2403                        probe,
2404                    })
2405                })
2406            }
2407            NodeKind::FanoutSrc(_) => {
2408                let Some(GraphNodeRef::FanoutSource(source)) = element else {
2409                    return Err(G2gError::CapsMismatch);
2410                };
2411                let sinks = fanout_src_ports(&vg, node, out_txs);
2412                alloc::boxed::Box::new(move || -> LocalArmFuture {
2413                    Box::pin(fanout_src_arm(source, sinks))
2414                })
2415            }
2416            NodeKind::Muxer(_) => unreachable!("muxer handled above"),
2417        };
2418        handles.push(spawner.spawn_arm(with_stream_status(bus.cloned(), build)));
2419        arm_kinds.push(kind);
2420        arm_names.push(names[node.0 as usize].clone());
2421    }
2422
2423    // Drop the template handle so the coordinator ends once every arm's clone
2424    // drops; append the coordinator as the final arm on its own thread.
2425    drop(coord_handle);
2426    let coord_arm_index = handles.len();
2427    handles.push(spawner.spawn_arm(with_stream_status(
2428        bus.cloned(),
2429        alloc::boxed::Box::new(move || -> LocalArmFuture {
2430            Box::pin(async move { Ok(coordinator.run().await) })
2431        }),
2432    )));
2433
2434    // M1004: same clock-health watch as the cooperative runner, driven on the
2435    // caller's executor rather than a worker thread (it only sleeps and reads).
2436    let results = match (elected_clock, bus, &ticker) {
2437        (Some(handle), Some(b), Some(t)) => {
2438            let monitor = clock_health_monitor(clock_candidates, handle, b.clone(), &**t);
2439            match select2(join_all(handles), monitor).await {
2440                Either::Left(results) => results,
2441                Either::Right(never) => match never {},
2442            }
2443        }
2444        _ => join_all(handles).await,
2445    };
2446    fold_run_stats(
2447        results,
2448        &arm_kinds,
2449        &arm_names,
2450        coord_arm_index,
2451        &dropped,
2452        &probes,
2453        latency,
2454        allocation,
2455        clock_priority,
2456        base_time_ns,
2457        bus,
2458    )
2459}
2460
2461/// Run a DAG with one OS thread per arm via `spawner` (opt-in multicore; the
2462/// GStreamer streaming-thread model). Cooperative [`run_graph`] stays the default
2463/// for lowest latency and the `no_std` / wasm executors; this trades a per-stage
2464/// thread handoff for CPU-bound stages overlapping across cores.
2465///
2466/// Fan-in arms tick exactly as they do cooperatively (M953): when `clock` hands
2467/// out a shared timer ([`PipelineClock::shared_ticker`](crate::PipelineClock::shared_ticker),
2468/// which the wall clock does), an element declaring a
2469/// [`tick_interval_ns`](crate::MultiInputElement::tick_interval_ns) receives
2470/// [`PipelinePacket::Tick`] on that period while its inputs are silent.
2471#[cfg(all(feature = "std", feature = "multi-thread"))]
2472pub async fn run_graph_threaded<Clk: PipelineClock, S: GraphSpawner>(
2473    graph: Graph<GraphNode>,
2474    clock: &Clk,
2475    link_capacity: impl Into<LinkCapacity>,
2476    spawner: &S,
2477) -> Result<RunStats, G2gError> {
2478    run_graph_threaded_inner(
2479        graph,
2480        clock,
2481        link_capacity,
2482        None,
2483        None,
2484        None,
2485        None,
2486        None,
2487        None,
2488        spawner,
2489    )
2490    .await
2491}
2492
2493/// As [`run_graph_threaded`], but takes the arms' **deadline tick** timer
2494/// explicitly (M879): `clock` is both the pipeline clock and the timer the arms
2495/// sleep on, so a fan-in element declaring a
2496/// [`tick_interval_ns`](crate::MultiInputElement::tick_interval_ns) receives
2497/// [`PipelinePacket::Tick`] on that period even while its inputs are silent.
2498///
2499/// The clock arrives as a shared handle rather than a borrow because each arm's
2500/// builder closure moves onto its own OS thread: `Arc::new(my_clock)` (any
2501/// [`AsyncClock`](crate::AsyncClock) that is `Send + Sync`) coerces to it. This is
2502/// the entry for a clock that cannot hand out such a handle from `&self`, which is
2503/// what [`run_graph_threaded`] reads
2504/// ([`PipelineClock::shared_ticker`](crate::PipelineClock::shared_ticker)).
2505#[cfg(all(feature = "std", feature = "multi-thread"))]
2506pub async fn run_graph_threaded_ticked<S: GraphSpawner>(
2507    graph: Graph<GraphNode>,
2508    clock: alloc::sync::Arc<dyn DynAsyncClock + Send + Sync>,
2509    link_capacity: impl Into<LinkCapacity>,
2510    spawner: &S,
2511) -> Result<RunStats, G2gError> {
2512    run_graph_threaded_inner(
2513        graph,
2514        &clock,
2515        link_capacity,
2516        None,
2517        None,
2518        None,
2519        None,
2520        None,
2521        Some(clock.clone()),
2522        spawner,
2523    )
2524    .await
2525}
2526
2527/// As [`run_graph_threaded`], but posts pipeline messages to `bus` (the
2528/// thread-per-arm analog of [`run_graph_with_bus`]). This runner adds the
2529/// [`StreamStatus`](crate::BusMessage::StreamStatus) enter / leave pair per arm
2530/// thread, which the cooperative runner has no equivalent of.
2531#[cfg(all(feature = "std", feature = "multi-thread"))]
2532pub async fn run_graph_threaded_with_bus<Clk: PipelineClock, S: GraphSpawner>(
2533    graph: Graph<GraphNode>,
2534    clock: &Clk,
2535    link_capacity: impl Into<LinkCapacity>,
2536    bus: &BusHandle,
2537    spawner: &S,
2538) -> Result<RunStats, G2gError> {
2539    run_graph_threaded_inner(
2540        graph,
2541        clock,
2542        link_capacity,
2543        Some(bus),
2544        None,
2545        None,
2546        None,
2547        None,
2548        None,
2549        spawner,
2550    )
2551    .await
2552}
2553
2554/// As [`run_graph_threaded`], but publishes playback progress (the thread-per-arm
2555/// analog of [`run_graph_with_progress`]).
2556#[cfg(all(feature = "std", feature = "multi-thread"))]
2557pub async fn run_graph_threaded_with_progress<Clk: PipelineClock, S: GraphSpawner>(
2558    graph: Graph<GraphNode>,
2559    clock: &Clk,
2560    link_capacity: impl Into<LinkCapacity>,
2561    progress: &PipelineProgress,
2562    bus: Option<&BusHandle>,
2563    spawner: &S,
2564) -> Result<RunStats, G2gError> {
2565    run_graph_threaded_inner(
2566        graph,
2567        clock,
2568        link_capacity,
2569        bus,
2570        None,
2571        Some(progress),
2572        None,
2573        None,
2574        None,
2575        spawner,
2576    )
2577    .await
2578}
2579
2580/// As [`run_graph_threaded`], but taps live telemetry into `observer` (the
2581/// thread-per-arm analog of [`run_graph_observed`]): a concurrent task reads
2582/// per-element `process()` latency / input-link fill mid-run via
2583/// [`Observer::snapshot`](crate::runtime::Observer::snapshot) while the arms run
2584/// on their own OS threads.
2585#[cfg(all(feature = "std", feature = "multi-thread"))]
2586pub async fn run_graph_threaded_observed<Clk: PipelineClock, S: GraphSpawner>(
2587    graph: Graph<GraphNode>,
2588    clock: &Clk,
2589    link_capacity: impl Into<LinkCapacity>,
2590    observer: &Observer,
2591    spawner: &S,
2592) -> Result<RunStats, G2gError> {
2593    run_graph_threaded_inner(
2594        graph,
2595        clock,
2596        link_capacity,
2597        None,
2598        None,
2599        None,
2600        Some(observer),
2601        None,
2602        None,
2603        spawner,
2604    )
2605    .await
2606}
2607
2608/// [`run_graph_recorded`]'s thread-per-arm twin: the same [`FlightRecorder`],
2609/// with each arm on its own OS thread. The rings are shared with the worker
2610/// threads that fill them, so a heavy multicore pipeline (the kind that runs
2611/// under [`run_graph_threaded`] in the first place) leaves the same replayable
2612/// per-edge recording behind when it fails.
2613///
2614/// `progress` is optional, as in [`run_graph_recorded`].
2615#[cfg(all(feature = "std", feature = "multi-thread"))]
2616pub async fn run_graph_threaded_recorded<Clk: PipelineClock, S: GraphSpawner>(
2617    graph: Graph<GraphNode>,
2618    clock: &Clk,
2619    link_capacity: impl Into<LinkCapacity>,
2620    progress: Option<&PipelineProgress>,
2621    bus: Option<&BusHandle>,
2622    recorder: &FlightRecorder,
2623    spawner: &S,
2624) -> Result<RunStats, G2gError> {
2625    run_graph_threaded_inner(
2626        graph,
2627        clock,
2628        link_capacity,
2629        bus,
2630        None,
2631        progress,
2632        None,
2633        Some(recorder),
2634        None,
2635        spawner,
2636    )
2637    .await
2638}
2639
2640/// Zero-dependency [`GraphSpawner`]: each arm runs on its own `std` thread driven
2641/// by the park-based [`block_on`](crate::runtime::block_on). Dependency-free and
2642/// sufficient for graphs of pure-core elements (core channels + clock). Elements
2643/// that need a tokio reactor (network sources) require a tokio-backed spawner
2644/// instead (`g2g-plugins`' `TokioThreadSpawner`), since `block_on` provides no
2645/// I/O driver.
2646#[cfg(all(feature = "std", feature = "multi-thread"))]
2647#[derive(Debug, Default, Clone, Copy)]
2648pub struct ThreadSpawner;
2649
2650#[cfg(all(feature = "std", feature = "multi-thread"))]
2651impl GraphSpawner for ThreadSpawner {
2652    fn spawn_arm(
2653        &self,
2654        build: alloc::boxed::Box<dyn FnOnce() -> LocalArmFuture + Send>,
2655    ) -> BoxFuture<'static, Result<u64, G2gError>> {
2656        // A capacity-1 channel is the handle: the worker delivers its one result,
2657        // the caller awaits it. Cross-thread wake is via the channel's waker.
2658        let (tx, rx) = crate::runtime::channel::bounded::<Result<u64, G2gError>>(1);
2659        std::thread::spawn(move || {
2660            let result = crate::runtime::block_on(build());
2661            // Best-effort: a dropped handle (the join aborted after a sibling's
2662            // error) just discards the result.
2663            let _ = tx.try_send(result);
2664        });
2665        Box::pin(async move { rx.recv().await.unwrap_or(Err(G2gError::Shutdown)) })
2666    }
2667}
2668
2669/// Each node's declared per-alternative costs, indexed by node id, for the
2670/// solver's minimum-cost fixation. Source, transform and sink nodes declare
2671/// them; a muxer / demux node is not part of a linear chain, which is the only
2672/// topology the cost minimization covers. A source's costs index its produce
2673/// set, which without them is read in its own order.
2674fn build_node_preferences(vg: &ValidatedGraph<GraphNodeRef<'_>>) -> Vec<Option<CapsPreferences>> {
2675    (0..vg.node_count())
2676        .map(|i| {
2677            let node = NodeId(i as u32);
2678            match vg.kind(node) {
2679                NodeKind::Source => source_ref(vg, node).and_then(|s| s.caps_preferences()),
2680                NodeKind::Transform | NodeKind::Sink => {
2681                    element_ref(vg, node).and_then(|e| e.caps_preferences())
2682                }
2683                _ => None,
2684            }
2685        })
2686        .collect()
2687}
2688
2689/// View a node's payload as a source. `None` for any other node kind.
2690fn source_ref<'g, 'a>(
2691    vg: &'g ValidatedGraph<GraphNodeRef<'a>>,
2692    node: NodeId,
2693) -> Option<&'g (dyn DynSourceLoop + 'a)> {
2694    match vg.element(node)? {
2695        GraphNodeRef::Source(src) => Some(&**src),
2696        GraphNodeRef::Element(_)
2697        | GraphNodeRef::Muxer(_)
2698        | GraphNodeRef::FanoutSource(_)
2699        | GraphNodeRef::Demux(_) => None,
2700    }
2701}
2702
2703/// View a node's payload as a transform/sink element. `None` for a source or a
2704/// muxer (whose constraints the runner builds from their own trait methods).
2705fn element_ref<'g, 'a>(
2706    vg: &'g ValidatedGraph<GraphNodeRef<'a>>,
2707    node: NodeId,
2708) -> Option<&'g (dyn DynAsyncElement + 'a)> {
2709    match vg.element(node)? {
2710        GraphNodeRef::Element(elem) => Some(&**elem),
2711        GraphNodeRef::Source(_)
2712        | GraphNodeRef::Muxer(_)
2713        | GraphNodeRef::FanoutSource(_)
2714        | GraphNodeRef::Demux(_) => None,
2715    }
2716}
2717
2718/// Build the per-node solver constraints for a validated graph, given each
2719/// source's probed produce set (indexed by node id, `None` for non-sources). The
2720/// constraints borrow their elements immutably, so the returned vec must be
2721/// dropped before any `&mut` borrow (configure). Shared by the runner's Phase 2
2722/// and the negotiate-only tooling path ([`negotiate_graph`]).
2723fn build_node_constraints<'g, 'a>(
2724    vg: &'g ValidatedGraph<GraphNodeRef<'a>>,
2725    source_caps: &[Option<CapsSet>],
2726) -> Result<Vec<NodeConstraint<'g>>, G2gError> {
2727    let mut constraints: Vec<NodeConstraint<'g>> = Vec::with_capacity(vg.node_count());
2728    for (i, src_caps) in source_caps.iter().enumerate() {
2729        let node = NodeId(i as u32);
2730        let nc = match vg.kind(node) {
2731            NodeKind::Source => {
2732                let set = src_caps.clone().ok_or(G2gError::CapsMismatch)?;
2733                NodeConstraint::Element(CapsConstraint::Produces(set))
2734            }
2735            NodeKind::Transform => {
2736                let elem = element_ref(vg, node).ok_or(G2gError::CapsMismatch)?;
2737                NodeConstraint::Element(elem.caps_constraint_as_transform())
2738            }
2739            NodeKind::Sink => {
2740                let elem = element_ref(vg, node).ok_or(G2gError::CapsMismatch)?;
2741                NodeConstraint::Element(elem.caps_constraint_as_sink())
2742            }
2743            // A plain (broadcast) tee is structural; the solver couples its
2744            // branches via `IdentityAny`. A demux that declares per-port caps
2745            // (M380) instead negotiates each branch against its port: build a
2746            // `Demux` constraint so a downstream decoder configures at startup.
2747            NodeKind::Tee(n) => {
2748                let ports: Vec<Option<Caps>> = match vg.element(node) {
2749                    Some(GraphNodeRef::Demux(elem)) => {
2750                        (0..n as usize).map(|p| elem.port_output_caps(p)).collect()
2751                    }
2752                    _ => Vec::new(),
2753                };
2754                if !ports.is_empty() && ports.iter().all(Option::is_some) {
2755                    let input = match vg.element(node) {
2756                        Some(GraphNodeRef::Demux(elem)) => elem.caps_constraint_as_input(),
2757                        _ => CapsConstraint::AcceptsAny,
2758                    };
2759                    let ports = ports
2760                        .into_iter()
2761                        .map(|c| CapsConstraint::Produces(CapsSet::one(c.expect("all Some"))))
2762                        .collect();
2763                    NodeConstraint::Demux { input, ports }
2764                } else {
2765                    NodeConstraint::Element(CapsConstraint::IdentityAny)
2766                }
2767            }
2768            NodeKind::Muxer(_) => {
2769                let GraphNodeRef::Muxer(elem) = vg.element(node).ok_or(G2gError::CapsMismatch)?
2770                else {
2771                    return Err(G2gError::CapsMismatch);
2772                };
2773                let inputs: Vec<CapsConstraint<'g>> = (0..elem.input_count())
2774                    .map(|pad| elem.caps_constraint_as_input(pad))
2775                    .collect();
2776                let follows = elem.output_follows_input();
2777                // An identity-passthrough mux derives its output from a pad, so it
2778                // need not (and may be unable to) declare output caps up front;
2779                // only ask for them in the independent-output case.
2780                let output = match follows {
2781                    Some(_) => CapsConstraint::AcceptsAny,
2782                    None => elem
2783                        .caps_constraint_for_output()
2784                        .map_err(|_| G2gError::CapsMismatch)?,
2785                };
2786                NodeConstraint::Muxer {
2787                    inputs,
2788                    output,
2789                    follows,
2790                }
2791            }
2792            // A terminal fan-out source produces per-port caps: the demux
2793            // constraint shape with the input half inert (no input edge).
2794            NodeKind::FanoutSrc(n) => {
2795                let GraphNodeRef::FanoutSource(elem) =
2796                    vg.element(node).ok_or(G2gError::CapsMismatch)?
2797                else {
2798                    return Err(G2gError::CapsMismatch);
2799                };
2800                let ports: Vec<CapsConstraint<'g>> = (0..n as usize)
2801                    .map(|p| {
2802                        elem.output_caps(p)
2803                            .map(|c| CapsConstraint::Produces(CapsSet::one(c)))
2804                    })
2805                    .collect::<Result<_, _>>()?;
2806                NodeConstraint::Demux {
2807                    input: CapsConstraint::AcceptsAny,
2808                    ports,
2809                }
2810            }
2811            // A terminal fan-in reuses the muxer constraint shape with the
2812            // output half inert (no output edge exists to couple).
2813            NodeKind::FaninSink(_) => {
2814                let GraphNodeRef::Muxer(elem) = vg.element(node).ok_or(G2gError::CapsMismatch)?
2815                else {
2816                    return Err(G2gError::CapsMismatch);
2817                };
2818                let inputs: Vec<CapsConstraint<'g>> = (0..elem.input_count())
2819                    .map(|pad| elem.caps_constraint_as_input(pad))
2820                    .collect();
2821                NodeConstraint::Muxer {
2822                    inputs,
2823                    output: CapsConstraint::AcceptsAny,
2824                    follows: None,
2825                }
2826            }
2827        };
2828        constraints.push(nc);
2829    }
2830    Ok(constraints)
2831}
2832
2833/// A node's label for the caps explainer and the DOT dump: the element's log
2834/// category (e.g. `h264parse`), falling back to the structural kind for a tee
2835/// (which carries no element).
2836fn caps_label(vg: &ValidatedGraph<GraphNodeRef<'_>>, node: NodeId) -> alloc::string::String {
2837    match vg.element(node) {
2838        Some(e) => e.log_category().to_string(),
2839        None => match vg.kind(node) {
2840            NodeKind::Tee(_) => "tee".to_string(),
2841            k => alloc::format!("{k:?}"),
2842        },
2843    }
2844}
2845
2846/// Run startup caps negotiation only, without running the pipeline: validate the
2847/// graph, probe each source's caps (Phase 1, async), and solve the whole-graph
2848/// CSP (Phase 2), returning the validated graph, the fixated caps per edge, and
2849/// each edge's memory domain (all indexed by edge id, as
2850/// [`crate::dot::DotAnnotations`] expects). The per-edge domain is the producing
2851/// node's [`output_memory`](GraphNodeRef::output_memory) (M285), so a GPU /
2852/// zero-copy link shows up in the dump. For tooling that wants the *chosen* caps
2853/// without moving data, e.g. `g2g-launch --dot`. It performs the same
2854/// source-caps probing the runner does, so a source that connects on
2855/// `intercept_caps` (a live ingress) will do so here too; a negotiation failure
2856/// returns `CapsMismatch` (the caller can fall back to a topology-only dump).
2857pub async fn negotiate_graph<'a>(
2858    graph: Graph<GraphNodeRef<'a>>,
2859) -> Result<
2860    (
2861        ValidatedGraph<GraphNodeRef<'a>>,
2862        Vec<Caps>,
2863        Vec<crate::memory::MemoryDomainKind>,
2864    ),
2865    G2gError,
2866> {
2867    negotiate_graph_explained(graph).await.map_err(|e| match e {
2868        NegotiateError::Setup(err) => err,
2869        NegotiateError::Solve(_) => G2gError::CapsMismatch,
2870    })
2871}
2872
2873/// Why [`negotiate_graph_explained`] could not negotiate a graph. `Setup` is a
2874/// structural / I/O failure before the solve (too few nodes, a bad source, a
2875/// source caps-probe error); `Solve` carries the structured
2876/// [`NegotiationFailure`] naming the conflicting link, which the opaque
2877/// [`negotiate_graph`] flattens to `CapsMismatch`.
2878#[derive(Debug)]
2879pub enum NegotiateError {
2880    Setup(G2gError),
2881    Solve(NegotiationFailure),
2882}
2883
2884/// As [`negotiate_graph`], but preserves the structured [`NegotiationFailure`]
2885/// on a solve conflict (for the caps-negotiation explainer / `validate`
2886/// tooling). `negotiate_graph` is the opaque wrapper over this.
2887pub async fn negotiate_graph_explained<'a>(
2888    graph: Graph<GraphNodeRef<'a>>,
2889) -> Result<
2890    (
2891        ValidatedGraph<GraphNodeRef<'a>>,
2892        Vec<Caps>,
2893        Vec<crate::memory::MemoryDomainKind>,
2894    ),
2895    NegotiateError,
2896> {
2897    let mut vg = graph
2898        .finish()
2899        .map_err(|_| NegotiateError::Setup(G2gError::CapsMismatch))?;
2900    let n = vg.node_count();
2901    if n < 2 {
2902        return Err(NegotiateError::Setup(G2gError::CapsMismatch));
2903    }
2904    let topo = vg.topo().to_vec();
2905
2906    // Phase 1: probe each source's produce set (async), releasing the mutable
2907    // borrow before the constraint phase borrows every node immutably.
2908    let mut source_caps: Vec<Option<CapsSet>> = (0..n).map(|_| None).collect();
2909    for &node in &topo {
2910        if matches!(vg.kind(node), NodeKind::Source) {
2911            let GraphNodeRef::Source(src) = vg
2912                .element_mut(node)
2913                .ok_or(NegotiateError::Setup(G2gError::CapsMismatch))?
2914            else {
2915                return Err(NegotiateError::Setup(G2gError::CapsMismatch));
2916            };
2917            source_caps[node.0 as usize] =
2918                Some(src.produced_caps().await.map_err(NegotiateError::Setup)?);
2919        }
2920    }
2921
2922    // Phase 2: build constraints and solve. Scope the immutable borrow so `vg`
2923    // moves out cleanly in the return.
2924    let solution = {
2925        let constraints =
2926            build_node_constraints(&vg, &source_caps).map_err(NegotiateError::Setup)?;
2927        let preferences = build_node_preferences(&vg);
2928        solve_graph_preferred(&vg, &constraints, &preferences, &|node| {
2929            caps_label(&vg, node)
2930        })
2931        .map_err(NegotiateError::Solve)?
2932    };
2933
2934    // Phase 3.5's allocation cascade, run for its effect on the elements: it is
2935    // what settles a multi-domain producer (a decoder that can keep frames on
2936    // the GPU or download them) on the domain its consumer asked for. Reading
2937    // `output_memory` without it reports every producer's default preference, so
2938    // the dump would call a downloading link a GPU link.
2939    cascade_allocation(&mut vg, &topo, &solution).map_err(NegotiateError::Setup)?;
2940
2941    // Per-edge memory domain: the domain of the node producing onto that edge.
2942    let edge_memory: Vec<crate::memory::MemoryDomainKind> = (0..vg.edge_count())
2943        .map(|id| {
2944            let src = vg.edge(id).src.node;
2945            vg.element(src)
2946                .map(|e| e.output_memory())
2947                .unwrap_or(crate::memory::MemoryDomainKind::System)
2948        })
2949        .collect();
2950
2951    Ok((vg, solution, edge_memory))
2952}
2953
2954/// Build the [`CopyPlan`](crate::copyplan::CopyPlan) for a negotiated graph from the
2955/// three arrays [`negotiate_graph`] returns (the validated graph, per-edge fixated
2956/// caps, and per-edge memory domain). Extracts each node's label + output domain and
2957/// each edge's producer/consumer/domain/caps into the flat profiles the pure
2958/// analysis works over, so tooling (e.g. `g2g-launch --copy-plan`) or a graph-level
2959/// copy budget can inspect the memory-domain path before running.
2960pub fn copy_plan(
2961    vg: &ValidatedGraph<GraphNodeRef<'_>>,
2962    edge_caps: &[Caps],
2963    edge_memory: &[crate::memory::MemoryDomainKind],
2964) -> crate::copyplan::CopyPlan {
2965    let nodes: Vec<crate::copyplan::NodeProfile> = (0..vg.node_count())
2966        .map(|i| {
2967            let node = NodeId(i as u32);
2968            crate::copyplan::NodeProfile {
2969                label: caps_label(vg, node),
2970                out_domain: vg
2971                    .element(node)
2972                    .map(|e| e.output_memory())
2973                    .unwrap_or(crate::memory::MemoryDomainKind::System),
2974            }
2975        })
2976        .collect();
2977    let edges: Vec<crate::copyplan::EdgeProfile> = (0..vg.edge_count())
2978        .map(|id| {
2979            let e = vg.edge(id);
2980            crate::copyplan::EdgeProfile {
2981                src: e.src.node.0 as usize,
2982                dst: e.dst.node.0 as usize,
2983                domain: edge_memory[id],
2984                caps: edge_caps[id].clone(),
2985            }
2986        })
2987        .collect();
2988    crate::copyplan::CopyPlan::analyze(&nodes, &edges)
2989}
2990
2991/// Run the allocation cascade over a solved graph, in reverse topo order: each
2992/// element absorbs the proposal arriving on its output edge(s)
2993/// (`configure_allocation`), then proposes from its output-link caps, and the
2994/// proposal is stored on its input edge(s) for its upstream to absorb. A tee
2995/// joins its branch proposals (most-restrictive intersection, loud failure on a
2996/// domain conflict) onto its single input; a muxer proposes its own per-pad
2997/// demand onto each input edge. For a linear chain this is byte-for-byte the
2998/// linear runner's sink->source fold.
2999///
3000/// Returns the source's absorbed proposal (what the runner reports as its
3001/// `allocation`). Shared with [`negotiate_graph`], which runs it
3002/// purely to settle each element's domain so a graph dump reports the negotiated
3003/// memory domain rather than every producer's default preference.
3004fn cascade_allocation(
3005    vg: &mut ValidatedGraph<GraphNodeRef<'_>>,
3006    topo: &[NodeId],
3007    solution: &[Caps],
3008) -> Result<Option<AllocationParams>, G2gError> {
3009    let nee = vg.edge_count();
3010    let mut edge_proposal: Vec<Option<AllocationParams>> = (0..nee).map(|_| None).collect();
3011    let mut allocation: Option<AllocationParams> = None;
3012    for &node in topo.iter().rev() {
3013        match vg.kind(node) {
3014            NodeKind::Sink => {
3015                let in_e = vg.in_edges(node)[0];
3016                let caps = solution[in_e].clone();
3017                edge_proposal[in_e] = element_propose(vg, node, &caps, MetaRequests::new());
3018            }
3019            NodeKind::Transform => {
3020                let in_e = vg.in_edges(node)[0];
3021                let out_e = vg.out_edges(node)[0];
3022                // A transform is a memory-domain pass-through here: it forwards the
3023                // downstream proposal to its own pool and re-proposes upstream
3024                // unchanged. Domain capability is enforced at the buffer-pool
3025                // origin (the source) and at the sibling join (the tee), not at
3026                // every hop, so a GPU proposal merely passing through a plain
3027                // transform is not rejected against its System default (M351).
3028                if let Some(p) = edge_proposal[out_e] {
3029                    element_configure_alloc(vg, node, &p);
3030                }
3031                let caps = solution[out_e].clone();
3032                let downstream = edge_meta_requests(edge_proposal[out_e]);
3033                edge_proposal[in_e] = element_propose(vg, node, &caps, downstream);
3034            }
3035            NodeKind::Tee(_) => {
3036                let in_e = vg.in_edges(node)[0];
3037                // The first branch seeds the join: a `None` from there on means a
3038                // branch that asked for nothing, which is not the same as no
3039                // branch yet (it vetoes a demand needing every consumer).
3040                let mut joined: Option<AllocationParams> = None;
3041                for (i, &oe) in vg.out_edges(node).iter().enumerate() {
3042                    joined = match i {
3043                        0 => edge_proposal[oe],
3044                        _ => join_alloc(joined, edge_proposal[oe])?,
3045                    };
3046                }
3047                // A plain tee carries no element and narrows nothing; a demux
3048                // node folds in the domains it declared it can parse, so a
3049                // System-only demuxer makes a GPU producer download (M1039).
3050                edge_proposal[in_e] = narrow_to_input_domains(joined, node_input_domains(vg, node));
3051            }
3052            NodeKind::Source => {
3053                let out_e = vg.out_edges(node)[0];
3054                if let Some(p) = edge_proposal[out_e] {
3055                    // M351: reconcile against the source's emittable domains, the
3056                    // upstream end of the two-sided negotiation. The reconciled
3057                    // proposal is what the source allocates and what `RunStats`
3058                    // reports.
3059                    let can = node_output_domains(vg, node);
3060                    // A proposal carrying only metadata demand accepts every
3061                    // domain, so reconciling it would let a metadata request pick
3062                    // the source's memory domain: pass it through untouched.
3063                    let resolved = match p.constrains_pool() {
3064                        true => p.resolve_for_producer(can)?,
3065                        false => p,
3066                    };
3067                    if let GraphNodeRef::Source(src) =
3068                        vg.element_mut(node).ok_or(G2gError::CapsMismatch)?
3069                    {
3070                        src.configure_allocation(&resolved);
3071                    }
3072                    allocation = Some(resolved);
3073                }
3074            }
3075            // A terminal fan-out source exposes no allocation hook (its
3076            // outputs are network-generated System bytes).
3077            NodeKind::FanoutSrc(_) => {}
3078            NodeKind::Muxer(_) | NodeKind::FaninSink(_) => {
3079                // A muxer / terminal fan-in asks each input pad for the allocation
3080                // it wants (most are content-agnostic and propose nothing), storing
3081                // it on that input edge so the demand crosses the boundary and
3082                // re-cascades up the branch like any other downstream proposal. The
3083                // muxer's own output edge proposal is not absorbed here: a container
3084                // muxer's byte output has no memory-domain tie to its inputs, and a
3085                // terminal fan-in has no output at all.
3086                //
3087                // M976: downstream *metadata* demand does cross the output, on its
3088                // own, because it describes the frames the fan-in writes (a GPU
3089                // compositor deciding whether to declare its row padding). Nothing
3090                // is called when no demand was declared.
3091                let out_demand = vg
3092                    .out_edges(node)
3093                    .first()
3094                    .map(|&out_e| edge_meta_requests(edge_proposal[out_e]))
3095                    .unwrap_or_default();
3096                if !out_demand.is_empty() {
3097                    if let Some(GraphNodeRef::Muxer(mux)) = vg.element_mut(node) {
3098                        mux.configure_allocation_for_output(&AllocationParams::meta_demand(
3099                            out_demand,
3100                        ));
3101                    }
3102                }
3103                let accepts = node_input_domains(vg, node);
3104                if let Some(GraphNodeRef::Muxer(mux)) = vg.element(node) {
3105                    for &in_e in vg.in_edges(node) {
3106                        let pad = vg.edge(in_e).dst.index as usize;
3107                        let caps = solution[in_e].clone();
3108                        edge_proposal[in_e] = narrow_to_input_domains(
3109                            mux.propose_allocation_for_input(pad, &caps),
3110                            accepts,
3111                        );
3112                    }
3113                }
3114            }
3115        }
3116    }
3117    Ok(allocation)
3118}
3119
3120/// A transform/sink node's allocation proposal from `caps` (its output-link caps
3121/// for a transform, its input-link caps for a sink), carrying the node's own meta
3122/// requests plus `downstream`'s onward up the cascade (M976). `None` for other
3123/// kinds.
3124fn element_propose(
3125    vg: &ValidatedGraph<GraphNodeRef<'_>>,
3126    node: NodeId,
3127    caps: &Caps,
3128    downstream: MetaRequests,
3129) -> Option<AllocationParams> {
3130    match vg.element(node) {
3131        Some(GraphNodeRef::Element(elem)) => with_meta_demand(
3132            narrow_to_input_domains(elem.propose_allocation(caps), elem.input_domains()),
3133            elem.meta_requests().carry_upstream(downstream),
3134        ),
3135        _ => None,
3136    }
3137}
3138
3139/// Fold the domains an element declared it can take into the proposal it hands
3140/// its producer. Without this an element that accepts one domain but proposes
3141/// nothing leaves its producer free to pick any domain, and the mismatch only
3142/// surfaces as an `UnsupportedDomain` on the first frame; the declaration used
3143/// to reach nothing but the converter auto-plug. The all-domains default (what
3144/// an element that never thought about memory reports) narrows nothing, so a
3145/// graph of such elements cascades exactly as before.
3146fn narrow_to_input_domains(
3147    proposal: Option<AllocationParams>,
3148    accepts: DomainSet,
3149) -> Option<AllocationParams> {
3150    if accepts == DomainSet::ALL {
3151        return proposal;
3152    }
3153    let narrowed = match proposal {
3154        Some(p) => p.accepts.intersect(accepts),
3155        None => accepts,
3156    };
3157    // An element whose own proposal contradicts its declaration is
3158    // self-inconsistent: keep the explicit proposal, so the producer-side
3159    // reconcile reports the conflict rather than this silently picking a side.
3160    let Some(domain) = narrowed.preferred() else {
3161        return proposal;
3162    };
3163    Some(AllocationParams {
3164        domain,
3165        accepts: narrowed,
3166        ..proposal.unwrap_or_default()
3167    })
3168}
3169
3170/// The metadata demand an edge's stored proposal carries, empty when there is
3171/// none.
3172fn edge_meta_requests(proposal: Option<AllocationParams>) -> MetaRequests {
3173    proposal.map(|p| p.meta_requests).unwrap_or_default()
3174}
3175
3176/// The set of memory domains a node accepts on its inputs, for folding into the
3177/// proposal it hands upstream. A node without an element (a structural tee)
3178/// requires nothing.
3179fn node_input_domains(vg: &ValidatedGraph<GraphNodeRef<'_>>, node: NodeId) -> DomainSet {
3180    vg.element(node)
3181        .map(|n| n.input_domains())
3182        .unwrap_or(DomainSet::ALL)
3183}
3184
3185/// The set of memory domains a node can emit (M351), for reconciling a
3186/// downstream allocation proposal against the producer's real capability.
3187/// Missing nodes report a System singleton (the conservative default).
3188fn node_output_domains(
3189    vg: &ValidatedGraph<GraphNodeRef<'_>>,
3190    node: NodeId,
3191) -> crate::memory::DomainSet {
3192    vg.element(node)
3193        .map(|n| n.output_domains())
3194        .unwrap_or(crate::memory::DomainSet::only(
3195            crate::memory::MemoryDomainKind::System,
3196        ))
3197}
3198
3199/// Apply a downstream-derived allocation proposal to a transform's own pool.
3200fn element_configure_alloc(
3201    vg: &mut ValidatedGraph<GraphNodeRef<'_>>,
3202    node: NodeId,
3203    params: &AllocationParams,
3204) {
3205    if let Some(GraphNodeRef::Element(elem)) = vg.element_mut(node) {
3206        elem.configure_allocation(params);
3207    }
3208}
3209
3210/// A node's latency contribution. `None` for structural (tee) and muxer nodes.
3211fn element_latency(vg: &ValidatedGraph<GraphNodeRef<'_>>, node: NodeId) -> Option<LatencyReport> {
3212    match vg.element(node) {
3213        Some(GraphNodeRef::Source(src)) => Some(src.latency()),
3214        Some(GraphNodeRef::Element(elem)) => Some(elem.latency()),
3215        _ => None,
3216    }
3217}
3218
3219/// How often the runner reads the elected clock's health. Coarse on purpose:
3220/// losing a grandmaster is a seconds-scale event, and the check costs a lock on
3221/// the clock.
3222const CLOCK_HEALTH_PERIOD_NS: u64 = 1_000_000_000;
3223
3224/// Watch the elected clock and re-elect when it loses the reference it
3225/// disciplines to (M1004). Runs alongside the graph's arms, sleeping on the same
3226/// timer the fan-in arms tick on so it stays executor-agnostic, and posts
3227/// [`BusMessage::ClockLost`] on each healthy -> unhealthy edge. It then elects
3228/// again over the candidates that are still healthy and retargets `elected`, which
3229/// is what every sink's [`ClockSync`] reads through. With no healthy candidate
3230/// left the pipeline keeps the clock it has: it still tells time, it is just no
3231/// longer disciplined, and a later re-lock is picked up by the same check.
3232///
3233/// Never returns; the runner drops it once the arms have finished.
3234async fn clock_health_monitor(
3235    candidates: Vec<ClockCandidate>,
3236    elected: alloc::sync::Arc<ElectedClock>,
3237    bus: BusHandle,
3238    ticker: &dyn DynAsyncClock,
3239) -> core::convert::Infallible {
3240    let mut was_healthy = true;
3241    loop {
3242        let deadline = ticker.now_ns().saturating_add(CLOCK_HEALTH_PERIOD_NS);
3243        ticker.sleep_until_ns(deadline).await;
3244        let healthy = elected.healthy();
3245        if healthy || !was_healthy {
3246            was_healthy = healthy;
3247            continue;
3248        }
3249        was_healthy = false;
3250        bus.try_post(BusMessage::ClockLost);
3251        if let Some(c) = elect_clock(
3252            candidates
3253                .iter()
3254                .filter(|c| c.clock.healthy())
3255                .cloned()
3256                .map(Some),
3257        ) {
3258            elected.swap(c.clock.clone());
3259            was_healthy = elected.healthy();
3260        }
3261    }
3262}
3263
3264/// A node's offered clock for the pipeline clock election.
3265fn element_clock(vg: &ValidatedGraph<GraphNodeRef<'_>>, node: NodeId) -> Option<ClockCandidate> {
3266    match vg.element(node) {
3267        Some(GraphNodeRef::Source(src)) => src.provide_clock(),
3268        Some(GraphNodeRef::Element(elem)) => elem.provide_clock(),
3269        _ => None,
3270    }
3271}
3272
3273/// Join two allocation proposals at a tee's input. Both branches consume the
3274/// one upstream producer, so the result is the most-restrictive per-parameter
3275/// intersection ([`AllocationParams::join`]): the larger size, count, and
3276/// alignment, with a matching memory domain. Divergent domains are an empty
3277/// intersection and fail loud with [`G2gError::AllocationConflict`] (no single
3278/// pool can satisfy, say, a CUDA branch and a D3D11 branch at once).
3279/// M976: a branch that proposes nothing still counts as a branch that requested
3280/// nothing, so a demand needing every consumer dies against it; and a join left
3281/// carrying neither pool constraints nor demand collapses back to `None`, so a
3282/// died-out request leaves the cascade exactly as it found it.
3283fn join_alloc(
3284    a: Option<AllocationParams>,
3285    b: Option<AllocationParams>,
3286) -> Result<Option<AllocationParams>, G2gError> {
3287    let joined = match (a, b) {
3288        (Some(x), Some(y)) => Some(x.join(y)?),
3289        (Some(x), None) => {
3290            Some(x.with_meta_requests(x.meta_requests.join_branches(MetaRequests::new())))
3291        }
3292        (None, Some(y)) => {
3293            Some(y.with_meta_requests(y.meta_requests.join_branches(MetaRequests::new())))
3294        }
3295        (None, None) => None,
3296    };
3297    Ok(joined.filter(|p| p.constrains_pool() || !p.meta_requests.is_empty()))
3298}
3299
3300/// Re-solve one muxer input pad against the boundary's new caps (MX-1).
3301fn solve_mux_input_dyn(
3302    new_caps: &Caps,
3303    mux: &dyn DynMultiInputElement,
3304    pad: usize,
3305) -> Result<Caps, G2gError> {
3306    let src_c = CapsConstraint::LegacySource(new_caps.clone());
3307    let mux_c = mux.caps_constraint_as_input(pad);
3308    let links = solve_linear(&[&src_c, &mux_c]).map_err(|_| G2gError::CapsMismatch)?;
3309    links.last().cloned().ok_or(G2gError::CapsMismatch)
3310}
3311
3312/// Re-derive the merged muxer output from its current per-input config (MX-2).
3313fn solve_mux_output_dyn(mux: &dyn DynMultiInputElement) -> Result<Caps, G2gError> {
3314    let mux_c = mux
3315        .caps_constraint_for_output()
3316        .map_err(|_| G2gError::CapsMismatch)?;
3317    let sink_c = CapsConstraint::AcceptsAny;
3318    let links = solve_linear(&[&mux_c, &sink_c]).map_err(|_| G2gError::CapsMismatch)?;
3319    links.last().cloned().ok_or(G2gError::CapsMismatch)
3320}
3321
3322async fn source_arm<'a>(
3323    mut src: Box<dyn DynSourceLoop + 'a>,
3324    out_tx: LinkSender,
3325    bus: Option<BusHandle>,
3326    progress: Option<PipelineProgress>,
3327) -> Result<u64, G2gError> {
3328    // M206: announce the stream start before any data, one per source, so an
3329    // application can bracket each stream's lifetime (StreamStart .. Eos).
3330    if let Some(b) = &bus {
3331        b.try_post(BusMessage::StreamStart);
3332    }
3333    // M203: publish the source's duration (if it knows one) before producing,
3334    // so a `DURATION` query is answerable from the first poll, and push-notify a
3335    // change on the bus. Polled once here; a source that discovers its length
3336    // mid-stream is a follow-up (it would publish through the handle directly).
3337    if let Some(duration_ns) = src.query_duration() {
3338        let changed = progress
3339            .as_ref()
3340            .map(|p| p.publish_duration(duration_ns))
3341            .unwrap_or(true);
3342        if changed {
3343            if let Some(b) = &bus {
3344                b.try_post(BusMessage::DurationChanged { duration_ns });
3345            }
3346        }
3347    }
3348    let mut adapter = SenderSink::new(out_tx);
3349    // M81: open the stream with a SEGMENT ahead of the source's data, so every
3350    // downstream branch maps timestamps to running time from the first frame.
3351    let _ = adapter
3352        .push(PipelinePacket::Segment(Segment::new()))
3353        .await?;
3354    src.run(&mut adapter).await
3355}
3356
3357/// An interior transform arm. Besides forwarding data, it (D4) selects on a β
3358/// `ArmDirective` channel alongside its data link so an upstream re-cascade
3359/// reaches it while parked on data, and on a mid-stream `CapsChanged` it steers
3360/// its forwarded output toward a downstream-acceptable shape using its
3361/// `downstream_feasible` snapshot (Caps-α), failing loud via a reverse
3362/// reconfigure if downstream positively rejects every output it can produce.
3363/// Everything a transform arm needs besides its element (M1000). Opaque on
3364/// purpose: only the runner can build one, so the `drive_transform_arm` hook
3365/// on `DynAsyncElement` stays implementable only through the blanket impl.
3366#[doc(hidden)]
3367#[allow(missing_debug_implementations)]
3368pub struct TransformArmIo {
3369    pub(crate) in_rx: LinkReceiver,
3370    pub(crate) out_tx: LinkSender,
3371    pub(crate) arm_rx: Receiver<ArmDirective>,
3372    pub(crate) coord: GraphCoordHandle,
3373    pub(crate) node: NodeId,
3374    pub(crate) out_caps: Caps,
3375    pub(crate) downstream_feasible: Option<CapsSet>,
3376    pub(crate) mode: BranchMode,
3377    pub(crate) bus: Option<BusHandle>,
3378    pub(crate) probe: Probe,
3379    pub(crate) control: Option<ArmController>,
3380}
3381
3382/// As [`TransformArmIo`], for the sink arm.
3383#[doc(hidden)]
3384#[allow(missing_debug_implementations)]
3385pub struct SinkArmIo {
3386    pub(crate) in_rx: LinkReceiver,
3387    pub(crate) arm_rx: Receiver<ArmDirective>,
3388    pub(crate) coord: GraphCoordHandle,
3389    pub(crate) node: NodeId,
3390    pub(crate) mode: BranchMode,
3391    pub(crate) bus: Option<BusHandle>,
3392    pub(crate) state: Option<StateController>,
3393    pub(crate) progress: Option<PipelineProgress>,
3394    pub(crate) probe: Probe,
3395    pub(crate) control: Option<ArmController>,
3396}
3397
3398/// Monomorphized over the element type by the `drive_transform_arm` blanket
3399/// hook (M1000), so the per-frame `process` future is the element's own
3400/// unboxed state machine, not a `Box<dyn Future>`.
3401#[doc(hidden)]
3402pub async fn transform_arm<E: AsyncElement>(
3403    mut elem: E,
3404    io: TransformArmIo,
3405) -> Result<u64, G2gError> {
3406    let TransformArmIo {
3407        in_rx,
3408        out_tx,
3409        arm_rx,
3410        coord,
3411        node,
3412        mut out_caps,
3413        downstream_feasible,
3414        mode,
3415        bus,
3416        probe,
3417        control,
3418    } = io;
3419    let mut adapter = SenderSink::new(out_tx);
3420    // M947: charge time spent blocked on the downstream link to this element's
3421    // push-wait, not to its `process()` compute.
3422    adapter.set_push_wait_probe(probe.clone());
3423    // M175: relay a downstream QoS report (seen on this transform's output link)
3424    // onto its input link, so it reaches the source/decoder one hop at a time
3425    // through any number of generic transforms, not just the sink's direct
3426    // upstream. The element's `process` is unaffected. M720 extends the same
3427    // hop to keyframe requests and bitrate targets when the element does not
3428    // consume them itself, so a PLI / BWE estimate crosses a parser between
3429    // the encoder and a WebRTC sink. M997 does the same for QoS: an element
3430    // that sheds work on a report (a decoder skipping non-reference frames)
3431    // observes it instead.
3432    if !elem.handles_qos() {
3433        adapter.relay_qos_to(in_rx.qos_slot());
3434    }
3435    adapter.relay_reconfigure_to(
3436        in_rx.reconfigure_slot(),
3437        ReconfigureAnswered {
3438            keyframe: elem.handles_keyframe_requests(),
3439            orientation: elem.handles_orientation(),
3440        },
3441    );
3442    if !elem.handles_bitrate_requests() {
3443        adapter.relay_bitrate_to(in_rx.bitrate_slot());
3444    }
3445    let mut control_open = true;
3446    let mut last_buffer_bucket: Option<u8> = None;
3447    loop {
3448        // M843: interior links report their fill too, so an application can see
3449        // *where* a pipeline is starved or backed up, not just at the sink.
3450        report_buffering(bus.as_ref(), &probe, &in_rx, &mut last_buffer_bucket);
3451        let packet = if control_open {
3452            match select2(arm_rx.recv(), in_rx.recv()).await {
3453                Either::Left(Some(ArmDirective::Recascade(params))) => {
3454                    // β: absorb the downstream proposal, re-derive our own from
3455                    // our output caps, and report it so the cascade continues to
3456                    // our upstream neighbour.
3457                    elem.configure_allocation(&params);
3458                    let proposal = with_meta_demand(
3459                        elem.propose_allocation(&out_caps),
3460                        elem.meta_requests().carry_upstream(params.meta_requests),
3461                    );
3462                    coord
3463                        .report(Recascade {
3464                            node,
3465                            route: RecascadeRoute::Upstream,
3466                            proposal,
3467                        })
3468                        .await;
3469                    continue;
3470                }
3471                Either::Left(Some(ArmDirective::ProducerAllocation(params))) => {
3472                    // M839: an upstream muxer settled a new pool on our input
3473                    // link. Absorb it and stay quiet: re-proposing here would
3474                    // walk straight back into the boundary it came from.
3475                    elem.configure_allocation(&params);
3476                    continue;
3477                }
3478                Either::Left(None) => {
3479                    control_open = false;
3480                    continue;
3481                }
3482                Either::Right(packet) => packet,
3483            }
3484        } else {
3485            in_rx.recv().await
3486        };
3487        match packet {
3488            Some(PipelinePacket::Eos) => {
3489                elem.process(PipelinePacket::Eos, &mut adapter).await?;
3490                // M909: skip our push when the element's catch-all arm already
3491                // forwarded the sentinel, so the sink sees exactly one `Eos`.
3492                if !adapter.eos_forwarded() {
3493                    adapter.push(PipelinePacket::Eos).await?;
3494                }
3495                // Drop our report handle so the coordinator can wind down once
3496                // every arm exits, then drain any tail-end re-cascade directive
3497                // still in flight (a β triggered by the final pre-EOS frames).
3498                // Dropping before the drain decouples wind-down from the drain,
3499                // so no arm blocks holding the last handle.
3500                drop(coord);
3501                while let Some(directive) = arm_rx.recv().await {
3502                    elem.configure_allocation(directive.params());
3503                }
3504                return Ok(0);
3505            }
3506            Some(PipelinePacket::CapsChanged(new_caps)) => {
3507                // Caps-α: derive the forwarded output from this element's
3508                // constraint steered by the downstream feasibility snapshot.
3509                // `Defer` keeps the prior behavior (forward the incoming caps);
3510                // `Infeasible` fails the run loud (no producer renegotiates).
3511                let (forward_caps, output_resolved) = {
3512                    let constraint = elem.caps_constraint_as_transform();
3513                    match resolve_forward_output(
3514                        &constraint,
3515                        &new_caps,
3516                        downstream_feasible.as_ref(),
3517                        Some(&out_caps),
3518                    ) {
3519                        ForwardResolve::Fixed(caps) => (caps, true),
3520                        ForwardResolve::Defer => (new_caps.clone(), false),
3521                        ForwardResolve::Infeasible(failure) => {
3522                            // A genuinely infeasible re-solve: the refined caps
3523                            // have no solution against the downstream chain, and
3524                            // no runtime producer renegotiates its output caps, so
3525                            // there is nothing to reverse-reconfigure into. A tee
3526                            // branch (`FailLoud`) and a single-producer chain
3527                            // (`Reconfigure`) both fail the run loud, naming the
3528                            // conflict, rather than flowing stale caps; `Drop` (a
3529                            // tee) ends this branch while its siblings continue.
3530                            match mode {
3531                                BranchMode::FailLoud | BranchMode::Reconfigure => {
3532                                    report_runtime_caps_conflict(&new_caps);
3533                                    report_nego_failure(bus.as_ref(), failure);
3534                                    return Err(G2gError::CapsMismatch);
3535                                }
3536                                BranchMode::Drop => {
3537                                    report_nego_failure(bus.as_ref(), failure);
3538                                    return Ok(0);
3539                                }
3540                            }
3541                        }
3542                    }
3543                };
3544                let instance = probe.as_deref().map(|p| p.name());
3545                log_caps_forward(instance, &new_caps, &forward_caps, output_resolved);
3546                match log_caps_rejected(instance, &new_caps, elem.configure_pipeline(&new_caps))? {
3547                    ConfigureOutcome::Accepted => {
3548                        // M188: re-resolve a caps-driven transform's output target
3549                        // on the mid-stream change too (matches startup, line ~421
3550                        // / the linear coordinator arm). No-op for property-driven
3551                        // or passthrough elements. Skipped on a Defer: there
3552                        // `forward_caps` is the incoming INPUT caps, not this
3553                        // element's output, and `configure_output`'s contract is
3554                        // output caps only (a strict transform rightly rejects
3555                        // an input-shaped set, e.g. OpusDec fed `audio/x-opus`).
3556                        if output_resolved {
3557                            log_caps_rejected(
3558                                instance,
3559                                &forward_caps,
3560                                elem.configure_output(&forward_caps),
3561                            )?;
3562                        }
3563                        realloc_local_dyn(&mut elem as &mut dyn DynAsyncElement, &forward_caps);
3564                        // On a Defer `forward_caps` is the incoming INPUT caps:
3565                        // keep the last known real output as the shape to steer
3566                        // future re-solves (and allocation proposals) by.
3567                        if output_resolved {
3568                            out_caps = forward_caps.clone();
3569                        }
3570                        elem.process(PipelinePacket::CapsChanged(forward_caps), &mut adapter)
3571                            .await?;
3572                    }
3573                    ConfigureOutcome::ReFixate(counter) => {
3574                        in_rx.request_reconfigure(Reconfigure::Propose(counter));
3575                    }
3576                }
3577            }
3578            Some(packet) => {
3579                // M759: if this element opts into metadata auto-application,
3580                // derive the propagated set from this input frame and stash it on
3581                // the adapter, which attaches it to fresh (meta-empty) outputs.
3582                // Recomputed per input frame: exact for a 1-in-1-out transform,
3583                // most-recent-input association for a pipelined one. Cloning the
3584                // set is cheap (Arc refcount bumps); an empty result clears the
3585                // stash so a Drop verdict never leaks a stale set.
3586                #[cfg(feature = "metadata")]
3587                if let (Some(t), PipelinePacket::DataFrame(frame)) =
3588                    (elem.meta_transform(), &packet)
3589                {
3590                    if frame.meta.is_empty() {
3591                        adapter.set_meta_stash(None);
3592                    } else {
3593                        let mut propagated = frame.meta.clone();
3594                        propagated.propagate(t);
3595                        adapter.set_meta_stash((!propagated.is_empty()).then_some(propagated));
3596                    }
3597                }
3598                // M399: time the data-frame `process()` and sample input fill;
3599                // control packets (segment/flush) are excluded so the histogram
3600                // reflects real per-frame work, not cheap signalling.
3601                let seq = match &packet {
3602                    PipelinePacket::DataFrame(f) => Some(f.sequence),
3603                    _ => None,
3604                };
3605                let timed = probe.as_deref().filter(|_| seq.is_some());
3606                let mut wait_ns = 0;
3607                if let Some(p) = timed {
3608                    p.record_fill(in_rx.fill_percent());
3609                    // Queue-residency of this frame on the input link (M684).
3610                    if let Some(t) = in_rx.pop_transit_ns() {
3611                        p.record_transit(t);
3612                        wait_ns = t;
3613                    }
3614                }
3615                // M882: animated properties are sampled at this frame's PTS, so
3616                // the element processes it under the values that frame's time
3617                // calls for.
3618                apply_control(
3619                    control.as_ref(),
3620                    &mut elem as &mut dyn DynAsyncElement,
3621                    &packet,
3622                    &probe,
3623                )?;
3624                let t0 = ElementProbe::mark();
3625                elem.process(packet, &mut adapter).await?;
3626                if let (Some(p), Some(seq)) = (timed, seq) {
3627                    let push_wait_ns = p.record_proc_since(t0);
3628                    // M851: this frame's own wait + work, joined across stages
3629                    // by sequence id at snapshot time. M951: the push-wait the
3630                    // call just banked is charged to this visit too, so the
3631                    // journey's work segment is compute.
3632                    p.record_visit(seq, wait_ns, t0, push_wait_ns);
3633                }
3634                // M1036: renegotiation a transform originates rather than
3635                // relays (a decoder that read a new resolution out of the
3636                // bitstream); store it on the input link, where the upstream
3637                // producer observes it as `PushOutcome::Reconfigure`.
3638                if let Some(reconf) = elem.take_reconfigure() {
3639                    in_rx.request_reconfigure(reconf);
3640                }
3641            }
3642            None => return Ok(0),
3643        }
3644    }
3645}
3646
3647/// A sink arm. On a mid-stream `CapsChanged` (D4) it re-solves its input against
3648/// its declared constraint; on accept it re-derives its own pool and reports the
3649/// proposal so the β cascade walks one hop upstream. A re-solve failure surfaces
3650/// loud as a reverse reconfigure into the boundary that emitted the change.
3651/// M839: it also selects on the β control channel, so the pool an upstream muxer
3652/// settles on for its merged output reaches the element that reads it.
3653/// Monomorphized over the element type by the `drive_sink_arm` blanket hook
3654/// (M1000); see [`transform_arm`].
3655#[doc(hidden)]
3656pub async fn sink_arm<E: AsyncElement>(mut elem: E, io: SinkArmIo) -> Result<u64, G2gError> {
3657    let probe = io.probe.clone();
3658    let result = sink_arm_loop(&mut elem, io).await;
3659    // A paced sink's presented / dropped counters, read here (after the loop
3660    // ended, on any exit) so the snapshot taken once every arm joined sees a
3661    // settled value.
3662    if let (Some(p), Some(stats)) = (&probe, elem.presentation_stats()) {
3663        p.set_presentation(stats);
3664    }
3665    result
3666}
3667
3668async fn sink_arm_loop<E: AsyncElement>(elem: &mut E, io: SinkArmIo) -> Result<u64, G2gError> {
3669    let SinkArmIo {
3670        in_rx,
3671        arm_rx,
3672        coord,
3673        node,
3674        mode,
3675        bus,
3676        state,
3677        progress,
3678        probe,
3679        control,
3680    } = io;
3681    let mut null = NullSink;
3682    let mut consumed = 0u64;
3683    let mut prerolled_self = false;
3684    let mut last_buffer_bucket: Option<u8> = None;
3685    // M203: the segment in force, so a buffer's PTS maps to stream-time position.
3686    let mut current_segment: Option<Segment> = None;
3687    // M360 re-preroll: generation this arm last prerolled at, and whether it is
3688    // draining stale pre-seek frames (paused flushing seek) until the `Flush`.
3689    let mut preroll_gen = state.as_ref().map_or(0, |sc| sc.preroll_generation());
3690    let mut flushing = false;
3691    // M839: while the coordinator lives, race the data link against the β control
3692    // channel; once it closes, degrade to data-only so the closed arm can't spin.
3693    let mut control_open = true;
3694    loop {
3695        // M78 flow gate: below `Playing` the sink parks here, so it stops
3696        // draining its edge and backpressure stalls the DAG upstream. Non-live
3697        // `Paused` admits this sink's one preroll buffer; `Null` ends the arm.
3698        if let Some(sc) = &state {
3699            if sc.flow_gate(prerolled_self, preroll_gen).await == Flow::Stop {
3700                return Ok(consumed);
3701            }
3702            // M360: a `request_repreroll` (paused flushing seek) bumped the
3703            // generation; re-arm preroll and drain stale pre-seek frames until
3704            // the `Flush`, so the post-flush target is the new visible preroll.
3705            let gen = sc.preroll_generation();
3706            if gen != preroll_gen {
3707                preroll_gen = gen;
3708                prerolled_self = false;
3709                flushing = true;
3710            }
3711        }
3712        // M87 buffering: sample the input link's fill and post a `Buffering`
3713        // report when it crosses a quartile band. The first iteration samples
3714        // an as-yet-unfilled link, so a `bus` always sees at least one report.
3715        report_buffering(bus.as_ref(), &probe, &in_rx, &mut last_buffer_bucket);
3716        let packet = if control_open {
3717            match select2(arm_rx.recv(), in_rx.recv()).await {
3718                // M839: a producer upstream (a muxer) settled a new pool on this
3719                // sink's input link. Absorb it; a sink is the end of the walk, so
3720                // it has nothing to report onward.
3721                Either::Left(Some(directive)) => {
3722                    elem.configure_allocation(directive.params());
3723                    continue;
3724                }
3725                Either::Left(None) => {
3726                    control_open = false;
3727                    continue;
3728                }
3729                Either::Right(packet) => packet,
3730            }
3731        } else {
3732            in_rx.recv().await
3733        };
3734        match packet {
3735            // M360: discard stale pre-seek buffers while draining toward the
3736            // `Flush`; control packets fall through (the `Flush` ends drain).
3737            Some(PipelinePacket::DataFrame(_)) if flushing => continue,
3738            Some(PipelinePacket::Eos) => {
3739                elem.process(PipelinePacket::Eos, &mut null).await?;
3740                // Count this sink toward preroll only if it never took a real
3741                // preroll buffer (an empty stream). Without the guard a sink
3742                // that already prerolled double-decrements the shared counter
3743                // and completes the pipeline preroll prematurely.
3744                if !prerolled_self {
3745                    if let Some(sc) = &state {
3746                        sc.notify_prerolled();
3747                    }
3748                }
3749                return Ok(consumed);
3750            }
3751            Some(PipelinePacket::CapsChanged(new_caps)) => {
3752                // A sink that rejects the refined caps has no solution: no
3753                // runtime producer renegotiates its output caps, so a tee branch
3754                // (`FailLoud`) and a single-producer chain (`Reconfigure`) both
3755                // fail the run loud rather than flowing stale caps; `Drop` (a
3756                // tee) ends this branch while its siblings continue.
3757                let sink_caps =
3758                    match re_solve_downstream_dyn_sink(&new_caps, &*elem as &dyn DynAsyncElement) {
3759                        Ok(caps) => caps,
3760                        Err(failure) => match mode {
3761                            BranchMode::FailLoud | BranchMode::Reconfigure => {
3762                                report_runtime_caps_conflict(&new_caps);
3763                                report_nego_failure(bus.as_ref(), failure);
3764                                return Err(G2gError::CapsMismatch);
3765                            }
3766                            BranchMode::Drop => {
3767                                report_nego_failure(bus.as_ref(), failure);
3768                                return Ok(consumed);
3769                            }
3770                        },
3771                    };
3772                let instance = probe.as_deref().map(|p| p.name());
3773                log_caps_forward(instance, &new_caps, &sink_caps, true);
3774                match log_caps_rejected(instance, &sink_caps, elem.configure_pipeline(&sink_caps))?
3775                {
3776                    ConfigureOutcome::Accepted => {
3777                        let proposal = with_meta_demand(
3778                            elem.propose_allocation(&sink_caps),
3779                            elem.meta_requests(),
3780                        );
3781                        if let Some(p) = &proposal {
3782                            elem.configure_allocation(p);
3783                        }
3784                        coord
3785                            .report(Recascade {
3786                                node,
3787                                route: RecascadeRoute::Upstream,
3788                                proposal,
3789                            })
3790                            .await;
3791                        elem.process(PipelinePacket::CapsChanged(sink_caps), &mut null)
3792                            .await?;
3793                    }
3794                    ConfigureOutcome::ReFixate(counter) => {
3795                        in_rx.request_reconfigure(Reconfigure::Propose(counter));
3796                    }
3797                }
3798            }
3799            // M360: the `Flush` ends the re-preroll drain; the next (post-flush)
3800            // DataFrame becomes the new visible preroll.
3801            Some(PipelinePacket::Flush) => {
3802                flushing = false;
3803                elem.process(PipelinePacket::Flush, &mut null).await?;
3804            }
3805            Some(packet) => {
3806                // M203: follow the segment and publish each buffer's stream-time
3807                // position, so an application POSITION poll is answerable. The
3808                // sink is the position authority, as in GStreamer (segment + last
3809                // buffer). Inspect before `process` moves the packet.
3810                match &packet {
3811                    PipelinePacket::Segment(seg) => current_segment = Some(*seg),
3812                    PipelinePacket::DataFrame(frame) => {
3813                        if let Some(p) = &progress {
3814                            let pts = frame.timing.pts_ns;
3815                            let pos = current_segment
3816                                .as_ref()
3817                                .and_then(|s| s.to_stream_time(pts))
3818                                .unwrap_or(pts);
3819                            p.set_position(pos);
3820                        }
3821                    }
3822                    _ => {}
3823                }
3824                let seq = match &packet {
3825                    PipelinePacket::DataFrame(f) => Some(f.sequence),
3826                    _ => None,
3827                };
3828                let is_buffer = seq.is_some();
3829                if is_buffer {
3830                    consumed += 1;
3831                }
3832                // M399: time the data-frame `process()` and sample input fill.
3833                let timed = probe.as_deref().filter(|_| is_buffer);
3834                let mut wait_ns = 0;
3835                if let Some(p) = timed {
3836                    p.record_fill(in_rx.fill_percent());
3837                    // Queue-residency of this frame on the input link (M684).
3838                    if let Some(t) = in_rx.pop_transit_ns() {
3839                        p.record_transit(t);
3840                        wait_ns = t;
3841                    }
3842                }
3843                // M882: sample the animated properties at this frame's PTS.
3844                apply_control(
3845                    control.as_ref(),
3846                    &mut *elem as &mut dyn DynAsyncElement,
3847                    &packet,
3848                    &probe,
3849                )?;
3850                let t0 = ElementProbe::mark();
3851                elem.process(packet, &mut null).await?;
3852                if let (Some(p), Some(seq)) = (timed, seq) {
3853                    let push_wait_ns = p.record_proc_since(t0);
3854                    // M851: the last hop of a frame's journey. A sink pushes
3855                    // nowhere, so the banked wait is always 0 here.
3856                    p.record_visit(seq, wait_ns, t0, push_wait_ns);
3857                }
3858                // M175 upstream QoS: a sink that dropped a late frame asks to
3859                // shed load; store its report on this sink's input link, where
3860                // the upstream transform relays it one hop further (or the source
3861                // observes it directly as `PushOutcome::Qos`).
3862                if let Some(qos) = elem.take_qos() {
3863                    in_rx.request_qos(qos);
3864                }
3865                // Keyframe-request / renegotiation a sink originates (WebRTC PLI);
3866                // store it on the input link, where the upstream encoder/transform
3867                // observes it as `PushOutcome::Reconfigure`.
3868                if let Some(reconf) = elem.take_reconfigure() {
3869                    in_rx.request_reconfigure(reconf);
3870                }
3871                // Target bitrate (WebRTC BWE) up the reverse channel to the encoder.
3872                if let Some(bps) = elem.take_bitrate() {
3873                    in_rx.request_bitrate(bps);
3874                }
3875                // M78: the first buffer in non-live `Paused` is this sink's
3876                // preroll frame; mark this arm prerolled so the gate flips to a
3877                // hold, and report it so the pipeline preroll aggregates toward
3878                // a single `AsyncDone`.
3879                if is_buffer && !prerolled_self {
3880                    prerolled_self = true;
3881                    if let Some(sc) = &state {
3882                        sc.notify_prerolled();
3883                    }
3884                }
3885            }
3886            None => return Ok(consumed),
3887        }
3888    }
3889}
3890
3891async fn tee_arm(
3892    in_rx: LinkReceiver,
3893    out_txs: Vec<LinkSender>,
3894    branch_drop: bool,
3895) -> Result<u64, G2gError> {
3896    let mut senders: Vec<SenderSink> = out_txs.into_iter().map(SenderSink::new).collect();
3897    loop {
3898        match in_rx.recv().await {
3899            Some(PipelinePacket::Eos) => {
3900                for s in senders.iter_mut() {
3901                    match s.push(PipelinePacket::Eos).await {
3902                        Ok(_) => {}
3903                        // A dropped branch (`AllowBranchDrop`) has closed its
3904                        // channel; skip it. Under `FailLoud` a closed branch is a
3905                        // genuine error and propagates.
3906                        Err(G2gError::Shutdown) if branch_drop => {}
3907                        Err(e) => return Err(e),
3908                    }
3909                }
3910                return Ok(0);
3911            }
3912            Some(packet) => {
3913                if branch_drop {
3914                    broadcast_drop_closed(&mut senders, packet).await?;
3915                    // Every branch has dropped: the fan-out has no consumers left,
3916                    // so this tee is done.
3917                    if senders.is_empty() {
3918                        return Ok(0);
3919                    }
3920                } else {
3921                    broadcast(&mut senders, packet).await?;
3922                }
3923            }
3924            None => return Ok(0),
3925        }
3926    }
3927}
3928
3929/// Broadcast like [`broadcast`], but a branch whose receiver has closed
3930/// (a dropped `AllowBranchDrop` branch) is removed from `senders` instead of
3931/// failing the fan-out. A genuine downstream error still surfaces through that
3932/// branch arm's own result, so swallowing the closed channel here is safe.
3933async fn broadcast_drop_closed(
3934    senders: &mut Vec<SenderSink>,
3935    mut packet: PipelinePacket,
3936) -> Result<(), G2gError> {
3937    if let PipelinePacket::DataFrame(frame) = &mut packet {
3938        frame.domain.make_shareable();
3939    }
3940    let mut dead: Vec<usize> = Vec::new();
3941    for (i, s) in senders.iter_mut().enumerate() {
3942        match s.push(try_clone_packet(&packet)?).await {
3943            Ok(_) => {}
3944            Err(G2gError::Shutdown) => dead.push(i),
3945            Err(e) => return Err(e),
3946        }
3947    }
3948    // Remove dead senders high-index-first so earlier indices stay valid.
3949    for &i in dead.iter().rev() {
3950        senders.remove(i);
3951    }
3952    Ok(())
3953}
3954
3955/// Everything a demux arm needs besides its element (M1009), the fan-out mirror
3956/// of [`TransformArmIo`]. Opaque on purpose: only the runner can build one, so
3957/// the `drive_demux_arm` hook stays implementable only through the blanket impl.
3958#[doc(hidden)]
3959#[allow(missing_debug_implementations)]
3960pub struct DemuxArmIo {
3961    pub(crate) in_rx: LinkReceiver,
3962    pub(crate) out_txs: Vec<LinkSender>,
3963    pub(crate) probe: Probe,
3964}
3965
3966/// The demux arm: drain the single input edge and let the routing element
3967/// dispatch each packet to a chosen output port (the transpose of `muxer_arm`).
3968/// Mirrors the `run_source_fanout` router loop: a packet goes to
3969/// `MultiOutputElement::process`, which calls `push_to(port, ..)`; on `Eos` the
3970/// element flushes first, then the arm closes every branch with its own `Eos`
3971/// (the runner owns the per-branch end, like the tee arm).
3972///
3973/// Monomorphized over the element type by the `drive_demux_arm` blanket hook
3974/// (M1009), so the per-packet `process` future is the element's own unboxed
3975/// state machine; see [`transform_arm`].
3976pub(crate) async fn demux_arm<E: MultiOutputElement>(
3977    mut demux: E,
3978    io: DemuxArmIo,
3979) -> Result<u64, G2gError> {
3980    let DemuxArmIo {
3981        in_rx,
3982        out_txs,
3983        probe,
3984    } = io;
3985    let branch_count = out_txs.len();
3986    let senders: Vec<SenderSink> = out_txs.into_iter().map(SenderSink::new).collect();
3987    let mut multi = MultiSenderSink::new(senders);
3988    multi.set_push_wait_probe(probe.clone());
3989    loop {
3990        match in_rx.recv().await {
3991            Some(PipelinePacket::Eos) => {
3992                demux.process(PipelinePacket::Eos, &mut multi).await?;
3993                for port in 0..branch_count {
3994                    multi.push_to(port, PipelinePacket::Eos).await?;
3995                }
3996                return Ok(0);
3997            }
3998            Some(packet) => {
3999                // M694: time the data-frame `process()` and sample input fill;
4000                // control packets are excluded so the histogram reflects real work.
4001                let timed = probe
4002                    .as_deref()
4003                    .filter(|_| matches!(&packet, PipelinePacket::DataFrame(_)));
4004                if let Some(p) = timed {
4005                    p.record_fill(in_rx.fill_percent());
4006                    if let Some(t) = in_rx.pop_transit_ns() {
4007                        p.record_transit(t);
4008                    }
4009                }
4010                let t0 = ElementProbe::mark();
4011                demux.process(packet, &mut multi).await?;
4012                if let Some(p) = timed {
4013                    p.record_proc_since(t0);
4014                }
4015            }
4016            None => return Ok(0),
4017        }
4018    }
4019}
4020
4021/// Send `packet` to every tee branch. The frame's memory is made shareable once
4022/// (a zero-copy refcount handle, M250), so the per-branch clones below are
4023/// refcount bumps, not deep copies; the original is then moved into the last
4024/// branch. A fan-out of `n` makes zero byte copies of a `System` / GPU frame.
4025pub(crate) async fn broadcast(
4026    senders: &mut [SenderSink],
4027    mut packet: PipelinePacket,
4028) -> Result<(), G2gError> {
4029    if let PipelinePacket::DataFrame(frame) = &mut packet {
4030        frame.domain.make_shareable();
4031    }
4032    let last = senders.len() - 1;
4033    for s in senders[..last].iter_mut() {
4034        s.push(try_clone_packet(&packet)?).await?;
4035    }
4036    senders[last].push(packet).await?;
4037    Ok(())
4038}
4039
4040/// One muxer input: drain its edge and tag every packet with its pad index for
4041/// the muxer arm. A per-input `Eos` (or a closed edge) is tagged so the muxer
4042/// arm can aggregate the single merged `Eos`.
4043async fn muxer_forwarder(
4044    in_rx: LinkReceiver,
4045    tagged: Sender<PipelinePacket>,
4046) -> Result<u64, G2gError> {
4047    loop {
4048        match in_rx.recv().await {
4049            Some(PipelinePacket::Eos) | None => {
4050                tagged
4051                    .send(PipelinePacket::Eos)
4052                    .await
4053                    .map_err(|_| G2gError::Shutdown)?;
4054                return Ok(0);
4055            }
4056            Some(packet) => {
4057                tagged.send(packet).await.map_err(|_| G2gError::Shutdown)?;
4058            }
4059        }
4060    }
4061}
4062
4063/// A pad receiver a fan-in arm can round-robin over: either a muxer's per-pad
4064/// bounded [`Receiver`] or a terminal fan-in's input-edge [`LinkReceiver`]. Both
4065/// expose the same `RecvFuture`, so [`muxer_recv_any`] blocks on either kind.
4066trait PadReceiver {
4067    fn recv_packet(&self) -> RecvFuture<'_, PipelinePacket>;
4068}
4069
4070impl PadReceiver for Receiver<PipelinePacket> {
4071    fn recv_packet(&self) -> RecvFuture<'_, PipelinePacket> {
4072        self.recv()
4073    }
4074}
4075
4076impl PadReceiver for LinkReceiver {
4077    fn recv_packet(&self) -> RecvFuture<'_, PipelinePacket> {
4078        self.recv()
4079    }
4080}
4081
4082/// Block until some open input pad delivers a packet (or closes), scanning
4083/// round-robin from `start` so the wake-up path stays fair too. Returns the
4084/// pad's slot index (into `pad_rxs`) and its packet; `None` means that pad's
4085/// channel closed without an `Eos` (an upstream error), treated as an end. All
4086/// receivers register the same task waker, so a push on any one wakes us.
4087async fn muxer_recv_any<R: PadReceiver>(
4088    pad_rxs: &[(usize, R)],
4089    open: &[bool],
4090    start: usize,
4091) -> (usize, Option<PipelinePacket>) {
4092    let n = pad_rxs.len();
4093    core::future::poll_fn(|cx| {
4094        for k in 0..n {
4095            let slot = (start + k) % n;
4096            if !open[slot] {
4097                continue;
4098            }
4099            // `RecvFuture` holds only a `&Receiver`, so it is `Unpin`; polling it
4100            // parks our waker on that channel when pending.
4101            let mut f = pad_rxs[slot].1.recv_packet();
4102            if let core::task::Poll::Ready(v) = core::future::Future::poll(Pin::new(&mut f), cx) {
4103                return core::task::Poll::Ready((slot, v));
4104            }
4105        }
4106        core::task::Poll::Pending
4107    })
4108    .await
4109}
4110
4111/// Pair each of a terminal fan-in's input-edge receivers with its pad index, and
4112/// clone each pad's reverse-signal channel from the session before it moves into
4113/// the arm. `in_rxs` is in the node's `in_edges` order, so it aligns with the pad
4114/// indices read from those same edges.
4115fn fanin_sink_pads<'a>(
4116    vg: &ValidatedGraph<GraphNodeRef<'a>>,
4117    node: NodeId,
4118    in_rxs: Vec<LinkReceiver>,
4119    session: &dyn DynMultiInputElement,
4120) -> (Vec<(usize, LinkReceiver)>, Vec<Option<ReverseChannel>>) {
4121    let pads: Vec<usize> = vg
4122        .in_edges(node)
4123        .iter()
4124        .map(|&eid| vg.edge(eid).dst.index as usize)
4125        .collect();
4126    let reverse: Vec<Option<ReverseChannel>> = pads
4127        .iter()
4128        .map(|&pad| session.reverse_channel(pad))
4129        .collect();
4130    let pad_rxs: Vec<(usize, LinkReceiver)> = in_rxs
4131        .into_iter()
4132        .zip(pads)
4133        .map(|(rx, pad)| (pad, rx))
4134        .collect();
4135    (pad_rxs, reverse)
4136}
4137
4138/// Relay each terminal fan-in input's pending reverse signal (WebRTC PLI / BWE)
4139/// onto that pad's edge, so it reaches the upstream encoder as a [`PushOutcome`]
4140/// on its next push, the same one-hop route a linked sink's reverse channel takes.
4141/// `reverse` is aligned with `pad_rxs` by slot.
4142fn relay_reverse(pad_rxs: &[(usize, LinkReceiver)], reverse: &[Option<ReverseChannel>]) {
4143    for (slot, (_pad, in_rx)) in pad_rxs.iter().enumerate() {
4144        let Some(rc) = &reverse[slot] else { continue };
4145        match rc.take() {
4146            Some(PushOutcome::Reconfigure(r)) => in_rx.request_reconfigure(r),
4147            Some(PushOutcome::Bitrate(bps)) => in_rx.request_bitrate(bps),
4148            _ => {}
4149        }
4150    }
4151}
4152
4153/// Order a terminal fan-out source's output senders by PORT (out-edges arrive
4154/// in edge order; the port is each edge's `src.index`), wrapped as the
4155/// [`MultiOutputSink`] the source pushes into.
4156/// Order a demux node's output senders by each out-edge's source pad index, so
4157/// `push_to(port)` reaches the branch linked to that port. Out-edges arrive in
4158/// insertion order, which only matches the port order when the graph happened
4159/// to be linked port-first (launch lines do, builders need not).
4160fn demux_out_txs_by_port<'a>(
4161    vg: &ValidatedGraph<GraphNodeRef<'a>>,
4162    node: NodeId,
4163    out_txs: Vec<LinkSender>,
4164) -> Vec<LinkSender> {
4165    let mut indexed: Vec<(usize, LinkSender)> = vg
4166        .out_edges(node)
4167        .iter()
4168        .map(|&oe| vg.edge(oe).src.index as usize)
4169        .zip(out_txs)
4170        .collect();
4171    indexed.sort_by_key(|(port, _)| *port);
4172    indexed.into_iter().map(|(_, tx)| tx).collect()
4173}
4174
4175fn fanout_src_ports<'a>(
4176    vg: &ValidatedGraph<GraphNodeRef<'a>>,
4177    node: NodeId,
4178    out_txs: Vec<LinkSender>,
4179) -> MultiSenderSink {
4180    let mut by_port: Vec<Option<SenderSink>> = (0..out_txs.len()).map(|_| None).collect();
4181    for (tx, &oe) in out_txs.into_iter().zip(vg.out_edges(node)) {
4182        let port = vg.edge(oe).src.index as usize;
4183        if let Some(slot) = by_port.get_mut(port) {
4184            *slot = Some(SenderSink::new(tx));
4185        }
4186    }
4187    // D1 validation guarantees each declared port is linked exactly once.
4188    MultiSenderSink::new(
4189        by_port
4190            .into_iter()
4191            .map(|s| s.expect("validated: every fan-out port linked"))
4192            .collect(),
4193    )
4194}
4195
4196/// Drive a terminal fan-out source (a 0-in / N-out [`MultiOutputSource`], e.g.
4197/// a WebRTC session receiving several tracks) over the DAG's per-edge channels:
4198/// the element pushes to each port itself and owes every port an `Eos`
4199/// (its `run` contract), so the arm just runs it to completion.
4200async fn fanout_src_arm<'a>(
4201    mut source: Box<dyn DynMultiOutputSource + 'a>,
4202    mut sinks: MultiSenderSink,
4203) -> Result<u64, G2gError> {
4204    source.run(&mut sinks).await
4205}
4206
4207/// Everything a terminal fan-in arm needs besides its element (M1009).
4208#[doc(hidden)]
4209#[allow(missing_debug_implementations)]
4210pub struct FaninSinkArmIo {
4211    pub(crate) pad_rxs: Vec<(usize, LinkReceiver)>,
4212    pub(crate) reverse: Vec<Option<ReverseChannel>>,
4213    pub(crate) probe: Probe,
4214}
4215
4216/// Drive a terminal fan-in element (an N-input [`MultiInputElement`] with no
4217/// downstream output, e.g. a WebRTC session) the `run_fanin_session` way but over
4218/// the DAG's per-edge channels. Drains the input edges round-robin (fairness, so a
4219/// fast layer cannot starve a slow one), calls `session.process(pad, ..)` serially,
4220/// delivers a per-input `Eos` so the session can flush that track, and ends once
4221/// every input has ended. Per-input reverse signals (`session.reverse_channel(pad)`)
4222/// are relayed onto each pad's edge, so a per-layer PLI reaches the encoder feeding
4223/// it (`Reconfigure::ForceKeyframe` through that arm), the analog of the
4224/// `TaggingSink` reverse routing in the standalone fan-in session runner.
4225///
4226/// Monomorphized over the element type by the `drive_fanin_sink_arm` hook
4227/// (M1009), so the per-packet `process` future is unboxed; see [`transform_arm`].
4228pub(crate) async fn fanin_sink_arm<E: MultiInputElement>(
4229    mut session: E,
4230    io: FaninSinkArmIo,
4231) -> Result<u64, G2gError> {
4232    let FaninSinkArmIo {
4233        pad_rxs,
4234        reverse,
4235        probe,
4236    } = io;
4237    let mut null = NullSink;
4238    let input_count = pad_rxs.len();
4239    let mut open = alloc::vec![true; input_count];
4240    let mut ended = 0usize;
4241    let mut consumed = 0u64;
4242    let mut next = 0usize;
4243    loop {
4244        relay_reverse(&pad_rxs, &reverse);
4245        // Round-robin try-drain across the input edges, then block on any.
4246        let mut picked: Option<(usize, PipelinePacket)> = None;
4247        for k in 0..input_count {
4248            let slot = (next + k) % input_count;
4249            if !open[slot] {
4250                continue;
4251            }
4252            if let Some(pkt) = pad_rxs[slot].1.try_recv() {
4253                picked = Some((slot, pkt));
4254                next = (slot + 1) % input_count;
4255                break;
4256            }
4257        }
4258        let (slot, packet) = match picked {
4259            Some(p) => p,
4260            None => {
4261                if !open.iter().any(|&o| o) {
4262                    return Ok(consumed);
4263                }
4264                let (slot, maybe) = muxer_recv_any(&pad_rxs, &open, next).await;
4265                next = (slot + 1) % input_count;
4266                // A closed channel with no `Eos` is an upstream end.
4267                (slot, maybe.unwrap_or(PipelinePacket::Eos))
4268            }
4269        };
4270        let pad = pad_rxs[slot].0;
4271        match packet {
4272            PipelinePacket::Eos => {
4273                session.process(pad, PipelinePacket::Eos, &mut null).await?;
4274                open[slot] = false;
4275                ended += 1;
4276                if ended == input_count {
4277                    return Ok(consumed);
4278                }
4279            }
4280            PipelinePacket::CapsChanged(new_caps) => {
4281                // Mid-stream re-solve (M724): re-configure the changed pad
4282                // before the session sees the new caps. A counter-fixation
4283                // travels back up this pad's edge like a sink's would.
4284                let instance = probe.as_deref().map(|p| p.name());
4285                match log_caps_rejected(
4286                    instance,
4287                    &new_caps,
4288                    session.configure_pipeline(pad, &new_caps),
4289                )? {
4290                    ConfigureOutcome::Accepted => {
4291                        session
4292                            .process(pad, PipelinePacket::CapsChanged(new_caps), &mut null)
4293                            .await?;
4294                    }
4295                    ConfigureOutcome::ReFixate(counter) => {
4296                        pad_rxs[slot]
4297                            .1
4298                            .request_reconfigure(Reconfigure::Propose(counter));
4299                    }
4300                }
4301            }
4302            packet => {
4303                let is_frame = matches!(packet, PipelinePacket::DataFrame(_));
4304                let timed = probe.as_deref().filter(|_| is_frame);
4305                if let Some(p) = timed {
4306                    p.record_fill(pad_rxs[slot].1.fill_percent());
4307                }
4308                if is_frame {
4309                    consumed += 1;
4310                }
4311                let t0 = ElementProbe::mark();
4312                session.process(pad, packet, &mut null).await?;
4313                if let Some(p) = timed {
4314                    p.record_proc_since(t0);
4315                }
4316                // The session may have raised a reverse signal for this track
4317                // during `process`; relay it up its pad now.
4318                relay_reverse(&pad_rxs, &reverse);
4319            }
4320        }
4321    }
4322}
4323
4324/// Maximum times a muxer may re-derive its merged-output allocation while
4325/// settling one mid-stream change (M839). Each round re-derives the output pool,
4326/// absorbs it, and re-cascades the input pads whose demand moved. Well-behaved
4327/// elements settle in a couple of rounds (the last confirms nothing moved); a
4328/// mutually-constraining pair of pads that keeps flipping the output never
4329/// settles, so this stops it with `AllocationConflict` instead of looping
4330/// forever. Reset on each new stimulus (a pad `CapsChanged`, a consumer demand).
4331const MAX_RECASCADE_ROUNDS: u32 = 8;
4332
4333/// One input pad's β state inside a muxer arm.
4334#[derive(Debug)]
4335struct MuxPad {
4336    /// This slot's pad index on the element (`pad_rxs` order).
4337    pad: usize,
4338    /// The arm feeding this pad's branch, `None` when a source feeds it directly
4339    /// (nothing interruptible to re-cascade into).
4340    upstream: Option<NodeId>,
4341    /// The pad's negotiated caps, refreshed by the MX-1 re-solve.
4342    caps: Caps,
4343    /// The demand last cascaded up this branch, so an unchanged one is not
4344    /// re-sent.
4345    alloc: Option<AllocationParams>,
4346}
4347
4348/// M839: walks a β allocation change *through* a multi-input boundary.
4349///
4350/// A muxer is where the node-keyed walk used to stop: a change arriving on one
4351/// input pad has no single continuation, since the merged output is derived from
4352/// every pad and any pool it settles on can constrain the others. This carries the
4353/// per-pad state that gives it one. A change on a pad (a mid-stream `CapsChanged`,
4354/// or a consumer's demand re-cascading into the output) moves that pad's demand;
4355/// the merged-output pool is then re-derived from all pads, absorbed by the
4356/// element, and pushed to the consumer arms; the pads are re-queried and only
4357/// those whose demand actually moved re-cascade up their own branch.
4358///
4359/// Convergence: the output derivation is walked to a fixed point, and `rounds`
4360/// bounds it at [`MAX_RECASCADE_ROUNDS`] per stimulus. A well-behaved element
4361/// settles in a couple of rounds (the last confirms nothing moved); a mutually-
4362/// constraining pair of pads that keeps flipping the output fails loud with
4363/// `AllocationConflict` instead of looping forever.
4364#[derive(Debug)]
4365pub(crate) struct MuxBeta {
4366    node: NodeId,
4367    coord: GraphCoordHandle,
4368    pads: Vec<MuxPad>,
4369    /// Arms reading the merged output, resolved through structural tees.
4370    consumers: Vec<NodeId>,
4371    /// The pool last derived for the merged output.
4372    out_alloc: Option<AllocationParams>,
4373    /// Output re-derivations since the last new stimulus.
4374    rounds: u32,
4375}
4376
4377impl MuxBeta {
4378    /// Baseline from the startup solve: what the muxer proposes per pad (exactly
4379    /// what the reverse-topo cascade stored on each input edge) and for its
4380    /// output, so the first mid-stream round re-cascades only what moved.
4381    fn seed(&mut self, mux: &(dyn DynMultiInputElement + '_), out_caps: &Caps) {
4382        for p in &mut self.pads {
4383            p.alloc = mux.propose_allocation_for_input(p.pad, &p.caps);
4384        }
4385        self.out_alloc = mux.propose_allocation_for_output(out_caps);
4386    }
4387
4388    /// A mid-stream `CapsChanged` fixated new caps on one pad.
4389    async fn pad_changed<'m>(
4390        &mut self,
4391        mux: &'m mut (dyn DynMultiInputElement + 'm),
4392        slot: usize,
4393        caps: Caps,
4394        out_caps: &Caps,
4395    ) -> Result<(), G2gError> {
4396        self.pads[slot].caps = caps;
4397        self.rounds = 0;
4398        self.refresh_pads(mux).await;
4399        self.settle_output(mux, out_caps).await
4400    }
4401
4402    /// A consumer's demand re-cascaded into the merged output: absorb it, then
4403    /// carry it across the boundary onto the input pads.
4404    async fn output_demand<'m>(
4405        &mut self,
4406        mux: &'m mut (dyn DynMultiInputElement + 'm),
4407        params: AllocationParams,
4408        out_caps: &Caps,
4409    ) -> Result<(), G2gError> {
4410        self.rounds = 0;
4411        mux.configure_allocation_for_output(&params);
4412        self.refresh_pads(mux).await;
4413        self.settle_output(mux, out_caps).await
4414    }
4415
4416    /// Re-query every pad's demand and re-cascade the ones that moved, so an
4417    /// input the change does not reach keeps the allocation it already has.
4418    async fn refresh_pads(&mut self, mux: &(dyn DynMultiInputElement + '_)) {
4419        for i in 0..self.pads.len() {
4420            let want = mux.propose_allocation_for_input(self.pads[i].pad, &self.pads[i].caps);
4421            if want == self.pads[i].alloc {
4422                continue;
4423            }
4424            self.pads[i].alloc = want;
4425            if let (Some(target), Some(p)) = (self.pads[i].upstream, want) {
4426                self.coord
4427                    .report(Recascade {
4428                        node: self.node,
4429                        route: RecascadeRoute::Pad(target),
4430                        proposal: Some(p),
4431                    })
4432                    .await;
4433            }
4434        }
4435    }
4436
4437    /// Re-derive the merged-output pool until it stops moving, absorbing each
4438    /// answer (so the pads can react to it) and telling the consumer arms.
4439    async fn settle_output<'m>(
4440        &mut self,
4441        mux: &'m mut (dyn DynMultiInputElement + 'm),
4442        out_caps: &Caps,
4443    ) -> Result<(), G2gError> {
4444        loop {
4445            let derived = mux.propose_allocation_for_output(out_caps);
4446            if derived == self.out_alloc {
4447                return Ok(());
4448            }
4449            self.rounds += 1;
4450            if self.rounds > MAX_RECASCADE_ROUNDS {
4451                return Err(G2gError::AllocationConflict);
4452            }
4453            self.out_alloc = derived;
4454            if let Some(p) = derived {
4455                mux.configure_allocation_for_output(&p);
4456                for &c in &self.consumers {
4457                    self.coord
4458                        .report(Recascade {
4459                            node: self.node,
4460                            route: RecascadeRoute::Consumer(c),
4461                            proposal: Some(p),
4462                        })
4463                        .await;
4464                }
4465            }
4466            self.refresh_pads(mux).await;
4467        }
4468    }
4469}
4470
4471/// Apply a β directive that reached a muxer arm (M839): a consumer's demand on
4472/// the merged output, which crosses the boundary onto the input pads. A muxer is
4473/// never a `ProducerAllocation` target, since that notice names one input pad and
4474/// the downstream-facing route cannot express which.
4475async fn apply_mux_directive<'m>(
4476    mux: &'m mut (dyn DynMultiInputElement + 'm),
4477    beta: &mut MuxBeta,
4478    directive: ArmDirective,
4479    out_caps: &Caps,
4480) -> Result<(), G2gError> {
4481    match directive {
4482        ArmDirective::Recascade(p) => beta.output_demand(mux, p, out_caps).await,
4483        ArmDirective::ProducerAllocation(_) => Ok(()),
4484    }
4485}
4486
4487/// A fan-in arm's deadline tick (M875): the clock to sleep on, the period the
4488/// element declared via
4489/// [`tick_interval_ns`](DynMultiInputElement::tick_interval_ns), and the next
4490/// deadline. Both [`muxer_arm`] and [`muxer_arm_pts`] drive it the same way, so
4491/// they share it: [`fire_if_due`](Self::fire_if_due) once per loop iteration for
4492/// the busy case, [`park_or_tick`] for the parked one.
4493struct ArmTick<'a> {
4494    clock: &'a dyn DynAsyncClock,
4495    period_ns: u64,
4496    next_ns: u64,
4497}
4498
4499impl<'a> ArmTick<'a> {
4500    /// Ticking needs both halves: a clock to sleep on and an element that asked.
4501    fn new(
4502        ticker: Option<&'a dyn DynAsyncClock>,
4503        mux: &(dyn DynMultiInputElement + '_),
4504    ) -> Option<Self> {
4505        let (clock, period_ns) = ticker.zip(mux.tick_interval_ns())?;
4506        Some(Self {
4507            clock,
4508            period_ns,
4509            next_ns: clock.now_ns().saturating_add(period_ns),
4510        })
4511    }
4512
4513    /// Deliver one tick, then snap the next deadline forward from now rather than
4514    /// adding a period to a deadline already in the past, so a slow element does
4515    /// not build up a backlog of ticks to fire.
4516    async fn fire<E: MultiInputElement>(
4517        &mut self,
4518        mux: &mut E,
4519        out: &mut dyn OutputSink,
4520    ) -> Result<(), G2gError> {
4521        mux.process(0, PipelinePacket::Tick, out).await?;
4522        self.next_ns = self.clock.now_ns().saturating_add(self.period_ns);
4523        Ok(())
4524    }
4525
4526    /// The busy path: a live pad can spin an arm's drain loop forever without
4527    /// ever parking below, so the deadline is checked per iteration too.
4528    async fn fire_if_due<E: MultiInputElement>(
4529        &mut self,
4530        mux: &mut E,
4531        out: &mut dyn OutputSink,
4532    ) -> Result<(), G2gError> {
4533        if self.clock.now_ns() >= self.next_ns {
4534            self.fire(mux, out).await?;
4535        }
4536        Ok(())
4537    }
4538}
4539
4540/// Race an arm's parked wait (data + β control) against its tick deadline, so a
4541/// fan-in whose inputs have all gone quiet still gets its tick. `None` means the
4542/// deadline won and a tick fired, so the caller loops. Data and control stay
4543/// biased ahead of the tick: a packet already waiting is handled first.
4544async fn park_or_tick<T, E: MultiInputElement>(
4545    park: impl core::future::Future<Output = T>,
4546    tick: Option<&mut ArmTick<'_>>,
4547    mux: &mut E,
4548    out: &mut dyn OutputSink,
4549) -> Result<Option<T>, G2gError> {
4550    let Some(tick) = tick else {
4551        return Ok(Some(park.await));
4552    };
4553    // The sleep rides the copied clock reference, not a borrow of `tick`, so
4554    // `fire` can still take it mutably below.
4555    let sleep = tick.clock.sleep_until_ns(tick.next_ns);
4556    match select2(park, sleep).await {
4557        Either::Left(v) => Ok(Some(v)),
4558        Either::Right(()) => {
4559            tick.fire(mux, out).await?;
4560            Ok(None)
4561        }
4562    }
4563}
4564
4565/// Everything a muxer arm needs besides its element and its ticker (M1009).
4566/// Split from the ticker so the cooperative arm (which borrows the graph's
4567/// clock) and the thread-per-arm one (which owns a shared handle) carry the
4568/// same payload.
4569#[allow(missing_debug_implementations)]
4570pub(crate) struct MuxerArmParts {
4571    pub(crate) pad_rxs: Vec<(usize, Receiver<PipelinePacket>)>,
4572    pub(crate) out_tx: LinkSender,
4573    pub(crate) input_count: usize,
4574    pub(crate) current_output: Caps,
4575    pub(crate) beta: MuxBeta,
4576    pub(crate) arm_rx: Receiver<ArmDirective>,
4577    pub(crate) probe: Probe,
4578    pub(crate) control: Option<ArmController>,
4579}
4580
4581/// A muxer arm's input, as the cooperative runner builds it. Opaque on purpose,
4582/// like [`TransformArmIo`]: only the runner can build one, so `drive_muxer_arm`
4583/// stays implementable only through the blanket impl.
4584#[doc(hidden)]
4585#[allow(missing_debug_implementations)]
4586pub struct MuxerArmIo<'a> {
4587    pub(crate) parts: MuxerArmParts,
4588    pub(crate) ticker: Option<&'a dyn DynAsyncClock>,
4589}
4590
4591/// As [`MuxerArmIo`], for the thread-per-arm runner: a builder closure that
4592/// crosses onto a worker thread must own everything it carries, so the ticker
4593/// is the shared handle rather than a borrow (M879).
4594#[cfg(all(feature = "std", feature = "multi-thread"))]
4595#[doc(hidden)]
4596#[allow(missing_debug_implementations)]
4597pub struct MuxerArmOwnedTickIo {
4598    pub(crate) parts: MuxerArmParts,
4599    pub(crate) ticker: Option<alloc::sync::Arc<dyn DynAsyncClock + Send + Sync>>,
4600}
4601
4602/// The muxer arm: drain the per-input channels round-robin, combine each input's
4603/// packets via `process(pad, ..)`, and emit a single `Eos` once every input has
4604/// ended. Round-robin draining keeps a fast input from starving a slow one (a
4605/// frozen overlay and a hung EOS aggregation). The per-input `Eos` is delivered
4606/// to the element first (so a stateful muxer can flush) but the element must not
4607/// forward it; the runner owns the merged one.
4608///
4609/// M875: when `ticker` is set and the element declares a
4610/// [`tick_interval_ns`](DynMultiInputElement::tick_interval_ns), the arm also
4611/// delivers [`PipelinePacket::Tick`] on that period, so a fan-in element whose
4612/// output cadence is its own (zero-order-hold over a stalled pad) emits without a
4613/// packet arriving. The deadline is checked on both paths: once per loop iteration
4614/// for the busy case (packets flowing on one pad while another is stalled never
4615/// park the arm), and raced against the parked `recv` otherwise.
4616///
4617/// Monomorphized over the element type by the `drive_muxer_arm` blanket hook
4618/// (M1009), so the per-packet `process` future is the element's own unboxed
4619/// state machine; see [`transform_arm`].
4620pub(crate) async fn muxer_arm<E: MultiInputElement>(
4621    mut mux: E,
4622    io: MuxerArmIo<'_>,
4623) -> Result<u64, G2gError> {
4624    let MuxerArmIo {
4625        parts:
4626            MuxerArmParts {
4627                pad_rxs,
4628                out_tx,
4629                input_count,
4630                mut current_output,
4631                mut beta,
4632                arm_rx,
4633                probe,
4634                control,
4635            },
4636        ticker,
4637    } = io;
4638    let mut adapter = SenderSink::new(out_tx);
4639    adapter.set_push_wait_probe(probe.clone());
4640    let mut open = alloc::vec![true; input_count];
4641    let mut ended = 0usize;
4642    // Cursor for round-robin fairness across both the try-drain and block paths.
4643    let mut next = 0usize;
4644    let mut control_open = true;
4645    let mut tick = ArmTick::new(ticker, &mux as &dyn DynMultiInputElement);
4646    beta.seed(&mux as &dyn DynMultiInputElement, &current_output);
4647    loop {
4648        // M839: a consumer's demand on the merged output arrives here. Checked
4649        // before the data drain so a busy muxer still applies it promptly.
4650        while let Some(directive) = arm_rx.try_recv() {
4651            apply_mux_directive(
4652                &mut mux as &mut dyn DynMultiInputElement,
4653                &mut beta,
4654                directive,
4655                &current_output,
4656            )
4657            .await?;
4658        }
4659        // Deadline reached while packets keep this arm busy (a live overlay pad
4660        // spinning the drain loop below without ever parking).
4661        if let Some(t) = tick.as_mut() {
4662            t.fire_if_due(&mut mux, &mut adapter).await?;
4663        }
4664        // Take one buffered packet, scanning round-robin from `next`, so no
4665        // single input can monopolize the muxer while others have data waiting.
4666        let mut picked: Option<(usize, PipelinePacket)> = None;
4667        for k in 0..input_count {
4668            let slot = (next + k) % input_count;
4669            if !open[slot] {
4670                continue;
4671            }
4672            if let Some(pkt) = pad_rxs[slot].1.try_recv() {
4673                picked = Some((slot, pkt));
4674                next = (slot + 1) % input_count;
4675                break;
4676            }
4677        }
4678        let (slot, packet) = match picked {
4679            Some(p) => p,
4680            None => {
4681                if !open.iter().any(|&o| o) {
4682                    return Ok(0);
4683                }
4684                // Race the β control channel against the pads, so a directive
4685                // reaches this arm while it is parked waiting for data.
4686                let park = async {
4687                    if control_open {
4688                        select2(arm_rx.recv(), muxer_recv_any(&pad_rxs, &open, next)).await
4689                    } else {
4690                        Either::Right(muxer_recv_any(&pad_rxs, &open, next).await)
4691                    }
4692                };
4693                // M875: the tick deadline joins the race; a fired tick loops.
4694                let Some(parked) =
4695                    park_or_tick(park, tick.as_mut(), &mut mux, &mut adapter).await?
4696                else {
4697                    continue;
4698                };
4699                let (slot, maybe) = match parked {
4700                    Either::Left(Some(directive)) => {
4701                        apply_mux_directive(
4702                            &mut mux as &mut dyn DynMultiInputElement,
4703                            &mut beta,
4704                            directive,
4705                            &current_output,
4706                        )
4707                        .await?;
4708                        continue;
4709                    }
4710                    Either::Left(None) => {
4711                        control_open = false;
4712                        continue;
4713                    }
4714                    Either::Right(v) => v,
4715                };
4716                next = (slot + 1) % input_count;
4717                // A closed channel with no `Eos` is an upstream end; fold it into
4718                // the same end-of-input path so aggregation still completes.
4719                (slot, maybe.unwrap_or(PipelinePacket::Eos))
4720            }
4721        };
4722        let pad = pad_rxs[slot].0;
4723        match packet {
4724            PipelinePacket::Eos => {
4725                mux.process(pad, PipelinePacket::Eos, &mut adapter).await?;
4726                open[slot] = false;
4727                ended += 1;
4728                if ended == input_count {
4729                    adapter.push(PipelinePacket::Eos).await?;
4730                    return Ok(0);
4731                }
4732            }
4733            PipelinePacket::CapsChanged(new_caps) => {
4734                // MX-1: re-solve this input against its pad constraint and
4735                // reconfigure the pad; the input-side `CapsChanged` is consumed,
4736                // not forwarded as if it were the merged output.
4737                let input_caps =
4738                    solve_mux_input_dyn(&new_caps, &mux as &dyn DynMultiInputElement, pad)?;
4739                log_caps_rejected(
4740                    probe.as_deref().map(|p| p.name()),
4741                    &input_caps,
4742                    mux.configure_pipeline(pad, &input_caps),
4743                )?
4744                .reject_refixate()?;
4745                // MX-2: the per-input change may shift the merged output. Emit one
4746                // downstream `CapsChanged` only when it actually changed.
4747                let new_output = solve_mux_output_dyn(&mux as &dyn DynMultiInputElement)?;
4748                if new_output != current_output {
4749                    current_output = new_output.clone();
4750                    adapter
4751                        .push(PipelinePacket::CapsChanged(new_output))
4752                        .await?;
4753                }
4754                // MX-1β / M839: walk the allocation change through this boundary.
4755                beta.pad_changed(
4756                    &mut mux as &mut dyn DynMultiInputElement,
4757                    slot,
4758                    input_caps,
4759                    &current_output,
4760                )
4761                .await?;
4762            }
4763            packet => {
4764                // M694: time the data-frame `process()` and sample this pad's
4765                // input fill; the per-pad channel is a plain `Receiver`, so it has
4766                // no transit ring (transit stays empty for muxer pads).
4767                let timed = probe
4768                    .as_deref()
4769                    .filter(|_| matches!(&packet, PipelinePacket::DataFrame(_)));
4770                if let Some(p) = timed {
4771                    p.record_fill(pad_rxs[slot].1.fill_percent());
4772                }
4773                // M882: sample the animated properties at this frame's PTS.
4774                apply_control(
4775                    control.as_ref(),
4776                    &mut mux as &mut dyn DynMultiInputElement,
4777                    &packet,
4778                    &probe,
4779                )?;
4780                let t0 = ElementProbe::mark();
4781                mux.process(pad, packet, &mut adapter).await?;
4782                if let Some(p) = timed {
4783                    p.record_proc_since(t0);
4784                }
4785            }
4786        }
4787    }
4788}
4789
4790/// The PTS-ordered muxer arm (the opt-in alternative to [`muxer_arm`], selected
4791/// by [`DynMultiInputElement::input_pts_ordered`]): buffer each input's
4792/// `DataFrame`s in an [`InputAggregator`] and release the globally-earliest-PTS
4793/// one only once every still-open input has a head queued, so `process(pad, ..)`
4794/// sees frames in non-decreasing PTS across all pads. The runner does the
4795/// time-ordered interleave a multi-camera grid / PTS-synchronized compositor
4796/// would otherwise hand-roll. `Eos` (per-input flush + aggregation) and
4797/// `CapsChanged` (MX-1 / MX-2) are handled as in [`muxer_arm`]; only `DataFrame`s
4798/// are reordered.
4799///
4800/// The deadline tick works as in [`muxer_arm`] (busy check per iteration plus the
4801/// parked race, via [`ArmTick`]), except that it comes after the merged-`Eos`
4802/// exit and after the aggregator's release round: an arm on its way out ticks no
4803/// more, and a tick never jumps ahead of a frame already safe to emit in PTS
4804/// order.
4805///
4806/// Monomorphized over the element type like [`muxer_arm`] (M1009).
4807pub(crate) async fn muxer_arm_pts<E: MultiInputElement>(
4808    mut mux: E,
4809    io: MuxerArmIo<'_>,
4810) -> Result<u64, G2gError> {
4811    let MuxerArmIo {
4812        parts:
4813            MuxerArmParts {
4814                pad_rxs,
4815                out_tx,
4816                input_count,
4817                mut current_output,
4818                mut beta,
4819                arm_rx,
4820                probe,
4821                control,
4822            },
4823        ticker,
4824    } = io;
4825    let mut adapter = SenderSink::new(out_tx);
4826    adapter.set_push_wait_probe(probe.clone());
4827    let mut open = alloc::vec![true; input_count];
4828    let mut agg: InputAggregator<Frame> = InputAggregator::new(input_count);
4829    // Round-robin wake cursor, so a fast input does not bias the block path.
4830    let mut next = 0usize;
4831    let mut control_open = true;
4832    let mut tick = ArmTick::new(ticker, &mux as &dyn DynMultiInputElement);
4833    beta.seed(&mux as &dyn DynMultiInputElement, &current_output);
4834    loop {
4835        // Release every frame now safe to emit, in global PTS order: the
4836        // aggregator yields the earliest only once every still-contributing input
4837        // has a head, so no later input can still deliver something earlier.
4838        while let Some((slot, frame)) = agg.take_earliest_by(|f| f.timing.pts_ns) {
4839            let pad = pad_rxs[slot].0;
4840            // M694: released frames are all DataFrames; time each `process()` and
4841            // sample this pad's input fill (plain `Receiver`, so no transit).
4842            if let Some(p) = probe.as_deref() {
4843                p.record_fill(pad_rxs[slot].1.fill_percent());
4844            }
4845            // M882: sampled in release (PTS) order, so the animation follows the
4846            // ordered stream rather than pad arrival.
4847            apply_control_at(
4848                control.as_ref(),
4849                &mut mux as &mut dyn DynMultiInputElement,
4850                frame.timing.pts_ns,
4851                &probe,
4852            )?;
4853            let t0 = ElementProbe::mark();
4854            mux.process(pad, PipelinePacket::DataFrame(frame), &mut adapter)
4855                .await?;
4856            if let Some(p) = probe.as_deref() {
4857                p.record_proc_since(t0);
4858            }
4859        }
4860        // Once every input has ended, the loop above has drained the aggregator
4861        // (ended+empty inputs drop out of the round); emit the single merged Eos.
4862        if !open.iter().any(|&o| o) {
4863            adapter.push(PipelinePacket::Eos).await?;
4864            return Ok(0);
4865        }
4866        // Deadline reached while frames keep arriving on one pad (the arm never
4867        // parks below).
4868        if let Some(t) = tick.as_mut() {
4869            t.fire_if_due(&mut mux, &mut adapter).await?;
4870        }
4871        // Make progress: block for the next packet from any still-open input,
4872        // racing the β control channel (M839) so a consumer's demand on the
4873        // merged output reaches this arm while it waits, and the tick deadline so
4874        // a quiet fan-in still gets its tick.
4875        let park = async {
4876            if control_open {
4877                select2(arm_rx.recv(), muxer_recv_any(&pad_rxs, &open, next)).await
4878            } else {
4879                Either::Right(muxer_recv_any(&pad_rxs, &open, next).await)
4880            }
4881        };
4882        let Some(parked) = park_or_tick(park, tick.as_mut(), &mut mux, &mut adapter).await? else {
4883            continue;
4884        };
4885        let (slot, maybe) = match parked {
4886            Either::Left(Some(directive)) => {
4887                apply_mux_directive(
4888                    &mut mux as &mut dyn DynMultiInputElement,
4889                    &mut beta,
4890                    directive,
4891                    &current_output,
4892                )
4893                .await?;
4894                continue;
4895            }
4896            Either::Left(None) => {
4897                control_open = false;
4898                continue;
4899            }
4900            Either::Right(v) => v,
4901        };
4902        next = (slot + 1) % input_count;
4903        let pad = pad_rxs[slot].0;
4904        // A closed channel with no `Eos` is an upstream end (as in `muxer_arm`).
4905        match maybe.unwrap_or(PipelinePacket::Eos) {
4906            PipelinePacket::DataFrame(frame) => agg.push(slot, frame),
4907            PipelinePacket::Eos => {
4908                mux.process(pad, PipelinePacket::Eos, &mut adapter).await?;
4909                open[slot] = false;
4910                agg.mark_ended(slot);
4911            }
4912            PipelinePacket::CapsChanged(new_caps) => {
4913                // MX-1 / MX-2 / MX-1β, identical to `muxer_arm`: re-solve this
4914                // input's pad, emit one downstream `CapsChanged` only when the
4915                // merged output actually shifts, and walk the allocation change
4916                // through the boundary.
4917                let input_caps =
4918                    solve_mux_input_dyn(&new_caps, &mux as &dyn DynMultiInputElement, pad)?;
4919                log_caps_rejected(
4920                    probe.as_deref().map(|p| p.name()),
4921                    &input_caps,
4922                    mux.configure_pipeline(pad, &input_caps),
4923                )?
4924                .reject_refixate()?;
4925                let new_output = solve_mux_output_dyn(&mux as &dyn DynMultiInputElement)?;
4926                if new_output != current_output {
4927                    current_output = new_output.clone();
4928                    adapter
4929                        .push(PipelinePacket::CapsChanged(new_output))
4930                        .await?;
4931                }
4932                beta.pad_changed(
4933                    &mut mux as &mut dyn DynMultiInputElement,
4934                    slot,
4935                    input_caps,
4936                    &current_output,
4937                )
4938                .await?;
4939            }
4940            // Every other packet reaches the element, as on the single-input
4941            // path and in `muxer_arm`: `Segment` and `Flush` are per-input
4942            // control a fan-in has to see. A fan-in that swallows the segment
4943            // leaves a paced sink downstream with no running-time mapping (the
4944            // compositor forwards its timing input's; a muxer whose timestamps
4945            // are already container-mapped ignores it).
4946            packet => {
4947                mux.process(pad, packet, &mut adapter).await?;
4948            }
4949        }
4950    }
4951}
4952
4953/// Owning-ticker shim for the thread-per-arm runner (M879): both fan-in arms take
4954/// their ticker as a borrow, but a builder closure that crosses onto a worker
4955/// thread must own everything it carries. This future owns the shared clock for its
4956/// whole life and lends it to the arm it drives, so the arms' signatures stay as
4957/// the cooperative runner needs them. [`MultiInputElement::input_pts_ordered`]
4958/// picks the arm, the same choice `drive_muxer_arm` makes on the cooperative path.
4959#[cfg(all(feature = "std", feature = "multi-thread"))]
4960pub(crate) async fn muxer_arm_owned_tick<E: MultiInputElement>(
4961    mux: E,
4962    io: MuxerArmOwnedTickIo,
4963) -> Result<u64, G2gError> {
4964    let MuxerArmOwnedTickIo { parts, ticker } = io;
4965    let tick: Option<&dyn DynAsyncClock> = ticker.as_deref().map(|c| c as &dyn DynAsyncClock);
4966    let io = MuxerArmIo {
4967        parts,
4968        ticker: tick,
4969    };
4970    if MultiInputElement::input_pts_ordered(&mux) {
4971        muxer_arm_pts(mux, io).await
4972    } else {
4973        muxer_arm(mux, io).await
4974    }
4975}
4976
4977/// Clone a packet for a tee branch (M213, M250). Control packets clone trivially;
4978/// a data frame's memory is shared via [`MemoryDomain::share`]: a zero-copy
4979/// refcount bump for the GPU domains, the shared-CPU `SystemView`, and (once
4980/// `broadcast` has called `make_shareable`) owned-CPU `System` bytes too. So a
4981/// GPU-decoded or CPU frame fans out to several consumers (eg inference +
4982/// display) with no copy, where `System` previously deep-copied per branch and a
4983/// GPU frame failed loud.
4984pub(crate) fn try_clone_packet(packet: &PipelinePacket) -> Result<PipelinePacket, G2gError> {
4985    Ok(match packet {
4986        PipelinePacket::CapsChanged(caps) => PipelinePacket::CapsChanged(caps.clone()),
4987        PipelinePacket::Eos => PipelinePacket::Eos,
4988        PipelinePacket::Flush => PipelinePacket::Flush,
4989        PipelinePacket::Segment(seg) => PipelinePacket::Segment(*seg),
4990        // Tee clone: shares the buffer where the domain allows (GPU handles /
4991        // pre-shared System bytes refcount, owned CPU bytes deep-copy) and shares
4992        // per-frame metadata by Arc refcount with copy-on-write on mutation, so a
4993        // detector branch and a video branch carry the same AnalyticsMeta without
4994        // aliasing. The frame-level fan-out primitive.
4995        PipelinePacket::DataFrame(frame) => PipelinePacket::DataFrame(frame.share()),
4996        // Runner-internal (a fan-in arm's deadline), so it is never on a link to
4997        // begin with; trivially cloneable all the same.
4998        PipelinePacket::Tick => PipelinePacket::Tick,
4999    })
5000}
5001
5002#[cfg(test)]
5003mod tests {
5004    use super::*;
5005    use crate::frame::FrameTiming;
5006    use crate::memory::{MemoryDomain, SystemSlice};
5007
5008    fn system_frame(bytes: &[u8], seq: u64) -> PipelinePacket {
5009        PipelinePacket::DataFrame(Frame {
5010            domain: MemoryDomain::System(SystemSlice::from_boxed(
5011                bytes.to_vec().into_boxed_slice(),
5012            )),
5013            timing: FrameTiming {
5014                pts_ns: 7,
5015                ..FrameTiming::default()
5016            },
5017            sequence: seq,
5018            meta: Default::default(),
5019        })
5020    }
5021
5022    #[test]
5023    fn buffering_bucket_bands_fill_into_quartiles() {
5024        // Empty, quarter steps, and full map to distinct bands; values within a
5025        // band collapse so the sink posts only on a level transition.
5026        assert_eq!(buffering_bucket(0), 0);
5027        assert_eq!(buffering_bucket(24), 0);
5028        assert_eq!(buffering_bucket(25), 1);
5029        assert_eq!(buffering_bucket(50), 2);
5030        assert_eq!(buffering_bucket(75), 3);
5031        assert_eq!(buffering_bucket(100), 4);
5032    }
5033
5034    #[test]
5035    fn clones_system_frame_bytes_and_timing() {
5036        let original = system_frame(&[1, 2, 3, 4], 9);
5037        let cloned = try_clone_packet(&original).expect("system frame clones");
5038        let (PipelinePacket::DataFrame(a), PipelinePacket::DataFrame(b)) = (&original, &cloned)
5039        else {
5040            panic!("expected data frames");
5041        };
5042        let Some(sa) = a.domain.as_system_slice() else {
5043            panic!()
5044        };
5045        let Some(sb) = b.domain.as_system_slice() else {
5046            panic!()
5047        };
5048        assert_eq!(sa, sb, "bytes copied");
5049        assert_ne!(sb.as_ptr(), sa.as_ptr(), "distinct allocation");
5050        assert_eq!(b.timing, a.timing);
5051        assert_eq!(b.sequence, 9);
5052    }
5053
5054    #[test]
5055    fn clones_control_packets() {
5056        assert!(matches!(
5057            try_clone_packet(&PipelinePacket::Eos),
5058            Ok(PipelinePacket::Eos)
5059        ));
5060        assert!(matches!(
5061            try_clone_packet(&PipelinePacket::Flush),
5062            Ok(PipelinePacket::Flush)
5063        ));
5064    }
5065
5066    #[cfg(feature = "metadata")]
5067    #[test]
5068    fn tee_clone_carries_analytics_meta() {
5069        use crate::meta::{AnalyticsMeta, BBox, ObjectDetection};
5070        // A detector attaches analytics; the tee clone must carry it onto the
5071        // sibling (video) branch so a downstream overlay can read it.
5072        let PipelinePacket::DataFrame(mut original) = system_frame(&[0, 0, 0, 0], 1) else {
5073            panic!("data frame");
5074        };
5075        let mut a = AnalyticsMeta::new();
5076        a.add_detection(ObjectDetection {
5077            bbox: BBox {
5078                x: 0.1,
5079                y: 0.1,
5080                w: 0.2,
5081                h: 0.2,
5082            },
5083            label: 5,
5084            confidence: 0.9,
5085        });
5086        original.meta.attach(a);
5087
5088        let cloned = try_clone_packet(&PipelinePacket::DataFrame(original)).expect("clone");
5089        let PipelinePacket::DataFrame(b) = cloned else {
5090            panic!("data frame")
5091        };
5092        let meta = b
5093            .meta
5094            .get::<AnalyticsMeta>()
5095            .expect("meta carried to tee branch");
5096        assert_eq!(meta.detections().count(), 1);
5097        assert_eq!(meta.detections().next().unwrap().label, 5);
5098    }
5099}