Skip to content
Merged
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.
1 change: 1 addition & 0 deletions changelog.d/8730-aliased-native-class-new-resolution.md
Original file line number Diff line number Diff line change
@@ -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.
14 changes: 14 additions & 0 deletions crates/perry-hir/src/lower/expr_new.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand Down
101 changes: 101 additions & 0 deletions crates/perry-hir/tests/aliased_native_new_resolution.rs
Original file line number Diff line number Diff line change
@@ -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 <alias>()` 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}"
);
}
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