fix(async): linearize await inside catch for async fns/closures (#8681) - #8707
fix(async): linearize await inside catch for async fns/closures (#8681)#8707proggeramlug wants to merge 1 commit into
await inside catch for async fns/closures (#8681)#8707Conversation
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (4)
Included review availability: Your plan provides up to 8 included reviews per hour; 0 remain after this review. 📝 WalkthroughWalkthroughThe transform registers async generator methods before transformation, routes linearized catch bodies through async-step states, and adds regression coverage for async closures and generators across control-flow and exception-handling forms. ChangesAsync transformation
Estimated code review effort: 3 (Moderate) | ~25 minutes Merge Risk: ⚪ Minimal · up to This change makes awaits in catch blocks suspend cooperatively instead of blocking, preventing the reported async deadlock while preserving related error-handling and async-generator behavior; no actionable merge-blocking risk remains after normal checks and review. Sequence Diagram(s)sequenceDiagram
participant AsyncStepDriver
participant CatchRoute
participant CatchBody
AsyncStepDriver->>CatchRoute: Match error outside catch states
CatchRoute->>CatchBody: Bind error and enter catch_entry_state
CatchBody-->>AsyncStepDriver: Return for async-step dispatch
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
|
Holding this one, but only just — the fix itself looks right and I'd like to land it as soon as one interaction is resolved. The root-cause writeup is excellent and the diagnosis checks out: The blocker is an interaction with current I checked this carefully before concluding anything:
Your base is quite far back now, which is likely the whole story: Two small things for the same push:
Given this fixes a hard deadlock in the natively-compiled Claude Code |
…erryTS#8681) 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 `PerryTS#4438` B2-finally lowering (a different path from this fix); tracked in PerryTS#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
831a58a to
40d0f6d
Compare
|
Rebased onto current
Ready for the gate set — thanks for the careful review. The |
… worklist (#8716) Lands #8707 and #8667. #8707 (fixes #8681) stops an `await` inside a `catch` compiling to a blocking busy-wait. `build_async_catch_route_body_direct` ignored `catch_entry_state` and re-inlined the catch body through `rewrite_yield_to_await_in_stmts`, turning every catch suspend back into a raw `Expr::Await` that codegen lowers as `js_wait_for_event`. Reached re-entrantly from the async-step cascade -- a rejected awaited promise in a `try` routing to a `catch` that itself awaits, the ordinary stream retry/backoff shape -- that blocking wait monopolises the single runtime thread while the driver that would resolve it sits suspended above it, and the program deadlocks. This is the natively-compiled Claude Code `-p` streaming hang. The revision also closes the `await`-in-`finally` gap its own test caught: a linearized catch now takes the state-based upper bound (`protected_end_state`, which excludes the catch's own states), so an error raised inside the catch routes correctly. The legacy inline path (`catch_entry_state == None`) keeps the bound it always used. #8667 completes the RegExp test262 worklist. Its `Cargo.toml` change is additive -- `regress` gains `features = ["utf16"]`, nothing dropped -- and `cargo check --locked` still succeeds, so the lockfile is current. One fix on top: #8707's new coverage pushed `async_to_generator.rs` to 2722 lines, past the file-size gate. `computed_and_field_async_tests` moved to a sibling file declared with `#[path]`, so the module path and every test name are unchanged (verified by running them). 2722 -> 1806. No version bump. Co-authored-by: Ralph Küpper <ralph@skelpo.com>
|
Landed on The Thanks also for picking up the One fix on top: the new coverage pushed Validated on the merged result: all 30 lint checkers, transform 92/0 (count unchanged across the split), runtime 2657/0, codegen 1214/0. Good catch on this one — a blocking busy-wait reached re-entrantly from the async-step cascade is a nasty failure mode to track down from a hang. |
…iased native-class new (#8739) Lands #8736 and #8738. #8736 (fixes #8715) closes the `finally` analog of the #8681 `await`-in-`catch` deadlock that #8707 fixed. This is the exact gap #8707's own new test surfaced when it was rebased -- it reported "await-in-finally: 2 raw await(s) survived" -- so the two land as a pair. An `await` inside a `finally` of a real `async function*` compiled to a blocking busy-wait rather than an async suspend; the linearizer already splits the finally into its own dispatch states with a `finally_entry_state`, and the async-step driver now routes through them. #8738 (fixes #8730) stops an aliased ESM named import of a Node built-in class throwing `ReferenceError: identifier is not defined` when constructed at module init -- `import { BlockList as Wj4 } from "net"; new Wj4()` and the same shape for `AsyncLocalStorage` and `PassThrough`. `lower_new`'s alias-rewrite block rewrites the callee from the local import name to the class's export name so the construction path matches the un-aliased form that codegen's builtin-`New` dispatch recognizes. This broke the natively-compiled Claude Code cli.js 2.1.112 bundle, which constructs all three at module init, so nearly every command crashed. No version bump. Co-authored-by: Ralph Küpper <ralph@skelpo.com>
Summary
Fixes #8681: an
awaitinside acatchblock of a plain async function/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 atryrouting to acatchthat itself awaits (the ubiquitous stream retry/backoff/cleanup shape) — that blocking wait monopolises the single runtime thread while the async-step driver that would resolve it sits suspended above it on the stack, and the program deadlocks. This is the natively-compiled Claude Code-pstreaming hang (perry_closure __85891: 7 blocking-await sites alongside async-step +for awaitmarkers).Root cause
transform_generatorsalready linearizes the catch body into real dispatch states and recordsCatchRoute.catch_entry_state = Some(..). But the async-step throw handlerbuild_async_catch_route_body_direct(generator/lower/async_step.rs) ignoredcatch_entry_stateand instead inlined a raw clone of the catch body throughrewrite_yield_to_await_in_stmts— turning every catch suspend back into a rawExpr::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+js_await_loop_tick_timers).Fix
crates/perry-transform/src/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,state = catch_entry_state, fall through to the step dispatch loop) instead of inlining the blocking copy — mirroring the sync path'sbuild_abrupt_routing. The linearized catch now 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 withstate_based = true(upper = protected_end_state, which excludes the catch's own states) so athrowraised inside the catch (catch (e) { await x; throw wrap(e) }) escapes to an enclosing handler instead of re-matching the same route. (Without this, throw-after-await-in-catch regressed to a swallowed throw — caught by the behavioral suite before it shipped.)crates/perry-transform/src/generator/mod.rs: record async-generator class methods (async *[Symbol.asyncIterator]()etc.) intoasync_generator_funcs, for parity with the top-levelasync function*/ closure-expression paths.Tests
New differential tests in
async_to_generator.rs:is_asynccleared),Expr::Awaitaftertransform_generators),Expr::Awaitsweep after the async-step closure rewrite — the direct regression guard for this bug (fails without the fix),89/89perry-transformunit tests pass.Separately validated end-to-end: 13 behavioral synthetics compiled with the fixed compiler are byte-identical to Node v26 — basic recover, retry-loop with
awaitin catch, multi-await + rethrow, try/catch/finally all awaiting, async-generator await-in-catch consumed byfor await, microtask interleaving/ordering (b1,a1,b2,a2,b3— proving the catch await now cooperatively suspends rather than blocks), nested try/catch, loop +continuefrom catch, throw-after-await-in-catch, throw-without-await, generatoryields in catch, and.throw()into a running async generator.https://claude.ai/code/session_01TwxRkALrR9HKSF1zKLSTAF
Summary by CodeRabbit
Bug Fixes
awaitinsidecatchblocks after a rejected promise.catchblocks propagate correctly.Tests
awaitexpressions.