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}