Skip to main content

ftui_render/
arena.rs

1//! Per-frame bump arena allocation.
2//!
3//! Provides [`FrameArena`], a thin wrapper around [`bumpalo::Bump`] for
4//! per-frame temporary allocations. The arena is reset at frame boundaries,
5//! eliminating allocator churn on the hot render path.
6//!
7//! # Usage
8//!
9//! ```
10//! use ftui_render::arena::FrameArena;
11//!
12//! let mut arena = FrameArena::new(256 * 1024); // 256 KB initial capacity
13//! let s = arena.alloc_str("hello");
14//! assert_eq!(s, "hello");
15//!
16//! let slice = arena.alloc_slice(&[1u32, 2, 3]);
17//! assert_eq!(slice, &[1, 2, 3]);
18//!
19//! arena.reset(); // O(1) — reclaims all memory for reuse
20//! ```
21//!
22//! # Safety
23//!
24//! This module uses only safe code. `bumpalo::Bump` provides a safe bump
25//! allocator with automatic growth. `reset()` is safe and frees all
26//! allocations, making the memory available for reuse.
27
28use bumpalo::Bump;
29
30/// A growable vector allocated in the bump arena.
31///
32/// Use this for scratch collections that need `push()` during rendering.
33/// The memory is reclaimed when the arena is reset at frame boundaries.
34pub type BumpVec<'a, T> = bumpalo::collections::Vec<'a, T>;
35
36/// Default initial capacity for the frame arena (256 KB).
37pub const DEFAULT_ARENA_CAPACITY: usize = 256 * 1024;
38
39/// A per-frame bump allocator for temporary render-path allocations.
40///
41/// `FrameArena` wraps [`bumpalo::Bump`] with a focused API for the common
42/// allocation patterns in the render pipeline: strings, slices, and
43/// single values. All allocations are invalidated on [`reset()`](Self::reset),
44/// which should be called at frame boundaries.
45///
46/// # Drop semantics
47///
48/// `bumpalo` intentionally does not run `Drop` for values allocated in the arena
49/// when calling [`reset()`](Self::reset) or when the arena itself is dropped.
50/// Only allocate short-lived scratch values that do not require destructor logic.
51///
52/// # Capacity
53///
54/// The arena starts with an initial capacity and grows automatically when
55/// exhausted. Growth allocates new chunks from the global allocator but
56/// never moves existing allocations.
57#[derive(Debug)]
58pub struct FrameArena {
59    bump: Bump,
60}
61
62impl FrameArena {
63    /// Create a new arena with the given initial capacity in bytes.
64    ///
65    /// # Panics
66    ///
67    /// Panics if the system allocator cannot fulfill the initial allocation.
68    pub fn new(capacity: usize) -> Self {
69        Self {
70            bump: Bump::with_capacity(capacity),
71        }
72    }
73
74    /// Create a new arena with the default capacity (256 KB).
75    pub fn with_default_capacity() -> Self {
76        Self::new(DEFAULT_ARENA_CAPACITY)
77    }
78
79    /// Reset the arena, reclaiming all memory for reuse.
80    ///
81    /// This is an O(1) operation. All previously allocated references
82    /// are invalidated. The arena retains its allocated chunks for
83    /// future allocations, avoiding repeated system allocator calls.
84    pub fn reset(&mut self) {
85        self.bump.reset();
86    }
87
88    /// Allocate a string slice in the arena.
89    ///
90    /// Returns a reference to the arena-allocated copy of `s`.
91    /// The returned reference is valid until the next [`reset()`](Self::reset).
92    pub fn alloc_str(&self, s: &str) -> &str {
93        self.bump.alloc_str(s)
94    }
95
96    /// Format a string directly into the arena, avoiding an intermediate heap `String`.
97    ///
98    /// This is the arena equivalent of `format!()`. The formatted text is
99    /// written into a bump-backed string and returned as an arena-allocated `&str`.
100    pub fn alloc_fmt(&self, args: std::fmt::Arguments<'_>) -> &str {
101        use core::fmt::Write;
102        let mut s = bumpalo::collections::String::new_in(&self.bump);
103        s.write_fmt(args).expect("formatting into arena string");
104        s.into_bump_str()
105    }
106
107    /// Allocate a copy of a slice in the arena.
108    ///
109    /// Returns a reference to the arena-allocated copy of `slice`.
110    /// The returned reference is valid until the next [`reset()`](Self::reset).
111    pub fn alloc_slice<T: Copy>(&self, slice: &[T]) -> &[T] {
112        self.bump.alloc_slice_copy(slice)
113    }
114
115    /// Allocate a single value in the arena, constructed by `f`.
116    ///
117    /// Returns a mutable reference to the arena-allocated value.
118    /// The returned reference is valid until the next [`reset()`](Self::reset).
119    pub fn alloc_with<T, F: FnOnce() -> T>(&self, f: F) -> &mut T {
120        self.bump.alloc_with(f)
121    }
122
123    /// Allocate a single value in the arena.
124    ///
125    /// Returns a mutable reference to the arena-allocated value.
126    /// The returned reference is valid until the next [`reset()`](Self::reset).
127    ///
128    /// NOTE: destructors are NEVER run for bump allocations — neither on
129    /// `reset()` nor on arena drop (pinned by test). Allocating a `Drop`
130    /// type (Rc, String, Vec, file handles…) leaks whatever the destructor
131    /// would have released; use arena allocation for plain data only.
132    pub fn alloc<T>(&self, val: T) -> &mut T {
133        self.bump.alloc(val)
134    }
135
136    /// Collect an iterator into an arena-allocated slice.
137    ///
138    /// This is the arena equivalent of `.collect::<Vec<T>>()` — it avoids
139    /// a heap allocation by writing elements directly into bump memory.
140    pub fn alloc_iter<T, I>(&self, iter: I) -> &mut [T]
141    where
142        I: IntoIterator<Item = T>,
143    {
144        let mut vec = bumpalo::collections::Vec::new_in(&self.bump);
145        vec.extend(iter);
146        vec.into_bump_slice_mut()
147    }
148
149    /// Create a new growable vector backed by this arena.
150    ///
151    /// Use this for scratch collections that grow via `push()` during
152    /// rendering. The vector's memory is reclaimed on arena reset.
153    pub fn new_vec<T>(&self) -> BumpVec<'_, T> {
154        bumpalo::collections::Vec::new_in(&self.bump)
155    }
156
157    /// Create a new growable vector with the given capacity.
158    pub fn new_vec_with_capacity<T>(&self, capacity: usize) -> BumpVec<'_, T> {
159        bumpalo::collections::Vec::with_capacity_in(capacity, &self.bump)
160    }
161
162    /// Returns the total bytes allocated in the arena (across all chunks).
163    ///
164    /// IMPORTANT: after [`reset()`](Self::reset) this reports the RETAINED
165    /// chunk capacity (bumpalo keeps its largest chunk for reuse), not live
166    /// allocation usage — it never decreases on its own. Treating it as a
167    /// "current usage" signal creates ratchet-and-stick behavior: this
168    /// exact misreading made the frame guardrails' emergency verdict
169    /// permanent (fixed by rebuilding the arena on the drop path). To
170    /// actually release memory, drop and recreate the arena.
171    pub fn allocated_bytes(&self) -> usize {
172        self.bump.allocated_bytes()
173    }
174
175    /// Returns total allocated bytes including allocator metadata.
176    ///
177    /// This reflects chunk footprint, not currently live allocation usage.
178    /// Chunk memory is retained across [`reset()`](Self::reset) for reuse.
179    pub fn allocated_bytes_including_metadata(&self) -> usize {
180        self.bump.allocated_bytes_including_metadata()
181    }
182
183    /// Returns a reference to the underlying [`Bump`] allocator.
184    ///
185    /// Use this for advanced allocation patterns not covered by the
186    /// convenience methods.
187    pub fn as_bump(&self) -> &Bump {
188        &self.bump
189    }
190}
191
192impl Default for FrameArena {
193    fn default() -> Self {
194        Self::with_default_capacity()
195    }
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201    use proptest::prelude::*;
202    use std::cell::Cell as DropCounter;
203    use std::mem::align_of;
204    use std::rc::Rc;
205
206    #[derive(Clone)]
207    struct DropSpy {
208        drops: Rc<DropCounter<usize>>,
209    }
210
211    impl Drop for DropSpy {
212        fn drop(&mut self) {
213            self.drops.set(self.drops.get() + 1);
214        }
215    }
216
217    #[test]
218    fn alloc_fmt_formats_into_arena() {
219        let arena = FrameArena::new(4096);
220        let s = arena.alloc_fmt(format_args!("hello {} {}", 42, "world"));
221        assert_eq!(s, "hello 42 world");
222    }
223
224    #[test]
225    fn alloc_iter_collects_to_arena() {
226        let arena = FrameArena::new(4096);
227        let data: Vec<u32> = (0..10).collect();
228        let slice = arena.alloc_iter(data.iter().copied());
229        assert_eq!(slice, &[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]);
230    }
231
232    #[test]
233    fn alloc_iter_empty() {
234        let arena = FrameArena::new(4096);
235        let slice: &mut [u8] = arena.alloc_iter(std::iter::empty());
236        assert!(slice.is_empty());
237    }
238
239    #[test]
240    fn new_vec_push_and_read() {
241        let arena = FrameArena::new(4096);
242        let mut v = arena.new_vec::<u32>();
243        v.push(1);
244        v.push(2);
245        v.push(3);
246        assert_eq!(v.as_slice(), &[1, 2, 3]);
247    }
248
249    #[test]
250    fn new_vec_with_capacity_preallocates() {
251        let arena = FrameArena::new(4096);
252        let v = arena.new_vec_with_capacity::<u64>(100);
253        assert!(v.capacity() >= 100);
254        assert!(v.is_empty());
255    }
256
257    #[test]
258    fn new_creates_arena_with_capacity() {
259        let arena = FrameArena::new(1024);
260        // Should be able to allocate without growing
261        let _s = arena.alloc_str("hello");
262    }
263
264    #[test]
265    fn default_uses_256kb() {
266        let arena = FrameArena::default();
267        let _s = arena.alloc_str("test");
268    }
269
270    #[test]
271    fn alloc_str_returns_correct_content() {
272        let arena = FrameArena::new(4096);
273        let s = arena.alloc_str("hello, world!");
274        assert_eq!(s, "hello, world!");
275    }
276
277    #[test]
278    fn alloc_str_empty() {
279        let arena = FrameArena::new(4096);
280        let s = arena.alloc_str("");
281        assert_eq!(s, "");
282    }
283
284    #[test]
285    fn alloc_str_unicode() {
286        let arena = FrameArena::new(4096);
287        let s = arena.alloc_str("こんにちは 🎉");
288        assert_eq!(s, "こんにちは 🎉");
289    }
290
291    #[test]
292    fn alloc_slice_copies_correctly() {
293        let arena = FrameArena::new(4096);
294        let data = [1u32, 2, 3, 4, 5];
295        let slice = arena.alloc_slice(&data);
296        assert_eq!(slice, &[1, 2, 3, 4, 5]);
297    }
298
299    #[test]
300    fn alloc_slice_empty() {
301        let arena = FrameArena::new(4096);
302        let slice: &[u8] = arena.alloc_slice(&[]);
303        assert!(slice.is_empty());
304    }
305
306    #[test]
307    fn alloc_slice_u8() {
308        let arena = FrameArena::new(4096);
309        let data = b"ANSI escape";
310        let slice = arena.alloc_slice(data.as_slice());
311        assert_eq!(slice, b"ANSI escape");
312    }
313
314    #[test]
315    fn alloc_with_constructs_value() {
316        let arena = FrameArena::new(4096);
317        let val = arena.alloc_with(|| 42u64);
318        assert_eq!(*val, 42);
319    }
320
321    #[test]
322    fn alloc_returns_mutable_ref() {
323        let arena = FrameArena::new(4096);
324        let val = arena.alloc(100i32);
325        assert_eq!(*val, 100);
326        *val = 200;
327        assert_eq!(*val, 200);
328    }
329
330    #[test]
331    fn reset_allows_reuse() {
332        let mut arena = FrameArena::new(4096);
333        let _s1 = arena.alloc_str("first frame data");
334        let bytes_before = arena.allocated_bytes();
335        assert!(bytes_before > 0);
336
337        arena.reset();
338
339        // After reset, new allocations reuse the same memory
340        let _s2 = arena.alloc_str("second frame data");
341    }
342
343    #[test]
344    fn multiple_allocations_coexist() {
345        let arena = FrameArena::new(4096);
346        let s1 = arena.alloc_str("hello");
347        let s2 = arena.alloc_str("world");
348        let slice = arena.alloc_slice(&[1u32, 2, 3]);
349        let val = arena.alloc(42u64);
350
351        // All references remain valid simultaneously
352        assert_eq!(s1, "hello");
353        assert_eq!(s2, "world");
354        assert_eq!(slice, &[1, 2, 3]);
355        assert_eq!(*val, 42);
356    }
357
358    #[test]
359    fn arena_grows_beyond_initial_capacity() {
360        let arena = FrameArena::new(64); // Very small initial capacity
361        // Allocate more than 64 bytes — arena should grow automatically
362        let large = "a]".repeat(100);
363        let s = arena.alloc_str(&large);
364        assert_eq!(s, large);
365    }
366
367    #[test]
368    fn default_capacity_grows_beyond_256kb_without_panic() {
369        let arena = FrameArena::default();
370        let large = vec![0xAB; DEFAULT_ARENA_CAPACITY + 64 * 1024];
371        let s = arena.alloc_slice(&large);
372        assert_eq!(s.len(), large.len());
373        assert_eq!(s[0], 0xAB);
374        assert!(arena.allocated_bytes() >= DEFAULT_ARENA_CAPACITY);
375    }
376
377    #[test]
378    fn allocated_bytes_tracks_usage() {
379        let arena = FrameArena::new(4096);
380        let initial = arena.allocated_bytes();
381        let _s = arena.alloc_str("some text for tracking");
382        assert!(arena.allocated_bytes() >= initial);
383    }
384
385    #[test]
386    fn as_bump_provides_access() {
387        let arena = FrameArena::new(4096);
388        let bump = arena.as_bump();
389        // Can use bump directly for advanced patterns
390        let val = bump.alloc(99u32);
391        assert_eq!(*val, 99);
392    }
393
394    #[test]
395    fn reset_then_heavy_reuse() {
396        let mut arena = FrameArena::new(4096);
397        for frame in 0..100 {
398            let s = arena.alloc_str(&format!("frame {frame}"));
399            assert!(s.starts_with("frame "));
400            let data: Vec<u32> = (0..50).collect();
401            let slice = arena.alloc_slice(&data);
402            assert_eq!(slice.len(), 50);
403            arena.reset();
404        }
405    }
406
407    #[test]
408    fn allocations_respect_alignment_requirements() {
409        let arena = FrameArena::new(4096);
410
411        let p_u8 = arena.alloc(1u8) as *mut u8 as usize;
412        let p_u32 = arena.alloc(2u32) as *mut u32 as usize;
413        let p_u64 = arena.alloc(3u64) as *mut u64 as usize;
414        let p_u128 = arena.alloc(4u128) as *mut u128 as usize;
415
416        assert_eq!(p_u8 % align_of::<u8>(), 0);
417        assert_eq!(p_u32 % align_of::<u32>(), 0);
418        assert_eq!(p_u64 % align_of::<u64>(), 0);
419        assert_eq!(p_u128 % align_of::<u128>(), 0);
420    }
421
422    #[test]
423    fn reset_reuses_existing_chunks_without_extra_growth() {
424        let mut arena = FrameArena::new(128);
425        let payload = vec![7u8; 32 * 1024];
426
427        let first = arena.alloc_slice(&payload);
428        assert_eq!(first.len(), payload.len());
429        let grown = arena.allocated_bytes_including_metadata();
430        assert!(grown > 128);
431
432        arena.reset();
433
434        let second = arena.alloc_slice(&payload);
435        assert_eq!(second.len(), payload.len());
436        let after = arena.allocated_bytes_including_metadata();
437        assert!(
438            after <= grown + 1024,
439            "arena should reuse existing chunks after reset: before={grown}, after={after}"
440        );
441    }
442
443    #[test]
444    fn reset_does_not_run_drop_glue_for_allocated_values() {
445        let drops = Rc::new(DropCounter::new(0));
446        {
447            let mut arena = FrameArena::new(1024);
448            let _spy = arena.alloc(DropSpy {
449                drops: Rc::clone(&drops),
450            });
451            arena.reset();
452            assert_eq!(
453                drops.get(),
454                0,
455                "reset() must not run Drop for bump allocations"
456            );
457        }
458        assert_eq!(
459            drops.get(),
460            0,
461            "dropping arena must not run Drop for bump allocations"
462        );
463    }
464
465    #[test]
466    fn debug_impl() {
467        let arena = FrameArena::new(1024);
468        let debug = format!("{arena:?}");
469        assert!(debug.contains("FrameArena"));
470    }
471
472    proptest! {
473        #[test]
474        fn proptest_random_alloc_reset_sequences_never_panic(ops in prop::collection::vec((0u8..=3, 0u16..1024), 1..300)) {
475            let mut arena = FrameArena::new(256);
476            for (op, size_hint) in ops {
477                match op {
478                    0 => {
479                        let len = (size_hint as usize % 256) + 1;
480                        let s = "x".repeat(len);
481                        let alloc = arena.alloc_str(&s);
482                        prop_assert_eq!(alloc.len(), len);
483                    }
484                    1 => {
485                        let len = (size_hint as usize % 128) + 1;
486                        let data = vec![size_hint as u32; len];
487                        let alloc = arena.alloc_slice(&data);
488                        prop_assert_eq!(alloc.len(), len);
489                    }
490                    2 => {
491                        let value = arena.alloc(size_hint as u64);
492                        prop_assert_eq!(*value, size_hint as u64);
493                    }
494                    _ => {
495                        arena.reset();
496                    }
497                }
498            }
499        }
500    }
501}