Skip to main content

g2g_core/runtime/
channel.rs

1use alloc::collections::VecDeque;
2use alloc::sync::Arc;
3use core::future::Future;
4use core::pin::Pin;
5use core::task::{Context, Poll, Waker};
6
7use spin::Mutex;
8
9use crate::element::{OutputSink, PushOutcome, QosMessage, Reconfigure};
10use crate::error::G2gError;
11use crate::frame::PipelinePacket;
12use crate::link::LinkPolicy;
13use crate::runtime::instrument::{EdgeCounters, Probe};
14
15pub fn bounded<T>(capacity: usize) -> (Sender<T>, Receiver<T>) {
16    assert!(capacity > 0, "channel capacity must be > 0");
17    let inner = Arc::new(Mutex::new(Inner {
18        queue: VecDeque::with_capacity(capacity),
19        capacity,
20        send_waker: None,
21        recv_waker: None,
22        senders: 1,
23        receivers: 1,
24    }));
25    (
26        Sender {
27            inner: inner.clone(),
28        },
29        Receiver { inner },
30    )
31}
32
33#[derive(Debug)]
34struct Inner<T> {
35    queue: VecDeque<T>,
36    capacity: usize,
37    send_waker: Option<Waker>,
38    recv_waker: Option<Waker>,
39    senders: usize,
40    receivers: usize,
41}
42
43#[derive(Debug)]
44pub struct Sender<T> {
45    inner: Arc<Mutex<Inner<T>>>,
46}
47
48#[derive(Debug)]
49pub struct Receiver<T> {
50    inner: Arc<Mutex<Inner<T>>>,
51}
52
53impl<T> Clone for Sender<T> {
54    fn clone(&self) -> Self {
55        self.inner.lock().senders += 1;
56        Self {
57            inner: self.inner.clone(),
58        }
59    }
60}
61
62impl<T> Drop for Sender<T> {
63    fn drop(&mut self) {
64        let mut g = self.inner.lock();
65        g.senders -= 1;
66        if g.senders == 0 {
67            if let Some(w) = g.recv_waker.take() {
68                w.wake();
69            }
70        }
71    }
72}
73
74impl<T> Drop for Receiver<T> {
75    fn drop(&mut self) {
76        let mut g = self.inner.lock();
77        g.receivers -= 1;
78        if g.receivers == 0 {
79            if let Some(w) = g.send_waker.take() {
80                w.wake();
81            }
82        }
83    }
84}
85
86#[derive(Debug, Clone, Copy, PartialEq, Eq)]
87pub enum SendError {
88    /// All receivers dropped.
89    Closed,
90    /// Channel at capacity (only from `try_send`).
91    Full,
92}
93
94impl<T> Sender<T> {
95    /// Best-effort synchronous push. Returns the rejected value plus a
96    /// reason if the channel is full or closed.
97    pub fn try_send(&self, value: T) -> Result<(), (T, SendError)> {
98        let mut g = self.inner.lock();
99        if g.receivers == 0 {
100            return Err((value, SendError::Closed));
101        }
102        if g.queue.len() >= g.capacity {
103            return Err((value, SendError::Full));
104        }
105        g.queue.push_back(value);
106        if let Some(w) = g.recv_waker.take() {
107            w.wake();
108        }
109        Ok(())
110    }
111
112    pub fn send(&self, value: T) -> SendFuture<'_, T> {
113        SendFuture {
114            sender: self,
115            value: Some(value),
116        }
117    }
118
119    /// Poll form of [`send`](Self::send): enqueue `value` once capacity frees,
120    /// parking the send waker while full. `value` is taken only on success, so
121    /// the caller re-polls with the same slot.
122    pub fn poll_send(
123        &self,
124        cx: &mut Context<'_>,
125        value: &mut Option<T>,
126    ) -> Poll<Result<(), SendError>> {
127        let mut g = self.inner.lock();
128        if g.receivers == 0 {
129            return Poll::Ready(Err(SendError::Closed));
130        }
131        if g.queue.len() < g.capacity {
132            let v = value.take().expect("poll_send called without a value");
133            g.queue.push_back(v);
134            if let Some(w) = g.recv_waker.take() {
135                w.wake();
136            }
137            return Poll::Ready(Ok(()));
138        }
139        g.send_waker = Some(cx.waker().clone());
140        Poll::Pending
141    }
142
143    /// Remove and return the front-most queued value matching `pred`, or
144    /// `None` if none match. Used by a leaky `DropOldest` link to evict the
145    /// oldest data frame and make room without disturbing queued control
146    /// packets. No waker is signalled: a receiver only parks when the queue is
147    /// empty, and eviction only runs on a full queue.
148    pub(crate) fn evict_front_matching(&self, pred: impl Fn(&T) -> bool) -> Option<T> {
149        let mut g = self.inner.lock();
150        let idx = g.queue.iter().position(pred)?;
151        g.queue.remove(idx)
152    }
153}
154
155#[allow(missing_debug_implementations)]
156pub struct SendFuture<'a, T> {
157    sender: &'a Sender<T>,
158    value: Option<T>,
159}
160
161impl<'a, T: Unpin> Future for SendFuture<'a, T> {
162    type Output = Result<(), SendError>;
163
164    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
165        let this = self.get_mut();
166        this.sender.poll_send(cx, &mut this.value)
167    }
168}
169
170impl<T> Receiver<T> {
171    pub fn recv(&self) -> RecvFuture<'_, T> {
172        RecvFuture { receiver: self }
173    }
174
175    /// Current fill of the channel as a percent (0 = empty, 100 = full),
176    /// a snapshot for buffering observability. Capacity is always > 0.
177    pub fn fill_percent(&self) -> u8 {
178        let g = self.inner.lock();
179        ((g.queue.len() * 100) / g.capacity) as u8
180    }
181
182    /// Non-blocking pop. Returns `None` when the queue is empty (whether or
183    /// not senders remain). Lets a consumer drain without awaiting.
184    pub fn try_recv(&self) -> Option<T> {
185        let mut g = self.inner.lock();
186        let v = g.queue.pop_front();
187        if v.is_some() {
188            if let Some(w) = g.send_waker.take() {
189                w.wake();
190            }
191        }
192        v
193    }
194}
195
196#[allow(missing_debug_implementations)]
197pub struct RecvFuture<'a, T> {
198    receiver: &'a Receiver<T>,
199}
200
201impl<'a, T> Future for RecvFuture<'a, T> {
202    type Output = Option<T>;
203
204    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
205        let this = self.get_mut();
206        let mut g = this.receiver.inner.lock();
207        if let Some(v) = g.queue.pop_front() {
208            if let Some(w) = g.send_waker.take() {
209                w.wake();
210            }
211            return Poll::Ready(Some(v));
212        }
213        if g.senders == 0 {
214            return Poll::Ready(None);
215        }
216        g.recv_waker = Some(cx.waker().clone());
217        Poll::Pending
218    }
219}
220
221/// Capacity-1 latest-wins slot carrying the upstream-traveling
222/// `Reconfigure` signal of a bidirectional link. Stores overwrite any
223/// pending value; takes consume it. Cheap: one `Arc<Mutex<Option<_>>>`.
224#[derive(Debug, Clone, Default)]
225pub struct ReconfigureSlot {
226    inner: Arc<Mutex<Option<Reconfigure>>>,
227}
228
229impl ReconfigureSlot {
230    pub fn store(&self, value: Reconfigure) {
231        *self.inner.lock() = Some(value);
232    }
233
234    pub fn take(&self) -> Option<Reconfigure> {
235        self.inner.lock().take()
236    }
237}
238
239/// Capacity-1 latest-wins slot carrying the upstream-traveling [`QosMessage`] of
240/// a bidirectional link (M174). Same shape as [`ReconfigureSlot`]: a later QoS
241/// report supersedes an unobserved earlier one (lateness is a current condition,
242/// not a stream).
243#[derive(Debug, Clone, Default)]
244pub struct QosSlot {
245    inner: Arc<Mutex<Option<QosMessage>>>,
246}
247
248impl QosSlot {
249    pub fn store(&self, value: QosMessage) {
250        *self.inner.lock() = Some(value);
251    }
252
253    pub fn take(&self) -> Option<QosMessage> {
254        self.inner.lock().take()
255    }
256}
257
258/// Capacity-1 latest-wins slot carrying an upstream-traveling target bitrate
259/// (bits/second), the WebRTC congestion-control / BWE signal. Same shape as
260/// [`QosSlot`]: a later estimate supersedes an unobserved earlier one (the
261/// current target, not a stream).
262#[derive(Debug, Clone, Default)]
263pub struct BitrateSlot {
264    inner: Arc<Mutex<Option<u32>>>,
265}
266
267impl BitrateSlot {
268    pub fn store(&self, value: u32) {
269        *self.inner.lock() = Some(value);
270    }
271
272    pub fn take(&self) -> Option<u32> {
273        self.inner.lock().take()
274    }
275}
276
277/// Upstream end of a bidirectional inter-element link: forward
278/// `PipelinePacket` channel + reverse `Reconfigure` slot. Held by the
279/// producing element (wrapped in [`SenderSink`]). Cloneable so a fan-in
280/// merger can share one output link across N forwarders; the link closes
281/// when the last clone drops.
282#[derive(Debug, Clone)]
283pub struct LinkSender {
284    pub(crate) data: Sender<PipelinePacket>,
285    pub(crate) reconfigure: ReconfigureSlot,
286    /// Reverse QoS slot (M174): a downstream sink stores a lateness report here;
287    /// the producer observes it on its next push as [`PushOutcome::Qos`].
288    pub(crate) qos: QosSlot,
289    /// Reverse bitrate slot: a downstream WebRTC sink stores its BWE estimate
290    /// here; the producer (encoder) observes it as [`PushOutcome::Bitrate`].
291    pub(crate) bitrate: BitrateSlot,
292    /// Backpressure policy for this link. `Block` (the default) awaits
293    /// capacity; the leaky variants drop data frames under a full channel.
294    pub(crate) policy: LinkPolicy,
295    /// Cumulative count of frames this link has dropped, shared with the
296    /// runner so the drop total surfaces in `RunStats`. `None` until the
297    /// runner installs one (leaky links only).
298    pub(crate) dropped: Option<Arc<Mutex<u64>>>,
299    /// Per-edge content-inspection slot (dev tooling). The `SenderSink` wrapping
300    /// this link shares it, so a tool can install a [`LinkInterceptor`] to sample
301    /// packets crossing this edge without touching the arms. Empty (pass-through,
302    /// zero cost) unless a subscriber installs one.
303    pub(crate) probe: ProbeSlot,
304    /// Per-edge transit-time ring (dev tooling): a send-time stamp per queued
305    /// `DataFrame`, popped at the consumer to measure queue residency. `None`
306    /// (zero cost) unless the runner enabled instrumentation on this edge.
307    pub(crate) transit: Option<TransitRing>,
308    /// Per-edge packet / byte / drop counters (dev tooling), shared with the
309    /// observer tap so a live consumer reads this edge's traffic mid-run.
310    /// `None` (zero cost) unless the runner installed them.
311    pub(crate) counters: Option<Arc<EdgeCounters>>,
312}
313
314/// Send-time stamps for the packets queued on one link, shared between its
315/// [`LinkSender`] and [`LinkReceiver`]. FIFO, aligned with the data channel (one
316/// stamp pushed per queued `DataFrame`, one popped per received `DataFrame`), so
317/// the consumer reads each frame's queue-residency time.
318pub(crate) type TransitRing = Arc<Mutex<VecDeque<u64>>>;
319
320/// Monotonic send stamp for the transit ring; 0 under `no_std` (no clock).
321#[inline]
322fn stamp_now_ns() -> u64 {
323    #[cfg(feature = "std")]
324    {
325        crate::metrics::monotonic_ns()
326    }
327    #[cfg(not(feature = "std"))]
328    {
329        0
330    }
331}
332
333impl LinkSender {
334    /// Set this link's backpressure policy (the runner applies the edge's
335    /// `LinkPolicy` after building the channel). Only the `std` graph runner
336    /// wires per-edge policy today; `no_std` runners use the `Block` default.
337    #[cfg(feature = "std")]
338    pub(crate) fn set_policy(&mut self, policy: LinkPolicy) {
339        self.policy = policy;
340    }
341
342    /// Install the shared drop counter so leaky drops are observable.
343    #[cfg(feature = "std")]
344    pub(crate) fn set_drop_counter(&mut self, counter: Arc<Mutex<u64>>) {
345        self.dropped = Some(counter);
346    }
347
348    /// Install this edge's live traffic counters (dev tooling), so a mid-run
349    /// observer snapshot sees the packets / bytes / drops crossing here.
350    #[cfg(feature = "std")]
351    pub(crate) fn set_counters(&mut self, counters: Arc<EdgeCounters>) {
352        self.counters = Some(counters);
353    }
354
355    /// Record one dropped frame, if a counter is installed.
356    fn record_drop(&self) {
357        if let Some(c) = &self.dropped {
358            *c.lock() += 1;
359        }
360        if let Some(c) = &self.counters {
361            c.record_drop();
362        }
363    }
364
365    /// Record one packet that entered the link, if counters are installed.
366    /// `blocked_since` is the stamp taken before a blocking send, so the time
367    /// the producer spent awaiting capacity is folded in.
368    fn record_sent(&self, bytes: u64, blocked_since: Option<u64>) {
369        if let Some(c) = &self.counters {
370            let blocked = blocked_since.map_or(0, |t0| stamp_now_ns().saturating_sub(t0));
371            c.record_packet(bytes, blocked);
372        }
373    }
374}
375
376/// Payload bytes of a packet as they cross a link: the CPU-resident buffer's
377/// length. A device-domain frame (a CUDA / texture handle) carries no bytes
378/// here, and a control packet none at all, so both count 0.
379pub(crate) fn packet_bytes(packet: &PipelinePacket) -> u64 {
380    match packet {
381        PipelinePacket::DataFrame(f) => match &f.domain {
382            crate::memory::MemoryDomain::System(s) => s.as_slice().len() as u64,
383            #[cfg(feature = "alloc")]
384            crate::memory::MemoryDomain::SystemView(v) => v.backing().len() as u64,
385            #[cfg(feature = "alloc")]
386            _ => 0,
387        },
388        _ => 0,
389    }
390}
391
392/// Downstream end of a bidirectional inter-element link. Held by the
393/// consuming element (or the runner loop driving it). `request_reconfigure`
394/// fires an upstream signal that the producer observes on its next
395/// [`OutputSink::push`].
396#[derive(Debug)]
397pub struct LinkReceiver {
398    pub(crate) data: Receiver<PipelinePacket>,
399    pub(crate) reconfigure: ReconfigureSlot,
400    pub(crate) qos: QosSlot,
401    pub(crate) bitrate: BitrateSlot,
402    /// Shared with the [`LinkSender`] when transit instrumentation is on; see
403    /// [`pop_transit_ns`](LinkReceiver::pop_transit_ns).
404    pub(crate) transit: Option<TransitRing>,
405}
406
407impl LinkReceiver {
408    pub fn recv(&self) -> RecvFuture<'_, PipelinePacket> {
409        self.data.recv()
410    }
411
412    /// Non-blocking drain of one packet; `None` when the link is empty.
413    pub fn try_recv(&self) -> Option<PipelinePacket> {
414        self.data.try_recv()
415    }
416
417    /// Fill of this link as a percent (0-100), for buffering reports.
418    pub fn fill_percent(&self) -> u8 {
419        self.data.fill_percent()
420    }
421
422    /// Pop the queue-residency (transit) time in ns of the just-received
423    /// `DataFrame`: the wall-clock elapsed since the producer queued it. Call
424    /// once per received `DataFrame` to keep the stamp ring aligned with the data
425    /// channel. `None` when this edge is not instrumented (or under `no_std`,
426    /// where there is no clock so the stamp is 0). Only `DataFrame`s are stamped,
427    /// so callers must not pop for control packets.
428    pub fn pop_transit_ns(&self) -> Option<u64> {
429        let ring = self.transit.as_ref()?;
430        let sent = ring.lock().pop_front()?;
431        #[cfg(feature = "std")]
432        {
433            Some(crate::metrics::monotonic_ns().saturating_sub(sent))
434        }
435        #[cfg(not(feature = "std"))]
436        {
437            let _ = sent;
438            Some(0)
439        }
440    }
441
442    /// Latest-wins: overwrites any pending request that the producer
443    /// hasn't yet observed. Reconfigure is a control signal, not a
444    /// stream — older proposals are stale by definition.
445    pub fn request_reconfigure(&self, r: Reconfigure) {
446        self.reconfigure.store(r);
447    }
448
449    /// Latest-wins QoS signal (M174): the consuming sink reports it ran behind
450    /// the clock; the producer observes it on its next [`OutputSink::push`] as
451    /// [`PushOutcome::Qos`] and may skip ahead to shed load.
452    pub fn request_qos(&self, q: QosMessage) {
453        self.qos.store(q);
454    }
455
456    /// Latest-wins target bitrate (bits/second): a downstream WebRTC sink reports
457    /// its congestion-control / BWE estimate; the producing encoder observes it on
458    /// its next [`OutputSink::push`] as [`PushOutcome::Bitrate`] and retargets.
459    pub fn request_bitrate(&self, bps: u32) {
460        self.bitrate.store(bps);
461    }
462
463    /// A clone of this link's reverse QoS slot (M175). A transform arm hands it
464    /// to its *output* [`SenderSink`] as a relay target, so a QoS report seen on
465    /// the downstream link is forwarded onto this (upstream) link toward the
466    /// source instead of being dropped at the transform.
467    pub(crate) fn qos_slot(&self) -> QosSlot {
468        self.qos.clone()
469    }
470
471    /// A clone of this link's reverse reconfigure slot (M720), the
472    /// keyframe-request analog of [`qos_slot`](Self::qos_slot).
473    pub(crate) fn reconfigure_slot(&self) -> ReconfigureSlot {
474        self.reconfigure.clone()
475    }
476
477    /// A clone of this link's reverse bitrate slot (M720).
478    pub(crate) fn bitrate_slot(&self) -> BitrateSlot {
479        self.bitrate.clone()
480    }
481}
482
483/// Build a bidirectional inter-element link with `capacity` forward
484/// slots and a capacity-1 reverse `Reconfigure` slot.
485pub fn link(capacity: usize) -> (LinkSender, LinkReceiver) {
486    build_link(capacity, None)
487}
488
489/// As [`link`], but with per-edge transit-time instrumentation enabled: the
490/// sender stamps each queued `DataFrame`, the receiver pops the stamp to measure
491/// queue residency. Used by the graph runner (std) when an observer is attached;
492/// gated on `std` so the no_std / runtime-only build doesn't flag it as unused.
493#[cfg(feature = "std")]
494pub(crate) fn link_with_transit(capacity: usize) -> (LinkSender, LinkReceiver) {
495    build_link(capacity, Some(Arc::new(Mutex::new(VecDeque::new()))))
496}
497
498fn build_link(capacity: usize, transit: Option<TransitRing>) -> (LinkSender, LinkReceiver) {
499    let (data_tx, data_rx) = bounded::<PipelinePacket>(capacity);
500    let slot = ReconfigureSlot::default();
501    let qos = QosSlot::default();
502    let bitrate = BitrateSlot::default();
503    (
504        LinkSender {
505            data: data_tx,
506            reconfigure: slot.clone(),
507            qos: qos.clone(),
508            bitrate: bitrate.clone(),
509            policy: LinkPolicy::Block,
510            dropped: None,
511            probe: ProbeSlot::default(),
512            transit: transit.clone(),
513            counters: None,
514        },
515        LinkReceiver {
516            data: data_rx,
517            reconfigure: slot,
518            qos,
519            bitrate,
520            transit,
521        },
522    )
523}
524
525/// What a [`LinkInterceptor`] decides for a packet crossing a link.
526#[derive(Debug, Clone, Copy, PartialEq, Eq)]
527pub enum ProbeAction {
528    /// Forward the packet downstream as usual.
529    Pass,
530    /// Drop the packet; it never reaches the downstream element.
531    Drop,
532}
533
534/// A probe registered on a link. `on_packet` is called for every packet
535/// before it is sent, and returns whether to pass or drop it. The g2g
536/// equivalent of a GStreamer pad probe (DESIGN.md §4.9).
537pub trait LinkInterceptor {
538    fn on_packet(&self, packet: &PipelinePacket) -> ProbeAction;
539}
540
541/// Cloneable slot holding the optional [`LinkInterceptor`] of a link's
542/// [`SenderSink`]. Same latest-wins shape as [`ReconfigureSlot`]; clones
543/// share the inner cell, so the application installs/removes a probe at
544/// runtime while the runner drives the link.
545#[derive(Clone, Default)]
546pub struct ProbeSlot {
547    inner: Arc<Mutex<Option<Arc<dyn LinkInterceptor + Send + Sync>>>>,
548}
549
550impl core::fmt::Debug for ProbeSlot {
551    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
552        f.debug_struct("ProbeSlot").finish_non_exhaustive()
553    }
554}
555
556impl ProbeSlot {
557    /// Install (or replace) the probe consulted on every push.
558    pub fn install(&self, probe: Arc<dyn LinkInterceptor + Send + Sync>) {
559        *self.inner.lock() = Some(probe);
560    }
561
562    /// Remove the probe; subsequent packets pass unconditionally.
563    pub fn remove(&self) {
564        *self.inner.lock() = None;
565    }
566
567    /// The verdict for `packet`: `Pass` when no interceptor is installed. Also
568    /// consulted by the fan-in session adapter, which shares the tagged channel
569    /// and so carries its own per-input slot.
570    pub(crate) fn action(&self, packet: &PipelinePacket) -> ProbeAction {
571        match self.inner.lock().as_ref() {
572            Some(probe) => probe.on_packet(packet),
573            None => ProbeAction::Pass,
574        }
575    }
576}
577
578/// Adapter from a [`LinkSender`] to the async `OutputSink` trait. Push
579/// flow per packet:
580///
581/// 1. A `ProbeSlot` may drop the packet outright.
582/// 2. The reverse `Reconfigure` slot is checked **before** send. If
583///    downstream already requested reconfigure, the packet is *not*
584///    enqueued and the producer sees `PushOutcome::Reconfigure(...)`.
585///    The caller is expected to handle the request — typically by
586///    calling `reconfigure()`, emitting a fresh `CapsChanged`, and
587///    composing the next frame under the agreed caps — before pushing
588///    again. The unsent packet is the caller's responsibility: resend
589///    it under the new caps, drop it, or skip ahead. This pre-send
590///    interception is the in-band ordering fix: rejected packets that
591///    the producer had not yet committed never cross the link under
592///    stale caps.
593/// 3. Otherwise the packet is enqueued. The slot is checked again
594///    afterwards: a request that fired *while* the producer was
595///    awaiting capacity still surfaces, but the just-enqueued packet
596///    has already crossed under old caps. That window is irreducible —
597///    the producer was already committed before the request was made.
598#[derive(Debug)]
599pub struct SenderSink {
600    link: LinkSender,
601    probe: ProbeSlot,
602    /// Relay target for a downstream QoS report (M175). `None` on a source's
603    /// output adapter: a QoS seen on the link surfaces as [`PushOutcome::Qos`]
604    /// so the source element acts on it. `Some` on a transform's output adapter:
605    /// the report is stored into this (the transform's *input* link) reverse
606    /// slot instead, forwarding it one hop toward the source. A generic
607    /// transform thus relays QoS without having to observe it in `process`.
608    upstream_qos: Option<QosSlot>,
609    /// As `upstream_qos`, for downstream reverse-channel `Reconfigure`s the
610    /// producer does not answer (M720): set on a transform's output adapter.
611    upstream_reconfigure: Option<ReconfigureSlot>,
612    /// Which `Reconfigure` variants this adapter's own producer answers, i.e.
613    /// which ones surface as `PushOutcome::Reconfigure` instead of travelling
614    /// on. Per variant because one element answers one signal and passes the
615    /// other: `videoflip` takes `AbsorbOrientation` and still relays a
616    /// keyframe request.
617    reconfigure_answered: ReconfigureAnswered,
618    /// As `upstream_qos`, for downstream bitrate targets (M720).
619    upstream_bitrate: Option<BitrateSlot>,
620    /// M759 auto-propagation: the propagated metadata set the owning transform
621    /// arm derived from its most recent input frame (its element declared a
622    /// [`meta_transform`](crate::element::AsyncElement::meta_transform)). Attached
623    /// to any outgoing `DataFrame` whose own meta is empty, so a transform that
624    /// emits fresh frames still carries the survivors forward without touching
625    /// its `process`. `None` on every other adapter and when the propagated set
626    /// came out empty (a Drop verdict must not leak a stale set).
627    #[cfg(feature = "metadata")]
628    meta_stash: Option<crate::meta::FrameMetaSet>,
629    /// M909: set once an `Eos` has been enqueued through this adapter. A runner
630    /// arm that forwards its own `Eos` after `process(Eos)` returns checks this
631    /// so an element that already forwarded one (typically via a catch-all
632    /// `other => out.push(other)` arm) does not emit a second.
633    eos_forwarded: bool,
634    /// M947: the probe of the element pushing through this adapter, when the arm
635    /// instruments it. Time spent awaiting capacity here is banked on that probe
636    /// so the element's `process()` timing separates its own work from
637    /// downstream backpressure. `None` on an uninstrumented adapter (no cost:
638    /// the blocking send then takes no extra clock read).
639    push_wait_probe: Probe,
640    /// In-flight push phase, so `poll_push` runs the pre-send steps exactly
641    /// once per packet and a blocked send resumes where it left off.
642    push_phase: PushPhase,
643}
644
645/// Tell the producer feeding `in_rx` that this sink applies an
646/// [`OrientationMeta`](crate::meta::OrientationMeta) itself, so a `videoflip`
647/// upstream attaches the descriptor instead of remapping pixels.
648///
649/// Called while the runner is still wiring arms, not from inside the sink arm:
650/// the arms are polled source-first, so an advertisement made once the sink arm
651/// runs would arrive a linkful of already-rotated frames late.
652pub(crate) fn advertise_orientation(in_rx: &LinkReceiver, absorbs: bool) {
653    if absorbs {
654        in_rx.request_reconfigure(crate::element::Reconfigure::AbsorbOrientation);
655    }
656}
657
658/// Which [`Reconfigure`](crate::element::Reconfigure) variants a
659/// [`SenderSink`]'s producer answers itself. A variant it does not answer goes
660/// onto the upstream link when one is wired, and is dropped otherwise: the
661/// pre-send check never enqueues the packet it intercepts, so surfacing a signal
662/// to a producer that ignores it would cost that packet.
663///
664/// `Propose` / `Renegotiate` are not listed: every producer answers those.
665#[derive(Debug, Clone, Copy, PartialEq, Eq)]
666pub(crate) struct ReconfigureAnswered {
667    /// [`Reconfigure::ForceKeyframe`](crate::element::Reconfigure::ForceKeyframe).
668    pub keyframe: bool,
669    /// [`Reconfigure::AbsorbOrientation`](crate::element::Reconfigure::AbsorbOrientation).
670    pub orientation: bool,
671}
672
673impl Default for ReconfigureAnswered {
674    fn default() -> Self {
675        // A source adapter's defaults: a keyframe request reaches the source
676        // element (an encoder-less live source may act on it), an orientation
677        // advertisement never does, since only a flip answers one.
678        ReconfigureAnswered {
679            keyframe: true,
680            orientation: false,
681        }
682    }
683}
684
685/// See [`SenderSink::push_phase`].
686#[derive(Debug, Clone, Copy)]
687enum PushPhase {
688    /// No push in flight: the next poll runs the pre-send steps.
689    Idle,
690    /// Past the pre-send steps, awaiting queue capacity: only the enqueue and
691    /// its accounting remain. `stamped` records whether a transit stamp was
692    /// pushed for this packet (Block links only), so a dead link rolls back
693    /// exactly what was stamped.
694    Sending {
695        bytes: u64,
696        blocked_since: Option<u64>,
697        stamped: bool,
698    },
699}
700
701impl SenderSink {
702    pub fn new(link: LinkSender) -> Self {
703        // Share the link's per-edge probe slot, so a tool that installed an
704        // interceptor on the edge (via the runner/observer) sees this adapter's
705        // packets. A bare link has an empty slot: pass-through, no cost.
706        let probe = link.probe.clone();
707        Self {
708            link,
709            probe,
710            upstream_qos: None,
711            upstream_reconfigure: None,
712            reconfigure_answered: ReconfigureAnswered::default(),
713            upstream_bitrate: None,
714            #[cfg(feature = "metadata")]
715            meta_stash: None,
716            eos_forwarded: false,
717            push_wait_probe: None,
718            push_phase: PushPhase::Idle,
719        }
720    }
721
722    /// Bank this adapter's push-wait on `probe`, the producing element's (M947).
723    /// The arm calls this right after building the adapter, so the element's
724    /// `proc` percentiles report compute and its `push_wait` percentiles report
725    /// the backpressure it served.
726    pub(crate) fn set_push_wait_probe(&mut self, probe: Probe) {
727        self.push_wait_probe = probe;
728    }
729
730    /// Charge the time this push spent awaiting capacity to the producing
731    /// element's probe. `since` is the pre-send stamp, `None` when neither the
732    /// probe nor the edge counters asked for one.
733    fn record_push_wait(&self, since: Option<u64>) {
734        if let (Some(probe), Some(t0)) = (&self.push_wait_probe, since) {
735            probe.add_push_wait(stamp_now_ns().saturating_sub(t0));
736        }
737    }
738
739    /// Whether a blocking send through this adapter needs a pre-send stamp,
740    /// which the edge counters and the producer's probe each ask for.
741    fn wants_blocked_stamp(&self) -> bool {
742        self.link.counters.is_some() || self.push_wait_probe.is_some()
743    }
744
745    /// Whether an `Eos` has already been enqueued through this adapter (M909).
746    pub(crate) fn eos_forwarded(&self) -> bool {
747        self.eos_forwarded
748    }
749
750    /// Stash the propagated metadata set to attach to outgoing meta-empty
751    /// `DataFrame`s (M759). The transform arm replaces it on each new input
752    /// frame, passing `None` to clear it (a Drop verdict, so no stale set leaks).
753    // Only the graph runner's transform arm sets this, and that runner is std,
754    // so without std the setter would be dead code (which the workspace denies).
755    #[cfg(all(feature = "metadata", feature = "std"))]
756    pub(crate) fn set_meta_stash(&mut self, meta: Option<crate::meta::FrameMetaSet>) {
757        self.meta_stash = meta;
758    }
759
760    /// A handle to this link's probe slot, for installing/removing a
761    /// [`LinkInterceptor`] at runtime.
762    pub fn probe(&self) -> ProbeSlot {
763        self.probe.clone()
764    }
765
766    /// Make this adapter relay any downstream QoS report onto `upstream` (the
767    /// owning transform's input link) rather than surfacing it (M175). The
768    /// runner wires this so QoS propagates source-ward through a transform.
769    pub(crate) fn relay_qos_to(&mut self, upstream: QosSlot) {
770        self.upstream_qos = Some(upstream);
771    }
772
773    /// Relay onto the upstream link (M720) every downstream `Reconfigure` the
774    /// owning transform does not answer itself (`answered`), so a PLI or an
775    /// orientation advertisement crosses any number of pass-through transforms
776    /// to reach the encoder / the flip.
777    pub(crate) fn relay_reconfigure_to(
778        &mut self,
779        upstream: ReconfigureSlot,
780        answered: ReconfigureAnswered,
781    ) {
782        self.upstream_reconfigure = Some(upstream);
783        self.reconfigure_answered = answered;
784    }
785
786    /// Relay a downstream bitrate target onto the upstream link (M720).
787    pub(crate) fn relay_bitrate_to(&mut self, upstream: BitrateSlot) {
788        self.upstream_bitrate = Some(upstream);
789    }
790
791    /// Outcome to report once a packet has been enqueued: a pending reverse
792    /// signal (reconfigure first, then QoS), else `Accepted`. Reconfigure takes
793    /// priority because it is negotiation-critical; QoS is advisory. When a
794    /// relay target is set (a transform adapter), an observed QoS is forwarded
795    /// upstream and the outcome stays `Accepted` rather than surfacing `Qos`.
796    /// Drain a pending downstream reconfigure. A variant this adapter's
797    /// producer answers ([`ReconfigureAnswered`]) is returned for it to observe;
798    /// anything else goes onto the upstream link when a relay target is set
799    /// (M720 for a PLI, M1058 for an orientation advertisement), so it crosses
800    /// pass-through elements, and is otherwise dropped. Shared by the pre-send
801    /// check and the post-send outcome, which pass `pre_send` accordingly.
802    fn take_reconfigure_or_relay(&self, pre_send: bool) -> Option<crate::element::Reconfigure> {
803        use crate::element::Reconfigure;
804        let r = self.link.reconfigure.take()?;
805        let answered = match &r {
806            Reconfigure::ForceKeyframe => self.reconfigure_answered.keyframe,
807            Reconfigure::AbsorbOrientation => self.reconfigure_answered.orientation,
808            Reconfigure::Propose(_) | Reconfigure::Renegotiate => true,
809        };
810        if answered {
811            // A producer answering `AbsorbOrientation` sends the packet again,
812            // because the pre-send check holds it back. Surfacing the same
813            // signal after a send would have it resend a packet that already
814            // crossed, so hold it for the next push's pre-send check instead.
815            if !pre_send && matches!(r, Reconfigure::AbsorbOrientation) {
816                self.link.reconfigure.store(r);
817                return None;
818            }
819            return Some(r);
820        }
821        if let Some(upstream) = &self.upstream_reconfigure {
822            upstream.store(r);
823        }
824        None
825    }
826
827    fn post_send_outcome(&self) -> PushOutcome {
828        if let Some(r) = self.take_reconfigure_or_relay(false) {
829            return PushOutcome::Reconfigure(r);
830        }
831        if let Some(q) = self.link.qos.take() {
832            match &self.upstream_qos {
833                Some(upstream) => upstream.store(q),
834                None => return PushOutcome::Qos(q),
835            }
836        }
837        if let Some(bps) = self.link.bitrate.take() {
838            // Lowest priority: surfaced to the immediate producer, or relayed
839            // upstream past a non-consuming transform (M720).
840            match &self.upstream_bitrate {
841                Some(upstream) => upstream.store(bps),
842                None => return PushOutcome::Bitrate(bps),
843            }
844        }
845        PushOutcome::Accepted
846    }
847}
848
849impl SenderSink {
850    /// The blocking-send tail of a push: enqueue when capacity frees, then the
851    /// accounting and the post-send outcome. `stamped` says whether the Block
852    /// path pushed a transit stamp for this packet, so a dead link rolls back
853    /// exactly that.
854    fn poll_blocking_send(
855        &mut self,
856        cx: &mut core::task::Context<'_>,
857        packet: &mut Option<PipelinePacket>,
858        bytes: u64,
859        blocked_since: Option<u64>,
860        stamped: bool,
861    ) -> Poll<Result<PushOutcome, G2gError>> {
862        match self.link.data.poll_send(cx, packet) {
863            Poll::Pending => Poll::Pending,
864            // Post-send check covers the "request fired while we were
865            // awaiting capacity" window; the packet is already in the link
866            // under old caps.
867            Poll::Ready(Ok(())) => {
868                self.push_phase = PushPhase::Idle;
869                self.link.record_sent(bytes, blocked_since);
870                self.record_push_wait(blocked_since);
871                Poll::Ready(Ok(self.post_send_outcome()))
872            }
873            Poll::Ready(Err(SendError::Closed)) => {
874                self.push_phase = PushPhase::Idle;
875                if stamped {
876                    if let Some(ring) = &self.link.transit {
877                        ring.lock().pop_back();
878                    }
879                }
880                // The old by-value push dropped an unsent packet with its
881                // future; taking it here keeps that.
882                packet.take();
883                Poll::Ready(Err(G2gError::Shutdown))
884            }
885            Poll::Ready(Err(SendError::Full)) => unreachable!("poll_send never returns Full"),
886        }
887    }
888}
889
890impl OutputSink for SenderSink {
891    fn begin_push(&mut self) {
892        // A cancelled push may have parked mid-send; its packet died with its
893        // future, so the phase must not leak into this push.
894        self.push_phase = PushPhase::Idle;
895    }
896
897    fn poll_push(
898        &mut self,
899        cx: &mut core::task::Context<'_>,
900        packet_slot: &mut Option<PipelinePacket>,
901    ) -> Poll<Result<PushOutcome, G2gError>> {
902        if let PushPhase::Sending {
903            bytes,
904            blocked_since,
905            stamped,
906        } = self.push_phase
907        {
908            return self.poll_blocking_send(cx, packet_slot, bytes, blocked_since, stamped);
909        }
910        let packet = packet_slot
911            .as_mut()
912            .expect("poll_push called without a packet");
913        // M759: attach the arm's stashed propagated metadata to a fresh
914        // output frame (one whose own meta is empty), so a transform that
915        // emits new frames still carries the survivors forward.
916        // Element-authored meta is never overwritten.
917        #[cfg(feature = "metadata")]
918        if let (Some(stash), PipelinePacket::DataFrame(frame)) = (&self.meta_stash, &mut *packet) {
919            if frame.meta.is_empty() {
920                frame.meta = stash.clone();
921            }
922        }
923        // A probe may drop the packet before it ever enters the link.
924        if self.probe.action(packet) == ProbeAction::Drop {
925            packet_slot.take();
926            return Poll::Ready(Ok(PushOutcome::Accepted));
927        }
928        // Pre-send check: if downstream already requested a
929        // reconfigure, surface it before this packet enters the
930        // link. Caller renegotiates and decides what to do with
931        // `packet` (resend under agreed caps, drop, etc.). A relayed
932        // ForceKeyframe hops upstream instead (M720).
933        // An Eos is exempt: the producer that would resend the held-back packet
934        // has already finished, so holding one back loses it and the consumer
935        // waits for an end of stream that never comes.
936        if !matches!(packet, PipelinePacket::Eos) {
937            if let Some(r) = self.take_reconfigure_or_relay(true) {
938                packet_slot.take();
939                return Poll::Ready(Ok(PushOutcome::Reconfigure(r)));
940            }
941        }
942        // Past the pre-send checks the packet is committed to the link, so
943        // an Eos here is one the consumer will see (M909).
944        if matches!(packet, PipelinePacket::Eos) {
945            self.eos_forwarded = true;
946        }
947        // M980: keep the caps this link is carrying, so an observer reads the
948        // shape data actually flows under, not just the solved one.
949        if let (PipelinePacket::CapsChanged(caps), Some(c)) = (&*packet, &self.link.counters) {
950            c.record_caps(caps);
951        }
952        // The frame's age as its element emits it, the number that catches an
953        // element buffering frames internally. Skipped for unstamped frames.
954        #[cfg(feature = "std")]
955        if let (Some(probe), PipelinePacket::DataFrame(frame)) = (&self.push_wait_probe, &*packet) {
956            if frame.timing.arrival_ns != 0 {
957                probe.record_age_at_emit(stamp_now_ns().saturating_sub(frame.timing.arrival_ns));
958            }
959        }
960        // Leaky links drop *data frames* under a full channel rather than
961        // applying backpressure; control packets (caps / segment / flush /
962        // eos) are never dropped, they always block so the stream stays
963        // correct. A non-leaky link (the default) always blocks.
964        let is_data = matches!(packet, PipelinePacket::DataFrame(_));
965        // Measured before the send moves the packet into the link.
966        let bytes = packet_bytes(packet);
967        if is_data && self.link.policy != LinkPolicy::Block {
968            let taken = packet_slot.take().expect("packet checked above");
969            match self.link.policy {
970                LinkPolicy::DropNewest => match self.link.data.try_send(taken) {
971                    Ok(()) => self.link.record_sent(bytes, None),
972                    // Channel full: drop the incoming frame.
973                    Err((_dropped, SendError::Full)) => self.link.record_drop(),
974                    Err((_v, SendError::Closed)) => return Poll::Ready(Err(G2gError::Shutdown)),
975                },
976                LinkPolicy::DropOldest => match self.link.data.try_send(taken) {
977                    Ok(()) => self.link.record_sent(bytes, None),
978                    Err((returned, SendError::Full)) => {
979                        // Evict the oldest queued data frame to make room.
980                        // If only control packets are queued, fall back to
981                        // blocking rather than dropping a control packet.
982                        if self
983                            .link
984                            .data
985                            .evict_front_matching(|p| matches!(p, PipelinePacket::DataFrame(_)))
986                            .is_some()
987                        {
988                            self.link.record_drop();
989                            match self.link.data.try_send(returned) {
990                                Ok(()) => self.link.record_sent(bytes, None),
991                                Err((_v, SendError::Closed)) => {
992                                    return Poll::Ready(Err(G2gError::Shutdown))
993                                }
994                                Err((_v, SendError::Full)) => {
995                                    unreachable!("a slot was just freed by eviction")
996                                }
997                            }
998                        } else {
999                            *packet_slot = Some(returned);
1000                            let blocked_since = self.wants_blocked_stamp().then(stamp_now_ns);
1001                            self.push_phase = PushPhase::Sending {
1002                                bytes,
1003                                blocked_since,
1004                                stamped: false,
1005                            };
1006                            return self.poll_blocking_send(
1007                                cx,
1008                                packet_slot,
1009                                bytes,
1010                                blocked_since,
1011                                false,
1012                            );
1013                        }
1014                    }
1015                    Err((_v, SendError::Closed)) => return Poll::Ready(Err(G2gError::Shutdown)),
1016                },
1017                LinkPolicy::Block => unreachable!("guarded by policy != Block"),
1018            }
1019            return Poll::Ready(Ok(self.post_send_outcome()));
1020        }
1021        // Transit instrumentation (Block links only, where there are no
1022        // drops so the stamp ring stays aligned): stamp the frame's queue
1023        // entry before the send, roll back if it never enqueues.
1024        let stamped = is_data && self.link.transit.is_some();
1025        if stamped {
1026            if let Some(ring) = &self.link.transit {
1027                ring.lock().push_back(stamp_now_ns());
1028            }
1029        }
1030        // Stamp before the blocking send so the counters carry how long the
1031        // producer was held up by a full link (M846), and the producing
1032        // element's probe can take that wait out of its `process()` timing.
1033        let blocked_since = self.wants_blocked_stamp().then(stamp_now_ns);
1034        self.push_phase = PushPhase::Sending {
1035            bytes,
1036            blocked_since,
1037            stamped,
1038        };
1039        self.poll_blocking_send(cx, packet_slot, bytes, blocked_since, stamped)
1040    }
1041}
1042
1043#[cfg(test)]
1044mod link_tests {
1045    use super::*;
1046    use crate::caps::{Caps, Dim, Rate, VideoCodec};
1047    use crate::element::OutputSinkExt;
1048    use crate::frame::{Frame, FrameTiming};
1049    use crate::memory::{MemoryDomain, SystemSlice};
1050    use alloc::boxed::Box;
1051    use alloc::vec::Vec;
1052    use core::pin::Pin;
1053    use core::task::{Context, Poll, RawWaker, RawWakerVTable, Waker};
1054
1055    // Hand-rolled noop waker so this test module has no extra dev-dep.
1056    // The link's send/recv futures resolve in a single poll whenever
1057    // capacity is non-zero, so we never need to actually re-wake.
1058    static NOOP_VTABLE: RawWakerVTable = RawWakerVTable::new(
1059        |_| RawWaker::new(core::ptr::null(), &NOOP_VTABLE),
1060        |_| {},
1061        |_| {},
1062        |_| {},
1063    );
1064
1065    fn noop_waker() -> Waker {
1066        // SAFETY: NOOP_VTABLE's functions are all no-ops and never
1067        // dereference the data pointer; passing null is safe.
1068        unsafe { Waker::from_raw(RawWaker::new(core::ptr::null(), &NOOP_VTABLE)) }
1069    }
1070
1071    fn run_to_ready<F: core::future::Future>(mut fut: F) -> F::Output {
1072        let waker = noop_waker();
1073        let mut cx = Context::from_waker(&waker);
1074        // SAFETY: `fut` lives on the stack for the duration of this fn
1075        // and we never move it after pinning.
1076        let mut pinned = unsafe { Pin::new_unchecked(&mut fut) };
1077        match pinned.as_mut().poll(&mut cx) {
1078            Poll::Ready(v) => v,
1079            Poll::Pending => panic!("link_tests::run_to_ready saw Pending"),
1080        }
1081    }
1082
1083    fn dummy_frame() -> PipelinePacket {
1084        PipelinePacket::DataFrame(Frame {
1085            domain: MemoryDomain::System(SystemSlice::from_boxed(Box::new([0u8; 4]))),
1086            timing: FrameTiming::default(),
1087            sequence: 0,
1088            meta: Default::default(),
1089        })
1090    }
1091
1092    fn proposed_caps() -> Caps {
1093        Caps::CompressedVideo {
1094            codec: VideoCodec::H264,
1095            width: Dim::Fixed(1280),
1096            height: Dim::Fixed(720),
1097            framerate: Rate::Any,
1098        }
1099    }
1100
1101    #[test]
1102    fn push_returns_accepted_when_no_reconfigure_pending() {
1103        let (tx, _rx) = link(2);
1104        let mut sink = SenderSink::new(tx);
1105        let outcome = run_to_ready(sink.push(dummy_frame())).expect("send ok");
1106        assert_eq!(outcome, PushOutcome::Accepted);
1107    }
1108
1109    #[test]
1110    fn request_reconfigure_surfaces_on_next_push() {
1111        let (tx, rx) = link(2);
1112        let mut sink = SenderSink::new(tx);
1113
1114        // Downstream fires reconfigure before upstream pushes.
1115        rx.request_reconfigure(Reconfigure::Propose(proposed_caps()));
1116
1117        // Pre-send check intercepts: the packet is NOT enqueued, and
1118        // the producer sees Reconfigure so it can renegotiate before
1119        // any frame crosses under stale caps. Caller decides whether
1120        // to resend `packet` under agreed caps, drop it, or skip.
1121        let outcome = run_to_ready(sink.push(dummy_frame())).expect("push ok");
1122        match outcome {
1123            PushOutcome::Reconfigure(Reconfigure::Propose(c)) => {
1124                assert_eq!(c, proposed_caps());
1125            }
1126            other => panic!("expected Reconfigure::Propose, got {other:?}"),
1127        }
1128
1129        // Channel is empty — the rejected-caps packet was held back.
1130        assert!(
1131            rx.try_recv().is_none(),
1132            "packet must not enqueue when reconfigure pending"
1133        );
1134    }
1135
1136    #[test]
1137    fn second_push_returns_accepted_after_reconfigure_drained() {
1138        let (tx, rx) = link(2);
1139        let mut sink = SenderSink::new(tx);
1140
1141        rx.request_reconfigure(Reconfigure::Renegotiate);
1142        let first = run_to_ready(sink.push(dummy_frame())).unwrap();
1143        assert!(matches!(first, PushOutcome::Reconfigure(_)));
1144
1145        let second = run_to_ready(sink.push(dummy_frame())).unwrap();
1146        assert_eq!(second, PushOutcome::Accepted);
1147    }
1148
1149    #[test]
1150    fn request_qos_surfaces_after_the_packet_is_sent() {
1151        let (tx, rx) = link(2);
1152        let mut sink = SenderSink::new(tx);
1153
1154        // Downstream reports it is behind; QoS is advisory, so the packet still
1155        // crosses and the producer sees Qos on the same push.
1156        rx.request_qos(QosMessage {
1157            jitter_ns: 5_000_000,
1158            running_time_ns: 100,
1159        });
1160        let outcome = run_to_ready(sink.push(dummy_frame())).expect("push ok");
1161        match outcome {
1162            PushOutcome::Qos(q) => {
1163                assert_eq!(q.jitter_ns, 5_000_000);
1164                assert_eq!(q.running_time_ns, 100);
1165            }
1166            other => panic!("expected Qos, got {other:?}"),
1167        }
1168        // Unlike reconfigure, the packet was enqueued (QoS does not hold it back).
1169        assert!(
1170            rx.try_recv().is_some(),
1171            "QoS is advisory; the frame still flowed"
1172        );
1173    }
1174
1175    #[test]
1176    fn reconfigure_takes_priority_over_qos() {
1177        let (tx, rx) = link(2);
1178        let mut sink = SenderSink::new(tx);
1179
1180        // Both pending: negotiation correctness wins, QoS waits for the next push.
1181        rx.request_qos(QosMessage {
1182            jitter_ns: 1_000,
1183            running_time_ns: 0,
1184        });
1185        rx.request_reconfigure(Reconfigure::Renegotiate);
1186        let first = run_to_ready(sink.push(dummy_frame())).unwrap();
1187        assert!(
1188            matches!(first, PushOutcome::Reconfigure(_)),
1189            "reconfigure first"
1190        );
1191
1192        let second = run_to_ready(sink.push(dummy_frame())).unwrap();
1193        assert!(
1194            matches!(second, PushOutcome::Qos(_)),
1195            "QoS surfaces once reconfigure drained"
1196        );
1197    }
1198
1199    #[test]
1200    fn try_recv_returns_value_then_none() {
1201        let (tx, rx) = bounded::<u32>(2);
1202        assert_eq!(rx.try_recv(), None, "empty queue");
1203        tx.try_send(7).unwrap();
1204        assert_eq!(rx.try_recv(), Some(7));
1205        assert_eq!(rx.try_recv(), None, "drained");
1206    }
1207
1208    #[test]
1209    fn try_recv_drains_then_none_after_senders_drop() {
1210        let (tx, rx) = bounded::<u32>(2);
1211        tx.try_send(1).unwrap();
1212        drop(tx);
1213        assert_eq!(rx.try_recv(), Some(1), "remaining value still drains");
1214        assert_eq!(rx.try_recv(), None, "empty and closed");
1215    }
1216
1217    /// The adapter of a transform that answers keyframe requests but not the
1218    /// orientation advertisement: the advertisement crosses toward the source,
1219    /// the keyframe request stops at the element.
1220    #[test]
1221    fn relay_is_decided_per_variant() {
1222        let (up_tx, up_rx) = link(2);
1223        let (down_tx, down_rx) = link(2);
1224        // The upstream link's sender is never used; only its reverse slot is.
1225        drop(up_tx);
1226        let mut adapter = SenderSink::new(down_tx);
1227        adapter.relay_reconfigure_to(
1228            up_rx.reconfigure_slot(),
1229            ReconfigureAnswered {
1230                keyframe: true,
1231                orientation: false,
1232            },
1233        );
1234
1235        down_rx.request_reconfigure(Reconfigure::ForceKeyframe);
1236        let outcome = run_to_ready(adapter.push(dummy_frame())).expect("push ok");
1237        assert!(
1238            matches!(
1239                outcome,
1240                PushOutcome::Reconfigure(Reconfigure::ForceKeyframe)
1241            ),
1242            "an answered variant surfaces to the producer, got {outcome:?}"
1243        );
1244        assert!(
1245            up_rx.reconfigure.take().is_none(),
1246            "an answered variant must not also travel upstream"
1247        );
1248
1249        down_rx.request_reconfigure(Reconfigure::AbsorbOrientation);
1250        let outcome = run_to_ready(adapter.push(dummy_frame())).expect("push ok");
1251        assert_eq!(
1252            outcome,
1253            PushOutcome::Accepted,
1254            "an unanswered variant is relayed, not surfaced"
1255        );
1256        assert!(
1257            matches!(
1258                up_rx.reconfigure.take(),
1259                Some(Reconfigure::AbsorbOrientation)
1260            ),
1261            "the advertisement must reach the upstream link"
1262        );
1263        assert!(
1264            down_rx.try_recv().is_some(),
1265            "a relayed variant does not hold the packet back"
1266        );
1267    }
1268
1269    /// The mirror case: a `videoflip`'s adapter answers the advertisement and
1270    /// relays a keyframe request past itself toward the encoder.
1271    #[test]
1272    fn an_answered_orientation_surfaces_while_a_keyframe_relays() {
1273        let (up_tx, up_rx) = link(2);
1274        let (down_tx, down_rx) = link(2);
1275        drop(up_tx);
1276        let mut adapter = SenderSink::new(down_tx);
1277        adapter.relay_reconfigure_to(
1278            up_rx.reconfigure_slot(),
1279            ReconfigureAnswered {
1280                keyframe: false,
1281                orientation: true,
1282            },
1283        );
1284
1285        down_rx.request_reconfigure(Reconfigure::AbsorbOrientation);
1286        let outcome = run_to_ready(adapter.push(dummy_frame())).expect("push ok");
1287        assert!(
1288            matches!(
1289                outcome,
1290                PushOutcome::Reconfigure(Reconfigure::AbsorbOrientation)
1291            ),
1292            "the flip has to see the advertisement, got {outcome:?}"
1293        );
1294        assert!(
1295            down_rx.try_recv().is_none(),
1296            "the pre-send check holds the packet back for the producer to resend"
1297        );
1298
1299        down_rx.request_reconfigure(Reconfigure::ForceKeyframe);
1300        let outcome = run_to_ready(adapter.push(dummy_frame())).expect("push ok");
1301        assert_eq!(outcome, PushOutcome::Accepted);
1302        assert!(matches!(
1303            up_rx.reconfigure.take(),
1304            Some(Reconfigure::ForceKeyframe)
1305        ));
1306    }
1307
1308    /// A held-back packet is the producer's to send again, and nothing sends an
1309    /// end of stream twice: holding one back would leave the consumer waiting
1310    /// for a stream end that never comes. Eos skips the pre-send hold.
1311    #[test]
1312    fn an_eos_crosses_even_with_a_reconfigure_pending() {
1313        let (tx, rx) = link(2);
1314        let mut sink = SenderSink::new(tx);
1315        rx.request_reconfigure(Reconfigure::AbsorbOrientation);
1316        let outcome = run_to_ready(sink.push(PipelinePacket::Eos)).expect("push ok");
1317        assert_eq!(outcome, PushOutcome::Accepted);
1318        assert!(
1319            matches!(rx.try_recv(), Some(PipelinePacket::Eos)),
1320            "the end of stream must still reach the consumer"
1321        );
1322    }
1323
1324    /// Without a relay target (a source's adapter) an unanswered variant is
1325    /// dropped rather than surfaced: the pre-send check does not enqueue the
1326    /// packet it intercepts, so handing the signal to a producer that ignores it
1327    /// would cost that frame.
1328    #[test]
1329    fn an_unanswered_variant_without_a_relay_target_is_dropped() {
1330        let (tx, rx) = link(2);
1331        let mut adapter = SenderSink::new(tx);
1332        adapter.reconfigure_answered = ReconfigureAnswered {
1333            keyframe: true,
1334            orientation: false,
1335        };
1336
1337        rx.request_reconfigure(Reconfigure::AbsorbOrientation);
1338        let outcome = run_to_ready(adapter.push(dummy_frame())).expect("push ok");
1339        assert_eq!(outcome, PushOutcome::Accepted);
1340        assert!(rx.try_recv().is_some(), "the frame still crossed");
1341    }
1342
1343    #[test]
1344    fn latest_reconfigure_overwrites_older_pending() {
1345        let (tx, rx) = link(2);
1346        let mut sink = SenderSink::new(tx);
1347
1348        // Stale: must be overwritten by the next request.
1349        rx.request_reconfigure(Reconfigure::Renegotiate);
1350        rx.request_reconfigure(Reconfigure::Propose(proposed_caps()));
1351
1352        let outcome = run_to_ready(sink.push(dummy_frame())).unwrap();
1353        match outcome {
1354            PushOutcome::Reconfigure(Reconfigure::Propose(c)) => {
1355                assert_eq!(c, proposed_caps(), "newest proposal must win");
1356            }
1357            other => panic!("expected newest Propose, got {other:?}"),
1358        }
1359    }
1360
1361    fn frame_seq(seq: u64) -> PipelinePacket {
1362        PipelinePacket::DataFrame(Frame {
1363            domain: MemoryDomain::System(SystemSlice::from_boxed(Box::new([0u8; 4]))),
1364            timing: FrameTiming::default(),
1365            sequence: seq,
1366            meta: Default::default(),
1367        })
1368    }
1369
1370    /// Drops `DataFrame`s with an odd sequence number; passes everything else.
1371    struct DropOdd;
1372    impl LinkInterceptor for DropOdd {
1373        fn on_packet(&self, packet: &PipelinePacket) -> ProbeAction {
1374            match packet {
1375                PipelinePacket::DataFrame(f) if f.sequence % 2 == 1 => ProbeAction::Drop,
1376                _ => ProbeAction::Pass,
1377            }
1378        }
1379    }
1380
1381    #[test]
1382    fn installed_probe_drops_selected_packets() {
1383        let (tx, rx) = link(8);
1384        let mut sink = SenderSink::new(tx);
1385        sink.probe().install(Arc::new(DropOdd));
1386
1387        for seq in 0..4 {
1388            run_to_ready(sink.push(frame_seq(seq))).unwrap();
1389        }
1390
1391        let mut got = Vec::new();
1392        while let Some(PipelinePacket::DataFrame(f)) = rx.try_recv() {
1393            got.push(f.sequence);
1394        }
1395        assert_eq!(got, [0, 2], "odd-sequence frames dropped by the probe");
1396    }
1397
1398    #[test]
1399    fn removed_probe_lets_packets_pass_again() {
1400        let (tx, rx) = link(8);
1401        let mut sink = SenderSink::new(tx);
1402        let probe = sink.probe();
1403
1404        probe.install(Arc::new(DropOdd));
1405        run_to_ready(sink.push(frame_seq(1))).unwrap(); // dropped
1406        probe.remove();
1407        run_to_ready(sink.push(frame_seq(3))).unwrap(); // passes now
1408
1409        let mut got = Vec::new();
1410        while let Some(PipelinePacket::DataFrame(f)) = rx.try_recv() {
1411            got.push(f.sequence);
1412        }
1413        assert_eq!(got, [3], "after remove(), the odd frame passes");
1414    }
1415
1416    #[cfg(feature = "std")]
1417    fn drained_sequences(rx: &LinkReceiver) -> Vec<u64> {
1418        let mut got = Vec::new();
1419        while let Some(PipelinePacket::DataFrame(f)) = rx.try_recv() {
1420            got.push(f.sequence);
1421        }
1422        got
1423    }
1424
1425    // Per-edge drop policy is wired only by the std graph runner.
1426    #[cfg(feature = "std")]
1427    #[test]
1428    fn drop_newest_discards_incoming_when_full() {
1429        let (mut tx, rx) = link(2);
1430        tx.set_policy(LinkPolicy::DropNewest);
1431        let counter = Arc::new(Mutex::new(0u64));
1432        tx.set_drop_counter(counter.clone());
1433        let mut sink = SenderSink::new(tx);
1434
1435        // Fill capacity, then overflow: the incoming frame is dropped, the
1436        // queued ones survive.
1437        for seq in 0..2 {
1438            assert_eq!(
1439                run_to_ready(sink.push(frame_seq(seq))).unwrap(),
1440                PushOutcome::Accepted
1441            );
1442        }
1443        assert_eq!(
1444            run_to_ready(sink.push(frame_seq(2))).unwrap(),
1445            PushOutcome::Accepted
1446        );
1447
1448        assert_eq!(
1449            drained_sequences(&rx),
1450            [0, 1],
1451            "drop-newest keeps the oldest"
1452        );
1453        assert_eq!(*counter.lock(), 1);
1454    }
1455
1456    #[cfg(feature = "std")]
1457    #[test]
1458    fn drop_oldest_evicts_front_when_full() {
1459        let (mut tx, rx) = link(2);
1460        tx.set_policy(LinkPolicy::DropOldest);
1461        let counter = Arc::new(Mutex::new(0u64));
1462        tx.set_drop_counter(counter.clone());
1463        let mut sink = SenderSink::new(tx);
1464
1465        for seq in 0..2 {
1466            run_to_ready(sink.push(frame_seq(seq))).unwrap();
1467        }
1468        // Overflow evicts the oldest (seq 0) and enqueues the newcomer (seq 2).
1469        assert_eq!(
1470            run_to_ready(sink.push(frame_seq(2))).unwrap(),
1471            PushOutcome::Accepted
1472        );
1473
1474        assert_eq!(
1475            drained_sequences(&rx),
1476            [1, 2],
1477            "drop-oldest keeps the newest"
1478        );
1479        assert_eq!(*counter.lock(), 1);
1480    }
1481
1482    #[test]
1483    fn fill_percent_tracks_link_occupancy() {
1484        let (tx, rx) = link(4);
1485        assert_eq!(rx.fill_percent(), 0, "empty link reads 0%");
1486        let mut sink = SenderSink::new(tx);
1487        run_to_ready(sink.push(frame_seq(0))).unwrap();
1488        run_to_ready(sink.push(frame_seq(1))).unwrap();
1489        assert_eq!(rx.fill_percent(), 50, "2 of 4 slots = 50%");
1490        run_to_ready(sink.push(frame_seq(2))).unwrap();
1491        run_to_ready(sink.push(frame_seq(3))).unwrap();
1492        assert_eq!(rx.fill_percent(), 100, "full link reads 100%");
1493        rx.try_recv();
1494        assert_eq!(rx.fill_percent(), 75, "after one drain, 3 of 4 = 75%");
1495    }
1496
1497    #[cfg(feature = "std")]
1498    #[test]
1499    fn leaky_links_never_drop_control_packets() {
1500        // A capacity-1 leaky link, filled with a data frame. A control packet
1501        // must not be dropped: with the link full it blocks (Pending) instead.
1502        let (mut tx, rx) = link(1);
1503        tx.set_policy(LinkPolicy::DropNewest);
1504        let mut sink = SenderSink::new(tx);
1505        run_to_ready(sink.push(frame_seq(0))).unwrap();
1506
1507        let waker = noop_waker();
1508        let mut cx = Context::from_waker(&waker);
1509        let mut fut = core::pin::pin!(sink.push(PipelinePacket::CapsChanged(proposed_caps())));
1510        assert!(
1511            matches!(fut.as_mut().poll(&mut cx), Poll::Pending),
1512            "a control packet blocks on a full leaky link, never dropped"
1513        );
1514
1515        // The queued data frame is untouched.
1516        assert_eq!(drained_sequences(&rx), [0]);
1517    }
1518}