g2g_core/fanout.rs
1//! Fan-out primitives for the dynamic graph layer (DESIGN.md §4.8.4).
2//!
3//! M9 (1→N slice): a multi-output sink abstraction plus the two routing
4//! primitives that cover branch enable/disable and A/B switching:
5//!
6//! - [`Gate`] — 1→1. Forwards or drops each `DataFrame` by an atomic flag.
7//! It is a plain [`AsyncElement`], so it drops into the existing
8//! `run_source_transform_sink` runner unchanged.
9//! - [`Router`] — 1→N. Sends each `DataFrame` to exactly one output port
10//! chosen by an atomic discriminator, and broadcasts `CapsChanged` to
11//! every port. It implements [`MultiOutputElement`], driven by the
12//! `run_source_fanout` runner.
13//!
14//! Both expose a cloneable control handle ([`GateHandle`], [`RouterHandle`]),
15//! mirroring `SwapHandle` (`slot.rs`), so application code or another task
16//! flips routing mid-stream without stalling the pipeline.
17//!
18//! The Merger (fan-in) and `BranchSlot` are a later slice. EOS broadcast on
19//! the `Router` is the runner's responsibility, matching the existing
20//! "runner forwards Eos" transform contract.
21
22use alloc::boxed::Box;
23use alloc::sync::Arc;
24use alloc::vec::Vec;
25use core::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
26
27use crate::caps::Caps;
28use crate::element::{
29 AsyncElement, BoxFuture, ConfigureOutcome, ElementBound, OutputSink, PushOutcome, Reconfigure,
30};
31use crate::error::G2gError;
32use crate::format_element::CapsConstraint;
33use crate::frame::PipelinePacket;
34use crate::memory::DomainSet;
35use crate::property::{ElementMetadata, PropError, PropValue, PropertySpec};
36use crate::runtime::SenderSink;
37use crate::runtime::{PadKind, PadRequest};
38use portable_atomic::AtomicU64;
39
40/// Sentinel for "no bitrate pending" in a [`ReverseChannel`] (a real target is
41/// a `u32`, so `u64::MAX` can never collide).
42const NO_BITRATE: u64 = u64::MAX;
43
44/// A per-input reverse-signal handle a fan-in session shares with the runner, so
45/// a signal that originates on one track (a WebRTC PLI or BWE estimate arriving
46/// for a given m-line) reaches the *matching* upstream source rather than being
47/// broadcast or lost. It is the multi-input analog of the single-track
48/// [`AsyncElement::take_reconfigure`](crate::AsyncElement::take_reconfigure) /
49/// [`take_bitrate`](crate::AsyncElement::take_bitrate) the linear runner polls.
50///
51/// Cloneable and `Send` (`Arc`-backed atomics): the session task (which owns the
52/// `Rtc` / network handle) writes with [`request_keyframe`](Self::request_keyframe)
53/// / [`set_bitrate`](Self::set_bitrate); the runner's per-source adapter reads
54/// the highest-priority pending signal with [`take`](Self::take) after each push
55/// and surfaces it to that source as a [`PushOutcome`], exactly as a linked sink
56/// would. Reconfigure outranks bitrate, matching the linear reverse channel.
57#[derive(Debug, Clone)]
58pub struct ReverseChannel {
59 keyframe: Arc<AtomicBool>,
60 bitrate: Arc<AtomicU64>,
61}
62
63impl Default for ReverseChannel {
64 fn default() -> Self {
65 Self {
66 keyframe: Arc::new(AtomicBool::new(false)),
67 bitrate: Arc::new(AtomicU64::new(NO_BITRATE)),
68 }
69 }
70}
71
72impl ReverseChannel {
73 /// A fresh channel with nothing pending.
74 pub fn new() -> Self {
75 Self::default()
76 }
77
78 /// Session side: request an upstream keyframe (a remote PLI for this track).
79 pub fn request_keyframe(&self) {
80 self.keyframe.store(true, Ordering::Relaxed);
81 }
82
83 /// Session side: relay a target send bitrate (bits/second) for this track.
84 pub fn set_bitrate(&self, bps: u32) {
85 self.bitrate.store(bps as u64, Ordering::Relaxed);
86 }
87
88 /// Runner side: consume the highest-priority pending signal as a
89 /// [`PushOutcome`], or `None` if nothing is pending. Reconfigure (keyframe)
90 /// takes priority over a bitrate update, as on the linear reverse channel.
91 pub fn take(&self) -> Option<PushOutcome> {
92 if self.keyframe.swap(false, Ordering::Relaxed) {
93 return Some(PushOutcome::Reconfigure(Reconfigure::ForceKeyframe));
94 }
95 match self.bitrate.swap(NO_BITRATE, Ordering::Relaxed) {
96 NO_BITRATE => None,
97 bps => Some(PushOutcome::Bitrate(bps as u32)),
98 }
99 }
100}
101
102/// Downstream output addressing one of N ports. The fan-out analog of
103/// [`OutputSink`]: `push_to` selects the destination port. Dyn-safe via the
104/// poll form (no heap), mirroring [`OutputSink::poll_push`]; `push_to` wraps
105/// it in the concrete [`PushToFuture`] so `&mut dyn MultiOutputSink` callers
106/// await it unchanged.
107pub trait MultiOutputSink {
108 /// Drive one packet toward `port`. Same slot contract as
109 /// [`OutputSink::poll_push`].
110 fn poll_push_to(
111 &mut self,
112 cx: &mut core::task::Context<'_>,
113 port: usize,
114 packet: &mut Option<PipelinePacket>,
115 ) -> core::task::Poll<Result<PushOutcome, G2gError>>;
116
117 /// Discard any phase a cancelled earlier push to `port` left behind.
118 fn begin_push_to(&mut self, _port: usize) {}
119
120 fn port_count(&self) -> usize;
121
122 /// Add an output port carrying `caps` and return its index, or `None` if this
123 /// sink cannot grow (M1014). A duplex session calls it when the peer adds a
124 /// track and none of its declared pads is free; the dynamic duplex runner
125 /// answers by building that port's link and finding it a sink. Never blocks,
126 /// so it is callable from a session's own poll loop: a sink that cannot take
127 /// the port right now answers `None` and the caller keeps whatever it does for
128 /// a track it cannot place.
129 ///
130 /// Default `None`: the fixed-arity multi-sinks the static runners build refuse
131 /// to grow, so a session written against this sees exactly today's behavior.
132 fn add_port(&mut self, _caps: &Caps) -> Option<usize> {
133 None
134 }
135}
136
137/// `push_to` for concrete (sized) sinks; split out for the same
138/// dyn-vs-provided-method ambiguity [`OutputSinkExt`] resolves.
139///
140/// [`OutputSinkExt`]: crate::element::OutputSinkExt
141pub trait MultiOutputSinkExt: MultiOutputSink + Sized {
142 fn push_to(&mut self, port: usize, packet: PipelinePacket) -> PushToFuture<'_, Self> {
143 self.begin_push_to(port);
144 PushToFuture {
145 sink: self,
146 port,
147 packet: Some(packet),
148 }
149 }
150}
151
152impl<S: MultiOutputSink> MultiOutputSinkExt for S {}
153
154impl<'e> dyn MultiOutputSink + 'e {
155 /// [`MultiOutputSink::push_to`] for trait objects (the provided method
156 /// needs `Self: Sized`).
157 pub fn push_to(
158 &mut self,
159 port: usize,
160 packet: PipelinePacket,
161 ) -> PushToFuture<'_, dyn MultiOutputSink + 'e> {
162 self.begin_push_to(port);
163 PushToFuture {
164 sink: self,
165 port,
166 packet: Some(packet),
167 }
168 }
169}
170
171/// Concrete future behind [`MultiOutputSink::push_to`]: the packet slot
172/// [`MultiOutputSink::poll_push_to`] drains. No heap.
173#[allow(missing_debug_implementations)]
174pub struct PushToFuture<'a, S: MultiOutputSink + ?Sized> {
175 sink: &'a mut S,
176 port: usize,
177 packet: Option<PipelinePacket>,
178}
179
180impl<S: MultiOutputSink + ?Sized> core::future::Future for PushToFuture<'_, S> {
181 type Output = Result<PushOutcome, G2gError>;
182
183 fn poll(
184 self: core::pin::Pin<&mut Self>,
185 cx: &mut core::task::Context<'_>,
186 ) -> core::task::Poll<Self::Output> {
187 let this = self.get_mut();
188 this.sink.poll_push_to(cx, this.port, &mut this.packet)
189 }
190}
191
192/// [`MultiOutputSink`] backed by one [`SenderSink`] per output link. Built
193/// by the fan-out runner from the branch links; `push_to` forwards to the
194/// addressed branch.
195#[derive(Debug)]
196pub struct MultiSenderSink {
197 ports: Vec<SenderSink>,
198}
199
200impl MultiSenderSink {
201 pub fn new(ports: Vec<SenderSink>) -> Self {
202 Self { ports }
203 }
204
205 /// Bank every port's push-wait on the producing element's probe (M947), so a
206 /// fan-out element's `proc` percentiles exclude the backpressure of whichever
207 /// branch is slowest.
208 // Only the std fan-out / demux runners build a probed multi-sink, so without
209 // std this would be dead code (which the workspace denies).
210 #[cfg(feature = "std")]
211 pub(crate) fn set_push_wait_probe(&mut self, probe: crate::runtime::Probe) {
212 for port in self.ports.iter_mut() {
213 port.set_push_wait_probe(probe.clone());
214 }
215 }
216
217 /// Append a port, for the growable multi-sink the dynamic duplex runner wraps
218 /// this in (M1014).
219 // Only that runner grows a multi-sink, so without std this would be dead code
220 // (which the workspace denies).
221 #[cfg(feature = "std")]
222 pub(crate) fn push_port(&mut self, port: SenderSink) {
223 self.ports.push(port);
224 }
225}
226
227impl MultiOutputSink for MultiSenderSink {
228 fn begin_push_to(&mut self, port: usize) {
229 if let Some(sink) = self.ports.get_mut(port) {
230 sink.begin_push();
231 }
232 }
233
234 fn poll_push_to(
235 &mut self,
236 cx: &mut core::task::Context<'_>,
237 port: usize,
238 packet: &mut Option<PipelinePacket>,
239 ) -> core::task::Poll<Result<PushOutcome, G2gError>> {
240 // Port range is an internal invariant: `Router` clamps its selection
241 // and broadcasts only over `0..port_count`, so an out-of-range port
242 // is a framework bug, not a runtime error.
243 let sink = self
244 .ports
245 .get_mut(port)
246 .expect("push_to: port out of range");
247 sink.poll_push(cx, packet)
248 }
249
250 fn port_count(&self) -> usize {
251 self.ports.len()
252 }
253}
254
255/// A terminal multi-output *source*: 0 inputs to N outputs, driven by
256/// [`run_fanout_session`](crate::runtime::run_fanout_session). Where
257/// [`MultiOutputElement`] demultiplexes an upstream input stream, this generates
258/// its outputs itself from an external source (e.g. a WHEP session that receives
259/// video + audio over one PeerConnection and emits each on its own pad). It is
260/// the fan-out mirror of a [`MultiInputElement`] used as a terminal session sink.
261pub trait MultiOutputSource: ElementBound {
262 type RunFuture<'a>: core::future::Future<Output = Result<u64, G2gError>> + 'a
263 where
264 Self: 'a;
265
266 /// Number of output pads (one per produced track).
267 fn output_count(&self) -> usize;
268
269 /// The caps this source produces on `output`. The runner fixates each and
270 /// configures the matching downstream sink before [`Self::run`]. Geometry the
271 /// source only learns later (e.g. H.264 dimensions from the in-band SPS) is
272 /// reported as `Any`, exactly as a single-output `SourceLoop` does.
273 fn output_caps(&self, output: usize) -> Result<Caps, G2gError>;
274
275 /// Run until EOS / disconnect, pushing frames to outputs via
276 /// `out.push_to(port, ..)`. The implementation MUST push a
277 /// [`PipelinePacket::Eos`] to every output before returning `Ok`, so no
278 /// downstream branch is stranded. Returns the count of `DataFrame`s pushed.
279 fn run<'a>(&'a mut self, out: &'a mut dyn MultiOutputSink) -> Self::RunFuture<'a>;
280
281 /// Settable properties, so a `gst-launch` line can configure the source
282 /// (M727). Default none.
283 fn properties(&self) -> &'static [PropertySpec] {
284 &[]
285 }
286
287 fn set_property(&mut self, _name: &str, _value: PropValue) -> Result<(), PropError> {
288 Err(PropError::Unknown)
289 }
290
291 fn get_property(&self, _name: &str) -> Option<PropValue> {
292 None
293 }
294
295 /// Receive this instance's log name and a per-instance log category
296 /// override, mirroring
297 /// [`AsyncElement::set_instance_name`](crate::AsyncElement::set_instance_name)
298 /// / [`set_log_category`](crate::AsyncElement::set_log_category). Default:
299 /// ignore. A session source that logs about itself stores them in a
300 /// [`LogName`](crate::log::LogName).
301 fn set_instance_name(&mut self, _name: alloc::string::String) {}
302
303 /// See [`set_instance_name`](Self::set_instance_name).
304 fn set_log_category(&mut self, _category: alloc::string::String) {}
305}
306
307/// Dyn-safe mirror of [`MultiOutputSource`] (boxed-future `run`), so a terminal
308/// fan-out source can be a graph node payload (M727), like
309/// [`DynSourceLoop`](crate::runtime::DynSourceLoop) for single-output sources.
310pub trait DynMultiOutputSource: ElementBound {
311 fn output_count(&self) -> usize;
312 fn output_caps(&self, output: usize) -> Result<Caps, G2gError>;
313 fn run<'a>(
314 &'a mut self,
315 out: &'a mut dyn MultiOutputSink,
316 ) -> BoxFuture<'a, Result<u64, G2gError>>;
317 fn properties(&self) -> &'static [PropertySpec] {
318 &[]
319 }
320 fn set_property(&mut self, _name: &str, _value: PropValue) -> Result<(), PropError> {
321 Err(PropError::Unknown)
322 }
323 fn get_property(&self, _name: &str) -> Option<PropValue> {
324 None
325 }
326 /// Dyn-safe mirror of [`MultiOutputSource::set_instance_name`].
327 fn set_instance_name(&mut self, _name: alloc::string::String) {}
328 /// Dyn-safe mirror of [`MultiOutputSource::set_log_category`].
329 fn set_log_category(&mut self, _category: alloc::string::String) {}
330}
331
332impl<T: MultiOutputSource> DynMultiOutputSource for T {
333 fn output_count(&self) -> usize {
334 MultiOutputSource::output_count(self)
335 }
336 fn output_caps(&self, output: usize) -> Result<Caps, G2gError> {
337 MultiOutputSource::output_caps(self, output)
338 }
339 fn run<'a>(
340 &'a mut self,
341 out: &'a mut dyn MultiOutputSink,
342 ) -> BoxFuture<'a, Result<u64, G2gError>> {
343 Box::pin(MultiOutputSource::run(self, out))
344 }
345 fn properties(&self) -> &'static [PropertySpec] {
346 MultiOutputSource::properties(self)
347 }
348 fn set_property(&mut self, name: &str, value: PropValue) -> Result<(), PropError> {
349 MultiOutputSource::set_property(self, name, value)
350 }
351 fn get_property(&self, name: &str) -> Option<PropValue> {
352 MultiOutputSource::get_property(self, name)
353 }
354 fn set_instance_name(&mut self, name: alloc::string::String) {
355 MultiOutputSource::set_instance_name(self, name)
356 }
357 fn set_log_category(&mut self, category: alloc::string::String) {
358 MultiOutputSource::set_log_category(self, category)
359 }
360}
361
362/// Forwarding impl so a borrowed `&mut dyn DynMultiOutputSource` can be boxed
363/// as a graph payload without taking ownership.
364impl<'b> DynMultiOutputSource for &'b mut (dyn DynMultiOutputSource + 'b) {
365 fn output_count(&self) -> usize {
366 (**self).output_count()
367 }
368 fn output_caps(&self, output: usize) -> Result<Caps, G2gError> {
369 (**self).output_caps(output)
370 }
371 fn run<'a>(
372 &'a mut self,
373 out: &'a mut dyn MultiOutputSink,
374 ) -> BoxFuture<'a, Result<u64, G2gError>> {
375 (**self).run(out)
376 }
377 fn set_instance_name(&mut self, name: alloc::string::String) {
378 (**self).set_instance_name(name)
379 }
380 fn set_log_category(&mut self, category: alloc::string::String) {
381 (**self).set_log_category(category)
382 }
383}
384
385/// Inbound side of a [`MultiDuplexSession`]: the runner hands the session a
386/// stream of `(input_index, packet)` drawn from its N send-side sources, the
387/// receive-end analog of the [`MultiOutputSink`] it pushes received tracks into.
388/// `recv` yields `None` once every send source has ended (all senders dropped),
389/// so the session can stop publishing while still draining the peer.
390pub trait DuplexInbound {
391 fn recv(&mut self) -> BoxFuture<'_, Option<(usize, PipelinePacket)>>;
392
393 /// The [`ReverseChannel`] of send input `input`, for a pad the session only
394 /// learned about mid-run (M1014). The runner asks
395 /// [`MultiDuplexSession::reverse_channel`] for the pads that exist when the
396 /// run starts, so a track attached later has no route for its PLI / BWE until
397 /// the session reads it back here, keyed by the index its packets arrive on.
398 /// Default `None`: the fixed-arity runner hands every channel over up front.
399 fn reverse_channel(&self, _input: usize) -> Option<ReverseChannel> {
400 None
401 }
402}
403
404/// A terminal **duplex** session: N send-side inputs **and** M recv-side outputs
405/// over one connection, with no external upstream or downstream beyond itself.
406/// The union of [`MultiInputElement`] used as a terminal sink
407/// ([`run_fanin_session`](crate::runtime::run_fanin_session)) and
408/// [`MultiOutputSource`] ([`run_fanout_session`](crate::runtime::run_fanout_session)):
409/// a `WebRtcBin`-style sendrecv PeerConnection both publishes local tracks and
410/// emits the peer's tracks. Driven by
411/// [`run_duplex_session`](crate::runtime::run_duplex_session).
412///
413/// One `run` loop owns the connection and is the sole holder of `&mut self`, so
414/// (unlike the egress session, which spawns a detached task to dodge aliasing)
415/// the send and recv halves share state directly: `run` selects over the inbound
416/// packets (`inbound.recv()`) and the network, feeding the former into the
417/// connection and pushing the latter to `out`.
418pub trait MultiDuplexSession: ElementBound {
419 type RunFuture<'a>: core::future::Future<Output = Result<u64, G2gError>> + 'a
420 where
421 Self: 'a;
422
423 /// Number of send-side input pads (local tracks published to the peer).
424 fn input_count(&self) -> usize;
425
426 /// Number of recv-side output pads (peer tracks emitted locally).
427 fn output_count(&self) -> usize;
428
429 /// Phase 1 for one send-side input pad: narrow that input's proposed caps.
430 fn intercept_caps(&self, input: usize, upstream_caps: &Caps) -> Result<Caps, G2gError>;
431
432 /// Phase 2 for one send-side input pad: fixate and configure it (the session
433 /// reads the track kind, e.g. H.264 video vs Opus audio, from these caps).
434 fn configure_input(
435 &mut self,
436 input: usize,
437 absolute_caps: &Caps,
438 ) -> Result<ConfigureOutcome, G2gError>;
439
440 /// The caps this session produces on one recv-side output pad. Geometry only
441 /// learned later (e.g. H.264 dimensions from the in-band SPS) is reported as a
442 /// `Range` placeholder, exactly as [`MultiOutputSource`] does.
443 fn output_caps(&self, output: usize) -> Result<Caps, G2gError>;
444
445 /// A [`ReverseChannel`] for send-side input pad `input`, if this session routes
446 /// reverse signals (WebRTC PLI / BWE arriving on that track's m-line) back to
447 /// the individual upstream source feeding it. The duplex runner clones it
448 /// before running and polls it after each push from that source, surfacing any
449 /// pending signal as a [`PushOutcome`], exactly as
450 /// [`MultiInputElement::reverse_channel`] does for a fan-in session. Default
451 /// `None`: no per-input reverse channel.
452 fn reverse_channel(&self, _input: usize) -> Option<ReverseChannel> {
453 None
454 }
455
456 /// Declare one send input pad's negotiation-time constraint, mirroring
457 /// [`MultiInputElement::caps_constraint_as_input`].
458 fn caps_constraint_as_input(&self, input: usize) -> CapsConstraint<'_>
459 where
460 Self: Sized,
461 {
462 CapsConstraint::LegacySink(alloc::boxed::Box::new(move |c: &Caps| {
463 <Self as MultiDuplexSession>::intercept_caps(self, input, c)
464 }))
465 }
466
467 /// Drive the session until the connection ends: drain `inbound` (the send-side
468 /// packets, tagged with their input pad) into the connection and push received
469 /// frames to `out`. Must push a [`PipelinePacket::Eos`] to every output before
470 /// returning `Ok`, so no downstream branch is stranded. Returns the count of
471 /// received `DataFrame`s pushed to outputs.
472 fn run<'a>(
473 &'a mut self,
474 inbound: &'a mut dyn DuplexInbound,
475 out: &'a mut dyn MultiOutputSink,
476 ) -> Self::RunFuture<'a>;
477}
478
479/// Multi-output element trait variant: identical negotiation to
480/// [`AsyncElement`], but `process` emits into a [`MultiOutputSink`] rather
481/// than a single downstream. [`Router`] is the first implementor; user code
482/// can write others (e.g. a content-based demux).
483pub trait MultiOutputElement: ElementBound {
484 type ProcessFuture<'a>: core::future::Future<Output = Result<(), G2gError>> + 'a
485 where
486 Self: 'a;
487
488 fn intercept_caps(&self, upstream_caps: &Caps) -> Result<Caps, G2gError>;
489
490 fn configure_pipeline(&mut self, absolute_caps: &Caps) -> Result<ConfigureOutcome, G2gError>;
491
492 fn process<'a>(
493 &'a mut self,
494 packet: PipelinePacket,
495 out: &'a mut dyn MultiOutputSink,
496 ) -> Self::ProcessFuture<'a>;
497
498 /// M18 step 1: declare the fan-out's input-side negotiation
499 /// constraint. Default wraps `intercept_caps(...)` as a
500 /// `LegacySink` (the fan-out narrows what it accepts from the
501 /// upstream source; downstream branches all receive the narrowed
502 /// caps via broadcast). Migrated fan-outs override to return
503 /// native variants (typically `AcceptsAny` for pass-through fan-
504 /// outs like `Router` whose output broadcasts the input verbatim,
505 /// or `Accepts(set)` for fan-outs that filter format on the input
506 /// side).
507 ///
508 /// Phase C FO-2 (per-branch downstream re-solve) will sit on top
509 /// of this: once the runner has the fan-out's negotiated input
510 /// caps, it broadcasts to each branch and runs Phase B's
511 /// `re_solve_downstream_sink` per branch sink.
512 fn caps_constraint_as_input(&self) -> CapsConstraint<'_>
513 where
514 Self: Sized,
515 {
516 CapsConstraint::LegacySink(alloc::boxed::Box::new(move |c: &Caps| {
517 <Self as MultiOutputElement>::intercept_caps(self, c)
518 }))
519 }
520
521 /// The caps output port `port` carries, if this fan-out is a **demux** whose
522 /// ports each carry a distinct elementary stream, rather than a broadcast tee
523 /// (M380). When every port returns `Some`, the graph solver negotiates each
524 /// branch against its port's caps (decoupled from the byte-stream input), so a
525 /// real decoder downstream of the port configures against its codec at startup
526 /// instead of having to retype from the input caps at runtime. Default `None`
527 /// for every port: a broadcast fan-out (e.g. [`Router`]) whose branches all
528 /// carry the input caps and negotiate as a tee. A demux (e.g. `MkvDemuxN`)
529 /// overrides it, returning `Some` for every port it exposes. The caps may be a
530 /// placeholder (geometry as a `Range`), refined at runtime via the port's
531 /// `CapsChanged`, exactly as a single-output demuxer's output caps are.
532 fn port_output_caps(&self, _port: usize) -> Option<Caps> {
533 None
534 }
535
536 /// The memory domains this fan-out accepts on its single input pad,
537 /// mirroring [`AsyncElement::input_domains`](crate::AsyncElement::input_domains).
538 /// Default [`DomainSet::ALL`] (no requirement). A demux that parses host
539 /// bytes narrows it to `System`, and the allocation cascade turns that into a
540 /// download demand on a GPU producer feeding it.
541 fn input_domains(&self) -> DomainSet {
542 DomainSet::ALL
543 }
544
545 /// Receive this instance's log name and a per-instance log category
546 /// override, mirroring
547 /// [`AsyncElement::set_instance_name`](crate::AsyncElement::set_instance_name)
548 /// / [`set_log_category`](crate::AsyncElement::set_log_category). Default:
549 /// ignore. A demux that logs about itself stores them in a
550 /// [`LogName`](crate::log::LogName).
551 fn set_instance_name(&mut self, _name: alloc::string::String) {}
552
553 /// See [`set_instance_name`](Self::set_instance_name).
554 fn set_log_category(&mut self, _category: alloc::string::String) {}
555
556 /// Runtime properties this demux exposes (M104), mirroring
557 /// [`AsyncElement::properties`](crate::AsyncElement::properties). Default:
558 /// none. A demux overrides this (with `set_property` / `get_property`) to be
559 /// settable by name from a `gst-launch` line, the same as a transform.
560 fn properties(&self) -> &'static [PropertySpec] {
561 &[]
562 }
563
564 /// Set a property by name (M104). Default: every name is unknown.
565 fn set_property(&mut self, _name: &str, _value: PropValue) -> Result<(), PropError> {
566 Err(PropError::Unknown)
567 }
568
569 /// Read a property back by name (M104). Default: `None`.
570 fn get_property(&self, _name: &str) -> Option<PropValue> {
571 None
572 }
573}
574
575/// Multi-input element trait variant: an N-input, 1-output element (a
576/// muxer). The mirror of [`MultiOutputElement`]. Negotiation is **per
577/// input** — each input pad narrows and fixates its own caps — and the
578/// element exposes a single merged `output_caps`. The fan-in runner
579/// (`run_muxer_sink`) aggregates EOS itself, so `process` is only ever
580/// handed `DataFrame`/`CapsChanged`, tagged with the originating `input`.
581pub trait MultiInputElement: ElementBound {
582 type ProcessFuture<'a>: core::future::Future<Output = Result<(), G2gError>> + 'a
583 where
584 Self: 'a;
585
586 fn input_count(&self) -> usize;
587
588 /// Whether the runner should deliver this element's inputs in global
589 /// presentation-timestamp order. Default `false` (arrival-order round-robin,
590 /// the historical behavior). When `true`, the runner merges the per-input
591 /// streams by `DataFrame` PTS, releasing the globally-earliest only once every
592 /// still-open input has one queued, so `process(pad, DataFrame(..))` arrives in
593 /// non-decreasing PTS across all pads.
594 ///
595 /// An element wanting time-aligned input without hand-rolling an
596 /// [`InputAggregator`](crate::InputAggregator) (a muxer, a multi-camera grid, a
597 /// PTS-synchronized compositor) opts in by returning `true`. Per-input `Eos`
598 /// and `CapsChanged` are still delivered as they occur; the merge holds only
599 /// `DataFrame`s. Inputs are assumed monotonic in PTS (the merge invariant).
600 fn input_pts_ordered(&self) -> bool {
601 false
602 }
603
604 /// Deadline tick period in nanoseconds, or `None` (the default) for no ticks.
605 ///
606 /// When set, and the runner was given a clock to sleep on, the fan-in arm
607 /// delivers [`PipelinePacket::Tick`] as `process(0, Tick, ..)` every period
608 /// even while the inputs are silent, so an element whose output cadence is its
609 /// own (a compositor holding the last frame of a stalled pad, zero-order-hold)
610 /// can emit without a packet arriving. The tick may fire spuriously: it says
611 /// the period elapsed, not that output is due, so the element decides.
612 fn tick_interval_ns(&self) -> Option<u64> {
613 None
614 }
615
616 /// Phase 1 for one input pad: narrow that input's proposed caps.
617 fn intercept_caps(&self, input: usize, upstream_caps: &Caps) -> Result<Caps, G2gError>;
618
619 /// Phase 2 for one input pad: fixate and configure that input.
620 fn configure_pipeline(
621 &mut self,
622 input: usize,
623 absolute_caps: &Caps,
624 ) -> Result<ConfigureOutcome, G2gError>;
625
626 /// The merged-output caps, valid once every input has been configured.
627 fn output_caps(&self) -> Result<Caps, G2gError>;
628
629 /// A [`ReverseChannel`] for input pad `input`, if this session routes reverse
630 /// signals (WebRTC PLI / BWE) back to individual upstream sources. The runner
631 /// clones it before running and polls it after each push from that source,
632 /// surfacing any pending signal as a [`PushOutcome`]. Default `None`: no
633 /// per-input reverse channel (a plain muxer imposes none).
634 fn reverse_channel(&self, _input: usize) -> Option<ReverseChannel> {
635 None
636 }
637
638 /// Whether this element is a terminal fan-in (a session sink that consumes
639 /// its inputs and produces no merged output, e.g. a WebRTC publisher). A
640 /// terminal element may end a graph with nothing downstream
641 /// (`Graph::add_fanin_sink`); a merging muxer without a downstream stays a
642 /// parse error, since its output would be silently dropped. Default `false`.
643 fn is_terminal(&self) -> bool {
644 false
645 }
646
647 /// If `Some(pad)`, the merged output's caps are the negotiated caps of input
648 /// pad `pad` (an identity-passthrough mux: an overlay / watermark / alpha
649 /// mixer that decorates a primary stream with a sidecar one). The solver then
650 /// derives the output edge from that input edge instead of from
651 /// [`caps_constraint_for_output`](Self::caps_constraint_for_output), so the
652 /// element need not know the output caps up front. Default `None`: the output
653 /// is independent (a container interleave, a fixed-size compositor), declared
654 /// by `caps_constraint_for_output`.
655 fn output_follows_input(&self) -> Option<usize> {
656 None
657 }
658
659 /// Map a named input-pad request to this element's concrete input index
660 /// (M481), so a `gst-launch` line can reference request pads by name
661 /// (`... ! mux.audio_0`, `... ! o.text`) instead of relying on the order the
662 /// branches are written. The transpose of the demuxer's output-pad selection
663 /// (M476): `req.kind` is `Video` / `Audio` / `Text` / `Any` with an ordinal.
664 ///
665 /// The default maps a bare `mux.` (`PadKind::Any`) to `ordinal` (the
666 /// positional behavior, unchanged), and declines a typed request (`None`) so a
667 /// homogeneous muxer without a naming scheme reports "no such pad" rather than
668 /// silently mis-routing. An element with named pads overrides this: an overlay
669 /// maps `Video -> 0`, `Text -> 1`; a container mux maps `video_%u` / `audio_%u`.
670 fn input_pad_index(&self, req: &PadRequest, ordinal: usize) -> Option<usize> {
671 let _ = ordinal;
672 match req.kind {
673 // A generic request pad (`sink_%u`, or a bare `mux.` whose index the
674 // parser set to the positional ordinal) maps straight to its index.
675 PadKind::Any => Some(req.index),
676 // A typed pad (`video_%u` / `audio_%u` / `text_%u`) needs an element
677 // scheme; a muxer without one declines it (reported as "no such pad").
678 _ => None,
679 }
680 }
681
682 /// Whether this element takes another input at runtime (M975), asked before a
683 /// source added through
684 /// [`DynamicFaninHandle::add_input`](crate::runtime::DynamicFaninHandle::add_input)
685 /// is attached to `pad`, with the caps that source would arrive with. The
686 /// runner has already checked `pad` against the declared
687 /// [`input_count`](Self::input_count) and the pad's own
688 /// [`caps_constraint_as_input`](Self::caps_constraint_as_input); this is the
689 /// element's veto for what neither expresses: a session with no spare pad of
690 /// that media kind, a container that cannot carry a second video track.
691 /// Refusing fails that one add ([`G2gError::InputRefused`] reaches the
692 /// caller), and the run continues on the inputs it already has.
693 ///
694 /// Default `true`: pad capacity is the only limit, the fixed-arity behavior.
695 /// The pad is not live until [`configure_pipeline`](Self::configure_pipeline)
696 /// runs for it, so do not commit pad state here.
697 fn accepts_runtime_input(&self, _pad: usize, _caps: &Caps) -> bool {
698 true
699 }
700
701 /// Combine one packet from `input` into the merged output.
702 ///
703 /// M22: a per-input `Eos` is delivered here when that input ends, so a
704 /// stateful muxer (a batcher) can flush per-input state. Implementations
705 /// must NOT forward `Eos` downstream: the runner aggregates input ends
706 /// and emits the single merged `Eos` itself.
707 fn process<'a>(
708 &'a mut self,
709 input: usize,
710 packet: PipelinePacket,
711 out: &'a mut dyn OutputSink,
712 ) -> Self::ProcessFuture<'a>;
713
714 /// M18 step 1: declare this input pad's negotiation-time
715 /// constraint. Default wraps `intercept_caps(input, ...)` as a
716 /// `LegacySink` (per-pad legacy bridge). Migrated muxers override
717 /// to return native variants (typically `AcceptsAny` for
718 /// per-frame-tagged interleave muxers, or `Accepts(set)` for
719 /// per-input format-restricted muxers).
720 ///
721 /// The runner calls this per-input during startup negotiation
722 /// (replacing the inline `LegacySink` construction in
723 /// `run_muxer_sink`) and during per-input mid-stream re-solve
724 /// once Phase C MX-1 lands.
725 fn caps_constraint_as_input(&self, input: usize) -> CapsConstraint<'_>
726 where
727 Self: Sized,
728 {
729 CapsConstraint::LegacySink(alloc::boxed::Box::new(move |c: &Caps| {
730 <Self as MultiInputElement>::intercept_caps(self, input, c)
731 }))
732 }
733
734 /// M18 step 1: declare the merged output's negotiation-time
735 /// constraint, evaluated against the muxer's current configured
736 /// inputs. Default eagerly calls `output_caps()` and wraps as
737 /// `LegacySource`. Migrated muxers with static or input-derived
738 /// output may override with `Produces(set)` or `DerivedOutput(fn)`.
739 ///
740 /// The runner uses this in place of `output_caps()?.fixate()` so
741 /// the downstream sink sees a uniformly-shaped constraint and the
742 /// Phase B-style re-solve (workaround #3 §4) extends naturally to
743 /// the muxer-output boundary.
744 fn caps_constraint_for_output(&self) -> Result<CapsConstraint<'_>, G2gError>
745 where
746 Self: Sized,
747 {
748 Ok(CapsConstraint::LegacySource(self.output_caps()?))
749 }
750
751 /// The allocation this muxer wants on one input pad, given that pad's
752 /// negotiated caps. The DAG runner stores it on the input edge during the
753 /// reverse-topo allocation cascade, so the demand crosses the muxer boundary
754 /// and re-cascades up that branch independently (a CUDA-resident interleave
755 /// muxer asking each video pad for device buffers, say). Default `None`: a
756 /// plain container muxer imposes no per-pad allocation, so the branch keeps
757 /// its own. Mirrors [`AsyncElement::propose_allocation`](crate::AsyncElement::propose_allocation),
758 /// but per input pad rather than on the single input.
759 fn propose_allocation_for_input(
760 &self,
761 _input: usize,
762 _caps: &Caps,
763 ) -> Option<crate::query::AllocationParams> {
764 None
765 }
766
767 /// The allocation this muxer's merged output needs, given the output's
768 /// negotiated caps. Default `None`: a container muxer's byte output has no
769 /// memory-domain tie to its inputs, so it imposes nothing downstream. A muxer
770 /// whose output pool is derived from its inputs overrides it (a device-resident
771 /// interleave writing into a surface sized by its video pads).
772 ///
773 /// The DAG runner re-queries this whenever an allocation change re-cascades
774 /// into any input pad, so the answer must fold in the current per-pad state
775 /// *and* whatever the element last absorbed through
776 /// [`configure_allocation_for_output`](Self::configure_allocation_for_output).
777 /// The runner walks to a fixed point of that pair, so an override that never
778 /// stops changing fails the run with `AllocationConflict` rather than looping.
779 fn propose_allocation_for_output(
780 &self,
781 _caps: &Caps,
782 ) -> Option<crate::query::AllocationParams> {
783 None
784 }
785
786 /// Absorb the allocation now in force on the merged output: this muxer's own
787 /// re-derived proposal, or a downstream consumer's demand that re-cascaded
788 /// into the output boundary. Default: ignore.
789 ///
790 /// A muxer whose output pool constrains its inputs overrides it and folds the
791 /// params into its state, so the following
792 /// [`propose_allocation_for_input`](Self::propose_allocation_for_input) answers
793 /// carry the constraint and the runner re-cascades it up the pads whose demand
794 /// actually moved.
795 fn configure_allocation_for_output(&mut self, _params: &crate::query::AllocationParams) {}
796
797 /// The memory domains this fan-in accepts on **every** input pad, mirroring
798 /// [`AsyncElement::input_domains`](crate::AsyncElement::input_domains).
799 /// Default [`DomainSet::ALL`] (no requirement). A muxer that reads host
800 /// memory narrows it to `System`, and the allocation cascade turns that into
801 /// a download demand on each GPU producer feeding a pad. Per-pad domains are
802 /// not expressible: a fan-in whose pads differ declares the union it can take
803 /// on any pad and rejects the rest at
804 /// [`configure_pipeline`](Self::configure_pipeline).
805 fn input_domains(&self) -> DomainSet {
806 DomainSet::ALL
807 }
808
809 /// Receive this instance's log name and a per-instance log category
810 /// override, mirroring
811 /// [`AsyncElement::set_instance_name`](crate::AsyncElement::set_instance_name)
812 /// / [`set_log_category`](crate::AsyncElement::set_log_category). Default:
813 /// ignore. A muxer that logs about itself stores them in a
814 /// [`LogName`](crate::log::LogName).
815 fn set_instance_name(&mut self, _name: alloc::string::String) {}
816
817 /// See [`set_instance_name`](Self::set_instance_name).
818 fn set_log_category(&mut self, _category: alloc::string::String) {}
819
820 /// Runtime properties this muxer exposes (M104), mirroring
821 /// [`AsyncElement::properties`](crate::AsyncElement::properties). Default:
822 /// none. A muxer overrides this (with `set_property` / `get_property`) to be
823 /// settable by name from a `gst-launch` line, the same as a transform.
824 fn properties(&self) -> &'static [PropertySpec] {
825 &[]
826 }
827
828 /// Set a property by name (M104). Default: every name is unknown.
829 fn set_property(&mut self, _name: &str, _value: PropValue) -> Result<(), PropError> {
830 Err(PropError::Unknown)
831 }
832
833 /// Read a property back by name (M104). Default: `None`.
834 fn get_property(&self, _name: &str) -> Option<PropValue> {
835 None
836 }
837
838 /// Static introspection metadata for this muxer (M178), the `gst-inspect`
839 /// "Factory Details" (long-name / classification / description / author),
840 /// mirroring [`AsyncElement::metadata`](crate::AsyncElement::metadata).
841 /// Default: empty. A muxer overrides it with a `const ElementMetadata`.
842 fn metadata(&self) -> ElementMetadata {
843 ElementMetadata::default()
844 }
845}
846
847/// 1→1 enable/disable element. Forwards `CapsChanged` unconditionally and
848/// `DataFrame` only while open; `Eos` is forwarded by the runner, never by
849/// the element (the transform contract). Drops dropped frames silently —
850/// observability of gate drops is a tracing concern for a later milestone.
851///
852/// # Example
853///
854/// ```no_run
855/// use g2g_core::fanout::Gate;
856///
857/// let gate = Gate::new(true);
858/// let handle = gate.handle();
859/// handle.set_open(false);
860/// ```
861#[derive(Debug)]
862pub struct Gate {
863 open: Arc<AtomicBool>,
864}
865
866impl Gate {
867 pub fn new(open: bool) -> Self {
868 Self {
869 open: Arc::new(AtomicBool::new(open)),
870 }
871 }
872
873 /// A cloneable handle that flips this gate from another task while the
874 /// runner drives it.
875 pub fn handle(&self) -> GateHandle {
876 GateHandle {
877 open: self.open.clone(),
878 }
879 }
880}
881
882/// Detached control handle for a [`Gate`].
883#[derive(Debug, Clone)]
884pub struct GateHandle {
885 open: Arc<AtomicBool>,
886}
887
888impl GateHandle {
889 pub fn set_open(&self, open: bool) {
890 self.open.store(open, Ordering::SeqCst);
891 }
892
893 pub fn is_open(&self) -> bool {
894 self.open.load(Ordering::SeqCst)
895 }
896}
897
898impl AsyncElement for Gate {
899 type ProcessFuture<'a>
900 = BoxFuture<'a, Result<(), G2gError>>
901 where
902 Self: 'a;
903
904 fn intercept_caps(&self, upstream_caps: &Caps) -> Result<Caps, G2gError> {
905 Ok(upstream_caps.clone())
906 }
907
908 fn configure_pipeline(&mut self, _absolute_caps: &Caps) -> Result<ConfigureOutcome, G2gError> {
909 Ok(ConfigureOutcome::Accepted)
910 }
911
912 fn process<'a>(
913 &'a mut self,
914 packet: PipelinePacket,
915 out: &'a mut dyn OutputSink,
916 ) -> Self::ProcessFuture<'a> {
917 let open = self.open.load(Ordering::SeqCst);
918 Box::pin(async move {
919 match packet {
920 PipelinePacket::DataFrame(f) => {
921 if open {
922 out.push(PipelinePacket::DataFrame(f)).await?;
923 }
924 }
925 PipelinePacket::CapsChanged(c) => {
926 out.push(PipelinePacket::CapsChanged(c)).await?;
927 }
928 // Flush is control: forward regardless of open state.
929 PipelinePacket::Flush => {
930 out.push(PipelinePacket::Flush).await?;
931 }
932 // Segment is control: forward regardless of open state.
933 PipelinePacket::Segment(s) => {
934 out.push(PipelinePacket::Segment(s)).await?;
935 }
936 // Runner forwards Eos after process() returns. A fan-in arm's
937 // deadline tick never reaches a 1->1 element.
938 PipelinePacket::Eos | PipelinePacket::Tick => {}
939 }
940 Ok(())
941 })
942 }
943}
944
945/// 1→N router. Each `DataFrame` goes to the single port named by an atomic
946/// discriminator; `CapsChanged` is broadcast to every port so all branches
947/// stay configured. `Eos` is broadcast by the runner.
948///
949/// # Example
950///
951/// ```no_run
952/// use g2g_core::fanout::Router;
953///
954/// let router = Router::new(3);
955/// let handle = router.handle();
956/// handle.select(2);
957/// ```
958#[derive(Debug)]
959pub struct Router {
960 selected: Arc<AtomicUsize>,
961 ports: usize,
962}
963
964impl Router {
965 pub fn new(ports: usize) -> Self {
966 assert!(ports > 0, "Router needs at least one output port");
967 Self {
968 selected: Arc::new(AtomicUsize::new(0)),
969 ports,
970 }
971 }
972
973 /// Number of output ports. The fan-out runner allocates one branch link
974 /// per port.
975 pub fn port_count(&self) -> usize {
976 self.ports
977 }
978
979 /// A cloneable handle that re-targets this router from another task.
980 pub fn handle(&self) -> RouterHandle {
981 RouterHandle {
982 selected: self.selected.clone(),
983 ports: self.ports,
984 }
985 }
986}
987
988/// Detached control handle for a [`Router`].
989#[derive(Debug, Clone)]
990pub struct RouterHandle {
991 selected: Arc<AtomicUsize>,
992 ports: usize,
993}
994
995impl RouterHandle {
996 /// Select the output port subsequent `DataFrame`s route to. Panics if
997 /// `port >= port_count`.
998 pub fn select(&self, port: usize) {
999 assert!(port < self.ports, "select: port out of range");
1000 self.selected.store(port, Ordering::SeqCst);
1001 }
1002
1003 pub fn selected(&self) -> usize {
1004 self.selected.load(Ordering::SeqCst)
1005 }
1006}
1007
1008impl MultiOutputElement for Router {
1009 type ProcessFuture<'a>
1010 = BoxFuture<'a, Result<(), G2gError>>
1011 where
1012 Self: 'a;
1013
1014 fn intercept_caps(&self, upstream_caps: &Caps) -> Result<Caps, G2gError> {
1015 Ok(upstream_caps.clone())
1016 }
1017
1018 /// M18 step 1: pass-through wildcard. `Router` broadcasts the
1019 /// upstream caps verbatim to every active branch and has no
1020 /// per-branch format restriction. `AcceptsAny` is the native
1021 /// shape; skips the dynamic intercept callback on the solver
1022 /// path.
1023 fn caps_constraint_as_input(&self) -> CapsConstraint<'_> {
1024 CapsConstraint::AcceptsAny
1025 }
1026
1027 fn configure_pipeline(&mut self, _absolute_caps: &Caps) -> Result<ConfigureOutcome, G2gError> {
1028 Ok(ConfigureOutcome::Accepted)
1029 }
1030
1031 fn process<'a>(
1032 &'a mut self,
1033 packet: PipelinePacket,
1034 out: &'a mut dyn MultiOutputSink,
1035 ) -> Self::ProcessFuture<'a> {
1036 // Clamp defensively so a stale handle write can never index past the
1037 // port list (the runner allocated exactly `ports` branches).
1038 let selected = self.selected.load(Ordering::SeqCst).min(self.ports - 1);
1039 let ports = self.ports;
1040 Box::pin(async move {
1041 match packet {
1042 PipelinePacket::DataFrame(f) => {
1043 out.push_to(selected, PipelinePacket::DataFrame(f)).await?;
1044 }
1045 PipelinePacket::CapsChanged(c) => {
1046 for port in 0..ports {
1047 out.push_to(port, PipelinePacket::CapsChanged(c.clone()))
1048 .await?;
1049 }
1050 }
1051 // Flush is broadcast to every branch, like CapsChanged.
1052 PipelinePacket::Flush => {
1053 for port in 0..ports {
1054 out.push_to(port, PipelinePacket::Flush).await?;
1055 }
1056 }
1057 // Segment is broadcast to every branch, like CapsChanged.
1058 PipelinePacket::Segment(s) => {
1059 for port in 0..ports {
1060 out.push_to(port, PipelinePacket::Segment(s)).await?;
1061 }
1062 }
1063 // Runner broadcasts Eos to all ports after process() returns. A
1064 // fan-in arm's deadline tick never reaches a 1->N element.
1065 PipelinePacket::Eos | PipelinePacket::Tick => {}
1066 }
1067 Ok(())
1068 })
1069 }
1070}
1071
1072/// N→1 fan-in selector: the control-driven mirror of [`Router`]. An atomic
1073/// discriminator names the single active input; the fan-in runner forwards
1074/// that input's frames and drains/discards the rest. The merged stream ends
1075/// only once every input has reached EOS (see `run_fanin_sink`). `Merger`
1076/// holds just the selector; the forwarding lives in the runner.
1077///
1078/// # Example
1079///
1080/// ```no_run
1081/// use g2g_core::fanout::Merger;
1082///
1083/// let merger = Merger::new(2);
1084/// let handle = merger.handle();
1085/// handle.select(1);
1086/// ```
1087#[derive(Debug)]
1088pub struct Merger {
1089 selected: Arc<AtomicUsize>,
1090 inputs: usize,
1091}
1092
1093impl Merger {
1094 pub fn new(inputs: usize) -> Self {
1095 assert!(inputs > 0, "Merger needs at least one input");
1096 Self {
1097 selected: Arc::new(AtomicUsize::new(0)),
1098 inputs,
1099 }
1100 }
1101
1102 /// Number of input ports. The fan-in runner allocates one branch link
1103 /// per input.
1104 pub fn input_count(&self) -> usize {
1105 self.inputs
1106 }
1107
1108 /// A cloneable handle that re-selects the active input from another task.
1109 pub fn handle(&self) -> MergerHandle {
1110 MergerHandle {
1111 selected: self.selected.clone(),
1112 inputs: self.inputs,
1113 }
1114 }
1115}
1116
1117/// Detached control handle for a [`Merger`].
1118#[derive(Debug, Clone)]
1119pub struct MergerHandle {
1120 selected: Arc<AtomicUsize>,
1121 inputs: usize,
1122}
1123
1124impl MergerHandle {
1125 /// Select which input feeds the merged output. Panics if
1126 /// `input >= input_count`.
1127 pub fn select(&self, input: usize) {
1128 assert!(input < self.inputs, "select: input out of range");
1129 self.selected.store(input, Ordering::SeqCst);
1130 }
1131
1132 pub fn selected(&self) -> usize {
1133 self.selected.load(Ordering::SeqCst)
1134 }
1135}
1136
1137#[cfg(test)]
1138mod tests {
1139 use super::*;
1140 use crate::caps::{Dim, Rate, RawVideoFormat};
1141 use crate::frame::{Frame, FrameTiming};
1142 use crate::memory::{MemoryDomain, SystemSlice};
1143 use core::future::Future;
1144 use core::pin::Pin;
1145
1146 fn caps() -> Caps {
1147 Caps::RawVideo {
1148 format: RawVideoFormat::Rgba8,
1149 width: Dim::Fixed(16),
1150 height: Dim::Fixed(16),
1151 framerate: Rate::Fixed(30 << 16),
1152 interlace: crate::Interlace::Any,
1153 }
1154 }
1155
1156 fn data(seq: u64) -> PipelinePacket {
1157 PipelinePacket::DataFrame(Frame {
1158 domain: MemoryDomain::System(SystemSlice::from_boxed(Box::new([0u8; 4]))),
1159 timing: FrameTiming::default(),
1160 sequence: seq,
1161 meta: Default::default(),
1162 })
1163 }
1164
1165 /// Records the kind of every packet pushed, per port, without channels.
1166 #[derive(Default)]
1167 struct RecordingMultiSink {
1168 ports: usize,
1169 data_seqs: Vec<Vec<u64>>,
1170 caps_changes: Vec<usize>,
1171 }
1172
1173 impl RecordingMultiSink {
1174 fn new(ports: usize) -> Self {
1175 Self {
1176 ports,
1177 data_seqs: alloc::vec![Vec::new(); ports],
1178 caps_changes: alloc::vec![0; ports],
1179 }
1180 }
1181 }
1182
1183 impl MultiOutputSink for RecordingMultiSink {
1184 fn poll_push_to(
1185 &mut self,
1186 _cx: &mut core::task::Context<'_>,
1187 port: usize,
1188 packet: &mut Option<PipelinePacket>,
1189 ) -> core::task::Poll<Result<PushOutcome, G2gError>> {
1190 match packet.take().expect("poll_push_to without a packet") {
1191 PipelinePacket::DataFrame(f) => self.data_seqs[port].push(f.sequence),
1192 PipelinePacket::CapsChanged(_) => self.caps_changes[port] += 1,
1193 PipelinePacket::Eos
1194 | PipelinePacket::Flush
1195 | PipelinePacket::Segment(_)
1196 | PipelinePacket::Tick => {}
1197 }
1198 core::task::Poll::Ready(Ok(PushOutcome::Accepted))
1199 }
1200
1201 fn port_count(&self) -> usize {
1202 self.ports
1203 }
1204 }
1205
1206 /// Records every packet a single-output element forwards.
1207 #[derive(Default)]
1208 struct RecordingSink {
1209 data_seqs: Vec<u64>,
1210 caps_changes: usize,
1211 }
1212
1213 impl OutputSink for RecordingSink {
1214 fn poll_push(
1215 &mut self,
1216 _cx: &mut core::task::Context<'_>,
1217 packet: &mut Option<PipelinePacket>,
1218 ) -> core::task::Poll<Result<PushOutcome, G2gError>> {
1219 match packet.take().expect("poll_push without a packet") {
1220 PipelinePacket::DataFrame(f) => self.data_seqs.push(f.sequence),
1221 PipelinePacket::CapsChanged(_) => self.caps_changes += 1,
1222 PipelinePacket::Eos
1223 | PipelinePacket::Flush
1224 | PipelinePacket::Segment(_)
1225 | PipelinePacket::Tick => {}
1226 }
1227 core::task::Poll::Ready(Ok(PushOutcome::Accepted))
1228 }
1229 }
1230
1231 /// Single-poll block_on; all futures here resolve immediately.
1232 fn block_on<F: Future>(mut fut: F) -> F::Output {
1233 use core::task::{Context, Poll, RawWaker, RawWakerVTable, Waker};
1234 static VT: RawWakerVTable = RawWakerVTable::new(
1235 |_| RawWaker::new(core::ptr::null(), &VT),
1236 |_| {},
1237 |_| {},
1238 |_| {},
1239 );
1240 // SAFETY: VT's hooks never dereference the data pointer.
1241 let waker = unsafe { Waker::from_raw(RawWaker::new(core::ptr::null(), &VT)) };
1242 let mut cx = Context::from_waker(&waker);
1243 // SAFETY: `fut` is pinned to the stack for the duration of this call.
1244 let mut pinned = unsafe { Pin::new_unchecked(&mut fut) };
1245 match pinned.as_mut().poll(&mut cx) {
1246 Poll::Ready(v) => v,
1247 Poll::Pending => panic!("fanout::tests::block_on saw Pending"),
1248 }
1249 }
1250
1251 #[test]
1252 fn router_input_constraint_is_wildcard() {
1253 // M18 step 1: Router broadcasts upstream caps verbatim, no
1254 // per-branch format restriction. AcceptsAny is the native
1255 // shape; skips the dynamic intercept callback on the solver
1256 // path.
1257 let r = Router::new(3);
1258 let c = r.caps_constraint_as_input();
1259 assert!(
1260 matches!(c, CapsConstraint::AcceptsAny),
1261 "Router input should be AcceptsAny, got {c:?}"
1262 );
1263 }
1264
1265 #[test]
1266 fn router_sends_each_frame_to_selected_port() {
1267 let mut router = Router::new(2);
1268 let handle = router.handle();
1269 let mut out = RecordingMultiSink::new(2);
1270
1271 block_on(router.process(data(0), &mut out)).unwrap(); // port 0
1272 handle.select(1);
1273 block_on(router.process(data(1), &mut out)).unwrap(); // port 1
1274 block_on(router.process(data(2), &mut out)).unwrap(); // port 1 (sticky)
1275 handle.select(0);
1276 block_on(router.process(data(3), &mut out)).unwrap(); // port 0
1277
1278 assert_eq!(out.data_seqs[0], alloc::vec![0, 3]);
1279 assert_eq!(out.data_seqs[1], alloc::vec![1, 2]);
1280 }
1281
1282 #[test]
1283 fn router_broadcasts_caps_changed_to_all_ports() {
1284 let mut router = Router::new(3);
1285 let mut out = RecordingMultiSink::new(3);
1286
1287 block_on(router.process(PipelinePacket::CapsChanged(caps()), &mut out)).unwrap();
1288
1289 assert_eq!(out.caps_changes, alloc::vec![1, 1, 1]);
1290 }
1291
1292 #[test]
1293 fn gate_open_forwards_data_closed_drops_it() {
1294 let gate = Gate::new(true);
1295 let handle = gate.handle();
1296 let mut gate = gate;
1297 let mut out = RecordingSink::default();
1298
1299 block_on(gate.process(data(0), &mut out)).unwrap(); // open -> pass
1300 handle.set_open(false);
1301 block_on(gate.process(data(1), &mut out)).unwrap(); // closed -> drop
1302 handle.set_open(true);
1303 block_on(gate.process(data(2), &mut out)).unwrap(); // open -> pass
1304
1305 assert_eq!(
1306 out.data_seqs,
1307 alloc::vec![0, 2],
1308 "frame 1 dropped while closed"
1309 );
1310 }
1311
1312 #[test]
1313 fn gate_forwards_caps_changed_regardless_of_open_state() {
1314 let mut gate = Gate::new(false);
1315 let mut out = RecordingSink::default();
1316
1317 block_on(gate.process(PipelinePacket::CapsChanged(caps()), &mut out)).unwrap();
1318
1319 assert_eq!(
1320 out.caps_changes, 1,
1321 "CapsChanged forwarded even while closed"
1322 );
1323 }
1324
1325 #[test]
1326 fn merger_handle_selects_active_input() {
1327 let merger = Merger::new(3);
1328 let handle = merger.handle();
1329 assert_eq!(handle.selected(), 0, "defaults to input 0");
1330 handle.select(2);
1331 assert_eq!(handle.selected(), 2);
1332 assert_eq!(merger.input_count(), 3);
1333 }
1334
1335 #[test]
1336 #[should_panic(expected = "input out of range")]
1337 fn merger_handle_rejects_out_of_range_input() {
1338 Merger::new(2).handle().select(2);
1339 }
1340}