fix(async): linearize await inside an async-generator finally (#8715) - #8736
fix(async): linearize await inside an async-generator finally (#8715)#8736proggeramlug wants to merge 1 commit into
Conversation
…S#8715) An `await` inside a `finally` of an `async function*` compiled to a blocking busy-wait instead of an async suspend — the finally analog of the PerryTS#8681 await-in-catch deadlock fixed by PerryTS#8707. When a `try` in an async generator has a `finally` that yields or awaits, the finally is linearized into its own dispatch states. `.next()` and `.throw()` drive those states through the shared `__agstep` async-step driver, so a finally `await` suspends on the microtask queue via `AsyncStepChain`. The `.return()` closure, however, re-drove the SAME states through a separate `build_dispatch_while_body(states, /*async_step*/ false, …)` continuation loop, whose `StateExit::Await` lowering emits the busy-wait fallback `__sent = await value; continue` (fs_await.rs → `js_wait_for_event`). So a `.return()` that ran the finally — an early `break` in a `for await`, or an explicit `gen.return(v)` while suspended in the try — block-waited on the finally's `await`, monopolising the single runtime thread while the driver that would settle it sits suspended, and deadlocked. Fix: `.return()` no longer builds or runs an async_step=false loop for async generators. After `build_abrupt_routing` records the pending return and jumps to `finally_entry_state`, `.return()` hands off to the shared `__agstep` driver with a fresh non-error resume (`AsyncGenResume(__agstep, undefined, false)`), exactly as `.next()`/`.throw()` already do. `__agstep` dispatches from `finally_entry_state`, runs the finally (its `yield`s settle this `.return()`'s promise; its `await`s suspend on the microtask queue), and its completion-check state re-raises the pending return as `{value, done: true}`. Sync generators are unchanged — they have no `await` states, so their inline busy-wait clone stays correct, and their `.return()` is a plain (non-driver) closure. The `async_generator_linearizes_every_await_position` test re-adds the `await-in-finally` case PerryTS#8707 had removed (pointing here), plus await-in-try-and-finally, await-in-try-catch-finally, and yield-in-finally-with-await; all now leave zero residual `Expr::Await`. Behaviorally verified byte-identical to Node v26 for explicit `.return()`, `.throw()`, yield-in-finally, and try/catch/finally shapes, with no deadlock. Full `cargo test -p perry-transform` is green. Claude-Session: https://claude.ai/code/session_01TwxRkALrR9HKSF1zKLSTAF
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (3)
Included review availability: Your plan provides up to 8 included reviews per hour; 3 remain after this review. 📝 WalkthroughWalkthroughAsync-generator ChangesAsync-generator finally handling
Estimated code review effort: 3 (Moderate) | ~20 minutes Merge Risk: ⚪ Minimal · up to This PR routes async-generator returns through the asynchronous completion path so awaits in finally blocks suspend correctly instead of blocking; no actionable merge-blocking risk remains beyond normal checks and review. Sequence Diagram(s)sequenceDiagram
participant AsyncGeneratorReturn
participant __agstep
participant FinallyAwait
participant MicrotaskQueue
AsyncGeneratorReturn->>__agstep: Resume yielding finally
__agstep->>FinallyAwait: Execute awaited finally continuation
FinallyAwait->>MicrotaskQueue: Suspend on await
MicrotaskQueue-->>__agstep: Resume continuation
__agstep-->>AsyncGeneratorReturn: Complete pending return
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 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 |
…iased native-class new (#8739) Lands #8736 and #8738. #8736 (fixes #8715) closes the `finally` analog of the #8681 `await`-in-`catch` deadlock that #8707 fixed. This is the exact gap #8707's own new test surfaced when it was rebased -- it reported "await-in-finally: 2 raw await(s) survived" -- so the two land as a pair. An `await` inside a `finally` of a real `async function*` compiled to a blocking busy-wait rather than an async suspend; the linearizer already splits the finally into its own dispatch states with a `finally_entry_state`, and the async-step driver now routes through them. #8738 (fixes #8730) stops an aliased ESM named import of a Node built-in class throwing `ReferenceError: identifier is not defined` when constructed at module init -- `import { BlockList as Wj4 } from "net"; new Wj4()` and the same shape for `AsyncLocalStorage` and `PassThrough`. `lower_new`'s alias-rewrite block rewrites the callee from the local import name to the class's export name so the construction path matches the un-aliased form that codegen's builtin-`New` dispatch recognizes. This broke the natively-compiled Claude Code cli.js 2.1.112 bundle, which constructs all three at module init, so nearly every command crashed. No version bump. Co-authored-by: Ralph Küpper <ralph@skelpo.com>
|
Landed on This closes the exact gap #8707's own test surfaced when I rebased it — it reported Nice symmetry with #8707: same failure mode, same driver, same fix shape. Validated: all 30 lint checkers, transform 93/0, hir 334/0, runtime 2669/0 at |
Fixes #8715.
Root cause
An
awaitinside afinallyof a realasync function*compiled to a blocking busy-wait instead of an async suspend point — the finally analog of the #8681await-in-catchdeadlock that #8707 just fixed.When a
tryin an async generator has afinallythat yields or awaits, the linearizer (#4438B2-finally) splits the finally into its own dispatch states with afinally_entry_state..next()and.throw()drive those states through the shared__agstepasync-step driver, built withasync_step = true, so a finallyawaitlowers toreturn AsyncStepChain(value, __agstep)— a real microtask suspend.The
.return()closure did not go through__agstep. It re-drove the same states through a separate continuation loop built withbuild_dispatch_while_body(&states, /*async_step*/ false, …)(lower.rs). Withasync_step = false,StateExit::Awaitlowers to the busy-wait fallback:which codegen (
expr/fs_await.rs,!ctx.is_async_fn) compiles to the blockingjs_wait_for_event/js_unsettled_top_level_await_exit. So a.return()that ran the finally — an earlybreakin afor await, or an explicitgen.return(v)while suspended in thetry— block-waited on the finally'sawait, monopolising the single runtime thread while the driver that would settle it sat suspended above, and the program deadlocked.At the transform level the repro from the issue
left 2 residual
Expr::Awaitaftertransform_async_to_generator+transform_generators— both inside the.return()continuation clone (LocalSet(__sent, Await(..))).Fix
.return()no longer builds or runs anasync_step = falsedispatch loop for async generators. Afterbuild_abrupt_routingrecords the pending return (pending_type = 2) and jumps tofinally_entry_state,.return()hands the continuation off to the shared__agstepdriver with a fresh non-error resume:exactly as
.next()/.throw()already do.__agstepdispatches fromfinally_entry_state, runs the finally (itsyields settle this.return()'s promise, itsawaits suspend on the microtask queue), and its completion-check state re-raises the pending return as{value, done: true}.wrap_generator_resume_bodyclears theexecutingflag before this return, so__agstep's re-entrancy guard passes.Sync generators are untouched: they have no
awaitstates, so their inline busy-wait clone stays correct, and their.return()is a plain (non-driver) closure.Three small edits in
crates/perry-transform/src/generator/lower.rs: async generators build an emptywhile_body_for_return(and skip wrapping it), and thehas_yielding_finallybranch of the.return()body delegates to__agstepfor async generators / keeps the inline loop for sync ones.Tests
async_generator_linearizes_every_await_positionre-adds theawait-in-finallycase that fix(async): linearizeawaitinsidecatchfor async fns/closures (#8681) #8707 removed with a comment pointing here, plusawait-in-try-and-finally,await-in-try-catch-finally, andyield-in-finally-with-await. All now leave zero residualExpr::Await.cargo test -p perry-transformis green (93 passed, 0 failed).perry,--no-auto-optimize) for: for-await normal completion, explicitgen.return(v)into an awaiting finally,gen.throw(e)routed through an awaiting finally, a finally that both yields and awaits, andtry/catch/finallyall awaiting. None deadlock; the pre-fix.return()/.throw()paths hung.Out of scope (separate pre-existing bug)
A
for await … breakdid not run the finally in perry — but this reproduces on a sync generator with a synchronous finally (noawaitat all), so it is an orthogonalfor-of/for awaititerator-close gap (breakdoesn't invoke the iterator's.return()), not the await-in-finally lowering. My change is inert on that path (the.return()closure is never invoked), so this PR neither fixes nor regresses it.https://claude.ai/code/session_01TwxRkALrR9HKSF1zKLSTAF
Summary by CodeRabbit
Bug Fixes
.return()enters afinallyblock containing anawait.finallyblocks suspend and resume correctly, including scenarios involvingtry,catch, andyield.Tests
finallyblocks with awaited operations.