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}