diff --git a/crates/perry-api-manifest/src/entries.rs b/crates/perry-api-manifest/src/entries.rs index 3ea786c90..53bacbe7f 100644 --- a/crates/perry-api-manifest/src/entries.rs +++ b/crates/perry-api-manifest/src/entries.rs @@ -460,7 +460,10 @@ 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( @@ -468,7 +471,7 @@ pub static API_MANIFEST: &[ApiEntry] = &[ "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), diff --git a/crates/perry-codegen/src/lower_call.rs b/crates/perry-codegen/src/lower_call.rs index 907dc4859..e81569635 100644 --- a/crates/perry-codegen/src/lower_call.rs +++ b/crates/perry-codegen/src/lower_call.rs @@ -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(...)` @@ -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 diff --git a/crates/perry-ext-net/src/lib.rs b/crates/perry-ext-net/src/lib.rs index ef315191b..2d7e298f2 100644 --- a/crates/perry-ext-net/src/lib.rs +++ b/crates/perry-ext-net/src/lib.rs @@ -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; @@ -201,6 +202,111 @@ unsafe fn string_from_header_i64(ptr: i64) -> Option { 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 { + 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 { + 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::() { + 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; @@ -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) ──────────────────── @@ -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); } } } diff --git a/crates/perry-stdlib/src/net/mod.rs b/crates/perry-stdlib/src/net/mod.rs index 5d8933b44..b500ae2d6 100644 --- a/crates/perry-stdlib/src/net/mod.rs +++ b/crates/perry-stdlib/src/net/mod.rs @@ -184,6 +184,98 @@ unsafe fn string_from_header_i64(ptr: i64) -> Option { std::str::from_utf8(bytes).ok().map(|s| s.to_string()) } +/// Issue #770 — true iff `val_f64` carries `POINTER_TAG` (0x7FFD), i.e. +/// it's a real heap-pointer NaN-box (object or closure). Plain `f64` +/// ports like `80.0` never reach this band, and `undefined` / `null` +/// land in `0x7FFC` so they're cleanly rejected — which matters +/// because the dispatch table pads missing user args with +/// `TAG_UNDEFINED`. +fn is_nanboxed_pointer(val_f64: f64) -> bool { + (val_f64.to_bits() >> 48) == 0x7FFD +} + +unsafe fn unbox_pointer(val_f64: f64) -> *mut u8 { + let bits = val_f64.to_bits(); + (bits & 0x0000_FFFF_FFFF_FFFF) as *mut u8 +} + +unsafe fn get_object_string_field(obj_f64: f64, field_name: &str) -> Option { + if !is_nanboxed_pointer(obj_f64) { + return None; + } + let obj_ptr = unbox_pointer(obj_f64) as *const perry_runtime::ObjectHeader; + if obj_ptr.is_null() { + return None; + } + let key = perry_runtime::js_string_from_bytes(field_name.as_ptr(), field_name.len() as u32); + let val = perry_runtime::js_object_get_field_by_name(obj_ptr, key); + 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.as_number() as i64)); + } + None +} + +unsafe fn get_object_number_field(obj_f64: f64, field_name: &str) -> Option { + if !is_nanboxed_pointer(obj_f64) { + return None; + } + let obj_ptr = unbox_pointer(obj_f64) as *const perry_runtime::ObjectHeader; + if obj_ptr.is_null() { + return None; + } + let key = perry_runtime::js_string_from_bytes(field_name.as_ptr(), field_name.len() as u32); + let val = perry_runtime::js_object_get_field_by_name(obj_ptr, key); + if val.is_undefined() || val.is_null() { + return None; + } + if val.is_number() { + return Some(val.as_number()); + } + if val.is_string() { + if let Some(s) = string_from_header_i64(val.as_string_ptr() as i64) { + if let Ok(n) = s.parse::() { + return Some(n); + } + } + } + None +} + +/// Issue #770 — build an `Error`-shaped object `{ message: msg }` so +/// `socket.on('error', err => err.message)` works. Returns a NaN-boxed +/// f64 pointing at the object, falling back to a bare string on alloc +/// failure. Packed-keys format (NUL-delimited names + hash shape id) +/// mirrors `crates/perry-stdlib/src/sqlite.rs::build_packed_keys`. +unsafe fn build_error_object(msg: &str) -> f64 { + use perry_runtime::JSValue; + let name = b"message"; + let packed: Vec = name.to_vec(); + let mut shape_id: u32 = 0x4E45_0000; // "NE" — net error + for &b in name { + shape_id = shape_id.wrapping_mul(31).wrapping_add(b as u32); + } + shape_id = shape_id.wrapping_add(1); + let s_msg = perry_runtime::js_string_from_bytes(msg.as_ptr(), msg.len() as u32); + let obj = perry_runtime::js_object_alloc_with_shape( + shape_id, + 1, + packed.as_ptr(), + packed.len() as u32, + ); + if obj.is_null() { + return f64::from_bits(0x7FFF_0000_0000_0000u64 | (s_msg as u64 & 0x0000_FFFF_FFFF_FFFF)); + } + perry_runtime::js_object_set_field(obj, 0, JSValue::string_ptr(s_msg)); + let obj_bits = (obj as u64 & 0x0000_FFFF_FFFF_FFFF) | 0x7FFD_0000_0000_0000; + f64::from_bits(obj_bits) +} + fn next_id() -> i64 { let mut g = NEXT_NET_ID.lock().unwrap(); let id = *g; @@ -290,22 +382,70 @@ fn build_tls_connector_insecure() -> Result { Ok(TlsConnector::from(Arc::new(config))) } -// ─── 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 (issue #770): +/// - Positional: `net.connect(port, host, cb?)` — `arg1_f64` is the +/// port, `arg2_f64` is the host (NaN-boxed string), `arg3_f64` is +/// the optional connectListener. +/// - Options object: `net.connect({ host, port }, cb?)` — `arg1_f64` +/// is a NaN-boxed pointer to the options object; `arg2_f64` is the +/// optional connectListener; `arg3_f64` is unused (the dispatch +/// table pads it with `undefined`). +/// +/// The `connectListener` is auto-registered as a `'connect'` listener +/// on the new socket handle, matching Node spec. /// -/// Argument order matches Node.js: port (number) first, host (string) second. /// Signature matches NATIVE_MODULE_TABLE entry -/// `{ module: "net", method: "createConnection", args: &[NA_F64, NA_STR], ret: NR_PTR }`. +/// `{ module: "net", method: "connect" | "createConnection", args: &[NA_F64, NA_F64, NA_F64], ret: NR_PTR }`. #[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 { + 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 = NET_LISTENERS.lock().unwrap(); + listeners + .entry(handle) + .or_default() + .entry("connect".to_string()) + .or_default() + .push(cb_ptr); + } + + if is_nanboxed_pointer(arg1_f64) { + 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); + register_connect_cb(handle, arg2_f64); + return handle; + } + // Positional form: arg2 is a NaN-boxed string, arg3 is the cb. + let host_ptr = perry_runtime::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) ──────────────────── @@ -805,13 +945,14 @@ pub unsafe extern "C" fn js_net_process_pending() -> i32 { if cbs.is_empty() { continue; } - let bytes = msg.as_bytes(); - let s = perry_runtime::js_string_from_bytes(bytes.as_ptr(), bytes.len() as u32); - let s_f64 = - f64::from_bits(0x7FFF_0000_0000_0000u64 | (s as u64 & 0x0000_FFFF_FFFF_FFFF)); + // Issue #770 — emit an Error-shaped object `{message: msg}` + // so user code can read `err.message`. Pre-fix the listener + // received a raw NaN-boxed string and `err.message` came + // back as `undefined`. + let err_f64 = build_error_object(&msg); for cb in cbs { if cb != 0 { - js_closure_call1(cb as *const ClosureHeader, s_f64); + js_closure_call1(cb as *const ClosureHeader, err_f64); } } } diff --git a/docs/api/perry.d.ts b/docs/api/perry.d.ts index bf9aeb001..a20b9c52a 100644 --- a/docs/api/perry.d.ts +++ b/docs/api/perry.d.ts @@ -444,9 +444,9 @@ declare module "net" { /** stdlib */ export function Socket(...args: any[]): any; /** stdlib */ - export function connect(p0: any, p1: string): any; + export function connect(p0: any, p1: any, p2: any): any; /** stdlib */ - export function createConnection(p0: any, p1: string): any; + export function createConnection(p0: any, p1: any, p2: any): any; } declare module "node-cron" { diff --git a/test-files/test_net_connect_options.ts b/test-files/test_net_connect_options.ts new file mode 100644 index 000000000..377d35ad5 --- /dev/null +++ b/test-files/test_net_connect_options.ts @@ -0,0 +1,63 @@ +// Issue #770 — `net.connect({ host, port }, cb)` options-object overload. +// +// Pre-fix this failed with "file name contained an unexpected NUL byte" +// because the codegen tried to coerce the callback function to a string +// pointer (NA_STR), the runtime read garbage bytes as the hostname, and +// `getaddrinfo`'s internal `CString::new()` blew up. Separately, the +// 'error' event emitted raw strings instead of Error objects, so +// `err.message` came back as `undefined`. +// +// This fixture spins up a node:http server (uses TCP), connects to it +// with the options-object form, and verifies (a) the auto-registered +// `connectListener` fires, (b) data round-trips, (c) the 'error' event +// for a closed port produces an object with a useful `.message`. + +import { createServer } from "node:http"; +import * as net from "net"; + +const port = 18891; + +const server = createServer((req: any, res: any) => { + res.statusCode = 200; + res.setHeader("Content-Type", "text/plain"); + res.end("ok"); +}); + +server.listen(port, () => { + console.log("server listening"); + + // Chain the connections so the parity comparison against + // `node --experimental-strip-types` sees a deterministic line order + // (parallel connections would race the two `connectListener fired` + // prints and intermittently flip them). + // + // (1) Options-object form with auto-registered connectListener. + const sock1 = net.connect({ host: "127.0.0.1", port: port }, () => { + console.log("sock1 (options): connectListener fired"); + + // (2) Positional form with auto-registered connectListener (3rd arg). + const sock2 = net.connect(port, "127.0.0.1", () => { + console.log("sock2 (positional): connectListener fired"); + + // (3) Closed-port connection — should fire 'error' with an + // Error-shaped object whose `.message` is a real string. + const sock3 = net.connect({ host: "127.0.0.1", port: 1 }); + sock3.on("connect", () => { + console.log("sock3 unexpected connect"); + }); + sock3.on("error", (err: any) => { + console.log("sock3 error typeof:", typeof err); + console.log("sock3 error message typeof:", typeof err?.message); + console.log("sock3 error has message:", err?.message ? "yes" : "no"); + server.close(); + console.log("done"); + }); + }); + sock2.on("error", (err: any) => { + console.log("sock2 unexpected error:", err?.message); + }); + }); + sock1.on("error", (err: any) => { + console.log("sock1 unexpected error:", err?.message); + }); +});