Skip to main content

lex_runtime/
arena.rs

1//! Per-request bump-allocator arena (#463 scaffolding).
2//!
3//! The minimal lifecycle machinery for request-scoped allocations.
4//! See `docs/design/jit-roadmap.md` for the broader plan and
5//! issue #463 for the perf rationale.
6//!
7//! ## Status: scaffolding only
8//!
9//! This module is wired through the `EffectHandler` trait so the VM
10//! can call `enter_request_scope` / `exit_request_scope` at the
11//! right boundaries — but the arena is **not yet plumbed into
12//! `Value` allocations**. Today every `MakeRecord`, `MakeList`,
13//! `Str` still goes through the global allocator. The arena gets
14//! created and dropped at each request boundary as a no-op proof
15//! that the lifetime machinery works, ready for a follow-on slice
16//! to route actual allocations.
17//!
18//! ## Why scaffolding-first
19//!
20//! Routing `Value`'s heap parts (`Box<IndexMap<…>>`,
21//! `VecDeque<Value>`, `Vec<u8>`, …) through the arena requires
22//! either a lifetime parameter on `Value` (massive churn — every
23//! one of the ~60 `as_int` / `as_str` / `as_bool` call sites in
24//! the codebase) or an arena-id tag on every heap allocation
25//! (`Drop` impl must dispatch to the right allocator). Both are
26//! sized in months — see the JIT roadmap doc.
27//!
28//! Landing the lifecycle first means future Value-rep changes
29//! have a stable trait surface to plug into. The cost today is
30//! one allocator construction + drop per HTTP request — negligible
31//! compared to the request itself.
32//!
33//! ## Lifetime model
34//!
35//! - One arena per request handler invocation.
36//! - Arena owns a bump-allocated page chain. `alloc` is a
37//!   pointer-bump on the current page; full pages chain into a
38//!   `Vec<Page>` and the whole vec drops at scope-exit.
39//! - Cannot outlive the request — values built into the arena
40//!   must not escape via channels / captures / closures. That's
41//!   why the verifier-based escape analysis in #464 is a
42//!   prerequisite for actually routing allocations through it;
43//!   for now nothing escapes because nothing uses it.
44
45use std::cell::UnsafeCell;
46
47/// Page size in bytes for arena allocations. 64 KiB matches typical
48/// L2 line patterns and is large enough that most request-scoped
49/// values fit in a single page. Pages are heap-allocated; the chain
50/// grows on demand.
51const PAGE_BYTES: usize = 64 * 1024;
52
53/// A bump allocator backing one request's allocations.
54///
55/// `alloc` returns an unaligned byte cursor; callers responsible
56/// for alignment. Drop releases all pages at once — no per-value
57/// destructor is run, so callers must put only POD-shaped data
58/// here today (slice-1 doesn't actually allocate Values into the
59/// arena; this is purely a lifecycle harness).
60pub struct Arena {
61    /// All allocated pages in order. The last page is the active
62    /// bump target; previous pages are full.
63    pages: UnsafeCell<Vec<Box<[u8; PAGE_BYTES]>>>,
64    /// Bump cursor within the active page.
65    cursor: UnsafeCell<usize>,
66}
67
68impl Arena {
69    /// Create an empty arena. No pages allocated until the first
70    /// `alloc` call.
71    pub fn new() -> Self {
72        Self {
73            pages: UnsafeCell::new(Vec::new()),
74            cursor: UnsafeCell::new(0),
75        }
76    }
77
78    /// Allocate `len` bytes from the arena. Returns a `&mut [u8]`
79    /// bound to the arena's lifetime. Grows by appending a fresh
80    /// page if the request doesn't fit in the active page.
81    ///
82    /// Panics if `len > PAGE_BYTES`. Larger allocations would
83    /// require multi-page allocation (boxed slice). Out of scope
84    /// for the scaffolding — the IndexMap / VecDeque values the
85    /// arena will eventually carry are well under 64 KiB.
86    ///
87    /// # Safety
88    ///
89    /// The returned slice is uninitialized.
90    ///
91    // `mut_from_ref` is the canonical bump-allocator shape (same as
92    // `bumpalo::Bump::alloc` and `typed_arena::Arena::alloc`): an
93    // immutable `&self` hands out a fresh mutable region per call.
94    // Soundness rests on (a) `UnsafeCell` for interior mutability,
95    // (b) `!Sync` so no two threads call this at once, and
96    // (c) the strict bump invariant — every returned slice starts
97    // past every prior slice's end, so references never alias.
98    #[allow(clippy::mut_from_ref)]
99    pub fn alloc(&self, len: usize) -> &mut [u8] {
100        assert!(len <= PAGE_BYTES, "arena alloc exceeds page size");
101        // SAFETY: `Arena` is `!Sync` (UnsafeCell), so no concurrent
102        // mutation. The reference returned by this call doesn't
103        // alias any prior reference because we strictly bump.
104        unsafe {
105            let pages = &mut *self.pages.get();
106            let cursor = &mut *self.cursor.get();
107            if pages.is_empty() || *cursor + len > PAGE_BYTES {
108                pages.push(Box::new([0u8; PAGE_BYTES]));
109                *cursor = 0;
110            }
111            let page_idx = pages.len() - 1;
112            let start = *cursor;
113            *cursor += len;
114            let page: *mut [u8; PAGE_BYTES] = pages[page_idx].as_mut() as *mut _;
115            std::slice::from_raw_parts_mut((*page).as_mut_ptr().add(start), len)
116        }
117    }
118
119    /// Total bytes allocated across all pages. Useful for tests
120    /// and a future `arena.stat` builtin that surfaces per-request
121    /// allocation pressure.
122    pub fn bytes_allocated(&self) -> usize {
123        // SAFETY: see `alloc` — no concurrent mutation.
124        unsafe {
125            let pages = &*self.pages.get();
126            let cursor = *self.cursor.get();
127            if pages.is_empty() { 0 } else { (pages.len() - 1) * PAGE_BYTES + cursor }
128        }
129    }
130
131    /// Page count. Tests use this to confirm grow-on-demand fires
132    /// at the right thresholds.
133    pub fn page_count(&self) -> usize {
134        // SAFETY: see `alloc`.
135        unsafe { (*self.pages.get()).len() }
136    }
137}
138
139impl Default for Arena {
140    fn default() -> Self { Self::new() }
141}
142
143/// Identifier handed out by `EffectHandler::enter_request_scope`
144/// and returned to `EffectHandler::exit_request_scope`. The
145/// implementation chooses its representation; the scaffolding's
146/// `DefaultHandler` uses a monotonic counter.
147#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
148pub struct ScopeId(pub u64);
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    #[test]
155    fn empty_arena_allocates_no_pages() {
156        let a = Arena::new();
157        assert_eq!(a.page_count(), 0);
158        assert_eq!(a.bytes_allocated(), 0);
159    }
160
161    #[test]
162    fn first_alloc_creates_a_page() {
163        let a = Arena::new();
164        let _b = a.alloc(16);
165        assert_eq!(a.page_count(), 1);
166        assert_eq!(a.bytes_allocated(), 16);
167    }
168
169    #[test]
170    fn alloc_grows_to_a_new_page_when_full() {
171        let a = Arena::new();
172        let _b1 = a.alloc(PAGE_BYTES - 100);
173        assert_eq!(a.page_count(), 1);
174        let _b2 = a.alloc(200);
175        assert_eq!(a.page_count(), 2);
176    }
177
178    #[test]
179    fn alloc_returns_distinct_regions() {
180        let a = Arena::new();
181        let b1 = a.alloc(8);
182        b1[0] = 0xAB;
183        let b2 = a.alloc(8);
184        b2[0] = 0xCD;
185        // No overlap — writes to b2 didn't clobber b1.
186        assert_eq!(b1[0], 0xAB);
187        assert_eq!(b2[0], 0xCD);
188    }
189
190    #[test]
191    fn alloc_larger_than_page_panics() {
192        let a = Arena::new();
193        let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
194            a.alloc(PAGE_BYTES + 1);
195        }));
196        assert!(r.is_err());
197    }
198}