diff --git a/crates/perry-ext-net/src/lib.rs b/crates/perry-ext-net/src/lib.rs index b84479684a..741c2d52c6 100644 --- a/crates/perry-ext-net/src/lib.rs +++ b/crates/perry-ext-net/src/lib.rs @@ -428,11 +428,24 @@ extern "C" { fn js_net_callback_ptr(value: f64) -> i64; } +/// Socket / server / block-list ids come from perry-ffi's SHARED handle-id +/// allocator, not a private counter. +/// +/// Every ext staticlib that mints ids privately from 1 aliases the others +/// inside the shared `[1, 0x40000)` band, and the composite handle-method +/// dispatch (`class_handles.rs::composite_handle_method_dispatch`) asks each +/// registered extension "is this handle yours?" — so the FIRST extension whose +/// private counter reached that number claims the call. Next.js's HTTP server +/// (perry-ext-http-server handle 1, via `register_handle`) therefore claimed +/// `socket.on('data', …)` on this crate's socket 1: the listener landed on the +/// HTTP server, the reader delivered the MySQL greeting to an empty listener +/// list, and mysql2's handshake hung to ETIMEDOUT. +/// +/// `reserve_handle_id` consumes an id from the same counter `register_handle` +/// uses, so ids stay globally unique across every ext library while this +/// crate keeps its own object map. pub(crate) fn next_id() -> i64 { - let mut g = statics::next_net_id().lock().unwrap(); - let id = *g; - *g += 1; - id + perry_ffi::reserve_handle_id() } fn push_event(ev: PendingNetEvent) { @@ -519,6 +532,23 @@ where /// /// 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 +/// 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 +/// while `.on('data')` registers in the other and the bytes are silently +/// dropped (mysql2 handshake ETIMEDOUT). Mirrors the +/// `js_ext_net_socket_write`/`_end`/`_destroy` splits (#5010/#5021). +#[no_mangle] +pub unsafe extern "C" fn js_ext_net_socket_connect( + arg1_f64: f64, + arg2_f64: f64, + arg3_f64: f64, +) -> i64 { + js_net_socket_connect(arg1_f64, arg2_f64, arg3_f64) +} + #[no_mangle] pub unsafe extern "C" fn js_net_socket_connect(arg1_f64: f64, arg2_f64: f64, arg3_f64: f64) -> i64 { /// Register `cb_f64` as a `'connect'` listener on `handle` if it @@ -1740,9 +1770,45 @@ pub extern "C" fn js_net_server_listening(handle: i64) -> i32 { /// reference resolved to perry-stdlib's no-op stub (compiled-out /// when bundled-net is off) and Map-retrieved sockets silently /// dispatched to undefined. +/// Distinct-symbol aliases for the socket EVENT-LISTENER surface (#5021's +/// twin-symbol disease). perry-stdlib exports same-named `js_net_socket_on` / +/// `_once` / `_remove_listener` twins, so in a build that links BOTH archives +/// the shared names bind to the bundled twin's EMPTY socket registry and the +/// listener registration is silently dropped: the socket connects, the reader +/// task delivers bytes, and the pump finds ZERO 'data' listeners — mysql2's +/// handshake then hangs to ETIMEDOUT. `write`/`end`/`destroy` were split out +/// for exactly this reason (#5010/#5021); the listener calls were not. +#[no_mangle] +pub unsafe extern "C" fn js_ext_net_socket_on(handle: i64, event_ptr: i64, cb: i64) { + js_net_socket_on(handle, event_ptr, cb) +} + +#[no_mangle] +pub unsafe extern "C" fn js_ext_net_socket_once(handle: i64, event_ptr: i64, cb: i64) -> i64 { + js_net_socket_once(handle, event_ptr, cb) +} + +#[no_mangle] +pub unsafe extern "C" fn js_ext_net_socket_remove_listener( + handle: i64, + event_ptr: i64, + cb: i64, +) -> i64 { + js_net_socket_remove_listener(handle, event_ptr, cb) +} + +#[no_mangle] +pub unsafe extern "C" fn js_ext_net_socket_remove_all_listeners( + handle: i64, + event_ptr: i64, +) -> i64 { + js_net_socket_remove_all_listeners(handle, event_ptr) +} + #[no_mangle] pub extern "C" fn js_ext_net_is_socket_handle(handle: i64) -> i32 { - if is_net_socket_handle(handle) { + let owned = is_net_socket_handle(handle); + if owned { 1 } else { 0 diff --git a/crates/perry-ffi/src/handle.rs b/crates/perry-ffi/src/handle.rs index d05bcf1af1..37ebb5cf6f 100644 --- a/crates/perry-ffi/src/handle.rs +++ b/crates/perry-ffi/src/handle.rs @@ -476,6 +476,19 @@ pub fn register_handle(value: T) -> Handle { handle } +/// Reserve a globally-unique handle id WITHOUT storing a value in the FFI +/// registry. For a subsystem that keeps its own object map (perry-ext-net's +/// socket registry) but must not alias another library's ids: every ext lib +/// that mints ids privately from 1 collides with the others in the shared +/// `[1, 0x40000)` band, and the composite handle-method dispatch then routes a +/// call to whichever extension *thinks* it owns that number. That is how +/// `socket.on('data', …)` on ext-net socket #1 got claimed by ext-http-server +/// (whose server was also #1) and the mysql2 handshake hung: the listener +/// registered on the HTTP server and the socket's bytes reached nobody. +pub fn reserve_handle_id() -> Handle { + pop_free_handle().unwrap_or_else(next_fresh_handle_id) +} + fn next_fresh_handle_id() -> Handle { let handle = NEXT_HANDLE.fetch_add(1, Ordering::SeqCst); if handle >= FFI_HANDLE_ID_END { diff --git a/crates/perry-ffi/src/lib.rs b/crates/perry-ffi/src/lib.rs index a4874c5bca..15cdeaea4d 100644 --- a/crates/perry-ffi/src/lib.rs +++ b/crates/perry-ffi/src/lib.rs @@ -65,8 +65,9 @@ pub use handle::gc_register_root_scanner; pub use handle::{ drain_quarantined_handles, drop_handle, drop_handle_until, gc_register_mutable_root_scanner, gc_register_mutable_root_scanner_named, get_handle, get_handle_mut, handle_exists, - iter_handle_ids_of, iter_handles_of, iter_handles_of_mut, register_handle, take_handle, - with_handle, with_handle_mut, GcMutableRootScanner, GcRootVisitor, Handle, INVALID_HANDLE, + iter_handle_ids_of, iter_handles_of, iter_handles_of_mut, register_handle, reserve_handle_id, + take_handle, with_handle, with_handle_mut, GcMutableRootScanner, GcRootVisitor, Handle, + INVALID_HANDLE, }; mod jsvalue; 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 ca88e72da2..2635dceff1 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 @@ -253,6 +253,8 @@ pub(crate) fn is_native_module_callable_export(module: &str, prop: &str) -> bool | ("tls", "createSecureContext") | ("tls", "SecureContext") | ("wasi", "WASI") + | ("net", "connect") + | ("net", "createConnection") | ("net", "createServer") | ("net", "Server") | ("net", "Socket") diff --git a/crates/perry-runtime/src/object/native_module/callable_exports.rs b/crates/perry-runtime/src/object/native_module/callable_exports.rs index 976e4f87be..6bf86d6b9b 100644 --- a/crates/perry-runtime/src/object/native_module/callable_exports.rs +++ b/crates/perry-runtime/src/object/native_module/callable_exports.rs @@ -445,6 +445,7 @@ fn native_callable_export_arity(module: &str, prop: &str) -> Option { | "isDestroyed", ) => Some(1), ("stream", "setDefaultHighWaterMark" | "addAbortSignal") => Some(2), + ("net", "connect" | "createConnection") => Some(3), ("net", "createServer" | "Server") => Some(2), ("net", "Socket") => Some(1), ("net", "BlockList" | "SocketAddress") => Some(0), @@ -698,18 +699,132 @@ const BUFFER_STATIC_METHODS: &[&str] = &[ "copyBytesFrom", ]; +/// Node exposes the WHOLE Buffer method surface on `Buffer.prototype`, and it is +/// enumerable — `for (const k in Buffer.prototype)` yields ~93 names there. +/// Perry used to install ELEVEN, which quietly broke any code that walks the +/// prototype: mysql2 sizes every outgoing packet by no-op'ing the write methods +/// of a zero-length Buffer +/// (`for (const k in Buffer.prototype) if (typeof mock[k] === "function") mock[k] = noop`), +/// so `writeUInt32LE` — absent from the stub list — stayed live, wrote into the +/// empty measuring buffer, and killed the MySQL handshake with +/// RangeError [ERR_OUT_OF_RANGE]. Generated from the dispatcher's own +/// `is_buffer_method_name` table so the two can't drift. const BUFFER_PROTOTYPE_METHODS: &[&str] = &[ "toString", - "equals", + "inspect", + "slice", "subarray", - "readUInt8", - "write", + "set", "copy", - "slice", + "write", + "toJSON", "fill", - "includes", + "equals", + "compare", "indexOf", "lastIndexOf", + "includes", + "at", + "swap16", + "swap32", + "swap64", + "values", + "keys", + "entries", + "undefined", + "hasOwnProperty", + "propertyIsEnumerable", + "valueOf", + "isPrototypeOf", + "toLocaleString", + "readUInt8", + "readUint8", + "readInt8", + "readUInt16BE", + "readUint16BE", + "readUInt16LE", + "readUint16LE", + "readInt16BE", + "readInt16LE", + "readUInt32BE", + "readUint32BE", + "readUInt32LE", + "readUint32LE", + "readInt32BE", + "readInt32LE", + "readFloatBE", + "readFloatLE", + "readDoubleBE", + "readDoubleLE", + "readBigInt64BE", + "readBigInt64LE", + "readBigUInt64BE", + "readBigUint64BE", + "readBigUInt64LE", + "readBigUint64LE", + "readUIntBE", + "readUintBE", + "readUIntLE", + "readUintLE", + "readIntBE", + "readIntLE", + "writeUInt8", + "writeUint8", + "writeInt8", + "writeUInt16BE", + "writeUint16BE", + "writeUInt16LE", + "writeUint16LE", + "writeInt16BE", + "writeInt16LE", + "writeUInt32BE", + "writeUint32BE", + "writeUInt32LE", + "writeUint32LE", + "writeInt32BE", + "writeInt32LE", + "writeFloatBE", + "writeFloatLE", + "writeDoubleBE", + "writeDoubleLE", + "writeBigInt64BE", + "writeBigInt64LE", + "writeBigUInt64BE", + "writeBigUint64BE", + "writeBigUInt64LE", + "writeBigUint64LE", + "writeUIntBE", + "writeUintBE", + "writeUIntLE", + "writeUintLE", + "writeIntBE", + "writeIntLE", + "toBase64", + "toHex", + "setFromBase64", + "setFromHex", + "copyWithin", + "function", + "getInt8", + "getUint8", + "getInt16", + "getUint16", + "getInt32", + "getUint32", + "getFloat32", + "getFloat64", + "setInt8", + "setUint8", + "setInt16", + "setUint16", + "setInt32", + "setUint32", + "setFloat32", + "setFloat64", + "getBigInt64", + "getBigUint64", + "setBigInt64", + "setBigUint64", ]; const SQLITE_DATABASE_SYNC_PROTOTYPE_METHODS: &[&str] = &[ 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 52278a6bf5..4dbc46de37 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 @@ -104,6 +104,35 @@ 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, + ) + } + } ("net", "_normalizeArgs") => crate::net_validate::js_net_normalize_args(arg(0)), ("net", "_createServerHandle") => crate::net_validate::js_net_create_server_handle_stub( arg(0), diff --git a/crates/perry-stdlib/src/common/dispatch/fastify_net_zlib.rs b/crates/perry-stdlib/src/common/dispatch/fastify_net_zlib.rs index d3713454a6..16e3c3d85e 100644 --- a/crates/perry-stdlib/src/common/dispatch/fastify_net_zlib.rs +++ b/crates/perry-stdlib/src/common/dispatch/fastify_net_zlib.rs @@ -186,7 +186,11 @@ pub(crate) unsafe fn dispatch_external_net_socket(handle: i64, method: &str, arg // chunk (NA_JSV bits) so `socket.end(data)` writes before FIN. fn js_ext_net_socket_end(handle: i64, chunk_bits: i64); fn js_ext_net_destroy_socket(handle: i64); - fn js_net_socket_on(handle: i64, event_ptr: i64, cb_ptr: i64); + // #5021 (listener half): the shared `js_net_socket_*` listener names + // have bundled-stdlib twins that bind to an EMPTY registry in a + // both-archives link — the registration is dropped and the socket's + // 'data' events never reach JS. Use ext-net's distinct symbols. + fn js_ext_net_socket_on(handle: i64, event_ptr: i64, cb_ptr: i64); fn js_net_socket_method_connect(handle: i64, port: f64, host_ptr: i64); fn js_net_socket_upgrade_tls( handle: i64, @@ -199,9 +203,9 @@ pub(crate) unsafe fn dispatch_external_net_socket(handle: i64, method: &str, arg // socket arg of `server.on('connection', sock => …)` after // codegen loses the static class) to them. fn js_net_socket_address(handle: i64) -> *mut perry_runtime::StringHeader; - fn js_net_socket_once(handle: i64, event_ptr: i64, cb_ptr: i64) -> i64; - fn js_net_socket_remove_listener(handle: i64, event_ptr: i64, cb_ptr: i64) -> i64; - fn js_net_socket_remove_all_listeners(handle: i64, event_ptr: i64) -> i64; + fn js_ext_net_socket_once(handle: i64, event_ptr: i64, cb_ptr: i64) -> i64; + fn js_ext_net_socket_remove_listener(handle: i64, event_ptr: i64, cb_ptr: i64) -> i64; + fn js_ext_net_socket_remove_all_listeners(handle: i64, event_ptr: i64) -> i64; fn js_net_socket_listener_count(handle: i64, event_ptr: i64) -> f64; fn js_net_socket_event_names(handle: i64) -> *mut perry_runtime::StringHeader; fn js_net_socket_reset_and_destroy(handle: i64) -> i64; @@ -250,7 +254,7 @@ pub(crate) unsafe fn dispatch_external_net_socket(handle: i64, method: &str, arg "on" | "addListener" if args.len() >= 2 => { let event_ptr = unbox_to_i64(args[0]); let cb_ptr = unbox_to_i64(args[1]); - js_net_socket_on(handle, event_ptr, cb_ptr); + js_ext_net_socket_on(handle, event_ptr, cb_ptr); nanbox_handle(handle) } "connect" if args.len() >= 2 => { @@ -272,13 +276,13 @@ pub(crate) unsafe fn dispatch_external_net_socket(handle: i64, method: &str, arg "once" if args.len() >= 2 => { let event_ptr = unbox_to_i64(args[0]); let cb_ptr = unbox_to_i64(args[1]); - js_net_socket_once(handle, event_ptr, cb_ptr); + js_ext_net_socket_once(handle, event_ptr, cb_ptr); nanbox_handle(handle) } "off" | "removeListener" if args.len() >= 2 => { let event_ptr = unbox_to_i64(args[0]); let cb_ptr = unbox_to_i64(args[1]); - js_net_socket_remove_listener(handle, event_ptr, cb_ptr); + js_ext_net_socket_remove_listener(handle, event_ptr, cb_ptr); nanbox_handle(handle) } "removeAllListeners" => { @@ -286,7 +290,7 @@ pub(crate) unsafe fn dispatch_external_net_socket(handle: i64, method: &str, arg // `undefined`; the FFI treats a null/non-string ptr as // "drain every event". let event_ptr = args.first().copied().map(unbox_to_i64).unwrap_or(0); - js_net_socket_remove_all_listeners(handle, event_ptr); + js_ext_net_socket_remove_all_listeners(handle, event_ptr); nanbox_handle(handle) } "listenerCount" if !args.is_empty() => { diff --git a/crates/perry-stdlib/src/common/dispatch/init.rs b/crates/perry-stdlib/src/common/dispatch/init.rs index ff15a3a1ae..59220217cc 100644 --- a/crates/perry-stdlib/src/common/dispatch/init.rs +++ b/crates/perry-stdlib/src/common/dispatch/init.rs @@ -266,6 +266,61 @@ unsafe extern "C" fn js_node_http_native_dispatch( 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" { diff --git a/test-files/test_gap_net_connect_bound_value.ts b/test-files/test_gap_net_connect_bound_value.ts new file mode 100644 index 0000000000..bd7399e3dd --- /dev/null +++ b/test-files/test_gap_net_connect_bound_value.ts @@ -0,0 +1,48 @@ +// `const net = require('net'); net.connect(port, host, cb)` — the socket factory +// reached as a bound VALUE through the CJS externals wrapper, which is how mysql2 +// (bundled by turbopack) opens its connection. The call arrives at the runtime's +// native-module dispatch rather than the static codegen table, and `net.connect` / +// `net.createConnection` had no arm there: they read as non-callable, so the +// connection was never opened. + +import { createRequire } from "node:module"; +const require = createRequire(import.meta.url); +const net = require("net"); + +console.log("connect :", typeof net.connect); +console.log("createConnection :", typeof net.createConnection); + +const server = net.createServer((sock: any) => { + sock.on("data", (d: any) => { + sock.write("echo:" + d.toString()); + }); +}); + +server.listen(0, "127.0.0.1", () => { + const port = server.address().port; + + // connect() as a bound value, with the connect-listener callback + const sock = net.connect(port, "127.0.0.1", () => { + sock.write("hello"); + }); + + sock.on("data", (d: any) => { + console.log("round trip :", d.toString()); + sock.end(); + + // createConnection() is the same factory under its other name + const sock2 = net.createConnection(port, "127.0.0.1", () => { + sock2.write("again"); + }); + sock2.on("data", (d2: any) => { + console.log("createConnection :", d2.toString()); + sock2.end(); + server.close(); + }); + }); + + sock.on("error", (e: any) => { + console.log("socket error :", e.message); + server.close(); + }); +});