From a3662afa8e581729dde889e5e31cd04e345fc02d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 13 Jul 2026 03:25:04 +0200 Subject: [PATCH 1/2] fix(transform): try/catch inside an async generator never caught anything MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In an `async function*`, a `try { … } catch (e) { … }` whose `try` block contains a `yield` never ran its catch. The exception escaped the generator body entirely and surfaced to the consumer as an unhandled rejection: async function* g() { try { yield 1; throw new Error("boom"); } catch (e) { yield "caught:" + e.message; } } for await (const v of g()) console.log(v); // node: 1, caught:boom // perry: 1, then Uncaught (in promise) Error: boom It applied to every way the `try` could fail — a plain `throw`, an exception from a callee, and an `await` of a rejecting promise — and to nested try/catch (the inner catch was skipped too). Sync generators and plain `async` functions were correct; only `async function*` was affected. Cause: when a `try` contains a `yield`, the linearizer must destroy the `Stmt::Try` and re-emit the catch body as its own states (`generator/linearize.rs`), recording a `CatchRoute` whose `catch_entry_state` is reachable *only* by the dispatch loop setting `__gen_state = catch_entry_state`. That routing lives in `wrap_dispatch_loop` — and it was gated off for async generators by a `!is_async_generator` conjunct (#4438 enabled it for sync generators only). So for an async generator the catch states were emitted but nothing could ever jump to them: the throw unwound past the state loop to the resume-body handler, which force-completes the generator and rejects. That is exactly the observed symptom. The trigger is the *presence* of a `yield` in the try, not its execution — a `try` with no `yield` keeps its native `Stmt::Try` and worked, which is why this hid for so long. Three gates lift: - `wrap_dispatch` (lower.rs) no longer excludes async generators, so a throw executing inside a protected region during `.next()` dispatch routes to the matching catch's states. - the completion-resume check on a yielding `finally` no longer excludes async generators. It was dead code while nothing routed into those finallys; now that throws do, a pending throw parked for a yielding `finally` has to be re-raised after the finally body or it would be silently swallowed (the hazard called out in `lower/abrupt.rs`). - `throw_continuation` is now `Some(..)` for async generators as well. The legacy async path inlined the catch body into the `.throw()` closure, which is empty once the linearizer has moved that body into states — so `gen.throw(e)` resolved to `{value: undefined, done: false}` instead of the value the catch yields. Validation: a node-vs-perry differential over 14 generator shapes is byte-identical where 4 of them previously diverged — catch after yield; catch of an awaited rejection; yielding `finally` with the throw escaping; catch-rethrow through a yielding `finally`; `return` through a yielding `finally`; throw inside a loop body's try; nested catch + rethrow to an outer catch; optional catch binding; consumer-driven `gen.throw()` routed into the catch; early `break` (the `return()` path); plus sync generator try/catch/finally and return-through-finally as regression anchors. perry-transform / perry-hir / perry-runtime suites pass (the one failing test, `every_dispatch_entry_has_manifest_counterpart` for `ws::readyState`, fails identically on a clean tree — pre-existing manifest drift, unrelated). Found on a large esbuild-bundled CLI app whose request loop is an async generator: its `catch` classified API errors, so with the catch dead every API error escaped the loop and the app reported a generic execution failure instead of the specific, actionable message Node prints. --- crates/perry-transform/src/generator/lower.rs | 55 ++++++++++++------- 1 file changed, 35 insertions(+), 20 deletions(-) diff --git a/crates/perry-transform/src/generator/lower.rs b/crates/perry-transform/src/generator/lower.rs index e910100e89..273b16d97a 100644 --- a/crates/perry-transform/src/generator/lower.rs +++ b/crates/perry-transform/src/generator/lower.rs @@ -206,9 +206,13 @@ pub fn transform_generator_function_with_extra_captures( // finally's completion-check state. After the finally body runs (on either // the happy path or an abrupt completion routed into it), re-raise a pending // throw/return; on the normal path (pending_type == 0) it's inert and the - // state falls through to post-finally. Sync only in practice — async never - // sets `pending_type`, so the checks are dead on the async path. - if !is_async_generator { + // state falls through to post-finally. + // + // Async generators need this for the same reason sync ones do: now that the + // dispatch loop routes body-internal throws (below), a throw routed into a + // yielding finally must be re-raised after the finally body — otherwise it is + // silently swallowed. + { let resume = build_completion_resume_stmts(pending_type_id, pending_value_id, done_id); for route in &finallys { if let Some(cc) = route.completion_check_state { @@ -372,16 +376,25 @@ pub fn transform_generator_function_with_extra_captures( // when it routes into a yielding finally (so the finally's `yield`s suspend). let while_body_for_return = while_body.clone(); - // #4438: for sync generators, wrap each state-dispatch loop body in a real - // try/catch so a `throw` *executing inside a try block during dispatch* is - // caught and routed to the matching catch/finally (or runs pending finally + - // completes the generator when unhandled). This applies to the `.next()` - // loop AND the `.throw()`/`.return()` continuation loops — e.g. a `catch` - // that rethrows must still run a non-yielding `finally` on the way out. + // #4438: wrap each state-dispatch loop body in a real try/catch so a `throw` + // *executing inside a try block during dispatch* is caught and routed to the + // matching catch/finally (or runs pending finally + completes the generator + // when unhandled). This applies to the `.next()` loop AND the + // `.throw()`/`.return()` continuation loops — e.g. a `catch` that rethrows + // must still run a non-yielding `finally` on the way out. + // + // Async generators need this exactly as much as sync ones. When a `try` + // contains a `yield`, the linearizer destroys the `Stmt::Try` and re-emits the + // catch body as its own states, reachable only by the dispatch handler setting + // `__gen_state = catch_entry_state`. Gating the wrapper off for async + // generators therefore left those states unreachable: every throw inside such a + // `try` unwound past the state loop to the resume-body handler, which force- + // completes the generator and rejects — i.e. `try {} catch {}` in an + // `async function*` never caught anything. let has_state_based_catch = catches.iter().any(|r| r.catch_entry_state.is_some()); let has_inlineable_finally = finallys.iter().any(|r| !r.has_yields); - let wrap_dispatch = !is_async_generator - && (has_state_based_catch || has_inlineable_finally || has_yielding_finally); + let wrap_dispatch = + has_state_based_catch || has_inlineable_finally || has_yielding_finally; let dispatch_body = if wrap_dispatch { let disp_err_id = alloc_local(next_local_id); wrap_dispatch_loop( @@ -859,15 +872,17 @@ pub fn transform_generator_function_with_extra_captures( &hoisted_ids, next_local_id, ); - // #4374: sync generators continue the state machine after a catch - // (running the inlined finally + reaching the next yield/completion); - // async generators keep the existing deferred-resume behavior to stay - // byte-identical on the async path. - let throw_continuation = if is_async_generator { - None - } else { - Some(while_body_for_throw) - }; + // #4374: continue the state machine after a catch — run the inlined + // finally and reach the next yield/completion within the `.throw()` call. + // + // Async generators took the legacy deferred-resume path here, which inlines + // the catch body into the `.throw()` closure. That only works when the catch + // body is still inline; once the `try` contains a `yield` the linearizer has + // moved the catch into its own states, so the inlined copy had nothing to run + // and `gen.throw(e)` resolved to `{value: undefined, done: false}` instead of + // the value the catch yields. Routing to the catch's states (as sync + // generators do) is what Node's semantics require. + let throw_continuation = Some(while_body_for_throw); // #4374: fresh binding for the inner catch that re-runs a try's finally // when its catch handler itself throws (catch-rethrow-with-finally). let inner_catch_id = alloc_local(next_local_id); From 27fecc8527fdf2d7fe7a4587a4051988f9375bc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 13 Jul 2026 08:46:45 +0200 Subject: [PATCH 2/2] style: cargo fmt (lint gate) --- crates/perry-transform/src/generator/lower.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/perry-transform/src/generator/lower.rs b/crates/perry-transform/src/generator/lower.rs index 273b16d97a..091a1db744 100644 --- a/crates/perry-transform/src/generator/lower.rs +++ b/crates/perry-transform/src/generator/lower.rs @@ -393,8 +393,7 @@ pub fn transform_generator_function_with_extra_captures( // `async function*` never caught anything. let has_state_based_catch = catches.iter().any(|r| r.catch_entry_state.is_some()); let has_inlineable_finally = finallys.iter().any(|r| !r.has_yields); - let wrap_dispatch = - has_state_based_catch || has_inlineable_finally || has_yielding_finally; + let wrap_dispatch = has_state_based_catch || has_inlineable_finally || has_yielding_finally; let dispatch_body = if wrap_dispatch { let disp_err_id = alloc_local(next_local_id); wrap_dispatch_loop(