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 /// **Deep-leaf widening** (#463 follow-up): the analysis now
270 /// tracks `inner` as contained in `outer` rather than leaking
271 /// it at the build site. Under `Policy::RequestScope` `Return`
272 /// doesn't escape `outer`, and the transitive expansion never
273 /// touches `inner` — so **both** sites are arena-eligible. The
274 /// pre-refinement answer was `[(1, false), (3, true)]`; the
275 /// recovery is the deep-tree response shape's whole point.
276 #[test]
277 fn outer_returned_aggregate_makes_inner_field_arena_eligible_too() {
278 let f = func("nest", 0, 0, vec![
279 Op::PushConst(0),
280 Op::MakeRecord { shape_idx: 0, field_count: 1 }, // inner @ pc=1
281 Op::PushConst(1),
282 Op::MakeRecord { shape_idx: 1, field_count: 2 }, // outer @ pc=3
283 Op::Return,
284 ]);
285 let r = analyze_function(&f);
286 assert_eligible(&r, &[(1, true), (3, true)]);
287 }
288
289 /// Two sites in one function, independently classified: one
290 /// returned (arena), one passed to a call (heap).
291 #[test]
292 fn two_sites_classified_independently() {
293 let f = func("mixed", 1, 0, vec![
294 Op::PushConst(0),
295 Op::MakeRecord { shape_idx: 0, field_count: 1 }, // pc=1: kept, returned
296 Op::StoreLocal(0),
297 Op::PushConst(0),
298 Op::MakeRecord { shape_idx: 0, field_count: 1 }, // pc=4: passed to call
299 Op::Call { fn_id: 1, arity: 1, node_id_idx: 0 },
300 Op::Pop,
301 Op::LoadLocal(0),
302 Op::Return,
303 ]);
304 let r = analyze_function(&f);
305 assert_eligible(&r, &[(1, true), (4, false)]);
306 }
307
308 #[test]
309 fn build_arena_index_keys_by_fn_and_pc() {
310 let f = func("idx_test", 0, 0, vec![
311 Op::PushConst(0),
312 Op::MakeRecord { shape_idx: 0, field_count: 1 },
313 Op::Return,
314 ]);
315 let idx = build_arena_index(&[f]);
316 assert_eq!(idx.get(&("idx_test".into(), 1)), Some(&true));
317 }
318
319 #[test]
320 fn analyze_program_skips_functions_with_no_sites() {
321 let f1 = func("noaggs", 0, 0, vec![Op::PushConst(0), Op::Return]);
322 let f2 = func("hasagg", 0, 0, vec![
323 Op::PushConst(0),
324 Op::MakeRecord { shape_idx: 0, field_count: 1 },
325 Op::Return,
326 ]);
327 let reports = analyze_program(&[f1, f2]);
328 assert_eq!(reports.len(), 1);
329 assert_eq!(reports[0].fn_name, "hasagg");
330 }
331
332 // ---- Parity sanity vs #464 ----
333
334 /// Where the two analyses *should* agree (a Call hatch is a hatch
335 /// regardless of policy), they do.
336 #[test]
337 fn parity_with_frame_escape_on_shared_hatch() {
338 use crate::escape::analyze_function as analyze_frame;
339 let f = func("parity_hatch", 0, 0, vec![
340 Op::PushConst(0),
341 Op::MakeRecord { shape_idx: 0, field_count: 1 },
342 Op::Call { fn_id: 1, arity: 1, node_id_idx: 0 },
343 Op::Return,
344 ]);
345 let arena = analyze_function(&f);
346 let frame = analyze_frame(&f);
347 assert!(!arena.sites[0].arena_eligible);
348 assert!(frame.sites[0].escapes);
349 }
350
351 /// Where the two *should* diverge (a plain Return), they do —
352 /// this test is the documented intentional difference and would
353 /// fire if the policy bit ever got dropped in a refactor.
354 #[test]
355 fn diverges_from_frame_escape_on_return() {
356 use crate::escape::analyze_function as analyze_frame;
357 let f = func("parity_return", 0, 0, vec![
358 Op::PushConst(0),
359 Op::MakeRecord { shape_idx: 0, field_count: 1 },
360 Op::Return,
361 ]);
362 let arena = analyze_function(&f);
363 let frame = analyze_frame(&f);
364 assert!(arena.sites[0].arena_eligible);
365 assert!(frame.sites[0].escapes);
366 }
367}