fix(transform): preserve per-iteration let/const bindings across the async state machine (#6345)#6353
Merged
Merged
Conversation
added 4 commits
July 13, 2026 10:14
…async state machine (#6345) The async/generator lowering hoisted every body Let into one activation-wide PreallocateBoxes frame, collapsing a loop's per-iteration let/const onto a single box: every closure made in the loop observed the last value. Only hoist bindings that must survive a suspend.
…#6345) A binding read after an await keeps its cross-state box, which is shared by every iteration. Copy it into a per-state local at each closure-creation site so each closure gets its own binding. Gated to captures HIR marks read-only (captures minus mutable_captures), with a rollback verifier.
Stmt::Labeled wraps the loop, so a bare Stmt::For match skipped every labeled loop and each_child_stmt_list unwrapped straight to its body — the loop statement itself was never analyzed.
Covers const/loop-var capture, await before/after the capture, while, do-while, for-of, for-in, for-await-of, nested loops, labeled loops, try/catch/finally, switch, array + destructured bindings, nested closures, sync generators, var (function-scoped, last value), closure-written and outer-written bindings, and a non-async control.
📝 WalkthroughWalkthroughThe async/generator transform now preserves per-iteration ChangesAsync per-iteration binding preservation
Estimated code review effort: 4 (Complex) | ~60 minutes Sequence Diagram(s)sequenceDiagram
participant AsyncGeneratorLowering
participant PerIterationAnalysis
participant SnapshotSuspendedCaptures
participant StateMachineLinearization
participant AsyncLoopTests
AsyncGeneratorLowering->>PerIterationAnalysis: collect_per_iteration_ids
AsyncGeneratorLowering->>SnapshotSuspendedCaptures: snapshot_suspended_loop_captures
SnapshotSuspendedCaptures-->>AsyncGeneratorLowering: protected local IDs
AsyncGeneratorLowering->>StateMachineLinearization: exclude protected IDs from hoisted rewrites
StateMachineLinearization-->>AsyncLoopTests: transformed async loop behavior
Possibly related issues
Suggested labels: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #6345.
Symptom
A
let/constdeclared in a loop and captured by a closure lost its per-iteration binding inside anasyncfunction — every closure saw the last iteration's value. Silent wrong answer, exit 0. The same code in a non-async function was correct.j = 0…j = 8mainj = 8×9It was not limited to the filed shape: every loop kind was affected —
while,do-while,for-of,for-in,for-await-of, nested loops, labeled loops, and direct capture of the loop variable (() => iprinted9nine times). Sync generators too, since they share the lowering.Root cause
generator::lowerhoists everyStmt::Letin the body into one activation-wide frame:collect_hoisted_varscollects all of them (recursing into loop bodies andfor-inits),Stmt::PreallocateBoxesallocates one box per id in the entry block — once per call,rewrite_hoisted_lets_in_stmtsdemotes eachStmt::Letto a bareExpr(LocalSet(id, init)).That is genuinely required for a binding whose live range crosses a suspend: the step closure returns at every
awaitand is re-entered later, so its plain locals do not survive — only the boxes it captured do.But it is wrong for a loop binding. The declaration is gone, so the box is never re-created; all N iterations share one cell, and every closure captures that one cell.
What the non-async path does that the async path lost: it simply leaves the
Stmt::Letinside the loop body. Codegen re-executes it every iteration, re-allocating its box when the binding needs one (boxed_vars.rs), and for a binding nothing ever writes it declines to box at all so the closure snapshot-captures the value at creation. Either route yields a distinct per-iteration binding. Hoisting destroys both signals at once — andPreallocateBoxesforce-marks the id boxed regardless of whatboxed_vars' own mutation analysis concluded.Worth noting the HIR was right all along: post-transform the user closure still carries
captures: [j], mutable_captures: [](i.e. by value). Codegen just materializes a capture fromboxed_varsalone (expr/closure.rs:let _ = mutable_captures;), so the forced box wins.Fix (
perry-transform, compiler-side only)New
generator/per_iteration.rs. Hoist only what must be hoisted.collect_per_iteration_ids— bindings whose live range provably stays inside one state keep their in-loopStmt::Let;lowersubtracts them from the hoisted set so they never reachPreallocateBoxes. A binding qualifies only when it is declared in a loop, is genuinely block-scoped, and is not read after a suspend that follows its declaration. Afor-init counter is loop-carried (condition/update re-read it after the body), so it qualifies only when the loop contains no suspend at all.snapshot_suspended_loop_captures— for a binding that really is read after anawait, the cross-state box must stay. The step closure's capture slots are fixed for its lifetime, so the box pointer cannot be swapped per iteration. Instead we give the closure the value rather than the cell: at the moment the closure is built the shared box holds the current iteration's value, so it is copied into a fresh local declared immediately before the closure — a local that lives and dies inside one state. Runs before linearization so theLetlands in the same state as its closure.Why this is safe
varstaysvar. HIR emits avaras a function-top pre-declarationLetplus an in-placeLetsharing the same id, sodecl_sites > 1identifies it; avarread outside the loop also tripstotal_refs > refs_inside_loop(the bug(codegen): var declared in a constant-bound (unrolled) loop loses its value when read after the loop #2308 test, whose exhaustive ref-counter is reused here rather than re-implemented). Both guards keep it hoisted: one function-scoped binding, closures see the last value — matching node, and matching what perry already did. (A "no references outside the loop" test alone would have silently brokenvar; the fixture pins this.)captures \ mutable_captures. HIR puts an id inmutable_captureswhen it is assigned anywhere — by a closure or by the enclosing scope (verified for both) — so a binding a closure writes (let acc = 0; const add = () => acc += 5) keeps its shared box untouched, and a binding the outer scope mutates after the capture (fns.push(() => z); z += 100) keeps sharing too. Afor-init counter is deliberately kept out ofmutable_capturesby HIR despitei++, which is exactly the per-iteration semantics we want.ArrayPush { array_id }&c.). Rather than trust a hand-rolled variant list, the rewrite is applied and then proved — with the same exhaustive counter thevaranalysis relies on — to have left no reference to the original id. If one survives, the whole closure is rolled back and keeps the (merely stale) shared-box behaviour instead of inventing a new bug.Verification
Every shape below diffed byte-for-byte against
node --experimental-strip-types. Trip counts are 9 on purpose — the static-loop unroller (MAX_TRIP_COUNT = 8) mints fresh ids per copy and masks the bug entirely at ≤ 8, which is why the issue's repro used 9.const j = icapture, await after loop (filed repro)() => iwhile/do-whilefor-of/for-in/for-await-ofcontinuetry/catch/finally,switchin a suspending loopfunction*generatorvar— function-scoped, ONE binding, last valuetest-files/test_gap_6345_async_loop_per_iteration_binding.ts(213 lines of output, byte-identical to node; 159 lines differ on pre-fixmain, so it genuinely gates).origin/maintoolchain (compiler and both static archives, both arms release): no regressions.cargo test -p perry-transform: 48 lib + 16 unroll tests pass.cargo fmt --all -- --checkandscripts/check_file_size.shclean.Known residual (pre-existing, unchanged)
A binding that a closure writes and that survives an
awaitstill collapses:This one needs a shared per-iteration cell that outlives a suspend — the step closure's fixed capture slots can only reach one box per activation, so it requires a second level of indirection rather than a snapshot. It behaves identically before and after this PR; filed separately as #6354 so the two fixes stay untangled.
Independent of #6328 / #6344 (a runtime fix for numeric-key computed calls); this one is entirely in
perry-transformand touches no runtime code.Summary by CodeRabbit
Bug Fixes
letandconstvariables retain distinct per-iteration values across suspension and resumption.vardeclarations.Tests