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}