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