From dae27b2189c8ce4c6e968ab4b14494ee6bda210a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 12 Aug 2026 10:46:58 +0200 Subject: [PATCH 1/7] perf(transform): release a completed async activation's boxed locals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The async-to-generator transform boxes every body local of an async function into a never-freed `BOX_REGISTRY` cell, and nothing ever cleared one — so every local of every activation the program had ever run stayed a live GC root. Clear (never free) the cells no closure can observe at the state machine's two terminal states. Refs #7933 Claude-Session: https://claude.ai/code/session_012B8z92S82sCfqCrVqrFgS2 --- .../src/generator/box_release.rs | 335 ++++++++++++++++++ crates/perry-transform/src/generator/lower.rs | 66 ++++ .../src/generator/lower/async_step.rs | 54 ++- crates/perry-transform/src/generator/mod.rs | 2 + 4 files changed, 446 insertions(+), 11 deletions(-) create mode 100644 crates/perry-transform/src/generator/box_release.rs diff --git a/crates/perry-transform/src/generator/box_release.rs b/crates/perry-transform/src/generator/box_release.rs new file mode 100644 index 0000000000..a806ef5d73 --- /dev/null +++ b/crates/perry-transform/src/generator/box_release.rs @@ -0,0 +1,335 @@ +//! #7933: releasing an async activation's boxed body locals at its terminal +//! state. +//! +//! The async-to-generator transform boxes every body local of an `async` +//! function (`Stmt::PreallocateBoxes`, one `js_box_alloc_bits` cell per local +//! per invocation) so the synthesized state-machine closures can share them +//! across suspends. Box cells are registered in the runtime's `BOX_REGISTRY` +//! and are **never freed** — that monotonicity is what makes perry#4898's +//! pointer rejection and #7906's positive pointer cache sound — and +//! `scan_box_roots_mut` marks the JSValue inside every registered cell on every +//! collection. So every local of every activation the program has *ever* run +//! stays a live GC root for the life of the process. +//! +//! The fix is to **clear** (not free) an activation's cells when its state +//! machine reaches a terminal state: a `js_box_set(cell, undefined)` keeps the +//! address registered and readable — a stale reader sees `undefined`, which is +//! already the defined value of an uninitialised boxed local (perry#4926) — and +//! drops the retention, which is the entire cost. +//! +//! Clearing a cell whose value is still *reachable* would be a silent +//! use-after-clear (a wrong answer, not a crash), so a cell is only cleared +//! when no closure in the function can hold its address. This module computes +//! that set. +//! +//! ## Why "referenced by a closure" is the right, and sufficient, test +//! +//! A box address is never a JS value: `LocalGet`/`LocalSet` on a boxed local +//! lower to `js_box_get`/`js_box_set` on the cell, and the raw address only +//! ever leaves the activation through a **closure capture slot**. Codegen +//! forwards the address into a capture slot for exactly the ids in +//! `compute_auto_captures(closure) ∩ boxed_vars`, and `compute_auto_captures` +//! is `explicit captures ∪ collect_ref_ids_in_stmts(closure body)`. +//! +//! [`closure_visible_ids`] returns a **superset** of that: the explicit +//! `captures` *and* `mutable_captures` lists plus +//! `perry_hir::analysis::collect_local_refs_expr` over the whole closure +//! expression (which descends into nested closures). An id it misses is an id +//! codegen's own free-variable walk also misses, so no capture slot for that id +//! exists and clearing its cell is unobservable. +//! +//! The one construct that breaks that argument is sloppy-mode `with`: +//! `Expr::WithGet`/`Expr::WithSet` carry a fallback `LocalId` as a *leaf field* +//! that `collect_local_refs_expr` does not report. A body containing either +//! poisons the analysis outright (`None`), and the caller clears nothing. + +use perry_hir::ir::*; +use perry_hir::types::LocalId; +use std::collections::HashSet; + +struct Scan { + out: HashSet, + /// Set when a construct is seen whose LocalId references cannot be + /// enumerated (sloppy `with`). The whole analysis is then unusable. + poisoned: bool, +} + +/// Every `LocalId` that some closure inside `stmts` can observe — its declared +/// capture lists plus every local referenced anywhere in its body (transitively +/// through nested closures). +/// +/// Returns `None` when the body contains a construct whose local references +/// cannot be enumerated; callers must then treat *every* id as escaping. +pub(crate) fn closure_visible_ids(stmts: &[Stmt]) -> Option> { + let mut scan = Scan { + out: HashSet::new(), + poisoned: false, + }; + scan_stmts(stmts, &mut scan); + if scan.poisoned { + None + } else { + Some(scan.out) + } +} + +fn scan_stmts(stmts: &[Stmt], scan: &mut Scan) { + for stmt in stmts { + scan_stmt(stmt, scan); + } +} + +/// Exhaustive over `Stmt` on purpose: a new statement variant that can hold an +/// expression must be routed here explicitly rather than silently hiding a +/// closure from the escape analysis. +fn scan_stmt(stmt: &Stmt, scan: &mut Scan) { + match stmt { + Stmt::Let { init, .. } => { + if let Some(e) = init { + scan_expr(e, scan); + } + } + Stmt::Expr(e) | Stmt::Throw(e) => scan_expr(e, scan), + Stmt::Return(e) => { + if let Some(e) = e { + scan_expr(e, scan); + } + } + Stmt::If { + condition, + then_branch, + else_branch, + } => { + scan_expr(condition, scan); + scan_stmts(then_branch, scan); + if let Some(eb) = else_branch { + scan_stmts(eb, scan); + } + } + Stmt::While { condition, body } => { + scan_expr(condition, scan); + scan_stmts(body, scan); + } + Stmt::DoWhile { body, condition } => { + scan_stmts(body, scan); + scan_expr(condition, scan); + } + Stmt::For { + init, + condition, + update, + body, + } => { + if let Some(init) = init { + scan_stmt(init, scan); + } + if let Some(c) = condition { + scan_expr(c, scan); + } + if let Some(u) = update { + scan_expr(u, scan); + } + scan_stmts(body, scan); + } + Stmt::Try { + body, + catch, + finally, + } => { + scan_stmts(body, scan); + if let Some(c) = catch { + scan_stmts(&c.body, scan); + } + if let Some(f) = finally { + scan_stmts(f, scan); + } + } + Stmt::Switch { + discriminant, + cases, + } => { + scan_expr(discriminant, scan); + for case in cases { + if let Some(t) = &case.test { + scan_expr(t, scan); + } + scan_stmts(&case.body, scan); + } + } + Stmt::Labeled { body, .. } => scan_stmt(body, scan), + Stmt::Break + | Stmt::Continue + | Stmt::LabeledBreak(_) + | Stmt::LabeledContinue(_) + | Stmt::PreallocateBoxes(_) + | Stmt::PreallocateTdzBoxes(_) => {} + } +} + +fn scan_expr(expr: &Expr, scan: &mut Scan) { + match expr { + // Sloppy-mode `with`: the fallback LocalId is a leaf field that the + // shared free-variable walk does not report, so the analysis cannot be + // trusted on this body at all. + Expr::WithGet { .. } | Expr::WithSet { .. } => { + scan.poisoned = true; + } + Expr::Closure { + body, + captures, + mutable_captures, + .. + } => { + scan.out.extend(captures.iter().copied()); + scan.out.extend(mutable_captures.iter().copied()); + let mut refs: Vec = Vec::new(); + let mut visited: HashSet = HashSet::new(); + perry_hir::analysis::collect_local_refs_expr(expr, &mut refs, &mut visited); + scan.out.extend(refs); + // Keep descending: nested closures contribute their own explicit + // capture lists, and a `with` anywhere inside must still poison. + scan_stmts(body, scan); + return; + } + _ => {} + } + perry_hir::walker::walk_expr_children(expr, &mut |child| scan_expr(child, scan)); +} + +/// `LocalSet(id, undefined)` per id — inside the state-machine step closure +/// each id is a boxed capture, so this lowers to one +/// `js_box_set(cell, TAG_UNDEFINED)`: no allocation, no collection point, and +/// the cell stays registered. +pub(crate) fn build_box_release_stmts(ids: &[LocalId]) -> Vec { + ids.iter() + .map(|id| Stmt::Expr(Expr::LocalSet(*id, Box::new(Expr::Undefined)))) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn local_get(id: LocalId) -> Expr { + Expr::LocalGet(id) + } + + fn closure(body: Vec, captures: Vec) -> Expr { + Expr::Closure { + func_id: 900, + params: Vec::new(), + return_type: perry_hir::types::Type::Any, + body, + captures, + mutable_captures: Vec::new(), + captures_this: false, + captures_new_target: false, + enclosing_class: None, + is_arrow: true, + is_strict: false, + is_async: false, + is_generator: false, + } + } + + /// A local read only by straight-line body code is not closure-visible, so + /// its cell is clearable. + #[test] + fn plain_body_local_is_not_closure_visible() { + let body = vec![ + Stmt::Let { + id: 1, + name: "v".into(), + ty: perry_hir::types::Type::Any, + mutable: true, + init: Some(Expr::Number(1.0)), + }, + Stmt::Return(Some(local_get(1))), + ]; + let ids = closure_visible_ids(&body).expect("not poisoned"); + assert!(ids.is_empty(), "no closure in the body: {:?}", ids); + } + + /// The negative case this whole module exists for: a local a closure can + /// read must be reported even when the HIR capture list is empty (codegen + /// auto-detects those captures from the body). + #[test] + fn closure_body_reference_is_visible_without_an_explicit_capture() { + let inner = vec![Stmt::Return(Some(local_get(7)))]; + let body = vec![Stmt::Return(Some(closure(inner, Vec::new())))]; + let ids = closure_visible_ids(&body).expect("not poisoned"); + assert!(ids.contains(&7), "auto-detected capture must escape: {ids:?}"); + } + + /// An explicit capture list entry counts even if the body never mentions it. + #[test] + fn explicit_capture_list_entry_is_visible() { + let body = vec![Stmt::Expr(closure(Vec::new(), vec![11]))]; + let ids = closure_visible_ids(&body).expect("not poisoned"); + assert!(ids.contains(&11), "{ids:?}"); + } + + /// Transitive: a closure nested two deep still exposes the outer local. + #[test] + fn nested_closure_reference_is_visible() { + let innermost = vec![Stmt::Return(Some(local_get(21)))]; + let middle = vec![Stmt::Return(Some(closure(innermost, Vec::new())))]; + let body = vec![Stmt::Expr(closure(middle, Vec::new()))]; + let ids = closure_visible_ids(&body).expect("not poisoned"); + assert!(ids.contains(&21), "{ids:?}"); + } + + /// Closures buried under control flow are reached (a `_ => {}` statement + /// arm here would silently make every such local look clearable). + #[test] + fn closure_under_control_flow_is_visible() { + let inner = vec![Stmt::Return(Some(local_get(31)))]; + let body = vec![Stmt::Try { + body: vec![Stmt::Switch { + discriminant: Expr::Number(0.0), + cases: vec![SwitchCase { + test: None, + body: vec![Stmt::Labeled { + label: "l".into(), + body: Box::new(Stmt::While { + condition: Expr::Bool(true), + body: vec![Stmt::Expr(closure(inner, Vec::new()))], + }), + }], + }], + }], + catch: None, + finally: None, + }]; + let ids = closure_visible_ids(&body).expect("not poisoned"); + assert!(ids.contains(&31), "{ids:?}"); + } + + /// Sloppy `with` poisons the analysis: its fallback LocalId is a leaf the + /// shared walk does not report, so nothing may be cleared. + #[test] + fn with_expression_poisons_the_analysis() { + let body = vec![Stmt::Expr(Expr::WithGet { + object: Box::new(Expr::Undefined), + property: "x".into(), + fallback: Box::new(local_get(41)), + })]; + assert!( + closure_visible_ids(&body).is_none(), + "`with` must poison the analysis" + ); + } + + #[test] + fn release_stmts_are_undefined_stores() { + let stmts = build_box_release_stmts(&[3, 5]); + assert_eq!(stmts.len(), 2); + match &stmts[0] { + Stmt::Expr(Expr::LocalSet(id, value)) => { + assert_eq!(*id, 3); + assert!(matches!(**value, Expr::Undefined)); + } + other => panic!("unexpected release stmt: {other:?}"), + } + } +} diff --git a/crates/perry-transform/src/generator/lower.rs b/crates/perry-transform/src/generator/lower.rs index 45a1f8d0cf..2316ceec06 100644 --- a/crates/perry-transform/src/generator/lower.rs +++ b/crates/perry-transform/src/generator/lower.rs @@ -316,6 +316,24 @@ pub fn transform_generator_function_with_extra_captures( // must be boxed captures like any other cross-state local. let prologue_hoist = collect_hoisted_vars(¶m_prologue); + // #7933: every local a closure in this body can observe. Read from the + // ORIGINAL body (before hoisting/linearization move statements around) — + // those passes rewrite references but never introduce a user closure, and + // the ones that do rewrite closure bodies (`rewrite_written_captures_to_cells`, + // `snapshot_suspended_loop_captures`) only ever redirect a closure onto a + // *different* id, which leaves the pre-pass answer conservative. `None` + // means the analysis is unusable (sloppy `with`) and nothing may be + // released. See `box_release.rs` for why closure visibility is the exact + // condition. + let closure_visible_before: Option> = { + closure_visible_ids(&func.body).and_then(|mut ids| { + closure_visible_ids(¶m_prologue).map(|p| { + ids.extend(p); + ids + }) + }) + }; + // #321: hoist `yield` / `yield*` that live inside a larger expression // (`return (yield 1) + (yield 2)`, call args, array/object literals, etc.) // into ordered `let __ygen_N = yield E;` temps so the linearizer below only @@ -864,6 +882,53 @@ pub fn transform_generator_function_with_extra_captures( // emit `Stmt::Throw(value)` inline in its is-error arm, saving one // closure allocation per async-fn invocation (50k/run on the // promise_all_chains kernel). + // #7933: the activation's boxed body locals that no closure can + // observe, released (set to `undefined`) at the state machine's + // terminal states. Second, independent scan of the POST-linearization + // bodies, unioned with the pre-pass one: an id either scan calls + // closure-visible is kept. + let release_ids: Vec = { + let post_visible = closure_visible_ids(&next_resume_body).and_then(|mut ids| { + let routes_ok = catches.iter().all(|route| { + match closure_visible_ids(&route.body) { + Some(r) => { + ids.extend(r); + true + } + None => false, + } + }); + if routes_ok { + Some(ids) + } else { + None + } + }); + match (&closure_visible_before, &post_visible) { + (Some(before), Some(after)) => { + let mut ids: Vec = hoisted + .iter() + .map(|(id, _, _)| *id) + .chain(extra_local_ids.iter().copied()) + // `__gen_sent` holds the value the last `await` + // delivered — a first-class retainer, and re-entry + // overwrites it before any read. + .chain(std::iter::once(sent_id)) + .filter(|id| !before.contains(id) && !after.contains(id)) + .collect(); + // The state-machine control locals (`__gen_state`, + // `__gen_done`, `__gen_executing`, the pending-completion + // record) are deliberately NOT released: a resume that + // arrives after the terminal state reads `__gen_done` to + // short-circuit, and an `undefined` there would drop it + // into the dispatch loop with no matching state. + ids.sort(); + ids.dedup(); + ids + } + _ => Vec::new(), + } + }; let throw_routes_for_step = if catches.is_empty() { None } else { @@ -885,6 +950,7 @@ pub fn transform_generator_function_with_extra_captures( captures_new_target, enclosing_class.clone(), func.is_strict, + &release_ids, ); for s in wrapper_stmts { new_body.push(s); diff --git a/crates/perry-transform/src/generator/lower/async_step.rs b/crates/perry-transform/src/generator/lower/async_step.rs index bbe9597ef9..cfbd5a10b6 100644 --- a/crates/perry-transform/src/generator/lower/async_step.rs +++ b/crates/perry-transform/src/generator/lower/async_step.rs @@ -4,6 +4,7 @@ //! `lower.rs`. use super::*; +use crate::generator::build_box_release_stmts; pub(crate) fn build_async_throw_body_direct( catches: Vec, @@ -87,6 +88,11 @@ pub fn build_async_step_driver_direct( captures_new_target: bool, enclosing_class: Option, is_strict: bool, + // #7933: boxed body locals of this activation that no closure can observe. + // Released (`js_box_set(cell, undefined)`) at the step machine's terminal + // states so a completed activation stops retaining its locals through the + // never-freed `BOX_REGISTRY`. See `generator/box_release.rs`. + release_ids: &[LocalId], ) -> Vec { // When `throw_closure_expr` is None, the function had no awaiting // try/catch so the throw path is a plain rethrow — we inline it @@ -250,6 +256,41 @@ pub fn build_async_step_driver_direct( }), }; + // #7933: the two terminal states of a plain-async activation. Everything + // else in this body either suspends (`AsyncStepChain`) or re-enters the + // step (`__step_self(e, true)`), and reaches one of these two later. + // + // 1. `IterResultGetDone` — the body ran to a `return` (which also set + // `__gen_done`), so the activation resolves. + // 2. the catch arm's `isError` branch — an exception escaped the body + // while already in the error re-entry, i.e. no user `catch` handled + // it, so the activation rejects. + // + // A resume that still arrives afterwards is harmless: it writes `__gen_sent` + // before reading it, then short-circuits on the un-released `__gen_done` and + // re-runs these (idempotent) stores. + let reject_arm: Vec = { + let mut stmts = build_box_release_stmts(release_ids); + stmts.push(Stmt::Return(Some(promise_reject(Expr::LocalGet( + catch_e_id, + ))))); + stmts + }; + let resolve_arm: Vec = { + let mut stmts = build_box_release_stmts(release_ids); + // Optimized: AsyncStepDone reuses INLINE_TRAP_NEXT instead of + // allocating a fresh `Promise.resolve(value)` Promise. Saves one + // js_promise_resolved alloc per async function call (50k/run on + // promise_all_chains). The return value already lives in the + // iter-result TLS slot, and a box release neither allocates nor + // collects, so ordering the releases first cannot disturb it. + stmts.push(Stmt::Return(Some(Expr::AsyncStepDone { + value: Box::new(Expr::IterResultGetValue), + step_closure: Box::new(Expr::LocalGet(step_self_id)), + }))); + stmts + }; + let step_body: Vec = vec![ Stmt::Let { id: step_self_id, @@ -265,9 +306,7 @@ pub fn build_async_step_driver_direct( body: vec![ Stmt::If { condition: Expr::LocalGet(is_error_param_id), - then_branch: vec![Stmt::Return(Some(promise_reject(Expr::LocalGet( - catch_e_id, - ))))], + then_branch: reject_arm, else_branch: None, }, // Use the step closure captured at entry so nested @@ -285,14 +324,7 @@ pub fn build_async_step_driver_direct( }, Stmt::If { condition: Expr::IterResultGetDone, - // Optimized: AsyncStepDone reuses INLINE_TRAP_NEXT instead - // of allocating a fresh `Promise.resolve(value)` Promise. - // Saves one js_promise_resolved alloc per async function - // call (50k/run on promise_all_chains). - then_branch: vec![Stmt::Return(Some(Expr::AsyncStepDone { - value: Box::new(Expr::IterResultGetValue), - step_closure: Box::new(Expr::LocalGet(step_self_id)), - }))], + then_branch: resolve_arm, else_branch: None, }, Stmt::Return(Some(Expr::AsyncStepChain { diff --git a/crates/perry-transform/src/generator/mod.rs b/crates/perry-transform/src/generator/mod.rs index df00df9833..479b567c71 100644 --- a/crates/perry-transform/src/generator/mod.rs +++ b/crates/perry-transform/src/generator/mod.rs @@ -11,6 +11,7 @@ use perry_hir::ir::*; use perry_hir::types::{FuncId, LocalId, Type}; +mod box_release; mod break_continue; mod helpers; mod hoist_yields; @@ -24,6 +25,7 @@ mod rewrite_returns; // Explicit named re-exports so siblings can reach each other via // `use super::*;`. Globs don't propagate transitively, so spell every // cross-module symbol here. +pub(crate) use box_release::{build_box_release_stmts, closure_visible_ids}; pub(crate) use break_continue::{ body_contains_yield, collect_hoisted_vars, fix_break_continue_sentinels, fix_break_continue_sentinels_in_catches, fix_break_continue_sentinels_in_stmts, From 10f84d17412ad567efd591dccfaf45a82b000a51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 12 Aug 2026 10:52:20 +0200 Subject: [PATCH 2/7] test(transform): cover async box release, positive and negative Claude-Session: https://claude.ai/code/session_012B8z92S82sCfqCrVqrFgS2 --- .../src/generator/box_release.rs | 196 ++++++++++++++++++ 1 file changed, 196 insertions(+) diff --git a/crates/perry-transform/src/generator/box_release.rs b/crates/perry-transform/src/generator/box_release.rs index a806ef5d73..7f7afc4d58 100644 --- a/crates/perry-transform/src/generator/box_release.rs +++ b/crates/perry-transform/src/generator/box_release.rs @@ -320,6 +320,202 @@ mod tests { ); } + // ── End-to-end: the transform actually emits (and withholds) the stores ── + + fn async_module(body: Vec) -> Module { + let f = Function { + id: 1, + name: "f".to_string(), + type_params: Vec::new(), + params: Vec::new(), + return_type: perry_hir::types::Type::Any, + body, + is_strict: true, + is_async: true, + is_generator: false, + is_exported: false, + captures: Vec::new(), + decorators: Vec::new(), + was_plain_async: false, + was_unrolled: false, + }; + let mut m = Module::new("t"); + m.functions.push(f); + m + } + + fn run_async_pipeline(m: &mut Module) { + crate::async_to_generator::transform_async_to_generator(m); + crate::generator::transform_generators(m); + } + + /// Count `LocalSet(id, undefined)` statements anywhere in a body, including + /// inside closures (the release stores live in the step closure). + fn count_release_stores(stmts: &[Stmt], id: LocalId) -> usize { + let mut n = 0; + fn walk_stmts(stmts: &[Stmt], id: LocalId, n: &mut usize) { + for s in stmts { + match s { + Stmt::Expr(Expr::LocalSet(sid, value)) + if *sid == id && matches!(**value, Expr::Undefined) => + { + *n += 1; + } + _ => {} + } + let mut sub: Vec<&Expr> = Vec::new(); + collect_stmt_exprs(s, &mut sub); + for e in sub { + walk_expr(e, id, n); + } + for body in stmt_child_bodies(s) { + walk_stmts(body, id, n); + } + } + } + fn walk_expr(e: &Expr, id: LocalId, n: &mut usize) { + if let Expr::Closure { body, .. } = e { + walk_stmts(body, id, n); + } + perry_hir::walker::walk_expr_children(e, &mut |c| walk_expr(c, id, n)); + } + fn collect_stmt_exprs<'a>(s: &'a Stmt, out: &mut Vec<&'a Expr>) { + match s { + Stmt::Let { init: Some(e), .. } + | Stmt::Expr(e) + | Stmt::Throw(e) + | Stmt::Return(Some(e)) => out.push(e), + Stmt::If { condition, .. } => out.push(condition), + Stmt::While { condition, .. } | Stmt::DoWhile { condition, .. } => { + out.push(condition) + } + Stmt::For { + condition, update, .. + } => { + if let Some(c) = condition { + out.push(c); + } + if let Some(u) = update { + out.push(u); + } + } + Stmt::Switch { discriminant, .. } => out.push(discriminant), + _ => {} + } + } + fn stmt_child_bodies(s: &Stmt) -> Vec<&[Stmt]> { + match s { + Stmt::If { + then_branch, + else_branch, + .. + } => { + let mut v: Vec<&[Stmt]> = vec![then_branch.as_slice()]; + if let Some(eb) = else_branch { + v.push(eb.as_slice()); + } + v + } + Stmt::While { body, .. } | Stmt::DoWhile { body, .. } | Stmt::For { body, .. } => { + vec![body.as_slice()] + } + Stmt::Try { + body, + catch, + finally, + } => { + let mut v: Vec<&[Stmt]> = vec![body.as_slice()]; + if let Some(c) = catch { + v.push(c.body.as_slice()); + } + if let Some(f) = finally { + v.push(f.as_slice()); + } + v + } + Stmt::Switch { cases, .. } => cases.iter().map(|c| c.body.as_slice()).collect(), + Stmt::Labeled { body, .. } => vec![std::slice::from_ref(body.as_ref())], + _ => Vec::new(), + } + } + walk_stmts(stmts, id, &mut n); + n + } + + fn awaited_let(id: LocalId) -> Stmt { + Stmt::Let { + id, + name: "v".into(), + ty: perry_hir::types::Type::Any, + mutable: false, + init: Some(Expr::Await(Box::new(Expr::Integer(1)))), + } + } + + /// The positive case: a body local that survives an `await` is boxed, no + /// closure can see it, so the terminal states must release it. Two stores — + /// one on the resolve arm, one on the reject arm. + #[test] + fn a_confined_body_local_is_released_at_the_terminal_states() { + let mut m = async_module(vec![awaited_let(50), Stmt::Return(Some(local_get(50)))]); + run_async_pipeline(&mut m); + assert_eq!( + count_release_stores(&m.functions[0].body, 50), + 2, + "expected a release on each terminal arm:\n{:#?}", + m.functions[0].body + ); + } + + /// The negative case that makes this safe: the same local, but a closure + /// escapes with it. Releasing it would be a silent use-after-clear, so the + /// transform must emit no store at all. + #[test] + fn a_body_local_a_closure_can_see_is_never_released() { + let escaping = closure(vec![Stmt::Return(Some(local_get(50)))], Vec::new()); + let mut m = async_module(vec![awaited_let(50), Stmt::Return(Some(escaping))]); + run_async_pipeline(&mut m); + assert_eq!( + count_release_stores(&m.functions[0].body, 50), + 0, + "a closure-visible local must never be released:\n{:#?}", + m.functions[0].body + ); + } + + /// `__gen_sent` (the value the last `await` delivered) is released too, and + /// the control locals are not: an `undefined` `__gen_done` would drop a late + /// resume into the dispatch loop with no matching state. + #[test] + fn the_state_machine_control_locals_are_not_released() { + let mut m = async_module(vec![awaited_let(50), Stmt::Return(Some(local_get(50)))]); + run_async_pipeline(&mut m); + let body = &m.functions[0].body; + // `PreallocateBoxes` lists the activation's cells; ids 0..=3 of the + // transform's own allocation are state/done/sent/executing. Find the + // prealloc list and assert at most one of its transform-internal ids is + // released (that one is `__gen_sent`). + let prealloc: Vec = body + .iter() + .find_map(|s| match s { + Stmt::PreallocateBoxes(ids) => Some(ids.clone()), + _ => None, + }) + .expect("the activation preallocates its boxes"); + let released: Vec = prealloc + .iter() + .copied() + .filter(|id| count_release_stores(body, *id) > 0) + .collect(); + // Exactly the user local (50) and `__gen_sent`. + assert_eq!( + released.len(), + 2, + "released set should be {{user local, __gen_sent}}, got {released:?} of {prealloc:?}" + ); + assert!(released.contains(&50), "{released:?}"); + } + #[test] fn release_stmts_are_undefined_stores() { let stmts = build_box_release_stmts(&[3, 5]); From d17313090283c2c3a4f44fe07a5b22ef815350f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 12 Aug 2026 10:59:45 +0200 Subject: [PATCH 3/7] style: cargo fmt Claude-Session: https://claude.ai/code/session_012B8z92S82sCfqCrVqrFgS2 --- .../src/generator/box_release.rs | 5 ++++- crates/perry-transform/src/generator/lower.rs | 19 ++++++++++--------- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/crates/perry-transform/src/generator/box_release.rs b/crates/perry-transform/src/generator/box_release.rs index 7f7afc4d58..15cffb3de6 100644 --- a/crates/perry-transform/src/generator/box_release.rs +++ b/crates/perry-transform/src/generator/box_release.rs @@ -258,7 +258,10 @@ mod tests { let inner = vec![Stmt::Return(Some(local_get(7)))]; let body = vec![Stmt::Return(Some(closure(inner, Vec::new())))]; let ids = closure_visible_ids(&body).expect("not poisoned"); - assert!(ids.contains(&7), "auto-detected capture must escape: {ids:?}"); + assert!( + ids.contains(&7), + "auto-detected capture must escape: {ids:?}" + ); } /// An explicit capture list entry counts even if the body never mentions it. diff --git a/crates/perry-transform/src/generator/lower.rs b/crates/perry-transform/src/generator/lower.rs index 2316ceec06..a041c6cd9e 100644 --- a/crates/perry-transform/src/generator/lower.rs +++ b/crates/perry-transform/src/generator/lower.rs @@ -889,15 +889,16 @@ pub fn transform_generator_function_with_extra_captures( // closure-visible is kept. let release_ids: Vec = { let post_visible = closure_visible_ids(&next_resume_body).and_then(|mut ids| { - let routes_ok = catches.iter().all(|route| { - match closure_visible_ids(&route.body) { - Some(r) => { - ids.extend(r); - true - } - None => false, - } - }); + let routes_ok = + catches + .iter() + .all(|route| match closure_visible_ids(&route.body) { + Some(r) => { + ids.extend(r); + true + } + None => false, + }); if routes_ok { Some(ids) } else { From b6cf3347d3ed8d150a88b9e71bdcf77a8669ed1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 12 Aug 2026 11:06:11 +0200 Subject: [PATCH 4/7] test: escaping-closure regression file for the async box release Claude-Session: https://claude.ai/code/session_012B8z92S82sCfqCrVqrFgS2 --- ...st_issue_7933_async_box_release_escapes.ts | 148 ++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 test-files/test_issue_7933_async_box_release_escapes.ts diff --git a/test-files/test_issue_7933_async_box_release_escapes.ts b/test-files/test_issue_7933_async_box_release_escapes.ts new file mode 100644 index 0000000000..917ff196d4 --- /dev/null +++ b/test-files/test_issue_7933_async_box_release_escapes.ts @@ -0,0 +1,148 @@ +// #7933: a completed async activation releases (clears) its boxed body +// locals, but ONLY the ones no closure can observe. Every shape below keeps a +// closure alive past the activation that declared its local — if the release +// analysis widened to cover any of them, the reads would come back +// `undefined` and this file's output would change. +// +// Expected (node): 61 6 7 202 L:x 9 15 18 30 61 L:x + +// 1. A closure returned from an async fn reads a local declared before the await. +async function returnsClosure(n: number): Promise<() => number> { + const base = n * 10; + await tick(); + const bump = base + 1; + return () => base + bump; +} + +// 2. A closure that WRITES a local declared before the await (mutable capture). +async function returnsWriter(n: number): Promise<() => number> { + let acc = n; + await tick(); + return () => { + acc = acc + 1; + return acc; + }; +} + +// 3. A closure created BEFORE the await and returned after it. +async function closureBeforeAwait(n: number): Promise<() => number> { + const seed = n + 100; + const f = () => seed * 2; + await tick(); + return f; +} + +// 4. A closure stored into an object that outlives the activation. +type Holder = { get: () => string }; +async function storesClosure(tag: string): Promise { + const label = `L:${tag}`; + await tick(); + const h: Holder = { get: () => label }; + return h; +} + +// 5. A closure in a nested async arrow reads the OUTER async fn's local. +async function nestedAsyncClosure(n: number): Promise<() => number> { + const outer = n + 7; + await tick(); + const inner = async () => { + await tick(); + return outer; + }; + await inner(); + return () => outer; +} + +// 6. Locals captured by a closure created inside a try, after the await, with +// the activation completing through the catch arm. +async function throwPathClosure(n: number): Promise<() => number> { + let kept = n; + try { + await tick(); + kept = kept + 1; + throw new Error("boom"); + } catch (e) { + kept = kept + 10; + } + await tick(); + return () => kept; +} + +// 7. A closure escaping through an array that outlives the activation. +async function closuresInLoop(n: number): Promise number>> { + const out: Array<() => number> = []; + for (let i = 0; i < n; i++) { + const each = i * 3; + await tick(); + out.push(() => each); + } + return out; +} + +// 8. Rejection path: the local is read by a closure carried on the thrown +// value. (A plain object, not an Error subclass — attaching a closure +// property to an `Error` is a separate, pre-existing Perry gap.) +type Thrown = { peek: () => number }; +async function rejectsWithClosure(n: number): Promise { + const secret = n * 5; + await tick(); + const err: Thrown = { peek: () => secret }; + throw err; +} + +function tick(): Promise { + return new Promise((resolve) => { + resolve(0); + }); +} + + +async function main(): Promise { + const parts: string[] = []; + + const c1 = await returnsClosure(3); + parts.push(String(c1())); + + const c2 = await returnsWriter(5); + parts.push(String(c2())); + parts.push(String(c2())); + + const c3 = await closureBeforeAwait(1); + parts.push(String(c3())); + + const h = await storesClosure("x"); + parts.push(h.get()); + + const c5 = await nestedAsyncClosure(2); + parts.push(String(c5())); + + const c6 = await throwPathClosure(4); + parts.push(String(c6())); + + const arr = await closuresInLoop(4); + let sum = 0; + for (let i = 0; i < arr.length; i++) sum = sum + arr[i](); + parts.push(String(sum)); + + try { + await rejectsWithClosure(6); + parts.push("NOTHROWN"); + } catch (e) { + const err = e as Thrown; + parts.push(String(err.peek())); + } + + // Force GC pressure so any released-but-still-reachable value would have + // been collected (or would read `undefined`) by the time we print. + const churn: number[][] = []; + for (let i = 0; i < 20000; i++) { + churn.push([i, i + 1, i + 2]); + if (churn.length > 100) churn.length = 0; + } + parts.push(String(c1())); + parts.push(h.get()); + + console.log(parts.join(" ")); +} + +main(); From 08300c521538b1b254140d69dbc81d3c861b990d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 12 Aug 2026 11:26:00 +0200 Subject: [PATCH 5/7] docs: changelog fragment for #7939 Claude-Session: https://claude.ai/code/session_012B8z92S82sCfqCrVqrFgS2 --- changelog.d/7939-async-box-release.md | 54 +++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 changelog.d/7939-async-box-release.md diff --git a/changelog.d/7939-async-box-release.md b/changelog.d/7939-async-box-release.md new file mode 100644 index 0000000000..edd30491de --- /dev/null +++ b/changelog.d/7939-async-box-release.md @@ -0,0 +1,54 @@ +### Fixed + +- **A completed `async` activation no longer retains its body locals forever (#7933).** + The async-to-generator transform boxes every body local of an `async` function + into a `js_box_alloc_bits` cell, and the runtime's `BOX_REGISTRY` is + **monotonic by design** — cells are never freed, because perry#4898's + pointer rejection and #7906's positive pointer cache both rest on "a + registered address can never become unregistered". `scan_box_roots_mut` marks + the JSValue inside every registered cell on every collection, and nothing ever + cleared one, so **every local of every activation the program had ever run + stayed a live GC root**. + + The state machine now **clears** (never frees) the cells at its terminal + states, which keeps every address registered and readable — a stale reader + sees `undefined`, already the defined value of an uninitialised boxed local + (perry#4926). + + Terminal states of the `was_plain_async` step driver, and there are exactly + two: the `IterResultGetDone` resolve arm (the body ran to a `return`, which + `prepend_done_before_returns` pairs with `__gen_done = true`) and the catch + arm's `isError` branch (an exception escaped with no user `catch` to take it, + so the activation rejects). Everything else either suspends + (`AsyncStepChain`) or re-enters the step (`__step_self(e, true)`) and reaches + one of those two later. A resume that still arrives after the terminal is + harmless: it writes `__gen_sent` before reading it, then short-circuits on + `__gen_done` — which is deliberately **not** cleared, since an `undefined` + there would drop it into the dispatch loop with no matching state. + + Clearing a cell whose value is still reachable would be a *silent* wrong + answer, not a crash, so a cell is cleared only when no closure can hold its + address. A box address is never a JS value — it leaves the activation solely + through a closure capture slot, which codegen fills for exactly the ids in + `compute_auto_captures(closure) ∩ boxed_vars`. The new + `generator/box_release.rs` computes a **superset** of that set (explicit + `captures` *and* `mutable_captures`, plus + `perry_hir::analysis::collect_local_refs_expr` over the whole closure, + descending into nested closures), so an id it misses is an id codegen's own + free-variable walk also misses — no capture slot exists for it and clearing is + unobservable. Sloppy-mode `with` is the one construct that breaks the + argument (`Expr::WithGet`/`WithSet` carry a fallback `LocalId` as a leaf field + the shared walk does not report), and poisons the analysis outright: nothing + is released for such a body. + + Async generators and sync generators are deliberately untouched — their + `{next, return, throw}` object is user-visible, so "done" is not the end of + observability. + + **Effect on `asyncpipe` at 240 batches** (output byte-identical, exit 0): + young survival at the first copying minor **770 ‰ → 24 ‰**, objects moved + **172 387 → 6 658**, `freed_bytes` **4.09 MB → 17.38 MB**, and the second + minor — 201 822 objects *promoted* into old-gen — **no longer happens at + all**. Instructions retired −31.9 %, peak RSS 94.5 MB → 57.8 MB. At 480 + batches the three minors go 770/943/766 ‰ → 24/9/14 ‰, instructions retired + −45.5 %, peak RSS 189.0 MB → 91.1 MB. From 5b1cca5bd2312881b2ef91da57635b6ed8a97c44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 12 Aug 2026 11:32:39 +0200 Subject: [PATCH 6/7] docs: correct an RSS figure in the changelog fragment Claude-Session: https://claude.ai/code/session_012B8z92S82sCfqCrVqrFgS2 --- changelog.d/7939-async-box-release.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.d/7939-async-box-release.md b/changelog.d/7939-async-box-release.md index edd30491de..468483fbd2 100644 --- a/changelog.d/7939-async-box-release.md +++ b/changelog.d/7939-async-box-release.md @@ -51,4 +51,4 @@ minor — 201 822 objects *promoted* into old-gen — **no longer happens at all**. Instructions retired −31.9 %, peak RSS 94.5 MB → 57.8 MB. At 480 batches the three minors go 770/943/766 ‰ → 24/9/14 ‰, instructions retired - −45.5 %, peak RSS 189.0 MB → 91.1 MB. + −45.5 %, peak RSS 189.1 MB → 91.1 MB. From 235bd95f6748b98df166263db5a7c7e16d3e9fa5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 12 Aug 2026 11:37:39 +0200 Subject: [PATCH 7/7] docs: note that the closure-visibility scan feeds only the plain-async arm Claude-Session: https://claude.ai/code/session_012B8z92S82sCfqCrVqrFgS2 --- crates/perry-transform/src/generator/lower.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/perry-transform/src/generator/lower.rs b/crates/perry-transform/src/generator/lower.rs index a041c6cd9e..f16d65a972 100644 --- a/crates/perry-transform/src/generator/lower.rs +++ b/crates/perry-transform/src/generator/lower.rs @@ -325,6 +325,9 @@ pub fn transform_generator_function_with_extra_captures( // means the analysis is unusable (sloppy `with`) and nothing may be // released. See `box_release.rs` for why closure visibility is the exact // condition. + // Consumed only by the `was_plain_async` arm below — a generator's + // `{next, return, throw}` object is user-visible, so "done" is not the end + // of observability there and nothing is ever released. let closure_visible_before: Option> = { closure_visible_ids(&func.body).and_then(|mut ids| { closure_visible_ids(¶m_prologue).map(|p| {