Skip to content

fix(runtime): route AsyncLocalStorage super() through any bound-export heritage shape - #10634

Open
proggeramlug wants to merge 2 commits into
mainfrom
fix/10625-asynclocalstorage-heritage
Open

proggeramlug wants to merge 2 commits into
mainfrom
fix/10625-asynclocalstorage-heritage

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

Summary

class X extends AsyncLocalStorage threw Class constructor AsyncLocalStorage cannot be invoked without 'new' at super() for every heritage shape
except a bare import { AsyncLocalStorage } from "node:async_hooks"
binding — the same defect #10621 fixed for AsyncResource (#10453). A local
alias, a namespace member, a default import, and a CJS destructured
require() all reach the identical bound native export value the bare
import does, but only that shape was recognized statically.

Root cause

Only the canonical bare-import heritage name is recognized statically at
HIR-lowering time (crates/perry-hir/src/lower_decl/class_decl.rs:404-406),
which routes to perry-stdlib's js_async_local_storage_subclass_init via a
codegen-declared extern symbol
(crates/perry-codegen/src/expr/this_super_call.rs:337-342, :1002-1006).
Every other heritage shape (local alias, namespace member, default import,
CJS require()) can't be resolved by name at lowering time, so super()
falls through at RUNTIME to js_fetch_or_value_super
(crates/perry-runtime/src/object/global_this/fetch_globals.rs), which
previously only special-cased WASI and did a plain CALL of the bound native
export for everything else — and AsyncLocalStorage's constructor throws by
design when called without new.

The design decision (per the issue)

Two shapes were available to make the fix reachable from
js_fetch_or_value_super (which lives in perry-runtime):

  1. Move js_async_local_storage_subclass_init down into perry-runtime.
  2. Introduce a registration-hook indirection perry-stdlib installs at
    startup.

Chose (2). js_async_local_storage_subclass_init
(crates/perry-stdlib/src/async_local_storage.rs:129) depends on
perry-stdlib's own Handle registry (get_handle_mut/register_handle)
and crate::common::dispatch::unbound_async_local_storage_method to wire up
the run/getStore/enterWith/exit/disable methods on the subclass
instance — moving it would mean either relocating that registry too or
duplicating it, which is exactly the kind of architectural change the fix
guidance says to avoid for a bug this narrow. Meanwhile the registration-hook
pattern already exists in this exact spot for this exact reason:
JS_NATIVE_ASYNC_HOOKS_CONSTRUCT / js_set_native_async_hooks_construct
(crates/perry-runtime/src/value/{tags,handle}.rs) is the identical
mechanism, registered by perry-stdlib at startup so perry-runtime's new
dispatcher (crates/perry-runtime/src/object/class_registry/construct.rs)
can reach a stdlib-side AsyncLocalStorage/AsyncResource constructor
without depending on perry-stdlib. JS_NATIVE_EVENTS_CONSTRUCT is the same
pattern for events.EventEmitter. I added
JS_NATIVE_ASYNC_LOCAL_STORAGE_SUBCLASS_INIT /
js_set_native_async_local_storage_subclass_init, matching that precedent
exactly (same AtomicPtr<()> + setter + null-check-before-dispatch shape),
and js_fetch_or_value_super now routes ("async_hooks", "AsyncLocalStorage") through it, mirroring the existing WASI arm.

bound_native_callable_module_and_method (the function that resolves a
runtime value back to its (module, method) bound-native-export identity)
needed no changes — it already generically resolves any bound native
export, independent of how the heritage expression reached it (bare import,
alias, namespace member, CJS destructure); only the per-consumer match arm
in js_fetch_or_value_super was missing for AsyncLocalStorage. I also
switched the existing WASI-arm's wasi_parent.is_some_and(...) (which
consumed the Option) to bound_native_parent.as_ref().is_some_and(...),
since the resolved value now has two consumers.

Tests

test-files/test_gap_10625_asynclocalstorage_heritage.ts (+
gap_10625_asynclocalstorage_heritage_helper.cjs) covers, for each shape, a
real run()/getStore() round-trip through the subclass instance (not just
that construction doesn't throw) — including a nested run() call and
instanceof/method-typeof checks:

  • canonical import { AsyncLocalStorage } (control — already worked)
  • local alias (const Alias = AsyncLocalStorage)
  • namespace member (import * as ah, ah.AsyncLocalStorage)
  • default import (import ahDefault from "node:async_hooks",
    ahDefault.AsyncLocalStorage)
  • CJS destructured require()
  • CJS namespace member on require() result

Proof it fails without the fix: built a pristine clone at main
(0058babd83, no fix), ran the new gap test —
TS extends AsyncLocalStorage (import) passed, all five other shapes threw
Class constructor AsyncLocalStorage cannot be invoked without 'new'.
Confirmed the fix makes all six pass (100% parity), and re-confirmed the
--release issue_806 integration failure below is unaffected by stashing
the fix and rebuilding — it reproduces identically on unmodified main.

Validation

  • cargo test --release (RUST_TEST_THREADS=1 for perry-runtime):
    • perry-runtime: 3998 passed / 2 failed / 4 ignored. Both failures
      (gc::tests::copy_slot_decode::sabotaged_remembering_arm_is_refused_by_the_coverage_cross_check,
      gc::tests::heap_generation::a_free_or_move_outside_every_scope_is_caught_in_debug_builds)
      are debug_assert!-dependent and compiled out under --release
      (documented in CLAUDE.md); both pass under cargo test --profile gcaudit (release codegen + debug assertions). Unrelated to this
      change — neither test touches value/, object/global_this/, or
      async_hooks/async_local_storage.
    • perry-stdlib: 139 passed / 0 failed (lib), all integration suites 0
      tests (no stdlib-crate integration tests exist for this area).
  • Lint (./scripts/run_lint_gates.sh, full, SKIP_COMPILE_GATES not
    set): 82/83 gates passed; pre-existing red: [Public benchmark evidence freshness] python3 benchmarks/ci_public_baseline_check.py — red
    on every PR in this repo, not mine to fix.
  • Gap suite (scoped, PERRY_SKIP_BUILD=1 ./run_parity_tests.sh --filter <name>): new test 100% parity; re-ran
    test_async_local_storage_context, test_gap_als_run_spread_args,
    test_gap_asynchooks_3089_3090_3091_3093, test_harness_async_context,
    test_parity_async_context, test_parity_async_hooks,
    test_parity_async_local_storage — all 100% parity, no regressions.
    Also ran crates/perry/tests/issue_5587_temporal_subclass.rs (6/6
    passed, same js_fetch_or_value_super codepath) and
    crates/perry/tests/issue_806_default_derived_ctor_forwarding.rs (5/6
    passed — the 1 failure, walk_into_rest_param_capturing_ctor, reproduces
    identically with my changes stashed against unmodified main; unrelated
    rest-param/mixin-ctor capture bug, pre-existing).
  • Performance (perf stat -e instructions,task-clock, 3 runs each,
    pristine-main binary vs fixed binary, both linked with
    PERRY_NO_AUTO_OPTIMIZE=1 against their own prebuilt archives):
    • Control probe (200k iterations of new on a class extends GlobalResponse alias — exercises the exact same
      js_fetch_or_value_super codepath my new arm adds a check to, but
      isn't itself AsyncLocalStorage): baseline avg 6,440.68M
      instructions vs fixed avg 6,434.41M instructions — no
      regression
      (fixed is ~0.10% lower, within noise).
    • New-capability probe (200k iterations of new ViaAlias() +
      run()/getStore(), the actual bug's hot path, previously threw):
      fixed avg 10,489.10M instructions, ~804ms task-clock. Node
      (node --experimental-strip-types, scaled) took 15.8s for 20k
      iterations of the equivalent workload — Perry is roughly 200x
      faster here, nowhere near the 20%-slower floor.

Not verified

  • Didn't run the full local gap suite (per validation guidance, scoped to
    plausibly-affected tests since this change is narrowly scoped to one
    runtime dispatch arm + one registration hook); leaving the 6 CI gap-suite
    shards as the broader gate.
  • Didn't investigate the pre-existing issue_806 rest-param/mixin-ctor
    failure or the two debug_assert!-only perry-runtime test failures —
    both confirmed pre-existing and unrelated, out of scope for this PR.

Fixes #10625

Summary by CodeRabbit

  • Bug Fixes

    • Fixed subclassing of AsyncLocalStorage when accessed through aliases, namespace or default imports, and CommonJS destructuring.
    • Ensured subclass instances correctly support inherited methods, instanceof, and async context propagation.
  • Tests

    • Added coverage for AsyncLocalStorage subclassing across supported import and require patterns.

@proggeramlug proggeramlug added the package-audit Found by the 2026 package audit: compiling real npm packages from source instead of native bindings label Sep 18, 2026
proggeramlug pushed a commit that referenced this pull request Sep 18, 2026
@coderabbitai

coderabbitai Bot commented Sep 18, 2026

Copy link
Copy Markdown

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 051ec9d0-c5a5-4427-99d1-78d1c68f799b

📥 Commits

Reviewing files that changed from the base of the PR and between 7dc50de and 926b820.

📒 Files selected for processing (2)
  • crates/perry-runtime/src/lib.rs
  • crates/perry-runtime/src/object/global_this/fetch_globals.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • crates/perry-runtime/src/lib.rs

Included review availability: Your plan provides up to 8 included reviews per hour; 5 remain after this review.


📝 Walkthrough

Walkthrough

Changes

AsyncLocalStorage heritage dispatch

Layer / File(s) Summary
Hook contract and registration
crates/perry-runtime/src/value/tags.rs, crates/perry-runtime/src/value/handle.rs, crates/perry-runtime/src/value/mod.rs, crates/perry-runtime/src/lib.rs, crates/perry-stdlib/src/common/dispatch/init.rs
The runtime exposes an atomic subclass-init hook and setter. The stdlib registers js_async_local_storage_subclass_init during dispatch initialization.
Resolved heritage dispatch
crates/perry-runtime/src/object/global_this/fetch_globals.rs
js_fetch_or_value_super invokes the registered hook for indirect AsyncLocalStorage heritage.
Import and require regression coverage
test-files/gap_10625_asynclocalstorage_heritage_helper.cjs, test-files/test_gap_10625_asynclocalstorage_heritage.ts, changelog.d/10634-asynclocalstorage-heritage.md
Tests cover direct, aliased, namespace, default-import, destructured require(), and namespace-member forms. The changelog documents the runtime hook and behavior.

Priority: ⬇️ Low

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

Change: Bug fix

Sequence Diagram(s)

sequenceDiagram
  participant Subclass
  participant js_fetch_or_value_super
  participant RuntimeHook
  participant PerryStdlib
  Subclass->>js_fetch_or_value_super: call super()
  js_fetch_or_value_super->>RuntimeHook: resolve AsyncLocalStorage hook
  RuntimeHook->>PerryStdlib: invoke js_async_local_storage_subclass_init
  PerryStdlib-->>Subclass: return initialized subclass instance
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 6 functions across 8 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely identifies the main runtime fix for AsyncLocalStorage heritage handling.
Description check ✅ Passed The description provides a detailed summary, root cause, design decision, related issue, tests, validation results, performance data, and known unrelated failures. It does not use every template headi…
Linked Issues check ✅ Passed Issue #10625 requires indirect AsyncLocalStorage heritage support and a cross-crate path to the perry-stdlib initializer. The PR adds bound-export detection for `("async_hooks", "AsyncLocalStorage")…
Out of Scope Changes check ✅ Passed The changes remain within Issue #10625. Runtime changes provide the required detection and registration indirection. The perry-stdlib change installs the callback. The dispatch update supports the new…
  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 2
📝 Generate docstrings 💡
  • Commit to this branch
  • Create a new PR
🛠️ Fix failing CI checks 💡
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR

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

Copy link
Copy Markdown
Contributor Author

Held out of merge train 222 — this needs a rebase onto current main, and so does its sibling #10649. Both conflict on the same file for the same reason.

18f93a8055 ("route AsyncResource super() through the bound-export value, not just the bare-import name") landed on main in train 220, and it rewrote the same block in crates/perry-runtime/src/object/global_this/fetch_globals.rs that this PR edits. Three conflict hunks, all in that file:

  1. The // Resolve the parent to a bound native-module export VALUE... doc comment — pure rewrap, main's version also documents the .as_ref() behaviour.
  2. The wasi arm: main now has normalize_native_module_alias(module.as_str()) == "wasi" && method.as_str() == "WASI", this branch has normalize_native_module_alias(module) == "wasi" && method == "WASI". That's a real type difference from main's .as_ref() change, not a text conflict — resolving it by picking a side will compile but may not be what you meant.
  3. The #10453 explanatory comment block.

I'd rather you took this than resolve it here: you have the series context, and hunk 2 is exactly the shape where a mechanical resolution produces something that builds and is wrong.

Rebase fix/10625-asynclocalstorage-heritage onto origin/main (currently 4715bc2fa1, v0.5.1598) and force-push; same for #10649's wip/10448-stream-subclass-heritage, which hits the identical conflict. Re-request after and I'll take both in the next train.

Worth knowing for the rebase: your warnings and cargo-test reds are not yours. Main's cargo check --all-targets was broken from 2026-09-18 until #10682 landed at 03:55Z today (#10655 — two ImportedClass test initializers missing constructor_has_synthetic_arguments). Every PR based before that shows those two red. The lint and gap-suite (5) reds are main's too and tracked in #10707 — a stale public benchmark baseline and a test_gap_10430_stream_module_constructor parity regression, both being worked. Rebasing clears the first pair; the other two will stay red until #10707 closes.

Ralph Küpper added 2 commits September 19, 2026 10:43
…t heritage shape

class X extends AsyncLocalStorage threw "Class constructor
AsyncLocalStorage cannot be invoked without 'new'" at super() for every
heritage shape except a bare import { AsyncLocalStorage } from
"node:async_hooks" binding -- the same defect #10621 fixed for
AsyncResource (#10453). A local alias, a namespace member, a default
import, and a CJS destructured require() all reach the identical bound
native export value the bare import does, but only that shape is
recognized statically at HIR-lowering time
(crates/perry-hir/src/lower_decl/class_decl.rs), which routes to
perry-stdlib's js_async_local_storage_subclass_init via a
codegen-declared extern symbol. Every other shape fell through
js_fetch_or_value_super to a plain CALL of the bound export.

Unlike AsyncResource, whose implementation lives entirely in
perry-runtime, AsyncLocalStorage's subclass-init helper lives in
perry-stdlib (it needs the stdlib Handle registry), and perry-runtime
cannot depend on perry-stdlib. Route through a registration hook
perry-stdlib installs at startup (JS_NATIVE_ASYNC_LOCAL_STORAGE_SUBCLASS_INIT),
matching the existing JS_NATIVE_ASYNC_HOOKS_CONSTRUCT /
JS_NATIVE_EVENTS_CONSTRUCT pattern already used for this exact kind of
cross-crate reach.

Adds test_gap_10625_asynclocalstorage_heritage.ts covering the canonical
import (control), local alias, namespace member, default import, and
two CJS require() shapes, asserting a real run()/getStore() round-trip
through the subclass -- not just that construction doesn't throw.
@proggeramlug
proggeramlug force-pushed the fix/10625-asynclocalstorage-heritage branch from 7dc50de to 926b820 Compare September 19, 2026 11:07
proggeramlug pushed a commit that referenced this pull request Sep 19, 2026
…eritage shape

Generalizes js_fetch_or_value_super (crates/perry-runtime/src/object/global_this/fetch_globals.rs)
to recognize Readable/Writable/Duplex/Transform reached through a local alias, namespace member,
indirect subclass, or CJS destructured require('stream') -- the same pattern #10621/#10634 already
fixed for AsyncResource/AsyncLocalStorage. PassThrough is deliberately left unhandled (separate,
deeper HIR-level gap; see code comment).

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

Labels

package-audit Found by the 2026 package audit: compiling real npm packages from source instead of native bindings

Projects

None yet

1 participant