Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions changelog.d/10565-node-module-value-dispatch.md
Original file line number Diff line number Diff line change
@@ -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).
5 changes: 5 additions & 0 deletions crates/perry-codegen/src/codegen/entry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 33 additions & 0 deletions crates/perry-codegen/src/codegen/entry/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"] {
Expand Down
9 changes: 9 additions & 0 deletions crates/perry-codegen/src/codegen/opts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
/// 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<String>,
}

impl Default for AppMetadata {
Expand All @@ -43,6 +51,7 @@ impl Default for AppMetadata {
app_group: None,
update_config: None,
entry_source_path: None,
native_provider_installs: Vec::new(),
}
}
}
Expand Down
25 changes: 25 additions & 0 deletions crates/perry-codegen/src/ext_registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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();
Expand Down
1 change: 1 addition & 0 deletions crates/perry-codegen/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
41 changes: 36 additions & 5 deletions crates/perry-codegen/src/nm_install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down Expand Up @@ -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<Item = &'a str>,
) -> Vec<String> {
let mut symbols: Vec<String> = 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
Expand All @@ -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",
Expand Down Expand Up @@ -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() {
Expand Down
4 changes: 2 additions & 2 deletions crates/perry-codegen/src/runtime_decls/objects.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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, &[]);
Expand Down
Loading
Loading