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). 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"); + }); + }); + }); + }); +});