lex_bytecode/arena.rs
1//! Request-scope arena-eligibility analysis (#463 slice 1).
2//!
3//! Per-allocation-site classification: is this `MakeRecord` /
4//! `MakeTuple` value safe to route through the active per-request
5//! arena (`EffectHandler::enter_request_scope`), instead of the
6//! global allocator?
7//!
8//! ## Relationship to `escape::analyze_function` (#464)
9//!
10//! Same lattice, same worklist, same step rules — with **one bit of
11//! policy flipped**: `Op::Return` is not an escape op here, because
12//! the returned value goes to the caller's stack and the caller is
13//! in the same request scope as us. Everything else (`Call`,
14//! `CallClosure`, `TailCall`, `EffectCall`, `MakeClosure` captures,
15//! aggregate-as-field, worker-pool ops, `Dup`, …) stays a hatch under
16//! the slice-1 intra-procedural conservative policy. The shared
17//! machinery is `escape::analyze_function_with_policy(_,
18//! Policy::RequestScope)`; this module wraps it and inverts the
19//! per-site `escapes` bool into `arena_eligible`.
20//!
21//! ## Slice scope
22//!
23//! Analysis only. No opcode lowering, no runtime behavior change, no
24//! bytecode-format change. Slice 2 (`AllocArenaRecord` /
25//! `AllocArenaList` / handle variants on `Value`) consults
26//! `build_arena_index` at codegen time.
27//!
28//! ## Soundness contract
29//!
30//! Inherits #464's contract verbatim (`docs/design/escape-analysis.md`
31//! § "Soundness contract"):
32//!
33//! - **Over-approximation** (`arena_eligible = false` when the value
34//! actually stays in-scope) costs a heap allocation — the
35//! status-quo baseline. Acceptable.
36//! - **Under-approximation** (`arena_eligible = true` when the value
37//! actually escapes the request) would let an arena handle outlive
38//! its slab and is UB. Slice 2 must pair this analysis with an
39//! unconditional runtime fallback (same shape as #464's
40//! `AllocStackRecord` heap fallback), so a missed hatch costs
41//! correctness only if the analysis is wrong *and* the fallback is
42//! omitted — never both.
43//!
44//! ## Out of scope for slice 1
45//!
46//! - Inter-procedural escape (the scoping doc defers this until
47//! inlining lands with #465 phase 1; any `Call` is a hatch here).
48//! - Worker-handler lifetime split (`spawn_for_worker` clone-handlers
49//! get a fresh empty arena stack — values handed to workers must
50//! never become arena handles). Already covered by the conservative
51//! hatches on `Call`/`ParallelMap`/`SortByKey`; slice 2 must keep
52//! that invariant when routing.
53
54use std::collections::HashMap;
55
56use crate::escape::{analyze_function_with_policy, Policy, SiteKind};
57use crate::program::Function;
58
59/// Per-function arena-eligibility report. Mirrors `EscapeReport`'s
60/// shape with the per-site bool inverted (`arena_eligible =
61/// !escapes_under_request_scope`) and renamed to reflect what
62/// downstream codegen will use it for.
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct ArenaReport {
65 pub fn_name: String,
66 pub sites: Vec<ArenaSite>,
67}
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
70pub struct ArenaSite {
71 pub pc: u32,
72 pub kind: SiteKind,
73 pub shape_idx: u32,
74 pub field_count: u16,
75 /// `true` if the value never leaves the request scope on any
76 /// reachable path — safe to allocate from the active request
77 /// arena. `false` means a hatch (`Call`, `EffectCall`,
78 /// `MakeClosure` capture, worker-pool op, …) is reachable — keep
79 /// on the heap.
80 pub arena_eligible: bool,
81}
82
83/// Analyze one function. Cheap on functions with no aggregate sites
84/// (early-exits in the underlying pass).
85pub fn analyze_function(func: &Function) -> ArenaReport {
86 let r = analyze_function_with_policy(func, Policy::RequestScope);
87 let sites = r
88 .sites
89 .into_iter()
90 .map(|s| ArenaSite {
91 pc: s.pc,
92 kind: s.kind,
93 shape_idx: s.shape_idx,
94 field_count: s.field_count,
95 arena_eligible: !s.escapes,
96 })
97 .collect();
98 ArenaReport { fn_name: r.fn_name, sites }
99}
100
101/// Analyze every function. Functions with no aggregate sites are
102/// omitted from the result, matching `escape::analyze_program`.
103pub fn analyze_program(functions: &[Function]) -> Vec<ArenaReport> {
104 functions
105 .iter()
106 .filter_map(|f| {
107 let r = analyze_function(f);
108 (!r.sites.is_empty()).then_some(r)
109 })
110 .collect()
111}
112
113/// Convenience map keyed by `(fn_name, pc)` for direct lookup during
114/// the slice-2 codegen pass. Mirrors `escape::build_escape_index`
115/// exactly so the codegen swap is structural.
116pub fn build_arena_index(functions: &[Function]) -> HashMap<(String, u32), bool> {
117 let mut idx = HashMap::new();
118 for report in analyze_program(functions) {
119 for site in report.sites {
120 idx.insert((report.fn_name.clone(), site.pc), site.arena_eligible);
121 }
122 }
123 idx
124}
125
126#[cfg(test)]
127mod tests {
128 use super::*;
129 use crate::op::Op;
130 use crate::program::{Function, ZERO_BODY_HASH};
131
132 fn func(name: &str, locals_count: u16, arity: u16, code: Vec<Op>) -> Function {
133 Function {
134 name: name.into(),
135 arity,
136 locals_count,
137 code,
138 effects: vec![],
139 body_hash: ZERO_BODY_HASH,
140 refinements: vec![],
141 field_ic_sites: 0,
142 }
143 }
144
145 fn assert_eligible(report: &ArenaReport, expected: &[(u32, bool)]) {
146 let got: Vec<(u32, bool)> = report
147 .sites
148 .iter()
149 .map(|s| (s.pc, s.arena_eligible))
150 .collect();
151 assert_eq!(
152 got, expected,
153 "arena eligibility for `{}` differs from expected",
154 report.fn_name
155 );
156 }
157
158 // ---- The slice's reason for existing: Return is not a hatch ----
159
160 /// A record built and returned (e.g. the `Response` the handler
161 /// hands up to `net.serve_fn`) is arena-eligible. Under #464's
162 /// frame-scope policy this identical shape escapes — the
163 /// divergence is the whole point.
164 #[test]
165 fn record_returned_is_arena_eligible() {
166 let f = func("handler", 0, 0, vec![
167 Op::PushConst(0),
168 Op::PushConst(1),
169 Op::MakeRecord { shape_idx: 0, field_count: 2 },
170 Op::Return,
171 ]);
172 let r = analyze_function(&f);
173 assert_eligible(&r, &[(2, true)]);
174 }
175
176 /// Tuple returned: same divergence as record. Confirms the
177 /// policy bit applies uniformly across aggregate kinds.
178 #[test]
179 fn tuple_returned_is_arena_eligible() {
180 let f = func("handler_t", 0, 0, vec![
181 Op::PushConst(0),
182 Op::PushConst(1),
183 Op::MakeTuple(2),
184 Op::Return,
185 ]);
186 let r = analyze_function(&f);
187 assert_eligible(&r, &[(2, true)]);
188 }
189
190 /// Round-trip through a local, then returned. The local read
191 /// keeps the slot tracked, and the Return doesn't escape under
192 /// request scope. End-to-end arena-eligible.
193 #[test]
194 fn record_round_tripped_and_returned_is_arena_eligible() {
195 let f = func("handler_rt", 1, 0, vec![
196 Op::PushConst(0),
197 Op::PushConst(1),
198 Op::MakeRecord { shape_idx: 0, field_count: 2 },
199 Op::StoreLocal(0),
200 Op::LoadLocal(0),
201 Op::Return,
202 ]);
203 let r = analyze_function(&f);
204 assert_eligible(&r, &[(2, true)]);
205 }
206
207 // ---- All other hatches still apply (parity with #464) ----
208
209 /// Slice 1's intra-procedural conservatism: any `Call` into a
210 /// non-inlined helper is a hatch. Args may leak via the callee's
211 /// own escape paths (`spawn`, channel send, module-level store).
212 #[test]
213 fn record_passed_to_call_is_not_arena_eligible() {
214 let f = func("caller", 0, 0, vec![
215 Op::PushConst(0),
216 Op::MakeRecord { shape_idx: 0, field_count: 1 },
217 Op::Call { fn_id: 1, arity: 1, node_id_idx: 0 },
218 Op::Return,
219 ]);
220 let r = analyze_function(&f);
221 assert_eligible(&r, &[(1, false)]);
222 }
223
224 /// Closure capture is a hatch — closures may outlive the request
225 /// (stored in module-level state, returned to the runtime, …).
226 #[test]
227 fn record_captured_in_closure_is_not_arena_eligible() {
228 let f = func("capturer", 0, 0, vec![
229 Op::PushConst(0),
230 Op::MakeRecord { shape_idx: 0, field_count: 1 },
231 Op::MakeClosure { fn_id: 1, capture_count: 1 },
232 Op::Return,
233 ]);
234 let r = analyze_function(&f);
235 assert_eligible(&r, &[(1, false)]);
236 }
237
238 /// EffectCall is a hatch — effect handlers can spawn workers,
239 /// send on channels, persist to disk: any path that outlives the
240 /// request.
241 #[test]
242 fn record_passed_to_effect_is_not_arena_eligible() {
243 let f = func("effecting", 0, 0, vec![
244 Op::PushConst(0),
245 Op::MakeRecord { shape_idx: 0, field_count: 1 },
246 Op::EffectCall { kind_idx: 0, op_idx: 0, arity: 1, node_id_idx: 0 },
247 Op::Return,
248 ]);
249 let r = analyze_function(&f);
250 assert_eligible(&r, &[(1, false)]);
251 }
252
253 // ---- Site bookkeeping ----
254
255 #[test]
256 fn record_dropped_is_arena_eligible() {
257 let f = func("drop", 0, 0, vec![
258 Op::PushConst(0),
259 Op::MakeRecord { shape_idx: 0, field_count: 1 },
260 Op::Pop,
261 Op::PushConst(0),
262 Op::Return,
263 ]);
264 let r = analyze_function(&f);
265 assert_eligible(&r, &[(1, true)]);
266 }
267
268 /// Inner record stored as a field of outer; outer returned.
269 /// Inner escapes (consumed by the outer aggregate's heap
270 /// constructor — slice-1 doesn't yet model "outer is also
271 /// arena → inner can live with it"; that's a slice-2 codegen
272 /// question). Outer is arena-eligible — its only consumer is
273 /// `Return`, which doesn't escape under request scope.
274 #[test]
275 fn outer_returned_aggregate_is_arena_eligible_inner_field_is_not() {
276 let f = func("nest", 0, 0, vec![
277 Op::PushConst(0),
278 Op::MakeRecord { shape_idx: 0, field_count: 1 }, // inner @ pc=1
279 Op::PushConst(1),
280 Op::MakeRecord { shape_idx: 1, field_count: 2 }, // outer @ pc=3
281 Op::Return,
282 ]);
283 let r = analyze_function(&f);
284 assert_eligible(&r, &[(1, false), (3, true)]);
285 }
286
287 /// Two sites in one function, independently classified: one
288 /// returned (arena), one passed to a call (heap).
289 #[test]
290 fn two_sites_classified_independently() {
291 let f = func("mixed", 1, 0, vec![
292 Op::PushConst(0),
293 Op::MakeRecord { shape_idx: 0, field_count: 1 }, // pc=1: kept, returned
294 Op::StoreLocal(0),
295 Op::PushConst(0),
296 Op::MakeRecord { shape_idx: 0, field_count: 1 }, // pc=4: passed to call
297 Op::Call { fn_id: 1, arity: 1, node_id_idx: 0 },
298 Op::Pop,
299 Op::LoadLocal(0),
300 Op::Return,
301 ]);
302 let r = analyze_function(&f);
303 assert_eligible(&r, &[(1, true), (4, false)]);
304 }
305
306 #[test]
307 fn build_arena_index_keys_by_fn_and_pc() {
308 let f = func("idx_test", 0, 0, vec![
309 Op::PushConst(0),
310 Op::MakeRecord { shape_idx: 0, field_count: 1 },
311 Op::Return,
312 ]);
313 let idx = build_arena_index(&[f]);
314 assert_eq!(idx.get(&("idx_test".into(), 1)), Some(&true));
315 }
316
317 #[test]
318 fn analyze_program_skips_functions_with_no_sites() {
319 let f1 = func("noaggs", 0, 0, vec![Op::PushConst(0), Op::Return]);
320 let f2 = func("hasagg", 0, 0, vec![
321 Op::PushConst(0),
322 Op::MakeRecord { shape_idx: 0, field_count: 1 },
323 Op::Return,
324 ]);
325 let reports = analyze_program(&[f1, f2]);
326 assert_eq!(reports.len(), 1);
327 assert_eq!(reports[0].fn_name, "hasagg");
328 }
329
330 // ---- Parity sanity vs #464 ----
331
332 /// Where the two analyses *should* agree (a Call hatch is a hatch
333 /// regardless of policy), they do.
334 #[test]
335 fn parity_with_frame_escape_on_shared_hatch() {
336 use crate::escape::analyze_function as analyze_frame;
337 let f = func("parity_hatch", 0, 0, vec![
338 Op::PushConst(0),
339 Op::MakeRecord { shape_idx: 0, field_count: 1 },
340 Op::Call { fn_id: 1, arity: 1, node_id_idx: 0 },
341 Op::Return,
342 ]);
343 let arena = analyze_function(&f);
344 let frame = analyze_frame(&f);
345 assert!(!arena.sites[0].arena_eligible);
346 assert!(frame.sites[0].escapes);
347 }
348
349 /// Where the two *should* diverge (a plain Return), they do —
350 /// this test is the documented intentional difference and would
351 /// fire if the policy bit ever got dropped in a refactor.
352 #[test]
353 fn diverges_from_frame_escape_on_return() {
354 use crate::escape::analyze_function as analyze_frame;
355 let f = func("parity_return", 0, 0, vec![
356 Op::PushConst(0),
357 Op::MakeRecord { shape_idx: 0, field_count: 1 },
358 Op::Return,
359 ]);
360 let arena = analyze_function(&f);
361 let frame = analyze_frame(&f);
362 assert!(arena.sites[0].arena_eligible);
363 assert!(frame.sites[0].escapes);
364 }
365}