Skip to main content

forge_alloc/layout/
stack_alloc.rs

1//! `StackAlloc<B>` — LIFO-discipline allocator. Deallocation is only valid
2//! for the most recently allocated block. An out-of-order (or double-) free
3//! is *detected*, not undefined behavior: debug builds panic, and release
4//! builds treat it as a safe no-op — the offending free is ignored and the
5//! allocator's cursor/frame stack stay consistent (the block is simply not
6//! reclaimed). The misuse is a caller logic error, but it never corrupts the
7//! allocator.
8//!
9//! Cheaper than [`BumpArena`](crate::layout::BumpArena) for patterns that do
10//! reclaim memory but always in LIFO order — typical of nested scope
11//! allocations where teardown is the inverse of construction.
12//!
13//! See `docs/ARCHITECTURE.md` for the stack-allocator design.
14
15use alloc::vec::Vec;
16use core::cell::UnsafeCell;
17use core::ptr::NonNull;
18
19use forge_alloc_core::{AllocError, Allocator, Deallocator, FixedRange, NonZeroLayout};
20
21/// LIFO-discipline allocator over a [`FixedRange`] backing.
22///
23/// Maintains a frame stack so nested alloc/free with arbitrary depth works
24/// correctly. Each `allocate` pushes the new frame's offset; `deallocate`
25/// validates the supplied pointer matches the top of the stack and pops.
26pub struct StackAlloc<B: FixedRange> {
27    backing: B,
28    // No cached `base` pointer: structure-relative backings (e.g.
29    // `InlineBacked`) report a different `base()` after a move, so we
30    // re-query through `self.backing.base()` at every access instead.
31    // The happy-path cost is one extra indirect load.
32    capacity: usize,
33    /// Current top of stack — bytes used.
34    cursor: UnsafeCell<usize>,
35    /// Frame stack: each entry is `(aligned_off, prev_cursor)` for one live
36    /// allocation. On deallocate we restore cursor to `prev_cursor`.
37    ///
38    /// `Vec` uses the global allocator via `alloc::vec::Vec`; this means
39    /// `StackAlloc` allocates from the global heap for its own bookkeeping
40    /// (not from the supplied backing). For pure-no-heap use cases, this is
41    /// the right trade-off — the actual user allocations still flow through
42    /// the backing.
43    frames: UnsafeCell<Vec<(usize, usize)>>,
44}
45
46impl<B: FixedRange> StackAlloc<B> {
47    /// Construct a LIFO allocator over `backing`'s entire range.
48    ///
49    /// The frame stack starts at capacity 0 and grows on demand via the
50    /// global allocator. For workloads with a known maximum nesting
51    /// depth, prefer [`Self::with_max_depth`] to pre-reserve the frame
52    /// stack and avoid heap reallocations on the alloc hot path.
53    pub fn new(backing: B) -> Result<Self, AllocError> {
54        Self::with_max_depth(backing, 0)
55    }
56
57    /// Construct with a hint for the maximum frame depth. The frame
58    /// stack is pre-allocated with `max_depth` capacity, so the alloc
59    /// hot path does not trigger a `Vec` grow / reallocation for the
60    /// first `max_depth` nested allocations.
61    ///
62    /// If `max_depth` is exceeded at runtime the `Vec` will grow
63    /// normally — the constructor's value is a hint, not a hard cap.
64    /// Pass `0` for the same behavior as [`Self::new`].
65    pub fn with_max_depth(backing: B, max_depth: usize) -> Result<Self, AllocError> {
66        let capacity = backing.size();
67        if capacity == 0 {
68            return Err(AllocError);
69        }
70        // Use `Vec::new()` + `try_reserve_exact` so an absurd `max_depth`
71        // (e.g. `usize::MAX`) translates allocation failure to `AllocError`
72        // instead of panicking inside `Vec::with_capacity`.
73        let mut frames: Vec<(usize, usize)> = Vec::new();
74        if max_depth > 0 {
75            frames
76                .try_reserve_exact(max_depth)
77                .map_err(|_| AllocError)?;
78        }
79        Ok(Self {
80            backing,
81            capacity,
82            cursor: UnsafeCell::new(0),
83            frames: UnsafeCell::new(frames),
84        })
85    }
86
87    /// Bytes currently in use.
88    #[inline]
89    pub fn allocated(&self) -> usize {
90        // SAFETY: !Sync.
91        unsafe { *self.cursor.get() }
92    }
93
94    /// Bytes remaining.
95    #[inline]
96    pub fn remaining(&self) -> usize {
97        self.capacity - self.allocated()
98    }
99
100    /// Borrow the backing.
101    #[inline]
102    pub fn backing(&self) -> &B {
103        &self.backing
104    }
105}
106
107unsafe impl<B: FixedRange> Deallocator for StackAlloc<B> {
108    #[inline]
109    unsafe fn deallocate(&self, ptr: NonNull<u8>, _layout: NonZeroLayout) {
110        // SAFETY: !Sync — single-threaded access to frames + cursor.
111        unsafe {
112            let frames = &mut *self.frames.get();
113            let base_addr = self.backing.base().as_ptr() as usize;
114            let p_addr = ptr.as_ptr() as usize;
115            let p_off = p_addr - base_addr;
116            // The top of the frame stack must be (p_off, prev_cursor). Out-of-order
117            // frees fail this check.
118            let Some(&(top_off, prev_cursor)) = frames.last() else {
119                debug_assert!(
120                    false,
121                    "StackAlloc::deallocate: stack is empty (double free?)",
122                );
123                return;
124            };
125            if top_off != p_off {
126                debug_assert!(
127                    false,
128                    "StackAlloc::deallocate: out-of-order free (ptr offset {} != top frame {})",
129                    p_off, top_off,
130                );
131                return;
132            }
133            // Pop the frame and restore the cursor to where it was BEFORE
134            // this allocation (recovering the alignment-pad bytes too).
135            frames.pop();
136            *self.cursor.get() = prev_cursor;
137        }
138    }
139}
140
141unsafe impl<B: FixedRange> Allocator for StackAlloc<B> {
142    #[inline]
143    fn allocate(&self, layout: NonZeroLayout) -> Result<NonNull<[u8]>, AllocError> {
144        let align = layout.align().get();
145        let size = layout.size().get();
146        // Re-query the backing base each call; structure-relative backings
147        // change address on move.
148        let base = self.backing.base();
149        let base_addr = base.as_ptr() as usize;
150
151        // SAFETY: !Sync.
152        unsafe {
153            let cursor_ptr = self.cursor.get();
154            let cur = *cursor_ptr;
155            let raw = base_addr.checked_add(cur).ok_or(AllocError)?;
156            let aligned = raw.checked_add(align - 1).ok_or(AllocError)? & !(align - 1);
157            let aligned_off = aligned - base_addr;
158            let end_off = aligned_off.checked_add(size).ok_or(AllocError)?;
159            if end_off > self.capacity {
160                return Err(AllocError);
161            }
162            // Commit the block on the backing before recording the frame or
163            // advancing the cursor, so a declined commit (a `lazy_commit`
164            // MmapBacked on Windows) leaves the stack unchanged and surfaces
165            // as AllocError instead of a fault on first write. No-op for
166            // already-writable backings. StackAlloc is `!Sync` single-writer,
167            // so the plain-cell watermark in MmapBacked is sound here, same
168            // as BumpArena.
169            self.backing.commit(aligned_off, size)?;
170            // Record the frame BEFORE advancing the cursor. `cur` is the
171            // value to restore on this frame's deallocate (which gives back
172            // the alignment-pad bytes between `cur` and `aligned_off`).
173            (&mut *self.frames.get()).push((aligned_off, cur));
174            *cursor_ptr = end_off;
175            let p = base.as_ptr().add(aligned_off);
176            Ok(NonNull::slice_from_raw_parts(
177                NonNull::new_unchecked(p),
178                size,
179            ))
180        }
181    }
182
183    #[inline]
184    fn capacity_bytes(&self) -> Option<usize> {
185        Some(self.capacity)
186    }
187}
188
189impl<B: FixedRange> FixedRange for StackAlloc<B> {
190    #[inline]
191    fn base(&self) -> NonNull<u8> {
192        self.backing.base()
193    }
194
195    #[inline]
196    fn size(&self) -> usize {
197        self.capacity
198    }
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204    use crate::backing::InlineBacked;
205
206    #[test]
207    fn lifo_alloc_then_free_restores_cursor() {
208        let s = StackAlloc::new(InlineBacked::<256>::new()).unwrap();
209        let layout = NonZeroLayout::from_size_align(64, 8).unwrap();
210        let a = s.allocate(layout).unwrap();
211        assert_eq!(s.allocated(), 64);
212        unsafe { s.deallocate(a.cast(), layout) };
213        assert_eq!(s.allocated(), 0);
214    }
215
216    #[test]
217    fn nested_alloc_then_lifo_free_works() {
218        let s = StackAlloc::new(InlineBacked::<256>::new()).unwrap();
219        let l1 = NonZeroLayout::from_size_align(32, 8).unwrap();
220        let l2 = NonZeroLayout::from_size_align(16, 8).unwrap();
221        let a = s.allocate(l1).unwrap();
222        let _b = s.allocate(l2).unwrap();
223        assert_eq!(s.allocated(), 48);
224        // Free in reverse (LIFO).
225        unsafe { s.deallocate(_b.cast(), l2) };
226        // After freeing the top, cursor rolls back to start of b's slot = 32.
227        assert_eq!(s.allocated(), 32);
228        unsafe { s.deallocate(a.cast(), l1) };
229        assert_eq!(s.allocated(), 0);
230    }
231
232    #[test]
233    #[should_panic(expected = "out-of-order free")]
234    #[cfg(debug_assertions)]
235    fn out_of_order_free_panics_in_debug() {
236        let s = StackAlloc::new(InlineBacked::<256>::new()).unwrap();
237        let layout = NonZeroLayout::from_size_align(8, 8).unwrap();
238        let a = s.allocate(layout).unwrap();
239        let _b = s.allocate(layout).unwrap();
240        // Free a before b — violates LIFO.
241        unsafe { s.deallocate(a.cast(), layout) };
242    }
243
244    /// In release builds an out-of-order free is a SAFE no-op (not UB): the
245    /// offending free is ignored and the cursor is left untouched, so the
246    /// allocator state stays consistent. Pinned so a future change that turned
247    /// the safe `return` into an actual pop (real corruption) is caught.
248    #[test]
249    #[cfg(not(debug_assertions))]
250    fn out_of_order_free_is_safe_noop_in_release() {
251        let s = StackAlloc::new(InlineBacked::<256>::new()).unwrap();
252        let layout = NonZeroLayout::from_size_align(8, 8).unwrap();
253        let a = s.allocate(layout).unwrap();
254        let _b = s.allocate(layout).unwrap();
255        let before = s.allocated();
256        // Free `a` before `b` — violates LIFO; must be ignored, cursor intact.
257        unsafe { s.deallocate(a.cast(), layout) };
258        assert_eq!(s.allocated(), before, "out-of-order free must not pop");
259    }
260
261    #[test]
262    fn exhaustion_returns_error() {
263        let s = StackAlloc::new(InlineBacked::<64>::new()).unwrap();
264        let big = NonZeroLayout::from_size_align(64, 1).unwrap();
265        let _ = s.allocate(big).unwrap();
266        let one = NonZeroLayout::from_size_align(1, 1).unwrap();
267        assert!(s.allocate(one).is_err());
268    }
269
270    #[test]
271    fn three_level_nested_lifo() {
272        // The original ship's "nested LIFO" test passed only by coincidence
273        // (the first allocation lived at offset 0, colliding with the empty
274        // sentinel). This test pushes the first frame off zero and exercises
275        // three nesting levels — only possible with a real frame stack.
276        let s = StackAlloc::new(InlineBacked::<256>::new()).unwrap();
277        let layout = NonZeroLayout::from_size_align(16, 8).unwrap();
278        let a = s.allocate(layout).unwrap();
279        let b = s.allocate(layout).unwrap();
280        let c = s.allocate(layout).unwrap();
281        // Free in reverse — every pop must succeed.
282        unsafe {
283            s.deallocate(c.cast(), layout);
284            assert_eq!(s.allocated(), 32);
285            s.deallocate(b.cast(), layout);
286            assert_eq!(s.allocated(), 16);
287            s.deallocate(a.cast(), layout);
288            assert_eq!(s.allocated(), 0);
289        }
290    }
291
292    #[test]
293    fn fixed_range_contains_allocations() {
294        let s = StackAlloc::new(InlineBacked::<128>::new()).unwrap();
295        let layout = NonZeroLayout::from_size_align(16, 8).unwrap();
296        let block = s.allocate(layout).unwrap();
297        assert!(s.contains(block.cast::<u8>()));
298    }
299}