Skip to content

perf(transform): release a completed async activation's boxed locals (#7933) - #7939

Merged
proggeramlug merged 7 commits into
mainfrom
gc/7933-async-box-release
Aug 12, 2026
Merged

perf(transform): release a completed async activation's boxed locals (#7933)#7939
proggeramlug merged 7 commits into
mainfrom
gc/7933-async-box-release

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Closes #7933.

What was wrong

The async-to-generator transform boxes every body local of every async
function
into a js_box_alloc_bits cell (Stmt::PreallocateBoxes, one cell
per local per invocation) so the synthesized state-machine closures can share
them across suspends. 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"
— and scan_box_roots_mut marks the JSValue inside every
registered cell on every collection.

Nothing ever cleared one. So every local of every activation the program had
ever run stayed a live GC root for the life of the process.
On asyncpipe
that is the whole Req/Ok/note graph of every batch the program has already
discarded.

What this does

Clear (never free) an activation's cells at the state machine's terminal
states. A cleared cell stays registered and readable; a stale reader sees
undefined, which is already the defined value of an uninitialised boxed local
(perry#4926). BOX_REGISTRY monotonicity, perry#4898 and #7906 are untouched.

The exit paths, and how each is covered

build_async_step_driver_direct has exactly two terminal return sites:

exit terminal? covered by
normal return (incl. implicit) yes the IterResultGetDone resolve arm — and prepend_done_before_returns already pairs every return with __gen_done = true, so the two predicates agree
throw with no user catch yes the catch arm's isError branch (Promise.reject)
throw from a user catch route yes same reject arm
throw caught by a user try/catch in the async fn no — the direct catch routes resume the machine, which later reaches one of the two terminals
await suspension no AsyncStepChain
error re-entry __step_self(e, true) no — it is in return position, so the caller does nothing after the callee reaches a terminal
activation never resumes (await on a promise that never settles) no correct: a suspended activation's locals are live

A resume that still arrives after a terminal is harmless: it writes
__gen_sent before reading it, then short-circuits on __gen_done — which is
deliberately not released, since an undefined there would drop it into the
dispatch loop with no matching state. __gen_state, __gen_executing and the
pending-completion record are held back for the same reason.

Async generators and sync generators are deliberately untouched. Their
{next, return, throw} object is user-visible, so "done" is not the end of
observability (gen.return() can still re-run a pending finally that reads
the locals).

Why releasing is safe — and where it stops

Clearing a cell whose value is still reachable is a silent wrong answer, not a
crash, so a cell is released only when no closure can hold its address.

A box address is never a JS value — LocalGet/LocalSet on a boxed local lower
to js_box_get/js_box_set — so it leaves the activation solely through a
closure capture slot, which codegen fills 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).

New generator/box_release.rs::closure_visible_ids computes a superset of
that: captures ∪ mutable_captures ∪ perry_hir::analysis::collect_local_refs_expr(closure) (descending 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 releasing its cell is
unobservable. The argument does not need my walker to be complete, only no less
complete than the one codegen already trusts for the same decision.

Sloppy-mode with is the single construct that breaks it: Expr::WithGet /
Expr::WithSet carry a fallback LocalId as a leaf field that
collect_local_refs_expr does not report. Either one anywhere in the body
poisons the analysis and nothing is released.

The scan runs twice and unions — over the original body and over the
post-linearization resume body + catch routes.

Effect

Base and fix are both built from b8c1f103e (the branch point). main has
moved 5 commits since — including two runtime changes (#7928, #7935) that
shift GC numbers — so the absolute figures below belong to that commit; the
A/B is same-commit, which is what the deltas rest on.

asyncpipe at 240 batches (the shipped corpus program is at 120, which
#7926 shows sits on a trigger cliff; 240 and 480 are both used here). Same
compiler flags, same runtime dir per arm, output byte-identical to the
baseline's and exit 0 in every cell.

ap_240 base fix
copying minors 2 1
minor #1 young_survival_permille 770 24
minor #1 copied_objects 172 387 6 658 (−96.1 %)
minor #1 freed_bytes 4 094 768 17 382 128 (4.2×)
minor #2 201 822 promoted never happens
eden_live_bytes @ minor #1 13 732 456 445 096
instructions retired (best of 5) 3 585.3 M 2 443.0 M (−31.9 %)
peak RSS 94.5 MB 57.8 MB (−38.8 %)
ap_480 base fix
copying minors 3 3
survival ‰ (m1 / m2 / m3) 770 / 943 / 766 24 / 9 / 14
objects moved (m1 / m2 / m3) 172 387 c / 201 822 p / 452 237 p 6 658 c / 6 140 c / 5 447
instructions retired (best of 5) 10 014.4 M 5 461.9 M (−45.5 %)
peak RSS 189.1 MB 91.1 MB (−51.8 %)

★ Read the adjacent counters: copied_objects is structurally zero on a
promoting cycle, so both copied_objects and promoted_objects are quoted.
Instructions retired is quoted as a load-independent cost proxy, not as a
speedup — the dev box ran at load 25–90 throughout.

The measured live young set (~6 600 objects) matches the unsound
PERRY_PROBE_BOX_NOMARK probe in #7933 to within noise — but here the program
exits 0 with the correct output, where the probe SIGABRTed.

Validation

  • 19/19 corpus programs byte-identical to their expected output, exit 0, on
    both arms (gc-handoff/boxclear/build_corpus.sh). Only asyncpipe in that
    corpus contains an async function, so the other 18 are a no-regression
    check.
  • New test-files/test_issue_7933_async_box_release_escapes.ts — eight
    shapes where a closure outlives the activation that declared its local
    (returned closure, mutating closure, closure created before the await,
    closure stored in an escaping object, closure in a nested async arrow,
    closure created on a catch path, closures pushed from a loop, and a closure
    carried on a rejected activation's thrown value). Byte-identical to node on
    both arms.
  • A scaled variant of the same file (3 000 rounds, closures kept alive across
    many later collections) is byte-identical to node on both arms and runs a
    real copying minor
    — 391 ‰ → 135 ‰ survival, so the release fires there too.
  • PERRY_GC_PROTECT_FROMSPACE=1 PERRY_GC_PROTECT_FROMSPACE_DEPTH=800 on the
    scaled probe: exit 0, correct output, and the instrument was live
    [gc-fromspace-protect] mode=ProtectPages retired_set=#0 blocks=18 bytes_protected=18874368.
  • PERRY_GC_VERIFY_EVACUATION=1 on ap_240 and the scaled probe: exit 0,
    correct output.
  • Full A/B of every test-files/*.ts containing async/await — 213 files,
    two compilers, byte-comparing stdout, stderr and exit code.

    ---- ran=213 diffs=8 basebuildfail=10 fixbuildfail=10 ----, and all 8
    flagged diffs are the OS thread id inside an identical panic message
    on
    pre-existing network/server failures that abort on both arms
    (perry-ext-http/src/server/server.rs:911, perry-ext-axios/src/lib.rs:94,
    tokio/net/addr.rs:182). Normalising the thread id leaves 0 real diffs;
    stdout was already identical in all 8. The 10 build failures are the same 10
    files on both arms
    (base-only [], fix-only []).
  • cargo test -p perry-transform: 83 + 10 passed, 0 failed.
  • cargo fmt --all -- --check, scripts/check_file_size.sh.

New unit tests

generator/box_release.rs carries both directions, including two end-to-end
tests that drive the real transform_async_to_generator +
transform_generators pipeline:

  • a_confined_body_local_is_released_at_the_terminal_states — two release
    stores, one per terminal arm.
  • a_body_local_a_closure_can_see_is_never_released — the same local, but a
    closure escapes with it: zero stores.
  • the_state_machine_control_locals_are_not_released.
  • plus the analysis' own positive/negative/transitive/with-poison cases.

Summary by CodeRabbit

  • Bug Fixes

    • Improved memory cleanup for completed asynchronous operations by releasing temporary local values at both successful and failed terminal states.
    • Preserved values still needed by closures, nested scopes, control flow, and error handling.
    • Safely disables cleanup when local references cannot be determined.
  • Tests

    • Added regression coverage for closures, mutable captures, escaped objects and arrays, nested asynchronous functions, and completion or rejection paths.

Ralph Küpper added 5 commits August 12, 2026 10:46
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
@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The async-to-generator transform now analyzes closure-visible locals, clears safe boxed locals at terminal completion and rejection, retains registry addresses and control cells, and validates closure escapes, terminal paths, and emitted stores.

Changes

Async activation box release

Layer / File(s) Summary
Closure visibility and release statement analysis
crates/perry-transform/src/generator/box_release.rs
The transform recursively collects closure-visible locals, disables release for unsafe with analysis, and emits LocalSet statements that assign undefined. Unit tests cover captures, nested control flow, poisoning, and emitted stores.
Terminal release wiring and validation
crates/perry-transform/src/generator/mod.rs, crates/perry-transform/src/generator/lower.rs, crates/perry-transform/src/generator/lower/async_step.rs, crates/perry-transform/src/generator/box_release.rs, test-files/test_issue_7933_async_box_release_escapes.ts, changelog.d/7939-async-box-release.md
Plain async lowering computes release candidates from pre- and post-linearization visibility. The direct async-step driver clears candidates before resolution and rejection while retaining control locals. Tests cover escaped closures, terminal paths, GC pressure, and sent handling. The changelog records the behavior and benchmarks.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant AsyncFunction
  participant AsyncLowering
  participant AsyncStepDriver
  participant BoxRelease
  participant Promise
  AsyncFunction->>AsyncLowering: lower generator body
  AsyncLowering->>BoxRelease: analyze closure-visible locals
  BoxRelease-->>AsyncLowering: release_ids
  AsyncLowering->>AsyncStepDriver: pass release_ids
  AsyncStepDriver->>BoxRelease: build release statements at terminal state
  BoxRelease-->>AsyncStepDriver: clear boxed locals with undefined
  AsyncStepDriver->>Promise: resolve or reject
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 60.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the performance change: releasing boxed locals from completed async activations.
Description check ✅ Passed The description is detailed and covers the problem, implementation, related issue, tests, validation results, and performance impact.
Linked Issues check ✅ Passed The changes address issue #7933 by clearing completed async activation boxes safely at normal-return and rejection terminal states.
Out of Scope Changes check ✅ Passed The implementation, tests, and changelog entry are directly related to async activation box release and the linked issue objectives.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch gc/7933-async-box-release

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@proggeramlug
proggeramlug marked this pull request as ready for review August 12, 2026 09:49
@proggeramlug
proggeramlug merged commit 8039fa7 into main Aug 12, 2026
0 of 19 checks passed
@proggeramlug
proggeramlug deleted the gc/7933-async-box-release branch August 12, 2026 09:51

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@changelog.d/7939-async-box-release.md`:
- Around line 18-22: Rewrite the sentence beginning with “Terminal states of the
`was_plain_async` step driver” in the changelog so it has a complete main clause
while preserving the explanation that exactly two terminal states exist: the
`IterResultGetDone` resolve arm and the catch arm’s `isError` branch.

In `@crates/perry-transform/src/generator/box_release.rs`:
- Around line 177-196: The closure branch in scan_expr must continue walking
closure parameter defaults after scanning the body. Remove the early return
after scan_stmts(body, scan), allowing the subsequent walk_expr_children call to
process defaults while preserving the existing capture and body scanning.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 4b18fa6c-5f98-4b85-aafa-1da0719c2047

📥 Commits

Reviewing files that changed from the base of the PR and between 8260a9e and 235bd95.

📒 Files selected for processing (6)
  • changelog.d/7939-async-box-release.md
  • crates/perry-transform/src/generator/box_release.rs
  • crates/perry-transform/src/generator/lower.rs
  • crates/perry-transform/src/generator/lower/async_step.rs
  • crates/perry-transform/src/generator/mod.rs
  • test-files/test_issue_7933_async_box_release_escapes.ts

Comment on lines +18 to +22
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Fix the sentence fragment.

Line 18 starts "Terminal states of the was_plain_async step driver, and there are exactly two:" — the clause has no main verb. This text appears in the assembled release notes.

✏️ Proposed fix
-  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
+  The `was_plain_async` step driver has exactly two terminal states: 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).
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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
The `was_plain_async` step driver has exactly two terminal states: 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
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@changelog.d/7939-async-box-release.md` around lines 18 - 22, Rewrite the
sentence beginning with “Terminal states of the `was_plain_async` step driver”
in the changelog so it has a complete main clause while preserving the
explanation that exactly two terminal states exist: the `IterResultGetDone`
resolve arm and the catch arm’s `isError` branch.

Comment on lines +177 to +196
Expr::Closure {
body,
captures,
mutable_captures,
..
} => {
scan.out.extend(captures.iter().copied());
scan.out.extend(mutable_captures.iter().copied());
let mut refs: Vec<LocalId> = Vec::new();
let mut visited: HashSet<usize> = 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));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Locate the walker and inspect the Closure arm of walk_expr_children.
fd -t f 'walker.rs' crates/perry-hir/src | while IFS= read -r f; do
  echo "== $f"
  rg -n -C 12 'Expr::Closure' "$f"
done
# Confirm whether collect_local_refs_expr descends into closure param defaults.
rg -n -C 20 'fn collect_local_refs_expr' crates/perry-hir/src

Repository: PerryTS/perry

Length of output: 3310


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '== walker definitions =='
rg -n -C 25 'pub fn walk_expr_children|fn walk_expr_children|Expr::Closure' crates/perry-hir/src
printf '%s\n' '== collect_local_refs_expr =='
sed -n '35,150p' crates/perry-hir/src/analysis.rs
printf '%s\n' '== box_release scan =='
sed -n '120,220p' crates/perry-transform/src/generator/box_release.rs
printf '%s\n' '== related closure walkers =='
rg -n -C 12 'walk_expr_children_mut|scan_expr|scan_stmts' crates/perry-transform/src/generator/per_iteration.rs crates/perry-transform/src/generator/box_release.rs

Repository: PerryTS/perry

Length of output: 50370


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '== walker files =='
git ls-files | rg '(^|/)walker(\.rs|/)|walk.*\.rs$'
printf '%s\n' '== walker function locations =='
rg -n '^(pub )?fn walk_expr_children|walk_expr_children_mut' crates/perry-hir/src --glob '*.rs' | head -80
printf '%s\n' '== closure walker implementation =='
file=$(rg -l '^(pub )?fn walk_expr_children' crates/perry-hir/src --glob '*.rs' | head -1)
echo "$file"
line=$(rg -n '^(pub )?fn walk_expr_children' "$file" | head -1 | cut -d: -f1)
start=$((line-10)); end=$((line+180))
sed -n "${start},${end}p" "$file"
printf '%s\n' '== collect_local_refs_expr complete relevant section =='
rg -n 'collect_local_refs_expr|Expr::Closure' crates/perry-hir/src/analysis.rs
printf '%s\n' '== box_release relevant section =='
rg -n -C 10 'Expr::Closure|fn scan_expr|fn scan_stmts' crates/perry-transform/src/generator/box_release.rs

Repository: PerryTS/perry

Length of output: 5175


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '== mutable walker =='
cat -n crates/perry-hir/src/walker/expr_mut.rs
printf '%s\n' '== read-only walker =='
cat -n crates/perry-hir/src/walker/expr_ref.rs
printf '%s\n' '== box_release scan functions =='
rg -n -C 16 'Expr::Closure|fn scan_expr|fn scan_stmts' crates/perry-transform/src/generator/box_release.rs
printf '%s\n' '== local-reference collector =='
sed -n '35,125p' crates/perry-hir/src/analysis.rs

Repository: PerryTS/perry

Length of output: 50370


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '== closure arms in walkers =='
rg -n -C 8 'Expr::Closure' crates/perry-hir/src/walker/expr_mut.rs crates/perry-hir/src/walker/expr_ref.rs
printf '%s\n' '== box_release closure scan =='
rg -n -C 20 'Expr::Closure|fn scan_expr|fn scan_stmts' crates/perry-transform/src/generator/box_release.rs
printf '%s\n' '== local-reference collector closure handling =='
rg -n -C 18 'Expr::Closure|pub fn collect_local_refs_expr' crates/perry-hir/src/analysis.rs

Repository: PerryTS/perry

Length of output: 20111


Do not return before walking closure parameter defaults.

walk_expr_children visits closure parameter defaults but not the body. The early return therefore misses WithGet/WithSet poisoning and explicit captures from nested closures in parameter defaults. Remove return and fall through after scan_stmts(body, scan).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-transform/src/generator/box_release.rs` around lines 177 - 196,
The closure branch in scan_expr must continue walking closure parameter defaults
after scanning the body. Remove the early return after scan_stmts(body, scan),
allowing the subsequent walk_expr_children call to process defaults while
preserving the existing capture and body scanning.

Source: Learnings

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

perf(codegen): async-to-generator boxes are never cleared — completed activations retain 96% of asyncpipe's young generation

1 participant