Skip to main content

g2g_core/
staticpool.rs

1//! Strict no-heap buffer pool for `no_std` RTOS targets that cannot tolerate
2//! `alloc`, the counterpart of [`crate::pool::BufferPool`] (which is
3//! `Arc`/`Vec`-backed). Sized at construction with a fixed `[T; N]`; acquiring
4//! moves a buffer out and the RAII handle returns it on drop. Pure `core`: no
5//! `alloc`, no `Arc`, no OS.
6//!
7//! `!Sync` (a `RefCell` free list), which suits the single-core cooperative
8//! Embassy executor: tasks share the pool by reference and never run in
9//! parallel, so the borrows are always short and non-overlapping. The async
10//! `acquire` parks a single waiter; a multi-consumer pool must poll
11//! `try_acquire` instead.
12
13use core::cell::{RefCell, UnsafeCell};
14use core::ffi::c_void;
15use core::fmt;
16use core::future::Future;
17use core::ops::{Deref, DerefMut};
18use core::pin::Pin;
19use core::sync::atomic::{AtomicBool, Ordering};
20use core::task::{Context, Poll, Waker};
21
22use crate::memory::SystemSlice;
23
24#[derive(Debug)]
25pub struct StaticBufferPool<T, const N: usize> {
26    inner: RefCell<Inner<T, N>>,
27}
28
29#[derive(Debug)]
30struct Inner<T, const N: usize> {
31    slots: [Option<T>; N],
32    waker: Option<Waker>,
33}
34
35impl<T, const N: usize> StaticBufferPool<T, N> {
36    /// Build a pool from `N` pre-allocated buffers; capacity is fixed at `N`.
37    pub fn new(buffers: [T; N]) -> Self {
38        Self {
39            inner: RefCell::new(Inner {
40                slots: buffers.map(Some),
41                waker: None,
42            }),
43        }
44    }
45
46    /// Capacity (the const `N`).
47    pub fn capacity(&self) -> usize {
48        N
49    }
50
51    /// Buffers currently available for acquisition.
52    pub fn available(&self) -> usize {
53        self.inner
54            .borrow()
55            .slots
56            .iter()
57            .filter(|s| s.is_some())
58            .count()
59    }
60
61    /// Buffers currently checked out (`capacity - available`).
62    pub fn outstanding(&self) -> usize {
63        N - self.available()
64    }
65
66    /// Try to check out one buffer; `None` if the pool is exhausted.
67    pub fn try_acquire(&self) -> Option<StaticPooled<'_, T, N>> {
68        let mut inner = self.inner.borrow_mut();
69        for slot in inner.slots.iter_mut() {
70            if let Some(value) = slot.take() {
71                return Some(StaticPooled {
72                    pool: self,
73                    value: Some(value),
74                });
75            }
76        }
77        None
78    }
79
80    /// Acquire one buffer, awaiting until one is free. Parks a single waiter
81    /// (the embedded single-consumer model); use [`Self::try_acquire`] for
82    /// multi-consumer pools.
83    pub fn acquire(&self) -> StaticAcquire<'_, T, N> {
84        StaticAcquire { pool: self }
85    }
86
87    /// Return a buffer to the first free slot and wake the parked acquirer.
88    fn release(&self, value: T) {
89        // Take the waker out before releasing the borrow, then wake outside it
90        // so a re-entrant acquire can't hit a double borrow.
91        let waker = {
92            let mut inner = self.inner.borrow_mut();
93            for slot in inner.slots.iter_mut() {
94                if slot.is_none() {
95                    *slot = Some(value);
96                    break;
97                }
98            }
99            inner.waker.take()
100        };
101        if let Some(w) = waker {
102            w.wake();
103        }
104    }
105}
106
107#[derive(Debug)]
108pub struct StaticPooled<'a, T, const N: usize> {
109    pool: &'a StaticBufferPool<T, N>,
110    value: Option<T>,
111}
112
113impl<T, const N: usize> Deref for StaticPooled<'_, T, N> {
114    type Target = T;
115    fn deref(&self) -> &T {
116        self.value
117            .as_ref()
118            .expect("StaticPooled accessed after drop")
119    }
120}
121
122impl<T, const N: usize> DerefMut for StaticPooled<'_, T, N> {
123    fn deref_mut(&mut self) -> &mut T {
124        self.value
125            .as_mut()
126            .expect("StaticPooled accessed after drop")
127    }
128}
129
130impl<T: AsRef<[u8]>, const N: usize> AsRef<[u8]> for StaticPooled<'_, T, N> {
131    fn as_ref(&self) -> &[u8] {
132        self.deref().as_ref()
133    }
134}
135
136impl<T: AsMut<[u8]>, const N: usize> AsMut<[u8]> for StaticPooled<'_, T, N> {
137    fn as_mut(&mut self) -> &mut [u8] {
138        self.deref_mut().as_mut()
139    }
140}
141
142impl<T, const N: usize> Drop for StaticPooled<'_, T, N> {
143    fn drop(&mut self) {
144        if let Some(v) = self.value.take() {
145            self.pool.release(v);
146        }
147    }
148}
149
150#[allow(missing_debug_implementations)]
151pub struct StaticAcquire<'a, T, const N: usize> {
152    pool: &'a StaticBufferPool<T, N>,
153}
154
155impl<'a, T, const N: usize> Future for StaticAcquire<'a, T, N> {
156    type Output = StaticPooled<'a, T, N>;
157
158    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
159        match self.pool.try_acquire() {
160            Some(buf) => Poll::Ready(buf),
161            None => {
162                self.pool.inner.borrow_mut().waker = Some(cx.waker().clone());
163                Poll::Pending
164            }
165        }
166    }
167}
168
169/// A fixed, heap-free ring of byte buffers that lends each slot to the pipeline
170/// *zero-copy* as a [`SystemSlice`], the capture-side sibling of
171/// [`StaticBufferPool`] (which moves an owned buffer out; this keeps the bytes in
172/// place and lends a borrow). It models a DMA capture ring: `N` slots of `BYTES`
173/// bytes live inline (no `alloc`), the producer fills the next free slot and
174/// [`publish`](RingSlot::publish)es it as a frame that *borrows* the slot, and the
175/// slot is reclaimed when that frame is dropped downstream (the lend's free
176/// callback clears the lease). A slot is never reused while still lent, so the
177/// producer stalls when every slot is in flight, the genuine ring back-pressure.
178///
179/// The borrow is runtime-guarded, not a Rust lifetime: a `PipelinePacket` crosses
180/// the `OutputSink` / stack-channel boundary by value (`'static`), so the lent
181/// slice is the `'static` foreign-lend ([`SystemSlice::from_foreign`]) with the
182/// lease standing in for the borrow. `new()` is not `const`; place the ring in a
183/// `StaticCell` (or a `static` via a const-init wrapper) on real hardware, or keep
184/// it alive on the stack for the duration of a `block_on` pipeline.
185pub struct StaticLendRing<const N: usize, const BYTES: usize> {
186    slots: [UnsafeCell<[u8; BYTES]>; N],
187    leased: [AtomicBool; N],
188}
189
190// SAFETY: interior mutability of `slots` is guarded by the per-slot `leased`
191// flags. A slot is written only through the unique `RingSlot` that holds its
192// lease (between acquire and publish) and is read-only once published until its
193// lease clears, so there is never an aliasing `&`/`&mut` to the same slot. The
194// flags are independent atomics written with plain store (no RMW), so a
195// DMA-completion ISR clearing one slot's lease never races a store to another and
196// the type builds on targets without atomic CAS (eg `thumbv6m`). Acquire's
197// scan-then-set is not atomic, so the single-executor capture contract holds: one
198// task *sets* leases; only *clears* (a frame drop, or an ISR) may come from
199// elsewhere.
200unsafe impl<const N: usize, const BYTES: usize> Sync for StaticLendRing<N, BYTES> {}
201
202impl<const N: usize, const BYTES: usize> StaticLendRing<N, BYTES> {
203    // Associated consts as array-repeat operands: the MSRV-1.75 way to build
204    // the arrays in a `const fn` (inline-const repeat needs 1.79). The lint
205    // warns because writing through a const copies, but copying fresh values
206    // into the array is exactly the intent; nothing ever reads these consts.
207    #[allow(clippy::declare_interior_mutable_const)]
208    const EMPTY_SLOT: UnsafeCell<[u8; BYTES]> = UnsafeCell::new([0u8; BYTES]);
209    #[allow(clippy::declare_interior_mutable_const)]
210    const UNLEASED: AtomicBool = AtomicBool::new(false);
211
212    /// Build a ring of `N` zeroed `BYTES`-sized slots. `const`, so the ring
213    /// can live in a `static` (the DMA-ring idiom), which is also what makes
214    /// the zero-copy lend safe without `unsafe` in application code: a
215    /// `&'static` ring trivially outlives every published frame (see e.g.
216    /// `g2g-mcu`'s `GrabberSrc::new`).
217    pub const fn new() -> Self {
218        Self {
219            slots: [Self::EMPTY_SLOT; N],
220            leased: [Self::UNLEASED; N],
221        }
222    }
223
224    /// Slot count (the const `N`).
225    pub const fn capacity(&self) -> usize {
226        N
227    }
228
229    /// Per-slot byte capacity (the const `BYTES`).
230    pub const fn slot_bytes(&self) -> usize {
231        BYTES
232    }
233
234    /// Slots currently lent and not yet reclaimed.
235    pub fn leased_count(&self) -> usize {
236        self.leased
237            .iter()
238            .filter(|f| f.load(Ordering::Acquire))
239            .count()
240    }
241
242    /// Reserve a free slot for capture, or `None` if all `N` are still in flight
243    /// (ring full: the producer must wait for a downstream drop). Fill the
244    /// returned handle, then [`publish`](RingSlot::publish) it as a frame slice.
245    pub fn acquire(&self) -> Option<RingSlot<'_, N, BYTES>> {
246        // The slot handle carries the slot / lease *references* (resolved here,
247        // where `idx` is loop-bounded) rather than re-indexing with a stored
248        // index at each use site: the no-alloc build proves itself free of
249        // bounds-check panic paths, so no use site may depend on the optimizer
250        // rediscovering the `idx < N` invariant.
251        for idx in 0..N {
252            if !self.leased[idx].load(Ordering::Acquire) {
253                self.leased[idx].store(true, Ordering::Release);
254                return Some(RingSlot {
255                    slot: &self.slots[idx],
256                    lease: &self.leased[idx],
257                });
258            }
259        }
260        None
261    }
262
263    /// True if `ptr` points inside one of this ring's slots, the zero-copy witness
264    /// a test uses to prove a received frame aliases the ring (no copy).
265    pub fn contains(&self, ptr: *const u8) -> bool {
266        let p = ptr as usize;
267        self.slots.iter().any(|s| {
268            let base = s.get() as usize;
269            p >= base && p < base + BYTES
270        })
271    }
272}
273
274impl<const N: usize, const BYTES: usize> Default for StaticLendRing<N, BYTES> {
275    fn default() -> Self {
276        Self::new()
277    }
278}
279
280impl<const N: usize, const BYTES: usize> fmt::Debug for StaticLendRing<N, BYTES> {
281    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
282        f.debug_struct("StaticLendRing")
283            .field("capacity", &N)
284            .field("slot_bytes", &BYTES)
285            .field("leased", &self.leased_count())
286            .finish()
287    }
288}
289
290/// An exclusive lease on one [`StaticLendRing`] slot: fill it via
291/// [`buf_mut`](Self::buf_mut), then [`publish`](Self::publish) it as a zero-copy
292/// frame slice. Dropping it without publishing releases the lease (the slot was
293/// reserved for a capture that never happened).
294pub struct RingSlot<'r, const N: usize, const BYTES: usize> {
295    slot: &'r UnsafeCell<[u8; BYTES]>,
296    lease: &'r AtomicBool,
297}
298
299impl<const N: usize, const BYTES: usize> RingSlot<'_, N, BYTES> {
300    /// The slot's backing bytes, for the capture (DMA target / test fill) to write.
301    pub fn buf_mut(&mut self) -> &mut [u8] {
302        // SAFETY: this `RingSlot` is the unique holder of its slot's lease and the
303        // slot is not yet published, so this is the only reference to those bytes.
304        let arr: &mut [u8; BYTES] = unsafe { &mut *self.slot.get() };
305        arr.as_mut_slice()
306    }
307
308    /// Publish the first `len` captured bytes as a zero-copy [`SystemSlice`] that
309    /// borrows this slot; the slot is reclaimed for reuse when the returned slice
310    /// (the frame carrying it) is dropped downstream.
311    ///
312    /// # Safety
313    /// The ring must outlive the returned `SystemSlice` and any frame holding it.
314    /// On a `static` / `StaticCell` ring this is automatic; a stack ring must be
315    /// kept alive until the pipeline drains.
316    pub unsafe fn publish(self, len: usize) -> SystemSlice {
317        debug_assert!(len <= BYTES, "published len exceeds slot capacity");
318        let ptr = self.slot.get() as *const u8;
319        let flag = self.lease as *const AtomicBool as *mut c_void;
320        // Hand lease-clearing from this handle's `Drop` to the lend's free callback,
321        // so the slot stays leased until the published frame is dropped.
322        core::mem::forget(self);
323        // SAFETY: `ptr` covers `len <= BYTES` bytes in a slot valid for the ring's
324        // lifetime (caller's contract); the slot is read-only while lent and is not
325        // reused until `release_slot` clears its lease on the frame's drop.
326        unsafe { SystemSlice::from_foreign(ptr, len, Some(release_slot), flag) }
327    }
328}
329
330impl<const N: usize, const BYTES: usize> fmt::Debug for RingSlot<'_, N, BYTES> {
331    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
332        f.debug_struct("RingSlot")
333            .field("slot", &self.slot.get())
334            .finish()
335    }
336}
337
338impl<const N: usize, const BYTES: usize> Drop for RingSlot<'_, N, BYTES> {
339    fn drop(&mut self) {
340        // Acquired but never published: release the lease so the slot is reusable.
341        self.lease.store(false, Ordering::Release);
342    }
343}
344
345/// Free callback for a published [`RingSlot`]: clears the slot's lease flag so the
346/// ring can hand it out again. `user` is the slot's `&AtomicBool` lease flag.
347unsafe extern "C" fn release_slot(user: *mut c_void) {
348    // SAFETY: `user` is the lease-flag pointer `RingSlot::publish` passed; it is
349    // valid for the ring's lifetime (the publish contract) and only stored to here.
350    unsafe { (*(user as *const AtomicBool)).store(false, Ordering::Release) };
351}
352
353#[cfg(test)]
354mod tests {
355    use super::*;
356    use core::task::{RawWaker, RawWakerVTable};
357
358    fn noop_waker() -> Waker {
359        fn clone(_: *const ()) -> RawWaker {
360            RawWaker::new(core::ptr::null(), &VTABLE)
361        }
362        fn no_op(_: *const ()) {}
363        static VTABLE: RawWakerVTable = RawWakerVTable::new(clone, no_op, no_op, no_op);
364        // SAFETY: every vtable fn is a no-op over a null data pointer and never
365        // dereferences it, so the RawWaker contract holds trivially.
366        unsafe { Waker::from_raw(RawWaker::new(core::ptr::null(), &VTABLE)) }
367    }
368
369    fn poll_once<F: Future + Unpin>(fut: &mut F) -> Poll<F::Output> {
370        let waker = noop_waker();
371        let mut cx = Context::from_waker(&waker);
372        Pin::new(fut).poll(&mut cx)
373    }
374
375    #[test]
376    fn capacity_and_available_match_on_construction() {
377        let pool: StaticBufferPool<[u8; 4], 3> = StaticBufferPool::new([[0u8; 4]; 3]);
378        assert_eq!(pool.capacity(), 3);
379        assert_eq!(pool.available(), 3);
380        assert_eq!(pool.outstanding(), 0);
381    }
382
383    #[test]
384    fn acquire_decrements_available_drop_returns() {
385        let pool: StaticBufferPool<[u8; 4], 3> = StaticBufferPool::new([[0u8; 4]; 3]);
386        {
387            let _a = pool.try_acquire().expect("a");
388            let _b = pool.try_acquire().expect("b");
389            assert_eq!(pool.available(), 1);
390            assert_eq!(pool.outstanding(), 2);
391        }
392        assert_eq!(pool.available(), 3, "dropping handles returns buffers");
393    }
394
395    #[test]
396    fn exhausted_pool_returns_none() {
397        let pool: StaticBufferPool<u32, 2> = StaticBufferPool::new([0; 2]);
398        let _a = pool.try_acquire().unwrap();
399        let _b = pool.try_acquire().unwrap();
400        assert!(pool.try_acquire().is_none());
401    }
402
403    #[test]
404    fn handle_derefs_to_buffer_and_writes_through() {
405        let pool: StaticBufferPool<[u8; 4], 1> = StaticBufferPool::new([[0u8; 4]; 1]);
406        let mut buf = pool.try_acquire().unwrap();
407        buf[0] = 0xAB;
408        assert_eq!(buf.as_ref(), &[0xAB, 0, 0, 0]);
409    }
410
411    #[test]
412    fn async_acquire_parks_then_resolves_when_a_buffer_is_freed() {
413        let pool: StaticBufferPool<u32, 1> = StaticBufferPool::new([7; 1]);
414        let held = pool.try_acquire().unwrap();
415        // Pool exhausted: acquire parks.
416        let mut fut = pool.acquire();
417        assert!(matches!(poll_once(&mut fut), Poll::Pending));
418        // Freeing the buffer wakes the acquirer; the next poll resolves.
419        drop(held);
420        let Poll::Ready(buf) = poll_once(&mut fut) else {
421            panic!("acquire must resolve once a buffer is free");
422        };
423        assert_eq!(pool.available(), 0, "the resolved acquire holds the buffer");
424        drop(buf);
425        assert_eq!(pool.available(), 1, "dropping it returns the buffer");
426    }
427
428    // --- StaticLendRing (zero-copy DMA ring) ---
429
430    #[test]
431    fn lend_ring_publish_borrows_slot_and_drop_reclaims() {
432        let ring: StaticLendRing<2, 8> = StaticLendRing::new();
433        assert_eq!(
434            (ring.capacity(), ring.slot_bytes(), ring.leased_count()),
435            (2, 8, 0)
436        );
437
438        let mut slot = ring.acquire().expect("free slot");
439        slot.buf_mut()[..3].copy_from_slice(&[1, 2, 3]);
440        assert_eq!(ring.leased_count(), 1, "acquire leases the slot");
441        // SAFETY: `ring` outlives `frame` (both drop at end of scope, frame first).
442        let frame = unsafe { slot.publish(3) };
443        assert_eq!(
444            ring.leased_count(),
445            1,
446            "publish keeps the lease until the frame drops"
447        );
448        // Zero-copy witness: the published bytes alias the ring slot, not a copy.
449        assert_eq!(frame.as_slice(), &[1, 2, 3]);
450        assert!(
451            ring.contains(frame.as_slice().as_ptr()),
452            "frame bytes live in the ring"
453        );
454
455        drop(frame);
456        assert_eq!(
457            ring.leased_count(),
458            0,
459            "dropping the frame reclaims the slot"
460        );
461    }
462
463    #[test]
464    fn lend_ring_acquired_but_unpublished_slot_is_released_on_drop() {
465        let ring: StaticLendRing<1, 4> = StaticLendRing::new();
466        {
467            let _slot = ring.acquire().expect("free slot");
468            assert!(
469                ring.acquire().is_none(),
470                "ring full while the lease is held"
471            );
472        }
473        assert_eq!(
474            ring.leased_count(),
475            0,
476            "dropping an unpublished lease frees the slot"
477        );
478        assert!(ring.acquire().is_some(), "slot reusable again");
479    }
480
481    #[test]
482    fn lend_ring_full_when_all_slots_in_flight_then_recycles() {
483        let ring: StaticLendRing<2, 4> = StaticLendRing::new();
484        // SAFETY: the ring outlives every published frame in this scope. (len 1 so
485        // the slice pointer is the slot base, not the empty-slice sentinel.)
486        let f0 = unsafe { ring.acquire().unwrap().publish(1) };
487        // SAFETY: as above, the ring outlives this frame and len is 1.
488        let f1 = unsafe { ring.acquire().unwrap().publish(1) };
489        let p0 = f0.as_slice().as_ptr();
490        assert!(
491            ring.acquire().is_none(),
492            "both slots lent: ring is full (back-pressure)"
493        );
494
495        drop(f0); // a downstream drop frees one slot
496                  // SAFETY: as above; the slot freed by the drop above is reacquired here.
497        let f2 = unsafe { ring.acquire().expect("slot freed by the drop").publish(1) };
498        // The recycled frame reuses slot 0's physical buffer: no fresh allocation.
499        assert_eq!(
500            f2.as_slice().as_ptr(),
501            p0,
502            "the freed slot's buffer is recycled"
503        );
504        drop(f1);
505        drop(f2);
506        assert_eq!(ring.leased_count(), 0);
507    }
508}