From 46971086e43b83c2f32c6007fcd059549219f072 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 24 Aug 2026 14:38:03 +0200 Subject: [PATCH] fix(async): linearize await inside an async-generator finally (#8715) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 #8681 await-in-catch deadlock fixed by #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 #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 --- changelog.d/8715-async-gen-finally-await.md | 15 +++++ .../src/async_to_generator_tests.rs | 53 ++++++++++++--- crates/perry-transform/src/generator/lower.rs | 65 ++++++++++++++----- 3 files changed, 108 insertions(+), 25 deletions(-) create mode 100644 changelog.d/8715-async-gen-finally-await.md diff --git a/changelog.d/8715-async-gen-finally-await.md b/changelog.d/8715-async-gen-finally-await.md new file mode 100644 index 0000000000..e2d3ab9f4b --- /dev/null +++ b/changelog.d/8715-async-gen-finally-await.md @@ -0,0 +1,15 @@ +fix(async): an `await` inside a `finally` of an `async function*` no longer +compiles to a blocking busy-wait. When a `try` in an async generator has a +finally that yields or awaits, the finally is linearized into its own dispatch +states, and `.next()`/`.throw()` drive those states through the shared async-step +driver so their `await`s suspend on the microtask queue. The `.return()` closure, +however, re-drove the same states through a separate busy-wait dispatch loop +(`__sent = await value; continue`) — so a `.return()` that ran the finally (an +early `break` in a `for await`, or an explicit `.return()`) block-waited on the +finally's `await`, monopolising the single runtime thread and deadlocking. This +is the finally analog of the #8681 `await`-in-`catch` deadlock. + +`.return()` now hands the continuation off to the shared `__agstep` driver +(a fresh non-error resume) after routing the pending return into the finally, +exactly as `.next()`/`.throw()` already do, so a finally `await` suspends +instead of blocking. Behavior for a finally that only yields is unchanged. diff --git a/crates/perry-transform/src/async_to_generator_tests.rs b/crates/perry-transform/src/async_to_generator_tests.rs index c5cec94737..ca2224b914 100644 --- a/crates/perry-transform/src/async_to_generator_tests.rs +++ b/crates/perry-transform/src/async_to_generator_tests.rs @@ -576,16 +576,49 @@ fn async_generator_linearizes_every_await_position() { finally: None, }], ), - // NOTE: `await` inside a `finally` of a REAL async generator - // (`async function*`) is a SEPARATE, pre-existing gap in the - // `#4438` B2-finally lowering — the yielding finally's states are - // built with a raw `Expr::Await` instead of an async suspend, so it - // block-waits the same way. It is NOT addressed by this PR (which - // fixes the `was_plain_async` catch path); the closure test - // `async_closure_rewrite_leaves_no_residual_await` DOES cover - // `in-finally` for the `was_plain_async` path, which is clean. - // Tracked separately in #8715; omitted here so this test asserts - // only what this change fixes. + // #8715: `await` inside a `finally` of a REAL async generator + // (`async function*`). The yielding finally is linearized into its own + // dispatch states, but the `.return()` closure used to re-drive them + // through an async_step=false busy-wait loop (`__sent = await v; + // continue`) — a blocking wait, the finally analog of the #8681 catch + // deadlock. `.return()` now delegates the continuation to the shared + // `__agstep` driver, so the finally `await` suspends on the microtask + // queue and no raw `Expr::Await` survives. + ( + "await-in-finally", + vec![Stmt::Try { + body: vec![y(Expr::Integer(0))], + catch: None, + finally: Some(vec![Stmt::Expr(await_(Expr::Integer(1)))]), + }], + ), + ( + "await-in-try-and-finally", + vec![Stmt::Try { + body: vec![Stmt::Expr(await_(Expr::Integer(0))), y(Expr::Integer(5))], + catch: None, + finally: Some(vec![Stmt::Expr(await_(Expr::Integer(1)))]), + }], + ), + ( + "await-in-try-catch-finally", + vec![Stmt::Try { + body: vec![y(Expr::Integer(0))], + catch: Some(CatchClause { + param: None, + body: vec![Stmt::Expr(await_(Expr::Integer(1)))], + }), + finally: Some(vec![Stmt::Expr(await_(Expr::Integer(2)))]), + }], + ), + ( + "yield-in-finally-with-await", + vec![Stmt::Try { + body: vec![y(Expr::Integer(0))], + catch: None, + finally: Some(vec![y(Expr::Integer(8)), Stmt::Expr(await_(Expr::Integer(9)))]), + }], + ), ( "await-in-if-inside-try-inside-loop", // The pi #6728 shape: await buried in nested control flow. diff --git a/crates/perry-transform/src/generator/lower.rs b/crates/perry-transform/src/generator/lower.rs index 05c341c013..8193f56930 100644 --- a/crates/perry-transform/src/generator/lower.rs +++ b/crates/perry-transform/src/generator/lower.rs @@ -516,13 +516,19 @@ pub fn transform_generator_function_with_extra_captures( // #4374: clone the state-dispatch loop so the .throw() closure can // *continue* the state machine after running a catch handler. let while_body_for_throw = while_body.clone(); - // #4438 B2-finally: the `.return()` closure needs the same continuation loop - // when it routes into a yielding finally (so the finally's `yield`s suspend). - // #6709: the `.return()` closure is NOT an async-step driver (it cannot - // chain an inner `await` through `CurrentStepClosure`), so its dispatch - // keeps the busy-wait `await` shape — matching pre-#6709 `.return()`. + // #4438 B2-finally: a `.return()` that routes into a yielding finally must + // keep driving the state machine so the finally's `yield`s/`await`s run. + // #8715: async generators delegate that continuation to the shared `__agstep` + // step driver (see the `has_yielding_finally` branch below), so an `await` + // inside the finally suspends on the microtask queue via `AsyncStepChain` + // exactly as it does on the `.next()`/`.throw()` paths. Building an + // async_step=false dispatch loop here instead would lower every such `await` + // to a blocking busy-wait (`__sent = await v; continue`) — the finally analog + // of the #8681 catch deadlock — so async generators build none. Sync + // generators keep the busy-wait clone: they have no `await` states, so it + // stays correct, and their `.return()` is a plain (non-driver) closure. let while_body_for_return = if is_async_generator { - build_dispatch_while_body(&states, false, state_id, done_id, sent_id) + Vec::new() } else { while_body.clone() }; @@ -577,7 +583,10 @@ pub fn transform_generator_function_with_extra_captures( } else { while_body_for_throw }; - let while_body_for_return = if wrap_dispatch { + // #8715: async generators no longer run a local `.return()` dispatch loop + // (`while_body_for_return` is empty — they delegate to `__agstep`), so skip + // wrapping it. Sync generators still wrap their busy-wait clone. + let while_body_for_return = if wrap_dispatch && !is_async_generator { let disp_err_id = alloc_local(next_local_id); wrap_dispatch_loop( while_body_for_return, @@ -977,10 +986,10 @@ pub fn transform_generator_function_with_extra_captures( )))); if has_yielding_finally { // #4438 B2-finally: route `.return(v)` into the innermost enclosing - // yielding finally (record the pending return + jump in), then fall - // through to the continuation loop so the finally's `yield`s suspend; - // its completion check re-raises the return. Catches don't catch a - // return completion, so only finally routes apply. + // yielding finally — record the pending return and jump to + // `finally_entry_state`. Catches don't catch a return completion, so + // only finally routes apply; on no match, `return_fallback` completes + // the generator directly (never reaching the continuation below). return_resume_body.extend(build_abrupt_routing( &catches, &finallys, @@ -994,10 +1003,36 @@ pub fn transform_generator_function_with_extra_captures( false, return_fallback, )); - return_resume_body.push(Stmt::While { - condition: Expr::Bool(true), - body: while_body_for_return, - }); + if is_async_generator { + // #8715: a matched route has set `state = finally_entry_state` + // and recorded the pending return in the shared boxed locals. + // Hand off to the shared `__agstep` driver (a fresh, non-error + // resume) rather than run a local async_step=false loop, so a + // finally `await` suspends on the microtask queue (`AsyncStepChain` + // re-entering `__agstep`) instead of block-waiting — the fix for + // this issue. `__agstep` dispatches from `finally_entry_state`, + // runs the finally (its `yield`s settle this `.return()`'s + // promise, its `await`s suspend), and its completion-check state + // re-raises the pending return as `{value, done: true}`. This + // mirrors how `.next()`/`.throw()` already drive a yielding + // finally. `wrap_generator_resume_body` clears `executing` before + // this return, so `__agstep`'s re-entrancy guard passes. + let agstep_local_id = + agstep_id.expect("agstep_id is set for async generators"); + return_resume_body.push(Stmt::Return(Some(Expr::AsyncGenResume { + step_closure: Box::new(Expr::LocalGet(agstep_local_id)), + value: Box::new(Expr::Undefined), + is_error: false, + }))); + } else { + // Sync generators re-drive the finally inline in this closure — + // no microtask suspend is needed (they have no `await`), and the + // finally's `yield`s return `{value, done: false}` directly. + return_resume_body.push(Stmt::While { + condition: Expr::Bool(true), + body: while_body_for_return, + }); + } } else { return_resume_body.extend(return_fallback); }