From dd502dc847b389c29f5900b164e32eabf3bbd26c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 18 Sep 2026 15:05:57 +0000 Subject: [PATCH 1/2] fix(runtime): route AsyncLocalStorage super() through any bound-export 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. --- crates/perry-runtime/src/lib.rs | 12 +-- .../src/object/global_this/fetch_globals.rs | 31 +++++++ crates/perry-runtime/src/value/handle.rs | 11 +++ crates/perry-runtime/src/value/mod.rs | 33 ++++---- crates/perry-runtime/src/value/tags.rs | 16 ++++ .../perry-stdlib/src/common/dispatch/init.rs | 12 +++ ...0625_asynclocalstorage_heritage_helper.cjs | 22 +++++ ...st_gap_10625_asynclocalstorage_heritage.ts | 83 +++++++++++++++++++ 8 files changed, 199 insertions(+), 21 deletions(-) create mode 100644 test-files/gap_10625_asynclocalstorage_heritage_helper.cjs create mode 100644 test-files/test_gap_10625_asynclocalstorage_heritage.ts diff --git a/crates/perry-runtime/src/lib.rs b/crates/perry-runtime/src/lib.rs index ceec019f32..2898e95751 100644 --- a/crates/perry-runtime/src/lib.rs +++ b/crates/perry-runtime/src/lib.rs @@ -361,12 +361,12 @@ pub use value::{ pub use value::{ js_set_handle_array_get, js_set_handle_array_length, js_set_handle_call_method, js_set_handle_object_get_property, js_set_handle_to_string, js_set_handle_typeof, - js_set_native_async_hooks_construct, js_set_native_bun_tcp_dispatch, - js_set_native_crypto_dispatch, js_set_native_domain_dispatch, js_set_native_events_construct, - js_set_native_events_dispatch, js_set_native_http_dispatch, js_set_native_module_js_loader, - js_set_native_net_dispatch, js_set_native_querystring_dispatch, js_set_native_sqlite_dispatch, - js_set_native_tls_dispatch, js_set_native_webcrypto_dispatch, js_set_native_zlib_dispatch, - js_set_new_from_handle_v8, + js_set_native_async_hooks_construct, js_set_native_async_local_storage_subclass_init, + js_set_native_bun_tcp_dispatch, js_set_native_crypto_dispatch, js_set_native_domain_dispatch, + js_set_native_events_construct, js_set_native_events_dispatch, js_set_native_http_dispatch, + js_set_native_module_js_loader, js_set_native_net_dispatch, js_set_native_querystring_dispatch, + js_set_native_sqlite_dispatch, js_set_native_tls_dispatch, js_set_native_webcrypto_dispatch, + js_set_native_zlib_dispatch, js_set_new_from_handle_v8, }; // Extension pump registration — allows extensions to register pump functions 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 2160572c59..13cbe05c23 100644 --- a/crates/perry-runtime/src/object/global_this/fetch_globals.rs +++ b/crates/perry-runtime/src/object/global_this/fetch_globals.rs @@ -724,6 +724,37 @@ pub unsafe extern "C" fn js_fetch_or_value_super( crate::async_hooks::js_async_resource_subclass_init(this_box, type_value, options); return undef; } + // #10625: `class X extends AsyncLocalStorage` reached indirectly (local + // alias, namespace member, CJS destructured `require()`) hits the same gap + // #10453/#10621 fixed for AsyncResource: only the canonical bare + // `import { AsyncLocalStorage } from "node:async_hooks"` binding 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 + // (`crates/perry-codegen/src/expr/this_super_call.rs`). Every other + // heritage shape resolves `parent_val` to the identical bound native + // export here, but this crate cannot call that stdlib helper directly — + // perry-runtime cannot depend on perry-stdlib, where the helper (and the + // `Handle` registry backing it) live — so route through the registration + // hook perry-stdlib installs at startup instead, exactly like the WASI arm + // above. + 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() == "AsyncLocalStorage" + }) + { + let ptr = crate::value::JS_NATIVE_ASYNC_LOCAL_STORAGE_SUBCLASS_INIT + .load(std::sync::atomic::Ordering::SeqCst); + if !ptr.is_null() { + let dispatch: crate::value::JsNativeAsyncLocalStorageSubclassInitFn = + std::mem::transmute(ptr); + return dispatch(this_box); + } + } // `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/crates/perry-runtime/src/value/handle.rs b/crates/perry-runtime/src/value/handle.rs index f13ebdae26..eafe398cb0 100644 --- a/crates/perry-runtime/src/value/handle.rs +++ b/crates/perry-runtime/src/value/handle.rs @@ -148,6 +148,17 @@ pub extern "C" fn js_set_native_async_hooks_construct(func: JsNativeEventsConstr JS_NATIVE_ASYNC_HOOKS_CONSTRUCT.store(func as *mut (), Ordering::SeqCst); } +/// Register the AsyncLocalStorage subclass-init dispatcher. See +/// `JS_NATIVE_ASYNC_LOCAL_STORAGE_SUBCLASS_INIT` for why perry-runtime needs +/// this indirection instead of calling perry-stdlib's +/// `js_async_local_storage_subclass_init` directly. (#10625) +#[no_mangle] +pub extern "C" fn js_set_native_async_local_storage_subclass_init( + func: JsNativeAsyncLocalStorageSubclassInitFn, +) { + JS_NATIVE_ASYNC_LOCAL_STORAGE_SUBCLASS_INIT.store(func as *mut (), Ordering::SeqCst); +} + /// Set the native module JS property loader (called by perry-jsruntime) /// This callback loads a native module via V8 and gets a property from it. #[no_mangle] diff --git a/crates/perry-runtime/src/value/mod.rs b/crates/perry-runtime/src/value/mod.rs index 30bada3331..577797490d 100644 --- a/crates/perry-runtime/src/value/mod.rs +++ b/crates/perry-runtime/src/value/mod.rs @@ -64,22 +64,24 @@ pub(crate) use tags::{ }; pub use tags::{ JS_HANDLE_CALL_METHOD, JS_HANDLE_TYPEOF, JS_NATIVE_ASYNC_HOOKS_CONSTRUCT, - JS_NATIVE_BUN_TCP_DISPATCH, JS_NATIVE_CRYPTO_DISPATCH, JS_NATIVE_DOMAIN_DISPATCH, - JS_NATIVE_EVENTS_CONSTRUCT, JS_NATIVE_EVENTS_DISPATCH, JS_NATIVE_HTTP_DISPATCH, - JS_NATIVE_MODULE_JS_LOADER, JS_NATIVE_NET_DISPATCH, JS_NATIVE_QUERYSTRING_DISPATCH, - JS_NATIVE_SQLITE_DISPATCH, JS_NATIVE_TLS_DISPATCH, JS_NATIVE_WEBCRYPTO_DISPATCH, - JS_NATIVE_ZLIB_DISPATCH, JS_NEW_FROM_HANDLE_V8, SHORT_STRING_MAX_LEN, + JS_NATIVE_ASYNC_LOCAL_STORAGE_SUBCLASS_INIT, JS_NATIVE_BUN_TCP_DISPATCH, + JS_NATIVE_CRYPTO_DISPATCH, JS_NATIVE_DOMAIN_DISPATCH, JS_NATIVE_EVENTS_CONSTRUCT, + JS_NATIVE_EVENTS_DISPATCH, JS_NATIVE_HTTP_DISPATCH, JS_NATIVE_MODULE_JS_LOADER, + JS_NATIVE_NET_DISPATCH, JS_NATIVE_QUERYSTRING_DISPATCH, JS_NATIVE_SQLITE_DISPATCH, + JS_NATIVE_TLS_DISPATCH, JS_NATIVE_WEBCRYPTO_DISPATCH, JS_NATIVE_ZLIB_DISPATCH, + JS_NEW_FROM_HANDLE_V8, SHORT_STRING_MAX_LEN, }; // Crate-internal handle dispatch atomics + callback type aliases (read by // every dispatcher that needs to call back into perry-jsruntime). pub(crate) use tags::{ JsHandleArrayGetFn, JsHandleArrayLengthFn, JsHandleCallMethodFn, JsHandleObjectGetPropertyFn, - JsHandleToStringFn, JsHandleTypeofFn, JsNativeBunTcpDispatchFn, JsNativeCryptoDispatchFn, - JsNativeDomainDispatchFn, JsNativeEventsConstructFn, JsNativeHttpDispatchFn, - JsNativeModuleJsLoaderFn, JsNativeNetDispatchFn, JsNativeQuerystringDispatchFn, - JsNativeSqliteDispatchFn, JsNativeTlsDispatchFn, JsNativeWebCryptoDispatchFn, - JsNativeZlibDispatchFn, JsNewFromHandleV8Fn, JS_HANDLE_ARRAY_GET, JS_HANDLE_ARRAY_LENGTH, + JsHandleToStringFn, JsHandleTypeofFn, JsNativeAsyncLocalStorageSubclassInitFn, + JsNativeBunTcpDispatchFn, JsNativeCryptoDispatchFn, JsNativeDomainDispatchFn, + JsNativeEventsConstructFn, JsNativeHttpDispatchFn, JsNativeModuleJsLoaderFn, + JsNativeNetDispatchFn, JsNativeQuerystringDispatchFn, JsNativeSqliteDispatchFn, + JsNativeTlsDispatchFn, JsNativeWebCryptoDispatchFn, JsNativeZlibDispatchFn, + JsNewFromHandleV8Fn, JS_HANDLE_ARRAY_GET, JS_HANDLE_ARRAY_LENGTH, JS_HANDLE_OBJECT_GET_PROPERTY, JS_HANDLE_TO_STRING, }; @@ -92,11 +94,12 @@ pub use handle::{ is_js_handle, js_handle_array_get, js_handle_array_length, js_set_handle_array_get, js_set_handle_array_length, js_set_handle_call_method, js_set_handle_object_get_property, js_set_handle_to_string, js_set_handle_typeof, js_set_native_async_hooks_construct, - js_set_native_bun_tcp_dispatch, js_set_native_crypto_dispatch, js_set_native_domain_dispatch, - js_set_native_events_construct, js_set_native_events_dispatch, js_set_native_http_dispatch, - js_set_native_module_js_loader, js_set_native_net_dispatch, js_set_native_querystring_dispatch, - js_set_native_sqlite_dispatch, js_set_native_tls_dispatch, js_set_native_webcrypto_dispatch, - js_set_native_zlib_dispatch, js_set_new_from_handle_v8, native_module_try_js_property, + js_set_native_async_local_storage_subclass_init, js_set_native_bun_tcp_dispatch, + js_set_native_crypto_dispatch, js_set_native_domain_dispatch, js_set_native_events_construct, + js_set_native_events_dispatch, js_set_native_http_dispatch, js_set_native_module_js_loader, + js_set_native_net_dispatch, js_set_native_querystring_dispatch, js_set_native_sqlite_dispatch, + js_set_native_tls_dispatch, js_set_native_webcrypto_dispatch, js_set_native_zlib_dispatch, + js_set_new_from_handle_v8, native_module_try_js_property, }; // ----- Basic NaN-box pack / unpack FFI ----- diff --git a/crates/perry-runtime/src/value/tags.rs b/crates/perry-runtime/src/value/tags.rs index 1bad2cfbee..2f2f3314ea 100644 --- a/crates/perry-runtime/src/value/tags.rs +++ b/crates/perry-runtime/src/value/tags.rs @@ -218,3 +218,19 @@ pub static JS_NATIVE_EVENTS_DISPATCH: AtomicPtr<()> = AtomicPtr::new(std::ptr::n // (method_name_ptr, method_name_len, args_ptr, args_len), returns the NaN-boxed // instance. Next.js standalone server startup blocker. pub static JS_NATIVE_ASYNC_HOOKS_CONSTRUCT: AtomicPtr<()> = AtomicPtr::new(std::ptr::null_mut()); +// Subclass-init hook for `class X extends ` reached through anything OTHER than the canonical bare +// `import { AsyncLocalStorage } from "node:async_hooks"` binding (a local +// alias, a namespace member, or a CJS destructured `require()`). Codegen +// already routes the canonical shape statically, straight to perry-stdlib's +// `js_async_local_storage_subclass_init` (declared as an extern symbol by +// codegen, which has no crate-dependency constraint); every other shape only +// resolves at runtime, inside `js_fetch_or_value_super` in THIS crate, which +// cannot depend on perry-stdlib (where the helper — and the `Handle` registry +// it needs — live). Registered by perry-stdlib at startup; stays null when +// stdlib isn't linked. Takes/returns the subclass instance (this_value) as a +// NaN-boxed f64, matching `js_async_local_storage_subclass_init`'s own +// signature. (#10625) +pub(crate) type JsNativeAsyncLocalStorageSubclassInitFn = unsafe extern "C" fn(f64) -> f64; +pub static JS_NATIVE_ASYNC_LOCAL_STORAGE_SUBCLASS_INIT: AtomicPtr<()> = + AtomicPtr::new(std::ptr::null_mut()); diff --git a/crates/perry-stdlib/src/common/dispatch/init.rs b/crates/perry-stdlib/src/common/dispatch/init.rs index faa3f1cb70..1a467dea6d 100644 --- a/crates/perry-stdlib/src/common/dispatch/init.rs +++ b/crates/perry-stdlib/src/common/dispatch/init.rs @@ -430,6 +430,18 @@ pub unsafe extern "C" fn js_stdlib_init_dispatch() { } } perry_runtime::js_set_native_async_hooks_construct(async_hooks_native_construct); + // #10625: register the AsyncLocalStorage subclass-init dispatcher so + // `class X extends ` reached + // through a local alias, namespace member, or CJS destructured `require()` + // reaches the real handle at `super()` time — not just the canonical bare + // import shape codegen already routes statically. See + // `js_fetch_or_value_super` in perry-runtime for why this indirection + // exists (perry-runtime cannot depend on perry-stdlib, where + // `js_async_local_storage_subclass_init` and the `Handle` registry it uses + // live). + perry_runtime::js_set_native_async_local_storage_subclass_init( + crate::async_local_storage::js_async_local_storage_subclass_init, + ); super::super::net_socket_bridge::register_net_socket_handle_probe(); #[cfg(feature = "external-http-client-pump")] { diff --git a/test-files/gap_10625_asynclocalstorage_heritage_helper.cjs b/test-files/gap_10625_asynclocalstorage_heritage_helper.cjs new file mode 100644 index 0000000000..6f8ceeb2c9 --- /dev/null +++ b/test-files/gap_10625_asynclocalstorage_heritage_helper.cjs @@ -0,0 +1,22 @@ +'use strict'; +// CommonJS half of test_gap_10625_asynclocalstorage_heritage.ts: the exact +// shape #10621 fixed for AsyncResource (undici's own heritage pattern), +// mirrored here for AsyncLocalStorage per #10625. +const { AsyncLocalStorage } = require('node:async_hooks'); +const asyncHooks = require('node:async_hooks'); + +class ViaRequire extends AsyncLocalStorage { + constructor() { + super(); + } +} + +// `require('node:async_hooks').AsyncLocalStorage` reached via a namespace +// member on a plain `require()` result (not destructured). +class ViaRequireNamespaceMember extends asyncHooks.AsyncLocalStorage { + constructor() { + super(); + } +} + +module.exports = { ViaRequire, ViaRequireNamespaceMember }; diff --git a/test-files/test_gap_10625_asynclocalstorage_heritage.ts b/test-files/test_gap_10625_asynclocalstorage_heritage.ts new file mode 100644 index 0000000000..ee4b28ae06 --- /dev/null +++ b/test-files/test_gap_10625_asynclocalstorage_heritage.ts @@ -0,0 +1,83 @@ +// #10625: `class X extends AsyncLocalStorage` has the same indirect-heritage +// defect #10621 fixed for AsyncResource (#10453) — every heritage shape +// EXCEPT a bare `import { AsyncLocalStorage } from "node:async_hooks"` +// binding fell through `js_fetch_or_value_super` +// (`crates/perry-runtime/src/object/global_this/fetch_globals.rs`) to a +// plain CALL of the bound native export, which throws "Class constructor +// AsyncLocalStorage cannot be invoked without 'new'" (or silently produces a +// class_id=0 instance whose inherited methods are missing, depending on the +// shape) instead of running the native-backing init the canonical import +// path already used. +// +// AsyncLocalStorage's subclass-init helper (`js_async_local_storage_subclass_init`) +// lives in perry-stdlib, not perry-runtime, so — unlike AsyncResource, whose +// implementation is entirely in perry-runtime — the fix routes through a +// registration hook perry-stdlib installs at startup +// (`JS_NATIVE_ASYNC_LOCAL_STORAGE_SUBCLASS_INIT`), since perry-runtime +// cannot depend on perry-stdlib. +import { AsyncLocalStorage } from "node:async_hooks"; +import * as ah from "node:async_hooks"; +import ahDefault from "node:async_hooks"; +import { + ViaRequire, + ViaRequireNamespaceMember, +} from "./gap_10625_asynclocalstorage_heritage_helper.cjs"; + +const Alias = AsyncLocalStorage; + +class ViaImport extends AsyncLocalStorage { + constructor() { + super(); + } +} +class ViaAlias extends Alias { + constructor() { + super(); + } +} +class ViaNamespace extends ah.AsyncLocalStorage { + constructor() { + super(); + } +} +class ViaDefaultImport extends ahDefault.AsyncLocalStorage { + constructor() { + super(); + } +} + +function t(name: string, C: any) { + try { + const inst = new C(); + // Round-trip through run()/getStore(), not just construction: a + // class_id=0 empty-object subclass instance would also survive `new` + // without throwing, so proving the fix needs the store to actually flow. + const outside = inst.getStore(); + const inside = inst.run(42, () => inst.getStore()); + const nested = inst.run("outer", () => + inst.run("inner", () => inst.getStore()), + ); + console.log( + name, + "ok", + "outside=" + String(outside), + "inside=" + inside, + "nested=" + nested, + inst instanceof AsyncLocalStorage, + typeof inst.run, + typeof inst.getStore, + typeof inst.enterWith, + typeof inst.exit, + typeof inst.disable, + ); + } catch (e: any) { + console.log(name, "threw:", e.message); + } +} + +t("TS extends AsyncLocalStorage (import) ", ViaImport); +t("TS extends Alias ", ViaAlias); +t("TS extends ah.AsyncLocalStorage ", ViaNamespace); +t("TS extends default.AsyncLocalStorage ", ViaDefaultImport); +t("CJS destructured require ", ViaRequire); +t("CJS namespace member export ", ViaRequireNamespaceMember); From 926b8208daff6116a6602894dc1a86b4b0c3317e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 18 Sep 2026 15:07:19 +0000 Subject: [PATCH 2/2] docs(changelog): add fragment for #10634 (AsyncLocalStorage heritage shapes) --- .../10634-asynclocalstorage-heritage.md | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 changelog.d/10634-asynclocalstorage-heritage.md diff --git a/changelog.d/10634-asynclocalstorage-heritage.md b/changelog.d/10634-asynclocalstorage-heritage.md new file mode 100644 index 0000000000..d9f357df9f --- /dev/null +++ b/changelog.d/10634-asynclocalstorage-heritage.md @@ -0,0 +1,33 @@ +### Fixed + +- **`class X extends AsyncLocalStorage` threw at `super()` unless the + heritage was a bare `import { AsyncLocalStorage } from + "node:async_hooks"` binding** (#10625), the same defect #10621 fixed for + `AsyncResource` (#10453). A local alias (`const Alias = + AsyncLocalStorage`), a namespace member (`ah.AsyncLocalStorage`), a + default import (`import ahDefault from "node:async_hooks"; + ahDefault.AsyncLocalStorage`), and a CJS destructured + `require('node:async_hooks')` all threw `Class constructor + AsyncLocalStorage 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 + 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` + (`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`. 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. Fixed by adding + `JS_NATIVE_ASYNC_LOCAL_STORAGE_SUBCLASS_INIT` / + `js_set_native_async_local_storage_subclass_init` + (`crates/perry-runtime/src/value/{tags,handle}.rs`), a registration hook + perry-stdlib installs at startup, matching the existing + `JS_NATIVE_ASYNC_HOOKS_CONSTRUCT` / `JS_NATIVE_EVENTS_CONSTRUCT` pattern + already used for this exact kind of cross-crate reach. + `bound_native_callable_module_and_method` needed no changes — it already + generically resolves any bound native export; only the per-consumer + match arm in `js_fetch_or_value_super` was missing for + `AsyncLocalStorage`.