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///
87/// Per-path refinement (#463 follow-up — precision pass): a slot can
88/// hold a **set** of allocation sites, not just one, because two
89/// branches of an `if`/`match` can each push a distinct `MakeRecord`
90/// / `MakeTuple` value onto the stack and the join point's "either-
91/// or" semantics must be modeled without losing tracking. The
92/// 3-variant shape keeps the singleton case zero-alloc — `Slot::Agg`
93/// is plain 8 bytes inline — and only branch-merges allocate.
94#[derive(Debug, Clone, PartialEq, Eq, Hash)]
95pub enum Slot {
96 /// Holds a single tracked aggregate (a record from
97 /// `Op::MakeRecord` or a tuple from `Op::MakeTuple`) allocated at
98 /// this pc. As long as every consumer reads this slot via a field
99 /// / element read (`GetField` / `GetElem`), `Pop`, or a
100 /// `StoreLocal`/`LoadLocal` round-trip, the site stays a
101 /// stack-alloc candidate. The escape rules are identical for both
102 /// aggregate kinds — only the codegen opcode differs — so the
103 /// analysis tracks them with one variant keyed on the alloc pc.
104 Agg(u32),
105 /// Holds one of N tracked aggregates, depending on which branch
106 /// reached this program point. Produced at join points where
107 /// distinct `Agg(p)` and `Agg(q)` flow together; the set remains
108 /// tracked across the merge so the eventual consumer (e.g.
109 /// `Return` under `Policy::RequestScope`) decides whether any
110 /// site leaks. Invariant: vec is sorted, dedup'd, length ≥ 2 —
111 /// `Slot::from_sites` normalizes single-element / empty inputs.
112 AggSet(Vec<u32>),
113 /// Anything else — primitives, already-escaped aggregates,
114 /// values produced by ops we don't model precisely. Treated
115 /// as not-a-tracked-aggregate for escape purposes.
116 Other,
117}
118
119impl Slot {
120 /// View this slot as a slice of tracked sites. Empty for
121 /// `Other`, singleton for `Agg`, the inner vec for `AggSet`.
122 /// Lets callers iterate sites uniformly across the three
123 /// variants — used by every consumer that needs to leak all
124 /// sites a slot might hold.
125 fn sites(&self) -> &[u32] {
126 match self {
127 Slot::Agg(p) => std::slice::from_ref(p),
128 Slot::AggSet(v) => v.as_slice(),
129 Slot::Other => &[],
130 }
131 }
132
133 /// Construct a slot from an arbitrary list of site pcs.
134 /// Normalizes: sorts + dedups + collapses empty → `Other`,
135 /// singleton → `Agg`. Used by `merge` and any other code that
136 /// produces a set-like slot.
137 fn from_sites(mut sites: Vec<u32>) -> Slot {
138 sites.sort_unstable();
139 sites.dedup();
140 match sites.len() {
141 0 => Slot::Other,
142 1 => Slot::Agg(sites[0]),
143 _ => Slot::AggSet(sites),
144 }
145 }
146
147 /// Pointwise merge for join points. Unions the site sets — both
148 /// sides stay tracked across the join, contra the pre-refinement
149 /// behavior of collapsing to `Other` (which also flagged both
150 /// sides as escaping). Under `Policy::RequestScope` this is the
151 /// whole point: a `match`-arm return like `match cond { true =>
152 /// {x:1}, false => {x:2} }` now produces an `AggSet([p, q])` at
153 /// the merge, which `Op::Return` passes through without leaking
154 /// (request-scope) instead of being forced into `Other` (escape).
155 fn merge(self, other: Slot) -> Slot {
156 if self == other { return self; }
157 let mut combined: Vec<u32> = Vec::with_capacity(
158 self.sites().len() + other.sites().len());
159 combined.extend_from_slice(self.sites());
160 combined.extend_from_slice(other.sites());
161 Slot::from_sites(combined)
162 }
163}
164
165/// Abstract state at one program point: the value stack from
166/// bottom up, plus a flat local-variable map.
167#[derive(Debug, Clone, PartialEq, Eq)]
168struct State {
169 stack: Vec<Slot>,
170 locals: Vec<Slot>,
171}
172
173impl State {
174 fn entry(locals_count: usize, arity: usize) -> Self {
175 // Function parameters land in the first `arity` locals;
176 // they're potentially-escaped values handed in by the
177 // caller, but the *sites* that produced them live in the
178 // caller's frame and aren't our concern. Treat as Other.
179 Self {
180 stack: Vec::new(),
181 locals: vec![Slot::Other; locals_count.max(arity)],
182 }
183 }
184
185 /// Merge `other` into `self`. Returns `(merged_state, escaped_sites)`.
186 ///
187 /// Per-path refinement: the merge itself **does not record any
188 /// escapes**. `Slot::merge` keeps both sides' sites tracked
189 /// across the join (`AggSet([p, q])` instead of `Other`), so the
190 /// downstream consumer ops decide whether any leak. The pre-
191 /// refinement collapse-to-Other behavior was the conservative
192 /// case the doc had labeled future work; this is that work.
193 ///
194 /// The one remaining escape source here is the dropped-tail case
195 /// for mismatched stack depths. That's a verifier-level bug
196 /// (#347 already checks shape-consistent merges), not the join-
197 /// point trap — those sites really are unreachable from the
198 /// merged state, so flagging them as escaped stays conservative-
199 /// correct.
200 fn merge_with(&self, other: &State) -> (State, HashSet<u32>) {
201 let mut escaped = HashSet::new();
202 let stack_len = self.stack.len().min(other.stack.len());
203 let mut stack = Vec::with_capacity(stack_len);
204 for i in 0..stack_len {
205 stack.push(self.stack[i].clone().merge(other.stack[i].clone()));
206 }
207 for tail in self.stack.iter().skip(stack_len).chain(other.stack.iter().skip(stack_len)) {
208 for &p in tail.sites() { escaped.insert(p); }
209 }
210 let local_len = self.locals.len().max(other.locals.len());
211 let mut locals = Vec::with_capacity(local_len);
212 for i in 0..local_len {
213 let a = self.locals.get(i).cloned().unwrap_or(Slot::Other);
214 let b = other.locals.get(i).cloned().unwrap_or(Slot::Other);
215 locals.push(a.merge(b));
216 }
217 (State { stack, locals }, escaped)
218 }
219}
220
221/// Per-function escape report — the artifact codegen consumes to
222/// decide where to emit a stack-alloc opcode vs the heap constructor.
223///
224/// Each entry is keyed by the allocation pc (the `Op::MakeRecord` or
225/// `Op::MakeTuple` site's index in the function's `code` vec) and
226/// tagged with its `SiteKind`. `escapes = false` means: across every
227/// reachable path through the function, the aggregate allocated here
228/// is only ever read locally (`GetField` / `GetElem`, dropped via
229/// `Pop`, round-tripped through locals) — never returned, captured,
230/// stored in another aggregate, or passed to a call.
231#[derive(Debug, Clone, PartialEq, Eq)]
232pub struct EscapeReport {
233 pub fn_name: String,
234 /// One entry per stack-allocatable aggregate (`MakeRecord` /
235 /// `MakeTuple`) site in the function, in pc order.
236 pub sites: Vec<EscapeSite>,
237}
238
239/// Which aggregate constructor an escape site is — determines which
240/// stack-alloc opcode a future codegen slice would emit for it
241/// (`AllocStackRecord` for records; tuple stack-alloc is not yet
242/// implemented, so `Tuple` sites are reported but not lowered).
243#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
244pub enum SiteKind {
245 /// `Op::MakeRecord`: `shape_idx` is meaningful, `field_count` is
246 /// the record's field count.
247 Record,
248 /// `Op::MakeTuple`: `shape_idx` is unused (`0`), `field_count` is
249 /// the tuple arity.
250 Tuple,
251}
252
253#[derive(Debug, Clone, PartialEq, Eq, Hash)]
254pub struct EscapeSite {
255 pub pc: u32,
256 pub kind: SiteKind,
257 pub shape_idx: u32,
258 pub field_count: u16,
259 pub escapes: bool,
260}
261
262/// Analyze every function in `functions`. Returns one
263/// `EscapeReport` per function that contains at least one
264/// `MakeRecord` site (functions with no record allocations are
265/// omitted — the consumer doesn't care about them).
266pub fn analyze_program(functions: &[Function]) -> Vec<EscapeReport> {
267 functions
268 .iter()
269 .filter_map(|f| {
270 let r = analyze_function(f);
271 (!r.sites.is_empty()).then_some(r)
272 })
273 .collect()
274}
275
276/// Analyze one function under the default `Policy::FrameScope`.
277/// See `analyze_function_with_policy` for the policy-parameterized
278/// form used by #463 arena routing.
279pub fn analyze_function(func: &Function) -> EscapeReport {
280 analyze_function_with_policy(func, Policy::FrameScope)
281}
282
283/// Analyze one function under the given scope policy. Cheap to call
284/// even when there are no aggregate sites (early-exits after the
285/// first pass over `code`).
286pub fn analyze_function_with_policy(func: &Function, policy: Policy) -> EscapeReport {
287 // Inventory the MakeRecord sites first. If there are none,
288 // skip the whole fixpoint — the function can't benefit from
289 // stack allocation anyway.
290 let sites: Vec<(u32, SiteKind, u32, u16)> = func
291 .code
292 .iter()
293 .enumerate()
294 .filter_map(|(pc, op)| match op {
295 Op::MakeRecord { shape_idx, field_count } => {
296 Some((pc as u32, SiteKind::Record, *shape_idx, *field_count))
297 }
298 Op::MakeTuple(arity) => {
299 Some((pc as u32, SiteKind::Tuple, 0, *arity))
300 }
301 _ => None,
302 })
303 .collect();
304 if sites.is_empty() {
305 return EscapeReport { fn_name: func.name.clone(), sites: vec![] };
306 }
307
308 let n = func.code.len();
309 let locals_count = func.locals_count as usize;
310 let arity = func.arity as usize;
311
312 // Per-pc in-states, computed by the fixpoint. None = unvisited.
313 let mut in_state: Vec<Option<State>> = vec![None; n];
314 let mut escaped: HashSet<u32> = HashSet::new();
315 // Containment map for deep-leaf widening: at each tracked-
316 // aggregate build site (MakeRecord / MakeTuple / AllocStack* /
317 // AllocArena*), the set of child sites whose values were popped
318 // as fields/elements. Used to transitively escape children when
319 // a parent escapes, instead of pessimistically leaking children
320 // at the build site. Accumulates monotonically across worklist
321 // iterations — sites can only be added.
322 let mut containment: HashMap<u32, HashSet<u32>> = HashMap::new();
323
324 let mut worklist: Vec<(usize, State)> = vec![(0, State::entry(locals_count, arity))];
325
326 while let Some((pc, incoming)) = worklist.pop() {
327 if pc >= n { continue; }
328
329 // Merge into existing in-state; only enqueue successors
330 // when something actually changed (fixpoint termination).
331 let (merged, new_escapes) = match &in_state[pc] {
332 Some(prev) => {
333 let (m, e) = prev.merge_with(&incoming);
334 if &m == prev && e.is_empty() {
335 continue;
336 }
337 (m, e)
338 }
339 None => (incoming, HashSet::new()),
340 };
341 escaped.extend(new_escapes);
342 in_state[pc] = Some(merged.clone());
343
344 // Step the op, get the out-state + any successors.
345 let (out, succs, leaked) = step(pc, &func.code[pc], merged, policy, &mut containment);
346 escaped.extend(leaked);
347 for s in succs {
348 if s < n {
349 worklist.push((s, out.clone()));
350 }
351 }
352 }
353
354 // Deep-leaf widening: every escaped parent transitively escapes
355 // all sites the containment map records as living inside it. Run
356 // to fixpoint over the accumulated containment so chains
357 // (`a` in `b` in `c`, c escapes → b, a) propagate correctly.
358 let mut changed = true;
359 while changed {
360 changed = false;
361 let snapshot: Vec<u32> = escaped.iter().copied().collect();
362 for p in snapshot {
363 if let Some(contained) = containment.get(&p) {
364 for &c in contained {
365 if escaped.insert(c) { changed = true; }
366 }
367 }
368 }
369 }
370
371 let report_sites = sites
372 .into_iter()
373 .map(|(pc, kind, shape_idx, field_count)| EscapeSite {
374 pc,
375 kind,
376 shape_idx,
377 field_count,
378 escapes: escaped.contains(&pc),
379 })
380 .collect();
381 EscapeReport { fn_name: func.name.clone(), sites: report_sites }
382}
383
384/// Apply one op to the abstract state. Returns the new state, the
385/// list of successor pcs (with their starting state being the
386/// returned state, except for control-flow ops where successors
387/// share the *same* state), and any sites that escaped during the
388/// step.
389fn step(
390 pc: usize,
391 op: &Op,
392 mut s: State,
393 policy: Policy,
394 containment: &mut HashMap<u32, HashSet<u32>>,
395) -> (State, Vec<usize>, HashSet<u32>) {
396 let mut escapes: HashSet<u32> = HashSet::new();
397
398 // Deep-leaf widening helper: pop `n` slots, but instead of
399 // leaking their sites immediately (as `pop_n_leak` does for
400 // genuine escape ops), record them as contained in the parent
401 // pc. The parent's later fate determines whether the children
402 // escape — transitive expansion at fixpoint exit propagates.
403 let pop_n_contain = |state: &mut State,
404 n: usize,
405 parent: u32,
406 containment: &mut HashMap<u32, HashSet<u32>>| {
407 let entry = containment.entry(parent).or_default();
408 for _ in 0..n {
409 if let Some(top) = state.stack.pop() {
410 for &child in top.sites() {
411 // Skip self-reference defensively (a parent
412 // shouldn't appear in its own field operands;
413 // if it ever does, a self-loop in containment
414 // would still be sound but pointless).
415 if child != parent { entry.insert(child); }
416 }
417 }
418 }
419 };
420
421 // Helper closures for the common pop-n / push patterns. `leak`
422 // iterates `slot.sites()` so multi-site slots (`AggSet`) leak
423 // every member — same conservative escape behavior as before for
424 // ops that genuinely escape, but now correctly handles the
425 // post-merge sets that the per-path lattice produces.
426 let leak = |slot: &Slot, into: &mut HashSet<u32>| {
427 for &p in slot.sites() { into.insert(p); }
428 };
429 let pop_n_leak = |state: &mut State, n: usize, esc: &mut HashSet<u32>| {
430 for _ in 0..n {
431 if let Some(top) = state.stack.pop() { leak(&top, esc); }
432 }
433 };
434 let pop_n_drop = |state: &mut State, n: usize| {
435 for _ in 0..n { state.stack.pop(); }
436 };
437
438 match op {
439 Op::PushConst(_) => { s.stack.push(Slot::Other); }
440 Op::Pop => { s.stack.pop(); /* drop — no escape on plain pop */ }
441 Op::Dup => {
442 // Aliasing breaks our linear-flow tracking. Anything
443 // duplicated escapes — both copies become Other.
444 if let Some(top) = s.stack.pop() {
445 leak(&top, &mut escapes);
446 s.stack.push(Slot::Other);
447 s.stack.push(Slot::Other);
448 }
449 }
450
451 Op::LoadLocal(i) => {
452 let slot = s.locals.get(*i as usize).cloned().unwrap_or(Slot::Other);
453 s.stack.push(slot);
454 }
455 Op::StoreLocal(i) => {
456 if let Some(top) = s.stack.pop() {
457 let i = *i as usize;
458 if i >= s.locals.len() { s.locals.resize(i + 1, Slot::Other); }
459 // Round-tripping an aggregate through a local keeps it
460 // tracked. Overwriting a local that held a *different*
461 // tracked aggregate does NOT make the old one escape:
462 // Lex `let` bindings are immutable, so the compiler
463 // only reuses a slot once its previous occupant is out
464 // of scope (dead). Any real escape of that occupant —
465 // returned, passed to a call, stored into another
466 // aggregate — flows through a `Load → {Return, Call,
467 // MakeRecord/MakeTuple/...}` chain those ops already
468 // leak. Flagging it here was a pure false-positive
469 // that defeated stack-alloc for the common
470 // destructure-then-bind pattern (`compile_match`
471 // rewinds its `__scrut`/`__tuple` temp slots, so the
472 // enclosing `let` reuses them — see #464 tuple codegen).
473 s.locals[i] = top;
474 }
475 }
476
477 // Allocation site. Deep-leaf widening (#463 follow-up):
478 // record child sites as **contained in** this parent
479 // instead of leaking them at the build site. The parent's
480 // eventual fate (escape via Call/etc., or stay local)
481 // decides what happens to the children — caught at the
482 // post-fixpoint transitive-expansion pass.
483 Op::MakeRecord { field_count, .. } => {
484 pop_n_contain(&mut s, *field_count as usize, pc as u32, containment);
485 s.stack.push(Slot::Agg(pc as u32));
486 }
487 // #464 step 2: post-lowering form of MakeRecord. Same deep-
488 // leaf containment story so re-running the analysis on
489 // already-lowered code produces the same shape.
490 Op::AllocStackRecord { field_count, .. } => {
491 pop_n_contain(&mut s, *field_count as usize, pc as u32, containment);
492 s.stack.push(Slot::Agg(pc as u32));
493 }
494 // #463 slice 2a: post-lowering form of MakeRecord for the
495 // arena path. Same containment treatment.
496 Op::AllocArenaRecord { field_count, .. } => {
497 pop_n_contain(&mut s, *field_count as usize, pc as u32, containment);
498 s.stack.push(Slot::Agg(pc as u32));
499 }
500 // Tuple build sites: identical deep-leaf treatment to
501 // records. `GetElem` reads an element without escaping the
502 // tuple, exactly as `GetField` does for records.
503 Op::MakeTuple(n) => {
504 pop_n_contain(&mut s, *n as usize, pc as u32, containment);
505 s.stack.push(Slot::Agg(pc as u32));
506 }
507 // Post-lowering form of MakeTuple — same containment.
508 Op::AllocStackTuple { arity } => {
509 pop_n_contain(&mut s, *arity as usize, pc as u32, containment);
510 s.stack.push(Slot::Agg(pc as u32));
511 }
512 Op::AllocArenaTuple { arity } => {
513 pop_n_contain(&mut s, *arity as usize, pc as u32, containment);
514 s.stack.push(Slot::Agg(pc as u32));
515 }
516 // Lists and variants remain escape sinks for any tracked
517 // aggregate operand and don't create new tracked candidates —
518 // list stack-allocation needs variable-length arena handling
519 // (a later slice), and variants aren't a stack-alloc target.
520 Op::MakeList(n) => {
521 pop_n_leak(&mut s, *n as usize, &mut escapes);
522 s.stack.push(Slot::Other);
523 }
524 Op::MakeVariant { arity, .. } => {
525 pop_n_leak(&mut s, *arity as usize, &mut escapes);
526 s.stack.push(Slot::Other);
527 }
528 Op::MakeClosure { capture_count, .. } => {
529 pop_n_leak(&mut s, *capture_count as usize, &mut escapes);
530 s.stack.push(Slot::Other);
531 }
532
533 // Field / element reads — receiver is consumed but only to
534 // read a field. Doesn't escape; the receiver becomes
535 // unreferenced after the op.
536 Op::GetField { .. } => { s.stack.pop(); s.stack.push(Slot::Other); }
537 Op::GetElem(_) => { s.stack.pop(); s.stack.push(Slot::Other); }
538 Op::TestVariant(_) => { /* peek-only */ s.stack.pop(); s.stack.push(Slot::Other); }
539 Op::GetVariant(_) => { s.stack.pop(); s.stack.push(Slot::Other); }
540 Op::GetVariantArg(_) => { s.stack.pop(); s.stack.push(Slot::Other); }
541 Op::GetListLen => { s.stack.pop(); s.stack.push(Slot::Other); }
542 Op::GetListElem(_) => { s.stack.pop(); s.stack.push(Slot::Other); }
543 Op::GetListElemDyn => {
544 // pop [list, idx] → push elem
545 s.stack.pop(); s.stack.pop(); s.stack.push(Slot::Other);
546 }
547 Op::ListAppend => {
548 // pop [list, value]; value is now inside the list → escape
549 if let Some(value) = s.stack.pop() { leak(&value, &mut escapes); }
550 s.stack.pop(); // list itself
551 s.stack.push(Slot::Other);
552 }
553
554 // Control flow.
555 Op::Jump(off) => {
556 let target = (pc as i32 + 1 + off) as usize;
557 return (s, vec![target], escapes);
558 }
559 Op::JumpIf(off) | Op::JumpIfNot(off) => {
560 s.stack.pop(); // consumed Bool
561 let target = (pc as i32 + 1 + off) as usize;
562 return (s, vec![pc + 1, target], escapes);
563 }
564 Op::Return => {
565 let top = s.stack.pop();
566 // The only policy-sensitive arm: under FrameScope the
567 // returned value crosses our frame and leaks; under
568 // RequestScope it stays inside the request and does not.
569 if matches!(policy, Policy::FrameScope) {
570 if let Some(top) = top { leak(&top, &mut escapes); }
571 }
572 return (s, vec![], escapes);
573 }
574 Op::Panic(_) => {
575 return (s, vec![], escapes);
576 }
577 Op::TailCall { arity, .. } => {
578 pop_n_leak(&mut s, *arity as usize, &mut escapes);
579 return (s, vec![], escapes);
580 }
581 Op::Call { arity, .. } => {
582 pop_n_leak(&mut s, *arity as usize, &mut escapes);
583 s.stack.push(Slot::Other);
584 }
585 Op::CallClosure { arity, .. } => {
586 // pop arity args + 1 closure
587 pop_n_leak(&mut s, *arity as usize + 1, &mut escapes);
588 s.stack.push(Slot::Other);
589 }
590 Op::SortByKey { .. } | Op::ParallelMap { .. }
591 | Op::ListMap { .. } | Op::ListFilter { .. } => {
592 // pop [xs, f]; both escape
593 pop_n_leak(&mut s, 2, &mut escapes);
594 s.stack.push(Slot::Other);
595 }
596 Op::ListFold { .. } => {
597 // pop [xs, init, f]; all escape into the native op
598 pop_n_leak(&mut s, 3, &mut escapes);
599 s.stack.push(Slot::Other);
600 }
601 Op::EffectCall { arity, .. } => {
602 pop_n_leak(&mut s, *arity as usize, &mut escapes);
603 s.stack.push(Slot::Other);
604 }
605
606 // Pure arithmetic / comparison / logic / string ops. Their
607 // operands are primitives in well-typed code (the existing
608 // type checker rejects record-typed args to IntAdd etc.),
609 // so we don't expect Rec slots to flow in. If one does, the
610 // pop_n_drop loses the Rec without recording escape — but
611 // that would be a type-system bug surfaced elsewhere.
612 Op::IntAdd | Op::IntSub | Op::IntMul | Op::IntDiv | Op::IntMod
613 | Op::IntEq | Op::IntLt | Op::IntLe
614 | Op::FloatAdd | Op::FloatSub | Op::FloatMul | Op::FloatDiv
615 | Op::FloatEq | Op::FloatLt | Op::FloatLe
616 | Op::NumAdd | Op::NumSub | Op::NumMul | Op::NumDiv | Op::NumMod
617 | Op::NumEq | Op::NumLt | Op::NumLe
618 | Op::BoolAnd | Op::BoolOr
619 | Op::StrConcat | Op::StrEq | Op::BytesEq => {
620 pop_n_drop(&mut s, 2);
621 s.stack.push(Slot::Other);
622 }
623 Op::IntNeg | Op::FloatNeg | Op::NumNeg | Op::BoolNot
624 | Op::StrLen | Op::BytesLen => {
625 s.stack.pop();
626 s.stack.push(Slot::Other);
627 }
628
629 // Superinstructions (#461). All operate on Int locals and
630 // primitive stack values — they neither produce nor consume
631 // Rec slots. The trailing tombstones are inert; the verifier
632 // pattern (skip ahead by N) is mirrored here.
633 Op::LoadLocalAddIntConst { .. } => {
634 // +1 net (LoadLocal + PushConst + IntAdd)
635 s.stack.push(Slot::Other);
636 }
637 Op::LoadLocalAddIntConstStoreLocal { dest, .. } => {
638 // delta 0; updates a local with an Int. Overwriting a
639 // local doesn't escape its prior aggregate — same rule as
640 // plain StoreLocal above (the dest is Int-typed by this
641 // superinstruction's contract, so prev is never an
642 // aggregate in practice; relaxed for consistency).
643 let i = *dest as usize;
644 if i >= s.locals.len() { s.locals.resize(i + 1, Slot::Other); }
645 s.locals[i] = Slot::Other;
646 return (s, vec![pc + 4], escapes);
647 }
648 Op::LoadLocalAddLocal { .. }
649 | Op::LoadLocalSubLocal { .. }
650 | Op::LoadLocalMulLocal { .. } => {
651 // +1 net (two LoadLocal + one binop)
652 s.stack.push(Slot::Other);
653 return (s, vec![pc + 3], escapes);
654 }
655 Op::LoadLocalGetField { .. } => {
656 // #461 slice 9: equivalent to LoadLocal + GetField —
657 // reads a field out of a local record (which does NOT
658 // escape the receiver, matching plain GetField) and
659 // pushes the field value (Other). Net delta +1; skip the
660 // single tombstone (pc+2). (Escape analysis runs before
661 // peephole, so this arm is exercised only if the analysis
662 // is ever re-run on fused code; it's here for exhaustive
663 // matching and forward-correctness.)
664 s.stack.push(Slot::Other);
665 return (s, vec![pc + 2], escapes);
666 }
667 Op::LoadLocalGetFieldAdd { .. }
668 | Op::LoadLocalGetFieldSub { .. }
669 | Op::LoadLocalGetFieldMul { .. } => {
670 // #461 slice 7/8: net delta on the value stack is 0 (pops
671 // prior Int top, pushes Int result). The receiver is read
672 // from a local — the analysis tracks locals separately,
673 // and reading a local doesn't escape its Rec slot (the
674 // round-trip-through-local rule from StoreLocal applies
675 // here too: the value stays referenced). We pop the
676 // existing top (the accumulator) and push a fresh Other
677 // (the result). Skip past the two tombstones.
678 s.stack.pop();
679 s.stack.push(Slot::Other);
680 return (s, vec![pc + 3], escapes);
681 }
682 Op::LoadLocalEqIntConstJumpIfNot { jump_offset, .. } => {
683 // delta 0; two successors (fall-through past tombstones,
684 // and the branch target relative to the original
685 // JumpIfNot's pc).
686 let target = (pc as i32 + 4 + jump_offset) as usize;
687 return (s, vec![pc + 4, target], escapes);
688 }
689 Op::LoadLocalStoreEqIntConstJumpIfNot { dst, jump_offset, .. } => {
690 // delta 0; also writes locals[dst] := locals[src].
691 // Treat the local update the same as StoreLocal of an
692 // Other (the scrutinee is an Int per slice-6's contract).
693 let i = *dst as usize;
694 if i >= s.locals.len() { s.locals.resize(i + 1, Slot::Other); }
695 // Overwriting a local doesn't escape its prior aggregate
696 // (same rule as plain StoreLocal); dst is Int-typed here.
697 s.locals[i] = Slot::Other;
698 let target = (pc as i32 + 6 + jump_offset) as usize;
699 return (s, vec![pc + 6, target], escapes);
700 }
701 }
702
703 (s, vec![pc + 1], escapes)
704}
705
706/// Convenience wrapper over `analyze_program` returning a map
707/// keyed by `(fn_name, pc)` for direct lookup during codegen.
708pub fn build_escape_index(functions: &[Function]) -> HashMap<(String, u32), bool> {
709 let mut idx = HashMap::new();
710 for report in analyze_program(functions) {
711 for site in report.sites {
712 idx.insert((report.fn_name.clone(), site.pc), site.escapes);
713 }
714 }
715 idx
716}
717
718#[cfg(test)]
719mod tests {
720 use super::*;
721 use crate::op::Op;
722 use crate::program::{Function, ZERO_BODY_HASH};
723
724 /// Helper: build a minimal Function with the given code and
725 /// just enough machinery for the analyzer.
726 fn func(name: &str, locals_count: u16, arity: u16, code: Vec<Op>) -> Function {
727 Function {
728 name: name.into(),
729 arity,
730 locals_count,
731 code,
732 effects: vec![],
733 body_hash: ZERO_BODY_HASH,
734 refinements: vec![],
735 field_ic_sites: 0,
736 }
737 }
738
739 /// Expectation helper: a list of `(pc, expected_escapes)` pairs.
740 fn assert_escapes(report: &EscapeReport, expected: &[(u32, bool)]) {
741 let got: Vec<(u32, bool)> = report.sites.iter().map(|s| (s.pc, s.escapes)).collect();
742 assert_eq!(got, expected,
743 "escape report for `{}` differs from expected", report.fn_name);
744 }
745
746 #[test]
747 fn record_built_and_dropped_does_not_escape() {
748 // PushConst PushConst MakeRecord Pop Return
749 let f = func("dropper", 0, 0, vec![
750 Op::PushConst(0),
751 Op::PushConst(1),
752 Op::MakeRecord { shape_idx: 0, field_count: 2 },
753 Op::Pop,
754 Op::PushConst(0),
755 Op::Return,
756 ]);
757 let r = analyze_function(&f);
758 assert_escapes(&r, &[(2, false)]);
759 }
760
761 #[test]
762 fn record_returned_escapes() {
763 let f = func("returner", 0, 0, vec![
764 Op::PushConst(0),
765 Op::PushConst(1),
766 Op::MakeRecord { shape_idx: 0, field_count: 2 },
767 Op::Return,
768 ]);
769 let r = analyze_function(&f);
770 assert_escapes(&r, &[(2, true)]);
771 }
772
773 #[test]
774 fn record_field_read_only_does_not_escape() {
775 // PushConst PushConst MakeRecord GetField Return (returns the field, not the record)
776 let f = func("reader", 0, 0, vec![
777 Op::PushConst(0),
778 Op::PushConst(1),
779 Op::MakeRecord { shape_idx: 0, field_count: 2 },
780 Op::GetField { name_idx: 0, site_idx: 0 },
781 Op::Return,
782 ]);
783 let r = analyze_function(&f);
784 assert_escapes(&r, &[(2, false)]);
785 }
786
787 #[test]
788 fn record_round_tripped_through_local_does_not_escape() {
789 let f = func("roundtrip", 1, 0, vec![
790 Op::PushConst(0),
791 Op::PushConst(1),
792 Op::MakeRecord { shape_idx: 0, field_count: 2 },
793 Op::StoreLocal(0),
794 Op::LoadLocal(0),
795 Op::GetField { name_idx: 0, site_idx: 0 },
796 Op::Return,
797 ]);
798 let r = analyze_function(&f);
799 assert_escapes(&r, &[(2, false)]);
800 }
801
802 #[test]
803 fn record_stored_into_outer_record_escapes() {
804 // Build inner, then build outer with inner as one of its fields.
805 let f = func("nest", 0, 0, vec![
806 Op::PushConst(0),
807 Op::PushConst(1),
808 Op::MakeRecord { shape_idx: 0, field_count: 2 }, // inner @ pc=2
809 Op::PushConst(2),
810 Op::MakeRecord { shape_idx: 1, field_count: 2 }, // outer @ pc=4
811 Op::Return, // outer escapes
812 ]);
813 let r = analyze_function(&f);
814 // inner escapes (captured in outer); outer escapes (returned).
815 assert_escapes(&r, &[(2, true), (4, true)]);
816 }
817
818 #[test]
819 fn record_passed_to_call_escapes() {
820 let f = func("passer", 0, 0, vec![
821 Op::PushConst(0),
822 Op::PushConst(1),
823 Op::MakeRecord { shape_idx: 0, field_count: 2 },
824 Op::Call { fn_id: 1, arity: 1, node_id_idx: 0 },
825 Op::Return,
826 ]);
827 let r = analyze_function(&f);
828 assert_escapes(&r, &[(2, true)]);
829 }
830
831 #[test]
832 fn record_captured_in_closure_escapes() {
833 let f = func("capturer", 0, 0, vec![
834 Op::PushConst(0),
835 Op::PushConst(1),
836 Op::MakeRecord { shape_idx: 0, field_count: 2 },
837 Op::MakeClosure { fn_id: 1, capture_count: 1 },
838 Op::Return,
839 ]);
840 let r = analyze_function(&f);
841 assert_escapes(&r, &[(2, true)]);
842 }
843
844 #[test]
845 fn record_in_one_branch_returned_escapes_after_merge() {
846 // if cond { rec1 } else { rec2 } — Return after merge.
847 // Under FrameScope, Return leaks every site in the merged
848 // `AggSet`, so both still escape. The precision refinement
849 // (sibling `merged_branch_records_kept_tracked_*` tests
850 // below) only changes the answer under RequestScope.
851 let f = func("brancher", 0, 1, vec![
852 Op::LoadLocal(0), // pc=0
853 Op::JumpIfNot(4), // pc=1; offset 4 → pc=6
854 Op::PushConst(0), // pc=2
855 Op::MakeRecord { shape_idx: 0, field_count: 1 }, // pc=3 (then-branch)
856 Op::Jump(2), // pc=4; offset 2 → pc=7
857 Op::PushConst(1), // pc=5 (unreached fall-through dead code)
858 Op::MakeRecord { shape_idx: 0, field_count: 1 }, // pc=6 (else-branch)
859 Op::Return, // pc=7 (merge + return)
860 ]);
861 let r = analyze_function(&f);
862 // Both record sites escape — Return sees a merged stack.
863 assert_escapes(&r, &[(3, true), (6, true)]);
864 }
865
866 /// Per-path precision refinement: under `RequestScope`, two
867 /// records produced in alternate `if`/`else` branches and merged
868 /// before `Return` stay tracked across the join (`AggSet([p,q])`)
869 /// and `Return` leaves them alone, so neither escapes. This is
870 /// the win that makes the `response_build`-shape `match`-arm
871 /// handlers arena-eligible.
872 #[test]
873 fn merged_branch_records_kept_tracked_under_request_scope() {
874 let f = func("brancher_req", 0, 1, vec![
875 Op::LoadLocal(0),
876 Op::JumpIfNot(4),
877 Op::PushConst(0),
878 Op::MakeRecord { shape_idx: 0, field_count: 1 }, // pc=3
879 Op::Jump(2),
880 Op::PushConst(1),
881 Op::MakeRecord { shape_idx: 0, field_count: 1 }, // pc=6
882 Op::Return,
883 ]);
884 let r = analyze_function_with_policy(&f, Policy::RequestScope);
885 // Neither site escapes — both stay in the merged AggSet
886 // through Return, which doesn't leak under RequestScope.
887 assert_escapes(&r, &[(3, false), (6, false)]);
888 }
889
890 /// Sibling guard: under `RequestScope`, if the merged set is
891 /// then passed to a `Call` (a hatch under both policies), every
892 /// site in the set still escapes. Confirms the leak helper
893 /// iterates `sites()` correctly across the multi-site variant.
894 #[test]
895 fn merged_branch_records_all_escape_at_call_under_request_scope() {
896 let f = func("brancher_then_call", 0, 1, vec![
897 Op::LoadLocal(0),
898 Op::JumpIfNot(4),
899 Op::PushConst(0),
900 Op::MakeRecord { shape_idx: 0, field_count: 1 }, // pc=3
901 Op::Jump(2),
902 Op::PushConst(1),
903 Op::MakeRecord { shape_idx: 0, field_count: 1 }, // pc=6
904 Op::Call { fn_id: 1, arity: 1, node_id_idx: 0 }, // pc=7
905 Op::Return,
906 ]);
907 let r = analyze_function_with_policy(&f, Policy::RequestScope);
908 // The Call consumes the merged AggSet → both sites leak.
909 assert_escapes(&r, &[(3, true), (6, true)]);
910 }
911
912 // ---- Deep-leaf widening (containment-tracking) ----
913
914 /// Inner record returned inside outer record — both arena-
915 /// eligible under request-scope. Pre-deep-leaf, `inner` was
916 /// leaked at the outer's `MakeRecord`; the transitive
917 /// expansion now leaves it alone because the outer doesn't
918 /// escape under `Policy::RequestScope`.
919 #[test]
920 fn deep_leaf_nested_records_both_arena_eligible() {
921 let f = func("nested_ret", 0, 0, vec![
922 Op::PushConst(0),
923 Op::MakeRecord { shape_idx: 0, field_count: 1 }, // inner @ pc=1
924 Op::PushConst(1),
925 Op::MakeRecord { shape_idx: 1, field_count: 2 }, // outer @ pc=3, holds inner
926 Op::Return,
927 ]);
928 let r = analyze_function_with_policy(&f, Policy::RequestScope);
929 assert_escapes(&r, &[(1, false), (3, false)]);
930 }
931
932 /// 3-deep nesting: inner → middle → outer → Return. Under
933 /// `RequestScope` all three are arena-eligible — transitive
934 /// expansion in `analyze_function_with_policy` chains through
935 /// the containment map.
936 #[test]
937 fn deep_leaf_three_deep_chain_all_arena_eligible() {
938 let f = func("three_deep", 0, 0, vec![
939 Op::PushConst(0),
940 Op::MakeRecord { shape_idx: 0, field_count: 1 }, // inner @ pc=1
941 Op::MakeRecord { shape_idx: 0, field_count: 1 }, // middle @ pc=2
942 Op::MakeRecord { shape_idx: 0, field_count: 1 }, // outer @ pc=3
943 Op::Return,
944 ]);
945 let r = analyze_function_with_policy(&f, Policy::RequestScope);
946 assert_escapes(&r, &[(1, false), (2, false), (3, false)]);
947 }
948
949 /// Soundness guard: when the outer reaches a real hatch
950 /// (`Call`), all contained children escape transitively. Same
951 /// chain shape as above but with the outer leaving via Call.
952 #[test]
953 fn deep_leaf_chain_all_escape_when_root_passed_to_call() {
954 let f = func("three_deep_call", 0, 0, vec![
955 Op::PushConst(0),
956 Op::MakeRecord { shape_idx: 0, field_count: 1 }, // inner @ pc=1
957 Op::MakeRecord { shape_idx: 0, field_count: 1 }, // middle @ pc=2
958 Op::MakeRecord { shape_idx: 0, field_count: 1 }, // outer @ pc=3
959 Op::Call { fn_id: 1, arity: 1, node_id_idx: 0 }, // pc=4
960 Op::Return,
961 ]);
962 let r = analyze_function_with_policy(&f, Policy::RequestScope);
963 // outer escapes via Call → middle escapes via containment →
964 // inner escapes via containment. All three flagged.
965 assert_escapes(&r, &[(1, true), (2, true), (3, true)]);
966 }
967
968 /// Mixed: outer dropped, inner contained. Under FrameScope the
969 /// outer is dropped and never returned, so the transitive
970 /// expansion leaves inner alone too — even under FrameScope
971 /// this is a precision win over the pre-refinement behavior
972 /// that leaked inner at the outer's build site.
973 #[test]
974 fn deep_leaf_outer_popped_inner_stays_local_under_frame_scope() {
975 let f = func("nested_pop", 0, 0, vec![
976 Op::PushConst(0),
977 Op::MakeRecord { shape_idx: 0, field_count: 1 }, // inner @ pc=1
978 Op::MakeRecord { shape_idx: 0, field_count: 1 }, // outer @ pc=2
979 Op::Pop,
980 Op::PushConst(0),
981 Op::Return,
982 ]);
983 let r = analyze_function(&f); // FrameScope
984 assert_escapes(&r, &[(1, false), (2, false)]);
985 }
986
987 #[test]
988 fn two_sites_classified_independently() {
989 // One record returned, one popped — they should classify
990 // separately. Sequencing: build keeper, store it; build
991 // discard, pop it; load keeper, return.
992 let f = func("mixed", 1, 0, vec![
993 Op::PushConst(0),
994 Op::MakeRecord { shape_idx: 0, field_count: 1 }, // keeper @ pc=1
995 Op::StoreLocal(0),
996 Op::PushConst(0),
997 Op::MakeRecord { shape_idx: 0, field_count: 1 }, // discard @ pc=4
998 Op::Pop,
999 Op::LoadLocal(0),
1000 Op::Return,
1001 ]);
1002 let r = analyze_function(&f);
1003 assert_escapes(&r, &[(1, true), (4, false)]);
1004 }
1005
1006 #[test]
1007 fn function_with_no_record_sites_produces_empty_report() {
1008 let f = func("pure_arith", 0, 2, vec![
1009 Op::LoadLocal(0),
1010 Op::LoadLocal(1),
1011 Op::IntAdd,
1012 Op::Return,
1013 ]);
1014 let r = analyze_function(&f);
1015 assert!(r.sites.is_empty());
1016 }
1017
1018 #[test]
1019 fn analyze_program_skips_no_record_functions() {
1020 let f1 = func("noreds", 0, 0, vec![Op::PushConst(0), Op::Return]);
1021 let f2 = func("hasrec", 0, 0, vec![
1022 Op::PushConst(0),
1023 Op::MakeRecord { shape_idx: 0, field_count: 1 },
1024 Op::Return,
1025 ]);
1026 let reports = analyze_program(&[f1, f2]);
1027 assert_eq!(reports.len(), 1);
1028 assert_eq!(reports[0].fn_name, "hasrec");
1029 }
1030
1031 #[test]
1032 fn record_passed_to_effect_call_escapes() {
1033 let f = func("effecting", 0, 0, vec![
1034 Op::PushConst(0),
1035 Op::MakeRecord { shape_idx: 0, field_count: 1 },
1036 Op::EffectCall { kind_idx: 0, op_idx: 0, arity: 1, node_id_idx: 0 },
1037 Op::Return,
1038 ]);
1039 let r = analyze_function(&f);
1040 assert_escapes(&r, &[(1, true)]);
1041 }
1042
1043 #[test]
1044 fn record_duplicated_escapes() {
1045 // Dup is conservatively an escape — aliasing breaks the
1046 // linear-flow assumption.
1047 let f = func("duper", 0, 0, vec![
1048 Op::PushConst(0),
1049 Op::MakeRecord { shape_idx: 0, field_count: 1 },
1050 Op::Dup,
1051 Op::Pop,
1052 Op::Pop,
1053 Op::PushConst(0),
1054 Op::Return,
1055 ]);
1056 let r = analyze_function(&f);
1057 assert_escapes(&r, &[(1, true)]);
1058 }
1059
1060 #[test]
1061 fn record_in_list_escapes() {
1062 let f = func("listed", 0, 0, vec![
1063 Op::PushConst(0),
1064 Op::MakeRecord { shape_idx: 0, field_count: 1 },
1065 Op::MakeList(1),
1066 Op::Return,
1067 ]);
1068 let r = analyze_function(&f);
1069 assert_escapes(&r, &[(1, true)]);
1070 }
1071
1072 #[test]
1073 fn build_escape_index_keys_by_fn_and_pc() {
1074 let f = func("idx_test", 0, 0, vec![
1075 Op::PushConst(0),
1076 Op::MakeRecord { shape_idx: 0, field_count: 1 },
1077 Op::Pop,
1078 Op::PushConst(0),
1079 Op::Return,
1080 ]);
1081 let idx = build_escape_index(&[f]);
1082 assert_eq!(idx.get(&("idx_test".into(), 1)), Some(&false));
1083 }
1084
1085 // ---- tuple widening (#464 tuple analysis slice) ----
1086
1087 #[test]
1088 fn tuple_built_and_dropped_does_not_escape() {
1089 let f = func("tup_drop", 0, 0, vec![
1090 Op::PushConst(0),
1091 Op::PushConst(1),
1092 Op::MakeTuple(2), // pc=2
1093 Op::Pop,
1094 Op::PushConst(0),
1095 Op::Return,
1096 ]);
1097 let r = analyze_function(&f);
1098 assert_escapes(&r, &[(2, false)]);
1099 assert_eq!(r.sites[0].kind, SiteKind::Tuple);
1100 assert_eq!(r.sites[0].field_count, 2);
1101 }
1102
1103 #[test]
1104 fn tuple_returned_escapes() {
1105 let f = func("tup_ret", 0, 0, vec![
1106 Op::PushConst(0),
1107 Op::PushConst(1),
1108 Op::MakeTuple(2), // pc=2
1109 Op::Return,
1110 ]);
1111 let r = analyze_function(&f);
1112 assert_escapes(&r, &[(2, true)]);
1113 }
1114
1115 #[test]
1116 fn tuple_elem_read_only_does_not_escape() {
1117 // Reading an element (GetElem) consumes the tuple locally,
1118 // mirroring GetField on a record — the tuple stays a candidate.
1119 let f = func("tup_read", 0, 0, vec![
1120 Op::PushConst(0),
1121 Op::PushConst(1),
1122 Op::MakeTuple(2), // pc=2
1123 Op::GetElem(0),
1124 Op::Return,
1125 ]);
1126 let r = analyze_function(&f);
1127 assert_escapes(&r, &[(2, false)]);
1128 }
1129
1130 #[test]
1131 fn tuple_round_tripped_through_local_does_not_escape() {
1132 let f = func("tup_rt", 1, 0, vec![
1133 Op::PushConst(0),
1134 Op::PushConst(1),
1135 Op::MakeTuple(2), // pc=2
1136 Op::StoreLocal(0),
1137 Op::LoadLocal(0),
1138 Op::GetElem(1),
1139 Op::Return,
1140 ]);
1141 let r = analyze_function(&f);
1142 assert_escapes(&r, &[(2, false)]);
1143 }
1144
1145 #[test]
1146 fn tuple_passed_to_call_escapes() {
1147 let f = func("tup_call", 0, 0, vec![
1148 Op::PushConst(0),
1149 Op::PushConst(1),
1150 Op::MakeTuple(2), // pc=2
1151 Op::Call { fn_id: 1, arity: 1, node_id_idx: 0 },
1152 Op::Return,
1153 ]);
1154 let r = analyze_function(&f);
1155 assert_escapes(&r, &[(2, true)]);
1156 }
1157
1158 #[test]
1159 fn record_stored_into_tuple_dropped_outer_neither_escapes() {
1160 // Build a record, wrap it in a tuple, drop the tuple.
1161 // Pre-deep-leaf this asserted `[(1, true), (2, false)]` —
1162 // the record was leaked at the tuple's build site because
1163 // we conservatively assumed any captured value escaped.
1164 // With containment-tracking, the record's fate is bound to
1165 // the tuple's: the tuple is dropped (no escape), so via
1166 // transitive expansion the record doesn't escape either.
1167 // The win is real — both can live in the frame's stack-
1168 // record arena now.
1169 let f = func("rec_in_tup", 0, 0, vec![
1170 Op::PushConst(0),
1171 Op::MakeRecord { shape_idx: 0, field_count: 1 }, // rec @ pc=1
1172 Op::MakeTuple(1), // tup @ pc=2 holds rec
1173 Op::Pop,
1174 Op::PushConst(0),
1175 Op::Return,
1176 ]);
1177 let r = analyze_function(&f);
1178 assert_escapes(&r, &[(1, false), (2, false)]);
1179 }
1180
1181 #[test]
1182 fn tuple_stored_into_record_escapes() {
1183 let f = func("tup_in_rec", 0, 0, vec![
1184 Op::PushConst(0),
1185 Op::PushConst(1),
1186 Op::MakeTuple(2), // tup @ pc=2
1187 Op::MakeRecord { shape_idx: 0, field_count: 1 }, // rec @ pc=3 holds tup
1188 Op::Return,
1189 ]);
1190 let r = analyze_function(&f);
1191 assert_escapes(&r, &[(2, true), (3, true)]);
1192 }
1193
1194 #[test]
1195 fn tuple_and_record_sites_carry_distinct_kinds() {
1196 let f = func("mixed_kinds", 0, 0, vec![
1197 Op::PushConst(0),
1198 Op::MakeRecord { shape_idx: 7, field_count: 1 }, // pc=1 record
1199 Op::Pop,
1200 Op::PushConst(0),
1201 Op::PushConst(1),
1202 Op::MakeTuple(2), // pc=5 tuple
1203 Op::Pop,
1204 Op::PushConst(0),
1205 Op::Return,
1206 ]);
1207 let r = analyze_function(&f);
1208 assert_eq!(r.sites.len(), 2);
1209 assert_eq!((r.sites[0].pc, r.sites[0].kind, r.sites[0].shape_idx), (1, SiteKind::Record, 7));
1210 assert_eq!((r.sites[1].pc, r.sites[1].kind, r.sites[1].field_count), (5, SiteKind::Tuple, 2));
1211 assert!(!r.sites[0].escapes && !r.sites[1].escapes);
1212 }
1213
1214 #[test]
1215 fn aggregate_overwritten_in_dead_slot_does_not_escape() {
1216 // rec1 is stored into local 0, then local 0 is overwritten
1217 // with an Int and rec1 is never otherwise used. Lex's
1218 // immutable `let` bindings mean a slot is only reused once its
1219 // prior occupant is dead, so the overwrite must NOT flag rec1
1220 // as escaping (the relaxed StoreLocal rule — #464 tuple
1221 // codegen). Pre-relaxation this returned `true`.
1222 let f = func("overwrite", 1, 0, vec![
1223 Op::PushConst(0),
1224 Op::MakeRecord { shape_idx: 0, field_count: 1 }, // rec1 @ pc=1
1225 Op::StoreLocal(0),
1226 Op::PushConst(0),
1227 Op::StoreLocal(0), // overwrite local 0 with an Int
1228 Op::PushConst(0),
1229 Op::Return,
1230 ]);
1231 let r = analyze_function(&f);
1232 assert_escapes(&r, &[(1, false)]);
1233 }
1234
1235 #[test]
1236 fn aggregate_loaded_then_returned_still_escapes() {
1237 // Soundness guard for the relaxed overwrite rule: an aggregate
1238 // stored in a local, then LOADED and returned, still escapes —
1239 // the `Return` leak catches it independent of any overwrite.
1240 let f = func("load_then_return", 1, 0, vec![
1241 Op::PushConst(0),
1242 Op::MakeRecord { shape_idx: 0, field_count: 1 }, // rec1 @ pc=1
1243 Op::StoreLocal(0),
1244 Op::LoadLocal(0),
1245 Op::Return, // returns rec1 → escapes
1246 ]);
1247 let r = analyze_function(&f);
1248 assert_escapes(&r, &[(1, true)]);
1249 }
1250
1251 #[test]
1252 fn tuple_only_function_now_produces_report() {
1253 // Pre-widening this function had no tracked sites and was
1254 // omitted from analyze_program; now its tuple site is reported.
1255 let f = func("tuponly", 0, 0, vec![
1256 Op::PushConst(0),
1257 Op::PushConst(1),
1258 Op::MakeTuple(2),
1259 Op::Pop,
1260 Op::PushConst(0),
1261 Op::Return,
1262 ]);
1263 let reports = analyze_program(&[f]);
1264 assert_eq!(reports.len(), 1);
1265 assert_eq!(reports[0].sites.len(), 1);
1266 assert_eq!(reports[0].sites[0].kind, SiteKind::Tuple);
1267 }
1268}