diff --git a/changelog.d/7093-async-hooks-prototypes.md b/changelog.d/7093-async-hooks-prototypes.md new file mode 100644 index 0000000000..88e7555728 --- /dev/null +++ b/changelog.d/7093-async-hooks-prototypes.md @@ -0,0 +1 @@ +Fixed `node:async_hooks` constructor prototype metadata and reflective prototype method calls. diff --git a/crates/perry-runtime/src/object/native_module.rs b/crates/perry-runtime/src/object/native_module.rs index 71a60986c1..ddc8e5eb16 100644 --- a/crates/perry-runtime/src/object/native_module.rs +++ b/crates/perry-runtime/src/object/native_module.rs @@ -12,6 +12,7 @@ use std::cell::{Cell, RefCell}; use std::ptr::null_mut; use std::sync::atomic::{AtomicPtr, Ordering}; +mod async_hooks_exports; mod callable_export_check; pub(crate) mod callable_exports; mod constants; diff --git a/crates/perry-runtime/src/object/native_module/async_hooks_exports.rs b/crates/perry-runtime/src/object/native_module/async_hooks_exports.rs new file mode 100644 index 0000000000..43db54c96a --- /dev/null +++ b/crates/perry-runtime/src/object/native_module/async_hooks_exports.rs @@ -0,0 +1,173 @@ +//! Reflective constructor/prototype surface for `node:async_hooks`. +//! +//! Direct calls on AsyncLocalStorage/AsyncResource are native-dispatched +//! elsewhere. This module supplies the ordinary JS prototype objects so +//! reflection and method-as-value reads see the same functions Node exposes. + +use super::callable_exports::set_builtin_closure_length; +use super::*; + +const ASYNC_LOCAL_STORAGE_METHODS: &[(&str, u32)] = &[ + ("run", 2), + ("getStore", 0), + ("enterWith", 1), + ("exit", 1), + ("disable", 0), +]; + +const ASYNC_RESOURCE_METHODS: &[(&str, u32)] = &[ + ("asyncId", 0), + ("triggerAsyncId", 0), + ("emitDestroy", 0), + ("runInAsyncScope", 2), + ("bind", 2), +]; + +/// Forward a prototype method call through the existing dynamic receiver +/// dispatcher. The rest array preserves every variadic argument for +/// `run`, `exit`, and `runInAsyncScope`. +extern "C" fn async_hooks_prototype_method_thunk( + closure: *const crate::closure::ClosureHeader, + rest: f64, +) -> f64 { + unsafe { + let name_ptr = crate::closure::js_closure_get_capture_ptr(closure, 0) as *const i8; + let name_len = crate::closure::js_closure_get_capture_ptr(closure, 1) as usize; + let receiver = crate::object::js_implicit_this_get(); + let name = std::slice::from_raw_parts(name_ptr as *const u8, name_len); + + // Node's enterWith/disable implementations do not brand-check an + // arbitrary object receiver; they simply have no observable storage + // state to mutate there. Preserve that no-op behavior instead of + // asking the generic object dispatcher to call a missing method. + if matches!(name, b"enterWith" | b"disable") { + let receiver_value = JSValue::from_bits(receiver.to_bits()); + if receiver_value.is_pointer() + && crate::value::addr_class::is_plausible_heap_addr( + receiver_value.as_pointer::() as usize, + ) + { + return f64::from_bits(crate::value::TAG_UNDEFINED); + } + } + + let args_array = crate::value::js_nanbox_get_pointer(rest); + crate::object::js_native_call_method_apply(receiver, name_ptr, name_len, args_array) + } +} + +fn attach_prototype(constructor_value: f64, methods: &[(&str, u32)]) -> f64 { + let constructor_js = JSValue::from_bits(constructor_value.to_bits()); + if !constructor_js.is_pointer() { + return constructor_value; + } + let constructor = constructor_js.as_pointer::() as usize; + if constructor == 0 { + return constructor_value; + } + + // Every allocation below can evacuate the constructor, prototype, method + // closures, and strings. Keep raw pointers only in updateable roots and + // reload them immediately before each use. + let scope = crate::gc::RuntimeHandleScope::new(); + let constructor_handle = + scope.root_raw_mut_ptr(constructor as *mut crate::closure::ClosureHeader); + let prototype = js_object_alloc(0, 0); + if prototype.is_null() { + return crate::value::js_nanbox_pointer( + constructor_handle.get_raw_mut_ptr::() as i64, + ); + } + let prototype_handle = scope.root_raw_mut_ptr(prototype); + + let constructor_name = "constructor"; + let constructor_key = crate::string::js_string_from_bytes( + constructor_name.as_ptr(), + constructor_name.len() as u32, + ); + let constructor_key_handle = scope.root_string_ptr(constructor_key); + js_object_set_field_by_name( + prototype_handle.get_raw_mut_ptr(), + constructor_key_handle.get_raw_mut_ptr(), + crate::value::js_nanbox_pointer( + constructor_handle.get_raw_mut_ptr::() as i64, + ), + ); + super::super::set_builtin_property_attrs( + prototype_handle.get_raw_mut_ptr::() as usize, + constructor_name.to_string(), + super::super::PropertyAttrs::new(true, false, true), + ); + + let thunk = async_hooks_prototype_method_thunk as *const u8; + crate::closure::js_register_closure_rest(thunk, 0); + for &(name, length) in methods { + let method = crate::closure::js_closure_alloc(thunk, 2); + if method.is_null() { + continue; + } + let method_handle = scope.root_raw_mut_ptr(method); + crate::closure::js_closure_set_capture_ptr( + method_handle.get_raw_mut_ptr(), + 0, + name.as_ptr() as i64, + ); + crate::closure::js_closure_set_capture_ptr( + method_handle.get_raw_mut_ptr(), + 1, + name.len() as i64, + ); + + let name_string = crate::string::js_string_from_bytes(name.as_ptr(), name.len() as u32); + let name_handle = scope.root_string_ptr(name_string); + crate::closure::closure_set_dynamic_prop( + method_handle.get_raw_mut_ptr::() as usize, + "name", + f64::from_bits(JSValue::string_ptr(name_handle.get_raw_mut_ptr()).bits()), + ); + super::super::set_builtin_property_attrs( + method_handle.get_raw_mut_ptr::() as usize, + "name".to_string(), + super::super::PropertyAttrs::new(false, false, true), + ); + set_builtin_closure_length( + method_handle.get_raw_mut_ptr::() as usize, + length, + ); + + js_object_set_field_by_name( + prototype_handle.get_raw_mut_ptr(), + name_handle.get_raw_mut_ptr(), + crate::value::js_nanbox_pointer( + method_handle.get_raw_mut_ptr::() as i64, + ), + ); + super::super::set_builtin_property_attrs( + prototype_handle.get_raw_mut_ptr::() as usize, + name.to_string(), + super::super::PropertyAttrs::new(true, false, true), + ); + } + + crate::closure::closure_set_dynamic_prop( + constructor_handle.get_raw_mut_ptr::() as usize, + "prototype", + crate::value::js_nanbox_pointer(prototype_handle.get_raw_mut_ptr::() as i64), + ); + super::super::set_builtin_property_attrs( + constructor_handle.get_raw_mut_ptr::() as usize, + "prototype".to_string(), + super::super::PropertyAttrs::new(false, false, false), + ); + crate::value::js_nanbox_pointer( + constructor_handle.get_raw_mut_ptr::() as i64, + ) +} + +pub(super) fn attach_async_local_storage_prototype(constructor_value: f64) -> f64 { + attach_prototype(constructor_value, ASYNC_LOCAL_STORAGE_METHODS) +} + +pub(super) fn attach_async_resource_prototype(constructor_value: f64) -> f64 { + attach_prototype(constructor_value, ASYNC_RESOURCE_METHODS) +} diff --git a/crates/perry-runtime/src/object/native_module/callable_exports.rs b/crates/perry-runtime/src/object/native_module/callable_exports.rs index 3607051746..7200ce999b 100644 --- a/crates/perry-runtime/src/object/native_module/callable_exports.rs +++ b/crates/perry-runtime/src/object/native_module/callable_exports.rs @@ -114,13 +114,23 @@ fn async_hooks_static_method_value( length: u32, ) -> f64 { crate::closure::js_register_closure_rest(func_ptr, fixed_arity); + let scope = crate::gc::RuntimeHandleScope::new(); let closure = crate::closure::js_closure_alloc(func_ptr, 0); if closure.is_null() { return f64::from_bits(crate::value::TAG_UNDEFINED); } - set_bound_native_closure_name(closure, name); - set_builtin_closure_length(closure as usize, length); - crate::value::js_nanbox_pointer(closure as i64) + let closure_handle = scope.root_raw_mut_ptr(closure); + set_bound_native_closure_name( + closure_handle.get_raw_mut_ptr::(), + name, + ); + set_builtin_closure_length( + closure_handle.get_raw_mut_ptr::() as usize, + length, + ); + crate::value::js_nanbox_pointer( + closure_handle.get_raw_mut_ptr::() as i64, + ) } extern "C" fn fs_namespace_descriptor_getter_thunk( @@ -246,7 +256,7 @@ fn native_callable_export_arity_reference(module: &str, prop: &str) -> Option Some(1), ("querystring", "stringify" | "parse") => Some(4), ("async_hooks", "AsyncLocalStorage") => Some(0), - ("async_hooks", "AsyncResource") => Some(2), + ("async_hooks", "AsyncResource") => Some(1), ("async_hooks", "createHook") => Some(1), ("async_hooks", "executionAsyncId") => Some(0), ("async_hooks", "triggerAsyncId") => Some(0), @@ -1447,9 +1457,16 @@ pub(crate) fn set_bound_native_closure_name( closure: *mut crate::closure::ClosureHeader, name: &str, ) { + let scope = crate::gc::RuntimeHandleScope::new(); + let closure_handle = scope.root_raw_mut_ptr(closure); let ptr = crate::string::js_string_from_bytes(name.as_ptr(), name.len() as u32); - let name_value = f64::from_bits(JSValue::string_ptr(ptr).bits()); - crate::closure::closure_set_dynamic_prop(closure as usize, "name", name_value); + let name_handle = scope.root_string_ptr(ptr); + let name_value = f64::from_bits(JSValue::string_ptr(name_handle.get_raw_mut_ptr()).bits()); + crate::closure::closure_set_dynamic_prop( + closure_handle.get_raw_mut_ptr::() as usize, + "name", + name_value, + ); // Spec: a function's `name` property is { writable:false, enumerable:false, // configurable:true }. Storing it as a plain dynamic prop left it ENUMERABLE // by default, so `for (k in Buffer)` yielded "name" — even though @@ -1472,7 +1489,7 @@ pub(crate) fn set_bound_native_closure_name( // table unconditionally, so the builtin variant preserves the // safe-buffer semantics above. crate::object::set_builtin_property_attrs( - closure as usize, + closure_handle.get_raw_mut_ptr::() as usize, "name".to_string(), crate::object::PropertyAttrs::new(false, false, true), ); @@ -1683,43 +1700,59 @@ pub(crate) unsafe fn nm_attach_perf_hooks( pub(crate) unsafe fn nm_attach_async_hooks( property_name: &str, mut value: f64, - closure_addr: usize, + _closure_addr: usize, ) -> f64 { + let scope = crate::gc::RuntimeHandleScope::new(); + let constructor_handle = scope.root_nanbox_f64(value); if property_name == "AsyncLocalStorage" { + constructor_handle.set_nanbox_f64( + super::async_hooks_exports::attach_async_local_storage_prototype( + constructor_handle.get_nanbox_f64(), + ), + ); + let bind = scope.root_nanbox_f64(async_hooks_static_method_value( + crate::async_hooks::js_async_local_storage_static_bind_method as *const u8, + "bind", + 1, + 1, + )); crate::closure::closure_set_dynamic_prop( - closure_addr, + crate::value::js_nanbox_get_pointer(constructor_handle.get_nanbox_f64()) as usize, "bind", - async_hooks_static_method_value( - crate::async_hooks::js_async_local_storage_static_bind_method as *const u8, - "bind", - 1, - 1, - ), + bind.get_nanbox_f64(), ); + let snapshot = scope.root_nanbox_f64(async_hooks_static_method_value( + crate::async_hooks::js_async_local_storage_static_snapshot_method as *const u8, + "snapshot", + 0, + 0, + )); crate::closure::closure_set_dynamic_prop( - closure_addr, + crate::value::js_nanbox_get_pointer(constructor_handle.get_nanbox_f64()) as usize, "snapshot", - async_hooks_static_method_value( - crate::async_hooks::js_async_local_storage_static_snapshot_method as *const u8, - "snapshot", - 0, - 0, - ), + snapshot.get_nanbox_f64(), ); } if property_name == "AsyncResource" { + constructor_handle.set_nanbox_f64( + super::async_hooks_exports::attach_async_resource_prototype( + constructor_handle.get_nanbox_f64(), + ), + ); + let bind = scope.root_nanbox_f64(async_hooks_static_method_value( + crate::async_hooks::js_async_resource_static_bind_method as *const u8, + "bind", + 3, + 3, + )); crate::closure::closure_set_dynamic_prop( - closure_addr, + crate::value::js_nanbox_get_pointer(constructor_handle.get_nanbox_f64()) as usize, "bind", - async_hooks_static_method_value( - crate::async_hooks::js_async_resource_static_bind_method as *const u8, - "bind", - 3, - 3, - ), + bind.get_nanbox_f64(), ); } + value = constructor_handle.get_nanbox_f64(); value } @@ -1817,7 +1850,7 @@ static CALLABLE_EXPORT_ARITY_TABLE: &[(&str, &[(&str, u32)])] = &[ "async_hooks", &[ ("AsyncLocalStorage", 0), - ("AsyncResource", 2), + ("AsyncResource", 1), ("createHook", 1), ("executionAsyncId", 0), ("executionAsyncResource", 0), diff --git a/crates/perry/tests/issue_6764_async_hooks_prototype_metadata.rs b/crates/perry/tests/issue_6764_async_hooks_prototype_metadata.rs new file mode 100644 index 0000000000..d4beef6ea6 --- /dev/null +++ b/crates/perry/tests/issue_6764_async_hooks_prototype_metadata.rs @@ -0,0 +1,120 @@ +//! Regression coverage for the first #6764 async_hooks parity increment: +//! constructor/prototype metadata and reflective prototype calls. + +use std::path::PathBuf; +use std::process::Command; + +fn perry_bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_perry")) +} + +#[test] +fn async_hooks_constructors_expose_real_prototype_methods() { + let dir = tempfile::tempdir().expect("tempdir"); + let entry = dir.path().join("main.ts"); + let output = dir.path().join("main_bin"); + std::fs::write( + &entry, + r#" +import { AsyncLocalStorage, AsyncResource } from "node:async_hooks"; + +function metadata(entries: Array<[string, unknown]>) { + return entries + .map(([name, value]) => + typeof value === "function" + ? `${name}:${(value as Function).name}/${(value as Function).length}` + : `${name}:missing`, + ) + .join("|"); +} + +console.log( + "storage:", + metadata([ + ["constructor", AsyncLocalStorage], + ["run", AsyncLocalStorage.prototype.run], + ["getStore", AsyncLocalStorage.prototype.getStore], + ["enterWith", AsyncLocalStorage.prototype.enterWith], + ["exit", AsyncLocalStorage.prototype.exit], + ["disable", AsyncLocalStorage.prototype.disable], + ]), +); +console.log( + "resource:", + metadata([ + ["constructor", AsyncResource], + ["asyncId", AsyncResource.prototype.asyncId], + ["triggerAsyncId", AsyncResource.prototype.triggerAsyncId], + ["emitDestroy", AsyncResource.prototype.emitDestroy], + ["runInAsyncScope", AsyncResource.prototype.runInAsyncScope], + ["bind", AsyncResource.prototype.bind], + ]), +); + +const storage = new AsyncLocalStorage(); +const storageResult = AsyncLocalStorage.prototype.run.call( + storage, + "ctx", + (a: number, b: number) => `${storage.getStore()}:${a + b}`, + 2, + 3, +); +console.log("storage call:", storageResult); + +const resource = new AsyncResource("fixture"); +const resourceResult = AsyncResource.prototype.runInAsyncScope.call( + resource, + (a: number, b: number) => a + b, + null, + 4, + 5, +); +console.log("resource call:", resourceResult); +console.log( + "foreign no-op:", + AsyncLocalStorage.prototype.enterWith.call({}, "value"), + AsyncLocalStorage.prototype.disable.call({}), +); +"#, + ) + .expect("write fixture"); + + let compile = Command::new(perry_bin()) + .current_dir(dir.path()) + .arg("compile") + .arg(&entry) + .arg("-o") + .arg(&output) + .output() + .expect("run perry compile"); + assert!( + compile.status.success(), + "perry compile failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&compile.stdout), + String::from_utf8_lossy(&compile.stderr) + ); + + let run = Command::new(&output) + .output() + .expect("run compiled fixture"); + assert!( + run.status.success(), + "compiled fixture failed\nstatus: {:?}\nstdout:\n{}\nstderr:\n{}", + run.status, + String::from_utf8_lossy(&run.stdout), + String::from_utf8_lossy(&run.stderr) + ); + assert_eq!( + String::from_utf8_lossy(&run.stdout), + concat!( + "storage: constructor:AsyncLocalStorage/0|run:run/2|getStore:getStore/0|", + "enterWith:enterWith/1|exit:exit/1|disable:disable/0\n", + "resource: constructor:AsyncResource/1|asyncId:asyncId/0|", + "triggerAsyncId:triggerAsyncId/0|emitDestroy:emitDestroy/0|", + "runInAsyncScope:runInAsyncScope/2|bind:bind/2\n", + "storage call: ctx:5\n", + "resource call: 9\n", + "foreign no-op: undefined undefined\n", + ) + ); +}