Skip to main content

g2g_core/
spsc.rs

1//! Single-producer / single-consumer capture ring: the heap-free hand-off across
2//! the interrupt boundary a real MCU capture needs. A DMA-completion (or timer)
3//! ISR produces frames into the ring in *interrupt context*; the pipeline, in
4//! the main/task context, consumes them, concurrently and lock-free. This is the
5//! piece the synchronous [`StaticLendRing`](crate::staticpool::StaticLendRing)
6//! lend model (single cooperative context) does not cover: there the same task
7//! fills and drains; here the producer and consumer run in genuinely different
8//! execution contexts.
9//!
10//! It is a FIFO ring (unlike the lend pool's any-order `acquire`), so frames are
11//! consumed in capture order, and a fixed `N` slots make it heap-free. Only the
12//! producer writes `tail` and any slot it fills; only the consumer writes `head`
13//! and reads the published slots; the `head`/`tail` Acquire/Release stores order
14//! the slot bytes across the boundary, so an ISR producer and a main-context
15//! consumer never race a slot. It uses only atomic load/store (no compare-and-
16//! swap), so it builds on Cortex-M targets without atomic CAS (e.g. `thumbv6m`),
17//! matching the rest of the no-alloc path.
18//!
19//! Back-pressure is explicit and non-blocking, because a producer in an ISR must
20//! not block: if the consumer falls behind and the ring fills, [`produce`] drops
21//! the frame and bumps an overrun counter the consumer can read
22//! ([`overruns`](SpscFrameRing::overruns)), rather than stalling the interrupt.
23//!
24//! [`produce`]: SpscFrameRing::produce
25
26use crate::sync::{AtomicU32, AtomicUsize, Ordering, UnsafeCell};
27
28// The consumer lend (`borrow`) and the `SpscCaptureSrc` source are not built
29// under loom (the zero-copy raw-pointer lend cannot be modeled), so their
30// supporting imports are gated with it to stay used in every configuration.
31#[cfg(not(loom))]
32use crate::error::G2gError;
33#[cfg(not(loom))]
34use crate::frame::{Frame, FrameTiming};
35#[cfg(not(loom))]
36use crate::memory::{MemoryDomain, SystemSlice};
37#[cfg(not(loom))]
38use crate::staticelem::StaticSource;
39#[cfg(not(loom))]
40use crate::supervise::Recover;
41
42/// [`SpscFrameRing::produce`] found the ring full (the consumer is behind), so
43/// this call could not enqueue. For the canonical ISR producer, which attempts
44/// each frame once and does not retry, that means the frame was dropped; it is
45/// counted in [`SpscFrameRing::overruns`]. (A producer that instead retries the
46/// same frame will see this per full-ring event, not per lost frame.)
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub struct Overrun;
49
50/// A fixed-capacity SPSC FIFO of `BYTES`-sized frames for the ISR-to-pipeline
51/// capture hand-off. `N` slots live inline (no `alloc`); usable capacity is
52/// `N - 1` (one slot separates a full ring from an empty one), so `N >= 2`, and
53/// `N >= 3` gives a double-buffer plus one frame in flight.
54pub struct SpscFrameRing<const N: usize, const BYTES: usize> {
55    slots: [UnsafeCell<[u8; BYTES]>; N],
56    /// Consumer cursor: index of the next slot to consume. Only the consumer
57    /// writes it; the producer reads it (Acquire) for the full check.
58    head: AtomicUsize,
59    /// Producer cursor: index of the next slot to fill. Only the producer writes
60    /// it (Release, to publish); the consumer reads it (Acquire) for emptiness.
61    tail: AtomicUsize,
62    /// Frames the producer dropped on a full ring. Only the producer writes it
63    /// (it is the sole producer, so a load+store increment needs no CAS).
64    overruns: AtomicU32,
65}
66
67// SAFETY: strict single-producer / single-consumer. The producer is the sole
68// writer of `tail` and of any slot it fills; the consumer is the sole writer of
69// `head` and the sole reader of the published slots [head, tail). A slot is
70// filled only when free (the full check keeps `tail` from catching `head`, so
71// the producer's slot and the consumer's slot are always distinct) and read only
72// once published. The producer's slot write is ordered before its `tail` Release
73// store, which the consumer's `tail` Acquire load synchronizes-with before it
74// reads the slot; symmetrically the consumer's `head` Release store frees a slot
75// the producer's `head` Acquire load observes before reuse. So an ISR producer
76// and a main-context consumer never form a data race. Only atomic load/store is
77// used (no CAS), so it builds on targets without atomic CAS (e.g. `thumbv6m`).
78unsafe impl<const N: usize, const BYTES: usize> Sync for SpscFrameRing<N, BYTES> {}
79
80impl<const N: usize, const BYTES: usize> SpscFrameRing<N, BYTES> {
81    // Associated const as the array-repeat operand: the MSRV-1.75 way to build
82    // the slot array in a `const fn` (inline-const repeat needs 1.79). Copying a
83    // fresh zeroed slot into each array element is exactly the intent.
84    #[cfg(not(loom))]
85    #[allow(clippy::declare_interior_mutable_const)]
86    const EMPTY_SLOT: UnsafeCell<[u8; BYTES]> = UnsafeCell::new([0u8; BYTES]);
87
88    /// Build an empty ring. `const`, so it lives in a `static` (the DMA-ring
89    /// idiom) shared between the producer ISR and the consumer. `N >= 2`.
90    #[cfg(not(loom))]
91    pub const fn new() -> Self {
92        Self {
93            slots: [Self::EMPTY_SLOT; N],
94            head: AtomicUsize::new(0),
95            tail: AtomicUsize::new(0),
96            overruns: AtomicU32::new(0),
97        }
98    }
99
100    /// loom build only: loom's atomics / cells are not const-constructible, so
101    /// build the slots at run time. Same empty ring, model-checkable.
102    #[cfg(loom)]
103    pub fn new() -> Self {
104        Self {
105            slots: core::array::from_fn(|_| UnsafeCell::new([0u8; BYTES])),
106            head: AtomicUsize::new(0),
107            tail: AtomicUsize::new(0),
108            overruns: AtomicU32::new(0),
109        }
110    }
111
112    /// Slot count (the const `N`); usable capacity is `N - 1`.
113    pub const fn capacity(&self) -> usize {
114        N
115    }
116
117    /// Full-ring events: [`produce`](Self::produce) calls that found no free
118    /// slot. For the canonical ISR producer (one attempt per frame, no retry)
119    /// this is the count of frames dropped to back-pressure.
120    pub fn overruns(&self) -> u32 {
121        self.overruns.load(Ordering::Relaxed)
122    }
123
124    /// True if the ring currently holds no published frames.
125    pub fn is_empty(&self) -> bool {
126        self.head.load(Ordering::Acquire) == self.tail.load(Ordering::Acquire)
127    }
128
129    /// The next index after `i`, wrapping at `N`. `i` is always `< N` (the
130    /// cursors only advance through here), so this both wraps and keeps them in
131    /// range without a bounds panic.
132    fn wrap(i: usize) -> usize {
133        if i + 1 >= N {
134            0
135        } else {
136            i + 1
137        }
138    }
139
140    /// PRODUCER side (call from exactly one context, e.g. a DMA-completion ISR):
141    /// fill the next free slot via `fill` and publish it to the consumer.
142    ///
143    /// Returns `Err(Overrun)` if the ring is full (the consumer is behind): the
144    /// frame is dropped and counted, never blocked on, because a producer in an
145    /// interrupt cannot wait.
146    pub fn produce(&self, fill: impl FnOnce(&mut [u8; BYTES])) -> Result<(), Overrun> {
147        let tail = self.tail.load(Ordering::Relaxed); // sole producer owns tail
148        let next = Self::wrap(tail);
149        if next == self.head.load(Ordering::Acquire) {
150            // Full: drop and count (single producer, so a load+store bump on the
151            // counter needs no CAS and cannot lose an update).
152            self.overruns.store(
153                self.overruns.load(Ordering::Relaxed).wrapping_add(1),
154                Ordering::Relaxed,
155            );
156            return Err(Overrun);
157        }
158        let Some(cell) = self.slots.get(tail) else {
159            // `tail` is always `< N`; unreachable, but never panic on a bad index.
160            return Err(Overrun);
161        };
162        // SAFETY: slot[tail] is free (outside the published range [head, tail),
163        // guaranteed by the full check above), and the producer is its sole
164        // writer until the Release store below publishes it.
165        cell.with_mut(|ptr| fill(unsafe { &mut *ptr }));
166        self.tail.store(next, Ordering::Release); // publish
167        Ok(())
168    }
169
170    /// CONSUMER side: borrow the oldest published frame zero-copy as a
171    /// [`SystemSlice`], or `None` if the ring is empty. The borrow aliases the
172    /// ring slot (no copy); it stays valid until [`release`](Self::release)
173    /// advances past it.
174    ///
175    /// Contract: borrow at most one frame at a time and [`release`] it before the
176    /// next borrow, after the frame (and its slice) is dropped, the single-frame-
177    /// in-flight discipline the static runners already follow (each frame is
178    /// dropped before the next `next()`). Releasing a still-referenced slice
179    /// would let the producer reuse the slot under the reader.
180    ///
181    /// Not built under `--cfg loom`: the lend hands out a raw pointer that
182    /// outlives any scoped cell access, which loom's `UnsafeCell` cannot model.
183    /// The loom consumer reads through [`Self::loom_peek0`] instead.
184    #[cfg(not(loom))]
185    pub fn borrow(&self) -> Option<SystemSlice> {
186        let head = self.head.load(Ordering::Relaxed); // sole consumer owns head
187        if head == self.tail.load(Ordering::Acquire) {
188            return None; // empty
189        }
190        let ptr = self.slots.get(head)?.get() as *const u8;
191        // SAFETY: slot[head] was fully written by the producer before the
192        // `tail` Release store this Acquire load observed, so the bytes are
193        // valid and stable; the producer will not reuse this slot until
194        // `release` advances `head` past it (the full check), so the read-only
195        // lend stays valid. `free` is None: the consumer reclaims the slot
196        // explicitly via `release`, not on the slice's drop.
197        Some(unsafe { SystemSlice::from_foreign(ptr, BYTES, None, core::ptr::null_mut()) })
198    }
199
200    /// loom build only: the consumer read the SPSC test uses in place of the
201    /// zero-copy [`borrow`](Self::borrow) lend. It returns the oldest published
202    /// frame's first byte (`None` if empty) through a scoped cell access, so
203    /// loom tracks the read and flags any overlap with the producer's write.
204    /// Same head/tail protocol as `borrow`.
205    #[cfg(loom)]
206    pub fn loom_peek0(&self) -> Option<u8> {
207        let head = self.head.load(Ordering::Relaxed);
208        if head == self.tail.load(Ordering::Acquire) {
209            return None; // empty
210        }
211        self.slots
212            .get(head)
213            .map(|cell| cell.with(|ptr| unsafe { (*ptr)[0] }))
214    }
215
216    /// CONSUMER side: reclaim the slot last returned by [`borrow`], freeing it for
217    /// the producer to refill. Call once per consumed frame, after the frame is
218    /// dropped. A `release` with nothing borrowed is a no-op.
219    pub fn release(&self) {
220        let head = self.head.load(Ordering::Relaxed);
221        if head != self.tail.load(Ordering::Acquire) {
222            self.head.store(Self::wrap(head), Ordering::Release);
223        }
224    }
225}
226
227impl<const N: usize, const BYTES: usize> Default for SpscFrameRing<N, BYTES> {
228    fn default() -> Self {
229        Self::new()
230    }
231}
232
233impl<const N: usize, const BYTES: usize> core::fmt::Debug for SpscFrameRing<N, BYTES> {
234    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
235        f.debug_struct("SpscFrameRing")
236            .field("capacity", &N)
237            .field("slot_bytes", &BYTES)
238            .field("empty", &self.is_empty())
239            .field("overruns", &self.overruns())
240            .finish()
241    }
242}
243
244/// The consumer side of an [`SpscFrameRing`] as a [`StaticSource`]: it drains the
245/// ring the producer ISR fills, yielding each captured frame zero-copy (the frame
246/// borrows the ring slot; the slot is reclaimed when the runner drops the frame
247/// and the next `next()` releases it). While the ring is empty it calls the
248/// caller-supplied `idle` hook and retries, so the consumer sleeps instead of
249/// spinning: on hardware `idle` is `cortex_m::asm::wfi` (wait for the capture
250/// interrupt), in a host test a yield/`spin_loop`. This is the ISR-driven capture
251/// source, the concurrent twin of the synchronous `GrabberSrc`.
252///
253/// Single frame in flight (the static runners drop each frame before the next
254/// `next()`), which is what makes the zero-copy borrow sound: the borrowed slot
255/// is released only after its frame is gone.
256#[cfg(not(loom))]
257pub struct SpscCaptureSrc<'r, I, const N: usize, const BYTES: usize> {
258    ring: &'r SpscFrameRing<N, BYTES>,
259    idle: I,
260    frame_interval_ns: u64,
261    remaining: Option<u32>,
262    seq: u64,
263    holding: bool,
264}
265
266#[cfg(not(loom))]
267impl<'r, I: FnMut(), const N: usize, const BYTES: usize> SpscCaptureSrc<'r, I, N, BYTES> {
268    /// A capture source draining `ring` (filled by a producer in another context,
269    /// e.g. a DMA/timer ISR). `idle` runs while waiting for the producer to
270    /// publish a frame, `cortex_m::asm::wfi` on hardware (sleep until the next
271    /// interrupt), a yield or `core::hint::spin_loop` in a host test.
272    /// `frame_interval_ns` sets the derived PTS cadence.
273    pub fn new(ring: &'r SpscFrameRing<N, BYTES>, idle: I, frame_interval_ns: u64) -> Self {
274        Self {
275            ring,
276            idle,
277            frame_interval_ns,
278            remaining: None,
279            seq: 0,
280            holding: false,
281        }
282    }
283
284    /// End the stream after `frames` captures (a capture is endless by default).
285    pub fn with_frame_limit(mut self, frames: u32) -> Self {
286        self.remaining = Some(frames);
287        self
288    }
289}
290
291#[cfg(not(loom))]
292impl<I: FnMut(), const N: usize, const BYTES: usize> StaticSource
293    for SpscCaptureSrc<'_, I, N, BYTES>
294{
295    async fn next(&mut self) -> Result<Option<Frame>, G2gError> {
296        // The frame from the previous `next()` has been consumed and dropped by
297        // the runner; reclaim its ring slot for the producer.
298        if self.holding {
299            self.ring.release();
300            self.holding = false;
301        }
302        if let Some(remaining) = &mut self.remaining {
303            if *remaining == 0 {
304                return Ok(None);
305            }
306            *remaining -= 1;
307        }
308        // Wait for the producer (ISR) to publish a frame, idling (WFI) meanwhile.
309        loop {
310            if let Some(slice) = self.ring.borrow() {
311                self.holding = true;
312                let pts_ns = self.seq.saturating_mul(self.frame_interval_ns);
313                let frame = Frame::new(
314                    MemoryDomain::System(slice),
315                    FrameTiming {
316                        pts_ns,
317                        ..FrameTiming::default()
318                    },
319                    self.seq,
320                );
321                self.seq = self.seq.wrapping_add(1);
322                return Ok(Some(frame));
323            }
324            (self.idle)();
325        }
326    }
327}
328
329#[cfg(not(loom))]
330impl<I: FnMut(), const N: usize, const BYTES: usize> Recover for SpscCaptureSrc<'_, I, N, BYTES> {
331    /// Recover a capture source after a fault by dropping any stale buffered
332    /// frames, so the pipeline resumes from live data instead of replaying a
333    /// backlog that accumulated while the fault was handled (the real-time
334    /// choice for a display / egress path). Bounded by the ring capacity: the
335    /// producer ISR can refill during the drain, but at most `N` slots exist,
336    /// so this cannot spin.
337    async fn recover(&mut self) -> Result<(), G2gError> {
338        if self.holding {
339            self.ring.release();
340            self.holding = false;
341        }
342        for _ in 0..N {
343            if self.ring.is_empty() {
344                break;
345            }
346            self.ring.release();
347        }
348        Ok(())
349    }
350}
351
352#[cfg(not(loom))]
353impl<I, const N: usize, const BYTES: usize> core::fmt::Debug for SpscCaptureSrc<'_, I, N, BYTES> {
354    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
355        f.debug_struct("SpscCaptureSrc")
356            .field("slots", &N)
357            .field("slot_bytes", &BYTES)
358            .field("frame_interval_ns", &self.frame_interval_ns)
359            .field("remaining", &self.remaining)
360            .field("seq", &self.seq)
361            .finish_non_exhaustive()
362    }
363}
364
365// The existing single-threaded tests exercise `borrow`, which is not built under
366// loom; the loom model test below covers the concurrent protocol instead.
367#[cfg(all(test, not(loom)))]
368mod tests {
369    use super::*;
370
371    /// The first payload byte of the currently-borrowed frame, or `None` if empty.
372    fn peek<const N: usize, const B: usize>(ring: &SpscFrameRing<N, B>) -> Option<u8> {
373        ring.borrow().map(|s| s.as_slice()[0])
374    }
375
376    #[test]
377    fn fifo_order_and_capacity() {
378        let ring: SpscFrameRing<4, 2> = SpscFrameRing::new();
379        assert_eq!(ring.capacity(), 4);
380        assert!(ring.is_empty());
381        // Fill to usable capacity (N-1 = 3).
382        for k in 0..3u8 {
383            ring.produce(|b| b[0] = k + 1).expect("space");
384        }
385        assert!(!ring.is_empty());
386        // Consume in capture order: 1, 2, 3.
387        for k in 0..3u8 {
388            assert_eq!(peek(&ring), Some(k + 1), "FIFO order");
389            ring.release();
390        }
391        assert!(ring.is_empty());
392        assert_eq!(ring.overruns(), 0);
393    }
394
395    #[test]
396    fn full_ring_drops_and_counts_overruns() {
397        let ring: SpscFrameRing<3, 1> = SpscFrameRing::new(); // usable capacity 2
398        assert!(ring.produce(|b| b[0] = 10).is_ok());
399        assert!(ring.produce(|b| b[0] = 20).is_ok());
400        // Third produce with no consume: full, dropped, counted.
401        assert_eq!(ring.produce(|b| b[0] = 30), Err(Overrun));
402        assert_eq!(ring.overruns(), 1);
403        // The dropped frame never entered the FIFO: consumer still sees 10, 20.
404        assert_eq!(peek(&ring), Some(10));
405        ring.release();
406        // A slot freed; the producer can enqueue again (the newest, 40).
407        assert!(ring.produce(|b| b[0] = 40).is_ok());
408        assert_eq!(peek(&ring), Some(20));
409        ring.release();
410        assert_eq!(peek(&ring), Some(40));
411        ring.release();
412        assert!(ring.is_empty());
413    }
414
415    #[test]
416    fn interleaved_produce_consume_wraps_around() {
417        // Cycle many more frames than N through the ring, one in flight at a
418        // time (the pipeline's single-frame discipline), forcing several wraps.
419        let ring: SpscFrameRing<3, 1> = SpscFrameRing::new();
420        for k in 0..20u8 {
421            ring.produce(|b| b[0] = k).expect("space (one in flight)");
422            assert_eq!(
423                peek(&ring),
424                Some(k),
425                "each frame consumed in order across wraps"
426            );
427            ring.release();
428        }
429        assert!(ring.is_empty());
430        assert_eq!(ring.overruns(), 0);
431    }
432
433    #[test]
434    fn release_without_borrow_is_a_noop() {
435        let ring: SpscFrameRing<2, 1> = SpscFrameRing::new();
436        ring.release(); // empty: must not corrupt the cursor
437        assert!(ring.is_empty());
438        ring.produce(|b| b[0] = 7).expect("space");
439        assert_eq!(peek(&ring), Some(7));
440    }
441}
442
443// Loom model check of the hand-rolled no-CAS Acquire/Release protocol: a producer
444// thread fills the ring while a consumer thread drains it, and loom explores every
445// interleaving. Run with `tools/loom-spsc.sh` (RUSTFLAGS="--cfg loom"). The scoped
446// cell accesses (`with_mut` on produce, `.with` on `loom_peek0`) let loom flag any
447// producer/consumer overlap on a slot; the value asserts catch a lost, duplicated,
448// or reordered frame.
449#[cfg(all(test, loom))]
450mod loom_tests {
451    use super::*;
452    use loom::sync::Arc;
453    use loom::thread;
454
455    // Frames pushed through the ring. `FRAMES > N` forces the cursors to wrap and
456    // reuse slots, exercising the full check (the producer must not refill a slot
457    // the consumer still holds). Kept small so loom's interleaving search stays
458    // tractable; every frame still flows through because the producer retries on a
459    // full ring rather than dropping (real backpressure, not the ISR drop policy).
460    const FRAMES: u8 = 4;
461
462    #[test]
463    fn producer_consumer_handoff_preserves_fifo_without_races() {
464        loom::model(|| {
465            // usable capacity 2 (N = 3); FRAMES = 4 wraps twice.
466            let ring = Arc::new(SpscFrameRing::<3, 1>::new());
467            let producer = {
468                let ring = ring.clone();
469                thread::spawn(move || {
470                    for v in 1..=FRAMES {
471                        // retry on a full ring so no frame is dropped: the consumer
472                        // must free a slot first, which is the full-check handshake.
473                        while ring.produce(|b| b[0] = v).is_err() {
474                            thread::yield_now();
475                        }
476                    }
477                })
478            };
479            // The consumer must receive every frame exactly once, in order: the
480            // handoff loses, duplicates, or reorders nothing. It spins (the ISR
481            // capture source's idle) until each expected frame is published; a
482            // wrong full check would let the producer write a slot the consumer is
483            // mid-read of, which loom flags as a concurrent cell access.
484            for expected in 1..=FRAMES {
485                loop {
486                    if let Some(v) = ring.loom_peek0() {
487                        assert_eq!(v, expected, "FIFO: exactly one of each, in order");
488                        ring.release();
489                        break;
490                    }
491                    thread::yield_now();
492                }
493            }
494            producer.join().unwrap();
495        });
496    }
497}