From 40d0f6d3c2fe6699f94b2d374b6827864ee6d71c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 24 Aug 2026 08:41:29 +0200 Subject: [PATCH] fix(async): linearize `await` inside `catch` for async fns/closures (#8681) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An `await` inside a `catch` block of a plain async function/closure compiled to a BLOCKING busy-wait instead of a suspend point. Reached re-entrantly from inside the async-step / async-generator pull cascade (a rejected awaited promise in a `try` routing to a `catch` that itself awaits — the ubiquitous stream retry/cleanup shape), that blocking wait monopolises the single runtime thread while the async-step driver that would resolve it sits suspended above on the stack, and the program deadlocks. This is the natively-compiled Claude Code `-p` streaming hang (the `perry_closure __85891` frame: 7 blocking-await sites next to async-step + `for await` markers). Root cause: `transform_generators` linearizes the catch body into real dispatch states (`CatchRoute.catch_entry_state = Some`), but the async-step throw handler `build_async_catch_route_body_direct` ignored `catch_entry_state` and inlined a raw clone of the catch body through `rewrite_yield_to_await_in_stmts`, turning every catch suspend back into a raw `Expr::Await` — which codegen (`fs_await.rs`, `!ctx.is_async_fn`) lowers as the blocking busy-wait (`js_wait_for_event` + `js_unsettled_top_level_await_exit`). Fix (generator/lower/async_step.rs): - `build_async_catch_route_body_direct`: when the catch was linearized (`catch_entry_state.is_some()`), route the delivered error INTO those states (bind the catch param, set `state = catch_entry_state`, fall through to the step dispatch loop) instead of inlining the blocking copy — mirroring the sync path's `build_abrupt_routing`. The linearized catch suspends via the async-step driver (`AsyncStepChain`) like any other await. A yield-free catch (nothing to suspend on) keeps the legacy inline. - `build_async_throw_body_direct`: for a linearized route, build the route condition with `state_based = true` (`upper = protected_end_state`, which EXCLUDES the catch's own states) so a `throw` raised inside the catch (`catch (e) { await x; throw wrap(e) }`) escapes to an enclosing handler instead of re-matching the same route. Also record async-generator CLASS METHODS (`async *[Symbol.asyncIterator]()`) into `async_generator_funcs` alongside top-level `async function*` (generator/mod.rs), for parity with the function/closure paths. Tests (perry-transform): differential await-position sweeps for async closures and async generators, a residual-`Expr::Await` sweep after the async-step rewrite (the regression guard for this bug), and async-gen-method recording. 92/92 unit tests pass. Separately validated: 13 behavioral synthetics compiled with the fixed compiler are byte-identical to Node v26 (retry-loop await-in- catch, rethrow-after-await, try/catch/finally all awaiting, async-generator await-in-catch consumed by `for await`, microtask ordering, `.throw()` into a running async generator). NOTE: `await` inside a `finally` of a *real* async generator is a separate, pre-existing gap in the `#4438` B2-finally lowering (a different path from this fix); tracked in #8715 and excluded from the generator await-position test with a pointer, since the `was_plain_async` `in-finally` case (covered by the closure test) is clean. Claude-Session: https://claude.ai/code/session_01TwxRkALrR9HKSF1zKLSTAF --- changelog.d/8707-await-in-catch-blockwait.md | 1 + .../perry-transform/src/async_to_generator.rs | 750 ++++++++++++++++++ .../src/generator/lower/async_step.rs | 36 +- crates/perry-transform/src/generator/mod.rs | 16 + 4 files changed, 802 insertions(+), 1 deletion(-) create mode 100644 changelog.d/8707-await-in-catch-blockwait.md diff --git a/changelog.d/8707-await-in-catch-blockwait.md b/changelog.d/8707-await-in-catch-blockwait.md new file mode 100644 index 0000000000..03627d075d --- /dev/null +++ b/changelog.d/8707-await-in-catch-blockwait.md @@ -0,0 +1 @@ +Fix a deadlock where an `await` inside a `catch` block of an async function or closure compiled to a blocking busy-wait instead of an async suspend. Reached re-entrantly from inside the async-step / async-generator pull cascade (a rejected awaited promise in a `try` routing to a `catch` that itself awaits — a common stream retry/cleanup shape), it monopolised the runtime thread and hung — the natively-compiled Claude Code `-p` streaming path being the motivating case. The async-step throw handler now routes the delivered error into the catch's already-linearized dispatch states (and lets a `throw` raised inside the catch escape correctly) instead of re-inlining the catch body as blocking awaits. (#8681) diff --git a/crates/perry-transform/src/async_to_generator.rs b/crates/perry-transform/src/async_to_generator.rs index edf43559ab..e194ec7333 100644 --- a/crates/perry-transform/src/async_to_generator.rs +++ b/crates/perry-transform/src/async_to_generator.rs @@ -1938,6 +1938,756 @@ mod computed_and_field_async_tests { ); } + // ── Differential await-position audit (#8681 -p streaming hang) ────────── + // + // The `-p` streaming deadlock traced to an async CLOSURE whose `await` + // reached codegen as a raw `Expr::Await` (the `fs_await.rs` blocking + // busy-wait) instead of a suspend point — i.e. `transform_async_to_generator` + // did not rewrite it. A raw block-wait entered from inside the async-step / + // microtask cascade (the SSE async-generator pull chain) monopolises the + // single runtime thread and self-deadlocks. + // + // For every syntactic position an `await` can sit in, an async closure that + // contains one MUST be (a) collected into `async_step_closures` and (b) + // CPS-rewritten so `is_async` is cleared. A cleared `is_async` is the + // definitive "rewrite fired, will suspend" signal; a still-set `is_async` on + // a closure that has an await is exactly the block-wait escape. This test + // sweeps the positions so a future edit to the walker / rewrite that drops + // one is caught here instead of in a 30-minute bundle compile. + fn await_(inner: Expr) -> Expr { + Expr::Await(Box::new(inner)) + } + + /// An async arrow whose body is `stmts`, at `func_id`. + fn async_closure_body(func_id: perry_hir::types::FuncId, stmts: Vec) -> Expr { + Expr::Closure { + func_id, + params: Vec::new(), + return_type: Type::Any, + body: stmts, + captures: Vec::new(), + mutable_captures: Vec::new(), + captures_this: false, + captures_new_target: false, + enclosing_class: None, + is_arrow: true, + is_async: true, + is_generator: false, + is_strict: false, + } + } + + #[test] + fn async_closure_await_in_every_position_is_rewritten() { + // (label, body containing exactly one `await` in the named position) + let cases: Vec<(&str, Vec)> = vec![ + ( + "ternary-then", + vec![Stmt::Expr(Expr::Conditional { + condition: Box::new(Expr::Bool(true)), + then_expr: Box::new(await_(Expr::Integer(1))), + else_expr: Box::new(Expr::Integer(0)), + })], + ), + ( + "logical-and-rhs", + vec![Stmt::Expr(Expr::Logical { + op: LogicalOp::And, + left: Box::new(Expr::Bool(true)), + right: Box::new(await_(Expr::Integer(1))), + })], + ), + ( + "logical-coalesce-rhs", + vec![Stmt::Expr(Expr::Logical { + op: LogicalOp::Coalesce, + left: Box::new(Expr::Null), + right: Box::new(await_(Expr::Integer(1))), + })], + ), + ( + "sequence", + vec![Stmt::Expr(Expr::Sequence(vec![ + Expr::Integer(0), + await_(Expr::Integer(1)), + ]))], + ), + ( + "switch-discriminant", + vec![Stmt::Switch { + discriminant: await_(Expr::Integer(1)), + cases: vec![], + }], + ), + ( + "switch-case-body", + vec![Stmt::Switch { + discriminant: Expr::Integer(0), + cases: vec![SwitchCase { + test: Some(Expr::Integer(0)), + body: vec![Stmt::Expr(await_(Expr::Integer(1)))], + }], + }], + ), + ( + "try-body", + vec![Stmt::Try { + body: vec![Stmt::Expr(await_(Expr::Integer(1)))], + catch: None, + finally: None, + }], + ), + ( + "catch-body", + vec![Stmt::Try { + body: vec![], + catch: Some(CatchClause { + param: None, + body: vec![Stmt::Expr(await_(Expr::Integer(1)))], + }), + finally: None, + }], + ), + ( + "finally-body", + vec![Stmt::Try { + body: vec![], + catch: None, + finally: Some(vec![Stmt::Expr(await_(Expr::Integer(1)))]), + }], + ), + ( + "array-element", + vec![Stmt::Expr(Expr::Array(vec![await_(Expr::Integer(1))]))], + ), + ( + "object-value", + vec![Stmt::Expr(Expr::Object(vec![( + "k".to_string(), + await_(Expr::Integer(1)), + )]))], + ), + ( + "call-arg", + vec![Stmt::Expr(Expr::Call { + callee: Box::new(Expr::Undefined), + args: vec![await_(Expr::Integer(1))], + type_args: vec![], + byte_offset: 0, + })], + ), + ( + "index", + vec![Stmt::Expr(Expr::IndexGet { + object: Box::new(Expr::Array(vec![])), + index: Box::new(await_(Expr::Integer(1))), + })], + ), + ( + "binary-rhs", + vec![Stmt::Expr(Expr::Binary { + op: BinaryOp::Add, + left: Box::new(Expr::Integer(1)), + right: Box::new(await_(Expr::Integer(1))), + })], + ), + ( + "return-await", + vec![Stmt::Return(Some(await_(Expr::Integer(1))))], + ), + ( + "throw-await", + vec![Stmt::Throw(await_(Expr::Integer(1)))], + ), + ( + "if-condition", + vec![Stmt::If { + condition: await_(Expr::Bool(true)), + then_branch: vec![], + else_branch: None, + }], + ), + ( + "while-body", + vec![Stmt::While { + condition: Expr::Bool(false), + body: vec![Stmt::Expr(await_(Expr::Integer(1)))], + }], + ), + ( + "for-of-iterable", + vec![Stmt::Expr(Expr::ForOfToArray(Box::new(await_( + Expr::Array(vec![]), + ))))], + ), + ]; + + // One class per case: the closure sits in an instance-field initializer + // (a stable container that stays an `Expr::Closure` after transform, so + // its `is_async` is directly inspectable — see the #5854 test above). + let mut module = Module::new("test"); + let base: perry_hir::types::FuncId = 1000; + for (i, (_label, body)) in cases.iter().enumerate() { + let id = base + i as perry_hir::types::FuncId; + let mut class = empty_class("C"); + class + .fields + .push(field_with_init("h", async_closure_body(id, body.clone()))); + module.classes.push(class); + } + + transform_async_to_generator(&mut module); + + let mut escaped: Vec = Vec::new(); + for (i, (label, _body)) in cases.iter().enumerate() { + let id = base + i as perry_hir::types::FuncId; + let collected = module.async_step_closures.contains(&id); + let rewritten = matches!( + &module.classes[i].fields[0].init, + Some(Expr::Closure { is_async: false, .. }) + ); + if !collected || !rewritten { + escaped.push(format!( + "{label}: collected={collected} rewritten={rewritten}" + )); + } + } + assert!( + escaped.is_empty(), + "async closures with an await in these positions escaped the \ + async->generator transform (would block-wait at runtime): {escaped:#?}" + ); + } + + // #8681 (-p streaming hang): an async-generator CLASS METHOD + // (`async *[Symbol.asyncIterator]()` — the Anthropic SDK `Stream` shape) must + // be recorded in `module.async_generator_funcs` just like a top-level + // `async function* g(){}`, or codegen never builds its async-generator driver + // wrapper and the method runs as a plain SYNC generator: its linearized + // awaits fall back to the blocking busy-wait (`fs_await.rs`), which + // self-deadlocks when driven from inside the async-step/microtask cascade. + fn async_gen_fn(id: perry_hir::types::FuncId) -> Function { + let mut f = empty_fn( + id, + vec![Stmt::Expr(Expr::Yield { + value: Some(Box::new(Expr::Await(Box::new(Expr::Integer(1))))), + delegate: false, + })], + ); + f.is_async = true; + f.is_generator = true; + f + } + + #[test] + fn async_generator_class_methods_are_recorded_like_top_level() { + use crate::generator::transform_generators; + + let mut module = Module::new("test"); + + // (a) baseline: a top-level `async function* g(){}` — known-recorded. + module.functions.push(async_gen_fn(100)); + + // (b) an async-generator INSTANCE method, (c) STATIC method, + // (d) COMPUTED-key member — the three class containers. + let mut class = empty_class("Stream"); + class.methods.push(async_gen_fn(200)); + class.static_methods.push(async_gen_fn(300)); + class.computed_members.push(ClassComputedMember { + key_expr: Expr::Integer(0), + function: async_gen_fn(400), + is_static: false, + kind: ClassComputedMemberKind::Method, + source_order: 0, + }); + module.classes.push(class); + + // The async-step pre-pass runs first in the real pipeline, then the + // generator transform records async-generator func ids. + transform_async_to_generator(&mut module); + transform_generators(&mut module); + + let recorded = &module.async_generator_funcs; + assert!( + recorded.contains(&100), + "top-level async generator must be recorded (baseline)" + ); + let mut missing: Vec<(&str, perry_hir::types::FuncId)> = Vec::new(); + for (label, id) in [ + ("instance-method", 200), + ("static-method", 300), + ("computed-member", 400), + ] { + if !recorded.contains(&id) { + missing.push((label, id)); + } + } + assert!( + missing.is_empty(), + "async-generator class methods NOT recorded in async_generator_funcs \ + (they will run as sync generators and block-wait): {missing:?}" + ); + } + + // ── Async-generator linearizer residual-await audit (#8681) ────────────── + // + // After the full async pipeline (`transform_async_to_generator` + + // `transform_generators`), NO raw `Expr::Await` may survive anywhere: every + // await is either linearized into a generator suspend or CPS-rewritten in a + // nested async closure. A surviving raw `Expr::Await` is compiled by + // `fs_await.rs` into the blocking busy-wait — the exact `-p` deadlock when it + // fires from inside the async-step / async-generator pull chain. The prior + // fix in this family (pi #6728) was an `await` inside `if`/loop/`try` in an + // async generator that never suspended; this sweep guards the whole matrix. + fn count_raw_awaits_stmts(stmts: &[Stmt]) -> usize { + stmts.iter().map(count_raw_awaits_stmt).sum() + } + fn count_raw_awaits_stmt(s: &Stmt) -> usize { + match s { + Stmt::Let { init: Some(e), .. } + | Stmt::Expr(e) + | Stmt::Throw(e) + | Stmt::Return(Some(e)) => count_raw_awaits_expr(e), + Stmt::If { + condition, + then_branch, + else_branch, + } => { + count_raw_awaits_expr(condition) + + count_raw_awaits_stmts(then_branch) + + else_branch.as_ref().map_or(0, |b| count_raw_awaits_stmts(b)) + } + Stmt::While { condition, body } | Stmt::DoWhile { body, condition } => { + count_raw_awaits_expr(condition) + count_raw_awaits_stmts(body) + } + Stmt::For { + init, + condition, + update, + body, + } => { + init.as_ref().map_or(0, |i| count_raw_awaits_stmt(i)) + + condition.as_ref().map_or(0, |c| count_raw_awaits_expr(c)) + + update.as_ref().map_or(0, |u| count_raw_awaits_expr(u)) + + count_raw_awaits_stmts(body) + } + Stmt::Try { + body, + catch, + finally, + } => { + count_raw_awaits_stmts(body) + + catch.as_ref().map_or(0, |c| count_raw_awaits_stmts(&c.body)) + + finally.as_ref().map_or(0, |f| count_raw_awaits_stmts(f)) + } + Stmt::Switch { + discriminant, + cases, + } => { + count_raw_awaits_expr(discriminant) + + cases + .iter() + .map(|c| { + c.test.as_ref().map_or(0, count_raw_awaits_expr) + + count_raw_awaits_stmts(&c.body) + }) + .sum::() + } + Stmt::Labeled { body, .. } => count_raw_awaits_stmt(body), + _ => 0, + } + } + fn count_raw_awaits_expr(e: &Expr) -> usize { + let mut n = if matches!(e, Expr::Await(_)) { 1 } else { 0 }; + // Descend into a nested closure body too: after the pipeline an async + // closure is a state machine, so a raw await there is equally a bug. + if let Expr::Closure { body, .. } = e { + n += count_raw_awaits_stmts(body); + } + perry_hir::walker::walk_expr_children(e, &mut |c| n += count_raw_awaits_expr(c)); + n + } + + fn async_gen_with_body(id: perry_hir::types::FuncId, body: Vec) -> Function { + let mut f = empty_fn(id, body); + f.is_async = true; + f.is_generator = true; + f + } + + #[test] + fn async_generator_linearizes_every_await_position() { + use crate::generator::transform_generators; + + let y = |v: Expr| Stmt::Expr(Expr::Yield { + value: Some(Box::new(v)), + delegate: false, + }); + let cases: Vec<(&str, Vec)> = vec![ + ("yield-await", vec![y(await_(Expr::Integer(1)))]), + ( + "await-in-if-body", + vec![Stmt::If { + condition: Expr::Bool(true), + then_branch: vec![Stmt::Expr(await_(Expr::Integer(1))), y(Expr::Integer(2))], + else_branch: None, + }], + ), + ( + "await-in-while-body", + vec![Stmt::While { + condition: Expr::Bool(false), + body: vec![Stmt::Expr(await_(Expr::Integer(1))), y(Expr::Integer(2))], + }], + ), + ( + "await-in-for-body", + vec![Stmt::For { + init: None, + condition: Some(Expr::Bool(false)), + update: None, + body: vec![Stmt::Expr(await_(Expr::Integer(1))), y(Expr::Integer(2))], + }], + ), + ( + "await-in-try-body", + vec![Stmt::Try { + body: vec![Stmt::Expr(await_(Expr::Integer(1))), y(Expr::Integer(2))], + catch: None, + finally: None, + }], + ), + ( + "await-in-catch", + vec![Stmt::Try { + body: vec![y(Expr::Integer(0))], + catch: Some(CatchClause { + param: None, + body: vec![Stmt::Expr(await_(Expr::Integer(1)))], + }), + 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. + ( + "await-in-if-inside-try-inside-loop", + // The pi #6728 shape: await buried in nested control flow. + vec![Stmt::While { + condition: Expr::Bool(false), + body: vec![Stmt::Try { + body: vec![Stmt::If { + condition: Expr::Bool(true), + then_branch: vec![Stmt::Expr(await_(Expr::Integer(1)))], + else_branch: None, + }], + catch: None, + finally: None, + }], + }], + ), + ( + "await-in-switch-case", + vec![Stmt::Switch { + discriminant: Expr::Integer(0), + cases: vec![SwitchCase { + test: Some(Expr::Integer(0)), + body: vec![Stmt::Expr(await_(Expr::Integer(1)))], + }], + }], + ), + ( + "await-in-ternary", + vec![Stmt::Expr(Expr::Conditional { + condition: Box::new(Expr::Bool(true)), + then_expr: Box::new(await_(Expr::Integer(1))), + else_expr: Box::new(Expr::Integer(0)), + })], + ), + ( + "await-in-logical", + vec![Stmt::Expr(Expr::Logical { + op: LogicalOp::And, + left: Box::new(Expr::Bool(true)), + right: Box::new(await_(Expr::Integer(1))), + })], + ), + ( + "await-then-yield-await", + vec![ + Stmt::Expr(await_(Expr::Integer(1))), + y(await_(Expr::Integer(2))), + ], + ), + ]; + + let mut escaped: Vec = Vec::new(); + for (label, body) in &cases { + let mut module = Module::new("test"); + module.functions.push(async_gen_with_body(500, body.clone())); + transform_async_to_generator(&mut module); + transform_generators(&mut module); + // Scan every function the pipeline produced (the original plus the + // synthesized step closures / bodies). + let residual: usize = module + .functions + .iter() + .map(|f| count_raw_awaits_stmts(&f.body)) + .sum::() + + module + .init + .iter() + .map(count_raw_awaits_stmt) + .sum::(); + if residual > 0 { + escaped.push(format!("{label}: {residual} raw await(s) survived")); + } + } + assert!( + escaped.is_empty(), + "raw Expr::Await survived async-generator linearization (would \ + block-wait at runtime): {escaped:#?}" + ); + } + + // #8681 (THE crash frame `perry_closure __85891`): a plain async CLOSURE + // rewritten to the async-step driver must leave NO raw `Expr::Await` in its + // body — every await must become an async-step suspend. A residual raw await + // is compiled by fs_await.rs (with `ctx.is_async_fn == false`, since the + // rewrite cleared `is_async`) into the blocking busy-wait + top-level-await + // exit — exactly the symbols the crash-frame closure calls + // (`js_wait_for_event`, `js_unsettled_top_level_await_exit`, ×7 sites). The + // earlier `..._await_in_every_position_is_rewritten` test only checked that + // `is_async` was cleared; it never checked for leftover awaits. This does. + #[test] + fn async_closure_rewrite_leaves_no_residual_await() { + let cases: Vec<(&str, Vec)> = vec![ + ("top-level", vec![Stmt::Expr(await_(Expr::Integer(1)))]), + ( + "in-if", + vec![Stmt::If { + condition: Expr::Bool(true), + then_branch: vec![Stmt::Expr(await_(Expr::Integer(1)))], + else_branch: None, + }], + ), + ( + "in-while", + vec![Stmt::While { + condition: Expr::Bool(false), + body: vec![Stmt::Expr(await_(Expr::Integer(1)))], + }], + ), + ( + "in-for", + vec![Stmt::For { + init: None, + condition: Some(Expr::Bool(false)), + update: None, + body: vec![Stmt::Expr(await_(Expr::Integer(1)))], + }], + ), + ( + "in-try", + vec![Stmt::Try { + body: vec![Stmt::Expr(await_(Expr::Integer(1)))], + catch: None, + finally: None, + }], + ), + ( + "try-await-and-catch-await", + vec![Stmt::Try { + body: vec![Stmt::Expr(await_(Expr::Integer(0)))], + catch: Some(CatchClause { + param: None, + body: vec![Stmt::Expr(await_(Expr::Integer(1)))], + }), + finally: None, + }], + ), + ( + "in-catch", + vec![Stmt::Try { + body: vec![Stmt::Expr(Expr::Integer(0))], + catch: Some(CatchClause { + param: None, + body: vec![Stmt::Expr(await_(Expr::Integer(1)))], + }), + finally: None, + }], + ), + ( + "in-finally", + vec![Stmt::Try { + body: vec![Stmt::Expr(Expr::Integer(0))], + catch: None, + finally: Some(vec![Stmt::Expr(await_(Expr::Integer(1)))]), + }], + ), + ( + "in-if-in-try-in-while", + vec![Stmt::While { + condition: Expr::Bool(false), + body: vec![Stmt::Try { + body: vec![Stmt::If { + condition: Expr::Bool(true), + then_branch: vec![Stmt::Expr(await_(Expr::Integer(1)))], + else_branch: None, + }], + catch: None, + finally: None, + }], + }], + ), + ( + "in-ternary", + vec![Stmt::Expr(Expr::Conditional { + condition: Box::new(Expr::Bool(true)), + then_expr: Box::new(await_(Expr::Integer(1))), + else_expr: Box::new(Expr::Integer(0)), + })], + ), + ( + "in-logical", + vec![Stmt::Expr(Expr::Logical { + op: LogicalOp::And, + left: Box::new(Expr::Bool(true)), + right: Box::new(await_(Expr::Integer(1))), + })], + ), + ( + "in-switch-case", + vec![Stmt::Switch { + discriminant: Expr::Integer(0), + cases: vec![SwitchCase { + test: Some(Expr::Integer(0)), + body: vec![Stmt::Expr(await_(Expr::Integer(1)))], + }], + }], + ), + ]; + + let mut module = Module::new("test"); + let base: perry_hir::types::FuncId = 2000; + for (i, (_label, body)) in cases.iter().enumerate() { + let id = base + i as perry_hir::types::FuncId; + let mut class = empty_class("C"); + class + .fields + .push(field_with_init("h", async_closure_body(id, body.clone()))); + module.classes.push(class); + } + + transform_async_to_generator(&mut module); + + let mut residual: Vec = Vec::new(); + for (i, (label, _body)) in cases.iter().enumerate() { + if let Some(init) = &module.classes[i].fields[0].init { + let n = count_raw_awaits_expr(init); + if n > 0 { + residual.push(format!("{label}: {n} raw await(s) survived")); + } + } + } + assert!( + residual.is_empty(), + "async-closure async-step rewrite left raw Expr::Await (would \ + block-wait at runtime — the `__85891` crash shape): {residual:#?}" + ); + } + + // #8681: async-generator CLOSURE EXPRESSIONS (`const g = async function*(){ + // await x; yield y }`) go through `transform_generator_closures_in_stmts`, a + // different path than named async-gen functions. The `-p` crash frame is a + // `perry_closure` — an inline closure — so this path is the closest match. + // After the pipeline no raw `Expr::Await` may survive in the closure or the + // synthesized bodies the transform lifts into `module.functions`. + #[test] + fn async_generator_closure_expressions_linearize_awaits() { + use crate::generator::transform_generators; + + let async_gen_closure = |id: perry_hir::types::FuncId, body: Vec| Expr::Closure { + func_id: id, + params: Vec::new(), + return_type: Type::Any, + body, + captures: Vec::new(), + mutable_captures: Vec::new(), + captures_this: false, + captures_new_target: false, + enclosing_class: None, + is_arrow: false, + is_async: true, + is_generator: true, + is_strict: false, + }; + let y = |v: Expr| Stmt::Expr(Expr::Yield { + value: Some(Box::new(v)), + delegate: false, + }); + + let bodies: Vec<(&str, Vec)> = vec![ + ("yield-await", vec![y(await_(Expr::Integer(1)))]), + ( + "await-in-loop-in-try", + vec![Stmt::Try { + body: vec![Stmt::While { + condition: Expr::Bool(false), + body: vec![Stmt::Expr(await_(Expr::Integer(1))), y(Expr::Integer(2))], + }], + catch: None, + finally: None, + }], + ), + ( + "await-in-if", + vec![Stmt::If { + condition: Expr::Bool(true), + then_branch: vec![Stmt::Expr(await_(Expr::Integer(1))), y(Expr::Integer(2))], + else_branch: None, + }], + ), + ]; + + let mut escaped: Vec = Vec::new(); + for (label, body) in &bodies { + let mut module = Module::new("test"); + // `const g = async function*(){...}` at module scope. + module.init.push(Stmt::Let { + id: 0, + name: "g".to_string(), + ty: Type::Any, + mutable: false, + init: Some(async_gen_closure(600, body.clone())), + }); + transform_async_to_generator(&mut module); + transform_generators(&mut module); + let residual: usize = module + .functions + .iter() + .map(|f| count_raw_awaits_stmts(&f.body)) + .sum::() + + module.init.iter().map(count_raw_awaits_stmt).sum::(); + if residual > 0 { + escaped.push(format!("{label}: {residual} raw await(s) survived")); + } + } + assert!( + escaped.is_empty(), + "raw Expr::Await survived async-generator CLOSURE linearization \ + (would block-wait at runtime): {escaped:#?}" + ); + } + // A computed-key member body (`[0]() { async () => await 1 }`). The rewrite // loop already walked `computed_members` (commit f80652ad0) but the collect // scan did not, so the id set it filters on never listed the closure and the diff --git a/crates/perry-transform/src/generator/lower/async_step.rs b/crates/perry-transform/src/generator/lower/async_step.rs index 9dbc446b13..30a4e1793d 100644 --- a/crates/perry-transform/src/generator/lower/async_step.rs +++ b/crates/perry-transform/src/generator/lower/async_step.rs @@ -16,7 +16,15 @@ pub(crate) fn build_async_throw_body_direct( let mut fallback = vec![Stmt::Throw(Expr::LocalGet(throw_param_id))]; for route in catches.into_iter().rev() { - let condition = catch_route_condition(&route, state_id, false, false); + // #8681: a LINEARIZED catch (its body became real dispatch states) must + // use the state-based upper bound (`protected_end_state`, which EXCLUDES + // the catch's own states) so an error raised *inside* the catch — e.g. + // `catch (e) { await x; throw wrap(e); }` — ESCAPES to an enclosing + // handler instead of re-matching this same route and re-entering the + // catch. The legacy inline path (catch_entry_state == None) keeps the + // async `post_catch_state` upper bound it always used. + let state_based = route.catch_entry_state.is_some(); + let condition = catch_route_condition(&route, state_id, state_based, false); let then_branch = build_async_catch_route_body_direct( route, state_id, @@ -49,6 +57,32 @@ pub(crate) fn build_async_catch_route_body_direct( ))); } + // #8681: when the catch body was linearized into its own dispatch states + // (`catch_entry_state`), route the delivered error INTO those states — + // bind the catch param (above), set `state = catch_entry_state`, and fall + // through to the step's `while (true)` dispatch loop — exactly as the sync + // path does in `build_abrupt_routing`. The old behavior inlined a raw copy + // of the catch body here and ran `rewrite_yield_to_await_in_stmts` over it, + // turning every `await` inside the catch into a BLOCKING busy-wait + // (`fs_await.rs`: `js_wait_for_event` + `js_unsettled_top_level_await_exit`). + // Reached re-entrantly from inside the async-step / async-generator pull + // cascade (a rejected awaited promise in a `try` routing to a `catch` that + // itself awaits — the Anthropic SDK stream error path), that blocking wait + // monopolises the single runtime thread and self-deadlocks: the + // `perry_closure __85891` `-p` hang. The linearized catch states suspend via + // the async-step driver (`AsyncStepChain`) like any other await, so the + // driver keeps making progress. + if let Some(catch_entry_state) = route.catch_entry_state { + body.push(Stmt::Expr(Expr::LocalSet( + state_id, + Box::new(Expr::Number(catch_entry_state as f64)), + ))); + return body; + } + + // Legacy fallback: the catch body was NOT linearized (no await/yield inside + // it, so there is nothing to suspend on) — inline it directly. A yield-free + // catch has no `await` to turn into a block-wait, so this stays correct. let mut rewritten = route.body; rewrite_hoisted_lets_in_stmts(&mut rewritten, hoisted_ids); rewrite_yield_to_await_in_stmts(&mut rewritten); diff --git a/crates/perry-transform/src/generator/mod.rs b/crates/perry-transform/src/generator/mod.rs index 6432b496ae..5ac3f27a4e 100644 --- a/crates/perry-transform/src/generator/mod.rs +++ b/crates/perry-transform/src/generator/mod.rs @@ -164,6 +164,13 @@ pub fn transform_generators(module: &mut Module) { } for m in &mut class.methods { if m.is_generator { + // #8681: an `async *m(){}` method is an async generator — record + // it (before the transform clears `is_async`) exactly like the + // top-level `async function*` loop above, so codegen has the same + // ground truth for methods as for functions/closures. + if m.is_async { + record_async_generator_func(m.id); + } transform_generator_function_with_extra_captures( m, &mut next_local_id, @@ -181,6 +188,10 @@ pub fn transform_generators(module: &mut Module) { } for m in &mut class.static_methods { if m.is_generator { + // #8681: `static async *m(){}` — see the instance-method note. + if m.is_async { + record_async_generator_func(m.id); + } transform_generator_function(m, &mut next_local_id, &mut next_func_id); } let mut b = std::mem::take(&mut m.body); @@ -193,6 +204,11 @@ pub fn transform_generators(module: &mut Module) { for member in &mut class.computed_members { let m = &mut member.function; if m.is_generator { + // #8681: `async *[Symbol.asyncIterator](){}` — the Anthropic SDK + // `Stream` shape; record it as an async generator like the rest. + if m.is_async { + record_async_generator_func(m.id); + } transform_generator_function_with_extra_captures( m, &mut next_local_id,