diff --git a/changelog.d/10621-asyncresource-heritage-shapes.md b/changelog.d/10621-asyncresource-heritage-shapes.md new file mode 100644 index 0000000000..f103e3aa88 --- /dev/null +++ b/changelog.d/10621-asyncresource-heritage-shapes.md @@ -0,0 +1,24 @@ +### Fixed + +- **`class X extends AsyncResource` threw at `super()` unless the heritage + was a bare `import { AsyncResource } from "node:async_hooks"` binding** + (#10453). A local alias (`const Alias = AsyncResource`), a namespace + member (`ah.AsyncResource`), and a CJS destructured + `require('node:async_hooks')` — the exact shape `undici`'s API handlers + use everywhere (`lib/api/api-request.js` etc.) — all threw `Class + constructor AsyncResource cannot be invoked without 'new'`. Only the bare + import shape was recognized statically at HIR-lowering time + (`canonical_native_parent_name`, `crates/perry-hir/src/lower_decl/class_decl.rs`), + routing to the dedicated `js_async_resource_subclass_init` codegen; every + other shape fell through `js_fetch_or_value_super` + (`crates/perry-runtime/src/object/global_this/fetch_globals.rs`) to a + plain CALL of the bound `async_hooks` export, which throws by design + without `new`. `js_fetch_or_value_super` already resolves ANY heritage + value to its bound native module/method via + `bound_native_callable_module_and_method` for the WASI case, regardless + of how the value was reached — this fix adds the same recognition for + `async_hooks`'s `AsyncResource`, so every aliasing shape now runs the + same native-backing init the canonical import already used. + `AsyncLocalStorage` likely has the same gap but isn't fixed here — its + subclass-init helper lives in `perry-stdlib`, which `perry-runtime` + cannot depend on. diff --git a/crates/perry-runtime/src/object/global_this/fetch_globals.rs b/crates/perry-runtime/src/object/global_this/fetch_globals.rs index 2230073e09..2160572c59 100644 --- a/crates/perry-runtime/src/object/global_this/fetch_globals.rs +++ b/crates/perry-runtime/src/object/global_this/fetch_globals.rs @@ -655,7 +655,13 @@ pub unsafe extern "C" fn js_fetch_or_value_super( b"Super constructor null is not a constructor", ); } - let wasi_parent = super::super::native_module::bound_native_callable_module_and_method( + // Resolve the parent to a bound native-module export VALUE, independent + // of how the heritage expression reached it: a bare import, a local + // alias, a namespace member, and a CJS destructured `require()` all + // produce the identical bound-closure representation (see + // `bound_native_callable_module_and_method`), even though only the bare + // import shape is recognized statically at HIR-lowering time. + let bound_native_parent = super::super::native_module::bound_native_callable_module_and_method( parent_val, ) .or_else(|| { @@ -665,10 +671,13 @@ pub unsafe extern "C" fn js_fetch_or_value_super( crate::object::class_registry::js_get_dynamic_parent_value(cid), ) }); - if wasi_parent.is_some_and(|(module, method)| { - super::super::native_module::normalize_native_module_alias(&module) == "wasi" - && method == "WASI" - }) { + if bound_native_parent + .as_ref() + .is_some_and(|(module, method)| { + super::super::native_module::normalize_native_module_alias(module.as_str()) == "wasi" + && method.as_str() == "WASI" + }) + { let arg0 = if args_len >= 1 && !args_ptr.is_null() { *args_ptr } else { @@ -677,6 +686,44 @@ pub unsafe extern "C" fn js_fetch_or_value_super( crate::wasi::js_wasi_init_subclass(this_box, arg0); return undef; } + // #10453: `class X extends AsyncResource` threw "Class constructor + // AsyncResource cannot be invoked without 'new'" for every heritage + // shape EXCEPT a bare `import { AsyncResource } from "node:async_hooks"` + // — the only shape `canonical_native_parent_name` recognizes statically + // (`crates/perry-hir/src/lower_decl/class_decl.rs`), which routes to the + // dedicated `js_async_resource_subclass_init` codegen + // (`crates/perry-codegen/src/expr/this_super_call.rs`). A local alias + // (`const Alias = AsyncResource`), a namespace member + // (`ah.AsyncResource`), and a CJS destructured + // `require('node:async_hooks')` all resolve `parent_val` to the exact + // same bound-native-export value the canonical import does, but HIR + // lowering can't see that statically for those shapes, so `super()` fell + // through to the ordinary value-super dispatch below — a plain CALL of + // the bound export, which `AsyncResource` throws on by design when + // invoked without `new` (`nm_dispatch_async_hooks`). Recognize the value + // here instead, exactly as the WASI arm above does, and run the same + // native-backing init the canonical path uses. + if bound_native_parent + .as_ref() + .is_some_and(|(module, method)| { + super::super::native_module::normalize_native_module_alias(module.as_str()) + == "async_hooks" + && method.as_str() == "AsyncResource" + }) + { + let type_value = if args_len >= 1 && !args_ptr.is_null() { + *args_ptr + } else { + undef + }; + let options = if args_len >= 2 && !args_ptr.is_null() { + *args_ptr.add(1) + } else { + undef + }; + crate::async_hooks::js_async_resource_subclass_init(this_box, type_value, options); + return undef; + } // `class X extends Temporal.` (non-spread `super(a, b)`): a Temporal // constructor returns a fresh NaN-boxed cell and does NOT mutate the // implicit `this`, so the ordinary dispatch below would drop that cell and diff --git a/test-files/gap_10453_asyncresource_heritage_helper.cjs b/test-files/gap_10453_asyncresource_heritage_helper.cjs new file mode 100644 index 0000000000..1e28c49d7d --- /dev/null +++ b/test-files/gap_10453_asyncresource_heritage_helper.cjs @@ -0,0 +1,31 @@ +'use strict'; +// CommonJS half of test_gap_10453_asyncresource_heritage.ts: the exact shape +// undici's API handlers use (`lib/api/api-request.js` etc.). +const { AsyncResource } = require('node:async_hooks'); +const asyncHooks = require('node:async_hooks'); + +class Plain extends AsyncResource { + constructor(type) { + super(type); + } +} + +class InTry extends AsyncResource { + constructor(type) { + try { + super(type); + } catch (err) { + throw err; + } + } +} + +// `require('node:async_hooks').AsyncResource` reached via a namespace member +// on a plain `require()` result (not destructured). +class ViaMemberExport extends asyncHooks.AsyncResource { + constructor(type) { + super(type); + } +} + +module.exports = { Plain, InTry, ViaMemberExport }; diff --git a/test-files/test_gap_10453_asyncresource_heritage.ts b/test-files/test_gap_10453_asyncresource_heritage.ts new file mode 100644 index 0000000000..02904833d8 --- /dev/null +++ b/test-files/test_gap_10453_asyncresource_heritage.ts @@ -0,0 +1,56 @@ +// #10453: `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 reach the same bound native export +// VALUE the bare import does, but HIR lowering only recognized the bare +// import shape statically, so `super()` for the other shapes fell through to +// a plain CALL of the native export — and `AsyncResource` throws by design +// when invoked without `new`. +// +// `undici` (`lib/api/api-request.js` etc.) uses exactly the CJS destructured +// shape: `const { AsyncResource } = require('node:async_hooks'); class … +// extends AsyncResource`. +import { AsyncResource } from "node:async_hooks"; +import * as ah from "node:async_hooks"; +import { Plain, InTry, ViaMemberExport } from "./gap_10453_asyncresource_heritage_helper.cjs"; + +const Alias = AsyncResource; + +class ViaImport extends AsyncResource { + constructor() { + super("X"); + } +} +class ViaAlias extends Alias { + constructor() { + super("X"); + } +} +class ViaNamespace extends ah.AsyncResource { + constructor() { + super("X"); + } +} +function t(name: string, C: any, ...args: unknown[]) { + try { + const r = new C(...args); + console.log( + name, + "ok", + typeof r.runInAsyncScope, + typeof r.triggerAsyncId(), + r instanceof AsyncResource, + ); + } catch (e: any) { + console.log(name, "threw:", e.message); + } +} + +t("TS extends AsyncResource (import) ", ViaImport, "X"); +t("TS extends Alias ", ViaAlias, "X"); +t("TS extends ah.AsyncResource ", ViaNamespace, "X"); +t("CJS destructured require, super() ", Plain, "X"); +t("CJS destructured require, try{super}", InTry, "X"); +t("CJS namespace member export ", ViaMemberExport, "X");