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
7 changes: 5 additions & 2 deletions crates/perry-api-manifest/src/entries.rs
Original file line number Diff line number Diff line change
Expand Up @@ -460,15 +460,18 @@ pub static API_MANIFEST: &[ApiEntry] = &[
"createConnection",
false,
None,
&[p_any("p0"), p_str("p1")],
// p0 = port (number) or options object; p1 = host (string) or
// connectListener; p2 = connectListener in positional form.
// Issue #770 widened to accept the options-object overload.
&[p_any("p0"), p_any("p1"), p_any("p2")],
TypeSpec::Any,
),
method_sig(
"net",
"connect",
false,
None,
&[p_any("p0"), p_str("p1")],
&[p_any("p0"), p_any("p1"), p_any("p2")],
TypeSpec::Any,
),
method_sig("net", "Socket", false, None, &[], TypeSpec::Any),
Expand Down
28 changes: 19 additions & 9 deletions crates/perry-codegen/src/lower_call.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6923,22 +6923,32 @@ const NATIVE_MODULE_TABLE: &[NativeModSig] = &[
ret: NR_VOID,
},
// ========== Raw TCP sockets (net) + TLS ==========
// Factory: `net.createConnection(port, host)` returns a Socket handle.
// Argument order matches Node.js: port (number) first, host (string) second.
// HIR lowering at crates/perry-hir/src/lower.rs registers the return
// value as class "Socket" so subsequent methods dispatch via the
// class_filter entries below.
// Factory: `net.createConnection(...)` / `net.connect(...)` returns
// a Socket handle. Supports both Node overloads:
// - `net.connect(port, host)` — positional
// - `net.connect({ host, port }, cb?)` — options object (issue #770)
// Both args are passed through as `NA_F64` so the runtime sees the
// raw NaN-boxed bits and can discriminate the overload by tag.
// Pre-#770 the second arg was `NA_STR`, which silently corrupted the
// options-object call site: codegen tried to coerce the callback
// function to a string pointer, the runtime read garbage bytes as
// the host name, and `getaddrinfo`'s internal `CString::new()`
// panicked with "file name contained an unexpected NUL byte".
//
// HIR lowering at crates/perry-hir/src/lower.rs registers the
// return value as class "Socket" so subsequent methods dispatch via
// the class_filter entries below.
NativeModSig {
module: "net",
has_receiver: false,
method: "createConnection",
class_filter: None,
runtime: "js_net_socket_connect",
args: &[NA_F64, NA_STR],
args: &[NA_F64, NA_F64, NA_F64],
ret: NR_PTR,
},
// Factory alias: `net.connect(port, host)` is the spec'd alias for
// `net.createConnection(port, host)`. Pre-issue-#422 only the
// Factory alias: `net.connect(...)` is the spec'd alias for
// `net.createConnection(...)`. Pre-issue-#422 only the
// `createConnection` form was wired; `net.connect(...)` fell through
// to the receiver-less unknown-method path which returns
// TAG_UNDEFINED, so user code reading `typeof net.connect(...)`
Expand All @@ -6949,7 +6959,7 @@ const NATIVE_MODULE_TABLE: &[NativeModSig] = &[
method: "connect",
class_filter: None,
runtime: "js_net_socket_connect",
args: &[NA_F64, NA_STR],
args: &[NA_F64, NA_F64, NA_F64],
ret: NR_PTR,
},
// Constructor: `new net.Socket()` allocates an unconnected socket
Expand Down
195 changes: 181 additions & 14 deletions crates/perry-ext-net/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,9 @@
//! for backwards compat; the well-known flip routes here.

use perry_ffi::{
alloc_buffer, alloc_string, gc_register_root_scanner, nanbox_string_bits, BufferHeader,
JsClosure, JsPromise, RawClosureHeader, StringHeader,
alloc_buffer, alloc_string, build_object_shape, gc_register_root_scanner,
js_object_alloc_with_shape, js_object_set_field, nanbox_string_bits, BufferHeader, JsClosure,
JsPromise, JsValue, ObjectHeader, RawClosureHeader, StringHeader,
};
use std::collections::HashMap;
use std::io;
Expand Down Expand Up @@ -201,6 +202,111 @@ unsafe fn string_from_header_i64(ptr: i64) -> Option<String> {
std::str::from_utf8(bytes).ok().map(|s| s.to_string())
}

// Runtime entrypoints provided by perry-runtime (declared as extern so
// perry-ext-net doesn't need to depend on the perry-runtime rlib).
extern "C" {
fn js_string_from_bytes(data: *const u8, len: u32) -> *mut StringHeader;
fn js_object_get_field_by_name_f64(obj: *const ObjectHeader, key: *const StringHeader) -> f64;
}

/// True iff `val_f64` carries `POINTER_TAG` (0x7FFD) — a real pointer
/// to a heap object or closure. Used to discriminate the
/// positional `net.connect(port, host)` overload (arg1 is a plain
/// number) from the options-object `net.connect({host, port}, cb?)`
/// overload (arg1 is a NaN-boxed object pointer), and to detect a
/// real `connectListener` closure in the trailing arg slot.
///
/// Narrower than "any NaN-tagged value": the dispatch table pads
/// missing user args with `TAG_UNDEFINED` (`0x7FFC` band), so this
/// check has to reject `undefined` cleanly to keep "user passed only
/// 2 args" from misfiring as "user passed a callback". Issue #770.
fn is_nanboxed_pointer(val_f64: f64) -> bool {
(val_f64.to_bits() >> 48) == 0x7FFD
}

/// Unbox a NaN-boxed value to the raw 48-bit pointer payload, regardless
/// of which `0x7FFx` tag it carries.
unsafe fn unbox_pointer(val_f64: f64) -> *mut u8 {
let bits = val_f64.to_bits();
(bits & 0x0000_FFFF_FFFF_FFFF) as *mut u8
}

/// Extract a string field from a NaN-boxed JS object. Accepts string
/// values and numeric values (numbers stringified) — Node accepts both
/// shapes for `port` etc.
unsafe fn get_object_string_field(obj_f64: f64, field_name: &str) -> Option<String> {
if !is_nanboxed_pointer(obj_f64) {
return None;
}
let obj_ptr = unbox_pointer(obj_f64) as *const ObjectHeader;
if obj_ptr.is_null() {
return None;
}
let key = js_string_from_bytes(field_name.as_ptr(), field_name.len() as u32);
let val_f64 = js_object_get_field_by_name_f64(obj_ptr, key);
let val = JsValue::from_bits(val_f64.to_bits());
if val.is_undefined() || val.is_null() {
return None;
}
if val.is_string() {
return string_from_header_i64(val.as_string_ptr() as i64);
}
if val.is_number() {
return Some(format!("{}", val.to_number() as i64));
}
None
}

unsafe fn get_object_number_field(obj_f64: f64, field_name: &str) -> Option<f64> {
if !is_nanboxed_pointer(obj_f64) {
return None;
}
let obj_ptr = unbox_pointer(obj_f64) as *const ObjectHeader;
if obj_ptr.is_null() {
return None;
}
let key = js_string_from_bytes(field_name.as_ptr(), field_name.len() as u32);
let val_f64 = js_object_get_field_by_name_f64(obj_ptr, key);
let val = JsValue::from_bits(val_f64.to_bits());
if val.is_undefined() || val.is_null() {
return None;
}
if val.is_number() {
return Some(val.to_number());
}
// Some npm code passes `port` as a string — accept that too.
if val.is_string() {
if let Some(s) = string_from_header_i64(val.as_string_ptr() as i64) {
if let Ok(n) = s.parse::<f64>() {
return Some(n);
}
}
}
None
}

/// Build an `Error`-shaped object `{ message: msg }` so user code can
/// read `err.message` from the `'error'` listener — Node emits Error
/// instances, not raw strings. Returns a NaN-boxed `f64` pointing at
/// the object. Issue #770.
unsafe fn build_error_object(msg: &str) -> f64 {
let keys: [&str; 1] = ["message"];
let (packed, shape_id) = build_object_shape(&keys);
let obj: *mut ObjectHeader =
js_object_alloc_with_shape(shape_id, 1, packed.as_ptr(), packed.len() as u32);
if obj.is_null() {
// Fall back to the bare string so the listener still receives
// *something* if the object alloc failed.
let s = alloc_string(msg);
return f64::from_bits(nanbox_string_bits(s.as_raw()));
}
let s = alloc_string(msg);
let v = JsValue::from_string_ptr(s.as_raw());
js_object_set_field(obj, 0, v);
let obj_v = JsValue::from_object_ptr(obj as *mut u8);
f64::from_bits(obj_v.bits())
}

fn next_id() -> i64 {
let mut g = statics::next_net_id().lock().unwrap();
let id = *g;
Expand Down Expand Up @@ -373,24 +479,83 @@ where
});
}

// ─── FFI: net.createConnection(port, host) ───────────────────────────────────
// ─── FFI: net.createConnection / net.connect ─────────────────────────────────

/// `net.createConnection(port, host)` — returns a handle immediately;
/// connection happens in the background and emits `'connect'` or `'error'`.
/// `net.createConnection(...)` / `net.connect(...)` — returns a handle
/// immediately; connection happens in the background and emits
/// `'connect'` or `'error'`. Supports both Node overloads:
///
/// - Positional: `net.connect(port, host, cb?)`. `arg1_f64` is the
/// port as a regular f64 number, `arg2_f64` carries the host as a
/// NaN-boxed string, `arg3_f64` is the optional `connectListener`.
/// - Options object: `net.connect({ host, port }, cb?)`. `arg1_f64`
/// is a NaN-boxed pointer to a JS object with `host`/`hostname`/
/// `port`; `arg2_f64` is the optional `connectListener`. In this
/// form `arg3_f64` is unused (the dispatch table pads it with
/// `undefined`). Issue #770.
///
/// The `connectListener` (whichever slot it ends up in) is
/// auto-registered as a `'connect'` listener on the new socket
/// handle, matching the Node spec.
///
/// # Safety
///
/// `host_ptr` must be null or a Perry-runtime `StringHeader` pointer (cast
/// to `i64` per the codegen ABI — see `NA_PTR` / `NA_STR` lowering in
/// perry-codegen).
/// All three args must be NaN-boxed Perry-runtime values per the
/// codegen ABI — see `NA_F64` lowering in perry-codegen.
#[no_mangle]
pub unsafe extern "C" fn js_net_socket_connect(port: f64, host_ptr: i64) -> i64 {
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
/// carries a real closure pointer. No-op otherwise.
fn register_connect_cb(handle: i64, cb_f64: f64) {
if handle == 0 || !is_nanboxed_pointer(cb_f64) {
return;
}
let cb_ptr = unsafe { unbox_pointer(cb_f64) } as i64;
if cb_ptr == 0 {
return;
}
let mut listeners = statics::listeners().lock().unwrap();
listeners
.entry(handle)
.or_default()
.entry("connect".to_string())
.or_default()
.push(cb_ptr);
}

if is_nanboxed_pointer(arg1_f64) {
// Options-object overload: extract host/port from the object.
let host = match get_object_string_field(arg1_f64, "host")
.or_else(|| get_object_string_field(arg1_f64, "hostname"))
{
Some(h) if !h.is_empty() => h,
_ => "localhost".to_string(),
};
let port = match get_object_number_field(arg1_f64, "port") {
Some(p) => p as u16,
None => return 0,
};
let handle = spawn_socket_task(host, port, /* direct_tls: */ None);
// connectListener lives in arg2 for the options form.
register_connect_cb(handle, arg2_f64);
return handle;
}
// Positional overload: arg1 is the port number, arg2 is the host
// string (NaN-boxed), arg3 is the optional connectListener. Reuse
// the runtime's string-pointer unifier (handles STRING_TAG and
// POINTER_TAG strings the same way).
extern "C" {
fn js_get_string_pointer_unified(value: f64) -> i64;
}
let host_ptr = js_get_string_pointer_unified(arg2_f64);
let host = match string_from_header_i64(host_ptr) {
Some(h) => h,
None => return 0,
};
let port = port as u16;
spawn_socket_task(host, port, /* direct_tls: */ None)
let port = arg1_f64 as u16;
let handle = spawn_socket_task(host, port, /* direct_tls: */ None);
register_connect_cb(handle, arg3_f64);
handle
}

// ─── FFI: new net.Socket() (alloc-only, deferred connect) ────────────────────
Expand Down Expand Up @@ -845,11 +1010,13 @@ pub unsafe extern "C" fn js_net_process_pending() -> i32 {
if cbs.is_empty() {
continue;
}
let s = alloc_string(&msg);
let s_f64 = f64::from_bits(nanbox_string_bits(s.as_raw()));
// Issue #770 — emit an Error-shaped object `{message: msg}`
// so user code can read `err.message`. Pre-fix this was a
// raw NaN-boxed string and `err.message` was `undefined`.
let err_f64 = build_error_object(&msg);
for cb in cbs {
if cb != 0 {
let _ = JsClosure::from_raw(cb as *const RawClosureHeader).call1(s_f64);
let _ = JsClosure::from_raw(cb as *const RawClosureHeader).call1(err_f64);
}
}
}
Expand Down
Loading
Loading