Skip to content

fix(runtime): AsyncResource super() via any bound-export heritage shape, not just bare import - #10621

Open
proggeramlug wants to merge 2 commits into
mainfrom
fix/10453-asyncresource-heritage
Open

proggeramlug wants to merge 2 commits into
mainfrom
fix/10453-asyncresource-heritage

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

Summary

class X extends AsyncResource threw Class constructor AsyncResource cannot be invoked without 'new' at super() for every heritage shape
except a bare import { AsyncResource } from "node:async_hooks" binding. A
local alias (const Alias = AsyncResource), a namespace member
(ah.AsyncResource), and a CJS destructured require('node:async_hooks')
all threw the same way. undici uses exactly the CJS destructured shape in
every API handler (lib/api/api-request.js etc.: const { AsyncResource } = require('node:async_hooks'); class RequestHandler extends AsyncResource),
so undici.request() and the Agent/Pool/Client request methods all
failed.

Root cause

canonical_native_parent_name (crates/perry-hir/src/lower_decl/class_decl.rs:10-24)
recognizes a native-module parent (including async_hooks's
AsyncResource/AsyncLocalStorage) only when the heritage Ident is a
bare import binding. That is the only shape that reaches the dedicated
static codegen (crates/perry-codegen/src/expr/this_super_call.rs:305-355),
which calls js_async_resource_subclass_init directly. Every other shape —
a local alias, a namespace member, a CJS destructured require() — is
either locally_shadowed or a member expression, so it takes the dynamic
extends_expr path instead: super() resolves through
js_fetch_or_value_super
(crates/perry-runtime/src/object/global_this/fetch_globals.rs:642), which
had no recognition arm for async_hooks classes and fell through to a
plain CALL of the bound native export — and AsyncResource throws by
design when called without new
(crates/perry-runtime/src/object/native_module_dispatch/dispatch_a_c.rs:155-167).

The fix doesn't need to catch every syntactic aliasing shape at
HIR-lowering time (which canonical_native_parent_name already tries, and
inherently can't cover every case — a runtime alias like
obj[key] = AsyncResource; class X extends obj[key] has no static name at
all). js_fetch_or_value_super already has a general mechanism for this
kind of problem: bound_native_callable_module_and_method inspects the
value reaching super() — a bound-native-export closure carrying its
originating module/method name as captures — which is identical regardless
of how the heritage expression reached it. The existing WASI arm already
uses this to recognize class X extends anyAliasOf(WASI) generally. This
PR adds the same recognition for async_hooks's AsyncResource.

The fix

crates/perry-runtime/src/object/global_this/fetch_globals.rs:
generalized the existing wasi_parent local (renamed
bound_native_parent, .as_ref() instead of consuming) and added a new arm
right after the WASI one: when the resolved (module, method) is
("async_hooks", "AsyncResource"), pull type/options from the raw args
and call js_async_resource_subclass_init — the exact same runtime
function the canonical bare-import path already uses — instead of falling
through to the plain-call dispatch that throws.

AsyncLocalStorage is very likely affected the same way for the same
reason (untested — see "Not verified" below): its subclass-init helper
(js_async_local_storage_subclass_init) lives in perry-stdlib, not
perry-runtime, and perry-runtime cannot depend on perry-stdlib (the
dependency only goes the other way — perry-stdlib is sometimes a
separate dylib image in app-only deployments, per the comment at the top of
crates/perry-stdlib/src/async_local_storage.rs). Fixing it the same way
would need either a cross-crate FFI declaration (risky under a
dylib-provider deployment) or moving/duplicating the init helper — that's
more than this PR's surgical fix, so it's left out. Filing a follow-up if
the maintainer agrees it's worth a dedicated issue.

Tests

New gap test test-files/test_gap_10453_asyncresource_heritage.ts +
test-files/gap_10453_asyncresource_heritage_helper.cjs (the exact
undici-shaped CJS destructured require()), covering: bare import
(already worked, kept as a control), local alias, namespace member, CJS
destructured require() with a plain super(), and the same inside a
try/catch. Node oracle (26.5.1) prints "ok" for all six; on the
pristine main baseline (0058babd83), five of six throw:

TS  extends AsyncResource (import)   ok function number true
TS  extends Alias                    threw: Class constructor AsyncResource cannot be invoked without 'new'
TS  extends ah.AsyncResource         threw: Class constructor AsyncResource cannot be invoked without 'new'
CJS destructured require, super()    threw: Class constructor AsyncResource cannot be invoked without 'new'
CJS destructured require, try{super} threw: Class constructor AsyncResource cannot be invoked without 'new'
CJS namespace member export          threw: Class constructor AsyncResource cannot be invoked without 'new'

This branch's binary matches Node byte-for-byte on all six lines, and
PERRY_SKIP_BUILD=1 ./run_parity_tests.sh --filter test_gap_10453_asyncresource_heritage reports PASS.

I found one more, unrelated pre-existing gap while writing the repro and
removed it from the committed test rather than folding it in: class NoCtor extends AsyncResource {} with new NoCtor("X") (the implicit default
derived constructor forwarding super(...args)) throws The "type" argument must be of type string. Received undefined on both the
baseline and this fix — the implicit derived constructor for a native base
doesn't forward its arguments to super(). Not touched here.

Validation

  • cargo test --release -p perry-runtime --tests (RUST_TEST_THREADS=1):
    3998 passed, 2 failed. Both failures are pre-existing and unrelated —
    confirmed by running the identical two tests against the pristine
    baseline (0058babd83), where they fail identically:
    gc::tests::copy_slot_decode::sabotaged_remembering_arm_is_refused_by_the_coverage_cross_check
    and
    gc::tests::heap_generation::a_free_or_move_outside_every_scope_is_caught_in_debug_builds.
    Both assert debug_assert!-only behavior, which is compiled out under
    --release (see CLAUDE.md's "Verifying a runtime change" note) — they'd
    need --profile gcaudit to actually exercise the assertion. Neither
    touches anything near fetch_globals.rs.
  • Lint (SKIP_COMPILE_GATES=1 ./scripts/run_lint_gates.sh): 76 of 77
    passed (compile tier skipped per the box's standing instructions). The
    one red, [Public benchmark evidence freshness] python3 benchmarks/ci_public_baseline_check.py, is the documented pre-existing
    red on every PR in this repo (CLAUDE.md, PR step 6) — reproduced
    identically on the pristine baseline.
  • Gap suite: ran only the new test plus check_test_registration.py
    (both above); did not run the full local suite (this change is scoped to
    one async_hooks-specific dispatch arm, not a hot lowering/runtime path
    used by most programs). Deferring the broader gap-suite gate to CI's
    6-shard run per the box's standing instructions.
  • Performance (perf stat -e instructions,task-clock, 3 runs each,
    200k-iteration loops, baseline = pristine 0058babd83):
    • Regression check — general dynamic-parent super() dispatch that does
      not match wasi/async_hooks (the shared fallthrough path this
      fix adds one check ahead of): baseline ≈1.896B instructions avg, this
      branch ≈1.897B avg — a ~0.07% difference, noise.
    • New capability — class X extends Alias (Alias = AsyncResource)
      constructed 200k times: throws immediately on baseline (no instruction
      count to compare), ≈47.9B instructions on this branch. For scale, the
      already-working canonical bare-import shape doing the identical
      200k-construction workload costs ≈37.0B instructions on the pristine
      baseline — so the dynamic-dispatch path this PR adds costs roughly
      25–30% more instructions than the already-existing static path for the
      same work, which is the expected, inherent cost of resolving the bound
      export value dynamically rather than statically; it is not a
      regression (this exact program did not run at all before). Node wall
      time for the same 200k-construction workload: 0.137–0.143s, for
      context only (instruction counts are the load-bearing comparison on
      this shared host).
  • Package check: did not re-run undici itself — it isn't installed in
    this clone and there's no existing perry.compilePackages wiring for it,
    so a real package-level compile wasn't cheap to set up. The CJS
    destructured-require shape in the new gap test is copied verbatim from
    undici's actual usage pattern (per the issue body), so I'm confident
    this closes undici's specific blocker, but I haven't run undici's own
    test suite against this build.

Not verified

  • AsyncLocalStorage through the same non-canonical heritage shapes (see
    "The fix" above for why it's out of scope here).
  • undici's own package-level compile/test suite.
  • The full local gap suite (scoped run only; deferring to CI's shards).

Fixes #10453

Summary by CodeRabbit

  • Bug Fixes

    • Fixed an error that prevented classes from extending AsyncResource when it was referenced through aliases, namespace members, or CommonJS imports.
    • AsyncResource subclasses now initialize correctly across supported import and inheritance patterns.
  • Tests

    • Added coverage for direct, aliased, namespace-based, and CommonJS AsyncResource inheritance scenarios.

…lue, not just the bare-import name

class X extends AsyncResource threw "Class constructor AsyncResource
cannot be invoked without 'new'" at super() for every heritage shape
except a bare import binding. A local alias, a namespace member, and a
CJS destructured require() all resolve to the identical bound native
export value the canonical import does, but only the bare import shape
was recognized statically at HIR-lowering time, so super() fell through
to a plain call of the export -- which AsyncResource throws on by
design.

Recognize the bound export VALUE in js_fetch_or_value_super, exactly as
the existing WASI arm does, and run the same native-backing init the
canonical path already uses.
@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
@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: fdb0195f-6a0a-4af6-8fb0-98930cb6a97b

📥 Commits

Reviewing files that changed from the base of the PR and between 0058bab and 4468c70.

📒 Files selected for processing (4)
  • changelog.d/10621-asyncresource-heritage-shapes.md
  • crates/perry-runtime/src/object/global_this/fetch_globals.rs
  • test-files/gap_10453_asyncresource_heritage_helper.cjs
  • test-files/test_gap_10453_asyncresource_heritage.ts

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


📝 Walkthrough

Walkthrough

The runtime now recognizes bound node:async_hooks AsyncResource exports during subclass construction. Tests cover direct imports, aliases, namespace members, CommonJS destructuring, and related helper exports. The changelog documents the supported heritage shapes and the unfixed AsyncLocalStorage case.

Changes

AsyncResource heritage resolution

Layer / File(s) Summary
Generalized native parent resolution
crates/perry-runtime/src/object/global_this/fetch_globals.rs
js_fetch_or_value_super stores the resolved native parent in bound_native_parent and retains the existing WASI matching semantics.
AsyncResource subclass initialization and regression coverage
crates/perry-runtime/src/object/global_this/fetch_globals.rs, test-files/gap_10453_asyncresource_heritage_helper.cjs, test-files/test_gap_10453_asyncresource_heritage.ts, changelog.d/10621-asyncresource-heritage-shapes.md
The runtime routes node:async_hooks AsyncResource heritage values to js_async_resource_subclass_init. Tests cover import, alias, namespace-member, CommonJS, and helper-export forms. The changelog records the change and notes that AsyncLocalStorage remains unchanged.

Priority: ➖ Normal

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

Change: Bug fix · Severity of issue fixed: Medium

Merge Risk: ⚪ Minimal · up to 4468c

The change has no remaining concrete merge-blocking risk in the reviewed AsyncResource inheritance paths.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 2 functions across 3 files. (1 skipped: 1… 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: supporting AsyncResource super() calls through bound-export heritage shapes beyond bare imports.
Description check ✅ Passed The description is comprehensive. It explains the failure, root cause, implementation, related issue, tests, validation results, performance impact, and known unverified areas. It does not use every t…
Linked Issues check ✅ Passed The PR meets the coding requirements in issue #10453. js_fetch_or_value_super now recognizes the bound node:async_hooks AsyncResource export by module and method and uses `js_async_resource_subc…
Out of Scope Changes check ✅ Passed The reviewed changes stay within issue #10453. The runtime change implements the missing AsyncResource subclass initialization path. The CommonJS helper and TypeScript gap test provide regression co…
Full details: Docstring Coverage

Explanation

Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 2 functions across 3 files. (1 skipped: 1 unsupported.)

  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • 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.

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