Skip to main content

g2g_core/runtime/
runner.rs

1#[cfg(feature = "std")]
2use alloc::boxed::Box;
3use core::future::Future;
4
5use crate::bus::BusHandle;
6use crate::caps::Caps;
7use crate::clock::{elect_clock, ClockCandidate, ClockPriority, ClockSync, PipelineClock};
8#[cfg(feature = "std")]
9use crate::element::BoxFuture;
10use crate::element::{
11    AsyncElement, ConfigureOutcome, ElementBound, OutputSink, OutputSinkExt, PushOutcome,
12    Reconfigure,
13};
14use crate::error::G2gError;
15use crate::format_element::{CapsConstraint, CapsPreferences};
16use crate::frame::PipelinePacket;
17use crate::memory::{DomainSet, MemoryDomainKind};
18use crate::property::{ElementMetadata, PropError, PropValue, PropertySpec};
19use crate::query::{AllocationParams, LatencyReport};
20use crate::runtime::channel::{advertise_orientation, link, ReconfigureAnswered, SenderSink};
21#[cfg(feature = "std")]
22use crate::runtime::coordinator::realloc_local_dyn;
23use crate::runtime::coordinator::{
24    coordinator_with_recascade, log_caps_forward, log_caps_rejected,
25    negotiate_source_transform_sink, realloc_local, report_nego_failure, solve_last_link,
26    CoordinatorEvent, MAX_FIXATION_ATTEMPTS,
27};
28use crate::runtime::instrument::ElementProbe;
29use crate::runtime::join::{select2, Either, Join2};
30use crate::runtime::solver::{
31    resolve_forward_output, solve_linear, ForwardResolve, NegotiationFailure,
32};
33use crate::runtime::state::{Flow, StateController};
34use crate::segment::Segment;
35
36/// Pick the most informative error from a pipeline's arm results. A closed-link
37/// `Shutdown` is usually the *consequence* of another arm erroring first (it
38/// dropped its channel end), so prefer any non-`Shutdown` error over it; fall
39/// back to the first error otherwise (M81). `None` if every arm succeeded.
40///
41/// Each result is paired with its arm's instance name, and the one that wins is
42/// logged naming that element, since `G2gError` carries no element identity.
43fn substantive_error<'a, I>(results: I) -> Option<G2gError>
44where
45    I: IntoIterator<Item = (&'a str, Option<&'a G2gError>)>,
46{
47    let mut first: Option<(&str, &G2gError)> = None;
48    for (name, e) in results {
49        let Some(e) = e else { continue };
50        if *e != G2gError::Shutdown {
51            crate::log::report_element_failure(Some(name), e);
52            return Some(e.clone());
53        }
54        first.get_or_insert((name, e));
55    }
56    let (name, e) = first?;
57    crate::log::report_element_failure(Some(name), e);
58    Some(e.clone())
59}
60
61#[cfg(feature = "std")]
62use crate::element::DynAsyncElement;
63#[cfg(feature = "std")]
64use crate::fanout::{MultiOutputElement, MultiOutputSinkExt, MultiOutputSource, MultiSenderSink};
65#[cfg(feature = "std")]
66use crate::graph::Graph;
67#[cfg(feature = "std")]
68use crate::runtime::channel::{bounded, Sender};
69#[cfg(feature = "std")]
70use crate::runtime::graph_runner::{broadcast, run_graph_inner, GraphNodeRef};
71#[cfg(feature = "std")]
72use crate::runtime::join::{dynamic_join, join_all};
73#[cfg(feature = "std")]
74use crate::runtime::observe::{link_tapped, register_runner_tap, TapEdge, TapNode};
75#[cfg(feature = "std")]
76use crate::runtime::{NodeRole, Observer, Probe};
77#[cfg(feature = "std")]
78use alloc::sync::Arc;
79#[cfg(feature = "std")]
80use alloc::vec::Vec;
81#[cfg(feature = "std")]
82use spin::Mutex;
83
84/// Source-side element trait. Sources have no input pad, so the packet-in /
85/// packet-out shape of [`AsyncElement`] does not fit them. A `SourceLoop`
86/// instead receives a single `run` call that iterates internally until EOS
87/// and returns the count of `DataFrame` packets pushed.
88pub trait SourceLoop: ElementBound {
89    type RunFuture<'a>: Future<Output = Result<u64, G2gError>> + 'a
90    where
91        Self: 'a;
92
93    /// Future returned by [`intercept_caps`]. Async so a source can perform
94    /// I/O during negotiation (e.g. RTSP DESCRIBE + SDP parse, hardware
95    /// capability probe). Sources that produce caps without I/O can return
96    /// [`core::future::Ready`] and the runner pays no cost.
97    type CapsFuture<'a>: Future<Output = Result<Caps, G2gError>> + 'a
98    where
99        Self: 'a;
100
101    /// Negotiation-time caps query. Awaited by the runner during startup
102    /// (and on re-fixate retries). `&mut self` because real implementations
103    /// (e.g. `RtspSrc`) open a session here and stash the connected state
104    /// for `run` to resume from. Synchronous sources just return
105    /// `core::future::ready(Ok(caps))`.
106    fn intercept_caps<'a>(&'a mut self) -> Self::CapsFuture<'a>;
107
108    fn configure_pipeline(&mut self, absolute_caps: &Caps) -> Result<ConfigureOutcome, G2gError>;
109
110    /// Runs the source until EOS or error. The implementation MUST emit a
111    /// final `PipelinePacket::Eos` before returning `Ok`. Returns the number
112    /// of `DataFrame` packets pushed (excluding `Eos`).
113    fn run<'a>(&'a mut self, out: &'a mut dyn OutputSink) -> Self::RunFuture<'a>;
114
115    /// Handle a downstream-originated `Reconfigure` request observed via
116    /// `PushOutcome::Reconfigure` during `run`. Implementations that can
117    /// retarget (eg picking a sub-stream over a main stream from an IP
118    /// camera, or switching bitrate) return the new caps they will produce
119    /// next; the source's `run` loop is then responsible for emitting a
120    /// `CapsChanged` packet and resuming under those caps.
121    ///
122    /// Default: reject — most sources can't change their output shape and
123    /// `FixationFailed` propagates as a fatal pipeline error.
124    fn reconfigure(&mut self, _request: Reconfigure) -> Result<Caps, G2gError> {
125        Err(G2gError::FixationFailed)
126    }
127
128    /// This source's latency contribution to the pipeline latency query (M12).
129    /// Live capture sources (cameras, RTSP) override this to report `live`
130    /// with their capture interval as `min_ns`; the default is zero, non-live
131    /// (eg a file or test-pattern source that can produce data on demand).
132    fn latency(&self) -> LatencyReport {
133        LatencyReport::ZERO
134    }
135
136    /// The memory domain of the frames this source emits. Default
137    /// [`System`](MemoryDomainKind::System); a GPU capture source (a hardware
138    /// decoder source emitting VRAM frames) overrides it. Surfaced per edge by
139    /// the negotiate-only path for the DOT dump (it is not part of `Caps`).
140    fn output_memory(&self) -> MemoryDomainKind {
141        MemoryDomainKind::System
142    }
143
144    /// The full set of memory domains this source can emit (M351). The
145    /// producer-capability half of the two-sided allocation-domain negotiation;
146    /// see [`AsyncElement::output_domains`](crate::element::AsyncElement::output_domains).
147    /// Default: just [`output_memory`](Self::output_memory). A GPU capture source
148    /// that can also deliver to System overrides this.
149    fn output_domains(&self) -> DomainSet {
150        DomainSet::only(self.output_memory())
151    }
152
153    /// The total stream duration in nanoseconds, the source's answer to the
154    /// application `DURATION` query (M203). A source that knows the total length
155    /// (a file / container source after reading its header, e.g. `Mp4Src`)
156    /// overrides this; the runner publishes it on the
157    /// [`PipelineProgress`](crate::runtime::PipelineProgress) handle and posts a
158    /// [`DurationChanged`](crate::BusMessage::DurationChanged). The default
159    /// `None` is "unknown" (a live / open-ended source, or one whose length is
160    /// not yet parsed). Polled by the runner just before [`run`](Self::run).
161    fn query_duration(&self) -> Option<u64> {
162        None
163    }
164
165    /// Receive the downstream peer's allocation proposal (M12) so the source
166    /// can allocate its output `BufferPool` from compatible parameters
167    /// (size, count, alignment, domain). Default: ignore and allocate the
168    /// source's own way. The proposal is advisory; a source that cannot honor
169    /// it (eg cannot produce the requested domain) falls back silently.
170    fn configure_allocation(&mut self, _params: &AllocationParams) {}
171
172    /// Offer a clock to the pipeline's clock election (M12). Default: none.
173    /// Live capture sources override this to provide their hardware capture
174    /// clock at [`ClockPriority::LiveSource`](crate::ClockPriority::LiveSource)
175    /// so the pipeline paces to capture cadence.
176    fn provide_clock(&self) -> Option<ClockCandidate> {
177        None
178    }
179
180    /// M16 step 5f: declare this source's negotiation-time constraint.
181    /// Default: eagerly await `intercept_caps()` and wrap as a
182    /// `LegacySource(Caps)` for the solver. Migrated sources override
183    /// to return `Produces(CapsSet)` (or another native variant) and
184    /// the chain takes the native arc-consistency path when every
185    /// other element is also native.
186    fn caps_constraint<'a>(
187        &'a mut self,
188    ) -> impl Future<Output = Result<CapsConstraint<'a>, G2gError>> + 'a {
189        async move { Ok(CapsConstraint::LegacySource(self.intercept_caps().await?)) }
190    }
191
192    /// What this source is willing to pay for each alternative of the produce
193    /// set its [`caps_constraint`](Self::caps_constraint) advertises. Default
194    /// `None`: the alternatives are already in preference order and cost their
195    /// index. A source overrides this to declare *equal* cost between formats it
196    /// does not care about (so a downstream element's preference decides) or a
197    /// gap wide enough that a downstream preference cannot pull the chain onto
198    /// its fallback.
199    fn caps_preferences(&self) -> Option<CapsPreferences> {
200        None
201    }
202
203    /// The fixed output caps this source already knows from its properties,
204    /// readable synchronously without negotiation or I/O (M195). The auto-plug
205    /// `decodebin` parser consults it to learn its upstream caps, so a property
206    /// that re-types the output (a `filesrc`'s `bytestream-format`) is reflected
207    /// into the chain search. The default `None` means "fall back to the
208    /// registry's declared caps"; a source whose output media type is
209    /// property-driven overrides it. Returns `None` when the caps are only known
210    /// at run time (e.g. `bytestream-format=auto`, which sniffs the file header).
211    fn configured_output_caps(&self) -> Option<Caps> {
212        None
213    }
214
215    /// Like [`configured_output_caps`](Self::configured_output_caps) but permitted
216    /// to do I/O to determine the type (M480): the auto-plug `decodebin` parser
217    /// calls this once, at parse time, to pick the demuxer. A `bytestream-format=
218    /// auto` source overrides it to sniff the file header now (so a mislabeled
219    /// `.ts` that is really an MP4 still auto-plugs the right demuxer, the way
220    /// GStreamer's runtime `typefind` would), where `configured_output_caps`
221    /// returns `None` because it may not read. Default: the no-I/O caps.
222    fn probe_output_caps(&mut self) -> Option<Caps> {
223        self.configured_output_caps()
224    }
225
226    /// The runtime properties this source type exposes (M104), the GObject
227    /// property-spec analog. Default: none. A source overrides this (and
228    /// [`set_property`](Self::set_property) / [`get_property`](Self::get_property))
229    /// to be settable by name from a `gst-launch` pipeline (eg `filesrc
230    /// location=...`, `videotestsrc pattern=...`).
231    fn properties(&self) -> &'static [PropertySpec] {
232        &[]
233    }
234
235    /// Static introspection metadata for this source type (M178), the
236    /// `gst-inspect` "Factory Details". Default: empty.
237    fn metadata(&self) -> ElementMetadata {
238        ElementMetadata::default()
239    }
240
241    /// Receive this source instance's log name (M179), assigned by the runner.
242    /// Default: ignore.
243    fn set_instance_name(&mut self, _name: alloc::string::String) {}
244
245    /// Override this instance's log category (M845), mirroring
246    /// [`AsyncElement::set_log_category`](crate::AsyncElement::set_log_category).
247    /// Default: ignore. A source that logs about itself stores it in a
248    /// [`LogName`](crate::log::LogName) and returns it from
249    /// `LogSource::log_category_override`.
250    fn set_log_category(&mut self, _category: alloc::string::String) {}
251
252    /// Set a property by name (M104). Default: [`PropError::Unknown`].
253    fn set_property(&mut self, _name: &str, _value: PropValue) -> Result<(), PropError> {
254        Err(PropError::Unknown)
255    }
256
257    /// Read a property back by name (M104). Default: `None`.
258    fn get_property(&self, _name: &str) -> Option<PropValue> {
259        None
260    }
261}
262
263/// Per-link queue depth handed to a runner. Each forward link between
264/// elements holds this many in-flight packets before backpressure
265/// kicks in; the steady-state glass-to-glass latency floor under
266/// backpressure is roughly `2 * link_capacity * consumer_period`.
267///
268/// Construct via a [`LatencyProfile`] for intent-based selection
269/// (`LatencyProfile::Live` for camera-to-display pipelines,
270/// `LatencyProfile::Throughput` for batch jobs) or via `From<usize>`
271/// for fine-grained tuning. Both forms compose through the runner's
272/// `impl Into<LinkCapacity>` parameter.
273#[derive(Debug, Clone, Copy, PartialEq, Eq)]
274pub struct LinkCapacity(usize);
275
276impl LinkCapacity {
277    /// `n` clamped to at least 1: a zero-capacity link would deadlock
278    /// the producer on its first push.
279    pub fn new(n: usize) -> Self {
280        Self(n.max(1))
281    }
282
283    /// Underlying queue depth. Used by the runner internals; callers
284    /// typically pass the `LinkCapacity` (or a `LatencyProfile`)
285    /// directly into the runner instead of unpacking.
286    pub fn get(self) -> usize {
287        self.0
288    }
289}
290
291impl From<usize> for LinkCapacity {
292    fn from(n: usize) -> Self {
293        Self::new(n)
294    }
295}
296
297/// Intent-based selector for [`LinkCapacity`]. Picks the link queue
298/// depth from the workload's latency-vs-throughput tradeoff so callers
299/// don't have to remember the steady-state floor formula
300/// (`2 * cap * consumer_period`).
301///
302/// At 60 fps:
303/// - `Live` (cap=2) -> ~67 ms floor. Right for RTSP -> decode -> display.
304/// - `Throughput` (cap=8) -> ~267 ms floor. Right for file ingest /
305///   batch where smoothing jitter matters more than time-to-glass.
306/// - `Custom(n)` -> caller picks. Useful when a profile bisection or
307///   live-edge tuning needs a specific value (a smoke test setting
308///   `cap=1` to push the floor below one frame, for example).
309#[derive(Debug, Clone, Copy, PartialEq, Eq)]
310pub enum LatencyProfile {
311    /// `link_capacity = 2`. Live camera -> display.
312    Live,
313    /// `link_capacity = 8`. Batch / throughput.
314    Throughput,
315    /// Caller-specified depth.
316    Custom(usize),
317}
318
319impl LatencyProfile {
320    /// The `LinkCapacity` this profile maps to.
321    pub fn link_capacity(self) -> LinkCapacity {
322        match self {
323            Self::Live => LinkCapacity::new(2),
324            Self::Throughput => LinkCapacity::new(8),
325            Self::Custom(n) => LinkCapacity::new(n),
326        }
327    }
328}
329
330impl From<LatencyProfile> for LinkCapacity {
331    fn from(p: LatencyProfile) -> Self {
332        p.link_capacity()
333    }
334}
335
336#[derive(Debug, Default, Clone, PartialEq, Eq)]
337pub struct RunStats {
338    pub frames_emitted: u64,
339    pub frames_consumed: u64,
340    /// Frames dropped by leaky links (`LinkPolicy::DropOldest`/`DropNewest`)
341    /// under downstream stall. `0` for all-`Block` pipelines and for runners
342    /// that don't expose per-edge policy (only `run_graph` does today).
343    pub frames_dropped: u64,
344    /// Aggregated source-to-sink latency (M12), computed once after
345    /// negotiation. Linear runners fold every element's `latency()`; fan-in /
346    /// fan-out runners leave this at `ZERO` (topology aggregation deferred).
347    pub latency: LatencyReport,
348    /// The allocation proposal (M12) handed to the head producer (the source),
349    /// negotiated from downstream. `None` when no downstream element proposed
350    /// one; always `None` for fan-in / fan-out runners (deferred).
351    pub allocation: Option<AllocationParams>,
352    /// Priority of the clock the pipeline elected (M12). `SystemFallback` when
353    /// no element provided one (the supplied clock stands); always
354    /// `SystemFallback` for fan-in / fan-out runners (deferred).
355    pub clock_priority: ClockPriority,
356    /// `now_ns()` of the elected clock, read once after election — the
357    /// pipeline's base-time origin.
358    pub base_time_ns: u64,
359    /// M18 β scaffolding: number of `CoordinatorEvent`s the coordinator
360    /// task observed over this run's control channel. Today the only
361    /// event is a boundary forwarding a mid-stream `CapsChanged` the next
362    /// element accepted; β will turn each into a `Recascade`. `0` for
363    /// runners that don't yet spawn a coordinator (simple / fan-out /
364    /// fan-in / muxer).
365    pub coordinator_events: u64,
366    /// Measured per-element telemetry (M399): each interior element's `process()`
367    /// latency distribution (p50/p99) and input-link fill, in topological order.
368    /// Populated by `run_graph` and the two linear runners
369    /// (`run_simple_pipeline` / `run_source_transform_sink`); empty for the
370    /// fan-in / fan-out / session / muxer runners and under `no_std` (no clock to
371    /// measure with). Sources carry no `process()` so they do not appear; their
372    /// cost surfaces as the downstream element's input fill.
373    pub per_element: alloc::vec::Vec<crate::runtime::ElementLatency>,
374}
375
376impl RunStats {
377    /// A human-readable end-of-run summary of the pipeline telemetry (M287):
378    /// frame counts + drop rate, the aggregated declared latency window, the
379    /// elected clock, and the negotiated head allocation. `g2g-launch` prints
380    /// this at end (and a host can log it). The latency is the chain's
381    /// *declared* min/max fold (each element's `latency()`), not a measured
382    /// runtime histogram; per-element / per-link p50/p99 is a follow-up.
383    pub fn report(&self) -> alloc::string::String {
384        use alloc::format;
385        let ms = |ns: u64| ns as f64 / 1.0e6;
386        let seen = self.frames_consumed + self.frames_dropped;
387        let drop_pct = if seen > 0 {
388            self.frames_dropped as f64 * 100.0 / seen as f64
389        } else {
390            0.0
391        };
392
393        let mut s = alloc::string::String::from("pipeline run summary:\n");
394        s.push_str(&format!(
395            "  frames:  emitted {}, consumed {}, dropped {} ({drop_pct:.1}% drop)\n",
396            self.frames_emitted, self.frames_consumed, self.frames_dropped
397        ));
398        let max = match self.latency.max_ns {
399            Some(m) => format!("{:.1} ms", ms(m)),
400            None => alloc::string::String::from("unbounded"),
401        };
402        s.push_str(&format!(
403            "  latency: {:.1} ms .. {max} ({}) [declared]\n",
404            ms(self.latency.min_ns),
405            if self.latency.live {
406                "live"
407            } else {
408                "non-live"
409            },
410        ));
411        s.push_str(&format!(
412            "  clock:   {:?} (base {} ns)\n",
413            self.clock_priority, self.base_time_ns
414        ));
415        if let Some(a) = &self.allocation {
416            s.push_str(&format!(
417                "  alloc:   {} B x {}, {:?}, align {}\n",
418                a.size_bytes, a.min_buffers, a.domain, a.align
419            ));
420        }
421        // Measured per-element process latency + input fill (M399). Only when the
422        // runner collected it (graph / linear runners under `std`); declared-only
423        // runs and `no_std` leave this empty.
424        if !self.per_element.is_empty() {
425            s.push_str("  per-element [measured]:\n");
426            for e in &self.per_element {
427                // proc.count == 0 means fill was sampled but no `process()` timing
428                // was taken (no_std, no clock); show fill alone in that case.
429                if e.proc.count > 0 {
430                    // Include measured input-link queue-residency (transit) when
431                    // the edge was instrumented; it is the "wait" half of the
432                    // per-stage latency, complementing the `process()` "work".
433                    let transit = if e.transit.count > 0 {
434                        format!(
435                            ", wait p50 {:.2} ms / p99 {:.2} ms",
436                            ms(e.transit.p50_ns),
437                            ms(e.transit.p99_ns)
438                        )
439                    } else {
440                        alloc::string::String::new()
441                    };
442                    // M947: what the element spent parked on a full output link.
443                    // `proc` excludes it, so a back-pressured element reads as
444                    // blocked rather than as expensive. Absent for an element the
445                    // runner does not attribute output pushes to (every sink).
446                    let blocked = if e.push_wait.max_ns > 0 {
447                        format!(
448                            ", blocked p50 {:.2} ms / p99 {:.2} ms",
449                            ms(e.push_wait.p50_ns),
450                            ms(e.push_wait.p99_ns)
451                        )
452                    } else {
453                        alloc::string::String::new()
454                    };
455                    // Frame age at this element's output push: the number that
456                    // exposes internal buffering (a decoder's reorder queue),
457                    // which cheap `proc` percentiles hide.
458                    let age = if e.age_at_emit.count > 0 {
459                        format!(
460                            ", age-out p50 {:.2} ms / p99 {:.2} ms",
461                            ms(e.age_at_emit.p50_ns),
462                            ms(e.age_at_emit.p99_ns)
463                        )
464                    } else {
465                        alloc::string::String::new()
466                    };
467                    s.push_str(&format!(
468                        "    {:<16} proc p50 {:.2} ms / p99 {:.2} ms (n={}){blocked}{transit}{age}, in-fill {}%/{}% avg/max\n",
469                        e.name,
470                        ms(e.proc.p50_ns),
471                        ms(e.proc.p99_ns),
472                        e.proc.count,
473                        e.fill_mean_pct,
474                        e.fill_max_pct,
475                    ));
476                } else {
477                    s.push_str(&format!(
478                        "    {:<16} in-fill {}%/{}% avg/max\n",
479                        e.name, e.fill_mean_pct, e.fill_max_pct,
480                    ));
481                }
482            }
483        }
484        // What a paced sink actually put on the display, vs what it shed: a
485        // stalling presentation shows up here even when `consumed` looks healthy.
486        for e in &self.per_element {
487            if let Some(p) = e.presentation {
488                s.push_str(&format!(
489                    "  present: {:<16} {} presented, {} dropped, {} late-dropped\n",
490                    e.name, p.presented, p.dropped, p.late_dropped
491                ));
492            }
493        }
494        s
495    }
496}
497
498/// M16 workaround #3 Phase B helper: re-solve the downstream subgraph
499/// when a forward `CapsChanged` crosses a format boundary mid-stream.
500///
501/// Today's 3-element runner has a single downstream link (boundary →
502/// sink), so the subgraph is one link and the solver's role is
503/// structural: it queries the sink's declared `CapsConstraint` (which
504/// may reject the boundary's output via `Accepts(set)` cleanly) before
505/// the sink ever sees `configure_pipeline`. The returned `Caps` is what
506/// the runner then hands to `configure_pipeline`.
507///
508/// Longer chains (4+ elements, future runner variants) will iterate
509/// the solver result to reconfigure every changed downstream link, not
510/// just the immediate next element — that's the structural unlock
511/// DESIGN.md §4.13.4 calls out.
512///
513/// Forward × reverse race (§7): an `EmptyLink` here means the sink
514/// can't take the boundary's output. The caller drops the forward
515/// `CapsChanged` and signals a reverse `Reconfigure` *into the
516/// boundary*, not past it to the source — that boundary owns the
517/// derivation and is the right place to surface the structured
518/// failure. An `Unfixable` (the boundary's caps left a ranged field
519/// like `Rate::Any` — common for decoders that don't know framerate at
520/// the pixel level) is *not* a failure: it means the sink accepted the
521/// shape, the caps just aren't fully fixated. Pass `new_caps` through
522/// unchanged.
523fn re_solve_downstream_sink<S>(new_caps: &Caps, sink: &S) -> Result<Caps, NegotiationFailure>
524where
525    S: AsyncElement + ?Sized,
526{
527    re_solve_against_sink_constraint(new_caps, &sink.caps_constraint_as_sink())
528}
529
530/// Shared core of the downstream re-solve: solve `LegacySource(new_caps)`
531/// against the sink's already-evaluated constraint. Factored out so the
532/// generic ([`re_solve_downstream_sink`]) and `Box`-erased
533/// ([`re_solve_downstream_dyn_sink`]) callers don't duplicate the
534/// `Unfixable`-is-not-a-failure handling.
535fn re_solve_against_sink_constraint(
536    new_caps: &Caps,
537    sink_c: &CapsConstraint<'_>,
538) -> Result<Caps, NegotiationFailure> {
539    let src_c = CapsConstraint::LegacySource(new_caps.clone());
540    match solve_linear(&[&src_c, sink_c]) {
541        Ok(links) => links
542            .into_iter()
543            .last()
544            .ok_or(NegotiationFailure::Degenerate),
545        Err(NegotiationFailure::Unfixable { .. }) => Ok(new_caps.clone()),
546        Err(other) => Err(other),
547    }
548}
549
550/// Fan-out Phase C FO-2: the [`DynAsyncElement`] counterpart of
551/// [`re_solve_downstream_sink`], for `Box`-erased branch sinks.
552#[cfg(feature = "std")]
553pub(crate) fn re_solve_downstream_dyn_sink(
554    new_caps: &Caps,
555    sink: &dyn DynAsyncElement,
556) -> Result<Caps, NegotiationFailure> {
557    re_solve_against_sink_constraint(new_caps, &sink.caps_constraint_as_sink())
558}
559
560/// Drives a `source → sink` pipeline over a single bounded link.
561/// Initial Phase 1+2 negotiation runs with bounded `ReFixate` backtrack
562/// (M8 piece 5): if any element's `configure_pipeline()` returns a
563/// counter-proposal, the runner restarts negotiation with that counter
564/// as the new starting proposal, up to `MAX_FIXATION_ATTEMPTS` total.
565pub async fn run_simple_pipeline<Src, Snk, Clk>(
566    source: &mut Src,
567    sink: &mut Snk,
568    clock: &Clk,
569    link_capacity: impl Into<LinkCapacity>,
570) -> Result<RunStats, G2gError>
571where
572    Src: SourceLoop,
573    Snk: AsyncElement,
574    Clk: PipelineClock,
575{
576    run_simple_pipeline_inner(source, sink, clock, link_capacity, None, None).await
577}
578
579/// As [`run_simple_pipeline`], but driven by a [`StateController`] (M76).
580///
581/// The sink arm gates on the controller: while the state is below
582/// `Playing` the sink stops pulling, the bounded link fills, and backpressure
583/// stalls the source. `set_state(Playing)` (from another task) opens the gate
584/// and data flows; `set_state(Null)` stops the sink arm and ends the run.
585/// Negotiation still runs eagerly at startup (it is resource acquisition, the
586/// `READY` step); only data flow is gated. Pass the controller starting in
587/// `Paused` for the common "build prerolled, then play" shape.
588pub async fn run_simple_pipeline_stateful<Src, Snk, Clk>(
589    source: &mut Src,
590    sink: &mut Snk,
591    clock: &Clk,
592    link_capacity: impl Into<LinkCapacity>,
593    state: &StateController,
594) -> Result<RunStats, G2gError>
595where
596    Src: SourceLoop,
597    Snk: AsyncElement,
598    Clk: PipelineClock,
599{
600    run_simple_pipeline_inner(
601        source,
602        sink,
603        clock,
604        link_capacity,
605        None,
606        Some(state.clone()),
607    )
608    .await
609}
610
611/// As [`run_simple_pipeline`], but posts a structured
612/// [`BusMessage::NegotiationFailed`](crate::BusMessage::NegotiationFailed) to
613/// `bus` on a startup or mid-stream negotiation failure (M18 item 7).
614pub async fn run_simple_pipeline_with_bus<Src, Snk, Clk>(
615    source: &mut Src,
616    sink: &mut Snk,
617    clock: &Clk,
618    link_capacity: impl Into<LinkCapacity>,
619    bus: &BusHandle,
620) -> Result<RunStats, G2gError>
621where
622    Src: SourceLoop,
623    Snk: AsyncElement,
624    Clk: PipelineClock,
625{
626    run_simple_pipeline_inner(source, sink, clock, link_capacity, Some(bus), None).await
627}
628
629async fn run_simple_pipeline_inner<Src, Snk, Clk>(
630    source: &mut Src,
631    sink: &mut Snk,
632    clock: &Clk,
633    link_capacity: impl Into<LinkCapacity>,
634    bus: Option<&BusHandle>,
635    state: Option<StateController>,
636) -> Result<RunStats, G2gError>
637where
638    Src: SourceLoop,
639    Snk: AsyncElement,
640    Clk: PipelineClock,
641{
642    let link_capacity: usize = link_capacity.into().get();
643    // M842: name and log the instances the way `run_graph` does, before
644    // negotiation, so an element driven by this runner logs under `<category>N`
645    // too. The sink's name also keys its measured-latency probe below.
646    let mut namer = crate::log::InstanceNamer::new();
647    let source_name = namer.add(crate::log::short_type_name::<Src>(), None);
648    SourceLoop::set_instance_name(source, source_name.clone());
649    let sink_name = namer.add(crate::log::short_type_name::<Snk>(), None);
650    AsyncElement::set_instance_name(sink, sink_name.clone());
651    // M16 step 5f: startup negotiation honors `SourceLoop::caps_constraint`
652    // so migrated native sources (e.g. `VideoTestSrc::Produces(...)`)
653    // take the native solver path. `ReFixate` retry falls back to
654    // `LegacySource(counter)` because counter-proposals are a legacy
655    // model concept and native sources don't accept them.
656    let mut refix_counter: Option<Caps> = None;
657    let mut attempts = 0u32;
658    let negotiated_caps = loop {
659        attempts += 1;
660        if attempts > MAX_FIXATION_ATTEMPTS {
661            return Err(G2gError::FixationFailed);
662        }
663        // Resolve src_c in its own scope so its borrow of `source`
664        // releases before `configure_pipeline(&fixated)` below.
665        let fixated = {
666            let src_c = match &refix_counter {
667                Some(c) => CapsConstraint::LegacySource(c.clone()),
668                None => source.caps_constraint().await?,
669            };
670            let sink_c = sink.caps_constraint_as_sink();
671            solve_last_link(&[&src_c, &sink_c], bus)?
672        };
673        match source.configure_pipeline(&fixated)? {
674            ConfigureOutcome::Accepted => {}
675            ConfigureOutcome::ReFixate(counter) => {
676                refix_counter = Some(counter);
677                continue;
678            }
679        }
680        match sink.configure_pipeline(&fixated)? {
681            ConfigureOutcome::Accepted => break fixated,
682            ConfigureOutcome::ReFixate(counter) => {
683                refix_counter = Some(counter);
684                continue;
685            }
686        }
687    };
688
689    // M12 latency query: fold the configured chain source → sink.
690    let latency = LatencyReport::aggregate([source.latency(), AsyncElement::latency(sink)]);
691
692    // M12 allocation query: the sink proposes buffers; the source allocates
693    // its output pool to match (zero-copy handoff when it can honor them). M351:
694    // the proposed domain is reconciled against what the source can actually emit
695    // (a two-sided negotiation), so a multi-domain sink/source pair settles on a
696    // shared domain (GPU-preferred) instead of the sink dictating unilaterally;
697    // no shared domain is a loud conflict rather than a silent mismatch.
698    // M976: the sink's meta requests ride the same proposal, so the source can
699    // ask what anyone downstream wants attached before it produces a frame.
700    let proposed = crate::query::with_meta_demand(
701        sink.propose_allocation(&negotiated_caps),
702        AsyncElement::meta_requests(sink),
703    );
704    // A demand-only proposal accepts every domain, so reconciling it would let a
705    // metadata request pick the source's memory domain: pass it through as is.
706    let allocation = match proposed {
707        Some(p) if p.constrains_pool() => {
708            Some(p.resolve_for_producer(SourceLoop::output_domains(source))?)
709        }
710        other => other,
711    };
712    if let Some(p) = &allocation {
713        source.configure_allocation(p);
714    }
715
716    // M12 clock distribution: elect the pipeline clock (source > sink > fallback).
717    let elected = elect_clock([source.provide_clock(), AsyncElement::provide_clock(sink)]);
718    let (clock_priority, base_time_ns) = match &elected {
719        Some(c) => (c.priority, c.clock.now_ns()),
720        None => (ClockPriority::SystemFallback, clock.now_ns()),
721    };
722
723    // Hand the elected clock + base time to the sink so it can present each
724    // frame at its running-time deadline (PTS pacing). Only when a clock was
725    // elected; without one the sink presents as fast as backpressure allows.
726    // M176: under a state controller, arm a Playing-transition anchor so the
727    // sink bases presentation on the play edge, not on startup / the preroll
728    // frame; without one, the eager startup base time stands.
729    if let Some(c) = &elected {
730        let sync = match &state {
731            Some(sc) => ClockSync::with_play_anchor(
732                c.clock.clone(),
733                base_time_ns,
734                sc.arm_play_anchor(c.clock.clone()),
735            ),
736            None => ClockSync::new(c.clock.clone(), base_time_ns),
737        };
738        AsyncElement::set_clock_sync(sink, sync.with_path_latency(latency));
739    }
740
741    let (link_tx, link_rx) = link(link_capacity);
742    advertise_orientation(&link_rx, AsyncElement::absorbs_orientation(sink));
743
744    let source_fut = async move {
745        let mut adapter = SenderSink::new(link_tx);
746        // M81: every stream opens with a SEGMENT, ahead of the source's data,
747        // so a sink maps frame timestamps to running time from the first frame.
748        let _ = adapter
749            .push(PipelinePacket::Segment(Segment::new()))
750            .await?;
751        let emitted = source.run(&mut adapter).await?;
752        Ok::<u64, G2gError>(emitted)
753    };
754
755    // M399: measured per-element telemetry for the sink (the linear runner's one
756    // interior element with a `process()`); the source's cost surfaces as fill.
757    let sink_probe = ElementProbe::new(sink_name.clone());
758    let probe_for_sink = sink_probe.clone();
759
760    let bus_for_sink = bus.cloned();
761    let state_for_sink = state;
762    let sink_fut = async move {
763        let bus_for_sink = bus_for_sink;
764        let state_for_sink = state_for_sink;
765        let probe_for_sink = probe_for_sink;
766        let mut null = NullSink;
767        let mut consumed: u64 = 0;
768        let mut prerolled_self = false;
769        // M360 re-preroll: the generation this arm last prerolled at, and whether
770        // it is currently draining stale pre-seek frames (after a paused flushing
771        // seek) until the `Flush` arrives.
772        let mut preroll_gen = state_for_sink
773            .as_ref()
774            .map_or(0, |sc| sc.preroll_generation());
775        let mut flushing = false;
776        loop {
777            // Flow gate (M76/M77): below `Playing` the sink parks here, so it
778            // stops draining the link; the bounded channel fills and
779            // backpressure stalls the source. `Playing` opens the gate; `Null`
780            // ends the arm. In non-live `Paused` the gate admits exactly one
781            // buffer (this sink's preroll frame) before it holds.
782            if let Some(sc) = &state_for_sink {
783                if sc.flow_gate(prerolled_self, preroll_gen).await == Flow::Stop {
784                    return Ok::<u64, G2gError>(consumed);
785                }
786                // M360: a `request_repreroll` (paused flushing seek) bumped the
787                // generation. Re-arm this arm's preroll and drain the stale
788                // pre-seek frames until the `Flush`, so the post-flush target
789                // becomes the new visible preroll rather than a stale buffer.
790                let gen = sc.preroll_generation();
791                if gen != preroll_gen {
792                    preroll_gen = gen;
793                    prerolled_self = false;
794                    flushing = true;
795                }
796            }
797            match link_rx.recv().await {
798                // M360: discard stale pre-seek buffers while draining toward the
799                // `Flush`; control packets fall through (the `Flush` ends drain).
800                Some(PipelinePacket::DataFrame(_)) if flushing => continue,
801                Some(PipelinePacket::Eos) => {
802                    sink.process(PipelinePacket::Eos, &mut null).await?;
803                    // M77: EOS during preroll still completes the async
804                    // `Paused` transition (idempotent; no-op once playing).
805                    if let Some(sc) = &state_for_sink {
806                        sc.notify_prerolled();
807                    }
808                    return Ok::<u64, G2gError>(consumed);
809                }
810                Some(PipelinePacket::CapsChanged(new_caps)) => {
811                    // M16 workaround #3 Phase B: re-solve the downstream
812                    // subgraph before applying. For a 2-element chain
813                    // the subgraph is one link, so the solver's role is
814                    // structural — it checks the sink's declared
815                    // `CapsConstraint::caps_constraint_as_sink()`
816                    // (which a native sink may use to reject the new
817                    // shape cleanly) before any `configure_pipeline`
818                    // call. Failure becomes a structured upstream
819                    // `Renegotiate` request instead of an opaque
820                    // `CapsMismatch`.
821                    let sink_caps = match re_solve_downstream_sink(&new_caps, &*sink) {
822                        Ok(caps) => caps,
823                        Err(failure) => {
824                            report_nego_failure(bus_for_sink.as_ref(), failure);
825                            link_rx.request_reconfigure(Reconfigure::Renegotiate);
826                            continue;
827                        }
828                    };
829                    // M8 piece 1: runner cascades mid-stream caps changes
830                    // through configure_pipeline before the element sees
831                    // the notification packet. Guarantees DataFrames with
832                    // the new caps never reach a stale element.
833                    match log_caps_rejected(
834                        Some(probe_for_sink.name()),
835                        &sink_caps,
836                        sink.configure_pipeline(&sink_caps),
837                    )? {
838                        ConfigureOutcome::Accepted => {
839                            // M18 α: element-local re-allocation under the
840                            // new caps before the sink sees the packet.
841                            realloc_local(sink, &sink_caps);
842                            sink.process(PipelinePacket::CapsChanged(sink_caps), &mut null)
843                                .await?;
844                        }
845                        // M8 piece 5: a sink that rejects new caps fires
846                        // its counter-proposal upstream as a Reconfigure
847                        // signal. The source observes it on its next push
848                        // (piece 4 wires source-side handling). The
849                        // CapsChanged packet is dropped — caps were not
850                        // accepted — and we keep draining old-caps frames
851                        // until the source emits a fresh CapsChanged.
852                        ConfigureOutcome::ReFixate(counter) => {
853                            link_rx.request_reconfigure(Reconfigure::Propose(counter));
854                        }
855                    }
856                }
857                // M360: the `Flush` ends the re-preroll drain; the next
858                // (post-flush) DataFrame becomes the new visible preroll.
859                Some(PipelinePacket::Flush) => {
860                    flushing = false;
861                    sink.process(PipelinePacket::Flush, &mut null).await?;
862                }
863                Some(packet) => {
864                    let is_buffer = matches!(packet, PipelinePacket::DataFrame(_));
865                    if is_buffer {
866                        consumed += 1;
867                    }
868                    // M399: time the data-frame `process()` and sample input fill.
869                    let timed = is_buffer.then(|| &*probe_for_sink);
870                    if let Some(p) = timed {
871                        p.record_fill(link_rx.fill_percent());
872                    }
873                    let t0 = ElementProbe::mark();
874                    sink.process(packet, &mut null).await?;
875                    if let Some(p) = timed {
876                        p.record_proc_since(t0);
877                    }
878                    // M174 upstream QoS: a sink that dropped a late frame asks to
879                    // shed load; forward its report onto the incoming link, where
880                    // the source observes it as `PushOutcome::Qos` and skips ahead.
881                    if let Some(qos) = sink.take_qos() {
882                        link_rx.request_qos(qos);
883                    }
884                    // Keyframe-request / renegotiation a sink originates (e.g. a
885                    // WebRTC sink on a remote PLI): forward it up the same reverse
886                    // channel, where the encoder sees it as `PushOutcome::Reconfigure`.
887                    if let Some(reconf) = sink.take_reconfigure() {
888                        link_rx.request_reconfigure(reconf);
889                    }
890                    // Target bitrate (WebRTC BWE) up the same reverse channel; the
891                    // upstream encoder observes it as `PushOutcome::Bitrate`.
892                    if let Some(bps) = sink.take_bitrate() {
893                        link_rx.request_bitrate(bps);
894                    }
895                    // M77: the first buffer in non-live `Paused` is the preroll
896                    // frame; mark this arm prerolled so the gate flips from
897                    // preroll-grant to hold, and report it for aggregation.
898                    // Idempotent and a no-op while `Playing`.
899                    if is_buffer && !prerolled_self {
900                        prerolled_self = true;
901                        if let Some(sc) = &state_for_sink {
902                            sc.notify_prerolled();
903                        }
904                    }
905                }
906                None => return Ok(consumed),
907            }
908        }
909    };
910
911    let (src_res, snk_res) = Join2::new(source_fut, sink_fut).await;
912    // M81: a closed-link `Shutdown` on the source arm can be the consequence of
913    // the sink arm's real error (it dropped the link), so surface the
914    // substantive one rather than whichever arm we check first.
915    if let Some(e) = substantive_error([
916        (source_name.as_str(), src_res.as_ref().err()),
917        (sink_name.as_str(), snk_res.as_ref().err()),
918    ]) {
919        return Err(e);
920    }
921    let emitted = src_res?;
922    let consumed = snk_res?;
923
924    // M399: the sink arm has joined, so its probe is settled; snapshot it.
925    let per_element = alloc::vec![sink_probe.snapshot()];
926    Ok(RunStats {
927        frames_emitted: emitted,
928        frames_consumed: consumed,
929        frames_dropped: 0,
930        latency,
931        allocation,
932        clock_priority,
933        base_time_ns,
934        coordinator_events: 0,
935        per_element,
936    })
937}
938
939/// Drives a `source → fan-out element → N sinks` pipeline (M9 fan-out core).
940/// The fan-out element (a [`MultiOutputElement`], e.g. `Router`) sends each
941/// `DataFrame` to one branch and broadcasts `CapsChanged` to all; the runner
942/// broadcasts `Eos` to every branch on shutdown.
943///
944/// Heterogeneous branches arrive as `Box`-erased `&mut dyn DynAsyncElement`
945/// (std only). Negotiation fixates the source proposal once and configures
946/// every element with it (DESIGN.md §4.2); per-branch caps negotiation is
947/// M10, so a sink returning `ReFixate` here fails with `FixationFailed`.
948#[cfg(feature = "std")]
949pub async fn run_source_fanout<Src, Tx, Clk>(
950    source: &mut Src,
951    fanout: &mut Tx,
952    sinks: Vec<&mut dyn DynAsyncElement>,
953    clock: &Clk,
954    link_capacity: impl Into<LinkCapacity>,
955) -> Result<RunStats, G2gError>
956where
957    Src: SourceLoop,
958    Tx: MultiOutputElement,
959    Clk: PipelineClock,
960{
961    run_source_fanout_inner(source, fanout, sinks, clock, link_capacity, None, None).await
962}
963
964/// As [`run_source_fanout`], but taps live telemetry into `observer` (M846), the
965/// hand-built analog of
966/// [`run_graph_observed`](crate::runtime::run_graph_observed): the topology is
967/// the source, the fan-out element, and the N branch sinks, each with its
968/// measured `process()` latency and per-link packet / byte / drop counters.
969#[cfg(feature = "std")]
970pub async fn run_source_fanout_observed<Src, Tx, Clk>(
971    source: &mut Src,
972    fanout: &mut Tx,
973    sinks: Vec<&mut dyn DynAsyncElement>,
974    clock: &Clk,
975    link_capacity: impl Into<LinkCapacity>,
976    observer: &Observer,
977) -> Result<RunStats, G2gError>
978where
979    Src: SourceLoop,
980    Tx: MultiOutputElement,
981    Clk: PipelineClock,
982{
983    run_source_fanout_inner(
984        source,
985        fanout,
986        sinks,
987        clock,
988        link_capacity,
989        None,
990        Some(observer),
991    )
992    .await
993}
994
995/// As [`run_source_fanout`], but posts a structured
996/// [`BusMessage::NegotiationFailed`](crate::BusMessage::NegotiationFailed) to
997/// `bus` on a startup or per-branch mid-stream negotiation failure (item 7).
998#[cfg(feature = "std")]
999pub async fn run_source_fanout_with_bus<Src, Tx, Clk>(
1000    source: &mut Src,
1001    fanout: &mut Tx,
1002    sinks: Vec<&mut dyn DynAsyncElement>,
1003    clock: &Clk,
1004    link_capacity: impl Into<LinkCapacity>,
1005    bus: &BusHandle,
1006) -> Result<RunStats, G2gError>
1007where
1008    Src: SourceLoop,
1009    Tx: MultiOutputElement,
1010    Clk: PipelineClock,
1011{
1012    run_source_fanout_inner(source, fanout, sinks, clock, link_capacity, Some(bus), None).await
1013}
1014
1015#[cfg(feature = "std")]
1016#[allow(clippy::too_many_arguments)]
1017async fn run_source_fanout_inner<Src, Tx, Clk>(
1018    source: &mut Src,
1019    fanout: &mut Tx,
1020    sinks: Vec<&mut dyn DynAsyncElement>,
1021    _clock: &Clk,
1022    link_capacity: impl Into<LinkCapacity>,
1023    bus: Option<&BusHandle>,
1024    observer: Option<&Observer>,
1025) -> Result<RunStats, G2gError>
1026where
1027    Src: SourceLoop,
1028    Tx: MultiOutputElement,
1029    Clk: PipelineClock,
1030{
1031    let link_capacity: usize = link_capacity.into().get();
1032    let branch_count = sinks.len();
1033    assert!(branch_count > 0, "fan-out needs at least one sink");
1034
1035    // M846: instance naming + a measured-latency probe for every node with a
1036    // `process()` (the fan-out element and each branch sink), as in the linear
1037    // runners. The fan-out element has no naming hook, so its probe is keyed by
1038    // its type name.
1039    let mut sinks = sinks;
1040    let mut namer = crate::log::InstanceNamer::new();
1041    let source_name = namer.add(crate::log::short_type_name::<Src>(), None);
1042    SourceLoop::set_instance_name(source, source_name.clone());
1043    let fanout_probe = ElementProbe::new(namer.add(crate::log::short_type_name::<Tx>(), None));
1044    let mut sink_probes = Vec::with_capacity(branch_count);
1045    for sink in sinks.iter_mut() {
1046        let name = namer.add(sink.log_category(), None);
1047        sink.set_instance_name(name.clone());
1048        sink_probes.push(ElementProbe::new(name));
1049    }
1050
1051    // M18 step 1: solve source → fanout via the solver using the new
1052    // `MultiOutputElement::caps_constraint_as_input()` trait method
1053    // (M16 step 4c had this constructing `LegacySink` inline). The
1054    // fan-out acts as the linear "sink" of the negotiation chain;
1055    // the real sinks downstream of it broadcast-receive the same
1056    // fixated caps and don't participate in narrowing. Phase C FO-2
1057    // (per-branch downstream re-solve once a mid-stream `CapsChanged`
1058    // crosses the fan-out boundary) lands once β (the coordinator
1059    // restructure) does — it slots in here via per-branch calls to
1060    // `re_solve_downstream_sink`.
1061    let fixated = {
1062        let src_c = source.caps_constraint().await?;
1063        let fanout_c = fanout.caps_constraint_as_input();
1064        solve_last_link(&[&src_c, &fanout_c], bus)?
1065    };
1066
1067    source.configure_pipeline(&fixated)?.reject_refixate()?;
1068    MultiOutputElement::configure_pipeline(fanout, &fixated)?.reject_refixate()?;
1069    for sink in sinks.iter_mut() {
1070        sink.configure_pipeline(&fixated)?.reject_refixate()?;
1071    }
1072
1073    let tap = observer.is_some();
1074    let (src_tx, src_rx, src_tap) = link_tapped(link_capacity, tap);
1075    let mut branch_senders = Vec::with_capacity(branch_count);
1076    let mut branch_receivers = Vec::with_capacity(branch_count);
1077    let mut branch_taps = Vec::with_capacity(branch_count);
1078    for _ in 0..branch_count {
1079        let (tx, rx, edge) = link_tapped(link_capacity, tap);
1080        branch_senders.push(SenderSink::new(tx));
1081        branch_receivers.push(rx);
1082        branch_taps.push(edge);
1083    }
1084
1085    // Dev-tooling tap: source 0, fan-out 1, then the branch sinks.
1086    if let Some(obs) = observer {
1087        let mut nodes: Vec<TapNode> = alloc::vec![
1088            (source_name.clone(), NodeRole::Source, None),
1089            (
1090                alloc::string::String::from(fanout_probe.name()),
1091                NodeRole::Tee,
1092                Some(fanout_probe.clone()),
1093            ),
1094        ];
1095        let mut edges: Vec<TapEdge> = alloc::vec![(0, 1, fixated.clone(), src_tap)];
1096        for (i, (probe, edge)) in sink_probes
1097            .iter()
1098            .zip(core::mem::take(&mut branch_taps))
1099            .enumerate()
1100        {
1101            nodes.push((
1102                alloc::string::String::from(probe.name()),
1103                NodeRole::Sink,
1104                Some(probe.clone()),
1105            ));
1106            edges.push((1, 2 + i, fixated.clone(), edge));
1107        }
1108        register_runner_tap(obs, nodes, edges);
1109    }
1110
1111    let source_fut: BoxFuture<'_, Result<u64, G2gError>> = Box::pin(async move {
1112        let mut adapter = SenderSink::new(src_tx);
1113        source.run(&mut adapter).await
1114    });
1115
1116    let probe_for_fanout = fanout_probe.clone();
1117    let router_fut: BoxFuture<'_, Result<u64, G2gError>> = Box::pin(async move {
1118        let mut multi = MultiSenderSink::new(branch_senders);
1119        // M947: a slow branch backs this router up; count that as push-wait.
1120        multi.set_push_wait_probe(Some(probe_for_fanout.clone()));
1121        loop {
1122            match src_rx.recv().await {
1123                Some(PipelinePacket::Eos) => {
1124                    MultiOutputElement::process(fanout, PipelinePacket::Eos, &mut multi).await?;
1125                    for port in 0..branch_count {
1126                        multi.push_to(port, PipelinePacket::Eos).await?;
1127                    }
1128                    return Ok::<u64, G2gError>(0);
1129                }
1130                Some(packet) => {
1131                    let is_data = matches!(packet, PipelinePacket::DataFrame(_));
1132                    if is_data {
1133                        probe_for_fanout.record_fill(src_rx.fill_percent());
1134                    }
1135                    let t0 = is_data.then(ElementProbe::mark).flatten();
1136                    MultiOutputElement::process(fanout, packet, &mut multi).await?;
1137                    if is_data {
1138                        probe_for_fanout.record_proc_since(t0);
1139                    }
1140                }
1141                None => return Ok(0),
1142            }
1143        }
1144    });
1145
1146    let mut arms: Vec<BoxFuture<'_, Result<u64, G2gError>>> = Vec::with_capacity(branch_count + 2);
1147    arms.push(source_fut);
1148    arms.push(router_fut);
1149
1150    for ((sink, rx), probe) in sinks
1151        .into_iter()
1152        .zip(branch_receivers)
1153        .zip(sink_probes.iter().cloned())
1154    {
1155        let bus_for_branch = bus.cloned();
1156        let sink_fut: BoxFuture<'_, Result<u64, G2gError>> = Box::pin(async move {
1157            let bus_for_branch = bus_for_branch;
1158            let mut null = NullSink;
1159            let mut consumed: u64 = 0;
1160            loop {
1161                match rx.recv().await {
1162                    Some(PipelinePacket::Eos) => {
1163                        sink.process(PipelinePacket::Eos, &mut null).await?;
1164                        return Ok::<u64, G2gError>(consumed);
1165                    }
1166                    Some(PipelinePacket::CapsChanged(new_caps)) => {
1167                        // M18 Phase C FO-2: per-branch downstream re-solve
1168                        // (Phase B applied per branch). Each branch runs in
1169                        // its own arm, so the broadcast `CapsChanged` is
1170                        // re-solved on every branch concurrently. FO-1
1171                        // strict default: a branch whose declared
1172                        // `caps_constraint_as_sink()` rejects the new caps
1173                        // fails the fan-out loud (matches GStreamer's
1174                        // `tee`-with-rejecting-downstream). `AllowBranchDrop`
1175                        // graceful degradation is a future opt-in.
1176                        let branch_caps =
1177                            re_solve_downstream_dyn_sink(&new_caps, &*sink).map_err(|f| {
1178                                report_nego_failure(bus_for_branch.as_ref(), f);
1179                                G2gError::CapsMismatch
1180                            })?;
1181                        match log_caps_rejected(
1182                            Some(probe.name()),
1183                            &branch_caps,
1184                            sink.configure_pipeline(&branch_caps),
1185                        )? {
1186                            ConfigureOutcome::Accepted => {
1187                                // M18 α: element-local re-allocation of this
1188                                // branch under its re-solved caps.
1189                                realloc_local_dyn(sink, &branch_caps);
1190                                sink.process(PipelinePacket::CapsChanged(branch_caps), &mut null)
1191                                    .await?;
1192                            }
1193                            ConfigureOutcome::ReFixate(counter) => {
1194                                rx.request_reconfigure(Reconfigure::Propose(counter));
1195                            }
1196                        }
1197                    }
1198                    Some(packet) => {
1199                        let is_data = matches!(packet, PipelinePacket::DataFrame(_));
1200                        if is_data {
1201                            consumed += 1;
1202                            probe.record_fill(rx.fill_percent());
1203                        }
1204                        let t0 = is_data.then(ElementProbe::mark).flatten();
1205                        sink.process(packet, &mut null).await?;
1206                        if is_data {
1207                            probe.record_proc_since(t0);
1208                        }
1209                    }
1210                    None => return Ok(consumed),
1211                }
1212            }
1213        });
1214        arms.push(sink_fut);
1215    }
1216
1217    let results = join_all(arms).await;
1218    // M81: a real branch error closes the shared links, surfacing as Shutdown on
1219    // the sibling arms; surface the substantive error rather than whichever arm
1220    // the count loop unwraps first (consistent with the linear path).
1221    // Arm order: [source, router, sink0, sink1, ...].
1222    let arm_names = [source_name.as_str(), fanout_probe.name()]
1223        .into_iter()
1224        .chain(sink_probes.iter().map(|p| p.name()));
1225    if let Some(e) = substantive_error(arm_names.zip(results.iter().map(|r| r.as_ref().err()))) {
1226        return Err(e);
1227    }
1228    let mut counts = Vec::with_capacity(results.len());
1229    for r in results {
1230        counts.push(r?);
1231    }
1232    // Arm order: [source, router, sink0, sink1, ...].
1233    let emitted = counts[0];
1234    let consumed: u64 = counts[2..].iter().copied().sum();
1235    // Fan-out latency / allocation / clock election across N branches is
1236    // deferred (M12 covers the linear path); report neutral values rather than
1237    // a misleading partial one.
1238    let mut probes: Vec<Probe> = alloc::vec![Some(fanout_probe)];
1239    probes.extend(sink_probes.into_iter().map(Some));
1240    Ok(RunStats {
1241        frames_emitted: emitted,
1242        frames_consumed: consumed,
1243        frames_dropped: 0,
1244        latency: LatencyReport::ZERO,
1245        allocation: None,
1246        clock_priority: ClockPriority::SystemFallback,
1247        base_time_ns: 0,
1248        coordinator_events: 0,
1249        per_element: crate::runtime::snapshot_all(&probes),
1250    })
1251}
1252
1253// ===========================================================================
1254// M310: runtime request pads (dynamic fan-out branches).
1255// ===========================================================================
1256
1257/// Self-identifying outcome of a dynamic-fan-out arm. Arm indices are not stable
1258/// once branches are added at runtime, so each arm reports what it was.
1259#[cfg(feature = "std")]
1260enum DynArmOut {
1261    /// The source produced this many `DataFrame`s.
1262    Source(u64),
1263    /// The router (no count of its own).
1264    Router,
1265    /// A branch consumed this many `DataFrame`s.
1266    Branch(u64),
1267}
1268
1269/// A handle to add output branches to a *running* dynamic fan-out (M310): the
1270/// runtime equivalent of GStreamer's tee request pads. Each
1271/// [`add_branch`](Self::add_branch) attaches a new sink the router routes frames
1272/// to; the new branch configures from the fan-out's sticky caps on attach, then
1273/// receives its share of subsequent frames. Cheap to clone (channel senders), so
1274/// several controllers can request pads.
1275///
1276/// `'a` is the run's lifetime: the handle is used concurrently with the run
1277/// future and must be dropped no later than it. Branches added after the source
1278/// has ended are rejected ([`G2gError::Shutdown`]).
1279#[derive(Clone)]
1280#[allow(missing_debug_implementations)]
1281#[cfg(feature = "std")]
1282pub struct DynamicFanoutHandle<'a> {
1283    new_branch_tx: Sender<alloc::boxed::Box<dyn DynAsyncElement + 'a>>,
1284}
1285
1286#[cfg(feature = "std")]
1287impl<'a> DynamicFanoutHandle<'a> {
1288    /// Request a new output pad: attach `sink` as a branch of the running
1289    /// fan-out. Returns [`G2gError::Shutdown`] if the fan-out has already
1290    /// finished (the source ended), so no branch can be added.
1291    pub fn add_branch(
1292        &self,
1293        sink: alloc::boxed::Box<dyn DynAsyncElement + 'a>,
1294    ) -> Result<(), G2gError> {
1295        self.new_branch_tx
1296            .try_send(sink)
1297            .map_err(|_| G2gError::Shutdown)
1298    }
1299}
1300
1301/// How a dynamic fan-out distributes each `DataFrame` across its branches.
1302#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1303#[cfg(feature = "std")]
1304enum FanOutMode {
1305    /// Each `DataFrame` goes to exactly one branch, round-robin (the `Router`
1306    /// model). Used when a frame must not be duplicated (load spreading).
1307    Route,
1308    /// Each `DataFrame` is shared to *every* branch (the `tee` model). The
1309    /// frame's memory is made shareable once (M250), so the per-branch copies
1310    /// are zero-copy refcount bumps, not byte copies.
1311    Broadcast,
1312}
1313
1314#[cfg(feature = "std")]
1315impl FanOutMode {
1316    /// Naming category of the runner's own routing stage, so its telemetry node
1317    /// reads `router0` / `tee0`.
1318    fn category(self) -> &'static str {
1319        match self {
1320            FanOutMode::Route => "router",
1321            FanOutMode::Broadcast => "tee",
1322        }
1323    }
1324}
1325
1326/// Node id of the runner's routing stage in a dynamic fan-out's telemetry
1327/// topology: the source is 0, and branches append from 2 as they attach.
1328#[cfg(feature = "std")]
1329const DYN_FANOUT_NODE: usize = 1;
1330
1331/// Telemetry bookkeeping the router arm of a dynamic fan-out carries, so a branch
1332/// attached mid-run gets named, probed, and put in the observer's topology before
1333/// its first packet (M869). `probes` is shared with the run future, which
1334/// snapshots it into [`RunStats::per_element`] once the arms have joined.
1335#[cfg(feature = "std")]
1336#[derive(Debug)]
1337struct FanoutTap {
1338    obs: Option<Observer>,
1339    namer: crate::log::InstanceNamer,
1340    probes: Arc<Mutex<Vec<Probe>>>,
1341}
1342
1343/// Drives `source -> dynamic router -> N branches`, where branches can be added
1344/// at runtime through the returned [`DynamicFanoutHandle`] (M310 request pads).
1345///
1346/// The router distributes `DataFrame`s round-robin across the currently-attached
1347/// branches (frames are not `Clone`, so this routes rather than broadcasts, the
1348/// `Router` model) and broadcasts `CapsChanged` / `Eos` to every branch. See
1349/// [`run_source_tee_dynamic`] for the broadcast (tee) variant. The fan-out's caps
1350/// are "sticky": the source's fixated output caps are replayed to each branch the
1351/// moment it attaches, so a late branch configures correctly without having seen
1352/// the original negotiation.
1353///
1354/// Returns the handle plus the run future; drive them concurrently (await the
1355/// future while using the handle from another task). The run completes once the
1356/// source ends and every attached branch has drained.
1357#[cfg(feature = "std")]
1358pub fn run_source_router_dynamic<'a, Src>(
1359    source: &'a mut Src,
1360    link_capacity: impl Into<LinkCapacity>,
1361) -> (
1362    DynamicFanoutHandle<'a>,
1363    impl Future<Output = Result<RunStats, G2gError>> + 'a,
1364)
1365where
1366    Src: SourceLoop + 'a,
1367{
1368    run_source_fanout_dynamic(source, link_capacity, FanOutMode::Route, None)
1369}
1370
1371/// As [`run_source_router_dynamic`], but taps live telemetry into `observer`
1372/// (M869). The topology starts as the source plus the runner's routing stage;
1373/// each branch attached through the handle appends its own node and link before
1374/// it sees a packet, so a dashboard polling [`Observer::snapshot`] sees runtime
1375/// branches appear with their measured `process()` latency and link counters.
1376#[cfg(feature = "std")]
1377pub fn run_source_router_dynamic_observed<'a, Src>(
1378    source: &'a mut Src,
1379    link_capacity: impl Into<LinkCapacity>,
1380    observer: &Observer,
1381) -> (
1382    DynamicFanoutHandle<'a>,
1383    impl Future<Output = Result<RunStats, G2gError>> + 'a,
1384)
1385where
1386    Src: SourceLoop + 'a,
1387{
1388    run_source_fanout_dynamic(
1389        source,
1390        link_capacity,
1391        FanOutMode::Route,
1392        Some(observer.clone()),
1393    )
1394}
1395
1396/// Drives `source -> dynamic tee -> N branches` (M319): the broadcast counterpart
1397/// of [`run_source_router_dynamic`]. Each `DataFrame` is shared to *every*
1398/// currently-attached branch via the M250 zero-copy frame-sharing path
1399/// ([`MemoryDomain::make_shareable`](crate::MemoryDomain::make_shareable) once,
1400/// then a refcount handle per branch), so a CPU or GPU frame fans out to N
1401/// consumers with no byte copies. This is the runtime equivalent of GStreamer's
1402/// `tee` request pads: an inference branch and a display branch both see every
1403/// frame, and either can be attached while the pipeline runs.
1404///
1405/// Branch attach, sticky caps, and shutdown are identical to
1406/// [`run_source_router_dynamic`]; only `DataFrame` distribution differs (broadcast
1407/// vs round-robin). `CapsChanged` / `Segment` / `Flush` / `Eos` are broadcast in
1408/// both modes.
1409#[cfg(feature = "std")]
1410pub fn run_source_tee_dynamic<'a, Src>(
1411    source: &'a mut Src,
1412    link_capacity: impl Into<LinkCapacity>,
1413) -> (
1414    DynamicFanoutHandle<'a>,
1415    impl Future<Output = Result<RunStats, G2gError>> + 'a,
1416)
1417where
1418    Src: SourceLoop + 'a,
1419{
1420    run_source_fanout_dynamic(source, link_capacity, FanOutMode::Broadcast, None)
1421}
1422
1423/// As [`run_source_tee_dynamic`], but taps live telemetry into `observer`, the
1424/// broadcast counterpart of [`run_source_router_dynamic_observed`].
1425#[cfg(feature = "std")]
1426pub fn run_source_tee_dynamic_observed<'a, Src>(
1427    source: &'a mut Src,
1428    link_capacity: impl Into<LinkCapacity>,
1429    observer: &Observer,
1430) -> (
1431    DynamicFanoutHandle<'a>,
1432    impl Future<Output = Result<RunStats, G2gError>> + 'a,
1433)
1434where
1435    Src: SourceLoop + 'a,
1436{
1437    run_source_fanout_dynamic(
1438        source,
1439        link_capacity,
1440        FanOutMode::Broadcast,
1441        Some(observer.clone()),
1442    )
1443}
1444
1445/// Shared driver behind [`run_source_router_dynamic`] (route) and
1446/// [`run_source_tee_dynamic`] (broadcast); `mode` selects how each `DataFrame` is
1447/// distributed across the attached branches.
1448#[cfg(feature = "std")]
1449fn run_source_fanout_dynamic<'a, Src>(
1450    source: &'a mut Src,
1451    link_capacity: impl Into<LinkCapacity>,
1452    mode: FanOutMode,
1453    observer: Option<Observer>,
1454) -> (
1455    DynamicFanoutHandle<'a>,
1456    impl Future<Output = Result<RunStats, G2gError>> + 'a,
1457)
1458where
1459    Src: SourceLoop + 'a,
1460{
1461    let link_capacity: usize = link_capacity.into().get();
1462    // Control channel: handle -> router (new branch elements).
1463    let (new_branch_tx, new_branch_rx) =
1464        bounded::<alloc::boxed::Box<dyn DynAsyncElement + 'a>>(link_capacity);
1465    // Arm channel: router -> join (the spawned branch futures).
1466    let (new_arm_tx, new_arm_rx) =
1467        bounded::<BoxFuture<'a, Result<DynArmOut, G2gError>>>(link_capacity);
1468
1469    let handle = DynamicFanoutHandle { new_branch_tx };
1470
1471    let run = async move {
1472        // M869: instance naming as in the static fan-out. The routing stage is the
1473        // runner itself (there is no fan-out element), so it is named after the
1474        // distribution mode; each branch is named and probed when it attaches.
1475        let mut namer = crate::log::InstanceNamer::new();
1476        let source_name = namer.add(crate::log::short_type_name::<Src>(), None);
1477        SourceLoop::set_instance_name(source, source_name.clone());
1478        let fanout_name = namer.add(mode.category(), None);
1479
1480        // The source self-fixates its output caps; that becomes the sticky caps
1481        // replayed to every branch on attach.
1482        let sticky = source.intercept_caps().await?;
1483        source.configure_pipeline(&sticky)?.reject_refixate()?;
1484
1485        let (src_tx, src_rx, src_tap) = link_tapped(link_capacity, observer.is_some());
1486
1487        // Dev-tooling tap: source 0, the routing stage 1. The routing stage has no
1488        // `process()` of its own (like a tee in `run_graph`), so it has no probe.
1489        if let Some(obs) = &observer {
1490            register_runner_tap(
1491                obs,
1492                alloc::vec![
1493                    (source_name, NodeRole::Source, None),
1494                    (fanout_name, NodeRole::Tee, None),
1495                ],
1496                alloc::vec![(0, DYN_FANOUT_NODE, sticky.clone(), src_tap)],
1497            );
1498        }
1499
1500        // Branch probes are minted in the router arm as branches attach, and read
1501        // back here for the report once the arms have joined.
1502        let branch_probes: Arc<Mutex<Vec<Probe>>> = Arc::new(Mutex::new(Vec::new()));
1503        let mut tap = FanoutTap {
1504            obs: observer,
1505            namer,
1506            probes: branch_probes.clone(),
1507        };
1508
1509        let source_fut: BoxFuture<'a, Result<DynArmOut, G2gError>> = Box::pin(async move {
1510            let mut adapter = SenderSink::new(src_tx);
1511            source.run(&mut adapter).await.map(DynArmOut::Source)
1512        });
1513
1514        let router_fut: BoxFuture<'a, Result<DynArmOut, G2gError>> = Box::pin(async move {
1515            let mut ports: Vec<SenderSink> = Vec::new();
1516            let mut rr = 0usize; // round-robin cursor
1517            let mut accepting = true; // poll the control channel until the handle drops
1518            loop {
1519                // Attach every branch queued so far BEFORE routing the next
1520                // packet, so a branch that arrived before a frame never misses
1521                // it (select2 below is left-biased toward the source, so without
1522                // this drain a backlog of frames would be routed to an empty
1523                // port set and dropped).
1524                while let Some(sink) = new_branch_rx.try_recv() {
1525                    attach_branch(
1526                        sink,
1527                        link_capacity,
1528                        &sticky,
1529                        &mut ports,
1530                        &new_arm_tx,
1531                        &mut tap,
1532                    )
1533                    .await?;
1534                }
1535
1536                if accepting {
1537                    match select2(src_rx.recv(), new_branch_rx.recv()).await {
1538                        Either::Left(pkt) => {
1539                            if route_packet(pkt, &mut ports, &mut rr, mode).await? {
1540                                return Ok(DynArmOut::Router); // source ended
1541                            }
1542                        }
1543                        Either::Right(Some(sink)) => {
1544                            attach_branch(
1545                                sink,
1546                                link_capacity,
1547                                &sticky,
1548                                &mut ports,
1549                                &new_arm_tx,
1550                                &mut tap,
1551                            )
1552                            .await?;
1553                        }
1554                        // Handle dropped: stop watching for new branches.
1555                        Either::Right(None) => accepting = false,
1556                    }
1557                } else if route_packet(src_rx.recv().await, &mut ports, &mut rr, mode).await? {
1558                    return Ok(DynArmOut::Router);
1559                }
1560            }
1561        });
1562
1563        // Drop our keep-alive arm sender into the router so that when the router
1564        // returns (source EOS), the arm channel closes and the join can finish.
1565        // (new_arm_tx is moved into router_fut above.)
1566        let arms: Vec<BoxFuture<'a, Result<DynArmOut, G2gError>>> =
1567            alloc::vec![source_fut, router_fut];
1568        let results = dynamic_join(arms, new_arm_rx).await;
1569
1570        let mut emitted = 0u64;
1571        let mut consumed = 0u64;
1572        for r in results {
1573            match r? {
1574                DynArmOut::Source(n) => emitted = n,
1575                DynArmOut::Branch(n) => consumed += n,
1576                DynArmOut::Router => {}
1577            }
1578        }
1579        let per_element = crate::runtime::snapshot_all(&branch_probes.lock());
1580        Ok(RunStats {
1581            frames_emitted: emitted,
1582            frames_consumed: consumed,
1583            frames_dropped: 0,
1584            latency: LatencyReport::ZERO,
1585            allocation: None,
1586            clock_priority: ClockPriority::SystemFallback,
1587            base_time_ns: 0,
1588            coordinator_events: 0,
1589            per_element,
1590        })
1591    };
1592
1593    (handle, run)
1594}
1595
1596/// Attach a runtime-requested branch: name and probe it, register it with the
1597/// observer, give it its own link, replay the sticky caps into it so it
1598/// configures before any frame, add its sender to the port set, and hand its loop
1599/// future to the dynamic join.
1600#[cfg(feature = "std")]
1601async fn attach_branch<'a>(
1602    sink: alloc::boxed::Box<dyn DynAsyncElement + 'a>,
1603    link_capacity: usize,
1604    sticky: &Caps,
1605    ports: &mut Vec<SenderSink>,
1606    new_arm_tx: &Sender<BoxFuture<'a, Result<DynArmOut, G2gError>>>,
1607    tap: &mut FanoutTap,
1608) -> Result<(), G2gError> {
1609    let mut sink = sink;
1610    // Named and probed like a branch the static fan-out was built with, so its
1611    // telemetry row is indistinguishable from one declared up front.
1612    let name = tap.namer.add(sink.log_category(), None);
1613    sink.set_instance_name(name.clone());
1614    let probe = ElementProbe::new(name.clone());
1615    tap.probes.lock().push(Some(probe.clone()));
1616
1617    let (btx, brx, edge) = link_tapped(link_capacity, tap.obs.is_some());
1618    // Register before the sticky caps are queued, so the node is in the topology
1619    // before the arm sees its first packet.
1620    if let Some(obs) = &tap.obs {
1621        let id = obs.add_node(name, NodeRole::Sink, Some(probe.clone()));
1622        obs.add_edge(DYN_FANOUT_NODE, id, sticky.clone(), edge);
1623    }
1624
1625    let mut port = SenderSink::new(btx);
1626    port.push(PipelinePacket::CapsChanged(sticky.clone()))
1627        .await?;
1628    ports.push(port);
1629    let arm: BoxFuture<'a, Result<DynArmOut, G2gError>> = Box::pin(async move {
1630        dyn_branch_loop(sink.as_mut(), brx, &probe)
1631            .await
1632            .map(DynArmOut::Branch)
1633    });
1634    new_arm_tx.try_send(arm).map_err(|_| G2gError::Shutdown)
1635}
1636
1637/// Route one received source packet to the branch ports. A `DataFrame` goes
1638/// either to the next branch round-robin ([`FanOutMode::Route`]) or, shared
1639/// zero-copy, to every branch ([`FanOutMode::Broadcast`], the tee). `CapsChanged`
1640/// / `Segment` / `Flush` broadcast to every branch in both modes (those are
1641/// cloneable). `Eos` (or a closed source channel) is broadcast to all branches
1642/// and returns `Ok(true)` to tell the router the source has ended.
1643#[cfg(feature = "std")]
1644async fn route_packet(
1645    pkt: Option<PipelinePacket>,
1646    ports: &mut [SenderSink],
1647    rr: &mut usize,
1648    mode: FanOutMode,
1649) -> Result<bool, G2gError> {
1650    match pkt {
1651        Some(PipelinePacket::DataFrame(frame)) => {
1652            if !ports.is_empty() {
1653                match mode {
1654                    FanOutMode::Route => {
1655                        let idx = *rr % ports.len();
1656                        *rr = rr.wrapping_add(1);
1657                        ports[idx].push(PipelinePacket::DataFrame(frame)).await?;
1658                    }
1659                    // Tee: share the frame's memory once, then each branch gets a
1660                    // refcount handle (M250 zero-copy fan-out), no byte copy.
1661                    FanOutMode::Broadcast => {
1662                        broadcast(ports, PipelinePacket::DataFrame(frame)).await?;
1663                    }
1664                }
1665            }
1666            // No branches attached: drop the frame (a tee with no src pads).
1667            Ok(false)
1668        }
1669        Some(PipelinePacket::CapsChanged(caps)) => {
1670            for p in ports.iter_mut() {
1671                p.push(PipelinePacket::CapsChanged(caps.clone())).await?;
1672            }
1673            Ok(false)
1674        }
1675        Some(PipelinePacket::Segment(seg)) => {
1676            for p in ports.iter_mut() {
1677                p.push(PipelinePacket::Segment(seg)).await?;
1678            }
1679            Ok(false)
1680        }
1681        Some(PipelinePacket::Flush) => {
1682            for p in ports.iter_mut() {
1683                p.push(PipelinePacket::Flush).await?;
1684            }
1685            Ok(false)
1686        }
1687        Some(PipelinePacket::Eos) | None => {
1688            for p in ports.iter_mut() {
1689                p.push(PipelinePacket::Eos).await?;
1690            }
1691            Ok(true)
1692        }
1693        // A fan-in arm's deadline tick is runner-internal and never crosses a
1694        // link, so nothing to route.
1695        Some(PipelinePacket::Tick) => Ok(false),
1696    }
1697}
1698
1699/// A dynamic branch's loop: configure from the first (sticky) `CapsChanged`, then
1700/// process frames until `Eos` / channel close. The configure path mirrors the
1701/// static fan-out branch arm so a runtime branch negotiates exactly like a built
1702/// one.
1703#[cfg(feature = "std")]
1704async fn dyn_branch_loop(
1705    sink: &mut dyn DynAsyncElement,
1706    rx: crate::runtime::channel::LinkReceiver,
1707    probe: &ElementProbe,
1708) -> Result<u64, G2gError> {
1709    let mut null = NullSink;
1710    let mut consumed = 0u64;
1711    loop {
1712        match rx.recv().await {
1713            Some(PipelinePacket::Eos) => {
1714                sink.process(PipelinePacket::Eos, &mut null).await?;
1715                return Ok(consumed);
1716            }
1717            Some(PipelinePacket::CapsChanged(caps)) => {
1718                let branch_caps = re_solve_downstream_dyn_sink(&caps, &*sink)
1719                    .map_err(|_| G2gError::CapsMismatch)?;
1720                match log_caps_rejected(
1721                    Some(probe.name()),
1722                    &branch_caps,
1723                    sink.configure_pipeline(&branch_caps),
1724                )? {
1725                    ConfigureOutcome::Accepted => {
1726                        sink.process(PipelinePacket::CapsChanged(branch_caps), &mut null)
1727                            .await?;
1728                    }
1729                    ConfigureOutcome::ReFixate(counter) => {
1730                        rx.request_reconfigure(Reconfigure::Propose(counter));
1731                    }
1732                }
1733            }
1734            Some(packet) => {
1735                let is_data = matches!(packet, PipelinePacket::DataFrame(_));
1736                if is_data {
1737                    consumed += 1;
1738                    probe.record_fill(rx.fill_percent());
1739                }
1740                let t0 = is_data.then(ElementProbe::mark).flatten();
1741                sink.process(packet, &mut null).await?;
1742                if is_data {
1743                    probe.record_proc_since(t0);
1744                }
1745            }
1746            None => return Ok(consumed),
1747        }
1748    }
1749}
1750
1751/// Drives a terminal multi-output *source* ([`MultiOutputSource`]: 0 inputs to N
1752/// outputs) into N sinks, with no upstream, the fan-out mirror of
1753/// [`run_fanin_session`](crate::runtime::run_fanin_session). A WHEP session
1754/// receiving A/V over one PeerConnection emits each track on its own pad. Each
1755/// output's caps configure the matching sink; the session pushes via a
1756/// [`MultiSenderSink`] and emits a per-output `Eos` when it ends, which the sink
1757/// arms observe to finish. Per-branch mid-stream re-solve is not wired here yet
1758/// (a follow-up, as for the egress session runner).
1759#[cfg(feature = "std")]
1760pub async fn run_fanout_session<Sess, Clk>(
1761    session: &mut Sess,
1762    sinks: Vec<&mut dyn DynAsyncElement>,
1763    clock: &Clk,
1764    link_capacity: impl Into<LinkCapacity>,
1765) -> Result<RunStats, G2gError>
1766where
1767    Sess: MultiOutputSource,
1768    Clk: PipelineClock,
1769{
1770    run_fanout_session_inner(session, sinks, clock, link_capacity, None).await
1771}
1772
1773/// As [`run_fanout_session`], but taps live telemetry into `observer` (M846):
1774/// the session plus its N recv sinks, each sink's measured `process()` latency
1775/// and every branch link's packet / byte / drop counters readable mid-run via
1776/// [`Observer::snapshot`].
1777#[cfg(feature = "std")]
1778pub async fn run_fanout_session_observed<Sess, Clk>(
1779    session: &mut Sess,
1780    sinks: Vec<&mut dyn DynAsyncElement>,
1781    clock: &Clk,
1782    link_capacity: impl Into<LinkCapacity>,
1783    observer: &Observer,
1784) -> Result<RunStats, G2gError>
1785where
1786    Sess: MultiOutputSource,
1787    Clk: PipelineClock,
1788{
1789    run_fanout_session_inner(session, sinks, clock, link_capacity, Some(observer)).await
1790}
1791
1792#[cfg(feature = "std")]
1793async fn run_fanout_session_inner<Sess, Clk>(
1794    session: &mut Sess,
1795    sinks: Vec<&mut dyn DynAsyncElement>,
1796    _clock: &Clk,
1797    link_capacity: impl Into<LinkCapacity>,
1798    observer: Option<&Observer>,
1799) -> Result<RunStats, G2gError>
1800where
1801    Sess: MultiOutputSource,
1802    Clk: PipelineClock,
1803{
1804    let link_capacity: usize = link_capacity.into().get();
1805    let branch_count = sinks.len();
1806    assert!(branch_count > 0, "fan-out session needs at least one sink");
1807    assert!(
1808        session.output_count() == branch_count,
1809        "session output count must match the number of sinks"
1810    );
1811
1812    // M846: instance naming + one measured-latency probe per sink (the session
1813    // drives itself through `run`, so it has no `process()` to time).
1814    let mut sinks = sinks;
1815    let mut namer = crate::log::InstanceNamer::new();
1816    let session_name = namer.add(crate::log::short_type_name::<Sess>(), None);
1817    let mut sink_probes = Vec::with_capacity(branch_count);
1818    for sink in sinks.iter_mut() {
1819        let name = namer.add(sink.log_category(), None);
1820        sink.set_instance_name(name.clone());
1821        sink_probes.push(ElementProbe::new(name));
1822    }
1823
1824    // Negotiate per output: the session self-fixates each output's caps and
1825    // configures the matching sink (the sink's own constraint is not consulted).
1826    let mut output_caps: Vec<Caps> = Vec::with_capacity(branch_count);
1827    for (i, sink) in sinks.iter_mut().enumerate() {
1828        let fixated = session.output_caps(i)?.fixate()?;
1829        sink.configure_pipeline(&fixated)?.reject_refixate()?;
1830        output_caps.push(fixated);
1831    }
1832
1833    let tap = observer.is_some();
1834    let mut branch_senders = Vec::with_capacity(branch_count);
1835    let mut branch_receivers = Vec::with_capacity(branch_count);
1836    let mut branch_taps = Vec::with_capacity(branch_count);
1837    for _ in 0..branch_count {
1838        let (tx, rx, edge) = link_tapped(link_capacity, tap);
1839        branch_senders.push(SenderSink::new(tx));
1840        branch_receivers.push(rx);
1841        branch_taps.push(edge);
1842    }
1843
1844    // Dev-tooling tap: the session is node 0, its sinks follow.
1845    if let Some(obs) = observer {
1846        let mut nodes: Vec<TapNode> = alloc::vec![(session_name, NodeRole::Source, None)];
1847        let mut edges: Vec<TapEdge> = Vec::with_capacity(branch_count);
1848        for (i, ((probe, caps), edge)) in sink_probes
1849            .iter()
1850            .zip(output_caps.iter())
1851            .zip(core::mem::take(&mut branch_taps))
1852            .enumerate()
1853        {
1854            nodes.push((
1855                alloc::string::String::from(probe.name()),
1856                NodeRole::Sink,
1857                Some(probe.clone()),
1858            ));
1859            edges.push((0, 1 + i, caps.clone(), edge));
1860        }
1861        register_runner_tap(obs, nodes, edges);
1862    }
1863
1864    let session_fut: BoxFuture<'_, Result<u64, G2gError>> = Box::pin(async move {
1865        let mut multi = MultiSenderSink::new(branch_senders);
1866        session.run(&mut multi).await
1867    });
1868
1869    let mut arms: Vec<BoxFuture<'_, Result<u64, G2gError>>> = Vec::with_capacity(branch_count + 1);
1870    arms.push(session_fut);
1871    for ((sink, rx), probe) in sinks
1872        .into_iter()
1873        .zip(branch_receivers)
1874        .zip(sink_probes.iter().cloned())
1875    {
1876        let sink_fut: BoxFuture<'_, Result<u64, G2gError>> = Box::pin(async move {
1877            let mut null = NullSink;
1878            let mut consumed: u64 = 0;
1879            loop {
1880                match rx.recv().await {
1881                    Some(PipelinePacket::Eos) => {
1882                        sink.process(PipelinePacket::Eos, &mut null).await?;
1883                        return Ok::<u64, G2gError>(consumed);
1884                    }
1885                    Some(PipelinePacket::CapsChanged(new_caps)) => {
1886                        match log_caps_rejected(
1887                            Some(probe.name()),
1888                            &new_caps,
1889                            sink.configure_pipeline(&new_caps),
1890                        )? {
1891                            ConfigureOutcome::Accepted => {
1892                                sink.process(PipelinePacket::CapsChanged(new_caps), &mut null)
1893                                    .await?;
1894                            }
1895                            ConfigureOutcome::ReFixate(counter) => {
1896                                rx.request_reconfigure(Reconfigure::Propose(counter));
1897                            }
1898                        }
1899                    }
1900                    Some(packet) => {
1901                        let is_data = matches!(packet, PipelinePacket::DataFrame(_));
1902                        if is_data {
1903                            consumed += 1;
1904                            probe.record_fill(rx.fill_percent());
1905                        }
1906                        let t0 = is_data.then(ElementProbe::mark).flatten();
1907                        sink.process(packet, &mut null).await?;
1908                        if is_data {
1909                            probe.record_proc_since(t0);
1910                        }
1911                    }
1912                    None => return Ok(consumed),
1913                }
1914            }
1915        });
1916        arms.push(sink_fut);
1917    }
1918
1919    let results = join_all(arms).await;
1920    let mut counts = Vec::with_capacity(results.len());
1921    for r in results {
1922        counts.push(r?);
1923    }
1924    // Arm order: [session, sink0, sink1, ...].
1925    let emitted = counts[0];
1926    let consumed: u64 = counts[1..].iter().copied().sum();
1927    Ok(RunStats {
1928        frames_emitted: emitted,
1929        frames_consumed: consumed,
1930        frames_dropped: 0,
1931        latency: LatencyReport::ZERO,
1932        allocation: None,
1933        clock_priority: ClockPriority::SystemFallback,
1934        base_time_ns: 0,
1935        coordinator_events: 0,
1936        per_element: crate::runtime::snapshot_all(
1937            &sink_probes.into_iter().map(Some).collect::<Vec<_>>(),
1938        ),
1939    })
1940}
1941
1942/// Drives an arbitrary-length linear pipeline:
1943/// `source -> transforms[0] -> ... -> transforms[N-1] -> sink`.
1944///
1945/// M18 item 4. Generalizes [`run_source_transform_sink`] (one transform) and
1946/// [`run_simple_pipeline`] (zero) past their fixed arity, lifting the
1947/// "runner caps at 3 elements" limit so chains like
1948/// `decoder -> capsfilter -> converter -> sink` are expressible. Interior
1949/// elements are `&mut dyn DynAsyncElement` (heterogeneous, std-only, the same
1950/// erasure the fan-out runner uses); source and sink stay statically typed.
1951///
1952/// Negotiation runs the solver over all `N + 2` constraints at once and
1953/// configures each element with its input-side caps (the source with link 0).
1954/// Data flows over `N + 1` bounded links across `N + 2` concurrently-joined
1955/// arms. On a mid-stream `CapsChanged` each interior element re-fixates its
1956/// output against a downstream feasibility snapshot (Caps-α), re-allocates its
1957/// own pool (α), and the β allocation re-cascade walks the demand back through
1958/// every interior hop. Clock election and latency aggregation fold the source,
1959/// every interior element, and the sink (via the dyn-safe `DynAsyncElement`
1960/// mirrors).
1961///
1962/// Owed: Caps-β, a forward coordinator re-solve walk for a downstream
1963/// `DerivedOutput` element that must re-derive mid-stream (driver-gated,
1964/// DESIGN.md §4.13.4). ReFixate at startup fails loud
1965/// (`FixationFailed`), as in `run_source_fanout`.
1966#[cfg(feature = "std")]
1967pub async fn run_linear_chain<Src, Snk, Clk>(
1968    source: &mut Src,
1969    transforms: Vec<&mut dyn DynAsyncElement>,
1970    sink: &mut Snk,
1971    clock: &Clk,
1972    link_capacity: impl Into<LinkCapacity>,
1973) -> Result<RunStats, G2gError>
1974where
1975    Src: SourceLoop,
1976    Snk: AsyncElement,
1977    Clk: PipelineClock,
1978{
1979    run_linear_chain_inner(source, transforms, sink, clock, link_capacity, None).await
1980}
1981
1982/// As [`run_linear_chain`], but posts a structured
1983/// [`BusMessage::NegotiationFailed`](crate::BusMessage::NegotiationFailed) to
1984/// `bus` on a startup or mid-stream negotiation failure (M18 item 7).
1985#[cfg(feature = "std")]
1986pub async fn run_linear_chain_with_bus<Src, Snk, Clk>(
1987    source: &mut Src,
1988    transforms: Vec<&mut dyn DynAsyncElement>,
1989    sink: &mut Snk,
1990    clock: &Clk,
1991    link_capacity: impl Into<LinkCapacity>,
1992    bus: &BusHandle,
1993) -> Result<RunStats, G2gError>
1994where
1995    Src: SourceLoop,
1996    Snk: AsyncElement,
1997    Clk: PipelineClock,
1998{
1999    run_linear_chain_inner(source, transforms, sink, clock, link_capacity, Some(bus)).await
2000}
2001
2002#[cfg(feature = "std")]
2003async fn run_linear_chain_inner<Src, Snk, Clk>(
2004    source: &mut Src,
2005    transforms: Vec<&mut dyn DynAsyncElement>,
2006    sink: &mut Snk,
2007    clock: &Clk,
2008    link_capacity: impl Into<LinkCapacity>,
2009    bus: Option<&BusHandle>,
2010) -> Result<RunStats, G2gError>
2011where
2012    Src: SourceLoop,
2013    Snk: AsyncElement,
2014    Clk: PipelineClock,
2015{
2016    // D5: thin builder over the DAG runner. A linear chain maps onto a
2017    // source -> transform* -> sink path; `run_graph` owns negotiation, the M12
2018    // stat folds, the β allocation re-cascade, and the Caps-α mid-stream
2019    // re-solve (graceful on this single-producer chain: no tee upstream).
2020    let mut g: Graph<GraphNodeRef<'_>> = Graph::new();
2021    let mut prev = g.add_source(GraphNodeRef::source_ref(source));
2022    for t in transforms {
2023        let node = g.add_transform(GraphNodeRef::element_ref(t));
2024        g.link(prev, node).map_err(|_| G2gError::CapsMismatch)?;
2025        prev = node;
2026    }
2027    let snk = g.add_sink(GraphNodeRef::element_ref(sink));
2028    g.link(prev, snk).map_err(|_| G2gError::CapsMismatch)?;
2029
2030    run_graph_inner(
2031        g,
2032        clock,
2033        link_capacity,
2034        bus,
2035        None,
2036        None,
2037        None,
2038        None,
2039        None,
2040        None,
2041    )
2042    .await
2043}
2044
2045/// Sentinel sink for terminal elements (sinks proper): swallows pushes.
2046/// Process implementations of true sinks should not emit, but the type
2047/// system still requires an `&mut dyn OutputSink` parameter.
2048#[derive(Debug)]
2049pub(crate) struct NullSink;
2050
2051impl OutputSink for NullSink {
2052    fn poll_push(
2053        &mut self,
2054        _cx: &mut core::task::Context<'_>,
2055        packet: &mut Option<PipelinePacket>,
2056    ) -> core::task::Poll<Result<PushOutcome, G2gError>> {
2057        packet.take();
2058        core::task::Poll::Ready(Ok(PushOutcome::Accepted))
2059    }
2060}
2061
2062/// Drives a `source → transform → sink` pipeline over two bounded links.
2063///
2064/// Transform contract: `process(Eos)` may flush buffered state as
2065/// `DataFrame` packets; the runner forwards the EOS sentinel downstream after
2066/// `process(Eos)` returns, and skips its own push when the element already
2067/// forwarded one (M909), so exactly one `Eos` reaches the sink either way.
2068///
2069/// `link_capacity` is the primary glass-to-glass latency knob. Under
2070/// steady-state backpressure each link sits full, so the latency floor is
2071/// roughly `2 * link_capacity * consumer_period`. For live video pipelines
2072/// (RTSP -> decode -> display) prefer **2**; for batch / throughput-oriented
2073/// workloads larger values are fine.
2074pub async fn run_source_transform_sink<Src, Tx, Snk, Clk>(
2075    source: &mut Src,
2076    transform: &mut Tx,
2077    sink: &mut Snk,
2078    clock: &Clk,
2079    link_capacity: impl Into<LinkCapacity>,
2080) -> Result<RunStats, G2gError>
2081where
2082    Src: SourceLoop,
2083    Tx: AsyncElement,
2084    Snk: AsyncElement,
2085    Clk: PipelineClock,
2086{
2087    run_source_transform_sink_inner(source, transform, sink, clock, link_capacity, None).await
2088}
2089
2090/// As [`run_source_transform_sink`], but posts a structured
2091/// [`BusMessage::NegotiationFailed`](crate::BusMessage::NegotiationFailed)
2092/// to `bus` so the application learns *which* link conflicted (the returned
2093/// error stays the opaque `CapsMismatch`). M18 item 7. Covers both startup
2094/// negotiation and the mid-stream re-solve sites (the sink's Phase-B re-solve
2095/// and the transform's Caps-α `Infeasible`). The bus is opt-in to keep the
2096/// common call site unchanged.
2097pub async fn run_source_transform_sink_with_bus<Src, Tx, Snk, Clk>(
2098    source: &mut Src,
2099    transform: &mut Tx,
2100    sink: &mut Snk,
2101    clock: &Clk,
2102    link_capacity: impl Into<LinkCapacity>,
2103    bus: &BusHandle,
2104) -> Result<RunStats, G2gError>
2105where
2106    Src: SourceLoop,
2107    Tx: AsyncElement,
2108    Snk: AsyncElement,
2109    Clk: PipelineClock,
2110{
2111    run_source_transform_sink_inner(source, transform, sink, clock, link_capacity, Some(bus)).await
2112}
2113
2114async fn run_source_transform_sink_inner<Src, Tx, Snk, Clk>(
2115    source: &mut Src,
2116    transform: &mut Tx,
2117    sink: &mut Snk,
2118    clock: &Clk,
2119    link_capacity: impl Into<LinkCapacity>,
2120    bus: Option<&BusHandle>,
2121) -> Result<RunStats, G2gError>
2122where
2123    Src: SourceLoop,
2124    Tx: AsyncElement,
2125    Snk: AsyncElement,
2126    Clk: PipelineClock,
2127{
2128    let link_capacity: usize = link_capacity.into().get();
2129    // M842: instance naming + lifecycle logging, as in `run_graph`; the two
2130    // interior names key the probes below.
2131    let mut namer = crate::log::InstanceNamer::new();
2132    let source_name = namer.add(crate::log::short_type_name::<Src>(), None);
2133    SourceLoop::set_instance_name(source, source_name.clone());
2134    let transform_name = namer.add(crate::log::short_type_name::<Tx>(), None);
2135    AsyncElement::set_instance_name(transform, transform_name.clone());
2136    let sink_name = namer.add(crate::log::short_type_name::<Snk>(), None);
2137    AsyncElement::set_instance_name(sink, sink_name.clone());
2138    // M18 Session C: the startup negotiation loop (solver + per-link
2139    // configure cascade with bounded `ReFixate` retry) is owned by the
2140    // coordinator module now, since β reuses the same machinery for the
2141    // mid-stream re-cascade. `sink_link` is the downstream-facing caps
2142    // (transform output = sink input) that M12 allocation flows along,
2143    // so it stands in for the loop's former `negotiated_caps`.
2144    // M12 allocation query now runs *inside* negotiation, before the
2145    // `configure_pipeline` cascade, so a transform (e.g. a hardware decoder)
2146    // sizes its buffer pool from the downstream `min_buffers` at open time.
2147    // The folded source-facing proposal comes back on `RunStats`.
2148    let negotiation = negotiate_source_transform_sink(source, transform, sink, bus).await?;
2149    let allocation = negotiation.allocation;
2150
2151    // Caps-α: the transform's downstream subgraph is the single sink link, so
2152    // its feasibility snapshot is just the sink's accept set (the N-hop sweep
2153    // in `run_linear_chain` reduces to this for one transform). A wildcard or
2154    // legacy sink leaves it unconstrained, so the transform keeps forwarding
2155    // greedily (Defer).
2156    let downstream_feasible = match sink.caps_constraint_as_sink() {
2157        CapsConstraint::Accepts(s) => Some(s.clone()),
2158        _ => None,
2159    };
2160
2161    // M12 latency query: fold the configured chain source → transform → sink.
2162    let latency = LatencyReport::aggregate([
2163        source.latency(),
2164        AsyncElement::latency(transform),
2165        AsyncElement::latency(sink),
2166    ]);
2167
2168    // M12 clock distribution: elect the pipeline clock from any element that
2169    // offers one (live source > provider > system fallback) and read its epoch.
2170    let elected = elect_clock([
2171        source.provide_clock(),
2172        AsyncElement::provide_clock(transform),
2173        AsyncElement::provide_clock(sink),
2174    ]);
2175    let (clock_priority, base_time_ns) = match &elected {
2176        Some(c) => (c.priority, c.clock.now_ns()),
2177        None => (ClockPriority::SystemFallback, clock.now_ns()),
2178    };
2179
2180    // Hand the elected clock + base time to the sink so it can present each
2181    // frame at its running-time deadline (PTS pacing). Only when a clock was
2182    // elected; without one the sink presents as fast as backpressure allows.
2183    if let Some(c) = &elected {
2184        AsyncElement::set_clock_sync(
2185            sink,
2186            ClockSync::new(c.clock.clone(), base_time_ns).with_path_latency(latency),
2187        );
2188    }
2189
2190    let (link1_tx, link1_rx) = link(link_capacity);
2191    let (link2_tx, link2_rx) = link(link_capacity);
2192    advertise_orientation(&link2_rx, AsyncElement::absorbs_orientation(sink));
2193
2194    // M18 β: a single coordinator task owns the cross-element re-cascade.
2195    // The sink arm reports an applied mid-stream `CapsChanged` (with its
2196    // re-derived allocation proposal) out-of-band
2197    // (DESIGN.md §4.13.5); the coordinator
2198    // forwards the proposal one hop upstream over `transform_ctrl_rx` to the
2199    // transform's `configure_allocation`. The transform arm selects on that
2200    // control receiver alongside its data link, so the directive reaches it
2201    // even while it is parked on `recv().await`. When the sink arm finishes,
2202    // the handle drops, the coordinator drains and closes `transform_ctrl_rx`,
2203    // and the transform arm's EOS-drain unblocks.
2204    let (coord, coord_handle, transform_ctrl_rx) = coordinator_with_recascade(link_capacity);
2205
2206    // M399: measured per-element telemetry for the two interior elements; each
2207    // arm writes its own probe, the runner snapshots them once both have joined.
2208    let transform_probe = ElementProbe::new(transform_name);
2209    let sink_probe = ElementProbe::new(sink_name);
2210    let probe_for_transform = transform_probe.clone();
2211    let probe_for_sink = sink_probe.clone();
2212
2213    let source_fut = async move {
2214        let mut adapter = SenderSink::new(link1_tx);
2215        // M81: unlike `run_simple_pipeline` and `run_graph`, this bespoke
2216        // 3-element runner does NOT emit an opening SEGMENT. Prepending a packet
2217        // here can exactly fill a link feeding a buffering transform and trip a
2218        // shutdown race in this hand-rolled data plane (a latent exact-capacity
2219        // fragility, tracked separately). The opening SEGMENT lands once this
2220        // runner is re-expressed as a thin builder over `run_graph` (as
2221        // `run_linear_chain` already is). Use `run_graph` / `run_linear_chain`
2222        // for the SEGMENT-emitting path.
2223        source.run(&mut adapter).await
2224    };
2225
2226    let bus_for_transform = bus.cloned();
2227    // Caps-α: the transform's startup-solved output, tracked across applied
2228    // mid-stream changes so the re-solve can keep the shape it already produces.
2229    let mut transform_out_caps = negotiation.sink_link.clone();
2230    let transform_fut = async move {
2231        let ctrl_rx = transform_ctrl_rx;
2232        let probe_for_transform = probe_for_transform;
2233        let mut adapter = SenderSink::new(link2_tx);
2234        // M947: the sink's backpressure is the transform's push-wait, not its work.
2235        adapter.set_push_wait_probe(Some(probe_for_transform.clone()));
2236        // M175: relay a QoS report from the sink (seen on the transform's output
2237        // link) onto the transform's input link, so the source observes it as
2238        // `PushOutcome::Qos` and sheds load. Without this the report dies at the
2239        // transform (its `process` push outcome is discarded). M720 extends the
2240        // same hop to keyframe requests / bitrate targets the transform does
2241        // not consume itself; M997 does the same for QoS, so a decoder that
2242        // sheds work itself observes the report instead of relaying it.
2243        if !transform.handles_qos() {
2244            adapter.relay_qos_to(link1_rx.qos_slot());
2245        }
2246        adapter.relay_reconfigure_to(
2247            link1_rx.reconfigure_slot(),
2248            ReconfigureAnswered {
2249                keyframe: transform.handles_keyframe_requests(),
2250                orientation: transform.handles_orientation(),
2251            },
2252        );
2253        if !transform.handles_bitrate_requests() {
2254            adapter.relay_bitrate_to(link1_rx.bitrate_slot());
2255        }
2256        // β: while the coordinator is alive, race the data link against the
2257        // re-cascade control channel so a directive is applied promptly. Once
2258        // control closes (coordinator gone) we degrade to data-only so the
2259        // closed arm can't spin.
2260        let mut control_open = true;
2261        loop {
2262            let packet = if control_open {
2263                match select2(ctrl_rx.recv(), link1_rx.recv()).await {
2264                    Either::Left(Some(directive)) => {
2265                        // β: apply the sink's downstream-derived proposal to
2266                        // our own output pool, then keep waiting for data.
2267                        transform.configure_allocation(directive.params());
2268                        continue;
2269                    }
2270                    Either::Left(None) => {
2271                        control_open = false;
2272                        continue;
2273                    }
2274                    Either::Right(packet) => packet,
2275                }
2276            } else {
2277                link1_rx.recv().await
2278            };
2279            match packet {
2280                Some(PipelinePacket::Eos) => {
2281                    transform.process(PipelinePacket::Eos, &mut adapter).await?;
2282                    // M909: an element whose catch-all arm forwards the packet
2283                    // has already sent the sentinel; a second push races the
2284                    // sink's exit on the first one and surfaces as `Shutdown`.
2285                    if !adapter.eos_forwarded() {
2286                        adapter.push(PipelinePacket::Eos).await?;
2287                    }
2288                    // β: the EOS we just forwarded will, once the sink applies
2289                    // its final `CapsChanged` and the coordinator forwards the
2290                    // matching re-cascade, close this control channel. Drain it
2291                    // first so a tail-end proposal is applied before we exit
2292                    // (in a live stream these apply inline above; this only
2293                    // covers the directive still in flight at shutdown).
2294                    while control_open {
2295                        match ctrl_rx.recv().await {
2296                            Some(directive) => {
2297                                transform.configure_allocation(directive.params());
2298                            }
2299                            None => control_open = false,
2300                        }
2301                    }
2302                    return Ok::<(), G2gError>(());
2303                }
2304                Some(PipelinePacket::CapsChanged(new_caps)) => {
2305                    // Caps-α (D3): derive the forwarded output from the
2306                    // transform's constraint, steered by the sink's accept set,
2307                    // instead of forwarding greedily. Mirrors `run_linear_chain`;
2308                    // here the downstream subgraph is the single sink link.
2309                    // `Infeasible` means the sink positively rejects every
2310                    // output the transform can produce: surface it loud as a
2311                    // reverse reconfigure into this boundary.
2312                    let (forward_caps, output_resolved) = {
2313                        let constraint = transform.caps_constraint_as_transform();
2314                        match resolve_forward_output(
2315                            &constraint,
2316                            &new_caps,
2317                            downstream_feasible.as_ref(),
2318                            Some(&transform_out_caps),
2319                        ) {
2320                            ForwardResolve::Fixed(caps) => (caps, true),
2321                            ForwardResolve::Defer => (new_caps.clone(), false),
2322                            ForwardResolve::Infeasible(failure) => {
2323                                report_nego_failure(bus_for_transform.as_ref(), failure);
2324                                link1_rx.request_reconfigure(Reconfigure::Renegotiate);
2325                                continue;
2326                            }
2327                        }
2328                    };
2329                    let instance = Some(probe_for_transform.name());
2330                    log_caps_forward(instance, &new_caps, &forward_caps, output_resolved);
2331                    match log_caps_rejected(
2332                        instance,
2333                        &new_caps,
2334                        transform.configure_pipeline(&new_caps),
2335                    )? {
2336                        ConfigureOutcome::Accepted => {
2337                            // M188: a caps-driven transform re-resolves its output
2338                            // target on the mid-stream change too, not just at
2339                            // startup, so a videoscale/videoconvert fed by a
2340                            // downstream capsfilter retargets when caps shift.
2341                            // No-op for property-driven / passthrough elements.
2342                            // Skipped on a Defer, where `forward_caps` is the
2343                            // incoming INPUT caps, not this element's output
2344                            // (the contract is output caps only).
2345                            if output_resolved {
2346                                log_caps_rejected(
2347                                    instance,
2348                                    &forward_caps,
2349                                    AsyncElement::configure_output(transform, &forward_caps),
2350                                )?;
2351                            }
2352                            // M18 α: element-local re-allocation under the
2353                            // re-fixated output caps before forwarding.
2354                            realloc_local(transform, &forward_caps);
2355                            if output_resolved {
2356                                transform_out_caps = forward_caps.clone();
2357                            }
2358                            transform
2359                                .process(PipelinePacket::CapsChanged(forward_caps), &mut adapter)
2360                                .await?;
2361                        }
2362                        // Mid-stream ReFixate: fire upstream via this
2363                        // element's input link, drop the rejected
2364                        // CapsChanged. Piece 4 will source-side react.
2365                        ConfigureOutcome::ReFixate(counter) => {
2366                            link1_rx.request_reconfigure(Reconfigure::Propose(counter));
2367                        }
2368                    }
2369                }
2370                Some(packet) => {
2371                    // M399: time the data-frame `process()` and sample input fill.
2372                    let timed = matches!(&packet, PipelinePacket::DataFrame(_))
2373                        .then(|| &*probe_for_transform);
2374                    if let Some(p) = timed {
2375                        p.record_fill(link1_rx.fill_percent());
2376                    }
2377                    let t0 = ElementProbe::mark();
2378                    transform.process(packet, &mut adapter).await?;
2379                    if let Some(p) = timed {
2380                        p.record_proc_since(t0);
2381                    }
2382                    // M1036: renegotiation this transform originates rather
2383                    // than relays (a decoder that read a new resolution out of
2384                    // the bitstream) goes up its own input link, where the
2385                    // source observes it as `PushOutcome::Reconfigure`.
2386                    if let Some(reconf) = transform.take_reconfigure() {
2387                        link1_rx.request_reconfigure(reconf);
2388                    }
2389                }
2390                None => return Ok(()),
2391            }
2392        }
2393    };
2394
2395    let bus_for_sink = bus.cloned();
2396    let sink_fut = async move {
2397        let coord_handle = coord_handle;
2398        let bus_for_sink = bus_for_sink;
2399        let probe_for_sink = probe_for_sink;
2400        let mut null = NullSink;
2401        let mut consumed: u64 = 0;
2402        loop {
2403            match link2_rx.recv().await {
2404                Some(PipelinePacket::Eos) => {
2405                    sink.process(PipelinePacket::Eos, &mut null).await?;
2406                    return Ok::<u64, G2gError>(consumed);
2407                }
2408                Some(PipelinePacket::CapsChanged(new_caps)) => {
2409                    // M16 workaround #3 Phase B: re-solve the downstream
2410                    // subgraph (boundary → sink) before applying. The
2411                    // boundary that emitted this `CapsChanged` is the
2412                    // transform; the subgraph is the one link feeding
2413                    // the sink. A `NegotiationFailure` here means the
2414                    // sink's declared `CapsConstraint` rejects the
2415                    // boundary's output — surface it as a reverse
2416                    // Reconfigure into the transform (§7 forward ×
2417                    // reverse race: terminates at the boundary, does
2418                    // not propagate past the source).
2419                    let sink_caps = match re_solve_downstream_sink(&new_caps, &*sink) {
2420                        Ok(caps) => caps,
2421                        Err(failure) => {
2422                            // M18 item 7: the mid-stream re-solve is the case
2423                            // the bus matters most for, there is no synchronous
2424                            // return to carry the detail. Post the structured
2425                            // failure, then drive the reverse Reconfigure.
2426                            report_nego_failure(bus_for_sink.as_ref(), failure);
2427                            link2_rx.request_reconfigure(Reconfigure::Renegotiate);
2428                            continue;
2429                        }
2430                    };
2431                    match log_caps_rejected(
2432                        Some(probe_for_sink.name()),
2433                        &sink_caps,
2434                        sink.configure_pipeline(&sink_caps),
2435                    )? {
2436                        ConfigureOutcome::Accepted => {
2437                            // M18 α: element-local re-allocation under the
2438                            // new caps before the sink sees the packet. The
2439                            // returned proposal is what the sink now wants
2440                            // its upstream to allocate.
2441                            let proposal = realloc_local(sink, &sink_caps);
2442                            // M18 β: report the applied caps change plus the
2443                            // sink's re-derived proposal so the coordinator
2444                            // forwards it one hop upstream to the transform's
2445                            // `configure_allocation` (the single-hop cascade).
2446                            coord_handle
2447                                .report(CoordinatorEvent::CapsChanged {
2448                                    caps: sink_caps.clone(),
2449                                    proposal,
2450                                })
2451                                .await;
2452                            sink.process(PipelinePacket::CapsChanged(sink_caps), &mut null)
2453                                .await?;
2454                        }
2455                        ConfigureOutcome::ReFixate(counter) => {
2456                            link2_rx.request_reconfigure(Reconfigure::Propose(counter));
2457                        }
2458                    }
2459                }
2460                Some(packet) => {
2461                    let is_buffer = matches!(packet, PipelinePacket::DataFrame(_));
2462                    if is_buffer {
2463                        consumed += 1;
2464                    }
2465                    // M399: time the data-frame `process()` and sample input fill.
2466                    let timed = is_buffer.then(|| &*probe_for_sink);
2467                    if let Some(p) = timed {
2468                        p.record_fill(link2_rx.fill_percent());
2469                    }
2470                    let t0 = ElementProbe::mark();
2471                    sink.process(packet, &mut null).await?;
2472                    if let Some(p) = timed {
2473                        p.record_proc_since(t0);
2474                    }
2475                    // M175 upstream QoS: a late sink stores its report on the
2476                    // link feeding it; the transform's output adapter relays it
2477                    // one hop further upstream (see `relay_qos_to` above).
2478                    if let Some(qos) = sink.take_qos() {
2479                        link2_rx.request_qos(qos);
2480                    }
2481                    // Keyframe-request / renegotiation up the reverse channel; the
2482                    // transform's output adapter relays it one hop toward the encoder.
2483                    if let Some(reconf) = sink.take_reconfigure() {
2484                        link2_rx.request_reconfigure(reconf);
2485                    }
2486                    if let Some(bps) = sink.take_bitrate() {
2487                        link2_rx.request_bitrate(bps);
2488                    }
2489                }
2490                None => return Ok(consumed),
2491            }
2492        }
2493    };
2494
2495    // The coordinator task drains the control channel until the sink arm
2496    // drops its handle. Joined as a fourth arm so it runs concurrently.
2497    let coordinator_fut = coord.run();
2498
2499    let (src_res, (tx_res, (snk_res, coordinator_events))) = Join2::new(
2500        source_fut,
2501        Join2::new(transform_fut, Join2::new(sink_fut, coordinator_fut)),
2502    )
2503    .await;
2504    // M81: prefer a substantive error over a secondary `Shutdown`. A real error
2505    // in the transform or sink closes a link, which can surface as `Shutdown` on
2506    // the source arm (checked first); without this, that masks the real cause.
2507    if let Some(e) = substantive_error([
2508        (source_name.as_str(), src_res.as_ref().err()),
2509        (transform_probe.name(), tx_res.as_ref().err()),
2510        (sink_probe.name(), snk_res.as_ref().err()),
2511    ]) {
2512        return Err(e);
2513    }
2514    let emitted = src_res?;
2515    tx_res?;
2516    let consumed = snk_res?;
2517
2518    // M399: both arms have joined; snapshot the transform and sink probes in
2519    // topological order (source carries no `process()` and is omitted).
2520    let per_element = alloc::vec![transform_probe.snapshot(), sink_probe.snapshot()];
2521    Ok(RunStats {
2522        frames_emitted: emitted,
2523        frames_consumed: consumed,
2524        frames_dropped: 0,
2525        latency,
2526        allocation,
2527        clock_priority,
2528        base_time_ns,
2529        coordinator_events,
2530        per_element,
2531    })
2532}
2533
2534#[cfg(test)]
2535mod profile_tests {
2536    use super::*;
2537
2538    #[test]
2539    fn live_profile_maps_to_capacity_2() {
2540        assert_eq!(LatencyProfile::Live.link_capacity().get(), 2);
2541    }
2542
2543    #[test]
2544    fn run_stats_report_formats_drops_and_latency() {
2545        let stats = RunStats {
2546            frames_emitted: 100,
2547            frames_consumed: 90,
2548            frames_dropped: 10,
2549            latency: LatencyReport {
2550                live: true,
2551                min_ns: 5_000_000,
2552                max_ns: Some(20_000_000),
2553            },
2554            ..RunStats::default()
2555        };
2556        let r = stats.report();
2557        // Frame line with the computed drop rate (10 / (90 + 10) = 10%).
2558        assert!(
2559            r.contains("emitted 100, consumed 90, dropped 10 (10.0% drop)"),
2560            "{r}"
2561        );
2562        // Declared latency window + live flag.
2563        assert!(r.contains("5.0 ms .. 20.0 ms (live) [declared]"), "{r}");
2564        assert!(r.contains("clock:"), "{r}");
2565
2566        // An unbounded-latency, lossless pipeline reads cleanly too.
2567        let clean = RunStats {
2568            frames_emitted: 5,
2569            frames_consumed: 5,
2570            latency: LatencyReport {
2571                live: false,
2572                min_ns: 0,
2573                max_ns: None,
2574            },
2575            ..RunStats::default()
2576        };
2577        let r = clean.report();
2578        assert!(r.contains("(0.0% drop)"), "{r}");
2579        assert!(r.contains("0.0 ms .. unbounded (non-live)"), "{r}");
2580    }
2581
2582    #[test]
2583    fn throughput_profile_maps_to_capacity_8() {
2584        assert_eq!(LatencyProfile::Throughput.link_capacity().get(), 8);
2585    }
2586
2587    #[test]
2588    fn custom_profile_passes_through() {
2589        assert_eq!(LatencyProfile::Custom(16).link_capacity().get(), 16);
2590    }
2591
2592    #[test]
2593    fn link_capacity_clamps_zero_to_one() {
2594        // A zero-depth link would deadlock the producer on its first push;
2595        // the constructor clamps so callers passing `0` (or a misconfigured
2596        // env var) get a runnable pipeline rather than a hang.
2597        assert_eq!(LinkCapacity::new(0).get(), 1);
2598        assert_eq!(LinkCapacity::from(0usize).get(), 1);
2599        assert_eq!(LatencyProfile::Custom(0).link_capacity().get(), 1);
2600    }
2601
2602    #[test]
2603    fn from_usize_and_from_profile_compose_through_into() {
2604        // The runner takes `impl Into<LinkCapacity>`; both an integer and
2605        // a profile must reach the same internal usize without ceremony.
2606        fn take<C: Into<LinkCapacity>>(c: C) -> usize {
2607            c.into().get()
2608        }
2609        assert_eq!(take(4usize), 4);
2610        assert_eq!(take(LatencyProfile::Live), 2);
2611        assert_eq!(take(LatencyProfile::Throughput), 8);
2612    }
2613}