Skip to main content

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}