diff --git a/changelog.d/6931-callable-attach-registry.md b/changelog.d/6931-callable-attach-registry.md new file mode 100644 index 0000000000..cc398c6778 --- /dev/null +++ b/changelog.d/6931-callable-attach-registry.md @@ -0,0 +1 @@ +**Binary size:** the per-module "attach" ladder inside `bound_native_callable_export_value` (stream prototype towers, sqlite/tty/tls/wasi/assert/crypto constructor decoration, EventEmitter statics, `util.promisify.custom`, `AsyncLocalStorage.bind`, …) is now a per-module `NM_ATTACH_REGISTRY`, registered by each module's existing `js_nm_install_()`. A binary links exactly the attach machinery of the modules it imports: on a `console.log` hello world this finally dead-strips the stream pipeline tower, fs residue (161→27 KB), child_process (→0.9 KB), wasi (→0) and node:vm (→6.5 KB) — −133 KB on this branch so far. Verified byte-identical to Node: stream iteration, `promisify(stream.pipeline)` custom hook, `events.once`/EventEmitter statics, `AsyncLocalStorage.bind`, plus a 12-test gap sweep; full runtime suite green serially (the parallel flakes are #6926/known). diff --git a/crates/perry-runtime/src/object/native_module.rs b/crates/perry-runtime/src/object/native_module.rs index 89b0884079..9857ceee76 100644 --- a/crates/perry-runtime/src/object/native_module.rs +++ b/crates/perry-runtime/src/object/native_module.rs @@ -13,7 +13,7 @@ use std::ptr::null_mut; use std::sync::atomic::{AtomicPtr, Ordering}; mod callable_export_check; -mod callable_exports; +pub(crate) mod callable_exports; mod constants; mod module_keys; mod namespace_builders; 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 c0136cb3c5..8781130bb1 100644 --- a/crates/perry-runtime/src/object/native_module/callable_exports.rs +++ b/crates/perry-runtime/src/object/native_module/callable_exports.rs @@ -52,169 +52,15 @@ pub(crate) fn bound_native_callable_export_value(module_name: &str, property_nam let mut value = crate::value::js_nanbox_pointer(closure as i64); let closure_addr = closure as usize; - if export_module_name == "module" && property_name == "Module" { - attach_module_cjs_constructor_statics(closure_addr); - } - if export_module_name == "tty" && matches!(property_name, "ReadStream" | "WriteStream") { - attach_tty_stream_prototype(value, property_name); - } - if export_module_name == "tls" && property_name == "SecureContext" { - attach_tls_secure_context_prototype(value); - } - if export_module_name == "wasi" && property_name == "WASI" { - crate::wasi::attach_wasi_constructor_prototype(value); - } - if export_module_name == "stream" && property_name == "Stream" { - attach_stream_legacy_prototype(value); - } - if export_module_name == "stream" - && matches!( - property_name, - "Readable" | "Writable" | "Duplex" | "Transform" | "PassThrough" - ) + // Per-module prototype/statics decoration, routed through the attach + // registry (see `native_module_registry::nm_attach_lookup`): each + // module's handler is registered by its `js_nm_install_()`, and + // this path is only reachable through that module's namespace — so a + // binary links exactly the attach machinery of the modules it imports. + if let Some(attach) = super::super::native_module_registry::nm_attach_lookup(export_module_name) { - attach_stream_constructor_prototype(value, property_name); - } - // #6692: Node defines `stream.pipeline[util.promisify.custom]` and - // `stream.finished[util.promisify.custom]` pointing at the promise-based - // `stream/promises` implementations, so `promisify(stream.pipeline)` returns - // that impl rather than the generic callback-appending wrapper. Wire the - // same hooks so `custom_promisified_value` (util_promisify.rs) honors them. - if export_module_name == "stream" && matches!(property_name, "pipeline" | "finished") { - // Reassign: the attach helper roots `value` and allocates (which may - // evacuate the closure), so it returns the possibly-relocated pointer. - value = attach_stream_promisify_custom(value, property_name); - } - if export_module_name == "sqlite" && property_name == "DatabaseSync" { - attach_sqlite_database_sync_prototype(value); - } - if export_module_name == "sqlite" && property_name == "Session" { - attach_sqlite_session_prototype(value); - } - if export_module_name == "assert" && property_name == "Assert" { - attach_assert_prototype(value); - } - if export_module_name == "crypto" && property_name == "KeyObject" { - attach_crypto_key_object_shape(closure_addr, value); - } - if export_module_name == "crypto" && property_name == "X509Certificate" { - attach_crypto_x509_certificate_shape(closure_addr, value); - } - - // `PerformanceObserver.supportedEntryTypes` is a static array on the - // constructor. `PerformanceObserver` is a function value (a bound-method - // closure), so hang the array off it as a dynamic property — keeps - // `typeof PerformanceObserver === "function"` while the static read works. - if export_module_name == "perf_hooks" && property_name == "PerformanceObserver" { - let arr = crate::perf_hooks::js_perf_supported_entry_types(); - crate::closure::closure_set_dynamic_prop(closure_addr, "supportedEntryTypes", arr); - } - - if export_module_name == "async_hooks" && property_name == "AsyncLocalStorage" { - crate::closure::closure_set_dynamic_prop( - closure_addr, - "bind", - 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, - "snapshot", - async_hooks_static_method_value( - crate::async_hooks::js_async_local_storage_static_snapshot_method as *const u8, - "snapshot", - 0, - 0, - ), - ); - } - - if export_module_name == "async_hooks" && property_name == "AsyncResource" { - crate::closure::closure_set_dynamic_prop( - closure_addr, - "bind", - async_hooks_static_method_value( - crate::async_hooks::js_async_resource_static_bind_method as *const u8, - "bind", - 3, - 3, - ), - ); - } - - if export_module_name == "events" && property_name == "EventEmitter" { - let async_resource_ctor = - bound_native_callable_export_value("events", "EventEmitterAsyncResource"); - for method in [ - "addAbortListener", - "once", - "on", - "getEventListeners", - "getMaxListeners", - "listenerCount", - "setMaxListeners", - ] { - let method_value = bound_native_callable_export_value("events", method); - crate::closure::closure_set_dynamic_prop(closure_addr, method, method_value); - } - crate::closure::closure_set_dynamic_prop(closure_addr, "EventEmitter", value); - crate::closure::closure_set_dynamic_prop( - closure_addr, - "EventEmitterAsyncResource", - async_resource_ctor, - ); - crate::closure::closure_set_dynamic_prop(closure_addr, "defaultMaxListeners", 10.0); - crate::closure::closure_set_dynamic_prop( - closure_addr, - "usingDomains", - f64::from_bits(JSValue::bool(false).bits()), - ); - crate::closure::closure_set_dynamic_prop( - closure_addr, - "captureRejections", - f64::from_bits(JSValue::bool(false).bits()), - ); - crate::closure::closure_set_dynamic_prop(closure_addr, "captureRejectionSymbol", { - let name = "nodejs.rejection"; - let ptr = crate::string::js_string_from_bytes(name.as_ptr(), name.len() as u32); - unsafe { crate::symbol::js_symbol_for(f64::from_bits(JSValue::string_ptr(ptr).bits())) } - }); - crate::closure::closure_set_dynamic_prop(closure_addr, "errorMonitor", { - let name = "events.errorMonitor"; - let ptr = crate::string::js_string_from_bytes(name.as_ptr(), name.len() as u32); - unsafe { crate::symbol::js_symbol_for(f64::from_bits(JSValue::string_ptr(ptr).bits())) } - }); - crate::closure::closure_set_dynamic_prop( - closure_addr, - "init", - bound_native_callable_export_value("events", "init"), - ); - } - - if export_module_name == "util" && property_name == "promisify" { - crate::closure::closure_set_dynamic_prop( - closure_addr, - "custom", - crate::util_promisify::promisify_custom_symbol(), - ); - } - if export_module_name == "util" && property_name == "inspect" { - crate::closure::closure_set_dynamic_prop( - closure_addr, - "custom", - util_inspect_custom_symbol(), - ); - crate::closure::closure_set_dynamic_prop( - closure_addr, - "defaultOptions", - util_inspect_default_options_value(), - ); - crate::closure::closure_set_dynamic_prop(closure_addr, "styles", util_inspect_styles()); - crate::closure::closure_set_dynamic_prop(closure_addr, "colors", util_inspect_colors()); + // SAFETY: registry only ever holds the `nm_attach_*` handlers below. + value = unsafe { attach(property_name, value, closure_addr) }; } NATIVE_CALLABLE_EXPORTS.with(|c| { @@ -1719,3 +1565,277 @@ pub(crate) fn builtin_closure_is_non_constructable_value(value: f64) -> bool { } builtin_closure_is_non_constructable(ptr as usize) } + +// --------------------------------------------------------------------------- +// Per-module attach handlers (bodies moved verbatim from the former inline +// ladder). Referenced ONLY from the attach registry via each module's +// `js_nm_install_()`, so unimported modules' machinery dead-strips. +// --------------------------------------------------------------------------- + +#[allow(unused_mut)] +pub(crate) unsafe fn nm_attach_module( + property_name: &str, + mut value: f64, + closure_addr: usize, +) -> f64 { + if property_name == "Module" { + attach_module_cjs_constructor_statics(closure_addr); + } + value +} + +#[allow(unused_mut)] +pub(crate) unsafe fn nm_attach_tty( + property_name: &str, + mut value: f64, + _closure_addr: usize, +) -> f64 { + if matches!(property_name, "ReadStream" | "WriteStream") { + attach_tty_stream_prototype(value, property_name); + } + value +} + +#[allow(unused_mut)] +pub(crate) unsafe fn nm_attach_tls( + property_name: &str, + mut value: f64, + _closure_addr: usize, +) -> f64 { + if property_name == "SecureContext" { + attach_tls_secure_context_prototype(value); + } + value +} + +#[allow(unused_mut)] +pub(crate) unsafe fn nm_attach_wasi( + property_name: &str, + mut value: f64, + _closure_addr: usize, +) -> f64 { + if property_name == "WASI" { + crate::wasi::attach_wasi_constructor_prototype(value); + } + value +} + +#[allow(unused_mut)] +pub(crate) unsafe fn nm_attach_stream( + property_name: &str, + mut value: f64, + _closure_addr: usize, +) -> f64 { + if property_name == "Stream" { + attach_stream_legacy_prototype(value); + } + if true + && matches!( + property_name, + "Readable" | "Writable" | "Duplex" | "Transform" | "PassThrough" + ) + { + attach_stream_constructor_prototype(value, property_name); + } + // #6692: Node defines `stream.pipeline[util.promisify.custom]` and + // `stream.finished[util.promisify.custom]` pointing at the promise-based + // `stream/promises` implementations, so `promisify(stream.pipeline)` returns + // that impl rather than the generic callback-appending wrapper. Wire the + // same hooks so `custom_promisified_value` (util_promisify.rs) honors them. + if matches!(property_name, "pipeline" | "finished") { + // Reassign: the attach helper roots `value` and allocates (which may + // evacuate the closure), so it returns the possibly-relocated pointer. + value = attach_stream_promisify_custom(value, property_name); + } + value +} + +#[allow(unused_mut)] +pub(crate) unsafe fn nm_attach_sqlite( + property_name: &str, + mut value: f64, + _closure_addr: usize, +) -> f64 { + if property_name == "DatabaseSync" { + attach_sqlite_database_sync_prototype(value); + } + if property_name == "Session" { + attach_sqlite_session_prototype(value); + } + value +} + +#[allow(unused_mut)] +pub(crate) unsafe fn nm_attach_assert( + property_name: &str, + mut value: f64, + _closure_addr: usize, +) -> f64 { + if property_name == "Assert" { + attach_assert_prototype(value); + } + value +} + +#[allow(unused_mut)] +pub(crate) unsafe fn nm_attach_crypto( + property_name: &str, + mut value: f64, + closure_addr: usize, +) -> f64 { + if property_name == "KeyObject" { + attach_crypto_key_object_shape(closure_addr, value); + } + if property_name == "X509Certificate" { + attach_crypto_x509_certificate_shape(closure_addr, value); + } + value +} + +#[allow(unused_mut)] +pub(crate) unsafe fn nm_attach_perf_hooks( + property_name: &str, + mut value: f64, + closure_addr: usize, +) -> f64 { + // `PerformanceObserver.supportedEntryTypes` is a static array on the + // constructor. `PerformanceObserver` is a function value (a bound-method + // closure), so hang the array off it as a dynamic property — keeps + // `typeof PerformanceObserver === "function"` while the static read works. + if property_name == "PerformanceObserver" { + let arr = crate::perf_hooks::js_perf_supported_entry_types(); + crate::closure::closure_set_dynamic_prop(closure_addr, "supportedEntryTypes", arr); + } + value +} + +#[allow(unused_mut)] +pub(crate) unsafe fn nm_attach_async_hooks( + property_name: &str, + mut value: f64, + closure_addr: usize, +) -> f64 { + if property_name == "AsyncLocalStorage" { + crate::closure::closure_set_dynamic_prop( + closure_addr, + "bind", + 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, + "snapshot", + async_hooks_static_method_value( + crate::async_hooks::js_async_local_storage_static_snapshot_method as *const u8, + "snapshot", + 0, + 0, + ), + ); + } + + if property_name == "AsyncResource" { + crate::closure::closure_set_dynamic_prop( + closure_addr, + "bind", + async_hooks_static_method_value( + crate::async_hooks::js_async_resource_static_bind_method as *const u8, + "bind", + 3, + 3, + ), + ); + } + value +} + +#[allow(unused_mut)] +pub(crate) unsafe fn nm_attach_events( + property_name: &str, + mut value: f64, + closure_addr: usize, +) -> f64 { + if property_name == "EventEmitter" { + let async_resource_ctor = + bound_native_callable_export_value("events", "EventEmitterAsyncResource"); + for method in [ + "addAbortListener", + "once", + "on", + "getEventListeners", + "getMaxListeners", + "listenerCount", + "setMaxListeners", + ] { + let method_value = bound_native_callable_export_value("events", method); + crate::closure::closure_set_dynamic_prop(closure_addr, method, method_value); + } + crate::closure::closure_set_dynamic_prop(closure_addr, "EventEmitter", value); + crate::closure::closure_set_dynamic_prop( + closure_addr, + "EventEmitterAsyncResource", + async_resource_ctor, + ); + crate::closure::closure_set_dynamic_prop(closure_addr, "defaultMaxListeners", 10.0); + crate::closure::closure_set_dynamic_prop( + closure_addr, + "usingDomains", + f64::from_bits(JSValue::bool(false).bits()), + ); + crate::closure::closure_set_dynamic_prop( + closure_addr, + "captureRejections", + f64::from_bits(JSValue::bool(false).bits()), + ); + crate::closure::closure_set_dynamic_prop(closure_addr, "captureRejectionSymbol", { + let name = "nodejs.rejection"; + let ptr = crate::string::js_string_from_bytes(name.as_ptr(), name.len() as u32); + unsafe { crate::symbol::js_symbol_for(f64::from_bits(JSValue::string_ptr(ptr).bits())) } + }); + crate::closure::closure_set_dynamic_prop(closure_addr, "errorMonitor", { + let name = "events.errorMonitor"; + let ptr = crate::string::js_string_from_bytes(name.as_ptr(), name.len() as u32); + unsafe { crate::symbol::js_symbol_for(f64::from_bits(JSValue::string_ptr(ptr).bits())) } + }); + crate::closure::closure_set_dynamic_prop( + closure_addr, + "init", + bound_native_callable_export_value("events", "init"), + ); + } + value +} + +#[allow(unused_mut)] +pub(crate) unsafe fn nm_attach_util( + property_name: &str, + mut value: f64, + closure_addr: usize, +) -> f64 { + if property_name == "promisify" { + crate::closure::closure_set_dynamic_prop( + closure_addr, + "custom", + crate::util_promisify::promisify_custom_symbol(), + ); + } + if property_name == "inspect" { + crate::closure::closure_set_dynamic_prop( + closure_addr, + "custom", + util_inspect_custom_symbol(), + ); + crate::closure::closure_set_dynamic_prop( + closure_addr, + "defaultOptions", + util_inspect_default_options_value(), + ); + crate::closure::closure_set_dynamic_prop(closure_addr, "styles", util_inspect_styles()); + crate::closure::closure_set_dynamic_prop(closure_addr, "colors", util_inspect_colors()); + } + value +} diff --git a/crates/perry-runtime/src/object/native_module_registry.rs b/crates/perry-runtime/src/object/native_module_registry.rs index 0c8120b828..60dfd1c3a7 100644 --- a/crates/perry-runtime/src/object/native_module_registry.rs +++ b/crates/perry-runtime/src/object/native_module_registry.rs @@ -156,6 +156,10 @@ pub(crate) fn nm_dispatch_lookup(name: &str) -> Option { #[no_mangle] pub extern "C" fn js_nm_install_assert() { + nm_register_attach( + NmBucket::Assert, + super::native_module::callable_exports::nm_attach_assert, + ); NM_DISPATCH_REGISTRY[NmBucket::Assert as usize].store( nm_dispatch_assert as NmDispatchFn as *mut (), Ordering::Relaxed, @@ -190,6 +194,10 @@ pub extern "C" fn js_globalthis_seed_async_local_storage() { #[no_mangle] pub extern "C" fn js_nm_install_async_hooks() { + nm_register_attach( + NmBucket::AsyncHooks, + super::native_module::callable_exports::nm_attach_async_hooks, + ); NM_DISPATCH_REGISTRY[NmBucket::AsyncHooks as usize].store( nm_dispatch_async_hooks as NmDispatchFn as *mut (), Ordering::Relaxed, @@ -247,6 +255,10 @@ pub extern "C" fn js_nm_install_console() { } #[no_mangle] pub extern "C" fn js_nm_install_crypto() { + nm_register_attach( + NmBucket::Crypto, + super::native_module::callable_exports::nm_attach_crypto, + ); NM_DISPATCH_REGISTRY[NmBucket::Crypto as usize].store( nm_dispatch_crypto as NmDispatchFn as *mut (), Ordering::Relaxed, @@ -275,6 +287,10 @@ pub extern "C" fn js_nm_install_domain() { } #[no_mangle] pub extern "C" fn js_nm_install_events() { + nm_register_attach( + NmBucket::Events, + super::native_module::callable_exports::nm_attach_events, + ); NM_DISPATCH_REGISTRY[NmBucket::Events as usize].store( nm_dispatch_events as NmDispatchFn as *mut (), Ordering::Relaxed, @@ -302,6 +318,10 @@ pub extern "C" fn js_nm_install_inspector() { } #[no_mangle] pub extern "C" fn js_nm_install_module() { + nm_register_attach( + NmBucket::Module, + super::native_module::callable_exports::nm_attach_module, + ); NM_DISPATCH_REGISTRY[NmBucket::Module as usize].store( nm_dispatch_module as NmDispatchFn as *mut (), Ordering::Relaxed, @@ -335,6 +355,10 @@ pub extern "C" fn js_nm_install_path() { } #[no_mangle] pub extern "C" fn js_nm_install_perf() { + nm_register_attach( + NmBucket::Perf, + super::native_module::callable_exports::nm_attach_perf_hooks, + ); NM_DISPATCH_REGISTRY[NmBucket::Perf as usize].store( nm_dispatch_perf as NmDispatchFn as *mut (), Ordering::Relaxed, @@ -386,6 +410,10 @@ pub extern "C" fn js_nm_install_sea() { } #[no_mangle] pub extern "C" fn js_nm_install_sqlite() { + nm_register_attach( + NmBucket::Sqlite, + super::native_module::callable_exports::nm_attach_sqlite, + ); NM_DISPATCH_REGISTRY[NmBucket::Sqlite as usize].store( nm_dispatch_sqlite as NmDispatchFn as *mut (), Ordering::Relaxed, @@ -393,6 +421,10 @@ pub extern "C" fn js_nm_install_sqlite() { } #[no_mangle] pub extern "C" fn js_nm_install_stream() { + nm_register_attach( + NmBucket::Stream, + super::native_module::callable_exports::nm_attach_stream, + ); NM_DISPATCH_REGISTRY[NmBucket::Stream as usize].store( nm_dispatch_stream as NmDispatchFn as *mut (), Ordering::Relaxed, @@ -408,6 +440,10 @@ pub extern "C" fn js_nm_install_timers() { } #[no_mangle] pub extern "C" fn js_nm_install_tls() { + nm_register_attach( + NmBucket::Tls, + super::native_module::callable_exports::nm_attach_tls, + ); NM_DISPATCH_REGISTRY[NmBucket::Tls as usize].store( nm_dispatch_tls as NmDispatchFn as *mut (), Ordering::Relaxed, @@ -416,6 +452,10 @@ pub extern "C" fn js_nm_install_tls() { } #[no_mangle] pub extern "C" fn js_nm_install_tty() { + nm_register_attach( + NmBucket::Tty, + super::native_module::callable_exports::nm_attach_tty, + ); NM_DISPATCH_REGISTRY[NmBucket::Tty as usize].store( nm_dispatch_tty as NmDispatchFn as *mut (), Ordering::Relaxed, @@ -431,6 +471,10 @@ pub extern "C" fn js_nm_install_url() { } #[no_mangle] pub extern "C" fn js_nm_install_util() { + nm_register_attach( + NmBucket::Util, + super::native_module::callable_exports::nm_attach_util, + ); NM_DISPATCH_REGISTRY[NmBucket::Util as usize].store( nm_dispatch_util as NmDispatchFn as *mut (), Ordering::Relaxed, @@ -449,6 +493,10 @@ pub extern "C" fn js_nm_install_vm() { } #[no_mangle] pub extern "C" fn js_nm_install_wasi() { + nm_register_attach( + NmBucket::Wasi, + super::native_module::callable_exports::nm_attach_wasi, + ); NM_DISPATCH_REGISTRY[NmBucket::Wasi as usize].store( nm_dispatch_wasi as NmDispatchFn as *mut (), Ordering::Relaxed, @@ -590,3 +638,44 @@ pub(crate) fn nm_ctor_lookup(module: &str) -> Option { fn nm_register_ctor(b: NmBucket, f: NmCtorFn) { NM_CTOR_REGISTRY[b as usize].store(f as *mut (), Ordering::Relaxed); } + +/// Per-module callable-export "attach" handlers: prototype/statics decoration +/// for bound module exports (`stream.Readable`'s prototype tower, +/// `sqlite.DatabaseSync`'s methods, `AsyncLocalStorage.bind`, …). Formerly a +/// hand-written per-module ladder inside +/// `callable_exports::bound_native_callable_export_value`, which statically +/// pinned every module's attach machinery (and through stream, the whole +/// readable/pipeline tower) into any binary that could mint ANY bound export. +/// Registered by the same `js_nm_install_()` the dispatch/ctor +/// registries use — sound because `bound_native_callable_export_value` is +/// only reached through a module's namespace, which exists only after its +/// install ran. Signature: (property_name, value, closure_addr) → possibly +/// relocated value (attach helpers may allocate/evacuate). +pub(crate) type NmAttachFn = unsafe fn(&str, f64, usize) -> f64; + +static NM_ATTACH_REGISTRY: [AtomicPtr<()>; NM_BUCKET_COUNT] = + [const { AtomicPtr::new(std::ptr::null_mut()) }; NM_BUCKET_COUNT]; + +pub(crate) fn nm_attach_lookup(module: &str) -> Option { + let b = nm_module_index(module) + .or_else(|| nm_module_index(module.split('/').next().unwrap_or(module)))?; + let p = NM_ATTACH_REGISTRY[b as usize].load(Ordering::Relaxed); + if !p.is_null() { + return Some(unsafe { std::mem::transmute::<*mut (), NmAttachFn>(p) }); + } + #[cfg(test)] + { + js_nm_install_all(); + let p = NM_ATTACH_REGISTRY[b as usize].load(Ordering::Relaxed); + if !p.is_null() { + return Some(unsafe { std::mem::transmute::<*mut (), NmAttachFn>(p) }); + } + } + None +} + +/// Register a bucket's attach fn (runtime-indexed array slot — not +/// speculatively devirtualizable, same as the ctor registry). +fn nm_register_attach(b: NmBucket, f: NmAttachFn) { + NM_ATTACH_REGISTRY[b as usize].store(f as *mut (), Ordering::Relaxed); +}