Skip to content

fix(async): linearize await inside an async-generator finally; fix aliased native-class new - #8739

Merged
proggeramlug merged 1 commit into
mainfrom
merge/b29
Aug 24, 2026
Merged

fix(async): linearize await inside an async-generator finally; fix aliased native-class new#8739
proggeramlug merged 1 commit into
mainfrom
merge/b29

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Lands #8736 and #8738.

#8736 — linearize await inside an async-generator finally (fixes #8715)

This closes the finally analog of the #8681 await-in-catch deadlock that #8707 fixed — and it is precisely the gap #8707's own new test surfaced when it was rebased onto main, which reported:

raw Expr::Await survived async-generator linearization (would block-wait at runtime):
  ["await-in-finally: 2 raw await(s) survived"]

At the time I held #8707 and flagged that resolving it meant deciding whether finally should route through its entry state the way catch now does. This is that decision, made properly: 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, so a finally await lowers to a real microtask suspend rather than a blocking busy-wait.

async_generator_linearizes_every_await_position passes on the merged result — verified explicitly, since that test is what named the gap.

#8738 — aliased native-class import new no longer throws (fixes #8730)

An aliased ESM named import of a Node built-in class threw ReferenceError: identifier is not defined when constructed at module init:

import { BlockList as Wj4 } from "net";              new Wj4()
import { AsyncLocalStorage as J_z } from "async_hooks"; new J_z()
import { PassThrough as Lrz } from "stream";         new Lrz()

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 with an uncaught ReferenceError.

Validation (on the merged result)

  • All 30 lint-job checkers pass
  • perry-transform --lib: 93 passed, 0 failed
  • perry-hir --lib: 334 passed, 0 failed
  • perry-runtime --lib (RUST_TEST_THREADS=1): 2669 passed, 0 failed
  • perry-codegen --lib: 1222 passed, 0 failed
  • Squashed tree verified identical to the validated tree

Both PRs carried their own changelog.d/ fragments. No version bump.

Summary by CodeRabbit

  • Bug Fixes

    • Fixed async generators becoming unresponsive when await is used inside a finally block during .return() or early loop termination.
    • Ensured asynchronous cleanup continues through microtasks without blocking execution.
    • Fixed aliased imports of native Node.js classes so constructors such as BlockList, AsyncLocalStorage, and PassThrough work correctly.
  • Tests

    • Added coverage for async-generator cleanup scenarios and aliased native class construction.

…iased native-class new

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.
@coderabbitai

coderabbitai Bot commented Aug 24, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 99882508-497d-466f-b008-1712b669b243

📥 Commits

Reviewing files that changed from the base of the PR and between c203c77 and 80fb6c5.

📒 Files selected for processing (6)
  • changelog.d/8715-async-gen-finally-await.md
  • changelog.d/8730-aliased-native-class-new-resolution.md
  • crates/perry-hir/src/lower/expr_new.rs
  • crates/perry-hir/tests/aliased_native_new_resolution.rs
  • crates/perry-transform/src/async_to_generator_tests.rs
  • crates/perry-transform/src/generator/lower.rs

📝 Walkthrough

Walkthrough

The PR fixes async-generator .return() handling for awaits in finally blocks and native-module resolution for aliased Node built-in classes. It adds transform and lowering regression tests plus changelog entries.

Changes

Async-generator finally suspension

Layer / File(s) Summary
Shared async-generator return routing
crates/perry-transform/src/generator/lower.rs, crates/perry-transform/src/async_to_generator_tests.rs, changelog.d/8715-async-gen-finally-await.md
Async-generator .return() now resumes through __agstep. Await expressions in finally blocks suspend asynchronously. Synchronous generators retain local dispatch-loop handling. Tests cover combined try/finally, catch/finally, awaits, and yields.

Aliased native constructor resolution

Layer / File(s) Summary
Native constructor lookup and regression coverage
crates/perry-hir/src/lower/expr_new.rs, crates/perry-hir/tests/aliased_native_new_resolution.rs, changelog.d/8730-aliased-native-class-new-resolution.md
new lowering checks both the rewritten and original constructor identifiers in the native-module registry. Tests cover aliased Node built-in classes, unaliased imports, and unresolved constructors.

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

Sequence Diagram(s)

sequenceDiagram
  participant Caller
  participant AsyncGenerator
  participant __agstep
  participant FinallyAwait
  Caller->>AsyncGenerator: call .return()
  AsyncGenerator->>__agstep: route pending return as non-error resume
  __agstep->>FinallyAwait: execute finally state
  FinallyAwait-->>__agstep: suspend await on microtask queue
  __agstep-->>AsyncGenerator: complete or re-raise pending return
  AsyncGenerator-->>Caller: resolve .return()
Loading
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch merge/b29

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.

proggeramlug added a commit that referenced this pull request Aug 27, 2026
* fix(hir): late-bind `new X()` to a class declared later; name the ReferenceError

Coop's Next.js App Route fixture died at module init on 0.5.1519 with
the nameless `ReferenceError: identifier is not defined`. The identifier
is `SentinelNode` in next/dist/server/lib/lru-cache.js: the CJS wrap
hoists `LRUCache` out of the module IIFE but never sees `SentinelNode`
(its doc comment closes on the `class` line, and the textual hoister
anchors on `class ` at column 0), so the hoisted constructor's
`new SentinelNode()` is lowered before the `__perry_cjs_factory` body
registers the class. The unresolved-`new` guard from #8643 (905017b,
inside the 1516..1519 window) turned that lowering-time miss into an
unconditional nameless throw; before it, the by-name `Expr::New` bound
at codegen through the module class table, which is why 0.5.1516 loaded.

- `pre_scan_class_decl_names` records every class DECLARATION name in
  the module at any depth; the guard keeps the late-bound by-name
  construction for those.
- Any other unresolved constructor is read off `globalThis` when the
  `new` executes (`js_global_get_or_throw_unresolved`, shared with the
  bare-identifier arm via `unresolved_global_get_expr`), so a
  runtime-created global constructs and a true miss throws
  `ReferenceError: <name> is not defined` -- with the identifier, as
  #8730 and #8882 asked. The compile log names it too, with the same
  "unknown identifier" warning the bare-identifier arm prints.

Regression tests: a hoisted class constructing a sibling declared inside
a later closure keeps `New { class_name }` (fails without the new guard
clause, verified); a `typeof`-guarded `new IntersectionObserver()`
lowers to the named runtime lookup; the #8739 positive control now
expects the named form.

Fixes #8882. Refs #8730.

Claude-Session: https://claude.ai/code/session_01UZJbhb2FTuakurTHPAKQgd

* fix(runtime): make the class registries per image so one process can host several apps

Every class-id-keyed table module init writes — vtables, static methods and
accessors, constructors and flags, the parent map and its dense mirror, names,
lengths, registered ids, bind lengths, the extends-Error / DataView /
typed-array marks, the hasInstance / toStringTag hooks, generic-origin and
fetch-parent maps, anon-shape ids — was a process-global static keyed by a
compile-time class id. Class ids come from a small sequential counter in
codegen, so N dlopen'd copies of one application register the SAME ids with
DIFFERENT func_ptrs (each image's own code addresses) into one HashMap, and
insert is last-writer-wins: after the last image's init every class of every
earlier image dispatched into the last image's code, and only the
last-initialised application worked (#8546). No write order over a shared
table works, so the 21 tables move into one ClassImageTables per image.

A thread resolves its image through a perry_thread_local! handle, falling back
to the process-wide primary image. js_gc_init — codegen's first runtime call in
both `main` and `perry_module_init`, on the thread that runs that image's
module init — enters an image: the first thread to enter owns the primary,
every later one gets a fresh image. perry/thread workers and worker_threads
Workers adopt their spawner's image before running anything, because they never
run module init. A thread that neither entered nor adopted (a pump firing JS
for the primary heap, a reactor thread, a libtest thread) uses the primary,
i.e. the process-global table it saw before, so single-image programs are
unchanged. Each former `static RwLock<..>` is a `static ImageTable<RwLock<..>>`
whose read()/write() return the same guard types, so the call sites are
untouched. Latches and VTABLE_GEN stay process-global on purpose.

Tests: two application threads registering the same class id with different
method addresses each dispatch to their own (sabotage-verified: with the enter
made a no-op the last writer wins and the test fails on the func_ptr); a
spawned worker shares its spawner's image while a second application sees
neither; a thread without an image reads the primary.

Claude-Session: https://claude.ai/code/session_01UZJbhb2FTuakurTHPAKQgd

* docs(changelog): fragment for #8893 (per-image class registries, #8546)

Claude-Session: https://claude.ai/code/session_01UZJbhb2FTuakurTHPAKQgd

* perf(codegen): bound TailCallElim's alloca walk on wide statepoint functions

`TailCallElimPass::markTails` walks the transitive SSA uses of every alloca;
only loads/stores and nocapture call arguments stop it. On a
statepoint-rewritten function an alloca handed to any runtime call reaches
the statepoint token, its gc.relocates and, through their gc-live bundles,
every later statepoint, so each walk covers the whole function and the pass
costs allocas x uses. Coop's Next.js route (jsonwebtoken's bundled entry:
400 allocas, 643k post-RS4GC instructions, 3.4k statepoints, 477k
relocates; ~1.6M visited uses per alloca) held one LLVM worker for ~100
CPU-minutes in that walk on a unit whose remaining `-Os` passes take ~16 s.

Before the optimization pipeline runs, estimate the walk as
`allocas x instructions` per function and stamp
`"disable-tail-calls"="true"` on any function over the budget (default
2^26; `PERRY_LL_TRE_MAX_ALLOCA_WALK=<n>` raises/lowers it, `0`/`off`
disables). That attribute is TRE's own early-out, so the function keeps
every other pass at the requested level (#8421); it gives up exactly
tail-recursion-to-loop and sibling-call codegen, and it is not `optnone`
(#8583). The trip is logged with the function's name and factors, and the
knob is a build/object cache input.

Fixes #8883

Claude-Session: https://claude.ai/code/session_01UZJbhb2FTuakurTHPAKQgd

---------

Co-authored-by: Ralph Küpper <ralph@skelpo.com>
Co-authored-by: Ralph Küpper <ralph3@skelpo.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant