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}