From 05f008ed3583a5d15475f08980f39c8d4d88a07b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 17 Sep 2026 13:21:24 +0000 Subject: [PATCH 1/2] fix(runtime): route node:net and node:http exports used as values to their providers The value forms of net/http/https/http2 exports (a CommonJS require('net'), an aliased module object, a destructured or pulled-out export, new on a bound class value) reached a dispatcher that only an auto-optimized stdlib with external-http-server-pump registered, so they returned undefined under PERRY_NO_AUTO_OPTIMIZE=1 and in every net-only program. perry-ext-net and perry-ext-http now own and register their export dispatchers from their namespace installs and from the entry prologue, net classes construct through the ctor registry, the net IP helpers are callable values, the provider namespaces are cached so require('node:http') === require('http'), and perry-ext-http registers its client handle-dispatch extension so erased ClientRequest/IncomingMessage/Agent receivers work without the stdlib pump. --- crates/perry-codegen/src/codegen/entry.rs | 5 + .../perry-codegen/src/codegen/entry/tests.rs | 33 ++ crates/perry-codegen/src/codegen/opts.rs | 9 + crates/perry-codegen/src/ext_registry.rs | 25 ++ crates/perry-codegen/src/lib.rs | 1 + crates/perry-codegen/src/nm_install.rs | 41 ++- .../src/runtime_decls/objects.rs | 4 +- .../perry-ext-http/src/client_dispatch_ext.rs | 321 ++++++++++++++++++ crates/perry-ext-http/src/lib.rs | 3 + crates/perry-ext-http/src/server/mod.rs | 2 + .../src/server/native_dispatch.rs | 168 +++++++++ crates/perry-ext-http/src/test_async_shims.rs | 14 + crates/perry-ext-net/src/lib.rs | 6 +- crates/perry-ext-net/src/native_dispatch.rs | 119 +++++++ crates/perry-runtime/src/lib.rs | 5 +- .../perry-runtime/src/object/native_module.rs | 5 + .../native_module/callable_export_check.rs | 10 + .../native_module/callable_export_table.rs | 7 + .../src/object/native_module_dispatch.rs | 4 +- .../native_module_dispatch/dispatch_m_p.rs | 187 ++++++++-- .../src/object/native_module_registry.rs | 1 + crates/perry-runtime/src/value/handle.rs | 16 +- crates/perry-runtime/src/value/mod.rs | 14 +- crates/perry-runtime/src/value/tags.rs | 15 +- .../perry-stdlib/src/common/dispatch/init.rs | 273 ++------------- .../src/commands/compile/object_cache.rs | 6 + .../object_cache/object_cache_tests.rs | 11 + .../src/commands/compile/run_pipeline.rs | 19 ++ .../gap_10428_10429_module_values_helper.cjs | 35 ++ ..._10428_10429_node_module_value_dispatch.ts | 163 +++++++++ 30 files changed, 1211 insertions(+), 311 deletions(-) create mode 100644 crates/perry-ext-http/src/client_dispatch_ext.rs create mode 100644 crates/perry-ext-http/src/server/native_dispatch.rs create mode 100644 crates/perry-ext-net/src/native_dispatch.rs create mode 100644 test-files/gap_10428_10429_module_values_helper.cjs create mode 100644 test-files/test_gap_10428_10429_node_module_value_dispatch.ts diff --git a/crates/perry-codegen/src/codegen/entry.rs b/crates/perry-codegen/src/codegen/entry.rs index c94ce7ffbf..484c6d0176 100644 --- a/crates/perry-codegen/src/codegen/entry.rs +++ b/crates/perry-codegen/src/codegen/entry.rs @@ -648,6 +648,11 @@ pub(super) fn compile_module_entry( if cross_module.needs_stdlib { blk.call_void("js_stdlib_init_dispatch", &[]); } + // #10428/#10429: linked providers register their module-export + // dispatchers before any module init (see `native_provider_installs`). + for install in &cross_module.app_metadata.native_provider_installs { + blk.call_void(install, &[]); + } // Start the Geisterhand HTTP inspector if requested. The // port comes from `--geisterhand-port` (default 7676). Calling // `perry_geisterhand_start` here also pins the geisterhand diff --git a/crates/perry-codegen/src/codegen/entry/tests.rs b/crates/perry-codegen/src/codegen/entry/tests.rs index ba2c5bd511..a8d559edce 100644 --- a/crates/perry-codegen/src/codegen/entry/tests.rs +++ b/crates/perry-codegen/src/codegen/entry/tests.rs @@ -395,6 +395,39 @@ fn executable_seeds_process_argv_script_path_but_dylib_does_not() { ); } +/// #10428/#10429: every linked provider's install wrapper runs from the entry +/// prologue (executable and dylib alike), BEFORE module initializers — a +/// CommonJS `require('net')` creates its module object at runtime, where no +/// codegen install is emitted. Nothing is emitted when the list is empty. +#[test] +fn entry_prologue_calls_native_provider_installs_before_module_init() { + for output_type in ["executable", "dylib"] { + let mut opts = entry_opts(output_type); + opts.non_entry_module_prefixes = vec!["lib_cjs".to_string()]; + opts.app_metadata.native_provider_installs = vec![ + "js_ext_http_nm_install".to_string(), + "js_ext_net_nm_install".to_string(), + ]; + let ir = String::from_utf8(compile_module(&empty_module(), opts).unwrap()).unwrap(); + let http = ir.find("call void @js_ext_http_nm_install()"); + let net = ir.find("call void @js_ext_net_nm_install()"); + let init = ir + .find("call void @lib_cjs__init()") + .unwrap_or_else(|| panic!("{output_type}: module init not called\n{ir}")); + assert!( + http.is_some() && net.is_some(), + "{output_type}: missing installs\n{ir}" + ); + assert!( + net.unwrap() < init, + "{output_type}: install after module init\n{ir}" + ); + } + let ir = emitted_ir("executable"); + assert!(!ir.contains("call void @js_ext_net_nm_install()"), "{ir}"); + assert!(!ir.contains("call void @js_ext_http_nm_install()"), "{ir}"); +} + #[test] fn executable_and_app_dylib_both_register_lazy_path_initializers() { for output_type in ["executable", "dylib"] { diff --git a/crates/perry-codegen/src/codegen/opts.rs b/crates/perry-codegen/src/codegen/opts.rs index 0b243882c6..d262040d7b 100644 --- a/crates/perry-codegen/src/codegen/opts.rs +++ b/crates/perry-codegen/src/codegen/opts.rs @@ -32,6 +32,14 @@ pub struct AppMetadata { /// `process.argv[1]` with the script path, matching Node/Bun's argv shape. /// It is compiler metadata rather than a user-configurable manifest field. pub entry_source_path: Option, + /// Install wrappers of the well-known native providers this program links + /// (`js_ext_net_nm_install`, …; see `native_provider_install_symbols`). + /// Set only on the entry module, whose `main` / dylib initializer calls + /// each one before any module initializer runs, so module objects the + /// runtime creates itself — a CommonJS `require('net')` resolves through + /// `createRequire` — already reach the provider's export dispatcher. + /// (#10428, #10429) + pub native_provider_installs: Vec, } impl Default for AppMetadata { @@ -43,6 +51,7 @@ impl Default for AppMetadata { app_group: None, update_config: None, entry_source_path: None, + native_provider_installs: Vec::new(), } } } diff --git a/crates/perry-codegen/src/ext_registry.rs b/crates/perry-codegen/src/ext_registry.rs index d40f4283a6..9017d4fdd8 100644 --- a/crates/perry-codegen/src/ext_registry.rs +++ b/crates/perry-codegen/src/ext_registry.rs @@ -113,6 +113,10 @@ const FFI_REGISTRY: &[(&str, OwnerKind)] = &[ // The Bun dispatch bucket can reach listen/connect through extracted // callable exports, so installing it also activates the net provider. ("js_bun_tcp_nm_install", OwnerKind::WellKnown("net")), + // #10428/#10429: a materialized `net` / `http`/`https`/`http2` namespace + // or bound export installs its provider's value-form dispatcher. + ("js_ext_net_nm_install", OwnerKind::WellKnown("net")), + ("js_ext_http_nm_install", OwnerKind::WellKnown("http")), // ── #835: Web Streams ──────────────────────────────────────────── // `perry-stdlib::streams` owns the canonical implementations. // `perry-ext-streams` re-implements a subset, but `js_stream_unwrap_handle` @@ -1054,6 +1058,27 @@ mod tests { ); } + /// #10428/#10429: materializing a `net` / `http`/`https`/`http2` + /// namespace (or a bound export such as `require('net').createConnection`) + /// emits the PROVIDER's install wrapper, which registers the value-form + /// dispatcher. It lives in the ext crate, so emitting it must flip that + /// crate onto the link line even when no import made it visible. + #[test] + fn provider_namespace_installs_route_to_their_well_known_binding() { + let _guard = ProviderTestGuard::new(); + for (module, owner) in [ + ("net", "net"), + ("node:net", "net"), + ("http", "http"), + ("node:https", "http"), + ("http2", "http"), + ] { + let symbol = crate::nm_install::nm_install_symbol(module) + .unwrap_or_else(|| panic!("{module} has no namespace install symbol")); + assert_symbol_routes_to(symbol, OwnerKind::WellKnown(owner)); + } + } + #[test] fn bun_serve_routes_to_http_and_fetch_providers() { let _guard = ProviderTestGuard::new(); diff --git a/crates/perry-codegen/src/lib.rs b/crates/perry-codegen/src/lib.rs index c8e5f33efa..9da92cfed1 100644 --- a/crates/perry-codegen/src/lib.rs +++ b/crates/perry-codegen/src/lib.rs @@ -44,6 +44,7 @@ pub mod native_emit; mod native_root_coverage; pub(crate) mod native_value; pub(crate) mod nm_install; +pub use nm_install::native_provider_install_symbols; pub mod opt_report; pub(crate) mod root_reload; pub mod rooting; diff --git a/crates/perry-codegen/src/nm_install.rs b/crates/perry-codegen/src/nm_install.rs index 8610269981..dc2f7eab33 100644 --- a/crates/perry-codegen/src/nm_install.rs +++ b/crates/perry-codegen/src/nm_install.rs @@ -28,14 +28,17 @@ pub(crate) fn nm_install_symbol(name: &str) -> Option<&'static str> { "domain" => Some("js_nm_install_domain"), "events" => Some("js_nm_install_events"), "fs" => Some("js_nm_install_fs"), - "http" | "http2" | "https" => Some("js_nm_install_http"), + // #10428/#10429: http/https/http2 and net exports are implemented by + // their well-known providers, whose install wrappers register the + // value-form dispatcher before chaining to the runtime bucket install. + "http" | "http2" | "https" => Some("js_ext_http_nm_install"), "inspector" | "inspector.Network" | "inspector.NetworkResources" | "inspector.DOMStorage" | "inspector/promises" => Some("js_nm_install_inspector"), "module" => Some("js_nm_install_module"), - "net" => Some("js_nm_install_net"), + "net" => Some("js_ext_net_nm_install"), // #6563: node-pty + the API-identical @lydell fork share one bucket. "node-pty" | "@lydell/node-pty" | "bun-pty" => Some("js_nm_install_node_pty"), "os" => Some("js_nm_install_os"), @@ -78,6 +81,25 @@ pub(crate) fn nm_install_symbol(name: &str) -> Option<&'static str> { } } +/// #10428/#10429: the provider-owned install wrappers (`js_ext_*_nm_install`) +/// for a program's native module imports, sorted and deduplicated. Their +/// crates are linked whenever the module is imported, and the entry prologue +/// calls each one so runtime-created module objects (a CommonJS +/// `require('net')`) reach the provider's export dispatcher too. +pub fn native_provider_install_symbols<'a>( + modules: impl IntoIterator, +) -> Vec { + let mut symbols: Vec = modules + .into_iter() + .filter_map(nm_install_symbol) + .filter(|symbol| symbol.starts_with("js_ext_")) + .map(str::to_string) + .collect(); + symbols.sort_unstable(); + symbols.dedup(); + symbols +} + /// All dispatch-install symbols + the dynamic fallback — declared so codegen can /// emit calls to them. #[allow(dead_code)] // consumed only by codegen configurations that emit dispatch declarations @@ -98,10 +120,10 @@ pub(crate) const NM_INSTALL_SYMBOLS: &[&str] = &[ "js_nm_install_domain", "js_nm_install_events", "js_nm_install_fs", - "js_nm_install_http", + "js_ext_http_nm_install", "js_nm_install_inspector", "js_nm_install_module", - "js_nm_install_net", + "js_ext_net_nm_install", "js_nm_install_node_pty", "js_nm_install_os", "js_nm_install_path", @@ -184,7 +206,16 @@ pub(crate) const NM_SUBMOD_INSTALL_SYMBOLS: &[&str] = &[ #[cfg(test)] mod tests { - use super::nm_install_symbol; + use super::{native_provider_install_symbols, nm_install_symbol}; + + #[test] + fn provider_installs_cover_net_and_the_http_family_once() { + assert_eq!( + native_provider_install_symbols(["fs", "net", "node:http", "https", "http2", "bun"]), + vec!["js_ext_http_nm_install", "js_ext_net_nm_install"] + ); + assert!(native_provider_install_symbols(["fs", "path", "events"]).is_empty()); + } #[test] fn top_level_test_module_installs_its_submodule_registry() { diff --git a/crates/perry-codegen/src/runtime_decls/objects.rs b/crates/perry-codegen/src/runtime_decls/objects.rs index 3f39151452..e8f1bfd637 100644 --- a/crates/perry-codegen/src/runtime_decls/objects.rs +++ b/crates/perry-codegen/src/runtime_decls/objects.rs @@ -339,10 +339,10 @@ pub fn declare_phase_b_objects(module: &mut LlModule) { module.declare_function("js_nm_install_domain", VOID, &[]); module.declare_function("js_nm_install_events", VOID, &[]); module.declare_function("js_nm_install_fs", VOID, &[]); - module.declare_function("js_nm_install_http", VOID, &[]); + module.declare_function("js_ext_http_nm_install", VOID, &[]); module.declare_function("js_nm_install_inspector", VOID, &[]); module.declare_function("js_nm_install_module", VOID, &[]); - module.declare_function("js_nm_install_net", VOID, &[]); + module.declare_function("js_ext_net_nm_install", VOID, &[]); module.declare_function("js_nm_install_node_pty", VOID, &[]); module.declare_function("js_nm_install_os", VOID, &[]); module.declare_function("js_nm_install_path", VOID, &[]); diff --git a/crates/perry-ext-http/src/client_dispatch_ext.rs b/crates/perry-ext-http/src/client_dispatch_ext.rs new file mode 100644 index 0000000000..4ce70b7bb3 --- /dev/null +++ b/crates/perry-ext-http/src/client_dispatch_ext.rs @@ -0,0 +1,321 @@ +//! Runtime handle-dispatch EXTENSION registration for HTTP-client handles +//! (`ClientRequest`, the client `IncomingMessage`, `Agent`). +//! +//! The client-side twin of `server/dispatch_ext.rs` (Wall 10). perry-stdlib's +//! `js_handle_{method,property,property_set}_dispatch` carry these arms behind +//! `external-http-client-pump`, which only an auto-optimized stdlib has. The +//! prebuilt `full` archive `PERRY_NO_AUTO_OPTIMIZE=1` links compiles them OUT, +//! so a client handle reached through an erased receiver — the `req` returned +//! by `const request = http.request; request(opts)` in ws, or any handle passed +//! through untyped code — answered `undefined` for `req.on` / `req.end` and the +//! request was never sent (#10428). +//! +//! Registered extensions run before the stdlib primary regardless of its +//! features. Each one claims a call only when the handle belongs to this crate +//! AND the name is in the same vocabulary the stdlib arms gate on (keep these +//! lists in sync with `perry-stdlib/src/common/dispatch_http.rs` and the +//! `external-http-client-pump` arms in `dispatch/{method,property}_dispatch.rs` +//! and `dispatch/init.rs`); any other name falls through unchanged. + +use std::sync::Once; + +use perry_ffi::StringHeader; + +const TAG_UNDEFINED: u64 = 0x7FFC_0000_0000_0001; +const POINTER_TAG: u64 = 0x7FFD_0000_0000_0000; +const PTR_MASK: u64 = 0x0000_FFFF_FFFF_FFFF; + +extern "C" { + fn js_register_handle_method_dispatch_extension( + f: unsafe extern "C" fn(i64, *const u8, usize, *const f64, usize, *mut f64) -> i32, + ); + fn js_register_handle_property_dispatch_extension( + f: unsafe extern "C" fn(i64, *const u8, usize, *mut f64) -> i32, + ); + fn js_register_handle_property_set_dispatch_extension( + f: unsafe extern "C" fn(i64, *const u8, usize, f64) -> i32, + ); + fn js_class_method_bind( + instance: f64, + method_name_ptr: *const u8, + method_name_len: usize, + ) -> f64; +} + +/// Register the three client handle-dispatch extensions. Called from the +/// client's one-time initialization, which every request and agent factory +/// runs before a handle exists. +pub(crate) fn ensure_registered() { + static REGISTER: Once = Once::new(); + REGISTER.call_once(|| unsafe { + js_register_handle_method_dispatch_extension(http_client_method_dispatch_ext); + js_register_handle_property_dispatch_extension(http_client_property_dispatch_ext); + js_register_handle_property_set_dispatch_extension(http_client_property_set_dispatch_ext); + }); +} + +fn is_agent_method(name: &str) -> bool { + matches!( + name, + "getName" | "destroy" | "keepSocketAlive" | "reuseSocket" | "createConnection" + ) +} + +fn is_agent_property(name: &str) -> bool { + matches!( + name, + "createConnection" + | "createSocket" + | "keepSocketAlive" + | "reuseSocket" + | "getName" + | "destroy" + | "maxSockets" + | "maxFreeSockets" + | "maxTotalSockets" + | "totalSocketCount" + | "keepAliveMsecs" + | "agentKeepAliveTimeoutBuffer" + | "keepAlive" + | "destroyed" + | "defaultPort" + | "protocol" + | "sockets" + | "freeSockets" + | "requests" + | "_sessionCache" + ) +} + +fn is_agent_writable(name: &str) -> bool { + matches!( + name, + "maxSockets" + | "maxFreeSockets" + | "maxTotalSockets" + | "keepAliveMsecs" + | "agentKeepAliveTimeoutBuffer" + | "keepAlive" + | "createConnection" + | "createSocket" + ) +} + +fn is_client_request_method(name: &str) -> bool { + matches!( + name, + "end" + | "write" + | "setHeader" + | "setTimeout" + | "listenerCount" + | "getHeader" + | "hasHeader" + | "removeHeader" + | "getHeaderNames" + | "getHeaders" + | "getRawHeaderNames" + | "abort" + | "destroy" + | "flushHeaders" + | "cork" + | "uncork" + | "setNoDelay" + | "setSocketKeepAlive" + | "on" + | "once" + | "addListener" + | "prependListener" + | "removeListener" + | "off" + | "removeAllListeners" + ) +} + +fn is_client_request_property(name: &str) -> bool { + is_client_request_method(name) + || matches!( + name, + "method" + | "protocol" + | "host" + | "path" + | "aborted" + | "destroyed" + | "finished" + | "reusedSocket" + | "maxHeadersCount" + | "writableEnded" + | "writableFinished" + | "socket" + | "connection" + | "constructor" + ) +} + +fn is_incoming_message_property(name: &str) -> bool { + matches!( + name, + "statusCode" + | "statusMessage" + | "headers" + | "trailers" + | "setEncoding" + | "socket" + | "connection" + | "req" + ) +} + +#[inline] +unsafe fn name_str<'a>(ptr: *const u8, len: usize) -> &'a str { + if ptr.is_null() || len == 0 { + "" + } else { + std::str::from_utf8(std::slice::from_raw_parts(ptr, len)).unwrap_or("") + } +} + +#[inline] +unsafe fn claim(out: *mut f64, value: f64) -> i32 { + if !out.is_null() { + *out = value; + } + 1 +} + +/// Mirrors perry-stdlib's `dispatch_client_incoming_method` plus the +/// `pause`/`resume` arm (Node's `Readable.pause()/resume()` return `this`). +unsafe fn incoming_message_method(handle: i64, name: &str, args: &[f64]) -> Option { + if !matches!( + name, + "setEncoding" | "on" | "once" | "addListener" | "pipe" | "pause" | "resume" + ) || crate::client_surface::js_http_is_incoming_message(handle) == 0 + { + return None; + } + let self_ref = f64::from_bits(POINTER_TAG | (handle as u64 & PTR_MASK)); + let arg_ptr = |n: usize| (args[n].to_bits() & PTR_MASK) as *const StringHeader; + Some(match name { + "pause" | "resume" => self_ref, + "setEncoding" if !args.is_empty() => { + crate::client_surface::js_http_incoming_message_set_encoding(handle, arg_ptr(0)); + self_ref + } + "on" | "addListener" if args.len() >= 2 => { + crate::js_http_on(handle, arg_ptr(0), (args[1].to_bits() & PTR_MASK) as i64); + self_ref + } + "once" if args.len() >= 2 => { + crate::js_http_once(handle, arg_ptr(0), (args[1].to_bits() & PTR_MASK) as i64); + self_ref + } + "pipe" if !args.is_empty() => { + crate::client_surface::js_http_incoming_message_pipe(handle, args[0]) + } + _ => f64::from_bits(TAG_UNDEFINED), + }) +} + +unsafe extern "C" fn http_client_method_dispatch_ext( + handle: i64, + method_ptr: *const u8, + method_len: usize, + args_ptr: *const f64, + args_len: usize, + out: *mut f64, +) -> i32 { + let name = name_str(method_ptr, method_len); + if name.is_empty() { + return 0; + } + if is_agent_method(name) && crate::js_ext_http_agent_is_handle(handle) != 0 { + let v = crate::js_ext_http_agent_dispatch_method( + handle, method_ptr, method_len, args_ptr, args_len, + ); + return claim(out, v); + } + if is_client_request_method(name) + && crate::client_request_surface::js_ext_http_client_request_is_handle(handle) != 0 + { + let v = crate::client_request_surface::js_ext_http_client_request_dispatch_method( + handle, method_ptr, method_len, args_ptr, args_len, + ); + return claim(out, v); + } + let args: &[f64] = if args_ptr.is_null() || args_len == 0 { + &[] + } else { + std::slice::from_raw_parts(args_ptr, args_len) + }; + match incoming_message_method(handle, name, args) { + Some(v) => claim(out, v), + None => 0, + } +} + +unsafe extern "C" fn http_client_property_dispatch_ext( + handle: i64, + property_ptr: *const u8, + property_len: usize, + out: *mut f64, +) -> i32 { + let name = name_str(property_ptr, property_len); + if name.is_empty() { + return 0; + } + if is_agent_property(name) && crate::js_ext_http_agent_is_handle(handle) != 0 { + let v = crate::js_ext_http_agent_dispatch_property(handle, property_ptr, property_len); + return claim(out, v); + } + if is_client_request_property(name) + && crate::client_request_surface::js_ext_http_client_request_is_handle(handle) != 0 + { + let v = crate::client_request_surface::js_ext_http_client_request_dispatch_property( + handle, + property_ptr, + property_len, + ); + return claim(out, v); + } + if !is_incoming_message_property(name) + || crate::client_surface::js_http_is_incoming_message(handle) == 0 + { + return 0; + } + use crate::client_surface as im; + let v = match name { + "setEncoding" => { + // Same bound-method value perry-stdlib's arm produces. + js_class_method_bind(handle as f64, name.as_ptr(), name.len()) + } + "statusCode" => im::js_http_status_code(handle), + "statusMessage" => { + let ptr = im::js_http_status_message(handle); + if ptr.is_null() { + f64::from_bits(TAG_UNDEFINED) + } else { + f64::from_bits(0x7FFF_0000_0000_0000 | (ptr as u64 & PTR_MASK)) + } + } + "headers" => im::js_http_response_headers(handle), + "trailers" => im::js_http_response_trailers(handle), + "socket" | "connection" => im::js_http_incoming_message_socket(handle), + _ => im::js_http_incoming_message_req(handle), + }; + claim(out, v) +} + +unsafe extern "C" fn http_client_property_set_dispatch_ext( + handle: i64, + property_ptr: *const u8, + property_len: usize, + value: f64, +) -> i32 { + let name = name_str(property_ptr, property_len); + if !is_agent_writable(name) || crate::js_ext_http_agent_is_handle(handle) == 0 { + return 0; + } + crate::js_ext_http_agent_dispatch_property_set(handle, property_ptr, property_len, value); + 1 +} diff --git a/crates/perry-ext-http/src/lib.rs b/crates/perry-ext-http/src/lib.rs index e58c9dbefa..758a572f87 100644 --- a/crates/perry-ext-http/src/lib.rs +++ b/crates/perry-ext-http/src/lib.rs @@ -75,6 +75,8 @@ use client_dispatch::dispatch_request; // Client-request event drain helpers (#4905) — extracted from this file // to stay under the 2000-line lint cap. mod client_abort; +// #10428: erased-receiver dispatch for client handles without the stdlib pump. +mod client_dispatch_ext; mod client_events; mod client_surface; pub(crate) use client_surface::*; @@ -409,6 +411,7 @@ pub(crate) fn ensure_gc_scanner_registered() { unsafe { js_register_http_agent_handle_probe(http_agent_probe); } + client_dispatch_ext::ensure_registered(); }); } diff --git a/crates/perry-ext-http/src/server/mod.rs b/crates/perry-ext-http/src/server/mod.rs index 5f9432c073..5e98a2189f 100644 --- a/crates/perry-ext-http/src/server/mod.rs +++ b/crates/perry-ext-http/src/server/mod.rs @@ -61,6 +61,8 @@ mod http2_session_settings; mod http2_settings; mod http2_stream_props; mod https_server; +// #10428: runtime callback for http/https/http2 exports used as values. +mod native_dispatch; mod raw_upgrade; mod request; mod response; diff --git a/crates/perry-ext-http/src/server/native_dispatch.rs b/crates/perry-ext-http/src/server/native_dispatch.rs new file mode 100644 index 0000000000..f0f26a7afd --- /dev/null +++ b/crates/perry-ext-http/src/server/native_dispatch.rs @@ -0,0 +1,168 @@ +//! `node:http` / `node:https` / `node:http2` exports reached as VALUES +//! (#2533, #4904, #10428). +//! +//! The method-call form on an import binding (`http.createServer(...)`) lowers +//! through codegen's static native table. A captured / aliased export — a +//! CommonJS `require('http').createServer(cb)`, `const { request } = +//! require('http')`, ws' `const request = isSecure ? https.request : +//! http.request`, fastify's `http.createServer(options.http, handler)` — instead +//! reaches perry-runtime's http dispatch bucket as a bound-method closure, and +//! the runtime calls back through `js_set_native_http_dispatch`. +//! +//! This dispatcher used to live in perry-stdlib and was registered only when +//! the stdlib was rebuilt with `external-http-server-pump`. The prebuilt +//! archives `PERRY_NO_AUTO_OPTIMIZE=1` links never have that feature, so every +//! value-form call returned `undefined`. It now lives with the implementations +//! it routes to and is registered by `js_ext_http_nm_install`, the install +//! symbol codegen emits wherever it materializes an http/https/http2 namespace +//! or bound export — in every compile mode. + +use perry_ffi::{ArrayHeader, JsValue, TransientRootScope}; + +use super::types::js_value_is_closure; + +const TAG_UNDEFINED: u64 = 0x7FFC_0000_0000_0001; +const POINTER_TAG: u64 = 0x7FFD_0000_0000_0000; +const POINTER_MASK: u64 = 0x0000_FFFF_FFFF_FFFF; + +type HttpDispatchFn = + unsafe extern "C" fn(*const u8, usize, *const u8, usize, *const f64, usize) -> f64; + +extern "C" { + fn js_set_native_http_dispatch(f: HttpDispatchFn); + fn js_nm_install_http(); +} + +/// Install the runtime's http dispatch bucket together with this crate's +/// export dispatcher, so the first value-form call already has a provider. +#[no_mangle] +pub unsafe extern "C" fn js_ext_http_nm_install() { + js_set_native_http_dispatch(js_ext_http_native_dispatch); + js_nm_install_http(); +} + +/// The caller's argument buffer. Slots are re-read at each use rather than +/// borrowed as a slice: the factories below can collect, and a moving +/// collection rewrites rooted slots in place. +#[derive(Clone, Copy)] +struct Args { + ptr: *const f64, + len: usize, +} + +impl Args { + unsafe fn get(self, n: usize) -> f64 { + if n < self.len { + *self.ptr.add(n) + } else { + f64::from_bits(TAG_UNDEFINED) + } + } +} + +fn handle_value(handle: i64) -> f64 { + if handle == 0 { + f64::from_bits(TAG_UNDEFINED) + } else { + f64::from_bits(POINTER_TAG | (handle as u64 & POINTER_MASK)) + } +} + +/// `get` / `request` keep the COMPLETE argument list and go through the same +/// overload normalizer as a statically-known call (#4975): picking only the +/// first non-closure argument lost `(url, options, callback)` and treated +/// WHATWG URL objects as plain option bags. +unsafe fn client_overload(module: &str, method: &str, args: Args) -> i64 { + let roots = TransientRootScope::enter(); + let rooted_args = (0..args.len) + .map(|n| roots.root_nanbox(args.get(n))) + .collect::>(); + let array = roots.root_addr(perry_ffi::js_array_alloc(args.len as u32) as i64); + for arg in rooted_args { + let _ = perry_ffi::js_array_push( + array.get() as *mut ArrayHeader, + JsValue::from_bits(arg.get().to_bits()), + ); + } + let array = array.get(); + match (module, method) { + ("http", "get") => crate::js_http_get_overload(array), + ("http", "request") => crate::js_http_request_overload(array), + ("https", "get") => crate::js_https_get_overload(array), + _ => crate::js_https_request_overload(array), + } +} + +/// Node's overloads are `createServer([options][, requestListener])`, while +/// `@hono/node-server` calls `createServer(serverOptions, requestListener)`. +/// Each arg is classified by type rather than position — the function/closure +/// arg is the handler, the remaining object arg is the options — so both +/// orders work. +unsafe fn create_server(module: &str, method: &str, args: Args) -> i64 { + let mut handler_ptr: i64 = 0; + let mut options = f64::from_bits(TAG_UNDEFINED); + for n in 0..args.len.min(2) { + let arg = args.get(n); + if js_value_is_closure(arg.to_bits() as i64) != 0 { + handler_ptr = (arg.to_bits() & POINTER_MASK) as i64; + } else if JsValue::from_bits(arg.to_bits()).is_pointer() { + options = arg; + } + } + let handler = handle_value(handler_ptr); + match module { + "http" => super::js_node_http_create_server_with_options(options, handler), + "https" => super::js_node_https_create_server(options, handler_ptr), + "http2" if method == "createSecureServer" => { + super::js_node_http2_create_secure_server(options, handler_ptr) + } + "http2" => super::js_node_http2_create_server(options, handler), + _ => 0, + } +} + +/// Runtime callback for the http/https/http2 (and `bun.serve`) value forms. +/// Each arm calls the entry point the static native-table row for that export +/// uses; construction (`new http.Agent(opts)`, `new http.IncomingMessage(s)`) +/// reaches the same arms through perry-runtime's class-registry http arm. +/// +/// # Safety +/// The name pointers must describe UTF-8 bytes; `args_ptr` must point to +/// `args_len` NaN-boxed values (or be null when `args_len` is 0). +#[no_mangle] +pub unsafe extern "C" fn js_ext_http_native_dispatch( + module_ptr: *const u8, + module_len: usize, + method_ptr: *const u8, + method_len: usize, + args_ptr: *const f64, + args_len: usize, +) -> f64 { + let name = |ptr: *const u8, len: usize| { + if ptr.is_null() { + "" + } else { + std::str::from_utf8(std::slice::from_raw_parts(ptr, len)).unwrap_or("") + } + }; + let module = name(module_ptr, module_len); + let method = name(method_ptr, method_len); + let args = Args { + ptr: args_ptr, + len: if args_ptr.is_null() { 0 } else { args_len }, + }; + let arg = |n: usize| args.get(n); + let handle = match (module, method) { + ("bun", "serve") => super::bun_server::js_bun_serve(arg(0)), + ("http", "OutgoingMessage") => super::js_node_http_outgoing_message_new(), + ("http", "IncomingMessage") => super::js_node_http_incoming_message_standalone_new(arg(0)), + ("http", "ServerResponse") => super::js_node_http_server_response_standalone_new(arg(0)), + ("http" | "https", "get" | "request") => client_overload(module, method, args), + ("http", "Agent") => crate::js_http_agent_new(arg(0)), + ("https", "Agent") => crate::js_https_agent_new(arg(0)), + ("http", "ClientRequest") => crate::js_http_client_request_standalone_new(arg(0)), + ("http" | "https" | "http2", _) => create_server(module, method, args), + _ => 0, + }; + handle_value(handle) +} diff --git a/crates/perry-ext-http/src/test_async_shims.rs b/crates/perry-ext-http/src/test_async_shims.rs index a2a6f2146c..3b9b496506 100644 --- a/crates/perry-ext-http/src/test_async_shims.rs +++ b/crates/perry-ext-http/src/test_async_shims.rs @@ -73,3 +73,17 @@ pub extern "C" fn perry_ffi_spawn_async(_ctx: *mut c_void) {} // this crate's test binaries pulls the extern in with it. #[no_mangle] pub extern "C" fn perry_ffi_run_pending(_budget_ms: u64) {} + +// #10428: the client handle-dispatch extension registered from +// `ensure_gc_scanner_registered` references the Agent's `createConnection` +// path, which retains perry-ext-net's TLS connect and its host-provided +// SNI/ALPN preflight hook. Same no-op shim perry-ext-net's own tests use. +#[no_mangle] +pub extern "C" fn js_tls_client_preflight( + _port: f64, + _servername_ptr: *const u8, + _servername_len: usize, + _options: f64, +) -> i32 { + 0 +} diff --git a/crates/perry-ext-net/src/lib.rs b/crates/perry-ext-net/src/lib.rs index 3420a85eb3..0fca51e553 100644 --- a/crates/perry-ext-net/src/lib.rs +++ b/crates/perry-ext-net/src/lib.rs @@ -45,6 +45,8 @@ mod ip; // `BytesMut` per read. See `buffer_pool.rs` for the rationale. mod buffer_pool; mod bun_tcp; +// #10429: runtime callback for `net` exports used as values. +mod native_dispatch; mod tls; pub use tls::{js_ext_tls_connect, js_tls_connect}; // #2131 — lifecycle / EventEmitter surface for `net.Socket` + `net.Server` @@ -449,8 +451,8 @@ fn mark_closed(id: i64) { /// /// All three args must be NaN-boxed Perry-runtime values per the /// codegen ABI — see `NA_F64` lowering in perry-codegen. -/// Distinct-symbol alias of `js_net_socket_connect` for perry-stdlib's -/// dynamic-dispatch bridge (`js_node_http_native_dispatch`'s net arm). The +/// Distinct-symbol alias of `js_net_socket_connect` for generated code and the +/// value-form dispatcher (`native_dispatch.rs`). The /// shared name has a bundled-stdlib twin, and in a build that links BOTH /// archives the shared symbol can bind to the twin whose socket registry the /// handle-dispatch never consults — connect then "succeeds" into one registry diff --git a/crates/perry-ext-net/src/native_dispatch.rs b/crates/perry-ext-net/src/native_dispatch.rs new file mode 100644 index 0000000000..1ae18bd11d --- /dev/null +++ b/crates/perry-ext-net/src/native_dispatch.rs @@ -0,0 +1,119 @@ +//! `node:net` exports reached as VALUES (#10429). +//! +//! Direct calls on an import binding (`net.connect(port, host)`) lower through +//! codegen's static native table to this crate's symbols. Every other shape — +//! a CommonJS `require('net')`, an alias (`const n = net`), a pulled-out export +//! (`(0, net_1.createConnection)(opts)`, ioredis/iovalkey), `new` on a bound +//! class value (`const Sock = net.Socket`, pg's function-local require) — +//! reaches perry-runtime's `nm_dispatch_net` bucket instead, which cannot name +//! this crate. The runtime calls back through `js_set_native_net_dispatch`. +//! +//! Registration happens in `js_ext_net_nm_install`, the install symbol codegen +//! emits wherever it materializes a `net` namespace or bound export. It used to +//! be perry-stdlib's http dispatcher, registered only when the stdlib was +//! rebuilt with `external-http-server-pump` — never for a net-only program and +//! never under `PERRY_NO_AUTO_OPTIMIZE=1`, so these forms returned `undefined`. + +const TAG_UNDEFINED: u64 = 0x7FFC_0000_0000_0001; +const POINTER_TAG: u64 = 0x7FFD_0000_0000_0000; +const POINTER_MASK: u64 = 0x0000_FFFF_FFFF_FFFF; + +extern "C" { + fn js_set_native_net_dispatch( + f: unsafe extern "C" fn(*const u8, usize, *const f64, usize) -> f64, + ); + fn js_nm_install_net(); + fn js_value_to_str_ptr_for_ffi(value: f64) -> i64; + fn js_value_is_closure(value_bits: i64) -> i32; + fn js_net_validate_create_server_options(value: f64); +} + +/// Install the runtime's `net` dispatch bucket together with this crate's +/// export dispatcher, so the first value-form call already has a provider. +#[no_mangle] +pub unsafe extern "C" fn js_ext_net_nm_install() { + js_set_native_net_dispatch(js_ext_net_native_dispatch); + js_nm_install_net(); +} + +/// Handle ids box exactly like the static table's `NR_HANDLE_ID` rows; a +/// failed factory (id 0) reads as `undefined`. +fn handle_value(handle: i64) -> f64 { + if handle == 0 { + f64::from_bits(TAG_UNDEFINED) + } else { + f64::from_bits(POINTER_TAG | (handle as u64 & POINTER_MASK)) + } +} + +/// Mirrors `Expr::NetCreateServer`: validate the first argument, then pass the +/// connection listener (the closure argument, in either position) and the +/// options object as raw pointers. +unsafe fn create_server(args_ptr: *const f64, args_len: usize) -> i64 { + if args_len > 0 { + js_net_validate_create_server_options(*args_ptr); + } + let mut options: i64 = 0; + let mut listener: i64 = 0; + for n in 0..args_len.min(2) { + let bits = (*args_ptr.add(n)).to_bits(); + if js_value_is_closure(bits as i64) != 0 { + listener = (bits & POINTER_MASK) as i64; + } else if bits >> 48 == POINTER_TAG >> 48 { + options = (bits & POINTER_MASK) as i64; + } + } + crate::js_ext_net_create_server(options, listener) +} + +/// Runtime callback for `nm_dispatch_net` / `nm_ctor_net`. Each arm calls the +/// same entry point the static native-table row for that export uses, so a +/// value-form socket lands in the same registries as a direct one. +/// +/// # Safety +/// `method_ptr`/`method_len` must describe UTF-8 bytes; `args_ptr` must point +/// to `args_len` NaN-boxed values (or be null when `args_len` is 0). +unsafe extern "C" fn js_ext_net_native_dispatch( + method_ptr: *const u8, + method_len: usize, + args_ptr: *const f64, + args_len: usize, +) -> f64 { + let undefined = f64::from_bits(TAG_UNDEFINED); + if method_ptr.is_null() { + return undefined; + } + let method = + std::str::from_utf8(std::slice::from_raw_parts(method_ptr, method_len)).unwrap_or(""); + // Read slots at each use rather than borrowing a slice: the factories can + // collect, and a moving collection rewrites rooted slots in place. + let args_len = if args_ptr.is_null() { 0 } else { args_len }; + let arg = |n: usize| { + if n < args_len { + *args_ptr.add(n) + } else { + undefined + } + }; + match method { + "connect" | "createConnection" => { + handle_value(crate::js_ext_net_socket_connect(arg(0), arg(1), arg(2))) + } + "createServer" | "Server" => handle_value(create_server(args_ptr, args_len)), + "Socket" | "Stream" => handle_value(crate::js_net_socket_alloc()), + "isIP" => crate::ip::js_net_is_ip(js_value_to_str_ptr_for_ffi(arg(0))), + "isIPv4" => crate::ip::js_net_is_ipv4(js_value_to_str_ptr_for_ffi(arg(0))), + "isIPv6" => crate::ip::js_net_is_ipv6(js_value_to_str_ptr_for_ffi(arg(0))), + "getDefaultAutoSelectFamily" => crate::ip::js_net_get_default_auto_select_family(), + "setDefaultAutoSelectFamily" => crate::ip::js_net_set_default_auto_select_family(arg(0)), + "getDefaultAutoSelectFamilyAttemptTimeout" => { + crate::ip::js_net_get_default_auto_select_family_attempt_timeout() + } + "setDefaultAutoSelectFamilyAttemptTimeout" => { + crate::ip::js_net_set_default_auto_select_family_attempt_timeout(arg(0)) + } + "BlockList" => handle_value(crate::js_net_block_list_new()), + "SocketAddress" => handle_value(crate::js_net_socket_address_new(arg(0))), + _ => undefined, + } +} diff --git a/crates/perry-runtime/src/lib.rs b/crates/perry-runtime/src/lib.rs index 79d57a7807..f0f359f1f2 100644 --- a/crates/perry-runtime/src/lib.rs +++ b/crates/perry-runtime/src/lib.rs @@ -363,8 +363,9 @@ pub use value::{ 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_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_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/native_module.rs b/crates/perry-runtime/src/object/native_module.rs index 1ae26fe3e7..01b95755f5 100644 --- a/crates/perry-runtime/src/object/native_module.rs +++ b/crates/perry-runtime/src/object/native_module.rs @@ -782,6 +782,11 @@ fn should_cache_native_module_namespace(module_name: &str) -> bool { // tag+name holders (all real dispatch keys off the module name, not // object state), so caching only affects object identity. | "fs" + // #10428: one object per module, so `require('node:http') === require('http')`. + | "http" + | "https" + | "http2" + | "net" | "dns.default" | "dns/promises.default" | "child_process.default" diff --git a/crates/perry-runtime/src/object/native_module/callable_export_check.rs b/crates/perry-runtime/src/object/native_module/callable_export_check.rs index 1d00e34dc9..d95be9a10d 100644 --- a/crates/perry-runtime/src/object/native_module/callable_export_check.rs +++ b/crates/perry-runtime/src/object/native_module/callable_export_check.rs @@ -333,6 +333,16 @@ pub(crate) fn is_native_module_callable_export_reference(module: &str, prop: &st | ("net", "SocketAddress") | ("net", "_normalizeArgs") | ("net", "_createServerHandle") + // #10429: the IP helpers and Happy-Eyeballs accessors read as + // callable values too (`const { isIP } = require('net')`); calls + // route to perry-ext-net's registered dispatcher. + | ("net", "isIP") + | ("net", "isIPv4") + | ("net", "isIPv6") + | ("net", "getDefaultAutoSelectFamily") + | ("net", "setDefaultAutoSelectFamily") + | ("net", "getDefaultAutoSelectFamilyAttemptTimeout") + | ("net", "setDefaultAutoSelectFamilyAttemptTimeout") | ("tls", "connect") | ("tls", "convertALPNProtocols") | ("tls", "createServer") diff --git a/crates/perry-runtime/src/object/native_module/callable_export_table.rs b/crates/perry-runtime/src/object/native_module/callable_export_table.rs index c23c9dd544..82ef648c4e 100644 --- a/crates/perry-runtime/src/object/native_module/callable_export_table.rs +++ b/crates/perry-runtime/src/object/native_module/callable_export_table.rs @@ -589,6 +589,13 @@ pub(super) static CALLABLE_EXPORT_TABLE: &[(&str, &[&str])] = &[ "connect", "createConnection", "createServer", + "getDefaultAutoSelectFamily", + "getDefaultAutoSelectFamilyAttemptTimeout", + "isIP", + "isIPv4", + "isIPv6", + "setDefaultAutoSelectFamily", + "setDefaultAutoSelectFamilyAttemptTimeout", ], ), ("node-pty", &["spawn"]), diff --git a/crates/perry-runtime/src/object/native_module_dispatch.rs b/crates/perry-runtime/src/object/native_module_dispatch.rs index ff17dfdd2b..b9a62e962f 100644 --- a/crates/perry-runtime/src/object/native_module_dispatch.rs +++ b/crates/perry-runtime/src/object/native_module_dispatch.rs @@ -356,8 +356,8 @@ pub(crate) use dispatch_d_i::{ nm_dispatch_http, nm_dispatch_inspector, }; pub(crate) use dispatch_m_p::{ - nm_dispatch_module, nm_dispatch_net, nm_dispatch_node_pty, nm_dispatch_os, nm_dispatch_path, - nm_dispatch_perf, nm_dispatch_process, + nm_ctor_net, nm_dispatch_module, nm_dispatch_net, nm_dispatch_node_pty, nm_dispatch_os, + nm_dispatch_path, nm_dispatch_perf, nm_dispatch_process, }; pub(crate) use dispatch_q_u::{ nm_dispatch_punycode, nm_dispatch_querystring, nm_dispatch_readline, nm_dispatch_repl, diff --git a/crates/perry-runtime/src/object/native_module_dispatch/dispatch_m_p.rs b/crates/perry-runtime/src/object/native_module_dispatch/dispatch_m_p.rs index 32fe9ccc1f..1e639f31d5 100644 --- a/crates/perry-runtime/src/object/native_module_dispatch/dispatch_m_p.rs +++ b/crates/perry-runtime/src/object/native_module_dispatch/dispatch_m_p.rs @@ -108,35 +108,11 @@ pub(crate) unsafe fn nm_dispatch_net(ctx: &NmCtx, module_name: &str, method_name typed_kind ); match (module_name, method_name) { - // `net.connect(port, host)` / `net.createConnection(...)` as a bound - // VALUE (mysql2-via-turbopack's externals wrapper requires 'net' and - // calls the export dynamically) — the socket factory lives in - // perry-stdlib, so route through the registered stdlib dispatcher, - // the same bridge the http client entry points use. - ("net", "connect") | ("net", "createConnection") => { - let ptr = - crate::value::JS_NATIVE_HTTP_DISPATCH.load(std::sync::atomic::Ordering::SeqCst); - if ptr.is_null() { - f64::from_bits(JSValue::undefined().bits()) - } else { - let dispatch: unsafe extern "C" fn( - *const u8, - usize, - *const u8, - usize, - *const f64, - usize, - ) -> f64 = std::mem::transmute(ptr); - dispatch( - module_name.as_ptr(), - module_name.len(), - method_name.as_ptr(), - method_name.len(), - args_ptr, - args_len, - ) - } - } + // node:net exports reached as VALUES — `require('net').connect(...)`, + // `const n = net; n.isIP(...)`, ioredis' `(0, net_1.createConnection)(...)`. + // Sockets, servers and the IP helpers live in perry-ext-net, which registers + // this dispatcher from its `net` namespace install (#10429). + ("net", name) if net_export_routes_to_provider(name) => net_provider_dispatch(ctx, name), ("net", "_normalizeArgs") => crate::net_validate::js_net_normalize_args(arg(0)), ("net", "_createServerHandle") => crate::net_validate::js_net_create_server_handle_stub( arg(0), @@ -153,6 +129,63 @@ pub(crate) unsafe fn nm_dispatch_net(ctx: &NmCtx, module_name: &str, method_name } } +/// node:net exports implemented by perry-ext-net (see `nm_dispatch_net`). +fn net_export_routes_to_provider(name: &str) -> bool { + matches!( + name, + "connect" + | "createConnection" + | "createServer" + | "Server" + | "Socket" + | "Stream" + | "isIP" + | "isIPv4" + | "isIPv6" + | "getDefaultAutoSelectFamily" + | "setDefaultAutoSelectFamily" + | "getDefaultAutoSelectFamilyAttemptTimeout" + | "setDefaultAutoSelectFamilyAttemptTimeout" + | "BlockList" + | "SocketAddress" + ) +} + +/// Forward a node:net export to perry-ext-net's registered dispatcher, or +/// `undefined` when no net provider is linked. +unsafe fn net_provider_dispatch(ctx: &NmCtx, name: &str) -> f64 { + let ptr = crate::value::JS_NATIVE_NET_DISPATCH.load(std::sync::atomic::Ordering::SeqCst); + if ptr.is_null() { + return f64::from_bits(JSValue::undefined().bits()); + } + let dispatch: crate::value::JsNativeNetDispatchFn = std::mem::transmute(ptr); + dispatch(name.as_ptr(), name.len(), ctx.args_ptr, ctx.args_len) +} + +/// `new` on a bound node:net class value (`const Sock = net.Socket; new Sock()`, +/// pg's function-local `new (require('net')).Socket()`). Registered by +/// `js_nm_install_net`; the classes are provider-owned like the factories. +pub(crate) unsafe fn nm_ctor_net( + _module: &str, + method: &str, + args_ptr: *const f64, + args_len: usize, +) -> Option { + if !matches!( + method, + "Socket" | "Stream" | "Server" | "BlockList" | "SocketAddress" + ) { + return None; + } + let ctx = NmCtx { + obj: std::ptr::null(), + args_ptr, + args_len, + assert_skip_prototype: false, + }; + Some(net_provider_dispatch(&ctx, method)) +} + /// #6563: node-pty / @lydell/node-pty — one shared bucket (the namespace name /// is normalized to `node-pty` at creation, and the `.default` alias to the /// base name in `dispatch_native_module_method`). Only `spawn` is a module @@ -820,3 +853,99 @@ pub(crate) unsafe fn nm_dispatch_process(ctx: &NmCtx, module_name: &str, method_ _ => f64::from_bits(JSValue::undefined().bits()), } } + +#[cfg(test)] +mod net_provider_dispatch_tests { + use super::*; + use std::sync::atomic::Ordering; + use std::sync::Mutex; + + static LAST_CALL: Mutex> = Mutex::new(None); + + unsafe extern "C" fn recording_provider( + method_ptr: *const u8, + method_len: usize, + _args_ptr: *const f64, + args_len: usize, + ) -> f64 { + let method = std::str::from_utf8(std::slice::from_raw_parts(method_ptr, method_len)) + .expect("method name is UTF-8"); + *LAST_CALL.lock().unwrap() = Some((method.to_string(), args_len)); + 42.0 + } + + fn take_last_call() -> Option<(String, usize)> { + LAST_CALL.lock().unwrap().take() + } + + /// #10429: every node:net export perry-ext-net implements must reach the + /// registered provider from the namespace-method path (`n.connect(...)`, + /// a bound `createConnection`) and, for the classes, from the ctor + /// registry `js_nm_install_net` fills (`new (net.Socket)()`). Before the + /// fix only connect/createConnection were forwarded, through a dispatcher + /// registered solely by an auto-optimized http build. + #[test] + fn net_exports_reach_the_registered_provider() { + crate::value::js_set_native_net_dispatch(recording_provider); + crate::object::native_module_registry::js_nm_install_net(); + let ns = crate::object::js_create_native_module_namespace(b"node:net".as_ptr(), 8); + let ns = (ns.to_bits() & crate::value::POINTER_MASK) as *const ObjectHeader; + let args = [f64::from_bits(crate::value::TAG_UNDEFINED); 2]; + for method in [ + "connect", + "createConnection", + "createServer", + "Server", + "Socket", + "Stream", + "isIP", + "isIPv4", + "isIPv6", + "getDefaultAutoSelectFamily", + "setDefaultAutoSelectFamilyAttemptTimeout", + "BlockList", + "SocketAddress", + ] { + let got = unsafe { dispatch_native_module_method(ns, method, args.as_ptr(), 2) }; + assert_eq!(got, 42.0, "net.{method}(...) did not reach the provider"); + assert_eq!(take_last_call(), Some((method.to_string(), 2))); + } + + let ctor = crate::object::nm_ctor_lookup("net") + .expect("js_nm_install_net must register the net class constructors"); + for class in ["Socket", "Stream", "Server", "BlockList", "SocketAddress"] { + assert_eq!(unsafe { ctor("net", class, args.as_ptr(), 1) }, Some(42.0)); + assert_eq!(take_last_call(), Some((class.to_string(), 1))); + } + assert_eq!(unsafe { ctor("net", "connect", args.as_ptr(), 0) }, None); + assert_eq!(take_last_call(), None); + + // No provider linked: the value forms stay `undefined`. + crate::value::JS_NATIVE_NET_DISPATCH.store(std::ptr::null_mut(), Ordering::SeqCst); + let got = unsafe { dispatch_native_module_method(ns, "isIP", args.as_ptr(), 1) }; + assert_eq!(got.to_bits(), crate::value::TAG_UNDEFINED); + assert_eq!(take_last_call(), None); + } + + /// #10428: `require('node:http') === require('http')` in Node; the + /// provider-owned namespaces are cached like `fs` / `path` so both + /// spellings (and repeated requires) yield one object. + #[test] + fn prefixed_and_bare_provider_namespaces_are_one_object() { + for (prefixed, bare) in [ + ("node:http", "http"), + ("node:https", "https"), + ("node:http2", "http2"), + ("node:net", "net"), + ] { + let a = + crate::object::js_create_native_module_namespace(prefixed.as_ptr(), prefixed.len()); + let b = crate::object::js_create_native_module_namespace(bare.as_ptr(), bare.len()); + assert_eq!( + a.to_bits(), + b.to_bits(), + "require('{prefixed}') !== require('{bare}')" + ); + } + } +} diff --git a/crates/perry-runtime/src/object/native_module_registry.rs b/crates/perry-runtime/src/object/native_module_registry.rs index 681e98cc17..5e51863a58 100644 --- a/crates/perry-runtime/src/object/native_module_registry.rs +++ b/crates/perry-runtime/src/object/native_module_registry.rs @@ -373,6 +373,7 @@ pub extern "C" fn js_nm_install_net() { nm_dispatch_net as NmDispatchFn as *mut (), Ordering::Relaxed, ); + nm_register_ctor(NmBucket::Net, super::native_module_dispatch::nm_ctor_net); } #[no_mangle] pub extern "C" fn js_nm_install_node_pty() { diff --git a/crates/perry-runtime/src/value/handle.rs b/crates/perry-runtime/src/value/handle.rs index aeb9164c77..f13ebdae26 100644 --- a/crates/perry-runtime/src/value/handle.rs +++ b/crates/perry-runtime/src/value/handle.rs @@ -79,6 +79,13 @@ pub extern "C" fn js_set_native_bun_tcp_dispatch(func: JsNativeBunTcpDispatchFn) JS_NATIVE_BUN_TCP_DISPATCH.store(func as *mut (), Ordering::SeqCst); } +/// Set the node:net export dispatcher. perry-ext-net registers it from the +/// `net` namespace install, so it is live before any value-form call can run. +#[no_mangle] +pub extern "C" fn js_set_native_net_dispatch(func: JsNativeNetDispatchFn) { + JS_NATIVE_NET_DISPATCH.store(func as *mut (), Ordering::SeqCst); +} + /// Set the node:events MODULE-level helper dispatcher (`events.listenerCount`, /// `events.once`, …). Registered by perry-stdlib at startup so a captured, /// type-erased or spread-called module helper reaches the same `js_events_*` @@ -111,10 +118,11 @@ pub extern "C" fn js_set_native_tls_dispatch(func: JsNativeTlsDispatchFn) { JS_NATIVE_TLS_DISPATCH.store(func as *mut (), Ordering::SeqCst); } -/// Set the node:http/https/http2 server-factory dispatcher. Registered by -/// perry-stdlib at startup (under `external-http-server-pump`) so a captured / -/// aliased `createServer` reaches the perry-ext-http impls, which this -/// crate can't call directly. Stays null when the http ext crate isn't linked. (#2533) +/// Set the node:http/https/http2 export dispatcher. perry-ext-http registers it +/// from the http namespace install (perry-stdlib also does at startup under +/// `external-http-server-pump`) so a captured / aliased `createServer` reaches +/// the perry-ext-http impls, which this crate can't call directly. Stays null +/// when the http ext crate isn't linked. (#2533, #10428) #[no_mangle] pub extern "C" fn js_set_native_http_dispatch(func: JsNativeHttpDispatchFn) { JS_NATIVE_HTTP_DISPATCH.store(func as *mut (), Ordering::SeqCst); diff --git a/crates/perry-runtime/src/value/mod.rs b/crates/perry-runtime/src/value/mod.rs index d8094680d2..30bada3331 100644 --- a/crates/perry-runtime/src/value/mod.rs +++ b/crates/perry-runtime/src/value/mod.rs @@ -66,9 +66,9 @@ 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_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_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 @@ -77,9 +77,9 @@ pub(crate) use tags::{ JsHandleArrayGetFn, JsHandleArrayLengthFn, JsHandleCallMethodFn, JsHandleObjectGetPropertyFn, JsHandleToStringFn, JsHandleTypeofFn, JsNativeBunTcpDispatchFn, JsNativeCryptoDispatchFn, JsNativeDomainDispatchFn, JsNativeEventsConstructFn, JsNativeHttpDispatchFn, - JsNativeModuleJsLoaderFn, JsNativeQuerystringDispatchFn, JsNativeSqliteDispatchFn, - JsNativeTlsDispatchFn, JsNativeWebCryptoDispatchFn, JsNativeZlibDispatchFn, - JsNewFromHandleV8Fn, JS_HANDLE_ARRAY_GET, JS_HANDLE_ARRAY_LENGTH, + 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, }; @@ -94,7 +94,7 @@ pub use handle::{ 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_querystring_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, }; diff --git a/crates/perry-runtime/src/value/tags.rs b/crates/perry-runtime/src/value/tags.rs index 1572950cee..1bad2cfbee 100644 --- a/crates/perry-runtime/src/value/tags.rs +++ b/crates/perry-runtime/src/value/tags.rs @@ -144,6 +144,12 @@ pub(crate) type JsNativeQuerystringDispatchFn = /// runtime-to-extension dependency indirect for captured callable exports. pub(crate) type JsNativeBunTcpDispatchFn = unsafe extern "C" fn(*const u8, usize, *const f64, usize) -> f64; +/// node:net export dispatcher registered by perry-ext-net (the only provider of +/// sockets and `isIP`) when a `net` namespace is materialized. Serves the +/// value-read forms (`require('net').connect(...)`, `const { isIP } = net`, +/// `new (net.Socket)()`) that never reach the static codegen table. (#10429) +pub(crate) type JsNativeNetDispatchFn = + unsafe extern "C" fn(*const u8, usize, *const f64, usize) -> f64; /// node:sqlite module-method/constructor dispatcher. Same dependency-boundary /// pattern as crypto/zlib, with an extra construct flag so dynamic `new /// DatabaseSync(...)` can reach the real stdlib constructor. @@ -157,14 +163,14 @@ pub(crate) type JsNativeDomainDispatchFn = /// implementation without perry-runtime depending on perry-stdlib. pub(crate) type JsNativeTlsDispatchFn = unsafe extern "C" fn(*const u8, usize, *const f64, usize) -> f64; -/// node:http / node:https / node:http2 server-factory dispatcher (registered -/// by perry-stdlib under the `external-http-server-pump` feature, which is -/// enabled whenever a program imports one of those modules). Lets a captured / +/// node:http / node:https / node:http2 export dispatcher, owned by +/// perry-ext-http and registered by its namespace install (and by +/// perry-stdlib's startup under `external-http-server-pump`). Lets a captured / /// aliased `createServer` (`const cs = createServer; cs(handler)`, or /// `@hono/node-server`'s `const createServer = options.createServer || /// createServerHTTP`) reach the perry-ext-http impls. Unlike crypto/zlib /// it also takes the module name so one callback can route http vs https vs -/// http2. Stays null when the http ext crate isn't linked. (#2533) +/// http2. Stays null when the http ext crate isn't linked. (#2533, #10428) pub(crate) type JsNativeHttpDispatchFn = unsafe extern "C" fn(*const u8, usize, *const u8, usize, *const f64, usize) -> f64; /// node:events class-constructor dispatcher (registered by perry-stdlib under @@ -194,6 +200,7 @@ pub static JS_NATIVE_WEBCRYPTO_DISPATCH: AtomicPtr<()> = AtomicPtr::new(std::ptr pub static JS_NATIVE_ZLIB_DISPATCH: AtomicPtr<()> = AtomicPtr::new(std::ptr::null_mut()); pub static JS_NATIVE_QUERYSTRING_DISPATCH: AtomicPtr<()> = AtomicPtr::new(std::ptr::null_mut()); pub static JS_NATIVE_BUN_TCP_DISPATCH: AtomicPtr<()> = AtomicPtr::new(std::ptr::null_mut()); +pub static JS_NATIVE_NET_DISPATCH: AtomicPtr<()> = AtomicPtr::new(std::ptr::null_mut()); pub static JS_NATIVE_SQLITE_DISPATCH: AtomicPtr<()> = AtomicPtr::new(std::ptr::null_mut()); pub static JS_NATIVE_DOMAIN_DISPATCH: AtomicPtr<()> = AtomicPtr::new(std::ptr::null_mut()); pub static JS_NATIVE_TLS_DISPATCH: 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 b633cbc7ec..faa3f1cb70 100644 --- a/crates/perry-stdlib/src/common/dispatch/init.rs +++ b/crates/perry-stdlib/src/common/dispatch/init.rs @@ -193,255 +193,6 @@ pub unsafe extern "C" fn js_handle_prototype_dispatch(handle: i64) -> f64 { f64::from_bits(perry_runtime::JSValue::undefined().bits()) } -/// #2533: route a captured / aliased `http`/`https`/`http2` `createServer` -/// (or the `Server` / `createSecureServer` aliases) back to the -/// perry-ext-http factories. Registered with the runtime via -/// `js_set_native_http_dispatch` under `external-http-server-pump` (enabled -/// whenever the program imports one of those modules), so we can safely -/// `extern "C"`-reference the ext-crate symbols — they're guaranteed linked. -/// -/// The method-call form (`http.createServer(...)`) already lowers through the -/// codegen NATIVE_MODULE_TABLE; this only serves the value-read form, where the -/// factory reaches the runtime as a bound-method closure (see -/// `is_native_module_callable_export`) and lands here when invoked. -/// -/// Node's overloads are `createServer([options][, requestListener])`, while -/// `@hono/node-server` calls `createServer(serverOptions, requestListener)`. We -/// classify each arg by type rather than position — the function/closure arg is -/// the handler, the remaining object arg is the options — so both orders work. -#[cfg(feature = "external-http-server-pump")] -unsafe extern "C" fn js_node_http_native_dispatch( - module_ptr: *const u8, - module_len: usize, - method_ptr: *const u8, - method_len: usize, - args_ptr: *const f64, - args_len: usize, -) -> f64 { - use perry_runtime::JSValue; - extern "C" { - fn js_bun_serve(options: f64) -> i64; - fn js_node_http_create_server_with_options(first_arg: f64, second_arg: f64) -> i64; - fn js_node_http_outgoing_message_new() -> i64; - fn js_node_https_create_server(opts_f64: f64, handler: i64) -> i64; - fn js_node_http2_create_server(first_arg: f64, second_arg: f64) -> i64; - fn js_node_http2_create_secure_server(opts_f64: f64, handler: i64) -> i64; - fn js_value_is_closure(value_bits: i64) -> i32; - } - let undefined = f64::from_bits(JSValue::undefined().bits()); - let module = if module_ptr.is_null() || module_len == 0 { - "" - } else { - std::str::from_utf8(std::slice::from_raw_parts(module_ptr, module_len)).unwrap_or("") - }; - let method = if method_ptr.is_null() || method_len == 0 { - "" - } else { - std::str::from_utf8(std::slice::from_raw_parts(method_ptr, method_len)).unwrap_or("") - }; - let arg = |n: usize| -> f64 { - if n < args_len && !args_ptr.is_null() { - *args_ptr.add(n) - } else { - undefined - } - }; - if module == "bun" && method == "serve" { - let handle = js_bun_serve(arg(0)); - return if handle == 0 { - undefined - } else { - perry_runtime::js_nanbox_pointer(handle) - }; - } - if module == "http" && method == "OutgoingMessage" { - let handle = js_node_http_outgoing_message_new(); - return if handle == 0 { - undefined - } else { - perry_runtime::js_nanbox_pointer(handle) - }; - } - // #4904: Node exposes Agent / ClientRequest / IncomingMessage / - // ServerResponse as constructable classes. Construction through any - // value/aliasing path (`const { Agent } = require('http')`, - // `new http.IncomingMessage(socket)`, …) lands here via the - // class_registry http construct arm. - if module == "http" && method == "IncomingMessage" { - extern "C" { - fn js_node_http_incoming_message_standalone_new(socket: f64) -> i64; - } - let handle = js_node_http_incoming_message_standalone_new(arg(0)); - return if handle == 0 { - undefined - } else { - perry_runtime::js_nanbox_pointer(handle) - }; - } - if module == "http" && method == "ServerResponse" { - extern "C" { - fn js_node_http_server_response_standalone_new(req: f64) -> i64; - } - let handle = js_node_http_server_response_standalone_new(arg(0)); - return if handle == 0 { - undefined - } else { - perry_runtime::js_nanbox_pointer(handle) - }; - } - // `net.connect` / `net.createConnection` reached as a bound VALUE — - // mysql2 (bundled by turbopack) does `const net = require('net'); - // net.connect(port, host)` through the externals wrapper, so the call - // arrives here instead of the static codegen table. Route to the same - // event-driven socket factory the static path uses. Same cfg gate as the - // `net` module itself (the auto-opt stdlib is feature-pruned) — and - // deliberately OUTSIDE the `external-http-client-pump` block below, which - // is not enabled for every build that has sockets. - if module == "net" && matches!(method, "connect" | "createConnection") { - // Route to the net implementation that OWNS the handle-dispatch - // registries in this build: crate-path under bundled-net, the - // DISTINCT `js_ext_net_socket_connect` symbol under the well-known - // ext-net flip. The shared `js_net_socket_connect` name has twins in - // both archives, and binding to the wrong one splits the socket - // registry from the `.on('data')` listener registry — the mysql2 - // handshake then times out with the bytes silently dropped (#5021's - // twin-symbol disease). - #[cfg(all( - feature = "bundled-net", - not(target_os = "ios"), - not(target_os = "android") - ))] - let handle = crate::net::js_net_socket_connect(arg(0), arg(1), arg(2)); - #[cfg(all( - not(feature = "bundled-net"), - feature = "external-net-pump", - not(target_os = "ios"), - not(target_os = "android") - ))] - let handle = { - extern "C" { - fn js_ext_net_socket_connect(arg1: f64, arg2: f64, arg3: f64) -> i64; - } - js_ext_net_socket_connect(arg(0), arg(1), arg(2)) - }; - #[cfg(not(any( - all( - feature = "bundled-net", - not(target_os = "ios"), - not(target_os = "android") - ), - all( - not(feature = "bundled-net"), - feature = "external-net-pump", - not(target_os = "ios"), - not(target_os = "android") - ) - )))] - let handle: i64 = 0; - return if handle == 0 { - undefined - } else { - perry_runtime::js_nanbox_pointer(handle) - }; - } - #[cfg(feature = "external-http-client-pump")] - { - extern "C" { - fn js_http_agent_new(options_f64: f64) -> i64; - fn js_https_agent_new(options_f64: f64) -> i64; - fn js_http_client_request_standalone_new(options_f64: f64) -> i64; - fn js_http_get_overload(args_array: i64) -> i64; - fn js_https_get_overload(args_array: i64) -> i64; - fn js_http_request_overload(args_array: i64) -> i64; - fn js_https_request_overload(args_array: i64) -> i64; - } - // #4904/#4975: captured / aliased `get` / `request` (`const { get } = - // require('http')`). Preserve the complete argument list and route it - // through the same overload normalizer as a statically-known call. - // Picking only the first non-closure argument lost `(url, options, - // callback)` and treated WHATWG URL objects as plain option bags. - if matches!(method, "get" | "request") && matches!(module, "http" | "https") { - let scope = perry_runtime::gc::RuntimeHandleScope::new(); - let args = (0..args_len) - .map(|n| scope.root_nanbox_f64(arg(n))) - .collect::>(); - let overload_args = scope.root_raw_mut_ptr::( - perry_runtime::js_array_alloc(args_len as u32), - ); - for arg in args { - overload_args.with_mut_ptr(|array: *mut perry_runtime::ArrayHeader| { - let _ = perry_runtime::js_array_push_f64(array, arg.get_nanbox_f64()); - }); - } - let handle = - overload_args.with_mut_ptr(|array: *mut perry_runtime::ArrayHeader| { - match (module, method) { - ("http", "get") => js_http_get_overload(array as i64), - ("http", "request") => js_http_request_overload(array as i64), - ("https", "get") => js_https_get_overload(array as i64), - _ => js_https_request_overload(array as i64), - } - }); - return if handle == 0 { - undefined - } else { - perry_runtime::js_nanbox_pointer(handle) - }; - } - if method == "Agent" && (module == "http" || module == "https") { - let handle = if module == "https" { - js_https_agent_new(arg(0)) - } else { - js_http_agent_new(arg(0)) - }; - return if handle == 0 { - undefined - } else { - perry_runtime::js_nanbox_pointer(handle) - }; - } - if module == "http" && method == "ClientRequest" { - let handle = js_http_client_request_standalone_new(arg(0)); - return if handle == 0 { - undefined - } else { - perry_runtime::js_nanbox_pointer(handle) - }; - } - } - // Disambiguate handler (function/closure) from options (object), - // independent of argument order. - let mut handler_ptr: i64 = 0; - let mut options_f64 = undefined; - for n in 0..args_len.min(2) { - let a = arg(n); - if js_value_is_closure(a.to_bits() as i64) != 0 { - handler_ptr = perry_runtime::js_nanbox_get_pointer(a); - } else if JSValue::from_bits(a.to_bits()).is_pointer() { - options_f64 = a; - } - } - let handler_f64 = if handler_ptr == 0 { - undefined - } else { - perry_runtime::js_nanbox_pointer(handler_ptr) - }; - let handle = match module { - "http" => js_node_http_create_server_with_options(options_f64, handler_f64), - "https" => js_node_https_create_server(options_f64, handler_ptr), - "http2" if method == "createSecureServer" => { - js_node_http2_create_secure_server(options_f64, handler_ptr) - } - "http2" => js_node_http2_create_server(options_f64, handler_f64), - _ => return undefined, - }; - if handle == 0 { - undefined - } else { - perry_runtime::js_nanbox_pointer(handle) - } -} - /// Initialize the handle method and property dispatch systems. /// This registers our dispatch functions with perry-runtime. /// Must be called before any user code runs. @@ -785,12 +536,26 @@ pub unsafe extern "C" fn js_stdlib_init_dispatch() { ))] perry_runtime::js_set_native_tls_dispatch(crate::tls::js_tls_native_dispatch); - // #2533: route captured / aliased http/https/http2 `createServer` back to - // the perry-ext-http factories. Only registered when the http ext - // crate is linked (its symbols are referenced by the dispatcher), so the - // runtime arm stays null-and-undefined for non-http programs. + // #2533: route captured / aliased http/https/http2 exports back to + // perry-ext-http. The dispatcher lives in that crate (#10428), which also + // registers it from its namespace install so the prebuilt no-auto archives + // work too; registering here keeps value forms that never materialize an + // http namespace (`bun.serve`, `class extends http.Server`) covered when + // the feature guarantees the crate is linked. #[cfg(feature = "external-http-server-pump")] - perry_runtime::js_set_native_http_dispatch(js_node_http_native_dispatch); + { + extern "C" { + fn js_ext_http_native_dispatch( + module_ptr: *const u8, + module_len: usize, + method_ptr: *const u8, + method_len: usize, + args_ptr: *const f64, + args_len: usize, + ) -> f64; + } + perry_runtime::js_set_native_http_dispatch(js_ext_http_native_dispatch); + } // #1545: register the Web Streams numeric-handle probe so method calls on // stream handles whose static type the codegen lost route to the stream diff --git a/crates/perry/src/commands/compile/object_cache.rs b/crates/perry/src/commands/compile/object_cache.rs index 64e7f9f927..b628b59f3e 100644 --- a/crates/perry/src/commands/compile/object_cache.rs +++ b/crates/perry/src/commands/compile/object_cache.rs @@ -388,6 +388,12 @@ fn compute_object_cache_key_with_env( "entry_source_path", opts.app_metadata.entry_source_path.as_deref().unwrap_or(""), ); + // Provider installs are calls baked into the entry prologue (#10428): + // adding a `require('net')` elsewhere must not reuse an entry without it. + h.field( + "native_provider_installs", + &opts.app_metadata.native_provider_installs.join("|"), + ); // Ordered lists (order is significant — topological init, FFI index, // bundled extension order, etc.) diff --git a/crates/perry/src/commands/compile/object_cache/object_cache_tests.rs b/crates/perry/src/commands/compile/object_cache/object_cache_tests.rs index 77662c1c25..d7e8bae808 100644 --- a/crates/perry/src/commands/compile/object_cache/object_cache_tests.rs +++ b/crates/perry/src/commands/compile/object_cache/object_cache_tests.rs @@ -403,6 +403,17 @@ fn key_changes_with_embedded_entry_source_path() { ); } +#[test] +fn key_changes_with_native_provider_installs() { + let a = empty_opts(); + let mut b = empty_opts(); + b.app_metadata.native_provider_installs = vec!["js_ext_net_nm_install".to_string()]; + assert_ne!( + compute_object_cache_key(&a, 1, "0.5.156"), + compute_object_cache_key(&b, 1, "0.5.156") + ); +} + #[test] fn key_changes_with_imported_class_signature() { let mut a = empty_opts(); diff --git a/crates/perry/src/commands/compile/run_pipeline.rs b/crates/perry/src/commands/compile/run_pipeline.rs index 16e2fb4457..02c3b64bb3 100644 --- a/crates/perry/src/commands/compile/run_pipeline.rs +++ b/crates/perry/src/commands/compile/run_pipeline.rs @@ -1099,6 +1099,20 @@ pub fn run_with_parse_cache( classify_eager_modules(&mut ctx, &entry_path); let non_entry_module_names: Vec = topo_sort_non_entry_modules(&ctx, &entry_path, format, verbose); + // #10428/#10429: every imported module the well-known flip serves from a + // provider crate (net, http/https/http2) gets that provider's install + // wrapper called from the entry prologue, so the provider's export + // dispatcher is live for module objects the runtime creates itself (a + // CommonJS `require('net')` goes through `createRequire`, not codegen). + // No flip, no provider on the link line: emit nothing. + let native_provider_installs: Vec = + if std::env::var_os("PERRY_DISABLE_WELL_KNOWN").is_some() { + Vec::new() + } else { + perry_codegen::native_provider_install_symbols( + ctx.native_module_imports.iter().map(String::as_str), + ) + }; // Build a map of all exported enums from all modules (owned data, no borrows) // Key: (resolved_path, enum_name) -> Vec<(member_name, EnumValue)> @@ -5359,6 +5373,11 @@ pub fn run_with_parse_cache( } else { None }, + native_provider_installs: if is_entry { + native_provider_installs.clone() + } else { + Vec::new() + }, ..ctx.app_metadata.clone() }, // Issue #100: namespace_entries empty unless this diff --git a/test-files/gap_10428_10429_module_values_helper.cjs b/test-files/gap_10428_10429_module_values_helper.cjs new file mode 100644 index 0000000000..2968445df2 --- /dev/null +++ b/test-files/gap_10428_10429_module_values_helper.cjs @@ -0,0 +1,35 @@ +'use strict'; +// CommonJS half of test_gap_10428_10429_node_module_value_dispatch.ts: the +// exact shapes pg, mysql2, ioredis and ws use when compiled from source. +const net = require('net'); +const http = require('http'); + +exports.sameModules = function () { + return [require('node:net') === net, require('node:http') === http]; +}; +// mysql2 lib/base/connection.js: `Net.connect(port, host)` +exports.connectMember = function (port, host) { + return net.connect(port, host); +}; +// ioredis/iovalkey StandaloneConnector: `(0, net_1.createConnection)(options)` +exports.createConnectionCall = function (options) { + return (0, net.createConnection)(options); +}; +// pg lib/stream.js: function-local require, then `new net.Socket()` +exports.newLocalSocket = function () { + const n2 = require('net'); + return new n2.Socket(); +}; +// pg lib/connection.js: `net.isIP(host)` +exports.isIP = function (host) { + return net.isIP(host); +}; +// fastify lib/server.js: `http.createServer(options.http, httpHandler)` +exports.createServer = function (options, handler) { + return http.createServer(options, handler); +}; +// ws lib/websocket.js: `const request = isSecure ? https.request : http.request` +exports.request = function (options, callback) { + const request = http.request; + return request(options, callback); +}; diff --git a/test-files/test_gap_10428_10429_node_module_value_dispatch.ts b/test-files/test_gap_10428_10429_node_module_value_dispatch.ts new file mode 100644 index 0000000000..b90eb8075e --- /dev/null +++ b/test-files/test_gap_10428_10429_node_module_value_dispatch.ts @@ -0,0 +1,163 @@ +// #10428 / #10429: `node:net` and `node:http` exports reached as VALUES. +// +// A direct call on an import binding (`net.connect(port, host)`) lowers through +// codegen's static native table. Every other shape — a module object aliased or +// passed around, a destructured or pulled-out export, `new` on a bound class +// value, and all CommonJS `require('net')` forms that pg / mysql2 / ioredis / ws +// use when compiled from source — reaches the runtime's module-object dispatch. +// That dispatch forwarded to a provider callback registered only when the +// stdlib was auto-optimized for an http import, so under +// `PERRY_NO_AUTO_OPTIMIZE=1`, or in any net-only program, `connect()` / +// `createConnection()` / `http.request()` returned `undefined`. `isIP` and the +// class constructors were never forwarded at all, and +// `require('node:http') !== require('http')`. +// +// Everything runs against in-process servers on 127.0.0.1 (port 0). +import * as net from "node:net"; +import * as http from "node:http"; +import * as cjs from "./gap_10428_10429_module_values_helper.cjs"; + +const HOST = "127.0.0.1"; +const n: any = net; +const h: any = http; + +function kind(v: any): string { + if (v === undefined) return "undefined"; + return `${typeof v} on:${typeof v.on} write:${typeof v.write}`; +} + +// ── synchronous value reads ── +function check(label: string, read: () => unknown[]): void { + try { + console.log(`${label}:`, ...read()); + } catch (e: any) { + console.log(`${label}: threw ${e?.name}`); + } +} +check("alias typeof connect/isIP", () => [typeof n.connect, typeof n.isIP]); +check("alias isIP v4/v6/bad", () => [n.isIP("127.0.0.1"), n.isIP("::1"), n.isIP("nope")]); +check("destructured isIPv4/isIPv6", () => { + const { isIPv4, isIPv6 } = n; + return [isIPv4("10.0.0.1"), isIPv6("10.0.0.1")]; +}); +check("cjs net.isIP", () => [cjs.isIP("::1"), cjs.isIP("10.1.2.3")]); +check("cjs require('node:x') === require('x')", () => [cjs.sameModules().join(",")]); + +// ── sockets opened through each value shape, echoed by an in-process server ── +type Opener = (port: number) => any; +const openers: Array<[string, Opener]> = [ + ["alias n.connect(port, host)", (port) => n.connect(port, HOST)], + [ + "destructured createConnection({ port, host })", + (port) => { + const { createConnection } = n; + return createConnection({ port, host: HOST }); + }, + ], + [ + "new (const Sock = n.Socket)().connect", + (port) => { + const Sock = n.Socket; + const s = new Sock(); + s.connect(port, HOST); + return s; + }, + ], + ["cjs Net.connect(port, host)", (port) => cjs.connectMember(port, HOST)], + ["cjs (0, net.createConnection)({ port, host })", (port) => cjs.createConnectionCall({ port, host: HOST })], + [ + "cjs fn-local new n2.Socket().connect", + (port) => { + const s = cjs.newLocalSocket(); + s.connect(port, HOST); + return s; + }, + ], +]; + +const echo = net.createServer((sock: any) => { + sock.on("data", (d: any) => sock.write("echo:" + d.toString())); +}); + +function runSockets(port: number, index: number, done: () => void): void { + if (index >= openers.length) { + done(); + return; + } + const [label, open] = openers[index]; + const sock = open(port); + console.log(`${label}: ${kind(sock)}`); + if (sock === undefined || typeof sock.on !== "function") { + runSockets(port, index + 1, done); + return; + } + let finished = false; + const next = () => { + if (finished) return; + finished = true; + sock.destroy(); + runSockets(port, index + 1, done); + }; + sock.on("connect", () => sock.write("#" + index)); + sock.on("data", (d: any) => { + console.log(` round trip: ${d.toString()}`); + next(); + }); + sock.on("error", (e: any) => { + console.log(` socket error: ${e.message}`); + next(); + }); +} + +// ── http.createServer / http.request reached as values (fastify, ws) ── +const web = cjs.createServer({}, (req: any, res: any) => { + res.end("hi " + req.url); +}); +console.log(`cjs http.createServer(options, handler): ${typeof web} listen:${typeof web?.listen}`); + +function get(label: string, request: (opts: any, cb: (res: any) => void) => any, port: number, path: string, done: () => void): void { + const req = request({ host: HOST, port, path }, (res: any) => { + let body = ""; + res.on("data", (c: any) => { + body += c.toString(); + }); + res.on("end", () => { + console.log(` ${res.statusCode} ${body}`); + done(); + }); + }); + console.log(`${label}: ${kind(req)}`); + if (req === undefined) { + done(); + return; + } + req.on("error", (e: any) => { + console.log(` request error: ${e.message}`); + done(); + }); + req.end(); +} + +// Fail with a diff instead of hanging if a round trip never completes. +const guard = setTimeout(() => { + console.log("timed out"); + process.exit(1); +}, 10000); + +echo.listen(0, HOST, () => { + const echoPort = (echo.address() as any).port; + runSockets(echoPort, 0, () => { + echo.close(); + web.listen(0, HOST, () => { + const webPort = (web.address() as any).port; + const request = h.request; + get("alias const request = h.request", request, webPort, "/alias", () => { + get("cjs const request = http.request", cjs.request, webPort, "/cjs", () => { + web.close(); + clearTimeout(guard); + console.log("done"); + }); + }); + }); + }); +}); From 338ad0a5d086514576cd8ff1f86b440808d781ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 18 Sep 2026 00:57:23 +0000 Subject: [PATCH 2/2] docs(changelog): fragment for #10565 --- .../10565-node-module-value-dispatch.md | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 changelog.d/10565-node-module-value-dispatch.md diff --git a/changelog.d/10565-node-module-value-dispatch.md b/changelog.d/10565-node-module-value-dispatch.md new file mode 100644 index 0000000000..74f7418ad3 --- /dev/null +++ b/changelog.d/10565-node-module-value-dispatch.md @@ -0,0 +1,30 @@ +Fixed `node:net` and `node:http`/`https`/`http2` exports being inert whenever the module object was +used as a VALUE: a CommonJS `require('net')`, an aliased namespace, a destructured or pulled-out +export (`(0, net_1.createConnection)(opts)`), or `new` on a bound class value. `connect()` / +`createConnection()` / `createServer()` / `request()` returned `undefined` under +`PERRY_NO_AUTO_OPTIMIZE=1` and, for any net-only program, in both compile modes; `net.isIP` was never +callable as a value, `new (net.Socket)()` produced a plain object, and +`require('node:http') !== require('http')`. These are the shapes pg, mysql2, ioredis/iovalkey, redis, +ws and fastify use when compiled from source (#10428, #10429). + +Root cause: the runtime's module-object dispatch forwarded these exports to a callback that only +perry-stdlib registered, behind the `external-http-server-pump` feature — deliberately absent from +the prebuilt `full` archive and enabled by auto-optimize only for programs importing +http/https/http2. The dispatcher lived in perry-stdlib while every implementation it routes to lives +in perry-ext-http / perry-ext-net, which is why it had to be gated at all. + +The dispatchers now live in the provider crates (`perry-ext-net/src/native_dispatch.rs`, +`perry-ext-http/src/server/native_dispatch.rs`) and register themselves from their namespace install +symbol and from the entry prologue, which calls the install wrapper of every linked provider before +module initialization — so module objects the runtime creates itself (a CommonJS `require` resolving +through `createRequire`) reach them too. A dedicated `JS_NATIVE_NET_DISPATCH` hook carries the net +exports, `js_nm_install_net` registers a constructor arm for `Socket`/`Stream`/`Server`/`BlockList`/ +`SocketAddress`, the `isIP` family joined the callable-export table, the http/https/http2/net +namespaces are cached so both spellings are one object, and perry-ext-http now registers handle +dispatch extensions for ClientRequest / client IncomingMessage / Agent so erased receivers work +without `external-http-client-pump` (the client twin of the Wall-10 server-handle fix). + +Validated with a new gap test covering every value shape against in-process servers (identical to +Node in BOTH compile modes; the baseline diverges in both), runtime/codegen/CLI unit tests, the full +gap suite (no new failures), and instruction counts on direct `net.connect` and `http.request` loops +(±0.06%, static call path untouched).