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/changelog.d/8730-aliased-native-class-new-resolution.md b/changelog.d/8730-aliased-native-class-new-resolution.md new file mode 100644 index 0000000000..b5898a30d1 --- /dev/null +++ b/changelog.d/8730-aliased-native-class-new-resolution.md @@ -0,0 +1 @@ +Fix an aliased ESM named import of a Node built-in class (`import { BlockList as Wj4 } from "net"`, `{ AsyncLocalStorage as J_z } from "async_hooks"`, `{ PassThrough as Lrz } from "stream"`) throwing `ReferenceError: identifier is not defined` when constructed. The `new`-lowering already rewrites the alias to the class's export name so construction matches the un-aliased form, but the unresolved-`new` guard added in #8688 re-checked the native-module registry under that rewritten export name — which is keyed on the local import name — and, since these classes are not reified global builtins, fired the nameless throw at module init. The guard now also consults the registry under the original imported identifier, so aliased native-class imports resolve and construct exactly like their un-aliased form. This unblocked the natively compiled Claude Code cli.js 2.1.112 bundle, which crashed at module init on nearly every command. diff --git a/crates/perry-hir/src/lower/expr_new.rs b/crates/perry-hir/src/lower/expr_new.rs index 3bb744b362..65c5014dc5 100644 --- a/crates/perry-hir/src/lower/expr_new.rs +++ b/crates/perry-hir/src/lower/expr_new.rs @@ -1571,12 +1571,26 @@ pub(super) fn lower_new(ctx: &mut LoweringContext, new_expr: &ast::NewExpr) -> R // evaluating the constructor reference. That is a ReferenceError // (`new Missing()`), distinct from the TypeError produced when a // present binding's value is non-constructable. + // + // Consult the native-module registry under BOTH the (possibly + // rewritten) `class_name` AND the original `source_class_name`. + // The alias-rewrite block just above replaces `class_name` with a + // native class's EXPORT name (`Wj4` → `BlockList`) so the + // construction path below matches the un-aliased form, but the + // registry is keyed on the LOCAL import name (`Wj4`), so + // `lookup_native_module(&class_name)` misses under the export name. + // Checking `source_class_name` recognizes the aliased native import + // as resolved; without it, an aliased `import { BlockList as Wj4 }` + // / `{ AsyncLocalStorage as J_z }` / `{ PassThrough as Lrz }` (none + // of which are reified global builtins) fell through to this throw + // at module init even though the binding is perfectly resolvable. if ctx.lookup_class(&class_name).is_none() && ctx.resolve_class_alias(&class_name).is_none() && ctx.lookup_local(&class_name).is_none() && ctx.lookup_func(&class_name).is_none() && ctx.lookup_imported_func(&class_name).is_none() && ctx.lookup_native_module(&class_name).is_none() + && ctx.lookup_native_module(source_class_name).is_none() && !ctx.forward_class_names.contains(source_class_name) && !is_reified_global_builtin_constructor(&class_name) { diff --git a/crates/perry-hir/tests/aliased_native_new_resolution.rs b/crates/perry-hir/tests/aliased_native_new_resolution.rs new file mode 100644 index 0000000000..9795ddbf21 --- /dev/null +++ b/crates/perry-hir/tests/aliased_native_new_resolution.rs @@ -0,0 +1,101 @@ +//! Regression test for #8730: an ALIASED ESM named import of a Node built-in +//! class (`import { BlockList as Wj4 } from "net"; new Wj4()`) must not lower +//! `new ()` to the nameless `js_throw_reference_error_unresolved_get` +//! throw. +//! +//! Root cause: the alias-rewrite block in `lower_new` replaces the callee's +//! `class_name` with the native class's EXPORT name (`Wj4` -> `BlockList`) so +//! the construction path matches the un-aliased form, but the freshly-added +//! (#8688) unresolved-`new` guard then consulted `lookup_native_module` under +//! that rewritten export name — which is not in the registry (it is keyed on +//! the LOCAL import name). None of these classes are reified global builtins, +//! so the guard fired and every command threw `ReferenceError: identifier is +//! not defined` at module init. + +use perry_diagnostics::SourceCache; +use perry_hir::lower_module; +use perry_parser::parse_typescript_with_cache; + +const THROW_HELPER: &str = "js_throw_reference_error_unresolved_get"; + +fn lower_debug(src: &str) -> String { + let src = src.to_string(); + std::thread::Builder::new() + .stack_size(32 * 1024 * 1024) + .spawn(move || { + let mut cache = SourceCache::new(); + let parsed = + parse_typescript_with_cache(&src, "aliased_native_new_resolution.ts", &mut cache) + .expect("parse should succeed"); + let module = lower_module(&parsed.module, "test", "aliased_native_new_resolution.ts") + .expect("lowering should succeed"); + format!("{module:#?}") + }) + .expect("spawn lower thread") + .join() + .expect("lower thread panicked") +} + +#[test] +fn aliased_native_class_import_does_not_lower_to_nameless_throw() { + // Each mirrors a real cli.js 2.1.112 shape from #8730 (BlockList/Wj4 built + // and `.addSubnet`-ed at module init; AsyncLocalStorage/J_z; PassThrough). + let cases = [ + ( + "BlockList", + r#"import { BlockList as Wj4 } from "net"; + const b = new Wj4(); + b.addSubnet("10.0.0.0", 8); + console.log(b.check("10.1.2.3"));"#, + ), + ( + "AsyncLocalStorage", + r#"import { AsyncLocalStorage as J_z } from "async_hooks"; + const s = new J_z(); + console.log(typeof s.run);"#, + ), + ( + "PassThrough", + r#"import { PassThrough as Lrz } from "stream"; + const p = new Lrz(); + console.log(typeof p.pipe);"#, + ), + ]; + + for (label, src) in cases { + let debug = lower_debug(src); + assert!( + !debug.contains(THROW_HELPER), + "aliased native import `{label}` must construct, not throw the nameless \ + ReferenceError at module init:\n{debug}" + ); + } +} + +#[test] +fn unaliased_native_class_import_still_constructs() { + // Control: the un-aliased form was never broken; keep it green so the fix + // is symmetric across aliased/un-aliased native imports. + let debug = lower_debug( + r#"import { BlockList } from "net"; + const b = new BlockList(); + b.addSubnet("10.0.0.0", 8); + console.log(b.check("10.1.2.3"));"#, + ); + assert!( + !debug.contains(THROW_HELPER), + "un-aliased native import must construct, not throw:\n{debug}" + ); +} + +#[test] +fn genuinely_unresolved_new_still_throws() { + // Positive control: the guard must still fire for a `new` on an identifier + // that resolves to no binding at all — the fix must not blanket-suppress it. + let debug = lower_debug(r#"const x = new Totally_Undefined_Constructor_Xyz();"#); + assert!( + debug.contains(THROW_HELPER), + "a genuinely unresolved `new` must still lower to the nameless \ + ReferenceError throw:\n{debug}" + ); +} 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); }