lex_bytecode/escape.rs
1//! Escape analysis for stack-allocatable aggregate sites —
2//! `Op::MakeRecord` and `Op::MakeTuple` (#464 + tuple widening).
3//!
4//! Walks every function's bytecode to classify each aggregate
5//! allocation site as either **stack-allocatable** (the value never
6//! leaves the function frame) or **escapes** (used as a closure
7//! capture, returned, stored in another aggregate, passed to a call,
8//! sent on a channel, etc.). The escape rules are identical for
9//! records and tuples — only the eventual codegen opcode differs —
10//! so a single `Slot::Agg(pc)` lattice value tracks both.
11//!
12//! ## Status
13//!
14//! - **Records** (`MakeRecord`): analysis + codegen complete (#464).
15//! `compiler::apply_escape_lowering` rewrites non-escaping
16//! `MakeRecord` to `AllocStackRecord`.
17//! - **Tuples** (`MakeTuple`): analysis only, this slice. Sites are
18//! classified and reported with `SiteKind::Tuple`, but no codegen
19//! consumes them yet — `apply_escape_lowering` only rewrites
20//! `MakeRecord`, so reporting tuple sites is inert until a tuple
21//! stack-alloc opcode lands. Mirrors how #464 sequenced records
22//! (analysis → codegen → bench).
23//!
24//! ## Approach
25//!
26//! Abstract interpretation over the bytecode CFG. Each abstract
27//! state tracks:
28//! - per-stack-slot: `Slot::Agg(pc)` (the aggregate allocated at
29//! `pc`, still local) or `Slot::Other` (anything else — int,
30//! string, captured value, aggregate we've stopped tracking)
31//! - per-local: same `Slot` lattice, indexed by `locals[i]`
32//!
33//! At each op we update the abstract state and union any newly-
34//! observed escapes into a `HashSet<u32>` keyed by allocation pc.
35//! Worklist fixpoint iterates until no state changes — joins use a
36//! pointwise merge that downgrades `Agg(a) ⊔ Agg(b)` (a ≠ b) and
37//! `Agg(a) ⊔ Other` to `Other`, marking the involved sites as
38//! escaped (we can no longer prove they stay local from this
39//! merge point forward).
40//!
41//! ## Intra-procedural limit
42//!
43//! Calls (`Call`, `TailCall`, `CallClosure`) escape all argument
44//! aggregates — we can't see the callee's body from here. Inlining
45//! could recover the cross-fn case but is deliberately out of
46//! scope; the issue's wording ("function frame") matches
47//! intra-procedural.
48//!
49//! ## Conservative defaults
50//!
51//! Whenever the analysis can't prove an aggregate stays local, it
52//! defaults to *escaped*. False positives (sites flagged as
53//! escaping when they actually don't) cost a heap allocation per
54//! request — the existing baseline. False negatives (a flagged-
55//! local site that actually escapes) would corrupt memory under
56//! stack-alloc codegen, so the codegen step treats the analysis
57//! output as a *necessary* but not sufficient precondition and
58//! pairs it with an unconditional runtime fallback.
59
60use std::collections::{HashMap, HashSet};
61
62use crate::op::Op;
63use crate::program::Function;
64
65/// Scope policy for the escape analysis. Only `Op::Return` consults
66/// it — every other escape rule is identical across policies.
67///
68/// - `FrameScope` (default for #464 stack-record lowering): `Return`
69/// leaks its operand, because the returned value crosses the
70/// current function's frame into the caller.
71/// - `RequestScope` (for #463 arena routing): `Return` does NOT leak
72/// its operand — the value goes to the caller's stack and the
73/// caller is in the same request scope opened by
74/// `EffectHandler::enter_request_scope`. What the caller does with
75/// the returned value is an inter-procedural question, deliberately
76/// left to the caller's own conservative `Call` arm (which marks
77/// its args as escaping in the intra-procedural first cut). See
78/// `docs/design/arena-plumbing.md`.
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80pub enum Policy {
81 FrameScope,
82 RequestScope,
83}
84
85/// Abstract value at a stack or local slot during the analysis.
86#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
87pub enum Slot {
88 /// Holds the stack-allocatable aggregate (a record from
89 /// `Op::MakeRecord` or a tuple from `Op::MakeTuple`) allocated at
90 /// this pc. As long as every consumer reads this slot via a field
91 /// / element read (`GetField` / `GetElem`), `Pop`, or a
92 /// `StoreLocal`/`LoadLocal` round-trip, the site stays a
93 /// stack-alloc candidate. The escape rules are identical for both
94 /// aggregate kinds — only the codegen opcode differs — so the
95 /// analysis tracks them with one variant keyed on the alloc pc.
96 Agg(u32),
97 /// Anything else — primitives, already-escaped aggregates,
98 /// values produced by ops we don't model precisely. Treated
99 /// as not-a-tracked-aggregate for escape purposes.
100 Other,
101}
102
103impl Slot {
104 /// Pointwise merge for join points. Same site survives;
105 /// anything else collapses to `Other`. Callers responsible for
106 /// recording any `Rec(_)` that was merged-away into the
107 /// escape set — we lose track of those sites past this merge.
108 fn merge(self, other: Slot) -> Slot {
109 match (self, other) {
110 (Slot::Agg(a), Slot::Agg(b)) if a == b => Slot::Agg(a),
111 _ => Slot::Other,
112 }
113 }
114}
115
116/// Abstract state at one program point: the value stack from
117/// bottom up, plus a flat local-variable map.
118#[derive(Debug, Clone, PartialEq, Eq)]
119struct State {
120 stack: Vec<Slot>,
121 locals: Vec<Slot>,
122}
123
124impl State {
125 fn entry(locals_count: usize, arity: usize) -> Self {
126 // Function parameters land in the first `arity` locals;
127 // they're potentially-escaped values handed in by the
128 // caller, but the *sites* that produced them live in the
129 // caller's frame and aren't our concern. Treat as Other.
130 Self {
131 stack: Vec::new(),
132 locals: vec![Slot::Other; locals_count.max(arity)],
133 }
134 }
135
136 /// Merge `other` into `self`. Returns `(merged_state, escaped_sites)`
137 /// — the sites that we lost track of during the merge. Callers
138 /// union the escapes into the function-level set.
139 fn merge_with(&self, other: &State) -> (State, HashSet<u32>) {
140 let mut escaped = HashSet::new();
141 // Mismatched stack depths are a verifier-level bug (#347
142 // already checks this); for the escape analysis we just
143 // truncate to the shorter and proceed — any sites on the
144 // extra slots count as escapes since they're no longer
145 // reachable from the join state.
146 let stack_len = self.stack.len().min(other.stack.len());
147 let mut stack = Vec::with_capacity(stack_len);
148 for i in 0..stack_len {
149 let merged = self.stack[i].merge(other.stack[i]);
150 // If a Rec was merged-away (either path had Rec, the
151 // result is Other), the corresponding site escapes.
152 if merged == Slot::Other {
153 if let Slot::Agg(p) = self.stack[i] { escaped.insert(p); }
154 if let Slot::Agg(p) = other.stack[i] { escaped.insert(p); }
155 }
156 stack.push(merged);
157 }
158 // The dropped tail of the longer stack also leaks any Rec.
159 for tail in self.stack.iter().skip(stack_len).chain(other.stack.iter().skip(stack_len)) {
160 if let Slot::Agg(p) = tail { escaped.insert(*p); }
161 }
162 let local_len = self.locals.len().max(other.locals.len());
163 let mut locals = Vec::with_capacity(local_len);
164 for i in 0..local_len {
165 let a = self.locals.get(i).copied().unwrap_or(Slot::Other);
166 let b = other.locals.get(i).copied().unwrap_or(Slot::Other);
167 let merged = a.merge(b);
168 if merged == Slot::Other {
169 if let Slot::Agg(p) = a { escaped.insert(p); }
170 if let Slot::Agg(p) = b { escaped.insert(p); }
171 }
172 locals.push(merged);
173 }
174 (State { stack, locals }, escaped)
175 }
176}
177
178/// Per-function escape report — the artifact codegen consumes to
179/// decide where to emit a stack-alloc opcode vs the heap constructor.
180///
181/// Each entry is keyed by the allocation pc (the `Op::MakeRecord` or
182/// `Op::MakeTuple` site's index in the function's `code` vec) and
183/// tagged with its `SiteKind`. `escapes = false` means: across every
184/// reachable path through the function, the aggregate allocated here
185/// is only ever read locally (`GetField` / `GetElem`, dropped via
186/// `Pop`, round-tripped through locals) — never returned, captured,
187/// stored in another aggregate, or passed to a call.
188#[derive(Debug, Clone, PartialEq, Eq)]
189pub struct EscapeReport {
190 pub fn_name: String,
191 /// One entry per stack-allocatable aggregate (`MakeRecord` /
192 /// `MakeTuple`) site in the function, in pc order.
193 pub sites: Vec<EscapeSite>,
194}
195
196/// Which aggregate constructor an escape site is — determines which
197/// stack-alloc opcode a future codegen slice would emit for it
198/// (`AllocStackRecord` for records; tuple stack-alloc is not yet
199/// implemented, so `Tuple` sites are reported but not lowered).
200#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
201pub enum SiteKind {
202 /// `Op::MakeRecord`: `shape_idx` is meaningful, `field_count` is
203 /// the record's field count.
204 Record,
205 /// `Op::MakeTuple`: `shape_idx` is unused (`0`), `field_count` is
206 /// the tuple arity.
207 Tuple,
208}
209
210#[derive(Debug, Clone, PartialEq, Eq, Hash)]
211pub struct EscapeSite {
212 pub pc: u32,
213 pub kind: SiteKind,
214 pub shape_idx: u32,
215 pub field_count: u16,
216 pub escapes: bool,
217}
218
219/// Analyze every function in `functions`. Returns one
220/// `EscapeReport` per function that contains at least one
221/// `MakeRecord` site (functions with no record allocations are
222/// omitted — the consumer doesn't care about them).
223pub fn analyze_program(functions: &[Function]) -> Vec<EscapeReport> {
224 functions
225 .iter()
226 .filter_map(|f| {
227 let r = analyze_function(f);
228 (!r.sites.is_empty()).then_some(r)
229 })
230 .collect()
231}
232
233/// Analyze one function under the default `Policy::FrameScope`.
234/// See `analyze_function_with_policy` for the policy-parameterized
235/// form used by #463 arena routing.
236pub fn analyze_function(func: &Function) -> EscapeReport {
237 analyze_function_with_policy(func, Policy::FrameScope)
238}
239
240/// Analyze one function under the given scope policy. Cheap to call
241/// even when there are no aggregate sites (early-exits after the
242/// first pass over `code`).
243pub fn analyze_function_with_policy(func: &Function, policy: Policy) -> EscapeReport {
244 // Inventory the MakeRecord sites first. If there are none,
245 // skip the whole fixpoint — the function can't benefit from
246 // stack allocation anyway.
247 let sites: Vec<(u32, SiteKind, u32, u16)> = func
248 .code
249 .iter()
250 .enumerate()
251 .filter_map(|(pc, op)| match op {
252 Op::MakeRecord { shape_idx, field_count } => {
253 Some((pc as u32, SiteKind::Record, *shape_idx, *field_count))
254 }
255 Op::MakeTuple(arity) => {
256 Some((pc as u32, SiteKind::Tuple, 0, *arity))
257 }
258 _ => None,
259 })
260 .collect();
261 if sites.is_empty() {
262 return EscapeReport { fn_name: func.name.clone(), sites: vec![] };
263 }
264
265 let n = func.code.len();
266 let locals_count = func.locals_count as usize;
267 let arity = func.arity as usize;
268
269 // Per-pc in-states, computed by the fixpoint. None = unvisited.
270 let mut in_state: Vec<Option<State>> = vec![None; n];
271 let mut escaped: HashSet<u32> = HashSet::new();
272
273 let mut worklist: Vec<(usize, State)> = vec![(0, State::entry(locals_count, arity))];
274
275 while let Some((pc, incoming)) = worklist.pop() {
276 if pc >= n { continue; }
277
278 // Merge into existing in-state; only enqueue successors
279 // when something actually changed (fixpoint termination).
280 let (merged, new_escapes) = match &in_state[pc] {
281 Some(prev) => {
282 let (m, e) = prev.merge_with(&incoming);
283 if &m == prev && e.is_empty() {
284 continue;
285 }
286 (m, e)
287 }
288 None => (incoming, HashSet::new()),
289 };
290 escaped.extend(new_escapes);
291 in_state[pc] = Some(merged.clone());
292
293 // Step the op, get the out-state + any successors.
294 let (out, succs, leaked) = step(pc, &func.code[pc], merged, policy);
295 escaped.extend(leaked);
296 for s in succs {
297 if s < n {
298 worklist.push((s, out.clone()));
299 }
300 }
301 }
302
303 let report_sites = sites
304 .into_iter()
305 .map(|(pc, kind, shape_idx, field_count)| EscapeSite {
306 pc,
307 kind,
308 shape_idx,
309 field_count,
310 escapes: escaped.contains(&pc),
311 })
312 .collect();
313 EscapeReport { fn_name: func.name.clone(), sites: report_sites }
314}
315
316/// Apply one op to the abstract state. Returns the new state, the
317/// list of successor pcs (with their starting state being the
318/// returned state, except for control-flow ops where successors
319/// share the *same* state), and any sites that escaped during the
320/// step.
321fn step(pc: usize, op: &Op, mut s: State, policy: Policy) -> (State, Vec<usize>, HashSet<u32>) {
322 let mut escapes: HashSet<u32> = HashSet::new();
323
324 // Helper closures for the common pop-n / push patterns.
325 let leak = |slot: Slot, into: &mut HashSet<u32>| {
326 if let Slot::Agg(p) = slot { into.insert(p); }
327 };
328 let pop_n_leak = |state: &mut State, n: usize, esc: &mut HashSet<u32>| {
329 for _ in 0..n {
330 if let Some(top) = state.stack.pop() { leak(top, esc); }
331 }
332 };
333 let pop_n_drop = |state: &mut State, n: usize| {
334 for _ in 0..n { state.stack.pop(); }
335 };
336
337 match op {
338 Op::PushConst(_) => { s.stack.push(Slot::Other); }
339 Op::Pop => { s.stack.pop(); /* drop — no escape on plain pop */ }
340 Op::Dup => {
341 // Aliasing breaks our linear-flow tracking. Anything
342 // duplicated escapes — both copies become Other.
343 if let Some(top) = s.stack.pop() {
344 leak(top, &mut escapes);
345 s.stack.push(Slot::Other);
346 s.stack.push(Slot::Other);
347 }
348 }
349
350 Op::LoadLocal(i) => {
351 let slot = s.locals.get(*i as usize).copied().unwrap_or(Slot::Other);
352 s.stack.push(slot);
353 }
354 Op::StoreLocal(i) => {
355 if let Some(top) = s.stack.pop() {
356 let i = *i as usize;
357 if i >= s.locals.len() { s.locals.resize(i + 1, Slot::Other); }
358 // Round-tripping an aggregate through a local keeps it
359 // tracked. Overwriting a local that held a *different*
360 // tracked aggregate does NOT make the old one escape:
361 // Lex `let` bindings are immutable, so the compiler
362 // only reuses a slot once its previous occupant is out
363 // of scope (dead). Any real escape of that occupant —
364 // returned, passed to a call, stored into another
365 // aggregate — flows through a `Load → {Return, Call,
366 // MakeRecord/MakeTuple/...}` chain those ops already
367 // leak. Flagging it here was a pure false-positive
368 // that defeated stack-alloc for the common
369 // destructure-then-bind pattern (`compile_match`
370 // rewinds its `__scrut`/`__tuple` temp slots, so the
371 // enclosing `let` reuses them — see #464 tuple codegen).
372 s.locals[i] = top;
373 }
374 }
375
376 // Allocation site.
377 Op::MakeRecord { field_count, .. } => {
378 // Field values get stored in the new heap record; if
379 // any of them is itself a tracked Rec, it escapes (now
380 // referenced from inside the parent).
381 pop_n_leak(&mut s, *field_count as usize, &mut escapes);
382 s.stack.push(Slot::Agg(pc as u32));
383 }
384 // #464 step 2: post-lowering form of MakeRecord (escape
385 // proved). Re-running the analysis on already-lowered code
386 // must produce the same shape, so treat it identically.
387 Op::AllocStackRecord { field_count, .. } => {
388 pop_n_leak(&mut s, *field_count as usize, &mut escapes);
389 s.stack.push(Slot::Agg(pc as u32));
390 }
391 // A tuple is a stack-allocatable aggregate, tracked the same
392 // way as a record: its field operands leak (any tracked
393 // aggregate stored inside it escapes), and the tuple itself
394 // becomes a tracked candidate keyed on this pc. `GetElem`
395 // reads an element without escaping the tuple, exactly as
396 // `GetField` does for records.
397 Op::MakeTuple(n) => {
398 pop_n_leak(&mut s, *n as usize, &mut escapes);
399 s.stack.push(Slot::Agg(pc as u32));
400 }
401 // Post-lowering form of MakeTuple (escape proved). Re-running
402 // the analysis on already-lowered code must classify it the
403 // same way, so treat it identically — mirrors AllocStackRecord.
404 Op::AllocStackTuple { arity } => {
405 pop_n_leak(&mut s, *arity as usize, &mut escapes);
406 s.stack.push(Slot::Agg(pc as u32));
407 }
408 // Lists and variants remain escape sinks for any tracked
409 // aggregate operand and don't create new tracked candidates —
410 // list stack-allocation needs variable-length arena handling
411 // (a later slice), and variants aren't a stack-alloc target.
412 Op::MakeList(n) => {
413 pop_n_leak(&mut s, *n as usize, &mut escapes);
414 s.stack.push(Slot::Other);
415 }
416 Op::MakeVariant { arity, .. } => {
417 pop_n_leak(&mut s, *arity as usize, &mut escapes);
418 s.stack.push(Slot::Other);
419 }
420 Op::MakeClosure { capture_count, .. } => {
421 pop_n_leak(&mut s, *capture_count as usize, &mut escapes);
422 s.stack.push(Slot::Other);
423 }
424
425 // Field / element reads — receiver is consumed but only to
426 // read a field. Doesn't escape; the receiver becomes
427 // unreferenced after the op.
428 Op::GetField { .. } => { s.stack.pop(); s.stack.push(Slot::Other); }
429 Op::GetElem(_) => { s.stack.pop(); s.stack.push(Slot::Other); }
430 Op::TestVariant(_) => { /* peek-only */ s.stack.pop(); s.stack.push(Slot::Other); }
431 Op::GetVariant(_) => { s.stack.pop(); s.stack.push(Slot::Other); }
432 Op::GetVariantArg(_) => { s.stack.pop(); s.stack.push(Slot::Other); }
433 Op::GetListLen => { s.stack.pop(); s.stack.push(Slot::Other); }
434 Op::GetListElem(_) => { s.stack.pop(); s.stack.push(Slot::Other); }
435 Op::GetListElemDyn => {
436 // pop [list, idx] → push elem
437 s.stack.pop(); s.stack.pop(); s.stack.push(Slot::Other);
438 }
439 Op::ListAppend => {
440 // pop [list, value]; value is now inside the list → escape
441 if let Some(value) = s.stack.pop() { leak(value, &mut escapes); }
442 s.stack.pop(); // list itself
443 s.stack.push(Slot::Other);
444 }
445
446 // Control flow.
447 Op::Jump(off) => {
448 let target = (pc as i32 + 1 + off) as usize;
449 return (s, vec![target], escapes);
450 }
451 Op::JumpIf(off) | Op::JumpIfNot(off) => {
452 s.stack.pop(); // consumed Bool
453 let target = (pc as i32 + 1 + off) as usize;
454 return (s, vec![pc + 1, target], escapes);
455 }
456 Op::Return => {
457 let top = s.stack.pop();
458 // The only policy-sensitive arm: under FrameScope the
459 // returned value crosses our frame and leaks; under
460 // RequestScope it stays inside the request and does not.
461 if matches!(policy, Policy::FrameScope) {
462 if let Some(top) = top { leak(top, &mut escapes); }
463 }
464 return (s, vec![], escapes);
465 }
466 Op::Panic(_) => {
467 return (s, vec![], escapes);
468 }
469 Op::TailCall { arity, .. } => {
470 pop_n_leak(&mut s, *arity as usize, &mut escapes);
471 return (s, vec![], escapes);
472 }
473 Op::Call { arity, .. } => {
474 pop_n_leak(&mut s, *arity as usize, &mut escapes);
475 s.stack.push(Slot::Other);
476 }
477 Op::CallClosure { arity, .. } => {
478 // pop arity args + 1 closure
479 pop_n_leak(&mut s, *arity as usize + 1, &mut escapes);
480 s.stack.push(Slot::Other);
481 }
482 Op::SortByKey { .. } | Op::ParallelMap { .. }
483 | Op::ListMap { .. } | Op::ListFilter { .. } => {
484 // pop [xs, f]; both escape
485 pop_n_leak(&mut s, 2, &mut escapes);
486 s.stack.push(Slot::Other);
487 }
488 Op::ListFold { .. } => {
489 // pop [xs, init, f]; all escape into the native op
490 pop_n_leak(&mut s, 3, &mut escapes);
491 s.stack.push(Slot::Other);
492 }
493 Op::EffectCall { arity, .. } => {
494 pop_n_leak(&mut s, *arity as usize, &mut escapes);
495 s.stack.push(Slot::Other);
496 }
497
498 // Pure arithmetic / comparison / logic / string ops. Their
499 // operands are primitives in well-typed code (the existing
500 // type checker rejects record-typed args to IntAdd etc.),
501 // so we don't expect Rec slots to flow in. If one does, the
502 // pop_n_drop loses the Rec without recording escape — but
503 // that would be a type-system bug surfaced elsewhere.
504 Op::IntAdd | Op::IntSub | Op::IntMul | Op::IntDiv | Op::IntMod
505 | Op::IntEq | Op::IntLt | Op::IntLe
506 | Op::FloatAdd | Op::FloatSub | Op::FloatMul | Op::FloatDiv
507 | Op::FloatEq | Op::FloatLt | Op::FloatLe
508 | Op::NumAdd | Op::NumSub | Op::NumMul | Op::NumDiv | Op::NumMod
509 | Op::NumEq | Op::NumLt | Op::NumLe
510 | Op::BoolAnd | Op::BoolOr
511 | Op::StrConcat | Op::StrEq | Op::BytesEq => {
512 pop_n_drop(&mut s, 2);
513 s.stack.push(Slot::Other);
514 }
515 Op::IntNeg | Op::FloatNeg | Op::NumNeg | Op::BoolNot
516 | Op::StrLen | Op::BytesLen => {
517 s.stack.pop();
518 s.stack.push(Slot::Other);
519 }
520
521 // Superinstructions (#461). All operate on Int locals and
522 // primitive stack values — they neither produce nor consume
523 // Rec slots. The trailing tombstones are inert; the verifier
524 // pattern (skip ahead by N) is mirrored here.
525 Op::LoadLocalAddIntConst { .. } => {
526 // +1 net (LoadLocal + PushConst + IntAdd)
527 s.stack.push(Slot::Other);
528 }
529 Op::LoadLocalAddIntConstStoreLocal { dest, .. } => {
530 // delta 0; updates a local with an Int. Overwriting a
531 // local doesn't escape its prior aggregate — same rule as
532 // plain StoreLocal above (the dest is Int-typed by this
533 // superinstruction's contract, so prev is never an
534 // aggregate in practice; relaxed for consistency).
535 let i = *dest as usize;
536 if i >= s.locals.len() { s.locals.resize(i + 1, Slot::Other); }
537 s.locals[i] = Slot::Other;
538 return (s, vec![pc + 4], escapes);
539 }
540 Op::LoadLocalAddLocal { .. }
541 | Op::LoadLocalSubLocal { .. }
542 | Op::LoadLocalMulLocal { .. } => {
543 // +1 net (two LoadLocal + one binop)
544 s.stack.push(Slot::Other);
545 return (s, vec![pc + 3], escapes);
546 }
547 Op::LoadLocalGetField { .. } => {
548 // #461 slice 9: equivalent to LoadLocal + GetField —
549 // reads a field out of a local record (which does NOT
550 // escape the receiver, matching plain GetField) and
551 // pushes the field value (Other). Net delta +1; skip the
552 // single tombstone (pc+2). (Escape analysis runs before
553 // peephole, so this arm is exercised only if the analysis
554 // is ever re-run on fused code; it's here for exhaustive
555 // matching and forward-correctness.)
556 s.stack.push(Slot::Other);
557 return (s, vec![pc + 2], escapes);
558 }
559 Op::LoadLocalGetFieldAdd { .. }
560 | Op::LoadLocalGetFieldSub { .. }
561 | Op::LoadLocalGetFieldMul { .. } => {
562 // #461 slice 7/8: net delta on the value stack is 0 (pops
563 // prior Int top, pushes Int result). The receiver is read
564 // from a local — the analysis tracks locals separately,
565 // and reading a local doesn't escape its Rec slot (the
566 // round-trip-through-local rule from StoreLocal applies
567 // here too: the value stays referenced). We pop the
568 // existing top (the accumulator) and push a fresh Other
569 // (the result). Skip past the two tombstones.
570 s.stack.pop();
571 s.stack.push(Slot::Other);
572 return (s, vec![pc + 3], escapes);
573 }
574 Op::LoadLocalEqIntConstJumpIfNot { jump_offset, .. } => {
575 // delta 0; two successors (fall-through past tombstones,
576 // and the branch target relative to the original
577 // JumpIfNot's pc).
578 let target = (pc as i32 + 4 + jump_offset) as usize;
579 return (s, vec![pc + 4, target], escapes);
580 }
581 Op::LoadLocalStoreEqIntConstJumpIfNot { dst, jump_offset, .. } => {
582 // delta 0; also writes locals[dst] := locals[src].
583 // Treat the local update the same as StoreLocal of an
584 // Other (the scrutinee is an Int per slice-6's contract).
585 let i = *dst as usize;
586 if i >= s.locals.len() { s.locals.resize(i + 1, Slot::Other); }
587 // Overwriting a local doesn't escape its prior aggregate
588 // (same rule as plain StoreLocal); dst is Int-typed here.
589 s.locals[i] = Slot::Other;
590 let target = (pc as i32 + 6 + jump_offset) as usize;
591 return (s, vec![pc + 6, target], escapes);
592 }
593 }
594
595 (s, vec![pc + 1], escapes)
596}
597
598/// Convenience wrapper over `analyze_program` returning a map
599/// keyed by `(fn_name, pc)` for direct lookup during codegen.
600pub fn build_escape_index(functions: &[Function]) -> HashMap<(String, u32), bool> {
601 let mut idx = HashMap::new();
602 for report in analyze_program(functions) {
603 for site in report.sites {
604 idx.insert((report.fn_name.clone(), site.pc), site.escapes);
605 }
606 }
607 idx
608}
609
610#[cfg(test)]
611mod tests {
612 use super::*;
613 use crate::op::Op;
614 use crate::program::{Function, ZERO_BODY_HASH};
615
616 /// Helper: build a minimal Function with the given code and
617 /// just enough machinery for the analyzer.
618 fn func(name: &str, locals_count: u16, arity: u16, code: Vec<Op>) -> Function {
619 Function {
620 name: name.into(),
621 arity,
622 locals_count,
623 code,
624 effects: vec![],
625 body_hash: ZERO_BODY_HASH,
626 refinements: vec![],
627 field_ic_sites: 0,
628 }
629 }
630
631 /// Expectation helper: a list of `(pc, expected_escapes)` pairs.
632 fn assert_escapes(report: &EscapeReport, expected: &[(u32, bool)]) {
633 let got: Vec<(u32, bool)> = report.sites.iter().map(|s| (s.pc, s.escapes)).collect();
634 assert_eq!(got, expected,
635 "escape report for `{}` differs from expected", report.fn_name);
636 }
637
638 #[test]
639 fn record_built_and_dropped_does_not_escape() {
640 // PushConst PushConst MakeRecord Pop Return
641 let f = func("dropper", 0, 0, vec![
642 Op::PushConst(0),
643 Op::PushConst(1),
644 Op::MakeRecord { shape_idx: 0, field_count: 2 },
645 Op::Pop,
646 Op::PushConst(0),
647 Op::Return,
648 ]);
649 let r = analyze_function(&f);
650 assert_escapes(&r, &[(2, false)]);
651 }
652
653 #[test]
654 fn record_returned_escapes() {
655 let f = func("returner", 0, 0, vec![
656 Op::PushConst(0),
657 Op::PushConst(1),
658 Op::MakeRecord { shape_idx: 0, field_count: 2 },
659 Op::Return,
660 ]);
661 let r = analyze_function(&f);
662 assert_escapes(&r, &[(2, true)]);
663 }
664
665 #[test]
666 fn record_field_read_only_does_not_escape() {
667 // PushConst PushConst MakeRecord GetField Return (returns the field, not the record)
668 let f = func("reader", 0, 0, vec![
669 Op::PushConst(0),
670 Op::PushConst(1),
671 Op::MakeRecord { shape_idx: 0, field_count: 2 },
672 Op::GetField { name_idx: 0, site_idx: 0 },
673 Op::Return,
674 ]);
675 let r = analyze_function(&f);
676 assert_escapes(&r, &[(2, false)]);
677 }
678
679 #[test]
680 fn record_round_tripped_through_local_does_not_escape() {
681 let f = func("roundtrip", 1, 0, vec![
682 Op::PushConst(0),
683 Op::PushConst(1),
684 Op::MakeRecord { shape_idx: 0, field_count: 2 },
685 Op::StoreLocal(0),
686 Op::LoadLocal(0),
687 Op::GetField { name_idx: 0, site_idx: 0 },
688 Op::Return,
689 ]);
690 let r = analyze_function(&f);
691 assert_escapes(&r, &[(2, false)]);
692 }
693
694 #[test]
695 fn record_stored_into_outer_record_escapes() {
696 // Build inner, then build outer with inner as one of its fields.
697 let f = func("nest", 0, 0, vec![
698 Op::PushConst(0),
699 Op::PushConst(1),
700 Op::MakeRecord { shape_idx: 0, field_count: 2 }, // inner @ pc=2
701 Op::PushConst(2),
702 Op::MakeRecord { shape_idx: 1, field_count: 2 }, // outer @ pc=4
703 Op::Return, // outer escapes
704 ]);
705 let r = analyze_function(&f);
706 // inner escapes (captured in outer); outer escapes (returned).
707 assert_escapes(&r, &[(2, true), (4, true)]);
708 }
709
710 #[test]
711 fn record_passed_to_call_escapes() {
712 let f = func("passer", 0, 0, vec![
713 Op::PushConst(0),
714 Op::PushConst(1),
715 Op::MakeRecord { shape_idx: 0, field_count: 2 },
716 Op::Call { fn_id: 1, arity: 1, node_id_idx: 0 },
717 Op::Return,
718 ]);
719 let r = analyze_function(&f);
720 assert_escapes(&r, &[(2, true)]);
721 }
722
723 #[test]
724 fn record_captured_in_closure_escapes() {
725 let f = func("capturer", 0, 0, vec![
726 Op::PushConst(0),
727 Op::PushConst(1),
728 Op::MakeRecord { shape_idx: 0, field_count: 2 },
729 Op::MakeClosure { fn_id: 1, capture_count: 1 },
730 Op::Return,
731 ]);
732 let r = analyze_function(&f);
733 assert_escapes(&r, &[(2, true)]);
734 }
735
736 #[test]
737 fn record_in_one_branch_returned_escapes_after_merge() {
738 // if cond { rec1 } else { rec2 } — Return after merge.
739 // Conservative analysis: at the merge both sites escape.
740 let f = func("brancher", 0, 1, vec![
741 Op::LoadLocal(0), // pc=0
742 Op::JumpIfNot(4), // pc=1; offset 4 → pc=6
743 Op::PushConst(0), // pc=2
744 Op::MakeRecord { shape_idx: 0, field_count: 1 }, // pc=3 (then-branch)
745 Op::Jump(2), // pc=4; offset 2 → pc=7
746 Op::PushConst(1), // pc=5 (unreached fall-through dead code)
747 Op::MakeRecord { shape_idx: 0, field_count: 1 }, // pc=6 (else-branch)
748 Op::Return, // pc=7 (merge + return)
749 ]);
750 let r = analyze_function(&f);
751 // Both record sites escape — Return sees a merged stack.
752 assert_escapes(&r, &[(3, true), (6, true)]);
753 }
754
755 #[test]
756 fn two_sites_classified_independently() {
757 // One record returned, one popped — they should classify
758 // separately. Sequencing: build keeper, store it; build
759 // discard, pop it; load keeper, return.
760 let f = func("mixed", 1, 0, vec![
761 Op::PushConst(0),
762 Op::MakeRecord { shape_idx: 0, field_count: 1 }, // keeper @ pc=1
763 Op::StoreLocal(0),
764 Op::PushConst(0),
765 Op::MakeRecord { shape_idx: 0, field_count: 1 }, // discard @ pc=4
766 Op::Pop,
767 Op::LoadLocal(0),
768 Op::Return,
769 ]);
770 let r = analyze_function(&f);
771 assert_escapes(&r, &[(1, true), (4, false)]);
772 }
773
774 #[test]
775 fn function_with_no_record_sites_produces_empty_report() {
776 let f = func("pure_arith", 0, 2, vec![
777 Op::LoadLocal(0),
778 Op::LoadLocal(1),
779 Op::IntAdd,
780 Op::Return,
781 ]);
782 let r = analyze_function(&f);
783 assert!(r.sites.is_empty());
784 }
785
786 #[test]
787 fn analyze_program_skips_no_record_functions() {
788 let f1 = func("noreds", 0, 0, vec![Op::PushConst(0), Op::Return]);
789 let f2 = func("hasrec", 0, 0, vec![
790 Op::PushConst(0),
791 Op::MakeRecord { shape_idx: 0, field_count: 1 },
792 Op::Return,
793 ]);
794 let reports = analyze_program(&[f1, f2]);
795 assert_eq!(reports.len(), 1);
796 assert_eq!(reports[0].fn_name, "hasrec");
797 }
798
799 #[test]
800 fn record_passed_to_effect_call_escapes() {
801 let f = func("effecting", 0, 0, vec![
802 Op::PushConst(0),
803 Op::MakeRecord { shape_idx: 0, field_count: 1 },
804 Op::EffectCall { kind_idx: 0, op_idx: 0, arity: 1, node_id_idx: 0 },
805 Op::Return,
806 ]);
807 let r = analyze_function(&f);
808 assert_escapes(&r, &[(1, true)]);
809 }
810
811 #[test]
812 fn record_duplicated_escapes() {
813 // Dup is conservatively an escape — aliasing breaks the
814 // linear-flow assumption.
815 let f = func("duper", 0, 0, vec![
816 Op::PushConst(0),
817 Op::MakeRecord { shape_idx: 0, field_count: 1 },
818 Op::Dup,
819 Op::Pop,
820 Op::Pop,
821 Op::PushConst(0),
822 Op::Return,
823 ]);
824 let r = analyze_function(&f);
825 assert_escapes(&r, &[(1, true)]);
826 }
827
828 #[test]
829 fn record_in_list_escapes() {
830 let f = func("listed", 0, 0, vec![
831 Op::PushConst(0),
832 Op::MakeRecord { shape_idx: 0, field_count: 1 },
833 Op::MakeList(1),
834 Op::Return,
835 ]);
836 let r = analyze_function(&f);
837 assert_escapes(&r, &[(1, true)]);
838 }
839
840 #[test]
841 fn build_escape_index_keys_by_fn_and_pc() {
842 let f = func("idx_test", 0, 0, vec![
843 Op::PushConst(0),
844 Op::MakeRecord { shape_idx: 0, field_count: 1 },
845 Op::Pop,
846 Op::PushConst(0),
847 Op::Return,
848 ]);
849 let idx = build_escape_index(&[f]);
850 assert_eq!(idx.get(&("idx_test".into(), 1)), Some(&false));
851 }
852
853 // ---- tuple widening (#464 tuple analysis slice) ----
854
855 #[test]
856 fn tuple_built_and_dropped_does_not_escape() {
857 let f = func("tup_drop", 0, 0, vec![
858 Op::PushConst(0),
859 Op::PushConst(1),
860 Op::MakeTuple(2), // pc=2
861 Op::Pop,
862 Op::PushConst(0),
863 Op::Return,
864 ]);
865 let r = analyze_function(&f);
866 assert_escapes(&r, &[(2, false)]);
867 assert_eq!(r.sites[0].kind, SiteKind::Tuple);
868 assert_eq!(r.sites[0].field_count, 2);
869 }
870
871 #[test]
872 fn tuple_returned_escapes() {
873 let f = func("tup_ret", 0, 0, vec![
874 Op::PushConst(0),
875 Op::PushConst(1),
876 Op::MakeTuple(2), // pc=2
877 Op::Return,
878 ]);
879 let r = analyze_function(&f);
880 assert_escapes(&r, &[(2, true)]);
881 }
882
883 #[test]
884 fn tuple_elem_read_only_does_not_escape() {
885 // Reading an element (GetElem) consumes the tuple locally,
886 // mirroring GetField on a record — the tuple stays a candidate.
887 let f = func("tup_read", 0, 0, vec![
888 Op::PushConst(0),
889 Op::PushConst(1),
890 Op::MakeTuple(2), // pc=2
891 Op::GetElem(0),
892 Op::Return,
893 ]);
894 let r = analyze_function(&f);
895 assert_escapes(&r, &[(2, false)]);
896 }
897
898 #[test]
899 fn tuple_round_tripped_through_local_does_not_escape() {
900 let f = func("tup_rt", 1, 0, vec![
901 Op::PushConst(0),
902 Op::PushConst(1),
903 Op::MakeTuple(2), // pc=2
904 Op::StoreLocal(0),
905 Op::LoadLocal(0),
906 Op::GetElem(1),
907 Op::Return,
908 ]);
909 let r = analyze_function(&f);
910 assert_escapes(&r, &[(2, false)]);
911 }
912
913 #[test]
914 fn tuple_passed_to_call_escapes() {
915 let f = func("tup_call", 0, 0, vec![
916 Op::PushConst(0),
917 Op::PushConst(1),
918 Op::MakeTuple(2), // pc=2
919 Op::Call { fn_id: 1, arity: 1, node_id_idx: 0 },
920 Op::Return,
921 ]);
922 let r = analyze_function(&f);
923 assert_escapes(&r, &[(2, true)]);
924 }
925
926 #[test]
927 fn record_stored_into_tuple_escapes_tuple_stays_local() {
928 // Build a record, wrap it in a tuple, drop the tuple. The
929 // record escapes into the tuple; the tuple itself is local.
930 let f = func("rec_in_tup", 0, 0, vec![
931 Op::PushConst(0),
932 Op::MakeRecord { shape_idx: 0, field_count: 1 }, // rec @ pc=1
933 Op::MakeTuple(1), // tup @ pc=2 holds rec
934 Op::Pop,
935 Op::PushConst(0),
936 Op::Return,
937 ]);
938 let r = analyze_function(&f);
939 assert_escapes(&r, &[(1, true), (2, false)]);
940 }
941
942 #[test]
943 fn tuple_stored_into_record_escapes() {
944 let f = func("tup_in_rec", 0, 0, vec![
945 Op::PushConst(0),
946 Op::PushConst(1),
947 Op::MakeTuple(2), // tup @ pc=2
948 Op::MakeRecord { shape_idx: 0, field_count: 1 }, // rec @ pc=3 holds tup
949 Op::Return,
950 ]);
951 let r = analyze_function(&f);
952 assert_escapes(&r, &[(2, true), (3, true)]);
953 }
954
955 #[test]
956 fn tuple_and_record_sites_carry_distinct_kinds() {
957 let f = func("mixed_kinds", 0, 0, vec![
958 Op::PushConst(0),
959 Op::MakeRecord { shape_idx: 7, field_count: 1 }, // pc=1 record
960 Op::Pop,
961 Op::PushConst(0),
962 Op::PushConst(1),
963 Op::MakeTuple(2), // pc=5 tuple
964 Op::Pop,
965 Op::PushConst(0),
966 Op::Return,
967 ]);
968 let r = analyze_function(&f);
969 assert_eq!(r.sites.len(), 2);
970 assert_eq!((r.sites[0].pc, r.sites[0].kind, r.sites[0].shape_idx), (1, SiteKind::Record, 7));
971 assert_eq!((r.sites[1].pc, r.sites[1].kind, r.sites[1].field_count), (5, SiteKind::Tuple, 2));
972 assert!(!r.sites[0].escapes && !r.sites[1].escapes);
973 }
974
975 #[test]
976 fn aggregate_overwritten_in_dead_slot_does_not_escape() {
977 // rec1 is stored into local 0, then local 0 is overwritten
978 // with an Int and rec1 is never otherwise used. Lex's
979 // immutable `let` bindings mean a slot is only reused once its
980 // prior occupant is dead, so the overwrite must NOT flag rec1
981 // as escaping (the relaxed StoreLocal rule — #464 tuple
982 // codegen). Pre-relaxation this returned `true`.
983 let f = func("overwrite", 1, 0, vec![
984 Op::PushConst(0),
985 Op::MakeRecord { shape_idx: 0, field_count: 1 }, // rec1 @ pc=1
986 Op::StoreLocal(0),
987 Op::PushConst(0),
988 Op::StoreLocal(0), // overwrite local 0 with an Int
989 Op::PushConst(0),
990 Op::Return,
991 ]);
992 let r = analyze_function(&f);
993 assert_escapes(&r, &[(1, false)]);
994 }
995
996 #[test]
997 fn aggregate_loaded_then_returned_still_escapes() {
998 // Soundness guard for the relaxed overwrite rule: an aggregate
999 // stored in a local, then LOADED and returned, still escapes —
1000 // the `Return` leak catches it independent of any overwrite.
1001 let f = func("load_then_return", 1, 0, vec![
1002 Op::PushConst(0),
1003 Op::MakeRecord { shape_idx: 0, field_count: 1 }, // rec1 @ pc=1
1004 Op::StoreLocal(0),
1005 Op::LoadLocal(0),
1006 Op::Return, // returns rec1 → escapes
1007 ]);
1008 let r = analyze_function(&f);
1009 assert_escapes(&r, &[(1, true)]);
1010 }
1011
1012 #[test]
1013 fn tuple_only_function_now_produces_report() {
1014 // Pre-widening this function had no tracked sites and was
1015 // omitted from analyze_program; now its tuple site is reported.
1016 let f = func("tuponly", 0, 0, vec![
1017 Op::PushConst(0),
1018 Op::PushConst(1),
1019 Op::MakeTuple(2),
1020 Op::Pop,
1021 Op::PushConst(0),
1022 Op::Return,
1023 ]);
1024 let reports = analyze_program(&[f]);
1025 assert_eq!(reports.len(), 1);
1026 assert_eq!(reports[0].sites.len(), 1);
1027 assert_eq!(reports[0].sites[0].kind, SiteKind::Tuple);
1028 }
1029}