Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions changelog.d/8715-async-gen-finally-await.md
Original file line number Diff line number Diff line change
@@ -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.
53 changes: 43 additions & 10 deletions crates/perry-transform/src/async_to_generator_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
65 changes: 50 additions & 15 deletions crates/perry-transform/src/generator/lower.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
};
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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);
}
Expand Down
Loading