Skip to content

Commit 4697108

Browse files
author
Ralph Küpper
committed
fix(async): linearize await inside an async-generator finally (#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 #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
1 parent d53f34b commit 4697108

3 files changed

Lines changed: 108 additions & 25 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
fix(async): an `await` inside a `finally` of an `async function*` no longer
2+
compiles to a blocking busy-wait. When a `try` in an async generator has a
3+
finally that yields or awaits, the finally is linearized into its own dispatch
4+
states, and `.next()`/`.throw()` drive those states through the shared async-step
5+
driver so their `await`s suspend on the microtask queue. The `.return()` closure,
6+
however, re-drove the same states through a separate busy-wait dispatch loop
7+
(`__sent = await value; continue`) — so a `.return()` that ran the finally (an
8+
early `break` in a `for await`, or an explicit `.return()`) block-waited on the
9+
finally's `await`, monopolising the single runtime thread and deadlocking. This
10+
is the finally analog of the #8681 `await`-in-`catch` deadlock.
11+
12+
`.return()` now hands the continuation off to the shared `__agstep` driver
13+
(a fresh non-error resume) after routing the pending return into the finally,
14+
exactly as `.next()`/`.throw()` already do, so a finally `await` suspends
15+
instead of blocking. Behavior for a finally that only yields is unchanged.

crates/perry-transform/src/async_to_generator_tests.rs

Lines changed: 43 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -576,16 +576,49 @@ fn async_generator_linearizes_every_await_position() {
576576
finally: None,
577577
}],
578578
),
579-
// NOTE: `await` inside a `finally` of a REAL async generator
580-
// (`async function*`) is a SEPARATE, pre-existing gap in the
581-
// `#4438` B2-finally lowering — the yielding finally's states are
582-
// built with a raw `Expr::Await` instead of an async suspend, so it
583-
// block-waits the same way. It is NOT addressed by this PR (which
584-
// fixes the `was_plain_async` catch path); the closure test
585-
// `async_closure_rewrite_leaves_no_residual_await` DOES cover
586-
// `in-finally` for the `was_plain_async` path, which is clean.
587-
// Tracked separately in #8715; omitted here so this test asserts
588-
// only what this change fixes.
579+
// #8715: `await` inside a `finally` of a REAL async generator
580+
// (`async function*`). The yielding finally is linearized into its own
581+
// dispatch states, but the `.return()` closure used to re-drive them
582+
// through an async_step=false busy-wait loop (`__sent = await v;
583+
// continue`) — a blocking wait, the finally analog of the #8681 catch
584+
// deadlock. `.return()` now delegates the continuation to the shared
585+
// `__agstep` driver, so the finally `await` suspends on the microtask
586+
// queue and no raw `Expr::Await` survives.
587+
(
588+
"await-in-finally",
589+
vec![Stmt::Try {
590+
body: vec![y(Expr::Integer(0))],
591+
catch: None,
592+
finally: Some(vec![Stmt::Expr(await_(Expr::Integer(1)))]),
593+
}],
594+
),
595+
(
596+
"await-in-try-and-finally",
597+
vec![Stmt::Try {
598+
body: vec![Stmt::Expr(await_(Expr::Integer(0))), y(Expr::Integer(5))],
599+
catch: None,
600+
finally: Some(vec![Stmt::Expr(await_(Expr::Integer(1)))]),
601+
}],
602+
),
603+
(
604+
"await-in-try-catch-finally",
605+
vec![Stmt::Try {
606+
body: vec![y(Expr::Integer(0))],
607+
catch: Some(CatchClause {
608+
param: None,
609+
body: vec![Stmt::Expr(await_(Expr::Integer(1)))],
610+
}),
611+
finally: Some(vec![Stmt::Expr(await_(Expr::Integer(2)))]),
612+
}],
613+
),
614+
(
615+
"yield-in-finally-with-await",
616+
vec![Stmt::Try {
617+
body: vec![y(Expr::Integer(0))],
618+
catch: None,
619+
finally: Some(vec![y(Expr::Integer(8)), Stmt::Expr(await_(Expr::Integer(9)))]),
620+
}],
621+
),
589622
(
590623
"await-in-if-inside-try-inside-loop",
591624
// The pi #6728 shape: await buried in nested control flow.

crates/perry-transform/src/generator/lower.rs

Lines changed: 50 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -516,13 +516,19 @@ pub fn transform_generator_function_with_extra_captures(
516516
// #4374: clone the state-dispatch loop so the .throw() closure can
517517
// *continue* the state machine after running a catch handler.
518518
let while_body_for_throw = while_body.clone();
519-
// #4438 B2-finally: the `.return()` closure needs the same continuation loop
520-
// when it routes into a yielding finally (so the finally's `yield`s suspend).
521-
// #6709: the `.return()` closure is NOT an async-step driver (it cannot
522-
// chain an inner `await` through `CurrentStepClosure`), so its dispatch
523-
// keeps the busy-wait `await` shape — matching pre-#6709 `.return()`.
519+
// #4438 B2-finally: a `.return()` that routes into a yielding finally must
520+
// keep driving the state machine so the finally's `yield`s/`await`s run.
521+
// #8715: async generators delegate that continuation to the shared `__agstep`
522+
// step driver (see the `has_yielding_finally` branch below), so an `await`
523+
// inside the finally suspends on the microtask queue via `AsyncStepChain`
524+
// exactly as it does on the `.next()`/`.throw()` paths. Building an
525+
// async_step=false dispatch loop here instead would lower every such `await`
526+
// to a blocking busy-wait (`__sent = await v; continue`) — the finally analog
527+
// of the #8681 catch deadlock — so async generators build none. Sync
528+
// generators keep the busy-wait clone: they have no `await` states, so it
529+
// stays correct, and their `.return()` is a plain (non-driver) closure.
524530
let while_body_for_return = if is_async_generator {
525-
build_dispatch_while_body(&states, false, state_id, done_id, sent_id)
531+
Vec::new()
526532
} else {
527533
while_body.clone()
528534
};
@@ -577,7 +583,10 @@ pub fn transform_generator_function_with_extra_captures(
577583
} else {
578584
while_body_for_throw
579585
};
580-
let while_body_for_return = if wrap_dispatch {
586+
// #8715: async generators no longer run a local `.return()` dispatch loop
587+
// (`while_body_for_return` is empty — they delegate to `__agstep`), so skip
588+
// wrapping it. Sync generators still wrap their busy-wait clone.
589+
let while_body_for_return = if wrap_dispatch && !is_async_generator {
581590
let disp_err_id = alloc_local(next_local_id);
582591
wrap_dispatch_loop(
583592
while_body_for_return,
@@ -977,10 +986,10 @@ pub fn transform_generator_function_with_extra_captures(
977986
))));
978987
if has_yielding_finally {
979988
// #4438 B2-finally: route `.return(v)` into the innermost enclosing
980-
// yielding finally (record the pending return + jump in), then fall
981-
// through to the continuation loop so the finally's `yield`s suspend;
982-
// its completion check re-raises the return. Catches don't catch a
983-
// return completion, so only finally routes apply.
989+
// yielding finally record the pending return and jump to
990+
// `finally_entry_state`. Catches don't catch a return completion, so
991+
// only finally routes apply; on no match, `return_fallback` completes
992+
// the generator directly (never reaching the continuation below).
984993
return_resume_body.extend(build_abrupt_routing(
985994
&catches,
986995
&finallys,
@@ -994,10 +1003,36 @@ pub fn transform_generator_function_with_extra_captures(
9941003
false,
9951004
return_fallback,
9961005
));
997-
return_resume_body.push(Stmt::While {
998-
condition: Expr::Bool(true),
999-
body: while_body_for_return,
1000-
});
1006+
if is_async_generator {
1007+
// #8715: a matched route has set `state = finally_entry_state`
1008+
// and recorded the pending return in the shared boxed locals.
1009+
// Hand off to the shared `__agstep` driver (a fresh, non-error
1010+
// resume) rather than run a local async_step=false loop, so a
1011+
// finally `await` suspends on the microtask queue (`AsyncStepChain`
1012+
// re-entering `__agstep`) instead of block-waiting — the fix for
1013+
// this issue. `__agstep` dispatches from `finally_entry_state`,
1014+
// runs the finally (its `yield`s settle this `.return()`'s
1015+
// promise, its `await`s suspend), and its completion-check state
1016+
// re-raises the pending return as `{value, done: true}`. This
1017+
// mirrors how `.next()`/`.throw()` already drive a yielding
1018+
// finally. `wrap_generator_resume_body` clears `executing` before
1019+
// this return, so `__agstep`'s re-entrancy guard passes.
1020+
let agstep_local_id =
1021+
agstep_id.expect("agstep_id is set for async generators");
1022+
return_resume_body.push(Stmt::Return(Some(Expr::AsyncGenResume {
1023+
step_closure: Box::new(Expr::LocalGet(agstep_local_id)),
1024+
value: Box::new(Expr::Undefined),
1025+
is_error: false,
1026+
})));
1027+
} else {
1028+
// Sync generators re-drive the finally inline in this closure —
1029+
// no microtask suspend is needed (they have no `await`), and the
1030+
// finally's `yield`s return `{value, done: false}` directly.
1031+
return_resume_body.push(Stmt::While {
1032+
condition: Expr::Bool(true),
1033+
body: while_body_for_return,
1034+
});
1035+
}
10011036
} else {
10021037
return_resume_body.extend(return_fallback);
10031038
}

0 commit comments

Comments
 (0)