Skip to content
Merged
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
76 changes: 71 additions & 5 deletions crates/perry-ext-net/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions crates/perry-ffi/src/handle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -476,6 +476,19 @@ pub fn register_handle<T: 'static + Send + Sync>(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)
}

Comment on lines +479 to +491

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🔴 Critical | 🏗️ Heavy lift

Handle ID leak: provide a way to recycle reserved handles.

reserve_handle_id allocates an ID, but there is no corresponding public API to free it. Since the existing take_handle function only recycles IDs that were successfully removed from the HANDLES registry, reserved IDs managed by external subsystems (like perry-ext-net's sockets) will leak when those sockets are destroyed.

Because the handle ID space is bounded ([1, 0x40000)), this leak will eventually exhaust the range and trigger the panic in next_fresh_handle_id, crashing long-running applications.

🐛 Proposed fix to expose a recycle function
 pub fn reserve_handle_id() -> Handle {
     pop_free_handle().unwrap_or_else(next_fresh_handle_id)
 }
+
+/// Recycle a globally-unique handle id that was previously reserved via `reserve_handle_id`.
+pub fn free_handle_id(handle: Handle) {
+    recycle_handle(handle);
+}

You will also need to re-export free_handle_id in crates/perry-ffi/src/lib.rs and ensure that perry-ext-net calls it when a socket is destroyed.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/// 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)
}
/// 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)
}
/// Recycle a globally-unique handle id that was previously reserved via `reserve_handle_id`.
pub fn free_handle_id(handle: Handle) {
recycle_handle(handle);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-ffi/src/handle.rs` around lines 479 - 491, Add a public
free_handle_id API in handle.rs that recycles IDs reserved by reserve_handle_id
without requiring an entry in HANDLES, and re-export it from lib.rs. Update
perry-ext-net’s socket destruction path to call free_handle_id for the socket’s
reserved handle after removing its own registry state.

fn next_fresh_handle_id() -> Handle {
let handle = NEXT_HANDLE.fetch_add(1, Ordering::SeqCst);
if handle >= FFI_HANDLE_ID_END {
Expand Down
5 changes: 3 additions & 2 deletions crates/perry-ffi/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
125 changes: 120 additions & 5 deletions crates/perry-runtime/src/object/native_module/callable_exports.rs
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,7 @@ fn native_callable_export_arity(module: &str, prop: &str) -> Option<u32> {
| "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),
Expand Down Expand Up @@ -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] = &[
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
20 changes: 12 additions & 8 deletions crates/perry-stdlib/src/common/dispatch/fastify_net_zlib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
Expand Down Expand Up @@ -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 => {
Expand All @@ -272,21 +276,21 @@ 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" => {
// Bare `removeAllListeners()` passes no event, padded as
// `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() => {
Expand Down
Loading
Loading