Skip to content

fix(transform): try/catch inside an async generator never caught anything#6331

Open
proggeramlug wants to merge 2 commits into
PerryTS:mainfrom
proggeramlug:fix/async-generator-try-catch
Open

fix(transform): try/catch inside an async generator never caught anything#6331
proggeramlug wants to merge 2 commits into
PerryTS:mainfrom
proggeramlug:fix/async-generator-try-catch

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

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.

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.

The fix

Three gates lift:

  • wrap_dispatch 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 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 48 passed / 0 failed; perry-runtime 1233 passed / 0 failed.

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.

Summary by CodeRabbit

  • Bug Fixes
    • Improved exception and completion routing in async generators so it matches the sync behavior when try/catch (and try+yield) is split across states.
    • Fixed async generator state-machine behavior for yield inside try/catch/finally.
    • Updated .throw() handling so execution resumes through the normal continuation path after a caught exception.

…hing

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
(PerryTS#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.
@coderabbitai

coderabbitai Bot commented Jul 13, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 4cf02d21-1355-449a-ad98-4a4948bea7a6

📥 Commits

Reviewing files that changed from the base of the PR and between a3662af and 27fecc8.

📒 Files selected for processing (1)
  • crates/perry-transform/src/generator/lower.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • crates/perry-transform/src/generator/lower.rs

📝 Walkthrough

Walkthrough

Async generator lowering now applies completion resume checks and dispatch-loop exception routing to async generators, and changes .throw() handling to continue through subsequent state-machine states after catches are linearized.

Changes

Async generator exception routing

Layer / File(s) Summary
Completion and dispatch routing
crates/perry-transform/src/generator/lower.rs
Yielding finally states now receive pending-completion resume checks, and async generators can wrap state dispatch in try/catch routing.
Throw and catch continuation
crates/perry-transform/src/generator/lower.rs
Async generator .throw() handling continues through the state machine after catches lifted into separate states.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant AsyncGenerator
  participant StateDispatchLoop
  participant CatchOrFinallyState
  AsyncGenerator->>StateDispatchLoop: throw completion
  StateDispatchLoop->>CatchOrFinallyState: route exception
  CatchOrFinallyState->>StateDispatchLoop: continue after catch or finally
Loading

Possibly related PRs

  • PerryTS/perry#5286: Modifies async-generator catch-route continuation in the same lowering module.
  • PerryTS/perry#5761: Adjusts async-generator abrupt completion and .throw() routing, including delegated yield* handling.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning It covers the bug, cause, fix, and validation, but it misses the template's Changes, Related issue, and Test plan sections. Add the missing template sections: Changes bullets, Related issue (or n/a), Test plan with commands/checks, and the checklist items.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and accurately describes the async-generator try/catch fix.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
crates/perry-transform/src/generator/lower.rs (1)

211-215: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Update comments that now describe the async path incorrectly.

Lines 150-151 still say async generators never set pending completion state, and Lines 369-374 say only sync .throw() uses the continuation loop. Both are invalid after these changes.

Proposed documentation update
-    // check. (Sync generators only — async never sets these, so the appended
-    // completion checks are inert on the async path.)
+    // check. Both sync and async generators use these when abrupt completion
+    // is routed through a yielding finally.

-    // instead of returning {value: undefined, done: false} and deferring to
-    // the next .next(). Only the sync-generator .throw() path uses this.
+    // instead of returning {value: undefined, done: false} and deferring to
+    // the next .next(). Async generators use this too when catch bodies were
+    // linearized into separate states.

Also applies to: 875-885

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-transform/src/generator/lower.rs` around lines 211 - 215, Update
the comments around the async generator handling in the lowering logic,
including the descriptions near the pending completion state, continuation loop,
and the additional locations around the async throw path. Remove claims that
async generators never set pending completion state or that only sync .throw()
uses the continuation loop, and document the shared behavior accurately without
changing implementation logic.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@crates/perry-transform/src/generator/lower.rs`:
- Around line 211-215: Update the comments around the async generator handling
in the lowering logic, including the descriptions near the pending completion
state, continuation loop, and the additional locations around the async throw
path. Remove claims that async generators never set pending completion state or
that only sync .throw() uses the continuation loop, and document the shared
behavior accurately without changing implementation logic.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 1d80999d-d9f8-428b-8609-d3b14cd19038

📥 Commits

Reviewing files that changed from the base of the PR and between 2c32585 and a3662af.

📒 Files selected for processing (1)
  • crates/perry-transform/src/generator/lower.rs

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant