Skip to content

fix(async): linearize await inside an async-generator finally (#8715) - #8736

Closed
proggeramlug wants to merge 1 commit into
PerryTS:mainfrom
proggeramlug:fix/8715-async-gen-finally-await
Closed

fix(async): linearize await inside an async-generator finally (#8715)#8736
proggeramlug wants to merge 1 commit into
PerryTS:mainfrom
proggeramlug:fix/8715-async-gen-finally-await

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Fixes #8715.

Root cause

An await inside a finally of a real async function* compiled to a blocking busy-wait instead of an async suspend point — the finally analog of the #8681 await-in-catch deadlock that #8707 just fixed.

When a try in an async generator has a finally that yields or awaits, the linearizer (#4438 B2-finally) splits the finally into its own dispatch states with a finally_entry_state. .next() and .throw() drive those states through the shared __agstep async-step driver, built with async_step = true, so a finally await lowers to return 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 with build_dispatch_while_body(&states, /*async_step*/ false, …) (lower.rs). With async_step = false, StateExit::Await lowers to the busy-wait fallback:

__sent = await value;   // raw Expr::Await
continue;

which codegen (expr/fs_await.rs, !ctx.is_async_fn) compiles to the blocking js_wait_for_event / js_unsettled_top_level_await_exit. 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 sat suspended above, and the program deadlocked.

At the transform level the repro from the issue

async function* g() { try { yield 0; } finally { await Promise.resolve(); } }

left 2 residual Expr::Await after transform_async_to_generator + transform_generators — both inside the .return() continuation clone (LocalSet(__sent, Await(..))).

Fix

.return() no longer builds or runs an async_step = false dispatch loop for async generators. After build_abrupt_routing records the pending return (pending_type = 2) and jumps to finally_entry_state, .return() hands the continuation off to the shared __agstep driver with a fresh non-error resume:

return AsyncGenResume(__agstep, undefined, /*is_error*/ false);

exactly as .next()/.throw() already do. __agstep dispatches from finally_entry_state, runs the finally (its yields settle this .return()'s promise, its awaits suspend on the microtask queue), and its completion-check state re-raises the pending return as {value, done: true}. wrap_generator_resume_body clears the executing flag before this return, so __agstep's re-entrancy guard passes.

Sync generators are untouched: they have no await states, 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 empty while_body_for_return (and skip wrapping it), and the has_yielding_finally branch of the .return() body delegates to __agstep for async generators / keeps the inline loop for sync ones.

Tests

  • async_generator_linearizes_every_await_position re-adds the await-in-finally case that fix(async): linearize await inside catch for async fns/closures (#8681) #8707 removed with a comment 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.
  • Full cargo test -p perry-transform is green (93 passed, 0 failed).
  • Behaviorally verified byte-identical to Node v26 (compiled with the fixed perry, --no-auto-optimize) for: for-await normal completion, explicit gen.return(v) into an awaiting finally, gen.throw(e) routed through an awaiting finally, a finally that both yields and awaits, and try/catch/finally all awaiting. None deadlock; the pre-fix .return()/.throw() paths hung.

Out of scope (separate pre-existing bug)

A for await … break did not run the finally in perry — but this reproduces on a sync generator with a synchronous finally (no await at all), so it is an orthogonal for-of/for await iterator-close gap (break doesn'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

    • Fixed async generators getting stuck when .return() enters a finally block containing an await.
    • Ensured asynchronous finally blocks suspend and resume correctly, including scenarios involving try, catch, and yield.
  • Tests

    • Expanded coverage for async-generator finally blocks with awaited operations.

…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
@coderabbitai

coderabbitai Bot commented Aug 24, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 34270a2d-2051-4daf-844b-c3d53a792f7a

📥 Commits

Reviewing files that changed from the base of the PR and between 0749bd3 and 4697108.

📒 Files selected for processing (3)
  • changelog.d/8715-async-gen-finally-await.md
  • crates/perry-transform/src/async_to_generator_tests.rs
  • crates/perry-transform/src/generator/lower.rs

Included review availability: Your plan provides up to 8 included reviews per hour; 3 remain after this review.


📝 Walkthrough

Walkthrough

Async-generator .return() now routes yielding finally continuations through the shared __agstep driver. Awaited finally blocks can suspend asynchronously. Tests cover try, catch, finally, and yield combinations.

Changes

Async-generator finally handling

Layer / File(s) Summary
Shared-driver return routing
crates/perry-transform/src/generator/lower.rs
Async generators no longer use a local .return() dispatch loop. Yielding finally continuations resume through __agstep; synchronous generators retain the inline loop.
Awaited-finally coverage and documentation
crates/perry-transform/src/async_to_generator_tests.rs, changelog.d/8715-async-gen-finally-await.md
Tests cover awaits in finally blocks and combined try/catch/finally flows. The changelog documents the updated routing behavior.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Merge Risk: ⚪ Minimal · up to 46971

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
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the async-generator finally-await fix, which is the primary change in the pull request.
Description check ✅ Passed The description covers the root cause, implementation, related issue, tests, and scope; formal template headings and checklist items are omitted but non-critical.
Linked Issues check ✅ Passed The changes address issue #8715 by routing async-generator return handling through __agstep and adding regression coverage for await-in-finally cases.
Out of Scope Changes check ✅ Passed The changelog entry, lowering changes, and regression tests directly support issue #8715, with no unrelated code changes identified.
Docstring Coverage ✅ Passed Docstring check was indeterminate for this PR — some files could not be analyzed in time. Not blocking.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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 added a commit that referenced this pull request Aug 24, 2026
…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>
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Landed on main via #8739 (squash 32f0eacee), with #8738.

This closes the exact gap #8707's own test surfaced when I rebased it — it reported await-in-finally: 2 raw await(s) survived, and I held it saying the resolution meant deciding whether finally should route through its entry state the way catch now does. This is that decision made properly, and async_generator_linearizes_every_await_position passes on the merged result (I ran it explicitly, since that test is what named the gap).

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 RUST_TEST_THREADS=1, codegen 1222/0.

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.

codegen(async): await inside finally of an async generator block-waits (residual raw Await in the linearized finally state)

1 participant