Skip to main content

g2g_core/runtime/
fanin.rs

1//! Fan-in runner for the [`Merger`](crate::fanout::Merger) primitive (M9).
2//!
3//! Drives `N sources → Merger → 1 sink`. Each input is drained by its own
4//! forwarder future; the forwarder whose index equals the merger's atomic
5//! selection pushes its frames to a single shared output link, the others
6//! discard (so no source stalls). The merged stream emits one `Eos` only
7//! after **every** input has ended (all-inputs-EOS aggregation), so no
8//! upstream branch is stranded.
9//!
10//! Heterogeneous branches arrive as `Box`-erased `&mut dyn DynSourceLoop`.
11//! `DynSourceLoop` is defined here, not in `runner.rs`, so that runner's
12//! generic `SourceLoop` calls stay unambiguous (the same reason
13//! `DynAsyncElement` lives apart from the runner).
14
15use alloc::boxed::Box;
16use alloc::sync::Arc;
17use alloc::vec::Vec;
18use core::future::Future;
19use core::sync::atomic::{AtomicUsize, Ordering};
20
21use super::autoplug::PadRequest;
22use crate::bus::BusHandle;
23use crate::caps::{Caps, CapsSet};
24use crate::clock::{ClockCandidate, ClockPriority};
25use crate::clock::{DynAsyncClock, PipelineClock};
26use crate::element::{
27    AsyncElement, BoxFuture, ConfigureOutcome, DynAsyncElement, ElementBound, OutputSink,
28    OutputSinkExt, PushOutcome, Reconfigure,
29};
30use crate::error::G2gError;
31use crate::fanout::{
32    DuplexInbound, Merger, MultiDuplexSession, MultiInputElement, MultiOutputSink, MultiSenderSink,
33};
34use crate::format_element::{CapsConstraint, CapsPreferences};
35use crate::frame::PipelinePacket;
36use crate::graph::Graph;
37use crate::memory::{DomainSet, MemoryDomainKind};
38use crate::property::{ElementMetadata, PropError, PropValue, PropertySpec};
39use crate::query::{AllocationParams, LatencyReport};
40use crate::runtime::channel::{
41    bounded, packet_bytes, ProbeAction, ProbeSlot, Receiver, SendError, Sender, SenderSink,
42};
43use crate::runtime::coordinator::log_caps_rejected;
44use crate::runtime::graph_runner::{run_graph_inner, GraphNodeRef};
45use crate::runtime::instrument::{EdgeCounters, ElementProbe, Probe};
46use crate::runtime::join::{dynamic_join, join_all, select2, Either};
47use crate::runtime::observe::{link_tapped, register_runner_tap, EdgeTap, TapEdge, TapNode};
48use crate::runtime::runner::{LinkCapacity, NullSink, RunStats, SourceLoop};
49use crate::runtime::{NodeRole, Observer};
50use spin::Mutex;
51
52/// Dyn-safe mirror of [`SourceLoop`] for heterogeneous fan-in branches, the
53/// source-side analog of [`DynAsyncElement`](crate::element::DynAsyncElement).
54/// Boxes `run`'s future so a `Vec<&mut dyn DynSourceLoop>` can hold sources
55/// of different concrete types.
56pub trait DynSourceLoop: ElementBound {
57    fn intercept_caps<'a>(&'a mut self) -> BoxFuture<'a, Result<Caps, G2gError>>;
58
59    /// Dyn-safe mirror of the produce set behind
60    /// [`SourceLoop::caps_constraint`], so the DAG runner negotiates a source
61    /// that offers alternatives (a camera's pixel formats) instead of only its
62    /// preferred one. The default is the single-caps answer; the blanket impl
63    /// reads the constraint the source declares.
64    fn produced_caps<'a>(&'a mut self) -> BoxFuture<'a, Result<CapsSet, G2gError>> {
65        Box::pin(async move { Ok(CapsSet::one(self.intercept_caps().await?)) })
66    }
67
68    /// Dyn-safe mirror of [`SourceLoop::caps_preferences`], so an erased
69    /// source's declared per-alternative costs reach the solver. Defaults to
70    /// `None` (cost = alternative index), matching `SourceLoop`.
71    fn caps_preferences(&self) -> Option<CapsPreferences> {
72        None
73    }
74
75    fn configure_pipeline(&mut self, absolute_caps: &Caps) -> Result<ConfigureOutcome, G2gError>;
76
77    fn run<'a>(&'a mut self, out: &'a mut dyn OutputSink) -> BoxFuture<'a, Result<u64, G2gError>>;
78
79    fn reconfigure(&mut self, request: Reconfigure) -> Result<Caps, G2gError>;
80
81    /// Dyn-safe mirror of [`SourceLoop::latency`], so the DAG runner folds a
82    /// source's latency contribution like the linear runner does.
83    fn latency(&self) -> LatencyReport;
84
85    /// Dyn-safe mirror of [`SourceLoop::output_memory`]. Default
86    /// [`System`](MemoryDomainKind::System).
87    fn output_memory(&self) -> MemoryDomainKind {
88        MemoryDomainKind::System
89    }
90
91    /// Dyn-safe mirror of [`SourceLoop::output_domains`]. Default
92    /// `only(output_memory())`.
93    fn output_domains(&self) -> DomainSet {
94        DomainSet::only(self.output_memory())
95    }
96
97    /// Dyn-safe mirror of [`SourceLoop::query_duration`] (M203), so the DAG
98    /// runner can publish an erased source's duration on the progress handle.
99    fn query_duration(&self) -> Option<u64> {
100        None
101    }
102
103    /// Dyn-safe mirror of [`SourceLoop::provide_clock`], for the runner's clock
104    /// election.
105    fn provide_clock(&self) -> Option<ClockCandidate>;
106
107    /// Dyn-safe mirror of [`SourceLoop::configure_allocation`], the upstream end
108    /// of the M12 allocation cascade.
109    fn configure_allocation(&mut self, params: &AllocationParams);
110
111    /// Dyn-safe mirror of [`SourceLoop::configured_output_caps`] (M195), so the
112    /// `decodebin` parser can read an erased source's property-driven caps.
113    fn configured_output_caps(&self) -> Option<Caps> {
114        None
115    }
116
117    /// Dyn-safe mirror of [`SourceLoop::probe_output_caps`] (M480): the parse-time
118    /// caps `decodebin` uses to pick a demuxer, allowed to sniff the header.
119    fn probe_output_caps(&mut self) -> Option<Caps> {
120        self.configured_output_caps()
121    }
122
123    /// Dyn-safe mirror of [`SourceLoop::properties`], for `gst-inspect` /
124    /// `gst-launch` introspection of an erased source.
125    fn properties(&self) -> &'static [PropertySpec] {
126        &[]
127    }
128
129    /// Dyn-safe mirror of [`SourceLoop::metadata`], for the `gst-inspect`
130    /// "Factory Details" of an erased source. Defaults to empty.
131    fn metadata(&self) -> ElementMetadata {
132        ElementMetadata::default()
133    }
134
135    /// The log category for this erased source (M179): its short type name by
136    /// default (the blanket impl fills it), so the runner can name and log it.
137    fn log_category(&self) -> &'static str {
138        "source"
139    }
140
141    /// Dyn-safe mirror of [`SourceLoop::set_instance_name`].
142    fn set_instance_name(&mut self, _name: alloc::string::String) {}
143
144    /// Dyn-safe mirror of [`SourceLoop::set_log_category`].
145    fn set_log_category(&mut self, _category: alloc::string::String) {}
146
147    /// Dyn-safe mirror of [`SourceLoop::set_property`]. Defaults to "no
148    /// properties"; the blanket `impl<T: SourceLoop>` overrides it to forward.
149    fn set_property(&mut self, _name: &str, _value: PropValue) -> Result<(), PropError> {
150        Err(PropError::Unknown)
151    }
152
153    /// Dyn-safe mirror of [`SourceLoop::get_property`]. Defaults to `None`; the
154    /// blanket impl forwards to the source.
155    fn get_property(&self, _name: &str) -> Option<PropValue> {
156        None
157    }
158}
159
160/// Blanket adapter: every [`SourceLoop`] is usable as a [`DynSourceLoop`]
161/// by boxing its `run` and `intercept_caps` futures. Calls are
162/// disambiguated to `SourceLoop::` because the two traits share method
163/// names.
164impl<T: SourceLoop> DynSourceLoop for T {
165    fn intercept_caps<'a>(&'a mut self) -> BoxFuture<'a, Result<Caps, G2gError>> {
166        Box::pin(SourceLoop::intercept_caps(self))
167    }
168
169    /// A source shape is `Produces` (native) or `LegacySource` (the migration
170    /// bridge). Any other variant means a sink / transform constraint on a source
171    /// slot, which is an element bug, so it fails loud rather than negotiating
172    /// something the source never offered.
173    fn produced_caps<'a>(&'a mut self) -> BoxFuture<'a, Result<CapsSet, G2gError>> {
174        Box::pin(async move {
175            match SourceLoop::caps_constraint(self).await? {
176                CapsConstraint::Produces(set) => Ok(set),
177                CapsConstraint::LegacySource(caps) => Ok(CapsSet::one(caps)),
178                _ => Err(G2gError::CapsMismatch),
179            }
180        })
181    }
182
183    fn caps_preferences(&self) -> Option<CapsPreferences> {
184        SourceLoop::caps_preferences(self)
185    }
186
187    fn configure_pipeline(&mut self, absolute_caps: &Caps) -> Result<ConfigureOutcome, G2gError> {
188        SourceLoop::configure_pipeline(self, absolute_caps)
189    }
190
191    fn run<'a>(&'a mut self, out: &'a mut dyn OutputSink) -> BoxFuture<'a, Result<u64, G2gError>> {
192        Box::pin(SourceLoop::run(self, out))
193    }
194
195    fn reconfigure(&mut self, request: Reconfigure) -> Result<Caps, G2gError> {
196        SourceLoop::reconfigure(self, request)
197    }
198
199    fn latency(&self) -> LatencyReport {
200        SourceLoop::latency(self)
201    }
202
203    fn output_memory(&self) -> MemoryDomainKind {
204        SourceLoop::output_memory(self)
205    }
206
207    fn output_domains(&self) -> DomainSet {
208        SourceLoop::output_domains(self)
209    }
210
211    fn query_duration(&self) -> Option<u64> {
212        SourceLoop::query_duration(self)
213    }
214
215    fn provide_clock(&self) -> Option<ClockCandidate> {
216        SourceLoop::provide_clock(self)
217    }
218
219    fn configure_allocation(&mut self, params: &AllocationParams) {
220        SourceLoop::configure_allocation(self, params)
221    }
222
223    fn configured_output_caps(&self) -> Option<Caps> {
224        SourceLoop::configured_output_caps(self)
225    }
226
227    fn probe_output_caps(&mut self) -> Option<Caps> {
228        SourceLoop::probe_output_caps(self)
229    }
230
231    fn properties(&self) -> &'static [PropertySpec] {
232        SourceLoop::properties(self)
233    }
234
235    fn metadata(&self) -> ElementMetadata {
236        SourceLoop::metadata(self)
237    }
238
239    fn log_category(&self) -> &'static str {
240        crate::log::short_type_name::<T>()
241    }
242
243    fn set_instance_name(&mut self, name: alloc::string::String) {
244        SourceLoop::set_instance_name(self, name)
245    }
246
247    fn set_log_category(&mut self, category: alloc::string::String) {
248        SourceLoop::set_log_category(self, category)
249    }
250
251    fn set_property(&mut self, name: &str, value: PropValue) -> Result<(), PropError> {
252        SourceLoop::set_property(self, name, value)
253    }
254
255    fn get_property(&self, name: &str) -> Option<PropValue> {
256        SourceLoop::get_property(self, name)
257    }
258}
259
260/// Forwarding impl so a borrowed `&mut dyn DynSourceLoop` can be boxed into a
261/// `Box<dyn DynSourceLoop + 'a>` graph node (the muxer/fan-out wrappers build a
262/// borrowing `Graph` over their `&mut` source references). Disjoint from the
263/// `SourceLoop` blanket above: a `&mut dyn DynSourceLoop` is not a `SourceLoop`.
264impl<'b> DynSourceLoop for &'b mut (dyn DynSourceLoop + 'b) {
265    fn intercept_caps<'a>(&'a mut self) -> BoxFuture<'a, Result<Caps, G2gError>> {
266        (**self).intercept_caps()
267    }
268
269    fn produced_caps<'a>(&'a mut self) -> BoxFuture<'a, Result<CapsSet, G2gError>> {
270        (**self).produced_caps()
271    }
272
273    fn caps_preferences(&self) -> Option<CapsPreferences> {
274        (**self).caps_preferences()
275    }
276
277    fn configure_pipeline(&mut self, absolute_caps: &Caps) -> Result<ConfigureOutcome, G2gError> {
278        (**self).configure_pipeline(absolute_caps)
279    }
280
281    fn run<'a>(&'a mut self, out: &'a mut dyn OutputSink) -> BoxFuture<'a, Result<u64, G2gError>> {
282        (**self).run(out)
283    }
284
285    fn reconfigure(&mut self, request: Reconfigure) -> Result<Caps, G2gError> {
286        (**self).reconfigure(request)
287    }
288
289    fn latency(&self) -> LatencyReport {
290        (**self).latency()
291    }
292
293    fn output_memory(&self) -> MemoryDomainKind {
294        (**self).output_memory()
295    }
296
297    fn output_domains(&self) -> DomainSet {
298        (**self).output_domains()
299    }
300
301    fn query_duration(&self) -> Option<u64> {
302        (**self).query_duration()
303    }
304
305    fn provide_clock(&self) -> Option<ClockCandidate> {
306        (**self).provide_clock()
307    }
308
309    fn configure_allocation(&mut self, params: &AllocationParams) {
310        (**self).configure_allocation(params)
311    }
312
313    fn configured_output_caps(&self) -> Option<Caps> {
314        (**self).configured_output_caps()
315    }
316
317    fn properties(&self) -> &'static [PropertySpec] {
318        (**self).properties()
319    }
320
321    fn metadata(&self) -> ElementMetadata {
322        (**self).metadata()
323    }
324
325    fn log_category(&self) -> &'static str {
326        (**self).log_category()
327    }
328
329    fn set_instance_name(&mut self, name: alloc::string::String) {
330        (**self).set_instance_name(name)
331    }
332
333    fn set_log_category(&mut self, category: alloc::string::String) {
334        (**self).set_log_category(category)
335    }
336
337    fn set_property(&mut self, name: &str, value: PropValue) -> Result<(), PropError> {
338        (**self).set_property(name, value)
339    }
340
341    fn get_property(&self, name: &str) -> Option<PropValue> {
342        (**self).get_property(name)
343    }
344}
345
346/// The caps one fan-in branch runs: the highest-preference alternative of the
347/// source's produce set that the pad it feeds accepts (M955). A source offering
348/// several formats (a camera's pixel formats) is therefore selected by what the
349/// merge point takes, the way the DAG runner selects it by what downstream takes.
350///
351/// When the pad accepts none of them the preferred alternative is returned
352/// anyway, leaving the rejection to that pad's `configure_pipeline`, which is
353/// where it was raised before a source had a set to choose from.
354fn select_branch_caps(produced: &CapsSet, pad: &CapsConstraint<'_>) -> Result<Caps, G2gError> {
355    let accepted = produced
356        .alternatives()
357        .iter()
358        .filter_map(|alt| alt.fixate().ok())
359        .find(|fixed| pad.accepts(fixed));
360    match accepted {
361        Some(caps) => Ok(caps),
362        None => produced.fixate().ok_or(G2gError::CapsMismatch),
363    }
364}
365
366/// Dyn-safe mirror of [`MultiInputElement`] for a fan-in muxer node in the DAG
367/// runner (`run_graph`). Boxes `process`'s future and forwards the
368/// `Self: Sized` constraint methods, the same shape as [`DynSourceLoop`] /
369/// [`DynAsyncElement`](crate::element::DynAsyncElement). Only the methods the
370/// runner uses are mirrored (the per-input `intercept_caps` / `output_caps`
371/// legacy paths stay on the concrete trait).
372pub trait DynMultiInputElement: ElementBound {
373    fn input_count(&self) -> usize;
374    /// Dyn-safe mirror of [`MultiInputElement::input_pts_ordered`]: whether the
375    /// runner delivers inputs in global PTS order rather than arrival order.
376    fn input_pts_ordered(&self) -> bool;
377    /// Dyn-safe mirror of [`MultiInputElement::output_follows_input`]: the input
378    /// pad whose caps the merged output follows (identity-passthrough mux), if any.
379    fn output_follows_input(&self) -> Option<usize>;
380    /// Dyn-safe mirror of [`MultiInputElement::tick_interval_ns`]: the deadline
381    /// tick period this element wants, if any.
382    fn tick_interval_ns(&self) -> Option<u64> {
383        None
384    }
385    /// Dyn-safe mirror of [`MultiInputElement::input_pad_index`] (M481): map a
386    /// named request pad to the concrete input index, for the launch parser.
387    fn input_pad_index(&self, req: &PadRequest, ordinal: usize) -> Option<usize>;
388    fn caps_constraint_as_input(&self, input: usize) -> CapsConstraint<'_>;
389    fn caps_constraint_for_output(&self) -> Result<CapsConstraint<'_>, G2gError>;
390    /// Dyn-safe mirror of [`MultiInputElement::input_domains`]. Default
391    /// [`DomainSet::ALL`].
392    fn input_domains(&self) -> DomainSet {
393        DomainSet::ALL
394    }
395    /// Dyn-safe mirror of [`MultiInputElement::propose_allocation_for_input`].
396    fn propose_allocation_for_input(&self, input: usize, caps: &Caps) -> Option<AllocationParams>;
397    /// Dyn-safe mirror of [`MultiInputElement::propose_allocation_for_output`].
398    fn propose_allocation_for_output(&self, caps: &Caps) -> Option<AllocationParams>;
399    /// Dyn-safe mirror of [`MultiInputElement::configure_allocation_for_output`].
400    fn configure_allocation_for_output(&mut self, params: &AllocationParams);
401    /// Dyn-safe mirror of [`MultiInputElement::output_caps`].
402    fn output_caps(&self) -> Result<Caps, G2gError>;
403    fn configure_pipeline(
404        &mut self,
405        input: usize,
406        absolute_caps: &Caps,
407    ) -> Result<ConfigureOutcome, G2gError>;
408    fn process<'a>(
409        &'a mut self,
410        input: usize,
411        packet: PipelinePacket,
412        out: &'a mut dyn OutputSink,
413    ) -> BoxFuture<'a, Result<(), G2gError>>;
414    fn properties(&self) -> &'static [PropertySpec];
415    fn set_property(&mut self, name: &str, value: PropValue) -> Result<(), PropError>;
416    fn get_property(&self, name: &str) -> Option<PropValue>;
417    /// Dyn-safe mirror of [`MultiInputElement::metadata`], for the `gst-inspect`
418    /// "Factory Details" of an erased fan-in muxer.
419    fn metadata(&self) -> ElementMetadata;
420
421    /// Dyn-safe mirror of [`MultiInputElement::reverse_channel`], so a terminal
422    /// fan-in node's arm can route a per-input reverse signal (WebRTC PLI / BWE)
423    /// back to the upstream feeding that pad. Default `None`.
424    fn reverse_channel(&self, _input: usize) -> Option<crate::fanout::ReverseChannel> {
425        None
426    }
427
428    /// Dyn-safe mirror of [`MultiInputElement::is_terminal`].
429    fn is_terminal(&self) -> bool {
430        false
431    }
432
433    /// Dyn-safe mirror of [`MultiInputElement::accepts_runtime_input`]: whether
434    /// this element takes an input added at runtime on `pad` with `caps`.
435    fn accepts_runtime_input(&self, _pad: usize, _caps: &Caps) -> bool {
436        true
437    }
438
439    /// Dyn-safe mirror of [`MultiInputElement::set_instance_name`], so the runner
440    /// can name an erased muxer instance for logging.
441    fn set_instance_name(&mut self, _name: alloc::string::String) {}
442
443    /// Dyn-safe mirror of [`MultiInputElement::set_log_category`].
444    fn set_log_category(&mut self, _category: alloc::string::String) {}
445
446    /// Consume this element into its graph-runner muxer arm (M1009), the fan-in
447    /// analog of
448    /// [`DynAsyncElement::drive_transform_arm`](crate::element::DynAsyncElement::drive_transform_arm).
449    /// The blanket impl picks the arrival-order or PTS-ordered arm from
450    /// [`input_pts_ordered`](Self::input_pts_ordered) and monomorphizes it over
451    /// the concrete element type, so the per-packet `process` future is unboxed.
452    /// Implementations outside the blanket cannot build the runner's
453    /// [`MuxerArmIo`](crate::runtime::MuxerArmIo); implement
454    /// [`MultiInputElement`] instead.
455    #[doc(hidden)]
456    fn drive_muxer_arm<'s>(
457        self: Box<Self>,
458        io: crate::runtime::MuxerArmIo<'s>,
459    ) -> BoxFuture<'s, Result<u64, G2gError>>
460    where
461        Self: 's;
462
463    /// As [`Self::drive_muxer_arm`], for the thread-per-arm runner, whose
464    /// builder closure owns its ticker rather than borrowing the graph's.
465    #[cfg(feature = "multi-thread")]
466    #[doc(hidden)]
467    fn drive_muxer_arm_owned_tick(
468        self: Box<Self>,
469        io: crate::runtime::MuxerArmOwnedTickIo,
470    ) -> BoxFuture<'static, Result<u64, G2gError>>
471    where
472        Self: 'static;
473
474    /// As [`Self::drive_muxer_arm`], for a terminal fan-in node (no downstream).
475    #[doc(hidden)]
476    fn drive_fanin_sink_arm<'s>(
477        self: Box<Self>,
478        io: crate::runtime::FaninSinkArmIo,
479    ) -> BoxFuture<'s, Result<u64, G2gError>>
480    where
481        Self: 's;
482}
483
484impl<T: MultiInputElement> DynMultiInputElement for T {
485    fn input_count(&self) -> usize {
486        MultiInputElement::input_count(self)
487    }
488
489    fn input_pts_ordered(&self) -> bool {
490        MultiInputElement::input_pts_ordered(self)
491    }
492
493    fn output_follows_input(&self) -> Option<usize> {
494        MultiInputElement::output_follows_input(self)
495    }
496
497    fn tick_interval_ns(&self) -> Option<u64> {
498        MultiInputElement::tick_interval_ns(self)
499    }
500
501    fn input_pad_index(&self, req: &PadRequest, ordinal: usize) -> Option<usize> {
502        MultiInputElement::input_pad_index(self, req, ordinal)
503    }
504
505    fn caps_constraint_as_input(&self, input: usize) -> CapsConstraint<'_> {
506        MultiInputElement::caps_constraint_as_input(self, input)
507    }
508
509    fn caps_constraint_for_output(&self) -> Result<CapsConstraint<'_>, G2gError> {
510        MultiInputElement::caps_constraint_for_output(self)
511    }
512
513    fn input_domains(&self) -> DomainSet {
514        MultiInputElement::input_domains(self)
515    }
516
517    fn propose_allocation_for_input(&self, input: usize, caps: &Caps) -> Option<AllocationParams> {
518        MultiInputElement::propose_allocation_for_input(self, input, caps)
519    }
520
521    fn propose_allocation_for_output(&self, caps: &Caps) -> Option<AllocationParams> {
522        MultiInputElement::propose_allocation_for_output(self, caps)
523    }
524
525    fn configure_allocation_for_output(&mut self, params: &AllocationParams) {
526        MultiInputElement::configure_allocation_for_output(self, params)
527    }
528
529    fn output_caps(&self) -> Result<Caps, G2gError> {
530        MultiInputElement::output_caps(self)
531    }
532
533    fn configure_pipeline(
534        &mut self,
535        input: usize,
536        absolute_caps: &Caps,
537    ) -> Result<ConfigureOutcome, G2gError> {
538        MultiInputElement::configure_pipeline(self, input, absolute_caps)
539    }
540
541    fn process<'a>(
542        &'a mut self,
543        input: usize,
544        packet: PipelinePacket,
545        out: &'a mut dyn OutputSink,
546    ) -> BoxFuture<'a, Result<(), G2gError>> {
547        Box::pin(MultiInputElement::process(self, input, packet, out))
548    }
549
550    fn properties(&self) -> &'static [PropertySpec] {
551        MultiInputElement::properties(self)
552    }
553
554    fn set_property(&mut self, name: &str, value: PropValue) -> Result<(), PropError> {
555        MultiInputElement::set_property(self, name, value)
556    }
557
558    fn get_property(&self, name: &str) -> Option<PropValue> {
559        MultiInputElement::get_property(self, name)
560    }
561
562    fn metadata(&self) -> ElementMetadata {
563        MultiInputElement::metadata(self)
564    }
565
566    fn reverse_channel(&self, input: usize) -> Option<crate::fanout::ReverseChannel> {
567        MultiInputElement::reverse_channel(self, input)
568    }
569
570    fn is_terminal(&self) -> bool {
571        MultiInputElement::is_terminal(self)
572    }
573
574    fn accepts_runtime_input(&self, pad: usize, caps: &Caps) -> bool {
575        MultiInputElement::accepts_runtime_input(self, pad, caps)
576    }
577
578    fn set_instance_name(&mut self, name: alloc::string::String) {
579        MultiInputElement::set_instance_name(self, name)
580    }
581
582    fn set_log_category(&mut self, category: alloc::string::String) {
583        MultiInputElement::set_log_category(self, category)
584    }
585
586    fn drive_muxer_arm<'s>(
587        self: Box<Self>,
588        io: crate::runtime::MuxerArmIo<'s>,
589    ) -> BoxFuture<'s, Result<u64, G2gError>>
590    where
591        Self: 's,
592    {
593        if MultiInputElement::input_pts_ordered(&*self) {
594            Box::pin(crate::runtime::graph_runner::muxer_arm_pts(*self, io))
595        } else {
596            Box::pin(crate::runtime::graph_runner::muxer_arm(*self, io))
597        }
598    }
599
600    #[cfg(feature = "multi-thread")]
601    fn drive_muxer_arm_owned_tick(
602        self: Box<Self>,
603        io: crate::runtime::MuxerArmOwnedTickIo,
604    ) -> BoxFuture<'static, Result<u64, G2gError>>
605    where
606        Self: 'static,
607    {
608        Box::pin(crate::runtime::graph_runner::muxer_arm_owned_tick(
609            *self, io,
610        ))
611    }
612
613    fn drive_fanin_sink_arm<'s>(
614        self: Box<Self>,
615        io: crate::runtime::FaninSinkArmIo,
616    ) -> BoxFuture<'s, Result<u64, G2gError>>
617    where
618        Self: 's,
619    {
620        Box::pin(crate::runtime::graph_runner::fanin_sink_arm(*self, io))
621    }
622}
623
624/// Private [`MultiInputElement`] face over an erased muxer, so the generic
625/// (monomorphized) arms can drive a `&mut dyn DynMultiInputElement` graph node
626/// too. Its per-packet process future stays boxed (the element underneath is
627/// erased). The `DynRef` shape, for the fan-in trait.
628struct MuxRef<'b>(&'b mut (dyn DynMultiInputElement + 'b));
629
630impl MultiInputElement for MuxRef<'_> {
631    type ProcessFuture<'a>
632        = BoxFuture<'a, Result<(), G2gError>>
633    where
634        Self: 'a;
635
636    fn input_count(&self) -> usize {
637        self.0.input_count()
638    }
639
640    fn input_pts_ordered(&self) -> bool {
641        self.0.input_pts_ordered()
642    }
643
644    fn tick_interval_ns(&self) -> Option<u64> {
645        self.0.tick_interval_ns()
646    }
647
648    /// Only reachable by a direct call: the arms drive `process`, and
649    /// `caps_constraint_as_input` below forwards the erased element's own
650    /// constraint rather than routing through here.
651    fn intercept_caps(&self, input: usize, upstream_caps: &Caps) -> Result<Caps, G2gError> {
652        let upstream = CapsConstraint::LegacySource(upstream_caps.clone());
653        let pad = self.0.caps_constraint_as_input(input);
654        crate::runtime::solver::solve_linear(&[&upstream, &pad])
655            .map_err(|_| G2gError::CapsMismatch)?
656            .last()
657            .cloned()
658            .ok_or(G2gError::CapsMismatch)
659    }
660
661    fn caps_constraint_as_input(&self, input: usize) -> CapsConstraint<'_> {
662        self.0.caps_constraint_as_input(input)
663    }
664
665    fn caps_constraint_for_output(&self) -> Result<CapsConstraint<'_>, G2gError> {
666        self.0.caps_constraint_for_output()
667    }
668
669    fn configure_pipeline(
670        &mut self,
671        input: usize,
672        absolute_caps: &Caps,
673    ) -> Result<ConfigureOutcome, G2gError> {
674        self.0.configure_pipeline(input, absolute_caps)
675    }
676
677    fn output_caps(&self) -> Result<Caps, G2gError> {
678        self.0.output_caps()
679    }
680
681    fn output_follows_input(&self) -> Option<usize> {
682        self.0.output_follows_input()
683    }
684
685    fn input_pad_index(&self, req: &PadRequest, ordinal: usize) -> Option<usize> {
686        self.0.input_pad_index(req, ordinal)
687    }
688
689    fn accepts_runtime_input(&self, pad: usize, caps: &Caps) -> bool {
690        self.0.accepts_runtime_input(pad, caps)
691    }
692
693    fn reverse_channel(&self, input: usize) -> Option<crate::fanout::ReverseChannel> {
694        self.0.reverse_channel(input)
695    }
696
697    fn is_terminal(&self) -> bool {
698        self.0.is_terminal()
699    }
700
701    fn input_domains(&self) -> DomainSet {
702        self.0.input_domains()
703    }
704
705    fn propose_allocation_for_input(&self, input: usize, caps: &Caps) -> Option<AllocationParams> {
706        self.0.propose_allocation_for_input(input, caps)
707    }
708
709    fn propose_allocation_for_output(&self, caps: &Caps) -> Option<AllocationParams> {
710        self.0.propose_allocation_for_output(caps)
711    }
712
713    fn configure_allocation_for_output(&mut self, params: &AllocationParams) {
714        self.0.configure_allocation_for_output(params)
715    }
716
717    fn process<'a>(
718        &'a mut self,
719        input: usize,
720        packet: PipelinePacket,
721        out: &'a mut dyn OutputSink,
722    ) -> Self::ProcessFuture<'a> {
723        self.0.process(input, packet, out)
724    }
725
726    fn properties(&self) -> &'static [PropertySpec] {
727        self.0.properties()
728    }
729
730    fn set_property(&mut self, name: &str, value: PropValue) -> Result<(), PropError> {
731        self.0.set_property(name, value)
732    }
733
734    fn get_property(&self, name: &str) -> Option<PropValue> {
735        self.0.get_property(name)
736    }
737
738    fn metadata(&self) -> ElementMetadata {
739        self.0.metadata()
740    }
741
742    fn set_instance_name(&mut self, name: alloc::string::String) {
743        self.0.set_instance_name(name)
744    }
745
746    fn set_log_category(&mut self, category: alloc::string::String) {
747        self.0.set_log_category(category)
748    }
749}
750
751/// Forwarding impl so a borrowed `&mut dyn DynMultiInputElement` can be boxed
752/// into a `Box<dyn DynMultiInputElement + 'a>` graph node (the muxer wrapper
753/// builds a borrowing `Graph` over its `&mut` muxer reference). Disjoint from
754/// the `MultiInputElement` blanket above.
755impl<'b> DynMultiInputElement for &'b mut (dyn DynMultiInputElement + 'b) {
756    fn input_count(&self) -> usize {
757        (**self).input_count()
758    }
759
760    fn input_pts_ordered(&self) -> bool {
761        (**self).input_pts_ordered()
762    }
763
764    fn output_follows_input(&self) -> Option<usize> {
765        (**self).output_follows_input()
766    }
767
768    fn tick_interval_ns(&self) -> Option<u64> {
769        (**self).tick_interval_ns()
770    }
771
772    fn input_pad_index(&self, req: &PadRequest, ordinal: usize) -> Option<usize> {
773        (**self).input_pad_index(req, ordinal)
774    }
775
776    fn caps_constraint_as_input(&self, input: usize) -> CapsConstraint<'_> {
777        (**self).caps_constraint_as_input(input)
778    }
779
780    fn caps_constraint_for_output(&self) -> Result<CapsConstraint<'_>, G2gError> {
781        (**self).caps_constraint_for_output()
782    }
783
784    fn input_domains(&self) -> DomainSet {
785        (**self).input_domains()
786    }
787
788    fn propose_allocation_for_input(&self, input: usize, caps: &Caps) -> Option<AllocationParams> {
789        (**self).propose_allocation_for_input(input, caps)
790    }
791
792    fn propose_allocation_for_output(&self, caps: &Caps) -> Option<AllocationParams> {
793        (**self).propose_allocation_for_output(caps)
794    }
795
796    fn configure_allocation_for_output(&mut self, params: &AllocationParams) {
797        (**self).configure_allocation_for_output(params)
798    }
799
800    fn output_caps(&self) -> Result<Caps, G2gError> {
801        (**self).output_caps()
802    }
803
804    fn configure_pipeline(
805        &mut self,
806        input: usize,
807        absolute_caps: &Caps,
808    ) -> Result<ConfigureOutcome, G2gError> {
809        (**self).configure_pipeline(input, absolute_caps)
810    }
811
812    fn process<'a>(
813        &'a mut self,
814        input: usize,
815        packet: PipelinePacket,
816        out: &'a mut dyn OutputSink,
817    ) -> BoxFuture<'a, Result<(), G2gError>> {
818        (**self).process(input, packet, out)
819    }
820
821    fn properties(&self) -> &'static [PropertySpec] {
822        (**self).properties()
823    }
824
825    fn set_property(&mut self, name: &str, value: PropValue) -> Result<(), PropError> {
826        (**self).set_property(name, value)
827    }
828
829    fn get_property(&self, name: &str) -> Option<PropValue> {
830        (**self).get_property(name)
831    }
832
833    fn metadata(&self) -> ElementMetadata {
834        (**self).metadata()
835    }
836
837    fn reverse_channel(&self, input: usize) -> Option<crate::fanout::ReverseChannel> {
838        (**self).reverse_channel(input)
839    }
840
841    fn is_terminal(&self) -> bool {
842        (**self).is_terminal()
843    }
844
845    fn accepts_runtime_input(&self, pad: usize, caps: &Caps) -> bool {
846        (**self).accepts_runtime_input(pad, caps)
847    }
848
849    fn set_instance_name(&mut self, name: alloc::string::String) {
850        (**self).set_instance_name(name)
851    }
852
853    fn set_log_category(&mut self, category: alloc::string::String) {
854        (**self).set_log_category(category)
855    }
856
857    fn drive_muxer_arm<'s>(
858        self: Box<Self>,
859        io: crate::runtime::MuxerArmIo<'s>,
860    ) -> BoxFuture<'s, Result<u64, G2gError>>
861    where
862        Self: 's,
863    {
864        let mux = MuxRef(*self);
865        if MultiInputElement::input_pts_ordered(&mux) {
866            Box::pin(crate::runtime::graph_runner::muxer_arm_pts(mux, io))
867        } else {
868            Box::pin(crate::runtime::graph_runner::muxer_arm(mux, io))
869        }
870    }
871
872    #[cfg(feature = "multi-thread")]
873    fn drive_muxer_arm_owned_tick(
874        self: Box<Self>,
875        io: crate::runtime::MuxerArmOwnedTickIo,
876    ) -> BoxFuture<'static, Result<u64, G2gError>>
877    where
878        Self: 'static,
879    {
880        Box::pin(crate::runtime::graph_runner::muxer_arm_owned_tick(
881            MuxRef(*self),
882            io,
883        ))
884    }
885
886    fn drive_fanin_sink_arm<'s>(
887        self: Box<Self>,
888        io: crate::runtime::FaninSinkArmIo,
889    ) -> BoxFuture<'s, Result<u64, G2gError>>
890    where
891        Self: 's,
892    {
893        Box::pin(crate::runtime::graph_runner::fanin_sink_arm(
894            MuxRef(*self),
895            io,
896        ))
897    }
898}
899
900/// Drives `N sources → Merger → 1 sink` (M9 fan-in). The `Merger` selects
901/// which input feeds the sink; the others are drained. The merged stream
902/// ends once every input has reached EOS.
903///
904/// Negotiation fixates each source's proposal independently and configures
905/// the sink against input 0's fixated caps (the merged-output caps);
906/// per-input caps negotiation is M10, so a `ReFixate` anywhere fails with
907/// `FixationFailed`. The slice assumes inputs agree (the A/B case).
908pub async fn run_fanin_sink<Snk, Clk>(
909    sources: Vec<&mut dyn DynSourceLoop>,
910    merger: &mut Merger,
911    sink: &mut Snk,
912    clock: &Clk,
913    link_capacity: impl Into<LinkCapacity>,
914) -> Result<RunStats, G2gError>
915where
916    Snk: AsyncElement,
917    Clk: PipelineClock,
918{
919    run_fanin_sink_inner(sources, merger, sink, clock, link_capacity, None).await
920}
921
922/// As [`run_fanin_sink`], but taps live telemetry into `observer` (M846), the
923/// hand-built analog of [`run_graph_observed`](crate::runtime::run_graph_observed):
924/// the topology is the N sources, the merger, and the sink, and a concurrent task
925/// reads the sink's measured `process()` latency plus each link's packet / byte /
926/// drop counters mid-run via [`Observer::snapshot`].
927pub async fn run_fanin_sink_observed<Snk, Clk>(
928    sources: Vec<&mut dyn DynSourceLoop>,
929    merger: &mut Merger,
930    sink: &mut Snk,
931    clock: &Clk,
932    link_capacity: impl Into<LinkCapacity>,
933    observer: &Observer,
934) -> Result<RunStats, G2gError>
935where
936    Snk: AsyncElement,
937    Clk: PipelineClock,
938{
939    run_fanin_sink_inner(sources, merger, sink, clock, link_capacity, Some(observer)).await
940}
941
942async fn run_fanin_sink_inner<Snk, Clk>(
943    sources: Vec<&mut dyn DynSourceLoop>,
944    merger: &mut Merger,
945    sink: &mut Snk,
946    _clock: &Clk,
947    link_capacity: impl Into<LinkCapacity>,
948    observer: Option<&Observer>,
949) -> Result<RunStats, G2gError>
950where
951    Snk: AsyncElement,
952    Clk: PipelineClock,
953{
954    let link_capacity: usize = link_capacity.into().get();
955    let input_count = sources.len();
956    assert!(input_count > 0, "fan-in needs at least one source");
957    assert!(
958        merger.input_count() == input_count,
959        "merger input count must match the number of sources"
960    );
961
962    let mut sources = sources;
963    // M842: instance naming + lifecycle logging, as in `run_graph`. The `Merger`
964    // carries no element payload (it selects, like a structural tee), so it is
965    // not named, matching the tee case there.
966    let mut namer = crate::log::InstanceNamer::new();
967    let mut source_names: Vec<alloc::string::String> = Vec::with_capacity(input_count);
968    for source in sources.iter_mut() {
969        let name = namer.add(source.log_category(), None);
970        source.set_instance_name(name.clone());
971        source_names.push(name);
972    }
973    let sink_name = namer.add(crate::log::short_type_name::<Snk>(), None);
974    AsyncElement::set_instance_name(sink, sink_name.clone());
975    // M846: the sink is the only node here with a `process()`, so it carries the
976    // run's measured-latency probe (as in the linear runners).
977    let sink_probe = ElementProbe::new(sink_name);
978
979    // Phase 1 + 2: fixate each source's caps and configure it; the sink is
980    // configured against input 0's fixated caps (the merged-output caps). This
981    // is not routed through `solve_linear` because the branches do not form a
982    // chain: each is narrowed against the one peer they share, the sink.
983    let mut fixated_caps: Vec<Caps> = Vec::with_capacity(input_count);
984    for source in sources.iter_mut() {
985        let produced = source.produced_caps().await?;
986        let fixated = {
987            let sink_constraint = sink.caps_constraint_as_sink();
988            select_branch_caps(&produced, &sink_constraint)?
989        };
990        source.configure_pipeline(&fixated)?.reject_refixate()?;
991        fixated_caps.push(fixated);
992    }
993    let merged_caps = fixated_caps[0].clone();
994    sink.configure_pipeline(&merged_caps)?.reject_refixate()?;
995
996    // One input link per source, one shared output link to the sink.
997    let tap = observer.is_some();
998    let mut input_senders = Vec::with_capacity(input_count);
999    let mut input_receivers = Vec::with_capacity(input_count);
1000    let mut taps = Vec::with_capacity(input_count + 1);
1001    for _ in 0..input_count {
1002        let (tx, rx, edge) = link_tapped(link_capacity, tap);
1003        input_senders.push(tx);
1004        input_receivers.push(rx);
1005        taps.push(edge);
1006    }
1007    let (out_tx, out_rx, out_tap) = link_tapped(link_capacity, tap);
1008    taps.push(out_tap);
1009    let live_inputs = Arc::new(AtomicUsize::new(input_count));
1010
1011    // Dev-tooling tap: sources 0..N, then the merger (structural, unnamed, like a
1012    // tee in `run_graph`), then the sink.
1013    if let Some(obs) = observer {
1014        let merger_id = input_count;
1015        let sink_id = input_count + 1;
1016        let mut nodes: Vec<TapNode> = source_names
1017            .iter()
1018            .map(|n| (n.clone(), NodeRole::Source, None))
1019            .collect();
1020        nodes.push((alloc::string::String::new(), NodeRole::Muxer, None));
1021        nodes.push((
1022            alloc::string::String::from(sink_probe.name()),
1023            NodeRole::Sink,
1024            Some(sink_probe.clone()),
1025        ));
1026        let mut edges: Vec<TapEdge> = Vec::with_capacity(input_count + 1);
1027        let mut taps = taps.into_iter();
1028        for (i, caps) in fixated_caps.iter().enumerate() {
1029            edges.push((i, merger_id, caps.clone(), taps.next().expect("input tap")));
1030        }
1031        edges.push((
1032            merger_id,
1033            sink_id,
1034            merged_caps.clone(),
1035            taps.next().expect("output tap"),
1036        ));
1037        register_runner_tap(obs, nodes, edges);
1038    }
1039
1040    let mut source_arms: Vec<BoxFuture<'_, Result<u64, G2gError>>> =
1041        Vec::with_capacity(input_count);
1042    for (source, in_tx) in sources.into_iter().zip(input_senders) {
1043        source_arms.push(Box::pin(async move {
1044            let mut adapter = SenderSink::new(in_tx);
1045            source.run(&mut adapter).await
1046        }));
1047    }
1048
1049    let mut forwarder_arms: Vec<BoxFuture<'_, Result<u64, G2gError>>> =
1050        Vec::with_capacity(input_count);
1051    for (idx, in_rx) in input_receivers.into_iter().enumerate() {
1052        let handle = merger.handle();
1053        let out_tx_i = out_tx.clone();
1054        let live = live_inputs.clone();
1055        forwarder_arms.push(Box::pin(async move {
1056            let mut out = SenderSink::new(out_tx_i);
1057            loop {
1058                match in_rx.recv().await {
1059                    Some(PipelinePacket::Eos) | None => {
1060                        // Last input to finish emits the single merged EOS.
1061                        if live.fetch_sub(1, Ordering::SeqCst) == 1 {
1062                            out.push(PipelinePacket::Eos).await?;
1063                        }
1064                        return Ok::<u64, G2gError>(0);
1065                    }
1066                    Some(packet) => {
1067                        if handle.selected() == idx {
1068                            out.push(packet).await?;
1069                        }
1070                        // Non-selected input: drain and discard so its
1071                        // source never stalls on a full link.
1072                    }
1073                }
1074            }
1075        }));
1076    }
1077    // Drop the runner's own sender clone so only the forwarders keep the
1078    // output link open.
1079    drop(out_tx);
1080
1081    let probe_for_sink = sink_probe.clone();
1082    let sink_arm: BoxFuture<'_, Result<u64, G2gError>> = Box::pin(async move {
1083        let mut null = NullSink;
1084        let mut consumed: u64 = 0;
1085        loop {
1086            match out_rx.recv().await {
1087                Some(PipelinePacket::Eos) => {
1088                    sink.process(PipelinePacket::Eos, &mut null).await?;
1089                    return Ok::<u64, G2gError>(consumed);
1090                }
1091                Some(PipelinePacket::CapsChanged(new_caps)) => {
1092                    match log_caps_rejected(
1093                        Some(probe_for_sink.name()),
1094                        &new_caps,
1095                        sink.configure_pipeline(&new_caps),
1096                    )? {
1097                        ConfigureOutcome::Accepted => {
1098                            sink.process(PipelinePacket::CapsChanged(new_caps), &mut null)
1099                                .await?;
1100                        }
1101                        ConfigureOutcome::ReFixate(counter) => {
1102                            out_rx.request_reconfigure(Reconfigure::Propose(counter));
1103                        }
1104                    }
1105                }
1106                Some(packet) => {
1107                    let is_data = matches!(packet, PipelinePacket::DataFrame(_));
1108                    if is_data {
1109                        consumed += 1;
1110                        probe_for_sink.record_fill(out_rx.fill_percent());
1111                    }
1112                    let t0 = is_data.then(ElementProbe::mark).flatten();
1113                    sink.process(packet, &mut null).await?;
1114                    if is_data {
1115                        probe_for_sink.record_proc_since(t0);
1116                    }
1117                }
1118                None => return Ok(consumed),
1119            }
1120        }
1121    });
1122
1123    // Arm order: [source0..N, forwarder0..N, sink].
1124    let mut arms = Vec::with_capacity(2 * input_count + 1);
1125    arms.extend(source_arms);
1126    arms.extend(forwarder_arms);
1127    arms.push(sink_arm);
1128
1129    let results = join_all(arms).await;
1130    let mut counts = Vec::with_capacity(results.len());
1131    for r in results {
1132        counts.push(r?);
1133    }
1134    let emitted: u64 = counts[0..input_count].iter().copied().sum();
1135    let consumed = counts[2 * input_count];
1136    // Fan-in latency / allocation / clock election across N inputs is deferred
1137    // (M12 covers the linear path); report neutral values rather than a
1138    // misleading partial one.
1139    Ok(RunStats {
1140        frames_emitted: emitted,
1141        frames_consumed: consumed,
1142        frames_dropped: 0,
1143        latency: LatencyReport::ZERO,
1144        allocation: None,
1145        clock_priority: ClockPriority::SystemFallback,
1146        base_time_ns: 0,
1147        coordinator_events: 0,
1148        per_element: alloc::vec![sink_probe.snapshot()],
1149    })
1150}
1151
1152/// `OutputSink` that tags each pushed packet with its source's input index and
1153/// forwards it into the shared session channel. Reverse signals are not routed
1154/// per-input yet (a follow-up), so push always reports `Accepted`.
1155struct TaggingSink {
1156    idx: usize,
1157    tx: Sender<(usize, PipelinePacket)>,
1158    /// The session's reverse-signal handle for this input, if any. After queuing
1159    /// each packet we surface any pending PLI / BWE for this track to the source
1160    /// as the push outcome, so a reverse signal reaches the matching upstream.
1161    reverse: Option<crate::fanout::ReverseChannel>,
1162    /// Live traffic counters for this input's edge (M846). The tagged channel is
1163    /// shared by every input, so the per-edge count is kept here rather than on
1164    /// the channel. `None` unless an observer is attached.
1165    counters: Option<Arc<EdgeCounters>>,
1166    /// Content-inspection slot for this input's edge (M849), the analog of the
1167    /// one [`SenderSink`] shares with its link: the tagged channel is shared, so
1168    /// each input carries its own slot here. `None` unless an observer is
1169    /// attached, and empty (pass-through) until a tool installs an interceptor.
1170    probe: Option<ProbeSlot>,
1171    /// The in-flight tagged packet of a blocked push, so the pre-send steps run
1172    /// exactly once and a later poll resumes at the enqueue. The tagged channel
1173    /// carries `(input, packet)`, so the caller's slot cannot hold it directly.
1174    staged: Option<(usize, PipelinePacket)>,
1175    /// Size of the staged packet, measured before the send moves it away.
1176    staged_bytes: u64,
1177    /// `DataFrame`s this input got into the channel, the count its arm reports
1178    /// when the session ends under it (the source's own count dies with the
1179    /// error that stopped it).
1180    delivered_data_frames: u64,
1181    /// Whether a push found the shared channel closed. The session arm holds the
1182    /// only receiver, so that can only happen once the session has returned.
1183    channel_closed: bool,
1184}
1185
1186impl OutputSink for TaggingSink {
1187    fn begin_push(&mut self) {
1188        // A cancelled push's packet died with its future; drop its leftovers.
1189        self.staged = None;
1190    }
1191
1192    fn poll_push(
1193        &mut self,
1194        cx: &mut core::task::Context<'_>,
1195        packet_slot: &mut Option<PipelinePacket>,
1196    ) -> core::task::Poll<Result<PushOutcome, G2gError>> {
1197        use core::task::Poll;
1198        if self.staged.is_none() {
1199            let packet = packet_slot
1200                .take()
1201                .expect("poll_push called without a packet");
1202            // A probe may drop the packet before it enters the channel, as on a
1203            // `SenderSink` link.
1204            if let Some(p) = &self.probe {
1205                if p.action(&packet) == ProbeAction::Drop {
1206                    return Poll::Ready(Ok(PushOutcome::Accepted));
1207                }
1208            }
1209            self.staged_bytes = packet_bytes(&packet);
1210            self.staged = Some((self.idx, packet));
1211        }
1212        let is_data = matches!(self.staged, Some((_, PipelinePacket::DataFrame(_))));
1213        match self.tx.poll_send(cx, &mut self.staged) {
1214            Poll::Pending => Poll::Pending,
1215            Poll::Ready(Ok(())) => {
1216                if is_data {
1217                    self.delivered_data_frames += 1;
1218                }
1219                if let Some(c) = &self.counters {
1220                    c.record_packet(self.staged_bytes, 0);
1221                }
1222                Poll::Ready(Ok(self
1223                    .reverse
1224                    .as_ref()
1225                    .and_then(|rc| rc.take())
1226                    .unwrap_or(PushOutcome::Accepted)))
1227            }
1228            Poll::Ready(Err(_)) => {
1229                self.staged = None;
1230                self.channel_closed = true;
1231                Poll::Ready(Err(G2gError::Shutdown))
1232            }
1233        }
1234    }
1235}
1236
1237impl TaggingSink {
1238    fn new(
1239        idx: usize,
1240        tx: Sender<(usize, PipelinePacket)>,
1241        reverse: Option<crate::fanout::ReverseChannel>,
1242        counters: Option<Arc<EdgeCounters>>,
1243        probe: Option<ProbeSlot>,
1244    ) -> Self {
1245        Self {
1246            idx,
1247            tx,
1248            reverse,
1249            counters,
1250            probe,
1251            staged: None,
1252            staged_bytes: 0,
1253            delivered_data_frames: 0,
1254            channel_closed: false,
1255        }
1256    }
1257}
1258
1259/// The result of a send-side arm, with a session that ended under it read as a
1260/// clean wind-down rather than a failed run. Only the session arm holds the
1261/// shared inbound receiver, so a source whose push found the channel closed was
1262/// still streaming when the session returned: it has nowhere left to push, and
1263/// the session's own result is what decides the run. Any other error stands.
1264fn wind_down_when_session_ended(
1265    result: Result<u64, G2gError>,
1266    adapter: &TaggingSink,
1267) -> Result<u64, G2gError> {
1268    match result {
1269        Err(G2gError::Shutdown) if adapter.channel_closed => Ok(adapter.delivered_data_frames),
1270        other => other,
1271    }
1272}
1273
1274/// Drives `N sources → terminal multi-input element` with **no downstream sink**
1275/// (the element is the destination, e.g. a WebRTC session that publishes its
1276/// inputs over one PeerConnection). The fan-in analog of a terminal sink: unlike
1277/// [`run_muxer_sink`], the [`MultiInputElement`] here produces no merged output,
1278/// so there is no trailing sink to wire.
1279///
1280/// Each source is narrowed against the session input pad it feeds (like
1281/// [`run_fanin_sink`], which narrows every branch against the merged sink), and
1282/// the resulting caps configure both the source and that pad. Every source pushes
1283/// into one shared `(input, packet)` channel; a single session task drains it and
1284/// calls `session.process(input, ..)` serially, so the session keeps `&mut` state
1285/// without aliasing. A per-input `Eos` is delivered to the session (so it can
1286/// flush that track); the run ends once every input has ended.
1287///
1288/// `output_caps()` is not consulted (there is no output), and reverse signals
1289/// (keyframe-request / bitrate / QoS) are not yet routed back per-input through
1290/// this runner, a documented follow-up.
1291pub async fn run_fanin_session<Sess, Clk>(
1292    sources: Vec<&mut dyn DynSourceLoop>,
1293    session: &mut Sess,
1294    clock: &Clk,
1295    link_capacity: impl Into<LinkCapacity>,
1296) -> Result<RunStats, G2gError>
1297where
1298    Sess: MultiInputElement,
1299    Clk: PipelineClock,
1300{
1301    run_fanin_session_inner(sources, session, clock, link_capacity, None).await
1302}
1303
1304/// As [`run_fanin_session`], but taps live telemetry into `observer` (M846): the
1305/// topology is the N sources feeding the session, whose measured `process()`
1306/// latency and per-input packet / byte counts a concurrent task reads mid-run via
1307/// [`Observer::snapshot`].
1308pub async fn run_fanin_session_observed<Sess, Clk>(
1309    sources: Vec<&mut dyn DynSourceLoop>,
1310    session: &mut Sess,
1311    clock: &Clk,
1312    link_capacity: impl Into<LinkCapacity>,
1313    observer: &Observer,
1314) -> Result<RunStats, G2gError>
1315where
1316    Sess: MultiInputElement,
1317    Clk: PipelineClock,
1318{
1319    run_fanin_session_inner(sources, session, clock, link_capacity, Some(observer)).await
1320}
1321
1322async fn run_fanin_session_inner<Sess, Clk>(
1323    sources: Vec<&mut dyn DynSourceLoop>,
1324    session: &mut Sess,
1325    _clock: &Clk,
1326    link_capacity: impl Into<LinkCapacity>,
1327    observer: Option<&Observer>,
1328) -> Result<RunStats, G2gError>
1329where
1330    Sess: MultiInputElement,
1331    Clk: PipelineClock,
1332{
1333    let link_capacity: usize = link_capacity.into().get();
1334    let input_count = sources.len();
1335    assert!(input_count > 0, "fan-in session needs at least one source");
1336    assert!(
1337        session.input_count() == input_count,
1338        "session input count must match the number of sources"
1339    );
1340
1341    // M846: instance naming, as in `run_fanin_sink`. The session is the only node
1342    // with a `process()`, so it carries the run's measured-latency probe.
1343    let mut sources = sources;
1344    let mut namer = crate::log::InstanceNamer::new();
1345    let mut source_names: Vec<alloc::string::String> = Vec::with_capacity(input_count);
1346    for source in sources.iter_mut() {
1347        let name = namer.add(source.log_category(), None);
1348        source.set_instance_name(name.clone());
1349        source_names.push(name);
1350    }
1351    let session_probe = ElementProbe::new(namer.add(crate::log::short_type_name::<Sess>(), None));
1352
1353    // Phase 1 + 2 per input: each source is narrowed against the session input
1354    // pad it feeds; the fixated caps configure both the source and that pad (the
1355    // session decides the track kind, e.g. H.264 video vs Opus audio, from them).
1356    let mut fixated_caps: Vec<Caps> = Vec::with_capacity(input_count);
1357    for (i, source) in sources.iter_mut().enumerate() {
1358        let produced = source.produced_caps().await?;
1359        let fixated = {
1360            let pad_constraint = MultiInputElement::caps_constraint_as_input(session, i);
1361            select_branch_caps(&produced, &pad_constraint)?
1362        };
1363        source.configure_pipeline(&fixated)?.reject_refixate()?;
1364        MultiInputElement::configure_pipeline(session, i, &fixated)?.reject_refixate()?;
1365        fixated_caps.push(fixated);
1366    }
1367
1368    // One shared tagged channel: every source pushes `(its index, packet)`.
1369    let (tx, rx) = bounded::<(usize, PipelinePacket)>(link_capacity);
1370    let live_inputs = Arc::new(AtomicUsize::new(input_count));
1371    // Per-input edge counters and content-inspection slots, kept on each
1372    // `TaggingSink` (the tagged channel is shared, so it cannot carry per-edge
1373    // state itself).
1374    let counters: Vec<Option<Arc<EdgeCounters>>> = (0..input_count)
1375        .map(|_| observer.map(|_| Arc::new(EdgeCounters::default())))
1376        .collect();
1377    let probes: Vec<Option<ProbeSlot>> = (0..input_count)
1378        .map(|_| observer.map(|_| ProbeSlot::default()))
1379        .collect();
1380
1381    if let Some(obs) = observer {
1382        let session_id = input_count;
1383        let mut nodes: Vec<TapNode> = source_names
1384            .iter()
1385            .map(|n| (n.clone(), NodeRole::Source, None))
1386            .collect();
1387        nodes.push((
1388            alloc::string::String::from(session_probe.name()),
1389            NodeRole::Muxer,
1390            Some(session_probe.clone()),
1391        ));
1392        let edges: Vec<TapEdge> = fixated_caps
1393            .iter()
1394            .zip(counters.iter())
1395            .zip(probes.iter())
1396            .enumerate()
1397            .map(|(i, ((caps, c), p))| {
1398                (
1399                    i,
1400                    session_id,
1401                    caps.clone(),
1402                    EdgeTap {
1403                        probe: p.clone().unwrap_or_default(),
1404                        counters: c.clone(),
1405                    },
1406                )
1407            })
1408            .collect();
1409        register_runner_tap(obs, nodes, edges);
1410    }
1411
1412    // Per-input reverse-signal handles (WebRTC PLI / BWE), cloned before the
1413    // session moves into its arm so a signal for track i reaches source i.
1414    let reverse: Vec<Option<crate::fanout::ReverseChannel>> = (0..input_count)
1415        .map(|i| session.reverse_channel(i))
1416        .collect();
1417
1418    let mut source_arms: Vec<BoxFuture<'_, Result<u64, G2gError>>> =
1419        Vec::with_capacity(input_count);
1420    for (i, source) in sources.into_iter().enumerate() {
1421        let tx_i = tx.clone();
1422        let reverse_i = reverse[i].clone();
1423        let counters_i = counters[i].clone();
1424        let probe_i = probes[i].clone();
1425        source_arms.push(Box::pin(async move {
1426            let mut adapter = TaggingSink::new(i, tx_i, reverse_i, counters_i, probe_i);
1427            let result = source.run(&mut adapter).await;
1428            wind_down_when_session_ended(result, &adapter)
1429        }));
1430    }
1431    // Drop the runner's own sender so the channel closes once all sources end.
1432    drop(tx);
1433
1434    let probe_for_session = session_probe.clone();
1435    let session_arm: BoxFuture<'_, Result<u64, G2gError>> = Box::pin(async move {
1436        let mut null = NullSink;
1437        let mut consumed: u64 = 0;
1438        loop {
1439            match rx.recv().await {
1440                Some((idx, PipelinePacket::Eos)) => {
1441                    // Per-input end: let the session flush that track, then finish
1442                    // once every input has ended (the session owns its own EOS to
1443                    // the network).
1444                    session.process(idx, PipelinePacket::Eos, &mut null).await?;
1445                    if live_inputs.fetch_sub(1, Ordering::SeqCst) == 1 {
1446                        return Ok::<u64, G2gError>(consumed);
1447                    }
1448                }
1449                Some((idx, PipelinePacket::CapsChanged(new_caps))) => {
1450                    // Mid-stream re-solve (M724): re-configure the changed pad
1451                    // before the session sees the new caps. A counter-fixation
1452                    // cannot travel back to the tagged source, so it fails loud.
1453                    MultiInputElement::configure_pipeline(session, idx, &new_caps)?
1454                        .reject_refixate()?;
1455                    session
1456                        .process(idx, PipelinePacket::CapsChanged(new_caps), &mut null)
1457                        .await?;
1458                }
1459                Some((idx, packet)) => {
1460                    let is_data = matches!(packet, PipelinePacket::DataFrame(_));
1461                    if is_data {
1462                        consumed += 1;
1463                        probe_for_session.record_fill(rx.fill_percent());
1464                    }
1465                    let t0 = is_data.then(ElementProbe::mark).flatten();
1466                    session.process(idx, packet, &mut null).await?;
1467                    if is_data {
1468                        probe_for_session.record_proc_since(t0);
1469                    }
1470                }
1471                None => return Ok(consumed),
1472            }
1473        }
1474    });
1475
1476    let mut arms = Vec::with_capacity(input_count + 1);
1477    arms.extend(source_arms);
1478    arms.push(session_arm);
1479
1480    let results = join_all(arms).await;
1481    let mut counts = Vec::with_capacity(results.len());
1482    for r in results {
1483        counts.push(r?);
1484    }
1485    let emitted: u64 = counts[0..input_count].iter().copied().sum();
1486    let consumed = counts[input_count];
1487    Ok(RunStats {
1488        frames_emitted: emitted,
1489        frames_consumed: consumed,
1490        frames_dropped: 0,
1491        latency: LatencyReport::ZERO,
1492        allocation: None,
1493        clock_priority: ClockPriority::SystemFallback,
1494        base_time_ns: 0,
1495        coordinator_events: 0,
1496        per_element: alloc::vec![session_probe.snapshot()],
1497    })
1498}
1499
1500/// The per-input reverse-signal handles a duplex run shares with its session.
1501/// The dynamic runner fills a slot when it attaches a send track mid-run, and the
1502/// session reads it back through [`DuplexInbound::reverse_channel`]; the
1503/// fixed-arity runner leaves it empty, having handed every channel over up front.
1504type ReverseMap = Arc<Mutex<Vec<Option<crate::fanout::ReverseChannel>>>>;
1505
1506/// [`DuplexInbound`] backed by the runner's shared tagged inbound channel, so a
1507/// [`MultiDuplexSession`] drains its send-side sources through the same erased
1508/// interface regardless of how the runner wired them.
1509struct InboundReceiver {
1510    rx: Receiver<(usize, PipelinePacket)>,
1511    reverse: ReverseMap,
1512}
1513
1514impl DuplexInbound for InboundReceiver {
1515    fn recv(&mut self) -> BoxFuture<'_, Option<(usize, PipelinePacket)>> {
1516        Box::pin(async move { self.rx.recv().await })
1517    }
1518
1519    fn reverse_channel(&self, input: usize) -> Option<crate::fanout::ReverseChannel> {
1520        self.reverse.lock().get(input).cloned().flatten()
1521    }
1522}
1523
1524/// One recv-side sink arm of a duplex run: configure on each `CapsChanged`, then
1525/// process until `Eos` or the branch link closes. Shared with the dynamic runner,
1526/// so a port grown mid-run drains exactly like a declared one.
1527async fn duplex_sink_arm(
1528    sink: &mut dyn DynAsyncElement,
1529    rx: crate::runtime::channel::LinkReceiver,
1530    probe: &ElementProbe,
1531) -> Result<u64, G2gError> {
1532    let mut null = NullSink;
1533    let mut consumed: u64 = 0;
1534    loop {
1535        match rx.recv().await {
1536            Some(PipelinePacket::Eos) => {
1537                sink.process(PipelinePacket::Eos, &mut null).await?;
1538                return Ok(consumed);
1539            }
1540            Some(PipelinePacket::CapsChanged(new_caps)) => {
1541                match log_caps_rejected(
1542                    Some(probe.name()),
1543                    &new_caps,
1544                    sink.configure_pipeline(&new_caps),
1545                )? {
1546                    ConfigureOutcome::Accepted => {
1547                        sink.process(PipelinePacket::CapsChanged(new_caps), &mut null)
1548                            .await?;
1549                    }
1550                    ConfigureOutcome::ReFixate(counter) => {
1551                        rx.request_reconfigure(Reconfigure::Propose(counter));
1552                    }
1553                }
1554            }
1555            Some(packet) => {
1556                let is_data = matches!(packet, PipelinePacket::DataFrame(_));
1557                if is_data {
1558                    consumed += 1;
1559                    probe.record_fill(rx.fill_percent());
1560                }
1561                let t0 = is_data.then(ElementProbe::mark).flatten();
1562                sink.process(packet, &mut null).await?;
1563                if is_data {
1564                    probe.record_proc_since(t0);
1565                }
1566            }
1567            None => return Ok(consumed),
1568        }
1569    }
1570}
1571
1572/// Drives a terminal **duplex** session ([`MultiDuplexSession`]): N send-side
1573/// sources **and** M recv-side sinks over one connection, the union of
1574/// [`run_fanin_session`] (send) and
1575/// [`run_fanout_session`](crate::runtime::run_fanout_session) (recv). A
1576/// `WebRtcBin`-style sendrecv PeerConnection both publishes local tracks and
1577/// emits the peer's tracks; this is the runner shape that expresses an element
1578/// that is at once a sink (for its inputs) and a source (for its outputs), which
1579/// neither the fan-in nor fan-out session runner could.
1580///
1581/// Negotiation mirrors both halves: each source is narrowed against the session
1582/// input pad it feeds and configures it (send side, like [`run_fanin_session`]); each
1583/// recv-side output's caps configure the matching sink (like
1584/// [`run_fanout_session`](crate::runtime::run_fanout_session)). At runtime the
1585/// sources push `(input, packet)` into one shared tagged channel; the single
1586/// session arm owns `&mut session` and calls `session.run(inbound, out)`, so the
1587/// send and recv halves share state with no aliasing (no detached task needed);
1588/// the M sink arms drain the per-output branch links. The run ends when the
1589/// session's `run` returns (e.g. on peer disconnect), which closes the branch
1590/// links and lets the sinks finish.
1591///
1592/// Per-input reverse signals (a remote PLI / BWE arriving on a track's m-line)
1593/// are routed back to the matching send source via
1594/// [`MultiDuplexSession::reverse_channel`], exactly as the fan-in session runner
1595/// does. Per-branch mid-stream re-solve on the recv side is still a follow-up.
1596pub async fn run_duplex_session<Sess, Clk>(
1597    sources: Vec<&mut dyn DynSourceLoop>,
1598    session: &mut Sess,
1599    sinks: Vec<&mut dyn DynAsyncElement>,
1600    clock: &Clk,
1601    link_capacity: impl Into<LinkCapacity>,
1602) -> Result<RunStats, G2gError>
1603where
1604    Sess: MultiDuplexSession,
1605    Clk: PipelineClock,
1606{
1607    run_duplex_session_inner(sources, session, sinks, clock, link_capacity, None).await
1608}
1609
1610/// As [`run_duplex_session`], but taps live telemetry into `observer` (M846):
1611/// the send sources, the session, and the recv sinks, with each sink's measured
1612/// `process()` latency and every link's packet / byte counts readable mid-run via
1613/// [`Observer::snapshot`].
1614pub async fn run_duplex_session_observed<Sess, Clk>(
1615    sources: Vec<&mut dyn DynSourceLoop>,
1616    session: &mut Sess,
1617    sinks: Vec<&mut dyn DynAsyncElement>,
1618    clock: &Clk,
1619    link_capacity: impl Into<LinkCapacity>,
1620    observer: &Observer,
1621) -> Result<RunStats, G2gError>
1622where
1623    Sess: MultiDuplexSession,
1624    Clk: PipelineClock,
1625{
1626    run_duplex_session_inner(
1627        sources,
1628        session,
1629        sinks,
1630        clock,
1631        link_capacity,
1632        Some(observer),
1633    )
1634    .await
1635}
1636
1637#[allow(clippy::too_many_arguments)]
1638async fn run_duplex_session_inner<Sess, Clk>(
1639    sources: Vec<&mut dyn DynSourceLoop>,
1640    session: &mut Sess,
1641    sinks: Vec<&mut dyn DynAsyncElement>,
1642    _clock: &Clk,
1643    link_capacity: impl Into<LinkCapacity>,
1644    observer: Option<&Observer>,
1645) -> Result<RunStats, G2gError>
1646where
1647    Sess: MultiDuplexSession,
1648    Clk: PipelineClock,
1649{
1650    let link_capacity: usize = link_capacity.into().get();
1651    let input_count = sources.len();
1652    let output_count = sinks.len();
1653    assert!(
1654        input_count > 0,
1655        "duplex session needs at least one send source"
1656    );
1657    assert!(
1658        output_count > 0,
1659        "duplex session needs at least one recv sink"
1660    );
1661    assert!(
1662        session.input_count() == input_count,
1663        "session input count must match the number of send sources"
1664    );
1665    assert!(
1666        session.output_count() == output_count,
1667        "session output count must match the number of recv sinks"
1668    );
1669
1670    // M846: instance naming + one measured-latency probe per recv sink (the
1671    // session drives itself through `run`, so it has no `process()` to time).
1672    let mut sources = sources;
1673    let mut sinks = sinks;
1674    let mut namer = crate::log::InstanceNamer::new();
1675    let mut source_names: Vec<alloc::string::String> = Vec::with_capacity(input_count);
1676    for source in sources.iter_mut() {
1677        let name = namer.add(source.log_category(), None);
1678        source.set_instance_name(name.clone());
1679        source_names.push(name);
1680    }
1681    let session_name = namer.add(crate::log::short_type_name::<Sess>(), None);
1682    let mut sink_probes = Vec::with_capacity(output_count);
1683    for sink in sinks.iter_mut() {
1684        let name = namer.add(sink.log_category(), None);
1685        sink.set_instance_name(name.clone());
1686        sink_probes.push(ElementProbe::new(name));
1687    }
1688
1689    // Negotiate the send inputs (like run_fanin_session): each source is narrowed
1690    // against the send input pad it feeds, and the fixated caps configure both
1691    // the source and that pad.
1692    let mut input_caps: Vec<Caps> = Vec::with_capacity(input_count);
1693    for (i, source) in sources.iter_mut().enumerate() {
1694        let produced = source.produced_caps().await?;
1695        let fixated = {
1696            let pad_constraint = MultiDuplexSession::caps_constraint_as_input(session, i);
1697            select_branch_caps(&produced, &pad_constraint)?
1698        };
1699        source.configure_pipeline(&fixated)?.reject_refixate()?;
1700        session.configure_input(i, &fixated)?.reject_refixate()?;
1701        input_caps.push(fixated);
1702    }
1703    // Negotiate the recv outputs (like run_fanout_session): the session self-
1704    // fixates each output's caps and configures the matching sink.
1705    let mut output_caps: Vec<Caps> = Vec::with_capacity(output_count);
1706    for (o, sink) in sinks.iter_mut().enumerate() {
1707        let fixated = session.output_caps(o)?.fixate()?;
1708        sink.configure_pipeline(&fixated)?.reject_refixate()?;
1709        output_caps.push(fixated);
1710    }
1711
1712    // Inbound: one shared tagged channel; every send source pushes (its index, packet).
1713    let (in_tx, in_rx) = bounded::<(usize, PipelinePacket)>(link_capacity);
1714    // Per-input edge counters and content-inspection slots ride the
1715    // `TaggingSink`s, the shared channel carries no per-edge state itself.
1716    let in_counters: Vec<Option<Arc<EdgeCounters>>> = (0..input_count)
1717        .map(|_| observer.map(|_| Arc::new(EdgeCounters::default())))
1718        .collect();
1719    let in_probes: Vec<Option<ProbeSlot>> = (0..input_count)
1720        .map(|_| observer.map(|_| ProbeSlot::default()))
1721        .collect();
1722    // Outbound: one branch link per recv output.
1723    let tap = observer.is_some();
1724    let mut branch_senders = Vec::with_capacity(output_count);
1725    let mut branch_receivers = Vec::with_capacity(output_count);
1726    let mut branch_taps = Vec::with_capacity(output_count);
1727    for _ in 0..output_count {
1728        let (tx, rx, edge) = link_tapped(link_capacity, tap);
1729        branch_senders.push(SenderSink::new(tx));
1730        branch_receivers.push(rx);
1731        branch_taps.push(edge);
1732    }
1733
1734    if let Some(obs) = observer {
1735        let session_id = input_count;
1736        let mut nodes: Vec<TapNode> = source_names
1737            .iter()
1738            .map(|n| (n.clone(), NodeRole::Source, None))
1739            .collect();
1740        nodes.push((session_name, NodeRole::Muxer, None));
1741        for probe in &sink_probes {
1742            nodes.push((
1743                alloc::string::String::from(probe.name()),
1744                NodeRole::Sink,
1745                Some(probe.clone()),
1746            ));
1747        }
1748        let mut edges: Vec<TapEdge> = Vec::with_capacity(input_count + output_count);
1749        for (i, ((caps, c), p)) in input_caps
1750            .iter()
1751            .zip(in_counters.iter())
1752            .zip(in_probes.iter())
1753            .enumerate()
1754        {
1755            edges.push((
1756                i,
1757                session_id,
1758                caps.clone(),
1759                EdgeTap {
1760                    probe: p.clone().unwrap_or_default(),
1761                    counters: c.clone(),
1762                },
1763            ));
1764        }
1765        for (o, (caps, edge)) in output_caps
1766            .iter()
1767            .zip(core::mem::take(&mut branch_taps))
1768            .enumerate()
1769        {
1770            edges.push((session_id, session_id + 1 + o, caps.clone(), edge));
1771        }
1772        register_runner_tap(obs, nodes, edges);
1773    }
1774
1775    // Per-input reverse-signal handles (WebRTC PLI / BWE), cloned before the
1776    // session moves into its arm so a signal for track i (a remote PLI on that
1777    // m-line, or a BWE estimate) reaches send source i, the duplex analog of the
1778    // fan-in session's per-input routing.
1779    let reverse: Vec<Option<crate::fanout::ReverseChannel>> = (0..input_count)
1780        .map(|i| session.reverse_channel(i))
1781        .collect();
1782
1783    let mut source_arms: Vec<BoxFuture<'_, Result<u64, G2gError>>> =
1784        Vec::with_capacity(input_count);
1785    for (i, source) in sources.into_iter().enumerate() {
1786        let tx_i = in_tx.clone();
1787        let reverse_i = reverse[i].clone();
1788        let counters_i = in_counters[i].clone();
1789        let probe_i = in_probes[i].clone();
1790        source_arms.push(Box::pin(async move {
1791            let mut adapter = TaggingSink::new(i, tx_i, reverse_i, counters_i, probe_i);
1792            let result = source.run(&mut adapter).await;
1793            wind_down_when_session_ended(result, &adapter)
1794        }));
1795    }
1796    // Drop the runner's own sender so the inbound channel closes once all sources
1797    // end (the session sees `recv() == None` and can stop publishing).
1798    drop(in_tx);
1799
1800    let session_arm: BoxFuture<'_, Result<u64, G2gError>> = Box::pin(async move {
1801        let mut inbound = InboundReceiver {
1802            rx: in_rx,
1803            reverse: Arc::new(Mutex::new(Vec::new())),
1804        };
1805        let mut multi = MultiSenderSink::new(branch_senders);
1806        session.run(&mut inbound, &mut multi).await
1807    });
1808
1809    let mut sink_arms: Vec<BoxFuture<'_, Result<u64, G2gError>>> = Vec::with_capacity(output_count);
1810    for ((sink, rx), probe) in sinks
1811        .into_iter()
1812        .zip(branch_receivers)
1813        .zip(sink_probes.iter().cloned())
1814    {
1815        sink_arms.push(Box::pin(
1816            async move { duplex_sink_arm(sink, rx, &probe).await },
1817        ));
1818    }
1819
1820    // Arm order: [source0..N, session, sink0..M].
1821    let mut arms = Vec::with_capacity(input_count + 1 + output_count);
1822    arms.extend(source_arms);
1823    arms.push(session_arm);
1824    arms.extend(sink_arms);
1825
1826    let results = join_all(arms).await;
1827    let mut counts = Vec::with_capacity(results.len());
1828    for r in results {
1829        counts.push(r?);
1830    }
1831    let emitted: u64 = counts[0..input_count].iter().copied().sum();
1832    let consumed: u64 = counts[input_count + 1..].iter().copied().sum();
1833    Ok(RunStats {
1834        frames_emitted: emitted,
1835        frames_consumed: consumed,
1836        frames_dropped: 0,
1837        latency: LatencyReport::ZERO,
1838        allocation: None,
1839        clock_priority: ClockPriority::SystemFallback,
1840        base_time_ns: 0,
1841        coordinator_events: 0,
1842        per_element: crate::runtime::snapshot_all(
1843            &sink_probes.into_iter().map(Some).collect::<Vec<_>>(),
1844        ),
1845    })
1846}
1847
1848/// Drives `N sources → muxer → 1 sink` (M10 true fan-in). Unlike
1849/// [`run_fanin_sink`], a [`MultiInputElement`] muxer combines **all** inputs
1850/// into the output. Each input's packets are tagged with its index and merged
1851/// into one channel; a single muxer task drains it and calls
1852/// `mux.process(input, ..)` serially (so the muxer keeps `&mut` state without
1853/// aliasing). The output emits one `Eos` after every input has ended.
1854///
1855/// Negotiation is per-input: each source ↔ its muxer pad fixate independently;
1856/// the sink is configured against `mux.output_caps()`. A `ReFixate` anywhere
1857/// fails with `FixationFailed`.
1858///
1859/// When `clock` can sleep on a deadline ([`PipelineClock::as_ticker`], which every
1860/// [`AsyncClock`](crate::AsyncClock) answers), the muxer arm also gets a **deadline tick** (M880): a
1861/// muxer declaring a [`MultiInputElement::tick_interval_ns`] receives
1862/// [`PipelinePacket::Tick`] on that period even while its inputs are silent, so a
1863/// compositor can keep emitting at its output rate when a pad stalls
1864/// (zero-order-hold on that pad's last frame) instead of freezing with it.
1865pub async fn run_muxer_sink<Mux, Snk, Clk>(
1866    sources: Vec<&mut dyn DynSourceLoop>,
1867    mux: &mut Mux,
1868    sink: &mut Snk,
1869    clock: &Clk,
1870    link_capacity: impl Into<LinkCapacity>,
1871) -> Result<RunStats, G2gError>
1872where
1873    Mux: MultiInputElement,
1874    Snk: AsyncElement,
1875    Clk: PipelineClock,
1876{
1877    run_muxer_sink_inner(sources, mux, sink, clock, link_capacity, None, None).await
1878}
1879
1880/// As [`run_muxer_sink`], but posts a structured
1881/// [`BusMessage::NegotiationFailed`](crate::BusMessage::NegotiationFailed) to
1882/// `bus` on a startup or per-input mid-stream negotiation failure (item 7).
1883pub async fn run_muxer_sink_with_bus<Mux, Snk, Clk>(
1884    sources: Vec<&mut dyn DynSourceLoop>,
1885    mux: &mut Mux,
1886    sink: &mut Snk,
1887    clock: &Clk,
1888    link_capacity: impl Into<LinkCapacity>,
1889    bus: &BusHandle,
1890) -> Result<RunStats, G2gError>
1891where
1892    Mux: MultiInputElement,
1893    Snk: AsyncElement,
1894    Clk: PipelineClock,
1895{
1896    run_muxer_sink_inner(sources, mux, sink, clock, link_capacity, Some(bus), None).await
1897}
1898
1899#[allow(clippy::too_many_arguments)]
1900async fn run_muxer_sink_inner<Mux, Snk, Clk>(
1901    sources: Vec<&mut dyn DynSourceLoop>,
1902    mux: &mut Mux,
1903    sink: &mut Snk,
1904    clock: &Clk,
1905    link_capacity: impl Into<LinkCapacity>,
1906    bus: Option<&BusHandle>,
1907    ticker: Option<&dyn DynAsyncClock>,
1908) -> Result<RunStats, G2gError>
1909where
1910    Mux: MultiInputElement,
1911    Snk: AsyncElement,
1912    Clk: PipelineClock,
1913{
1914    let n = sources.len();
1915    assert!(n > 0, "muxer needs at least one source");
1916    assert!(
1917        mux.input_count() == n,
1918        "muxer input count must match the number of sources"
1919    );
1920
1921    // D5: thin builder over the DAG runner. The muxer maps onto the graph's
1922    // fan-in node; `run_graph` owns negotiation, the per-input forwarders, the
1923    // single merged Eos, and the MX-1 / MX-2 mid-stream re-solve.
1924    let mut g: Graph<GraphNodeRef<'_>> = Graph::new();
1925    let mux_node = g.add_muxer(GraphNodeRef::muxer_ref(mux), n as u8);
1926    let snk = g.add_sink(GraphNodeRef::element_ref(sink));
1927    for (i, source) in sources.into_iter().enumerate() {
1928        let s = g.add_source(GraphNodeRef::source_ref(source));
1929        g.link(s, mux_node.input(i as u8))
1930            .map_err(|_| G2gError::CapsMismatch)?;
1931    }
1932    g.link(mux_node.output(), snk)
1933        .map_err(|_| G2gError::CapsMismatch)?;
1934
1935    run_graph_inner(
1936        g,
1937        clock,
1938        link_capacity,
1939        bus,
1940        None,
1941        None,
1942        None,
1943        None,
1944        None,
1945        ticker,
1946    )
1947    .await
1948}
1949
1950/// Which arm of a dynamic fan-in ([`run_aggregator_dynamic`]) produced this
1951/// result. The arm set grows at runtime, so indices are not stable; identity is
1952/// carried in the variant instead (the [`DynamicJoin`](crate::runtime) contract).
1953#[derive(Debug, Clone, Copy)]
1954enum FaninArmOut {
1955    /// The aggregator arm consumed this many `DataFrame`s.
1956    Aggregator(u64),
1957    /// A runtime-attached input source emitted this many `DataFrame`s.
1958    Source(u64),
1959    /// The trailing sink arm ([`run_muxer_sink_dynamic`]) consumed this many
1960    /// merged `DataFrame`s.
1961    Sink(u64),
1962}
1963
1964/// Node id of a dynamic fan-in's aggregator / muxer in its telemetry topology.
1965/// Runtime-attached inputs append after the fixed stages.
1966#[cfg(feature = "std")]
1967const DYN_FANIN_NODE: usize = 0;
1968
1969/// Node id of the trailing sink in [`run_muxer_sink_dynamic`]'s topology.
1970#[cfg(feature = "std")]
1971const DYN_FANIN_SINK_NODE: usize = 1;
1972
1973/// Telemetry bookkeeping the aggregator / muxer arm of a dynamic fan-in carries,
1974/// so an input attached mid-run gets named and put in the observer's topology
1975/// before its first frame (M869). The runtime inputs are sources, so they carry
1976/// no `process()` probe; their telemetry is the per-input edge counters the
1977/// [`TaggingSink`] advances.
1978#[cfg(feature = "std")]
1979#[derive(Debug)]
1980struct FaninTap {
1981    obs: Option<Observer>,
1982    namer: crate::log::InstanceNamer,
1983}
1984
1985/// The log category a refused runtime input is reported on, so
1986/// `G2G_DEBUG=fanin:debug` follows request-pad decisions independently of element
1987/// logging (as [`CAPS_CATEGORY`](crate::log::CAPS_CATEGORY) does for the solver).
1988#[cfg(feature = "std")]
1989const FANIN_CATEGORY: &str = "fanin";
1990
1991/// One runtime input-add in flight: the source, the pad reserved for it, and the
1992/// channel the fan-in arm answers on once it has negotiated the input.
1993#[cfg(feature = "std")]
1994struct InputRequest<'a> {
1995    pad: usize,
1996    source: Box<dyn DynSourceLoop + 'a>,
1997    verdict: Sender<Result<(), G2gError>>,
1998}
1999
2000/// The pending outcome of a [`DynamicFaninHandle::add_input`] (M975). The request
2001/// is queued; whether the element takes the input is only known once its arm has
2002/// negotiated it, so [`accepted`](Self::accepted) resolves to the verdict (drive
2003/// the run future while awaiting it, since that arm is what answers). Dropping
2004/// this is fire-and-forget: a refused input is then only logged.
2005#[cfg(feature = "std")]
2006#[derive(Debug)]
2007pub struct PendingInput {
2008    pad: usize,
2009    verdict: Receiver<Result<(), G2gError>>,
2010}
2011
2012#[cfg(feature = "std")]
2013impl PendingInput {
2014    /// The input pad this request reserved.
2015    pub fn pad(&self) -> usize {
2016        self.pad
2017    }
2018
2019    /// Resolve once the element has accepted or refused the input:
2020    /// [`G2gError::InputRefused`] if the element declined the pad,
2021    /// [`G2gError::CapsMismatch`] if the pad does not accept the source's caps,
2022    /// [`G2gError::Shutdown`] if the run ended before the request was handled.
2023    pub async fn accepted(self) -> Result<(), G2gError> {
2024        self.verdict.recv().await.unwrap_or(Err(G2gError::Shutdown))
2025    }
2026}
2027
2028/// A handle to add inputs to a *running* dynamic aggregator (M320): the fan-in
2029/// dual of [`DynamicFanoutHandle`](crate::runtime::DynamicFanoutHandle), the
2030/// runtime equivalent of GStreamer's aggregator/muxer request **sink** pads. Each
2031/// [`add_input`](Self::add_input) attaches a new source feeding the next free
2032/// input pad of the aggregator; the source is fixated and its pad configured on
2033/// attach, then its frames are tagged with the pad index and aggregated. Cheap to
2034/// clone (a channel sender plus an atomic), so several controllers can request
2035/// pads.
2036///
2037/// `'a` is the run's lifetime: the handle is used concurrently with the run
2038/// future and must be dropped no later than it. The aggregator declares a fixed
2039/// pad capacity ([`MultiInputElement::input_count`]); [`add_input`](Self::add_input)
2040/// past that capacity, or after the run has finished, is rejected with
2041/// [`G2gError::Shutdown`].
2042#[derive(Clone)]
2043#[allow(missing_debug_implementations)]
2044pub struct DynamicFaninHandle<'a> {
2045    new_input_tx: Sender<InputRequest<'a>>,
2046    /// Next free input pad index, reserved atomically so concurrent callers get
2047    /// distinct pads. The aggregator's `process(pad, ..)` indexes a fixed pad set,
2048    /// so a pad is only handed out while `< max_inputs`.
2049    next_pad: Arc<AtomicUsize>,
2050    max_inputs: usize,
2051}
2052
2053#[cfg(feature = "std")]
2054impl<'a> DynamicFaninHandle<'a> {
2055    /// Request a new sink pad: attach `source` as a new input of the running
2056    /// aggregator. Reserves the next pad index atomically and hands the source to
2057    /// the aggregator arm, which fixates it, asks the element to
2058    /// [accept it](MultiInputElement::accepts_runtime_input), and configures the
2059    /// pad before its first frame. That answer arrives on the returned
2060    /// [`PendingInput`]; a refused input costs its reserved pad but leaves the run
2061    /// on its existing inputs.
2062    ///
2063    /// Returns [`G2gError::Shutdown`] if every declared pad is already in use or
2064    /// the aggregator has already finished, and [`G2gError::PoolExhausted`] if the
2065    /// add channel is transiently full (the aggregator has not drained pending
2066    /// adds yet); retry the latter.
2067    pub fn add_input(&self, source: Box<dyn DynSourceLoop + 'a>) -> Result<PendingInput, G2gError> {
2068        // Reserve a pad. fetch_add can overshoot past capacity under contention,
2069        // but that only makes later calls also see `>= max_inputs` and fail, which
2070        // is the intended "no free pad" outcome.
2071        let pad = self.next_pad.fetch_add(1, Ordering::SeqCst);
2072        if pad >= self.max_inputs {
2073            return Err(G2gError::Shutdown);
2074        }
2075        let (verdict_tx, verdict_rx) = bounded::<Result<(), G2gError>>(1);
2076        match self.new_input_tx.try_send(InputRequest {
2077            pad,
2078            source,
2079            verdict: verdict_tx,
2080        }) {
2081            Ok(()) => Ok(PendingInput {
2082                pad,
2083                verdict: verdict_rx,
2084            }),
2085            Err((_, SendError::Closed)) => Err(G2gError::Shutdown),
2086            Err((_, SendError::Full)) => {
2087                // Transient backpressure, not a teardown. Roll back the pad we
2088                // reserved (only when no later add claimed one) so a retry does
2089                // not permanently shrink the usable pad count.
2090                let _ = self.next_pad.compare_exchange(
2091                    pad + 1,
2092                    pad,
2093                    Ordering::SeqCst,
2094                    Ordering::SeqCst,
2095                );
2096                Err(G2gError::PoolExhausted)
2097            }
2098        }
2099    }
2100}
2101
2102/// Drives `N sources -> dynamic aggregator` (M320 fan-in request pads), where
2103/// inputs can be added at runtime through the returned [`DynamicFaninHandle`].
2104/// The fan-in dual of
2105/// [`run_source_tee_dynamic`](crate::runtime::run_source_tee_dynamic): there
2106/// branches attach to a running source, here sources attach to a running
2107/// aggregator.
2108///
2109/// The aggregator is **terminal** (it consumes its inputs and produces no merged
2110/// downstream output, like [`run_fanin_session`]): a multi-stream batching sink,
2111/// a compositor-to-display, a WebRTC publisher. A source attaches via
2112/// [`DynamicFaninHandle::add_input`]; on attach the source is narrowed against
2113/// the aggregator input pad it feeds and the resulting caps configure both, so a
2114/// late input is negotiated without a global re-solve. Each input's packets
2115/// are tagged with its pad index and drained by a single aggregator arm that owns
2116/// `&mut aggregator`, so the aggregator keeps its state without aliasing. A
2117/// per-input `Eos` is delivered to the aggregator (so it can flush that pad's
2118/// state); the run completes once the handle is dropped **and** every attached
2119/// input has ended.
2120///
2121/// Returns the handle plus the run future; drive them concurrently. A merged
2122/// downstream output (the [`run_muxer_sink`] shape, with a trailing sink and
2123/// output-caps coupling) is a follow-up.
2124#[cfg(feature = "std")]
2125pub fn run_aggregator_dynamic<'a, Agg>(
2126    aggregator: &'a mut Agg,
2127    link_capacity: impl Into<LinkCapacity>,
2128) -> (
2129    DynamicFaninHandle<'a>,
2130    impl Future<Output = Result<RunStats, G2gError>> + 'a,
2131)
2132where
2133    Agg: MultiInputElement + 'a,
2134{
2135    run_aggregator_dynamic_inner(aggregator, link_capacity, None)
2136}
2137
2138/// As [`run_aggregator_dynamic`], but taps live telemetry into `observer` (M869).
2139/// The topology starts as the aggregator alone; each input attached through the
2140/// handle appends its own node and link before its first frame, so a dashboard
2141/// polling [`Observer::snapshot`] sees runtime inputs appear with their per-input
2142/// packet / byte counters beside the aggregator's measured `process()` latency.
2143#[cfg(feature = "std")]
2144pub fn run_aggregator_dynamic_observed<'a, Agg>(
2145    aggregator: &'a mut Agg,
2146    link_capacity: impl Into<LinkCapacity>,
2147    observer: &Observer,
2148) -> (
2149    DynamicFaninHandle<'a>,
2150    impl Future<Output = Result<RunStats, G2gError>> + 'a,
2151)
2152where
2153    Agg: MultiInputElement + 'a,
2154{
2155    run_aggregator_dynamic_inner(aggregator, link_capacity, Some(observer.clone()))
2156}
2157
2158#[cfg(feature = "std")]
2159fn run_aggregator_dynamic_inner<'a, Agg>(
2160    aggregator: &'a mut Agg,
2161    link_capacity: impl Into<LinkCapacity>,
2162    observer: Option<Observer>,
2163) -> (
2164    DynamicFaninHandle<'a>,
2165    impl Future<Output = Result<RunStats, G2gError>> + 'a,
2166)
2167where
2168    Agg: MultiInputElement + 'a,
2169{
2170    let link_capacity: usize = link_capacity.into().get();
2171    let max_inputs = aggregator.input_count();
2172
2173    // Control channel: handle -> aggregator arm (new (pad, source) inputs).
2174    let (new_input_tx, new_input_rx) = bounded::<InputRequest<'a>>(link_capacity);
2175    // Arm channel: aggregator arm -> join (the attached source-run futures).
2176    let (new_arm_tx, new_arm_rx) =
2177        bounded::<BoxFuture<'a, Result<FaninArmOut, G2gError>>>(link_capacity);
2178
2179    let handle = DynamicFaninHandle {
2180        new_input_tx,
2181        next_pad: Arc::new(AtomicUsize::new(0)),
2182        max_inputs,
2183    };
2184
2185    let run = async move {
2186        // One shared tagged channel: every attached source pushes `(pad, packet)`.
2187        let (tagged_tx, tagged_rx) = bounded::<(usize, PipelinePacket)>(link_capacity);
2188
2189        // M869: instance naming + the aggregator's measured-latency probe, as in
2190        // `run_fanin_session`. Attached inputs are named off the same namer.
2191        let mut namer = crate::log::InstanceNamer::new();
2192        let agg_probe = ElementProbe::new(namer.add(crate::log::short_type_name::<Agg>(), None));
2193        if let Some(obs) = &observer {
2194            register_runner_tap(
2195                obs,
2196                alloc::vec![(
2197                    alloc::string::String::from(agg_probe.name()),
2198                    NodeRole::Muxer,
2199                    Some(agg_probe.clone()),
2200                )],
2201                Vec::new(),
2202            );
2203        }
2204        let mut tap = FaninTap {
2205            obs: observer,
2206            namer,
2207        };
2208        let probe_for_agg = agg_probe.clone();
2209
2210        let aggregator_arm: BoxFuture<'a, Result<FaninArmOut, G2gError>> = Box::pin(async move {
2211            // Coerce once so configure / process go through the boxed-future Dyn
2212            // surface; `Agg: MultiInputElement` implies `DynMultiInputElement`.
2213            let aggregator: &mut dyn DynMultiInputElement = aggregator;
2214            let mut null = NullSink;
2215            let mut consumed = 0u64;
2216            let mut accepting = true;
2217            // Hold one tagged sender open while we still accept inputs, so the
2218            // tagged channel does not close (and end the run) before any source
2219            // attaches. Dropped the moment the handle goes away.
2220            let mut keepalive: Option<Sender<(usize, PipelinePacket)>> = Some(tagged_tx);
2221            loop {
2222                // Attach every input queued so far BEFORE draining the next packet,
2223                // so an input requested before a frame is never missed (select2
2224                // below is left-biased toward the data channel; mirrors the M310
2225                // fan-out drain-first gotcha).
2226                while let Some(request) = new_input_rx.try_recv() {
2227                    let tx = keepalive.as_ref().expect("keepalive held while accepting");
2228                    attach_input(request, aggregator, tx, &new_arm_tx, &mut tap).await?;
2229                }
2230
2231                if accepting {
2232                    match select2(tagged_rx.recv(), new_input_rx.recv()).await {
2233                        Either::Left(Some((pad, PipelinePacket::Eos))) => {
2234                            // Per-input end: let the aggregator flush that pad. It
2235                            // must not forward Eos; the run owns the end.
2236                            aggregator
2237                                .process(pad, PipelinePacket::Eos, &mut null)
2238                                .await?;
2239                        }
2240                        Either::Left(Some((pad, packet))) => {
2241                            let is_data = matches!(packet, PipelinePacket::DataFrame(_));
2242                            if is_data {
2243                                consumed += 1;
2244                                probe_for_agg.record_fill(tagged_rx.fill_percent());
2245                            }
2246                            let t0 = is_data.then(ElementProbe::mark).flatten();
2247                            aggregator.process(pad, packet, &mut null).await?;
2248                            if is_data {
2249                                probe_for_agg.record_proc_since(t0);
2250                            }
2251                        }
2252                        // Unreachable while `keepalive` is held (a live sender keeps
2253                        // the channel open), but folded into the end path for safety.
2254                        Either::Left(None) => return Ok(FaninArmOut::Aggregator(consumed)),
2255                        Either::Right(Some(request)) => {
2256                            let tx = keepalive.as_ref().expect("keepalive held while accepting");
2257                            attach_input(request, aggregator, tx, &new_arm_tx, &mut tap).await?;
2258                        }
2259                        // Handle dropped: stop accepting and release the keepalive
2260                        // so the tagged channel can close once every attached input
2261                        // has ended.
2262                        Either::Right(None) => {
2263                            accepting = false;
2264                            keepalive = None;
2265                        }
2266                    }
2267                } else {
2268                    match tagged_rx.recv().await {
2269                        Some((pad, PipelinePacket::Eos)) => {
2270                            aggregator
2271                                .process(pad, PipelinePacket::Eos, &mut null)
2272                                .await?;
2273                        }
2274                        Some((pad, packet)) => {
2275                            let is_data = matches!(packet, PipelinePacket::DataFrame(_));
2276                            if is_data {
2277                                consumed += 1;
2278                                probe_for_agg.record_fill(tagged_rx.fill_percent());
2279                            }
2280                            let t0 = is_data.then(ElementProbe::mark).flatten();
2281                            aggregator.process(pad, packet, &mut null).await?;
2282                            if is_data {
2283                                probe_for_agg.record_proc_since(t0);
2284                            }
2285                        }
2286                        None => return Ok(FaninArmOut::Aggregator(consumed)),
2287                    }
2288                }
2289            }
2290        });
2291
2292        // The aggregator arm owns `new_arm_tx`; when it returns (run end) the arm
2293        // channel closes and the dynamic join can finish.
2294        let arms: Vec<BoxFuture<'a, Result<FaninArmOut, G2gError>>> = alloc::vec![aggregator_arm];
2295        let results = dynamic_join(arms, new_arm_rx).await;
2296
2297        let mut emitted = 0u64;
2298        let mut consumed = 0u64;
2299        for r in results {
2300            match r? {
2301                FaninArmOut::Source(n) => emitted += n,
2302                FaninArmOut::Aggregator(n) => consumed = n,
2303                // The terminal aggregator has no trailing sink arm.
2304                FaninArmOut::Sink(_) => {}
2305            }
2306        }
2307        Ok(RunStats {
2308            frames_emitted: emitted,
2309            frames_consumed: consumed,
2310            frames_dropped: 0,
2311            latency: LatencyReport::ZERO,
2312            allocation: None,
2313            clock_priority: ClockPriority::SystemFallback,
2314            base_time_ns: 0,
2315            coordinator_events: 0,
2316            per_element: alloc::vec![agg_probe.snapshot()],
2317        })
2318    };
2319
2320    (handle, run)
2321}
2322
2323/// Like [`run_aggregator_dynamic`], but with a trailing **sink**: the muxer's
2324/// merged output flows to `sink`, the [`run_muxer_sink`] shape extended to
2325/// runtime-added inputs (dynamically attach a late audio track to a running
2326/// `muxer ! filesink`, say). Inputs are added through the returned
2327/// [`DynamicFaninHandle`] exactly as for the terminal aggregator; the difference
2328/// is the merged output is not discarded.
2329///
2330/// The muxer's output caps are coupled to the sink without a global re-solve:
2331/// because inputs attach one at a time, the merged output (`output_caps`) only
2332/// firms up as pads are configured, so the muxer arm emits a `CapsChanged` to the
2333/// sink whenever the derived output changes (the dynamic analog of the static
2334/// `run_muxer_sink` MX-2 coupling), and the sink configures against it before the
2335/// first merged frame. When every input has ended and the handle is dropped, the
2336/// muxer arm closes the merged link with `Eos`, ending the sink arm.
2337/// `RunStats::frames_consumed` is the sink's merged-frame count.
2338#[cfg(feature = "std")]
2339pub fn run_muxer_sink_dynamic<'a, Mux, Snk>(
2340    mux: &'a mut Mux,
2341    sink: &'a mut Snk,
2342    link_capacity: impl Into<LinkCapacity>,
2343) -> (
2344    DynamicFaninHandle<'a>,
2345    impl Future<Output = Result<RunStats, G2gError>> + 'a,
2346)
2347where
2348    Mux: MultiInputElement + 'a,
2349    Snk: AsyncElement + 'a,
2350{
2351    run_muxer_sink_dynamic_inner(mux, sink, link_capacity, None)
2352}
2353
2354/// As [`run_muxer_sink_dynamic`], but taps live telemetry into `observer` (M869).
2355/// The muxer and the sink are registered up front with their measured-latency
2356/// probes; each runtime input appends its node and link on attach, and the merged
2357/// `muxer -> sink` link joins the topology once its caps firm up (they only exist
2358/// after a pad is configured).
2359#[cfg(feature = "std")]
2360pub fn run_muxer_sink_dynamic_observed<'a, Mux, Snk>(
2361    mux: &'a mut Mux,
2362    sink: &'a mut Snk,
2363    link_capacity: impl Into<LinkCapacity>,
2364    observer: &Observer,
2365) -> (
2366    DynamicFaninHandle<'a>,
2367    impl Future<Output = Result<RunStats, G2gError>> + 'a,
2368)
2369where
2370    Mux: MultiInputElement + 'a,
2371    Snk: AsyncElement + 'a,
2372{
2373    run_muxer_sink_dynamic_inner(mux, sink, link_capacity, Some(observer.clone()))
2374}
2375
2376#[cfg(feature = "std")]
2377fn run_muxer_sink_dynamic_inner<'a, Mux, Snk>(
2378    mux: &'a mut Mux,
2379    sink: &'a mut Snk,
2380    link_capacity: impl Into<LinkCapacity>,
2381    observer: Option<Observer>,
2382) -> (
2383    DynamicFaninHandle<'a>,
2384    impl Future<Output = Result<RunStats, G2gError>> + 'a,
2385)
2386where
2387    Mux: MultiInputElement + 'a,
2388    Snk: AsyncElement + 'a,
2389{
2390    let link_capacity: usize = link_capacity.into().get();
2391    let max_inputs = mux.input_count();
2392
2393    let (new_input_tx, new_input_rx) = bounded::<InputRequest<'a>>(link_capacity);
2394    let (new_arm_tx, new_arm_rx) =
2395        bounded::<BoxFuture<'a, Result<FaninArmOut, G2gError>>>(link_capacity);
2396
2397    let handle = DynamicFaninHandle {
2398        new_input_tx,
2399        next_pad: Arc::new(AtomicUsize::new(0)),
2400        max_inputs,
2401    };
2402
2403    let run = async move {
2404        let (tagged_tx, tagged_rx) = bounded::<(usize, PipelinePacket)>(link_capacity);
2405        // Merged-output link: muxer arm -> sink arm.
2406        let (out_tx, out_rx, out_tap) = link_tapped(link_capacity, observer.is_some());
2407
2408        // M869: instance naming + a probe per stage with a `process()`, as in the
2409        // static `run_muxer_sink`. Attached inputs are named off the same namer.
2410        let mut namer = crate::log::InstanceNamer::new();
2411        let mux_probe = ElementProbe::new(namer.add(crate::log::short_type_name::<Mux>(), None));
2412        let sink_name = namer.add(crate::log::short_type_name::<Snk>(), None);
2413        AsyncElement::set_instance_name(sink, sink_name.clone());
2414        let sink_probe = ElementProbe::new(sink_name.clone());
2415        if let Some(obs) = &observer {
2416            // The merged link's caps do not exist until a pad is configured, so
2417            // only the two nodes are registered here; the edge follows.
2418            register_runner_tap(
2419                obs,
2420                alloc::vec![
2421                    (
2422                        alloc::string::String::from(mux_probe.name()),
2423                        NodeRole::Muxer,
2424                        Some(mux_probe.clone()),
2425                    ),
2426                    (sink_name, NodeRole::Sink, Some(sink_probe.clone())),
2427                ],
2428                Vec::new(),
2429            );
2430        }
2431        let mut tap = FaninTap {
2432            obs: observer,
2433            namer,
2434        };
2435        let probe_for_mux = mux_probe.clone();
2436        let probe_for_sink = sink_probe.clone();
2437
2438        // Sink arm: configure on the muxer's output `CapsChanged`, then process
2439        // merged frames until the muxer arm closes the link (Eos / drop).
2440        let sink_arm: BoxFuture<'a, Result<FaninArmOut, G2gError>> = Box::pin(async move {
2441            let sink: &mut dyn DynAsyncElement = sink;
2442            let mut null = NullSink;
2443            let mut consumed = 0u64;
2444            while let Some(pkt) = out_rx.recv().await {
2445                match pkt {
2446                    PipelinePacket::CapsChanged(caps) => {
2447                        log_caps_rejected(
2448                            Some(probe_for_sink.name()),
2449                            &caps,
2450                            sink.configure_pipeline(&caps),
2451                        )?
2452                        .reject_refixate()?;
2453                        sink.process(PipelinePacket::CapsChanged(caps), &mut null)
2454                            .await?;
2455                    }
2456                    PipelinePacket::Eos => break,
2457                    other => {
2458                        let is_data = matches!(other, PipelinePacket::DataFrame(_));
2459                        if is_data {
2460                            consumed += 1;
2461                            probe_for_sink.record_fill(out_rx.fill_percent());
2462                        }
2463                        let t0 = is_data.then(ElementProbe::mark).flatten();
2464                        sink.process(other, &mut null).await?;
2465                        if is_data {
2466                            probe_for_sink.record_proc_since(t0);
2467                        }
2468                    }
2469                }
2470            }
2471            Ok(FaninArmOut::Sink(consumed))
2472        });
2473
2474        let muxer_arm: BoxFuture<'a, Result<FaninArmOut, G2gError>> = Box::pin(async move {
2475            let mux: &mut dyn DynMultiInputElement = mux;
2476            let mut out = SenderSink::new(out_tx);
2477            // M947: downstream backpressure is push-wait, not muxing work.
2478            out.set_push_wait_probe(Some(probe_for_mux.clone()));
2479            let mut merged_tap = Some(out_tap);
2480            let mut current_output: Option<Caps> = None;
2481            let mut consumed = 0u64;
2482            let mut accepting = true;
2483            let mut keepalive: Option<Sender<(usize, PipelinePacket)>> = Some(tagged_tx);
2484            loop {
2485                while let Some(request) = new_input_rx.try_recv() {
2486                    let tx = keepalive.as_ref().expect("keepalive held while accepting");
2487                    attach_input(request, mux, tx, &new_arm_tx, &mut tap).await?;
2488                }
2489
2490                let next = if accepting {
2491                    match select2(tagged_rx.recv(), new_input_rx.recv()).await {
2492                        Either::Left(packet) => packet,
2493                        Either::Right(Some(request)) => {
2494                            let tx = keepalive.as_ref().expect("keepalive held while accepting");
2495                            attach_input(request, mux, tx, &new_arm_tx, &mut tap).await?;
2496                            continue;
2497                        }
2498                        Either::Right(None) => {
2499                            // Handle dropped: stop accepting, release the keepalive
2500                            // so the tagged channel closes once inputs end.
2501                            accepting = false;
2502                            keepalive = None;
2503                            continue;
2504                        }
2505                    }
2506                } else {
2507                    tagged_rx.recv().await
2508                };
2509
2510                match next {
2511                    Some((pad, PipelinePacket::Eos)) => {
2512                        // Per-input end: let the muxer flush that pad. It must not
2513                        // forward Eos; this runner owns the merged end.
2514                        mux.process(pad, PipelinePacket::Eos, &mut out).await?;
2515                    }
2516                    Some((pad, packet)) => {
2517                        let is_data = matches!(packet, PipelinePacket::DataFrame(_));
2518                        if is_data {
2519                            // Couple the merged output to the sink: emit one
2520                            // `CapsChanged` whenever the derived output firms up or
2521                            // shifts (a newly attached pad can change it), before
2522                            // the frame it qualifies.
2523                            if let Ok(oc) = mux.output_caps() {
2524                                if current_output.as_ref() != Some(&oc) {
2525                                    if let Some(obs) = &tap.obs {
2526                                        // First solved output: the merged edge can
2527                                        // now be registered with real caps.
2528                                        if let Some(t) = merged_tap.take() {
2529                                            obs.add_edge(
2530                                                DYN_FANIN_NODE,
2531                                                DYN_FANIN_SINK_NODE,
2532                                                oc.clone(),
2533                                                t,
2534                                            );
2535                                        }
2536                                    }
2537                                    out.push(PipelinePacket::CapsChanged(oc.clone())).await?;
2538                                    current_output = Some(oc);
2539                                }
2540                            }
2541                            consumed += 1;
2542                            probe_for_mux.record_fill(tagged_rx.fill_percent());
2543                        }
2544                        let t0 = is_data.then(ElementProbe::mark).flatten();
2545                        mux.process(pad, packet, &mut out).await?;
2546                        if is_data {
2547                            probe_for_mux.record_proc_since(t0);
2548                        }
2549                    }
2550                    None => break,
2551                }
2552            }
2553            // Close the merged link so the sink arm ends.
2554            out.push(PipelinePacket::Eos).await?;
2555            Ok(FaninArmOut::Aggregator(consumed))
2556        });
2557
2558        let arms: Vec<BoxFuture<'a, Result<FaninArmOut, G2gError>>> =
2559            alloc::vec![muxer_arm, sink_arm];
2560        let results = dynamic_join(arms, new_arm_rx).await;
2561
2562        let mut emitted = 0u64;
2563        let mut consumed = 0u64;
2564        for r in results {
2565            match r? {
2566                FaninArmOut::Source(n) => emitted += n,
2567                FaninArmOut::Sink(n) => consumed = n,
2568                FaninArmOut::Aggregator(_) => {}
2569            }
2570        }
2571        Ok(RunStats {
2572            frames_emitted: emitted,
2573            frames_consumed: consumed,
2574            frames_dropped: 0,
2575            latency: LatencyReport::ZERO,
2576            allocation: None,
2577            clock_priority: ClockPriority::SystemFallback,
2578            base_time_ns: 0,
2579            coordinator_events: 0,
2580            per_element: alloc::vec![mux_probe.snapshot(), sink_probe.snapshot()],
2581        })
2582    };
2583
2584    (handle, run)
2585}
2586
2587/// Negotiate a runtime-requested input before any of its frames can flow (M975):
2588/// fixate the source against the pad it would feed, hold the pad's own constraint
2589/// as the last word on those caps, ask the element to accept the input, then
2590/// configure both. Every failure here is the *add's*, not the run's.
2591#[cfg(feature = "std")]
2592async fn negotiate_new_input(
2593    pad: usize,
2594    source: &mut dyn DynSourceLoop,
2595    aggregator: &mut dyn DynMultiInputElement,
2596) -> Result<Caps, G2gError> {
2597    let produced = source.produced_caps().await?;
2598    let fixated = {
2599        let pad_constraint = aggregator.caps_constraint_as_input(pad);
2600        let fixated = select_branch_caps(&produced, &pad_constraint)?;
2601        // select_branch_caps falls back to the source's own caps when the pad
2602        // accepts none of its alternatives, so check the pad again.
2603        if !pad_constraint.accepts(&fixated) {
2604            return Err(G2gError::CapsMismatch);
2605        }
2606        fixated
2607    };
2608    if !aggregator.accepts_runtime_input(pad, &fixated) {
2609        return Err(G2gError::InputRefused);
2610    }
2611    source.configure_pipeline(&fixated)?.reject_refixate()?;
2612    aggregator
2613        .configure_pipeline(pad, &fixated)?
2614        .reject_refixate()?;
2615    Ok(fixated)
2616}
2617
2618/// Attach a runtime-requested input: negotiate it, then hand the source's run
2619/// loop (feeding a [`TaggingSink`] tagged with `pad`) to the dynamic join. Mirrors
2620/// [`run_source_router_dynamic`](crate::runtime::run_source_router_dynamic)'s
2621/// `attach_branch`, transposed to the input side.
2622///
2623/// A refused or unnegotiable input answers the requester and leaves the run alone
2624/// (`Ok`); only losing the arm channel, which would strand an accepted input, ends
2625/// the run.
2626#[cfg(feature = "std")]
2627async fn attach_input<'a>(
2628    request: InputRequest<'a>,
2629    aggregator: &mut dyn DynMultiInputElement,
2630    tagged_tx: &Sender<(usize, PipelinePacket)>,
2631    new_arm_tx: &Sender<BoxFuture<'a, Result<FaninArmOut, G2gError>>>,
2632    tap: &mut FaninTap,
2633) -> Result<(), G2gError> {
2634    let InputRequest {
2635        pad,
2636        mut source,
2637        verdict,
2638    } = request;
2639    let fixated = match negotiate_new_input(pad, source.as_mut(), aggregator).await {
2640        Ok(caps) => caps,
2641        Err(e) => {
2642            crate::g2g_error!(
2643                crate::log::Target::category(FANIN_CATEGORY),
2644                "runtime input on pad {pad} rejected: {e:?}"
2645            );
2646            let _ = verdict.try_send(Err(e));
2647            return Ok(());
2648        }
2649    };
2650
2651    // Named like an input the static fan-in was built with, and registered before
2652    // the arm runs, so the topology holds it before its first frame. The tagged
2653    // channel is shared by every input, so this input's counters and inspect slot
2654    // ride its `TaggingSink`.
2655    let name = tap.namer.add(source.log_category(), None);
2656    source.set_instance_name(name.clone());
2657    let (counters, slot) = match &tap.obs {
2658        Some(obs) => {
2659            let counters = Arc::new(EdgeCounters::default());
2660            let slot = ProbeSlot::default();
2661            let id = obs.add_node(name, NodeRole::Source, None);
2662            obs.add_edge(
2663                id,
2664                DYN_FANIN_NODE,
2665                fixated,
2666                EdgeTap {
2667                    probe: slot.clone(),
2668                    counters: Some(counters.clone()),
2669                },
2670            );
2671            (Some(counters), Some(slot))
2672        }
2673        None => (None, None),
2674    };
2675
2676    let tx = tagged_tx.clone();
2677    let arm: BoxFuture<'a, Result<FaninArmOut, G2gError>> = Box::pin(async move {
2678        let mut sink = TaggingSink::new(pad, tx, None, counters, slot);
2679        let mut source = source;
2680        let result = source.run(&mut sink).await;
2681        wind_down_when_session_ended(result, &sink).map(FaninArmOut::Source)
2682    });
2683    new_arm_tx.try_send(arm).map_err(|_| G2gError::Shutdown)?;
2684    let _ = verdict.try_send(Ok(()));
2685    Ok(())
2686}
2687
2688/// The log category a dynamic duplex run reports pad growth on, so
2689/// `G2G_DEBUG=duplex:debug` follows runtime tracks and ports independently of
2690/// element logging (as [`FANIN_CATEGORY`] does for request sink pads).
2691#[cfg(feature = "std")]
2692const DUPLEX_CATEGORY: &str = "duplex";
2693
2694/// Which arm of a dynamic duplex run ([`run_duplex_session_dynamic`]) produced
2695/// this result. The arm set grows at runtime, so identity rides in the variant
2696/// rather than in a position, as it does for the dynamic fan-in.
2697#[cfg(feature = "std")]
2698#[derive(Debug, Clone, Copy)]
2699enum DuplexArmOut {
2700    /// A send-side source emitted this many `DataFrame`s.
2701    Source(u64),
2702    /// The session arm ended. Its own received-frame count is not reported: as
2703    /// in the fixed runner, `frames_consumed` is what the recv sinks took.
2704    Session,
2705    /// A recv-side sink consumed this many `DataFrame`s.
2706    Sink(u64),
2707    /// A grown recv port the sink factory declined dropped this many `DataFrame`s.
2708    Dropped(u64),
2709    /// The control arm, which attaches runtime send tracks and recv ports.
2710    Control,
2711}
2712
2713/// One runtime send-track add in flight: the source and the input index reserved
2714/// for it. Unlike an [`InputRequest`] there is no verdict channel: the session
2715/// only learns of the pad from its first packet, so nothing can answer for it.
2716#[cfg(feature = "std")]
2717struct SendTrackRequest<'a> {
2718    input: usize,
2719    source: Box<dyn DynSourceLoop + 'a>,
2720}
2721
2722/// A recv port the session grew mid-run through [`MultiOutputSink::add_port`]:
2723/// the index it was given, the caps it carries, and the link the runner drains.
2724#[cfg(feature = "std")]
2725#[derive(Debug)]
2726struct GrownPort {
2727    port: usize,
2728    caps: Caps,
2729    rx: crate::runtime::channel::LinkReceiver,
2730    edge: EdgeTap,
2731}
2732
2733/// [`MultiOutputSink`] whose port set grows at runtime: `add_port` mints the
2734/// port's link, keeps the sending end, and hands the receiving end to the dynamic
2735/// duplex runner's control arm, which finds it a sink. The growable counterpart
2736/// of [`MultiSenderSink`], which it wraps for the fixed ports' push path.
2737#[cfg(feature = "std")]
2738#[derive(Debug)]
2739struct GrowableSenderSink {
2740    ports: MultiSenderSink,
2741    link_capacity: usize,
2742    /// Whether links carry observer taps, so a grown port is instrumented like a
2743    /// declared one.
2744    tap: bool,
2745    grown_tx: Sender<GrownPort>,
2746}
2747
2748#[cfg(feature = "std")]
2749impl MultiOutputSink for GrowableSenderSink {
2750    fn begin_push_to(&mut self, port: usize) {
2751        self.ports.begin_push_to(port);
2752    }
2753
2754    fn poll_push_to(
2755        &mut self,
2756        cx: &mut core::task::Context<'_>,
2757        port: usize,
2758        packet: &mut Option<PipelinePacket>,
2759    ) -> core::task::Poll<Result<PushOutcome, G2gError>> {
2760        self.ports.poll_push_to(cx, port, packet)
2761    }
2762
2763    fn port_count(&self) -> usize {
2764        self.ports.port_count()
2765    }
2766
2767    fn add_port(&mut self, caps: &Caps) -> Option<usize> {
2768        let port = self.ports.port_count();
2769        let (tx, rx, edge) = link_tapped(self.link_capacity, self.tap);
2770        let request = GrownPort {
2771            port,
2772            caps: caps.clone(),
2773            rx,
2774            edge,
2775        };
2776        // Non-blocking by contract: a full or closed control channel is a refusal,
2777        // so the session keeps whatever it does for a track it cannot place.
2778        match self.grown_tx.try_send(request) {
2779            Ok(()) => {
2780                self.ports.push_port(SenderSink::new(tx));
2781                Some(port)
2782            }
2783            Err(_) => None,
2784        }
2785    }
2786}
2787
2788/// Telemetry bookkeeping the control arm of a dynamic duplex run carries, so a
2789/// track or port attached mid-run is named and in the observer's topology before
2790/// its first packet (M869).
2791#[cfg(feature = "std")]
2792#[derive(Debug)]
2793struct DuplexTap {
2794    obs: Option<Observer>,
2795    namer: crate::log::InstanceNamer,
2796    /// Measured-latency probes of the recv sinks, seeded with the declared ones
2797    /// and appended to as ports grow, read back for [`RunStats::per_element`].
2798    probes: Arc<Mutex<Vec<Probe>>>,
2799    /// Node id of the session in the topology registered at startup.
2800    session_id: usize,
2801}
2802
2803/// A handle to add send-side tracks to a *running* duplex session (M1014): the
2804/// duplex analog of [`DynamicFaninHandle`], for the case the fixed-arity
2805/// [`run_duplex_session`] cannot serve, a track beyond the pads the session was
2806/// built with. Cheap to clone (a channel sender plus an atomic).
2807///
2808/// `'a` is the run's lifetime: the handle is used concurrently with the run
2809/// future and must be dropped no later than it. Dropping it is also what tells
2810/// the runner no more tracks are coming, so a run whose session ends when its
2811/// send side does only finishes once the handle is gone.
2812#[cfg(feature = "std")]
2813#[derive(Clone)]
2814#[allow(missing_debug_implementations)]
2815pub struct DynamicDuplexHandle<'a> {
2816    new_track_tx: Sender<SendTrackRequest<'a>>,
2817    /// Next free send input index. Reserving it and enqueueing the request happen
2818    /// under this one lock, so indices reach the runner in order: the session
2819    /// learns pad N before N+1, which its grow-on-first-sight path relies on. It
2820    /// starts past the sources the run was built with and has no ceiling: growing
2821    /// the pad count is the point of this runner.
2822    next_input: Arc<Mutex<usize>>,
2823}
2824
2825#[cfg(feature = "std")]
2826impl<'a> DynamicDuplexHandle<'a> {
2827    /// Attach `source` as a new send-side track of the running session, returning
2828    /// the input index its packets will be tagged with. The runner negotiates the
2829    /// source on its own (the session owns itself inside its run loop, so it
2830    /// cannot be consulted mid-run) and announces the fixated caps on that index
2831    /// before the source runs, which is how the session learns the pad exists.
2832    ///
2833    /// Unlike [`DynamicFaninHandle::add_input`] there is no verdict to await: a
2834    /// session that cannot map the caps to a track logs the refusal and drops that
2835    /// index's packets, so the answer is in the log and in whether media flows.
2836    ///
2837    /// Returns [`G2gError::Shutdown`] if the run has finished, and
2838    /// [`G2gError::PoolExhausted`] if the add channel is transiently full (the
2839    /// runner has not drained pending adds yet); retry the latter.
2840    pub fn add_send_track(&self, source: Box<dyn DynSourceLoop + 'a>) -> Result<usize, G2gError> {
2841        // Reserve and enqueue under one lock: a fetch_add-then-send pair lets two
2842        // callers enqueue out of order, and a session that learns index N+1 first
2843        // would treat a later N as an already-known pad and orphan it.
2844        let mut next_input = self.next_input.lock();
2845        let input = *next_input;
2846        match self
2847            .new_track_tx
2848            .try_send(SendTrackRequest { input, source })
2849        {
2850            Ok(()) => {
2851                *next_input = input + 1;
2852                Ok(input)
2853            }
2854            Err((_, SendError::Closed)) => Err(G2gError::Shutdown),
2855            // Transient backpressure, not a teardown. The index was never
2856            // claimed, so a retry reuses it and leaves no hole.
2857            Err((_, SendError::Full)) => Err(G2gError::PoolExhausted),
2858        }
2859    }
2860}
2861
2862/// Drives a terminal **duplex** session whose pad count grows at runtime (M1014),
2863/// the renegotiating counterpart of [`run_duplex_session`]: a sendrecv
2864/// PeerConnection that takes a new local track, or receives a new peer track,
2865/// with no pad reserved for it up front.
2866///
2867/// It starts exactly as the fixed runner does (negotiate the send sources against
2868/// their pads, configure the recv sinks against the session's output caps), and
2869/// adds the two growth paths:
2870///
2871/// - **Send side.** [`DynamicDuplexHandle::add_send_track`] attaches a source at
2872///   the next input index. The runner fixates it alone, registers a
2873///   [`ReverseChannel`](crate::fanout::ReverseChannel) the session reads back
2874///   through [`DuplexInbound::reverse_channel`], and its arm announces the caps on
2875///   the new index before the source runs, so the session sees the pad before any
2876///   of its frames.
2877/// - **Recv side.** The session calls [`MultiOutputSink::add_port`] when a peer
2878///   track has no free pad. The runner mints that port's link and asks
2879///   `sink_factory` for the element to drain it; the factory receives the port
2880///   index and the caps the session declared for it. A factory that answers `None`
2881///   leaves the port draining to nowhere (`add_port` already succeeded on the
2882///   session's side, so the frames have to go somewhere) and the run logs it.
2883///
2884/// Returns the handle plus the run future; drive them concurrently. The run ends
2885/// as the fixed one does, when the session's `run` returns and the arms drain.
2886#[cfg(feature = "std")]
2887pub fn run_duplex_session_dynamic<'a, Sess, Clk, Factory>(
2888    sources: Vec<&'a mut dyn DynSourceLoop>,
2889    session: &'a mut Sess,
2890    sinks: Vec<&'a mut dyn DynAsyncElement>,
2891    clock: &'a Clk,
2892    link_capacity: impl Into<LinkCapacity>,
2893    sink_factory: Factory,
2894) -> (
2895    DynamicDuplexHandle<'a>,
2896    impl Future<Output = Result<RunStats, G2gError>> + 'a,
2897)
2898where
2899    Sess: MultiDuplexSession + 'a,
2900    Clk: PipelineClock + 'a,
2901    Factory: FnMut(usize, &Caps) -> Option<Box<dyn DynAsyncElement + 'a>> + 'a,
2902{
2903    run_duplex_session_dynamic_inner(
2904        sources,
2905        session,
2906        sinks,
2907        clock,
2908        link_capacity,
2909        sink_factory,
2910        None,
2911    )
2912}
2913
2914/// As [`run_duplex_session_dynamic`], but taps live telemetry into `observer`.
2915/// The topology starts as the declared sources, session and sinks; a track or
2916/// port added later appends its node and link before its first packet, so a
2917/// dashboard polling [`Observer::snapshot`] sees renegotiated pads appear.
2918#[cfg(feature = "std")]
2919pub fn run_duplex_session_dynamic_observed<'a, Sess, Clk, Factory>(
2920    sources: Vec<&'a mut dyn DynSourceLoop>,
2921    session: &'a mut Sess,
2922    sinks: Vec<&'a mut dyn DynAsyncElement>,
2923    clock: &'a Clk,
2924    link_capacity: impl Into<LinkCapacity>,
2925    sink_factory: Factory,
2926    observer: &Observer,
2927) -> (
2928    DynamicDuplexHandle<'a>,
2929    impl Future<Output = Result<RunStats, G2gError>> + 'a,
2930)
2931where
2932    Sess: MultiDuplexSession + 'a,
2933    Clk: PipelineClock + 'a,
2934    Factory: FnMut(usize, &Caps) -> Option<Box<dyn DynAsyncElement + 'a>> + 'a,
2935{
2936    run_duplex_session_dynamic_inner(
2937        sources,
2938        session,
2939        sinks,
2940        clock,
2941        link_capacity,
2942        sink_factory,
2943        Some(observer.clone()),
2944    )
2945}
2946
2947#[cfg(feature = "std")]
2948#[allow(clippy::too_many_arguments)]
2949fn run_duplex_session_dynamic_inner<'a, Sess, Clk, Factory>(
2950    sources: Vec<&'a mut dyn DynSourceLoop>,
2951    session: &'a mut Sess,
2952    sinks: Vec<&'a mut dyn DynAsyncElement>,
2953    _clock: &'a Clk,
2954    link_capacity: impl Into<LinkCapacity>,
2955    mut sink_factory: Factory,
2956    observer: Option<Observer>,
2957) -> (
2958    DynamicDuplexHandle<'a>,
2959    impl Future<Output = Result<RunStats, G2gError>> + 'a,
2960)
2961where
2962    Sess: MultiDuplexSession + 'a,
2963    Clk: PipelineClock + 'a,
2964    Factory: FnMut(usize, &Caps) -> Option<Box<dyn DynAsyncElement + 'a>> + 'a,
2965{
2966    let link_capacity: usize = link_capacity.into().get();
2967    let input_count = sources.len();
2968    let output_count = sinks.len();
2969    // Control channel: handle -> control arm (new send tracks).
2970    let (new_track_tx, new_track_rx) = bounded::<SendTrackRequest<'a>>(link_capacity);
2971    let handle = DynamicDuplexHandle {
2972        new_track_tx,
2973        next_input: Arc::new(Mutex::new(input_count)),
2974    };
2975
2976    let run = async move {
2977        assert!(
2978            input_count > 0,
2979            "duplex session needs at least one send source"
2980        );
2981        assert!(
2982            output_count > 0,
2983            "duplex session needs at least one recv sink"
2984        );
2985        assert!(
2986            session.input_count() == input_count,
2987            "session input count must match the number of send sources"
2988        );
2989        assert!(
2990            session.output_count() == output_count,
2991            "session output count must match the number of recv sinks"
2992        );
2993
2994        let mut sources = sources;
2995        let mut sinks = sinks;
2996        let mut namer = crate::log::InstanceNamer::new();
2997        let mut source_names: Vec<alloc::string::String> = Vec::with_capacity(input_count);
2998        for source in sources.iter_mut() {
2999            let name = namer.add(source.log_category(), None);
3000            source.set_instance_name(name.clone());
3001            source_names.push(name);
3002        }
3003        let session_name = namer.add(crate::log::short_type_name::<Sess>(), None);
3004        let mut sink_probes = Vec::with_capacity(output_count);
3005        for sink in sinks.iter_mut() {
3006            let name = namer.add(sink.log_category(), None);
3007            sink.set_instance_name(name.clone());
3008            sink_probes.push(ElementProbe::new(name));
3009        }
3010
3011        // Negotiate the declared send inputs and recv outputs exactly as the
3012        // fixed runner does; only the pads added later take the growth paths.
3013        let mut input_caps: Vec<Caps> = Vec::with_capacity(input_count);
3014        for (i, source) in sources.iter_mut().enumerate() {
3015            let produced = source.produced_caps().await?;
3016            let fixated = {
3017                let pad_constraint = MultiDuplexSession::caps_constraint_as_input(session, i);
3018                select_branch_caps(&produced, &pad_constraint)?
3019            };
3020            source.configure_pipeline(&fixated)?.reject_refixate()?;
3021            session.configure_input(i, &fixated)?.reject_refixate()?;
3022            input_caps.push(fixated);
3023        }
3024        let mut output_caps: Vec<Caps> = Vec::with_capacity(output_count);
3025        for (o, sink) in sinks.iter_mut().enumerate() {
3026            let fixated = session.output_caps(o)?.fixate()?;
3027            sink.configure_pipeline(&fixated)?.reject_refixate()?;
3028            output_caps.push(fixated);
3029        }
3030
3031        let (in_tx, in_rx) = bounded::<(usize, PipelinePacket)>(link_capacity);
3032        let in_counters: Vec<Option<Arc<EdgeCounters>>> = (0..input_count)
3033            .map(|_| observer.as_ref().map(|_| Arc::new(EdgeCounters::default())))
3034            .collect();
3035        let in_probes: Vec<Option<ProbeSlot>> = (0..input_count)
3036            .map(|_| observer.as_ref().map(|_| ProbeSlot::default()))
3037            .collect();
3038        let tap = observer.is_some();
3039        let mut branch_senders = Vec::with_capacity(output_count);
3040        let mut branch_receivers = Vec::with_capacity(output_count);
3041        let mut branch_taps = Vec::with_capacity(output_count);
3042        for _ in 0..output_count {
3043            let (tx, rx, edge) = link_tapped(link_capacity, tap);
3044            branch_senders.push(SenderSink::new(tx));
3045            branch_receivers.push(rx);
3046            branch_taps.push(edge);
3047        }
3048
3049        let session_id = input_count;
3050        if let Some(obs) = &observer {
3051            let mut nodes: Vec<TapNode> = source_names
3052                .iter()
3053                .map(|n| (n.clone(), NodeRole::Source, None))
3054                .collect();
3055            nodes.push((session_name, NodeRole::Muxer, None));
3056            for probe in &sink_probes {
3057                nodes.push((
3058                    alloc::string::String::from(probe.name()),
3059                    NodeRole::Sink,
3060                    Some(probe.clone()),
3061                ));
3062            }
3063            let mut edges: Vec<TapEdge> = Vec::with_capacity(input_count + output_count);
3064            for (i, ((caps, c), p)) in input_caps
3065                .iter()
3066                .zip(in_counters.iter())
3067                .zip(in_probes.iter())
3068                .enumerate()
3069            {
3070                edges.push((
3071                    i,
3072                    session_id,
3073                    caps.clone(),
3074                    EdgeTap {
3075                        probe: p.clone().unwrap_or_default(),
3076                        counters: c.clone(),
3077                    },
3078                ));
3079            }
3080            for (o, (caps, edge)) in output_caps
3081                .iter()
3082                .zip(core::mem::take(&mut branch_taps))
3083                .enumerate()
3084            {
3085                edges.push((session_id, session_id + 1 + o, caps.clone(), edge));
3086            }
3087            register_runner_tap(obs, nodes, edges);
3088        }
3089
3090        // Reverse channels: the declared pads' come from the session, the ones
3091        // grown later are minted by the control arm into this same map, which the
3092        // session reads through `DuplexInbound::reverse_channel`.
3093        let reverse: Vec<Option<crate::fanout::ReverseChannel>> = (0..input_count)
3094            .map(|i| session.reverse_channel(i))
3095            .collect();
3096        let reverse_map: ReverseMap = Arc::new(Mutex::new(reverse.clone()));
3097
3098        let mut source_arms: Vec<BoxFuture<'a, Result<DuplexArmOut, G2gError>>> =
3099            Vec::with_capacity(input_count);
3100        for (i, source) in sources.into_iter().enumerate() {
3101            let tx_i = in_tx.clone();
3102            let reverse_i = reverse[i].clone();
3103            let counters_i = in_counters[i].clone();
3104            let probe_i = in_probes[i].clone();
3105            source_arms.push(Box::pin(async move {
3106                let mut adapter = TaggingSink::new(i, tx_i, reverse_i, counters_i, probe_i);
3107                let result = source.run(&mut adapter).await;
3108                wind_down_when_session_ended(result, &adapter).map(DuplexArmOut::Source)
3109            }));
3110        }
3111
3112        // Growth channel: the session's multi-sink -> control arm (new recv
3113        // ports). The session arm owns the sending end, so it closes when the
3114        // session ends, which is what lets the control arm finish.
3115        let (grown_tx, grown_rx) = bounded::<GrownPort>(link_capacity);
3116        // Arm channel: control arm -> join (the attached source / sink futures).
3117        let (new_arm_tx, new_arm_rx) =
3118            bounded::<BoxFuture<'a, Result<DuplexArmOut, G2gError>>>(link_capacity);
3119
3120        let session_reverse = reverse_map.clone();
3121        let session_arm: BoxFuture<'a, Result<DuplexArmOut, G2gError>> = Box::pin(async move {
3122            let mut inbound = InboundReceiver {
3123                rx: in_rx,
3124                reverse: session_reverse,
3125            };
3126            let mut multi = GrowableSenderSink {
3127                ports: MultiSenderSink::new(branch_senders),
3128                link_capacity,
3129                tap,
3130                grown_tx,
3131            };
3132            session
3133                .run(&mut inbound, &mut multi)
3134                .await
3135                .map(|_| DuplexArmOut::Session)
3136        });
3137
3138        let mut sink_arms: Vec<BoxFuture<'a, Result<DuplexArmOut, G2gError>>> =
3139            Vec::with_capacity(output_count);
3140        for ((sink, rx), probe) in sinks
3141            .into_iter()
3142            .zip(branch_receivers)
3143            .zip(sink_probes.iter().cloned())
3144        {
3145            sink_arms.push(Box::pin(async move {
3146                duplex_sink_arm(sink, rx, &probe)
3147                    .await
3148                    .map(DuplexArmOut::Sink)
3149            }));
3150        }
3151
3152        let probes: Arc<Mutex<Vec<Probe>>> =
3153            Arc::new(Mutex::new(sink_probes.into_iter().map(Some).collect()));
3154        let mut tap_state = DuplexTap {
3155            obs: observer,
3156            namer,
3157            probes: probes.clone(),
3158            session_id,
3159        };
3160        let control_arm: BoxFuture<'a, Result<DuplexArmOut, G2gError>> = Box::pin(async move {
3161            // Hold one tagged sender open while tracks may still be added, so the
3162            // inbound channel does not close (telling the session its send side is
3163            // over) before a late track can attach. Dropped when the handle goes.
3164            let mut keepalive: Option<Sender<(usize, PipelinePacket)>> = Some(in_tx);
3165            loop {
3166                // Attach everything queued so far BEFORE parking on the select
3167                // below, so a request that arrived while we were busy is never
3168                // left waiting behind an idle channel.
3169                if let Some(tx) = keepalive.clone() {
3170                    while let Some(request) = new_track_rx.try_recv() {
3171                        attach_send_track(request, &tx, &new_arm_tx, &reverse_map, &mut tap_state)
3172                            .await?;
3173                    }
3174                }
3175                while let Some(grown) = grown_rx.try_recv() {
3176                    attach_recv_port(grown, &mut sink_factory, &new_arm_tx, &mut tap_state).await?;
3177                }
3178
3179                match keepalive {
3180                    Some(ref tx) => {
3181                        let tx = tx.clone();
3182                        match select2(new_track_rx.recv(), grown_rx.recv()).await {
3183                            Either::Left(Some(request)) => {
3184                                attach_send_track(
3185                                    request,
3186                                    &tx,
3187                                    &new_arm_tx,
3188                                    &reverse_map,
3189                                    &mut tap_state,
3190                                )
3191                                .await?;
3192                            }
3193                            // Handle dropped: no more tracks, so release the
3194                            // keepalive and let the send side end with its sources.
3195                            Either::Left(None) => keepalive = None,
3196                            Either::Right(Some(grown)) => {
3197                                attach_recv_port(
3198                                    grown,
3199                                    &mut sink_factory,
3200                                    &new_arm_tx,
3201                                    &mut tap_state,
3202                                )
3203                                .await?;
3204                            }
3205                            Either::Right(None) => return Ok(DuplexArmOut::Control),
3206                        }
3207                    }
3208                    // The session can still grow its recv side after the last
3209                    // track request; its arm ending closes this channel.
3210                    None => match grown_rx.recv().await {
3211                        Some(grown) => {
3212                            attach_recv_port(grown, &mut sink_factory, &new_arm_tx, &mut tap_state)
3213                                .await?;
3214                        }
3215                        None => return Ok(DuplexArmOut::Control),
3216                    },
3217                }
3218            }
3219        });
3220
3221        let mut arms: Vec<BoxFuture<'a, Result<DuplexArmOut, G2gError>>> =
3222            Vec::with_capacity(input_count + output_count + 2);
3223        arms.extend(source_arms);
3224        arms.push(session_arm);
3225        arms.extend(sink_arms);
3226        arms.push(control_arm);
3227
3228        let results = dynamic_join(arms, new_arm_rx).await;
3229        let mut emitted = 0u64;
3230        let mut consumed = 0u64;
3231        let mut dropped = 0u64;
3232        for r in results {
3233            match r? {
3234                DuplexArmOut::Source(n) => emitted += n,
3235                DuplexArmOut::Sink(n) => consumed += n,
3236                DuplexArmOut::Dropped(n) => dropped += n,
3237                DuplexArmOut::Session | DuplexArmOut::Control => {}
3238            }
3239        }
3240        let per_element = crate::runtime::snapshot_all(&probes.lock());
3241        Ok(RunStats {
3242            frames_emitted: emitted,
3243            frames_consumed: consumed,
3244            frames_dropped: dropped,
3245            latency: LatencyReport::ZERO,
3246            allocation: None,
3247            clock_priority: ClockPriority::SystemFallback,
3248            base_time_ns: 0,
3249            coordinator_events: 0,
3250            per_element,
3251        })
3252    };
3253
3254    (handle, run)
3255}
3256
3257/// Fixate a send track added at runtime. It negotiates alone: the session owns
3258/// itself inside its run loop, so unlike a declared input there is no pad
3259/// constraint to narrow against, and the session hears about the caps in the
3260/// `CapsChanged` the track's arm announces.
3261#[cfg(feature = "std")]
3262async fn negotiate_new_send_track(source: &mut dyn DynSourceLoop) -> Result<Caps, G2gError> {
3263    let produced = source.produced_caps().await?;
3264    let fixated = produced.fixate().ok_or(G2gError::CapsMismatch)?;
3265    source.configure_pipeline(&fixated)?.reject_refixate()?;
3266    Ok(fixated)
3267}
3268
3269/// Attach a send track requested at runtime: negotiate it, register the reverse
3270/// channel the session will look up for it, and hand its run loop (feeding a
3271/// [`TaggingSink`] tagged with the new index) to the dynamic join.
3272///
3273/// A source that cannot negotiate is logged and dropped, leaving the run on the
3274/// tracks it already has; only losing the arm channel, which would strand an
3275/// attached track, ends the run.
3276#[cfg(feature = "std")]
3277async fn attach_send_track<'a>(
3278    request: SendTrackRequest<'a>,
3279    in_tx: &Sender<(usize, PipelinePacket)>,
3280    new_arm_tx: &Sender<BoxFuture<'a, Result<DuplexArmOut, G2gError>>>,
3281    reverse: &ReverseMap,
3282    tap: &mut DuplexTap,
3283) -> Result<(), G2gError> {
3284    let SendTrackRequest { input, mut source } = request;
3285    let fixated = match negotiate_new_send_track(source.as_mut()).await {
3286        Ok(caps) => caps,
3287        Err(e) => {
3288            crate::g2g_error!(
3289                crate::log::Target::category(DUPLEX_CATEGORY),
3290                "runtime send track on input {input} rejected: {e:?}"
3291            );
3292            return Ok(());
3293        }
3294    };
3295
3296    let name = tap.namer.add(source.log_category(), None);
3297    source.set_instance_name(name.clone());
3298    let (counters, slot) = match &tap.obs {
3299        Some(obs) => {
3300            let counters = Arc::new(EdgeCounters::default());
3301            let slot = ProbeSlot::default();
3302            let id = obs.add_node(name, NodeRole::Source, None);
3303            obs.add_edge(
3304                id,
3305                tap.session_id,
3306                fixated.clone(),
3307                EdgeTap {
3308                    probe: slot.clone(),
3309                    counters: Some(counters.clone()),
3310                },
3311            );
3312            (Some(counters), Some(slot))
3313        }
3314        None => (None, None),
3315    };
3316
3317    let channel = crate::fanout::ReverseChannel::new();
3318    {
3319        let mut map = reverse.lock();
3320        if map.len() <= input {
3321            map.resize(input + 1, None);
3322        }
3323        map[input] = Some(channel.clone());
3324    }
3325
3326    let tx = in_tx.clone();
3327    let arm: BoxFuture<'a, Result<DuplexArmOut, G2gError>> = Box::pin(async move {
3328        let mut adapter = TaggingSink::new(input, tx, Some(channel), counters, slot);
3329        // The session has no other way to learn the pad exists, so its caps go out
3330        // before the source can push a frame on the index.
3331        let announced = adapter.push(PipelinePacket::CapsChanged(fixated)).await;
3332        let mut source = source;
3333        let result = match announced {
3334            Ok(_) => source.run(&mut adapter).await,
3335            Err(e) => Err(e),
3336        };
3337        wind_down_when_session_ended(result, &adapter).map(DuplexArmOut::Source)
3338    });
3339    // Await capacity rather than failing: a burst of adds larger than the arm
3340    // channel is backpressure on the control arm, not a session teardown. The
3341    // dynamic join drains this channel on every poll, so the send resumes.
3342    new_arm_tx.send(arm).await.map_err(|_| G2gError::Shutdown)
3343}
3344
3345/// Prepare the element a factory returned for a grown recv port: name it, probe
3346/// it, put it in the observer's topology, and configure it against the port's
3347/// caps. `None` (logged) if it refuses them, which leaves the port draining
3348/// rather than failing a live session over one late sink.
3349#[cfg(feature = "std")]
3350fn prepare_grown_sink<'a>(
3351    mut sink: Box<dyn DynAsyncElement + 'a>,
3352    port: usize,
3353    caps: &Caps,
3354    edge: EdgeTap,
3355    tap: &mut DuplexTap,
3356) -> Option<(Box<dyn DynAsyncElement + 'a>, Arc<ElementProbe>)> {
3357    let name = tap.namer.add(sink.log_category(), None);
3358    sink.set_instance_name(name.clone());
3359    let probe = ElementProbe::new(name.clone());
3360    if let Some(obs) = &tap.obs {
3361        let id = obs.add_node(name, NodeRole::Sink, Some(probe.clone()));
3362        obs.add_edge(tap.session_id, id, caps.clone(), edge);
3363    }
3364    match log_caps_rejected(Some(probe.name()), caps, sink.configure_pipeline(caps)) {
3365        Ok(ConfigureOutcome::Accepted) => {
3366            tap.probes.lock().push(Some(probe.clone()));
3367            Some((sink, probe))
3368        }
3369        _ => {
3370            crate::g2g_error!(
3371                crate::log::Target::category(DUPLEX_CATEGORY),
3372                "sink for grown recv port {port} refused its caps: draining the port"
3373            );
3374            None
3375        }
3376    }
3377}
3378
3379/// Attach a recv port the session grew: ask the factory for its sink, then hand
3380/// that sink's drain loop to the dynamic join. With no sink for it the port is
3381/// drained and counted as dropped: `add_port` already answered the session, so
3382/// its frames are coming either way.
3383#[cfg(feature = "std")]
3384async fn attach_recv_port<'a, Factory>(
3385    grown: GrownPort,
3386    factory: &mut Factory,
3387    new_arm_tx: &Sender<BoxFuture<'a, Result<DuplexArmOut, G2gError>>>,
3388    tap: &mut DuplexTap,
3389) -> Result<(), G2gError>
3390where
3391    Factory: FnMut(usize, &Caps) -> Option<Box<dyn DynAsyncElement + 'a>>,
3392{
3393    let GrownPort {
3394        port,
3395        caps,
3396        rx,
3397        edge,
3398    } = grown;
3399    let prepared = match factory(port, &caps) {
3400        Some(sink) => prepare_grown_sink(sink, port, &caps, edge, tap),
3401        None => {
3402            crate::g2g_error!(
3403                crate::log::Target::category(DUPLEX_CATEGORY),
3404                "no sink for grown recv port {port}: draining it"
3405            );
3406            None
3407        }
3408    };
3409    let arm: BoxFuture<'a, Result<DuplexArmOut, G2gError>> = match prepared {
3410        Some((mut sink, probe)) => Box::pin(async move {
3411            duplex_sink_arm(sink.as_mut(), rx, &probe)
3412                .await
3413                .map(DuplexArmOut::Sink)
3414        }),
3415        None => Box::pin(async move { drain_grown_port(rx).await.map(DuplexArmOut::Dropped) }),
3416    };
3417    // Same backpressure contract as the send-track twin above.
3418    new_arm_tx.send(arm).await.map_err(|_| G2gError::Shutdown)
3419}
3420
3421/// Consume a grown recv port that has no sink behind it, counting the frames it
3422/// drops so they show up in [`RunStats::frames_dropped`].
3423#[cfg(feature = "std")]
3424async fn drain_grown_port(rx: crate::runtime::channel::LinkReceiver) -> Result<u64, G2gError> {
3425    let mut dropped = 0u64;
3426    loop {
3427        match rx.recv().await {
3428            Some(PipelinePacket::DataFrame(_)) => dropped += 1,
3429            Some(PipelinePacket::Eos) | None => return Ok(dropped),
3430            Some(_) => {}
3431        }
3432    }
3433}