diff --git a/changelog.d/9763-websocket-server-upgrades.md b/changelog.d/9763-websocket-server-upgrades.md new file mode 100644 index 0000000000..584c275a40 --- /dev/null +++ b/changelog.d/9763-websocket-server-upgrades.md @@ -0,0 +1,4 @@ +### Fixed +- Attach native `WebSocketServer({ server })` instances to an existing HTTP listener; deliver manual `handleUpgrade` callbacks and connection events with usable client handles and the original request. +- Bind `WebSocketServer({ port: 0 })` to an ephemeral port and expose the actual listening address through `address()`. +- Treat native handle IDs as identities when hashing Sets, including `WebSocketServer.clients`. diff --git a/crates/perry-api-manifest/src/entries/part_1.rs b/crates/perry-api-manifest/src/entries/part_1.rs index faead889b2..8ccae06fe6 100644 --- a/crates/perry-api-manifest/src/entries/part_1.rs +++ b/crates/perry-api-manifest/src/entries/part_1.rs @@ -428,6 +428,8 @@ pub(crate) const API_MANIFEST_PART_1: &[ApiEntry] = &[ // #1113 — `wss.handleUpgrade(req, socket, head, cb)` for a // `new WebSocketServer({ noServer: true })`. method("ws", "handleUpgrade", true, None), + method("ws", "address", true, None), + method("ws", "emit", true, None), // Issue #577 Phase 4 — Client-class methods for the upgrade-path wsId. method("ws", "on", true, Some("Client")), method("ws", "addListener", true, Some("Client")), diff --git a/crates/perry-codegen/src/ext_registry.rs b/crates/perry-codegen/src/ext_registry.rs index b7f35da1e9..d40f4283a6 100644 --- a/crates/perry-codegen/src/ext_registry.rs +++ b/crates/perry-codegen/src/ext_registry.rs @@ -515,6 +515,8 @@ const FFI_REGISTRY: &[(&str, OwnerKind)] = &[ ("js_ws_close_client", OwnerKind::WellKnown("ws")), ("js_ws_server_new", OwnerKind::WellKnown("ws")), ("js_ws_server_clients", OwnerKind::WellKnown("ws")), + ("js_ws_server_address", OwnerKind::WellKnown("ws")), + ("js_ws_server_emit", OwnerKind::WellKnown("ws")), ("js_ws_server_close", OwnerKind::WellKnown("ws")), // ── #1724: global Blob/File + URL object-URL helpers ────────────── diff --git a/crates/perry-codegen/src/lower_call/native_table/ws_events.rs b/crates/perry-codegen/src/lower_call/native_table/ws_events.rs index 9b7ec796c0..7cdadc04c1 100644 --- a/crates/perry-codegen/src/lower_call/native_table/ws_events.rs +++ b/crates/perry-codegen/src/lower_call/native_table/ws_events.rs @@ -78,6 +78,33 @@ pub(super) const WS_EVENTS_ROWS: &[NativeModSig] = &[ args: &[], ret: NR_F64, }, + NativeModSig { + module: "ws", + has_receiver: true, + method: "handleUpgrade", + class_filter: None, + runtime: "js_ws_handle_upgrade", + args: &[NA_F64, NA_F64, NA_F64, NA_PTR], + ret: NR_VOID, + }, + NativeModSig { + module: "ws", + has_receiver: true, + method: "address", + class_filter: None, + runtime: "js_ws_server_address", + args: &[], + ret: NR_F64, + }, + NativeModSig { + module: "ws", + has_receiver: true, + method: "emit", + class_filter: None, + runtime: "js_ws_server_emit", + args: &[NA_STR, NA_F64, NA_F64], + ret: NR_BOOL, + }, // Issue #577 Phase 4 — `("ws", "Client")` instance methods. // The wsId delivered to `Server.on('upgrade', (req, wsId, head) => …)` // is NaN-boxed POINTER_TAG so unbox_to_i64 (called by the dispatch diff --git a/crates/perry-codegen/src/runtime_decls/stdlib_ffi/web.rs b/crates/perry-codegen/src/runtime_decls/stdlib_ffi/web.rs index a7b8115ee7..6d1b5b460c 100644 --- a/crates/perry-codegen/src/runtime_decls/stdlib_ffi/web.rs +++ b/crates/perry-codegen/src/runtime_decls/stdlib_ffi/web.rs @@ -130,6 +130,8 @@ pub(crate) fn declare_web(module: &mut LlModule) { module.declare_function("js_ws_on_client_i64", I64, &[I64, I64, I64]); module.declare_function("js_ws_server_close", VOID, &[I64]); module.declare_function("js_ws_server_clients", DOUBLE, &[I64]); + module.declare_function("js_ws_server_address", DOUBLE, &[I64]); + module.declare_function("js_ws_server_emit", I32, &[I64, I64, DOUBLE, DOUBLE]); module.declare_function("js_ws_server_new", I64, &[DOUBLE]); // #1113 — `wss.handleUpgrade(req, socket, head, cb)`. Receiver // (the noServer WsServerHandle) is passed as I64 (post-unbox_to_i64 @@ -138,7 +140,7 @@ pub(crate) fn declare_web(module: &mut LlModule) { // cb is the unboxed closure pointer (I64). module.declare_function( "js_ws_handle_upgrade", - I64, + VOID, &[I64, DOUBLE, DOUBLE, DOUBLE, I64], ); module.declare_function("js_ws_wait_for_message", I64, &[I64, DOUBLE]); diff --git a/crates/perry-ext-http/src/server/dispatch_ext.rs b/crates/perry-ext-http/src/server/dispatch_ext.rs index a13ea8f317..5a314a299d 100644 --- a/crates/perry-ext-http/src/server/dispatch_ext.rs +++ b/crates/perry-ext-http/src/server/dispatch_ext.rs @@ -68,6 +68,7 @@ extern "C" { pub(crate) fn ensure_dispatch_extensions_registered() { static REGISTER: Once = Once::new(); REGISTER.call_once(|| unsafe { + perry_ext_ws::register_http_address_reader(crate::server::upgrade::attached_address); js_register_handle_method_dispatch_extension(http_server_method_dispatch_ext); js_register_handle_property_dispatch_extension(http_server_property_dispatch_ext); js_register_handle_property_set_dispatch_extension(http_server_property_set_dispatch_ext); diff --git a/crates/perry-ext-http/src/server/server.rs b/crates/perry-ext-http/src/server/server.rs index 4dd197b078..ff11492046 100644 --- a/crates/perry-ext-http/src/server/server.rs +++ b/crates/perry-ext-http/src/server/server.rs @@ -1220,7 +1220,9 @@ async fn handle_request( let has_upgrade_listeners = get_handle::(server_handle) .map(|server| server_has_event_listener(server, "upgrade")) .unwrap_or(false); - if has_upgrade_listeners && req.headers().contains_key("sec-websocket-key") { + if (has_upgrade_listeners || perry_ext_ws::has_attached_server(server_handle)) + && req.headers().contains_key("sec-websocket-key") + { return handle_websocket_upgrade( server_handle, peer, @@ -1582,6 +1584,11 @@ pub extern "C" fn js_node_http_server_process_pending() -> i32 { up.head, ); } else { + perry_ext_ws::accept_attached_connection( + up.server_handle, + handle_to_pointer_f64(up.request_handle), + up.ws_id, + ); crate::server::upgrade::fire_upgrade_listeners( up.server_handle, up.request_handle, diff --git a/crates/perry-ext-http/src/server/server/deferred_events.rs b/crates/perry-ext-http/src/server/server/deferred_events.rs index 0d1cba9b24..86084ac72d 100644 --- a/crates/perry-ext-http/src/server/server/deferred_events.rs +++ b/crates/perry-ext-http/src/server/server/deferred_events.rs @@ -95,6 +95,7 @@ where // #8082: the drained snapshot crosses each callback — root it. let scope = perry_ffi::TransientRootScope::enter(); let rooted = scope.root_addrs(&cbs); + perry_ext_ws::attached_server_listening(server_handle); let mut call = DeferredCallbacksCall { callbacks: rooted.as_ptr(), len: rooted.len(), diff --git a/crates/perry-ext-http/src/server/upgrade.rs b/crates/perry-ext-http/src/server/upgrade.rs index 2b0a9695cd..9ba0a8db54 100644 --- a/crates/perry-ext-http/src/server/upgrade.rs +++ b/crates/perry-ext-http/src/server/upgrade.rs @@ -22,18 +22,7 @@ //! so user code can interact with it through `ws.on('message',…)`, //! `ws.send(…)`, `ws.close(…)` unchanged. //! -//! The TS-side wrapper for `import { WebSocketServer } from 'ws'` -//! when constructed with `{ server }` simply registers an -//! `'upgrade'` listener that re-dispatches to its own `'connection'` -//! event: -//! -//! ```ts -//! const wss = new WebSocketServer({ server: httpServer }); -//! // wss internally: -//! // server.on('upgrade', (req, wsId, head) => { -//! // wss.emit('connection', wsId, req); -//! // }); -//! ``` +//! Attached WebSocket servers are native observers registered by perry-ext-ws. use perry_ffi::{alloc_string, get_handle_mut, JsClosure, RawClosureHeader}; @@ -122,3 +111,19 @@ pub(crate) fn fire_upgrade_listeners( fn _force_link() -> u64 { POINTER_TAG | (PTR_MASK & 0) } + +/// Read owned address metadata without allocating JS objects or introducing a +/// reverse dependency from ws to HTTP. +pub(crate) fn attached_address(handle: i64) -> Option<(String, u16)> { + perry_ffi::get_handle::(handle) + .and_then(|s| s.listening.then(|| (s.bound_host.clone(), s.bound_port))) + .or_else(|| { + perry_ffi::get_handle::(handle).and_then( + |s| { + s.base + .listening + .then(|| (s.base.bound_host.clone(), s.base.bound_port)) + }, + ) + }) +} diff --git a/crates/perry-ext-http/src/test_async_shims.rs b/crates/perry-ext-http/src/test_async_shims.rs index 6f642e5f85..d6bd8c9f12 100644 --- a/crates/perry-ext-http/src/test_async_shims.rs +++ b/crates/perry-ext-http/src/test_async_shims.rs @@ -64,3 +64,9 @@ pub extern "C" fn perry_ffi_spawn_blocking_with_reactor( // host stdlib archive, which unit-test binaries do not link. #[no_mangle] pub extern "C" fn perry_ffi_spawn_async(_ctx: *mut c_void) {} + +// Linking the ws dispatch extension also retains its synchronous polling +// helper. These unit tests use the no-op task shim above; real networking is +// exercised by the compiled HTTP/WebSocket integration tests. +#[no_mangle] +pub extern "C" fn perry_ffi_run_pending(_budget_ms: u64) {} diff --git a/crates/perry-ext-ws/src/dispatch.rs b/crates/perry-ext-ws/src/dispatch.rs new file mode 100644 index 0000000000..b3090c9518 --- /dev/null +++ b/crates/perry-ext-ws/src/dispatch.rs @@ -0,0 +1,126 @@ +//! Runtime dispatch for WebSocket receivers whose static type was erased. +use super::*; + +extern "C" { + fn js_register_handle_method_dispatch_extension( + f: unsafe extern "C" fn(i64, *const u8, usize, *const f64, usize, *mut f64) -> i32, + ); + fn js_class_method_bind(receiver: f64, name: *const u8, len: usize) -> f64; +} + +pub(super) unsafe fn register_method_dispatch() { + js_register_handle_method_dispatch_extension(method); +} + +fn knows(handle: i64, name: &str) -> bool { + if get_handle_mut::(handle).is_some() { + matches!( + name, + "clients" | "address" | "handleUpgrade" | "emit" | "on" | "addListener" | "close" + ) + } else if get_handle_mut::(handle).is_some() { + matches!(name, "send" | "close" | "on" | "addListener" | "readyState") + } else { + false + } +} + +pub(super) unsafe fn property(handle: i64, ptr: *const u8, len: usize, out: *mut f64) -> i32 { + if ptr.is_null() { + return 0; + } + let Ok(name) = std::str::from_utf8(std::slice::from_raw_parts(ptr, len)) else { + return 0; + }; + if !knows(handle, name) { + return 0; + } + let value = match name { + "clients" => js_ws_server_clients(handle), + "readyState" => js_ws_ready_state(handle), + _ => js_class_method_bind(f64::from_bits(POINTER_TAG | handle as u64), ptr, len), + }; + if !out.is_null() { + *out = value; + } + 1 +} + +unsafe extern "C" fn method( + handle: i64, + ptr: *const u8, + len: usize, + args: *const f64, + argc: usize, + out: *mut f64, +) -> i32 { + if ptr.is_null() { + return 0; + } + let Ok(name) = std::str::from_utf8(std::slice::from_raw_parts(ptr, len)) else { + return 0; + }; + if !knows(handle, name) || matches!(name, "clients" | "readyState") { + return 0; + } + let args = if args.is_null() { + &[][..] + } else { + std::slice::from_raw_parts(args, argc) + }; + let scope = perry_ffi::TransientRootScope::enter(); + let args: Vec<_> = args.iter().map(|value| scope.root_nanbox(*value)).collect(); + let arg = |i| { + args.get(i) + .map(|value: &perry_ffi::TransientRootedNanbox| value.get()) + .unwrap_or_else(undefined) + }; + let value = match name { + "address" => js_ws_server_address(handle), + "handleUpgrade" => { + js_ws_handle_upgrade( + handle, + arg(0), + arg(1), + arg(2), + (arg(3).to_bits() & POINTER_MASK) as i64, + ); + undefined() + } + "emit" => { + let event = string_arg(arg(0)); + f64::from_bits( + JsValue::from_bool(js_ws_server_emit(handle, event, arg(1), arg(2)) != 0).bits(), + ) + } + "on" | "addListener" => { + let event = string_arg(arg(0)); + js_ws_on(handle, event, (arg(1).to_bits() & POINTER_MASK) as i64); + f64::from_bits(POINTER_TAG | handle as u64) + } + "send" => { + js_ws_send(handle, string_arg(arg(0))); + undefined() + } + "close" => { + js_ws_close(handle); + undefined() + } + _ => return 0, + }; + if !out.is_null() { + *out = value; + } + 1 +} + +fn string_arg(value: f64) -> *const StringHeader { + let value = JsValue::from_bits(value.to_bits()); + if value.is_short_string() { + value_string(value) + .map(|s| alloc_string(&s).as_raw() as *const StringHeader) + .unwrap_or(std::ptr::null()) + } else { + value.as_string_ptr() + } +} diff --git a/crates/perry-ext-ws/src/lib.rs b/crates/perry-ext-ws/src/lib.rs index 5d381615dc..83c64d8a5c 100644 --- a/crates/perry-ext-ws/src/lib.rs +++ b/crates/perry-ext-ws/src/lib.rs @@ -25,6 +25,7 @@ //! enough for typical WebSocket usage. Cooperative `spawn_async` is //! a v0.6.0 followup. +mod dispatch; /// SIMD-widened WebSocket frame (un)masking (RFC 6455 §5.3). See /// [`mask::apply_mask`] / [`mask::apply_mask_from`]. The hot tungstenite /// read/write path masks internally with its own `u32`-blocked routine @@ -32,6 +33,8 @@ /// frame bytes perry handles itself — kept byte-identical to the scalar /// reference and validated by a property test. pub mod mask; +mod server; +pub use server::*; #[cfg(test)] mod test_async_shims; @@ -42,8 +45,7 @@ use perry_ffi::{ alloc_set, alloc_string, gc_register_mutable_root_scanner_named, get_handle_mut, iter_handles_of_mut, notify_main_thread, register_aux_event_pump, register_handle, set_add, set_delete, spawn_async, spawn_blocking_with_reactor as spawn_blocking, take_handle, - GcRootVisitor, Handle, JsClosure, JsString, JsValue, ObjectHeader, RawClosureHeader, - StringHeader, + GcRootVisitor, Handle, JsClosure, JsString, JsValue, RawClosureHeader, StringHeader, }; use std::collections::HashMap; use std::sync::atomic::{AtomicI32, Ordering}; @@ -75,6 +77,8 @@ unsafe fn read_str(ptr: *const StringHeader) -> Option { // ── Global state ────────────────────────────────────────────────── +struct WsClientHandle; + struct WsConnection { sender: mpsc::UnboundedSender, messages: Vec, @@ -101,6 +105,9 @@ pub struct WsServerHandle { /// Event name → list of closure pointers. pub listeners: HashMap>, pub port: u16, + pub host: String, + pub attached_server: Option, + pub no_server: bool, pub is_listening: bool, pub client_ids: Vec, /// The persistent JavaScript `Set` exposed as `WebSocketServer.clients`. @@ -127,7 +134,6 @@ enum PendingWsEvent { lazy_static! { static ref WS_CONNECTIONS: Mutex> = Mutex::new(HashMap::new()); static ref WS_CLIENT_PARENT_SERVER: Mutex> = Mutex::new(HashMap::new()); - static ref NEXT_WS_ID: Mutex = Mutex::new(1); static ref WS_CLIENT_LISTENERS: Mutex> = Mutex::new(HashMap::new()); static ref WS_PENDING_EVENTS: Mutex> = Mutex::new(Vec::new()); @@ -150,7 +156,8 @@ fn ensure_runtime_hooks_registered() { gc_register_mutable_root_scanner_named("perry-ext-ws", scan_ws_roots); register_aux_event_pump(js_ws_process_pending, js_ws_has_pending); unsafe { - js_register_handle_property_dispatch_extension(js_ext_ws_handle_property_dispatch) + js_register_handle_property_dispatch_extension(js_ext_ws_handle_property_dispatch); + dispatch::register_method_dispatch(); }; }); } @@ -168,9 +175,8 @@ fn ensure_runtime_hooks_registered() { /// heartbeat — uncatchable by application code, so the process exited every /// 30 seconds. /// -/// Returns 0 (not handled) for every other property and for any handle that is -/// not a live `WsServerHandle`, so the composite dispatcher falls through to -/// the primary stdlib dispatcher unchanged. +/// Also exposes native server/client method values. Unknown members and +/// unrelated handle types fall through to the primary dispatcher. /// /// # Safety /// FFI entry; `property_name_ptr` must be valid for `property_name_len` bytes, @@ -182,20 +188,7 @@ pub unsafe extern "C" fn js_ext_ws_handle_property_dispatch( property_name_len: usize, out: *mut f64, ) -> i32 { - if property_name_ptr.is_null() || property_name_len != b"clients".len() { - return 0; - } - if std::slice::from_raw_parts(property_name_ptr, property_name_len) != b"clients" { - return 0; - } - let clients = js_ws_server_clients(handle); - if clients.to_bits() == JsValue::UNDEFINED.bits() { - return 0; - } - if !out.is_null() { - *out = clients; - } - 1 + dispatch::property(handle, property_name_ptr, property_name_len, out) } fn scan_ws_roots(visitor: &mut GcRootVisitor<'_>) { @@ -225,9 +218,7 @@ fn push_ws_event(ev: PendingWsEvent) { #[inline] fn client_js_value(ws_id: usize) -> JsValue { - // Server-side clients are represented throughout this wrapper as ordinary - // numeric handles (the same value delivered to `connection` listeners). - JsValue::from_number(ws_id as f64) + JsValue::from_bits(POINTER_TAG | ws_id as u64) } /// Add a connection to a server's persistent JS-visible clients Set. @@ -325,10 +316,7 @@ pub extern "C" fn js_ws_connect_start(url_nanboxed: f64) -> f64 { // Allocate the id synchronously so the caller can register // listeners before the connect resolves. - let mut id_guard = NEXT_WS_ID.lock().unwrap(); - let ws_id = *id_guard; - *id_guard += 1; - drop(id_guard); + let ws_id = register_handle(WsClientHandle) as usize; let (tx, rx) = mpsc::unbounded_channel::(); WS_CONNECTIONS.lock().unwrap().insert( ws_id, @@ -381,10 +369,7 @@ fn setup_client_io( tokio_tungstenite::MaybeTlsStream, >, ) -> usize { - let mut id_guard = NEXT_WS_ID.lock().unwrap(); - let ws_id = *id_guard; - *id_guard += 1; - drop(id_guard); + let ws_id = register_handle(WsClientHandle) as usize; let (tx, rx) = mpsc::unbounded_channel::(); WS_CONNECTIONS.lock().unwrap().insert( ws_id, @@ -500,6 +485,10 @@ pub unsafe extern "C" fn js_ws_send(handle: i64, message_ptr: *const StringHeade #[no_mangle] pub extern "C" fn js_ws_close(handle: i64) { + if get_handle_mut::(handle).is_some() { + js_ws_server_close(handle); + return; + } let id = handle as usize; if let Some(c) = WS_CONNECTIONS.lock().unwrap().get_mut(&id) { let _ = c.sender.send(WsCommand::Close); @@ -598,7 +587,7 @@ pub unsafe extern "C" fn js_ws_on_client_i64( /// `message_ptr` must be null or a Perry-runtime `StringHeader`. #[no_mangle] pub unsafe extern "C" fn js_ws_send_to_client(handle_f64: f64, message_ptr: *const StringHeader) { - let id = handle_f64 as i64 as usize; + let id = decode_client_id(handle_f64); let Some(msg) = read_str(message_ptr) else { return; }; @@ -609,7 +598,7 @@ pub unsafe extern "C" fn js_ws_send_to_client(handle_f64: f64, message_ptr: *con #[no_mangle] pub extern "C" fn js_ws_close_client(handle_f64: f64) { - let id = handle_f64 as i64 as usize; + let id = decode_client_id(handle_f64); if let Some(c) = WS_CONNECTIONS.lock().unwrap().get_mut(&id) { let _ = c.sender.send(WsCommand::Close); c.is_open = false; @@ -728,14 +717,7 @@ pub unsafe extern "C" fn js_ws_on( if callback_ptr == 0 { return handle; } - // Issue #606: client ws_ids (NEXT_WS_ID counter) and server handle - // ids (perry-ffi NEXT_HANDLE counter) live in disjoint registries - // but their numeric ranges collide — both start near 1. If we look - // up the server registry first, a client id that happens to also - // be a registered server handle id would route through the server - // arm and the user's `client.on("open", cb)` would land on the - // server's listeners. Check the client registry first so client - // dispatch is correct regardless of allocation order. + // Client and server ids share the handle allocator, so routing is unambiguous. let ws_id = handle as usize; let is_client = WS_CONNECTIONS.lock().unwrap().contains_key(&ws_id); if !is_client { @@ -789,210 +771,6 @@ pub unsafe extern "C" fn js_ws_on( // ── Server ──────────────────────────────────────────────────────── -/// `new WebSocketServer({ port })` — sync ctor; spawns the accept loop. -/// -/// #1113: `new WebSocketServer({ noServer: true })` must NOT bind a -/// TCP port or spawn the accept loop — it's a passive registry whose -/// connections arrive exclusively via `wss.handleUpgrade(...)` driven -/// by a host server's `'upgrade'` event (fastify's `app.server` or -/// `node:http`). For that shape we register a listener-only handle and -/// return early; `WS_ACTIVE_SERVERS` is left untouched so a noServer -/// wss doesn't keep the event loop alive on its own (the host server's -/// has-active gate — `js_fastify_has_active` — does that). -#[no_mangle] -pub extern "C" fn js_ws_server_new(opts_f64: f64) -> Handle { - ensure_runtime_hooks_registered(); - let port = extract_port(opts_f64); - let no_server = extract_no_server(opts_f64); - let clients_bits = alloc_set(4).bits(); - - if no_server || port == 0 { - // Listener-only handle — no bind, no accept loop, no shutdown - // channel (nothing to shut down). Connections are injected via - // `js_ws_handle_upgrade`. - return register_handle(WsServerHandle { - listeners: HashMap::new(), - port: 0, - is_listening: false, - client_ids: Vec::new(), - clients_bits, - shutdown_tx: None, - }); - } - - let (shutdown_tx, mut shutdown_rx) = mpsc::unbounded_channel::<()>(); - let server_handle = register_handle(WsServerHandle { - listeners: HashMap::new(), - port, - is_listening: false, - client_ids: Vec::new(), - clients_bits, - shutdown_tx: Some(shutdown_tx), - }); - WS_ACTIVE_SERVERS.fetch_add(1, Ordering::Relaxed); - let handle_id = server_handle; - // Issue #606 — `spawn_blocking_with_reactor` already runs the closure - // inside a tokio worker task, so `Handle::current().block_on(fut)` panics - // with "Cannot start a runtime from within a runtime". Schedule the - // accept loop as a sibling task on the existing runtime instead. - // (Same root cause as the v0.5.691 sweep that fixed perry-ext-http's - // server.rs / https_server.rs / http2_server.rs and perry-ext-ws's - // `drive_server_client_io` — this site was missed in that sweep.) - spawn_blocking(move || { - tokio::spawn(async move { - let addr = format!("0.0.0.0:{}", port); - let listener = match tokio::net::TcpListener::bind(&addr).await { - Ok(l) => l, - Err(e) => { - push_ws_event(PendingWsEvent::ServerError( - handle_id, - format!("WebSocketServer bind error: {}", e), - )); - return; - } - }; - if let Some(s) = get_handle_mut::(handle_id) { - s.is_listening = true; - } - push_ws_event(PendingWsEvent::Listening(handle_id)); - loop { - tokio::select! { - accept_result = listener.accept() => { - match accept_result { - Ok((tcp_stream, _addr)) => { - match tokio_tungstenite::accept_async(tcp_stream).await { - Ok(ws_stream) => { - let mut id_guard = NEXT_WS_ID.lock().unwrap(); - let ws_id = *id_guard; - *id_guard += 1; - drop(id_guard); - let (tx, rx) = mpsc::unbounded_channel::(); - WS_CONNECTIONS.lock().unwrap().insert(ws_id, WsConnection { - sender: tx, - messages: Vec::new(), - is_open: true, - is_closing: false, - is_closed: false, - }); - WS_CLIENT_LISTENERS.lock().unwrap().insert(ws_id, WsClientListeners { - listeners: HashMap::new(), - }); - if let Some(s) = get_handle_mut::(handle_id) { - s.client_ids.push(ws_id); - } - WS_CLIENT_PARENT_SERVER.lock().unwrap().insert(ws_id, handle_id); - push_ws_event(PendingWsEvent::Connection(handle_id, ws_id)); - drive_server_client_io(ws_id, ws_stream, rx); - } - Err(e) => { - push_ws_event(PendingWsEvent::ServerError( - handle_id, - format!("WebSocket handshake error: {}", e), - )); - } - } - } - Err(e) => { - push_ws_event(PendingWsEvent::ServerError( - handle_id, - format!("accept error: {}", e), - )); - } - } - } - _ = shutdown_rx.recv() => { - break; - } - } - } - if let Some(s) = get_handle_mut::(handle_id) { - s.is_listening = false; - } - WS_ACTIVE_SERVERS.fetch_sub(1, Ordering::Relaxed); - }); - }); - server_handle -} - -/// Return the persistent `Set` exposed as `WebSocketServer.clients`. -/// -/// The Set is allocated with the server, updated before connection/close -/// callbacks run, and rooted through the server handle for its full lifetime. -#[no_mangle] -pub extern "C" fn js_ws_server_clients(handle: i64) -> f64 { - get_handle_mut::(handle) - .map(|server| f64::from_bits(server.clients_bits)) - .unwrap_or_else(|| f64::from_bits(JsValue::UNDEFINED.bits())) -} - -fn extract_port(opts_f64: f64) -> u16 { - let bits = opts_f64.to_bits(); - if (bits & TAG_MASK) == POINTER_TAG { - let ptr = (bits & POINTER_MASK) as *const ObjectHeader; - if !ptr.is_null() { - // Object literal: assume `port` is the first field - // (positional shape — same convention as nodemailer/pg/mysql2). - let val = unsafe { perry_ffi::js_object_get_field(ptr, 0) }; - if val.is_number() { - let n = val.to_number(); - if n.is_finite() && n > 0.0 { - return n as u16; - } - } - } - return 0; - } - if opts_f64.is_finite() && opts_f64 > 0.0 { - opts_f64 as u16 - } else { - 0 - } -} - -/// #1113 — detect `new WebSocketServer({ noServer: true })`. -/// -/// perry-ffi exposes only positional object-field reads -/// (`js_object_get_field(ptr, idx)`), not name-based lookup, so we -/// can't read the `noServer` key by name. Heuristic: an options -/// object that carries a `true` boolean field AND no positive numeric -/// port field is a `noServer` config. (A real `{ port: N }` config -/// has a positive number in field 0 — `extract_port` handles that; -/// a `{ noServer: true }` config has no port and a `true` boolean.) -/// `js_ws_server_new` additionally treats "object with no positive -/// port" as noServer, so this is a belt-and-suspenders signal that -/// also catches `{ noServer: true, ...other }` shapes regardless of -/// field order. -fn extract_no_server(opts_f64: f64) -> bool { - let bits = opts_f64.to_bits(); - if (bits & TAG_MASK) != POINTER_TAG { - return false; - } - let ptr = (bits & POINTER_MASK) as *const ObjectHeader; - if ptr.is_null() { - return false; - } - unsafe { - // #8113: the header's `field_count` word is gone; the live inline-slot - // bound comes from the runtime accessor. - let n = perry_ffi::js_object_live_slot_count(ptr); - let mut saw_true = false; - let mut saw_positive_port = false; - for i in 0..n { - let v = perry_ffi::js_object_get_field(ptr, i); - if v.is_bool() && v.to_bool() { - saw_true = true; - } - if v.is_number() { - let num = v.to_number(); - if num.is_finite() && num > 0.0 { - saw_positive_port = true; - } - } - } - saw_true && !saw_positive_port - } -} - fn drive_server_client_io( ws_id: usize, ws_stream: tokio_tungstenite::WebSocketStream, @@ -1137,10 +915,7 @@ where S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static, { ensure_runtime_hooks_registered(); - let mut id_guard = NEXT_WS_ID.lock().unwrap(); - let ws_id = *id_guard; - *id_guard += 1; - drop(id_guard); + let ws_id = register_handle(WsClientHandle) as usize; let (tx, rx) = mpsc::unbounded_channel::(); WS_CONNECTIONS.lock().unwrap().insert( ws_id, @@ -1176,22 +951,10 @@ where /// re-dispatch shim — it does NOT register another stream or perform /// another handshake. /// -/// Steps (mirror `WebSocketServer({port})`'s per-connection wiring): -/// 1. Decode `ws_id` from the POINTER_TAG-boxed `ws_id_f64`. -/// 2. Adopt the connection under this server (`WS_CLIENT_PARENT_SERVER` -/// + `client_ids`) so server-level `wss.on('message'|'close', …)` -/// handlers route, and the GC scanner pins the right listeners. -/// 3. Invoke the user's `cb(socket)` with `socket === ws_id_f64` -/// (the same NaN-boxed id `wss.on('connection', (ws) => …)` gets, -/// so `ws.send(...)` / `ws.on(...)` dispatch through the Client -/// class arm). -/// 4. Also push `PendingWsEvent::Connection` so a separately -/// registered `wss.on('connection', cb)` fires through the pump. -/// -/// `req_f64` / `head_f64` are accepted for API shape parity (Node's -/// `handleUpgrade(request, socket, head, callback)`); they're not -/// consumed here — the request metadata was already surfaced to the -/// `'upgrade'` handler. +/// Adopt the client into the server's tracked Set before invoking +/// `cb(socket, request)`. The callback decides whether to emit `connection`; +/// `handleUpgrade` itself never emits that event and returns `undefined`. +/// The HTTP transport has already consumed the head bytes. /// /// # Safety /// `cb`, when non-zero, must be a valid NaN-boxed / raw closure @@ -1200,48 +963,32 @@ where #[no_mangle] pub unsafe extern "C" fn js_ws_handle_upgrade( server_handle: i64, - _req_f64: f64, + req_f64: f64, ws_id_f64: f64, _head_f64: f64, cb: i64, -) -> i64 { +) { ensure_runtime_hooks_registered(); - // `ws_id_f64` is POINTER_TAG-boxed (the host upgrade path encodes - // it as `POINTER_TAG | (ws_id & POINTER_MASK)` so codegen's - // unbox_to_i64 round-trips it). Extract the low-48 bits. - let ws_id = (ws_id_f64.to_bits() & POINTER_MASK) as usize; - if ws_id == 0 { - return server_handle; + let scope = perry_ffi::TransientRootScope::enter(); + let cb = scope.root_addr((cb as u64 & POINTER_MASK) as i64); + let req = scope.root_nanbox(req_f64); + let ws_id = decode_client_id(ws_id_f64); + if get_handle_mut::(server_handle).is_none() + || !WS_CONNECTIONS.lock().unwrap().contains_key(&ws_id) + { + return; } - WS_CLIENT_PARENT_SERVER .lock() .unwrap() .insert(ws_id, server_handle); - // `ws` adds the socket to `clients` before invoking handleUpgrade's - // callback. Keep that ordering so the callback observes itself in the Set. track_server_client(server_handle, ws_id); - - if cb != 0 { - // Accept either a NaN-boxed POINTER_TAG closure or a raw - // pointer (same dual-shape the rest of the crate handles). - let raw = if (cb as u64 & TAG_MASK) == POINTER_TAG { - (cb as u64 & POINTER_MASK) as *const RawClosureHeader - } else { - cb as *const RawClosureHeader - }; - let closure = JsClosure::from_raw(raw); - if !closure.is_null() { - let _ = closure.call1(ws_id_f64); - } + // ws delegates connection emission to the callback. Emitting again here + // duplicates the usual `wss.emit("connection", ws, req)` idiom. + if cb.get() != 0 { + let closure = JsClosure::from_raw(cb.get() as *const RawClosureHeader); + let _ = closure.call2(f64::from_bits(client_js_value(ws_id).bits()), req.get()); } - - // Also fire a Connection event so a `wss.on('connection', cb)` - // registered separately from `handleUpgrade`'s inline callback - // still runs through the normal pump. - push_ws_event(PendingWsEvent::Connection(server_handle, ws_id)); - notify_main_thread(); - server_handle } // ── Event-loop tick ─────────────────────────────────────────────── @@ -1268,9 +1015,11 @@ pub extern "C" fn js_ws_process_pending() -> i32 { for cb in listeners { if cb != 0 { let closure = unsafe { JsClosure::from_raw(cb as *const RawClosureHeader) }; - // Pass client_id as f64 so user handler can - // pass it back to js_ws_send_to_client etc. - let _ = unsafe { closure.call1(client_id as f64) }; + // Use the same handle value as the clients Set and + // manual-upgrade callback, including dynamic dispatch. + let _ = unsafe { + closure.call1(f64::from_bits(client_js_value(client_id).bits())) + }; fired += 1; } } @@ -1297,7 +1046,12 @@ pub extern "C" fn js_ws_process_pending() -> i32 { if cb != 0 { let closure = unsafe { JsClosure::from_raw(cb as *const RawClosureHeader) }; - let _ = unsafe { closure.call2(ws_id as f64, msg_f64) }; + let _ = unsafe { + closure.call2( + f64::from_bits(client_js_value(ws_id).bits()), + msg_f64, + ) + }; fired += 1; } } @@ -1327,7 +1081,9 @@ pub extern "C" fn js_ws_process_pending() -> i32 { if cb != 0 { let closure = unsafe { JsClosure::from_raw(cb as *const RawClosureHeader) }; - let _ = unsafe { closure.call1(ws_id as f64) }; + let _ = unsafe { + closure.call1(f64::from_bits(client_js_value(ws_id).bits())) + }; fired += 1; } } @@ -1494,6 +1250,9 @@ mod tests { let server_handle = register_handle(WsServerHandle { listeners: HashMap::from([("connection".to_string(), vec![server_callback])]), port: 0, + host: "0.0.0.0".into(), + attached_server: None, + no_server: true, is_listening: false, client_ids: Vec::new(), clients_bits: clients_before, @@ -1596,12 +1355,18 @@ mod tests { assert!(!clients.is_null()); assert_eq!(perry_runtime::set::js_set_size(clients), 0); - let client_id = 9_325_001; + let client_id = register_handle(WsClientHandle) as usize; track_server_client(server_handle, client_id); let clients = JsValue::from_bits(js_ws_server_clients(server_handle).to_bits()) .as_pointer::(); assert_eq!(perry_runtime::set::js_set_size(clients), 1); - assert_eq!(perry_runtime::set::js_set_has(clients, client_id as f64), 1); + assert_eq!( + perry_runtime::set::js_set_has( + clients, + f64::from_bits(client_js_value(client_id).bits()) + ), + 1 + ); WS_CLIENT_PARENT_SERVER .lock() @@ -1612,6 +1377,7 @@ mod tests { .as_pointer::(); assert_eq!(perry_runtime::set::js_set_size(clients), 0); + drop_handle(client_id as i64); drop_handle(server_handle); } @@ -1639,10 +1405,19 @@ mod tests { } #[test] - fn extract_port_from_number_arg() { - assert_eq!(extract_port(8080.0), 8080); - assert_eq!(extract_port(0.0), 0); - assert_eq!(extract_port(-5.0), 0); + fn client_handles_do_not_alias_registered_servers() { + let server = js_ws_server_new(f64::from_bits(JsValue::UNDEFINED.bits())); + let client = register_handle(WsClientHandle); + assert_ne!(server, client); + assert!(get_handle_mut::(client).is_none()); + assert!(get_handle_mut::(server).is_none()); + assert_eq!( + decode_client_id(f64::from_bits(client_js_value(client as usize).bits())), + client as usize + ); + assert_eq!(decode_client_id(client as f64), client as usize); + perry_ffi::drop_handle(client); + perry_ffi::drop_handle(server); } /// #6117 — `readyState` walks the npm-ws lifecycle: CONNECTING (0) diff --git a/crates/perry-ext-ws/src/server.rs b/crates/perry-ext-ws/src/server.rs new file mode 100644 index 0000000000..1aeafbfff8 --- /dev/null +++ b/crates/perry-ext-ws/src/server.rs @@ -0,0 +1,338 @@ +//! WebSocket server construction and HTTP-server attachment. +use super::*; + +extern "C" { + fn js_object_get_field_by_name( + object: *const perry_ffi::ObjectHeader, + key: *const StringHeader, + ) -> JsValue; +} + +pub(super) fn value_string(value: JsValue) -> Option { + if value.is_short_string() { + let mut bytes = [0; 5]; + let len = value.short_string_to_buf(&mut bytes)?; + Some(String::from_utf8_lossy(&bytes[..len]).into_owned()) + } else { + unsafe { read_str(value.as_string_ptr()) } + } +} + +/// `new WebSocketServer({ port })` — sync ctor; spawns the accept loop. +/// +/// #1113: `new WebSocketServer({ noServer: true })` must NOT bind a +/// TCP port or spawn the accept loop — it's a passive registry whose +/// connections arrive exclusively via `wss.handleUpgrade(...)` driven +/// by a host server's `'upgrade'` event (fastify's `app.server` or +/// `node:http`). For that shape we register a listener-only handle and +/// return early; `WS_ACTIVE_SERVERS` is left untouched so a noServer +/// wss doesn't keep the event loop alive on its own (the host server's +/// has-active gate — `js_fastify_has_active` — does that). +#[no_mangle] +pub extern "C" fn js_ws_server_new(opts_f64: f64) -> Handle { + ensure_runtime_hooks_registered(); + let scope = perry_ffi::TransientRootScope::enter(); + let opts = scope.root_nanbox(opts_f64); + // Allocate each property key before reloading the rooted options receiver. + let field = |key| { + let key = alloc_string(key); + let value = JsValue::from_bits(opts.get().to_bits()); + if !value.is_pointer() { + return JsValue::UNDEFINED; + } + unsafe { js_object_get_field_by_name(value.as_pointer(), key.as_raw()) } + }; + let port_value = field("port"); + let port = if port_value.is_number() { + Some(port_value.to_number() as u16) + } else { + None + }; + let no_server = field("noServer").to_bool(); + let attached = field("server"); + let attached_server = if attached.is_pointer() { + Some((attached.bits() & POINTER_MASK) as i64) + } else { + None + }; + let host_value = field("host"); + let host = value_string(host_value).unwrap_or_else(|| "0.0.0.0".into()); + let clients_bits = alloc_set(4).bits(); + + if no_server || attached_server.is_some() || port.is_none() { + return register_handle(WsServerHandle { + listeners: HashMap::new(), + port: 0, + host, + attached_server, + no_server, + is_listening: false, + client_ids: Vec::new(), + clients_bits, + shutdown_tx: None, + }); + } + let port = port.unwrap(); + + let (shutdown_tx, mut shutdown_rx) = mpsc::unbounded_channel::<()>(); + let server_handle = register_handle(WsServerHandle { + listeners: HashMap::new(), + port, + host: host.clone(), + attached_server: None, + no_server: false, + is_listening: false, + client_ids: Vec::new(), + clients_bits, + shutdown_tx: Some(shutdown_tx), + }); + WS_ACTIVE_SERVERS.fetch_add(1, Ordering::Relaxed); + let handle_id = server_handle; + // Issue #606 — `spawn_blocking_with_reactor` already runs the closure + // inside a tokio worker task, so `Handle::current().block_on(fut)` panics + // with "Cannot start a runtime from within a runtime". Schedule the + // accept loop as a sibling task on the existing runtime instead. + // (Same root cause as the v0.5.691 sweep that fixed perry-ext-http's + // server.rs / https_server.rs / http2_server.rs and perry-ext-ws's + // `drive_server_client_io` — this site was missed in that sweep.) + spawn_blocking(move || { + tokio::spawn(async move { + let addr = (host.as_str(), port); + let listener = match tokio::net::TcpListener::bind(addr).await { + Ok(l) => l, + Err(e) => { + push_ws_event(PendingWsEvent::ServerError( + handle_id, + format!("WebSocketServer bind error: {}", e), + )); + WS_ACTIVE_SERVERS.fetch_sub(1, Ordering::Relaxed); + return; + } + }; + if let Some(s) = get_handle_mut::(handle_id) { + s.is_listening = true; + if let Ok(address) = listener.local_addr() { + s.port = address.port(); + s.host = address.ip().to_string(); + } + } + push_ws_event(PendingWsEvent::Listening(handle_id)); + loop { + tokio::select! { + accept_result = listener.accept() => { + match accept_result { + Ok((tcp_stream, _addr)) => { + match tokio_tungstenite::accept_async(tcp_stream).await { + Ok(ws_stream) => { + let ws_id = register_handle(WsClientHandle) as usize; + let (tx, rx) = mpsc::unbounded_channel::(); + WS_CONNECTIONS.lock().unwrap().insert(ws_id, WsConnection { + sender: tx, + messages: Vec::new(), + is_open: true, + is_closing: false, + is_closed: false, + }); + WS_CLIENT_LISTENERS.lock().unwrap().insert(ws_id, WsClientListeners { + listeners: HashMap::new(), + }); + if let Some(s) = get_handle_mut::(handle_id) { + s.client_ids.push(ws_id); + } + WS_CLIENT_PARENT_SERVER.lock().unwrap().insert(ws_id, handle_id); + push_ws_event(PendingWsEvent::Connection(handle_id, ws_id)); + drive_server_client_io(ws_id, ws_stream, rx); + } + Err(e) => { + push_ws_event(PendingWsEvent::ServerError( + handle_id, + format!("WebSocket handshake error: {}", e), + )); + } + } + } + Err(e) => { + push_ws_event(PendingWsEvent::ServerError( + handle_id, + format!("accept error: {}", e), + )); + } + } + } + _ = shutdown_rx.recv() => { + break; + } + } + } + if let Some(s) = get_handle_mut::(handle_id) { + s.is_listening = false; + } + WS_ACTIVE_SERVERS.fetch_sub(1, Ordering::Relaxed); + }); + }); + server_handle +} + +/// Return the persistent `Set` exposed as `WebSocketServer.clients`. +/// +/// The Set is allocated with the server, updated before connection/close +/// callbacks run, and rooted through the server handle for its full lifetime. +#[no_mangle] +pub extern "C" fn js_ws_server_clients(handle: i64) -> f64 { + get_handle_mut::(handle) + .map(|server| f64::from_bits(server.clients_bits)) + .unwrap_or_else(|| f64::from_bits(JsValue::UNDEFINED.bits())) +} + +// The HTTP wrapper depends on ws for stream handoff. A host-supplied address +// reader keeps that dependency one-way and stores only a code pointer. +type HostAddress = fn(Handle) -> Option<(String, u16)>; +static HOST_ADDRESS: std::sync::OnceLock = std::sync::OnceLock::new(); + +pub fn register_http_address_reader(reader: HostAddress) { + let _ = HOST_ADDRESS.set(reader); +} + +fn attached_servers(host: Handle) -> Vec { + let mut result = Vec::new(); + perry_ffi::iter_handle_ids_of::(|id| result.push(id)); + result.retain(|id| { + get_handle_mut::(*id).is_some_and(|s| s.attached_server == Some(host)) + }); + result +} + +pub fn has_attached_server(host: Handle) -> bool { + !attached_servers(host).is_empty() +} + +/// Called on the JS thread after HTTP has adopted the upgraded stream. +pub fn accept_attached_connection(host: Handle, request: f64, client: i64) { + for server in attached_servers(host) { + WS_CLIENT_PARENT_SERVER + .lock() + .unwrap() + .insert(client as usize, server); + track_server_client(server, client as usize); + emit_server_event( + server, + "connection", + f64::from_bits(client_js_value(client as usize).bits()), + request, + 2, + ); + } +} + +pub fn attached_server_listening(host: Handle) { + for server in attached_servers(host) { + emit_server_event(server, "listening", undefined(), undefined(), 0); + } +} + +pub(super) fn undefined() -> f64 { + f64::from_bits(JsValue::UNDEFINED.bits()) +} + +pub(super) fn decode_client_id(value: f64) -> usize { + if value.to_bits() & TAG_MASK == POINTER_TAG { + (value.to_bits() & POINTER_MASK) as usize + } else { + value as usize + } +} + +/// Snapshot and root listeners and arguments before invoking user code. +fn emit_server_event(handle: Handle, event: &str, first: f64, second: f64, argc: usize) -> i32 { + let scope = perry_ffi::TransientRootScope::enter(); + let listeners = scope.root_addrs(&listeners_on_server(handle, event)); + let first = scope.root_nanbox(first); + let second = scope.root_nanbox(second); + let had_listeners = !listeners.is_empty(); + for cb in listeners { + if cb.get() == 0 { + continue; + } + unsafe { + let closure = JsClosure::from_raw(cb.get() as *const RawClosureHeader); + match argc { + 0 => { + closure.call0(); + } + 1 => { + closure.call1(first.get()); + } + _ => { + closure.call2(first.get(), second.get()); + } + } + } + } + i32::from(had_listeners) +} + +/// # Safety +/// `event` must point to a live runtime string. +#[no_mangle] +pub unsafe extern "C" fn js_ws_server_emit( + handle: i64, + event: *const StringHeader, + first: f64, + second: f64, +) -> i32 { + let Some(event) = read_str(event) else { + return 0; + }; + emit_server_event(handle, &event, first, second, 2) +} + +#[no_mangle] +pub extern "C" fn js_ws_server_address(handle: i64) -> f64 { + let Some((attached, no_server, listening, host, port)) = + get_handle_mut::(handle).map(|s| { + ( + s.attached_server, + s.no_server, + s.is_listening, + s.host.clone(), + s.port, + ) + }) + else { + return f64::from_bits(JsValue::NULL.bits()); + }; + if no_server { + perry_ffi::throw_with_code( + "The server is operating in \"noServer\" mode", + "ERR_WEBSOCKET_NO_SERVER", + perry_ffi::ErrorKind::Error, + ); + } + let address = if let Some(host) = attached { + HOST_ADDRESS.get().and_then(|reader| reader(host)) + } else if listening { + Some((host, port)) + } else { + None + }; + let Some((host, port)) = address else { + return f64::from_bits(JsValue::NULL.bits()); + }; + let scope = perry_ffi::TransientRootScope::enter(); + let family = if host.contains(':') { "IPv6" } else { "IPv4" }; + let address = scope.root_nanbox(f64::from_bits( + JsValue::from_string_ptr(alloc_string(&host).as_raw()).bits(), + )); + let family = scope.root_nanbox(f64::from_bits( + JsValue::from_string_ptr(alloc_string(family).as_raw()).bits(), + )); + let (keys, shape) = perry_ffi::build_object_shape(&["address", "family", "port"]); + unsafe { + let object = + perry_ffi::js_object_alloc_with_shape(shape, 3, keys.as_ptr(), keys.len() as u32); + perry_ffi::js_object_set_field(object, 0, JsValue::from_bits(address.get().to_bits())); + perry_ffi::js_object_set_field(object, 1, JsValue::from_bits(family.get().to_bits())); + perry_ffi::js_object_set_field(object, 2, JsValue::from_number(port as f64)); + f64::from_bits(JsValue::from_object_ptr(object).bits()) + } +} diff --git a/crates/perry-runtime/src/set.rs b/crates/perry-runtime/src/set.rs index 5bb25eade3..95629053e2 100644 --- a/crates/perry-runtime/src/set.rs +++ b/crates/perry-runtime/src/set.rs @@ -795,7 +795,9 @@ fn is_string_like(bits: u64) -> bool { // content-compared them by reinterpreting `ObjectHeader` as `StringHeader` // (class_id became byte_len, etc.) — colliding empty objects in `Set.add`. let ptr = extract_string_ptr_from_value(bits); - if ptr.is_null() || (ptr as usize) < 0x1000 { + // Native handles (including WebSocket clients) are identities, never + // string allocations, even when their ids have grown beyond one page. + if !crate::value::addr_class::is_above_handle_band(ptr as usize) { return false; } unsafe { @@ -2620,6 +2622,39 @@ mod tests { assert_eq!(js_set_has(set, 0.0), 0); } + #[test] + fn native_handles_are_set_identities_across_scan_and_hash_paths() { + use crate::value::{addr_class, POINTER_TAG}; + let handles = [ + 1, + 4095, + 4096, + 4097, + 8192, + 65535, + addr_class::COMMON_HANDLE_BAND_END - 1, + addr_class::FETCH_HANDLE_BAND_START, + addr_class::ZLIB_HANDLE_BAND_START, + addr_class::HANDLE_BAND_MAX - 1, + ]; + let set = js_set_alloc(4); + for &handle in &handles { + let value = f64::from_bits(POINTER_TAG | handle as u64); + assert_eq!(js_set_has(set, value), 0); + js_set_add(set, value); + assert_eq!(js_set_has(set, value), 1); + } + assert_eq!(js_set_size(set), handles.len() as u32); + for &handle in &handles { + let value = f64::from_bits(POINTER_TAG | handle as u64); + assert_eq!(js_set_has(set, value), 1); + assert_eq!(js_set_has(set, handle as f64), 0); + assert_eq!(js_set_delete(set, value), 1); + assert_eq!(js_set_has(set, value), 0); + } + assert_eq!(js_set_size(set), 0); + } + // #2872: helper to pass a Set pointer as the NaN-boxed `other` argument. // A raw heap pointer (top16 == 0) is returned unchanged by `clean_set_ptr`, // so reinterpreting the pointer bits as f64 round-trips through diff --git a/crates/perry/tests/issue_9325_ws_server_clients.rs b/crates/perry/tests/issue_9325_ws_server_clients.rs index a815c48c7a..ece5503213 100644 --- a/crates/perry/tests/issue_9325_ws_server_clients.rs +++ b/crates/perry/tests/issue_9325_ws_server_clients.rs @@ -27,7 +27,6 @@ fn compile_and_run(dir: &Path, source: &str) -> String { .arg("-o") .arg(&output) .arg("--no-cache") - .env_remove("PERRY_NO_AUTO_OPTIMIZE") .env("PERRY_WORKSPACE_ROOT", workspace_root()) .output() .expect("run perry compile"); @@ -69,6 +68,7 @@ console.log(typeof first[Symbol.iterator]); let count = 0; for (const _client of first) count += 1; console.log(first === second, count, first.size); +wss.close(); "#, ); diff --git a/crates/perry/tests/issue_9619_ws_server_upgrades.rs b/crates/perry/tests/issue_9619_ws_server_upgrades.rs new file mode 100644 index 0000000000..f99f9e6df0 --- /dev/null +++ b/crates/perry/tests/issue_9619_ws_server_upgrades.rs @@ -0,0 +1,180 @@ +//! Exercise real HTTP and WebSocket traffic through compiled native bindings. +use std::path::PathBuf; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +const SOURCE: &str = r#" +import { createServer } from "node:http"; +import { WebSocketServer, WebSocket } from "ws"; +const mode = "@MODE@"; +const total = @TOTAL@; +let connections = 0, callbacks = 0, messages = 0, opened = 0, errors = 0; +let urls = 0, members = 0, listening = 0; +const http = createServer((req, res) => res.end("http-ok")); +const wss = new WebSocketServer(@OPTIONS@); +const clients = wss.clients; +if (mode === "manual" || mode === "callback-only") { + let message = ""; + try { wss.address(); } catch (error) { message = error.message; } + if (message !== 'The server is operating in "noServer" mode') throw new Error("noServer address"); +} +const watchdog = setTimeout(() => { + console.log("timeout", connections, callbacks, messages, opened, errors, listening); + process.exit(1); +}, 10000); +function emitConnection(server: any, event: string, ws: any, req: any) { + return server[event]("connection", ws, req); +} +function sendHello(ws: any) { ws.send("hello"); } +wss.on("connection", (ws, req) => { + connections++; + if (mode === "ephemeral" || req.url === "/v1/ws?token=ok") urls++; + if (clients.has(ws)) members++; + sendHello(ws); +}); +if (mode === "manual" || mode === "callback-only") { + http.on("upgrade", (req, socket, head) => { + if (req.url.split("?")[0] !== "/v1/ws") return; + const result = wss.handleUpgrade(req, socket, head, (ws, request) => { + callbacks++; + if (request !== req) throw new Error("request identity"); + if (mode === "manual") { + if (!emitConnection(wss, "emit", ws, req)) throw new Error("emit return"); + } else { sendHello(ws); } + }); + if (result !== undefined) throw new Error("handleUpgrade return"); + }); +} +function connect(port: number) { + for (let i = 0; i < total; i++) { + const client = new WebSocket("ws://127.0.0.1:" + port + "/v1/ws?token=ok"); + client.on("open", () => { opened++; }); + client.on("error", () => { errors++; }); + client.on("message", (data) => { + if (data.toString() !== "hello") throw new Error("message payload"); + messages++; + client.close(); + if (messages === total) { + setTimeout(async () => { + console.log("counts", connections, callbacks, messages, opened, errors, urls, members, listening); + wss.close(); + if (mode === "attached") { + const response = await fetch("http://127.0.0.1:" + port + "/after-ws-close"); + console.log("detached-http", await response.text()); + } + if (mode !== "ephemeral") http.close(); + clearTimeout(watchdog); + }, 30); + } + }); + } +} +wss.on("listening", () => { + listening++; + const address = wss.address(); + console.log("address", address.address === "127.0.0.1", address.family === "IPv4", address.port > 0); + if (mode === "ephemeral") connect(address.port); +}); +if (mode !== "ephemeral") { + http.listen(0, "127.0.0.1", async () => { + const port = http.address().port; + console.log("http", await (await fetch("http://127.0.0.1:" + port + "/")).text()); + if (mode === "attached") console.log("shared-port", wss.address().port === port); + connect(port); + }); +} +"#; + +fn run(mode: &str, options: &str, total: usize) -> String { + let dir = tempfile::tempdir().expect("tempdir"); + let source = SOURCE + .replace("@MODE@", mode) + .replace("@OPTIONS@", options) + .replace("@TOTAL@", &total.to_string()); + let entry = dir.path().join("main.ts"); + let binary = dir.path().join("main"); + std::fs::write(&entry, source).unwrap(); + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."); + let compile = Command::new(env!("CARGO_BIN_EXE_perry")) + .args([ + "compile", + entry.to_str().unwrap(), + "-o", + binary.to_str().unwrap(), + "--no-cache", + ]) + .env("PERRY_WORKSPACE_ROOT", root) + .output() + .expect("compile"); + assert!( + compile.status.success(), + "compile failed: {}", + String::from_utf8_lossy(&compile.stderr) + ); + let mut child = Command::new(binary) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let deadline = Instant::now() + Duration::from_secs(20); + while child.try_wait().unwrap().is_none() { + if Instant::now() >= deadline { + child.kill().unwrap(); + let output = child.wait_with_output().unwrap(); + panic!( + "{mode} hung: {}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + std::thread::sleep(Duration::from_millis(20)); + } + let output = child.wait_with_output().unwrap(); + assert!( + output.status.success(), + "{mode} failed: {}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout).unwrap() +} + +#[test] +fn attached_server_shares_http_port_and_accepts_120_clients() { + let output = run("attached", "{ clientTracking: true, server: http }", 120); + assert!(output.contains("http http-ok\n"), "{output}"); + assert!(output.contains("shared-port true\n"), "{output}"); + assert!(output.contains("detached-http http-ok\n"), "{output}"); + assert!(output.contains("address true true true\n"), "{output}"); + assert!( + output.contains("counts 120 0 120 120 0 120 120 1\n"), + "{output}" + ); +} + +#[test] +fn manual_upgrade_calls_callback_and_emits_exactly_once_for_60_clients() { + let output = run("manual", "{ maxPayload: 1024, noServer: true }", 60); + assert!(output.contains("http http-ok\n"), "{output}"); + assert!( + output.contains("counts 60 60 60 60 0 60 60 0\n"), + "{output}" + ); +} + +#[test] +fn handle_upgrade_does_not_emit_connection_without_callback_emission() { + let output = run("callback-only", "{ noServer: true }", 3); + assert!(output.contains("counts 0 3 3 3 0 0 0 0\n"), "{output}"); +} + +#[test] +fn ephemeral_port_listens_and_address_locates_the_server() { + let output = run( + "ephemeral", + "{ clientTracking: true, host: '127.0.0.1', port: 0 }", + 6, + ); + assert!(output.contains("address true true true\n"), "{output}"); + assert!(output.contains("counts 6 0 6 6 0 6 6 1\n"), "{output}"); +} diff --git a/docs/api/perry.d.ts b/docs/api/perry.d.ts index c8c478c975..a2a6b00868 100644 --- a/docs/api/perry.d.ts +++ b/docs/api/perry.d.ts @@ -1,6 +1,6 @@ // Auto-generated from Perry's API manifest (#465). Do not edit by hand. // Source: perry-api-manifest::API_MANIFEST -// Coverage: 2089 entries across 136 modules +// Coverage: 2091 entries across 136 modules type PerryI8 = number & { readonly __perryI8?: never }; type PerryI16 = number & { readonly __perryI16?: never }; @@ -375,6 +375,8 @@ declare module "bun" { /** stdlib */ export function build(...args: any[]): any; /** stdlib */ + export function connect(...args: any[]): any; + /** stdlib */ export function deepEquals(...args: any[]): any; /** stdlib */ export function file(...args: any[]): any; @@ -387,6 +389,8 @@ declare module "bun" { /** stdlib */ export function hash(...args: any[]): any; /** stdlib */ + export function listen(...args: any[]): any; + /** stdlib */ export function pathToFileURL(...args: any[]): any; /** stdlib */ export function serve(options: any): any; diff --git a/docs/src/api/reference.md b/docs/src/api/reference.md index 552694501d..62b4b3ff0c 100644 --- a/docs/src/api/reference.md +++ b/docs/src/api/reference.md @@ -2,7 +2,7 @@ This page is auto-generated from Perry's compile-time API manifest (`perry-api-manifest::API_MANIFEST`). It is the source of truth for what `perry compile` accepts; references to symbols not listed here produce `R005 UnimplementedApi` (issue #463). Stubs (#464) are flagged ⚠ — they link cleanly but no-op at runtime on the chosen target. -Total: 3046 entries across 138 modules. +Total: 3050 entries across 138 modules. ## Modules @@ -429,12 +429,14 @@ Total: 3046 entries across 138 modules. - `Terminal` — module - `Transpiler` — module - `build` — module +- `connect` — module - `deepEquals` — module - `file` — module - `fileURLToPath` — module - `gc` — module - `generateHeapSnapshot` — module - `hash` — module +- `listen` — module - `pathToFileURL` — module - `scan` — instance *(class: `Transpiler`)* - `scanImports` — instance *(class: `Transpiler`)* @@ -4131,10 +4133,12 @@ Total: 3046 entries across 138 modules. - `Server` — module - `WebSocket` — module - `addListener` — instance *(class: `Client`)* +- `address` — instance - `clients` — instance - `close` — instance - `close` — instance *(class: `Client`)* - `closeClient` — module +- `emit` — instance - `handleUpgrade` — instance - `on` — instance - `on` — instance *(class: `Client`)* diff --git a/docs/src/stdlib/http.md b/docs/src/stdlib/http.md index ff6b89154a..dcc6b15e08 100644 --- a/docs/src/stdlib/http.md +++ b/docs/src/stdlib/http.md @@ -175,6 +175,22 @@ Perry's Fastify implementation is API-compatible with the npm package. Routes, r {{#include ../../examples/stdlib/http/snippets.ts:websocket-client}} ``` +`new WebSocketServer({ server: httpServer })` shares an existing HTTP server's +port: ordinary requests still reach the HTTP handler and WebSocket upgrades +fire `wss.on("connection", (ws, req) => ...)`. `wss.address()` reports the +host server's address. Closing the WebSocket server detaches it without +closing the shared HTTP listener. + +For manual routing, create `new WebSocketServer({ noServer: true })` and call +`wss.handleUpgrade(req, socket, head, (ws, req) => wss.emit("connection", ws, req))` +from the HTTP server's `upgrade` listener. The callback runs once; it controls +whether the connection event is emitted. The native HTTP transport performs +the handshake before dispatching `upgrade`, as described above. + +A standalone `new WebSocketServer({ port: 0 })` binds an ephemeral port and +emits `listening`. Read `wss.address().port` inside that listener to connect to +it. Its address object contains `address`, `family`, and `port`. + ## AWS S3 / S3-Compatible Object Storage [`@bradenmacdonald/s3-lite-client`](https://github.com/bradenmacdonald/s3-lite-client) is a zero-dependency, MIT-licensed S3 client (~1.9k LoC, derived from the official MinIO JS client without the lodash/async/xml2js baggage). It compiles natively under `perry.compilePackages` with no patches required — verified against a SigV4 presigned-URL byte-for-byte match with `bun` (issue #551).