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    pub fn alloc<T>(&self, val: T) -> &mut T {
128        self.bump.alloc(val)
129    }
130
131    /// Collect an iterator into an arena-allocated slice.
132    ///
133    /// This is the arena equivalent of `.collect::<Vec<T>>()` — it avoids
134    /// a heap allocation by writing elements directly into bump memory.
135    pub fn alloc_iter<T, I>(&self, iter: I) -> &mut [T]
136    where
137        I: IntoIterator<Item = T>,
138    {
139        let mut vec = bumpalo::collections::Vec::new_in(&self.bump);
140        vec.extend(iter);
141        vec.into_bump_slice_mut()
142    }
143
144    /// Create a new growable vector backed by this arena.
145    ///
146    /// Use this for scratch collections that grow via `push()` during
147    /// rendering. The vector's memory is reclaimed on arena reset.
148    pub fn new_vec<T>(&self) -> BumpVec<'_, T> {
149        bumpalo::collections::Vec::new_in(&self.bump)
150    }
151
152    /// Create a new growable vector with the given capacity.
153    pub fn new_vec_with_capacity<T>(&self, capacity: usize) -> BumpVec<'_, T> {
154        bumpalo::collections::Vec::with_capacity_in(capacity, &self.bump)
155    }
156
157    /// Returns the total bytes allocated in the arena (across all chunks).
158    pub fn allocated_bytes(&self) -> usize {
159        self.bump.allocated_bytes()
160    }
161
162    /// Returns total allocated bytes including allocator metadata.
163    ///
164    /// This reflects chunk footprint, not currently live allocation usage.
165    /// Chunk memory is retained across [`reset()`](Self::reset) for reuse.
166    pub fn allocated_bytes_including_metadata(&self) -> usize {
167        self.bump.allocated_bytes_including_metadata()
168    }
169
170    /// Returns a reference to the underlying [`Bump`] allocator.
171    ///
172    /// Use this for advanced allocation patterns not covered by the
173    /// convenience methods.
174    pub fn as_bump(&self) -> &Bump {
175        &self.bump
176    }
177}
178
179impl Default for FrameArena {
180    fn default() -> Self {
181        Self::with_default_capacity()
182    }
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188    use proptest::prelude::*;
189    use std::cell::Cell as DropCounter;
190    use std::mem::align_of;
191    use std::rc::Rc;
192
193    #[derive(Clone)]
194    struct DropSpy {
195        drops: Rc<DropCounter<usize>>,
196    }
197
198    impl Drop for DropSpy {
199        fn drop(&mut self) {
200            self.drops.set(self.drops.get() + 1);
201        }
202    }
203
204    #[test]
205    fn alloc_fmt_formats_into_arena() {
206        let arena = FrameArena::new(4096);
207        let s = arena.alloc_fmt(format_args!("hello {} {}", 42, "world"));
208        assert_eq!(s, "hello 42 world");
209    }
210
211    #[test]
212    fn alloc_iter_collects_to_arena() {
213        let arena = FrameArena::new(4096);
214        let data: Vec<u32> = (0..10).collect();
215        let slice = arena.alloc_iter(data.iter().copied());
216        assert_eq!(slice, &[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]);
217    }
218
219    #[test]
220    fn alloc_iter_empty() {
221        let arena = FrameArena::new(4096);
222        let slice: &mut [u8] = arena.alloc_iter(std::iter::empty());
223        assert!(slice.is_empty());
224    }
225
226    #[test]
227    fn new_vec_push_and_read() {
228        let arena = FrameArena::new(4096);
229        let mut v = arena.new_vec::<u32>();
230        v.push(1);
231        v.push(2);
232        v.push(3);
233        assert_eq!(v.as_slice(), &[1, 2, 3]);
234    }
235
236    #[test]
237    fn new_vec_with_capacity_preallocates() {
238        let arena = FrameArena::new(4096);
239        let v = arena.new_vec_with_capacity::<u64>(100);
240        assert!(v.capacity() >= 100);
241        assert!(v.is_empty());
242    }
243
244    #[test]
245    fn new_creates_arena_with_capacity() {
246        let arena = FrameArena::new(1024);
247        // Should be able to allocate without growing
248        let _s = arena.alloc_str("hello");
249    }
250
251    #[test]
252    fn default_uses_256kb() {
253        let arena = FrameArena::default();
254        let _s = arena.alloc_str("test");
255    }
256
257    #[test]
258    fn alloc_str_returns_correct_content() {
259        let arena = FrameArena::new(4096);
260        let s = arena.alloc_str("hello, world!");
261        assert_eq!(s, "hello, world!");
262    }
263
264    #[test]
265    fn alloc_str_empty() {
266        let arena = FrameArena::new(4096);
267        let s = arena.alloc_str("");
268        assert_eq!(s, "");
269    }
270
271    #[test]
272    fn alloc_str_unicode() {
273        let arena = FrameArena::new(4096);
274        let s = arena.alloc_str("こんにちは 🎉");
275        assert_eq!(s, "こんにちは 🎉");
276    }
277
278    #[test]
279    fn alloc_slice_copies_correctly() {
280        let arena = FrameArena::new(4096);
281        let data = [1u32, 2, 3, 4, 5];
282        let slice = arena.alloc_slice(&data);
283        assert_eq!(slice, &[1, 2, 3, 4, 5]);
284    }
285
286    #[test]
287    fn alloc_slice_empty() {
288        let arena = FrameArena::new(4096);
289        let slice: &[u8] = arena.alloc_slice(&[]);
290        assert!(slice.is_empty());
291    }
292
293    #[test]
294    fn alloc_slice_u8() {
295        let arena = FrameArena::new(4096);
296        let data = b"ANSI escape";
297        let slice = arena.alloc_slice(data.as_slice());
298        assert_eq!(slice, b"ANSI escape");
299    }
300
301    #[test]
302    fn alloc_with_constructs_value() {
303        let arena = FrameArena::new(4096);
304        let val = arena.alloc_with(|| 42u64);
305        assert_eq!(*val, 42);
306    }
307
308    #[test]
309    fn alloc_returns_mutable_ref() {
310        let arena = FrameArena::new(4096);
311        let val = arena.alloc(100i32);
312        assert_eq!(*val, 100);
313        *val = 200;
314        assert_eq!(*val, 200);
315    }
316
317    #[test]
318    fn reset_allows_reuse() {
319        let mut arena = FrameArena::new(4096);
320        let _s1 = arena.alloc_str("first frame data");
321        let bytes_before = arena.allocated_bytes();
322        assert!(bytes_before > 0);
323
324        arena.reset();
325
326        // After reset, new allocations reuse the same memory
327        let _s2 = arena.alloc_str("second frame data");
328    }
329
330    #[test]
331    fn multiple_allocations_coexist() {
332        let arena = FrameArena::new(4096);
333        let s1 = arena.alloc_str("hello");
334        let s2 = arena.alloc_str("world");
335        let slice = arena.alloc_slice(&[1u32, 2, 3]);
336        let val = arena.alloc(42u64);
337
338        // All references remain valid simultaneously
339        assert_eq!(s1, "hello");
340        assert_eq!(s2, "world");
341        assert_eq!(slice, &[1, 2, 3]);
342        assert_eq!(*val, 42);
343    }
344
345    #[test]
346    fn arena_grows_beyond_initial_capacity() {
347        let arena = FrameArena::new(64); // Very small initial capacity
348        // Allocate more than 64 bytes — arena should grow automatically
349        let large = "a]".repeat(100);
350        let s = arena.alloc_str(&large);
351        assert_eq!(s, large);
352    }
353
354    #[test]
355    fn default_capacity_grows_beyond_256kb_without_panic() {
356        let arena = FrameArena::default();
357        let large = vec![0xAB; DEFAULT_ARENA_CAPACITY + 64 * 1024];
358        let s = arena.alloc_slice(&large);
359        assert_eq!(s.len(), large.len());
360        assert_eq!(s[0], 0xAB);
361        assert!(arena.allocated_bytes() >= DEFAULT_ARENA_CAPACITY);
362    }
363
364    #[test]
365    fn allocated_bytes_tracks_usage() {
366        let arena = FrameArena::new(4096);
367        let initial = arena.allocated_bytes();
368        let _s = arena.alloc_str("some text for tracking");
369        assert!(arena.allocated_bytes() >= initial);
370    }
371
372    #[test]
373    fn as_bump_provides_access() {
374        let arena = FrameArena::new(4096);
375        let bump = arena.as_bump();
376        // Can use bump directly for advanced patterns
377        let val = bump.alloc(99u32);
378        assert_eq!(*val, 99);
379    }
380
381    #[test]
382    fn reset_then_heavy_reuse() {
383        let mut arena = FrameArena::new(4096);
384        for frame in 0..100 {
385            let s = arena.alloc_str(&format!("frame {frame}"));
386            assert!(s.starts_with("frame "));
387            let data: Vec<u32> = (0..50).collect();
388            let slice = arena.alloc_slice(&data);
389            assert_eq!(slice.len(), 50);
390            arena.reset();
391        }
392    }
393
394    #[test]
395    fn allocations_respect_alignment_requirements() {
396        let arena = FrameArena::new(4096);
397
398        let p_u8 = arena.alloc(1u8) as *mut u8 as usize;
399        let p_u32 = arena.alloc(2u32) as *mut u32 as usize;
400        let p_u64 = arena.alloc(3u64) as *mut u64 as usize;
401        let p_u128 = arena.alloc(4u128) as *mut u128 as usize;
402
403        assert_eq!(p_u8 % align_of::<u8>(), 0);
404        assert_eq!(p_u32 % align_of::<u32>(), 0);
405        assert_eq!(p_u64 % align_of::<u64>(), 0);
406        assert_eq!(p_u128 % align_of::<u128>(), 0);
407    }
408
409    #[test]
410    fn reset_reuses_existing_chunks_without_extra_growth() {
411        let mut arena = FrameArena::new(128);
412        let payload = vec![7u8; 32 * 1024];
413
414        let first = arena.alloc_slice(&payload);
415        assert_eq!(first.len(), payload.len());
416        let grown = arena.allocated_bytes_including_metadata();
417        assert!(grown > 128);
418
419        arena.reset();
420
421        let second = arena.alloc_slice(&payload);
422        assert_eq!(second.len(), payload.len());
423        let after = arena.allocated_bytes_including_metadata();
424        assert!(
425            after <= grown + 1024,
426            "arena should reuse existing chunks after reset: before={grown}, after={after}"
427        );
428    }
429
430    #[test]
431    fn reset_does_not_run_drop_glue_for_allocated_values() {
432        let drops = Rc::new(DropCounter::new(0));
433        {
434            let mut arena = FrameArena::new(1024);
435            let _spy = arena.alloc(DropSpy {
436                drops: Rc::clone(&drops),
437            });
438            arena.reset();
439            assert_eq!(
440                drops.get(),
441                0,
442                "reset() must not run Drop for bump allocations"
443            );
444        }
445        assert_eq!(
446            drops.get(),
447            0,
448            "dropping arena must not run Drop for bump allocations"
449        );
450    }
451
452    #[test]
453    fn debug_impl() {
454        let arena = FrameArena::new(1024);
455        let debug = format!("{arena:?}");
456        assert!(debug.contains("FrameArena"));
457    }
458
459    proptest! {
460        #[test]
461        fn proptest_random_alloc_reset_sequences_never_panic(ops in prop::collection::vec((0u8..=3, 0u16..1024), 1..300)) {
462            let mut arena = FrameArena::new(256);
463            for (op, size_hint) in ops {
464                match op {
465                    0 => {
466                        let len = (size_hint as usize % 256) + 1;
467                        let s = "x".repeat(len);
468                        let alloc = arena.alloc_str(&s);
469                        prop_assert_eq!(alloc.len(), len);
470                    }
471                    1 => {
472                        let len = (size_hint as usize % 128) + 1;
473                        let data = vec![size_hint as u32; len];
474                        let alloc = arena.alloc_slice(&data);
475                        prop_assert_eq!(alloc.len(), len);
476                    }
477                    2 => {
478                        let value = arena.alloc(size_hint as u64);
479                        prop_assert_eq!(*value, size_hint as u64);
480                    }
481                    _ => {
482                        arena.reset();
483                    }
484                }
485            }
486        }
487    }
488}