From 25901d13b1bbb00a6121ab8eee5bc69ea523246d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 5 Oct 2026 19:21:23 +0000 Subject: [PATCH 01/18] feat(runtime,ffi): rebase native payload transport P0 onto current main Bring net transport P0 (#12064, #11919) onto 8476086155. Keep current PayloadVTable, PayloadBuffer support, stream state record, native-this alias and ServerResponse payload ABI; extend that ABI rather than add P0's older duplicate implementation. TransportCore is inline at offset zero of TransportPayload. Tokens carry route/op/cell, terminal driver completions retain the cell, close hands the handle to the driver, and generational handles and resolver ops protect reopen. Link teardown discards completions before heap teardown. Keep id routes for subsystems that have not migrated; add no id/cell alias. Use MaybeUninit words for opaque FFI storage and check its ABI before initialization. Expose binding owner links, lifecycle and typed reopen through the shared cell/vtable. A checked route store preserves the live multishot read. Runtime witnesses cover N1, N5, N6, N7, N9, N10, N11, and the runtime half of N8, with nine sabotages that turn them red. Phase A's ext-net and HTTP connection conversion remains separate work. --- changelog.d/12064-net-transport-p0.md | 16 + crates/perry-ffi/src/native_payload.rs | 4 + crates/perry-ffi/src/native_payload_links.rs | 122 ++ crates/perry-ffi/src/turnloop_net.rs | 740 +++++++- crates/perry-runtime/src/gc/tests/mod.rs | 2 + .../src/gc/tests/net_transport.rs | 760 ++++++++ crates/perry-runtime/src/native_payload.rs | 114 +- .../perry-runtime/src/native_payload_abi.rs | 4 + .../src/native_payload_transport_abi.rs | 310 ++++ crates/perry-runtime/src/turnloop_net/abi.rs | 525 +++++- crates/perry-runtime/src/turnloop_net/mod.rs | 60 +- crates/perry-runtime/src/turnloop_net/sink.rs | 62 +- .../perry-runtime/src/turnloop_net/tests.rs | 1 + .../src/turnloop_net/transport.rs | 1600 +++++++++++++++++ .../src/turnloop_net/windows_pipe.rs | 26 +- .../src/turnloop_net/write_queue.rs | 39 +- docs/native-payload-pattern.md | 52 + scripts/gc_runtime_root_holders.json | 6 + 18 files changed, 4343 insertions(+), 100 deletions(-) create mode 100644 changelog.d/12064-net-transport-p0.md create mode 100644 crates/perry-ffi/src/native_payload_links.rs create mode 100644 crates/perry-runtime/src/gc/tests/net_transport.rs create mode 100644 crates/perry-runtime/src/native_payload_transport_abi.rs create mode 100644 crates/perry-runtime/src/turnloop_net/transport.rs diff --git a/changelog.d/12064-net-transport-p0.md b/changelog.d/12064-net-transport-p0.md new file mode 100644 index 0000000000..f99ac8ed71 --- /dev/null +++ b/changelog.d/12064-net-transport-p0.md @@ -0,0 +1,16 @@ +Add an opaque TransportCore at offset zero of a native transport payload and +link-token dispatch through the payload cell. Each terminal completion owed by +the driver retains the cell; close moves its handle into the driver, and stale +generational completions cannot mutate a reopened transport. Accepted resources +are installed directly into child payloads. Teardown discards link completions. + +Rebase P0 onto the current payload ABI: preserve its typed PayloadVTable, +PayloadBuffer support, stream state record and native-this alias. Binding owner +links and reopen use the same cell and vtable, with no second native_state word. +Id routes remain available to subsystems that have not yet migrated; this +prerequisite does not convert ext-net or HTTP connections. + +The binding's opaque storage uses MaybeUninit words, so Rust padding and +reserved tail bytes may remain uninitialized. Its constructor checks the ABI +layout before the runtime writes the block. A route switch changes only the +core route, preserving the outstanding multishot read's operation and token. diff --git a/crates/perry-ffi/src/native_payload.rs b/crates/perry-ffi/src/native_payload.rs index 546dacab43..fc5b82e49f 100644 --- a/crates/perry-ffi/src/native_payload.rs +++ b/crates/perry-ffi/src/native_payload.rs @@ -241,3 +241,7 @@ pub fn set_external_bytes( ) { unsafe { js_perry_payload_external_bytes(value, vtable, bytes) }; } + +#[path = "native_payload_links.rs"] +mod links; +pub use links::*; diff --git a/crates/perry-ffi/src/native_payload_links.rs b/crates/perry-ffi/src/native_payload_links.rs new file mode 100644 index 0000000000..6bb5a40f51 --- /dev/null +++ b/crates/perry-ffi/src/native_payload_links.rs @@ -0,0 +1,122 @@ +//! Async owner links for the shared payload ABI. +use super::*; + +/// Stable inert cell address. A bare link is not a GC root. +#[repr(transparent)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct OwnerLink(usize); +impl OwnerLink { + /// Address packed into a transport token. + pub fn raw(self) -> usize { + self.0 + } + /// Recover a link carried by an outstanding completion. + /// # Safety + /// raw is a live cell address kept by a matching link ref. + pub unsafe fn from_raw(raw: usize) -> Self { + Self(raw) + } +} +/// State of the same cell across close and reopen. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Lifecycle { + /// Payload installed. + Open, + /// Release deferred until a native call returns. + Closing, + /// Payload released; the cell and owner remain. + Closed, +} +/// A checked payload receiver could not be used. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PayloadMiss { + /// Foreign receiver or descriptor. + Foreign, + /// Finalized or wrong-thread receiver. + Closed, +} + +extern "C" { + fn js_perry_payload_reopen( + value: f64, + family: *const PayloadFamily, + resource: *mut c_void, + bytes: usize, + ) -> i32; + fn js_perry_payload_lifecycle(value: f64, family: *const PayloadFamily) -> i32; + fn js_perry_payload_owner_link(value: f64, family: *const PayloadFamily) -> usize; + fn js_perry_payload_link_ref(link: usize); + fn js_perry_payload_link_unref(link: usize); + fn js_perry_payload_link_event_owner(link: usize, out: *mut f64) -> i32; + fn js_perry_payload_js_state(value: f64, family: *const PayloadFamily, create: i32) -> f64; +} +/// Allocate the family's typed cell in CLOSED state on its prototype. +/// # Safety +/// proto is the family's prototype. +pub unsafe fn alloc_closed(family: &'static PayloadFamily, proto: f64) -> f64 { + assert!(abi_matches(), "native payload ABI mismatch"); + js_perry_payload_alloc(family, std::ptr::null_mut(), proto, 0) +} +/// Reopen the same typed cell; ownership transfers only on success. +/// # Safety +/// T is the family's payload type. No live native resource is overwritten. +pub unsafe fn reopen( + value: f64, + family: &'static PayloadFamily, + payload: T, + bytes: usize, +) -> bool { + assert!(abi_matches(), "native payload ABI mismatch"); + let ptr = Box::into_raw(Box::new(payload)); + if js_perry_payload_reopen(value, family, ptr.cast(), bytes) == 0 { + true + } else { + drop(Box::from_raw(ptr)); + false + } +} +/// Check a receiver's current lifecycle. +pub fn lifecycle(value: f64, family: &'static PayloadFamily) -> Result { + assert!(abi_matches(), "native payload ABI mismatch"); + match unsafe { js_perry_payload_lifecycle(value, family) } { + 1 => Ok(Lifecycle::Open), + 2 => Ok(Lifecycle::Closing), + 3 => Ok(Lifecycle::Closed), + -2 => Err(PayloadMiss::Closed), + _ => Err(PayloadMiss::Foreign), + } +} +/// Obtain a link on the creator thread, including after explicit close. +pub fn owner_link(value: f64, family: &'static PayloadFamily) -> Result { + assert!(abi_matches(), "native payload ABI mismatch"); + let raw = unsafe { js_perry_payload_owner_link(value, family) }; + if raw == 0 { + Err(PayloadMiss::Foreign) + } else { + Ok(OwnerLink(raw)) + } +} +/// Retain one outstanding completion. +/// # Safety +/// link is live on this thread. +pub unsafe fn link_ref(link: OwnerLink) { + js_perry_payload_link_ref(link.0); +} +/// Release a matching outstanding completion ref. +/// # Safety +/// link is live on this thread and has a matching ref. +pub unsafe fn link_unref(link: OwnerLink) { + js_perry_payload_link_unref(link.0); +} +/// Event owner, including after release. Root before any allocation or JS. +/// # Safety +/// link is live on its creator thread. +pub unsafe fn link_event_owner(link: OwnerLink) -> Option { + let mut out = 0.0; + (js_perry_payload_link_event_owner(link.0, &mut out) != 0).then_some(out) +} +/// Traced JS-state object, created on demand when requested. +pub fn js_state(value: f64, family: &'static PayloadFamily, create: bool) -> f64 { + assert!(abi_matches(), "native payload ABI mismatch"); + unsafe { js_perry_payload_js_state(value, family, create as i32) } +} diff --git a/crates/perry-ffi/src/turnloop_net.rs b/crates/perry-ffi/src/turnloop_net.rs index dffd32465b..0c5fc5d528 100644 --- a/crates/perry-ffi/src/turnloop_net.rs +++ b/crates/perry-ffi/src/turnloop_net.rs @@ -46,6 +46,8 @@ pub const NET_CLOSED: i32 = 7; pub const NET_ERROR: i32 = 8; /// Completion kind: a subsystem-owned deadline expired (P5); `id` names it. pub const NET_TIMER: i32 = 9; +/// `NetCompletion::flags`: a link route's completion (`id` is an owner link). +pub const NET_FLAG_LINK: i32 = 1; /// This module's view of the runtime's completion record. /// @@ -60,11 +62,14 @@ pub struct NetCompletion { pub errno: i32, /// Nonzero when the operation that produced this will produce no more. pub terminal: i32, - /// Layout padding; see the runtime's definition. - pub _reserved: i32, - /// The socket or listener this concerns. + /// [`NET_FLAG_LINK`] on a link route's completions; zero otherwise. + pub flags: i32, + /// The socket or listener this concerns. On a link route: its owner link + /// ([`NetCompletion::link`]). pub id: i64, - /// For [`NET_ACCEPT`], the accepted connection's id; else zero. + /// For [`NET_ACCEPT`], the accepted connection's id; else zero. On a link + /// route: an install slot for [`link_install_accepted`], valid during the + /// sink call only. pub conn: i64, /// The write/end completion token the caller supplied; zero if none. pub user: u64, @@ -85,6 +90,14 @@ pub struct NetCompletion { } impl NetCompletion { + /// The payload this completion is for, on a link route. + pub fn link(&self) -> Option { + (self.flags & NET_FLAG_LINK != 0 && self.id != 0).then(|| { + // SAFETY: the runtime put a cell address from `owner_link` here. + unsafe { crate::native_payload::OwnerLink::from_raw(self.id as usize) } + }) + } + /// Borrow the read payload. /// /// # Safety @@ -306,16 +319,24 @@ const OK: i32 = 0; const ENOLOOP: i32 = -2; /// Revision of the ABI this file is written against; must match the runtime's. -const ABI_VERSION: u8 = 2; +const ABI_VERSION: u8 = 3; fn layout_digest() -> u64 { + layout_digest_for(TRANSPORT_CORE_WORDS) +} + +/// The runtime's `net_abi_layout` expression, over this crate's own structs +/// and block size. +const fn layout_digest_for(core_words: usize) -> u64 { use std::mem::{align_of, offset_of, size_of}; - (size_of::() as u64) << 48 - | (offset_of!(NetCompletion, id) as u64) << 40 - | (offset_of!(NetCompletion, data) as u64) << 32 - | (offset_of!(NetCompletion, code) as u64) << 24 - | (offset_of!(NetCompletion, syscall) as u64) << 16 - | (align_of::() as u64) << 8 + (size_of::() as u64 & 0xFFF) << 52 + | (offset_of!(NetCompletion, id) as u64 & 0x3F) << 46 + | (offset_of!(NetCompletion, data) as u64 & 0x7F) << 39 + | (offset_of!(NetCompletion, code) as u64 & 0x7F) << 32 + | (offset_of!(NetCompletion, syscall) as u64 & 0x7F) << 25 + | (align_of::() as u64 & 0xF) << 21 + | (core_words as u64 & 0x1FF) << 12 + | (align_of::() as u64 & 0xF) << 8 | ABI_VERSION as u64 } @@ -918,6 +939,666 @@ pub fn peer_address(id: i64) -> Option { }) } +// ── Link routes (NET-TRANSPORT-DESIGN P0) ─────────────────────────────────── +// +// A converted binding owns each socket in a native payload +// (`crate::native_payload`): its payload type is `TransportPayload`, the +// runtime's transport state first and the binding's own fields after it. Every +// call names the socket by `(&mut payload.core, owner_link)` instead of an id; +// every completion's `id` is that owner link ([`NetCompletion::link`]). While +// the driver owes a terminal completion (a handle's close, a resolve, a +// deadline) the payload's cell is pinned, so a listening server or open socket +// with no JS reference lives until it is closed, as in Node. + +/// Words of the opaque block that holds the runtime's transport state. Part of +/// the ABI digest: a runtime with another block size refuses this binding. +#[cfg(not(windows))] +pub const TRANSPORT_CORE_WORDS: usize = 39; +/// See the non-Windows definition. +#[cfg(windows)] +pub const TRANSPORT_CORE_WORDS: usize = 48; + +/// The runtime's transport state for one socket or listener, held inline in +/// the binding's payload (one allocation per socket). Opaque: only the +/// runtime reads it. Dropping it frees memory only. +#[repr(C, align(8))] +pub struct TransportCore { + // Rust may leave struct padding and reserved tail words uninitialized. + // The binding never reads words; MaybeUninit makes moving the opaque + // storage valid without pretending those bytes are initialized integers. + words: [std::mem::MaybeUninit; TRANSPORT_CORE_WORDS], +} + +impl TransportCore { + /// An idle core whose completions go to the link sink `route`. + pub fn new(route: u8) -> Self { + let mut core = std::mem::MaybeUninit::::uninit(); + runtime_call!( + { + // Refuse a different block layout BEFORE the runtime writes it. + assert!( + unsafe { js_perry_net_abi_layout() == layout_digest() } + && crate::native_payload::abi_matches(), + "transport core ABI mismatch" + ); + // SAFETY: `core` is writable for the whole block and 8-aligned. + let rc = + unsafe { js_perry_net_core_init(core.as_mut_ptr().cast(), i32::from(route)) }; + assert_eq!(rc, OK, "link route {route} is outside the sink table"); + // SAFETY: runtime fields are initialized; padding/reserved bytes + // remain valid MaybeUninit storage and are never read here. + unsafe { core.assume_init() } + }, + { + let _ = route; + // SAFETY: an all-zero block; the fallback build never reads it. + unsafe { + core.as_mut_ptr().write_bytes(0, 1); + core.assume_init() + } + } + ) + } + + fn raw(&mut self) -> *mut std::ffi::c_void { + (self as *mut Self).cast() + } +} + +impl Drop for TransportCore { + fn drop(&mut self) { + runtime_call!( + { + // SAFETY: initialized by `new`, dropped exactly once. + unsafe { js_perry_net_core_drop(self.raw()) } + }, + {} + ) + } +} + +/// A family payload that owns a transport: the core first (the runtime reads +/// it at offset 0 of the payload a link names), then the binding's fields. +#[repr(C)] +pub struct TransportPayload { + /// The runtime's transport state. + pub core: TransportCore, + /// The binding's own fields. + pub ext: E, +} + +impl TransportPayload { + /// An idle transport for the link sink `route`, plus the binding's fields. + pub fn new(route: u8, ext: E) -> Self { + Self { + core: TransportCore::new(route), + ext, + } + } +} + +use crate::native_payload::OwnerLink; + +#[cfg(any(not(test), feature = "runtime-link"))] +extern "C" { + fn js_perry_net_register_link_sink(subsystem: i32, sink: SinkFn) -> i32; + fn js_perry_net_link_set_route( + core: *mut std::ffi::c_void, + link: usize, + route: i32, + err: *mut RawNetError, + ) -> i32; + fn js_perry_net_core_init(core: *mut std::ffi::c_void, route: i32) -> i32; + fn js_perry_net_core_drop(core: *mut std::ffi::c_void); + fn js_perry_net_link_tcp_listen( + core: *mut std::ffi::c_void, + link: usize, + host: *const u8, + host_len: usize, + port: u16, + backlog: u32, + reuse_port: i32, + nodelay: i32, + err: *mut RawNetError, + ) -> i32; + fn js_perry_net_link_pipe_listen( + core: *mut std::ffi::c_void, + link: usize, + path: *const u8, + path_len: usize, + backlog: u32, + err: *mut RawNetError, + ) -> i32; + fn js_perry_net_link_accept_start( + core: *mut std::ffi::c_void, + link: usize, + err: *mut RawNetError, + ) -> i32; + fn js_perry_net_link_install_accepted( + core: *mut std::ffi::c_void, + link: usize, + completion: *const NetCompletion, + err: *mut RawNetError, + ) -> i32; + fn js_perry_net_link_tcp_connect( + core: *mut std::ffi::c_void, + link: usize, + host: *const u8, + host_len: usize, + port: u16, + nodelay: i32, + err: *mut RawNetError, + ) -> i32; + fn js_perry_net_link_pipe_connect( + core: *mut std::ffi::c_void, + link: usize, + path: *const u8, + path_len: usize, + err: *mut RawNetError, + ) -> i32; + fn js_perry_net_link_adopt_stream( + core: *mut std::ffi::c_void, + link: usize, + socket: i64, + err: *mut RawNetError, + ) -> i32; + fn js_perry_net_link_read_start( + core: *mut std::ffi::c_void, + link: usize, + err: *mut RawNetError, + ) -> i32; + fn js_perry_net_link_write( + core: *mut std::ffi::c_void, + link: usize, + bytes: *const u8, + len: usize, + user: u64, + out_queued: *mut usize, + err: *mut RawNetError, + ) -> i32; + fn js_perry_net_link_shutdown( + core: *mut std::ffi::c_void, + link: usize, + user: u64, + err: *mut RawNetError, + ) -> i32; + fn js_perry_net_link_close( + core: *mut std::ffi::c_void, + link: usize, + out_closed: *mut i32, + err: *mut RawNetError, + ) -> i32; + fn js_perry_net_link_set_ref(core: *mut std::ffi::c_void, link: usize, referenced: i32) -> i32; + fn js_perry_net_link_queued_bytes(core: *mut std::ffi::c_void, link: usize) -> usize; + fn js_perry_net_link_deadline_arm( + core: *mut std::ffi::c_void, + link: usize, + delay_ms: u64, + err: *mut RawNetError, + ) -> i32; + fn js_perry_net_link_deadline_park( + core: *mut std::ffi::c_void, + link: usize, + err: *mut RawNetError, + ) -> i32; + fn js_perry_net_link_deadline_cancel( + core: *mut std::ffi::c_void, + link: usize, + err: *mut RawNetError, + ) -> i32; + fn js_perry_net_link_local_address( + core: *mut std::ffi::c_void, + link: usize, + out: *mut u8, + cap: usize, + out_len: *mut usize, + out_port: *mut u16, + out_family: *mut i32, + ) -> i32; + fn js_perry_net_link_peer_address( + core: *mut std::ffi::c_void, + link: usize, + out: *mut u8, + cap: usize, + out_len: *mut usize, + out_port: *mut u16, + out_family: *mut i32, + ) -> i32; +} + +/// Install a link-routed binding's completion sink (no id allocator: the sink +/// allocates the payload of an accepted connection itself and calls +/// [`link_install_accepted`]). Refused, leaving the route unusable, when the +/// runtime's completion or core layout differs from this crate's. +pub fn register_link_sink(subsystem: u8, sink: SinkFn) -> bool { + runtime_call!( + { + // SAFETY: plain registration calls in the linked runtime. + let ok = unsafe { + if js_perry_net_abi_layout() != layout_digest() + || !crate::native_payload::abi_matches() + { + return false; + } + js_perry_net_register_link_sink(i32::from(subsystem), sink) != 0 + }; + if ok { + REGISTERED.store(true, Ordering::Release); + } + ok + }, + { + let _ = (subsystem, sink, layout_digest()); + false + } + ) +} + +/// Run one link call with an error out-param. +#[cfg(any(not(test), feature = "runtime-link"))] +fn link_call(f: impl FnOnce(*mut RawNetError) -> i32) -> Result<(), NetError> { + let mut raw = RawNetError::blank(); + let rc = f(&mut raw); + check(rc, raw) +} + +/// Bind and listen on `host:port` (link form of [`tcp_listen`]). The handle +/// pins the payload until its close completes. +pub fn link_tcp_listen( + core: &mut TransportCore, + link: OwnerLink, + host: &str, + port: u16, + backlog: u32, + reuse_port: bool, + nodelay: bool, +) -> Result<(), NetError> { + runtime_call!( + { + link_call(|err| unsafe { + js_perry_net_link_tcp_listen( + core.raw(), + link.raw(), + host.as_ptr(), + host.len(), + port, + backlog, + i32::from(reuse_port), + i32::from(nodelay), + err, + ) + }) + }, + { + let _ = (core, link, host, port, backlog, reuse_port, nodelay); + Err(unavailable()) + } + ) +} + +/// Listen on a Unix-domain socket path or a Windows named pipe. Unlink the +/// path yourself, synchronously, at close. +pub fn link_pipe_listen( + core: &mut TransportCore, + link: OwnerLink, + path: &str, + backlog: u32, +) -> Result<(), NetError> { + runtime_call!( + { + link_call(|err| unsafe { + js_perry_net_link_pipe_listen( + core.raw(), + link.raw(), + path.as_ptr(), + path.len(), + backlog, + err, + ) + }) + }, + { + let _ = (core, link, path, backlog); + Err(unavailable()) + } + ) +} + +/// Start the listener's multishot accept. +pub fn link_accept_start(core: &mut TransportCore, link: OwnerLink) -> Result<(), NetError> { + runtime_call!( + { link_call(|err| unsafe { js_perry_net_link_accept_start(core.raw(), link.raw(), err) }) }, + { + let _ = (core, link); + Err(unavailable()) + } + ) +} + +/// Inside the sink, for a [`NET_ACCEPT`] completion: install the accepted +/// connection into a fresh payload's core. A connection not installed before +/// the sink returns is closed by the runtime. +pub fn link_install_accepted( + core: &mut TransportCore, + link: OwnerLink, + completion: &NetCompletion, +) -> Result<(), NetError> { + runtime_call!( + { + link_call(|err| unsafe { + js_perry_net_link_install_accepted(core.raw(), link.raw(), completion, err) + }) + }, + { + let _ = (core, link, completion); + Err(unavailable()) + } + ) +} + +/// Connect a TCP client (link form of [`tcp_connect`]). +pub fn link_tcp_connect( + core: &mut TransportCore, + link: OwnerLink, + host: &str, + port: u16, + nodelay: bool, +) -> Result<(), NetError> { + runtime_call!( + { + link_call(|err| unsafe { + js_perry_net_link_tcp_connect( + core.raw(), + link.raw(), + host.as_ptr(), + host.len(), + port, + i32::from(nodelay), + err, + ) + }) + }, + { + let _ = (core, link, host, port, nodelay); + Err(unavailable()) + } + ) +} + +/// Connect to a Unix-domain socket or a Windows named pipe. +pub fn link_pipe_connect( + core: &mut TransportCore, + link: OwnerLink, + path: &str, +) -> Result<(), NetError> { + runtime_call!( + { + link_call(|err| unsafe { + js_perry_net_link_pipe_connect( + core.raw(), + link.raw(), + path.as_ptr(), + path.len(), + err, + ) + }) + }, + { + let _ = (core, link, path); + Err(unavailable()) + } + ) +} + +/// Adopt an already-connected stream socket; consumed on every outcome. +#[cfg(any(unix, windows))] +pub fn link_adopt_stream( + core: &mut TransportCore, + link: OwnerLink, + socket: AdoptedSocket, +) -> Result<(), NetError> { + runtime_call!( + { + #[cfg(unix)] + let raw_socket = { + use std::os::fd::IntoRawFd; + i64::from(socket.into_raw_fd()) + }; + #[cfg(windows)] + let raw_socket = { + use std::os::windows::io::IntoRawSocket; + socket.into_raw_socket() as i64 + }; + link_call(|err| unsafe { + js_perry_net_link_adopt_stream(core.raw(), link.raw(), raw_socket, err) + }) + }, + { + // Consumed on every outcome: dropping it closes it. + drop(socket); + let _ = (core, link); + Err(unavailable()) + } + ) +} + +/// Start the multishot read. +pub fn link_read_start(core: &mut TransportCore, link: OwnerLink) -> Result<(), NetError> { + runtime_call!( + { link_call(|err| unsafe { js_perry_net_link_read_start(core.raw(), link.raw(), err) }) }, + { + let _ = (core, link); + Err(unavailable()) + } + ) +} + +/// Queue bytes (copied); returns the socket's total queued bytes. +pub fn link_write( + core: &mut TransportCore, + link: OwnerLink, + bytes: &[u8], + user: u64, +) -> Result { + runtime_call!( + { + let mut queued = 0usize; + link_call(|err| unsafe { + js_perry_net_link_write( + core.raw(), + link.raw(), + bytes.as_ptr(), + bytes.len(), + user, + &mut queued, + err, + ) + }) + .map(|()| queued) + }, + { + let _ = (core, link, bytes, user); + Err(unavailable()) + } + ) +} + +/// `end()`: shut the write side down behind the queued writes. +pub fn link_shutdown(core: &mut TransportCore, link: OwnerLink, user: u64) -> Result<(), NetError> { + runtime_call!( + { + link_call(|err| unsafe { + js_perry_net_link_shutdown(core.raw(), link.raw(), user, err) + }) + }, + { + let _ = (core, link, user); + Err(unavailable()) + } + ) +} + +/// Release the transport: the handle moves into the driver's close, the +/// deadline and a pending resolve go with it. `Ok(true)`: a [`NET_CLOSED`] +/// for this link follows; `Ok(false)`: there was no handle, so a binding that +/// owes a `close` event schedules it itself. Then release the payload +/// (`native_payload::close`); never close from `Drop`. +pub fn link_close(core: &mut TransportCore, link: OwnerLink) -> Result { + runtime_call!( + { + let mut closed = 0i32; + link_call(|err| unsafe { + js_perry_net_link_close(core.raw(), link.raw(), &mut closed, err) + }) + .map(|()| closed != 0) + }, + { + let _ = (core, link); + Err(unavailable()) + } + ) +} + +/// Node's `ref()`/`unref()`; remembered for handles installed later. +pub fn link_set_ref(core: &mut TransportCore, link: OwnerLink, referenced: bool) -> bool { + runtime_call!( + { + // SAFETY: `core`/`link` name one payload of this thread. + unsafe { + js_perry_net_link_set_ref(core.raw(), link.raw(), i32::from(referenced)) == OK + } + }, + { + let _ = (core, link, referenced); + false + } + ) +} + +/// Bytes accepted and not yet reported written (`writableLength`). +pub fn link_queued_bytes(core: &mut TransportCore, link: OwnerLink) -> usize { + runtime_call!( + { + // SAFETY: `core`/`link` name one payload of this thread. + unsafe { js_perry_net_link_queued_bytes(core.raw(), link.raw()) } + }, + { + let _ = (core, link); + 0 + } + ) +} + +/// Arm or move the socket's deadline; its expiry arrives as [`NET_TIMER`]. +pub fn link_deadline_arm( + core: &mut TransportCore, + link: OwnerLink, + delay_ms: u64, +) -> Result<(), NetError> { + runtime_call!( + { + link_call(|err| unsafe { + js_perry_net_link_deadline_arm(core.raw(), link.raw(), delay_ms, err) + }) + }, + { + let _ = (core, link, delay_ms); + Err(unavailable()) + } + ) +} + +/// Disarm the deadline, keeping its handle for a cheap re-arm. +pub fn link_deadline_park(core: &mut TransportCore, link: OwnerLink) -> Result<(), NetError> { + runtime_call!( + { + link_call(|err| unsafe { js_perry_net_link_deadline_park(core.raw(), link.raw(), err) }) + }, + { + let _ = (core, link); + Err(unavailable()) + } + ) +} + +/// Cancel the deadline. Idempotent. +pub fn link_deadline_cancel(core: &mut TransportCore, link: OwnerLink) -> Result<(), NetError> { + runtime_call!( + { + link_call(|err| unsafe { + js_perry_net_link_deadline_cancel(core.raw(), link.raw(), err) + }) + }, + { + let _ = (core, link); + Err(unavailable()) + } + ) +} + +#[cfg(any(not(test), feature = "runtime-link"))] +fn link_endpoint( + core: &mut TransportCore, + link: OwnerLink, + f: unsafe extern "C" fn( + *mut std::ffi::c_void, + usize, + *mut u8, + usize, + *mut usize, + *mut u16, + *mut i32, + ) -> i32, +) -> Option { + let mut buf = [0u8; 64]; + let mut len: usize = 0; + let mut port: u16 = 0; + let mut family: i32 = 4; + // SAFETY: `buf` is writable for its own length and every out-param is a + // live local. + let rc = unsafe { + f( + core.raw(), + link.raw(), + buf.as_mut_ptr(), + buf.len(), + &mut len, + &mut port, + &mut family, + ) + }; + if rc != OK { + return None; + } + Some(Endpoint { + address: String::from_utf8_lossy(&buf[..len]).into_owned(), + port, + family, + }) +} + +/// The local endpoint (`server.address()`, `socket.localAddress`). +pub fn link_local_address(core: &mut TransportCore, link: OwnerLink) -> Option { + runtime_call!( + { link_endpoint(core, link, js_perry_net_link_local_address) }, + { + let _ = (core, link); + None + } + ) +} + +/// The peer endpoint (`socket.remoteAddress`). +pub fn link_peer_address(core: &mut TransportCore, link: OwnerLink) -> Option { + runtime_call!( + { link_endpoint(core, link, js_perry_net_link_peer_address) }, + { + let _ = (core, link); + None + } + ) +} + #[cfg(test)] mod tests { use super::*; @@ -931,13 +1612,26 @@ mod tests { let digest = layout_digest(); assert_eq!(digest as u8, ABI_VERSION); assert_eq!( - (digest >> 48) as usize, + (digest >> 52) as usize, std::mem::size_of::() ); assert_eq!( - ((digest >> 32) & 0xff) as usize, + ((digest >> 39) & 0x7f) as usize, std::mem::offset_of!(NetCompletion, data) ); + // The opaque core block is part of the contract: a binding built + // against another block size computes another digest. + assert_eq!(((digest >> 12) & 0x1ff) as usize, TRANSPORT_CORE_WORDS); + assert_ne!(digest, layout_digest_for(TRANSPORT_CORE_WORDS + 1)); + } + + /// The runtime and this crate agree on the completion and the core block + /// (linked runs). Sabotage: change `TRANSPORT_CORE_WORDS` here by one. + #[cfg(feature = "runtime-link")] + #[test] + fn the_runtime_agrees_on_the_completion_and_core_layout() { + // SAFETY: a plain getter in the linked runtime. + assert_eq!(unsafe { js_perry_net_abi_layout() }, layout_digest()); } #[test] @@ -948,3 +1642,23 @@ mod tests { assert_eq!(String::from_utf8_lossy(&raw[..5]), "127.0"); } } + +/// Switch a transport's completion sink by storing its route. Outstanding +/// reads retain their original tokens and are neither cancelled nor submitted again. +pub fn link_set_route( + core: &mut TransportCore, + link: OwnerLink, + route: u8, +) -> Result<(), NetError> { + runtime_call!( + { + link_call(|err| unsafe { + js_perry_net_link_set_route(core.raw(), link.raw(), i32::from(route), err) + }) + }, + { + let _ = (core, link, route); + Err(unavailable()) + } + ) +} diff --git a/crates/perry-runtime/src/gc/tests/mod.rs b/crates/perry-runtime/src/gc/tests/mod.rs index 002e581f0c..6610921105 100644 --- a/crates/perry-runtime/src/gc/tests/mod.rs +++ b/crates/perry-runtime/src/gc/tests/mod.rs @@ -73,6 +73,8 @@ mod native_payload_callbacks; mod native_payload_streams; mod stream_state_record; mod native_this_alias; +#[cfg(not(target_arch = "wasm32"))] +mod net_transport; mod noncollecting_root_lock; mod object_create; mod old_free_intrusive; diff --git a/crates/perry-runtime/src/gc/tests/net_transport.rs b/crates/perry-runtime/src/gc/tests/net_transport.rs new file mode 100644 index 0000000000..a2c32742d5 --- /dev/null +++ b/crates/perry-runtime/src/gc/tests/net_transport.rs @@ -0,0 +1,760 @@ +//! NET-TRANSPORT-DESIGN P0 witnesses: a turnloop transport owned by a native +//! payload, on the real driver, under real collections. Each test asserts its +//! subject ran (a listener bound, a completion dispatched, a move happened), +//! and `every_net_transport_sabotage_makes_its_witness_red` runs each one +//! against the defect it exists to catch. +use super::super::*; +use super::support::*; +use crate::native_payload::{self as np, CloseOutcome, NativePayloadFamily, OwnerLink}; +use crate::turnloop_net::transport::{self, TransportCore, TransportPayload}; +use crate::turnloop_net::{ + NetCompletion, NET_ACCEPT, NET_CLOSED, NET_CONNECT, NET_DATA, NET_FLAG_LINK, +}; +use std::cell::{Cell, RefCell}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::{Duration, Instant}; + +static DROPS: AtomicUsize = AtomicUsize::new(0); + +/// The family's own fields; its drop is counted. +struct Ext; +impl Drop for Ext { + fn drop(&mut self) { + DROPS.fetch_add(1, Ordering::SeqCst); + } +} +type Payload = TransportPayload; + +fn install(_: &mut np::PayloadPrototype) {} +static FAMILY: NativePayloadFamily = NativePayloadFamily { + class_id: crate::native_class_ids::CRYPTO_HASH, + name: "TransportProbe", + constructor_export: None, + constructor_length: 0, + links_owner: true, + install_prototype: install, +}; + +/// An unclaimed sink slot (the id-route tests hold 3). +const ROUTE: u8 = 14; + +#[derive(Clone, Debug)] +struct Event { + kind: i32, + link: usize, + /// `link_event_owner` as the sink saw it. + owner: Option, + bytes: Vec, + rerouted: bool, +} + +thread_local! { + static EVENTS: RefCell> = const { RefCell::new(Vec::new()) }; + /// When set, the sink installs an accepted connection into a fresh + /// payload instead of refusing it. + static INSTALL_ACCEPTED: Cell = const { Cell::new(false) }; + static REROUTED: Cell = const { Cell::new(false) }; + static CHILD: Cell = const { Cell::new(0) }; +} + +extern "C" fn link_sink(completion: *const NetCompletion) { + // SAFETY: dispatch passes a live completion for the duration of the call. + let c = unsafe { &*completion }; + assert_ne!( + c.flags & NET_FLAG_LINK, + 0, + "a link route marks its completions" + ); + let link = OwnerLink(c.id as usize); + // SAFETY: the dispatched completion's ref keeps the cell alive. + let owner = unsafe { np::link_event_owner(link) }.map(f64::to_bits); + if c.kind == NET_ACCEPT && INSTALL_ACCEPTED.with(Cell::get) { + let scope = RuntimeHandleScope::new(); + let child = scope.root_nanbox_f64(fresh()); + let child_link = np::owner_link(child.get_nanbox_f64(), &FAMILY).unwrap(); + // SAFETY: the child is OPEN on this thread; the completion is ours. + unsafe { + transport::install_accepted(core(child.get_nanbox_f64()), child_link, completion) + .expect("install the accepted connection"); + } + CHILD.with(|slot| slot.set(child_link.0)); + } + EVENTS.with(|events| { + events.borrow_mut().push(Event { + kind: c.kind, + link: link.0, + owner, + bytes: if c.kind == NET_DATA && c.len != 0 { + unsafe { std::slice::from_raw_parts(c.data, c.len) }.to_vec() + } else { + Vec::new() + }, + rerouted: REROUTED.with(Cell::get), + }) + }); +} + +extern "C" fn alternate_sink(completion: *const NetCompletion) { + REROUTED.with(|flag| flag.set(true)); + link_sink(completion); + REROUTED.with(|flag| flag.set(false)); +} + +fn fresh() -> f64 { + np::alloc( + &FAMILY, + Payload { + core: TransportCore::new(ROUTE), + ext: Ext, + }, + 0, + &[], + ) +} + +/// The payload's core: offset 0 of the payload (`repr(C)`, core first). +fn core(value: f64) -> *mut TransportCore { + let payload = unsafe { np::payload_mut::(value, &FAMILY) }.expect("an OPEN payload"); + &mut payload.core as *mut TransportCore +} + +fn cell(link: OwnerLink) -> *mut crate::native_handle::NativeHandleHeader { + link.0 as *mut crate::native_handle::NativeHandleHeader +} + +fn refs(link: OwnerLink) -> u32 { + unsafe { (*cell(link)).refs } +} + +fn finalized() -> usize { + crate::native_handle::PAYLOAD_FINALIZED.load(Ordering::SeqCst) +} + +fn full() { + let before = crate::gc::block_persist_force_mark_count(); + gc_collect_full_mark_sweep_with_trigger(GcTriggerSnapshot::capture(GcTriggerKind::Manual)); + assert_eq!(crate::gc::block_persist_force_mark_count(), before); +} + +fn count(kind: i32, link: OwnerLink) -> usize { + EVENTS.with(|events| { + events + .borrow() + .iter() + .filter(|e| e.kind == kind && e.link == link.0) + .count() + }) +} + +fn last(kind: i32, link: OwnerLink) -> Option { + EVENTS.with(|events| { + events + .borrow() + .iter() + .rev() + .find(|e| e.kind == kind && e.link == link.0) + .cloned() + }) +} + +fn pump_until(want: impl Fn() -> bool) -> bool { + let limit = Instant::now() + Duration::from_secs(5); + loop { + if want() { + return true; + } + if Instant::now() >= limit { + return false; + } + crate::event_pump::pump_net_for_test(Duration::from_millis(5)); + } +} + +/// Root scanners, counters and the loop, as the payload tests set them up. +struct Fixture; +impl Fixture { + fn start() -> Self { + np::reset_payload_prototypes_for_tests(); + gc_register_mutable_root_scanner(np::scan_payload_prototype_roots_mut); + register_runtime_handle_root_scanner_for_tests(); + gc_register_named_mutable_root_scanner( + "shape_table", + crate::object::shapes::scan_shape_table_rekey_mut, + ); + gc_register_named_mutable_root_scanner( + "pinned", + crate::gc::pin::scan_pinned_object_roots_mut, + ); + DROPS.store(0, Ordering::SeqCst); + EVENTS.with(|events| events.borrow_mut().clear()); + INSTALL_ACCEPTED.with(|f| f.set(false)); + CHILD.with(|c| c.set(0)); + assert!( + crate::event_pump::install_net_loop_for_test(), + "the host must provide a turnloop loop" + ); + assert!( + crate::turnloop_net::register_link_sink(ROUTE, link_sink), + "link sink registration must succeed, or every assertion is vacuous" + ); + Fixture + } +} +impl Drop for Fixture { + fn drop(&mut self) { + crate::event_pump::reset_net_loop_for_test(); + EVENTS.with(|events| events.borrow_mut().clear()); + np::reset_payload_prototypes_for_tests(); + } +} + +/// Close the transport and release the payload (rule 3); the `Closed` stays +/// owed. Returns whether a `NET_CLOSED` will follow. +fn destroy(link: OwnerLink) -> bool { + let scope = RuntimeHandleScope::new(); + let value = scope.root_nanbox_f64(unsafe { np::link_event_owner(link) }.unwrap()); + let closed = unsafe { transport::close(core(value.get_nanbox_f64()), link) }.unwrap(); + assert_eq!( + np::close(value.get_nanbox_f64(), &FAMILY), + CloseOutcome::Closed + ); + closed +} + +/// N1 (runtime half) + rule 2: a payload holding a driver handle, with no JS +/// reference, survives full and moving collections until the handle's +/// terminal `Closed` is dispatched; the accept on the way reaches the same +/// owner and installs into a child that is itself kept by its handle. +#[test] +fn n1_a_handle_holding_payload_lives_until_its_closed_is_dispatched() { + let _guard = CopyingNurseryTestGuard::new(0); + let _fixture = Fixture::start(); + let _no_stack = ConservativeScanDisabledGuard::new(); + let _trigger = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + let scope = RuntimeHandleScope::new(); + let value = scope.root_nanbox_f64(fresh()); + let link = np::owner_link(value.get_nanbox_f64(), &FAMILY).unwrap(); + let addr = unsafe { + let c = core(value.get_nanbox_f64()); + let addr = transport::tcp_listen(c, link, "127.0.0.1:0".parse().unwrap(), 16, false, true) + .expect("listen"); + transport::accept_start(c, link).expect("accept"); + addr + }; + assert!(crate::turnloop_net::live_handles() >= 1); + drop(scope); + // Prove marking reaches the unreferenced owner through the pinned cell: + // after a sweep, a stale-but-not-reused owner would otherwise look alive. + let owner_addr = + (unsafe { np::link_event_owner(link) }.unwrap().to_bits() & POINTER_MASK) as usize; + clear_marks(); + clear_mark_seeds(); + let valid_ptrs = build_valid_pointer_set(); + mark_mutable_registered_roots(&valid_ptrs); + drain_incremental_mark_barrier_seeds(&valid_ptrs); + assert_ne!( + unsafe { (*header_from_user_ptr(owner_addr as *const u8)).gc_flags } & GC_FLAG_MARKED, + 0, + "the pinned cell must mark its otherwise unreferenced owner" + ); + clear_marks(); + clear_mark_seeds(); + let trace = collect_minor_trace(GcTriggerKind::MallocCount); + assert!(trace.copying_nursery.eligible); + full(); + full(); + assert_eq!( + DROPS.load(Ordering::SeqCst), + 0, + "a payload holding a driver handle must survive every collection" + ); + assert_eq!(refs(link), 1, "the installed handle holds exactly one ref"); + let before_finalized = finalized(); + + INSTALL_ACCEPTED.with(|f| f.set(true)); + let _client = std::net::TcpStream::connect(addr).expect("connect to the listener"); + assert!( + pump_until(|| count(NET_ACCEPT, link) == 1), + "the accept must reach the unreferenced listener" + ); + let accept = last(NET_ACCEPT, link).unwrap(); + assert_eq!( + accept.owner, + unsafe { np::link_event_owner(link) }.map(f64::to_bits) + ); + let child = OwnerLink(CHILD.with(Cell::get)); + assert_ne!(child.0, 0, "the sink installed the connection"); + assert_eq!(refs(child), 1, "the accepted handle pins its own payload"); + full(); + assert_eq!(DROPS.load(Ordering::SeqCst), 0); + + // Rule 3: close moves each handle into the driver's close, and the + // payload is released at once; the cell stays while the Closed is owed. + assert!(destroy(link)); + assert!(destroy(child)); + assert_eq!(DROPS.load(Ordering::SeqCst), 2, "release drops T now"); + assert_eq!(refs(link), 1, "the owed Closed keeps its ref after release"); + full(); + assert_eq!(finalized(), before_finalized, "no cell dies while owed"); + assert!( + pump_until(|| count(NET_CLOSED, link) == 1 && count(NET_CLOSED, child) == 1), + "both Closed completions must be dispatched" + ); + assert!(last(NET_CLOSED, link).unwrap().owner.is_some()); + assert_eq!(refs(link), 0); + assert_eq!(refs(child), 0); + full(); + assert_eq!( + finalized(), + before_finalized + 2, + "after the last terminal completion the cycles are garbage" + ); + assert_eq!( + DROPS.load(Ordering::SeqCst), + 2, + "the drop ran exactly once each" + ); +} + +/// The cb-net-2 blocker witness (N5): `close(); listen()` reopens the same +/// object; the first listener's `Closed`, dispatched after the reopen, is +/// delivered once as that close and leaves the new listener untouched. +#[test] +fn n5_a_stale_closed_after_reopen_leaves_the_new_listener_alone() { + let _guard = CopyingNurseryTestGuard::new(0); + let _fixture = Fixture::start(); + let _trigger = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + let scope = RuntimeHandleScope::new(); + let value = scope.root_nanbox_f64(fresh()); + let link = np::owner_link(value.get_nanbox_f64(), &FAMILY).unwrap(); + unsafe { + let c = core(value.get_nanbox_f64()); + transport::tcp_listen(c, link, "127.0.0.1:0".parse().unwrap(), 16, false, true).unwrap(); + transport::accept_start(c, link).unwrap(); + } + assert!(destroy(link)); + let reopened = np::attach( + value.get_nanbox_f64(), + &FAMILY, + Payload { + core: TransportCore::new(ROUTE), + ext: Ext, + }, + 0, + ); + assert_eq!(reopened, Ok(())); + let addr = unsafe { + let c = core(value.get_nanbox_f64()); + let addr = transport::tcp_listen(c, link, "127.0.0.1:0".parse().unwrap(), 16, false, true) + .unwrap(); + transport::accept_start(c, link).unwrap(); + addr + }; + assert_eq!(refs(link), 2, "the owed Closed and the new handle"); + let handle = unsafe { (*core(value.get_nanbox_f64())).handle() }; + assert!(handle.is_some()); + assert!( + pump_until(|| count(NET_CLOSED, link) == 1), + "the first listener's Closed must be dispatched" + ); + assert_eq!( + unsafe { (*core(value.get_nanbox_f64())).handle() }, + handle, + "a stale Closed must not touch the reopened listener" + ); + assert_eq!(refs(link), 1); + let _client = std::net::TcpStream::connect(addr).unwrap(); + assert!( + pump_until(|| count(NET_ACCEPT, link) == 1), + "the reopened listener still accepts" + ); + assert!(destroy(link)); + assert!(pump_until(|| count(NET_CLOSED, link) == 2)); + assert_eq!(refs(link), 0); +} + +/// Runtime half of N8: the original multishot read token survives a route +/// store, and bytes already sent on that read reach the new sink in order. +#[test] +fn n8_a_route_store_keeps_the_same_multishot_read() { + use std::io::Write; + let _guard = CopyingNurseryTestGuard::new(0); + let _fixture = Fixture::start(); + let _trigger = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + assert!(crate::turnloop_net::register_link_sink( + ROUTE + 1, + alternate_sink + )); + let scope = RuntimeHandleScope::new(); + let server = scope.root_nanbox_f64(fresh()); + let link = np::owner_link(server.get_nanbox_f64(), &FAMILY).unwrap(); + let addr = unsafe { + let c = core(server.get_nanbox_f64()); + let addr = transport::tcp_listen(c, link, "127.0.0.1:0".parse().unwrap(), 16, false, true) + .unwrap(); + transport::accept_start(c, link).unwrap(); + addr + }; + INSTALL_ACCEPTED.with(|flag| flag.set(true)); + let mut peer = std::net::TcpStream::connect(addr).unwrap(); + assert!(pump_until(|| CHILD.with(Cell::get) != 0)); + let child = OwnerLink(CHILD.with(Cell::get)); + let owner = scope.root_nanbox_f64(unsafe { np::link_event_owner(child) }.unwrap()); + unsafe { + let c = core(owner.get_nanbox_f64()); + transport::read_start(c, child).unwrap(); + let read = (*c).read_operation(); + assert!(read.is_some(), "subject must have a multishot read"); + peer.write_all(b"head+same-packet").unwrap(); + transport::set_route(c, child, ROUTE + 1).unwrap(); + assert_eq!( + (*c).read_operation(), + read, + "upgrade must not replace its read" + ); + } + assert!(pump_until(|| EVENTS.with(|events| events + .borrow() + .iter() + .filter(|e| e.link == child.0) + .map(|e| e.bytes.len()) + .sum::() + >= 16))); + EVENTS.with(|events| { + let events = events.borrow(); + let reads: Vec<_> = events + .iter() + .filter(|e| e.link == child.0 && e.kind == NET_DATA) + .collect(); + assert!( + reads.iter().all(|e| e.rerouted), + "old read tokens dispatch through the current route" + ); + assert_eq!( + reads + .iter() + .flat_map(|e| e.bytes.iter().copied()) + .collect::>(), + b"head+same-packet" + ); + }); + assert!(destroy(child)); + assert!(destroy(link)); + assert!(pump_until(|| refs(child) == 0 && refs(link) == 0)); +} + +/// N9: a cancelled resolver cannot clear the reopened socket's plan, and +/// pre-connect writes survive localhost's v6-to-v4 fallback. +#[test] +fn n9_a_stale_resolve_leaves_the_new_plan_and_backlog_alone() { + use std::io::Read; + let _guard = CopyingNurseryTestGuard::new(0); + let _fixture = Fixture::start(); + let _trigger = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + listener.set_nonblocking(true).unwrap(); + let port = listener.local_addr().unwrap().port(); + let scope = RuntimeHandleScope::new(); + let socket = scope.root_nanbox_f64(fresh()); + let link = np::owner_link(socket.get_nanbox_f64(), &FAMILY).unwrap(); + unsafe { + transport::tcp_connect(core(socket.get_nanbox_f64()), link, "localhost", port, true) + .unwrap(); + } + assert_eq!(refs(link), 1, "the old resolver is owed before any turn"); + assert!(!destroy(link), "resolving socket has no handle yet"); + np::attach( + socket.get_nanbox_f64(), + &FAMILY, + Payload { + core: TransportCore::new(ROUTE), + ext: Ext, + }, + 0, + ) + .unwrap(); + unsafe { + let c = core(socket.get_nanbox_f64()); + transport::tcp_connect(c, link, "localhost", port, true).unwrap(); + transport::write(c, link, b"pre-connect".to_vec(), 1).unwrap(); + } + assert!( + pump_until(|| count(NET_CONNECT, link) == 1), + "new resolver must connect despite the old terminal completion" + ); + assert!( + pump_until(|| count(crate::turnloop_net::NET_WROTE, link) == 1), + "pre-connect write must leave the backlog" + ); + let (mut peer, _) = listener + .accept() + .expect("only the new plan reaches the v4 listener"); + peer.set_read_timeout(Some(Duration::from_secs(2))).unwrap(); + let mut bytes = [0; 11]; + peer.read_exact(&mut bytes).unwrap(); + assert_eq!(&bytes, b"pre-connect"); + assert!(destroy(link)); + assert!(pump_until(|| refs(link) == 0)); +} + +/// N11: the pipe server path. `close()`, a synchronous unlink (the binding's +/// job), then `listen(samePath)`: the old `Closed` neither disturbs the new +/// listener nor its socket file. +#[cfg(unix)] +#[test] +fn n11_a_pipe_server_relistens_on_its_path_across_a_stale_closed() { + let _guard = CopyingNurseryTestGuard::new(0); + let _fixture = Fixture::start(); + let _trigger = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + let dir = std::env::temp_dir().join(format!("perry-netp0-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("n11.sock"); + let _ = std::fs::remove_file(&path); + let scope = RuntimeHandleScope::new(); + let value = scope.root_nanbox_f64(fresh()); + let link = np::owner_link(value.get_nanbox_f64(), &FAMILY).unwrap(); + unsafe { + let c = core(value.get_nanbox_f64()); + transport::pipe_listen(c, link, &path, 16).unwrap(); + transport::accept_start(c, link).unwrap(); + } + assert!(destroy(link)); + std::fs::remove_file(&path).expect("the binding unlinks at close, synchronously"); + np::attach( + value.get_nanbox_f64(), + &FAMILY, + Payload { + core: TransportCore::new(ROUTE), + ext: Ext, + }, + 0, + ) + .unwrap(); + unsafe { + let c = core(value.get_nanbox_f64()); + transport::pipe_listen(c, link, &path, 16).unwrap(); + transport::accept_start(c, link).unwrap(); + } + assert!(pump_until(|| count(NET_CLOSED, link) == 1)); + assert!( + path.exists(), + "the old Closed must not remove the new socket" + ); + assert!(unsafe { (*core(value.get_nanbox_f64())).handle() }.is_some()); + let _client = std::os::unix::net::UnixStream::connect(&path).expect("connect to the new path"); + assert!(pump_until(|| count(NET_ACCEPT, link) == 1)); + assert!(destroy(link)); + assert!(pump_until(|| count(NET_CLOSED, link) == 2)); + let _ = std::fs::remove_dir_all(&dir); +} + +/// N7: dropping a payload never reaches the loop, from the sweep or from an +/// explicit release (the cb-net blocker's "record Drop releases listener" +/// witness becomes this guard). +#[test] +fn n7_dropping_a_transport_payload_never_reaches_the_driver() { + let _guard = CopyingNurseryTestGuard::new(0); + let _fixture = Fixture::start(); + let _no_stack = ConservativeScanDisabledGuard::new(); + let before = finalized(); + { + let scope = RuntimeHandleScope::new(); + let _never_connected = scope.root_nanbox_f64(fresh()); + let released = scope.root_nanbox_f64(fresh()); + let _forbid = transport::ForbidDriver::new(); + assert_eq!( + np::close(released.get_nanbox_f64(), &FAMILY), + CloseOutcome::Closed + ); + } + assert_eq!(DROPS.load(Ordering::SeqCst), 1); + { + let _forbid = transport::ForbidDriver::new(); + full(); + } + assert_eq!( + DROPS.load(Ordering::SeqCst), + 2, + "the sweep dropped the other" + ); + assert_eq!(finalized(), before + 2); +} + +/// N10: a moving collection between submission and completion moves the +/// owner; the token still names the cell, and the event reaches the moved +/// object (the pinned cell's owner edge is rewritten). +#[test] +fn n10_a_moved_owner_still_receives_its_events() { + let _guard = CopyingNurseryTestGuard::new(1); + let _fixture = Fixture::start(); + let _trigger = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + let scope = RuntimeHandleScope::new(); + let value = scope.root_nanbox_f64(fresh()); + let link = np::owner_link(value.get_nanbox_f64(), &FAMILY).unwrap(); + let addr = unsafe { + let c = core(value.get_nanbox_f64()); + let addr = transport::tcp_listen(c, link, "127.0.0.1:0".parse().unwrap(), 16, false, true) + .unwrap(); + transport::accept_start(c, link).unwrap(); + addr + }; + let before = value.get_nanbox_f64().to_bits(); + let trace = collect_minor_trace(GcTriggerKind::MallocCount); + assert!(trace.copying_nursery.eligible); + let moved = value.get_nanbox_f64().to_bits(); + assert_ne!(before, moved, "the fixture must move its owner"); + let _client = std::net::TcpStream::connect(addr).unwrap(); + assert!(pump_until(|| count(NET_ACCEPT, link) == 1)); + assert_eq!( + last(NET_ACCEPT, link).unwrap().owner, + Some(value.get_nanbox_f64().to_bits()), + "the event must reach the moved owner" + ); + assert!(destroy(link)); + assert!(pump_until(|| count(NET_CLOSED, link) == 1)); +} + +/// N6 / L8 (runtime half): a worker agent going away with a `Closed` still +/// owed runs no JS for it and dereferences no token; its heap teardown then +/// finalizes the cell despite the ref. +#[test] +fn n6_teardown_discards_owed_completions_without_dispatch() { + let _guard = CopyingNurseryTestGuard::new(0); + let before = finalized(); + let (dispatched, discarded) = std::thread::spawn(|| { + let _fixture = Fixture::start(); + let scope = RuntimeHandleScope::new(); + let value = scope.root_nanbox_f64(fresh()); + let link = np::owner_link(value.get_nanbox_f64(), &FAMILY).unwrap(); + unsafe { + let c = core(value.get_nanbox_f64()); + transport::tcp_listen(c, link, "127.0.0.1:0".parse().unwrap(), 16, false, true) + .unwrap(); + } + assert!(destroy(link)); + drop(scope); + // The agent's teardown sets the discard rule before its last turns. + transport::begin_teardown_for_test(); + assert!( + pump_until(|| transport::discarded_for_test() + count(NET_CLOSED, link) >= 1), + "the owed Closed must arrive during teardown, or the witness is vacuous" + ); + crate::event_pump::shutdown_agent_loop(); + (count(NET_CLOSED, link), transport::discarded_for_test()) + }) + .join() + .unwrap(); + assert_eq!(dispatched, 0, "teardown must discard, never dispatch"); + assert!(discarded >= 1); + assert_eq!( + finalized(), + before + 1, + "the worker heap teardown finalizes the pinned cell" + ); + assert_eq!(DROPS.load(Ordering::SeqCst), 1); +} + +/// The ABI digest folds the opaque core block: a binding built against +/// another block size computes another digest and is refused at +/// registration (perry-ffi's `register_*_sink`). The block is tight on the +/// platforms this test runs on, so the struct and the block move together. +#[test] +fn the_net_abi_digest_folds_the_transport_core_block() { + use crate::turnloop_net::abi::{js_perry_net_abi_layout, net_abi_layout}; + use crate::turnloop_net::TRANSPORT_CORE_WORDS; + assert_eq!( + js_perry_net_abi_layout(), + net_abi_layout(TRANSPORT_CORE_WORDS) + ); + assert_ne!( + net_abi_layout(TRANSPORT_CORE_WORDS), + net_abi_layout(TRANSPORT_CORE_WORDS + 1) + ); + assert_ne!( + net_abi_layout(TRANSPORT_CORE_WORDS), + net_abi_layout(TRANSPORT_CORE_WORDS - 1) + ); + let size = std::mem::size_of::(); + eprintln!("TransportCore: {size} bytes, block {TRANSPORT_CORE_WORDS} words"); + #[cfg(not(windows))] + assert_eq!( + size.div_ceil(8), + TRANSPORT_CORE_WORDS, + "the reserved block must match the core exactly on this platform" + ); + assert_eq!(std::mem::offset_of!(Payload, core), 0, "core first"); +} + +#[test] +fn every_net_transport_sabotage_makes_its_witness_red() { + let exe = std::env::current_exe().unwrap(); + for (var, fault, witness) in [ + ( + "PERRY_TEST_NET_SABOTAGE", + "skip_ref", + "n1_a_handle_holding_payload_lives_until_its_closed_is_dispatched", + ), + ( + "PERRY_TEST_CALLBACK_SABOTAGE", + "mark", + "n1_a_handle_holding_payload_lives_until_its_closed_is_dispatched", + ), + ( + "PERRY_TEST_NET_SABOTAGE", + "plan_check", + "n9_a_stale_resolve_leaves_the_new_plan_and_backlog_alone", + ), + ( + "PERRY_TEST_NET_SABOTAGE", + "route_resubmit", + "n8_a_route_store_keeps_the_same_multishot_read", + ), + ( + "PERRY_TEST_NET_SABOTAGE", + "handle_check", + "n5_a_stale_closed_after_reopen_leaves_the_new_listener_alone", + ), + ( + "PERRY_TEST_NET_SABOTAGE", + "handle_check", + "n11_a_pipe_server_relistens_on_its_path_across_a_stale_closed", + ), + ( + "PERRY_TEST_NET_SABOTAGE", + "drop_closes", + "n7_dropping_a_transport_payload_never_reaches_the_driver", + ), + ( + "PERRY_TEST_CALLBACK_SABOTAGE", + "rewrite", + "n10_a_moved_owner_still_receives_its_events", + ), + ( + "PERRY_TEST_NET_SABOTAGE", + "teardown_dispatch", + "n6_teardown_discards_owed_completions_without_dispatch", + ), + ] { + if cfg!(not(unix)) && witness.starts_with("n11") { + continue; + } + let name = format!("gc::tests::net_transport::{witness}"); + let output = std::process::Command::new(&exe) + .args(["--exact", &name, "--nocapture", "--test-threads=1"]) + .env(var, fault) + .output() + .unwrap(); + assert!( + String::from_utf8_lossy(&output.stdout).contains("running 1 test"), + "{witness} must exist" + ); + assert!( + !output.status.success(), + "{var}={fault} must make {witness} RED" + ); + eprintln!( + "net transport sabotage {var}={fault}: {witness} RED ({})", + output.status + ); + } +} diff --git a/crates/perry-runtime/src/native_payload.rs b/crates/perry-runtime/src/native_payload.rs index b94d68b988..4b98f13f26 100644 --- a/crates/perry-runtime/src/native_payload.rs +++ b/crates/perry-runtime/src/native_payload.rs @@ -862,44 +862,68 @@ fn attach_cell( vtable: &'static PayloadVTable, external_bytes: usize, ) -> Result<(), AttachMiss> { - unsafe { - if (*cell).magic != crate::native_handle::NATIVE_HANDLE_MAGIC - || (*cell).creator_thread_id != crate::native_handle::current_thread_id() - { - return Err(AttachMiss::Foreign); - } - if (*cell).finalized != 0 && !lifecycle_sabotage("attach_finalized") { - return Err(AttachMiss::Finalized); - } - if (*cell).flags & CLOSING != 0 || (*cell).busy != 0 { - return Err(AttachMiss::Closing); - } - if !(*cell).resource_ptr.is_null() { - return Err(AttachMiss::Open); - } - let tag = type_tag::(family.class_id); - if (*cell).type_id != family.class_id as u64 - && ((*cell).type_id != tag - || crate::native_handle::cell_drop_fn(cell).map(|f| f as usize) - != Some(vtable.drop as usize)) - { - return Err(AttachMiss::Foreign); - } - // alloc_closed cannot know T. Establish the layout/vtable on first - // attach; subsequent opens keep both unchanged. - if (*cell).type_id == family.class_id as u64 { - (*cell).type_id = tag; - (*cell).finalizer = vtable as *const PayloadVTable as *mut c_void; - (*cell).flags |= VTABLE_WORD; - } - let resource = Box::into_raw(Box::new(payload)) as *mut c_void; - crate::native_handle::native_handle_attach_rust_payload(cell, resource); - // Root the owner before reporting bytes: pacing can collect. The - // payload is already installed and reachable through the owner. - let scope = crate::gc::RuntimeHandleScope::new(); - let _owner = scope.root_nanbox_f64(value); - crate::native_handle::native_handle_set_external_bytes(cell, external_bytes); + let resource = Box::into_raw(Box::new(payload)) as *mut c_void; + let result = unsafe { + attach_external_cell( + value, + cell, + type_tag::(family.class_id), + resource, + vtable, + external_bytes, + ) + }; + if result.is_err() { + unsafe { drop(Box::from_raw(resource as *mut T)) }; } + result +} + +/// Shared CLOSED -> OPEN installation for in-tree and C-ABI families. +/// Ownership of resource transfers only on success. +/// # Safety +/// cell is a live cell, resource has tag's layout and vtable's payload type. +pub(crate) unsafe fn attach_external_cell( + value: f64, + cell: *mut NativeHandleHeader, + tag: u64, + resource: *mut c_void, + vtable: &'static PayloadVTable, + external_bytes: usize, +) -> Result<(), AttachMiss> { + if (*cell).magic != crate::native_handle::NATIVE_HANDLE_MAGIC + || (*cell).creator_thread_id != crate::native_handle::current_thread_id() + { + return Err(AttachMiss::Foreign); + } + if (*cell).finalized != 0 && !lifecycle_sabotage("attach_finalized") { + return Err(AttachMiss::Finalized); + } + if (*cell).flags & CLOSING != 0 || (*cell).busy != 0 { + return Err(AttachMiss::Closing); + } + if !(*cell).resource_ptr.is_null() { + return Err(AttachMiss::Open); + } + + if (*cell).type_id != tag as u32 as u64 + && ((*cell).type_id != tag + || crate::native_handle::cell_drop_fn(cell).map(|f| f as usize) + != Some(vtable.drop as usize)) + { + return Err(AttachMiss::Foreign); + } + // alloc_closed cannot know T. Establish the layout/vtable on first + // attach; subsequent opens keep both unchanged. + if (*cell).type_id == tag as u32 as u64 { + (*cell).type_id = tag; + (*cell).finalizer = vtable as *const PayloadVTable as *mut c_void; + (*cell).flags |= VTABLE_WORD; + } + crate::native_handle::native_handle_attach_rust_payload(cell, resource); + let scope = crate::gc::RuntimeHandleScope::new(); + let _owner = scope.root_nanbox_f64(value); + crate::native_handle::native_handle_set_external_bytes(cell, external_bytes); Ok(()) } @@ -954,6 +978,16 @@ pub fn close(value: f64, family: &NativePayloadFamily) -> CloseOutcome { #[repr(transparent)] pub struct OwnerLink(pub(crate) usize); +/// Cell addresses carried in transport tokens must fit below 2^56. +pub(crate) const LINK_ADDRESS_LIMIT: u64 = 1 << 56; + +/// The OPEN payload behind a live owner-thread link. No allocation or JS. +/// # Safety +/// The cell is kept alive by an outstanding resource or link_ref. +pub(crate) unsafe fn link_open_payload(link: OwnerLink) -> *mut c_void { + crate::native_handle::rust_payload_ptr_on_owner_thread(link.0 as *mut NativeHandleHeader) +} + /// Obtain a link in any non-finalized state with the traced owner edge enabled. pub fn owner_link(value: f64, family: &NativePayloadFamily) -> Result { if !family.links_owner { @@ -971,6 +1005,10 @@ pub fn owner_link(value: f64, family: &NativePayloadFamily) -> Result f64 { js_state_for_class(value, family.class_id, create) } -fn js_state_for_class(value: f64, class_id: u32, create: bool) -> f64 { +pub(crate) fn js_state_for_class(value: f64, class_id: u32, create: bool) -> f64 { let undefined = undefined(); let Some(obj) = instance_of(value, class_id) else { return undefined; diff --git a/crates/perry-runtime/src/native_payload_abi.rs b/crates/perry-runtime/src/native_payload_abi.rs index 521339abad..8c988ac4a2 100644 --- a/crates/perry-runtime/src/native_payload_abi.rs +++ b/crates/perry-runtime/src/native_payload_abi.rs @@ -673,3 +673,7 @@ pub unsafe extern "C" fn js_perry_payload_external_bytes( crate::native_handle::native_handle_set_external_bytes(cell, bytes); } } + +#[path = "native_payload_transport_abi.rs"] +mod transport; +pub use transport::*; diff --git a/crates/perry-runtime/src/native_payload_transport_abi.rs b/crates/perry-runtime/src/native_payload_transport_abi.rs new file mode 100644 index 0000000000..89b3dc4325 --- /dev/null +++ b/crates/perry-runtime/src/native_payload_transport_abi.rs @@ -0,0 +1,310 @@ +//! Owner links and reopen for asynchronous binding families, using the same +//! typed vtable and cell as the stream/payload ABI. +use super::*; +use crate::native_payload as np; + +/// Reopen a closed typed cell. Caller retains resource on refusal. +/// # Safety +/// Same resource/descriptor contract as js_perry_payload_attach. +#[no_mangle] +pub unsafe extern "C" fn js_perry_payload_reopen( + value: f64, + family: *const PerryPayloadFamily, + resource: *mut c_void, + bytes: usize, +) -> i32 { + let Some(family) = checked_family(family) else { + return -1; + }; + let Some(cell) = family_cell(value, family) else { + return -1; + }; + if resource.is_null() { + return -1; + } + match np::attach_external_cell( + value, + cell, + family_type_id(family), + resource, + &*family.vtable, + bytes, + ) { + Ok(()) => 0, + Err(np::AttachMiss::Foreign) => -1, + Err(np::AttachMiss::Open) => 5, + Err(np::AttachMiss::Closing) => 6, + Err(np::AttachMiss::Finalized) => 7, + } +} + +/// OPEN=1, CLOSING=2, CLOSED=3; foreign=-1, finalized/wrong-thread=-2. +/// # Safety +/// family is a static descriptor. +#[no_mangle] +pub unsafe extern "C" fn js_perry_payload_lifecycle( + value: f64, + family: *const PerryPayloadFamily, +) -> i32 { + let Some(family) = checked_family(family) else { + return -1; + }; + let Some(cell) = family_cell(value, family) else { + return -1; + }; + if (*cell).finalized != 0 + || (*cell).creator_thread_id != crate::native_handle::current_thread_id() + { + return -2; + } + if (*cell).flags & np::CLOSING != 0 { + 2 + } else if (*cell).resource_ptr.is_null() { + 3 + } else { + 1 + } +} + +/// Stable owner link, or zero for a foreign/finalized/wrong-thread/N cell. +/// # Safety +/// family is a static descriptor. +#[no_mangle] +pub unsafe extern "C" fn js_perry_payload_owner_link( + value: f64, + family: *const PerryPayloadFamily, +) -> usize { + let Some(family) = checked_family(family) else { + return 0; + }; + if family.links_owner == 0 { + return 0; + } + let Some(cell) = family_cell(value, family) else { + return 0; + }; + if np::link_event_owner(np::OwnerLink(cell as usize)).is_none() { + return 0; + } + assert!( + (cell as u64) < np::LINK_ADDRESS_LIMIT && cell as usize & 7 == 0, + "a payload cell must be 8-aligned below 2^56 to travel in a link token" + ); + cell as usize +} + +/// Retain a live owner-thread link until its terminal completion. +/// # Safety +/// link names a live cell on this thread. +#[no_mangle] +pub unsafe extern "C" fn js_perry_payload_link_ref(link: usize) { + np::link_ref(np::OwnerLink(link)); +} +/// Release one outstanding completion's reference. +/// # Safety +/// link names a live cell with a matching ref on this thread. +#[no_mangle] +pub unsafe extern "C" fn js_perry_payload_link_unref(link: usize) { + np::link_unref(np::OwnerLink(link)); +} +/// Fetch an event owner even after close; never allocates or throws. +/// # Safety +/// link is live on this thread and out is writable. +#[no_mangle] +pub unsafe extern "C" fn js_perry_payload_link_event_owner(link: usize, out: *mut f64) -> i32 { + if link == 0 || out.is_null() { + return 0; + } + match np::link_event_owner(np::OwnerLink(link)) { + Some(owner) => { + // GC_STORE_AUDIT(STACK): caller's writable out-param. This helper + // cannot allocate; the caller roots the owner before any safepoint. + out.write(owner); + 1 + } + None => 0, + } +} +/// The traced JS-state object; no JS value belongs in the native payload. +/// # Safety +/// family is a static descriptor. +#[no_mangle] +pub unsafe extern "C" fn js_perry_payload_js_state( + value: f64, + family: *const PerryPayloadFamily, + create: i32, +) -> f64 { + let Some(family) = checked_family(family) else { + return bytes_undefined(); + }; + if family_cell(value, family).is_none() { + return bytes_undefined(); + } + // family_cell brands source subclasses by the attached cell. JS state is + // owned by the actual object, whose class id can differ from the family. + let obj = np::any_object(value).unwrap(); + np::js_state_for_class(value, (*obj).class_id, create != 0) +} + +#[cfg(feature = "keepalive-anchors")] +mod keepalive { + use super::*; + #[used(compiler)] + static REOPEN: unsafe extern "C" fn(f64, *const PerryPayloadFamily, *mut c_void, usize) -> i32 = + js_perry_payload_reopen; + #[used(compiler)] + static LIFECYCLE: unsafe extern "C" fn(f64, *const PerryPayloadFamily) -> i32 = + js_perry_payload_lifecycle; + #[used(compiler)] + static OWNER_LINK: unsafe extern "C" fn(f64, *const PerryPayloadFamily) -> usize = + js_perry_payload_owner_link; + #[used(compiler)] + static LINK_REF: unsafe extern "C" fn(usize) = js_perry_payload_link_ref; + #[used(compiler)] + static LINK_UNREF: unsafe extern "C" fn(usize) = js_perry_payload_link_unref; + #[used(compiler)] + static EVENT_OWNER: unsafe extern "C" fn(usize, *mut f64) -> i32 = + js_perry_payload_link_event_owner; + #[used(compiler)] + static JS_STATE: unsafe extern "C" fn(f64, *const PerryPayloadFamily, i32) -> f64 = + js_perry_payload_js_state; +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + per_test_global! { static DROPS: AtomicUsize = AtomicUsize::new(0); } + unsafe extern "C" fn drop_bytes(resource: *mut c_void, _: *mut c_void) { + drop(Box::from_raw(resource as *mut Vec)); + DROPS.fetch_add(1, Ordering::SeqCst); + } + static VTABLE: np::PayloadVTable = np::PayloadVTable { + drop: drop_bytes, + stream: None, + }; + fn family() -> PerryPayloadFamily { + PerryPayloadFamily { + abi: payload_abi_layout(), + class_id: crate::native_class_ids::CRYPTO_HASH, + links_owner: 1, + constructor_length: 1, + _reserved: 0, + name: b"Bytes".as_ptr(), + name_len: 5, + install_prototype: None, + vtable: &VTABLE, + payload_size: std::mem::size_of::>(), + payload_align: std::mem::align_of::>(), + } + } + #[test] + fn binding_reopen_preserves_the_stream_record_cell_and_owner_link() { + DROPS.store(0, Ordering::SeqCst); + let family = family(); + let scope = crate::gc::RuntimeHandleScope::new(); + let proto = scope.root_nanbox_f64(crate::value::js_nanbox_pointer( + crate::object::js_object_alloc(0, 0) as i64, + )); + let owner = scope.root_nanbox_f64(unsafe { + js_perry_payload_alloc(&family, std::ptr::null_mut(), proto.get_nanbox_f64(), 0) + }); + unsafe { + assert_eq!( + js_perry_payload_lifecycle(owner.get_nanbox_f64(), &family), + 3 + ); + let cell = family_cell(owner.get_nanbox_f64(), &family).unwrap(); + let link = js_perry_payload_owner_link(owner.get_nanbox_f64(), &family); + assert_eq!(link, cell as usize); + js_perry_payload_link_ref(link); + let data = Box::into_raw(Box::new(vec![19u8; 8192])); + assert_eq!( + js_perry_payload_reopen(owner.get_nanbox_f64(), &family, data.cast(), 8192), + 0 + ); + crate::node_stream::init_transform_in_place(owner.get_nanbox_f64(), bytes_undefined()); + let obj = np::any_object(owner.get_nanbox_f64()).unwrap(); + let record = (*(*obj).meta).native_state; + assert!( + crate::node_stream::is_stream_record_word(record), + "subject must have a stream state record" + ); + assert_eq!(family_cell(owner.get_nanbox_f64(), &family), Some(cell)); + assert_eq!( + js_perry_payload_get(owner.get_nanbox_f64(), &family), + data.cast() + ); + assert_eq!((*cell).external_bytes, 8192); + let refused = Box::into_raw(Box::new(vec![0u8; 1])); + assert_eq!( + js_perry_payload_reopen(owner.get_nanbox_f64(), &family, refused.cast(), 1), + 5 + ); + drop(Box::from_raw(refused)); + assert_eq!(js_perry_payload_close(owner.get_nanbox_f64(), &family), 0); + assert_eq!(DROPS.load(Ordering::SeqCst), 1); + assert_eq!((*cell).external_bytes, 0); + assert_eq!((*cell).finalized, 0); + let mut event_owner = 0.0; + assert_eq!(js_perry_payload_link_event_owner(link, &mut event_owner), 1); + assert_eq!(event_owner.to_bits(), owner.get_nanbox_f64().to_bits()); + let next = Box::into_raw(Box::new(vec![7u8; 4096])); + assert_eq!( + js_perry_payload_reopen(owner.get_nanbox_f64(), &family, next.cast(), 4096), + 0 + ); + assert_eq!( + js_perry_payload_owner_link(owner.get_nanbox_f64(), &family), + link + ); + let obj = np::any_object(owner.get_nanbox_f64()).unwrap(); + assert_eq!( + (*(*obj).meta).native_state, + record, + "one native_state word; do not replace the stream record" + ); + assert_eq!( + js_perry_payload_get(owner.get_nanbox_f64(), &family), + next.cast() + ); + js_perry_payload_close(owner.get_nanbox_f64(), &family); + js_perry_payload_link_unref(link); + assert_eq!((*cell).refs, 0); + assert_eq!(DROPS.load(Ordering::SeqCst), 2); + } + } + #[test] + fn binding_owner_link_and_js_state_use_the_attached_subclass_cell() { + let family = family(); + let scope = crate::gc::RuntimeHandleScope::new(); + let owner = scope.root_nanbox_f64(crate::value::js_nanbox_pointer( + crate::object::js_object_alloc(123456, 0) as i64, + )); + unsafe { + let data = Box::into_raw(Box::new(vec![19u8; 4])); + assert_eq!( + js_perry_payload_attach(owner.get_nanbox_f64(), &family, data.cast(), 4), + 0 + ); + let cell = family_cell(owner.get_nanbox_f64(), &family).unwrap(); + let link = js_perry_payload_owner_link(owner.get_nanbox_f64(), &family); + assert_eq!(link, cell as usize); + let state = scope.root_nanbox_f64(js_perry_payload_js_state( + owner.get_nanbox_f64(), + &family, + 1, + )); + assert!(np::any_object(state.get_nanbox_f64()).is_some()); + assert_eq!( + state.get_nanbox_f64().to_bits(), + js_perry_payload_js_state(owner.get_nanbox_f64(), &family, 0).to_bits() + ); + assert_eq!(js_perry_payload_close(owner.get_nanbox_f64(), &family), 0); + assert_eq!( + js_perry_payload_lifecycle(owner.get_nanbox_f64(), &family), + 3 + ); + } + } +} diff --git a/crates/perry-runtime/src/turnloop_net/abi.rs b/crates/perry-runtime/src/turnloop_net/abi.rs index 103cc50006..a4f5c3a311 100644 --- a/crates/perry-runtime/src/turnloop_net/abi.rs +++ b/crates/perry-runtime/src/turnloop_net/abi.rs @@ -12,6 +12,7 @@ //! the P1 coexistence contract: a worker agent has no loop until P3/P4, so its //! binding keeps the tokio transport, and the two paths never share a socket. +use std::ffi::c_void; use std::net::SocketAddr; use std::path::PathBuf; @@ -94,9 +95,14 @@ unsafe fn str_arg<'a>(ptr: *const u8, len: usize) -> &'a str { /// Revision of this ABI. Bumped whenever a signature or a struct field /// changes; a binding compiled against a different revision is refused rather /// than allowed to misread a completion. -pub const PERRY_NET_ABI_VERSION: u8 = 2; +/// +/// 3: link routes (NET-TRANSPORT-DESIGN P0): `NetCompletion::flags`, the +/// `js_perry_net_link_*` family and the opaque `TransportCore` block. +pub const PERRY_NET_ABI_VERSION: u8 = 3; -/// A digest of [`NetCompletion`]'s layout plus [`PERRY_NET_ABI_VERSION`]. +/// A digest of [`NetCompletion`]'s layout, the opaque `TransportCore` block +/// a binding reserves ([`super::TRANSPORT_CORE_WORDS`]) and +/// [`PERRY_NET_ABI_VERSION`]. /// /// A binding declares its own `#[repr(C)]` copy of the completion struct — it /// has no Cargo edge to this crate — so the two definitions can drift apart @@ -106,13 +112,23 @@ pub const PERRY_NET_ABI_VERSION: u8 = 2; /// registration instead of a corrupt read. #[no_mangle] pub extern "C" fn js_perry_net_abi_layout() -> u64 { + net_abi_layout(super::TRANSPORT_CORE_WORDS) +} + +/// The digest for a given core block size; perry-ffi computes the same +/// expression from its own struct and its own block constant. Fields, in bit +/// ranges: completion size 12 | `id` 6 | `data` 7 | `code` 7 | `syscall` 7 | +/// completion align 4 | core words 9 | core align 4 | version 8. +pub(crate) const fn net_abi_layout(core_words: usize) -> u64 { use std::mem::{align_of, offset_of, size_of}; - (size_of::() as u64) << 48 - | (offset_of!(NetCompletion, id) as u64) << 40 - | (offset_of!(NetCompletion, data) as u64) << 32 - | (offset_of!(NetCompletion, code) as u64) << 24 - | (offset_of!(NetCompletion, syscall) as u64) << 16 - | (align_of::() as u64) << 8 + (size_of::() as u64 & 0xFFF) << 52 + | (offset_of!(NetCompletion, id) as u64 & 0x3F) << 46 + | (offset_of!(NetCompletion, data) as u64 & 0x7F) << 39 + | (offset_of!(NetCompletion, code) as u64 & 0x7F) << 32 + | (offset_of!(NetCompletion, syscall) as u64 & 0x7F) << 25 + | (align_of::() as u64 & 0xF) << 21 + | (core_words as u64 & 0x1FF) << 12 + | (align_of::() as u64 & 0xF) << 8 | PERRY_NET_ABI_VERSION as u64 } @@ -658,3 +674,496 @@ pub unsafe extern "C" fn js_perry_net_completion_bytes( } c.data } + +// ── Link routes (NET-TRANSPORT-DESIGN P0) ─────────────────────────────────── +// +// Every call takes the payload's `TransportCore` (offset 0 of the binding's +// `TransportPayload`) and its owner link (`js_perry_payload_owner_link`) +// instead of an id. The runtime checks that the link's payload is OPEN on this +// thread and that `core` is its first field before touching either. + +use super::transport::{self, TransportCore}; +use crate::native_payload::OwnerLink; + +/// Install a link-routed binding's completion sink (no id allocator: the sink +/// installs accepted connections itself). Returns nonzero on success. +#[no_mangle] +pub extern "C" fn js_perry_net_register_link_sink(subsystem: i32, sink: SinkFn) -> i32 { + if subsystem < 0 || subsystem as usize >= super::MAX_SUBSYSTEMS { + return 0; + } + i32::from(super::register_link_sink(subsystem as u8, sink)) +} + +/// Construct an idle core for link route `route` in the binding's opaque +/// block. Returns [`PERRY_NET_ERR`] (and writes nothing) for a route outside +/// the sink table. +/// +/// # Safety +/// `core` is writable for `TRANSPORT_CORE_WORDS` words and 8-aligned. +#[no_mangle] +pub unsafe extern "C" fn js_perry_net_core_init(core: *mut c_void, route: i32) -> i32 { + if core.is_null() || route < 0 || route as usize >= super::MAX_SUBSYSTEMS { + return PERRY_NET_ERR; + } + // SAFETY: forwarded contract. + unsafe { transport::core_init(core.cast::(), route as u8) }; + PERRY_NET_OK +} + +/// Drop a core in place. Frees memory only (it runs in a collection, at +/// release or at thread teardown): it never reaches the loop. +/// +/// # Safety +/// `core` was initialized by [`js_perry_net_core_init`] and is dead after. +#[no_mangle] +pub unsafe extern "C" fn js_perry_net_core_drop(core: *mut c_void) { + if !core.is_null() { + // SAFETY: forwarded contract. + unsafe { transport::core_drop(core.cast::()) }; + } +} + +fn link_arg(link: usize) -> OwnerLink { + OwnerLink(link) +} + +/// Link form of [`js_perry_net_tcp_listen`]. +/// +/// # Safety +/// `core`/`link` name one OPEN payload of this thread; `host` as there. +#[no_mangle] +pub unsafe extern "C" fn js_perry_net_link_tcp_listen( + core: *mut c_void, + link: usize, + host: *const u8, + host_len: usize, + port: u16, + backlog: u32, + reuse_port: i32, + nodelay: i32, + err: *mut PerryNetError, +) -> i32 { + // SAFETY: forwarded contract from this function's own safety note. + let host = unsafe { str_arg(host, host_len) }; + let host = if host.is_empty() { "0.0.0.0" } else { host }; + let Ok(addr) = parse_bind_addr(host, port) else { + PerryNetError::write( + err, + NodeError { + code: "EINVAL", + errno: 0, + syscall: "listen", + }, + ); + return PERRY_NET_ERR; + }; + // SAFETY: forwarded contract. + let result = unsafe { + transport::tcp_listen( + core.cast(), + link_arg(link), + addr, + backlog, + reuse_port != 0, + nodelay != 0, + ) + }; + finish(result.map(|_| ()), err) +} + +/// Link form of [`js_perry_net_pipe_listen`]. +/// +/// # Safety +/// As [`js_perry_net_link_tcp_listen`]. +#[no_mangle] +pub unsafe extern "C" fn js_perry_net_link_pipe_listen( + core: *mut c_void, + link: usize, + path: *const u8, + path_len: usize, + backlog: u32, + err: *mut PerryNetError, +) -> i32 { + // SAFETY: forwarded contract. + let path = unsafe { str_arg(path, path_len) }; + finish( + unsafe { + transport::pipe_listen(core.cast(), link_arg(link), &PathBuf::from(path), backlog) + }, + err, + ) +} + +/// Link form of [`js_perry_net_accept_start`]. +/// +/// # Safety +/// As [`js_perry_net_link_tcp_listen`]. +#[no_mangle] +pub unsafe extern "C" fn js_perry_net_link_accept_start( + core: *mut c_void, + link: usize, + err: *mut PerryNetError, +) -> i32 { + finish( + unsafe { transport::accept_start(core.cast(), link_arg(link)) }, + err, + ) +} + +/// Install the connection of the accept completion the sink is handling into +/// a fresh payload's core (+1 ref on that payload's cell). A connection the +/// sink does not install is closed by the runtime when the sink returns. +/// +/// # Safety +/// As [`js_perry_net_link_tcp_listen`]; `completion` is the sink's argument +/// and the sink call is still on the stack. +#[no_mangle] +pub unsafe extern "C" fn js_perry_net_link_install_accepted( + core: *mut c_void, + link: usize, + completion: *const NetCompletion, + err: *mut PerryNetError, +) -> i32 { + finish( + unsafe { transport::install_accepted(core.cast(), link_arg(link), completion) }, + err, + ) +} + +/// Link form of [`js_perry_net_tcp_connect`]. +/// +/// # Safety +/// As [`js_perry_net_link_tcp_listen`]. +#[no_mangle] +pub unsafe extern "C" fn js_perry_net_link_tcp_connect( + core: *mut c_void, + link: usize, + host: *const u8, + host_len: usize, + port: u16, + nodelay: i32, + err: *mut PerryNetError, +) -> i32 { + // SAFETY: forwarded contract. + let host = unsafe { str_arg(host, host_len) }; + let host = if host.is_empty() { "127.0.0.1" } else { host }; + finish( + unsafe { transport::tcp_connect(core.cast(), link_arg(link), host, port, nodelay != 0) }, + err, + ) +} + +/// Link form of [`js_perry_net_pipe_connect`]. +/// +/// # Safety +/// As [`js_perry_net_link_tcp_listen`]. +#[no_mangle] +pub unsafe extern "C" fn js_perry_net_link_pipe_connect( + core: *mut c_void, + link: usize, + path: *const u8, + path_len: usize, + err: *mut PerryNetError, +) -> i32 { + // SAFETY: forwarded contract. + let path = unsafe { str_arg(path, path_len) }; + finish( + unsafe { transport::pipe_connect(core.cast(), link_arg(link), &PathBuf::from(path)) }, + err, + ) +} + +/// Link form of [`js_perry_net_adopt_stream`]: `socket` is consumed on every +/// outcome. +/// +/// # Safety +/// As [`js_perry_net_link_tcp_listen`] and [`js_perry_net_adopt_stream`]. +#[no_mangle] +pub unsafe extern "C" fn js_perry_net_link_adopt_stream( + core: *mut c_void, + link: usize, + socket: i64, + err: *mut PerryNetError, +) -> i32 { + if socket < 0 { + return finish( + Err(super::map_error( + turnloop::Error::new(turnloop::ErrorKind::InvalidInput), + "adopt", + )), + err, + ); + } + #[cfg(unix)] + { + use std::os::fd::FromRawFd; + // SAFETY: the caller transfers ownership of an open descriptor. + let fd = unsafe { std::os::fd::OwnedFd::from_raw_fd(socket as i32) }; + finish( + unsafe { transport::adopt_stream(core.cast(), link_arg(link), fd) }, + err, + ) + } + #[cfg(windows)] + { + use std::os::windows::io::FromRawSocket; + // SAFETY: the caller transfers ownership of an open socket. + let sock = unsafe { std::os::windows::io::OwnedSocket::from_raw_socket(socket as u64) }; + finish( + unsafe { transport::adopt_stream(core.cast(), link_arg(link), sock) }, + err, + ) + } + #[cfg(not(any(unix, windows)))] + { + let _ = (core, link); + finish( + Err(super::map_error( + turnloop::Error::new(turnloop::ErrorKind::Unsupported), + "adopt", + )), + err, + ) + } +} + +/// Link form of [`js_perry_net_read_start`]. +/// +/// # Safety +/// As [`js_perry_net_link_tcp_listen`]. +#[no_mangle] +pub unsafe extern "C" fn js_perry_net_link_read_start( + core: *mut c_void, + link: usize, + err: *mut PerryNetError, +) -> i32 { + finish( + unsafe { transport::read_start(core.cast(), link_arg(link)) }, + err, + ) +} + +/// Link form of [`js_perry_net_write`] (the bytes are copied). +/// +/// # Safety +/// As [`js_perry_net_link_tcp_listen`] and [`js_perry_net_write`]. +#[no_mangle] +pub unsafe extern "C" fn js_perry_net_link_write( + core: *mut c_void, + link: usize, + bytes: *const u8, + len: usize, + user: u64, + out_queued: *mut usize, + err: *mut PerryNetError, +) -> i32 { + let owned = if bytes.is_null() || len == 0 { + Vec::new() + } else { + // SAFETY: the caller promises a readable range for `len` bytes. + unsafe { std::slice::from_raw_parts(bytes, len) }.to_vec() + }; + match unsafe { transport::write(core.cast(), link_arg(link), owned, user) } { + Ok(queued) => { + if !out_queued.is_null() { + // SAFETY: the caller supplies a writable `usize`. + // GC_STORE_AUDIT(POINTER_FREE): a queued-byte count into a caller out-param. + unsafe { std::ptr::write(out_queued, queued) }; + } + PERRY_NET_OK + } + Err(e) => finish(Err(e), err), + } +} + +/// Link form of [`js_perry_net_shutdown`]. +/// +/// # Safety +/// As [`js_perry_net_link_tcp_listen`]. +#[no_mangle] +pub unsafe extern "C" fn js_perry_net_link_shutdown( + core: *mut c_void, + link: usize, + user: u64, + err: *mut PerryNetError, +) -> i32 { + finish( + unsafe { transport::shutdown(core.cast(), link_arg(link), user) }, + err, + ) +} + +/// Release the core's driver resources (the handle moves into the driver's +/// close). `*out_closed` is 1 when a `NET_CLOSED` completion for this link +/// will follow, 0 when there was no handle. The binding then releases the +/// payload. +/// +/// # Safety +/// As [`js_perry_net_link_tcp_listen`]; `out_closed` null or writable. +#[no_mangle] +pub unsafe extern "C" fn js_perry_net_link_close( + core: *mut c_void, + link: usize, + out_closed: *mut i32, + err: *mut PerryNetError, +) -> i32 { + match unsafe { transport::close(core.cast(), link_arg(link)) } { + Ok(closed) => { + if !out_closed.is_null() { + // SAFETY: caller-supplied writable `i32`. + // GC_STORE_AUDIT(POINTER_FREE): a flag into a caller out-param. + unsafe { std::ptr::write(out_closed, i32::from(closed)) }; + } + PERRY_NET_OK + } + Err(e) => finish(Err(e), err), + } +} + +/// Link form of [`js_perry_net_set_ref`]. +/// +/// # Safety +/// As [`js_perry_net_link_tcp_listen`]. +#[no_mangle] +pub unsafe extern "C" fn js_perry_net_link_set_ref( + core: *mut c_void, + link: usize, + referenced: i32, +) -> i32 { + match unsafe { transport::set_ref(core.cast(), link_arg(link), referenced != 0) } { + Ok(()) => PERRY_NET_OK, + Err(_) => PERRY_NET_ERR, + } +} + +/// Link form of [`js_perry_net_queued_bytes`]. +/// +/// # Safety +/// As [`js_perry_net_link_tcp_listen`]. +#[no_mangle] +pub unsafe extern "C" fn js_perry_net_link_queued_bytes(core: *mut c_void, link: usize) -> usize { + unsafe { transport::queued_bytes(core.cast(), link_arg(link)) } +} + +/// Arm or move the socket's deadline (link form of [`js_perry_net_timer_arm`]). +/// +/// # Safety +/// As [`js_perry_net_link_tcp_listen`]. +#[no_mangle] +pub unsafe extern "C" fn js_perry_net_link_deadline_arm( + core: *mut c_void, + link: usize, + delay_ms: u64, + err: *mut PerryNetError, +) -> i32 { + finish( + unsafe { transport::deadline_arm(core.cast(), link_arg(link), delay_ms) }, + err, + ) +} + +/// Park the socket's deadline (link form of [`js_perry_net_timer_park`]). +/// +/// # Safety +/// As [`js_perry_net_link_tcp_listen`]. +#[no_mangle] +pub unsafe extern "C" fn js_perry_net_link_deadline_park( + core: *mut c_void, + link: usize, + err: *mut PerryNetError, +) -> i32 { + finish( + unsafe { transport::deadline_park(core.cast(), link_arg(link)) }, + err, + ) +} + +/// Cancel the socket's deadline (link form of [`js_perry_net_timer_cancel`]). +/// +/// # Safety +/// As [`js_perry_net_link_tcp_listen`]. +#[no_mangle] +pub unsafe extern "C" fn js_perry_net_link_deadline_cancel( + core: *mut c_void, + link: usize, + err: *mut PerryNetError, +) -> i32 { + finish( + unsafe { transport::deadline_cancel(core.cast(), link_arg(link)) }, + err, + ) +} + +/// Link form of [`js_perry_net_local_address`]. +/// +/// # Safety +/// As [`js_perry_net_link_tcp_listen`] and [`write_addr`]. +#[no_mangle] +pub unsafe extern "C" fn js_perry_net_link_local_address( + core: *mut c_void, + link: usize, + out: *mut u8, + cap: usize, + out_len: *mut usize, + out_port: *mut u16, + out_family: *mut i32, +) -> i32 { + // SAFETY: forwarded contract. + unsafe { + write_addr( + transport::local_addr(core.cast(), link_arg(link)), + out, + cap, + out_len, + out_port, + out_family, + ) + } +} + +/// Link form of [`js_perry_net_peer_address`]. +/// +/// # Safety +/// As [`js_perry_net_link_tcp_listen`] and [`write_addr`]. +#[no_mangle] +pub unsafe extern "C" fn js_perry_net_link_peer_address( + core: *mut c_void, + link: usize, + out: *mut u8, + cap: usize, + out_len: *mut usize, + out_port: *mut u16, + out_family: *mut i32, +) -> i32 { + // SAFETY: forwarded contract. + unsafe { + write_addr( + transport::peer_addr(core.cast(), link_arg(link)), + out, + cap, + out_len, + out_port, + out_family, + ) + } +} + +/// Change the link sink without cancelling or replacing outstanding I/O. +/// # Safety +/// As js_perry_net_link_tcp_listen; route names a registered link sink. +#[no_mangle] +pub unsafe extern "C" fn js_perry_net_link_set_route( + core: *mut c_void, + link: usize, + route: i32, + err: *mut PerryNetError, +) -> i32 { + if !(0..super::MAX_SUBSYSTEMS as i32).contains(&route) { + return finish(Err(super::transport::bad("route")), err); + } + finish( + super::transport::set_route(core.cast(), link_arg(link), route as u8), + err, + ) +} diff --git a/crates/perry-runtime/src/turnloop_net/mod.rs b/crates/perry-runtime/src/turnloop_net/mod.rs index f7ec1732c4..74c9adf3f4 100644 --- a/crates/perry-runtime/src/turnloop_net/mod.rs +++ b/crates/perry-runtime/src/turnloop_net/mod.rs @@ -65,6 +65,7 @@ pub mod abi; pub mod census; pub(crate) mod errors; mod sink; +pub mod transport; #[cfg(windows)] mod windows_pipe; mod write_queue; @@ -74,14 +75,17 @@ mod write_queue; mod tests; pub use errors::{map_error, NodeError}; -pub use sink::{register_sink, sink_installed, NetCompletion, SinkFn, MAX_SUBSYSTEMS}; +pub use sink::{ + register_link_sink, register_sink, sink_installed, NetCompletion, SinkFn, MAX_SUBSYSTEMS, +}; +pub use transport::{TransportCore, TransportPayload, TRANSPORT_CORE_WORDS}; // P6: the completion kinds, for an in-tree subsystem. A separately linked // binding reads them through `perry_ffi::turnloop_net`, which declares its // own copy; perry-stdlib has a Cargo edge to this crate and must not need a // second declaration to keep in step with. pub use sink::{ - NET_ACCEPT, NET_CLOSED, NET_CONNECT, NET_DATA, NET_EOF, NET_ERROR, NET_SHUTDOWN, NET_TIMER, - NET_WROTE, + NET_ACCEPT, NET_CLOSED, NET_CONNECT, NET_DATA, NET_EOF, NET_ERROR, NET_FLAG_LINK, NET_SHUTDOWN, + NET_TIMER, NET_WROTE, }; // ── Operation classes, carried in the top 8 bits of every submission token ── @@ -112,6 +116,26 @@ fn token_parts(t: Token) -> (u64, i64) { ((t.0 >> ID_BITS), (t.0 & ID_MASK) as i64) } +/// How a socket's submissions are named in their tokens: by the binding's id +/// (the id route, phases P1-P7), or by its payload cell (a link route, +/// NET-TRANSPORT-DESIGN). The write queue and the Windows pipe helpers serve +/// both, so they take this rather than an id. +#[derive(Clone, Copy, Debug)] +pub(super) enum Name { + Id(i64), + Link { route: u8, cell: usize }, +} + +impl Name { + #[inline] + fn token(self, op: u64) -> Token { + match self { + Name::Id(id) => token(op, id), + Name::Link { route, cell } => transport::link_token(route, op, cell), + } + } +} + /// The `syscall` string Node reports for a failure of each operation class. fn syscall_for(op: u64) -> &'static str { match op { @@ -279,6 +303,11 @@ struct ConnectPlan { retrying: bool, /// The most recent failure, reported if the list runs out. last_error: Option, + /// The pending resolve of a link-routed plan: its incarnation. A resolve + /// completion whose op is not this one belongs to an earlier connect of + /// the same socket and must not start an attempt. Always `None` on the id + /// route, whose plan map already forgets a superseded plan. + op: Option, } /// A subsystem-owned one-shot deadline (P5). @@ -323,7 +352,7 @@ pub fn live_handles() -> usize { NET.with(|net| { let net = net.borrow(); net.entries.len() + net.plans.len() + net.timers.len() - }) + }) + transport::outstanding() } /// Whether this thread can take the turnloop net path at all. @@ -355,6 +384,8 @@ fn not_found(syscall: &'static str) -> NodeError { /// Run `f` against this agent's driver, creating a net-sized loop first. fn with_driver(f: impl FnOnce(&mut turnloop::Loop) -> R) -> Option { + #[cfg(test)] + transport::assert_driver_access_allowed(); crate::event_pump::with_net_driver(f) } @@ -549,6 +580,7 @@ pub fn tcp_connect_host( remaining: std::collections::VecDeque::new(), retrying: false, last_error: None, + op: None, }, ) }); @@ -840,7 +872,7 @@ pub fn close(id: i64) -> NetResult<()> { entry.closing = true; #[cfg(windows)] { - windows_pipe::cancel_eof(driver, id, entry); + windows_pipe::cancel_eof(driver, Name::Id(id), entry); if let Some(op) = entry.pipe_drain.take() { driver.cancel(op); } @@ -911,6 +943,12 @@ pub fn is_live(id: i64) -> bool { /// never calls host code), so a sink is free to run JS, allocate, collect, and /// submit new operations on the same loop. pub(crate) fn dispatch(completion: Completion) { + // A link token names its payload cell, not an id: route it before the + // id lookups below (the transitional fork; phase D deletes the id route). + if transport::owns(completion.token) { + transport::dispatch(completion); + return; + } let (op_class, id) = token_parts(completion.token); census::note_completion(op_class); let Completion { @@ -1000,7 +1038,7 @@ pub(crate) fn dispatch(completion: Completion) { .entries .get_mut(&id) .ok_or_else(|| Error::new(ErrorKind::InvalidInput))?; - windows_pipe::arm_eof(driver, id, entry) + windows_pipe::arm_eof(driver, Name::Id(id), entry).map(|_| ()) }) }); if !matches!(armed, Some(Ok(()))) { @@ -1074,7 +1112,7 @@ pub(crate) fn dispatch(completion: Completion) { NET.with(|net| { if let Some(entry) = net.borrow_mut().entries.get_mut(&id) { if entry.pipe_eof_timer.is_some() { - let _ = windows_pipe::arm_eof(driver, id, entry); + let _ = windows_pipe::arm_eof(driver, Name::Id(id), entry); } } }) @@ -1091,7 +1129,7 @@ pub(crate) fn dispatch(completion: Completion) { with_driver(|driver| { NET.with(|net| { if let Some(entry) = net.borrow_mut().entries.get_mut(&id) { - windows_pipe::cancel_eof(driver, id, entry); + windows_pipe::cancel_eof(driver, Name::Id(id), entry); } }) }); @@ -1329,6 +1367,7 @@ fn accept_connection(subsystem: u8, server: i64, conn: Handle, peer: Option = NET.with(|net| net.borrow().entries.keys().copied().collect()); for id in ids { let _ = close(id); diff --git a/crates/perry-runtime/src/turnloop_net/sink.rs b/crates/perry-runtime/src/turnloop_net/sink.rs index 53c1e7b575..f1d5eb5fed 100644 --- a/crates/perry-runtime/src/turnloop_net/sink.rs +++ b/crates/perry-runtime/src/turnloop_net/sink.rs @@ -20,7 +20,7 @@ //! allocate JS values. use std::net::SocketAddr; -use std::sync::atomic::{AtomicPtr, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicPtr, Ordering}; use super::NodeError; @@ -84,6 +84,9 @@ pub const NET_SHUTDOWN: i32 = 6; pub const NET_CLOSED: i32 = 7; /// An operation failed; `code`/`errno`/`syscall` carry Node's triple. pub const NET_ERROR: i32 = 8; +/// `NetCompletion::flags`: the completion comes from a link route, so `id` is +/// the payload's owner link and an accept's `conn` is its install slot. +pub const NET_FLAG_LINK: i32 = 1; /// A subsystem-owned deadline expired (P5). `id` names the deadline, which is /// the caller's own id — a connection's, not a socket handle's. pub const NET_TIMER: i32 = 9; @@ -107,12 +110,17 @@ pub struct NetCompletion { /// terminal, and Node does not tear the server down for one — the tokio /// accept loop deliberately did not break on an accept error either. pub terminal: i32, - /// Padding, so the struct's layout is identical on both sides of the ABI - /// without depending on how the compiler packs two trailing i32s. - pub _reserved: i32, - /// The Perry-side id of the socket or listener this concerns. + /// [`NET_FLAG_LINK`] when this completion comes from a link route; zero + /// otherwise. Also keeps the layout independent of how a compiler packs + /// two trailing i32s. + pub flags: i32, + /// The Perry-side id of the socket or listener this concerns. On a link + /// route ([`NET_FLAG_LINK`]): the payload's owner link (its cell address). pub id: i64, - /// For [`NET_ACCEPT`], the newly allocated connection id; else zero. + /// For [`NET_ACCEPT`], the newly allocated connection id; else zero. On a + /// link route: the accepted connection's slot, which the sink hands to + /// `install_accepted` while this call is on the stack (or leaves alone, + /// and the runtime closes the connection). pub conn: i64, /// The caller's write/end completion token, echoed back; zero if none. pub user: u64, @@ -138,7 +146,7 @@ impl NetCompletion { kind, errno: 0, terminal: 0, - _reserved: 0, + flags: 0, id, conn: 0, user: 0, @@ -152,6 +160,12 @@ impl NetCompletion { } } + /// Mark a completion as coming from a link route. + pub(super) fn linked(mut self) -> Self { + self.flags |= NET_FLAG_LINK; + self + } + pub(super) fn connect(id: i64) -> Self { Self::blank(NET_CONNECT, id) } @@ -258,6 +272,10 @@ static SINKS: [AtomicPtr<()>; MAX_SUBSYSTEMS] = [const { AtomicPtr::new(std::ptr::null_mut()) }; MAX_SUBSYSTEMS]; static ALLOCS: [AtomicPtr<()>; MAX_SUBSYSTEMS] = [const { AtomicPtr::new(std::ptr::null_mut()) }; MAX_SUBSYSTEMS]; +/// Whether each slot's sink declared `links`: its sockets are transport cores +/// in payloads, named in tokens by their cell (`transport.rs`), and its +/// completions carry that cell in `id`. Set once, at registration. +static LINKS: [AtomicBool; MAX_SUBSYSTEMS] = [const { AtomicBool::new(false) }; MAX_SUBSYSTEMS]; /// Install a binding's completion sink and accepted-connection id allocator. /// @@ -281,7 +299,7 @@ pub fn register_sink(subsystem: u8, sink: SinkFn, alloc: AllocFn) -> bool { // Re-registering the same sink stays idempotent, which is the documented // contract and what a binding whose module is initialised twice relies on. let held = SINKS[slot].load(Ordering::Acquire); - if !held.is_null() && held != sink as *mut () { + if !held.is_null() && (held != sink as *mut () || LINKS[slot].load(Ordering::Acquire)) { return false; } // Publish the allocator first: an accept completion needs it, and a sink @@ -291,6 +309,34 @@ pub fn register_sink(subsystem: u8, sink: SinkFn, alloc: AllocFn) -> bool { true } +/// Install a link-routed binding's completion sink (NET-TRANSPORT-DESIGN P0). +/// +/// The binding declares `links`: its sockets are [`super::TransportCore`]s +/// inside native payloads, every completion's `id` is the payload's owner link +/// (the cell address), and an accepted connection is installed by the sink +/// into a payload it allocates (`transport::install_accepted`), so there is no +/// id allocator. Refused, like [`register_sink`], for a slot another sink +/// holds, and for a slot already registered without `links`. +pub fn register_link_sink(subsystem: u8, sink: SinkFn) -> bool { + let slot = subsystem as usize; + if slot >= MAX_SUBSYSTEMS { + return false; + } + let held = SINKS[slot].load(Ordering::Acquire); + if !held.is_null() && (held != sink as *mut () || !LINKS[slot].load(Ordering::Acquire)) { + return false; + } + LINKS[slot].store(true, Ordering::Release); + SINKS[slot].store(sink as *mut (), Ordering::Release); + true +} + +/// Whether `subsystem`'s sink was registered with `links`. +#[inline] +pub(super) fn is_link_route(subsystem: u8) -> bool { + (subsystem as usize) < MAX_SUBSYSTEMS && LINKS[subsystem as usize].load(Ordering::Acquire) +} + pub(super) fn emit(subsystem: u8, completion: NetCompletion) { let slot = subsystem as usize; if slot >= MAX_SUBSYSTEMS { diff --git a/crates/perry-runtime/src/turnloop_net/tests.rs b/crates/perry-runtime/src/turnloop_net/tests.rs index 2417b78138..8cbffffe2a 100644 --- a/crates/perry-runtime/src/turnloop_net/tests.rs +++ b/crates/perry-runtime/src/turnloop_net/tests.rs @@ -923,6 +923,7 @@ fn writes_between_connect_attempts_reach_the_attempt_that_succeeds() { remaining: [dead, live].into_iter().collect(), retrying: false, last_error: None, + op: None, }, ) }); diff --git a/crates/perry-runtime/src/turnloop_net/transport.rs b/crates/perry-runtime/src/turnloop_net/transport.rs new file mode 100644 index 0000000000..ffce2b524e --- /dev/null +++ b/crates/perry-runtime/src/turnloop_net/transport.rs @@ -0,0 +1,1600 @@ +//! Transport cores: turnloop sockets owned by native payloads +//! (NET-TRANSPORT-DESIGN P0, #11919). +//! +//! A link-routed socket keeps no entry in this module's id maps. Its state is a +//! [`TransportCore`] at offset 0 of the family's payload ([`TransportPayload`]), +//! and every submission's token names the payload *cell*, so a completion +//! finds its socket with no lookup: +//! +//! ```text +//! token = 0b11 (link tag) | route:4 | op:4 | (cell >> 3):54 +//! dispatch: link = cell; owner = link_event_owner(link)?; core = link_open_payload(link) +//! ``` +//! +//! The cell is malloc'd, never moves and outlives its payload, so the token +//! stays valid across every collection. Three rules make that safe: +//! +//! 1. **The token is the route.** It carries the cell, the operation class and +//! the sink route; no map is consulted. +//! 2. **A live driver resource pins its cell.** Each terminal completion the +//! driver still owes holds one `link_ref` from submission until it is +//! dispatched: a socket or listener handle's `Closed`, a resolve's result, a +//! deadline timer handle's `Closed`. Reads, accepts and writes need no ref: +//! they finish before their handle's `Closed` (DESIGN D4). So the sweep never +//! finalizes a payload that holds a driver handle, `Drop for T` only frees +//! memory, and no completion can reach a freed or reused cell. +//! 3. **Release moves the handle into the close.** [`close`] takes the handle +//! out of the core and submits the driver's close under the cell's token; +//! the binding then releases the payload (`native_payload::close`). The +//! driver owns the descriptor until its `Closed`, which is dispatched +//! through `link_event_owner` (it answers for a CLOSED cell) and unrefs. +//! +//! **Incarnation** is the driver's generational handle: a completion mutates +//! the core only when `core.entry.handle == completion.handle` (the plan's +//! resolve op for a resolve, `core.deadline` for a timer). A stale `Closed` +//! still delivers the owner-level `close` and its unref; any other stale +//! result is dropped, and a stale accept closes the new connection. +//! +//! The id route (`super::dispatch`'s maps) stays as it is until each binding +//! converts; [`owns`] is the transitional fork. + +use std::cell::Cell; +use std::ffi::c_void; +use std::net::SocketAddr; +use std::path::Path; + +use super::*; +use crate::native_payload::{self as np, OwnerLink}; + +/// Bits 63..62 set: a link token. The id route's op classes are 1..=10, the +/// process, pool and posted-job spaces sit at 0x10..=0x3F in the top byte and +/// the JS timer is `u64::MAX`, which no link token equals (its cell bits would +/// name an address at 2^57 - 8, above `LINK_ADDRESS_LIMIT`). +const LINK_TAG: u64 = 0b11 << 62; +const ROUTE_SHIFT: u32 = 58; +const OP_SHIFT: u32 = 54; +const CELL_MASK: u64 = (1 << OP_SHIFT) - 1; +/// A link token naming no cell: for a close whose `Closed` nobody waits on (a +/// refused accepted connection, an expired timer's handle has its own token). +pub(super) const LINK_NULL: Token = Token(LINK_TAG); + +/// Words of the opaque block perry-ffi reserves for a [`TransportCore`] +/// (`perry_ffi::turnloop_net::TransportCore`). Folded into the ABI digest +/// (`js_perry_net_abi_layout`), so a binding built against another block size +/// is refused at registration; the assertion below keeps the core inside it. +#[cfg(not(windows))] +pub const TRANSPORT_CORE_WORDS: usize = 39; +/// Windows' `Entry` carries the pipe drain and EOF-timer fields (48 bytes) +/// and a wider `PathBuf` (8): 46 words by construction, reserved with a +/// two-word margin because no Windows build checked it exactly; the +/// assertion below fails that build if the estimate is short. +#[cfg(windows)] +pub const TRANSPORT_CORE_WORDS: usize = 48; + +const _: () = assert!( + std::mem::size_of::() <= TRANSPORT_CORE_WORDS * 8 + && std::mem::align_of::() <= 8, + "TransportCore outgrew the opaque block perry-ffi reserves: raise TRANSPORT_CORE_WORDS \ + here AND in perry-ffi (the ABI digest then refuses bindings built against the old size)" +); + +/// One socket or listener's transport state, owned by its payload. +/// +/// Plain Rust data: no JS value, no GC pointer, no thread-local, so dropping +/// it only frees memory (the payload rule). A driver `Handle` is a plain +/// generational id; while one is installed the cell is pinned (rule 2), so the +/// sweep never drops a core that names a live descriptor. Worker teardown may +/// drop one that still names a handle: by then `Loop::drop` released it. +pub struct TransportCore { + /// The installed handle and its stream or listener state. `None` while + /// idle, resolving, or after [`close`] took the handle. + entry: Option, + /// Writes and an `end()` waiting for a connect or for the write in flight. + backlog: Backlog, + /// A client connect still choosing an address; `plan.op` is the pending + /// resolve's incarnation. + plan: Option>, + /// The socket's one-shot deadline (server timeouts, `setTimeout`). + deadline: Option, + /// The sink this socket's completions go to. + route: u8, + /// Node's `ref()` state, applied to every handle this core installs. + referenced: bool, +} + +impl TransportCore { + /// An idle core whose completions go to the link sink `route`. + pub fn new(route: u8) -> Self { + Self { + entry: None, + backlog: Backlog::default(), + plan: None, + deadline: None, + route, + referenced: true, + } + } + + /// The handle this core holds now (its incarnation), if any. + pub fn handle(&self) -> Option { + self.entry.as_ref().map(|e| e.handle) + } + + #[cfg(test)] + pub(crate) fn read_operation(&self) -> Option { + self.entry.as_ref().and_then(|e| e.read_op) + } + + /// The sink route. + pub fn route(&self) -> u8 { + self.route + } + + /// Move the socket to another link sink (an HTTP upgrade hands the + /// connection to `net`). Outstanding operations keep their tokens; every + /// completion is delivered to the route current at dispatch. + pub fn set_route(&mut self, route: u8) { + self.route = route; + } +} + +/// A family payload that owns a transport: the core first, so the runtime +/// reads it at offset 0 of the payload a link names. +#[repr(C)] +pub struct TransportPayload { + pub core: TransportCore, + pub ext: E, +} + +/// An accepted connection in flight to the sink (`NetCompletion::conn`). +pub(super) struct AcceptSlot { + conn: Option, + peer: Option, +} + +crate::perry_thread_local! { + /// Set by agent teardown: link completions are discarded, never + /// dereferenced (LIFECYCLE-DESIGN L8). The thread's loop is dropped next + /// and its heap teardown finalizes the cells. + static TEARDOWN: Cell = const { Cell::new(false) }; + /// Driver resources this thread's link routes still hold a ref for: + /// installed handles, pending resolves, deadline timers and pipe jobs. + static LINK_RESOURCES: Cell = const { Cell::new(0) }; +} + +/// Whether `t` is a link token (the transitional fork in `super::dispatch`). +#[inline] +pub(super) fn owns(t: Token) -> bool { + t.0 & LINK_TAG == LINK_TAG +} + +#[inline] +pub(super) fn link_token(route: u8, op: u64, cell: usize) -> Token { + debug_assert!(route < 16 && op < 16 && cell & 7 == 0); + debug_assert!((cell as u64) < np::LINK_ADDRESS_LIMIT); + Token(LINK_TAG | (route as u64) << ROUTE_SHIFT | op << OP_SHIFT | (cell as u64) >> 3) +} + +#[inline] +fn link_parts(t: Token) -> (u8, u64, usize) { + ( + ((t.0 >> ROUTE_SHIFT) & 0xF) as u8, + (t.0 >> OP_SHIFT) & 0xF, + ((t.0 & CELL_MASK) << 3) as usize, + ) +} + +/// Driver resources held by link routes on this thread (`live_handles`). +pub(super) fn outstanding() -> usize { + LINK_RESOURCES.with(Cell::get) +} + +pub(super) fn begin_teardown() { + TEARDOWN.with(|t| t.set(true)); +} + +#[cfg(test)] +pub(super) fn reset_for_test() { + TEARDOWN.with(|t| t.set(false)); + LINK_RESOURCES.with(|n| n.set(0)); +} + +// Test-only: a guard under which any driver access panics. A payload `Drop` +// runs inside a collection or at thread teardown and must never reach the +// loop (N7); the witness drops payloads under this guard. +#[cfg(test)] +crate::perry_thread_local! { + static DRIVER_FORBIDDEN: Cell = const { Cell::new(false) }; + /// Link completions the teardown rule discarded: the witness asserts its + /// subject ran. + static DISCARDED: Cell = const { Cell::new(0) }; +} + +#[cfg(test)] +pub(crate) fn discarded_for_test() -> usize { + DISCARDED.with(Cell::get) +} + +#[cfg(test)] +pub(crate) fn begin_teardown_for_test() { + begin_teardown(); +} + +#[cfg(test)] +pub(super) fn assert_driver_access_allowed() { + assert!( + !DRIVER_FORBIDDEN.with(Cell::get), + "the turnloop driver was reached where it is forbidden (a payload Drop)" + ); +} + +#[cfg(test)] +pub(crate) struct ForbidDriver; + +#[cfg(test)] +impl ForbidDriver { + pub(crate) fn new() -> Self { + DRIVER_FORBIDDEN.with(|f| f.set(true)); + ForbidDriver + } +} + +#[cfg(test)] +impl Drop for ForbidDriver { + fn drop(&mut self) { + DRIVER_FORBIDDEN.with(|f| f.set(false)); + } +} + +/// Sabotage hooks for the P0 witnesses, compiled only into test binaries. +#[inline] +pub(super) fn sabotage(fault: &str) -> bool { + #[cfg(test)] + { + std::env::var("PERRY_TEST_NET_SABOTAGE").as_deref() == Ok(fault) + } + #[cfg(not(test))] + { + let _ = fault; + false + } +} + +/// Take one ref for a driver resource that will end in one terminal +/// completion on this cell. +unsafe fn hold(link: OwnerLink) { + if sabotage("skip_ref") { + return; + } + np::link_ref(link); + LINK_RESOURCES.with(|n| n.set(n.get() + 1)); +} + +/// Drop the ref of a resource whose terminal completion was just dispatched, +/// or that will never produce one. +unsafe fn release(link: OwnerLink) { + np::link_unref(link); + LINK_RESOURCES.with(|n| n.set(n.get().saturating_sub(1))); +} + +pub(super) fn bad(syscall: &'static str) -> NodeError { + map_error(Error::new(ErrorKind::InvalidInput), syscall) +} + +/// Validate a submission's `(core, link)` pair: the link's payload is OPEN on +/// this thread and `core` is its first field, and the core's route is a link +/// sink. +/// +/// # Safety +/// `link` came from `owner_link` on this thread and its cell is alive (the +/// caller holds the owner). +unsafe fn bind<'a>( + core: *mut TransportCore, + link: OwnerLink, + syscall: &'static str, +) -> NetResult<(&'a mut TransportCore, Name)> { + if core.is_null() || link.0 == 0 || np::link_open_payload(link) != core.cast::() { + return Err(bad(syscall)); + } + let c = &mut *core; + if !sink::is_link_route(c.route) { + return Err(bad(syscall)); + } + let name = Name::Link { + route: c.route, + cell: link.0, + }; + Ok((c, name)) +} + +/// The OPEN core behind `link`, for dispatch. +/// +/// # Safety +/// The cell is alive (a ref is held for the completion being dispatched). +unsafe fn core_of<'a>(link: OwnerLink) -> Option<&'a mut TransportCore> { + let p = np::link_open_payload(link); + (!p.is_null()).then(|| &mut *(p as *mut TransportCore)) +} + +/// The OPEN core behind `link` when it still holds `handle`: the incarnation +/// that submitted the operation now completing. +unsafe fn current<'a>(link: OwnerLink, handle: Option) -> Option<&'a mut TransportCore> { + let c = core_of(link)?; + let matches = c.entry.as_ref().is_some_and(|e| Some(e.handle) == handle); + (matches || (c.entry.is_some() && sabotage("handle_check"))).then_some(c) +} + +/// Change only the route; existing multishot tokens keep naming this cell. +/// # Safety +/// Same live payload/link contract as tcp_listen. +pub unsafe fn set_route(core: *mut TransportCore, link: OwnerLink, route: u8) -> NetResult<()> { + let (c, _) = bind(core, link, "route")?; + if !sink::is_link_route(route) { + return Err(bad("route")); + } + c.set_route(route); + #[cfg(test)] + if sabotage("route_resubmit") { + let old = c.entry.as_mut().and_then(|e| e.read_op.take()); + if let Some(old) = old { + let _ = with_driver(|driver| driver.cancel(old)); + read_start(core, link)?; + } + } + Ok(()) +} + +/// Install a handle the driver just created for this core (+1 ref). +unsafe fn install( + driver: &mut turnloop::Loop, + c: &mut TransportCore, + link: OwnerLink, + mut entry: Entry, +) { + if !c.referenced { + let _ = driver.set_ref(entry.handle, false); + entry.referenced = false; + } + c.entry = Some(entry); + hold(link); +} + +fn idle_or(c: &TransportCore, syscall: &'static str) -> NetResult<()> { + if c.entry.is_some() || c.plan.is_some() { + return Err(bad(syscall)); + } + Ok(()) +} + +// ── Submission ────────────────────────────────────────────────────────────── + +/// Bind and listen on a TCP address; returns the actual local address. +/// +/// # Safety +/// See [`bind`]. +pub unsafe fn tcp_listen( + core: *mut TransportCore, + link: OwnerLink, + addr: SocketAddr, + backlog: u32, + reuse_port: bool, + nodelay: bool, +) -> NetResult { + let (c, _) = bind(core, link, "listen")?; + idle_or(c, "listen")?; + with_driver(|driver| { + let opts = listen_opts(backlog, reuse_port, nodelay); + let handle = driver + .tcp_listen(addr, &opts) + .map_err(|e| map_error(e, "listen"))?; + let local = driver.local_addr(handle).unwrap_or(addr); + let mut entry = Entry::new(handle, c.route, true); + entry.local = Some(local); + install(driver, c, link, entry); + Ok(local) + }) + .unwrap_or_else(|| Err(no_loop())) +} + +/// Bind and listen on a Unix-domain socket path or a Windows named pipe. +/// Unlinking a stale or closed socket file is the binding's job, done +/// synchronously at close (a deferred unlink would delete a reopened +/// listener's path). +/// +/// # Safety +/// See [`bind`]. +pub unsafe fn pipe_listen( + core: *mut TransportCore, + link: OwnerLink, + path: &Path, + backlog: u32, +) -> NetResult<()> { + let (c, _) = bind(core, link, "listen")?; + idle_or(c, "listen")?; + with_driver(|driver| { + let opts = ListenOpts { + reuse_port: ReusePort::No, + backlog, + ..ListenOpts::default() + }; + let handle = driver + .pipe_listen(&PipeName(path.to_path_buf()), &opts) + .map_err(|e| map_error(e, "listen"))?; + let mut entry = Entry::new(handle, c.route, true); + entry.path = Some(path.to_path_buf()); + install(driver, c, link, entry); + Ok(()) + }) + .unwrap_or_else(|| Err(no_loop())) +} + +/// Start the listener's multishot accept. +/// +/// # Safety +/// See [`bind`]. +pub unsafe fn accept_start(core: *mut TransportCore, link: OwnerLink) -> NetResult<()> { + let (c, name) = bind(core, link, "accept")?; + with_driver(|driver| { + let entry = c + .entry + .as_mut() + .filter(|e| e.listener && !e.closing) + .ok_or_else(|| not_found("accept"))?; + if entry.accept_op.is_some() { + return Ok(()); + } + let op = driver + .accept_start(entry.handle, name.token(OP_ACCEPT)) + .map_err(|e| map_error(e, "accept"))?; + entry.accept_op = Some(op); + census::note_submit(OP_ACCEPT); + Ok(()) + }) + .unwrap_or_else(|| Err(no_loop())) +} + +/// Install the connection an accept completion carries into a fresh payload's +/// core. Valid only inside the sink call that received `completion`; a +/// connection the sink does not install is closed by the runtime after the +/// sink returns. +/// +/// # Safety +/// See [`bind`]; `completion` is the pointer the sink was called with. +pub unsafe fn install_accepted( + core: *mut TransportCore, + link: OwnerLink, + completion: *const NetCompletion, +) -> NetResult<()> { + let (c, _) = bind(core, link, "accept")?; + idle_or(c, "accept")?; + let Some(completion) = completion.as_ref() else { + return Err(bad("accept")); + }; + if completion.kind != sink::NET_ACCEPT + || completion.flags & sink::NET_FLAG_LINK == 0 + || completion.conn == 0 + { + return Err(bad("accept")); + } + let slot = &mut *(completion.conn as *mut AcceptSlot); + let Some(conn) = slot.conn.take() else { + return Err(bad("accept")); + }; + let peer = slot.peer; + with_driver(|driver| { + let mut entry = Entry::new(conn, c.route, false); + entry.local = driver.local_addr(conn).ok(); + entry.peer = peer; + install(driver, c, link, entry); + }) + .ok_or_else(no_loop) +} + +/// Connect a TCP client to `host:port`, resolving a hostname off the loop +/// thread first (a resolve holds its own ref until its result is dispatched). +/// +/// # Safety +/// See [`bind`]. +pub unsafe fn tcp_connect( + core: *mut TransportCore, + link: OwnerLink, + host: &str, + port: u16, + nodelay: bool, +) -> NetResult<()> { + let (c, name) = bind(core, link, "connect")?; + idle_or(c, "connect")?; + if let Ok(ip) = host.parse::() { + return with_driver(|driver| { + connect_addr(driver, c, link, SocketAddr::new(ip, port), nodelay) + }) + .unwrap_or_else(|| Err(no_loop())); + } + with_driver(|driver| { + let request = turnloop::DnsRequest { + host: host.to_string(), + port, + }; + let op = driver + .resolve(request, name.token(OP_RESOLVE)) + .map_err(|e| map_error(e, "getaddrinfo"))?; + census::note_submit(OP_RESOLVE); + c.plan = Some(Box::new(ConnectPlan { + subsystem: c.route, + nodelay, + remaining: std::collections::VecDeque::new(), + retrying: false, + last_error: None, + op: Some(op), + })); + hold(link); + Ok(()) + }) + .unwrap_or_else(|| Err(no_loop())) +} + +/// One connect attempt: a fresh handle, installed (+1 ref). +unsafe fn connect_addr( + driver: &mut turnloop::Loop, + c: &mut TransportCore, + link: OwnerLink, + addr: SocketAddr, + nodelay: bool, +) -> NetResult<()> { + let name = Name::Link { + route: c.route, + cell: link.0, + }; + let handle = driver + .tcp_connect( + addr, + &TcpOpts { + nodelay, + ..TcpOpts::default() + }, + name.token(OP_CONNECT), + ) + .map_err(|e| map_error(e, "connect"))?; + let mut entry = Entry::new(handle, c.route, false); + entry.peer = Some(addr); + entry.connecting = true; + install(driver, c, link, entry); + Ok(()) +} + +/// Connect to a Unix-domain socket or a Windows named pipe. +/// +/// # Safety +/// See [`bind`]. +pub unsafe fn pipe_connect( + core: *mut TransportCore, + link: OwnerLink, + path: &Path, +) -> NetResult<()> { + let (c, name) = bind(core, link, "connect")?; + idle_or(c, "connect")?; + with_driver(|driver| { + let handle = driver + .pipe_connect(&PipeName(path.to_path_buf()), name.token(OP_CONNECT)) + .map_err(|e| map_error(e, "connect"))?; + let mut entry = Entry::new(handle, c.route, false); + entry.path = Some(path.to_path_buf()); + entry.connecting = true; + install(driver, c, link, entry); + Ok(()) + }) + .unwrap_or_else(|| Err(no_loop())) +} + +/// Adopt an already-connected stream socket. `socket` is consumed on every +/// outcome, as on the id route. +/// +/// # Safety +/// See [`bind`]. +#[cfg(any(unix, windows))] +pub unsafe fn adopt_stream( + core: *mut TransportCore, + link: OwnerLink, + socket: AdoptedSocket, +) -> NetResult<()> { + let (c, name) = bind(core, link, "adopt")?; + idle_or(c, "adopt")?; + let (local, peer) = { + let sock = socket2::SockRef::from(&socket); + ( + sock.local_addr().ok().and_then(|a| a.as_socket()), + sock.peer_addr().ok().and_then(|a| a.as_socket()), + ) + }; + with_driver(move |driver| { + #[cfg(unix)] + let detached = turnloop::Detached::from_fd(socket); + #[cfg(windows)] + let detached = turnloop::Detached::from_socket(socket); + let detached = detached.map_err(|e| map_error(e, "adopt"))?; + let handle = driver + .attach(detached, name.token(OP_READ)) + .map_err(|e| map_error(e, "adopt"))?; + let mut entry = Entry::new(handle, c.route, false); + entry.local = local; + entry.peer = peer; + install(driver, c, link, entry); + Ok(()) + }) + .unwrap_or_else(|| Err(no_loop())) +} + +/// Start the multishot read. +/// +/// # Safety +/// See [`bind`]. +pub unsafe fn read_start(core: *mut TransportCore, link: OwnerLink) -> NetResult<()> { + let (c, name) = bind(core, link, "read")?; + with_driver(|driver| { + let entry = c.entry.as_mut().ok_or_else(|| not_found("read"))?; + if entry.read_op.is_some() || entry.closing { + return Ok(()); + } + let op = driver + .read_start(entry.handle, name.token(OP_READ)) + .map_err(|e| map_error(e, "read"))?; + entry.read_op = Some(op); + census::note_submit(OP_READ); + Ok(()) + }) + .unwrap_or_else(|| Err(no_loop())) +} + +/// Queue `bytes`; returns the socket's total queued bytes (`writableLength`). +/// The same queue discipline as the id route (`write_queue`): one driver write +/// in flight, the rest coalesced in the backlog, writes before `Connected` +/// held for the attempt that succeeds. +/// +/// # Safety +/// See [`bind`]. +pub unsafe fn write( + core: *mut TransportCore, + link: OwnerLink, + bytes: Vec, + user: u64, +) -> NetResult { + let (c, name) = bind(core, link, "write")?; + with_driver(|driver| accept_write(driver, c, name, bytes, user)) + .unwrap_or_else(|| Err(no_loop())) +} + +/// `end()`: shut the write side down behind every write accepted before it. +/// +/// # Safety +/// See [`bind`]. +pub unsafe fn shutdown(core: *mut TransportCore, link: OwnerLink, user: u64) -> NetResult<()> { + let (c, name) = bind(core, link, "shutdown")?; + with_driver(|driver| accept_shutdown(driver, c, name, link, user)) + .unwrap_or_else(|| Err(no_loop())) +} + +/// Release the core's driver resources (rule 3). Takes the handle out and +/// submits its close under the cell's token, closes the deadline, cancels a +/// pending resolve and drops the backlog. Returns whether a `Closed` (a +/// `NET_CLOSED` completion for this link) will follow; when it is `false` +/// there was no handle, and a binding that owes a `close` event emits it +/// itself. The binding releases the payload afterwards. +/// +/// # Safety +/// See [`bind`]. +pub unsafe fn close(core: *mut TransportCore, link: OwnerLink) -> NetResult { + let (c, name) = bind(core, link, "close")?; + with_driver(|driver| { + if let Some(timer) = c.deadline.take() { + if driver.close(timer, name.token(OP_TIMER)).is_err() { + release(link); + } + } + if let Some(plan) = c.plan.take() { + if let Some(op) = plan.op { + // Its terminal (`Cancelled`) result carries the resolve's ref. + driver.cancel(op); + } + } + c.backlog = Backlog::default(); + let Some(entry) = c.entry.take() else { + return Ok(false); + }; + #[cfg(windows)] + let mut entry = entry; + #[cfg(windows)] + { + windows_pipe::cancel_eof(driver, name, &mut entry); + if let Some(op) = entry.pipe_drain.take() { + driver.cancel(op); + } + } + if entry.closing { + // A failed connect attempt's close is already in flight; its + // `Closed` no longer matches the core and is delivered as this + // socket's close. + return Ok(true); + } + census::note_submit(OP_CLOSE); + match driver.close(entry.handle, name.token(OP_CLOSE)) { + Ok(()) => Ok(true), + Err(e) => { + // The handle is still live: keep it (and its ref) so no + // completion can outlive the cell. + c.entry = Some(entry); + Err(map_error(e, "close")) + } + } + }) + .unwrap_or_else(|| Err(no_loop())) +} + +/// Node's `ref()`/`unref()`: remembered on the core, so a handle installed +/// later inherits it. +/// +/// # Safety +/// See [`bind`]. +pub unsafe fn set_ref( + core: *mut TransportCore, + link: OwnerLink, + referenced: bool, +) -> NetResult<()> { + let (c, _) = bind(core, link, "")?; + c.referenced = referenced; + let Some(entry) = c.entry.as_mut() else { + return Ok(()); + }; + if entry.referenced == referenced { + return Ok(()); + } + entry.referenced = referenced; + let handle = entry.handle; + with_driver(|driver| { + driver + .set_ref(handle, referenced) + .map_err(|e| map_error(e, "")) + }) + .unwrap_or_else(|| Err(no_loop())) +} + +/// Bytes accepted and not yet reported written. +/// +/// # Safety +/// See [`bind`]. +pub unsafe fn queued_bytes(core: *mut TransportCore, link: OwnerLink) -> usize { + bind(core, link, "").map_or(0, |(c, _)| total_queued(c)) +} + +/// The local endpoint (`server.address()`, `socket.localAddress`). +/// +/// # Safety +/// See [`bind`]. +pub unsafe fn local_addr(core: *mut TransportCore, link: OwnerLink) -> Option { + let (c, _) = bind(core, link, "").ok()?; + c.entry.as_ref().and_then(|e| e.local) +} + +/// The peer endpoint (`socket.remoteAddress`). +/// +/// # Safety +/// See [`bind`]. +pub unsafe fn peer_addr(core: *mut TransportCore, link: OwnerLink) -> Option { + let (c, _) = bind(core, link, "").ok()?; + c.entry.as_ref().and_then(|e| e.peer) +} + +/// Arm, or move, the socket's one-shot deadline `delay_ms` from now. Never +/// keeps the loop alive on its own. Its expiry is delivered as `NET_TIMER`. +/// +/// # Safety +/// See [`bind`]. +pub unsafe fn deadline_arm( + core: *mut TransportCore, + link: OwnerLink, + delay_ms: u64, +) -> NetResult<()> { + let (c, name) = bind(core, link, "timer")?; + with_driver(|driver| { + let at = driver.now() + std::time::Duration::from_millis(delay_ms); + if let Some(timer) = c.deadline { + if driver.timer_reset(timer, at) { + census::note_timer_reset(); + return Ok(()); + } + // Fired (its expiry not yet dispatched) or closing: replace it. + // Its `Closed` releases its ref; a pending expiry no longer + // matches `deadline` and is dropped. + c.deadline = None; + if driver.close(timer, name.token(OP_TIMER)).is_err() { + release(link); + } + } + let timer = driver + .timer(at, None, name.token(OP_TIMER)) + .map_err(|e| map_error(e, "timer"))?; + census::note_timer_create(); + let _ = driver.set_ref(timer, false); + c.deadline = Some(timer); + hold(link); + Ok(()) + }) + .unwrap_or_else(|| Err(no_loop())) +} + +/// Disarm the deadline but keep its handle (see the id route's `timer_park`). +/// +/// # Safety +/// See [`bind`]. +pub unsafe fn deadline_park(core: *mut TransportCore, link: OwnerLink) -> NetResult<()> { + let (c, name) = bind(core, link, "timer")?; + let Some(timer) = c.deadline else { + return Ok(()); + }; + with_driver(|driver| { + if let Some(at) = driver.now().checked_add(PARK_AHEAD) { + if driver.timer_reset(timer, at) { + census::note_timer_park(); + return Ok(()); + } + } + c.deadline = None; + census::note_submit(OP_TIMER); + if driver.close(timer, name.token(OP_TIMER)).is_err() { + release(link); + } + Ok(()) + }) + .unwrap_or_else(|| Err(no_loop())) +} + +/// Cancel the deadline. Idempotent. +/// +/// # Safety +/// See [`bind`]. +pub unsafe fn deadline_cancel(core: *mut TransportCore, link: OwnerLink) -> NetResult<()> { + let (c, name) = bind(core, link, "timer")?; + let Some(timer) = c.deadline.take() else { + return Ok(()); + }; + with_driver(|driver| { + census::note_submit(OP_TIMER); + if driver.close(timer, name.token(OP_TIMER)).is_err() { + release(link); + } + Ok(()) + }) + .unwrap_or_else(|| Err(no_loop())) +} + +// ── Write queue on a core (the id route's `write_queue`, per socket) ──────── + +fn total_queued(c: &TransportCore) -> usize { + c.entry.as_ref().map_or(0, |e| e.queued) + c.backlog.bytes.len() +} + +/// Caller writes a backlog flush could not hand to the driver. +struct Failure { + error: NodeError, + users: Vec, +} + +fn accept_write( + driver: &mut turnloop::Loop, + c: &mut TransportCore, + name: Name, + bytes: Vec, + user: u64, +) -> NetResult { + let retrying = c.plan.as_ref().is_some_and(|p| p.retrying); + match c.entry.as_mut() { + Some(entry) if !retrying => { + if entry.listener || entry.closing { + return Err(bad("write")); + } + let waiting = !c.backlog.is_idle(); + if !entry.connecting && !waiting && entry.inflight < MAX_INFLIGHT_WRITES { + let len = bytes.len(); + write_queue::submit_bytes(driver, name, entry, bytes) + .map_err(|e| map_error(e, "write"))?; + entry.writes.push_back(PendingWrite { + user, + len, + last: true, + }); + } else { + if c.backlog.shutdown.is_some() { + return Err(bad("write")); + } + c.backlog.push(bytes, user); + } + } + // Resolving, or between two attempts of a plan. + _ if c.plan.is_some() => { + if c.backlog.shutdown.is_some() { + return Err(bad("write")); + } + c.backlog.push(bytes, user); + } + _ => return Err(not_found("write")), + } + Ok(total_queued(c)) +} + +unsafe fn accept_shutdown( + driver: &mut turnloop::Loop, + c: &mut TransportCore, + name: Name, + link: OwnerLink, + user: u64, +) -> NetResult<()> { + let retrying = c.plan.as_ref().is_some_and(|p| p.retrying); + let deferred = match c.entry.as_ref() { + Some(_) if retrying => true, + Some(entry) if entry.listener || entry.closing => return Err(bad("shutdown")), + Some(entry) => entry.connecting, + None if c.plan.is_some() => true, + None => return Err(not_found("shutdown")), + }; + if c.backlog.shutdown.is_some() { + return Err(bad("shutdown")); + } + c.backlog.shutdown = Some(user); + if deferred { + return Ok(()); + } + match flush(driver, c, name, link, true) { + Some(failure) => Err(failure.error), + None => Ok(()), + } +} + +/// Hand the backlog (and a deferred shutdown) to the driver if the socket can +/// take it now; `force` ignores the in-flight cap. +unsafe fn flush( + driver: &mut turnloop::Loop, + c: &mut TransportCore, + name: Name, + link: OwnerLink, + force: bool, +) -> Option { + let entry = c.entry.as_mut()?; + if entry.connecting || entry.closing { + return None; + } + let backlog = &mut c.backlog; + let mut failure = None; + if !backlog.writes.is_empty() { + if !force && entry.inflight >= MAX_INFLIGHT_WRITES { + return None; + } + let bytes = std::mem::take(&mut backlog.bytes); + let mut writes = std::mem::take(&mut backlog.writes); + match write_queue::submit_bytes(driver, name, entry, bytes) { + Ok(()) => { + if let Some(tail) = writes.last_mut() { + tail.last = true; + } + entry.writes.extend(writes); + } + Err(e) => { + let mut users: Vec = writes.iter().map(|w| w.user).collect(); + users.extend(backlog.shutdown.take()); + failure = Some(Failure { + error: map_error(e, "write"), + users, + }); + } + } + } + #[cfg(windows)] + if failure.is_none() + && backlog.shutdown.is_some() + && entry.inflight > 0 + && windows_pipe::is_pipe(driver, entry) + { + return None; + } + if failure.is_none() { + if let Some(user) = backlog.shutdown.take() { + match write_queue::submit_shutdown(driver, name, entry, user) { + // A Windows pipe drain is a job, not a handle operation: it + // holds its own ref until its terminal result. + Ok(true) => hold(link), + Ok(false) => {} + Err(e) => { + failure = Some(Failure { + error: map_error(e, "shutdown"), + users: vec![user], + }) + } + } + } + } + failure +} + +/// [`flush`] from a completion; a refused flush is reported like a failed +/// write. +unsafe fn flush_after(link: OwnerLink, force: bool) { + let Some(c) = core_of(link) else { + return; + }; + let route = c.route; + let name = Name::Link { + route, + cell: link.0, + }; + let failure = with_driver(|driver| flush(driver, c, name, link, force)).flatten(); + if let Some(failure) = failure { + let queued = core_of(link).map_or(0, |c| total_queued(c)); + report_error(route, link, &failure.users, queued, failure.error, false); + } +} + +/// Retire the caller writes one driver write (or shutdown) covered. +fn retire(c: &mut TransportCore, op_class: u64) -> Vec<(u64, usize, usize)> { + let waiting = c.backlog.bytes.len(); + let Some(entry) = c.entry.as_mut() else { + return Vec::new(); + }; + if op_class == OP_WRITE { + entry.inflight = entry.inflight.saturating_sub(1); + } + let mut retired = Vec::new(); + while let Some(w) = entry.writes.pop_front() { + entry.queued = entry.queued.saturating_sub(w.len); + retired.push((w.user, w.len, entry.queued + waiting)); + if w.last { + break; + } + } + retired +} + +fn report_error( + route: u8, + link: OwnerLink, + users: &[u64], + queued: usize, + error: NodeError, + terminal: bool, +) { + let id = link.0 as i64; + let mut reported = false; + for &user in users.iter().filter(|&&user| user != 0) { + sink::emit( + route, + NetCompletion::error(id, user, queued, error, terminal).linked(), + ); + reported = true; + } + if !reported { + sink::emit( + route, + NetCompletion::error(id, 0, queued, error, terminal).linked(), + ); + } +} + +// ── Dispatch ──────────────────────────────────────────────────────────────── + +/// Route one link-token completion. Called by `super::dispatch` after the +/// turn, outside every loop borrow, so a sink may run JS and submit. +pub(super) fn dispatch(completion: Completion) { + let (route, op, cell) = link_parts(completion.token); + census::note_completion(op); + if cell == 0 { + // LINK_NULL: a close nobody waits on. + return; + } + if TEARDOWN.with(Cell::get) && !sabotage("teardown_dispatch") { + // Never dereference a token once the thread is going away. + #[cfg(test)] + DISCARDED.with(|n| n.set(n.get() + 1)); + return; + } + let link = OwnerLink(cell); + // SAFETY: a ref held for this completion's resource keeps the cell alive + // (rule 2); a finalized cell answers None and is never touched again. + if unsafe { np::link_event_owner(link) }.is_none() { + census::note_no_entry(); + return; + } + let Completion { + result, + terminal, + op: op_id, + handle, + .. + } = completion; + // SAFETY: as above, for every helper below. + unsafe { + match op { + OP_TIMER => dispatch_timer(link, handle, result), + OP_RESOLVE => dispatch_resolve(link, op_id, terminal, result), + #[cfg(windows)] + OP_PIPE_EOF => dispatch_pipe_eof(link, handle, result), + #[cfg(windows)] + OP_PIPE_DRAIN => dispatch_pipe_drain(link, op_id, terminal, result), + _ => dispatch_stream(route, op, link, handle, terminal, result), + } + } +} + +unsafe fn dispatch_timer(link: OwnerLink, handle: Option, result: OpResult) { + census::note_timer_result(&result); + match result { + OpResult::Timer => { + let Some(c) = core_of(link) else { + return; + }; + let (Some(timer), true) = (handle, c.deadline == handle) else { + // Replaced or cancelled before this expiry was dispatched; + // whoever did so closed the handle. + return; + }; + c.deadline = None; + let route = c.route; + let name = Name::Link { + route, + cell: link.0, + }; + // A fired one-shot keeps its handle until closed; the `Closed` + // releases the deadline's ref. + let closed = with_driver(|driver| driver.close(timer, name.token(OP_TIMER)).is_ok()); + if closed != Some(true) { + release(link); + } + sink::emit(route, NetCompletion::timer(link.0 as i64).linked()); + } + OpResult::Closed => release(link), + _ => {} + } +} + +unsafe fn dispatch_resolve(link: OwnerLink, op_id: Option, terminal: bool, result: OpResult) { + let current = core_of(link).filter(|c| { + c.plan + .as_ref() + .is_some_and(|p| p.op.is_some() && p.op == op_id) + || (c.plan.is_some() && sabotage("plan_check")) + }); + match result { + OpResult::Resolved(addresses) => { + if let Some(c) = current { + if let Some(plan) = c.plan.as_mut() { + plan.op = None; + plan.remaining = addresses.into_iter().collect(); + } + attempt_next_address(link); + } + } + OpResult::Err(err) => { + if let Some(c) = current { + c.plan = None; + c.backlog = Backlog::default(); + let route = c.route; + let mut mapped = map_error(err, "getaddrinfo"); + // libuv (and so node) reports any failed lookup as ENOTFOUND. + mapped.code = "ENOTFOUND"; + sink::emit( + route, + NetCompletion::error(link.0 as i64, 0, 0, mapped, true).linked(), + ); + } + } + OpResult::Cancelled | OpResult::Stopped => { + if let Some(c) = current { + c.plan = None; + c.backlog = Backlog::default(); + } + } + _ => {} + } + if terminal { + release(link); + } +} + +/// Start the plan's next address, or report its final failure. +unsafe fn attempt_next_address(link: OwnerLink) { + loop { + let Some(c) = core_of(link) else { + return; + }; + let route = c.route; + let Some(plan) = c.plan.as_mut() else { + return; + }; + let (nodelay, next, last_error) = + (plan.nodelay, plan.remaining.pop_front(), plan.last_error); + let Some(addr) = next else { + c.plan = None; + c.backlog = Backlog::default(); + let err = last_error.unwrap_or(NodeError { + code: "ECONNREFUSED", + errno: 0, + syscall: "connect", + }); + sink::emit( + route, + NetCompletion::error(link.0 as i64, 0, 0, err, true).linked(), + ); + return; + }; + let submitted = with_driver(|driver| connect_addr(driver, c, link, addr, nodelay)) + .unwrap_or_else(|| Err(no_loop())); + match submitted { + Ok(()) => return, + Err(err) => { + // Submission itself failed: record it and try the next one. + if let Some(plan) = core_of(link).and_then(|c| c.plan.as_mut()) { + plan.last_error = Some(err); + } + } + } + } +} + +/// A connect attempt failed: true when absorbed into a retry. The attempt's +/// handle is closed here and its `Closed` starts the next address. +unsafe fn connect_failed(c: &mut TransportCore, link: OwnerLink, err: NodeError) -> bool { + let Some(plan) = c.plan.as_mut() else { + return false; + }; + plan.last_error = Some(err); + if plan.remaining.is_empty() { + c.plan = None; + return false; + } + plan.retrying = true; + let name = Name::Link { + route: c.route, + cell: link.0, + }; + if let Some(entry) = c.entry.as_mut() { + if !entry.closing { + entry.closing = true; + census::note_submit(OP_CLOSE); + let handle = entry.handle; + let _ = with_driver(|driver| driver.close(handle, name.token(OP_CLOSE))); + } + } + true +} + +fn clear_op(c: &mut TransportCore, op_class: u64) { + if let Some(entry) = c.entry.as_mut() { + match op_class { + OP_ACCEPT => entry.accept_op = None, + OP_READ => entry.read_op = None, + _ => {} + } + } +} + +fn close_null(conn: Handle) { + let _ = with_driver(|driver| driver.close(conn, LINK_NULL)); +} + +unsafe fn dispatch_stream( + route: u8, + op: u64, + link: OwnerLink, + handle: Option, + terminal: bool, + result: OpResult, +) { + let id = link.0 as i64; + match result { + OpResult::Closed => { + let mut route = route; + let mut retry = false; + if let Some(c) = current(link, handle) { + route = c.route; + c.entry = None; + match c.plan.as_mut() { + Some(plan) if plan.retrying => { + plan.retrying = false; + retry = true; + } + _ => { + c.plan = None; + c.backlog = Backlog::default(); + } + } + } else if let Some(c) = core_of(link) { + route = c.route; + } + if retry { + // A failed attempt's handle, not the socket the caller sees. + attempt_next_address(link); + } else { + sink::emit(route, NetCompletion::closed(id).linked()); + } + // The handle's ref, taken at install. Last: the sink ran with the + // cell pinned. + release(link); + } + OpResult::Accepted { conn, peer } => accepted(link, handle, conn, Some(peer)), + OpResult::PipeAccepted { conn } => accepted(link, handle, conn, None), + OpResult::Connected => { + let Some(c) = current(link, handle) else { + return; + }; + let route = c.route; + let local = + handle.and_then(|h| with_driver(|driver| driver.local_addr(h).ok()).flatten()); + if let Some(entry) = c.entry.as_mut() { + entry.local = local; + entry.connecting = false; + } + c.plan = None; + sink::emit(route, NetCompletion::connect(id).linked()); + // Writes (and an `end()`) issued while connecting go out now. + flush_after(link, true); + } + OpResult::Read { n, lease } => { + let Some(c) = current(link, handle) else { + return; + }; + let route = c.route; + #[cfg(windows)] + { + let name = Name::Link { + route, + cell: link.0, + }; + if let Some(entry) = c.entry.as_mut() { + if entry.pipe_eof_timer.is_some() { + let created = + with_driver(|driver| windows_pipe::arm_eof(driver, name, entry)); + if matches!(created, Some(Ok(true))) { + hold(link); + } + } + } + } + let bytes = lease.as_ref().map(|l| l.as_slice()).unwrap_or(&[]); + debug_assert!(bytes.len() == n || lease.is_none()); + sink::emit(route, NetCompletion::data(id, bytes).linked()); + drop(lease); + } + OpResult::Eof => { + let Some(c) = current(link, handle) else { + return; + }; + let route = c.route; + if let Some(entry) = c.entry.as_mut() { + #[cfg(windows)] + { + let name = Name::Link { + route, + cell: link.0, + }; + let _ = with_driver(|driver| windows_pipe::cancel_eof(driver, name, entry)); + } + entry.read_op = None; + } + sink::emit(route, NetCompletion::eof(id).linked()); + } + OpResult::Wrote(n) => { + let Some(c) = current(link, handle) else { + return; + }; + let route = c.route; + let retired = retire(c, OP_WRITE); + debug_assert!( + retired.is_empty() || retired.iter().map(|r| r.1).sum::() == n, + "a write completes its whole buffer" + ); + flush_after(link, false); + for (user, len, queued) in retired { + sink::emit(route, NetCompletion::wrote(id, user, len, queued).linked()); + } + } + OpResult::Shutdown => { + let Some(c) = current(link, handle) else { + return; + }; + let route = c.route; + let user = retire(c, OP_SHUTDOWN).first().map_or(0, |r| r.0); + sink::emit(route, NetCompletion::shutdown(id, user).linked()); + } + OpResult::Err(err) => { + let Some(c) = current(link, handle) else { + return; + }; + let route = c.route; + let users: Vec = if op == OP_WRITE || op == OP_SHUTDOWN { + retire(c, op).into_iter().map(|r| r.0).collect() + } else { + Vec::new() + }; + let queued = total_queued(c); + if terminal { + clear_op(c, op); + } + let mapped = map_error(err, syscall_for(op)); + if op == OP_CONNECT && connect_failed(c, link, mapped) { + return; + } + report_error(route, link, &users, queued, mapped, terminal); + } + OpResult::Cancelled | OpResult::Stopped => { + if let Some(c) = current(link, handle) { + clear_op(c, op); + if op == OP_WRITE || op == OP_SHUTDOWN { + retire(c, op); + } + } + } + _ => {} + } +} + +unsafe fn accepted( + link: OwnerLink, + handle: Option, + conn: Handle, + peer: Option, +) { + let Some(c) = current(link, handle) else { + // A stale listener's connection: nobody can own it. + close_null(conn); + return; + }; + let route = c.route; + let mut slot = AcceptSlot { + conn: Some(conn), + peer, + }; + let completion = + NetCompletion::accept(link.0 as i64, &mut slot as *mut AcceptSlot as i64, peer); + sink::emit(route, completion.linked()); + if let Some(conn) = slot.conn.take() { + // The sink refused (or could not allocate) the connection. + close_null(conn); + } +} + +#[cfg(windows)] +unsafe fn dispatch_pipe_eof(link: OwnerLink, handle: Option, result: OpResult) { + match result { + OpResult::Timer => { + let Some(c) = core_of(link) else { + return; + }; + let live = c + .entry + .as_ref() + .is_some_and(|e| !e.closing && e.pipe_eof_timer == handle); + if !live { + return; + } + let route = c.route; + let name = Name::Link { + route, + cell: link.0, + }; + sink::emit(route, NetCompletion::eof(link.0 as i64).linked()); + close_stream(link, name); + } + // The EOF timer handle's own `Closed`: its ref. + OpResult::Closed => release(link), + _ => {} + } +} + +#[cfg(windows)] +unsafe fn dispatch_pipe_drain( + link: OwnerLink, + op_id: Option, + terminal: bool, + result: OpResult, +) { + let live = core_of(link).and_then(|c| { + let route = c.route; + c.entry + .as_ref() + .is_some_and(|e| !e.closing && e.pipe_drain == op_id) + .then_some(route) + }); + if let Some(route) = live { + let name = Name::Link { + route, + cell: link.0, + }; + match result { + OpResult::Blocking(_) => { + let Some(c) = core_of(link) else { + return; + }; + let user = retire(c, OP_SHUTDOWN).first().map_or(0, |r| r.0); + sink::emit(route, NetCompletion::shutdown(link.0 as i64, user).linked()); + let armed = core_of(link) + .and_then(|c| c.entry.as_mut()) + .and_then(|entry| { + with_driver(|driver| windows_pipe::arm_eof(driver, name, entry)) + }); + match armed { + Some(Ok(true)) => hold(link), + Some(Ok(false)) => {} + _ => { + sink::emit(route, NetCompletion::eof(link.0 as i64).linked()); + close_stream(link, name); + } + } + } + OpResult::Err(error) => { + let users: Vec = core_of(link) + .map(|c| retire(c, OP_SHUTDOWN).into_iter().map(|r| r.0).collect()) + .unwrap_or_default(); + report_error(route, link, &users, 0, map_error(error, "shutdown"), true); + close_stream(link, name); + } + _ => {} + } + } + if terminal { + release(link); + } +} + +/// The runtime's own close of a stream (a Windows pipe's EOF): the handle +/// stays in the core, closing, and its `Closed` is delivered as the close. +#[cfg(windows)] +unsafe fn close_stream(link: OwnerLink, name: Name) { + let Some(entry) = core_of(link).and_then(|c| c.entry.as_mut()) else { + return; + }; + if entry.closing { + return; + } + entry.closing = true; + let _ = with_driver(|driver| windows_pipe::cancel_eof(driver, name, entry)); + if let Some(op) = entry.pipe_drain.take() { + let _ = with_driver(|driver| driver.cancel(op)); + } + census::note_submit(OP_CLOSE); + let handle = entry.handle; + let _ = with_driver(|driver| driver.close(handle, name.token(OP_CLOSE))); +} + +// ── The core as an ABI value ──────────────────────────────────────────────── + +/// Construct a core in place (perry-ffi's `TransportCore::new`). +/// +/// # Safety +/// `core` is writable for `TRANSPORT_CORE_WORDS` words, 8-aligned. +pub(super) unsafe fn core_init(core: *mut TransportCore, route: u8) { + // GC_STORE_AUDIT(POINTER_FREE): a binding's payload memory on the Rust + // heap (or its stack); a TransportCore holds no GC reference. + std::ptr::write(core, TransportCore::new(route)); +} + +/// Drop a core in place (perry-ffi's `Drop for TransportCore`). +/// +/// # Safety +/// `core` was initialized by [`core_init`] and is not used afterwards. +pub(super) unsafe fn core_drop(core: *mut TransportCore) { + std::ptr::drop_in_place(core); +} + +/// Frees memory only: a core is dropped inside a collection, at release, or +/// at thread teardown, so it never reaches the loop or any other thread-local +/// (rule 2 guarantees a swept core holds no live handle). The body exists for +/// the N7 sabotage alone and is empty in a production build. +impl Drop for TransportCore { + fn drop(&mut self) { + if sabotage("drop_closes") { + // The defect the rule forbids: a Drop that releases its handle. + let handle = self.handle(); + let _ = with_driver(|driver| { + if let Some(handle) = handle { + let _ = driver.close(handle, LINK_NULL); + } + }); + } + } +} diff --git a/crates/perry-runtime/src/turnloop_net/windows_pipe.rs b/crates/perry-runtime/src/turnloop_net/windows_pipe.rs index d44cfaab78..7fbc4161cd 100644 --- a/crates/perry-runtime/src/turnloop_net/windows_pipe.rs +++ b/crates/perry-runtime/src/turnloop_net/windows_pipe.rs @@ -15,31 +15,37 @@ pub(super) fn is_pipe(driver: &turnloop::Loop, entry: &Entry) -> bool { /// Windows duplex pipes cannot half-close. Match libuv's 50 ms read grace /// after draining writes, refreshing it whenever incoming data arrives. +/// Returns whether a new timer handle was created (a link route holds one ref +/// per timer handle until its `Closed`). pub(super) fn arm_eof( driver: &mut turnloop::Loop, - id: i64, + name: Name, entry: &mut Entry, -) -> Result<(), Error> { +) -> Result { let at = driver.now() + std::time::Duration::from_millis(50); if let Some(handle) = entry.pipe_eof_timer { if driver.timer_reset(handle, at) { - return Ok(()); + return Ok(false); } - let _ = driver.close(handle, token(OP_PIPE_EOF, id)); + let _ = driver.close(handle, name.token(OP_PIPE_EOF)); } - let handle = driver.timer(at, None, token(OP_PIPE_EOF, id))?; + let handle = driver.timer(at, None, name.token(OP_PIPE_EOF))?; let _ = driver.set_ref(handle, false); entry.pipe_eof_timer = Some(handle); - Ok(()) + Ok(true) } -pub(super) fn cancel_eof(driver: &mut turnloop::Loop, id: i64, entry: &mut Entry) { +pub(super) fn cancel_eof(driver: &mut turnloop::Loop, name: Name, entry: &mut Entry) { if let Some(handle) = entry.pipe_eof_timer.take() { - let _ = driver.close(handle, token(OP_PIPE_EOF, id)); + let _ = driver.close(handle, name.token(OP_PIPE_EOF)); } } -pub(super) fn submit(driver: &mut turnloop::Loop, id: i64, entry: &mut Entry) -> Result<(), Error> { +pub(super) fn submit( + driver: &mut turnloop::Loop, + name: Name, + entry: &mut Entry, +) -> Result<(), Error> { let turnloop::RawTransport::Handle(raw) = driver.raw_transport(entry.handle)? else { return Err(Error::new(ErrorKind::InvalidInput)); }; @@ -96,7 +102,7 @@ pub(super) fn submit(driver: &mut turnloop::Loop, id: i64, entry: &mut Entry) -> .unwrap_or_else(|_| Err(Error::new(ErrorKind::Other))) }, turnloop::Occupancy::Long, - token(OP_PIPE_DRAIN, id), + name.token(OP_PIPE_DRAIN), )?; entry.pipe_drain = Some(op); census::note_submit(OP_PIPE_DRAIN); diff --git a/crates/perry-runtime/src/turnloop_net/write_queue.rs b/crates/perry-runtime/src/turnloop_net/write_queue.rs index 30344c2fb7..ff93c527da 100644 --- a/crates/perry-runtime/src/turnloop_net/write_queue.rs +++ b/crates/perry-runtime/src/turnloop_net/write_queue.rs @@ -32,34 +32,42 @@ fn invalid(syscall: &'static str) -> NodeError { /// Submit `bytes` as one driver write. The caller records the /// [`PendingWrite`]s it covers. -fn submit_bytes( +pub(super) fn submit_bytes( driver: &mut turnloop::Loop, - id: i64, + name: Name, entry: &mut Entry, bytes: Vec, ) -> Result<(), Error> { let len = bytes.len(); - driver.write(entry.handle, WriteBuf::Owned(bytes), token(OP_WRITE, id))?; + driver.write(entry.handle, WriteBuf::Owned(bytes), name.token(OP_WRITE))?; census::note_submit(OP_WRITE); entry.queued += len; entry.inflight += 1; Ok(()) } -fn submit_shutdown( +/// Submit the write-side shutdown. Returns whether it went to a Windows pipe +/// drain job instead (a job, not a handle operation: a link route must hold a +/// ref for it until its terminal completion). +pub(super) fn submit_shutdown( driver: &mut turnloop::Loop, - id: i64, + name: Name, entry: &mut Entry, user: u64, -) -> Result<(), Error> { +) -> Result { #[cfg(windows)] - if windows_pipe::is_pipe(driver, entry) { - windows_pipe::submit(driver, id, entry)?; + let drain = if windows_pipe::is_pipe(driver, entry) { + windows_pipe::submit(driver, name, entry)?; + true } else { - driver.shutdown(entry.handle, token(OP_SHUTDOWN, id))?; - } + driver.shutdown(entry.handle, name.token(OP_SHUTDOWN))?; + false + }; #[cfg(not(windows))] - driver.shutdown(entry.handle, token(OP_SHUTDOWN, id))?; + let drain = { + driver.shutdown(entry.handle, name.token(OP_SHUTDOWN))?; + false + }; census::note_submit(OP_SHUTDOWN); // The user token rides the pending-write queue's tail slot so the // `Shutdown` completion can echo it back; a zero-length entry never @@ -69,7 +77,7 @@ fn submit_shutdown( len: 0, last: true, }); - Ok(()) + Ok(drain) } /// Accept one caller write: straight to the driver when nothing is ahead of @@ -101,7 +109,8 @@ pub(super) fn accept_write( let waiting = backlogs.get(&id).is_some_and(|b| !b.is_idle()); if !entry.connecting && !waiting && entry.inflight < MAX_INFLIGHT_WRITES { let len = bytes.len(); - submit_bytes(driver, id, entry, bytes).map_err(|e| map_error(e, "write"))?; + submit_bytes(driver, Name::Id(id), entry, bytes) + .map_err(|e| map_error(e, "write"))?; entry.writes.push_back(PendingWrite { user, len, @@ -189,7 +198,7 @@ pub(super) fn flush( } let bytes = std::mem::take(&mut backlog.bytes); let mut writes = std::mem::take(&mut backlog.writes); - match submit_bytes(driver, id, entry, bytes) { + match submit_bytes(driver, Name::Id(id), entry, bytes) { Ok(()) => { if let Some(tail) = writes.last_mut() { tail.last = true; @@ -216,7 +225,7 @@ pub(super) fn flush( } if failure.is_none() { if let Some(user) = backlog.shutdown.take() { - if let Err(e) = submit_shutdown(driver, id, entry, user) { + if let Err(e) = submit_shutdown(driver, Name::Id(id), entry, user) { failure = Some(FlushFailure { error: map_error(e, "shutdown"), users: vec![user], diff --git a/docs/native-payload-pattern.md b/docs/native-payload-pattern.md index 08a9caf53e..7e72399451 100644 --- a/docs/native-payload-pattern.md +++ b/docs/native-payload-pattern.md @@ -349,3 +349,55 @@ writable buffer, readable queue, listeners and pipes. * **Overrides.** A JS `_transform`/`_flush` captured at init (an option, or a subclass override) takes precedence over the hooks; the family prototype's builtin `_transform` runs the same step for `super._transform(...)`. + +## Families in a binding crate (the C ABI) + +A binding crate links only `perry-ffi`, so it cannot instantiate the generic +`native_payload::alloc::`. It declares its family as a `static` +`perry_ffi::native_payload::PayloadFamily::new::(class_id, name, +links_owner, vtable).with_installer(install)` and calls the same operations +through `perry_ffi::native_payload`: `alloc`, `alloc_closed`, `get_mut` +(checked by class id, layout and vtable), `close`, `reopen`, +`lifecycle`, `owner_link`, `link_ref`/`link_unref`, `link_event_owner`, +`js_state`, `set_external_bytes`, and `prototype_method` for the installer. +They forward to `native_payload_abi.rs`, which runs the in-tree code: one +cell, one lifecycle, one owner link. The descriptor's first word is perry-ffi's +layout digest; the runtime refuses a descriptor with another one. Every rule +above holds unchanged. + +## Families that own a transport (NET-TRANSPORT-DESIGN) + +A socket, server or other turnloop transport is an A family whose payload is +`TransportPayload`: the runtime's `TransportCore` first (an opaque fixed +block in perry-ffi, its size folded into `js_perry_net_abi_layout`), then the +family's fields `E`. One allocation per socket; no id, no map. + +1. Register the sink with `register_link_sink(route, sink)`. Every completion + carries `NET_FLAG_LINK` and its `id` is the payload's owner link + (`NetCompletion::link`); dispatch reaches the owner through + `link_event_owner`, with no lookup. +2. Submit with `(&mut payload.core, owner_link)` (`link_tcp_listen`, + `link_tcp_connect`, `link_write`, ...). Hold no `&mut T` across a sink + call; fetch the payload again after any JS runs. +3. The runtime holds one `link_ref` per terminal completion the driver owes + (a handle's `Closed`, a resolve, a deadline) from submission to dispatch. + A listening server or open socket with no JS reference therefore lives + until it is closed, as in Node, and the sweep never drops a payload that + names a live descriptor. The family takes no refs of its own for I/O. +4. `close`/`destroy` is a JS method: `link_close` (the handle moves into the + driver's close; `Ok(false)` means there was no handle, so schedule the + `close` event yourself), then `native_payload::close`. Reopen uses `reopen` + in perry-ffi, distinct from the `attach` operation used by subclass + constructors. A pipe server + unlinks its path synchronously here. Never touch the driver from `Drop`. +5. Reopen (`connect()` / `listen()` after close) installs a fresh payload + in the same cell (`attach` in-tree, `reopen` in perry-ffi). Staleness is the driver's generational handle: + an old `Closed` still delivers its `close` once and touches nothing else. +6. An HTTP upgrade calls `link_set_route`: one store changes the sink, while + the outstanding multishot read retains its token and operation. +7. An accepted connection arrives as `NET_ACCEPT`: allocate the child payload + inside the sink and call `link_install_accepted`; one not installed is + closed when the sink returns. +8. Worker teardown discards link completions (no JS, no token dereferenced); + `Loop::drop` releases the descriptors and the heap teardown drops the + payloads. diff --git a/scripts/gc_runtime_root_holders.json b/scripts/gc_runtime_root_holders.json index 4a30151d48..1fc1719656 100644 --- a/scripts/gc_runtime_root_holders.json +++ b/scripts/gc_runtime_root_holders.json @@ -33,6 +33,12 @@ "Known census holders have explicit verdicts, including PASS1_MARKED's window contract." ], "holders": [ + { + "file": "crates/perry-runtime/src/turnloop_net/transport.rs", + "name": "LINK_RESOURCES", + "verdict": "not_a_gc_pointer", + "why": "Per-thread numeric census of driver resources whose terminal completions still hold a link_ref. Incremented in hold, decremented in release; read only as a live-resource count. It stores neither a cell address nor a JS value; the cell refs themselves are traced and pinned by native_payload::link_ref." + }, { "file": "crates/perry-ext-http/src/lib.rs", "name": "HTTP_PENDING_EVENTS", From e22a2d176f2acaaadabebae98671be5fdd372c1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 9 Oct 2026 04:42:34 +0200 Subject: [PATCH 02/18] feat(net): net.Socket and net.Server as native payloads with link-routed transports perry-ext-net's sockets and servers are TransportPayload native payloads: completions route by link token through the payload cell, so the id-keyed socket, server and event tables are gone (ledger: ext-net 19 tables and 10 producers, now 0 and 0). A closed, dropped Socket is collected and its WeakRefs clear. Class ids live in perry-abi; codegen drops the net id rows. --- crates/perry-abi/src/native_class_ids.rs | 8 + .../src/expr/property_get_names.rs | 8 - .../perry-codegen/src/expr/this_super_call.rs | 35 + crates/perry-codegen/src/ext_registry.rs | 1 + .../src/gc_effects/linux-x86_64.tsv | 8 +- .../src/gc_effects/macos-aarch64.tsv | 8 +- .../src/gc_effects/windows-x86_64.tsv | 8 +- .../src/lower_call/native_module_dispatch.rs | 26 +- .../src/lower_call/native_table/bun.rs | 2 +- .../native_table/net_classes_state.rs | 184 +- .../src/lower_call/native_table/net_events.rs | 687 +------ .../runtime_decls/stdlib_ffi/language_core.rs | 6 + .../perry-codegen/src/wasm32/runtime_abi.tsv | 6 +- crates/perry-db-turnloop/src/lib.rs | 3 +- crates/perry-ext-net/Cargo.toml | 2 +- crates/perry-ext-net/src/adopt.rs | 233 --- crates/perry-ext-net/src/buffer_pool.rs | 424 ---- crates/perry-ext-net/src/bun_tcp.rs | 1561 ++++---------- crates/perry-ext-net/src/classes.rs | 237 ++- crates/perry-ext-net/src/classes_prototype.rs | 169 ++ crates/perry-ext-net/src/cork.rs | 219 -- crates/perry-ext-net/src/dispatch.rs | 805 -------- crates/perry-ext-net/src/dispatch_custody.rs | 204 -- crates/perry-ext-net/src/gc_roots.rs | 78 - crates/perry-ext-net/src/handle_exports.rs | 95 - crates/perry-ext-net/src/handle_ids.rs | 87 - crates/perry-ext-net/src/ipc.rs | 165 -- crates/perry-ext-net/src/lib.rs | 1495 +------------- crates/perry-ext-net/src/lifecycle.rs | 1791 ----------------- crates/perry-ext-net/src/native_dispatch.rs | 14 +- crates/perry-ext-net/src/native_transport.rs | 446 ++++ crates/perry-ext-net/src/option_setters.rs | 154 -- crates/perry-ext-net/src/payload_abi.rs | 1112 ++++++++++ crates/perry-ext-net/src/payload_closed.rs | 147 ++ crates/perry-ext-net/src/payload_events.rs | 182 ++ crates/perry-ext-net/src/payload_io.rs | 546 +++++ crates/perry-ext-net/src/payload_pipe.rs | 201 ++ crates/perry-ext-net/src/payload_prototype.rs | 325 +++ crates/perry-ext-net/src/payload_provider.rs | 79 + crates/perry-ext-net/src/payload_server.rs | 735 +++++++ crates/perry-ext-net/src/payload_socket.rs | 1532 ++++++++++++++ crates/perry-ext-net/src/payload_tls.rs | 555 +++++ crates/perry-ext-net/src/payload_transport.rs | 363 ++++ crates/perry-ext-net/src/pipe.rs | 345 ---- .../perry-ext-net/src/provider_lifecycle.rs | 109 - crates/perry-ext-net/src/raw_bridge.rs | 194 -- crates/perry-ext-net/src/server_state.rs | 530 ----- crates/perry-ext-net/src/socket_emit.rs | 128 -- crates/perry-ext-net/src/socket_events.rs | 577 ------ crates/perry-ext-net/src/socket_facade.rs | 165 +- crates/perry-ext-net/src/tls.rs | 375 ++-- crates/perry-ext-net/src/turnloop_io.rs | 1080 ---------- crates/perry-ext-net/src/turnloop_tls.rs | 51 + crates/perry-ext-net/src/turnloop_tls_io.rs | 466 ----- crates/perry-ffi/src/agent_post.rs | 13 + crates/perry-ffi/src/native_payload_links.rs | 229 +++ crates/perry-ffi/src/turnloop_net.rs | 165 ++ crates/perry-runtime/Cargo.toml | 3 + crates/perry-runtime/src/agent.rs | 52 +- crates/perry-runtime/src/async_hooks.rs | 1 + .../src/async_hooks/provider_ffi.rs | 135 ++ .../src/async_hooks/test_support.rs | 70 + .../src/closure/dispatch/bound.rs | 36 + .../src/event_pump/agent_loop.rs | 4 + crates/perry-runtime/src/gc/mod.rs | 6 + crates/perry-runtime/src/lib.rs | 37 + crates/perry-runtime/src/native_class_ids.rs | 18 +- crates/perry-runtime/src/native_handle.rs | 6 + crates/perry-runtime/src/native_payload.rs | 121 +- .../perry-runtime/src/native_payload_abi.rs | 17 +- .../src/native_payload_attach.rs | 2 + .../src/native_payload_event_abi.rs | 149 ++ .../src/native_payload_transport_abi.rs | 267 +++ .../src/object/class_constructors.rs | 22 + .../src/object/global_this/fetch_globals.rs | 23 + crates/perry-runtime/src/object/mod.rs | 2 +- .../src/object/native_module_dispatch.rs | 4 +- .../native_module_dispatch/dispatch_m_p.rs | 35 + crates/perry-runtime/src/tls.rs | 354 ++-- crates/perry-runtime/src/turnloop_net/abi.rs | 96 +- .../perry-runtime/src/turnloop_net/errors.rs | 2 + crates/perry-runtime/src/turnloop_net/mod.rs | 7 +- crates/perry-runtime/src/turnloop_net/sink.rs | 16 +- .../src/turnloop_net/transport.rs | 168 +- scripts/native_handle_ledger.py | 2 +- scripts/native_handle_ledger.txt | 14 - 86 files changed, 9246 insertions(+), 11796 deletions(-) delete mode 100644 crates/perry-ext-net/src/adopt.rs delete mode 100644 crates/perry-ext-net/src/buffer_pool.rs create mode 100644 crates/perry-ext-net/src/classes_prototype.rs delete mode 100644 crates/perry-ext-net/src/cork.rs delete mode 100644 crates/perry-ext-net/src/dispatch.rs delete mode 100644 crates/perry-ext-net/src/dispatch_custody.rs delete mode 100644 crates/perry-ext-net/src/gc_roots.rs delete mode 100644 crates/perry-ext-net/src/handle_exports.rs delete mode 100644 crates/perry-ext-net/src/handle_ids.rs delete mode 100644 crates/perry-ext-net/src/ipc.rs delete mode 100644 crates/perry-ext-net/src/lifecycle.rs create mode 100644 crates/perry-ext-net/src/native_transport.rs delete mode 100644 crates/perry-ext-net/src/option_setters.rs create mode 100644 crates/perry-ext-net/src/payload_abi.rs create mode 100644 crates/perry-ext-net/src/payload_closed.rs create mode 100644 crates/perry-ext-net/src/payload_events.rs create mode 100644 crates/perry-ext-net/src/payload_io.rs create mode 100644 crates/perry-ext-net/src/payload_pipe.rs create mode 100644 crates/perry-ext-net/src/payload_prototype.rs create mode 100644 crates/perry-ext-net/src/payload_provider.rs create mode 100644 crates/perry-ext-net/src/payload_server.rs create mode 100644 crates/perry-ext-net/src/payload_socket.rs create mode 100644 crates/perry-ext-net/src/payload_tls.rs create mode 100644 crates/perry-ext-net/src/payload_transport.rs delete mode 100644 crates/perry-ext-net/src/pipe.rs delete mode 100644 crates/perry-ext-net/src/provider_lifecycle.rs delete mode 100644 crates/perry-ext-net/src/raw_bridge.rs delete mode 100644 crates/perry-ext-net/src/server_state.rs delete mode 100644 crates/perry-ext-net/src/socket_emit.rs delete mode 100644 crates/perry-ext-net/src/socket_events.rs delete mode 100644 crates/perry-ext-net/src/turnloop_io.rs delete mode 100644 crates/perry-ext-net/src/turnloop_tls_io.rs create mode 100644 crates/perry-runtime/src/native_payload_event_abi.rs diff --git a/crates/perry-abi/src/native_class_ids.rs b/crates/perry-abi/src/native_class_ids.rs index 27a9f4aea0..28d07737bd 100644 --- a/crates/perry-abi/src/native_class_ids.rs +++ b/crates/perry-abi/src/native_class_ids.rs @@ -12,3 +12,11 @@ pub const ZSTD_COMPRESS: u32 = 0xFFFF_2426; pub const ZSTD_DECOMPRESS: u32 = 0xFFFF_2427; pub const ZLIB_BASE: u32 = 0xFFFF_2428; pub const HTTP_SERVER_RESPONSE: u32 = 0xFFFF_2429; +pub const NET_BLOCK_LIST: u32 = 0xFFFF_242A; +pub const NET_SOCKET_ADDRESS: u32 = 0xFFFF_242B; +pub const NET_SOCKET: u32 = 0xFFFF_242C; +pub const NET_SERVER: u32 = 0xFFFF_242D; +pub const HTTP_PARSER: u32 = 0xFFFF_242E; +pub const WS_CODEC: u32 = 0xFFFF_242F; +pub const HTTP2_CODEC: u32 = 0xFFFF_2430; +pub const HTTP_CLIENT_PARSER: u32 = 0xFFFF_2431; diff --git a/crates/perry-codegen/src/expr/property_get_names.rs b/crates/perry-codegen/src/expr/property_get_names.rs index 2308f36731..bca975726e 100644 --- a/crates/perry-codegen/src/expr/property_get_names.rs +++ b/crates/perry-codegen/src/expr/property_get_names.rs @@ -106,18 +106,10 @@ fn is_net_server_method_name(name: &str) -> bool { ) } -fn is_net_block_list_method_name(name: &str) -> bool { - matches!( - name, - "addAddress" | "addRange" | "addSubnet" | "check" | "toJSON" | "fromJSON" - ) -} - pub(super) fn is_net_native_method_value(class_name: &str, name: &str) -> bool { match class_name { "Socket" => is_net_socket_method_name(name), "Server" => is_net_server_method_name(name), - "BlockList" => is_net_block_list_method_name(name), _ => false, } } diff --git a/crates/perry-codegen/src/expr/this_super_call.rs b/crates/perry-codegen/src/expr/this_super_call.rs index c916775605..d33941be55 100644 --- a/crates/perry-codegen/src/expr/this_super_call.rs +++ b/crates/perry-codegen/src/expr/this_super_call.rs @@ -555,6 +555,41 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { return Ok(double_literal(0.0)); } }; + if let Some((module, export)) = current_class.native_extends.as_ref() { + if module.trim_start_matches("node:") == "net" + && matches!(export.as_str(), "Socket" | "Stream" | "Server") + { + let undef = double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)); + let (args, roots) = crate::lower_call::lower_call_args_rooted(ctx, super_args)?; + let options = args.first().cloned().unwrap_or_else(|| undef.clone()); + let callback = args.get(1).cloned().unwrap_or_else(|| undef.clone()); + let this = ctx + .this_stack + .last() + .cloned() + .map(|slot| ctx.block().load(DOUBLE, &slot)) + .unwrap_or_else(|| undef.clone()); + let kind = u32::from(export == "Server").to_string(); + ctx.block().call( + DOUBLE, + "js_net_subclass_init", + &[ + (DOUBLE, &this), + (DOUBLE, &options), + (DOUBLE, &callback), + (I32, &kind), + ], + ); + bind_derived_this_after_super(ctx); + crate::lower_call::apply_field_initializers_recursive( + ctx, + ¤t_class_name, + crate::lower_call::FieldInitMode::SelfOnly, + )?; + roots.release(ctx); + return Ok(undef); + } + } // #5437 (Next.js p-queue `PQueue`): when HIR captured a dynamic // `extends_expr` for this class, the parent is a LEXICAL runtime // value (an in-scope local / require result) — NOT the same-named diff --git a/crates/perry-codegen/src/ext_registry.rs b/crates/perry-codegen/src/ext_registry.rs index d3752e1377..64051fa5a7 100644 --- a/crates/perry-codegen/src/ext_registry.rs +++ b/crates/perry-codegen/src/ext_registry.rs @@ -476,6 +476,7 @@ const FFI_REGISTRY: &[(&str, OwnerKind)] = &[ // stdlib-only link resolved them without the wrapper. That copy is // deleted; perry-ext-net is the only definition, so an emitted call must // put its archive on the link line like every other net symbol. + ("js_ext_net_socket_alloc", OwnerKind::WellKnown("net")), ("js_net_socket_alloc", OwnerKind::WellKnown("net")), ("js_net_socket_connect", OwnerKind::WellKnown("net")), ("js_net_socket_method_connect", OwnerKind::WellKnown("net")), diff --git a/crates/perry-codegen/src/gc_effects/linux-x86_64.tsv b/crates/perry-codegen/src/gc_effects/linux-x86_64.tsv index fb3b7b8186..4dcc474f74 100644 --- a/crates/perry-codegen/src/gc_effects/linux-x86_64.tsv +++ b/crates/perry-codegen/src/gc_effects/linux-x86_64.tsv @@ -3239,10 +3239,10 @@ js_timer_validate_callback Reenters js_tls_check_server_identity Reenters js_tls_client_check_identity Reenters js_tls_client_check_identity_from_metadata Reenters -js_tls_client_is_connected Leaf +js_tls_client_is_connected Reenters js_tls_client_preflight Reenters -js_tls_client_record_closed Leaf -js_tls_client_record_connected Leaf +js_tls_client_record_closed Reenters +js_tls_client_record_connected Reenters js_tls_client_record_start Reenters js_tls_convert_alpn_protocols Reenters js_tls_create_secure_context Reenters @@ -3283,7 +3283,7 @@ js_tls_socket_get_protocol Reenters js_tls_socket_get_session Reenters js_tls_socket_get_shared_sigalgs Reenters js_tls_socket_get_x509_certificate Reenters -js_tls_socket_is_session_reused Leaf +js_tls_socket_is_session_reused Reenters js_tls_socket_set_key_cert Reenters js_tls_socket_set_max_send_fragment Reenters js_tls_tlssocket_constructor Reenters diff --git a/crates/perry-codegen/src/gc_effects/macos-aarch64.tsv b/crates/perry-codegen/src/gc_effects/macos-aarch64.tsv index 4f2c852474..0ca993973f 100644 --- a/crates/perry-codegen/src/gc_effects/macos-aarch64.tsv +++ b/crates/perry-codegen/src/gc_effects/macos-aarch64.tsv @@ -3246,10 +3246,10 @@ js_timer_validate_callback Reenters js_tls_check_server_identity Reenters js_tls_client_check_identity Reenters js_tls_client_check_identity_from_metadata Reenters -js_tls_client_is_connected Leaf +js_tls_client_is_connected Reenters js_tls_client_preflight Reenters -js_tls_client_record_closed Leaf -js_tls_client_record_connected Leaf +js_tls_client_record_closed Reenters +js_tls_client_record_connected Reenters js_tls_client_record_start Reenters js_tls_convert_alpn_protocols Reenters js_tls_create_secure_context Reenters @@ -3290,7 +3290,7 @@ js_tls_socket_get_protocol AllocOnly js_tls_socket_get_session Reenters js_tls_socket_get_shared_sigalgs Reenters js_tls_socket_get_x509_certificate Reenters -js_tls_socket_is_session_reused Leaf +js_tls_socket_is_session_reused Reenters js_tls_socket_set_key_cert Reenters js_tls_socket_set_max_send_fragment ThrowOnly js_tls_tlssocket_constructor Reenters diff --git a/crates/perry-codegen/src/gc_effects/windows-x86_64.tsv b/crates/perry-codegen/src/gc_effects/windows-x86_64.tsv index c9267f8cfe..c0e2b321dd 100644 --- a/crates/perry-codegen/src/gc_effects/windows-x86_64.tsv +++ b/crates/perry-codegen/src/gc_effects/windows-x86_64.tsv @@ -3246,10 +3246,10 @@ js_timer_validate_callback Reenters js_tls_check_server_identity Reenters js_tls_client_check_identity Reenters js_tls_client_check_identity_from_metadata Reenters -js_tls_client_is_connected Leaf +js_tls_client_is_connected Reenters js_tls_client_preflight Reenters -js_tls_client_record_closed Leaf -js_tls_client_record_connected Leaf +js_tls_client_record_closed Reenters +js_tls_client_record_connected Reenters js_tls_client_record_start Reenters js_tls_convert_alpn_protocols Reenters js_tls_create_secure_context Reenters @@ -3290,7 +3290,7 @@ js_tls_socket_get_protocol Reenters js_tls_socket_get_session Reenters js_tls_socket_get_shared_sigalgs Reenters js_tls_socket_get_x509_certificate Reenters -js_tls_socket_is_session_reused Leaf +js_tls_socket_is_session_reused Reenters js_tls_socket_set_key_cert Reenters js_tls_socket_set_max_send_fragment Reenters js_tls_tlssocket_constructor Reenters diff --git a/crates/perry-codegen/src/lower_call/native_module_dispatch.rs b/crates/perry-codegen/src/lower_call/native_module_dispatch.rs index 91eae952f1..3c632aa074 100644 --- a/crates/perry-codegen/src/lower_call/native_module_dispatch.rs +++ b/crates/perry-codegen/src/lower_call/native_module_dispatch.rs @@ -498,14 +498,26 @@ mod ffi_return_type_tests { } #[test] - fn net_socket_write_and_end_match_their_float_ffi_abi() { - for method in ["write", "end"] { - let sig = super::native_module_lookup("net", true, method, Some("Socket")) - .unwrap_or_else(|| panic!("net.Socket.{method} must resolve")); + fn net_socket_methods_keep_their_external_float_abi_without_id_lowering() { + let abi = include_str!("../wasm32/runtime_abi.tsv"); + for (method, symbol) in [ + ("write", "js_ext_net_socket_write3"), + ("end", "js_ext_net_socket_end3"), + ] { + assert!( + super::native_module_lookup("net", true, method, Some("Socket")).is_none(), + "net.Socket.{method} must use its ordinary prototype method" + ); + let signature = abi + .lines() + .find_map(|line| { + let columns: Vec<_> = line.split('\t').collect(); + (columns.first() == Some(&symbol)).then_some(columns) + }) + .unwrap_or_else(|| panic!("{symbol} must remain available to linked archives")); assert_eq!( - sig.args, - [super::NativeArgKind::F64; 3], - "net.Socket.{method} takes three NaN-boxed f64 arguments" + signature[2], "i64,f64,f64,f64", + "{symbol} takes an ordinary owner address and three NaN-boxed arguments" ); } } diff --git a/crates/perry-codegen/src/lower_call/native_table/bun.rs b/crates/perry-codegen/src/lower_call/native_table/bun.rs index cf99a88dd9..cf343a1150 100644 --- a/crates/perry-codegen/src/lower_call/native_table/bun.rs +++ b/crates/perry-codegen/src/lower_call/native_table/bun.rs @@ -61,7 +61,7 @@ pub(crate) const BUN_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_bun_tcp_listen", args: &[NA_F64], - ret: NR_HANDLE_ID, + ret: NR_GCPTR, }, NativeModSig { module: "bun", diff --git a/crates/perry-codegen/src/lower_call/native_table/net_classes_state.rs b/crates/perry-codegen/src/lower_call/native_table/net_classes_state.rs index b501f05fab..2f77d0e3e7 100644 --- a/crates/perry-codegen/src/lower_call/native_table/net_classes_state.rs +++ b/crates/perry-codegen/src/lower_call/native_table/net_classes_state.rs @@ -4,24 +4,6 @@ pub(super) const NET_CLASSES_STATE_ROWS: &[NativeModSig] = &[ // #11227 — a typed `net.Server` had no `prependListener` / // `prependOnceListener` row (the Socket rows live in `net_events.rs`, // which is at the file-size cap), so the call registered nothing. - NativeModSig { - module: "net", - has_receiver: true, - method: "prependListener", - class_filter: Some("Server"), - runtime: "js_net_server_prepend_listener", - args: &[NA_STR, NA_PTR], - ret: NR_HANDLE_ID, - }, - NativeModSig { - module: "net", - has_receiver: true, - method: "prependOnceListener", - class_filter: Some("Server"), - runtime: "js_net_server_prepend_once_listener", - args: &[NA_STR, NA_PTR], - ret: NR_HANDLE_ID, - }, NativeModSig { module: "net", has_receiver: false, @@ -29,7 +11,7 @@ pub(super) const NET_CLASSES_STATE_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_net_block_list_new", args: &[], - ret: NR_HANDLE_ID, + ret: NR_GCPTR, }, NativeModSig { module: "net", @@ -38,7 +20,7 @@ pub(super) const NET_CLASSES_STATE_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_net_socket_address_new", args: &[NA_F64], - ret: NR_HANDLE_ID, + ret: NR_GCPTR, }, NativeModSig { module: "net", @@ -58,166 +40,4 @@ pub(super) const NET_CLASSES_STATE_ROWS: &[NativeModSig] = &[ args: &[NA_F64], ret: NR_F64, }, - NativeModSig { - module: "net", - has_receiver: true, - method: "getTypeOfService", - class_filter: Some("Socket"), - runtime: "js_net_socket_get_type_of_service", - args: &[], - ret: NR_F64, - }, - NativeModSig { - module: "net", - has_receiver: true, - method: "setTypeOfService", - class_filter: Some("Socket"), - runtime: "js_net_socket_set_type_of_service", - args: &[NA_F64], - ret: NR_HANDLE_ID, - }, - NativeModSig { - module: "net", - has_receiver: true, - method: "addAddress", - class_filter: Some("BlockList"), - runtime: "js_net_block_list_add_address", - args: &[NA_STR, NA_STR], - ret: NR_F64, - }, - NativeModSig { - module: "net", - has_receiver: true, - method: "addRange", - class_filter: Some("BlockList"), - runtime: "js_net_block_list_add_range", - args: &[NA_STR, NA_STR, NA_STR], - ret: NR_F64, - }, - NativeModSig { - module: "net", - has_receiver: true, - method: "addSubnet", - class_filter: Some("BlockList"), - runtime: "js_net_block_list_add_subnet", - args: &[NA_STR, NA_F64, NA_STR], - ret: NR_F64, - }, - NativeModSig { - module: "net", - has_receiver: true, - method: "check", - class_filter: Some("BlockList"), - runtime: "js_net_block_list_check", - args: &[NA_STR, NA_STR], - ret: NR_F64, - }, - NativeModSig { - module: "net", - has_receiver: true, - method: "toJSON", - class_filter: Some("BlockList"), - runtime: "js_net_block_list_to_json", - args: &[], - ret: NR_F64, - }, - NativeModSig { - module: "net", - has_receiver: true, - method: "fromJSON", - class_filter: Some("BlockList"), - runtime: "js_net_block_list_from_json", - args: &[NA_F64], - ret: NR_F64, - }, - NativeModSig { - module: "net", - has_receiver: true, - method: "rules", - class_filter: Some("BlockList"), - runtime: "js_net_block_list_rules", - args: &[], - ret: NR_GCPTR, - }, - NativeModSig { - module: "net", - has_receiver: true, - method: "address", - class_filter: Some("SocketAddress"), - runtime: "js_net_socket_address_get_address", - args: &[], - ret: NR_STR, - }, - NativeModSig { - module: "net", - has_receiver: true, - method: "family", - class_filter: Some("SocketAddress"), - runtime: "js_net_socket_address_get_family", - args: &[], - ret: NR_STR, - }, - NativeModSig { - module: "net", - has_receiver: true, - method: "port", - class_filter: Some("SocketAddress"), - runtime: "js_net_socket_address_get_port", - args: &[], - ret: NR_F64, - }, - NativeModSig { - module: "net", - has_receiver: true, - method: "flowlabel", - class_filter: Some("SocketAddress"), - runtime: "js_net_socket_address_get_flowlabel", - args: &[], - ret: NR_F64, - }, - NativeModSig { - module: "net", - has_receiver: true, - method: "listening", - class_filter: Some("Server"), - runtime: "js_net_server_get_listening", - args: &[], - ret: NR_F64, - }, - NativeModSig { - module: "net", - has_receiver: true, - method: "maxConnections", - class_filter: Some("Server"), - runtime: "js_net_server_get_max_connections", - args: &[], - ret: NR_F64, - }, - NativeModSig { - module: "net", - has_receiver: true, - method: "dropMaxConnection", - class_filter: Some("Server"), - runtime: "js_net_server_get_drop_max_connection", - args: &[], - ret: NR_F64, - }, - NativeModSig { - module: "net", - has_receiver: true, - method: "__set_maxConnections", - class_filter: Some("Server"), - runtime: "js_net_server_set_max_connections", - args: &[NA_F64], - ret: NR_F64, - }, - NativeModSig { - module: "net", - has_receiver: true, - method: "__set_dropMaxConnection", - class_filter: Some("Server"), - runtime: "js_net_server_set_drop_max_connection", - args: &[NA_F64], - ret: NR_F64, - }, ]; diff --git a/crates/perry-codegen/src/lower_call/native_table/net_events.rs b/crates/perry-codegen/src/lower_call/native_table/net_events.rs index 6c2a0248a5..1fa6c7822d 100644 --- a/crates/perry-codegen/src/lower_call/native_table/net_events.rs +++ b/crates/perry-codegen/src/lower_call/native_table/net_events.rs @@ -24,7 +24,7 @@ pub(super) const NET_EVENTS_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_ext_net_socket_connect", args: &[NA_F64, NA_F64, NA_F64], - ret: NR_HANDLE_ID, + ret: NR_GCPTR, }, // Factory alias: `net.connect(...)` is the spec'd alias for // `net.createConnection(...)`. Pre-issue-#422 only the @@ -39,7 +39,7 @@ pub(super) const NET_EVENTS_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_ext_net_socket_connect", args: &[NA_F64, NA_F64, NA_F64], - ret: NR_HANDLE_ID, + ret: NR_GCPTR, }, // `net.createServer` and callable `net.Server` are normally rewritten to // `Expr::NetCreateServer` so the one-arg listener shorthand is preserved. @@ -52,7 +52,7 @@ pub(super) const NET_EVENTS_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_ext_net_create_server", args: &[NA_PTR, NA_PTR], - ret: NR_HANDLE_ID, + ret: NR_GCPTR, }, NativeModSig { module: "net", @@ -61,7 +61,7 @@ pub(super) const NET_EVENTS_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_ext_net_create_server", args: &[NA_PTR, NA_PTR], - ret: NR_HANDLE_ID, + ret: NR_GCPTR, }, // Constructor: `new net.Socket()` allocates an unconnected socket // handle whose TCP connection is deferred until `sock.connect(port, @@ -76,9 +76,9 @@ pub(super) const NET_EVENTS_ROWS: &[NativeModSig] = &[ has_receiver: false, method: "Socket", class_filter: None, - runtime: "js_net_socket_alloc", - args: &[], - ret: NR_HANDLE_ID, + runtime: "js_ext_net_socket_alloc", + args: &[NA_F64], + ret: NR_GCPTR, }, NativeModSig { module: "net", @@ -167,454 +167,38 @@ pub(super) const NET_EVENTS_ROWS: &[NativeModSig] = &[ // TCP connection on a `new net.Socket()`-allocated handle. Twin of // the `createConnection` factory above — both end up in the same // tokio task body via `run_socket_task`. - NativeModSig { - module: "net", - has_receiver: true, - method: "connect", - class_filter: Some("Socket"), - runtime: "js_net_socket_method_connect", - // Keep every slot raw so port/host, options, and path overloads reach - // the runtime without callback-to-string coercion. - args: &[NA_F64, NA_F64, NA_F64], - ret: NR_VOID, - }, - NativeModSig { - module: "net", - has_receiver: true, - method: "write", - class_filter: Some("Socket"), - runtime: "js_ext_net_socket_write3", - // Issue #1131 — pass each full NaN-boxed JS value as an f64 so the - // runtime can probe Buffer-vs-string-vs-number and read through the - // correct header layout. This must be NA_F64, not NA_JSV: the Rust FFI - // receives f64 arguments, while NA_JSV uses the integer ABI for - // runtimes whose signatures explicitly take raw i64 bits. - args: &[NA_F64, NA_F64, NA_F64], - // #11111 — Node's boolean (NaN-boxed), not `undefined`. - ret: NR_F64, - }, - NativeModSig { - module: "net", - has_receiver: true, - method: "end", - class_filter: Some("Socket"), - runtime: "js_ext_net_socket_end3", - // Issue #1852 — `socket.end([data])` writes the optional final chunk - // before half-closing. NA_F64 preserves the full NaN-boxed value in - // the floating-point ABI expected by `js_ext_net_socket_end3`; the - // no-arg form pads every missing slot with JS `undefined`. - args: &[NA_F64, NA_F64, NA_F64], - ret: NR_VOID, - }, - NativeModSig { - module: "net", - has_receiver: true, - method: "destroy", - class_filter: Some("Socket"), - runtime: "js_net_socket_destroy", - args: &[], - ret: NR_VOID, - }, - NativeModSig { - module: "net", - has_receiver: true, - method: "on", - class_filter: Some("Socket"), - // #10442 — was `ret: NR_VOID`. `js_ext_net_socket_on` (the runtime - // symbol both this row and `addListener` below call) now returns the - // socket handle (see `perry-ext-net/src/handle_exports.rs`), so a - // typed `const sock: net.Socket` can chain `sock.on(...).on(...)` - // the same way the untyped/`once`/`setNoDelay` paths already did. - runtime: "js_ext_net_socket_on", - args: &[NA_STR, NA_PTR], - ret: NR_HANDLE_ID, - }, // #10441 — front-inserting variants of `on`. Absent entirely pre-fix: // a typed `net.Socket` receiver fell through to a plain property read // for `prependListener`/`prependOnceListener` and got `undefined`, // matching the untyped-dispatch gap fixed in `dispatch.rs`'s // `socket_method_name`. - NativeModSig { - module: "net", - has_receiver: true, - method: "prependListener", - class_filter: Some("Socket"), - runtime: "js_net_socket_prepend_listener", - args: &[NA_STR, NA_PTR], - ret: NR_HANDLE_ID, - }, - NativeModSig { - module: "net", - has_receiver: true, - method: "prependOnceListener", - class_filter: Some("Socket"), - runtime: "js_net_socket_prepend_once_listener", - args: &[NA_STR, NA_PTR], - ret: NR_HANDLE_ID, - }, // #10444 — `net.Socket` is a `stream.Duplex`; `pipe`/`unpipe` had no // typed-receiver row at all (nor an untyped one — see // `dispatch.rs`'s `socket_method_name`). `js_net_socket_pipe` returns // `dest` (an arbitrary JSValue, NOT a socket handle — hence NR_F64, the // same return kind the generic `stream` table's own `pipe` row uses) // for chaining, matching Node. - NativeModSig { - module: "net", - has_receiver: true, - method: "pipe", - class_filter: Some("Socket"), - runtime: "js_net_socket_pipe", - args: &[NA_F64, NA_F64], - ret: NR_F64, - }, - NativeModSig { - module: "net", - has_receiver: true, - method: "unpipe", - class_filter: Some("Socket"), - runtime: "js_net_socket_unpipe", - args: &[NA_F64], - ret: NR_HANDLE_ID, - }, // Issue #1852 — chainable no-op `net.Socket` option setters. Perry's // TCP transport doesn't model Nagle/keep-alive/idle-timeout or read // back-pressure yet, but the methods must exist + be callable (pre-fix // they threw "x is not a function" — the radar's "value() missing" // cluster). Each returns the socket handle so chained forms keep // dispatching. `args: &[]` ignores the option arguments. - NativeModSig { - module: "net", - has_receiver: true, - method: "setNoDelay", - class_filter: Some("Socket"), - runtime: "js_net_socket_noop_self", - args: &[], - ret: NR_HANDLE_ID, - }, - NativeModSig { - module: "net", - has_receiver: true, - method: "setKeepAlive", - class_filter: Some("Socket"), - runtime: "js_net_socket_noop_self", - args: &[], - ret: NR_HANDLE_ID, - }, - NativeModSig { - module: "net", - has_receiver: true, - method: "setTimeout", - class_filter: Some("Socket"), - // #2013: validate `msecs` (number, non-negative finite); the optional - // callback is passed through but ignored. Returns the socket handle. - runtime: "js_net_socket_set_timeout", - args: &[NA_F64, NA_PTR], - ret: NR_HANDLE_ID, - }, - NativeModSig { - module: "net", - has_receiver: true, - method: "setEncoding", - class_filter: Some("Socket"), - // #4973: real setEncoding — switches 'data' delivery to strings. - runtime: "js_net_socket_set_encoding", - args: &[NA_STR], - ret: NR_HANDLE_ID, - }, - NativeModSig { - module: "net", - has_receiver: true, - method: "pause", - class_filter: Some("Socket"), - runtime: "js_net_socket_noop_self", - args: &[], - ret: NR_HANDLE_ID, - }, - NativeModSig { - module: "net", - has_receiver: true, - method: "resume", - class_filter: Some("Socket"), - runtime: "js_net_socket_noop_self", - args: &[], - ret: NR_HANDLE_ID, - }, - NativeModSig { - module: "net", - has_receiver: true, - method: "ref", - class_filter: Some("Socket"), - runtime: "js_net_socket_ref", - args: &[], - ret: NR_HANDLE_ID, - }, - NativeModSig { - module: "net", - has_receiver: true, - method: "unref", - class_filter: Some("Socket"), - runtime: "js_net_socket_unref", - args: &[], - ret: NR_HANDLE_ID, - }, - NativeModSig { - module: "net", - has_receiver: true, - method: "writableCorked", - class_filter: Some("Socket"), - runtime: "js_net_socket_get_writable_corked", - args: &[], - ret: NR_F64, - }, - NativeModSig { - module: "net", - has_receiver: true, - method: "cork", - class_filter: Some("Socket"), - runtime: "js_net_socket_cork", - args: &[], - ret: NR_HANDLE_ID, - }, - NativeModSig { - module: "net", - has_receiver: true, - method: "uncork", - class_filter: Some("Socket"), - runtime: "js_net_socket_uncork", - args: &[], - ret: NR_HANDLE_ID, - }, - NativeModSig { - module: "net", - has_receiver: true, - method: "setDefaultEncoding", - class_filter: Some("Socket"), - runtime: "js_net_socket_noop_self", - args: &[], - ret: NR_HANDLE_ID, - }, // Issue #2131 — `socket.address()` returns the local bind address // (`{ address, family, port }`). Captured at connect/accept time and // emitted as JSON through `NR_OBJ_FROM_JSON_STR` so user code reads // a real object whose `.port` is a number — closes the "undefined.address" // cluster on the radar. - NativeModSig { - module: "net", - has_receiver: true, - method: "address", - class_filter: Some("Socket"), - runtime: "js_net_socket_address", - args: &[], - ret: NR_OBJ_FROM_JSON_STR, - }, // #2549 — `net.Socket` state / counter / metadata property getters. // Socket rows remain generic so they still match in the fallback pass when // the HIR preserves a more specific net class filter for nearby accessors // such as `Server.listening` and `SocketAddress.address`. - NativeModSig { - module: "net", - has_receiver: true, - method: "read", - class_filter: None, - runtime: "js_net_socket_read", - args: &[NA_F64], - ret: NR_F64, - }, - NativeModSig { - module: "net", - has_receiver: true, - method: "pending", - class_filter: None, - runtime: "js_net_socket_get_pending", - args: &[], - ret: NR_F64, - }, - NativeModSig { - module: "net", - has_receiver: true, - method: "connecting", - class_filter: None, - runtime: "js_net_socket_get_connecting", - args: &[], - ret: NR_F64, - }, - NativeModSig { - module: "net", - has_receiver: true, - method: "destroyed", - class_filter: None, - runtime: "js_net_socket_get_destroyed", - args: &[], - ret: NR_F64, - }, - NativeModSig { - module: "net", - has_receiver: true, - method: "readyState", - class_filter: None, - runtime: "js_net_socket_get_ready_state", - args: &[], - ret: NR_STR, - }, // #10465 — `writable`/`readable`/`writableEnded`/`readableEnded`/ // `_writableState`/`_readableState` were entirely absent from this // table (a typed `net.Socket` read `undefined` for all six; pg's // `Connection._send` gates every protocol write on `this.stream.writable` // being truthy, so the audit's client silently dropped its startup // message and hung until the connection timeout). - NativeModSig { - module: "net", - has_receiver: true, - method: "writable", - class_filter: None, - runtime: "js_net_socket_get_writable", - args: &[], - ret: NR_F64, - }, - NativeModSig { - module: "net", - has_receiver: true, - method: "readable", - class_filter: None, - runtime: "js_net_socket_get_readable", - args: &[], - ret: NR_F64, - }, - NativeModSig { - module: "net", - has_receiver: true, - method: "writableEnded", - class_filter: None, - runtime: "js_net_socket_get_writable_ended", - args: &[], - ret: NR_F64, - }, - NativeModSig { - module: "net", - has_receiver: true, - method: "readableEnded", - class_filter: None, - runtime: "js_net_socket_get_readable_ended", - args: &[], - ret: NR_F64, - }, - NativeModSig { - module: "net", - has_receiver: true, - method: "_writableState", - class_filter: None, - runtime: "js_net_socket_get_writable_state", - args: &[], - ret: NR_OBJ_FROM_JSON_STR, - }, - NativeModSig { - module: "net", - has_receiver: true, - method: "_readableState", - class_filter: None, - runtime: "js_net_socket_get_readable_state", - args: &[], - ret: NR_OBJ_FROM_JSON_STR, - }, - NativeModSig { - module: "net", - has_receiver: true, - method: "bytesRead", - class_filter: None, - runtime: "js_net_socket_get_bytes_read", - args: &[], - ret: NR_F64, - }, - NativeModSig { - module: "net", - has_receiver: true, - method: "bytesWritten", - class_filter: None, - runtime: "js_net_socket_get_bytes_written", - args: &[], - ret: NR_F64, - }, - NativeModSig { - module: "net", - has_receiver: true, - method: "timeout", - class_filter: None, - runtime: "js_net_socket_get_timeout", - args: &[], - ret: NR_F64, - }, - NativeModSig { - module: "net", - has_receiver: true, - method: "localAddress", - class_filter: None, - runtime: "js_net_socket_get_local_address", - args: &[], - ret: NR_F64, - }, - NativeModSig { - module: "net", - has_receiver: true, - method: "localPort", - class_filter: None, - runtime: "js_net_socket_get_local_port", - args: &[], - ret: NR_F64, - }, - NativeModSig { - module: "net", - has_receiver: true, - method: "localFamily", - class_filter: None, - runtime: "js_net_socket_get_local_family", - args: &[], - ret: NR_F64, - }, - NativeModSig { - module: "net", - has_receiver: true, - method: "remoteAddress", - class_filter: None, - runtime: "js_net_socket_get_remote_address", - args: &[], - ret: NR_F64, - }, - NativeModSig { - module: "net", - has_receiver: true, - method: "remotePort", - class_filter: None, - runtime: "js_net_socket_get_remote_port", - args: &[], - ret: NR_F64, - }, - NativeModSig { - module: "net", - has_receiver: true, - method: "remoteFamily", - class_filter: None, - runtime: "js_net_socket_get_remote_family", - args: &[], - ret: NR_F64, - }, - NativeModSig { - module: "net", - has_receiver: true, - method: "bufferSize", - class_filter: None, - runtime: "js_net_socket_get_buffer_size", - args: &[], - ret: NR_F64, - }, - NativeModSig { - module: "net", - has_receiver: true, - method: "autoSelectFamilyAttemptedAddresses", - class_filter: None, - runtime: "js_net_socket_get_auto_select_family_attempted_addresses", - args: &[], - ret: NR_F64, - }, // Issue #2131 — EventEmitter surface beyond `on`/`addListener`. // `once` flags the listener in a side-table so the pump removes it // after the next event fires. `off`/`removeListener` delete a @@ -624,74 +208,6 @@ pub(super) const NET_EVENTS_ROWS: &[NativeModSig] = &[ // pre-#2131 returned "x is not a function" for all of these // (radar's "function should not have been called" + "undefined.on" // clusters). - NativeModSig { - module: "net", - has_receiver: true, - method: "once", - class_filter: Some("Socket"), - // Use ext-net's collision-proof symbol. The bundled stdlib exports a - // same-named `js_net_socket_once`; in an auto-optimized link the - // shared name can resolve to that empty registry and silently drop - // listeners on sockets owned by perry-ext-net. - runtime: "js_ext_net_socket_once", - args: &[NA_STR, NA_PTR], - ret: NR_HANDLE_ID, - }, - NativeModSig { - module: "net", - has_receiver: true, - method: "addListener", - class_filter: Some("Socket"), - // #10442 — same fix as the `on` row above (same runtime symbol). - runtime: "js_ext_net_socket_on", - args: &[NA_STR, NA_PTR], - ret: NR_HANDLE_ID, - }, - NativeModSig { - module: "net", - has_receiver: true, - method: "off", - class_filter: Some("Socket"), - runtime: "js_net_socket_remove_listener", - args: &[NA_STR, NA_PTR], - ret: NR_HANDLE_ID, - }, - NativeModSig { - module: "net", - has_receiver: true, - method: "removeListener", - class_filter: Some("Socket"), - runtime: "js_net_socket_remove_listener", - args: &[NA_STR, NA_PTR], - ret: NR_HANDLE_ID, - }, - NativeModSig { - module: "net", - has_receiver: true, - method: "removeAllListeners", - class_filter: Some("Socket"), - runtime: "js_net_socket_remove_all_listeners", - args: &[NA_STR], - ret: NR_HANDLE_ID, - }, - NativeModSig { - module: "net", - has_receiver: true, - method: "listenerCount", - class_filter: Some("Socket"), - runtime: "js_net_socket_listener_count", - args: &[NA_STR], - ret: NR_F64, - }, - NativeModSig { - module: "net", - has_receiver: true, - method: "eventNames", - class_filter: Some("Socket"), - runtime: "js_net_socket_event_names", - args: &[], - ret: NR_OBJ_FROM_JSON_STR, - }, // Issue #2211 — `socket.listeners(event)` / `socket.rawListeners(event)`. // Returns a real JS array of registered callbacks; consumers do // `socket.listeners('timeout').length` etc. Returned as NR_PTR so @@ -700,51 +216,15 @@ pub(super) const NET_EVENTS_ROWS: &[NativeModSig] = &[ // `listeners` and `rawListeners` share an impl — the onceWrapper // distinction is unobservable to callers that read the array before // any event has fired (which is the shape the radar tests use). - NativeModSig { - module: "net", - has_receiver: true, - method: "listeners", - class_filter: Some("Socket"), - runtime: "js_net_socket_listeners", - args: &[NA_STR], - ret: NR_GCPTR, - }, - NativeModSig { - module: "net", - has_receiver: true, - method: "rawListeners", - class_filter: Some("Socket"), - runtime: "js_net_socket_raw_listeners", - args: &[NA_STR], - ret: NR_GCPTR, - }, // Issue #2131 — `socket.resetAndDestroy()` is the "send RST then // destroy" variant; we alias to `destroy()` (FIN-then-close) for // now since the connected peer treats both as an abrupt close in // the cases the parity radar exercises. - NativeModSig { - module: "net", - has_receiver: true, - method: "resetAndDestroy", - class_filter: Some("Socket"), - runtime: "js_net_socket_reset_and_destroy", - args: &[], - ret: NR_HANDLE_ID, - }, // upgradeToTLS returns a Promise (handle pointer) — await it to wait // for the TLS handshake before sending anything over the upgraded stream. // upgradeToTLS(servername, verify): verify is 0/1 (number, not bool). // verify=1 uses the system trust store + hostname check (sslmode=verify-full); // verify=0 accepts any cert (sslmode=require, for local self-signed DBs). - NativeModSig { - module: "net", - has_receiver: true, - method: "upgradeToTLS", - class_filter: Some("Socket"), - runtime: "js_net_socket_upgrade_tls", - args: &[NA_STR, NA_F64], - ret: NR_PROMISE, - }, // Factory: `tls.connect(...)` opens plain TCP then runs a full TLS // handshake before firing 'connect'. Returns a Socket handle that behaves // identically to one produced by net.createConnection (same @@ -761,7 +241,7 @@ pub(super) const NET_EVENTS_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_ext_tls_connect", args: &[NA_F64, NA_F64, NA_F64, NA_F64], - ret: NR_HANDLE_ID, + ret: NR_GCPTR, }, // ========== net.Server (issue #1123 followup) ========== // Server-side TCP via `net.createServer(...).listen(port, cb)`. The @@ -771,167 +251,16 @@ pub(super) const NET_EVENTS_ROWS: &[NativeModSig] = &[ // `("net", "Server")` in HIR lowering. Shape mirrors // `js_node_http_server_*` from perry-ext-http (signatures // are deliberately parallel so the codegen side reads the same). - NativeModSig { - module: "net", - has_receiver: true, - method: "listen", - class_filter: Some("Server"), - runtime: "js_net_server_listen", - args: &[NA_F64, NA_F64, NA_F64], - ret: NR_VOID, - }, - NativeModSig { - module: "net", - has_receiver: true, - method: "close", - class_filter: Some("Server"), - runtime: "js_net_server_close", - args: &[NA_PTR], - ret: NR_VOID, - }, - NativeModSig { - module: "net", - has_receiver: true, - method: "address", - class_filter: Some("Server"), - runtime: "js_net_server_address", - args: &[], - // Issue #1852 — `js_net_server_address` returns a JSON string - // (`{"port":…,"address":…,"family":…}` or `"null"`). - // NR_OBJ_FROM_JSON_STR pipes it through `js_json_parse_or_null` - // so `server.address().port` reads a real number. Pre-fix the - // NR_PTR kind NaN-boxed the StringHeader as a POINTER_TAG object, - // so `.port` came back `undefined` (the radar's "undefined.address" - // cluster). - ret: NR_OBJ_FROM_JSON_STR, - }, - NativeModSig { - module: "net", - has_receiver: true, - method: "on", - class_filter: Some("Server"), - runtime: "js_net_server_on", - args: &[NA_STR, NA_PTR], - ret: NR_VOID, - }, - NativeModSig { - module: "net", - has_receiver: true, - method: "addListener", - class_filter: Some("Server"), - runtime: "js_net_server_on", - args: &[NA_STR, NA_PTR], - ret: NR_VOID, - }, // Issue #1852 — chainable no-op `net.Server` option setters // (`ref`/`unref`/`setTimeout`). Same rationale as the Socket stubs // above: callable + chainable, options ignored. - NativeModSig { - module: "net", - has_receiver: true, - method: "ref", - class_filter: Some("Server"), - runtime: "js_net_server_noop_self", - args: &[], - ret: NR_HANDLE_ID, - }, - NativeModSig { - module: "net", - has_receiver: true, - method: "unref", - class_filter: Some("Server"), - runtime: "js_net_server_noop_self", - args: &[], - ret: NR_HANDLE_ID, - }, - NativeModSig { - module: "net", - has_receiver: true, - method: "setTimeout", - class_filter: Some("Server"), - runtime: "js_net_server_noop_self", - args: &[], - ret: NR_HANDLE_ID, - }, // Issue #2131 — `net.Server` EventEmitter surface beyond // `on`/`addListener`. Same shape as the Socket entries above; the // FFI implementations share the underlying `statics::listeners()` // map and `statics::once_flags()` side-table. - NativeModSig { - module: "net", - has_receiver: true, - method: "once", - class_filter: Some("Server"), - runtime: "js_net_server_once", - args: &[NA_STR, NA_PTR], - ret: NR_HANDLE_ID, - }, - NativeModSig { - module: "net", - has_receiver: true, - method: "off", - class_filter: Some("Server"), - runtime: "js_net_server_remove_listener", - args: &[NA_STR, NA_PTR], - ret: NR_HANDLE_ID, - }, - NativeModSig { - module: "net", - has_receiver: true, - method: "removeListener", - class_filter: Some("Server"), - runtime: "js_net_server_remove_listener", - args: &[NA_STR, NA_PTR], - ret: NR_HANDLE_ID, - }, - NativeModSig { - module: "net", - has_receiver: true, - method: "removeAllListeners", - class_filter: Some("Server"), - runtime: "js_net_server_remove_all_listeners", - args: &[NA_STR], - ret: NR_HANDLE_ID, - }, - NativeModSig { - module: "net", - has_receiver: true, - method: "listenerCount", - class_filter: Some("Server"), - runtime: "js_net_server_listener_count", - args: &[NA_STR], - ret: NR_F64, - }, - NativeModSig { - module: "net", - has_receiver: true, - method: "eventNames", - class_filter: Some("Server"), - runtime: "js_net_server_event_names", - args: &[], - ret: NR_OBJ_FROM_JSON_STR, - }, // Issue #2211 — mirror of the Socket listeners/rawListeners surface // for net.Server. Same impl since socket and server handles share // the `statics::listeners()` map keyed by id. - NativeModSig { - module: "net", - has_receiver: true, - method: "listeners", - class_filter: Some("Server"), - runtime: "js_net_server_listeners", - args: &[NA_STR], - ret: NR_GCPTR, - }, - NativeModSig { - module: "net", - has_receiver: true, - method: "rawListeners", - class_filter: Some("Server"), - runtime: "js_net_server_raw_listeners", - args: &[NA_STR], - ret: NR_GCPTR, - }, // ========== node:stream — Readable.from / Duplex.from (#631/#1532) ========== // The other stream constructors (`new Readable(opts)` etc.) are wired // via `lower_builtin_new` so the codegen can carry the closure-fields diff --git a/crates/perry-codegen/src/runtime_decls/stdlib_ffi/language_core.rs b/crates/perry-codegen/src/runtime_decls/stdlib_ffi/language_core.rs index b386b8d5a9..e7507927ea 100644 --- a/crates/perry-codegen/src/runtime_decls/stdlib_ffi/language_core.rs +++ b/crates/perry-codegen/src/runtime_decls/stdlib_ffi/language_core.rs @@ -262,6 +262,12 @@ pub(crate) fn declare_core(module: &mut LlModule) { module.declare_function("js_net_socket_once", I64, &[I64, I64, I64]); module.declare_function("js_ext_net_socket_once", I64, &[I64, I64, I64]); module.declare_function("js_ext_net_socket_on", VOID, &[I64, I64, I64]); + module.declare_function( + "js_net_subclass_init", + DOUBLE, + &[DOUBLE, DOUBLE, DOUBLE, I32], + ); + module.declare_function("js_ext_net_socket_alloc", I64, &[DOUBLE]); module.declare_function("js_ext_tls_connect", I64, &[DOUBLE, DOUBLE, DOUBLE, DOUBLE]); module.declare_function("js_net_socket_remove_listener", I64, &[I64, I64, I64]); module.declare_function("js_net_socket_remove_all_listeners", I64, &[I64, I64]); diff --git a/crates/perry-codegen/src/wasm32/runtime_abi.tsv b/crates/perry-codegen/src/wasm32/runtime_abi.tsv index 0db6a62965..96160a66e8 100644 --- a/crates/perry-codegen/src/wasm32/runtime_abi.tsv +++ b/crates/perry-codegen/src/wasm32/runtime_abi.tsv @@ -1194,11 +1194,9 @@ js_ext_http_server_response_dispatch_property_set i32s i64,ptr,usize,f64 js_ext_http_server_response_is_handle i32s i64 js_ext_net_create_server i64 i64,i64 js_ext_net_destroy_socket void i64 -js_ext_net_drain_pending i32s js_ext_net_handle_method_dispatch i32s i64,ptr,usize,ptr,usize,ptr js_ext_net_handle_property_dispatch i32s i64,ptr,usize,ptr js_ext_net_handle_property_set_dispatch i32s i64,ptr,usize,f64 -js_ext_net_has_active_handles i32s js_ext_net_is_block_list_handle i32s i64 js_ext_net_is_server_handle i32s i64 js_ext_net_is_socket_address_handle i32s i64 @@ -1207,6 +1205,7 @@ js_ext_net_nm_install void js_ext_net_register_http_agent_socket_event_hook void ptr js_ext_net_set_http_agent_phase void i64,i32s js_ext_net_set_tls_metadata void i64,i32s,ptr,usize,ptr,usize,i64,i32s +js_ext_net_socket_alloc i64 f64 js_ext_net_socket_connect i64 f64,f64,f64 js_ext_net_socket_emit f64 i64,i64,ptr,usize js_ext_net_socket_emit_abort_error void i64 @@ -2113,6 +2112,7 @@ js_net_socket_unpipe i64 i64,f64 js_net_socket_unref i64 i64 js_net_socket_upgrade_tls ptr i64,i64,f64 js_net_socket_write void i64,i64 +js_net_subclass_init f64 f64,f64,f64,i32 js_net_throw_invalid_address void js_net_validate_block_list_prefix i32s f64,f64 js_net_validate_connect_port void f64 @@ -3770,6 +3770,8 @@ js_timer_tick_if_refed i32s js_timer_unref void i64 js_timer_validate_callback i64 f64,i32s js_tls_check_server_identity f64 f64,f64 +js_tls_client_attach_socket_prototype void i64 +js_tls_client_certificate f64 i64,i32,f64 js_tls_client_check_identity f64 i64,f64 js_tls_client_check_identity_from_metadata f64 i64 js_tls_client_is_connected i32s i64 diff --git a/crates/perry-db-turnloop/src/lib.rs b/crates/perry-db-turnloop/src/lib.rs index e53782db25..e86449fc43 100644 --- a/crates/perry-db-turnloop/src/lib.rs +++ b/crates/perry-db-turnloop/src/lib.rs @@ -136,7 +136,8 @@ pub mod subsystem { //! //! Keep this band contiguous and keep it here: it is the one authority for //! the database slots, and `perry-runtime`'s `turnloop_net::sink` carries - //! the full map. The ceiling is `MAX_SUBSYSTEMS` (16). + //! the full map. The ceiling is `MAX_SUBSYSTEMS` (32); phase A protocol sinks use + //! 13, 15 and 18 and leave these reservations intact. /// Formerly `perry-ext-pg` (removed, #10677: npm `pg` now compiles from /// source over `net`). Kept reserved so the database band never renumbers diff --git a/crates/perry-ext-net/Cargo.toml b/crates/perry-ext-net/Cargo.toml index 7e1993bd91..18e937c5f9 100644 --- a/crates/perry-ext-net/Cargo.toml +++ b/crates/perry-ext-net/Cargo.toml @@ -59,4 +59,4 @@ perry-ffi = { workspace = true, features = ["runtime-link"] } # with the real perry-stdlib, so drop the bundled no-op stdlib_stubs that would # otherwise shadow perry-stdlib's real symbols (#6314). `default`: keep the copy # feature-identical to the shipped runtime so gated dispatchers behave (#6303). -perry-runtime = { workspace = true, features = ["default", "stdlib"] } +perry-runtime = { workspace = true, features = ["default", "stdlib", "native-payload-test-census"] } diff --git a/crates/perry-ext-net/src/adopt.rs b/crates/perry-ext-net/src/adopt.rs deleted file mode 100644 index c44ad0e6aa..0000000000 --- a/crates/perry-ext-net/src/adopt.rs +++ /dev/null @@ -1,233 +0,0 @@ -//! #4973 — adopt an already-connected TCP stream as a `net.Socket` -//! (the HTTP raw-`'upgrade'` handoff), plus the base64 helper the -//! string-encoding data delivery uses. Split out of `lib.rs` to keep it -//! under the 2000-line CI gate. - -use crate::{dispatch, ensure_gc_scanner_registered, next_id, statics, turnloop_io, SocketState}; -#[cfg(any(unix, windows))] -use perry_ffi::turnloop_net as tl; -use std::collections::HashMap; - -/// Adopt an already-connected TCP stream as a `net.Socket` handle. -/// -/// Node hands an `'upgrade'` listener the raw connection socket with nothing -/// written to it; this publishes a live stream some other code connected under -/// the standard socket surface (`write` / `end` / `on('data')` / …), exactly -/// like a socket accepted by `net.createServer`. perry-ext-http's own raw -/// `'upgrade'` paths no longer come here — they keep the connection on the -/// loop and move it with `turnloop_net::transfer` ([`adopt_turnloop_upgrade`]). -/// -/// The stream becomes a turnloop socket: its descriptor is handed to the -/// agent's loop (`turnloop_net::adopt_stream`) and read there, **synchronously, -/// on the calling thread**, which must be a JS agent thread that owns that -/// loop — never a transport's worker thread, which asking would make claim a -/// loop nobody turns (`turnloop_io::post_to_owner`). There is deliberately no -/// posting route (#11155). The one this replaced parked the stream in a -/// process-wide map and posted the adoption without naming an agent, so a -/// caller on a tokio worker — which acts for no agent — reached the *primary* -/// agent's loop whoever owned the connection, and any loop draining that map -/// could adopt another agent's stream onto its own thread and heap. A socket -/// belongs to one loop from creation to close; the caller that knows which -/// loop that is is the one that has to be on it. -/// -/// Returns `INVALID_HANDLE`, with the stream closed and nothing registered, -/// when the handle-id band is exhausted, when this thread does not own its -/// agent's loop, or when the loop refuses the descriptor. The caller must treat -/// that as a failed upgrade (Node emits `'error'`), never hand JS the `0` as a -/// socket. Call [`ensure_adopted_socket_dispatch`] before user code touches a -/// socket this returns. -pub fn adopt_upgraded_tcp_stream(stream: std::net::TcpStream) -> i64 { - // Asked before an id is spent: `enabled` is the ownership check, and a - // `false` means there is no loop here to put the descriptor on. - if !turnloop_io::enabled() { - drop(stream); - return perry_ffi::INVALID_HANDLE; - } - let id = next_id(); - // #6441: exhaustion can't throw to a JS frame from here. Drop the stream - // and return the `0` sentinel rather than register a phantom socket. - if id == perry_ffi::INVALID_HANDLE { - drop(stream); - return perry_ffi::INVALID_HANDLE; - } - let local = stream.local_addr().ok(); - let remote = stream.peer_addr().ok(); - statics::sockets().lock().unwrap().insert( - id, - SocketState { - tcp_async_id: 0, - owner_agent: perry_ffi::agent_post::current_agent(), - connect_async_id: 0, - shutdown_async_id: 0, - awaiting_connect: false, - unconnected_write_failed: false, - is_open: true, - raw_fd: None, - refed: true, - local_addr: local, - remote_addr: remote, - raw: None, - destroyed: false, - connecting: false, - has_opened: true, - writable_ended: false, - readable_ended: false, - bytes_read: 0, - bytes_written: 0, - bytes_queued: 0, - cork: crate::cork::CorkBuffer::default(), - need_drain: false, - timeout: None, - type_of_service: 0, - server_id: None, - server_connection_active: false, - tls: Default::default(), - turnloop: true, - }, - ); - statics::listeners() - .lock() - .unwrap() - .insert(id, HashMap::new()); - if !adopt_on_loop(id, stream) { - forget_socket(id); - return perry_ffi::INVALID_HANDLE; - } - id -} - -/// Hand the descriptor to this thread's loop under `id` and start reading it. -/// `false`, with the stream closed, if the driver refused it. -#[cfg(any(unix, windows))] -fn adopt_on_loop(id: i64, stream: std::net::TcpStream) -> bool { - let socket: tl::AdoptedSocket = stream.into(); - match tl::adopt_stream(id, turnloop_io::SUBSYSTEM, socket) { - Ok(()) => { - turnloop_io::start_reading(id); - true - } - Err(_) => false, - } -} - -#[cfg(not(any(unix, windows)))] -fn adopt_on_loop(_id: i64, stream: std::net::TcpStream) -> bool { - drop(stream); - false -} - -/// Undo the registration of a socket the loop would not take. -fn forget_socket(id: i64) { - statics::sockets().lock().unwrap().remove(&id); - statics::listeners().lock().unwrap().remove(&id); -} - -/// Adopt an already-accepted **turnloop** connection as a `net.Socket` (P5). -/// -/// The HTTP `'upgrade'` handoff with no descriptor moving: the runtime's -/// `turnloop_net::transfer` has already pointed the connection's completions -/// at this crate's sink, keeping the id and the outstanding multishot read, so -/// the next byte arrives here with no gap and no resubmission. All this has to -/// do is publish the JS-visible socket record under the same id. -/// -/// Called on the loop thread from perry-ext-http's completion sink, so it may -/// touch the registries directly but must not build JS values — it doesn't. -pub fn adopt_turnloop_upgrade(id: i64) -> bool { - if id == perry_ffi::INVALID_HANDLE { - return false; - } - let local = crate::turnloop_io::local_endpoint(id) - .as_ref() - .and_then(endpoint_to_addr); - let remote = perry_ffi::turnloop_net::peer_address(id) - .as_ref() - .and_then(endpoint_to_addr); - statics::sockets().lock().unwrap().insert( - id, - SocketState { - tcp_async_id: 0, - owner_agent: perry_ffi::agent_post::current_agent(), - connect_async_id: 0, - shutdown_async_id: 0, - awaiting_connect: false, - unconnected_write_failed: false, - is_open: true, - raw_fd: None, - refed: true, - local_addr: local, - remote_addr: remote, - raw: None, - destroyed: false, - // #10465: an accepted socket never went through a connect attempt - // and is open the moment it is registered, so it starts - // `connecting: false` / `has_opened: true`; neither half has been - // ended yet. - connecting: false, - has_opened: true, - writable_ended: false, - readable_ended: false, - bytes_read: 0, - bytes_written: 0, - bytes_queued: 0, - cork: crate::cork::CorkBuffer::default(), - need_drain: false, - timeout: None, - type_of_service: 0, - server_id: None, - server_connection_active: false, - tls: Default::default(), - turnloop: true, - }, - ); - statics::listeners() - .lock() - .unwrap() - .insert(id, HashMap::new()); - true -} - -fn endpoint_to_addr(endpoint: &perry_ffi::turnloop_net::Endpoint) -> Option { - endpoint - .address - .parse::() - .ok() - .map(|ip| std::net::SocketAddr::new(ip, endpoint.port)) -} - -/// Main-thread companion to `adopt_upgraded_tcp_stream`: registers the GC -/// root scanner and the runtime handle-dispatch/pump extensions so an -/// adopted socket's methods, events, and liveness work even when no other -/// `js_net_*` entry point has run yet (an http-only program receiving a raw -/// upgrade). -pub fn ensure_adopted_socket_dispatch() { - ensure_gc_scanner_registered(); - dispatch::ensure_runtime_dispatch_registered(); -} - -/// Minimal standard-alphabet base64 (with padding) for `setEncoding('base64')` -/// data delivery — avoids pulling a base64 crate into perry-ext-net. -pub(crate) fn base64_encode(bytes: &[u8]) -> String { - const ALPHA: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; - let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4); - for chunk in bytes.chunks(3) { - let b = [ - chunk[0], - chunk.get(1).copied().unwrap_or(0), - chunk.get(2).copied().unwrap_or(0), - ]; - let n = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | b[2] as u32; - out.push(ALPHA[(n >> 18) as usize & 63] as char); - out.push(ALPHA[(n >> 12) as usize & 63] as char); - out.push(if chunk.len() > 1 { - ALPHA[(n >> 6) as usize & 63] as char - } else { - '=' - }); - out.push(if chunk.len() > 2 { - ALPHA[n as usize & 63] as char - } else { - '=' - }); - } - out -} diff --git a/crates/perry-ext-net/src/buffer_pool.rs b/crates/perry-ext-net/src/buffer_pool.rs deleted file mode 100644 index a21d365462..0000000000 --- a/crates/perry-ext-net/src/buffer_pool.rs +++ /dev/null @@ -1,424 +0,0 @@ -//! A process-wide freelist of read buffers, so the socket read path -//! reuses pooled 16 KiB capacity instead of allocating a fresh -//! `BytesMut` on every read. -//! -//! # Why a pool, given per-socket buffer reuse already exists -//! -//! The socket read loop already reuses a single `BytesMut` per socket -//! (`read_buf` fills it in place and `split_to(n).freeze()` carves a -//! zero-copy `Bytes` view out of it), so there is no per-read *copy* — -//! but that does not remove the per-read *allocation*. The -//! `Bytes` chunk handed to the `'data'` event is queued in the global -//! pending-events vec and only converted to a JS `Buffer` (a real -//! `copy_nonoverlapping`) on the next main-thread drain tick. So the -//! chunk — and therefore the `BytesMut`'s backing storage, which the -//! chunk shares — is still alive when the read loop comes back around -//! to `reserve(16 KiB)` for the next read. `BytesMut::reserve` cannot -//! reclaim shared storage in place, so it **allocates a brand-new -//! 16 KiB block on every read that delivered data** (empirically -//! confirmed against `bytes` 1.12). On top of that, every new -//! connection paid one `BytesMut::with_capacity(16 KiB)`. -//! -//! # What the pool amortizes -//! -//! The read loop now [`checkout`]s a buffer from this freelist for each -//! read and [`checkin`]s the post-`split_to` remainder afterwards. A -//! recycled buffer's `checkout` does `clear()` + `reserve(16 KiB)`: -//! once the chunk it previously yielded has been drained and dropped -//! (refcount back to 1), that `reserve` reclaims the **same** 16 KiB -//! allocation in place — no new allocation. In steady state the pool's -//! depth covers the handful of chunks in flight between read and drain, -//! so a buffer that cycles back to the front of the freelist has -//! already had its chunk drained, and the read reuses pooled capacity. -//! New connections draw from the same pool, so the per-socket -//! allocation is amortized across the fleet too. -//! -//! Behavior is unchanged: `checkout` always hands back an empty buffer -//! with ≥ 16 KiB of writable capacity, identical to what -//! `BytesMut::with_capacity(16 KiB)` + per-read `clear()`/`reserve()` -//! produced. Read sizing and chunk boundaries belong to the read site -//! (turnloop's pooled read lease, copied out in -//! `turnloop_io::deliver_plaintext`); this module only owns buffer -//! *recycling*. -//! -//! # Concurrency -//! -//! Reads are delivered on whichever thread owns an agent's loop, and a -//! process can run several agents, so the freelist is a -//! `Mutex>`, matching the other process-wide `statics` -//! maps in this crate. The lock is held only for the O(1) `pop_front` / -//! `push_back`. -//! -//! # FIFO, not LIFO -//! -//! The freelist is a queue, not a stack: checkout takes from the front -//! (`pop_front`), checkin returns to the back (`push_back`). A stack -//! would hand the *most-recently* returned buffer straight back out — -//! but that buffer's chunk was only just enqueued for the drain tick and -//! is therefore still alive, so its `reserve(READ_BUF_CAP)` could not -//! reclaim in place and would reallocate, defeating the pool on the hot -//! read path. FIFO cycles the other parked buffers through first, giving -//! a returned buffer's chunk time to drain + drop before that buffer is -//! reused, so the reuse hits the in-place-reclaim path. - -use bytes::BytesMut; -use std::collections::VecDeque; -use std::sync::{Mutex, OnceLock}; - -/// Per-read window size. Every pooled buffer is reserved to at least this -/// capacity on checkout. -pub(crate) const READ_BUF_CAP: usize = 16 * 1024; - -/// Upper bound on idle buffers parked in the freelist. The pool exists -/// to recycle steady-state capacity, not to grow without limit: under a -/// burst that frees many buffers at once, anything past this cap is -/// dropped (its allocation returned to the global allocator) so idle -/// memory stays bounded at `MAX_POOLED * 16 KiB` (1 MiB). A burst that -/// needs more than `MAX_POOLED` buffers simply allocates fresh ones on -/// checkout, exactly as the pre-pool code always did — the cap trades a -/// little peak-burst reuse for a hard memory ceiling. -const MAX_POOLED: usize = 64; - -/// The process-wide freelist of idle read buffers. -fn pool() -> &'static Mutex> { - static POOL: OnceLock>> = OnceLock::new(); - POOL.get_or_init(|| Mutex::new(VecDeque::new())) -} - -/// Check out a read buffer: an empty `BytesMut` with at least -/// [`READ_BUF_CAP`] bytes of writable capacity, ready for `read_buf`. -/// -/// Pops a recycled buffer from the **front** of the freelist when one is -/// available, else allocates a fresh one. `clear()` + `reserve(READ_BUF_CAP)` -/// mean a recycled buffer whose prior chunk has already drained reclaims its -/// backing allocation in place (no allocation); a recycled buffer whose -/// chunk is somehow still alive, or a fresh buffer, allocates — the same -/// outcome the pre-pool `with_capacity` / `reserve` path produced. -pub(crate) fn checkout() -> BytesMut { - checkout_from(pool()) -} - -/// Return a read buffer to the **back** of the freelist for reuse. -/// -/// Called with the post-`split_to` remainder once the read loop has -/// handed its chunk downstream. The buffer's backing storage may still -/// be shared with that in-flight chunk; that is fine — the next -/// `checkout` of this buffer will reclaim in place only once the chunk -/// has drained, and reallocate otherwise (never corrupting the chunk). -/// Returning to the back (FIFO) gives the chunk time to drain while other -/// parked buffers cycle through first. Drops the buffer instead of parking -/// it once the freelist is at [`MAX_POOLED`], keeping idle memory bounded. -pub(crate) fn checkin(buf: BytesMut) { - checkin_to(pool(), buf); -} - -/// `checkout` against an explicit freelist — the global pool in production, -/// a test-local instance in unit tests (so pool-state tests never race the -/// process-wide pool). -fn checkout_from(pool: &Mutex>) -> BytesMut { - let mut buf = pool.lock().unwrap().pop_front().unwrap_or_default(); - // `clear()` drops any (already split-off) contents; `reserve` - // guarantees the 16 KiB writable window. On a recycled buffer with a - // drained chunk this is an in-place reclaim; otherwise it allocates. - buf.clear(); - buf.reserve(READ_BUF_CAP); - buf -} - -/// `checkin` against an explicit freelist — see [`checkout_from`]. -fn checkin_to(pool: &Mutex>, buf: BytesMut) { - let mut pool = pool.lock().unwrap(); - if pool.len() < MAX_POOLED { - pool.push_back(buf); - } - // else: drop `buf`, returning its allocation to the global allocator. -} - -#[cfg(test)] -mod tests { - use super::*; - use bytes::Bytes; - - // Pool-data-structure tests run against a TEST-LOCAL freelist via - // `checkout_from` / `checkin_to`, never the process-wide `pool()`. That - // makes pointer-reuse and count assertions deterministic regardless of - // Rust's default parallel test execution — no test can observe or mutate - // another's pool, so no `#[serial]` / global guard is needed. - fn local_pool() -> Mutex> { - Mutex::new(VecDeque::new()) - } - - /// A fresh checkout (empty pool) hands back an empty buffer with the - /// full 16 KiB write window — byte-for-byte the precondition the read - /// loop relied on from `BytesMut::with_capacity(16 KiB)`. - #[test] - fn checkout_yields_empty_16k_window() { - let pool = local_pool(); - let buf = checkout_from(&pool); - assert_eq!(buf.len(), 0, "checked-out buffer must start empty"); - assert!( - buf.capacity() >= READ_BUF_CAP, - "checked-out buffer must offer the 16 KiB read window, got {}", - buf.capacity() - ); - } - - /// The core amortization claim, exercised directly: a buffer whose - /// yielded chunk has been dropped (the steady-state drain ordering) - /// is recycled WITHOUT a new allocation — the same backing storage - /// comes back on the next checkout. This is the per-read allocation - /// the pool removes; a pool that reallocated here would not buy the - /// step-4 win. - #[test] - fn drained_buffer_recycles_storage_in_place() { - let pool = local_pool(); - // Mirror the read loop: checkout, fill, split a chunk off, then - // park the remainder once the chunk has drained. - let mut buf = checkout_from(&pool); - let base = buf.as_ptr() as usize; - buf.extend_from_slice(&[0xAB; 256]); - let chunk: Bytes = buf.split_to(256).freeze(); - drop(chunk); // consumer drained + dropped the chunk - checkin_to(&pool, buf); - - let recycled = checkout_from(&pool); - assert_eq!( - recycled.as_ptr() as usize, - base, - "a drained, returned buffer must be reused in place, not reallocated" - ); - } - - /// FIFO is load-bearing on the hot path: a buffer whose chunk is STILL - /// IN FLIGHT when it is checked in must not be the very next buffer - /// handed out — otherwise its `reserve` cannot reclaim in place and - /// reallocates. With FIFO the other parked buffers cycle through first, - /// so by the time the buffer comes back its chunk has drained and the - /// reuse is in-place (pointer-identity, no realloc). The companion - /// `lifo_reuses_too_soon_and_reallocates` proves a LIFO pool fails this - /// exact sequence, so the structure choice — not luck — is what restores - /// the reclaim. - #[test] - fn fifo_lets_in_flight_chunk_drain_before_reuse() { - let pool = local_pool(); - // Steady state: two buffers already parked (a real read loop keeps a - // handful in flight). FIFO orders them front = P1, back = P2. - checkin_to(&pool, BytesMut::with_capacity(READ_BUF_CAP)); - checkin_to(&pool, BytesMut::with_capacity(READ_BUF_CAP)); - - // Read 1: check out the front buffer (call it A), fill it, freeze a - // chunk that STAYS ALIVE (mirroring the pending-events queue), and - // return A to the back of the freelist. Pool: [P2, A]. - let mut a = checkout_from(&pool); - let base_a = a.as_ptr() as usize; - a.extend_from_slice(&[0xCD; 512]); - let chunk_a: Bytes = a.split_to(512).freeze(); - checkin_to(&pool, a); // A to the back; its chunk is still alive - - // Read 2: the next checkout hands out the OTHER parked buffer (FIFO - // front), never A — so A sits parked while its chunk is in flight and - // is never reserved against a live chunk. Pool after check-in: [A, _]. - let b = checkout_from(&pool); - assert_ne!( - b.as_ptr() as usize, - base_a, - "FIFO must hand out the older parked buffer, not the just-returned A" - ); - checkin_to(&pool, b); - - // The consumer drains + drops A's chunk on the next tick. - drop(chunk_a); - - // Read 3 reaches A at the FIFO front. Its chunk has drained, so the - // `reserve` reclaims A's backing storage IN PLACE — same pointer, no - // reallocation. Under a LIFO pool A would have been re-handed at Read 2 - // (chunk still alive → forced realloc), so this exact pointer-identity - // assertion fails; `lifo_reuses_too_soon_and_reallocates` shows that. - let recycled = checkout_from(&pool); - assert_eq!( - recycled.as_ptr() as usize, - base_a, - "A must reclaim its drained storage in place, not reallocate" - ); - } - - /// The failure the FIFO design fixes, made explicit: a LIFO pool - /// (`pop_back`) re-hands the just-returned buffer immediately, while its - /// chunk is still alive, so `reserve` cannot reclaim in place and the - /// buffer is REALLOCATED to a new address. This is the regression the - /// FIFO `pop_front` avoids — run the same sequence both ways and the - /// pointers diverge. - #[test] - fn lifo_reuses_too_soon_and_reallocates() { - // Same two-buffer steady state as the FIFO test. - let buf1 = BytesMut::with_capacity(READ_BUF_CAP); - let buf2 = BytesMut::with_capacity(READ_BUF_CAP); - let mut lifo: VecDeque = VecDeque::new(); - lifo.push_back(buf1); - lifo.push_back(buf2); - - // Emulate the LIFO variant inline: take from the back, return to the - // back. Read 1 checks out A, queues a live chunk, returns A. - let mut a = lifo.pop_back().unwrap(); - a.clear(); - a.reserve(READ_BUF_CAP); - let base_a = a.as_ptr() as usize; - a.extend_from_slice(&[0xCD; 512]); - let chunk_a: Bytes = a.split_to(512).freeze(); - lifo.push_back(a); // returned to the back; chunk still alive - - // Read 2 with LIFO: `pop_back` hands A straight back — chunk alive — - // so `reserve` must allocate elsewhere. Pointer diverges from base_a. - let mut reused = lifo.pop_back().unwrap(); - reused.clear(); - reused.reserve(READ_BUF_CAP); - assert_ne!( - reused.as_ptr() as usize, - base_a, - "LIFO reuse while the chunk is alive must reallocate (pointer moves)" - ); - drop(chunk_a); - } - - /// Recycling never corrupts an in-flight chunk: even though a - /// returned buffer shares storage with a chunk that has NOT yet - /// drained, the bytes the chunk holds stay intact after the buffer is - /// checked back out and refilled. (Checkout simply reallocates rather - /// than reclaiming in this case — correctness over reuse.) - #[test] - fn checkin_preserves_live_chunk_bytes() { - let pool = local_pool(); - let mut buf = checkout_from(&pool); - buf.extend_from_slice(b"first-message"); - let chunk: Bytes = buf.split_to(b"first-message".len()).freeze(); - // Return the remainder while the chunk is still "in the queue". - checkin_to(&pool, buf); - - // Next reader checks out and writes a different payload. - let mut next = checkout_from(&pool); - next.extend_from_slice(b"second-message-payload"); - let chunk2 = next.split_to(b"second-message-payload".len()).freeze(); - - // The first chunk's bytes are untouched by the second read. - assert_eq!(&chunk[..], b"first-message"); - assert_eq!(&chunk2[..], b"second-message-payload"); - } - - /// The freelist is bounded: parking more than `MAX_POOLED` buffers - /// keeps only `MAX_POOLED`, so idle memory can't grow without limit. - /// Checking out past the cap still works — it just allocates fresh, - /// exactly the pre-pool behavior. - #[test] - fn freelist_is_bounded() { - let pool = local_pool(); - for _ in 0..(MAX_POOLED + 16) { - checkin_to(&pool, BytesMut::with_capacity(READ_BUF_CAP)); - } - assert_eq!( - pool.lock().unwrap().len(), - MAX_POOLED, - "freelist must cap at MAX_POOLED idle buffers" - ); - } - - // ─── Differential read-path test against a real socket ────────────── - - use std::io::{Read, Write}; - use std::net::{Shutdown, TcpListener, TcpStream}; - - /// Drive the *exact* pooled delivery core — a read of at most 16 KiB - /// (turnloop's pooled lease stands in as a fixed scratch here) → - /// `checkout` → `extend_from_slice` → `split_to(n).freeze()` → `checkin`, - /// which is `turnloop_io::deliver_plaintext` — over a real loopback TCP - /// connection, collecting every chunk in the order delivered. Each chunk - /// is kept alive (mirroring the global pending-events queue, where a chunk - /// outlives the read that produced it) so the pool's - /// recycle-under-live-chunks path is genuinely exercised. Returns the - /// chunk sizes and the concatenated bytes, so a caller can assert - /// byte-identity and the 16 KiB chunk-boundary behavior. - fn read_all_pooled(payload: &[u8]) -> (Vec, Vec) { - let listener = TcpListener::bind("127.0.0.1:0").unwrap(); - let addr = listener.local_addr().unwrap(); - - // Writer half: connect and stream the payload, then close (FIN). - let payload_owned = payload.to_vec(); - let writer = std::thread::spawn(move || { - let mut c = TcpStream::connect(addr).unwrap(); - c.write_all(&payload_owned).unwrap(); - c.shutdown(Shutdown::Write).unwrap(); - }); - - let (mut server, _) = listener.accept().unwrap(); - - // Read CONCURRENTLY with the writer — do NOT join the writer first. - // For payloads larger than the combined OS send + receive buffers, - // `write_all` cannot complete until the server drains the socket, so - // joining the writer up front would deadlock. - let mut lease = vec![0u8; READ_BUF_CAP]; - let mut chunks: Vec = Vec::new(); // kept alive like the queue - let mut sizes = Vec::new(); - let mut joined = Vec::new(); - loop { - let n = server.read(&mut lease).unwrap(); - if n == 0 { - break; // peer FIN — the sink's EOF path - } - // Mirror `turnloop_io::deliver_plaintext` exactly. - let mut buf = checkout(); - buf.extend_from_slice(&lease[..n]); - let chunk = buf.split_to(n).freeze(); - checkin(buf); - sizes.push(chunk.len()); - joined.extend_from_slice(&chunk); - chunks.push(chunk); // outlives the iteration, as in the queue - } - - writer.join().unwrap(); - (sizes, joined) - } - - /// The headline behavior-preservation guarantee: across small, - /// multi-message, exactly-16 KiB-boundary, and well-over-16 KiB - /// payloads, the pooled read loop delivers byte-identical data and - /// the same per-read 16 KiB chunk boundaries the step-2 fixed-window - /// loop did. A pool that corrupted, dropped, or reordered bytes — or - /// that broke the 16 KiB window cap — would fail here. - #[test] - fn pooled_reads_are_byte_identical_across_sizes() { - // Small single message — fits in one read well under the window. - let small = b"hello world".to_vec(); - let (sizes, joined) = read_all_pooled(&small); - assert_eq!(joined, small, "small payload must round-trip byte-for-byte"); - assert!( - sizes.iter().all(|&s| s <= READ_BUF_CAP), - "no chunk may exceed the 16 KiB window" - ); - - // Exactly the 16 KiB window boundary. - let boundary: Vec = (0..READ_BUF_CAP).map(|i| (i % 251) as u8).collect(); - let (sizes, joined) = read_all_pooled(&boundary); - assert_eq!(joined, boundary, "16 KiB-boundary payload must round-trip"); - assert!( - sizes.iter().all(|&s| s <= READ_BUF_CAP), - "the per-read window cap holds at the boundary" - ); - - // Well over the window — must arrive as multiple ≤16 KiB chunks - // whose concatenation is the original, with all but the last read - // bounded by the window (proving the cap, not just the total). - let big: Vec = (0..(READ_BUF_CAP * 4 + 777)) - .map(|i| (i % 256) as u8) - .collect(); - let (sizes, joined) = read_all_pooled(&big); - assert_eq!(joined, big, ">16 KiB payload must reassemble byte-for-byte"); - assert!( - sizes.iter().all(|&s| s <= READ_BUF_CAP), - "every read is capped at the 16 KiB window, got {sizes:?}" - ); - assert!( - sizes.len() >= 5, - "a >64 KiB payload must span multiple reads, got {} chunks", - sizes.len() - ); - } -} diff --git a/crates/perry-ext-net/src/bun_tcp.rs b/crates/perry-ext-net/src/bun_tcp.rs index 78eeb1b135..652137c4c8 100644 --- a/crates/perry-ext-net/src/bun_tcp.rs +++ b/crates/perry-ext-net/src/bun_tcp.rs @@ -1,1235 +1,436 @@ -//! Bun TCP socket facades (#9635). -//! -//! Several helpers and two `BunSocket` fields are written but not yet read on -//! this build: they are reachable only from paths the follow-up surface turns -//! on. Allowed at module scope rather than deleted, since removing them would -//! also remove the writes that feed them; flagged to the author on landing. -#![allow(dead_code)] - -//! Bun's low-level TCP facade over the existing `node:net` transport. -//! -//! The transport, accept loop, and event pump remain shared with `net.Socket`. -//! This module only owns Bun's handler-table calling convention, per-socket -//! `.data`, Promise settlement for `connect`, and bounded write admission. - -use bytes::Bytes; -use perry_ffi::{ - alloc_buffer, alloc_string, JsClosure, JsPromise, JsValue, Promise, RawClosureHeader, -}; -use std::collections::{HashMap, VecDeque}; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::{Mutex, OnceLock}; - +//! Bun's TCP calling convention on ordinary Socket and Server owners. +//! Handler tables, data, listeners and promises are traced JS edges. use crate::{ - build_error_object, get_object_number_field, get_object_string_field, get_object_value_field, - statics, SocketCommand, + payload_events as events, payload_server as server, payload_socket as socket, + payload_transport as p, }; - -const TAG_UNDEFINED: u64 = 0x7FFC_0000_0000_0001; -const POINTER_TAG: u64 = 0x7FFD_0000_0000_0000; -const POINTER_MASK: u64 = 0x0000_FFFF_FFFF_FFFF; -const WRITE_HIGH_WATER_MARK: usize = 64 * 1024; - -#[derive(Clone, Default)] -struct Handlers { - open: i64, - data: i64, - drain: i64, - close: i64, - error: i64, - connect_error: i64, - end: i64, -} - -struct BunSocket { - handlers: Handlers, - data_bits: u64, - connect_promise: usize, - listener: Option, - opened: bool, - paused: bool, - paused_data: VecDeque, - paused_end: bool, - paused_close: bool, - shutting_down: bool, - needs_drain: bool, - last_error: Option, -} - -struct BunServer { - handlers: Handlers, - data_bits: u64, - refed: bool, - ready: bool, -} - -#[derive(Clone)] -enum Endpoint { - Tcp { host: String, port: f64 }, - Unix(String), -} - -struct ParsedOptions { - endpoint: Endpoint, - handlers: Handlers, - data_bits: u64, -} - -fn sockets() -> &'static Mutex> { - static SOCKETS: OnceLock>> = OnceLock::new(); - SOCKETS.get_or_init(|| Mutex::new(HashMap::new())) -} - -fn servers() -> &'static Mutex> { - static SERVERS: OnceLock>> = OnceLock::new(); - SERVERS.get_or_init(|| Mutex::new(HashMap::new())) -} - -fn write_tokens() -> &'static Mutex> { - static TOKENS: OnceLock>> = OnceLock::new(); - TOKENS.get_or_init(|| Mutex::new(HashMap::new())) -} - -fn undefined() -> f64 { - f64::from_bits(TAG_UNDEFINED) -} - -fn handle_value(handle: i64) -> f64 { - f64::from_bits(POINTER_TAG | (handle as u64 & POINTER_MASK)) -} - -fn callback_pointer(value: f64) -> i64 { - extern "C" { - fn js_net_callback_ptr(value: f64) -> i64; - } - unsafe { js_net_callback_ptr(value) } -} - -/// Parse every GC-managed option while transient roots keep the option object, -/// handler object, callbacks, and initial data current across property-key -/// allocations. No Perry allocation occurs between this returning and the -/// caller publishing the resulting pointers in the scanned side tables. -unsafe fn parse_options(options: f64, listen: bool) -> Option { - let roots = perry_ffi::TransientRootScope::enter(); - let options = roots.root_nanbox(options); - let handler_value = get_object_value_field(options.get(), "socket")?; - let handler = roots.root_nanbox(handler_value); - let names = [ - "open", - "data", - "drain", - "close", - "error", - "connectError", - "end", - ]; - let callbacks = names - .iter() - .map(|name| { - roots.root_nanbox(get_object_value_field(handler.get(), name).unwrap_or_else(undefined)) - }) - .collect::>(); - let data = - roots.root_nanbox(get_object_value_field(options.get(), "data").unwrap_or_else(undefined)); - - let unix = get_object_string_field(options.get(), "unix") - .or_else(|| get_object_string_field(options.get(), "path")); - let endpoint = if let Some(path) = unix { - Endpoint::Unix(path) +use perry_ffi::{JsPromise, JsThis, JsValue, Promise, RawClosureHeader, TransientRootScope}; + +const EVENTS: [&str; 7] = [ + "connect", "data", "drain", "close", "error", "end", "timeout", +]; +const HANDLERS: [&str; 7] = ["open", "data", "drain", "close", "error", "end", "timeout"]; +fn boolean(value: f64) -> bool { + JsValue::from_bits(value.to_bits()).to_bool() +} +fn bun_state(owner: f64) -> f64 { + if p::server_link(owner).is_ok() { + server::state(owner) } else { - let default_host = if listen { "0.0.0.0" } else { "127.0.0.1" }; - let host = get_object_string_field(options.get(), "hostname") - .or_else(|| get_object_string_field(options.get(), "host")) - .filter(|host| !host.is_empty()) - .unwrap_or_else(|| default_host.to_string()); - let port = get_object_number_field(options.get(), "port")?; - Endpoint::Tcp { host, port } - }; - - let ptr = |index: usize| callback_pointer(callbacks[index].get()); - Some(ParsedOptions { - endpoint, - handlers: Handlers { - open: ptr(0), - data: ptr(1), - drain: ptr(2), - close: ptr(3), - error: ptr(4), - connect_error: ptr(5), - end: ptr(6), - }, - data_bits: data.get().to_bits(), - }) -} - -unsafe fn parse_reload_handlers(options: f64) -> Option { - let roots = perry_ffi::TransientRootScope::enter(); - let options = roots.root_nanbox(options); - let nested = get_object_value_field(options.get(), "socket").unwrap_or(options.get()); - let handler = roots.root_nanbox(nested); - let names = [ - "open", - "data", - "drain", - "close", - "error", - "connectError", - "end", - ]; - let callbacks = names - .iter() - .map(|name| { - roots.root_nanbox(get_object_value_field(handler.get(), name).unwrap_or_else(undefined)) - }) - .collect::>(); - let ptr = |index: usize| callback_pointer(callbacks[index].get()); - Some(Handlers { - open: ptr(0), - data: ptr(1), - drain: ptr(2), - close: ptr(3), - error: ptr(4), - connect_error: ptr(5), - end: ptr(6), - }) -} - -fn nanbox_string(value: &str) -> f64 { - let string = alloc_string(value).as_raw(); - f64::from_bits(0x7FFF_0000_0000_0000 | (string as u64 & POINTER_MASK)) -} - -fn dispatch_one(callback: i64, socket: i64) { - if callback == 0 { - return; - } - let frame = crate::dispatch_custody::DispatchFrame::park(vec![callback]); - unsafe { - let _ = JsClosure::from_raw(frame.cb(0) as *const RawClosureHeader) - .call1(perry_ffi::JsThis::UNDEFINED, handle_value(socket)); - } -} - -fn dispatch_two(callback: i64, socket: i64, payload: f64) { - if callback == 0 { - return; - } - let mut frame = crate::dispatch_custody::DispatchFrame::park(vec![callback]); - frame.set_payload(payload.to_bits()); - unsafe { - let _ = JsClosure::from_raw(frame.cb(0) as *const RawClosureHeader).call2( - perry_ffi::JsThis::UNDEFINED, - handle_value(socket), - f64::from_bits(frame.payload_bits()), - ); - } -} - -fn dispatch_error(callback: i64, socket: i64, message: &str) { - if callback == 0 { - return; + socket::state(owner) + } +} + +unsafe extern "C" fn event(closure: *const RawClosureHeader, _: JsThis, arg: f64) -> f64 { + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(perry_ffi::closure_capture_f64(closure, 0)); + let index = perry_ffi::closure_capture_f64(closure, 1) as usize; + let arg = scope.root_nanbox(arg); + let state = scope.root_nanbox(bun_state(owner.get())); + let handlers = scope.root_nanbox(p::own_get(state.get(), "bunHandlers")); + let promise = scope.root_nanbox(p::own_get(state.get(), "bunPromise")); + let opened = boolean(p::own_get(state.get(), "bunOpened")); + if index == 0 || index == 4 { + p::own_set(state.get(), "bunPromise", p::undefined()); + if JsValue::from_bits(promise.get().to_bits()).is_pointer() { + let promise = JsPromise::from_raw(p::raw_owner(promise.get()) as *mut Promise); + if index == 0 { + promise.resolve(JsValue::from_bits(owner.get().to_bits())); + } else { + promise.reject(JsValue::from_bits(arg.get().to_bits())); + } + } } - let mut frame = crate::dispatch_custody::DispatchFrame::park(vec![callback]); - frame.set_payload(unsafe { build_error_object(message) }.to_bits()); - unsafe { - let _ = JsClosure::from_raw(frame.cb(0) as *const RawClosureHeader).call2( - perry_ffi::JsThis::UNDEFINED, - handle_value(socket), - f64::from_bits(frame.payload_bits()), + if index == 0 { + p::own_set( + state.get(), + "bunOpened", + f64::from_bits(JsValue::TRUE.bits()), ); } -} - -fn settle_connect(handle: i64, error: Option<&str>) { - let promise = sockets() - .lock() - .unwrap() - .get_mut(&handle) - .map(|socket| std::mem::take(&mut socket.connect_promise)) - .unwrap_or(0); - if promise == 0 { - return; - } - let roots = perry_ffi::TransientRootScope::enter(); - let promise = roots.root_addr(promise as i64); - unsafe { - if let Some(message) = error { - let reason = roots.root_nanbox(build_error_object(message)); - JsPromise::from_raw(promise.get() as *mut Promise) - .reject(JsValue::from_bits(reason.get().to_bits())); + let handler = scope.root_nanbox(p::own_get( + handlers.get(), + if index == 4 && !opened { + "connectError" + } else { + HANDLERS[index] + }, + )); + if socket::is_callback(handler.get()) { + if index == 1 || index == 4 { + events::call(handler.get(), JsThis::UNDEFINED, &[owner.get(), arg.get()]); } else { - JsPromise::from_raw(promise.get() as *mut Promise) - .resolve(JsValue::from_bits(handle_value(handle).to_bits())); + events::call(handler.get(), JsThis::UNDEFINED, &[owner.get()]); } } + p::undefined() } - -/// Install Bun's runtime dispatch bucket together with the ext-net callback -/// used by extracted `listen`/`connect` exports. The generated import path -/// calls this wrapper before it can mint a callable export, so registration -/// does not depend on the event loop having initialized perry-stdlib yet. -#[no_mangle] -pub unsafe extern "C" fn js_bun_tcp_nm_install() { - extern "C" { - fn js_nm_install_bun(); +fn bind_event(owner: f64, index: usize) { + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(owner); + let callback_root = scope.root_addr(perry_ffi::alloc_closure( + perry_ffi::js_function_info!(event, 1; with_flags(perry_ffi::FN_BUILTIN)), + 2, + ) as i64); + unsafe { + let callback = callback_root.get() as *mut RawClosureHeader; + perry_ffi::set_closure_capture_f64(callback, 0, owner.get()); + perry_ffi::set_closure_capture_f64(callback, 1, index as f64); + extern "C" { + fn js_node_stream_method_on(owner: i64, event: f64, callback: f64) -> f64; + } + let name = scope.root_nanbox(events::string(EVENTS[index])); + js_node_stream_method_on( + p::raw_owner(owner.get()), + name.get(), + p::boxed_addr(callback_root.get()), + ); } - - crate::dispatch::ensure_runtime_dispatch_registered(); - js_nm_install_bun(); } - -#[no_mangle] -pub unsafe extern "C" fn js_bun_tcp_listen(options: f64) -> i64 { - crate::ensure_gc_scanner_registered(); - crate::dispatch::ensure_runtime_dispatch_registered(); - let roots = perry_ffi::TransientRootScope::enter(); - let options = roots.root_nanbox(options); - let handle = crate::js_ext_net_create_server(0, 0); - let parsed = match parse_options(options.get(), true) { - Some(parsed) => parsed, - None => { - crate::server_state::remove_server(handle); - statics::servers().lock().unwrap().remove(&handle); - statics::listeners().lock().unwrap().remove(&handle); - return 0; - } - }; - servers().lock().unwrap().insert( - handle, - BunServer { - handlers: parsed.handlers, - data_bits: parsed.data_bits, - refed: true, - ready: false, - }, +fn install(owner: f64, options: f64) { + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(owner); + let options = scope.root_nanbox(options); + let state = scope.root_nanbox(bun_state(owner.get())); + let handlers = scope.root_nanbox(p::own_get(options.get(), "socket")); + p::own_set(state.get(), "bunHandlers", handlers.get()); + let data = scope.root_nanbox(p::own_get(options.get(), "data")); + p::own_set(owner.get(), "data", data.get()); + p::own_set( + state.get(), + "bunOpened", + f64::from_bits(JsValue::FALSE.bits()), ); - - match parsed.endpoint { - Endpoint::Tcp { host, port } => { - crate::js_net_server_listen(handle, port, nanbox_string(&host), undefined()); - } - Endpoint::Unix(path) => { - crate::js_net_server_listen(handle, nanbox_string(&path), undefined(), undefined()); + if p::socket_link(owner.get()).is_ok() { + unsafe { + if let Ok(payload) = p::socket_ptr(socket::link(owner.get())) { + (*payload).ext.bun = true; + } } - } - - // Bun.listen binds before returning, which makes `listen({ port: 0 }).port` - // immediately usable by a client. Perry's node:net bind is asynchronous; - // drive that shared task/pump just until its ServerListening event arrives. - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); - while std::time::Instant::now() < deadline { - if servers() - .lock() - .unwrap() - .get(&handle) - .map(|server| server.ready) - .unwrap_or(true) - { - break; + for index in 0..EVENTS.len() { + bind_event(owner.get(), index); } - perry_ffi::run_pending(2); - crate::js_ext_net_drain_pending(); - } - handle -} - -#[no_mangle] -pub unsafe extern "C" fn js_bun_tcp_connect(options: f64) -> *mut Promise { - crate::ensure_gc_scanner_registered(); - crate::dispatch::ensure_runtime_dispatch_registered(); - let roots = perry_ffi::TransientRootScope::enter(); - let options = roots.root_nanbox(options); - let handle = crate::js_net_socket_alloc(); - let promise = JsPromise::new(); - let promise = roots.root_addr(promise.as_raw() as i64); - let parsed = match parse_options(options.get(), false) { - Some(parsed) => parsed, - None => { - statics::sockets().lock().unwrap().remove(&handle); - statics::listeners().lock().unwrap().remove(&handle); - let rooted = JsPromise::from_raw(promise.get() as *mut Promise); - rooted.reject_string("Bun.connect requires socket handlers and a TCP or Unix endpoint"); - return promise.get() as *mut Promise; + method( + owner.get(), + "write", + perry_ffi::js_function_info!(write, 3; with_flags(perry_ffi::FN_BUILTIN)), + ); + method( + owner.get(), + "end", + perry_ffi::js_function_info!(end, 3; with_flags(perry_ffi::FN_BUILTIN)), + ); + method( + owner.get(), + "close", + perry_ffi::js_function_info!(close, 0; with_flags(perry_ffi::FN_BUILTIN)), + ); + method( + owner.get(), + "terminate", + perry_ffi::js_function_info!(terminate, 0; with_flags(perry_ffi::FN_BUILTIN)), + ); + method( + owner.get(), + "shutdown", + perry_ffi::js_function_info!(shutdown, 1; with_flags(perry_ffi::FN_BUILTIN)), + ); + method( + owner.get(), + "flush", + perry_ffi::js_function_info!(flush, 0; with_flags(perry_ffi::FN_BUILTIN)), + ); + method( + owner.get(), + "timeout", + perry_ffi::js_function_info!(timeout, 1; with_flags(perry_ffi::FN_BUILTIN)), + ); + extern "C" { + fn js_object_define_getter(owner: f64, key: f64, callback: f64) -> f64; } - }; - sockets().lock().unwrap().insert( - handle, - BunSocket { - handlers: parsed.handlers, - data_bits: parsed.data_bits, - connect_promise: promise.get() as usize, - listener: None, - opened: false, - paused: false, - paused_data: VecDeque::new(), - paused_end: false, - paused_close: false, - shutting_down: false, - needs_drain: false, - last_error: None, - }, - ); - match parsed.endpoint { - Endpoint::Tcp { host, port } => { - crate::js_net_socket_method_connect(handle, port, nanbox_string(&host), undefined()); + // The numeric Bun surface is an own accessor; the canonical Socket + // prototype remains the shared Node surface. + let getter = scope.root_addr(perry_ffi::alloc_closure( + perry_ffi::js_function_info!(ready, 0; with_flags(perry_ffi::FN_BUILTIN)), + 0, + ) as i64); + unsafe { + let name = scope.root_nanbox(events::string("readyState")); + js_object_define_getter(owner.get(), name.get(), p::boxed_addr(getter.get())); } - Endpoint::Unix(path) => crate::ipc::connect_existing(handle, path), + } else { + method( + owner.get(), + "stop", + perry_ffi::js_function_info!(stop, 1; with_flags(perry_ffi::FN_BUILTIN)), + ); } - promise.get() as *mut Promise + method( + owner.get(), + "reload", + perry_ffi::js_function_info!(reload, 1; with_flags(perry_ffi::FN_BUILTIN)), + ); } - -/// Indirect runtime bridge for captured `Bun.listen` / `Bun.connect` values. -#[no_mangle] -pub unsafe extern "C" fn js_bun_tcp_native_dispatch( - method_ptr: *const u8, - method_len: usize, - args_ptr: *const f64, - args_len: usize, +fn method(owner: f64, key: &str, info: &'static perry_ffi::JsFunctionInfo) { + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(owner); + let callback = perry_ffi::alloc_closure(info, 0); + p::own_set(owner.get(), key, p::boxed_addr(callback as i64)); +} +unsafe extern "C" fn write( + _: *const RawClosureHeader, + this: JsThis, + chunk: f64, + offset: f64, + length: f64, ) -> f64 { - let method = if method_ptr.is_null() { - "" + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(this.as_f64()); + let bytes = crate::jsvalue_to_socket_bytes(chunk).unwrap_or_default(); + let offset = if JsValue::from_bits(offset.to_bits()).is_number() { + offset.max(0.0) as usize } else { - std::str::from_utf8(std::slice::from_raw_parts(method_ptr, method_len)).unwrap_or("") - }; - let options = if args_ptr.is_null() || args_len == 0 { - undefined() + 0 + } + .min(bytes.len()); + let length = if JsValue::from_bits(length.to_bits()).is_number() { + length.max(0.0) as usize } else { - *args_ptr - }; - match method { - "listen" => handle_value(js_bun_tcp_listen(options)), - "connect" => handle_value(js_bun_tcp_connect(options) as i64), - _ => undefined(), + bytes.len() - offset + } + .min(bytes.len() - offset); + let queued = socket::get(owner.get(), "writableLength"); + let count = length.min(socket::HIGH_WATER_MARK.saturating_sub(queued.max(0.0) as usize)); + if count > 0 { + let buffer = perry_ffi::alloc_buffer(&bytes[offset..offset + count]); + socket::write( + owner.get(), + p::boxed_addr(buffer as i64), + p::undefined(), + p::undefined(), + ); } -} - -pub(crate) fn is_socket(handle: i64) -> bool { - sockets().lock().unwrap().contains_key(&handle) -} - -pub(crate) fn is_server(handle: i64) -> bool { - servers().lock().unwrap().contains_key(&handle) -} - -pub(crate) fn server_keeps_alive(handle: i64) -> bool { - servers() - .lock() - .unwrap() - .get(&handle) - .map(|server| server.refed) - .unwrap_or(true) -} - -pub(crate) fn on_server_listening(handle: i64) { - if let Some(server) = servers().lock().unwrap().get_mut(&handle) { - server.ready = true; + if count < length { + if let Ok(payload) = p::socket_ptr(socket::link(owner.get())) { + (*payload).ext.need_drain = true; + } } + count as f64 } - -pub(crate) fn on_server_close(handle: i64) { - servers().lock().unwrap().remove(&handle); -} - -pub(crate) fn on_accept(server_id: i64, socket_id: i64) -> bool { - let (handlers, data_bits) = match servers().lock().unwrap().get(&server_id) { - Some(server) => (server.handlers.clone(), server.data_bits), - None => return false, - }; - let open = handlers.open; - sockets().lock().unwrap().insert( - socket_id, - BunSocket { - handlers, - data_bits, - connect_promise: 0, - listener: Some(server_id), - opened: true, - paused: false, - paused_data: VecDeque::new(), - paused_end: false, - paused_close: false, - shutting_down: false, - needs_drain: false, - last_error: None, - }, +unsafe extern "C" fn end( + c: *const RawClosureHeader, + this: JsThis, + chunk: f64, + offset: f64, + length: f64, +) -> f64 { + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(this.as_f64()); + let n = write(c, this, chunk, offset, length); + socket::end(owner.get(), p::undefined(), p::undefined(), p::undefined()); + n +} +unsafe extern "C" fn close(_: *const RawClosureHeader, this: JsThis) -> f64 { + socket::end( + this.as_f64(), + p::undefined(), + p::undefined(), + p::undefined(), ); - dispatch_one(open, socket_id); - true + p::undefined() } - -pub(crate) fn on_connect(handle: i64) -> bool { - let callback = { - let mut sockets = sockets().lock().unwrap(); - let Some(socket) = sockets.get_mut(&handle) else { - return false; - }; - socket.opened = true; - socket.handlers.open - }; - dispatch_one(callback, handle); - settle_connect(handle, None); - true +unsafe extern "C" fn terminate(_: *const RawClosureHeader, this: JsThis) -> f64 { + socket::destroy(this.as_f64(), p::undefined()); + p::undefined() } - -fn dispatch_data(handle: i64, bytes: &Bytes) { - let callback = sockets() - .lock() - .unwrap() - .get(&handle) - .map(|socket| socket.handlers.data) - .unwrap_or(0); - if callback == 0 { - return; - } - let frame = crate::dispatch_custody::DispatchFrame::park(vec![callback]); - let buffer = alloc_buffer(bytes); - if buffer.is_null() { - return; - } - let mut frame = frame; - frame.set_payload(POINTER_TAG | (buffer as u64 & POINTER_MASK)); - unsafe { - let _ = JsClosure::from_raw(frame.cb(0) as *const RawClosureHeader).call2( - perry_ffi::JsThis::UNDEFINED, - handle_value(handle), - f64::from_bits(frame.payload_bits()), - ); +unsafe extern "C" fn shutdown(c: *const RawClosureHeader, this: JsThis, graceful: f64) -> f64 { + if boolean(graceful) { + close(c, this) + } else { + terminate(c, this) } } - -pub(crate) fn on_data(handle: i64, bytes: &Bytes) -> bool { - { - let mut sockets = sockets().lock().unwrap(); - let Some(socket) = sockets.get_mut(&handle) else { - return false; - }; - if socket.paused { - socket.paused_data.push_back(bytes.clone()); - return true; - } - } - dispatch_data(handle, bytes); - true +unsafe extern "C" fn flush(_: *const RawClosureHeader, this: JsThis) -> f64 { + socket::link(this.as_f64()); + p::undefined() } - -pub(crate) fn on_end(handle: i64) -> bool { - let callback = match sockets().lock().unwrap().get_mut(&handle) { - Some(socket) if socket.paused => { - socket.paused_end = true; - return true; - } - Some(socket) => socket.handlers.end, - None => return false, - }; - dispatch_one(callback, handle); - true +unsafe extern "C" fn timeout(_: *const RawClosureHeader, this: JsThis, seconds: f64) -> f64 { + socket::set_timeout(this.as_f64(), seconds * 1000.0, p::undefined()); + p::undefined() } - -pub(crate) fn on_error(handle: i64, message: &str) -> bool { - let (callback, opened) = { - let mut sockets = sockets().lock().unwrap(); - let Some(socket) = sockets.get_mut(&handle) else { - return false; - }; - socket.last_error = Some(message.to_string()); - let callback = if !socket.opened && socket.handlers.connect_error != 0 { - socket.handlers.connect_error - } else { - socket.handlers.error - }; - (callback, socket.opened) - }; - dispatch_error(callback, handle, message); - if !opened { - settle_connect(handle, Some(message)); +unsafe extern "C" fn ready(_: *const RawClosureHeader, this: JsThis) -> f64 { + let owner = this.as_f64(); + socket::link(owner); + if boolean(socket::get(owner, "destroyed")) || boolean(socket::get(owner, "connecting")) { + 0.0 + } else if boolean(socket::get(owner, "writableEnded")) { + -2.0 + } else { + 1.0 } - true } - -pub(crate) fn on_close(handle: i64) -> bool { - let (callback, error, opened) = match sockets().lock().unwrap().get_mut(&handle) { - Some(socket) if socket.paused => { - socket.paused_close = true; - return true; - } - Some(socket) => ( - socket.handlers.close, - socket.last_error.clone(), - socket.opened, - ), - None => return false, - }; - if let Some(message) = error.as_deref() { - dispatch_error(callback, handle, message); +unsafe extern "C" fn reload(_: *const RawClosureHeader, this: JsThis, options: f64) -> f64 { + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(this.as_f64()); + let options = scope.root_nanbox(options); + let state = scope.root_nanbox(bun_state(owner.get())); + let nested = scope.root_nanbox(p::own_get(options.get(), "socket")); + let handlers = if JsValue::from_bits(nested.get().to_bits()).is_undefined() { + options.get() } else { - dispatch_two(callback, handle, undefined()); + nested.get() + }; + let handlers = scope.root_nanbox(handlers); + p::own_set(state.get(), "bunHandlers", handlers.get()); + if p::server_link(owner.get()).is_ok() { + crate::native_transport::for_each_server_child(owner.get(), |child| { + let state = scope.root_nanbox(bun_state(child.value())); + p::own_set(state.get(), "bunHandlers", handlers.get()); + }); } - if !opened { - settle_connect( - handle, - Some( - error - .as_deref() - .unwrap_or("Socket closed before connecting"), - ), - ); + p::undefined() +} +unsafe extern "C" fn stop(_: *const RawClosureHeader, this: JsThis, active: f64) -> f64 { + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(this.as_f64()); + if boolean(active) { + crate::native_transport::for_each_server_child(owner.get(), |child| { + socket::destroy(child.value(), p::undefined()); + }); } - write_tokens() - .lock() - .unwrap() - .retain(|_, socket| *socket != handle); - // Net handle ids remain reserved for the lifetime of their JS facade. - // Keep only the closed socket's user data so late reads still work and - // write/end can report -1, while releasing callback and buffer roots. - if let Some(socket) = sockets().lock().unwrap().get_mut(&handle) { - socket.handlers = Handlers::default(); - socket.connect_promise = 0; - socket.paused = false; - socket.paused_data.clear(); - socket.paused_end = false; - socket.paused_close = false; - socket.shutting_down = true; - socket.needs_drain = false; - socket.last_error = None; + server::close(owner.get(), p::undefined()); + p::undefined() +} +unsafe extern "C" fn connection(closure: *const RawClosureHeader, _: JsThis, child: f64) -> f64 { + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(perry_ffi::closure_capture_f64(closure, 0)); + let child = scope.root_nanbox(child); + let state = scope.root_nanbox(bun_state(owner.get())); + let handlers = scope.root_nanbox(p::own_get(state.get(), "bunHandlers")); + let options = scope.root_nanbox(f64::from_bits(perry_ffi::alloc_object().bits())); + p::own_set(options.get(), "socket", handlers.get()); + let data = scope.root_nanbox(p::own_get(owner.get(), "data")); + p::own_set(options.get(), "data", data.get()); + install(child.get(), options.get()); + p::own_set(child.get(), "listener", owner.get()); + let callback = scope.root_nanbox(p::own_get(handlers.get(), "open")); + let state = scope.root_nanbox(bun_state(child.get())); + p::own_set( + state.get(), + "bunOpened", + f64::from_bits(JsValue::TRUE.bits()), + ); + if socket::is_callback(callback.get()) { + events::call(callback.get(), JsThis::UNDEFINED, &[child.get()]); } - true + p::undefined() } - -fn number_arg(value: f64) -> Option { - let value = JsValue::from_bits(value.to_bits()); - if value.is_number() { - let number = value.to_number(); - if number.is_finite() && number >= 0.0 { - return Some(number as usize); - } +fn endpoint(options: f64, listen: bool) -> (Option, String, f64) { + unsafe { + ( + crate::get_object_string_field(options, "unix") + .or_else(|| crate::get_object_string_field(options, "path")), + crate::get_object_string_field(options, "hostname") + .or_else(|| crate::get_object_string_field(options, "host")) + .unwrap_or_else(|| if listen { "0.0.0.0" } else { "127.0.0.1" }.to_owned()), + crate::get_object_number_field(options, "port").unwrap_or(0.0), + ) } - None } - -unsafe fn socket_write(handle: i64, value: f64, offset: f64, length: f64) -> f64 { - let js_value = JsValue::from_bits(value.to_bits()); - let is_string = js_value.is_any_string(); - let Some(mut bytes) = crate::jsvalue_to_socket_bytes(value) else { - return -1.0; - }; - if !is_string { - let start = number_arg(offset).unwrap_or(0).min(bytes.len()); - let available = bytes.len().saturating_sub(start); - let count = number_arg(length).unwrap_or(available).min(available); - bytes = bytes[start..start + count].to_vec(); - } - - // The submission happens under the registry lock, through - // `SocketState::command`, because a turnloop-backed socket has no channel - // to send on afterwards — its command channel exists only to keep one - // `SocketState` shape across both transports and has no receiver. - let (accepted, token, delivered) = { - let mut net_sockets = statics::sockets().lock().unwrap(); - let Some(socket) = net_sockets.get_mut(&handle) else { - return -1.0; - }; - if !socket.is_open || socket.destroyed { - return -1.0; - } - if sockets() - .lock() - .unwrap() - .get(&handle) - .map(|socket| socket.shutting_down) - .unwrap_or(true) - { - return -1.0; - } - let capacity = WRITE_HIGH_WATER_MARK.saturating_sub(socket.bytes_queued as usize); - let accepted = capacity.min(bytes.len()); - if accepted == 0 { - if let Some(bun) = sockets().lock().unwrap().get_mut(&handle) { - bun.needs_drain = true; - } - return 0.0; - } - static NEXT_TOKEN: AtomicU64 = AtomicU64::new(1 << 63); - let token = NEXT_TOKEN.fetch_add(1, Ordering::Relaxed); - write_tokens().lock().unwrap().insert(token, handle); - let delivered = socket.command( - handle, - SocketCommand::Write(bytes[..accepted].to_vec(), token), +#[no_mangle] +pub unsafe extern "C" fn js_bun_tcp_nm_install() { + extern "C" { + fn js_nm_install_bun(); + fn js_set_native_bun_tcp_dispatch( + f: unsafe extern "C" fn(*const u8, usize, *const f64, usize) -> f64, ); - (accepted, token, delivered) - }; - if delivered.is_err() { - write_tokens().lock().unwrap().remove(&token); - return -1.0; - } - if accepted < bytes.len() { - if let Some(socket) = sockets().lock().unwrap().get_mut(&handle) { - socket.needs_drain = true; - } } - accepted as f64 + js_set_native_bun_tcp_dispatch(js_bun_tcp_native_dispatch); + js_nm_install_bun(); } - -unsafe fn socket_end(handle: i64, value: f64, offset: f64, length: f64) -> f64 { - if sockets() - .lock() - .unwrap() - .get(&handle) - .map(|socket| socket.shutting_down) - .unwrap_or(true) - { - return -1.0; +#[no_mangle] +pub unsafe extern "C" fn js_bun_tcp_listen(options: f64) -> i64 { + let scope = TransientRootScope::enter(); + let options = scope.root_nanbox(options); + let (path, host, port) = endpoint(options.get(), true); + let owner = scope.root_nanbox(server::new_server( + crate::payload_io::ROUTE, + p::undefined(), + p::undefined(), + )); + install(owner.get(), options.get()); + let callback = scope.root_addr(perry_ffi::alloc_closure( + perry_ffi::js_function_info!(connection, 1; with_flags(perry_ffi::FN_BUILTIN)), + 1, + ) as i64); + perry_ffi::set_closure_capture_f64(callback.get() as *mut RawClosureHeader, 0, owner.get()); + extern "C" { + fn js_node_stream_method_on(owner: i64, event: f64, callback: f64) -> f64; } - let has_data = !JsValue::from_bits(value.to_bits()).is_undefined() - && !JsValue::from_bits(value.to_bits()).is_null(); - let result = if has_data { - socket_write(handle, value, offset, length) + let name = scope.root_nanbox(events::string("connection")); + js_node_stream_method_on( + p::raw_owner(owner.get()), + name.get(), + p::boxed_addr(callback.get()), + ); + if let Some(path) = path { + server::listen( + owner.get(), + events::string(&path), + p::undefined(), + p::undefined(), + ); + p::own_set(owner.get(), "unix", events::string(&path)); } else { - if !is_socket(handle) { - return -1.0; - } - 0.0 - }; - if result < 0.0 { - return result; + server::listen(owner.get(), port, events::string(&host), p::undefined()); } - if let Some(socket) = sockets().lock().unwrap().get_mut(&handle) { - socket.shutting_down = true; - } - crate::js_ext_net_socket_end(handle, TAG_UNDEFINED as i64); - result + let address = scope.root_nanbox(server::address(owner.get())); + let port = p::own_get(address.get(), "port"); + p::own_set(owner.get(), "port", port); + p::own_set(owner.get(), "hostname", events::string(&host)); + p::raw_owner(owner.get()) } - -pub(crate) fn on_write_complete(handle: i64, token: u64, succeeded: bool) -> bool { - if write_tokens().lock().unwrap().remove(&token).is_none() { - return false; - } - if !succeeded { - return true; +#[no_mangle] +pub unsafe extern "C" fn js_bun_tcp_connect(options: f64) -> *mut Promise { + let scope = TransientRootScope::enter(); + let options = scope.root_nanbox(options); + let (path, host, port) = endpoint(options.get(), false); + let owner = scope.root_nanbox(socket::new_socket(crate::payload_io::ROUTE, options.get())); + install(owner.get(), options.get()); + let promise = scope.root_addr(JsPromise::new().as_raw() as i64); + let state = scope.root_nanbox(bun_state(owner.get())); + p::own_set(state.get(), "bunPromise", p::boxed_addr(promise.get())); + if let Some(path) = path { + socket::connect( + owner.get(), + events::string(&path), + p::undefined(), + p::undefined(), + ); + } else { + socket::connect(owner.get(), port, events::string(&host), p::undefined()); } - let queued = statics::sockets() - .lock() - .unwrap() - .get(&handle) - .map(|socket| socket.bytes_queued as usize) - .unwrap_or(0); - let callback = { - let mut sockets = sockets().lock().unwrap(); - let Some(socket) = sockets.get_mut(&handle) else { - return true; - }; - if socket.needs_drain && queued < WRITE_HIGH_WATER_MARK { - socket.needs_drain = false; - socket.handlers.drain - } else { - 0 - } - }; - dispatch_one(callback, handle); - true -} - -pub(crate) fn method_name(handle: i64, property: &str) -> Option<&'static [u8]> { - // Classify the name before touching either ownership map. Ordinary - // node:net properties also pass through this facade's dispatch hook. - let (name, socket_method, server_method): (&'static [u8], bool, bool) = match property { - "write" => (b"write", true, false), - "end" => (b"end", true, false), - "close" => (b"close", true, false), - "terminate" => (b"terminate", true, false), - "ref" => (b"ref", true, true), - "unref" => (b"unref", true, true), - "pause" => (b"pause", true, false), - "resume" => (b"resume", true, false), - "flush" => (b"flush", true, false), - "reload" => (b"reload", true, true), - "shutdown" => (b"shutdown", true, false), - "stop" => (b"stop", false, true), - _ => return None, - }; - let socket = is_socket(handle); - let server = !socket && is_server(handle); - ((socket && socket_method) || (server && server_method)).then_some(name) + promise.get() as *mut Promise } - -pub(crate) unsafe fn dispatch_method(handle: i64, method: &str, args: &[f64]) -> Option { - if method_name(handle, method).is_none() { - return None; +#[no_mangle] +pub unsafe extern "C" fn js_bun_tcp_native_dispatch( + name: *const u8, + len: usize, + args: *const f64, + count: usize, +) -> f64 { + if name.is_null() { + return p::undefined(); } - let arg = |index: usize| args.get(index).copied().unwrap_or_else(undefined); - let result = if is_socket(handle) { - match method { - "write" => socket_write(handle, arg(0), arg(1), arg(2)), - "end" => socket_end(handle, arg(0), arg(1), arg(2)), - "close" => { - let _ = socket_end(handle, undefined(), undefined(), undefined()); - undefined() - } - "shutdown" => { - if JsValue::from_bits(arg(0).to_bits()).to_bool() { - let _ = socket_end(handle, undefined(), undefined(), undefined()); - } else { - if let Some(socket) = sockets().lock().unwrap().get_mut(&handle) { - socket.shutting_down = true; - } - crate::js_ext_net_destroy_socket(handle); - } - undefined() - } - "terminate" => { - if let Some(socket) = sockets().lock().unwrap().get_mut(&handle) { - socket.shutting_down = true; - } - crate::js_ext_net_destroy_socket(handle); - undefined() - } - "ref" => { - crate::js_net_socket_ref(handle); - undefined() - } - "unref" => { - crate::js_net_socket_unref(handle); - undefined() - } - "pause" => { - if let Some(socket) = sockets().lock().unwrap().get_mut(&handle) { - socket.paused = true; - } - undefined() - } - "resume" => { - let (pending, ended, closed) = sockets() - .lock() - .unwrap() - .get_mut(&handle) - .map(|socket| { - socket.paused = false; - let pending = socket.paused_data.drain(..).collect::>(); - let ended = std::mem::take(&mut socket.paused_end); - let closed = std::mem::take(&mut socket.paused_close); - (pending, ended, closed) - }) - .unwrap_or_default(); - for bytes in pending { - crate::push_event(crate::PendingNetEvent::Data(handle, bytes)); - } - if ended { - crate::push_event(crate::PendingNetEvent::End(handle)); - } - if closed { - crate::push_event(crate::PendingNetEvent::Close(handle)); - } - undefined() - } - "reload" => { - if let Some(handlers) = parse_reload_handlers(arg(0)) { - if let Some(socket) = sockets().lock().unwrap().get_mut(&handle) { - socket.handlers = handlers; - } - } - undefined() - } - "flush" => undefined(), - _ => undefined(), - } + let name = std::str::from_utf8(std::slice::from_raw_parts(name, len)).unwrap_or(""); + let options = if count > 0 && !args.is_null() { + *args } else { - match method { - "stop" => { - let close_active = JsValue::from_bits(arg(0).to_bits()).to_bool(); - if close_active { - let active = statics::sockets() - .lock() - .unwrap() - .iter() - .filter_map(|(id, socket)| { - (socket.server_id == Some(handle)).then_some(*id) - }) - .collect::>(); - for socket in active { - crate::js_ext_net_destroy_socket(socket); - } - } - crate::js_net_server_close(handle, 0); - undefined() - } - "ref" | "unref" => { - if let Some(server) = servers().lock().unwrap().get_mut(&handle) { - server.refed = method == "ref"; - } - perry_ffi::notify_main_thread(); - undefined() - } - "reload" => { - if let Some(handlers) = parse_reload_handlers(arg(0)) { - if let Some(server) = servers().lock().unwrap().get_mut(&handle) { - server.handlers = handlers.clone(); - } - for socket in sockets().lock().unwrap().values_mut() { - if socket.listener == Some(handle) { - socket.handlers = handlers.clone(); - } - } - } - undefined() - } - _ => undefined(), - } - }; - Some(result) -} - -pub(crate) fn property(handle: i64, property: &str) -> Option { - // Classify once before probing ownership. Unrelated node:net properties - // need neither Bun map, while recognized names keep their lookup order. - enum Property { - Data, - Listener, - RemoteAddress, - RemotePort, - RemoteFamily, - LocalAddress, - LocalPort, - LocalFamily, - BytesWritten, - ReadyState, - Port, - Hostname, - Unix, - } - let property = match property { - "data" => Property::Data, - "listener" => Property::Listener, - "remoteAddress" => Property::RemoteAddress, - "remotePort" => Property::RemotePort, - "remoteFamily" => Property::RemoteFamily, - "localAddress" => Property::LocalAddress, - "localPort" => Property::LocalPort, - "localFamily" => Property::LocalFamily, - "bytesWritten" => Property::BytesWritten, - "readyState" => Property::ReadyState, - "port" => Property::Port, - "hostname" => Property::Hostname, - "unix" => Property::Unix, - _ => return None, + p::undefined() }; - if is_socket(handle) { - let (data_bits, listener, shutting_down) = { - let sockets = sockets().lock().unwrap(); - let socket = sockets.get(&handle)?; - (socket.data_bits, socket.listener, socket.shutting_down) - }; - return Some(match property { - Property::Data => f64::from_bits(data_bits), - Property::Listener => listener - .filter(|server| is_server(*server)) - .map(handle_value) - .unwrap_or_else(undefined), - Property::RemoteAddress => unsafe { crate::js_net_socket_get_remote_address(handle) }, - Property::RemotePort => unsafe { crate::js_net_socket_get_remote_port(handle) }, - Property::RemoteFamily => unsafe { crate::js_net_socket_get_remote_family(handle) }, - Property::LocalAddress => unsafe { crate::js_net_socket_get_local_address(handle) }, - Property::LocalPort => unsafe { crate::js_net_socket_get_local_port(handle) }, - Property::LocalFamily => unsafe { crate::js_net_socket_get_local_family(handle) }, - Property::BytesWritten => unsafe { crate::js_net_socket_get_bytes_written(handle) }, - Property::ReadyState => { - let state = statics::sockets() - .lock() - .unwrap() - .get(&handle) - .map(|socket| { - if socket.destroyed || !socket.is_open { - 0.0 - } else if shutting_down { - -2.0 - } else { - 1.0 - } - }) - .unwrap_or(0.0); - state - } - _ => return None, - }); - } - if !is_server(handle) { - return None; - } - let data_bits = servers().lock().unwrap().get(&handle)?.data_bits; - Some(match property { - Property::Data => f64::from_bits(data_bits), - Property::Port => statics::servers() - .lock() - .unwrap() - .get(&handle) - .map(|server| server.bound_port as f64) - .unwrap_or(0.0), - Property::Hostname => { - let host = statics::servers() - .lock() - .unwrap() - .get(&handle) - .map(|server| server.bound_host.clone()) - .unwrap_or_default(); - nanbox_string(&host) - } - Property::Unix => { - let path = statics::servers() - .lock() - .unwrap() - .get(&handle) - .and_then(|server| server.bound_path.clone()); - path.as_deref().map(nanbox_string).unwrap_or_else(undefined) - } - _ => return None, - }) -} - -pub(crate) fn set_property(handle: i64, property: &str, value: f64) -> bool { - if property != "data" { - return false; - } - if let Some(socket) = sockets().lock().unwrap().get_mut(&handle) { - socket.data_bits = value.to_bits(); - return true; - } - if let Some(server) = servers().lock().unwrap().get_mut(&handle) { - server.data_bits = value.to_bits(); - return true; - } - false -} - -fn scan_handlers(handlers: &mut Handlers, visitor: &mut perry_ffi::GcRootVisitor<'_>) { - visitor.visit_i64_slot(&mut handlers.open); - visitor.visit_i64_slot(&mut handlers.data); - visitor.visit_i64_slot(&mut handlers.drain); - visitor.visit_i64_slot(&mut handlers.close); - visitor.visit_i64_slot(&mut handlers.error); - visitor.visit_i64_slot(&mut handlers.connect_error); - visitor.visit_i64_slot(&mut handlers.end); -} - -pub(crate) fn scan_roots(visitor: &mut perry_ffi::GcRootVisitor<'_>) { - if let Ok(mut sockets) = sockets().lock() { - for socket in sockets.values_mut() { - scan_handlers(&mut socket.handlers, visitor); - visitor.visit_nanbox_u64_slot(&mut socket.data_bits); - if socket.connect_promise != 0 { - let mut promise = socket.connect_promise as *mut Promise; - visitor.visit_raw_mut_ptr_slot(&mut promise); - socket.connect_promise = promise as usize; - } - } - } - if let Ok(mut servers) = servers().lock() { - for server in servers.values_mut() { - scan_handlers(&mut server.handlers, visitor); - visitor.visit_nanbox_u64_slot(&mut server.data_bits); - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn node_flags_preserve_bun_socket_and_server_surfaces() { - let _lock = crate::tests::GC_TEST_LOCK.lock().unwrap(); - const SOCKET: i64 = 900_010; - const SERVER: i64 = 900_011; - const MISSING: i64 = 900_012; - struct Cleanup; - impl Drop for Cleanup { - fn drop(&mut self) { - sockets().lock().unwrap().remove(&SOCKET); - servers().lock().unwrap().remove(&SERVER); - statics::sockets().lock().unwrap().remove(&SOCKET); - } - } - let _cleanup = Cleanup; - sockets().lock().unwrap().insert( - SOCKET, - BunSocket { - handlers: Handlers::default(), - data_bits: 42.0f64.to_bits(), - connect_promise: 0, - listener: None, - opened: false, - paused: false, - paused_data: VecDeque::new(), - paused_end: false, - paused_close: false, - shutting_down: false, - needs_drain: false, - last_error: None, - }, - ); - servers().lock().unwrap().insert( - SERVER, - BunServer { - handlers: Handlers::default(), - data_bits: 43.0f64.to_bits(), - refed: false, - ready: false, - }, - ); - for name in [ - "write", - "end", - "close", - "terminate", - "ref", - "unref", - "pause", - "resume", - "flush", - "reload", - "shutdown", - "stop", - "destroyed", - "connecting", - "writableLength", - "", - "unknown", - ] { - assert_eq!( - method_name(SOCKET, name), - (!matches!( - name, - "stop" | "destroyed" | "connecting" | "writableLength" | "" | "unknown" - )) - .then_some(name.as_bytes()), - "socket method {name}" - ); - assert_eq!( - method_name(SERVER, name), - matches!(name, "stop" | "ref" | "unref" | "reload").then_some(name.as_bytes()), - "server method {name}" - ); - assert_eq!(method_name(MISSING, name), None, "missing method {name}"); - } - for name in [ - "data", - "listener", - "remoteAddress", - "remotePort", - "remoteFamily", - "localAddress", - "localPort", - "localFamily", - "bytesWritten", - "readyState", - "port", - "hostname", - "unix", - "destroyed", - "connecting", - "writableLength", - "", - "unknown", - ] { - assert_eq!( - property(SOCKET, name).is_some(), - matches!( - name, - "data" - | "listener" - | "remoteAddress" - | "remotePort" - | "remoteFamily" - | "localAddress" - | "localPort" - | "localFamily" - | "bytesWritten" - | "readyState" - ), - "socket property {name}" - ); - assert_eq!( - property(SERVER, name).is_some(), - matches!(name, "data" | "port" | "hostname" | "unix"), - "server property {name}" - ); - assert!(property(MISSING, name).is_none(), "missing property {name}"); - } - assert_eq!(property(SOCKET, "data"), Some(42.0)); - assert_eq!(property(SERVER, "data"), Some(43.0)); - for name in [ - "listener", - "remoteAddress", - "remotePort", - "remoteFamily", - "localAddress", - "localPort", - "localFamily", - ] { - assert_eq!( - property(SOCKET, name).unwrap().to_bits(), - TAG_UNDEFINED, - "unset {name}" - ); - } - assert_eq!(property(SOCKET, "readyState"), Some(0.0)); - assert_eq!(property(SERVER, "port"), Some(0.0)); - assert_eq!(property(SERVER, "unix").unwrap().to_bits(), TAG_UNDEFINED); - // Bun's numeric readyState differs from node:net's string property. - // The shared name must keep Bun precedence after Node-only flags move. - statics::sockets() - .lock() - .unwrap() - .insert(SOCKET, crate::SocketState::for_test(false)); - for (handle, name, expected) in [ - (SOCKET, "destroyed", unsafe { - crate::js_net_socket_get_destroyed(SOCKET) - }), - (SOCKET, "connecting", unsafe { - crate::js_net_socket_get_connecting(SOCKET) - }), - (SOCKET, "writableLength", unsafe { - crate::js_net_socket_get_writable_length(SOCKET) - }), - (SOCKET, "data", 42.0), - (SERVER, "data", 43.0), - ( - SOCKET, - "readyState", - property(SOCKET, "readyState").unwrap(), - ), - ] { - let mut actual = undefined(); - assert_eq!( - unsafe { - crate::dispatch::js_ext_net_handle_property_dispatch( - handle, - name.as_ptr(), - name.len(), - &mut actual, - ) - }, - 1, - "whole dispatch {name}" - ); - assert_eq!( - actual.to_bits(), - expected.to_bits(), - "whole dispatch value {name}" - ); - } - for (handle, name) in [(SOCKET, "write"), (SERVER, "stop")] { - let mut actual = undefined(); - assert_eq!( - unsafe { - crate::dispatch::js_ext_net_handle_property_dispatch( - handle, - name.as_ptr(), - name.len(), - &mut actual, - ) - }, - 1, - "bound method {name}" - ); - assert_ne!(actual.to_bits(), TAG_UNDEFINED, "bound method value {name}"); - } + match name { + "listen" => p::boxed_addr(js_bun_tcp_listen(options)), + "connect" => p::boxed_addr(js_bun_tcp_connect(options) as i64), + _ => p::undefined(), } } diff --git a/crates/perry-ext-net/src/classes.rs b/crates/perry-ext-net/src/classes.rs index 9e65ed78bb..908fb6b575 100644 --- a/crates/perry-ext-net/src/classes.rs +++ b/crates/perry-ext-net/src/classes.rs @@ -1,13 +1,24 @@ +use perry_ffi::native_payload::{self as np, PayloadFamily, PayloadMiss}; +use perry_ffi::TransientRootScope; use perry_ffi::{ alloc_string, js_array_alloc, js_array_get, js_array_push, ArrayHeader, JsValue, StringHeader, }; -use std::collections::HashMap; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; -use std::sync::{Mutex, OnceLock}; - -use crate::{ - get_object_number_field, get_object_string_field, next_id_or_throw, string_from_header_i64, -}; +#[path = "classes_prototype.rs"] +mod prototype; + +const BLOCK_LIST_ID: u32 = perry_ffi::native_class_ids::NET_BLOCK_LIST; +const SOCKET_ADDRESS_ID: u32 = perry_ffi::native_class_ids::NET_SOCKET_ADDRESS; +static BLOCK_LIST_VTABLE: perry_ffi::native_stream::PayloadVTable = perry_ffi::native_stream::payload_vtable::(None); +static BLOCK_LIST: PayloadFamily = PayloadFamily::new::(BLOCK_LIST_ID, "BlockList", false, &BLOCK_LIST_VTABLE) + .with_constructor_length(0) + .with_installer(prototype::install_block_list); +static SOCKET_ADDRESS_VTABLE: perry_ffi::native_stream::PayloadVTable = perry_ffi::native_stream::payload_vtable::(None); +static SOCKET_ADDRESS: PayloadFamily = PayloadFamily::new::(SOCKET_ADDRESS_ID, "SocketAddress", false, &SOCKET_ADDRESS_VTABLE) + .with_constructor_length(0) + .with_installer(prototype::install_socket_address); + +use crate::{get_object_number_field, get_object_string_field, string_from_header_i64}; const TAG_UNDEFINED: u64 = 0x7FFC_0000_0000_0001; @@ -30,14 +41,36 @@ struct SocketAddressState { flowlabel: u32, } -fn block_lists() -> &'static Mutex> { - static MAP: OnceLock>> = OnceLock::new(); - MAP.get_or_init(|| Mutex::new(HashMap::new())) +// The static native-call ABI passes a raw object address. State is reached +// through that ordinary object's class-checked cell, never through an id. +unsafe fn block_list<'a>(handle: i64) -> &'a mut BlockListState { + np::payload_mut(boxed_handle(handle), &BLOCK_LIST).unwrap_or_else(throw_miss) } -fn socket_addresses() -> &'static Mutex> { - static MAP: OnceLock>> = OnceLock::new(); - MAP.get_or_init(|| Mutex::new(HashMap::new())) +fn throw_miss(miss: PayloadMiss) -> T { + extern "C" { + fn js_typeerror_new(message: *mut StringHeader) -> *mut u8; + } + // Release transports JS exceptions as raw foreign unwind records. Its + // panic=abort C-unwind call guard would abort before the generated catch. + #[cfg(panic = "abort")] + extern "C" { + fn js_throw(value: f64) -> !; + } + #[cfg(not(panic = "abort"))] + extern "C-unwind" { + fn js_throw(value: f64) -> !; + } + let text = match miss { + PayloadMiss::Foreign => "Illegal receiver", + PayloadMiss::Closed => "Native payload is closed", + }; + let message = alloc_string(text); + unsafe { + js_throw(f64::from_bits( + JsValue::from_object_ptr(js_typeerror_new(message.as_raw())).bits(), + )) + } } extern "C" { @@ -63,8 +96,6 @@ fn handle_from_value(value: f64) -> Option { let value = JsValue::from_bits(value.to_bits()); if value.is_pointer() { Some(value.as_pointer::() as i64) - } else if value.is_number() { - Some(value.to_number() as i64) } else { None } @@ -120,21 +151,25 @@ fn rule_to_string(rule: &BlockRule) -> String { } fn rules_array(rules: &[BlockRule]) -> *mut ArrayHeader { - let mut arr = unsafe { js_array_alloc(rules.len() as u32) }; + let scope = TransientRootScope::enter(); + let mut arr = scope.root_addr(unsafe { js_array_alloc(rules.len() as u32) } as i64); for rule in rules.iter().rev() { let s = alloc_string(&rule_to_string(rule)); - arr = unsafe { js_array_push(arr, JsValue::from_string_ptr(s.as_raw())) }; + let next = unsafe { + js_array_push( + arr.get() as *mut ArrayHeader, + JsValue::from_string_ptr(s.as_raw()), + ) + }; + arr = scope.root_addr(next as i64); } - arr + arr.get() as *mut ArrayHeader } fn block_list_rules_array(handle: i64) -> *mut ArrayHeader { - block_lists() - .lock() - .unwrap() - .get(&handle) - .map(|list| rules_array(&list.rules)) - .unwrap_or_else(|| unsafe { js_array_alloc(0) }) + // Snapshot Rust-only data before allocating; no payload borrow crosses GC. + let rules = unsafe { block_list(handle).rules.clone() }; + rules_array(&rules) } fn addr_to_u128(ip: IpAddr) -> u128 { @@ -214,28 +249,22 @@ fn string_from_js_value(value: JsValue) -> Option { #[no_mangle] pub unsafe extern "C" fn js_net_block_list_new() -> i64 { - crate::ensure_gc_scanner_registered(); - crate::dispatch::ensure_runtime_dispatch_registered(); - let id = next_id_or_throw(); - block_lists() - .lock() - .unwrap() - .insert(id, BlockListState { rules: Vec::new() }); - id + handle_from_value(unsafe { np::alloc_in(&BLOCK_LIST, "net", + BlockListState { rules: Vec::new() }, + 0, + &[], + ) }) + .unwrap_or(0) } #[no_mangle] pub extern "C" fn js_ext_net_is_block_list_handle(handle: i64) -> i32 { - block_lists().lock().unwrap().contains_key(&handle) as i32 + np::lifecycle(boxed_handle(handle), &BLOCK_LIST).is_ok() as i32 } #[no_mangle] pub extern "C" fn js_net_block_list_is_block_list(value: f64) -> f64 { - js_bool( - JsValue::from_bits(value.to_bits()).is_pointer() - && handle_from_value(value) - .is_some_and(|h| block_lists().lock().unwrap().contains_key(&h)), - ) + js_bool(np::lifecycle(value, &BLOCK_LIST).is_ok()) } #[no_mangle] @@ -244,10 +273,12 @@ pub unsafe extern "C" fn js_net_block_list_add_address( address_ptr: i64, family_ptr: i64, ) -> f64 { + let scope = TransientRootScope::enter(); + let owner = scope.root_addr(handle); let ip = parse_ip(address_ptr, family_ptr); - if let Some(list) = block_lists().lock().unwrap().get_mut(&handle) { - list.rules.push(BlockRule::Address(ip)); - } + block_list(owner.get()).rules.push(BlockRule::Address(ip)); + let bytes = block_list(owner.get()).rules.capacity() * std::mem::size_of::(); + np::set_external_bytes(boxed_handle(owner.get()), &BLOCK_LIST_VTABLE, bytes); undefined() } @@ -258,14 +289,18 @@ pub unsafe extern "C" fn js_net_block_list_add_range( end_ptr: i64, family_ptr: i64, ) -> f64 { + let scope = TransientRootScope::enter(); + let owner = scope.root_addr(handle); let start = parse_ip(start_ptr, family_ptr); let end = parse_ip(end_ptr, family_ptr); if !same_family(start, end) { js_net_throw_invalid_address(); } - if let Some(list) = block_lists().lock().unwrap().get_mut(&handle) { - list.rules.push(BlockRule::Range(start, end)); - } + block_list(owner.get()) + .rules + .push(BlockRule::Range(start, end)); + let bytes = block_list(owner.get()).rules.capacity() * std::mem::size_of::(); + np::set_external_bytes(boxed_handle(owner.get()), &BLOCK_LIST_VTABLE, bytes); undefined() } @@ -276,12 +311,16 @@ pub unsafe extern "C" fn js_net_block_list_add_subnet( prefix: f64, family_ptr: i64, ) -> f64 { + let scope = TransientRootScope::enter(); + let owner = scope.root_addr(handle); let ip = parse_ip(address_ptr, family_ptr); let max = if ip.is_ipv4() { 32.0 } else { 128.0 }; let prefix = js_net_validate_block_list_prefix(prefix, max) as u8; - if let Some(list) = block_lists().lock().unwrap().get_mut(&handle) { - list.rules.push(BlockRule::Subnet(ip, prefix)); - } + block_list(owner.get()) + .rules + .push(BlockRule::Subnet(ip, prefix)); + let bytes = block_list(owner.get()).rules.capacity() * std::mem::size_of::(); + np::set_external_bytes(boxed_handle(owner.get()), &BLOCK_LIST_VTABLE, bytes); undefined() } @@ -291,12 +330,13 @@ pub unsafe extern "C" fn js_net_block_list_check( address_ptr: i64, family_ptr: i64, ) -> f64 { + let scope = TransientRootScope::enter(); + let owner = scope.root_addr(handle); let ip = parse_ip(address_ptr, family_ptr); - let contains = block_lists() - .lock() - .unwrap() - .get(&handle) - .is_some_and(|list| list.rules.iter().any(|rule| rule_contains(rule, ip))); + let contains = block_list(owner.get()) + .rules + .iter() + .any(|rule| rule_contains(rule, ip)); js_bool(contains) } @@ -312,6 +352,8 @@ pub extern "C" fn js_net_block_list_rules(handle: i64) -> *mut ArrayHeader { #[no_mangle] pub unsafe extern "C" fn js_net_block_list_from_json(handle: i64, value: f64) -> f64 { + let scope = TransientRootScope::enter(); + let receiver = scope.root_addr(handle); let value = JsValue::from_bits(value.to_bits()); if !value.is_pointer() { return undefined(); @@ -320,48 +362,50 @@ pub unsafe extern "C" fn js_net_block_list_from_json(handle: i64, value: f64) -> if arr.is_null() { return undefined(); } + let arr = scope.root_addr(arr as i64); let mut parsed = Vec::new(); - for i in 0..(*arr).length { - if let Some(rule) = - string_from_js_value(js_array_get(arr, i)).and_then(|s| parse_rule_string(&s)) + let length = (*(arr.get() as *mut ArrayHeader)).length; + for i in 0..length { + if let Some(rule) = string_from_js_value(js_array_get(arr.get() as *mut ArrayHeader, i)) + .and_then(|s| parse_rule_string(&s)) { parsed.push(rule); } } - if let Some(list) = block_lists().lock().unwrap().get_mut(&handle) { - list.rules = parsed; - } + block_list(receiver.get()).rules = parsed; + let bytes = block_list(receiver.get()).rules.capacity() * std::mem::size_of::(); + np::set_external_bytes(boxed_handle(receiver.get()), &BLOCK_LIST_VTABLE, bytes); undefined() } fn socket_address_new(address: IpAddr, port: u16, flowlabel: u32) -> f64 { - crate::dispatch::ensure_runtime_dispatch_registered(); - let id = next_id_or_throw(); - socket_addresses().lock().unwrap().insert( - id, + unsafe { np::alloc_in(&SOCKET_ADDRESS, "net", SocketAddressState { address, port, flowlabel, }, - ); - boxed_handle(id) + 0, + &[], + ) } } #[no_mangle] pub unsafe extern "C" fn js_net_socket_address_new(options: f64) -> i64 { - crate::ensure_gc_scanner_registered(); - let address = get_object_string_field(options, "address").unwrap_or_else(|| "127.0.0.1".into()); - let family = get_object_string_field(options, "family").map(|s| s.to_ascii_lowercase()); + let scope = TransientRootScope::enter(); + let options = scope.root_nanbox(options); + let address = + get_object_string_field(options.get(), "address").unwrap_or_else(|| "127.0.0.1".into()); + let family = get_object_string_field(options.get(), "family").map(|s| s.to_ascii_lowercase()); let Ok(ip) = address.parse::() else { js_net_throw_invalid_address() }; if !family_matches(&ip, family.as_deref()) { js_net_throw_invalid_address(); } - let port = get_object_number_field(options, "port").unwrap_or(0.0); + let port = get_object_number_field(options.get(), "port").unwrap_or(0.0); js_net_validate_listen_port(port); - let flowlabel = get_object_number_field(options, "flowlabel") + let flowlabel = get_object_number_field(options.get(), "flowlabel") .unwrap_or(0.0) .max(0.0) as u32; handle_from_value(socket_address_new(ip, port as u16, flowlabel)).unwrap_or(0) @@ -369,7 +413,7 @@ pub unsafe extern "C" fn js_net_socket_address_new(options: f64) -> i64 { #[no_mangle] pub extern "C" fn js_ext_net_is_socket_address_handle(handle: i64) -> i32 { - socket_addresses().lock().unwrap().contains_key(&handle) as i32 + np::lifecycle(boxed_handle(handle), &SOCKET_ADDRESS).is_ok() as i32 } #[no_mangle] @@ -409,13 +453,11 @@ pub unsafe extern "C" fn js_net_socket_address_parse(input_ptr: i64) -> f64 { } } -fn with_socket_address(handle: i64, default: T, f: impl FnOnce(&SocketAddressState) -> T) -> T { - socket_addresses() - .lock() - .unwrap() - .get(&handle) - .map(f) - .unwrap_or(default) +fn with_socket_address(handle: i64, _default: T, f: impl FnOnce(&SocketAddressState) -> T) -> T { + let state = + unsafe { np::payload_mut::(boxed_handle(handle), &SOCKET_ADDRESS) } + .unwrap_or_else(throw_miss); + f(state) } #[no_mangle] @@ -455,3 +497,46 @@ pub unsafe extern "C" fn js_net_socket_address_parse_value(input: f64) -> f64 { } js_net_socket_address_parse(js_net_validate_socket_address_parse_input(input)) } + +#[no_mangle] +pub extern "C" fn js_net_socket_address_to_json(handle: i64) -> f64 { + let state = with_socket_address(handle, None, |s| Some(s.clone())).unwrap(); + let scope = TransientRootScope::enter(); + let address = scope.root_nanbox(f64::from_bits( + JsValue::from_string_ptr(alloc_string(&state.address.to_string()).as_raw()).bits(), + )); + let family = scope.root_nanbox(f64::from_bits( + JsValue::from_string_ptr( + alloc_string(if state.address.is_ipv6() { + "ipv6" + } else { + "ipv4" + }) + .as_raw(), + ) + .bits(), + )); + let keys = ["address", "port", "family", "flowlabel"]; + let (packed, shape_id) = perry_ffi::build_object_shape(&keys); + let object = scope.root_addr(unsafe { + perry_ffi::js_object_alloc_with_shape(shape_id, 4, packed.as_ptr(), packed.len() as u32) + } as i64); + for (i, value) in [ + address.get(), + state.port as f64, + family.get(), + state.flowlabel as f64, + ] + .into_iter() + .enumerate() + { + unsafe { + perry_ffi::js_object_set_field( + object.get() as *mut perry_ffi::ObjectHeader, + i as u32, + JsValue::from_bits(value.to_bits()), + ); + } + } + boxed_handle(object.get()) +} diff --git a/crates/perry-ext-net/src/classes_prototype.rs b/crates/perry-ext-net/src/classes_prototype.rs new file mode 100644 index 0000000000..5da00a3dbe --- /dev/null +++ b/crates/perry-ext-net/src/classes_prototype.rs @@ -0,0 +1,169 @@ +//! Prototype bodies for the Rust-only net payload families. +use super::*; +use perry_ffi::{JsThis, RawClosureHeader, TransientRootedAddr}; +use std::ffi::c_void; + +extern "C" { + fn js_value_to_str_ptr_for_ffi(value: f64) -> i64; +} + +fn receiver(scope: &TransientRootScope, this: JsThis) -> TransientRootedAddr { + scope.root_addr(handle_from_value(this.as_f64()).unwrap_or(0)) +} +fn string_arg(scope: &TransientRootScope, value: f64) -> TransientRootedAddr { + if JsValue::from_bits(value.to_bits()).is_undefined() { + return scope.root_addr(0); + } + scope.root_addr(unsafe { js_value_to_str_ptr_for_ffi(value) }) +} + +unsafe extern "C" fn add_address( + _: *const RawClosureHeader, + this: JsThis, + address: f64, + family: f64, +) -> f64 { + let scope = TransientRootScope::enter(); + let owner = receiver(&scope, this); + block_list(owner.get()); + let family = scope.root_nanbox(family); + let address = string_arg(&scope, address); + let family = string_arg(&scope, family.get()); + js_net_block_list_add_address(owner.get(), address.get(), family.get()) +} +unsafe extern "C" fn add_range( + _: *const RawClosureHeader, + this: JsThis, + start: f64, + end: f64, + family: f64, +) -> f64 { + let scope = TransientRootScope::enter(); + let owner = receiver(&scope, this); + block_list(owner.get()); + let end = scope.root_nanbox(end); + let family = scope.root_nanbox(family); + let start = string_arg(&scope, start); + let end = string_arg(&scope, end.get()); + let family = string_arg(&scope, family.get()); + js_net_block_list_add_range(owner.get(), start.get(), end.get(), family.get()) +} +unsafe extern "C" fn add_subnet( + _: *const RawClosureHeader, + this: JsThis, + address: f64, + prefix: f64, + family: f64, +) -> f64 { + let scope = TransientRootScope::enter(); + let owner = receiver(&scope, this); + block_list(owner.get()); + let prefix = scope.root_nanbox(prefix); + let family = scope.root_nanbox(family); + let address = string_arg(&scope, address); + let family = string_arg(&scope, family.get()); + js_net_block_list_add_subnet(owner.get(), address.get(), prefix.get(), family.get()) +} +unsafe extern "C" fn check( + _: *const RawClosureHeader, + this: JsThis, + address: f64, + family: f64, +) -> f64 { + let scope = TransientRootScope::enter(); + let owner = receiver(&scope, this); + block_list(owner.get()); + let family = scope.root_nanbox(family); + let address = string_arg(&scope, address); + let family = string_arg(&scope, family.get()); + js_net_block_list_check(owner.get(), address.get(), family.get()) +} +unsafe extern "C" fn rules(_: *const RawClosureHeader, this: JsThis) -> f64 { + js_net_block_list_to_json(handle_from_value(this.as_f64()).unwrap_or(0)) +} +unsafe extern "C" fn from_json(_: *const RawClosureHeader, this: JsThis, value: f64) -> f64 { + let handle = handle_from_value(this.as_f64()).unwrap_or(0); + block_list(handle); + js_net_block_list_from_json(handle, value) +} +unsafe extern "C" fn address(_: *const RawClosureHeader, this: JsThis) -> f64 { + let raw = js_net_socket_address_get_address(handle_from_value(this.as_f64()).unwrap_or(0)); + f64::from_bits(JsValue::from_string_ptr(raw).bits()) +} +unsafe extern "C" fn family(_: *const RawClosureHeader, this: JsThis) -> f64 { + let raw = js_net_socket_address_get_family(handle_from_value(this.as_f64()).unwrap_or(0)); + f64::from_bits(JsValue::from_string_ptr(raw).bits()) +} +unsafe extern "C" fn port(_: *const RawClosureHeader, this: JsThis) -> f64 { + js_net_socket_address_get_port(handle_from_value(this.as_f64()).unwrap_or(0)) +} +unsafe extern "C" fn flowlabel(_: *const RawClosureHeader, this: JsThis) -> f64 { + js_net_socket_address_get_flowlabel(handle_from_value(this.as_f64()).unwrap_or(0)) +} + +unsafe extern "C" fn address_json(_: *const RawClosureHeader, this: JsThis) -> f64 { + js_net_socket_address_to_json(handle_from_value(this.as_f64()).unwrap_or(0)) +} + +pub(super) unsafe extern "C" fn install_block_list(raw: *mut c_void) { + let mut proto = np::PayloadPrototype::from_raw(raw); + proto.method( + "addAddress", + perry_ffi::js_function_info!(add_address, 2; with_flags(perry_ffi::FN_BUILTIN)), + 1, + ); + proto.method( + "addRange", + perry_ffi::js_function_info!(add_range, 3; with_flags(perry_ffi::FN_BUILTIN)), + 2, + ); + proto.method( + "addSubnet", + perry_ffi::js_function_info!(add_subnet, 3; with_flags(perry_ffi::FN_BUILTIN)), + 2, + ); + proto.method( + "check", + perry_ffi::js_function_info!(check, 2; with_flags(perry_ffi::FN_BUILTIN)), + 1, + ); + proto.method( + "toJSON", + perry_ffi::js_function_info!(rules, 0; with_flags(perry_ffi::FN_BUILTIN)), + 0, + ); + proto.method( + "fromJSON", + perry_ffi::js_function_info!(from_json, 1; with_flags(perry_ffi::FN_BUILTIN)), + 1, + ); + proto.getter( + "rules", + perry_ffi::js_function_info!(rules, 0; with_flags(perry_ffi::FN_BUILTIN)), + ); +} + +pub(super) unsafe extern "C" fn install_socket_address(raw: *mut c_void) { + let mut proto = np::PayloadPrototype::from_raw(raw); + proto.getter( + "address", + perry_ffi::js_function_info!(address, 0; with_flags(perry_ffi::FN_BUILTIN)), + ); + proto.getter( + "port", + perry_ffi::js_function_info!(port, 0; with_flags(perry_ffi::FN_BUILTIN)), + ); + proto.getter( + "family", + perry_ffi::js_function_info!(family, 0; with_flags(perry_ffi::FN_BUILTIN)), + ); + proto.getter( + "flowlabel", + perry_ffi::js_function_info!(flowlabel, 0; with_flags(perry_ffi::FN_BUILTIN)), + ); + proto.method( + "toJSON", + perry_ffi::js_function_info!(address_json, 0; with_flags(perry_ffi::FN_BUILTIN)), + 0, + ); +} diff --git a/crates/perry-ext-net/src/cork.rs b/crates/perry-ext-net/src/cork.rs deleted file mode 100644 index b659a462a9..0000000000 --- a/crates/perry-ext-net/src/cork.rs +++ /dev/null @@ -1,219 +0,0 @@ -//! Node writable corking: hold owned bytes until the outermost uncork or end. -use crate::{statics, SocketCommand, SocketState}; - -#[derive(Default)] -pub(crate) struct CorkBuffer { - pub(crate) depth: u32, - pub(crate) bytes: Vec, - completions: Vec, - has_writes: bool, -} - -impl CorkBuffer { - pub(crate) fn push(&mut self, bytes: Vec, completion: u64) { - self.bytes.extend_from_slice(&bytes); - self.has_writes = true; - if completion != 0 { - self.completions.push(completion); - } - } - - /// Release buffered bytes; callback custody is retired by Close. - pub(crate) fn discard(&mut self) { - self.bytes.clear(); - self.completions.clear(); - self.has_writes = false; - } - - fn take_write(&mut self) -> Option { - if !std::mem::take(&mut self.has_writes) { - return None; - } - let bytes = std::mem::take(&mut self.bytes); - let tokens = std::mem::take(&mut self.completions); - // Callbacks stay in the existing GC-scanned custody table. Merge them - // under the first token so one transport completion fires them in order. - let token = tokens.first().copied().unwrap_or(0); - if token != 0 { - let mut table = crate::lifecycle::socket_completions().lock().unwrap(); - let mut callbacks = Vec::new(); - let mut owner = 0; - for t in tokens { - if let Some((id, mut cbs)) = table.remove(&t) { - owner = id; - callbacks.append(&mut cbs); - } - } - table.insert(token, (owner, callbacks)); - } - Some(SocketCommand::Write(bytes, token)) - } -} - -impl SocketState { - pub(crate) fn flush_cork(&mut self, id: i64) -> Result<(), String> { - let Some(cmd) = self.cork.take_write() else { - return Ok(()); - }; - let completion = match &cmd { - SocketCommand::Write(_, token) => *token, - _ => 0, - }; - if let SocketCommand::Write(ref bytes, _) = cmd { - self.bytes_queued = self.bytes_queued.saturating_sub(bytes.len() as u64); - } - let result = self.command_transport(id, cmd); - if let Err(ref message) = result { - // Queue the error; never invoke JS while holding the socket lock. - crate::push_event(crate::PendingNetEvent::WriteComplete( - id, - completion, - Some(message.clone()), - )); - } - result - } -} - -#[no_mangle] -pub extern "C" fn js_net_socket_cork(handle: i64) -> i64 { - if let Some(socket) = statics::sockets().lock().unwrap().get_mut(&handle) { - socket.cork.depth = socket.cork.depth.saturating_add(1); - } - handle -} - -#[no_mangle] -pub extern "C" fn js_net_socket_uncork(handle: i64) -> i64 { - let failure = { - let mut sockets = statics::sockets().lock().unwrap(); - sockets.get_mut(&handle).and_then(|socket| { - socket.cork.depth = socket.cork.depth.saturating_sub(1); - if socket.cork.depth == 0 { - socket.flush_cork(handle).err() - } else { - None - } - }) - }; - if let Some(message) = failure { - crate::turnloop_io::submission_failed(handle, 0, message); - } - handle -} - -#[no_mangle] -pub extern "C" fn js_net_socket_get_writable_corked(handle: i64) -> f64 { - statics::sockets() - .lock() - .unwrap() - .get(&handle) - .map(|s| s.cork.depth as f64) - .unwrap_or(0.0) -} - -#[cfg(test)] -mod tests { - use super::*; - #[test] - fn corked_writes_form_one_ordered_transport_command() { - let mut socket = SocketState::for_test(false); - socket.turnloop = true; - socket.cork.depth = 2; - for part in ["P", "arse-message-", "B", "ind-portal-", "E"] { - socket - .command(123, SocketCommand::Write(part.as_bytes().to_vec(), 0)) - .unwrap(); - } - assert_eq!( - socket.bytes_queued, - b"Parse-message-Bind-portal-E".len() as u64 - ); - assert_eq!(socket.cork.depth, 2); - match socket.cork.take_write().unwrap() { - SocketCommand::Write(bytes, 0) => assert_eq!(bytes, b"Parse-message-Bind-portal-E"), - _ => panic!("expected one write"), - } - assert!(socket.cork.take_write().is_none()); - } - #[test] - fn batch_completion_retains_every_callback_in_write_order() { - let _lock = crate::tests::GC_TEST_LOCK - .lock() - .unwrap_or_else(|p| p.into_inner()); - let mut buffer = CorkBuffer::default(); - { - let mut table = crate::lifecycle::socket_completions().lock().unwrap(); - table.insert(u64::MAX - 1, (-10526, vec![101])); - table.insert(u64::MAX, (-10526, vec![102])); - } - buffer.push(vec![1], u64::MAX - 1); - buffer.push(vec![2], 0); - buffer.push(vec![3], u64::MAX); - assert!( - matches!(buffer.take_write(), Some(SocketCommand::Write(bytes, token)) - if bytes == [1,2,3] && token == u64::MAX - 1) - ); - let mut table = crate::lifecycle::socket_completions().lock().unwrap(); - assert_eq!( - table.remove(&(u64::MAX - 1)), - Some((-10526, vec![101, 102])) - ); - assert!(!table.contains_key(&u64::MAX)); - } - #[test] - fn cork_before_connect_holds_bytes_without_a_transport() { - let mut socket = SocketState::for_test(true); - socket.cork.depth = 1; - socket - .command(-10526, SocketCommand::Write(vec![1, 2], 0)) - .unwrap(); - assert_eq!(socket.bytes_queued, 2); - assert_eq!(socket.cork.bytes, [1, 2]); - assert!(!socket.unconnected_write_failed); - } - #[test] - fn destroying_unopened_corked_socket_orders_and_retires_completions_once() { - let _lock = crate::tests::GC_TEST_LOCK - .lock() - .unwrap_or_else(|p| p.into_inner()); - let id = unsafe { crate::js_net_socket_alloc() }; - { - let mut sockets = statics::sockets().lock().unwrap(); - let socket = sockets.get_mut(&id).unwrap(); - socket.cork.depth = 1; - // Insert out of order so this verifies token ordering rather than - // HashMap insertion order. Empty callback vectors need no JS heap. - for token in [u64::MAX - 2, u64::MAX - 4, u64::MAX - 3] { - crate::lifecycle::socket_completions() - .lock() - .unwrap() - .insert(token, (id, Vec::new())); - socket - .command(id, SocketCommand::Write(vec![1], token)) - .unwrap(); - } - } - crate::lifecycle::js_ext_net_destroy_socket(id); - crate::lifecycle::js_ext_net_destroy_socket(id); - assert_eq!( - crate::lifecycle::pending_socket_completions(id), - [u64::MAX - 4, u64::MAX - 3, u64::MAX - 2] - ); - assert_eq!(statics::sockets().lock().unwrap()[&id].bytes_queued, 0); - assert_eq!( - statics::pending_events() - .lock() - .unwrap() - .iter() - .filter(|ev| matches!(ev, crate::PendingNetEvent::Close(h) if *h == id)) - .count(), - 1 - ); - unsafe { - crate::js_net_process_pending(); - } - assert!(crate::lifecycle::pending_socket_completions(id).is_empty()); - assert!(!statics::sockets().lock().unwrap().contains_key(&id)); - } -} diff --git a/crates/perry-ext-net/src/dispatch.rs b/crates/perry-ext-net/src/dispatch.rs deleted file mode 100644 index e586dbe3fc..0000000000 --- a/crates/perry-ext-net/src/dispatch.rs +++ /dev/null @@ -1,805 +0,0 @@ -//! Runtime handle-dispatch extension for `perry-ext-net` handles. -//! -//! The default debug stdlib archive is built with bundled net enabled, so it -//! cannot reference ext-net symbols directly. Registering this extension lets -//! small external net handles expose method values and alias-call dispatch while -//! still falling through to the primary stdlib dispatcher for every other -//! handle family. - -use perry_ffi::{ - build_object_shape, js_object_alloc_with_shape, js_object_set_field, register_aux_event_pump, - ArrayHeader, JsClosure, JsPromise, JsValue, ObjectHeader, Promise, RawClosureHeader, - StringHeader, -}; -use std::sync::Once; - -use crate::statics; - -const TAG_UNDEFINED: u64 = 0x7FFC_0000_0000_0001; -const TAG_NULL: u64 = 0x7FFC_0000_0000_0002; -const POINTER_TAG: u64 = 0x7FFD_0000_0000_0000; -const POINTER_MASK: u64 = 0x0000_FFFF_FFFF_FFFF; - -extern "C" { - fn js_class_method_bind( - instance: f64, - method_name_ptr: *const u8, - method_name_len: usize, - ) -> f64; - fn js_json_parse_or_null(text_ptr: *const StringHeader) -> JsValue; - fn js_promise_resolve(promise: *mut Promise, value: f64); - fn js_register_handle_method_dispatch_extension( - f: unsafe extern "C" fn(i64, *const u8, usize, *const f64, usize, *mut f64) -> i32, - ); - fn js_register_handle_property_dispatch_extension( - f: unsafe extern "C" fn(i64, *const u8, usize, *mut f64) -> i32, - ); - fn js_register_handle_property_set_dispatch_extension( - f: unsafe extern "C" fn(i64, *const u8, usize, f64) -> i32, - ); - fn js_set_native_bun_tcp_dispatch( - f: unsafe extern "C" fn(*const u8, usize, *const f64, usize) -> f64, - ); -} - -// Keep this adapter distinct from the legacy exported trampoline in the -// crate root. LLVM otherwise merges their identical bodies and rewrites this -// callback's address to `js_net_process_pending`; bundled stdlib can supply that -// symbol and silently redirect the registered pump to its unrelated queue. -// The differing noinline attribute prevents that merge (including at -O3). -#[inline(never)] -extern "C" fn process_pending_aux() -> i32 { - // Drain via the DISTINCT `js_ext_net_drain_pending` symbol — NOT the - // `js_net_process_pending` extern, whose symbol the bundled stdlib net - // twin shadows in a workspace build (that twin drains stdlib's queue, - // leaving ext-net's own adopted-socket events — e.g. the raw-`'upgrade'` - // `Close` — stuck and the loop pinned). #5010. - unsafe { crate::js_ext_net_drain_pending() } -} - -pub(crate) fn ensure_runtime_dispatch_registered() { - static REGISTER: Once = Once::new(); - REGISTER.call_once(|| { - register_aux_event_pump(process_pending_aux, crate::js_ext_net_has_active_handles); - unsafe { - js_register_handle_method_dispatch_extension(js_ext_net_handle_method_dispatch); - js_register_handle_property_dispatch_extension(js_ext_net_handle_property_dispatch); - js_register_handle_property_set_dispatch_extension( - js_ext_net_handle_property_set_dispatch, - ); - js_set_native_bun_tcp_dispatch(crate::bun_tcp::js_bun_tcp_native_dispatch); - } - }); -} - -pub(crate) fn undefined() -> f64 { - f64::from_bits(TAG_UNDEFINED) -} - -fn null() -> f64 { - f64::from_bits(TAG_NULL) -} - -pub(crate) fn nanbox_handle(handle: i64) -> f64 { - f64::from_bits(POINTER_TAG | (handle as u64 & POINTER_MASK)) -} - -fn nanbox_ptr(ptr: *mut T) -> f64 { - f64::from_bits(POINTER_TAG | (ptr as u64 & POINTER_MASK)) -} - -fn socket_private_handle(handle: i64) -> f64 { - let fd = statics::sockets() - .lock() - .ok() - .and_then(|sockets| sockets.get(&handle).and_then(|socket| socket.raw_fd)) - .unwrap_or(-1); - let keys = ["fd"]; - let (packed, shape_id) = build_object_shape(&keys); - let object: *mut ObjectHeader = - unsafe { js_object_alloc_with_shape(shape_id, 1, packed.as_ptr(), packed.len() as u32) }; - if object.is_null() { - return undefined(); - } - unsafe { js_object_set_field(object, 0, JsValue::from_number(fd as f64)) }; - nanbox_ptr(object) -} - -fn unbox_to_i64(v: f64) -> i64 { - (v.to_bits() & POINTER_MASK) as i64 -} - -/// Unbox a string argument (an event name, an address, an encoding) for a native that takes a -/// `*const StringHeader` as `i64` and copies the name out on entry. An inline -/// SSO name (`"da" + "ta"`, `String(n)`) has no header behind its masked bits, -/// so it goes through `string_arg_ptr`'s scratch copy (#11519); every other -/// value unboxes exactly as [`unbox_to_i64`] does. -fn str_arg(v: f64) -> i64 { - perry_ffi::string_arg_ptr(v) as i64 -} - -/// Coerce a nanboxed JS number to a plain `f64`. perry stores small integers as -/// a tagged int32 (`0x7FFE` high bits), not a raw double, so passing the raw -/// nanboxed value where a real number is expected (e.g. `BlockList.addSubnet`'s -/// `prefix: f64`) yields garbage bits. A non-int-tagged value is already an -/// IEEE double and passes through unchanged. -fn unbox_to_f64(v: f64) -> f64 { - const INT32_TAG: u64 = 0x7FFE_0000_0000_0000; - const INT32_MASK: u64 = 0x0000_0000_FFFF_FFFF; - let bits = v.to_bits(); - if (bits & 0xFFFF_0000_0000_0000) == INT32_TAG { - ((bits & INT32_MASK) as u32 as i32) as f64 - } else { - v - } -} - -fn property_name<'a>(ptr: *const u8, len: usize) -> &'a str { - if ptr.is_null() || len == 0 { - "" - } else { - unsafe { std::str::from_utf8(std::slice::from_raw_parts(ptr, len)).unwrap_or("") } - } -} - -fn json_str_to_value(s: *mut StringHeader) -> f64 { - if s.is_null() { - return null(); - } - f64::from_bits(unsafe { js_json_parse_or_null(s).bits() }) -} - -fn resolved_promise(value: f64) -> f64 { - let promise = JsPromise::new(); - let raw = promise.as_raw(); - unsafe { js_promise_resolve(raw, value) }; - nanbox_ptr(raw) -} - -fn bind_handle_method(handle: i64, name: &'static [u8]) -> f64 { - unsafe { js_class_method_bind(nanbox_handle(handle), name.as_ptr(), name.len()) } -} - -fn socket_method_name(prop: &str) -> Option<&'static [u8]> { - match prop { - "address" => Some(b"address"), - "connect" => Some(b"connect"), - "destroy" => Some(b"destroy"), - "destroySoon" => Some(b"destroySoon"), - "end" => Some(b"end"), - "emit" => Some(b"emit"), - "pause" => Some(b"pause"), - "read" => Some(b"read"), - "ref" => Some(b"ref"), - "resetAndDestroy" => Some(b"resetAndDestroy"), - "resume" => Some(b"resume"), - "setEncoding" => Some(b"setEncoding"), - "setKeepAlive" => Some(b"setKeepAlive"), - "setNoDelay" => Some(b"setNoDelay"), - "getTypeOfService" => Some(b"getTypeOfService"), - "setTypeOfService" => Some(b"setTypeOfService"), - "setTimeout" => Some(b"setTimeout"), - "getMaxListeners" => Some(b"getMaxListeners"), - "setMaxListeners" => Some(b"setMaxListeners"), - "unref" => Some(b"unref"), - "write" => Some(b"write"), - "on" => Some(b"on"), - "addListener" => Some(b"addListener"), - "once" => Some(b"once"), - // #10441 — front-inserting variants of `on`/`once`. Missing here - // meant the untyped dispatch fell through to the generic property - // read for these names, which returned `undefined`: calling it was - // a silent no-op instead of a `TypeError`. - "prependListener" => Some(b"prependListener"), - "prependOnceListener" => Some(b"prependOnceListener"), - // #10444 — `net.Socket` is a `stream.Duplex`; `pipe`/`unpipe` were - // entirely absent from this table. - "pipe" => Some(b"pipe"), - "unpipe" => Some(b"unpipe"), - "off" => Some(b"off"), - "removeListener" => Some(b"removeListener"), - "removeAllListeners" => Some(b"removeAllListeners"), - "listenerCount" => Some(b"listenerCount"), - "eventNames" => Some(b"eventNames"), - "listeners" => Some(b"listeners"), - "rawListeners" => Some(b"rawListeners"), - "upgradeToTLS" => Some(b"upgradeToTLS"), - "getSession" => Some(b"getSession"), - "isSessionReused" => Some(b"isSessionReused"), - // The primary stdlib dispatcher owns `getPeerCertificate`: it builds - // the complete legacy certificate object from the DER recorded at the - // handshake. Claiming it here returned the extension's reduced JSON - // facade, whose optional CN is populated only for adopted HTTPS - // sockets, so direct `tls.connect()` handles produced `{}`. - "setDefaultEncoding" => Some(b"setDefaultEncoding"), - "cork" => Some(b"cork"), - "uncork" => Some(b"uncork"), - _ => None, - } -} - -fn server_method_name(prop: &str) -> Option<&'static [u8]> { - match prop { - "address" => Some(b"address"), - "close" => Some(b"close"), - "getConnections" => Some(b"getConnections"), - "listen" => Some(b"listen"), - "ref" => Some(b"ref"), - "unref" => Some(b"unref"), - "@@__perry_wk_asyncDispose" => Some(b"@@__perry_wk_asyncDispose"), - "on" => Some(b"on"), - "addListener" => Some(b"addListener"), - "once" => Some(b"once"), - // #11227 — a server is an EventEmitter too; these were missing, so - // `server.prependListener(...)` silently registered nothing. - "prependListener" => Some(b"prependListener"), - "prependOnceListener" => Some(b"prependOnceListener"), - "off" => Some(b"off"), - "removeListener" => Some(b"removeListener"), - "removeAllListeners" => Some(b"removeAllListeners"), - "listenerCount" => Some(b"listenerCount"), - "eventNames" => Some(b"eventNames"), - "listeners" => Some(b"listeners"), - "rawListeners" => Some(b"rawListeners"), - _ => None, - } -} - -fn block_list_method_name(prop: &str) -> Option<&'static [u8]> { - match prop { - "addAddress" => Some(b"addAddress"), - "addRange" => Some(b"addRange"), - "addSubnet" => Some(b"addSubnet"), - "check" => Some(b"check"), - "toJSON" => Some(b"toJSON"), - "fromJSON" => Some(b"fromJSON"), - _ => None, - } -} - -unsafe fn socket_method(handle: i64, method: &str, args: &[f64]) -> Option { - if socket_method_name(method).is_none() || crate::js_ext_net_is_socket_handle(handle) == 0 { - return None; - } - - let result = match method { - "read" => { - crate::js_ext_net_socket_read(handle, args.first().copied().unwrap_or_else(undefined)) - } - // #11111 — Node's boolean: drain-aware writers (mongodb's - // `writeCommand`) wait for `'drain'` on anything falsy. - "write" if !args.is_empty() => crate::js_ext_net_socket_write3( - handle, - args[0], - args.get(1).copied().unwrap_or_else(undefined), - args.get(2).copied().unwrap_or_else(undefined), - ), - "end" => { - crate::js_ext_net_socket_end3( - handle, - args.first().copied().unwrap_or_else(undefined), - args.get(1).copied().unwrap_or_else(undefined), - args.get(2).copied().unwrap_or_else(undefined), - ); - undefined() - } - "emit" if !args.is_empty() => { - let event = str_arg(args[0]); - let rest = args.get(1..).unwrap_or(&[]); - crate::js_ext_net_socket_emit(handle, event, rest.as_ptr(), rest.len()) - } - "destroy" | "destroySoon" => { - // Drive teardown through the DISTINCT `js_ext_net_destroy_socket` - // symbol — NOT the `js_net_socket_destroy` extern, whose symbol the - // bundled stdlib net twin shadows in a workspace build (it would - // mark the socket destroyed in stdlib's registry, leaving the - // adopted raw-`'upgrade'` socket alive in ext-net's). #5010. - crate::js_ext_net_destroy_socket(handle); - undefined() - } - "on" | "addListener" if args.len() >= 2 => { - crate::js_net_socket_on(handle, str_arg(args[0]), unbox_to_i64(args[1])); - nanbox_handle(handle) - } - // #10441 — same shape as `once` below, but inserted at the FRONT of - // the listener list. - "prependListener" if args.len() >= 2 => { - crate::js_net_socket_prepend_listener(handle, str_arg(args[0]), unbox_to_i64(args[1])); - nanbox_handle(handle) - } - "prependOnceListener" if args.len() >= 2 => { - crate::js_net_socket_prepend_once_listener( - handle, - str_arg(args[0]), - unbox_to_i64(args[1]), - ); - nanbox_handle(handle) - } - // #10444 — forward socket data to `dest` via the same generic - // Get("write")+call duck-typed dispatch the runtime already uses to - // resolve thenables (`crate::pipe`), so `dest` can be any Writable - // representation (another handle-backed socket, a node:stream - // object, …), not just one specific one. - "pipe" if !args.is_empty() => crate::pipe::socket_pipe( - handle, - args[0], - args.get(1).copied().unwrap_or_else(undefined), - ), - "unpipe" => { - crate::pipe::socket_unpipe(handle, args.first().copied().unwrap_or_else(undefined)); - nanbox_handle(handle) - } - "connect" if !args.is_empty() => { - let arg2 = args.get(1).copied().unwrap_or_else(undefined); - let arg3 = args.get(2).copied().unwrap_or_else(undefined); - crate::js_ext_net_socket_method_connect(handle, args[0], arg2, arg3); - nanbox_handle(handle) - } - "upgradeToTLS" if !args.is_empty() => { - let verify = args.get(1).copied().unwrap_or(1.0); - nanbox_ptr(crate::js_net_socket_upgrade_tls( - handle, - unbox_to_i64(args[0]), - verify, - )) - } - "getSession" => nanbox_ptr(crate::js_ext_net_socket_tls_session(handle)), - "isSessionReused" => crate::js_ext_net_socket_tls_session_reused(handle), - "once" if args.len() >= 2 => { - crate::js_net_socket_once(handle, str_arg(args[0]), unbox_to_i64(args[1])); - nanbox_handle(handle) - } - "off" | "removeListener" if args.len() >= 2 => { - crate::js_net_socket_remove_listener(handle, str_arg(args[0]), unbox_to_i64(args[1])); - nanbox_handle(handle) - } - "removeAllListeners" => { - let event = args.first().copied().map(str_arg).unwrap_or(0); - crate::js_net_socket_remove_all_listeners(handle, event); - nanbox_handle(handle) - } - "listenerCount" if !args.is_empty() => { - crate::js_net_socket_listener_count(handle, str_arg(args[0])) - } - "getMaxListeners" => crate::js_ext_net_socket_get_max_listeners(handle), - "setMaxListeners" if !args.is_empty() => { - crate::js_ext_net_socket_set_max_listeners(handle, unbox_to_f64(args[0])) - } - "eventNames" => json_str_to_value(crate::js_net_socket_event_names(handle)), - "listeners" if !args.is_empty() => { - let arr = crate::js_net_socket_listeners(handle, str_arg(args[0])); - nanbox_ptr(arr as *mut ArrayHeader) - } - "rawListeners" if !args.is_empty() => { - let arr = crate::js_net_socket_raw_listeners(handle, str_arg(args[0])); - nanbox_ptr(arr as *mut ArrayHeader) - } - "address" => json_str_to_value(crate::js_net_socket_address(handle)), - "getTypeOfService" => crate::js_net_socket_get_type_of_service(handle), - "setTypeOfService" => { - let value = args.first().copied().unwrap_or_else(undefined); - crate::js_net_socket_set_type_of_service(handle, value); - nanbox_handle(handle) - } - "resetAndDestroy" => { - crate::js_net_socket_reset_and_destroy(handle); - nanbox_handle(handle) - } - "setTimeout" => { - let msecs = args.first().copied().unwrap_or(0.0); - let callback = args.get(1).copied().map(unbox_to_i64).unwrap_or(0); - crate::js_net_socket_set_timeout(handle, msecs, callback); - nanbox_handle(handle) - } - // #4973: real setEncoding — switches 'data' delivery to strings. - "setEncoding" => { - let enc_ptr = args - .first() - .map(|a| { - // Heap or inline SSO (`"utf8"`, `"hex"`, #11519). - let bits = a.to_bits(); - if (bits >> 48) == 0x7FFF || (bits >> 48) == 0x7FF9 { - str_arg(*a) - } else { - 0 - } - }) - .unwrap_or(0); - crate::js_net_socket_set_encoding(handle, enc_ptr); - nanbox_handle(handle) - } - "setNoDelay" => { - // Node coerces a missing arg to `true` inside the FFI helper, so - // pass `undefined` through when called with no argument. - let arg = args.first().copied().unwrap_or_else(undefined); - crate::js_net_socket_set_no_delay(handle, arg.to_bits() as i64); - nanbox_handle(handle) - } - "cork" => nanbox_handle(crate::cork::js_net_socket_cork(handle)), - "uncork" => nanbox_handle(crate::cork::js_net_socket_uncork(handle)), - "ref" => nanbox_handle(crate::js_net_socket_ref(handle)), - "unref" => nanbox_handle(crate::js_net_socket_unref(handle)), - "setKeepAlive" | "pause" | "resume" | "setDefaultEncoding" => nanbox_handle(handle), - _ => undefined(), - }; - Some(result) -} - -unsafe fn server_method(handle: i64, method: &str, args: &[f64]) -> Option { - if server_method_name(method).is_none() || crate::js_ext_net_is_server_handle(handle) == 0 { - return None; - } - - let result = match method { - "listen" => { - let port = args.first().copied().unwrap_or(0.0); - let arg2 = args.get(1).copied().unwrap_or_else(undefined); - let arg3 = args.get(2).copied().unwrap_or_else(undefined); - crate::js_net_server_listen(handle, port, arg2, arg3); - nanbox_handle(handle) - } - "close" => { - let callback = args.first().copied().map(unbox_to_i64).unwrap_or(0); - crate::js_net_server_close(handle, callback); - nanbox_handle(handle) - } - "@@__perry_wk_asyncDispose" => { - crate::js_net_server_close(handle, 0); - resolved_promise(undefined()) - } - "address" => json_str_to_value(crate::js_net_server_address(handle)), - "on" | "addListener" if args.len() >= 2 => { - crate::js_net_server_on(handle, str_arg(args[0]), unbox_to_i64(args[1])); - nanbox_handle(handle) - } - "once" if args.len() >= 2 => { - crate::js_net_server_once(handle, str_arg(args[0]), unbox_to_i64(args[1])); - nanbox_handle(handle) - } - "prependListener" if args.len() >= 2 => { - crate::js_net_server_prepend_listener(handle, str_arg(args[0]), unbox_to_i64(args[1])); - nanbox_handle(handle) - } - "prependOnceListener" if args.len() >= 2 => { - crate::js_net_server_prepend_once_listener( - handle, - str_arg(args[0]), - unbox_to_i64(args[1]), - ); - nanbox_handle(handle) - } - "off" | "removeListener" if args.len() >= 2 => { - crate::js_net_server_remove_listener(handle, str_arg(args[0]), unbox_to_i64(args[1])); - nanbox_handle(handle) - } - "removeAllListeners" => { - let event = args.first().copied().map(str_arg).unwrap_or(0); - crate::js_net_server_remove_all_listeners(handle, event); - nanbox_handle(handle) - } - "listenerCount" if !args.is_empty() => { - crate::js_net_server_listener_count(handle, str_arg(args[0])) - } - "eventNames" => json_str_to_value(crate::js_net_server_event_names(handle)), - "listeners" if !args.is_empty() => { - let arr = crate::js_net_server_listeners(handle, str_arg(args[0])); - nanbox_ptr(arr as *mut ArrayHeader) - } - "rawListeners" if !args.is_empty() => { - let arr = crate::js_net_server_raw_listeners(handle, str_arg(args[0])); - nanbox_ptr(arr as *mut ArrayHeader) - } - "ref" | "unref" => nanbox_handle(handle), - "getConnections" => { - if let Some(callback) = args.first().copied().map(unbox_to_i64) { - if callback >= 0x1000 { - let cb = JsClosure::from_raw(callback as *const RawClosureHeader); - let _ = cb.call2( - perry_ffi::JsThis::UNDEFINED, - null(), - crate::js_net_server_get_connections(handle), - ); - } - } - undefined() - } - _ => undefined(), - }; - Some(result) -} - -unsafe fn block_list_method(handle: i64, method: &str, args: &[f64]) -> Option { - if method != "rules" && block_list_method_name(method).is_none() - || crate::js_ext_net_is_block_list_handle(handle) == 0 - { - return None; - } - - let result = match method { - "addAddress" => { - let address = args.first().copied().map(str_arg).unwrap_or(0); - let family = args.get(1).copied().map(str_arg).unwrap_or(0); - crate::js_net_block_list_add_address(handle, address, family) - } - "addRange" => { - let start = args.first().copied().map(str_arg).unwrap_or(0); - let end = args.get(1).copied().map(str_arg).unwrap_or(0); - let family = args.get(2).copied().map(str_arg).unwrap_or(0); - crate::js_net_block_list_add_range(handle, start, end, family) - } - "addSubnet" => { - let address = args.first().copied().map(str_arg).unwrap_or(0); - // A missing `prefix` must reach `js_net_validate_block_list_prefix` - // as a non-numeric value so it throws `ERR_INVALID_ARG_TYPE`, matching - // Node (the parameter is required). Defaulting to a real number here - // would silently accept the call as a `/0` subnet. - let prefix = args - .get(1) - .copied() - .map(unbox_to_f64) - .unwrap_or_else(undefined); - let family = args.get(2).copied().map(str_arg).unwrap_or(0); - crate::js_net_block_list_add_subnet(handle, address, prefix, family) - } - "check" => { - let address = args.first().copied().map(str_arg).unwrap_or(0); - let family = args.get(1).copied().map(str_arg).unwrap_or(0); - crate::js_net_block_list_check(handle, address, family) - } - "rules" | "toJSON" => crate::js_net_block_list_to_json(handle), - "fromJSON" => { - let value = args.first().copied().unwrap_or_else(undefined); - crate::js_net_block_list_from_json(handle, value) - } - _ => undefined(), - }; - Some(result) -} - -#[no_mangle] -pub unsafe extern "C" fn js_ext_net_handle_method_dispatch( - handle: i64, - method_name_ptr: *const u8, - method_name_len: usize, - args_ptr: *const f64, - args_len: usize, - out: *mut f64, -) -> i32 { - let method = property_name(method_name_ptr, method_name_len); - let args = if args_ptr.is_null() || args_len == 0 { - &[] - } else { - std::slice::from_raw_parts(args_ptr, args_len) - }; - let value = crate::bun_tcp::dispatch_method(handle, method, args) - .or_else(|| socket_method(handle, method, args)) - .or_else(|| server_method(handle, method, args)) - .or_else(|| block_list_method(handle, method, args)); - if let Some(value) = value { - if !out.is_null() { - *out = value; - } - 1 - } else { - 0 - } -} - -// Endpoint reads are resolved only after the existing property/method paths. -// Return its claim directly so the existing value/result joins stay unchanged. -#[cold] -#[inline(never)] -unsafe fn socket_endpoint_property(handle: i64, prop: &str, out: *mut f64) -> i32 { - enum Endpoint { - LocalAddress, - LocalPort, - LocalFamily, - RemoteAddress, - RemotePort, - RemoteFamily, - } - let endpoint = match prop { - "localAddress" => Endpoint::LocalAddress, - "localPort" => Endpoint::LocalPort, - "localFamily" => Endpoint::LocalFamily, - "remoteAddress" => Endpoint::RemoteAddress, - "remotePort" => Endpoint::RemotePort, - "remoteFamily" => Endpoint::RemoteFamily, - _ => return 0, - }; - if crate::js_ext_net_is_socket_handle(handle) == 0 { - return 0; - } - let value = match endpoint { - Endpoint::LocalAddress => crate::js_net_socket_get_local_address(handle), - Endpoint::LocalPort => crate::js_net_socket_get_local_port(handle), - Endpoint::LocalFamily => crate::js_net_socket_get_local_family(handle), - Endpoint::RemoteAddress => crate::js_net_socket_get_remote_address(handle), - Endpoint::RemotePort => crate::js_net_socket_get_remote_port(handle), - Endpoint::RemoteFamily => crate::js_net_socket_get_remote_family(handle), - }; - if !out.is_null() { - *out = value; - } - 1 -} - -#[no_mangle] -pub unsafe extern "C" fn js_ext_net_handle_property_dispatch( - handle: i64, - property_name_ptr: *const u8, - property_name_len: usize, - out: *mut f64, -) -> i32 { - let prop = property_name(property_name_ptr, property_name_len); - // These Node-only flags are unsupported by both Bun helpers. Resolve - // them first without their four irrelevant ownership-map probes. - let value = if prop == "destroyed" && crate::js_ext_net_is_socket_handle(handle) != 0 { - Some(crate::js_net_socket_get_destroyed(handle)) - } else if matches!( - prop, - "writable" | "readable" | "connecting" | "pending" | "writableEnded" | "readableEnded" - ) && crate::js_ext_net_is_socket_handle(handle) != 0 - { - // #10465 — the untyped (`(sock: any)`/plain-JS-driver) dispatch path - // had NO arm at all for these; every driver holds its socket through - // an untyped field (`this.stream`), so this — not the typed-receiver - // table in `net_events.rs` — is the path pg/ioredis/iovalkey/ - // @redis/client actually hit. - Some(match prop { - "writable" => crate::js_net_socket_get_writable(handle), - "readable" => crate::js_net_socket_get_readable(handle), - "connecting" => crate::js_net_socket_get_connecting(handle), - "pending" => crate::js_net_socket_get_pending(handle), - "writableEnded" => crate::js_net_socket_get_writable_ended(handle), - _ => crate::js_net_socket_get_readable_ended(handle), - }) - } else if matches!( - prop, - "writableLength" - | "writableHighWaterMark" - | "writableNeedDrain" - | "writableCorked" - | "bufferSize" - ) && crate::js_ext_net_is_socket_handle(handle) != 0 - { - // #11111 — the write-queue surface a drain-aware writer reads next to - // `write()`'s return value. - Some(match prop { - "writableCorked" => crate::cork::js_net_socket_get_writable_corked(handle), - "writableLength" => crate::js_net_socket_get_writable_length(handle), - "writableHighWaterMark" => crate::js_net_socket_get_writable_high_water_mark(handle), - "writableNeedDrain" => crate::js_net_socket_get_writable_need_drain(handle), - _ => crate::js_net_socket_get_buffer_size(handle), - }) - } else if let Some(name) = crate::bun_tcp::method_name(handle, prop) { - Some(bind_handle_method(handle, name)) - } else if let Some(value) = crate::bun_tcp::property(handle, prop) { - Some(value) - } else if matches!(prop, "address" | "family" | "port" | "flowlabel") - && crate::js_ext_net_is_socket_address_handle(handle) != 0 - { - Some(match prop { - "address" => f64::from_bits( - JsValue::from_string_ptr(crate::js_net_socket_address_get_address(handle)).bits(), - ), - "family" => f64::from_bits( - JsValue::from_string_ptr(crate::js_net_socket_address_get_family(handle)).bits(), - ), - "port" => crate::js_net_socket_address_get_port(handle), - _ => crate::js_net_socket_address_get_flowlabel(handle), - }) - } else if prop == "_handle" && crate::js_ext_net_is_socket_handle(handle) != 0 { - Some(socket_private_handle(handle)) - } else if prop == "parser" - && crate::js_ext_net_is_socket_handle(handle) != 0 - && crate::statics::http_agent_phases() - .lock() - .unwrap() - .contains_key(&handle) - { - Some(null()) - } else if prop == "readyState" && crate::js_ext_net_is_socket_handle(handle) != 0 { - Some(f64::from_bits( - JsValue::from_string_ptr(crate::js_net_socket_get_ready_state(handle)).bits(), - )) - } else if prop == "_writableState" && crate::js_ext_net_is_socket_handle(handle) != 0 { - Some(json_str_to_value(crate::js_net_socket_get_writable_state( - handle, - ))) - } else if prop == "_readableState" && crate::js_ext_net_is_socket_handle(handle) != 0 { - Some(json_str_to_value(crate::js_net_socket_get_readable_state( - handle, - ))) - } else if matches!( - prop, - "encrypted" | "authorized" | "servername" | "bytesWritten" - ) && crate::js_ext_net_is_socket_handle(handle) != 0 - { - Some(match prop { - "encrypted" => crate::js_ext_net_socket_tls_encrypted(handle), - "authorized" => crate::js_ext_net_socket_tls_authorized(handle), - "servername" => crate::js_ext_net_socket_tls_servername(handle), - _ => crate::js_net_socket_get_bytes_written(handle), - }) - } else if let Some(name) = socket_method_name(prop) { - if crate::js_ext_net_is_socket_handle(handle) != 0 { - Some(bind_handle_method(handle, name)) - } else { - None - } - } else if let Some(name) = server_method_name(prop) { - if crate::js_ext_net_is_server_handle(handle) != 0 { - Some(bind_handle_method(handle, name)) - } else { - None - } - } else if let Some(name) = block_list_method_name(prop) { - if crate::js_ext_net_is_block_list_handle(handle) != 0 { - Some(bind_handle_method(handle, name)) - } else { - None - } - } else if prop == "rules" && crate::js_ext_net_is_block_list_handle(handle) != 0 { - Some(nanbox_ptr(crate::js_net_block_list_rules(handle))) - } else if prop == "listening" && crate::js_ext_net_is_server_handle(handle) != 0 { - Some(crate::js_net_server_get_listening(handle)) - } else if matches!(prop, "maxConnections" | "dropMaxConnection") - && crate::js_ext_net_is_server_handle(handle) != 0 - { - Some(match prop { - "maxConnections" => crate::js_net_server_get_max_connections(handle), - _ => crate::js_net_server_get_drop_max_connection(handle), - }) - } else if (9..=13).contains(&prop.len()) - && matches!( - (prop.as_bytes()[0], prop.as_bytes()[1]), - (b'l', b'o') | (b'r', b'e') - ) - { - // Unrelated capsule/handle queries must not touch the endpoint helper. - return socket_endpoint_property(handle, prop, out); - } else { - None - }; - - if let Some(value) = value { - if !out.is_null() { - *out = value; - } - 1 - } else { - 0 - } -} - -#[no_mangle] -pub unsafe extern "C" fn js_ext_net_handle_property_set_dispatch( - handle: i64, - property_name_ptr: *const u8, - property_name_len: usize, - value: f64, -) -> i32 { - if crate::bun_tcp::set_property( - handle, - property_name(property_name_ptr, property_name_len), - value, - ) { - return 1; - } - if crate::js_ext_net_is_server_handle(handle) == 0 { - return 0; - } - - match property_name(property_name_ptr, property_name_len) { - "maxConnections" => { - crate::js_net_server_set_max_connections(handle, value); - 1 - } - "dropMaxConnection" => { - crate::js_net_server_set_drop_max_connection(handle, value); - 1 - } - _ => 0, - } -} diff --git a/crates/perry-ext-net/src/dispatch_custody.rs b/crates/perry-ext-net/src/dispatch_custody.rs deleted file mode 100644 index af502727af..0000000000 --- a/crates/perry-ext-net/src/dispatch_custody.rs +++ /dev/null @@ -1,204 +0,0 @@ -//! #8259 — GC custody for the pump's in-flight listener dispatch. -//! -//! Every arm of `js_ext_net_drain_pending` used to snapshot its listeners -//! into a bare `Vec` (`listeners_for`) and then, for some arms, allocate -//! a payload (`alloc_buffer`/`alloc_string`/`build_error_object`) before -//! calling each closure. Both steps can collect: the allocation directly, and -//! every `call*` by running arbitrary user JS (the #8259 witness's `once` -//! handler churns 30k objects). Under the evacuating arms the collection -//! MOVES the closures; `scan_net_roots` rewrites the canonical -//! `statics::listeners()` slots, but a bare local snapshot is invisible to -//! it, so the *next* callback in the loop — and the already-built payload — -//! were dereferenced at their OLD addresses. Deterministic SIGSEGV under -//! `force_verify` (`test_gap_gc_net_once_flags_rekey`, exit 139). -//! -//! Two arms were worse: `ServerListening` / `ServerClose` REMOVE their -//! callbacks from the table before firing (one-shot semantics), so during -//! dispatch nothing rooted them at all — a full sweep could free, not just -//! move, them. -//! -//! Same custody pattern as perry-ext-http's `H2_DRAINED_EVENTS` / -//! `H2_ACTIVE_CALLBACKS` (#8216): park the snapshot (and at most one -//! NaN-boxed payload per frame) in scanned thread-locals, re-read each slot -//! immediately before use so the copying GC's rewrite is observed, and pop -//! on drop. Frames are strictly nested (a re-entrant pump inside `call1` -//! builds and drops its own frame before the outer loop resumes), so plain -//! stacks suffice. - -use perry_ffi::GcRootVisitor; -use std::cell::RefCell; - -thread_local! { - /// Callback addresses of every dispatch frame currently on the stack. - /// Raw closure addresses (same representation as `statics::listeners()`), - /// visited and REWRITTEN by [`scan`]. - static CBS: RefCell> = const { RefCell::new(Vec::new()) }; - /// At most one NaN-boxed payload per frame that carries one (Data's - /// buffer/string, the error objects, ServerDrop's info object). - /// Immediate payloads (booleans, handle-tagged ids) are not parked — - /// they hold no heap address. - static PAYLOADS: RefCell> = const { RefCell::new(Vec::new()) }; -} - -/// Visit every parked slot. Wired into `scan_net_roots` so the registered -/// scanner keeps custody of in-flight dispatches; the census call-graph walk -/// (scripts/gc_runtime_root_holders.py) certifies the two holders through -/// this path. -pub(crate) fn scan(visitor: &mut GcRootVisitor<'_>) { - CBS.with(|cbs| { - for cb in cbs.borrow_mut().iter_mut() { - if *cb != 0 { - visitor.visit_i64_slot(cb); - } - } - }); - PAYLOADS.with(|payloads| { - for bits in payloads.borrow_mut().iter_mut() { - visitor.visit_nanbox_u64_slot(bits); - } - }); -} - -/// One parked snapshot of listener callbacks (+ optionally one payload). -/// Construct with [`DispatchFrame::park`] BEFORE any allocating payload -/// prep; read every callback via [`DispatchFrame::cb`] and the payload via -/// [`DispatchFrame::payload_bits`] immediately before each call. -pub(crate) struct DispatchFrame { - cb_base: usize, - cb_len: usize, - payload_base: Option, - payload_len: usize, -} - -impl DispatchFrame { - pub(crate) fn park(cbs: Vec) -> Self { - let cb_len = cbs.len(); - let cb_base = CBS.with(|s| { - let mut s = s.borrow_mut(); - let base = s.len(); - s.extend_from_slice(&cbs); - base - }); - DispatchFrame { - cb_base, - cb_len, - payload_base: None, - payload_len: 0, - } - } - - #[cfg(test)] - pub(crate) fn is_empty(&self) -> bool { - self.cb_len == 0 - } - - pub(crate) fn len(&self) -> usize { - self.cb_len - } - - /// Fresh read: the scanner may have rewritten the slot since parking. - pub(crate) fn cb(&self, i: usize) -> i64 { - debug_assert!(i < self.cb_len); - CBS.with(|s| s.borrow()[self.cb_base + i]) - } - - /// Park the frame's NaN-boxed payload. Call at most once, AFTER `park` - /// (so the callbacks were already in custody while the payload was - /// allocated) and BEFORE the first `call*`. - pub(crate) fn set_payload(&mut self, bits: u64) { - self.set_payloads(&[bits]); - } - - /// Park several NaN-boxed arguments for a single synchronous dispatch. - pub(crate) fn set_payloads(&mut self, bits: &[u64]) { - debug_assert!(self.payload_base.is_none()); - if bits.is_empty() { - return; - } - let base = PAYLOADS.with(|p| { - let mut p = p.borrow_mut(); - let base = p.len(); - p.extend_from_slice(bits); - base - }); - self.payload_base = Some(base); - self.payload_len = bits.len(); - } - - /// Fresh read of the parked payload (rewritten if its object moved). - pub(crate) fn payload_bits(&self) -> u64 { - self.payload_bits_at(0) - } - - /// Fresh read of one parked argument. - pub(crate) fn payload_bits_at(&self, index: usize) -> u64 { - debug_assert!(index < self.payload_len); - let base = self - .payload_base - .expect("payload_bits_at without set_payloads"); - PAYLOADS.with(|p| p.borrow()[base + index]) - } -} - -impl Drop for DispatchFrame { - fn drop(&mut self) { - CBS.with(|s| { - let mut s = s.borrow_mut(); - debug_assert_eq!(s.len(), self.cb_base + self.cb_len, "unnested frame drop"); - s.truncate(self.cb_base); - }); - if let Some(base) = self.payload_base { - PAYLOADS.with(|p| { - let mut p = p.borrow_mut(); - debug_assert_eq!(p.len(), base + self.payload_len, "unnested payload drop"); - p.truncate(base); - }); - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn frames_nest_and_pop() { - let outer = DispatchFrame::park(vec![11, 22]); - assert_eq!(outer.len(), 2); - assert_eq!(outer.cb(0), 11); - { - let mut inner = DispatchFrame::park(vec![33]); - inner.set_payload(0x7FFD_0000_0000_1234); - assert_eq!(inner.cb(0), 33); - assert_eq!(inner.payload_bits(), 0x7FFD_0000_0000_1234); - } - // Inner frame popped; outer slots intact. - assert_eq!(outer.cb(1), 22); - CBS.with(|s| assert_eq!(s.borrow().len(), 2)); - PAYLOADS.with(|p| assert!(p.borrow().is_empty())); - drop(outer); - CBS.with(|s| assert!(s.borrow().is_empty())); - } - - #[test] - fn parked_slots_are_the_read_path() { - // A rewrite of the thread-local slot (what the GC scanner does when - // an object moves) must be visible through the frame's accessors — - // that is the entire point of parking. - let mut frame = DispatchFrame::park(vec![0x1000]); - frame.set_payloads(&[0x7FFF_0000_0000_2000, 0x7FFF_0000_0000_3000]); - CBS.with(|s| s.borrow_mut()[0] = 0x9000); - PAYLOADS.with(|p| p.borrow_mut()[0] = 0x7FFF_0000_0000_A000); - assert_eq!(frame.cb(0), 0x9000); - assert_eq!(frame.payload_bits(), 0x7FFF_0000_0000_A000); - assert_eq!(frame.payload_bits_at(1), 0x7FFF_0000_0000_3000); - } - - #[test] - fn empty_frame_is_cheap_and_safe() { - let frame = DispatchFrame::park(Vec::new()); - assert!(frame.is_empty()); - drop(frame); - CBS.with(|s| assert!(s.borrow().is_empty())); - } -} diff --git a/crates/perry-ext-net/src/gc_roots.rs b/crates/perry-ext-net/src/gc_roots.rs deleted file mode 100644 index 005bf30b77..0000000000 --- a/crates/perry-ext-net/src/gc_roots.rs +++ /dev/null @@ -1,78 +0,0 @@ -//! GC-root registration and scanning for native net handles. - -use super::*; - -static NET_GC_REGISTERED: std::sync::Once = std::sync::Once::new(); - -extern "C" { - fn js_register_net_socket_handle_probe(f: unsafe extern "C" fn(i64) -> bool); -} - -unsafe extern "C" fn ext_net_socket_handle_probe(handle: i64) -> bool { - is_net_socket_handle(handle) -} - -/// Register the net GC root scanner exactly once. Safe to call from any -/// `js_net_*` entry point on the main thread. -pub(crate) fn ensure_gc_scanner_registered() { - NET_GC_REGISTERED.call_once(|| { - gc_register_mutable_root_scanner_named("perry-ext-net", scan_net_roots); - unsafe { - js_register_net_socket_handle_probe(ext_net_socket_handle_probe); - } - // #2154 — publish the raw-consumer vtable for perry-ext-http (runs on - // the first net FFI entry, before http could reference a socket). - raw_bridge::register(); - }); -} - -/// GC root scanner for net.Socket event listener closures. -/// -/// Without this, any GC cycle between `.on()` and the next dispatch would -/// sweep the closure; the next `closure.call*()` would dereference freed -/// memory. Same pattern as perry-stdlib's net mod. -pub(crate) fn scan_net_roots(visitor: &mut GcRootVisitor<'_>) { - if let Ok(mut listeners) = statics::listeners().lock() { - for per_socket in listeners.values_mut() { - for cb_vec in per_socket.values_mut() { - for cb in cb_vec.iter_mut() { - visitor.visit_i64_slot(cb); - } - } - } - } - // `once_flags()` keys membership by the closure's ADDRESS BITS. The - // canonical copy in `listeners()` above keeps the closure alive and is - // rewritten when the copying GC moves it — but a `HashSet` element - // cannot be rewritten in place, so without this rebuild the set still - // holds the OLD address after evacuation: the once-membership test in - // `lifecycle.rs` then misses, the listener is never auto-removed, and a - // "once" callback fires on every subsequent event. Drain, forward each - // element through the visitor, and reinsert under the new identity. - if let Ok(mut once) = statics::once_flags().lock() { - for per_handle in once.values_mut() { - for set in per_handle.values_mut() { - let old: Vec = set.drain().collect(); - for mut cb in old { - visitor.visit_i64_slot(&mut cb); - set.insert(cb); - } - } - } - } - if let Ok(mut completions) = crate::lifecycle::socket_completions().lock() { - for (_, callbacks) in completions.values_mut() { - for callback in callbacks { - visitor.visit_i64_slot(callback); - } - } - } - crate::bun_tcp::scan_roots(visitor); - // #8259 — the pump's in-flight dispatch frames (snapshotted callbacks + - // parked payloads), which the table walks above cannot see. - dispatch_custody::scan(visitor); - // #10444 — `pipe()`'s own closure-pointer bookkeeping (see the doc on - // `pipe::scan_roots` for why it needs its own visit despite the SAME - // pointers already being visited via `statics::listeners()` above). - crate::pipe::scan_roots(visitor); -} diff --git a/crates/perry-ext-net/src/handle_exports.rs b/crates/perry-ext-net/src/handle_exports.rs deleted file mode 100644 index ae2fe569a1..0000000000 --- a/crates/perry-ext-net/src/handle_exports.rs +++ /dev/null @@ -1,95 +0,0 @@ -use super::*; - -pub(super) fn listeners_for(id: i64, event: &str) -> Vec { - statics::listeners() - .lock() - .unwrap() - .get(&id) - .and_then(|m| m.get(event).cloned()) - .unwrap_or_default() -} - -#[no_mangle] -pub extern "C" fn js_net_has_pending() -> i32 { - server_state::has_active_handles() as i32 -} - -pub fn is_net_socket_handle(handle: i64) -> bool { - statics::sockets().lock().unwrap().contains_key(&handle) -} - -pub fn is_net_server_handle(handle: i64) -> bool { - statics::servers().lock().unwrap().contains_key(&handle) -} - -#[no_mangle] -pub extern "C" fn js_net_server_listening(handle: i64) -> i32 { - statics::servers() - .lock() - .ok() - .and_then(|servers| { - servers - .get(&handle) - .map(|server| i32::from(server.listening)) - }) - .unwrap_or(0) -} - -/// `server.on(event, cb)` — register a server-level listener for -/// `'connection'`, `'listening'`, `'close'`, or `'error'`. -/// -/// # Safety -/// -/// `event_ptr` must be null or a Perry-runtime `StringHeader`. `cb` -/// is a raw `*const ClosureHeader` cast to `i64`. -#[no_mangle] -pub unsafe extern "C" fn js_net_server_on(handle: i64, event_ptr: i64, cb: i64) { - ensure_gc_scanner_registered(); - let event = match string_from_header_i64(event_ptr) { - Some(e) => e, - None => return, - }; - let mut listeners = statics::listeners().lock().unwrap(); - let entry = listeners.entry(handle).or_default(); - entry.entry(event).or_default().push(cb); -} - -/// #10442 — returns the socket handle so the TYPED `net.Socket` codegen -/// table (`net_events.rs`'s `on`/`addListener` rows, which call this -/// runtime symbol as their `ret: NR_HANDLE_ID` carrier) can chain -/// `sock.on(...).on(...)` instead of reading back `undefined`. The -/// underlying `js_net_socket_on` stays void — it is also the untyped -/// dynamic-dispatch path's registration call in `dispatch.rs`, which -/// already supplies its own handle return separately. -#[no_mangle] -pub unsafe extern "C" fn js_ext_net_socket_on(handle: i64, event_ptr: i64, cb: i64) -> i64 { - js_net_socket_on(handle, event_ptr, cb); - handle -} - -#[no_mangle] -pub unsafe extern "C" fn js_ext_net_socket_once(handle: i64, event_ptr: i64, cb: i64) -> i64 { - js_net_socket_once(handle, event_ptr, cb) -} - -#[no_mangle] -pub unsafe extern "C" fn js_ext_net_socket_remove_listener( - handle: i64, - event_ptr: i64, - cb: i64, -) -> i64 { - js_net_socket_remove_listener(handle, event_ptr, cb) -} - -#[no_mangle] -pub unsafe extern "C" fn js_ext_net_socket_remove_all_listeners( - handle: i64, - event_ptr: i64, -) -> i64 { - js_net_socket_remove_all_listeners(handle, event_ptr) -} - -#[no_mangle] -pub extern "C" fn js_ext_net_is_server_handle(handle: i64) -> i32 { - i32::from(is_net_server_handle(handle)) -} diff --git a/crates/perry-ext-net/src/handle_ids.rs b/crates/perry-ext-net/src/handle_ids.rs deleted file mode 100644 index 850467a214..0000000000 --- a/crates/perry-ext-net/src/handle_ids.rs +++ /dev/null @@ -1,87 +0,0 @@ -//! Socket / server / block-list handle-id allocation. -//! -//! Split out of `lib.rs` to keep that file under the file-size gate. These -//! helpers wrap perry-ffi's SHARED handle-id allocator and add the -//! exhaustion-degradation policy (#6441). - -/// Socket / server / block-list ids come from perry-ffi's SHARED handle-id -/// allocator, not a private counter. -/// -/// Every ext staticlib that mints ids privately from 1 aliases the others -/// inside the shared `[1, 0x40000)` band, and the composite handle-method -/// dispatch (`class_handles.rs::composite_handle_method_dispatch`) asks each -/// registered extension "is this handle yours?" — so the FIRST extension whose -/// private counter reached that number claims the call. Next.js's HTTP server -/// (perry-ext-http handle 1, via `register_handle`) therefore claimed -/// `socket.on('data', …)` on this crate's socket 1: the listener landed on the -/// HTTP server, the reader delivered the MySQL greeting to an empty listener -/// list, and mysql2's handshake hung to ETIMEDOUT. -/// -/// `reserve_handle_id` consumes an id from the same counter `register_handle` -/// uses, so ids stay globally unique across every ext library while this -/// crate keeps its own object map. -/// -/// Returns [`perry_ffi::INVALID_HANDLE`] (`0`) when that shared band is -/// exhausted rather than aborting the process (#6441). Reserved ids currently -/// leak — a `net.Socket`'s id lives as long as the JS object, not the TCP -/// connection, so freeing on `'close'` would let a stale reference alias a -/// recycled socket (the aliasing class #6407 fixes) — so a long-running server -/// will eventually drain the band. Callers on a synchronous FFI path must route -/// the `0` sentinel through [`next_id_or_throw`]; background callers must guard -/// it explicitly (never register an object under `0`). -pub(crate) fn next_id() -> i64 { - perry_ffi::reserve_handle_id_in_domain(net_registry_domain()) -} - -/// One authoritative domain for net's private payload maps, sharing only the -/// numeric allocation pool with FFI's ordinary payload registry. -pub(crate) fn net_registry_domain() -> perry_ffi::NativeRegistryDomain { - static DOMAIN: std::sync::LazyLock = - std::sync::LazyLock::new(|| { - perry_ffi::NativeRegistryDomain::new().expect("net native registry domains exhausted") - }); - *DOMAIN -} - -/// [`next_id`] for the synchronous FFI entry points (`new net.Socket()`, -/// `net.createServer`, `net.connect`, `new net.BlockList()`, -/// `new net.SocketAddress()`): on band exhaustion, throw a recoverable, -/// JS-visible `EMFILE`-coded error instead of returning the `0` sentinel that -/// would otherwise be registered as a phantom socket. Node surfaces file-handle -/// exhaustion the same way, so `try/catch` and `'error'` handlers can react. -/// -/// Diverges on exhaustion (via `perry_ffi::throw_with_code`), exactly like this -/// crate's Node argument validators — so it must be called BEFORE acquiring any -/// `statics::*` lock, which every alloc site already does (`let id = ...` first). -pub(crate) fn next_id_or_throw() -> i64 { - let id = next_id(); - if id == perry_ffi::INVALID_HANDLE { - perry_ffi::throw_with_code( - "too many open handles: net handle-id space exhausted", - "EMFILE", - perry_ffi::ErrorKind::Error, - ); - } - id -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn reserved_net_id_names_the_private_domain() { - let id = next_id(); - assert_ne!(id, perry_ffi::INVALID_HANDLE); - let identity = perry_ffi::handle_registration(id).unwrap(); - assert_eq!(identity.domain(), net_registry_domain()); - assert_ne!(identity.domain(), perry_ffi::handle_registry_domain()); - let lease = - perry_ffi::acquire_handle_registration(identity, perry_ffi::NativeLeaseKind::Operation) - .unwrap(); - perry_ffi::free_handle_id(id); - perry_ffi::drain_quarantined_handles(); - assert!(perry_ffi::handle_registration(id).is_none()); - drop(lease); - } -} diff --git a/crates/perry-ext-net/src/ipc.rs b/crates/perry-ext-net/src/ipc.rs deleted file mode 100644 index fc6ed0eb2d..0000000000 --- a/crates/perry-ext-net/src/ipc.rs +++ /dev/null @@ -1,165 +0,0 @@ -//! Local IPC transport for `node:net` path overloads. -//! -//! Node maps `server.listen(path)` and `net.connect({ path })` to named pipes -//! on Windows and Unix-domain sockets on Unix. Both run on the agent's -//! turnloop loop (`turnloop_io::listen_pipe` / `connect_pipe`), so data, end, -//! error, close, and server connection events share one implementation with -//! TCP. - -use crate::{ - dispatch, ensure_gc_scanner_registered, mark_closed, next_id_or_throw, push_event, - server_state, statics, PendingNetEvent, SocketState, TlsSocketMetadata, -}; - -fn allocate_socket() -> i64 { - ensure_gc_scanner_registered(); - dispatch::ensure_runtime_dispatch_registered(); - let id = next_id_or_throw(); - statics::sockets().lock().unwrap().insert( - id, - SocketState { - tcp_async_id: 0, - owner_agent: perry_ffi::agent_post::current_agent(), - connect_async_id: 0, - shutdown_async_id: 0, - awaiting_connect: false, - unconnected_write_failed: false, - is_open: false, - raw_fd: None, - refed: true, - local_addr: None, - remote_addr: None, - raw: None, - destroyed: false, - connecting: true, - has_opened: false, - writable_ended: false, - readable_ended: false, - bytes_read: 0, - bytes_written: 0, - bytes_queued: 0, - cork: crate::cork::CorkBuffer::default(), - need_drain: false, - timeout: None, - type_of_service: 0, - server_id: None, - server_connection_active: false, - tls: TlsSocketMetadata::default(), - turnloop: false, - }, - ); - statics::listeners() - .lock() - .unwrap() - .insert(id, Default::default()); - id -} - -/// Read a JS string without coercing closures or option objects through a -/// StringHeader layout. -pub(crate) unsafe fn string_value(value: f64) -> Option { - crate::jsvalue_to_owned_string(value) -} - -pub(crate) fn register_connect_cb(handle: i64, cb_f64: f64) { - if handle == 0 || !crate::is_nanboxed_pointer(cb_f64) { - return; - } - let cb_ptr = unsafe { crate::unbox_pointer(cb_f64) } as i64; - if cb_ptr == 0 { - return; - } - statics::listeners() - .lock() - .unwrap() - .entry(handle) - .or_default() - .entry("connect".to_string()) - .or_default() - .push(cb_ptr); -} - -pub(crate) fn spawn_socket(path: String) -> i64 { - let id = allocate_socket(); - spawn_connect(id, path); - id -} - -pub(crate) fn connect_existing(handle: i64, path: String) { - { - let mut sockets = statics::sockets().lock().unwrap(); - match sockets.get_mut(&handle) { - Some(socket) if socket.awaiting_connect => { - socket.awaiting_connect = false; - // #10465 — `socket.connect(path)` on a `new net.Socket()` - // starts connecting synchronously, same as the TCP path. - socket.connecting = true; - if socket.unconnected_write_failed { - return; - } - } - _ => { - push_event(PendingNetEvent::Error( - handle, - "socket already connected (or unknown handle)".to_string(), - )); - return; - } - } - } - spawn_connect(handle, path); -} - -fn spawn_connect(id: i64, path: String) { - let local_server = server_state::begin_local_path_connect(&path); - // A local socket connects on the loop. It can never be TLS-upgraded - // (`upgradeToTLS` reports "unsupported for IPC sockets"). - set_turnloop(id, true); - let target = path.clone(); - let submitted = crate::turnloop_io::on_loop(move || { - crate::turnloop_io::note_local_connect(id, local_server); - if let Err(error) = crate::turnloop_io::connect_pipe(id, &path) { - refuse_connect(id, &error.message(), &path, local_server); - } - }); - if !submitted { - refuse_connect( - id, - &format!("connect {}", crate::turnloop_io::NO_LOOP_CODE), - &target, - local_server, - ); - } -} - -/// libuv's shape (`connect ENOENT /tmp/x.sock`), which is what -/// `build_error_object` parses into code/errno/syscall. -fn refuse_connect(id: i64, message: &str, path: &str, local_server: Option<(i64, bool)>) { - set_turnloop(id, false); - server_state::cancel_local_connect(local_server); - push_event(PendingNetEvent::Error(id, format!("{message} {path}"))); - push_event(PendingNetEvent::Close(id)); - mark_closed(id); -} - -fn set_turnloop(id: i64, on_loop: bool) { - if let Ok(mut sockets) = statics::sockets().lock() { - if let Some(socket) = sockets.get_mut(&id) { - socket.turnloop = on_loop; - } - } -} - -#[cfg(test)] -mod tests { - #[test] - fn nanboxed_string_is_recognized_as_an_ipc_path() { - let path = std::env::temp_dir() - .join(format!("perry-ext-net-{}-1.sock", std::process::id())) - .to_string_lossy() - .into_owned(); - let header = perry_ffi::alloc_string(&path).as_raw(); - let value = f64::from_bits(perry_ffi::nanbox_string_bits(header)); - assert_eq!(unsafe { super::string_value(value) }, Some(path)); - } -} diff --git a/crates/perry-ext-net/src/lib.rs b/crates/perry-ext-net/src/lib.rs index e49364f76a..a4381ee5d3 100644 --- a/crates/perry-ext-net/src/lib.rs +++ b/crates/perry-ext-net/src/lib.rs @@ -1,689 +1,41 @@ -//! Native bindings for Node `net.Socket` — TCP plus optional TLS upgrade. -//! -//! Ported from `crates/perry-stdlib/src/net/mod.rs` to perry-ffi v0.5.x's -//! stable surface as part of #466 Phase 5. Every socket and listener lives on -//! the agent's turnloop loop (`turnloop_io`): one multishot read per socket, -//! submissions made where the FFI call happens, and completions turned into -//! `PendingNetEvent`s by this crate's sink. Read data is queued as a zero-copy -//! `Bytes` view into `NET_PENDING_EVENTS` and converted to `Buffer` on the JS -//! thread inside `js_net_process_pending` — the arena-safety rule (JSValue -//! construction MUST run on the agent's own thread, never in the I/O path). -//! -//! TLS runs as a sans-I/O rustls session *above* the turnloop handle -//! (`turnloop_tls_io`), for both `tls.connect` and `socket.upgradeToTLS`. -//! -//! This crate has no tokio. The tokio socket task that used to back a thread -//! with no loop of its own is replaced by posting to the thread that owns the -//! agent's loop (`turnloop_io::on_loop`); see that module's header. -//! -//! # Differences from the perry-stdlib version -//! -//! - Uses perry-ffi closures, buffers, and mutable GC root scanning; the latter -//! rewrites listener pointers after a copying minor collection. -//! -//! TLS is unconditionally compiled in (no `#[cfg(feature = "tls")]` gates -//! like perry-stdlib has) — keeping the wrapper crate simple, the deps are -//! small. perry-stdlib's umbrella `net = ["async-runtime"]` + separate -//! `tls = ["net", ...]` feature split is preserved on the perry-stdlib side -//! for backwards compat; the well-known flip routes here. - -use bytes::Bytes; -use perry_ffi::{ - alloc_buffer, alloc_string, gc_register_mutable_root_scanner_named, GcRootVisitor, JsClosure, - JsPromise, JsValue, RawClosureHeader, StringHeader, TransientRootScope, -}; -use std::collections::HashMap; -use std::net::SocketAddr; -use std::sync::{Arc, Mutex}; - -// #1852 — topical sub-modules split out to keep this file under the -// 2000-line size gate. `tls` holds the rustls config + handshake; `ip` -// holds the `net.isIP*` + auto-select-family helpers. -mod ip; -// Process-wide freelist of read buffers, so the read sink recycles pooled -// 16 KiB capacity instead of allocating a fresh `BytesMut` per read. See -// `buffer_pool.rs` for the rationale. -mod buffer_pool; +//! Ordinary net.Socket and net.Server owners with link-routed transports. +//! Rust payloads own resources; callbacks and protocol owners are traced JS edges. +use perry_ffi::{alloc_buffer, alloc_string, JsValue, StringHeader, TransientRootScope}; mod bun_tcp; -// #10429: runtime callback for `net` exports used as values. +mod ip; mod native_dispatch; mod tls; pub use tls::{js_ext_tls_connect, js_tls_connect}; -// #2131 — lifecycle / EventEmitter surface for `net.Socket` + `net.Server` -// (once / off / removeAllListeners / listenerCount / eventNames / -// resetAndDestroy, plus `socket.address()`). Re-exports keep the -// `pub unsafe extern "C" fn js_net_*` symbols at the crate root so the -// ext_registry well-known flip + native_table entries link the same as -// the rest of the FFI surface. -mod lifecycle; -pub use lifecycle::*; mod classes; pub use classes::*; -mod handle_ids; -pub(crate) use handle_ids::{next_id, next_id_or_throw}; -mod dispatch; -mod dispatch_custody; -mod gc_roots; -mod ipc; -// #10444 — `net.Socket.prototype.pipe()`/`.unpipe()` (split out; see the -// module doc for why it doesn't reuse node:stream's own pipe machinery). -mod pipe; -pub(crate) use gc_roots::ensure_gc_scanner_registered; -mod socket_emit; -pub use socket_emit::{ - js_ext_net_register_http_agent_socket_event_hook, js_ext_net_set_http_agent_phase, - js_ext_net_socket_emit, js_ext_net_socket_emit_abort_error, -}; -// #2154 — raw-consumer bridge so perry-ext-http can drive an HTTP exchange -// over a socket produced by `agent.createConnection` (split out for the gate). -mod provider_lifecycle; -mod raw_bridge; -use provider_lifecycle::{ - event_provider_id, init_provider, init_provider_with_trigger, prepare_event_provider, - ProviderScope, -}; -use raw_bridge::RawReadState; -// #2013 — chainable option-setter no-ops + Node arg-validation bridge to -// perry-runtime (split out to keep lib.rs under the 2000-line gate). The -// `#[no_mangle]` setter/setTimeout symbols re-export at the crate root; the -// validator `extern` declarations are imported for the listen/connect sites. -mod adopt; -pub use adopt::{ - adopt_turnloop_upgrade, adopt_upgraded_tcp_stream, ensure_adopted_socket_dispatch, -}; - -/// This crate's slot in the runtime's turnloop completion-sink registry. -/// -/// Published so perry-ext-http can `turnloop_net::transfer` an upgraded -/// connection here by name rather than by a duplicated literal. -pub const TURNLOOP_SUBSYSTEM: u8 = turnloop_io::SUBSYSTEM; -mod cork; -mod option_setters; -pub use option_setters::{ - js_net_server_noop_self, js_net_socket_get_type_of_service, js_net_socket_noop_self, - js_net_socket_ref, js_net_socket_set_encoding, js_net_socket_set_no_delay, - js_net_socket_set_timeout, js_net_socket_set_type_of_service, js_net_socket_unref, -}; -use option_setters::{js_net_validate_connect_port, js_net_validate_listen_port}; -mod socket_facade; -pub(crate) use socket_facade::TlsSocketMetadata; -pub use socket_facade::{ - js_ext_net_has_active_handles, js_ext_net_is_socket_handle, js_ext_net_set_tls_metadata, - js_ext_net_socket_has_ref, js_ext_net_socket_peer_certificate_json, js_ext_net_socket_set_ref, - js_ext_net_socket_tls_authorized, js_ext_net_socket_tls_encrypted, - js_ext_net_socket_tls_servername, js_ext_net_socket_tls_session, - js_ext_net_socket_tls_session_reused, -}; - -mod server_state; +mod jsvalue; +pub mod native_transport; +mod payload_closed; +mod payload_events; +mod payload_io; +mod payload_pipe; +mod payload_prototype; +mod payload_provider; +mod payload_server; +mod payload_socket; +#[cfg(test)] +mod payload_tests; +mod payload_tls; +mod payload_transport; #[cfg(test)] mod test_async_shims; -pub use server_state::*; -// NaN-box value-conversion helpers (string/buffer/number/bool extraction + -// the `Error`-shaped object builder) split out for the file-size gate. The -// `crate::` re-export keeps every existing call site — here and in the -// `tls` / `classes` / `ip` / `lifecycle` / `option_setters` siblings — -// unchanged. -mod jsvalue; +use crate::tls::TlsClientConfigData; pub(crate) use jsvalue::{ build_error_object, get_object_bool_field, get_object_number_field, get_object_string_field, get_object_value_field, is_nanboxed_pointer, jsvalue_to_owned_string, jsvalue_to_socket_bytes, string_from_header_i64, unbox_pointer, }; - -use crate::tls::TlsClientConfigData; - -/// `node:net` on turnloop handles — the only transport (`turnloop_io.rs`). -mod turnloop_io; +mod socket_facade; pub mod turnloop_tls; -pub mod turnloop_tls_io; - -// ─── Handle storage ────────────────────────────────────────────────────────── -// -// We keep our own integer-keyed handle map here rather than going through -// perry-ffi's generic registry, because every socket needs *two* parallel -// data structures (state + listeners) keyed by the same id. Splitting them -// across two registry types would force two lookups per FFI entry; bundling -// them into one `SocketHandle` value would make the GC scanner walk noisier. -// The pattern matches perry-stdlib's existing copy exactly. - -pub(crate) mod statics { - use super::*; - use std::sync::OnceLock; - - pub fn sockets() -> &'static Mutex> { - static S: OnceLock>> = OnceLock::new(); - S.get_or_init(|| Mutex::new(HashMap::new())) - } - - pub fn listeners() -> &'static Mutex>>> { - static L: OnceLock>>>> = OnceLock::new(); - L.get_or_init(|| Mutex::new(HashMap::new())) - } - - /// Issue #2131 — closure pointers that were registered via - /// `socket.once(event, cb)` / `server.once(event, cb)`. Keyed by - /// handle id (socket OR server — they share the listener namespace) - /// then event name. After the pump fires an event, any callback in - /// this set is removed from both the regular listener vector AND - /// this set, giving Node's "fire once and auto-remove" semantics. - /// Kept as a side table so the flat `Vec` listener storage - /// (and the GC scanner that walks it) stays unchanged. - pub fn once_flags( - ) -> &'static Mutex>>> { - static O: OnceLock>>>> = - OnceLock::new(); - O.get_or_init(|| Mutex::new(HashMap::new())) - } - - /// The CALLING AGENT's queue of events waiting for its pump. - /// - /// #11340: this used to be one process-wide queue. Every agent's pump - /// drains through `js_ext_net_drain_pending`, and every push wakes the - /// primary thread, so a socket opened on a `worker_threads` worker had its - /// completions raced for by the primary: whichever pump ran first took - /// the worker's `'connect'` / `'data'` / `'close'`. Taken by the primary, - /// the event was dispatched against a socket whose listeners live on the - /// worker's heap — dropped (the worker then waited forever), delivered to - /// the wrong thread (`TypeError`s after the worker exited), or a crash. - /// Events are produced on the owning agent's thread (its loop's sink, or - /// an FFI call made by its JS), so keying by the agent that pushes is - /// keying by the agent that owns the socket. - /// - /// One queue per agent, created on the agent's first use and never freed: - /// the handful of bytes an exited worker's empty queue keeps is the price - /// of handing out `&'static` without a teardown hook. - pub fn pending_events() -> &'static Mutex> { - type Queues = HashMap>>; - static QUEUES: OnceLock> = OnceLock::new(); - let agent = perry_ffi::agent_post::current_agent(); - let mut queues = QUEUES - .get_or_init(|| Mutex::new(HashMap::new())) - .lock() - .unwrap_or_else(|e| e.into_inner()); - queues - .entry(agent) - .or_insert_with(|| Box::leak(Box::new(Mutex::new(Vec::new())))) - } - - /// HTTP Agent-owned socket handles are transport facades over the HTTP - /// client's private connection pool. `true` means assigned to a request; - /// `false` means parked in `agent.freeSockets`. Keeping this tiny bit of - /// metadata here lets the ordinary net.Socket EventEmitter surface expose - /// Node's internal listener counts without leaking HTTP internals into the - /// generic handle dispatcher. - pub fn http_agent_phases() -> &'static Mutex> { - static H: OnceLock>> = OnceLock::new(); - H.get_or_init(|| Mutex::new(HashMap::new())) - } - - /// Server registry — `net.createServer(...)` returns a handle here. - /// Separate from the socket map: server handles host an accept-loop - /// shutdown channel and a bound port; sockets host a per-connection - /// command channel + per-connection listener map. Keyed by the same - /// monotonic id counter as sockets, so handles never collide. - pub fn servers() -> &'static Mutex> { - static S: OnceLock>> = OnceLock::new(); - S.get_or_init(|| Mutex::new(HashMap::new())) - } - - /// #4973 — per-socket read encoding set via `socket.setEncoding(enc)`. - /// When present, the main-thread pump delivers `'data'` as a decoded - /// string instead of a Buffer (Node readable-stream semantics). Side - /// table (not a SocketState field) so the many SocketState literal - /// constructions stay untouched. - pub fn encodings() -> &'static Mutex> { - static E: OnceLock>> = OnceLock::new(); - E.get_or_init(|| Mutex::new(HashMap::new())) - } - - /// Per-socket EventEmitter warning thresholds set through `events.*`. - pub fn max_listeners() -> &'static Mutex> { - static M: OnceLock>> = OnceLock::new(); - M.get_or_init(|| Mutex::new(HashMap::new())) - } -} - -/// Backing state for an `net.Server` handle (`net.createServer(...)`). -/// Mirrors `perry-ext-http::HttpServer` in shape but stripped to -/// the raw-TCP surface — no hyper, no request/response channels, just -/// the listen state + bound address. Per-server event -/// listeners (`'connection'`, `'listening'`, `'close'`, `'error'`) live -/// in the shared `statics::listeners()` map keyed by the server's id; -/// reusing the socket listener map keeps the GC scanner walk single- -/// pass instead of needing a second per-server scanner. -pub(crate) struct ServerState { - pub async_id: u64, - /// The agent that created this server (`perry_ffi::agent_post::current_agent`). - /// Only that agent's pump delivers its events, so only that agent's - /// keepalive may count it (#11340). - pub owner_agent: u64, - /// Set by `.listen()`. Keeps `has_active_handles` answering yes from the - /// `listen()` call until the server's registry entry is removed by its - /// `'close'`, including the window before the bind has been published. - /// It replaced the tokio accept loop's shutdown sender, whose `is_some()` - /// was the same question. - pub listen_armed: bool, - pub bound_port: u16, - pub bound_host: String, - /// Named-pipe / Unix-domain-socket path for an IPC listener. TCP servers - /// leave this unset and use `bound_host` + `bound_port`. - pub bound_path: Option, - pub listening: bool, - pub active_connections: usize, - pub pending_connections: usize, - pub pending_local_connect_events: usize, - pub max_connections: Option, - pub drop_max_connection: Option, -} - -pub(crate) struct SocketState { - pub(crate) tcp_async_id: u64, - /// The agent the socket belongs to — the one whose thread created it - /// (`perry_ffi::agent_post::current_agent`). Its events go to that agent's - /// queue, so only that agent's keepalive may count it (#11340). - pub(crate) owner_agent: u64, - pub(crate) connect_async_id: u64, - pub(crate) shutdown_async_id: u64, - /// True only between `js_net_socket_alloc` and the first - /// `js_net_socket_method_connect` (issue #422: `new net.Socket()` then - /// `sock.connect(port, host)`). A second `connect()` finds it false and - /// reports "already connected"; `has_active_handles` does not count a - /// socket that has never been asked to connect. - /// - /// Writes in this state fail: no connection attempt owns their bytes. - pub(crate) awaiting_connect: bool, - /// An unopened write failed; its callbacks, error and close are queued. - /// A synchronous connect() must not revive this failed writable stream. - pub(crate) unconnected_write_failed: bool, - pub(crate) is_open: bool, - /// Borrowed OS descriptor for Node's private `socket._handle.fd` shape. - /// turnloop owns its descriptors without exposing them, so this is unset - /// on every socket today; the field keeps the getter's shape. - pub(crate) raw_fd: Option, - /// Whether pending socket I/O keeps the process event loop alive. - pub(crate) refed: bool, - /// Issue #2131 — the kernel-assigned local address, populated after - /// `TcpStream::connect`/`accept`. Drives `socket.address()` so the - /// "undefined.address" cluster reports the actual bound port/family. - pub(crate) local_addr: Option, - pub(crate) remote_addr: Option, - /// #2154 — raw-consumer mode (see `raw_bridge`). When `Some`, the read - /// sink buffers inbound bytes here for `perry-ext-http` to drain instead - /// of firing JS `'data'` events. - raw: Option>>, - /// #2549 — Node `net.Socket` lifecycle/counter property surface. - /// `destroyed` flips true on `.destroy()`/peer close; drives - /// `socket.destroyed` and the `readyState` string. Byte counters track - /// `socket.bytesRead`/`socket.bytesWritten`. `timeout` holds the value set - /// via `setTimeout(ms)` (Node reports `undefined` until one is set). - pub(crate) destroyed: bool, - /// #10465 — true from `net.connect()`/`socket.connect()` until the - /// attempt resolves (open, error, or destroy). `false` both before any - /// connect attempt (`new net.Socket()`) and once resolved — matches - /// Node's `socket.connecting`, which Perry previously hardcoded to - /// `false` unconditionally. - pub(crate) connecting: bool, - /// #10465 — true from the moment the socket FIRST became open (set - /// alongside every `is_open = true` transition, and at construction for - /// a socket that starts already open, e.g. a server-accepted - /// connection), never reset back to `false`. `socket.pending`'s real - /// Node formula is "no live handle", which — once a socket has ever - /// connected — reduces to `destroyed`, NOT `!is_open`: `is_open` itself - /// flips false earlier than `destroyed` does (via - /// `server_state::mark_socket_closed`, called from `mark_closed` in the - /// completion sink as soon as teardown starts, well before the main - /// thread has processed the `'end'`/`'close'` events those pushed). A - /// `pending` getter keyed on `is_open` directly read `true` from inside - /// the `'end'` listener, where Node still reports `false`. `destroyed` - /// doesn't have that problem — see `socket_events.rs`'s `Close` arm — - /// so `has_opened` lets the getter pick the RIGHT signal for each phase: - /// `!has_opened` (never connected / still connecting) or `destroyed` - /// (has connected at least once). - pub(crate) has_opened: bool, - /// #10465 — true as soon as `.end()`/`.destroy()` is called, independent - /// of whether the FIN has actually flushed. Drives `socket.writable` and - /// `socket.writableEnded`. - pub(crate) writable_ended: bool, - /// #10465 — true once the readable side has seen EOF (peer FIN) and the - /// `'end'` event has fired. Drives `socket.readable` and - /// `socket.readableEnded`. - pub(crate) readable_ended: bool, - pub(crate) bytes_read: u64, - pub(crate) bytes_written: u64, - /// Bytes `write()` accepted that have not left yet — Node's - /// `writableLength`, which `write()`'s return value is judged against. - pub(crate) bytes_queued: u64, - pub(crate) cork: cork::CorkBuffer, - /// #11111 — a `write()` returned `false` (the queue reached - /// `writableHighWaterMark`) and `'drain'` has not fired since. Node's - /// `writableNeedDrain`; cleared when the queue empties and `'drain'` is - /// emitted. - pub(crate) need_drain: bool, - pub(crate) timeout: Option, - pub(crate) type_of_service: u8, - pub(crate) server_id: Option, - pub(crate) server_connection_active: bool, - pub(crate) tls: TlsSocketMetadata, - /// A connect, accept or adoption has been submitted to the agent's - /// turnloop loop for this socket, so its commands are submissions. False - /// only for a socket that never got that far — allocated and not yet - /// connected, or whose connect was refused before the driver took it. - pub(crate) turnloop: bool, -} - -impl SocketState { - /// Deliver one socket command to the loop that owns this socket. - /// - /// The single choke point: every `socket.write` / `.end()` / `.destroy()` - /// / `.setNoDelay()` call site goes through here. - /// - /// Callers hold the socket registry lock, so this updates `bytes_queued` - /// itself and returns the failure message instead of emitting it — the - /// caller drops the lock first and then reports through - /// [`turnloop_io::submission_failed`] or its own path. - pub(crate) fn command(&mut self, id: i64, cmd: SocketCommand) -> Result<(), String> { - if self.cork.depth != 0 - && !self.unconnected_write_failed - && !self.destroyed - && !self.writable_ended - { - if let SocketCommand::Write(bytes, completion) = cmd { - self.bytes_queued = self.bytes_queued.saturating_add(bytes.len() as u64); - self.cork.push(bytes, completion); - return Ok(()); - } - } - if matches!(cmd, SocketCommand::End(_)) { - self.cork.depth = 0; - self.flush_cork(id)?; - } else if matches!(cmd, SocketCommand::Destroy) { - // Completion records stay rooted until Close dispatches them in - // token order, including writes already submitted to the transport. - self.bytes_queued = self - .bytes_queued - .saturating_sub(self.cork.bytes.len() as u64); - self.cork.discard(); - } - self.command_transport(id, cmd) - } - - fn command_transport(&mut self, id: i64, cmd: SocketCommand) -> Result<(), String> { - let bytes = match &cmd { - SocketCommand::Write(bytes, _) => bytes.len() as u64, - _ => 0, - }; - if matches!(&cmd, SocketCommand::Write(..)) - && (self.unconnected_write_failed || (!self.turnloop && self.awaiting_connect)) - { - return Err("Socket is closed".to_string()); - } - if !self.turnloop { - // Configuration commands may precede connect(), but there is no - // transport to accept writes until a connection is submitted. - if self.awaiting_connect { - if matches!(cmd, SocketCommand::Destroy) { - // No driver owns a never-connected socket, so it cannot - // report Closed. Queue terminal cleanup ourselves. - push_event(PendingNetEvent::Close(id)); - } - return Ok(()); - } - return Err("Socket write failed".to_string()); - } - let submits = matches!( - cmd, - SocketCommand::Write(..) | SocketCommand::End(_) | SocketCommand::Destroy - ); - if submits && !turnloop_io::enabled() { - // A second thread acting for this agent: the socket lives on the - // owner's loop, so the command is carried there. The queue length - // is provisional until the owner reports the driver's own count. - turnloop_io::command_on_owner(id, cmd)?; - self.bytes_queued = self.bytes_queued.saturating_add(bytes); - return Ok(()); - } - let mut queued = None; - let result = turnloop_io::command(id, cmd, &mut queued); - if let Some(queued) = queued { - self.bytes_queued = queued.saturating_add(self.cork.bytes.len() as u64); - } - result - } -} - -/// Publish a connection turnloop accepted as a normal `net.Socket`. -/// -/// The turnloop twin of `ipc::register_accepted_transport`: same registries, -/// same `'connection'` event, no task and no command channel. -pub(crate) fn register_turnloop_socket( - server_id: i64, - socket_id: i64, - local_addr: Option, - remote_addr: Option, -) { - ensure_gc_scanner_registered(); - dispatch::ensure_runtime_dispatch_registered(); - statics::sockets().lock().unwrap().insert( - socket_id, - SocketState { - tcp_async_id: 0, - owner_agent: perry_ffi::agent_post::current_agent(), - connect_async_id: 0, - shutdown_async_id: 0, - awaiting_connect: false, - unconnected_write_failed: false, - is_open: true, - raw_fd: None, - refed: true, - local_addr, - remote_addr, - raw: None, - destroyed: false, - // #10465: accepted-socket values, as in `adopt.rs`. - connecting: false, - has_opened: true, - writable_ended: false, - readable_ended: false, - bytes_read: 0, - bytes_written: 0, - bytes_queued: 0, - cork: crate::cork::CorkBuffer::default(), - need_drain: false, - timeout: None, - type_of_service: 0, - server_id: Some(server_id), - server_connection_active: false, - tls: TlsSocketMetadata::default(), - turnloop: true, - }, - ); - statics::listeners() - .lock() - .unwrap() - .insert(socket_id, Default::default()); -} - -#[cfg(test)] -impl SocketState { - /// Minimal socket state that has not reached the loop, for the byte - /// accounting tests. Neither form has a driver; `awaiting_connect` - /// distinguishes an unopened socket from a refused connection. - pub(crate) fn for_test(awaiting_connect: bool) -> Self { - SocketState { - tcp_async_id: 0, - owner_agent: perry_ffi::agent_post::current_agent(), - connect_async_id: 0, - shutdown_async_id: 0, - awaiting_connect, - unconnected_write_failed: false, - is_open: true, - raw_fd: None, - refed: true, - local_addr: None, - remote_addr: None, - raw: None, - destroyed: false, - connecting: false, - has_opened: true, - writable_ended: false, - readable_ended: false, - bytes_read: 0, - bytes_written: 0, - bytes_queued: 0, - cork: crate::cork::CorkBuffer::default(), - need_drain: false, - timeout: None, - type_of_service: 0, - server_id: None, - server_connection_active: false, - tls: TlsSocketMetadata::default(), - turnloop: false, - } - } -} - -pub(crate) enum SocketCommand { - Write(Vec, u64), - End(u64), - Destroy, - /// The main thread finished dispatching the accepted socket's - /// `connection` callback. Commands queued by that callback precede this - /// marker, so a peer FIN may now auto-close without dropping its response. - ServerConnectionReady, -} - -#[derive(Debug)] -enum PendingNetEvent { - /// `.1` identifies a same-process server target and whether its admission - /// is expected to hit `dropMaxConnection`; external connects use `None`. - Connect(i64, Option<(i64, bool)>), - SecureConnect(i64), - /// One chunk of read data. Carried as a refcounted `Bytes` — a zero-copy - /// view sliced out of the read sink's reused buffer (`split_to`) — - /// so the path from the receive buffer to the main-thread drain handler - /// (which only borrows it as `&[u8]`) stays alloc-free per read. - Data(i64, Bytes), - /// Peer half-closed (FIN received); public readable-side `end` event. - End(i64), - /// A queued `socket.write` finished with a completion token and optional error. - WriteComplete(i64, u64, Option), - /// #11111 — the write queue emptied after a `write()` returned `false`. - Drain(i64), - /// `socket.end()` writable shutdown with a completion token and optional error. - ShutdownComplete(i64, u64, Option), - Close(i64), - Error(i64, String), - AbortError(i64), - /// Accept-loop produced a socket for the server's `connection` listeners. - /// `.0` = server id (for listener lookup) - /// `.1` = socket id (passed to listeners as the arg) - /// `.2` = loopback client callback has crossed a pump boundary - ServerConnection(i64, i64, bool), - /// Issue #1123 followup — `listener.bind()` resolved + accept - /// loop is running. Fires `'listening'` listeners + the - /// `.listen(port, cb)` callback. `.0` = server id. - ServerListening(i64), - /// Issue #1123 followup — accept-loop exited (after `.close()` - /// or bind failure). Fires `'close'` listeners on the server. - ServerClose(i64), - /// Issue #1123 followup — bind / accept I/O error on the server. - /// Fires `'error'` listeners with an Error-shaped object. - /// `.0` = server id, `.1` = error message. - ServerError(i64, String), - ServerDrop(i64, server_state::DropInfo), -} - -// ─── Helpers ───────────────────────────────────────────────────────────────── - -// Runtime entrypoint provided by perry-runtime (declared as extern so -// perry-ext-net doesn't need to depend on the perry-runtime rlib). The -// NaN-box value-conversion helpers and their runtime externs now live in -// `jsvalue.rs` (split out for the file-size gate); this one stays here -// because `js_net_create_server` below resolves the `connectionListener` -// callback pointer with it. -extern "C" { - fn js_net_callback_ptr(value: f64) -> i64; - fn js_async_hooks_provider_init(type_ptr: *const u8, type_len: usize) -> u64; - fn js_async_hooks_provider_init_with_trigger( - type_ptr: *const u8, - type_len: usize, - trigger_async_id: u64, - ) -> u64; - fn js_async_hooks_provider_enter(async_id: u64); - fn js_async_hooks_provider_leave(async_id: u64); - fn js_async_hooks_provider_destroy(async_id: u64); - fn perry_cluster_worker_listening( - addr_ptr: *const u8, - addr_len: u32, - port: i32, - address_type: i32, - ); -} - -fn push_event(ev: PendingNetEvent) { - if let PendingNetEvent::ServerConnection(server_id, socket_id, false) = &ev { - if !server_state::queue_server_connection(*server_id, *socket_id) { - return; - } - } - statics::pending_events().lock().unwrap().push(ev); - // Wake the main thread so its `js_wait_for_event` returns - // promptly instead of waiting on the heartbeat cap (#84 - // sub-millisecond responsiveness). perry-ffi shipped this - // surface in v0.5.567. - perry_ffi::notify_main_thread(); -} - -fn mark_closed(id: i64) { - if let Some(socket) = statics::sockets().lock().unwrap().get_mut(&id) { - socket.raw_fd = None; - } - server_state::mark_socket_closed(id); - // #10465 — `destroyed`/`is_open`/`connecting` are NOT flipped here on - // purpose. `mark_closed` runs in the completion sink immediately after - // queuing the `Close` (and, on this path, `End`) pending events — well - // before the main thread's `js_ext_net_drain_pending` has processed - // either. Flipping the fields here (an earlier version of this fix did) - // made them ALREADY read "destroyed" from inside the `'end'` listener, - // which fires first and, in real Node, still observes `destroyed: - // false`. `socket_events::js_ext_net_drain_pending`'s `Close` arm sets - // them instead, synchronously with firing `'close'` — the one point - // where Node's own timing and this runtime's actually agree. -} - -// ─── FFI: net.createConnection / net.connect ───────────────────────────────── - -/// `net.createConnection(...)` / `net.connect(...)` — returns a handle -/// immediately; connection happens in the background and emits -/// `'connect'` or `'error'`. Supports Node's TCP and IPC 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. -/// - IPC: `net.connect(path, cb?)` or `net.connect({ path }, cb?)`. -/// -/// 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 -/// -/// All three args must be NaN-boxed Perry-runtime values per the -/// codegen ABI — see `NA_F64` lowering in perry-codegen. -/// Distinct-symbol alias of `js_net_socket_connect` for generated code and the -/// value-form dispatcher (`native_dispatch.rs`). The -/// shared name has a bundled-stdlib twin, and in a build that links BOTH -/// archives the shared symbol can bind to the twin whose socket registry the -/// handle-dispatch never consults — connect then "succeeds" into one registry -/// while `.on('data')` registers in the other and the bytes are silently -/// dropped (mysql2 handshake ETIMEDOUT). Mirrors the -/// `js_ext_net_socket_write`/`_end`/`_destroy` splits (#5010/#5021). +pub use socket_facade::*; +mod payload_abi; +pub use payload_abi::*; +pub const TURNLOOP_SUBSYSTEM: u8 = 0; #[no_mangle] pub unsafe extern "C" fn js_ext_net_socket_connect( arg1_f64: f64, @@ -695,165 +47,51 @@ pub unsafe extern "C" fn js_ext_net_socket_connect( #[no_mangle] pub unsafe extern "C" fn js_net_socket_connect(arg1_f64: f64, arg2_f64: f64, arg3_f64: f64) -> i64 { - // Path overload: `net.connect(path[, cb])`. - if let Some(path) = ipc::string_value(arg1_f64) { - let handle = ipc::spawn_socket(path); - ipc::register_connect_cb(handle, arg2_f64); - return handle; - } - - if is_nanboxed_pointer(arg1_f64) { - // Options-object overload. A `path` selects local IPC before the TCP - // host/port fields are considered, matching Node's normalization. - if let Some(path) = get_object_string_field(arg1_f64, "path") { - let handle = ipc::spawn_socket(path); - ipc::register_connect_cb(handle, arg2_f64); - return handle; - } - 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) => { - // #2013: validate `options.port` before truncating to u16. - js_net_validate_connect_port(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. - ipc::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. Accept only - // actual JS string tags: arg2 may instead be the connectListener closure, - // whose POINTER_TAG storage must never be read as a StringHeader (#8909). - let (host, listener_f64) = match jsvalue_to_owned_string(arg2_f64) { - Some(h) => (h, arg3_f64), - // #4905: `connect(port)` / `connect(port, connectListener)` — - // Node defaults the host to localhost when arg2 isn't a string - // (it may carry the connectListener instead). Pre-fix this - // returned handle 0, so the socket never connected and no - // 'connect'/'error' event ever fired. - None => ("127.0.0.1".to_string(), arg2_f64), - }; - // #2013: positional `port` must be a valid integer in [0, 65536). - js_net_validate_connect_port(arg1_f64); - let port = arg1_f64 as u16; - let handle = spawn_socket_task(host, port, /* direct_tls: */ None); - ipc::register_connect_cb(handle, listener_f64); - handle + let scope = TransientRootScope::enter(); + let a = scope.root_nanbox(arg1_f64); + let b = scope.root_nanbox(arg2_f64); + let c = scope.root_nanbox(arg3_f64); + let owner = scope.root_nanbox(payload_socket::new_socket(payload_io::ROUTE, a.get())); + payload_socket::connect(owner.get(), a.get(), b.get(), c.get()); + payload_transport::raw_owner(owner.get()) } -// ─── FFI: new net.Socket() (alloc-only, deferred connect) ──────────────────── - -/// `new net.Socket()` — allocates an unconnected socket handle. The TCP -/// connection is deferred until `js_net_socket_method_connect` runs. Issue -/// #422 added this path; pre-#422 only the eager `createConnection` factory -/// existed. #[no_mangle] pub unsafe extern "C" fn js_net_socket_alloc() -> i64 { - ensure_gc_scanner_registered(); - dispatch::ensure_runtime_dispatch_registered(); - let id = next_id_or_throw(); - let tcp_async_id = init_provider(b"TCPWRAP"); - statics::sockets().lock().unwrap().insert( - id, - SocketState { - tcp_async_id, - owner_agent: perry_ffi::agent_post::current_agent(), - connect_async_id: 0, - shutdown_async_id: 0, - awaiting_connect: true, - unconnected_write_failed: false, - is_open: false, - raw_fd: None, - refed: true, - local_addr: None, - remote_addr: None, - raw: None, - destroyed: false, - connecting: false, - has_opened: false, - writable_ended: false, - readable_ended: false, - bytes_read: 0, - bytes_written: 0, - bytes_queued: 0, - cork: crate::cork::CorkBuffer::default(), - need_drain: false, - timeout: None, - type_of_service: 0, - server_id: None, - server_connection_active: false, - tls: TlsSocketMetadata::default(), - turnloop: false, - }, - ); - statics::listeners() - .lock() - .unwrap() - .insert(id, HashMap::new()); - id + payload_transport::raw_owner(payload_socket::new_socket( + payload_io::ROUTE, + payload_transport::undefined(), + )) } -// ─── FFI: net.createServer(options?, connectionListener?) ──────────────────── +#[no_mangle] +pub unsafe extern "C" fn js_ext_net_socket_alloc(options: f64) -> i64 { + payload_transport::raw_owner(payload_socket::new_socket(payload_io::ROUTE, options)) +} -/// `net.createServer(options?, connectionListener?)`. #[no_mangle] pub unsafe extern "C" fn js_net_create_server( _options_i64: i64, connection_listener_i64: i64, ) -> i64 { - ensure_gc_scanner_registered(); - dispatch::ensure_runtime_dispatch_registered(); - let id = next_id_or_throw(); - statics::listeners() - .lock() - .unwrap() - .insert(id, HashMap::new()); - // Issue #1123 followup — register the server in the dedicated - // `servers()` map alongside the listener-map entry. - // `js_net_server_listen` populates the listen state + bound address - // fields; `js_net_server_close` closes the listener on the loop. - statics::servers().lock().unwrap().insert( - id, - ServerState { - async_id: 0, - owner_agent: perry_ffi::agent_post::current_agent(), - listen_armed: false, - bound_port: 0, - bound_host: String::new(), - bound_path: None, - listening: false, - active_connections: 0, - pending_connections: 0, - pending_local_connect_events: 0, - max_connections: None, - drop_max_connection: None, - }, - ); - if connection_listener_i64 != 0 { - if let Ok(mut listeners) = statics::listeners().lock() { - listeners - .entry(id) - .or_default() - .entry("connection".to_string()) - .or_default() - .push(connection_listener_i64); - } - } - id + let scope = TransientRootScope::enter(); + let options = scope.root_nanbox(if _options_i64 == 0 { + payload_transport::undefined() + } else { + payload_transport::boxed_addr(_options_i64) + }); + let callback = scope.root_nanbox(if connection_listener_i64 == 0 { + payload_transport::undefined() + } else { + payload_transport::boxed_addr(connection_listener_i64) + }); + payload_transport::raw_owner(payload_server::new_server( + payload_io::ROUTE, + options.get(), + callback.get(), + )) } -/// Collision-proof server factory for generated code. Pulling this distinct -/// symbol from perry-ext-net also ensures the server/socket symbols in this -/// archive win over bundled-stdlib twins with separate handle registries. #[no_mangle] pub unsafe extern "C" fn js_ext_net_create_server( options_i64: i64, @@ -862,258 +100,30 @@ pub unsafe extern "C" fn js_ext_net_create_server( js_net_create_server(options_i64, connection_listener_i64) } -// ─── FFI: net.Server.listen / .close / .address / .on ──────────────────────── - -/// `server.listen(port | path, callback?)` — bind TCP, a Windows named pipe, -/// or a Unix-domain socket and start a multishot accept on the agent's loop. -/// The `callback` (a NaN-boxed closure pointer in the codegen's -/// NA_PTR slot, raw i64 here after unboxing in lower_call.rs) is -/// registered as a one-shot `'listening'` listener; when the bind -/// resolves, a `ServerListening` event is pushed so -/// the main-thread pump invokes both the user's `.on('listening', cb)` -/// listeners and the trailing `.listen(port, cb)` callback. -/// -/// Bind failures emit a `ServerError` and a `ServerClose` so the user's -/// `.on('error', err => …)` + `.on('close', () => …)` listeners fire -/// the same way they would on Node. -/// -/// # Safety -/// -/// `handle` must be a server id returned by `js_net_create_server`. -/// `callback_i64` may be 0 (no callback) or a raw `*const RawClosureHeader` -/// cast to `i64` — the codegen ABI for NA_PTR-unboxed closures. #[no_mangle] pub unsafe extern "C" fn js_net_server_listen(handle: i64, port: f64, arg2: f64, arg3: f64) { - ensure_gc_scanner_registered(); - let roots = TransientRootScope::enter(); - let arg2 = roots.root_nanbox(arg2); - let arg3 = roots.root_nanbox(arg3); - let path = ipc::string_value(port) - .or_else(|| is_nanboxed_pointer(port).then(|| get_object_string_field(port, "path"))?); - let (port_u16, host) = if path.is_some() { - (0, String::new()) - } else if is_nanboxed_pointer(port) { - let option_port = get_object_number_field(port, "port").unwrap_or(0.0); - js_net_validate_listen_port(option_port); - let option_host = get_object_string_field(port, "host") - .filter(|host| !host.is_empty()) - .unwrap_or_else(|| "0.0.0.0".to_string()); - (option_port as u16, option_host) - } else { - // #2013: a numeric `port` must be an integer in [0, 65536); Node throws - // RangeError [ERR_SOCKET_BAD_PORT] otherwise. - js_net_validate_listen_port(port); - // `listen(port, callback)` places the callback in arg2. Keep the host - // boundary strict so closure/object storage cannot become a hostname; - // real heap and SSO strings are copied at their logical byte length. - let host = jsvalue_to_owned_string(arg2.get()).unwrap_or_else(|| "0.0.0.0".to_string()); - (port as u16, host) - }; - let server_async_id = init_provider(b"TCPSERVERWRAP"); - let callback_i64 = match js_net_callback_ptr(arg3.get()) { - 0 => js_net_callback_ptr(arg2.get()), - cb => cb, - }; - - // Mark the server as listening. If the handle isn't registered, bail - // before touching the loop. - { - let mut servers = match statics::servers().lock() { - Ok(g) => g, - Err(_) => return, - }; - let s = match servers.get_mut(&handle) { - Some(s) => s, - None => return, - }; - s.async_id = server_async_id; - s.listen_armed = true; - s.bound_port = port_u16; - s.bound_host = host.clone(); - s.bound_path = path.clone(); - s.listening = true; - } - - // Stash the listen-callback under `'listening'` so the pump fires - // it on the first ServerListening event, then drops it (matching - // Node's "callback runs once on listen" semantics). - if callback_i64 != 0 { - if let Ok(mut listeners) = statics::listeners().lock() { - listeners - .entry(handle) - .or_default() - .entry("listening".to_string()) - .or_default() - .push(callback_i64); - } - } - - let server_id = handle; - let no_loop_target = match &path { - Some(path) => path.clone(), - None => format!("{}:{}", host, port_u16), - }; - // Bind and accept on the agent's turnloop loop. `tcp_listen` binds - // synchronously, so an EADDRINUSE is known inside the submission — but it - // still reaches JS through the pending-event queue, so `'error'` stays - // asynchronous exactly as Node's is. - let submitted = turnloop_io::on_loop(move || match path { - Some(path) => match turnloop_io::listen_pipe(server_id, &path, 511) { - Ok(()) => push_event(PendingNetEvent::ServerListening(server_id)), - Err(err) => fail_listen(server_id, format!("{} {}", err.message(), path)), - }, - None => match turnloop_io::listen_tcp(server_id, &host, port_u16, 511) { - Ok(()) => { - publish_bound_address(server_id); - push_event(PendingNetEvent::ServerListening(server_id)); - } - Err(err) => fail_listen( - server_id, - format!("{} {}:{}", err.message(), host, port_u16), - ), - }, - }); - if !submitted { - fail_listen( - server_id, - format!("bind {}: {}", no_loop_target, turnloop_io::NO_LOOP_CODE), - ); - } -} - -/// Record the address the kernel actually bound, before `'listening'` fires. -/// -/// `server.listen(0, () => client.connect(server.address().port))` is the -/// dominant pattern in Node's own net tests; reporting the requested port (0) -/// instead of the real one makes every such test connect to port 0. -fn publish_bound_address(server_id: i64) { - let Some(local) = turnloop_io::local_endpoint(server_id) else { - return; - }; - if let Ok(mut servers) = statics::servers().lock() { - if let Some(s) = servers.get_mut(&server_id) { - s.bound_port = local.port; - s.bound_host = local.address.clone(); - } - } - unsafe { - perry_cluster_worker_listening( - local.address.as_ptr(), - local.address.len() as u32, - local.port as i32, - local.family, - ); - } -} - -/// A bind that failed: Node emits `'error'` then `'close'`, and the server is -/// no longer listening. -fn fail_listen(server_id: i64, message: String) { - push_event(PendingNetEvent::ServerError(server_id, message)); - push_event(PendingNetEvent::ServerClose(server_id)); - if let Ok(mut servers) = statics::servers().lock() { - if let Some(s) = servers.get_mut(&server_id) { - s.listening = false; - } - } + payload_server::listen(payload_transport::boxed_addr(handle), port, arg2, arg3); } -/// `server.close(callback?)` — close the listener and fire the optional -/// callback once it is gone. The actual `'close'` listener dispatch happens -/// in the main-thread pump when the listener's terminal `Closed` completion -/// pushes `ServerClose`. -/// -/// # Safety -/// -/// `handle` must be a server id; `callback_i64` is a raw closure ptr. #[no_mangle] pub unsafe extern "C" fn js_net_server_close(handle: i64, callback_i64: i64) { - // Stash the user's close callback under `'close'` so the pump fires - // it alongside the registered listeners when the accept loop exits. - if callback_i64 != 0 { - if let Ok(mut listeners) = statics::listeners().lock() { - listeners - .entry(handle) - .or_default() - .entry("close".to_string()) - .or_default() - .push(callback_i64); - } - } - // Closing the listener cancels its multishot accept and delivers a - // terminal `Closed`, which is what pushes `'close'`. Decided on the loop's - // owner, because only that thread can see whether the listener is live. - // A server that never bound (its `listen()` failed, or never ran) has no - // listener to close; it only stops counting as a live handle, which is - // what dropping the old accept loop's shutdown sender did. - let forget = move || { - if let Ok(mut servers) = statics::servers().lock() { - if let Some(s) = servers.get_mut(&handle) { - s.listen_armed = false; - } - } - }; - let submitted = turnloop_io::on_loop(move || { - if turnloop_io::owns(handle) { - turnloop_io::close_server(handle); + payload_server::close( + payload_transport::boxed_addr(handle), + if callback_i64 == 0 { + payload_transport::undefined() } else { - forget(); - } - }); - if !submitted { - forget(); - } + payload_transport::boxed_addr(callback_i64) + }, + ); } -/// `server.address()` — returns a JSON string the TS-side wrapper can -/// `JSON.parse` into `{ port, address, family }`. Matches the -/// perry-ext-http contract (`js_node_http_server_address_json`). -/// -/// Returns `null` (as a JS string) for an unlistening server. -/// -/// # Safety -/// -/// `handle` must be a server id. The returned `*mut StringHeader` is -/// allocated in the runtime arena and follows perry-ffi's standard -/// ownership: the caller hands it to user code as a NaN-boxed JS string. +/// `server.address()` as a JSON string, for the stdlib's untyped method +/// dispatch (`handle` is the server object's address). #[no_mangle] pub unsafe extern "C" fn js_net_server_address(handle: i64) -> *mut StringHeader { - let json = match statics::servers().lock() { - Ok(g) => match g.get(&handle) { - Some(s) if s.listening => { - if let Some(path) = &s.bound_path { - return alloc_string( - &serde_json::to_string(path).unwrap_or_else(|_| "null".to_string()), - ) - .as_raw(); - } - let family = if s.bound_host.contains(':') { - "IPv6" - } else { - "IPv4" - }; - format!( - "{{\"port\":{},\"address\":\"{}\",\"family\":\"{}\"}}", - s.bound_port, s.bound_host, family - ) - } - _ => "null".to_string(), - }, - Err(_) => "null".to_string(), - }; - alloc_string(&json).as_raw() + alloc_string(&payload_server::address_json(payload_transport::boxed_addr(handle))).as_raw() } -// ─── FFI: socket.connect(port, host) (instance method on existing handle) ───── - -/// `socket.connect(port, host)` / `socket.connect(path)` — initiates a TCP or -/// IPC connection on a socket previously allocated by `new net.Socket()`, -/// under that socket's own id, so any listener already registered -/// (`sock.on('data', cb)`) sees the same handle once the connect completes. -/// -/// # Safety -/// -/// See `js_net_socket_connect`. #[no_mangle] pub unsafe extern "C" fn js_ext_net_socket_method_connect( handle: i64, @@ -1131,376 +141,5 @@ pub unsafe extern "C" fn js_net_socket_method_connect( arg2: f64, arg3: f64, ) { - if let Some(path) = ipc::string_value(arg1) { - ipc::register_connect_cb(handle, arg2); - ipc::connect_existing(handle, path); - return; - } - - let (host, port, callback) = if is_nanboxed_pointer(arg1) { - if let Some(path) = get_object_string_field(arg1, "path") { - ipc::register_connect_cb(handle, arg2); - ipc::connect_existing(handle, path); - return; - } - let port = match get_object_number_field(arg1, "port") { - Some(port) => port, - None => { - push_event(PendingNetEvent::Error( - handle, - "socket.connect: options.port or options.path is required".to_string(), - )); - return; - } - }; - let host = get_object_string_field(arg1, "host") - .or_else(|| get_object_string_field(arg1, "hostname")) - .filter(|host| !host.is_empty()) - .unwrap_or_else(|| "localhost".to_string()); - (host, port, arg2) - } else { - let host = ipc::string_value(arg2); - let callback = if host.is_some() { arg3 } else { arg2 }; - ( - host.unwrap_or_else(|| "127.0.0.1".to_string()), - arg1, - callback, - ) - }; - // #2013: validate before truncating, matching Node's synchronous - // ERR_SOCKET_BAD_PORT behavior for positional and options overloads. - js_net_validate_connect_port(port); - let port = port as u16; - ipc::register_connect_cb(handle, callback); - - let tcp_async_id = { - let mut guard = statics::sockets().lock().unwrap(); - match guard.get_mut(&handle) { - Some(socket) if socket.awaiting_connect => { - socket.awaiting_connect = false; - socket.tcp_async_id - } - _ => { - push_event(PendingNetEvent::Error( - handle, - "socket already connected (or unknown handle)".to_string(), - )); - return; - } - } - }; - let connect_async_id = init_provider_with_trigger(b"TCPCONNECTWRAP", tcp_async_id); - if let Some(socket) = statics::sockets().lock().unwrap().get_mut(&handle) { - socket.connect_async_id = connect_async_id; - // #10465 — `socket.connect(...)` on a `new net.Socket()` starts - // connecting synchronously from the caller's point of view, same as - // the eager `net.connect()` factory. - socket.connecting = true; - if socket.unconnected_write_failed { - // Its queued write error will close it before any I/O can begin. - return; - } - } - - let local_server = server_state::begin_local_connect(&host, port); - // The deferred-connect shape (`new net.Socket()` then `socket.connect()`, - // which is also what `Bun.connect` and `net.Socket.prototype.connect` - // lower to) takes the same route `net.connect()` does. - submit_tcp_connect(handle, host, port, local_server, None); -} - -/// Submit an outbound TCP connect for socket `id` on the agent's loop, and -/// report a refusal the way a failed connect is reported. -/// -/// `direct_tls` is `tls.connect`'s request for TLS from byte zero: the session -/// is installed the instant the connect completes, before `'connect'` is -/// pushed. -fn submit_tcp_connect( - id: i64, - host: String, - port: u16, - local_server: Option<(i64, bool)>, - direct_tls: Option<(String, bool, TlsClientConfigData)>, -) { - // Published before the submission, not after it: on a thread that posts - // to the loop's owner, a `socket.write()` issued before the connect has - // run must already be routed to the loop rather than parked. - set_turnloop(id, true); - let target = host.clone(); - let submitted = - turnloop_io::on_loop( - move || match turnloop_io::connect_tcp(id, &host, port, true) { - Ok(()) => { - turnloop_io::note_local_connect(id, local_server); - if let Some((servername, verify, config)) = direct_tls { - turnloop_io::note_direct_tls(id, servername, verify, config); - } - } - Err(err) => refuse_connect(id, &err.code, &host, port, local_server), - }, - ); - if !submitted { - refuse_connect(id, turnloop_io::NO_LOOP_CODE, &target, port, local_server); - } -} - -/// A connect the driver never took: `'error'` then `'close'`, and the socket -/// never reached the loop, so its later commands are refused rather than -/// submitted. -fn refuse_connect(id: i64, code: &str, host: &str, port: u16, local_server: Option<(i64, bool)>) { - set_turnloop(id, false); - server_state::cancel_local_connect(local_server); - // libuv's shape, which is Node's `err.message` and the only place - // `err.code` / `errno` / `syscall` come from (`build_error_object` parses - // it). - push_event(PendingNetEvent::Error( - id, - format!("connect {} {}:{}", code, host, port), - )); - push_event(PendingNetEvent::Close(id)); - mark_closed(id); -} - -fn set_turnloop(id: i64, on_loop: bool) { - if let Some(s) = statics::sockets().lock().unwrap().get_mut(&id) { - s.turnloop = on_loop; - } + payload_socket::connect(payload_transport::boxed_addr(handle), arg1, arg2, arg3); } - -// ─── FFI: tls.connect ──────────────────────────────────────────────────────── -// `js_tls_connect` lives in tls.rs (this file is at the 2000-line gate); -// it resolves Node's connect overloads and reuses `spawn_socket_task`. - -/// Internal: allocate the handle and submit its connect. -/// `direct_tls = Some((servername, verify))` runs a TLS handshake before -/// firing 'connect'; None keeps the socket in plain TCP mode. -pub(crate) fn spawn_socket_task( - host: String, - port: u16, - direct_tls: Option<(String, bool, TlsClientConfigData)>, -) -> i64 { - spawn_socket_task_initialized(host, port, direct_tls, |_| {}) -} - -/// Allocate a socket and run `initialize` after its registries exist but before -/// its connect can complete. TLS uses this boundary to publish its runtime -/// metadata without racing a fast loopback handshake. -pub(crate) fn spawn_socket_task_initialized( - host: String, - port: u16, - direct_tls: Option<(String, bool, TlsClientConfigData)>, - initialize: F, -) -> i64 -where - F: FnOnce(i64), -{ - ensure_gc_scanner_registered(); - dispatch::ensure_runtime_dispatch_registered(); - let id = next_id_or_throw(); - let local_server = direct_tls - .is_none() - .then(|| server_state::begin_local_connect(&host, port)) - .flatten(); - let tcp_async_id = unsafe { init_provider(b"TCPWRAP") }; - let connect_async_id = unsafe { init_provider_with_trigger(b"TCPCONNECTWRAP", tcp_async_id) }; - - statics::sockets().lock().unwrap().insert( - id, - SocketState { - tcp_async_id, - owner_agent: perry_ffi::agent_post::current_agent(), - connect_async_id, - shutdown_async_id: 0, - awaiting_connect: false, - unconnected_write_failed: false, - is_open: false, - raw_fd: None, - refed: true, - local_addr: None, - remote_addr: None, - raw: None, - destroyed: false, - connecting: true, - has_opened: false, - writable_ended: false, - readable_ended: false, - bytes_read: 0, - bytes_written: 0, - bytes_queued: 0, - cork: crate::cork::CorkBuffer::default(), - need_drain: false, - timeout: None, - type_of_service: 0, - server_id: None, - server_connection_active: false, - tls: TlsSocketMetadata::default(), - turnloop: false, - }, - ); - statics::listeners() - .lock() - .unwrap() - .insert(id, HashMap::new()); - initialize(id); - - // TLS runs above the turnloop handle (`turnloop_tls_io`), so a client that - // may later be upgraded needs no descriptor handover and lives on the loop - // from the start. `tls.connect` (`direct_tls`) installs its session the - // moment the connect completes, before `'connect'` is pushed. - submit_tcp_connect(id, host, port, local_server, direct_tls); - id -} - -// ─── FFI: socket.write / end / destroy live in `lifecycle.rs` ──────────────── -// (#2549 split — moved there alongside the new state/counter getters to keep -// this file under the 2000-line gate; they mutate the same SocketState.) - -// ─── FFI: socket.on(event, callback) ───────────────────────────────────────── - -/// `socket.on(event, cb)` — registers a listener. Closures are stored as -/// raw `i64` pointers; the GC root scanner keeps them alive across cycles. -/// -/// # Safety -/// -/// `event_ptr` must be null or a Perry-runtime `StringHeader`. `cb` is a -/// raw `*const ClosureHeader` cast to `i64` (codegen ABI for NA_PTR). -#[no_mangle] -pub unsafe extern "C" fn js_net_socket_on(handle: i64, event_ptr: i64, cb: i64) { - ensure_gc_scanner_registered(); - let event = match string_from_header_i64(event_ptr) { - Some(e) => e, - None => return, - }; - { - let mut listeners = statics::listeners().lock().unwrap(); - let entry = listeners.entry(handle).or_default(); - entry.entry(event.clone()).or_default().push(cb); - } - if event == "close" { - tls::fire_pending_tls_abort(handle); - } -} - -// ─── FFI: socket.upgradeToTLS(servername, verify) -> Promise ───────────────── - -/// `socket.upgradeToTLS(servername, verify)` — installs a client TLS session -/// above the socket's turnloop handle and returns a Promise that resolves when -/// the handshake completes (or rejects on failure). -/// -/// # Safety -/// -/// `servername_ptr` must be null or a Perry-runtime `StringHeader`. -#[no_mangle] -pub unsafe extern "C" fn js_net_socket_upgrade_tls( - handle: i64, - servername_ptr: i64, - verify: f64, -) -> *mut perry_ffi::Promise { - let promise = JsPromise::new(); - let promise_raw = promise.as_raw(); - - let servername = match string_from_header_i64(servername_ptr) { - Some(s) => s, - None => { - // Reject on the same thread we're called from — works because - // the resolution is queued and processed on the main thread by - // the runtime's promise dispatcher. - promise.reject_string("invalid servername"); - return promise_raw; - } - }; - - let turnloop = { - let sockets = statics::sockets().lock().unwrap(); - match sockets.get(&handle) { - Some(s) => s.turnloop, - None => { - promise.reject_string(&format!("socket {} not found", handle)); - return promise_raw; - } - } - }; - if !turnloop { - // Never connected, or its connect was refused: there is no stream to - // put a session on. - promise.reject_string("socket is not connected"); - return promise_raw; - } - - // The upgrade happens in place. No descriptor changes hands — the rustls - // session is installed *above* the same handle. The promise is held by a - // native-async token rather than a bare `*mut Promise` in a side table, so - // the runtime pins and root-scans it across the collections that happen - // while the handshake is in flight (#9552); the token settles on the loop - // thread, inside the same dispatch that sees the handshake finish. - let token = - perry_ffi::JsNativeAsyncCompletion::with_flags(perry_ffi::PERRY_NATIVE_ASYNC_THREAD_MAIN); - let token_promise = token.promise(); - // The promise minted above is unused on this path; settle it so the - // runtime never carries a permanently pending one. - promise.resolve_undefined(); - let verify = verify != 0.0; - // `begin_client_upgrade` settles the token on every failure path, so the - // caller does not have to get it back to reject it. It runs on the loop's - // owner, which is this thread unless a second thread is acting for the - // agent. - let token = Arc::new(Mutex::new(Some(token))); - let job_token = token.clone(); - let submitted = turnloop_io::on_loop(move || { - let token = job_token.lock().unwrap_or_else(|e| e.into_inner()).take(); - let _ = turnloop_tls_io::begin_client_upgrade( - handle, - servername, - verify, - TlsClientConfigData::default(), - token, - ); - }); - if !submitted { - if let Some(token) = token.lock().unwrap_or_else(|e| e.into_inner()).take() { - token.reject_string(turnloop_io::NO_LOOP_CODE); - } - } - token_promise -} - -// ─── Main-thread event pump ────────────────────────────────────────────────── - -/// Dispatches queued socket events to JS listeners on the main thread. -/// Called from codegen's event-loop tick (via the well-known pending-events -/// pump). -/// -/// Per the arena-safety rule: JSValue construction (Buffer, error string) -/// happens HERE on the main thread, never in the completion sink. -/// -/// Returns the number of events fired in this pass. -/// -/// #1114 followup (mysql wedge): this pump runs on EVERY iteration of -/// the generated event loop AND every iteration of every inline `await` -/// poll loop. `@perryts/mysql` (pure-TS driver) drives all its bytes -/// through `net.Socket`, so under a `setInterval` + async-query JobLoop -/// this function is the dominant per-tick path. The original -/// `Vec::drain(..).collect()` allocated a fresh Vec every call -/// (mirroring the fastify wedge that e538caa7 fixed) → GC `madvise` -/// page-churn. Reuse a per-thread scratch buffer (moved out across -/// dispatch so a re-entrant pump from inside a user callback is safe; -/// capacity retained → zero steady-state allocation). -#[no_mangle] -pub unsafe extern "C" fn js_net_process_pending() -> i32 { - js_ext_net_drain_pending() -} - -mod socket_events; -pub use socket_events::js_ext_net_drain_pending; - -mod handle_exports; -use handle_exports::listeners_for; -pub use handle_exports::{ - is_net_server_handle, is_net_socket_handle, js_ext_net_is_server_handle, js_ext_net_socket_on, - js_ext_net_socket_once, js_ext_net_socket_remove_all_listeners, - js_ext_net_socket_remove_listener, js_net_has_pending, js_net_server_listening, - js_net_server_on, -}; - -#[cfg(test)] -mod tests; diff --git a/crates/perry-ext-net/src/lifecycle.rs b/crates/perry-ext-net/src/lifecycle.rs deleted file mode 100644 index 5283ffb40f..0000000000 --- a/crates/perry-ext-net/src/lifecycle.rs +++ /dev/null @@ -1,1791 +0,0 @@ -//! Issue #2131 — `net.Socket` / `net.Server` lifecycle + EventEmitter -//! surface beyond what #1852 shipped. Split into its own module to -//! keep `lib.rs` under the 2000-line file-size gate. The functions -//! here mirror node's EventEmitter surface, but operate on the existing -//! `statics::listeners()` map keyed by net handle id (socket OR -//! server — they share the namespace via the monotonic `next_id()`). -//! -//! `socket.address()` lives here too: it reads back the local -//! address captured at `connect`/`accept` time (see -//! `SocketState::local_addr`) and emits the JSON shape consumed by -//! the codegen's `NR_OBJ_FROM_JSON_STR` return-kind, so user code -//! gets a real `{ port, address, family }` object instead of the -//! pre-fix `undefined`. -//! -//! All entry points use the same FFI ABI as their `js_net_*` -//! neighbors in `lib.rs`: handles arrive as `i64`, NaN-boxed strings -//! arrive as pre-unboxed `*const StringHeader`, closures arrive as -//! raw `*const RawClosureHeader` cast to `i64`. The corresponding -//! `NativeModSig` rows live in -//! `perry-codegen/src/lower_call/native_table/net_events.rs`. - -use perry_ffi::{ - alloc_buffer, alloc_string, nanbox_string_bits, ArrayHeader, JsValue, StringHeader, -}; -use std::collections::HashSet; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::{Mutex, OnceLock}; - -use crate::statics; -use crate::string_from_header_i64; - -// ─── #2549: net.Socket state / counter / metadata property getters ─────────── -// -// These zero-arg getters back the `net.Socket` property surface Node exposes -// (`socket.pending`, `.connecting`, `.destroyed`, `.readyState`, `.bytesRead`, -// `.bytesWritten`, `.timeout`, the `local*`/`remote*` endpoint fields, …). -// The codegen lowers a bare member read on a `("net", "Socket")` instance into -// a zero-arg `NativeMethodCall`; the matching `NativeModSig` rows live in -// `perry-codegen/src/lower_call/native_table/net_events.rs`. -// -// Numeric/boolean/undefined-valued getters return a *NaN-boxed* `f64` through -// the dispatch table's `NR_F64` kind (the value passes straight through). -// `readyState` is always a string, so it uses `NR_STR` and returns a raw -// `*mut StringHeader`. String-or-undefined fields (`localAddress`, …) box the -// string themselves and fall back to `TAG_UNDEFINED` when unconnected, since -// Node reports `undefined` (not `null`) for those before a connection. - -const TAG_UNDEFINED_BITS: u64 = 0x7FFC_0000_0000_0001; -const TAG_FALSE_BITS: u64 = 0x7FFC_0000_0000_0003; -const TAG_TRUE_BITS: u64 = 0x7FFC_0000_0000_0004; - -fn nanbox_bool(b: bool) -> f64 { - f64::from_bits(if b { TAG_TRUE_BITS } else { TAG_FALSE_BITS }) -} - -fn nanbox_undefined() -> f64 { - f64::from_bits(TAG_UNDEFINED_BITS) -} - -/// Paused-mode `net.Socket.read()`: return the next buffered chunk, or the -/// Node sentinel `null` when no data is currently available. -#[no_mangle] -pub unsafe extern "C" fn js_ext_net_socket_read(handle: i64, _size: f64) -> f64 { - let Some(bytes) = crate::server_state::take_pending_socket_data(handle) else { - return f64::from_bits(JsValue::NULL.bits()); - }; - let buffer = alloc_buffer(&bytes); - f64::from_bits(0x7FFD_0000_0000_0000 | (buffer as u64 & 0x0000_FFFF_FFFF_FFFF)) -} - -/// Typed native-table calls use the provider-neutral symbol name. -#[no_mangle] -pub unsafe extern "C" fn js_net_socket_read(handle: i64, size: f64) -> f64 { - js_ext_net_socket_read(handle, size) -} - -/// Main-thread custody for write/end callbacks awaiting socket-task I/O. -pub(crate) fn socket_completions() -> &'static Mutex)>> -{ - static COMPLETIONS: OnceLock)>>> = - OnceLock::new(); - COMPLETIONS.get_or_init(|| Mutex::new(std::collections::HashMap::new())) -} - -pub(crate) unsafe fn dispatch_socket_completion(completion: u64, error: Option) { - let callbacks = socket_completions() - .lock() - .unwrap() - .remove(&completion) - .map(|(_, callbacks)| callbacks) - .unwrap_or_default(); - if callbacks.is_empty() { - return; - } - let mut frame = crate::dispatch_custody::DispatchFrame::park(callbacks); - if let Some(ref message) = error { - frame.set_payload(crate::build_error_object(message).to_bits()); - } - for i in 0..frame.len() { - let cb = perry_ffi::JsClosure::from_raw(frame.cb(i) as *const perry_ffi::RawClosureHeader); - if error.is_some() { - let _ = cb.call1( - perry_ffi::JsThis::UNDEFINED, - f64::from_bits(frame.payload_bits()), - ); - } else { - let _ = cb.call0(perry_ffi::JsThis::UNDEFINED); - } - } -} - -/// Snapshot unfinished writes/end callbacks in their registration order. -pub(crate) fn pending_socket_completions(socket_id: i64) -> Vec { - let mut completions = socket_completions() - .lock() - .unwrap() - .iter() - .filter_map(|(completion, (owner, _))| (*owner == socket_id).then_some(*completion)) - .collect::>(); - // Tokens are monotonically allocated; HashMap iteration is unordered. - completions.sort_unstable(); - completions -} - -pub(crate) fn drop_socket_completions(socket_id: i64) { - for completion in pending_socket_completions(socket_id) { - unsafe { - dispatch_socket_completion(completion, Some("Socket is closed".to_string())); - } - } -} - -/// NaN-box a freshly allocated runtime string as an `f64` JS value. -fn nanbox_string_value(s: &str) -> f64 { - let header = alloc_string(s).as_raw(); - f64::from_bits(nanbox_string_bits(header)) -} - -/// Run `f` against the live `SocketState` for `handle`, returning `default` -/// when the handle is unknown (e.g. already torn down). -fn with_socket(handle: i64, default: T, f: impl FnOnce(&crate::SocketState) -> T) -> T { - match statics::sockets().lock() { - Ok(g) => g.get(&handle).map(f).unwrap_or(default), - Err(_) => default, - } -} - -/// `socket.pending` — `true` until the socket starts connecting. We treat a -/// not-yet-open, not-destroyed handle as pending (matches Node's value for a -/// freshly constructed `new net.Socket()`). -/// -/// # Safety -/// -/// `handle` must be a registered socket id (raw, NOT NaN-boxed). -#[no_mangle] -pub unsafe extern "C" fn js_net_socket_get_pending(handle: i64) -> f64 { - // #10465 — Node's real getter is `!this._handle`: once there is no live - // handle (never connected, still connecting, OR fully closed/destroyed) - // `pending` reads `true` again — it is NOT simply the complement of - // `destroyed`. A handle already reaped from the registry (see the - // `'close'` teardown in `socket_events.rs`, which removes the - // `SocketState` entry once the `'close'` event has fired) falls through - // to the `true` default below, which is what we want for that case too. - // - // Deliberately keyed on `has_opened`/`destroyed`, NOT `is_open`: - // `is_open` flips false via `server_state::mark_socket_closed`, called - // from the completion sink as soon as teardown STARTS (before the main - // thread has processed the `'end'`/`'close'` events that same teardown - // just queued), while `destroyed` only flips at `'close'`-processing - // time — the one point that actually agrees with Node's own timing (see - // the `Close` arm in `socket_events.rs`). Once a socket has opened at - // least once, "does it have a live handle" reduces to "has it been - // destroyed yet", not to the (earlier-flipping) `is_open` flag. - nanbox_bool(with_socket(handle, true, |s| { - if s.has_opened { - s.destroyed - } else { - true - } - })) -} - -/// `socket.connecting` — `true` from `net.connect()`/`socket.connect()` -/// until the attempt resolves (open, error, or destroy). Backed by -/// `SocketState::connecting` (#10465); pre-fix this was hardcoded `false`, -/// so `readyState` could never report `"opening"` and any caller polling -/// `connecting` during the handshake window saw the wrong value. -/// -/// # Safety -/// -/// See [`js_net_socket_get_pending`]. -#[no_mangle] -pub unsafe extern "C" fn js_net_socket_get_connecting(handle: i64) -> f64 { - nanbox_bool(with_socket(handle, false, |s| s.connecting)) -} - -/// `socket.destroyed` — `true` once `.destroy()` ran or the peer closed. -/// Defaults to `true` for a handle with no live `SocketState` — the -/// `'close'` teardown removes the entry once its listeners have run, and by -/// then the socket is unambiguously destroyed (#10465; pre-fix this -/// defaulted `false`, so `destroyed` read `false` again after `'close'`). -/// -/// # Safety -/// -/// See [`js_net_socket_get_pending`]. -#[no_mangle] -pub unsafe extern "C" fn js_net_socket_get_destroyed(handle: i64) -> f64 { - nanbox_bool(with_socket(handle, true, |s| s.destroyed)) -} - -/// `socket.writable` — `true` until `.end()`/`.destroy()` flips -/// `writable_ended`. Independent of connect state, matching Node (a fresh -/// `new net.Socket()` is `writable` before it has ever connected). #10465 — -/// pre-fix this property didn't exist at all (read `undefined`). -/// -/// # Safety -/// -/// See [`js_net_socket_get_pending`]. -#[no_mangle] -pub unsafe extern "C" fn js_net_socket_get_writable(handle: i64) -> f64 { - nanbox_bool(with_socket(handle, false, |s| { - !s.destroyed && !s.writable_ended && (!s.unconnected_write_failed || s.connecting) - })) -} - -/// `socket.readable` — `true` until the peer's EOF has been observed (the -/// `'end'` event) or the socket is destroyed. #10465 companion to -/// [`js_net_socket_get_writable`]. -/// -/// # Safety -/// -/// See [`js_net_socket_get_pending`]. -#[no_mangle] -pub unsafe extern "C" fn js_net_socket_get_readable(handle: i64) -> f64 { - nanbox_bool(with_socket(handle, false, |s| { - !s.destroyed && !s.readable_ended - })) -} - -/// `socket.writableEnded` — `true` immediately once `.end()` is called -/// (before the FIN even flushes), matching Node's documented timing. #10465. -/// -/// # Safety -/// -/// See [`js_net_socket_get_pending`]. -#[no_mangle] -pub unsafe extern "C" fn js_net_socket_get_writable_ended(handle: i64) -> f64 { - nanbox_bool(with_socket(handle, true, |s| s.writable_ended)) -} - -/// `socket.readableEnded` — `true` once the `'end'` event has fired. -/// #10465 companion to [`js_net_socket_get_writable_ended`]. -/// -/// # Safety -/// -/// See [`js_net_socket_get_pending`]. -#[no_mangle] -pub unsafe extern "C" fn js_net_socket_get_readable_ended(handle: i64) -> f64 { - nanbox_bool(with_socket(handle, true, |s| s.readable_ended)) -} - -/// `socket._writableState` / `socket._readableState` — Node internals expose -/// a full `WritableState`/`ReadableState` object; drivers that reach into it -/// (pg, ioredis, `@redis/client`) mostly just check `typeof … === "object"` -/// or a couple of scalar fields. #10465: this returns a minimal object -/// carrying the two fields the audited drivers actually read -/// (`ended`/`finished` mirror `writableEnded`, kept in sync with the same -/// `SocketState` bit) rather than a full internal-stream-state shape. -/// -/// # Safety -/// -/// See [`js_net_socket_get_pending`]. -#[no_mangle] -pub unsafe extern "C" fn js_net_socket_get_writable_state(handle: i64) -> *mut StringHeader { - let ended = with_socket(handle, true, |s| s.writable_ended); - let json = format!("{{\"ended\":{ended},\"finished\":{ended}}}"); - alloc_string(&json).as_raw() -} - -/// See [`js_net_socket_get_writable_state`]. -/// -/// # Safety -/// -/// See [`js_net_socket_get_pending`]. -#[no_mangle] -pub unsafe extern "C" fn js_net_socket_get_readable_state(handle: i64) -> *mut StringHeader { - let ended = with_socket(handle, true, |s| s.readable_ended); - let json = format!("{{\"ended\":{ended}}}"); - alloc_string(&json).as_raw() -} - -/// `socket.readyState` — one of `"opening" | "open" | "readOnly" | -/// "writeOnly" | "closed"`. Mirrors Node's real getter (`connecting` ? -/// `"opening"` : `readable && writable` ? `"open"` : `readable` ? -/// `"readOnly"` : `writable` ? `"writeOnly"` : `"closed"`) instead of the -/// pre-#10465 two-state `destroyed ? "closed" : "open"`, which could never -/// report `"opening"` (mid-connect) or `"readOnly"` (after `.end()`, before -/// the peer's FIN). -/// -/// # Safety -/// -/// See [`js_net_socket_get_pending`]. -#[no_mangle] -pub unsafe extern "C" fn js_net_socket_get_ready_state(handle: i64) -> *mut StringHeader { - let state = with_socket(handle, "closed", |s| { - if s.connecting { - "opening" - } else { - let writable = !s.destroyed && !s.writable_ended; - let readable = !s.destroyed && !s.readable_ended; - match (readable, writable) { - (true, true) => "open", - (true, false) => "readOnly", - (false, true) => "writeOnly", - (false, false) => "closed", - } - } - }); - alloc_string(state).as_raw() -} - -/// `socket.bytesRead` — total bytes consumed from the socket. -/// -/// # Safety -/// -/// See [`js_net_socket_get_pending`]. -#[no_mangle] -pub unsafe extern "C" fn js_net_socket_get_bytes_read(handle: i64) -> f64 { - with_socket(handle, 0u64, |s| s.bytes_read) as f64 -} - -/// `socket.bytesWritten` — bytes dispatched to the transport or still queued. -/// -/// # Safety -/// -/// See [`js_net_socket_get_pending`]. -#[no_mangle] -pub unsafe extern "C" fn js_net_socket_get_bytes_written(handle: i64) -> f64 { - with_socket(handle, 0u64, |s| { - s.bytes_written.saturating_add(s.bytes_queued) - }) as f64 -} - -/// `socket.timeout` — the value set via `setTimeout(ms)`, or `undefined`. -/// -/// # Safety -/// -/// See [`js_net_socket_get_pending`]. -#[no_mangle] -pub unsafe extern "C" fn js_net_socket_get_timeout(handle: i64) -> f64 { - match with_socket(handle, None, |s| s.timeout) { - Some(ms) => ms as f64, - None => nanbox_undefined(), - } -} - -/// `socket.localAddress` — the bound local IP string, or `undefined`. -/// -/// # Safety -/// -/// See [`js_net_socket_get_pending`]. -#[no_mangle] -pub unsafe extern "C" fn js_net_socket_get_local_address(handle: i64) -> f64 { - match with_socket(handle, None, |s| s.local_addr) { - Some(addr) => nanbox_string_value(&addr.ip().to_string()), - None => nanbox_undefined(), - } -} - -/// `socket.localPort` — the bound local port number, or `undefined`. -/// -/// # Safety -/// -/// See [`js_net_socket_get_pending`]. -#[no_mangle] -pub unsafe extern "C" fn js_net_socket_get_local_port(handle: i64) -> f64 { - match with_socket(handle, None, |s| s.local_addr) { - Some(addr) => addr.port() as f64, - None => nanbox_undefined(), - } -} - -/// `socket.localFamily` — `"IPv4"`/`"IPv6"` of the local endpoint, else -/// `undefined`. -/// -/// # Safety -/// -/// See [`js_net_socket_get_pending`]. -#[no_mangle] -pub unsafe extern "C" fn js_net_socket_get_local_family(handle: i64) -> f64 { - match with_socket(handle, None, |s| s.local_addr) { - Some(addr) => nanbox_string_value(if addr.is_ipv6() { "IPv6" } else { "IPv4" }), - None => nanbox_undefined(), - } -} - -/// `socket.remoteAddress` — the peer IP string, or `undefined`. -/// -/// # Safety -/// -/// See [`js_net_socket_get_pending`]. -#[no_mangle] -pub unsafe extern "C" fn js_net_socket_get_remote_address(handle: i64) -> f64 { - match with_socket(handle, None, |s| s.remote_addr) { - Some(addr) => nanbox_string_value(&addr.ip().to_string()), - None => nanbox_undefined(), - } -} - -/// `socket.remotePort` — the peer port, or `undefined` (see -/// [`js_net_socket_get_remote_address`]). -/// -/// # Safety -/// -/// See [`js_net_socket_get_pending`]. -#[no_mangle] -pub unsafe extern "C" fn js_net_socket_get_remote_port(handle: i64) -> f64 { - match with_socket(handle, None, |s| s.remote_addr) { - Some(addr) => addr.port() as f64, - None => nanbox_undefined(), - } -} - -/// `socket.remoteFamily` — `"IPv4"`/`"IPv6"` of the peer, or `undefined` -/// (see [`js_net_socket_get_remote_address`]). -/// -/// # Safety -/// -/// See [`js_net_socket_get_pending`]. -#[no_mangle] -pub unsafe extern "C" fn js_net_socket_get_remote_family(handle: i64) -> f64 { - match with_socket(handle, None, |s| s.remote_addr) { - Some(addr) => nanbox_string_value(if addr.is_ipv6() { "IPv6" } else { "IPv4" }), - None => nanbox_undefined(), - } -} - -/// `socket.bufferSize` — Node reports `undefined` for an unconnected socket -/// and `writableLength` once connected (its getter is literally -/// `this._handle ? this.writableLength : undefined`). -/// -/// # Safety -/// -/// See [`js_net_socket_get_pending`]. -#[no_mangle] -pub unsafe extern "C" fn js_net_socket_get_buffer_size(handle: i64) -> f64 { - match with_socket(handle, None, |s| s.is_open.then_some(s.bytes_queued)) { - Some(queued) => queued as f64, - None => nanbox_undefined(), - } -} - -/// Node's default `writableHighWaterMark` for a byte stream -/// (`stream.getDefaultHighWaterMark(false)`, 64 KiB since Node 22). -pub(crate) const WRITABLE_HIGH_WATER_MARK: u64 = 64 * 1024; - -/// `socket.writableHighWaterMark` (#11111). -/// -/// # Safety -/// -/// See [`js_net_socket_get_pending`]. -#[no_mangle] -pub unsafe extern "C" fn js_net_socket_get_writable_high_water_mark(_handle: i64) -> f64 { - WRITABLE_HIGH_WATER_MARK as f64 -} - -/// `socket.writableLength` — bytes `write()` accepted that have not left yet -/// (#11111). `0` for a socket this registry no longer knows, as in Node once -/// the stream is destroyed and its buffer cleared. -/// -/// # Safety -/// -/// See [`js_net_socket_get_pending`]. -#[no_mangle] -pub unsafe extern "C" fn js_net_socket_get_writable_length(handle: i64) -> f64 { - with_socket(handle, 0u64, |s| s.bytes_queued) as f64 -} - -/// `socket.writableNeedDrain` — a `write()` returned `false` and `'drain'` has -/// not fired since (#11111). -/// -/// # Safety -/// -/// See [`js_net_socket_get_pending`]. -#[no_mangle] -pub unsafe extern "C" fn js_net_socket_get_writable_need_drain(handle: i64) -> f64 { - let need_drain = with_socket(handle, false, |s| s.need_drain); - f64::from_bits(perry_ffi::JsValue::from_bool(need_drain).bits()) -} - -/// Node's `write()` return value, judged the way `Writable.prototype.write` -/// judges it (`writeOrBuffer`): the chunk is counted into `writableLength` -/// FIRST, then `ret = writableLength < writableHighWaterMark`, and a `false` -/// arms `writableNeedDrain` so the queue emptying emits `'drain'`. A write to -/// a destroyed or ended socket is `false` as well. -/// -/// Called right after the chunk was queued, so `bytes_queued` already holds -/// it. `accepted` is false when the queueing itself failed. -fn write_return_value(handle: i64, accepted: bool) -> f64 { - let ret = statics::sockets() - .lock() - .ok() - .and_then(|mut sockets| { - let s = sockets.get_mut(&handle)?; - if !accepted || s.destroyed || s.writable_ended { - return Some(false); - } - if s.awaiting_connect && s.cork.depth == 0 { - // `new net.Socket()` written before `connect()`: Node refuses - // the write (`ERR_SOCKET_CLOSED`) and returns false. No - // `'drain'` is owed, since nothing will ever be flushed. - return Some(false); - } - let below = s.bytes_queued < WRITABLE_HIGH_WATER_MARK; - if !below { - s.need_drain = true; - } - Some(below) - }) - .unwrap_or(false); - f64::from_bits(perry_ffi::JsValue::from_bool(ret).bits()) -} - -/// Whether the queue just emptied with a `'drain'` owed (#11111): clears -/// `need_drain` and reports true exactly once per `false` return. Node's -/// `afterWrite` emits `'drain'` only while the stream is not ending and not -/// destroyed, and before the completed writes' callbacks. -pub(crate) fn take_drain(socket: &mut crate::SocketState) -> bool { - if socket.need_drain && socket.bytes_queued == 0 && !socket.writable_ended && !socket.destroyed - { - socket.need_drain = false; - return true; - } - false -} - -/// `socket.autoSelectFamilyAttemptedAddresses` — Node reports `undefined` -/// until a Happy-Eyeballs connect runs. Perry does not model the per-attempt -/// list, so we return `undefined`. -/// -/// # Safety -/// -/// See [`js_net_socket_get_pending`]. -#[no_mangle] -pub unsafe extern "C" fn js_net_socket_get_auto_select_family_attempted_addresses( - _handle: i64, -) -> f64 { - nanbox_undefined() -} - -// ─── socket.write / end / destroy ──────────────────────────────────────────── -// -// Moved here from `lib.rs` (#2549) to keep that file under the 2000-line -// gate; they share `SocketState` with the getters above and also feed the -// `bytesWritten`/`destroyed` counters those getters read. - -/// `socket.write(chunk)` — enqueues bytes for the writer task and bumps the -/// `bytesWritten` counter. `chunk_bits` is the full NaN-boxed JS value (NA_JSV); -/// `jsvalue_to_socket_bytes` probes Buffer/Uint8Array/string/number/bool and -/// reads through the correct layout (#1131). -/// -/// Carries a DISTINCT `#[no_mangle]` symbol (`js_ext_net_socket_write`), -/// deliberately NOT the `js_net_socket_write` name that the bundled stdlib net -/// ALSO exports. In a workspace / jsruntime build both crates are linked, so -/// `js_net_socket_write` is a duplicate symbol that the link binds to whichever -/// twin wins (the bundled stdlib's). A socket created here lives in ext-net's -/// registry; when it's then written to via the runtime's `HANDLE_METHOD_DISPATCH` -/// fallback (`dispatch_external_net_socket` in perry-stdlib — the path a -/// captured-by-closure `s.write(...)` inside an `'data'` handler takes), routing -/// through the shared `js_net_socket_write` symbol landed in the bundled twin's -/// EMPTY registry: `sockets.get(&handle)` missed, the `SocketCommand::Write` was -/// never enqueued, no `write()` syscall fired, and the bytes were silently -/// dropped. The dispatch helper calls THIS uniquely-named entry point instead — -/// a symbol with no twin — so the write always reaches ext-net's own registry. -/// Mirrors the `js_ext_net_destroy_socket` / `js_ext_net_drain_pending` fix. -/// (#5021, follows #5010.) -/// -/// # Safety -/// -/// `chunk_bits` must be a valid NaN-boxed JS value; string / Buffer pointers -/// must reference live runtime allocations. -/// -/// Returns Node's boolean `write()` result (#11111). -#[no_mangle] -pub unsafe extern "C" fn js_ext_net_socket_write(handle: i64, chunk_bits: i64) -> f64 { - let bytes = match crate::jsvalue_to_socket_bytes(f64::from_bits(chunk_bits as u64)) { - Some(b) => b, - None => return write_return_value(handle, false), - }; - let accepted = enqueue_socket_write(handle, bytes, 0); - write_return_value(handle, accepted) -} - -/// Queue `bytes`; false when the submission was refused (and reported). -fn enqueue_socket_write(handle: i64, bytes: Vec, completion: u64) -> bool { - let mut sockets = statics::sockets().lock().unwrap(); - let (failure, turnloop, unopened, first_failure) = match sockets.get_mut(&handle) { - Some(s) => { - let failure = s - .command(handle, crate::SocketCommand::Write(bytes, completion)) - .err(); - let unopened = failure.is_some() - && !s.turnloop - && (s.awaiting_connect || s.unconnected_write_failed); - let first = unopened && !s.unconnected_write_failed; - s.unconnected_write_failed |= unopened; - (failure, s.turnloop, unopened, first) - } - None => (Some("Socket is closed".to_string()), false, false, false), - }; - drop(sockets); - let Some(message) = failure else { - return true; - }; - if unopened { - queue_unconnected_write_failure(handle, completion, first_failure); - return false; - } - if turnloop { - // The driver refused the submission: report it as a write failure, - // including the 'error' + teardown. - crate::turnloop_io::submission_failed(handle, completion, message); - return false; - } - if completion != 0 { - unsafe { - dispatch_socket_completion(completion, Some(message)); - } - } - false -} - -// Write callbacks precede the one terminal error/close pair, including -// multiple writes made synchronously before the pending events are drained. -fn queue_unconnected_write_failure(handle: i64, completion: u64, first: bool) { - use crate::PendingNetEvent; - let mut events = statics::pending_events().lock().unwrap(); - if completion != 0 { - let before_error = events - .iter() - .position(|event| matches!(event, PendingNetEvent::Error(id, _) if *id == handle)) - .unwrap_or(events.len()); - events.insert( - before_error, - PendingNetEvent::WriteComplete( - handle, - completion, - Some("Socket is closed".to_string()), - ), - ); - } - if first { - events.push(PendingNetEvent::Error( - handle, - "Socket is closed".to_string(), - )); - events.push(PendingNetEvent::Close(handle)); - } - drop(events); - perry_ffi::notify_main_thread(); -} - -/// `socket.write(chunk)` under the name the static NATIVE_MODULE_TABLE path -/// emits. Delegates to the collision-proof [`js_ext_net_socket_write`] via a -/// crate-local call, so even when the bundled stdlib's same-named twin wins the -/// link this body still reaches ext-net's own registry. -/// -/// # Safety -/// -/// See [`js_ext_net_socket_write`]. -#[no_mangle] -pub unsafe extern "C" fn js_net_socket_write(handle: i64, chunk_bits: i64) { - // Deliberately `()`: the bundled stdlib twin of this shared name returns - // nothing, and a duplicate symbol must keep one signature. Callers that - // need `write()`'s boolean use the distinct `js_ext_net_socket_write*`. - js_ext_net_socket_write(handle, chunk_bits); -} - -unsafe fn socket_completion(values: [f64; 3]) -> i64 { - extern "C" { - fn js_value_is_closure(value_bits: i64) -> i32; - } - values - .into_iter() - .find(|value| js_value_is_closure(value.to_bits() as i64) != 0) - .map(|callback| { - const POINTER_MASK: u64 = 0x0000_FFFF_FFFF_FFFF; - (callback.to_bits() & POINTER_MASK) as i64 - }) - .unwrap_or(0) -} - -fn register_socket_completion(handle: i64, callback: i64) -> u64 { - static NEXT_COMPLETION: AtomicU64 = AtomicU64::new(1); - if callback == 0 { - return 0; - } - let token = NEXT_COMPLETION.fetch_add(1, Ordering::Relaxed); - socket_completions() - .lock() - .unwrap() - .insert(token, (handle, vec![callback])); - token -} - -/// Full Node overload for `socket.write(chunk[, encoding][, callback])`. -/// -/// Returns Node's boolean result (#11111): `true` while `writableLength` -/// stays under `writableHighWaterMark`, `false` once it reaches it — and a -/// `false` is always followed by `'drain'` when the queue empties. -#[no_mangle] -pub unsafe extern "C" fn js_ext_net_socket_write3( - handle: i64, - chunk: f64, - encoding_or_callback: f64, - callback: f64, -) -> f64 { - let roots = perry_ffi::TransientRootScope::enter(); - let callback = roots.root_nanbox(callback); - let encoding_or_callback = roots.root_nanbox(encoding_or_callback); - let completion = socket_completion([chunk, encoding_or_callback.get(), callback.get()]); - let completion = register_socket_completion(handle, completion); - let Some(bytes) = crate::jsvalue_to_socket_bytes(chunk) else { - if completion != 0 { - dispatch_socket_completion( - completion, - Some("Invalid data passed to socket.write".to_string()), - ); - } - return write_return_value(handle, false); - }; - let accepted = enqueue_socket_write(handle, bytes, completion); - write_return_value(handle, accepted) -} - -/// `socket.end([data])` — optionally write a final chunk, then half-close the -/// write side (#1852). `undefined`/`null` (the no-arg form, padded with -/// `TAG_UNDEFINED`) yields `None` and we just send FIN. -/// -/// Carries a DISTINCT `#[no_mangle]` symbol for the same reason as -/// [`js_ext_net_socket_write`] — the shared `js_net_socket_end` name collides -/// with the bundled stdlib twin, so the dispatch fallback would drop the -/// optional final chunk into the bundled twin's empty registry. (#5021.) -/// -/// # Safety -/// -/// `chunk_bits` must be a valid NaN-boxed JS value; string / Buffer pointers -/// must reference live runtime allocations. -#[no_mangle] -pub unsafe extern "C" fn js_ext_net_socket_end(handle: i64, chunk_bits: i64) { - // Decode the GC-managed input before provider init can run user hooks and - // move it. Only owned bytes survive across that callback boundary. - let final_bytes = crate::jsvalue_to_socket_bytes(f64::from_bits(chunk_bits as u64)); - let trigger = statics::sockets().lock().ok().and_then(|sockets| { - sockets - .get(&handle) - .and_then(|socket| (socket.shutdown_async_id == 0).then_some(socket.tcp_async_id)) - }); - if let Some(trigger) = trigger { - let async_id = crate::init_provider_with_trigger(b"SHUTDOWNWRAP", trigger); - if let Some(socket) = statics::sockets().lock().unwrap().get_mut(&handle) { - socket.shutdown_async_id = async_id; - } - } - let mut sockets = statics::sockets().lock().unwrap(); - let failure = if let Some(s) = sockets.get_mut(&handle) { - if let Some(bytes) = final_bytes { - if !bytes.is_empty() { - let _ = s.command(handle, crate::SocketCommand::Write(bytes, 0)); - } - } - // #10465 — `writableEnded` (and `writable`) flip as soon as `.end()` - // is CALLED, per Node's docs, not once the FIN actually flushes. - s.writable_ended = true; - // Routed through `command`, the one place that knows which thread - // owns the socket's loop. - s.command(handle, crate::SocketCommand::End(0)).err() - } else { - None - }; - drop(sockets); - if let Some(message) = failure { - crate::turnloop_io::submission_failed(handle, 0, message); - } -} - -/// `socket.end([data])` under the name the static NATIVE_MODULE_TABLE path -/// emits. Delegates to the collision-proof [`js_ext_net_socket_end`]. -/// -/// # Safety -/// -/// See [`js_ext_net_socket_end`]. -#[no_mangle] -pub unsafe extern "C" fn js_net_socket_end(handle: i64, chunk_bits: i64) { - js_ext_net_socket_end(handle, chunk_bits); -} - -/// Full Node overload for `socket.end([data][, encoding][, callback])`. -#[no_mangle] -pub unsafe extern "C" fn js_ext_net_socket_end3( - handle: i64, - chunk_or_callback: f64, - encoding_or_callback: f64, - callback: f64, -) { - let roots = perry_ffi::TransientRootScope::enter(); - let chunk_or_callback = roots.root_nanbox(chunk_or_callback); - let encoding_or_callback = roots.root_nanbox(encoding_or_callback); - let callback = roots.root_nanbox(callback); - let completion = socket_completion([ - chunk_or_callback.get(), - encoding_or_callback.get(), - callback.get(), - ]); - let completion = register_socket_completion(handle, completion); - let final_bytes = crate::jsvalue_to_socket_bytes(chunk_or_callback.get()); - let trigger = statics::sockets().lock().ok().and_then(|sockets| { - sockets - .get(&handle) - .and_then(|socket| (socket.shutdown_async_id == 0).then_some(socket.tcp_async_id)) - }); - if let Some(trigger) = trigger { - let async_id = crate::init_provider_with_trigger(b"SHUTDOWNWRAP", trigger); - if let Some(socket) = statics::sockets().lock().unwrap().get_mut(&handle) { - socket.shutdown_async_id = async_id; - } - } - let mut sockets = statics::sockets().lock().unwrap(); - let failure = if let Some(socket) = sockets.get_mut(&handle) { - if let Some(bytes) = final_bytes.filter(|bytes| !bytes.is_empty()) { - let _ = socket.command(handle, crate::SocketCommand::Write(bytes, 0)); - } - // #10465 — see the sibling note in `js_ext_net_socket_end`. - socket.writable_ended = true; - socket - .command(handle, crate::SocketCommand::End(completion)) - .err() - } else { - if completion != 0 { - socket_completions().lock().unwrap().remove(&completion); - } - None - }; - drop(sockets); - if let Some(message) = failure { - crate::turnloop_io::submission_failed(handle, completion, message); - } -} - -/// `socket.destroy()` — hard close. Flags the handle destroyed (so -/// `socket.destroyed` / `readyState` reflect it) and sends the teardown -/// command. -/// -/// # Safety -/// -/// `handle` must be a registered socket id (raw, NOT NaN-boxed). -#[no_mangle] -pub unsafe extern "C" fn js_net_socket_destroy(handle: i64) { - js_ext_net_destroy_socket(handle); -} - -/// Destroy an `ext-net` socket by id, operating directly on this crate's -/// socket registry. -/// -/// This carries a DISTINCT `#[no_mangle]` symbol (`js_ext_net_destroy_socket`), -/// deliberately NOT the `js_net_socket_destroy` name that the bundled stdlib -/// net ALSO exports. In a workspace/auto-optimize build both are linked, so -/// `js_net_socket_destroy` is a duplicate symbol bound to whichever twin -/// wins (stdlib's). The handle-dispatch `socket_method` "destroy" arm and the -/// extern wrapper above call THIS uniquely-named entry point instead — a -/// symbol with no twin — so an adopted raw-`'upgrade'` socket is actually -/// marked destroyed in ext-net's own registry rather than in stdlib's empty -/// one, which is what let the event loop drain. (#5010) -#[no_mangle] -pub extern "C" fn js_ext_net_destroy_socket(handle: i64) { - let mut sockets = statics::sockets().lock().unwrap(); - if let Some(s) = sockets.get_mut(&handle) { - if s.destroyed { - return; - } - s.destroyed = true; - s.is_open = false; - let _ = s.command(handle, crate::SocketCommand::Destroy); - } -} - -// ─── socket.address() ──────────────────────────────────────────────────────── - -/// `socket.address()` — returns a JSON string the codegen's -/// `NR_OBJ_FROM_JSON_STR` kind parses into `{ address, family, port }`. -/// Falls back to `"{}"` (an empty object) on an unconnected handle so -/// `addr && typeof addr === "object"` reads `true` either way — Node -/// returns `{}` on a socket that never connected, not `null`. -/// -/// # Safety -/// -/// `handle` must be a registered socket id (raw, NOT NaN-boxed; the -/// dispatch shim unboxes via `unbox_to_i64` before the FFI call). -#[no_mangle] -pub unsafe extern "C" fn js_net_socket_address(handle: i64) -> *mut StringHeader { - let json = match statics::sockets().lock() { - Ok(g) => match g.get(&handle) { - Some(s) => match s.local_addr { - Some(addr) => { - let family = if addr.is_ipv6() { "IPv6" } else { "IPv4" }; - format!( - "{{\"address\":\"{}\",\"family\":\"{}\",\"port\":{}}}", - addr.ip(), - family, - addr.port() - ) - } - None => "{}".to_string(), - }, - None => "{}".to_string(), - }, - Err(_) => "{}".to_string(), - }; - alloc_string(&json).as_raw() -} - -// ─── socket / server EventEmitter shims ────────────────────────────────────── -// -// The next batch all share the same shape: read the event name, -// mutate `statics::listeners()` (and `statics::once_flags()` for -// `once`), then return the handle for chaining. They're hand-written -// instead of generated because the GC scanner has to keep walking -// the raw `Vec` storage that `js_net_socket_on` / `js_net_server_on` -// already use — no new shape, no scanner change. - -fn read_event(event_ptr: i64) -> Option { - unsafe { string_from_header_i64(event_ptr) } -} - -pub(crate) fn event_name_from_ptr(event_ptr: i64) -> Option { - read_event(event_ptr) -} - -fn register_listener_with_flag(handle: i64, event: String, cb: i64, once: bool) { - register_listener(handle, event, cb, once, false); -} - -/// #10441 — shared by `on`/`once`/`prependListener`/`prependOnceListener`. -/// `prepend` inserts at the FRONT of the listener vector instead of pushing -/// at the back, which is the only difference Node's `prependListener` has -/// from `addListener`/`on` (same once-flag bookkeeping, same pending-data -/// release for a first `'data'` listener). -fn register_listener(handle: i64, event: String, cb: i64, once: bool, prepend: bool) { - if cb == 0 { - return; - } - let releases_pending_data = event == "data"; - { - let mut listeners = statics::listeners().lock().unwrap(); - let vec = listeners - .entry(handle) - .or_default() - .entry(event.clone()) - .or_default(); - if prepend { - vec.insert(0, cb); - } else { - vec.push(cb); - } - } - if once { - let mut flags = statics::once_flags().lock().unwrap(); - flags - .entry(handle) - .or_default() - .entry(event) - .or_default() - .insert(cb); - } - if releases_pending_data { - crate::server_state::release_pending_server_data(handle); - } -} - -/// Issue #2131 — drop any callback pointer flagged as a `once` listener -/// for `(handle, event)` from both the listener vector and the -/// once-flag side table. Called from `lib.rs`'s pump right after each -/// event dispatch so the next emit doesn't re-fire it. The early -/// return on an empty/missing set keeps the steady-state path (no -/// `once` users) lock-light: one map probe + drop. -pub(crate) fn drain_once_listeners(handle: i64, event: &str) { - let to_drop: HashSet = { - let mut once = statics::once_flags().lock().unwrap(); - let Some(per) = once.get_mut(&handle) else { - return; - }; - let Some(set) = per.remove(event) else { - return; - }; - if per.is_empty() { - once.remove(&handle); - } - set - }; - if to_drop.is_empty() { - return; - } - let mut listeners = statics::listeners().lock().unwrap(); - if let Some(per) = listeners.get_mut(&handle) { - if let Some(vec) = per.get_mut(event) { - vec.retain(|cb| !to_drop.contains(cb)); - if vec.is_empty() { - per.remove(event); - } - } - } -} - -fn remove_listener_at_handle(handle: i64, event: &str, cb: i64) { - let mut removed = false; - { - let mut listeners = statics::listeners().lock().unwrap(); - if let Some(per) = listeners.get_mut(&handle) { - if let Some(vec) = per.get_mut(event) { - if let Some(pos) = vec.iter().position(|x| *x == cb) { - vec.remove(pos); - removed = true; - } - if vec.is_empty() { - per.remove(event); - } - } - } - } - if removed { - let mut flags = statics::once_flags().lock().unwrap(); - if let Some(per) = flags.get_mut(&handle) { - if let Some(set) = per.get_mut(event) { - set.remove(&cb); - if set.is_empty() { - per.remove(event); - } - } - if per.is_empty() { - flags.remove(&handle); - } - } - } -} - -fn remove_all_listeners_at_handle(handle: i64, event: Option<&str>) { - { - let mut listeners = statics::listeners().lock().unwrap(); - if let Some(per) = listeners.get_mut(&handle) { - match event { - Some(e) => { - per.remove(e); - } - None => per.clear(), - } - } - } - let mut flags = statics::once_flags().lock().unwrap(); - if let Some(per) = flags.get_mut(&handle) { - match event { - Some(e) => { - per.remove(e); - } - None => per.clear(), - } - if per.is_empty() { - flags.remove(&handle); - } - } -} - -fn listener_count_at_handle(handle: i64, event: &str) -> f64 { - let listeners = statics::listeners().lock().unwrap(); - listeners - .get(&handle) - .and_then(|m| m.get(event)) - .map(|v| v.len() as f64) - .unwrap_or(0.0) -} - -/// Build a JS array-of-strings JSON blob for the event names registered -/// on `handle`. Uses the same `NR_OBJ_FROM_JSON_STR` channel the codegen -/// already employs for `server.address()`, so the consumer sees a real -/// array (length, indexing) instead of a raw string. Names are emitted -/// in HashMap iteration order — `Array.isArray(names) && names.length >= N` -/// is the contract the parity test pins, not a specific ordering. -fn event_names_json(handle: i64) -> String { - let listeners = statics::listeners().lock().unwrap(); - let Some(per) = listeners.get(&handle) else { - return "[]".to_string(); - }; - let mut seen: HashSet<&str> = HashSet::new(); - let mut parts: Vec = Vec::new(); - for (name, vec) in per.iter() { - if vec.is_empty() { - continue; - } - if seen.insert(name.as_str()) { - parts.push(format!("\"{}\"", json_escape(name))); - } - } - format!("[{}]", parts.join(",")) -} - -/// Issue #2211 — build a JS array of the listener callbacks registered -/// on `(handle, event)`. Each `cb` slot stores the raw closure pointer -/// (`*const RawClosureHeader`) cast to `i64`; we NaN-box it back as -/// POINTER_TAG so the returned array is full of callable JS values. -/// Both `listeners()` and `rawListeners()` go through this helper — Node -/// returns the wrapped onceWrapper for `rawListeners` and the unwrapped -/// callback for `listeners`, but Perry's `once`-listener implementation -/// drains the entry from the listener vector on first emit (the -/// once-flag side table is just a removal set), so the wrap/unwrap -/// distinction never observes a difference for callers that only ask -/// before any event has fired. Matching Node's "the array is a real -/// snapshot of current listeners" is what the -/// `socket.listeners('timeout').length` check needs. -fn listeners_array_for_event(handle: i64, event: &str) -> *mut ArrayHeader { - let snapshot: Vec = statics::listeners() - .lock() - .ok() - .and_then(|m| m.get(&handle).and_then(|p| p.get(event)).cloned()) - .unwrap_or_default(); - let mut arr = unsafe { perry_ffi::js_array_alloc(snapshot.len() as u32) }; - for cb in snapshot { - if cb == 0 { - continue; - } - let value = JsValue::from_object_ptr(cb as *mut u8); - arr = unsafe { perry_ffi::js_array_push(arr, value) }; - } - arr -} - -fn json_escape(s: &str) -> String { - let mut out = String::with_capacity(s.len()); - for ch in s.chars() { - match ch { - '"' => out.push_str("\\\""), - '\\' => out.push_str("\\\\"), - '\n' => out.push_str("\\n"), - '\r' => out.push_str("\\r"), - '\t' => out.push_str("\\t"), - c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)), - c => out.push(c), - } - } - out -} - -// ─── socket.* FFI exports ──────────────────────────────────────────────────── - -/// `socket.once(event, cb)` — register a one-shot listener. -/// -/// # Safety -/// -/// `event_ptr` must be null or a Perry-runtime `StringHeader` pointer -/// cast to `i64`. `cb` is a raw `*const RawClosureHeader` as `i64`. -#[no_mangle] -pub unsafe extern "C" fn js_net_socket_once(handle: i64, event_ptr: i64, cb: i64) -> i64 { - crate::ensure_gc_scanner_registered(); - if let Some(event) = read_event(event_ptr) { - register_listener_with_flag(handle, event, cb, true); - } - handle -} - -/// `socket.prependListener(event, cb)` — like `.on()`/`.addListener()` but -/// inserts at the FRONT of the listener list, so this callback fires before -/// any listener already registered for `event`. #10441: pre-fix, neither the -/// dynamic (untyped-receiver) dispatch nor the typed `net.Socket` codegen -/// table had an entry for this method at all — it silently read `undefined` -/// and calling it was a no-op (ioredis/iovalkey's RESP parser attach via -/// `stream.prependListener("data", …)` never saw a byte). -/// -/// # Safety -/// -/// Same as [`js_net_socket_once`]. -#[no_mangle] -pub unsafe extern "C" fn js_net_socket_prepend_listener( - handle: i64, - event_ptr: i64, - cb: i64, -) -> i64 { - crate::ensure_gc_scanner_registered(); - if let Some(event) = read_event(event_ptr) { - register_listener(handle, event, cb, false, true); - } - handle -} - -/// `socket.prependOnceListener(event, cb)` — the front-inserting, one-shot -/// combination of [`js_net_socket_prepend_listener`] and -/// [`js_net_socket_once`]. #10441. -/// -/// # Safety -/// -/// Same as [`js_net_socket_once`]. -#[no_mangle] -pub unsafe extern "C" fn js_net_socket_prepend_once_listener( - handle: i64, - event_ptr: i64, - cb: i64, -) -> i64 { - crate::ensure_gc_scanner_registered(); - if let Some(event) = read_event(event_ptr) { - register_listener(handle, event, cb, true, true); - } - handle -} - -/// `socket.removeListener(event, cb)` — remove the first matching cb. -/// -/// # Safety -/// -/// Same as `js_net_socket_once`. -#[no_mangle] -pub unsafe extern "C" fn js_net_socket_remove_listener( - handle: i64, - event_ptr: i64, - cb: i64, -) -> i64 { - if let Some(event) = read_event(event_ptr) { - remove_listener_at_handle(handle, &event, cb); - } - handle -} - -/// `socket.removeAllListeners([event])` — drop every listener for the -/// given event, or every event when `event_ptr` is null. Returns the -/// handle for chaining (Node semantics). -/// -/// # Safety -/// -/// `event_ptr` may be null (meaning "all events") or a Perry-runtime -/// `StringHeader` pointer cast to `i64`. -#[no_mangle] -pub unsafe extern "C" fn js_net_socket_remove_all_listeners(handle: i64, event_ptr: i64) -> i64 { - let event = read_event(event_ptr); - remove_all_listeners_at_handle(handle, event.as_deref()); - handle -} - -/// `socket.listenerCount(event)` — count registered listeners for `event`. -/// -/// # Safety -/// -/// `event_ptr` must be null or a Perry-runtime `StringHeader` pointer. -#[no_mangle] -pub unsafe extern "C" fn js_net_socket_listener_count(handle: i64, event_ptr: i64) -> f64 { - let Some(event) = read_event(event_ptr) else { - return 0.0; - }; - let public = listener_count_at_handle(handle, &event); - let internal = crate::statics::http_agent_phases() - .lock() - .unwrap() - .get(&handle) - .map(|active| match event.as_str() { - "error" => 1.0, - "end" if *active => 2.0, - "end" => 1.0, - _ => 0.0, - }) - .unwrap_or(0.0); - public + internal -} - -/// Collision-proof listener-count entry for external net handles. -#[no_mangle] -pub unsafe extern "C" fn js_ext_net_socket_listener_count(handle: i64, event_ptr: i64) -> f64 { - js_net_socket_listener_count(handle, event_ptr) -} - -/// Module-level `events.getMaxListeners(socket)` bridge. -#[no_mangle] -pub extern "C" fn js_ext_net_socket_get_max_listeners(handle: i64) -> f64 { - crate::statics::max_listeners() - .lock() - .unwrap() - .get(&handle) - .copied() - .unwrap_or(10.0) -} - -/// Module-level `events.setMaxListeners(n, socket)` bridge. -#[no_mangle] -pub extern "C" fn js_ext_net_socket_set_max_listeners(handle: i64, n: f64) -> f64 { - if crate::is_net_socket_handle(handle) { - crate::statics::max_listeners() - .lock() - .unwrap() - .insert(handle, n); - } - n -} - -/// `socket.eventNames()` — return an array of registered event names. -/// Emits JSON for the codegen's `NR_OBJ_FROM_JSON_STR` return kind so -/// callers get a true array, not a string. -#[no_mangle] -pub unsafe extern "C" fn js_net_socket_event_names(handle: i64) -> *mut StringHeader { - let json = event_names_json(handle); - alloc_string(&json).as_raw() -} - -/// Issue #2211 — `socket.listeners(event)` / `socket.rawListeners(event)`. -/// Both return a JS array of the registered callbacks. The codegen -/// dispatches this through `NR_PTR`, so the raw ArrayHeader pointer is -/// NaN-boxed with POINTER_TAG and reaches the caller as a real JS array -/// (length, indexing, iteration). `rawListeners` shares the same impl -/// because Perry collapses `once`-registered callbacks into the -/// listener vector — see the helper's doc comment for the -/// once-wrap/unwrap discussion. -/// -/// # Safety -/// -/// `event_ptr` must be null or a Perry-runtime `StringHeader` pointer. -#[no_mangle] -pub unsafe extern "C" fn js_net_socket_listeners(handle: i64, event_ptr: i64) -> i64 { - let Some(event) = read_event(event_ptr) else { - return unsafe { perry_ffi::js_array_alloc(0) } as i64; - }; - listeners_array_for_event(handle, &event) as i64 -} - -#[no_mangle] -pub unsafe extern "C" fn js_ext_net_socket_listeners(handle: i64, event_ptr: i64) -> i64 { - js_net_socket_listeners(handle, event_ptr) -} - -/// `socket.rawListeners(event)` — see `js_net_socket_listeners`. -/// -/// # Safety -/// -/// Same as `js_net_socket_listeners`. -#[no_mangle] -pub unsafe extern "C" fn js_net_socket_raw_listeners(handle: i64, event_ptr: i64) -> i64 { - js_net_socket_listeners(handle, event_ptr) -} - -/// `socket.resetAndDestroy()` — Node treats this as "send RST then -/// destroy" but exposes the same teardown surface as `destroy()` from -/// the caller's point of view (the `'close'` event still fires). We -/// alias to the destroy command for now: tests that only assert -/// "callable + closes cleanly" pass byte-for-byte with Node, and the -/// RST-vs-FIN distinction is invisible to the connected peer in the -/// pure-JS test cases that exercise this path (the peer just sees an -/// abrupt close either way). -#[no_mangle] -pub unsafe extern "C" fn js_net_socket_reset_and_destroy(handle: i64) -> i64 { - crate::js_net_socket_destroy(handle); - handle -} - -// ─── server.* FFI exports (mirror of the socket surface) ───────────────────── - -/// `server.once(event, cb)` — register a one-shot server-level listener. -/// -/// # Safety -/// -/// See `js_net_socket_once`. -#[no_mangle] -pub unsafe extern "C" fn js_net_server_once(handle: i64, event_ptr: i64, cb: i64) -> i64 { - crate::ensure_gc_scanner_registered(); - if let Some(event) = read_event(event_ptr) { - register_listener_with_flag(handle, event, cb, true); - } - handle -} - -/// `server.prependListener(event, cb)` — front-inserting `on` (#11227). -/// -/// # Safety -/// -/// See `js_net_socket_once`. -#[no_mangle] -pub unsafe extern "C" fn js_net_server_prepend_listener( - handle: i64, - event_ptr: i64, - cb: i64, -) -> i64 { - crate::ensure_gc_scanner_registered(); - if let Some(event) = read_event(event_ptr) { - register_listener(handle, event, cb, false, true); - } - handle -} - -/// `server.prependOnceListener(event, cb)` — front-inserting `once` (#11227). -/// -/// # Safety -/// -/// See `js_net_socket_once`. -#[no_mangle] -pub unsafe extern "C" fn js_net_server_prepend_once_listener( - handle: i64, - event_ptr: i64, - cb: i64, -) -> i64 { - crate::ensure_gc_scanner_registered(); - if let Some(event) = read_event(event_ptr) { - register_listener(handle, event, cb, true, true); - } - handle -} - -/// `server.removeListener(event, cb)`. -/// -/// # Safety -/// -/// See `js_net_socket_once`. -#[no_mangle] -pub unsafe extern "C" fn js_net_server_remove_listener( - handle: i64, - event_ptr: i64, - cb: i64, -) -> i64 { - if let Some(event) = read_event(event_ptr) { - remove_listener_at_handle(handle, &event, cb); - } - handle -} - -/// `server.removeAllListeners([event])`. -/// -/// # Safety -/// -/// See `js_net_socket_remove_all_listeners`. -#[no_mangle] -pub unsafe extern "C" fn js_net_server_remove_all_listeners(handle: i64, event_ptr: i64) -> i64 { - let event = read_event(event_ptr); - remove_all_listeners_at_handle(handle, event.as_deref()); - handle -} - -/// `server.listenerCount(event)`. -/// -/// # Safety -/// -/// See `js_net_socket_listener_count`. -#[no_mangle] -pub unsafe extern "C" fn js_net_server_listener_count(handle: i64, event_ptr: i64) -> f64 { - let Some(event) = read_event(event_ptr) else { - return 0.0; - }; - listener_count_at_handle(handle, &event) -} - -/// `server.eventNames()`. -#[no_mangle] -pub unsafe extern "C" fn js_net_server_event_names(handle: i64) -> *mut StringHeader { - let json = event_names_json(handle); - alloc_string(&json).as_raw() -} - -/// `server.listeners(event)` — mirror of `js_net_socket_listeners` since -/// net.Server and net.Socket share the `statics::listeners()` keyed by -/// handle id. Same NR_PTR/POINTER_TAG return contract. -/// -/// # Safety -/// -/// `event_ptr` must be null or a Perry-runtime `StringHeader` pointer. -#[no_mangle] -pub unsafe extern "C" fn js_net_server_listeners(handle: i64, event_ptr: i64) -> i64 { - let Some(event) = read_event(event_ptr) else { - return unsafe { perry_ffi::js_array_alloc(0) } as i64; - }; - listeners_array_for_event(handle, &event) as i64 -} - -/// `server.rawListeners(event)` — see `js_net_server_listeners`. -/// -/// # Safety -/// -/// Same as `js_net_server_listeners`. -#[no_mangle] -pub unsafe extern "C" fn js_net_server_raw_listeners(handle: i64, event_ptr: i64) -> i64 { - js_net_server_listeners(handle, event_ptr) -} - -#[cfg(test)] -mod tests { - use super::*; - use std::collections::HashMap; - - fn reset_handle(handle: i64) { - statics::listeners().lock().unwrap().remove(&handle); - statics::once_flags().lock().unwrap().remove(&handle); - } - - /// `once` listener appears in both the listener vector AND the - /// once-flag set; after `remove_listener_at_handle` runs it - /// disappears from both. - #[test] - fn once_register_and_remove_round_trip() { - let handle = -91_234; - reset_handle(handle); - - register_listener_with_flag(handle, "data".to_string(), 0xCAFE, true); - register_listener_with_flag(handle, "data".to_string(), 0xBEEF, false); - - let listener_count = listener_count_at_handle(handle, "data"); - assert_eq!(listener_count, 2.0); - - let flags_has_once = statics::once_flags() - .lock() - .unwrap() - .get(&handle) - .and_then(|m| m.get("data")) - .is_some_and(|s| s.contains(&0xCAFE_i64) && !s.contains(&0xBEEF_i64)); - assert!(flags_has_once); - - remove_listener_at_handle(handle, "data", 0xCAFE); - assert_eq!(listener_count_at_handle(handle, "data"), 1.0); - - let flags_cleared = statics::once_flags() - .lock() - .unwrap() - .get(&handle) - .and_then(|m| m.get("data")) - .is_none(); - assert!(flags_cleared); - - reset_handle(handle); - } - - /// `removeAllListeners(None)` clears everything; passing an event - /// only clears that event. - #[test] - fn remove_all_listeners_scope() { - let handle = -91_235; - reset_handle(handle); - - register_listener_with_flag(handle, "data".to_string(), 1, false); - register_listener_with_flag(handle, "end".to_string(), 2, true); - - remove_all_listeners_at_handle(handle, Some("data")); - assert_eq!(listener_count_at_handle(handle, "data"), 0.0); - assert_eq!(listener_count_at_handle(handle, "end"), 1.0); - - remove_all_listeners_at_handle(handle, None); - assert_eq!(listener_count_at_handle(handle, "end"), 0.0); - let no_once_left = statics::once_flags().lock().unwrap().get(&handle).is_none(); - assert!(no_once_left); - - reset_handle(handle); - } - - /// Issue #2211 — `listeners_array_for_event` returns an - /// ArrayHeader holding one NaN-boxed POINTER_TAG value per - /// registered callback. The pointer round-trips bit-exact so the - /// runtime sees the closure handle the original `on()` call - /// stored. - #[test] - fn listeners_array_round_trips_callback_pointers() { - let handle = -91_237; - reset_handle(handle); - - // Use raw pointer-shaped values (high bit clear) — the helper - // only re-NaN-boxes them, never dereferences. - register_listener_with_flag(handle, "timeout".to_string(), 0x1234, false); - register_listener_with_flag(handle, "timeout".to_string(), 0x5678, false); - - let arr = listeners_array_for_event(handle, "timeout"); - assert!(!arr.is_null()); - let len = unsafe { (*arr).length }; - assert_eq!(len, 2); - - let v0 = unsafe { perry_ffi::js_array_get(arr, 0) }; - let v1 = unsafe { perry_ffi::js_array_get(arr, 1) }; - assert!(v0.is_pointer()); - assert!(v1.is_pointer()); - assert_eq!(v0.as_pointer::() as usize, 0x1234); - assert_eq!(v1.as_pointer::() as usize, 0x5678); - - // Unknown event name → empty array (zero allocations beyond - // the header), not a panic. - let empty = listeners_array_for_event(handle, "never"); - assert!(!empty.is_null()); - assert_eq!(unsafe { (*empty).length }, 0); - - reset_handle(handle); - } - - /// `eventNames` JSON survives a basic event name with no escaping - /// drama; empty-listener events are filtered. - #[test] - fn event_names_emits_json_array() { - let handle = -91_236; - reset_handle(handle); - - // Seed two events with listeners + one event with an empty vec - // (shouldn't appear in the result). - { - let mut listeners = statics::listeners().lock().unwrap(); - let per = listeners.entry(handle).or_default(); - per.insert("data".to_string(), vec![10]); - per.insert("end".to_string(), vec![11]); - per.insert("orphan".to_string(), Vec::new()); - let _: &HashMap> = per; - } - - let json = event_names_json(handle); - assert!(json.starts_with('[')); - assert!(json.contains("\"data\"")); - assert!(json.contains("\"end\"")); - assert!(!json.contains("\"orphan\"")); - - reset_handle(handle); - } - - #[test] - fn rejected_write_does_not_increase_bytes_written() { - let handle = -91_238; - // A socket whose connect never reached the loop refuses the write. - statics::sockets() - .lock() - .unwrap() - .insert(handle, crate::SocketState::for_test(false)); - - enqueue_socket_write(handle, vec![1, 2, 3], 0); - assert_eq!(unsafe { js_net_socket_get_bytes_written(handle) }, 0.0); - - statics::sockets().lock().unwrap().remove(&handle); - } - - #[test] - fn bytes_written_includes_queue_then_keeps_only_dispatched_progress_on_close() { - let handle = -91_239; - // Model the queue reported by the driver for an accepted write. - let mut socket = crate::SocketState::for_test(false); - socket.bytes_queued = 4; - statics::sockets().lock().unwrap().insert(handle, socket); - assert_eq!(unsafe { js_net_socket_get_bytes_written(handle) }, 4.0); - // Two bytes reached the wire (what the write sink records), two are - // still queued when the socket closes. - if let Some(socket) = statics::sockets().lock().unwrap().get_mut(&handle) { - socket.bytes_queued -= 2; - socket.bytes_written += 2; - } - assert_eq!(unsafe { js_net_socket_get_bytes_written(handle) }, 4.0); - crate::server_state::mark_socket_closed(handle); - assert_eq!(unsafe { js_net_socket_get_bytes_written(handle) }, 2.0); - - statics::sockets().lock().unwrap().remove(&handle); - } - - fn js_bool(b: bool) -> u64 { - JsValue::from_bool(b).bits() - } - - /// #11111 — `write()` is Node's boolean, judged after the chunk is - /// counted, and a `false` arms `writableNeedDrain`. - #[test] - fn write_returns_node_boolean_against_the_high_water_mark() { - let handle = -91_240; - // A socket whose connect has started: `bytes_queued` is what the - // driver reports after each accepted write, set directly here. - let mut socket = crate::SocketState::for_test(true); - socket.awaiting_connect = false; - statics::sockets().lock().unwrap().insert(handle, socket); - let set_queued = |n: u64| { - if let Some(s) = statics::sockets().lock().unwrap().get_mut(&handle) { - s.bytes_queued = n; - } - }; - - set_queued(5); - assert_eq!(write_return_value(handle, true).to_bits(), js_bool(true)); - assert_eq!( - unsafe { js_net_socket_get_writable_need_drain(handle) }.to_bits(), - js_bool(false) - ); - - let hwm = WRITABLE_HIGH_WATER_MARK; - set_queued(hwm + 5); - assert_eq!( - write_return_value(handle, true).to_bits(), - js_bool(false), - "a chunk that reaches the high-water mark returns false" - ); - assert_eq!( - unsafe { js_net_socket_get_writable_length(handle) }, - (hwm + 5) as f64 - ); - assert_eq!( - unsafe { js_net_socket_get_writable_need_drain(handle) }.to_bits(), - js_bool(true) - ); - assert_eq!( - unsafe { js_net_socket_get_writable_high_water_mark(handle) }, - 65536.0 - ); - - statics::sockets().lock().unwrap().remove(&handle); - } - - /// CodeRabbit on #11130: `new net.Socket()` written before `connect()` is - /// `false` in Node (the write fails with `ERR_SOCKET_CLOSED`), for a small - /// chunk too, and it must not leave a `'drain'` owed that nothing clears. - #[test] - fn write_before_connect_is_called_returns_false_and_owes_no_drain() { - let handle = -91_242; - statics::sockets() - .lock() - .unwrap() - .insert(handle, crate::SocketState::for_test(true)); - let accepted = enqueue_socket_write(handle, vec![0; 2], 0); - assert_eq!( - write_return_value(handle, accepted).to_bits(), - js_bool(false) - ); - let accepted = enqueue_socket_write(handle, vec![0; 65_536], 0); - assert_eq!( - write_return_value(handle, accepted).to_bits(), - js_bool(false) - ); - assert_eq!( - unsafe { js_net_socket_get_writable_need_drain(handle) }.to_bits(), - js_bool(false), - "no drain is owed for bytes that will never be flushed" - ); - statics::sockets().lock().unwrap().remove(&handle); - super::unconnected_write_tests::take_events(handle); - } - - /// A refused write, or one after `end()`, is `false` — never `undefined`. - #[test] - fn refused_or_ended_writes_return_false() { - let handle = -91_241; - statics::sockets() - .lock() - .unwrap() - .insert(handle, crate::SocketState::for_test(false)); - let accepted = enqueue_socket_write(handle, vec![1], 0); - assert!(!accepted, "a socket whose connect never started refuses"); - assert_eq!( - write_return_value(handle, accepted).to_bits(), - js_bool(false) - ); - - if let Some(socket) = statics::sockets().lock().unwrap().get_mut(&handle) { - *socket = crate::SocketState::for_test(true); - socket.writable_ended = true; - } - let accepted = enqueue_socket_write(handle, vec![1], 0); - assert_eq!( - write_return_value(handle, accepted).to_bits(), - js_bool(false) - ); - - statics::sockets().lock().unwrap().remove(&handle); - super::unconnected_write_tests::take_events(handle); - assert_eq!( - write_return_value(handle, true).to_bits(), - js_bool(false), - "an unknown socket is false too" - ); - } - - /// `'drain'` is owed exactly once per `false`, only when the queue is - /// empty, and never once the writable side is ending or destroyed. - #[test] - fn drain_fires_once_when_the_queue_empties_and_not_after_end() { - let mut socket = crate::SocketState::for_test(true); - assert!(!take_drain(&mut socket), "no false return, no drain"); - - socket.need_drain = true; - socket.bytes_queued = 10; - assert!(!take_drain(&mut socket), "not while bytes are still queued"); - socket.bytes_queued = 0; - assert!(take_drain(&mut socket)); - assert!( - !socket.need_drain, - "writableNeedDrain clears with the event" - ); - assert!(!take_drain(&mut socket), "exactly once"); - - socket.need_drain = true; - socket.writable_ended = true; - assert!(!take_drain(&mut socket), "Node skips 'drain' while ending"); - socket.writable_ended = false; - socket.destroyed = true; - assert!(!take_drain(&mut socket), "or once destroyed"); - } -} - -#[cfg(test)] -#[path = "unconnected_write_tests.rs"] -mod unconnected_write_tests; diff --git a/crates/perry-ext-net/src/native_dispatch.rs b/crates/perry-ext-net/src/native_dispatch.rs index 5561c402ed..bbbe6cef62 100644 --- a/crates/perry-ext-net/src/native_dispatch.rs +++ b/crates/perry-ext-net/src/native_dispatch.rs @@ -100,11 +100,23 @@ unsafe extern "C" fn js_ext_net_native_dispatch( } }; match method { + "Socket.init" => crate::payload_socket::initialize_socket( + crate::payload_io::ROUTE, + arg(1), + u64::MAX, + Some(arg(0)), + ), + "Server.init" => crate::payload_server::initialize_server( + crate::payload_io::ROUTE, + arg(1), + arg(2), + Some(arg(0)), + ), "connect" | "createConnection" => { handle_value(crate::js_ext_net_socket_connect(arg(0), arg(1), arg(2))) } "createServer" | "Server" => handle_value(create_server(args_ptr, args_len)), - "Socket" | "Stream" => handle_value(crate::js_net_socket_alloc()), + "Socket" | "Stream" => crate::payload_socket::new_socket(crate::payload_io::ROUTE, arg(0)), "isIP" => crate::ip::js_net_is_ip(js_value_to_str_ptr_for_ffi(arg(0))), "isIPv4" => crate::ip::js_net_is_ipv4(js_value_to_str_ptr_for_ffi(arg(0))), "isIPv6" => crate::ip::js_net_is_ipv6(js_value_to_str_ptr_for_ffi(arg(0))), diff --git a/crates/perry-ext-net/src/native_transport.rs b/crates/perry-ext-net/src/native_transport.rs new file mode 100644 index 0000000000..2c73beba18 --- /dev/null +++ b/crates/perry-ext-net/src/native_transport.rs @@ -0,0 +1,446 @@ +//! The ordinary Socket transport shared with HTTP and attached protocols. +//! +//! Callers retain sockets, parsers and callbacks as traced JS edges. These +//! functions never reserve an id or translate one to a cell. Core projections +//! are short native borrows; no projection may survive JS, close or reopen. + +use crate::{payload_io as io, payload_socket as socket, payload_transport as p}; +use perry_ffi::native_payload::{OwnerLink, PayloadMiss}; +use perry_ffi::turnloop_net::{self as tl, TransportCore}; +use perry_ffi::{JsValue, TransientRootScope, TransientRootedNanbox}; + +pub const ROUTE: u8 = io::ROUTE; + +/// A temporary rooted receiver for a protocol operation. Never put this in +/// a payload or a pending queue. Read `value()` again after allocation or JS. +pub struct RootedSocket { + root: TransientRootedNanbox, + incarnation: Option, + _scope: TransientRootScope, +} +impl RootedSocket { + pub fn new(value: f64) -> Self { + let scope = TransientRootScope::enter(); + let root = scope.root_nanbox(value); + let incarnation = snapshot(root.get()); + Self { + root, + incarnation, + _scope: scope, + } + } + pub fn value(&self) -> f64 { + self.root.get() + } + pub fn link(&self) -> Result { + socket_link(self.value()) + } + pub fn is_current(&self) -> bool { + self.incarnation + .as_ref() + .is_none_or(|snapshot| matches(self.value(), snapshot)) + } + /// # Safety + /// End the projected borrow before JS, close or reopen. + pub unsafe fn core(&self) -> Result<*mut TransportCore, PayloadMiss> { + if self.is_current() { + core(self.value()) + } else { + Err(PayloadMiss::Closed) + } + } +} + +pub fn new_socket(route: u8, options: f64) -> f64 { + socket::new_socket(route, options) +} +pub fn new_server(options: f64, callback: f64) -> f64 { + crate::payload_server::new_server(ROUTE, options, callback) +} +pub fn enabled() -> bool { + io::enabled() +} +pub fn listen_tcp( + owner: f64, + host: &str, + port: u16, + backlog: u32, + reuse_port: bool, + no_delay: bool, + tls_config: Option>, +) -> Result { + crate::payload_server::listen_tcp(owner, host, port, backlog, reuse_port, no_delay, tls_config) +} +pub fn socket_link(owner: f64) -> Result { + socket::transport_link(owner) +} +pub fn server_link(owner: f64) -> Result { + p::server_link(owner) +} + +/// # Safety +/// Root the owner before calling. End the projection before any operation +/// that can run JS, explicitly close the owner or attach a new incarnation. +pub unsafe fn core(owner: f64) -> Result<*mut TransportCore, PayloadMiss> { + p::socket_core(socket::transport_link(owner)?) +} +/// # Safety +/// Same projection contract as `core`, for a listener. +pub unsafe fn listener_core(owner: f64) -> Result<*mut TransportCore, PayloadMiss> { + p::server_core(p::server_link(owner)?) +} + +/// The result is a JS value; root it before allocating or running JS. +pub fn state(owner: f64) -> f64 { + socket::state(owner) +} +pub fn server_state(owner: f64) -> f64 { + crate::payload_server::state(owner) +} + +/// Snapshot children from every live listener incarnation's ordinary edges. +/// Closed listeners retain their children until the final child's close. +pub fn for_each_server_child(owner: f64, mut visit: impl FnMut(&RootedSocket)) { + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(owner); + let state = scope.root_nanbox(server_state(owner.get())); + let active = scope.root_nanbox(own_get(state.get(), "activeGroup")); + let closing = scope.root_nanbox(own_get(state.get(), "closeCallbacks")); + let mut groups = vec![active]; + let values = |array: f64| -> Vec { + let value = perry_ffi::JsValue::from_bits(array.to_bits()); + if !value.is_pointer() { + return Vec::new(); + } + let array = value.as_pointer::(); + (0..unsafe { perry_ffi::js_array_length(array) }) + .map(|index| f64::from_bits(unsafe { perry_ffi::js_array_get(array, index).bits() })) + .collect() + }; + #[cfg(test)] + let include_closing = + std::env::var("PERRY_NET_A_CHILDREN_SABOTAGE").as_deref() != Ok("old_group"); + #[cfg(not(test))] + let include_closing = true; + if include_closing { + groups.extend( + values(closing.get()) + .into_iter() + .map(|group| scope.root_nanbox(group)), + ); + } + let mut children = Vec::new(); + for group in groups { + if !perry_ffi::JsValue::from_bits(group.get().to_bits()).is_pointer() { + continue; + } + let array = scope.root_nanbox(own_get(group.get(), "children")); + children.extend( + values(array.get()) + .into_iter() + .map(|child| scope.root_nanbox(child)), + ); + } + for child in children { + visit(&RootedSocket::new(child.get())); + } +} +pub fn own_get(object: f64, key: &str) -> f64 { + p::own_get(object, key) +} +pub fn own_set(object: f64, key: &str, value: f64) { + p::own_set(object, key, value) +} + +/// Install the parser's ordinary edge and its memory-only explicit release. +/// +/// # Safety +/// `release` must be static code which neither runs JS nor reaches the +/// driver. The parser's Rust payload must contain no JS values or GC pointers. +pub unsafe fn set_codec(owner: f64, key: &str, codec: f64, release: unsafe fn(f64)) { + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(owner); + let codec = scope.root_nanbox(codec); + let link = socket::link(owner.get()); + let state = scope.root_nanbox(socket::state(owner.get())); + p::own_set(state.get(), key, codec.get()); + let physical = + scope.root_nanbox(unsafe { perry_ffi::native_payload::link_event_owner(link) }.unwrap()); + let physical_state = scope.root_nanbox(socket::state(physical.get())); + p::own_set(physical_state.get(), "codecOwner", owner.get()); + (*p::socket_ptr(link).unwrap_or_else(crate::payload_prototype::throw_miss)) + .ext + .close_codec = Some(release); +} + +pub(crate) fn release_codec(owner: f64) { + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(owner); + let release = unsafe { + socket::transport_link(owner.get()).ok().and_then(|link| { + p::socket_ptr(link) + .ok() + .and_then(|payload| (*payload).ext.close_codec.take()) + }) + }; + if let Some(release) = release { + let link = socket::link(owner.get()); + let physical = scope + .root_nanbox(unsafe { perry_ffi::native_payload::link_event_owner(link) }.unwrap()); + let physical_state = scope.root_nanbox(socket::state(physical.get())); + let codec_owner = scope.root_nanbox(p::own_get(physical_state.get(), "codecOwner")); + p::own_set(physical_state.get(), "codecOwner", p::undefined()); + unsafe { release(codec_owner.get()) }; + } +} + +pub fn set_route(owner: f64, route: u8) -> Result<(), tl::NetError> { + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(owner); + unsafe { p::set_route(socket::link(owner.get()), route) } +} +pub fn emit(owner: f64, event: &str, args: &[f64]) -> f64 { + crate::payload_events::emit(owner, event, args) +} +pub fn set_ref(owner: f64, referenced: bool) { + socket::set_ref(owner, referenced); +} +pub fn has_ref(owner: f64) -> bool { + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(owner); + if socket_link(owner.get()).is_err() { + return false; + } + let state = scope.root_nanbox(state(owner.get())); + JsValue::from_bits(own_get(state.get(), "refed").to_bits()).to_bool() +} +pub fn destroy(owner: f64) { + socket::destroy(owner, p::undefined()); +} +pub fn destroy_error(owner: f64, code: &str, message: &str) { + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(owner); + let error = scope.root_nanbox(socket::error(code, message)); + socket::destroy(owner.get(), error.get()); +} +pub fn eof(owner: f64) { + io::eof(owner); +} +pub fn flow(owner: f64) { + socket::flow(owner); +} +pub fn adopted(owner: f64) { + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(owner); + let link = socket::link(owner.get()); + unsafe { + (*p::socket_ptr(link).unwrap_or_else(crate::payload_prototype::throw_miss)) + .ext + .opened = true; + } + socket::update_addresses(link); +} +pub fn received(owner: f64, len: usize) { + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(owner); + let link = socket::link(owner.get()); + unsafe { + if let Ok(payload) = p::socket_ptr(link) { + (*payload).ext.bytes_read = (*payload).ext.bytes_read.saturating_add(len as u64); + } + } + socket::refresh_timeout(link); +} +pub fn endpoint(owner: f64, peer: bool) -> Option { + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(owner); + let link = socket::link(owner.get()); + unsafe { + let core = p::socket_core(link).ok()?; + if peer { + tl::link_peer_address(&mut *core, link) + } else { + tl::link_local_address(&mut *core, link) + } + } +} +pub fn deadline_arm(owner: f64, ms: u64) { + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(owner); + let link = socket::link(owner.get()); + unsafe { + if let Ok(core) = p::socket_core(link) { + let _ = tl::link_deadline_arm(&mut *core, link, ms); + } + } +} +pub fn deadline_park(owner: f64) { + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(owner); + let link = socket::link(owner.get()); + unsafe { + if let Ok(core) = p::socket_core(link) { + let _ = tl::link_deadline_park(&mut *core, link); + } + } +} +pub fn deadline_cancel(owner: f64) { + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(owner); + let link = socket::link(owner.get()); + unsafe { + if let Ok(core) = p::socket_core(link) { + let _ = tl::link_deadline_cancel(&mut *core, link); + } + } +} +pub fn queued_bytes(owner: f64) -> usize { + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(owner); + let link = socket::link(owner.get()); + unsafe { + p::socket_core(link) + .ok() + .map_or(0, |core| tl::link_queued_bytes(&mut *core, link)) + } +} +pub fn snapshot(owner: f64) -> Option { + socket::transport_link(owner).ok().and_then(io::snapshot) +} +pub fn matches(owner: f64, snapshot: &tl::HandleSnapshot) -> bool { + socket::transport_link(owner) + .ok() + .is_some_and(|link| io::matches(link, snapshot)) +} +pub fn call(callback: f64, this: perry_ffi::JsThis, args: &[f64]) -> f64 { + crate::payload_events::call(callback, this, args) +} +pub fn close_server(owner: f64) { + crate::payload_server::close(owner, p::undefined()); +} +pub fn connect(owner: f64, a: f64, b: f64, c: f64) -> f64 { + socket::connect(owner, a, b, c) +} +pub fn get(owner: f64, key: &str) -> f64 { + socket::get(owner, key) +} + +/// Protocol bytes use the Socket's TLS session and native write accounting. +/// They do not own JS callbacks; the protocol payload retains its own bytes. +pub fn write(owner: f64, bytes: &[u8], user: u64) -> Result { + crate::payload_tls::write(owner, bytes, user) +} +pub fn shutdown(owner: f64, user: u64) -> Result<(), tl::NetError> { + crate::payload_tls::shutdown(owner, user) +} +pub fn install_server_tls( + owner: f64, + config: std::sync::Arc, +) -> Result<(), String> { + crate::payload_tls::install_server(owner, config) +} + +/// Owned certificate facts for a prebuilt client configuration. Callback +/// identity and promises belong to the Socket's ordinary JS state. +#[derive(Clone)] +pub struct ClientTlsMetadata { + pub servername: String, + pub verify: bool, + pub ca: Option>>, + pub certificate_pem: Vec, +} + +pub fn install_client_tls( + owner: f64, + config: std::sync::Arc, + server_name: rustls::pki_types::ServerName<'static>, + metadata: ClientTlsMetadata, +) -> Result<(), String> { + crate::payload_tls::install_client_config( + owner, + config, + server_name, + metadata.servername, + metadata.verify, + crate::TlsClientConfigData::for_cached_config(metadata.ca, metadata.certificate_pem), + ) +} +pub fn tls_installed(owner: f64) -> bool { + socket::transport_link(owner).is_ok_and(crate::payload_tls::installed) +} +pub fn receive_tls(owner: f64, bytes: &[u8]) { + crate::payload_tls::receive(owner, bytes); +} + +/// Common transport completions still belong to the Socket when its parser +/// owns the route. Data/EOF/deadlines remain for the protocol sink to decode. +/// The completion's owed ref roots its cell; this function roots the owner. +/// +/// # Safety +/// `event` must be the live completion supplied by the runtime to this sink. +pub unsafe fn dispatch_common(event: &tl::NetCompletion) -> bool { + let Some(link) = event.link() else { + return false; + }; + let Some(owner) = perry_ffi::native_payload::link_event_owner(link) else { + return true; + }; + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(owner); + if socket::transport_link(owner.get()).is_err() { + return true; + } + match event.kind { + tl::NET_CONNECT => io::connected(owner.get()), + tl::NET_WROTE => { + if crate::payload_tls::installed(link) { + crate::payload_tls::wrote(owner.get(), event.len); + } else { + io::wrote(owner.get(), event.user, event.len, event.queued); + } + } + tl::NET_SHUTDOWN => io::shutdown(owner.get(), event.user), + tl::NET_CLOSED => io::socket_closed(owner.get(), event), + _ => return false, + } + true +} + +/// Install an ordinary emitter listener; callbacks remain solely in JS fields. +pub fn once(owner: f64, event: &str, callback: f64) { + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(owner); + let callback = scope.root_nanbox(callback); + let event = scope.root_nanbox(crate::payload_events::string(event)); + extern "C" { + fn js_node_stream_method_once(owner: i64, event: f64, callback: f64) -> f64; + } + unsafe { + js_node_stream_method_once( + crate::payload_transport::raw_owner(owner.get()), + event.get(), + callback.get(), + ); + } +} + +/// Remove an ordinary emitter listener while keeping all operands rooted. +pub fn remove_listener(owner: f64, event: &str, callback: f64) { + if !socket::is_callback(callback) { + return; + } + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(owner); + let callback = scope.root_nanbox(callback); + let event = scope.root_nanbox(crate::payload_events::string(event)); + extern "C" { + fn js_node_stream_method_remove_listener(owner: i64, event: f64, callback: f64) -> f64; + } + unsafe { + js_node_stream_method_remove_listener( + crate::payload_transport::raw_owner(owner.get()), + event.get(), + callback.get(), + ); + } +} diff --git a/crates/perry-ext-net/src/option_setters.rs b/crates/perry-ext-net/src/option_setters.rs deleted file mode 100644 index b66bffc7a2..0000000000 --- a/crates/perry-ext-net/src/option_setters.rs +++ /dev/null @@ -1,154 +0,0 @@ -//! Chainable no-op option setters for `net.Socket` / `net.Server` (#1852) and -//! the C-ABI bridge to perry-runtime's Node argument validators (#2013). -//! -//! Split out of `lib.rs` to keep that file under the 2000-line gate. The -//! `#[no_mangle]` functions here are codegen dispatch targets (reached by -//! symbol, not by Rust path), so they re-export transparently at the crate -//! root. The `extern "C"` validator declarations are `pub(crate)` so the -//! `server.listen` / `socket.connect` sites in `lib.rs` can call them. - -extern "C" { - // #2013: Node argument validators (perry-runtime/src/net_validate.rs). - // perry-ext-net has no Cargo dependency on perry-runtime, so these are - // reached over the C ABI — the same pattern as the runtime entry points - // declared in `lib.rs`. Each diverges via `js_throw` on bad input and - // returns normally for a valid value. - pub(crate) fn js_net_validate_listen_port(value: f64); - pub(crate) fn js_net_validate_connect_port(value: f64); - fn js_net_validate_socket_timeout(value: f64); - fn js_net_validate_tos(value: f64) -> i32; -} - -// ─── Chainable no-op socket/server options (issue #1852) ───────────────────── -// -// Node's `net.Socket` and `net.Server` expose a family of configuration -// methods that return the instance (`this`) for chaining — `setNoDelay`, -// `setKeepAlive`, `setTimeout`, `setEncoding`, `pause`, `resume`, `ref`, -// `unref`, `cork`, `uncork`, etc. Perry's TCP transport doesn't model TCP -// socket options (Nagle, keep-alive, idle-timeout) or read-pause yet, but -// the methods still need to *exist and be callable*: pre-fix, calling any -// of them threw "x is not a function" (the radar's "value() missing" -// cluster) and aborted the program before the real I/O ever ran. -// -// These shims accept the receiver handle (the dispatch table declares -// `args: &[]`, so user-supplied option args are evaluated for the call but -// not forwarded) and return it unchanged so `sock.setNoDelay(true)` and -// chained forms like `sock.setKeepAlive().setNoDelay()` both type-check and -// keep flowing. The codegen NaN-boxes the returned id with POINTER_TAG -// (NR_PTR), reproducing the original Socket/Server value shape so a -// subsequent method on the result still dispatches. - -/// Chainable no-op for `net.Socket` option setters — returns the socket -/// handle unchanged. -#[no_mangle] -pub extern "C" fn js_net_socket_noop_self(handle: i64) -> i64 { - handle -} - -/// Keep a socket's pending I/O referenced by the process event loop. -#[no_mangle] -pub extern "C" fn js_net_socket_ref(handle: i64) -> i64 { - crate::js_ext_net_socket_set_ref(handle, 1); - handle -} - -/// Allow the process to exit while this socket's pending I/O continues. -#[no_mangle] -pub extern "C" fn js_net_socket_unref(handle: i64) -> i64 { - crate::js_ext_net_socket_set_ref(handle, 0); - handle -} - -/// `socket.setTimeout(msecs, callback?)` — validates `msecs` the way Node does -/// (number → `ERR_INVALID_ARG_TYPE`, non-negative finite → `ERR_OUT_OF_RANGE`, -/// #2013) and then behaves as the chainable no-op (the underlying idle-timeout -/// machinery is not modelled), returning the socket handle for chaining. The -/// optional callback is accepted but, like the other option setters, ignored. -/// -/// # Safety -/// -/// `_callback_i64` is a NaN-boxed JSValue passed as raw bits; it is not -/// dereferenced here. -#[no_mangle] -pub extern "C" fn js_net_socket_set_timeout(handle: i64, msecs: f64, _callback_i64: i64) -> i64 { - unsafe { js_net_validate_socket_timeout(msecs) }; - // #2549 — record the timeout so `socket.timeout` reflects it. Node clears - // (reports `undefined`) when the timeout is set to 0. - if let Some(s) = crate::statics::sockets().lock().unwrap().get_mut(&handle) { - s.timeout = if msecs > 0.0 { - Some(msecs as u64) - } else { - None - }; - } - handle -} - -/// Chainable no-op for `net.Server` option setters — returns the server -/// handle unchanged. -#[no_mangle] -pub extern "C" fn js_net_server_noop_self(handle: i64) -> i64 { - handle -} - -#[no_mangle] -pub extern "C" fn js_net_socket_get_type_of_service(handle: i64) -> f64 { - crate::statics::sockets() - .lock() - .ok() - .and_then(|sockets| sockets.get(&handle).map(|s| s.type_of_service as f64)) - .unwrap_or(0.0) -} - -#[no_mangle] -pub extern "C" fn js_net_socket_set_type_of_service(handle: i64, tos: f64) -> i64 { - let tos = unsafe { js_net_validate_tos(tos) } as u8; - if let Some(s) = crate::statics::sockets().lock().unwrap().get_mut(&handle) { - s.type_of_service = tos; - } - handle -} - -/// `socket.setNoDelay([enable])` — chainable, returns the handle. -/// -/// `TCP_NODELAY` is fixed when turnloop creates the socket: on for a client -/// (`TcpOpts`, Node's default), and the listener's `AcceptDefaults` for an -/// accepted one. turnloop has no socket-option call for a live handle, so a -/// later `setNoDelay()` cannot change it; the flag is not observable from JS -/// either way. This was already true of every turnloop socket before tokio -/// left this crate — the only transport that could apply it was the tokio -/// socket task. Needs a turnloop socket-option API to finish. -/// -/// # Safety -/// -/// `arg_bits` is a NaN-boxed JSValue passed as raw bits (the codegen NA_F64 -/// slot); it is not read. -#[no_mangle] -pub unsafe extern "C" fn js_net_socket_set_no_delay(handle: i64, arg_bits: i64) -> i64 { - let _ = arg_bits; - handle -} - -/// #4973: `socket.setEncoding(enc?)` — record the read encoding so the -/// main-thread pump delivers `'data'` events as decoded strings (Node -/// readable-stream semantics). Calling with no/nullish arg clears it -/// (back to Buffers). Recognized text encodings decode as UTF-8 (lossy); -/// 'hex'/'base64' render their respective text forms in the pump. -/// -/// # Safety -/// `enc_ptr` is either 0 or a StringHeader pointer (codegen NA_STR slot). -#[no_mangle] -pub unsafe extern "C" fn js_net_socket_set_encoding(handle: i64, enc_ptr: i64) -> i64 { - match crate::string_from_header_i64(enc_ptr) { - Some(enc) if !enc.is_empty() => { - crate::statics::encodings() - .lock() - .unwrap() - .insert(handle, enc.to_ascii_lowercase()); - } - _ => { - crate::statics::encodings().lock().unwrap().remove(&handle); - } - } - handle -} diff --git a/crates/perry-ext-net/src/payload_abi.rs b/crates/perry-ext-net/src/payload_abi.rs new file mode 100644 index 0000000000..ee98ef7359 --- /dev/null +++ b/crates/perry-ext-net/src/payload_abi.rs @@ -0,0 +1,1112 @@ +//! Raw-owner compatibility entry points for independently linked archives. +//! These carriers are actual GC object addresses, never transport ids. +use super::*; +use super::{payload_server as server, payload_socket as socket, payload_transport as p}; +use perry_ffi::JsThis; +extern "C" { + fn js_json_stringify(value: f64, hint: u32) -> *mut StringHeader; + fn js_dynamic_object_get_property(owner: f64, key: *const i8, len: usize) -> f64; +} +fn owner(raw: i64) -> f64 { + p::boxed_addr(raw) +} +fn call(raw: i64, name: &str, args: &[f64]) -> f64 { + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(owner(raw)); + let args: Vec<_> = args.iter().map(|arg| scope.root_nanbox(*arg)).collect(); + if p::socket_link(owner.get()).is_err() && p::server_link(owner.get()).is_err() { + super::payload_prototype::throw_miss::<()>(perry_ffi::native_payload::PayloadMiss::Foreign); + } + let callback = scope.root_nanbox(unsafe { + js_dynamic_object_get_property(owner.get(), name.as_ptr() as *const i8, name.len()) + }); + let args: Vec<_> = args.iter().map(|arg| arg.get()).collect(); + super::payload_events::call(callback.get(), JsThis::from_f64(owner.get()), &args) +} +fn event_value(raw: i64) -> f64 { + unsafe { string_from_header_i64(raw) } + .map(|name| super::payload_events::string(&name)) + .unwrap_or_else(p::undefined) +} +fn callback_value(raw: i64) -> f64 { + if raw == 0 { + p::undefined() + } else { + p::boxed_addr(raw) + } +} + +#[no_mangle] +pub unsafe extern "C" fn js_ext_net_socket_read(handle: i64, _size: f64) -> f64 { + let scope = TransientRootScope::enter(); + let handle_root = scope.root_addr(handle); + let _size_root = scope.root_nanbox(_size); + + { + let args = [_size_root.get()]; + call(handle_root.get(), "read", &args) + } +} + +#[no_mangle] +pub unsafe extern "C" fn js_net_socket_read(handle: i64, size: f64) -> f64 { + let scope = TransientRootScope::enter(); + let handle_root = scope.root_addr(handle); + let size_root = scope.root_nanbox(size); + + { + let args = [size_root.get()]; + call(handle_root.get(), "read", &args) + } +} + +#[no_mangle] +pub unsafe extern "C" fn js_net_socket_get_pending(handle: i64) -> f64 { + let scope = TransientRootScope::enter(); + let handle_root = scope.root_addr(handle); + + socket::get(owner(handle_root.get()), "pending") +} + +#[no_mangle] +pub unsafe extern "C" fn js_net_socket_get_connecting(handle: i64) -> f64 { + let scope = TransientRootScope::enter(); + let handle_root = scope.root_addr(handle); + + socket::get(owner(handle_root.get()), "connecting") +} + +#[no_mangle] +pub unsafe extern "C" fn js_net_socket_get_destroyed(handle: i64) -> f64 { + let scope = TransientRootScope::enter(); + let handle_root = scope.root_addr(handle); + + socket::get(owner(handle_root.get()), "destroyed") +} + +#[no_mangle] +pub unsafe extern "C" fn js_net_socket_get_writable(handle: i64) -> f64 { + let scope = TransientRootScope::enter(); + let handle_root = scope.root_addr(handle); + + socket::get(owner(handle_root.get()), "writable") +} + +#[no_mangle] +pub unsafe extern "C" fn js_net_socket_get_readable(handle: i64) -> f64 { + let scope = TransientRootScope::enter(); + let handle_root = scope.root_addr(handle); + + socket::get(owner(handle_root.get()), "readable") +} + +#[no_mangle] +pub unsafe extern "C" fn js_net_socket_get_writable_ended(handle: i64) -> f64 { + let scope = TransientRootScope::enter(); + let handle_root = scope.root_addr(handle); + + socket::get(owner(handle_root.get()), "writableEnded") +} + +#[no_mangle] +pub unsafe extern "C" fn js_net_socket_get_readable_ended(handle: i64) -> f64 { + let scope = TransientRootScope::enter(); + let handle_root = scope.root_addr(handle); + + socket::get(owner(handle_root.get()), "readableEnded") +} + +#[no_mangle] +pub unsafe extern "C" fn js_net_socket_get_writable_state(handle: i64) -> *mut StringHeader { + let scope = TransientRootScope::enter(); + let handle_root = scope.root_addr(handle); + + let value = socket::get(owner(handle_root.get()), "_writableState"); + js_json_stringify(value, 0) +} + +#[no_mangle] +pub unsafe extern "C" fn js_net_socket_get_readable_state(handle: i64) -> *mut StringHeader { + let scope = TransientRootScope::enter(); + let handle_root = scope.root_addr(handle); + + let value = socket::get(owner(handle_root.get()), "_readableState"); + js_json_stringify(value, 0) +} + +#[no_mangle] +pub unsafe extern "C" fn js_net_socket_get_ready_state(handle: i64) -> *mut StringHeader { + let scope = TransientRootScope::enter(); + let handle_root = scope.root_addr(handle); + + let value = socket::get(owner(handle_root.get()), "readyState"); + alloc_string(&jsvalue_to_owned_string(value).unwrap_or_default()).as_raw() +} + +#[no_mangle] +pub unsafe extern "C" fn js_net_socket_get_bytes_read(handle: i64) -> f64 { + let scope = TransientRootScope::enter(); + let handle_root = scope.root_addr(handle); + + socket::get(owner(handle_root.get()), "bytesRead") +} + +#[no_mangle] +pub unsafe extern "C" fn js_net_socket_get_bytes_written(handle: i64) -> f64 { + let scope = TransientRootScope::enter(); + let handle_root = scope.root_addr(handle); + + socket::get(owner(handle_root.get()), "bytesWritten") +} + +#[no_mangle] +pub unsafe extern "C" fn js_net_socket_get_timeout(handle: i64) -> f64 { + let scope = TransientRootScope::enter(); + let handle_root = scope.root_addr(handle); + + socket::get(owner(handle_root.get()), "timeout") +} + +#[no_mangle] +pub unsafe extern "C" fn js_net_socket_get_local_address(handle: i64) -> f64 { + let scope = TransientRootScope::enter(); + let handle_root = scope.root_addr(handle); + + socket::get(owner(handle_root.get()), "localAddress") +} + +#[no_mangle] +pub unsafe extern "C" fn js_net_socket_get_local_port(handle: i64) -> f64 { + let scope = TransientRootScope::enter(); + let handle_root = scope.root_addr(handle); + + socket::get(owner(handle_root.get()), "localPort") +} + +#[no_mangle] +pub unsafe extern "C" fn js_net_socket_get_local_family(handle: i64) -> f64 { + let scope = TransientRootScope::enter(); + let handle_root = scope.root_addr(handle); + + socket::get(owner(handle_root.get()), "localFamily") +} + +#[no_mangle] +pub unsafe extern "C" fn js_net_socket_get_remote_address(handle: i64) -> f64 { + let scope = TransientRootScope::enter(); + let handle_root = scope.root_addr(handle); + + socket::get(owner(handle_root.get()), "remoteAddress") +} + +#[no_mangle] +pub unsafe extern "C" fn js_net_socket_get_remote_port(handle: i64) -> f64 { + let scope = TransientRootScope::enter(); + let handle_root = scope.root_addr(handle); + + socket::get(owner(handle_root.get()), "remotePort") +} + +#[no_mangle] +pub unsafe extern "C" fn js_net_socket_get_remote_family(handle: i64) -> f64 { + let scope = TransientRootScope::enter(); + let handle_root = scope.root_addr(handle); + + socket::get(owner(handle_root.get()), "remoteFamily") +} + +#[no_mangle] +pub unsafe extern "C" fn js_net_socket_get_buffer_size(handle: i64) -> f64 { + let scope = TransientRootScope::enter(); + let handle_root = scope.root_addr(handle); + + socket::get(owner(handle_root.get()), "bufferSize") +} + +#[no_mangle] +pub unsafe extern "C" fn js_net_socket_get_writable_length(handle: i64) -> f64 { + let scope = TransientRootScope::enter(); + let handle_root = scope.root_addr(handle); + + socket::get(owner(handle_root.get()), "writableLength") +} + +#[no_mangle] +pub unsafe extern "C" fn js_net_socket_get_writable_need_drain(handle: i64) -> f64 { + let scope = TransientRootScope::enter(); + let handle_root = scope.root_addr(handle); + + socket::get(owner(handle_root.get()), "writableNeedDrain") +} + +#[no_mangle] +pub unsafe extern "C" fn js_ext_net_socket_write(handle: i64, chunk_bits: i64) -> f64 { + let scope = TransientRootScope::enter(); + let handle_root = scope.root_addr(handle); + let chunk_bits_root = scope.root_nanbox(f64::from_bits(chunk_bits as u64)); + + { + let args = [f64::from_bits(chunk_bits_root.get().to_bits() as i64 as u64)]; + call(handle_root.get(), "write", &args) + } +} + +#[no_mangle] +pub unsafe extern "C" fn js_net_socket_write(handle: i64, chunk_bits: i64) { + let scope = TransientRootScope::enter(); + let handle_root = scope.root_addr(handle); + let chunk_bits_root = scope.root_nanbox(f64::from_bits(chunk_bits as u64)); + + { + let args = [f64::from_bits(chunk_bits_root.get().to_bits() as i64 as u64)]; + call(handle_root.get(), "write", &args) + }; +} + +#[no_mangle] +pub unsafe extern "C" fn js_ext_net_socket_write3( + handle: i64, + chunk: f64, + encoding_or_callback: f64, + callback: f64, +) -> f64 { + let scope = TransientRootScope::enter(); + let handle_root = scope.root_addr(handle); + let chunk_root = scope.root_nanbox(chunk); + let encoding_or_callback_root = scope.root_nanbox(encoding_or_callback); + let callback_root = scope.root_nanbox(callback); + + { + let args = [ + chunk_root.get(), + encoding_or_callback_root.get(), + callback_root.get(), + ]; + call(handle_root.get(), "write", &args) + } +} + +#[no_mangle] +pub unsafe extern "C" fn js_ext_net_socket_end(handle: i64, chunk_bits: i64) { + let scope = TransientRootScope::enter(); + let handle_root = scope.root_addr(handle); + let chunk_bits_root = scope.root_nanbox(f64::from_bits(chunk_bits as u64)); + + { + let args = [f64::from_bits(chunk_bits_root.get().to_bits() as i64 as u64)]; + call(handle_root.get(), "end", &args) + }; +} + +#[no_mangle] +pub unsafe extern "C" fn js_net_socket_end(handle: i64, chunk_bits: i64) { + let scope = TransientRootScope::enter(); + let handle_root = scope.root_addr(handle); + let chunk_bits_root = scope.root_nanbox(f64::from_bits(chunk_bits as u64)); + + { + let args = [f64::from_bits(chunk_bits_root.get().to_bits() as i64 as u64)]; + call(handle_root.get(), "end", &args) + }; +} + +#[no_mangle] +pub unsafe extern "C" fn js_ext_net_socket_end3( + handle: i64, + chunk_or_callback: f64, + encoding_or_callback: f64, + callback: f64, +) { + let scope = TransientRootScope::enter(); + let handle_root = scope.root_addr(handle); + let chunk_or_callback_root = scope.root_nanbox(chunk_or_callback); + let encoding_or_callback_root = scope.root_nanbox(encoding_or_callback); + let callback_root = scope.root_nanbox(callback); + + call( + handle_root.get(), + "end", + &[ + chunk_or_callback_root.get(), + encoding_or_callback_root.get(), + callback_root.get(), + ], + ); +} + +#[no_mangle] +pub unsafe extern "C" fn js_net_socket_destroy(handle: i64) { + let scope = TransientRootScope::enter(); + let handle_root = scope.root_addr(handle); + + { + let args = []; + call(handle_root.get(), "destroy", &args) + }; +} + +#[no_mangle] +pub unsafe extern "C" fn js_ext_net_destroy_socket(handle: i64) { + let scope = TransientRootScope::enter(); + let handle_root = scope.root_addr(handle); + + { + let args = []; + call(handle_root.get(), "destroy", &args) + }; +} + +#[no_mangle] +pub unsafe extern "C" fn js_net_socket_address(handle: i64) -> *mut StringHeader { + let scope = TransientRootScope::enter(); + let handle_root = scope.root_addr(handle); + + let value = { + let args = []; + call(handle_root.get(), "address", &args) + }; + js_json_stringify(value, 0) +} + +#[no_mangle] +pub unsafe extern "C" fn js_net_socket_once(handle: i64, event_ptr: i64, cb: i64) -> i64 { + let scope = TransientRootScope::enter(); + let handle_root = scope.root_addr(handle); + let event_ptr_root = scope.root_addr(event_ptr); + let cb_root = scope.root_addr(cb); + + let args = [ + event_value(event_ptr_root.get()), + callback_value(cb_root.get()), + ]; + let value = call(handle_root.get(), "once", &args); + p::raw_owner(value) +} + +#[no_mangle] +pub unsafe extern "C" fn js_net_socket_prepend_listener( + handle: i64, + event_ptr: i64, + cb: i64, +) -> i64 { + let scope = TransientRootScope::enter(); + let handle_root = scope.root_addr(handle); + let event_ptr_root = scope.root_addr(event_ptr); + let cb_root = scope.root_addr(cb); + + let args = [ + event_value(event_ptr_root.get()), + callback_value(cb_root.get()), + ]; + let value = call(handle_root.get(), "prependListener", &args); + p::raw_owner(value) +} + +#[no_mangle] +pub unsafe extern "C" fn js_net_socket_prepend_once_listener( + handle: i64, + event_ptr: i64, + cb: i64, +) -> i64 { + let scope = TransientRootScope::enter(); + let handle_root = scope.root_addr(handle); + let event_ptr_root = scope.root_addr(event_ptr); + let cb_root = scope.root_addr(cb); + + let args = [ + event_value(event_ptr_root.get()), + callback_value(cb_root.get()), + ]; + let value = call(handle_root.get(), "prependOnceListener", &args); + p::raw_owner(value) +} + +#[no_mangle] +pub unsafe extern "C" fn js_net_socket_remove_listener( + handle: i64, + event_ptr: i64, + cb: i64, +) -> i64 { + let scope = TransientRootScope::enter(); + let handle_root = scope.root_addr(handle); + let event_ptr_root = scope.root_addr(event_ptr); + let cb_root = scope.root_addr(cb); + + let args = [ + event_value(event_ptr_root.get()), + callback_value(cb_root.get()), + ]; + let value = call(handle_root.get(), "removeListener", &args); + p::raw_owner(value) +} + +#[no_mangle] +pub unsafe extern "C" fn js_net_socket_remove_all_listeners(handle: i64, event_ptr: i64) -> i64 { + let scope = TransientRootScope::enter(); + let handle_root = scope.root_addr(handle); + let event_ptr_root = scope.root_addr(event_ptr); + + let value = { + let args = [event_value(event_ptr_root.get())]; + call(handle_root.get(), "removeAllListeners", &args) + }; + p::raw_owner(value) +} + +#[no_mangle] +pub unsafe extern "C" fn js_net_socket_listener_count(handle: i64, event_ptr: i64) -> f64 { + let scope = TransientRootScope::enter(); + let handle_root = scope.root_addr(handle); + let event_ptr_root = scope.root_addr(event_ptr); + + { + let args = [event_value(event_ptr_root.get())]; + call(handle_root.get(), "listenerCount", &args) + } +} + +#[no_mangle] +pub unsafe extern "C" fn js_ext_net_socket_listener_count(handle: i64, event_ptr: i64) -> f64 { + let scope = TransientRootScope::enter(); + let handle_root = scope.root_addr(handle); + let event_ptr_root = scope.root_addr(event_ptr); + + { + let args = [event_value(event_ptr_root.get())]; + call(handle_root.get(), "listenerCount", &args) + } +} + +#[no_mangle] +pub unsafe extern "C" fn js_ext_net_socket_get_max_listeners(handle: i64) -> f64 { + let scope = TransientRootScope::enter(); + let handle_root = scope.root_addr(handle); + + { + let args = []; + call(handle_root.get(), "getMaxListeners", &args) + } +} + +#[no_mangle] +pub unsafe extern "C" fn js_ext_net_socket_set_max_listeners(handle: i64, n: f64) -> f64 { + let scope = TransientRootScope::enter(); + let handle_root = scope.root_addr(handle); + let n_root = scope.root_nanbox(n); + + { + let args = [n_root.get()]; + call(handle_root.get(), "setMaxListeners", &args) + } +} + +#[no_mangle] +pub unsafe extern "C" fn js_net_socket_event_names(handle: i64) -> *mut StringHeader { + let scope = TransientRootScope::enter(); + let handle_root = scope.root_addr(handle); + + let value = { + let args = []; + call(handle_root.get(), "eventNames", &args) + }; + js_json_stringify(value, 0) +} + +#[no_mangle] +pub unsafe extern "C" fn js_net_socket_listeners(handle: i64, event_ptr: i64) -> i64 { + let scope = TransientRootScope::enter(); + let handle_root = scope.root_addr(handle); + let event_ptr_root = scope.root_addr(event_ptr); + + let value = { + let args = [event_value(event_ptr_root.get())]; + call(handle_root.get(), "listeners", &args) + }; + p::raw_owner(value) +} + +#[no_mangle] +pub unsafe extern "C" fn js_ext_net_socket_listeners(handle: i64, event_ptr: i64) -> i64 { + let scope = TransientRootScope::enter(); + let handle_root = scope.root_addr(handle); + let event_ptr_root = scope.root_addr(event_ptr); + + let value = { + let args = [event_value(event_ptr_root.get())]; + call(handle_root.get(), "listeners", &args) + }; + p::raw_owner(value) +} + +#[no_mangle] +pub unsafe extern "C" fn js_net_socket_raw_listeners(handle: i64, event_ptr: i64) -> i64 { + let scope = TransientRootScope::enter(); + let handle_root = scope.root_addr(handle); + let event_ptr_root = scope.root_addr(event_ptr); + + let value = { + let args = [event_value(event_ptr_root.get())]; + call(handle_root.get(), "rawListeners", &args) + }; + p::raw_owner(value) +} + +#[no_mangle] +pub unsafe extern "C" fn js_net_socket_reset_and_destroy(handle: i64) -> i64 { + let scope = TransientRootScope::enter(); + let handle_root = scope.root_addr(handle); + + let value = { + let args = []; + call(handle_root.get(), "resetAndDestroy", &args) + }; + p::raw_owner(value) +} + +#[no_mangle] +pub unsafe extern "C" fn js_net_server_once(handle: i64, event_ptr: i64, cb: i64) -> i64 { + let scope = TransientRootScope::enter(); + let handle_root = scope.root_addr(handle); + let event_ptr_root = scope.root_addr(event_ptr); + let cb_root = scope.root_addr(cb); + + let args = [ + event_value(event_ptr_root.get()), + callback_value(cb_root.get()), + ]; + let value = call(handle_root.get(), "once", &args); + p::raw_owner(value) +} + +#[no_mangle] +pub unsafe extern "C" fn js_net_server_prepend_listener( + handle: i64, + event_ptr: i64, + cb: i64, +) -> i64 { + let scope = TransientRootScope::enter(); + let handle_root = scope.root_addr(handle); + let event_ptr_root = scope.root_addr(event_ptr); + let cb_root = scope.root_addr(cb); + + let args = [ + event_value(event_ptr_root.get()), + callback_value(cb_root.get()), + ]; + let value = call(handle_root.get(), "prependListener", &args); + p::raw_owner(value) +} + +#[no_mangle] +pub unsafe extern "C" fn js_net_server_prepend_once_listener( + handle: i64, + event_ptr: i64, + cb: i64, +) -> i64 { + let scope = TransientRootScope::enter(); + let handle_root = scope.root_addr(handle); + let event_ptr_root = scope.root_addr(event_ptr); + let cb_root = scope.root_addr(cb); + + let args = [ + event_value(event_ptr_root.get()), + callback_value(cb_root.get()), + ]; + let value = call(handle_root.get(), "prependOnceListener", &args); + p::raw_owner(value) +} + +#[no_mangle] +pub unsafe extern "C" fn js_net_server_remove_listener( + handle: i64, + event_ptr: i64, + cb: i64, +) -> i64 { + let scope = TransientRootScope::enter(); + let handle_root = scope.root_addr(handle); + let event_ptr_root = scope.root_addr(event_ptr); + let cb_root = scope.root_addr(cb); + + let args = [ + event_value(event_ptr_root.get()), + callback_value(cb_root.get()), + ]; + let value = call(handle_root.get(), "removeListener", &args); + p::raw_owner(value) +} + +#[no_mangle] +pub unsafe extern "C" fn js_net_server_remove_all_listeners(handle: i64, event_ptr: i64) -> i64 { + let scope = TransientRootScope::enter(); + let handle_root = scope.root_addr(handle); + let event_ptr_root = scope.root_addr(event_ptr); + + let value = { + let args = [event_value(event_ptr_root.get())]; + call(handle_root.get(), "removeAllListeners", &args) + }; + p::raw_owner(value) +} + +#[no_mangle] +pub unsafe extern "C" fn js_net_server_listener_count(handle: i64, event_ptr: i64) -> f64 { + let scope = TransientRootScope::enter(); + let handle_root = scope.root_addr(handle); + let event_ptr_root = scope.root_addr(event_ptr); + + { + let args = [event_value(event_ptr_root.get())]; + call(handle_root.get(), "listenerCount", &args) + } +} + +#[no_mangle] +pub unsafe extern "C" fn js_net_server_event_names(handle: i64) -> *mut StringHeader { + let scope = TransientRootScope::enter(); + let handle_root = scope.root_addr(handle); + + let value = { + let args = []; + call(handle_root.get(), "eventNames", &args) + }; + js_json_stringify(value, 0) +} + +#[no_mangle] +pub unsafe extern "C" fn js_net_server_listeners(handle: i64, event_ptr: i64) -> i64 { + let scope = TransientRootScope::enter(); + let handle_root = scope.root_addr(handle); + let event_ptr_root = scope.root_addr(event_ptr); + + let value = { + let args = [event_value(event_ptr_root.get())]; + call(handle_root.get(), "listeners", &args) + }; + p::raw_owner(value) +} + +#[no_mangle] +pub unsafe extern "C" fn js_net_server_raw_listeners(handle: i64, event_ptr: i64) -> i64 { + let scope = TransientRootScope::enter(); + let handle_root = scope.root_addr(handle); + let event_ptr_root = scope.root_addr(event_ptr); + + let value = { + let args = [event_value(event_ptr_root.get())]; + call(handle_root.get(), "rawListeners", &args) + }; + p::raw_owner(value) +} + +#[no_mangle] +pub unsafe extern "C" fn js_net_socket_noop_self(handle: i64) -> i64 { + let scope = TransientRootScope::enter(); + let handle_root = scope.root_addr(handle); + + let value = { + let args = []; + call(handle_root.get(), "ref", &args) + }; + p::raw_owner(value) +} + +#[no_mangle] +pub unsafe extern "C" fn js_net_socket_ref(handle: i64) -> i64 { + let scope = TransientRootScope::enter(); + let handle_root = scope.root_addr(handle); + + let value = { + let args = []; + call(handle_root.get(), "ref", &args) + }; + p::raw_owner(value) +} + +#[no_mangle] +pub unsafe extern "C" fn js_net_socket_unref(handle: i64) -> i64 { + let scope = TransientRootScope::enter(); + let handle_root = scope.root_addr(handle); + + let value = { + let args = []; + call(handle_root.get(), "unref", &args) + }; + p::raw_owner(value) +} + +#[no_mangle] +pub unsafe extern "C" fn js_net_socket_set_timeout( + handle: i64, + msecs: f64, + _callback_i64: i64, +) -> i64 { + let scope = TransientRootScope::enter(); + let handle_root = scope.root_addr(handle); + let msecs_root = scope.root_nanbox(msecs); + let _callback_i64_root = scope.root_nanbox(f64::from_bits(_callback_i64 as u64)); + + let value = call( + handle_root.get(), + "setTimeout", + &[ + msecs_root.get(), + f64::from_bits(_callback_i64_root.get().to_bits() as i64 as u64), + ], + ); + p::raw_owner(value) +} + +#[no_mangle] +pub unsafe extern "C" fn js_net_server_noop_self(handle: i64) -> i64 { + let scope = TransientRootScope::enter(); + let handle_root = scope.root_addr(handle); + + let value = { + let args = []; + call(handle_root.get(), "ref", &args) + }; + p::raw_owner(value) +} + +#[no_mangle] +pub unsafe extern "C" fn js_net_socket_get_type_of_service(handle: i64) -> f64 { + let scope = TransientRootScope::enter(); + let handle_root = scope.root_addr(handle); + + { + let args = []; + call(handle_root.get(), "getTypeOfService", &args) + } +} + +#[no_mangle] +pub unsafe extern "C" fn js_net_socket_set_type_of_service(handle: i64, tos: f64) -> i64 { + let scope = TransientRootScope::enter(); + let handle_root = scope.root_addr(handle); + let tos_root = scope.root_nanbox(tos); + + let value = { + let args = [tos_root.get()]; + call(handle_root.get(), "setTypeOfService", &args) + }; + p::raw_owner(value) +} + +#[no_mangle] +pub unsafe extern "C" fn js_net_socket_set_no_delay(handle: i64, arg_bits: i64) -> i64 { + let scope = TransientRootScope::enter(); + let handle_root = scope.root_addr(handle); + let arg_bits_root = scope.root_nanbox(f64::from_bits(arg_bits as u64)); + + let value = { + let args = [f64::from_bits(arg_bits_root.get().to_bits() as i64 as u64)]; + call(handle_root.get(), "setNoDelay", &args) + }; + p::raw_owner(value) +} + +#[no_mangle] +pub unsafe extern "C" fn js_net_socket_set_encoding(handle: i64, enc_ptr: i64) -> i64 { + let scope = TransientRootScope::enter(); + let handle_root = scope.root_addr(handle); + let enc_ptr_root = scope.root_addr(enc_ptr); + + let value = { + let args = [event_value(enc_ptr_root.get())]; + call(handle_root.get(), "setEncoding", &args) + }; + p::raw_owner(value) +} + +#[no_mangle] +pub unsafe extern "C" fn js_net_server_on(handle: i64, event_ptr: i64, cb: i64) { + let scope = TransientRootScope::enter(); + let handle_root = scope.root_addr(handle); + let event_ptr_root = scope.root_addr(event_ptr); + let cb_root = scope.root_addr(cb); + + { + let args = [ + event_value(event_ptr_root.get()), + callback_value(cb_root.get()), + ]; + call(handle_root.get(), "on", &args) + }; +} + +#[no_mangle] +pub unsafe extern "C" fn js_ext_net_socket_on(handle: i64, event_ptr: i64, cb: i64) -> i64 { + let scope = TransientRootScope::enter(); + let handle_root = scope.root_addr(handle); + let event_ptr_root = scope.root_addr(event_ptr); + let cb_root = scope.root_addr(cb); + + let value = { + let args = [ + event_value(event_ptr_root.get()), + callback_value(cb_root.get()), + ]; + call(handle_root.get(), "on", &args) + }; + p::raw_owner(value) +} + +#[no_mangle] +pub unsafe extern "C" fn js_ext_net_socket_once(handle: i64, event_ptr: i64, cb: i64) -> i64 { + let scope = TransientRootScope::enter(); + let handle_root = scope.root_addr(handle); + let event_ptr_root = scope.root_addr(event_ptr); + let cb_root = scope.root_addr(cb); + + let args = [ + event_value(event_ptr_root.get()), + callback_value(cb_root.get()), + ]; + let value = call(handle_root.get(), "once", &args); + p::raw_owner(value) +} + +#[no_mangle] +pub unsafe extern "C" fn js_ext_net_socket_remove_listener( + handle: i64, + event_ptr: i64, + cb: i64, +) -> i64 { + let scope = TransientRootScope::enter(); + let handle_root = scope.root_addr(handle); + let event_ptr_root = scope.root_addr(event_ptr); + let cb_root = scope.root_addr(cb); + + let args = [ + event_value(event_ptr_root.get()), + callback_value(cb_root.get()), + ]; + let value = call(handle_root.get(), "removeListener", &args); + p::raw_owner(value) +} + +#[no_mangle] +pub unsafe extern "C" fn js_ext_net_socket_remove_all_listeners( + handle: i64, + event_ptr: i64, +) -> i64 { + let scope = TransientRootScope::enter(); + let handle_root = scope.root_addr(handle); + let event_ptr_root = scope.root_addr(event_ptr); + + let value = { + let args = [event_value(event_ptr_root.get())]; + call(handle_root.get(), "removeAllListeners", &args) + }; + p::raw_owner(value) +} + +#[no_mangle] +pub unsafe extern "C" fn js_net_socket_cork(handle: i64) -> i64 { + let scope = TransientRootScope::enter(); + let handle_root = scope.root_addr(handle); + + let value = { + let args = []; + call(handle_root.get(), "cork", &args) + }; + p::raw_owner(value) +} + +#[no_mangle] +pub unsafe extern "C" fn js_net_socket_uncork(handle: i64) -> i64 { + let scope = TransientRootScope::enter(); + let handle_root = scope.root_addr(handle); + + let value = { + let args = []; + call(handle_root.get(), "uncork", &args) + }; + p::raw_owner(value) +} + +#[no_mangle] +pub unsafe extern "C" fn js_net_socket_get_writable_corked(handle: i64) -> f64 { + let scope = TransientRootScope::enter(); + let handle_root = scope.root_addr(handle); + + socket::get(owner(handle_root.get()), "writableCorked") +} + +#[no_mangle] +pub unsafe extern "C" fn js_net_server_get_listening(handle: i64) -> f64 { + let scope = TransientRootScope::enter(); + let handle_root = scope.root_addr(handle); + + server::get(owner(handle_root.get()), "listening") +} + +#[no_mangle] +pub unsafe extern "C" fn js_net_server_get_connections(handle: i64) -> f64 { + server::get(owner(handle), "_connections") +} + +#[no_mangle] +pub unsafe extern "C" fn js_net_server_get_max_connections(handle: i64) -> f64 { + let scope = TransientRootScope::enter(); + let handle_root = scope.root_addr(handle); + + server::get(owner(handle_root.get()), "maxConnections") +} + +#[no_mangle] +pub unsafe extern "C" fn js_net_server_set_max_connections(handle: i64, value: f64) -> f64 { + let scope = TransientRootScope::enter(); + let handle_root = scope.root_addr(handle); + let value_root = scope.root_nanbox(value); + + { + server::link(owner(handle_root.get())); + p::own_set(owner(handle_root.get()), "maxConnections", value_root.get()); + value_root.get() + } +} + +#[no_mangle] +pub unsafe extern "C" fn js_net_server_get_drop_max_connection(handle: i64) -> f64 { + let scope = TransientRootScope::enter(); + let handle_root = scope.root_addr(handle); + + server::get(owner(handle_root.get()), "dropMaxConnection") +} + +#[no_mangle] +pub unsafe extern "C" fn js_net_server_set_drop_max_connection(handle: i64, value: f64) -> f64 { + let scope = TransientRootScope::enter(); + let handle_root = scope.root_addr(handle); + let value_root = scope.root_nanbox(value); + + { + server::link(owner(handle_root.get())); + p::own_set( + owner(handle_root.get()), + "dropMaxConnection", + value_root.get(), + ); + value_root.get() + } +} + +#[no_mangle] +pub unsafe extern "C" fn js_net_socket_pipe(handle: i64, dest: f64, options: f64) -> f64 { + let scope = TransientRootScope::enter(); + let handle_root = scope.root_addr(handle); + let dest_root = scope.root_nanbox(dest); + let options_root = scope.root_nanbox(options); + + { + let args = [dest_root.get(), options_root.get()]; + call(handle_root.get(), "pipe", &args) + } +} + +#[no_mangle] +pub unsafe extern "C" fn js_net_socket_unpipe(handle: i64, dest: f64) -> i64 { + let scope = TransientRootScope::enter(); + let handle_root = scope.root_addr(handle); + let dest_root = scope.root_nanbox(dest); + + let value = { + let args = [dest_root.get()]; + call(handle_root.get(), "unpipe", &args) + }; + p::raw_owner(value) +} + +#[no_mangle] +pub extern "C" fn js_net_has_pending() -> i32 { + 0 +} +#[no_mangle] +pub unsafe extern "C" fn js_net_process_pending() -> i32 { + 0 +} +pub fn is_net_socket_handle(raw: i64) -> bool { + p::socket_link(owner(raw)).is_ok() +} +pub fn is_net_server_handle(raw: i64) -> bool { + p::server_link(owner(raw)).is_ok() +} +#[no_mangle] +pub extern "C" fn js_ext_net_is_server_handle(raw: i64) -> i32 { + is_net_server_handle(raw) as i32 +} +#[no_mangle] +pub extern "C" fn js_net_server_listening(raw: i64) -> i32 { + JsValue::from_bits(server::get(owner(raw), "listening").to_bits()).to_bool() as i32 +} +#[no_mangle] +pub unsafe extern "C" fn js_net_socket_on(raw: i64, event_ptr: i64, cb: i64) { + let scope = TransientRootScope::enter(); + let raw_root = scope.root_addr(raw); + let event_ptr_root = scope.root_addr(event_ptr); + let cb_root = scope.root_addr(cb); + + { + let args = [ + event_value(event_ptr_root.get()), + callback_value(cb_root.get()), + ]; + call(raw_root.get(), "on", &args) + }; +} +#[no_mangle] +pub unsafe extern "C" fn js_ext_net_socket_emit( + raw: i64, + event_ptr: i64, + args: *const f64, + count: usize, +) -> f64 { + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(owner(raw)); + let args: Vec<_> = if args.is_null() { + Vec::new() + } else { + std::slice::from_raw_parts(args, count) + .iter() + .map(|arg| scope.root_nanbox(*arg)) + .collect() + }; + let name = scope.root_nanbox(event_value(event_ptr)); + let mut values = vec![name.get()]; + values.extend(args.iter().map(|arg| arg.get())); + call(p::raw_owner(owner.get()), "emit", &values) +} +#[no_mangle] +pub unsafe extern "C" fn js_ext_net_socket_emit_abort_error(raw: i64) { + let scope = TransientRootScope::enter(); + let raw_root = scope.root_addr(raw); + + socket::destroy( + owner(raw_root.get()), + socket::error("ABORT_ERR", "The operation was aborted"), + ); +} +#[no_mangle] +pub unsafe extern "C" fn js_net_socket_upgrade_tls( + raw: i64, + servername_ptr: i64, + verify: f64, +) -> *mut perry_ffi::Promise { + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(owner(raw)); + socket::link(owner.get()); + let name = string_from_header_i64(servername_ptr).unwrap_or_default(); + let promise = scope.root_addr(perry_ffi::JsPromise::new().as_raw() as i64); + let state = scope.root_nanbox(socket::state(owner.get())); + p::own_set(state.get(), "upgradePromise", p::boxed_addr(promise.get())); + if let Err(message) = super::payload_tls::install_client( + owner.get(), + name, + verify != 0.0, + super::TlsClientConfigData::default(), + ) { + super::payload_tls::settle_upgrade(owner.get(), Some(&message)); + } + promise.get() as *mut perry_ffi::Promise +} diff --git a/crates/perry-ext-net/src/payload_closed.rs b/crates/perry-ext-net/src/payload_closed.rs new file mode 100644 index 0000000000..7a1badbcb8 --- /dev/null +++ b/crates/perry-ext-net/src/payload_closed.rs @@ -0,0 +1,147 @@ +//! Pending terminal callback context is owned by the ordinary JS owner. +//! +//! Explicit release destroys Rust state immediately. Its later Closed still +//! needs the error and server edge from that call even when the owner has +//! reopened. These are ordinary callback records, matched by the driver's +//! existing generational capability; they never route or pin a native cell. + +use super::payload_transport as p; +use perry_ffi::{ArrayHeader, JsValue, TransientRootScope}; + +extern "C" { + fn js_object_alloc_null_proto(class_id: u32, fields: u32) -> *mut perry_ffi::ObjectHeader; +} + +fn array_ptr(value: f64) -> *mut ArrayHeader { + JsValue::from_bits(value.to_bits()).as_pointer() +} + +pub(crate) fn parts(record: f64) -> [u32; 4] { + let scope = TransientRootScope::enter(); + let record = scope.root_nanbox(record); + let handle = scope.root_nanbox(p::own_get(record.get(), "handle")); + std::array::from_fn(|index| unsafe { + perry_ffi::js_array_get(array_ptr(handle.get()), index as u32).to_number() as u32 + }) +} + +pub(crate) fn append(state: f64, record: f64) { + let scope = TransientRootScope::enter(); + let state = scope.root_nanbox(state); + let record = scope.root_nanbox(record); + let mut array = scope.root_nanbox(p::own_get(state.get(), "closeCallbacks")); + if !JsValue::from_bits(array.get().to_bits()).is_pointer() { + array = scope.root_nanbox(p::boxed_addr(unsafe { perry_ffi::js_array_alloc(0) } as i64)); + } + let updated = unsafe { + perry_ffi::js_array_push( + array_ptr(array.get()), + JsValue::from_bits(record.get().to_bits()), + ) + }; + p::own_set(state.get(), "closeCallbacks", p::boxed_addr(updated as i64)); +} + +pub(crate) fn record(parts: [u32; 4]) -> f64 { + let scope = TransientRootScope::enter(); + let record = scope.root_nanbox(p::boxed_addr( + unsafe { js_object_alloc_null_proto(0, 4) } as i64 + )); + let mut capability = scope.root_addr(unsafe { perry_ffi::js_array_alloc(4) } as i64); + for part in parts { + capability = scope.root_addr(unsafe { + perry_ffi::js_array_push( + capability.get() as *mut ArrayHeader, + JsValue::from_number(part as f64), + ) + } as i64); + } + p::own_set(record.get(), "handle", p::boxed_addr(capability.get())); + record.get() +} + +pub(crate) fn peek(state: f64, capability: [u32; 4]) -> f64 { + let scope = TransientRootScope::enter(); + let state = scope.root_nanbox(state); + let array = scope.root_nanbox(p::own_get(state.get(), "closeCallbacks")); + if !JsValue::from_bits(array.get().to_bits()).is_pointer() { + return p::undefined(); + } + let count = unsafe { perry_ffi::js_array_length(array_ptr(array.get())) }; + for index in 0..count { + let record = scope.root_nanbox(f64::from_bits(unsafe { + perry_ffi::js_array_get(array_ptr(array.get()), index).bits() + })); + if parts(record.get()) == capability { + return record.get(); + } + } + p::undefined() +} + +pub(crate) fn for_wrapper(state: f64, owner: f64) -> f64 { + let scope = TransientRootScope::enter(); + let state = scope.root_nanbox(state); + let owner = scope.root_nanbox(owner); + let array = scope.root_nanbox(p::own_get(state.get(), "closeCallbacks")); + if !JsValue::from_bits(array.get().to_bits()).is_pointer() { + return p::undefined(); + } + let count = unsafe { perry_ffi::js_array_length(array_ptr(array.get())) }; + for index in 0..count { + let record = scope.root_nanbox(f64::from_bits(unsafe { + perry_ffi::js_array_get(array_ptr(array.get()), index).bits() + })); + if p::own_get(record.get(), "tlsWrapper").to_bits() == owner.get().to_bits() { + return record.get(); + } + } + p::undefined() +} + +/// Consume exactly this handle's terminal callback record. All other records +/// keep their ordinary GC edges; completions across handles may arrive in +/// either order. There is no FIFO assumption across incarnations. +pub(crate) fn take(state: f64, parts: [u32; 4]) -> f64 { + let scope = TransientRootScope::enter(); + let state = scope.root_nanbox(state); + let array = scope.root_nanbox(p::own_get(state.get(), "closeCallbacks")); + if !JsValue::from_bits(array.get().to_bits()).is_pointer() { + return p::undefined(); + } + let count = unsafe { perry_ffi::js_array_length(array_ptr(array.get())) }; + let records: Vec<_> = (0..count) + .map(|index| { + scope.root_nanbox(f64::from_bits(unsafe { + perry_ffi::js_array_get(array_ptr(array.get()), index).bits() + })) + }) + .collect(); + let found = records.iter().position(|record| { + let handle = scope.root_nanbox(p::own_get(record.get(), "handle")); + if !JsValue::from_bits(handle.get().to_bits()).is_pointer() { + return false; + } + (0..4).all(|index| unsafe { + perry_ffi::js_array_get(array_ptr(handle.get()), index).to_number() + == parts[index as usize] as f64 + }) + }); + let Some(found) = found else { + return p::undefined(); + }; + let mut kept = + scope.root_addr(unsafe { perry_ffi::js_array_alloc(count.saturating_sub(1)) } as i64); + for (index, record) in records.iter().enumerate() { + if index != found { + kept = scope.root_addr(unsafe { + perry_ffi::js_array_push( + kept.get() as *mut ArrayHeader, + JsValue::from_bits(record.get().to_bits()), + ) + } as i64); + } + } + p::own_set(state.get(), "closeCallbacks", p::boxed_addr(kept.get())); + records[found].get() +} diff --git a/crates/perry-ext-net/src/payload_events.rs b/crates/perry-ext-net/src/payload_events.rs new file mode 100644 index 0000000000..8562953e94 --- /dev/null +++ b/crates/perry-ext-net/src/payload_events.rs @@ -0,0 +1,182 @@ +//! Event delivery through ordinary emitter properties and nextTick captures. + +use super::payload_transport::{boxed_addr, raw_owner, undefined}; +use perry_ffi::{JsThis, JsValue, RawClosureHeader, TransientRootScope}; + +extern "C" { + fn js_perry_event_emit_in_resource(resource: f64, owner: f64, event: f64, args: f64) -> f64; + fn js_perry_event_call_in_resource( + resource: f64, + callback: f64, + receiver: f64, + args: *const f64, + len: usize, + ) -> f64; + fn js_node_stream_method_listener_count(owner: i64, event: f64) -> f64; + fn js_queue_next_tick(callback: i64); +} + +pub(crate) fn string(text: &str) -> f64 { + f64::from_bits(JsValue::from_string_ptr(perry_ffi::alloc_string(text).as_raw()).bits()) +} + +/// The array is a traced JS object, including during allocation and callback +/// reentry. No Rust snapshot of callbacks or argument pointers is retained. +fn argument_array(scope: &TransientRootScope, args: &[f64]) -> perry_ffi::TransientRootedAddr { + let args: Vec<_> = args.iter().map(|&arg| scope.root_nanbox(arg)).collect(); + let mut array = scope.root_addr(unsafe { perry_ffi::js_array_alloc(args.len() as u32) } as i64); + for arg in args { + let updated = unsafe { + perry_ffi::js_array_push( + array.get() as *mut perry_ffi::ArrayHeader, + JsValue::from_bits(arg.get().to_bits()), + ) + }; + array = scope.root_addr(updated as i64); + } + array +} + +pub(crate) fn emit(owner: f64, event: &str, args: &[f64]) -> f64 { + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(owner); + let args: Vec<_> = args.iter().map(|&arg| scope.root_nanbox(arg)).collect(); + let resource = scope.root_nanbox(super::payload_provider::resource(owner.get())); + let args: Vec<_> = args.iter().map(|arg| arg.get()).collect(); + emit_in(resource.get(), owner.get(), event, &args) +} + +pub(crate) fn emit_in(resource: f64, owner: f64, event: &str, args: &[f64]) -> f64 { + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(owner); + let resource = scope.root_nanbox(resource); + let args = argument_array(&scope, args); + let event = scope.root_nanbox(string(event)); + unsafe { + js_perry_event_emit_in_resource( + resource.get(), + owner.get(), + event.get(), + boxed_addr(args.get()), + ) + } +} + +pub(crate) fn listener_count(owner: f64, event: &str) -> usize { + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(owner); + let event = string(event); + let result = unsafe { js_node_stream_method_listener_count(raw_owner(owner.get()), event) }; + JsValue::from_bits(result.to_bits()).to_number().max(0.0) as usize +} + +unsafe extern "C" fn emit_tick(closure: *const RawClosureHeader, _: JsThis) -> f64 { + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(perry_ffi::closure_capture_f64(closure, 0)); + let event = scope.root_nanbox(perry_ffi::closure_capture_f64(closure, 1)); + let args = scope.root_nanbox(perry_ffi::closure_capture_f64(closure, 2)); + let resource = scope.root_nanbox(perry_ffi::closure_capture_f64(closure, 3)); + let terminal = perry_ffi::closure_capture_f64(closure, 4) == 1.0; + js_perry_event_emit_in_resource(resource.get(), owner.get(), event.get(), args.get()); + if terminal { + super::payload_provider::retire(resource.get()); + } + undefined() +} + +/// Synthetic events have no driver completion to keep the owner alive. The +/// nextTick closure supplies the ordinary traced owner edge instead. +pub(crate) fn queue_emit(owner: f64, event: &str, args: &[f64]) { + queue_emit_with(owner, event, args, false); +} +fn queue_emit_with(owner: f64, event: &str, args: &[f64], terminal: bool) { + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(owner); + let args: Vec<_> = args.iter().map(|&arg| scope.root_nanbox(arg)).collect(); + let resource = scope.root_nanbox(super::payload_provider::resource(owner.get())); + let args: Vec<_> = args.iter().map(|arg| arg.get()).collect(); + queue_emit_in(resource.get(), owner.get(), event, &args, terminal); +} +pub(crate) fn queue_emit_in(resource: f64, owner: f64, event: &str, args: &[f64], terminal: bool) { + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(owner); + let resource = scope.root_nanbox(resource); + let args = argument_array(&scope, args); + let event = scope.root_nanbox(string(event)); + let callback = scope.root_addr(perry_ffi::alloc_closure( + perry_ffi::js_function_info!(emit_tick, 0; with_flags(perry_ffi::FN_BUILTIN)), + 5, + ) as i64); + unsafe { + let callback_ptr = callback.get() as *mut RawClosureHeader; + perry_ffi::set_closure_capture_f64(callback_ptr, 0, owner.get()); + perry_ffi::set_closure_capture_f64(callback_ptr, 1, event.get()); + perry_ffi::set_closure_capture_f64(callback_ptr, 2, boxed_addr(args.get())); + perry_ffi::set_closure_capture_f64(callback_ptr, 3, resource.get()); + perry_ffi::set_closure_capture_f64(callback_ptr, 4, if terminal { 1.0 } else { 0.0 }); + js_queue_next_tick(callback.get()); + } +} + +unsafe extern "C" fn call_tick(closure: *const RawClosureHeader, _: JsThis) -> f64 { + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(perry_ffi::closure_capture_f64(closure, 0)); + let callback = scope.root_nanbox(perry_ffi::closure_capture_f64(closure, 1)); + let array = scope.root_nanbox(perry_ffi::closure_capture_f64(closure, 2)); + let array_ptr = + JsValue::from_bits(array.get().to_bits()).as_pointer::(); + let len = perry_ffi::js_array_length(array_ptr); + let args: Vec<_> = (0..len) + .map(|index| { + scope.root_nanbox(f64::from_bits( + perry_ffi::js_array_get(array_ptr, index).bits(), + )) + }) + .collect(); + let args: Vec<_> = args.iter().map(|arg| arg.get()).collect(); + let resource = scope.root_nanbox(perry_ffi::closure_capture_f64(closure, 3)); + call_in(resource.get(), callback.get(), JsThis::UNDEFINED, &args); + let _ = owner; + undefined() +} + +pub(crate) fn queue_call(owner: f64, callback: f64, args: &[f64]) { + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(owner); + let callback = scope.root_nanbox(callback); + let args = argument_array(&scope, args); + let resource = scope.root_nanbox(super::payload_provider::resource(owner.get())); + let job = scope.root_addr(perry_ffi::alloc_closure( + perry_ffi::js_function_info!(call_tick, 0; with_flags(perry_ffi::FN_BUILTIN)), + 4, + ) as i64); + unsafe { + let job_ptr = job.get() as *mut RawClosureHeader; + perry_ffi::set_closure_capture_f64(job_ptr, 0, owner.get()); + perry_ffi::set_closure_capture_f64(job_ptr, 1, callback.get()); + perry_ffi::set_closure_capture_f64(job_ptr, 2, boxed_addr(args.get())); + perry_ffi::set_closure_capture_f64(job_ptr, 3, resource.get()); + js_queue_next_tick(job.get()); + } +} + +pub(crate) fn call(callback: f64, this: JsThis, args: &[f64]) -> f64 { + call_in(undefined(), callback, this, args) +} +pub(crate) fn call_in(resource: f64, callback: f64, this: JsThis, args: &[f64]) -> f64 { + let scope = TransientRootScope::enter(); + let resource = scope.root_nanbox(resource); + let callback = scope.root_nanbox(callback); + let this = scope.root_nanbox(this.as_f64()); + let args: Vec<_> = args.iter().map(|arg| scope.root_nanbox(*arg)).collect(); + let args: Vec<_> = args.iter().map(|arg| arg.get()).collect(); + unsafe { + js_perry_event_call_in_resource( + resource.get(), + callback.get(), + this.get(), + args.as_ptr(), + args.len(), + ) + } +} diff --git a/crates/perry-ext-net/src/payload_io.rs b/crates/perry-ext-net/src/payload_io.rs new file mode 100644 index 0000000000..e96493d360 --- /dev/null +++ b/crates/perry-ext-net/src/payload_io.rs @@ -0,0 +1,546 @@ +//! Link-token completion sink. JS runs after the driver's turn has returned. + +use super::{payload_closed as closed, payload_events as events, payload_server as server}; +use super::{payload_provider as provider, payload_socket as socket, payload_transport as p}; +use perry_ffi::native_payload::{self as np, OwnerLink}; +use perry_ffi::turnloop_net as tl; +use perry_ffi::{JsThis, JsValue, RawClosureHeader, TransientRootScope}; + +pub(crate) const ROUTE: u8 = crate::TURNLOOP_SUBSYSTEM; +pub(crate) const TLS_SUBSYSTEM: u8 = 4; + +pub(crate) fn register() { + static REGISTERED: std::sync::Once = std::sync::Once::new(); + REGISTERED.call_once(|| { + assert!( + tl::register_link_sink(ROUTE, sink), + "net link sink registration refused" + ); + assert!( + tl::register_link_sink(TLS_SUBSYSTEM, sink), + "TLS wrapper link sink registration refused" + ); + }); +} + +pub(crate) fn enabled() -> bool { + register(); + tl::available(ROUTE) +} + +pub(crate) fn snapshot(socket: OwnerLink) -> Option { + unsafe { + p::socket_core(socket) + .ok() + .and_then(|core| tl::link_snapshot_handle(&mut *core, socket)) + } +} +pub(crate) fn matches(socket: OwnerLink, snapshot: &tl::HandleSnapshot) -> bool { + unsafe { + p::socket_core(socket) + .ok() + .is_some_and(|core| tl::link_handle_matches(&mut *core, socket, snapshot)) + } +} + +extern "C" fn sink(completion: *const tl::NetCompletion) { + if completion.is_null() { + return; + } + let event = unsafe { &*completion }; + let Some(link) = event.link() else { + return; + }; + let Some(owner) = (unsafe { np::link_event_owner(link) }) else { + return; + }; + let scope = TransientRootScope::enter(); + let original = scope.root_nanbox(owner); + let owner = scope.root_nanbox( + if p::socket_link(original.get()).is_ok() + && (event.kind == tl::NET_CLOSED || super::payload_tls::installed(link)) + { + let state = scope.root_nanbox(unsafe { p::socket_state(link, false) }); + let pending = scope.root_nanbox( + unsafe { event.closed_handle_parts() } + .map(|parts| closed::peek(state.get(), parts)) + .unwrap_or_else(p::undefined), + ); + let saved = scope.root_nanbox(p::own_get(pending.get(), "tlsWrapper")); + let wrapper = scope.root_nanbox(if p::socket_link(saved.get()).is_ok() { + saved.get() + } else if event.kind != tl::NET_CLOSED + || (!JsValue::from_bits(pending.get().to_bits()).is_pointer() + && event.flags & tl::NET_FLAG_STALE == 0) + { + let current = scope.root_nanbox(p::own_get(state.get(), "tlsWrapper")); + if p::socket_link(current.get()) + .is_ok_and(|own| unsafe { p::socket_ptr(own) }.is_ok()) + { + current.get() + } else { + original.get() + } + } else { + original.get() + }); + if p::socket_link(wrapper.get()).is_ok() { + wrapper.get() + } else { + original.get() + } + } else { + original.get() + }, + ); + if p::server_link(owner.get()).is_ok() { + match event.kind { + tl::NET_ACCEPT => server::accepted(owner.get(), completion), + tl::NET_CLOSED => server::listener_closed(owner.get(), event), + tl::NET_ERROR => { + let snapshot = unsafe { + p::server_core(link) + .ok() + .and_then(|core| tl::link_snapshot_handle(&mut *core, link)) + }; + let error = socket::error( + unsafe { event.code() }.unwrap_or("EIO"), + &error_message(event), + ); + events::emit(owner.get(), "error", &[error]); + if snapshot.as_ref().is_some_and(|snapshot| unsafe { + p::server_core(link) + .ok() + .is_some_and(|core| tl::link_handle_matches(&mut *core, link, snapshot)) + }) { + server::close(owner.get(), p::undefined()); + } + } + _ => {} + } + return; + } + if p::socket_link(owner.get()).is_err() { + return; + } + let _account = p::AccountSocket(link); + match event.kind { + tl::NET_CONNECT => connected(owner.get()), + tl::NET_DATA => { + let bytes = unsafe { event.bytes() }; + if event.flags & tl::NET_FLAG_PLAINTEXT == 0 && super::payload_tls::installed(link) { + super::payload_tls::receive(owner.get(), bytes); + return; + } + unsafe { + if let Ok(payload) = p::socket_ptr(link) { + (*payload).ext.bytes_read = + (*payload).ext.bytes_read.saturating_add(bytes.len() as u64); + } + } + socket::refresh_timeout(link); + socket::data(owner.get(), bytes); + } + tl::NET_EOF => eof(owner.get()), + tl::NET_WROTE => { + if super::payload_tls::installed(link) { + super::payload_tls::wrote(owner.get(), event.len); + } else { + wrote(owner.get(), event.user, event.len, event.queued); + } + } + tl::NET_SHUTDOWN => shutdown(owner.get(), event.user), + tl::NET_CLOSED => { + if owner.get().to_bits() != original.get().to_bits() { + // A wrapper may reopen on its own cell before this parent + // handle's Closed arrives. It remains an owner event only. + let mut closed = *event; + if socket::link(owner.get()) != link { + closed.flags |= tl::NET_FLAG_STALE; + } + // Dispose both resources before either close listener runs. + // A listener may reopen either object during the first emit. + let wrapper_record = scope.root_nanbox(prepare_socket_closed(owner.get(), &closed)); + let parent_record = scope.root_nanbox(prepare_socket_closed(original.get(), event)); + emit_socket_closed(owner.get(), wrapper_record.get()); + emit_socket_closed(original.get(), parent_record.get()); + } else { + socket_closed(owner.get(), event); + } + } + tl::NET_ERROR => { + let error = socket::error( + unsafe { event.code() }.unwrap_or("EIO"), + &error_message(event), + ); + socket::destroy(owner.get(), error); + } + tl::NET_TIMER => { + events::emit(owner.get(), "timeout", &[]); + } + _ => {} + } +} + +fn error_message(event: &tl::NetCompletion) -> String { + format!( + "{} {}", + unsafe { event.syscall() }.unwrap_or("read"), + unsafe { event.code() }.unwrap_or("EIO") + ) +} + +pub(crate) fn connected(owner: f64) { + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(owner); + let link = socket::link(owner.get()); + let Some(snapshot) = snapshot(link) else { + return; + }; + unsafe { + if let Ok(payload) = p::socket_ptr(link) { + (*payload).ext.connecting = false; + (*payload).ext.opened = true; + } + } + socket::update_addresses(link); + socket::refresh_timeout(link); + super::payload_tls::begin_connected(owner.get()); + if !matches(link, &snapshot) { + return; + } + let state = scope.root_nanbox(socket::state(owner.get())); + let connect = scope.root_nanbox(p::own_get(state.get(), provider::CONNECT)); + events::emit_in(connect.get(), owner.get(), "connect", &[]); + provider::retire(connect.get()); + if !matches(link, &snapshot) { + return; + } + events::emit(owner.get(), "ready", &[]); + if !matches(link, &snapshot) { + return; + } + socket::flow(owner.get()); +} + +pub(crate) fn eof(owner: f64) { + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(owner); + let link = socket::link(owner.get()); + let Some(snapshot) = snapshot(link) else { + return; + }; + let emit = unsafe { + p::socket_ptr(link).ok().is_some_and(|payload| { + let fields = &mut (*payload).ext; + fields.read_ended = true; + if fields.read_end_emitted || !fields.read_buffer.is_empty() { + return false; + } + fields.read_end_emitted = true; + true + }) + }; + if !emit { + return; + } + events::emit(owner.get(), "end", &[]); + if !matches(link, &snapshot) { + return; + } + let action = unsafe { + p::socket_ptr(link).ok().map(|payload| { + let fields = &(*payload).ext; + ( + fields.allow_half_open, + fields.write_ended, + fields.shutdown_done, + ) + }) + }; + if let Some((false, false, _)) = action { + socket::end(owner.get(), p::undefined(), p::undefined(), p::undefined()); + } else if let Some((_, _, true)) = action { + socket::destroy(owner.get(), p::undefined()); + } +} + +pub(crate) fn wrote(owner: f64, user: u64, len: usize, queued: usize) { + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(owner); + let link = socket::link(owner.get()); + if snapshot(link).is_none() { + return; + } + let users = unsafe { + p::socket_ptr(link).ok().map(|payload| { + let fields = &mut (*payload).ext; + fields.bytes_written = fields.bytes_written.saturating_add(len as u64); + fields.queued = queued.saturating_add(fields.cork_bytes.len()); + if fields + .coalesced_users + .front() + .is_some_and(|group| group.0 == user) + { + fields.coalesced_users.pop_front().unwrap().1 + } else { + vec![user] + } + }) + }; + socket::refresh_timeout(link); + // The acknowledged callbacks no longer belong to the writable queue. + // Root them and their original provider before a drain listener can + // destroy/reopen the Socket or enqueue another write. + let callbacks = users + .unwrap_or_default() + .into_iter() + .map(|user| scope.root_nanbox(socket::take_callback(owner.get(), user))) + .collect::>(); + let resource = scope.root_nanbox(provider::resource(owner.get())); + let drain = unsafe { + p::socket_ptr(link).ok().is_some_and(|payload| { + let fields = &mut (*payload).ext; + if fields.need_drain && fields.queued == 0 { + fields.need_drain = false; + true + } else { + false + } + }) + }; + if drain { + events::emit(owner.get(), "drain", &[]); + } + // Node emits drain before the completed write callbacks. They are already + // successful completions, including if the drain listener closed its old + // transport; none of these calls projects the possibly replaced payload. + for callback in callbacks { + if socket::is_callback(callback.get()) { + events::call_in(resource.get(), callback.get(), JsThis::UNDEFINED, &[]); + } + } +} + +pub(crate) fn shutdown(owner: f64, user: u64) { + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(owner); + let link = socket::link(owner.get()); + let Some(snapshot) = snapshot(link) else { + return; + }; + let state = scope.root_nanbox(socket::state(owner.get())); + let resource = scope.root_nanbox(p::own_get(state.get(), provider::SHUTDOWN)); + let users = unsafe { + p::socket_ptr(link).ok().map(|payload| { + let fields = &mut (*payload).ext; + fields.shutdown_done = true; + let mut users = vec![user]; + users.append(&mut fields.extra_end_users); + users + }) + }; + for user in users.unwrap_or_default() { + if !matches(link, &snapshot) { + return; + } + let callback = scope.root_nanbox(socket::take_callback(owner.get(), user)); + if socket::is_callback(callback.get()) { + events::call_in(resource.get(), callback.get(), JsThis::UNDEFINED, &[]); + } + } + if !matches(link, &snapshot) { + return; + } + events::emit_in(resource.get(), owner.get(), "finish", &[]); + provider::retire(resource.get()); + if !matches(link, &snapshot) { + return; + } + let read_ended = unsafe { + p::socket_ptr(link) + .ok() + .is_some_and(|payload| (*payload).ext.read_end_emitted) + }; + if read_ended { + socket::destroy(owner.get(), p::undefined()); + } +} + +pub(crate) fn socket_closed(owner: f64, event: &tl::NetCompletion) { + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(owner); + let record = scope.root_nanbox(prepare_socket_closed(owner.get(), event)); + emit_socket_closed(owner.get(), record.get()); +} + +fn prepare_socket_closed(owner: f64, event: &tl::NetCompletion) -> f64 { + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(owner); + let link = socket::link(owner.get()); + let own = p::socket_link(owner.get()).unwrap(); + let parent = scope.root_nanbox(unsafe { np::link_event_owner(link) }.unwrap_or(owner.get())); + let state = scope.root_nanbox(socket::state(owner.get())); + let mut record = scope.root_nanbox( + unsafe { event.closed_handle_parts() } + .map(|parts| closed::take(state.get(), parts)) + .unwrap_or_else(p::undefined), + ); + if event.flags & tl::NET_FLAG_STALE == 0 && unsafe { p::socket_ptr(link) }.is_ok() { + if !JsValue::from_bits(record.get().to_bits()).is_pointer() { + record = scope.root_nanbox(closed::record( + unsafe { event.closed_handle_parts() }.unwrap_or([0; 4]), + )); + let group = scope.root_nanbox(p::own_get(state.get(), "serverGroup")); + p::own_set(record.get(), "serverGroup", group.get()); + } + socket::cache_before_release(owner.get(), link); + if own != link { + socket::cache_before_release(parent.get(), link); + let parent_state = scope.root_nanbox(socket::state(parent.get())); + let parent_record = scope.root_nanbox(closed::record( + unsafe { event.closed_handle_parts() }.unwrap_or([0; 4]), + )); + let group = scope.root_nanbox(p::own_get(parent_state.get(), "serverGroup")); + p::own_set(parent_record.get(), "serverGroup", group.get()); + provider::capture_socket(parent_state.get(), parent_record.get()); + closed::append(parent_state.get(), parent_record.get()); + } + let error = scope.root_nanbox(socket::error("ERR_STREAM_DESTROYED", "Socket is closed")); + socket::cancel_callbacks(owner.get(), state.get(), error.get()); + provider::capture_socket(state.get(), record.get()); + super::native_transport::release_codec(owner.get()); + // The driver already retired this handle. No close is submitted here. + unsafe { + np::close_link(link, &p::SOCKET); + } + } + if own != link && event.flags & tl::NET_FLAG_STALE == 0 && unsafe { p::socket_ptr(own) }.is_ok() + { + if !JsValue::from_bits(record.get().to_bits()).is_pointer() { + record = scope.root_nanbox(closed::record( + unsafe { event.closed_handle_parts() }.unwrap_or([0; 4]), + )); + socket::cache_before_release(owner.get(), link); + let error = + scope.root_nanbox(socket::error("ERR_STREAM_DESTROYED", "Socket is closed")); + socket::cancel_callbacks(owner.get(), state.get(), error.get()); + provider::capture_socket(state.get(), record.get()); + } + // The driver resource belongs to the parent. Dispose the wrapper's + // idle payload as well, while its ordinary event object remains. + unsafe { + np::close_link(own, &p::SOCKET); + } + } + record.get() +} + +fn emit_socket_closed(owner: f64, record: f64) { + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(owner); + let record = scope.root_nanbox(record); + let record_exists = JsValue::from_bits(record.get().to_bits()).is_pointer(); + let error = scope.root_nanbox(if record_exists { + p::own_get(record.get(), "error") + } else { + p::undefined() + }); + let group = scope.root_nanbox(if record_exists { + p::own_get(record.get(), "serverGroup") + } else { + p::undefined() + }); + let had_error = !JsValue::from_bits(error.get().to_bits()).is_undefined() + && !JsValue::from_bits(error.get().to_bits()).is_null(); + let resource = scope.root_nanbox(if record_exists { + p::own_get(record.get(), provider::TCP) + } else { + p::undefined() + }); + server::child_closed(owner.get(), group.get()); + if had_error { + events::emit_in(resource.get(), owner.get(), "error", &[error.get()]); + } + let had_handle = + !record_exists || p::own_get(record.get(), "hadHandle").to_bits() != JsValue::FALSE.bits(); + let args = if had_handle { + vec![f64::from_bits(JsValue::from_bool(had_error).bits())] + } else { + Vec::new() + }; + events::emit_in(resource.get(), owner.get(), "close", &args); + if record_exists { + provider::retire_socket(record.get()); + } +} + +unsafe extern "C" fn close_tick(closure: *const RawClosureHeader, _: JsThis) -> f64 { + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(perry_ffi::closure_capture_f64(closure, 0)); + let record = scope.root_nanbox(perry_ffi::closure_capture_f64(closure, 1)); + emit_socket_closed(owner.get(), record.get()); + p::undefined() +} + +unsafe extern "C" fn closed_wrapper_tick(closure: *const RawClosureHeader, _: JsThis) -> f64 { + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(perry_ffi::closure_capture_f64(closure, 0)); + let record = scope.root_nanbox(perry_ffi::closure_capture_f64(closure, 1)); + let state = scope.root_nanbox(socket::state(owner.get())); + // Only the wrapper's idle payload is ours. Its parent may already have + // reopened by this tick; never project or close that replacement core. + let own = p::socket_link(owner.get()).unwrap(); + if np::link_lifecycle(own, &p::SOCKET) == Ok(np::Lifecycle::Closed) { + return p::undefined(); + } + p::own_set(state.get(), "tlsParent", p::undefined()); + p::own_set( + state.get(), + "destroyed", + f64::from_bits(JsValue::TRUE.bits()), + ); + np::close_link(own, &p::SOCKET); + emit_socket_closed(owner.get(), record.get()); + p::undefined() +} + +pub(crate) fn queue_closed_wrapper(owner: f64) { + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(owner); + let record = scope.root_nanbox(closed::record([0; 4])); + queue_close_job( + owner.get(), + record.get(), + perry_ffi::js_function_info!(closed_wrapper_tick, 0; with_flags(perry_ffi::FN_BUILTIN)), + ); +} + +pub(crate) fn queue_socket_closed(owner: f64, record: f64) { + #[cfg(test)] + if std::env::var("PERRY_NET_BINDING_SABOTAGE").as_deref() == Ok("late_close") { + emit_socket_closed(owner, record); + return; + } + queue_close_job( + owner, + record, + perry_ffi::js_function_info!(close_tick, 0; with_flags(perry_ffi::FN_BUILTIN)), + ); +} + +fn queue_close_job(owner: f64, record: f64, info: &'static perry_ffi::JsFunctionInfo) { + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(owner); + let record = scope.root_nanbox(record); + let job = scope.root_addr(perry_ffi::alloc_closure(info, 2) as i64); + unsafe { + let ptr = job.get() as *mut RawClosureHeader; + perry_ffi::set_closure_capture_f64(ptr, 0, owner.get()); + perry_ffi::set_closure_capture_f64(ptr, 1, record.get()); + extern "C" { + fn js_queue_next_tick(job: i64); + } + js_queue_next_tick(job.get()); + } +} diff --git a/crates/perry-ext-net/src/payload_pipe.rs b/crates/perry-ext-net/src/payload_pipe.rs new file mode 100644 index 0000000000..acfab7b2a1 --- /dev/null +++ b/crates/perry-ext-net/src/payload_pipe.rs @@ -0,0 +1,201 @@ +//! Pipe callbacks and destination edges live in the owner's ordinary JS state. + +use super::{payload_events as events, payload_socket as socket, payload_transport as p}; +use perry_ffi::{ArrayHeader, JsThis, JsValue, RawClosureHeader, TransientRootScope}; + +extern "C" { + fn js_perry_event_get(owner: f64, key: f64) -> f64; + fn js_node_stream_method_on(owner: i64, event: f64, callback: f64) -> f64; + fn js_node_stream_method_remove_listener(owner: i64, event: f64, callback: f64) -> f64; + fn js_object_alloc_null_proto(class_id: u32, fields: u32) -> *mut perry_ffi::ObjectHeader; +} +fn array(value: f64) -> *mut ArrayHeader { + JsValue::from_bits(value.to_bits()).as_pointer() +} + +fn get(owner: f64, key: &str) -> f64 { + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(owner); + let key = scope.root_nanbox(events::string(key)); + unsafe { js_perry_event_get(owner.get(), key.get()) } +} +fn method(owner: f64, key: &str, args: &[f64]) -> f64 { + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(owner); + let args: Vec<_> = args.iter().map(|&arg| scope.root_nanbox(arg)).collect(); + let callback = scope.root_nanbox(get(owner.get(), key)); + if !socket::is_callback(callback.get()) { + return p::undefined(); + } + let args: Vec<_> = args.iter().map(|arg| arg.get()).collect(); + events::call(callback.get(), JsThis::from_f64(owner.get()), &args) +} +unsafe extern "C" fn data(closure: *const RawClosureHeader, _: JsThis, chunk: f64) -> f64 { + let scope = TransientRootScope::enter(); + let source = scope.root_nanbox(perry_ffi::closure_capture_f64(closure, 0)); + let dest = scope.root_nanbox(perry_ffi::closure_capture_f64(closure, 1)); + let chunk = scope.root_nanbox(chunk); + let link = socket::link(source.get()); + let snapshot = super::payload_io::snapshot(link); + let result = method(dest.get(), "write", &[chunk.get()]); + if result.to_bits() == JsValue::FALSE.bits() + && snapshot + .as_ref() + .is_some_and(|snapshot| super::payload_io::matches(link, snapshot)) + { + socket::set_paused(source.get(), true); + } + p::undefined() +} +unsafe extern "C" fn end(closure: *const RawClosureHeader, _: JsThis) -> f64 { + let scope = TransientRootScope::enter(); + let dest = scope.root_nanbox(perry_ffi::closure_capture_f64(closure, 0)); + method(dest.get(), "end", &[]); + p::undefined() +} +unsafe extern "C" fn drain(closure: *const RawClosureHeader, _: JsThis) -> f64 { + let scope = TransientRootScope::enter(); + let source = scope.root_nanbox(perry_ffi::closure_capture_f64(closure, 0)); + // This is an ordinary stream control callback. A released Socket remains + // the same owner; resume must not attach native state or create a handle. + socket::set_paused(source.get(), false); + p::undefined() +} +fn callback(info: &'static perry_ffi::JsFunctionInfo, captures: &[f64]) -> f64 { + let scope = TransientRootScope::enter(); + let captures: Vec<_> = captures + .iter() + .map(|&value| scope.root_nanbox(value)) + .collect(); + let callback = scope.root_addr(perry_ffi::alloc_closure(info, captures.len() as u32) as i64); + for (index, value) in captures.iter().enumerate() { + unsafe { + perry_ffi::set_closure_capture_f64( + callback.get() as *mut RawClosureHeader, + index as u32, + value.get(), + ); + } + } + p::boxed_addr(callback.get()) +} +fn listen(owner: f64, event: &str, callback: f64, remove: bool) { + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(owner); + let callback = scope.root_nanbox(callback); + let event = scope.root_nanbox(events::string(event)); + unsafe { + if remove { + js_node_stream_method_remove_listener( + p::raw_owner(owner.get()), + event.get(), + callback.get(), + ); + } else { + js_node_stream_method_on(p::raw_owner(owner.get()), event.get(), callback.get()); + } + } +} +pub(crate) fn pipe(owner: f64, dest: f64, options: f64) -> f64 { + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(owner); + let dest = scope.root_nanbox(dest); + let options = scope.root_nanbox(options); + socket::link(owner.get()); + let state = scope.root_nanbox(socket::state(owner.get())); + let ends = !JsValue::from_bits(options.get().to_bits()).is_pointer() + || get(options.get(), "end").to_bits() != JsValue::FALSE.bits(); + let record = scope.root_nanbox(p::boxed_addr( + unsafe { js_object_alloc_null_proto(0, 4) } as i64 + )); + let on_data = scope.root_nanbox(callback( + perry_ffi::js_function_info!(data, 1; with_flags(perry_ffi::FN_BUILTIN)), + &[owner.get(), dest.get()], + )); + let on_end = scope.root_nanbox(if ends { + callback( + perry_ffi::js_function_info!(end, 0; with_flags(perry_ffi::FN_BUILTIN)), + &[dest.get()], + ) + } else { + p::undefined() + }); + let on_drain = scope.root_nanbox(callback( + perry_ffi::js_function_info!(drain, 0; with_flags(perry_ffi::FN_BUILTIN)), + &[owner.get()], + )); + p::own_set(record.get(), "dest", dest.get()); + p::own_set(record.get(), "data", on_data.get()); + p::own_set(record.get(), "end", on_end.get()); + p::own_set(record.get(), "drain", on_drain.get()); + let mut records = scope.root_nanbox(p::own_get(state.get(), "pipes")); + if !JsValue::from_bits(records.get().to_bits()).is_pointer() { + records = scope.root_nanbox(p::boxed_addr(unsafe { perry_ffi::js_array_alloc(0) } as i64)); + } + let updated = unsafe { + perry_ffi::js_array_push( + array(records.get()), + JsValue::from_bits(record.get().to_bits()), + ) + }; + p::own_set(state.get(), "pipes", p::boxed_addr(updated as i64)); + listen(owner.get(), "data", on_data.get(), false); + if ends { + listen(owner.get(), "end", on_end.get(), false); + } + listen(dest.get(), "drain", on_drain.get(), false); + events::emit(dest.get(), "pipe", &[owner.get()]); + socket::flow(owner.get()); + dest.get() +} +pub(crate) fn unpipe(owner: f64, dest: f64) -> f64 { + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(owner); + let dest = scope.root_nanbox(dest); + socket::link(owner.get()); + let state = scope.root_nanbox(socket::state(owner.get())); + let records = scope.root_nanbox(p::own_get(state.get(), "pipes")); + if !JsValue::from_bits(records.get().to_bits()).is_pointer() { + return owner.get(); + } + let count = unsafe { perry_ffi::js_array_length(array(records.get())) }; + let values: Vec<_> = (0..count) + .map(|i| { + scope.root_nanbox(f64::from_bits(unsafe { + perry_ffi::js_array_get(array(records.get()), i).bits() + })) + }) + .collect(); + let mut keep = scope.root_addr(unsafe { perry_ffi::js_array_alloc(count) } as i64); + let mut removed = Vec::new(); + for value in values { + let target = scope.root_nanbox(p::own_get(value.get(), "dest")); + if JsValue::from_bits(dest.get().to_bits()).is_undefined() + || target.get().to_bits() == dest.get().to_bits() + { + removed.push(value); + } else { + keep = scope.root_addr(unsafe { + perry_ffi::js_array_push( + keep.get() as *mut ArrayHeader, + JsValue::from_bits(value.get().to_bits()), + ) + } as i64); + } + } + // Publish removal before any destination listener can pipe again. + p::own_set(state.get(), "pipes", p::boxed_addr(keep.get())); + for record in removed { + let target = scope.root_nanbox(p::own_get(record.get(), "dest")); + let on_data = scope.root_nanbox(p::own_get(record.get(), "data")); + let on_end = scope.root_nanbox(p::own_get(record.get(), "end")); + let on_drain = scope.root_nanbox(p::own_get(record.get(), "drain")); + listen(owner.get(), "data", on_data.get(), true); + if socket::is_callback(on_end.get()) { + listen(owner.get(), "end", on_end.get(), true); + } + listen(target.get(), "drain", on_drain.get(), true); + events::emit(target.get(), "unpipe", &[owner.get()]); + } + owner.get() +} diff --git a/crates/perry-ext-net/src/payload_prototype.rs b/crates/perry-ext-net/src/payload_prototype.rs new file mode 100644 index 0000000000..c521d5b854 --- /dev/null +++ b/crates/perry-ext-net/src/payload_prototype.rs @@ -0,0 +1,325 @@ +//! Ordinary Socket and Server prototype methods. There is no handle dispatcher. + +use super::{payload_socket as socket, payload_transport as p}; +use perry_ffi::native_payload::{PayloadMiss, PayloadPrototype}; +use perry_ffi::{JsThis, JsValue, RawClosureHeader, TransientRootScope}; +use std::ffi::c_void; + +pub(crate) fn throw_miss(miss: PayloadMiss) -> T { + extern "C" { + fn js_typeerror_new(message: *mut perry_ffi::StringHeader) -> *mut u8; + } + #[cfg(panic = "abort")] + extern "C" { + fn js_throw(value: f64) -> !; + } + #[cfg(not(panic = "abort"))] + extern "C-unwind" { + fn js_throw(value: f64) -> !; + } + let message = perry_ffi::alloc_string(match miss { + PayloadMiss::Foreign => "Illegal receiver", + PayloadMiss::Closed => "Native payload is closed", + }); + unsafe { + js_throw(f64::from_bits( + JsValue::from_object_ptr(js_typeerror_new(message.as_raw())).bits(), + )) + } +} + +extern "C" { + fn js_node_stream_method_on(owner: i64, event: f64, callback: f64) -> f64; + fn js_node_stream_method_once(owner: i64, event: f64, callback: f64) -> f64; + fn js_node_stream_method_prepend_listener(owner: i64, event: f64, callback: f64) -> f64; + fn js_node_stream_method_prepend_once_listener(owner: i64, event: f64, callback: f64) -> f64; +} + +macro_rules! method0 { + ($name:ident, $body:expr) => { + unsafe extern "C" fn $name(_: *const RawClosureHeader, this: JsThis) -> f64 { + $body(this.as_f64()) + } + }; +} +macro_rules! method1 { + ($name:ident, $body:expr) => { + unsafe extern "C" fn $name(_: *const RawClosureHeader, this: JsThis, a: f64) -> f64 { + $body(this.as_f64(), a) + } + }; +} +macro_rules! method3 { + ($name:ident, $body:expr) => { + unsafe extern "C" fn $name( + _: *const RawClosureHeader, + this: JsThis, + a: f64, + b: f64, + c: f64, + ) -> f64 { + $body(this.as_f64(), a, b, c) + } + }; +} +method3!(connect, socket::connect); +method3!(write, socket::write); +method3!(end, socket::end); +method1!(destroy, socket::destroy); +method1!(read, socket::read); +method0!(cork, |owner| socket::cork(owner, false)); +method0!(uncork, |owner| socket::cork(owner, true)); +method0!(reference, |owner| socket::set_ref(owner, true)); +method0!(unreference, |owner| socket::set_ref(owner, false)); +method0!(pause, |owner| socket::set_paused(owner, true)); +method0!(resume, |owner| socket::set_paused(owner, false)); +method0!(reset_destroy, |owner| socket::destroy( + owner, + p::undefined() +)); +method0!(address, socket::address); +method1!(encoding, socket::set_encoding); +method1!(tos, socket::set_tos); +method0!(get_tos, |owner| socket::get(owner, "typeOfService")); +method3!(listen, super::payload_server::listen); +method1!(close_server, super::payload_server::close); +method0!(server_address, super::payload_server::address); +method1!(connections, super::payload_server::get_connections); +method0!(server_reference, |owner| super::payload_server::set_ref( + owner, true +)); +method0!(server_unreference, |owner| super::payload_server::set_ref( + owner, false +)); +method3!(keep_alive, |owner, _, _, _| { + socket::link(owner); + owner +}); +method1!(no_delay, |owner, _| { + socket::link(owner); + owner +}); + +unsafe extern "C" fn timeout( + _: *const RawClosureHeader, + this: JsThis, + ms: f64, + callback: f64, +) -> f64 { + socket::set_timeout(this.as_f64(), ms, callback) +} +unsafe extern "C" fn pipe( + _: *const RawClosureHeader, + this: JsThis, + dest: f64, + options: f64, +) -> f64 { + super::payload_pipe::pipe(this.as_f64(), dest, options) +} +unsafe extern "C" fn unpipe(_: *const RawClosureHeader, this: JsThis, dest: f64) -> f64 { + super::payload_pipe::unpipe(this.as_f64(), dest) +} + +macro_rules! add_listener { + ($name:ident, $runtime:ident) => { + unsafe extern "C" fn $name( + _: *const RawClosureHeader, + this: JsThis, + event: f64, + callback: f64, + ) -> f64 { + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(this.as_f64()); + let event = scope.root_nanbox(event); + let callback = scope.root_nanbox(callback); + socket::link(owner.get()); + $runtime(p::raw_owner(owner.get()), event.get(), callback.get()); + if crate::jsvalue_to_owned_string(event.get()).as_deref() == Some("data") { + socket::flow(owner.get()); + } + owner.get() + } + }; +} +add_listener!(on, js_node_stream_method_on); +add_listener!(once, js_node_stream_method_once); +add_listener!(prepend, js_node_stream_method_prepend_listener); +add_listener!(prepend_once, js_node_stream_method_prepend_once_listener); + +macro_rules! socket_getters { + ($($name:ident => $key:literal),+ $(,)?) => { $(method0!($name, |owner| socket::get(owner, $key));)+ }; +} +socket_getters!( + destroyed => "destroyed", connecting => "connecting", pending => "pending", + writable => "writable", readable => "readable", writable_ended => "writableEnded", + readable_ended => "readableEnded", writable_finished => "writableFinished", + need_drain => "writableNeedDrain", bytes_read => "bytesRead", bytes_written => "bytesWritten", + length => "writableLength", buffer_size => "bufferSize", corked => "writableCorked", + hwm => "writableHighWaterMark", read_hwm => "readableHighWaterMark", timeout_value => "timeout", + tos_value => "typeOfService", ready_state => "readyState", readable_encoding => "readableEncoding", + local_address => "localAddress", local_port => "localPort", local_family => "localFamily", + remote_address => "remoteAddress", remote_port => "remotePort", remote_family => "remoteFamily", + readable_state => "_readableState", writable_state => "_writableState", + flowing => "readableFlowing" +); +method0!(is_paused, |owner| socket::get(owner, "isPaused")); +method0!(listening, |owner| super::payload_server::get( + owner, + "listening" +)); + +method0!(tls_encrypted, |owner| p::own_get( + socket::state(owner), + "encrypted" +)); +method0!(tls_authorized, |owner| p::own_get( + socket::state(owner), + "authorized" +)); +method0!(tls_servername, |owner| p::own_get( + socket::state(owner), + "servername" +)); +method0!(tls_alpn, |owner| p::own_get( + socket::state(owner), + "alpnProtocol" +)); +method0!(tls_auth_error, |owner| p::own_get( + socket::state(owner), + "authorizationError" +)); +method0!(tls_cipher, |owner| p::own_get( + socket::state(owner), + "cipher" +)); +method0!(tls_protocol, |owner| { + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(owner); + let state = scope.root_nanbox(socket::state(owner.get())); + if JsValue::from_bits(p::own_get(state.get(), "destroyed").to_bits()).to_bool() { + f64::from_bits(JsValue::NULL.bits()) + } else { + p::own_get(state.get(), "protocol") + } +}); +method0!(tls_session_reused, |owner| { + let value = p::own_get(socket::state(owner), "sessionSupplied"); + f64::from_bits(JsValue::from_bool(JsValue::from_bits(value.to_bits()).to_bool()).bits()) +}); +method0!(tls_session, |owner| { + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(owner); + let state = scope.root_nanbox(socket::state(owner.get())); + let session = scope.root_nanbox(p::own_get(state.get(), "session")); + if JsValue::from_bits(session.get().to_bits()).is_pointer() { + return session.get(); + } + if !JsValue::from_bits(p::own_get(state.get(), "tlsConnected").to_bits()).to_bool() + || JsValue::from_bits(p::own_get(state.get(), "destroyed").to_bits()).to_bool() + { + return p::undefined(); + } + let der = p::own_get(state.get(), "peerCertificateDer"); + let bytes = crate::jsvalue_to_socket_bytes(der).unwrap_or_default(); + if bytes.is_empty() { + p::undefined() + } else { + p::boxed_addr(perry_ffi::alloc_buffer(&bytes[..bytes.len().min(64)]) as i64) + } +}); +extern "C" { + fn js_tls_client_certificate(owner: i64, own: i32, detailed: f64) -> f64; +} +method1!(tls_peer_certificate, |owner, detailed| { + socket::link(owner); + unsafe { js_tls_client_certificate(p::raw_owner(owner), 0, detailed) } +}); +method0!(tls_own_certificate, |owner| { + socket::link(owner); + unsafe { js_tls_client_certificate(p::raw_owner(owner), 1, p::undefined()) } +}); +unsafe extern "C" fn tls_upgrade( + _: *const RawClosureHeader, + this: JsThis, + name: f64, + verify: f64, +) -> f64 { + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(this.as_f64()); + let verify = scope.root_nanbox(verify); + let name = scope.root_nanbox(name); + let name = crate::jsvalue_to_owned_string(name.get()).unwrap_or_default(); + let name = scope.root_addr(perry_ffi::alloc_string(&name).as_raw() as i64); + p::boxed_addr(crate::js_net_socket_upgrade_tls( + p::raw_owner(owner.get()), + name.get(), + if JsValue::from_bits(verify.get().to_bits()).is_undefined() { + 1.0 + } else { + if JsValue::from_bits(verify.get().to_bits()).to_bool() { + 1.0 + } else { + 0.0 + } + }, + ) as i64) +} + +macro_rules! methods { + ($proto:ident; $($name:literal => $fn:ident / $arity:tt / $length:literal),+ $(,)?) => { + $($proto.method($name, perry_ffi::js_function_info!($fn, $arity; with_flags(perry_ffi::FN_BUILTIN)), $length);)+ + }; +} +macro_rules! getters { + ($proto:ident; $($name:literal => $fn:ident),+ $(,)?) => { + $($proto.getter($name, perry_ffi::js_function_info!($fn, 0; with_flags(perry_ffi::FN_BUILTIN)));)+ + }; +} + +pub(crate) unsafe extern "C" fn install_socket(raw: *mut c_void) { + let mut proto = PayloadPrototype::from_raw(raw); + // The canonical net.Socket constructor already links this prototype to + // Duplex.prototype. Keep that exact chain and its EventEmitter parent. + methods!(proto; + "getProtocol" => tls_protocol/0/0, "getCipher" => tls_cipher/0/0, + "getPeerCertificate" => tls_peer_certificate/1/0, "getCertificate" => tls_own_certificate/0/0, + "getSession" => tls_session/0/0, "isSessionReused" => tls_session_reused/0/0, + "upgradeToTLS" => tls_upgrade/2/2, "connect" => connect/3/0, "write" => write/3/3, "end" => end/3/3, + "destroy" => destroy/1/1, "resetAndDestroy" => reset_destroy/0/0, + "read" => read/1/1, "cork" => cork/0/0, "uncork" => uncork/0/0, + "ref" => reference/0/0, "unref" => unreference/0/0, + "pause" => pause/0/0, "resume" => resume/0/0, "isPaused" => is_paused/0/0, + "address" => address/0/0, "setTimeout" => timeout/2/2, + "setEncoding" => encoding/1/1, "setTypeOfService" => tos/1/1, + "getTypeOfService" => get_tos/0/0, "setNoDelay" => no_delay/1/0, + "setKeepAlive" => keep_alive/3/0, "pipe" => pipe/2/2, "unpipe" => unpipe/1/1, + "on" => on/2/2, "addListener" => on/2/2, "once" => once/2/2, + "prependListener" => prepend/2/2, "prependOnceListener" => prepend_once/2/2 + ); + getters!(proto; + "encrypted" => tls_encrypted, "authorized" => tls_authorized, + "servername" => tls_servername, "alpnProtocol" => tls_alpn, + "authorizationError" => tls_auth_error, "destroyed" => destroyed, "connecting" => connecting, "pending" => pending, + "writable" => writable, "readable" => readable, "writableEnded" => writable_ended, + "readableEnded" => readable_ended, "writableFinished" => writable_finished, + "writableNeedDrain" => need_drain, "bytesRead" => bytes_read, "bytesWritten" => bytes_written, + "writableLength" => length, "bufferSize" => buffer_size, "writableCorked" => corked, + "writableHighWaterMark" => hwm, "readableHighWaterMark" => read_hwm, + "timeout" => timeout_value, "typeOfService" => tos_value, "readyState" => ready_state, + "readableEncoding" => readable_encoding, "localAddress" => local_address, + "localPort" => local_port, "localFamily" => local_family, "remoteAddress" => remote_address, + "remotePort" => remote_port, "remoteFamily" => remote_family, + "_readableState" => readable_state, "_writableState" => writable_state, + "readableFlowing" => flowing + ); +} + +pub(crate) unsafe extern "C" fn install_server(raw: *mut c_void) { + let mut proto = PayloadPrototype::from_raw(raw); + // net.Server's canonical prototype already inherits EventEmitter. + methods!(proto; "listen" => listen/3/0, "close" => close_server/1/1, + "address" => server_address/0/0, "getConnections" => connections/1/1, + "ref" => server_reference/0/0, "unref" => server_unreference/0/0 + ); + getters!(proto; "listening" => listening); +} diff --git a/crates/perry-ext-net/src/payload_provider.rs b/crates/perry-ext-net/src/payload_provider.rs new file mode 100644 index 0000000000..adb7e6754e --- /dev/null +++ b/crates/perry-ext-net/src/payload_provider.rs @@ -0,0 +1,79 @@ +//! Async provider objects are ordinary JS edges, never SocketFields members. + +use super::{payload_server as server, payload_socket as socket, payload_transport as p}; +use perry_ffi::{JsValue, TransientRootScope}; + +extern "C" { + fn js_async_hooks_owned_provider_new(name: *const u8, len: usize, trigger: u64) -> f64; + fn js_async_hooks_owned_provider_emit_init(resource: f64); + fn js_async_resource_async_id(owner: i64) -> f64; + fn js_async_hooks_owned_provider_destroy(resource: f64); +} + +pub(crate) const TCP: &str = "tcpResource"; +pub(crate) const CONNECT: &str = "connectResource"; +pub(crate) const SHUTDOWN: &str = "shutdownResource"; +pub(crate) const SERVER: &str = "serverResource"; + +pub(crate) fn new(name: &'static [u8], trigger: u64) -> f64 { + unsafe { js_async_hooks_owned_provider_new(name.as_ptr(), name.len(), trigger) } +} +pub(crate) fn notify(resource: f64) { + unsafe { + js_async_hooks_owned_provider_emit_init(resource); + } +} +pub(crate) fn id(resource: f64) -> u64 { + if !JsValue::from_bits(resource.to_bits()).is_pointer() { + return 0; + } + let value = unsafe { js_async_resource_async_id(p::raw_owner(resource)) }; + JsValue::from_bits(value.to_bits()).to_number().max(0.0) as u64 +} +pub(crate) fn retire(resource: f64) { + unsafe { + js_async_hooks_owned_provider_destroy(resource); + } +} + +/// The state edge is installed before init hooks can call JS. The native ids +/// are installed by the caller before notify, with no native borrow across it. +pub(crate) fn publish(state: f64, key: &str, name: &'static [u8], trigger: u64) -> f64 { + let scope = TransientRootScope::enter(); + let state = scope.root_nanbox(state); + let resource = scope.root_nanbox(new(name, trigger)); + p::own_set(state.get(), key, resource.get()); + resource.get() +} +pub(crate) fn resource(owner: f64) -> f64 { + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(owner); + if p::server_link(owner.get()).is_ok() { + let state = scope.root_nanbox(server::state(owner.get())); + p::own_get(state.get(), SERVER) + } else if p::socket_link(owner.get()).is_ok() { + let state = scope.root_nanbox(socket::state(owner.get())); + p::own_get(state.get(), TCP) + } else { + p::undefined() + } +} +pub(crate) fn capture_socket(state: f64, record: f64) { + let scope = TransientRootScope::enter(); + let state = scope.root_nanbox(state); + let record = scope.root_nanbox(record); + for key in [TCP, CONNECT, SHUTDOWN] { + let resource = scope.root_nanbox(p::own_get(state.get(), key)); + p::own_set(record.get(), key, resource.get()); + p::own_set(state.get(), key, p::undefined()); + } +} +pub(crate) fn retire_socket(record: f64) { + let scope = TransientRootScope::enter(); + let record = scope.root_nanbox(record); + for key in [CONNECT, SHUTDOWN, TCP] { + let resource = scope.root_nanbox(p::own_get(record.get(), key)); + retire(resource.get()); + p::own_set(record.get(), key, p::undefined()); + } +} diff --git a/crates/perry-ext-net/src/payload_server.rs b/crates/perry-ext-net/src/payload_server.rs new file mode 100644 index 0000000000..16c0c3a1f7 --- /dev/null +++ b/crates/perry-ext-net/src/payload_server.rs @@ -0,0 +1,735 @@ +//! Listener methods and accepted child ownership on ordinary JS objects. + +use super::payload_transport::{self as p, ServerFields, SERVER}; +use super::{ + payload_closed as closed, payload_events as events, payload_provider as provider, + payload_socket as socket, +}; +use perry_ffi::native_payload::{self as np, OwnerLink, PayloadMiss}; +use perry_ffi::turnloop_net as tl; +use perry_ffi::{ArrayHeader, JsThis, JsValue, RawClosureHeader, TransientRootScope}; + +extern "C" { + fn js_net_validate_listen_port(port: f64); + fn js_node_stream_method_once(owner: i64, event: f64, callback: f64) -> f64; +} + +pub(crate) fn link(owner: f64) -> OwnerLink { + p::server_link(owner).unwrap_or_else(super::payload_prototype::throw_miss) +} +pub(crate) fn state(owner: f64) -> f64 { + unsafe { p::server_state(link(owner), true) } +} + +pub(crate) fn new_server(route: u8, options: f64, callback: f64) -> f64 { + initialize_server(route, options, callback, None) +} +pub(crate) fn initialize_server( + route: u8, + options: f64, + callback: f64, + receiver: Option, +) -> f64 { + let scope = TransientRootScope::enter(); + let receiver = receiver.map(|value| scope.root_nanbox(value)); + let options = scope.root_nanbox(options); + let callback = scope.root_nanbox(if socket::is_callback(options.get()) { + options.get() + } else { + callback + }); + let fields = ServerFields { + allow_half_open: unsafe { crate::get_object_bool_field(options.get(), "allowHalfOpen") } + .unwrap_or(false), + pause_on_connect: unsafe { crate::get_object_bool_field(options.get(), "pauseOnConnect") } + .unwrap_or(false), + ..ServerFields::default() + }; + let options = (fields.allow_half_open, fields.pause_on_connect); + let owner = scope.root_nanbox(match receiver { + Some(receiver) => { + if !p::attach_server(receiver.get(), route, fields) { + super::payload_prototype::throw_miss::<()>(PayloadMiss::Foreign); + } + receiver.get() + } + None => p::alloc_server(route, fields), + }); + let state = scope.root_nanbox(state(owner.get())); + p::own_set(owner.get(), "_connections", 0.0); + p::own_set(state.get(), "refed", f64::from_bits(JsValue::TRUE.bits())); + p::own_set( + state.get(), + "allowHalfOpen", + f64::from_bits(JsValue::from_bool(options.0).bits()), + ); + p::own_set( + state.get(), + "pauseOnConnect", + f64::from_bits(JsValue::from_bool(options.1).bits()), + ); + if socket::is_callback(callback.get()) { + extern "C" { + fn js_node_stream_method_on(owner: i64, event: f64, callback: f64) -> f64; + } + unsafe { + js_node_stream_method_on( + p::raw_owner(owner.get()), + events::string("connection"), + callback.get(), + ); + } + } + owner.get() +} + +fn once(owner: f64, event: &str, callback: f64) { + if !socket::is_callback(callback) { + return; + } + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(owner); + let callback = scope.root_nanbox(callback); + unsafe { + js_node_stream_method_once( + p::raw_owner(owner.get()), + events::string(event), + callback.get(), + ); + } +} + +pub(crate) fn listen(owner: f64, a: f64, b: f64, c: f64) -> f64 { + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(owner); + let a = scope.root_nanbox(a); + let b = scope.root_nanbox(b); + let c = scope.root_nanbox(c); + let server = link(owner.get()); + let _account = p::AccountServer(server); + let (path, host, port, backlog, callback) = unsafe { + if let Some(path) = crate::jsvalue_to_owned_string(a.get()) { + (Some(path), String::new(), 0.0, 511, b.get()) + } else if JsValue::from_bits(a.get().to_bits()).is_pointer() { + ( + crate::get_object_string_field(a.get(), "path"), + crate::get_object_string_field(a.get(), "host").unwrap_or_else(|| "::".into()), + crate::get_object_number_field(a.get(), "port").unwrap_or(0.0), + crate::get_object_number_field(a.get(), "backlog").unwrap_or(511.0) as u32, + b.get(), + ) + } else { + let host = crate::jsvalue_to_owned_string(b.get()); + let callback = if host.is_some() { c.get() } else { b.get() }; + ( + None, + host.unwrap_or_else(|| "::".into()), + JsValue::from_bits(a.get().to_bits()).to_number(), + 511, + callback, + ) + } + }; + let callback = scope.root_nanbox(callback); + if path.is_none() { + unsafe { + js_net_validate_listen_port(port); + } + } + once(owner.get(), "listening", callback.get()); + unsafe { + if np::link_lifecycle(server, &SERVER) == Ok(np::Lifecycle::Closed) { + let state = scope.root_nanbox(state(owner.get())); + let allow_half_open = + JsValue::from_bits(p::own_get(state.get(), "allowHalfOpen").to_bits()).to_bool(); + let pause_on_connect = + JsValue::from_bits(p::own_get(state.get(), "pauseOnConnect").to_bits()).to_bool(); + p::reopen_server( + server, + super::payload_io::ROUTE, + ServerFields { + allow_half_open, + pause_on_connect, + ..ServerFields::default() + }, + ) + .unwrap_or_else(|_| super::payload_prototype::throw_miss::<()>(PayloadMiss::Closed)); + } + let listening = (*p::server_ptr(server) + .unwrap_or_else(super::payload_prototype::throw_miss)) + .ext + .listening; + if listening { + perry_ffi::throw_with_code( + "Listen method has been called more than once without closing.", + "ERR_SERVER_ALREADY_LISTEN", + perry_ffi::ErrorKind::Error, + ); + } + } + let result = unsafe { + let core = p::server_core(server).unwrap_or_else(super::payload_prototype::throw_miss); + let result = match &path { + Some(path) => tl::link_pipe_listen(&mut *core, server, path, backlog), + None => { + tl::link_tcp_listen(&mut *core, server, &host, port as u16, backlog, false, true) + } + }; + result.and_then(|_| tl::link_accept_start(&mut *core, server)) + }; + if let Err(failure) = result { + let error = socket::error(&failure.code, &failure.message()); + events::queue_emit(owner.get(), "error", &[error]); + close(owner.get(), p::undefined()); + return owner.get(); + } + finish_listen(owner.get(), path); + owner.get() +} + +/// The HTTP listener uses the same Server acceptance and child ownership, +/// retaining this ordinary object on its logical HTTP server. Binding errors +/// return synchronously to its existing deferred error path. +pub(crate) fn listen_tcp( + owner: f64, + host: &str, + port: u16, + backlog: u32, + reuse_port: bool, + no_delay: bool, + tls_config: Option>, +) -> Result { + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(owner); + let server = link(owner.get()); + let _account = p::AccountServer(server); + unsafe { + if np::link_lifecycle(server, &SERVER) == Ok(np::Lifecycle::Closed) { + let state = scope.root_nanbox(state(owner.get())); + p::reopen_server( + server, + super::payload_io::ROUTE, + ServerFields { + allow_half_open: JsValue::from_bits( + p::own_get(state.get(), "allowHalfOpen").to_bits(), + ) + .to_bool(), + pause_on_connect: JsValue::from_bits( + p::own_get(state.get(), "pauseOnConnect").to_bits(), + ) + .to_bool(), + ..ServerFields::default() + }, + ) + .unwrap_or_else(|_| super::payload_prototype::throw_miss::<()>(PayloadMiss::Closed)); + } + (*p::server_ptr(server).unwrap_or_else(super::payload_prototype::throw_miss)) + .ext + .tls_config = tls_config; + } + let endpoint = unsafe { + let core = p::server_core(server).unwrap_or_else(super::payload_prototype::throw_miss); + tl::link_tcp_listen( + &mut *core, server, host, port, backlog, reuse_port, no_delay, + )?; + tl::link_accept_start(&mut *core, server)?; + tl::link_local_address(&mut *core, server).expect("bound listener has an address") + }; + finish_listen(owner.get(), None); + Ok(endpoint) +} + +fn finish_listen(owner: f64, path: Option) { + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(owner); + let server = link(owner.get()); + let parts = unsafe { + let core = p::server_core(server).unwrap(); + let endpoint = tl::link_local_address(&mut *core, server); + let local = endpoint.and_then(|endpoint| { + endpoint + .address + .parse() + .ok() + .map(|ip| std::net::SocketAddr::new(ip, endpoint.port)) + }); + let fields = &mut (*p::server_ptr(server).unwrap()).ext; + fields.local = local; + fields.path = path; + fields.listening = true; + tl::link_handle_parts(&mut *core, server).expect("bound listener has a driver handle") + }; + let state = scope.root_nanbox(state(owner.get())); + let group = scope.root_nanbox(closed::record(parts)); + p::own_set(group.get(), "server", owner.get()); + p::own_set( + group.get(), + "closing", + f64::from_bits(JsValue::FALSE.bits()), + ); + p::own_set( + group.get(), + "listenerClosed", + f64::from_bits(JsValue::FALSE.bits()), + ); + let children = unsafe { perry_ffi::js_array_alloc(0) }; + p::own_set(group.get(), "children", p::boxed_addr(children as i64)); + p::own_set(state.get(), "activeGroup", group.get()); + let resource = scope.root_nanbox(provider::publish( + state.get(), + provider::SERVER, + b"TCPSERVERWRAP", + u64::MAX, + )); + unsafe { + (*p::server_ptr(server).unwrap()).ext.async_id = provider::id(resource.get()); + } + p::own_set(group.get(), provider::SERVER, resource.get()); + events::queue_emit(owner.get(), "listening", &[]); + provider::notify(resource.get()); +} + +pub(crate) fn close(owner: f64, callback: f64) -> f64 { + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(owner); + let callback = scope.root_nanbox(callback); + let server = link(owner.get()); + let _account = p::AccountServer(server); + let state = scope.root_nanbox(state(owner.get())); + let listening = unsafe { + p::server_ptr(server) + .ok() + .is_some_and(|payload| (*payload).ext.listening) + }; + if !listening { + if socket::is_callback(callback.get()) { + let error = scope.root_nanbox(socket::error( + "ERR_SERVER_NOT_RUNNING", + "Server is not running.", + )); + let wrapper = scope.root_addr(perry_ffi::alloc_closure( + perry_ffi::js_function_info!(not_running_callback, 0; with_flags(perry_ffi::FN_BUILTIN)), 2, + ) as i64); + unsafe { + let wrapper = wrapper.get() as *mut RawClosureHeader; + perry_ffi::set_closure_capture_f64(wrapper, 0, callback.get()); + perry_ffi::set_closure_capture_f64(wrapper, 1, error.get()); + } + once(owner.get(), "close", p::boxed_addr(wrapper.get())); + } + if unsafe { p::server_ptr(server) }.is_ok() { + unsafe { + np::close_link(server, &SERVER); + } + } + if JsValue::from_bits(p::own_get(owner.get(), "_connections").to_bits()).to_number() == 0.0 + { + events::queue_emit(owner.get(), "close", &[]); + } + return owner.get(); + } + once(owner.get(), "close", callback.get()); + let group = scope.root_nanbox(p::own_get(state.get(), "activeGroup")); + let (path, options) = unsafe { + let fields = &(*p::server_ptr(server).unwrap()).ext; + ( + fields.path.clone(), + (fields.allow_half_open, fields.pause_on_connect), + ) + }; + // Unlink synchronously. A late old Closed must never unlink a new bind. + #[cfg(unix)] + if let Some(path) = path { + let _ = std::fs::remove_file(path); + } + #[cfg(not(unix))] + let _ = path; + let owed = unsafe { tl::link_close(&mut *p::server_core(server).unwrap(), server) }; + let owed = match owed { + Ok(owed) => owed, + Err(failure) => { + let error = socket::error(&failure.code, &failure.message()); + events::queue_emit(owner.get(), "error", &[error]); + return owner.get(); + } + }; + unsafe { + np::close_link(server, &SERVER); + } + p::own_set( + state.get(), + "allowHalfOpen", + f64::from_bits(JsValue::from_bool(options.0).bits()), + ); + p::own_set( + state.get(), + "pauseOnConnect", + f64::from_bits(JsValue::from_bool(options.1).bits()), + ); + if JsValue::from_bits(group.get().to_bits()).is_pointer() { + p::own_set(group.get(), "closing", f64::from_bits(JsValue::TRUE.bits())); + if owed { + closed::append(state.get(), group.get()); + } else { + p::own_set( + group.get(), + "listenerClosed", + f64::from_bits(JsValue::TRUE.bits()), + ); + finish_group(group.get(), true); + } + } else if !owed { + events::queue_emit(owner.get(), "close", &[]); + } + owner.get() +} + +unsafe extern "C" fn not_running_callback(closure: *const RawClosureHeader, this: JsThis) -> f64 { + // The enclosing emitter owns the trap. Nothing owned is live in this + // forwarding frame when the user callback throws through it. + let callback = perry_ffi::closure_capture_f64(closure, 0); + let error = perry_ffi::closure_capture_f64(closure, 1); + perry_ffi::call_value(callback, this, &[error]) +} + +/// `server.address()` as JSON (`null` when not listening), for callers that +/// hold the server as a raw address rather than a JS value. +pub(crate) fn address_json(owner: f64) -> String { + let server = link(owner); + let address = unsafe { + p::server_ptr(server).ok().and_then(|payload| { + let fields = &(*payload).ext; + fields + .listening + .then(|| (fields.path.clone(), fields.local)) + }) + }; + match address { + Some((Some(path), _)) => { + serde_json::to_string(&path).unwrap_or_else(|_| "null".to_string()) + } + Some((None, Some(local))) => format!( + "{{\"port\":{},\"address\":\"{}\",\"family\":\"{}\"}}", + local.port(), + local.ip(), + if local.is_ipv4() { "IPv4" } else { "IPv6" } + ), + _ => "null".to_string(), + } +} + +pub(crate) fn address(owner: f64) -> f64 { + let server = link(owner); + let address = unsafe { + p::server_ptr(server).ok().and_then(|payload| { + let fields = &(*payload).ext; + fields + .listening + .then(|| (fields.path.clone(), fields.local)) + }) + }; + let Some((path, local)) = address else { + return f64::from_bits(JsValue::NULL.bits()); + }; + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(owner); + if let Some(path) = path { + return events::string(&path); + } + let Some(local) = local else { + return f64::from_bits(JsValue::NULL.bits()); + }; + let object = scope.root_nanbox(f64::from_bits(perry_ffi::alloc_object().bits())); + p::own_set( + object.get(), + "address", + events::string(&local.ip().to_string()), + ); + p::own_set( + object.get(), + "family", + events::string(if local.is_ipv4() { "IPv4" } else { "IPv6" }), + ); + p::own_set(object.get(), "port", local.port() as f64); + let _ = owner; + object.get() +} + +pub(crate) fn get(owner: f64, key: &str) -> f64 { + let server = link(owner); + if key == "listening" { + return f64::from_bits( + JsValue::from_bool(unsafe { + p::server_ptr(server) + .ok() + .is_some_and(|payload| (*payload).ext.listening) + }) + .bits(), + ); + } + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(owner); + let state = scope.root_nanbox(state(owner.get())); + if key == "_connections" || key == "maxConnections" || key == "dropMaxConnection" { + p::own_get(owner.get(), key) + } else { + p::own_get(state.get(), key) + } +} + +pub(crate) fn get_connections(owner: f64, callback: f64) -> f64 { + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(owner); + let callback = scope.root_nanbox(callback); + let count = get(owner.get(), "_connections"); + if socket::is_callback(callback.get()) { + events::queue_call( + owner.get(), + callback.get(), + &[f64::from_bits(JsValue::NULL.bits()), count], + ); + } + p::undefined() +} + +pub(crate) fn set_ref(owner: f64, referenced: bool) -> f64 { + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(owner); + let server = link(owner.get()); + let _account = p::AccountServer(server); + unsafe { + if let Ok(core) = p::server_core(server) { + tl::link_set_ref(&mut *core, server, referenced); + } + } + let state = scope.root_nanbox(state(owner.get())); + p::own_set( + state.get(), + "refed", + f64::from_bits(JsValue::from_bool(referenced).bits()), + ); + owner.get() +} + +fn array_ptr(value: f64) -> *mut ArrayHeader { + JsValue::from_bits(value.to_bits()).as_pointer() +} + +fn finish_group(group: f64, synthetic: bool) { + let scope = TransientRootScope::enter(); + let group = scope.root_nanbox(group); + let children = scope.root_nanbox(p::own_get(group.get(), "children")); + if unsafe { perry_ffi::js_array_length(array_ptr(children.get())) } != 0 { + return; + } + if !JsValue::from_bits(p::own_get(group.get(), "closing").to_bits()).to_bool() + || !JsValue::from_bits(p::own_get(group.get(), "listenerClosed").to_bits()).to_bool() + { + return; + } + // Consume the pending close before running user listeners. This is the + // ordinary close callback's lifecycle, not a transport-generation latch. + p::own_set( + group.get(), + "closing", + f64::from_bits(JsValue::FALSE.bits()), + ); + let owner = scope.root_nanbox(p::own_get(group.get(), "server")); + let resource = scope.root_nanbox(p::own_get(group.get(), provider::SERVER)); + if synthetic { + events::queue_emit_in(resource.get(), owner.get(), "close", &[], true); + } else { + events::emit_in(resource.get(), owner.get(), "close", &[]); + provider::retire(resource.get()); + } +} + +fn finish_pending(owner: f64, synthetic: bool) { + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(owner); + if JsValue::from_bits(p::own_get(owner.get(), "_connections").to_bits()).to_number() != 0.0 { + return; + } + let state = scope.root_nanbox(state(owner.get())); + let array = scope.root_nanbox(p::own_get(state.get(), "closeCallbacks")); + if !JsValue::from_bits(array.get().to_bits()).is_pointer() { + return; + } + let count = unsafe { perry_ffi::js_array_length(array_ptr(array.get())) }; + let groups: Vec<_> = (0..count) + .map(|index| { + scope.root_nanbox(f64::from_bits(unsafe { + perry_ffi::js_array_get(array_ptr(array.get()), index).bits() + })) + }) + .collect(); + for group in groups { + if !JsValue::from_bits(p::own_get(group.get(), "listenerClosed").to_bits()).to_bool() { + continue; + } + let group = scope.root_nanbox(closed::take(state.get(), closed::parts(group.get()))); + if JsValue::from_bits(group.get().to_bits()).is_pointer() { + finish_group(group.get(), synthetic); + } + } +} + +pub(crate) fn listener_closed(owner: f64, event: &tl::NetCompletion) { + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(owner); + let state = scope.root_nanbox(state(owner.get())); + let Some(parts) = (unsafe { event.closed_handle_parts() }) else { + return; + }; + let group = scope.root_nanbox(closed::take(state.get(), parts)); + if !JsValue::from_bits(group.get().to_bits()).is_pointer() { + return; + } + p::own_set( + group.get(), + "listenerClosed", + f64::from_bits(JsValue::TRUE.bits()), + ); + closed::append(state.get(), group.get()); + finish_pending(owner.get(), false); +} + +pub(crate) fn child_closed(child: f64, group: f64) { + let scope = TransientRootScope::enter(); + let child = scope.root_nanbox(child); + let group = scope.root_nanbox(group); + if !JsValue::from_bits(group.get().to_bits()).is_pointer() { + return; + } + let children = scope.root_nanbox(p::own_get(group.get(), "children")); + let count = unsafe { perry_ffi::js_array_length(array_ptr(children.get())) }; + let values: Vec<_> = (0..count) + .map(|index| { + scope.root_nanbox(f64::from_bits(unsafe { + perry_ffi::js_array_get(array_ptr(children.get()), index).bits() + })) + }) + .collect(); + let Some(index) = values + .iter() + .position(|value| value.get().to_bits() == child.get().to_bits()) + else { + return; + }; + let mut remaining = + scope.root_addr(unsafe { perry_ffi::js_array_alloc(count.saturating_sub(1)) } as i64); + for (position, value) in values.iter().enumerate() { + if position != index { + remaining = scope.root_addr(unsafe { + perry_ffi::js_array_push( + remaining.get() as *mut ArrayHeader, + JsValue::from_bits(value.get().to_bits()), + ) + } as i64); + } + } + p::own_set(group.get(), "children", p::boxed_addr(remaining.get())); + let server = scope.root_nanbox(p::own_get(group.get(), "server")); + let count = JsValue::from_bits(p::own_get(server.get(), "_connections").to_bits()).to_number(); + p::own_set(server.get(), "_connections", (count - 1.0).max(0.0)); + finish_pending(server.get(), true); +} + +pub(crate) fn accepted(owner: f64, completion: *const tl::NetCompletion) { + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(owner); + let server = link(owner.get()); + let _account = p::AccountServer(server); + let state = scope.root_nanbox(state(owner.get())); + let group = scope.root_nanbox(p::own_get(state.get(), "activeGroup")); + let Some((allow_half_open, pause_on_connect, tls_config, snapshot)) = (unsafe { + p::server_ptr(server).ok().and_then(|payload| { + let fields = &(*payload).ext; + let core = std::ptr::addr_of_mut!((*payload).core); + tl::link_snapshot_handle(&mut *core, server).map(|snapshot| { + ( + fields.allow_half_open, + fields.pause_on_connect, + fields.tls_config.clone(), + snapshot, + ) + }) + }) + }) else { + return; + }; + let count = JsValue::from_bits(p::own_get(owner.get(), "_connections").to_bits()).to_number(); + let max = JsValue::from_bits(p::own_get(owner.get(), "maxConnections").to_bits()); + if max.is_number() && count >= max.to_number().max(0.0) { + events::emit(owner.get(), "drop", &[]); + return; // The runtime closes the accepted install slot on return. + } + let resource = scope.root_nanbox(p::own_get(group.get(), provider::SERVER)); + let child = scope.root_nanbox(socket::new_socket_with_trigger( + super::payload_io::ROUTE, + p::undefined(), + provider::id(resource.get()), + )); + let child_link = socket::link(child.get()); + let _child_account = p::AccountSocket(child_link); + unsafe { + let Ok(core) = p::server_core(server) else { + return; + }; + if !tl::link_handle_matches(&mut *core, server, &snapshot) { + return; + } + let Ok(child_core) = p::socket_core(child_link) else { + return; + }; + if tl::link_install_accepted(&mut *child_core, child_link, &*completion).is_err() { + return; + } + let fields = &mut (*p::socket_ptr(child_link).unwrap()).ext; + fields.opened = true; + fields.allow_half_open = allow_half_open; + fields.paused = pause_on_connect; + } + socket::update_addresses(child_link); + let child_state = scope.root_nanbox(socket::state(child.get())); + p::own_set( + child_state.get(), + "allowHalfOpen", + f64::from_bits(JsValue::from_bool(allow_half_open).bits()), + ); + p::own_set(child_state.get(), "server", owner.get()); + p::own_set(child_state.get(), "serverGroup", group.get()); + let children = scope.root_nanbox(p::own_get(group.get(), "children")); + let updated = unsafe { + perry_ffi::js_array_push( + array_ptr(children.get()), + JsValue::from_bits(child.get().to_bits()), + ) + }; + p::own_set(group.get(), "children", p::boxed_addr(updated as i64)); + p::own_set(owner.get(), "_connections", count + 1.0); + if let Some(config) = tls_config { + if let Err(message) = super::payload_tls::install_server(child.get(), config) { + let error = socket::error("ERR_TLS_HANDSHAKE_FAILED", &message); + socket::destroy(child.get(), error); + return; + } + } + let child_snapshot = super::payload_io::snapshot(child_link); + let child_resource = scope.root_nanbox(provider::resource(child.get())); + events::emit_in( + child_resource.get(), + owner.get(), + "connection", + &[child.get()], + ); + // Listener callbacks can close/reopen the child. Starting its read belongs + // to whatever owner listeners requested; flow re-fetches its current T. + if child_snapshot + .as_ref() + .is_some_and(|snapshot| super::payload_io::matches(child_link, snapshot)) + { + socket::flow(child.get()); + } + let _ = JsThis::UNDEFINED; +} diff --git a/crates/perry-ext-net/src/payload_socket.rs b/crates/perry-ext-net/src/payload_socket.rs new file mode 100644 index 0000000000..5f29a994a6 --- /dev/null +++ b/crates/perry-ext-net/src/payload_socket.rs @@ -0,0 +1,1532 @@ +//! Link-routed Socket methods; every reentrant call ends its payload borrow. + +use super::payload_transport::{self as p, SocketFields, SOCKET}; +use super::{payload_events as events, payload_provider as provider}; +use perry_ffi::native_payload::{self as np, OwnerLink, PayloadMiss}; +use perry_ffi::turnloop_net as tl; +use perry_ffi::{ArrayHeader, JsThis, JsValue, RawClosureHeader, TransientRootScope}; + +pub(crate) const HIGH_WATER_MARK: usize = 64 * 1024; + +extern "C" { + fn js_value_is_closure(bits: i64) -> i32; + fn js_net_validate_connect_port(port: f64); + fn js_node_stream_method_once(owner: i64, event: f64, callback: f64) -> f64; + fn js_array_shift_f64(array: *mut ArrayHeader) -> f64; + fn js_queue_next_tick(callback: i64); +} + +pub(crate) fn is_callback(value: f64) -> bool { + unsafe { js_value_is_closure(value.to_bits() as i64) != 0 } +} + +pub(crate) fn error(code: &str, message: &str) -> f64 { + let scope = TransientRootScope::enter(); + let value = scope.root_nanbox(unsafe { crate::build_error_object(message) }); + let code = events::string(code); + p::own_set(value.get(), "code", code); + value.get() +} + +pub(crate) fn link(owner: f64) -> OwnerLink { + transport_link(owner).unwrap_or_else(super::payload_prototype::throw_miss) +} + +pub(crate) fn transport_link(owner: f64) -> Result { + let own = p::socket_link(owner)?; + // Closed wrappers use their own disposed cell. Their saved completion + // context can still deliver close, but public methods may not reach a + // reopened parent's replacement resource through an old parent edge. + let Ok(payload) = (unsafe { p::socket_ptr(own) }) else { + return Ok(own); + }; + if unsafe { !(*payload).ext.tls_parent } { + return Ok(own); + } + let state = unsafe { p::socket_state(own, false) }; + let parent = p::own_get(state, "tlsParent"); + if JsValue::from_bits(parent.to_bits()).is_undefined() { + Ok(own) + } else { + p::socket_link(parent) + } +} + +pub(crate) fn state(owner: f64) -> f64 { + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(owner); + unsafe { + p::socket_state( + p::socket_link(owner.get()).unwrap_or_else(super::payload_prototype::throw_miss), + true, + ) + } +} + +/// The wrapper and its parent are ordinary objects, with ordinary traced +/// edges in both directions. Existing read tokens continue naming the parent +/// cell; the TLS route reads its wrapper edge when delivering JS events. +pub(crate) fn new_tls_wrapper(parent: f64) -> f64 { + let scope = TransientRootScope::enter(); + let parent = scope.root_nanbox(parent); + let parent_link = link(parent.get()); + let transport_owner = + scope.root_nanbox(unsafe { np::link_event_owner(parent_link) }.unwrap_or(parent.get())); + let open = unsafe { p::socket_ptr(parent_link) }.is_ok(); + let owner = scope.root_nanbox(p::alloc_socket( + super::payload_io::TLS_SUBSYSTEM, + p::SocketFields { + tls_parent: true, + ..Default::default() + }, + )); + let state = scope.root_nanbox(state(owner.get())); + initialize_open_state(owner.get(), state.get()); + p::own_set(owner.get(), "_parent", parent.get()); + let parent_state = scope.root_nanbox(self::state(transport_owner.get())); + p::own_set(state.get(), "tlsParent", transport_owner.get()); + p::own_set(parent_state.get(), "tlsWrapper", owner.get()); + if !open { + for key in [ + "bytesRead", + "bytesWritten", + "readableEnded", + "writableEnded", + "localAddress", + "localPort", + "localFamily", + "peerAddress", + "peerPort", + "peerFamily", + ] { + let value = scope.root_nanbox(p::own_get(parent_state.get(), key)); + p::own_set(state.get(), key, value.get()); + } + } + if open { + let parent_state = scope.root_nanbox(self::state(transport_owner.get())); + for key in [ + "allowHalfOpen", + "refed", + provider::TCP, + provider::CONNECT, + provider::SHUTDOWN, + ] { + let value = scope.root_nanbox(p::own_get(parent_state.get(), key)); + p::own_set(state.get(), key, value.get()); + } + unsafe { + p::set_route(parent_link, super::payload_io::TLS_SUBSYSTEM) + .expect("TLS wrapper keeps its parent's registered link route"); + } + } + owner.get() +} + +/// Initialize stream state on the ordinary owner. No JS edge enters Rust. +pub(crate) fn new_socket(route: u8, options: f64) -> f64 { + new_socket_with_trigger(route, options, u64::MAX) +} +pub(crate) fn new_socket_with_trigger(route: u8, options: f64, trigger: u64) -> f64 { + initialize_socket(route, options, trigger, None) +} +pub(crate) fn initialize_socket( + route: u8, + options: f64, + trigger: u64, + receiver: Option, +) -> f64 { + let scope = TransientRootScope::enter(); + let receiver = receiver.map(|value| scope.root_nanbox(value)); + let options = scope.root_nanbox(options); + let allow_half_open = + unsafe { crate::get_object_bool_field(options.get(), "allowHalfOpen") }.unwrap_or(false); + let fields = SocketFields { + allow_half_open, + ..SocketFields::default() + }; + let owner = scope.root_nanbox(match receiver { + Some(receiver) => { + if !p::attach_socket(receiver.get(), route, fields) { + super::payload_prototype::throw_miss::<()>(PayloadMiss::Foreign); + } + receiver.get() + } + None => p::alloc_socket(route, fields), + }); + let state = scope.root_nanbox(state(owner.get())); + p::own_set( + state.get(), + "allowHalfOpen", + f64::from_bits(JsValue::from_bool(allow_half_open).bits()), + ); + initialize_open_state(owner.get(), state.get()); + let resource = scope.root_nanbox(prepare_tcp(owner.get(), trigger)); + provider::notify(resource.get()); + owner.get() +} + +fn prepare_tcp(owner: f64, trigger: u64) -> f64 { + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(owner); + let state = scope.root_nanbox(state(owner.get())); + let resource = scope.root_nanbox(provider::publish( + state.get(), + provider::TCP, + b"TCPWRAP", + trigger, + )); + unsafe { + if let Ok(payload) = p::socket_ptr(link(owner.get())) { + (*payload).ext.tcp_async_id = provider::id(resource.get()); + } + } + resource.get() +} + +fn initialize_open_state(owner: f64, state: f64) { + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(owner); + let state = scope.root_nanbox(state); + for key in [ + "destroyed", + "hadError", + "readableEnded", + "writableEnded", + "unconnectedWriteFailed", + ] { + p::own_set(state.get(), key, f64::from_bits(JsValue::FALSE.bits())); + } + for key in ["bytesRead", "bytesWritten"] { + p::own_set(state.get(), key, 0.0); + } + for key in [ + "closeError", + "server", + "serverGroup", + "parser", + "encrypted", + "authorized", + "servername", + "alpnProtocol", + "authorizationError", + "protocol", + "peerCertificateDer", + "ownCertificateDer", + "checkServerIdentity", + "session", + "sessionSupplied", + "sessionReused", + "tlsConnected", + "cipher", + "tlsWrapper", + "tlsParent", + ] { + p::own_set(state.get(), key, p::undefined()); + } + p::own_set(state.get(), "refed", f64::from_bits(JsValue::TRUE.bits())); + let callbacks = unsafe { perry_ffi::js_array_alloc(0) }; + p::own_set(state.get(), "callbacks", p::boxed_addr(callbacks as i64)); + let _ = owner; +} + +pub(crate) fn once(owner: f64, event: &str, callback: f64) { + if !is_callback(callback) { + return; + } + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(owner); + let callback = scope.root_nanbox(callback); + let event = events::string(event); + unsafe { + js_node_stream_method_once(p::raw_owner(owner.get()), event, callback.get()); + } +} + +pub(crate) fn connect(owner: f64, arg1: f64, arg2: f64, arg3: f64) -> f64 { + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(owner); + let arg1 = scope.root_nanbox(arg1); + let arg2 = scope.root_nanbox(arg2); + let arg3 = scope.root_nanbox(arg3); + let mut socket = link(owner.get()); + let _transport_owner = + scope.root_nanbox(unsafe { np::link_event_owner(socket) }.unwrap_or(owner.get())); + let (host, port, path, callback) = unsafe { + if let Some(path) = crate::jsvalue_to_owned_string(arg1.get()) { + (String::new(), 0.0, Some(path), arg2.get()) + } else if JsValue::from_bits(arg1.get().to_bits()).is_pointer() { + let path = crate::get_object_string_field(arg1.get(), "path"); + let port = crate::get_object_number_field(arg1.get(), "port").unwrap_or(0.0); + let host = crate::get_object_string_field(arg1.get(), "host") + .or_else(|| crate::get_object_string_field(arg1.get(), "hostname")) + .unwrap_or_else(|| "localhost".into()); + (host, port, path, arg2.get()) + } else { + let host = crate::jsvalue_to_owned_string(arg2.get()); + let callback = if host.is_some() { + arg3.get() + } else { + arg2.get() + }; + ( + host.unwrap_or_else(|| "localhost".into()), + JsValue::from_bits(arg1.get().to_bits()).to_number(), + None, + callback, + ) + } + }; + let callback = scope.root_nanbox(callback); + if path.is_none() { + unsafe { + js_net_validate_connect_port(port); + } + } + let own = p::socket_link(owner.get()).unwrap_or_else(super::payload_prototype::throw_miss); + if own != socket && unsafe { np::link_lifecycle(own, &SOCKET) } == Ok(np::Lifecycle::Closed) { + let own_state = scope.root_nanbox(state(owner.get())); + p::own_set(own_state.get(), "tlsParent", p::undefined()); + socket = own; + } + let _account = p::AccountSocket(socket); + once(owner.get(), "connect", callback.get()); + let state = scope.root_nanbox(state(owner.get())); + unsafe { + if np::link_lifecycle(socket, &SOCKET) == Ok(np::Lifecycle::Closed) { + p::reopen_socket(socket, super::payload_io::ROUTE).unwrap_or_else(|_| { + super::payload_prototype::throw_miss::<()>(PayloadMiss::Closed) + }); + let allow = + JsValue::from_bits(p::own_get(state.get(), "allowHalfOpen").to_bits()).to_bool(); + (*p::socket_ptr(socket).expect("reopened Socket")) + .ext + .allow_half_open = allow; + initialize_open_state(owner.get(), state.get()); + } + } + if JsValue::from_bits(p::own_get(state.get(), "unconnectedWriteFailed").to_bits()).to_bool() { + return owner.get(); + } + let busy = unsafe { + let fields = + &(*p::socket_ptr(socket).unwrap_or_else(super::payload_prototype::throw_miss)).ext; + fields.connecting || fields.opened + }; + if busy { + let error = error( + "ERR_SOCKET_ALREADY_CONNECTED", + "Socket is already connected", + ); + events::queue_emit(owner.get(), "error", &[error]); + return owner.get(); + } + unsafe { + let fields = + &mut (*p::socket_ptr(socket).unwrap_or_else(super::payload_prototype::throw_miss)).ext; + fields.connecting = true; + fields.host = host.clone(); + fields.port = port as u16; + } + let needs_tcp = unsafe { (*p::socket_ptr(socket).unwrap()).ext.tcp_async_id == 0 }; + let tcp = scope.root_nanbox(if needs_tcp { + prepare_tcp(owner.get(), u64::MAX) + } else { + p::own_get(state.get(), provider::TCP) + }); + let connect = scope.root_nanbox(provider::publish( + state.get(), + provider::CONNECT, + b"TCPCONNECTWRAP", + provider::id(tcp.get()), + )); + unsafe { + (*p::socket_ptr(socket).unwrap()).ext.connect_async_id = provider::id(connect.get()); + } + let result = unsafe { + let core = p::socket_core(socket).unwrap_or_else(super::payload_prototype::throw_miss); + match path { + Some(path) => tl::link_pipe_connect(&mut *core, socket, &path), + None => tl::link_tcp_connect(&mut *core, socket, &host, port as u16, true), + } + }; + if let Err(failure) = result { + let error = error(&failure.code, &failure.message()); + destroy(owner.get(), error); + if needs_tcp { + provider::notify(tcp.get()); + } + provider::notify(connect.get()); + return owner.get(); + } + let snapshot = super::payload_io::snapshot(socket); + if needs_tcp { + provider::notify(tcp.get()); + } + if snapshot + .as_ref() + .is_some_and(|snapshot| !super::payload_io::matches(socket, snapshot)) + { + return owner.get(); + } + provider::notify(connect.get()); + if snapshot + .as_ref() + .is_some_and(|snapshot| !super::payload_io::matches(socket, snapshot)) + { + return owner.get(); + } + owner.get() +} + +pub(crate) fn update_addresses(socket: OwnerLink) { + unsafe { + let Ok(core) = p::socket_core(socket) else { + return; + }; + let local = tl::link_local_address(&mut *core, socket); + let peer = tl::link_peer_address(&mut *core, socket); + let parse = |endpoint: tl::Endpoint| { + endpoint + .address + .parse() + .ok() + .map(|ip| std::net::SocketAddr::new(ip, endpoint.port)) + }; + if let Ok(payload) = p::socket_ptr(socket) { + (*payload).ext.local = local.and_then(parse); + (*payload).ext.peer = peer.and_then(parse); + } + } +} + +/// Copy terminal getters before disposing native memory. The owner and state +/// remain ordinary, usable JS objects after the native resource is released. +pub(crate) fn cache_before_release(owner: f64, socket: OwnerLink) { + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(owner); + let cached = unsafe { + p::socket_ptr(socket).ok().map(|payload| { + let fields = &(*payload).ext; + ( + fields.bytes_read, + fields.bytes_written, + fields.read_end_emitted, + fields.write_ended, + fields.local, + fields.peer, + ) + }) + }; + let state = scope.root_nanbox(state(owner.get())); + if let Some((read, written, read_ended, write_ended, local, peer)) = cached { + p::own_set(state.get(), "bytesRead", read as f64); + p::own_set(state.get(), "bytesWritten", written as f64); + p::own_set( + state.get(), + "readableEnded", + f64::from_bits(JsValue::from_bool(read_ended).bits()), + ); + p::own_set( + state.get(), + "writableEnded", + f64::from_bits(JsValue::from_bool(write_ended).bits()), + ); + for (key, address) in [("local", local), ("peer", peer)] { + if let Some(address) = address { + let endpoint = scope.root_nanbox(events::string(&address.ip().to_string())); + p::own_set(state.get(), &format!("{key}Address"), endpoint.get()); + p::own_set(state.get(), &format!("{key}Port"), address.port() as f64); + p::own_set( + state.get(), + &format!("{key}Family"), + events::string(if address.is_ipv4() { "IPv4" } else { "IPv6" }), + ); + } + } + } else if p::socket_link(owner.get()).is_ok_and(|own| own != socket) { + let parent = scope.root_nanbox(unsafe { np::link_event_owner(socket) }.unwrap()); + let parent_state = scope.root_nanbox(self::state(parent.get())); + for key in [ + "bytesRead", + "bytesWritten", + "readableEnded", + "writableEnded", + "localAddress", + "localPort", + "localFamily", + "peerAddress", + "peerPort", + "peerFamily", + ] { + let value = scope.root_nanbox(p::own_get(parent_state.get(), key)); + p::own_set(state.get(), key, value.get()); + } + } + p::own_set( + state.get(), + "destroyed", + f64::from_bits(JsValue::TRUE.bits()), + ); +} + +pub(crate) fn destroy(owner: f64, error_value: f64) -> f64 { + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(owner); + let error_value = scope.root_nanbox(error_value); + let socket = link(owner.get()); + let own = p::socket_link(owner.get()).unwrap_or_else(super::payload_prototype::throw_miss); + let _account = p::AccountSocket(socket); + if unsafe { np::link_lifecycle(own, &SOCKET) } == Ok(np::Lifecycle::Closed) { + return owner.get(); + } + let state = scope.root_nanbox(state(owner.get())); + if own == socket { + let wrapper = scope.root_nanbox(p::own_get(state.get(), "tlsWrapper")); + if let Ok(wrapper_link) = p::socket_link(wrapper.get()) { + if wrapper.get().to_bits() != owner.get().to_bits() + && link(wrapper.get()) == socket + && unsafe { np::link_lifecycle(wrapper_link, &SOCKET) } == Ok(np::Lifecycle::Open) + { + destroy(wrapper.get(), error_value.get()); + return owner.get(); + } + } + } + if own != socket && unsafe { np::link_lifecycle(socket, &SOCKET) } == Ok(np::Lifecycle::Closed) + { + let parent = scope.root_nanbox(unsafe { np::link_event_owner(socket) }.unwrap()); + let parent_state = scope.root_nanbox(self::state(parent.get())); + let pending = scope.root_nanbox(super::payload_closed::for_wrapper( + parent_state.get(), + owner.get(), + )); + let owed = JsValue::from_bits(pending.get().to_bits()).is_pointer(); + let record = scope.root_nanbox(super::payload_closed::record(if owed { + super::payload_closed::parts(pending.get()) + } else { + [0; 4] + })); + p::own_set(record.get(), "error", error_value.get()); + p::own_set( + record.get(), + "hadHandle", + f64::from_bits(JsValue::TRUE.bits()), + ); + cache_before_release(owner.get(), socket); + let failure = scope.root_nanbox(error("ERR_STREAM_DESTROYED", "Socket is closed")); + cancel_callbacks(owner.get(), state.get(), failure.get()); + provider::capture_socket(state.get(), record.get()); + unsafe { + np::close_link(own, &SOCKET); + } + if owed { + super::payload_closed::append(state.get(), record.get()); + } else { + super::payload_io::queue_socket_closed(owner.get(), record.get()); + } + return owner.get(); + } + let parts = unsafe { + p::socket_core(socket) + .ok() + .and_then(|core| tl::link_handle_parts(&mut *core, socket)) + }; + let close_record = scope.root_nanbox(super::payload_closed::record(parts.unwrap_or([0; 4]))); + let parent = scope.root_nanbox(unsafe { np::link_event_owner(socket) }.unwrap_or(owner.get())); + let parent_state = scope.root_nanbox(self::state(parent.get())); + let wrapper = scope.root_nanbox(p::own_get(parent_state.get(), "tlsWrapper")); + if p::socket_link(wrapper.get()).is_ok() { + p::own_set(close_record.get(), "tlsWrapper", wrapper.get()); + } + let had_handle = own != socket + || parts.is_some() + || unsafe { + p::socket_ptr(socket) + .ok() + .is_some_and(|payload| (*payload).ext.connecting) + }; + p::own_set( + close_record.get(), + "hadHandle", + f64::from_bits(JsValue::from_bool(had_handle).bits()), + ); + let had_error = !JsValue::from_bits(error_value.get().to_bits()).is_undefined() + && !JsValue::from_bits(error_value.get().to_bits()).is_null(); + { + p::own_set( + close_record.get(), + "error", + if had_error { + error_value.get() + } else { + p::undefined() + }, + ); + let group = scope.root_nanbox(p::own_get(state.get(), "serverGroup")); + p::own_set(close_record.get(), "serverGroup", group.get()); + } + let result = unsafe { + let core = p::socket_core(socket).unwrap_or_else(super::payload_prototype::throw_miss); + tl::link_close(&mut *core, socket) + }; + let owed = match result { + Ok(owed) => owed, + // A refused driver close restored the live handle and its pin. Keep + // its payload installed; disposing it would strand that capability. + Err(failure) => { + let failure = error(&failure.code, &failure.message()); + events::queue_emit(owner.get(), "error", &[failure]); + return owner.get(); + } + }; + if had_error { + p::own_set( + state.get(), + "hadError", + f64::from_bits(JsValue::TRUE.bits()), + ); + p::own_set(state.get(), "closeError", error_value.get()); + } + cache_before_release(owner.get(), socket); + if own != socket { + cache_before_release(parent.get(), socket); + } + let callback_error = if had_error { + error_value.get() + } else { + error( + "ERR_STREAM_DESTROYED", + "Cannot call write after a stream was destroyed", + ) + }; + cancel_callbacks(owner.get(), state.get(), callback_error); + super::native_transport::release_codec(owner.get()); + #[cfg(test)] + if std::env::var("PERRY_NET_BINDING_SABOTAGE").as_deref() == Ok("churn_backlog") { + // The N3 fault retains a real owned buffer after its Socket closes. + // This byte-only leak exists solely in the test executable. + static LEAK: std::sync::Mutex>> = + std::sync::Mutex::new(Vec::new()); + if let Ok(payload) = unsafe { p::socket_ptr(socket) } { + let bytes = unsafe { std::mem::take(&mut (*payload).ext.read_buffer) }; + LEAK.lock().unwrap().push(bytes); + } + } + unsafe { + np::close_link(socket, &SOCKET); + if own != socket { + np::close_link(own, &SOCKET); + } + // N4 fault (test executable only): drop the owed Closed's cell + // reference at close() instead of when Closed is delivered. + #[cfg(test)] + if std::env::var("PERRY_NET_BINDING_SABOTAGE").as_deref() == Ok("early_unref") { + np::link_unref(socket); + } + } + provider::capture_socket(state.get(), close_record.get()); + if owed { + super::payload_closed::append(state.get(), close_record.get()); + if own != socket { + let parent_record = + scope.root_nanbox(super::payload_closed::record(parts.unwrap_or([0; 4]))); + p::own_set(parent_record.get(), "tlsWrapper", owner.get()); + let group = scope.root_nanbox(p::own_get(parent_state.get(), "serverGroup")); + p::own_set(parent_record.get(), "serverGroup", group.get()); + provider::capture_socket(parent_state.get(), parent_record.get()); + super::payload_closed::append(parent_state.get(), parent_record.get()); + } + } + if !owed { + if own != socket { + let parent_record = scope.root_nanbox(super::payload_closed::record([0; 4])); + p::own_set( + parent_record.get(), + "hadHandle", + f64::from_bits(JsValue::FALSE.bits()), + ); + let group = scope.root_nanbox(p::own_get(parent_state.get(), "serverGroup")); + p::own_set(parent_record.get(), "serverGroup", group.get()); + provider::capture_socket(parent_state.get(), parent_record.get()); + super::payload_io::queue_socket_closed(parent.get(), parent_record.get()); + } + super::payload_io::queue_socket_closed(owner.get(), close_record.get()); + } + owner.get() +} + +fn array_ptr(array: f64) -> *mut ArrayHeader { + JsValue::from_bits(array.to_bits()).as_pointer() +} + +fn register_callback(owner: f64, callback: f64) -> u64 { + if !is_callback(callback) { + return 0; + } + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(owner); + let callback = scope.root_nanbox(callback); + let socket = link(owner.get()); + let _account = p::AccountSocket(socket); + let user = unsafe { + let fields = + &mut (*p::socket_ptr(socket).unwrap_or_else(super::payload_prototype::throw_miss)).ext; + fields.callback_seq = fields + .callback_seq + .checked_add(1) + .expect("Socket callback sequence exhausted"); + fields.callback_seq + }; + let state = scope.root_nanbox(state(owner.get())); + let callbacks = scope.root_nanbox(p::own_get(state.get(), "callbacks")); + let entry = scope.root_addr(unsafe { perry_ffi::js_array_alloc(2) } as i64); + unsafe { + let entry_ptr = perry_ffi::js_array_push( + entry.get() as *mut ArrayHeader, + JsValue::from_number(user as f64), + ); + let entry = scope.root_addr(entry_ptr as i64); + let entry_ptr = perry_ffi::js_array_push( + entry.get() as *mut ArrayHeader, + JsValue::from_bits(callback.get().to_bits()), + ); + let entry = scope.root_addr(entry_ptr as i64); + let updated = perry_ffi::js_array_push( + array_ptr(callbacks.get()), + JsValue::from_object_ptr(entry.get() as *mut ArrayHeader), + ); + p::own_set(state.get(), "callbacks", p::boxed_addr(updated as i64)); + } + user +} + +/// Writes retire in submission order. Consuming the JS queue's prefix also +/// bounds its retained capacity for long-lived sockets; there is no token map. +pub(crate) fn take_callback(owner: f64, user: u64) -> f64 { + if user == 0 { + return p::undefined(); + } + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(owner); + let state = scope.root_nanbox(state(owner.get())); + let callbacks = scope.root_nanbox(p::own_get(state.get(), "callbacks")); + unsafe { + let callbacks_ptr = array_ptr(callbacks.get()); + if callbacks_ptr.is_null() || perry_ffi::js_array_length(callbacks_ptr) == 0 { + return p::undefined(); + } + let entry = perry_ffi::js_array_get(callbacks_ptr, 0); + let entry_ptr = entry.as_pointer::(); + if perry_ffi::js_array_get(entry_ptr, 0).to_number() as u64 != user { + return p::undefined(); + } + let callback = perry_ffi::js_array_get(entry_ptr, 1); + js_array_shift_f64(callbacks_ptr); + f64::from_bits(callback.bits()) + } +} + +unsafe extern "C" fn cancel_tick(closure: *const RawClosureHeader, _: JsThis) -> f64 { + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(perry_ffi::closure_capture_f64(closure, 0)); + let callbacks = scope.root_nanbox(perry_ffi::closure_capture_f64(closure, 1)); + let error = scope.root_nanbox(perry_ffi::closure_capture_f64(closure, 2)); + let resource = scope.root_nanbox(perry_ffi::closure_capture_f64(closure, 3)); + loop { + let callbacks_ptr = array_ptr(callbacks.get()); + if perry_ffi::js_array_length(callbacks_ptr) == 0 { + break; + } + let entry = scope.root_nanbox(js_array_shift_f64(callbacks_ptr)); + let callback = scope.root_nanbox(f64::from_bits( + perry_ffi::js_array_get(array_ptr(entry.get()), 1).bits(), + )); + events::call_in( + resource.get(), + callback.get(), + JsThis::UNDEFINED, + &[error.get()], + ); + } + let _ = owner; + p::undefined() +} + +pub(crate) fn cancel_callbacks(owner: f64, state: f64, error: f64) { + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(owner); + let state = scope.root_nanbox(state); + let error = scope.root_nanbox(error); + let callbacks = scope.root_nanbox(p::own_get(state.get(), "callbacks")); + if unsafe { perry_ffi::js_array_length(array_ptr(callbacks.get())) } == 0 { + return; + } + let replacement = unsafe { perry_ffi::js_array_alloc(0) }; + p::own_set(state.get(), "callbacks", p::boxed_addr(replacement as i64)); + let resource = scope.root_nanbox(provider::resource(owner.get())); + let job = scope.root_addr(perry_ffi::alloc_closure( + perry_ffi::js_function_info!(cancel_tick, 0; with_flags(perry_ffi::FN_BUILTIN)), + 4, + ) as i64); + unsafe { + let job_ptr = job.get() as *mut RawClosureHeader; + perry_ffi::set_closure_capture_f64(job_ptr, 0, owner.get()); + perry_ffi::set_closure_capture_f64(job_ptr, 1, callbacks.get()); + perry_ffi::set_closure_capture_f64(job_ptr, 2, error.get()); + perry_ffi::set_closure_capture_f64(job_ptr, 3, resource.get()); + js_queue_next_tick(job.get()); + } +} + +unsafe extern "C" fn unopened_write_tick(closure: *const RawClosureHeader, _: JsThis) -> f64 { + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(perry_ffi::closure_capture_f64(closure, 0)); + let failure = scope.root_nanbox(error("ERR_SOCKET_CLOSED", "Socket is closed")); + destroy(owner.get(), failure.get()) +} + +fn unopened_write_failure(owner: f64, callback: f64) { + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(owner); + let callback = scope.root_nanbox(callback); + register_callback(owner.get(), callback.get()); + let state = scope.root_nanbox(state(owner.get())); + if JsValue::from_bits(p::own_get(state.get(), "unconnectedWriteFailed").to_bits()).to_bool() { + return; + } + p::own_set( + state.get(), + "unconnectedWriteFailed", + f64::from_bits(JsValue::TRUE.bits()), + ); + let job = scope.root_addr(perry_ffi::alloc_closure( + perry_ffi::js_function_info!(unopened_write_tick, 0; with_flags(perry_ffi::FN_BUILTIN)), + 1, + ) as i64); + unsafe { + perry_ffi::set_closure_capture_f64(job.get() as *mut RawClosureHeader, 0, owner.get()); + js_queue_next_tick(job.get()); + } +} + +pub(crate) fn write(owner: f64, chunk: f64, encoding: f64, callback: f64) -> f64 { + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(owner); + let chunk = scope.root_nanbox(chunk); + let encoding = scope.root_nanbox(encoding); + let callback = scope.root_nanbox(callback); + let callback = scope.root_nanbox(if is_callback(encoding.get()) { + encoding.get() + } else { + callback.get() + }); + let socket = link(owner.get()); + let _account = p::AccountSocket(socket); + let lifecycle = unsafe { np::link_lifecycle(socket, &SOCKET) }; + if lifecycle == Ok(np::Lifecycle::Closed) { + if is_callback(callback.get()) { + let error = error( + "ERR_STREAM_DESTROYED", + "Cannot call write after a stream was destroyed", + ); + events::queue_call(owner.get(), callback.get(), &[error]); + } + return f64::from_bits(JsValue::FALSE.bits()); + } + let bytes = unsafe { crate::jsvalue_to_socket_bytes(chunk.get()) }.unwrap_or_default(); + let unopened = unsafe { + let fields = + &(*p::socket_ptr(socket).unwrap_or_else(super::payload_prototype::throw_miss)).ext; + !fields.opened && !fields.connecting && fields.cork_depth == 0 + }; + if unopened { + unopened_write_failure(owner.get(), callback.get()); + return f64::from_bits(JsValue::FALSE.bits()); + } + let ended = unsafe { + (*p::socket_ptr(socket).unwrap_or_else(super::payload_prototype::throw_miss)) + .ext + .write_ended + }; + if ended { + let error = scope.root_nanbox(error("ERR_STREAM_WRITE_AFTER_END", "write after end")); + if is_callback(callback.get()) { + events::queue_call(owner.get(), callback.get(), &[error.get()]); + } + destroy(owner.get(), error.get()); + return f64::from_bits(JsValue::FALSE.bits()); + } + let user = register_callback(owner.get(), callback.get()); + let corked = unsafe { + let fields = + &mut (*p::socket_ptr(socket).unwrap_or_else(super::payload_prototype::throw_miss)).ext; + if fields.cork_depth != 0 { + fields.queued = fields.queued.saturating_add(bytes.len()); + fields.cork_bytes.extend_from_slice(&bytes); + if user != 0 { + fields.cork_users.push(user); + } + true + } else { + false + } + }; + if !corked { + let result = super::payload_tls::write(owner.get(), &bytes, user); + match result { + Ok(queued) => unsafe { + if let Ok(payload) = p::socket_ptr(socket) { + (*payload).ext.queued = queued; + } + }, + Err(failure) => { + let error = error(&failure.code, &failure.message()); + destroy(owner.get(), error); + return f64::from_bits(JsValue::FALSE.bits()); + } + } + } + if unsafe { p::socket_ptr(socket) }.is_err() { + return f64::from_bits(JsValue::FALSE.bits()); + } + let below = unsafe { + let fields = + &mut (*p::socket_ptr(socket).unwrap_or_else(super::payload_prototype::throw_miss)).ext; + let below = fields.queued < HIGH_WATER_MARK; + fields.need_drain |= !below; + below + }; + f64::from_bits(JsValue::from_bool(below).bits()) +} + +pub(crate) fn cork(owner: f64, uncork: bool) -> f64 { + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(owner); + let socket = link(owner.get()); + let _account = p::AccountSocket(socket); + let flush = unsafe { + p::socket_ptr(socket).ok().is_some_and(|payload| { + let fields = &mut (*payload).ext; + if uncork { + fields.cork_depth = fields.cork_depth.saturating_sub(1); + } else { + fields.cork_depth = fields.cork_depth.saturating_add(1); + } + uncork && fields.cork_depth == 0 + }) + }; + if flush { + flush_cork(owner.get()); + } + owner.get() +} + +fn flush_cork(owner: f64) { + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(owner); + let socket = link(owner.get()); + let _account = p::AccountSocket(socket); + let Some((bytes, user, unopened)) = (unsafe { + p::socket_ptr(socket).ok().and_then(|payload| { + let fields = &mut (*payload).ext; + if fields.cork_bytes.is_empty() && fields.cork_users.is_empty() { + return None; + } + let bytes = std::mem::take(&mut fields.cork_bytes); + let users = std::mem::take(&mut fields.cork_users); + let user = users.first().copied().unwrap_or(0); + if users.len() > 1 { + fields.coalesced_users.push_back((user, users)); + } + fields.queued = fields.queued.saturating_sub(bytes.len()); + Some((bytes, user, !fields.opened && !fields.connecting)) + }) + }) else { + return; + }; + if unopened { + unopened_write_failure(owner.get(), p::undefined()); + return; + } + let result = super::payload_tls::write(owner.get(), &bytes, user); + match result { + Ok(queued) => unsafe { + if let Ok(payload) = p::socket_ptr(socket) { + (*payload).ext.queued = queued; + } + }, + Err(failure) => { + let error = error(&failure.code, &failure.message()); + destroy(owner.get(), error); + } + } +} + +pub(crate) fn end(owner: f64, chunk: f64, encoding: f64, callback: f64) -> f64 { + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(owner); + let chunk = scope.root_nanbox(chunk); + let encoding = scope.root_nanbox(encoding); + let callback = scope.root_nanbox(callback); + let (chunk_value, callback_value) = if is_callback(chunk.get()) { + (p::undefined(), chunk.get()) + } else if is_callback(encoding.get()) { + (chunk.get(), encoding.get()) + } else { + (chunk.get(), callback.get()) + }; + let chunk = scope.root_nanbox(chunk_value); + let callback = scope.root_nanbox(callback_value); + let socket = link(owner.get()); + let _account = p::AccountSocket(socket); + if unsafe { np::link_lifecycle(socket, &SOCKET) } == Ok(np::Lifecycle::Closed) { + if is_callback(callback.get()) { + let error = error( + "ERR_STREAM_DESTROYED", + "Cannot call end after a stream was destroyed", + ); + events::queue_call(owner.get(), callback.get(), &[error]); + } + return owner.get(); + } + if !JsValue::from_bits(chunk.get().to_bits()).is_undefined() + && !JsValue::from_bits(chunk.get().to_bits()).is_null() + { + write(owner.get(), chunk.get(), encoding.get(), p::undefined()); + } + // write() may have disposed the resource. Re-project it before continuing. + if unsafe { p::socket_ptr(socket) }.is_err() { + return owner.get(); + } + let finished = unsafe { (*p::socket_ptr(socket).unwrap()).ext.shutdown_done }; + if finished { + if is_callback(callback.get()) { + events::queue_call(owner.get(), callback.get(), &[]); + } + return owner.get(); + } + let user = register_callback(owner.get(), callback.get()); + let already_ended = unsafe { + let fields = &mut (*p::socket_ptr(socket).unwrap()).ext; + let ended = fields.write_ended; + if ended { + if user != 0 { + fields.extra_end_users.push(user); + } + } else { + fields.write_ended = true; + fields.cork_depth = 0; + } + ended + }; + if already_ended { + return owner.get(); + } + let snapshot = super::payload_io::snapshot(socket); + let state = scope.root_nanbox(state(owner.get())); + let tcp = scope.root_nanbox(p::own_get(state.get(), provider::TCP)); + let shutdown = scope.root_nanbox(provider::publish( + state.get(), + provider::SHUTDOWN, + b"SHUTDOWNWRAP", + provider::id(tcp.get()), + )); + unsafe { + (*p::socket_ptr(socket).unwrap()).ext.shutdown_async_id = provider::id(shutdown.get()); + } + provider::notify(shutdown.get()); + if snapshot + .as_ref() + .is_some_and(|snapshot| !super::payload_io::matches(socket, snapshot)) + { + return owner.get(); + } + flush_cork(owner.get()); + if snapshot + .as_ref() + .is_some_and(|snapshot| !super::payload_io::matches(socket, snapshot)) + { + return owner.get(); + } + let result = super::payload_tls::shutdown(owner.get(), user); + if let Err(failure) = result { + let error = error(&failure.code, &failure.message()); + destroy(owner.get(), error); + } + owner.get() +} + +pub(crate) fn read(owner: f64, size: f64) -> f64 { + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(owner); + let socket = link(owner.get()); + let _account = p::AccountSocket(socket); + let bytes = unsafe { + p::socket_ptr(socket).ok().and_then(|payload| { + let buffer = &mut (*payload).ext.read_buffer; + if buffer.is_empty() { + return None; + } + let size = JsValue::from_bits(size.to_bits()); + let count = if size.is_number() { + (size.to_number().max(0.0) as usize).min(buffer.len()) + } else { + buffer.len() + }; + if count == 0 { + return None; + } + Some(buffer.drain(..count).collect::>()) + }) + }; + queue_read_end(owner.get()); + match bytes { + Some(bytes) => { + f64::from_bits(JsValue::from_object_ptr(perry_ffi::alloc_buffer(&bytes)).bits()) + } + None => f64::from_bits(JsValue::NULL.bits()), + } +} + +/// An EOF delayed by buffered bytes still belongs to the original driver +/// handle. Captures are ordinary JS values; no native continuation registry. +unsafe extern "C" fn read_end_tick(closure: *const RawClosureHeader, _: JsThis) -> f64 { + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(perry_ffi::closure_capture_f64(closure, 0)); + let socket = link(owner.get()); + let _account = p::AccountSocket(socket); + let parts = std::array::from_fn(|index| { + perry_ffi::closure_capture_f64(closure, index as u32 + 1) as u32 + }); + let current = p::socket_core(socket) + .ok() + .and_then(|core| tl::link_handle_parts(&mut *core, socket)); + if current == Some(parts) { + super::payload_io::eof(owner.get()); + } + p::undefined() +} + +fn queue_read_end(owner: f64) { + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(owner); + let socket = link(owner.get()); + let _account = p::AccountSocket(socket); + let ready = unsafe { + p::socket_ptr(socket).ok().is_some_and(|payload| { + let fields = &(*payload).ext; + fields.read_ended && !fields.read_end_emitted && fields.read_buffer.is_empty() + }) + }; + if !ready { + return; + } + let Some(parts) = (unsafe { + p::socket_core(socket) + .ok() + .and_then(|core| tl::link_handle_parts(&mut *core, socket)) + }) else { + return; + }; + let job = scope.root_addr(perry_ffi::alloc_closure( + perry_ffi::js_function_info!(read_end_tick, 0; with_flags(perry_ffi::FN_BUILTIN)), + 5, + ) as i64); + unsafe { + let closure = job.get() as *mut RawClosureHeader; + perry_ffi::set_closure_capture_f64(closure, 0, owner.get()); + for (index, part) in parts.into_iter().enumerate() { + perry_ffi::set_closure_capture_f64(closure, index as u32 + 1, part as f64); + } + js_queue_next_tick(job.get()); + } +} + +pub(crate) fn set_ref(owner: f64, referenced: bool) -> f64 { + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(owner); + let socket = link(owner.get()); + let _account = p::AccountSocket(socket); + unsafe { + if let Ok(core) = p::socket_core(socket) { + tl::link_set_ref(&mut *core, socket, referenced); + } + } + let state = scope.root_nanbox(state(owner.get())); + p::own_set( + state.get(), + "refed", + f64::from_bits(JsValue::from_bool(referenced).bits()), + ); + owner.get() +} + +pub(crate) fn get(owner: f64, key: &str) -> f64 { + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(owner); + let socket = link(owner.get()); + let _account = p::AccountSocket(socket); + let state = scope.root_nanbox(state(owner.get())); + if key == "destroyed" { + return p::own_get(state.get(), key); + } + if key == "writableHighWaterMark" || key == "readableHighWaterMark" { + return HIGH_WATER_MARK as f64; + } + if key == "_readableState" || key == "_writableState" { + let cached = scope.root_nanbox(p::own_get(state.get(), key)); + if JsValue::from_bits(cached.get().to_bits()).is_pointer() { + return cached.get(); + } + let object = scope.root_nanbox(f64::from_bits(perry_ffi::alloc_object().bits())); + p::own_set(state.get(), key, object.get()); + return object.get(); + } + enum Scalar { + Value(f64), + Text(String), + Cached, + } + let value = unsafe { + match p::socket_ptr(socket) { + Ok(payload) => { + let fields = &(*payload).ext; + let bool_value = + |value| Scalar::Value(f64::from_bits(JsValue::from_bool(value).bits())); + match key { + "connecting" => bool_value(fields.connecting), + "pending" => bool_value(!fields.opened), + "writable" => bool_value(!fields.write_ended), + "readable" => bool_value(!fields.read_ended), + "writableEnded" => bool_value(fields.write_ended), + "readableEnded" => bool_value(fields.read_end_emitted), + "writableFinished" => bool_value(fields.shutdown_done), + "isPaused" => bool_value(fields.paused), + "readableFlowing" => match fields.flowing { + Some(value) => bool_value(value), + None => Scalar::Value(f64::from_bits(JsValue::NULL.bits())), + }, + "writableNeedDrain" => bool_value(fields.need_drain), + "bytesRead" => Scalar::Value(fields.bytes_read as f64), + "bytesWritten" => Scalar::Value( + fields.bytes_written.saturating_add(fields.queued as u64) as f64, + ), + "writableLength" | "bufferSize" => Scalar::Value(fields.queued as f64), + "writableCorked" => Scalar::Value(fields.cork_depth as f64), + "timeout" => Scalar::Value(if fields.timeout_ms == 0 { + p::undefined() + } else { + fields.timeout_ms as f64 + }), + "typeOfService" => Scalar::Value(fields.type_of_service as f64), + "readyState" => Scalar::Text( + if fields.connecting { + "opening" + } else if fields.read_ended { + if fields.write_ended { + "closed" + } else { + "writeOnly" + } + } else if fields.write_ended { + "readOnly" + } else { + "open" + } + .into(), + ), + "readableEncoding" => match &fields.encoding { + Some(encoding) => Scalar::Text(encoding.clone()), + None => Scalar::Value(f64::from_bits(JsValue::NULL.bits())), + }, + "localAddress" | "remoteAddress" => match if key == "localAddress" { + fields.local + } else { + fields.peer + } { + Some(address) => Scalar::Text(address.ip().to_string()), + None => Scalar::Value(p::undefined()), + }, + "localPort" | "remotePort" => Scalar::Value( + (if key == "localPort" { + fields.local + } else { + fields.peer + }) + .map_or(p::undefined(), |address| address.port() as f64), + ), + "localFamily" | "remoteFamily" => match if key == "localFamily" { + fields.local + } else { + fields.peer + } { + Some(address) => { + Scalar::Text(if address.is_ipv4() { "IPv4" } else { "IPv6" }.into()) + } + None => Scalar::Value(p::undefined()), + }, + _ => Scalar::Cached, + } + } + Err(_) => match key { + "connecting" | "writable" | "readable" | "writableNeedDrain" => { + Scalar::Value(f64::from_bits(JsValue::FALSE.bits())) + } + "pending" => Scalar::Value(f64::from_bits(JsValue::TRUE.bits())), + "writableLength" | "writableCorked" => Scalar::Value(0.0), + "readyState" => Scalar::Text("closed".into()), + "bufferSize" => Scalar::Value(p::undefined()), + _ => Scalar::Cached, + }, + } + }; + match value { + Scalar::Value(value) => value, + Scalar::Text(text) => events::string(&text), + Scalar::Cached => { + let key = match key { + "remoteAddress" => "peerAddress", + "remotePort" => "peerPort", + "remoteFamily" => "peerFamily", + key => key, + }; + p::own_get(state.get(), key) + } + } +} + +extern "C" { + fn js_net_validate_socket_timeout(value: f64); + fn js_net_validate_tos(value: f64) -> i32; +} + +pub(crate) fn set_timeout(owner: f64, ms: f64, callback: f64) -> f64 { + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(owner); + let callback = scope.root_nanbox(callback); + let socket = link(owner.get()); + let _account = p::AccountSocket(socket); + unsafe { + js_net_validate_socket_timeout(ms); + } + once(owner.get(), "timeout", callback.get()); + unsafe { + if let Ok(payload) = p::socket_ptr(socket) { + (*payload).ext.timeout_ms = ms.max(0.0) as u64; + let core = std::ptr::addr_of_mut!((*payload).core); + if ms > 0.0 { + let _ = tl::link_deadline_arm(&mut *core, socket, ms as u64); + } else { + let _ = tl::link_deadline_cancel(&mut *core, socket); + } + } + } + owner.get() +} + +pub(crate) fn refresh_timeout(socket: OwnerLink) { + unsafe { + let Ok(payload) = p::socket_ptr(socket) else { + return; + }; + let timeout = (*payload).ext.timeout_ms; + if timeout != 0 { + let core = std::ptr::addr_of_mut!((*payload).core); + let _ = tl::link_deadline_arm(&mut *core, socket, timeout); + } + } +} + +pub(crate) fn set_tos(owner: f64, value: f64) -> f64 { + let socket = link(owner); + let value = unsafe { js_net_validate_tos(value) } as u8; + unsafe { + if let Ok(payload) = p::socket_ptr(socket) { + (*payload).ext.type_of_service = value; + } + } + owner +} + +pub(crate) fn set_encoding(owner: f64, encoding: f64) -> f64 { + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(owner); + let socket = link(owner.get()); + let _account = p::AccountSocket(socket); + let encoding = unsafe { crate::jsvalue_to_owned_string(encoding) } + .unwrap_or_else(|| "utf8".into()) + .to_ascii_lowercase(); + unsafe { + if let Ok(payload) = p::socket_ptr(socket) { + (*payload).ext.encoding = Some(encoding); + } + } + owner.get() +} + +pub(crate) fn address(owner: f64) -> f64 { + let socket = link(owner); + let address = unsafe { + p::socket_ptr(socket) + .ok() + .and_then(|payload| (*payload).ext.local) + }; + let Some(address) = address else { + return f64::from_bits(JsValue::NULL.bits()); + }; + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(owner); + let object = scope.root_nanbox(f64::from_bits(perry_ffi::alloc_object().bits())); + p::own_set( + object.get(), + "address", + events::string(&address.ip().to_string()), + ); + p::own_set( + object.get(), + "family", + events::string(if address.is_ipv4() { "IPv4" } else { "IPv6" }), + ); + p::own_set(object.get(), "port", address.port() as f64); + let _ = owner; + object.get() +} + +pub(crate) fn set_paused(owner: f64, paused: bool) -> f64 { + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(owner); + let socket = link(owner.get()); + let _account = p::AccountSocket(socket); + unsafe { + if let Ok(payload) = p::socket_ptr(socket) { + (*payload).ext.paused = paused; + (*payload).ext.flowing = Some(!paused); + } + } + if !paused { + flow(owner.get()); + } + owner.get() +} + +/// Listener registration and resume use the same path. Read buffers belong +/// to this payload until listeners consume them; no pending-data registry. +pub(crate) fn flow(owner: f64) { + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(owner); + let socket = link(owner.get()); + let _account = p::AccountSocket(socket); + let listeners = events::listener_count(owner.get(), "data"); + let action = unsafe { + p::socket_ptr(socket).ok().and_then(|payload| { + let fields = &mut (*payload).ext; + if fields.paused && !fields.raw_consumer { + return None; + } + if listeners != 0 && !fields.raw_consumer { + fields.flowing = Some(true); + } + let start = fields.opened && !fields.read_started; + if start { + fields.read_started = true; + } + let bytes = if (listeners != 0 || fields.flowing == Some(true)) && !fields.raw_consumer + { + fields.read_buffer.drain(..).collect::>() + } else { + Vec::new() + }; + Some((start, bytes)) + }) + }; + let Some((start, bytes)) = action else { + return; + }; + if start { + let result = unsafe { + p::socket_core(socket) + .ok() + .map(|core| tl::link_read_start(&mut *core, socket)) + }; + if let Some(Err(failure)) = result { + let failure = error(&failure.code, &failure.message()); + destroy(owner.get(), failure); + return; + } + } + if !bytes.is_empty() { + data(owner.get(), &bytes); + } + queue_read_end(owner.get()); +} + +pub(crate) fn data(owner: f64, bytes: &[u8]) { + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(owner); + let socket = link(owner.get()); + let _account = p::AccountSocket(socket); + let listeners = events::listener_count(owner.get(), "data"); + let mut buffered = false; + let encoding = unsafe { + p::socket_ptr(socket).ok().and_then(|payload| { + let fields = &mut (*payload).ext; + if fields.paused + || fields.raw_consumer + || (listeners == 0 && fields.flowing != Some(true)) + { + fields.read_buffer.extend(bytes.iter().copied()); + buffered = !fields.raw_consumer; + return None; + } + // Explicit resume consumes an unread stream, as in Node. Keeping + // these bytes would postpone EOF forever when there is no data + // listener, stranding its peer and a closing server's last child. + if listeners == 0 { + return None; + } + Some(fields.encoding.clone()) + }) + }; + let Some(encoding) = encoding else { + if buffered { + events::emit(owner.get(), "readable", &[]); + } + return; + }; + let value = match encoding.as_deref() { + Some("hex") => events::string( + &bytes + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(), + ), + Some("base64") => events::string(&base64_encode(bytes)), + Some(_) => events::string(&String::from_utf8_lossy(bytes)), + None => f64::from_bits(JsValue::from_object_ptr(perry_ffi::alloc_buffer(bytes)).bits()), + }; + events::emit(owner.get(), "data", &[value]); +} + +fn base64_encode(bytes: &[u8]) -> String { + const ALPHA: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4); + for chunk in bytes.chunks(3) { + let a = chunk[0]; + let b = chunk.get(1).copied().unwrap_or(0); + let c = chunk.get(2).copied().unwrap_or(0); + out.push(ALPHA[(a >> 2) as usize] as char); + out.push(ALPHA[(((a & 3) << 4) | (b >> 4)) as usize] as char); + out.push(if chunk.len() > 1 { + ALPHA[(((b & 15) << 2) | (c >> 6)) as usize] as char + } else { + '=' + }); + out.push(if chunk.len() > 2 { + ALPHA[(c & 63) as usize] as char + } else { + '=' + }); + } + out +} diff --git a/crates/perry-ext-net/src/payload_tls.rs b/crates/perry-ext-net/src/payload_tls.rs new file mode 100644 index 0000000000..f140782380 --- /dev/null +++ b/crates/perry-ext-net/src/payload_tls.rs @@ -0,0 +1,555 @@ +//! Sans-I/O TLS in SocketFields. Its promise and metadata are ordinary JS edges. + +use super::payload_transport::{self as p, TlsLayer, TlsWrite}; +use super::{payload_events as events, payload_io as io, payload_socket as socket}; +use perry_ffi::native_payload::OwnerLink; +use perry_ffi::turnloop_net as tl; +use perry_ffi::{JsValue, TransientRootScope}; +use std::collections::VecDeque; + +fn attach_tls_prototype(owner: f64) { + extern "C" { + fn js_tls_client_attach_socket_prototype(owner: i64); + } + unsafe { + js_tls_client_attach_socket_prototype(p::raw_owner(owner)); + } +} + +pub(crate) fn installed(link: OwnerLink) -> bool { + unsafe { + p::socket_ptr(link) + .ok() + .is_some_and(|payload| (*payload).ext.tls.is_some()) + } +} + +pub(crate) fn install_client( + owner: f64, + servername: String, + verify: bool, + config: crate::TlsClientConfigData, +) -> Result<(), String> { + let client_config = crate::tls::build_client_config(verify, Some(&config))?; + let server_name = crate::turnloop_tls::server_name(&servername)?; + install_client_config( + owner, + client_config, + server_name, + servername, + verify, + config, + ) +} + +/// HTTP supplies its cached verifier/configuration, retaining session +/// resumption while the Socket owns the records through a later upgrade. +pub(crate) fn install_client_config( + owner: f64, + client_config: std::sync::Arc, + server_name: rustls::pki_types::ServerName<'static>, + servername: String, + verify: bool, + config: crate::TlsClientConfigData, +) -> Result<(), String> { + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(owner); + let link = socket::link(owner.get()); + let _account = p::AccountSocket(link); + if installed(link) { + return Err("socket is already TLS".into()); + } + let session = crate::turnloop_tls::TlsSession::client(client_config, server_name)?; + unsafe { + p::socket_ptr(link) + .map_err(|_| "socket is closed")? + .as_mut() + .unwrap() + .ext + .tls = Some(Box::new(TlsLayer { + session, + servername, + verify, + config, + cipher_written: 0, + cipher_acked: 0, + pending: VecDeque::new(), + pending_shutdown: None, + secure_emitted: false, + closing: false, + server: false, + })); + } + attach_tls_prototype(owner.get()); + let started = unsafe { + p::socket_ptr(link).is_ok_and(|payload| (*payload).ext.opened || (*payload).ext.connecting) + }; + if started { + drive(owner.get()); + } + Ok(()) +} + +pub(crate) fn install_server( + owner: f64, + config: std::sync::Arc, +) -> Result<(), String> { + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(owner); + let link = socket::link(owner.get()); + let _account = p::AccountSocket(link); + if installed(link) { + return Err("socket is already TLS".into()); + } + let session = crate::turnloop_tls::TlsSession::server(config)?; + unsafe { + p::socket_ptr(link) + .map_err(|_| "socket is closed")? + .as_mut() + .unwrap() + .ext + .tls = Some(Box::new(TlsLayer { + session, + servername: String::new(), + verify: true, + config: crate::TlsClientConfigData::default(), + cipher_written: 0, + cipher_acked: 0, + pending: VecDeque::new(), + pending_shutdown: None, + secure_emitted: false, + closing: false, + server: true, + })); + } + attach_tls_prototype(owner.get()); + Ok(()) +} + +pub(crate) fn queued(link: OwnerLink) -> usize { + unsafe { + p::socket_ptr(link) + .ok() + .and_then(|payload| (*payload).ext.tls.as_ref()) + .map_or(0, |layer| { + layer.pending.iter().map(|write| write.plain_len).sum() + }) + } +} + +pub(crate) fn write(owner: f64, bytes: &[u8], user: u64) -> Result { + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(owner); + let link = socket::link(owner.get()); + let _account = p::AccountSocket(link); + unsafe { + let payload = p::socket_ptr(link).map_err(|_| bad_fd("write"))?; + let fields = &mut (*payload).ext; + if let Some(layer) = fields.tls.as_mut() { + layer.session.write(bytes); + layer.pending.push_back(TlsWrite { + user, + plain_len: bytes.len(), + mark: None, + }); + } else if fields.direct_tls.is_some() { + fields.held_tls_writes.push((bytes.to_vec(), user)); + return Ok(fields + .held_tls_writes + .iter() + .map(|write| write.0.len()) + .sum()); + } else { + return tl::link_write( + &mut *p::socket_core(link).map_err(|_| bad_fd("write"))?, + link, + bytes, + user, + ); + } + } + drive(owner.get()); + Ok(queued(link)) +} + +pub(crate) fn shutdown(owner: f64, user: u64) -> Result<(), tl::NetError> { + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(owner); + let link = socket::link(owner.get()); + let _account = p::AccountSocket(link); + unsafe { + let fields = &mut (*p::socket_ptr(link).map_err(|_| bad_fd("shutdown"))?).ext; + if let Some(layer) = fields.tls.as_mut() { + layer.session.close_notify(); + layer.pending_shutdown = Some(user); + layer.closing = true; + } else if fields.direct_tls.is_some() { + fields.held_tls_end = Some(user); + return Ok(()); + } else { + return tl::link_shutdown( + &mut *p::socket_core(link).map_err(|_| bad_fd("shutdown"))?, + link, + user, + ); + } + } + drive(owner.get()); + Ok(()) +} + +fn bad_fd(syscall: &str) -> tl::NetError { + tl::NetError { + code: "EBADF".into(), + syscall: syscall.into(), + errno: -9, + no_loop: false, + } +} + +pub(crate) fn begin_connected(owner: f64) { + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(owner); + let link = socket::link(owner.get()); + let _account = p::AccountSocket(link); + let initial = unsafe { + p::socket_ptr(link) + .ok() + .and_then(|payload| (*payload).ext.direct_tls.take()) + }; + let Some((servername, verify, config)) = initial else { + if installed(link) { + drive(owner.get()); + } + return; + }; + if let Err(message) = install_client(owner.get(), servername, verify, config) { + fail(owner.get(), &message, false); + return; + } + let pending = unsafe { + p::socket_ptr(link).ok().map(|payload| { + ( + std::mem::take(&mut (*payload).ext.held_tls_writes), + (*payload).ext.held_tls_end.take(), + ) + }) + }; + if let Some((writes, end)) = pending { + for (bytes, user) in writes { + if let Err(error) = write(owner.get(), &bytes, user) { + fail(owner.get(), &error.message(), false); + return; + } + } + if let Some(user) = end { + if let Err(error) = shutdown(owner.get(), user) { + fail(owner.get(), &error.message(), false); + } + } + } +} + +pub(crate) fn receive(owner: f64, bytes: &[u8]) { + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(owner); + let link = socket::link(owner.get()); + let _account = p::AccountSocket(link); + unsafe { + if let Ok(payload) = p::socket_ptr(link) { + if let Some(layer) = (*payload).ext.tls.as_mut() { + layer.session.receive(bytes); + } + } + } + drive(owner.get()); +} + +pub(crate) fn wrote(owner: f64, len: usize) { + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(owner); + let link = socket::link(owner.get()); + let _account = p::AccountSocket(link); + let Some(snapshot) = io::snapshot(link) else { + return; + }; + let done = unsafe { + p::socket_ptr(link).ok().and_then(|payload| { + let layer = (*payload).ext.tls.as_mut()?; + layer.cipher_acked = layer.cipher_acked.saturating_add(len as u64); + let mut done = Vec::new(); + while layer + .pending + .front() + .is_some_and(|write| write.mark.is_some_and(|mark| mark <= layer.cipher_acked)) + { + let write = layer.pending.pop_front().unwrap(); + done.push((write.user, write.plain_len)); + } + Some(done) + }) + }; + for (user, plain_len) in done.unwrap_or_default() { + if !io::matches(link, &snapshot) { + return; + } + io::wrote(owner.get(), user, plain_len, queued(link)); + } +} + +/// Complete every native borrow before publishing facts or emitting JS. +fn drive(owner: f64) { + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(owner); + let link = socket::link(owner.get()); + let _account = p::AccountSocket(link); + let Some(snapshot) = io::snapshot(link) else { + return; + }; + let driven = unsafe { + p::socket_ptr(link).ok().and_then(|payload| { + let layer = (*payload).ext.tls.as_mut()?; + let progress = layer.session.pump(); + let output = layer.session.take_output(); + if !output.is_empty() { + layer.cipher_written = layer.cipher_written.saturating_add(output.len() as u64); + for write in layer + .pending + .iter_mut() + .filter(|write| write.mark.is_none()) + { + write.mark = Some(layer.cipher_written); + } + } + let end = if layer.session.close_sent() { + layer.pending_shutdown.take() + } else { + None + }; + let facts = if progress.handshake_done && !layer.secure_emitted { + layer.secure_emitted = true; + Some(crate::tls::HandshakeFacts::from_session( + &layer.session, + &layer.servername, + layer.verify, + Some(&layer.config), + )) + } else { + None + }; + layer.closing |= progress.peer_closed; + Some(( + output, + end, + facts, + layer.server, + layer.session.take_plaintext(), + progress.peer_closed, + layer.session.failure().map(str::to_owned), + layer.closing, + )) + }) + }; + let Some((output, end, facts, server, plain, eof, failure, closing)) = driven else { + return; + }; + if !output.is_empty() { + if let Err(error) = + unsafe { tl::link_write(&mut *p::socket_core(link).unwrap(), link, &output, 0) } + { + fail(owner.get(), &error.message(), closing); + return; + } + } + if let Some(user) = end { + if let Err(error) = + unsafe { tl::link_shutdown(&mut *p::socket_core(link).unwrap(), link, user) } + { + fail(owner.get(), &error.message(), closing); + return; + } + } + if let Some(message) = failure { + fail(owner.get(), &message, closing); + return; + } + if let Some(facts) = facts { + publish(owner.get(), &facts); + if !server { + extern "C" { + fn js_tls_client_check_identity_from_metadata(owner: i64) -> f64; + } + let failure = scope.root_nanbox(unsafe { + js_tls_client_check_identity_from_metadata(p::raw_owner(owner.get())) + }); + if !io::matches(link, &snapshot) { + return; + } + if !JsValue::from_bits(failure.get().to_bits()).is_undefined() { + settle_upgrade(owner.get(), Some("TLS identity callback rejected the peer")); + socket::destroy(owner.get(), failure.get()); + return; + } + } + if !io::matches(link, &snapshot) { + return; + } + settle_upgrade(owner.get(), None); + events::emit( + owner.get(), + if server { "secure" } else { "secureConnect" }, + &[], + ); + } + if !io::matches(link, &snapshot) { + return; + } + if !plain.is_empty() { + // The handshake's secure listener can change the codec. Read its + // current route after JS, preserving the cell/read token/ref count. + unsafe { + if let Ok(core) = p::socket_core(link) { + let _ = tl::link_dispatch_plaintext(core, link, &plain, false); + } + } + } + if eof && io::matches(link, &snapshot) { + unsafe { + if let Ok(core) = p::socket_core(link) { + let _ = tl::link_dispatch_plaintext(core, link, &[], true); + } + } + } +} + +fn publish(owner: f64, facts: &crate::tls::HandshakeFacts) { + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(owner); + let state = scope.root_nanbox(socket::state(owner.get())); + p::own_set( + state.get(), + "tlsConnected", + f64::from_bits(JsValue::TRUE.bits()), + ); + p::own_set( + state.get(), + "encrypted", + f64::from_bits(JsValue::TRUE.bits()), + ); + p::own_set( + state.get(), + "authorized", + f64::from_bits(JsValue::from_bool(facts.authorized).bits()), + ); + if let Some((name, standard)) = facts.cipher.as_ref() { + let cipher = scope.root_nanbox(f64::from_bits(perry_ffi::alloc_object().bits())); + p::own_set(cipher.get(), "name", events::string(name)); + p::own_set(cipher.get(), "standardName", events::string(standard)); + p::own_set(cipher.get(), "version", events::string(facts.protocol)); + p::own_set(state.get(), "cipher", cipher.get()); + } + p::own_set(state.get(), "protocol", events::string(facts.protocol)); + p::own_set(state.get(), "servername", events::string(&facts.servername)); + p::own_set( + state.get(), + "alpnProtocol", + if facts.alpn.is_empty() { + f64::from_bits(JsValue::FALSE.bits()) + } else { + events::string(&String::from_utf8_lossy(&facts.alpn)) + }, + ); + p::own_set( + state.get(), + "authorizationError", + if facts.authorized { + p::undefined() + } else { + events::string("DEPTH_ZERO_SELF_SIGNED_CERT") + }, + ); + p::own_set( + state.get(), + "peerCertificateDer", + f64::from_bits(JsValue::from_object_ptr(perry_ffi::alloc_buffer(&facts.peer)).bits()), + ); + p::own_set( + state.get(), + "ownCertificateDer", + f64::from_bits( + JsValue::from_object_ptr(perry_ffi::alloc_buffer(&facts.own_certificate)).bits(), + ), + ); +} + +pub(crate) fn settle_upgrade(owner: f64, failure: Option<&str>) { + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(owner); + let state = scope.root_nanbox(socket::state(owner.get())); + let promise = scope.root_nanbox(p::own_get(state.get(), "upgradePromise")); + if !JsValue::from_bits(promise.get().to_bits()).is_pointer() { + return; + } + p::own_set(state.get(), "upgradePromise", p::undefined()); + let promise = unsafe { + perry_ffi::JsPromise::from_raw( + JsValue::from_bits(promise.get().to_bits()).as_pointer::() + as *mut perry_ffi::Promise, + ) + }; + match failure { + Some(message) => promise.reject_string(message), + None => promise.resolve_undefined(), + } +} + +fn fail(owner: f64, message: &str, closing: bool) { + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(owner); + settle_upgrade(owner.get(), Some(message)); + let error = if closing { + p::undefined() + } else { + { + let (code, text) = message + .split_once(':') + .unwrap_or(("ERR_TLS_HANDSHAKE_FAILED", message)); + socket::error(code, &node_failure_message(code, text.trim_start())) + } + }; + socket::destroy(owner.get(), error); +} + +/// Node's message for a handshake failure's cause code. Node reports these as +/// an `Error` carrying `.code` and OpenSSL's text; rustls has its own text, so +/// the codes Node users test for get Node's words and anything else keeps +/// rustls's. +fn node_failure_message(code: &str, rustls_message: &str) -> String { + match code { + "UNABLE_TO_VERIFY_LEAF_SIGNATURE" => "unable to verify the first certificate".to_string(), + "CERT_HAS_EXPIRED" => "certificate has expired".to_string(), + "CERT_NOT_YET_VALID" => "certificate is not yet valid".to_string(), + "CERT_REVOKED" => "certificate revoked".to_string(), + "ERR_TLS_CERT_ALTNAME_INVALID" => { + format!("Hostname/IP does not match certificate's altnames: {rustls_message}") + } + _ => rustls_message.to_string(), + } +} + +#[cfg(test)] +mod node_text_tests { + use super::node_failure_message; + + #[test] + fn node_codes_get_node_text() { + assert_eq!( + node_failure_message("UNABLE_TO_VERIFY_LEAF_SIGNATURE", "x"), + "unable to verify the first certificate" + ); + assert_eq!( + node_failure_message("ERR_SSL_PROTOCOL_ERROR", "rustls text"), + "rustls text" + ); + } +} diff --git a/crates/perry-ext-net/src/payload_transport.rs b/crates/perry-ext-net/src/payload_transport.rs new file mode 100644 index 0000000000..f1fd3d7cd5 --- /dev/null +++ b/crates/perry-ext-net/src/payload_transport.rs @@ -0,0 +1,363 @@ +//! Socket and listener ownership for link-routed transports. +//! +//! Rust owns bytes, codec state and driver capabilities. Ordinary JS state +//! owns callbacks, the server edge, parser/codec objects and terminal caches. +//! No object address or JS value is retained in these Rust fields. + +use perry_ffi::native_payload::{self as np, OwnerLink, PayloadFamily, PayloadMiss}; +use perry_ffi::turnloop_net::{self as tl, TransportPayload}; +use perry_ffi::{JsValue, ObjectHeader, TransientRootScope}; +use std::collections::VecDeque; +use std::net::SocketAddr; + +pub(crate) const SOCKET_CLASS_ID: u32 = perry_ffi::native_class_ids::NET_SOCKET; +pub(crate) const SERVER_CLASS_ID: u32 = perry_ffi::native_class_ids::NET_SERVER; + +static SOCKET_VTABLE: perry_ffi::native_stream::PayloadVTable = perry_ffi::native_stream::payload_vtable::(None); +pub(crate) static SOCKET: PayloadFamily = PayloadFamily::new::(SOCKET_CLASS_ID, "Socket", true, &SOCKET_VTABLE) + .with_constructor_length(1) + .with_installer(super::payload_prototype::install_socket); +static SERVER_VTABLE: perry_ffi::native_stream::PayloadVTable = perry_ffi::native_stream::payload_vtable::(None); +pub(crate) static SERVER: PayloadFamily = PayloadFamily::new::(SERVER_CLASS_ID, "Server", true, &SERVER_VTABLE) + .with_constructor_length(2) + .with_installer(super::payload_prototype::install_server); + +pub(crate) type SocketPayload = TransportPayload; +pub(crate) type ServerPayload = TransportPayload; + +#[derive(Default)] +pub(crate) struct SocketFields { + /// A TLSSocket wrapper owns its JS state; its parent Socket owns the + /// physical transport. The parent itself is an ordinary JS state edge. + pub tls_parent: bool, + pub local: Option, + pub peer: Option, + pub host: String, + pub port: u16, + pub encoding: Option, + pub timeout_ms: u64, + pub tcp_async_id: u64, + pub connect_async_id: u64, + pub shutdown_async_id: u64, + pub connecting: bool, + pub opened: bool, + pub read_ended: bool, + pub read_end_emitted: bool, + pub write_ended: bool, + pub shutdown_done: bool, + pub allow_half_open: bool, + pub read_started: bool, + pub paused: bool, + pub flowing: Option, + pub need_drain: bool, + pub bytes_read: u64, + pub bytes_written: u64, + pub queued: usize, + pub callback_seq: u64, + pub cork_depth: u32, + pub cork_bytes: Vec, + pub cork_users: Vec, + pub coalesced_users: VecDeque<(u64, Vec)>, + pub extra_end_users: Vec, + pub read_buffer: VecDeque, + pub raw_consumer: bool, + pub raw_error: Option, + pub tls: Option>, + pub direct_tls: Option<(String, bool, crate::TlsClientConfigData)>, + pub held_tls_writes: Vec<(Vec, u64)>, + pub held_tls_end: Option, + pub type_of_service: u8, + pub bun: bool, + /// Explicit-close cleanup for a separately owned protocol payload. + /// This is a static code pointer, never JS or a heap address. Its target + /// may release the parser/codec payload but may not run JS or the driver. + /// Drop of SocketFields does not invoke it: GC traces the ordinary edge. + pub close_codec: Option, +} + +/// The sans-I/O session and its byte accounting. The upgrade promise and +/// checkServerIdentity callback are fields of the owner's JS state. +pub(crate) struct TlsLayer { + pub session: crate::turnloop_tls::TlsSession, + pub servername: String, + pub verify: bool, + pub config: crate::TlsClientConfigData, + pub cipher_written: u64, + pub cipher_acked: u64, + pub pending: VecDeque, + pub pending_shutdown: Option, + pub secure_emitted: bool, + pub closing: bool, + pub server: bool, +} +pub(crate) struct TlsWrite { + pub user: u64, + pub plain_len: usize, + pub mark: Option, +} + +#[derive(Default)] +pub(crate) struct ServerFields { + pub local: Option, + pub path: Option, + pub async_id: u64, + pub allow_half_open: bool, + pub pause_on_connect: bool, + pub listening: bool, + /// The HTTPS delegate owns a sans-I/O configuration, never a JS value. + pub tls_config: Option>, +} + +impl SocketFields { + fn retained_bytes(&self) -> usize { + use std::mem::size_of; + self.host.capacity() + + self.encoding.as_ref().map_or(0, String::capacity) + + self.cork_bytes.capacity() + + self.cork_users.capacity() * size_of::() + + self.coalesced_users.capacity() * size_of::<(u64, Vec)>() + + self + .coalesced_users + .iter() + .map(|(_, users)| users.capacity() * size_of::()) + .sum::() + + self.extra_end_users.capacity() * size_of::() + + self.read_buffer.capacity() + + self.raw_error.as_ref().map_or(0, String::capacity) + + self.direct_tls.as_ref().map_or(0, |(host, _, config)| { + host.capacity() + config.retained_bytes() + }) + + self.held_tls_writes.capacity() * size_of::<(Vec, u64)>() + + self + .held_tls_writes + .iter() + .map(|(bytes, _)| bytes.capacity()) + .sum::() + + self.tls.as_ref().map_or(0, |tls| { + size_of::() + + tls.session.retained_bytes() + + tls.servername.capacity() + + tls.config.retained_bytes() + + tls.pending.capacity() * size_of::() + }) + } +} + +/// Temporary binding cleanup, never stored in a payload. Its caller roots +/// the owner until this guard drops. Native event callbacks catch JS throws +/// before returning here; release/reopen is checked again at cleanup. +pub(crate) struct AccountSocket(pub OwnerLink); +pub(crate) struct AccountServer(pub OwnerLink); +impl Drop for AccountServer { + fn drop(&mut self) { + unsafe { + account_server(self.0); + } + } +} +impl Drop for AccountSocket { + fn drop(&mut self) { + unsafe { + account_socket(self.0); + } + } +} + +pub(crate) unsafe fn account_socket(link: OwnerLink) { + let Ok(payload) = socket_ptr(link) else { + return; + }; + let fields = (*payload).ext.retained_bytes(); + let core = std::ptr::addr_of_mut!((*payload).core); + let bytes = + std::mem::size_of::() + fields + tl::link_retained_bytes(&mut *core, link); + np::link_set_external_bytes(link, &SOCKET, bytes); +} +pub(crate) unsafe fn account_server(link: OwnerLink) { + let Ok(payload) = server_ptr(link) else { + return; + }; + let fields = (*payload).ext.path.as_ref().map_or(0, String::capacity); + let core = std::ptr::addr_of_mut!((*payload).core); + let bytes = + std::mem::size_of::() + fields + tl::link_retained_bytes(&mut *core, link); + np::link_set_external_bytes(link, &SERVER, bytes); +} + +extern "C" { + fn js_event_emitter_subclass_init(owner: f64, options: f64) -> f64; + fn js_object_set_field_by_name( + object: *mut ObjectHeader, + key: *const perry_ffi::StringHeader, + value: f64, + ); +} + +pub(crate) fn undefined() -> f64 { + f64::from_bits(JsValue::UNDEFINED.bits()) +} +pub(crate) fn boxed_addr(addr: i64) -> f64 { + f64::from_bits(JsValue::from_object_ptr(addr as *mut u8).bits()) +} +pub(crate) fn raw_owner(owner: f64) -> i64 { + JsValue::from_bits(owner.to_bits()).as_pointer::() as i64 +} + +/// Allocate before any driver submission, so every token names this cell +/// from the first connect, resolve, accept-install or read onward. +pub(crate) fn alloc_socket(route: u8, fields: SocketFields) -> f64 { + super::payload_io::register(); + let scope = TransientRootScope::enter(); + let bytes = std::mem::size_of::() + fields.retained_bytes(); + let owner = scope.root_nanbox(unsafe { np::alloc_in(&SOCKET, "net", + SocketPayload::new(route, fields), + bytes, + &[], + ) }); + unsafe { + js_event_emitter_subclass_init(owner.get(), undefined()); + } + owner.get() +} +pub(crate) fn alloc_server(route: u8, fields: ServerFields) -> f64 { + super::payload_io::register(); + let scope = TransientRootScope::enter(); + let bytes = + std::mem::size_of::() + fields.path.as_ref().map_or(0, String::capacity); + let owner = scope.root_nanbox(unsafe { np::alloc_in(&SERVER, "net", + ServerPayload::new(route, fields), + bytes, + &[], + ) }); + unsafe { + js_event_emitter_subclass_init(owner.get(), undefined()); + } + owner.get() +} + +/// Back a source subclass's actual receiver. No temporary native owner is +/// allocated or aliased, and attach preserves its class and prototype. +pub(crate) fn attach_socket(owner: f64, route: u8, fields: SocketFields) -> bool { + super::payload_io::register(); + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(owner); + let bytes = std::mem::size_of::() + fields.retained_bytes(); + if !unsafe { np::attach_to_object( + owner.get(), + &SOCKET, + SocketPayload::new(route, fields), + bytes, + ) } { + return false; + } + unsafe { + js_event_emitter_subclass_init(owner.get(), undefined()); + } + true +} +pub(crate) fn attach_server(owner: f64, route: u8, fields: ServerFields) -> bool { + super::payload_io::register(); + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(owner); + let bytes = + std::mem::size_of::() + fields.path.as_ref().map_or(0, String::capacity); + if !unsafe { np::attach_to_object( + owner.get(), + &SERVER, + ServerPayload::new(route, fields), + bytes, + ) } { + return false; + } + unsafe { + js_event_emitter_subclass_init(owner.get(), undefined()); + } + true +} + +pub(crate) fn socket_link(owner: f64) -> Result { + np::receiver_link(owner, &SOCKET) +} +pub(crate) fn server_link(owner: f64) -> Result { + np::receiver_link(owner, &SERVER) +} + +/// # Safety +/// A rooted owner or an outstanding driver ref keeps this checked cell alive. +/// End each projected borrow before JS, release or reopen. +pub(crate) unsafe fn socket_ptr(link: OwnerLink) -> Result<*mut SocketPayload, PayloadMiss> { + np::link_payload_ptr(link, &SOCKET) +} +pub(crate) unsafe fn server_ptr(link: OwnerLink) -> Result<*mut ServerPayload, PayloadMiss> { + np::link_payload_ptr(link, &SERVER) +} + +pub(crate) fn own_get(owner: f64, key: &str) -> f64 { + f64::from_bits(perry_ffi::object_field_by_name(JsValue::from_bits(owner.to_bits()), key).bits()) +} +pub(crate) fn own_set(owner: f64, key: &str, value: f64) { + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(owner); + let value = scope.root_nanbox(value); + let key = perry_ffi::alloc_string(key); + unsafe { + js_object_set_field_by_name( + JsValue::from_bits(owner.get().to_bits()).as_pointer(), + key.as_raw(), + value.get(), + ); + } +} + +/// JS state follows the owner through moving collections and survives release. +/// Callers root the result before any later allocation. +pub(crate) unsafe fn socket_state(link: OwnerLink, create: bool) -> f64 { + np::link_js_state(link, &SOCKET, create) +} +pub(crate) unsafe fn server_state(link: OwnerLink, create: bool) -> f64 { + np::link_js_state(link, &SERVER, create) +} + +/// Disjoint projection of the opaque core. No borrow of SocketFields remains +/// alive while the driver runs or a completion reaches a binding sink. +pub(crate) unsafe fn socket_core(link: OwnerLink) -> Result<*mut tl::TransportCore, PayloadMiss> { + Ok(std::ptr::addr_of_mut!((*socket_ptr(link)?).core)) +} +pub(crate) unsafe fn server_core(link: OwnerLink) -> Result<*mut tl::TransportCore, PayloadMiss> { + Ok(std::ptr::addr_of_mut!((*server_ptr(link)?).core)) +} + +pub(crate) unsafe fn reopen_socket(link: OwnerLink, route: u8) -> Result<(), np::AttachMiss> { + np::attach_link( + link, + &SOCKET, + SocketPayload::new(route, SocketFields::default()), + std::mem::size_of::(), + ) +} +pub(crate) unsafe fn reopen_server( + link: OwnerLink, + route: u8, + fields: ServerFields, +) -> Result<(), np::AttachMiss> { + let bytes = + std::mem::size_of::() + fields.path.as_ref().map_or(0, String::capacity); + np::attach_link(link, &SERVER, ServerPayload::new(route, fields), bytes) +} + +/// Route changes keep the same handle, multishot read and cell. The caller +/// owns the ordinary parser/codec edge and releases it after this store. +pub(crate) unsafe fn set_route(link: OwnerLink, route: u8) -> Result<(), tl::NetError> { + let core = socket_core(link).map_err(|_| tl::NetError { + code: "EBADF".into(), + syscall: "route".into(), + errno: -9, + no_loop: false, + })?; + tl::link_set_route(&mut *core, link, route) +} + +// Dropping any field above is memory-only. Driver close belongs to explicit +// methods; TransportCore::drop never dereferences a driver or a thread-local. +const _: () = assert!(std::mem::offset_of!(SocketPayload, core) == 0); +const _: () = assert!(std::mem::offset_of!(ServerPayload, core) == 0); diff --git a/crates/perry-ext-net/src/pipe.rs b/crates/perry-ext-net/src/pipe.rs deleted file mode 100644 index 3cc2ceb4a8..0000000000 --- a/crates/perry-ext-net/src/pipe.rs +++ /dev/null @@ -1,345 +0,0 @@ -//! #10444 — `net.Socket.prototype.pipe(dest[, options])` / `.unpipe(dest?)`. -//! -//! A `net.Socket` lives behind ext-net's own handle registry -//! (`statics::sockets()` / `statics::listeners()`), a completely different -//! representation from node:stream's own object+closure model -//! (`perry-runtime`'s `node_stream` module, which backs `Readable`/ -//! `Writable`/`Duplex`/`Transform`/`PassThrough`). Bridging those two -//! independent state machines so a socket could reuse node:stream's own -//! `pipe()` implementation (with its full backpressure/unpipe-on-error/ -//! `'pipe'`+`'unpipe'` event machinery) is a much larger undertaking than -//! this cluster fix covers. -//! -//! Instead this reuses the SAME generic `Get(dest, "write")` + call -//! duck-typed dispatch the runtime already relies on to resolve thenables -//! (`crate::promise::assimilate::assimilate_via_then_property` in -//! `perry-runtime`, which does `Get(value, "then")` then invokes it with -//! `this` bound to the thenable): fetch `dest.write` / `dest.end` by name -//! through `js_dynamic_object_get_property` and invoke whatever comes back -//! through `js_native_call_value` with `dest` as the receiver. -//! That resolves correctly regardless of what representation `dest` is — -//! another handle-backed socket, a node:stream object, or a plain user -//! object that overrides `write` — the same way real Node duck-types its -//! destination. -//! -//! Scope: this forwards `'data'` to `dest.write(chunk)` and (unless -//! `{ end: false }`) calls `dest.end()` once the source's `'end'` fires, and -//! returns `dest` for chaining. It does NOT implement automatic -//! unpipe-on-error, backpressure-aware pause/resume of the source, or the -//! `'pipe'`/`'unpipe'` events on the destination that Node's real -//! `Readable.prototype.pipe` fires — those are follow-up work, not part of -//! the #10444 reproduction (a `PassThrough`/`Transform` destination reading -//! everything a socket writes). - -use std::collections::HashMap; -use std::sync::{Mutex, OnceLock}; - -use perry_ffi::{ - alloc_closure, closure_capture_f64, set_closure_capture_f64, GcRootVisitor, RawClosureHeader, -}; - -use crate::statics; - -const TAG_UNDEFINED_BITS: u64 = 0x7FFC_0000_0000_0001; -const TAG_NULL_BITS: u64 = 0x7FFC_0000_0000_0002; -const TAG_FALSE_BITS: u64 = 0x7FFC_0000_0000_0003; - -// `js_dynamic_object_get_property` / `js_native_call_value` aren't -// wrapped by perry-ffi (unlike the closure -// helpers above); declare them the same way `dispatch.rs` declares its own -// direct `perry-runtime` FFI symbols (`js_class_method_bind`, -// `js_promise_resolve`, …) — resolved at final link time, not a Rust-level -// crate dependency. -extern "C" { - fn js_dynamic_object_get_property( - obj_value: f64, - property_name_ptr: *const i8, - property_name_len: usize, - ) -> f64; - fn js_native_call_value( - func_value: f64, - this: perry_ffi::JsThis, - args_ptr: *const f64, - args_len: usize, - ) -> f64; -} - -fn is_nullish(v: f64) -> bool { - let bits = v.to_bits(); - bits == TAG_UNDEFINED_BITS || bits == TAG_NULL_BITS -} - -fn is_callable(v: f64) -> bool { - // Native closures / bound handle methods / class methods are all - // POINTER_TAG (0x7FFD) or the handle-method-bind shape; a non-callable - // `Get` result (missing property, a plain data field) is either - // undefined or some other tag entirely. This mirrors the coarse - // callable check `assimilate_via_then_property` uses before invoking a - // fetched `then` — good enough to avoid calling `undefined()` when - // `dest` has no `write` at all, without re-implementing full - // `IsCallable`. - !is_nullish(v) && (v.to_bits() >> 48) == 0x7FFD -} - -/// `Get(dest, "write")(chunk)` with `this` bound to `dest`. -fn generic_write(dest: f64, chunk: f64) { - // The `Get` can run a getter (user code): root the receiver and the - // argument across it and re-read both. - let scope = perry_ffi::TransientRootScope::enter(); - let dest = scope.root_nanbox(dest); - let chunk = scope.root_nanbox(chunk); - unsafe { - let write_fn = js_dynamic_object_get_property(dest.get(), c"write".as_ptr(), 5); - if !is_callable(write_fn) { - return; - } - let args = [chunk.get()]; - let _ = js_native_call_value( - write_fn, - perry_ffi::JsThis::from_f64(dest.get()), - args.as_ptr(), - args.len(), - ); - } -} - -/// `Get(dest, "end")()` with `this` bound to `dest`. -fn generic_end(dest: f64) { - // The `Get` can run a getter (user code): root the receiver across it. - let scope = perry_ffi::TransientRootScope::enter(); - let dest = scope.root_nanbox(dest); - unsafe { - let end_fn = js_dynamic_object_get_property(dest.get(), c"end".as_ptr(), 3); - if !is_callable(end_fn) { - return; - } - let _ = js_native_call_value( - end_fn, - perry_ffi::JsThis::from_f64(dest.get()), - std::ptr::null(), - 0, - ); - } -} - -extern "C" fn pipe_data_forward( - closure: *const RawClosureHeader, - _this: perry_ffi::JsThis, - chunk: f64, -) -> f64 { - if !closure.is_null() { - let dest = unsafe { closure_capture_f64(closure, 0) }; - generic_write(dest, chunk); - } - f64::from_bits(TAG_UNDEFINED_BITS) -} - -extern "C" fn pipe_end_forward(closure: *const RawClosureHeader, _this: perry_ffi::JsThis) -> f64 { - if !closure.is_null() { - let dest = unsafe { closure_capture_f64(closure, 0) }; - let end_on_finish = unsafe { closure_capture_f64(closure, 1) }; - if end_on_finish.to_bits() != TAG_FALSE_BITS { - generic_end(dest); - } - } - f64::from_bits(TAG_UNDEFINED_BITS) -} - -/// One socket -> destination pipe route, tracked so `unpipe` can remove -/// exactly the listener closures a matching `pipe()` call installed. -/// -/// Deliberately does NOT cache `dest`'s bits here: `dest` is a NaN-boxed -/// value that can be a heap pointer, and a second, un-rooted copy of it -/// would go stale the moment a GC cycle moves the object — the closure's -/// OWN capture slot 0 (scanned automatically once `data_cb` is reachable -/// via `statics::listeners()`, see `install_pipe_listeners`) is the only -/// copy this module keeps, and `matches_dest` below reads it back live at -/// comparison time instead of trusting a cache the collector cannot see. -struct PipeRoute { - data_cb: i64, - end_cb: i64, -} - -impl PipeRoute { - fn matches_dest(&self, dest_bits: u64) -> bool { - let live_dest = unsafe { closure_capture_f64(self.data_cb as *const RawClosureHeader, 0) }; - live_dest.to_bits() == dest_bits - } -} - -fn pipe_routes() -> &'static Mutex>> { - static ROUTES: OnceLock>>> = OnceLock::new(); - ROUTES.get_or_init(|| Mutex::new(HashMap::new())) -} - -/// Register `data_cb`/`end_cb` as normal `'data'`/`'end'` listeners on -/// `handle`, reusing the SAME `statics::listeners()` registry every other -/// socket listener goes through — so they get the same GC-root scanning -/// (`gc_roots::scan_net_roots`) and the same dispatch path -/// (`socket_events::js_ext_net_drain_pending`) as a user's own `.on(...)`, -/// with no new plumbing. -fn install_pipe_listeners(handle: i64, data_cb: i64, end_cb: i64) { - let mut listeners = statics::listeners().lock().unwrap(); - let per_socket = listeners.entry(handle).or_default(); - per_socket - .entry("data".to_string()) - .or_default() - .push(data_cb); - per_socket - .entry("end".to_string()) - .or_default() - .push(end_cb); -} - -fn uninstall_pipe_listeners(handle: i64, data_cb: i64, end_cb: i64) { - let mut listeners = statics::listeners().lock().unwrap(); - if let Some(per_socket) = listeners.get_mut(&handle) { - if let Some(vec) = per_socket.get_mut("data") { - vec.retain(|cb| *cb != data_cb); - } - if let Some(vec) = per_socket.get_mut("end") { - vec.retain(|cb| *cb != end_cb); - } - } -} - -/// `socket.pipe(dest[, options])`. Returns `dest` unchanged (Node's -/// chaining contract), or `undefined` when `dest` is missing/nullish. -pub(crate) fn socket_pipe(handle: i64, dest: f64, options: f64) -> f64 { - if is_nullish(dest) { - return f64::from_bits(TAG_UNDEFINED_BITS); - } - crate::ensure_gc_scanner_registered(); - - let end_on_finish = unsafe { - if is_nullish(options) { - f64::from_bits(0x7FFC_0000_0000_0004) // default true - } else { - let v = js_dynamic_object_get_property(options, c"end".as_ptr(), 3); - if is_nullish(v) { - f64::from_bits(0x7FFC_0000_0000_0004) - } else { - v - } - } - }; - - let data_closure = alloc_closure(&PIPE_DATA_FORWARD_INFO, 1); - let end_closure = alloc_closure(&PIPE_END_FORWARD_INFO, 2); - if data_closure.is_null() || end_closure.is_null() { - return f64::from_bits(TAG_UNDEFINED_BITS); - } - unsafe { - set_closure_capture_f64(data_closure, 0, dest); - set_closure_capture_f64(end_closure, 0, dest); - set_closure_capture_f64(end_closure, 1, end_on_finish); - } - let data_cb = data_closure as i64; - let end_cb = end_closure as i64; - install_pipe_listeners(handle, data_cb, end_cb); - pipe_routes() - .lock() - .unwrap() - .entry(handle) - .or_default() - .push(PipeRoute { data_cb, end_cb }); - - dest -} - -/// `socket.unpipe([dest])`. Removes the pipe route(s) installed by a prior -/// `pipe()` call — all of them when `dest` is omitted, only the ones whose -/// destination matches otherwise. Always returns the socket handle. -pub(crate) fn socket_unpipe(handle: i64, dest: f64) { - let filter_bits = (!is_nullish(dest)).then(|| dest.to_bits()); - let removed: Vec<(i64, i64)> = { - let mut routes = pipe_routes().lock().unwrap(); - let Some(list) = routes.get_mut(&handle) else { - return; - }; - let mut removed = Vec::new(); - list.retain(|route| { - let matches = filter_bits.is_none_or(|bits| route.matches_dest(bits)); - if matches { - removed.push((route.data_cb, route.end_cb)); - } - !matches - }); - if list.is_empty() { - routes.remove(&handle); - } - removed - }; - for (data_cb, end_cb) in removed { - uninstall_pipe_listeners(handle, data_cb, end_cb); - } -} - -/// Drop every tracked pipe route for `handle` without touching the listener -/// registry — called from the `'close'` teardown, which already clears the -/// whole `statics::listeners()` entry for `handle` (see -/// `socket_events::js_ext_net_drain_pending`'s `Close` arm), so removing the -/// individual callbacks there would be redundant. -pub(crate) fn drop_routes(handle: i64) { - pipe_routes().lock().unwrap().remove(&handle); -} - -/// GC root scanner for `pipe_routes()` — called from -/// `gc_roots::scan_net_roots` alongside the sibling `statics::listeners()` -/// scan. `data_cb`/`end_cb` are a SECOND copy of pointers already rooted via -/// `statics::listeners()` (`install_pipe_listeners` pushes the same values -/// there), but a copying GC cycle only rewrites addresses IN PLACE at -/// wherever the scanner visits them — the two copies are independent slots -/// as far as the collector is concerned, so this copy needs its own visit or -/// it keeps the pre-evacuation address after the `statics::listeners()` copy -/// has already been updated (`matches_dest`'s capture-slot read would then -/// dereference a stale/forwarded pointer — exactly the class of bug -/// `scripts/gc_runtime_root_holders.py` exists to catch). -pub(crate) fn scan_roots(visitor: &mut GcRootVisitor<'_>) { - if let Ok(mut routes) = pipe_routes().lock() { - for per_socket in routes.values_mut() { - for route in per_socket.iter_mut() { - visitor.visit_i64_slot(&mut route.data_cb); - visitor.visit_i64_slot(&mut route.end_cb); - } - } - } -} - -// ─── FFI: typed `net.Socket.prototype.pipe`/`.unpipe` ──────────────────────── -// -// The `NativeModSig` rows in -// `crates/perry-codegen/src/lower_call/native_table/net_events.rs` call -// these two symbols directly for a statically-typed `net.Socket` receiver. -// The untyped/dynamic-dispatch path (`dispatch.rs`'s `socket_method`) calls -// `socket_pipe`/`socket_unpipe` above instead of going through here, since -// it already has its own handle-nanboxing conventions. - -/// `socket.pipe(dest[, options])` for a statically-typed `net.Socket` -/// receiver. See the module doc for what this does and does not implement. -/// -/// # Safety -/// -/// `dest`/`options` must be valid NaN-boxed JS values (or `undefined`). -#[no_mangle] -pub unsafe extern "C" fn js_net_socket_pipe(handle: i64, dest: f64, options: f64) -> f64 { - socket_pipe(handle, dest, options) -} - -/// `socket.unpipe([dest])` for a statically-typed `net.Socket` receiver. -/// Returns the socket handle for chaining, matching Node. -/// -/// # Safety -/// -/// `dest` must be a valid NaN-boxed JS value (or `undefined`). -#[no_mangle] -pub unsafe extern "C" fn js_net_socket_unpipe(handle: i64, dest: f64) -> i64 { - socket_unpipe(handle, dest); - handle -} - -static PIPE_DATA_FORWARD_INFO: perry_ffi::JsFunctionInfo = - perry_ffi::JsFunctionInfo::of(pipe_data_forward as perry_ffi::JsBody1).with_declared(1); - -static PIPE_END_FORWARD_INFO: perry_ffi::JsFunctionInfo = - perry_ffi::JsFunctionInfo::of(pipe_end_forward as perry_ffi::JsBody0).with_declared(0); diff --git a/crates/perry-ext-net/src/provider_lifecycle.rs b/crates/perry-ext-net/src/provider_lifecycle.rs deleted file mode 100644 index 5dee7990ee..0000000000 --- a/crates/perry-ext-net/src/provider_lifecycle.rs +++ /dev/null @@ -1,109 +0,0 @@ -use super::*; - -pub(super) unsafe fn init_provider(name: &'static [u8]) -> u64 { - js_async_hooks_provider_init(name.as_ptr(), name.len()) -} - -pub(super) unsafe fn init_provider_with_trigger(name: &'static [u8], trigger_async_id: u64) -> u64 { - js_async_hooks_provider_init_with_trigger(name.as_ptr(), name.len(), trigger_async_id) -} - -pub(super) struct ProviderScope(u64); - -impl ProviderScope { - pub(super) unsafe fn enter(async_id: u64) -> Self { - if async_id != 0 { - js_async_hooks_provider_enter(async_id); - } - Self(async_id) - } -} - -impl Drop for ProviderScope { - fn drop(&mut self) { - if self.0 != 0 { - unsafe { js_async_hooks_provider_leave(self.0) }; - } - } -} - -pub(super) unsafe fn prepare_event_provider(ev: &PendingNetEvent) { - match ev { - PendingNetEvent::ServerConnection(server_id, socket_id, _) => { - let server_async_id = statics::servers() - .lock() - .ok() - .and_then(|servers| servers.get(server_id).map(|server| server.async_id)) - .unwrap_or(0); - let needs_init = statics::sockets() - .lock() - .ok() - .and_then(|sockets| { - sockets - .get(socket_id) - .map(|socket| socket.tcp_async_id == 0) - }) - .unwrap_or(false); - if needs_init { - let async_id = init_provider_with_trigger(b"TCPWRAP", server_async_id); - if let Some(socket) = statics::sockets().lock().unwrap().get_mut(socket_id) { - socket.tcp_async_id = async_id; - } - } - } - PendingNetEvent::ShutdownComplete(id, _, _) => { - let trigger = statics::sockets().lock().ok().and_then(|sockets| { - sockets.get(id).and_then(|socket| { - (socket.shutdown_async_id == 0).then_some(socket.tcp_async_id) - }) - }); - if let Some(trigger) = trigger { - let async_id = init_provider_with_trigger(b"SHUTDOWNWRAP", trigger); - if let Some(socket) = statics::sockets().lock().unwrap().get_mut(id) { - socket.shutdown_async_id = async_id; - } - } - } - _ => {} - } -} - -pub(super) fn event_provider_id(ev: &PendingNetEvent) -> u64 { - match ev { - PendingNetEvent::Connect(id, _) => statics::sockets() - .lock() - .ok() - .and_then(|sockets| sockets.get(id).map(|socket| socket.connect_async_id)) - .unwrap_or(0), - PendingNetEvent::SecureConnect(id) - | PendingNetEvent::Data(id, _) - | PendingNetEvent::End(id) - | PendingNetEvent::WriteComplete(id, _, _) - | PendingNetEvent::Drain(id) - | PendingNetEvent::Error(id, _) - | PendingNetEvent::AbortError(id) - | PendingNetEvent::Close(id) => statics::sockets() - .lock() - .ok() - .and_then(|sockets| sockets.get(id).map(|socket| socket.tcp_async_id)) - .unwrap_or(0), - PendingNetEvent::ShutdownComplete(id, _, _) => statics::sockets() - .lock() - .ok() - .and_then(|sockets| sockets.get(id).map(|socket| socket.shutdown_async_id)) - .unwrap_or(0), - PendingNetEvent::ServerConnection(_, socket_id, _) => statics::sockets() - .lock() - .ok() - .and_then(|sockets| sockets.get(socket_id).map(|socket| socket.tcp_async_id)) - .unwrap_or(0), - PendingNetEvent::ServerListening(id) - | PendingNetEvent::ServerClose(id) - | PendingNetEvent::ServerError(id, _) - | PendingNetEvent::ServerDrop(id, _) => statics::servers() - .lock() - .ok() - .and_then(|servers| servers.get(id).map(|server| server.async_id)) - .unwrap_or(0), - } -} diff --git a/crates/perry-ext-net/src/raw_bridge.rs b/crates/perry-ext-net/src/raw_bridge.rs deleted file mode 100644 index e78973630b..0000000000 --- a/crates/perry-ext-net/src/raw_bridge.rs +++ /dev/null @@ -1,194 +0,0 @@ -//! #2154 — raw-consumer bridge for `http.Agent.createConnection`. -//! -//! `perry-ext-http` drives a full HTTP/1.1 exchange over a socket the user's -//! `agent.createConnection` override produced (typically a `net.connect(...)` -//! result). The C-ABI entry points here are published into perry-ffi's -//! raw-net slot (see [`register`]) so http can attach/write/read/close the -//! socket without a Cargo edge to this crate. They speak only raw bytes — the -//! HTTP framing lives in perry-ext-http. -//! -//! Split out of `lib.rs` to keep that file under the 2000-line size gate. - -use std::collections::VecDeque; -use std::sync::{Arc, Mutex}; - -use crate::{statics, SocketCommand}; - -/// Backing buffer for a socket in raw-consumer mode. The read sink pushes -/// inbound bytes into `buf`; [`perry_net_raw_poll_read`] drains them. `closed` -/// flips on peer-FIN / destroy / error so a drained reader sees EOF; `error` -/// carries the transport error message if one occurred. -#[derive(Default)] -pub(crate) struct RawReadState { - pub(crate) buf: VecDeque, - pub(crate) closed: bool, - #[allow(dead_code)] - pub(crate) error: Option, -} - -/// Return the raw-consumer buffer for `id` if the socket is in raw mode (an -/// `http.request` over an `agent.createConnection` socket), else `None`. -/// Cloning the `Arc` is cheap and lets the read sink route bytes without -/// holding the sockets-map lock across the buffer mutation. -pub(crate) fn raw_state_for(id: i64) -> Option>> { - statics::sockets() - .lock() - .ok() - .and_then(|g| g.get(&id).and_then(|s| s.raw.clone())) -} - -/// Flip a raw-mode socket's buffer to closed (recording `error` if any) so a -/// draining `poll_read` observes EOF. No-op for JS-mode sockets. -fn raw_mark_closed(raw: &Arc>, error: Option) { - if let Ok(mut st) = raw.lock() { - st.closed = true; - if error.is_some() { - st.error = error; - } - } -} - -/// Read-sink hook: route an inbound chunk for socket `id`. Returns -/// `true` if the socket is in raw mode (bytes buffered for `poll_read`), -/// `false` if the caller should emit a JS `'data'` event instead. -pub(crate) fn route_data(id: i64, bytes: &[u8]) -> bool { - match raw_state_for(id) { - Some(raw) => { - if let Ok(mut st) = raw.lock() { - st.buf.extend(bytes.iter().copied()); - } - // After the buffer lock is released: the consumer drains on a - // later turn (see `perry_ffi::RawNetNotify`'s contract). - perry_ffi::raw_net_notify(id); - true - } - None => false, - } -} - -/// Read-sink hook: mark socket `id` terminal (EOF / destroy / error). -/// Returns `true` if the socket is in raw mode (buffer flagged closed, no JS -/// events), `false` if the caller should emit the JS End/Close or Error/Close -/// events. `error` is recorded on the buffer in raw mode. -pub(crate) fn mark_terminal(id: i64, error: Option) -> bool { - match raw_state_for(id) { - Some(raw) => { - raw_mark_closed(&raw, error); - perry_ffi::raw_net_notify(id); - true - } - None => false, - } -} - -/// Switch socket `socket_id` into raw-consumer mode. Inbound bytes are -/// buffered for [`perry_net_raw_poll_read`] instead of dispatched to JS -/// `'data'` listeners. Returns `1` on success, `0` if the handle is unknown. -extern "C" fn perry_net_raw_attach(socket_id: i64) -> i32 { - if let Ok(mut g) = statics::sockets().lock() { - if let Some(s) = g.get_mut(&socket_id) { - if s.raw.is_none() { - s.raw = Some(Arc::new(Mutex::new(RawReadState::default()))); - } - return 1; - } - } - 0 -} - -/// Enqueue `len` bytes at `ptr` to be written to socket `socket_id`. The write -/// is buffered by the socket's command channel and flushed once the connection -/// is established. Returns `1` if queued, `0` otherwise. -extern "C" fn perry_net_raw_write(socket_id: i64, ptr: *const u8, len: usize) -> i32 { - let bytes = if len == 0 { - Vec::new() - } else if ptr.is_null() { - return 0; - } else { - unsafe { std::slice::from_raw_parts(ptr, len) }.to_vec() - }; - if let Ok(mut g) = statics::sockets().lock() { - if let Some(s) = g.get_mut(&socket_id) { - return i32::from(s.command(socket_id, SocketCommand::Write(bytes, 0)).is_ok()); - } - } - 0 -} - -/// Drain up to `max` buffered inbound bytes from socket `socket_id` into `out`. -/// Returns the byte count (`> 0`), `0` for clean EOF once drained and the peer -/// closed, or `-1` when nothing is available but the socket is still open -/// ("would block" — wait for the next `perry_ffi::raw_net_notify`). -extern "C" fn perry_net_raw_poll_read(socket_id: i64, out: *mut u8, max: usize) -> isize { - if out.is_null() || max == 0 { - return -1; - } - let raw = match raw_state_for(socket_id) { - Some(r) => r, - None => return -1, - }; - let mut st = match raw.lock() { - Ok(s) => s, - Err(_) => return -1, - }; - if st.buf.is_empty() { - return if st.closed { 0 } else { -1 }; - } - let n = st.buf.len().min(max); - let out_slice = unsafe { std::slice::from_raw_parts_mut(out, n) }; - for slot in out_slice.iter_mut() { - // `pop_front` can't fail: we just bounded `n` by `buf.len()`. - *slot = st.buf.pop_front().unwrap_or(0); - } - n as isize -} - -/// Return an attached socket to ordinary JS event delivery while keeping the -/// underlying connection alive. HTTP uses this after parsing a `101` response -/// so the request's `upgrade` listener receives the same live `net.Socket` -/// supplied by `createConnection`. -extern "C" fn perry_net_raw_detach(socket_id: i64) { - if let Ok(mut sockets) = statics::sockets().lock() { - if let Some(socket) = sockets.get_mut(&socket_id) { - socket.raw = None; - } - } -} - -/// Tear down socket `socket_id` (equivalent to `socket.destroy()`) and -/// unregister it. `perry-ext-http` calls this once the HTTP exchange is done -/// draining. Because raw mode suppresses the JS `Close` event (the path that -/// normally removes the socket from the registry in `js_net_process_pending`), -/// we must remove it here — otherwise the lingering entry keeps -/// `js_net_has_pending` / `js_ext_net_has_active_handles` returning 1 and the -/// program never exits. -extern "C" fn perry_net_raw_close(socket_id: i64) { - // Signal the read/write task to stop (best-effort; dropping the - // SocketState below also closes the cmd channel, ending the task). - if let Ok(mut g) = statics::sockets().lock() { - if let Some(s) = g.get_mut(&socket_id) { - let _ = s.command(socket_id, SocketCommand::Destroy); - } - } - let _ = statics::sockets().lock().map(|mut g| g.remove(&socket_id)); - let _ = statics::listeners() - .lock() - .map(|mut g| g.remove(&socket_id)); - let _ = statics::once_flags() - .lock() - .map(|mut g| g.remove(&socket_id)); -} - -/// Publish the raw-net vtable into perry-ffi's slot so perry-ext-http can drive -/// an HTTP exchange over a socket produced by `agent.createConnection`. Called -/// once from the net GC-scanner init (first net FFI entry, i.e. any socket -/// creation), so the vtable is in place before http could reference a socket. -pub(crate) fn register() { - perry_ffi::register_raw_net(perry_ffi::RawNetVtable { - attach: perry_net_raw_attach, - write: perry_net_raw_write, - poll_read: perry_net_raw_poll_read, - detach: perry_net_raw_detach, - close: perry_net_raw_close, - }); -} diff --git a/crates/perry-ext-net/src/server_state.rs b/crates/perry-ext-net/src/server_state.rs deleted file mode 100644 index ae4ceeed70..0000000000 --- a/crates/perry-ext-net/src/server_state.rs +++ /dev/null @@ -1,530 +0,0 @@ -use bytes::Bytes; -use perry_ffi::{ - alloc_string, build_object_shape, js_object_alloc_with_shape, js_object_set_field, JsValue, - ObjectHeader, -}; -use std::collections::{HashMap, VecDeque}; -use std::net::SocketAddr; -use std::sync::{Mutex, OnceLock}; - -use crate::{statics, PendingNetEvent}; - -const TAG_UNDEFINED: u64 = 0x7FFC_0000_0000_0001; - -#[derive(Default)] -struct ConnectionOrderState { - // An accepted loopback socket must not become visible to - // `server.getConnections()` until the matching client-side `connect` - // callback has returned to the event loop. The accept and connect tasks - // run concurrently, so either side can reach the main-thread queue first. - deferred_connections: HashMap>, - // A connect callback can run before accept queues ServerConnection. Keep - // a per-server credit so the later accept still crosses a pump boundary. - completed_local_connects: HashMap, - // The accepted socket's read task can receive bytes during that boundary. - // Preserve those chunks until the server `connection` callback has had a - // chance to install its socket listeners. - pending_socket_data: HashMap>, -} - -fn connection_order_state() -> &'static Mutex { - static STATE: OnceLock> = OnceLock::new(); - STATE.get_or_init(|| Mutex::new(ConnectionOrderState::default())) -} - -fn schedule_server_connection(server_id: i64, socket_id: i64) { - // `js_run_stdlib_pump` can reach ext-net twice in one invocation (the - // stdlib feature arm and the auxiliary-pump registry). Crossing a timer - // boundary lets the compiled await poll observe the client callback before - // the server connection becomes visible. The timer is a 1 ms deadline on - // the loop, which delivers the deferred `ServerConnection(.., true)`. - crate::turnloop_io::arm_deadline( - 1, - crate::turnloop_io::Deadline::ServerConnection { - server_id, - socket_id, - }, - ); -} - -fn take_completed_local_connect(state: &mut ConnectionOrderState, server_id: i64) -> bool { - let remove = match state.completed_local_connects.get_mut(&server_id) { - Some(count) if *count > 0 => { - *count -= 1; - *count == 0 - } - _ => return false, - }; - if remove { - state.completed_local_connects.remove(&server_id); - } - true -} - -fn pop_deferred_connection(state: &mut ConnectionOrderState, server_id: i64) -> Option { - let (socket_id, remove) = { - let queue = state.deferred_connections.get_mut(&server_id)?; - let socket_id = queue.pop_front(); - (socket_id, queue.is_empty()) - }; - if remove { - state.deferred_connections.remove(&server_id); - } - socket_id -} - -pub(crate) fn queue_server_connection(server_id: i64, socket_id: i64) -> bool { - let mut state = connection_order_state().lock().unwrap(); - if !take_completed_local_connect(&mut state, server_id) { - return true; - } - drop(state); - schedule_server_connection(server_id, socket_id); - false -} - -pub(crate) fn defer_server_connection(server_id: i64, socket_id: i64) -> bool { - let pending_local_connect = statics::servers() - .lock() - .unwrap() - .get(&server_id) - .is_some_and(|server| server.pending_local_connect_events > 0); - let mut state = connection_order_state().lock().unwrap(); - if take_completed_local_connect(&mut state, server_id) { - drop(state); - schedule_server_connection(server_id, socket_id); - return true; - } - if !pending_local_connect { - return false; - } - state - .deferred_connections - .entry(server_id) - .or_default() - .push_back(socket_id); - true -} - -pub(crate) fn buffer_pending_server_data(socket_id: i64, bytes: Bytes) { - let mut state = connection_order_state().lock().unwrap(); - // Reads can win the race with JavaScript listener registration on both - // accepted and outbound sockets. Keep every otherwise-undeliverable - // chunk here; registration releases it immediately, while close() drops - // abandoned data through `discard_pending_server_data`. - state - .pending_socket_data - .entry(socket_id) - .or_default() - .push_back(bytes); -} - -fn pop_pending_socket_data(state: &mut ConnectionOrderState, socket_id: i64) -> Option { - let (chunk, empty) = { - let queue = state.pending_socket_data.get_mut(&socket_id)?; - let chunk = queue.pop_front(); - (chunk, queue.is_empty()) - }; - if empty { - state.pending_socket_data.remove(&socket_id); - } - chunk -} - -/// Consume one buffered socket chunk for Node's paused-mode `Socket.read()`. -pub(crate) fn take_pending_socket_data(socket_id: i64) -> Option { - pop_pending_socket_data(&mut connection_order_state().lock().unwrap(), socket_id) -} - -pub(crate) fn release_pending_server_data(socket_id: i64) { - let chunks = connection_order_state() - .lock() - .unwrap() - .pending_socket_data - .remove(&socket_id); - let Some(chunks) = chunks else { - return; - }; - let mut events = statics::pending_events().lock().unwrap(); - events.extend( - chunks - .into_iter() - .map(|bytes| PendingNetEvent::Data(socket_id, bytes)), - ); - drop(events); - perry_ffi::notify_main_thread(); -} - -pub(crate) fn discard_pending_server_data(socket_id: i64) { - connection_order_state() - .lock() - .unwrap() - .pending_socket_data - .remove(&socket_id); -} - -#[derive(Debug)] -pub(crate) struct DropInfo { - pub(crate) local: Option, - pub(crate) remote: Option, -} - -fn undefined() -> f64 { - f64::from_bits(TAG_UNDEFINED) -} - -fn js_bool(value: bool) -> f64 { - f64::from_bits(JsValue::from_bool(value).bits()) -} - -fn addr_string(addr: Option) -> JsValue { - match addr { - Some(addr) => JsValue::from_string_ptr(alloc_string(&addr.ip().to_string()).as_raw()), - None => JsValue::UNDEFINED, - } -} - -fn family_string(addr: Option) -> JsValue { - match addr { - Some(addr) => JsValue::from_string_ptr( - alloc_string(if addr.is_ipv6() { "IPv6" } else { "IPv4" }).as_raw(), - ), - None => JsValue::UNDEFINED, - } -} - -fn port_number(addr: Option) -> JsValue { - match addr { - Some(addr) => JsValue::from_number(addr.port() as f64), - None => JsValue::UNDEFINED, - } -} - -pub(crate) fn build_drop_object(info: &DropInfo) -> f64 { - let keys = [ - "localAddress", - "localPort", - "localFamily", - "remoteAddress", - "remotePort", - "remoteFamily", - ]; - let (packed, shape_id) = build_object_shape(&keys); - let obj: *mut ObjectHeader = - unsafe { js_object_alloc_with_shape(shape_id, 6, packed.as_ptr(), packed.len() as u32) }; - if obj.is_null() { - return undefined(); - } - let values = [ - addr_string(info.local), - port_number(info.local), - family_string(info.local), - addr_string(info.remote), - port_number(info.remote), - family_string(info.remote), - ]; - for (index, value) in values.into_iter().enumerate() { - unsafe { js_object_set_field(obj, index as u32, value) }; - } - f64::from_bits(JsValue::from_object_ptr(obj as *mut u8).bits()) -} - -/// Admission check for a connection turnloop accepted. turnloop hands the -/// endpoints back directly, so this takes them rather than a stream. -pub(crate) fn should_drop_accepted( - server_id: i64, - local: Option, - peer: Option, -) -> Option { - reserve_connection(server_id, local, peer) -} - -fn reserve_connection( - server_id: i64, - local: Option, - remote: Option, -) -> Option { - let mut servers = statics::servers().lock().ok()?; - let server = servers.get_mut(&server_id)?; - if server - .max_connections - .is_some_and(|max| server.active_connections + server.pending_connections >= max) - && server.drop_max_connection.unwrap_or(false) - { - return Some(DropInfo { local, remote }); - } - server.pending_connections += 1; - None -} - -pub(crate) fn cancel_pending_connection(server_id: i64) { - if let Some(server) = statics::servers().lock().unwrap().get_mut(&server_id) { - server.pending_connections = server.pending_connections.saturating_sub(1); - } -} - -pub(crate) fn begin_local_connect(host: &str, port: u16) -> Option<(i64, bool)> { - if !matches!(host, "localhost" | "127.0.0.1" | "::1" | "0.0.0.0") { - return None; - } - let mut servers = statics::servers().lock().ok()?; - let (server_id, server) = servers - .iter_mut() - .find(|(_, server)| server.listening && server.bound_port == port)?; - let completed = connection_order_state() - .lock() - .unwrap() - .completed_local_connects - .get(server_id) - .copied() - .unwrap_or(0); - let expects_drop = server.drop_max_connection.unwrap_or(false) - && server.max_connections.is_some_and(|max| { - server.active_connections + server.pending_connections + completed >= max - }); - server.pending_local_connect_events += 1; - Some((*server_id, expects_drop)) -} - -pub(crate) fn begin_local_path_connect(path: &str) -> Option<(i64, bool)> { - let mut servers = statics::servers().lock().ok()?; - let (server_id, server) = servers - .iter_mut() - .find(|(_, server)| server.listening && server.bound_path.as_deref() == Some(path))?; - let completed = connection_order_state() - .lock() - .unwrap() - .completed_local_connects - .get(server_id) - .copied() - .unwrap_or(0); - let expects_drop = server.drop_max_connection.unwrap_or(false) - && server.max_connections.is_some_and(|max| { - server.active_connections + server.pending_connections + completed >= max - }); - server.pending_local_connect_events += 1; - Some((*server_id, expects_drop)) -} - -fn complete_local_connect(local_server: Option<(i64, bool)>, connected: bool) { - let Some((server_id, expects_drop)) = local_server else { - return; - }; - if let Some(server) = statics::servers().lock().unwrap().get_mut(&server_id) { - server.pending_local_connect_events = server.pending_local_connect_events.saturating_sub(1); - } - if !connected || expects_drop { - return; - } - let mut state = connection_order_state().lock().unwrap(); - let socket_id = pop_deferred_connection(&mut state, server_id); - if let Some(socket_id) = socket_id { - drop(state); - schedule_server_connection(server_id, socket_id); - } else { - *state.completed_local_connects.entry(server_id).or_default() += 1; - } -} - -pub(crate) fn finish_local_connect(local_server: Option<(i64, bool)>) { - complete_local_connect(local_server, true); -} - -pub(crate) fn cancel_local_connect(local_server: Option<(i64, bool)>) { - complete_local_connect(local_server, false); -} - -pub(crate) fn activate_connection(server_id: i64, socket_id: i64) { - let mut sockets = statics::sockets().lock().unwrap(); - let Some(socket) = sockets.get_mut(&socket_id) else { - return; - }; - if socket.server_id != Some(server_id) || socket.server_connection_active { - return; - } - socket.server_connection_active = true; - if let Some(server) = statics::servers().lock().unwrap().get_mut(&server_id) { - server.pending_connections = server.pending_connections.saturating_sub(1); - server.active_connections += 1; - } -} - -/// Tell the socket task that JavaScript's accepted-socket callback has -/// returned. Any writes/end queued by that callback are already ahead of this -/// marker in the channel, so peer-EOF handling can safely auto-close after it. -pub(crate) fn release_connection_callback(socket_id: i64) { - if let Some(socket) = statics::sockets().lock().unwrap().get_mut(&socket_id) { - let _ = socket.command(socket_id, crate::SocketCommand::ServerConnectionReady); - } -} - -pub(crate) fn mark_socket_closed(socket_id: i64) { - let mut sockets = statics::sockets().lock().unwrap(); - let Some(socket) = sockets.get_mut(&socket_id) else { - return; - }; - socket.is_open = false; - socket.bytes_queued = 0; - let Some(server_id) = socket.server_id.take() else { - return; - }; - if let Some(server) = statics::servers().lock().unwrap().get_mut(&server_id) { - if socket.server_connection_active { - server.active_connections = server.active_connections.saturating_sub(1); - } else { - server.pending_connections = server.pending_connections.saturating_sub(1); - } - } -} - -pub(crate) fn remove_server(server_id: i64) { - let mut state = connection_order_state().lock().unwrap(); - state.deferred_connections.remove(&server_id); - state.completed_local_connects.remove(&server_id); -} - -/// Returns true while queued net events, connecting/open sockets, or listening -/// servers need the runtime event loop to stay alive. Constructed but -/// unlistened sockets/servers match Node by not keeping the process alive. -pub(crate) fn has_active_handles() -> bool { - if !statics::pending_events().lock().unwrap().is_empty() { - return true; - } - // #11227: a destroyed socket still owes its `'close'` (Node emits it on - // nextTick, whatever the socket's ref state). - if crate::turnloop_io::close_in_flight() { - return true; - } - // #11340: only this agent's sockets and servers. A worker's socket is - // served by the worker's pump; counting it here kept the primary alive - // forever once the worker had exited with that socket's events undrained. - let agent = perry_ffi::agent_post::current_agent(); - if statics::sockets().lock().unwrap().values().any(|socket| { - socket.owner_agent == agent - && socket.refed - && !socket.destroyed - && (socket.is_open || !socket.awaiting_connect) - }) { - return true; - } - statics::servers() - .lock() - .unwrap() - .iter() - .any(|(id, server)| { - server.owner_agent == agent - && (server.listening || server.listen_armed) - && crate::bun_tcp::server_keeps_alive(*id) - }) -} - -#[no_mangle] -pub extern "C" fn js_net_server_get_listening(handle: i64) -> f64 { - js_bool(crate::js_net_server_listening(handle) != 0) -} - -#[no_mangle] -pub extern "C" fn js_net_server_get_connections(handle: i64) -> f64 { - statics::servers() - .lock() - .ok() - .and_then(|servers| servers.get(&handle).map(|s| s.active_connections as f64)) - .unwrap_or(0.0) -} - -#[no_mangle] -pub extern "C" fn js_net_server_get_max_connections(handle: i64) -> f64 { - statics::servers() - .lock() - .ok() - .and_then(|servers| servers.get(&handle).and_then(|s| s.max_connections)) - .map(|n| n as f64) - .unwrap_or_else(undefined) -} - -#[no_mangle] -pub extern "C" fn js_net_server_set_max_connections(handle: i64, value: f64) -> f64 { - if let Ok(mut servers) = statics::servers().lock() { - if let Some(server) = servers.get_mut(&handle) { - server.max_connections = if value.is_finite() && value >= 0.0 { - Some(value as usize) - } else { - None - }; - } - } - value -} - -#[no_mangle] -pub extern "C" fn js_net_server_get_drop_max_connection(handle: i64) -> f64 { - statics::servers() - .lock() - .ok() - .and_then(|servers| servers.get(&handle).and_then(|s| s.drop_max_connection)) - .map(js_bool) - .unwrap_or_else(undefined) -} - -#[no_mangle] -pub extern "C" fn js_net_server_set_drop_max_connection(handle: i64, value: f64) -> f64 { - let bool_value = JsValue::from_bits(value.to_bits()).to_bool(); - if let Ok(mut servers) = statics::servers().lock() { - if let Some(server) = servers.get_mut(&handle) { - server.drop_max_connection = Some(bool_value); - } - } - value -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn deferred_connections_are_fifo_and_remove_empty_server_queues() { - let mut state = ConnectionOrderState::default(); - state - .deferred_connections - .insert(7, VecDeque::from([101, 102])); - - assert_eq!(pop_deferred_connection(&mut state, 7), Some(101)); - assert!(state.deferred_connections.contains_key(&7)); - assert_eq!(pop_deferred_connection(&mut state, 7), Some(102)); - assert!(!state.deferred_connections.contains_key(&7)); - assert_eq!(pop_deferred_connection(&mut state, 7), None); - } - - #[test] - fn completed_connect_credits_are_consumed_and_removed() { - let mut state = ConnectionOrderState::default(); - state.completed_local_connects.insert(7, 2); - - assert!(take_completed_local_connect(&mut state, 7)); - assert_eq!(state.completed_local_connects.get(&7), Some(&1)); - assert!(take_completed_local_connect(&mut state, 7)); - assert!(!state.completed_local_connects.contains_key(&7)); - assert!(!take_completed_local_connect(&mut state, 7)); - } - - #[test] - fn pending_socket_data_is_fifo_and_removes_empty_queue() { - let mut state = ConnectionOrderState::default(); - state.pending_socket_data.insert( - 7, - VecDeque::from([Bytes::from_static(b"first"), Bytes::from_static(b"second")]), - ); - - assert_eq!( - pop_pending_socket_data(&mut state, 7).as_deref(), - Some(&b"first"[..]) - ); - assert!(state.pending_socket_data.contains_key(&7)); - assert_eq!( - pop_pending_socket_data(&mut state, 7).as_deref(), - Some(&b"second"[..]) - ); - assert!(!state.pending_socket_data.contains_key(&7)); - assert!(pop_pending_socket_data(&mut state, 7).is_none()); - } -} diff --git a/crates/perry-ext-net/src/socket_emit.rs b/crates/perry-ext-net/src/socket_emit.rs deleted file mode 100644 index 09a3a5ef8b..0000000000 --- a/crates/perry-ext-net/src/socket_emit.rs +++ /dev/null @@ -1,128 +0,0 @@ -//! Synchronous `net.Socket` EventEmitter dispatch used by the HTTP Agent. - -use super::*; - -extern "C" { - fn js_closure_call_array( - closure: i64, - this: perry_ffi::JsThis, - args: *const f64, - args_len: i64, - ) -> f64; -} - -type HttpAgentSocketEventHook = extern "C" fn(i64, *const u8, usize); - -fn http_agent_socket_event_hook() -> &'static Mutex> { - static HOOK: std::sync::OnceLock>> = - std::sync::OnceLock::new(); - HOOK.get_or_init(|| Mutex::new(None)) -} - -/// Mark an alloc-only net.Socket handle as the public facade for an HTTP -/// Agent pool slot. `active != 0` selects the in-use listener shape; zero is -/// the free-pool shape. -#[no_mangle] -pub extern "C" fn js_ext_net_set_http_agent_phase(handle: i64, active: i32) { - if is_net_socket_handle(handle) { - statics::http_agent_phases() - .lock() - .unwrap() - .insert(handle, active != 0); - } -} - -/// Register the HTTP-side lifecycle hook used when user code manually emits -/// an error on an idle Agent socket. -#[no_mangle] -pub extern "C" fn js_ext_net_register_http_agent_socket_event_hook(hook: HttpAgentSocketEventHook) { - *http_agent_socket_event_hook().lock().unwrap() = Some(hook); -} - -/// Synchronous EventEmitter `socket.emit(event, ...args)` surface. Network -/// generated events still travel through the pending-event pump; this entry -/// point covers explicit user emission and preserves `once()` removal. -#[no_mangle] -pub unsafe extern "C" fn js_ext_net_socket_emit( - handle: i64, - event_ptr: i64, - args_ptr: *const f64, - args_len: usize, -) -> f64 { - let Some(event) = lifecycle::event_name_from_ptr(event_ptr) else { - return f64::from_bits(0x7FFC_0000_0000_0003); - }; - let args = if args_ptr.is_null() || args_len == 0 { - &[] - } else { - std::slice::from_raw_parts(args_ptr, args_len) - }; - let arg_bits = args.iter().map(|arg| arg.to_bits()).collect::>(); - let mut frame = dispatch_custody::DispatchFrame::park(listeners_for(handle, &event)); - frame.set_payloads(&arg_bits); - // #11227: `emit()` binds `this` to the socket, like every Node emitter. - let this = crate::socket_events::ListenerThis::of(handle); - for index in 0..frame.len() { - let callback = frame.cb(index); - if callback == 0 { - continue; - } - let closure = JsClosure::from_raw(callback as *const RawClosureHeader); - match arg_bits.len() { - 0 => { - let _ = closure.call0(this.receiver()); - } - 1 => { - let _ = closure.call1(this.receiver(), f64::from_bits(frame.payload_bits())); - } - 2 => { - let _ = closure.call2( - this.receiver(), - f64::from_bits(frame.payload_bits_at(0)), - f64::from_bits(frame.payload_bits_at(1)), - ); - } - _ => { - let args = (0..arg_bits.len()) - .map(|index| f64::from_bits(frame.payload_bits_at(index))) - .collect::>(); - let _ = js_closure_call_array( - callback, - this.receiver(), - args.as_ptr(), - args.len() as i64, - ); - } - } - } - let emitted = frame.len() != 0; - drop(frame); - lifecycle::drain_once_listeners(handle, &event); - if statics::http_agent_phases() - .lock() - .unwrap() - .contains_key(&handle) - { - if event == "error" { - js_ext_net_destroy_socket(handle); - } - let hook = *http_agent_socket_event_hook().lock().unwrap(); - if let Some(hook) = hook { - hook(handle, event.as_ptr(), event.len()); - } - } - f64::from_bits(if emitted { - 0x7FFC_0000_0000_0004 - } else { - 0x7FFC_0000_0000_0003 - }) -} - -/// Queue a Node AbortError on a socket. Used by Agent.createConnection's -/// AbortSignal integration so an already-aborted signal still emits after the -/// caller has had a chance to attach `once(socket, 'error')`. -#[no_mangle] -pub extern "C" fn js_ext_net_socket_emit_abort_error(handle: i64) { - js_ext_net_destroy_socket(handle); - push_event(PendingNetEvent::AbortError(handle)); -} diff --git a/crates/perry-ext-net/src/socket_events.rs b/crates/perry-ext-net/src/socket_events.rs deleted file mode 100644 index 492a534329..0000000000 --- a/crates/perry-ext-net/src/socket_events.rs +++ /dev/null @@ -1,577 +0,0 @@ -//! Socket event emission helpers, split from `lib.rs` for the 2000-line -//! file cap (#9607 added the borrowed-fd plumbing above). - -use super::*; - -fn socket_receiver(handle: i64) -> f64 { - f64::from_bits(0x7FFD_0000_0000_0000 | (handle as u64 & 0x0000_FFFF_FFFF_FFFF)) -} - -/// Node calls every EventEmitter listener with `this` bound to the emitter -/// (#11227). Only `'connect'`/`'drain'`/`'readable'` did; every other socket -/// and server event ran its `function` listeners with `this === undefined`, -/// so undici's `onHttpSocketClose` (`this[kParser]`) threw on `close()`. -/// -/// The receiver every listener of one dispatch is called with: the -/// socket/server handle. A handle id is not a heap address, so it needs no -/// root across the listeners. -#[derive(Clone, Copy)] -pub(crate) struct ListenerThis { - receiver: perry_ffi::JsThis, -} - -impl ListenerThis { - pub(crate) fn of(handle: i64) -> Self { - Self { - receiver: perry_ffi::JsThis::from_f64(socket_receiver(handle)), - } - } - - pub(crate) fn receiver(self) -> perry_ffi::JsThis { - self.receiver - } -} - -unsafe fn emit_socket_no_arg(handle: i64, event: &str) { - let frame = dispatch_custody::DispatchFrame::park(listeners_for(handle, event)); - let this = ListenerThis::of(handle); - for index in 0..frame.len() { - let callback = frame.cb(index); - if callback != 0 { - let _ = JsClosure::from_raw(callback as *const RawClosureHeader).call0(this.receiver()); - } - } - drop(frame); - lifecycle::drain_once_listeners(handle, event); -} - -unsafe fn emit_tls_secure_connect(handle: i64) { - extern "C" { - fn js_tls_client_check_identity_from_metadata(handle: i64) -> f64; - } - let identity_error = js_tls_client_check_identity_from_metadata(handle); - if !JsValue::from_bits(identity_error.to_bits()).is_undefined() { - let mut frame = dispatch_custody::DispatchFrame::park(listeners_for(handle, "error")); - frame.set_payload(identity_error.to_bits()); - let this = ListenerThis::of(handle); - for index in 0..frame.len() { - let callback = frame.cb(index); - if callback != 0 { - let _ = JsClosure::from_raw(callback as *const RawClosureHeader) - .call1(this.receiver(), f64::from_bits(frame.payload_bits())); - } - } - drop(frame); - lifecycle::drain_once_listeners(handle, "error"); - if let Some(socket) = statics::sockets().lock().unwrap().get_mut(&handle) { - let _ = socket.command(handle, SocketCommand::Destroy); - } - return; - } - emit_socket_no_arg(handle, "secureConnect"); -} - -/// Drain ext-net's own pending-event queue. -/// -/// This carries a DISTINCT `#[no_mangle]` symbol (`js_ext_net_drain_pending`), -/// deliberately NOT the `js_net_process_pending` name that the bundled stdlib -/// net ALSO exports. In a workspace/auto-optimize build both crates are -/// linked, so `js_net_process_pending` is a duplicate symbol; the link binds -/// every reference to whichever twin wins (stdlib's). The aux pump -/// (`process_pending_aux`) and the extern wrapper above therefore call THIS -/// uniquely-named entry point instead — a symbol with no twin and nothing to -/// fold against — so the adopted raw-`'upgrade'` socket's `Close` event in -/// ext-net's own queue is actually drained rather than left to pin the event -/// loop forever. Without this the loop hung, and the behavior flipped with -/// unrelated code-size changes (link-order roulette). (#5010) -/// -/// # Safety -/// Fires user JS closures (listeners); callers must hold a valid runtime. -#[no_mangle] -pub unsafe extern "C" fn js_ext_net_drain_pending() -> i32 { - thread_local! { - static SCRATCH: std::cell::RefCell> = - const { std::cell::RefCell::new(Vec::new()) }; - } - let mut events = SCRATCH.with(|s| std::mem::take(&mut *s.borrow_mut())); - events.clear(); - { - let mut g = statics::pending_events().lock().unwrap(); - events.append(&mut *g); - } - let count = events.len() as i32; - - for ev in events.drain(..) { - prepare_event_provider(&ev); - let provider_id = event_provider_id(&ev); - let destroy_ids: Vec = match &ev { - PendingNetEvent::Connect(id, _) => statics::sockets() - .lock() - .ok() - .and_then(|sockets| sockets.get(id).map(|socket| vec![socket.connect_async_id])) - .unwrap_or_default(), - PendingNetEvent::ShutdownComplete(id, _, _) => statics::sockets() - .lock() - .ok() - .and_then(|sockets| sockets.get(id).map(|socket| vec![socket.shutdown_async_id])) - .unwrap_or_default(), - PendingNetEvent::Close(id) => statics::sockets() - .lock() - .ok() - .and_then(|sockets| { - sockets.get(id).map(|socket| { - vec![ - socket.connect_async_id, - socket.shutdown_async_id, - socket.tcp_async_id, - ] - }) - }) - .unwrap_or_default(), - PendingNetEvent::ServerClose(id) => statics::servers() - .lock() - .ok() - .and_then(|servers| servers.get(id).map(|server| vec![server.async_id])) - .unwrap_or_default(), - _ => Vec::new(), - }; - let provider_scope = ProviderScope::enter(provider_id); - match ev { - PendingNetEvent::Connect(id, local_server) => { - server_state::finish_local_connect(local_server); - // #8259: park the snapshot so callback N stays rooted (and is - // rewritten on evacuation) while callback N-1 runs user JS. - emit_socket_no_arg(id, "connect"); - // Node's `afterConnect` emits `'ready'` right after - // `'connect'` (#11227: never emitted before). - emit_socket_no_arg(id, "ready"); - // TLS sockets additionally fire 'secureConnect' once the - // handshake completes — the direct-TLS connect path only - // signals Connect after the handshake, so this is the right - // tick. Plain sockets simply have no listeners here. #4971. - extern "C" { - fn js_tls_client_is_connected(handle: i64) -> i32; - } - if js_tls_client_is_connected(id) != 0 { - emit_tls_secure_connect(id); - } - } - PendingNetEvent::SecureConnect(id) => emit_tls_secure_connect(id), - PendingNetEvent::Data(id, bytes) => { - let cbs = listeners_for(id, "data"); - if cbs.is_empty() { - server_state::buffer_pending_server_data(id, bytes); - emit_socket_no_arg(id, "readable"); - continue; - } - // #8259: park BEFORE the payload allocation below — it can - // collect, and the evacuating arms then move the closures a - // bare snapshot would still point at. The payload is parked - // too: callback 1's JS can move it before callback 2 runs. - let mut frame = dispatch_custody::DispatchFrame::park(cbs); - // #4973: `socket.setEncoding(enc)` switches 'data' delivery - // from Buffers to decoded strings (Node readable-stream - // semantics). 'hex'/'base64' render their text forms; the - // remaining text encodings decode as UTF-8 (lossy). - let encoding = statics::encodings().lock().unwrap().get(&id).cloned(); - let payload_f64 = if let Some(enc) = encoding { - let s = match enc.as_str() { - "hex" => bytes.iter().map(|b| format!("{b:02x}")).collect::(), - "base64" => adopt::base64_encode(&bytes), - _ => String::from_utf8_lossy(&bytes).into_owned(), - }; - let hdr = alloc_string(&s); - f64::from_bits( - 0x7FFF_0000_0000_0000 | (hdr.as_raw() as u64 & 0x0000_FFFF_FFFF_FFFF), - ) - } else { - let buf = alloc_buffer(&bytes); - if buf.is_null() { - continue; - } - // POINTER_TAG over the buffer pointer. - f64::from_bits(0x7FFD_0000_0000_0000 | (buf as u64 & 0x0000_FFFF_FFFF_FFFF)) - }; - frame.set_payload(payload_f64.to_bits()); - let this = ListenerThis::of(id); - for i in 0..frame.len() { - let cb = frame.cb(i); - if cb != 0 { - let _ = JsClosure::from_raw(cb as *const RawClosureHeader) - .call1(this.receiver(), f64::from_bits(frame.payload_bits())); - } - } - drop(frame); - lifecycle::drain_once_listeners(id, "data"); - } - PendingNetEvent::Error(id, msg) => { - if let Some(socket) = statics::sockets().lock().unwrap().get_mut(&id) { - if socket.unconnected_write_failed { - socket.destroyed = true; - socket.is_open = false; - socket.connecting = false; - socket.bytes_queued = 0; - socket.need_drain = false; - } - } - let cbs = listeners_for(id, "error"); - if cbs.is_empty() { - continue; - } - // #8259: park before the allocating build_error_object. - let mut frame = dispatch_custody::DispatchFrame::park(cbs); - // 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); - frame.set_payload(err_f64.to_bits()); - let this = ListenerThis::of(id); - for i in 0..frame.len() { - let cb = frame.cb(i); - if cb != 0 { - let _ = JsClosure::from_raw(cb as *const RawClosureHeader) - .call1(this.receiver(), f64::from_bits(frame.payload_bits())); - } - } - drop(frame); - lifecycle::drain_once_listeners(id, "error"); - } - PendingNetEvent::AbortError(id) => { - let cbs = listeners_for(id, "error"); - if cbs.is_empty() { - continue; - } - let mut frame = dispatch_custody::DispatchFrame::park(cbs); - extern "C" { - fn js_abort_error_value() -> f64; - } - frame.set_payload(js_abort_error_value().to_bits()); - let this = ListenerThis::of(id); - for i in 0..frame.len() { - let cb = frame.cb(i); - if cb != 0 { - let _ = JsClosure::from_raw(cb as *const RawClosureHeader) - .call1(this.receiver(), f64::from_bits(frame.payload_bits())); - } - } - drop(frame); - lifecycle::drain_once_listeners(id, "error"); - } - PendingNetEvent::End(id) => { - // #10465 — `readableEnded` (and `readable`) flip as part of - // emitting `'end'`, before any listener runs, matching Node. - if let Some(socket) = statics::sockets().lock().unwrap().get_mut(&id) { - socket.readable_ended = true; - } - // Issue #1852 — readable side ended (peer FIN). Fire the - // `'end'` listeners; the trailing `Close` event (pushed - // once the socket is torn down) does the actual - // listener-map / socket-map teardown, so don't remove - // anything here. - let frame = dispatch_custody::DispatchFrame::park(listeners_for(id, "end")); - let this = ListenerThis::of(id); - for i in 0..frame.len() { - let cb = frame.cb(i); - if cb != 0 { - let _ = JsClosure::from_raw(cb as *const RawClosureHeader) - .call0(this.receiver()); - } - } - drop(frame); - lifecycle::drain_once_listeners(id, "end"); - // P1: a turnloop socket has no task to run the post-EOF - // shutdown, so it happens here — AFTER the `'end'` listeners - // ran, which is what gives a synchronous `socket.write()` - // inside an `'end'` handler the same chance the tokio task's - // post-EOF command drain gave it. Node's default - // (`allowHalfOpen: false`) ends the writable side once the - // readable side has ended, then closes. - crate::turnloop_io::finish_read_end(id); - } - // #11111 — the queue emptied after a `write()` returned false. - PendingNetEvent::Drain(id) => emit_socket_no_arg(id, "drain"), - PendingNetEvent::WriteComplete(_, completion, error) - | PendingNetEvent::ShutdownComplete(_, completion, error) => { - lifecycle::dispatch_socket_completion(completion, error); - } - PendingNetEvent::Close(id) => { - lifecycle::drop_socket_completions(id); - extern "C" { - fn js_tls_client_record_closed(handle: i64); - } - js_tls_client_record_closed(id); - // #10465 — flip the terminal state fields synchronously with - // firing `'close'`, matching Node's own timing (its - // `'close'` listeners see `destroyed: true`; earlier events - // on the SAME socket do not). This is the common teardown - // point for every path that reaches `Close`: peer EOF + - // local end, explicit `.destroy()`, connect failure, TLS - // handshake failure. - if let Some(socket) = statics::sockets().lock().unwrap().get_mut(&id) { - socket.destroyed = true; - socket.is_open = false; - socket.connecting = false; - } - let had_error = f64::from_bits(JsValue::from_bool(false).bits()); - let frame = dispatch_custody::DispatchFrame::park(listeners_for(id, "close")); - let this = ListenerThis::of(id); - for i in 0..frame.len() { - let cb = frame.cb(i); - if cb != 0 { - let _ = JsClosure::from_raw(cb as *const RawClosureHeader) - .call1(this.receiver(), had_error); - } - } - drop(frame); - statics::listeners().lock().unwrap().remove(&id); - statics::sockets().lock().unwrap().remove(&id); - statics::once_flags().lock().unwrap().remove(&id); - statics::encodings().lock().unwrap().remove(&id); - statics::http_agent_phases().lock().unwrap().remove(&id); - statics::max_listeners().lock().unwrap().remove(&id); - server_state::discard_pending_server_data(id); - // #10444 — the listener-map entry above just went away, so - // any pipe route's tracked callback pointers are dangling; - // drop the tracking table entry too (nothing left to - // uninstall from). - crate::pipe::drop_routes(id); - } - // Issue #1123 followup — server-side events. The - // accept loop pushes `ServerConnection`/`ServerListening`/ - // `ServerError`/`ServerClose`; the main-thread pump - // converts them into the appropriate JS dispatch. - PendingNetEvent::ServerConnection(server_id, socket_id, released) => { - if !released && server_state::defer_server_connection(server_id, socket_id) { - continue; - } - server_state::activate_connection(server_id, socket_id); - let cbs = listeners_for(server_id, "connection"); - if cbs.is_empty() { - // Drain any `server.once('connection', cb)` flagged - // here too — listeners_for returned empty but the - // once-set may still be holding stale entries. - lifecycle::drain_once_listeners(server_id, "connection"); - server_state::release_pending_server_data(socket_id); - server_state::release_connection_callback(socket_id); - continue; - } - // Sockets returned by the codegen's `net.connect` - // path (`js_net_socket_connect` → NR_PTR ret kind in - // lower_call.rs) are NaN-boxed with POINTER_TAG over - // the raw socket id. Match that here so user code - // sees the same value shape regardless of which side - // produced the socket: `sock.on(...)` then dispatches - // through the `("net", true, "on", Some("Socket"))` - // NATIVE_MODULE_TABLE row (which `unbox_to_i64`s the - // receiver back to the raw id). Bare-number sockets - // skipped the dispatch and hit the generic property - // path → `(number).on is not a function`. - let sock_f64 = f64::from_bits( - 0x7FFD_0000_0000_0000 | (socket_id as u64 & 0x0000_FFFF_FFFF_FFFF), - ); - // #8259: sock_f64 is a handle id (not a heap address), so - // only the callbacks need custody. - let frame = dispatch_custody::DispatchFrame::park(cbs); - let this = ListenerThis::of(server_id); - for i in 0..frame.len() { - let cb = frame.cb(i); - if cb != 0 { - let _ = JsClosure::from_raw(cb as *const RawClosureHeader) - .call1(this.receiver(), sock_f64); - } - } - drop(frame); - lifecycle::drain_once_listeners(server_id, "connection"); - server_state::release_pending_server_data(socket_id); - server_state::release_connection_callback(socket_id); - } - PendingNetEvent::ServerListening(server_id) => { - // Take + drain the 'listening' listeners so the - // optional `listen(port, cb)` callback fires exactly - // once (Node's semantics). Subsequent - // `.on('listening', ...)` registrations would have - // to wait for another `.listen(...)` cycle — fine, - // re-binding without close() in between would error - // on bind anyway. - let cbs = { - let mut listeners = statics::listeners().lock().unwrap(); - if let Some(per_server) = listeners.get_mut(&server_id) { - per_server.remove("listening").unwrap_or_default() - } else { - Vec::new() - } - }; - // #8259: these were REMOVED from the table above (one-shot), - // so this frame is their ONLY root during dispatch — without - // it a collection here can free, not just move, them. - let frame = dispatch_custody::DispatchFrame::park(cbs); - let this = ListenerThis::of(server_id); - for i in 0..frame.len() { - let cb = frame.cb(i); - if cb != 0 { - let _ = JsClosure::from_raw(cb as *const RawClosureHeader) - .call0(this.receiver()); - } - } - drop(frame); - } - PendingNetEvent::ServerClose(server_id) => { - // Drain close listeners (one-shot, like Node). - let cbs = { - let mut listeners = statics::listeners().lock().unwrap(); - if let Some(per_server) = listeners.get_mut(&server_id) { - per_server.remove("close").unwrap_or_default() - } else { - Vec::new() - } - }; - // #8259: removed from the table above — custody is the only - // root; see the ServerListening arm. - let frame = dispatch_custody::DispatchFrame::park(cbs); - let this = ListenerThis::of(server_id); - for i in 0..frame.len() { - let cb = frame.cb(i); - if cb != 0 { - let _ = JsClosure::from_raw(cb as *const RawClosureHeader) - .call0(this.receiver()); - } - } - drop(frame); - // Tear down the server entry so the keepalive gate - // (`js_ext_net_has_active_handles`) lets the runtime - // exit cleanly after the user's close() resolves. - server_state::remove_server(server_id); - statics::servers().lock().unwrap().remove(&server_id); - statics::listeners().lock().unwrap().remove(&server_id); - statics::once_flags().lock().unwrap().remove(&server_id); - } - PendingNetEvent::ServerError(server_id, msg) => { - let cbs = listeners_for(server_id, "error"); - if cbs.is_empty() { - // Node prints to stderr if there's no handler and - // crashes the process; we just log and continue — - // less hostile to test harnesses that haven't - // wired an error listener yet. - eprintln!("[perry-ext-net] server {} error: {}", server_id, msg); - continue; - } - // #8259: park before the allocating build_error_object. - let mut frame = dispatch_custody::DispatchFrame::park(cbs); - let err_f64 = build_error_object(&msg); - frame.set_payload(err_f64.to_bits()); - let this = ListenerThis::of(server_id); - for i in 0..frame.len() { - let cb = frame.cb(i); - if cb != 0 { - let _ = JsClosure::from_raw(cb as *const RawClosureHeader) - .call1(this.receiver(), f64::from_bits(frame.payload_bits())); - } - } - drop(frame); - lifecycle::drain_once_listeners(server_id, "error"); - } - PendingNetEvent::ServerDrop(server_id, info) => { - let cbs = listeners_for(server_id, "drop"); - if cbs.is_empty() { - lifecycle::drain_once_listeners(server_id, "drop"); - continue; - } - // #8259: park before the allocating build_drop_object. - let mut frame = dispatch_custody::DispatchFrame::park(cbs); - let info = server_state::build_drop_object(&info); - frame.set_payload(info.to_bits()); - let this = ListenerThis::of(server_id); - for i in 0..frame.len() { - let cb = frame.cb(i); - if cb != 0 { - let _ = JsClosure::from_raw(cb as *const RawClosureHeader) - .call1(this.receiver(), f64::from_bits(frame.payload_bits())); - } - } - drop(frame); - lifecycle::drain_once_listeners(server_id, "drop"); - } - } - drop(provider_scope); - for async_id in destroy_ids { - if async_id != 0 { - js_async_hooks_provider_destroy(async_id); - } - } - } - - // Restore the (capacity-retaining) buffer to the thread-local so the - // next tick reuses it. A re-entrant pump call during dispatch may - // have left a grown buffer in the slot — keep whichever is larger. - SCRATCH.with(|s| { - let mut slot = s.borrow_mut(); - if events.capacity() >= slot.capacity() { - *slot = events; - } - }); - - count -} - -#[cfg(test)] -mod listener_this_tests { - use super::*; - - const UNDEFINED: u64 = 0x7FFC_0000_0000_0001; - /// Not a NaN-boxed value any call passes: "the listener never ran". - const NOT_CALLED: f64 = 0.5; - - /// Records the receiver it was called with in its own capture slot 0 (a - /// slot the collector sees), so no static holds a heap value. - extern "C" fn record_this(closure: *const RawClosureHeader, this: perry_ffi::JsThis) -> f64 { - unsafe { - perry_ffi::set_closure_capture_f64(closure as *mut RawClosureHeader, 0, this.as_f64()) - }; - f64::from_bits(UNDEFINED) - } - - fn recording_listener() -> i64 { - let closure = perry_ffi::alloc_closure( - perry_ffi::js_function_info!(record_this, 0; with_declared(0)), - 1, - ); - unsafe { perry_ffi::set_closure_capture_f64(closure, 0, NOT_CALLED) }; - closure as i64 - } - - fn seen_this(listener: i64) -> u64 { - unsafe { perry_ffi::closure_capture_f64(listener as *const RawClosureHeader, 0) }.to_bits() - } - - /// #11227: every listener dispatch passes the emitter as `this`. The - /// listener reads `this` only from its receiver parameter, so this fails - /// if the dispatch makes a plain call (the listener would see - /// `undefined`). - #[test] - fn listeners_receive_the_emitting_handle_as_this() { - const HANDLE: i64 = 0x7A11_0042; - let listener = recording_listener(); - statics::listeners() - .lock() - .unwrap() - .entry(HANDLE) - .or_default() - .insert("probe".to_string(), vec![listener]); - unsafe { emit_socket_no_arg(HANDLE, "probe") }; - statics::listeners().lock().unwrap().remove(&HANDLE); - assert_eq!(seen_this(listener), socket_receiver(HANDLE).to_bits()); - } - - /// The negative control: the same listener called plainly sees - /// `undefined`, so the test above observes the receiver the dispatch - /// passed and nothing left over from an earlier call. - #[test] - fn a_plain_call_of_the_same_listener_sees_undefined() { - let listener = recording_listener(); - let _ = unsafe { - JsClosure::from_raw(listener as *const RawClosureHeader) - .call0(perry_ffi::JsThis::UNDEFINED) - }; - assert_eq!(seen_this(listener), UNDEFINED); - } -} diff --git a/crates/perry-ext-net/src/socket_facade.rs b/crates/perry-ext-net/src/socket_facade.rs index 5bfef5a45c..326fbed335 100644 --- a/crates/perry-ext-net/src/socket_facade.rs +++ b/crates/perry-ext-net/src/socket_facade.rs @@ -1,19 +1,18 @@ -//! TLS metadata and event-loop reference state for socket facades. +//! Ordinary Socket TLS compatibility facts and loop reference state. use super::*; -#[derive(Clone, Default)] -pub(crate) struct TlsSocketMetadata { - pub(crate) encrypted: bool, - pub(crate) authorized: bool, - pub(crate) servername: Option, - pub(crate) session: Vec, - pub(crate) session_reused: bool, - pub(crate) peer_certificate_cn: Option, +fn owner(handle: i64) -> f64 { + crate::payload_transport::boxed_addr(handle) +} +fn tls_field(handle: i64, key: &str) -> f64 { + let scope = perry_ffi::TransientRootScope::enter(); + let owner = scope.root_nanbox(owner(handle)); + let state = scope.root_nanbox(crate::payload_socket::state(owner.get())); + crate::payload_transport::own_get(state.get(), key) } -/// Attach TLS-observable state to a socket facade owned by another native -/// extension (notably the reqwest-backed HTTPS client). +/// TLS compatibility facts are ordinary Socket JS state, never an id record. #[no_mangle] pub unsafe extern "C" fn js_ext_net_set_tls_metadata( handle: i64, @@ -25,135 +24,89 @@ pub unsafe extern "C" fn js_ext_net_set_tls_metadata( session_id: u64, session_reused: i32, ) { - if let Some(socket) = statics::sockets().lock().unwrap().get_mut(&handle) { - socket.tls.encrypted = true; - socket.tls.authorized = authorized != 0; - socket.tls.servername = if servername_ptr.is_null() { - None - } else { - Some( - String::from_utf8_lossy(std::slice::from_raw_parts(servername_ptr, servername_len)) - .into_owned(), - ) - }; - socket.tls.peer_certificate_cn = if peer_certificate_cn_ptr.is_null() { - None + let scope = perry_ffi::TransientRootScope::enter(); + let owner = scope.root_nanbox(owner(handle)); + if crate::payload_transport::socket_link(owner.get()).is_err() { + return; + } + let state = scope.root_nanbox(crate::payload_socket::state(owner.get())); + let string = |ptr: *const u8, len: usize| { + if ptr.is_null() { + crate::payload_transport::undefined() } else { - Some( - String::from_utf8_lossy(std::slice::from_raw_parts( - peer_certificate_cn_ptr, - peer_certificate_cn_len, - )) - .into_owned(), - ) - }; - socket.tls.session = session_id.to_be_bytes().to_vec(); - socket.tls.session_reused = session_reused != 0; + crate::payload_events::string(&String::from_utf8_lossy(std::slice::from_raw_parts( + ptr, len, + ))) + } + }; + for (key, value) in [ + ("encrypted", true), + ("authorized", authorized != 0), + ("sessionReused", session_reused != 0), + ] { + crate::payload_transport::own_set( + state.get(), + key, + f64::from_bits(JsValue::from_bool(value).bits()), + ); } + crate::payload_transport::own_set( + state.get(), + "servername", + string(servername_ptr, servername_len), + ); + crate::payload_transport::own_set( + state.get(), + "peerCertificateCn", + string(peer_certificate_cn_ptr, peer_certificate_cn_len), + ); + crate::payload_transport::own_set( + state.get(), + "session", + f64::from_bits(JsValue::from_object_ptr(alloc_buffer(&session_id.to_be_bytes())).bits()), + ); } #[no_mangle] pub unsafe extern "C" fn js_ext_net_socket_tls_encrypted(handle: i64) -> f64 { - let value = statics::sockets() - .lock() - .unwrap() - .get(&handle) - .is_some_and(|socket| socket.tls.encrypted); - f64::from_bits(JsValue::from_bool(value).bits()) + tls_field(handle, "encrypted") } - #[no_mangle] pub unsafe extern "C" fn js_ext_net_socket_tls_authorized(handle: i64) -> f64 { - let value = statics::sockets() - .lock() - .unwrap() - .get(&handle) - .is_some_and(|socket| socket.tls.authorized); - f64::from_bits(JsValue::from_bool(value).bits()) + tls_field(handle, "authorized") } - #[no_mangle] pub unsafe extern "C" fn js_ext_net_socket_tls_servername(handle: i64) -> f64 { - match statics::sockets() - .lock() - .unwrap() - .get(&handle) - .and_then(|socket| socket.tls.servername.clone()) - { - Some(value) if !value.is_empty() => { - f64::from_bits(JsValue::from_string_ptr(alloc_string(&value).as_raw()).bits()) - } - _ => f64::from_bits(JsValue::from_bool(false).bits()), - } + tls_field(handle, "servername") } - #[no_mangle] pub unsafe extern "C" fn js_ext_net_socket_tls_session( handle: i64, ) -> *mut perry_ffi::BufferHeader { - let bytes = statics::sockets() - .lock() - .unwrap() - .get(&handle) - .map(|socket| socket.tls.session.clone()) - .unwrap_or_default(); + let bytes = crate::jsvalue_to_socket_bytes(tls_field(handle, "session")).unwrap_or_default(); alloc_buffer(&bytes) } - #[no_mangle] pub unsafe extern "C" fn js_ext_net_socket_tls_session_reused(handle: i64) -> f64 { - let value = statics::sockets() - .lock() - .unwrap() - .get(&handle) - .is_some_and(|socket| socket.tls.session_reused); - f64::from_bits(JsValue::from_bool(value).bits()) + tls_field(handle, "sessionReused") } - #[no_mangle] pub unsafe extern "C" fn js_ext_net_socket_peer_certificate_json(handle: i64) -> *mut StringHeader { - let cn = statics::sockets() - .lock() - .unwrap() - .get(&handle) - .and_then(|socket| socket.tls.peer_certificate_cn.clone()); + let cn = crate::jsvalue_to_owned_string(tls_field(handle, "peerCertificateCn")); let value = cn - .map(|cn| serde_json::json!({"subject": {"CN": cn}})) + .map(|cn| serde_json::json!({"subject":{"CN":cn}})) .unwrap_or_else(|| serde_json::json!({})); alloc_string(&value.to_string()).as_raw() } - #[no_mangle] pub extern "C" fn js_ext_net_is_socket_handle(handle: i64) -> i32 { - let owned = is_net_socket_handle(handle); - if owned { - 1 - } else { - 0 - } + crate::payload_transport::socket_link(owner(handle)).is_ok() as i32 } - -/// Update whether a socket participates in event-loop liveness. #[no_mangle] pub extern "C" fn js_ext_net_socket_set_ref(handle: i64, refed: i32) { - if let Some(socket) = statics::sockets().lock().unwrap().get_mut(&handle) { - socket.refed = refed != 0; - } - perry_ffi::notify_main_thread(); + crate::payload_socket::set_ref(owner(handle), refed != 0); } - -/// Return nonzero when a socket is still referenced by the event loop. #[no_mangle] pub extern "C" fn js_ext_net_socket_has_ref(handle: i64) -> i32 { - statics::sockets() - .lock() - .unwrap() - .get(&handle) - .is_none_or(|socket| socket.refed) as i32 -} - -/// Auxiliary liveness hook registered with the runtime for mixed stdlib links. -#[no_mangle] -pub extern "C" fn js_ext_net_has_active_handles() -> i32 { - server_state::has_active_handles() as i32 + crate::native_transport::has_ref(owner(handle)) as i32 } diff --git a/crates/perry-ext-net/src/tls.rs b/crates/perry-ext-net/src/tls.rs index 00fa35b500..c77cd56bba 100644 --- a/crates/perry-ext-net/src/tls.rs +++ b/crates/perry-ext-net/src/tls.rs @@ -3,7 +3,7 @@ //! the 2000-line gate. The handshake itself runs over the socket's turnloop //! handle (`turnloop_tls_io`). -use std::sync::{Arc, Mutex, OnceLock}; +use std::sync::Arc; use perry_ffi::{js_array_get, js_array_length, ArrayHeader, JsValue}; // `rustls` is this crate's own direct dependency — the same rustls 0.23 @@ -22,6 +22,24 @@ pub(crate) struct TlsClientConfigData { } impl TlsClientConfigData { + pub(crate) fn for_cached_config(ca: Option>>, certificate_pem: Vec) -> Self { + Self { + ca, + cert: certificate_pem, + ..Self::default() + } + } + pub(crate) fn retained_bytes(&self) -> usize { + fn vectors(values: &Vec>) -> usize { + values.capacity() * std::mem::size_of::>() + + values.iter().map(Vec::capacity).sum::() + } + self.ca.as_ref().map_or(0, vectors) + + self.cert.capacity() + + self.key.capacity() + + vectors(&self.alpn_protocols) + } + /// The configuration an **in-process** caller needs: an ALPN list, and /// everything else left at the platform default. /// @@ -43,87 +61,6 @@ impl TlsClientConfigData { } } -fn pending_tls_aborts() -> &'static Mutex> { - static ABORTS: OnceLock>> = OnceLock::new(); - ABORTS.get_or_init(|| Mutex::new(std::collections::HashSet::new())) -} - -pub(crate) fn fire_pending_tls_abort(handle: i64) { - if pending_tls_aborts().lock().unwrap().remove(&handle) { - crate::push_event(crate::PendingNetEvent::AbortError(handle)); - crate::push_event(crate::PendingNetEvent::Close(handle)); - } -} - -/// Node reports an already-aborted connect asynchronously, after callers have -/// had a chance to attach `error` and `close` listeners to the returned socket. -unsafe fn schedule_tls_abort(handle: i64) { - // An alloc-only SocketState is `awaiting_connect` and is not considered - // live by ext-net until connect() runs. This TLS fast path never starts - // connect(), so mark the synthetic socket live until its deferred Close - // event removes it from the registry. - if let Some(socket) = crate::statics::sockets().lock().unwrap().get_mut(&handle) { - socket.is_open = true; - } - pending_tls_aborts().lock().unwrap().insert(handle); - // A deadline on the loop, not a timer task: registering a `'close'` - // listener fires the abort early (`js_net_socket_on`), and whichever comes - // first wins because `fire_pending_tls_abort` removes the entry. - crate::turnloop_io::arm_deadline( - 25, - crate::turnloop_io::Deadline::TlsAbort { socket_id: handle }, - ); -} - -pub(crate) fn begin_tls_upgrade( - handle: i64, - servername: String, - verify: bool, - config: TlsClientConfigData, -) -> Result<(), String> { - let (turnloop, awaiting_connect) = { - let sockets = crate::statics::sockets().lock().unwrap(); - let socket = sockets - .get(&handle) - .ok_or_else(|| "socket is closed".to_string())?; - (socket.turnloop, socket.awaiting_connect) - }; - if !turnloop { - // A `new net.Socket()` that has not been asked to connect accepts the - // request and drops it, as every command it is given before connecting - // is dropped (`SocketState::awaiting_connect`); one whose connect was - // refused has nothing to put a session on. - return if awaiting_connect { - Ok(()) - } else { - Err("socket task is gone".to_string()) - }; - } - // The session is installed above the same turnloop handle. No reply - // channel: JS learns the outcome from `'secureConnect'` / `'error'`, which - // is what this caller (`tls.connect` after a plain connect) already - // listened for. It runs on the loop's owner; a failure there is reported - // the way the caller reports one it gets back from here. - if crate::turnloop_io::enabled() { - return crate::turnloop_tls_io::begin_client_upgrade( - handle, servername, verify, config, None, - ); - } - let posted = crate::turnloop_io::post_to_owner(Box::new(move || { - if let Err(error) = - crate::turnloop_tls_io::begin_client_upgrade(handle, servername, verify, config, None) - { - crate::push_event(crate::PendingNetEvent::Error(handle, error)); - crate::push_event(crate::PendingNetEvent::Close(handle)); - } - })); - if posted { - Ok(()) - } else { - Err(crate::turnloop_io::NO_LOOP_CODE.to_string()) - } -} - unsafe fn is_array(value: f64) -> bool { extern "C" { fn js_array_is_array(value: f64) -> f64; @@ -575,12 +512,13 @@ fn build_client_config_insecure( /// path's unbuffered session: what `socket.authorized` / `getProtocol()` / /// `getPeerCertificate()` report. pub(crate) struct HandshakeFacts { - protocol: &'static str, - alpn: Vec, - peer: Vec, - own_certificate: Vec, - authorized: bool, - servername: String, + pub(crate) protocol: &'static str, + pub(crate) cipher: Option<(String, String)>, + pub(crate) alpn: Vec, + pub(crate) peer: Vec, + pub(crate) own_certificate: Vec, + pub(crate) authorized: bool, + pub(crate) servername: String, } impl HandshakeFacts { @@ -596,6 +534,7 @@ impl HandshakeFacts { .unwrap_or_default(); Self { protocol: session.protocol_version(), + cipher: session.cipher_names(), alpn: session.alpn_protocol().unwrap_or_default(), authorized: verify || trusted_by_configured_ca(data, &peer), peer, @@ -603,55 +542,6 @@ impl HandshakeFacts { servername: servername.to_string(), } } - - /// Write the facts onto the socket and tell JS through the runtime's - /// `js_tls_client_record_connected` extern. - pub(crate) fn publish(&self, handle: i64) { - let authorization_error = if self.authorized { - "" - } else { - "DEPTH_ZERO_SELF_SIGNED_CERT" - }; - if let Some(socket) = crate::statics::sockets().lock().unwrap().get_mut(&handle) { - socket.tls.encrypted = true; - socket.tls.authorized = self.authorized; - socket.tls.servername = Some(self.servername.clone()); - } - extern "C" { - fn js_tls_client_record_connected( - handle: i64, - authorized: i32, - authorization_error_ptr: *const u8, - authorization_error_len: usize, - protocol_ptr: *const u8, - protocol_len: usize, - alpn_ptr: *const u8, - alpn_len: usize, - peer_cert_ptr: *const u8, - peer_cert_len: usize, - own_cert_ptr: *const u8, - own_cert_len: usize, - ); - } - // SAFETY: every pointer/length pair below borrows a live local for the - // duration of the call; the runtime copies what it keeps. - unsafe { - js_tls_client_record_connected( - handle, - self.authorized as i32, - authorization_error.as_ptr(), - authorization_error.len(), - self.protocol.as_ptr(), - self.protocol.len(), - self.alpn.as_ptr(), - self.alpn.len(), - self.peer.as_ptr(), - self.peer.len(), - self.own_certificate.as_ptr(), - self.own_certificate.len(), - ); - } - } } /// Node treats a peer certificate that the caller itself supplied as `ca` as @@ -724,11 +614,12 @@ pub unsafe extern "C" fn js_tls_connect(arg1: f64, arg2: f64, arg3: f64, arg4: f fn js_tls_prepare_connect(); } js_tls_prepare_connect(); - use crate::option_setters::js_net_validate_connect_port; + extern "C" { + fn js_net_validate_connect_port(value: f64); + } use crate::{ get_object_bool_field, get_object_number_field, get_object_string_field, - is_nanboxed_pointer, jsvalue_to_owned_string, spawn_socket_task_initialized, statics, - unbox_pointer, + is_nanboxed_pointer, jsvalue_to_owned_string, }; use perry_ffi::JsValue; @@ -822,61 +713,36 @@ pub unsafe extern "C" fn js_tls_connect(arg1: f64, arg2: f64, arg3: f64, arg4: f } js_tls_validate_connect_options(rooted_args[0].get()); if let Some(socket_value) = crate::get_object_value_field(rooted_args[0].get(), "socket") { - let socket_js = JsValue::from_bits(socket_value.to_bits()); - let handle = if socket_js.is_pointer() { - crate::unbox_pointer(socket_value) as i64 - } else { - 0 - }; - if handle != 0 { - host = get_object_string_field(rooted_args[0].get(), "host") + if !JsValue::from_bits(socket_value.to_bits()).is_undefined() { + let parent = root_scope.root_nanbox(socket_value); + crate::payload_socket::link(parent.get()); + let owner = + root_scope.root_nanbox(crate::payload_socket::new_tls_wrapper(parent.get())); + let host = get_object_string_field(rooted_args[0].get(), "host") .or_else(|| get_object_string_field(rooted_args[0].get(), "hostname")) - .unwrap_or_else(|| "localhost".to_string()); - servername = get_object_string_field(rooted_args[0].get(), "servername") - .unwrap_or_else(|| host.clone()); - verify = get_object_bool_field(rooted_args[0].get(), "rejectUnauthorized") + .unwrap_or_else(|| "localhost".to_owned()); + let servername = + get_object_string_field(rooted_args[0].get(), "servername").unwrap_or(host); + let verify = get_object_bool_field(rooted_args[0].get(), "rejectUnauthorized") .unwrap_or(true); - callback_arg = is_closure(rooted_args[1].get()).then_some(1); let config = tls_client_config_data(rooted_args[0].get()); - extern "C" { - fn js_tls_client_record_start( - handle: i64, - options: f64, - servername_ptr: *const u8, - servername_len: usize, - ); - } - js_tls_client_record_start( - handle, - rooted_args[0].get(), - servername.as_ptr(), - servername.len(), - ); - if let Some(index) = callback_arg { - let cb_ptr = unbox_pointer(rooted_args[index].get()) as i64; - if cb_ptr != 0 { - statics::listeners() - .lock() - .unwrap() - .entry(handle) - .or_default() - .entry("secureConnect".to_string()) - .or_default() - .push(cb_ptr); - } + record_start(owner.get(), rooted_args[0].get(), &servername); + crate::payload_socket::once(owner.get(), "secureConnect", rooted_args[1].get()); + if crate::payload_transport::socket_ptr(crate::payload_socket::link(parent.get())) + .is_err() + { + crate::payload_io::queue_closed_wrapper(owner.get()); + return crate::payload_transport::raw_owner(owner.get()); } let preflight = tls_preflight(0, &servername, rooted_args[0].get()); if preflight != 0 { - crate::push_event(crate::PendingNetEvent::Error( - handle, - preflight_error(preflight).to_string(), - )); - crate::push_event(crate::PendingNetEvent::Close(handle)); - } else if let Err(error) = begin_tls_upgrade(handle, servername, verify, config) { - crate::push_event(crate::PendingNetEvent::Error(handle, error)); - crate::push_event(crate::PendingNetEvent::Close(handle)); + defer_failure(owner.get(), preflight_error(preflight)); + } else if let Err(message) = + crate::payload_tls::install_client(owner.get(), servername, verify, config) + { + defer_failure(owner.get(), &message); } - return handle; + return crate::payload_transport::raw_owner(owner.get()); } } port = match get_object_number_field(rooted_args[0].get(), "port") { @@ -907,83 +773,36 @@ pub unsafe extern "C" fn js_tls_connect(arg1: f64, arg2: f64, arg3: f64, arg4: f .unwrap_or_else(|| f64::from_bits(0x7FFC_0000_0000_0001)) }; let config = tls_client_config_data(metadata_options()); + let owner = root_scope.root_nanbox(crate::payload_socket::new_socket( + crate::payload_io::ROUTE, + metadata_options(), + )); + record_start(owner.get(), metadata_options(), &servername); + if let Some(index) = callback_arg { + crate::payload_socket::once(owner.get(), "secureConnect", rooted_args[index].get()); + } if signal_is_pre_aborted(metadata_options()) { - let handle = crate::js_net_socket_alloc(); - extern "C" { - fn js_tls_client_record_start( - handle: i64, - options: f64, - servername_ptr: *const u8, - servername_len: usize, - ); - } - js_tls_client_record_start( - handle, - metadata_options(), - servername.as_ptr(), - servername.len(), - ); - schedule_tls_abort(handle); - return handle; + defer_failure(owner.get(), "ABORT_ERR:The operation was aborted"); + return crate::payload_transport::raw_owner(owner.get()); } let preflight = tls_preflight(port, &servername, metadata_options()); if preflight != 0 { - let handle = crate::js_net_socket_alloc(); - extern "C" { - fn js_tls_client_record_start( - handle: i64, - options: f64, - servername_ptr: *const u8, - servername_len: usize, - ); - } - js_tls_client_record_start( - handle, - metadata_options(), - servername.as_ptr(), - servername.len(), - ); - crate::push_event(crate::PendingNetEvent::Error( - handle, - preflight_error(preflight).to_string(), - )); - crate::push_event(crate::PendingNetEvent::Close(handle)); - return handle; - } - let metadata_servername = servername.clone(); - let handle = - spawn_socket_task_initialized(host, port, Some((servername, verify, config)), |handle| { - extern "C" { - fn js_tls_client_record_start( - handle: i64, - options: f64, - servername_ptr: *const u8, - servername_len: usize, - ); - } - js_tls_client_record_start( - handle, - metadata_options(), - metadata_servername.as_ptr(), - metadata_servername.len(), - ); - }); - if let Some(index) = callback_arg { - if handle != 0 { - let cb_ptr = unbox_pointer(rooted_args[index].get()) as i64; - if cb_ptr != 0 { - statics::listeners() - .lock() - .unwrap() - .entry(handle) - .or_default() - .entry("secureConnect".to_string()) - .or_default() - .push(cb_ptr); - } - } + defer_failure(owner.get(), preflight_error(preflight)); + return crate::payload_transport::raw_owner(owner.get()); + } + let host_value = root_scope.root_nanbox(crate::payload_events::string(&host)); + crate::payload_socket::connect( + owner.get(), + port as f64, + host_value.get(), + crate::payload_transport::undefined(), + ); + if let Ok(payload) = + crate::payload_transport::socket_ptr(crate::payload_socket::link(owner.get())) + { + (*payload).ext.direct_tls = Some((servername, verify, config)); } - handle + crate::payload_transport::raw_owner(owner.get()) } /// Collision-proof entry point for AOT calls. Both bundled stdlib net and @@ -994,3 +813,39 @@ pub unsafe extern "C" fn js_tls_connect(arg1: f64, arg2: f64, arg3: f64, arg4: f pub unsafe extern "C" fn js_ext_tls_connect(arg1: f64, arg2: f64, arg3: f64, arg4: f64) -> i64 { js_tls_connect(arg1, arg2, arg3, arg4) } + +unsafe fn record_start(owner: f64, options: f64, servername: &str) { + extern "C" { + fn js_tls_client_record_start(handle: i64, options: f64, name: *const u8, len: usize); + fn js_tls_client_attach_socket_prototype(handle: i64); + } + let scope = perry_ffi::TransientRootScope::enter(); + let owner = scope.root_nanbox(owner); + let options = scope.root_nanbox(options); + js_tls_client_attach_socket_prototype(crate::payload_transport::raw_owner(owner.get())); + js_tls_client_record_start( + crate::payload_transport::raw_owner(owner.get()), + options.get(), + servername.as_ptr(), + servername.len(), + ); +} + +pub(crate) fn defer_failure(owner: f64, message: &str) { + let scope = perry_ffi::TransientRootScope::enter(); + let owner = scope.root_nanbox(owner); + let (code, message) = message + .split_once(':') + .unwrap_or(("ERR_TLS_HANDSHAKE_FAILED", message)); + let error = scope.root_nanbox(crate::payload_socket::error(code, message)); + if code == "ABORT_ERR" { + crate::payload_transport::own_set( + error.get(), + "name", + crate::payload_events::string("AbortError"), + ); + } + // Explicit release happens now; the ordinary terminal job or the driver's + // Closed completion delivers error/close after callers attach listeners. + crate::payload_socket::destroy(owner.get(), error.get()); +} diff --git a/crates/perry-ext-net/src/turnloop_io.rs b/crates/perry-ext-net/src/turnloop_io.rs deleted file mode 100644 index 10f6fbcffb..0000000000 --- a/crates/perry-ext-net/src/turnloop_io.rs +++ /dev/null @@ -1,1080 +0,0 @@ -//! `node:net` on turnloop handles (P1). -//! -//! What this replaces, one for one: -//! -//! | tokio | turnloop | -//! |---|---| -//! | a `spawn_async` accept loop per listening server (`lib.rs`, `ipc.rs`) | one multishot `accept_start` | -//! | a `run_socket_task` per connection, selecting on `read_buf` and a channel | one multishot `read_start`, plus direct submissions | -//! | `SocketCommand` over a per-socket `mpsc` for every write / end / destroy | `write` / `shutdown` / `close` submitted where the FFI call happens | -//! | `TcpStream::connect(&str)` on a task, which also resolved the name | `tcp_connect`, whose name lookup runs on the shared blocking pool | -//! -//! Everything downstream is untouched: this module produces exactly the same -//! [`PendingNetEvent`]s in the same order, into the same queue, drained by the -//! same `js_ext_net_drain_pending`. The JS-visible surface, the listener maps, -//! the GC root scanner and the buffer pool do not know which transport ran. -//! -//! # Which sockets come here -//! -//! All of them. This crate has no other transport: tokio and `tokio_rustls` -//! are gone from its manifest, and with them the per-socket task this table -//! replaced. -//! -//! What used to keep that task alive was the P1 coexistence rule — a thread -//! that could not get a loop of its own kept the tokio socket. Since turnloop -//! P9 every JS agent has a loop, so such a thread is a **second thread acting -//! for an agent another thread already owns** (an embedder's pump thread, -//! Android's UI thread for `perry-native`). It serves the same JS heap as the -//! owner, so [`on_loop`] hands its submission to the owner through -//! `perry_ffi::agent_post` and the completion is delivered where the socket's -//! JS values live. That is the whole replacement for the fallback. -//! -//! The one case posting cannot serve is an agent with no loop anywhere — a -//! host where `Loop::new` failed. There the operation fails with `ENOTSUP` -//! ([`NO_LOOP_CODE`]) through the socket's normal `'error'` path, instead of -//! silently running on a second event loop. -//! -//! TLS rides the same handles: the rustls session runs *above* the turnloop -//! socket (`turnloop_tls_io`), so neither `tls.connect` nor -//! `socket.upgradeToTLS` needs a descriptor to move. -//! -//! There is no handover in either direction — a socket belongs to one loop -//! from creation to close. -//! -//! # Threading and the GC -//! -//! The sink runs on the agent thread, from the event loop's own turn, so it -//! may touch the socket registries directly. It still only pushes -//! [`PendingNetEvent`]s: JS values are built later, in the pump, exactly as -//! the tokio path required, so the arena-safety rule in `lib.rs` holds -//! unchanged. Read bytes are copied out of turnloop's pooled lease before the -//! sink returns; write bytes were already copied into an owned `Vec` by -//! `jsvalue_to_socket_bytes`. No JS heap pointer reaches the driver. - -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{Mutex, OnceLock}; - -use perry_ffi::agent_post::{self, AgentJob}; -use perry_ffi::turnloop_net as tl; - -use crate::{ - buffer_pool, mark_closed, push_event, raw_bridge, server_state, statics, PendingNetEvent, - SocketCommand, -}; - -/// This binding's slot in the runtime's sink registry. -pub(crate) const SUBSYSTEM: u8 = 0; - -/// Whether sink registration has been attempted. The runtime's own -/// registration is idempotent; this only keeps the ABI layout check behind it -/// off the per-socket path. -static REGISTERED: AtomicBool = AtomicBool::new(false); - -/// Per-socket state this transport needs and `SocketState` has no field for. -#[derive(Default)] -struct Aux { - /// `server_state::begin_local_connect`'s reservation, held until the - /// connect completes so `PendingNetEvent::Connect` can carry it. - local_server: Option<(i64, bool)>, - /// The peer sent FIN and `'end'` has not been delivered yet. - read_ended: bool, - /// The readable side ended and Node's `allowHalfOpen: false` default is - /// closing the socket — but only once the write-side shutdown completes, - /// so writes issued from the `'end'` handler are not cancelled by the - /// close (turnloop's `close` cancels every outstanding operation). - close_after_shutdown: bool, - /// The peer's FIN arrived before this accepted socket's `'connection'` - /// callback had run, so `'end'` is held until it does. The event queue - /// alone cannot order these: `server_state` may defer a loopback - /// `ServerConnection` across a pump boundary, and turnloop can deliver the - /// whole request plus its FIN inside the very first turn — so an `'end'` - /// pushed at EOF time would be dispatched to a socket that has no - /// listeners yet and be lost. The tokio task had the same hazard and - /// solved it by blocking its post-EOF drain on the same marker. - deferred_eof: bool, - /// An `'error'` has been reported for this socket. The tokio task broke - /// its loop after the first one; this keeps that "one error, then the - /// terminal pair" shape when several operations fail in the same turn. - errored: bool, - /// `PendingNetEvent::Close` has been pushed. Node emits `'close'` AFTER - /// `'error'`, so this guards double-emission — never emission itself. - closed_emitted: bool, - /// `socket.end()` has already submitted the write-side shutdown. Node's - /// `allowHalfOpen: false` close on `'end'` must NOT submit a second one: a - /// second `shutdown(2)` on a socket whose peer has gone answers `ENOTCONN`, - /// which reached JS as a spurious `'error'` — visible on the TLS upgrade - /// path, where `end()` always precedes the peer's FIN, and latent on a - /// plain socket with the same ordering. - write_ended: bool, - /// That shutdown has completed, which means every write queued ahead of it - /// has left. Until then the socket must not be closed: `Loop::close` - /// cancels outstanding operations, so closing here would cancel exactly - /// the writes an `'end'` handler just issued (P1's third behaviour note). - shutdown_done: bool, - /// Completion tokens of `end()` calls made after the first one. Node runs - /// every `end(cb)` callback once the stream finishes; only the first - /// `end()` submits the shutdown, and these complete with it. - extra_end_users: Vec, - /// The readable EOF has been delivered. A TLS socket can reach it twice — - /// the peer's `close_notify` and then the TCP FIN — and Node emits - /// `'end'` exactly once. - eof_emitted: bool, - /// P5: `tls.connect` asked for TLS from byte zero. The session is - /// installed the instant the connect completes and before `'connect'` is - /// pushed, which is the ordering the tokio path got by handshaking before - /// it pushed the event. - direct_tls: Option<(String, bool, crate::TlsClientConfigData)>, - /// Application writes to a `tls.connect` socket made before its TCP - /// connect completed, with their completion tokens. They must be - /// encrypted, so they cannot wait in the runtime's plaintext backlog — - /// that sent them in the clear ahead of the ClientHello. Replayed through - /// the TLS layer the moment it is installed. - held_tls_writes: Vec<(Vec, u64)>, - /// An `end()` made in that same window, replayed after the writes. - held_tls_end: Option, -} - -/// Sockets whose close has been submitted to the driver and whose `'close'` -/// has not been pushed yet (#11227). Node emits `'close'` after `destroy()` -/// on a `process.nextTick`, which runs whatever else the loop holds; here the -/// driver answers later, and a destroyed socket no longer counted as active, -/// so a program with nothing else pending exited in between and the -/// `'close'` never fired. undici's `Client.close()` awaits exactly that -/// event, so it never settled. -/// Sockets whose close was submitted and whose `'close'` is still owed, each -/// with the agent that submitted it (#11340: only that agent waits for it). -fn closing() -> &'static Mutex> { - static CLOSING: OnceLock>> = OnceLock::new(); - CLOSING.get_or_init(|| Mutex::new(std::collections::HashMap::new())) -} - -/// Whether a submitted socket close still owes its `'close'` event; the -/// keepalive gate (`server_state::has_active_handles`) waits for it. -pub(crate) fn close_in_flight() -> bool { - let agent = perry_ffi::agent_post::current_agent(); - closing() - .lock() - .unwrap_or_else(|e| e.into_inner()) - .values() - .any(|owner| *owner == agent) -} - -fn note_close_submitted(id: i64) { - closing() - .lock() - .unwrap_or_else(|e| e.into_inner()) - .insert(id, perry_ffi::agent_post::current_agent()); -} - -fn submit_socket_close(id: i64) -> bool { - let submitted = tl::close(id).is_ok(); - if submitted { - note_close_submitted(id); - } - submitted -} - -fn aux() -> &'static Mutex> { - static AUX: OnceLock>> = OnceLock::new(); - AUX.get_or_init(|| Mutex::new(std::collections::HashMap::new())) -} - -fn with_aux(id: i64, f: impl FnOnce(&mut Aux) -> R) -> R { - let mut map = aux().lock().unwrap_or_else(|e| e.into_inner()); - f(map.entry(id).or_default()) -} - -fn forget_aux(id: i64) -> Aux { - let mut map = aux().lock().unwrap_or_else(|e| e.into_inner()); - map.remove(&id).unwrap_or_default() -} - -/// Whether a socket created *now, on this thread* should live on turnloop. -/// -/// Deliberately not cached: availability is a property of the calling -/// *thread* (only the thread that owns its agent's loop may submit), not of -/// the process. Registration behind it is idempotent and costs one atomic once -/// it has happened. A `false` here sends the work to [`on_loop`]'s posting -/// route, never to a second transport. -pub(crate) fn enabled() -> bool { - if !REGISTERED.load(Ordering::Acquire) { - // `register_sink` refuses if the runtime's completion layout does not - // match this crate's, which leaves `available` false rather than - // submitting work nothing can deliver. - tl::register_sink(SUBSYSTEM, sink, alloc_id); - REGISTERED.store(true, Ordering::Release); - } - tl::available(SUBSYSTEM) -} - -/// Allocate the JS-visible handle id for a connection turnloop just accepted. -extern "C" fn alloc_id() -> i64 { - let id = crate::next_id(); - if id == perry_ffi::INVALID_HANDLE { - 0 - } else { - id - } -} - -// ── Which thread submits ──────────────────────────────────────────────────── - -/// Node's `err.code` for an operation this agent has no loop to run on. -/// -/// Only reachable on a host where turnloop's `Loop::new` failed: every other -/// thread either owns its agent's loop or can post to the thread that does. -pub(crate) const NO_LOOP_CODE: &str = "ENOTSUP"; - -/// How many times a transiently refused post is retried before the operation -/// is reported as failed. A refusal is `Again` only while the owner is between -/// claiming its route and publishing its loop, or while its postbox is full — -/// both drain within a turn, so a short spin is the whole remedy. -const POST_ATTEMPTS: usize = 64; - -/// One submission carried to the thread that owns this agent's loop. -struct LoopJob(Box); - -impl AgentJob for LoopJob { - fn run(self: Box) { - (self.0)(); - } -} - -/// Run `op` on the thread that owns this agent's turnloop loop. -/// -/// Inline when that is this thread — the common case, and exactly what the -/// call sites did before — otherwise posted to the owner, which serves the -/// same JS heap (module note). Returns `false`, with `op` dropped unrun, only -/// when no loop exists for this agent at all; the caller then reports -/// [`NO_LOOP_CODE`] through its usual error path. -/// -/// `op` must not assume it runs synchronously: on the posting path it runs on -/// a later turn of the owner, so anything the caller needs to observe -/// immediately (a handle id, a `connecting` flag) is published before this is -/// called, and failures are reported from inside `op` as events. -pub(crate) fn on_loop(op: impl FnOnce() + Send + 'static) -> bool { - if enabled() { - op(); - return true; - } - post_to_owner(Box::new(op)) -} - -/// Post `op` to the owner without first asking whether this thread owns the -/// loop. -/// -/// For callers that may not be the loop's owner and must not become it by -/// asking. [`enabled`] must not be asked -/// there: the first thread to ask *claims* its agent's route for life, and a -/// foreign thread that won that race would own a loop nobody turns. -pub(crate) fn post_to_owner(op: Box) -> bool { - let mut job = Box::new(LoopJob(op)); - for _ in 0..POST_ATTEMPTS { - match agent_post::post_job(job) { - Ok(()) => return true, - Err(rejected) if rejected.is_permanent() => return false, - Err(rejected) => { - job = rejected.into_job(); - std::thread::yield_now(); - } - } - } - false -} - -/// A one-shot deadline this crate armed, and what to do when it fires. -/// -/// Two transport-agnostic paths used to sleep on a tokio timer: the loopback -/// `'connection'` deferral (`server_state::schedule_server_connection`) and -/// the already-aborted `tls.connect` signal path (`tls::schedule_tls_abort`). -/// Both are deadlines on the loop now, delivered to this crate's sink as a -/// `NET_TIMER` completion naming the id armed here. -pub(crate) enum Deadline { - /// Publish a deferred loopback `ServerConnection(server, socket, true)`. - ServerConnection { server_id: i64, socket_id: i64 }, - /// Fire an already-aborted `tls.connect`'s `AbortError` + `'close'`. - TlsAbort { socket_id: i64 }, -} - -fn deadlines() -> &'static Mutex> { - static DEADLINES: OnceLock>> = OnceLock::new(); - DEADLINES.get_or_init(|| Mutex::new(std::collections::HashMap::new())) -} - -/// Run `deadline` `delay_ms` from now, on the loop. -/// -/// The deadline is unreferenced (turnloop's `timer_arm`): it never keeps the -/// process alive by itself. Both users are covered by a handle that does — -/// the listening server, or the aborted socket that `schedule_tls_abort` -/// marks open until its `'close'`. -pub(crate) fn arm_deadline(delay_ms: u64, deadline: Deadline) { - on_loop_or_now(move || { - // A fresh id from the shared allocator, never a socket's: the runtime - // keys deadlines by id per thread across every subsystem, so reusing - // an id another binding armed would *move* that binding's deadline. - let id = crate::next_id(); - if id == perry_ffi::INVALID_HANDLE { - fire_deadline(deadline); - return; - } - deadlines() - .lock() - .unwrap_or_else(|e| e.into_inner()) - .insert(id, deadline); - if tl::timer_arm(id, SUBSYSTEM, delay_ms).is_err() { - // No deadline could be armed: fire now rather than never. The - // delay only ever ordered an event behind a pump boundary. - on_timer(id); - } - }); -} - -/// [`on_loop`], falling back to running `op` right here when this agent has -/// no loop at all. Only for work that is correct on any thread and merely -/// *prefers* the loop (a deadline that can fire immediately instead). -fn on_loop_or_now(op: impl FnOnce() + Send + 'static) { - let op: Box = Box::new(op); - if enabled() { - op(); - return; - } - // `post_to_owner` consumes the job even on refusal, so decide first. - if agent_post::available() { - if post_to_owner(op) { - return; - } - // Refused after `available()` said yes: the owner went away in - // between. The deadline is lost with the job, which is the same - // outcome as an agent torn down with a deadline pending. - return; - } - op(); -} - -fn fire_deadline(deadline: Deadline) { - match deadline { - Deadline::ServerConnection { - server_id, - socket_id, - } => { - statics::pending_events() - .lock() - .unwrap() - .push(PendingNetEvent::ServerConnection( - server_id, socket_id, true, - )); - perry_ffi::notify_main_thread(); - } - Deadline::TlsAbort { socket_id } => crate::tls::fire_pending_tls_abort(socket_id), - } -} - -fn on_timer(id: i64) { - let deadline = deadlines() - .lock() - .unwrap_or_else(|e| e.into_inner()) - .remove(&id); - // A fired one-shot is terminal on the runtime side too: it dropped its own - // record, so there is nothing to cancel. - if let Some(deadline) = deadline { - // The id named only this deadline and was never handed to JS, so it - // goes back to the shared allocator rather than leaking a slot of the - // handle band per deferred connection. - perry_ffi::free_handle_id(id); - fire_deadline(deadline); - } -} - -// ── Submission helpers, called from the FFI entry points ──────────────────── - -/// Deliver one socket command to a turnloop-backed socket. -/// -/// **Called with the socket registry locked**, from `SocketState::command`, so -/// nothing here may take that lock again — the caller owns the `SocketState` -/// and applies the byte accounting this returns. `Err` carries the message the -/// caller emits once it has dropped the lock. -/// -/// `queued_out` receives the socket's new queued byte count for a write. -pub(crate) fn command( - id: i64, - cmd: SocketCommand, - queued_out: &mut Option, -) -> Result<(), String> { - let secure = crate::turnloop_tls_io::installed(id); - // A `tls.connect` still connecting has no TLS layer yet: hold its writes - // (see `Aux::held_tls_writes`). - let tls_pending = !secure && with_aux(id, |a| a.direct_tls.is_some()); - match cmd { - SocketCommand::Write(bytes, completion) if tls_pending => { - let held = with_aux(id, |a| { - a.held_tls_writes.push((bytes, completion)); - a.held_tls_writes - .iter() - .map(|(b, _)| b.len()) - .sum::() - }); - *queued_out = Some(held as u64); - Ok(()) - } - SocketCommand::Write(bytes, completion) if secure => { - match crate::turnloop_tls_io::write(id, &bytes, completion) { - Ok(queued) => { - *queued_out = Some(queued as u64); - Ok(()) - } - Err(message) => Err(message), - } - } - SocketCommand::Write(bytes, completion) => match tl::write(id, &bytes, completion) { - Ok(queued) => { - *queued_out = Some(queued as u64); - Ok(()) - } - Err(err) => Err(err.message()), - }, - // `end()` on a TLS socket sends close_notify first; the FIN is queued - // behind it so the peer sees an orderly shutdown rather than a - // truncation attack. - SocketCommand::End(completion) => { - // A repeated `end()` must not submit a second shutdown: it would - // replace the first one's pending token (stranding that - // callback), and a second `shutdown(2)` answers `ENOTCONN`. It - // completes together with the first instead — or right away, if - // that one already has. - enum Repeat { - First, - Pending, - Done, - } - let repeat = with_aux(id, |a| { - if !a.write_ended { - a.write_ended = true; - Repeat::First - } else if a.shutdown_done { - Repeat::Done - } else { - if completion != 0 { - a.extra_end_users.push(completion); - } - Repeat::Pending - } - }); - match repeat { - Repeat::First if tls_pending => { - with_aux(id, |a| a.held_tls_end = Some(completion)); - Ok(()) - } - Repeat::First if secure => crate::turnloop_tls_io::shutdown(id, completion), - Repeat::First => tl::shutdown(id, completion).map_err(|e| e.message()), - Repeat::Pending => Ok(()), - Repeat::Done => { - if completion != 0 { - push_event(PendingNetEvent::ShutdownComplete(id, completion, None)); - } - Ok(()) - } - } - } - SocketCommand::Destroy => { - let submitted = tl::close(id).map_err(|e| e.message()); - if submitted.is_ok() { - note_close_submitted(id); - } - submitted - } - // The accepted socket's `'connection'` callback has returned, so its - // listeners exist: release an EOF that arrived before them. - SocketCommand::ServerConnectionReady => { - release_deferred_eof(id); - Ok(()) - } - } -} - -/// [`command`] for a socket whose loop another thread owns. -/// -/// The command is carried to that thread and applied there, with the same -/// accounting and the same failure path the inline call has: the queued byte -/// count is written back once the driver has answered, and a refused -/// submission is reported through [`submission_failed`] on the owner. -/// -/// Returns `Err` with a Node-shaped message only when this agent has no loop -/// at all, in which case nothing was queued. -pub(crate) fn command_on_owner(id: i64, cmd: SocketCommand) -> Result<(), String> { - let completion = match &cmd { - SocketCommand::Write(_, completion) | SocketCommand::End(completion) => *completion, - _ => 0, - }; - let posted = post_to_owner(Box::new(move || { - let mut queued = None; - let result = command(id, cmd, &mut queued); - if let Some(queued) = queued { - if let Ok(mut sockets) = statics::sockets().lock() { - if let Some(socket) = sockets.get_mut(&id) { - socket.bytes_queued = queued.saturating_add(socket.cork.bytes.len() as u64); - } - } - } - if let Err(message) = result { - submission_failed(id, completion, message); - } - })); - if posted { - Ok(()) - } else { - Err(NO_LOOP_CODE.to_string()) - } -} - -/// A submission that failed before the driver accepted it. -/// -/// Called with no lock held, after the caller released the socket registry. -/// Mirrors the tokio task's write-failure path: the write callback first, then -/// `'error'`, then the socket is torn down. -pub(crate) fn submission_failed(id: i64, completion: u64, message: String) { - if completion != 0 { - push_event(PendingNetEvent::WriteComplete( - id, - completion, - Some(message.clone()), - )); - } - if !with_aux(id, |a| std::mem::replace(&mut a.errored, true)) - && !raw_bridge::mark_terminal(id, Some(message.clone())) - { - push_event(PendingNetEvent::Error(id, message)); - } - destroy(id); -} - -/// `socket.destroy()` on a turnloop socket. The `'close'` event is pushed when -/// the driver reports the handle really gone, never before. -pub(crate) fn destroy(id: i64) { - if submit_socket_close(id) { - // The driver will deliver `Closed`, and that is what emits `'close'`. - return; - } - // The handle is already gone (a destroy that raced the peer's reset, or a - // second `destroy()`): emit the terminal event the caller is waiting for - // rather than stranding the socket. - emit_close_once(id); -} - -/// Push `'close'` and retire the socket, at most once per socket. -fn emit_close_once(id: i64) { - closing() - .lock() - .unwrap_or_else(|e| e.into_inner()) - .remove(&id); - if with_aux(id, |a| std::mem::replace(&mut a.closed_emitted, true)) { - return; - } - crate::turnloop_tls_io::forget(id); - if !raw_bridge::mark_terminal(id, None) { - push_event(PendingNetEvent::Close(id)); - } - mark_closed(id); - forget_aux(id); -} - -/// Start the readable side. Called once the socket is connected or accepted. -pub(crate) fn start_reading(id: i64) { - if let Err(err) = tl::read_start(id) { - push_event(PendingNetEvent::Error(id, err.message())); - destroy(id); - } -} - -/// Finish a readable-EOF that the pump has now delivered to `'end'` listeners. -/// -/// Node's default (`allowHalfOpen: false`) ends the writable side once the -/// readable side has ended, and only then closes. Doing it here — after the -/// pump fired `'end'` — is what gives a synchronous `socket.write()` inside an -/// `'end'` handler the same chance it had inside the tokio task's post-EOF -/// command drain. -pub(crate) fn finish_read_end(id: i64) { - // The pump that delivered `'end'` may be a second thread acting for this - // agent; the shutdown and close below are submissions, so they run on the - // loop's owner. A socket exists only if a loop does, so a refused post has - // nothing left to finish. - if !enabled() { - let _ = post_to_owner(Box::new(move || finish_read_end(id))); - return; - } - if !with_aux(id, |a| std::mem::replace(&mut a.read_ended, false)) { - return; - } - // The application already ended the writable side inside its `'end'` - // handler, so the shutdown is submitted and there is nothing to ask for - // again (a second `shutdown(2)` answers `ENOTCONN`). Whether the socket may - // close *now* is the whole question: closing while that shutdown is still - // outstanding cancels the writes queued ahead of it, which is how a - // `socket.write()` from an `'end'` handler went missing. - if with_aux(id, |a| a.write_ended) { - if with_aux(id, |a| a.shutdown_done) { - destroy(id); - } else { - with_aux(id, |a| a.close_after_shutdown = true); - } - return; - } - // Queue the shutdown BEHIND whatever the `'end'` handler just wrote, and - // close only when it completes. turnloop orders a handle's writes and its - // shutdown, so a completed shutdown means every queued byte left — while - // closing here instead would cancel those writes outright. - match tl::shutdown(id, 0) { - Ok(()) => with_aux(id, |a| a.close_after_shutdown = true), - Err(_) => destroy(id), - } -} - -/// Record the local-connect reservation for a socket that is connecting. -pub(crate) fn note_local_connect(id: i64, local_server: Option<(i64, bool)>) { - with_aux(id, |a| a.local_server = local_server); -} - -/// Record that this connecting socket is a `tls.connect`, so the handshake -/// starts as soon as the connect completes. -pub(crate) fn note_direct_tls( - id: i64, - servername: String, - verify: bool, - config: crate::TlsClientConfigData, -) { - with_aux(id, |a| a.direct_tls = Some((servername, verify, config))); -} - -/// Start a local (Unix socket / named pipe) client connect. -pub(crate) fn connect_pipe(id: i64, path: &str) -> Result<(), tl::NetError> { - tl::pipe_connect(id, SUBSYSTEM, path) -} - -/// Start an outbound TCP client connect. -/// -/// P1 deliberately left this class on tokio: `socket.upgradeToTLS` moved a -/// live `TcpStream` into `tokio_rustls`, turnloop owns its descriptor without -/// exposing it, and a socket's transport is fixed at creation — so a client -/// that *might* be upgraded could not be created on turnloop. P5 removes the -/// premise rather than the restriction: TLS now runs above the turnloop handle -/// (`turnloop_tls_io`), so nothing has to move and the class comes over. -/// -/// A hostname is resolved by the driver off the loop thread, which is the -/// property `TcpStream::connect(&str)` had and a `to_socket_addrs()` here -/// would have silently lost. -pub(crate) fn connect_tcp( - id: i64, - host: &str, - port: u16, - nodelay: bool, -) -> Result<(), tl::NetError> { - tl::tcp_connect(id, SUBSYSTEM, host, port, nodelay) -} - -/// Bind, listen and start accepting on a TCP server. -pub(crate) fn listen_tcp(id: i64, host: &str, port: u16, backlog: u32) -> Result<(), tl::NetError> { - // `net.createServer({ noDelay })` defaults to FALSE in Node, unlike - // `http.createServer`'s, and Perry's `net` surface has never applied it — - // so this stays false and the behaviour is unchanged. - tl::tcp_listen(id, SUBSYSTEM, host, port, backlog, false, false)?; - tl::accept_start(id) -} - -/// Bind, listen and start accepting on a local server. -pub(crate) fn listen_pipe(id: i64, path: &str, backlog: u32) -> Result<(), tl::NetError> { - tl::pipe_listen(id, SUBSYSTEM, path, backlog)?; - tl::accept_start(id) -} - -/// `server.close()` on a turnloop-backed server. -/// -/// Must run on the loop's owner; `js_net_server_close` routes it there. -pub(crate) fn close_server(id: i64) { - if tl::close(id).is_err() { - // Never listened, or already closing: the caller still needs its - // terminal event. - push_event(PendingNetEvent::ServerClose(id)); - } -} - -/// Whether `id` is a live turnloop handle, for the FFI entry points that have -/// to choose a transport without a `SocketState` in hand. -pub(crate) fn owns(id: i64) -> bool { - tl::is_live(id) -} - -/// `server.address()` for a turnloop-backed listener. -pub(crate) fn local_endpoint(id: i64) -> Option { - tl::local_address(id) -} - -// ── Completion sink ───────────────────────────────────────────────────────── - -extern "C" fn sink(completion: *const tl::NetCompletion) { - if completion.is_null() { - return; - } - // SAFETY: the runtime passes a live completion for the duration of the - // call, which is this function's body. - let c = unsafe { &*completion }; - match c.kind { - tl::NET_CONNECT => on_connect(c.id), - tl::NET_ACCEPT => on_accept(c.id, c.conn), - // SAFETY: same call; the pooled lease outlives it. - tl::NET_DATA => on_data(c.id, unsafe { c.bytes() }), - tl::NET_EOF => on_eof(c.id), - tl::NET_WROTE => on_wrote(c.id, c.user, c.len, c.queued), - tl::NET_SHUTDOWN => on_shutdown(c.id, c.user), - tl::NET_CLOSED => on_closed(c.id), - tl::NET_TIMER => on_timer(c.id), - tl::NET_ERROR => { - // SAFETY: same call; both point at `'static` string data. - let (code, syscall) = unsafe { (c.code(), c.syscall()) }; - on_error(c.id, c.user, code, syscall, c.terminal != 0); - } - _ => {} - } -} - -fn on_connect(id: i64) { - let local = tl::local_address(id); - let peer = tl::peer_address(id); - if let Ok(mut sockets) = statics::sockets().lock() { - if let Some(s) = sockets.get_mut(&id) { - s.is_open = true; - s.local_addr = local.as_ref().and_then(endpoint_to_addr); - s.remote_addr = peer.as_ref().and_then(endpoint_to_addr); - } - } - let local_server = with_aux(id, |a| a.local_server.take()); - if let Some((servername, verify, config)) = with_aux(id, |a| a.direct_tls.take()) { - if let Err(message) = - crate::turnloop_tls_io::begin_client_upgrade(id, servername, verify, config, None) - { - server_state::cancel_pending_connection(id); - push_event(PendingNetEvent::Error(id, message)); - destroy(id); - return; - } - replay_held_tls(id); - } - // #10465: clear the connect-phase flags at the same tick the JS 'connect' - // event is emitted, so a listener observing the socket sees Node's state. - // Both tokio connect paths (lib.rs) set all three together; this one set - // only `is_open`, so `connecting` stayed true forever and `readyState` - // (lifecycle.rs:275 returns "opening" whenever `connecting`) never left - // "opening" for the socket's whole connected life, while `pending` - // (keyed on `has_opened`, lifecycle.rs:144) never cleared. A driver that - // waits for `readyState === "open"` or guards on `!connecting` before - // writing therefore never proceeds. - // - // Deliberately here rather than in the `is_open` block above: the - // direct-TLS branch can fail and return early, and that socket is being - // destroyed, so it must NOT be recorded as opened. - // - // DO NOT "fix" this to wait for the TLS handshake. It looks early — the - // flags clear while a direct-TLS upgrade is still in flight — but it is - // what Node does: a TLS socket's underlying connection completes at the - // TCP level, which is when `'connect'` fires and `connecting` goes false, - // and the handshake is signalled separately by `'secureConnect'`. The - // deleted tokio path held `connecting` true until the transport INCLUDING - // TLS was established; that was the deviation, not this. Nothing - // pins it yet — the parity fixture is plain-socket only, so both timings - // pass today (see #11056). - if let Ok(mut sockets) = statics::sockets().lock() { - if let Some(s) = sockets.get_mut(&id) { - s.has_opened = true; - s.connecting = false; - } - } - push_event(PendingNetEvent::Connect(id, local_server)); - start_reading(id); -} - -/// Encrypt and submit what a `tls.connect` socket was given while connecting, -/// in order, now that its session exists (`Aux::held_tls_writes`). -fn replay_held_tls(id: i64) { - let (writes, end) = with_aux(id, |a| { - ( - std::mem::take(&mut a.held_tls_writes), - a.held_tls_end.take(), - ) - }); - for (bytes, completion) in writes { - match crate::turnloop_tls_io::write(id, &bytes, completion) { - Ok(queued) => { - if let Ok(mut sockets) = statics::sockets().lock() { - if let Some(s) = sockets.get_mut(&id) { - s.bytes_queued = queued as u64 + s.cork.bytes.len() as u64; - } - } - } - Err(message) => { - submission_failed(id, completion, message); - return; - } - } - } - if let Some(completion) = end { - if let Err(message) = crate::turnloop_tls_io::shutdown(id, completion) { - submission_failed(id, completion, message); - } - } -} - -fn endpoint_to_addr(endpoint: &tl::Endpoint) -> Option { - endpoint - .address - .parse::() - .ok() - .map(|ip| std::net::SocketAddr::new(ip, endpoint.port)) -} - -fn on_accept(server_id: i64, socket_id: i64) { - if socket_id == 0 { - server_state::cancel_pending_connection(server_id); - return; - } - let local = tl::local_address(socket_id) - .as_ref() - .and_then(endpoint_to_addr); - let peer = tl::peer_address(socket_id) - .as_ref() - .and_then(endpoint_to_addr); - if let Some(info) = server_state::should_drop_accepted(server_id, local, peer) { - push_event(PendingNetEvent::ServerDrop(server_id, info)); - let _ = tl::close(socket_id); - return; - } - crate::register_turnloop_socket(server_id, socket_id, local, peer); - push_event(PendingNetEvent::ServerConnection( - server_id, socket_id, false, - )); - start_reading(socket_id); -} - -fn on_data(id: i64, bytes: &[u8]) { - if bytes.is_empty() { - return; - } - // A TLS socket receives ciphertext. Decrypting here, inside the dispatch - // call on the loop thread, keeps the rule that no JS value and no heap - // pointer ever reaches the driver: plaintext is an owned `Vec` that the - // plaintext path below copies into this crate's read pool exactly as it - // would a cleartext read. - if let Some(received) = crate::turnloop_tls_io::receive(id, bytes) { - if !received.plaintext.is_empty() { - deliver_plaintext(id, &received.plaintext); - } - if received.peer_closed { - on_eof(id); - } - return; - } - deliver_plaintext(id, bytes); -} - -fn deliver_plaintext(id: i64, bytes: &[u8]) { - if let Ok(mut sockets) = statics::sockets().lock() { - if let Some(s) = sockets.get_mut(&id) { - s.bytes_read += bytes.len() as u64; - } - } - // Copy out of turnloop's pooled lease into this crate's own read pool, so - // the `Bytes` handed to the pump has the same ownership and lifetime the - // tokio path gave it and the lease can go straight back. - let mut buf = buffer_pool::checkout(); - buf.extend_from_slice(bytes); - let chunk = buf.split_to(bytes.len()).freeze(); - buffer_pool::checkin(buf); - if !raw_bridge::route_data(id, &chunk) { - push_event(PendingNetEvent::Data(id, chunk)); - } -} - -fn on_eof(id: i64) { - if with_aux(id, |a| { - a.closed_emitted || a.errored || std::mem::replace(&mut a.eof_emitted, true) - }) { - return; - } - if raw_bridge::mark_terminal(id, None) { - // Raw (`http.Agent`) consumers own their own terminal state. - destroy(id); - return; - } - if awaiting_connection_callback(id) { - with_aux(id, |a| a.deferred_eof = true); - return; - } - with_aux(id, |a| a.read_ended = true); - push_event(PendingNetEvent::End(id)); -} - -/// Whether this is an accepted socket whose `'connection'` callback has not -/// been dispatched yet. -fn awaiting_connection_callback(id: i64) -> bool { - statics::sockets() - .lock() - .map(|sockets| { - sockets - .get(&id) - .is_some_and(|s| s.server_id.is_some() && !s.server_connection_active) - }) - .unwrap_or(false) -} - -/// Deliver an `'end'` that was held for the `'connection'` callback. -/// -/// Called from `release_connection_callback`, which holds the socket registry -/// lock — so this must not take it. -fn release_deferred_eof(id: i64) { - if with_aux(id, |a| std::mem::replace(&mut a.deferred_eof, false)) { - with_aux(id, |a| a.read_ended = true); - push_event(PendingNetEvent::End(id)); - } -} - -fn on_shutdown(id: i64, user: u64) { - // Every byte queued ahead of the shutdown has left: turnloop orders a - // handle's writes before its shutdown. - let extra = with_aux(id, |a| { - a.shutdown_done = true; - std::mem::take(&mut a.extra_end_users) - }); - push_event(PendingNetEvent::ShutdownComplete(id, user, None)); - for user in extra { - push_event(PendingNetEvent::ShutdownComplete(id, user, None)); - } - if with_aux(id, |a| { - std::mem::replace(&mut a.close_after_shutdown, false) - }) { - destroy(id); - } -} - -fn on_wrote(id: i64, user: u64, len: usize, queued: usize) { - // On a TLS socket `len` is ciphertext and `user` is always zero (the - // ciphertext submission is not an application write). The layer maps the - // acknowledgement back to the application writes it covers, so - // `bytesWritten` stays plaintext and `write(chunk, cb)` still fires when - // the bytes have left. - if let Some(completed) = crate::turnloop_tls_io::wrote(id, len) { - // `queued` is the driver's ciphertext count; `writableLength` is the - // application bytes still outstanding, which the layer tracks. - let _ = queued; - let outstanding = crate::turnloop_tls_io::outstanding_plaintext(id); - let mut drain = false; - if let Ok(mut sockets) = statics::sockets().lock() { - if let Some(s) = sockets.get_mut(&id) { - s.bytes_queued = outstanding as u64 + s.cork.bytes.len() as u64; - for (_, plain_len) in &completed { - s.bytes_written += *plain_len as u64; - } - drain = crate::lifecycle::take_drain(s); - } - } - if drain { - push_event(PendingNetEvent::Drain(id)); - } - for (user, _) in completed { - if user != 0 { - push_event(PendingNetEvent::WriteComplete(id, user, None)); - } - } - return; - } - let mut drain = false; - if let Ok(mut sockets) = statics::sockets().lock() { - if let Some(s) = sockets.get_mut(&id) { - s.bytes_written += len as u64; - s.bytes_queued = queued as u64 + s.cork.bytes.len() as u64; - drain = crate::lifecycle::take_drain(s); - } - } - // #11111 — Node's `afterWrite` emits `'drain'` BEFORE the callbacks of - // the writes that just completed. - if drain { - push_event(PendingNetEvent::Drain(id)); - } - if user != 0 { - push_event(PendingNetEvent::WriteComplete(id, user, None)); - } -} - -fn on_closed(id: i64) { - let is_server = statics::servers() - .lock() - .map(|servers| servers.contains_key(&id)) - .unwrap_or(false); - if is_server { - forget_aux(id); - if let Ok(mut servers) = statics::servers().lock() { - if let Some(server) = servers.get_mut(&id) { - server.listening = false; - } - } - push_event(PendingNetEvent::ServerClose(id)); - return; - } - emit_close_once(id); -} - -fn on_error(id: i64, user: u64, code: Option<&str>, syscall: Option<&str>, terminal: bool) { - let message = match (syscall, code) { - (Some(syscall), Some(code)) if !syscall.is_empty() => format!("{syscall} {code}"), - (_, Some(code)) => code.to_string(), - _ => "UNKNOWN".to_string(), - }; - let is_server = statics::servers() - .lock() - .map(|servers| servers.contains_key(&id)) - .unwrap_or(false); - if is_server { - push_event(PendingNetEvent::ServerError(id, message)); - // A transient accept failure (EMFILE, a peer that reset between the - // SYN and the accept) does not end the listener — the tokio accept - // loop deliberately kept going on one too, and Node does the same. - if terminal { - close_server(id); - } - return; - } - if user != 0 { - push_event(PendingNetEvent::WriteComplete( - id, - user, - Some(message.clone()), - )); - } - // One `'error'` per socket, as the tokio task gave by breaking its loop. - // `'close'` is NOT suppressed with it: Node emits close after error, and - // it arrives from the driver's own terminal `Closed`. - if !with_aux(id, |a| std::mem::replace(&mut a.errored, true)) - && !raw_bridge::mark_terminal(id, Some(message.clone())) - { - push_event(PendingNetEvent::Error(id, message)); - } - destroy(id); -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn the_commands_with_no_driver_equivalent_succeed_rather_than_fall_through() { - // A command that returned `Err` here would be reported to JS as a - // socket error. The server-ready marker has no turnloop submission and - // must stay a silent no-op. - let mut queued = None; - assert!(super::command(-1, SocketCommand::ServerConnectionReady, &mut queued).is_ok()); - assert_eq!(queued, None, "a non-write never reports a queue length"); - } - - #[test] - fn the_subsystem_slot_is_within_the_runtime_registry() { - // `register_sink` refuses an out-of-range slot; a binding that picked - // one would register nothing and look like a socket with no events. - assert!((SUBSYSTEM as usize) < 4); - } -} diff --git a/crates/perry-ext-net/src/turnloop_tls.rs b/crates/perry-ext-net/src/turnloop_tls.rs index 6e4f231372..c1dcf5ba33 100644 --- a/crates/perry-ext-net/src/turnloop_tls.rs +++ b/crates/perry-ext-net/src/turnloop_tls.rs @@ -141,6 +141,23 @@ pub struct TlsSession { } impl TlsSession { + /// Native allocations directly retained by the sans-I/O session. Shared + /// rustls configuration belongs to its configuration owner, not each link. + pub fn retained_bytes(&self) -> usize { + let buffers = &self.buffers; + let connection = match &self.session { + Session::Client(conn) => std::mem::size_of_val(&**conn), + Session::Server(conn) => std::mem::size_of_val(&**conn), + }; + connection + + buffers.input.capacity() + + buffers.scratch.capacity() + + buffers.out.capacity() + + buffers.plain.capacity() + + buffers.deferred.capacity() + + buffers.failed.as_ref().map_or(0, String::capacity) + } + /// A client session for `socket.upgradeToTLS` / `tls.connect`. pub fn client( config: Arc, @@ -215,6 +232,26 @@ impl TlsSession { } } + /// Negotiated suite names used by the ordinary Socket inspection surface. + pub fn cipher_names(&self) -> Option<(String, String)> { + let suite = match &self.session { + Session::Client(c) => c.negotiated_cipher_suite(), + Session::Server(s) => s.negotiated_cipher_suite(), + }?; + let standard = format!("{:?}", suite.suite()).replace("TLS13_", "TLS_"); + let openssl = match standard.as_str() { + "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256" => "ECDHE-RSA-AES128-GCM-SHA256", + "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384" => "ECDHE-RSA-AES256-GCM-SHA384", + "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256" => "ECDHE-ECDSA-AES128-GCM-SHA256", + "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384" => "ECDHE-ECDSA-AES256-GCM-SHA384", + "TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256" => "ECDHE-RSA-CHACHA20-POLY1305", + "TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256" => "ECDHE-ECDSA-CHACHA20-POLY1305", + other => other, + } + .to_owned(); + Some((openssl, standard)) + } + pub fn peer_closed(&self) -> bool { self.buffers.peer_closed } @@ -473,3 +510,17 @@ pub fn server_name(name: &str) -> Result, pub fn node_code(error: &rustls::Error) -> &'static str { turnloop_tls::node_error_code(error) } + +/// Construct a pure client codec for an independently owned phase-C transport. +/// The caller stores this session in its existing connection record and owns +/// all driver submissions; this function has no transport or JS side effects. +pub fn open_client_session( + servername: &str, + verify: bool, + alpn: Vec>, + ca: Vec>, +) -> Result { + let config = crate::tls::TlsClientConfigData::for_alpn(alpn, ca); + let config = crate::tls::build_client_config(verify, Some(&config))?; + TlsSession::client(config, server_name(servername)?) +} diff --git a/crates/perry-ext-net/src/turnloop_tls_io.rs b/crates/perry-ext-net/src/turnloop_tls_io.rs deleted file mode 100644 index 03ef51f885..0000000000 --- a/crates/perry-ext-net/src/turnloop_tls_io.rs +++ /dev/null @@ -1,466 +0,0 @@ -//! TLS on a turnloop socket: the per-socket layer between the P1 completion -//! sink and [`crate::turnloop_tls::TlsSession`] (P5). -//! -//! P1 kept every TLS-upgradable socket on tokio because `socket.upgradeToTLS` -//! hands a live `TcpStream` to `tokio_rustls`. Running the rustls state -//! machine *above* the turnloop handle removes the need to move a descriptor -//! at all: the handle keeps carrying bytes and a session is installed on it -//! mid-stream, which is exactly PostgreSQL's `SSLRequest` shape -//! (`test-files/test_net_upgrade_tls.ts`). -//! -//! # Write accounting -//! -//! A caller writes *plaintext*; turnloop reports *ciphertext* written. Those -//! are different byte counts and the mapping is not one-to-one — a write -//! issued during the handshake is buffered by rustls and encrypted later, and -//! one flush can carry several application writes plus handshake records. -//! -//! So each application write records the total ciphertext offset at which its -//! plaintext had been encrypted (its *mark*), and a `NET_WROTE` completion -//! advances a running acknowledged-ciphertext counter. A write's callback -//! fires when the counter reaches its mark. Writes that the handshake has not -//! encrypted yet carry no mark and simply wait. This is what makes -//! `socket.write(chunk, cb)` on an upgraded socket keep Node's "cb fires when -//! the bytes have left" contract. -//! -//! # GC -//! -//! The layer holds owned `Vec`s and one [`JsNativeAsyncCompletion`] token. -//! The token is the *only* JS-visible thing here, and it is the runtime's own -//! pinned, root-scanned handle to the promise (#9552) rather than a bare -//! `*mut Promise` cached in a side table — which is the shape -//! `scripts/gc_runtime_root_holders.py` exists to catch. - -use std::collections::{HashMap, VecDeque}; -use std::sync::{Arc, Mutex, OnceLock}; - -use perry_ffi::turnloop_net as tl; -use perry_ffi::JsNativeAsyncCompletion; - -use crate::turnloop_tls::TlsSession; -use crate::{push_event, PendingNetEvent, TlsClientConfigData}; - -/// One application write awaiting its ciphertext acknowledgement. -struct PendingWrite { - /// The caller's completion token; zero means no callback. - user: u64, - /// Plaintext bytes the caller handed over, for `bytesWritten`. - plain_len: usize, - /// Total ciphertext offset after this write's plaintext was encrypted. - /// `None` while the handshake has not encrypted it yet. - mark: Option, -} - -/// The TLS layer installed on one turnloop socket. -struct Layer { - session: TlsSession, - /// Pending `socket.upgradeToTLS()` promise, settled on handshake outcome. - token: Option, - servername: String, - verify: bool, - config: TlsClientConfigData, - cipher_written: u64, - cipher_acked: u64, - pending: VecDeque, - /// A `socket.end()` seen before the handshake finished; the write-side - /// shutdown runs once `close_notify` has been encrypted. - pending_shutdown: Option, - secure_emitted: bool, - /// The application asked to close, or the peer already did. A rustls - /// failure *after* that is teardown noise — the records that follow a - /// `close_notify` on a socket nobody is reading any more — and Node emits - /// no `'error'` for it. Before it, a failure is the real thing. - closing: bool, -} - -fn layers() -> &'static Mutex> { - static LAYERS: OnceLock>> = OnceLock::new(); - LAYERS.get_or_init(|| Mutex::new(HashMap::new())) -} - -fn with_layer(id: i64, f: impl FnOnce(&mut Layer) -> R) -> Option { - let mut map = layers().lock().unwrap_or_else(|e| e.into_inner()); - map.get_mut(&id).map(f) -} - -/// Whether this socket carries TLS. -pub fn installed(id: i64) -> bool { - layers() - .lock() - .unwrap_or_else(|e| e.into_inner()) - .contains_key(&id) -} - -/// Drop the layer; called from the socket's terminal path. -pub fn forget(id: i64) { - let layer = layers() - .lock() - .unwrap_or_else(|e| e.into_inner()) - .remove(&id); - if let Some(mut layer) = layer { - // An upgrade that never completed must not leave its promise pending - // for the life of the process. - if let Some(token) = layer.token.take() { - token.reject_string("socket closed before the TLS handshake completed"); - } - } -} - -/// Install a client session and send the first handshake flight. -/// -/// `token` settles when the handshake does; `None` is the `tls.connect` shape, -/// where JS learns the outcome from `'secureConnect'` / `'error'`. -pub(crate) fn begin_client_upgrade( - id: i64, - servername: String, - verify: bool, - config: TlsClientConfigData, - token: Option, -) -> Result<(), String> { - // Every early return settles `token`, so a caller that handed one over - // never has to take it back to reject it. - macro_rules! refuse { - ($message:expr) => {{ - let message: String = $message; - if let Some(token) = token { - token.reject_string(&message); - } - return Err(message); - }}; - } - if installed(id) { - refuse!("socket is already TLS".to_string()); - } - if !tl::is_live(id) { - refuse!("socket is closed".to_string()); - } - let client_config = match crate::tls::build_client_config(verify, Some(&config)) { - Ok(config) => config, - Err(message) => refuse!(message), - }; - let name = match crate::turnloop_tls::server_name(&servername) { - Ok(name) => name, - Err(message) => refuse!(message), - }; - let session = match TlsSession::client(client_config, name) { - Ok(session) => session, - Err(message) => refuse!(message), - }; - layers().lock().unwrap_or_else(|e| e.into_inner()).insert( - id, - Layer { - session, - token, - servername, - verify, - config, - cipher_written: 0, - cipher_acked: 0, - pending: VecDeque::new(), - pending_shutdown: None, - secure_emitted: false, - closing: false, - }, - ); - // Produce and send the ClientHello. - pump_session(id); - Ok(()) -} - -/// Install a client session on a turnloop socket that is already connected. -/// -/// The client twin of [`install_server_session`], and the reason it exists -/// separately from [`begin_client_upgrade`]: that one takes this crate's own -/// `TlsClientConfigData` (built by reading JS values) and settles a -/// `JsNativeAsyncCompletion`, so it is reachable only from `net.Socket`'s -/// `upgradeToTLS`. A caller that owns a raw turnloop handle and has no promise -/// to settle — `http2.connect('https://…')`, which needs **ALPN** decided -/// before it knows whether it may speak HTTP/2 at all — could not use it. -/// -/// `alpn` is the protocol list to offer, in preference order; read the result -/// back with [`alpn_protocol`] once [`handshake_done`] is true. The outcome is -/// reported the way `tls.connect` reports it — a `'secureConnect'` event on -/// success, an `'error'` and a destroyed socket on failure — because that is -/// what this layer already does for a socket with no upgrade promise. -/// -/// The ClientHello goes out before this returns. -pub fn install_client_session( - id: i64, - servername: String, - verify: bool, - alpn: Vec>, - ca: Vec>, -) -> Result<(), String> { - begin_client_upgrade( - id, - servername, - verify, - crate::tls::TlsClientConfigData::for_alpn(alpn, ca), - None, - ) -} - -/// Install an already-built server session on an accepted connection. -/// -/// Used by the `https` / `http2` server paths, which build their -/// `rustls::ServerConfig` from Node's own option surface. -pub fn install_server_session( - id: i64, - config: Arc, -) -> Result<(), String> { - if installed(id) { - return Err("socket is already TLS".to_string()); - } - let session = TlsSession::server(config)?; - layers().lock().unwrap_or_else(|e| e.into_inner()).insert( - id, - Layer { - session, - token: None, - servername: String::new(), - verify: true, - config: TlsClientConfigData::default(), - cipher_written: 0, - cipher_acked: 0, - pending: VecDeque::new(), - pending_shutdown: None, - secure_emitted: false, - closing: false, - }, - ); - Ok(()) -} - -/// Negotiated ALPN protocol, once the handshake has completed. -pub fn alpn_protocol(id: i64) -> Option> { - with_layer(id, |l| l.session.alpn_protocol()).flatten() -} - -/// Whether the handshake on this socket has completed. -pub fn handshake_done(id: i64) -> bool { - with_layer(id, |l| !l.session.is_handshaking()).unwrap_or(false) -} - -/// Decrypted application data produced by one ciphertext delivery. -pub struct Received { - pub plaintext: Vec, - /// The peer sent `close_notify`: treat it as readable EOF. - pub peer_closed: bool, -} - -/// Feed ciphertext from a `NET_DATA` completion and take back plaintext. -/// -/// Returns `None` for a socket with no TLS layer, so the caller can keep its -/// plaintext path unchanged. -pub fn receive(id: i64, ciphertext: &[u8]) -> Option { - let installed = with_layer(id, |l| l.session.receive(ciphertext)).is_some(); - if !installed { - return None; - } - let out = pump_session(id); - Some(Received { - plaintext: out.plaintext, - peer_closed: out.peer_closed, - }) -} - -/// Encrypt and submit one application write. Returns the socket's -/// outstanding *application* bytes — Node's `writableLength` (#11111). -/// -/// Not the driver's ciphertext queue: while the handshake runs, rustls holds -/// the plaintext and nothing is queued on the wire yet, so a 64 KiB write -/// would have reported 0 and `write()` returned `true` with the whole chunk -/// still buffered. -pub fn write(id: i64, bytes: &[u8], user: u64) -> Result { - let known = with_layer(id, |l| { - l.session.write(bytes); - l.pending.push_back(PendingWrite { - user, - plain_len: bytes.len(), - mark: None, - }); - }) - .is_some(); - if !known { - return Err("socket is closed".to_string()); - } - pump_session(id); - Ok(outstanding_plaintext(id)) -} - -/// Application bytes written on this TLS socket and not yet acknowledged by -/// the driver: still inside the session (mid-handshake) or queued as -/// ciphertext. Retired by [`wrote`] exactly when `bytesWritten` grows. -pub fn outstanding_plaintext(id: i64) -> usize { - with_layer(id, |l| l.pending.iter().map(|w| w.plain_len).sum()).unwrap_or(0) -} - -/// `socket.end()` on a TLS socket: send `close_notify`, then shut the write -/// side down once it has been encrypted and queued. -pub fn shutdown(id: i64, user: u64) -> Result<(), String> { - let known = with_layer(id, |l| { - l.session.close_notify(); - l.pending_shutdown = Some(user); - l.closing = true; - }) - .is_some(); - if !known { - return Err("socket is closed".to_string()); - } - pump_session(id); - Ok(()) -} - -/// Account a `NET_WROTE` completion and return the application writes it -/// completed, as `(user token, plaintext length)` pairs in submission order. -pub fn wrote(id: i64, len: usize) -> Option> { - with_layer(id, |l| { - l.cipher_acked = l.cipher_acked.saturating_add(len as u64); - let mut done = Vec::new(); - while let Some(front) = l.pending.front() { - match front.mark { - Some(mark) if mark <= l.cipher_acked => { - let w = l.pending.pop_front().expect("checked"); - done.push((w.user, w.plain_len)); - } - _ => break, - } - } - done - }) -} - -struct Driven { - plaintext: Vec, - peer_closed: bool, -} - -/// Run the session, submit whatever ciphertext it produced, and report the -/// handshake and close transitions to JS. -/// -/// Named `pump_session` rather than the obvious `drive` on purpose: -/// `scripts/gc_runtime_root_holders.py` resolves function names ACROSS crates, -/// and `perry-runtime`'s `gc/roots/stack_maps_walker_agreement.rs` has its own -/// `fn drive`. A same-named function in a scanner-registering crate pulled that -/// file's body into the reachable set and silently flipped its `PROBE` holder -/// to COVERED, invalidating someone else's frontier entry (P1's report records -/// the same coincidence from the other direction). -fn pump_session(id: i64) -> Driven { - let mut out = Driven { - plaintext: Vec::new(), - peer_closed: false, - }; - let mut handshake_done = false; - let mut failure: Option = None; - let mut ciphertext = Vec::new(); - let mut shutdown_user: Option = None; - let mut closing = false; - - let present = with_layer(id, |l| { - let progress = l.session.pump(); - handshake_done = progress.handshake_done; - out.peer_closed = progress.peer_closed; - failure = l.session.failure().map(str::to_string); - out.plaintext = l.session.take_plaintext(); - ciphertext = l.session.take_output(); - if !ciphertext.is_empty() { - l.cipher_written = l.cipher_written.saturating_add(ciphertext.len() as u64); - // Everything the session had buffered has now been encrypted into - // this flush, in submission order, so every unmarked write is - // covered by it. - for pending in l.pending.iter_mut().filter(|p| p.mark.is_none()) { - pending.mark = Some(l.cipher_written); - } - } - if l.session.close_sent() { - shutdown_user = l.pending_shutdown.take(); - } - if out.peer_closed { - l.closing = true; - } - closing = l.closing; - }) - .is_some(); - if !present { - return out; - } - - if !ciphertext.is_empty() { - // `user` is zero: the ciphertext write is not an application write. - // Application callbacks are driven by the ciphertext acknowledgement - // accounting in `wrote`, not by this submission's own completion. - if let Err(err) = tl::write(id, &ciphertext, 0) { - fail(id, err.message(), closing); - return out; - } - } - if let Some(user) = shutdown_user { - // turnloop orders a handle's writes ahead of its shutdown, so the - // queued close_notify is on the wire before the FIN. - if let Err(err) = tl::shutdown(id, user) { - fail(id, err.message(), closing); - return out; - } - } - if let Some(message) = failure { - fail(id, message, closing); - return out; - } - if handshake_done { - finish_handshake(id); - } - out -} - -fn finish_handshake(id: i64) { - let (token, servername, verify, config, already) = { - let mut map = layers().lock().unwrap_or_else(|e| e.into_inner()); - let Some(layer) = map.get_mut(&id) else { - return; - }; - let already = std::mem::replace(&mut layer.secure_emitted, true); - ( - layer.token.take(), - layer.servername.clone(), - layer.verify, - layer.config.clone(), - already, - ) - }; - if already { - return; - } - // Record the handshake facts (`socket.authorized`, ALPN, peer certificate) - // on the same JS-visible surface the tokio path wrote. - let recorded = with_layer(id, |l| { - crate::tls::HandshakeFacts::from_session(&l.session, &servername, verify, Some(&config)) - }); - if let Some(facts) = recorded { - facts.publish(id); - } - if let Some(token) = token { - token.resolve_undefined(); - } - push_event(PendingNetEvent::SecureConnect(id)); -} - -/// Report a TLS failure and tear the socket down. -/// -/// `closing` suppresses the JS `'error'`: once the application has asked to -/// close — or the peer has sent `close_notify` — a rustls failure is teardown -/// noise on a socket nobody is reading, and Node emits nothing for it. The -/// pending upgrade promise is still settled, because a caller awaiting it must -/// not be left hanging by a close that raced the handshake. -fn fail(id: i64, message: String, closing: bool) { - let token = { - let mut map = layers().lock().unwrap_or_else(|e| e.into_inner()); - map.get_mut(&id).and_then(|l| l.token.take()) - }; - if let Some(token) = token { - token.reject_string(&message); - } - if !closing { - push_event(PendingNetEvent::Error(id, message)); - } - crate::turnloop_io::destroy(id); -} diff --git a/crates/perry-ffi/src/agent_post.rs b/crates/perry-ffi/src/agent_post.rs index 7045724a4e..b1395c20ea 100644 --- a/crates/perry-ffi/src/agent_post.rs +++ b/crates/perry-ffi/src/agent_post.rs @@ -47,6 +47,19 @@ extern "C" { fn js_perry_agent_post(run: Option, ctx: *mut c_void) -> i32; fn js_perry_agent_post_dispatched() -> u64; fn js_perry_agent_current() -> u64; + fn js_perry_agent_register_retire_hook(hook: extern "C" fn(u64)); +} + +/// Release a binding's logical ownership edges when an agent retires. This +/// uses the runtime's existing hook list, not a connection registry. +/// The hook must neither run JS nor touch the dying agent's driver. +pub fn register_retire_hook(hook: extern "C" fn(u64)) { + #[cfg(any(not(test), feature = "runtime-link"))] + unsafe { + js_perry_agent_register_retire_hook(hook) + } + #[cfg(all(test, not(feature = "runtime-link")))] + let _ = hook; } /// The agent the calling thread acts for: its own id on a `worker_threads` diff --git a/crates/perry-ffi/src/native_payload_links.rs b/crates/perry-ffi/src/native_payload_links.rs index 6bb5a40f51..b3fd591aa1 100644 --- a/crates/perry-ffi/src/native_payload_links.rs +++ b/crates/perry-ffi/src/native_payload_links.rs @@ -120,3 +120,232 @@ pub fn js_state(value: f64, family: &'static PayloadFamily, create: bool) -> f64 assert!(abi_matches(), "native payload ABI mismatch"); unsafe { js_perry_payload_js_state(value, family, create as i32) } } + +/// Why a reopen did not install a payload. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum AttachMiss { + /// Foreign receiver or descriptor. + Foreign, + /// The cell still holds a payload. + Open, + /// A native call is releasing it. + Closing, + /// The cell was finalized. + Finalized, +} + +fn attach_code(rc: i32) -> Result<(), AttachMiss> { + match rc { + 0 => Ok(()), + 5 => Err(AttachMiss::Open), + 6 => Err(AttachMiss::Closing), + 7 => Err(AttachMiss::Finalized), + _ => Err(AttachMiss::Foreign), + } +} + +fn miss_code(code: i32) -> PayloadMiss { + if code == -2 { + PayloadMiss::Closed + } else { + PayloadMiss::Foreign + } +} + +extern "C" { + fn js_perry_payload_link_ptr(link: usize, family: *const PayloadFamily, miss: *mut i32) + -> *mut c_void; + fn js_perry_payload_link_lifecycle(link: usize, family: *const PayloadFamily) -> i32; + fn js_perry_payload_link_close(link: usize, family: *const PayloadFamily) -> i32; + fn js_perry_payload_link_reopen( + link: usize, + family: *const PayloadFamily, + resource: *mut c_void, + bytes: usize, + ) -> i32; + fn js_perry_payload_link_js_state(link: usize, family: *const PayloadFamily, create: i32) + -> f64; + fn js_perry_payload_link_external_bytes(link: usize, family: *const PayloadFamily, bytes: usize); + fn js_perry_payload_receiver_link( + value: f64, + family: *const PayloadFamily, + miss: *mut i32, + ) -> usize; + fn js_perry_payload_proto_getter( + proto: *mut c_void, + name: *const u8, + name_len: usize, + info: *const crate::JsFunctionInfo, + ); + fn js_perry_payload_proto_data( + proto: *mut c_void, + name: *const u8, + name_len: usize, + value: f64, + flags: u32, + ); + fn js_perry_payload_proto_inherit(proto: *mut c_void, parent: f64); +} + +impl PayloadFamily { + /// Set the constructor's `length`. + pub const fn with_constructor_length(mut self, length: u32) -> Self { + self.constructor_length = length; + self + } +} + +/// Typed access with the miss kind: `Foreign` for another family, `Closed` +/// for a released or finalized cell. +/// # Safety +/// T is the descriptor's type. No allocation or JS while the reference lives. +pub unsafe fn payload_mut<'a, T>( + value: f64, + family: &'static PayloadFamily, +) -> Result<&'a mut T, PayloadMiss> { + match get_mut::(value, family) { + Some(payload) => Ok(payload), + None => match lifecycle(value, family) { + Err(PayloadMiss::Foreign) => Err(PayloadMiss::Foreign), + _ => Err(PayloadMiss::Closed), + }, + } +} + +/// Allocate on the family's prototype in `module` (empty for an internal +/// family with no export), then define `own` fields in order. +/// # Safety +/// T is the descriptor's payload type. +pub unsafe fn alloc_in( + family: &'static PayloadFamily, + module: &str, + payload: T, + bytes: usize, + own_fields: &[(&str, f64)], +) -> f64 { + let proto = prototype(family, module); + let value = alloc(family, payload, proto, bytes); + for &(key, field) in own_fields { + own(value, key, field); + } + value +} + +/// The link of a receiver of this family (direct or subclass), OPEN or CLOSED. +pub fn receiver_link(value: f64, family: &'static PayloadFamily) -> Result { + assert!(abi_matches(), "native payload ABI mismatch"); + let mut miss = 0; + match unsafe { js_perry_payload_receiver_link(value, family, &mut miss) } { + 0 => Err(miss_code(miss)), + raw => Ok(OwnerLink(raw)), + } +} + +/// The OPEN payload behind a link. +/// # Safety +/// A rooted owner or an outstanding ref keeps the cell alive. T is the +/// family's payload type. End the borrow before JS, release or reopen. +pub unsafe fn link_payload_ptr( + link: OwnerLink, + family: &'static PayloadFamily, +) -> Result<*mut T, PayloadMiss> { + let mut miss = 0; + let ptr = js_perry_payload_link_ptr(link.0, family, &mut miss); + if ptr.is_null() { + Err(miss_code(miss)) + } else { + Ok(ptr.cast()) + } +} + +/// Lifecycle of the cell behind a link. +/// # Safety +/// As [`link_payload_ptr`]. +pub unsafe fn link_lifecycle( + link: OwnerLink, + family: &'static PayloadFamily, +) -> Result { + match js_perry_payload_link_lifecycle(link.0, family) { + 1 => Ok(Lifecycle::Open), + 2 => Ok(Lifecycle::Closing), + 3 => Ok(Lifecycle::Closed), + code => Err(miss_code(code)), + } +} + +/// Release the payload behind a link; the cell and owner remain. +/// # Safety +/// As [`link_payload_ptr`]. +pub unsafe fn close_link(link: OwnerLink, family: &'static PayloadFamily) -> bool { + js_perry_payload_link_close(link.0, family) == 0 +} + +/// Reopen the CLOSED cell behind a link with a new payload. +/// # Safety +/// As [`link_payload_ptr`]; T is the family's payload type. +pub unsafe fn attach_link( + link: OwnerLink, + family: &'static PayloadFamily, + payload: T, + bytes: usize, +) -> Result<(), AttachMiss> { + let ptr = Box::into_raw(Box::new(payload)); + let result = attach_code(js_perry_payload_link_reopen(link.0, family, ptr.cast(), bytes)); + if result.is_err() { + drop(Box::from_raw(ptr)); + } + result +} + +/// Attach a payload to a source subclass instance (its `super()` call). +/// # Safety +/// T is the family's payload type. +pub unsafe fn attach_to_object( + value: f64, + family: &'static PayloadFamily, + payload: T, + bytes: usize, +) -> bool { + attach(value, family, payload, bytes) +} + +/// The owner's traced JS-state object, through its link. +/// # Safety +/// As [`link_payload_ptr`]. +pub unsafe fn link_js_state(link: OwnerLink, family: &'static PayloadFamily, create: bool) -> f64 { + js_perry_payload_link_js_state(link.0, family, create as i32) +} + +/// Restate the native bytes the payload behind a link retains. +/// # Safety +/// As [`link_payload_ptr`]. +pub unsafe fn link_set_external_bytes(link: OwnerLink, family: &'static PayloadFamily, bytes: usize) { + js_perry_payload_link_external_bytes(link.0, family, bytes); +} + +/// The prototype builder handed to a family's installer. +pub struct PayloadPrototype(*mut c_void); +impl PayloadPrototype { + /// # Safety + /// raw is the prototype handed to the installer. + pub unsafe fn from_raw(raw: *mut c_void) -> Self { + Self(raw) + } + /// A method. + pub fn method(&mut self, name: &str, info: &'static crate::JsFunctionInfo, arity: u32) { + unsafe { prototype_method(self.0, name, info, arity) } + } + /// An accessor (getter only). + pub fn getter(&mut self, name: &str, info: &'static crate::JsFunctionInfo) { + unsafe { js_perry_payload_proto_getter(self.0, name.as_ptr(), name.len(), info) } + } + /// A data property. + pub fn data(&mut self, name: &str, value: f64, writable: bool, enumerable: bool, configurable: bool) { + let flags = u32::from(writable) | u32::from(enumerable) << 1 | u32::from(configurable) << 2; + unsafe { js_perry_payload_proto_data(self.0, name.as_ptr(), name.len(), value, flags) } + } + /// Link to a parent prototype. + pub fn inherit(&mut self, parent: f64) { + unsafe { js_perry_payload_proto_inherit(self.0, parent) } + } +} diff --git a/crates/perry-ffi/src/turnloop_net.rs b/crates/perry-ffi/src/turnloop_net.rs index 0c5fc5d528..74cab32c0d 100644 --- a/crates/perry-ffi/src/turnloop_net.rs +++ b/crates/perry-ffi/src/turnloop_net.rs @@ -48,6 +48,14 @@ pub const NET_ERROR: i32 = 8; pub const NET_TIMER: i32 = 9; /// `NetCompletion::flags`: a link route's completion (`id` is an owner link). pub const NET_FLAG_LINK: i32 = 1; +/// A link `Closed` for an earlier handle of a reopened, OPEN cell. Emit only +/// the terminal event; do not mark the new transport closed. +pub const NET_FLAG_STALE: i32 = 2; +/// A link `Closed` lends its generational driver handle as four u32s +/// ([`NetCompletion::closed_handle_parts`]). +pub const NET_FLAG_HANDLE_PARTS: i32 = 4; +/// TLS plaintext, already decoded, on the socket's current link route. +pub const NET_FLAG_PLAINTEXT: i32 = 8; /// This module's view of the runtime's completion record. /// @@ -90,6 +98,22 @@ pub struct NetCompletion { } impl NetCompletion { + /// The closing driver handle's owner/key (low/high u32s), for callback + /// context kept in the owner's JS state. No id is minted. + /// + /// # Safety + /// Only during the sink call receiving this runtime completion. + pub unsafe fn closed_handle_parts(&self) -> Option<[u32; 4]> { + if self.kind != NET_CLOSED + || self.flags & NET_FLAG_HANDLE_PARTS == 0 + || self.data.is_null() + || self.len != std::mem::size_of::<[u32; 4]>() + { + return None; + } + Some(std::ptr::read_unaligned(self.data.cast::<[u32; 4]>())) + } + /// The payload this completion is for, on a link route. pub fn link(&self) -> Option { (self.flags & NET_FLAG_LINK != 0 && self.id != 0).then(|| { @@ -1662,3 +1686,144 @@ pub fn link_set_route( } ) } + +/// An ephemeral copy of the driver's generational handle or pending resolve, +/// for checking the incarnation after JS re-entry. Opaque native data: no JS +/// pointer, ref, id or ownership of the resource. +#[repr(C, align(8))] +#[derive(Clone, Copy)] +pub struct HandleSnapshot { + words: [std::mem::MaybeUninit; 4], +} + +#[cfg(any(not(test), feature = "runtime-link"))] +extern "C" { + fn js_perry_net_link_handle_parts( + core: *mut std::ffi::c_void, + link: usize, + out: *mut u32, + ) -> i32; + fn js_perry_net_link_snapshot_handle( + core: *mut std::ffi::c_void, + link: usize, + out: *mut std::ffi::c_void, + ) -> i32; + fn js_perry_net_link_handle_matches( + core: *mut std::ffi::c_void, + link: usize, + snapshot: *const std::ffi::c_void, + ) -> i32; + fn js_perry_net_link_retained_bytes(core: *mut std::ffi::c_void, link: usize) -> usize; + fn js_perry_net_link_dispatch_plaintext( + core: *mut std::ffi::c_void, + link: usize, + bytes: *const u8, + len: usize, + eof: i32, + err: *mut RawNetError, + ) -> i32; +} + +/// The installed handle as four u32s. +pub fn link_handle_parts(core: &mut TransportCore, link: OwnerLink) -> Option<[u32; 4]> { + runtime_call!( + { + let mut parts = [0u32; 4]; + (unsafe { js_perry_net_link_handle_parts(core.raw(), link.raw(), parts.as_mut_ptr()) } + != 0) + .then_some(parts) + }, + { + let _ = (core, link); + None + } + ) +} + +/// Snapshot the current handle (or pending resolve) before running JS. +pub fn link_snapshot_handle(core: &mut TransportCore, link: OwnerLink) -> Option { + runtime_call!( + { + let mut snapshot = HandleSnapshot { + words: [std::mem::MaybeUninit::uninit(); 4], + }; + (unsafe { + js_perry_net_link_snapshot_handle( + core.raw(), + link.raw(), + (&mut snapshot as *mut HandleSnapshot).cast(), + ) + } != 0) + .then_some(snapshot) + }, + { + let _ = (core, link); + None + } + ) +} + +/// Whether the freshly projected core still holds the snapshot's resource. +pub fn link_handle_matches( + core: &mut TransportCore, + link: OwnerLink, + snapshot: &HandleSnapshot, +) -> bool { + runtime_call!( + { + unsafe { + js_perry_net_link_handle_matches( + core.raw(), + link.raw(), + (snapshot as *const HandleSnapshot).cast(), + ) != 0 + } + }, + { + let _ = (core, link, snapshot); + false + } + ) +} + +/// Heap bytes the core owns, for the payload's external-bytes accounting. +pub fn link_retained_bytes(core: &mut TransportCore, link: OwnerLink) -> usize { + runtime_call!( + { unsafe { js_perry_net_link_retained_bytes(core.raw(), link.raw()) } }, + { + let _ = (core, link); + 0 + } + ) +} + +/// Deliver decoded TLS plaintext (or EOF) on the core's current route. +/// +/// # Safety +/// `core`/`link` name an OPEN payload whose owner is rooted. The sink may run +/// JS: re-project the core afterwards. +pub unsafe fn link_dispatch_plaintext( + core: *mut TransportCore, + link: OwnerLink, + bytes: &[u8], + eof: bool, +) -> Result<(), NetError> { + runtime_call!( + { + link_call(|err| { + js_perry_net_link_dispatch_plaintext( + core.cast(), + link.raw(), + bytes.as_ptr(), + bytes.len(), + i32::from(eof), + err, + ) + }) + }, + { + let _ = (core, link, bytes, eof); + Err(unavailable()) + } + ) +} diff --git a/crates/perry-runtime/Cargo.toml b/crates/perry-runtime/Cargo.toml index 89e7580c61..9e408cc1a3 100644 --- a/crates/perry-runtime/Cargo.toml +++ b/crates/perry-runtime/Cargo.toml @@ -16,6 +16,9 @@ workspace = true crate-type = ["rlib"] [features] +# Phase A acceptance census and fault hooks. Scalar counts only; compiled out +# of shipped builds. Ext-net's dev dependency enables the binding witnesses. +native-payload-test-census = [] # #10868 measurement: `PERRY_SHAPE_MINT_DIAG`, the per-mint cause / # key-list-identity / call-site / transition-cache-outcome census. # diff --git a/crates/perry-runtime/src/agent.rs b/crates/perry-runtime/src/agent.rs index ae865b96d3..56087a42eb 100644 --- a/crates/perry-runtime/src/agent.rs +++ b/crates/perry-runtime/src/agent.rs @@ -130,16 +130,31 @@ pub fn owns(owner: AgentId) -> bool { /// queues below, and need the same purge when that agent dies — but they cannot /// be named from here. A hook registers once per process and runs inside /// [`retire_agent`] with the dying agent's id. -static RETIRE_HOOKS: Mutex> = Mutex::new(Vec::new()); +#[derive(Clone, Copy)] +enum RetireHook { + Rust(fn(AgentId)), + Ffi(extern "C" fn(AgentId)), +} +static RETIRE_HOOKS: Mutex> = Mutex::new(Vec::new()); /// Register `hook` to run from [`retire_agent`]. Idempotent per function. pub fn register_retire_hook(hook: fn(AgentId)) { let mut hooks = RETIRE_HOOKS.lock().unwrap_or_else(PoisonError::into_inner); if !hooks .iter() - .any(|existing| std::ptr::fn_addr_eq(*existing, hook)) + .any(|existing| matches!(existing, RetireHook::Rust(existing) if std::ptr::fn_addr_eq(*existing, hook))) { - hooks.push(hook); + hooks.push(RetireHook::Rust(hook)); + } +} + +/// Native bindings share the existing retire-hook list. Hooks release their +/// logical JS edges while the dying heap still exists; no JS may run here. +#[no_mangle] +pub extern "C" fn js_perry_agent_register_retire_hook(hook: extern "C" fn(AgentId)) { + let mut hooks = RETIRE_HOOKS.lock().unwrap_or_else(PoisonError::into_inner); + if !hooks.iter().any(|existing| matches!(existing, RetireHook::Ffi(existing) if std::ptr::fn_addr_eq(*existing, hook))) { + hooks.push(RetireHook::Ffi(hook)); } } @@ -178,12 +193,15 @@ pub fn retire_agent(id: AgentId) { crate::timer::purge_agent_timers(id); crate::thread::purge_agent_thread_results(id); // Copied out so a hook may itself take locks without holding this one. - let hooks: Vec = RETIRE_HOOKS + let hooks: Vec = RETIRE_HOOKS .lock() .unwrap_or_else(PoisonError::into_inner) .clone(); for hook in hooks { - hook(id); + match hook { + RetireHook::Rust(hook) => hook(id), + RetireHook::Ffi(hook) => hook(id), + } } // Deliberately do NOT clear `CURRENT_AGENT`. Clearing it would make // `current_agent()` fall back to `PRIMARY_AGENT` for the rest of this @@ -197,6 +215,30 @@ pub fn retire_agent(id: AgentId) { mod tests { use super::*; + #[test] + fn ffi_retire_hooks_share_the_existing_once_per_agent_cleanup_list() { + use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; + static SEEN: AtomicU64 = AtomicU64::new(0); + static CALLS: AtomicUsize = AtomicUsize::new(0); + extern "C" fn hook(agent: AgentId) { + assert_eq!(current_agent(), agent); + SEEN.store(agent, Ordering::SeqCst); + CALLS.fetch_add(1, Ordering::SeqCst); + } + js_perry_agent_register_retire_hook(hook); + js_perry_agent_register_retire_hook(hook); + let before = CALLS.load(Ordering::SeqCst); + let agent = std::thread::spawn(|| { + let agent = enter_worker_agent(); + retire_agent(agent); + agent + }) + .join() + .unwrap(); + assert_eq!(SEEN.load(Ordering::SeqCst), agent); + assert_eq!(CALLS.load(Ordering::SeqCst), before + 1); + } + /// A plain thread that never claims a worker agent — the main thread, or a /// pump like Android's UI thread — resolves to the primary agent, so it /// keeps draining exactly the work the primary JS thread scheduled. This is diff --git a/crates/perry-runtime/src/async_hooks.rs b/crates/perry-runtime/src/async_hooks.rs index 8175e5dbcc..44d3cff154 100644 --- a/crates/perry-runtime/src/async_hooks.rs +++ b/crates/perry-runtime/src/async_hooks.rs @@ -25,6 +25,7 @@ use arg_values::{ throw_apply_not_function, trigger_id_from_options, validate_bind_callback, }; mod provider_ffi; +pub(crate) use provider_ffi::owned_provider_scope; pub use provider_ffi::{ defer_destroy_after_check_turns, js_async_hooks_provider_defer_destroy, js_async_hooks_provider_destroy, js_async_hooks_provider_enter, js_async_hooks_provider_init, diff --git a/crates/perry-runtime/src/async_hooks/provider_ffi.rs b/crates/perry-runtime/src/async_hooks/provider_ffi.rs index a8cf8e4919..b3cbdf5f78 100644 --- a/crates/perry-runtime/src/async_hooks/provider_ffi.rs +++ b/crates/perry-runtime/src/async_hooks/provider_ffi.rs @@ -5,6 +5,141 @@ use super::{ try_leave_resource_scope, AsyncResourceIds, RESOURCES, }; +/// A native provider whose metadata lifetime follows an ordinary GC object. +/// The binding holds this object in JS state; its Rust payload holds only the +/// existing async ids. No socket registry or native owner address is created. +/// +/// # Safety +/// type_ptr/type_len is UTF-8 valid for this call. MAX selects the current +/// execution id; every other trigger is passed through unchanged. +#[no_mangle] +pub unsafe extern "C" fn js_async_hooks_owned_provider_new( + type_ptr: *const u8, + type_len: usize, + trigger_async_id: u64, +) -> f64 { + if type_ptr.is_null() { + return super::TAG_UNDEFINED_F64; + } + let name = std::str::from_utf8_unchecked(std::slice::from_raw_parts(type_ptr, type_len)); + let trigger = if trigger_async_id == u64::MAX { + super::execution_async_id_u64() + } else { + trigger_async_id + }; + let scope = crate::gc::RuntimeHandleScope::new(); + // Match the existing native-provider resource's observable prototype. + let owner = scope.root_nanbox_f64(crate::value::js_nanbox_pointer( + crate::object::js_object_alloc_null_proto(0, 0) as i64, + )); + let payload = super::AsyncResourcePayload { + ids: AsyncResourceIds { + async_id: 0, + trigger_async_id: trigger, + }, + }; + if !crate::native_payload::attach_to_object( + owner.get_nanbox_f64(), + &super::ASYNC_RESOURCE_FAMILY, + payload, + std::mem::size_of::(), + ) { + return super::TAG_UNDEFINED_F64; + } + let ids = super::init_resource_metadata(name, owner.get_nanbox_f64(), true, trigger); + let raw = crate::value::js_nanbox_get_pointer(owner.get_nanbox_f64()); + if let Some(payload) = super::resource_payload(raw) { + payload.ids = ids; + } + let _ = super::swap_resource_value(ids.async_id, super::TAG_UNDEFINED_F64); + owner.get_nanbox_f64() +} + +/// Emit init after the binding has published the provider as an ordinary JS +/// edge and copied its ids into native fields. An init hook can then close or +/// reopen the owner without leaving work that mutates its old native state. +#[no_mangle] +pub extern "C" fn js_async_hooks_owned_provider_emit_init(resource: f64) { + let scope = crate::gc::RuntimeHandleScope::new(); + let resource = scope.root_nanbox_f64(resource); + let raw = crate::value::js_nanbox_get_pointer(resource.get_nanbox_f64()); + let Some(ids) = (unsafe { super::resource_payload(raw) }).map(|payload| payload.ids) else { + return; + }; + let name = RESOURCES + .lock() + .unwrap() + .get(&ids.async_id) + .map(|meta| meta.type_name.clone()); + if let Some(name) = name { + super::emit_init( + ids.async_id, + &name, + ids.trigger_async_id, + resource.get_nanbox_f64(), + ); + } +} + +/// Run a native event in an owned provider's scope, including temporary +/// executionAsyncResource publication. Restore context and that publication +/// before returning an exception to the outer native event boundary. +pub(crate) fn owned_provider_scope( + resource: f64, + callback: impl FnOnce() -> f64, +) -> Result { + let scope = crate::gc::RuntimeHandleScope::new(); + let owner = scope.root_nanbox_f64(resource); + if !crate::value::JSValue::from_bits(owner.get_nanbox_f64().to_bits()).is_pointer() { + return crate::exception::js_call_catching(callback); + } + let raw = crate::value::js_nanbox_get_pointer(owner.get_nanbox_f64()); + let Some(ids) = (unsafe { super::resource_payload(raw) }).map(|payload| payload.ids) else { + return crate::exception::js_call_catching(callback); + }; + let previous = scope.root_nanbox_f64(super::swap_resource_value( + ids.async_id, + owner.get_nanbox_f64(), + )); + let outcome = super::try_run_resource_scope(ids, callback); + // A returned exception/result may itself move while another native cleanup + // runs, so keep it in the same handle scope until publication is restored. + let (threw, value) = match outcome { + Ok(value) => (false, scope.root_nanbox_f64(value)), + Err(error) => (true, scope.root_nanbox_f64(error)), + }; + let _ = super::swap_resource_value(ids.async_id, previous.get_nanbox_f64()); + if threw { + Err(value.get_nanbox_f64()) + } else { + Ok(value.get_nanbox_f64()) + } +} + +/// End a JS-owned native provider after its terminal event. A hook exception +/// cannot retain its metadata; like other async-hook exceptions it is fatal. +#[no_mangle] +pub extern "C" fn js_async_hooks_owned_provider_destroy(resource: f64) { + let scope = crate::gc::RuntimeHandleScope::new(); + let owner = scope.root_nanbox_f64(resource); + if !crate::value::JSValue::from_bits(owner.get_nanbox_f64().to_bits()).is_pointer() { + return; + } + let raw = crate::value::js_nanbox_get_pointer(owner.get_nanbox_f64()); + let Some(id) = (unsafe { super::resource_payload(raw) }).map(|payload| payload.ids.async_id) + else { + return; + }; + let outcome = crate::exception::js_call_catching(|| { + destroy(id); + super::TAG_UNDEFINED_F64 + }); + if let Err(error) = outcome { + RESOURCES.lock().unwrap().remove(&id); + crate::exception::exit_on_uncaught(error); + } +} + extern "C" fn deferred_destroy_step( closure: *const crate::closure::ClosureHeader, _this: crate::closure::JsThis, diff --git a/crates/perry-runtime/src/async_hooks/test_support.rs b/crates/perry-runtime/src/async_hooks/test_support.rs index 2a6957b677..f0870f0373 100644 --- a/crates/perry-runtime/src/async_hooks/test_support.rs +++ b/crates/perry-runtime/src/async_hooks/test_support.rs @@ -65,6 +65,76 @@ pub(crate) fn test_async_hooks_scanner_snapshot() -> (usize, u64) { mod tests { use super::*; + #[test] + fn owned_native_provider_publishes_resource_and_restores_context_after_throw() { + reset_for_tests(); + let storage = crate::async_context::AsyncLocalStoragePayload::default(); + let token = storage.token(); + crate::async_context::enter_with(token, 11.0); + let scope = crate::gc::RuntimeHandleScope::new(); + let resource = scope.root_nanbox_f64(unsafe { + provider_ffi::js_async_hooks_owned_provider_new(b"TCPWRAP".as_ptr(), 7, u64::MAX) + }); + let raw = crate::value::js_nanbox_get_pointer(resource.get_nanbox_f64()); + let ids = unsafe { resource_payload(raw) }.unwrap().ids; + let metadata = RESOURCES.lock().unwrap(); + assert_eq!( + metadata.get(&ids.async_id).unwrap().resource.to_bits(), + TAG_UNDEFINED_F64.to_bits(), + "the existing async registry must not root the owning provider object" + ); + drop(metadata); + crate::async_context::enter_with(token, 22.0); + let outcome = provider_ffi::owned_provider_scope(resource.get_nanbox_f64(), || { + assert_eq!(execution_async_id_u64(), ids.async_id); + assert_eq!(crate::async_context::get_store(token), Some(11.0)); + assert_eq!( + js_async_hooks_execution_async_resource().to_bits(), + resource.get_nanbox_f64().to_bits() + ); + crate::exception::js_throw(41.0) + }); + assert_eq!(outcome.unwrap_err(), 41.0); + assert_eq!(execution_async_id_u64(), 0); + assert!(EXECUTION_STACK.with(|stack| stack.borrow().is_empty())); + assert_eq!(crate::async_context::get_store(token), Some(22.0)); + assert_eq!( + RESOURCES + .lock() + .unwrap() + .get(&ids.async_id) + .unwrap() + .resource + .to_bits(), + TAG_UNDEFINED_F64.to_bits() + ); + provider_ffi::js_async_hooks_owned_provider_destroy(resource.get_nanbox_f64()); + provider_ffi::js_async_hooks_owned_provider_destroy(resource.get_nanbox_f64()); + assert!(!RESOURCES.lock().unwrap().contains_key(&ids.async_id)); + crate::async_context::clear_store(token); + reset_for_tests(); + } + + #[test] + fn owned_native_provider_payload_drop_removes_existing_metadata_without_js() { + reset_for_tests(); + let scope = crate::gc::RuntimeHandleScope::new(); + let resource = scope.root_nanbox_f64(unsafe { + provider_ffi::js_async_hooks_owned_provider_new(b"TCPWRAP".as_ptr(), 7, u64::MAX) + }); + let raw = crate::value::js_nanbox_get_pointer(resource.get_nanbox_f64()); + let id = unsafe { resource_payload(raw) }.unwrap().ids.async_id; + assert!(RESOURCES.lock().unwrap().contains_key(&id)); + assert!( + crate::native_payload::close_attached::( + resource.get_nanbox_f64(), + &ASYNC_RESOURCE_FAMILY + ) + ); + assert!(!RESOURCES.lock().unwrap().contains_key(&id)); + reset_for_tests(); + } + #[test] fn disabled_hooks_release_payloads_and_retire_callback_records() { reset_for_tests(); diff --git a/crates/perry-runtime/src/closure/dispatch/bound.rs b/crates/perry-runtime/src/closure/dispatch/bound.rs index db85115da2..6c94e27ae6 100644 --- a/crates/perry-runtime/src/closure/dispatch/bound.rs +++ b/crates/perry-runtime/src/closure/dispatch/bound.rs @@ -69,6 +69,42 @@ pub unsafe fn dispatch_bound_method( let private_brand = (crate::closure::real_capture_count((*closure).capture_count) >= 4) .then(|| js_closure_get_capture_f64(closure, 3)); + // Callable native net constructors initialize an explicit receiver, just + // as their proven super() path does. Namespace calls still allocate a new + // owner. The constructor's captured export metadata selects the family. + let receiver = this.as_f64(); + let receiver_object = crate::value::JSValue::from_bits(receiver.to_bits()); + if receiver_object.is_pointer() + && receiver.to_bits() != namespace_obj.to_bits() + && crate::object::js_object_get_class_id(crate::value::js_nanbox_get_pointer(namespace_obj) + as *const crate::object::ObjectHeader) + == crate::object::NATIVE_MODULE_CLASS_ID + { + let address = receiver_object.as_pointer::() as usize; + if matches!(crate::value::addr_class::try_read_gc_header(address), Some(header) if header.obj_type == crate::gc::GC_TYPE_OBJECT) + && crate::object::js_object_get_class_id(address as *const crate::object::ObjectHeader) + != crate::object::NATIVE_MODULE_CLASS_ID + { + if let Some((module, export)) = + crate::object::native_module::bound_native_callable_module_and_method( + crate::value::js_nanbox_pointer(closure as i64), + ) + { + if crate::object::native_module::normalize_native_module_alias(&module) == "net" + && matches!(export.as_str(), "Socket" | "Stream" | "Server") + { + let undefined = f64::from_bits(crate::value::TAG_UNDEFINED); + return crate::object::js_net_subclass_init( + receiver, + args.first().copied().unwrap_or(undefined), + args.get(1).copied().unwrap_or(undefined), + u32::from(export == "Server"), + ); + } + } + } + } + // #6173: a SYMBOL-keyed class method read as a value — there is no name to // re-resolve; the captures carry the already-resolved func_ptr + arity // meta (see `SYMBOL_BOUND_METHOD_NAME` for the layout). Discriminated by diff --git a/crates/perry-runtime/src/event_pump/agent_loop.rs b/crates/perry-runtime/src/event_pump/agent_loop.rs index 15e57e88a0..28d4df4abe 100644 --- a/crates/perry-runtime/src/event_pump/agent_loop.rs +++ b/crates/perry-runtime/src/event_pump/agent_loop.rs @@ -1171,6 +1171,10 @@ pub fn loop_statistics() -> Option { /// those completions. Skipping it on a worker would strand every promise those /// engines owe — which presents as a hang, not an error. pub fn shutdown_current_thread() { + #[cfg(any(test, feature = "native-payload-test-census"))] + if std::env::var("PERRY_TEST_NET_SABOTAGE").as_deref() == Ok("teardown_driver") { + return; + } if STATE.with(Cell::get) == LoopState::Owner { // Close P1's sockets while the loop is still here, then run one // nonblocking turn so their `Closed` completions reach the binding diff --git a/crates/perry-runtime/src/gc/mod.rs b/crates/perry-runtime/src/gc/mod.rs index 2bb060ddf6..4d5fb0961c 100644 --- a/crates/perry-runtime/src/gc/mod.rs +++ b/crates/perry-runtime/src/gc/mod.rs @@ -310,6 +310,12 @@ pub fn gc_collect_minor() -> u64 { .emit_after_current() } +/// Synchronous full collection for the default-off native payload acceptance census. +#[cfg(feature = "native-payload-test-census")] +pub fn native_payload_test_collect_full() { + gc_collect_full_mark_sweep_with_trigger(GcTriggerSnapshot::capture(GcTriggerKind::Manual)); +} + pub(super) fn gc_collect_minor_with_trigger(trigger: GcTriggerSnapshot) -> GcCollectOutcome { // Build the stack-map index if it is still owed. This is the chokepoint: // every collection funnels through one of these three entries, and here diff --git a/crates/perry-runtime/src/lib.rs b/crates/perry-runtime/src/lib.rs index 73103085d6..6523860274 100644 --- a/crates/perry-runtime/src/lib.rs +++ b/crates/perry-runtime/src/lib.rs @@ -863,6 +863,14 @@ pub(crate) mod stdlib_pump { /// async ops, etc.). Returns 0 if perry-stdlib is not linked. #[no_mangle] pub extern "C" fn js_stdlib_has_active_handles() -> i32 { + // Payload transports have no extension handle registry. Their driver + // already accounts for ref'd sockets and owed completions, including + // Closed after destroy. Query the existing agent loop without creating + // one, so a net-only program stays alive to deliver those completions. + #[cfg(not(target_arch = "wasm32"))] + if crate::event_pump::agent_loop_has_outstanding_work() { + return 1; + } #[cfg(feature = "node-api-host")] if crate::node_api_host::has_active_work() { return 1; @@ -1120,6 +1128,35 @@ pub(crate) mod stdlib_pump { } static HAS_ACTIVE_FLAG: AtomicI32 = AtomicI32::new(0); + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn agent_driver_reference_keeps_the_generated_loop_alive_without_an_extension_map() { + std::thread::spawn(|| { + let agent = crate::agent::enter_worker_agent(); + assert_eq!(js_stdlib_has_active_handles(), 0); + let handle = crate::event_pump::with_net_driver(|driver| { + driver + .tcp_listen("127.0.0.1:0".parse().unwrap(), &Default::default()) + .unwrap() + }) + .expect("a JS agent must own its driver"); + assert_eq!( + js_stdlib_has_active_handles(), + 1, + "a referenced driver handle must keep the generated loop alive" + ); + crate::event_pump::with_net_driver(|driver| driver.set_ref(handle, false).unwrap()); + assert_eq!(js_stdlib_has_active_handles(), 0); + crate::event_pump::with_net_driver(|driver| driver.set_ref(handle, true).unwrap()); + assert_eq!(js_stdlib_has_active_handles(), 1); + crate::agent::retire_agent(agent); + assert_eq!(js_stdlib_has_active_handles(), 0); + }) + .join() + .unwrap(); + } + extern "C" fn flag_has_active() -> i32 { HAS_ACTIVE_FLAG.load(AtomicOrdering::SeqCst) } diff --git a/crates/perry-runtime/src/native_class_ids.rs b/crates/perry-runtime/src/native_class_ids.rs index 4fe7611796..0b70128984 100644 --- a/crates/perry-runtime/src/native_class_ids.rs +++ b/crates/perry-runtime/src/native_class_ids.rs @@ -82,7 +82,7 @@ pub const ASYNC_LOCAL_STORAGE_LEGACY: u32 = 0xFFFF_0078; /// family between them carries a `native_state` word the far side of a /// `postMessage` could not reconstruct. const NATIVE_BACKED_FIRST: u32 = ABORT_CONTROLLER; -const NATIVE_BACKED_LAST: u32 = HTTP_SERVER_RESPONSE; +const NATIVE_BACKED_LAST: u32 = HTTP_CLIENT_PARSER; /// Class ids whose instances are ordinary objects carrying native state that /// cannot cross a thread boundary (#340/#341). @@ -156,6 +156,14 @@ const ALL: &[u32] = &[ ZSTD_DECOMPRESS, ZLIB_BASE, HTTP_SERVER_RESPONSE, + NET_BLOCK_LIST, + NET_SOCKET_ADDRESS, + NET_SOCKET, + NET_SERVER, + HTTP_PARSER, + WS_CODEC, + HTTP2_CODEC, + HTTP_CLIENT_PARSER, ]; /// Strictly ascending ⟹ no two families share an id, and the block stays @@ -236,6 +244,14 @@ mod tests { ZSTD_DECOMPRESS, ZLIB_BASE, HTTP_SERVER_RESPONSE, + NET_BLOCK_LIST, + NET_SOCKET_ADDRESS, + NET_SOCKET, + NET_SERVER, + HTTP_PARSER, + WS_CODEC, + HTTP2_CODEC, + HTTP_CLIENT_PARSER, ASYNC_LOCAL_STORAGE_LEGACY, ASYNC_RESOURCE_LEGACY, ] { diff --git a/crates/perry-runtime/src/native_handle.rs b/crates/perry-runtime/src/native_handle.rs index f40dfdbf79..c12a287733 100644 --- a/crates/perry-runtime/src/native_handle.rs +++ b/crates/perry-runtime/src/native_handle.rs @@ -315,6 +315,8 @@ unsafe fn finalize_once_with(handle: *mut NativeHandleHeader, account: bool) -> } #[cfg(test)] PAYLOAD_FINALIZED.fetch_add(1, Ordering::SeqCst); + #[cfg(feature = "native-payload-test-census")] + crate::native_payload::test_census::finalized((*handle).type_id, (*handle).refs); #[cfg(test)] let sabotage = crate::native_payload::callback_sabotage("finalized"); #[cfg(not(test))] @@ -327,6 +329,8 @@ unsafe fn finalize_once_with(handle: *mut NativeHandleHeader, account: bool) -> && !(*handle).resource_ptr.is_null() && drop_fn.is_some(); if let (true, Some(finalizer)) = (should_finalize, drop_fn) { + #[cfg(feature = "native-payload-test-census")] + crate::native_payload::test_census::dropped((*handle).type_id); finalizer((*handle).resource_ptr, ptr::null_mut()); } (*handle).finalized = 1; @@ -490,6 +494,8 @@ pub(crate) unsafe fn native_handle_release_rust_payload(handle: *mut NativeHandl (*handle).flags |= crate::native_payload::CLOSING; if (*handle).ownership == OWNERSHIP_OWNED { if let Some(finalizer) = cell_drop_fn(handle) { + #[cfg(feature = "native-payload-test-census")] + crate::native_payload::test_census::dropped((*handle).type_id); finalizer((*handle).resource_ptr, ptr::null_mut()); } } diff --git a/crates/perry-runtime/src/native_payload.rs b/crates/perry-runtime/src/native_payload.rs index 4b98f13f26..8e6b0fe3da 100644 --- a/crates/perry-runtime/src/native_payload.rs +++ b/crates/perry-runtime/src/native_payload.rs @@ -88,6 +88,10 @@ pub struct PayloadPrototype { } impl PayloadPrototype { + /// A builder over a prototype handed to a binding's installer. + pub(crate) fn from_raw(proto: *mut ObjectHeader) -> Self { + Self { proto } + } /// LazyTransform's inherited state getters (first state read initializes). pub fn lazy_stream_state_getters(&mut self) { crate::node_stream::native_hooks::install_lazy_state_getters(self.proto); @@ -291,6 +295,21 @@ pub(crate) fn scan_payload_prototype_roots_mut(visitor: &mut crate::gc::RuntimeR }); } +/// A non-exported family's constructor: a named closure whose `prototype` +/// becomes the family prototype. +pub(crate) fn anonymous_constructor(name: &str, length: u32) -> f64 { + let closure = crate::closure::js_closure_alloc( + crate::fn_info!(payload_ctor_thunk, 0; with_declared(0)), + 0, + ); + if closure.is_null() { + return f64::from_bits(crate::value::TAG_UNDEFINED); + } + crate::object::native_module::set_bound_native_closure_name(closure, name); + crate::object::native_module::set_builtin_closure_length(closure as usize, length); + crate::value::js_nanbox_pointer(closure as i64) +} + fn family_prototype(family: &NativePayloadFamily) -> *mut ObjectHeader { let index = slot_index(family.class_id); let existing = PAYLOAD_PROTOTYPES.with(|slots| slots[index].load(Ordering::Acquire)); @@ -306,22 +325,7 @@ fn family_prototype(family: &NativePayloadFamily) -> *mut ObjectHeader { // it published instead of building a second. let constructor = match family.constructor_export { Some((module, export)) => crate::object::bound_native_callable_export_value(module, export), - None => { - let closure = crate::closure::js_closure_alloc( - crate::fn_info!(payload_ctor_thunk, 0; with_declared(0)), - 0, - ); - if closure.is_null() { - f64::from_bits(crate::value::TAG_UNDEFINED) - } else { - crate::object::native_module::set_bound_native_closure_name(closure, family.name); - crate::object::native_module::set_builtin_closure_length( - closure as usize, - family.constructor_length, - ); - crate::value::js_nanbox_pointer(closure as i64) - } - } + None => anonymous_constructor(family.name, family.constructor_length), }; let existing = PAYLOAD_PROTOTYPES.with(|slots| slots[index].load(Ordering::Acquire)); if existing != 0 { @@ -1638,6 +1642,57 @@ pub fn set_pending_exception(owner: f64, exception: f64) -> Result<(), ()> { } } +/// Default-off acceptance instrumentation for net.Socket payload cells. +/// Scalar counts only; no addresses or identities are stored. +#[cfg(feature = "native-payload-test-census")] +pub mod test_census { + use std::sync::atomic::{AtomicU64, Ordering}; + static CREATED: AtomicU64 = AtomicU64::new(0); + static FINALIZED: AtomicU64 = AtomicU64::new(0); + static DROPS: AtomicU64 = AtomicU64::new(0); + static REFS: AtomicU64 = AtomicU64::new(0); + fn counted(tag: u64) -> bool { + tag as u32 == crate::native_class_ids::NET_SOCKET + } + /// `[created, finalized, payload drops, outstanding cell refs]`. + pub fn snapshot() -> [u64; 4] { + [ + CREATED.load(Ordering::SeqCst), + FINALIZED.load(Ordering::SeqCst), + DROPS.load(Ordering::SeqCst), + REFS.load(Ordering::SeqCst), + ] + } + pub(crate) fn created(tag: u64) { + if counted(tag) { + CREATED.fetch_add(1, Ordering::SeqCst); + } + } + pub(crate) fn finalized(tag: u64, refs: u32) { + if counted(tag) { + FINALIZED.fetch_add(1, Ordering::SeqCst); + // Completions discarded at worker teardown never unref; the heap + // retires their remaining refs with the cell. + REFS.fetch_sub(refs as u64, Ordering::SeqCst); + } + } + pub(crate) fn dropped(tag: u64) { + if counted(tag) { + DROPS.fetch_add(1, Ordering::SeqCst); + } + } + pub(crate) fn reference(tag: u64) { + if counted(tag) { + REFS.fetch_add(1, Ordering::SeqCst); + } + } + pub(crate) fn unreference(tag: u64) { + if counted(tag) { + REFS.fetch_sub(1, Ordering::SeqCst); + } + } +} + /// # Safety /// Call on the creator thread with a live cell, once per outstanding item. pub unsafe fn link_ref(link: OwnerLink) { @@ -1647,6 +1702,8 @@ pub unsafe fn link_ref(link: OwnerLink) { crate::native_handle::current_thread_id() ); (*cell).refs = (*cell).refs.checked_add(1).expect("native refs overflow"); + #[cfg(feature = "native-payload-test-census")] + test_census::reference((*cell).type_id); #[cfg(test)] if callback_sabotage("pin") { return; @@ -1676,6 +1733,8 @@ pub unsafe fn link_unref(link: OwnerLink) { ); assert_ne!((*cell).refs, 0, "unbalanced native unref"); (*cell).refs -= 1; + #[cfg(feature = "native-payload-test-census")] + test_census::unreference((*cell).type_id); if (*cell).refs == 0 { crate::gc::unpin_object( (cell as *mut u8).sub(crate::gc::GC_HEADER_SIZE) as *mut crate::gc::GcHeader @@ -1817,6 +1876,34 @@ pub fn js_state(value: f64, family: &NativePayloadFamily, create: bool) -> f64 { js_state_for_class(value, family.class_id, create) } +/// The class id carried by `value`'s attached payload cell (the family's, +/// also for a source subclass instance), on the cell's creator thread. +pub(crate) fn attached_family_class_id(value: f64) -> Option { + let obj = any_object(value)?; + let meta = unsafe { (*obj).meta }; + if meta.is_null() { + return None; + } + let cell = payload_cell_of_word(unsafe { (*meta).native_state })?; + unsafe { + if (*cell).finalized != 0 + || (*cell).creator_thread_id != crate::native_handle::current_thread_id() + || (*cell).owner != value.to_bits() + { + return None; + } + Some((*cell).type_id as u32) + } +} + +/// The JS state of the actual object, whatever its own class id. +pub(crate) fn js_state_object(value: f64, create: bool) -> f64 { + match any_object(value) { + Some(obj) => js_state_for_class(value, unsafe { (*obj).class_id }, create), + None => undefined(), + } +} + pub(crate) fn js_state_for_class(value: f64, class_id: u32, create: bool) -> f64 { let undefined = undefined(); let Some(obj) = instance_of(value, class_id) else { @@ -1850,6 +1937,8 @@ pub(crate) fn export_class_id(module: &str, export: &str) -> Option { use crate::native_class_ids as ids; Some(match (module, export) { ("http", "ServerResponse") => ids::HTTP_SERVER_RESPONSE, + ("net", "Socket" | "Stream") => ids::NET_SOCKET, + ("net", "Server") => ids::NET_SERVER, ("crypto", "Hash") => ids::CRYPTO_HASH, ("crypto", "Hmac") => ids::CRYPTO_HMAC, ("crypto", "Cipheriv") => ids::CRYPTO_CIPHERIV, diff --git a/crates/perry-runtime/src/native_payload_abi.rs b/crates/perry-runtime/src/native_payload_abi.rs index 8c988ac4a2..45b6297bcc 100644 --- a/crates/perry-runtime/src/native_payload_abi.rs +++ b/crates/perry-runtime/src/native_payload_abi.rs @@ -562,9 +562,14 @@ pub unsafe extern "C" fn js_perry_payload_prototype( return bytes_undefined(); }; let scope = crate::gc::RuntimeHandleScope::new(); - let ctor = scope.root_nanbox_f64(crate::object::bound_native_callable_export_value( - module, name, - )); + // An empty module names an internal family (a codec or parser object) + // with no export: its prototype hangs off an anonymous constructor. + let ctor = scope.root_nanbox_f64(if module.is_empty() { + let _no_move = crate::gc::GcSuppressScope::new(); + crate::native_payload::anonymous_constructor(name, family.constructor_length) + } else { + crate::object::bound_native_callable_export_value(module, name) + }); let proto = scope.root_nanbox_f64(crate::object::js_function_prototype_value_for_read( ctor.get_nanbox_f64(), )); @@ -573,6 +578,9 @@ pub unsafe extern "C" fn js_perry_payload_prototype( }; let ptr = crate::native_payload::adopt_prototype_with(family.class_id, ptr, |proto| { if let Some(install) = family.install_prototype { + // The installer allocates closures against a raw prototype + // pointer; keep the heap fixed until it returns. + let _no_move = crate::gc::GcSuppressScope::new(); install(proto.cast()); } }); @@ -677,3 +685,6 @@ pub unsafe extern "C" fn js_perry_payload_external_bytes( #[path = "native_payload_transport_abi.rs"] mod transport; pub use transport::*; +#[path = "native_payload_event_abi.rs"] +mod event; +pub use event::*; diff --git a/crates/perry-runtime/src/native_payload_attach.rs b/crates/perry-runtime/src/native_payload_attach.rs index 7b3eabc5c5..14853d8521 100644 --- a/crates/perry-runtime/src/native_payload_attach.rs +++ b/crates/perry-runtime/src/native_payload_attach.rs @@ -122,6 +122,8 @@ pub(crate) fn attach_external_rooted( links_owner, ) }; + #[cfg(feature = "native-payload-test-census")] + super::test_census::created(unsafe { (*cell).type_id }); let word = crate::value::JSValue::pointer(cell as *const u8).bits(); obj.with_mut_ptr::(|obj| unsafe { let meta = (*obj).meta; diff --git a/crates/perry-runtime/src/native_payload_event_abi.rs b/crates/perry-runtime/src/native_payload_event_abi.rs new file mode 100644 index 0000000000..5145893dbb --- /dev/null +++ b/crates/perry-runtime/src/native_payload_event_abi.rs @@ -0,0 +1,149 @@ +//! Event boundaries for binding callbacks: catch JS throws inside one call so +//! binding roots, driver refs and owned buffers retire on the normal path. +use super::*; + +fn undefined() -> f64 { + bytes_undefined() +} + +fn event_exception(error: f64) -> f64 { + let scope = crate::gc::RuntimeHandleScope::new(); + let error = scope.root_nanbox_f64(error); + match crate::exception::catch_js_throw(|| { + crate::os::emit_process_event("uncaughtException", &[error.get_nanbox_f64()]) + }) { + Ok(true) => f64::from_bits(crate::value::TAG_UNDEFINED), + Ok(false) => crate::exception::exit_on_uncaught(error.get_nanbox_f64()), + Err(error) => crate::exception::exit_on_uncaught(error), + } +} + +/// A property Get used by a native event callback. A destination's getter may +/// throw; catch inside this call so binding roots and Rust temporaries still +/// retire on the ordinary return path. +#[no_mangle] +pub extern "C" fn js_perry_event_get(owner: f64, key: f64) -> f64 { + let scope = crate::gc::RuntimeHandleScope::new(); + let owner = scope.root_nanbox_f64(owner); + let key = scope.root_nanbox_f64(key); + match crate::exception::catch_js_throw(|| { + crate::value::js_dyn_index_get(owner.get_nanbox_f64(), key.get_nanbox_f64()) + }) { + Ok(value) => value, + Err(error) => event_exception(error), + } +} + +/// Emit from a native completion. Catch inside this one call so a listener +/// throw cannot skip its caller's driver-ref retirement or owned-buffer drop. +/// This is the same uncaught path as a timer callback, with no payload busy +/// guard: explicit close and same-cell reopen remain possible in a listener. +#[no_mangle] +pub extern "C" fn js_perry_event_emit(owner: f64, event: f64, arguments: f64) -> f64 { + js_perry_event_emit_in_resource(undefined(), owner, event, arguments) +} + +/// The event boundary with a JS-owned async provider. Scope restoration stays +/// inside the runtime call even when a listener throws or reopens its Socket. +#[no_mangle] +pub extern "C" fn js_perry_event_emit_in_resource( + resource: f64, + owner: f64, + event: f64, + arguments: f64, +) -> f64 { + let scope = crate::gc::RuntimeHandleScope::new(); + let resource = scope.root_nanbox_f64(resource); + let owner = scope.root_nanbox_f64(owner); + let event = scope.root_nanbox_f64(event); + let arguments = scope.root_nanbox_f64(arguments); + #[cfg(test)] + if std::env::var("PERRY_TEST_NET_SABOTAGE").as_deref() == Ok("event_catch") { + return crate::node_stream::js_node_stream_method_emit_args( + crate::value::js_nanbox_get_pointer(owner.get_nanbox_f64()), + event.get_nanbox_f64(), + crate::value::js_nanbox_get_pointer(arguments.get_nanbox_f64()), + ); + } + match crate::async_hooks::owned_provider_scope(resource.get_nanbox_f64(), || { + crate::node_stream::js_node_stream_method_emit_args( + crate::value::js_nanbox_get_pointer(owner.get_nanbox_f64()), + event.get_nanbox_f64(), + crate::value::js_nanbox_get_pointer(arguments.get_nanbox_f64()), + ) + }) { + Ok(value) => value, + Err(error) => event_exception(error), + } +} + +/// Invoke a callback from a native event, preserving the ordinary receiver +/// and the pump's uncaught path. It does not begin a deferred payload close. +/// +/// # Safety +/// args is readable for len NaN-boxed values, or null with len zero. +#[no_mangle] +pub unsafe extern "C" fn js_perry_event_call( + callback: f64, + receiver: f64, + args: *const f64, + len: usize, +) -> f64 { + js_perry_event_call_in_resource(undefined(), callback, receiver, args, len) +} + +/// Callback counterpart to emit_in_resource. All inputs remain rooted through +/// before/after hooks; the argument Vec drops after caught JS control returns. +/// +/// # Safety +/// As js_perry_event_call. +#[no_mangle] +pub unsafe extern "C" fn js_perry_event_call_in_resource( + resource: f64, + callback: f64, + receiver: f64, + args: *const f64, + len: usize, +) -> f64 { + let scope = crate::gc::RuntimeHandleScope::new(); + let resource = scope.root_nanbox_f64(resource); + let callback = scope.root_nanbox_f64(callback); + let receiver = scope.root_nanbox_f64(receiver); + let args = if args.is_null() { + &[] + } else { + std::slice::from_raw_parts(args, len) + }; + let args = scope.root_nanbox_f64_slice(args); + let mut values = Vec::with_capacity(args.len()); + match crate::async_hooks::owned_provider_scope(resource.get_nanbox_f64(), || { + // A before hook may move arguments. Read the rooted slots after it; + // keep the owning Vec outside the JS exception boundary. + values.extend(args.iter().map(|arg| arg.get_nanbox_f64())); + crate::closure::native_call_value_this( + callback.get_nanbox_f64(), + crate::closure::JsThis::from_f64(receiver.get_nanbox_f64()), + values.as_ptr(), + values.len(), + ) + }) { + Ok(value) => value, + Err(error) => event_exception(error), + } +} + +#[cfg(feature = "keepalive-anchors")] +mod keepalive { + use super::*; + #[used(compiler)] + static GET: extern "C" fn(f64, f64) -> f64 = js_perry_event_get; + #[used(compiler)] + static EMIT: extern "C" fn(f64, f64, f64) -> f64 = js_perry_event_emit; + #[used(compiler)] + static EMIT_IN: extern "C" fn(f64, f64, f64, f64) -> f64 = js_perry_event_emit_in_resource; + #[used(compiler)] + static CALL: unsafe extern "C" fn(f64, f64, *const f64, usize) -> f64 = js_perry_event_call; + #[used(compiler)] + static CALL_IN: unsafe extern "C" fn(f64, f64, f64, *const f64, usize) -> f64 = + js_perry_event_call_in_resource; +} diff --git a/crates/perry-runtime/src/native_payload_transport_abi.rs b/crates/perry-runtime/src/native_payload_transport_abi.rs index 89b3dc4325..e6b1778494 100644 --- a/crates/perry-runtime/src/native_payload_transport_abi.rs +++ b/crates/perry-runtime/src/native_payload_transport_abi.rs @@ -168,6 +168,35 @@ mod keepalive { #[used(compiler)] static JS_STATE: unsafe extern "C" fn(f64, *const PerryPayloadFamily, i32) -> f64 = js_perry_payload_js_state; + #[used(compiler)] + static LINK_PTR: unsafe extern "C" fn(usize, *const PerryPayloadFamily, *mut i32) -> *mut c_void = + js_perry_payload_link_ptr; + #[used(compiler)] + static LINK_LIFECYCLE: unsafe extern "C" fn(usize, *const PerryPayloadFamily) -> i32 = + js_perry_payload_link_lifecycle; + #[used(compiler)] + static LINK_CLOSE: unsafe extern "C" fn(usize, *const PerryPayloadFamily) -> i32 = + js_perry_payload_link_close; + #[used(compiler)] + static LINK_REOPEN: unsafe extern "C" fn(usize, *const PerryPayloadFamily, *mut c_void, usize) -> i32 = + js_perry_payload_link_reopen; + #[used(compiler)] + static LINK_JS_STATE: unsafe extern "C" fn(usize, *const PerryPayloadFamily, i32) -> f64 = + js_perry_payload_link_js_state; + #[used(compiler)] + static LINK_BYTES: unsafe extern "C" fn(usize, *const PerryPayloadFamily, usize) = + js_perry_payload_link_external_bytes; + #[used(compiler)] + static RECEIVER_LINK: unsafe extern "C" fn(f64, *const PerryPayloadFamily, *mut i32) -> usize = + js_perry_payload_receiver_link; + #[used(compiler)] + static PROTO_GETTER: unsafe extern "C" fn(*mut c_void, *const u8, usize, *const crate::closure::JsFunctionInfo) = + js_perry_payload_proto_getter; + #[used(compiler)] + static PROTO_DATA: unsafe extern "C" fn(*mut c_void, *const u8, usize, f64, u32) = + js_perry_payload_proto_data; + #[used(compiler)] + static PROTO_INHERIT: unsafe extern "C" fn(*mut c_void, f64) = js_perry_payload_proto_inherit; } #[cfg(test)] @@ -308,3 +337,241 @@ mod tests { } } } + +/// The typed cell behind a link, OPEN or CLOSED, on its creator thread. +unsafe fn link_family_cell( + link: usize, + family: *const PerryPayloadFamily, +) -> Result<*mut crate::native_handle::NativeHandleHeader, i32> { + let family = checked_family(family).ok_or(-1)?; + if link == 0 || link & 7 != 0 { + return Err(-1); + } + let cell = link as *mut crate::native_handle::NativeHandleHeader; + if (*cell).type_id != family_type_id(family) + || crate::native_handle::cell_vtable(cell).map(|v| v as *const _) != Some(family.vtable) + { + return Err(-1); + } + if (*cell).finalized != 0 + || (*cell).creator_thread_id != crate::native_handle::current_thread_id() + { + return Err(-2); + } + Ok(cell) +} + +unsafe fn write_miss(out: *mut i32, code: i32) { + if !out.is_null() { + // GC_STORE_AUDIT(POINTER_FREE): a scalar receiver-miss code. + out.write(code); + } +} + +/// The OPEN payload behind a link; null with `-1` (foreign) or `-2` (closed +/// or finalized) in `out_miss`. +/// # Safety +/// The link's cell is kept alive by a rooted owner or an outstanding ref. +#[no_mangle] +pub unsafe extern "C" fn js_perry_payload_link_ptr( + link: usize, + family: *const PerryPayloadFamily, + out_miss: *mut i32, +) -> *mut c_void { + match link_family_cell(link, family) { + Ok(cell) => { + let ptr = crate::native_handle::native_handle_rust_payload_ptr(cell, (*cell).type_id); + if ptr.is_null() { + write_miss(out_miss, -2); + } + ptr + } + Err(code) => { + write_miss(out_miss, code); + std::ptr::null_mut() + } + } +} + +/// Lifecycle of the cell behind a link (codes as `js_perry_payload_lifecycle`). +/// # Safety +/// As js_perry_payload_link_ptr. +#[no_mangle] +pub unsafe extern "C" fn js_perry_payload_link_lifecycle( + link: usize, + family: *const PerryPayloadFamily, +) -> i32 { + match link_family_cell(link, family) { + Ok(cell) if (*cell).flags & np::CLOSING != 0 => 2, + Ok(cell) if (*cell).resource_ptr.is_null() => 3, + Ok(_) => 1, + Err(code) => code, + } +} + +/// Close the payload behind a link, keeping the cell and owner (codes as +/// `js_perry_payload_close`). +/// # Safety +/// As js_perry_payload_link_ptr. +#[no_mangle] +pub unsafe extern "C" fn js_perry_payload_link_close( + link: usize, + family: *const PerryPayloadFamily, +) -> i32 { + let Ok(cell) = link_family_cell(link, family) else { + return -1; + }; + if (*cell).resource_ptr.is_null() { + return 0; + } + if (*cell).busy != 0 { + (*cell).flags |= np::CLOSING; + } else { + crate::native_handle::native_handle_release_rust_payload(cell); + } + 0 +} + +/// Reopen the CLOSED cell behind a link with a new payload (codes as +/// `js_perry_payload_reopen`). Ownership of resource transfers only on 0. +/// # Safety +/// As js_perry_payload_link_ptr; resource is a boxed payload of the family. +#[no_mangle] +pub unsafe extern "C" fn js_perry_payload_link_reopen( + link: usize, + family_ptr: *const PerryPayloadFamily, + resource: *mut c_void, + bytes: usize, +) -> i32 { + let (Ok(cell), Some(family)) = (link_family_cell(link, family_ptr), checked_family(family_ptr)) + else { + return -1; + }; + let Some(owner) = np::link_event_owner(np::OwnerLink(link)) else { + return 7; + }; + if resource.is_null() { + return -1; + } + match np::attach_external_cell( + owner, + cell, + family_type_id(family), + resource, + &*family.vtable, + bytes, + ) { + Ok(()) => 0, + Err(np::AttachMiss::Foreign) => -1, + Err(np::AttachMiss::Open) => 5, + Err(np::AttachMiss::Closing) => 6, + Err(np::AttachMiss::Finalized) => 7, + } +} + +/// The owner's traced JS-state object, reached through its link. +/// # Safety +/// As js_perry_payload_link_ptr. +#[no_mangle] +pub unsafe extern "C" fn js_perry_payload_link_js_state( + link: usize, + family: *const PerryPayloadFamily, + create: i32, +) -> f64 { + if link_family_cell(link, family).is_err() { + return bytes_undefined(); + } + match np::link_event_owner(np::OwnerLink(link)) { + Some(owner) => js_perry_payload_js_state(owner, family, create), + None => bytes_undefined(), + } +} + +/// Restate the native bytes the payload behind a link retains. +/// # Safety +/// As js_perry_payload_link_ptr. +#[no_mangle] +pub unsafe extern "C" fn js_perry_payload_link_external_bytes( + link: usize, + family: *const PerryPayloadFamily, + bytes: usize, +) { + if let Ok(cell) = link_family_cell(link, family) { + crate::native_handle::native_handle_set_external_bytes(cell, bytes); + } +} + +/// The owner link of a receiver of this family (direct or subclass), OPEN or +/// CLOSED; 0 with a miss code otherwise. +/// # Safety +/// family is a static descriptor. +#[no_mangle] +pub unsafe extern "C" fn js_perry_payload_receiver_link( + value: f64, + family_ptr: *const PerryPayloadFamily, + out_miss: *mut i32, +) -> usize { + let Some(family) = checked_family(family_ptr) else { + write_miss(out_miss, -1); + return 0; + }; + if family.links_owner == 0 { + write_miss(out_miss, -1); + return 0; + } + let Some(cell) = family_cell(value, family) else { + write_miss(out_miss, -1); + return 0; + }; + match link_family_cell(cell as usize, family_ptr) { + Ok(cell) => cell as usize, + Err(code) => { + write_miss(out_miss, code); + 0 + } + } +} + +/// Install an accessor on a binding's prototype (inside its installer). +/// # Safety +/// proto is the prototype handed to the installer; name is UTF-8. +#[no_mangle] +pub unsafe extern "C" fn js_perry_payload_proto_getter( + proto: *mut c_void, + name: *const u8, + name_len: usize, + info: *const crate::closure::JsFunctionInfo, +) { + let name = std::str::from_utf8(std::slice::from_raw_parts(name, name_len)).unwrap(); + np::PayloadPrototype::from_raw(proto.cast()).getter(name, info); +} + +/// Define a data property on a binding's prototype (inside its installer). +/// flags: bit 0 writable, 1 enumerable, 2 configurable. +/// # Safety +/// As js_perry_payload_proto_getter. +#[no_mangle] +pub unsafe extern "C" fn js_perry_payload_proto_data( + proto: *mut c_void, + name: *const u8, + name_len: usize, + value: f64, + flags: u32, +) { + let name = std::str::from_utf8(std::slice::from_raw_parts(name, name_len)).unwrap(); + np::PayloadPrototype::from_raw(proto.cast()).data( + name, + value, + flags & 1 != 0, + flags & 2 != 0, + flags & 4 != 0, + ); +} + +/// Link a binding's prototype to a parent prototype (inside its installer). +/// # Safety +/// As js_perry_payload_proto_getter. +#[no_mangle] +pub unsafe extern "C" fn js_perry_payload_proto_inherit(proto: *mut c_void, parent: f64) { + np::PayloadPrototype::from_raw(proto.cast()).inherit(parent); +} diff --git a/crates/perry-runtime/src/object/class_constructors.rs b/crates/perry-runtime/src/object/class_constructors.rs index 584434c138..b185442fcf 100644 --- a/crates/perry-runtime/src/object/class_constructors.rs +++ b/crates/perry-runtime/src/object/class_constructors.rs @@ -660,6 +660,28 @@ pub unsafe extern "C" fn js_super_construct_apply( let mut cur = crate::object::get_parent_class_id(child_cid).unwrap_or(0); let mut depth = 0usize; while cur != 0 && depth < 64 { + if matches!( + cur, + crate::native_class_ids::NET_SOCKET | crate::native_class_ids::NET_SERVER + ) { + let options = if arr.is_null() { + undef + } else { + crate::array::js_array_get_f64(arr, 0) + }; + let callback = if arr.is_null() { + undef + } else { + crate::array::js_array_get_f64(arr, 1) + }; + crate::object::native_module_dispatch::js_net_subclass_init( + crate::value::js_nanbox_pointer(this_raw), + options, + callback, + u32::from(cur == crate::native_class_ids::NET_SERVER), + ); + return undef; + } if let Some((ctor_ptr, total_params, sig_caps)) = lookup_class_constructor(cur) { if std::env::var_os("PERRY_SUPER_DEBUG").is_some() { eprintln!( diff --git a/crates/perry-runtime/src/object/global_this/fetch_globals.rs b/crates/perry-runtime/src/object/global_this/fetch_globals.rs index 519253f3ae..4d24b77a13 100644 --- a/crates/perry-runtime/src/object/global_this/fetch_globals.rs +++ b/crates/perry-runtime/src/object/global_this/fetch_globals.rs @@ -678,6 +678,29 @@ pub unsafe extern "C" fn js_fetch_or_value_super( crate::object::class_registry::js_get_dynamic_parent_value(cid), ) }); + if let Some((module, export)) = bound_native_parent.as_ref() { + if super::super::native_module::normalize_native_module_alias(module) == "net" + && matches!(export.as_str(), "Socket" | "Stream" | "Server") + { + let options = if args_len > 0 && !args_ptr.is_null() { + *args_ptr + } else { + undef + }; + let callback = if args_len > 1 && !args_ptr.is_null() { + *args_ptr.add(1) + } else { + undef + }; + super::super::native_module_dispatch::js_net_subclass_init( + this_box, + options, + callback, + u32::from(export == "Server"), + ); + return undef; + } + } if bound_native_parent .as_ref() .is_some_and(|(module, method)| { diff --git a/crates/perry-runtime/src/object/mod.rs b/crates/perry-runtime/src/object/mod.rs index fd396a6408..908773e299 100644 --- a/crates/perry-runtime/src/object/mod.rs +++ b/crates/perry-runtime/src/object/mod.rs @@ -217,7 +217,7 @@ pub(crate) use native_module_registry::nm_ctor_lookup; // (`fs/promises` → `fs.constants`, `sys` → `util`). pub(crate) use native_module_registry::{ js_install_global_value_surfaces, js_nm_install_events, js_nm_install_fs, js_nm_install_module, - js_nm_install_perf, js_nm_install_readline, js_nm_install_util, + js_nm_install_perf, js_nm_install_readline, js_nm_install_tls, js_nm_install_util, }; mod literal_constructor; mod native_module_stream; diff --git a/crates/perry-runtime/src/object/native_module_dispatch.rs b/crates/perry-runtime/src/object/native_module_dispatch.rs index b9a62e962f..70d6ff04dd 100644 --- a/crates/perry-runtime/src/object/native_module_dispatch.rs +++ b/crates/perry-runtime/src/object/native_module_dispatch.rs @@ -356,8 +356,8 @@ pub(crate) use dispatch_d_i::{ nm_dispatch_http, nm_dispatch_inspector, }; pub(crate) use dispatch_m_p::{ - nm_ctor_net, nm_dispatch_module, nm_dispatch_net, nm_dispatch_node_pty, nm_dispatch_os, - nm_dispatch_path, nm_dispatch_perf, nm_dispatch_process, + js_net_subclass_init, nm_ctor_net, nm_dispatch_module, nm_dispatch_net, nm_dispatch_node_pty, + nm_dispatch_os, nm_dispatch_path, nm_dispatch_perf, nm_dispatch_process, }; pub(crate) use dispatch_q_u::{ nm_dispatch_punycode, nm_dispatch_querystring, nm_dispatch_readline, nm_dispatch_repl, diff --git a/crates/perry-runtime/src/object/native_module_dispatch/dispatch_m_p.rs b/crates/perry-runtime/src/object/native_module_dispatch/dispatch_m_p.rs index 1e639f31d5..5d4bd775b1 100644 --- a/crates/perry-runtime/src/object/native_module_dispatch/dispatch_m_p.rs +++ b/crates/perry-runtime/src/object/native_module_dispatch/dispatch_m_p.rs @@ -162,6 +162,41 @@ unsafe fn net_provider_dispatch(ctx: &NmCtx, name: &str) -> f64 { dispatch(name.as_ptr(), name.len(), ctx.args_ptr, ctx.args_len) } +/// Initialize a proven native net base on its existing derived receiver. +/// Only constructor export metadata chooses the family; no receiver name or +/// transport id participates in attachment. +#[no_mangle] +pub unsafe extern "C" fn js_net_subclass_init( + receiver: f64, + options: f64, + callback: f64, + server: u32, +) -> f64 { + let scope = crate::gc::RuntimeHandleScope::new(); + let receiver = scope.root_nanbox_f64(receiver); + let options = scope.root_nanbox_f64(options); + let callback = scope.root_nanbox_f64(callback); + let args = [ + receiver.get_nanbox_f64(), + options.get_nanbox_f64(), + callback.get_nanbox_f64(), + ]; + let ctx = NmCtx { + obj: std::ptr::null(), + args_ptr: args.as_ptr(), + args_len: args.len(), + assert_skip_prototype: false, + }; + net_provider_dispatch( + &ctx, + if server != 0 { + "Server.init" + } else { + "Socket.init" + }, + ) +} + /// `new` on a bound node:net class value (`const Sock = net.Socket; new Sock()`, /// pg's function-local `new (require('net')).Socket()`). Registered by /// `js_nm_install_net`; the classes are provider-owned like the factories. diff --git a/crates/perry-runtime/src/tls.rs b/crates/perry-runtime/src/tls.rs index 9d0a777cf4..ea29793897 100644 --- a/crates/perry-runtime/src/tls.rs +++ b/crates/perry-runtime/src/tls.rs @@ -9,9 +9,8 @@ use crate::array::ArrayHeader; use crate::object::ObjectHeader; use crate::string::StringHeader; use crate::value::{JSValue, TAG_UNDEFINED}; -use std::collections::HashMap; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; -use std::sync::{Mutex, OnceLock}; +use std::sync::Mutex; pub const CLASS_ID_TLS_SECURE_CONTEXT: u32 = 0xFFFF_00B5; @@ -47,7 +46,6 @@ static DEFAULT_CA_CONFIG: Mutex>> = Mutex::new(None); /// default array that predates the latest configuration. Starts at 1: a /// thread's cache generation 0 means "never built". static DEFAULT_CA_GENERATION: AtomicU64 = AtomicU64::new(1); -static TLS_CLIENT_METADATA: OnceLock>> = OnceLock::new(); /// `tls.connect` as perry-ext-net implements it (`js_tls_connect`). pub type TlsConnectProviderFn = unsafe extern "C" fn(f64, f64, f64, f64) -> i64; @@ -88,49 +86,72 @@ pub struct TlsClientMetadata { pub peer_certificate: Vec, pub own_certificate: Vec, pub connected: bool, - pub check_server_identity: i64, pub session_supplied: bool, } -fn client_metadata() -> &'static Mutex> { - // #11541: the map registers its thread-exit hook when it is created (every - // insert goes through here; the release reads the `OnceLock` directly) - // instead of being named in `arena::thread_exit`'s dispatcher, which - // linked it into every binary. - crate::once_init::get_or_init(&TLS_CLIENT_METADATA, || { - crate::arena::thread_exit::register_thread_exit_range_hook( - release_tls_client_metadata_in_freed_ranges, - ); - Mutex::new(HashMap::new()) - }) +/// TLS facts belong to the actual Socket's ordinary hidden JS state. +/// The attached family's class proves the state receiver, including subclasses; +/// no id lookup, constructor name or Rust projection is involved. +fn client_state(handle: i64, create: bool) -> Option { + let value = ptr_value(handle as *mut u8); + (crate::native_payload::attached_family_class_id(value) + == Some(crate::native_class_ids::NET_SOCKET)) + .then(|| crate::native_payload::js_state_object(value, create)) + .filter(|value| !JSValue::from_bits(value.to_bits()).is_undefined()) } - -/// #11471: drop every client record whose `checkServerIdentity` closure lives -/// in an exiting thread's arena. The map is process-global while -/// `tls.connect` can run on any thread, and closing a socket keeps its record, -/// so a left-behind entry would be rooted as a dangling address and called by a -/// late `js_tls_client_check_identity`. -pub(crate) fn release_tls_client_metadata_in_freed_ranges( - freed: &crate::arena::thread_exit::FreedRanges, -) { - if let Some(map) = TLS_CLIENT_METADATA.get() { - map.lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .retain(|_, metadata| !freed.holds_i64(metadata.check_server_identity)); - } +fn state_field(state: f64, key: &str) -> f64 { + object_ptr(state) + .map(|object| get_field(object, key)) + .unwrap_or_else(|| f64::from_bits(TAG_UNDEFINED)) } - pub fn tls_client_metadata(handle: i64) -> Option { - client_metadata().lock().ok()?.get(&handle).cloned() + let scope = crate::gc::RuntimeHandleScope::new(); + let owner = scope.root_nanbox_f64(ptr_value(handle as *mut u8)); + let state = scope.root_nanbox_f64(client_state( + crate::value::js_nanbox_get_pointer(owner.get_nanbox_f64()), + false, + )?); + if !JSValue::from_bits(state_field(state.get_nanbox_f64(), "encrypted").to_bits()).to_bool() { + return None; + } + let string = |key| strict_string(state_field(state.get_nanbox_f64(), key)); + let bytes = |key| { + let value = state_field(state.get_nanbox_f64(), key); + crate::buffer::bytes::no_gc(|scope| { + crate::buffer::bytes::bytes(value, scope) + .map(<[u8]>::to_vec) + .unwrap_or_default() + }) + }; + let connected = !JSValue::from_bits(state_field(state.get_nanbox_f64(), "destroyed").to_bits()) + .to_bool() + && JSValue::from_bits(state_field(state.get_nanbox_f64(), "tlsConnected").to_bits()) + .to_bool(); + Some(TlsClientMetadata { + servername: string("servername"), + authorized: JSValue::from_bits(state_field(state.get_nanbox_f64(), "authorized").to_bits()) + .to_bool(), + authorization_error: string("authorizationError"), + protocol: connected.then(|| string("protocol")).flatten(), + alpn_protocol: string("alpnProtocol"), + peer_certificate: bytes("peerCertificateDer"), + own_certificate: bytes("ownCertificateDer"), + connected, + session_supplied: JSValue::from_bits( + state_field(state.get_nanbox_f64(), "sessionSupplied").to_bits(), + ) + .to_bool(), + }) } - pub fn is_tls_client_handle(handle: i64) -> bool { - client_metadata() - .lock() - .map(|all| all.contains_key(&handle)) - .unwrap_or(false) + let scope = crate::gc::RuntimeHandleScope::new(); + let owner = scope.root_nanbox_f64(ptr_value(handle as *mut u8)); + client_state( + crate::value::js_nanbox_get_pointer(owner.get_nanbox_f64()), + false, + ) + .is_some_and(|state| JSValue::from_bits(state_field(state, "encrypted").to_bits()).to_bool()) } - #[no_mangle] pub extern "C" fn js_tls_client_is_connected(handle: i64) -> i32 { tls_client_metadata(handle) @@ -450,13 +471,6 @@ pub fn scan_tls_roots_mut(visitor: &mut crate::gc::RuntimeRootVisitor<'_>) { visitor.visit_atomic_nanbox_u64_slot(slot, Ordering::Relaxed, Ordering::Relaxed) }); } - if let Ok(mut all) = client_metadata().lock() { - for metadata in all.values_mut() { - if metadata.check_server_identity != 0 { - visitor.visit_i64_slot(&mut metadata.check_server_identity); - } - } - } } pub fn tls_shared_signature_algorithms() -> f64 { @@ -471,9 +485,7 @@ pub fn tls_shared_signature_algorithms() -> f64 { value } -/// Register a TLS client handle synchronously, before `tls.connect()` returns. -/// The external net archive calls this bridge too, keeping its small numeric -/// socket handles visible to the stdlib TLS class/property dispatcher. +/// Publish TLS options on the actual Socket before connect returns. #[no_mangle] pub unsafe extern "C" fn js_tls_client_record_start( handle: i64, @@ -481,45 +493,56 @@ pub unsafe extern "C" fn js_tls_client_record_start( servername_ptr: *const u8, servername_len: usize, ) { - if handle <= 0 { + let scope = crate::gc::RuntimeHandleScope::new(); + let owner = scope.root_nanbox_f64(ptr_value(handle as *mut u8)); + let options = scope.root_nanbox_f64(options); + let Some(state) = client_state( + crate::value::js_nanbox_get_pointer(owner.get_nanbox_f64()), + true, + ) else { return; - } + }; + let state = scope.root_raw_mut_ptr(object_ptr(state).unwrap() as *mut ObjectHeader); let servername = if servername_ptr.is_null() { None } else { std::str::from_utf8(std::slice::from_raw_parts(servername_ptr, servername_len)) .ok() .map(str::to_string) - .filter(|name| !name.is_empty()) }; - let check_server_identity = object_ptr(options) - .map(|obj| get_field(obj, "checkServerIdentity")) - .filter(|value| { - let js = JSValue::from_bits(value.to_bits()); - js.is_pointer() && crate::closure::is_closure_ptr(js.as_pointer::() as usize) - }) - .map(|value| (value.to_bits() & crate::value::POINTER_MASK) as i64) - .unwrap_or(0); - let session_supplied = object_ptr(options).is_some_and(|object| { - let value = get_field(object, "session"); - let js = JSValue::from_bits(value.to_bits()); - !js.is_undefined() && !js.is_null() + let callback = scope.root_nanbox_f64( + object_ptr(options.get_nanbox_f64()) + .map(|obj| get_field(obj, "checkServerIdentity")) + .unwrap_or_else(|| f64::from_bits(TAG_UNDEFINED)), + ); + let session_supplied = object_ptr(options.get_nanbox_f64()).is_some_and(|obj| { + let value = JSValue::from_bits(get_field(obj, "session").to_bits()); + !value.is_undefined() && !value.is_null() }); - client_metadata().lock().unwrap().insert( - handle, - TlsClientMetadata { - servername, - authorized: false, - authorization_error: None, - protocol: Some("TLSv1.3".to_string()), - alpn_protocol: None, - peer_certificate: Vec::new(), - own_certificate: Vec::new(), - connected: false, - check_server_identity, - session_supplied, - }, + set_rooted_object_field(&state, "checkServerIdentity", callback.get_nanbox_f64()); + set_rooted_object_field( + &state, + "sessionSupplied", + f64::from_bits(JSValue::bool(session_supplied).bits()), + ); + set_rooted_object_field( + &state, + "encrypted", + f64::from_bits(JSValue::bool(true).bits()), + ); + set_rooted_object_field( + &state, + "tlsConnected", + f64::from_bits(JSValue::bool(false).bits()), + ); + set_rooted_object_field( + &state, + "authorized", + f64::from_bits(JSValue::bool(false).bits()), ); + if let Some(servername) = servername { + set_rooted_object_field(&state, "servername", string_value(&servername)); + } } #[no_mangle] @@ -537,59 +560,100 @@ pub unsafe extern "C" fn js_tls_client_record_connected( own_cert_ptr: *const u8, own_cert_len: usize, ) { - let copy_string = |ptr: *const u8, len: usize| { - if ptr.is_null() || len == 0 { - None - } else { - std::str::from_utf8(std::slice::from_raw_parts(ptr, len)) - .ok() - .map(str::to_string) - } + let scope = crate::gc::RuntimeHandleScope::new(); + let owner = scope.root_nanbox_f64(ptr_value(handle as *mut u8)); + let Some(state) = client_state( + crate::value::js_nanbox_get_pointer(owner.get_nanbox_f64()), + true, + ) else { + return; }; - let copy_bytes = |ptr: *const u8, len: usize| { - if ptr.is_null() || len == 0 { - Vec::new() + let state = scope.root_raw_mut_ptr(object_ptr(state).unwrap() as *mut ObjectHeader); + for (key, ptr, len) in [ + ( + "authorizationError", + authorization_error_ptr, + authorization_error_len, + ), + ("protocol", protocol_ptr, protocol_len), + ("alpnProtocol", alpn_ptr, alpn_len), + ] { + let value = if ptr.is_null() || len == 0 { + f64::from_bits(TAG_UNDEFINED) } else { - std::slice::from_raw_parts(ptr, len).to_vec() - } - }; - if let Some(metadata) = client_metadata().lock().unwrap().get_mut(&handle) { - metadata.authorized = authorized != 0; - metadata.authorization_error = - copy_string(authorization_error_ptr, authorization_error_len); - metadata.protocol = copy_string(protocol_ptr, protocol_len); - metadata.alpn_protocol = copy_string(alpn_ptr, alpn_len); - metadata.peer_certificate = copy_bytes(peer_cert_ptr, peer_cert_len); - metadata.own_certificate = copy_bytes(own_cert_ptr, own_cert_len); - metadata.connected = true; + string_value(&String::from_utf8_lossy(std::slice::from_raw_parts( + ptr, len, + ))) + }; + set_rooted_object_field(&state, key, value); } + for (key, ptr, len) in [ + ("peerCertificateDer", peer_cert_ptr, peer_cert_len), + ("ownCertificateDer", own_cert_ptr, own_cert_len), + ] { + let bytes = if ptr.is_null() || len == 0 { + &[][..] + } else { + std::slice::from_raw_parts(ptr, len) + }; + let buffer = crate::buffer::bytes::from_slice(crate::buffer::bytes::Brand::Buffer, bytes); + set_rooted_object_field(&state, key, buffer); + } + set_rooted_object_field( + &state, + "authorized", + f64::from_bits(JSValue::bool(authorized != 0).bits()), + ); + set_rooted_object_field( + &state, + "tlsConnected", + f64::from_bits(JSValue::bool(true).bits()), + ); } #[no_mangle] pub extern "C" fn js_tls_client_record_closed(handle: i64) { - if let Some(metadata) = client_metadata().lock().unwrap().get_mut(&handle) { - metadata.connected = false; - metadata.protocol = None; + let Some(state) = client_state(handle, false) else { + return; + }; + let scope = crate::gc::RuntimeHandleScope::new(); + let state = scope.root_raw_mut_ptr(object_ptr(state).unwrap() as *mut ObjectHeader); + unsafe { + set_rooted_object_field( + &state, + "tlsConnected", + f64::from_bits(JSValue::bool(false).bits()), + ); } } -/// Run a user supplied `checkServerIdentity` callback after the native TLS -/// handshake has produced the peer-certificate object. An `undefined` return -/// accepts the identity; any other value is emitted as the socket error. +/// The Socket JS state owns and traces the callback. Root every operand before +/// allocating the hostname or invoking user code; no payload borrow spans JS. #[no_mangle] pub extern "C" fn js_tls_client_check_identity(handle: i64, certificate: f64) -> f64 { - let Some(metadata) = tls_client_metadata(handle) else { + let scope = crate::gc::RuntimeHandleScope::new(); + let owner = scope.root_nanbox_f64(ptr_value(handle as *mut u8)); + let certificate = scope.root_nanbox_f64(certificate); + let Some(state) = client_state( + crate::value::js_nanbox_get_pointer(owner.get_nanbox_f64()), + false, + ) else { return f64::from_bits(TAG_UNDEFINED); }; - if metadata.check_server_identity == 0 { + let state = scope.root_nanbox_f64(state); + let callback = + scope.root_nanbox_f64(state_field(state.get_nanbox_f64(), "checkServerIdentity")); + let callback_ptr = crate::value::js_nanbox_get_pointer(callback.get_nanbox_f64()); + if !crate::closure::is_closure_ptr(callback_ptr as usize) { return f64::from_bits(TAG_UNDEFINED); } - let host = string_value(metadata.servername.as_deref().unwrap_or_default()); + let host = scope.root_nanbox_f64(state_field(state.get_nanbox_f64(), "servername")); crate::closure::js_closure_call2( - metadata.check_server_identity as *const crate::ClosureHeader, + crate::value::js_nanbox_get_pointer(callback.get_nanbox_f64()) + as *const crate::ClosureHeader, crate::closure::plain_call_receiver(), - host, - certificate, + host.get_nanbox_f64(), + certificate.get_nanbox_f64(), ) } @@ -669,6 +733,51 @@ fn certificate_subject_alt_name(cert: &x509_cert::Certificate) -> Option /// `checkServerIdentity` callback. This lives in the runtime (rather than the /// TLS stdlib) because the external net archive must also work in optimized /// programs whose direct `TLSSocket` use does not enable the stdlib TLS gate. +/// Decode a Socket-owned DER snapshot without retaining a GC address. +#[no_mangle] +pub unsafe extern "C" fn js_tls_client_certificate(owner: i64, own: i32, detailed: f64) -> f64 { + let scope = crate::gc::RuntimeHandleScope::new(); + let owner_root = scope.root_nanbox_f64(ptr_value(owner as *mut u8)); + let metadata = tls_client_metadata(crate::value::js_nanbox_get_pointer( + owner_root.get_nanbox_f64(), + )); + let bytes = metadata + .map(|metadata| { + if own != 0 { + metadata.own_certificate + } else { + metadata.peer_certificate + } + }) + .unwrap_or_default(); + tls_legacy_certificate_object(&bytes, JSValue::from_bits(detailed.to_bits()).to_bool()) +} + +/// TLS client transports use the Socket payload, with Node's ordinary +/// TLSSocket prototype above Socket.prototype. No transport identity changes. +#[no_mangle] +pub extern "C" fn js_tls_client_attach_socket_prototype(owner: i64) { + let scope = crate::gc::RuntimeHandleScope::new(); + let owner = scope.root_nanbox_f64(ptr_value(owner as *mut u8)); + if crate::native_payload::attached_family_class_id(owner.get_nanbox_f64()) + != Some(crate::native_class_ids::NET_SOCKET) + { + return; + } + // Direct FFI factories can reach TLS without materializing its module + // namespace. Install the canonical constructor hierarchy before minting + // TLSSocket, just as the value-form import path does. + crate::object::js_nm_install_tls(); + let constructor = scope.root_nanbox_f64(crate::object::bound_native_callable_export_value( + "tls", + "TLSSocket", + )); + let prototype = scope.root_nanbox_f64(crate::object::js_function_prototype_value_for_read( + constructor.get_nanbox_f64(), + )); + crate::object::js_object_set_prototype_of(owner.get_nanbox_f64(), prototype.get_nanbox_f64()); +} + pub unsafe fn tls_legacy_certificate_object(der: &[u8], detailed: bool) -> f64 { use x509_cert::der::Decode; let Ok(cert) = x509_cert::Certificate::from_der(der) else { @@ -710,11 +819,16 @@ pub unsafe extern "C" fn js_tls_client_check_identity_from_metadata(handle: i64) let Some(metadata) = tls_client_metadata(handle) else { return f64::from_bits(TAG_UNDEFINED); }; - if metadata.check_server_identity == 0 { - return f64::from_bits(TAG_UNDEFINED); - } - let certificate = tls_legacy_certificate_object(&metadata.peer_certificate, true); - js_tls_client_check_identity(handle, certificate) + let scope = crate::gc::RuntimeHandleScope::new(); + let owner = scope.root_nanbox_f64(ptr_value(handle as *mut u8)); + let certificate = scope.root_nanbox_f64(tls_legacy_certificate_object( + &metadata.peer_certificate, + true, + )); + js_tls_client_check_identity( + crate::value::js_nanbox_get_pointer(owner.get_nanbox_f64()), + certificate.get_nanbox_f64(), + ) } pub fn js_tls_root_certificates() -> f64 { @@ -1601,6 +1715,14 @@ static KEEP_JS_TLS_CLIENT_CHECK_IDENTITY: extern "C" fn(i64, f64) -> f64 = js_tls_client_check_identity; #[cfg(feature = "keepalive-anchors")] #[used(compiler)] +static KEEP_JS_TLS_CLIENT_ATTACH_SOCKET_PROTOTYPE: extern "C" fn(i64) = + js_tls_client_attach_socket_prototype; +#[cfg(feature = "keepalive-anchors")] +#[used(compiler)] +static KEEP_JS_TLS_CLIENT_CERTIFICATE: unsafe extern "C" fn(i64, i32, f64) -> f64 = + js_tls_client_certificate; +#[cfg(feature = "keepalive-anchors")] +#[used(compiler)] static KEEP_JS_TLS_CLIENT_CHECK_IDENTITY_FROM_METADATA: unsafe extern "C" fn(i64) -> f64 = js_tls_client_check_identity_from_metadata; #[cfg(feature = "keepalive-anchors")] diff --git a/crates/perry-runtime/src/turnloop_net/abi.rs b/crates/perry-runtime/src/turnloop_net/abi.rs index a4f5c3a311..049d68138d 100644 --- a/crates/perry-runtime/src/turnloop_net/abi.rs +++ b/crates/perry-runtime/src/turnloop_net/abi.rs @@ -689,7 +689,7 @@ use crate::native_payload::OwnerLink; /// installs accepted connections itself). Returns nonzero on success. #[no_mangle] pub extern "C" fn js_perry_net_register_link_sink(subsystem: i32, sink: SinkFn) -> i32 { - if subsystem < 0 || subsystem as usize >= super::MAX_SUBSYSTEMS { + if subsystem < 0 || subsystem as usize >= super::MAX_LINK_ROUTES { return 0; } i32::from(super::register_link_sink(subsystem as u8, sink)) @@ -703,7 +703,7 @@ pub extern "C" fn js_perry_net_register_link_sink(subsystem: i32, sink: SinkFn) /// `core` is writable for `TRANSPORT_CORE_WORDS` words and 8-aligned. #[no_mangle] pub unsafe extern "C" fn js_perry_net_core_init(core: *mut c_void, route: i32) -> i32 { - if core.is_null() || route < 0 || route as usize >= super::MAX_SUBSYSTEMS { + if core.is_null() || route < 0 || route as usize >= super::MAX_LINK_ROUTES { return PERRY_NET_ERR; } // SAFETY: forwarded contract. @@ -1159,7 +1159,7 @@ pub unsafe extern "C" fn js_perry_net_link_set_route( route: i32, err: *mut PerryNetError, ) -> i32 { - if !(0..super::MAX_SUBSYSTEMS as i32).contains(&route) { + if !(0..super::MAX_LINK_ROUTES as i32).contains(&route) { return finish(Err(super::transport::bad("route")), err); } finish( @@ -1167,3 +1167,93 @@ pub unsafe extern "C" fn js_perry_net_link_set_route( err, ) } + +/// The installed handle as four u32s; 0 when the core holds none. +/// +/// # Safety +/// As [`js_perry_net_link_tcp_listen`]; `out` names four writable u32s. +#[no_mangle] +pub unsafe extern "C" fn js_perry_net_link_handle_parts( + core: *mut c_void, + link: usize, + out: *mut u32, +) -> i32 { + match unsafe { transport::snapshot_handle_parts(core.cast(), link_arg(link)) } { + Some(parts) if !out.is_null() => { + unsafe { std::ptr::copy_nonoverlapping(parts.as_ptr(), out, 4) }; + 1 + } + _ => 0, + } +} + +/// Copy the core's current capability (handle or pending resolve) into the +/// caller's opaque [`transport::HANDLE_SNAPSHOT_WORDS`]-word block. +/// +/// # Safety +/// As [`js_perry_net_link_tcp_listen`]; `out` names the opaque block. +#[no_mangle] +pub unsafe extern "C" fn js_perry_net_link_snapshot_handle( + core: *mut c_void, + link: usize, + out: *mut c_void, +) -> i32 { + match unsafe { transport::snapshot_handle(core.cast(), link_arg(link)) } { + Some(snapshot) if !out.is_null() => { + unsafe { std::ptr::write(out.cast::(), snapshot) }; + 1 + } + _ => 0, + } +} + +/// Whether the core still holds the snapshot's handle or resolve. +/// +/// # Safety +/// As [`js_perry_net_link_tcp_listen`]; `snapshot` came from +/// [`js_perry_net_link_snapshot_handle`] returning 1. +#[no_mangle] +pub unsafe extern "C" fn js_perry_net_link_handle_matches( + core: *mut c_void, + link: usize, + snapshot: *const c_void, +) -> i32 { + if snapshot.is_null() { + return 0; + } + let snapshot = unsafe { std::ptr::read(snapshot.cast::()) }; + i32::from(unsafe { transport::handle_matches(core.cast(), link_arg(link), snapshot) }) +} + +/// Heap bytes the core owns (backlog, write records, plan, pipe path). +/// +/// # Safety +/// As [`js_perry_net_link_tcp_listen`]. +#[no_mangle] +pub unsafe extern "C" fn js_perry_net_link_retained_bytes(core: *mut c_void, link: usize) -> usize { + unsafe { transport::retained_bytes(core.cast(), link_arg(link)) } +} + +/// Deliver decoded TLS plaintext (or its EOF) on the core's current route. +/// +/// # Safety +/// As [`js_perry_net_link_tcp_listen`]; `bytes` is readable for `len`. +#[no_mangle] +pub unsafe extern "C" fn js_perry_net_link_dispatch_plaintext( + core: *mut c_void, + link: usize, + bytes: *const u8, + len: usize, + eof: i32, + err: *mut PerryNetError, +) -> i32 { + let bytes = if bytes.is_null() || len == 0 { + &[][..] + } else { + unsafe { std::slice::from_raw_parts(bytes, len) } + }; + finish( + unsafe { transport::dispatch_plaintext(core.cast(), link_arg(link), bytes, eof != 0) }, + err, + ) +} diff --git a/crates/perry-runtime/src/turnloop_net/errors.rs b/crates/perry-runtime/src/turnloop_net/errors.rs index 076008e8ef..a0e049b66c 100644 --- a/crates/perry-runtime/src/turnloop_net/errors.rs +++ b/crates/perry-runtime/src/turnloop_net/errors.rs @@ -217,6 +217,8 @@ pub(super) fn invalid_input(syscall: &'static str) -> NodeError { } } +/// Only a target with no socket adoption reaches this. +#[cfg(not(any(unix, windows, target_os = "wasi")))] pub(super) fn unsupported(syscall: &'static str) -> NodeError { NodeError { code: "ENOTSUP", diff --git a/crates/perry-runtime/src/turnloop_net/mod.rs b/crates/perry-runtime/src/turnloop_net/mod.rs index 74c9adf3f4..917de4cbee 100644 --- a/crates/perry-runtime/src/turnloop_net/mod.rs +++ b/crates/perry-runtime/src/turnloop_net/mod.rs @@ -76,7 +76,8 @@ mod tests; pub use errors::{map_error, NodeError}; pub use sink::{ - register_link_sink, register_sink, sink_installed, NetCompletion, SinkFn, MAX_SUBSYSTEMS, + register_link_sink, register_sink, sink_installed, NetCompletion, SinkFn, MAX_LINK_ROUTES, + MAX_SUBSYSTEMS, }; pub use transport::{TransportCore, TransportPayload, TRANSPORT_CORE_WORDS}; // P6: the completion kinds, for an in-tree subsystem. A separately linked @@ -84,8 +85,8 @@ pub use transport::{TransportCore, TransportPayload, TRANSPORT_CORE_WORDS}; // own copy; perry-stdlib has a Cargo edge to this crate and must not need a // second declaration to keep in step with. pub use sink::{ - NET_ACCEPT, NET_CLOSED, NET_CONNECT, NET_DATA, NET_EOF, NET_ERROR, NET_FLAG_LINK, NET_SHUTDOWN, - NET_TIMER, NET_WROTE, + NET_ACCEPT, NET_CLOSED, NET_CONNECT, NET_DATA, NET_EOF, NET_ERROR, NET_FLAG_HANDLE_PARTS, + NET_FLAG_LINK, NET_FLAG_PLAINTEXT, NET_FLAG_STALE, NET_SHUTDOWN, NET_TIMER, NET_WROTE, }; // ── Operation classes, carried in the top 8 bits of every submission token ── diff --git a/crates/perry-runtime/src/turnloop_net/sink.rs b/crates/perry-runtime/src/turnloop_net/sink.rs index f1d5eb5fed..00bb7c1ac5 100644 --- a/crates/perry-runtime/src/turnloop_net/sink.rs +++ b/crates/perry-runtime/src/turnloop_net/sink.rs @@ -56,7 +56,11 @@ use super::NodeError; /// (`perry-db-turnloop::subsystem`, which is the one authority for that band), /// and [`register_sink`] refuses a slot already held by a *different* sink, so /// a future collision declines loudly instead of silently misrouting. -pub const MAX_SUBSYSTEMS: usize = 16; +pub const MAX_SUBSYSTEMS: usize = 32; + +/// Link routes travel in a link token's 4-bit route field, so only the first +/// 16 slots can hold a link sink; id-routed sinks may use any slot. +pub const MAX_LINK_ROUTES: usize = 16; /// A completion sink: called on the loop-owning thread, once per completion. pub type SinkFn = extern "C" fn(*const NetCompletion); @@ -87,6 +91,14 @@ pub const NET_ERROR: i32 = 8; /// `NetCompletion::flags`: the completion comes from a link route, so `id` is /// the payload's owner link and an accept's `conn` is its install slot. pub const NET_FLAG_LINK: i32 = 1; +/// A link `Closed` for an earlier handle of a reopened, OPEN cell: only the +/// terminal event; the new transport is not marked closed. +pub const NET_FLAG_STALE: i32 = 2; +/// A link `Closed` lends four u32s (`data`/`len`) naming its generational +/// driver handle, for callback context the owner keeps in its JS state. +pub const NET_FLAG_HANDLE_PARTS: i32 = 4; +/// TLS plaintext, already decoded, delivered on the socket's current route. +pub const NET_FLAG_PLAINTEXT: i32 = 8; /// A subsystem-owned deadline expired (P5). `id` names the deadline, which is /// the caller's own id — a connection's, not a socket handle's. pub const NET_TIMER: i32 = 9; @@ -319,7 +331,7 @@ pub fn register_sink(subsystem: u8, sink: SinkFn, alloc: AllocFn) -> bool { /// holds, and for a slot already registered without `links`. pub fn register_link_sink(subsystem: u8, sink: SinkFn) -> bool { let slot = subsystem as usize; - if slot >= MAX_SUBSYSTEMS { + if slot >= MAX_LINK_ROUTES { return false; } let held = SINKS[slot].load(Ordering::Acquire); diff --git a/crates/perry-runtime/src/turnloop_net/transport.rs b/crates/perry-runtime/src/turnloop_net/transport.rs index ffce2b524e..cde31ca94b 100644 --- a/crates/perry-runtime/src/turnloop_net/transport.rs +++ b/crates/perry-runtime/src/turnloop_net/transport.rs @@ -122,6 +122,12 @@ impl TransportCore { #[cfg(test)] pub(crate) fn read_operation(&self) -> Option { + self.read_op() + } + + /// The armed read operation, for transport identity witnesses. Submits + /// no work and takes no ref. + pub fn read_op(&self) -> Option { self.entry.as_ref().and_then(|e| e.read_op) } @@ -136,6 +142,132 @@ impl TransportCore { pub fn set_route(&mut self, route: u8) { self.route = route; } + + /// Heap capacity owned by this core, excluding the inline core and the + /// driver's buffers. This query submits no work and takes no ref. + fn retained_bytes(&self) -> usize { + use std::mem::size_of; + let mut bytes = self.backlog.bytes.capacity() + + self.backlog.writes.capacity() * size_of::(); + if let Some(entry) = &self.entry { + bytes += entry.writes.capacity() * size_of::(); + if let Some(path) = &entry.path { + bytes += path.capacity(); + } + } + if let Some(plan) = &self.plan { + bytes += size_of::() + plan.remaining.capacity() * size_of::(); + } + bytes + } +} + +/// Deliver TLS plaintext to this cell's current route. No driver operation: +/// no token, multishot read or ref changes. The core borrow ends before the +/// sink runs, which may close or reopen the rooted owner. +/// +/// # Safety +/// core/link name an OPEN payload whose owner is rooted; bytes outlives the +/// sink call. The caller re-projects before any later core use. +pub(super) unsafe fn dispatch_plaintext( + core: *mut TransportCore, + link: OwnerLink, + bytes: &[u8], + eof: bool, +) -> NetResult<()> { + let route = { + let (core, _) = bind(core, link, "plaintext")?; + core.route + }; + let mut event = if eof { + NetCompletion::eof(link.0 as i64) + } else { + NetCompletion::data(link.0 as i64, bytes) + } + .linked(); + if !sabotage("plaintext_flag") { + event.flags |= sink::NET_FLAG_PLAINTEXT; + } + sink::emit(route, event); + Ok(()) +} + +/// # Safety +/// As bind. +pub(super) unsafe fn retained_bytes(core: *mut TransportCore, link: OwnerLink) -> usize { + bind(core, link, "retained_bytes") + .map(|(core, _)| core.retained_bytes()) + .unwrap_or(0) +} + +/// An ephemeral copy of the driver's capability for checking the incarnation +/// after JS re-entry. A hostname connect may hold only its resolve OpId. +/// Neither variant mints an identity or holds a ref. +#[derive(Clone, Copy)] +pub(super) enum HandleSnapshot { + Handle(Handle), + Resolve(OpId), +} + +pub(super) const HANDLE_SNAPSHOT_WORDS: usize = 4; +const _: () = assert!( + std::mem::size_of::() <= HANDLE_SNAPSHOT_WORDS * 8 + && std::mem::align_of::() <= 8 +); + +/// The driver's generational handle as four u32s (each exact as a JS +/// number). No identity is minted. +fn handle_parts(handle: Handle) -> [u32; 4] { + let owner = handle.owner(); + let key = handle.key(); + [ + owner as u32, + (owner >> 32) as u32, + key as u32, + (key >> 32) as u32, + ] +} + +/// # Safety +/// As bind. +pub(super) unsafe fn snapshot_handle_parts( + core: *mut TransportCore, + link: OwnerLink, +) -> Option<[u32; 4]> { + let (core, _) = bind(core, link, "handle").ok()?; + core.handle().map(handle_parts) +} + +/// # Safety +/// As bind; the caller roots the owner across the JS call that follows. +pub(super) unsafe fn snapshot_handle( + core: *mut TransportCore, + link: OwnerLink, +) -> Option { + let (core, _) = bind(core, link, "handle").ok()?; + core.handle().map(HandleSnapshot::Handle).or_else(|| { + core.plan + .as_ref() + .and_then(|plan| plan.op) + .map(HandleSnapshot::Resolve) + }) +} + +/// # Safety +/// As bind. Compare against the newly projected core, never an address saved +/// from an earlier payload. +pub(super) unsafe fn handle_matches( + core: *mut TransportCore, + link: OwnerLink, + snapshot: HandleSnapshot, +) -> bool { + if sabotage("continuation_handle") { + return true; + } + bind(core, link, "handle").is_ok_and(|(core, _)| match snapshot { + HandleSnapshot::Handle(handle) => core.handle() == Some(handle), + HandleSnapshot::Resolve(op) => core.plan.as_ref().and_then(|plan| plan.op) == Some(op), + }) } /// A family payload that owns a transport: the core first, so the runtime @@ -1196,6 +1328,25 @@ unsafe fn dispatch_resolve(link: OwnerLink, op_id: Option, terminal: bool, } } +/// Inspect a resolve incarnation in acceptance tests. +/// +/// # Safety +/// `cell` is a live transport payload cell on this thread. +#[cfg(any(test, feature = "native-payload-test-census"))] +pub unsafe fn pending_resolve_for_test(cell: usize) -> Option { + core_of(OwnerLink(cell)).and_then(|core| core.plan.as_ref().and_then(|plan| plan.op)) +} + +/// Replay a resolve completion in acceptance tests. Nonterminal: the real +/// driver completion still owns its ref. +/// +/// # Safety +/// As [`pending_resolve_for_test`]. +#[cfg(any(test, feature = "native-payload-test-census"))] +pub unsafe fn replay_resolve_for_test(cell: usize, op: OpId, addresses: Vec) { + dispatch_resolve(OwnerLink(cell), Some(op), false, OpResult::Resolved(addresses)); +} + /// Start the plan's next address, or report its final failure. unsafe fn attempt_next_address(link: OwnerLink) { loop { @@ -1290,6 +1441,7 @@ unsafe fn dispatch_stream( OpResult::Closed => { let mut route = route; let mut retry = false; + let mut stale = false; if let Some(c) = current(link, handle) { route = c.route; c.entry = None; @@ -1305,12 +1457,26 @@ unsafe fn dispatch_stream( } } else if let Some(c) = core_of(link) { route = c.route; + stale = true; } if retry { // A failed attempt's handle, not the socket the caller sees. attempt_next_address(link); } else { - sink::emit(route, NetCompletion::closed(id).linked()); + let mut event = NetCompletion::closed(id).linked(); + // Callback context belongs to the closing incarnation even if + // the owner already has a new payload. Lend the handle's + // parts for the sink call only. + let parts = handle.map(handle_parts); + if let Some(parts) = parts.as_ref() { + event.flags |= sink::NET_FLAG_HANDLE_PARTS; + event.data = parts.as_ptr().cast(); + event.len = std::mem::size_of_val(parts); + } + if stale && !sabotage("stale_closed_flag") { + event.flags |= sink::NET_FLAG_STALE; + } + sink::emit(route, event); } // The handle's ref, taken at install. Last: the sink ran with the // cell pinned. diff --git a/scripts/native_handle_ledger.py b/scripts/native_handle_ledger.py index 9996bd9641..b67f525642 100755 --- a/scripts/native_handle_ledger.py +++ b/scripts/native_handle_ledger.py @@ -388,7 +388,7 @@ def self_test() -> int: if missing: print(f"native_handle_ledger self-test FAILED: stale classification entries: {missing}") return 1 - if sum(tables.values()) < 150 or sum(producers.values()) < 150: + if sum(tables.values()) < 150 or sum(producers.values()) < 100: print("native_handle_ledger self-test FAILED: implausibly small real-tree census") return 1 print( diff --git a/scripts/native_handle_ledger.txt b/scripts/native_handle_ledger.txt index 09ba672407..325e1c4059 100644 --- a/scripts/native_handle_ledger.txt +++ b/scripts/native_handle_ledger.txt @@ -7,8 +7,6 @@ 0 1 crates/perry-ext-http/src/agent.rs 0 1 crates/perry-ext-http/src/agent/tls_compat.rs 0 3 crates/perry-ext-http/src/client_events.rs -1 0 crates/perry-ext-http/src/client_request_surface.rs -0 1 crates/perry-ext-http/src/client_turnloop/mod.rs 1 1 crates/perry-ext-http/src/lib.rs 1 1 crates/perry-ext-http/src/server/bun_server.rs 0 2 crates/perry-ext-http/src/server/http2_server.rs @@ -24,17 +22,6 @@ 2 0 crates/perry-ext-http/src/server/turnloop_h2/mod.rs 2 0 crates/perry-ext-http/src/server/turnloop_serve/conn.rs 1 1 crates/perry-ext-http/src/server/turnloop_serve/mod.rs -0 1 crates/perry-ext-net/src/adopt.rs -3 0 crates/perry-ext-net/src/bun_tcp.rs -2 2 crates/perry-ext-net/src/classes.rs -0 1 crates/perry-ext-net/src/handle_ids.rs -0 1 crates/perry-ext-net/src/ipc.rs -7 3 crates/perry-ext-net/src/lib.rs -1 0 crates/perry-ext-net/src/lifecycle.rs -1 0 crates/perry-ext-net/src/pipe.rs -1 0 crates/perry-ext-net/src/tls.rs -3 2 crates/perry-ext-net/src/turnloop_io.rs -1 0 crates/perry-ext-net/src/turnloop_tls_io.rs 0 1 crates/perry-ext-nodemailer/src/lib.rs 1 0 crates/perry-ext-nodemailer/src/turnloop_bridge.rs 1 2 crates/perry-ext-parcel-watcher/src/lib.rs @@ -93,7 +80,6 @@ 1 0 crates/perry-runtime/src/shared_sab.rs 4 0 crates/perry-runtime/src/symbol.rs 1 0 crates/perry-runtime/src/symbol/accessors.rs -1 0 crates/perry-runtime/src/tls.rs 1 0 crates/perry-runtime/src/tui/hooks.rs 1 0 crates/perry-runtime/src/tui/state.rs 1 0 crates/perry-runtime/src/tui/tree.rs From 62147609b3a531db3c87894403e449fddba6be34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 9 Oct 2026 04:42:34 +0200 Subject: [PATCH 03/18] feat(http): HTTP/1 connections are net.Socket payloads; upgrade is a route store ext-http's server and client HTTP/1 connections are net.Socket payloads (decision 77), so an HTTP upgrade is one store to the connection's route: the listener receives the same Socket, with head bytes and every later byte as its own data. The HIR no longer tags an upgrade listener's socket parameter as a ws Client (the static tag sent its data listener to a ws id table that never fired); ws client handles keep their own dynamic dispatch. The HTTP/2 client gets its own id-routed slot 16; the driver keeps 32 sink slots, and link routes use the 16 that fit the link token's route field. --- changelog.d/12064-net-transport-phase-a.md | 14 + crates/perry-ext-http/Cargo.toml | 2 +- crates/perry-ext-http/src/agent.rs | 573 +++---- crates/perry-ext-http/src/agent/tls_compat.rs | 65 +- crates/perry-ext-http/src/client_abort.rs | 4 +- .../src/client_connect_override.rs | 102 +- crates/perry-ext-http/src/client_events.rs | 66 +- .../src/client_request_surface.rs | 39 +- .../src/client_turnloop/conn.rs | 1140 ------------- .../perry-ext-http/src/client_turnloop/mod.rs | 658 ++----- .../src/client_turnloop/native_client.rs | 572 +++++++ .../src/client_turnloop/pool.rs | 29 +- .../src/client_turnloop/protocol.rs | 808 +++++++++ .../src/client_turnloop/raw_socket.rs | 309 ---- .../perry-ext-http/src/client_turnloop/tls.rs | 52 +- crates/perry-ext-http/src/lib.rs | 283 +-- crates/perry-ext-http/src/pending_dispatch.rs | 22 +- crates/perry-ext-http/src/plain_client.rs | 116 -- crates/perry-ext-http/src/response_headers.rs | 2 +- crates/perry-ext-http/src/root_scanner.rs | 53 +- .../src/server/handle_dispatch.rs | 30 +- .../perry-ext-http/src/server/http2_server.rs | 11 +- .../src/server/http2_server/controls.rs | 2 +- .../src/server/http2_server/dispatch.rs | 9 +- .../src/server/http2_server/pump.rs | 3 + .../src/server/http2_server/session.rs | 58 +- .../src/server/http2_server/turnloop_glue.rs | 45 +- .../server/http2_server/turnloop_listen.rs | 9 +- crates/perry-ext-http/src/server/mod.rs | 65 +- crates/perry-ext-http/src/server/request.rs | 67 +- crates/perry-ext-http/src/server/response.rs | 27 +- .../src/server/response_turnloop.rs | 43 +- crates/perry-ext-http/src/server/server.rs | 122 +- .../src/server/server/turnloop_listen.rs | 31 +- .../src/server/turnloop_h2/conn.rs | 479 +++--- .../src/server/turnloop_h2/control.rs | 21 +- .../src/server/turnloop_h2/mod.rs | 205 +-- .../src/server/turnloop_h2/native_server.rs | 325 ++++ .../src/server/turnloop_h2/stream.rs | 46 +- .../src/server/turnloop_h2/target.rs | 71 + .../src/server/turnloop_route.rs | 118 +- .../src/server/turnloop_serve/conn.rs | 591 +++---- .../src/server/turnloop_serve/mod.rs | 196 +-- crates/perry-ext-http/src/server/upgrade.rs | 29 +- crates/perry-ext-http/src/tls_client.rs | 17 + crates/perry-ext-ws/Cargo.toml | 1 + crates/perry-ext-ws/src/lib.rs | 71 +- crates/perry-ext-ws/src/native_socket.rs | 268 +++ crates/perry-hir/src/lower/context.rs | 4 +- crates/perry-hir/src/lower/context_new.rs | 1 - crates/perry-hir/src/lower/expr_call/mod.rs | 3 +- .../perry-hir/src/lower/expr_call/prescans.rs | 24 +- .../lower/expr_call/static_and_instance.rs | 2 +- crates/perry-hir/src/lower/expr_member.rs | 15 + crates/perry-hir/src/lower/lower_module_fn.rs | 7 - .../perry-hir/src/lower/lowering_context.rs | 21 +- crates/perry-hir/src/lower/pre_scan.rs | 1517 ----------------- crates/perry-hir/src/lower_decl/fn_decl.rs | 22 - crates/perry-hir/src/lower_patterns.rs | 65 - 59 files changed, 3920 insertions(+), 5630 deletions(-) create mode 100644 changelog.d/12064-net-transport-phase-a.md delete mode 100644 crates/perry-ext-http/src/client_turnloop/conn.rs create mode 100644 crates/perry-ext-http/src/client_turnloop/native_client.rs create mode 100644 crates/perry-ext-http/src/client_turnloop/protocol.rs delete mode 100644 crates/perry-ext-http/src/client_turnloop/raw_socket.rs delete mode 100644 crates/perry-ext-http/src/plain_client.rs create mode 100644 crates/perry-ext-http/src/server/turnloop_h2/native_server.rs create mode 100644 crates/perry-ext-http/src/server/turnloop_h2/target.rs create mode 100644 crates/perry-ext-ws/src/native_socket.rs diff --git a/changelog.d/12064-net-transport-phase-a.md b/changelog.d/12064-net-transport-phase-a.md new file mode 100644 index 0000000000..30e1304e4f --- /dev/null +++ b/changelog.d/12064-net-transport-phase-a.md @@ -0,0 +1,14 @@ +`net.Socket` and `net.Server` are native payloads with a TransportCore, and +their completions route by link token through the payload cell. perry-ext-net +no longer keeps id-keyed socket or server tables (19 tables and 10 producers +in the native handle ledger, now 0 and 0), so a Socket that closes and is +dropped is collected and its WeakRefs clear, as in Node. + +HTTP/1 server and client connections are `net.Socket` payloads. An HTTP +`upgrade` is a single store to the connection's route: the listener gets the +same Socket, with the bytes that arrived with the head as `head` and every +later byte as its own `data`. The upgrade listener's socket parameter is an +ordinary value; the compiler no longer tags it as a `ws` Client, which used to +register its `data` listener in a ws id table that never fired. A server with +an attached `WebSocketServer` still hands the listener a ws client handle, +which dispatches through ws's own handle methods. diff --git a/crates/perry-ext-http/Cargo.toml b/crates/perry-ext-http/Cargo.toml index 22749c13d9..7d95db8eba 100644 --- a/crates/perry-ext-http/Cargo.toml +++ b/crates/perry-ext-http/Cargo.toml @@ -42,5 +42,5 @@ x509-cert = { version = "0.3", default-features = false } [dev-dependencies] # GC and async test shims call runtime internals; production code uses only perry-ffi. -perry-runtime = { workspace = true, features = ["default", "stdlib"] } +perry-runtime = { workspace = true, features = ["default", "stdlib", "native-payload-test-census"] } perry-ffi = { workspace = true, features = ["runtime-link"] } diff --git a/crates/perry-ext-http/src/agent.rs b/crates/perry-ext-http/src/agent.rs index a1eee7131a..f1fd350cac 100644 --- a/crates/perry-ext-http/src/agent.rs +++ b/crates/perry-ext-http/src/agent.rs @@ -25,8 +25,8 @@ //! `http.request` services a request whose agent defines a //! `createConnection` or `createSocket` override, the override is invoked //! (on the main thread) to produce a `net.Socket`, and the HTTP/1.1 exchange -//! is driven over that socket via the raw-net bridge (`perry_ffi::raw_net`, -//! published by perry-ext-net) instead of the default transport. `createConnection` +//! runs on that same Socket core and TLS session, with a separate pure +//! framing payload. `createConnection` //! returns the socket synchronously (see `try_create_connection_socket` //! here); `createSocket(req, options, cb)` follows Node's //! `Agent.prototype.addRequest` contract and delivers the socket via its @@ -46,7 +46,6 @@ use perry_ffi::{ StringHeader, }; use std::collections::{HashMap, VecDeque}; -use std::sync::Once; mod tls_compat; pub(crate) use tls_compat::{ @@ -95,6 +94,8 @@ fn bind_agent_method_value(handle: Handle, name: &'static [u8]) -> f64 { /// per-agent keep-alive + socket-counter accessors + setters). pub struct AgentHandle { pub(crate) owner_agent: u64, + /// The built-in HTTPS Agent is an app record belonging to this JS heap. + pub(crate) default_https: bool, pub protocol: Option, pub keep_alive: bool, pub keep_alive_msecs: f64, @@ -121,8 +122,8 @@ pub struct AgentHandle { /// Incremented at dispatch, decremented when the response or error /// pump fires on the main thread. pub sockets: HashMap, - /// Idle keep-alive connection count per host. The transport owns the - /// physical sockets; this mirrors their Agent-visible lifecycle. + /// Idle keep-alive connection count per host, mirrored from the actual + /// Socket ownership edges below. pub free_sockets: HashMap, /// Queued request count per host, mirrored by `queued_requests` below. pub requests: HashMap, @@ -130,17 +131,9 @@ pub struct AgentHandle { /// separately from the public counts so the next request can be resumed /// when the active response reaches a terminal edge. pub queued_requests: HashMap>, - /// Stable public socket handles backing the count mirrors above. The HTTP - /// transport owns the physical sockets; these alloc-only net.Socket handles - /// provide Node's observable Agent/ClientRequest socket identity and - /// EventEmitter lifecycle. + /// Actual ordinary Socket ownership edges backing the count mirrors. pub active_socket_handles: HashMap>, pub free_socket_handles: HashMap>, - /// Generation of each current free-pool admission. Idle invalidation is - /// asynchronous, so a generation prevents an old guard from destroying a - /// socket that has since been reused and returned to the pool again. - pub free_socket_generations: HashMap, - pub next_free_socket_generation: u64, /// Synthetic public session identity layered over rustls' real cached /// sessions. Node exposes opaque session bytes through `TLSSocket`, while /// the transport keeps them inside rustls; this mirror preserves the cache and @@ -160,6 +153,7 @@ impl Default for AgentHandle { fn default() -> Self { AgentHandle { owner_agent: perry_ffi::agent_post::current_agent(), + default_https: false, protocol: Some("http:".to_string()), keep_alive: false, keep_alive_msecs: 1000.0, @@ -178,8 +172,6 @@ impl Default for AgentHandle { queued_requests: HashMap::new(), active_socket_handles: HashMap::new(), free_socket_handles: HashMap::new(), - free_socket_generations: HashMap::new(), - next_free_socket_generation: 0, max_cached_sessions: 100, tls_sessions: HashMap::new(), tls_session_order: VecDeque::new(), @@ -202,10 +194,7 @@ pub(crate) fn agent_pool_config(handle: Handle) -> Option<(bool, f64, f64)> { .map(|a| (a.keep_alive, a.max_free_sockets, a.keep_alive_msecs)) } -/// Close `handle`'s idle physical connections. The reqwest transport did this -/// by dropping the agent's cached client — on `destroy()`, when an idle -/// socket facade expired, and when a pool setter changed the config — and -/// the same edges keep doing it, so connection reuse is unchanged. +/// Close the actual idle Socket owners when an Agent pool option changes. fn invalidate_agent_client(handle: Handle) { crate::client_turnloop::purge_agent(handle); } @@ -319,7 +308,8 @@ extern "C" fn agent_connection_abort_listener( closure: *const RawClosureHeader, _this: perry_ffi::JsThis, ) -> f64 { - let socket = unsafe { perry_ffi::closure_capture_f64(closure, 0) } as i64; + let scope = perry_ffi::TransientRootScope::enter(); + let socket = scope.root_nanbox(unsafe { perry_ffi::closure_capture_f64(closure, 0) }); let signal_value = unsafe { perry_ffi::closure_capture_f64(closure, 1) }; unsafe { extern "C" { @@ -342,7 +332,11 @@ extern "C" fn agent_connection_abort_listener( js_abort_signal_remove_listener(signal, event.get(), listener_value.get()); } } - perry_ext_net::js_ext_net_socket_emit_abort_error(socket); + perry_ext_net::native_transport::destroy_error( + socket.get(), + "ABORT_ERR", + "The operation was aborted", + ); f64::from_bits(TAG_UNDEFINED) } @@ -357,13 +351,18 @@ unsafe fn install_connection_abort_signal(options: f64, socket: Handle) { fn js_abort_signal_add_listener(signal: *mut ObjectHeader, event_type: f64, listener: f64); } let scope = perry_ffi::TransientRootScope::enter(); + let socket = scope.root_addr(socket); let signal_value = scope.root_nanbox(signal_value); let signal = js_abort_signal_resolve_ptr(signal_value.get()); if signal.is_null() { return; } if js_abort_signal_is_aborted(signal) != 0 { - perry_ext_net::js_ext_net_socket_emit_abort_error(socket); + perry_ext_net::native_transport::destroy_error( + handle_value(socket.get()), + "ABORT_ERR", + "The operation was aborted", + ); return; } let listener = perry_ffi::alloc_closure( @@ -375,7 +374,7 @@ unsafe fn install_connection_abort_signal(options: f64, socket: Handle) { } let listener_value = scope.root_nanbox(f64::from_bits(POINTER_TAG | (listener as u64 & PTR_MASK))); - perry_ffi::set_closure_capture_f64(listener, 0, socket as f64); + perry_ffi::set_closure_capture_f64(listener, 0, handle_value(socket.get())); perry_ffi::set_closure_capture_f64(listener, 1, signal_value.get()); let event = scope.root_nanbox(f64::from_bits( JsValue::from_string_ptr(alloc_string("abort").as_raw()).bits(), @@ -454,6 +453,13 @@ fn empty_object_f64() -> f64 { } fn handle_map_object_f64(values: &HashMap>) -> f64 { + // Clone snapshots carry actual Socket addresses. Root them before the + // object/array allocations below can move their owners. + let scope = perry_ffi::TransientRootScope::enter(); + let rooted: HashMap<&str, Vec<_>> = values + .iter() + .map(|(key, handles)| (key.as_str(), scope.root_addrs(handles))) + .collect(); let mut entries: Vec<(&str, &Vec)> = values .iter() .filter(|(_, handles)| !handles.is_empty()) @@ -477,7 +483,6 @@ fn handle_map_object_f64(values: &HashMap>) -> f64 { if object.is_null() { return f64::from_bits(TAG_UNDEFINED); } - let scope = perry_ffi::TransientRootScope::enter(); let object = scope.root_nanbox(f64::from_bits( JsValue::from_object_ptr(object as *mut u8).bits(), )); @@ -488,13 +493,13 @@ fn handle_map_object_f64(values: &HashMap>) -> f64 { ) .bits(), )); - for handle in handles.iter().copied() { + for handle in &rooted[entries[index].0] { let current = JsValue::from_bits(array.get().to_bits()).as_pointer::(); let pushed = unsafe { perry_ffi::js_array_push( current, - JsValue::from_bits(handle_value(handle).to_bits()), + JsValue::from_bits(handle_value(handle.get()).to_bits()), ) }; array = scope.root_nanbox(f64::from_bits( @@ -514,55 +519,78 @@ fn handle_map_object_f64(values: &HashMap>) -> f64 { } pub(crate) fn allocate_agent_socket() -> Handle { - ensure_agent_socket_hook_registered(); - let socket = unsafe { perry_ext_net::js_net_socket_alloc() }; - perry_ext_net::js_ext_net_set_http_agent_phase(socket, 1); - socket + let _ = crate::client_turnloop::available(); + let owner = perry_ext_net::native_transport::new_socket( + crate::client_turnloop::SUBSYSTEM, + f64::from_bits(TAG_UNDEFINED), + ); + JsValue::from_bits(owner.to_bits()).as_pointer::() as Handle } -extern "C" fn agent_socket_event(handle: Handle, event_ptr: *const u8, event_len: usize) { - if event_ptr.is_null() || event_len == 0 { - return; - } - let event = unsafe { - std::str::from_utf8(std::slice::from_raw_parts(event_ptr, event_len)).unwrap_or("") - }; - if !matches!(event, "error" | "close") { - return; +/// A Socket's ordinary close listener removes its existing Agent ownership +/// edge. The closure and Socket are traced together, without a global hook. +unsafe extern "C" fn agent_socket_closed( + closure: *const RawClosureHeader, + _: perry_ffi::JsThis, + _: f64, +) -> f64 { + let handle = perry_ffi::closure_capture_f64(closure, 0) as Handle; + let scope = perry_ffi::TransientRootScope::enter(); + let socket = scope.root_nanbox(perry_ffi::closure_capture_f64(closure, 1)); + // A listener may have reopened the same cell before this close listener. + // Its current driver capability then belongs to the new use. + if !JsValue::from_bits( + perry_ext_net::native_transport::get(socket.get(), "destroyed").to_bits(), + ) + .to_bool() + { + return f64::from_bits(TAG_UNDEFINED); } - iter_handles_of_mut::(|agent| { - for (key, handles) in &mut agent.active_socket_handles { - let before = handles.len(); - handles.retain(|socket| *socket != handle); - if handles.len() != before { - agent.sockets.insert(key.clone(), handles.len() as u32); - } - } - for (key, handles) in &mut agent.free_socket_handles { - let before = handles.len(); - handles.retain(|socket| *socket != handle); - if handles.len() != before { - agent.free_sockets.insert(key.clone(), handles.len() as u32); + let raw = JsValue::from_bits(socket.get().to_bits()).as_pointer::() as Handle; + if let Some(agent) = get_handle_mut::(handle) { + for (counts, sockets) in [ + (&mut agent.sockets, &mut agent.active_socket_handles), + (&mut agent.free_sockets, &mut agent.free_socket_handles), + ] { + for (key, owners) in sockets.iter_mut() { + owners.retain(|owner| *owner != raw); + if owners.is_empty() { + counts.remove(key); + } else { + counts.insert(key.clone(), owners.len() as u32); + } } + sockets.retain(|_, owners| !owners.is_empty()); } - agent.sockets.retain(|_, count| *count > 0); - agent.free_sockets.retain(|_, count| *count > 0); - agent - .active_socket_handles - .retain(|_, sockets| !sockets.is_empty()); - agent - .free_socket_handles - .retain(|_, sockets| !sockets.is_empty()); - agent.free_socket_generations.remove(&handle); - }); - tls_compat::sync_default_https_agent_if_initialized(); + } + sync_default_https_agent(handle); + f64::from_bits(TAG_UNDEFINED) } -fn ensure_agent_socket_hook_registered() { - static REGISTER: Once = Once::new(); - REGISTER.call_once(|| { - perry_ext_net::js_ext_net_register_http_agent_socket_event_hook(agent_socket_event); - }); +pub(crate) fn track_agent_socket(handle: Handle, socket: Handle) { + let scope = perry_ffi::TransientRootScope::enter(); + let socket = scope.root_addr(socket); + let callback = scope.root_addr(perry_ffi::alloc_closure( + perry_ffi::js_function_info!(agent_socket_closed, 1; with_flags(perry_ffi::FN_BUILTIN)), + 2, + ) as i64); + unsafe { + perry_ffi::set_closure_capture_f64( + callback.get() as *mut RawClosureHeader, + 0, + handle as f64, + ); + perry_ffi::set_closure_capture_f64( + callback.get() as *mut RawClosureHeader, + 1, + handle_value(socket.get()), + ); + } + perry_ext_net::native_transport::once( + handle_value(socket.get()), + "close", + handle_value(callback.get()), + ); } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -587,105 +615,97 @@ pub(crate) fn request_key(url: &str) -> String { /// Acquire an Agent slot or append the request to the per-origin queue. pub(crate) fn admit_request(handle: Handle, key: &str, request_handle: Handle) -> PoolAdmission { let result = admit_request_inner(handle, key, request_handle); + let scope = perry_ffi::TransientRootScope::enter(); + let socket = match result { + PoolAdmission::Active { socket, .. } => Some(scope.root_addr(socket)), + PoolAdmission::Queued => None, + }; sync_default_https_agent(handle); - result + match result { + PoolAdmission::Active { reused, .. } => PoolAdmission::Active { + reused, + socket: socket.unwrap().get(), + }, + PoolAdmission::Queued => PoolAdmission::Queued, + } } fn admit_request_inner(handle: Handle, key: &str, request_handle: Handle) -> PoolAdmission { - let Some(agent) = get_handle_mut::(handle) else { - return PoolAdmission::Active { - reused: false, - socket: 0, + let mut reused = false; + let mut socket = 0; + { + let Some(agent) = get_handle_mut::(handle) else { + return PoolAdmission::Active { + reused: false, + socket: 0, + }; }; - }; - - if let Some(free) = agent.free_sockets.get_mut(key) { - *free = free.saturating_sub(1); - if *free == 0 { - agent.free_sockets.remove(key); + if let Some(owners) = agent.free_socket_handles.get_mut(key) { + socket = if agent.scheduling == "fifo" && !owners.is_empty() { + owners.remove(0) + } else { + owners.pop().unwrap_or(0) + }; + let remaining = owners.len(); + if remaining == 0 { + agent.free_socket_handles.remove(key); + agent.free_sockets.remove(key); + } else { + agent.free_sockets.insert(key.to_string(), remaining as u32); + } + reused = socket != 0; } - let socket = agent - .free_socket_handles - .get_mut(key) - .and_then(Vec::pop) - .unwrap_or_else(allocate_agent_socket); - agent.free_socket_generations.remove(&socket); - if agent - .free_socket_handles - .get(key) - .is_some_and(Vec::is_empty) - { - agent.free_socket_handles.remove(key); + if !reused { + let active = agent.sockets.get(key).copied().unwrap_or(0); + let total: u32 = agent.sockets.values().copied().sum(); + if active as f64 >= agent.max_sockets || total as f64 >= agent.max_total_sockets { + let queue = agent.queued_requests.entry(key.to_string()).or_default(); + queue.push(request_handle); + agent.requests.insert(key.to_string(), queue.len() as u32); + return PoolAdmission::Queued; + } } - perry_ext_net::js_ext_net_set_http_agent_phase(socket, 1); - agent - .active_socket_handles - .entry(key.to_string()) - .or_default() - .push(socket); *agent.sockets.entry(key.to_string()).or_default() += 1; - return PoolAdmission::Active { - reused: true, - socket, - }; } - - let active_for_key = agent.sockets.get(key).copied().unwrap_or(0); - let active_total: u32 = agent.sockets.values().copied().sum(); - if (active_for_key as f64) < agent.max_sockets - && (active_total as f64) < agent.max_total_sockets - { - let socket = allocate_agent_socket(); + let scope = perry_ffi::TransientRootScope::enter(); + let socket = scope.root_addr(if socket == 0 { + allocate_agent_socket() + } else { + socket + }); + if let Some(agent) = get_handle_mut::(handle) { agent .active_socket_handles .entry(key.to_string()) .or_default() - .push(socket); - *agent.sockets.entry(key.to_string()).or_default() += 1; - return PoolAdmission::Active { - reused: false, - socket, - }; + .push(socket.get()); } - - let queue = agent.queued_requests.entry(key.to_string()).or_default(); - queue.push(request_handle); - agent.requests.insert(key.to_string(), queue.len() as u32); - PoolAdmission::Queued -} - -/// Release one active slot. A queued request, when present, inherits the slot -/// and is returned for dispatch; otherwise successful keep-alive exchanges -/// become one observable idle pooled socket. -pub(crate) fn release_request( - handle: Handle, - key: &str, - socket: Handle, - keep_alive: bool, -) -> Option<(Handle, bool, Handle)> { - let result = release_request_inner(handle, key, socket, keep_alive); - let became_free = get_handle_mut::(handle) - .and_then(|agent| agent.free_socket_handles.get(key)) - .is_some_and(|sockets| sockets.contains(&socket)); - sync_default_https_agent(handle); - if became_free { - emit_default_https_agent( - handle, - "free", - handle_value(socket), - f64::from_bits(TAG_UNDEFINED), - ); + if !reused { + track_agent_socket(handle, socket.get()); + } + PoolAdmission::Active { + reused, + socket: socket.get(), } - result } -fn release_request_inner( +/// Reserve the next logical slot under the app borrow, then allocate, close +/// and emit outside it. Active/free lists own the actual Socket objects. +pub(crate) fn release_request( handle: Handle, key: &str, socket: Handle, keep_alive: bool, ) -> Option<(Handle, bool, Handle)> { - let free_generation = { + let scope = perry_ffi::TransientRootScope::enter(); + let socket = scope.root_addr(socket); + let keep_alive = keep_alive + && socket.get() != 0 + && !JsValue::from_bits( + perry_ext_net::native_transport::get(handle_value(socket.get()), "destroyed").to_bits(), + ) + .to_bool(); + let (next, parked) = { let agent = get_handle_mut::(handle)?; if let Some(active) = agent.sockets.get_mut(key) { *active = active.saturating_sub(1); @@ -693,133 +713,87 @@ fn release_request_inner( agent.sockets.remove(key); } } - if let Some(active) = agent.active_socket_handles.get_mut(key) { - active.retain(|candidate| *candidate != socket); - if active.is_empty() { + if let Some(owners) = agent.active_socket_handles.get_mut(key) { + owners.retain(|owner| *owner != socket.get()); + if owners.is_empty() { agent.active_socket_handles.remove(key); } } - let next = agent .queued_requests .get_mut(key) .and_then(|queue| (!queue.is_empty()).then(|| queue.remove(0))); - if let Some(next_handle) = next { - let remaining = agent.queued_requests.get(key).map(Vec::len).unwrap_or(0); - if remaining == 0 { - agent.queued_requests.remove(key); - agent.requests.remove(key); - } else { - agent.requests.insert(key.to_string(), remaining as u32); - } - let (next_socket, reused) = if keep_alive && socket != 0 { - (socket, true) - } else { - if socket != 0 { - perry_ext_net::js_ext_net_destroy_socket(socket); - } - (allocate_agent_socket(), false) - }; - perry_ext_net::js_ext_net_set_http_agent_phase(next_socket, 1); + let remaining = agent.queued_requests.get(key).map(Vec::len).unwrap_or(0); + if remaining == 0 { + agent.queued_requests.remove(key); + agent.requests.remove(key); + } else { + agent.requests.insert(key.to_string(), remaining as u32); + } + if next.is_some() { + *agent.sockets.entry(key.to_string()).or_default() += 1; + (next, false) + } else if keep_alive + && agent.keep_alive + && !agent.destroyed + && (agent + .free_socket_handles + .get(key) + .map(Vec::len) + .unwrap_or(0) as f64) + < agent.max_free_sockets.max(0.0) + { + let owners = agent + .free_socket_handles + .entry(key.to_string()) + .or_default(); + owners.push(socket.get()); + agent + .free_sockets + .insert(key.to_string(), owners.len() as u32); + (None, true) + } else { + (None, false) + } + }; + if let Some(request) = next { + let reused = keep_alive; + let next_socket = scope.root_addr(if reused { + socket.get() + } else { + allocate_agent_socket() + }); + if let Some(agent) = get_handle_mut::(handle) { agent .active_socket_handles .entry(key.to_string()) .or_default() - .push(next_socket); - *agent.sockets.entry(key.to_string()).or_default() += 1; - return Some((next_handle, reused, next_socket)); + .push(next_socket.get()); } - - if keep_alive && agent.keep_alive && !agent.destroyed && socket != 0 { - let free = agent.free_sockets.entry(key.to_string()).or_default(); - if (*free as f64) < agent.max_free_sockets.max(0.0) { - *free += 1; - perry_ext_net::js_ext_net_set_http_agent_phase(socket, 0); - agent - .free_socket_handles - .entry(key.to_string()) - .or_default() - .push(socket); - agent.next_free_socket_generation = - agent.next_free_socket_generation.wrapping_add(1); - let generation = agent.next_free_socket_generation; - agent.free_socket_generations.insert(socket, generation); - Some(generation) - } else { - perry_ext_net::js_ext_net_destroy_socket(socket); - None - } - } else { - if socket != 0 { - perry_ext_net::js_ext_net_destroy_socket(socket); - } - None + if !reused { + track_agent_socket(handle, next_socket.get()); } - }; - - if let Some(generation) = free_generation { - unsafe { - let event = alloc_string("free"); - perry_ext_net::js_ext_net_socket_emit( - socket, - event.as_raw() as i64, - std::ptr::null(), - 0, - ); + if !reused && socket.get() != 0 { + perry_ext_net::native_transport::destroy(handle_value(socket.get())); } - let key = key.to_string(); - // The transport owns the physical pooled connection, so the public - // net.Socket facade cannot receive its idle read/EOF edge. - // Conservatively retire an unclaimed facade after the I/O guard - // window; immediate/next-tick reuse cancels this via the generation - // check below. A deadline on the loop, not a tokio sleep. - crate::client_turnloop::push_after( - 40, - crate::PendingHttpEvent::AgentIdleExpire { - agent_handle: handle, - key, - socket, - generation, - }, + sync_default_https_agent(handle); + return Some((request, reused, next_socket.get())); + } + sync_default_https_agent(handle); + if parked { + perry_ext_net::native_transport::emit(handle_value(socket.get()), "free", &[]); + emit_default_https_agent( + handle, + "free", + handle_value(socket.get()), + f64::from_bits(TAG_UNDEFINED), ); + } else if socket.get() != 0 { + perry_ext_net::native_transport::destroy(handle_value(socket.get())); } None } -/// Retire a socket facade only if it is still the exact free-pool admission -/// scheduled by `release_request`. Reuse removes the generation, making stale -/// timers harmless. -pub(crate) fn expire_free_socket(handle: Handle, key: &str, socket: Handle, generation: u64) { - let Some(agent) = get_handle_mut::(handle) else { - return; - }; - if agent.free_socket_generations.get(&socket).copied() != Some(generation) { - return; - } - let Some(free) = agent.free_socket_handles.get_mut(key) else { - agent.free_socket_generations.remove(&socket); - return; - }; - let before = free.len(); - free.retain(|candidate| *candidate != socket); - if free.len() == before { - agent.free_socket_generations.remove(&socket); - return; - } - agent.free_socket_generations.remove(&socket); - if free.is_empty() { - agent.free_socket_handles.remove(key); - agent.free_sockets.remove(key); - } else { - agent - .free_sockets - .insert(key.to_string(), free.len() as u32); - } - perry_ext_net::js_ext_net_destroy_socket(socket); - invalidate_agent_client(handle); - sync_default_https_agent(handle); -} - // ------------------------------------------------------------------ // GC scanner — call from lib.rs's scan_http_roots // ------------------------------------------------------------------ @@ -829,6 +803,15 @@ pub(crate) fn scan_agent_roots(visitor: &mut GcRootVisitor<'_>) { if agent.owner_agent != perry_ffi::agent_post::current_agent() { return; } + for sockets in agent + .active_socket_handles + .values_mut() + .chain(agent.free_socket_handles.values_mut()) + { + for socket in sockets { + visitor.visit_i64_slot(socket); + } + } visitor.visit_nanbox_f64_slot(&mut agent.agent_keep_alive_timeout_buffer); if agent.create_connection != 0 { visitor.visit_i64_slot(&mut agent.create_connection); @@ -842,6 +825,24 @@ pub(crate) fn scan_agent_roots(visitor: &mut GcRootVisitor<'_>) { }); } +pub(crate) fn retire_agent_roots(owner: u64) { + iter_handles_of_mut::(|agent| { + if agent.owner_agent != owner { + return; + } + agent.active_socket_handles.clear(); + agent.free_socket_handles.clear(); + agent.sockets.clear(); + agent.free_sockets.clear(); + agent.queued_requests.clear(); + agent.requests.clear(); + agent.create_connection = 0; + agent.create_socket = 0; + agent.tls_defaults.check_server_identity_callback = 0; + agent.destroyed = true; + }); +} + #[no_mangle] pub extern "C" fn js_ext_http_agent_is_handle(handle: Handle) -> i32 { if get_handle::(handle).is_some() { @@ -1332,37 +1333,33 @@ pub extern "C" fn js_http_agent_noop_self(handle: Handle) -> Handle { /// the handle for chainability. #[no_mangle] pub extern "C" fn js_http_agent_destroy(handle: Handle) -> Handle { - if let Some(agent) = get_handle_mut::(handle) { - agent.destroyed = true; - let sockets = agent - .active_socket_handles - .values() - .chain(agent.free_socket_handles.values()) - .flatten() - .copied() - .collect::>(); - for socket in sockets { - perry_ext_net::js_ext_net_destroy_socket(socket); - } - agent.sockets.clear(); - agent.free_sockets.clear(); - agent.active_socket_handles.clear(); - agent.free_socket_handles.clear(); - agent.free_socket_generations.clear(); - // Node leaves maxSockets waiters queued when `destroy()` tears down - // the current sockets. The next released slot reconnects and serves - // the oldest waiter, so retain both the private queue and its public - // `requests` mirror instead of orphaning those ClientRequests. + let sockets = get_handle_mut::(handle) + .map(|agent| { + agent.destroyed = true; + let sockets: Vec<_> = agent + .active_socket_handles + .values() + .chain(agent.free_socket_handles.values()) + .flatten() + .copied() + .collect(); + agent.sockets.clear(); + agent.free_sockets.clear(); + agent.active_socket_handles.clear(); + agent.free_socket_handles.clear(); + // Keep queued requests: the next released admission reconnects. + sockets + }) + .unwrap_or_default(); + let scope = perry_ffi::TransientRootScope::enter(); + let sockets = scope.root_addrs(&sockets); + for socket in sockets { + perry_ext_net::native_transport::destroy(handle_value(socket.get())); } - invalidate_agent_client(handle); sync_default_https_agent(handle); handle } -// ------------------------------------------------------------------ -// Property getters -// ------------------------------------------------------------------ - fn agent_field(handle: Handle, default: T, f: F) -> T where F: FnOnce(&AgentHandle) -> T, @@ -1657,19 +1654,9 @@ unsafe fn invoke_create_connection_closure( let closure = JsClosure::from_raw(cc.get() as *const RawClosureHeader); let ret = closure.call1(perry_ffi::JsThis::UNDEFINED, options.get()); - // `net.connect` / `net.createConnection` return the socket id NaN-boxed - // with POINTER_TAG; some codegen paths hand back a bare raw pointer. - // Extract the 48-bit handle id either way; reject anything else. - let bits = ret.to_bits(); - let upper = bits >> 48; - let id = if upper == 0x7FFD { - (bits & PTR_MASK) as i64 - } else if upper == 0 && bits >= 0x10000 { - bits as i64 - } else { - return None; - }; - (id > 0).then_some(id) + let owner = scope.root_nanbox(ret); + perry_ext_net::native_transport::socket_link(owner.get()).ok()?; + Some(JsValue::from_bits(owner.get().to_bits()).as_pointer::() as Handle) } /// #2154 — the agent's `createSocket` override closure pointer (0 when no diff --git a/crates/perry-ext-http/src/agent/tls_compat.rs b/crates/perry-ext-http/src/agent/tls_compat.rs index c1ad85ca79..3eef31216b 100644 --- a/crates/perry-ext-http/src/agent/tls_compat.rs +++ b/crates/perry-ext-http/src/agent/tls_compat.rs @@ -4,9 +4,6 @@ use super::*; use std::hash::{Hash, Hasher}; -use std::sync::OnceLock; - -static HTTPS_GLOBAL_AGENT_HANDLE: OnceLock = OnceLock::new(); extern "C" { fn js_https_global_agent_override_value() -> f64; @@ -14,12 +11,6 @@ extern "C" { fn js_https_global_agent_emit(event_ptr: *const u8, event_len: usize, arg0: f64, arg1: f64); } -pub(super) fn sync_default_https_agent_if_initialized() { - if let Some(handle) = HTTPS_GLOBAL_AGENT_HANDLE.get().copied() { - sync_default_https_agent(handle); - } -} - pub(crate) fn tls_session_for_request(handle: Handle, key: &str, port: u16) -> (u64, bool) { let Some(agent) = get_handle_mut::(handle) else { return (1, false); @@ -64,12 +55,23 @@ pub(crate) fn invalidate_tls_sessions_for_server_port(port: u16) { }); } fn default_https_agent_handle() -> Handle { - *HTTPS_GLOBAL_AGENT_HANDLE.get_or_init(|| { - register_handle(AgentHandle { - protocol: Some("https:".to_string()), - keep_alive: true, - ..AgentHandle::default() - }) + let owner = perry_ffi::agent_post::current_agent(); + let mut ids = Vec::new(); + perry_ffi::iter_handle_ids_of::(|id| ids.push(id)); + for id in ids { + if get_handle::(id) + .is_some_and(|agent| agent.owner_agent == owner && agent.default_https) + { + return id; + } + } + // Reuse the existing logical Agent app registry. A process-global handle + // would make workers share Socket edges belonging to different JS heaps. + register_handle(AgentHandle { + default_https: true, + protocol: Some("https:".to_string()), + keep_alive: true, + ..AgentHandle::default() }) } @@ -95,7 +97,9 @@ pub(crate) unsafe fn resolve_https_agent_handle(explicit: Handle) -> Handle { } fn is_default_https_agent(handle: Handle) -> bool { - HTTPS_GLOBAL_AGENT_HANDLE.get().copied() == Some(handle) + get_handle::(handle).is_some_and(|agent| { + agent.default_https && agent.owner_agent == perry_ffi::agent_post::current_agent() + }) } pub(super) fn sync_default_https_agent(handle: Handle) { @@ -286,3 +290,32 @@ unsafe fn raw_pfx_identity(options: f64) -> Option { } Some(format!("{:016x}", hasher.finish())) } + +#[cfg(test)] +#[test] +fn default_https_agents_belong_to_their_worker_heap() { + let workers: Vec<_> = (0..2) + .map(|_| { + std::thread::spawn(|| { + let worker = perry_runtime::agent::enter_worker_agent(); + let handle = default_https_agent_handle(); + assert_eq!( + get_handle::(handle).unwrap().owner_agent, + perry_ffi::agent_post::current_agent(), + "a default HTTPS Agent must belong to its worker heap" + ); + assert_eq!(default_https_agent_handle(), handle); + perry_runtime::agent::retire_agent(worker); + handle + }) + }) + .collect(); + let handles: Vec<_> = workers + .into_iter() + .map(|worker| worker.join().unwrap()) + .collect(); + assert_ne!( + handles[0], handles[1], + "workers must not share their default HTTPS Agent" + ); +} diff --git a/crates/perry-ext-http/src/client_abort.rs b/crates/perry-ext-http/src/client_abort.rs index 855e1ad305..c14c602018 100644 --- a/crates/perry-ext-http/src/client_abort.rs +++ b/crates/perry-ext-http/src/client_abort.rs @@ -98,7 +98,9 @@ pub(crate) unsafe fn handle_request_signal_abort(request_handle: Handle) { .map(|request| (request.agent_handle, request.socket_handle)) { if agent_handle == 0 && socket != 0 { - perry_ext_net::js_ext_net_destroy_socket(socket); + if let Some(socket) = crate::current_request_socket(request_handle) { + perry_ext_net::native_transport::destroy(socket.value()); + } } } finish_agent_request(request_handle, false); diff --git a/crates/perry-ext-http/src/client_connect_override.rs b/crates/perry-ext-http/src/client_connect_override.rs index 518892c481..0a89afa25f 100644 --- a/crates/perry-ext-http/src/client_connect_override.rs +++ b/crates/perry-ext-http/src/client_connect_override.rs @@ -1,10 +1,7 @@ -//! Client requests routed over a caller-supplied raw socket instead of -//! the default transport: both `agent.createConnection`/`agent.createSocket` (#2154) and -//! the request option's own `createConnection` (#10469, honored only when -//! `agent_handle == 0`) end up here. Split out of `lib.rs` to stay under -//! the file-size cap; the closure storage/invocation and the `{ host, port, -//! path, keepAlive, keepAliveInitialDelay }` options object still live in -//! `agent.rs` alongside the pre-existing Agent-level override. +//! HTTP requests on a caller-supplied net.Socket. Override callbacks retain +//! the same actual object; the request/Agent ownership edges are updated +//! before releasing the unconnected placeholder. The Socket's link route +//! becomes HTTP while preserving its driver read and TLS session. use std::collections::HashMap; @@ -28,13 +25,7 @@ pub(crate) fn dispatch_for_handle(request_handle: Handle, url: &str) -> Option(request_handle).map(|request| { + let previous = request.socket_handle; + request.socket_handle = owner.get(); + request.socket_snapshot = None; + request.reused_socket = false; + ( + previous, + request.agent_handle, + request.agent_key.clone(), + request.tls.clone(), + ) + }) + else { + return; + }; + let previous = scope.root_addr(previous); + if let Some(agent) = perry_ffi::get_handle_mut::(agent) { + if let Some(sockets) = agent.active_socket_handles.get_mut(&key) { + for socket in sockets { + if *socket == previous.get() { + *socket = owner.get(); + } + } + } + } + if agent != 0 { + crate::agent::track_agent_socket(agent, owner.get()); + } + if previous.get() != 0 && previous.get() != owner.get() { + perry_ext_net::native_transport::destroy(f64::from_bits( + perry_ffi::JsValue::from_object_ptr(previous.get() as *mut u8).bits(), + )); + } + crate::client_turnloop::dispatch_supplied( + crate::client_turnloop::Request { + request_handle, + method: &method, + url: &url, + headers, + body, + timeout_ms, + agent_handle: agent, + tls: &tls, + continue_mode: false, + }, + f64::from_bits(perry_ffi::JsValue::from_object_ptr(owner.get() as *mut u8).bits()), req_bytes, - wants_upgrade, - timeout_ms, - socket_id, ); } diff --git a/crates/perry-ext-http/src/client_events.rs b/crates/perry-ext-http/src/client_events.rs index f35315234f..598c567a5a 100644 --- a/crates/perry-ext-http/src/client_events.rs +++ b/crates/perry-ext-http/src/client_events.rs @@ -130,12 +130,13 @@ pub(crate) unsafe fn fire_request_socket_event(request_handle: Handle) { if socket == 0 { return; } - let value = f64::from_bits(POINTER_TAG | (socket as u64 & PTR_MASK)); let scope = perry_ffi::TransientRootScope::enter(); + let socket = scope.root_addr(socket); let listeners = scope.root_addrs(&listeners); for callback in listeners { if callback.get() != 0 { let closure = JsClosure::from_raw(callback.get() as *const RawClosureHeader); + let value = f64::from_bits(POINTER_TAG | (socket.get() as u64 & PTR_MASK)); let _ = closure.call1(perry_ffi::JsThis::UNDEFINED, value); } } @@ -145,13 +146,16 @@ unsafe fn fire_incoming_event(incoming: Handle, event: &str, arg: Option) { let listeners = get_handle_mut::(incoming) .and_then(|response| response.listeners.get(event).cloned()) .unwrap_or_default(); + let scope = perry_ffi::TransientRootScope::enter(); + let listeners = scope.root_addrs(&listeners); + let arg = arg.map(|value| scope.root_nanbox(value)); for callback in listeners { - if callback == 0 { + if callback.get() == 0 { continue; } - let closure = JsClosure::from_raw(callback as *const RawClosureHeader); - if let Some(value) = arg { - let _ = closure.call1(perry_ffi::JsThis::UNDEFINED, value); + let closure = JsClosure::from_raw(callback.get() as *const RawClosureHeader); + if let Some(value) = &arg { + let _ = closure.call1(perry_ffi::JsThis::UNDEFINED, value.get()); } else { let _ = closure.call0(perry_ffi::JsThis::UNDEFINED); } @@ -162,13 +166,8 @@ unsafe fn handle_incoming_transport_abort(request_handle: Handle, incoming: Hand fire_incoming_event(incoming, "aborted", None); fire_incoming_event(incoming, "error", Some(error)); fire_incoming_event(incoming, "close", None); - if let Some(socket) = get_handle_mut::(incoming) - .map(|response| response.socket_handle) - .filter(|socket| *socket != 0) - { - let event = alloc_string("close"); - perry_ext_net::js_ext_net_socket_emit(socket, event.as_raw() as i64, std::ptr::null(), 0); - perry_ext_net::js_ext_net_destroy_socket(socket); + if let Some(socket) = crate::current_request_socket(request_handle) { + perry_ext_net::native_transport::destroy(socket.value()); } finish_agent_request(request_handle, false); fire_request_close_once(request_handle); @@ -397,16 +396,8 @@ pub(crate) unsafe fn handle_response_event( fire_request_close_once(request_handle); } -/// Drain handler for `PendingHttpEvent::Upgrade` (#10468): build a -/// lightweight client `IncomingMessage` (statusCode/headers only — the body -/// is the upgraded protocol now, delivered over the adopted socket instead) -/// and fire `req.on('upgrade', (res, socket, head) => ...)` with -/// `(res, socket, head)`, Node's exact argument shape. `socket` is the -/// `net.Socket` id the client transport already -/// adopted via `perry_ext_net::adopt_turnloop_upgrade`; `head` is any -/// bytes the peer sent past the header block, as a `Buffer` (never a lossy -/// string — the write side of #10471 stays server-only, this is a fresh -/// client-side implementation). +/// Deliver the response head, actual Socket and binary upgrade head. The +/// client already stored net's route on this same cell; no adoption is needed. /// /// # Safety /// @@ -419,6 +410,8 @@ pub(crate) unsafe fn handle_upgrade_event( socket_handle: Handle, head: Vec, ) { + let scope = perry_ffi::TransientRootScope::enter(); + let socket = scope.root_addr(socket_handle); let already_done = with_handle_mut::(request_handle, |req| { let was = req.completed; req.completed = true; @@ -430,11 +423,13 @@ pub(crate) unsafe fn handle_upgrade_event( } client_abort::cleanup_request_signal(request_handle); - // Main-thread companion of the net adoption (#4973) — must run before - // user code touches the socket. - if socket_handle != 0 { - perry_ext_net::ensure_adopted_socket_dispatch(); - } + debug_assert!( + perry_ext_net::native_transport::socket_link(f64::from_bits( + POINTER_TAG | (socket.get() as u64 & PTR_MASK), + )) + .is_ok(), + "HTTP upgrade must retain its actual Socket" + ); let incoming = register_handle(IncomingMessageHandle { owner_agent: perry_ffi::agent_post::current_agent(), @@ -447,7 +442,7 @@ pub(crate) unsafe fn handle_upgrade_event( encoding: None, decoder_pending: Vec::new(), pipes: Vec::new(), - socket_handle, + socket_handle: socket.get(), request_handle, http_version: (1, 1), complete: true, @@ -458,12 +453,13 @@ pub(crate) unsafe fn handle_upgrade_event( }) .unwrap_or_default(); - let res_arg = f64::from_bits(POINTER_TAG | (incoming as u64 & PTR_MASK)); - let socket_arg = if socket_handle == 0 { + let res_arg = scope.root_nanbox(f64::from_bits(POINTER_TAG | (incoming as u64 & PTR_MASK))); + let socket_arg = scope.root_nanbox(if socket.get() == 0 { f64::from_bits(TAG_UNDEFINED) } else { - f64::from_bits(POINTER_TAG | (socket_handle as u64 & PTR_MASK)) - }; + f64::from_bits(POINTER_TAG | (socket.get() as u64 & PTR_MASK)) + }); + let listeners = scope.root_addrs(&upgrade_listeners); // Node always hands the listener a Buffer here, even when the peer sent // no bytes past the header block (`Buffer.isBuffer(head) === true` for a // zero-length upgrade head) — never `undefined`. @@ -472,11 +468,7 @@ pub(crate) unsafe fn handle_upgrade_event( f64::from_bits(POINTER_TAG | (buf as u64 & PTR_MASK)) }; - let scope = perry_ffi::TransientRootScope::enter(); - let res_arg = scope.root_nanbox(res_arg); - let socket_arg = scope.root_nanbox(socket_arg); let head_arg = scope.root_nanbox(head_arg); - let listeners = scope.root_addrs(&upgrade_listeners); for cb in listeners { if cb.get() != 0 { let closure = JsClosure::from_raw(cb.get() as *const RawClosureHeader); @@ -489,7 +481,7 @@ pub(crate) unsafe fn handle_upgrade_event( } } - finish_agent_request(request_handle, false); + finish_upgraded_agent_request(request_handle); fire_request_close_once(request_handle); } diff --git a/crates/perry-ext-http/src/client_request_surface.rs b/crates/perry-ext-http/src/client_request_surface.rs index 4e8589e70f..9f4bd21e6f 100644 --- a/crates/perry-ext-http/src/client_request_surface.rs +++ b/crates/perry-ext-http/src/client_request_surface.rs @@ -1,16 +1,11 @@ use super::*; #[derive(Default)] -struct ClientRequestSurfaceState { +pub(super) struct ClientRequestSurfaceState { aborted: bool, destroyed: bool, - socket: f64, } -static CLIENT_REQUEST_SURFACE: std::sync::LazyLock< - Mutex>, -> = std::sync::LazyLock::new(|| Mutex::new(HashMap::new())); - extern "C" { fn js_class_method_bind( instance: f64, @@ -175,26 +170,15 @@ fn handle_value(handle: Handle) -> f64 { f64::from_bits(POINTER_TAG | (handle as u64 & PTR_MASK)) } -pub(crate) fn scan_roots(visitor: &mut GcRootVisitor<'_>) { - for (handle, state) in CLIENT_REQUEST_SURFACE.lock().unwrap().iter_mut() { - if get_handle_mut::(*handle) - .is_none_or(|request| request.owner_agent != perry_ffi::agent_post::current_agent()) - { - continue; - } - if state.socket != 0.0 { - visitor.visit_nanbox_f64_slot(&mut state.socket); - } - } -} - fn is_client_request_handle(handle: Handle) -> bool { get_handle_mut::(handle).is_some() } fn with_state_mut(handle: Handle, f: impl FnOnce(&mut ClientRequestSurfaceState) -> T) -> T { - let mut states = CLIENT_REQUEST_SURFACE.lock().unwrap(); - f(states.entry(handle).or_default()) + match get_handle_mut::(handle) { + Some(request) => f(&mut request.surface), + None => f(&mut ClientRequestSurfaceState::default()), + } } /// Whether `req.destroy()` has been called on this request (#4905 — @@ -314,23 +298,16 @@ fn socket_value(handle: Handle) -> f64 { { return handle_value(socket); } - with_state_mut(handle, |state| { - if state.socket == 0.0 { - state.socket = f64::from_bits(perry_ffi::alloc_object().bits()); - } - state.socket - }) + f64::from_bits(JsValue::NULL.bits()) } fn state_bool(handle: Handle, property: &str) -> f64 { let ended = get_handle_mut::(handle) .map(|req| req.ended) .unwrap_or(false); - let states = CLIENT_REQUEST_SURFACE.lock().unwrap(); - let state = states.get(&handle); bool_value(match property { - "aborted" => state.map(|s| s.aborted).unwrap_or(false), - "destroyed" => state.map(|s| s.destroyed).unwrap_or(false), + "aborted" => with_state_mut(handle, |state| state.aborted), + "destroyed" => with_state_mut(handle, |state| state.destroyed), "finished" | "writableEnded" | "writableFinished" => ended, "reusedSocket" => get_handle_mut::(handle) .map(|request| request.reused_socket) diff --git a/crates/perry-ext-http/src/client_turnloop/conn.rs b/crates/perry-ext-http/src/client_turnloop/conn.rs deleted file mode 100644 index 31f1a4955f..0000000000 --- a/crates/perry-ext-http/src/client_turnloop/conn.rs +++ /dev/null @@ -1,1140 +0,0 @@ -//! One client connection's state machine: connect, optional `CONNECT` tunnel, -//! optional TLS, then one HTTP/1.1 exchange at a time. -//! -//! Every function here runs with [`State`] locked and returns what it wants -//! done as [`Effect`]s; see the module header in `mod.rs` for why no `tl::` -//! call may be made from inside. -//! -//! # Settling -//! -//! An exchange lives in `Conn::exchange` from `start` until exactly one of: -//! `finish` (the response ended), `fail`/`premature` (an error), a deadline, or -//! `cancel`. Each of those *takes* it, so no second terminal event can be -//! produced for the same request, whichever completions arrive afterwards. -//! -//! # Input -//! -//! Bytes that the decoder has not consumed stay in `Conn::inbuf` across reads: -//! a response head or a chunk-size line split over two TCP reads is completed -//! by the next one. The loop runs while a step makes progress — consumed -//! bytes *or* produced an event — because `Event::End` and `Event::Upgrade` -//! arrive from zero-byte steps (turnloop#50's contract). - -use bytes::Bytes; -use perry_ffi::Handle; -use perry_tls_session::TlsSession; -use turnloop_http::http1; - -use super::{ - next_id, tls, wire, Effect, Mode, Outbound, PoolKey, State, Timer, COMPLETED, HANDSHAKES, - REUSED, TIMED_OUT, -}; -use crate::{ClientInflightGuard, PendingHttpEvent}; - -/// Bound on decode steps per delivery. Every step consumes input or produces -/// an event; this only stops a decoder that did neither from spinning the -/// agent's loop, which would be a worse failure than a dropped response. -const MAX_STEPS: usize = 1 << 20; - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum Phase { - /// `tcp_connect` submitted. - Connecting, - /// Connected to a proxy; waiting for the `CONNECT` response. - Tunnel, - /// Carrying (or about to carry) an exchange. - Open, - /// Parked in the pool between exchanges. - Idle, - /// `tl::close` submitted; waiting for `NET_CLOSED`. - Closing, -} - -/// The `Expect: 100-continue` body hand-off. -enum Continue { - /// Not a continue exchange, or the body has been dealt with. - Done, - /// Waiting for the interim `100`; `end()` may already have supplied the - /// body. - AwaitingInterim(Option>), - /// The `100` arrived before `end()`: the body goes out when it comes. - Released, -} - -struct ResponseHead { - status: u16, - reason: String, - version: u8, - headers: Vec<(String, String)>, -} - -pub(super) struct Exchange { - out: Box, - framing: wire::Framing, - /// The request asked the server to close. - closes: bool, - /// The request head has been handed to the socket (or TLS session). - sent: bool, - /// This exchange runs on a connection taken from the pool. - reused: bool, - /// This exchange is itself a retry; it is never retried again. - retried: bool, - /// Any response byte arrived. A reused connection that dies before one - /// does is retried once on a fresh connection, as hyper's pool did: the - /// peer closing an idle keep-alive socket just as it is reused is a race, - /// not a failure of the request. - got_bytes: bool, - head: Option, - /// A `ResponseHead` event has been queued (the drain has an - /// `IncomingMessage` to fail with `'aborted'`). - head_delivered: bool, - buffered: Vec, - trailers: Vec<(String, String)>, - cont: Continue, - deadline: i64, - _inflight: ClientInflightGuard, -} - -pub(super) struct Conn { - key: PoolKey, - /// What was dialed — the proxy when proxied — for Node's error messages. - peer_host: String, - peer_port: u16, - phase: Phase, - tls: Option, - handshake_done: bool, - decoder: http1::Decoder, - inbuf: Vec, - exchange: Option, - /// Exchanges this connection has completed. - served: u32, - idle_timer: i64, -} - -fn new_decoder() -> http1::Decoder { - http1::Decoder::new(http1::Mode::Response, http1::Limits::default()) -} - -/// The host to dial for a URL: brackets stripped from an IPv6 literal, which -/// `Url::host_str` keeps. -pub(super) fn dial_host(url: &url::Url) -> Option { - Some(match url.host()? { - url::Host::Domain(domain) => domain.to_string(), - url::Host::Ipv4(address) => address.to_string(), - url::Host::Ipv6(address) => address.to_string(), - }) -} - -/// Streamed (`ResponseHead` → chunks → `ResponseEnd`) rather than buffered -/// into one `Response` event. -fn streams(out: &Outbound) -> bool { - match out.mode { - Mode::Normal | Mode::Continue => true, - Mode::Trailers => false, - // A cleartext upgrade that the server declined was delivered buffered - // by its bypass; over TLS, reqwest streamed whatever came back. - Mode::Upgrade => out.key.https, - } -} - -// ── Starting ──────────────────────────────────────────────────────────────── - -/// Start an exchange. `inflight` is the guard of the exchange this one -/// retries, carried over so the exit gate never sees the request drop out of -/// the in-flight set between the two. -pub(super) fn start( - st: &mut State, - out: Outbound, - inflight: Option, - fx: &mut Vec, -) { - let request_handle = out.request_handle; - let retried = inflight.is_some(); - let mut exchange = Exchange { - framing: wire::Framing::Raw, - closes: false, - sent: false, - reused: false, - retried, - got_bytes: false, - head: None, - head_delivered: false, - buffered: Vec::new(), - trailers: Vec::new(), - cont: if out.mode == Mode::Continue { - Continue::AwaitingInterim(None) - } else { - Continue::Done - }, - deadline: 0, - _inflight: inflight.unwrap_or_else(|| ClientInflightGuard::new(request_handle)), - out: Box::new(out), - }; - - // A kept-alive connection first, when the agent allows one. A retry never - // takes one: it exists because a reused connection just failed. - let pooled = if !retried && exchange.out.reuse.is_some() { - take_idle(st, &exchange.out.key, fx) - } else { - None - }; - let id = match pooled { - Some(id) => id, - None => { - let id = next_id(); - if id == perry_ffi::INVALID_HANDLE { - fx.push(Effect::Push(PendingHttpEvent::Error { - request_handle, - error_message: "http client: connection ids exhausted".to_string(), - })); - return; - } - id - } - }; - - if let Some(ms) = exchange.out.timeout_ms { - let timer = next_id(); - if timer != perry_ffi::INVALID_HANDLE { - st.timers.insert( - timer, - Timer::Deadline { - conn: id, - request: request_handle, - }, - ); - exchange.deadline = timer; - fx.push(Effect::ArmTimer(timer, ms)); - } - } - st.by_request.insert(request_handle, id); - - if pooled.is_some() { - REUSED.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - exchange.reused = true; - let Some(conn) = st.conns.get_mut(&id) else { - return; - }; - conn.exchange = Some(exchange); - send_request(st, id, fx); - return; - } - - let (peer_host, peer_port) = match &exchange.out.proxy { - Some(proxy) => ( - dial_host(proxy).unwrap_or_default(), - proxy.port_or_known_default().unwrap_or(80), - ), - None => (exchange.out.key.host.clone(), exchange.out.key.port), - }; - st.conns.insert( - id, - Conn { - key: exchange.out.key.clone(), - peer_host: peer_host.clone(), - peer_port, - phase: Phase::Connecting, - tls: None, - handshake_done: false, - decoder: new_decoder(), - inbuf: Vec::new(), - exchange: Some(exchange), - served: 0, - idle_timer: 0, - }, - ); - fx.push(Effect::Connect { - id, - host: peer_host, - port: peer_port, - }); -} - -pub(super) fn on_connect(st: &mut State, id: i64) -> Vec { - let mut fx = Vec::new(); - let Some(conn) = st.conns.get_mut(&id) else { - return fx; - }; - if conn.phase != Phase::Connecting { - return fx; - } - // Reading starts before anything is written: a loopback peer's reply can - // be in flight before the write submission returns. - fx.push(Effect::ReadStart(id)); - let tunnel = conn.exchange.as_ref().and_then(|ex| { - let out = &ex.out; - (out.key.https) - .then(|| out.proxy.as_ref()) - .flatten() - .map(|proxy| wire::connect_head(&out.key.host, out.key.port, proxy)) - }); - if let Some(head) = tunnel { - conn.phase = Phase::Tunnel; - conn.decoder.response_to("CONNECT"); - fx.push(Effect::Write(id, head)); - return fx; - } - conn.phase = Phase::Open; - if conn.key.https && !open_tls(st, id, &mut fx) { - return fx; - } - send_request(st, id, &mut fx); - fx -} - -/// Install the TLS session and send the ClientHello. `false` means it failed -/// and the exchange has been settled. -fn open_tls(st: &mut State, id: i64, fx: &mut Vec) -> bool { - let Some(conn) = st.conns.get_mut(&id) else { - return false; - }; - let plan = conn.exchange.as_ref().and_then(|ex| ex.out.tls.clone()); - let session = match plan.as_ref().map(tls::open) { - Some(Ok(session)) => session, - Some(Err(message)) => { - fail_coded(st, id, message, "ERR_SSL_PROTOCOL_ERROR", fx); - return false; - } - None => { - fail_coded( - st, - id, - "https request without a TLS configuration".to_string(), - "ERR_SSL_PROTOCOL_ERROR", - fx, - ); - return false; - } - }; - conn.tls = Some(session); - pump_tls(st, id, fx); - st.conns.contains_key(&id) -} - -/// Put the current exchange's request on the wire (or into the TLS session, -/// which holds it until the handshake allows). -fn send_request(st: &mut State, id: i64, fx: &mut Vec) { - let Some(conn) = st.conns.get_mut(&id) else { - return; - }; - let Some(ex) = conn.exchange.as_mut() else { - return; - }; - if ex.sent { - return; - } - if conn.served > 0 && conn.decoder.reset().is_err() { - // Only a connection the decoder called reusable is ever parked, so - // this cannot happen; if it does, a fresh decoder is the safe state. - conn.decoder = new_decoder(); - } - let method = ex.out.method.to_ascii_uppercase(); - conn.decoder.response_to(&method); - - let out = &ex.out; - let absolute = out.proxy.is_some() && !out.key.https; - let target = wire::request_target(&out.url, absolute); - let mut extra = out.extra.clone(); - if absolute { - if let Some(credentials) = out.proxy.as_ref().and_then(wire::basic_credentials) { - extra.push(("Proxy-Authorization".to_string(), credentials)); - } - } - let serialized = wire::serialize_head( - &out.method, - &target, - &out.url, - &out.headers, - &extra, - out.body.len(), - out.mode, - ); - let mut bytes = serialized.head; - if out.mode != Mode::Continue { - bytes.extend_from_slice(&wire::frame_body(&out.body, serialized.framing)); - } - ex.framing = serialized.framing; - ex.closes = serialized.closes; - ex.sent = true; - send(st, id, bytes, fx); -} - -/// Hand plaintext to the connection: through the TLS session when there is -/// one, straight to the socket otherwise. -fn send(st: &mut State, id: i64, bytes: Vec, fx: &mut Vec) { - let Some(conn) = st.conns.get_mut(&id) else { - return; - }; - match conn.tls.as_mut() { - Some(session) => { - session.write(&bytes); - pump_tls(st, id, fx); - } - None => fx.push(Effect::Write(id, bytes)), - } -} - -/// Run the TLS state machine and flush its ciphertext. Returns decrypted -/// plaintext and whether the peer sent `close_notify`; `None` if the session -/// failed (the exchange has then been settled). -fn pump_tls(st: &mut State, id: i64, fx: &mut Vec) -> Option<(Vec, bool)> { - let conn = st.conns.get_mut(&id)?; - let session = conn.tls.as_mut()?; - let progress = session.pump(); - let ciphertext = session.take_output(); - if !ciphertext.is_empty() { - fx.push(Effect::Write(id, ciphertext)); - } - if let Some(failure) = session.failure() { - let code = failure.code; - let message = tls::node_failure_message(code, &failure.message); - let handshaking = !conn.handshake_done; - if handshaking { - fail_coded(st, id, message, code, fx); - } else { - premature(st, id, fx); - } - return None; - } - if progress.handshake_done { - conn.handshake_done = true; - HANDSHAKES.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - } - Some((session.take_plaintext(), progress.peer_closed)) -} - -// ── Input ─────────────────────────────────────────────────────────────────── - -pub(super) fn on_data(st: &mut State, id: i64, bytes: &[u8]) -> Vec { - let mut fx = Vec::new(); - let Some(conn) = st.conns.get_mut(&id) else { - return fx; - }; - match conn.phase { - Phase::Connecting | Phase::Closing => {} - Phase::Tunnel => tunnel_input(st, id, bytes, &mut fx), - Phase::Open | Phase::Idle => { - if conn.tls.is_some() { - let Some(session) = conn.tls.as_mut() else { - return fx; - }; - session.receive(bytes); - if let Some((plaintext, peer_closed)) = pump_tls(st, id, &mut fx) { - if !plaintext.is_empty() { - http_input(st, id, &plaintext, &mut fx); - } - if peer_closed { - eof(st, id, &mut fx); - } - } - } else { - http_input(st, id, bytes, &mut fx); - } - } - } - fx -} - -/// The proxy's answer to `CONNECT`. A 2xx is an `Upgrade` from the decoder's -/// point of view; whatever follows it is the target's TLS. -fn tunnel_input(st: &mut State, id: i64, bytes: &[u8], fx: &mut Vec) { - let Some(conn) = st.conns.get_mut(&id) else { - return; - }; - conn.inbuf.extend_from_slice(bytes); - for _ in 0..MAX_STEPS { - let Some(conn) = st.conns.get_mut(&id) else { - return; - }; - let step = match conn.decoder.receive(&conn.inbuf) { - Ok(step) => step, - Err(error) => { - fail_coded( - st, - id, - format!("{} {}", error.code, error.message), - "ERR_PROXY_TUNNEL", - fx, - ); - return; - } - }; - let consumed = step.consumed; - let outcome = match step.event { - Some(http1::Event::Head(head)) if (200..300).contains(&head.status) => None, - Some(http1::Event::Head(head)) => Some(Err(format!( - "Failed to establish tunnel to {}:{}: HTTP/1.{} {} {}", - conn.key.host, - conn.key.port, - head.version, - head.status, - wire::reason_phrase(&conn.inbuf[..consumed]), - ))), - Some(http1::Event::Upgrade) => Some(Ok(())), - Some(_) => None, - None if consumed == 0 => return, - None => None, - }; - conn.inbuf.drain(..consumed); - match outcome { - None => {} - Some(Err(message)) => { - fail_coded(st, id, message, "ERR_PROXY_TUNNEL", fx); - return; - } - Some(Ok(())) => { - let leftover = std::mem::take(&mut conn.inbuf); - conn.decoder = new_decoder(); - conn.phase = Phase::Open; - if !open_tls(st, id, fx) { - return; - } - send_request(st, id, fx); - if !leftover.is_empty() { - let mut more = on_data(st, id, &leftover); - fx.append(&mut more); - } - return; - } - } - } -} - -fn http_input(st: &mut State, id: i64, bytes: &[u8], fx: &mut Vec) { - let Some(conn) = st.conns.get_mut(&id) else { - return; - }; - conn.inbuf.extend_from_slice(bytes); - process(st, id, fx); -} - -/// One decoded event, owned so the connection can be released before it is -/// acted on. -enum Decoded { - Head(http1::Head, String), - Informational(u16), - Body(Vec), - Trailers(Vec), - End, - Upgrade, - Nothing, -} - -fn process(st: &mut State, id: i64, fx: &mut Vec) { - for _ in 0..MAX_STEPS { - let Some(conn) = st.conns.get_mut(&id) else { - return; - }; - let Some(ex) = conn.exchange.as_mut() else { - // Bytes on a connection with no request in flight: unsolicited - // data on an idle keep-alive socket, or bytes after a response - // ended. Neither can be framed; the connection is done. - if !conn.inbuf.is_empty() { - close(st, id, fx); - } - return; - }; - if !conn.inbuf.is_empty() { - ex.got_bytes = true; - } - let step = match conn.decoder.receive(&conn.inbuf) { - Ok(step) => step, - Err(error) => { - fail_protocol(st, id, error, fx); - return; - } - }; - let consumed = step.consumed; - let decoded = match step.event { - Some(http1::Event::Head(head)) => { - let reason = wire::reason_phrase(&conn.inbuf[..consumed]); - Decoded::Head(head, reason) - } - Some(http1::Event::Informational(head)) => Decoded::Informational(head.status), - Some(http1::Event::Body(chunk)) => Decoded::Body(chunk.to_vec()), - Some(http1::Event::Trailers(trailers)) => Decoded::Trailers(trailers), - Some(http1::Event::End) => Decoded::End, - Some(http1::Event::Upgrade) => Decoded::Upgrade, - None => Decoded::Nothing, - }; - conn.inbuf.drain(..consumed); - match decoded { - Decoded::Nothing => { - if consumed == 0 { - return; - } - } - Decoded::Informational(status) => on_informational(st, id, status, fx), - Decoded::Head(head, reason) => on_head(st, id, head, reason, fx), - Decoded::Body(chunk) => on_body(st, id, chunk, fx), - Decoded::Trailers(trailers) => { - if let Some(ex) = st.conns.get_mut(&id).and_then(|c| c.exchange.as_mut()) { - ex.trailers = trailers - .into_iter() - .map(|h| (h.name, String::from_utf8_lossy(&h.value).into_owned())) - .collect(); - } - } - Decoded::End => finish(st, id, false, fx), - Decoded::Upgrade => on_upgrade(st, id, fx), - } - } -} - -fn on_informational(st: &mut State, id: i64, status: u16, fx: &mut Vec) { - if status != 100 { - // `102`/`103` — Node's `'information'`, which neither the reqwest - // path nor the bypasses surfaced. - return; - } - let Some(ex) = st.conns.get_mut(&id).and_then(|c| c.exchange.as_mut()) else { - return; - }; - if ex.out.mode != Mode::Continue { - return; - } - let request_handle = ex.out.request_handle; - let body = match std::mem::replace(&mut ex.cont, Continue::Done) { - Continue::AwaitingInterim(Some(body)) => Some(body), - Continue::AwaitingInterim(None) => { - ex.cont = Continue::Released; - None - } - other => { - ex.cont = other; - return; - } - }; - fx.push(Effect::Push(PendingHttpEvent::Continue { request_handle })); - if let Some(body) = body { - let framed = wire::frame_body(&body, ex.framing); - send(st, id, framed, fx); - } -} - -/// `end()` supplied the body of an `Expect: 100-continue` request. -pub(super) fn continue_body( - st: &mut State, - request_handle: Handle, - body: Vec, - fx: &mut Vec, -) { - let Some(&id) = st.by_request.get(&request_handle) else { - return; - }; - let Some(ex) = st.conns.get_mut(&id).and_then(|c| c.exchange.as_mut()) else { - return; - }; - if ex.out.request_handle != request_handle || ex.out.mode != Mode::Continue { - return; - } - match std::mem::replace(&mut ex.cont, Continue::Done) { - Continue::AwaitingInterim(_) => ex.cont = Continue::AwaitingInterim(Some(body)), - Continue::Released => { - let framed = wire::frame_body(&body, ex.framing); - send(st, id, framed, fx); - } - Continue::Done => {} - } -} - -fn on_head(st: &mut State, id: i64, head: http1::Head, reason: String, fx: &mut Vec) { - let Some(ex) = st.conns.get_mut(&id).and_then(|c| c.exchange.as_mut()) else { - return; - }; - // A final response without the interim `100`: the server declined to see - // the body, which is never sent. - if matches!(ex.cont, Continue::AwaitingInterim(_) | Continue::Released) { - ex.cont = Continue::Done; - } - let headers: Vec<(String, String)> = head - .headers - .iter() - .map(|h| { - ( - h.name.clone(), - String::from_utf8_lossy(&h.value).into_owned(), - ) - }) - .collect(); - let status = head.status; - if streams(&ex.out) { - ex.head_delivered = true; - fx.push(Effect::Push(PendingHttpEvent::ResponseHead { - request_handle: ex.out.request_handle, - status, - status_message: reason.clone(), - headers: headers.clone(), - // `Head::version` is the HTTP/1 minor: 0 for 1.0, 1 for 1.1. - http_version: (1, head.version), - })); - } - ex.head = Some(ResponseHead { - status, - reason, - version: head.version, - headers, - }); -} - -fn on_body(st: &mut State, id: i64, chunk: Vec, fx: &mut Vec) { - let Some(ex) = st.conns.get_mut(&id).and_then(|c| c.exchange.as_mut()) else { - return; - }; - if ex.head_delivered { - fx.push(Effect::Push(PendingHttpEvent::ResponseChunk { - request_handle: ex.out.request_handle, - chunk: Bytes::from(chunk), - })); - } else { - ex.buffered.extend_from_slice(&chunk); - } -} - -fn on_upgrade(st: &mut State, id: i64, fx: &mut Vec) { - let Some(conn) = st.conns.get_mut(&id) else { - return; - }; - let handoff = conn.exchange.as_ref().is_some_and(|ex| { - ex.out.mode == Mode::Upgrade - && !ex.out.key.https - && ex.head.as_ref().is_some_and(|h| h.status == 101) - }); - if !handoff { - // A `CONNECT` 2xx, an unrequested `101`, or a `101` over TLS: the - // message is over and the connection cannot be reused. - finish(st, id, true, fx); - return; - } - let Some(conn) = st.conns.remove(&id) else { - return; - }; - let Some(ex) = conn.exchange else { - return; - }; - clear_deadline(st, &ex, fx); - forget_request(st, ex.out.request_handle, id); - COMPLETED.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - let head = ex.head.unwrap_or(ResponseHead { - status: 101, - reason: String::new(), - version: 1, - headers: Vec::new(), - }); - // The connection now belongs to `net`: no close, no id release here. - fx.push(Effect::Handoff( - id, - PendingHttpEvent::Upgrade { - request_handle: ex.out.request_handle, - status: head.status, - status_message: head.reason, - headers: head.headers, - socket_handle: id, - head: conn.inbuf, - }, - )); -} - -// ── Settling ──────────────────────────────────────────────────────────────── - -fn clear_deadline(st: &mut State, ex: &Exchange, fx: &mut Vec) { - if ex.deadline != 0 && st.timers.remove(&ex.deadline).is_some() { - fx.push(Effect::CancelTimer(ex.deadline)); - } -} - -fn forget_request(st: &mut State, request_handle: Handle, id: i64) { - if st.by_request.get(&request_handle) == Some(&id) { - st.by_request.remove(&request_handle); - } -} - -/// Take the exchange off a connection, clearing its deadline and request -/// mapping. Every terminal path goes through here exactly once. -fn take_exchange(st: &mut State, id: i64, fx: &mut Vec) -> Option { - let ex = st.conns.get_mut(&id)?.exchange.take()?; - clear_deadline(st, &ex, fx); - forget_request(st, ex.out.request_handle, id); - Some(ex) -} - -/// The response ended. Deliver it, then park or close the connection. -fn finish(st: &mut State, id: i64, never_reuse: bool, fx: &mut Vec) { - let Some(ex) = take_exchange(st, id, fx) else { - return; - }; - COMPLETED.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - let request_handle = ex.out.request_handle; - if ex.head_delivered { - fx.push(Effect::Push(PendingHttpEvent::ResponseEnd { - request_handle, - })); - } else { - let head = ex.head.as_ref(); - fx.push(Effect::Push(PendingHttpEvent::Response { - request_handle, - status: head.map_or(0, |h| h.status), - status_message: head.map(|h| h.reason.clone()).unwrap_or_default(), - headers: head.map(|h| h.headers.clone()).unwrap_or_default(), - trailers: ex.trailers.clone(), - body: ex.buffered.clone(), - http_version: (1, head.map_or(1, |h| h.version)), - })); - } - let Some(conn) = st.conns.get_mut(&id) else { - return; - }; - let reuse = ex.out.reuse.filter(|_| { - !never_reuse - && ex.out.mode == Mode::Normal - && !ex.closes - && conn.decoder.reusable() - && conn.inbuf.is_empty() - && conn - .tls - .as_ref() - .is_none_or(|s| !s.peer_closed() && s.failure().is_none()) - }); - drop(ex); - match reuse { - Some(reuse) => park(st, id, reuse, fx), - None => close(st, id, fx), - } -} - -/// Return a connection to the pool (see `pool.rs` for the ordering rule this -/// is the only caller of). -fn park(st: &mut State, id: i64, reuse: super::Reuse, fx: &mut Vec) { - let Some(conn) = st.conns.get_mut(&id) else { - return; - }; - let key = conn.key.clone(); - let parked = st.idle.get(&key).map_or(0, Vec::len); - if parked >= reuse.max_free { - close(st, id, fx); - return; - } - let timer = next_id(); - let Some(conn) = st.conns.get_mut(&id) else { - return; - }; - conn.phase = Phase::Idle; - conn.served = conn.served.saturating_add(1); - if timer != perry_ffi::INVALID_HANDLE { - conn.idle_timer = timer; - st.timers.insert(timer, Timer::Idle { conn: id }); - fx.push(Effect::ArmTimer(timer, reuse.idle_ms)); - } - fx.push(Effect::SetRef(id, false)); - st.idle.entry(key).or_default().push(id); -} - -/// Take the most recently parked live connection for `key`. -fn take_idle(st: &mut State, key: &PoolKey, fx: &mut Vec) -> Option { - loop { - let id = st.idle.get_mut(key)?.pop()?; - let Some(conn) = st.conns.get_mut(&id) else { - continue; - }; - if conn.phase != Phase::Idle { - continue; - } - conn.phase = Phase::Open; - let timer = std::mem::take(&mut conn.idle_timer); - if timer != 0 && st.timers.remove(&timer).is_some() { - fx.push(Effect::CancelTimer(timer)); - } - fx.push(Effect::SetRef(id, true)); - return Some(id); - } -} - -fn unpark(st: &mut State, id: i64, fx: &mut Vec) { - let Some(conn) = st.conns.get_mut(&id) else { - return; - }; - let timer = std::mem::take(&mut conn.idle_timer); - let key = conn.key.clone(); - if timer != 0 && st.timers.remove(&timer).is_some() { - fx.push(Effect::CancelTimer(timer)); - } - if let Some(list) = st.idle.get_mut(&key) { - list.retain(|&parked| parked != id); - if list.is_empty() { - st.idle.remove(&key); - } - } -} - -/// Submit the close. The record stays until `NET_CLOSED`, which frees the id. -fn close(st: &mut State, id: i64, fx: &mut Vec) { - unpark(st, id, fx); - let Some(conn) = st.conns.get_mut(&id) else { - return; - }; - if conn.phase == Phase::Closing { - return; - } - conn.phase = Phase::Closing; - fx.push(Effect::Close(id)); -} - -fn fail_coded(st: &mut State, id: i64, message: String, code: &str, fx: &mut Vec) { - if let Some(ex) = take_exchange(st, id, fx) { - fx.push(Effect::Push(PendingHttpEvent::CodedError { - request_handle: ex.out.request_handle, - message, - code: code.to_string(), - })); - } - close(st, id, fx); -} - -/// A framing refusal from the codec, which carries an llhttp-style code. -fn fail_protocol(st: &mut State, id: i64, error: turnloop_http::Error, fx: &mut Vec) { - if let Some(ex) = take_exchange(st, id, fx) { - fx.push(Effect::Push(PendingHttpEvent::CodedError { - request_handle: ex.out.request_handle, - message: format!("Parse Error: {}", error.message), - code: error.code.to_string(), - })); - } - close(st, id, fx); -} - -/// The connection went away before the response ended. -fn premature(st: &mut State, id: i64, fx: &mut Vec) { - let Some(ex) = take_exchange(st, id, fx) else { - close(st, id, fx); - return; - }; - close(st, id, fx); - report_premature(ex, fx); -} - -fn report_premature(ex: Exchange, fx: &mut Vec) { - let request_handle = ex.out.request_handle; - if ex.reused && !ex.got_bytes && !ex.retried { - // The stale keep-alive race: nothing of the response arrived, so the - // request may safely be sent again on a fresh connection. - let Exchange { out, _inflight, .. } = ex; - fx.push(Effect::Redispatch(out, _inflight)); - return; - } - if ex.head_delivered { - // Node: the response is `'aborted'`; the drain builds that error from - // the `IncomingMessage` it already has. - fx.push(Effect::Push(PendingHttpEvent::Error { - request_handle, - error_message: "aborted".to_string(), - })); - } else { - fx.push(Effect::Push(PendingHttpEvent::CodedError { - request_handle, - message: "socket hang up".to_string(), - code: "ECONNRESET".to_string(), - })); - } -} - -/// Readable EOF, from the socket or a TLS `close_notify`. -fn eof(st: &mut State, id: i64, fx: &mut Vec) { - let Some(conn) = st.conns.get_mut(&id) else { - return; - }; - match conn.phase { - Phase::Closing => return, - Phase::Idle | Phase::Connecting => { - close(st, id, fx); - return; - } - Phase::Tunnel => { - premature(st, id, fx); - return; - } - Phase::Open => {} - } - if conn.exchange.is_none() { - close(st, id, fx); - return; - } - // A body delimited by the close ends here: the decoder turns EOF into - // `End`, which the next step delivers. - if conn.decoder.eof().is_ok() { - process(st, id, fx); - } - let still_open = st - .conns - .get(&id) - .is_some_and(|conn| conn.exchange.is_some()); - if still_open { - premature(st, id, fx); - } -} - -pub(super) fn on_eof(st: &mut State, id: i64) -> Vec { - let mut fx = Vec::new(); - eof(st, id, &mut fx); - fx -} - -pub(super) fn on_error( - st: &mut State, - id: i64, - code: &str, - syscall: &str, - errno: i64, -) -> Vec { - let mut fx = Vec::new(); - let Some(conn) = st.conns.get_mut(&id) else { - return fx; - }; - match conn.phase { - Phase::Closing => return fx, - Phase::Idle => { - close(st, id, &mut fx); - return fx; - } - Phase::Connecting => { - // Node's connect-failure message names the peer: - // `connect ECONNREFUSED 127.0.0.1:1`, `getaddrinfo ENOTFOUND host`. - let (message, code, syscall, errno) = crate::transport_error::connect_failure( - code, - syscall, - errno, - &conn.peer_host, - conn.peer_port, - ); - if let Some(ex) = take_exchange(st, id, &mut fx) { - fx.push(Effect::Push(PendingHttpEvent::TransportError { - request_handle: ex.out.request_handle, - message, - code, - syscall, - errno, - })); - } - close(st, id, &mut fx); - return fx; - } - Phase::Tunnel | Phase::Open => {} - } - if matches!(code, "ECONNRESET" | "EPIPE" | "ECONNABORTED") { - premature(st, id, &mut fx); - return fx; - } - if let Some(ex) = take_exchange(st, id, &mut fx) { - let message = if syscall.is_empty() { - code.to_string() - } else { - format!("{syscall} {code}") - }; - fx.push(Effect::Push(PendingHttpEvent::TransportError { - request_handle: ex.out.request_handle, - message, - code: code.to_string(), - syscall: syscall.to_string(), - errno, - })); - } - close(st, id, &mut fx); - fx -} - -pub(super) fn on_closed(st: &mut State, id: i64) -> Vec { - let mut fx = Vec::new(); - unpark(st, id, &mut fx); - let Some(mut conn) = st.conns.remove(&id) else { - return fx; - }; - if let Some(ex) = conn.exchange.take() { - // The socket went away with a request still on it and no terminal - // event of its own. - clear_deadline(st, &ex, &mut fx); - forget_request(st, ex.out.request_handle, id); - report_premature(ex, &mut fx); - } - // The terminal completion: nothing can name this id again, so it goes - // back to the shared band (the #6441 id-exhaustion shape). - fx.push(Effect::FreeId(id)); - fx -} - -pub(super) fn on_timer(st: &mut State, timer: i64) -> Vec { - let mut fx = Vec::new(); - let Some(kind) = st.timers.remove(&timer) else { - return fx; - }; - // A fired one-shot is terminal on the runtime side: nothing to cancel. - fx.push(Effect::FreeId(timer)); - match kind { - Timer::Deadline { conn, request } => { - let owns = st - .conns - .get(&conn) - .and_then(|c| c.exchange.as_ref()) - .is_some_and(|ex| ex.out.request_handle == request && ex.deadline == timer); - if !owns { - return fx; - } - if let Some(mut ex) = st.conns.get_mut(&conn).and_then(|c| c.exchange.take()) { - // Already removed from `timers`; don't cancel it again. - ex.deadline = 0; - forget_request(st, request, conn); - TIMED_OUT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - fx.push(Effect::Push(PendingHttpEvent::Timeout { - request_handle: request, - })); - } - close(st, conn, &mut fx); - } - Timer::Idle { conn } => { - let idle = st - .conns - .get_mut(&conn) - .filter(|c| c.phase == Phase::Idle && c.idle_timer == timer); - if let Some(c) = idle { - c.idle_timer = 0; - close(st, conn, &mut fx); - } - } - Timer::Deferred(event) => { - super::DEFERRED_FIRED.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - fx.push(Effect::Push(event)); - } - Timer::RawDrain { socket } => { - fx.extend(super::raw_socket::on_raw_drain_timer(st, socket, timer)) - } - Timer::RawDeadline { socket } => fx.extend(super::raw_socket::on_raw_deadline(st, socket)), - } - fx -} - -pub(super) fn cancel(st: &mut State, request_handle: Handle, fx: &mut Vec) { - let Some(id) = st.by_request.get(&request_handle).copied() else { - return; - }; - let owns = st - .conns - .get(&id) - .and_then(|c| c.exchange.as_ref()) - .is_some_and(|ex| ex.out.request_handle == request_handle); - if owns { - drop(take_exchange(st, id, fx)); - close(st, id, fx); - } else { - st.by_request.remove(&request_handle); - } -} - -pub(super) fn purge_agent(st: &mut State, agent_handle: Handle, fx: &mut Vec) { - let idle: Vec = st - .idle - .iter() - .filter(|(key, _)| key.agent == agent_handle) - .flat_map(|(_, ids)| ids.iter().copied()) - .collect(); - for id in idle { - close(st, id, fx); - } -} diff --git a/crates/perry-ext-http/src/client_turnloop/mod.rs b/crates/perry-ext-http/src/client_turnloop/mod.rs index 403a4e6e21..b51cebdf1d 100644 --- a/crates/perry-ext-http/src/client_turnloop/mod.rs +++ b/crates/perry-ext-http/src/client_turnloop/mod.rs @@ -1,97 +1,25 @@ -//! The `node:http` / `node:https` client, on turnloop — the only transport. -//! -//! Lane 1 (#11091) put the simplest shape here — a cleartext, bodyless request -//! on the implicit agent — and declined everything else to reqwest. This module -//! now carries **every** exchange `dispatch_request_snapshot` used to hand -//! reqwest, plus the three shapes that bypassed reqwest on raw tokio sockets, -//! and `reqwest` is no longer a dependency of this crate: -//! -//! | shape | how | -//! |---|---| -//! | request bodies | buffered at `end()`, so always a known length: `Content-Length`, or chunked when the caller set `Transfer-Encoding: chunked` ([`wire`]) | -//! | `options.timeout` / `req.setTimeout` | a deadline on the loop (`tl::timer_arm`) covering the whole exchange, as reqwest's `RequestBuilder::timeout` did; it fires `'timeout'` and tears the exchange down | -//! | `https:` | [`perry_tls_session::TlsSession`] above the same socket handle, with the verifier `tls_client` builds from Node's options ([`tls`]) | -//! | an explicit or HTTPS `Agent` | keep-alive with physical reuse ([`pool`]); the observable agent pools in `agent.rs` are untouched | -//! | an explicit `Host` | sent verbatim, as Node and reqwest both did ([`wire`]) | -//! | `NODE_USE_ENV_PROXY=1` | absolute-form through an HTTP proxy, or a `CONNECT` tunnel for `https:` ([`proxy`]) | -//! | `TE: trailers` | the codec's `Event::Trailers`, delivered with the buffered response — was `plain_client.rs` over a tokio `TcpStream` | -//! | `Expect: 100-continue` | head first, body withheld until the interim `100`, which fires `'continue'` — was `continue_client.rs` over tokio | -//! | `Connection: Upgrade` | the codec's `Event::Upgrade`; a `101` hands the live handle to `net` with `tl::transfer` — was `client_upgrade.rs` over tokio | -//! -//! # What is still not here -//! -//! * `agent.createConnection` / `createSocket` and a request-level -//! `createConnection` run their exchange over a socket JS produced -//! (`client_connect_override.rs`, perry-ext-net's raw vtable). They are -//! decided before this module is offered the request and are unchanged. -//! * A thread that does not own its agent's loop **posts** the submission to -//! the owner (`perry_ffi::agent_post`), which serves the same JS heap; only a -//! host where no loop exists at all reports `ENOTSUP` — the rule `perry-ext-net` -//! adopted when it dropped tokio. -//! * `Connection: Upgrade` over `https:` cannot hand a TLS session to `net`, so -//! a `101` there is delivered as an ordinary response, exactly as reqwest did. -//! -//! # Redirects -//! -//! None are followed. Node's `http.request` never follows a 3xx; reqwest had to -//! be told `redirect::Policy::none()`, and driving the codec directly gives it -//! by construction. -//! -//! # Threading, locking and the GC -//! -//! Every connection lives on the agent's loop and is touched only from this -//! module's completion sink or from work running on that loop. State is one -//! mutex ([`State`]), and **no `tl::` call is ever made while it is held**: a -//! loopback completion can be delivered to the sink before the submitting call -//! returns, and the sink takes the same lock. Handlers therefore compute a list -//! of [`Effect`]s under the lock and perform them after releasing it. -//! -//! The sink runs no JS. Every outcome is a `PendingHttpEvent` for -//! `js_http_process_pending`, exactly as the reqwest task's were. Nothing here -//! holds a JS value — requests are owned `String`/`Vec` copies — so there is no -//! GC root to register and `scan_http_roots` is unchanged. - -mod conn; +//! HTTP client exchanges run on ordinary net.Socket payloads. Request and +//! Agent app records own the actual objects; their separately owned framing +//! payload contains pure Rust bytes. Socket owns TLS, driver capabilities and +//! deadlines. Upgrade stores the current route without a transfer or retoken. + +mod native_client; mod pool; +mod protocol; mod proxy; -pub(crate) mod raw_socket; pub(crate) mod tls; mod wire; -use std::collections::HashMap; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::{Mutex, OnceLock}; - -use perry_ffi::turnloop_net as tl; -use perry_ffi::Handle; - use crate::tls_client::TlsOptions; -use crate::{push_event, PendingHttpEvent}; - +use crate::{push_event, ClientRequestHandle, PendingHttpEvent}; +use perry_ffi::turnloop_net as tl; +use perry_ffi::{Handle, JsValue, TransientRootScope}; pub(crate) use pool::{PoolKey, Reuse}; - -/// This lane's slot in the runtime's completion-sink registry. -/// -/// Distinct from `server/turnloop_serve`'s `1`, which is this crate's *server*. -/// The authority for the map is `perry-db-turnloop`'s `subsystem` module -/// header; `6` is the free slot between `perry-stdlib`'s framework server (5) -/// and `perry-ext-ws`'s client (7). +use std::collections::HashMap; +use std::sync::atomic::{AtomicU64, Ordering}; pub(crate) const SUBSYSTEM: u8 = 6; - -/// Node sets `TCP_NODELAY` on client sockets; a request that sat in Nagle's -/// queue would add a round trip to every exchange. -const NODELAY: bool = true; - -/// The code a request fails with when no loop exists for its agent at all. -/// Same as `perry-ext-net`'s: there is no second event loop to fall back to. const NO_LOOP_CODE: &str = "ENOTSUP"; -// ── Liveness counters ─────────────────────────────────────────────────────── -// -// A transport that silently did nothing would leave every JS-level test green -// having exercised nothing — the "gate runs but its subject never did" shape. -// These let a test assert the subject was live, per shape. - static ACCEPTED: AtomicU64 = AtomicU64::new(0); static COMPLETED: AtomicU64 = AtomicU64::new(0); static REUSED: AtomicU64 = AtomicU64::new(0); @@ -125,13 +53,13 @@ pub fn timed_out_total() -> u64 { TIMED_OUT.load(Ordering::Relaxed) } -/// `createConnection` / `createSocket` exchanges ([`raw_socket`]) that +/// Exchanges on caller-supplied Sockets that /// delivered a response or an upgrade. pub fn raw_completed_total() -> u64 { RAW_COMPLETED.load(Ordering::Relaxed) } -/// Deferred events ([`push_after`]) whose loop deadline fired. +/// Deferred request timeout callbacks that fired. pub fn deferred_fired_total() -> u64 { DEFERRED_FIRED.load(Ordering::Relaxed) } @@ -173,207 +101,6 @@ pub(crate) struct Outbound { pub(crate) extra: Vec<(String, String)>, } -// ── Shared state ──────────────────────────────────────────────────────────── - -/// A deadline this module armed, and what it is for. -enum Timer { - /// `options.timeout` for an in-flight exchange. - Deadline { conn: i64, request: Handle }, - /// An idle pooled connection's expiry. - Idle { conn: i64 }, - /// An event queued for later: `req.setTimeout(ms, cb)` armed before (or - /// independently of) dispatch, or an Agent socket facade's idle expiry. - Deferred(PendingHttpEvent), - /// Read a `createConnection` socket that `perry-ext-net` said is ready. - RawDrain { socket: i64 }, - /// A `createConnection` exchange's deadline. - RawDeadline { socket: i64 }, -} - -#[derive(Default)] -struct State { - conns: HashMap, - /// Which connection is carrying a request, for `destroy()` and the - /// deferred `Expect: 100-continue` body. - by_request: HashMap, - timers: HashMap, - idle: HashMap>, - /// `createConnection` exchanges, keyed by the user's socket id. - raw: HashMap, -} - -fn state() -> &'static Mutex { - static STATE: OnceLock> = OnceLock::new(); - STATE.get_or_init(|| Mutex::new(State::default())) -} - -fn with_state(f: impl FnOnce(&mut State) -> R) -> R { - let mut guard = state().lock().unwrap_or_else(|e| e.into_inner()); - f(&mut guard) -} - -/// I/O decided under the lock and performed after it is released. -enum Effect { - Connect { - id: i64, - host: String, - port: u16, - }, - ReadStart(i64), - Write(i64, Vec), - Close(i64), - SetRef(i64, bool), - ArmTimer(i64, u64), - CancelTimer(i64), - FreeId(i64), - Push(PendingHttpEvent), - /// Hand a connection to `net` after a `101`, then publish the event. - Handoff(i64, PendingHttpEvent), - /// Read a `createConnection` socket ([`raw_socket::drain_raw_socket`]). - RawDrain(i64), - /// Close a `createConnection` socket through the raw-net vtable. - RawClose(i64), - /// Run a request again on a fresh connection (a reused one died before - /// the response began), keeping its in-flight guard. - Redispatch(Box, crate::ClientInflightGuard), -} - -/// Perform effects in order. A failed submission feeds its handler, whose own -/// effects join the queue — that is how a write error on a connection becomes -/// the same teardown a read error would. -fn run(effects: Vec) { - let mut queue = std::collections::VecDeque::from(effects); - while let Some(effect) = queue.pop_front() { - let more = match effect { - Effect::Connect { id, host, port } => { - match tl::tcp_connect(id, SUBSYSTEM, &host, port, NODELAY) { - Ok(()) => Vec::new(), - Err(error) => with_state(|st| { - conn::on_error(st, id, &error.code, &error.syscall, error.errno as i64) - }), - } - } - Effect::ReadStart(id) => match tl::read_start(id) { - Ok(()) => Vec::new(), - Err(error) => with_state(|st| { - conn::on_error(st, id, &error.code, &error.syscall, error.errno as i64) - }), - }, - Effect::Write(id, bytes) => { - if bytes.is_empty() { - Vec::new() - } else { - match tl::write(id, &bytes, 0) { - Ok(_) => Vec::new(), - Err(error) => with_state(|st| { - conn::on_error(st, id, &error.code, &error.syscall, error.errno as i64) - }), - } - } - } - Effect::Close(id) => { - let _ = tl::close(id); - Vec::new() - } - Effect::SetRef(id, referenced) => { - tl::set_ref(id, referenced); - Vec::new() - } - Effect::ArmTimer(id, ms) => match tl::timer_arm(id, SUBSYSTEM, ms) { - Ok(()) => Vec::new(), - // No deadline could be armed: fire it now rather than never. - Err(_) => with_state(|st| conn::on_timer(st, id)), - }, - Effect::CancelTimer(id) => { - let _ = tl::timer_cancel(id); - perry_ffi::free_handle_id(id); - Vec::new() - } - Effect::FreeId(id) => { - perry_ffi::free_handle_id(id); - Vec::new() - } - Effect::Push(event) => { - push_event(event); - Vec::new() - } - Effect::RawDrain(socket) => { - raw_socket::drain_raw_socket(socket); - Vec::new() - } - Effect::RawClose(socket) => { - raw_socket::close_raw_socket(socket); - Vec::new() - } - Effect::Handoff(id, event) => { - handoff(id, event); - Vec::new() - } - Effect::Redispatch(outbound, inflight) => with_state(|st| { - let mut fx = Vec::new(); - conn::start(st, *outbound, Some(inflight), &mut fx); - fx - }), - }; - queue.extend(more); - } -} - -/// A `101` on a cleartext upgrade request: the live handle becomes a -/// `net.Socket`, keeping its id and outstanding read — the server's own -/// `'upgrade'` handoff in `turnloop_serve`, from the client side. -fn handoff(id: i64, event: PendingHttpEvent) { - let adopted = tl::transfer(id, perry_ext_net::TURNLOOP_SUBSYSTEM).is_ok() - && perry_ext_net::adopt_turnloop_upgrade(id); - if adopted { - push_event(event); - return; - } - let _ = tl::close(id); - if let PendingHttpEvent::Upgrade { request_handle, .. } = event { - push_event(PendingHttpEvent::CodedError { - request_handle, - message: "socket hang up".to_string(), - code: "ECONNRESET".to_string(), - }); - } -} - -// ── Ids ───────────────────────────────────────────────────────────────────── - -/// One authoritative id domain for the connections and deadlines this module -/// creates. The runtime keys its handle table by id across every subsystem, -/// so it must be globally unique. -fn registry_domain() -> perry_ffi::NativeRegistryDomain { - static DOMAIN: OnceLock = OnceLock::new(); - *DOMAIN.get_or_init(|| { - perry_ffi::NativeRegistryDomain::new().expect("http client registry domains exhausted") - }) -} - -fn next_id() -> i64 { - perry_ffi::reserve_handle_id_in_domain(registry_domain()) -} - -/// This subsystem accepts nothing — it only dials. -extern "C" fn alloc_id() -> i64 { - 0 -} - -// ── Availability and routing ──────────────────────────────────────────────── - -/// Whether a request issued *now, on this thread* can be submitted directly. -/// -/// Deliberately not cached: availability is a property of the calling thread, -/// and the first thread to ask claims its agent's route. -pub fn available() -> bool { - static REGISTERED: std::sync::Once = std::sync::Once::new(); - REGISTERED.call_once(|| { - tl::register_sink(SUBSYSTEM, sink, alloc_id); - }); - tl::available(SUBSYSTEM) -} - struct LoopJob(Box); impl perry_ffi::agent_post::AgentJob for LoopJob { @@ -382,23 +109,8 @@ impl perry_ffi::agent_post::AgentJob for LoopJob { } } -/// How often a transiently refused post is retried before the request fails. -const POST_ATTEMPTS: usize = 64; - -thread_local! { - /// This thread has already been found to own the agent's loop by - /// [`on_loop`]. Asking [`available`] *claims* the route for the first - /// thread that asks, so [`push_after`] consults this instead of asking: - /// scheduling a deferred event must never be what decides which thread - /// owns the loop. - static OWNS_LOOP_HERE: std::cell::Cell = const { std::cell::Cell::new(false) }; -} - -/// Run `op` on the loop: here when this thread owns it, else on the owner. -/// `false` means no loop exists for this agent at all; `op` was not run. fn on_loop(op: impl FnOnce() + Send + 'static) -> bool { if available() { - OWNS_LOOP_HERE.with(|owns| owns.set(true)); op(); return true; } @@ -482,7 +194,7 @@ fn prepare(request: Request<'_>) -> Result { "https" => true, other => return Err(format!("unsupported protocol {other}:")), }; - let host = conn::dial_host(&url).ok_or_else(|| "missing host".to_string())?; + let host = protocol::dial_host(&url).ok_or_else(|| "missing host".to_string())?; let port = url .port_or_known_default() .unwrap_or(if https { 443 } else { 80 }); @@ -586,272 +298,136 @@ pub(crate) fn dispatch(request: Request<'_>) -> Route { } /// Start an exchange on the loop thread. -fn start(outbound: Outbound) { - let effects = with_state(|st| start_locked(st, outbound)); - run(effects); +pub fn available() -> bool { + native_client::available() } +const POST_ATTEMPTS: usize = 64; -fn start_locked(st: &mut State, outbound: Outbound) -> Vec { - let mut fx = Vec::new(); - conn::start(st, outbound, None, &mut fx); - fx +fn socket_owner(request: Handle) -> Option { + perry_ffi::get_handle::(request) + .filter(|req| req.socket_handle != 0) + .map(|req| f64::from_bits(JsValue::from_object_ptr(req.socket_handle as *mut u8).bits())) } -/// The body `end()` supplies to an `Expect: 100-continue` exchange. -pub(crate) fn continue_body(request_handle: Handle, body: Vec) { - let carried = on_loop(move || { - let effects = with_state(|st| { - let mut fx = Vec::new(); - conn::continue_body(st, request_handle, body, &mut fx); - fx +fn start(outbound: Outbound) { + let request = outbound.request_handle; + let Some(owner) = socket_owner(request) else { + push_event(PendingHttpEvent::Error { + request_handle: request, + error_message: "HTTP request has no assigned Socket".into(), }); - run(effects); - }); - let _ = carried; -} - -/// `req.destroy()` / `req.abort()`: stop carrying the request. The JS-visible -/// teardown (`'error'`/`'close'`) is the caller's; this only closes the socket -/// so the peer sees it, as Node's destroy does, and drops the exchange so no -/// late event reaches a completed request. -pub(crate) fn cancel(request_handle: Handle) { - // Only a request this module is carrying has anything to cancel. Checking - // first also keeps `destroy()` of a never-dispatched request from asking - // for the loop route, which the first asker claims for its thread. - if !with_state(|st| st.by_request.contains_key(&request_handle)) { return; + }; + if let Err(message) = native_client::start_on_socket(owner, outbound, None, false) { + push_event(PendingHttpEvent::Error { + request_handle: request, + error_message: message, + }); } +} + +pub(crate) fn continue_body(request: Handle, body: Vec) { let _ = on_loop(move || { - let effects = with_state(|st| { - let mut fx = Vec::new(); - conn::cancel(st, request_handle, &mut fx); - fx - }); - run(effects); + if let Some(owner) = socket_owner(request) { + native_client::continue_body(owner, request, body); + } }); } - -/// `agent.destroy()`: close the agent's idle pooled connections. Also -/// reachable from `tests/turnloop_client_exchange.rs`, which parks one. -pub fn purge_agent(agent_handle: Handle) { - // Idle connections exist only once an agent has used the transport; an - // agent that never did must not claim the loop route just by being - // configured or destroyed. - if !with_state(|st| st.idle.keys().any(|key| key.agent == agent_handle)) { - return; - } +pub(crate) fn cancel(request: Handle) { let _ = on_loop(move || { - let effects = with_state(|st| { - let mut fx = Vec::new(); - conn::purge_agent(st, agent_handle, &mut fx); - fx - }); - run(effects); + if let Some(owner) = socket_owner(request) { + native_client::cancel(owner, request); + } }); } -/// `req.setTimeout(ms[, cb])` / `options.timeout` armed at request creation: -/// a one-shot `'timeout'` for the request, independent of any exchange. -pub(crate) fn arm_request_timeout(request_handle: Handle, ms: u64) { - push_after(ms, PendingHttpEvent::Timeout { request_handle }); +pub fn purge_agent(handle: Handle) { + let sockets = perry_ffi::get_handle_mut::(handle) + .map(|agent| { + agent.free_sockets.clear(); + agent + .free_socket_handles + .drain() + .flat_map(|(_, sockets)| sockets) + .collect::>() + }) + .unwrap_or_default(); + let scope = TransientRootScope::enter(); + for socket in scope.root_addrs(&sockets) { + perry_ext_net::native_transport::destroy(f64::from_bits( + JsValue::from_object_ptr(socket.get() as *mut u8).bits(), + )); + } } -/// Queue `event` for the drain `ms` milliseconds from now, on a loop deadline. -/// -/// The deadline is unreferenced (`tl::timer_arm`): like the tokio sleeps it -/// replaces for `'timeout'` and the Agent facade's idle expiry, it never keeps -/// the process alive on its own. -pub(crate) fn push_after(ms: u64, event: PendingHttpEvent) { - if !OWNS_LOOP_HERE.with(std::cell::Cell::get) { - // Not (yet) known to be the loop owner — a `'timeout'` armed before - // this thread's first request, or bookkeeping on a thread that never - // carried one. A plain thread keeps the promise that the event fires - // without claiming the loop route as a side effect. - std::thread::spawn(move || { - std::thread::sleep(std::time::Duration::from_millis(ms)); - push_event(event); - }); - return; +/// A deferred request timeout is an ordinary runtime Timeout callback. It +/// owns its callback on the current JS heap; no HTTP timer identity map. +pub(crate) fn arm_request_timeout(request: Handle, ms: u64) { + extern "C" { + fn js_set_timeout_callback(callback: i64, delay: f64) -> i64; + fn js_timer_unref(timer: i64); } - // `on_loop` drops its closure unrun when it refuses, so the event is held - // in a shared slot the "no loop at all" fallback below can take back. - let slot = std::sync::Arc::new(Mutex::new(Some(event))); - let posted = slot.clone(); - let carried = on_loop(move || { - let Some(event) = posted.lock().unwrap_or_else(|e| e.into_inner()).take() else { - return; - }; - let id = next_id(); - if id == perry_ffi::INVALID_HANDLE { - push_event(event); - return; - } - with_state(|st| st.timers.insert(id, Timer::Deferred(event))); - run(vec![Effect::ArmTimer(id, ms)]); - }); - if !carried { - // No loop anywhere for this agent: a plain thread keeps the promise - // that the event fires, without a second event loop. - if let Some(event) = slot.lock().unwrap_or_else(|e| e.into_inner()).take() { - std::thread::spawn(move || { - std::thread::sleep(std::time::Duration::from_millis(ms)); - push_event(event); - }); - } + let scope = TransientRootScope::enter(); + let callback = scope.root_addr(perry_ffi::alloc_closure( + perry_ffi::js_function_info!(deferred_timeout, 0; with_flags(perry_ffi::FN_BUILTIN)), + 1, + ) as i64); + unsafe { + perry_ffi::set_closure_capture_f64( + callback.get() as *mut perry_ffi::RawClosureHeader, + 0, + request as f64, + ); + let timer = js_set_timeout_callback(callback.get(), ms as f64); + js_timer_unref(timer); } } - -// ── The public liveness hooks ─────────────────────────────────────────────── - -/// Offer a request to this module the way `dispatch_request_snapshot` does, -/// with no TLS options. `true` means it was carried (directly or posted). -/// -/// Kept `pub` for `tests/turnloop_client_exchange.rs`, which asserts the lane -/// was live rather than trusting a JS-level green. -#[allow(clippy::too_many_arguments)] -pub fn try_dispatch( - request_handle: Handle, - method: &str, - url: &str, - headers: &HashMap, - body: &[u8], - timeout_ms: Option, - agent_handle: Handle, -) -> bool { - try_dispatch_tls( - request_handle, - method, - url, - headers, - body, - timeout_ms, - agent_handle, - Vec::new(), - ) -} - -/// [`try_dispatch`] with an explicit `ca` (PEM) for an `https:` URL. -#[allow(clippy::too_many_arguments)] -pub fn try_dispatch_tls( - request_handle: Handle, - method: &str, - url: &str, - headers: &HashMap, - body: &[u8], - timeout_ms: Option, - agent_handle: Handle, - ca_pems: Vec>, -) -> bool { - let tls = TlsOptions { - ca_pems, - ..TlsOptions::default() - }; - let route = dispatch(Request { - request_handle, - method, - url, - headers: headers.clone(), - body: body.to_vec(), - timeout_ms, - agent_handle, - tls: &tls, - continue_mode: false, - }); - matches!(route, Route::Direct | Route::Posted) +unsafe extern "C" fn deferred_timeout( + closure: *const perry_ffi::RawClosureHeader, + _: perry_ffi::JsThis, +) -> f64 { + let request = perry_ffi::closure_capture_f64(closure, 0) as Handle; + if perry_ffi::get_handle::(request).is_some_and(|req| !req.completed) { + DEFERRED_FIRED.fetch_add(1, Ordering::Relaxed); + push_event(PendingHttpEvent::Timeout { + request_handle: request, + }); + } + f64::from_bits(JsValue::UNDEFINED.bits()) } -/// Keep-alive for the liveness test, which has no `AgentHandle` to read the -/// policy from: carry `request` with this reuse policy on pool key `agent`. -#[allow(clippy::too_many_arguments)] -pub fn try_dispatch_pooled( - request_handle: Handle, - method: &str, - url: &str, - headers: &HashMap, - body: &[u8], - agent: Handle, - max_free: usize, - idle_ms: u64, -) -> bool { - let tls = TlsOptions::default(); - let mut outbound = match prepare(Request { - request_handle, - method, - url, - headers: headers.clone(), - body: body.to_vec(), - timeout_ms: None, - agent_handle: agent, - tls: &tls, - continue_mode: false, - }) { - Ok(outbound) => outbound, - Err(_) => return false, +pub(crate) fn dispatch_supplied(request: Request<'_>, owner: f64, head: Vec) { + let request_handle = request.request_handle; + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(owner); + let outbound = match prepare(request) { + Ok(mut out) => { + out.reuse = None; + out + } + Err(error_message) => { + push_event(PendingHttpEvent::Error { + request_handle, + error_message, + }); + return; + } }; - outbound.reuse = Some(Reuse { max_free, idle_ms }); - ACCEPTED.fetch_add(1, Ordering::Relaxed); - on_loop(move || start(outbound)) -} - -/// Run an HTTP exchange over an already-open `perry-ext-net` socket, as -/// `agent.createConnection` does. For `tests/turnloop_client_exchange.rs`. -#[allow(clippy::too_many_arguments)] -pub fn try_dispatch_over_socket( - request_handle: Handle, - method: &str, - url: &str, - headers: &HashMap, - body: &[u8], - timeout_ms: Option, - socket_id: i64, -) { - crate::client_connect_override::dispatch_request_over_socket( - request_handle, - method.to_string(), - url.to_string(), - headers.clone(), - body.to_vec(), - timeout_ms, - socket_id, - ); + if let Err(error_message) = + native_client::start_on_socket(owner.get(), outbound, Some(head), false) + { + push_event(PendingHttpEvent::Error { + request_handle, + error_message, + }); + } } -/// Queue a `'timeout'` for `request_handle` in `ms` ([`push_after`]). For +/// Queue a `'timeout'` for `request_handle` in `ms`. For /// `tests/turnloop_client_exchange.rs`. pub fn schedule_timeout_for_test(request_handle: Handle, ms: u64) { arm_request_timeout(request_handle, ms); } -// ── The completion sink ───────────────────────────────────────────────────── - -extern "C" fn sink(completion: *const tl::NetCompletion) { - if completion.is_null() { - return; - } - // SAFETY: the runtime passes a live completion for the duration of the - // call, which is this function's body. - let c = unsafe { &*completion }; - let effects = match c.kind { - tl::NET_CONNECT => with_state(|st| conn::on_connect(st, c.id)), - // SAFETY: same call; the pooled lease outlives it. - tl::NET_DATA => { - let bytes = unsafe { c.bytes() }; - with_state(|st| conn::on_data(st, c.id, bytes)) - } - tl::NET_EOF => with_state(|st| conn::on_eof(st, c.id)), - tl::NET_ERROR => { - // SAFETY: the runtime builds these from `&'static str`s. - let code = unsafe { c.code() }.unwrap_or("EPIPE").to_string(); - let syscall = unsafe { c.syscall() }.unwrap_or("").to_string(); - with_state(|st| conn::on_error(st, c.id, &code, &syscall, c.errno as i64)) - } - tl::NET_CLOSED => with_state(|st| conn::on_closed(st, c.id)), - tl::NET_TIMER => with_state(|st| conn::on_timer(st, c.id)), - // `NET_WROTE` is an acknowledgement only: `tl::write` copies. - _ => Vec::new(), - }; - run(effects); -} - #[cfg(test)] mod tests; diff --git a/crates/perry-ext-http/src/client_turnloop/native_client.rs b/crates/perry-ext-http/src/client_turnloop/native_client.rs new file mode 100644 index 0000000000..2fc7b93b38 --- /dev/null +++ b/crates/perry-ext-http/src/client_turnloop/native_client.rs @@ -0,0 +1,572 @@ +//! The HTTP client above an ordinary Socket. A separate parser owns only +//! framing bytes; Socket owns the core, TLS session and all driver refs. +//! There is no connection, request-to-connection, timer or pool identity map. +//! Request/Agent ownership stays in their existing traced app records. + +use std::collections::VecDeque; +use std::sync::atomic::Ordering; + +use perry_ext_net::native_transport::{self as net, RootedSocket}; +use perry_ffi::native_payload::{self as np, PayloadFamily}; +use perry_ffi::turnloop_net as tl; +use perry_ffi::{Handle, JsValue, TransientRootScope}; + +use super::protocol::{self, Conn, Effect}; +use super::{Outbound, PoolKey, SUBSYSTEM}; +use crate::{push_event, ClientRequestHandle, PendingHttpEvent}; + +static PARSER_VTABLE: perry_ffi::native_stream::PayloadVTable = perry_ffi::native_stream::payload_vtable::(None); +static PARSER: PayloadFamily = + PayloadFamily::new::(perry_ffi::native_class_ids::HTTP_CLIENT_PARSER, "HTTPClientParser", false, &PARSER_VTABLE) + .with_constructor_length(0); +const PARSER_EDGE: &str = "httpClientParser"; + +pub(super) fn available() -> bool { + static REGISTER: std::sync::Once = std::sync::Once::new(); + REGISTER.call_once(|| { + assert!( + tl::register_link_sink(SUBSYSTEM, sink), + "HTTP client link sink registration refused" + ); + }); + net::enabled() && tl::available(SUBSYSTEM) +} + +/// `f` returns owned Rust data. End every projection before performing an +/// effect, allocating JS, closing a parser or touching the driver. +fn with_conn(socket: &RootedSocket, f: impl FnOnce(&mut Conn) -> R) -> Option { + if !socket.is_current() || net::socket_link(socket.value()).is_err() { + return None; + } + let scope = TransientRootScope::enter(); + let state = scope.root_nanbox(net::state(socket.value())); + let parser = scope.root_nanbox(net::own_get(state.get(), PARSER_EDGE)); + unsafe { np::payload_mut::(parser.get(), &PARSER).ok().map(f) } +} + +pub(super) fn reusable(owner: f64, key: &PoolKey) -> bool { + with_conn(&RootedSocket::new(owner), |conn| { + conn.reusable() && conn.key() == key + }) + .unwrap_or(false) +} + +fn mark_request(request: Handle) { + crate::CLIENT_REQUESTS_INFLIGHT + .lock() + .unwrap() + .insert((perry_ffi::agent_post::current_agent(), request)); +} +fn retire_request(request: Handle) { + crate::CLIENT_REQUESTS_INFLIGHT + .lock() + .unwrap() + .remove(&(perry_ffi::agent_post::current_agent(), request)); + perry_ffi::notify_main_thread(); +} + +/// Memory-only explicit parser release. It must not run JS or the driver: +/// Socket destroy is already retiring the actual transport capability. +unsafe fn close_parser(owner: f64) { + let socket = RootedSocket::new(owner); + let scope = TransientRootScope::enter(); + let state = scope.root_nanbox(net::state(socket.value())); + let error = scope.root_nanbox(net::own_get(state.get(), "closeError")); + let text = + |key: &str| JsValue::from_bits(net::own_get(error.get(), key).to_bits()).to_owned_string(); + let code = text("code").unwrap_or_else(|| "ECONNRESET".into()); + let message = text("message").unwrap_or_else(|| "socket hang up".into()); + let effects = + with_conn(&socket, |conn| protocol::on_destroy(conn, &code, &message)).unwrap_or_default(); + let parser = scope.root_nanbox(net::own_get(state.get(), PARSER_EDGE)); + np::close(parser.get(), &PARSER); + for effect in effects { + match effect { + Effect::RetireRequest(request) => retire_request(request), + Effect::Push(event) => push_event(event), + _ => {} // The caller already closes/cancels all transport operations. + } + } +} + +fn dispose_parser(socket: &RootedSocket) { + let scope = TransientRootScope::enter(); + let state = scope.root_nanbox(net::state(socket.value())); + let parser = scope.root_nanbox(net::own_get(state.get(), PARSER_EDGE)); + np::close(parser.get(), &PARSER); +} + +/// Begin on the actual Socket assigned by request/Agent ownership. A supplied +/// Socket keeps its existing read and TLS state. A fresh Socket dials once. +pub(super) fn start_on_socket( + owner: f64, + outbound: Outbound, + supplied_head: Option>, + retry: bool, +) -> Result<(), String> { + let socket = RootedSocket::new(owner); + if net::socket_link(socket.value()).is_err() { + return Err("createConnection must return a net.Socket".into()); + } + if !available() { + return Err("connect ENOTSUP".into()); + } + let supplied = supplied_head.is_some(); + let request = outbound.request_handle; + mark_request(request); + if !supplied && reusable(socket.value(), &outbound.key) { + net::deadline_cancel(socket.value()); + net::set_ref(socket.value(), true); + super::REUSED.fetch_add(1, Ordering::Relaxed); + remember_capability(request, &socket); + let effects = with_conn(&socket, |conn| conn.restart(outbound)).unwrap_or_default(); + run(&socket, effects); + return Ok(()); + } + if !supplied && net::snapshot(socket.value()).is_some() { + // An idle Socket with another TLS/pool identity cannot be redialed. + // Replace the actual request/Agent edge, then close the old owner. + return replace_socket(&socket, outbound, retry); + } + let timeout = outbound.timeout_ms; + let conn = if retry { + Conn::retry(outbound) + } else { + Conn::new(outbound, net::tls_installed(socket.value()), supplied_head) + }; + let (host, port) = conn.peer(); + let (host, port) = (host.to_owned(), port); + let scope = TransientRootScope::enter(); + let parser = scope.root_nanbox(unsafe { np::alloc_in(&PARSER, "", conn, std::mem::size_of::(), &[]) }); + if np::lifecycle(parser.get(), &PARSER) != Ok(np::Lifecycle::Open) { + retire_request(request); + return Err("HTTP client parser allocation refused".into()); + } + let state = scope.root_nanbox(net::state(socket.value())); + net::own_set( + state.get(), + "httpClientSupplied", + f64::from_bits(JsValue::from_bool(supplied).bits()), + ); + unsafe { net::set_codec(socket.value(), PARSER_EDGE, parser.get(), close_parser) }; + attach_http_errors(&socket); + net::set_route(socket.value(), SUBSYSTEM).map_err(|e| e.message())?; + if supplied { + remember_capability(request, &socket); + let connecting = + JsValue::from_bits(net::get(socket.value(), "connecting").to_bits()).to_bool(); + if !connecting { + let effects = with_conn(&socket, protocol::on_connect).unwrap_or_default(); + run(&socket, effects); + if socket.is_current() { + net::flow(socket.value()); + } + } + } else { + let host = scope.root_nanbox(f64::from_bits( + JsValue::from_string_ptr(perry_ffi::alloc_string(&host).as_raw()).bits(), + )); + net::connect( + socket.value(), + port as f64, + host.get(), + f64::from_bits(JsValue::UNDEFINED.bits()), + ); + } + if !supplied { + remember_capability(request, &socket); + } + if let Some(ms) = timeout { + if with_conn(&socket, |conn| conn.request() == Some(request)).unwrap_or(false) { + net::deadline_arm(socket.value(), ms); + } + } + Ok(()) +} + +fn remember_capability(request: Handle, socket: &RootedSocket) { + let capability = net::snapshot(socket.value()); + if let Some(req) = perry_ffi::get_handle_mut::(request) { + req.socket_snapshot = capability; + } +} + +pub(super) fn continue_body(_owner: f64, request: Handle, body: Vec) { + let Some(socket) = crate::current_request_socket(request) else { + return; + }; + let effects = with_conn(&socket, |conn| { + let mut effects = Vec::new(); + protocol::continue_body(conn, request, body, &mut effects); + effects + }) + .unwrap_or_default(); + run(&socket, effects); +} +pub(super) fn cancel(_owner: f64, request: Handle) { + let Some(socket) = crate::current_request_socket(request) else { + return; + }; + let effects = with_conn(&socket, |conn| protocol::cancel(conn, request)).unwrap_or_default(); + run(&socket, effects); +} + +fn run(socket: &RootedSocket, effects: Vec) { + let supplied = JsValue::from_bits( + net::own_get(net::state(socket.value()), "httpClientSupplied").to_bits(), + ) + .to_bool(); + let mut effects = VecDeque::from(effects); + while let Some(effect) = effects.pop_front() { + // App outcomes still settle the original request if a Socket listener + // closed/reopened it; the generational check forbids I/O on its new use. + match effect { + Effect::RetireRequest(request) => retire_request(request), + Effect::Push(event) => { + if matches!( + event, + PendingHttpEvent::ResponseEnd { .. } | PendingHttpEvent::Response { .. } + ) { + super::COMPLETED.fetch_add(1, Ordering::Relaxed); + if supplied { + super::RAW_COMPLETED.fetch_add(1, Ordering::Relaxed); + } + } + if matches!(event, PendingHttpEvent::Timeout { .. }) { + super::TIMED_OUT.fetch_add(1, Ordering::Relaxed); + } + push_event(event); + } + other if !socket.is_current() => { + drop(other); + } + Effect::Write(bytes) => { + if let Err(error) = net::write(socket.value(), &bytes, 0) { + effects.extend( + with_conn(socket, |conn| { + protocol::on_error( + conn, + &error.code, + &error.syscall, + error.errno as i64, + ) + }) + .unwrap_or_default(), + ); + } + } + Effect::InstallTls(plan) => { + let scope = TransientRootScope::enter(); + let callback = scope.root_addr(perry_ffi::alloc_closure( + perry_ffi::js_function_info!(handshaken, 0; with_flags(perry_ffi::FN_BUILTIN)), + 0, + ) as i64); + net::once( + socket.value(), + "secureConnect", + f64::from_bits(JsValue::from_object_ptr(callback.get() as *mut u8).bits()), + ); + if let Err(error) = net::install_client_tls( + socket.value(), + plan.config, + plan.server_name, + plan.metadata, + ) { + effects.extend( + with_conn(socket, |conn| { + protocol::on_destroy(conn, "ERR_TLS_HANDSHAKE_FAILED", &error) + }) + .unwrap_or_default(), + ); + } + } + Effect::Ciphertext(bytes) => net::receive_tls(socket.value(), &bytes), + Effect::Close => net::destroy(socket.value()), + Effect::Deadline(Some(ms)) => net::deadline_arm(socket.value(), ms), + Effect::Deadline(None) => net::deadline_cancel(socket.value()), + Effect::Park(policy) => { + net::set_ref(socket.value(), false); + net::deadline_arm(socket.value(), policy.idle_ms); + #[cfg(test)] + if std::env::var("PERRY_NET_A_CLIENT_SABOTAGE").as_deref() == Ok("idlepark") { + net::deadline_park(socket.value()); + } + } + Effect::Upgrade { + request, + status, + reason, + headers, + head, + } => { + if supplied { + super::RAW_COMPLETED.fetch_add(1, Ordering::Relaxed); + } + #[cfg(test)] + let store_route = + std::env::var("PERRY_NET_A_CLIENT_SABOTAGE").as_deref() != Ok("route"); + #[cfg(not(test))] + let store_route = true; + if let Err(error) = if store_route { + net::set_route(socket.value(), net::ROUTE) + } else { + Ok(()) + } { + push_event(PendingHttpEvent::CodedError { + request_handle: request, + message: error.message(), + code: error.code, + }); + net::destroy(socket.value()); + continue; + } + dispose_parser(socket); + detach_http_errors(socket); + #[cfg(test)] + let head = if std::env::var("PERRY_NET_A_CLIENT_SABOTAGE").as_deref() == Ok("head") { + Vec::new() + } else { + head + }; + push_event(PendingHttpEvent::Upgrade { + request_handle: request, + status, + status_message: reason, + headers, + socket_handle: JsValue::from_bits(socket.value().to_bits()).as_pointer::() + as i64, + head, + }); + } + Effect::Redispatch(out) => retry(socket, *out), + } + } +} + +// HTTP owns Socket errors until upgrade, just as Node's connection handler +// does. Protocol effects already deliver the request error. The ordinary +// Socket listener prevents a second unhandled emitter error; it leaves with +// HTTP, so an upgraded Socket follows the user's own error listeners. +unsafe extern "C" fn http_socket_error( + _: *const perry_ffi::RawClosureHeader, + _: perry_ffi::JsThis, + _: f64, +) -> f64 { + f64::from_bits(JsValue::UNDEFINED.bits()) +} + +fn detach_http_errors(socket: &RootedSocket) { + let scope = TransientRootScope::enter(); + let state = scope.root_nanbox(net::state(socket.value())); + let error = scope.root_nanbox(net::own_get(state.get(), "httpErrorListener")); + let close = scope.root_nanbox(net::own_get(state.get(), "httpCloseListener")); + net::own_set( + state.get(), + "httpErrorListener", + f64::from_bits(JsValue::UNDEFINED.bits()), + ); + net::own_set( + state.get(), + "httpCloseListener", + f64::from_bits(JsValue::UNDEFINED.bits()), + ); + net::remove_listener(socket.value(), "error", error.get()); + net::remove_listener(socket.value(), "close", close.get()); +} + +unsafe extern "C" fn http_socket_closed( + closure: *const perry_ffi::RawClosureHeader, + _: perry_ffi::JsThis, + _: f64, +) -> f64 { + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(perry_ffi::closure_capture_f64(closure, 0)); + let error = scope.root_nanbox(perry_ffi::closure_capture_f64(closure, 1)); + let state = scope.root_nanbox(net::state(owner.get())); + net::remove_listener(owner.get(), "error", error.get()); + // A previous listener may already have reopened the Socket and installed + // another handler. Remove only the handler this closure actually owns. + if net::own_get(state.get(), "httpErrorListener").to_bits() == error.get().to_bits() { + net::own_set( + state.get(), + "httpErrorListener", + f64::from_bits(JsValue::UNDEFINED.bits()), + ); + net::own_set( + state.get(), + "httpCloseListener", + f64::from_bits(JsValue::UNDEFINED.bits()), + ); + } + f64::from_bits(JsValue::UNDEFINED.bits()) +} + +fn attach_http_errors(socket: &RootedSocket) { + detach_http_errors(socket); + let scope = TransientRootScope::enter(); + let error = scope.root_addr(perry_ffi::alloc_closure( + perry_ffi::js_function_info!(http_socket_error, 1; with_flags(perry_ffi::FN_BUILTIN)), + 0, + ) as i64); + let close = scope.root_addr(perry_ffi::alloc_closure( + perry_ffi::js_function_info!(http_socket_closed, 1; with_flags(perry_ffi::FN_BUILTIN)), + 2, + ) as i64); + let error_value = || f64::from_bits(JsValue::from_object_ptr(error.get() as *mut u8).bits()); + unsafe { + perry_ffi::set_closure_capture_f64( + close.get() as *mut perry_ffi::RawClosureHeader, + 0, + socket.value(), + ); + perry_ffi::set_closure_capture_f64( + close.get() as *mut perry_ffi::RawClosureHeader, + 1, + error_value(), + ); + } + let state = scope.root_nanbox(net::state(socket.value())); + net::own_set(state.get(), "httpErrorListener", error_value()); + net::own_set( + state.get(), + "httpCloseListener", + f64::from_bits(JsValue::from_object_ptr(close.get() as *mut u8).bits()), + ); + net::once(socket.value(), "error", error_value()); + net::once( + socket.value(), + "close", + f64::from_bits(JsValue::from_object_ptr(close.get() as *mut u8).bits()), + ); +} + +unsafe extern "C" fn handshaken( + _: *const perry_ffi::RawClosureHeader, + _: perry_ffi::JsThis, +) -> f64 { + super::HANDSHAKES.fetch_add(1, Ordering::Relaxed); + f64::from_bits(JsValue::UNDEFINED.bits()) +} + +fn replace_socket(previous: &RootedSocket, outbound: Outbound, retry: bool) -> Result<(), String> { + let request = outbound.request_handle; + let Some((agent, key)) = perry_ffi::get_handle_mut::(request) + .filter(|req| !req.completed) + .map(|req| (req.agent_handle, req.agent_key.clone())) + else { + return Err("request already completed".into()); + }; + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(net::new_socket( + SUBSYSTEM, + f64::from_bits(JsValue::UNDEFINED.bits()), + )); + let old = JsValue::from_bits(previous.value().to_bits()).as_pointer::() as i64; + let raw = JsValue::from_bits(owner.get().to_bits()).as_pointer::() as i64; + if let Some(req) = perry_ffi::get_handle_mut::(request) { + req.socket_handle = raw; + req.socket_snapshot = None; + req.reused_socket = false; + } + if let Some(owner_agent) = perry_ffi::get_handle_mut::(agent) { + if let Some(sockets) = owner_agent.active_socket_handles.get_mut(&key) { + for socket in sockets { + if *socket == old { + *socket = raw; + } + } + } + } + if agent != 0 { + crate::agent::track_agent_socket(agent, raw); + } + if previous.is_current() { + net::destroy(previous.value()); + } + push_event(PendingHttpEvent::Socket { + request_handle: request, + }); + start_on_socket(owner.get(), outbound, None, retry) +} + +fn retry(previous: &RootedSocket, outbound: Outbound) { + let request = outbound.request_handle; + if let Err(message) = replace_socket(previous, outbound, true) { + retire_request(request); + push_event(PendingHttpEvent::Error { + request_handle: request, + error_message: message, + }); + } +} + +extern "C" fn sink(completion: *const tl::NetCompletion) { + if completion.is_null() { + return; + } + let event = unsafe { &*completion }; + let Some(link) = event.link() else { return }; + let Some(owner) = (unsafe { np::link_event_owner(link) }) else { + return; + }; + let socket = RootedSocket::new(owner); + if net::socket_link(socket.value()).is_err() { + return; + } + match event.kind { + tl::NET_CONNECT => { + unsafe { + net::dispatch_common(event); + } + let effects = with_conn(&socket, protocol::on_connect).unwrap_or_default(); + run(&socket, effects); + } + tl::NET_DATA => { + let bytes = unsafe { event.bytes() }; + if event.flags & tl::NET_FLAG_PLAINTEXT == 0 && net::tls_installed(socket.value()) { + net::receive_tls(socket.value(), bytes); + } else { + net::received(socket.value(), bytes.len()); + let effects = + with_conn(&socket, |conn| protocol::on_data(conn, bytes)).unwrap_or_default(); + run(&socket, effects); + } + } + tl::NET_EOF => { + let effects = with_conn(&socket, protocol::on_eof).unwrap_or_default(); + net::eof(socket.value()); + run(&socket, effects); + } + tl::NET_TIMER => { + let effects = with_conn(&socket, protocol::on_timer).unwrap_or_default(); + run(&socket, effects); + } + tl::NET_ERROR => { + let code = unsafe { event.code() }.unwrap_or("EIO"); + let syscall = unsafe { event.syscall() }.unwrap_or("read"); + let effects = with_conn(&socket, |conn| { + protocol::on_error(conn, code, syscall, event.errno as i64) + }) + .unwrap_or_default(); + net::destroy_error(socket.value(), code, &format!("{syscall} {code}")); + run(&socket, effects); + } + tl::NET_CLOSED => { + if event.flags & tl::NET_FLAG_STALE == 0 { + let effects = with_conn(&socket, protocol::on_closed).unwrap_or_default(); + dispose_parser(&socket); + run(&socket, effects); + } + unsafe { + net::dispatch_common(event); + } + } + _ => unsafe { + net::dispatch_common(event); + }, + } +} + +#[cfg(test)] +#[path = "native_client_tests.rs"] +mod tests; diff --git a/crates/perry-ext-http/src/client_turnloop/pool.rs b/crates/perry-ext-http/src/client_turnloop/pool.rs index 12539c83e8..4f2c13c5f4 100644 --- a/crates/perry-ext-http/src/client_turnloop/pool.rs +++ b/crates/perry-ext-http/src/client_turnloop/pool.rs @@ -1,29 +1,8 @@ -//! Keep-alive: which idle connection a request may reuse, and when an idle one -//! is let go. +//! Pure keep-alive policy and compatibility keys for actual Agent Socket edges. //! -//! This is the *physical* pool. `agent.rs` keeps the *observable* one — -//! `agent.sockets` / `freeSockets` / `requests`, `req.reusedSocket`, the -//! `maxSockets` FIFO — and it is unchanged: it always sat above the transport, -//! and reqwest's hidden connection pool sat below it. This module is what -//! replaces the latter, with the same knobs reqwest was configured from -//! (`client_for_agent`: `keepAlive`, `maxFreeSockets`, `keepAliveMsecs`). -//! -//! # The ordering rule -//! -//! A connection is parked only from `conn.rs`'s `finish`, i.e. after the -//! decoder has delivered `Event::End` for the response it was carrying, with -//! nothing left over in its input buffer, and only when -//! `http1::Decoder::reusable()` agrees (a complete message and a keep-alive -//! response). Releasing earlier is the framing-misattribution hazard: the -//! next request would read the tail of the previous response as its own. -//! -//! # Idle connections -//! -//! An idle connection is unreferenced (`tl::set_ref(false)`), so it never -//! keeps the process alive — Node unrefs its free sockets too — and it is -//! closed by an unreferenced idle timer (`keepAliveMsecs`, 1 s by default, -//! which is what reqwest's `pool_idle_timeout` was set to), by the peer, or by -//! any unsolicited byte arriving on it. +//! There is no physical connection pool here. `agent.rs` owns active/free +//! Socket objects. Framing parks one only after a complete reusable response; +//! its unreferenced core deadline, peer EOF or unsolicited data releases it. use perry_ffi::Handle; diff --git a/crates/perry-ext-http/src/client_turnloop/protocol.rs b/crates/perry-ext-http/src/client_turnloop/protocol.rs new file mode 100644 index 0000000000..5279c685e2 --- /dev/null +++ b/crates/perry-ext-http/src/client_turnloop/protocol.rs @@ -0,0 +1,808 @@ +//! Pure HTTP client framing owned by a Socket's separate parser payload. +//! There are no transport identities, JS values, GC pointers, driver calls or +//! retirement guards here. The rooted Socket runner performs copied effects +//! after this borrow ends, using its existing generational handle capability. + +use super::{tls, wire, Mode, Outbound, PoolKey, Reuse}; +use crate::PendingHttpEvent; +use bytes::Bytes; +use perry_ffi::Handle; +use turnloop_http::http1; +const MAX_STEPS: usize = 1 << 20; + +pub(super) enum Effect { + Write(Vec), + InstallTls(tls::TlsPlan), + Ciphertext(Vec), + Close, + Deadline(Option), + Push(PendingHttpEvent), + RetireRequest(Handle), + Redispatch(Box), + Park(Reuse), + Upgrade { + request: Handle, + status: u16, + reason: String, + headers: Vec<(String, String)>, + head: Vec, + }, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum Phase { + /// `tcp_connect` submitted. + Connecting, + /// Connected to a proxy; waiting for the `CONNECT` response. + Tunnel, + /// Carrying (or about to carry) an exchange. + Open, + /// Parked in the pool between exchanges. + Idle, + /// `tl::close` submitted; waiting for `NET_CLOSED`. + Closing, +} + +/// The `Expect: 100-continue` body hand-off. +enum Continue { + /// Not a continue exchange, or the body has been dealt with. + Done, + /// Waiting for the interim `100`; `end()` may already have supplied the + /// body. + AwaitingInterim(Option>), + /// The `100` arrived before `end()`: the body goes out when it comes. + Released, +} + +struct ResponseHead { + status: u16, + reason: String, + version: u8, + headers: Vec<(String, String)>, +} + +pub(super) struct Exchange { + out: Box, + framing: wire::Framing, + /// The request asked the server to close. + closes: bool, + /// The request head has been handed to the socket (or TLS session). + sent: bool, + /// This exchange runs on a connection taken from the pool. + reused: bool, + /// This exchange is itself a retry; it is never retried again. + retried: bool, + /// Any response byte arrived. A reused connection that dies before one + /// does is retried once on a fresh connection, as hyper's pool did: the + /// peer closing an idle keep-alive socket just as it is reused is a race, + /// not a failure of the request. + got_bytes: bool, + head: Option, + /// A `ResponseHead` event has been queued (the drain has an + /// `IncomingMessage` to fail with `'aborted'`). + head_delivered: bool, + buffered: Vec, + trailers: Vec<(String, String)>, + cont: Continue, +} + +pub(super) struct Conn { + key: PoolKey, + /// What was dialed — the proxy when proxied — for Node's error messages. + peer_host: String, + peer_port: u16, + pub(super) phase: Phase, + decoder: http1::Decoder, + inbuf: Vec, + exchange: Option, + /// Exchanges this connection has completed. + served: u32, + native_tls: bool, + preset_head: Option>, +} + +fn new_decoder() -> http1::Decoder { + http1::Decoder::new(http1::Mode::Response, http1::Limits::default()) +} + +pub(super) fn dial_host(url: &url::Url) -> Option { + Some(match url.host()? { + url::Host::Domain(domain) => domain.to_string(), + url::Host::Ipv4(address) => address.to_string(), + url::Host::Ipv6(address) => address.to_string(), + }) +} + +fn streams(out: &Outbound) -> bool { + match out.mode { + Mode::Normal | Mode::Continue => true, + Mode::Trailers => false, + // Upgrade is deferred until its complete head is known, including + // over TLS: a 101 fires upgrade without an earlier response callback. + Mode::Upgrade => false, + } +} + +impl Conn { + pub(super) fn new(out: Outbound, native_tls: bool, preset_head: Option>) -> Self { + let (peer_host, peer_port) = match &out.proxy { + Some(proxy) => ( + dial_host(proxy).unwrap_or_default(), + proxy.port_or_known_default().unwrap_or(80), + ), + None => (out.key.host.clone(), out.key.port), + }; + Self { + key: out.key.clone(), + peer_host, + peer_port, + phase: Phase::Connecting, + decoder: new_decoder(), + inbuf: Vec::new(), + exchange: None, + served: 0, + native_tls, + preset_head, + } + .with_exchange(out, false, false) + } + fn with_exchange(mut self, out: Outbound, reused: bool, retried: bool) -> Self { + self.exchange = Some(Exchange { + framing: wire::Framing::Raw, + closes: false, + sent: false, + reused, + retried, + got_bytes: false, + head: None, + head_delivered: false, + buffered: Vec::new(), + trailers: Vec::new(), + cont: if out.mode == Mode::Continue { + Continue::AwaitingInterim(None) + } else { + Continue::Done + }, + out: Box::new(out), + }); + self + } + pub(super) fn peer(&self) -> (&str, u16) { + (&self.peer_host, self.peer_port) + } + pub(super) fn retry(out: Outbound) -> Self { + let mut conn = Self::new(out, false, None); + if let Some(exchange) = &mut conn.exchange { + exchange.retried = true; + } + conn + } + pub(super) fn key(&self) -> &PoolKey { + &self.key + } + pub(super) fn request(&self) -> Option { + self.exchange.as_ref().map(|ex| ex.out.request_handle) + } + pub(super) fn reusable(&self) -> bool { + self.phase == Phase::Idle && self.exchange.is_none() + } + pub(super) fn restart(&mut self, out: Outbound) -> Vec { + self.exchange = Self::new(out, self.native_tls, None).exchange; + if let Some(ex) = &mut self.exchange { + ex.reused = true; + } + self.phase = Phase::Open; + let mut fx = Vec::new(); + if let Some(ms) = self.exchange.as_ref().and_then(|ex| ex.out.timeout_ms) { + fx.push(Effect::Deadline(Some(ms))); + } + send_request(self, &mut fx); + fx + } +} + +pub(super) fn on_connect(conn: &mut Conn) -> Vec { + let mut fx = Vec::new(); + if conn.phase != Phase::Connecting { + return fx; + } + // Reading starts before anything is written: a loopback peer's reply can + // be in flight before the write submission returns. + let tunnel = conn.exchange.as_ref().and_then(|ex| { + let out = &ex.out; + (out.key.https) + .then(|| out.proxy.as_ref()) + .flatten() + .map(|proxy| wire::connect_head(&out.key.host, out.key.port, proxy)) + }); + if let Some(head) = tunnel { + conn.phase = Phase::Tunnel; + conn.decoder.response_to("CONNECT"); + fx.push(Effect::Write(head)); + return fx; + } + conn.phase = Phase::Open; + if conn.key.https && !open_tls(conn, &mut fx) { + return fx; + } + send_request(conn, &mut fx); + fx +} + +fn open_tls(conn: &mut Conn, fx: &mut Vec) -> bool { + if conn.native_tls { + return true; + } + if let Some(plan) = conn.exchange.as_ref().and_then(|ex| ex.out.tls.clone()) { + conn.native_tls = true; + fx.push(Effect::InstallTls(plan)); + true + } else { + fail_coded( + conn, + "https request without a TLS configuration".into(), + "ERR_SSL_PROTOCOL_ERROR", + fx, + ); + false + } +} + +fn send_request(conn: &mut Conn, fx: &mut Vec) { + let Some(ex) = conn.exchange.as_mut() else { + return; + }; + if ex.sent { + return; + } + if conn.served > 0 && conn.decoder.reset().is_err() { + // Only a connection the decoder called reusable is ever parked, so + // this cannot happen; if it does, a fresh decoder is the safe state. + conn.decoder = new_decoder(); + } + if let Some(bytes) = conn.preset_head.take() { + ex.sent = true; + conn.decoder + .response_to(&ex.out.method.to_ascii_uppercase()); + fx.push(Effect::Write(bytes)); + return; + } + let method = ex.out.method.to_ascii_uppercase(); + conn.decoder.response_to(&method); + + let out = &ex.out; + let absolute = out.proxy.is_some() && !out.key.https; + let target = wire::request_target(&out.url, absolute); + let mut extra = out.extra.clone(); + if absolute { + if let Some(credentials) = out.proxy.as_ref().and_then(wire::basic_credentials) { + extra.push(("Proxy-Authorization".to_string(), credentials)); + } + } + let serialized = wire::serialize_head( + &out.method, + &target, + &out.url, + &out.headers, + &extra, + out.body.len(), + out.mode, + ); + let mut bytes = serialized.head; + if out.mode != Mode::Continue { + bytes.extend_from_slice(&wire::frame_body(&out.body, serialized.framing)); + } + ex.framing = serialized.framing; + ex.closes = serialized.closes; + ex.sent = true; + send(conn, bytes, fx); +} + +fn send(_: &mut Conn, bytes: Vec, fx: &mut Vec) { + fx.push(Effect::Write(bytes)); +} + +pub(super) fn on_data(conn: &mut Conn, bytes: &[u8]) -> Vec { + let mut fx = Vec::new(); + match conn.phase { + Phase::Connecting | Phase::Closing => (), + Phase::Tunnel => tunnel_input(conn, bytes, &mut fx), + Phase::Open | Phase::Idle => http_input(conn, bytes, &mut fx), + } + fx +} + +fn tunnel_input(conn: &mut Conn, bytes: &[u8], fx: &mut Vec) { + conn.inbuf.extend_from_slice(bytes); + for _ in 0..MAX_STEPS { + let step = match conn.decoder.receive(&conn.inbuf) { + Ok(step) => step, + Err(error) => { + fail_coded( + conn, + format!("{} {}", error.code, error.message), + "ERR_PROXY_TUNNEL", + fx, + ); + return; + } + }; + let consumed = step.consumed; + let outcome = match step.event { + Some(http1::Event::Head(head)) if (200..300).contains(&head.status) => None, + Some(http1::Event::Head(head)) => Some(Err(format!( + "Failed to establish tunnel to {}:{}: HTTP/1.{} {} {}", + conn.key.host, + conn.key.port, + head.version, + head.status, + wire::reason_phrase(&conn.inbuf[..consumed]), + ))), + Some(http1::Event::Upgrade) => Some(Ok(())), + Some(_) => None, + None if consumed == 0 => return, + None => None, + }; + conn.inbuf.drain(..consumed); + match outcome { + None => {} + Some(Err(message)) => { + fail_coded(conn, message, "ERR_PROXY_TUNNEL", fx); + return; + } + Some(Ok(())) => { + let leftover = std::mem::take(&mut conn.inbuf); + conn.decoder = new_decoder(); + conn.phase = Phase::Open; + if !open_tls(conn, fx) { + return; + } + send_request(conn, fx); + if !leftover.is_empty() { + fx.push(Effect::Ciphertext(leftover)); + } + return; + } + } + } +} + +fn http_input(conn: &mut Conn, bytes: &[u8], fx: &mut Vec) { + conn.inbuf.extend_from_slice(bytes); + process(conn, fx); +} + +enum Decoded { + Head(http1::Head, String), + Informational(u16), + Body(Vec), + Trailers(Vec), + End, + Upgrade, + Nothing, +} + +fn process(conn: &mut Conn, fx: &mut Vec) { + for _ in 0..MAX_STEPS { + let Some(ex) = conn.exchange.as_mut() else { + // Bytes on a connection with no request in flight: unsolicited + // data on an idle keep-alive socket, or bytes after a response + // ended. Neither can be framed; the connection is done. + if !conn.inbuf.is_empty() { + close(conn, fx); + } + return; + }; + if !conn.inbuf.is_empty() { + ex.got_bytes = true; + } + let step = match conn.decoder.receive(&conn.inbuf) { + Ok(step) => step, + Err(error) => { + fail_protocol(conn, error, fx); + return; + } + }; + let consumed = step.consumed; + let decoded = match step.event { + Some(http1::Event::Head(head)) => { + let reason = wire::reason_phrase(&conn.inbuf[..consumed]); + Decoded::Head(head, reason) + } + Some(http1::Event::Informational(head)) => Decoded::Informational(head.status), + Some(http1::Event::Body(chunk)) => Decoded::Body(chunk.to_vec()), + Some(http1::Event::Trailers(trailers)) => Decoded::Trailers(trailers), + Some(http1::Event::End) => Decoded::End, + Some(http1::Event::Upgrade) => Decoded::Upgrade, + None => Decoded::Nothing, + }; + conn.inbuf.drain(..consumed); + match decoded { + Decoded::Nothing => { + if consumed == 0 { + return; + } + } + Decoded::Informational(status) => on_informational(conn, status, fx), + Decoded::Head(head, reason) => on_head(conn, head, reason, fx), + Decoded::Body(chunk) => on_body(conn, chunk, fx), + Decoded::Trailers(trailers) => { + if let Some(ex) = conn.exchange.as_mut() { + ex.trailers = trailers + .into_iter() + .map(|h| (h.name, String::from_utf8_lossy(&h.value).into_owned())) + .collect(); + } + } + Decoded::End => finish(conn, false, fx), + Decoded::Upgrade => on_upgrade(conn, fx), + } + } +} + +fn on_informational(conn: &mut Conn, status: u16, fx: &mut Vec) { + if status != 100 { + // `102`/`103` — Node's `'information'`, which neither the reqwest + // path nor the bypasses surfaced. + return; + } + let Some(ex) = conn.exchange.as_mut() else { + return; + }; + if ex.out.mode != Mode::Continue { + return; + } + let request_handle = ex.out.request_handle; + let body = match std::mem::replace(&mut ex.cont, Continue::Done) { + Continue::AwaitingInterim(Some(body)) => Some(body), + Continue::AwaitingInterim(None) => { + ex.cont = Continue::Released; + None + } + other => { + ex.cont = other; + return; + } + }; + fx.push(Effect::Push(PendingHttpEvent::Continue { request_handle })); + if let Some(body) = body { + let framed = wire::frame_body(&body, ex.framing); + send(conn, framed, fx); + } +} + +pub(super) fn continue_body( + conn: &mut Conn, + request_handle: Handle, + body: Vec, + fx: &mut Vec, +) { + let Some(ex) = conn.exchange.as_mut() else { + return; + }; + if ex.out.request_handle != request_handle || ex.out.mode != Mode::Continue { + return; + } + match std::mem::replace(&mut ex.cont, Continue::Done) { + Continue::AwaitingInterim(_) => ex.cont = Continue::AwaitingInterim(Some(body)), + Continue::Released => { + let framed = wire::frame_body(&body, ex.framing); + send(conn, framed, fx); + } + Continue::Done => {} + } +} + +fn on_head(conn: &mut Conn, head: http1::Head, reason: String, fx: &mut Vec) { + let Some(ex) = conn.exchange.as_mut() else { + return; + }; + // A final response without the interim `100`: the server declined to see + // the body, which is never sent. + if matches!(ex.cont, Continue::AwaitingInterim(_) | Continue::Released) { + ex.cont = Continue::Done; + } + let headers: Vec<(String, String)> = head + .headers + .iter() + .map(|h| { + ( + h.name.clone(), + String::from_utf8_lossy(&h.value).into_owned(), + ) + }) + .collect(); + let status = head.status; + if streams(&ex.out) { + ex.head_delivered = true; + fx.push(Effect::Push(PendingHttpEvent::ResponseHead { + request_handle: ex.out.request_handle, + status, + status_message: reason.clone(), + headers: headers.clone(), + // `Head::version` is the HTTP/1 minor: 0 for 1.0, 1 for 1.1. + http_version: (1, head.version), + })); + } + ex.head = Some(ResponseHead { + status, + reason, + version: head.version, + headers, + }); +} + +fn on_body(conn: &mut Conn, chunk: Vec, fx: &mut Vec) { + let Some(ex) = conn.exchange.as_mut() else { + return; + }; + if ex.head_delivered { + fx.push(Effect::Push(PendingHttpEvent::ResponseChunk { + request_handle: ex.out.request_handle, + chunk: Bytes::from(chunk), + })); + } else { + ex.buffered.extend_from_slice(&chunk); + } +} + +fn on_upgrade(conn: &mut Conn, fx: &mut Vec) { + let requested = conn.exchange.as_ref().is_some_and(|ex| { + ex.out.mode == Mode::Upgrade && ex.head.as_ref().is_some_and(|head| head.status == 101) + }); + if !requested { + finish(conn, true, fx); + return; + } + let Some(ex) = take_exchange(conn, fx) else { + return; + }; + let head = ex.head.unwrap(); + // TLS remains on the Socket. Parser disposal cannot discard the session. + fx.push(Effect::Upgrade { + request: ex.out.request_handle, + status: head.status, + reason: head.reason, + headers: head.headers, + head: std::mem::take(&mut conn.inbuf), + }); + conn.phase = Phase::Closing; +} +fn take_exchange(conn: &mut Conn, fx: &mut Vec) -> Option { + let ex = conn.exchange.take()?; + fx.push(Effect::Deadline(None)); + fx.push(Effect::RetireRequest(ex.out.request_handle)); + Some(ex) +} + +fn finish(conn: &mut Conn, never_reuse: bool, fx: &mut Vec) { + let Some(ex) = take_exchange(conn, fx) else { + return; + }; + let request_handle = ex.out.request_handle; + if ex.head_delivered { + fx.push(Effect::Push(PendingHttpEvent::ResponseEnd { + request_handle, + })); + } else { + let head = ex.head.as_ref(); + fx.push(Effect::Push(PendingHttpEvent::Response { + request_handle, + status: head.map_or(0, |h| h.status), + status_message: head.map(|h| h.reason.clone()).unwrap_or_default(), + headers: head.map(|h| h.headers.clone()).unwrap_or_default(), + trailers: ex.trailers.clone(), + body: ex.buffered.clone(), + http_version: (1, head.map_or(1, |h| h.version)), + })); + } + let reuse = ex.out.reuse.filter(|_| { + !never_reuse + && ex.out.mode == Mode::Normal + && !ex.closes + && conn.decoder.reusable() + && conn.inbuf.is_empty() + }); + drop(ex); + match reuse { + Some(reuse) => park(conn, reuse, fx), + None => close(conn, fx), + } +} + +fn park(conn: &mut Conn, reuse: Reuse, fx: &mut Vec) { + conn.phase = Phase::Idle; + conn.served = conn.served.saturating_add(1); + fx.push(Effect::Park(reuse)); +} +fn close(conn: &mut Conn, fx: &mut Vec) { + if conn.phase != Phase::Closing { + conn.phase = Phase::Closing; + fx.push(Effect::Close); + } +} + +fn fail_coded(conn: &mut Conn, message: String, code: &str, fx: &mut Vec) { + if let Some(ex) = take_exchange(conn, fx) { + fx.push(Effect::Push(PendingHttpEvent::CodedError { + request_handle: ex.out.request_handle, + message, + code: code.to_string(), + })); + } + close(conn, fx); +} + +fn fail_protocol(conn: &mut Conn, error: turnloop_http::Error, fx: &mut Vec) { + if let Some(ex) = take_exchange(conn, fx) { + fx.push(Effect::Push(PendingHttpEvent::CodedError { + request_handle: ex.out.request_handle, + message: format!("Parse Error: {}", error.message), + code: error.code.to_string(), + })); + } + close(conn, fx); +} + +fn premature(conn: &mut Conn, fx: &mut Vec) { + let Some(ex) = take_exchange(conn, fx) else { + close(conn, fx); + return; + }; + close(conn, fx); + report_premature(ex, fx); +} + +fn report_premature(ex: Exchange, fx: &mut Vec) { + let request_handle = ex.out.request_handle; + if ex.reused && !ex.got_bytes && !ex.retried { + // The stale keep-alive race: nothing of the response arrived, so the + // request may safely be sent again on a fresh connection. + let Exchange { out, .. } = ex; + fx.push(Effect::Redispatch(out)); + return; + } + if ex.head_delivered { + // Node: the response is `'aborted'`; the drain builds that error from + // the `IncomingMessage` it already has. + fx.push(Effect::Push(PendingHttpEvent::Error { + request_handle, + error_message: "aborted".to_string(), + })); + } else { + fx.push(Effect::Push(PendingHttpEvent::CodedError { + request_handle, + message: "socket hang up".to_string(), + code: "ECONNRESET".to_string(), + })); + } +} + +fn eof(conn: &mut Conn, fx: &mut Vec) { + match conn.phase { + Phase::Closing => return, + Phase::Idle | Phase::Connecting => { + close(conn, fx); + return; + } + Phase::Tunnel => { + premature(conn, fx); + return; + } + Phase::Open => {} + } + if conn.exchange.is_none() { + close(conn, fx); + return; + } + // A body delimited by the close ends here: the decoder turns EOF into + // `End`, which the next step delivers. + if conn.decoder.eof().is_ok() { + process(conn, fx); + } + let still_open = conn.exchange.is_some(); + if still_open { + premature(conn, fx); + } +} + +pub(super) fn on_eof(conn: &mut Conn) -> Vec { + let mut fx = Vec::new(); + eof(conn, &mut fx); + fx +} + +pub(super) fn on_error(conn: &mut Conn, code: &str, syscall: &str, errno: i64) -> Vec { + let mut fx = Vec::new(); + match conn.phase { + Phase::Closing => return fx, + Phase::Idle => { + close(conn, &mut fx); + return fx; + } + Phase::Connecting => { + // Node's connect-failure message names the peer: + // `connect ECONNREFUSED 127.0.0.1:1`, `getaddrinfo ENOTFOUND host`. + let (message, code, syscall, errno) = crate::transport_error::connect_failure( + code, + syscall, + errno, + &conn.peer_host, + conn.peer_port, + ); + if let Some(ex) = take_exchange(conn, &mut fx) { + fx.push(Effect::Push(PendingHttpEvent::TransportError { + request_handle: ex.out.request_handle, + message, + code, + syscall, + errno, + })); + } + close(conn, &mut fx); + return fx; + } + Phase::Tunnel | Phase::Open => {} + } + if matches!(code, "ECONNRESET" | "EPIPE" | "ECONNABORTED") { + premature(conn, &mut fx); + return fx; + } + if let Some(ex) = take_exchange(conn, &mut fx) { + let message = if syscall.is_empty() { + code.to_string() + } else { + format!("{syscall} {code}") + }; + fx.push(Effect::Push(PendingHttpEvent::TransportError { + request_handle: ex.out.request_handle, + message, + code: code.to_string(), + syscall: syscall.to_string(), + errno, + })); + } + close(conn, &mut fx); + fx +} + +pub(super) fn on_closed(conn: &mut Conn) -> Vec { + let mut fx = Vec::new(); + if let Some(ex) = take_exchange(conn, &mut fx) { + report_premature(ex, &mut fx); + } + conn.phase = Phase::Closing; + fx +} +pub(super) fn on_timer(conn: &mut Conn) -> Vec { + let mut fx = Vec::new(); + if conn.phase == Phase::Idle { + close(conn, &mut fx); + } else if let Some(ex) = take_exchange(conn, &mut fx) { + fx.push(Effect::Push(PendingHttpEvent::Timeout { + request_handle: ex.out.request_handle, + })); + close(conn, &mut fx); + } + fx +} +pub(super) fn cancel(conn: &mut Conn, request: Handle) -> Vec { + let mut fx = Vec::new(); + if conn.request() == Some(request) { + drop(take_exchange(conn, &mut fx)); + close(conn, &mut fx); + } + fx +} + +/// The Socket's explicit close hook cannot enter the driver. It copies the +/// terminal app outcome, then releases this parser before returning. +pub(super) fn on_destroy(conn: &mut Conn, code: &str, message: &str) -> Vec { + let mut fx = Vec::new(); + fail_coded(conn, message.to_owned(), code, &mut fx); + fx +} + +#[cfg(test)] +#[path = "protocol_tests.rs"] +mod tests; diff --git a/crates/perry-ext-http/src/client_turnloop/raw_socket.rs b/crates/perry-ext-http/src/client_turnloop/raw_socket.rs deleted file mode 100644 index 2e4441d9e6..0000000000 --- a/crates/perry-ext-http/src/client_turnloop/raw_socket.rs +++ /dev/null @@ -1,309 +0,0 @@ -//! An HTTP exchange over a socket JS produced: `agent.createConnection` / -//! `createSocket` (#2154) and the request-level `createConnection` (#10469). -//! -//! The socket belongs to `perry-ext-net`, which already runs it on the agent's -//! turnloop loop (#11105) and, for `tls.connect`, decrypts above it. This -//! module never touches the handle directly: it speaks to the socket through -//! perry-ffi's raw-net vtable (`attach` / `write` / `poll_read` / `detach` / -//! `close`), so the user's socket keeps its own connect/TLS state and JS -//! identity, exactly as before. -//! -//! What changed is how the bytes are *waited for*. This used to be a tokio task -//! that called `poll_read` in a loop with a 1 ms `tokio::time::sleep` between -//! empty reads. Now `perry-ext-net` calls `perry_ffi::raw_net_notify` when a -//! raw-mode socket gains bytes or goes terminal, and [`raw_socket_ready`] schedules a -//! drain on a 0 ms loop timer, so the socket is read when there is something -//! to read and not otherwise. The drain runs from this module's own completion -//! (never inside `perry-ext-net`'s dispatch, per `RawNetNotify`'s contract). -//! -//! The exchange itself is unchanged: the request goes out in one write with -//! `Connection: close` (`client_connect_override::serialize_http_request`), the -//! response is read to EOF and parsed with `plain_client::parse_http_response`, -//! and a `101` to an upgrade request detaches the socket and hands it to the -//! request's `'upgrade'` listener with the bytes after the head. The deadline -//! keeps its old default of 30 s; it is a loop timer now. - -use std::sync::atomic::Ordering; - -use perry_ffi::Handle; - -use super::{next_id, on_loop, run, with_state, Effect, State, Timer, RAW_COMPLETED}; -use crate::plain_client::parse_http_response; -use crate::{push_event, ClientInflightGuard, PendingHttpEvent}; - -/// The deadline the tokio task applied when the request set none. -const DEFAULT_DEADLINE_MS: u64 = 30_000; - -pub(super) struct RawExchange { - request_handle: Handle, - wants_upgrade: bool, - /// Response bytes read so far; parsed once the peer closes. - raw: Vec, - /// Armed drain timer, `0` when none is pending. - drain_timer: i64, - deadline_timer: i64, - _inflight: ClientInflightGuard, -} - -/// Start the exchange. Called on the JS thread with the socket already in -/// raw mode (`attach`ed by the caller so no byte can reach a JS `'data'` -/// listener first). Delivers exactly one terminal event for the request. -pub(crate) fn start_raw_exchange( - request_handle: Handle, - request: Vec, - wants_upgrade: bool, - timeout_ms: Option, - socket_id: i64, -) { - let Some(vtable) = perry_ffi::raw_net() else { - push_event(PendingHttpEvent::Error { - request_handle, - error_message: "agent.createConnection requires node:net (not linked)".to_string(), - }); - return; - }; - perry_ffi::register_raw_net_notify(raw_socket_ready); - let inflight = ClientInflightGuard::new(request_handle); - let carried = on_loop(move || { - (vtable.attach)(socket_id); - if (vtable.write)(socket_id, request.as_ptr(), request.len()) == 0 { - push_event(PendingHttpEvent::Error { - request_handle, - error_message: "failed to write request to agent socket".to_string(), - }); - drop(inflight); - return; - } - let effects = with_state(|st| { - let mut fx = Vec::new(); - let deadline = next_id(); - let deadline_timer = if deadline == perry_ffi::INVALID_HANDLE { - 0 - } else { - st.timers - .insert(deadline, Timer::RawDeadline { socket: socket_id }); - fx.push(Effect::ArmTimer( - deadline, - timeout_ms.unwrap_or(DEFAULT_DEADLINE_MS), - )); - deadline - }; - st.raw.insert( - socket_id, - RawExchange { - request_handle, - wants_upgrade, - raw: Vec::new(), - drain_timer: 0, - deadline_timer, - _inflight: inflight, - }, - ); - // Bytes may already be buffered (a server that answers before the - // request finished writing): drain once without waiting for a - // notification. - schedule_raw_drain(st, socket_id, &mut fx); - fx - }); - run(effects); - }); - if !carried { - push_event(PendingHttpEvent::TransportError { - request_handle, - message: format!("connect {}", super::NO_LOOP_CODE), - code: super::NO_LOOP_CODE.to_string(), - syscall: "connect".to_string(), - errno: perry_ffi::turnloop_net::errno_for_code(super::NO_LOOP_CODE) as i64, - }); - } -} - -/// `perry_ffi::raw_net_notify`'s target. Runs inside `perry-ext-net`'s -/// completion handling, so it only schedules; see [`drain_raw_socket`]. -extern "C" fn raw_socket_ready(socket_id: i64) { - let effects = with_state(|st| { - let mut fx = Vec::new(); - schedule_raw_drain(st, socket_id, &mut fx); - fx - }); - run(effects); -} - -fn schedule_raw_drain(st: &mut State, socket_id: i64, fx: &mut Vec) { - let Some(exchange) = st.raw.get_mut(&socket_id) else { - return; - }; - if exchange.drain_timer != 0 { - return; - } - let timer = next_id(); - if timer == perry_ffi::INVALID_HANDLE { - // No id for a timer: drain from the effect queue instead, which runs - // after the current dispatch has returned. - fx.push(Effect::RawDrain(socket_id)); - return; - } - exchange.drain_timer = timer; - st.timers - .insert(timer, Timer::RawDrain { socket: socket_id }); - fx.push(Effect::ArmTimer(timer, 0)); -} - -/// A drain timer fired: forget it, then read (outside the lock). -pub(super) fn on_raw_drain_timer(st: &mut State, socket_id: i64, timer: i64) -> Vec { - if let Some(exchange) = st.raw.get_mut(&socket_id) { - if exchange.drain_timer == timer { - exchange.drain_timer = 0; - } - } - vec![Effect::RawDrain(socket_id)] -} - -/// The deadline fired with the exchange still open. -pub(super) fn on_raw_deadline(st: &mut State, socket_id: i64) -> Vec { - let Some(exchange) = st.raw.remove(&socket_id) else { - return Vec::new(); - }; - let mut fx = Vec::new(); - if exchange.drain_timer != 0 && st.timers.remove(&exchange.drain_timer).is_some() { - fx.push(Effect::CancelTimer(exchange.drain_timer)); - } - fx.push(Effect::RawClose(socket_id)); - fx.push(Effect::Push(PendingHttpEvent::Timeout { - request_handle: exchange.request_handle, - })); - fx -} - -/// Settle an exchange: take it out of the table and cancel its timers. -fn settle_raw_exchange( - st: &mut State, - socket_id: i64, - fx: &mut Vec, -) -> Option { - let exchange = st.raw.remove(&socket_id)?; - for timer in [exchange.drain_timer, exchange.deadline_timer] { - if timer != 0 && st.timers.remove(&timer).is_some() { - fx.push(Effect::CancelTimer(timer)); - } - } - Some(exchange) -} - -/// Read everything `perry-ext-net` has buffered for the socket, and finish the -/// exchange if the response is complete. Runs from the effect queue with no -/// lock held: the vtable calls re-enter `perry-ext-net`, whose teardown can -/// call [`raw_socket_ready`] synchronously. -pub(super) fn drain_raw_socket(socket_id: i64) { - let Some(vtable) = perry_ffi::raw_net() else { - return; - }; - let mut chunk = [0u8; 16 * 1024]; - loop { - if !with_state(|st| st.raw.contains_key(&socket_id)) { - return; - } - let n = (vtable.poll_read)(socket_id, chunk.as_mut_ptr(), chunk.len()); - if n < 0 { - // Would block: the next notification brings more. - return; - } - if n == 0 { - // Clean EOF: the response is whatever arrived. - let (effects, exchange) = with_state(|st| { - let mut fx = Vec::new(); - let exchange = settle_raw_exchange(st, socket_id, &mut fx); - (fx, exchange) - }); - run(effects); - (vtable.close)(socket_id); - if let Some(exchange) = exchange { - deliver_raw_response(exchange); - } - return; - } - let bytes = &chunk[..n as usize]; - let upgraded = with_state(|st| { - let exchange = st.raw.get_mut(&socket_id)?; - exchange.raw.extend_from_slice(bytes); - if !exchange.wants_upgrade { - return None; - } - let end = exchange.raw.windows(4).position(|w| w == b"\r\n\r\n")? + 4; - let status = std::str::from_utf8(&exchange.raw[..end]) - .ok() - .and_then(|head| head.lines().next()) - .and_then(|line| line.split_whitespace().nth(1)) - .and_then(|code| code.parse::().ok()); - (status == Some(101)).then_some(end) - }); - if let Some(end) = upgraded { - let (effects, exchange) = with_state(|st| { - let mut fx = Vec::new(); - let exchange = settle_raw_exchange(st, socket_id, &mut fx); - (fx, exchange) - }); - run(effects); - let Some(exchange) = exchange else { - return; - }; - hand_off_raw_upgrade(exchange, socket_id, end); - return; - } - } -} - -fn hand_off_raw_upgrade(exchange: RawExchange, socket_id: i64, head_end: usize) { - let Some(vtable) = perry_ffi::raw_net() else { - return; - }; - RAW_COMPLETED.fetch_add(1, Ordering::Relaxed); - match parse_http_response(&exchange.raw[..head_end]) { - Ok(parsed) => { - (vtable.detach)(socket_id); - push_event(PendingHttpEvent::Upgrade { - request_handle: exchange.request_handle, - status: parsed.status, - status_message: parsed.status_message, - headers: parsed.headers, - socket_handle: socket_id, - head: exchange.raw[head_end..].to_vec(), - }); - } - Err(error_message) => { - (vtable.close)(socket_id); - push_event(PendingHttpEvent::Error { - request_handle: exchange.request_handle, - error_message, - }); - } - } -} - -fn deliver_raw_response(exchange: RawExchange) { - match parse_http_response(&exchange.raw) { - Ok(parsed) => { - RAW_COMPLETED.fetch_add(1, Ordering::Relaxed); - push_event(PendingHttpEvent::Response { - request_handle: exchange.request_handle, - status: parsed.status, - status_message: parsed.status_message, - headers: parsed.headers, - trailers: parsed.trailers, - body: parsed.body, - http_version: parsed.http_version, - }); - } - Err(error_message) => push_event(PendingHttpEvent::Error { - request_handle: exchange.request_handle, - error_message, - }), - } -} - -/// Close the socket for a deadline (from the effect queue, no lock held). -pub(super) fn close_raw_socket(socket_id: i64) { - if let Some(vtable) = perry_ffi::raw_net() { - (vtable.close)(socket_id); - } -} diff --git a/crates/perry-ext-http/src/client_turnloop/tls.rs b/crates/perry-ext-http/src/client_turnloop/tls.rs index 9250d98bb6..229fdeac05 100644 --- a/crates/perry-ext-http/src/client_turnloop/tls.rs +++ b/crates/perry-ext-http/src/client_turnloop/tls.rs @@ -27,7 +27,6 @@ use std::collections::HashMap; use std::hash::{Hash, Hasher}; use std::sync::{Arc, Mutex, OnceLock}; -use perry_tls_session::TlsSession; use rustls::pki_types::ServerName; use crate::tls_client::TlsOptions; @@ -37,6 +36,7 @@ use crate::tls_client::TlsOptions; pub(crate) struct TlsPlan { pub(super) config: Arc, pub(super) server_name: ServerName<'static>, + pub(super) metadata: perry_ext_net::native_transport::ClientTlsMetadata, } fn configs() -> &'static Mutex>> { @@ -103,35 +103,27 @@ pub(crate) fn plan(options: &TlsOptions, url_host: &str) -> Result Result { - TlsSession::client(plan.config.clone(), plan.server_name.clone()).map_err(|e| e.to_string()) -} - -/// Node's message for a handshake failure's cause code. Node reports these as -/// an `Error` carrying `.code` and OpenSSL's text; rustls has its own text, so -/// the codes Node users test for get Node's words and anything else keeps -/// rustls's. -pub(super) fn node_failure_message(code: &str, rustls_message: &str) -> String { - match code { - "UNABLE_TO_VERIFY_LEAF_SIGNATURE" => "unable to verify the first certificate".to_string(), - "CERT_HAS_EXPIRED" => "certificate has expired".to_string(), - "CERT_NOT_YET_VALID" => "certificate is not yet valid".to_string(), - "CERT_REVOKED" => "certificate revoked".to_string(), - "ERR_TLS_CERT_ALTNAME_INVALID" => { - format!("Hostname/IP does not match certificate's altnames: {rustls_message}") - } - _ => rustls_message.to_string(), - } -} - #[cfg(test)] mod tests { use super::*; @@ -181,16 +173,4 @@ mod tests { assert!(!plan.config.enable_sni); assert_eq!(plan.server_name.to_str(), "localhost"); } - - #[test] - fn node_codes_get_node_text() { - assert_eq!( - node_failure_message("UNABLE_TO_VERIFY_LEAF_SIGNATURE", "x"), - "unable to verify the first certificate" - ); - assert_eq!( - node_failure_message("ERR_SSL_PROTOCOL_ERROR", "rustls text"), - "rustls text" - ); - } } diff --git a/crates/perry-ext-http/src/lib.rs b/crates/perry-ext-http/src/lib.rs index ae5f4679fa..e1bac3ab57 100644 --- a/crates/perry-ext-http/src/lib.rs +++ b/crates/perry-ext-http/src/lib.rs @@ -52,7 +52,6 @@ mod tls_client; // parser those socket paths share. mod client_connect_override; mod client_upgrade; -mod plain_client; // `Expect: 100-continue` (#5080): arms the head-first exchange and hands the // withheld body to it at `end()`. @@ -65,7 +64,7 @@ mod continue_client; // `pub` rather than private for one reason: a transport that silently did // nothing would be indistinguishable from a working one at the JS surface — // the "gate runs but its subject never did" shape. The liveness counters and -// `try_dispatch*` are reachable so `tests/turnloop_client_exchange.rs` can +// Transport counters are reachable so `tests/turnloop_client_exchange.rs` can // assert the subject was live. No C-ABI symbol is added. pub mod client_turnloop; @@ -106,10 +105,10 @@ use root_scanner::scan_http_roots; use bytes::Bytes; use perry_ffi::{ - alloc_string, gc_register_mutable_root_scanner_named, get_handle_mut, iter_handles_of_mut, - json_stringify, notify_main_thread, register_agent_event_pump, register_handle, - with_handle_mut, ArrayHeader, GcRootVisitor, Handle, JsClosure, JsString, JsValue, - ObjectHeader, RawClosureHeader, StringHeader, + alloc_string, gc_register_mutable_root_scanner_named, get_handle, get_handle_mut, + iter_handles_of_mut, json_stringify, notify_main_thread, register_agent_event_pump, + register_handle, with_handle_mut, ArrayHeader, GcRootVisitor, Handle, JsClosure, + JsString, JsValue, ObjectHeader, RawClosureHeader, StringHeader, }; use std::collections::HashMap; use std::sync::{Mutex, Once}; @@ -133,14 +132,6 @@ pub(crate) enum PendingHttpEvent { Socket { request_handle: Handle }, /// An `options.signal` listener fired for a ClientRequest. SignalAbort { request_handle: Handle }, - /// Generation-guarded retirement of a public Agent socket facade that - /// remained idle long enough for an unsolicited-data/remote-close poll. - AgentIdleExpire { - agent_handle: Handle, - key: String, - socket: Handle, - generation: u64, - }, Response { request_handle: Handle, status: u16, @@ -236,36 +227,6 @@ static CLIENT_REQUESTS_INFLIGHT: std::sync::LazyLock< std::sync::Mutex>, > = std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashSet::new())); -/// RAII in-flight marker, held by an exchange from dispatch until it settles. -/// Its owning agent is captured on the transport loop, independently of the -/// request registry: removing a request cannot shorten the exchange lifetime. -pub(crate) struct ClientInflightGuard { - owner_agent: u64, - request_handle: Handle, -} -impl ClientInflightGuard { - pub(crate) fn new(request_handle: Handle) -> Self { - let owner_agent = perry_ffi::agent_post::current_agent(); - CLIENT_REQUESTS_INFLIGHT - .lock() - .unwrap() - .insert((owner_agent, request_handle)); - ClientInflightGuard { - owner_agent, - request_handle, - } - } -} -impl Drop for ClientInflightGuard { - fn drop(&mut self) { - CLIENT_REQUESTS_INFLIGHT - .lock() - .unwrap() - .remove(&(self.owner_agent, self.request_handle)); - notify_main_thread(); - } -} - /// Registered with the runtime's extension keepalive gate (#5779 follow-up): /// returns nonzero while this agent has an HTTP client fetch outstanding. #[no_mangle] @@ -284,7 +245,10 @@ pub extern "C" fn js_ext_http_client_inflight() -> i32 { request.socket_handle }) .unwrap_or(0); - socket == 0 || perry_ext_net::js_ext_net_socket_has_ref(socket) != 0 + socket == 0 + || perry_ext_net::native_transport::has_ref(f64::from_bits( + JsValue::from_object_ptr(socket as *mut u8).bits(), + )) }) .count() .min(i32::MAX as usize) as i32 @@ -336,8 +300,9 @@ extern "C" fn client_has_active() -> i32 { } pub(crate) fn ensure_gc_scanner_registered() { + gc_register_mutable_root_scanner_named("perry-ext-http-client", scan_http_roots); HTTP_GC_REGISTERED.call_once(|| { - gc_register_mutable_root_scanner_named("perry-ext-http", scan_http_roots); + perry_ffi::agent_post::register_retire_hook(root_scanner::retire_client_roots); // Register both halves directly with perry-runtime. The stdlib bridge // intentionally does not name extension symbols: a client contributes // only after this crate is linked and one of its entry points runs. @@ -412,6 +377,7 @@ fn map_to_js_object(map: &HashMap) -> f64 { pub struct ClientRequestHandle { /// The JS heap that owns this request and every callback it registers. owner_agent: u64, + surface: client_request_surface::ClientRequestSurfaceState, async_id: u64, method: String, url: String, @@ -462,9 +428,11 @@ pub struct ClientRequestHandle { agent_queued: bool, /// Whether this request consumed an idle Agent slot. reused_socket: bool, - /// Stable public net.Socket facade assigned by the Agent (or by the - /// implicit global pool for requests without an explicit Agent). + /// Actual ordinary Socket assigned by request/Agent ownership. socket_handle: Handle, + /// The existing driver capability for this exchange, copied before JS. + /// Delayed app cleanup must not operate on a later use of the same cell. + socket_snapshot: Option, abort_signal_bits: u64, abort_listener_bits: u64, /// Client-side TLS options (#4906): `rejectUnauthorized` / `ca` / @@ -514,7 +482,7 @@ unsafe impl Send for ClientRequestHandle {} unsafe impl Sync for ClientRequestHandle {} pub struct IncomingMessageHandle { - owner_agent: u64, + pub owner_agent: u64, pub status_code: u16, pub status_message: String, /// Raw `(name, value)` header pairs in arrival order, multiplicity @@ -669,17 +637,22 @@ fn make_request_handle( agent_key: String, request_create_connection: i64, ) -> Handle { + let scope = perry_ffi::TransientRootScope::enter(); + let callback = scope.root_addr(callback); + let create_connection = scope.root_addr(request_create_connection); + let async_id = unsafe { js_async_hooks_provider_init(b"HTTPCLIENTREQUEST".as_ptr(), b"HTTPCLIENTREQUEST".len()) }; let handle = register_handle(ClientRequestHandle { + surface: client_request_surface::ClientRequestSurfaceState::default(), owner_agent: perry_ffi::agent_post::current_agent(), async_id, method, url, headers, body: Vec::new(), - response_callback: callback, + response_callback: callback.get(), response_raw_wrapper: 0, listeners: HashMap::new(), timeout_ms, @@ -692,11 +665,12 @@ fn make_request_handle( close_emitted: false, agent_handle, agent_key, - request_create_connection, + request_create_connection: create_connection.get(), agent_active: false, agent_queued: false, reused_socket: false, socket_handle: 0, + socket_snapshot: None, abort_signal_bits: 0, abort_listener_bits: 0, tls: tls_client::TlsOptions::default(), @@ -706,9 +680,13 @@ fn make_request_handle( continue_body_pending: false, agent_false: false, }); - if callback != 0 { - let wrapper = - client_request_surface::create_client_once_wrapper(handle, "response", callback, true); + if callback.get() != 0 { + let wrapper = client_request_surface::create_client_once_wrapper( + handle, + "response", + callback.get(), + true, + ); with_handle_mut::(handle, |request| { request.response_raw_wrapper = wrapper; }); @@ -793,7 +771,6 @@ fn pending_request_handle(event: &PendingHttpEvent) -> Handle { | PendingHttpEvent::Flushed { request_handle } | PendingHttpEvent::Continue { request_handle } | PendingHttpEvent::DeferredArmContinue { request_handle } => *request_handle, - PendingHttpEvent::AgentIdleExpire { .. } => 0, } } @@ -1030,7 +1007,11 @@ unsafe extern "C" fn http_create_socket_cb( } else { 0 }; - if socket_id <= 0 { + if perry_ext_net::native_transport::socket_link(f64::from_bits( + JsValue::from_object_ptr(socket_id as *mut u8).bits(), + )) + .is_err() + { push_event(PendingHttpEvent::Error { request_handle, error_message: "agent.createSocket callback did not provide a socket".to_string(), @@ -1052,9 +1033,6 @@ unsafe extern "C" fn http_create_socket_cb( if let Some((method, url, headers, body, timeout_ms)) = snap { // Attach raw mode on the main thread before the async task runs, to // close the same data race the `createConnection` path guards against. - if let Some(vt) = perry_ffi::raw_net() { - (vt.attach)(socket_id); - } client_connect_override::dispatch_request_over_socket( request_handle, method, @@ -1073,12 +1051,15 @@ unsafe extern "C" fn http_create_socket_cb( // ------------------------------------------------------------------ unsafe fn request_common(arg_f64: f64, callback: i64, default_protocol: &str) -> Handle { + let scope = perry_ffi::TransientRootScope::enter(); + let arg = scope.root_nanbox(arg_f64); + let callback = scope.root_addr(callback); ensure_gc_scanner_registered(); // Issue #769 — accept either a URL string or an options object. Mirrors // the dispatch in `get_common` so `http.request("http://…", cb)` works // the same as `http.request({ host, port, path }, cb)`. - let (method, url, headers, timeout, agent_handle) = if is_string_value(arg_f64) { - let raw = extract_string_value(arg_f64).unwrap_or_default(); + let (method, url, headers, timeout, agent_handle) = if is_string_value(arg.get()) { + let raw = extract_string_value(arg.get()).unwrap_or_default(); validate_client_url_string(&raw); // #4907 let url = if raw.starts_with("http://") || raw.starts_with("https://") { raw @@ -1089,7 +1070,7 @@ unsafe fn request_common(arg_f64: f64, callback: i64, default_protocol: &str) -> }; ("GET".to_string(), url, HashMap::new(), None, 0) } else { - let opts = parse_options_object(arg_f64).unwrap_or(serde_json::Value::Null); + let opts = parse_options_object(arg.get()).unwrap_or(serde_json::Value::Null); validate_client_options(&opts, default_protocol); // #4907 let method = method_from_options(&opts); let url = url_from_options(&opts, default_protocol); @@ -1098,7 +1079,7 @@ unsafe fn request_common(arg_f64: f64, callback: i64, default_protocol: &str) -> // #2154: `options.agent` doesn't survive the JSON round-trip // (pointer-tagged values get dropped) — read the field straight // off the NaN-boxed object instead. - let agent_handle = agent::agent_handle_from_options(arg_f64).unwrap_or(0); + let agent_handle = agent::agent_handle_from_options(arg.get()).unwrap_or(0); (method, url, headers, timeout, agent_handle) }; let agent_handle = if default_protocol == "https" { @@ -1106,21 +1087,21 @@ unsafe fn request_common(arg_f64: f64, callback: i64, default_protocol: &str) -> } else { agent_handle }; - let agent_key = agent::request_key_from_options(agent_handle, arg_f64, &url); - let request_create_connection = agent::request_create_connection_from_options(arg_f64); // #10469 + let agent_key = agent::request_key_from_options(agent_handle, arg.get(), &url); + let request_create_connection = agent::request_create_connection_from_options(arg.get()); // #10469 let handle = make_request_handle( method, url, headers, timeout, - callback, + callback.get(), agent_handle, agent_key, request_create_connection, ); - client_abort::attach_request_signal(handle, arg_f64); - note_agent_false(handle, arg_f64); // #11452 - attach_tls_options(handle, arg_f64); // #4906 + client_abort::attach_request_signal(handle, arg.get()); + note_agent_false(handle, arg.get()); // #11452 + attach_tls_options(handle, arg.get()); // #4906 continue_client::defer_arm(handle); // #5080 (next-tick head flush) handle } @@ -1147,9 +1128,12 @@ pub unsafe extern "C" fn js_https_request(opts_f64: f64, callback_i64: i64) -> H } unsafe fn get_common(arg_f64: f64, callback: i64, default_protocol: &str) -> Handle { + let scope = perry_ffi::TransientRootScope::enter(); + let arg = scope.root_nanbox(arg_f64); + let callback = scope.root_addr(callback); ensure_gc_scanner_registered(); - let (url, headers, timeout, agent_handle) = if is_string_value(arg_f64) { - let raw = extract_string_value(arg_f64).unwrap_or_default(); + let (url, headers, timeout, agent_handle) = if is_string_value(arg.get()) { + let raw = extract_string_value(arg.get()).unwrap_or_default(); validate_client_url_string(&raw); // #4907 let url = if raw.starts_with("http://") || raw.starts_with("https://") { raw @@ -1160,12 +1144,12 @@ unsafe fn get_common(arg_f64: f64, callback: i64, default_protocol: &str) -> Han }; (url, HashMap::new(), None, 0) } else { - let opts = parse_options_object(arg_f64).unwrap_or(serde_json::Value::Null); + let opts = parse_options_object(arg.get()).unwrap_or(serde_json::Value::Null); validate_client_options(&opts, default_protocol); // #4907 let url = url_from_options(&opts, default_protocol); let headers = headers_from_options(&opts); let timeout = timeout_from_options(&opts); - let agent_handle = agent::agent_handle_from_options(arg_f64).unwrap_or(0); + let agent_handle = agent::agent_handle_from_options(arg.get()).unwrap_or(0); (url, headers, timeout, agent_handle) }; @@ -1174,22 +1158,22 @@ unsafe fn get_common(arg_f64: f64, callback: i64, default_protocol: &str) -> Han } else { agent_handle }; - let agent_key = agent::request_key_from_options(agent_handle, arg_f64, &url); - let request_create_connection = agent::request_create_connection_from_options(arg_f64); // #10469 + let agent_key = agent::request_key_from_options(agent_handle, arg.get(), &url); + let request_create_connection = agent::request_create_connection_from_options(arg.get()); // #10469 let handle = make_request_handle( "GET".to_string(), url, headers, timeout, - callback, + callback.get(), agent_handle, agent_key, request_create_connection, ); - client_abort::attach_request_signal(handle, arg_f64); - note_agent_false(handle, arg_f64); // #11452 - attach_tls_options(handle, arg_f64); // #4906 - // GET auto-`end()`s, kicking off the request. + client_abort::attach_request_signal(handle, arg.get()); + note_agent_false(handle, arg.get()); // #11452 + attach_tls_options(handle, arg.get()); // #4906 + // GET auto-`end()`s, kicking off the request. js_http_client_request_end(handle, f64::from_bits(TAG_UNDEFINED)); handle } @@ -1218,41 +1202,46 @@ pub unsafe extern "C" fn js_https_get(arg_f64: f64, callback_i64: i64) -> Handle // ------------------------------------------------------------------ unsafe fn request_overload(args_array: i64, default_protocol: &str, force_get: bool) -> Handle { + let scope = perry_ffi::TransientRootScope::enter(); + let args = scope.root_addr(args_array); ensure_gc_scanner_registered(); - let parsed = parse_client_args(args_array); + let parsed = parse_client_args(args.get()); + let url_arg = scope.root_nanbox(parsed.url); + let options = scope.root_nanbox(parsed.opts); + let callback = scope.root_addr(parsed.callback); // #4907 — validate before building the request handle. A string URL // argument is validated as a WHATWG URL; the options bag is validated for // method / path / headers / protocol / option types. - if is_string_value(parsed.url) { - let raw = extract_string_value(parsed.url).unwrap_or_default(); + if is_string_value(url_arg.get()) { + let raw = extract_string_value(url_arg.get()).unwrap_or_default(); validate_client_url_string(&raw); } - if let Some(opts) = parse_options_object(parsed.opts) { + if let Some(opts) = parse_options_object(options.get()) { validate_client_options(&opts, default_protocol); } - let method = method_for_overload(parsed.opts); + let method = method_for_overload(options.get()); let (url, headers, timeout, agent_handle) = - merge_url_and_options(parsed.url, parsed.opts, default_protocol); + merge_url_and_options(url_arg.get(), options.get(), default_protocol); let agent_handle = if default_protocol == "https" { agent::resolve_https_agent_handle(agent_handle) } else { agent_handle }; - let agent_key = agent::request_key_from_options(agent_handle, parsed.opts, &url); - let request_create_connection = agent::request_create_connection_from_options(parsed.opts); // #10469 + let agent_key = agent::request_key_from_options(agent_handle, options.get(), &url); + let request_create_connection = agent::request_create_connection_from_options(options.get()); // #10469 let handle = make_request_handle( method, url, headers, timeout, - parsed.callback, + callback.get(), agent_handle, agent_key, request_create_connection, ); - client_abort::attach_request_signal(handle, parsed.opts); - attach_tls_options(handle, parsed.opts); // #4906 — TLS options ride on the options bag - note_agent_false(handle, parsed.opts); // #11452 + client_abort::attach_request_signal(handle, options.get()); + attach_tls_options(handle, options.get()); // #4906 — TLS options ride on the options bag + note_agent_false(handle, options.get()); // #11452 if force_get { // `get()` auto-`end()`s, kicking off the request. js_http_client_request_end(handle, f64::from_bits(TAG_UNDEFINED)); @@ -1556,9 +1545,6 @@ unsafe fn dispatch_request_snapshot(handle: Handle, snapshot: RequestSnapshot) { { // Attach raw mode now (main thread) so no inbound byte can be // dispatched as a JS 'data' event before the task takes over. - if let Some(vt) = perry_ffi::raw_net() { - (vt.attach)(socket_id); - } client_connect_override::dispatch_request_over_socket( handle, method, url, headers, body, timeout_ms, socket_id, ); @@ -1606,12 +1592,32 @@ unsafe fn dispatch_request_snapshot(handle: Handle, snapshot: RequestSnapshot) { }); } +/// Return a rooted receiver only while the request's original driver capability +/// still belongs to it. Completed callbacks may have reopened the same object. +pub(crate) fn current_request_socket( + request: Handle, +) -> Option { + let (owner, incarnation) = get_handle::(request) + .map(|req| (req.socket_handle, req.socket_snapshot))?; + if owner == 0 { + return None; + } + let socket = perry_ext_net::native_transport::RootedSocket::new(f64::from_bits( + JsValue::from_object_ptr(owner as *mut u8).bits(), + )); + let current = incarnation.as_ref().map_or_else( + || perry_ext_net::native_transport::snapshot(socket.value()).is_none(), + |snapshot| perry_ext_net::native_transport::matches(socket.value(), snapshot), + ); + current.then_some(socket) +} + /// Move a completed request out of its Agent's active pool and resume the /// oldest per-origin waiter, skipping requests that were aborted while queued. /// Successful responses may leave one observable idle slot when keepAlive is /// enabled; error/abort/destroy paths always release without retaining it. pub(crate) unsafe fn finish_agent_request(request_handle: Handle, keep_alive: bool) { - let Some((agent_handle, key, was_active, socket)) = + let Some((agent_handle, key, was_active, socket, incarnation)) = with_handle_mut::(request_handle, |request| { let active = request.agent_active; request.agent_active = false; @@ -1620,6 +1626,7 @@ pub(crate) unsafe fn finish_agent_request(request_handle: Handle, keep_alive: bo request.agent_key.clone(), active, request.socket_handle, + request.socket_snapshot, ) }) else { @@ -1629,14 +1636,52 @@ pub(crate) unsafe fn finish_agent_request(request_handle: Handle, keep_alive: bo return; } - let mut next = agent::release_request(agent_handle, &key, socket, keep_alive); + let scope = perry_ffi::TransientRootScope::enter(); + let socket = scope.root_addr(socket); + let owner = f64::from_bits(JsValue::from_object_ptr(socket.get() as *mut u8).bits()); + let current = incarnation.as_ref().map_or_else( + || perry_ext_net::native_transport::snapshot(owner).is_none(), + |snapshot| perry_ext_net::native_transport::matches(owner, snapshot), + ); + #[cfg(test)] + let current = + current || std::env::var("PERRY_NET_A_CLIENT_SABOTAGE").as_deref() == Ok("cleanup_handle"); + let transport = if current { + socket.get() + } else { + if let Some(agent) = get_handle_mut::(agent_handle) { + if let Some(owners) = agent.active_socket_handles.get_mut(&key) { + owners.retain(|candidate| *candidate != socket.get()); + } + } + 0 // Only the old logical admission is owed; preserve the new driver. + }; + resume_agent_waiters( + agent_handle, + &key, + agent::release_request(agent_handle, &key, transport, keep_alive && current), + ); +} + +/// Both normal completion and upgrade release the same logical admission. +/// An unended waiter owns its slot until end(); an aborted waiter is skipped. +unsafe fn resume_agent_waiters( + agent_handle: Handle, + key: &str, + mut next: Option<(Handle, bool, Handle)>, +) { while let Some((next_handle, reused, next_socket)) = next { - let next_snapshot = with_handle_mut::(next_handle, |request| { + let admitted = with_handle_mut::(next_handle, |request| { + if request.completed { + request.agent_queued = false; + return None; + } request.agent_active = true; request.agent_queued = false; request.reused_socket = reused; request.socket_handle = next_socket; - (request.ended && !request.completed).then(|| { + request.socket_snapshot = None; + Some(request.ended.then(|| { ( request.method.clone(), request.url.clone(), @@ -1646,21 +1691,47 @@ pub(crate) unsafe fn finish_agent_request(request_handle: Handle, keep_alive: bo request.agent_handle, request.tls.clone(), ) - }) + })) }) .flatten(); - if let Some(snapshot) = next_snapshot { + if let Some(snapshot) = admitted { push_event(PendingHttpEvent::Socket { request_handle: next_handle, }); - dispatch_request_snapshot(next_handle, snapshot); - break; + if let Some(snapshot) = snapshot { + dispatch_request_snapshot(next_handle, snapshot); + } + return; } - with_handle_mut::(next_handle, |request| { + next = agent::release_request(agent_handle, key, next_socket, false); + } +} + +/// An upgraded Socket leaves the HTTP Agent, while the request and upgrade +/// callback retain the actual ordinary Socket. It must never enter the idle +/// HTTP pool or be destroyed when the ClientRequest completes. +unsafe fn finish_upgraded_agent_request(request_handle: Handle) { + let Some((agent, key, socket)) = get_handle_mut::(request_handle) + .filter(|request| request.agent_active) + .map(|request| { request.agent_active = false; - }); - next = agent::release_request(agent_handle, &key, next_socket, false); + ( + request.agent_handle, + request.agent_key.clone(), + request.socket_handle, + ) + }) + else { + return; + }; + if let Some(agent) = get_handle_mut::(agent) { + if let Some(sockets) = agent.active_socket_handles.get_mut(&key) { + sockets.retain(|candidate| *candidate != socket); + } } + // The normal release path admits waiters with a fresh Socket. A null + // transport argument releases only the logical slot, preserving upgrade. + resume_agent_waiters(agent, &key, agent::release_request(agent, &key, 0, false)); } /// Parse a request URL into the `(host, port, path)` an diff --git a/crates/perry-ext-http/src/pending_dispatch.rs b/crates/perry-ext-http/src/pending_dispatch.rs index bc3388fa57..8667951835 100644 --- a/crates/perry-ext-http/src/pending_dispatch.rs +++ b/crates/perry-ext-http/src/pending_dispatch.rs @@ -1,4 +1,5 @@ use super::*; +use perry_ffi::TransientRootScope; /// Drain the pending HTTP-event queue and fire user callbacks. Events remain /// in the shared queue until selected so re-entrant event-loop pumps can make @@ -12,7 +13,16 @@ pub unsafe extern "C" fn js_http_process_pending() -> i32 { Ok(mut q) => take_event(&mut q, owner), Err(_) => return count, }; - let Some(ev) = ev else { break }; + let Some(mut ev) = ev else { break }; + // Queue ownership ends here. Root a transported Socket before async + // hooks or listeners can collect and rewrite its ordinary object. + let scope = TransientRootScope::enter(); + let socket = match &ev { + PendingHttpEvent::Upgrade { socket_handle, .. } => { + Some(scope.root_addr(*socket_handle)) + } + _ => None, + }; count += 1; let request_handle = pending_request_handle(&ev); let terminal = terminal_http_event(&ev); @@ -23,6 +33,10 @@ pub unsafe extern "C" fn js_http_process_pending() -> i32 { if async_id != 0 { js_async_hooks_provider_enter(async_id); } + if let (PendingHttpEvent::Upgrade { socket_handle, .. }, Some(socket)) = (&mut ev, &socket) + { + *socket_handle = socket.get(); + } match ev { PendingHttpEvent::Socket { request_handle } => { client_events::fire_request_socket_event(request_handle); @@ -30,12 +44,6 @@ pub unsafe extern "C" fn js_http_process_pending() -> i32 { PendingHttpEvent::SignalAbort { request_handle } => { client_abort::handle_request_signal_abort(request_handle); } - PendingHttpEvent::AgentIdleExpire { - agent_handle, - key, - socket, - generation, - } => agent::expire_free_socket(agent_handle, &key, socket, generation), PendingHttpEvent::Response { request_handle, status, diff --git a/crates/perry-ext-http/src/plain_client.rs b/crates/perry-ext-http/src/plain_client.rs deleted file mode 100644 index 6d9a65ba84..0000000000 --- a/crates/perry-ext-http/src/plain_client.rs +++ /dev/null @@ -1,116 +0,0 @@ -//! The raw HTTP/1.1 response parser shared by the `agent.createConnection` -//! socket paths (`client_connect_override.rs`, #2154): status line, headers, -//! decoded body and trailers from the bytes read off a JS-produced socket. -//! -//! It used to also carry a `TE: trailers` bypass over a tokio `TcpStream`, -//! because reqwest's body API dropped trailer blocks. That exchange now runs in -//! `client_turnloop` (`Mode::Trailers`), whose codec hands trailers back as -//! `Event::Trailers`. - -/// A parsed HTTP/1.1 response message (status line + headers + decoded body -/// + trailers). Produced by [`parse_http_response`]. -pub(crate) struct ParsedHttpResponse { - pub(crate) status: u16, - pub(crate) status_message: String, - pub(crate) headers: Vec<(String, String)>, - pub(crate) trailers: Vec<(String, String)>, - pub(crate) body: Vec, - /// `(major, minor)` parsed from the status line (`HTTP/1.1 200 OK`). - /// Falls back to `(1, 1)` on anything that doesn't parse as - /// `HTTP/.` (#10467). - pub(crate) http_version: (u8, u8), -} - -/// Parse a raw HTTP/1.1 response (the bytes read off a socket) into status / -/// headers / decoded body / trailers. Decodes `Transfer-Encoding: chunked` -/// (including a trailer block) and honors `Content-Length`; with neither it -/// treats the remainder as the body (read-until-EOF transports). Used by the -/// #2154 `agent.createConnection` socket path -/// (`client_connect_override::dispatch_request_over_socket`). -pub(crate) fn parse_http_response(raw: &[u8]) -> Result { - let Some(header_end) = raw.windows(4).position(|w| w == b"\r\n\r\n") else { - return Err("invalid HTTP response".to_string()); - }; - let head = String::from_utf8_lossy(&raw[..header_end]); - let mut lines = head.split("\r\n"); - let status_line = lines.next().unwrap_or_default(); - let mut status_parts = status_line.splitn(3, ' '); - let http_version = status_parts - .next() - .and_then(|v| v.strip_prefix("HTTP/")) - .and_then(|v| v.split_once('.')) - .and_then(|(maj, min)| Some((maj.parse::().ok()?, min.parse::().ok()?))) - .unwrap_or((1, 1)); - let status = status_parts - .next() - .and_then(|s| s.parse::().ok()) - .unwrap_or(0); - let status_message = status_parts.next().unwrap_or("").to_string(); - let mut hdrs = Vec::new(); - let mut is_chunked = false; - let mut content_length: Option = None; - for line in lines { - if let Some((name, value)) = line.split_once(':') { - let name = name.trim().to_ascii_lowercase(); - let value = value.trim().to_string(); - if name == "transfer-encoding" && value.to_ascii_lowercase().contains("chunked") { - is_chunked = true; - } - if name == "content-length" { - content_length = value.parse::().ok(); - } - hdrs.push((name, value)); - } - } - let payload = &raw[header_end + 4..]; - let mut decoded = Vec::new(); - let mut trailers = Vec::new(); - if is_chunked { - let mut pos = 0; - while pos < payload.len() { - let Some(line_end_rel) = payload[pos..].windows(2).position(|w| w == b"\r\n") else { - break; - }; - let line_end = pos + line_end_rel; - let size_line = String::from_utf8_lossy(&payload[pos..line_end]); - let size_hex = size_line.split(';').next().unwrap_or("").trim(); - let size = usize::from_str_radix(size_hex, 16).unwrap_or(0); - pos = line_end + 2; - if size == 0 { - if pos <= payload.len() { - let rest = &payload[pos..]; - let trailer_end = rest - .windows(4) - .position(|w| w == b"\r\n\r\n") - .unwrap_or(rest.len()); - let trailer_text = String::from_utf8_lossy(&rest[..trailer_end]); - for line in trailer_text.split("\r\n") { - if let Some((name, value)) = line.split_once(':') { - trailers - .push((name.trim().to_ascii_lowercase(), value.trim().to_string())); - } - } - } - break; - } - if pos + size > payload.len() { - break; - } - decoded.extend_from_slice(&payload[pos..pos + size]); - pos += size + 2; - } - } else if let Some(len) = content_length { - decoded.extend_from_slice(&payload[..payload.len().min(len)]); - } else { - decoded.extend_from_slice(payload); - } - - Ok(ParsedHttpResponse { - status, - status_message, - headers: hdrs, - trailers, - body: decoded, - http_version, - }) -} diff --git a/crates/perry-ext-http/src/response_headers.rs b/crates/perry-ext-http/src/response_headers.rs index c1a23d004f..6136e749c0 100644 --- a/crates/perry-ext-http/src/response_headers.rs +++ b/crates/perry-ext-http/src/response_headers.rs @@ -51,7 +51,7 @@ fn is_single_value_header(name: &str) -> bool { /// pooled reqwest path normalizes names to lower case before Perry ever /// sees them (`http::HeaderName` only stores lower case), so this does not /// reproduce Node's original wire casing on that path — only the raw-socket -/// paths (`plain_client`/`agent.createConnection`) could preserve it, and +/// path (`agent.createConnection`) could preserve it, and /// today they lower-case on parse too. Tracked as a known gap, not silently /// papered over. pub(crate) fn build_raw_headers_array(raw: &[(String, String)]) -> f64 { diff --git a/crates/perry-ext-http/src/root_scanner.rs b/crates/perry-ext-http/src/root_scanner.rs index 464a0464e9..c3b0d8ccef 100644 --- a/crates/perry-ext-http/src/root_scanner.rs +++ b/crates/perry-ext-http/src/root_scanner.rs @@ -5,10 +5,20 @@ use perry_ffi::GcRootVisitor; /// Raw closure pointers are exposed as mutable slots so copying GC can /// rewrite them after relocation. pub(super) fn scan_http_roots(visitor: &mut GcRootVisitor<'_>) { + let agent = perry_ffi::agent_post::current_agent(); + if let Ok(mut queue) = HTTP_PENDING_EVENTS.lock() { + for (_, event) in queue.iter_mut().filter(|(owner, _)| *owner == agent) { + if let PendingHttpEvent::Upgrade { socket_handle, .. } = event { + visitor.visit_i64_slot(socket_handle); + } + } + } iter_handles_of_mut::(|req| { - if req.owner_agent != perry_ffi::agent_post::current_agent() { + if req.owner_agent != agent { return; } + visitor.visit_i64_slot(&mut req.socket_handle); + visitor.visit_i64_slot(&mut req.request_create_connection); visitor.visit_i64_slot(&mut req.response_callback); visitor.visit_i64_slot(&mut req.response_raw_wrapper); visitor.visit_i64_slot(&mut req.end_callback); @@ -38,9 +48,10 @@ pub(super) fn scan_http_roots(visitor: &mut GcRootVisitor<'_>) { }); iter_handles_of_mut::(|msg| { - if msg.owner_agent != perry_ffi::agent_post::current_agent() { + if msg.owner_agent != agent { return; } + visitor.visit_i64_slot(&mut msg.socket_handle); for cbs in msg.listeners.values_mut() { for cb in cbs { visitor.visit_i64_slot(cb); @@ -53,5 +64,41 @@ pub(super) fn scan_http_roots(visitor: &mut GcRootVisitor<'_>) { }); agent::scan_agent_roots(visitor); - client_request_surface::scan_roots(visitor); +} + +/// Agent teardown drops only that heap's app ownership edges. The runtime +/// retires driver capabilities separately; this hook never runs JS/driver. +pub(super) extern "C" fn retire_client_roots(agent: u64) { + iter_handles_of_mut::(|req| { + if req.owner_agent != agent { + return; + } + req.socket_handle = 0; + req.socket_snapshot = None; + req.response_callback = 0; + req.response_raw_wrapper = 0; + req.end_callback = 0; + req.request_create_connection = 0; + req.abort_signal_bits = 0; + req.abort_listener_bits = 0; + req.pending_write_callbacks.clear(); + req.listeners.clear(); + req.tls.check_server_identity_callback = 0; + req.completed = true; + }); + iter_handles_of_mut::(|msg| { + if msg.owner_agent != agent { + return; + } + msg.socket_handle = 0; + msg.listeners.clear(); + msg.pipes.clear(); + }); + if let Ok(mut queue) = HTTP_PENDING_EVENTS.lock() { + queue.retain(|(owner, _)| *owner != agent); + } + if let Ok(mut inflight) = CLIENT_REQUESTS_INFLIGHT.lock() { + inflight.retain(|(owner, _)| *owner != agent); + } + agent::retire_agent_roots(agent); } diff --git a/crates/perry-ext-http/src/server/handle_dispatch.rs b/crates/perry-ext-http/src/server/handle_dispatch.rs index 69aa0b6223..bcb5c686d5 100644 --- a/crates/perry-ext-http/src/server/handle_dispatch.rs +++ b/crates/perry-ext-http/src/server/handle_dispatch.rs @@ -1019,7 +1019,7 @@ pub unsafe extern "C" fn js_ext_http_server_response_dispatch_method( // The socket closes immediately and an in-flight request gets a // reset, which is what Node's `socket.destroy()` does. if let Some((conn, _)) = turnloop { - crate::server::turnloop_serve::destroy_connection(conn); + crate::server::turnloop_route::destroy(&conn.root()); } handle_to_pointer_f64(response_root.get()) } @@ -1406,8 +1406,13 @@ fn response_socket_value(handle: i64) -> f64 { } } if let Some(sr) = super::response::response_state(handle) { - if sr.socket_handle != 0 { - return handle_to_pointer_f64(sr.socket_handle); + if let Some((crate::server::turnloop_route::ResponseConnection::Socket { owner, .. }, _)) = + sr.turnloop + { + return owner; + } + if !JsValue::from_bits(sr.socket_value.to_bits()).is_undefined() { + return sr.socket_value; } } let req_handle = unsafe { js_node_http_res_req_handle(handle) }; @@ -1421,16 +1426,18 @@ fn response_socket_value(handle: i64) -> f64 { #[cfg(test)] mod response_socket_tests { use super::*; - use crate::server::request::{ - alloc_connection_socket, alloc_incoming_message, incoming_socket_assign, - }; + use crate::server::request::{alloc_incoming_message, incoming_socket_assign}; use perry_ffi::{drop_handle, register_handle}; use std::collections::HashMap; #[test] fn response_socket_retains_initial_socket_after_request_reassignment() { - let socket_handle = alloc_connection_socket("127.0.0.1".into(), 1234); - let socket = handle_to_pointer_f64(socket_handle); + let scope = perry_ffi::TransientRootScope::enter(); + let socket = scope.root_nanbox(perry_ext_net::native_transport::new_socket( + 0, + f64::from_bits(TAG_UNDEFINED), + )); + let socket = socket.get(); for _ in 0..2 { let request = alloc_incoming_message(IncomingMessage::new( "GET".into(), @@ -1443,10 +1450,7 @@ mod response_socket_tests { )); assert!(incoming_socket_assign(request, socket)); let response = crate::server::response::alloc_http1_server_response_for_turnloop( - 0, - 0, - request, - socket_handle, + socket, 0, request, ); assert_eq!(response_socket_value(response).to_bits(), socket.to_bits()); assert!(incoming_socket_assign(request, 42.0)); @@ -1463,7 +1467,7 @@ mod response_socket_tests { socket.to_bits() ); drop_handle(standalone); - drop_handle(socket_handle); + perry_ext_net::native_transport::destroy(socket); } #[test] diff --git a/crates/perry-ext-http/src/server/http2_server.rs b/crates/perry-ext-http/src/server/http2_server.rs index 1daaf62ace..0630a7d79d 100644 --- a/crates/perry-ext-http/src/server/http2_server.rs +++ b/crates/perry-ext-http/src/server/http2_server.rs @@ -71,7 +71,8 @@ pub(crate) use turnloop_glue::{ queue_turnloop_client_body, queue_turnloop_client_response, queue_turnloop_goaway, queue_turnloop_remote_settings, queue_turnloop_session_error, queue_turnloop_stream_error, queue_turnloop_stream_reset, register_turnloop_server_session, register_turnloop_stream_handle, - server_has_stream_listener, turnloop_conn_of_session, turnloop_target_of_stream, + server_has_stream_listener, socket_of_session, turnloop_conn_of_session, + turnloop_target_of_stream, }; pub(crate) static H2_PENDING_EVENTS: std::sync::LazyLock>> = @@ -177,11 +178,13 @@ pub struct Http2SecureServer { pub settings: Http2SettingsState, /// Node's `allowHTTP1`: what an ALPN negotiation of `http/1.1` means. pub allow_http1: bool, - /// The turnloop listener id, or zero when this server is not listening. - pub turnloop_listener: i64, } pub struct Http2SessionHandle { + /// Server connections are ordinary Sockets, owned by the logical session. + pub socket_value: f64, + pub socket_incarnation: Option, + pub owner_agent: u64, pub server_handle: i64, /// Client-side local TCP port / server-side peer TCP port. A loopback /// client reserves and records its local port before connecting, so the @@ -475,7 +478,6 @@ pub unsafe extern "C" fn js_node_http2_create_secure_server(opts_f64: f64, handl base: HttpServer::with_handler(handler), settings, allow_http1, - turnloop_listener: 0, }) } @@ -506,7 +508,6 @@ pub unsafe extern "C" fn js_node_http2_create_server(first_arg: f64, second_arg: base: HttpServer::with_handler(handler), settings, allow_http1, - turnloop_listener: 0, }) } diff --git a/crates/perry-ext-http/src/server/http2_server/controls.rs b/crates/perry-ext-http/src/server/http2_server/controls.rs index e29ed0e09d..5b3f160f2d 100644 --- a/crates/perry-ext-http/src/server/http2_server/controls.rs +++ b/crates/perry-ext-http/src/server/http2_server/controls.rs @@ -35,7 +35,7 @@ pub(crate) fn numeric_value(value: f64) -> Option { /// Whether this session's frames reach a wire, and which connection carries /// them. -fn turnloop_of(handle: i64) -> Option { +fn turnloop_of(handle: i64) -> Option { super::turnloop_conn_of_session(handle) } diff --git a/crates/perry-ext-http/src/server/http2_server/dispatch.rs b/crates/perry-ext-http/src/server/http2_server/dispatch.rs index 9e69d4eda9..c57b54cb16 100644 --- a/crates/perry-ext-http/src/server/http2_server/dispatch.rs +++ b/crates/perry-ext-http/src/server/http2_server/dispatch.rs @@ -217,7 +217,14 @@ pub unsafe extern "C" fn js_ext_http2_session_dispatch_property( "state" => get_handle::(handle) .map(session_state_value) .unwrap_or_else(empty_object_value), - "socket" => empty_object_value(), + "socket" => { + let socket = super::socket_of_session(handle); + if perry_ext_net::native_transport::socket_link(socket).is_ok() { + socket + } else { + empty_object_value() + } + } _ => undef, } } diff --git a/crates/perry-ext-http/src/server/http2_server/pump.rs b/crates/perry-ext-http/src/server/http2_server/pump.rs index b1fa9d8472..24c097dd5b 100644 --- a/crates/perry-ext-http/src/server/http2_server/pump.rs +++ b/crates/perry-ext-http/src/server/http2_server/pump.rs @@ -435,6 +435,9 @@ mod tests { connect_event_emitted: bool, ) -> Http2SessionHandle { Http2SessionHandle { + socket_value: f64::from_bits(perry_ffi::JsValue::UNDEFINED.bits()), + socket_incarnation: None, + owner_agent: perry_ffi::agent_post::current_agent(), server_handle, connection_port, session_event_emitted: false, diff --git a/crates/perry-ext-http/src/server/http2_server/session.rs b/crates/perry-ext-http/src/server/http2_server/session.rs index 3553992493..4cb3430578 100644 --- a/crates/perry-ext-http/src/server/http2_server/session.rs +++ b/crates/perry-ext-http/src/server/http2_server/session.rs @@ -6,7 +6,7 @@ use super::*; use std::collections::HashMap; use perry_ffi::{ - get_handle, get_handle_mut, iter_handle_ids_of, iter_handles_of, iter_handles_of_mut, + get_handle, get_handle_mut, iter_handle_ids_of, iter_handles_of, register_handle, JsValue, }; @@ -15,21 +15,25 @@ use crate::server::http2_session_settings::Http2SettingsState; use crate::server::types::jsvalue_to_owned_string; pub(crate) fn mark_server_sessions_closed(server_handle: i64) { - let mut turnloop_conns = Vec::new(); - iter_handles_of_mut::(|session| { - if session.server_handle == server_handle { + let mut sessions = Vec::new(); + iter_handle_ids_of::(|id| { + if get_handle::(id).is_some_and(|session| { + session.owner_agent == perry_ffi::agent_post::current_agent() + && session.server_handle == server_handle + }) { + sessions.push(id); + } + }); + for session in sessions { + // Resolve the logical session's Socket edge before marking it + // destroyed; codec cleanup owns the terminal protocol updates. + crate::server::turnloop_h2::control::session_destroy( + crate::server::turnloop_h2::target::Target::Session(session), + ); + if let Some(session) = get_handle_mut::(session) { session.closed = true; session.destroyed = true; - if session.turnloop_conn != 0 { - turnloop_conns.push(std::mem::replace(&mut session.turnloop_conn, 0)); - } } - }); - // A turnloop connection is a live handle that keeps the loop referenced; - // marking the JS session destroyed without closing it would keep the - // process alive after `server.close()`. - for conn in turnloop_conns { - crate::server::turnloop_h2::control::session_destroy(conn); } } @@ -157,6 +161,9 @@ pub unsafe extern "C" fn js_node_http2_connect( .push(callback); } let session_handle = register_handle(Http2SessionHandle { + socket_value: f64::from_bits(perry_ffi::JsValue::UNDEFINED.bits()), + socket_incarnation: None, + owner_agent: perry_ffi::agent_post::current_agent(), server_handle: local_server_handle, connection_port: 0, session_event_emitted: false, @@ -463,6 +470,9 @@ mod tests { fn client_session(turnloop_conn: i64) -> Http2SessionHandle { Http2SessionHandle { + socket_value: f64::from_bits(perry_ffi::JsValue::UNDEFINED.bits()), + socket_incarnation: None, + owner_agent: perry_ffi::agent_post::current_agent(), server_handle: 0, connection_port: 0, session_event_emitted: false, @@ -487,28 +497,6 @@ mod tests { } } - /// The capability the tokio inventory recorded as MISSING — - /// "`turnloop_tls_io` exposes `install_server_session` publicly but only - /// `begin_client_upgrade` (`pub(crate)`…), so `http2.connect('https://…')` - /// has no way to install a client session on a turnloop socket". - /// - /// It exists, it is `pub`, and it takes neither perry-ext-net's own - /// `TlsClientConfigData` nor a `JsNativeAsyncCompletion` — which were the - /// two shape objections in that entry. Coercing it to a plain fn pointer is - /// the assertion: narrowing it back to `pub(crate)`, or putting either of - /// those types in the signature, stops this compiling. - #[test] - fn a_public_tls_client_installer_exists_for_a_turnloop_socket() { - let install: fn(i64, String, bool, Vec>, Vec>) -> Result<(), String> = - perry_ext_net::turnloop_tls_io::install_client_session; - // Used, so the coercion cannot be optimized away as a dead binding. - assert!(!std::ptr::fn_addr_eq( - install, - (|_, _, _, _, _| Ok(())) - as fn(i64, String, bool, Vec>, Vec>) -> Result<(), String> - )); - } - /// `http2.connect('https://…')` may speak HTTP/2 only if the server selects /// `h2`, so the offer is the whole point of having a client installer. It /// must be `h2` ALONE: with `http/1.1` in the list a server could select it diff --git a/crates/perry-ext-http/src/server/http2_server/turnloop_glue.rs b/crates/perry-ext-http/src/server/http2_server/turnloop_glue.rs index f06525c592..e87482e48a 100644 --- a/crates/perry-ext-http/src/server/http2_server/turnloop_glue.rs +++ b/crates/perry-ext-http/src/server/http2_server/turnloop_glue.rs @@ -18,13 +18,16 @@ use crate::server::http2_session_settings::Http2SettingsState; /// A session handle for a connection turnloop just accepted. pub(crate) fn register_turnloop_server_session( server_handle: i64, - conn_id: i64, + socket: f64, peer_port: u16, encrypted: bool, alpn: &str, local_settings: Http2SettingsState, ) -> i64 { let session_handle = register_handle(Http2SessionHandle { + socket_value: socket, + socket_incarnation: perry_ext_net::native_transport::snapshot(socket), + owner_agent: perry_ffi::agent_post::current_agent(), server_handle, connection_port: peer_port, session_event_emitted: false, @@ -45,7 +48,7 @@ pub(crate) fn register_turnloop_server_session( close_callbacks: Vec::new(), pending_callbacks: Vec::new(), timeout_callback: 0, - turnloop_conn: conn_id, + turnloop_conn: 0, }); let has_session_listener = get_handle::(server_handle) .map(|server| crate::server::server::server_has_event_listener(&server.base, "session")) @@ -79,19 +82,41 @@ pub(crate) fn bind_turnloop_client_port(session_handle: i64, port: u16) { /// The turnloop connection a session handle rides on, or `None` on the legacy /// transport. Every control surface routes on this. -pub(crate) fn turnloop_conn_of_session(session_handle: i64) -> Option { - get_handle::(session_handle) - .map(|s| s.turnloop_conn) - .filter(|id| *id != 0) +pub(crate) fn socket_of_session(session: i64) -> f64 { + perry_ffi::get_handle::(session) + .filter(|session| session.owner_agent == perry_ffi::agent_post::current_agent()) + .map(|session| session.socket_value) + .unwrap_or(f64::from_bits(perry_ffi::JsValue::UNDEFINED.bits())) } -/// The turnloop connection and real stream id behind an `Http2Stream` handle. -pub(crate) fn turnloop_target_of_stream(stream_handle: i64) -> Option<(i64, u32)> { +pub(crate) fn turnloop_conn_of_session( + session_handle: i64, +) -> Option { + use crate::server::turnloop_h2::target::Target; + let session = get_handle::(session_handle)?; + if session.owner_agent != perry_ffi::agent_post::current_agent() { + return None; + } + if perry_ext_net::native_transport::socket_link(session.socket_value).is_ok() { + Some(Target::Session(session_handle)) + } else if session.turnloop_conn != 0 { + Some(Target::Client(session.turnloop_conn)) + } else { + None + } +} + +pub(crate) fn turnloop_target_of_stream( + stream_handle: i64, +) -> Option<(crate::server::turnloop_h2::target::Target, u32)> { let stream = get_handle::(stream_handle)?; - if stream.turnloop_conn == 0 || stream.id <= 0 { + if stream.id <= 0 { return None; } - Some((stream.turnloop_conn, stream.id as u32)) + Some(( + turnloop_conn_of_session(stream.session_handle)?, + stream.id as u32, + )) } pub(crate) fn mark_turnloop_client_connected(session_handle: i64, protocol: &str) { diff --git a/crates/perry-ext-http/src/server/http2_server/turnloop_listen.rs b/crates/perry-ext-http/src/server/http2_server/turnloop_listen.rs index b32800aa5d..bc1119d126 100644 --- a/crates/perry-ext-http/src/server/http2_server/turnloop_listen.rs +++ b/crates/perry-ext-http/src/server/http2_server/turnloop_listen.rs @@ -85,7 +85,7 @@ pub(super) fn try_listen_on_turnloop(server_handle: i64, host: &str, port: u16) server.base.bound_port = bound_port; server.base.bound_host = host.to_string(); server.base.listening = true; - server.turnloop_listener = id; + let _ = id; H2Listen::Bound } Err(err) if err.no_loop => H2Listen::NoLoop, @@ -109,8 +109,11 @@ pub(super) fn try_listen_on_turnloop(server_handle: i64, host: &str, port: u16) /// sessions finish, which is Node's contract. pub(super) fn close_turnloop_listener(server_handle: i64) -> bool { let id = match get_handle_mut::(server_handle) { - Some(server) if server.turnloop_listener != 0 => { - std::mem::replace(&mut server.turnloop_listener, 0) + Some(server) + if perry_ext_net::native_transport::server_link(server.base.transport_listener) + .is_ok() => + { + server.base.transport_listener } _ => return false, }; diff --git a/crates/perry-ext-http/src/server/mod.rs b/crates/perry-ext-http/src/server/mod.rs index 8150618374..86d26e2085 100644 --- a/crates/perry-ext-http/src/server/mod.rs +++ b/crates/perry-ext-http/src/server/mod.rs @@ -96,7 +96,9 @@ static GC_REGISTERED: Once = Once::new(); /// closure registration and callback dispatch would sweep them — /// same root cause as issue #35 for net.Socket listeners. pub(crate) fn ensure_gc_scanner_registered() { + gc_register_mutable_root_scanner_named("perry-ext-http-server", scan_http_server_roots); GC_REGISTERED.call_once(|| { + perry_ffi::agent_post::register_retire_hook(retire_transport_edges); // The pump below walks the server handles every tick (keepalive // probe + drain). Index them so that walk costs O(servers), not // O(every live handle): each keep-alive connection holds a @@ -104,7 +106,6 @@ pub(crate) fn ensure_gc_scanner_registered() { perry_ffi::index_handle_type::(); perry_ffi::index_handle_type::(); perry_ffi::index_handle_type::(); - gc_register_mutable_root_scanner_named("perry-ext-http", scan_http_server_roots); // Register the extension's pump and keepalive contributor with runtime; // stdlib intentionally does not name either HTTP symbol (#9696). perry_ffi::register_aux_event_pump( @@ -136,6 +137,9 @@ fn scan_http_server_roots(visitor: &mut GcRootVisitor<'_>) { } fn scan_base_server_roots(server: &mut HttpServer, visitor: &mut GcRootVisitor<'_>) { + if server.owner_agent == perry_ffi::agent_post::current_agent() { + visitor.visit_nanbox_f64_slot(&mut server.transport_listener); + } visitor.visit_i64_slot(&mut server.handler); visitor.visit_i64_slot(&mut server.bun_error_handler); scan_listener_roots(&mut server.listeners, visitor); @@ -151,6 +155,15 @@ fn scan_http_server_roots(visitor: &mut GcRootVisitor<'_>) { } } + if let Ok(mut upgrades) = server::TURNLOOP_UPGRADES.lock() { + let agent = perry_ffi::agent_post::current_agent(); + for upgrade in upgrades + .iter_mut() + .filter(|upgrade| upgrade.owner_agent == agent) + { + visitor.visit_nanbox_f64_slot(&mut upgrade.raw_socket_value); + } + } iter_handles_of_mut::(|s| { scan_base_server_roots(s, visitor); }); @@ -169,11 +182,19 @@ fn scan_http_server_roots(visitor: &mut GcRootVisitor<'_>) { scan_listener_roots(&mut im.listeners, visitor); visitor.visit_nanbox_f64_slot(&mut im.signal_controller); visitor.visit_nanbox_f64_slot(&mut im.signal); - visitor.visit_nanbox_f64_slot(&mut im.socket_value); + if im.owner_agent == perry_ffi::agent_post::current_agent() { + visitor.visit_nanbox_f64_slot(&mut im.socket_value); + } }); // Transport and OutgoingMessage handles only. Standalone ServerResponse // objects are never registered here; GC traces their object-owned JS state. iter_handles_of_mut::(|sr| { + if sr.owner_agent == perry_ffi::agent_post::current_agent() { + visitor.visit_nanbox_f64_slot(&mut sr.socket_value); + if let Some((connection, _)) = &mut sr.turnloop { + connection.scan(visitor); + } + } scan_listener_roots(&mut sr.listeners, visitor); // #8163: `res.once(event, cb)` stores into a SECOND table that // `take_event_listeners` merges into every emit. It was never scanned, @@ -189,6 +210,9 @@ fn scan_http_server_roots(visitor: &mut GcRootVisitor<'_>) { } }); iter_handles_of_mut::(|session| { + if session.owner_agent == perry_ffi::agent_post::current_agent() { + visitor.visit_nanbox_f64_slot(&mut session.socket_value); + } scan_listener_roots(&mut session.listeners, visitor); for cb in session.close_callbacks.iter_mut() { visitor.visit_i64_slot(cb); @@ -208,6 +232,42 @@ fn scan_http_server_roots(visitor: &mut GcRootVisitor<'_>) { bun_server::scan_pending_roots(visitor); } +/// Remove existing logical objects' Socket edges before a worker heap is unmapped. +/// The loop is already retired: this hook touches no JS, payload or driver. +extern "C" fn retire_transport_edges(agent: u64) { + let undefined = f64::from_bits(perry_ffi::JsValue::UNDEFINED.bits()); + let clear_server = |server: &mut HttpServer| { + if server.owner_agent == agent { + server.transport_listener = undefined; + } + }; + iter_handles_of_mut::(clear_server); + iter_handles_of_mut::(|server| clear_server(&mut server.base)); + iter_handles_of_mut::(|server| { + clear_server(&mut server.base); + }); + iter_handles_of_mut::(|request| { + if request.owner_agent == agent { + request.socket_value = undefined; + } + }); + iter_handles_of_mut::(|response| { + if response.owner_agent == agent { + response.turnloop = None; + response.socket_value = undefined; + } + }); + iter_handles_of_mut::(|session| { + if session.owner_agent == agent { + session.socket_value = undefined; + session.socket_incarnation = None; + } + }); + if let Ok(mut upgrades) = crate::server::server::TURNLOOP_UPGRADES.lock() { + upgrades.retain(|upgrade| upgrade.owner_agent != agent); + } +} + #[cfg(test)] mod tests { use super::*; @@ -460,7 +520,6 @@ mod tests { base: http_server(h2_base_handler, listener_map("close", h2_listener)), settings: crate::server::http2_session_settings::Http2SettingsState::default(), allow_http1: false, - turnloop_listener: 0, }); let incoming_listener = young_gc_root(); diff --git a/crates/perry-ext-http/src/server/request.rs b/crates/perry-ext-http/src/server/request.rs index 54ea9e073a..54f69d8517 100644 --- a/crates/perry-ext-http/src/server/request.rs +++ b/crates/perry-ext-http/src/server/request.rs @@ -34,6 +34,7 @@ use crate::server::types::{ /// and TS (handler accessing `req.method`, `req.headers`, etc.) read /// through this. pub struct IncomingMessage { + pub(crate) owner_agent: u64, pub method: String, /// Path + optional `?query`, matching Node's `req.url`. pub url: String, @@ -109,6 +110,7 @@ impl IncomingMessage { remote_port: u16, ) -> Self { Self { + owner_agent: perry_ffi::agent_post::current_agent(), method, url, headers, @@ -594,15 +596,15 @@ pub extern "C" fn js_node_http_im_resume_self(handle: i64) -> i64 { /// Destroy a request handle, or the transport behind an accepted socket. #[no_mangle] pub extern "C" fn js_node_http_im_destroy(handle: i64) { - if let Some(im) = get_handle_mut::(handle) { + let socket = if let Some(im) = get_handle_mut::(handle) { im.destroyed = true; + im.socket_value } else { return; - } - if crate::server::turnloop_h2::conn::destroy_socket(handle) - || crate::server::turnloop_serve::destroy_socket(handle) - { - return; + }; + let socket = perry_ext_net::native_transport::RootedSocket::new(socket); + if perry_ext_net::native_transport::socket_link(socket.value()).is_ok() { + perry_ext_net::native_transport::destroy(socket.value()); } close_incoming_message(handle); } @@ -863,33 +865,6 @@ pub(crate) fn emit_no_arg_to_listeners(listeners: &[i64]) { } } -/// Fire a one-arg event (`server.on('connection', socket)`). -/// Call every listener with `this` bound to `this_val` (the emitting object: -/// Node invokes server, socket and request callbacks that way, so -/// `server.listen(0, function() { this.address().port })` resolves `this` to -/// the server, #2132) and one argument. -pub(crate) fn emit_one_arg_to_listeners(listeners: &[i64], this_val: f64, arg: f64) { - // #8082: the snapshot, the receiver AND the arg cross every callback — - // root all of them. - let scope = perry_ffi::TransientRootScope::enter(); - let rooted = scope.root_addrs(listeners); - let this_val = scope.root_nanbox(this_val); - let arg = scope.root_nanbox(arg); - for cb in &rooted { - let addr = cb.get(); - if addr == 0 { - continue; - } - unsafe { - let raw = addr as *const RawClosureHeader; - let closure = JsClosure::from_raw(raw); - if !closure.is_null() { - let _ = closure.call1(perry_ffi::JsThis::from_f64(this_val.get()), arg.get()); - } - } - } -} - /// Mark the request as closed, abort `req.signal`, and fire `'close'` once. pub(crate) fn close_incoming_message(handle: i64) { let close_listeners; @@ -918,32 +893,6 @@ pub(crate) fn alloc_incoming_message(im: IncomingMessage) -> i64 { register_handle(im) } -/// Build the JS-visible object exposed to `'connection'` listeners and, for -/// every request on this connection, as `req.socket`/`req.connection`. One -/// per accepted connection, shared by every request that connection carries. -/// -/// Deliberately reuses `IncomingMessage` rather than a new type: it already -/// has `remoteAddress`/`remotePort`, an `on`/`once`/`destroy` dispatch -/// surface, and a `'close'` emit (`close_incoming_message`) — exactly the -/// net.Socket-shaped surface Node exposes here, with no request-specific -/// state ever populated on it (`method`/`url`/`headers` stay at their -/// `IncomingMessage::new` defaults and are never Node-accurate for this -/// object; nothing reads them, since real `net.Socket` has no such -/// properties either). -pub(crate) fn alloc_connection_socket(remote_address: String, remote_port: u16) -> i64 { - let mut socket = IncomingMessage::new( - String::new(), - String::new(), - HashMap::new(), - Vec::new(), - Vec::new(), - remote_address, - remote_port, - ); - socket.complete = true; - register_handle(socket) -} - /// Record only that the response wrote at least one byte. The req/res facade /// does not currently share the transport's exact byte counter, so this is a /// deliberate nonzero approximation rather than Node's precise total. diff --git a/crates/perry-ext-http/src/server/response.rs b/crates/perry-ext-http/src/server/response.rs index b837974b26..83b9b6ba97 100644 --- a/crates/perry-ext-http/src/server/response.rs +++ b/crates/perry-ext-http/src/server/response.rs @@ -87,6 +87,7 @@ fn is_valid_link_header(value: &str) -> bool { /// Legacy transport/OutgoingMessage handle. Standalone ServerResponse objects /// own a ResponseState payload and keep their JS edges on the object. pub struct ServerResponse { + pub(crate) owner_agent: u64, pub state: ResponseState, pub listeners: HashMap>, pub once_listeners: HashMap>, @@ -140,7 +141,8 @@ pub struct ResponseState { pub send_date: bool, pub strict_content_length: bool, pub req_handle: i64, - pub socket_handle: i64, + /// The public connection Socket is a traced edge, retained after close. + pub socket_value: f64, /// True for direct `new http.OutgoingMessage()` handles. They share the /// outgoing header/writable surface but are not a live ServerResponse. pub outgoing_message_only: bool, @@ -170,7 +172,7 @@ pub struct ResponseState { /// keeps a late `res.end()` from writing onto the connection's *next* /// request after the first one was destroyed. `None` for a standalone /// `new http.ServerResponse(req)` and a bare `OutgoingMessage`. - pub turnloop: Option<(i64, u64)>, + pub(crate) turnloop: Option<(crate::server::turnloop_route::ResponseConnection, u64)>, /// P5: the head has gone out and further writes stream straight to the /// socket. pub turnloop_streaming: bool, @@ -194,6 +196,7 @@ pub struct ResponseShape { impl ServerResponse { pub fn new() -> Self { Self { + owner_agent: perry_ffi::agent_post::current_agent(), state: ResponseState::new(), listeners: HashMap::new(), once_listeners: HashMap::new(), @@ -230,7 +233,7 @@ impl ResponseState { send_date: true, strict_content_length: false, req_handle: 0, - socket_handle: 0, + socket_value: f64::from_bits(TAG_UNDEFINED), outgoing_message_only: false, buffered_body: Vec::new(), needs_drain: false, @@ -1057,9 +1060,10 @@ fn stream_write_with_cb(handle: i64, bytes: &[u8], callback: i64) -> Option Option<(Vec // final chunk and the trailer block are encoded and submitted directly. if sr.turnloop_streaming { let (conn, seq) = sr.turnloop.expect("streaming implies a turnloop target"); + let conn = conn.root(); let chunk = final_chunk.clone(); let trailers = sr.snapshot_trailers(); sr.writable_ended = true; @@ -1259,9 +1264,9 @@ pub(crate) fn finalize_buffered_end(handle: i64, chunk: f64) -> Option<(Vec let finish_listeners = response_event_listeners(response_root.get(), "finish"); let close_listeners = response_event_listeners(response_root.get(), "close"); if let Some(c) = chunk { - let _ = crate::server::turnloop_route::send_body(conn, seq, &c); + let _ = crate::server::turnloop_route::send_body(&conn, seq, &c); } - crate::server::turnloop_route::finish_body(conn, seq, &trailers); + crate::server::turnloop_route::finish_body(&conn, seq, &trailers); crate::server::request::mark_connection_written(req_handle_of(response_root.get())); return Some((finish_listeners, close_listeners)); } @@ -1292,7 +1297,8 @@ pub(crate) fn finalize_buffered_end(handle: i64, chunk: f64) -> Option<(Vec // connection (the connection's request was never delivered) has nowhere // to go, and is simply finished. if let Some((conn, seq)) = turnloop { - crate::server::turnloop_route::send_response(conn, seq, shape); + let conn = conn.root(); + crate::server::turnloop_route::send_response(&conn, seq, shape); } if let Some(sr) = response_state_mut(response_root.get()) { sr.writable_finished = true; @@ -1339,17 +1345,18 @@ pub(crate) fn begin_streaming(handle: i64) -> bool { body: Vec::new(), auto_content_length: false, }; + let conn = conn.root(); let first = std::mem::take(&mut sr.buffered_body); sr.headers_sent = true; sr.turnloop_streaming = true; - if !crate::server::turnloop_route::begin_stream(conn, seq, shape) { + if !crate::server::turnloop_route::begin_stream(&conn, seq, shape) { if let Some(sr) = response_state_mut(response_root.get()) { sr.turnloop_streaming = false; } return false; } if !first.is_empty() { - let _ = crate::server::turnloop_route::send_body(conn, seq, &first); + let _ = crate::server::turnloop_route::send_body(&conn, seq, &first); } true } @@ -1369,7 +1376,7 @@ pub(crate) fn take_drain_listeners_if_ready(handle: i64) -> Vec { } let below = match sr.turnloop { Some((conn, seq)) if sr.turnloop_streaming => { - crate::server::turnloop_route::writable_below_watermark(conn, seq) + crate::server::turnloop_route::writable_below_watermark(&conn.root(), seq) } _ => false, }; diff --git a/crates/perry-ext-http/src/server/response_turnloop.rs b/crates/perry-ext-http/src/server/response_turnloop.rs index 44193f1f91..78c3943b8d 100644 --- a/crates/perry-ext-http/src/server/response_turnloop.rs +++ b/crates/perry-ext-http/src/server/response_turnloop.rs @@ -103,18 +103,37 @@ pub(crate) fn req_handle_of(handle: i64) -> i64 { /// The handler runs on the thread that owns the connection, so `res.end()` /// encodes and submits the write itself. pub(crate) fn alloc_server_response_for_turnloop(conn_id: i64, seq: u64, req_handle: i64) -> i64 { - alloc_http1_server_response_for_turnloop(conn_id, seq, req_handle, 0) + alloc_response_for_connection( + crate::server::http2_server::socket_of_session(conn_id), + Some(crate::server::turnloop_route::ResponseConnection::H2Session(conn_id)), + seq, + req_handle, + ) } pub(crate) fn alloc_http1_server_response_for_turnloop( - conn_id: i64, + socket: f64, + seq: u64, + req_handle: i64, +) -> i64 { + let connection = perry_ext_net::native_transport::snapshot(socket).map(|incarnation| { + crate::server::turnloop_route::ResponseConnection::Socket { + owner: socket, + incarnation, + } + }); + alloc_response_for_connection(socket, connection, seq, req_handle) +} + +fn alloc_response_for_connection( + socket: f64, + connection: Option, seq: u64, req_handle: i64, - socket_handle: i64, ) -> i64 { let mut response = ServerResponse::new().with_request_handle(req_handle); - response.socket_handle = socket_handle; - response.turnloop = Some((conn_id, seq)); + response.socket_value = socket; + response.turnloop = connection.map(|connection| (connection, seq)); register_handle(response) } @@ -125,7 +144,7 @@ pub(crate) fn stream_receiver_gone(handle: i64) -> bool { return false; }; match sr.turnloop { - Some((conn, _)) => !perry_ffi::turnloop_net::is_live(conn), + Some((conn, _)) => !crate::server::turnloop_route::is_live(&conn.root()), None => false, } } @@ -138,7 +157,11 @@ pub(crate) fn stream_receiver_gone(handle: i64) -> bool { #[no_mangle] pub extern "C" fn js_node_http_res_write_continue(handle: i64) { if let Some((conn, seq)) = get_handle::(handle).and_then(|sr| sr.turnloop) { - crate::server::turnloop_serve::send_interim(conn, seq, b"HTTP/1.1 100 Continue\r\n\r\n"); + crate::server::turnloop_route::send_interim( + &conn.root(), + seq, + b"HTTP/1.1 100 Continue\r\n\r\n", + ); } } @@ -146,6 +169,10 @@ pub extern "C" fn js_node_http_res_write_continue(handle: i64) { #[no_mangle] pub extern "C" fn js_node_http_res_write_processing(handle: i64) { if let Some((conn, seq)) = get_handle::(handle).and_then(|sr| sr.turnloop) { - crate::server::turnloop_serve::send_interim(conn, seq, b"HTTP/1.1 102 Processing\r\n\r\n"); + crate::server::turnloop_route::send_interim( + &conn.root(), + seq, + b"HTTP/1.1 102 Processing\r\n\r\n", + ); } } diff --git a/crates/perry-ext-http/src/server/server.rs b/crates/perry-ext-http/src/server/server.rs index c2ef7fbef7..7ede6c657a 100644 --- a/crates/perry-ext-http/src/server/server.rs +++ b/crates/perry-ext-http/src/server/server.rs @@ -42,13 +42,15 @@ pub(crate) use deferred_events::{ mod turnloop_listen; use turnloop_listen::try_listen_on_turnloop; pub(crate) use turnloop_listen::{ - idle_close_ms, note_turnloop_request_aborted, queue_turnloop_connection_event, - queue_turnloop_upgrade, turnloop_connection_closed, + emit_connection, idle_close_ms, note_turnloop_request_aborted, queue_turnloop_upgrade, + turnloop_connection_closed, }; /// Backing struct for an `http.Server` JS-side handle. pub struct HttpServer { pub async_id: u64, + /// Traced ordinary net.Server delegate; application ids remain logical. + pub transport_listener: f64, /// User's `(req, res) => ...` handler. Stored as raw `i64`; the /// GC root scanner pins it across malloc-triggered sweeps. pub handler: i64, @@ -159,6 +161,7 @@ impl HttpServer { pub fn with_handler(handler: i64) -> Self { Self { async_id: 0, + transport_listener: f64::from_bits(TAG_UNDEFINED), handler, listeners: HashMap::new(), once_listeners: HashMap::new(), @@ -241,23 +244,15 @@ pub struct HttpPendingUpgrade { /// WebSocket path (real handshakes with a `Sec-WebSocket-Key`): the /// perry-ext-ws connection id. 0 on the raw path. pub ws_id: i64, - /// #4973 raw path (keyless Upgrade requests): the perry-ext-net socket - /// id adopted from the connection. 0 on the WebSocket path. - pub raw_socket_id: i64, + /// Raw upgrade path: the connection's original ordinary Socket value. + /// Zero on the WebSocket path, which retains its own protocol id. + pub raw_socket_value: f64, + pub owner_agent: u64, /// #4973 raw path: unconsumed bytes that followed the request head — /// Node's `upgradeHead` argument. pub head: Vec, } -/// `(server_handle, socket_handle)` pairs whose accept loop saw a new -/// connection since the last pump tick. Drained by -/// `js_node_http_server_process_pending` to fire `'connection'` listeners on -/// the main thread (#4905). `socket_handle` is the connection's identity -/// (`crate::server::request::alloc_connection_socket`): the `IncomingMessage`-shaped object Node passes as the -/// listener's argument and that every request on the connection shares as -/// `req.socket`. -pub(crate) static PENDING_CONNECTION_EVENTS: Mutex> = Mutex::new(Vec::new()); - /// Read the `HttpServer` behind a JS server handle, whichever flavour it is. /// /// `https.Server` and `http2.SecureServer` both embed an `HttpServer` as @@ -300,11 +295,11 @@ pub(crate) static TURNLOOP_UPGRADES: Mutex false, + Some(false) => false, Some(_) => true, // Only reachable if the loop vanished between the caller's check // and the bind: report it rather than leave the listen hanging. @@ -1074,19 +1069,6 @@ pub extern "C" fn js_node_http_server_has_active() -> i32 { active } -/// Whether `server_handle` names an HTTP, HTTPS or HTTP/2 server the calling -/// agent owns. A handle that names none of them reads as not owned. -pub(crate) fn server_handle_owned_here(server_handle: i64) -> bool { - if let Some(s) = get_handle::(server_handle) { - return s.owned_here(); - } - if let Some(s) = get_handle::(server_handle) { - return s.base.owned_here(); - } - get_handle::(server_handle) - .is_some_and(|s| s.base.owned_here()) -} - /// Drain pending requests + upgrades from every registered server, /// dispatching to the user handler / `'upgrade'` listener on the /// main thread. Called each tick by perry-stdlib's pump (gated on @@ -1126,44 +1108,6 @@ pub extern "C" fn js_node_http_server_process_pending() -> i32 { // response since the last tick (or timed out) before draining new ones. reap_in_flight_requests(); - // #4905 — fire `'connection'` listeners for connections accepted since - // the last tick, before their requests are dispatched (Node fires - // `'connection'` ahead of `'request'`). - // Only the events of servers this agent owns (#11433); another agent's stay - // queued for its own pump. - let connection_events: Vec<(i64, i64)> = PENDING_CONNECTION_EVENTS - .lock() - .map(|mut q| { - let (mine, theirs): (Vec<_>, Vec<_>) = std::mem::take(&mut *q) - .into_iter() - .partition(|(server_handle, _)| server_handle_owned_here(*server_handle)); - *q = theirs; - mine - }) - .unwrap_or_default(); - for (server_handle, socket_handle) in connection_events { - let listeners = with_base_server_mut(server_handle, |server| { - take_server_event_listeners(server, "connection") - }) - .unwrap_or_default(); - if listeners.is_empty() { - continue; - } - let this_val = handle_to_pointer_f64(server_handle); - let socket_val = handle_to_pointer_f64(socket_handle); - crate::server::request::emit_one_arg_to_listeners(&listeners, this_val, socket_val); - count += 1; - } - - // Fire `'close'` on every connection - // socket whose TCP connection fully closed since the last tick — before - // this tick's server `'close'` callback below (`drain_deferred_close_for`), - // matching Node's ordering. - for socket_handle in crate::server::turnloop_serve::take_closed_sockets() { - crate::server::request::close_incoming_message(socket_handle); - count += 1; - } - // P5: a connection that died before its response completed raises Node's // `'aborted'` on the request; the sink queued it because it may not run JS. count += turnloop_listen::drain_aborted_requests(); @@ -1245,20 +1189,6 @@ pub extern "C" fn js_node_http_server_process_pending() -> i32 { } count += crate::server::http2_server::process_pending_h2_events(); - // #5010 — drain perry-ext-net's own pending-event queue. A raw - // `'upgrade'` (#4973) hands the listener a real `net.Socket` adopted into - // perry-ext-net (`adopt_turnloop_upgrade`); when user code destroys it, - // the socket task queues a `Close` event in perry-ext-net's queue. For an - // http-only program perry-stdlib runs with its OWN bundled net (so its - // `external-net-pump` arm is OFF and never touches ext-net's queue), and - // the perry-ext-net aux pump proved unreliable across workspace link - // layouts. The http-server pump, by contrast, runs every tick - // (external-http-server-pump) and directly depends on perry-ext-net, so - // draining here — through the UNIQUE `js_ext_net_drain_pending` symbol - // (no stdlib twin) — reliably empties that queue so the destroyed upgrade - // socket stops pinning the event loop. Cheap (one mutex peek) when empty. - count += unsafe { perry_ext_net::js_ext_net_drain_pending() }; - count } @@ -1269,30 +1199,24 @@ pub extern "C" fn js_node_http_server_process_pending() -> i32 { fn drain_upgrades(server_handle: i64) -> i32 { let mut count = 0; while let Some(up) = try_recv_upgrade(server_handle) { - // #6710 — the upgrade path bypasses `process_pending`, but its request - // handle and the adopted socket / WebSocket handles are recycled from - // the same freelist. Clear their per-handle JS side tables here, on the - // main thread, before any upgrade listener sees them (no-op for a zero - // handle). + let scope = perry_ffi::TransientRootScope::enter(); + let socket = scope.root_nanbox(up.raw_socket_value); + // The logical request and WebSocket protocol handles still use the + // phase C freelist. Socket identity is its ordinary JS value above. unsafe { js_handle_clear_side_tables(up.request_handle); - js_handle_clear_side_tables(up.raw_socket_id); js_handle_clear_side_tables(up.ws_id); } - if up.raw_socket_id != 0 { - // #4973 raw path: make sure the adopted net.Socket's dispatch - // extensions + GC scanner are registered on the main thread before - // user code touches the socket. - perry_ext_net::ensure_adopted_socket_dispatch(); + if perry_ext_net::native_transport::socket_link(socket.get()).is_ok() { if !crate::server::upgrade::fire_upgrade_listeners( up.server_handle, up.request_handle, - up.raw_socket_id, + socket.get(), up.head, ) { // Listeners may disappear between connection admission and // delivery. Nobody owns this raw upgrade in that case. - perry_ext_net::js_ext_net_destroy_socket(up.raw_socket_id); + perry_ext_net::native_transport::destroy(socket.get()); perry_ffi::drop_handle(up.request_handle); } } else { @@ -1304,7 +1228,7 @@ fn drain_upgrades(server_handle: i64) -> i32 { crate::server::upgrade::fire_upgrade_listeners( up.server_handle, up.request_handle, - up.ws_id, + handle_to_pointer_f64(up.ws_id), Vec::new(), ); } @@ -1505,9 +1429,10 @@ pub(crate) fn synthesize_default_response_if_needed(response_handle: i64) { // P5 streaming: the head is on the wire; close the body framing. if sr.turnloop_streaming { let (conn, seq) = sr.turnloop.expect("streaming implies a turnloop target"); + let conn = conn.root(); let trailers = sr.snapshot_trailers(); sr.needs_drain = false; - crate::server::turnloop_serve::finish_body(conn, seq, &trailers); + crate::server::turnloop_route::finish_body(&conn, seq, &trailers); return; } let auto_content_length = sr.ensure_content_length(); @@ -1525,7 +1450,8 @@ pub(crate) fn synthesize_default_response_if_needed(response_handle: i64) { auto_content_length, }; if let Some((conn, seq)) = sr.turnloop { - crate::server::turnloop_serve::send_response(conn, seq, shape); + let conn = conn.root(); + crate::server::turnloop_route::send_response(&conn, seq, shape); } } } diff --git a/crates/perry-ext-http/src/server/server/turnloop_listen.rs b/crates/perry-ext-http/src/server/server/turnloop_listen.rs index 570ebcc635..544b05d408 100644 --- a/crates/perry-ext-http/src/server/server/turnloop_listen.rs +++ b/crates/perry-ext-http/src/server/server/turnloop_listen.rs @@ -6,7 +6,7 @@ use perry_ffi::{get_handle, get_handle_mut}; -use super::{HttpPendingUpgrade, HttpServer, PENDING_CONNECTION_EVENTS, TURNLOOP_UPGRADES}; +use super::{HttpPendingUpgrade, HttpServer, TURNLOOP_UPGRADES}; /// Fire Node's `'aborted'` on every request whose connection died before its /// response completed (P5), and report how many listeners ran. @@ -43,9 +43,21 @@ pub(crate) fn note_turnloop_request_aborted(request_handle: i64) { /// Drained by the pump, whose listeners fire with `socket_handle` — the /// connection's socket object — as the sole /// argument. -pub(crate) fn queue_turnloop_connection_event(server_handle: i64, socket_handle: i64) { - if let Ok(mut q) = PENDING_CONNECTION_EVENTS.lock() { - q.push((server_handle, socket_handle)); +pub(crate) fn emit_connection(server_handle: i64, socket: f64) { + let scope = perry_ffi::TransientRootScope::enter(); + let socket = scope.root_nanbox(socket); + let listeners = super::with_base_server_mut(server_handle, |server| { + super::take_server_event_listeners(server, "connection") + }) + .unwrap_or_default(); + let listeners = scope.root_addrs(&listeners); + let this = crate::server::request::handle_to_pointer_f64(server_handle); + for callback in listeners { + perry_ext_net::native_transport::call( + crate::server::request::handle_to_pointer_f64(callback.get()), + perry_ffi::JsThis::from_f64(this), + &[socket.get()], + ); } } @@ -84,7 +96,7 @@ pub(super) fn try_listen_on_turnloop( host: &str, port: u16, resolved: Option, -) -> Option { +) -> Option { if !crate::server::turnloop_serve::enabled() { return None; } @@ -113,7 +125,8 @@ pub(super) fn try_listen_on_turnloop( server.bound_port = bound_port; server.bound_host = host.to_string(); server.listening = true; - Some(id) + let _ = id; + Some(true) } Err(err) if err.no_loop => None, Err(err) => { @@ -131,7 +144,7 @@ pub(super) fn try_listen_on_turnloop( &err.syscall, ); // The failure is reported once and the listen ends here. - Some(0) + Some(false) } } } @@ -212,7 +225,7 @@ mod tests { use std::sync::mpsc; use std::time::{Duration, Instant}; - type Observed = (bool, u16, bool, Option, Option); + type Observed = (bool, u16, bool, Option, Option); /// `listen()` on 127.0.0.1:0, and what the server reports right after. fn listen_and_observe(handle: i64) -> Observed { let args = crate::server::types::ListenArgs { @@ -230,7 +243,7 @@ mod tests { crate::server::turnloop_serve::listener_for_server(handle), ) } - fn close(handle: i64, listener: Option) { + fn close(handle: i64, listener: Option) { if let Some(id) = listener { crate::server::turnloop_serve::close_listener(id); } diff --git a/crates/perry-ext-http/src/server/turnloop_h2/conn.rs b/crates/perry-ext-http/src/server/turnloop_h2/conn.rs index 03d611591a..b56d59acce 100644 --- a/crates/perry-ext-http/src/server/turnloop_h2/conn.rs +++ b/crates/perry-ext-http/src/server/turnloop_h2/conn.rs @@ -63,7 +63,7 @@ //! for it, so this module counts the SETTINGS frames it sent out of band and //! eats exactly that many acks before the core sees them. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::sync::{Mutex, OnceLock}; use std::time::{Duration, Instant}; @@ -74,6 +74,7 @@ use turnloop_http::http2::{self, Event, HeadersKind, Role}; use crate::server::http2_session_settings::Http2SettingsState; use super::stream::{self, H2Stream}; +use super::target::Target; /// Node's `settingsTimeout`: how long a peer has to acknowledge our SETTINGS. const SETTINGS_TIMEOUT_MS: u64 = 10_000; @@ -204,7 +205,10 @@ pub(crate) struct ClientTls { } pub(crate) struct H2Conn { + /// A phase-C client transport id; zero for a Socket-owned server codec. pub(crate) id: i64, + pub(super) wire: Vec>, + pub(super) deadline: Option>, pub(crate) role: Role, /// The `Http2SecureServer` handle; zero on a client session. pub(crate) server_handle: i64, @@ -222,6 +226,7 @@ pub(crate) struct H2Conn { /// by `on_connect`; `None` on a server connection, whose TLS configuration /// belongs to the listener and is installed at accept time instead. pub(crate) client_tls: Option, + pub(crate) tls_session: Option, pub(crate) alpn: Option>, pub(crate) peer_address: String, pub(crate) peer_port: u16, @@ -295,50 +300,23 @@ fn conns() -> &'static Mutex> { /// Ids this module owns, kept separately from [`conns`] so that [`owns`] stays /// truthful while a record is checked out by [`with_owned`]. -fn owned_ids() -> &'static Mutex> { - static IDS: OnceLock>> = OnceLock::new(); - IDS.get_or_init(|| Mutex::new(HashMap::new())) +fn owned_ids() -> &'static Mutex> { + static IDS: OnceLock>> = OnceLock::new(); + IDS.get_or_init(|| Mutex::new(HashSet::new())) } pub(crate) fn owns(id: i64) -> bool { owned_ids() .lock() .unwrap_or_else(|e| e.into_inner()) - .contains_key(&id) + .contains(&id) } -fn socket_handle(id: i64) -> i64 { +pub(crate) fn insert(conn: H2Conn) { owned_ids() .lock() .unwrap_or_else(|e| e.into_inner()) - .get(&id) - .copied() - .unwrap_or(0) -} - -fn connection_id_for_socket(socket: i64) -> Option { - owned_ids() - .lock() - .unwrap_or_else(|e| e.into_inner()) - .iter() - .find_map(|(id, handle)| (*handle == socket).then_some(*id)) -} - -/// Route a JS accepted-socket handle through the HTTP/2 transport owner. -pub(crate) fn destroy_socket(socket: i64) -> bool { - if let Some(id) = connection_id_for_socket(socket) { - destroy_connection(id); - true - } else { - false - } -} - -pub(crate) fn insert(conn: H2Conn, socket_handle: i64) { - owned_ids() - .lock() - .unwrap_or_else(|e| e.into_inner()) - .insert(conn.id, socket_handle); + .insert(conn.id); conns() .lock() .unwrap_or_else(|e| e.into_inner()) @@ -372,7 +350,11 @@ fn forget(id: i64) -> Option { /// process that failed one write would leak a handle id per connection (the /// #6441 exhaustion class). [`forget`] is the only release, and [`on_closed`] /// is the only caller that can reach it for a live socket. -pub(crate) fn with_owned(id: i64, f: impl FnOnce(&mut H2Conn) -> R) -> Option { +pub(crate) fn with_owned(id: impl Into, f: impl FnOnce(&mut H2Conn) -> R) -> Option { + let id = match id.into() { + Target::Session(session) => return super::native_server::with_session(session, f), + Target::Client(id) => id, + }; let mut conn = conns() .lock() .unwrap_or_else(|e| e.into_inner()) @@ -388,9 +370,14 @@ pub(crate) fn with_owned(id: i64, f: impl FnOnce(&mut H2Conn) -> R) -> Option } /// Read one field of a connection without checking it out. -pub(crate) fn peek(id: i64, f: impl FnOnce(&H2Conn) -> R) -> Option { - let map = conns().lock().unwrap_or_else(|e| e.into_inner()); - map.get(&id).map(f) +pub(crate) fn peek(id: impl Into, f: impl FnOnce(&H2Conn) -> R) -> Option { + match id.into() { + Target::Client(id) => { + let map = conns().lock().unwrap_or_else(|e| e.into_inner()); + map.get(&id).map(f) + } + Target::Session(session) => super::native_server::peek_session(session, f), + } } // ── Completion routing ────────────────────────────────────────────────────── @@ -398,13 +385,6 @@ pub(crate) fn peek(id: i64, f: impl FnOnce(&H2Conn) -> R) -> Option { /// Called first from P5's sink. Returns true when this completion was HTTP/2's. pub(crate) fn intercept(c: &tl::NetCompletion) -> bool { match c.kind { - tl::NET_ACCEPT => { - if !super::is_listener(c.id) { - return false; - } - on_accept(c.id, c.conn); - true - } _ => { if !owns(c.id) { return false; @@ -432,109 +412,8 @@ pub(crate) fn intercept(c: &tl::NetCompletion) -> bool { /// A listener error. P5's sink owns listener ids it knows; this answers for /// HTTP/2's, which P5's `on_error` would otherwise treat as a connection. -pub(crate) fn intercept_listener_error(id: i64, terminal: bool) -> bool { - if !super::is_listener(id) { - return false; - } - if terminal { - super::close_listener(id); - } - true -} - -// ── Server accept ─────────────────────────────────────────────────────────── - -fn on_accept(listener_id: i64, conn_id: i64) { - if conn_id == 0 { - return; - } - let Some((server_handle, tls, allow_http1, settings, max_session_memory)) = - super::with_listener(listener_id, |l| { - ( - l.server_handle, - l.tls.clone(), - l.allow_http1, - l.settings.clone(), - l.max_session_memory, - ) - }) - else { - let _ = tl::close(conn_id); - return; - }; - let secure = tls.is_some(); - if let Some(config) = tls { - if perry_ext_net::turnloop_tls_io::install_server_session(conn_id, config).is_err() { - let _ = tl::close(conn_id); - return; - } - } - let peer = tl::peer_address(conn_id); - let session_handle = crate::server::http2_server::register_turnloop_server_session( - server_handle, - conn_id, - peer.as_ref().map(|e| e.port).unwrap_or(0), - secure, - if secure { "h2" } else { "h2c" }, - advertised_settings(&settings), - ); - let mut conn = H2Conn { - id: conn_id, - role: Role::Server, - server_handle, - session_handle, - core: None, - input: Vec::with_capacity(16 * 1024), - streams: Vec::new(), - secure, - handshaking: secure, - connecting: false, - // A server connection's TLS is the listener's; it was installed at - // accept time, before this record existed. - client_tls: None, - alpn: None, - peer_address: peer.as_ref().map(|e| e.address.clone()).unwrap_or_default(), - peer_port: peer.as_ref().map(|e| e.port).unwrap_or(0), - buffered: 0, - max_session_memory, - timer: Timer::None, - draining: false, - closing: false, - read_eof: false, - destroyed: false, - queued_opens: Vec::new(), - allow_http1, - settings, - preface_done: false, - core_settings_acked: false, - owed_settings_acks: 0, - goaway_opaque: Vec::new(), - peer_settings: None, - pending_controls: Vec::new(), - }; - if !secure { - // h2c with prior knowledge: the core starts immediately and the client - // preface is the first thing it will be fed. - if !start_core(&mut conn) { - let _ = tl::close(conn_id); - return; - } - } - let connection_socket = crate::server::request::alloc_connection_socket( - peer.as_ref().map(|e| e.address.clone()).unwrap_or_default(), - peer.as_ref().map(|e| e.port).unwrap_or(0), - ); - insert(conn, connection_socket); - crate::server::server::queue_turnloop_connection_event(server_handle, connection_socket); - if let Err(_err) = tl::read_start(conn_id) { - destroy_connection(conn_id); - return; - } - flush_id(conn_id); -} - /// Build the protocol core and queue our own preface + SETTINGS. -fn start_core(c: &mut H2Conn) -> bool { +pub(super) fn start_core(c: &mut H2Conn) -> bool { let mut limits = http2::Limits::default(); // `Limits::streams` is both the SETTINGS_MAX_CONCURRENT_STREAMS we // advertise and the size of the core's stream table, so it is clamped to @@ -635,7 +514,10 @@ fn arm_settings_timeout(c: &mut H2Conn) { return; }; core.set_settings_deadline(Some(deadline)); - if tl::timer_arm(c.id, super::SUBSYSTEM, SETTINGS_TIMEOUT_MS).is_ok() { + if c.role == Role::Server { + c.deadline = Some(Some(SETTINGS_TIMEOUT_MS)); + c.timer = Timer::Settings; + } else if tl::timer_arm(c.id, super::SUBSYSTEM, SETTINGS_TIMEOUT_MS).is_ok() { c.timer = Timer::Settings; } } @@ -682,49 +564,49 @@ pub(crate) fn connect_client( if id == perry_ffi::INVALID_HANDLE { return None; } - insert( - H2Conn { - id, - role: Role::Client, - server_handle: 0, - session_handle, - core: None, - input: Vec::with_capacity(16 * 1024), - streams: Vec::new(), - secure: tls.is_some(), - // A client handshake has not started yet — `on_connect` installs the - // session once the socket is up — but the flag has to be set here, - // because `on_data` reads it to decide whether the first bytes are a - // ServerHello or an HTTP/2 preface. - handshaking: tls.is_some(), - connecting: true, - client_tls: tls, - alpn: None, - peer_address: String::new(), - peer_port: port, - buffered: 0, - // Node's `maxSessionMemory` default, in bytes. - max_session_memory: 10 * 1024 * 1024, - timer: Timer::None, - draining: false, - closing: false, - read_eof: false, - destroyed: false, - queued_opens: Vec::new(), - allow_http1: false, - // What this connection's core will actually advertise, so - // `clamp_to_core` in `control.rs` has something truthful to clamp a - // later `session.settings()` against. - settings: advertised_settings(&Http2SettingsState::default()), - preface_done: false, - core_settings_acked: false, - owed_settings_acks: 0, - goaway_opaque: Vec::new(), - peer_settings: None, - pending_controls: Vec::new(), - }, - 0, - ); + insert(H2Conn { + wire: Vec::new(), + deadline: None, + id, + role: Role::Client, + server_handle: 0, + session_handle, + core: None, + input: Vec::with_capacity(16 * 1024), + streams: Vec::new(), + secure: tls.is_some(), + // A client handshake has not started yet — `on_connect` installs the + // session once the socket is up — but the flag has to be set here, + // because `on_data` reads it to decide whether the first bytes are a + // ServerHello or an HTTP/2 preface. + handshaking: tls.is_some(), + connecting: true, + client_tls: tls, + tls_session: None, + alpn: None, + peer_address: String::new(), + peer_port: port, + buffered: 0, + // Node's `maxSessionMemory` default, in bytes. + max_session_memory: 10 * 1024 * 1024, + timer: Timer::None, + draining: false, + closing: false, + read_eof: false, + destroyed: false, + queued_opens: Vec::new(), + allow_http1: false, + // What this connection's core will actually advertise, so + // `clamp_to_core` in `control.rs` has something truthful to clamp a + // later `session.settings()` against. + settings: advertised_settings(&Http2SettingsState::default()), + preface_done: false, + core_settings_acked: false, + owed_settings_acks: 0, + goaway_opaque: Vec::new(), + peer_settings: None, + pending_controls: Vec::new(), + }); // Node sets TCP_NODELAY on an HTTP/2 client socket. if tl::tcp_connect(id, super::SUBSYSTEM, host, port, true).is_err() { forget(id); @@ -776,14 +658,24 @@ fn on_connect(id: i64) { destroy_connection(id); return; }; - if perry_ext_net::turnloop_tls_io::install_client_session( - id, - tls.servername, + let session = perry_ext_net::turnloop_tls::open_client_session( + &tls.servername, tls.verify, client_alpn(), tls.ca, - ) - .is_err() + ); + let output = with_owned(id, |conn| { + let mut session = session.ok()?; + session.pump(); + if session.failure().is_some() { + return None; + } + let output = session.take_output(); + conn.tls_session = Some(session); + Some(output) + }) + .flatten(); + if output.is_none_or(|output| !output.is_empty() && tl::write(id, &output, 0).is_err()) { destroy_connection(id); } @@ -838,13 +730,24 @@ fn on_data(id: i64, bytes: &[u8]) { feed(id, bytes); return; } - let Some(received) = perry_ext_net::turnloop_tls_io::receive(id, bytes) else { - // The layer is gone (the handshake failed and destroyed the - // connection); there is nothing to decode. + let received = with_owned(id, |conn| { + let session = conn.tls_session.as_mut()?; + session.receive(bytes); + let progress = session.pump(); + let failed = session.failure().is_some(); + let output = session.take_output(); + let text = session.take_plaintext(); + Some((progress, failed, output, text, !session.is_handshaking())) + }) + .flatten(); + let Some((progress, failed, output, text, ready)) = received else { return; }; - let text = received.plaintext; - if received.peer_closed { + if failed || (!output.is_empty() && tl::write(id, &output, 0).is_err()) { + destroy_connection(id); + return; + } + if progress.peer_closed { // A TLS close_notify is the readable EOF. if !text.is_empty() { feed(id, &text); @@ -852,9 +755,7 @@ fn on_data(id: i64, bytes: &[u8]) { on_eof(id); return; } - if peek(id, |c| c.handshaking).unwrap_or(false) - && perry_ext_net::turnloop_tls_io::handshake_done(id) - { + if peek(id, |c| c.handshaking).unwrap_or(false) && ready { // `text` is handed over, not dropped. The handshake's last flight and // the peer's first application bytes routinely arrive in one read — a // TLS 1.3 client sends `Finished` and its request back to back — and an @@ -878,8 +779,13 @@ fn on_data(id: i64, bytes: &[u8]) { /// connection leaves it to the caller to feed. /// /// Returns false when the connection is no longer ours. -fn finish_handshake(id: i64, pending: &[u8]) -> bool { - let alpn = perry_ext_net::turnloop_tls_io::alpn_protocol(id); +fn finish_handshake(id: i64, _pending: &[u8]) -> bool { + let alpn = peek(id, |conn| { + conn.tls_session + .as_ref() + .and_then(|session| session.alpn_protocol()) + }) + .flatten(); let decision = with_owned(id, |c| { c.handshaking = false; c.alpn = alpn.clone(); @@ -913,7 +819,7 @@ fn finish_handshake(id: i64, pending: &[u8]) -> bool { true } Some(Handshake::Http1) => { - hand_to_http1(id, pending); + destroy_connection(id); false } Some(Handshake::Refuse) => { @@ -934,26 +840,8 @@ enum Handshake { /// listener. The socket keeps its id, its TLS layer and its outstanding /// multishot read; only the owning table changes, because both halves are the /// same subsystem. That is the whole reason this module shares slot 1. -fn hand_to_http1(id: i64, pending: &[u8]) { - let connection_socket = socket_handle(id); - let Some(conn) = forget(id) else { return }; - let mut leftover = conn.input; - leftover.extend_from_slice(pending); - if !crate::server::turnloop_serve::adopt_alpn_http1( - id, - conn.server_handle, - conn.peer_address, - conn.peer_port, - connection_socket, - leftover, - ) { - let _ = tl::close(id); - return; - } - crate::server::http2_server::mark_turnloop_session_closed(conn.session_handle); -} - -fn feed(id: i64, bytes: &[u8]) { +pub(super) fn feed(id: impl Into, bytes: &[u8]) { + let id = id.into(); let ready = with_owned(id, |conn| { conn.input.extend_from_slice(bytes); conn.core.is_some() && !conn.destroyed @@ -1006,7 +894,8 @@ fn prescan(conn: &mut H2Conn) -> Prescan { Prescan::Pass } -fn pump(id: i64) { +fn pump(id: impl Into) { + let id = id.into(); let outcome = with_owned(id, |conn| { let mut fatal = None; loop { @@ -1123,7 +1012,8 @@ fn apply(conn: &mut H2Conn, event: Owned) { /// Post-pump bookkeeping: cancel a satisfied SETTINGS deadline and close a /// drained connection. -fn settle(id: i64) { +fn settle(id: impl Into) { + let id = id.into(); let action = with_owned(id, |conn| { if conn.timer == Timer::Settings && conn @@ -1132,7 +1022,11 @@ fn settle(id: i64) { .is_some_and(|core| core.next_timeout().is_none()) { conn.timer = Timer::None; - let _ = tl::timer_cancel(conn.id); + if conn.role == Role::Server { + conn.deadline = Some(None); + } else { + let _ = tl::timer_cancel(conn.id); + } } conn.core.as_ref().is_some_and(|core| core.is_drained()) }); @@ -1148,7 +1042,32 @@ fn settle(id: i64) { /// turnloop's `write` copies and queues the whole slice, so a successful /// submission is a complete one and the acknowledgement is unconditional — /// which is what lets `consume_output` take the whole buffer in one step. +fn client_tls_write(conn: &mut H2Conn, bytes: &[u8]) -> Result { + let session = conn.tls_session.as_mut().ok_or("TLS session is closed")?; + session.write(bytes); + session.pump(); + if let Some(failure) = session.failure() { + return Err(failure.to_owned()); + } + let output = session.take_output(); + if !output.is_empty() { + tl::write(conn.id, &output, 0).map_err(|failure| failure.message())?; + } + Ok(bytes.len()) +} + pub(crate) fn flush(conn: &mut H2Conn) { + if conn.role == Role::Server { + if let Some(core) = conn.core.as_mut() { + let bytes = core.output().to_vec(); + if !bytes.is_empty() { + let n = bytes.len(); + conn.wire.push(bytes); + let _ = core.consume_output(n); + } + } + return; + } loop { let bytes = match conn.core.as_ref() { Some(core) => core.output().to_vec(), @@ -1158,7 +1077,7 @@ pub(crate) fn flush(conn: &mut H2Conn) { return; } let written = if conn.secure { - perry_ext_net::turnloop_tls_io::write(conn.id, &bytes, 0) + client_tls_write(conn, &bytes) .map(|_| bytes.len()) .map_err(|_| ()) } else { @@ -1183,7 +1102,8 @@ pub(crate) fn flush(conn: &mut H2Conn) { } } -pub(crate) fn flush_id(id: i64) { +pub(crate) fn flush_id(id: impl Into) { + let id = id.into(); with_owned(id, flush); } @@ -1201,10 +1121,12 @@ pub(crate) fn write_raw(conn: &mut H2Conn, frame: &[u8]) { return; } flush(conn); + if conn.role == Role::Server { + conn.wire.push(frame.to_vec()); + return; + } let written = if conn.secure { - perry_ext_net::turnloop_tls_io::write(conn.id, frame, 0) - .map(|_| ()) - .map_err(|_| ()) + client_tls_write(conn, frame).map(|_| ()).map_err(|_| ()) } else { tl::write(conn.id, frame, 0).map(|_| ()).map_err(|_| ()) }; @@ -1219,7 +1141,8 @@ pub(crate) fn write_raw(conn: &mut H2Conn, frame: &[u8]) { /// A connection-level protocol failure. The GOAWAY is already on the wire; /// every still-open stream now gets exactly one terminal event, and the /// connection closes. -fn fail_connection(id: i64, code: &'static str) { +fn fail_connection(id: impl Into, code: &'static str) { + let id = id.into(); let terminated = with_owned(id, |conn| { if let Some(core) = conn.core.as_mut() { core.eof(); @@ -1248,7 +1171,8 @@ fn fail_connection(id: i64, code: &'static str) { /// Node's `session.close()` and the drained end of `session.goaway()`: the /// GOAWAY has gone, every stream has finished, so end the write side. -pub(crate) fn graceful_close(id: i64) { +pub(crate) fn graceful_close(id: impl Into) { + let id = id.into(); with_owned(id, |conn| { crate::server::http2_server::mark_turnloop_session_closed(conn.session_handle); }); @@ -1256,7 +1180,8 @@ pub(crate) fn graceful_close(id: i64) { } /// `session.destroy()` / a transport error: no GOAWAY, no drain. -pub(crate) fn destroy_connection(id: i64) { +pub(crate) fn destroy_connection(id: impl Into) { + let id = id.into(); let existed = with_owned(id, |conn| { conn.closing = true; conn.destroyed = true; @@ -1272,31 +1197,43 @@ pub(crate) fn destroy_connection(id: i64) { } crate::server::http2_server::mark_turnloop_session_closed(session); } - let _ = tl::timer_cancel(id); - let _ = tl::close(id); + id.cancel_timer(); + id.destroy(); } /// End the write side and close once it has drained. turnloop orders a /// handle's writes ahead of its shutdown, so a completed shutdown means every /// queued byte — the GOAWAY included — left the process. -fn finish_and_close(id: i64) { +fn finish_and_close(id: impl Into) { + let id = id.into(); let secure = with_owned(id, |conn| { conn.closing = true; conn.secure }); - let _ = tl::timer_cancel(id); - match secure { - Some(true) => { - let _ = perry_ext_net::turnloop_tls_io::shutdown(id, 0); - } - Some(false) => { - if tl::shutdown(id, 0).is_err() { - let _ = tl::close(id); + id.cancel_timer(); + match (id, secure) { + (Target::Session(_), Some(_)) => id.shutdown(), + (Target::Client(client), Some(true)) => { + let output = with_owned(client, |conn| { + let session = conn.tls_session.as_mut()?; + session.close_notify(); + session.pump(); + Some(session.take_output()) + }) + .flatten(); + if output + .is_none_or(|output| !output.is_empty() && tl::write(client, &output, 0).is_err()) + || tl::shutdown(client, 0).is_err() + { + id.destroy(); } } - None => { - let _ = tl::close(id); + (Target::Client(client), Some(false)) => { + if tl::shutdown(client, 0).is_err() { + id.destroy(); + } } + (_, None) => id.destroy(), } } @@ -1305,7 +1242,8 @@ fn on_shutdown(id: i64) { let _ = tl::close(id); } -fn on_eof(id: i64) { +pub(super) fn on_eof(id: impl Into) { + let id = id.into(); let already = with_owned(id, |conn| { std::mem::replace(&mut conn.read_eof, true) || conn.closing }); @@ -1342,7 +1280,6 @@ fn on_eof(id: i64) { } fn on_closed(id: i64) { - let connection_socket = socket_handle(id); // `forget` may find nothing — an earlier failed write dropped the record // and left only the ownership entry — and the cleanup below still has to // run, because it is the ownership entry and the id itself that leak. @@ -1362,16 +1299,13 @@ fn on_closed(id: i64) { .unwrap_or_else(|e| e.into_inner()) .remove(&id); } - if connection_socket != 0 { - crate::server::turnloop_serve::note_closed_socket(connection_socket); - } - perry_ext_net::turnloop_tls_io::forget(id); // The terminal completion: no completion can name this id again, so it goes // back to the shared band rather than leaking one per connection. perry_ffi::free_handle_id(id); } -fn on_timer(id: i64) { +pub(super) fn on_timer(id: impl Into) { + let id = id.into(); let expired = with_owned(id, |conn| { if conn.timer != Timer::Settings { return false; @@ -1389,7 +1323,8 @@ fn on_timer(id: i64) { } } -fn on_error(id: i64, code: Option<&str>) { +pub(super) fn on_error(id: impl Into, code: Option<&str>) { + let id = id.into(); let session = peek(id, |conn| conn.session_handle).unwrap_or(0); if session != 0 { crate::server::http2_server::queue_turnloop_session_error( @@ -1414,6 +1349,8 @@ mod prescan_tests { fn conn(owed: u32, core_acked: bool) -> H2Conn { H2Conn { + wire: Vec::new(), + deadline: None, id: 0, role: Role::Server, server_handle: 0, @@ -1427,6 +1364,7 @@ mod prescan_tests { handshaking: false, connecting: false, client_tls: None, + tls_session: None, alpn: None, peer_address: String::new(), peer_port: 0, @@ -1450,34 +1388,21 @@ mod prescan_tests { } #[test] - fn terminal_close_queues_the_announced_socket_once() { + fn terminal_client_close_retires_ownership_after_early_codec_disposal() { for destroyed_early in [false, true] { let id = perry_ffi::reserve_handle_id(); - let socket = crate::server::request::alloc_connection_socket(String::new(), 0); let mut connection = conn(0, false); connection.id = id; - insert(connection, socket); - assert_eq!(connection_id_for_socket(socket), Some(id)); + insert(connection); + assert!(owns(id)); if destroyed_early { with_owned(id, |connection| connection.destroyed = true); assert!(peek(id, |_| ()).is_none()); - assert_eq!(connection_id_for_socket(socket), Some(id)); + assert!(owns(id)); } - on_closed(id); assert!(!owns(id)); - assert_eq!(connection_id_for_socket(socket), None); - assert_eq!( - crate::server::turnloop_serve::take_closed_sockets(), - vec![socket] - ); - assert!(crate::server::turnloop_serve::take_closed_sockets().is_empty()); - crate::server::request::close_incoming_message(socket); - assert!( - perry_ffi::get_handle::(socket) - .is_some_and(|message| message.close_emitted) - ); - perry_ffi::drop_handle(socket); + assert!(peek(id, |_| ()).is_none()); } } diff --git a/crates/perry-ext-http/src/server/turnloop_h2/control.rs b/crates/perry-ext-http/src/server/turnloop_h2/control.rs index f90d0787b4..4ac39400de 100644 --- a/crates/perry-ext-http/src/server/turnloop_h2/control.rs +++ b/crates/perry-ext-http/src/server/turnloop_h2/control.rs @@ -42,6 +42,7 @@ //! `session.settings(obj, cb)`'s callback and Node's `'localSettings'`: the //! core consumes an ack with `event: None`, so there is nothing else to fire on. +use super::target::Target; use turnloop_http::http2::encode_frame; use crate::server::http2_session_settings::Http2SettingsState; @@ -108,9 +109,10 @@ fn settings_payload(role_is_client: bool, settings: &Http2SettingsState) -> Vec< /// Returns the settings that went on the wire, or `None` when this session is /// not on turnloop (the caller then keeps the legacy path). pub(crate) fn send_settings( - conn_id: i64, + conn_id: impl Into, requested: &Http2SettingsState, ) -> Option { + let conn_id = conn_id.into(); conn::with_owned(conn_id, |c| { let effective = clamp_to_core(requested, &c.settings); if !conn::transport_ready(c) { @@ -187,7 +189,13 @@ pub(crate) fn drain_pending(c: &mut H2Conn) { /// no opaque data, so the frame is hand-encoded. The session is **not** marked /// draining: Node's `goaway()` sends a frame and leaves the session usable, /// unlike `close()`. -pub(crate) fn send_goaway(conn_id: i64, code: u32, last_stream_id: u32, opaque: &[u8]) -> bool { +pub(crate) fn send_goaway( + conn_id: impl Into, + code: u32, + last_stream_id: u32, + opaque: &[u8], +) -> bool { + let conn_id = conn_id.into(); conn::with_owned(conn_id, |c| { if !conn::transport_ready(c) { c.pending_controls.push(PendingControl::Goaway { @@ -217,7 +225,8 @@ fn write_goaway(c: &mut H2Conn, code: u32, last_stream_id: u32, opaque: &[u8]) - /// `session.ping(payload, cb)` — a real PING frame. The callback fires from /// `Event::Ping { ack: true }`, not from here. -pub(crate) fn send_ping(conn_id: i64, payload: [u8; 8]) -> bool { +pub(crate) fn send_ping(conn_id: impl Into, payload: [u8; 8]) -> bool { + let conn_id = conn_id.into(); conn::with_owned(conn_id, |c| { if !conn::transport_ready(c) { // Node's `ping()` answers true for a session that is still @@ -238,12 +247,14 @@ pub(crate) fn send_ping(conn_id: i64, payload: [u8; 8]) -> bool { } /// `session.close([cb])` — Node's graceful GOAWAY, then close once drained. -pub(crate) fn session_close(conn_id: i64) { +pub(crate) fn session_close(conn_id: impl Into) { + let conn_id = conn_id.into(); super::stream::session_close(conn_id); } /// `session.destroy()` — no GOAWAY, no drain. -pub(crate) fn session_destroy(conn_id: i64) { +pub(crate) fn session_destroy(conn_id: impl Into) { + let conn_id = conn_id.into(); conn::destroy_connection(conn_id); } diff --git a/crates/perry-ext-http/src/server/turnloop_h2/mod.rs b/crates/perry-ext-http/src/server/turnloop_h2/mod.rs index 77aaf5e2b0..f6882fc889 100644 --- a/crates/perry-ext-http/src/server/turnloop_h2/mod.rs +++ b/crates/perry-ext-http/src/server/turnloop_h2/mod.rs @@ -1,65 +1,15 @@ -//! turnloop HTTP/2: `node:http2` servers and `http2.connect` on turnloop -//! handles, driving `turnloop_http::http2::Connection` sans-I/O. +//! HTTP/2 protocol framing over the agent's own loop. //! -//! # What this replaces +//! Server connections are ordinary net.Socket objects from accept onward. +//! The Socket's ordinary `h2` JS edge owns a separate pure Rust codec payload; +//! the logical HTTP/2 session retains its public Socket. An ALPN handoff to +//! HTTP/1 replaces the codec and stores the new route on the existing core, +//! preserving its cell, driver handle and pending read. //! -//! | hyper / `h2` / tokio | turnloop | -//! |---|---| -//! | `hyper_util::server::conn::auto::Builder` doing ALPN and HTTP/2 framing | `turnloop_http::http2::Connection` (`Role::Server`) over one multishot read | -//! | a `tokio::spawn` accept loop per `http2.createSecureServer` | one multishot `accept_start` | -//! | `h2::client::handshake` on a **private `current_thread` runtime per session** | `turnloop_http::http2::Connection` (`Role::Client`) on the agent's own loop | -//! | a **second** private runtime per `session.request()` | `Connection::open`, on the loop, multiplexed | -//! | `tokio_rustls::TlsAcceptor` | `perry_ext_net::turnloop_tls_io`'s unbuffered session | -//! | an `mpsc` + `oneshot` pair per request | a queue on this thread, because the codec already runs on it | -//! -//! The three CLIENT rows are now *deletions*, not bypasses: `perry-ext-http` -//! has no `h2` manifest edge left, and there is no second HTTP/2 client behind -//! this one. An agent that cannot reach a loop gets an `'error'` from -//! `http2.connect` (`http2_server::session::decline_client_session`), the -//! `perry-ext-ws` rule. The SERVER rows are deletions too: a thread that does -//! not own its agent's loop posts `listen()` to the owner -//! (`turnloop_serve::post_to_owner`), and there is no hyper accept path left. -//! -//! # Why sans-I/O -//! -//! Identical to P5's reason, and it applies to `turnloop_http::asynchronous`'s -//! HTTP/2 driver too: `LocalExecutor::with_config` constructs its **own** -//! `Driver`, and `Shared::dispatch` returns early for any token without its own -//! tag bit, so P1's net, P2's process, P3's timer and P4's pool completions -//! would be **silently dropped** (PerryTS/turnloop#45). Perry already owns one -//! `turnloop::Loop` per agent. The protocol core is the part that replaces -//! hyper and `h2`, and it has no such coupling. -//! -//! # The subsystem slot -//! -//! This module does **not** take a slot of its own. `perry-ext-http` is one -//! linked staticlib with one sink per subsystem, and slot 1 is already its own -//! ([`super::turnloop_serve::SUBSYSTEM`]). Sharing it buys the thing a separate -//! slot would have cost work to get back: an ALPN negotiation that lands on -//! `http/1.1` hands the connection to P5 by **moving one table entry**, with no -//! `turnloop_net::transfer` and no window in which a completion could be -//! misrouted. [`intercept`] is called first from P5's sink and answers "mine" -//! by id. -//! -//! # Ordering, and why JS never runs inside a turn -//! -//! P5's rule, unchanged: the sink runs inside `dispatch_staged`, after a turn -//! has returned. It may allocate Rust state and register handles, but it must -//! **not** call JS. A decoded request is queued and the existing pump -//! (`js_node_http_server_process_pending`) dispatches it on its own tick, -//! exactly where hyper's `mpsc` delivered it; a client-side response, body -//! chunk or session event is queued as an `Http2PendingEvent` and fired by -//! `process_pending_h2_events`, exactly where the `h2` task's `push_h2_event` -//! put it. -//! -//! # GC -//! -//! A connection holds request/response bytes as owned `Vec`s and the -//! *handle ids* of the JS objects it produced. No JS value and no heap pointer -//! reaches the driver, so this module registers no root scanner of its own: the -//! `IncomingMessage` / `ServerResponse` handles are scanned by -//! `scan_http_server_roots` and the queued event callbacks by -//! `scan_h2_pending_event_roots`, both of which already exist. +//! HTTP/2 clients use only their own ids and stay in phase C. Their existing +//! transport table and id sink use slot 18; server codecs use link slot 15. +//! Protocol output is copied out of a server codec before any transport call +//! or JS callback. Decoded requests and session events use the existing pumps. use std::collections::{HashMap, VecDeque}; use std::sync::{Mutex, OnceLock}; @@ -68,19 +18,25 @@ use perry_ffi::turnloop_net as tl; pub(crate) mod conn; pub(crate) mod control; +mod native_server; pub(crate) mod stream; +pub(crate) mod target; #[cfg(test)] #[path = "tests.rs"] mod tests; -pub(crate) use conn::{connect_client, intercept, intercept_listener_error, owns, ClientTls}; +pub(crate) use conn::{connect_client, ClientTls}; +pub(crate) use native_server::{close_listener, listen}; pub(crate) use stream::{ destroy_stream, h2_begin_stream, h2_finish_body, h2_send_body, h2_send_response, }; -/// The completion-sink slot. Shared with P5 on purpose — see the module docs. -pub(crate) const SUBSYSTEM: u8 = super::turnloop_serve::SUBSYSTEM; +/// The phase-C client completion slot; server codecs have a link sink. +/// It is id-routed, so it may sit above the 16 link-route slots, which are +/// all claimed (the HTTP/2 client used to share the HTTP server's slot 1, +/// which is now a link sink). +pub(crate) const SUBSYSTEM: u8 = 16; /// Whether HTTP/2 on turnloop is available to a server or session created /// *now, on this thread*. @@ -88,7 +44,22 @@ pub(crate) const SUBSYSTEM: u8 = super::turnloop_serve::SUBSYSTEM; /// Deliberately not cached, for P5's reason: availability is a property of the /// calling agent, and caching a loop-less agent's "no" would strand the others. pub(crate) fn enabled() -> bool { - super::turnloop_serve::enabled() + static REGISTERED: std::sync::Once = std::sync::Once::new(); + REGISTERED.call_once(|| { + extern "C" fn sink(event: *const tl::NetCompletion) { + if !event.is_null() { + conn::intercept(unsafe { &*event }); + } + } + extern "C" fn alloc() -> i64 { + next_id() + } + assert!( + tl::register_sink(SUBSYSTEM, sink, alloc), + "HTTP2 client sink registration refused" + ); + }); + perry_ext_net::native_transport::enabled() && tl::available(SUBSYSTEM) } /// Ids come from P5's domain: the runtime keys its `Entry` map by this id @@ -98,112 +69,6 @@ pub(crate) fn next_id() -> i64 { super::turnloop_serve::next_id() } -/// A bound turnloop listener serving HTTP/2, and the JS server it belongs to. -pub(crate) struct Listener { - pub(crate) server_handle: i64, - /// `Some` for `http2.createSecureServer`; `None` for `createServer` (h2c, - /// prior knowledge). The config's `alpn_protocols` decide what a secure - /// connection may negotiate. - pub(crate) tls: Option>, - /// Node's `allowHTTP1`. With ALPN `http/1.1`, or a cleartext connection - /// that does not start with the HTTP/2 preface, a false value destroys the - /// connection and a true value hands it to P5's HTTP/1.1 server. - pub(crate) allow_http1: bool, - /// `options.settings` as the server's own SETTINGS, plus the two limits the - /// core derives from them. - pub(crate) settings: crate::server::http2_session_settings::Http2SettingsState, - /// Node's `maxSessionMemory`, in bytes (the option is in MB). The receive - /// window is not reopened past this much buffered, undispatched body. - pub(crate) max_session_memory: usize, -} - -fn listeners() -> &'static Mutex> { - static LISTENERS: OnceLock>> = OnceLock::new(); - LISTENERS.get_or_init(|| Mutex::new(HashMap::new())) -} - -pub(crate) fn with_listener(id: i64, f: impl FnOnce(&Listener) -> R) -> Option { - let map = listeners().lock().unwrap_or_else(|e| e.into_inner()); - map.get(&id).map(f) -} - -pub(crate) fn is_listener(id: i64) -> bool { - listeners() - .lock() - .unwrap_or_else(|e| e.into_inner()) - .contains_key(&id) -} - -/// Bind and start accepting HTTP/2. Returns the listener id, port and host. -/// -/// The bind is synchronous, so `server.address().port` is correct inside the -/// `listen(0, cb)` callback — the same property P5's `listen` preserves. -#[allow(clippy::too_many_arguments)] -pub(crate) fn listen( - server_handle: i64, - host: &str, - port: u16, - backlog: u32, - tls: Option>, - allow_http1: bool, - settings: crate::server::http2_session_settings::Http2SettingsState, - max_session_memory: usize, - reuse_port: bool, - no_delay: bool, -) -> Result<(i64, u16, String), tl::NetError> { - let id = next_id(); - if id == perry_ffi::INVALID_HANDLE { - return Err(tl::error_from_os(None, "listen")); - } - // `reuse_port` is false for every ordinary server: two - // `http2.createServer().listen(p)` calls must race to `EADDRINUSE` the way - // Node's do, not both succeed. It is true for exactly one caller, the - // cluster worker, which shares the port on purpose — turnloop - // 0.1.0-alpha.6's `ReusePort::Share`, which is what - // `cluster_bind::bind_listener` did by hand and is why that worker had to - // decline this path. See `server::turnloop_listen::try_listen_on_turnloop` - // for why `Share` and not `Distribute`. - // - // `no_delay` is the server's own `noDelay` (Node defaults it to true), and - // it reaches the listener rather than being applied per accepted socket: - // `tcp_listen` hands it to the accepting loop, which applies it to every - // connection before the completion reaches the binding. The hyper HTTP/2 - // path does the same thing by hand in `http2_server.rs` - // (`apply_accept_no_delay`) — this is that behaviour on the turnloop path. - tl::tcp_listen(id, SUBSYSTEM, host, port, backlog, reuse_port, no_delay)?; - tl::accept_start(id)?; - let bound = tl::local_address(id); - let bound_port = bound.as_ref().map(|e| e.port).unwrap_or(port); - let bound_host = bound - .as_ref() - .map(|e| e.address.clone()) - .unwrap_or_else(|| host.to_string()); - listeners() - .lock() - .unwrap_or_else(|e| e.into_inner()) - .insert( - id, - Listener { - server_handle, - tls, - allow_http1, - settings, - max_session_memory, - }, - ); - Ok((id, bound_port, bound_host)) -} - -/// `server.close()` — stop accepting. Live sessions finish, which is Node's -/// contract: `server.close()` resolves once every session has closed. -pub(crate) fn close_listener(id: i64) { - listeners() - .lock() - .unwrap_or_else(|e| e.into_inner()) - .remove(&id); - let _ = tl::close(id); -} - // ── The request queue ─────────────────────────────────────────────────────── /// Requests decoded and waiting for the main-thread pump, per JS server handle. diff --git a/crates/perry-ext-http/src/server/turnloop_h2/native_server.rs b/crates/perry-ext-http/src/server/turnloop_h2/native_server.rs new file mode 100644 index 0000000000..5cf111c993 --- /dev/null +++ b/crates/perry-ext-http/src/server/turnloop_h2/native_server.rs @@ -0,0 +1,325 @@ +//! Server HTTP/2 codecs live on the ordinary Socket they parse. ALPN changes +//! that Socket's route and codec; its cell and pending read never change. + +use super::conn::{self, H2Conn, Timer}; +use super::target::Target; +use crate::server::http2_session_settings::Http2SettingsState; +use perry_ext_net::native_transport::{self as net, RootedSocket}; +use perry_ffi::native_payload::{self as np, PayloadFamily}; +use perry_ffi::turnloop_net as tl; +use perry_ffi::{JsThis, JsValue, RawClosureHeader, TransientRootScope}; +use turnloop_http::http2::Role; + +const LINK_SUBSYSTEM: u8 = 15; +static FAMILY_VTABLE: perry_ffi::native_stream::PayloadVTable = perry_ffi::native_stream::payload_vtable::(None); +static FAMILY: PayloadFamily = + PayloadFamily::new::(perry_ffi::native_class_ids::HTTP2_CODEC, "HTTP2Codec", false, &FAMILY_VTABLE) + .with_constructor_length(0); + +fn with_codec(socket: &RootedSocket, f: impl FnOnce(&mut H2Conn) -> R) -> Option { + if !socket.is_current() { + return None; + } + let scope = TransientRootScope::enter(); + let state = scope.root_nanbox(net::state(socket.value())); + let codec = scope.root_nanbox(net::own_get(state.get(), "h2")); + unsafe { np::payload_mut::(codec.get(), &FAMILY).ok().map(f) } +} + +pub(super) fn peek_session(session: i64, f: impl FnOnce(&H2Conn) -> R) -> Option { + let socket = Target::Session(session).socket()?; + with_codec(&socket, |codec| f(codec)) +} + +pub(super) fn with_session(session: i64, f: impl FnOnce(&mut H2Conn) -> R) -> Option { + let socket = Target::Session(session).socket()?; + let (result, wire, deadline) = with_codec(&socket, |codec| { + let result = f(codec); + ( + result, + std::mem::take(&mut codec.wire), + codec.deadline.take(), + ) + })?; + // The borrow ends before writes, TLS processing, GC or event callbacks. + for bytes in wire { + if !socket.is_current() { + return Some(result); + } + if net::write(socket.value(), &bytes, 0).is_err() { + net::destroy(socket.value()); + return Some(result); + } + } + if socket.is_current() { + match deadline { + Some(Some(ms)) => net::deadline_arm(socket.value(), ms), + Some(None) => net::deadline_cancel(socket.value()), + None => (), + } + } + Some(result) +} + +unsafe fn release(owner: f64) { + let socket = RootedSocket::new(owner); + let terminal = with_codec(&socket, |codec| { + codec.destroyed = true; + let streams: Vec<_> = codec.streams.iter().map(|stream| stream.h2_id).collect(); + for stream in streams { + super::stream::terminate(codec, stream, Some("ECONNRESET")); + } + codec.session_handle + }); + if let Some(session) = terminal { + crate::server::http2_server::mark_turnloop_session_closed(session); + } + let scope = TransientRootScope::enter(); + let state = scope.root_nanbox(net::state(socket.value())); + let codec = scope.root_nanbox(net::own_get(state.get(), "h2")); + np::close(codec.get(), &FAMILY); +} + +unsafe extern "C" fn accepted(closure: *const RawClosureHeader, _: JsThis, owner: f64) -> f64 { + let socket = RootedSocket::new(owner); + let server_handle = perry_ffi::closure_capture_f64(closure, 0) as i64; + let Some((settings, allow_http1, secure)) = perry_ffi::get_handle::< + crate::server::http2_server::Http2SecureServer, + >(server_handle) + .map(|server| { + ( + server.settings.clone(), + server.allow_http1, + !server.plaintext, + ) + }) else { + net::destroy(socket.value()); + return undefined(); + }; + let peer = net::endpoint(socket.value(), true); + let session = crate::server::http2_server::register_turnloop_server_session( + server_handle, + socket.value(), + peer.as_ref().map(|peer| peer.port).unwrap_or(0), + secure, + if secure { "h2" } else { "h2c" }, + conn::advertised_settings(&settings), + ); + let scope = TransientRootScope::enter(); + let codec = scope.root_nanbox(unsafe { np::alloc_in(&FAMILY, "", + H2Conn { + id: 0, + wire: Vec::new(), + deadline: None, + role: Role::Server, + server_handle, + session_handle: session, + core: None, + input: Vec::with_capacity(16 * 1024), + streams: Vec::new(), + secure, + handshaking: secure, + connecting: false, + client_tls: None, + tls_session: None, + alpn: None, + peer_address: peer + .as_ref() + .map(|peer| peer.address.clone()) + .unwrap_or_default(), + peer_port: peer.as_ref().map(|peer| peer.port).unwrap_or(0), + buffered: 0, + max_session_memory: 10 * 1024 * 1024, + timer: Timer::None, + draining: false, + closing: false, + read_eof: false, + destroyed: false, + queued_opens: Vec::new(), + allow_http1, + settings, + preface_done: false, + core_settings_acked: false, + owed_settings_acks: 0, + goaway_opaque: Vec::new(), + peer_settings: None, + pending_controls: Vec::new(), + }, + std::mem::size_of::() + 16 * 1024, + &[], + ) }); + net::set_codec(socket.value(), "h2", codec.get(), release); + if net::set_route(socket.value(), LINK_SUBSYSTEM).is_err() { + net::destroy(socket.value()); + return undefined(); + } + if secure { + let callback = scope.root_addr(perry_ffi::alloc_closure( + perry_ffi::js_function_info!(secure_ready, 0; with_flags(perry_ffi::FN_BUILTIN)), + 1, + ) as i64); + perry_ffi::set_closure_capture_f64( + callback.get() as *mut RawClosureHeader, + 0, + session as f64, + ); + net::once( + socket.value(), + "secure", + f64::from_bits(JsValue::from_object_ptr(callback.get() as *mut u8).bits()), + ); + } else { + with_session(session, |codec| { + conn::start_core(codec); + conn::flush(codec); + }); + } + crate::server::server::emit_connection(server_handle, socket.value()); + undefined() +} + +unsafe extern "C" fn secure_ready(closure: *const RawClosureHeader, _: JsThis) -> f64 { + let session = perry_ffi::closure_capture_f64(closure, 0) as i64; + let Some(socket) = Target::Session(session).socket() else { + return undefined(); + }; + let scope = TransientRootScope::enter(); + let state = scope.root_nanbox(net::state(socket.value())); + let alpn = net::own_get(state.get(), "alpnProtocol"); + let alpn = crate::server::types::jsvalue_to_owned_string(alpn); + let choice = with_codec(&socket, |codec| { + codec.handshaking = false; + match alpn.as_deref() { + Some("http/1.1" | "http/1.0") if codec.allow_http1 => { + Some((codec.server_handle, std::mem::take(&mut codec.input))) + } + Some("h2") | None => { + conn::start_core(codec); + None + } + _ => { + codec.destroyed = true; + None + } + } + }); + if let Some(Some((server, leftover))) = choice { + release(socket.value()); + // The HTTP/1 initializer performs the single route store. No read is resubmitted. + crate::server::turnloop_serve::adopt_alpn_http1(socket.value(), server, leftover); + } else if with_codec(&socket, |codec| codec.destroyed).unwrap_or(true) { + net::destroy(socket.value()); + } else { + conn::flush_id(Target::Session(session)); + } + undefined() +} + +extern "C" fn sink(event: *const tl::NetCompletion) { + let Some(event) = (unsafe { event.as_ref() }) else { + return; + }; + let Some(link) = event.link() else { return }; + let Some(owner) = (unsafe { np::link_event_owner(link) }) else { + return; + }; + let socket = RootedSocket::new(owner); + let session = with_codec(&socket, |codec| codec.session_handle); + match event.kind { + tl::NET_DATA => { + let bytes = unsafe { event.bytes() }; + if net::tls_installed(socket.value()) && event.flags & tl::NET_FLAG_PLAINTEXT == 0 { + net::receive_tls(socket.value(), bytes); + } else if let Some(session) = session { + net::received(socket.value(), bytes.len()); + conn::feed(Target::Session(session), bytes); + } + } + tl::NET_EOF => { + if let Some(session) = session { + conn::on_eof(Target::Session(session)); + } + } + tl::NET_TIMER => { + if let Some(session) = session { + conn::on_timer(Target::Session(session)); + } + } + tl::NET_ERROR => { + if let Some(session) = session { + conn::on_error(Target::Session(session), unsafe { event.code() }); + } + } + tl::NET_SHUTDOWN => { + unsafe { + net::dispatch_common(event); + } + if socket.is_current() { + net::destroy(socket.value()); + } + } + _ => unsafe { + net::dispatch_common(event); + }, + } +} + +fn undefined() -> f64 { + f64::from_bits(JsValue::UNDEFINED.bits()) +} + +#[allow(clippy::too_many_arguments)] +pub(crate) fn listen( + server_handle: i64, + host: &str, + port: u16, + backlog: u32, + tls: Option>, + _allow_http1: bool, + _settings: Http2SettingsState, + _max_session_memory: usize, + reuse_port: bool, + no_delay: bool, +) -> Result<(f64, u16, String), tl::NetError> { + static REGISTERED: std::sync::Once = std::sync::Once::new(); + REGISTERED.call_once(|| assert!(tl::register_link_sink(LINK_SUBSYSTEM, sink))); + let scope = TransientRootScope::enter(); + let existing = + crate::server::server::with_base_server(server_handle, |server| server.transport_listener) + .ok_or_else(|| tl::error_from_os(None, "listen"))?; + let listener = scope.root_nanbox(if net::server_link(existing).is_ok() { + existing + } else { + let callback = scope.root_addr(perry_ffi::alloc_closure( + perry_ffi::js_function_info!(accepted, 1; with_flags(perry_ffi::FN_BUILTIN)), + 1, + ) as i64); + unsafe { + perry_ffi::set_closure_capture_f64( + callback.get() as *mut RawClosureHeader, + 0, + server_handle as f64, + ); + } + net::new_server( + undefined(), + f64::from_bits(JsValue::from_object_ptr(callback.get() as *mut u8).bits()), + ) + }); + crate::server::server::with_base_server_mut(server_handle, |server| { + server.transport_listener = listener.get() + }); + let bound = net::listen_tcp( + listener.get(), + host, + port, + backlog, + reuse_port, + no_delay, + tls, + )?; + Ok((listener.get(), bound.port, bound.address)) +} +pub(crate) fn close_listener(listener: f64) { + net::close_server(listener); +} diff --git a/crates/perry-ext-http/src/server/turnloop_h2/stream.rs b/crates/perry-ext-http/src/server/turnloop_h2/stream.rs index 864c7d37df..d0db501781 100644 --- a/crates/perry-ext-http/src/server/turnloop_h2/stream.rs +++ b/crates/perry-ext-http/src/server/turnloop_h2/stream.rs @@ -52,6 +52,7 @@ use std::collections::HashMap; +use super::target::Target; use perry_ffi::turnloop_net as tl; use turnloop_http::http1::Header; use turnloop_http::http2::{HeadersKind, Role}; @@ -323,7 +324,7 @@ fn dispatch_request(conn: &mut H2Conn, i: usize) { let server_handle = conn.server_handle; let peer_address = conn.peer_address.clone(); let peer_port = conn.peer_port; - let conn_id = conn.id; + let conn_id = conn.session_handle; let session_handle = conn.session_handle; let stream = &mut conn.streams[i]; let h2_id = stream.h2_id; @@ -351,6 +352,7 @@ fn dispatch_request(conn: &mut H2Conn, i: usize) { peer_port, ); im.http_version = "2.0".to_string(); + im.socket_value = crate::server::http2_server::socket_of_session(session_handle); let request_handle = crate::server::request::alloc_incoming_message(im); let response_handle = crate::server::response::alloc_server_response_for_turnloop( conn_id, @@ -368,7 +370,7 @@ fn dispatch_request(conn: &mut H2Conn, i: usize) { let stream_handle = if has_stream_listener { crate::server::http2_server::register_turnloop_stream_handle( session_handle, - conn_id, + 0, h2_id as i64, headers_vec.clone(), ) @@ -559,7 +561,8 @@ pub(crate) fn body_forbidden(status: u16, head_request: bool) -> bool { } /// `res.end(body)` on a fully buffered response. -pub(crate) fn h2_send_response(conn_id: i64, h2_id: u32, shape: ResponseShape) { +pub(crate) fn h2_send_response(conn_id: impl Into, h2_id: u32, shape: ResponseShape) { + let conn_id = conn_id.into(); super::conn::with_owned(conn_id, |conn| { let Some(i) = index_of(conn, h2_id) else { return; @@ -600,7 +603,12 @@ pub(crate) fn h2_send_response(conn_id: i64, h2_id: u32, shape: ResponseShape) { } /// `res.flushHeaders()` / the first `res.write(...)`: send the head now. -pub(crate) fn h2_begin_stream(conn_id: i64, h2_id: u32, shape: ResponseShape) -> bool { +pub(crate) fn h2_begin_stream( + conn_id: impl Into, + h2_id: u32, + shape: ResponseShape, +) -> bool { + let conn_id = conn_id.into(); super::conn::with_owned(conn_id, |conn| { let Some(i) = index_of(conn, h2_id) else { return false; @@ -625,7 +633,8 @@ pub(crate) fn h2_begin_stream(conn_id: i64, h2_id: u32, shape: ResponseShape) -> /// A streaming `res.write(chunk)`. `None` means the stream is gone; /// `Some(false)` means the bytes were accepted with backpressure. -pub(crate) fn h2_send_body(conn_id: i64, h2_id: u32, bytes: &[u8]) -> Option { +pub(crate) fn h2_send_body(conn_id: impl Into, h2_id: u32, bytes: &[u8]) -> Option { + let conn_id = conn_id.into(); super::conn::with_owned(conn_id, |conn| { let Some(i) = index_of(conn, h2_id) else { return None; @@ -642,7 +651,12 @@ pub(crate) fn h2_send_body(conn_id: i64, h2_id: u32, bytes: &[u8]) -> Option, + h2_id: u32, + trailers: &[(String, String)], +) { + let conn_id = conn_id.into(); super::conn::with_owned(conn_id, |conn| { let Some(i) = index_of(conn, h2_id) else { return; @@ -655,7 +669,8 @@ pub(crate) fn h2_finish_body(conn_id: i64, h2_id: u32, trailers: &[(String, Stri } /// `res.destroy()` / `stream.close(code)` — one stream, not the connection. -pub(crate) fn destroy_stream(conn_id: i64, h2_id: u32, code: u32) { +pub(crate) fn destroy_stream(conn_id: impl Into, h2_id: u32, code: u32) { + let conn_id = conn_id.into(); super::conn::with_owned(conn_id, |conn| { if index_of(conn, h2_id).is_none() { return; @@ -791,11 +806,18 @@ pub(crate) fn writable_below_watermark(conn: &H2Conn, h2_id: u32) -> bool { let stalled = index_of(conn, h2_id) .map(|i| conn.streams[i].outbox.len()) .unwrap_or(0); - stalled + tl::queued_bytes(conn.id) <= HIGH_WATER_MARK + let staged: usize = conn.wire.iter().map(Vec::len).sum(); + let queued = if conn.role == Role::Server { + staged + } else { + tl::queued_bytes(conn.id) + }; + stalled + queued <= HIGH_WATER_MARK } /// After a stream ends, a connection that was asked to close may now be drained. -fn maybe_drain(conn_id: i64) { +fn maybe_drain(conn_id: impl Into) { + let conn_id = conn_id.into(); let drained = super::conn::peek(conn_id, |conn| { conn.core.as_ref().is_some_and(|core| core.is_drained()) }); @@ -862,11 +884,12 @@ pub(crate) fn open_client_stream(conn: &mut H2Conn, open: QueuedOpen) { /// `session.request(headers)` from JS. Opens immediately when the transport is /// ready — which is what Node does — and queues otherwise. pub(crate) fn request( - conn_id: i64, + conn_id: impl Into, stream_handle: i64, headers: Vec<(String, String)>, body: Vec, ) { + let conn_id = conn_id.into(); super::conn::with_owned(conn_id, |conn| { let open = QueuedOpen { stream_handle, @@ -886,7 +909,8 @@ pub(crate) fn request( } /// `session.close()` — Node's graceful GOAWAY. -pub(crate) fn session_close(conn_id: i64) { +pub(crate) fn session_close(conn_id: impl Into) { + let conn_id = conn_id.into(); let drained = super::conn::with_owned(conn_id, |conn| { if !super::conn::transport_ready(conn) { // A `close()` on a session that is still connecting: the GOAWAY diff --git a/crates/perry-ext-http/src/server/turnloop_h2/target.rs b/crates/perry-ext-http/src/server/turnloop_h2/target.rs new file mode 100644 index 0000000000..c82eebdb8f --- /dev/null +++ b/crates/perry-ext-http/src/server/turnloop_h2/target.rs @@ -0,0 +1,71 @@ +//! Protocol operations name a logical session or a phase-C client transport. +//! A server session's ordinary Socket edge is its connection ownership. + +use perry_ext_net::native_transport::{self as net, RootedSocket}; +use perry_ffi::turnloop_net as tl; + +#[derive(Clone, Copy, Debug)] +pub(crate) enum Target { + Client(i64), + Session(i64), +} +impl From for Target { + fn from(id: i64) -> Self { + Self::Client(id) + } +} +impl Target { + pub(super) fn socket(self) -> Option { + let Self::Session(session) = self else { + return None; + }; + let session = + perry_ffi::get_handle::(session)?; + if session.owner_agent != perry_ffi::agent_post::current_agent() { + return None; + } + let socket = RootedSocket::new(session.socket_value); + session + .socket_incarnation + .as_ref() + .filter(|handle| net::matches(socket.value(), handle))?; + Some(socket) + } + pub(crate) fn is_live(self) -> bool { + match self { + Self::Client(id) => super::conn::peek(id, |codec| !codec.destroyed).unwrap_or(false), + Self::Session(_) => self.socket().is_some(), + } + } + pub(super) fn cancel_timer(self) { + match self { + Self::Client(id) => { + let _ = tl::timer_cancel(id); + } + Self::Session(_) => { + if let Some(socket) = self.socket() { + net::deadline_cancel(socket.value()); + } + } + } + } + pub(super) fn destroy(self) { + match self { + Self::Client(id) => { + let _ = tl::close(id); + } + Self::Session(_) => { + if let Some(socket) = self.socket() { + net::destroy(socket.value()); + } + } + } + } + pub(super) fn shutdown(self) { + if let Some(socket) = self.socket() { + if net::shutdown(socket.value(), 0).is_err() { + net::destroy(socket.value()); + } + } + } +} diff --git a/crates/perry-ext-http/src/server/turnloop_route.rs b/crates/perry-ext-http/src/server/turnloop_route.rs index b891812e96..65f962417e 100644 --- a/crates/perry-ext-http/src/server/turnloop_route.rs +++ b/crates/perry-ext-http/src/server/turnloop_route.rs @@ -13,54 +13,121 @@ //! silently reaching only HTTP/1.1. use crate::server::response::ResponseShape; +use perry_ext_net::native_transport::{self as net, RootedSocket}; -fn is_h2(conn: i64) -> bool { - crate::server::turnloop_h2::owns(conn) +/// HTTP responses retain the ordinary Socket, or their logical HTTP/2 +/// session. Neither variant is a transport id or an id-to-cell alias. +#[derive(Clone, Copy)] +pub(crate) enum ResponseConnection { + Socket { + owner: f64, + incarnation: perry_ffi::turnloop_net::HandleSnapshot, + }, + H2Session(i64), +} +impl ResponseConnection { + pub(crate) fn scan(&mut self, visitor: &mut perry_ffi::GcRootVisitor<'_>) { + if let Self::Socket { owner, .. } = self { + visitor.visit_nanbox_f64_slot(owner); + } + } + pub(crate) fn root(self) -> RootedConnection { + match self { + Self::Socket { owner, incarnation } => RootedConnection::Socket { + owner: RootedSocket::new(owner), + incarnation, + }, + Self::H2Session(session) => RootedConnection::H2Session(session), + } + } +} +pub(crate) enum RootedConnection { + Socket { + owner: RootedSocket, + incarnation: perry_ffi::turnloop_net::HandleSnapshot, + }, + H2Session(i64), +} +impl RootedConnection { + fn socket(&self) -> Option<&RootedSocket> { + match self { + Self::Socket { owner, incarnation } if net::matches(owner.value(), incarnation) => { + Some(owner) + } + _ => None, + } + } + fn h2(&self) -> Option { + match self { + Self::H2Session(session) => { + crate::server::http2_server::turnloop_conn_of_session(*session) + } + _ => None, + } + } +} + +pub(crate) fn destroy(connection: &RootedConnection) { + if let Some(socket) = connection.socket() { + net::destroy(socket.value()) + } else if let Some(connection) = connection.h2() { + crate::server::turnloop_h2::conn::destroy_connection(connection) + } +} +pub(crate) fn is_live(connection: &RootedConnection) -> bool { + connection.socket().is_some() || connection.h2().is_some_and(|target| target.is_live()) +} +pub(crate) fn send_interim(connection: &RootedConnection, seq: u64, bytes: &[u8]) { + if let Some(socket) = connection.socket() { + crate::server::turnloop_serve::send_interim(socket, seq, bytes) + } } /// `res.end(body)` on a fully buffered response. -pub(crate) fn send_response(conn: i64, seq: u64, shape: ResponseShape) { - if is_h2(conn) { - crate::server::turnloop_h2::h2_send_response(conn, seq as u32, shape); - } else { - crate::server::turnloop_serve::send_response(conn, seq, shape); +pub(crate) fn send_response(conn: &RootedConnection, seq: u64, shape: ResponseShape) { + if let Some(h2) = conn.h2() { + crate::server::turnloop_h2::h2_send_response(h2, seq as u32, shape); + } else if let Some(socket) = conn.socket() { + crate::server::turnloop_serve::send_response(socket, seq, shape); } } /// `res.flushHeaders()` / the first `res.write(...)`: send the head now. -pub(crate) fn begin_stream(conn: i64, seq: u64, shape: ResponseShape) -> bool { - if is_h2(conn) { - crate::server::turnloop_h2::h2_begin_stream(conn, seq as u32, shape) +pub(crate) fn begin_stream(conn: &RootedConnection, seq: u64, shape: ResponseShape) -> bool { + if let Some(h2) = conn.h2() { + crate::server::turnloop_h2::h2_begin_stream(h2, seq as u32, shape) } else { - crate::server::turnloop_serve::begin_stream(conn, seq, shape) + conn.socket() + .is_some_and(|socket| crate::server::turnloop_serve::begin_stream(socket, seq, shape)) } } /// A streaming `res.write(chunk)`: accepted with Node's backpressure answer, /// or `None` when the transport no longer owns the response. -pub(crate) fn send_body(conn: i64, seq: u64, bytes: &[u8]) -> Option { - if is_h2(conn) { - crate::server::turnloop_h2::h2_send_body(conn, seq as u32, bytes) +pub(crate) fn send_body(conn: &RootedConnection, seq: u64, bytes: &[u8]) -> Option { + if let Some(h2) = conn.h2() { + crate::server::turnloop_h2::h2_send_body(h2, seq as u32, bytes) } else { - crate::server::turnloop_serve::send_body(conn, seq, bytes) - .then(|| writable_below_watermark(conn, seq)) + conn.socket() + .filter(|socket| crate::server::turnloop_serve::send_body(socket, seq, bytes)) + .map(|_| writable_below_watermark(conn, seq)) } } /// A streaming `res.end()`: close the body framing and finish the stream. -pub(crate) fn finish_body(conn: i64, seq: u64, trailers: &[(String, String)]) { - if is_h2(conn) { - crate::server::turnloop_h2::h2_finish_body(conn, seq as u32, trailers); - } else { - crate::server::turnloop_serve::finish_body(conn, seq, trailers); +pub(crate) fn finish_body(conn: &RootedConnection, seq: u64, trailers: &[(String, String)]) { + if let Some(h2) = conn.h2() { + crate::server::turnloop_h2::h2_finish_body(h2, seq as u32, trailers); + } else if let Some(socket) = conn.socket() { + crate::server::turnloop_serve::finish_body(socket, seq, trailers); } } /// HTTP/2 also buffers bytes above the socket while waiting for peer credit. /// An empty socket queue alone does not mean that stream can emit `drain`. -pub(crate) fn writable_below_watermark(conn: i64, seq: u64) -> bool { - if is_h2(conn) { - crate::server::turnloop_h2::conn::peek(conn, |connection| { +pub(crate) fn writable_below_watermark(conn: &RootedConnection, seq: u64) -> bool { + if let Some(h2) = conn.h2() { + crate::server::turnloop_h2::conn::peek(h2, |connection| { connection.streams.iter().any(|s| s.h2_id == seq as u32) && crate::server::turnloop_h2::stream::writable_below_watermark( connection, seq as u32, @@ -68,6 +135,7 @@ pub(crate) fn writable_below_watermark(conn: i64, seq: u64) -> bool { }) .unwrap_or(false) } else { - perry_ffi::turnloop_net::queued_bytes(conn) <= 16 * 1024 + conn.socket() + .is_some_and(|socket| net::queued_bytes(socket.value()) <= 16 * 1024) } } diff --git a/crates/perry-ext-http/src/server/turnloop_serve/conn.rs b/crates/perry-ext-http/src/server/turnloop_serve/conn.rs index c001cb128d..6de0422a06 100644 --- a/crates/perry-ext-http/src/server/turnloop_serve/conn.rs +++ b/crates/perry-ext-http/src/server/turnloop_serve/conn.rs @@ -15,14 +15,14 @@ use std::collections::{HashMap, VecDeque}; use std::sync::{Mutex, OnceLock}; +use perry_ext_net::native_transport::{self as net, RootedSocket}; +use perry_ffi::native_payload::{self as np, PayloadFamily}; use perry_ffi::turnloop_net as tl; +use perry_ffi::{JsValue, TransientRootScope}; use turnloop_http::http1; use super::wire::{self, Framing}; -use crate::server::request::{ - alloc_connection_socket, alloc_incoming_message, handle_to_pointer_f64, incoming_socket_assign, - IncomingMessage, -}; +use crate::server::request::{alloc_incoming_message, incoming_socket_assign, IncomingMessage}; use crate::server::response::{alloc_http1_server_response_for_turnloop, ResponseShape}; use crate::server::server::{with_base_server, HttpPendingRequest}; @@ -76,15 +76,9 @@ struct Active { } pub(crate) struct Conn { - id: i64, server_handle: i64, peer_address: String, peer_port: u16, - /// The `IncomingMessage`-shaped object - /// handed to `server.on('connection', ...)` and shared as `req.socket` - /// by every request this connection carries. One per connection, built - /// once in `start_connection`/`adopt_alpn_http1`. - socket_handle: i64, decoder: http1::Decoder, input: Vec, building: Option, @@ -118,9 +112,20 @@ pub(crate) struct Conn { websocket: bool, } -fn conns() -> &'static Mutex> { - static CONNS: OnceLock>> = OnceLock::new(); - CONNS.get_or_init(|| Mutex::new(HashMap::new())) +static PARSER_VTABLE: perry_ffi::native_stream::PayloadVTable = perry_ffi::native_stream::payload_vtable::(None); +static PARSER: PayloadFamily = + PayloadFamily::new::(perry_ffi::native_class_ids::HTTP_PARSER, "HTTPParser", false, &PARSER_VTABLE) + .with_constructor_length(0); + +/// Explicit cleanup runs outside SocketFields::Drop. The ordinary `parser` +/// edge remains valid and CLOSED, including for a late socket close listener. +unsafe fn close_parser(owner: f64) { + let socket = RootedSocket::new(owner); + note_aborted(&socket); + let scope = TransientRootScope::enter(); + let state = scope.root_nanbox(net::state(socket.value())); + let parser = scope.root_nanbox(net::own_get(state.get(), "parser")); + np::close(parser.get(), &PARSER); } /// Requests decoded and waiting for the main-thread pump, per JS server handle. @@ -173,31 +178,9 @@ fn take_owned(queue: &mut Vec<(u64, i64)>) -> Vec { mine.into_iter().map(|(_, handle)| handle).collect() } -/// Connection-socket handles (`alloc_connection_socket`) whose TCP connection -/// has fully closed. Same pattern as `aborted()`: the completion sink cannot -/// run JS, so the pump drains this and fires the socket's `'close'` -/// listeners on its own tick. -fn closed_sockets() -> &'static Mutex> { - static CLOSED: OnceLock>> = OnceLock::new(); - CLOSED.get_or_init(|| Mutex::new(Vec::new())) -} - -pub(crate) fn note_closed_socket(socket_handle: i64) { - closed_sockets() - .lock() - .unwrap_or_else(|e| e.into_inner()) - .push((perry_ffi::agent_post::current_agent(), socket_handle)); -} - -/// Take the connection-socket handles due a `'close'` emit. -pub(crate) fn take_closed_sockets() -> Vec { - let mut queue = closed_sockets().lock().unwrap_or_else(|e| e.into_inner()); - take_owned(&mut queue) -} - /// Note that this connection's in-flight request (if any) will never be /// answered, exactly once per request. -fn note_aborted(id: i64) { +fn note_aborted(id: &RootedSocket) { let handle = with_conn(id, |c| { c.active .as_mut() @@ -228,35 +211,31 @@ fn queue_pending(server_handle: i64, request: HttpPendingRequest) { .push_back(request); } -fn with_conn(id: i64, f: impl FnOnce(&mut Conn) -> R) -> Option { - let mut map = conns().lock().unwrap_or_else(|e| e.into_inner()); - map.get_mut(&id).map(f) -} - -/// Every live turnloop connection of one server. -pub(crate) fn connections_of(server_handle: i64) -> Vec { - conns() - .lock() - .unwrap_or_else(|e| e.into_inner()) - .iter() - .filter(|(_, c)| c.server_handle == server_handle) - .map(|(id, _)| *id) - .collect() +/// The parser's owner is an ordinary child of the rooted Socket. `f` may +/// allocate, but must not call JS, close/reopen the Socket, or enter the driver. +fn with_conn(socket: &RootedSocket, f: impl FnOnce(&mut Conn) -> R) -> Option { + if !socket.is_current() { + return None; + } + let scope = TransientRootScope::enter(); + let state = scope.root_nanbox(net::state(socket.value())); + let parser = scope.root_nanbox(net::own_get(state.get(), "parser")); + unsafe { np::payload_mut::(parser.get(), &PARSER).ok().map(f) } } -/// Route a JS accepted-socket handle to its live transport, if this is ours. -pub(crate) fn destroy_socket(socket_handle: i64) -> bool { - let conn_id = conns() - .lock() - .unwrap_or_else(|e| e.into_inner()) - .iter() - .find_map(|(id, conn)| (conn.socket_handle == socket_handle).then_some(*id)); - if let Some(conn_id) = conn_id { - destroy_connection(conn_id); - true - } else { - false - } +/// Visit children through the delegate's existing ordinary ownership graph. +/// Root the complete snapshot before any callback can allocate or close one. +pub(crate) fn with_connections(server_handle: i64, mut visit: impl FnMut(&RootedSocket)) { + let Some(listener) = super::listener_for_server(server_handle) else { + return; + }; + net::for_each_server_child(listener, |socket| { + // Upgraded sockets have released the HTTP parser and belong to the + // upgraded protocol; Node excludes them from closeAllConnections. + if with_conn(socket, |_| ()).is_some() { + visit(socket); + } + }); } /// Whether a connection has a request in flight (`closeIdleConnections`, @@ -270,7 +249,7 @@ pub(crate) fn destroy_socket(socket_handle: i64) -> bool { /// through sending its request (#11586, `test_issue_4971_tls_connect_options` /// — the tokio implementation tracked this as `read_active`, and the turnloop /// port in b77aba634 dropped it). -pub(crate) fn is_busy(id: i64) -> bool { +pub(crate) fn is_busy(id: &RootedSocket) -> bool { with_conn(id, |c| { c.active.is_some() || c.building.is_some() || !c.input.is_empty() }) @@ -283,81 +262,81 @@ pub(crate) extern "C" fn sink(completion: *const tl::NetCompletion) { if completion.is_null() { return; } - // SAFETY: the runtime passes a live completion for the duration of the - // call, which is this function's body. - let c = unsafe { &*completion }; - // HTTP/2 shares this subsystem slot (see `turnloop_h2`'s module docs), and - // answers first by id. A completion it claims never reaches the HTTP/1.1 - // state machine below. - if crate::server::turnloop_h2::intercept(c) { + let event = unsafe { &*completion }; + let Some(link) = event.link() else { return }; + let Some(owner) = (unsafe { np::link_event_owner(link) }) else { + return; + }; + let socket = RootedSocket::new(owner); + if net::socket_link(socket.value()).is_err() { return; } - match c.kind { - tl::NET_ACCEPT => on_accept(c.id, c.conn), - // SAFETY: same call; the pooled lease outlives it. - tl::NET_DATA => on_data(c.id, unsafe { c.bytes() }), - tl::NET_EOF => on_eof(c.id), - tl::NET_WROTE => on_wrote(c.id, c.len), - tl::NET_SHUTDOWN => on_shutdown(c.id), - tl::NET_CLOSED => on_closed(c.id), - tl::NET_TIMER => on_timer(c.id), - tl::NET_ERROR => { - // SAFETY: same call; both point at `'static` string data. - let (code, syscall) = unsafe { (c.code(), c.syscall()) }; - if crate::server::turnloop_h2::intercept_listener_error(c.id, c.terminal != 0) { - return; + match event.kind { + tl::NET_DATA => { + let bytes = unsafe { event.bytes() }; + if event.flags & tl::NET_FLAG_PLAINTEXT == 0 && net::tls_installed(socket.value()) { + net::receive_tls(socket.value(), bytes); + } else { + net::received(socket.value(), bytes.len()); + on_data(&socket, bytes); } - on_error(c.id, code, syscall, c.terminal != 0); } - _ => {} + tl::NET_EOF => on_eof(&socket), + tl::NET_SHUTDOWN => { + on_shutdown(&socket); + unsafe { + net::dispatch_common(event); + } + } + tl::NET_CLOSED => { + if event.flags & tl::NET_FLAG_STALE == 0 { + on_closed(&socket) + } + unsafe { + net::dispatch_common(event); + } + } + tl::NET_TIMER => on_timer(&socket), + tl::NET_ERROR => on_error( + &socket, + unsafe { event.code() }, + unsafe { event.syscall() }, + event.terminal != 0, + ), + _ => unsafe { + net::dispatch_common(event); + }, } } -fn on_accept(listener_id: i64, conn_id: i64) { - if conn_id == 0 { - return; - } - let Some((server_handle, tls, idle_close_ms)) = super::with_listener(listener_id, |l| { - (l.server_handle, l.tls.clone(), l.idle_close_ms) - }) else { - let _ = tl::close(conn_id); - return; - }; - start_connection(conn_id, server_handle, tls, idle_close_ms); +/// An accepted/adopted connection is already an ordinary Socket with its +/// handle installed. No id or fake IncomingMessage socket is allocated. +pub(crate) fn start_connection(owner: f64, server_handle: i64) { + initialize_connection(owner, server_handle, true); } -/// Serve a connection that is already on this thread's loop under `conn_id` -/// — one turnloop accepted from a listener, or one adopted from a descriptor -/// another process accepted (a SCHED_RR cluster worker's, `adopt_connection`). -pub(crate) fn start_connection( - conn_id: i64, - server_handle: i64, - tls: Option>, - idle_close_ms: u64, -) { - let secure = tls.is_some(); - if let Some(config) = tls { - if let Err(_message) = - perry_ext_net::turnloop_tls_io::install_server_session(conn_id, config) - { - let _ = tl::close(conn_id); - return; - } - } - let peer = tl::peer_address(conn_id); - let peer_address = peer.as_ref().map(|e| e.address.clone()).unwrap_or_default(); - let peer_port = peer.as_ref().map(|e| e.port).unwrap_or(0); - let keep_alive_timeout_ms = - with_base_server(server_handle, |s| s.keep_alive_timeout).unwrap_or(5_000.0); - let socket_handle = alloc_connection_socket(peer_address.clone(), peer_port); - conns().lock().unwrap_or_else(|e| e.into_inner()).insert( - conn_id, +fn initialize_connection(owner: f64, server_handle: i64, announce: bool) { + let socket = RootedSocket::new(owner); + let peer_address = net::endpoint(socket.value(), true) + .map(|endpoint| endpoint.address) + .unwrap_or_default(); + let peer_port = net::endpoint(socket.value(), true) + .map(|endpoint| endpoint.port) + .unwrap_or(0); + let (idle_close_ms, keep_alive_timeout_ms) = with_base_server(server_handle, |server| { + ( + crate::server::server::idle_close_ms(server), + server.keep_alive_timeout, + ) + }) + .unwrap_or((0, 5_000.0)); + let secure = net::tls_installed(socket.value()); + let scope = TransientRootScope::enter(); + let parser = scope.root_nanbox(unsafe { np::alloc_in(&PARSER, "", Conn { - id: conn_id, server_handle, peer_address, peer_port, - socket_handle, decoder: http1::Decoder::new(http1::Mode::Request, Default::default()), input: Vec::with_capacity(8 * 1024), building: None, @@ -375,129 +354,52 @@ pub(crate) fn start_connection( handshaking: secure, websocket: false, }, - ); - crate::server::server::queue_turnloop_connection_event(server_handle, socket_handle); - arm_idle(conn_id); - if let Err(_err) = tl::read_start(conn_id) { - destroy_connection(conn_id); + std::mem::size_of::() + 8 * 1024, + &[], + ) }); + unsafe { + net::set_codec(socket.value(), "parser", parser.get(), close_parser); + } + if net::set_route(socket.value(), super::SUBSYSTEM).is_err() { + net::destroy(socket.value()); + return; + } + if announce { + crate::server::server::emit_connection(server_handle, socket.value()); + } + if socket.is_current() { + arm_idle(&socket); } } -/// Adopt a TLS connection whose ALPN chose `http/1.1` from the HTTP/2 listener -/// (`http2.createSecureServer({ allowHTTP1: true })`). -/// -/// The socket keeps its id, its installed TLS layer and its outstanding -/// multishot read: only the owning table changes, because both halves live in -/// the same subsystem slot. `leftover` is whatever plaintext the HTTP/2 side -/// had buffered but not decoded — with ALPN there is normally none, but a -/// client that pipelined its first request into the handshake's last flight -/// would lose it otherwise. -/// -/// Returns false when no `Conn` could be made, in which case the caller closes -/// the socket rather than leaving an orphan. -pub(crate) fn adopt_alpn_http1( - id: i64, - server_handle: i64, - peer_address: String, - peer_port: u16, - socket_handle: i64, - leftover: Vec, -) -> bool { - // `id` is a connection, not a listener, so the idle deadline comes from the - // server the same way P5's own `listen` derived it. - let idle_close_ms = - with_base_server(server_handle, crate::server::server::idle_close_ms).unwrap_or(0); - let keep_alive_timeout_ms = - with_base_server(server_handle, |s| s.keep_alive_timeout).unwrap_or(5_000.0); - let mut input = Vec::with_capacity(8 * 1024); - input.extend_from_slice(&leftover); - // Keep the connection socket already announced by HTTP/2 admission. - conns().lock().unwrap_or_else(|e| e.into_inner()).insert( - id, - Conn { - id, - server_handle, - peer_address, - peer_port, - socket_handle, - decoder: http1::Decoder::new(http1::Mode::Request, Default::default()), - input, - building: None, - active: None, - seq: 0, - requests: 0, - idle_close_ms, - keep_alive_timeout_ms, - paused: false, - read_eof: false, - closing: false, - write_shut: false, - destroyed: false, - secure: true, - // The handshake is already complete: that is what decided ALPN. - handshaking: false, - websocket: false, - }, - ); +/// The ALPN handoff replaces a separate codec on the same rooted Socket. +pub(crate) fn adopt_alpn_http1(socket: f64, server_handle: i64, leftover: Vec) -> bool { + let socket = RootedSocket::new(socket); + initialize_connection(socket.value(), server_handle, false); + with_conn(&socket, |connection| connection.handshaking = false); if !leftover.is_empty() { - decode(id); + feed(&socket, &leftover); } - true + with_conn(&socket, |_| ()).is_some() } -fn on_data(id: i64, bytes: &[u8]) { - // Every read refreshes the idle deadline; the connection is only "idle" - // between a completed response and the next request byte. Park it rather - // than cancel it: cancelling destroys the timer handle, and turnloop - // answers that with a `Cancelled` and a `Closed` — two completions per - // request, both routed nowhere — after which `arm_idle` has to build a - // fresh handle. Parking keeps the handle, so this disarm and the re-arm in - // `complete_response` are both a deadline move, which costs nothing. +fn on_data(id: &RootedSocket, bytes: &[u8]) { park_idle(id); - let plaintext: Option> = if with_conn(id, |c| c.secure).unwrap_or(false) { - match perry_ext_net::turnloop_tls_io::receive(id, bytes) { - Some(received) => { - if received.peer_closed { - // A TLS close_notify is the readable EOF. - let text = received.plaintext; - if !text.is_empty() { - feed(id, &text); - } - on_eof(id); - return; - } - Some(received.plaintext) - } - // The layer is gone (the handshake failed and destroyed the - // connection); there is nothing to decode. - None => return, - } - } else { - None - }; - if with_conn(id, |c| c.handshaking).unwrap_or(false) - && perry_ext_net::turnloop_tls_io::handshake_done(id) - { - with_conn(id, |c| c.handshaking = false); - } - match plaintext { - Some(text) if !text.is_empty() => feed(id, &text), - Some(_) => {} - None => feed(id, bytes), - } + with_conn(id, |connection| connection.handshaking = false); + feed(id, bytes); } /// Is this connection carrying a WebSocket rather than HTTP? -fn is_websocket(id: i64) -> bool { +fn is_websocket(id: &RootedSocket) -> bool { with_conn(id, |c| c.websocket).unwrap_or(false) } -fn feed(id: i64, bytes: &[u8]) { +fn feed(id: &RootedSocket, bytes: &[u8]) { if is_websocket(id) { // Past the 101 these are frames, not HTTP. The connection, its id, its // outstanding multishot read and its TLS layer are all unchanged — only // who decodes the bytes. - perry_ext_ws::turnloop_link::on_data(id, bytes); + perry_ext_ws::native_socket::on_data(id.value(), bytes); return; } let known = with_conn(id, |c| c.input.extend_from_slice(bytes)).is_some(); @@ -512,7 +414,7 @@ fn feed(id: i64, bytes: &[u8]) { /// is only `reset()` once the current response has been written, so a pipelined /// request stays in `input` and is dispatched afterwards. That is Node's /// per-connection serialization, and it is also what makes `res` unambiguous. -fn decode(id: i64) { +fn decode(id: &RootedSocket) { loop { enum Step { Idle, @@ -611,7 +513,7 @@ fn decode(id: i64) { c.requests += 1; c.seq += 1; c.paused = true; - let (request, send_continue) = finish_request(c, building); + let (request, send_continue) = finish_request(id, c, building); Step::Dispatch(request, send_continue) } None => Step::Again, @@ -716,7 +618,11 @@ fn building_from(head: &http1::Head) -> Building { /// Turn a fully decoded request into the `(req, res)` handle pair the pump /// dispatches. -fn finish_request(c: &mut Conn, building: Building) -> (HttpPendingRequest, bool) { +fn finish_request( + socket: &RootedSocket, + c: &mut Conn, + building: Building, +) -> (HttpPendingRequest, bool) { let mut im = IncomingMessage::new( building.method.clone(), building.url.clone(), @@ -735,9 +641,8 @@ fn finish_request(c: &mut Conn, building: Building) -> (HttpPendingRequest, bool // `req.socket === conn` for the // `'connection'` listener's argument — same handle, every request on // this connection. - incoming_socket_assign(im_handle, handle_to_pointer_f64(c.socket_handle)); - let sr_handle = - alloc_http1_server_response_for_turnloop(c.id, c.seq, im_handle, c.socket_handle); + incoming_socket_assign(im_handle, socket.value()); + let sr_handle = alloc_http1_server_response_for_turnloop(socket.value(), c.seq, im_handle); let is_check_continue = building.expects_continue && with_base_server(c.server_handle, |server| { @@ -821,7 +726,7 @@ fn prepare_headers(c: &mut Conn, shape: &mut ResponseShape) -> bool { } /// `res.end()` on a fully buffered response. -pub(crate) fn send_response(conn_id: i64, seq: u64, mut shape: ResponseShape) { +pub(crate) fn send_response(conn_id: &RootedSocket, seq: u64, mut shape: ResponseShape) { let bytes = with_conn(conn_id, |c| { if !owns(c, seq) { return None; @@ -915,7 +820,7 @@ pub(crate) fn send_response(conn_id: i64, seq: u64, mut shape: ResponseShape) { /// 102 was never sent at all. On turnloop nothing is automatic once a /// `'checkContinue'` listener has taken the request over, so the call has to /// reach the wire. -pub(crate) fn send_interim(conn_id: i64, seq: u64, bytes: &[u8]) { +pub(crate) fn send_interim(conn_id: &RootedSocket, seq: u64, bytes: &[u8]) { let ours = with_conn(conn_id, |c| { owns(c, seq) && !(bytes.starts_with(b"HTTP/1.1 100 ") @@ -929,7 +834,7 @@ pub(crate) fn send_interim(conn_id: i64, seq: u64, bytes: &[u8]) { /// `res.flushHeaders()` / the first `res.write(...)`: send the head now and /// stream the body afterwards. -pub(crate) fn begin_stream(conn_id: i64, seq: u64, mut shape: ResponseShape) -> bool { +pub(crate) fn begin_stream(conn_id: &RootedSocket, seq: u64, mut shape: ResponseShape) -> bool { let prepared = with_conn(conn_id, |c| { if !owns(c, seq) { return None; @@ -966,7 +871,7 @@ pub(crate) fn begin_stream(conn_id: i64, seq: u64, mut shape: ResponseShape) -> } /// A streaming `res.write(chunk)`. -pub(crate) fn send_body(conn_id: i64, seq: u64, bytes: &[u8]) -> bool { +pub(crate) fn send_body(conn_id: &RootedSocket, seq: u64, bytes: &[u8]) -> bool { let framed = with_conn(conn_id, |c| { if !owns(c, seq) { return None; @@ -996,7 +901,7 @@ pub(crate) fn send_body(conn_id: i64, seq: u64, bytes: &[u8]) -> bool { } /// A streaming `res.end()`: close the body framing and finish the exchange. -pub(crate) fn finish_body(conn_id: i64, seq: u64, trailers: &[(String, String)]) { +pub(crate) fn finish_body(conn_id: &RootedSocket, seq: u64, trailers: &[(String, String)]) { let framed = with_conn(conn_id, |c| { if !owns(c, seq) { return None; @@ -1025,7 +930,7 @@ pub(crate) fn finish_body(conn_id: i64, seq: u64, trailers: &[(String, String)]) } /// Retire the answered request and decide the connection's fate. -fn complete_response(conn_id: i64, seq: u64, framing: Framing) { +fn complete_response(conn_id: &RootedSocket, seq: u64, framing: Framing) { let decision = with_conn(conn_id, |c| { if !owns(c, seq) { return None; @@ -1058,60 +963,30 @@ fn complete_response(conn_id: i64, seq: u64, framing: Framing) { } /// `res.destroy()` / `socket.destroy()` on the turnloop connection. -pub(crate) fn destroy_connection(conn_id: i64) { +pub(crate) fn destroy_connection(conn_id: &RootedSocket) { note_aborted(conn_id); - let known = with_conn(conn_id, |c| { - c.destroyed = true; - c.closing = true; - }) - .is_some(); - if known { - cancel_idle(conn_id); - let _ = tl::close(conn_id); - } + net::destroy(conn_id.value()); } -/// End the write side and close once it has drained. turnloop orders a -/// handle's writes ahead of its shutdown, so a completed shutdown means every -/// queued byte left — closing outright would cancel them. -fn finish_and_close(conn_id: i64) { +fn finish_and_close(conn_id: &RootedSocket) { cancel_idle(conn_id); - let secure = with_conn(conn_id, |c| { - c.closing = true; - c.secure - }) - .unwrap_or(false); - if secure { - // `close_notify` first, then the FIN, then the close (`on_shutdown`). - if perry_ext_net::turnloop_tls_io::shutdown(conn_id, 0).is_err() { - let _ = tl::close(conn_id); - } - return; - } - if tl::shutdown(conn_id, 0).is_err() { - let _ = tl::close(conn_id); + with_conn(conn_id, |c| c.closing = true); + if net::shutdown(conn_id.value(), 0).is_err() { + net::destroy(conn_id.value()); } } -fn write_raw(conn_id: i64, bytes: &[u8]) { - if bytes.is_empty() { +fn write_raw(conn_id: &RootedSocket, bytes: &[u8]) { + if bytes.is_empty() || !conn_id.is_current() { return; } - let secure = with_conn(conn_id, |c| c.secure).unwrap_or(false); - let result = if secure { - perry_ext_net::turnloop_tls_io::write(conn_id, bytes, 0).map(|_| ()) - } else { - tl::write(conn_id, bytes, 0) - .map(|_| ()) - .map_err(|e| e.message()) - }; - if result.is_err() { - destroy_connection(conn_id); + if net::write(conn_id.value(), bytes, 0).is_err() { + destroy_connection(conn_id) } } /// Answer a malformed request the way Node does: one 400, then close. -fn bad_request(conn_id: i64, _code: &str) { +fn bad_request(conn_id: &RootedSocket, _code: &str) { write_raw( conn_id, b"HTTP/1.1 400 Bad Request\r\nConnection: close\r\nContent-Length: 0\r\n\r\n", @@ -1126,7 +1001,7 @@ fn bad_request(conn_id: i64, _code: &str) { // ── Terminal completions ──────────────────────────────────────────────────── -fn on_eof(id: i64) { +fn on_eof(id: &RootedSocket) { if is_websocket(id) { // An upgraded connection has no request in flight and no response to // finish; `ws` reports a missing close frame as 1006. Our own side is @@ -1140,12 +1015,12 @@ fn on_eof(id: i64) { c.write_shut }) .unwrap_or(false); - if perry_ext_ws::turnloop_link::on_eof(id) { + if perry_ext_ws::native_socket::on_eof(id.value()) { finish_and_close(id); } else if shut { // The close handshake finished and our FIN went out first; this // FIN is the last thing either side sends (see `on_shutdown`). - let _ = tl::close(id); + net::destroy(id.value()); } return; } @@ -1200,7 +1075,7 @@ fn on_eof(id: i64) { /// lets it go when the peer's FIN arrives too, so the close is whichever of /// this and that EOF comes second. Only a shutdown this layer asked for /// (`closing`) counts. -fn on_shutdown(id: i64) { +fn on_shutdown(id: &RootedSocket) { let close = with_conn(id, |c| { if !c.closing { return false; @@ -1210,69 +1085,16 @@ fn on_shutdown(id: i64) { }) .unwrap_or(false); if close { - let _ = tl::close(id); + net::destroy(id.value()); } } -fn on_wrote(_id: i64, _len: usize) { - // Backpressure for `res.write()`'s boolean return is read directly from - // `tl::queued_bytes` at the call site, so a write completion needs no - // bookkeeping here. -} - -fn on_closed(id: i64) { - if is_websocket(id) { - // The ws side has to learn the connection is gone before the id is - // recycled, or a later connection drawing the same id would find a - // stale link — the same class of bug the `turnloop_tls_io::forget` - // comment below records. - perry_ext_ws::turnloop_link::on_closed(id); - } - // A peer that vanished mid-request reaches the terminal `Closed` without - // ever passing through `destroy_connection`. +fn on_closed(id: &RootedSocket) { note_aborted(id); - cancel_idle(id); - let removed = conns() - .lock() - .unwrap_or_else(|e| e.into_inner()) - .remove(&id); - let owned = removed.is_some(); - // The connection is fully gone — queue its - // socket's `'close'` for the pump's next tick. This sink runs inside - // `dispatch_staged` and must not run JS (same rule as `note_aborted` - // above), so `js_node_http_server_process_pending` (via - // `take_closed_sockets`) fires the listeners instead. - if let Some(conn) = removed { - if conn.socket_handle != 0 { - note_closed_socket(conn.socket_handle); - } - } - crate::server::server::turnloop_connection_closed(id); - if owned { - // The rustls session has to go BEFORE the id does. `turnloop_tls_io` - // keys its layer table by connection id, and nothing on this - // subsystem's terminal path was dropping it — perry-ext-net's - // `emit_close_once` is the only caller of `forget`, and that is - // subsystem 0's socket path, not this one. So every HTTPS connection - // left a `Layer` (a rustls session plus its buffers) behind for the - // life of the process, and — worse — once `free_handle_id` handed the - // id back and the next accepted connection drew it, `install_server_ - // session` answered "socket is already TLS" and the connection was - // closed before a byte was read. - // - // Found through the HTTP/2 `allowHTTP1` handoff, which routes an ALPN - // `http/1.1` connection here and then closes it: the next TLS - // connection to that server got EOF, every time. - perry_ext_net::turnloop_tls_io::forget(id); - // The terminal completion: no completion can name this id again, and - // unlike a `net.Socket` id there is no JS object still holding it, so - // it goes back to the shared band instead of leaking one id per - // connection for the life of a server (the #6441 exhaustion class). - perry_ffi::free_handle_id(id); - } + unsafe { close_parser(id.value()) }; } -fn on_timer(id: i64) { +fn on_timer(id: &RootedSocket) { // The idle keep-alive deadline. Node closes the connection; an exchange // that started in the meantime cancelled the deadline already. let idle = with_conn(id, |c| c.active.is_none() && c.building.is_none()).unwrap_or(false); @@ -1281,15 +1103,7 @@ fn on_timer(id: i64) { } } -fn on_error(id: i64, code: Option<&str>, syscall: Option<&str>, terminal: bool) { - if super::with_listener(id, |_| ()).is_some() { - // A transient accept failure does not end the listener, exactly as the - // hyper accept loop kept going on one. - if terminal { - super::close_listener(id); - } - return; - } +fn on_error(id: &RootedSocket, code: Option<&str>, syscall: Option<&str>, terminal: bool) { if is_websocket(id) { let message = code.unwrap_or("WS_ERR_SOCKET"); // Same rule, and the same reason P5 stopped reporting a rustls failure @@ -1297,12 +1111,12 @@ fn on_error(id: i64, code: Option<&str>, syscall: Option<&str>, terminal: bool) // connection this layer has already finished with is teardown noise, // and destroying the handle for it cancels writes that are still going // out. - if perry_ext_ws::turnloop_link::on_error(id, message) { + if perry_ext_ws::native_socket::on_error(id.value(), message) { destroy_connection(id); } return; } - let _ = (code, syscall); + let _ = (code, syscall, terminal); destroy_connection(id); } @@ -1314,22 +1128,22 @@ fn on_error(id: i64, code: Option<&str>, syscall: Option<&str>, terminal: bool) /// `keepAliveTimeout + keepAliveTimeoutBuffer` (defaults 5000 + 1000 ms), and /// `keepAliveTimeout = 0` disables the close entirely while *keeping* /// keep-alive on. Zero here therefore arms nothing. -fn arm_idle(id: i64) { +fn arm_idle(id: &RootedSocket) { let ms = with_conn(id, |c| c.idle_close_ms).unwrap_or(0); if ms == 0 { return; } - let _ = tl::timer_arm(id, super::SUBSYSTEM, ms); + net::deadline_arm(id.value(), ms); } /// Disarm the idle close for the duration of an exchange, keeping the handle. /// Teardown still uses `cancel_idle`: there the handle really is going away. -fn park_idle(id: i64) { - let _ = tl::timer_park(id); +fn park_idle(id: &RootedSocket) { + net::deadline_park(id.value()); } -fn cancel_idle(id: i64) { - let _ = tl::timer_cancel(id); +fn cancel_idle(id: &RootedSocket) { + net::deadline_cancel(id.value()); } // ── WebSocket ─────────────────────────────────────────────────────────────── @@ -1349,7 +1163,7 @@ fn cancel_idle(id: i64) { /// TLS layer all stay exactly as they are, and only the decoder changes. That /// is the shape P5 used for TLS (a session *above* the handle), applied one /// layer up. -fn on_websocket(id: i64, building: Building) { +fn on_websocket(id: &RootedSocket, building: Building) { let Some((server_handle, leftover, secure)) = with_conn(id, |c| { (c.server_handle, std::mem::take(&mut c.input), c.secure) }) else { @@ -1386,7 +1200,8 @@ fn on_websocket(id: i64, building: Building) { c.websocket = true; c.paused = false; }); - let ws_id = perry_ext_ws::turnloop_link::adopt(id, &leftover); + unsafe { close_parser(id.value()) }; + let ws_id = perry_ext_ws::native_socket::adopt(id.value(), &leftover); let mut im = IncomingMessage::new( building.method, @@ -1407,25 +1222,12 @@ fn on_websocket(id: i64, building: Building) { server_handle, request_handle, ws_id, - raw_socket_id: 0, + owner_agent: perry_ffi::agent_post::current_agent(), + raw_socket_value: f64::from_bits(JsValue::UNDEFINED.bits()), head: Vec::new(), }); } -/// Install this crate as `perry-ext-ws`'s turnloop transport. -/// -/// `perry-ext-http` already depends on `perry-ext-ws`, so the reverse would be -/// a cycle; function pointers are the same one-way seam -/// `register_http_address_reader` uses. Both are TLS-transparent because -/// `write_raw` and `destroy_connection` are. -pub(crate) fn register_ws_transport() { - perry_ext_ws::turnloop_link::register_transport(perry_ext_ws::turnloop_link::Transport { - write: |id, bytes| write_raw(id, bytes), - finish: finish_and_close, - destroy: destroy_connection, - }); -} - // ── Upgrade ───────────────────────────────────────────────────────────────── /// Node's `'upgrade'`: hand the whole connection to `net` as a raw @@ -1436,7 +1238,7 @@ pub(crate) fn register_ws_transport() { /// itself is `turnloop_net::transfer`: the id and the outstanding multishot /// read stay exactly as they are and only the completion route changes, so no /// byte can be lost between the two owners and no descriptor moves. -fn on_upgrade(id: i64, building: Building) { +fn on_upgrade(id: &RootedSocket, building: Building) { let (server_handle, head) = match with_conn(id, |c| { c.closing = true; (c.server_handle, std::mem::take(&mut c.input)) @@ -1457,18 +1259,15 @@ fn on_upgrade(id: i64, building: Building) { return; } cancel_idle(id); - if tl::transfer(id, perry_ext_net::TURNLOOP_SUBSYSTEM).is_err() - || !perry_ext_net::adopt_turnloop_upgrade(id) - { + #[cfg(test)] + let sabotage_route = std::env::var("PERRY_NET_A_HTTP_SABOTAGE").as_deref() == Ok("route"); + #[cfg(not(test))] + let sabotage_route = false; + if !sabotage_route && net::set_route(id.value(), net::ROUTE).is_err() { destroy_connection(id); return; } - // The connection is no longer ours: drop our record without closing the - // handle, which now belongs to `net`. - conns() - .lock() - .unwrap_or_else(|e| e.into_inner()) - .remove(&id); + unsafe { close_parser(id.value()) }; let mut im = IncomingMessage::new( building.method, @@ -1481,11 +1280,23 @@ fn on_upgrade(id: i64, building: Building) { ); im.http_version = if building.version == 0 { "1.0" } else { "1.1" }.to_string(); let request_handle = alloc_incoming_message(im); + incoming_socket_assign(request_handle, id.value()); + #[cfg(test)] + let head = if std::env::var("PERRY_NET_A_HTTP_SABOTAGE").as_deref() == Ok("head") { + Vec::new() + } else { + head + }; crate::server::server::queue_turnloop_upgrade(crate::server::server::HttpPendingUpgrade { server_handle, request_handle, ws_id: 0, - raw_socket_id: id, + owner_agent: perry_ffi::agent_post::current_agent(), + raw_socket_value: id.value(), head, }); } + +#[cfg(test)] +#[path = "native_tests.rs"] +mod native_tests; diff --git a/crates/perry-ext-http/src/server/turnloop_serve/mod.rs b/crates/perry-ext-http/src/server/turnloop_serve/mod.rs index 72e010fd2e..8a49e50b20 100644 --- a/crates/perry-ext-http/src/server/turnloop_serve/mod.rs +++ b/crates/perry-ext-http/src/server/turnloop_serve/mod.rs @@ -42,8 +42,9 @@ //! `perry-ext-http`'s existing `scan_http_server_roots`, which is also why a //! request is carried as ids rather than as `f64` closures (#8082). -use std::collections::HashMap; -use std::sync::{Mutex, OnceLock}; +use perry_ext_net::native_transport as net; +use perry_ffi::{JsThis, JsValue, RawClosureHeader, TransientRootScope}; +use std::sync::OnceLock; use perry_ffi::turnloop_net as tl; @@ -57,9 +58,8 @@ mod tests; use perry_ffi::agent_post::{self, AgentJob}; pub(crate) use conn::{ - adopt_alpn_http1, begin_stream, connections_of, destroy_connection, destroy_socket, - finish_body, is_busy, note_aborted_handle, note_closed_socket, send_body, send_interim, - send_response, take_aborted, take_closed_sockets, take_pending, + adopt_alpn_http1, begin_stream, destroy_connection, finish_body, is_busy, note_aborted_handle, + send_body, send_interim, send_response, take_aborted, take_pending, with_connections, }; /// This crate's slot in the runtime's completion-sink registry. @@ -103,12 +103,14 @@ pub(crate) fn enabled() -> bool { // Registration is refused if the runtime's completion layout does not // match this crate's, which leaves `available` false rather than // submitting work nothing can deliver. - tl::register_sink(SUBSYSTEM, conn::sink, alloc_id); + assert!( + tl::register_link_sink(SUBSYSTEM, conn::sink), + "HTTP link sink registration refused" + ); // An attached `WebSocketServer` runs on this connection, so give // perry-ext-ws the writer it needs to reach it (see `conn::on_websocket`). - conn::register_ws_transport(); }); - tl::available(SUBSYSTEM) + net::enabled() && tl::available(SUBSYSTEM) } /// Node's `err.code` for a listen this agent has no loop to run on. @@ -229,55 +231,35 @@ pub(crate) fn adopt_connection(server_handle: i64, socket: tl::AdoptedSocket) { if !enabled() { return; } - let id = next_id(); - if id == perry_ffi::INVALID_HANDLE { - return; - } - if tl::adopt_stream(id, SUBSYSTEM, socket).is_err() { - // `adopt_stream` consumed and closed the descriptor; the id it - // was offered never became a handle, so give it back. - perry_ffi::free_handle_id(id); + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(net::new_socket( + SUBSYSTEM, + f64::from_bits(JsValue::UNDEFINED.bits()), + )); + let link = net::socket_link(owner.get()).expect("new Socket cell"); + let installed = unsafe { + tl::link_adopt_stream( + &mut *net::core(owner.get()).expect("new Socket core"), + link, + socket, + ) + }; + if installed.is_err() { + net::destroy(owner.get()); return; } - let idle_close_ms = crate::server::server::with_base_server( - server_handle, - crate::server::server::idle_close_ms, - ) - .unwrap_or(0); - conn::start_connection(id, server_handle, None, idle_close_ms); + net::adopted(owner.get()); + conn::start_connection(owner.get(), server_handle); + net::flow(owner.get()); })); } -/// Allocate the id for a connection turnloop just accepted. -extern "C" fn alloc_id() -> i64 { - let id = next_id(); - if id == perry_ffi::INVALID_HANDLE { - 0 - } else { - id - } -} - -/// A bound turnloop listener and the JS server it belongs to. -pub(crate) struct Listener { - pub(crate) server_handle: i64, - /// `Some` for `https.createServer` / `http2.createSecureServer`: every - /// accepted connection starts a TLS handshake before any HTTP byte. - pub(crate) tls: Option>, - /// `server.keepAliveTimeout` + `server.keepAliveTimeoutBuffer`, in ms, as - /// the *idle close* deadline. Zero means "never time out" — Node's - /// documented meaning for `keepAliveTimeout = 0`, measured on 26.5.1. - pub(crate) idle_close_ms: u64, -} - -fn listeners() -> &'static Mutex> { - static LISTENERS: OnceLock>> = OnceLock::new(); - LISTENERS.get_or_init(|| Mutex::new(HashMap::new())) -} - -pub(crate) fn with_listener(id: i64, f: impl FnOnce(&Listener) -> R) -> Option { - let map = listeners().lock().unwrap_or_else(|e| e.into_inner()); - map.get(&id).map(f) +/// The listener's connection handler captures the logical application server, +/// not a transport identity. Accepted sockets already own their link cell. +unsafe extern "C" fn accepted(closure: *const RawClosureHeader, _: JsThis, socket: f64) -> f64 { + let server_handle = perry_ffi::closure_capture_f64(closure, 0) as i64; + conn::start_connection(socket, server_handle); + f64::from_bits(JsValue::UNDEFINED.bits()) } /// Bind and start accepting. Returns the listener id and the bound port. @@ -294,73 +276,57 @@ pub(crate) fn listen( reuse_port: bool, no_delay: bool, idle_close_ms: u64, -) -> Result<(i64, u16, String), tl::NetError> { - let id = next_id(); - if id == perry_ffi::INVALID_HANDLE { - return Err(tl::error_from_os(None, "listen")); - } - // `reuse_port` is now an argument, and it is FALSE for every ordinary - // server. It used to be hard-wired false for a reason that has since - // changed, and before that it accidentally received `no_delay` — which - // defaults to true (`http.createServer`'s Node default), so every turnloop - // HTTP and HTTPS listener bound with `SO_REUSEPORT` and a second `listen()` - // on the same port quietly succeeded where Node answers EADDRINUSE. That - // must not come back: a plain server passes `false` and an `EADDRINUSE` - // stays an `EADDRINUSE`. - // - // What changed is the cluster worker. It is the one caller that *wants* - // `SO_REUSEPORT`, and the hard-wired `false` is why it had to decline the - // turnloop path and bind a `std::net::TcpListener` by hand - // (`cluster_bind::bind_listener`, `socket.set_reuse_port(true)`). turnloop - // 0.1.0-alpha.6 split its own `bool` into `ReusePort::{No, Share, - // Distribute}`, and `perry-runtime`'s `listen_opts` maps this `true` to - // `Share` — which is exactly what `bind_listener` does by hand, on every - // platform Perry ships. NOT `Distribute`: that is the kernel-balanced - // variant, it is `Unsupported` on macOS and the non-FreeBSD BSDs, and - // Perry's cluster has never had it. - // - // `no_delay` reaches the option it names. Node's `http.createServer` - // defaults it to true and applies it to every accepted connection; the - // hyper path did that by hand and the turnloop path did not do it at all, - // because this argument was landing in `reuse_port` instead. - tl::tcp_listen(id, SUBSYSTEM, host, port, backlog, reuse_port, no_delay)?; - tl::accept_start(id)?; - let bound = tl::local_address(id); - let bound_port = bound.as_ref().map(|e| e.port).unwrap_or(port); - let bound_host = bound - .as_ref() - .map(|e| e.address.clone()) - .unwrap_or_else(|| host.to_string()); - listeners() - .lock() - .unwrap_or_else(|e| e.into_inner()) - .insert( - id, - Listener { - server_handle, - tls, - idle_close_ms, - }, - ); - Ok((id, bound_port, bound_host)) +) -> Result<(f64, u16, String), tl::NetError> { + let scope = TransientRootScope::enter(); + let existing = + crate::server::server::with_base_server(server_handle, |server| server.transport_listener) + .ok_or_else(|| tl::error_from_os(None, "listen"))?; + let listener = scope.root_nanbox(if net::server_link(existing).is_ok() { + existing + } else { + let callback = scope.root_addr(perry_ffi::alloc_closure( + perry_ffi::js_function_info!(accepted, 1; with_flags(perry_ffi::FN_BUILTIN)), + 1, + ) as i64); + unsafe { + perry_ffi::set_closure_capture_f64( + callback.get() as *mut RawClosureHeader, + 0, + server_handle as f64, + ); + } + net::new_server( + f64::from_bits(JsValue::UNDEFINED.bits()), + f64::from_bits(JsValue::from_object_ptr(callback.get() as *mut u8).bits()), + ) + }); + // Publish the existing logical server's traced edge before init hooks. + crate::server::server::with_base_server_mut(server_handle, |server| { + server.transport_listener = listener.get(); + }) + .ok_or_else(|| tl::error_from_os(None, "listen"))?; + let state = scope.root_nanbox(net::server_state(listener.get())); + net::own_set(state.get(), "httpIdleCloseMs", idle_close_ms as f64); + let bound = net::listen_tcp( + listener.get(), + host, + port, + backlog, + reuse_port, + no_delay, + tls, + )?; + Ok((listener.get(), bound.port, bound.address)) } /// `server.close()` — stop accepting. In-flight connections finish, which is /// Node's contract; `closeAllConnections` is what tears those down. -pub(crate) fn close_listener(id: i64) { - listeners() - .lock() - .unwrap_or_else(|e| e.into_inner()) - .remove(&id); - let _ = tl::close(id); +pub(crate) fn close_listener(listener: f64) { + net::close_server(listener); } -/// Whether any turnloop listener belongs to this JS server handle. -pub(crate) fn listener_for_server(server_handle: i64) -> Option { - listeners() - .lock() - .unwrap_or_else(|e| e.into_inner()) - .iter() - .find(|(_, l)| l.server_handle == server_handle) - .map(|(id, _)| *id) +/// The application's existing traced ownership edge, never a lookup table. +pub(crate) fn listener_for_server(server_handle: i64) -> Option { + crate::server::server::with_base_server(server_handle, |server| server.transport_listener) + .filter(|owner| net::server_link(*owner).is_ok()) } diff --git a/crates/perry-ext-http/src/server/upgrade.rs b/crates/perry-ext-http/src/server/upgrade.rs index 27b97470af..2cdea70fe3 100644 --- a/crates/perry-ext-http/src/server/upgrade.rs +++ b/crates/perry-ext-http/src/server/upgrade.rs @@ -12,8 +12,6 @@ //! //! Attached WebSocket servers are native observers registered by perry-ext-ws. -use perry_ffi::{JsClosure, RawClosureHeader}; - use crate::server::request::handle_to_pointer_f64; use crate::server::server::HttpServer; use crate::server::types::{js_promise_run_microtasks, POINTER_TAG, PTR_MASK}; @@ -29,7 +27,7 @@ fn upgrade_head_arg(head_data: &[u8]) -> f64 { pub(crate) fn fire_upgrade_listeners( server_handle: i64, im_handle: i64, - ws_id: i64, + socket: f64, head_data: Vec, ) -> bool { let listeners = crate::server::server::with_base_server_mut(server_handle, |server| { @@ -43,13 +41,7 @@ pub(crate) fn fire_upgrade_listeners( let listeners = scope.root_addrs(&listeners); let req_f64 = handle_to_pointer_f64(im_handle); - // Encode ws_id as NaN-boxed POINTER_TAG so `unbox_to_i64` (the - // codegen helper used at every NATIVE_MODULE_TABLE receiver - // call site — `wsId.send(...)` / `wsId.on(...)`) extracts the - // low-48 bits as the original ws_id. A plain `ws_id as f64` - // (1.0_f64) would have bits 0x3FF0_…, which `unbox_to_i64` - // AND-masks to 0, missing the WS_CONNECTIONS lookup entirely. - let ws_id_f64 = f64::from_bits(POINTER_TAG | (ws_id as u64 & PTR_MASK)); + let socket = scope.root_nanbox(socket); // Node always supplies a Buffer, including for a zero-length head. Public // `ws` reads `head.length` before deciding whether to call `unshift`, and // upgrade bytes are arbitrary protocol data rather than UTF-8 text. @@ -60,18 +52,13 @@ pub(crate) fn fire_upgrade_listeners( if cb.get() == 0 { continue; } + delivered = true; + perry_ext_net::native_transport::call( + f64::from_bits(perry_ffi::JsValue::from_object_ptr(cb.get() as *mut u8).bits()), + perry_ffi::JsThis::from_f64(handle_to_pointer_f64(server_handle)), + &[req_f64, socket.get(), head_arg.get()], + ); unsafe { - let raw = cb.get() as *const RawClosureHeader; - let closure = JsClosure::from_raw(raw); - if !closure.is_null() { - delivered = true; - let _ = closure.call3( - perry_ffi::JsThis::UNDEFINED, - req_f64, - ws_id_f64, - head_arg.get(), - ); - } js_promise_run_microtasks(); } } diff --git a/crates/perry-ext-http/src/tls_client.rs b/crates/perry-ext-http/src/tls_client.rs index d11cc83cbd..322a824613 100644 --- a/crates/perry-ext-http/src/tls_client.rs +++ b/crates/perry-ext-http/src/tls_client.rs @@ -131,6 +131,23 @@ pub(crate) struct TlsOptions { } impl TlsOptions { + /// Owned identity facts for the Socket which will retain the TLS session + /// when an HTTPS request upgrades to another protocol. + pub(crate) fn client_certificate_pem(&self) -> Result, String> { + let Some((der, passphrase)) = self.client_pfx.first() else { + return Ok(Vec::new()); + }; + let (certificates, _) = pfx_certificate_and_key(der, passphrase)?; + use perry_base64::Engine; + let encoded = perry_base64::engine::general_purpose::STANDARD.encode(&certificates[0]); + let mut pem = b"-----BEGIN CERTIFICATE-----\n".to_vec(); + for line in encoded.as_bytes().chunks(64) { + pem.extend_from_slice(line); + pem.push(b'\n'); + } + pem.extend_from_slice(b"-----END CERTIFICATE-----\n"); + Ok(pem) + } /// Whether these options require a dedicated TLS config instead of the /// shared default. `NODE_TLS_REJECT_UNAUTHORIZED=0` alone counts (it /// disables verification process-wide). diff --git a/crates/perry-ext-ws/Cargo.toml b/crates/perry-ext-ws/Cargo.toml index e1d4acc4aa..eaec0f5212 100644 --- a/crates/perry-ext-ws/Cargo.toml +++ b/crates/perry-ext-ws/Cargo.toml @@ -13,6 +13,7 @@ crate-type = ["staticlib", "rlib"] [dependencies] perry-ffi.workspace = true +perry-ext-net.workspace = true # The protocol, sans-I/O. This is what lets one codec serve every host: a # `turnloop_websocket::Connection` is a state machine over byte slices, so it # runs equally on a connection this crate dialled, one its own listener diff --git a/crates/perry-ext-ws/src/lib.rs b/crates/perry-ext-ws/src/lib.rs index 6495c54989..52cb9f8f6a 100644 --- a/crates/perry-ext-ws/src/lib.rs +++ b/crates/perry-ext-ws/src/lib.rs @@ -57,6 +57,7 @@ pub mod mask; mod server; pub use server::*; mod host_upgrade; +pub mod native_socket; mod turnloop_io; pub mod turnloop_link; pub use host_upgrade::{accept_http_upgrade, drive_http_upgraded, Refusal, HTTP_SERVER_TRANSPORT}; @@ -114,6 +115,12 @@ enum WsTransport { /// [`attach_turnloop_client`] the moment the connection is adopted. Connecting(Vec), Turnloop(i64), + /// The application's traced ownership edge for an attached connection. + Socket { + owner: f64, + agent: u64, + }, + Closed, } struct WsConnection { @@ -236,8 +243,9 @@ extern "C" { /// contributors, the mutable-root scanner, and the handle-property dispatch /// extension that answers dynamic reads on ws handles (#9324). fn ensure_runtime_hooks_registered() { + gc_register_mutable_root_scanner_named("perry-ext-ws", scan_ws_roots); WS_RUNTIME_HOOKS_REGISTERED.call_once(|| { - gc_register_mutable_root_scanner_named("perry-ext-ws", scan_ws_roots); + perry_ffi::agent_post::register_retire_hook(retire_socket_edges); 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); @@ -246,6 +254,20 @@ fn ensure_runtime_hooks_registered() { }); } +extern "C" fn retire_socket_edges(agent: u64) { + let mut connections = WS_CONNECTIONS + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + for connection in connections.values_mut() { + if matches!(connection.transport, WsTransport::Socket { agent: owner, .. } if owner == agent) + { + connection.transport = WsTransport::Closed; + connection.is_open = false; + connection.is_closed = true; + } + } +} + /// Resolve `WebSocketServer.clients` for an UNTYPED receiver (#9324). /// /// #9325/#9335 made `clients` a real `Set`, but only through the statically @@ -276,6 +298,16 @@ pub unsafe extern "C" fn js_ext_ws_handle_property_dispatch( } fn scan_ws_roots(visitor: &mut GcRootVisitor<'_>) { + if let Ok(mut connections) = WS_CONNECTIONS.lock() { + let current = perry_ffi::agent_post::current_agent(); + for connection in connections.values_mut() { + if let WsTransport::Socket { owner, agent } = &mut connection.transport { + if *agent == current { + visitor.visit_nanbox_f64_slot(owner); + } + } + } + } if let Ok(mut per_client) = WS_CLIENT_LISTENERS.lock() { for client in per_client.values_mut() { for cb_vec in client.listeners.values_mut() { @@ -420,6 +452,9 @@ pub(crate) fn connection_closed(ws_id: usize, code: u16, reason: String) { } c.is_open = false; c.is_closed = true; + if matches!(c.transport, WsTransport::Socket { .. }) { + c.transport = WsTransport::Closed; + } } else { return; } @@ -463,6 +498,18 @@ pub(crate) fn allocate_client_id() -> usize { ws_id } +/// An existing logical WS client retains its actual ordinary Socket edge. +/// This is application ownership, with no new connection lookup or alias. +fn attach_socket_client(ws_id: usize, socket: f64) { + if let Some(connection) = WS_CONNECTIONS.lock().unwrap().get_mut(&ws_id) { + connection.transport = WsTransport::Socket { + owner: socket, + agent: perry_ffi::agent_post::current_agent(), + }; + connection.is_open = true; + } +} + /// Bind an allocated id to the connection that now carries it. /// /// Returns whatever `ws.send`/`ws.close` queued while it was connecting, for @@ -475,7 +522,7 @@ pub(crate) fn attach_turnloop_client(ws_id: usize, conn_id: i64) -> Vec queued, - WsTransport::Turnloop(_) => Vec::new(), + WsTransport::Turnloop(_) | WsTransport::Socket { .. } | WsTransport::Closed => Vec::new(), }; connection.is_open = true; queued @@ -517,7 +564,11 @@ pub(crate) fn connection_failed(ws_id: usize, message: &str) { /// re-enter this crate through `connection_error` / `connection_closed`, and /// `std::sync::Mutex` is not reentrant. fn command_on(ws_id: usize, command: WsCommand) { - let conn_id = { + enum Target { + Id(i64), + Socket(f64), + } + let target = { let mut map = WS_CONNECTIONS.lock().unwrap(); match map.get_mut(&ws_id) { Some(connection) => match &mut connection.transport { @@ -525,12 +576,22 @@ fn command_on(ws_id: usize, command: WsCommand) { queued.push(command); return; } - WsTransport::Turnloop(conn_id) => *conn_id, + WsTransport::Turnloop(id) => Target::Id(*id), + WsTransport::Socket { owner, agent } + if *agent == perry_ffi::agent_post::current_agent() => + { + Target::Socket(*owner) + } + WsTransport::Socket { .. } => return, + WsTransport::Closed => return, }, None => return, } }; - replay_command(conn_id, command); + match target { + Target::Id(id) => replay_command(id, command), + Target::Socket(owner) => native_socket::command(owner, command), + } } /// `ws.send(...)`. diff --git a/crates/perry-ext-ws/src/native_socket.rs b/crates/perry-ext-ws/src/native_socket.rs new file mode 100644 index 0000000000..c9d50d46ee --- /dev/null +++ b/crates/perry-ext-ws/src/native_socket.rs @@ -0,0 +1,268 @@ +//! Attached WebSocket protocol on an ordinary net.Socket, from HTTP upgrade. +//! The Socket's `ws` JS edge owns this pure Rust codec. No transport id, link +//! registry, retoken or descriptor handover exists on this path. + +use crate::codec::{Codec, Incoming, Role}; +use perry_ext_net::native_transport::{self as net, RootedSocket}; +use perry_ffi::native_payload::{self as np, PayloadFamily}; +use perry_ffi::turnloop_net as tl; +use perry_ffi::TransientRootScope; + +const LINK_SUBSYSTEM: u8 = 13; +struct SocketCodec { + ws_id: usize, + codec: Codec, + closing: bool, + write_shut: bool, + read_eof: bool, +} +static FAMILY_VTABLE: perry_ffi::native_stream::PayloadVTable = perry_ffi::native_stream::payload_vtable::(None); +static FAMILY: PayloadFamily = + PayloadFamily::new::(perry_ffi::native_class_ids::WS_CODEC, "WebSocketCodec", false, &FAMILY_VTABLE) + .with_constructor_length(0); + +fn with_codec(socket: &RootedSocket, f: impl FnOnce(&mut SocketCodec) -> R) -> Option { + if !socket.is_current() { + return None; + } + let scope = TransientRootScope::enter(); + let state = scope.root_nanbox(net::state(socket.value())); + let codec = scope.root_nanbox(net::own_get(state.get(), "ws")); + unsafe { + np::payload_mut::(codec.get(), &FAMILY) + .ok() + .map(f) + } +} +unsafe fn release(owner: f64) { + let socket = RootedSocket::new(owner); + if let Some((ws_id, code)) = with_codec(&socket, |codec| (codec.ws_id, codec.codec.eof())) { + crate::connection_closed( + ws_id, + code.unwrap_or(crate::codec::CLOSE_ABNORMAL), + String::new(), + ); + } + let scope = TransientRootScope::enter(); + let state = scope.root_nanbox(net::state(socket.value())); + let codec = scope.root_nanbox(net::own_get(state.get(), "ws")); + np::close(codec.get(), &FAMILY); +} + +/// The logical WS id remains its application's identity. Its transport is the +/// existing Socket, and only one route store changes where its read goes. +pub fn adopt(owner: f64, leftover: &[u8]) -> i64 { + static REGISTERED: std::sync::Once = std::sync::Once::new(); + REGISTERED.call_once(|| assert!(tl::register_link_sink(LINK_SUBSYSTEM, sink))); + let socket = RootedSocket::new(owner); + let ws_id = crate::allocate_client_id(); + let scope = TransientRootScope::enter(); + let codec = scope.root_nanbox(unsafe { np::alloc_in(&FAMILY, "", + SocketCodec { + ws_id, + codec: Codec::new(Role::Server), + closing: false, + write_shut: false, + read_eof: false, + }, + std::mem::size_of::(), + &[], + ) }); + unsafe { net::set_codec(socket.value(), "ws", codec.get(), release) }; + crate::attach_socket_client(ws_id, socket.value()); + if net::set_route(socket.value(), LINK_SUBSYSTEM).is_err() { + net::destroy(socket.value()); + return 0; + } + if !leftover.is_empty() { + on_data(socket.value(), leftover) + } + ws_id as i64 +} + +fn finish(socket: &RootedSocket) { + if !socket.is_current() { + return; + } + with_codec(socket, |codec| codec.closing = true); + if net::shutdown(socket.value(), 0).is_err() { + net::destroy(socket.value()) + } +} +fn write(socket: &RootedSocket, bytes: &[u8]) { + if bytes.is_empty() || !socket.is_current() { + return; + } + if net::write(socket.value(), bytes, 0).is_err() { + net::destroy(socket.value()) + } +} + +pub fn on_data(owner: f64, bytes: &[u8]) { + let socket = RootedSocket::new(owner); + let Some((ws_id, events, output, terminal)) = with_codec(&socket, |codec| { + let events = codec.codec.receive(bytes); + ( + codec.ws_id, + events, + codec.codec.take_output(), + codec.codec.is_terminal(), + ) + }) else { + return; + }; + write(&socket, &output); + match events { + Ok(events) => { + let mut closed = None; + for event in events { + if let Incoming::Close(frame) = &event { + closed = Some( + frame + .clone() + .unwrap_or((crate::codec::CLOSE_NO_STATUS, String::new())), + ); + } + crate::emit_incoming(ws_id, event); + } + if let Some((code, reason)) = closed { + crate::connection_closed(ws_id, code, reason); + finish(&socket); + } else if terminal { + crate::connection_closed(ws_id, crate::codec::CLOSE_ABNORMAL, String::new()); + finish(&socket); + } + } + Err(error) => { + crate::connection_error(ws_id, &crate::codec_error_message(&error)); + crate::connection_closed(ws_id, crate::codec::CLOSE_ABNORMAL, String::new()); + finish(&socket); + } + } +} +pub fn on_eof(owner: f64) -> bool { + let socket = RootedSocket::new(owner); + let Some((ws_id, code, shut, already)) = with_codec(&socket, |codec| { + let already = codec.read_eof; + codec.read_eof = true; + (codec.ws_id, codec.codec.eof(), codec.write_shut, already) + }) else { + return false; + }; + if let Some(code) = code { + crate::connection_closed(ws_id, code, String::new()) + } + if shut { + net::destroy(socket.value()) + } else if !already { + finish(&socket) + } + true +} +pub fn on_error(owner: f64, message: &str) -> bool { + let socket = RootedSocket::new(owner); + let Some(ws_id) = with_codec(&socket, |codec| codec.ws_id) else { + return false; + }; + crate::connection_error(ws_id, message); + net::destroy(socket.value()); + true +} + +pub(crate) fn command(owner: f64, command: crate::WsCommand) { + let socket = RootedSocket::new(owner); + if matches!(command, crate::WsCommand::Terminate) { + net::destroy(socket.value()); + return; + } + let Some((ws_id, result, output, deadline)) = with_codec(&socket, |codec| { + let result = match command { + crate::WsCommand::Send(outgoing) => codec.codec.send(outgoing.into_message()), + crate::WsCommand::Close(code, reason) => { + codec.closing = true; + codec.codec.close(code, &reason) + } + crate::WsCommand::Terminate => unreachable!(), + }; + ( + codec.ws_id, + result, + codec.codec.take_output(), + codec.codec.next_timeout(), + ) + }) else { + return; + }; + match result { + Ok(()) => write(&socket, &output), + Err(error) => crate::connection_error(ws_id, &crate::codec_error_message(&error)), + } + if let Some(deadline) = deadline { + net::deadline_arm( + socket.value(), + deadline + .saturating_duration_since(std::time::Instant::now()) + .as_millis() + .max(1) as u64, + ); + } +} + +extern "C" fn sink(event: *const tl::NetCompletion) { + if event.is_null() { + return; + } + let event = unsafe { &*event }; + let Some(link) = event.link() else { return }; + let Some(owner) = (unsafe { np::link_event_owner(link) }) else { + return; + }; + let socket = RootedSocket::new(owner); + match event.kind { + tl::NET_DATA => { + let bytes = unsafe { event.bytes() }; + if event.flags & tl::NET_FLAG_PLAINTEXT == 0 && net::tls_installed(socket.value()) { + net::receive_tls(socket.value(), bytes); + } else { + net::received(socket.value(), bytes.len()); + on_data(socket.value(), bytes); + } + } + tl::NET_EOF => { + on_eof(socket.value()); + } + tl::NET_ERROR => { + on_error( + socket.value(), + unsafe { event.code() }.unwrap_or("WS_ERR_SOCKET"), + ); + } + tl::NET_SHUTDOWN => { + let close = with_codec(&socket, |codec| { + codec.write_shut = true; + codec.read_eof + }) + .unwrap_or(false); + unsafe { + net::dispatch_common(event); + } + if close { + net::destroy(socket.value()) + } + } + tl::NET_TIMER => { + if let Some((ws_id, Some(code))) = with_codec(&socket, |codec| { + ( + codec.ws_id, + codec.codec.handle_timeout(std::time::Instant::now()), + ) + }) { + crate::connection_closed(ws_id, code, String::new()); + net::destroy(socket.value()); + } + } + _ => unsafe { + net::dispatch_common(event); + }, + } +} diff --git a/crates/perry-hir/src/lower/context.rs b/crates/perry-hir/src/lower/context.rs index 92211aa224..cd408154ab 100644 --- a/crates/perry-hir/src/lower/context.rs +++ b/crates/perry-hir/src/lower/context.rs @@ -1414,8 +1414,8 @@ impl LoweringContext { /// `Array.prototype.map.call(e)` worked → `(number).join is not a function`). pub(crate) fn shadow_native_instance_if_present(&mut self, name: &str) { // A pre-scan registered this exact param name as a native instance for - // the callback now being lowered (e.g. `wsId` for `server.on('upgrade', - // (req, wsId, head) => …)`). That tag is the FRESH, intended one — the + // the callback now being lowered (e.g. `res` for a request handler). + // That tag is the FRESH, intended one — the // param IS the native instance — so its own param binding must NOT // tombstone it. One-shot: consume the protection so a later, unrelated // param of the same name still shadows a genuinely stale tag. diff --git a/crates/perry-hir/src/lower/context_new.rs b/crates/perry-hir/src/lower/context_new.rs index 65f094dde2..f535ce3076 100644 --- a/crates/perry-hir/src/lower/context_new.rs +++ b/crates/perry-hir/src/lower/context_new.rs @@ -84,7 +84,6 @@ impl LoweringContext { type_param_scopes: Vec::new(), type_param_constraints: Vec::new(), native_instances: Vec::new(), - param_native_hints: HashMap::new(), current_strict: false, ui_widget_type_aliases: HashMap::new(), deferred_unknown_native_imports: HashMap::new(), diff --git a/crates/perry-hir/src/lower/expr_call/mod.rs b/crates/perry-hir/src/lower/expr_call/mod.rs index 4268c607af..cfc9d8d8d3 100644 --- a/crates/perry-hir/src/lower/expr_call/mod.rs +++ b/crates/perry-hir/src/lower/expr_call/mod.rs @@ -232,8 +232,7 @@ pub(super) fn static_call_prop_name(prop: &ast::MemberProp) -> Option<&str> { /// Issue #1132 — scope the native-instance param tags that /// `lower_call_inner`'s pre-scans register (createServer's -/// `(req, res)`, http.get's `(res)`, fastify's `(req, reply)`, the -/// `'upgrade'` `wsId`, …) to the call expression that owns the +/// `(req, res)`, http.get's `(res)`, fastify's `(req, reply)`, …) to the call expression that owns the /// handler arrow. /// /// Those pre-scans `register_native_instance(...)` BEFORE the handler diff --git a/crates/perry-hir/src/lower/expr_call/prescans.rs b/crates/perry-hir/src/lower/expr_call/prescans.rs index e717e1a2f4..e47e8adf74 100644 --- a/crates/perry-hir/src/lower/expr_call/prescans.rs +++ b/crates/perry-hir/src/lower/expr_call/prescans.rs @@ -12,7 +12,6 @@ use crate::ir::Expr; use crate::lower_patterns::{ pre_scan_fastify_handler_params, pre_scan_node_http_client_callback_params, pre_scan_node_http_client_request_socket_params, pre_scan_node_http_create_server_params, - pre_scan_node_http_upgrade_params, }; use super::super::{try_desugar_reactive_animate, try_desugar_reactive_text, LoweringContext}; @@ -47,7 +46,7 @@ pub(super) fn run_call_prescans( // through the fastify Request/Reply table rows. Protect only when // registration actually happened (it no-ops under a `perry.compilePackages` // override), else a later same-named param would skip tombstoning a - // genuinely stale tag — same gating as the `wsId`/`sock` sites below. + // genuinely stale tag — same gating as the `sock` site below. // // Without this, an untyped `(req, reply) =>` handler lost the Reply tag, // so every `reply.*` lowered to the generic fallback and silently no-op'd: @@ -111,24 +110,6 @@ pub(super) fn run_call_prescans( } } - // Issue #577 Phase 4 — `httpServer.on('upgrade', (req, wsId, head) => …)` - // — register `wsId` as a `("ws", "Client")` native instance BEFORE - // the arrow body is lowered, so `wsId.send(...)` / `wsId.on(...)` / - // `wsId.close()` inside the handler dispatch via the Client-class - // entries in NATIVE_MODULE_TABLE. - if let Some(ws_id_name) = pre_scan_node_http_upgrade_params(ctx, call) { - // The arrow's own `wsId` param binding would otherwise tombstone this - // fresh tag via shadow_native_instance_if_present — protect it so - // `wsId.send`/`.on` inside the handler keep the Client-class dispatch. - // Protect only when registration actually happened (it no-ops under a - // compile-package override), else a later same-named param would skip - // tombstoning a genuinely stale tag. - if ctx.register_native_instance(ws_id_name.clone(), "ws".to_string(), "Client".to_string()) - { - ctx.protect_native_param(ws_id_name); - } - } - // Issue #2211 — `request.on('socket', sock => …)` on a `ClientRequest` // hands the consumer the underlying TCP socket; pre-tag the arrow param // as a `("net", "Socket")` native instance so EventEmitter introspection @@ -137,7 +118,8 @@ pub(super) fn run_call_prescans( // NATIVE_MODULE_TABLE. if let Some(sock_name) = pre_scan_node_http_client_request_socket_params(ctx, call) { // Protect only on successful registration (no-ops under a - // compile-package override) — see the `wsId` site above. + // compile-package override), else a later same-named param would + // skip tombstoning a genuinely stale tag. if ctx.register_native_instance(sock_name.clone(), "net".to_string(), "Socket".to_string()) { ctx.protect_native_param(sock_name); diff --git a/crates/perry-hir/src/lower/expr_call/static_and_instance.rs b/crates/perry-hir/src/lower/expr_call/static_and_instance.rs index f710668585..209344198a 100644 --- a/crates/perry-hir/src/lower/expr_call/static_and_instance.rs +++ b/crates/perry-hir/src/lower/expr_call/static_and_instance.rs @@ -113,7 +113,7 @@ pub(super) fn try_static_method_and_instance( // Scope this guard to the STATIC-class / native-module-static arms // below — NOT to the native-INSTANCE arm (line ~191), which is // itself keyed on a local/param binding via `lookup_native_instance` - // (e.g. an upgrade handler's `wsId.send(...)` parameter) and must + // (e.g. a request handler's `res.end(...)` parameter) and must // still dispatch. // #11157: a per-evaluation class's self-binding is a value too — // `C.m()` inside C's own body must call THIS evaluation's `m` diff --git a/crates/perry-hir/src/lower/expr_member.rs b/crates/perry-hir/src/lower/expr_member.rs index 06cb708020..73f6cf53ba 100644 --- a/crates/perry-hir/src/lower/expr_member.rs +++ b/crates/perry-hir/src/lower/expr_member.rs @@ -1466,6 +1466,21 @@ fn lower_member_inner(ctx: &mut LoweringContext, member: &ast::MemberExpr) -> Re object: Box::new(object_expr), property: property_name, }); + } else if module_name == "net" + && matches!( + class_name.as_str(), + "Socket" | "Server" | "BlockList" | "SocketAddress" + ) + { + // These families are ordinary payload objects. A bare + // member read resolves their prototype, including getters; + // method values must not invoke a zero-argument FFI call. + let object_expr = lower_expr(ctx, &member.obj)?; + return Ok(Expr::PropertyGet { + byte_offset: 0, + object: Box::new(object_expr), + property: property_name, + }); } else if module_name == "net" && ((class_name == "Socket" && is_net_socket_method_name(&property_name)) || (class_name == "Server" && is_net_server_method_name(&property_name))) diff --git a/crates/perry-hir/src/lower/lower_module_fn.rs b/crates/perry-hir/src/lower/lower_module_fn.rs index 1211ff9bf7..ce3c5c8ef4 100644 --- a/crates/perry-hir/src/lower/lower_module_fn.rs +++ b/crates/perry-hir/src/lower/lower_module_fn.rs @@ -975,13 +975,6 @@ pub fn lower_module_full_with_platform_globals( // the member instead of silently lowering to 0. pre_register_module_enums(ast_module, &mut ctx); - // Propagate a native host-handle's class across direct function-call - // boundaries (the `("ws","Client")` upgrade `wsId` handed to a helper) so - // `wsId.send(...)` inside the callee dispatches to the Client runtime - // instead of a silent generic no-op. Must run before any function body is - // lowered so `lower_fn_decl` can tag the receiving parameter. - pre_scan_cross_fn_native_params(ast_module, &mut ctx); - // JSX expressions lower directly to the built-in `jsx`/`jsxs` externs in // `jsx.rs`. Do not synthesize a `react/jsx-runtime` import here: codegen // routes those extern names to Perry's runtime adapter, and making the diff --git a/crates/perry-hir/src/lower/lowering_context.rs b/crates/perry-hir/src/lower/lowering_context.rs index 40e4654b29..9ea4d39ce4 100644 --- a/crates/perry-hir/src/lower/lowering_context.rs +++ b/crates/perry-hir/src/lower/lowering_context.rs @@ -330,20 +330,6 @@ pub struct LoweringContext { /// Native class instances: local_name -> (module_name, class_name) /// Tracks variables that hold instances of native module classes (e.g., EventEmitter) pub(crate) native_instances: Vec<(String, String, String)>, - /// Cross-function native-instance hints: (fn_ident_span_lo, param_index) -> - /// (module_name, class_name). Populated by `pre_scan_cross_fn_native_params` - /// BEFORE any function body is lowered, so `lower_fn_decl` can tag an - /// otherwise-untyped parameter as a native instance when a known native - /// handle (e.g. the `("ws","Client")` upgrade `wsId`) is passed across the - /// call boundary into it. Without this, `wsId.send(...)` inside a helper - /// reached as `handleConnection(req, wsId)` lowers to a generic (silent - /// no-op) dispatch instead of `js_ws_send_client_i64`. - /// - /// Keyed by the function declaration's identifier span (its `lo` byte - /// offset) — a stable AST identity — rather than its name, so a hint seeded - /// for one declaration never leaks onto an unrelated same-named (shadowed or - /// redeclared) function. - pub(crate) param_native_hints: HashMap<(u32, usize), (String, String)>, /// True while lowering code governed by ECMAScript strict mode. pub(crate) current_strict: bool, /// #1483: type-only perry/ui widget import aliases — local_name -> @@ -765,12 +751,11 @@ pub struct LoweringContext { /// index keeps the FIRST pushed index per name (`entry().or_insert`). pub(crate) func_return_native_instances_index: HashMap, /// Param names a pre-scan registered as native instances for the callback - /// it is ABOUT to lower (e.g. the `wsId` of `server.on('upgrade', (req, - /// wsId, head) => …)` tagged `("ws","Client")`). The callback's own param + /// it is ABOUT to lower (e.g. the `res` of a request handler tagged + /// `("http","ServerResponse")`). The callback's own param /// binding then calls `shadow_native_instance_if_present(param)` to tombstone /// stale leaked native tags — but here the tag is the FRESH, intended one, - /// so that shadow would wrongly erase it (the `wsId.send`/`.on` dead-channel - /// bug). One-shot per name: `shadow_native_instance_if_present` consumes the + /// so that shadow would wrongly erase it (the dead-channel bug). One-shot per name: `shadow_native_instance_if_present` consumes the /// entry and skips the tombstone exactly once. /// /// Each entry is anchored to the CALLBACK's param `scope_depth` (one below diff --git a/crates/perry-hir/src/lower/pre_scan.rs b/crates/perry-hir/src/lower/pre_scan.rs index ac5e1ae530..468ef0c921 100644 --- a/crates/perry-hir/src/lower/pre_scan.rs +++ b/crates/perry-hir/src/lower/pre_scan.rs @@ -4,7 +4,6 @@ //! visibility and are re-exported from `lower/mod.rs` so the existing //! `expr_*` submodules and the rest of the crate keep compiling unchanged. -use std::collections::{HashMap, HashSet}; use swc_ecma_ast as ast; use super::*; @@ -100,1522 +99,6 @@ pub(crate) fn pre_scan_mixin_functions(ast_module: &ast::Module, ctx: &mut Lower } } -/// Cross-function native-instance propagation — the `("ws","Client")` upgrade -/// handle delivered to `server.on("upgrade", (req, wsId, head) => …)` only -/// dispatches its `.send()/.on()/.close()` to the `js_ws_*_client_i64` runtime -/// when codegen statically knows the receiver is the upgrade `Client`. That -/// class is tagged at the upgrade callback's parameter, but is NOT propagated -/// when `wsId` is handed to a helper (`handleConnection(req, wsId)`): inside the -/// callee the parameter is plain/`any`, the `class_filter: Some("Client")` row -/// no longer matches, and `wsId.send(...)` silently lowers to a generic no-op — -/// the frame is dropped, no error thrown. -/// -/// This pre-pass runs BEFORE any function body is lowered. It seeds from the -/// HTTP-upgrade idiom (gated on the receiver being a `createServer(...)` result, -/// mirroring the runtime `("http","HttpServer")` check) and follows the handle -/// through subsequent `userFn(…, wsId, …)` calls — transitively — recording a -/// `(callee_name, param_index) -> (module, class)` hint in -/// `ctx.param_native_hints`. `lower_fn_decl` consults the hint to register the -/// otherwise-untyped parameter as a native instance, so the callee's -/// `wsId.send(...)` dispatches to `js_ws_send_client_i64` exactly like the -/// inline call would. -pub(crate) fn pre_scan_cross_fn_native_params(ast_module: &ast::Module, ctx: &mut LoweringContext) { - // Top-level user functions: name -> ordered param names (None marks a - // destructuring/rest param that can't be a simple handle binding), and - // name -> body block to follow the handle into. - let mut fn_params: HashMap>> = HashMap::new(); - let mut fn_bodies: HashMap = HashMap::new(); - // name -> identifier span `lo` (stable AST identity) for keying hints, so a - // hint never leaks onto an unrelated same-named declaration. - let mut fn_spans: HashMap = HashMap::new(); - for item in &ast_module.body { - let fd = match item { - ast::ModuleItem::Stmt(ast::Stmt::Decl(ast::Decl::Fn(fd))) => fd, - ast::ModuleItem::ModuleDecl(ast::ModuleDecl::ExportDecl(e)) => { - if let ast::Decl::Fn(fd) = &e.decl { - fd - } else { - continue; - } - } - _ => continue, - }; - let Some(body) = &fd.function.body else { - continue; - }; - let name = fd.ident.sym.to_string(); - let names: Vec> = fd - .function - .params - .iter() - .map(|p| cross_fn_pat_name(&p.pat)) - // Drop the TypeScript `this:` type-only param exactly as - // `lower_fn_decl` does before enumerating real params. Call sites - // never pass `this` positionally, so keeping it would shift every - // hint/param index by one for `function f(this: T, req, wsId)`. - .filter(|name| name.as_deref() != Some("this")) - .collect(); - fn_spans.insert(name.clone(), fd.ident.span.lo.0); - fn_params.insert(name.clone(), names); - fn_bodies.insert(name, body); - } - if fn_params.is_empty() { - return; - } - - // Idents bound to a `createServer(...)` / `createSecureServer(...)` result - // — the static proxy for a `("http","HttpServer")` native instance. - let mut server_idents: HashSet = HashSet::new(); - collect_server_idents_in_module(ast_module, &mut server_idents); - - // Walk each gated upgrade handler's body with the `wsId` handle tainted, - // recording a hint for every top-level user function the handle flows into. - // The walk is scope-aware (`walk_taint_*`): descending into a nested - // function/arrow drops any name that scope rebinds as a parameter, so a - // shadowing `function later(wsId) {…}` cannot inherit the outer handle — - // while legitimate captures (`wsId.on('message', m => helper(req, wsId))`) - // still propagate. Hints are keyed by callee identity (span) and applied - // after the walk so the immutable `fn_*` tables can stay borrowed throughout. - let tcx = TaintCtx { - fn_params: &fn_params, - fn_bodies: &fn_bodies, - fn_spans: &fn_spans, - }; - let mut all_calls: Vec<&ast::CallExpr> = Vec::new(); - collect_calls_in_module(ast_module, &mut all_calls); - let mut acc = TaintAcc { - tcx: &tcx, - applied: HashSet::new(), - hints: Vec::new(), - ws_fed: HashSet::new(), - deps: HashMap::new(), - source: None, - guard: 0, - }; - for call in &all_calls { - if let Some((ws_id, handler)) = upgrade_handler_ws_id(call, &server_idents) { - let mut taint: Taint = HashMap::new(); - taint.insert(ws_id, ("ws".to_string(), "Client".to_string())); - walk_taint_callback_body(handler, &taint, &mut acc); - } - } - - // Polymorphism guard. A hint tags a function PARAMETER, which fixes the - // dispatch for that parameter across EVERY call site of the function — not - // just the upgrade-fed one. So only keep a hint when the function is - // provably always handed the ws handle at that parameter. If any caller - // passes a non-ws value there (or any caller uses a spread arg, whose - // positional mapping is unreliable), tagging the param would silently - // re-route that caller's `.send()/.on()/.close()` to the Client runtime and - // drop the frame — the exact bug this pass fixes. Detect such functions and - // drop their hints. The caller scan is scope-aware so a nested function that - // SHADOWS a top-level name is not mistaken for a call to the top-level one. - let candidate_keys: HashSet<(u32, usize)> = acc.hints.iter().map(|(k, _)| *k).collect(); - let mut shadowed_call_ids: HashSet = HashSet::new(); - collect_shadowed_callee_calls(ast_module, &fn_spans, &mut shadowed_call_ids); - - let mut invalid: HashSet<(u32, usize)> = HashSet::new(); - for call in &all_calls { - let call_id = *call as *const ast::CallExpr as usize; - // A call whose callee NAME is shadowed by a nearer binding does not - // reach the top-level function — skip it. - if shadowed_call_ids.contains(&call_id) { - continue; - } - let Some(callee) = cross_fn_call_ident(call) else { - continue; - }; - let Some(&span) = fn_spans.get(&callee) else { - continue; - }; - if call.args.iter().any(|a| a.spread.is_some()) { - // Spread args defeat positional matching — invalidate every - // candidate param of this callee. - for key in &candidate_keys { - if key.0 == span { - invalid.insert(*key); - } - } - continue; - } - for i in 0..call.args.len() { - if candidate_keys.contains(&(span, i)) && !acc.ws_fed.contains(&(span, i, call_id)) { - invalid.insert((span, i)); - } - } - } - - // Transitive demotion: a hint reached by following the handle THROUGH an - // upstream param P is only as valid as P. Once P is invalid (polymorphic), - // P can deliver a non-ws value downstream, so demote everything that - // depended on it. Iterate to a fixpoint. - loop { - let mut changed = false; - for (key, deps) in &acc.deps { - if !invalid.contains(key) && deps.iter().any(|d| invalid.contains(d)) { - invalid.insert(*key); - changed = true; - } - } - if !changed { - break; - } - } - - for (key, val) in acc.hints { - if !invalid.contains(&key) { - ctx.param_native_hints.insert(key, val); - } - } -} - -/// Scope-aware pass that records the AST pointer identity of every bare-ident -/// call `name(...)` whose callee `name` is a top-level function (`fn_names`) but -/// is SHADOWED at the call site by a nearer binding (an enclosing -/// function/arrow/method param, or a function declaration hoisted into the -/// enclosing function body). Such a call does NOT reach the top-level function, -/// so the polymorphism guard must not treat it as a caller of it. -/// -/// Conservative by construction: it only records calls it can prove are -/// shadowed. A missed shadow leaves the call counted as a real caller, which can -/// only DROP a hint (safe), never keep an unsound one. -fn collect_shadowed_callee_calls( - ast_module: &ast::Module, - fn_names: &HashMap, - out: &mut HashSet, -) { - // Shadowing names introduced by a function/arrow/method body: its params - // plus the names of function declarations hoisted to the top of that body. - fn body_shadows<'a>( - params: impl Iterator, - stmts: &[ast::Stmt], - base: &HashSet, - ) -> HashSet { - let mut s = base.clone(); - for p in params { - if let Some(n) = cross_fn_pat_name(p) { - s.insert(n); - } - } - for st in stmts { - if let ast::Stmt::Decl(ast::Decl::Fn(f)) = st { - s.insert(f.ident.sym.to_string()); - } - } - s - } - for item in &ast_module.body { - match item { - ast::ModuleItem::Stmt(s) => shadow_scan_stmt(s, &HashSet::new(), fn_names, out), - ast::ModuleItem::ModuleDecl(ast::ModuleDecl::ExportDecl(e)) => match &e.decl { - ast::Decl::Var(v) => { - for d in &v.decls { - if let Some(init) = &d.init { - shadow_scan_expr(init, &HashSet::new(), fn_names, out); - } - } - } - ast::Decl::Fn(f) => { - if let Some(b) = &f.function.body { - let sh = body_shadows( - f.function.params.iter().map(|p| &p.pat), - &b.stmts, - &HashSet::new(), - ); - for st in &b.stmts { - shadow_scan_stmt(st, &sh, fn_names, out); - } - } - } - _ => {} - }, - _ => {} - } - } -} - -fn shadow_scan_stmt( - stmt: &ast::Stmt, - shadowed: &HashSet, - fn_names: &HashMap, - out: &mut HashSet, -) { - macro_rules! e { - ($x:expr) => { - shadow_scan_expr($x, shadowed, fn_names, out) - }; - } - macro_rules! s { - ($x:expr) => { - shadow_scan_stmt($x, shadowed, fn_names, out) - }; - } - match stmt { - ast::Stmt::Expr(x) => e!(&x.expr), - ast::Stmt::Return(r) => { - if let Some(a) = &r.arg { - e!(a); - } - } - ast::Stmt::Decl(ast::Decl::Var(v)) => { - for d in &v.decls { - if let Some(init) = &d.init { - e!(init); - } - } - } - ast::Stmt::Decl(ast::Decl::Fn(f)) => { - if let Some(b) = &f.function.body { - let mut sh = shadowed.clone(); - for p in &f.function.params { - if let Some(n) = cross_fn_pat_name(&p.pat) { - sh.insert(n); - } - } - for st in &b.stmts { - if let ast::Stmt::Decl(ast::Decl::Fn(inner)) = st { - sh.insert(inner.ident.sym.to_string()); - } - } - for st in &b.stmts { - shadow_scan_stmt(st, &sh, fn_names, out); - } - } - } - ast::Stmt::Block(b) => { - for st in &b.stmts { - s!(st); - } - } - ast::Stmt::If(i) => { - e!(&i.test); - s!(&i.cons); - if let Some(alt) = &i.alt { - s!(alt); - } - } - ast::Stmt::While(w) => { - e!(&w.test); - s!(&w.body); - } - ast::Stmt::DoWhile(w) => { - s!(&w.body); - e!(&w.test); - } - ast::Stmt::For(f) => { - if let Some(ast::VarDeclOrExpr::VarDecl(vd)) = &f.init { - for d in &vd.decls { - if let Some(init) = &d.init { - e!(init); - } - } - } else if let Some(ast::VarDeclOrExpr::Expr(x)) = &f.init { - e!(x); - } - if let Some(t) = &f.test { - e!(t); - } - if let Some(u) = &f.update { - e!(u); - } - s!(&f.body); - } - ast::Stmt::ForIn(f) => { - e!(&f.right); - s!(&f.body); - } - ast::Stmt::ForOf(f) => { - e!(&f.right); - s!(&f.body); - } - ast::Stmt::Throw(t) => e!(&t.arg), - ast::Stmt::Try(t) => { - for st in &t.block.stmts { - s!(st); - } - if let Some(h) = &t.handler { - for st in &h.body.stmts { - s!(st); - } - } - if let Some(f) = &t.finalizer { - for st in &f.stmts { - s!(st); - } - } - } - ast::Stmt::Switch(sw) => { - e!(&sw.discriminant); - for c in &sw.cases { - if let Some(t) = &c.test { - e!(t); - } - for st in &c.cons { - s!(st); - } - } - } - ast::Stmt::Labeled(l) => s!(&l.body), - _ => {} - } -} - -fn shadow_scan_expr( - expr: &ast::Expr, - shadowed: &HashSet, - fn_names: &HashMap, - out: &mut HashSet, -) { - macro_rules! e { - ($x:expr) => { - shadow_scan_expr($x, shadowed, fn_names, out) - }; - } - // Walk into a nested function/arrow/method body with its params (and hoisted - // fn-decls) added to the shadow set. - macro_rules! into_body { - ($params:expr, $stmts:expr) => {{ - let mut sh = shadowed.clone(); - for p in $params { - if let Some(n) = cross_fn_pat_name(p) { - sh.insert(n); - } - } - for st in $stmts { - if let ast::Stmt::Decl(ast::Decl::Fn(inner)) = st { - sh.insert(inner.ident.sym.to_string()); - } - } - for st in $stmts { - shadow_scan_stmt(st, &sh, fn_names, out); - } - }}; - } - match expr { - ast::Expr::Call(c) => { - if let ast::Callee::Expr(ce) = &c.callee { - if let ast::Expr::Ident(id) = ce.as_ref() { - let name = id.sym.as_ref(); - if fn_names.contains_key(name) && shadowed.contains(name) { - out.insert(c as *const ast::CallExpr as usize); - } - } - e!(ce); - } - for a in &c.args { - e!(&a.expr); - } - } - ast::Expr::New(n) => { - e!(&n.callee); - if let Some(args) = &n.args { - for a in args { - e!(&a.expr); - } - } - } - ast::Expr::Member(m) => { - e!(&m.obj); - if let ast::MemberProp::Computed(c) = &m.prop { - e!(&c.expr); - } - } - ast::Expr::Bin(b) => { - e!(&b.left); - e!(&b.right); - } - ast::Expr::Unary(u) => e!(&u.arg), - ast::Expr::Update(u) => e!(&u.arg), - ast::Expr::Assign(a) => e!(&a.right), - ast::Expr::Cond(c) => { - e!(&c.test); - e!(&c.cons); - e!(&c.alt); - } - ast::Expr::Paren(p) => e!(&p.expr), - ast::Expr::Seq(s) => { - for x in &s.exprs { - e!(x); - } - } - ast::Expr::Await(a) => e!(&a.arg), - ast::Expr::Yield(y) => { - if let Some(a) = &y.arg { - e!(a); - } - } - ast::Expr::Tpl(t) => { - for x in &t.exprs { - e!(x); - } - } - ast::Expr::TaggedTpl(t) => { - e!(&t.tag); - for x in &t.tpl.exprs { - e!(x); - } - } - ast::Expr::Array(a) => { - for el in a.elems.iter().flatten() { - e!(&el.expr); - } - } - ast::Expr::Object(o) => { - for prop in &o.props { - match prop { - ast::PropOrSpread::Spread(s) => e!(&s.expr), - ast::PropOrSpread::Prop(p) => match p.as_ref() { - ast::Prop::KeyValue(kv) => e!(&kv.value), - ast::Prop::Method(m) => { - if let Some(b) = &m.function.body { - into_body!(m.function.params.iter().map(|p| &p.pat), &b.stmts); - } - } - _ => {} - }, - } - } - } - ast::Expr::Arrow(a) => match a.body.as_ref() { - ast::ArrowFunctionBody::FunctionBody(b) => { - into_body!(a.params.iter(), &b.stmts); - } - ast::ArrowFunctionBody::Expr(x) => { - let mut sh = shadowed.clone(); - for p in &a.params { - if let Some(n) = cross_fn_pat_name(p) { - sh.insert(n); - } - } - shadow_scan_expr(x, &sh, fn_names, out); - } - }, - ast::Expr::Fn(f) => { - if let Some(b) = &f.function.body { - into_body!(f.function.params.iter().map(|p| &p.pat), &b.stmts); - } - } - ast::Expr::OptChain(o) => match o.base.as_ref() { - ast::OptChainBase::Member(m) => { - e!(&m.obj); - if let ast::MemberProp::Computed(c) = &m.prop { - e!(&c.expr); - } - } - ast::OptChainBase::Call(c) => { - e!(&c.callee); - for a in &c.args { - e!(&a.expr); - } - } - }, - ast::Expr::TsAs(t) => e!(&t.expr), - ast::Expr::TsNonNull(t) => e!(&t.expr), - ast::Expr::TsTypeAssertion(t) => e!(&t.expr), - ast::Expr::TsConstAssertion(t) => e!(&t.expr), - ast::Expr::TsInstantiation(t) => e!(&t.expr), - _ => {} - } -} - -/// Immutable top-level-function tables shared by the scope-aware taint walk. -struct TaintCtx<'a> { - fn_params: &'a HashMap>>, - fn_bodies: &'a HashMap, - fn_spans: &'a HashMap, -} - -type Taint = HashMap; - -/// Mutable accumulators + the immutable function tables for the scope-aware -/// taint walk, bundled so the recursive walkers take a single `&mut` and so the -/// "current upstream source" can be saved/restored around a cross-fn descent -/// without threading another argument through every arm. -struct TaintAcc<'a> { - tcx: &'a TaintCtx<'a>, - /// Dedup: a `(fn_span, param_idx, module, class)` hint is pushed / recursed - /// into at most once. - applied: HashSet<(u32, usize, String, String)>, - /// Recorded hints, validated (polymorphism guard + transitive demotion) by - /// the caller after the walk. - hints: Vec<((u32, usize), (String, String))>, - /// Call sites — by AST pointer identity — that passed a ws-tainted handle at - /// `(callee_span, param_idx)`. After the walk, a hinted param whose function - /// ALSO has a non-ws-feeding caller is dropped: tagging the param would - /// mis-route that other caller's `.send()/.on()/.close()` to the Client - /// runtime (a silent no-op for the non-ws value). - ws_fed: HashSet<(u32, usize, usize)>, - /// Validity dependencies: a hint recorded while following the handle THROUGH - /// an upstream param P only holds when P is itself validly tagged. Seed-level - /// hints carry no dep. Drives transitive demotion. - deps: HashMap<(u32, usize), HashSet<(u32, usize)>>, - /// The upstream param the current descent is flowing the handle from (`None` - /// at the upgrade-handler seed level). Set when recursing into a callee body. - source: Option<(u32, usize)>, - guard: usize, -} - -/// Clone `taint`, dropping any name the arrow rebinds as a parameter (shadowing). -fn prune_for_arrow(taint: &Taint, arrow: &ast::ArrowExpr) -> Taint { - let mut t = taint.clone(); - for p in &arrow.params { - if let Some(n) = cross_fn_pat_name(p) { - t.remove(&n); - } - } - t -} - -/// Clone `taint`, dropping any name the function rebinds as a parameter. -fn prune_for_fn(taint: &Taint, func: &ast::Function) -> Taint { - let mut t = taint.clone(); - for p in &func.params { - if let Some(n) = cross_fn_pat_name(&p.pat) { - t.remove(&n); - } - } - t -} - -/// Walk a callback expression's body (arrow/function) under the seed taint. The -/// callback's own params already account for the tainted handle, so no pruning. -fn walk_taint_callback_body(handler: &ast::Expr, taint: &Taint, acc: &mut TaintAcc) { - match handler { - ast::Expr::Arrow(a) => match a.body.as_ref() { - ast::ArrowFunctionBody::FunctionBody(b) => walk_taint_stmts(&b.stmts, taint, acc), - ast::ArrowFunctionBody::Expr(e) => walk_taint_expr(e, taint, acc), - }, - ast::Expr::Fn(f) => { - if let Some(b) = &f.function.body { - walk_taint_stmts(&b.stmts, taint, acc); - } - } - _ => {} - } -} - -fn walk_taint_stmts(stmts: &[ast::Stmt], taint: &Taint, acc: &mut TaintAcc) { - for s in stmts { - walk_taint_stmt(s, taint, acc); - } -} - -fn walk_taint_stmt(stmt: &ast::Stmt, taint: &Taint, acc: &mut TaintAcc) { - macro_rules! e { - ($x:expr) => { - walk_taint_expr($x, taint, acc) - }; - } - macro_rules! s { - ($x:expr) => { - walk_taint_stmt($x, taint, acc) - }; - } - match stmt { - ast::Stmt::Expr(x) => e!(&x.expr), - ast::Stmt::Return(r) => { - if let Some(a) = &r.arg { - e!(a); - } - } - ast::Stmt::Decl(ast::Decl::Var(v)) => { - for d in &v.decls { - if let Some(init) = &d.init { - e!(init); - } - } - } - // A nested function declaration is a new scope — prune its params. - ast::Stmt::Decl(ast::Decl::Fn(f)) => { - if let Some(b) = &f.function.body { - let pruned = prune_for_fn(taint, &f.function); - walk_taint_stmts(&b.stmts, &pruned, acc); - } - } - ast::Stmt::Block(b) => walk_taint_stmts(&b.stmts, taint, acc), - ast::Stmt::If(i) => { - e!(&i.test); - s!(&i.cons); - if let Some(alt) = &i.alt { - s!(alt); - } - } - ast::Stmt::While(w) => { - e!(&w.test); - s!(&w.body); - } - ast::Stmt::DoWhile(w) => { - s!(&w.body); - e!(&w.test); - } - ast::Stmt::For(f) => { - if let Some(ast::VarDeclOrExpr::VarDecl(vd)) = &f.init { - for d in &vd.decls { - if let Some(init) = &d.init { - e!(init); - } - } - } else if let Some(ast::VarDeclOrExpr::Expr(x)) = &f.init { - e!(x); - } - if let Some(t) = &f.test { - e!(t); - } - if let Some(u) = &f.update { - e!(u); - } - s!(&f.body); - } - ast::Stmt::ForIn(f) => { - e!(&f.right); - s!(&f.body); - } - ast::Stmt::ForOf(f) => { - e!(&f.right); - s!(&f.body); - } - ast::Stmt::Throw(t) => e!(&t.arg), - ast::Stmt::Try(t) => { - walk_taint_stmts(&t.block.stmts, taint, acc); - if let Some(h) = &t.handler { - walk_taint_stmts(&h.body.stmts, taint, acc); - } - if let Some(f) = &t.finalizer { - walk_taint_stmts(&f.stmts, taint, acc); - } - } - ast::Stmt::Switch(sw) => { - e!(&sw.discriminant); - for c in &sw.cases { - if let Some(t) = &c.test { - e!(t); - } - walk_taint_stmts(&c.cons, taint, acc); - } - } - ast::Stmt::Labeled(l) => s!(&l.body), - _ => {} - } -} - -fn walk_taint_expr(expr: &ast::Expr, taint: &Taint, acc: &mut TaintAcc) { - acc.guard += 1; - if acc.guard > 200_000 { - return; // pathological-input backstop - } - // Copy the shared function tables out (a `&` is Copy) so reading them below - // doesn't conflict with the `&mut acc` mutations in the call arm. - let tcx = acc.tcx; - macro_rules! e { - ($x:expr) => { - walk_taint_expr($x, taint, acc) - }; - } - match expr { - ast::Expr::Call(c) => { - // Record a cross-fn hint when this is a top-level user-fn call that - // receives a currently-tainted ident argument. - if let Some(callee) = cross_fn_call_ident(c) { - if let (Some(params), Some(&span)) = - (tcx.fn_params.get(&callee), tcx.fn_spans.get(&callee)) - { - for (i, arg) in c.args.iter().enumerate() { - if arg.spread.is_some() { - continue; - } - let ast::Expr::Ident(id) = arg.expr.as_ref() else { - continue; - }; - let Some((module, class)) = taint.get(id.sym.as_ref()) else { - continue; - }; - let Some(Some(param_name)) = params.get(i) else { - continue; - }; - let (module, class) = (module.clone(), class.clone()); - // Record this ws-feeding call site (by AST pointer - // identity) BEFORE the dedup below, so EVERY ws caller is - // captured for the polymorphism guard even when the hint - // itself was already recorded from another seed. - let call_id = c as *const ast::CallExpr as usize; - acc.ws_fed.insert((span, i, call_id)); - // This hint only holds if the upstream param we are - // flowing through is itself validly tagged. - if let Some(src) = acc.source { - acc.deps.entry((span, i)).or_default().insert(src); - } - if !acc.applied.insert((span, i, module.clone(), class.clone())) { - continue; - } - acc.hints.push(((span, i), (module.clone(), class.clone()))); - // Follow the handle into the callee body with a FRESH - // scoped taint binding only its receiving parameter, and - // mark this callee param as the upstream source so any - // deeper hint records its dependency on us. - if let Some(body) = tcx.fn_bodies.get(&callee) { - let mut fresh: Taint = HashMap::new(); - fresh.insert(param_name.clone(), (module, class)); - let prev = acc.source; - acc.source = Some((span, i)); - walk_taint_stmts(&body.stmts, &fresh, acc); - acc.source = prev; - } - } - } - } - // Descend into the callee + args (this scope; nested closures inside - // an arg get pruned by their own arms below). - if let ast::Callee::Expr(ce) = &c.callee { - e!(ce); - } - for arg in &c.args { - e!(&arg.expr); - } - } - ast::Expr::New(n) => { - e!(&n.callee); - if let Some(args) = &n.args { - for a in args { - e!(&a.expr); - } - } - } - ast::Expr::Member(m) => { - e!(&m.obj); - if let ast::MemberProp::Computed(c) = &m.prop { - e!(&c.expr); - } - } - ast::Expr::Bin(b) => { - e!(&b.left); - e!(&b.right); - } - ast::Expr::Unary(u) => e!(&u.arg), - ast::Expr::Update(u) => e!(&u.arg), - ast::Expr::Assign(a) => e!(&a.right), - ast::Expr::Cond(c) => { - e!(&c.test); - e!(&c.cons); - e!(&c.alt); - } - ast::Expr::Paren(p) => e!(&p.expr), - ast::Expr::Seq(s) => { - for x in &s.exprs { - e!(x); - } - } - ast::Expr::Await(a) => e!(&a.arg), - ast::Expr::Yield(y) => { - if let Some(a) = &y.arg { - e!(a); - } - } - ast::Expr::Tpl(t) => { - for x in &t.exprs { - e!(x); - } - } - ast::Expr::TaggedTpl(t) => { - e!(&t.tag); - for x in &t.tpl.exprs { - e!(x); - } - } - ast::Expr::Array(a) => { - for el in a.elems.iter().flatten() { - e!(&el.expr); - } - } - ast::Expr::Object(o) => { - for prop in &o.props { - match prop { - ast::PropOrSpread::Spread(s) => e!(&s.expr), - ast::PropOrSpread::Prop(p) => match p.as_ref() { - ast::Prop::KeyValue(kv) => e!(&kv.value), - // Object methods are new scopes — prune their params. - ast::Prop::Method(m) => { - if let Some(b) = &m.function.body { - let pruned = prune_for_fn(taint, &m.function); - walk_taint_stmts(&b.stmts, &pruned, acc); - } - } - _ => {} - }, - } - } - } - // New lexical scope — drop any name it rebinds as a parameter. - ast::Expr::Arrow(a) => { - let pruned = prune_for_arrow(taint, a); - match a.body.as_ref() { - ast::ArrowFunctionBody::FunctionBody(b) => walk_taint_stmts(&b.stmts, &pruned, acc), - ast::ArrowFunctionBody::Expr(x) => walk_taint_expr(x, &pruned, acc), - } - } - ast::Expr::Fn(f) => { - if let Some(b) = &f.function.body { - let pruned = prune_for_fn(taint, &f.function); - walk_taint_stmts(&b.stmts, &pruned, acc); - } - } - ast::Expr::OptChain(o) => match o.base.as_ref() { - ast::OptChainBase::Member(m) => { - e!(&m.obj); - if let ast::MemberProp::Computed(c) = &m.prop { - e!(&c.expr); - } - } - ast::OptChainBase::Call(c) => { - e!(&c.callee); - for arg in &c.args { - e!(&arg.expr); - } - } - }, - ast::Expr::TsAs(t) => e!(&t.expr), - ast::Expr::TsNonNull(t) => e!(&t.expr), - ast::Expr::TsTypeAssertion(t) => e!(&t.expr), - ast::Expr::TsConstAssertion(t) => e!(&t.expr), - ast::Expr::TsInstantiation(t) => e!(&t.expr), - ast::Expr::TsSatisfies(t) => e!(&t.expr), - // A class expression's member bodies are new scopes: prune each - // method/constructor's params before recursing (like object methods); - // field initializers and static blocks run in the surrounding taint. - ast::Expr::Class(c) => { - for member in &c.class.body { - match member { - ast::ClassMember::Method(m) => { - if let Some(b) = &m.function.body { - let pruned = prune_for_fn(taint, &m.function); - walk_taint_stmts(&b.stmts, &pruned, acc); - } - } - ast::ClassMember::PrivateMethod(m) => { - if let Some(b) = &m.function.body { - let pruned = prune_for_fn(taint, &m.function); - walk_taint_stmts(&b.stmts, &pruned, acc); - } - } - ast::ClassMember::Constructor(ctor) => { - if let Some(b) = &ctor.body { - let mut pruned = taint.clone(); - for p in &ctor.params { - if let ast::ParamOrTsParamProp::Param(param) = p { - if let Some(n) = cross_fn_pat_name(¶m.pat) { - pruned.remove(&n); - } - } - } - walk_taint_stmts(&b.stmts, &pruned, acc); - } - } - ast::ClassMember::ClassProp(p) => { - if let Some(v) = &p.value { - e!(v); - } - } - ast::ClassMember::PrivateProp(p) => { - if let Some(v) = &p.value { - e!(v); - } - } - ast::ClassMember::StaticBlock(s) => { - walk_taint_stmts(&s.body.stmts, taint, acc); - } - _ => {} - } - } - } - _ => {} - } -} - -/// Plain-ident name of a pattern, else `None` (destructuring / rest). -fn cross_fn_pat_name(pat: &ast::Pat) -> Option { - match pat { - ast::Pat::Ident(i) => Some(i.id.sym.to_string()), - _ => None, - } -} - -/// `g(...)` where the callee is a bare identifier — returns `g`. -fn cross_fn_call_ident(call: &ast::CallExpr) -> Option { - match &call.callee { - ast::Callee::Expr(e) => match e.as_ref() { - ast::Expr::Ident(i) => Some(i.sym.to_string()), - _ => None, - }, - _ => None, - } -} - -/// `X.on("upgrade", (req, wsId, head) => …)` / `.addListener` / `.once`, where -/// `X` is a `createServer(...)` result. Returns the `wsId` param name and the -/// handler expression (whose body carries the handle in scope). -fn upgrade_handler_ws_id<'a>( - call: &'a ast::CallExpr, - server_idents: &HashSet, -) -> Option<(String, &'a ast::Expr)> { - let ast::Callee::Expr(callee) = &call.callee else { - return None; - }; - let ast::Expr::Member(member) = callee.as_ref() else { - return None; - }; - // Receiver must be a known http server ident. - let ast::Expr::Ident(obj) = member.obj.as_ref() else { - return None; - }; - if !server_idents.contains(obj.sym.as_ref()) { - return None; - } - let method = match &member.prop { - ast::MemberProp::Ident(i) => i.sym.as_ref(), - _ => return None, - }; - if method != "on" && method != "addListener" && method != "once" { - return None; - } - let event = call.args.first()?; - if event.spread.is_some() { - return None; - } - let is_upgrade = matches!( - event.expr.as_ref(), - ast::Expr::Lit(ast::Lit::Str(s)) if s.value.as_str() == Some("upgrade") - ); - if !is_upgrade { - return None; - } - let handler = call.args.get(1)?; - if handler.spread.is_some() { - return None; - } - let ws_id = match handler.expr.as_ref() { - ast::Expr::Arrow(a) => a.params.get(1).and_then(cross_fn_pat_name), - ast::Expr::Fn(f) => perry_parser::function_parameter_patterns(&f.function) - .nth(1) - .as_deref() - .and_then(cross_fn_pat_name), - _ => None, - }?; - Some((ws_id, handler.expr.as_ref())) -} - -/// Node HTTP-family module specifiers whose `createServer`/`createSecureServer` -/// produce a real `("http","HttpServer")` handle. -fn is_http_module_specifier(src: &str) -> bool { - matches!( - src, - "http" | "node:http" | "https" | "node:https" | "http2" | "node:http2" - ) -} - -/// References that resolve to an HTTP-family `createServer`/`createSecureServer`, -/// gathered from the module's imports and `require(...)`s. `bare` holds local -/// names that ARE the function (named import / destructured require); `ns` holds -/// namespace locals so `.createServer(...)` is recognised. -#[derive(Default)] -struct HttpCreateRefs { - bare: HashSet, - ns: HashSet, -} - -/// Is `expr` a `require("")` call? Returns the specifier text. -fn require_http_specifier(expr: &ast::Expr) -> Option<&str> { - let ast::Expr::Call(call) = expr else { - return None; - }; - let ast::Callee::Expr(callee) = &call.callee else { - return None; - }; - let ast::Expr::Ident(i) = callee.as_ref() else { - return None; - }; - if i.sym.as_ref() != "require" { - return None; - } - let arg = call.args.first()?; - let ast::Expr::Lit(ast::Lit::Str(s)) = arg.expr.as_ref() else { - return None; - }; - let src = s.value.as_str()?; - is_http_module_specifier(src).then_some(src) -} - -/// Build the set of HTTP-imported `createServer`/`createSecureServer` references -/// (named/default/namespace imports + `require(...)` aliases, top-level scan). -fn collect_http_create_server_refs(ast_module: &ast::Module) -> HttpCreateRefs { - let mut refs = HttpCreateRefs::default(); - let note_require_binding = |pat: &ast::Pat, refs: &mut HttpCreateRefs| match pat { - // `const http = require("node:http")` → namespace binding. - ast::Pat::Ident(id) => { - refs.ns.insert(id.id.sym.to_string()); - } - // `const { createServer } = require("node:http")` → bare binding. - ast::Pat::Object(obj) => { - for prop in &obj.props { - if let ast::ObjectPatProp::Assign(a) = prop { - let key = a.key.sym.as_ref(); - if key == "createServer" || key == "createSecureServer" { - refs.bare.insert(a.key.sym.to_string()); - } - } else if let ast::ObjectPatProp::KeyValue(kv) = prop { - if let ast::PropName::Ident(k) = &kv.key { - let key = k.sym.as_ref(); - if key == "createServer" || key == "createSecureServer" { - if let ast::Pat::Ident(local) = kv.value.as_ref() { - refs.bare.insert(local.id.sym.to_string()); - } - } - } - } - } - } - _ => {} - }; - for item in &ast_module.body { - match item { - ast::ModuleItem::ModuleDecl(ast::ModuleDecl::Import(imp)) => { - if !is_http_module_specifier(imp.src.value.as_str().unwrap_or("")) { - continue; - } - for spec in &imp.specifiers { - match spec { - ast::ImportSpecifier::Named(n) => { - let imported = match &n.imported { - Some(ast::ModuleExportName::Ident(i)) => i.sym.to_string(), - Some(ast::ModuleExportName::Str(s)) => { - s.value.as_str().unwrap_or("").to_string() - } - None => n.local.sym.to_string(), - }; - if imported == "createServer" || imported == "createSecureServer" { - refs.bare.insert(n.local.sym.to_string()); - } - } - ast::ImportSpecifier::Default(d) => { - refs.ns.insert(d.local.sym.to_string()); - } - ast::ImportSpecifier::Namespace(ns) => { - refs.ns.insert(ns.local.sym.to_string()); - } - } - } - } - ast::ModuleItem::Stmt(ast::Stmt::Decl(ast::Decl::Var(v))) => { - for d in &v.decls { - if let Some(init) = &d.init { - if require_http_specifier(init).is_some() { - note_require_binding(&d.name, &mut refs); - } - } - } - } - _ => {} - } - } - refs -} - -/// Record idents bound to an HTTP-family `createServer(...)` / -/// `createSecureServer(...)` call (verified import/require provenance) anywhere -/// in the module. -fn collect_server_idents_in_module(ast_module: &ast::Module, out: &mut HashSet) { - let refs = collect_http_create_server_refs(ast_module); - fn is_create_server_call(expr: &ast::Expr, refs: &HttpCreateRefs) -> bool { - let mut e = expr; - loop { - match e { - ast::Expr::Paren(p) => e = &p.expr, - ast::Expr::TsAs(t) => e = &t.expr, - ast::Expr::TsNonNull(t) => e = &t.expr, - // `createServer(...).listen(...)` still binds the server to the - // chained receiver, but the bound ident is the chain result, not - // the server — so only treat a *direct* call result as a server. - _ => break, - } - } - let ast::Expr::Call(call) = e else { - return false; - }; - let ast::Callee::Expr(callee) = &call.callee else { - return false; - }; - match callee.as_ref() { - // Bare `createServer(...)` — only when the name is an HTTP-imported - // binding (named import or destructured require), not a user factory. - ast::Expr::Ident(i) => refs.bare.contains(i.sym.as_ref()), - // `.createServer(...)` — `` must be an HTTP namespace import. - ast::Expr::Member(m) => { - let ast::MemberProp::Ident(prop) = &m.prop else { - return false; - }; - let prop = prop.sym.as_ref(); - if prop != "createServer" && prop != "createSecureServer" { - return false; - } - matches!(m.obj.as_ref(), ast::Expr::Ident(o) if refs.ns.contains(o.sym.as_ref())) - } - _ => false, - } - } - fn record_decl(decl: &ast::VarDeclarator, refs: &HttpCreateRefs, out: &mut HashSet) { - if let (ast::Pat::Ident(id), Some(init)) = (&decl.name, decl.init.as_ref()) { - if is_create_server_call(init, refs) { - out.insert(id.id.sym.to_string()); - } - } - } - fn walk_stmt(stmt: &ast::Stmt, refs: &HttpCreateRefs, out: &mut HashSet) { - match stmt { - ast::Stmt::Decl(ast::Decl::Var(v)) => { - for d in &v.decls { - record_decl(d, refs, out); - } - } - ast::Stmt::Decl(ast::Decl::Fn(f)) => { - if let Some(b) = &f.function.body { - for s in &b.stmts { - walk_stmt(s, refs, out); - } - } - } - ast::Stmt::Expr(e) => walk_expr(&e.expr, refs, out), - ast::Stmt::Block(b) => { - for s in &b.stmts { - walk_stmt(s, refs, out); - } - } - ast::Stmt::If(i) => { - walk_stmt(&i.cons, refs, out); - if let Some(alt) = &i.alt { - walk_stmt(alt, refs, out); - } - } - ast::Stmt::While(w) => walk_stmt(&w.body, refs, out), - ast::Stmt::DoWhile(w) => walk_stmt(&w.body, refs, out), - ast::Stmt::For(f) => { - if let Some(ast::VarDeclOrExpr::VarDecl(vd)) = &f.init { - for d in &vd.decls { - record_decl(d, refs, out); - } - } - walk_stmt(&f.body, refs, out); - } - ast::Stmt::ForIn(f) => walk_stmt(&f.body, refs, out), - ast::Stmt::ForOf(f) => walk_stmt(&f.body, refs, out), - ast::Stmt::Try(t) => { - for s in &t.block.stmts { - walk_stmt(s, refs, out); - } - if let Some(h) = &t.handler { - for s in &h.body.stmts { - walk_stmt(s, refs, out); - } - } - if let Some(f) = &t.finalizer { - for s in &f.stmts { - walk_stmt(s, refs, out); - } - } - } - ast::Stmt::Switch(s) => { - for c in &s.cases { - for s in &c.cons { - walk_stmt(s, refs, out); - } - } - } - ast::Stmt::Return(r) => { - if let Some(a) = &r.arg { - walk_expr(a, refs, out); - } - } - _ => {} - } - } - // Descend into callback bodies (e.g. a server created inside `main()` or a - // `.listen(0, () => { const server = createServer(...) })`). - fn walk_expr(expr: &ast::Expr, refs: &HttpCreateRefs, out: &mut HashSet) { - match expr { - ast::Expr::Call(c) => { - for a in &c.args { - walk_expr(&a.expr, refs, out); - } - } - ast::Expr::Arrow(a) => match a.body.as_ref() { - ast::ArrowFunctionBody::FunctionBody(b) => { - for s in &b.stmts { - walk_stmt(s, refs, out); - } - } - ast::ArrowFunctionBody::Expr(e) => walk_expr(e, refs, out), - }, - ast::Expr::Fn(f) => { - if let Some(b) = &f.function.body { - for s in &b.stmts { - walk_stmt(s, refs, out); - } - } - } - ast::Expr::Paren(p) => walk_expr(&p.expr, refs, out), - _ => {} - } - } - for item in &ast_module.body { - match item { - ast::ModuleItem::Stmt(s) => walk_stmt(s, &refs, out), - ast::ModuleItem::ModuleDecl(ast::ModuleDecl::ExportDecl(e)) => { - if let ast::Decl::Var(v) = &e.decl { - for d in &v.decls { - record_decl(d, &refs, out); - } - } else if let ast::Decl::Fn(f) = &e.decl { - if let Some(b) = &f.function.body { - for s in &b.stmts { - walk_stmt(s, &refs, out); - } - } - } - } - _ => {} - } - } -} - -/// Collect every `CallExpr` reachable in the module (statement position and -/// nested closures) — used to find the upgrade-handler seed. -fn collect_calls_in_module<'a>(ast_module: &'a ast::Module, out: &mut Vec<&'a ast::CallExpr>) { - for item in &ast_module.body { - match item { - ast::ModuleItem::Stmt(s) => collect_calls_in_stmt(s, out), - ast::ModuleItem::ModuleDecl(ast::ModuleDecl::ExportDecl(e)) => match &e.decl { - ast::Decl::Var(v) => { - for d in &v.decls { - if let Some(init) = &d.init { - collect_calls_in_expr(init, out); - } - } - } - ast::Decl::Fn(f) => { - if let Some(b) = &f.function.body { - collect_calls_in_stmts(&b.stmts, out); - } - } - _ => {} - }, - _ => {} - } - } -} - -fn collect_calls_in_stmts<'a>(stmts: &'a [ast::Stmt], out: &mut Vec<&'a ast::CallExpr>) { - for s in stmts { - collect_calls_in_stmt(s, out); - } -} - -fn collect_calls_in_stmt<'a>(stmt: &'a ast::Stmt, out: &mut Vec<&'a ast::CallExpr>) { - match stmt { - ast::Stmt::Expr(e) => collect_calls_in_expr(&e.expr, out), - ast::Stmt::Return(r) => { - if let Some(a) = &r.arg { - collect_calls_in_expr(a, out); - } - } - ast::Stmt::Decl(ast::Decl::Var(v)) => { - for d in &v.decls { - if let Some(init) = &d.init { - collect_calls_in_expr(init, out); - } - } - } - ast::Stmt::Decl(ast::Decl::Fn(f)) => { - if let Some(b) = &f.function.body { - collect_calls_in_stmts(&b.stmts, out); - } - } - ast::Stmt::Block(b) => collect_calls_in_stmts(&b.stmts, out), - ast::Stmt::If(i) => { - collect_calls_in_expr(&i.test, out); - collect_calls_in_stmt(&i.cons, out); - if let Some(alt) = &i.alt { - collect_calls_in_stmt(alt, out); - } - } - ast::Stmt::While(w) => { - collect_calls_in_expr(&w.test, out); - collect_calls_in_stmt(&w.body, out); - } - ast::Stmt::DoWhile(w) => { - collect_calls_in_stmt(&w.body, out); - collect_calls_in_expr(&w.test, out); - } - ast::Stmt::For(f) => { - if let Some(ast::VarDeclOrExpr::VarDecl(vd)) = &f.init { - for d in &vd.decls { - if let Some(init) = &d.init { - collect_calls_in_expr(init, out); - } - } - } else if let Some(ast::VarDeclOrExpr::Expr(e)) = &f.init { - collect_calls_in_expr(e, out); - } - if let Some(t) = &f.test { - collect_calls_in_expr(t, out); - } - if let Some(u) = &f.update { - collect_calls_in_expr(u, out); - } - collect_calls_in_stmt(&f.body, out); - } - ast::Stmt::ForIn(f) => { - collect_calls_in_expr(&f.right, out); - collect_calls_in_stmt(&f.body, out); - } - ast::Stmt::ForOf(f) => { - collect_calls_in_expr(&f.right, out); - collect_calls_in_stmt(&f.body, out); - } - ast::Stmt::Throw(t) => collect_calls_in_expr(&t.arg, out), - ast::Stmt::Try(t) => { - collect_calls_in_stmts(&t.block.stmts, out); - if let Some(h) = &t.handler { - collect_calls_in_stmts(&h.body.stmts, out); - } - if let Some(f) = &t.finalizer { - collect_calls_in_stmts(&f.stmts, out); - } - } - ast::Stmt::Switch(s) => { - collect_calls_in_expr(&s.discriminant, out); - for c in &s.cases { - if let Some(t) = &c.test { - collect_calls_in_expr(t, out); - } - collect_calls_in_stmts(&c.cons, out); - } - } - ast::Stmt::Labeled(l) => collect_calls_in_stmt(&l.body, out), - _ => {} - } -} - -fn collect_calls_in_expr<'a>(expr: &'a ast::Expr, out: &mut Vec<&'a ast::CallExpr>) { - match expr { - ast::Expr::Call(c) => { - out.push(c); - if let ast::Callee::Expr(e) = &c.callee { - collect_calls_in_expr(e, out); - } - for a in &c.args { - collect_calls_in_expr(&a.expr, out); - } - } - ast::Expr::New(n) => { - collect_calls_in_expr(&n.callee, out); - if let Some(args) = &n.args { - for a in args { - collect_calls_in_expr(&a.expr, out); - } - } - } - ast::Expr::Member(m) => { - collect_calls_in_expr(&m.obj, out); - if let ast::MemberProp::Computed(c) = &m.prop { - collect_calls_in_expr(&c.expr, out); - } - } - ast::Expr::Bin(b) => { - collect_calls_in_expr(&b.left, out); - collect_calls_in_expr(&b.right, out); - } - ast::Expr::Unary(u) => collect_calls_in_expr(&u.arg, out), - ast::Expr::Update(u) => collect_calls_in_expr(&u.arg, out), - // Assignment LHS rarely carries a handle-passing call; follow the RHS. - ast::Expr::Assign(a) => collect_calls_in_expr(&a.right, out), - ast::Expr::Cond(c) => { - collect_calls_in_expr(&c.test, out); - collect_calls_in_expr(&c.cons, out); - collect_calls_in_expr(&c.alt, out); - } - ast::Expr::Paren(p) => collect_calls_in_expr(&p.expr, out), - ast::Expr::Seq(s) => { - for e in &s.exprs { - collect_calls_in_expr(e, out); - } - } - ast::Expr::Await(a) => collect_calls_in_expr(&a.arg, out), - ast::Expr::Yield(y) => { - if let Some(a) = &y.arg { - collect_calls_in_expr(a, out); - } - } - ast::Expr::Tpl(t) => { - for e in &t.exprs { - collect_calls_in_expr(e, out); - } - } - ast::Expr::TaggedTpl(t) => { - collect_calls_in_expr(&t.tag, out); - for e in &t.tpl.exprs { - collect_calls_in_expr(e, out); - } - } - ast::Expr::Array(a) => { - for el in a.elems.iter().flatten() { - collect_calls_in_expr(&el.expr, out); - } - } - ast::Expr::Object(o) => { - for p in &o.props { - match p { - ast::PropOrSpread::Spread(s) => collect_calls_in_expr(&s.expr, out), - ast::PropOrSpread::Prop(prop) => match prop.as_ref() { - ast::Prop::KeyValue(kv) => collect_calls_in_expr(&kv.value, out), - ast::Prop::Method(m) => { - if let Some(b) = &m.function.body { - collect_calls_in_stmts(&b.stmts, out); - } - } - _ => {} - }, - } - } - } - ast::Expr::Arrow(a) => match a.body.as_ref() { - ast::ArrowFunctionBody::FunctionBody(b) => collect_calls_in_stmts(&b.stmts, out), - ast::ArrowFunctionBody::Expr(e) => collect_calls_in_expr(e, out), - }, - ast::Expr::Fn(f) => { - if let Some(b) = &f.function.body { - collect_calls_in_stmts(&b.stmts, out); - } - } - ast::Expr::OptChain(o) => match o.base.as_ref() { - ast::OptChainBase::Member(m) => { - collect_calls_in_expr(&m.obj, out); - if let ast::MemberProp::Computed(c) = &m.prop { - collect_calls_in_expr(&c.expr, out); - } - } - ast::OptChainBase::Call(c) => { - collect_calls_in_expr(&c.callee, out); - for a in &c.args { - collect_calls_in_expr(&a.expr, out); - } - } - }, - ast::Expr::TsAs(t) => collect_calls_in_expr(&t.expr, out), - ast::Expr::TsNonNull(t) => collect_calls_in_expr(&t.expr, out), - ast::Expr::TsTypeAssertion(t) => collect_calls_in_expr(&t.expr, out), - ast::Expr::TsConstAssertion(t) => collect_calls_in_expr(&t.expr, out), - ast::Expr::TsInstantiation(t) => collect_calls_in_expr(&t.expr, out), - _ => {} - } -} - /// #4510: pre-register module-level `enum` declarations so a forward /// reference (an enum used in a function body or earlier statement, before its /// textual declaration) resolves instead of falling through to the diff --git a/crates/perry-hir/src/lower_decl/fn_decl.rs b/crates/perry-hir/src/lower_decl/fn_decl.rs index e70f252c1a..f8bd99263f 100644 --- a/crates/perry-hir/src/lower_decl/fn_decl.rs +++ b/crates/perry-hir/src/lower_decl/fn_decl.rs @@ -204,28 +204,6 @@ pub fn lower_fn_decl(ctx: &mut LoweringContext, fn_decl: &ast::FnDecl) -> Result } } - // Cross-function native-handle propagation: a parameter that receives a - // known native handle across a call boundary (e.g. the `("ws","Client")` - // upgrade `wsId` passed as `handleConnection(req, wsId)`) is untyped here, - // so the type-annotation loop above can't tag it. `pre_scan_cross_fn_native_params` - // recorded a `(fn_ident_span_lo, param_index) -> (module, class)` hint before - // any body lowered; apply it now (before the body lowers) so `wsId.send(...)` - // inside this function dispatches to `js_ws_send_client_i64` like the inline - // call. The key is this declaration's identifier span (not its name), so a - // hint seeded for a same-named but distinct function never leaks here. Only - // registers when the param isn't already a native instance, so an explicit - // annotation always wins. - if !ctx.param_native_hints.is_empty() { - let fn_span_lo = fn_decl.ident.span.lo.0; - for (idx, param) in params.iter().enumerate() { - if let Some((module, class)) = ctx.param_native_hints.get(&(fn_span_lo, idx)).cloned() { - if ctx.lookup_native_instance(¶m.name).is_none() { - ctx.register_native_instance(param.name.clone(), module, class); - } - } - } - } - // #1483: perry/ui widget parameters (e.g. `canvas: Canvas` or, via a // type-only import alias, `canvas: CanvasType`) must dispatch instance // methods through perry/ui `NativeMethodCall` exactly like a local diff --git a/crates/perry-hir/src/lower_patterns.rs b/crates/perry-hir/src/lower_patterns.rs index e0b4bac627..96ecc3e133 100644 --- a/crates/perry-hir/src/lower_patterns.rs +++ b/crates/perry-hir/src/lower_patterns.rs @@ -1270,71 +1270,6 @@ pub(crate) fn pre_scan_node_http_client_callback_params( arrow.params.first().and_then(pat_ident_name) } -/// Pre-scan for `httpServer.on('upgrade', (req, wsId, head) => …)` -/// (issue #577 Phase 4). When the receiver is a registered HttpServer -/// native instance and the event name is `'upgrade'`, register the -/// SECOND arrow param (`wsId`) as a `("ws", "Client")` native instance -/// BEFORE the body is lowered, so calls inside the handler like -/// `wsId.send(...)` / `wsId.on('message', cb)` / `wsId.close()` -/// dispatch through the dedicated Client-class entries in -/// NATIVE_MODULE_TABLE (which call the `js_ws_send_client_i64` / -/// `js_ws_close_client_i64` / `js_ws_on_client_i64` shims that take -/// the receiver as `i64` after `unbox_to_i64` — the wsId arrives -/// from the upgrade dispatch NaN-boxed POINTER_TAG so the unbox -/// extracts the raw integer correctly). -/// -/// Returns `Some(wsId_local_name)` when the pattern matches. -pub(crate) fn pre_scan_node_http_upgrade_params( - ctx: &crate::lower::LoweringContext, - call: &ast::CallExpr, -) -> Option { - use ast::Callee; - let callee_expr = match &call.callee { - Callee::Expr(e) => e, - _ => return None, - }; - let member = match callee_expr.as_ref() { - ast::Expr::Member(m) => m, - _ => return None, - }; - let obj_ident = match member.obj.as_ref() { - ast::Expr::Ident(i) => i, - _ => return None, - }; - let obj_name = obj_ident.sym.to_string(); - let (module, class) = ctx.lookup_native_instance(&obj_name)?; - if module != "http" || class != "HttpServer" { - return None; - } - let method_name = match &member.prop { - ast::MemberProp::Ident(i) => i.sym.to_string(), - _ => return None, - }; - if method_name != "on" && method_name != "addListener" { - return None; - } - // First arg must be the literal string "upgrade". - let event_arg = call.args.first()?; - let event_name = match event_arg.expr.as_ref() { - ast::Expr::Lit(ast::Lit::Str(s)) => s.value.as_str().unwrap_or(""), - _ => return None, - }; - if event_name != "upgrade" { - return None; - } - // Second arg = handler. Pull the second param (wsId). - let handler_arg = call.args.get(1)?; - if handler_arg.spread.is_some() { - return None; - } - let arrow = match handler_arg.expr.as_ref() { - ast::Expr::Arrow(a) => a, - _ => return None, - }; - let ws_id_name = arrow.params.get(1).and_then(pat_ident_name)?; - Some(ws_id_name) -} - /// Issue #2211 — pre-scan for `request.on('socket', sock => …)` (and the /// `'connect'`/`'connection'` aliases). When the receiver is a /// `ClientRequest` native instance and the event name is `'socket'`, From 22aaf0943d70cfb24bbfeb842fa84011ded52eb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 9 Oct 2026 04:42:34 +0200 Subject: [PATCH 04/18] test(net): phase A witnesses, upgrade fixtures and sabotages N2-N4 binding witnesses with sabotages (late_close, churn_backlog, early_unref), N8 server and client upgrade tests (route/head sabotages), the typed upgrade gap fixture, and the HIR test that the upgrade socket parameter lowers to ordinary method calls. --- .../src/lower_call/args_sweep_tests.rs | 7 +- .../src/lower_call/conversion_window_tests.rs | 12 +- .../client_turnloop/native_client_tests.rs | 508 ++++++++ .../src/client_turnloop/protocol_tests.rs | 210 ++++ .../src/client_turnloop/tests.rs | 413 +------ .../src/server/server/upgrade_tests.rs | 58 +- .../server/turnloop_h2/backpressure_tests.rs | 24 +- .../src/server/turnloop_serve/native_tests.rs | 195 +++ crates/perry-ext-http/src/tests.rs | 47 +- .../tests/turnloop_client_exchange.rs | 242 ++-- crates/perry-ext-net/src/payload_tests.rs | 1062 +++++++++++++++++ crates/perry-ext-net/src/tests.rs | 624 ---------- .../src/unconnected_write_tests.rs | 80 -- .../tests/pump_symbol_identity.rs | 34 - .../upgrade_socket_param_plain_dispatch.rs | 66 + .../tests/ws_upgrade_param_native_dispatch.rs | 71 -- .../gc/tests/residual_prototype_relocation.rs | 7 + .../listeners_tests.rs | 56 +- ...s_client_handle_cross_function_dispatch.rs | 514 -------- .../test_gap_http_upgrade_socket_data.ts | 72 ++ 20 files changed, 2439 insertions(+), 1863 deletions(-) create mode 100644 crates/perry-ext-http/src/client_turnloop/native_client_tests.rs create mode 100644 crates/perry-ext-http/src/client_turnloop/protocol_tests.rs create mode 100644 crates/perry-ext-http/src/server/turnloop_serve/native_tests.rs create mode 100644 crates/perry-ext-net/src/payload_tests.rs delete mode 100644 crates/perry-ext-net/src/tests.rs delete mode 100644 crates/perry-ext-net/src/unconnected_write_tests.rs delete mode 100644 crates/perry-ext-net/tests/pump_symbol_identity.rs create mode 100644 crates/perry-hir/tests/upgrade_socket_param_plain_dispatch.rs delete mode 100644 crates/perry-hir/tests/ws_upgrade_param_native_dispatch.rs delete mode 100644 crates/perry/tests/ws_client_handle_cross_function_dispatch.rs create mode 100644 test-files/test_gap_http_upgrade_socket_data.ts diff --git a/crates/perry-codegen/src/lower_call/args_sweep_tests.rs b/crates/perry-codegen/src/lower_call/args_sweep_tests.rs index d63a84b8d9..ed2f16019a 100644 --- a/crates/perry-codegen/src/lower_call/args_sweep_tests.rs +++ b/crates/perry-codegen/src/lower_call/args_sweep_tests.rs @@ -188,8 +188,7 @@ fn console_instance_call_roots_receiver_across_arguments() { #[test] fn native_instance_method_roots_receiver_across_arguments() { - let sig = super::native_module_lookup("net", true, "write", Some("Socket")) - .expect("net.Socket.write resolves"); + assert!(super::native_module_lookup("net", true, "write", Some("Socket")).is_none()); assert_first_operand_rooted( native( "net", @@ -198,8 +197,8 @@ fn native_instance_method_roots_receiver_across_arguments() { "write", vec![collecting("chunk")], ), - sig.runtime, - "native-table instance receiver", + "js_native_call_method_nullsafe", + "ordinary Socket receiver", ); } diff --git a/crates/perry-codegen/src/lower_call/conversion_window_tests.rs b/crates/perry-codegen/src/lower_call/conversion_window_tests.rs index 2c92c22424..a31d59202a 100644 --- a/crates/perry-codegen/src/lower_call/conversion_window_tests.rs +++ b/crates/perry-codegen/src/lower_call/conversion_window_tests.rs @@ -115,15 +115,15 @@ fn ui_table_call_roots_first_string_pointer_across_second_conversion() { fn native_table_call_roots_first_string_pointer_across_second_conversion() { // U2: `lower_native_module_dispatch` took `js_value_to_str_ptr_for_ffi` of // each `StrPtr` argument in order. - let sig = super::native_module_lookup("net", true, "check", Some("BlockList")) - .expect("net.BlockList.check resolves"); + let sig = super::native_module_lookup("http", true, "appendHeader", Some("ServerResponse")) + .expect("http.ServerResponse.appendHeader resolves"); let ir = compile_stmt( native( - "net", - Some("BlockList"), + "http", + Some("ServerResponse"), Some(object("recv")), - "check", - vec![object("address"), object("family")], + "appendHeader", + vec![object("name"), object("value")], ), CompileOptions::default(), ); diff --git a/crates/perry-ext-http/src/client_turnloop/native_client_tests.rs b/crates/perry-ext-http/src/client_turnloop/native_client_tests.rs new file mode 100644 index 0000000000..f96ffe1681 --- /dev/null +++ b/crates/perry-ext-http/src/client_turnloop/native_client_tests.rs @@ -0,0 +1,508 @@ +//! Real client bytes above ordinary Socket cells, including public request +//! factories and exact capability witnesses on their existing app records. +use super::*; +use crate::client_turnloop::Mode; +use perry_ffi::{JsThis, RawClosureHeader}; +use std::cell::{Cell, RefCell}; +use std::collections::HashMap; +use std::io::{Read, Write}; +use std::net::TcpListener; +use std::time::{Duration, Instant}; + +const CERT: &[u8] = + include_bytes!("../../../../test-parity/node-suite/tls/fixtures/localhost-cert.pem"); +const KEY: &[u8] = + include_bytes!("../../../../test-parity/node-suite/tls/fixtures/localhost-key.pem"); +thread_local! { + static RESPONSE: Cell = const { Cell::new(false) }; + static UPGRADE: Cell = const { Cell::new(false) }; + static CLOSED: Cell = const { Cell::new(false) }; + static TAIL: RefCell> = const { RefCell::new(Vec::new()) }; +} +fn undefined() -> f64 { + f64::from_bits(JsValue::UNDEFINED.bits()) +} +fn boxed(raw: i64) -> f64 { + f64::from_bits(JsValue::from_object_ptr(raw as *mut u8).bits()) +} +fn raw(value: f64) -> i64 { + JsValue::from_bits(value.to_bits()).as_pointer::() as i64 +} +fn bytes(value: f64) -> Vec { + perry_ffi::bytes::no_gc(|scope| { + perry_ffi::read_buffer_bytes(raw(value) as *const perry_ffi::BufferHeader, scope) + .expect("Socket data and upgrade head are Buffers") + .to_vec() + }) +} +fn read_head(stream: &mut impl Read) -> Vec { + let mut bytes = Vec::new(); + while !bytes.ends_with(b"\r\n\r\n") { + assert!(bytes.len() < 32768); + let mut byte = [0]; + stream.read_exact(&mut byte).unwrap(); + bytes.push(byte[0]); + } + bytes +} +fn drive_until(mut done: impl FnMut() -> bool) { + let until = Instant::now() + Duration::from_secs(10); + while !done() { + assert!(Instant::now() < until, "native HTTP client stalled"); + perry_runtime::event_pump::js_loop_turn_bounded(2); + unsafe { + crate::js_http_process_pending(); + } + perry_runtime::promise::js_promise_run_microtasks(); + } +} +fn request(owner: f64, mode: Mode, port: u16, secure: bool, callback: i64) -> Outbound { + let url = format!( + "{}://127.0.0.1:{port}/client", + if secure { "https" } else { "http" } + ); + let request = perry_ffi::register_handle(ClientRequestHandle { + owner_agent: perry_ffi::agent_post::current_agent(), + surface: crate::client_request_surface::ClientRequestSurfaceState::default(), + async_id: 0, + method: "GET".into(), + url: url.clone(), + headers: HashMap::new(), + body: Vec::new(), + response_callback: if mode == Mode::Upgrade { 0 } else { callback }, + response_raw_wrapper: 0, + listeners: if mode == Mode::Upgrade { + HashMap::from([( + "upgrade".into(), + vec![crate::ClientEventListener { + callback, + raw_wrapper: 0, + once: false, + }], + )]) + } else { + HashMap::new() + }, + timeout_ms: None, + ended: true, + flushed_early: false, + pending_write_callbacks: Vec::new(), + end_callback: 0, + completed: false, + timeout_fired: false, + close_emitted: false, + agent_handle: 0, + agent_key: format!("127.0.0.1:{port}:"), + request_create_connection: 0, + agent_active: false, + agent_queued: false, + reused_socket: false, + socket_handle: raw(owner), + socket_snapshot: None, + abort_signal_bits: 0, + abort_listener_bits: 0, + tls: crate::tls_client::TlsOptions::default(), + preflight_error: None, + incoming_handle: 0, + expects_continue: false, + continue_body_pending: false, + agent_false: false, + }); + crate::ensure_gc_scanner_registered(); + let options = crate::tls_client::TlsOptions { + ca_pems: vec![CERT.to_vec()], + ..Default::default() + }; + Outbound { + request_handle: request, + method: "GET".into(), + url: url::Url::parse(&url).unwrap(), + headers: if mode == Mode::Upgrade { + HashMap::from([ + ("Connection".into(), "Upgrade".into()), + ("Upgrade".into(), "raw".into()), + ]) + } else { + HashMap::new() + }, + body: Vec::new(), + timeout_ms: None, + mode, + reuse: None, + key: PoolKey { + agent: 0, + https: secure, + host: "127.0.0.1".into(), + port, + proxy: None, + tls: 0, + }, + tls: secure.then(|| super::super::tls::plan(&options, "127.0.0.1").unwrap()), + proxy: None, + extra: Vec::new(), + } +} +unsafe extern "C" fn response(closure: *const RawClosureHeader, _: JsThis, value: f64) -> f64 { + let incoming = perry_ffi::get_handle::(raw(value)).unwrap(); + assert_eq!(incoming.status_code, 200); + assert_eq!(incoming.body, b"payload"); + assert_eq!( + incoming.socket_handle, + raw(perry_ffi::closure_capture_f64(closure, 0)) + ); + assert_eq!( + incoming.trailers.get("x-end").map(String::as_str), + Some("yes") + ); + RESPONSE.with(|seen| seen.set(true)); + undefined() +} +#[test] +fn a_fragmented_client_response_uses_the_requests_actual_socket() { + fragmented_response(false); +} +#[test] +fn public_http_request_creates_and_drives_the_actual_socket() { + fragmented_response(true); +} +fn fragmented_response(public_factory: bool) { + std::thread::spawn(move || { + let agent = perry_runtime::agent::enter_worker_agent(); + RESPONSE.with(|seen| seen.set(false)); + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let peer = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(10))) + .unwrap(); + let head = read_head(&mut stream); + assert!(head.starts_with(b"GET /client HTTP/1.1\r\n")); + assert!(head.ends_with(b"Connection: close\r\n\r\n")); + for bytes in [ + b"HTTP/1.1 200 OK\r\nTransfer-Encod".as_slice(), + b"ing: chunked\r\n\r\n7\r\npay", + b"load\r\n0\r\nX-End: yes\r\n\r\n", + ] { + stream.write_all(bytes).unwrap(); + } + }); + let scope = TransientRootScope::enter(); + let completed = super::super::completed_total(); + let accepted = super::super::accepted_total(); + let callback = scope.root_addr(perry_ffi::alloc_closure( + perry_ffi::js_function_info!(response, 1; with_flags(perry_ffi::FN_BUILTIN)), + 1, + ) as i64); + let socket = if public_factory { + let url = scope.root_nanbox(f64::from_bits( + JsValue::from_string_ptr( + perry_ffi::alloc_string(&format!("http://127.0.0.1:{port}/client")).as_raw(), + ) + .bits(), + )); + let request = unsafe { crate::js_http_request(url.get(), callback.get()) }; + crate::client_request_surface::set_header(request, "TE", "trailers".into()); + let socket = scope.root_nanbox(boxed( + perry_ffi::get_handle::(request) + .unwrap() + .socket_handle, + )); + unsafe { + perry_ffi::set_closure_capture_f64( + callback.get() as *mut RawClosureHeader, + 0, + socket.get(), + ); + crate::js_http_client_request_end(request, undefined()); + } + assert_eq!(super::super::accepted_total(), accepted + 1); + socket + } else { + let socket = scope.root_nanbox(net::new_socket(SUBSYSTEM, undefined())); + unsafe { + perry_ffi::set_closure_capture_f64( + callback.get() as *mut RawClosureHeader, + 0, + socket.get(), + ); + } + let out = request(socket.get(), Mode::Trailers, port, false, callback.get()); + start_on_socket(socket.get(), out, None, false).unwrap(); + socket + }; + let cell = net::socket_link(socket.get()).unwrap(); + drive_until(|| RESPONSE.with(Cell::get)); + assert_eq!(net::socket_link(socket.get()).unwrap(), cell); + assert_eq!(super::super::completed_total(), completed + 1); + assert_eq!( + np::lifecycle(net::own_get(net::state(socket.get()), PARSER_EDGE), &PARSER), + Ok(np::Lifecycle::Closed) + ); + assert_eq!(crate::js_ext_http_client_inflight(), 0); + peer.join().unwrap(); + perry_runtime::agent::retire_agent(agent); + }) + .join() + .unwrap(); +} +fn diagnostics(owner: f64) -> (usize, String, String, u8, usize) { + let link = net::socket_link(owner).unwrap(); + let core = unsafe { + &*(net::core(owner).unwrap() + as *const perry_runtime::turnloop_net::transport::TransportCore) + }; + let refs = + unsafe { (*(link.raw() as *const perry_runtime::native_handle::NativeHandleHeader)).refs }; + ( + link.raw(), + format!("{:?}", core.read_op()), + format!("{:?}", core.handle()), + core.route(), + refs as usize, + ) +} +unsafe extern "C" fn closed(_: *const RawClosureHeader, _: JsThis, _: f64) -> f64 { + CLOSED.with(|seen| seen.set(true)); + undefined() +} +unsafe extern "C" fn data(closure: *const RawClosureHeader, this: JsThis, value: f64) -> f64 { + let socket = RootedSocket::new(this.as_f64()); + let bytes = bytes(value); + TAIL.with(|tail| tail.borrow_mut().extend_from_slice(&bytes)); + if TAIL.with(|tail| tail.borrow().len() >= 6) { + TAIL.with(|tail| assert_eq!(&*tail.borrow(), b"TAIL\0\xff")); + net::destroy(socket.value()); + let scope = TransientRootScope::enter(); + let callback = scope.root_addr(perry_ffi::alloc_closure( + perry_ffi::js_function_info!(closed, 1; with_flags(perry_ffi::FN_BUILTIN)), + 0, + ) as i64); + net::once(socket.value(), "close", boxed(callback.get())); + } else { + net::once(socket.value(), "data", boxed(closure as i64)); + } + undefined() +} +unsafe extern "C" fn upgrade( + closure: *const RawClosureHeader, + _: JsThis, + incoming: f64, + value: f64, + head: f64, +) -> f64 { + let socket = RootedSocket::new(value); + assert_eq!( + perry_ffi::closure_capture_f64(closure, 0).to_bits(), + socket.value().to_bits() + ); + assert_eq!( + perry_ffi::get_handle::(raw(incoming)) + .unwrap() + .socket_handle, + raw(socket.value()) + ); + let holder = perry_ffi::closure_capture_f64(closure, 1); + let (cell, read, handle, route, refs) = diagnostics(socket.value()); + assert_eq!( + route, + net::ROUTE, + "client upgrade must change the current route" + ); + assert_eq!(cell, net::own_get(holder, "cell") as usize); + assert_eq!( + read, + JsValue::from_bits(net::own_get(holder, "read").to_bits()) + .to_owned_string() + .unwrap(), + "client upgrade must preserve the pending read" + ); + assert_eq!( + handle, + JsValue::from_bits(net::own_get(holder, "handle").to_bits()) + .to_owned_string() + .unwrap(), + "client upgrade must preserve the driver handle" + ); + assert_eq!( + refs, + net::own_get(holder, "refs") as usize, + "client upgrade must preserve owed refs" + ); + assert_eq!( + bytes(head), + b"\0HEAD\xff", + "client binary upgrade head must be preserved" + ); + assert!( + net::tls_installed(socket.value()), + "TLS must stay owned by the upgraded Socket" + ); + assert_eq!( + np::lifecycle( + net::own_get(net::state(socket.value()), PARSER_EDGE), + &PARSER + ), + Ok(np::Lifecycle::Closed) + ); + net::write(socket.value(), b"READY", 0).unwrap(); + let scope = TransientRootScope::enter(); + let callback = scope.root_addr(perry_ffi::alloc_closure( + perry_ffi::js_function_info!(data, 1; with_flags(perry_ffi::FN_BUILTIN)), + 0, + ) as i64); + net::once(socket.value(), "data", boxed(callback.get())); + UPGRADE.with(|seen| seen.set(true)); + undefined() +} +#[test] +fn n8_https_client_upgrade_preserves_socket_cell_tls_read_head_and_tail() { + std::thread::spawn(|| { + let agent = perry_runtime::agent::enter_worker_agent(); UPGRADE.with(|seen| seen.set(false)); CLOSED.with(|seen| seen.set(false)); TAIL.with(|tail| tail.borrow_mut().clear()); + let certs = rustls_pemfile::certs(&mut std::io::Cursor::new(CERT)).collect::, _>>().unwrap(); let key = rustls_pemfile::private_key(&mut std::io::Cursor::new(KEY)).unwrap().unwrap(); + let config = std::sync::Arc::new(rustls::ServerConfig::builder_with_provider(std::sync::Arc::new(rustls::crypto::ring::default_provider())).with_safe_default_protocol_versions().unwrap().with_no_client_auth().with_single_cert(certs, key).unwrap()); + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let port = listener.local_addr().unwrap().port(); let (go, ready) = std::sync::mpsc::channel(); + let peer = std::thread::spawn(move || { + let (stream, _) = listener.accept().unwrap(); stream.set_read_timeout(Some(Duration::from_secs(10))).unwrap(); let session = rustls::ServerConnection::new(config).unwrap(); let mut stream = rustls::StreamOwned::new(session, stream); + assert!(read_head(&mut stream).starts_with(b"GET /client HTTP/1.1\r\n")); ready.recv_timeout(Duration::from_secs(10)).unwrap(); + stream.write_all(b"HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: raw\r\n\r\n\0HEAD\xff").unwrap(); stream.flush().unwrap(); + let mut ack = [0;5]; stream.read_exact(&mut ack).unwrap(); assert_eq!(&ack, b"READY"); stream.write_all(b"TAIL\0\xff").unwrap(); stream.flush().unwrap(); let mut rest = Vec::new(); let _ = stream.read_to_end(&mut rest); + }); + let scope = TransientRootScope::enter(); let socket = scope.root_nanbox(net::new_socket(SUBSYSTEM, undefined())); let holder = scope.root_nanbox(f64::from_bits(perry_ffi::alloc_object().bits())); + let callback = scope.root_addr(perry_ffi::alloc_closure(perry_ffi::js_function_info!(upgrade, 3; with_flags(perry_ffi::FN_BUILTIN)), 2) as i64); + unsafe { perry_ffi::set_closure_capture_f64(callback.get() as *mut RawClosureHeader, 0, socket.get()); perry_ffi::set_closure_capture_f64(callback.get() as *mut RawClosureHeader, 1, holder.get()); } + let out = request(socket.get(), Mode::Upgrade, port, true, callback.get()); let handshakes = super::super::tls_handshakes_total(); start_on_socket(socket.get(), out, None, false).unwrap(); + drive_until(|| super::super::tls_handshakes_total() == handshakes + 1 && net::queued_bytes(socket.get()) == 0); + let (cell, read, handle, route, refs) = diagnostics(socket.get()); assert_eq!(route, SUBSYSTEM); assert_ne!(read, "None"); net::own_set(holder.get(), "cell", cell as f64); net::own_set(holder.get(), "refs", refs as f64); + for (key, text) in [("read", read), ("handle", handle)] { let text = scope.root_nanbox(f64::from_bits(JsValue::from_string_ptr(perry_ffi::alloc_string(&text).as_raw()).bits())); net::own_set(holder.get(), key, text.get()); } + go.send(()).unwrap(); drive_until(|| UPGRADE.with(Cell::get) && CLOSED.with(Cell::get)); assert_eq!(crate::js_ext_http_client_inflight(), 0); peer.join().unwrap(); perry_runtime::agent::retire_agent(agent); + }).join().unwrap(); +} + +#[test] +fn a_parked_socket_expires_on_its_unreferenced_deadline() { + std::thread::spawn(|| { + let agent = perry_runtime::agent::enter_worker_agent(); + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let peer = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(10))) + .unwrap(); + assert!(read_head(&mut stream).starts_with(b"GET /client HTTP/1.1\r\n")); + stream + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 1\r\n\r\na") + .unwrap(); + let mut byte = [0]; + assert_eq!( + stream.read(&mut byte).unwrap(), + 0, + "idle expiry must close the actual descriptor" + ); + }); + let scope = TransientRootScope::enter(); + CLOSED.with(|seen| seen.set(false)); + let owner = scope.root_nanbox(net::new_socket(SUBSYSTEM, undefined())); + let close = scope.root_addr(perry_ffi::alloc_closure( + perry_ffi::js_function_info!(closed, 1; with_flags(perry_ffi::FN_BUILTIN)), + 0, + ) as i64); + net::once(owner.get(), "close", boxed(close.get())); + let mut out = request(owner.get(), Mode::Normal, port, false, 0); + out.reuse = Some(super::super::Reuse { + max_free: 1, + idle_ms: 25, + }); + start_on_socket(owner.get(), out, None, false).unwrap(); + let deadline = Instant::now() + Duration::from_millis(500); + while !JsValue::from_bits(net::get(owner.get(), "destroyed").to_bits()).to_bool() { + assert!( + Instant::now() < deadline, + "the HTTP idle deadline must expire, not be parked" + ); + perry_runtime::event_pump::js_loop_turn_bounded(2); + unsafe { + crate::js_http_process_pending(); + } + } + assert_eq!(crate::js_ext_http_client_inflight(), 0); + assert_eq!( + np::lifecycle(net::own_get(net::state(owner.get()), PARSER_EDGE), &PARSER), + Ok(np::Lifecycle::Closed) + ); + drive_until(|| CLOSED.with(Cell::get)); + peer.join().unwrap(); + perry_runtime::agent::retire_agent(agent); + }) + .join() + .unwrap(); +} + +#[test] +fn delayed_agent_cleanup_preserves_a_reopened_socket_capability() { + std::thread::spawn(|| { + let worker = perry_runtime::agent::enter_worker_agent(); + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(net::new_socket(net::ROUTE, undefined())); + let original = std::net::TcpStream::connect(listener.local_addr().unwrap()).unwrap(); + let (accepted, _) = listener.accept().unwrap(); + let cell = net::socket_link(owner.get()).unwrap(); + unsafe { + tl::link_adopt_stream(&mut *net::core(owner.get()).unwrap(), cell, original.into()) + .unwrap(); + } + net::adopted(owner.get()); + let old = net::snapshot(owner.get()).unwrap(); + let agent = unsafe { crate::agent::js_http_agent_new(undefined()) }; + let out = request(owner.get(), Mode::Normal, port, false, 0); + let request = out.request_handle; + // Use the request's actual existing Agent key and ownership edges. + let key = perry_ffi::get_handle_mut::(request) + .map(|req| { + req.agent_handle = agent; + req.agent_active = true; + req.socket_snapshot = Some(old); + req.agent_key.clone() + }) + .unwrap(); + let agent_record = perry_ffi::get_handle_mut::(agent).unwrap(); + agent_record.sockets.insert(key.clone(), 1); + agent_record + .active_socket_handles + .insert(key.clone(), vec![raw(owner.get())]); + net::destroy(owner.get()); + let host = scope.root_nanbox(f64::from_bits( + JsValue::from_string_ptr(perry_ffi::alloc_string("127.0.0.1").as_raw()).bits(), + )); + net::connect(owner.get(), port as f64, host.get(), undefined()); + let replacement = + net::snapshot(owner.get()).expect("reopen creates a new driver capability"); + assert_eq!( + net::socket_link(owner.get()).unwrap(), + cell, + "reopen retains the same cell" + ); + assert!(!net::matches(owner.get(), &old)); + unsafe { + crate::finish_agent_request(request, false); + } + assert!( + net::matches(owner.get(), &replacement), + "delayed HTTP cleanup must preserve the reopened driver capability" + ); + assert!(!JsValue::from_bits(net::get(owner.get(), "destroyed").to_bits()).to_bool()); + assert!(perry_ffi::get_handle::(agent) + .unwrap() + .active_socket_handles + .get(&key) + .is_none_or(Vec::is_empty)); + net::destroy(owner.get()); + drop(accepted); + perry_runtime::agent::retire_agent(worker); + }) + .join() + .unwrap(); +} diff --git a/crates/perry-ext-http/src/client_turnloop/protocol_tests.rs b/crates/perry-ext-http/src/client_turnloop/protocol_tests.rs new file mode 100644 index 0000000000..5777f5fb1b --- /dev/null +++ b/crates/perry-ext-http/src/client_turnloop/protocol_tests.rs @@ -0,0 +1,210 @@ +//! The framing contract after moving transport ownership to Socket. These +//! witnesses exercise fragmented input, retries and terminal effects directly. +use super::*; +use std::collections::HashMap; + +fn request(mode: Mode) -> Outbound { + Outbound { + request_handle: 123, + method: "GET".into(), + url: url::Url::parse("http://127.0.0.1:9/path").unwrap(), + headers: HashMap::new(), + body: Vec::new(), + timeout_ms: None, + mode, + reuse: None, + key: PoolKey { + agent: 7, + https: false, + host: "127.0.0.1".into(), + port: 9, + proxy: None, + tls: 0, + }, + tls: None, + proxy: None, + extra: Vec::new(), + } +} +fn writes(effects: &[Effect]) -> Vec { + effects + .iter() + .filter_map(|effect| match effect { + Effect::Write(bytes) => Some(bytes.as_slice()), + _ => None, + }) + .flatten() + .copied() + .collect() +} +fn kinds(effects: &[Effect]) -> Vec<&'static str> { + effects + .iter() + .filter_map(|effect| match effect { + Effect::Push(PendingHttpEvent::ResponseHead { .. }) => Some("head"), + Effect::Push(PendingHttpEvent::ResponseChunk { .. }) => Some("chunk"), + Effect::Push(PendingHttpEvent::ResponseEnd { .. }) => Some("end"), + Effect::Push(PendingHttpEvent::Response { .. }) => Some("response"), + Effect::Push(PendingHttpEvent::Continue { .. }) => Some("continue"), + Effect::Push(PendingHttpEvent::Timeout { .. }) => Some("timeout"), + Effect::RetireRequest(_) => Some("retire"), + Effect::Close => Some("close"), + _ => None, + }) + .collect() +} +fn connected(out: Outbound) -> Conn { + let mut conn = Conn::new(out, false, None); + let sent = on_connect(&mut conn); + assert!(writes(&sent).starts_with(b"GET /path HTTP/1.1\r\n")); + conn +} +#[test] +fn fragmented_headers_stream_one_head_then_body_and_retire_once() { + let mut conn = connected(request(Mode::Normal)); + assert!(on_data(&mut conn, b"HTTP/1.1 200 Fine By Me\r\ncontent-le").is_empty()); + let effects = on_data(&mut conn, b"ngth: 2\r\n\r\nok"); + assert_eq!(kinds(&effects), ["head", "chunk", "retire", "end", "close"]); + assert!(effects.iter().any(|effect| matches!(effect, + Effect::Push(PendingHttpEvent::ResponseHead { status_message, .. }) if status_message == "Fine By Me"))); + assert!(conn.request().is_none()); + assert!(on_closed(&mut conn).is_empty()); +} +#[test] +fn chunked_trailers_are_buffered_and_delivered_together() { + let mut conn = connected(request(Mode::Trailers)); + let effects = on_data( + &mut conn, + b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n2\r\nok\r\n0\r\nX-End: yes\r\n\r\n", + ); + assert_eq!(kinds(&effects), ["retire", "response", "close"]); + assert!(effects.iter().any(|effect| matches!(effect, + Effect::Push(PendingHttpEvent::Response { body, trailers, .. }) + if body == b"ok" && trailers == &[("x-end".into(), "yes".into())]))); +} +#[test] +fn upgrade_returns_binary_head_once_and_tls_remains_outside_the_parser() { + let mut out = request(Mode::Upgrade); + out.key.https = true; + // A user-supplied TLS socket already owns its session. + let mut conn = Conn::new(out, true, None); + on_connect(&mut conn); + let effects = on_data(&mut conn, + b"HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: raw\r\n\r\n\0HEAD\xff"); + assert!(effects.iter().any(|effect| matches!(effect, + Effect::Upgrade { request: 123, status: 101, head, .. } if head == b"\0HEAD\xff"))); + assert!(!effects + .iter() + .any(|effect| matches!(effect, Effect::Push(PendingHttpEvent::ResponseHead { .. })))); + assert!(!effects.iter().any(|effect| matches!(effect, Effect::Close))); + assert!(conn.request().is_none()); + assert!(on_closed(&mut conn).is_empty()); +} +#[test] +fn continue_waits_for_interim_then_sends_the_supplied_body() { + let mut out = request(Mode::Continue); + out.body = b"held".to_vec(); + let mut conn = Conn::new(out, false, None); + let initial = on_connect(&mut conn); + assert!(!writes(&initial).ends_with(b"held")); + let mut supplied = Vec::new(); + continue_body(&mut conn, 123, b"held".to_vec(), &mut supplied); + assert!(supplied.is_empty()); + let interim = on_data(&mut conn, b"HTTP/1.1 100 Continue\r\n\r\n"); + assert_eq!(kinds(&interim), ["continue"]); + assert_eq!(writes(&interim), b"4\r\nheld\r\n0\r\n\r\n"); + let end = on_data(&mut conn, b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n"); + assert_eq!(kinds(&end), ["head", "retire", "end", "close"]); +} +#[test] +fn close_delimited_body_finishes_on_eof() { + let mut conn = connected(request(Mode::Normal)); + let first = on_data(&mut conn, b"HTTP/1.0 200 OK\r\n\r\nbody"); + assert_eq!(kinds(&first), ["head", "chunk"]); + let end = on_eof(&mut conn); + assert_eq!(kinds(&end), ["retire", "end", "close"]); + assert!(on_closed(&mut conn).is_empty()); +} +#[test] +fn reused_socket_without_response_bytes_retries_once() { + let mut out = request(Mode::Normal); + out.reuse = Some(Reuse { + max_free: 4, + idle_ms: 1000, + }); + let mut conn = connected(out); + let effects = on_data(&mut conn, b"HTTP/1.1 200 OK\r\nContent-Length: 1\r\n\r\na"); + assert!(effects + .iter() + .any(|effect| matches!(effect, Effect::Park(_)))); + assert!(conn.reusable()); + let effects = conn.restart(request(Mode::Normal)); + assert!(!writes(&effects).is_empty()); + let effects = on_eof(&mut conn); + let out = effects + .into_iter() + .find_map(|effect| match effect { + Effect::Redispatch(out) => Some(*out), + _ => None, + }) + .expect("a pooled connection that received no bytes retries"); + assert_eq!(out.request_handle, 123); + let mut retry = Conn::retry(out); + on_connect(&mut retry); + let effects = on_eof(&mut retry); + assert!(!effects + .iter() + .any(|effect| matches!(effect, Effect::Redispatch(_)))); + assert!(effects.iter().any(|effect| matches!(effect, + Effect::Push(PendingHttpEvent::CodedError { code, .. }) if code == "ECONNRESET"))); +} +#[test] +fn deadline_retires_the_request_and_discards_later_response_bytes() { + let mut conn = connected(request(Mode::Normal)); + let effects = on_timer(&mut conn); + assert_eq!(kinds(&effects), ["retire", "timeout", "close"]); + assert!(on_data(&mut conn, b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n").is_empty()); + assert!(on_closed(&mut conn).is_empty()); +} +#[test] +fn connect_error_preserves_node_peer_and_transport_fields() { + let mut conn = Conn::new(request(Mode::Normal), false, None); + let effects = on_error(&mut conn, "ECONNREFUSED", "connect", -111); + assert!(effects.iter().any(|effect| matches!(effect, + Effect::Push(PendingHttpEvent::TransportError { message, code, syscall, errno, .. }) + if message == "connect ECONNREFUSED 127.0.0.1:9" && code == "ECONNREFUSED" && syscall == "connect" && *errno == -111))); + assert!(conn.request().is_none()); +} + +#[test] +fn a_cancelled_request_is_closed_without_a_late_event() { + let mut conn = connected(request(Mode::Normal)); + let fx = cancel(&mut conn, 123); + assert!(fx.iter().any(|e| matches!(e, Effect::Close))); + assert!(fx.iter().any(|e| matches!(e, Effect::RetireRequest(123)))); + assert!(!fx.iter().any(|e| matches!(e, Effect::Push(_)))); + assert!(on_closed(&mut conn).is_empty()); +} +#[test] +fn a_response_that_closes_is_never_pooled() { + let mut out = request(Mode::Normal); + out.reuse = Some(Reuse { + max_free: 4, + idle_ms: 1000, + }); + let mut conn = connected(out); + let fx = on_data( + &mut conn, + b"HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: 1\r\n\r\na", + ); + assert!(fx.iter().any(|e| matches!(e, Effect::Close))); + assert!(!fx.iter().any(|e| matches!(e, Effect::Park(_)))); + assert!(!conn.reusable()); +} +#[test] +fn a_close_before_the_head_is_a_socket_hang_up() { + let mut conn = connected(request(Mode::Normal)); + let fx = on_eof(&mut conn); + assert!(fx.iter().any(|e| matches!(e, Effect::Push(PendingHttpEvent::CodedError { message, code, .. }) if message == "socket hang up" && code == "ECONNRESET"))); + assert!(on_closed(&mut conn).is_empty()); +} diff --git a/crates/perry-ext-http/src/client_turnloop/tests.rs b/crates/perry-ext-http/src/client_turnloop/tests.rs index 265aa6497e..4dfdf985d5 100644 --- a/crates/perry-ext-http/src/client_turnloop/tests.rs +++ b/crates/perry-ext-http/src/client_turnloop/tests.rs @@ -204,7 +204,7 @@ fn only_a_complete_keep_alive_response_leaves_the_connection_reusable() { fn an_ipv6_literal_is_dialed_without_brackets() { let url = url::Url::parse("http://[::1]:8080/").unwrap(); assert_eq!(url.host_str(), Some("[::1]")); - assert_eq!(conn::dial_host(&url).as_deref(), Some("::1")); + assert_eq!(protocol::dial_host(&url).as_deref(), Some("::1")); } #[test] @@ -223,414 +223,3 @@ fn a_non_http_url_is_refused_with_a_message() { }); assert!(result.is_err()); } - -// ── The state machine, driven by hand ─────────────────────────────────────── -// -// These feed the sink handlers directly and inspect the effects they return -// instead of performing them, so no loop is needed and nothing is left to a -// scheduling lottery. `GC_TEST_LOCK` serializes them with the crate tests that -// read the process-wide in-flight count and event queue. - -fn lock() -> std::sync::MutexGuard<'static, ()> { - crate::tests::GC_TEST_LOCK - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) -} - -fn outbound(request_handle: Handle, url: &str, pairs: &[(&str, &str)]) -> Outbound { - let tls = TlsOptions::default(); - prepare(Request { - request_handle, - method: "GET", - url, - headers: headers(pairs), - body: Vec::new(), - timeout_ms: None, - agent_handle: 0, - tls: &tls, - continue_mode: false, - }) - .unwrap_or_else(|message| panic!("request prepares: {message}")) -} - -fn connect_id(fx: &[Effect]) -> i64 { - fx.iter() - .find_map(|e| match e { - Effect::Connect { id, .. } => Some(*id), - _ => None, - }) - .expect("a new connection is dialed") -} - -fn written(fx: &[Effect]) -> Vec { - fx.iter() - .filter_map(|e| match e { - Effect::Write(_, bytes) => Some(bytes.clone()), - _ => None, - }) - .flatten() - .collect() -} - -fn pushed(fx: &[Effect]) -> Vec<&'static str> { - fx.iter() - .filter_map(|e| match e { - Effect::Push(event) => Some(match event { - PendingHttpEvent::ResponseHead { .. } => "head", - PendingHttpEvent::ResponseChunk { .. } => "chunk", - PendingHttpEvent::ResponseEnd { .. } => "end", - PendingHttpEvent::Response { .. } => "response", - PendingHttpEvent::Continue { .. } => "continue", - PendingHttpEvent::Timeout { .. } => "timeout", - PendingHttpEvent::Error { .. } => "error", - PendingHttpEvent::CodedError { .. } => "coded-error", - PendingHttpEvent::TransportError { .. } => "transport-error", - _ => "other", - }), - _ => None, - }) - .collect() -} - -fn closes(fx: &[Effect], id: i64) -> bool { - fx.iter().any(|e| matches!(e, Effect::Close(c) if *c == id)) -} - -/// Release whatever a test left in the shared state, as `NET_CLOSED` would. -fn forget_conn(id: i64) { - let fx = with_state(|st| conn::on_closed(st, id)); - for effect in fx { - if let Effect::FreeId(freed) = effect { - perry_ffi::free_handle_id(freed); - } - } -} - -/// Was a `tests.rs` test of the reqwest dispatch (#5892 remainder / -/// issue_4909 early exit): from the moment a request is dispatched until its -/// response events are queued, the exchange must be visible to the exit gate. -/// The in-flight guard is now owned by the exchange itself, so it is held -/// from `start` — before the connect even completes — and released exactly -/// when the terminal event is produced. -#[test] -fn dispatch_request_stays_visible_to_exit_gate_until_response_queued() { - let _lock = lock(); - let request_handle = 0x7e57_0001; - let baseline = crate::js_ext_http_client_inflight(); - let fx = - with_state(|st| start_locked(st, outbound(request_handle, "http://127.0.0.1:9/", &[]))); - let id = connect_id(&fx); - assert!( - crate::js_ext_http_client_inflight() > baseline, - "in-flight guard must be held from dispatch, before the connect completes" - ); - let fx = with_state(|st| conn::on_connect(st, id)); - assert!(written(&fx).starts_with(b"GET / HTTP/1.1\r\n")); - assert!(crate::js_ext_http_client_inflight() > baseline); - - // The head arrives split mid-line across two reads: the first must be - // retained, not dropped. - let fx = with_state(|st| conn::on_data(st, id, b"HTTP/1.1 200 Fine By Me\r\ncontent-le")); - assert!(pushed(&fx).is_empty(), "no event from half a head"); - assert!(crate::js_ext_http_client_inflight() > baseline); - let fx = with_state(|st| conn::on_data(st, id, b"ngth: 2\r\n\r\nok")); - assert_eq!(pushed(&fx), ["head", "chunk", "end"]); - let reason = fx.iter().find_map(|e| match e { - Effect::Push(PendingHttpEvent::ResponseHead { status_message, .. }) => { - Some(status_message.clone()) - } - _ => None, - }); - assert_eq!( - reason.as_deref(), - Some("Fine By Me"), - "the server's own reason phrase" - ); - assert!(closes(&fx, id), "no agent: the connection is not pooled"); - assert_eq!( - crate::js_ext_http_client_inflight(), - baseline, - "the guard must release with the terminal event" - ); - forget_conn(id); -} - -#[test] -fn a_kept_alive_connection_is_parked_after_end_and_reused() { - let _lock = lock(); - let agent = 0x7e57_a9e1; - let mut first = outbound(0x7e57_0010, "http://127.0.0.1:9/a", &[]); - first.key.agent = agent; - first.reuse = Some(Reuse { - max_free: 2, - idle_ms: 60_000, - }); - let fx = with_state(|st| start_locked(st, first)); - let id = connect_id(&fx); - with_state(|st| conn::on_connect(st, id)); - let fx = - with_state(|st| conn::on_data(st, id, b"HTTP/1.1 200 OK\r\ncontent-length: 1\r\n\r\na")); - assert_eq!(pushed(&fx), ["head", "chunk", "end"]); - assert!( - !closes(&fx, id), - "a complete keep-alive response parks the connection" - ); - assert!(fx - .iter() - .any(|e| matches!(e, Effect::SetRef(c, false) if *c == id))); - - let reused_before = reused_total(); - let mut second = outbound(0x7e57_0011, "http://127.0.0.1:9/b", &[]); - second.key.agent = agent; - second.reuse = Some(Reuse { - max_free: 2, - idle_ms: 60_000, - }); - let fx = with_state(|st| start_locked(st, second)); - assert!( - !fx.iter().any(|e| matches!(e, Effect::Connect { .. })), - "the parked connection is reused, not a new one dialed" - ); - assert!(written(&fx).starts_with(b"GET /b HTTP/1.1\r\n")); - assert_eq!(reused_total(), reused_before + 1); - - // The stale keep-alive race: the peer closes before any response byte. - // The request goes again on a fresh connection instead of failing. - let fx = with_state(|st| conn::on_eof(st, id)); - assert!( - fx.iter() - .any(|e| matches!(e, Effect::Redispatch(out, _) if out.request_handle == 0x7e57_0011)), - "a reused connection that dies before the response is retried once" - ); - assert!(pushed(&fx).is_empty(), "and no error reaches the request"); - forget_conn(id); -} - -#[test] -fn a_response_that_closes_is_never_pooled() { - let _lock = lock(); - let mut out = outbound(0x7e57_0020, "http://127.0.0.1:9/", &[]); - out.key.agent = 0x7e57_a9e2; - out.reuse = Some(Reuse { - max_free: 2, - idle_ms: 60_000, - }); - let fx = with_state(|st| start_locked(st, out)); - let id = connect_id(&fx); - with_state(|st| conn::on_connect(st, id)); - let fx = with_state(|st| { - conn::on_data( - st, - id, - b"HTTP/1.1 200 OK\r\nconnection: close\r\ncontent-length: 1\r\n\r\na", - ) - }); - assert_eq!(pushed(&fx), ["head", "chunk", "end"]); - assert!(closes(&fx, id)); - forget_conn(id); -} - -#[test] -fn a_deadline_times_the_exchange_out_and_closes_it() { - let _lock = lock(); - let mut out = outbound(0x7e57_0030, "http://127.0.0.1:9/", &[]); - out.timeout_ms = Some(50); - let fx = with_state(|st| start_locked(st, out)); - let id = connect_id(&fx); - let timer = fx - .iter() - .find_map(|e| match e { - Effect::ArmTimer(timer, 50) => Some(*timer), - _ => None, - }) - .expect("the deadline is armed at dispatch"); - let fx = with_state(|st| conn::on_timer(st, timer)); - assert_eq!(pushed(&fx), ["timeout"]); - assert!(closes(&fx, id)); - // A response racing the deadline is dropped, not delivered twice. - let fx = - with_state(|st| conn::on_data(st, id, b"HTTP/1.1 200 OK\r\ncontent-length: 0\r\n\r\n")); - assert!(pushed(&fx).is_empty()); - forget_conn(id); -} - -#[test] -fn a_close_before_the_head_is_a_socket_hang_up() { - let _lock = lock(); - let fx = with_state(|st| start_locked(st, outbound(0x7e57_0040, "http://127.0.0.1:9/", &[]))); - let id = connect_id(&fx); - with_state(|st| conn::on_connect(st, id)); - let fx = with_state(|st| conn::on_eof(st, id)); - assert_eq!(pushed(&fx), ["coded-error"]); - let message = fx.iter().find_map(|e| match e { - Effect::Push(PendingHttpEvent::CodedError { message, code, .. }) => { - Some((message.clone(), code.clone())) - } - _ => None, - }); - assert_eq!( - message, - Some(("socket hang up".to_string(), "ECONNRESET".to_string())) - ); - forget_conn(id); -} - -#[test] -fn a_body_delimited_by_the_close_ends_at_eof() { - let _lock = lock(); - let fx = with_state(|st| start_locked(st, outbound(0x7e57_0050, "http://127.0.0.1:9/", &[]))); - let id = connect_id(&fx); - with_state(|st| conn::on_connect(st, id)); - let fx = with_state(|st| conn::on_data(st, id, b"HTTP/1.0 200 OK\r\n\r\npartial")); - assert_eq!(pushed(&fx), ["head", "chunk"]); - let fx = with_state(|st| conn::on_eof(st, id)); - assert_eq!(pushed(&fx), ["end"]); - forget_conn(id); -} - -#[test] -fn a_connect_failure_names_the_peer_as_node_does() { - let _lock = lock(); - let fx = with_state(|st| start_locked(st, outbound(0x7e57_0060, "http://127.0.0.1:1/", &[]))); - let id = connect_id(&fx); - let fx = with_state(|st| conn::on_error(st, id, "ECONNREFUSED", "connect", -111)); - let message = fx.iter().find_map(|e| match e { - Effect::Push(PendingHttpEvent::TransportError { message, .. }) => Some(message.clone()), - _ => None, - }); - assert_eq!(message.as_deref(), Some("connect ECONNREFUSED 127.0.0.1:1")); - forget_conn(id); -} - -#[test] -fn trailers_are_delivered_with_the_buffered_response() { - let _lock = lock(); - let fx = with_state(|st| { - start_locked( - st, - outbound(0x7e57_0070, "http://127.0.0.1:9/", &[("TE", "trailers")]), - ) - }); - let id = connect_id(&fx); - let fx = with_state(|st| conn::on_connect(st, id)); - assert!(String::from_utf8_lossy(&written(&fx)).contains("Connection: close\r\n")); - let fx = with_state(|st| { - conn::on_data( - st, - id, - b"HTTP/1.1 200 OK\r\ntransfer-encoding: chunked\r\n\r\n2\r\nok\r\n0\r\nx-sum: 7\r\n\r\n", - ) - }); - assert_eq!(pushed(&fx), ["response"]); - let delivered = fx.iter().find_map(|e| match e { - Effect::Push(PendingHttpEvent::Response { body, trailers, .. }) => { - Some((body.clone(), trailers.clone())) - } - _ => None, - }); - assert_eq!( - delivered, - Some((b"ok".to_vec(), vec![("x-sum".to_string(), "7".to_string())])) - ); - forget_conn(id); -} - -#[test] -fn an_expect_continue_body_waits_for_the_interim_response() { - let _lock = lock(); - let tls = TlsOptions::default(); - let out = prepare(Request { - request_handle: 0x7e57_0080, - method: "POST", - url: "http://127.0.0.1:9/up", - headers: headers(&[("Expect", "100-continue")]), - body: Vec::new(), - timeout_ms: None, - agent_handle: 0, - tls: &tls, - continue_mode: true, - }) - .unwrap_or_else(|message| panic!("{message}")); - let fx = with_state(|st| start_locked(st, out)); - let id = connect_id(&fx); - let fx = with_state(|st| conn::on_connect(st, id)); - let head = String::from_utf8(written(&fx)).unwrap(); - assert!(head.contains("Transfer-Encoding: chunked\r\n"), "{head}"); - assert!(head.ends_with("\r\n\r\n"), "only the head goes out: {head}"); - - // `end()` runs before the server's `100`: the body is held. - let fx = with_state(|st| { - let mut fx = Vec::new(); - conn::continue_body(st, 0x7e57_0080, b"data".to_vec(), &mut fx); - fx - }); - assert!( - written(&fx).is_empty(), - "the body waits for the interim 100" - ); - let fx = with_state(|st| conn::on_data(st, id, b"HTTP/1.1 100 Continue\r\n\r\n")); - assert_eq!(pushed(&fx), ["continue"]); - assert_eq!(written(&fx), b"4\r\ndata\r\n0\r\n\r\n"); - let fx = - with_state(|st| conn::on_data(st, id, b"HTTP/1.1 200 OK\r\ncontent-length: 0\r\n\r\n")); - assert_eq!(pushed(&fx), ["head", "end"]); - forget_conn(id); -} - -#[test] -fn a_101_hands_the_connection_to_net_with_the_bytes_after_the_head() { - let _lock = lock(); - let fx = with_state(|st| { - start_locked( - st, - outbound( - 0x7e57_0090, - "http://127.0.0.1:9/ws", - &[("Connection", "Upgrade"), ("Upgrade", "websocket")], - ), - ) - }); - let id = connect_id(&fx); - with_state(|st| conn::on_connect(st, id)); - let fx = with_state(|st| { - conn::on_data( - st, - id, - b"HTTP/1.1 101 Switching Protocols\r\nupgrade: websocket\r\nconnection: upgrade\r\n\r\n\x81\x00", - ) - }); - let handoff = fx.iter().find_map(|e| match e { - Effect::Handoff(c, PendingHttpEvent::Upgrade { head, status, .. }) if *c == id => { - Some((*status, head.clone())) - } - _ => None, - }); - assert_eq!(handoff, Some((101, vec![0x81, 0x00]))); - assert!(!closes(&fx, id), "the handle now belongs to net"); - assert!( - with_state(|st| !st.conns.contains_key(&id)), - "the transport forgets the connection without closing it" - ); - perry_ffi::free_handle_id(id); -} - -#[test] -fn a_cancelled_request_is_closed_without_an_event() { - let _lock = lock(); - let fx = with_state(|st| start_locked(st, outbound(0x7e57_00a0, "http://127.0.0.1:9/", &[]))); - let id = connect_id(&fx); - let fx = with_state(|st| { - let mut fx = Vec::new(); - conn::cancel(st, 0x7e57_00a0, &mut fx); - fx - }); - assert!(pushed(&fx).is_empty()); - assert!(closes(&fx, id)); - let fx = with_state(|st| conn::on_closed(st, id)); - assert!(pushed(&fx).is_empty(), "no late event after a cancel"); - for effect in fx { - if let Effect::FreeId(freed) = effect { - perry_ffi::free_handle_id(freed); - } - } -} diff --git a/crates/perry-ext-http/src/server/server/upgrade_tests.rs b/crates/perry-ext-http/src/server/server/upgrade_tests.rs index 09c38c17b3..14f657d631 100644 --- a/crates/perry-ext-http/src/server/server/upgrade_tests.rs +++ b/crates/perry-ext-http/src/server/server/upgrade_tests.rs @@ -40,14 +40,15 @@ fn server(https: bool) -> i64 { } } -fn queue(server_handle: i64, request_handle: i64, raw_socket_id: i64) { +fn queue(server_handle: i64, request_handle: i64, raw_socket_value: f64) { TURNLOOP_UPGRADES .lock() .unwrap() .push_back(HttpPendingUpgrade { server_handle, request_handle, - raw_socket_id, + raw_socket_value, + owner_agent: perry_ffi::agent_post::current_agent(), ws_id: 0, head: vec![0xff, 0, 0x80], }); @@ -66,8 +67,11 @@ fn delivery(https: bool) { }) .unwrap(); let request = perry_ffi::register_handle(String::from("request")); - let socket = perry_ffi::reserve_handle_id(); - assert!(perry_ext_net::adopt_turnloop_upgrade(socket)); + let socket = scope.root_nanbox(perry_ext_net::native_transport::new_socket( + 0, + f64::from_bits(TAG_UNDEFINED), + )); + let socket = socket.get(); queue(server, request, socket); assert_eq!(drain_upgrades(server), 1); unsafe { @@ -78,10 +82,10 @@ fn delivery(https: bool) { ); assert_eq!( closure_capture_f64(callback.get() as *const _, 2).to_bits(), - POINTER_TAG | socket as u64 + socket.to_bits() ); assert_eq!( - perry_ext_net::js_net_socket_get_destroyed(socket).to_bits(), + perry_ext_net::native_transport::get(socket, "destroyed").to_bits(), 0x7ffc_0000_0000_0003 ); } @@ -93,7 +97,7 @@ fn delivery(https: bool) { unsafe { assert_eq!(closure_capture_f64(callback.get() as *const _, 0), 1.0); assert_eq!( - perry_ext_net::js_net_socket_get_destroyed(socket).to_bits(), + perry_ext_net::native_transport::get(socket, "destroyed").to_bits(), 0x7ffc_0000_0000_0004 ); } @@ -103,31 +107,45 @@ fn delivery(https: bool) { #[test] fn https_upgrade_delivers_once_then_releases_unclaimed_upgrade() { - delivery(true); + worker(|| delivery(true)); } #[test] fn http_upgrade_delivers_once_then_releases_unclaimed_upgrade() { - delivery(false); + worker(|| delivery(false)); } #[test] fn deleted_server_releases_unclaimed_raw_upgrade() { - let server = server(true); - perry_ffi::drop_handle(server); - let request = perry_ffi::register_handle(String::from("request")); - let socket = perry_ffi::reserve_handle_id(); - assert!(perry_ext_net::adopt_turnloop_upgrade(socket)); - queue(server, request, socket); - assert_eq!(drain_upgrades(server), 1); - assert!(perry_ffi::get_handle::(request).is_none()); - unsafe { + worker(|| { + let scope = perry_ffi::TransientRootScope::enter(); + let server = server(true); + perry_ffi::drop_handle(server); + let request = perry_ffi::register_handle(String::from("request")); + let socket = scope.root_nanbox(perry_ext_net::native_transport::new_socket( + 0, + f64::from_bits(TAG_UNDEFINED), + )); + let socket = socket.get(); + queue(server, request, socket); + assert_eq!(drain_upgrades(server), 1); + assert!(perry_ffi::get_handle::(request).is_none()); assert_eq!( - perry_ext_net::js_net_socket_get_destroyed(socket).to_bits(), + perry_ext_net::native_transport::get(socket, "destroyed").to_bits(), 0x7ffc_0000_0000_0004 ); - } + }); } static OBSERVE_INFO: perry_ffi::JsFunctionInfo = perry_ffi::JsFunctionInfo::of(observe as perry_ffi::JsBody3).with_declared(3); + +fn worker(body: impl FnOnce() + Send + 'static) { + std::thread::spawn(|| { + let agent = perry_runtime::agent::enter_worker_agent(); + body(); + perry_runtime::agent::retire_agent(agent); + }) + .join() + .unwrap(); +} diff --git a/crates/perry-ext-http/src/server/turnloop_h2/backpressure_tests.rs b/crates/perry-ext-http/src/server/turnloop_h2/backpressure_tests.rs index 18a478c154..5ca54643b0 100644 --- a/crates/perry-ext-http/src/server/turnloop_h2/backpressure_tests.rs +++ b/crates/perry-ext-http/src/server/turnloop_h2/backpressure_tests.rs @@ -7,6 +7,8 @@ use perry_ffi::{get_handle, get_handle_mut, register_handle}; fn connection(id: i64) -> H2Conn { H2Conn { + wire: Vec::new(), + deadline: None, id, role: Role::Server, server_handle: 0, @@ -20,6 +22,7 @@ fn connection(id: i64) -> H2Conn { handshaking: false, connecting: false, client_tls: None, + tls_session: None, alpn: None, peer_address: String::new(), peer_port: 0, @@ -45,6 +48,7 @@ fn connection(id: i64) -> H2Conn { struct Fixture { conn: i64, response: i64, + session: i64, } impl Fixture { fn new() -> Self { @@ -71,12 +75,27 @@ impl Fixture { unreleased: 0, withheld: 0, }); - insert(connection, 0); + insert(connection); let mut response = response::ServerResponse::new(); - response.turnloop = Some((conn, 1)); + let session = crate::server::http2_server::register_turnloop_server_session( + 0, + f64::from_bits(perry_ffi::JsValue::UNDEFINED.bits()), + 0, + false, + "h2c", + Http2SettingsState::default(), + ); + get_handle_mut::(session) + .unwrap() + .turnloop_conn = conn; + response.turnloop = Some(( + crate::server::turnloop_route::ResponseConnection::H2Session(session), + 1, + )); response.turnloop_streaming = true; Self { conn, + session, response: register_handle(response), } } @@ -86,6 +105,7 @@ impl Fixture { } impl Drop for Fixture { fn drop(&mut self) { + perry_ffi::drop_handle(self.session); forget(self.conn); perry_ffi::free_handle_id(self.conn); perry_ffi::drop_handle(self.response); diff --git a/crates/perry-ext-http/src/server/turnloop_serve/native_tests.rs b/crates/perry-ext-http/src/server/turnloop_serve/native_tests.rs new file mode 100644 index 0000000000..706c9ec3bd --- /dev/null +++ b/crates/perry-ext-http/src/server/turnloop_serve/native_tests.rs @@ -0,0 +1,195 @@ +//! N8 exercises the actual HTTP decoder and public upgrade delivery against +//! a TCP peer. Test bookkeeping contains no JS values or native transport maps. + +use super::*; +use perry_ffi::{JsThis, RawClosureHeader}; +use std::cell::{Cell, RefCell}; +use std::io::{Read, Write}; +use std::time::{Duration, Instant}; + +thread_local! { + static CELL: Cell = const { Cell::new(0) }; + static READ: RefCell = const { RefCell::new(String::new()) }; + static HANDLE: RefCell = const { RefCell::new(String::new()) }; + static CLOSED: Cell = const { Cell::new(false) }; + static UPGRADED: Cell = const { Cell::new(false) }; + static TAIL: RefCell> = const { RefCell::new(Vec::new()) }; +} + +fn undefined() -> f64 { + f64::from_bits(JsValue::UNDEFINED.bits()) +} +fn boxed(raw: i64) -> f64 { + f64::from_bits(JsValue::from_object_ptr(raw as *mut u8).bits()) +} + +fn diagnostics(socket: f64) -> (String, String, u8) { + // The ABI validates the opaque core's layout before returning its pointer. + // This diagnostic borrow is read-only and ends before any callback or GC. + let core = unsafe { + &*(net::core(socket).unwrap() + as *const perry_runtime::turnloop_net::transport::TransportCore) + }; + ( + format!("{:?}", core.read_op()), + format!("{:?}", core.handle()), + core.route(), + ) +} + +unsafe extern "C" fn connection(closure: *const RawClosureHeader, _: JsThis, owner: f64) -> f64 { + let scope = TransientRootScope::enter(); + let socket = scope.root_nanbox(owner); + let holder = scope.root_nanbox(perry_ffi::closure_capture_f64(closure, 0)); + net::own_set(holder.get(), "socket", socket.get()); + undefined() +} + +unsafe extern "C" fn closed(_: *const RawClosureHeader, _: JsThis, _: f64) -> f64 { + CLOSED.with(|seen| seen.set(true)); + undefined() +} + +unsafe extern "C" fn data(closure: *const RawClosureHeader, this: JsThis, bytes: f64) -> f64 { + let socket = RootedSocket::new(this.as_f64()); + let bytes = crate::server::types::jsvalue_to_body_bytes(bytes).unwrap(); + TAIL.with(|tail| tail.borrow_mut().extend_from_slice(&bytes)); + let done = TAIL.with(|tail| tail.borrow().len() >= 6); + if done { + TAIL.with(|tail| assert_eq!(&*tail.borrow(), b"TAIL\0\xff")); + net::destroy(socket.value()); + let scope = TransientRootScope::enter(); + let callback = scope.root_addr(perry_ffi::alloc_closure( + perry_ffi::js_function_info!(closed, 1; with_flags(perry_ffi::FN_BUILTIN)), + 0, + ) as i64); + net::once(socket.value(), "close", boxed(callback.get())); + } else { + net::once(socket.value(), "data", boxed(closure as i64)); + net::flow(socket.value()); + } + undefined() +} + +unsafe extern "C" fn upgraded( + closure: *const RawClosureHeader, + _: JsThis, + request: f64, + owner: f64, + head: f64, +) -> f64 { + let socket = RootedSocket::new(owner); + let scope = TransientRootScope::enter(); + let holder = scope.root_nanbox(perry_ffi::closure_capture_f64(closure, 0)); + assert_eq!( + net::own_get(holder.get(), "socket").to_bits(), + socket.value().to_bits() + ); + assert_eq!( + net::socket_link(socket.value()).unwrap().raw(), + CELL.with(Cell::get) + ); + let (read, handle, route) = diagnostics(socket.value()); + assert_eq!(route, net::ROUTE, "upgrade must change the current route"); + READ.with(|before| { + assert_eq!( + &read, + &*before.borrow(), + "the pending read must not be resubmitted" + ) + }); + HANDLE.with(|before| { + assert_eq!( + &handle, + &*before.borrow(), + "the driver handle must not be replaced" + ) + }); + let request = (request.to_bits() & 0x0000_FFFF_FFFF_FFFF) as i64; + assert_eq!( + perry_ffi::get_handle::(request) + .unwrap() + .socket_value + .to_bits(), + socket.value().to_bits() + ); + assert_eq!( + crate::server::types::jsvalue_to_body_bytes(head).unwrap(), + b"\0HEAD\xff", + "binary upgrade head must be preserved" + ); + let state = scope.root_nanbox(net::state(socket.value())); + let parser = scope.root_nanbox(net::own_get(state.get(), "parser")); + assert_eq!( + np::lifecycle(parser.get(), &PARSER), + Ok(np::Lifecycle::Closed) + ); + assert_eq!(net::write(socket.value(), b"READY", 0).unwrap(), 5); + let callback = scope.root_addr(perry_ffi::alloc_closure( + perry_ffi::js_function_info!(data, 1; with_flags(perry_ffi::FN_BUILTIN)), + 0, + ) as i64); + net::once(socket.value(), "data", boxed(callback.get())); + net::flow(socket.value()); + UPGRADED.with(|seen| seen.set(true)); + undefined() +} + +fn turn_until(mut done: impl FnMut() -> bool) { + let until = Instant::now() + Duration::from_secs(10); + while !done() { + assert!(Instant::now() < until, "HTTP upgrade stalled"); + perry_runtime::event_pump::js_loop_turn_bounded(2); + crate::server::server::js_node_http_server_process_pending(); + perry_runtime::promise::js_promise_run_microtasks(); + } +} + +#[test] +fn n8_http_upgrade_preserves_socket_cell_read_and_binary_head_and_tail() { + std::thread::spawn(|| { + let agent = perry_runtime::agent::enter_worker_agent(); + assert!(super::super::enabled()); + crate::server::ensure_gc_scanner_registered(); + let scope = TransientRootScope::enter(); + let holder = scope.root_addr(perry_runtime::object::js_object_alloc(0, 0) as i64); + let mut server = crate::server::server::HttpServer::with_handler(0); + for (event, callback) in [ + ("connection", perry_ffi::js_function_info!(connection, 1; with_flags(perry_ffi::FN_BUILTIN))), + ("upgrade", perry_ffi::js_function_info!(upgraded, 3; with_flags(perry_ffi::FN_BUILTIN))), + ] { + let callback = scope.root_addr(perry_ffi::alloc_closure(callback, 1) as i64); + unsafe { perry_ffi::set_closure_capture_f64(callback.get() as *mut RawClosureHeader, 0, boxed(holder.get())); } + server.listeners.insert(event.into(), vec![callback.get()]); + } + let server = perry_ffi::register_handle(server); + let (listener, port, _) = super::super::listen(server, "127.0.0.1", 0, 128, None, false, true, 6000).unwrap(); + let listener = scope.root_nanbox(listener); + let (go, ready) = std::sync::mpsc::channel(); + let peer = std::thread::spawn(move || { + let mut stream = std::net::TcpStream::connect(("127.0.0.1", port)).unwrap(); + stream.set_read_timeout(Some(Duration::from_secs(10))).unwrap(); + ready.recv_timeout(Duration::from_secs(10)).unwrap(); + stream.write_all(b"GET / HTTP/1.1\r\nHost: localhost\r\nConnection: Upgrade\r\nUpgrade: raw\r\n\r\n\0HEAD\xff").unwrap(); + let mut ack = [0; 5]; + stream.read_exact(&mut ack).unwrap(); + assert_eq!(&ack, b"READY"); + stream.write_all(b"TAIL\0\xff").unwrap(); + let mut eof = [0]; + assert_eq!(stream.read(&mut eof).unwrap(), 0, "Socket.destroy must close the peer descriptor"); + }); + turn_until(|| net::socket_link(net::own_get(boxed(holder.get()), "socket")).is_ok()); + let socket = RootedSocket::new(net::own_get(boxed(holder.get()), "socket")); + let (read, handle, _) = diagnostics(socket.value()); + assert_ne!(read, "None", "the accepted Socket has already armed its real read"); + READ.with(|before| *before.borrow_mut() = read); + HANDLE.with(|before| *before.borrow_mut() = handle); + CELL.with(|before| before.set(net::socket_link(socket.value()).unwrap().raw())); + go.send(()).unwrap(); + turn_until(|| UPGRADED.with(Cell::get) && CLOSED.with(Cell::get) && TAIL.with(|tail| tail.borrow().len() == 6)); + peer.join().unwrap(); + net::close_server(listener.get()); + perry_runtime::agent::retire_agent(agent); + perry_ffi::drop_handle(server); + }).join().unwrap(); +} diff --git a/crates/perry-ext-http/src/tests.rs b/crates/perry-ext-http/src/tests.rs index 65ac48be21..457406f99a 100644 --- a/crates/perry-ext-http/src/tests.rs +++ b/crates/perry-ext-http/src/tests.rs @@ -66,6 +66,12 @@ fn gc_mutable_scanner_rewrites_request_response_listener_roots() { let request_once_callback = young_gc_root(); let request_once_wrapper = young_gc_root(); let incoming_listener = young_gc_root(); + let request_socket = young_gc_root(); + let incoming_socket = young_gc_root(); + let upgrade_socket = young_gc_root(); + let create_connection = young_gc_root(); + let active_socket = young_gc_root(); + let free_socket = young_gc_root(); let mut request_listeners = HashMap::new(); request_listeners.insert( "error".to_string(), @@ -80,6 +86,7 @@ fn gc_mutable_scanner_rewrites_request_response_listener_roots() { }], ); let request_handle = register_handle(ClientRequestHandle { + surface: client_request_surface::ClientRequestSurfaceState::default(), owner_agent: perry_ffi::agent_post::current_agent(), async_id: 0, method: "GET".to_string(), @@ -99,11 +106,12 @@ fn gc_mutable_scanner_rewrites_request_response_listener_roots() { close_emitted: false, agent_handle: 0, agent_key: "localhost::".to_string(), - request_create_connection: 0, + request_create_connection: create_connection, agent_active: false, agent_queued: false, reused_socket: false, - socket_handle: 0, + socket_handle: request_socket, + socket_snapshot: None, abort_signal_bits: 0, abort_listener_bits: 0, tls: crate::tls_client::TlsOptions::default(), @@ -127,18 +135,34 @@ fn gc_mutable_scanner_rewrites_request_response_listener_roots() { encoding: None, decoder_pending: Vec::new(), pipes: Vec::new(), - socket_handle: 0, + socket_handle: incoming_socket, request_handle, http_version: (1, 1), complete: true, }); + let agent_handle = register_handle(agent::AgentHandle { + active_socket_handles: HashMap::from([("peer".into(), vec![active_socket])]), + free_socket_handles: HashMap::from([("peer".into(), vec![free_socket])]), + ..Default::default() + }); + push_event(PendingHttpEvent::Upgrade { + request_handle, + status: 101, + status_message: "Switching Protocols".into(), + headers: Vec::new(), + socket_handle: upgrade_socket, + head: Vec::new(), + }); + let _ = perry_runtime::gc::gc_collect_minor(); { let req = get_handle::(request_handle) .expect("request handle should remain live"); assert_rewritten(response_callback, req.response_callback); + assert_rewritten(request_socket, req.socket_handle); + assert_rewritten(create_connection, req.request_create_connection); assert_rewritten(response_raw_wrapper, req.response_raw_wrapper); assert_rewritten(request_listener, req.listeners["error"][0].callback); assert_rewritten(request_once_callback, req.listeners["timeout"][0].callback); @@ -149,6 +173,7 @@ fn gc_mutable_scanner_rewrites_request_response_listener_roots() { let msg = get_handle::(incoming_handle) .expect("incoming message handle should remain live"); assert_rewritten(incoming_listener, msg.listeners["data"][0]); + assert_rewritten(incoming_socket, msg.socket_handle); assert_eq!(msg.request_handle, request_handle); assert_eq!( js_http_incoming_message_req(incoming_handle).to_bits(), @@ -156,8 +181,22 @@ fn gc_mutable_scanner_rewrites_request_response_listener_roots() { "client IncomingMessage.req must expose its paired ClientRequest" ); } + let agent = get_handle::(agent_handle).unwrap(); + assert_rewritten(active_socket, agent.active_socket_handles["peer"][0]); + assert_rewritten(free_socket, agent.free_socket_handles["peer"][0]); + let mut queue = HTTP_PENDING_EVENTS.lock().unwrap(); + let index = queue + .iter() + .position(|entry| matches!(entry.event, PendingHttpEvent::Upgrade { request_handle: id, .. } if id == request_handle)) + .unwrap(); + let event = queue.remove(index); + if let PendingHttpEvent::Upgrade { socket_handle, .. } = event.event { + assert_rewritten(upgrade_socket, socket_handle); + } + drop(queue); drop_handle(request_handle); drop_handle(incoming_handle); + drop_handle(agent_handle); } /// The streamed-response drain (`ResponseHead` → N×`ResponseChunk` → @@ -174,6 +213,7 @@ fn gc_mutable_scanner_rewrites_request_response_listener_roots() { /// no live codegen — only the handle registry the other tests already use. fn drain_streamed_body(chunks: &[&[u8]]) -> Vec { let request_handle = register_handle(ClientRequestHandle { + surface: client_request_surface::ClientRequestSurfaceState::default(), owner_agent: perry_ffi::agent_post::current_agent(), async_id: 0, method: "GET".to_string(), @@ -198,6 +238,7 @@ fn drain_streamed_body(chunks: &[&[u8]]) -> Vec { agent_queued: false, reused_socket: false, socket_handle: 0, + socket_snapshot: None, abort_signal_bits: 0, abort_listener_bits: 0, tls: crate::tls_client::TlsOptions::default(), diff --git a/crates/perry-ext-http/tests/turnloop_client_exchange.rs b/crates/perry-ext-http/tests/turnloop_client_exchange.rs index c582040064..b3b514e5c8 100644 --- a/crates/perry-ext-http/tests/turnloop_client_exchange.rs +++ b/crates/perry-ext-http/tests/turnloop_client_exchange.rs @@ -72,19 +72,100 @@ unsafe extern "C" fn js_bun_http_response_snapshot_json( std::ptr::null_mut() } -/// `ClientRequestHandle` ids that are deliberately not in the handle registry. -/// Nothing in the transport dereferences them — they are the addresses events -/// are queued against — and `js_ext_http_client_inflight` treats an unknown -/// handle as having no socket facade, which is the counted case. -const GET_307: i64 = 0x5eed_c11e; -const POST_BODY: i64 = GET_307 + 1; -const POOLED_A: i64 = GET_307 + 2; -const POOLED_B: i64 = GET_307 + 3; -const DEADLINE: i64 = GET_307 + 4; -const TRAILERS: i64 = GET_307 + 5; -const SECURE: i64 = GET_307 + 6; -const OVER_SOCKET: i64 = GET_307 + 7; -const DEFERRED: i64 = GET_307 + 8; +// All requests and Agents below use their actual public app records. +extern "C" { + fn js_http_agent_new(options: f64) -> i64; +} +fn undefined() -> f64 { + f64::from_bits(perry_ffi::JsValue::UNDEFINED.bits()) +} +fn json_object(value: &serde_json::Value) -> f64 { + let text = perry_ffi::alloc_string(&value.to_string()); + unsafe { f64::from_bits(perry_runtime::json::js_json_parse(text.as_raw().cast()).bits()) } +} +fn request_options( + method: &str, + address: &str, + headers: &HashMap, + timeout: Option, + agent: i64, + ca: Vec>, +) -> f64 { + let url = url::Url::parse(address).unwrap(); + let path = format!( + "{}{}", + url.path(), + url.query() + .map(|query| format!("?{query}")) + .unwrap_or_default() + ); + let mut options = serde_json::json!({ "method": method, "protocol": format!("{}:", url.scheme()), "hostname": url.host_str().unwrap(), "port": url.port_or_known_default().unwrap(), "path": path, "headers": headers }); + if let Some(timeout) = timeout { + options["timeout"] = timeout.into(); + } + if !ca.is_empty() { + options["ca"] = ca + .iter() + .map(|pem| String::from_utf8(pem.clone()).unwrap()) + .collect::>() + .into(); + } + let owner = json_object(&options); + if agent != 0 { + perry_ext_net::native_transport::own_set( + owner, + "agent", + f64::from_bits(perry_ffi::JsValue::from_object_ptr(agent as *mut u8).bits()), + ); + } + owner +} +fn dispatch( + method: &str, + url: &str, + headers: &HashMap, + body: &[u8], + timeout: Option, + agent: i64, +) -> bool { + dispatch_tls(method, url, headers, body, timeout, agent, Vec::new()) +} +fn dispatch_tls( + method: &str, + url: &str, + headers: &HashMap, + body: &[u8], + timeout: Option, + agent: i64, + ca: Vec>, +) -> bool { + let scope = perry_ffi::TransientRootScope::enter(); + let options = scope.root_nanbox(request_options(method, url, headers, timeout, agent, ca)); + let accepted = client_turnloop::accepted_total(); + let request = unsafe { + if url.starts_with("https:") { + perry_ext_http::js_https_request(options.get(), 0) + } else { + perry_ext_http::js_http_request(options.get(), 0) + } + }; + let body = if body.is_empty() { + undefined() + } else { + f64::from_bits(perry_ffi::JsValue::from_object_ptr(perry_ffi::alloc_buffer(body)).bits()) + }; + unsafe { + perry_ext_http::js_http_client_request_end(request, body); + } + client_turnloop::accepted_total() == accepted + 1 +} +unsafe extern "C" fn supplied_connection( + closure: *const perry_ffi::RawClosureHeader, + _: perry_ffi::JsThis, + _: f64, +) -> f64 { + perry_ffi::closure_capture_f64(closure, 0) +} const CERT_PEM: &[u8] = include_bytes!("../../../test-parity/node-suite/tls/fixtures/localhost-cert.pem"); @@ -101,7 +182,13 @@ const KEY_PEM: &[u8] = /// completion. (The single-exchange version of this test never noticed: its /// only events were pushed after its last turn.) fn turn() { + perry_runtime::timer::js_event_loop_timers_phase(); perry_runtime::event_pump::js_loop_turn_bounded(0); + perry_runtime::timer::js_event_loop_poll_callbacks(); + perry_runtime::timer::js_event_loop_check_phase(); + unsafe { + perry_ext_http::js_http_process_pending(); + } std::thread::sleep(Duration::from_millis(1)); } @@ -229,7 +316,7 @@ fn a_cleartext_get_is_carried_and_a_307_is_not_followed() { let completed_before = client_turnloop::completed_total(); let url = format!("http://127.0.0.1:{port}/start"); assert!( - client_turnloop::try_dispatch(GET_307, "GET", &url, &no_headers(), &[], None, 0), + dispatch("GET", &url, &no_headers(), &[], None, 0), "a cleartext GET must be carried" ); drive("GET 307"); @@ -281,9 +368,7 @@ fn a_post_body_and_an_explicit_host_reach_the_wire() { ] .into(); let url = format!("http://127.0.0.1:{port}/upload"); - assert!(client_turnloop::try_dispatch( - POST_BODY, "POST", &url, &headers, b"hello", None, 0 - )); + assert!(dispatch("POST", &url, &headers, b"hello", None, 0)); drive("POST body"); assert_eq!(client_turnloop::completed_total(), completed_before + 1); @@ -338,29 +423,13 @@ fn an_agent_with_keep_alive_reuses_one_connection() { let reused_before = client_turnloop::reused_total(); let completed_before = client_turnloop::completed_total(); let url = format!("http://127.0.0.1:{port}/pooled"); - // Agent key 0x7a is not a registered AgentHandle; the pool keys by it all - // the same, which is all this needs. - assert!(client_turnloop::try_dispatch_pooled( - POOLED_A, - "GET", - &url, - &no_headers(), - &[], - 0x7a, - 4, - 5_000 - )); + let options = json_object( + &serde_json::json!({ "keepAlive": true, "maxFreeSockets": 4, "keepAliveMsecs": 5000 }), + ); + let agent = unsafe { js_http_agent_new(options) }; + assert!(dispatch("GET", &url, &no_headers(), &[], None, agent)); drive("pooled A"); - assert!(client_turnloop::try_dispatch_pooled( - POOLED_B, - "GET", - &url, - &no_headers(), - &[], - 0x7a, - 4, - 5_000 - )); + assert!(dispatch("GET", &url, &no_headers(), &[], None, agent)); drive("pooled B"); assert_eq!(client_turnloop::completed_total(), completed_before + 2); assert_eq!( @@ -383,7 +452,7 @@ fn an_agent_with_keep_alive_reuses_one_connection() { .iter() .all(|h| h.starts_with("GET /pooled HTTP/1.1\r\n"))); // Close the parked connection so the binary exits with no open handle. - client_turnloop::purge_agent(0x7a); + client_turnloop::purge_agent(agent); settle(); } @@ -402,15 +471,7 @@ fn a_deadline_tears_down_an_exchange_the_server_never_answers() { let completed_before = client_turnloop::completed_total(); let url = format!("http://127.0.0.1:{port}/silent"); let started = Instant::now(); - assert!(client_turnloop::try_dispatch( - DEADLINE, - "GET", - &url, - &no_headers(), - &[], - Some(150), - 0 - )); + assert!(dispatch("GET", &url, &no_headers(), &[], Some(150), 0)); drive("deadline"); assert_eq!( client_turnloop::timed_out_total(), @@ -444,15 +505,7 @@ fn a_te_trailers_response_is_decoded_to_its_end() { let completed_before = client_turnloop::completed_total(); let headers: HashMap = [("TE".to_string(), "trailers".to_string())].into(); let url = format!("http://127.0.0.1:{port}/trailers"); - assert!(client_turnloop::try_dispatch( - TRAILERS, - "GET", - &url, - &headers, - &[], - None, - 0 - )); + assert!(dispatch("GET", &url, &headers, &[], None, 0)); drive("trailers"); assert_eq!(client_turnloop::completed_total(), completed_before + 1); let head = received @@ -508,8 +561,7 @@ fn an_https_request_handshakes_with_the_callers_ca() { let handshakes_before = client_turnloop::tls_handshakes_total(); let completed_before = client_turnloop::completed_total(); let url = format!("https://127.0.0.1:{port}/secure"); - assert!(client_turnloop::try_dispatch_tls( - SECURE, + assert!(dispatch_tls( "GET", &url, &no_headers(), @@ -566,30 +618,51 @@ fn a_create_connection_socket_is_read_when_net_says_it_is_ready() { }); let stream = TcpStream::connect(("127.0.0.1", port)).expect("the client side connects"); - let socket = perry_ext_net::adopt_upgraded_tcp_stream(stream); - assert_ne!( - socket, - perry_ffi::INVALID_HANDLE, - "ext-net adopted the socket" - ); - // Publish the raw-net vtable (the adoption itself already ran, here). - perry_ext_net::ensure_adopted_socket_dispatch(); - assert!( - perry_ffi::raw_net().is_some(), - "perry-ext-net's raw-net vtable must be published, or this shape tests nothing" - ); - + let scope = perry_ffi::TransientRootScope::enter(); + let socket = scope.root_nanbox(perry_ext_net::native_transport::new_socket( + perry_ext_net::native_transport::ROUTE, + undefined(), + )); + unsafe { + let link = perry_ext_net::native_transport::socket_link(socket.get()).unwrap(); + perry_ffi::turnloop_net::link_adopt_stream( + &mut *perry_ext_net::native_transport::core(socket.get()).unwrap(), + link, + stream.into(), + ) + .unwrap(); + } + perry_ext_net::native_transport::adopted(socket.get()); + let create = scope.root_addr(perry_ffi::alloc_closure( + perry_ffi::js_function_info!(supplied_connection, 1; with_flags(perry_ffi::FN_BUILTIN)), + 1, + ) as i64); + unsafe { + perry_ffi::set_closure_capture_f64( + create.get() as *mut perry_ffi::RawClosureHeader, + 0, + socket.get(), + ); + } let completed_before = client_turnloop::raw_completed_total(); let url = format!("http://127.0.0.1:{port}/over-socket"); - client_turnloop::try_dispatch_over_socket( - OVER_SOCKET, + let options = scope.root_nanbox(request_options( "GET", &url, &no_headers(), - &[], None, - socket, + 0, + Vec::new(), + )); + perry_ext_net::native_transport::own_set( + options.get(), + "createConnection", + f64::from_bits(perry_ffi::JsValue::from_object_ptr(create.get() as *mut u8).bits()), ); + let request = unsafe { perry_ext_http::js_http_request(options.get(), 0) }; + unsafe { + perry_ext_http::js_http_client_request_end(request, undefined()); + } drive("createConnection"); assert_eq!( client_turnloop::raw_completed_total(), @@ -605,12 +678,21 @@ fn a_create_connection_socket_is_read_when_net_says_it_is_ready() { assert!(head.contains("Connection: close\r\n"), "{head}"); } -/// The Agent facade's idle expiry and `req.setTimeout`'s early `'timeout'` are -/// deadlines on the loop now (they were tokio sleeps). +/// An unended request's timeout is an ordinary JS Timeout. Drive the same +/// timers phase as the compiled event loop, in addition to transport I/O. fn a_deferred_event_fires_from_a_loop_deadline() { let fired_before = client_turnloop::deferred_fired_total(); let started = Instant::now(); - client_turnloop::schedule_timeout_for_test(DEFERRED, 40); + let options = request_options( + "GET", + "http://127.0.0.1:9/unended", + &no_headers(), + None, + 0, + Vec::new(), + ); + let request = unsafe { perry_ext_http::js_http_request(options, 0) }; + client_turnloop::schedule_timeout_for_test(request, 40); let deadline = Instant::now() + Duration::from_secs(10); while client_turnloop::deferred_fired_total() == fired_before { turn(); diff --git a/crates/perry-ext-net/src/payload_tests.rs b/crates/perry-ext-net/src/payload_tests.rs new file mode 100644 index 0000000000..3ceaa09e44 --- /dev/null +++ b/crates/perry-ext-net/src/payload_tests.rs @@ -0,0 +1,1062 @@ +//! Real JS emitter and driver witnesses for the new binding implementation. +//! These exercise the same public Socket and Server payloads used by compiled programs. + +use super::{payload_events as events, payload_io as io, payload_server as server}; +use super::{payload_socket as socket, payload_transport as p}; +use perry_ffi::{JsThis, RawClosureHeader, TransientRootScope}; +use std::cell::Cell; + +thread_local! { + static CLOSES: Cell = const { Cell::new(0) }; + static ECHO_BYTES: Cell = const { Cell::new(0) }; +} + +static CODEC_DROPS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); +struct CodecProbe(Vec); +impl Drop for CodecProbe { + fn drop(&mut self) { + CODEC_DROPS.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + } +} +static CODEC_VTABLE: perry_ffi::native_stream::PayloadVTable = perry_ffi::native_stream::payload_vtable::(None); +static CODEC: perry_ffi::native_payload::PayloadFamily = + perry_ffi::native_payload::PayloadFamily::new::(perry_ffi::native_class_ids::HTTP_PARSER, "HTTPParser", false, &CODEC_VTABLE) + .with_constructor_length(0); + +unsafe fn close_codec(owner: f64) { + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(owner); + let state = scope.root_nanbox(socket::state(owner.get())); + let codec = scope.root_nanbox(p::own_get(state.get(), "parser")); + perry_ffi::native_payload::close(codec.get(), &CODEC); +} + +#[test] +fn explicit_socket_close_releases_its_separate_codec_immediately_and_once() { + std::thread::spawn(|| { + let agent = perry_runtime::agent::enter_worker_agent(); + CODEC_DROPS.store(0, std::sync::atomic::Ordering::SeqCst); + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(socket::new_socket(io::ROUTE, p::undefined())); + let parser = scope.root_nanbox(unsafe { perry_ffi::native_payload::alloc_in(&CODEC, "", + CodecProbe(vec![1; 16 * 1024]), + 16 * 1024 + std::mem::size_of::(), + &[], + ) }); + unsafe { + assert_eq!( + perry_ffi::native_payload::payload_mut::(parser.get(), &CODEC) + .unwrap() + .0 + .len(), + 16 * 1024 + ); + crate::native_transport::set_codec(owner.get(), "parser", parser.get(), close_codec); + } + assert_eq!(CODEC_DROPS.load(std::sync::atomic::Ordering::SeqCst), 0); + socket::destroy(owner.get(), p::undefined()); + assert_eq!(CODEC_DROPS.load(std::sync::atomic::Ordering::SeqCst), 1); + assert_eq!( + perry_ffi::native_payload::lifecycle(parser.get(), &CODEC), + Ok(perry_ffi::native_payload::Lifecycle::Closed) + ); + socket::destroy(owner.get(), p::undefined()); + perry_runtime::promise::js_promise_run_microtasks(); + assert_eq!(CODEC_DROPS.load(std::sync::atomic::Ordering::SeqCst), 1); + perry_runtime::agent::retire_agent(agent); + }) + .join() + .unwrap(); +} + +unsafe extern "C" fn saw_close(closure: *const RawClosureHeader, this: JsThis, _: f64) -> f64 { + assert_eq!( + this.as_f64().to_bits(), + perry_ffi::closure_capture_f64(closure, 0).to_bits(), + "close receives its original owner" + ); + CLOSES.with(|closes| closes.set(closes.get() + 1)); + p::undefined() +} + +fn close_listener(owner: f64) { + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(owner); + let callback = scope.root_addr(perry_ffi::alloc_closure( + perry_ffi::js_function_info!(saw_close, 1; with_flags(perry_ffi::FN_BUILTIN)), + 1, + ) as i64); + unsafe { + perry_ffi::set_closure_capture_f64(callback.get() as *mut RawClosureHeader, 0, owner.get()); + } + socket::once(owner.get(), "close", p::boxed_addr(callback.get())); +} + +#[test] +fn idle_destroy_keeps_ordinary_identity_and_delivers_a_late_close_listener() { + std::thread::spawn(|| { + let agent = perry_runtime::agent::enter_worker_agent(); + CLOSES.with(|closes| closes.set(0)); + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(socket::new_socket(io::ROUTE, p::undefined())); + let link = socket::link(owner.get()); + p::own_set(owner.get(), "extra", 17.0); + assert_eq!( + socket::destroy(owner.get(), p::undefined()).to_bits(), + owner.get().to_bits() + ); + assert!( + unsafe { p::socket_ptr(link) }.is_err(), + "explicit destroy disposes native fields now" + ); + assert_eq!( + socket::link(owner.get()), + link, + "the released cell still belongs to this object" + ); + assert_eq!(p::own_get(owner.get(), "extra"), 17.0); + close_listener(owner.get()); + perry_runtime::promise::js_promise_run_microtasks(); + assert_eq!( + CLOSES.with(Cell::get), + 1, + "listener installed after destroy receives close" + ); + perry_runtime::promise::js_promise_run_microtasks(); + assert_eq!(CLOSES.with(Cell::get), 1); + perry_runtime::agent::retire_agent(agent); + }) + .join() + .unwrap(); +} + +#[test] +fn listener_reopens_the_same_cell_before_its_previous_closed_is_dispatched() { + std::thread::spawn(|| { + let agent = perry_runtime::agent::enter_worker_agent(); + assert!(io::enabled()); + CLOSES.with(|closes| closes.set(0)); + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(server::new_server( + io::ROUTE, + p::undefined(), + p::undefined(), + )); + let link = server::link(owner.get()); + let host = scope.root_nanbox(events::string("127.0.0.1")); + server::listen(owner.get(), 0.0, host.get(), p::undefined()); + let old = unsafe { + perry_ffi::turnloop_net::link_snapshot_handle(&mut *p::server_core(link).unwrap(), link) + .unwrap() + }; + server::close(owner.get(), p::undefined()); + close_listener(owner.get()); + server::listen(owner.get(), 0.0, host.get(), p::undefined()); + assert_eq!(server::link(owner.get()), link); + assert!(!unsafe { + perry_ffi::turnloop_net::link_handle_matches( + &mut *p::server_core(link).unwrap(), + link, + &old, + ) + }); + let replacement = unsafe { + perry_ffi::turnloop_net::link_snapshot_handle(&mut *p::server_core(link).unwrap(), link) + .unwrap() + }; + let limit = std::time::Instant::now() + std::time::Duration::from_secs(10); + while CLOSES.with(Cell::get) == 0 { + assert!( + std::time::Instant::now() < limit, + "old Closed was never dispatched" + ); + perry_runtime::event_pump::js_loop_turn_bounded(2); + perry_runtime::promise::js_promise_run_microtasks(); + } + assert_eq!(CLOSES.with(Cell::get), 1); + assert!( + unsafe { + perry_ffi::turnloop_net::link_handle_matches( + &mut *p::server_core(link).unwrap(), + link, + &replacement, + ) + }, + "old Closed must retain the replacement driver's handle" + ); + assert_eq!( + server::get(owner.get(), "listening").to_bits(), + perry_ffi::JsValue::TRUE.bits() + ); + server::close(owner.get(), p::undefined()); + perry_runtime::agent::retire_agent(agent); + }) + .join() + .unwrap(); +} + +unsafe extern "C" fn received_echo(_: *const RawClosureHeader, this: JsThis, chunk: f64) -> f64 { + let owner = this.as_f64(); + assert_eq!( + p::own_get(owner, "marker"), + 23.0, + "the listener receives the original object" + ); + let bytes = crate::jsvalue_to_socket_bytes(chunk).expect("data Buffer"); + assert_eq!(bytes, b"phase-A"); + ECHO_BYTES.with(|count| count.set(count.get() + bytes.len())); + socket::destroy(owner, p::undefined()); + close_listener(owner); // Installed after explicit release, on a live TCP socket. + p::undefined() +} + +unsafe extern "C" fn churn_readable(_: *const RawClosureHeader, this: JsThis) -> f64 { + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(this.as_f64()); + assert_eq!(p::own_get(owner.get(), "churnOwner"), 11919.0); + let value = socket::read(owner.get(), p::undefined()); + if let Some(bytes) = crate::jsvalue_to_socket_bytes(value) { + assert!( + bytes.iter().all(|byte| *byte == b'x'), + "N3 echo bytes corrupted" + ); + ECHO_BYTES.with(|count| count.set(count.get() + bytes.len())); + } + p::undefined() +} + +#[cfg(target_os = "linux")] +fn rss_kib() -> u64 { + let status = std::fs::read_to_string("/proc/self/status").unwrap(); + status + .lines() + .find_map(|line| { + line.strip_prefix("VmRSS:") + .map(|rss| rss.split_whitespace().next().unwrap().parse().unwrap()) + }) + .unwrap() +} + +/// Actual ordinary Socket owners and real loopback connections. Roots are +/// bounded by the concurrency, and all owners are released after each batch. +#[cfg(target_os = "linux")] +#[test] +fn n3_fifty_thousand_sequential_and_fifty_thousand_concurrent_sockets_release_everything() { + use perry_runtime::native_payload::test_census; + let whole_before = test_census::snapshot(); + let measured = std::thread::spawn(|| { + use std::io::{Read, Write}; + let agent = perry_runtime::agent::enter_worker_agent(); + assert!(io::enabled()); + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let peer = std::thread::spawn(move || { + for _ in 0..124_576 { + let (mut conn, _) = listener.accept().unwrap(); + conn.set_read_timeout(Some(std::time::Duration::from_secs(30))).unwrap(); + let mut bytes = [0; 8192]; + conn.read_exact(&mut bytes).unwrap(); + assert!(bytes.iter().all(|byte| *byte == b'x')); + conn.write_all(&bytes).unwrap(); + conn.shutdown(std::net::Shutdown::Write).unwrap(); + let mut tail = [0]; + assert_eq!(conn.read(&mut tail).unwrap(), 0); + } + }); + let scope = TransientRootScope::enter(); + let host = scope.root_nanbox(events::string("127.0.0.1")); + let message = scope.root_nanbox(f64::from_bits(perry_ffi::JsValue::from_object_ptr( + perry_ffi::alloc_buffer(&vec![b'x'; 8192])).bits())); + let readable = scope.root_addr(perry_ffi::alloc_closure( + perry_ffi::js_function_info!(churn_readable, 0; with_flags(perry_ffi::FN_BUILTIN)), 0) as i64); + let mut checkpoint = 0; + let mut floor = 0; + let mut peak = 0; + let mut before = [0; 4]; + // Warm both allocation shapes and return to the sequential shape. + // Each phase spans more than the collector's retained-block age + // window, so a concurrency transition is not mistaken for a leak. + for (total, concurrency, warmup) in [(8192, 1, true), (8192, 64, true), (8192, 1, true), + (50_000, 1, false), (50_000, 64, false)] { + if !warmup && floor == 0 { + // The last warm-up checkpoint has already collected. A + // second back-to-back full would cool the retained arena + // blocks and measure rewarming instead of steady churn. + assert_eq!(checkpoint, 0); + floor = rss_kib(); peak = floor; + before = test_census::snapshot(); + eprintln!("N3 warm rss_kib={floor} counts={before:?}"); + } + let mut done = 0; + while done < total { + let n = concurrency.min(total - done); + { + let batch = TransientRootScope::enter(); + let closes = CLOSES.with(Cell::get); + let echoes = ECHO_BYTES.with(Cell::get); + let mut owners = Vec::with_capacity(n); + for _ in 0..n { + // This is the production exported factory, not a test identity. + let raw = unsafe { crate::js_net_socket_alloc() }; + let owner = batch.root_nanbox(p::boxed_addr(raw)); + p::own_set(owner.get(), "churnOwner", 11919.0); + let event = batch.root_nanbox(events::string("readable")); + unsafe { crate::payload_abi::js_net_socket_on(p::raw_owner(owner.get()), perry_ffi::JsValue::from_bits(event.get().to_bits()).as_string_ptr() as i64, readable.get()); } + assert_eq!(events::listener_count(owner.get(), "readable"), 1); + close_listener(owner.get()); + socket::connect(owner.get(), port as f64, host.get(), p::undefined()); + socket::end(owner.get(), message.get(), p::undefined(), p::undefined()); + owners.push(owner); + } + let limit = std::time::Instant::now() + std::time::Duration::from_secs(30); + while CLOSES.with(Cell::get) < closes + n { + if std::time::Instant::now() >= limit { + for owner in &owners { + if let Ok(payload) = unsafe { p::socket_ptr(socket::link(owner.get())) } { + let fields = unsafe { &(*payload).ext }; + eprintln!("N3 stalled done={done} total={total} connecting={} opened={} read_started={} read_ended={} read_end_emitted={} write_ended={} shutdown_done={} buffered={} bytes_read={} bytes_written={}", fields.connecting, fields.opened, fields.read_started, fields.read_ended, fields.read_end_emitted, fields.write_ended, fields.shutdown_done, fields.read_buffer.len(), fields.bytes_read, fields.bytes_written); + } + } + panic!("N3 connection or close stalled"); + } + perry_runtime::event_pump::js_loop_turn_bounded(2); + perry_runtime::promise::js_promise_run_microtasks(); + } + assert_eq!(ECHO_BYTES.with(Cell::get), echoes + n * 8192); + for owner in &owners { + let cell = socket::link(owner.get()).raw() as *const perry_runtime::native_handle::NativeHandleHeader; + unsafe { + assert!((*cell).resource_ptr.is_null(), "N3 explicit close retained T"); + assert_eq!((*cell).external_bytes, 0); + assert_eq!((*cell).refs, 0, "N3 terminal completion retained a ref"); + } + } + assert_eq!(perry_runtime::turnloop_net::live_handles(), 0, "N3 driver census retained a handle"); + } + done += n; checkpoint += n; + if checkpoint >= 1024 { + checkpoint = 0; + perry_runtime::gc::native_payload_test_collect_full(); + if !warmup { + peak = peak.max(rss_kib()); + assert!(peak.saturating_sub(floor) < 4096, + "N3 RSS grew by {} KiB after warm-up (total={total}, done={done}, counts={:?})", peak.saturating_sub(floor), test_census::snapshot()); + } + } + } + } + peer.join().unwrap(); + perry_runtime::gc::native_payload_test_collect_full(); + let after = test_census::snapshot(); + assert_eq!(after[0] - before[0], 100_000); + assert_eq!(after[2] - before[2], 100_000, "N3 not every T was dropped"); + assert_eq!(after[3], 0, "N3 total cell refs must be zero"); + eprintln!("N3 sequential=50000 concurrent=50000 width=64 rss_warm_kib={floor} rss_peak_kib={peak} delta_kib={} created=100000 drops=100000 live=0 refs=0", peak.saturating_sub(floor)); + perry_runtime::agent::retire_agent(agent); + (before, after) + }).join().unwrap(); + let final_counts = test_census::snapshot(); + // Thread heap teardown also collects the last conservative stack survivor. + let created = final_counts[0] - whole_before[0]; + let finalized = final_counts[1] - whole_before[1]; + let drops = final_counts[2] - whole_before[2]; + assert_eq!(created, 124_576); + assert_eq!(created, finalized, "N3 created cells must all be finalized"); + assert_eq!(created, drops, "N3 created cells must all drop their T"); + assert_eq!(final_counts[3], 0); + assert_eq!(measured.1[0] - measured.0[0], 100_000); + eprintln!("N3 after worker including warm-up: created={created} finalized={finalized} drops={drops} refs={}", final_counts[3]); +} + +#[test] +fn connected_socket_echo_reopens_with_its_same_owner_and_cell() { + std::thread::spawn(|| { + use std::io::{Read, Write}; + let agent = perry_runtime::agent::enter_worker_agent(); + assert!(io::enabled()); + CLOSES.with(|count| count.set(0)); + ECHO_BYTES.with(|count| count.set(0)); + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let peer = std::thread::spawn(move || { + for _ in 0..2 { + let (mut conn, _) = listener.accept().unwrap(); + conn.set_read_timeout(Some(std::time::Duration::from_secs(10))) + .unwrap(); + let mut bytes = [0; 7]; + conn.read_exact(&mut bytes).unwrap(); + assert_eq!(&bytes, b"phase-A"); + conn.write_all(&bytes).unwrap(); + let mut tail = [0]; + assert_eq!( + conn.read(&mut tail).unwrap(), + 0, + "destroy closed the real peer descriptor" + ); + } + }); + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(socket::new_socket(io::ROUTE, p::undefined())); + let cell = socket::link(owner.get()); + p::own_set(owner.get(), "marker", 23.0); + let callback = scope.root_addr(perry_ffi::alloc_closure( + perry_ffi::js_function_info!(received_echo, 1; with_flags(perry_ffi::FN_BUILTIN)), + 0, + ) as i64); + let host = scope.root_nanbox(events::string("127.0.0.1")); + let message = scope.root_nanbox(events::string("phase-A")); + for round in 1..=2 { + socket::once(owner.get(), "data", p::boxed_addr(callback.get())); + socket::connect(owner.get(), port as f64, host.get(), p::undefined()); + socket::write(owner.get(), message.get(), p::undefined(), p::undefined()); + let limit = std::time::Instant::now() + std::time::Duration::from_secs(10); + while CLOSES.with(Cell::get) < round { + assert!( + std::time::Instant::now() < limit, + "echo/terminal close stalled" + ); + perry_runtime::event_pump::js_loop_turn_bounded(2); + perry_runtime::promise::js_promise_run_microtasks(); + } + assert_eq!(socket::link(owner.get()), cell); + assert!(unsafe { p::socket_ptr(cell) }.is_err()); + assert_eq!(ECHO_BYTES.with(Cell::get), 7 * round); + } + peer.join().unwrap(); + perry_runtime::agent::retire_agent(agent); + }) + .join() + .unwrap(); +} + +#[test] +fn n9_localhost_retry_and_pending_resolve_reopen_keep_identity_and_preconnect_writes() { + std::thread::spawn(|| { + use perry_runtime::turnloop_net::transport as core_test; + use std::io::{Read, Write}; + let agent = perry_runtime::agent::enter_worker_agent(); + assert!(io::enabled()); + CLOSES.with(|count| count.set(0)); + ECHO_BYTES.with(|count| count.set(0)); + let old_listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + old_listener.set_nonblocking(true).unwrap(); + let old_address = old_listener.local_addr().unwrap(); + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let peer = std::thread::spawn(move || { + let (mut conn, _) = listener.accept().unwrap(); + conn.set_read_timeout(Some(std::time::Duration::from_secs(10))) + .unwrap(); + let mut bytes = [0; 7]; + conn.read_exact(&mut bytes).unwrap(); + assert_eq!( + &bytes, b"phase-A", + "N9 only the reopened plan's write may arrive" + ); + conn.write_all(&bytes).unwrap(); + let mut tail = [0]; + assert_eq!(conn.read(&mut tail).unwrap(), 0); + }); + let scope = TransientRootScope::enter(); + let raw = unsafe { crate::js_net_socket_alloc() }; + let owner = scope.root_nanbox(p::boxed_addr(raw)); + let cell = socket::link(owner.get()); + let runtime_link = cell.raw(); + let host = scope.root_nanbox(events::string("localhost")); + let old_message = scope.root_nanbox(events::string("oldplan")); + let message = scope.root_nanbox(events::string("phase-A")); + p::own_set(owner.get(), "marker", 23.0); + socket::connect( + owner.get(), + old_address.port() as f64, + host.get(), + p::undefined(), + ); + socket::write( + owner.get(), + old_message.get(), + p::undefined(), + p::undefined(), + ); + let old_op = unsafe { core_test::pending_resolve_for_test(runtime_link) } + .expect("N9 old resolve really pending"); + socket::destroy(owner.get(), p::undefined()); + close_listener(owner.get()); + socket::connect(owner.get(), port as f64, host.get(), p::undefined()); + assert_eq!( + socket::link(owner.get()), + cell, + "N9 reopen retains its cell and object" + ); + let new_op = unsafe { core_test::pending_resolve_for_test(runtime_link) } + .expect("N9 replacement resolve really pending"); + assert_ne!(old_op, new_op, "N9 resolve incarnations differ"); + // Reproduce a delayed answer from the old operation deterministically, + // before draining either real resolver result. No synthetic ref is owed. + unsafe { + core_test::replay_resolve_for_test(runtime_link, old_op, vec![old_address]); + } + assert_eq!( + unsafe { core_test::pending_resolve_for_test(runtime_link) }, + Some(new_op), + "N9 stale resolver result must not replace the new plan" + ); + let callback = scope.root_addr(perry_ffi::alloc_closure( + perry_ffi::js_function_info!(received_echo, 1; with_flags(perry_ffi::FN_BUILTIN)), + 0, + ) as i64); + socket::once(owner.get(), "data", p::boxed_addr(callback.get())); + socket::end(owner.get(), message.get(), p::undefined(), p::undefined()); + let limit = std::time::Instant::now() + std::time::Duration::from_secs(10); + while CLOSES.with(Cell::get) < 2 { + assert!( + std::time::Instant::now() < limit, + "N9 DNS retry or close stalled" + ); + perry_runtime::event_pump::js_loop_turn_bounded(2); + perry_runtime::promise::js_promise_run_microtasks(); + } + peer.join().unwrap(); + assert_eq!(ECHO_BYTES.with(Cell::get), 7); + assert_eq!( + old_listener.accept().unwrap_err().kind(), + std::io::ErrorKind::WouldBlock, + "N9 the stale address must never get a connection" + ); + assert_eq!(socket::link(owner.get()), cell); + let header = cell.raw() as *const perry_runtime::native_handle::NativeHandleHeader; + assert_eq!( + unsafe { (*header).refs }, + 0, + "N9 both resolver completions and close release refs" + ); + assert_eq!(perry_runtime::turnloop_net::live_handles(), 0); + perry_runtime::agent::retire_agent(agent); + }) + .join() + .unwrap(); +} + +#[test] +fn n4_a_destroyed_socket_cell_cannot_be_reused_before_its_owed_closed() { + std::thread::spawn(|| { + use perry_runtime::native_payload::test_census; + let agent = perry_runtime::agent::enter_worker_agent(); + assert!(io::enabled()); + CLOSES.with(|count| count.set(0)); + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let before = test_census::snapshot(); + let old_cell; + { + let scope = TransientRootScope::enter(); + let raw = unsafe { crate::js_net_socket_alloc() }; + let owner = scope.root_nanbox(p::boxed_addr(raw)); + let host = scope.root_nanbox(events::string("127.0.0.1")); + socket::connect(owner.get(), port as f64, host.get(), p::undefined()); + old_cell = socket::link(owner.get()).raw(); + socket::destroy(owner.get(), p::undefined()); + close_listener(owner.get()); + } + // No ordinary root remains. Only the completion's cell ref can keep + // this owner graph alive across this collection and allocator churn. + perry_runtime::gc::native_payload_test_collect_full(); + assert_eq!( + test_census::snapshot()[1], + before[1], + "N4 an owed Closed must prevent cell finalization" + ); + let cell = old_cell as *const perry_runtime::native_handle::NativeHandleHeader; + assert_eq!(unsafe { (*cell).refs }, 1); + assert!(unsafe { (*cell).resource_ptr.is_null() }); + for _ in 0..512 { + let scope = TransientRootScope::enter(); + let raw = unsafe { crate::js_net_socket_alloc() }; + let replacement = scope.root_nanbox(p::boxed_addr(raw)); + assert_ne!( + socket::link(replacement.get()).raw(), + old_cell, + "N4 a new object must not reuse an owed completion's cell" + ); + socket::destroy(replacement.get(), p::undefined()); + } + let limit = std::time::Instant::now() + std::time::Duration::from_secs(10); + while CLOSES.with(Cell::get) == 0 { + assert!( + std::time::Instant::now() < limit, + "N4 old owner's close stalled" + ); + perry_runtime::event_pump::js_loop_turn_bounded(2); + perry_runtime::promise::js_promise_run_microtasks(); + } + assert_eq!( + CLOSES.with(Cell::get), + 1, + "N4 the completion must reach its original object exactly once" + ); + perry_runtime::gc::native_payload_test_collect_full(); + assert_eq!(perry_runtime::turnloop_net::live_handles(), 0); + perry_runtime::agent::retire_agent(agent); + }) + .join() + .unwrap(); +} + +#[test] +fn retained_cork_capacity_is_reported_and_explicit_close_releases_it() { + std::thread::spawn(|| { + let agent = perry_runtime::agent::enter_worker_agent(); + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(socket::new_socket(io::ROUTE, p::undefined())); + let link = socket::link(owner.get()); + let cell = link.raw() as *const perry_runtime::native_handle::NativeHandleHeader; + let initial = unsafe { (*cell).external_bytes }; + assert!(initial >= std::mem::size_of::() as u64); + socket::cork(owner.get(), false); + let bytes = vec![b'x'; 16 * 1024]; + let chunk = scope.root_nanbox(f64::from_bits( + perry_ffi::JsValue::from_object_ptr(perry_ffi::alloc_buffer(&bytes)).bits(), + )); + socket::write(owner.get(), chunk.get(), p::undefined(), p::undefined()); + assert!( + unsafe { (*cell).external_bytes } >= initial + bytes.len() as u64, + "the retained native cork buffer must contribute to GC pressure" + ); + socket::destroy(owner.get(), p::undefined()); + assert_eq!( + unsafe { (*cell).external_bytes }, + 0, + "explicit close releases both the buffer and its GC pressure now" + ); + perry_runtime::agent::retire_agent(agent); + }) + .join() + .unwrap(); +} + +unsafe extern "C" fn saw_close_in_resource( + closure: *const RawClosureHeader, + _: JsThis, + _: f64, +) -> f64 { + let scope = TransientRootScope::enter(); + let expected = scope.root_nanbox(perry_ffi::closure_capture_f64(closure, 0)); + assert_eq!( + perry_runtime::async_hooks::js_async_hooks_execution_async_resource().to_bits(), + expected.get().to_bits() + ); + let expected_id = super::payload_provider::id(expected.get()); + assert_eq!( + perry_runtime::async_hooks::js_async_hooks_execution_async_id(), + expected_id as f64 + ); + CLOSES.with(|closes| closes.set(closes.get() + 1)); + p::undefined() +} + +#[test] +fn idle_close_keeps_its_owned_async_resource_until_the_late_listener_runs() { + std::thread::spawn(|| { + let agent = perry_runtime::agent::enter_worker_agent(); + CLOSES.with(|count| count.set(0)); + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(socket::new_socket(io::ROUTE, p::undefined())); + let resource = scope.root_nanbox(super::payload_provider::resource(owner.get())); + let id = super::payload_provider::id(resource.get()); + assert!(id > 0); + assert!(perry_runtime::async_hooks::resource_tracked_for_test(id)); + socket::destroy(owner.get(), p::undefined()); + let callback = scope.root_addr(perry_ffi::alloc_closure( + perry_ffi::js_function_info!(saw_close_in_resource, 1; with_flags(perry_ffi::FN_BUILTIN)), 1) as i64); + unsafe { perry_ffi::set_closure_capture_f64(callback.get() as *mut RawClosureHeader, 0, resource.get()); } + socket::once(owner.get(), "close", p::boxed_addr(callback.get())); + perry_runtime::promise::js_promise_run_microtasks(); + assert_eq!(CLOSES.with(Cell::get), 1); + assert!(!perry_runtime::async_hooks::resource_tracked_for_test(id), "terminal delivery retires the existing provider metadata"); + assert_eq!(perry_runtime::async_hooks::js_async_hooks_execution_async_id(), 0.0); + perry_runtime::agent::retire_agent(agent); + }).join().unwrap(); +} + +#[test] +fn socket_and_server_initialize_the_actual_subclass_receiver() { + std::thread::spawn(|| { + let agent = perry_runtime::agent::enter_worker_agent(); + let scope = TransientRootScope::enter(); + for is_server in [false, true] { + let raw = perry_runtime::object::js_object_alloc(12345, 0); + let owner = scope.root_nanbox(perry_runtime::value::js_nanbox_pointer(raw as i64)); + let prototype = scope.root_nanbox(perry_runtime::object::js_object_get_prototype_of( + owner.get(), + )); + p::own_set(owner.get(), "extra", 17.0); + let result = if is_server { + server::initialize_server( + io::ROUTE, + p::undefined(), + p::undefined(), + Some(owner.get()), + ) + } else { + socket::initialize_socket(io::ROUTE, p::undefined(), u64::MAX, Some(owner.get())) + }; + assert_eq!(result.to_bits(), owner.get().to_bits()); + assert_eq!( + perry_runtime::object::js_object_get_class_id( + p::raw_owner(owner.get()) as *const perry_runtime::ObjectHeader + ), + 12345 + ); + assert_eq!( + perry_runtime::object::js_object_get_prototype_of(owner.get()).to_bits(), + prototype.get().to_bits() + ); + assert_eq!(p::own_get(owner.get(), "extra"), 17.0); + if is_server { + server::close(owner.get(), p::undefined()); + } else { + socket::destroy(owner.get(), p::undefined()); + } + perry_runtime::promise::js_promise_run_microtasks(); + assert_eq!(p::own_get(owner.get(), "extra"), 17.0); + } + perry_runtime::agent::retire_agent(agent); + }) + .join() + .unwrap(); +} + +thread_local! { static PIPE_BYTES: Cell = const { Cell::new(0) }; } +unsafe extern "C" fn pipe_write(closure: *const RawClosureHeader, this: JsThis, chunk: f64) -> f64 { + assert_eq!( + this.as_f64().to_bits(), + perry_ffi::closure_capture_f64(closure, 0).to_bits() + ); + let bytes = crate::jsvalue_to_socket_bytes(chunk).unwrap(); + PIPE_BYTES.with(|count| count.set(count.get() + bytes.len())); + f64::from_bits(perry_ffi::JsValue::TRUE.bits()) +} +#[test] +fn socket_pipe_forwards_data_to_an_ordinary_destination_and_unpipe_removes_it() { + std::thread::spawn(|| { + let agent = perry_runtime::agent::enter_worker_agent(); + PIPE_BYTES.with(|count| count.set(0)); + let scope = TransientRootScope::enter(); + let source = scope.root_nanbox(socket::new_socket(io::ROUTE, p::undefined())); + let dest = scope.root_nanbox(f64::from_bits(perry_ffi::alloc_object().bits())); + let write = scope.root_addr(perry_ffi::alloc_closure( + perry_ffi::js_function_info!(pipe_write, 1; with_flags(perry_ffi::FN_BUILTIN)), + 1, + ) as i64); + unsafe { + perry_ffi::set_closure_capture_f64(write.get() as *mut RawClosureHeader, 0, dest.get()); + } + p::own_set(dest.get(), "write", p::boxed_addr(write.get())); + assert_eq!( + super::payload_pipe::pipe(source.get(), dest.get(), p::undefined()).to_bits(), + dest.get().to_bits() + ); + socket::data(source.get(), b"phase-A"); + assert_eq!(PIPE_BYTES.with(Cell::get), 7); + super::payload_pipe::unpipe(source.get(), dest.get()); + socket::data(source.get(), b"not forwarded"); + assert_eq!(PIPE_BYTES.with(Cell::get), 7); + socket::destroy(source.get(), p::undefined()); + perry_runtime::promise::js_promise_run_microtasks(); + perry_runtime::agent::retire_agent(agent); + }) + .join() + .unwrap(); +} + +#[test] +fn relisten_keeps_children_of_both_listener_incarnations_in_the_ownership_graph() { + std::thread::spawn(|| { + let agent = perry_runtime::agent::enter_worker_agent(); + assert!(io::enabled()); + let scope = TransientRootScope::enter(); + let owner = scope.root_nanbox(server::new_server( + io::ROUTE, + p::undefined(), + p::undefined(), + )); + let cell = server::link(owner.get()); + let host = scope.root_nanbox(events::string("127.0.0.1")); + let mut peers = Vec::new(); + for count in 1..=2 { + server::listen(owner.get(), 0.0, host.get(), p::undefined()); + assert_eq!(server::link(owner.get()), cell); + let port = unsafe { + perry_ffi::turnloop_net::link_local_address( + &mut *p::server_core(cell).unwrap(), + cell, + ) + .unwrap() + .port + }; + peers.push(std::net::TcpStream::connect(("127.0.0.1", port)).unwrap()); + let limit = std::time::Instant::now() + std::time::Duration::from_secs(10); + loop { + let mut observed = 0; + crate::native_transport::for_each_server_child(owner.get(), |_| observed += 1); + if observed == count { + break; + } + assert!( + std::time::Instant::now() < limit, + "ownership graph must include children from the closed listener incarnation" + ); + perry_runtime::event_pump::js_loop_turn_bounded(2); + perry_runtime::promise::js_promise_run_microtasks(); + } + if count == 1 { + server::close(owner.get(), p::undefined()); + } + } + let mut destroyed = 0; + crate::native_transport::for_each_server_child(owner.get(), |child| { + socket::destroy(child.value(), p::undefined()); + destroyed += 1; + }); + assert_eq!(destroyed, 2); + server::close(owner.get(), p::undefined()); + drop(peers); + perry_runtime::agent::retire_agent(agent); + }) + .join() + .unwrap(); +} + +#[cfg(target_os = "linux")] +#[test] +fn n6_socket_worker_teardown_with_closed_queued_releases_fds_without_js() { + fn fds() -> usize { + std::fs::read_dir("/proc/self/fd").unwrap().count() + } + let baseline = fds(); + std::thread::spawn(move || { + let agent = perry_runtime::agent::enter_worker_agent(); + assert!(io::enabled()); + CLOSES.with(|count| count.set(0)); + let scope = TransientRootScope::enter(); + let raw = unsafe { crate::js_net_create_server(0, 0) }; + let owner = scope.root_nanbox(p::boxed_addr(raw)); + close_listener(owner.get()); + let host = scope.root_nanbox(events::string("127.0.0.1")); + server::listen(owner.get(), 0.0, host.get(), p::undefined()); + let cell = server::link(owner.get()); + let port = unsafe { + perry_ffi::turnloop_net::link_local_address(&mut *p::server_core(cell).unwrap(), cell) + .unwrap() + .port + }; + let mut clients = Vec::new(); + for _ in 0..64 { + let raw = unsafe { crate::js_net_socket_alloc() }; + let client = scope.root_nanbox(p::boxed_addr(raw)); + close_listener(client.get()); + socket::connect(client.get(), port as f64, host.get(), p::undefined()); + clients.push(client); + } + let limit = std::time::Instant::now() + std::time::Duration::from_secs(10); + loop { + let mut accepted = 0; + crate::native_transport::for_each_server_child(owner.get(), |_| accepted += 1); + if accepted == 64 { + break; + } + assert!(std::time::Instant::now() < limit, "N6 accept stalled"); + perry_runtime::event_pump::js_loop_turn_bounded(2); + perry_runtime::promise::js_promise_run_microtasks(); + } + let live = fds(); + assert!( + live >= baseline + 129, + "N6 must actually own 129 socket descriptors" + ); + for client in &clients { + socket::destroy(client.get(), p::undefined()); + } + crate::native_transport::for_each_server_child(owner.get(), |child| { + close_listener(child.value()); + socket::destroy(child.value(), p::undefined()); + }); + server::close(owner.get(), p::undefined()); + assert_eq!(CLOSES.with(Cell::get), 0, "N6 Closed really remains queued"); + perry_runtime::agent::retire_agent(agent); + assert_eq!(CLOSES.with(Cell::get), 0, "N6 teardown may not dispatch JS"); + assert_eq!( + fds(), + baseline, + "N6 worker teardown must release socket and driver descriptors before returning" + ); + eprintln!( + "N6 Socket owners: fd baseline={baseline} live={live} retired={} JS close callbacks=0", + fds() + ); + }) + .join() + .unwrap(); + assert_eq!( + fds(), + baseline, + "N6 the main thread continues without leaked descriptors" + ); +} + +#[test] +fn tls_wrapper_keeps_the_parent_cell_handle_and_pending_read_and_disposes_both_payloads() { + std::thread::spawn(|| { + use perry_ffi::native_payload as np; + let agent = perry_runtime::agent::enter_worker_agent(); + CLOSES.with(|count| count.set(0)); + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let scope = TransientRootScope::enter(); + let parent = scope.root_nanbox(socket::new_socket(io::ROUTE, p::undefined())); + let parent_link = p::socket_link(parent.get()).unwrap(); + let host = scope.root_nanbox(events::string("127.0.0.1")); + socket::connect( + parent.get(), + listener.local_addr().unwrap().port() as f64, + host.get(), + p::undefined(), + ); + let limit = std::time::Instant::now() + std::time::Duration::from_secs(10); + while !unsafe { (*p::socket_ptr(parent_link).unwrap()).ext.opened } { + assert!( + std::time::Instant::now() < limit, + "TLS parent connect stalled" + ); + perry_runtime::event_pump::js_loop_turn_bounded(2); + perry_runtime::promise::js_promise_run_microtasks(); + } + let (peer, _) = listener.accept().unwrap(); + socket::set_paused(parent.get(), false); + let before = unsafe { + let core = &*(p::socket_core(parent_link).unwrap() + as *const perry_runtime::turnloop_net::transport::TransportCore); + ( + format!("{:?}", core.handle()), + format!("{:?}", core.read_op()), + ) + }; + assert_ne!(before.1, "None", "TLS wrapper must inherit an armed read"); + let wrapper = scope.root_nanbox(socket::new_tls_wrapper(parent.get())); + let wrapper_link = p::socket_link(wrapper.get()).unwrap(); + assert_ne!(wrapper.get().to_bits(), parent.get().to_bits()); + assert_ne!( + wrapper_link, parent_link, + "the wrapper has its own ordinary payload owner" + ); + assert_eq!(socket::link(wrapper.get()), parent_link); + assert_eq!( + unsafe { np::link_event_owner(parent_link) } + .unwrap() + .to_bits(), + parent.get().to_bits() + ); + let after = unsafe { + let core = &*(p::socket_core(parent_link).unwrap() + as *const perry_runtime::turnloop_net::transport::TransportCore); + assert_eq!(core.route(), io::TLS_SUBSYSTEM); + ( + format!("{:?}", core.handle()), + format!("{:?}", core.read_op()), + ) + }; + assert_eq!( + before, after, + "TLS wrapping changes only the parent core route" + ); + socket::destroy(parent.get(), p::undefined()); + assert_eq!( + np::lifecycle(parent.get(), &p::SOCKET), + Ok(np::Lifecycle::Closed) + ); + assert_eq!( + np::lifecycle(wrapper.get(), &p::SOCKET), + Ok(np::Lifecycle::Closed) + ); + close_listener(parent.get()); + close_listener(wrapper.get()); + while CLOSES.with(Cell::get) != 2 { + assert!( + std::time::Instant::now() < limit, + "TLS wrapper/parent late close stalled" + ); + perry_runtime::event_pump::js_loop_turn_bounded(2); + perry_runtime::promise::js_promise_run_microtasks(); + } + assert_eq!( + unsafe { + (*(parent_link.raw() as *const perry_runtime::native_handle::NativeHandleHeader)) + .refs + }, + 0 + ); + assert_eq!( + unsafe { + (*(wrapper_link.raw() as *const perry_runtime::native_handle::NativeHandleHeader)) + .refs + }, + 0 + ); + drop(peer); + perry_runtime::agent::retire_agent(agent); + }) + .join() + .unwrap(); +} + +unsafe extern "C" fn reopen_parent_on_wrapper_close( + closure: *const RawClosureHeader, + _: JsThis, + _: f64, +) -> f64 { + let scope = TransientRootScope::enter(); + let parent = scope.root_nanbox(perry_ffi::closure_capture_f64(closure, 0)); + let port = perry_ffi::closure_capture_f64(closure, 1); + let host = scope.root_nanbox(events::string("127.0.0.1")); + socket::connect(parent.get(), port, host.get(), p::undefined()); + CLOSES.with(|closes| closes.set(closes.get() + 1)); + p::undefined() +} + +#[test] +fn tls_wrapper_close_listener_can_reopen_its_parent_before_the_parent_close_listener() { + std::thread::spawn(|| { + let agent = perry_runtime::agent::enter_worker_agent(); + CLOSES.with(|count| count.set(0)); + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let scope = TransientRootScope::enter(); + let parent = scope.root_nanbox(socket::new_socket(io::ROUTE, p::undefined())); + let host = scope.root_nanbox(events::string("127.0.0.1")); + let port = listener.local_addr().unwrap().port() as f64; + socket::connect(parent.get(), port, host.get(), p::undefined()); + let parent_link = socket::link(parent.get()); + let wrapper = scope.root_nanbox(socket::new_tls_wrapper(parent.get())); + let callback = scope.root_addr(perry_ffi::alloc_closure( + perry_ffi::js_function_info!(reopen_parent_on_wrapper_close, 1; with_flags(perry_ffi::FN_BUILTIN)), 2) as i64); + unsafe { + perry_ffi::set_closure_capture_f64(callback.get() as *mut RawClosureHeader, 0, parent.get()); + perry_ffi::set_closure_capture_f64(callback.get() as *mut RawClosureHeader, 1, port); + } + socket::once(wrapper.get(), "close", p::boxed_addr(callback.get())); + socket::destroy(wrapper.get(), p::undefined()); + close_listener(parent.get()); + let limit = std::time::Instant::now() + std::time::Duration::from_secs(10); + while CLOSES.with(Cell::get) != 2 { + assert!(std::time::Instant::now() < limit, "TLS wrapper reentrant reopen stalled"); + perry_runtime::event_pump::js_loop_turn_bounded(2); + perry_runtime::promise::js_promise_run_microtasks(); + } + assert_eq!(socket::link(parent.get()), parent_link); + assert_eq!(perry_ffi::native_payload::lifecycle(parent.get(), &p::SOCKET), Ok(perry_ffi::native_payload::Lifecycle::Open)); + assert!(unsafe { perry_ffi::turnloop_net::link_handle_parts(&mut *p::socket_core(parent_link).unwrap(), parent_link) }.is_some(), "the old wrapper/parent close may not retire the replacement handle"); + let old_write = scope.root_nanbox(events::string("old wrapper")); + assert_eq!(socket::write(wrapper.get(), old_write.get(), p::undefined(), p::undefined()).to_bits(), perry_ffi::JsValue::FALSE.bits(), "a disposed wrapper may not write to its reopened parent's new handle"); + assert_ne!(socket::link(wrapper.get()), parent_link); + socket::destroy(parent.get(), p::undefined()); + perry_runtime::agent::retire_agent(agent); + }).join().unwrap(); +} diff --git a/crates/perry-ext-net/src/tests.rs b/crates/perry-ext-net/src/tests.rs deleted file mode 100644 index 3f335c63b3..0000000000 --- a/crates/perry-ext-net/src/tests.rs +++ /dev/null @@ -1,624 +0,0 @@ -use super::*; -use std::sync::{Mutex, MutexGuard}; - -/// Serialises every test that touches the process-global socket, listener and -/// pending-event tables against every test that drains them -/// (`js_net_process_pending` dispatches whatever is queued, for every handle). -pub(crate) static GC_TEST_LOCK: Mutex<()> = Mutex::new(()); - -struct GcTestGuard { - frame: u64, - previous_force_evacuation: i32, - _lock: MutexGuard<'static, ()>, -} - -impl GcTestGuard { - fn new() -> Self { - let lock = GC_TEST_LOCK - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - // Rewriting is observable only when the collector moves the root. - // Keep that test policy thread-local so unrelated test threads do not - // observe a process-wide environment mutation. - let previous_force_evacuation = perry_runtime::gc::js_gc_force_evacuation_test_override(1); - perry_runtime::gc::js_gc_write_barriers_emitted(1); - let frame = perry_runtime::gc::js_shadow_frame_push(1); - Self { - frame, - previous_force_evacuation, - _lock: lock, - } - } -} - -impl Drop for GcTestGuard { - fn drop(&mut self) { - perry_runtime::gc::js_shadow_frame_pop(self.frame); - perry_runtime::gc::js_gc_write_barriers_emitted(0); - perry_runtime::gc::js_gc_force_evacuation_test_override(self.previous_force_evacuation); - } -} - -struct NetHandleCleanup { - handles: Vec, -} - -impl NetHandleCleanup { - fn new(handles: Vec) -> Self { - Self { handles } - } -} - -impl Drop for NetHandleCleanup { - fn drop(&mut self) { - let mut listeners = statics::listeners().lock().unwrap(); - for handle in &self.handles { - listeners.remove(handle); - } - drop(listeners); - - let mut sockets = statics::sockets().lock().unwrap(); - for handle in &self.handles { - sockets.remove(handle); - } - } -} - -fn young_gc_root() -> i64 { - perry_runtime::arena::arena_alloc_gc(32, 8, perry_runtime::gc::GC_TYPE_STRING) as i64 -} - -fn assert_rewritten(before: i64, after: i64) { - assert_ne!(after, before); - assert!(perry_runtime::arena::pointer_in_nursery(after as usize)); -} - -#[test] -fn gc_mutable_scanner_rewrites_listener_roots() { - let _guard = GcTestGuard::new(); - perry_ffi::gc_register_mutable_root_scanner_named( - "perry-ext-net", - crate::gc_roots::scan_net_roots, - ); - - // Keep an ordinary shadow-stack root as the control. Its rewrite proves - // the collection copied live objects independently of scan_net_roots, so - // a listener that stays at its old address is an actual scanner failure. - let control = young_gc_root(); - perry_runtime::gc::js_shadow_slot_set(0, control as u64); - - let socket_id = -9_001; - let _cleanup = NetHandleCleanup::new(vec![socket_id]); - let callback = young_gc_root(); - { - let mut listeners = statics::listeners().lock().unwrap(); - listeners - .entry(socket_id) - .or_default() - .entry("data".to_string()) - .or_default() - .push(callback); - } - - let copying_cycles_before = perry_runtime::gc::copying_minor_cycles(); - let moved_objects_before = perry_runtime::gc::moved_objects_total(); - let _ = perry_runtime::gc::gc_collect_minor(); - - assert!( - perry_runtime::gc::copying_minor_cycles() > copying_cycles_before, - "minor GC did not run the copying collector, so the scanner was not exercised" - ); - assert!( - perry_runtime::gc::moved_objects_total() > moved_objects_before, - "copying minor did not relocate an object, so the scanner was not exercised" - ); - assert_rewritten(control, perry_runtime::gc::js_shadow_slot_get(0) as i64); - - let after = { - let listeners = statics::listeners().lock().unwrap(); - listeners - .get(&socket_id) - .and_then(|per_socket| per_socket.get("data")) - .and_then(|callbacks| callbacks.first()) - .copied() - }; - statics::listeners().lock().unwrap().remove(&socket_id); - assert_rewritten( - callback, - after.expect("listener callback should remain registered"), - ); -} - -/// Issuing two `js_net_socket_alloc()` calls must not panic and must -/// register the GC scanner exactly once. Both handles should be -/// distinct positive integers. -#[test] -fn alloc_is_idempotent() { - let _lock = GC_TEST_LOCK - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - let h1 = unsafe { js_net_socket_alloc() }; - let h2 = unsafe { js_net_socket_alloc() }; - let _cleanup = NetHandleCleanup::new(vec![h1, h2]); - assert!(h1 > 0); - assert!(h2 > 0); - assert_ne!(h1, h2); - assert!(is_net_socket_handle(h1)); - assert!(is_net_socket_handle(h2)); -} - -/// `js_net_has_pending()` returns 0 when no sockets are registered -/// and no events are pending — the loop-keepalive baseline. -/// -/// We can't truly assert "no sockets registered" because earlier -/// tests in the same process leave entries behind (the registry is -/// process-wide). Instead, allocate a socket, drop it via the close -/// path, and check that has_pending eventually returns to 0. -#[test] -fn has_pending_false_when_idle() { - let _lock = GC_TEST_LOCK - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - // Drain any leftover events from sibling tests. - let _ = unsafe { js_net_process_pending() }; - // Snapshot: with no real connection in flight, has_pending may - // still return 1 because of the alloc-test sockets above leaving - // handles in the registry. The contract documented here is that - // it returns *some* non-negative integer without crashing. - let v = js_net_has_pending(); - assert!(v == 0 || v == 1, "has_pending must be 0 or 1, got {}", v); -} - -/// Listener registration round-trip: `.on('data', cb)` stores the -/// callback pointer in the per-socket listener map. We use a non-zero -/// sentinel so we never try to invoke it. -#[test] -fn listener_registration_round_trip() { - let _lock = GC_TEST_LOCK - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - let h = unsafe { js_net_socket_alloc() }; - let _cleanup = NetHandleCleanup::new(vec![h]); - let event = alloc_string("data"); - unsafe { - js_net_socket_on(h, event.as_raw() as i64, 0xDEADBEEF_i64); - js_net_socket_on(h, event.as_raw() as i64, 0xCAFEBABE_i64); - } - let cbs = listeners_for(h, "data"); - assert_eq!(cbs.len(), 2); - assert_eq!(cbs[0], 0xDEADBEEF_i64); - assert_eq!(cbs[1], 0xCAFEBABE_i64); -} - -/// `undefined` as the codegen hands it to a native method: the two trailing -/// arguments of `socket.connect(port)`. -const UNDEFINED: f64 = f64::from_bits(0x7FFC_0000_0000_0001); - -/// `new net.Socket()` then `socket.connect(port)` — issue #422's deferred -/// connect, which is also what `Bun.connect` and `net.Socket.prototype.connect` -/// lower to — must reach the agent's turnloop loop, on whichever thread asks. -/// -/// History: `js_net_socket_method_connect` once had no `turnloop_io::enabled()` -/// check at all and spawned a tokio socket task even with the loop fully -/// available. There is no tokio task to fall back to any more, so the three -/// outcomes are the three routes of `turnloop_io::on_loop`: -/// -/// * this thread owns the loop — the connect is submitted here, and -/// `turnloop_net::live_handles` (the independent witness: it counts the -/// handles this thread's loop actually holds) moves by one; -/// * another thread owns it — the socket is published as a loop socket and -/// the submission is posted to that owner, so nothing is registered here, -/// and the owner's next turn runs it; -/// * no loop exists for the agent — here, a thread declined while another -/// owned the route, connecting after that owner exited (a decline is for the -/// thread's life, so it does not re-claim) — the connect is refused with -/// `ENOTSUP` and the socket is NOT left marked as a loop socket. -/// -/// **Every route runs on every run, each on an agent this test mints.** An -/// agent's route is claimed once per thread by the first thread to ask -/// (`event_pump::agent_loop::claim_route`), for as long as that thread lives. -/// This test used to observe the route of the PRIMARY agent from a libtest -/// thread, so which arm ran was chosen by other tests, and the non-owner arms -/// could not be decided at all: whether a post lands depends on another -/// test's thread still being alive with its loop built, which can change -/// between this test's snapshot and its `connect()` (#11597). A fresh agent's -/// slot is one nobody else can name. -#[test] -fn deferred_connect_reaches_the_loop_on_every_route() { - use std::sync::mpsc; - use std::time::{Duration, Instant}; - - /// Allocate a socket, `connect()` it to loopback port 1, and report - /// `(handle, on_loop, awaiting, live handles before, live handles after)`. - /// Port 1: the submission is under test, not the connect's outcome. - /// turnloop connects asynchronously, so a refused peer arrives as a later - /// completion and cannot make this pass. - fn connect_and_observe() -> (i64, bool, bool, usize, usize) { - let handles_before = perry_ffi::turnloop_net::live_handles(); - let h = unsafe { js_net_socket_alloc() }; - { - let sockets = statics::sockets().lock().unwrap(); - assert!( - !sockets[&h].turnloop && sockets[&h].awaiting_connect, - "fixture must start unconnected, or the verdict below is vacuous" - ); - } - unsafe { - js_net_socket_method_connect(h, 1.0, UNDEFINED, UNDEFINED); - } - let (on_loop, awaiting) = { - let sockets = statics::sockets().lock().unwrap(); - (sockets[&h].turnloop, sockets[&h].awaiting_connect) - }; - let handles_after = perry_ffi::turnloop_net::live_handles(); - (h, on_loop, awaiting, handles_before, handles_after) - } - - let _lock = GC_TEST_LOCK - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - - // Routes 1 and 2: an owner, and a second thread of its agent. - std::thread::spawn(|| { - let agent = perry_runtime::agent::enter_worker_agent(); - assert!( - turnloop_io::enabled(), - "the first thread of a fresh agent must own its loop" - ); - let (h, on_loop, awaiting, handles_before, handles_after) = connect_and_observe(); - let _cleanup = NetHandleCleanup::new(vec![h]); - // Leave no in-flight connect behind for a sibling test's pump. - crate::lifecycle::js_ext_net_destroy_socket(h); - let _ = unsafe { js_net_process_pending() }; - assert!( - !awaiting, - "connect() must consume the awaiting-connect state" - ); - assert!(on_loop, "an owned loop must take the connect"); - assert_eq!( - handles_after, - handles_before + 1, - "the flag says turnloop but the driver holds no new handle" - ); - - // The owner's loop exists now; a turn publishes its postbox. - let limit = Instant::now() + Duration::from_secs(10); - while !perry_ffi::agent_post::available() { - assert!(Instant::now() < limit, "the owner never published a route"); - perry_runtime::event_pump::js_loop_turn_bounded(0); - } - - let ran_before = perry_ffi::agent_post::dispatched(); - let (owns, can_post, (h, on_loop, awaiting, handles_before, handles_after)) = - std::thread::spawn(move || { - perry_runtime::agent::enter_agent_for_test(agent); - let owns = turnloop_io::enabled(); - let can_post = perry_ffi::agent_post::available(); - (owns, can_post, connect_and_observe()) - }) - .join() - .expect("the posting thread does not panic"); - let _cleanup = NetHandleCleanup::new(vec![h]); - assert!(!owns, "a second thread of the agent must not own its loop"); - assert!(can_post, "the agent has a live, built loop to post to"); - assert!( - !awaiting, - "connect() must consume the awaiting-connect state" - ); - assert!(on_loop, "a posted connect still makes this a loop socket"); - assert_eq!( - handles_after, handles_before, - "a posted connect must not register a handle on the posting thread" - ); - // The owner runs the posted submission on its next turn. - let limit = Instant::now() + Duration::from_secs(10); - while perry_ffi::agent_post::dispatched() == ran_before { - assert!( - Instant::now() < limit, - "the owner never ran the posted connect" - ); - perry_runtime::event_pump::js_loop_turn_bounded(10); - } - crate::lifecycle::js_ext_net_destroy_socket(h); - let _ = unsafe { js_net_process_pending() }; - perry_runtime::agent::retire_agent(agent); - }) - .join() - .expect("the owner thread does not panic"); - - // Route 3: declined by a live owner, connecting after that owner exited. - let (claimed_tx, claimed_rx) = mpsc::channel(); - let (release_tx, release_rx) = mpsc::channel::<()>(); - let claimant = std::thread::spawn(move || { - let agent = perry_runtime::agent::enter_worker_agent(); - // Asking claims the route slot, without building a loop. - let claimed = turnloop_io::enabled(); - claimed_tx - .send((agent, claimed)) - .expect("the test is waiting"); - let _ = release_rx.recv(); - perry_runtime::agent::retire_agent(agent); - }); - let (agent, claimed) = claimed_rx.recv().expect("the claimant reports"); - assert!( - claimed, - "the first thread of a fresh agent claims its route" - ); - let (declined_tx, declined_rx) = mpsc::channel(); - let (gone_tx, gone_rx) = mpsc::channel::<()>(); - let refused = std::thread::spawn(move || { - perry_runtime::agent::enter_agent_for_test(agent); - let owns = turnloop_io::enabled(); - declined_tx.send(()).expect("the test is waiting"); - gone_rx.recv().expect("the test reports the owner gone"); - let can_post = perry_ffi::agent_post::available(); - (owns, can_post, connect_and_observe()) - }); - declined_rx.recv().expect("the declined thread reports"); - release_tx.send(()).expect("the claimant is waiting"); - claimant.join().expect("the claimant does not panic"); - gone_tx.send(()).expect("the declined thread is waiting"); - let (owns, can_post, (h, on_loop, awaiting, handles_before, handles_after)) = - refused.join().expect("the refused thread does not panic"); - let _cleanup = NetHandleCleanup::new(vec![h]); - let _ = unsafe { js_net_process_pending() }; - assert!(!owns, "the route was held by a live claimant when asked"); - assert!( - !can_post, - "the owner is gone, so there is nothing to post to" - ); - assert!( - !awaiting, - "connect() must consume the awaiting-connect state" - ); - assert!( - !on_loop, - "a refused connect must not leave the socket marked as a loop socket" - ); - assert_eq!(handles_after, handles_before); -} - -/// Endpoint dispatch depends on stored socket state, independently of the OS -/// listener used by the adopted-stream regression below. -#[test] -fn stored_socket_endpoints_reach_dynamic_dispatch() { - let _guard = GcTestGuard::new(); - let socket_id = -30_001; - let _cleanup = NetHandleCleanup::new(vec![socket_id]); - let getters: [(&str, unsafe extern "C" fn(i64) -> f64); 6] = [ - ("remoteAddress", js_net_socket_get_remote_address), - ("remotePort", js_net_socket_get_remote_port), - ("remoteFamily", js_net_socket_get_remote_family), - ("localAddress", js_net_socket_get_local_address), - ("localPort", js_net_socket_get_local_port), - ("localFamily", js_net_socket_get_local_family), - ]; - for endpoints in [ - Some(("127.0.0.1:12001", "127.0.0.2:12002")), - Some(("[::1]:12003", "[2001:db8::1]:12004")), - None, - ] { - let mut socket = SocketState::for_test(false); - if let Some((local, remote)) = endpoints { - socket.local_addr = Some(local.parse().unwrap()); - socket.remote_addr = Some(remote.parse().unwrap()); - } - statics::sockets().lock().unwrap().insert(socket_id, socket); - for (name, getter) in getters { - let expected = unsafe { getter(socket_id) }; - assert_eq!( - expected.to_bits() == dispatch::undefined().to_bits(), - endpoints.is_none(), - "{name} follows the stored endpoint" - ); - // Preserve an address/family string if the dispatch getter allocates - // across a moving collection before the value comparison. - perry_runtime::gc::js_shadow_slot_set(0, expected.to_bits()); - let mut actual = dispatch::undefined(); - assert_eq!( - unsafe { - dispatch::js_ext_net_handle_property_dispatch( - socket_id, - name.as_ptr(), - name.len(), - &mut actual, - ) - }, - 1, - "{name} is claimed for a socket" - ); - let expected = f64::from_bits(perry_runtime::gc::js_shadow_slot_get(0)); - assert_ne!( - perry_runtime::value::js_jsvalue_equals(expected, actual), - 0, - "{name} agrees with its direct getter" - ); - } - } - let flags: [(&str, unsafe extern "C" fn(i64) -> f64); 3] = [ - ("destroyed", js_net_socket_get_destroyed), - ("connecting", js_net_socket_get_connecting), - ("writableLength", js_net_socket_get_writable_length), - ]; - for (name, getter) in flags { - let expected = unsafe { getter(socket_id) }; - let mut actual = dispatch::undefined(); - assert_eq!( - unsafe { - dispatch::js_ext_net_handle_property_dispatch( - socket_id, - name.as_ptr(), - name.len(), - &mut actual, - ) - }, - 1, - "existing {name} remains claimed" - ); - assert_ne!(perry_runtime::value::js_jsvalue_equals(expected, actual), 0); - } - let invalid_id = -30_002; - assert!(!statics::sockets().lock().unwrap().contains_key(&invalid_id)); - for (name, _) in getters { - let mut actual = 123.0; - assert_eq!( - unsafe { - dispatch::js_ext_net_handle_property_dispatch( - invalid_id, - name.as_ptr(), - name.len(), - &mut actual, - ) - }, - 0, - "{name} is not claimed for an invalid handle" - ); - assert_eq!(actual, 123.0); - } -} - -/// #11155: `adopt_upgraded_tcp_stream` adopts on the calling thread's own loop -/// or refuses outright. It used to park the stream in a process-wide map and -/// post the adoption with no agent named, so a caller that did not own the -/// loop reached whichever agent the runtime defaulted to, and the socket id it -/// returned could be adopted onto another agent's loop. -/// -/// Both halves are observed, and both run every time. A thread of the agent -/// that is not its owner must get `INVALID_HANDLE`, register nothing, and close -/// the stream (the peer reads EOF, the witness that nothing kept it parked). -/// The owner's adoption must land on its own loop: the driver holds one more -/// handle before the call returns, with no turn run in between. -/// -/// **Why the test mints its own agent.** An unclaimed libtest thread resolves -/// to the PRIMARY agent, whose route slot belongs to whichever test thread -/// asked first, for as long as that thread lives. This test used to spawn its -/// "non-owner" from such a thread and assume the route was settled. It was -/// not: when this thread had been declined, the owner was some other test's -/// thread, and if that thread exited first its `ClaimGuard` released the slot, -/// so the spawned thread found no route, claimed the loop itself, and got a -/// socket id (#11597; intermittent on Linux and macOS alike, 0 of 300 runs -/// with one test thread). That is the documented first-to-ask rule, not a -/// leak: the route of a dead owner is free. A fresh agent has an empty slot -/// nobody else can name, so here the owner is this test's own thread, alive -/// until the refusal has been observed. -#[test] -fn upgraded_stream_adoption_is_on_the_callers_loop_or_refused() { - use std::io::Read; - use std::net::{TcpListener, TcpStream}; - use std::time::Duration; - - let _lock = GC_TEST_LOCK - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - - let listener = TcpListener::bind("127.0.0.1:0").expect("an ephemeral port"); - let port = listener.local_addr().expect("a bound address").port(); - let connect = move || TcpStream::connect(("127.0.0.1", port)).expect("loopback connects"); - let accept = move |listener: &TcpListener| { - let (peer, _) = listener.accept().expect("the connection is accepted"); - peer.set_read_timeout(Some(Duration::from_secs(10))) - .expect("a read timeout"); - peer - }; - - std::thread::spawn(move || { - let agent = perry_runtime::agent::enter_worker_agent(); - assert!( - turnloop_io::enabled(), - "the first thread of a fresh agent must own its loop, or neither \ - half below tests what it names" - ); - - // Off the owner: a second thread of THIS agent, while the owner lives. - // Counted per agent: other tests insert into the same process-wide - // table without this lock, and an adoption is stamped with its agent. - let agent_sockets = move || { - let sockets = statics::sockets().lock().unwrap(); - sockets.values().filter(|s| s.owner_agent == agent).count() - }; - let sockets_before = agent_sockets(); - let (refused, could_submit) = std::thread::spawn(move || { - perry_runtime::agent::enter_agent_for_test(agent); - let id = adopt_upgraded_tcp_stream(connect()); - (id, turnloop_io::enabled()) - }) - .join() - .expect("the adopting thread does not panic"); - let mut peer = accept(&listener); - assert!( - !could_submit, - "the second thread must NOT own the loop, or the refusal is vacuous" - ); - assert_eq!( - refused, - perry_ffi::INVALID_HANDLE, - "a thread that does not own the loop must not get a socket id" - ); - assert_eq!( - agent_sockets(), - sockets_before, - "a refused adoption must leave no socket registered" - ); - let mut byte = [0u8; 1]; - assert_eq!( - peer.read(&mut byte) - .expect("the peer reads EOF, not a timeout"), - 0, - "a refused adoption must close the stream, not park it" - ); - - // On the owner. - let handles_before = perry_ffi::turnloop_net::live_handles(); - let id = adopt_upgraded_tcp_stream(connect()); - let _peer = accept(&listener); - let _cleanup = NetHandleCleanup::new(vec![id]); - assert_ne!(id, perry_ffi::INVALID_HANDLE, "the owner adopts"); - assert_eq!( - perry_ffi::turnloop_net::live_handles(), - handles_before + 1, - "the adoption must be on this thread's loop when the call returns" - ); - // Accepted/upgraded sockets carry both endpoints already; the dynamic - // property path must serve the same getters as a statically typed one. - for (name, expected) in unsafe { - [ - ("remoteAddress", js_net_socket_get_remote_address(id)), - ("remotePort", js_net_socket_get_remote_port(id)), - ("remoteFamily", js_net_socket_get_remote_family(id)), - ("localAddress", js_net_socket_get_local_address(id)), - ("localPort", js_net_socket_get_local_port(id)), - ("localFamily", js_net_socket_get_local_family(id)), - ] - } { - assert_ne!( - expected.to_bits(), - dispatch::undefined().to_bits(), - "{name} has an endpoint" - ); - let mut actual = dispatch::undefined(); - assert_eq!( - unsafe { - dispatch::js_ext_net_handle_property_dispatch( - id, - name.as_ptr(), - name.len(), - &mut actual, - ) - }, - 1, - "{name} is claimed" - ); - // Address/family reads allocate strings; compare their JS values. - assert_ne!( - perry_runtime::value::js_jsvalue_equals(expected, actual), - 0, - "{name} matches" - ); - } - crate::lifecycle::js_ext_net_destroy_socket(id); - let _ = unsafe { js_net_process_pending() }; - perry_runtime::agent::retire_agent(agent); - }) - .join() - .expect("the owner thread does not panic"); -} diff --git a/crates/perry-ext-net/src/unconnected_write_tests.rs b/crates/perry-ext-net/src/unconnected_write_tests.rs deleted file mode 100644 index 6519df45ec..0000000000 --- a/crates/perry-ext-net/src/unconnected_write_tests.rs +++ /dev/null @@ -1,80 +0,0 @@ -use super::*; -use crate::{PendingNetEvent, SocketCommand, SocketState}; - -pub(super) fn take_events(handle: i64) -> Vec { - let mut pending = statics::pending_events().lock().unwrap(); - let mut selected = Vec::new(); - let mut i = 0; - while i < pending.len() { - if matches!(&pending[i], PendingNetEvent::WriteComplete(id, ..) - | PendingNetEvent::Error(id, _) | PendingNetEvent::Close(id) if *id == handle) - { - selected.push(pending.remove(i)); - } else { - i += 1; - } - } - selected -} - -#[test] -fn unopened_command_refuses_bytes_instead_of_counting_and_dropping_them() { - let mut socket = SocketState::for_test(true); - assert_eq!( - socket.command(-111_560, SocketCommand::Write(vec![1, 2], 0)), - Err("Socket is closed".to_string()) - ); - assert_eq!(socket.bytes_queued, 0); - assert_eq!(socket.bytes_written, 0); -} - -#[test] -fn unopened_writes_queue_callbacks_before_one_error_and_close() { - // Another test's `js_net_process_pending` would dispatch (and consume) - // the events this test queues and then asserts on, calling the fake - // callbacks 501/502 as closures. - let _lock = crate::tests::GC_TEST_LOCK - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - let handle = -111_561; - take_events(handle); - statics::sockets() - .lock() - .unwrap() - .insert(handle, SocketState::for_test(true)); - let first = enqueue_socket_write(handle, vec![1], 501); - let second = enqueue_socket_write(handle, vec![2], 502); - let events = take_events(handle); - let socket = statics::sockets().lock().unwrap().remove(&handle).unwrap(); - assert!(!first && !second); - assert!(!socket.destroyed, "failure delivery must be asynchronous"); - assert_eq!(socket.bytes_queued, 0); - assert!(!socket.need_drain); - assert_eq!(events.len(), 4, "{events:?}"); - assert!( - matches!(&events[0], PendingNetEvent::WriteComplete(id, 501, Some(message)) if *id == handle && message == "Socket is closed") - ); - assert!( - matches!(&events[1], PendingNetEvent::WriteComplete(id, 502, Some(message)) if *id == handle && message == "Socket is closed") - ); - assert!( - matches!(&events[2], PendingNetEvent::Error(id, message) if *id == handle && message == "Socket is closed") - ); - assert!(matches!(&events[3], PendingNetEvent::Close(id) if *id == handle)); -} - -#[test] -fn closed_socket_errors_keep_the_node_code_and_message() { - unsafe { - let roots = perry_ffi::TransientRootScope::enter(); - let error = roots.root_nanbox(crate::build_error_object("Socket is closed")); - assert_eq!( - crate::get_object_string_field(error.get(), "code").as_deref(), - Some("ERR_SOCKET_CLOSED") - ); - assert_eq!( - crate::get_object_string_field(error.get(), "message").as_deref(), - Some("Socket is closed") - ); - } -} diff --git a/crates/perry-ext-net/tests/pump_symbol_identity.rs b/crates/perry-ext-net/tests/pump_symbol_identity.rs deleted file mode 100644 index 03ace13de0..0000000000 --- a/crates/perry-ext-net/tests/pump_symbol_identity.rs +++ /dev/null @@ -1,34 +0,0 @@ -//! Keep the optimizer regression visible to the diff-scoped integration gate. -//! The probe compiles the actual adapter extracted from dispatch.rs, not a -//! separately maintained copy. Each rustc invocation has a 30-second deadline. - -use std::path::Path; -use std::process::Command; - -#[test] -fn optimized_network_pump_keeps_its_private_callback() { - let root = Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .unwrap() - .parent() - .unwrap(); - let output = Command::new("node") - .arg(root.join("scripts/test-net-pump-symbol-identity.mjs")) - .current_dir(root) - .output() - .expect("Node must be installed to run the bounded rustc optimizer probe"); - assert!( - output.status.success(), - "optimizer regression failed: {}\n{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8(output.stdout).unwrap(); - assert_eq!( - stdout - .lines() - .filter(|line| line.starts_with("PASS:")) - .count(), - 7 - ); -} diff --git a/crates/perry-hir/tests/upgrade_socket_param_plain_dispatch.rs b/crates/perry-hir/tests/upgrade_socket_param_plain_dispatch.rs new file mode 100644 index 0000000000..655c439d74 --- /dev/null +++ b/crates/perry-hir/tests/upgrade_socket_param_plain_dispatch.rs @@ -0,0 +1,66 @@ +// The socket an HTTP `'upgrade'` listener receives +// (`server.on('upgrade', (req, socket, head) => …)`) is an ordinary +// `net.Socket` (decision 77: an HTTP connection IS a net.Socket payload), or, +// for a server with an attached WebSocketServer, a ws client handle with its +// own dynamic method dispatch. The compiler cannot tell the two apart, so the +// parameter must carry NO native-instance class: `socket.on('data', …)` then +// reaches the Socket's own prototype method (its flowing readable side), not +// a class-filtered `ws` Client row that registers the listener in a ws id +// table nobody fires (N8: the upgraded socket never saw `data`). +// +// Sabotage: re-tag the parameter `("ws", "Client")` in the call pre-scans and +// both assertions below go red (`module: "ws"` NativeMethodCalls reappear). + +use perry_diagnostics::SourceCache; +use perry_hir::{clear_current_module_source, fix_local_native_instances, lower_module}; +use perry_parser::parse_typescript_with_cache; + +fn lower(src: &str) -> perry_hir::Module { + let mut cache = SourceCache::new(); + let parsed = + parse_typescript_with_cache(src, "/tmp/ws_upgrade_param.ts", &mut cache).expect("parse"); + let mut module = + lower_module(&parsed.module, "test", "/tmp/ws_upgrade_param.ts").expect("lower"); + clear_current_module_source(); + fix_local_native_instances(&mut module); + module +} + +#[test] +fn upgrade_listener_socket_param_lowers_to_ordinary_method_calls() { + let module = lower( + r#" + import { createServer } from "node:http"; + import type { Duplex } from "node:stream"; + + const server = createServer((req: any, res: any) => { + res.end("ok"); + }); + + server.on("upgrade", (req: any, socket: Duplex, head: Buffer) => { + socket.on("data", (_part: Buffer) => {}); + socket.write("READY"); + }); + + function helper(socket: any) { + socket.send("x"); + } + server.on("upgrade", (req: any, wsId: any, _head: any) => { + wsId.on("message", (_msg: any) => {}); + helper(wsId); + }); + "#, + ); + + let dump = format!("{module:#?}"); + let ws_dispatches = dump.matches("module: \"ws\"").count(); + assert_eq!( + ws_dispatches, 0, + "an upgrade listener's socket must not be lowered as a ws Client \ + NativeMethodCall ({ws_dispatches} found). Lowered HIR:\n{dump}" + ); + assert!( + !dump.contains("\"Client\""), + "no upgrade parameter may be registered as a ws Client. Lowered HIR:\n{dump}" + ); +} diff --git a/crates/perry-hir/tests/ws_upgrade_param_native_dispatch.rs b/crates/perry-hir/tests/ws_upgrade_param_native_dispatch.rs deleted file mode 100644 index efbfbc7b2b..0000000000 --- a/crates/perry-hir/tests/ws_upgrade_param_native_dispatch.rs +++ /dev/null @@ -1,71 +0,0 @@ -// Regression: the `wsId` parameter of an HTTP `'upgrade'` handler -// (`server.on('upgrade', (req, wsId, head) => …)`) must keep the -// `("ws", "Client")` native-instance tag the upgrade pre-scan assigns it, so -// `wsId.send(...)` / `wsId.on(...)` inside the handler lower to Client-class -// `Expr::NativeMethodCall { module: "ws", … }`. -// -// The arrow's own `wsId` param binding calls `shadow_native_instance_if_present` -// to tombstone any STALE native tag leaked from an outer scope — but here the -// tag is the FRESH, intended one the pre-scan just registered. Without the -// one-shot protection (`protect_native_param` / `prescan_protected_native_params`) -// that binding wrongly erased it, and the method calls fell back to generic -// dynamic dispatch (the post-upgrade "dead channel": `.send` / `.on` no longer -// routed to the ws Client shims). -// -// The handler arrow lowers to an inline `Expr::Closure { body, .. }` nested as -// the second argument of the `http.on` call (closures here are NOT lifted into -// `module.functions`). perry-hir's public walker (`walk_expr_children`) skips -// closure bodies and there is no public statement walker, so we assert over the -// module's `Debug` rendering instead: only `Expr::NativeMethodCall` carries a -// `module: ""` field, so each `module: "ws"` occurrence is a surviving -// Client-class dispatch. Without the fix the calls degrade to `Expr::Call` and -// no `module: "ws"` appears. - -use perry_diagnostics::SourceCache; -use perry_hir::{clear_current_module_source, fix_local_native_instances, lower_module}; -use perry_parser::parse_typescript_with_cache; - -fn lower(src: &str) -> perry_hir::Module { - let mut cache = SourceCache::new(); - let parsed = - parse_typescript_with_cache(src, "/tmp/ws_upgrade_param.ts", &mut cache).expect("parse"); - let mut module = - lower_module(&parsed.module, "test", "/tmp/ws_upgrade_param.ts").expect("lower"); - clear_current_module_source(); - fix_local_native_instances(&mut module); - module -} - -#[test] -fn upgrade_handler_wsid_keeps_client_class_dispatch_across_its_own_binding() { - let module = lower( - r#" - import { createServer } from "node:http"; - - const server = createServer((req: any, res: any) => { - res.end("ok"); - }); - - server.on("upgrade", (req: any, wsId: any, _head: any) => { - wsId.send("perry-hello"); - wsId.on("message", (_msg: any) => {}); - }); - "#, - ); - - let dump = format!("{module:#?}"); - let ws_dispatches = dump.matches("module: \"ws\"").count(); - - // Both `wsId.send` and `wsId.on` must survive as ws Client NativeMethodCalls. - assert!( - ws_dispatches >= 2, - "expected wsId.send + wsId.on to lower as ws Client-class NativeMethodCalls, \ - but found {ws_dispatches} `module: \"ws\"` dispatch(es). Lowered HIR:\n{dump}" - ); - // `method: "send"` is unique to the ws Client call (the only other native - // call here is `http.on`), so its presence pins the discriminating case. - assert!( - dump.contains("method: \"send\""), - "wsId.send must dispatch as a ws NativeMethodCall. Lowered HIR:\n{dump}" - ); -} diff --git a/crates/perry-runtime/src/gc/tests/residual_prototype_relocation.rs b/crates/perry-runtime/src/gc/tests/residual_prototype_relocation.rs index 4c8568a89a..3b8827583d 100644 --- a/crates/perry-runtime/src/gc/tests/residual_prototype_relocation.rs +++ b/crates/perry-runtime/src/gc/tests/residual_prototype_relocation.rs @@ -285,6 +285,13 @@ fn test_residual_prototype_owners_of_every_movable_kind_survive_a_copying_minor( let _guard = CopyingNurseryTestGuard::new(1); let _trigger_guard = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); register_key_table_scanners(); + // RegExp resolves its intrinsic from the lazy realm after the preceding + // copying minors. The isolation guard removed the normal realm scanner; + // restore it so that cache is marked and rewritten instead of naming a + // prototype freed by an earlier iteration. The custom prototypes below + // remain unrooted and are retained only through their tested owner edge. + gc_register_mutable_root_scanner(crate::object::scan_object_cache_roots_mut); + register_runtime_handle_root_scanner_for_tests(); type Alloc = Box usize>; let mut owners: Vec<(&str, Alloc)> = vec![ diff --git a/crates/perry-stdlib/src/runtime_thread_exit_tests/listeners_tests.rs b/crates/perry-stdlib/src/runtime_thread_exit_tests/listeners_tests.rs index 9442b302c7..48d5412ad1 100644 --- a/crates/perry-stdlib/src/runtime_thread_exit_tests/listeners_tests.rs +++ b/crates/perry-stdlib/src/runtime_thread_exit_tests/listeners_tests.rs @@ -217,10 +217,32 @@ fn thread_exit_clears_the_global_this_root_slot_it_wrote() { } #[test] -fn thread_exit_releases_the_threads_tls_client_records() { - const HANDLE: i64 = 0x1147_1002; - let live = std::thread::spawn(|| { +fn thread_exit_drops_the_socket_owned_tls_state() { + struct Probe(std::sync::Arc); + impl Drop for Probe { + fn drop(&mut self) { + self.0.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + } + } + static SOCKET: perry_runtime::native_payload::NativePayloadFamily = + perry_runtime::native_payload::NativePayloadFamily { + class_id: perry_runtime::native_class_ids::NET_SOCKET, + links_owner: true, + name: "Socket", + constructor_export: Some(("net", "Socket")), + constructor_length: 1, + install_prototype: |_| {}, + }; + let drops = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let worker_drops = drops.clone(); + std::thread::spawn(move || { let scope = RuntimeHandleScope::new(); + let socket = scope.root_nanbox_f64(perry_runtime::native_payload::alloc( + &SOCKET, + Probe(worker_drops), + 0, + &[], + )); let closure = scope.root_raw_mut_ptr(perry_runtime::closure::js_closure_alloc( perry_runtime::fn_info!(thunk2, 2), 0, @@ -231,25 +253,33 @@ fn thread_exit_releases_the_threads_tls_client_records() { "checkServerIdentity", pointer_value(closure.get_raw_mut_ptr::() as *const u8), ); + let raw = || (socket.get_nanbox_f64().to_bits() & 0x0000_FFFF_FFFF_FFFF) as i64; unsafe { perry_runtime::tls::js_tls_client_record_start( - HANDLE, + raw(), pointer_value(options.get_raw_mut_ptr::() as *const u8), std::ptr::null(), 0, ); } - perry_runtime::tls::tls_client_metadata(HANDLE) - .is_some_and(|metadata| metadata.check_server_identity != 0) + assert!( + perry_runtime::tls::tls_client_metadata(raw()).is_some(), + "TLS facts must be owned by the live Socket" + ); + assert_eq!( + perry_runtime::tls::js_tls_client_check_identity( + raw(), + f64::from_bits(JSValue::undefined().bits()) + ) + .to_bits(), + JSValue::undefined().bits() + ); }) .join() .unwrap(); - assert!( - live, - "the record must hold the thread's checkServerIdentity while it lives" - ); - assert!( - perry_runtime::tls::tls_client_metadata(HANDLE).is_none(), - "a dead thread's checkServerIdentity record outlived its heap" + assert_eq!( + drops.load(std::sync::atomic::Ordering::SeqCst), + 1, + "thread exit must drop the Socket rather than retain a TLS client registry" ); } diff --git a/crates/perry/tests/ws_client_handle_cross_function_dispatch.rs b/crates/perry/tests/ws_client_handle_cross_function_dispatch.rs deleted file mode 100644 index 26aee88f10..0000000000 --- a/crates/perry/tests/ws_client_handle_cross_function_dispatch.rs +++ /dev/null @@ -1,514 +0,0 @@ -//! Regression: the `("ws","Client")` upgrade handle delivered to -//! `server.on("upgrade", (req, wsId, head) => …)` must keep dispatching its -//! `.send()` / `.on()` / `.close()` to the dedicated `js_ws_*_client_i64` -//! runtime even when `wsId` is handed to a helper function. -//! -//! Before the fix, that host-class was tagged only at the upgrade callback's -//! parameter and was NOT propagated across a call boundary: inside a -//! `handleConnection(req, wsId)` helper the parameter was plain/`any`, the -//! codegen dispatch table's `class_filter: Some("Client")` rows no longer -//! matched, and `wsId.send(...)` silently lowered to a generic no-op — the -//! frame was dropped with no error. (The downstream workaround was to inline -//! every `wsId` use in the upgrade callback.) -//! -//! These are codegen-dispatch assertions: a WS network round-trip needs a live -//! socket + client, but the bug is purely *which runtime symbol the call site -//! lowers to*. So we compile to LLVM IR and assert the helper's `wsId.send` -//! lowers to a `call` to `js_ws_send_value_client_i64` (present after the fix, absent -//! before — verified by toggling the fix). - -use std::path::{Path, PathBuf}; -use std::process::Command; - -fn perry_bin() -> PathBuf { - PathBuf::from(env!("CARGO_BIN_EXE_perry")) -} - -/// Compile `source` and return the concatenated LLVM IR text of all emitted -/// modules (via `PERRY_SAVE_LL`). Asserts the compile succeeds. -fn compile_to_ir_impl(dir: &Path, source: &str, link: bool) -> String { - let entry = dir.join("main.ts"); - let output = dir.join("main_bin"); - let ll_dir = dir.join("ll"); - std::fs::create_dir_all(&ll_dir).expect("mk ll dir"); - std::fs::write(&entry, source).expect("write entry"); - - let mut command = Command::new(perry_bin()); - command - .current_dir(dir) - .arg("compile") - .arg(&entry) - .arg("-o") - .arg(&output) - // Emit per-module .ll and force a full (uncached) codegen so the IR is - // actually written for this build. - .env("PERRY_SAVE_LL", &ll_dir) - .env("PERRY_LLVM_KEEP_IR", "1") - .env("PERRY_NO_CACHE", "1"); - if link { - // Only the semantic nested-scope test needs an executable. Keep its - // provider set deterministic without making this HIR suite test - // automatic well-known binding discovery too. - command.env("PERRY_FORCE_WELL_KNOWN", "http,ws"); - } else { - // IR-only assertions do not need runtime or extension archives. The - // upgrade handle is seeded by `node:http`'s `createServer`, which only - // perry-ext-http implements, so `node:http` stays routed to it: with - // PERRY_DISABLE_WELL_KNOWN=1 these programs have no http provider and - // do not compile. - command - .arg("--no-auto-optimize") - .arg("--no-link") - .env_remove("PERRY_DISABLE_WELL_KNOWN"); - } - let compile = command.output().expect("run perry compile"); - assert!( - compile.status.success(), - "perry compile failed\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&compile.stdout), - String::from_utf8_lossy(&compile.stderr) - ); - - let mut ir = String::new(); - for entry in std::fs::read_dir(&ll_dir).expect("read ll dir") { - let path = entry.expect("dir entry").path(); - if path.extension().and_then(|e| e.to_str()) == Some("ll") { - ir.push_str(&std::fs::read_to_string(&path).expect("read .ll")); - ir.push('\n'); - } - } - assert!(!ir.is_empty(), "no .ll IR was emitted"); - ir -} - -fn compile_to_ir(dir: &Path, source: &str) -> String { - compile_to_ir_impl(dir, source, false) -} - -fn compile_to_ir_and_link(dir: &Path, source: &str) -> String { - compile_to_ir_impl(dir, source, true) -} - -/// True if the IR contains a `call` instruction targeting `@` (i.e. a -/// real call site, not merely the unconditional `declare`). -fn ir_has_call(ir: &str, symbol: &str) -> bool { - let needle = format!("@{symbol}("); - ir.lines() - .any(|l| l.contains("call ") && l.contains(&needle)) -} - -fn run_compiled(dir: &Path) -> String { - let output = dir.join("main_bin"); - let run = Command::new(&output) - .current_dir(dir) - .output() - .expect("run compiled binary"); - assert!( - run.status.success(), - "compiled binary failed\nstatus: {:?}\nstdout:\n{}\nstderr:\n{}", - run.status, - String::from_utf8_lossy(&run.stdout), - String::from_utf8_lossy(&run.stderr) - ); - String::from_utf8_lossy(&run.stdout).into_owned() -} - -fn compile_and_run(dir: &Path, source: &str) -> String { - let entry = dir.join("main.ts"); - let output = dir.join("main_bin"); - std::fs::write(&entry, source).expect("write entry"); - let compile = Command::new(perry_bin()) - .current_dir(dir) - .arg("compile") - .arg(&entry) - .arg("-o") - .arg(&output) - .output() - .expect("run perry compile"); - assert!( - compile.status.success(), - "perry compile failed\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&compile.stdout), - String::from_utf8_lossy(&compile.stderr) - ); - run_compiled(dir) -} - -/// The reported failure: the ONLY `wsId.send` is inside a helper reached as -/// `pushFrame(wsId, …)`. After the fix it must lower to the Client runtime. -#[test] -fn helper_wsid_send_dispatches_to_client_runtime() { - let dir = tempfile::tempdir().expect("tempdir"); - let ir = compile_to_ir( - dir.path(), - r#" -import { createServer } from "node:http"; - -function pushFrame(wsId: any, msg: string) { - wsId.send(msg); -} - -const server = createServer((req: any, res: any) => { res.statusCode = 200; res.end("ok"); }); - -server.on("upgrade", (req: any, wsId: any, _head: any) => { - pushFrame(wsId, "hello-from-helper"); -}); - -server.listen(0, () => {}); -"#, - ); - assert!( - ir_has_call(&ir, "js_ws_send_value_client_i64"), - "helper `wsId.send` must dispatch to js_ws_send_value_client_i64 (the upgrade \ - Client handle), not a silent generic no-op" - ); -} - -/// The handle is forwarded through TWO helpers — exercises the transitive -/// fixpoint propagation, not just one hop. -#[test] -fn transitively_forwarded_wsid_send_dispatches_to_client_runtime() { - let dir = tempfile::tempdir().expect("tempdir"); - let ir = compile_to_ir( - dir.path(), - r#" -import { createServer } from "node:http"; - -function sendFrame(wsId: any, msg: string) { - wsId.send(msg); -} -function handleConnection(req: any, wsId: any) { - sendFrame(wsId, "mount"); -} - -const server = createServer((req: any, res: any) => { res.statusCode = 200; res.end("ok"); }); - -server.on("upgrade", (req: any, wsId: any, _head: any) => { - handleConnection(req, wsId); -}); - -server.listen(0, () => {}); -"#, - ); - assert!( - ir_has_call(&ir, "js_ws_send_value_client_i64"), - "wsId.send two call-hops away from the upgrade callback must still \ - dispatch to the Client runtime" - ); -} - -/// `.on(...)` and `.close()` on the forwarded handle must likewise reach the -/// Client runtime, not just `.send`. -#[test] -fn helper_wsid_on_and_close_dispatch_to_client_runtime() { - let dir = tempfile::tempdir().expect("tempdir"); - let ir = compile_to_ir( - dir.path(), - r#" -import { createServer } from "node:http"; - -function wire(wsId: any) { - wsId.on("message", (m: any) => { wsId.send("echo:" + m); }); - wsId.close(); -} - -const server = createServer((req: any, res: any) => { res.statusCode = 200; res.end("ok"); }); - -server.on("upgrade", (req: any, wsId: any, _head: any) => { - wire(wsId); -}); - -server.listen(0, () => {}); -"#, - ); - assert!( - ir_has_call(&ir, "js_ws_on_client_i64"), - "helper `wsId.on` must dispatch to js_ws_on_client_i64" - ); - assert!( - ir_has_call(&ir, "js_ws_close_with_client_i64"), - "helper `wsId.close` must dispatch to js_ws_close_with_client_i64" - ); - assert!( - ir_has_call(&ir, "js_ws_send_value_client_i64"), - "the nested `wsId.send` (inside the helper's own `.on` callback) must \ - dispatch to js_ws_send_value_client_i64" - ); -} - -/// Gate / no-regression control: a `.send`-bearing parameter that is NOT an -/// upgrade Client handle must keep invoking the value's OWN `send` method — the -/// propagation must not mis-tag arbitrary parameters as `("ws","Client")`. -/// (Also confirms the `createServer`-origin gate: there is no upgrade seed at -/// all here.) -#[test] -fn unrelated_send_param_runs_its_own_method() { - let dir = tempfile::tempdir().expect("tempdir"); - let out = compile_and_run( - dir.path(), - r#" -function deliver(ch: any, msg: string) { - ch.send(msg); -} -const sink = { send(m: string) { console.log("own-send:" + m); } }; -deliver(sink, "hi"); -"#, - ); - assert!( - out.contains("own-send:hi"), - "a non-ws object's own `send` must run when passed to a helper (got: {out:?})" - ); - // And it must NOT have been routed to the ws Client runtime. - let ir = compile_to_ir( - dir.path(), - r#" -function deliver(ch: any, msg: string) { - ch.send(msg); -} -const sink = { send(m: string) { console.log("own-send:" + m); } }; -deliver(sink, "hi"); -"#, - ); - assert!( - !ir_has_call(&ir, "js_ws_send_value_client_i64"), - "a plain object's `.send` must NOT be mis-dispatched to the ws Client runtime" - ); -} - -// ─── PR #5493 review fixes — edge cases ────────────────────────────────────── - -/// Review #2: a leading TypeScript `this:` param must not shift the hint index; -/// the `wsId` (2nd real param) still dispatches to the Client runtime. -#[test] -fn leading_this_param_keeps_hint_index_aligned() { - let dir = tempfile::tempdir().expect("tempdir"); - let ir = compile_to_ir( - dir.path(), - r#" -import { createServer } from "node:http"; - -function deliver(this: any, req: any, wsId: any) { - wsId.send("hi"); -} - -const server = createServer((req: any, res: any) => { res.statusCode = 200; res.end("ok"); }); -server.on("upgrade", (req: any, wsId: any, _head: any) => { - deliver(req, wsId); -}); -server.listen(0, () => {}); -"#, - ); - assert!( - ir_has_call(&ir, "js_ws_send_value_client_i64"), - "wsId after a TS `this:` param must still dispatch to the Client runtime" - ); -} - -/// Review #3: a `createServer` that is NOT a `node:http` import must not seed ws -/// taint — a user factory of the same name is ignored. -#[test] -fn non_http_create_server_does_not_seed_ws_taint() { - let dir = tempfile::tempdir().expect("tempdir"); - let ir = compile_to_ir( - dir.path(), - r#" -// A user-defined factory that happens to be named `createServer`. -function createServer(): any { - return { on(_e: string, _cb: any) {} }; -} -function deliver(wsId: any) { - wsId.send("nope"); -} -const server: any = createServer(); -server.on("upgrade", (req: any, wsId: any, _head: any) => { - deliver(wsId); -}); -"#, - ); - assert!( - !ir_has_call(&ir, "js_ws_send_value_client_i64"), - "a non-http `createServer` must not seed the ws Client dispatch" - ); -} - -/// Review #4: a nested function that shadows `wsId` must not inherit the outer -/// handle — its callee stays untagged even though it is reached from the -/// upgrade callback's lexical scope. -#[test] -fn shadowing_nested_param_does_not_inherit_taint() { - let dir = tempfile::tempdir().expect("tempdir"); - let ir = compile_to_ir( - dir.path(), - r#" -import { createServer } from "node:http"; - -function deliver(ch: any) { - ch.send("shadow"); -} - -const server = createServer((req: any, res: any) => { res.statusCode = 200; res.end("ok"); }); -server.on("upgrade", (req: any, wsId: any, _head: any) => { - // `later` rebinds `wsId`, so the value flowing into `deliver` is NOT the - // upgrade handle — `deliver` must not be tagged as a ws Client receiver. - function later(wsId: any) { - deliver(wsId); - } - later(req); -}); -server.listen(0, () => {}); -"#, - ); - assert!( - !ir_has_call(&ir, "js_ws_send_value_client_i64"), - "a shadowing nested param must not propagate the outer Client handle" - ); -} - -/// Review #1: two functions named `pushFrame` — only the upgrade-fed top-level -/// declaration is tagged; a same-named nested declaration receiving a non-ws -/// value is keyed by a distinct identity and left untouched. Do not count -/// module-wide runtime calls here: specialization may legitimately emit both a -/// specialized and generic copy of the top-level call (#9138). -#[test] -fn same_named_function_is_not_cross_tagged() { - let dir = tempfile::tempdir().expect("tempdir"); - let ir = compile_to_ir_and_link( - dir.path(), - r#" -import { createServer } from "node:http"; - -function pushFrame(wsId: any, m: string) { - wsId.send(m); -} -function elsewhere() { - function pushFrame(x: any) { - x.send("nope"); - } - pushFrame({ send(s: string) { console.log("nested-own-send:" + s); } }); -} - -const server = createServer((req: any, res: any) => { res.statusCode = 200; res.end("ok"); }); -server.on("upgrade", (req: any, wsId: any, _head: any) => { - pushFrame(wsId, "real"); -}); -elsewhere(); -"#, - ); - assert!( - ir_has_call(&ir, "js_ws_send_value_client_i64"), - "the upgrade-fed top-level `pushFrame` must dispatch to the Client runtime" - ); - let out = run_compiled(dir.path()); - assert!( - out.contains("nested-own-send:nope"), - "the same-named nested declaration must call the plain object's own \ - `send` method, not the Client runtime (got: {out:?})" - ); -} - -// ─── Polymorphism guard — a helper fed BOTH a ws Client and a non-ws value ─── - -/// A hint tags a function PARAMETER, fixing the dispatch for that param across -/// EVERY call site. So a helper that is fed the upgrade `wsId` from one caller -/// AND a plain object from another must NOT be tagged — otherwise the plain -/// object's `.send(...)` would silently re-route to `js_ws_send_value_client_i64` -/// (a no-op handle miss) instead of running the object's own method. The pass -/// must only tag a param it can prove is ALWAYS the ws handle. -#[test] -fn polymorphic_helper_fed_ws_and_nonws_is_not_tagged() { - let dir = tempfile::tempdir().expect("tempdir"); - let ir = compile_to_ir( - dir.path(), - r#" -import { createServer } from "node:http"; - -// `emit` is reused for BOTH the ws upgrade handle and a plain emitter. -function emit(target: any, msg: string) { - target.send(msg); -} - -const server = createServer((req: any, res: any) => { res.statusCode = 200; res.end("ok"); }); -server.on("upgrade", (req: any, wsId: any, _head: any) => { - emit(wsId, "ws-bound"); -}); - -// A non-ws caller of the same helper — must keep running the value's own method. -const bus: any = { send(m: string) { console.log("bus:" + m); } }; -emit(bus, "not-ws"); - -server.listen(0, () => {}); -"#, - ); - assert!( - !ir_has_call(&ir, "js_ws_send_value_client_i64"), - "a helper that is ALSO called with a non-ws value must not be tagged as \ - a ws Client receiver (would silently drop the non-ws caller's frame)" - ); -} - -/// Transitive demotion: the handle is forwarded `mid` → `inner`, but `mid` is -/// ALSO called with a non-ws value. `mid` is polymorphic, so on that path it -/// delivers a non-ws value to `inner` — therefore NEITHER `mid` nor `inner` may -/// be tagged. Exercises the dependency fixpoint, not just the direct caller. -#[test] -fn transitive_through_polymorphic_intermediate_is_not_tagged() { - let dir = tempfile::tempdir().expect("tempdir"); - let ir = compile_to_ir( - dir.path(), - r#" -import { createServer } from "node:http"; - -function inner(target: any, msg: string) { - target.send(msg); -} -function mid(target: any, msg: string) { - inner(target, msg); -} - -const server = createServer((req: any, res: any) => { res.statusCode = 200; res.end("ok"); }); -server.on("upgrade", (req: any, wsId: any, _head: any) => { - mid(wsId, "ws-bound"); -}); - -// `mid` is also fed a non-ws value — taint through it is unsound. -const bus: any = { send(m: string) { console.log("bus:" + m); } }; -mid(bus, "not-ws"); - -server.listen(0, () => {}); -"#, - ); - assert!( - !ir_has_call(&ir, "js_ws_send_value_client_i64"), - "a handle forwarded through a polymorphic intermediate must not tag the \ - downstream helper (transitive demotion)" - ); -} - -/// Guardrail for over-correction: a helper that is ONLY ever fed the ws handle -/// (across two distinct upgrade callbacks) must STILL be tagged — the -/// polymorphism guard must not drop a legitimately ws-exclusive helper. -#[test] -fn ws_exclusive_helper_with_multiple_ws_callers_stays_tagged() { - let dir = tempfile::tempdir().expect("tempdir"); - let ir = compile_to_ir( - dir.path(), - r#" -import { createServer } from "node:http"; - -function push(wsId: any, msg: string) { - wsId.send(msg); -} - -const a = createServer((req: any, res: any) => { res.end("a"); }); -const b = createServer((req: any, res: any) => { res.end("b"); }); -a.on("upgrade", (req: any, wsId: any, _h: any) => { push(wsId, "from-a"); }); -b.on("upgrade", (req: any, wsId: any, _h: any) => { push(wsId, "from-b"); }); -a.listen(0, () => {}); -b.listen(0, () => {}); -"#, - ); - assert!( - ir_has_call(&ir, "js_ws_send_value_client_i64"), - "a helper fed ONLY ws handles (from multiple upgrade callbacks) must stay tagged" - ); -} diff --git a/test-files/test_gap_http_upgrade_socket_data.ts b/test-files/test_gap_http_upgrade_socket_data.ts new file mode 100644 index 0000000000..4a773ef157 --- /dev/null +++ b/test-files/test_gap_http_upgrade_socket_data.ts @@ -0,0 +1,72 @@ +// N8 (net phase A, decision 77): an HTTP `'upgrade'` hands the listener the +// connection itself as an ordinary net.Socket. The upgrade is one route store +// on the connection, so the bytes that arrived with the head come back as +// `head`, and every later byte is the socket's own `data`, whether the +// listener's parameter is untyped, typed `stream.Duplex`, or passed on to a +// helper. A socket the compiler mis-tags as a ws Client never sees `data` +// and this test times out. +import * as http from "node:http"; +import * as net from "node:net"; +import type { Duplex } from "node:stream"; + +function echoUntil(socket: Duplex, head: Buffer, label: string, done: () => void) { + let bytes = head.toString(); + socket.on("data", (part: Buffer) => { + bytes += part.toString(); + if (bytes.endsWith("+TAIL")) { + console.log(label, "bytes", bytes, "flowing", socket.readableFlowing); + socket.end("DONE"); + done(); + } + }); + socket.write("READY"); +} + +const server = http.createServer(); +let upgrades = 0; +server.on("upgrade", (req: http.IncomingMessage, socket: Duplex, head: Buffer) => { + upgrades++; + console.log("upgrade", req.method, req.url, req.headers.upgrade, "head", head.length); + if (req.url === "/typed") { + let bytes = head.toString(); + socket.on("data", (part: Buffer) => { + bytes += part.toString(); + if (bytes.endsWith("+TAIL")) { + console.log("typed bytes", bytes, "listeners", socket.listenerCount("data")); + socket.end("DONE"); + } + }); + socket.write("READY"); + } else { + echoUntil(socket, head, "helper", () => {}); + } +}); + +function client(path: string): Promise { + return new Promise((resolve) => { + const port = (server.address() as net.AddressInfo).port; + const socket = net.connect(port, "127.0.0.1"); + let output = ""; + let sent = false; + socket.on("connect", () => + socket.write( + "GET " + path + " HTTP/1.1\r\nHost: localhost\r\nConnection: Upgrade\r\nUpgrade: echo\r\n\r\nHEAD+SAME-PACKET", + ), + ); + socket.on("data", (part: Buffer) => { + output += part.toString(); + if (!sent && output.includes("READY")) { + sent = true; + socket.write("+TAIL"); + } + }); + socket.on("end", () => socket.end()); + socket.on("close", () => resolve(output)); + }); +} + +server.listen(0, "127.0.0.1", async () => { + console.log("client typed", await client("/typed")); + console.log("client helper", await client("/helper")); + server.close(() => console.log("closed upgrades", upgrades)); +}); From 997092f448c79e6512bebffbc21b0e82c8187f68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 9 Oct 2026 05:15:34 +0200 Subject: [PATCH 05/18] fix(net): finish payload custody audits and enforce a single opened field Registry census drops 249 to 225 (shrinking maps 167 to 146) because phase A deletes 24 net/HTTP/TLS tables and adds none. Adjust parser liveness floors 250/170 to 220/140 for the deleted population. --- Cargo.lock | 1 + changelog.d/12064-net-transport-phase-a.md | 18 + .../src/client_turnloop/native_client.rs | 19 +- .../src/server/http2_server/session.rs | 3 +- .../src/server/turnloop_h2/native_server.rs | 101 +-- .../src/server/turnloop_serve/conn.rs | 67 +- crates/perry-ext-net/src/bun_tcp.rs | 21 +- crates/perry-ext-net/src/classes.rs | 60 +- crates/perry-ext-net/src/lib.rs | 5 +- crates/perry-ext-net/src/payload_prototype.rs | 17 +- crates/perry-ext-net/src/payload_tests.rs | 24 +- crates/perry-ext-net/src/payload_transport.rs | 80 +- crates/perry-ext-ws/src/native_socket.rs | 39 +- crates/perry-ffi/src/native_payload_links.rs | 42 +- crates/perry-runtime/src/agent.rs | 14 +- crates/perry-runtime/src/gc/tests/mod.rs | 2 +- .../src/gc/tests/native_payload.rs | 4 +- .../src/gc/tests/native_payload_callbacks.rs | 14 +- .../lifecycle.rs} | 0 .../src/gc/tests/net_transport.rs | 6 +- crates/perry-runtime/src/native_payload.rs | 102 +-- .../src/native_payload_census.rs | 48 ++ .../perry-runtime/src/native_payload_links.rs | 64 ++ .../src/native_payload_transport_abi.rs | 29 +- crates/perry-runtime/src/turnloop_net/abi.rs | 2 + .../src/turnloop_net/transport.rs | 7 +- .../check_ext_net_socket_open_invariant.py | 743 ++---------------- scripts/ext_net_socket_open_exceptions.json | 17 +- scripts/gc_runtime_root_holders.json | 85 +- scripts/registry_lifetime_allowlist.json | 20 - scripts/registry_lifetime_check.py | 7 +- scripts/thread_exit_address_globals.json | 17 +- scripts/unrooted_local_shape_baseline.json | 4 +- 33 files changed, 562 insertions(+), 1120 deletions(-) rename crates/perry-runtime/src/gc/tests/{native_payload_lifecycle.rs => native_payload_callbacks/lifecycle.rs} (100%) create mode 100644 crates/perry-runtime/src/native_payload_census.rs create mode 100644 crates/perry-runtime/src/native_payload_links.rs diff --git a/Cargo.lock b/Cargo.lock index d90d8f334e..858ba00102 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5208,6 +5208,7 @@ dependencies = [ name = "perry-ext-ws" version = "0.5.1659" dependencies = [ + "perry-ext-net", "perry-ffi", "perry-http-server", "perry-runtime", diff --git a/changelog.d/12064-net-transport-phase-a.md b/changelog.d/12064-net-transport-phase-a.md index 30e1304e4f..c1138d1c5f 100644 --- a/changelog.d/12064-net-transport-phase-a.md +++ b/changelog.d/12064-net-transport-phase-a.md @@ -12,3 +12,21 @@ ordinary value; the compiler no longer tags it as a `ws` Client, which used to register its `data` listener in a ws id table that never fired. A server with an attached `WebSocketServer` still hands the listener a ws client handle, which dispatches through ws's own handle methods. + +The socket-open audit now requires one `SocketFields.opened` declaration and +live connect/accept transitions, and rejects duplicate Rust/JS flags and +id-keyed maps. Bun's duplicate `bunOpened` latch is removed: its existing +pending connect promise selects `connectError`. The workflow step is retained. + +Registry lifetime census: current main has 249 maps (167 with removal paths), +phase A has 225 (146 with removal paths). All 24 disappearing identities are +net/HTTP/TLS tables; none are added. Parser-liveness floors are adjusted from +250/170 to 220/140 solely because those tables were deleted. + +Review notes: main already violates the native handle ledger because +`buffer/header.rs` moved to `buffer/store/layout.rs`. The seven ws Client +codegen rows and their manifest entries are unused after removal of the HTTP +upgrade parameter hint; delete them in the immediate follow-up. Linux ext-ws +lib tests fail to compile on main (`js_shadow_frame_push` is absent with native +stack maps). Main also fails issue_9619's manual/callback-only path and +`nested_object_literal_ws_inbound`: ws handleUpgrade has no raw-socket path. diff --git a/crates/perry-ext-http/src/client_turnloop/native_client.rs b/crates/perry-ext-http/src/client_turnloop/native_client.rs index 2fc7b93b38..5f72166c4a 100644 --- a/crates/perry-ext-http/src/client_turnloop/native_client.rs +++ b/crates/perry-ext-http/src/client_turnloop/native_client.rs @@ -15,10 +15,15 @@ use super::protocol::{self, Conn, Effect}; use super::{Outbound, PoolKey, SUBSYSTEM}; use crate::{push_event, ClientRequestHandle, PendingHttpEvent}; -static PARSER_VTABLE: perry_ffi::native_stream::PayloadVTable = perry_ffi::native_stream::payload_vtable::(None); -static PARSER: PayloadFamily = - PayloadFamily::new::(perry_ffi::native_class_ids::HTTP_CLIENT_PARSER, "HTTPClientParser", false, &PARSER_VTABLE) - .with_constructor_length(0); +static PARSER_VTABLE: perry_ffi::native_stream::PayloadVTable = + perry_ffi::native_stream::payload_vtable::(None); +static PARSER: PayloadFamily = PayloadFamily::new::( + perry_ffi::native_class_ids::HTTP_CLIENT_PARSER, + "HTTPClientParser", + false, + &PARSER_VTABLE, +) +.with_constructor_length(0); const PARSER_EDGE: &str = "httpClientParser"; pub(super) fn available() -> bool { @@ -137,7 +142,8 @@ pub(super) fn start_on_socket( let (host, port) = conn.peer(); let (host, port) = (host.to_owned(), port); let scope = TransientRootScope::enter(); - let parser = scope.root_nanbox(unsafe { np::alloc_in(&PARSER, "", conn, std::mem::size_of::(), &[]) }); + let parser = scope + .root_nanbox(unsafe { np::alloc_in(&PARSER, "", conn, std::mem::size_of::(), &[]) }); if np::lifecycle(parser.get(), &PARSER) != Ok(np::Lifecycle::Open) { retire_request(request); return Err("HTTP client parser allocation refused".into()); @@ -323,7 +329,8 @@ fn run(socket: &RootedSocket, effects: Vec) { dispose_parser(socket); detach_http_errors(socket); #[cfg(test)] - let head = if std::env::var("PERRY_NET_A_CLIENT_SABOTAGE").as_deref() == Ok("head") { + let head = if std::env::var("PERRY_NET_A_CLIENT_SABOTAGE").as_deref() == Ok("head") + { Vec::new() } else { head diff --git a/crates/perry-ext-http/src/server/http2_server/session.rs b/crates/perry-ext-http/src/server/http2_server/session.rs index 4cb3430578..53ed295d5c 100644 --- a/crates/perry-ext-http/src/server/http2_server/session.rs +++ b/crates/perry-ext-http/src/server/http2_server/session.rs @@ -6,8 +6,7 @@ use super::*; use std::collections::HashMap; use perry_ffi::{ - get_handle, get_handle_mut, iter_handle_ids_of, iter_handles_of, - register_handle, JsValue, + get_handle, get_handle_mut, iter_handle_ids_of, iter_handles_of, register_handle, JsValue, }; use crate::server::ensure_gc_scanner_registered; diff --git a/crates/perry-ext-http/src/server/turnloop_h2/native_server.rs b/crates/perry-ext-http/src/server/turnloop_h2/native_server.rs index 5cf111c993..7dcc67dba7 100644 --- a/crates/perry-ext-http/src/server/turnloop_h2/native_server.rs +++ b/crates/perry-ext-http/src/server/turnloop_h2/native_server.rs @@ -11,10 +11,15 @@ use perry_ffi::{JsThis, JsValue, RawClosureHeader, TransientRootScope}; use turnloop_http::http2::Role; const LINK_SUBSYSTEM: u8 = 15; -static FAMILY_VTABLE: perry_ffi::native_stream::PayloadVTable = perry_ffi::native_stream::payload_vtable::(None); -static FAMILY: PayloadFamily = - PayloadFamily::new::(perry_ffi::native_class_ids::HTTP2_CODEC, "HTTP2Codec", false, &FAMILY_VTABLE) - .with_constructor_length(0); +static FAMILY_VTABLE: perry_ffi::native_stream::PayloadVTable = + perry_ffi::native_stream::payload_vtable::(None); +static FAMILY: PayloadFamily = PayloadFamily::new::( + perry_ffi::native_class_ids::HTTP2_CODEC, + "HTTP2Codec", + false, + &FAMILY_VTABLE, +) +.with_constructor_length(0); fn with_codec(socket: &RootedSocket, f: impl FnOnce(&mut H2Conn) -> R) -> Option { if !socket.is_current() { @@ -106,48 +111,52 @@ unsafe extern "C" fn accepted(closure: *const RawClosureHeader, _: JsThis, owner conn::advertised_settings(&settings), ); let scope = TransientRootScope::enter(); - let codec = scope.root_nanbox(unsafe { np::alloc_in(&FAMILY, "", - H2Conn { - id: 0, - wire: Vec::new(), - deadline: None, - role: Role::Server, - server_handle, - session_handle: session, - core: None, - input: Vec::with_capacity(16 * 1024), - streams: Vec::new(), - secure, - handshaking: secure, - connecting: false, - client_tls: None, - tls_session: None, - alpn: None, - peer_address: peer - .as_ref() - .map(|peer| peer.address.clone()) - .unwrap_or_default(), - peer_port: peer.as_ref().map(|peer| peer.port).unwrap_or(0), - buffered: 0, - max_session_memory: 10 * 1024 * 1024, - timer: Timer::None, - draining: false, - closing: false, - read_eof: false, - destroyed: false, - queued_opens: Vec::new(), - allow_http1, - settings, - preface_done: false, - core_settings_acked: false, - owed_settings_acks: 0, - goaway_opaque: Vec::new(), - peer_settings: None, - pending_controls: Vec::new(), - }, - std::mem::size_of::() + 16 * 1024, - &[], - ) }); + let codec = scope.root_nanbox(unsafe { + np::alloc_in( + &FAMILY, + "", + H2Conn { + id: 0, + wire: Vec::new(), + deadline: None, + role: Role::Server, + server_handle, + session_handle: session, + core: None, + input: Vec::with_capacity(16 * 1024), + streams: Vec::new(), + secure, + handshaking: secure, + connecting: false, + client_tls: None, + tls_session: None, + alpn: None, + peer_address: peer + .as_ref() + .map(|peer| peer.address.clone()) + .unwrap_or_default(), + peer_port: peer.as_ref().map(|peer| peer.port).unwrap_or(0), + buffered: 0, + max_session_memory: 10 * 1024 * 1024, + timer: Timer::None, + draining: false, + closing: false, + read_eof: false, + destroyed: false, + queued_opens: Vec::new(), + allow_http1, + settings, + preface_done: false, + core_settings_acked: false, + owed_settings_acks: 0, + goaway_opaque: Vec::new(), + peer_settings: None, + pending_controls: Vec::new(), + }, + std::mem::size_of::() + 16 * 1024, + &[], + ) + }); net::set_codec(socket.value(), "h2", codec.get(), release); if net::set_route(socket.value(), LINK_SUBSYSTEM).is_err() { net::destroy(socket.value()); diff --git a/crates/perry-ext-http/src/server/turnloop_serve/conn.rs b/crates/perry-ext-http/src/server/turnloop_serve/conn.rs index 6de0422a06..995b5b9187 100644 --- a/crates/perry-ext-http/src/server/turnloop_serve/conn.rs +++ b/crates/perry-ext-http/src/server/turnloop_serve/conn.rs @@ -112,10 +112,15 @@ pub(crate) struct Conn { websocket: bool, } -static PARSER_VTABLE: perry_ffi::native_stream::PayloadVTable = perry_ffi::native_stream::payload_vtable::(None); -static PARSER: PayloadFamily = - PayloadFamily::new::(perry_ffi::native_class_ids::HTTP_PARSER, "HTTPParser", false, &PARSER_VTABLE) - .with_constructor_length(0); +static PARSER_VTABLE: perry_ffi::native_stream::PayloadVTable = + perry_ffi::native_stream::payload_vtable::(None); +static PARSER: PayloadFamily = PayloadFamily::new::( + perry_ffi::native_class_ids::HTTP_PARSER, + "HTTPParser", + false, + &PARSER_VTABLE, +) +.with_constructor_length(0); /// Explicit cleanup runs outside SocketFields::Drop. The ordinary `parser` /// edge remains valid and CLOSED, including for a late socket close listener. @@ -332,31 +337,35 @@ fn initialize_connection(owner: f64, server_handle: i64, announce: bool) { .unwrap_or((0, 5_000.0)); let secure = net::tls_installed(socket.value()); let scope = TransientRootScope::enter(); - let parser = scope.root_nanbox(unsafe { np::alloc_in(&PARSER, "", - Conn { - server_handle, - peer_address, - peer_port, - decoder: http1::Decoder::new(http1::Mode::Request, Default::default()), - input: Vec::with_capacity(8 * 1024), - building: None, - active: None, - seq: 0, - requests: 0, - idle_close_ms, - keep_alive_timeout_ms, - paused: false, - read_eof: false, - closing: false, - write_shut: false, - destroyed: false, - secure, - handshaking: secure, - websocket: false, - }, - std::mem::size_of::() + 8 * 1024, - &[], - ) }); + let parser = scope.root_nanbox(unsafe { + np::alloc_in( + &PARSER, + "", + Conn { + server_handle, + peer_address, + peer_port, + decoder: http1::Decoder::new(http1::Mode::Request, Default::default()), + input: Vec::with_capacity(8 * 1024), + building: None, + active: None, + seq: 0, + requests: 0, + idle_close_ms, + keep_alive_timeout_ms, + paused: false, + read_eof: false, + closing: false, + write_shut: false, + destroyed: false, + secure, + handshaking: secure, + websocket: false, + }, + std::mem::size_of::() + 8 * 1024, + &[], + ) + }); unsafe { net::set_codec(socket.value(), "parser", parser.get(), close_parser); } diff --git a/crates/perry-ext-net/src/bun_tcp.rs b/crates/perry-ext-net/src/bun_tcp.rs index 652137c4c8..ab4b15a68d 100644 --- a/crates/perry-ext-net/src/bun_tcp.rs +++ b/crates/perry-ext-net/src/bun_tcp.rs @@ -29,7 +29,6 @@ unsafe extern "C" fn event(closure: *const RawClosureHeader, _: JsThis, arg: f64 let state = scope.root_nanbox(bun_state(owner.get())); let handlers = scope.root_nanbox(p::own_get(state.get(), "bunHandlers")); let promise = scope.root_nanbox(p::own_get(state.get(), "bunPromise")); - let opened = boolean(p::own_get(state.get(), "bunOpened")); if index == 0 || index == 4 { p::own_set(state.get(), "bunPromise", p::undefined()); if JsValue::from_bits(promise.get().to_bits()).is_pointer() { @@ -41,16 +40,9 @@ unsafe extern "C" fn event(closure: *const RawClosureHeader, _: JsThis, arg: f64 } } } - if index == 0 { - p::own_set( - state.get(), - "bunOpened", - f64::from_bits(JsValue::TRUE.bits()), - ); - } let handler = scope.root_nanbox(p::own_get( handlers.get(), - if index == 4 && !opened { + if index == 4 && JsValue::from_bits(promise.get().to_bits()).is_pointer() { "connectError" } else { HANDLERS[index] @@ -96,11 +88,6 @@ fn install(owner: f64, options: f64) { p::own_set(state.get(), "bunHandlers", handlers.get()); let data = scope.root_nanbox(p::own_get(options.get(), "data")); p::own_set(owner.get(), "data", data.get()); - p::own_set( - state.get(), - "bunOpened", - f64::from_bits(JsValue::FALSE.bits()), - ); if p::socket_link(owner.get()).is_ok() { unsafe { if let Ok(payload) = p::socket_ptr(socket::link(owner.get())) { @@ -314,12 +301,6 @@ unsafe extern "C" fn connection(closure: *const RawClosureHeader, _: JsThis, chi install(child.get(), options.get()); p::own_set(child.get(), "listener", owner.get()); let callback = scope.root_nanbox(p::own_get(handlers.get(), "open")); - let state = scope.root_nanbox(bun_state(child.get())); - p::own_set( - state.get(), - "bunOpened", - f64::from_bits(JsValue::TRUE.bits()), - ); if socket::is_callback(callback.get()) { events::call(callback.get(), JsThis::UNDEFINED, &[child.get()]); } diff --git a/crates/perry-ext-net/src/classes.rs b/crates/perry-ext-net/src/classes.rs index 908fb6b575..79091467a4 100644 --- a/crates/perry-ext-net/src/classes.rs +++ b/crates/perry-ext-net/src/classes.rs @@ -9,14 +9,22 @@ mod prototype; const BLOCK_LIST_ID: u32 = perry_ffi::native_class_ids::NET_BLOCK_LIST; const SOCKET_ADDRESS_ID: u32 = perry_ffi::native_class_ids::NET_SOCKET_ADDRESS; -static BLOCK_LIST_VTABLE: perry_ffi::native_stream::PayloadVTable = perry_ffi::native_stream::payload_vtable::(None); -static BLOCK_LIST: PayloadFamily = PayloadFamily::new::(BLOCK_LIST_ID, "BlockList", false, &BLOCK_LIST_VTABLE) - .with_constructor_length(0) - .with_installer(prototype::install_block_list); -static SOCKET_ADDRESS_VTABLE: perry_ffi::native_stream::PayloadVTable = perry_ffi::native_stream::payload_vtable::(None); -static SOCKET_ADDRESS: PayloadFamily = PayloadFamily::new::(SOCKET_ADDRESS_ID, "SocketAddress", false, &SOCKET_ADDRESS_VTABLE) - .with_constructor_length(0) - .with_installer(prototype::install_socket_address); +static BLOCK_LIST_VTABLE: perry_ffi::native_stream::PayloadVTable = + perry_ffi::native_stream::payload_vtable::(None); +static BLOCK_LIST: PayloadFamily = + PayloadFamily::new::(BLOCK_LIST_ID, "BlockList", false, &BLOCK_LIST_VTABLE) + .with_constructor_length(0) + .with_installer(prototype::install_block_list); +static SOCKET_ADDRESS_VTABLE: perry_ffi::native_stream::PayloadVTable = + perry_ffi::native_stream::payload_vtable::(None); +static SOCKET_ADDRESS: PayloadFamily = PayloadFamily::new::( + SOCKET_ADDRESS_ID, + "SocketAddress", + false, + &SOCKET_ADDRESS_VTABLE, +) +.with_constructor_length(0) +.with_installer(prototype::install_socket_address); use crate::{get_object_number_field, get_object_string_field, string_from_header_i64}; @@ -249,11 +257,15 @@ fn string_from_js_value(value: JsValue) -> Option { #[no_mangle] pub unsafe extern "C" fn js_net_block_list_new() -> i64 { - handle_from_value(unsafe { np::alloc_in(&BLOCK_LIST, "net", - BlockListState { rules: Vec::new() }, - 0, - &[], - ) }) + handle_from_value(unsafe { + np::alloc_in( + &BLOCK_LIST, + "net", + BlockListState { rules: Vec::new() }, + 0, + &[], + ) + }) .unwrap_or(0) } @@ -379,15 +391,19 @@ pub unsafe extern "C" fn js_net_block_list_from_json(handle: i64, value: f64) -> } fn socket_address_new(address: IpAddr, port: u16, flowlabel: u32) -> f64 { - unsafe { np::alloc_in(&SOCKET_ADDRESS, "net", - SocketAddressState { - address, - port, - flowlabel, - }, - 0, - &[], - ) } + unsafe { + np::alloc_in( + &SOCKET_ADDRESS, + "net", + SocketAddressState { + address, + port, + flowlabel, + }, + 0, + &[], + ) + } } #[no_mangle] diff --git a/crates/perry-ext-net/src/lib.rs b/crates/perry-ext-net/src/lib.rs index a4381ee5d3..5bb49da36b 100644 --- a/crates/perry-ext-net/src/lib.rs +++ b/crates/perry-ext-net/src/lib.rs @@ -121,7 +121,10 @@ pub unsafe extern "C" fn js_net_server_close(handle: i64, callback_i64: i64) { /// dispatch (`handle` is the server object's address). #[no_mangle] pub unsafe extern "C" fn js_net_server_address(handle: i64) -> *mut StringHeader { - alloc_string(&payload_server::address_json(payload_transport::boxed_addr(handle))).as_raw() + alloc_string(&payload_server::address_json( + payload_transport::boxed_addr(handle), + )) + .as_raw() } #[no_mangle] diff --git a/crates/perry-ext-net/src/payload_prototype.rs b/crates/perry-ext-net/src/payload_prototype.rs index c521d5b854..a5de200761 100644 --- a/crates/perry-ext-net/src/payload_prototype.rs +++ b/crates/perry-ext-net/src/payload_prototype.rs @@ -17,13 +17,20 @@ pub(crate) fn throw_miss(miss: PayloadMiss) -> T { extern "C-unwind" { fn js_throw(value: f64) -> !; } - let message = perry_ffi::alloc_string(match miss { - PayloadMiss::Foreign => "Illegal receiver", - PayloadMiss::Closed => "Native payload is closed", - }); + let scope = TransientRootScope::enter(); + let message = scope.root_addr( + perry_ffi::alloc_string(match miss { + PayloadMiss::Foreign => "Illegal receiver", + PayloadMiss::Closed => "Native payload is closed", + }) + .as_raw() as i64, + ); unsafe { js_throw(f64::from_bits( - JsValue::from_object_ptr(js_typeerror_new(message.as_raw())).bits(), + JsValue::from_object_ptr(js_typeerror_new( + message.get() as *mut perry_ffi::StringHeader + )) + .bits(), )) } } diff --git a/crates/perry-ext-net/src/payload_tests.rs b/crates/perry-ext-net/src/payload_tests.rs index 3ceaa09e44..4182febbc8 100644 --- a/crates/perry-ext-net/src/payload_tests.rs +++ b/crates/perry-ext-net/src/payload_tests.rs @@ -18,9 +18,15 @@ impl Drop for CodecProbe { CODEC_DROPS.fetch_add(1, std::sync::atomic::Ordering::SeqCst); } } -static CODEC_VTABLE: perry_ffi::native_stream::PayloadVTable = perry_ffi::native_stream::payload_vtable::(None); +static CODEC_VTABLE: perry_ffi::native_stream::PayloadVTable = + perry_ffi::native_stream::payload_vtable::(None); static CODEC: perry_ffi::native_payload::PayloadFamily = - perry_ffi::native_payload::PayloadFamily::new::(perry_ffi::native_class_ids::HTTP_PARSER, "HTTPParser", false, &CODEC_VTABLE) + perry_ffi::native_payload::PayloadFamily::new::( + perry_ffi::native_class_ids::HTTP_PARSER, + "HTTPParser", + false, + &CODEC_VTABLE, + ) .with_constructor_length(0); unsafe fn close_codec(owner: f64) { @@ -38,11 +44,15 @@ fn explicit_socket_close_releases_its_separate_codec_immediately_and_once() { CODEC_DROPS.store(0, std::sync::atomic::Ordering::SeqCst); let scope = TransientRootScope::enter(); let owner = scope.root_nanbox(socket::new_socket(io::ROUTE, p::undefined())); - let parser = scope.root_nanbox(unsafe { perry_ffi::native_payload::alloc_in(&CODEC, "", - CodecProbe(vec![1; 16 * 1024]), - 16 * 1024 + std::mem::size_of::(), - &[], - ) }); + let parser = scope.root_nanbox(unsafe { + perry_ffi::native_payload::alloc_in( + &CODEC, + "", + CodecProbe(vec![1; 16 * 1024]), + 16 * 1024 + std::mem::size_of::(), + &[], + ) + }); unsafe { assert_eq!( perry_ffi::native_payload::payload_mut::(parser.get(), &CODEC) diff --git a/crates/perry-ext-net/src/payload_transport.rs b/crates/perry-ext-net/src/payload_transport.rs index f1fd3d7cd5..631ce02130 100644 --- a/crates/perry-ext-net/src/payload_transport.rs +++ b/crates/perry-ext-net/src/payload_transport.rs @@ -13,14 +13,18 @@ use std::net::SocketAddr; pub(crate) const SOCKET_CLASS_ID: u32 = perry_ffi::native_class_ids::NET_SOCKET; pub(crate) const SERVER_CLASS_ID: u32 = perry_ffi::native_class_ids::NET_SERVER; -static SOCKET_VTABLE: perry_ffi::native_stream::PayloadVTable = perry_ffi::native_stream::payload_vtable::(None); -pub(crate) static SOCKET: PayloadFamily = PayloadFamily::new::(SOCKET_CLASS_ID, "Socket", true, &SOCKET_VTABLE) - .with_constructor_length(1) - .with_installer(super::payload_prototype::install_socket); -static SERVER_VTABLE: perry_ffi::native_stream::PayloadVTable = perry_ffi::native_stream::payload_vtable::(None); -pub(crate) static SERVER: PayloadFamily = PayloadFamily::new::(SERVER_CLASS_ID, "Server", true, &SERVER_VTABLE) - .with_constructor_length(2) - .with_installer(super::payload_prototype::install_server); +static SOCKET_VTABLE: perry_ffi::native_stream::PayloadVTable = + perry_ffi::native_stream::payload_vtable::(None); +pub(crate) static SOCKET: PayloadFamily = + PayloadFamily::new::(SOCKET_CLASS_ID, "Socket", true, &SOCKET_VTABLE) + .with_constructor_length(1) + .with_installer(super::payload_prototype::install_socket); +static SERVER_VTABLE: perry_ffi::native_stream::PayloadVTable = + perry_ffi::native_stream::payload_vtable::(None); +pub(crate) static SERVER: PayloadFamily = + PayloadFamily::new::(SERVER_CLASS_ID, "Server", true, &SERVER_VTABLE) + .with_constructor_length(2) + .with_installer(super::payload_prototype::install_server); pub(crate) type SocketPayload = TransportPayload; pub(crate) type ServerPayload = TransportPayload; @@ -209,11 +213,15 @@ pub(crate) fn alloc_socket(route: u8, fields: SocketFields) -> f64 { super::payload_io::register(); let scope = TransientRootScope::enter(); let bytes = std::mem::size_of::() + fields.retained_bytes(); - let owner = scope.root_nanbox(unsafe { np::alloc_in(&SOCKET, "net", - SocketPayload::new(route, fields), - bytes, - &[], - ) }); + let owner = scope.root_nanbox(unsafe { + np::alloc_in( + &SOCKET, + "net", + SocketPayload::new(route, fields), + bytes, + &[], + ) + }); unsafe { js_event_emitter_subclass_init(owner.get(), undefined()); } @@ -224,11 +232,15 @@ pub(crate) fn alloc_server(route: u8, fields: ServerFields) -> f64 { let scope = TransientRootScope::enter(); let bytes = std::mem::size_of::() + fields.path.as_ref().map_or(0, String::capacity); - let owner = scope.root_nanbox(unsafe { np::alloc_in(&SERVER, "net", - ServerPayload::new(route, fields), - bytes, - &[], - ) }); + let owner = scope.root_nanbox(unsafe { + np::alloc_in( + &SERVER, + "net", + ServerPayload::new(route, fields), + bytes, + &[], + ) + }); unsafe { js_event_emitter_subclass_init(owner.get(), undefined()); } @@ -242,12 +254,14 @@ pub(crate) fn attach_socket(owner: f64, route: u8, fields: SocketFields) -> bool let scope = TransientRootScope::enter(); let owner = scope.root_nanbox(owner); let bytes = std::mem::size_of::() + fields.retained_bytes(); - if !unsafe { np::attach_to_object( - owner.get(), - &SOCKET, - SocketPayload::new(route, fields), - bytes, - ) } { + if !unsafe { + np::attach_to_object( + owner.get(), + &SOCKET, + SocketPayload::new(route, fields), + bytes, + ) + } { return false; } unsafe { @@ -261,12 +275,14 @@ pub(crate) fn attach_server(owner: f64, route: u8, fields: ServerFields) -> bool let owner = scope.root_nanbox(owner); let bytes = std::mem::size_of::() + fields.path.as_ref().map_or(0, String::capacity); - if !unsafe { np::attach_to_object( - owner.get(), - &SERVER, - ServerPayload::new(route, fields), - bytes, - ) } { + if !unsafe { + np::attach_to_object( + owner.get(), + &SERVER, + ServerPayload::new(route, fields), + bytes, + ) + } { return false; } unsafe { @@ -299,11 +315,11 @@ pub(crate) fn own_set(owner: f64, key: &str, value: f64) { let scope = TransientRootScope::enter(); let owner = scope.root_nanbox(owner); let value = scope.root_nanbox(value); - let key = perry_ffi::alloc_string(key); + let key = scope.root_addr(perry_ffi::alloc_string(key).as_raw() as i64); unsafe { js_object_set_field_by_name( JsValue::from_bits(owner.get().to_bits()).as_pointer(), - key.as_raw(), + key.get() as *const perry_ffi::StringHeader, value.get(), ); } diff --git a/crates/perry-ext-ws/src/native_socket.rs b/crates/perry-ext-ws/src/native_socket.rs index c9d50d46ee..7c59252c1c 100644 --- a/crates/perry-ext-ws/src/native_socket.rs +++ b/crates/perry-ext-ws/src/native_socket.rs @@ -16,10 +16,15 @@ struct SocketCodec { write_shut: bool, read_eof: bool, } -static FAMILY_VTABLE: perry_ffi::native_stream::PayloadVTable = perry_ffi::native_stream::payload_vtable::(None); -static FAMILY: PayloadFamily = - PayloadFamily::new::(perry_ffi::native_class_ids::WS_CODEC, "WebSocketCodec", false, &FAMILY_VTABLE) - .with_constructor_length(0); +static FAMILY_VTABLE: perry_ffi::native_stream::PayloadVTable = + perry_ffi::native_stream::payload_vtable::(None); +static FAMILY: PayloadFamily = PayloadFamily::new::( + perry_ffi::native_class_ids::WS_CODEC, + "WebSocketCodec", + false, + &FAMILY_VTABLE, +) +.with_constructor_length(0); fn with_codec(socket: &RootedSocket, f: impl FnOnce(&mut SocketCodec) -> R) -> Option { if !socket.is_current() { @@ -57,17 +62,21 @@ pub fn adopt(owner: f64, leftover: &[u8]) -> i64 { let socket = RootedSocket::new(owner); let ws_id = crate::allocate_client_id(); let scope = TransientRootScope::enter(); - let codec = scope.root_nanbox(unsafe { np::alloc_in(&FAMILY, "", - SocketCodec { - ws_id, - codec: Codec::new(Role::Server), - closing: false, - write_shut: false, - read_eof: false, - }, - std::mem::size_of::(), - &[], - ) }); + let codec = scope.root_nanbox(unsafe { + np::alloc_in( + &FAMILY, + "", + SocketCodec { + ws_id, + codec: Codec::new(Role::Server), + closing: false, + write_shut: false, + read_eof: false, + }, + std::mem::size_of::(), + &[], + ) + }); unsafe { net::set_codec(socket.value(), "ws", codec.get(), release) }; crate::attach_socket_client(ws_id, socket.value()); if net::set_route(socket.value(), LINK_SUBSYSTEM).is_err() { diff --git a/crates/perry-ffi/src/native_payload_links.rs b/crates/perry-ffi/src/native_payload_links.rs index b3fd591aa1..c3b9bf0933 100644 --- a/crates/perry-ffi/src/native_payload_links.rs +++ b/crates/perry-ffi/src/native_payload_links.rs @@ -153,8 +153,11 @@ fn miss_code(code: i32) -> PayloadMiss { } extern "C" { - fn js_perry_payload_link_ptr(link: usize, family: *const PayloadFamily, miss: *mut i32) - -> *mut c_void; + fn js_perry_payload_link_ptr( + link: usize, + family: *const PayloadFamily, + miss: *mut i32, + ) -> *mut c_void; fn js_perry_payload_link_lifecycle(link: usize, family: *const PayloadFamily) -> i32; fn js_perry_payload_link_close(link: usize, family: *const PayloadFamily) -> i32; fn js_perry_payload_link_reopen( @@ -163,9 +166,16 @@ extern "C" { resource: *mut c_void, bytes: usize, ) -> i32; - fn js_perry_payload_link_js_state(link: usize, family: *const PayloadFamily, create: i32) - -> f64; - fn js_perry_payload_link_external_bytes(link: usize, family: *const PayloadFamily, bytes: usize); + fn js_perry_payload_link_js_state( + link: usize, + family: *const PayloadFamily, + create: i32, + ) -> f64; + fn js_perry_payload_link_external_bytes( + link: usize, + family: *const PayloadFamily, + bytes: usize, + ); fn js_perry_payload_receiver_link( value: f64, family: *const PayloadFamily, @@ -290,7 +300,12 @@ pub unsafe fn attach_link( bytes: usize, ) -> Result<(), AttachMiss> { let ptr = Box::into_raw(Box::new(payload)); - let result = attach_code(js_perry_payload_link_reopen(link.0, family, ptr.cast(), bytes)); + let result = attach_code(js_perry_payload_link_reopen( + link.0, + family, + ptr.cast(), + bytes, + )); if result.is_err() { drop(Box::from_raw(ptr)); } @@ -319,7 +334,11 @@ pub unsafe fn link_js_state(link: OwnerLink, family: &'static PayloadFamily, cre /// Restate the native bytes the payload behind a link retains. /// # Safety /// As [`link_payload_ptr`]. -pub unsafe fn link_set_external_bytes(link: OwnerLink, family: &'static PayloadFamily, bytes: usize) { +pub unsafe fn link_set_external_bytes( + link: OwnerLink, + family: &'static PayloadFamily, + bytes: usize, +) { js_perry_payload_link_external_bytes(link.0, family, bytes); } @@ -340,7 +359,14 @@ impl PayloadPrototype { unsafe { js_perry_payload_proto_getter(self.0, name.as_ptr(), name.len(), info) } } /// A data property. - pub fn data(&mut self, name: &str, value: f64, writable: bool, enumerable: bool, configurable: bool) { + pub fn data( + &mut self, + name: &str, + value: f64, + writable: bool, + enumerable: bool, + configurable: bool, + ) { let flags = u32::from(writable) | u32::from(enumerable) << 1 | u32::from(configurable) << 2; unsafe { js_perry_payload_proto_data(self.0, name.as_ptr(), name.len(), value, flags) } } diff --git a/crates/perry-runtime/src/agent.rs b/crates/perry-runtime/src/agent.rs index 56087a42eb..bea401a73e 100644 --- a/crates/perry-runtime/src/agent.rs +++ b/crates/perry-runtime/src/agent.rs @@ -218,8 +218,10 @@ mod tests { #[test] fn ffi_retire_hooks_share_the_existing_once_per_agent_cleanup_list() { use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; - static SEEN: AtomicU64 = AtomicU64::new(0); - static CALLS: AtomicUsize = AtomicUsize::new(0); + per_test_global! { + static SEEN: AtomicU64 = AtomicU64::new(0); + static CALLS: AtomicUsize = AtomicUsize::new(0); + } extern "C" fn hook(agent: AgentId) { assert_eq!(current_agent(), agent); SEEN.store(agent, Ordering::SeqCst); @@ -227,16 +229,14 @@ mod tests { } js_perry_agent_register_retire_hook(hook); js_perry_agent_register_retire_hook(hook); - let before = CALLS.load(Ordering::SeqCst); - let agent = std::thread::spawn(|| { + std::thread::spawn(|| { let agent = enter_worker_agent(); retire_agent(agent); - agent + assert_eq!(SEEN.load(Ordering::SeqCst), agent); + assert_eq!(CALLS.load(Ordering::SeqCst), 1); }) .join() .unwrap(); - assert_eq!(SEEN.load(Ordering::SeqCst), agent); - assert_eq!(CALLS.load(Ordering::SeqCst), before + 1); } /// A plain thread that never claims a worker agent — the main thread, or a diff --git a/crates/perry-runtime/src/gc/tests/mod.rs b/crates/perry-runtime/src/gc/tests/mod.rs index 6610921105..c0d1e6c30c 100644 --- a/crates/perry-runtime/src/gc/tests/mod.rs +++ b/crates/perry-runtime/src/gc/tests/mod.rs @@ -71,7 +71,6 @@ mod native_payload; mod native_payload_buffer; mod native_payload_callbacks; mod native_payload_streams; -mod stream_state_record; mod native_this_alias; #[cfg(not(target_arch = "wasm32"))] mod net_transport; @@ -104,6 +103,7 @@ mod smoke; mod start_bitmap; mod static_seed_keys; mod step_bounds; +mod stream_state_record; mod string_char_array_roots; pub(super) mod support; mod survival_diag; diff --git a/crates/perry-runtime/src/gc/tests/native_payload.rs b/crates/perry-runtime/src/gc/tests/native_payload.rs index 43d34b483d..4e1176ad30 100644 --- a/crates/perry-runtime/src/gc/tests/native_payload.rs +++ b/crates/perry-runtime/src/gc/tests/native_payload.rs @@ -11,7 +11,9 @@ use super::support::*; use crate::native_payload::{self, NativePayloadFamily, PayloadMiss}; use std::sync::atomic::{AtomicUsize, Ordering}; -static DROPS: AtomicUsize = AtomicUsize::new(0); +per_test_global! { + static DROPS: AtomicUsize = AtomicUsize::new(0); +} struct Probe { value: u64, diff --git a/crates/perry-runtime/src/gc/tests/native_payload_callbacks.rs b/crates/perry-runtime/src/gc/tests/native_payload_callbacks.rs index fe95eb1de5..a1d155b354 100644 --- a/crates/perry-runtime/src/gc/tests/native_payload_callbacks.rs +++ b/crates/perry-runtime/src/gc/tests/native_payload_callbacks.rs @@ -8,9 +8,15 @@ use crate::native_payload::{ use crate::value::TAG_UNDEFINED; use std::sync::atomic::{AtomicUsize, Ordering}; -static DROPS: AtomicUsize = AtomicUsize::new(0); -static CALLS: AtomicUsize = AtomicUsize::new(0); -static FINAL_JS: AtomicUsize = AtomicUsize::new(0); +per_test_global! { + static DROPS: AtomicUsize = AtomicUsize::new(0); +} +per_test_global! { + static CALLS: AtomicUsize = AtomicUsize::new(0); +} +per_test_global! { + static FINAL_JS: AtomicUsize = AtomicUsize::new(0); +} #[derive(Default)] struct Probe { @@ -1015,5 +1021,5 @@ fn thrown(result: Result<(), CallEnd>) -> f64 { } } -#[path = "native_payload_lifecycle.rs"] +#[path = "native_payload_callbacks/lifecycle.rs"] mod lifecycle; diff --git a/crates/perry-runtime/src/gc/tests/native_payload_lifecycle.rs b/crates/perry-runtime/src/gc/tests/native_payload_callbacks/lifecycle.rs similarity index 100% rename from crates/perry-runtime/src/gc/tests/native_payload_lifecycle.rs rename to crates/perry-runtime/src/gc/tests/native_payload_callbacks/lifecycle.rs diff --git a/crates/perry-runtime/src/gc/tests/net_transport.rs b/crates/perry-runtime/src/gc/tests/net_transport.rs index a2c32742d5..51245ce74b 100644 --- a/crates/perry-runtime/src/gc/tests/net_transport.rs +++ b/crates/perry-runtime/src/gc/tests/net_transport.rs @@ -14,7 +14,11 @@ use std::cell::{Cell, RefCell}; use std::sync::atomic::{AtomicUsize, Ordering}; use std::time::{Duration, Instant}; -static DROPS: AtomicUsize = AtomicUsize::new(0); +per_test_global! { + + static DROPS: AtomicUsize = AtomicUsize::new(0); + +} /// The family's own fields; its drop is counted. struct Ext; diff --git a/crates/perry-runtime/src/native_payload.rs b/crates/perry-runtime/src/native_payload.rs index 8e6b0fe3da..94eb832449 100644 --- a/crates/perry-runtime/src/native_payload.rs +++ b/crates/perry-runtime/src/native_payload.rs @@ -1642,105 +1642,11 @@ pub fn set_pending_exception(owner: f64, exception: f64) -> Result<(), ()> { } } -/// Default-off acceptance instrumentation for net.Socket payload cells. -/// Scalar counts only; no addresses or identities are stored. +#[path = "native_payload_links.rs"] +mod links; #[cfg(feature = "native-payload-test-census")] -pub mod test_census { - use std::sync::atomic::{AtomicU64, Ordering}; - static CREATED: AtomicU64 = AtomicU64::new(0); - static FINALIZED: AtomicU64 = AtomicU64::new(0); - static DROPS: AtomicU64 = AtomicU64::new(0); - static REFS: AtomicU64 = AtomicU64::new(0); - fn counted(tag: u64) -> bool { - tag as u32 == crate::native_class_ids::NET_SOCKET - } - /// `[created, finalized, payload drops, outstanding cell refs]`. - pub fn snapshot() -> [u64; 4] { - [ - CREATED.load(Ordering::SeqCst), - FINALIZED.load(Ordering::SeqCst), - DROPS.load(Ordering::SeqCst), - REFS.load(Ordering::SeqCst), - ] - } - pub(crate) fn created(tag: u64) { - if counted(tag) { - CREATED.fetch_add(1, Ordering::SeqCst); - } - } - pub(crate) fn finalized(tag: u64, refs: u32) { - if counted(tag) { - FINALIZED.fetch_add(1, Ordering::SeqCst); - // Completions discarded at worker teardown never unref; the heap - // retires their remaining refs with the cell. - REFS.fetch_sub(refs as u64, Ordering::SeqCst); - } - } - pub(crate) fn dropped(tag: u64) { - if counted(tag) { - DROPS.fetch_add(1, Ordering::SeqCst); - } - } - pub(crate) fn reference(tag: u64) { - if counted(tag) { - REFS.fetch_add(1, Ordering::SeqCst); - } - } - pub(crate) fn unreference(tag: u64) { - if counted(tag) { - REFS.fetch_sub(1, Ordering::SeqCst); - } - } -} - -/// # Safety -/// Call on the creator thread with a live cell, once per outstanding item. -pub unsafe fn link_ref(link: OwnerLink) { - let cell = link.0 as *mut NativeHandleHeader; - assert_eq!( - (*cell).creator_thread_id, - crate::native_handle::current_thread_id() - ); - (*cell).refs = (*cell).refs.checked_add(1).expect("native refs overflow"); - #[cfg(feature = "native-payload-test-census")] - test_census::reference((*cell).type_id); - #[cfg(test)] - if callback_sabotage("pin") { - return; - } - if (*cell).refs == 1 { - #[cfg(test)] - if callback_sabotage("latch") { - crate::gc::pin_object( - (((*cell).owner & crate::value::POINTER_MASK) as *mut u8) - .sub(crate::gc::GC_HEADER_SIZE) as *mut crate::gc::GcHeader, - ); - return; - } - crate::gc::pin_object_non_young( - (cell as *mut u8).sub(crate::gc::GC_HEADER_SIZE) as *mut crate::gc::GcHeader - ); - } -} - -/// # Safety -/// Match a link_ref on the creator thread, even when explicitly closed. -pub unsafe fn link_unref(link: OwnerLink) { - let cell = link.0 as *mut NativeHandleHeader; - assert_eq!( - (*cell).creator_thread_id, - crate::native_handle::current_thread_id() - ); - assert_ne!((*cell).refs, 0, "unbalanced native unref"); - (*cell).refs -= 1; - #[cfg(feature = "native-payload-test-census")] - test_census::unreference((*cell).type_id); - if (*cell).refs == 0 { - crate::gc::unpin_object( - (cell as *mut u8).sub(crate::gc::GC_HEADER_SIZE) as *mut crate::gc::GcHeader - ); - } -} +pub use links::test_census; +pub use links::{link_ref, link_unref}; /// Explicitly close a payload attached to an ordinary subclass instance. /// diff --git a/crates/perry-runtime/src/native_payload_census.rs b/crates/perry-runtime/src/native_payload_census.rs new file mode 100644 index 0000000000..28d70db9e1 --- /dev/null +++ b/crates/perry-runtime/src/native_payload_census.rs @@ -0,0 +1,48 @@ +//! Scalar-only acceptance census for net.Socket payloads. +use std::sync::atomic::{AtomicU64, Ordering}; +static CREATED: AtomicU64 = AtomicU64::new(0); +static FINALIZED: AtomicU64 = AtomicU64::new(0); +per_test_global! { + static DROPS: AtomicU64 = AtomicU64::new(0); +} +static REFS: AtomicU64 = AtomicU64::new(0); +fn counted(tag: u64) -> bool { + tag as u32 == crate::native_class_ids::NET_SOCKET +} +/// `[created, finalized, payload drops, outstanding cell refs]`. +pub fn snapshot() -> [u64; 4] { + [ + CREATED.load(Ordering::SeqCst), + FINALIZED.load(Ordering::SeqCst), + DROPS.load(Ordering::SeqCst), + REFS.load(Ordering::SeqCst), + ] +} +pub(crate) fn created(tag: u64) { + if counted(tag) { + CREATED.fetch_add(1, Ordering::SeqCst); + } +} +pub(crate) fn finalized(tag: u64, refs: u32) { + if counted(tag) { + FINALIZED.fetch_add(1, Ordering::SeqCst); + // Completions discarded at worker teardown never unref; the heap + // retires their remaining refs with the cell. + REFS.fetch_sub(refs as u64, Ordering::SeqCst); + } +} +pub(crate) fn dropped(tag: u64) { + if counted(tag) { + DROPS.fetch_add(1, Ordering::SeqCst); + } +} +pub(crate) fn reference(tag: u64) { + if counted(tag) { + REFS.fetch_add(1, Ordering::SeqCst); + } +} +pub(crate) fn unreference(tag: u64) { + if counted(tag) { + REFS.fetch_sub(1, Ordering::SeqCst); + } +} diff --git a/crates/perry-runtime/src/native_payload_links.rs b/crates/perry-runtime/src/native_payload_links.rs new file mode 100644 index 0000000000..ca2b41eec2 --- /dev/null +++ b/crates/perry-runtime/src/native_payload_links.rs @@ -0,0 +1,64 @@ +//! Reference pins and acceptance census for stable payload links. +use super::*; + +/// Default-off acceptance instrumentation for net.Socket payload cells. +/// Scalar counts only; no addresses or identities are stored. +#[cfg(feature = "native-payload-test-census")] +#[path = "native_payload_census.rs"] +pub mod test_census; + +/// # Safety +/// Call on the creator thread with a live cell, once per outstanding item. +pub unsafe fn link_ref(link: OwnerLink) { + let cell = link.0 as *mut NativeHandleHeader; + assert_eq!( + (*cell).creator_thread_id, + crate::native_handle::current_thread_id() + ); + (*cell).refs = (*cell).refs.checked_add(1).expect("native refs overflow"); + #[cfg(feature = "native-payload-test-census")] + test_census::reference((*cell).type_id); + #[cfg(test)] + if callback_sabotage("pin") { + return; + } + if (*cell).refs == 1 { + #[cfg(test)] + if callback_sabotage("latch") { + crate::gc::pin_object( + crate::value::addr_class::try_read_tracked_gc_header( + ((*cell).owner & crate::value::POINTER_MASK) as usize, + ) + .expect("live payload owner") + .as_ptr(), + ); + return; + } + crate::gc::pin_object_non_young( + crate::value::addr_class::try_read_tracked_gc_header(cell as usize) + .expect("live payload cell") + .as_ptr(), + ); + } +} + +/// # Safety +/// Match a link_ref on the creator thread, even when explicitly closed. +pub unsafe fn link_unref(link: OwnerLink) { + let cell = link.0 as *mut NativeHandleHeader; + assert_eq!( + (*cell).creator_thread_id, + crate::native_handle::current_thread_id() + ); + assert_ne!((*cell).refs, 0, "unbalanced native unref"); + (*cell).refs -= 1; + #[cfg(feature = "native-payload-test-census")] + test_census::unreference((*cell).type_id); + if (*cell).refs == 0 { + crate::gc::unpin_object( + crate::value::addr_class::try_read_tracked_gc_header(cell as usize) + .expect("live payload cell") + .as_ptr(), + ); + } +} diff --git a/crates/perry-runtime/src/native_payload_transport_abi.rs b/crates/perry-runtime/src/native_payload_transport_abi.rs index e6b1778494..2343d5fd1c 100644 --- a/crates/perry-runtime/src/native_payload_transport_abi.rs +++ b/crates/perry-runtime/src/native_payload_transport_abi.rs @@ -169,8 +169,11 @@ mod keepalive { static JS_STATE: unsafe extern "C" fn(f64, *const PerryPayloadFamily, i32) -> f64 = js_perry_payload_js_state; #[used(compiler)] - static LINK_PTR: unsafe extern "C" fn(usize, *const PerryPayloadFamily, *mut i32) -> *mut c_void = - js_perry_payload_link_ptr; + static LINK_PTR: unsafe extern "C" fn( + usize, + *const PerryPayloadFamily, + *mut i32, + ) -> *mut c_void = js_perry_payload_link_ptr; #[used(compiler)] static LINK_LIFECYCLE: unsafe extern "C" fn(usize, *const PerryPayloadFamily) -> i32 = js_perry_payload_link_lifecycle; @@ -178,8 +181,12 @@ mod keepalive { static LINK_CLOSE: unsafe extern "C" fn(usize, *const PerryPayloadFamily) -> i32 = js_perry_payload_link_close; #[used(compiler)] - static LINK_REOPEN: unsafe extern "C" fn(usize, *const PerryPayloadFamily, *mut c_void, usize) -> i32 = - js_perry_payload_link_reopen; + static LINK_REOPEN: unsafe extern "C" fn( + usize, + *const PerryPayloadFamily, + *mut c_void, + usize, + ) -> i32 = js_perry_payload_link_reopen; #[used(compiler)] static LINK_JS_STATE: unsafe extern "C" fn(usize, *const PerryPayloadFamily, i32) -> f64 = js_perry_payload_link_js_state; @@ -190,8 +197,12 @@ mod keepalive { static RECEIVER_LINK: unsafe extern "C" fn(f64, *const PerryPayloadFamily, *mut i32) -> usize = js_perry_payload_receiver_link; #[used(compiler)] - static PROTO_GETTER: unsafe extern "C" fn(*mut c_void, *const u8, usize, *const crate::closure::JsFunctionInfo) = - js_perry_payload_proto_getter; + static PROTO_GETTER: unsafe extern "C" fn( + *mut c_void, + *const u8, + usize, + *const crate::closure::JsFunctionInfo, + ) = js_perry_payload_proto_getter; #[used(compiler)] static PROTO_DATA: unsafe extern "C" fn(*mut c_void, *const u8, usize, f64, u32) = js_perry_payload_proto_data; @@ -443,8 +454,10 @@ pub unsafe extern "C" fn js_perry_payload_link_reopen( resource: *mut c_void, bytes: usize, ) -> i32 { - let (Ok(cell), Some(family)) = (link_family_cell(link, family_ptr), checked_family(family_ptr)) - else { + let (Ok(cell), Some(family)) = ( + link_family_cell(link, family_ptr), + checked_family(family_ptr), + ) else { return -1; }; let Some(owner) = np::link_event_owner(np::OwnerLink(link)) else { diff --git a/crates/perry-runtime/src/turnloop_net/abi.rs b/crates/perry-runtime/src/turnloop_net/abi.rs index 049d68138d..b69c4d6e23 100644 --- a/crates/perry-runtime/src/turnloop_net/abi.rs +++ b/crates/perry-runtime/src/turnloop_net/abi.rs @@ -1200,6 +1200,8 @@ pub unsafe extern "C" fn js_perry_net_link_snapshot_handle( ) -> i32 { match unsafe { transport::snapshot_handle(core.cast(), link_arg(link)) } { Some(snapshot) if !out.is_null() => { + // GC_STORE_AUDIT(POINTER_FREE): caller-owned opaque capability snapshot; + // numeric driver handle/generation/resolve ids, no GC pointers or JS values. unsafe { std::ptr::write(out.cast::(), snapshot) }; 1 } diff --git a/crates/perry-runtime/src/turnloop_net/transport.rs b/crates/perry-runtime/src/turnloop_net/transport.rs index cde31ca94b..29a5575997 100644 --- a/crates/perry-runtime/src/turnloop_net/transport.rs +++ b/crates/perry-runtime/src/turnloop_net/transport.rs @@ -1344,7 +1344,12 @@ pub unsafe fn pending_resolve_for_test(cell: usize) -> Option { /// As [`pending_resolve_for_test`]. #[cfg(any(test, feature = "native-payload-test-census"))] pub unsafe fn replay_resolve_for_test(cell: usize, op: OpId, addresses: Vec) { - dispatch_resolve(OwnerLink(cell), Some(op), false, OpResult::Resolved(addresses)); + dispatch_resolve( + OwnerLink(cell), + Some(op), + false, + OpResult::Resolved(addresses), + ); } /// Start the plan's next address, or report its final failure. diff --git a/scripts/check_ext_net_socket_open_invariant.py b/scripts/check_ext_net_socket_open_invariant.py index 25a6f7e601..f31c3dfd06 100755 --- a/scripts/check_ext_net_socket_open_invariant.py +++ b/scripts/check_ext_net_socket_open_invariant.py @@ -1,96 +1,21 @@ #!/usr/bin/env python3 -"""Enforce perry-ext-net's ``is_open`` / ``has_opened`` invariant (#11056). +"""Audit net.Socket's single payload opened field (#12064). -``SocketState.has_opened`` is monotonic history: every real transition to -``is_open = true`` must set ``has_opened = true`` on the same receiver in the -same Rust block. ``socket.pending`` depends on that history, so missing the -second assignment leaves an already-connected socket looking pending forever. - -There is one intentional exception today. The already-aborted TLS path marks -a synthetic socket live long enough to deliver deferred ``error`` / ``close`` -events, but that socket never opened. Exceptions live in -``scripts/ext_net_socket_open_exceptions.json`` and are identity-pinned by -path, function, and receiver. A new unpaired transition fails, and an -exception that stops matching an unpaired transition also fails so fixes must -delete their own exemption. - -This is a deliberately small Rust source audit rather than a parser. It masks -comments and literals, balances braces, enumerates every ``.is_open = true`` -transition and ``is_open: true`` initializer, and rejects receiver syntax it -cannot prove. ``MIN_OPEN_SITES`` and the self-test keep a broken matcher from -reporting an empty green census. - -Usage: - python3 scripts/check_ext_net_socket_open_invariant.py - python3 scripts/check_ext_net_socket_open_invariant.py --self-test - python3 scripts/check_ext_net_socket_open_invariant.py --list +Reject second open flags, JS-state open latches, and id-keyed map/set tables +anywhere in perry-ext-net. Comments and literals are masked for Rust syntax; +JS own-property writes are inspected separately. The payload field and its +three production transitions must exist, so an empty census cannot pass. """ - from __future__ import annotations - import argparse import json import re import sys -from dataclasses import dataclass from pathlib import Path -from typing import Any, Iterable - - ROOT = Path(__file__).resolve().parent.parent -SOURCE_ROOT = ROOT / "crates" / "perry-ext-net" / "src" -EXCEPTIONS_PATH = ROOT / "scripts" / "ext_net_socket_open_exceptions.json" -MIN_OPEN_SITES = 6 - -OPEN_ASSIGNMENT = re.compile(r"\.\s*is_open\s*=\s*true\b") -OPEN_INITIALIZER = re.compile(r"\bis_open\s*:\s*true\b") -OPENED_INITIALIZER = re.compile(r"\bhas_opened\s*:\s*true\b") -SIMPLE_RECEIVER = re.compile( - r"(?|=(?!=|>)|:(?!:)|\bin\b|\|)") -IF_KEYWORD = re.compile(r"\bif\b") - - -@dataclass(frozen=True) -class Site: - path: str - function: str - receiver: str | None - line: int - paired: bool - kind: str - - @property - def key(self) -> tuple[str, str, str] | None: - if self.kind != "assignment" or self.receiver is None: - return None - return (self.path, self.function, self.receiver) - - -@dataclass(frozen=True) -class ExceptionEntry: - name: str - path: str - function: str - receiver: str - reason: str - - @property - def key(self) -> tuple[str, str, str]: - return (self.path, self.function, self.receiver) - - def _blank(chars: list[str], start: int, end: int) -> None: """Replace a non-code range with spaces while preserving newlines.""" @@ -173,615 +98,79 @@ def mask_non_code(source: str) -> str: return "".join(chars) -def delimiter_pairs(code: str, opening: str, closing: str) -> dict[int, int]: - """Map OPENING delimiters to CLOSING delimiters or reject malformed input.""" - - stack: list[int] = [] - pairs: dict[int, int] = {} - for index, char in enumerate(code): - if char == opening: - stack.append(index) - elif char == closing: - if not stack: - raise ValueError(f"unmatched {closing!r} at byte {index}") - pairs[stack.pop()] = index - if stack: - raise ValueError(f"unmatched {opening!r} at byte {stack[-1]}") - return pairs - - -def brace_pairs(code: str) -> dict[int, int]: - """Map opening braces to closing braces, rejecting malformed input.""" - - return delimiter_pairs(code, "{", "}") - - -def function_spans(code: str, pairs: dict[int, int]) -> list[tuple[int, int, str]]: - """Return named function body spans found in masked Rust source.""" - - openings = sorted(pairs) - spans: list[tuple[int, int, str]] = [] - for match in FUNCTION_NAME.finditer(code): - opening = next((item for item in openings if item > match.end()), None) - if opening is None: - continue - semicolon = code.find(";", match.end(), opening) - if semicolon >= 0: - continue - spans.append((opening, pairs[opening], match.group(1))) - return spans - - -def containing_span( - position: int, spans: Iterable[tuple[int, int, str]] -) -> tuple[int, int, str] | None: - """Return the innermost named function span containing POSITION.""" - - matches = [span for span in spans if span[0] < position < span[1]] - return max(matches, key=lambda span: span[0], default=None) - - -def containing_block( - position: int, openings: Iterable[int], pairs: dict[int, int] -) -> tuple[int, int] | None: - """Return the innermost brace pair containing POSITION.""" - - blocks = [ - (opening, pairs[opening]) - for opening in openings - if opening < position < pairs[opening] - ] - return max(blocks, key=lambda block: block[0], default=None) - - -def has_direct_match( - pattern: re.Pattern[str], - code: str, - block: tuple[int, int], - openings: list[int], - pairs: dict[int, int], -) -> bool: - """Whether PATTERN matches directly in BLOCK, excluding nested blocks.""" - - return any( - containing_block(match.start(), openings, pairs) == block - for match in pattern.finditer(code, block[0] + 1, block[1]) - ) - - -def is_struct_pattern( - code: str, - block: tuple[int, int], - braces: dict[int, int], - parentheses: dict[int, int], - brackets: dict[int, int], -) -> bool: - """Whether BLOCK is nested in Rust pattern syntax rather than an expression.""" - - for macro in PATTERN_MACRO.finditer(code): - opening = code.find("(", macro.start(), macro.end()) - closing = parentheses.get(opening) - if closing is None or not (opening < block[0] < block[1] < closing): - continue - - outer = (opening, closing) - delimiters = (braces, parentheses, brackets) - - def is_direct(position: int) -> bool: - """Whether POSITION is directly inside the macro argument list.""" - - return not any( - inner_open < position < inner_close - for pairs in delimiters - for inner_open, inner_close in pairs.items() - if (inner_open, inner_close) != outer - and opening < inner_open < inner_close < closing - ) - - commas = [ - position - for position in range(opening + 1, closing) - if code[position] == "," and is_direct(position) - ] - if not commas or block[0] < commas[0]: - continue - if len(commas) > 1 and block[0] > commas[1]: - continue - - guard = next( - ( - match.start() - for match in IF_KEYWORD.finditer(code, commas[0] + 1, block[0]) - if is_direct(match.start()) - ), - None, - ) - if guard is None: - return True - - containers = [block] - for pairs in (braces, parentheses, brackets): - containers.extend( - (opening, closing) - for opening, closing in pairs.items() - if opening < block[0] < block[1] < closing - ) - for _, closing in containers: - tail = code[closing + 1 :].lstrip() - if PATTERN_FOLLOW.match(tail): - return True - return False - - -def receiver_pattern(receiver: str, field: str) -> re.Pattern[str]: - """Build an assignment pattern for FIELD on a proven simple RECEIVER.""" - - pieces = [re.escape(piece) for piece in receiver.split(".")] - receiver_expr = r"\s*\.\s*".join(pieces) - return re.compile( - rf"(? list[Site]: - """Enumerate open transitions and already-open initializers in SOURCE.""" - - code = mask_non_code(source) - pairs = brace_pairs(code) - parentheses = delimiter_pairs(code, "(", ")") - brackets = delimiter_pairs(code, "[", "]") - spans = function_spans(code, pairs) - openings = sorted(pairs) - sites: list[Site] = [] - - for assignment in OPEN_ASSIGNMENT.finditer(code): - line = source.count("\n", 0, assignment.start()) + 1 - function = containing_span(assignment.start(), spans) - function_name = function[2] if function else "" - - receiver_match = SIMPLE_RECEIVER.search(code[: assignment.start()]) - receiver = None - if receiver_match: - receiver = re.sub(r"\s+", "", receiver_match.group(1)) - - block = containing_block(assignment.start(), openings, pairs) - paired = False - if receiver is not None and block is not None: - paired = has_direct_match( - receiver_pattern(receiver, "has_opened"), - code, - block, - openings, - pairs, - ) - - sites.append( - Site( - path=path, - function=function_name, - receiver=receiver, - line=line, - paired=paired, - kind="assignment", - ) - ) - - for initializer in OPEN_INITIALIZER.finditer(code): - line = source.count("\n", 0, initializer.start()) + 1 - function = containing_span(initializer.start(), spans) - function_name = function[2] if function else "" - block = containing_block(initializer.start(), openings, pairs) - if block is not None and is_struct_pattern( - code, block, pairs, parentheses, brackets - ): - continue - receiver = None - if block is not None and SOCKET_STATE_HEAD.search(code[: block[0]]): - receiver = "SocketState" - paired = bool( - receiver is not None - and block is not None - and has_direct_match( - OPENED_INITIALIZER, code, block, openings, pairs - ) - ) - sites.append( - Site( - path=path, - function=function_name, - receiver=receiver, - line=line, - paired=paired, - kind="initializer", - ) - ) - return sites - - -def parse_exceptions(data: Any) -> tuple[list[ExceptionEntry], list[str]]: - """Parse and validate the identity-pinned exception registry.""" - - errors: list[str] = [] - if not isinstance(data, dict) or data.get("schema_version") != 1: - return [], ["exception registry must be an object with schema_version 1"] - raw_entries = data.get("exceptions") - if not isinstance(raw_entries, list): - return [], ["exception registry field 'exceptions' must be a list"] - - entries: list[ExceptionEntry] = [] - required = ("name", "path", "function", "receiver", "reason") - for index, raw in enumerate(raw_entries): - if not isinstance(raw, dict): - errors.append(f"exception {index} must be an object") - continue - missing = [ - key - for key in required - if not isinstance(raw.get(key), str) or not raw[key].strip() - ] - if missing: - errors.append(f"exception {index} has missing/empty fields: {', '.join(missing)}") - continue - if len(raw["reason"].strip()) < 20: - errors.append( - f"exception {raw['name']!r} needs a specific reason of at least 20 characters" - ) - continue - entries.append( - ExceptionEntry(**{key: raw[key].strip() for key in required}) - ) - - names: set[str] = set() - keys: set[tuple[str, str, str]] = set() - for entry in entries: - if entry.name in names: - errors.append(f"duplicate exception name: {entry.name}") - names.add(entry.name) - if entry.key in keys: - errors.append( - "duplicate exception identity: " + "::".join(entry.key) - ) - keys.add(entry.key) - return entries, errors - - -def evaluate( - sources: dict[str, str], - registry: Any, - *, - min_open_sites: int = MIN_OPEN_SITES, -) -> tuple[list[Site], list[str]]: - """Audit SOURCES against REGISTRY and return sites plus violations.""" - - entries, errors = parse_exceptions(registry) - sites: list[Site] = [] - for path, source in sorted(sources.items()): - try: - sites.extend(scan_source(path, source)) - except ValueError as error: - errors.append(f"{path}: source scan failed: {error}") - - if len(sites) < min_open_sites: - errors.append( - f"matched only {len(sites)} is_open=true site(s), below the " - f"coverage floor of {min_open_sites}; the scanner or source population changed" - ) - - unpaired_by_key: dict[tuple[str, str, str], list[Site]] = {} - for site in sites: - if site.kind == "initializer": - if site.receiver is None: - errors.append( - f"{site.path}:{site.line}: is_open:true appears in an " - "initializer the checker cannot prove is SocketState" - ) - elif not site.paired: - errors.append( - f"{site.path}:{site.line}: SocketState initializer sets " - "is_open:true without has_opened:true directly in the same block" - ) - continue - if site.receiver is None: - errors.append( - f"{site.path}:{site.line}: is_open=true uses receiver syntax the " - "checker cannot prove; keep the receiver as a simple identifier/field path" - ) - elif not site.paired: - assert site.key is not None - unpaired_by_key.setdefault(site.key, []).append(site) - - entries_by_key = {entry.key: entry for entry in entries} - for key, matches in sorted(unpaired_by_key.items()): - if len(matches) > 1: - locations = ", ".join(str(site.line) for site in matches) - errors.append( - f"{'::'.join(key)}: exception identity is ambiguous across lines " - f"{locations}; each intentional transition needs a unique function/receiver" - ) - continue - if key not in entries_by_key: - site = matches[0] - errors.append( - f"{site.path}:{site.line}: {site.receiver}.is_open=true in " - f"{site.function} does not set {site.receiver}.has_opened=true in " - "the same block and has no documented exception" - ) - - for entry in entries: - matches = unpaired_by_key.get(entry.key, []) - if len(matches) != 1: - errors.append( - f"exception {entry.name!r} ({'::'.join(entry.key)}) matches " - f"{len(matches)} unpaired transition(s); delete stale exceptions and " - "give each live exception a unique identity" - ) +OPEN_NAME = re.compile(r"open", re.I) +FIELD = re.compile(r"\b(\w+)\s*:\s*(?:bool|AtomicBool|Cell\s*<\s*bool\s*>)\b") +TABLE = re.compile(r"\b(?:HashMap|BTreeMap|DashMap|IndexMap|HashSet|BTreeSet|Slab)\b") +CANONICAL = ("crates/perry-ext-net/src/payload_transport.rs", "opened") + +def evaluate(sources, registry): + errors, sites, used = [], [], set() + exceptions = {(e["path"], e["name"]): e for e in registry["exceptions"]} + canonical = 0 + transitions = 0 + for path, source in sources.items(): + code = mask_non_code(source) + for match in FIELD.finditer(code): + name = match[1] + if not OPEN_NAME.search(name): + continue + key = path, name + sites.append(f"{path}:{code[:match.start()].count(chr(10))+1}: {name}") + if key == CANONICAL: + canonical += 1 + elif key in exceptions and len(exceptions[key].get("reason", "")) >= 40: + used.add(key) + else: + errors.append(f"{path}: second open flag {name}") + if TABLE.search(code): + errors.append(f"{path}: id-keyed map/set table in payload-only net crate") + for match in re.finditer(r"\.\s*(\w*open\w*)\s*=(?!=)", code, re.I): + if match[1] not in {"opened", "allow_half_open"}: + errors.append(f"{path}: second open assignment {match[1]}") + if match[1] == "opened" and re.match(r"\s*true\b", code[match.end():]): + transitions += 1 + # A JS-visible hidden latch is still duplicate state. Scan property + # writes using original literal keys, after removing comments. + for match in re.finditer(r'\bown_set\s*\([^;]*?"([^"\n]*open[^"\n]*)"', source, re.I): + if match[1] != "allowHalfOpen": + errors.append(f"{path}: duplicate JS open property {match[1]}") + if canonical != 1: + errors.append(f"expected exactly one SocketFields.opened declaration, found {canonical}") + if transitions < 3: + errors.append(f"opened transition census {transitions} below three production transitions") + for key in exceptions.keys() - used: + errors.append(f"stale exception: {key}") return sites, errors - -def load_sources() -> dict[str, str]: - """Load every Rust source under perry-ext-net using repository paths.""" - - return { - path.relative_to(ROOT).as_posix(): path.read_text(encoding="utf-8") - for path in sorted(SOURCE_ROOT.rglob("*.rs")) - } - - -def load_registry() -> Any: - """Load the checked-in JSON exception registry.""" - - return json.loads(EXCEPTIONS_PATH.read_text(encoding="utf-8")) - - -def self_test() -> None: - """Exercise positive and negative checker shapes without repository mutation.""" - - path = "crates/perry-ext-net/src/example.rs" - exception = { - "name": "synthetic-open", - "path": path, - "function": "open", - "receiver": "socket", - "reason": "Synthetic socket stays live only to deliver a deferred close event.", - } - - paired = """ -fn open() { - if ready { - socket.is_open = true; - socket.has_opened = true; - } -} -""" - sites, errors = evaluate( - {path: paired}, {"schema_version": 1, "exceptions": []}, min_open_sites=1 - ) - assert not errors and len(sites) == 1 and sites[0].paired - - missing = paired.replace(" socket.has_opened = true;\n", "") - _, errors = evaluate( - {path: missing}, {"schema_version": 1, "exceptions": []}, min_open_sites=1 - ) - assert any("no documented exception" in error for error in errors) - - _, errors = evaluate( - {path: missing}, - {"schema_version": 1, "exceptions": [exception]}, - min_open_sites=1, - ) - assert not errors - - _, errors = evaluate( - {path: paired}, - {"schema_version": 1, "exceptions": [exception]}, - min_open_sites=1, - ) - assert any("matches 0 unpaired" in error for error in errors) - - sibling = """ -fn open() { - if ready { socket.is_open = true; } - if history { socket.has_opened = true; } -} -""" - _, errors = evaluate( - {path: sibling}, {"schema_version": 1, "exceptions": []}, min_open_sites=1 - ) - assert any("same block" in error for error in errors) - - nested = """ -fn open() { - socket.is_open = true; - if history { socket.has_opened = true; } -} -""" - _, errors = evaluate( - {path: nested}, {"schema_version": 1, "exceptions": []}, min_open_sites=1 - ) - assert any("same block" in error for error in errors) - - masked = r''' -fn harmless() { - // socket.is_open = true; - let a = "socket.is_open = true; {"; - let b = r#"socket.is_open = true; }"#; - let c = '{'; - let d = "a valid Rust string may contain -socket.is_open = true; and is_open: true {"; - /* nested /* socket.is_open = true; */ comment */ -} -''' - sites, errors = evaluate( - {path: masked}, {"schema_version": 1, "exceptions": []}, min_open_sites=0 - ) - assert not sites and not errors - - complex_receiver = "fn open() { (*socket).is_open = true; }" - _, errors = evaluate( - {path: complex_receiver}, - {"schema_version": 1, "exceptions": []}, - min_open_sites=1, - ) - assert any("receiver syntax" in error for error in errors) - - paired_initializer = """ -fn build() -> SocketState { - SocketState { - is_open: true, - has_opened: true, - } -} -""" - sites, errors = evaluate( - {path: paired_initializer}, - {"schema_version": 1, "exceptions": []}, - min_open_sites=1, - ) - assert not errors and len(sites) == 1 and sites[0].paired - assert sites[0].kind == "initializer" - - missing_initializer = paired_initializer.replace( - " has_opened: true,\n", "" - ) - _, errors = evaluate( - {path: missing_initializer}, - {"schema_version": 1, "exceptions": []}, - min_open_sites=1, - ) - assert any("SocketState initializer" in error for error in errors) - - unknown_initializer = paired_initializer.replace("SocketState {", "OtherState {") - _, errors = evaluate( - {path: unknown_initializer}, - {"schema_version": 1, "exceptions": []}, - min_open_sites=1, - ) - assert any("cannot prove is SocketState" in error for error in errors) - - struct_patterns = """ -fn inspect(socket: SocketState, pair: (SocketState, bool)) { - let _ = matches!(socket, SocketState { is_open: true, .. }); - let _ = match socket { - SocketState { is_open: true, .. } => true, - _ => false, - }; - let SocketState { is_open: true, .. } = socket; - let (SocketState { is_open: true, .. }, _) = pair; -} -""" - sites, errors = evaluate( - {path: struct_patterns}, - {"schema_version": 1, "exceptions": []}, - min_open_sites=0, - ) - assert not sites and not errors - - matches_expression = """ -fn inspect() { - let _ = matches!( - SocketState { is_open: true, has_opened: true }, - SocketState { is_open: true, .. } - ); -} -""" - sites, errors = evaluate( - {path: matches_expression}, - {"schema_version": 1, "exceptions": []}, - min_open_sites=1, - ) - assert not errors and len(sites) == 1 and sites[0].paired - - missing_matches_expression = matches_expression.replace( - ", has_opened: true", "" - ) - _, errors = evaluate( - {path: missing_matches_expression}, - {"schema_version": 1, "exceptions": []}, - min_open_sites=1, - ) - assert any("SocketState initializer" in error for error in errors) - - complex_field_receiver = """ -fn open() { - factory().socket.is_open = true; - factory().socket.has_opened = true; -} -""" - _, errors = evaluate( - {path: complex_field_receiver}, - {"schema_version": 1, "exceptions": []}, - min_open_sites=1, - ) - assert any("receiver syntax" in error for error in errors) - - ambiguous = """ -fn open() { - if first { socket.is_open = true; } - if second { socket.is_open = true; } -} -""" - _, errors = evaluate( - {path: ambiguous}, - {"schema_version": 1, "exceptions": [exception]}, - min_open_sites=2, - ) - assert any("ambiguous" in error for error in errors) - - bad_reason = dict(exception, reason="too short") - _, errors = evaluate( - {path: missing}, - {"schema_version": 1, "exceptions": [bad_reason]}, - min_open_sites=1, - ) - assert any("specific reason" in error for error in errors) - - print( - "check_ext_net_socket_open_invariant self-test: OK " - "(pair, missing, exception, stale, scope, nesting, masking, syntax, " - "initializer, struct patterns, ambiguity)" - ) - - -def main() -> int: - """Run the requested self-test, listing, or repository audit mode.""" - +def self_test(): + good = {CANONICAL[0]: "struct SocketFields { opened: bool } fn connected() { s.opened = true; s.opened = true; s.opened = true; }"} + registry = {"exceptions": []} + assert not evaluate(good, registry)[1] + for bad in ["struct Second { is_open: bool }", "struct Second { has_opened: bool }", "static OPEN: AtomicBool = AtomicBool::new(false);", "static FLAGS: HashMap = todo!();", 'fn f() { p::own_set(owner, "bunOpened", true); }']: + assert evaluate(dict(good, extra=bad), registry)[1], bad + assert evaluate({}, registry)[1] + assert evaluate(good, {"exceptions": [{"path": "gone", "name": "open", "reason": "x"*50}]})[1] + assert not evaluate(dict(good, extra='// is_open: bool\n/* HashMap */ fn f() { let s = "has_opened: bool"; }'), registry)[1] + print("socket-open self-test: OK (duplicate fields, atomic flag, table, JS latch, empty census, stale exception, masking)") + +def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--self-test", action="store_true") parser.add_argument("--list", action="store_true") args = parser.parse_args() - if args.self_test: self_test() return 0 - - try: - sites, errors = evaluate(load_sources(), load_registry()) - except (OSError, json.JSONDecodeError) as error: - print(f"check_ext_net_socket_open_invariant: {error}", file=sys.stderr) - return 2 - + sources = {str(p.relative_to(ROOT)): p.read_text() for p in (ROOT / "crates/perry-ext-net/src").rglob("*.rs")} + sites, errors = evaluate(sources, json.loads(EXCEPTIONS_PATH.read_text())) if args.list: - for site in sites: - receiver = site.receiver or "" - status = "paired" if site.paired else "exception candidate" - print(f"{site.path}:{site.line} {site.function} {receiver} {status}") - + print("\n".join(sites)) if errors: - print("check_ext_net_socket_open_invariant FAILED:", file=sys.stderr) - for error in errors: - print(f" - {error}", file=sys.stderr) + print("\n".join(errors), file=sys.stderr) return 1 - - paired = sum(site.paired for site in sites) - print( - "check_ext_net_socket_open_invariant: OK — " - f"{len(sites)} is_open=true site(s), {paired} paired, " - f"{len(sites) - paired} documented exception(s)" - ) + print("socket-open invariant: OK; one payload opened field, no second flag or id-keyed open table") return 0 - if __name__ == "__main__": raise SystemExit(main()) diff --git a/scripts/ext_net_socket_open_exceptions.json b/scripts/ext_net_socket_open_exceptions.json index 86af515600..cc621d1efc 100644 --- a/scripts/ext_net_socket_open_exceptions.json +++ b/scripts/ext_net_socket_open_exceptions.json @@ -1,19 +1,10 @@ { - "schema_version": 1, + "schema_version": 2, "exceptions": [ { - "name": "synthetic-already-aborted-tls-connect", - "path": "crates/perry-ext-net/src/tls.rs", - "function": "schedule_tls_abort", - "receiver": "socket", - "reason": "An already-aborted TLS connect never opens. is_open keeps its synthetic SocketState live only until deferred error and close events are delivered; has_opened must remain false so socket.pending stays true." - }, - { - "name": "turnloop-on-connect-deferred-has-opened", - "path": "crates/perry-ext-net/src/turnloop_io.rs", - "function": "on_connect", - "receiver": "s", - "reason": "on_connect sets is_open with the endpoint addresses, then may start a direct-TLS upgrade that can fail and destroy the socket before 'connect' is emitted. has_opened/connecting are therefore set in a second lock block just before PendingNetEvent::Connect (see the #10465 comment there), so a socket destroyed by a failed upgrade is never recorded as opened." + "path": "crates/perry-ext-net/src/payload_transport.rs", + "name": "allow_half_open", + "reason": "Node socket option controlling whether peer EOF closes the writable side. It is configuration, never evidence that the connection has opened." } ] } diff --git a/scripts/gc_runtime_root_holders.json b/scripts/gc_runtime_root_holders.json index 1fc1719656..d0e2d4e031 100644 --- a/scripts/gc_runtime_root_holders.json +++ b/scripts/gc_runtime_root_holders.json @@ -45,12 +45,6 @@ "verdict": "not_a_gc_pointer", "why": "Client-side pending-event queue. Every variant carries a perry-ffi registry Handle, strings, Bytes, or an errno i64 (rule S fired on TransportError.errno) \u2014 no NaN-boxed value. The closures the drain fires live in ClientRequestHandle (response_callback/end_callback/pending_write_callbacks/listeners), which scan_http_roots visits." }, - { - "file": "crates/perry-ext-http/src/server/server.rs", - "name": "PENDING_CONNECTION_EVENTS", - "verdict": "not_a_gc_pointer", - "why": "Vec of (server_handle, socket_handle) registry-id pairs drained by js_node_http_server_process_pending to fire 'connection' listeners; socket_handle is a connection-socket IncomingMessage handle (alloc_connection_socket). Both are perry-ffi registry ids, not NaN-boxed values \u2014 the listeners themselves are in HttpServer.listeners, scanned by scan_http_server_roots, and the socket's own listeners/signal fields are scanned via iter_handles_of_mut::." - }, { "file": "crates/perry-ext-http/src/server/server.rs", "name": "TURNLOOP_UPGRADES", @@ -63,61 +57,6 @@ "verdict": "not_a_gc_pointer", "why": "InFlightRequest = three perry-ffi registry handle ids + a deadline Instant. The JS-value-bearing objects behind the ids (IncomingMessage/ServerResponse) are registered handles scanned by scan_http_server_roots via iter_handles_of_mut." }, - { - "file": "crates/perry-ext-http/src/server/turnloop_serve/conn.rs", - "name": "CLOSED", - "verdict": "not_a_gc_pointer", - "why": "Vec of connection-socket handle ids (alloc_connection_socket) whose TCP connection fully closed, queued by note_closed_socket/on_closed and drained by the pump \u2014 same shape and same file as the pre-existing ABORTED/aborted() request-handle queue this crate already uses for 'aborted'. A registry id, not a NaN-boxed value: the socket's own listeners/signal fields are scanned via iter_handles_of_mut:: in server/mod.rs's scan_http_server_roots." - }, - { - "file": "crates/perry-ext-net/src/bun_tcp.rs", - "name": "NEXT_TOKEN", - "verdict": "not_a_gc_pointer", - "why": "Monotonic AtomicU64 that mints write-token ids for TOKENS. A counter: incremented and used as a map key, never dereferenced and never derived from an address." - }, - { - "file": "crates/perry-ext-net/src/bun_tcp.rs", - "name": "TOKENS", - "verdict": "not_a_gc_pointer", - "why": "write_tokens(): HashMap. Each i64 is the same handle-band id ABORTS holds \u2014 the key used to look the socket up in crate::statics::sockets(), never a heap address. Entries are removed on write completion (bun_tcp.rs) and retained-out when a handle closes, so no id outlives its facade." - }, - { - "file": "crates/perry-ext-net/src/server_state.rs", - "name": "STATE", - "verdict": "not_a_gc_pointer", - "why": "ConnectionOrderState = socket-id VecDeques, per-server usize credits, and buffered Bytes chunks keyed by socket id. Pure ids and bytes; no NaN-boxed values." - }, - { - "file": "crates/perry-ext-net/src/socket_events.rs", - "name": "SCRATCH", - "verdict": "not_a_gc_pointer", - "why": "Drain-side reusable Vec inside js_ext_net_drain_pending; same payload as P (ids/Bytes/strings), emptied within the drain call. No JS values." - }, - { - "file": "crates/perry-ext-net/src/tls.rs", - "name": "ABORTS", - "verdict": "not_a_gc_pointer", - "why": "HashSet of pending TLS abort handles. Each i64 is a socket HANDLE -- the key used to look the socket up in crate::statics::sockets() and the payload of PendingNetEvent::AbortError/Close -- i.e. an index into the handle side table, stable across collection, not a heap address. The set holds no other field, so there is nothing for a scanner to visit." - }, - { - "file": "crates/perry-ext-net/src/turnloop_io.rs", - "name": "AUX", - "verdict": "not_a_gc_pointer", - "why": "HashMap holding the per-socket state the turnloop transport needs and SocketState has no field for: whether the peer's FIN arrived before the 'connection' callback, whether the post-EOF shutdown should be followed by a close, and whether an 'error' and a 'close' have been emitted -- plus the server_state::begin_local_connect reservation, a server HANDLE and a bool; and, since P5, whether the write side was already ended, whether the readable EOF was already delivered, and a pending `tls.connect` request (a servername String, a bool and a TlsClientConfigData of owned PEM/DER byte vectors). Rule S fires on those i64s; every one is a handle-band id used to look a record up in crate::statics::sockets() or servers(), never a heap address and never dereferenced. No JS value reaches this map: read bytes are copied into a Bytes before the sink returns and write bytes were already owned Vecs. forget_aux removes the entry on the terminal close." - }, - { - "file": "crates/perry-ext-net/src/turnloop_io.rs", - "name": "DEADLINES", - "verdict": "not_a_gc_pointer", - "why": "deadlines(): HashMap. The key is a handle-band id reserved for one turnloop timer and freed when it fires; each Deadline variant carries only i64 server/socket HANDLES (a deferred loopback ServerConnection, or an already-aborted tls.connect). Rule S fires on those i64s; every one is the key used to look a record up in crate::statics::sockets() or servers() and the payload of a PendingNetEvent, never a heap address and never dereferenced. No JS value reaches this map. It replaced two tokio sleeps (server_state::schedule_server_connection, tls::schedule_tls_abort); on_timer removes the entry when the deadline fires." - }, - { - "file": "crates/perry-ext-net/src/turnloop_tls_io.rs", - "name": "LAYERS", - "verdict": "covered_elsewhere", - "scanner": "perry-runtime's native-async completion registry (promise::native_async::scan_native_async_completion_roots_mut), armed by perry_ffi_native_async_new before the token is stored", - "why": "turnloop P5. A `Layer` is owned `Vec` buffers plus ONE JS-visible thing: the `JsNativeAsyncCompletion` token for a pending `socket.upgradeToTLS()` promise. That is deliberately a native-async token and not a bare `*mut Promise` cached here \u2014 the runtime creates such a promise cross-thread-pinned and root-scans it until settlement (#9552), so the pointer in this table is one the collector already knows about. Settling or dropping the layer releases it." - }, { "file": "crates/perry-ext-parcel-watcher/src/lib.rs", "name": "PENDING", @@ -130,12 +69,6 @@ "verdict": "not_a_gc_pointer", "why": "HashSet of perry-ffi registry handle ids already found stale, kept only to log each once. Registry ids are indices into the ffi DashMap, not heap addresses." }, - { - "file": "crates/perry-ext-ws/src/lib.rs", - "name": "WS_CONNECTIONS", - "verdict": "not_a_gc_pointer", - "why": "Per-connection PROTOCOL state, keyed by the crate's own ws id. A `WsConnection` is `transport: WsTransport`, `messages: Vec` and three bools. Rule S fires on `WsTransport::Turnloop(i64)`; that i64 is the HOST's connection id, not an address \u2014 it is minted by the turnloop client adoption path (`attach_turnloop_client`) and its only use is as the first argument of `replay_command(conn_id, ..)`, which looks the connection up in the host registry. It is never cast to a pointer and never dereferenced. The other variant, `Connecting(Vec)`, holds `Send(WsOutgoing)` / `Close(Option, String)` / `Terminate`, and `WsOutgoing` is `Text(String)` / `Binary|Ping|Pong(Vec)` \u2014 Rust-owned bytes throughout, as is `WsPayload`. No NaN-boxed value and no heap pointer enters this map. The JS callbacks for these connections live in `WS_CLIENT_LISTENERS` (`HashMap>` of closure pointers), which is exactly what the registered `scan_ws_roots` visits \u2014 the split is deliberate. Newly uncovered because turnloop replaced the transport variant that held a tokio task's command channel with this bare id, which is what made rule S look at it." - }, { "file": "crates/perry-runtime/src/alloc_census.rs", "name": "CREDIT", @@ -983,12 +916,6 @@ "verdict": "test_only", "why": "#[cfg(test)] diagnostic trace for the bound-method moving-GC regression: records the (before, after) addresses a test-forced minor produced so the test can assert the relocation happened. The addresses are compared as integers, never dereferenced, and the cell is dead in a shipped binary." }, - { - "file": "crates/perry-runtime/src/object/native_this_alias.rs", - "name": "IN_SERVER_RESPONSE_FORWARD", - "verdict": "not_a_gc_pointer", - "why": "Per-thread Cell re-entrancy latch for the ServerResponse.prototype method thunk (#10454). Only false/true are stored; the receiver lives in the caller's frame and its aliased handle id in its own ObjectMeta.native_state word." - }, { "file": "crates/perry-runtime/src/object/null_stub.rs", "name": "NULL_STUB_SLOT", @@ -2925,16 +2852,10 @@ "why": "Numeric diagnostic count of block-persistence forced marks. It holds no pointer; B4 scanner deletions exposed incidental prior call-graph coverage." }, { - "file": "crates/perry-runtime/src/json_tape_store.rs", - "name": "TAPE_REGISTRY", - "verdict": "not_a_gc_pointer", - "why": "Shutdown inventory from a nonmoving old LazyArrayHeader address to a uniquely owned, immutable native TapeEntry allocation. TapeEntry is pointer-free offset/kind/link data; keys deliberately do not keep headers alive. LazyArrayTape finalization, materialization and thread teardown release the native allocation. No GC edge needs tracing; B4 scanner deletions exposed incidental prior call-graph coverage." - }, - { - "file": "crates/perry-runtime/src/json_tape_store.rs", - "name": "TAPE_REGISTRY_NONEMPTY", + "file": "crates/perry-ext-http/src/client_turnloop/mod.rs", + "name": "DEFERRED_FIRED", "verdict": "not_a_gc_pointer", - "why": "Boolean indicating whether a native JSON tape side allocation inventory has entries. It holds no heap pointer; B4 scanner deletions exposed incidental prior call-graph coverage." + "why": "Monotonic number of fired deferred HTTP client callbacks. fetch_add(1) in the drain and load in deferred_fired; never stores an address or JS value." } ], "_FRONTIER_README": "Identity-pinned debt ratchet over new perry-ui* candidates and otherwise-unclassified core raw/Perry TLS declarations (see the census docstring, \u201cThe identity-pinned frontier\u201d). A new uncovered holder fails until it is scanned, receives a researched holders verdict, or is deliberately pinned as debt. Moving a researched false positive to holders graduates it from this list. A fixed or classified holder makes its old frontier pin stale, so the receipt must be deleted.", diff --git a/scripts/registry_lifetime_allowlist.json b/scripts/registry_lifetime_allowlist.json index 8d45cacfe3..39d5f1dda2 100644 --- a/scripts/registry_lifetime_allowlist.json +++ b/scripts/registry_lifetime_allowlist.json @@ -6,12 +6,6 @@ "`open_leak` entries are real unbounded tables, named so the debt is visible; each is a follow-up to fix." ], "registries": [ - { - "file": "crates/perry-ext-http/src/client_request_surface.rs", - "name": "CLIENT_REQUEST_SURFACE", - "verdict": "open_leak", - "why": "Keyed by ClientRequest handle, one entry per outgoing request via with_state_mut's entry().or_default(); destroy() only flips `destroyed`, nothing removes the key" - }, { "file": "crates/perry-ext-http/src/client_turnloop/tls.rs", "name": "CONFIGS", @@ -33,20 +27,6 @@ "verdict": "open_leak", "why": "Keyed by runtime server handle; take_pending pops the deque but never removes the key, leaving one entry per created server after close" }, - { - "file": "crates/perry-ext-net/src/classes.rs", - "name": "MAP", - "fn": "block_lists", - "verdict": "open_leak", - "why": "Keyed by per-instance id from js_net_block_list_new: one entry per new BlockList(), and no production path removes it" - }, - { - "file": "crates/perry-ext-net/src/classes.rs", - "name": "MAP", - "fn": "socket_addresses", - "verdict": "open_leak", - "why": "Keyed by per-instance handle: one SocketAddressState per new SocketAddress, production only inserts/reads" - }, { "file": "crates/perry-ext-streams/src/lib.rs", "name": "TRANSFORM_PAIRS", diff --git a/scripts/registry_lifetime_check.py b/scripts/registry_lifetime_check.py index 40b6a96cda..1f24ffa578 100644 --- a/scripts/registry_lifetime_check.py +++ b/scripts/registry_lifetime_check.py @@ -151,8 +151,11 @@ ) # Floors: "the extraction still works" assertions, not budgets. -MIN_REGISTRIES = 250 -MIN_REMOVING = 170 +# Phase A deletes 24 net/HTTP/TLS maps without adding any (main 249 -> 225). +# Keep the parser liveness floor below that new, audited population. +MIN_REGISTRIES = 220 +# Phase A removes 21 maps previously counted as shrinking (167 -> 146). +MIN_REMOVING = 140 MIN_WHY = 20 diff --git a/scripts/thread_exit_address_globals.json b/scripts/thread_exit_address_globals.json index eb93d25dac..d4a6d59186 100644 --- a/scripts/thread_exit_address_globals.json +++ b/scripts/thread_exit_address_globals.json @@ -2902,15 +2902,6 @@ "verdict": "no_heap_address", "why": "Code address of the platform UI ForEach render handler, stored by js_register_foreach_render_handler." }, - { - "file": "crates/perry-runtime/src/tls.rs", - "names": [ - "TLS_CLIENT_METADATA" - ], - "verdict": "thread_exit_invalidated", - "why": "tls.connect records keep the raw checkServerIdentity closure address and are never removed on close; records whose closure is freed are dropped at thread exit.", - "hook": "release_tls_client_metadata_in_freed_ranges" - }, { "file": "crates/perry-runtime/src/tls.rs", "names": [ @@ -3876,6 +3867,14 @@ ], "verdict": "no_heap_address", "why": "Monotonic scalar ClassDefinitionEvaluation identity, initialized to 1. Its only update is checked integer increment via AtomicU64::try_update, rejecting u64::MAX (the template namespace); zero is reserved for intrinsic private names. The returned integer is copied to class metadata native_state and used to qualify private storage keys. No pointer is stored in this atomic, and it is never dereferenced. Checked exhaustion replaces fetch_add, so the mechanical counter-only recognizer no longer admits it." + }, + { + "file": "crates/perry-runtime/src/agent.rs", + "names": [ + "SEEN" + ], + "verdict": "no_heap_address", + "why": "Test-only observed AgentId from the retire hook; AgentId is a monotonic numeric agent identity, never a heap address. per_test_global isolates the assertion on the retiring worker thread." } ] } diff --git a/scripts/unrooted_local_shape_baseline.json b/scripts/unrooted_local_shape_baseline.json index c9a37bb754..52f9df3a46 100644 --- a/scripts/unrooted_local_shape_baseline.json +++ b/scripts/unrooted_local_shape_baseline.json @@ -6,11 +6,9 @@ "crates/perry-ext-http/src/client_request_surface.rs": 2, "crates/perry-ext-http/src/server/handle_dispatch.rs": 2, "crates/perry-ext-http/src/server/request.rs": 7, - "crates/perry-ext-http/src/server/response.rs": 1, "crates/perry-ext-http/src/server/types.rs": 1, "crates/perry-ext-net/src/classes.rs": 2, "crates/perry-ext-net/src/jsvalue.rs": 1, - "crates/perry-ext-net/src/lifecycle.rs": 1, "crates/perry-ext-streams/src/lib.rs": 4, "crates/perry-ext-ws/src/server.rs": 3, "crates/perry-stdlib/src/cheerio.rs": 6, @@ -48,5 +46,5 @@ "crates/perry-stdlib/src/worker_threads/worker_surface.rs": 6 }, "schema_version": 3, - "total": 315 + "total": 313 } From 4d1e84084d14d788a30e64a71846a6dab97d4b93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 9 Oct 2026 05:19:09 +0200 Subject: [PATCH 06/18] test(net): audit numeric open flags and ignore commented JS setters --- scripts/check_ext_net_socket_open_invariant.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/scripts/check_ext_net_socket_open_invariant.py b/scripts/check_ext_net_socket_open_invariant.py index f31c3dfd06..c25098fd7a 100755 --- a/scripts/check_ext_net_socket_open_invariant.py +++ b/scripts/check_ext_net_socket_open_invariant.py @@ -16,6 +16,8 @@ EXCEPTIONS_PATH = ROOT / "scripts/ext_net_socket_open_exceptions.json" CHAR_LITERAL = re.compile(r"'(?:\\(?:x[0-9A-Fa-f]{2}|u\{[0-9A-Fa-f_]+\}|.)|[^'\\\n])'") RAW_STRING_START = re.compile(r'(?:b|c)?r(#{0,255})"') + + def _blank(chars: list[str], start: int, end: int) -> None: """Replace a non-code range with spaces while preserving newlines.""" @@ -24,7 +26,7 @@ def _blank(chars: list[str], start: int, end: int) -> None: chars[index] = " " -def mask_non_code(source: str) -> str: +def mask_non_code(source: str, keep_strings: bool = False) -> str: """Mask Rust comments and literals without changing byte positions.""" chars = list(source) @@ -64,7 +66,8 @@ def mask_non_code(source: str) -> str: if end_marker < 0: raise ValueError("unterminated raw string") end = end_marker + len(delimiter) - _blank(chars, index, end) + if not keep_strings: + _blank(chars, index, end) index = end continue @@ -83,7 +86,8 @@ def mask_non_code(source: str) -> str: end += 1 else: raise ValueError("unterminated string literal") - _blank(chars, index, end) + if not keep_strings: + _blank(chars, index, end) index = end continue @@ -100,7 +104,7 @@ def mask_non_code(source: str) -> str: OPEN_NAME = re.compile(r"open", re.I) -FIELD = re.compile(r"\b(\w+)\s*:\s*(?:bool|AtomicBool|Cell\s*<\s*bool\s*>)\b") +FIELD = re.compile(r"\b(\w+)\s*:\s*(?:bool|Atomic\w+|Cell|Option|u8|u32|u64|usize|i32|i64)\b") TABLE = re.compile(r"\b(?:HashMap|BTreeMap|DashMap|IndexMap|HashSet|BTreeSet|Slab)\b") CANONICAL = ("crates/perry-ext-net/src/payload_transport.rs", "opened") @@ -132,7 +136,7 @@ def evaluate(sources, registry): transitions += 1 # A JS-visible hidden latch is still duplicate state. Scan property # writes using original literal keys, after removing comments. - for match in re.finditer(r'\bown_set\s*\([^;]*?"([^"\n]*open[^"\n]*)"', source, re.I): + for match in re.finditer(r'\bown_set\s*\([^;]*?"([^"\n]*open[^"\n]*)"', mask_non_code(source, keep_strings=True), re.I): if match[1] != "allowHalfOpen": errors.append(f"{path}: duplicate JS open property {match[1]}") if canonical != 1: @@ -147,11 +151,11 @@ def self_test(): good = {CANONICAL[0]: "struct SocketFields { opened: bool } fn connected() { s.opened = true; s.opened = true; s.opened = true; }"} registry = {"exceptions": []} assert not evaluate(good, registry)[1] - for bad in ["struct Second { is_open: bool }", "struct Second { has_opened: bool }", "static OPEN: AtomicBool = AtomicBool::new(false);", "static FLAGS: HashMap = todo!();", 'fn f() { p::own_set(owner, "bunOpened", true); }']: + for bad in ["struct Second { is_open: bool }", "struct Second { has_opened: bool }", "static OPEN: AtomicBool = AtomicBool::new(false);", "struct Second { opened: Option }", "struct Second { is_open: u8 }", "static FLAGS: HashMap = todo!();", 'fn f() { p::own_set(owner, "bunOpened", true); }']: assert evaluate(dict(good, extra=bad), registry)[1], bad assert evaluate({}, registry)[1] assert evaluate(good, {"exceptions": [{"path": "gone", "name": "open", "reason": "x"*50}]})[1] - assert not evaluate(dict(good, extra='// is_open: bool\n/* HashMap */ fn f() { let s = "has_opened: bool"; }'), registry)[1] + assert not evaluate(dict(good, extra='// own_set(owner, "bunOpened", true);\n// is_open: bool\n/* HashMap */ fn f() { let s = "has_opened: bool"; }'), registry)[1] print("socket-open self-test: OK (duplicate fields, atomic flag, table, JS latch, empty census, stale exception, masking)") def main(): From 97a7c9d52bd8609fc4626f8e49137760b0b5ec5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 9 Oct 2026 05:25:08 +0200 Subject: [PATCH 07/18] test(net): pin opened declaration and live production transition paths --- .../check_ext_net_socket_open_invariant.py | 27 ++++++++++++++----- 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/scripts/check_ext_net_socket_open_invariant.py b/scripts/check_ext_net_socket_open_invariant.py index c25098fd7a..eef8eff901 100755 --- a/scripts/check_ext_net_socket_open_invariant.py +++ b/scripts/check_ext_net_socket_open_invariant.py @@ -107,12 +107,17 @@ def mask_non_code(source: str, keep_strings: bool = False) -> str: FIELD = re.compile(r"\b(\w+)\s*:\s*(?:bool|Atomic\w+|Cell|Option|u8|u32|u64|usize|i32|i64)\b") TABLE = re.compile(r"\b(?:HashMap|BTreeMap|DashMap|IndexMap|HashSet|BTreeSet|Slab)\b") CANONICAL = ("crates/perry-ext-net/src/payload_transport.rs", "opened") +TRANSITIONS = { + "crates/perry-ext-net/src/native_transport.rs", + "crates/perry-ext-net/src/payload_io.rs", + "crates/perry-ext-net/src/payload_server.rs", +} def evaluate(sources, registry): errors, sites, used = [], [], set() exceptions = {(e["path"], e["name"]): e for e in registry["exceptions"]} canonical = 0 - transitions = 0 + transitions = set() for path, source in sources.items(): code = mask_non_code(source) for match in FIELD.finditer(code): @@ -122,7 +127,14 @@ def evaluate(sources, registry): key = path, name sites.append(f"{path}:{code[:match.start()].count(chr(10))+1}: {name}") if key == CANONICAL: - canonical += 1 + # The declaration must belong to the payload extension, + # rather than a replacement flag in another struct. + prefix = code[:match.start()] + head = prefix.rfind("struct SocketFields") + if head >= 0 and prefix[head:].count("{") > prefix[head:].count("}"): + canonical += 1 + else: + errors.append(f"{path}: opened is outside SocketFields") elif key in exceptions and len(exceptions[key].get("reason", "")) >= 40: used.add(key) else: @@ -133,7 +145,7 @@ def evaluate(sources, registry): if match[1] not in {"opened", "allow_half_open"}: errors.append(f"{path}: second open assignment {match[1]}") if match[1] == "opened" and re.match(r"\s*true\b", code[match.end():]): - transitions += 1 + transitions.add(path) # A JS-visible hidden latch is still duplicate state. Scan property # writes using original literal keys, after removing comments. for match in re.finditer(r'\bown_set\s*\([^;]*?"([^"\n]*open[^"\n]*)"', mask_non_code(source, keep_strings=True), re.I): @@ -141,19 +153,22 @@ def evaluate(sources, registry): errors.append(f"{path}: duplicate JS open property {match[1]}") if canonical != 1: errors.append(f"expected exactly one SocketFields.opened declaration, found {canonical}") - if transitions < 3: - errors.append(f"opened transition census {transitions} below three production transitions") + for path in TRANSITIONS - transitions: + errors.append(f"missing production opened transition: {path}") for key in exceptions.keys() - used: errors.append(f"stale exception: {key}") return sites, errors def self_test(): - good = {CANONICAL[0]: "struct SocketFields { opened: bool } fn connected() { s.opened = true; s.opened = true; s.opened = true; }"} + good = {CANONICAL[0]: "struct SocketFields { opened: bool }"} + good.update({path: "fn connected() { s.opened = true; }" for path in TRANSITIONS}) registry = {"exceptions": []} assert not evaluate(good, registry)[1] for bad in ["struct Second { is_open: bool }", "struct Second { has_opened: bool }", "static OPEN: AtomicBool = AtomicBool::new(false);", "struct Second { opened: Option }", "struct Second { is_open: u8 }", "static FLAGS: HashMap = todo!();", 'fn f() { p::own_set(owner, "bunOpened", true); }']: assert evaluate(dict(good, extra=bad), registry)[1], bad assert evaluate({}, registry)[1] + assert evaluate({CANONICAL[0]: good[CANONICAL[0]]}, registry)[1] + assert evaluate(dict(good, **{CANONICAL[0]: "struct Other { opened: bool }"}), registry)[1] assert evaluate(good, {"exceptions": [{"path": "gone", "name": "open", "reason": "x"*50}]})[1] assert not evaluate(dict(good, extra='// own_set(owner, "bunOpened", true);\n// is_open: bool\n/* HashMap */ fn f() { let s = "has_opened: bool"; }'), registry)[1] print("socket-open self-test: OK (duplicate fields, atomic flag, table, JS latch, empty census, stale exception, masking)") From 927c5a148d77571c223508f2329f0f27ce6ec9c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 9 Oct 2026 05:40:46 +0200 Subject: [PATCH 08/18] fix(net): document FFI safety contracts and resolve new Clippy findings Remove the unused direct bytes dependency and the stale main VERDICT_CLASSES registry exemption exposed by the corrected census floor. Simplify the no-op promise pointer cast and the two HTTP Clippy findings without changing transport behavior. --- Cargo.lock | 1 - .../src/client_turnloop/protocol.rs | 2 +- .../src/server/turnloop_h2/conn.rs | 38 ++- crates/perry-ext-net/Cargo.toml | 5 - crates/perry-ext-net/src/classes.rs | 27 ++ crates/perry-ext-net/src/lib.rs | 33 +++ crates/perry-ext-net/src/payload_abi.rs | 258 ++++++++++++++++++ crates/perry-ext-net/src/payload_tls.rs | 3 +- crates/perry-ext-net/src/socket_facade.rs | 28 ++ crates/perry-ext-net/src/tls.rs | 3 + scripts/registry_lifetime_allowlist.json | 6 - 11 files changed, 368 insertions(+), 36 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 858ba00102..189002e507 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5116,7 +5116,6 @@ dependencies = [ name = "perry-ext-net" version = "0.5.1659" dependencies = [ - "bytes", "perry-ffi", "perry-runtime", "rustls", diff --git a/crates/perry-ext-http/src/client_turnloop/protocol.rs b/crates/perry-ext-http/src/client_turnloop/protocol.rs index 5279c685e2..442b913b13 100644 --- a/crates/perry-ext-http/src/client_turnloop/protocol.rs +++ b/crates/perry-ext-http/src/client_turnloop/protocol.rs @@ -211,7 +211,7 @@ pub(super) fn on_connect(conn: &mut Conn) -> Vec { let tunnel = conn.exchange.as_ref().and_then(|ex| { let out = &ex.out; (out.key.https) - .then(|| out.proxy.as_ref()) + .then_some(out.proxy.as_ref()) .flatten() .map(|proxy| wire::connect_head(&out.key.host, out.key.port, proxy)) }); diff --git a/crates/perry-ext-http/src/server/turnloop_h2/conn.rs b/crates/perry-ext-http/src/server/turnloop_h2/conn.rs index b56d59acce..8f533c04ca 100644 --- a/crates/perry-ext-http/src/server/turnloop_h2/conn.rs +++ b/crates/perry-ext-http/src/server/turnloop_h2/conn.rs @@ -384,30 +384,26 @@ pub(crate) fn peek(id: impl Into, f: impl FnOnce(&H2Conn) -> R) -> Op /// Called first from P5's sink. Returns true when this completion was HTTP/2's. pub(crate) fn intercept(c: &tl::NetCompletion) -> bool { + if !owns(c.id) { + return false; + } match c.kind { - _ => { - if !owns(c.id) { - return false; - } - match c.kind { - tl::NET_CONNECT => on_connect(c.id), - // SAFETY: valid for the duration of this sink call. - tl::NET_DATA => on_data(c.id, unsafe { c.bytes() }), - tl::NET_EOF => on_eof(c.id), - tl::NET_WROTE => {} - tl::NET_SHUTDOWN => on_shutdown(c.id), - tl::NET_CLOSED => on_closed(c.id), - tl::NET_TIMER => on_timer(c.id), - tl::NET_ERROR => { - // SAFETY: same call; both point at `'static` string data. - let code = unsafe { c.code() }; - on_error(c.id, code); - } - _ => {} - } - true + tl::NET_CONNECT => on_connect(c.id), + // SAFETY: valid for the duration of this sink call. + tl::NET_DATA => on_data(c.id, unsafe { c.bytes() }), + tl::NET_EOF => on_eof(c.id), + tl::NET_WROTE => {} + tl::NET_SHUTDOWN => on_shutdown(c.id), + tl::NET_CLOSED => on_closed(c.id), + tl::NET_TIMER => on_timer(c.id), + tl::NET_ERROR => { + // SAFETY: same call; both point at `'static` string data. + let code = unsafe { c.code() }; + on_error(c.id, code); } + _ => {} } + true } /// A listener error. P5's sink owns listener ids it knows; this answers for diff --git a/crates/perry-ext-net/Cargo.toml b/crates/perry-ext-net/Cargo.toml index 18e937c5f9..69664fdab5 100644 --- a/crates/perry-ext-net/Cargo.toml +++ b/crates/perry-ext-net/Cargo.toml @@ -13,11 +13,6 @@ crate-type = ["staticlib", "rlib"] [dependencies] perry-ffi.workspace = true -# Zero-copy socket reads: each read is copied into a reused `BytesMut` and -# `split_to(n).freeze()` carves each read off as a refcounted `Bytes` view, -# which the 'data' event carries to the drain handler (it only borrows it as -# `&[u8]`). Drops the per-read `Vec` alloc + memcpy. -bytes.workspace = true # No tokio and no tokio-rustls: every socket, listener and TLS session lives # on the agent's turnloop loop (`turnloop_io`, `turnloop_tls_io`). A thread # that does not own that loop posts its submissions to the thread that does diff --git a/crates/perry-ext-net/src/classes.rs b/crates/perry-ext-net/src/classes.rs index 79091467a4..2ed20b5ed7 100644 --- a/crates/perry-ext-net/src/classes.rs +++ b/crates/perry-ext-net/src/classes.rs @@ -255,6 +255,9 @@ fn string_from_js_value(value: JsValue) -> Option { } } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. #[no_mangle] pub unsafe extern "C" fn js_net_block_list_new() -> i64 { handle_from_value(unsafe { @@ -279,6 +282,9 @@ pub extern "C" fn js_net_block_list_is_block_list(value: f64) -> f64 { js_bool(np::lifecycle(value, &BLOCK_LIST).is_ok()) } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. #[no_mangle] pub unsafe extern "C" fn js_net_block_list_add_address( handle: i64, @@ -294,6 +300,9 @@ pub unsafe extern "C" fn js_net_block_list_add_address( undefined() } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. #[no_mangle] pub unsafe extern "C" fn js_net_block_list_add_range( handle: i64, @@ -316,6 +325,9 @@ pub unsafe extern "C" fn js_net_block_list_add_range( undefined() } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. #[no_mangle] pub unsafe extern "C" fn js_net_block_list_add_subnet( handle: i64, @@ -336,6 +348,9 @@ pub unsafe extern "C" fn js_net_block_list_add_subnet( undefined() } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. #[no_mangle] pub unsafe extern "C" fn js_net_block_list_check( handle: i64, @@ -362,6 +377,9 @@ pub extern "C" fn js_net_block_list_rules(handle: i64) -> *mut ArrayHeader { block_list_rules_array(handle) } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. #[no_mangle] pub unsafe extern "C" fn js_net_block_list_from_json(handle: i64, value: f64) -> f64 { let scope = TransientRootScope::enter(); @@ -406,6 +424,9 @@ fn socket_address_new(address: IpAddr, port: u16, flowlabel: u32) -> f64 { } } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. #[no_mangle] pub unsafe extern "C" fn js_net_socket_address_new(options: f64) -> i64 { let scope = TransientRootScope::enter(); @@ -432,6 +453,9 @@ pub extern "C" fn js_ext_net_is_socket_address_handle(handle: i64) -> i32 { np::lifecycle(boxed_handle(handle), &SOCKET_ADDRESS).is_ok() as i32 } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. #[no_mangle] pub unsafe extern "C" fn js_net_socket_address_parse(input_ptr: i64) -> f64 { let Some(input) = string_from_header_i64(input_ptr) else { @@ -506,6 +530,9 @@ pub extern "C" fn js_net_socket_address_get_flowlabel(handle: i64) -> f64 { with_socket_address(handle, 0, |s| s.flowlabel) as f64 } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. #[no_mangle] pub unsafe extern "C" fn js_net_socket_address_parse_value(input: f64) -> f64 { extern "C" { diff --git a/crates/perry-ext-net/src/lib.rs b/crates/perry-ext-net/src/lib.rs index 5bb49da36b..5cb07606ea 100644 --- a/crates/perry-ext-net/src/lib.rs +++ b/crates/perry-ext-net/src/lib.rs @@ -36,6 +36,9 @@ pub use socket_facade::*; mod payload_abi; pub use payload_abi::*; pub const TURNLOOP_SUBSYSTEM: u8 = 0; +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. #[no_mangle] pub unsafe extern "C" fn js_ext_net_socket_connect( arg1_f64: f64, @@ -45,6 +48,9 @@ pub unsafe extern "C" fn js_ext_net_socket_connect( js_net_socket_connect(arg1_f64, arg2_f64, arg3_f64) } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. #[no_mangle] pub unsafe extern "C" fn js_net_socket_connect(arg1_f64: f64, arg2_f64: f64, arg3_f64: f64) -> i64 { let scope = TransientRootScope::enter(); @@ -56,6 +62,9 @@ pub unsafe extern "C" fn js_net_socket_connect(arg1_f64: f64, arg2_f64: f64, arg payload_transport::raw_owner(owner.get()) } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. #[no_mangle] pub unsafe extern "C" fn js_net_socket_alloc() -> i64 { payload_transport::raw_owner(payload_socket::new_socket( @@ -64,11 +73,17 @@ pub unsafe extern "C" fn js_net_socket_alloc() -> i64 { )) } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. #[no_mangle] pub unsafe extern "C" fn js_ext_net_socket_alloc(options: f64) -> i64 { payload_transport::raw_owner(payload_socket::new_socket(payload_io::ROUTE, options)) } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. #[no_mangle] pub unsafe extern "C" fn js_net_create_server( _options_i64: i64, @@ -92,6 +107,9 @@ pub unsafe extern "C" fn js_net_create_server( )) } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. #[no_mangle] pub unsafe extern "C" fn js_ext_net_create_server( options_i64: i64, @@ -100,11 +118,17 @@ pub unsafe extern "C" fn js_ext_net_create_server( js_net_create_server(options_i64, connection_listener_i64) } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. #[no_mangle] pub unsafe extern "C" fn js_net_server_listen(handle: i64, port: f64, arg2: f64, arg3: f64) { payload_server::listen(payload_transport::boxed_addr(handle), port, arg2, arg3); } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. #[no_mangle] pub unsafe extern "C" fn js_net_server_close(handle: i64, callback_i64: i64) { payload_server::close( @@ -119,6 +143,9 @@ pub unsafe extern "C" fn js_net_server_close(handle: i64, callback_i64: i64) { /// `server.address()` as a JSON string, for the stdlib's untyped method /// dispatch (`handle` is the server object's address). +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. #[no_mangle] pub unsafe extern "C" fn js_net_server_address(handle: i64) -> *mut StringHeader { alloc_string(&payload_server::address_json( @@ -127,6 +154,9 @@ pub unsafe extern "C" fn js_net_server_address(handle: i64) -> *mut StringHeader .as_raw() } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. #[no_mangle] pub unsafe extern "C" fn js_ext_net_socket_method_connect( handle: i64, @@ -137,6 +167,9 @@ pub unsafe extern "C" fn js_ext_net_socket_method_connect( js_net_socket_method_connect(handle, arg1, arg2, arg3); } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. #[no_mangle] pub unsafe extern "C" fn js_net_socket_method_connect( handle: i64, diff --git a/crates/perry-ext-net/src/payload_abi.rs b/crates/perry-ext-net/src/payload_abi.rs index ee98ef7359..e3e33ecc87 100644 --- a/crates/perry-ext-net/src/payload_abi.rs +++ b/crates/perry-ext-net/src/payload_abi.rs @@ -36,6 +36,9 @@ fn callback_value(raw: i64) -> f64 { } } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. #[no_mangle] pub unsafe extern "C" fn js_ext_net_socket_read(handle: i64, _size: f64) -> f64 { let scope = TransientRootScope::enter(); @@ -48,6 +51,9 @@ pub unsafe extern "C" fn js_ext_net_socket_read(handle: i64, _size: f64) -> f64 } } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. #[no_mangle] pub unsafe extern "C" fn js_net_socket_read(handle: i64, size: f64) -> f64 { let scope = TransientRootScope::enter(); @@ -60,6 +66,9 @@ pub unsafe extern "C" fn js_net_socket_read(handle: i64, size: f64) -> f64 { } } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. #[no_mangle] pub unsafe extern "C" fn js_net_socket_get_pending(handle: i64) -> f64 { let scope = TransientRootScope::enter(); @@ -68,6 +77,9 @@ pub unsafe extern "C" fn js_net_socket_get_pending(handle: i64) -> f64 { socket::get(owner(handle_root.get()), "pending") } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. #[no_mangle] pub unsafe extern "C" fn js_net_socket_get_connecting(handle: i64) -> f64 { let scope = TransientRootScope::enter(); @@ -76,6 +88,9 @@ pub unsafe extern "C" fn js_net_socket_get_connecting(handle: i64) -> f64 { socket::get(owner(handle_root.get()), "connecting") } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. #[no_mangle] pub unsafe extern "C" fn js_net_socket_get_destroyed(handle: i64) -> f64 { let scope = TransientRootScope::enter(); @@ -84,6 +99,9 @@ pub unsafe extern "C" fn js_net_socket_get_destroyed(handle: i64) -> f64 { socket::get(owner(handle_root.get()), "destroyed") } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. #[no_mangle] pub unsafe extern "C" fn js_net_socket_get_writable(handle: i64) -> f64 { let scope = TransientRootScope::enter(); @@ -92,6 +110,9 @@ pub unsafe extern "C" fn js_net_socket_get_writable(handle: i64) -> f64 { socket::get(owner(handle_root.get()), "writable") } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. #[no_mangle] pub unsafe extern "C" fn js_net_socket_get_readable(handle: i64) -> f64 { let scope = TransientRootScope::enter(); @@ -100,6 +121,9 @@ pub unsafe extern "C" fn js_net_socket_get_readable(handle: i64) -> f64 { socket::get(owner(handle_root.get()), "readable") } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. #[no_mangle] pub unsafe extern "C" fn js_net_socket_get_writable_ended(handle: i64) -> f64 { let scope = TransientRootScope::enter(); @@ -108,6 +132,9 @@ pub unsafe extern "C" fn js_net_socket_get_writable_ended(handle: i64) -> f64 { socket::get(owner(handle_root.get()), "writableEnded") } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. #[no_mangle] pub unsafe extern "C" fn js_net_socket_get_readable_ended(handle: i64) -> f64 { let scope = TransientRootScope::enter(); @@ -116,6 +143,9 @@ pub unsafe extern "C" fn js_net_socket_get_readable_ended(handle: i64) -> f64 { socket::get(owner(handle_root.get()), "readableEnded") } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. #[no_mangle] pub unsafe extern "C" fn js_net_socket_get_writable_state(handle: i64) -> *mut StringHeader { let scope = TransientRootScope::enter(); @@ -125,6 +155,9 @@ pub unsafe extern "C" fn js_net_socket_get_writable_state(handle: i64) -> *mut S js_json_stringify(value, 0) } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. #[no_mangle] pub unsafe extern "C" fn js_net_socket_get_readable_state(handle: i64) -> *mut StringHeader { let scope = TransientRootScope::enter(); @@ -134,6 +167,9 @@ pub unsafe extern "C" fn js_net_socket_get_readable_state(handle: i64) -> *mut S js_json_stringify(value, 0) } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. #[no_mangle] pub unsafe extern "C" fn js_net_socket_get_ready_state(handle: i64) -> *mut StringHeader { let scope = TransientRootScope::enter(); @@ -143,6 +179,9 @@ pub unsafe extern "C" fn js_net_socket_get_ready_state(handle: i64) -> *mut Stri alloc_string(&jsvalue_to_owned_string(value).unwrap_or_default()).as_raw() } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. #[no_mangle] pub unsafe extern "C" fn js_net_socket_get_bytes_read(handle: i64) -> f64 { let scope = TransientRootScope::enter(); @@ -151,6 +190,9 @@ pub unsafe extern "C" fn js_net_socket_get_bytes_read(handle: i64) -> f64 { socket::get(owner(handle_root.get()), "bytesRead") } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. #[no_mangle] pub unsafe extern "C" fn js_net_socket_get_bytes_written(handle: i64) -> f64 { let scope = TransientRootScope::enter(); @@ -159,6 +201,9 @@ pub unsafe extern "C" fn js_net_socket_get_bytes_written(handle: i64) -> f64 { socket::get(owner(handle_root.get()), "bytesWritten") } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. #[no_mangle] pub unsafe extern "C" fn js_net_socket_get_timeout(handle: i64) -> f64 { let scope = TransientRootScope::enter(); @@ -167,6 +212,9 @@ pub unsafe extern "C" fn js_net_socket_get_timeout(handle: i64) -> f64 { socket::get(owner(handle_root.get()), "timeout") } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. #[no_mangle] pub unsafe extern "C" fn js_net_socket_get_local_address(handle: i64) -> f64 { let scope = TransientRootScope::enter(); @@ -175,6 +223,9 @@ pub unsafe extern "C" fn js_net_socket_get_local_address(handle: i64) -> f64 { socket::get(owner(handle_root.get()), "localAddress") } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. #[no_mangle] pub unsafe extern "C" fn js_net_socket_get_local_port(handle: i64) -> f64 { let scope = TransientRootScope::enter(); @@ -183,6 +234,9 @@ pub unsafe extern "C" fn js_net_socket_get_local_port(handle: i64) -> f64 { socket::get(owner(handle_root.get()), "localPort") } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. #[no_mangle] pub unsafe extern "C" fn js_net_socket_get_local_family(handle: i64) -> f64 { let scope = TransientRootScope::enter(); @@ -191,6 +245,9 @@ pub unsafe extern "C" fn js_net_socket_get_local_family(handle: i64) -> f64 { socket::get(owner(handle_root.get()), "localFamily") } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. #[no_mangle] pub unsafe extern "C" fn js_net_socket_get_remote_address(handle: i64) -> f64 { let scope = TransientRootScope::enter(); @@ -199,6 +256,9 @@ pub unsafe extern "C" fn js_net_socket_get_remote_address(handle: i64) -> f64 { socket::get(owner(handle_root.get()), "remoteAddress") } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. #[no_mangle] pub unsafe extern "C" fn js_net_socket_get_remote_port(handle: i64) -> f64 { let scope = TransientRootScope::enter(); @@ -207,6 +267,9 @@ pub unsafe extern "C" fn js_net_socket_get_remote_port(handle: i64) -> f64 { socket::get(owner(handle_root.get()), "remotePort") } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. #[no_mangle] pub unsafe extern "C" fn js_net_socket_get_remote_family(handle: i64) -> f64 { let scope = TransientRootScope::enter(); @@ -215,6 +278,9 @@ pub unsafe extern "C" fn js_net_socket_get_remote_family(handle: i64) -> f64 { socket::get(owner(handle_root.get()), "remoteFamily") } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. #[no_mangle] pub unsafe extern "C" fn js_net_socket_get_buffer_size(handle: i64) -> f64 { let scope = TransientRootScope::enter(); @@ -223,6 +289,9 @@ pub unsafe extern "C" fn js_net_socket_get_buffer_size(handle: i64) -> f64 { socket::get(owner(handle_root.get()), "bufferSize") } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. #[no_mangle] pub unsafe extern "C" fn js_net_socket_get_writable_length(handle: i64) -> f64 { let scope = TransientRootScope::enter(); @@ -231,6 +300,9 @@ pub unsafe extern "C" fn js_net_socket_get_writable_length(handle: i64) -> f64 { socket::get(owner(handle_root.get()), "writableLength") } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. #[no_mangle] pub unsafe extern "C" fn js_net_socket_get_writable_need_drain(handle: i64) -> f64 { let scope = TransientRootScope::enter(); @@ -239,6 +311,9 @@ pub unsafe extern "C" fn js_net_socket_get_writable_need_drain(handle: i64) -> f socket::get(owner(handle_root.get()), "writableNeedDrain") } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. #[no_mangle] pub unsafe extern "C" fn js_ext_net_socket_write(handle: i64, chunk_bits: i64) -> f64 { let scope = TransientRootScope::enter(); @@ -251,6 +326,9 @@ pub unsafe extern "C" fn js_ext_net_socket_write(handle: i64, chunk_bits: i64) - } } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. #[no_mangle] pub unsafe extern "C" fn js_net_socket_write(handle: i64, chunk_bits: i64) { let scope = TransientRootScope::enter(); @@ -263,6 +341,9 @@ pub unsafe extern "C" fn js_net_socket_write(handle: i64, chunk_bits: i64) { }; } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. #[no_mangle] pub unsafe extern "C" fn js_ext_net_socket_write3( handle: i64, @@ -286,6 +367,9 @@ pub unsafe extern "C" fn js_ext_net_socket_write3( } } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. #[no_mangle] pub unsafe extern "C" fn js_ext_net_socket_end(handle: i64, chunk_bits: i64) { let scope = TransientRootScope::enter(); @@ -298,6 +382,9 @@ pub unsafe extern "C" fn js_ext_net_socket_end(handle: i64, chunk_bits: i64) { }; } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. #[no_mangle] pub unsafe extern "C" fn js_net_socket_end(handle: i64, chunk_bits: i64) { let scope = TransientRootScope::enter(); @@ -310,6 +397,9 @@ pub unsafe extern "C" fn js_net_socket_end(handle: i64, chunk_bits: i64) { }; } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. #[no_mangle] pub unsafe extern "C" fn js_ext_net_socket_end3( handle: i64, @@ -334,6 +424,9 @@ pub unsafe extern "C" fn js_ext_net_socket_end3( ); } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. #[no_mangle] pub unsafe extern "C" fn js_net_socket_destroy(handle: i64) { let scope = TransientRootScope::enter(); @@ -345,6 +438,9 @@ pub unsafe extern "C" fn js_net_socket_destroy(handle: i64) { }; } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. #[no_mangle] pub unsafe extern "C" fn js_ext_net_destroy_socket(handle: i64) { let scope = TransientRootScope::enter(); @@ -356,6 +452,9 @@ pub unsafe extern "C" fn js_ext_net_destroy_socket(handle: i64) { }; } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. #[no_mangle] pub unsafe extern "C" fn js_net_socket_address(handle: i64) -> *mut StringHeader { let scope = TransientRootScope::enter(); @@ -368,6 +467,9 @@ pub unsafe extern "C" fn js_net_socket_address(handle: i64) -> *mut StringHeader js_json_stringify(value, 0) } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. #[no_mangle] pub unsafe extern "C" fn js_net_socket_once(handle: i64, event_ptr: i64, cb: i64) -> i64 { let scope = TransientRootScope::enter(); @@ -383,6 +485,9 @@ pub unsafe extern "C" fn js_net_socket_once(handle: i64, event_ptr: i64, cb: i64 p::raw_owner(value) } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. #[no_mangle] pub unsafe extern "C" fn js_net_socket_prepend_listener( handle: i64, @@ -402,6 +507,9 @@ pub unsafe extern "C" fn js_net_socket_prepend_listener( p::raw_owner(value) } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. #[no_mangle] pub unsafe extern "C" fn js_net_socket_prepend_once_listener( handle: i64, @@ -421,6 +529,9 @@ pub unsafe extern "C" fn js_net_socket_prepend_once_listener( p::raw_owner(value) } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. #[no_mangle] pub unsafe extern "C" fn js_net_socket_remove_listener( handle: i64, @@ -440,6 +551,9 @@ pub unsafe extern "C" fn js_net_socket_remove_listener( p::raw_owner(value) } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. #[no_mangle] pub unsafe extern "C" fn js_net_socket_remove_all_listeners(handle: i64, event_ptr: i64) -> i64 { let scope = TransientRootScope::enter(); @@ -453,6 +567,9 @@ pub unsafe extern "C" fn js_net_socket_remove_all_listeners(handle: i64, event_p p::raw_owner(value) } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. #[no_mangle] pub unsafe extern "C" fn js_net_socket_listener_count(handle: i64, event_ptr: i64) -> f64 { let scope = TransientRootScope::enter(); @@ -465,6 +582,9 @@ pub unsafe extern "C" fn js_net_socket_listener_count(handle: i64, event_ptr: i6 } } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. #[no_mangle] pub unsafe extern "C" fn js_ext_net_socket_listener_count(handle: i64, event_ptr: i64) -> f64 { let scope = TransientRootScope::enter(); @@ -477,6 +597,9 @@ pub unsafe extern "C" fn js_ext_net_socket_listener_count(handle: i64, event_ptr } } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. #[no_mangle] pub unsafe extern "C" fn js_ext_net_socket_get_max_listeners(handle: i64) -> f64 { let scope = TransientRootScope::enter(); @@ -488,6 +611,9 @@ pub unsafe extern "C" fn js_ext_net_socket_get_max_listeners(handle: i64) -> f64 } } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. #[no_mangle] pub unsafe extern "C" fn js_ext_net_socket_set_max_listeners(handle: i64, n: f64) -> f64 { let scope = TransientRootScope::enter(); @@ -500,6 +626,9 @@ pub unsafe extern "C" fn js_ext_net_socket_set_max_listeners(handle: i64, n: f64 } } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. #[no_mangle] pub unsafe extern "C" fn js_net_socket_event_names(handle: i64) -> *mut StringHeader { let scope = TransientRootScope::enter(); @@ -512,6 +641,9 @@ pub unsafe extern "C" fn js_net_socket_event_names(handle: i64) -> *mut StringHe js_json_stringify(value, 0) } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. #[no_mangle] pub unsafe extern "C" fn js_net_socket_listeners(handle: i64, event_ptr: i64) -> i64 { let scope = TransientRootScope::enter(); @@ -525,6 +657,9 @@ pub unsafe extern "C" fn js_net_socket_listeners(handle: i64, event_ptr: i64) -> p::raw_owner(value) } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. #[no_mangle] pub unsafe extern "C" fn js_ext_net_socket_listeners(handle: i64, event_ptr: i64) -> i64 { let scope = TransientRootScope::enter(); @@ -538,6 +673,9 @@ pub unsafe extern "C" fn js_ext_net_socket_listeners(handle: i64, event_ptr: i64 p::raw_owner(value) } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. #[no_mangle] pub unsafe extern "C" fn js_net_socket_raw_listeners(handle: i64, event_ptr: i64) -> i64 { let scope = TransientRootScope::enter(); @@ -551,6 +689,9 @@ pub unsafe extern "C" fn js_net_socket_raw_listeners(handle: i64, event_ptr: i64 p::raw_owner(value) } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. #[no_mangle] pub unsafe extern "C" fn js_net_socket_reset_and_destroy(handle: i64) -> i64 { let scope = TransientRootScope::enter(); @@ -563,6 +704,9 @@ pub unsafe extern "C" fn js_net_socket_reset_and_destroy(handle: i64) -> i64 { p::raw_owner(value) } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. #[no_mangle] pub unsafe extern "C" fn js_net_server_once(handle: i64, event_ptr: i64, cb: i64) -> i64 { let scope = TransientRootScope::enter(); @@ -578,6 +722,9 @@ pub unsafe extern "C" fn js_net_server_once(handle: i64, event_ptr: i64, cb: i64 p::raw_owner(value) } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. #[no_mangle] pub unsafe extern "C" fn js_net_server_prepend_listener( handle: i64, @@ -597,6 +744,9 @@ pub unsafe extern "C" fn js_net_server_prepend_listener( p::raw_owner(value) } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. #[no_mangle] pub unsafe extern "C" fn js_net_server_prepend_once_listener( handle: i64, @@ -616,6 +766,9 @@ pub unsafe extern "C" fn js_net_server_prepend_once_listener( p::raw_owner(value) } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. #[no_mangle] pub unsafe extern "C" fn js_net_server_remove_listener( handle: i64, @@ -635,6 +788,9 @@ pub unsafe extern "C" fn js_net_server_remove_listener( p::raw_owner(value) } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. #[no_mangle] pub unsafe extern "C" fn js_net_server_remove_all_listeners(handle: i64, event_ptr: i64) -> i64 { let scope = TransientRootScope::enter(); @@ -648,6 +804,9 @@ pub unsafe extern "C" fn js_net_server_remove_all_listeners(handle: i64, event_p p::raw_owner(value) } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. #[no_mangle] pub unsafe extern "C" fn js_net_server_listener_count(handle: i64, event_ptr: i64) -> f64 { let scope = TransientRootScope::enter(); @@ -660,6 +819,9 @@ pub unsafe extern "C" fn js_net_server_listener_count(handle: i64, event_ptr: i6 } } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. #[no_mangle] pub unsafe extern "C" fn js_net_server_event_names(handle: i64) -> *mut StringHeader { let scope = TransientRootScope::enter(); @@ -672,6 +834,9 @@ pub unsafe extern "C" fn js_net_server_event_names(handle: i64) -> *mut StringHe js_json_stringify(value, 0) } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. #[no_mangle] pub unsafe extern "C" fn js_net_server_listeners(handle: i64, event_ptr: i64) -> i64 { let scope = TransientRootScope::enter(); @@ -685,6 +850,9 @@ pub unsafe extern "C" fn js_net_server_listeners(handle: i64, event_ptr: i64) -> p::raw_owner(value) } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. #[no_mangle] pub unsafe extern "C" fn js_net_server_raw_listeners(handle: i64, event_ptr: i64) -> i64 { let scope = TransientRootScope::enter(); @@ -698,6 +866,9 @@ pub unsafe extern "C" fn js_net_server_raw_listeners(handle: i64, event_ptr: i64 p::raw_owner(value) } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. #[no_mangle] pub unsafe extern "C" fn js_net_socket_noop_self(handle: i64) -> i64 { let scope = TransientRootScope::enter(); @@ -710,6 +881,9 @@ pub unsafe extern "C" fn js_net_socket_noop_self(handle: i64) -> i64 { p::raw_owner(value) } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. #[no_mangle] pub unsafe extern "C" fn js_net_socket_ref(handle: i64) -> i64 { let scope = TransientRootScope::enter(); @@ -722,6 +896,9 @@ pub unsafe extern "C" fn js_net_socket_ref(handle: i64) -> i64 { p::raw_owner(value) } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. #[no_mangle] pub unsafe extern "C" fn js_net_socket_unref(handle: i64) -> i64 { let scope = TransientRootScope::enter(); @@ -734,6 +911,9 @@ pub unsafe extern "C" fn js_net_socket_unref(handle: i64) -> i64 { p::raw_owner(value) } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. #[no_mangle] pub unsafe extern "C" fn js_net_socket_set_timeout( handle: i64, @@ -756,6 +936,9 @@ pub unsafe extern "C" fn js_net_socket_set_timeout( p::raw_owner(value) } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. #[no_mangle] pub unsafe extern "C" fn js_net_server_noop_self(handle: i64) -> i64 { let scope = TransientRootScope::enter(); @@ -768,6 +951,9 @@ pub unsafe extern "C" fn js_net_server_noop_self(handle: i64) -> i64 { p::raw_owner(value) } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. #[no_mangle] pub unsafe extern "C" fn js_net_socket_get_type_of_service(handle: i64) -> f64 { let scope = TransientRootScope::enter(); @@ -779,6 +965,9 @@ pub unsafe extern "C" fn js_net_socket_get_type_of_service(handle: i64) -> f64 { } } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. #[no_mangle] pub unsafe extern "C" fn js_net_socket_set_type_of_service(handle: i64, tos: f64) -> i64 { let scope = TransientRootScope::enter(); @@ -792,6 +981,9 @@ pub unsafe extern "C" fn js_net_socket_set_type_of_service(handle: i64, tos: f64 p::raw_owner(value) } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. #[no_mangle] pub unsafe extern "C" fn js_net_socket_set_no_delay(handle: i64, arg_bits: i64) -> i64 { let scope = TransientRootScope::enter(); @@ -805,6 +997,9 @@ pub unsafe extern "C" fn js_net_socket_set_no_delay(handle: i64, arg_bits: i64) p::raw_owner(value) } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. #[no_mangle] pub unsafe extern "C" fn js_net_socket_set_encoding(handle: i64, enc_ptr: i64) -> i64 { let scope = TransientRootScope::enter(); @@ -818,6 +1013,9 @@ pub unsafe extern "C" fn js_net_socket_set_encoding(handle: i64, enc_ptr: i64) - p::raw_owner(value) } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. #[no_mangle] pub unsafe extern "C" fn js_net_server_on(handle: i64, event_ptr: i64, cb: i64) { let scope = TransientRootScope::enter(); @@ -834,6 +1032,9 @@ pub unsafe extern "C" fn js_net_server_on(handle: i64, event_ptr: i64, cb: i64) }; } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. #[no_mangle] pub unsafe extern "C" fn js_ext_net_socket_on(handle: i64, event_ptr: i64, cb: i64) -> i64 { let scope = TransientRootScope::enter(); @@ -851,6 +1052,9 @@ pub unsafe extern "C" fn js_ext_net_socket_on(handle: i64, event_ptr: i64, cb: i p::raw_owner(value) } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. #[no_mangle] pub unsafe extern "C" fn js_ext_net_socket_once(handle: i64, event_ptr: i64, cb: i64) -> i64 { let scope = TransientRootScope::enter(); @@ -866,6 +1070,9 @@ pub unsafe extern "C" fn js_ext_net_socket_once(handle: i64, event_ptr: i64, cb: p::raw_owner(value) } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. #[no_mangle] pub unsafe extern "C" fn js_ext_net_socket_remove_listener( handle: i64, @@ -885,6 +1092,9 @@ pub unsafe extern "C" fn js_ext_net_socket_remove_listener( p::raw_owner(value) } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. #[no_mangle] pub unsafe extern "C" fn js_ext_net_socket_remove_all_listeners( handle: i64, @@ -901,6 +1111,9 @@ pub unsafe extern "C" fn js_ext_net_socket_remove_all_listeners( p::raw_owner(value) } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. #[no_mangle] pub unsafe extern "C" fn js_net_socket_cork(handle: i64) -> i64 { let scope = TransientRootScope::enter(); @@ -913,6 +1126,9 @@ pub unsafe extern "C" fn js_net_socket_cork(handle: i64) -> i64 { p::raw_owner(value) } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. #[no_mangle] pub unsafe extern "C" fn js_net_socket_uncork(handle: i64) -> i64 { let scope = TransientRootScope::enter(); @@ -925,6 +1141,9 @@ pub unsafe extern "C" fn js_net_socket_uncork(handle: i64) -> i64 { p::raw_owner(value) } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. #[no_mangle] pub unsafe extern "C" fn js_net_socket_get_writable_corked(handle: i64) -> f64 { let scope = TransientRootScope::enter(); @@ -933,6 +1152,9 @@ pub unsafe extern "C" fn js_net_socket_get_writable_corked(handle: i64) -> f64 { socket::get(owner(handle_root.get()), "writableCorked") } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. #[no_mangle] pub unsafe extern "C" fn js_net_server_get_listening(handle: i64) -> f64 { let scope = TransientRootScope::enter(); @@ -941,11 +1163,17 @@ pub unsafe extern "C" fn js_net_server_get_listening(handle: i64) -> f64 { server::get(owner(handle_root.get()), "listening") } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. #[no_mangle] pub unsafe extern "C" fn js_net_server_get_connections(handle: i64) -> f64 { server::get(owner(handle), "_connections") } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. #[no_mangle] pub unsafe extern "C" fn js_net_server_get_max_connections(handle: i64) -> f64 { let scope = TransientRootScope::enter(); @@ -954,6 +1182,9 @@ pub unsafe extern "C" fn js_net_server_get_max_connections(handle: i64) -> f64 { server::get(owner(handle_root.get()), "maxConnections") } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. #[no_mangle] pub unsafe extern "C" fn js_net_server_set_max_connections(handle: i64, value: f64) -> f64 { let scope = TransientRootScope::enter(); @@ -967,6 +1198,9 @@ pub unsafe extern "C" fn js_net_server_set_max_connections(handle: i64, value: f } } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. #[no_mangle] pub unsafe extern "C" fn js_net_server_get_drop_max_connection(handle: i64) -> f64 { let scope = TransientRootScope::enter(); @@ -975,6 +1209,9 @@ pub unsafe extern "C" fn js_net_server_get_drop_max_connection(handle: i64) -> f server::get(owner(handle_root.get()), "dropMaxConnection") } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. #[no_mangle] pub unsafe extern "C" fn js_net_server_set_drop_max_connection(handle: i64, value: f64) -> f64 { let scope = TransientRootScope::enter(); @@ -992,6 +1229,9 @@ pub unsafe extern "C" fn js_net_server_set_drop_max_connection(handle: i64, valu } } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. #[no_mangle] pub unsafe extern "C" fn js_net_socket_pipe(handle: i64, dest: f64, options: f64) -> f64 { let scope = TransientRootScope::enter(); @@ -1005,6 +1245,9 @@ pub unsafe extern "C" fn js_net_socket_pipe(handle: i64, dest: f64, options: f64 } } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. #[no_mangle] pub unsafe extern "C" fn js_net_socket_unpipe(handle: i64, dest: f64) -> i64 { let scope = TransientRootScope::enter(); @@ -1022,6 +1265,9 @@ pub unsafe extern "C" fn js_net_socket_unpipe(handle: i64, dest: f64) -> i64 { pub extern "C" fn js_net_has_pending() -> i32 { 0 } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. #[no_mangle] pub unsafe extern "C" fn js_net_process_pending() -> i32 { 0 @@ -1040,6 +1286,9 @@ pub extern "C" fn js_ext_net_is_server_handle(raw: i64) -> i32 { pub extern "C" fn js_net_server_listening(raw: i64) -> i32 { JsValue::from_bits(server::get(owner(raw), "listening").to_bits()).to_bool() as i32 } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. #[no_mangle] pub unsafe extern "C" fn js_net_socket_on(raw: i64, event_ptr: i64, cb: i64) { let scope = TransientRootScope::enter(); @@ -1055,6 +1304,9 @@ pub unsafe extern "C" fn js_net_socket_on(raw: i64, event_ptr: i64, cb: i64) { call(raw_root.get(), "on", &args) }; } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. #[no_mangle] pub unsafe extern "C" fn js_ext_net_socket_emit( raw: i64, @@ -1077,6 +1329,9 @@ pub unsafe extern "C" fn js_ext_net_socket_emit( values.extend(args.iter().map(|arg| arg.get())); call(p::raw_owner(owner.get()), "emit", &values) } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. #[no_mangle] pub unsafe extern "C" fn js_ext_net_socket_emit_abort_error(raw: i64) { let scope = TransientRootScope::enter(); @@ -1087,6 +1342,9 @@ pub unsafe extern "C" fn js_ext_net_socket_emit_abort_error(raw: i64) { socket::error("ABORT_ERR", "The operation was aborted"), ); } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. #[no_mangle] pub unsafe extern "C" fn js_net_socket_upgrade_tls( raw: i64, diff --git a/crates/perry-ext-net/src/payload_tls.rs b/crates/perry-ext-net/src/payload_tls.rs index f140782380..e450efae36 100644 --- a/crates/perry-ext-net/src/payload_tls.rs +++ b/crates/perry-ext-net/src/payload_tls.rs @@ -493,8 +493,7 @@ pub(crate) fn settle_upgrade(owner: f64, failure: Option<&str>) { p::own_set(state.get(), "upgradePromise", p::undefined()); let promise = unsafe { perry_ffi::JsPromise::from_raw( - JsValue::from_bits(promise.get().to_bits()).as_pointer::() - as *mut perry_ffi::Promise, + JsValue::from_bits(promise.get().to_bits()).as_pointer::(), ) }; match failure { diff --git a/crates/perry-ext-net/src/socket_facade.rs b/crates/perry-ext-net/src/socket_facade.rs index 326fbed335..664b231e0b 100644 --- a/crates/perry-ext-net/src/socket_facade.rs +++ b/crates/perry-ext-net/src/socket_facade.rs @@ -13,6 +13,10 @@ fn tls_field(handle: i64, key: &str) -> f64 { } /// TLS compatibility facts are ordinary Socket JS state, never an id record. +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. +/// Non-null byte pointers must be readable for their paired length. #[no_mangle] pub unsafe extern "C" fn js_ext_net_set_tls_metadata( handle: i64, @@ -67,18 +71,34 @@ pub unsafe extern "C" fn js_ext_net_set_tls_metadata( ); } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. +/// Non-null byte pointers must be readable for their paired length. #[no_mangle] pub unsafe extern "C" fn js_ext_net_socket_tls_encrypted(handle: i64) -> f64 { tls_field(handle, "encrypted") } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. +/// Non-null byte pointers must be readable for their paired length. #[no_mangle] pub unsafe extern "C" fn js_ext_net_socket_tls_authorized(handle: i64) -> f64 { tls_field(handle, "authorized") } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. +/// Non-null byte pointers must be readable for their paired length. #[no_mangle] pub unsafe extern "C" fn js_ext_net_socket_tls_servername(handle: i64) -> f64 { tls_field(handle, "servername") } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. +/// Non-null byte pointers must be readable for their paired length. #[no_mangle] pub unsafe extern "C" fn js_ext_net_socket_tls_session( handle: i64, @@ -86,10 +106,18 @@ pub unsafe extern "C" fn js_ext_net_socket_tls_session( let bytes = crate::jsvalue_to_socket_bytes(tls_field(handle, "session")).unwrap_or_default(); alloc_buffer(&bytes) } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. +/// Non-null byte pointers must be readable for their paired length. #[no_mangle] pub unsafe extern "C" fn js_ext_net_socket_tls_session_reused(handle: i64) -> f64 { tls_field(handle, "sessionReused") } +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. +/// Non-null byte pointers must be readable for their paired length. #[no_mangle] pub unsafe extern "C" fn js_ext_net_socket_peer_certificate_json(handle: i64) -> *mut StringHeader { let cn = crate::jsvalue_to_owned_string(tls_field(handle, "peerCertificateCn")); diff --git a/crates/perry-ext-net/src/tls.rs b/crates/perry-ext-net/src/tls.rs index c77cd56bba..535ae22e73 100644 --- a/crates/perry-ext-net/src/tls.rs +++ b/crates/perry-ext-net/src/tls.rs @@ -809,6 +809,9 @@ pub unsafe extern "C" fn js_tls_connect(arg1: f64, arg2: f64, arg3: f64, arg4: f /// perry-ext-net export `js_tls_connect`; auto-optimized binaries may bind the /// shared name to the bundled registry while their event pump owns ext-net /// sockets. Keep generated TLS clients in the same backend as the pump. +/// # Safety +/// Call on the owning runtime thread. Heap-address carriers must name live, +/// rooted allocations in that realm; NaN-boxed arguments must be valid JS values. #[no_mangle] pub unsafe extern "C" fn js_ext_tls_connect(arg1: f64, arg2: f64, arg3: f64, arg4: f64) -> i64 { js_tls_connect(arg1, arg2, arg3, arg4) diff --git a/scripts/registry_lifetime_allowlist.json b/scripts/registry_lifetime_allowlist.json index 39d5f1dda2..4dbfe3a392 100644 --- a/scripts/registry_lifetime_allowlist.json +++ b/scripts/registry_lifetime_allowlist.json @@ -321,12 +321,6 @@ "verdict": "open_leak", "why": "Key includes the runtime SymbolHeader.id: a re-evaluated class expression with computed [Symbol()] members adds entries each time" }, - { - "file": "crates/perry-runtime/src/object/class_registry/verdict_classes.rs", - "name": "VERDICT_CLASSES", - "verdict": "open_leak", - "why": "Receiver class ids from packed_add store verdicts, including synthetic ids minted per closure; never pruned (small u32s)" - }, { "file": "crates/perry-runtime/src/object/field_get_set/ic_miss/private_guard_fast.rs", "name": "PRIVATE_NAME_INTERNER", From a1274c2ccb2aabbfe47e40be8bdcf80fc7fead88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 9 Oct 2026 06:15:36 +0200 Subject: [PATCH 09/18] fix(runtime): document TLS payload safety and remove redundant casts --- crates/perry-runtime/src/tls.rs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/crates/perry-runtime/src/tls.rs b/crates/perry-runtime/src/tls.rs index ea29793897..601a932160 100644 --- a/crates/perry-runtime/src/tls.rs +++ b/crates/perry-runtime/src/tls.rs @@ -486,6 +486,12 @@ pub fn tls_shared_signature_algorithms() -> f64 { } /// Publish TLS options on the actual Socket before connect returns. +/// +/// # Safety +/// Run on the Socket's owning runtime thread. `handle` must name a live, +/// rooted Socket and `options` must be a valid JS value. A non-null +/// `servername_ptr` must remain readable for `servername_len` bytes until +/// this call returns. #[no_mangle] pub unsafe extern "C" fn js_tls_client_record_start( handle: i64, @@ -502,7 +508,7 @@ pub unsafe extern "C" fn js_tls_client_record_start( ) else { return; }; - let state = scope.root_raw_mut_ptr(object_ptr(state).unwrap() as *mut ObjectHeader); + let state = scope.root_raw_mut_ptr(object_ptr(state).unwrap()); let servername = if servername_ptr.is_null() { None } else { @@ -568,7 +574,7 @@ pub unsafe extern "C" fn js_tls_client_record_connected( ) else { return; }; - let state = scope.root_raw_mut_ptr(object_ptr(state).unwrap() as *mut ObjectHeader); + let state = scope.root_raw_mut_ptr(object_ptr(state).unwrap()); for (key, ptr, len) in [ ( "authorizationError", @@ -617,7 +623,7 @@ pub extern "C" fn js_tls_client_record_closed(handle: i64) { return; }; let scope = crate::gc::RuntimeHandleScope::new(); - let state = scope.root_raw_mut_ptr(object_ptr(state).unwrap() as *mut ObjectHeader); + let state = scope.root_raw_mut_ptr(object_ptr(state).unwrap()); unsafe { set_rooted_object_field( &state, From 74a5ee07e6bb8770ee1f2e2f7e0b7ddbb8bb7f3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 9 Oct 2026 06:40:20 +0200 Subject: [PATCH 10/18] test(runtime): observe worker drops without sharing per-thread sinks --- .../src/gc/tests/native_payload_callbacks.rs | 29 +++++++++++++++++-- .../native_payload_callbacks/lifecycle.rs | 14 +++++---- .../src/gc/tests/net_transport.rs | 2 +- 3 files changed, 36 insertions(+), 9 deletions(-) diff --git a/crates/perry-runtime/src/gc/tests/native_payload_callbacks.rs b/crates/perry-runtime/src/gc/tests/native_payload_callbacks.rs index a1d155b354..6d2129155c 100644 --- a/crates/perry-runtime/src/gc/tests/native_payload_callbacks.rs +++ b/crates/perry-runtime/src/gc/tests/native_payload_callbacks.rs @@ -21,14 +21,23 @@ per_test_global! { #[derive(Default)] struct Probe { link: Option, + worker_observations: Option>, } impl Drop for Probe { fn drop(&mut self) { DROPS.fetch_add(1, Ordering::SeqCst); + let mut final_js = false; if let Some(link) = self.link { // Simulate a C xFinal called by native resource destruction. if unsafe { np::link_owner(link) }.is_some() { FINAL_JS.fetch_add(1, Ordering::SeqCst); + final_js = true; + } + } + if let Some(observations) = &self.worker_observations { + observations[0].fetch_add(1, Ordering::SeqCst); + if final_js { + observations[1].fetch_add(1, Ordering::SeqCst); } } } @@ -353,8 +362,20 @@ fn t6_t11_finalization_callbacks_stay_out_of_js() { } assert_eq!(DROPS.load(Ordering::SeqCst), 3); assert_eq!(FINAL_JS.load(Ordering::SeqCst), 0); - std::thread::spawn(|| { - let value = owner(); + // The worker's payload owns its observations, so TLS teardown can report + // to this test without turning the per-thread sinks into shared globals. + let observations = std::sync::Arc::new([AtomicUsize::new(0), AtomicUsize::new(0)]); + let worker_observations = observations.clone(); + std::thread::spawn(move || { + let value = np::alloc( + &FAMILY, + Probe { + worker_observations: Some(worker_observations), + ..Default::default() + }, + 0, + &[], + ); let link = np::owner_link(value, &FAMILY).unwrap(); unsafe { np::payload_mut::(value, &FAMILY).unwrap().link = Some(link); @@ -363,7 +384,9 @@ fn t6_t11_finalization_callbacks_stay_out_of_js() { }) .join() .unwrap(); - assert_eq!(DROPS.load(Ordering::SeqCst), 4); + assert_eq!(DROPS.load(Ordering::SeqCst), 3); + assert_eq!(observations[0].load(Ordering::SeqCst), 1); + assert_eq!(observations[1].load(Ordering::SeqCst), 0); assert_eq!(FINAL_JS.load(Ordering::SeqCst), 0); } diff --git a/crates/perry-runtime/src/gc/tests/native_payload_callbacks/lifecycle.rs b/crates/perry-runtime/src/gc/tests/native_payload_callbacks/lifecycle.rs index 02eece21c6..358b677bbb 100644 --- a/crates/perry-runtime/src/gc/tests/native_payload_callbacks/lifecycle.rs +++ b/crates/perry-runtime/src/gc/tests/native_payload_callbacks/lifecycle.rs @@ -90,7 +90,8 @@ fn l5_teardown_finalized_cell_cannot_attach() { // Same size/alignment as Probe, but a different destructor. A safe attach // must never install this under Probe's retained drop thunk. -struct OtherProbe([usize; 2]); +const PROBE_WORDS: usize = std::mem::size_of::() / std::mem::size_of::(); +struct OtherProbe([usize; PROBE_WORDS]); impl Drop for OtherProbe { fn drop(&mut self) { CALLS.fetch_add(self.0[0], Ordering::SeqCst); @@ -131,7 +132,10 @@ fn reopen_preserves_object_cell_properties_and_serial_identity() { np::attach( value.get_nanbox_f64(), &FAMILY, - Probe { link: Some(link) }, + Probe { + link: Some(link), + worker_observations: None, + }, 4096 ), Ok(()) @@ -182,7 +186,7 @@ fn reopen_preserves_object_cell_properties_and_serial_identity() { std::mem::align_of::() ); assert_eq!( - np::attach(value.get_nanbox_f64(), &FAMILY, OtherProbe([1, 0]), 0), + np::attach(value.get_nanbox_f64(), &FAMILY, OtherProbe([1; PROBE_WORDS]), 0), Err(AttachMiss::Foreign) ); assert_eq!( @@ -284,6 +288,7 @@ fn l8_worker_discards_queued_items_before_finalization_without_dispatch() { unsafe { np::link_ref(link) }; let mut queue = vec![link]; // plain queued data; Drop never dereferences a link assert_eq!(np::close(value, &FAMILY), CloseOutcome::Closed); + assert_eq!(DROPS.load(Ordering::SeqCst), 1); let mut dispatches = 0; if np::lifecycle_sabotage("teardown_drain") { unsafe { crate::native_handle::finalize_native_handle_at_teardown(cell(link)) }; @@ -296,6 +301,7 @@ fn l8_worker_discards_queued_items_before_finalization_without_dispatch() { } else { queue.clear(); } + assert_eq!(CALLS.load(Ordering::SeqCst), 0); // TLS heap teardown finalizes even though the queue's ref pin remains. dispatches }) @@ -307,8 +313,6 @@ fn l8_worker_discards_queued_items_before_finalization_without_dispatch() { before + 1, "a pending ref cannot leak the worker cell" ); - assert_eq!(DROPS.load(Ordering::SeqCst), 1); - assert_eq!(CALLS.load(Ordering::SeqCst), 0); } extern "C" fn closes_then_throws( diff --git a/crates/perry-runtime/src/gc/tests/net_transport.rs b/crates/perry-runtime/src/gc/tests/net_transport.rs index 51245ce74b..b0c0157947 100644 --- a/crates/perry-runtime/src/gc/tests/net_transport.rs +++ b/crates/perry-runtime/src/gc/tests/net_transport.rs @@ -637,6 +637,7 @@ fn n6_teardown_discards_owed_completions_without_dispatch() { } assert!(destroy(link)); drop(scope); + assert_eq!(DROPS.load(Ordering::SeqCst), 1); // The agent's teardown sets the discard rule before its last turns. transport::begin_teardown_for_test(); assert!( @@ -655,7 +656,6 @@ fn n6_teardown_discards_owed_completions_without_dispatch() { before + 1, "the worker heap teardown finalizes the pinned cell" ); - assert_eq!(DROPS.load(Ordering::SeqCst), 1); } /// The ABI digest folds the opaque core block: a binding built against From fe996cd444cce9163d85851d6ef145b2c7a2c02c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 9 Oct 2026 07:18:05 +0200 Subject: [PATCH 11/18] fix(runtime): place transport ABI tests after production exports --- .../src/native_payload_transport_abi.rs | 278 +++++++++--------- 1 file changed, 139 insertions(+), 139 deletions(-) diff --git a/crates/perry-runtime/src/native_payload_transport_abi.rs b/crates/perry-runtime/src/native_payload_transport_abi.rs index 2343d5fd1c..ed1db58d93 100644 --- a/crates/perry-runtime/src/native_payload_transport_abi.rs +++ b/crates/perry-runtime/src/native_payload_transport_abi.rs @@ -210,145 +210,6 @@ mod keepalive { static PROTO_INHERIT: unsafe extern "C" fn(*mut c_void, f64) = js_perry_payload_proto_inherit; } -#[cfg(test)] -mod tests { - use super::*; - use std::sync::atomic::{AtomicUsize, Ordering}; - per_test_global! { static DROPS: AtomicUsize = AtomicUsize::new(0); } - unsafe extern "C" fn drop_bytes(resource: *mut c_void, _: *mut c_void) { - drop(Box::from_raw(resource as *mut Vec)); - DROPS.fetch_add(1, Ordering::SeqCst); - } - static VTABLE: np::PayloadVTable = np::PayloadVTable { - drop: drop_bytes, - stream: None, - }; - fn family() -> PerryPayloadFamily { - PerryPayloadFamily { - abi: payload_abi_layout(), - class_id: crate::native_class_ids::CRYPTO_HASH, - links_owner: 1, - constructor_length: 1, - _reserved: 0, - name: b"Bytes".as_ptr(), - name_len: 5, - install_prototype: None, - vtable: &VTABLE, - payload_size: std::mem::size_of::>(), - payload_align: std::mem::align_of::>(), - } - } - #[test] - fn binding_reopen_preserves_the_stream_record_cell_and_owner_link() { - DROPS.store(0, Ordering::SeqCst); - let family = family(); - let scope = crate::gc::RuntimeHandleScope::new(); - let proto = scope.root_nanbox_f64(crate::value::js_nanbox_pointer( - crate::object::js_object_alloc(0, 0) as i64, - )); - let owner = scope.root_nanbox_f64(unsafe { - js_perry_payload_alloc(&family, std::ptr::null_mut(), proto.get_nanbox_f64(), 0) - }); - unsafe { - assert_eq!( - js_perry_payload_lifecycle(owner.get_nanbox_f64(), &family), - 3 - ); - let cell = family_cell(owner.get_nanbox_f64(), &family).unwrap(); - let link = js_perry_payload_owner_link(owner.get_nanbox_f64(), &family); - assert_eq!(link, cell as usize); - js_perry_payload_link_ref(link); - let data = Box::into_raw(Box::new(vec![19u8; 8192])); - assert_eq!( - js_perry_payload_reopen(owner.get_nanbox_f64(), &family, data.cast(), 8192), - 0 - ); - crate::node_stream::init_transform_in_place(owner.get_nanbox_f64(), bytes_undefined()); - let obj = np::any_object(owner.get_nanbox_f64()).unwrap(); - let record = (*(*obj).meta).native_state; - assert!( - crate::node_stream::is_stream_record_word(record), - "subject must have a stream state record" - ); - assert_eq!(family_cell(owner.get_nanbox_f64(), &family), Some(cell)); - assert_eq!( - js_perry_payload_get(owner.get_nanbox_f64(), &family), - data.cast() - ); - assert_eq!((*cell).external_bytes, 8192); - let refused = Box::into_raw(Box::new(vec![0u8; 1])); - assert_eq!( - js_perry_payload_reopen(owner.get_nanbox_f64(), &family, refused.cast(), 1), - 5 - ); - drop(Box::from_raw(refused)); - assert_eq!(js_perry_payload_close(owner.get_nanbox_f64(), &family), 0); - assert_eq!(DROPS.load(Ordering::SeqCst), 1); - assert_eq!((*cell).external_bytes, 0); - assert_eq!((*cell).finalized, 0); - let mut event_owner = 0.0; - assert_eq!(js_perry_payload_link_event_owner(link, &mut event_owner), 1); - assert_eq!(event_owner.to_bits(), owner.get_nanbox_f64().to_bits()); - let next = Box::into_raw(Box::new(vec![7u8; 4096])); - assert_eq!( - js_perry_payload_reopen(owner.get_nanbox_f64(), &family, next.cast(), 4096), - 0 - ); - assert_eq!( - js_perry_payload_owner_link(owner.get_nanbox_f64(), &family), - link - ); - let obj = np::any_object(owner.get_nanbox_f64()).unwrap(); - assert_eq!( - (*(*obj).meta).native_state, - record, - "one native_state word; do not replace the stream record" - ); - assert_eq!( - js_perry_payload_get(owner.get_nanbox_f64(), &family), - next.cast() - ); - js_perry_payload_close(owner.get_nanbox_f64(), &family); - js_perry_payload_link_unref(link); - assert_eq!((*cell).refs, 0); - assert_eq!(DROPS.load(Ordering::SeqCst), 2); - } - } - #[test] - fn binding_owner_link_and_js_state_use_the_attached_subclass_cell() { - let family = family(); - let scope = crate::gc::RuntimeHandleScope::new(); - let owner = scope.root_nanbox_f64(crate::value::js_nanbox_pointer( - crate::object::js_object_alloc(123456, 0) as i64, - )); - unsafe { - let data = Box::into_raw(Box::new(vec![19u8; 4])); - assert_eq!( - js_perry_payload_attach(owner.get_nanbox_f64(), &family, data.cast(), 4), - 0 - ); - let cell = family_cell(owner.get_nanbox_f64(), &family).unwrap(); - let link = js_perry_payload_owner_link(owner.get_nanbox_f64(), &family); - assert_eq!(link, cell as usize); - let state = scope.root_nanbox_f64(js_perry_payload_js_state( - owner.get_nanbox_f64(), - &family, - 1, - )); - assert!(np::any_object(state.get_nanbox_f64()).is_some()); - assert_eq!( - state.get_nanbox_f64().to_bits(), - js_perry_payload_js_state(owner.get_nanbox_f64(), &family, 0).to_bits() - ); - assert_eq!(js_perry_payload_close(owner.get_nanbox_f64(), &family), 0); - assert_eq!( - js_perry_payload_lifecycle(owner.get_nanbox_f64(), &family), - 3 - ); - } - } -} - /// The typed cell behind a link, OPEN or CLOSED, on its creator thread. unsafe fn link_family_cell( link: usize, @@ -588,3 +449,142 @@ pub unsafe extern "C" fn js_perry_payload_proto_data( pub unsafe extern "C" fn js_perry_payload_proto_inherit(proto: *mut c_void, parent: f64) { np::PayloadPrototype::from_raw(proto.cast()).inherit(parent); } + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + per_test_global! { static DROPS: AtomicUsize = AtomicUsize::new(0); } + unsafe extern "C" fn drop_bytes(resource: *mut c_void, _: *mut c_void) { + drop(Box::from_raw(resource as *mut Vec)); + DROPS.fetch_add(1, Ordering::SeqCst); + } + static VTABLE: np::PayloadVTable = np::PayloadVTable { + drop: drop_bytes, + stream: None, + }; + fn family() -> PerryPayloadFamily { + PerryPayloadFamily { + abi: payload_abi_layout(), + class_id: crate::native_class_ids::CRYPTO_HASH, + links_owner: 1, + constructor_length: 1, + _reserved: 0, + name: b"Bytes".as_ptr(), + name_len: 5, + install_prototype: None, + vtable: &VTABLE, + payload_size: std::mem::size_of::>(), + payload_align: std::mem::align_of::>(), + } + } + #[test] + fn binding_reopen_preserves_the_stream_record_cell_and_owner_link() { + DROPS.store(0, Ordering::SeqCst); + let family = family(); + let scope = crate::gc::RuntimeHandleScope::new(); + let proto = scope.root_nanbox_f64(crate::value::js_nanbox_pointer( + crate::object::js_object_alloc(0, 0) as i64, + )); + let owner = scope.root_nanbox_f64(unsafe { + js_perry_payload_alloc(&family, std::ptr::null_mut(), proto.get_nanbox_f64(), 0) + }); + unsafe { + assert_eq!( + js_perry_payload_lifecycle(owner.get_nanbox_f64(), &family), + 3 + ); + let cell = family_cell(owner.get_nanbox_f64(), &family).unwrap(); + let link = js_perry_payload_owner_link(owner.get_nanbox_f64(), &family); + assert_eq!(link, cell as usize); + js_perry_payload_link_ref(link); + let data = Box::into_raw(Box::new(vec![19u8; 8192])); + assert_eq!( + js_perry_payload_reopen(owner.get_nanbox_f64(), &family, data.cast(), 8192), + 0 + ); + crate::node_stream::init_transform_in_place(owner.get_nanbox_f64(), bytes_undefined()); + let obj = np::any_object(owner.get_nanbox_f64()).unwrap(); + let record = (*(*obj).meta).native_state; + assert!( + crate::node_stream::is_stream_record_word(record), + "subject must have a stream state record" + ); + assert_eq!(family_cell(owner.get_nanbox_f64(), &family), Some(cell)); + assert_eq!( + js_perry_payload_get(owner.get_nanbox_f64(), &family), + data.cast() + ); + assert_eq!((*cell).external_bytes, 8192); + let refused = Box::into_raw(Box::new(vec![0u8; 1])); + assert_eq!( + js_perry_payload_reopen(owner.get_nanbox_f64(), &family, refused.cast(), 1), + 5 + ); + drop(Box::from_raw(refused)); + assert_eq!(js_perry_payload_close(owner.get_nanbox_f64(), &family), 0); + assert_eq!(DROPS.load(Ordering::SeqCst), 1); + assert_eq!((*cell).external_bytes, 0); + assert_eq!((*cell).finalized, 0); + let mut event_owner = 0.0; + assert_eq!(js_perry_payload_link_event_owner(link, &mut event_owner), 1); + assert_eq!(event_owner.to_bits(), owner.get_nanbox_f64().to_bits()); + let next = Box::into_raw(Box::new(vec![7u8; 4096])); + assert_eq!( + js_perry_payload_reopen(owner.get_nanbox_f64(), &family, next.cast(), 4096), + 0 + ); + assert_eq!( + js_perry_payload_owner_link(owner.get_nanbox_f64(), &family), + link + ); + let obj = np::any_object(owner.get_nanbox_f64()).unwrap(); + assert_eq!( + (*(*obj).meta).native_state, + record, + "one native_state word; do not replace the stream record" + ); + assert_eq!( + js_perry_payload_get(owner.get_nanbox_f64(), &family), + next.cast() + ); + js_perry_payload_close(owner.get_nanbox_f64(), &family); + js_perry_payload_link_unref(link); + assert_eq!((*cell).refs, 0); + assert_eq!(DROPS.load(Ordering::SeqCst), 2); + } + } + #[test] + fn binding_owner_link_and_js_state_use_the_attached_subclass_cell() { + let family = family(); + let scope = crate::gc::RuntimeHandleScope::new(); + let owner = scope.root_nanbox_f64(crate::value::js_nanbox_pointer( + crate::object::js_object_alloc(123456, 0) as i64, + )); + unsafe { + let data = Box::into_raw(Box::new(vec![19u8; 4])); + assert_eq!( + js_perry_payload_attach(owner.get_nanbox_f64(), &family, data.cast(), 4), + 0 + ); + let cell = family_cell(owner.get_nanbox_f64(), &family).unwrap(); + let link = js_perry_payload_owner_link(owner.get_nanbox_f64(), &family); + assert_eq!(link, cell as usize); + let state = scope.root_nanbox_f64(js_perry_payload_js_state( + owner.get_nanbox_f64(), + &family, + 1, + )); + assert!(np::any_object(state.get_nanbox_f64()).is_some()); + assert_eq!( + state.get_nanbox_f64().to_bits(), + js_perry_payload_js_state(owner.get_nanbox_f64(), &family, 0).to_bits() + ); + assert_eq!(js_perry_payload_close(owner.get_nanbox_f64(), &family), 0); + assert_eq!( + js_perry_payload_lifecycle(owner.get_nanbox_f64(), &family), + 3 + ); + } + } +} From 6c55211f97fb8f5877f9d390068527117fb8de78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 9 Oct 2026 07:56:48 +0200 Subject: [PATCH 12/18] test(net): reject wider open flags and require the payload struct field --- .../check_ext_net_socket_open_invariant.py | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/scripts/check_ext_net_socket_open_invariant.py b/scripts/check_ext_net_socket_open_invariant.py index eef8eff901..2029063851 100755 --- a/scripts/check_ext_net_socket_open_invariant.py +++ b/scripts/check_ext_net_socket_open_invariant.py @@ -104,7 +104,7 @@ def mask_non_code(source: str, keep_strings: bool = False) -> str: OPEN_NAME = re.compile(r"open", re.I) -FIELD = re.compile(r"\b(\w+)\s*:\s*(?:bool|Atomic\w+|Cell|Option|u8|u32|u64|usize|i32|i64)\b") +FIELD = re.compile(r"\b(\w+)\s*:\s*(?:[A-Za-z_]\w*\s*::\s*)*(?:bool|Atomic\w+|Cell|Option|OnceCell|OnceLock|LazyLock|[ui](?:8|16|32|64|128|size))\b") TABLE = re.compile(r"\b(?:HashMap|BTreeMap|DashMap|IndexMap|HashSet|BTreeSet|Slab)\b") CANONICAL = ("crates/perry-ext-net/src/payload_transport.rs", "opened") TRANSITIONS = { @@ -113,6 +113,18 @@ def mask_non_code(source: str, keep_strings: bool = False) -> str: "crates/perry-ext-net/src/payload_server.rs", } +def socket_fields_ranges(code): + """Find each SocketFields body without admitting later function bodies.""" + ranges = [] + for declaration in re.finditer(r"\bstruct\s+SocketFields\s*\{", code): + depth = 1 + for index in range(declaration.end(), len(code)): + depth += (code[index] == "{") - (code[index] == "}") + if depth == 0: + ranges.append((declaration.end(), index)) + break + return ranges + def evaluate(sources, registry): errors, sites, used = [], [], set() exceptions = {(e["path"], e["name"]): e for e in registry["exceptions"]} @@ -120,6 +132,7 @@ def evaluate(sources, registry): transitions = set() for path, source in sources.items(): code = mask_non_code(source) + payload_ranges = socket_fields_ranges(code) if path == CANONICAL[0] else [] for match in FIELD.finditer(code): name = match[1] if not OPEN_NAME.search(name): @@ -129,9 +142,7 @@ def evaluate(sources, registry): if key == CANONICAL: # The declaration must belong to the payload extension, # rather than a replacement flag in another struct. - prefix = code[:match.start()] - head = prefix.rfind("struct SocketFields") - if head >= 0 and prefix[head:].count("{") > prefix[head:].count("}"): + if any(start <= match.start() < end for start, end in payload_ranges): canonical += 1 else: errors.append(f"{path}: opened is outside SocketFields") @@ -164,11 +175,13 @@ def self_test(): good.update({path: "fn connected() { s.opened = true; }" for path in TRANSITIONS}) registry = {"exceptions": []} assert not evaluate(good, registry)[1] - for bad in ["struct Second { is_open: bool }", "struct Second { has_opened: bool }", "static OPEN: AtomicBool = AtomicBool::new(false);", "struct Second { opened: Option }", "struct Second { is_open: u8 }", "static FLAGS: HashMap = todo!();", 'fn f() { p::own_set(owner, "bunOpened", true); }']: + for bad in ["struct Second { is_open: bool }", "struct Second { has_opened: bool }", "static OPEN: AtomicBool = AtomicBool::new(false);", "struct Second { opened: Option }", "struct Second { is_open: u8 }", "struct Second { is_open: u16 }", "static OPENED: OnceLock = OnceLock::new();", "static FLAGS: HashMap = todo!();", 'fn f() { p::own_set(owner, "bunOpened", true); }']: assert evaluate(dict(good, extra=bad), registry)[1], bad assert evaluate({}, registry)[1] assert evaluate({CANONICAL[0]: good[CANONICAL[0]]}, registry)[1] assert evaluate(dict(good, **{CANONICAL[0]: "struct Other { opened: bool }"}), registry)[1] + assert evaluate(dict(good, **{CANONICAL[0]: "struct SocketFields {} fn fake() { let opened: bool; }"}), registry)[1] + assert evaluate(dict(good, extra="static OPENED: std::sync::atomic::AtomicBool = todo!();"), registry)[1] assert evaluate(good, {"exceptions": [{"path": "gone", "name": "open", "reason": "x"*50}]})[1] assert not evaluate(dict(good, extra='// own_set(owner, "bunOpened", true);\n// is_open: bool\n/* HashMap */ fn f() { let s = "has_opened: bool"; }'), registry)[1] print("socket-open self-test: OK (duplicate fields, atomic flag, table, JS latch, empty census, stale exception, masking)") From 3fbda92f006085112863db8661a03968653266d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 9 Oct 2026 08:38:42 +0200 Subject: [PATCH 13/18] docs(net): record final validation and performance acceptance blockers --- changelog.d/12064-net-transport-phase-a.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/changelog.d/12064-net-transport-phase-a.md b/changelog.d/12064-net-transport-phase-a.md index c1138d1c5f..f30e128273 100644 --- a/changelog.d/12064-net-transport-phase-a.md +++ b/changelog.d/12064-net-transport-phase-a.md @@ -30,3 +30,24 @@ upgrade parameter hint; delete them in the immediate follow-up. Linux ext-ws lib tests fail to compile on main (`js_shadow_frame_push` is absent with native stack maps). Main also fails issue_9619's manual/callback-only path and `nested_object_literal_ws_inbound`: ws handleUpgrade has no raw-socket path. + +Validation uses main a9834307e829 (the newest origin/main at the final rebase) +and separate qb6 targets. Script lint: main 102/125 pass, lane 103/125 pass; +the remaining failures and strict Clippy failures are inherited, with no new +diagnostics. Lean dependencies and pruned feature builds pass. The full lane +gap subset, with its echo fixture running, is 88 pass / 15 mismatch / 0 timeout +against the supplied main's 85 / 16 / 1: zero regressions. The worker completes +25 rounds, ws_client passes, and the new upgrade fixture passes. + +This is a review bundle, with performance acceptance still blocked. All eight +programs match Node for five interleaved instructions/RSS runs on CPUs 0-55 +with ASLR disabled. HTTP 10k adds 24.606% instructions and net echo 10k adds +36.939%; repeated payload validation/root access and event argument allocation +outweigh the deleted table lookups. Net's extra minor collection promotes an +old arena; its RSS increase survives THP-off. Fix-forward: reuse validation +within a callback-free operation, use existing rooted emitter arguments, and +avoid empty write-ack callback/provider work while preserving async_hooks. +The small tsc increase (+0.029%) is not fully attributed. P0's global +bound-method constructor guard also compares module/export names; replace +that special case with captured canonical constructor metadata to meet the +architecture policy. No performance acceptance is claimed for this bundle. From db35623df8d2e2f53cf0c45d4ff0891d1ea1221f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 9 Oct 2026 12:30:02 +0200 Subject: [PATCH 14/18] fix(runtime): select net constructor families from immutable class facts Canonical native exports capture their class identity in a distinct constructor layout. Both explicit receiver calls and dynamic super read that fact, with no constructor-name comparisons or new side table. Ordinary bound methods skip native receiver validation. The fixture mutates function names and prototype constructor properties before call, Reflect.apply and aliased super. --- .../src/closure/dispatch/bound.rs | 63 +++++++++---------- crates/perry-runtime/src/closure/mod.rs | 10 +-- crates/perry-runtime/src/closure/registry.rs | 30 +++++++++ .../src/object/global_this/fetch_globals.rs | 52 ++++++++------- .../object/native_module/callable_exports.rs | 23 ++++++- ...test_gap_net_constructor_class_identity.ts | 31 +++++++++ 6 files changed, 146 insertions(+), 63 deletions(-) create mode 100644 test-files/test_gap_net_constructor_class_identity.ts diff --git a/crates/perry-runtime/src/closure/dispatch/bound.rs b/crates/perry-runtime/src/closure/dispatch/bound.rs index 6c94e27ae6..8ed598b9d2 100644 --- a/crates/perry-runtime/src/closure/dispatch/bound.rs +++ b/crates/perry-runtime/src/closure/dispatch/bound.rs @@ -66,44 +66,39 @@ pub unsafe fn dispatch_bound_method( let mut namespace_obj = js_closure_get_capture_f64(closure, 0); let method_name_ptr = js_closure_get_capture_ptr(closure, 1) as *const i8; let method_name_len = js_closure_get_capture_ptr(closure, 2) as usize; - let private_brand = (crate::closure::real_capture_count((*closure).capture_count) >= 4) - .then(|| js_closure_get_capture_f64(closure, 3)); - - // Callable native net constructors initialize an explicit receiver, just - // as their proven super() path does. Namespace calls still allocate a new - // owner. The constructor's captured export metadata selects the family. - let receiver = this.as_f64(); - let receiver_object = crate::value::JSValue::from_bits(receiver.to_bits()); - if receiver_object.is_pointer() - && receiver.to_bits() != namespace_obj.to_bits() - && crate::object::js_object_get_class_id(crate::value::js_nanbox_get_pointer(namespace_obj) - as *const crate::object::ObjectHeader) - == crate::object::NATIVE_MODULE_CLASS_ID - { - let address = receiver_object.as_pointer::() as usize; - if matches!(crate::value::addr_class::try_read_gc_header(address), Some(header) if header.obj_type == crate::gc::GC_TYPE_OBJECT) - && crate::object::js_object_get_class_id(address as *const crate::object::ObjectHeader) - != crate::object::NATIVE_MODULE_CLASS_ID - { - if let Some((module, export)) = - crate::object::native_module::bound_native_callable_module_and_method( - crate::value::js_nanbox_pointer(closure as i64), - ) + // Only the constructor info has a fourth class-identity capture. Ordinary + // bound methods pay one static info comparison, never object validation or + // native namespace/name resolution. Function name/prototype mutations do + // not alter a constructor's family. + let native_constructor = std::ptr::eq( + (*closure).info, + &crate::closure::BOUND_NATIVE_CONSTRUCTOR_INFO, + ); + if native_constructor { + let receiver = this.as_f64(); + let receiver_object = crate::value::JSValue::from_bits(receiver.to_bits()); + if receiver_object.is_pointer() && receiver.to_bits() != namespace_obj.to_bits() { + let address = receiver_object.as_pointer::() as usize; + if matches!(crate::value::addr_class::try_read_gc_header(address), Some(header) if header.obj_type == crate::gc::GC_TYPE_OBJECT) + && crate::object::js_object_get_class_id( + address as *const crate::object::ObjectHeader, + ) != crate::object::NATIVE_MODULE_CLASS_ID { - if crate::object::native_module::normalize_native_module_alias(&module) == "net" - && matches!(export.as_str(), "Socket" | "Stream" | "Server") - { - let undefined = f64::from_bits(crate::value::TAG_UNDEFINED); - return crate::object::js_net_subclass_init( - receiver, - args.first().copied().unwrap_or(undefined), - args.get(1).copied().unwrap_or(undefined), - u32::from(export == "Server"), - ); - } + let class = js_closure_get_capture_f64(closure, 3) as u32; + let undefined = f64::from_bits(crate::value::TAG_UNDEFINED); + return crate::object::js_net_subclass_init( + receiver, + args.first().copied().unwrap_or(undefined), + args.get(1).copied().unwrap_or(undefined), + u32::from(class == crate::native_class_ids::NET_SERVER), + ); } } } + // The constructor capture is a class id, not a private method brand. + let private_brand = (!native_constructor + && crate::closure::real_capture_count((*closure).capture_count) >= 4) + .then(|| js_closure_get_capture_f64(closure, 3)); // #6173: a SYMBOL-keyed class method read as a value — there is no name to // re-resolve; the captures carry the already-resolved func_ptr + arity diff --git a/crates/perry-runtime/src/closure/mod.rs b/crates/perry-runtime/src/closure/mod.rs index 7df472a6d1..c111e4fff5 100644 --- a/crates/perry-runtime/src/closure/mod.rs +++ b/crates/perry-runtime/src/closure/mod.rs @@ -51,14 +51,14 @@ pub use registry::{ closure_is_bound_method, closure_length, dispatch_rest_bundled, dispatch_with_arity, info_arity, info_has, info_length, info_rest, real_capture_count, resolve_strategy, DispatchStrategy, JsFunctionInfo, RestDispatchKind, BOUND_FUNCTION_FUNC_PTR, - BOUND_FUNCTION_INFO, BOUND_METHOD_FUNC_PTR, BOUND_METHOD_INFO, CAPTURES_THIS_FLAG, FN_ARROW, - FN_ASYNC, FN_ASYNC_GENERATOR, FN_BUILTIN, FN_GENERATOR, FN_HAS_DECLARED, FN_HAS_LENGTH, - FN_NON_CONSTRUCTOR, FN_REST_MASK, FN_REST_SYNTHETIC_ARGUMENTS, FN_REST_USER, - FN_REST_USER_AND_ARGUMENTS, FN_STRICT, NO_THIS_REBIND_FLAG, + BOUND_FUNCTION_INFO, BOUND_METHOD_FUNC_PTR, BOUND_METHOD_INFO, BOUND_NATIVE_CONSTRUCTOR_INFO, + CAPTURES_THIS_FLAG, FN_ARROW, FN_ASYNC, FN_ASYNC_GENERATOR, FN_BUILTIN, FN_GENERATOR, + FN_HAS_DECLARED, FN_HAS_LENGTH, FN_NON_CONSTRUCTOR, FN_REST_MASK, FN_REST_SYNTHETIC_ARGUMENTS, + FN_REST_USER, FN_REST_USER_AND_ARGUMENTS, FN_STRICT, NO_THIS_REBIND_FLAG, }; pub(crate) use registry::{ closure_body_is_non_constructor, info_receives_primitive_this, info_trusted_direct, - info_versioned_loop_direct, + info_versioned_loop_direct, native_constructor_class_id, }; pub(crate) use dispatch::{ diff --git a/crates/perry-runtime/src/closure/registry.rs b/crates/perry-runtime/src/closure/registry.rs index 76a43fb244..0c5fa12d1e 100644 --- a/crates/perry-runtime/src/closure/registry.rs +++ b/crates/perry-runtime/src/closure/registry.rs @@ -513,6 +513,36 @@ pub const BOUND_FUNCTION_FUNC_PTR: *const u8 = 0xBADD_B12D_u64 as *const u8; pub static BOUND_METHOD_INFO: JsFunctionInfo = unsafe { JsFunctionInfo::from_code(BOUND_METHOD_FUNC_PTR, 0) }; +/// A native constructor's bound export has the ordinary three export captures +/// plus its immutable class identity. The distinct info describes that capture +/// layout; dispatch never infers a family from mutable function properties. +// SAFETY: this is the same routed sentinel, never a callable code address. +pub static BOUND_NATIVE_CONSTRUCTOR_INFO: JsFunctionInfo = + unsafe { JsFunctionInfo::from_code(BOUND_METHOD_FUNC_PTR, 2) }; + +/// A constructor family's immutable capture fact, independent of all JS +/// names and prototype properties. Export resolution installs this layout. +pub(crate) fn native_constructor_class_id(value: f64) -> Option { + let value = crate::value::JSValue::from_bits(value.to_bits()); + if !value.is_pointer() { + return None; + } + let address = value.as_pointer::() as usize; + let header = unsafe { crate::value::addr_class::try_read_tracked_gc_header(address) }?; + if unsafe { header.as_ref().obj_type } != crate::gc::GC_TYPE_CLOSURE { + return None; + } + unsafe { + let closure = address as *const ClosureHeader; + if !std::ptr::eq((*closure).info, &BOUND_NATIVE_CONSTRUCTOR_INFO) + || real_capture_count((*closure).capture_count) != 4 + { + return None; + } + Some(js_closure_get_capture_f64(closure, 3) as u32) + } +} + /// The info every `Function.prototype.bind` result points to (its "code" is /// the [`BOUND_FUNCTION_FUNC_PTR`] sentinel). // SAFETY: the dispatchers route this sentinel by value; it is never called. diff --git a/crates/perry-runtime/src/object/global_this/fetch_globals.rs b/crates/perry-runtime/src/object/global_this/fetch_globals.rs index 4d24b77a13..950f12b764 100644 --- a/crates/perry-runtime/src/object/global_this/fetch_globals.rs +++ b/crates/perry-runtime/src/object/global_this/fetch_globals.rs @@ -662,6 +662,35 @@ pub unsafe extern "C" fn js_fetch_or_value_super( b"Super constructor null is not a constructor", ); } + // Resolve native net heritage from the immutable constructor class fact. + // Alias/import syntax and mutable constructor properties cannot choose it. + let native_class = crate::closure::native_constructor_class_id(parent_val).or_else(|| { + let obj = subclass_this_object_ptr(this_box)?; + crate::closure::native_constructor_class_id( + crate::object::class_registry::js_get_dynamic_parent_value( + crate::object::js_object_get_class_id(obj), + ), + ) + }); + if let Some(class) = native_class { + let options = if args_len > 0 && !args_ptr.is_null() { + *args_ptr + } else { + undef + }; + let callback = if args_len > 1 && !args_ptr.is_null() { + *args_ptr.add(1) + } else { + undef + }; + super::super::native_module_dispatch::js_net_subclass_init( + this_box, + options, + callback, + u32::from(class == crate::native_class_ids::NET_SERVER), + ); + return undef; + } // Resolve the parent to a bound native-module export VALUE, independent // of how the heritage expression reached it: a bare import, a local // alias, a namespace member, and a CJS destructured `require()` all @@ -678,29 +707,6 @@ pub unsafe extern "C" fn js_fetch_or_value_super( crate::object::class_registry::js_get_dynamic_parent_value(cid), ) }); - if let Some((module, export)) = bound_native_parent.as_ref() { - if super::super::native_module::normalize_native_module_alias(module) == "net" - && matches!(export.as_str(), "Socket" | "Stream" | "Server") - { - let options = if args_len > 0 && !args_ptr.is_null() { - *args_ptr - } else { - undef - }; - let callback = if args_len > 1 && !args_ptr.is_null() { - *args_ptr.add(1) - } else { - undef - }; - super::super::native_module_dispatch::js_net_subclass_init( - this_box, - options, - callback, - u32::from(export == "Server"), - ); - return undef; - } - } if bound_native_parent .as_ref() .is_some_and(|(module, method)| { diff --git a/crates/perry-runtime/src/object/native_module/callable_exports.rs b/crates/perry-runtime/src/object/native_module/callable_exports.rs index 9207f48acf..64f150dc21 100644 --- a/crates/perry-runtime/src/object/native_module/callable_exports.rs +++ b/crates/perry-runtime/src/object/native_module/callable_exports.rs @@ -103,7 +103,23 @@ pub fn bound_native_callable_export_value(module_name: &str, property_name: &str callable_module_name.as_ptr(), callable_module_name.len(), )); - let closure = crate::closure::js_closure_alloc(&crate::closure::BOUND_METHOD_INFO, 3); + // Export resolution already publishes the native class identity used by + // instanceof and super. Carry that fact on the canonical constructor; + // invocation must not resolve names again or consult a side table. + let constructor_class = + crate::native_payload::export_class_id(export_module_name, property_name).filter(|class| { + matches!( + *class, + crate::native_class_ids::NET_SOCKET | crate::native_class_ids::NET_SERVER + ) + }); + let info = if constructor_class.is_some() { + &crate::closure::BOUND_NATIVE_CONSTRUCTOR_INFO + } else { + &crate::closure::BOUND_METHOD_INFO + }; + let closure = + crate::closure::js_closure_alloc(info, 3 + u32::from(constructor_class.is_some())); let closure = scope.root_raw_mut_ptr(closure); closure.with_mut_ptr(|c: *mut crate::closure::ClosureHeader| { crate::closure::js_closure_set_capture_f64(c, 0, ns.get_nanbox_f64()); @@ -114,6 +130,11 @@ pub fn bound_native_callable_export_value(module_name: &str, property_name: &str closure.with_mut_ptr(|c: *mut crate::closure::ClosureHeader| { crate::closure::js_closure_set_capture_ptr(c, 2, method_bytes.len() as i64); }); + if let Some(class) = constructor_class { + closure.with_mut_ptr(|c: *mut crate::closure::ClosureHeader| { + crate::closure::js_closure_set_capture_f64(c, 3, class as f64); + }); + } #[cfg(test)] TEST_COLLECT_NATIVE_EXPORT_AFTER_ALLOC.with(|armed| { if armed.replace(false) { diff --git a/test-files/test_gap_net_constructor_class_identity.ts b/test-files/test_gap_net_constructor_class_identity.ts new file mode 100644 index 0000000000..a46e13552f --- /dev/null +++ b/test-files/test_gap_net_constructor_class_identity.ts @@ -0,0 +1,31 @@ +import { Socket, Server } from 'node:net'; + +// Names and constructor properties are user data, not native family facts. +Object.defineProperty(Socket, 'name', { value: 'Server', configurable: true }); +Object.defineProperty(Server, 'name', { value: 'Socket', configurable: true }); +const socketConstructor = Socket.prototype.constructor; +const serverConstructor = Server.prototype.constructor; +Socket.prototype.constructor = Server; +Server.prototype.constructor = Socket; + +function DerivedSocket(this: any) { Socket.call(this); } +Object.setPrototypeOf(DerivedSocket.prototype, Socket.prototype); +function DerivedServer(this: any) { Reflect.apply(Server, this, []); } +Object.setPrototypeOf(DerivedServer.prototype, Server.prototype); +const socket = new (DerivedSocket as any)(); +const server = new (DerivedServer as any)(); +console.log('socket', typeof socket.write, typeof socket.connect, socket.destroyed); +console.log('server', typeof server.listen, typeof server.getConnections, server.listening); +const SocketAlias: any = Socket; +const ServerAlias: any = Server; +class ClassSocket extends SocketAlias { constructor() { super(); } } +class ClassServer extends ServerAlias { constructor() { super(); } } +const classSocket = new ClassSocket(); +const classServer = new ClassServer(); +console.log('class socket', typeof classSocket.write, classSocket.destroyed); +console.log('class server', typeof classServer.listen, classServer.listening); +classSocket.destroy(); +socket.destroy(); +console.log('destroyed', socket.destroyed); +Socket.prototype.constructor = socketConstructor; +Server.prototype.constructor = serverConstructor; From 831da42919850b1a297557040d056a443c250eaa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 9 Oct 2026 12:32:14 +0200 Subject: [PATCH 15/18] perf(net): share event roots and proven payloads across native work Use callback-free own-data record reads and barriered overwrites instead of allocating field keys. Project each data completion once and batch parser and native write/deadline effects within that proof. Share the emitter/provider dispatch scope, keep short argument spans inline, and avoid JS buffers when no listener can receive them or provider work for empty write acknowledgements. End every native borrow before JS, close or reopen. Add moving inline/overflow-edge and borrowed-handle-scope witnesses; keep the issuer-owned HTTP pending-event scanner custody accurate after the main rebase. --- .../src/client_turnloop/native_client.rs | 127 +++++-- crates/perry-ext-http/src/lib.rs | 19 +- .../src/server/turnloop_h2/native_server.rs | 6 +- .../src/server/turnloop_serve/conn.rs | 318 ++++++++++-------- crates/perry-ext-http/src/server/types.rs | 15 +- crates/perry-ext-http/src/tests.rs | 4 +- crates/perry-ext-net/src/jsvalue.rs | 16 +- crates/perry-ext-net/src/native_transport.rs | 193 ++++++++++- crates/perry-ext-net/src/payload_abi.rs | 2 +- crates/perry-ext-net/src/payload_closed.rs | 20 +- crates/perry-ext-net/src/payload_events.rs | 85 +++-- crates/perry-ext-net/src/payload_io.rs | 84 ++++- crates/perry-ext-net/src/payload_pipe.rs | 26 +- crates/perry-ext-net/src/payload_prototype.rs | 26 +- crates/perry-ext-net/src/payload_provider.rs | 27 +- crates/perry-ext-net/src/payload_server.rs | 73 ++-- crates/perry-ext-net/src/payload_socket.rs | 292 ++++++++++------ crates/perry-ext-net/src/payload_tls.rs | 59 ++-- crates/perry-ext-net/src/payload_transport.rs | 33 +- crates/perry-ffi/src/jsvalue.rs | 28 ++ crates/perry-ffi/src/lib.rs | 3 +- crates/perry-ffi/src/native_payload_links.rs | 41 +++ crates/perry-ffi/src/transient_roots.rs | 9 + crates/perry-runtime/src/async_hooks.rs | 2 +- .../src/async_hooks/provider_ffi.rs | 9 + .../src/gc/roots/runtime_handles.rs | 23 ++ .../src/gc/roots/runtime_handles/tests.rs | 26 ++ .../src/gc/tests/rooted_container_values.rs | 77 +++++ crates/perry-runtime/src/native_payload.rs | 2 +- .../src/native_payload_event_abi.rs | 91 +++++ .../src/native_payload_transport_abi.rs | 69 ++++ crates/perry-runtime/src/node_stream.rs | 9 +- .../src/node_stream_event_emitter.rs | 47 ++- .../perry-runtime/src/object/field_get_set.rs | 4 +- .../src/object/field_get_set/accessors.rs | 53 +++ scripts/gc_runtime_root_holders.json | 11 +- 36 files changed, 1423 insertions(+), 506 deletions(-) diff --git a/crates/perry-ext-http/src/client_turnloop/native_client.rs b/crates/perry-ext-http/src/client_turnloop/native_client.rs index 5f72166c4a..0cd8bf6b55 100644 --- a/crates/perry-ext-http/src/client_turnloop/native_client.rs +++ b/crates/perry-ext-http/src/client_turnloop/native_client.rs @@ -40,13 +40,14 @@ pub(super) fn available() -> bool { /// `f` returns owned Rust data. End every projection before performing an /// effect, allocating JS, closing a parser or touching the driver. fn with_conn(socket: &RootedSocket, f: impl FnOnce(&mut Conn) -> R) -> Option { - if !socket.is_current() || net::socket_link(socket.value()).is_err() { - return None; + unsafe { + socket + .with_current(|_, state| { + let parser = net::record_get(state, PARSER_EDGE); + np::payload_mut::(parser, &PARSER).ok().map(f) + }) + .flatten() } - let scope = TransientRootScope::enter(); - let state = scope.root_nanbox(net::state(socket.value())); - let parser = scope.root_nanbox(net::own_get(state.get(), PARSER_EDGE)); - unsafe { np::payload_mut::(parser.get(), &PARSER).ok().map(f) } } pub(super) fn reusable(owner: f64, key: &PoolKey) -> bool { @@ -76,14 +77,14 @@ unsafe fn close_parser(owner: f64) { let socket = RootedSocket::new(owner); let scope = TransientRootScope::enter(); let state = scope.root_nanbox(net::state(socket.value())); - let error = scope.root_nanbox(net::own_get(state.get(), "closeError")); + let error = scope.root_nanbox(net::record_get(state.get(), "closeError")); let text = |key: &str| JsValue::from_bits(net::own_get(error.get(), key).to_bits()).to_owned_string(); let code = text("code").unwrap_or_else(|| "ECONNRESET".into()); let message = text("message").unwrap_or_else(|| "socket hang up".into()); let effects = with_conn(&socket, |conn| protocol::on_destroy(conn, &code, &message)).unwrap_or_default(); - let parser = scope.root_nanbox(net::own_get(state.get(), PARSER_EDGE)); + let parser = scope.root_nanbox(net::record_get(state.get(), PARSER_EDGE)); np::close(parser.get(), &PARSER); for effect in effects { match effect { @@ -97,7 +98,7 @@ unsafe fn close_parser(owner: f64) { fn dispose_parser(socket: &RootedSocket) { let scope = TransientRootScope::enter(); let state = scope.root_nanbox(net::state(socket.value())); - let parser = scope.root_nanbox(net::own_get(state.get(), PARSER_EDGE)); + let parser = scope.root_nanbox(net::record_get(state.get(), PARSER_EDGE)); np::close(parser.get(), &PARSER); } @@ -219,11 +220,73 @@ pub(super) fn cancel(_owner: f64, request: Handle) { fn run(socket: &RootedSocket, effects: Vec) { let supplied = JsValue::from_bits( - net::own_get(net::state(socket.value()), "httpClientSupplied").to_bits(), + net::record_get(net::state(socket.value()), "httpClientSupplied").to_bits(), ) .to_bool(); let mut effects = VecDeque::from(effects); - while let Some(effect) = effects.pop_front() { + while !effects.is_empty() { + // Native effects cannot run JS. Keep one proven socket through the + // batch, then end it before close, TLS hooks, upgrade or retry. + let failure = if matches!( + effects.front(), + Some(Effect::Write(_) | Effect::Deadline(_) | Effect::Park(_)) + ) { + unsafe { + socket + .with_native_io(|io| { + loop { + match effects.front() { + Some(Effect::Write(_)) if io.can_write_without_js() => { + let Some(Effect::Write(bytes)) = effects.pop_front() else { + unreachable!() + }; + if let Err(error) = io.write(&bytes, 0) { + return Some(error); + } + } + Some(Effect::Deadline(_)) => { + let Some(Effect::Deadline(ms)) = effects.pop_front() else { + unreachable!() + }; + io.deadline(ms); + } + Some(Effect::Park(policy)) => { + if !io.set_ref(false) { + break; + } + let ms = policy.idle_ms; + effects.pop_front(); + io.deadline(Some(ms)); + #[cfg(test)] + if std::env::var("PERRY_NET_A_CLIENT_SABOTAGE").as_deref() + == Ok("idlepark") + { + // Keep this sabotage at the same semantic boundary. + io.park_deadline(); + } + } + _ => break, + } + } + None + }) + .flatten() + } + } else { + None + }; + if let Some(error) = failure { + effects.extend( + with_conn(socket, |conn| { + protocol::on_error(conn, &error.code, &error.syscall, error.errno as i64) + }) + .unwrap_or_default(), + ); + } + let Some(effect) = effects.pop_front() else { + break; + }; + // App outcomes still settle the original request if a Socket listener // closed/reopened it; the generational check forbids I/O on its new use. match effect { @@ -365,8 +428,8 @@ unsafe extern "C" fn http_socket_error( fn detach_http_errors(socket: &RootedSocket) { let scope = TransientRootScope::enter(); let state = scope.root_nanbox(net::state(socket.value())); - let error = scope.root_nanbox(net::own_get(state.get(), "httpErrorListener")); - let close = scope.root_nanbox(net::own_get(state.get(), "httpCloseListener")); + let error = scope.root_nanbox(net::record_get(state.get(), "httpErrorListener")); + let close = scope.root_nanbox(net::record_get(state.get(), "httpCloseListener")); net::own_set( state.get(), "httpErrorListener", @@ -393,7 +456,7 @@ unsafe extern "C" fn http_socket_closed( net::remove_listener(owner.get(), "error", error.get()); // A previous listener may already have reopened the Socket and installed // another handler. Remove only the handler this closure actually owns. - if net::own_get(state.get(), "httpErrorListener").to_bits() == error.get().to_bits() { + if net::record_get(state.get(), "httpErrorListener").to_bits() == error.get().to_bits() { net::own_set( state.get(), "httpErrorListener", @@ -516,10 +579,31 @@ extern "C" fn sink(completion: *const tl::NetCompletion) { let Some(owner) = (unsafe { np::link_event_owner(link) }) else { return; }; - let socket = RootedSocket::new(owner); - if net::socket_link(socket.value()).is_err() { + if event.kind == tl::NET_DATA { + let bytes = unsafe { event.bytes() }; + let received = unsafe { + RootedSocket::receive( + owner, + event.flags & tl::NET_FLAG_PLAINTEXT != 0, + bytes.len(), + false, + |_, state| { + let parser = net::record_get(state, PARSER_EDGE); + np::payload_mut::(parser, &PARSER) + .ok() + .map(|conn| protocol::on_data(conn, bytes)) + .unwrap_or_default() + }, + ) + }; + match received { + Some((socket, Some(effects))) => run(&socket, effects), + Some((socket, None)) => net::receive_tls(socket.value(), bytes), + None => {} + } return; } + let socket = RootedSocket::new(owner); match event.kind { tl::NET_CONNECT => { unsafe { @@ -528,17 +612,6 @@ extern "C" fn sink(completion: *const tl::NetCompletion) { let effects = with_conn(&socket, protocol::on_connect).unwrap_or_default(); run(&socket, effects); } - tl::NET_DATA => { - let bytes = unsafe { event.bytes() }; - if event.flags & tl::NET_FLAG_PLAINTEXT == 0 && net::tls_installed(socket.value()) { - net::receive_tls(socket.value(), bytes); - } else { - net::received(socket.value(), bytes.len()); - let effects = - with_conn(&socket, |conn| protocol::on_data(conn, bytes)).unwrap_or_default(); - run(&socket, effects); - } - } tl::NET_EOF => { let effects = with_conn(&socket, protocol::on_eof).unwrap_or_default(); net::eof(socket.value()); diff --git a/crates/perry-ext-http/src/lib.rs b/crates/perry-ext-http/src/lib.rs index e1bac3ab57..b0d850f06e 100644 --- a/crates/perry-ext-http/src/lib.rs +++ b/crates/perry-ext-http/src/lib.rs @@ -107,8 +107,8 @@ use bytes::Bytes; use perry_ffi::{ alloc_string, gc_register_mutable_root_scanner_named, get_handle, get_handle_mut, iter_handles_of_mut, json_stringify, notify_main_thread, register_agent_event_pump, - register_handle, with_handle_mut, ArrayHeader, GcRootVisitor, Handle, JsClosure, - JsString, JsValue, ObjectHeader, RawClosureHeader, StringHeader, + register_handle, with_handle_mut, ArrayHeader, Handle, JsClosure, JsString, JsValue, + ObjectHeader, RawClosureHeader, StringHeader, }; use std::collections::HashMap; use std::sync::{Mutex, Once}; @@ -329,16 +329,13 @@ fn client_has_pending() -> bool { } pub(crate) fn push_event(ev: PendingHttpEvent) { - // A completion can be produced by a transport thread acting for another - // agent. Its request (or idle Agent), not the producer, owns the callback. - let owner = match &ev { - PendingHttpEvent::AgentIdleExpire { agent_handle, .. } => { - with_handle_mut::(*agent_handle, |agent| agent.owner_agent) - } - _ => with_handle_mut::(pending_request_handle(&ev), |request| { + // The issuing request owns the callback, including transport completions + // produced on another thread. Idle expiry belongs to the socket payload + // and no longer creates a PendingHttpEvent. + let owner = + with_handle_mut::(pending_request_handle(&ev), |request| { request.owner_agent - }), - }; + }); let Some(owner) = owner else { return }; if let Ok(mut q) = HTTP_PENDING_EVENTS.lock() { q.push((owner, ev)); diff --git a/crates/perry-ext-http/src/server/turnloop_h2/native_server.rs b/crates/perry-ext-http/src/server/turnloop_h2/native_server.rs index 7dcc67dba7..c8cbd8d707 100644 --- a/crates/perry-ext-http/src/server/turnloop_h2/native_server.rs +++ b/crates/perry-ext-http/src/server/turnloop_h2/native_server.rs @@ -27,7 +27,7 @@ fn with_codec(socket: &RootedSocket, f: impl FnOnce(&mut H2Conn) -> R) -> Opt } let scope = TransientRootScope::enter(); let state = scope.root_nanbox(net::state(socket.value())); - let codec = scope.root_nanbox(net::own_get(state.get(), "h2")); + let codec = scope.root_nanbox(net::record_get(state.get(), "h2")); unsafe { np::payload_mut::(codec.get(), &FAMILY).ok().map(f) } } @@ -81,7 +81,7 @@ unsafe fn release(owner: f64) { } let scope = TransientRootScope::enter(); let state = scope.root_nanbox(net::state(socket.value())); - let codec = scope.root_nanbox(net::own_get(state.get(), "h2")); + let codec = scope.root_nanbox(net::record_get(state.get(), "h2")); np::close(codec.get(), &FAMILY); } @@ -194,7 +194,7 @@ unsafe extern "C" fn secure_ready(closure: *const RawClosureHeader, _: JsThis) - }; let scope = TransientRootScope::enter(); let state = scope.root_nanbox(net::state(socket.value())); - let alpn = net::own_get(state.get(), "alpnProtocol"); + let alpn = net::record_get(state.get(), "alpnProtocol"); let alpn = crate::server::types::jsvalue_to_owned_string(alpn); let choice = with_codec(&socket, |codec| { codec.handshaking = false; diff --git a/crates/perry-ext-http/src/server/turnloop_serve/conn.rs b/crates/perry-ext-http/src/server/turnloop_serve/conn.rs index 995b5b9187..21be460e25 100644 --- a/crates/perry-ext-http/src/server/turnloop_serve/conn.rs +++ b/crates/perry-ext-http/src/server/turnloop_serve/conn.rs @@ -217,15 +217,17 @@ fn queue_pending(server_handle: i64, request: HttpPendingRequest) { } /// The parser's owner is an ordinary child of the rooted Socket. `f` may -/// allocate, but must not call JS, close/reopen the Socket, or enter the driver. +/// allocate Rust state, but must not allocate JS, call JS, close/reopen the +/// Socket, or enter the driver. Its ordinary parser edge cannot move here. fn with_conn(socket: &RootedSocket, f: impl FnOnce(&mut Conn) -> R) -> Option { - if !socket.is_current() { - return None; + unsafe { + socket + .with_current(|_, state| { + let parser = net::record_get(state, "parser"); + np::payload_mut::(parser, &PARSER).ok().map(f) + }) + .flatten() } - let scope = TransientRootScope::enter(); - let state = scope.root_nanbox(net::state(socket.value())); - let parser = scope.root_nanbox(net::own_get(state.get(), "parser")); - unsafe { np::payload_mut::(parser.get(), &PARSER).ok().map(f) } } /// Visit children through the delegate's existing ordinary ownership graph. @@ -272,20 +274,31 @@ pub(crate) extern "C" fn sink(completion: *const tl::NetCompletion) { let Some(owner) = (unsafe { np::link_event_owner(link) }) else { return; }; - let socket = RootedSocket::new(owner); - if net::socket_link(socket.value()).is_err() { + if event.kind == tl::NET_DATA { + let bytes = unsafe { event.bytes() }; + let received = unsafe { + RootedSocket::receive( + owner, + event.flags & tl::NET_FLAG_PLAINTEXT != 0, + bytes.len(), + true, + |socket, state| { + let parser = net::record_get(state, "parser"); + np::payload_mut::(parser, &PARSER) + .ok() + .map(|conn| feed_step(socket, conn, bytes)) + }, + ) + }; + match received { + Some((socket, Some(Some(step)))) => deliver_feed(&socket, bytes, step), + Some((socket, None)) => net::receive_tls(socket.value(), bytes), + _ => {} + } return; } + let socket = RootedSocket::new(owner); match event.kind { - tl::NET_DATA => { - let bytes = unsafe { event.bytes() }; - if event.flags & tl::NET_FLAG_PLAINTEXT == 0 && net::tls_installed(socket.value()) { - net::receive_tls(socket.value(), bytes); - } else { - net::received(socket.value(), bytes.len()); - on_data(&socket, bytes); - } - } tl::NET_EOF => on_eof(&socket), tl::NET_SHUTDOWN => { on_shutdown(&socket); @@ -392,28 +405,31 @@ pub(crate) fn adopt_alpn_http1(socket: f64, server_handle: i64, leftover: Vec bool { with_conn(id, |c| c.websocket).unwrap_or(false) } -fn feed(id: &RootedSocket, bytes: &[u8]) { - if is_websocket(id) { - // Past the 101 these are frames, not HTTP. The connection, its id, its - // outstanding multishot read and its TLS layer are all unchanged — only - // who decodes the bytes. - perry_ext_ws::native_socket::on_data(id.value(), bytes); - return; +fn feed_step(id: &RootedSocket, c: &mut Conn, bytes: &[u8]) -> Option { + c.handshaking = false; + if c.websocket { + return None; + } + c.input.extend_from_slice(bytes); + Some(decode_step(id, c)) +} + +fn deliver_feed(id: &RootedSocket, bytes: &[u8], step: Option) { + match step { + Some(step) => decode_after(id, Some(step)), + // End the HTTP borrow before WebSocket callbacks can close/reopen. + None => perry_ext_ws::native_socket::on_data(id.value(), bytes), } - let known = with_conn(id, |c| c.input.extend_from_slice(bytes)).is_some(); - if known { - decode(id); +} + +fn feed(id: &RootedSocket, bytes: &[u8]) { + if let Some(step) = with_conn(id, |c| feed_step(id, c, bytes)) { + deliver_feed(id, bytes, step); } } @@ -423,121 +439,135 @@ fn feed(id: &RootedSocket, bytes: &[u8]) { /// is only `reset()` once the current response has been written, so a pipelined /// request stays in `input` and is dispatched afterwards. That is Node's /// per-connection serialization, and it is also what makes `res` unambiguous. -fn decode(id: &RootedSocket) { +enum Step { + Idle, + Again, + /// A decoded request, and whether the client is waiting for a + /// `100 Continue` before it sends the body. + Dispatch(HttpPendingRequest, bool), + /// A head with `Expect: 100-continue` was decoded: answer + /// `100 Continue` now, then keep decoding (the body follows it). + Continue, + Upgrade(Building), + /// A WebSocket upgrade an attached `WebSocketServer` will answer. + WebSocket(Building), + Failed(&'static str), +} + +fn decode_step(id: &RootedSocket, c: &mut Conn) -> Step { loop { - enum Step { - Idle, - Again, - /// A decoded request, and whether the client is waiting for a - /// `100 Continue` before it sends the body. - Dispatch(HttpPendingRequest, bool), - /// A head with `Expect: 100-continue` was decoded: answer - /// `100 Continue` now, then keep decoding (the body follows it). - Continue, - Upgrade(Building), - /// A WebSocket upgrade an attached `WebSocketServer` will answer. - WebSocket(Building), - Failed(&'static str), + if c.destroyed || c.paused || c.handshaking { + return Step::Idle; } - let step = with_conn(id, |c| { - if c.destroyed || c.paused || c.handshaking { - return Step::Idle; - } - let step = match c.decoder.receive(&c.input) { - Ok(step) => step, - Err(e) => return Step::Failed(e.code), - }; - let consumed = step.consumed; - let mut outcome = Step::Idle; - match step.event { - Some(http1::Event::Head(head)) => { - let mut building = building_from(&head); - // A client that sent `Expect: 100-continue` withholds the - // body until it sees `100 Continue`, and this decoder only - // dispatches a request at its END — so waiting for the - // dispatch to send it deadlocked every such request - // (#5080's test_http_100_continue_5080, a turnloop - // regression: hyper sent it when the body was first - // polled). Send it as soon as the head arrives, as hyper - // did; a `'checkContinue'` listener still receives the - // request, and its `writeContinue()` then has nothing left - // to send. - if building.expects_continue && building.version != 0 { - building.continue_sent = true; - outcome = Step::Continue; - } else { - outcome = Step::Again; - } - c.building = Some(building); - } - Some(http1::Event::Body(chunk)) => { - if let Some(b) = c.building.as_mut() { - b.body.extend_from_slice(chunk); - } + let step = match c.decoder.receive(&c.input) { + Ok(step) => step, + Err(e) => return Step::Failed(e.code), + }; + let consumed = step.consumed; + let mut outcome = Step::Idle; + match step.event { + Some(http1::Event::Head(head)) => { + let mut building = building_from(&head); + // A client that sent `Expect: 100-continue` withholds the + // body until it sees `100 Continue`, and this decoder only + // dispatches a request at its END — so waiting for the + // dispatch to send it deadlocked every such request + // (#5080's test_http_100_continue_5080, a turnloop + // regression: hyper sent it when the body was first + // polled). Send it as soon as the head arrives, as hyper + // did; a `'checkContinue'` listener still receives the + // request, and its `writeContinue()` then has nothing left + // to send. + if building.expects_continue && building.version != 0 { + building.continue_sent = true; + outcome = Step::Continue; + } else { outcome = Step::Again; } - Some(http1::Event::Trailers(_)) => outcome = Step::Again, - // `Upgrade` joins `End` here rather than getting its own arm. - // turnloop-http 0.1.0-alpha.7 made the REQUEST-mode decoder end - // an upgrade message with `Event::Upgrade` INSTEAD of - // `Event::End` (`upgrade_request` = CONNECT, or HTTP/1.1 with - // `Upgrade` + `Connection: upgrade`). Both mean the same thing - // on this side — the message is complete — and all the routing - // policy lives below, so they must not diverge. - // - // This was a silent regression waiting to happen: the old - // `Event::Upgrade` arm was written as unreachable and routed - // straight to `Step::Upgrade`, so once alpha.7 started raising - // it, every upgrade would have bypassed BOTH the attached - // `WebSocketServer` precedence and the `has_upgrade_listener` - // test (#4973: an upgrade with no listener is served as an - // ordinary request), and a CONNECT — which sets - // `upgrade_request` but never `Building::upgrade` — would have - // stopped being dispatched as a request at all. None of that is - // a compile error, because the arm already existed. - Some(http1::Event::End | http1::Event::Upgrade) => { - outcome = match c.building.take() { - // A WebSocket upgrade with a `WebSocketServer` attached - // to this server is answered here, before the generic - // `'upgrade'` route — that is `ws`'s own precedence, - // and it is the case P5 had to decline. - Some(building) - if building.websocket - && perry_ext_ws::has_attached_server(c.server_handle) => - { - c.paused = true; - Step::WebSocket(building) - } - // Node dispatches an upgrade request to `'upgrade'` - // instead of `'request'` — but only when a listener - // exists; with none it is served as an ordinary - // request, which is #4973's rule. - Some(building) - if building.upgrade && has_upgrade_listener(c.server_handle) => - { - c.paused = true; - Step::Upgrade(building) - } - Some(building) => { - c.requests += 1; - c.seq += 1; - c.paused = true; - let (request, send_continue) = finish_request(id, c, building); - Step::Dispatch(request, send_continue) - } - None => Step::Again, - }; + c.building = Some(building); + } + Some(http1::Event::Body(chunk)) => { + if let Some(b) = c.building.as_mut() { + b.body.extend_from_slice(chunk); } - Some(http1::Event::Informational(_)) => outcome = Step::Again, - None => { - if consumed > 0 { - outcome = Step::Again; + outcome = Step::Again; + } + Some(http1::Event::Trailers(_)) => outcome = Step::Again, + // `Upgrade` joins `End` here rather than getting its own arm. + // turnloop-http 0.1.0-alpha.7 made the REQUEST-mode decoder end + // an upgrade message with `Event::Upgrade` INSTEAD of + // `Event::End` (`upgrade_request` = CONNECT, or HTTP/1.1 with + // `Upgrade` + `Connection: upgrade`). Both mean the same thing + // on this side — the message is complete — and all the routing + // policy lives below, so they must not diverge. + // + // This was a silent regression waiting to happen: the old + // `Event::Upgrade` arm was written as unreachable and routed + // straight to `Step::Upgrade`, so once alpha.7 started raising + // it, every upgrade would have bypassed BOTH the attached + // `WebSocketServer` precedence and the `has_upgrade_listener` + // test (#4973: an upgrade with no listener is served as an + // ordinary request), and a CONNECT — which sets + // `upgrade_request` but never `Building::upgrade` — would have + // stopped being dispatched as a request at all. None of that is + // a compile error, because the arm already existed. + Some(http1::Event::End | http1::Event::Upgrade) => { + outcome = match c.building.take() { + // A WebSocket upgrade with a `WebSocketServer` attached + // to this server is answered here, before the generic + // `'upgrade'` route — that is `ws`'s own precedence, + // and it is the case P5 had to decline. + Some(building) + if building.websocket + && perry_ext_ws::has_attached_server(c.server_handle) => + { + c.paused = true; + Step::WebSocket(building) + } + // Node dispatches an upgrade request to `'upgrade'` + // instead of `'request'` — but only when a listener + // exists; with none it is served as an ordinary + // request, which is #4973's rule. + Some(building) if building.upgrade && has_upgrade_listener(c.server_handle) => { + c.paused = true; + Step::Upgrade(building) + } + Some(building) => { + c.requests += 1; + c.seq += 1; + c.paused = true; + let (request, send_continue) = finish_request(id, c, building); + Step::Dispatch(request, send_continue) } + None => Step::Again, + }; + } + Some(http1::Event::Informational(_)) => outcome = Step::Again, + None => { + if consumed > 0 { + outcome = Step::Again; } } - c.input.drain(..consumed.min(c.input.len())); - outcome - }); + } + c.input.drain(..consumed.min(c.input.len())); + if matches!(outcome, Step::Again) { + continue; + } + return outcome; + } +} + +fn decode(id: &RootedSocket) { + decode_after(id, None); +} + +fn decode_after(id: &RootedSocket, mut initial: Option) { + loop { + // Head/body/trailer steps do not run JS or allocate GC objects. + // Keep the same proven parser until an externally visible action. + let step = initial + .take() + .or_else(|| with_conn(id, |c| decode_step(id, c))); match step { None | Some(Step::Idle) => return, Some(Step::Again) => continue, @@ -1145,12 +1175,6 @@ fn arm_idle(id: &RootedSocket) { net::deadline_arm(id.value(), ms); } -/// Disarm the idle close for the duration of an exchange, keeping the handle. -/// Teardown still uses `cancel_idle`: there the handle really is going away. -fn park_idle(id: &RootedSocket) { - net::deadline_park(id.value()); -} - fn cancel_idle(id: &RootedSocket) { net::deadline_cancel(id.value()); } diff --git a/crates/perry-ext-http/src/server/types.rs b/crates/perry-ext-http/src/server/types.rs index 45d6b8ebd7..0fdc2dead2 100644 --- a/crates/perry-ext-http/src/server/types.rs +++ b/crates/perry-ext-http/src/server/types.rs @@ -248,13 +248,14 @@ pub fn jsvalue_to_body_bytes(value: f64) -> Option> { if v.is_undefined() || v.is_null() { return None; } - // JS string — heap STRING_TAG *or* inline SSO SHORT_STRING_TAG. - // #1781: the strict `is_string()` matched STRING_TAG only, so a - // short response body (`res.end("hi")`) fell through every branch - // to `None` and was silently dropped. Gate on `is_any_string()` - // and materialize via `js_get_string_pointer_unified`, which copies - // SSO bytes onto the heap (and returns the existing pointer for a - // heap string), so the `StringHeader` read works for both reprs. + // Inline strings already contain their wire bytes. Copy them directly + // instead of materializing a transient GC StringHeader on every write. + // Heap strings retain their existing header-backed read below. + if v.is_short_string() { + let mut buffer = [0; perry_ffi::SHORT_STRING_MAX_LEN]; + let len = v.short_string_to_buf(&mut buffer)?; + return Some(buffer[..len].to_vec()); + } if v.is_any_string() { let ptr = unsafe { js_get_string_pointer_unified(value) } as *mut StringHeader; if ptr.is_null() { diff --git a/crates/perry-ext-http/src/tests.rs b/crates/perry-ext-http/src/tests.rs index 457406f99a..4aa8a8bdc7 100644 --- a/crates/perry-ext-http/src/tests.rs +++ b/crates/perry-ext-http/src/tests.rs @@ -187,10 +187,10 @@ fn gc_mutable_scanner_rewrites_request_response_listener_roots() { let mut queue = HTTP_PENDING_EVENTS.lock().unwrap(); let index = queue .iter() - .position(|entry| matches!(entry.event, PendingHttpEvent::Upgrade { request_handle: id, .. } if id == request_handle)) + .position(|(_, event)| matches!(event, PendingHttpEvent::Upgrade { request_handle: id, .. } if *id == request_handle)) .unwrap(); let event = queue.remove(index); - if let PendingHttpEvent::Upgrade { socket_handle, .. } = event.event { + if let PendingHttpEvent::Upgrade { socket_handle, .. } = event.1 { assert_rewritten(upgrade_socket, socket_handle); } drop(queue); diff --git a/crates/perry-ext-net/src/jsvalue.rs b/crates/perry-ext-net/src/jsvalue.rs index 77c1104a36..64cd019d80 100644 --- a/crates/perry-ext-net/src/jsvalue.rs +++ b/crates/perry-ext-net/src/jsvalue.rs @@ -93,14 +93,14 @@ pub(crate) unsafe fn jsvalue_to_socket_bytes(value: f64) -> Option> { if v.is_undefined() || v.is_null() { return None; } - // JS string — heap STRING_TAG *or* inline SSO SHORT_STRING_TAG. - // #1781: the strict `is_string()` matches STRING_TAG only, so a - // short string (`socket.write("hi")`) used to fall through every - // branch to `None` and was silently dropped. Gate on - // `is_any_string()` and route through `js_get_string_pointer_unified`, - // which materializes the SSO bytes onto the heap (and returns the - // existing pointer for a heap string), so the `StringHeader` read - // below works for both representations. + // Inline strings already contain their wire bytes. Copy them directly + // instead of materializing a transient GC StringHeader on every write. + // Heap strings retain their existing header-backed read below. + if v.is_short_string() { + let mut buffer = [0; perry_ffi::SHORT_STRING_MAX_LEN]; + let len = v.short_string_to_buf(&mut buffer)?; + return Some(buffer[..len].to_vec()); + } if v.is_any_string() { let ptr = js_get_string_pointer_unified(value) as *const StringHeader; if ptr.is_null() { diff --git a/crates/perry-ext-net/src/native_transport.rs b/crates/perry-ext-net/src/native_transport.rs index 2c73beba18..44b0dadb90 100644 --- a/crates/perry-ext-net/src/native_transport.rs +++ b/crates/perry-ext-net/src/native_transport.rs @@ -40,13 +40,154 @@ impl RootedSocket { .as_ref() .is_none_or(|snapshot| matches(self.value(), snapshot)) } + /// Execute a callback-free operation against the proven incarnation. + /// # Safety + /// f must not allocate JS, invoke JS, close, reopen or retain its arguments. + pub unsafe fn with_current( + &self, + f: impl FnOnce(&mut TransportCore, f64) -> R, + ) -> Option { + let (own, transport) = self.project_current()?; + Some(f(&mut (*transport.payload).core, own.state)) + } + /// Root and prove an incoming completion once, then decode plaintext using + /// that same socket proof for TLS selection, accounting and the deadline. + /// `None` inside the result asks the caller to consume ciphertext after + /// this native borrow ends. The returned receiver stays rooted for effects. + /// # Safety + /// f must not allocate JS, invoke JS, close, reopen or retain state. + pub unsafe fn receive( + value: f64, + plaintext: bool, + len: usize, + park_idle: bool, + f: impl FnOnce(&RootedSocket, f64) -> R, + ) -> Option<(Self, Option)> { + let scope = TransientRootScope::enter(); + let root = scope.root_nanbox(value); + let (own, transport) = Self::project_windows(root.get())?; + let incarnation = tl::link_snapshot_handle(&mut (*transport.payload).core, transport.link); + let socket = Self { + root, + incarnation, + _scope: scope, + }; + let result = if !plaintext && (*transport.payload).ext.tls.is_some() { + None + } else { + let payload = &mut *own.payload; + payload.ext.bytes_read = payload.ext.bytes_read.saturating_add(len as u64); + socket::refresh_timeout_proven(payload, own.link); + if park_idle { + let _ = tl::link_deadline_park(&mut (*transport.payload).core, transport.link); + } + Some(f(&socket, own.state)) + }; + Some((socket, result)) + } + unsafe fn project_current( + &self, + ) -> Option<( + perry_ffi::native_payload::PayloadWindow, + perry_ffi::native_payload::PayloadWindow, + )> { + let (own, transport) = Self::project_windows(self.value())?; + if self.incarnation.as_ref().is_some_and(|snapshot| { + !tl::link_handle_matches(&mut (*transport.payload).core, transport.link, snapshot) + }) { + return None; + } + Some((own, transport)) + } + unsafe fn project_windows( + value: f64, + ) -> Option<( + perry_ffi::native_payload::PayloadWindow, + perry_ffi::native_payload::PayloadWindow, + )> { + let own = perry_ffi::native_payload::project::(value, &p::SOCKET).ok()?; + let transport = if (*own.payload).ext.tls_parent { + let parent = p::record_get(own.state, "tlsParent"); + perry_ffi::native_payload::project::(parent, &p::SOCKET).ok()? + } else { + perry_ffi::native_payload::PayloadWindow { + payload: own.payload, + link: own.link, + state: own.state, + } + }; + Some((own, transport)) + } + /// Execute consecutive native effects with one incarnation proof. + /// # Safety + /// f must not allocate JS, invoke JS, release/reopen or retain the view. + pub unsafe fn with_native_io(&self, f: impl FnOnce(&mut NativeIo<'_>) -> R) -> Option { + let (own, transport) = self.project_current()?; + let mut io = NativeIo { + payload: &mut *transport.payload, + link: transport.link, + state: own.state, + }; + let result = f(&mut io); + p::account_socket_proven(io.payload, io.link); + Some(result) + } /// # Safety /// End the projected borrow before JS, close or reopen. pub unsafe fn core(&self) -> Result<*mut TransportCore, PayloadMiss> { - if self.is_current() { - core(self.value()) + self.with_current(|core, _| core as *mut _) + .ok_or(PayloadMiss::Closed) + } +} + +/// A callback-free view of the physical transport and its logical JS state. +/// It is borrowed for one operation batch, never stored or used across JS. +pub struct NativeIo<'a> { + payload: &'a mut p::SocketPayload, + link: OwnerLink, + state: f64, +} +impl NativeIo<'_> { + pub fn can_write_without_js(&self) -> bool { + self.payload.ext.tls.is_none() + } + pub fn write(&mut self, bytes: &[u8], user: u64) -> Result { + assert!(self.can_write_without_js()); + // This is the same submission routine used by Socket.write and TLS. + unsafe { + crate::payload_tls::write_proven( + p::undefined(), + perry_ffi::native_payload::PayloadWindow { + payload: self.payload, + link: self.link, + state: self.state, + }, + bytes, + user, + ) + } + } + pub fn deadline(&mut self, ms: Option) { + if let Some(ms) = ms { + let _ = tl::link_deadline_arm(&mut self.payload.core, self.link, ms); + } else { + let _ = tl::link_deadline_cancel(&mut self.payload.core, self.link); + } + } + pub fn park_deadline(&mut self) { + let _ = tl::link_deadline_park(&mut self.payload.core, self.link); + } + /// The binding initializes refed as an ordinary data edge at birth. + /// Refuse an incomplete record instead of allocating in this window. + pub fn set_ref(&mut self, referenced: bool) -> bool { + let value = JsValue::from_bool(referenced); + if unsafe { + perry_ffi::object_record_set(JsValue::from_bits(self.state.to_bits()), "refed", value) + } { + tl::link_set_ref(&mut self.payload.core, self.link, referenced); + true } else { - Err(PayloadMiss::Closed) + false } } } @@ -82,7 +223,8 @@ pub fn server_link(owner: f64) -> Result { /// Root the owner before calling. End the projection before any operation /// that can run JS, explicitly close the owner or attach a new incarnation. pub unsafe fn core(owner: f64) -> Result<*mut TransportCore, PayloadMiss> { - p::socket_core(socket::transport_link(owner)?) + let window = transport_window(owner)?; + Ok(std::ptr::addr_of_mut!((*window.payload).core)) } /// # Safety /// Same projection contract as `core`, for a listener. @@ -148,6 +290,10 @@ pub fn for_each_server_child(owner: f64, mut visit: impl FnMut(&RootedSocket)) { pub fn own_get(object: f64, key: &str) -> f64 { p::own_get(object, key) } +/// Read an opaque parser/transport state record without invoking JS. +pub fn record_get(object: f64, key: &str) -> f64 { + p::record_get(object, key) +} pub fn own_set(object: f64, key: &str, value: f64) { p::own_set(object, key, value) } @@ -206,14 +352,13 @@ pub fn set_ref(owner: f64, referenced: bool) { socket::set_ref(owner, referenced); } pub fn has_ref(owner: f64) -> bool { - let scope = TransientRootScope::enter(); - let owner = scope.root_nanbox(owner); - if socket_link(owner.get()).is_err() { - return false; - } - let state = scope.root_nanbox(state(owner.get())); - JsValue::from_bits(own_get(state.get(), "refed").to_bits()).to_bool() + // A pump probe is a callback-free state read, including after explicit + // release while a terminal completion is still owed. Do not allocate a + // key string, create state or open field scopes on every poll turn. + let state = perry_ffi::native_payload::js_state(owner, &p::SOCKET, false); + JsValue::from_bits(p::record_get(state, "refed").to_bits()).to_bool() } + pub fn destroy(owner: f64) { socket::destroy(owner, p::undefined()); } @@ -304,14 +449,32 @@ pub fn queued_bytes(owner: f64) -> usize { .map_or(0, |core| tl::link_queued_bytes(&mut *core, link)) } } +/// Callback-free physical transport facts; wrappers keep their own JS state. +unsafe fn transport_window( + owner: f64, +) -> Result, PayloadMiss> { + let own = perry_ffi::native_payload::project::(owner, &p::SOCKET)?; + if (*own.payload).ext.tls_parent { + perry_ffi::native_payload::project(p::record_get(own.state, "tlsParent"), &p::SOCKET) + } else { + Ok(own) + } +} + pub fn snapshot(owner: f64) -> Option { - socket::transport_link(owner).ok().and_then(io::snapshot) + unsafe { + let window = transport_window(owner).ok()?; + tl::link_snapshot_handle(&mut (*window.payload).core, window.link) + } } pub fn matches(owner: f64, snapshot: &tl::HandleSnapshot) -> bool { - socket::transport_link(owner) - .ok() - .is_some_and(|link| io::matches(link, snapshot)) + unsafe { + transport_window(owner).ok().is_some_and(|window| { + tl::link_handle_matches(&mut (*window.payload).core, window.link, snapshot) + }) + } } + pub fn call(callback: f64, this: perry_ffi::JsThis, args: &[f64]) -> f64 { crate::payload_events::call(callback, this, args) } diff --git a/crates/perry-ext-net/src/payload_abi.rs b/crates/perry-ext-net/src/payload_abi.rs index e3e33ecc87..2a4b030624 100644 --- a/crates/perry-ext-net/src/payload_abi.rs +++ b/crates/perry-ext-net/src/payload_abi.rs @@ -1357,7 +1357,7 @@ pub unsafe extern "C" fn js_net_socket_upgrade_tls( let name = string_from_header_i64(servername_ptr).unwrap_or_default(); let promise = scope.root_addr(perry_ffi::JsPromise::new().as_raw() as i64); let state = scope.root_nanbox(socket::state(owner.get())); - p::own_set(state.get(), "upgradePromise", p::boxed_addr(promise.get())); + p::record_set(state.get(), "upgradePromise", p::boxed_addr(promise.get())); if let Err(message) = super::payload_tls::install_client( owner.get(), name, diff --git a/crates/perry-ext-net/src/payload_closed.rs b/crates/perry-ext-net/src/payload_closed.rs index 7a1badbcb8..36569ccd2f 100644 --- a/crates/perry-ext-net/src/payload_closed.rs +++ b/crates/perry-ext-net/src/payload_closed.rs @@ -19,7 +19,7 @@ fn array_ptr(value: f64) -> *mut ArrayHeader { pub(crate) fn parts(record: f64) -> [u32; 4] { let scope = TransientRootScope::enter(); let record = scope.root_nanbox(record); - let handle = scope.root_nanbox(p::own_get(record.get(), "handle")); + let handle = scope.root_nanbox(p::record_get(record.get(), "handle")); std::array::from_fn(|index| unsafe { perry_ffi::js_array_get(array_ptr(handle.get()), index as u32).to_number() as u32 }) @@ -29,7 +29,7 @@ pub(crate) fn append(state: f64, record: f64) { let scope = TransientRootScope::enter(); let state = scope.root_nanbox(state); let record = scope.root_nanbox(record); - let mut array = scope.root_nanbox(p::own_get(state.get(), "closeCallbacks")); + let mut array = scope.root_nanbox(p::record_get(state.get(), "closeCallbacks")); if !JsValue::from_bits(array.get().to_bits()).is_pointer() { array = scope.root_nanbox(p::boxed_addr(unsafe { perry_ffi::js_array_alloc(0) } as i64)); } @@ -39,7 +39,7 @@ pub(crate) fn append(state: f64, record: f64) { JsValue::from_bits(record.get().to_bits()), ) }; - p::own_set(state.get(), "closeCallbacks", p::boxed_addr(updated as i64)); + p::record_set(state.get(), "closeCallbacks", p::boxed_addr(updated as i64)); } pub(crate) fn record(parts: [u32; 4]) -> f64 { @@ -56,14 +56,14 @@ pub(crate) fn record(parts: [u32; 4]) -> f64 { ) } as i64); } - p::own_set(record.get(), "handle", p::boxed_addr(capability.get())); + p::record_set(record.get(), "handle", p::boxed_addr(capability.get())); record.get() } pub(crate) fn peek(state: f64, capability: [u32; 4]) -> f64 { let scope = TransientRootScope::enter(); let state = scope.root_nanbox(state); - let array = scope.root_nanbox(p::own_get(state.get(), "closeCallbacks")); + let array = scope.root_nanbox(p::record_get(state.get(), "closeCallbacks")); if !JsValue::from_bits(array.get().to_bits()).is_pointer() { return p::undefined(); } @@ -83,7 +83,7 @@ pub(crate) fn for_wrapper(state: f64, owner: f64) -> f64 { let scope = TransientRootScope::enter(); let state = scope.root_nanbox(state); let owner = scope.root_nanbox(owner); - let array = scope.root_nanbox(p::own_get(state.get(), "closeCallbacks")); + let array = scope.root_nanbox(p::record_get(state.get(), "closeCallbacks")); if !JsValue::from_bits(array.get().to_bits()).is_pointer() { return p::undefined(); } @@ -92,7 +92,7 @@ pub(crate) fn for_wrapper(state: f64, owner: f64) -> f64 { let record = scope.root_nanbox(f64::from_bits(unsafe { perry_ffi::js_array_get(array_ptr(array.get()), index).bits() })); - if p::own_get(record.get(), "tlsWrapper").to_bits() == owner.get().to_bits() { + if p::record_get(record.get(), "tlsWrapper").to_bits() == owner.get().to_bits() { return record.get(); } } @@ -105,7 +105,7 @@ pub(crate) fn for_wrapper(state: f64, owner: f64) -> f64 { pub(crate) fn take(state: f64, parts: [u32; 4]) -> f64 { let scope = TransientRootScope::enter(); let state = scope.root_nanbox(state); - let array = scope.root_nanbox(p::own_get(state.get(), "closeCallbacks")); + let array = scope.root_nanbox(p::record_get(state.get(), "closeCallbacks")); if !JsValue::from_bits(array.get().to_bits()).is_pointer() { return p::undefined(); } @@ -118,7 +118,7 @@ pub(crate) fn take(state: f64, parts: [u32; 4]) -> f64 { }) .collect(); let found = records.iter().position(|record| { - let handle = scope.root_nanbox(p::own_get(record.get(), "handle")); + let handle = scope.root_nanbox(p::record_get(record.get(), "handle")); if !JsValue::from_bits(handle.get().to_bits()).is_pointer() { return false; } @@ -142,6 +142,6 @@ pub(crate) fn take(state: f64, parts: [u32; 4]) -> f64 { } as i64); } } - p::own_set(state.get(), "closeCallbacks", p::boxed_addr(kept.get())); + p::record_set(state.get(), "closeCallbacks", p::boxed_addr(kept.get())); records[found].get() } diff --git a/crates/perry-ext-net/src/payload_events.rs b/crates/perry-ext-net/src/payload_events.rs index 8562953e94..1a2380a83a 100644 --- a/crates/perry-ext-net/src/payload_events.rs +++ b/crates/perry-ext-net/src/payload_events.rs @@ -4,6 +4,25 @@ use super::payload_transport::{boxed_addr, raw_owner, undefined}; use perry_ffi::{JsThis, JsValue, RawClosureHeader, TransientRootScope}; extern "C" { + fn js_perry_event_emit_span_rooted( + scope: usize, + resource: usize, + owner: usize, + event: *const u8, + event_len: usize, + args: *const f64, + len: usize, + ) -> f64; + fn js_node_stream_listener_count_fast(owner: f64, event: *const u8, len: usize) -> i64; + fn js_string_new_sso(data: *const u8, len: u32) -> f64; + fn js_perry_event_emit_span_in_resource( + resource: f64, + owner: f64, + event: *const u8, + event_len: usize, + args: *const f64, + len: usize, + ) -> f64; fn js_perry_event_emit_in_resource(resource: f64, owner: f64, event: f64, args: f64) -> f64; fn js_perry_event_call_in_resource( resource: f64, @@ -17,7 +36,7 @@ extern "C" { } pub(crate) fn string(text: &str) -> f64 { - f64::from_bits(JsValue::from_string_ptr(perry_ffi::alloc_string(text).as_raw()).bits()) + unsafe { js_string_new_sso(text.as_ptr(), text.len() as u32) } } /// The array is a traced JS object, including during allocation and callback @@ -38,26 +57,39 @@ fn argument_array(scope: &TransientRootScope, args: &[f64]) -> perry_ffi::Transi } pub(crate) fn emit(owner: f64, event: &str, args: &[f64]) -> f64 { - let scope = TransientRootScope::enter(); - let owner = scope.root_nanbox(owner); - let args: Vec<_> = args.iter().map(|&arg| scope.root_nanbox(arg)).collect(); - let resource = scope.root_nanbox(super::payload_provider::resource(owner.get())); - let args: Vec<_> = args.iter().map(|arg| arg.get()).collect(); - emit_in(resource.get(), owner.get(), event, &args) + // Provider resolution is a callback-free read of an ordinary state edge. + emit_in(super::payload_provider::resource(owner), owner, event, args) } pub(crate) fn emit_in(resource: f64, owner: f64, event: &str, args: &[f64]) -> f64 { - let scope = TransientRootScope::enter(); - let owner = scope.root_nanbox(owner); - let resource = scope.root_nanbox(resource); - let args = argument_array(&scope, args); - let event = scope.root_nanbox(string(event)); unsafe { - js_perry_event_emit_in_resource( - resource.get(), - owner.get(), - event.get(), - boxed_addr(args.get()), + js_perry_event_emit_span_in_resource( + resource, + owner, + event.as_ptr(), + event.len(), + args.as_ptr(), + args.len(), + ) + } +} + +pub(crate) fn emit_rooted( + scope: &TransientRootScope, + resource: &perry_ffi::TransientRootedNanbox, + owner: &perry_ffi::TransientRootedNanbox, + event: &str, + args: &[f64], +) -> f64 { + unsafe { + js_perry_event_emit_span_rooted( + scope.runtime_token(), + resource.runtime_token(), + owner.runtime_token(), + event.as_ptr(), + event.len(), + args.as_ptr(), + args.len(), ) } } @@ -70,6 +102,12 @@ pub(crate) fn listener_count(owner: f64, event: &str) -> usize { JsValue::from_bits(result.to_bits()).to_number().max(0.0) as usize } +/// Ordinary own-data emitter shapes need neither allocation nor field roots. +pub(crate) fn listener_count_fast(owner: f64, event: &str) -> Option { + let count = unsafe { js_node_stream_listener_count_fast(owner, event.as_ptr(), event.len()) }; + (count >= 0).then_some(count as usize) +} + unsafe extern "C" fn emit_tick(closure: *const RawClosureHeader, _: JsThis) -> f64 { let scope = TransientRootScope::enter(); let owner = scope.root_nanbox(perry_ffi::closure_capture_f64(closure, 0)); @@ -164,17 +202,12 @@ pub(crate) fn call(callback: f64, this: JsThis, args: &[f64]) -> f64 { call_in(undefined(), callback, this, args) } pub(crate) fn call_in(resource: f64, callback: f64, this: JsThis, args: &[f64]) -> f64 { - let scope = TransientRootScope::enter(); - let resource = scope.root_nanbox(resource); - let callback = scope.root_nanbox(callback); - let this = scope.root_nanbox(this.as_f64()); - let args: Vec<_> = args.iter().map(|arg| scope.root_nanbox(*arg)).collect(); - let args: Vec<_> = args.iter().map(|arg| arg.get()).collect(); + // The runtime boundary roots every input before any allocation or hook. unsafe { js_perry_event_call_in_resource( - resource.get(), - callback.get(), - this.get(), + resource, + callback, + this.as_f64(), args.as_ptr(), args.len(), ) diff --git a/crates/perry-ext-net/src/payload_io.rs b/crates/perry-ext-net/src/payload_io.rs index e96493d360..b98f4e3674 100644 --- a/crates/perry-ext-net/src/payload_io.rs +++ b/crates/perry-ext-net/src/payload_io.rs @@ -56,6 +56,46 @@ extern "C" fn sink(completion: *const tl::NetCompletion) { }; let scope = TransientRootScope::enter(); let original = scope.root_nanbox(owner); + if event.kind == tl::NET_DATA && event.flags & tl::NET_FLAG_STALE == 0 { + unsafe { + if let Ok(window) = np::project::(original.get(), &p::SOCKET) { + let payload = &mut *window.payload; + if payload.ext.tls.is_none() && !payload.ext.tls_parent { + if let Some(listeners) = events::listener_count_fast(original.get(), "data") { + let bytes = event.bytes(); + payload.ext.bytes_read = + payload.ext.bytes_read.saturating_add(bytes.len() as u64); + socket::refresh_timeout_proven(payload, window.link); + socket::data_proven(&scope, &original, window, bytes, listeners); + return; + } + } + } + } + } + // Plain write acknowledgements carry no JS values. The completion's link + // pins its owner, and this one projection proves the callback-free window. + if event.kind == tl::NET_WROTE && event.user == 0 && event.flags & tl::NET_FLAG_STALE == 0 { + unsafe { + if let Ok(window) = np::project::(original.get(), &p::SOCKET) { + let payload = &mut *window.payload; + if payload.ext.tls.is_none() + && !payload.ext.tls_parent + && payload.ext.coalesced_users.is_empty() + && !payload.ext.need_drain + { + if tl::link_snapshot_handle(&mut payload.core, window.link).is_some() { + payload.ext.bytes_written = + payload.ext.bytes_written.saturating_add(event.len as u64); + payload.ext.queued = + event.queued.saturating_add(payload.ext.cork_bytes.len()); + socket::refresh_timeout_proven(payload, window.link); + } + return; + } + } + } + } let owner = scope.root_nanbox( if p::socket_link(original.get()).is_ok() && (event.kind == tl::NET_CLOSED || super::payload_tls::installed(link)) @@ -66,14 +106,14 @@ extern "C" fn sink(completion: *const tl::NetCompletion) { .map(|parts| closed::peek(state.get(), parts)) .unwrap_or_else(p::undefined), ); - let saved = scope.root_nanbox(p::own_get(pending.get(), "tlsWrapper")); + let saved = scope.root_nanbox(p::record_get(pending.get(), "tlsWrapper")); let wrapper = scope.root_nanbox(if p::socket_link(saved.get()).is_ok() { saved.get() } else if event.kind != tl::NET_CLOSED || (!JsValue::from_bits(pending.get().to_bits()).is_pointer() && event.flags & tl::NET_FLAG_STALE == 0) { - let current = scope.root_nanbox(p::own_get(state.get(), "tlsWrapper")); + let current = scope.root_nanbox(p::record_get(state.get(), "tlsWrapper")); if p::socket_link(current.get()) .is_ok_and(|own| unsafe { p::socket_ptr(own) }.is_ok()) { @@ -210,7 +250,7 @@ pub(crate) fn connected(owner: f64) { return; } let state = scope.root_nanbox(socket::state(owner.get())); - let connect = scope.root_nanbox(p::own_get(state.get(), provider::CONNECT)); + let connect = scope.root_nanbox(p::record_get(state.get(), provider::CONNECT)); events::emit_in(connect.get(), owner.get(), "connect", &[]); provider::retire(connect.get()); if !matches(link, &snapshot) { @@ -268,6 +308,22 @@ pub(crate) fn eof(owner: f64) { pub(crate) fn wrote(owner: f64, user: u64, len: usize, queued: usize) { let scope = TransientRootScope::enter(); let owner = scope.root_nanbox(owner); + // Most writes have no JS acknowledgement. Keep this callback-free window + // on the proven payload: no user Vec, callback roots or provider lookup. + unsafe { + if let Ok(window) = np::project::(owner.get(), &p::SOCKET) { + let payload = &mut *window.payload; + if user == 0 && payload.ext.coalesced_users.is_empty() && !payload.ext.need_drain { + if tl::link_snapshot_handle(&mut payload.core, window.link).is_none() { + return; + } + payload.ext.bytes_written = payload.ext.bytes_written.saturating_add(len as u64); + payload.ext.queued = queued.saturating_add(payload.ext.cork_bytes.len()); + socket::refresh_timeout_proven(payload, window.link); + return; + } + } + } let link = socket::link(owner.get()); if snapshot(link).is_none() { return; @@ -330,7 +386,7 @@ pub(crate) fn shutdown(owner: f64, user: u64) { return; }; let state = scope.root_nanbox(socket::state(owner.get())); - let resource = scope.root_nanbox(p::own_get(state.get(), provider::SHUTDOWN)); + let resource = scope.root_nanbox(p::record_get(state.get(), provider::SHUTDOWN)); let users = unsafe { p::socket_ptr(link).ok().map(|payload| { let fields = &mut (*payload).ext; @@ -391,8 +447,8 @@ fn prepare_socket_closed(owner: f64, event: &tl::NetCompletion) -> f64 { record = scope.root_nanbox(closed::record( unsafe { event.closed_handle_parts() }.unwrap_or([0; 4]), )); - let group = scope.root_nanbox(p::own_get(state.get(), "serverGroup")); - p::own_set(record.get(), "serverGroup", group.get()); + let group = scope.root_nanbox(p::record_get(state.get(), "serverGroup")); + p::record_set(record.get(), "serverGroup", group.get()); } socket::cache_before_release(owner.get(), link); if own != link { @@ -401,7 +457,7 @@ fn prepare_socket_closed(owner: f64, event: &tl::NetCompletion) -> f64 { let parent_record = scope.root_nanbox(closed::record( unsafe { event.closed_handle_parts() }.unwrap_or([0; 4]), )); - let group = scope.root_nanbox(p::own_get(parent_state.get(), "serverGroup")); + let group = scope.root_nanbox(p::record_get(parent_state.get(), "serverGroup")); p::own_set(parent_record.get(), "serverGroup", group.get()); provider::capture_socket(parent_state.get(), parent_record.get()); closed::append(parent_state.get(), parent_record.get()); @@ -442,19 +498,19 @@ fn emit_socket_closed(owner: f64, record: f64) { let record = scope.root_nanbox(record); let record_exists = JsValue::from_bits(record.get().to_bits()).is_pointer(); let error = scope.root_nanbox(if record_exists { - p::own_get(record.get(), "error") + p::record_get(record.get(), "error") } else { p::undefined() }); let group = scope.root_nanbox(if record_exists { - p::own_get(record.get(), "serverGroup") + p::record_get(record.get(), "serverGroup") } else { p::undefined() }); let had_error = !JsValue::from_bits(error.get().to_bits()).is_undefined() && !JsValue::from_bits(error.get().to_bits()).is_null(); let resource = scope.root_nanbox(if record_exists { - p::own_get(record.get(), provider::TCP) + p::record_get(record.get(), provider::TCP) } else { p::undefined() }); @@ -462,8 +518,8 @@ fn emit_socket_closed(owner: f64, record: f64) { if had_error { events::emit_in(resource.get(), owner.get(), "error", &[error.get()]); } - let had_handle = - !record_exists || p::own_get(record.get(), "hadHandle").to_bits() != JsValue::FALSE.bits(); + let had_handle = !record_exists + || p::record_get(record.get(), "hadHandle").to_bits() != JsValue::FALSE.bits(); let args = if had_handle { vec![f64::from_bits(JsValue::from_bool(had_error).bits())] } else { @@ -494,8 +550,8 @@ unsafe extern "C" fn closed_wrapper_tick(closure: *const RawClosureHeader, _: Js if np::link_lifecycle(own, &p::SOCKET) == Ok(np::Lifecycle::Closed) { return p::undefined(); } - p::own_set(state.get(), "tlsParent", p::undefined()); - p::own_set( + p::record_set(state.get(), "tlsParent", p::undefined()); + p::record_set( state.get(), "destroyed", f64::from_bits(JsValue::TRUE.bits()), diff --git a/crates/perry-ext-net/src/payload_pipe.rs b/crates/perry-ext-net/src/payload_pipe.rs index acfab7b2a1..cca0358105 100644 --- a/crates/perry-ext-net/src/payload_pipe.rs +++ b/crates/perry-ext-net/src/payload_pipe.rs @@ -124,11 +124,11 @@ pub(crate) fn pipe(owner: f64, dest: f64, options: f64) -> f64 { perry_ffi::js_function_info!(drain, 0; with_flags(perry_ffi::FN_BUILTIN)), &[owner.get()], )); - p::own_set(record.get(), "dest", dest.get()); - p::own_set(record.get(), "data", on_data.get()); - p::own_set(record.get(), "end", on_end.get()); - p::own_set(record.get(), "drain", on_drain.get()); - let mut records = scope.root_nanbox(p::own_get(state.get(), "pipes")); + p::record_set(record.get(), "dest", dest.get()); + p::record_set(record.get(), "data", on_data.get()); + p::record_set(record.get(), "end", on_end.get()); + p::record_set(record.get(), "drain", on_drain.get()); + let mut records = scope.root_nanbox(p::record_get(state.get(), "pipes")); if !JsValue::from_bits(records.get().to_bits()).is_pointer() { records = scope.root_nanbox(p::boxed_addr(unsafe { perry_ffi::js_array_alloc(0) } as i64)); } @@ -138,7 +138,7 @@ pub(crate) fn pipe(owner: f64, dest: f64, options: f64) -> f64 { JsValue::from_bits(record.get().to_bits()), ) }; - p::own_set(state.get(), "pipes", p::boxed_addr(updated as i64)); + p::record_set(state.get(), "pipes", p::boxed_addr(updated as i64)); listen(owner.get(), "data", on_data.get(), false); if ends { listen(owner.get(), "end", on_end.get(), false); @@ -154,7 +154,7 @@ pub(crate) fn unpipe(owner: f64, dest: f64) -> f64 { let dest = scope.root_nanbox(dest); socket::link(owner.get()); let state = scope.root_nanbox(socket::state(owner.get())); - let records = scope.root_nanbox(p::own_get(state.get(), "pipes")); + let records = scope.root_nanbox(p::record_get(state.get(), "pipes")); if !JsValue::from_bits(records.get().to_bits()).is_pointer() { return owner.get(); } @@ -169,7 +169,7 @@ pub(crate) fn unpipe(owner: f64, dest: f64) -> f64 { let mut keep = scope.root_addr(unsafe { perry_ffi::js_array_alloc(count) } as i64); let mut removed = Vec::new(); for value in values { - let target = scope.root_nanbox(p::own_get(value.get(), "dest")); + let target = scope.root_nanbox(p::record_get(value.get(), "dest")); if JsValue::from_bits(dest.get().to_bits()).is_undefined() || target.get().to_bits() == dest.get().to_bits() { @@ -184,12 +184,12 @@ pub(crate) fn unpipe(owner: f64, dest: f64) -> f64 { } } // Publish removal before any destination listener can pipe again. - p::own_set(state.get(), "pipes", p::boxed_addr(keep.get())); + p::record_set(state.get(), "pipes", p::boxed_addr(keep.get())); for record in removed { - let target = scope.root_nanbox(p::own_get(record.get(), "dest")); - let on_data = scope.root_nanbox(p::own_get(record.get(), "data")); - let on_end = scope.root_nanbox(p::own_get(record.get(), "end")); - let on_drain = scope.root_nanbox(p::own_get(record.get(), "drain")); + let target = scope.root_nanbox(p::record_get(record.get(), "dest")); + let on_data = scope.root_nanbox(p::record_get(record.get(), "data")); + let on_end = scope.root_nanbox(p::record_get(record.get(), "end")); + let on_drain = scope.root_nanbox(p::record_get(record.get(), "drain")); listen(owner.get(), "data", on_data.get(), true); if socket::is_callback(on_end.get()) { listen(owner.get(), "end", on_end.get(), true); diff --git a/crates/perry-ext-net/src/payload_prototype.rs b/crates/perry-ext-net/src/payload_prototype.rs index a5de200761..58f8c91081 100644 --- a/crates/perry-ext-net/src/payload_prototype.rs +++ b/crates/perry-ext-net/src/payload_prototype.rs @@ -175,27 +175,27 @@ method0!(listening, |owner| super::payload_server::get( "listening" )); -method0!(tls_encrypted, |owner| p::own_get( +method0!(tls_encrypted, |owner| p::record_get( socket::state(owner), "encrypted" )); -method0!(tls_authorized, |owner| p::own_get( +method0!(tls_authorized, |owner| p::record_get( socket::state(owner), "authorized" )); -method0!(tls_servername, |owner| p::own_get( +method0!(tls_servername, |owner| p::record_get( socket::state(owner), "servername" )); -method0!(tls_alpn, |owner| p::own_get( +method0!(tls_alpn, |owner| p::record_get( socket::state(owner), "alpnProtocol" )); -method0!(tls_auth_error, |owner| p::own_get( +method0!(tls_auth_error, |owner| p::record_get( socket::state(owner), "authorizationError" )); -method0!(tls_cipher, |owner| p::own_get( +method0!(tls_cipher, |owner| p::record_get( socket::state(owner), "cipher" )); @@ -203,30 +203,30 @@ method0!(tls_protocol, |owner| { let scope = TransientRootScope::enter(); let owner = scope.root_nanbox(owner); let state = scope.root_nanbox(socket::state(owner.get())); - if JsValue::from_bits(p::own_get(state.get(), "destroyed").to_bits()).to_bool() { + if JsValue::from_bits(p::record_get(state.get(), "destroyed").to_bits()).to_bool() { f64::from_bits(JsValue::NULL.bits()) } else { - p::own_get(state.get(), "protocol") + p::record_get(state.get(), "protocol") } }); method0!(tls_session_reused, |owner| { - let value = p::own_get(socket::state(owner), "sessionSupplied"); + let value = p::record_get(socket::state(owner), "sessionSupplied"); f64::from_bits(JsValue::from_bool(JsValue::from_bits(value.to_bits()).to_bool()).bits()) }); method0!(tls_session, |owner| { let scope = TransientRootScope::enter(); let owner = scope.root_nanbox(owner); let state = scope.root_nanbox(socket::state(owner.get())); - let session = scope.root_nanbox(p::own_get(state.get(), "session")); + let session = scope.root_nanbox(p::record_get(state.get(), "session")); if JsValue::from_bits(session.get().to_bits()).is_pointer() { return session.get(); } - if !JsValue::from_bits(p::own_get(state.get(), "tlsConnected").to_bits()).to_bool() - || JsValue::from_bits(p::own_get(state.get(), "destroyed").to_bits()).to_bool() + if !JsValue::from_bits(p::record_get(state.get(), "tlsConnected").to_bits()).to_bool() + || JsValue::from_bits(p::record_get(state.get(), "destroyed").to_bits()).to_bool() { return p::undefined(); } - let der = p::own_get(state.get(), "peerCertificateDer"); + let der = p::record_get(state.get(), "peerCertificateDer"); let bytes = crate::jsvalue_to_socket_bytes(der).unwrap_or_default(); if bytes.is_empty() { p::undefined() diff --git a/crates/perry-ext-net/src/payload_provider.rs b/crates/perry-ext-net/src/payload_provider.rs index adb7e6754e..9cf8a891c2 100644 --- a/crates/perry-ext-net/src/payload_provider.rs +++ b/crates/perry-ext-net/src/payload_provider.rs @@ -1,6 +1,6 @@ //! Async provider objects are ordinary JS edges, never SocketFields members. -use super::{payload_server as server, payload_socket as socket, payload_transport as p}; +use super::payload_transport as p; use perry_ffi::{JsValue, TransientRootScope}; extern "C" { @@ -42,38 +42,35 @@ pub(crate) fn publish(state: f64, key: &str, name: &'static [u8], trigger: u64) let scope = TransientRootScope::enter(); let state = scope.root_nanbox(state); let resource = scope.root_nanbox(new(name, trigger)); - p::own_set(state.get(), key, resource.get()); + p::record_set(state.get(), key, resource.get()); resource.get() } pub(crate) fn resource(owner: f64) -> f64 { - let scope = TransientRootScope::enter(); - let owner = scope.root_nanbox(owner); - if p::server_link(owner.get()).is_ok() { - let state = scope.root_nanbox(server::state(owner.get())); - p::own_get(state.get(), SERVER) - } else if p::socket_link(owner.get()).is_ok() { - let state = scope.root_nanbox(socket::state(owner.get())); - p::own_get(state.get(), TCP) + if let Ok(link) = p::server_link(owner) { + p::record_get(unsafe { p::server_state(link, false) }, SERVER) + } else if let Ok(link) = p::socket_link(owner) { + p::record_get(unsafe { p::socket_state(link, false) }, TCP) } else { p::undefined() } } + pub(crate) fn capture_socket(state: f64, record: f64) { let scope = TransientRootScope::enter(); let state = scope.root_nanbox(state); let record = scope.root_nanbox(record); for key in [TCP, CONNECT, SHUTDOWN] { - let resource = scope.root_nanbox(p::own_get(state.get(), key)); - p::own_set(record.get(), key, resource.get()); - p::own_set(state.get(), key, p::undefined()); + let resource = scope.root_nanbox(p::record_get(state.get(), key)); + p::record_set(record.get(), key, resource.get()); + p::record_set(state.get(), key, p::undefined()); } } pub(crate) fn retire_socket(record: f64) { let scope = TransientRootScope::enter(); let record = scope.root_nanbox(record); for key in [CONNECT, SHUTDOWN, TCP] { - let resource = scope.root_nanbox(p::own_get(record.get(), key)); + let resource = scope.root_nanbox(p::record_get(record.get(), key)); retire(resource.get()); - p::own_set(record.get(), key, p::undefined()); + p::record_set(record.get(), key, p::undefined()); } } diff --git a/crates/perry-ext-net/src/payload_server.rs b/crates/perry-ext-net/src/payload_server.rs index 16c0c3a1f7..f350084544 100644 --- a/crates/perry-ext-net/src/payload_server.rs +++ b/crates/perry-ext-net/src/payload_server.rs @@ -57,13 +57,13 @@ pub(crate) fn initialize_server( }); let state = scope.root_nanbox(state(owner.get())); p::own_set(owner.get(), "_connections", 0.0); - p::own_set(state.get(), "refed", f64::from_bits(JsValue::TRUE.bits())); - p::own_set( + p::record_set(state.get(), "refed", f64::from_bits(JsValue::TRUE.bits())); + p::record_set( state.get(), "allowHalfOpen", f64::from_bits(JsValue::from_bool(options.0).bits()), ); - p::own_set( + p::record_set( state.get(), "pauseOnConnect", f64::from_bits(JsValue::from_bool(options.1).bits()), @@ -141,9 +141,10 @@ pub(crate) fn listen(owner: f64, a: f64, b: f64, c: f64) -> f64 { if np::link_lifecycle(server, &SERVER) == Ok(np::Lifecycle::Closed) { let state = scope.root_nanbox(state(owner.get())); let allow_half_open = - JsValue::from_bits(p::own_get(state.get(), "allowHalfOpen").to_bits()).to_bool(); + JsValue::from_bits(p::record_get(state.get(), "allowHalfOpen").to_bits()).to_bool(); let pause_on_connect = - JsValue::from_bits(p::own_get(state.get(), "pauseOnConnect").to_bits()).to_bool(); + JsValue::from_bits(p::record_get(state.get(), "pauseOnConnect").to_bits()) + .to_bool(); p::reopen_server( server, super::payload_io::ROUTE, @@ -211,11 +212,11 @@ pub(crate) fn listen_tcp( super::payload_io::ROUTE, ServerFields { allow_half_open: JsValue::from_bits( - p::own_get(state.get(), "allowHalfOpen").to_bits(), + p::record_get(state.get(), "allowHalfOpen").to_bits(), ) .to_bool(), pause_on_connect: JsValue::from_bits( - p::own_get(state.get(), "pauseOnConnect").to_bits(), + p::record_get(state.get(), "pauseOnConnect").to_bits(), ) .to_bool(), ..ServerFields::default() @@ -261,20 +262,20 @@ fn finish_listen(owner: f64, path: Option) { }; let state = scope.root_nanbox(state(owner.get())); let group = scope.root_nanbox(closed::record(parts)); - p::own_set(group.get(), "server", owner.get()); - p::own_set( + p::record_set(group.get(), "server", owner.get()); + p::record_set( group.get(), "closing", f64::from_bits(JsValue::FALSE.bits()), ); - p::own_set( + p::record_set( group.get(), "listenerClosed", f64::from_bits(JsValue::FALSE.bits()), ); let children = unsafe { perry_ffi::js_array_alloc(0) }; - p::own_set(group.get(), "children", p::boxed_addr(children as i64)); - p::own_set(state.get(), "activeGroup", group.get()); + p::record_set(group.get(), "children", p::boxed_addr(children as i64)); + p::record_set(state.get(), "activeGroup", group.get()); let resource = scope.root_nanbox(provider::publish( state.get(), provider::SERVER, @@ -284,7 +285,7 @@ fn finish_listen(owner: f64, path: Option) { unsafe { (*p::server_ptr(server).unwrap()).ext.async_id = provider::id(resource.get()); } - p::own_set(group.get(), provider::SERVER, resource.get()); + p::record_set(group.get(), provider::SERVER, resource.get()); events::queue_emit(owner.get(), "listening", &[]); provider::notify(resource.get()); } @@ -329,7 +330,7 @@ pub(crate) fn close(owner: f64, callback: f64) -> f64 { return owner.get(); } once(owner.get(), "close", callback.get()); - let group = scope.root_nanbox(p::own_get(state.get(), "activeGroup")); + let group = scope.root_nanbox(p::record_get(state.get(), "activeGroup")); let (path, options) = unsafe { let fields = &(*p::server_ptr(server).unwrap()).ext; ( @@ -356,22 +357,22 @@ pub(crate) fn close(owner: f64, callback: f64) -> f64 { unsafe { np::close_link(server, &SERVER); } - p::own_set( + p::record_set( state.get(), "allowHalfOpen", f64::from_bits(JsValue::from_bool(options.0).bits()), ); - p::own_set( + p::record_set( state.get(), "pauseOnConnect", f64::from_bits(JsValue::from_bool(options.1).bits()), ); if JsValue::from_bits(group.get().to_bits()).is_pointer() { - p::own_set(group.get(), "closing", f64::from_bits(JsValue::TRUE.bits())); + p::record_set(group.get(), "closing", f64::from_bits(JsValue::TRUE.bits())); if owed { closed::append(state.get(), group.get()); } else { - p::own_set( + p::record_set( group.get(), "listenerClosed", f64::from_bits(JsValue::TRUE.bits()), @@ -473,7 +474,7 @@ pub(crate) fn get(owner: f64, key: &str) -> f64 { if key == "_connections" || key == "maxConnections" || key == "dropMaxConnection" { p::own_get(owner.get(), key) } else { - p::own_get(state.get(), key) + p::record_get(state.get(), key) } } @@ -503,7 +504,7 @@ pub(crate) fn set_ref(owner: f64, referenced: bool) -> f64 { } } let state = scope.root_nanbox(state(owner.get())); - p::own_set( + p::record_set( state.get(), "refed", f64::from_bits(JsValue::from_bool(referenced).bits()), @@ -518,24 +519,24 @@ fn array_ptr(value: f64) -> *mut ArrayHeader { fn finish_group(group: f64, synthetic: bool) { let scope = TransientRootScope::enter(); let group = scope.root_nanbox(group); - let children = scope.root_nanbox(p::own_get(group.get(), "children")); + let children = scope.root_nanbox(p::record_get(group.get(), "children")); if unsafe { perry_ffi::js_array_length(array_ptr(children.get())) } != 0 { return; } - if !JsValue::from_bits(p::own_get(group.get(), "closing").to_bits()).to_bool() - || !JsValue::from_bits(p::own_get(group.get(), "listenerClosed").to_bits()).to_bool() + if !JsValue::from_bits(p::record_get(group.get(), "closing").to_bits()).to_bool() + || !JsValue::from_bits(p::record_get(group.get(), "listenerClosed").to_bits()).to_bool() { return; } // Consume the pending close before running user listeners. This is the // ordinary close callback's lifecycle, not a transport-generation latch. - p::own_set( + p::record_set( group.get(), "closing", f64::from_bits(JsValue::FALSE.bits()), ); - let owner = scope.root_nanbox(p::own_get(group.get(), "server")); - let resource = scope.root_nanbox(p::own_get(group.get(), provider::SERVER)); + let owner = scope.root_nanbox(p::record_get(group.get(), "server")); + let resource = scope.root_nanbox(p::record_get(group.get(), provider::SERVER)); if synthetic { events::queue_emit_in(resource.get(), owner.get(), "close", &[], true); } else { @@ -551,7 +552,7 @@ fn finish_pending(owner: f64, synthetic: bool) { return; } let state = scope.root_nanbox(state(owner.get())); - let array = scope.root_nanbox(p::own_get(state.get(), "closeCallbacks")); + let array = scope.root_nanbox(p::record_get(state.get(), "closeCallbacks")); if !JsValue::from_bits(array.get().to_bits()).is_pointer() { return; } @@ -564,7 +565,7 @@ fn finish_pending(owner: f64, synthetic: bool) { }) .collect(); for group in groups { - if !JsValue::from_bits(p::own_get(group.get(), "listenerClosed").to_bits()).to_bool() { + if !JsValue::from_bits(p::record_get(group.get(), "listenerClosed").to_bits()).to_bool() { continue; } let group = scope.root_nanbox(closed::take(state.get(), closed::parts(group.get()))); @@ -585,7 +586,7 @@ pub(crate) fn listener_closed(owner: f64, event: &tl::NetCompletion) { if !JsValue::from_bits(group.get().to_bits()).is_pointer() { return; } - p::own_set( + p::record_set( group.get(), "listenerClosed", f64::from_bits(JsValue::TRUE.bits()), @@ -601,7 +602,7 @@ pub(crate) fn child_closed(child: f64, group: f64) { if !JsValue::from_bits(group.get().to_bits()).is_pointer() { return; } - let children = scope.root_nanbox(p::own_get(group.get(), "children")); + let children = scope.root_nanbox(p::record_get(group.get(), "children")); let count = unsafe { perry_ffi::js_array_length(array_ptr(children.get())) }; let values: Vec<_> = (0..count) .map(|index| { @@ -628,8 +629,8 @@ pub(crate) fn child_closed(child: f64, group: f64) { } as i64); } } - p::own_set(group.get(), "children", p::boxed_addr(remaining.get())); - let server = scope.root_nanbox(p::own_get(group.get(), "server")); + p::record_set(group.get(), "children", p::boxed_addr(remaining.get())); + let server = scope.root_nanbox(p::record_get(group.get(), "server")); let count = JsValue::from_bits(p::own_get(server.get(), "_connections").to_bits()).to_number(); p::own_set(server.get(), "_connections", (count - 1.0).max(0.0)); finish_pending(server.get(), true); @@ -641,7 +642,7 @@ pub(crate) fn accepted(owner: f64, completion: *const tl::NetCompletion) { let server = link(owner.get()); let _account = p::AccountServer(server); let state = scope.root_nanbox(state(owner.get())); - let group = scope.root_nanbox(p::own_get(state.get(), "activeGroup")); + let group = scope.root_nanbox(p::record_get(state.get(), "activeGroup")); let Some((allow_half_open, pause_on_connect, tls_config, snapshot)) = (unsafe { p::server_ptr(server).ok().and_then(|payload| { let fields = &(*payload).ext; @@ -664,7 +665,7 @@ pub(crate) fn accepted(owner: f64, completion: *const tl::NetCompletion) { events::emit(owner.get(), "drop", &[]); return; // The runtime closes the accepted install slot on return. } - let resource = scope.root_nanbox(p::own_get(group.get(), provider::SERVER)); + let resource = scope.root_nanbox(p::record_get(group.get(), provider::SERVER)); let child = scope.root_nanbox(socket::new_socket_with_trigger( super::payload_io::ROUTE, p::undefined(), @@ -699,14 +700,14 @@ pub(crate) fn accepted(owner: f64, completion: *const tl::NetCompletion) { ); p::own_set(child_state.get(), "server", owner.get()); p::own_set(child_state.get(), "serverGroup", group.get()); - let children = scope.root_nanbox(p::own_get(group.get(), "children")); + let children = scope.root_nanbox(p::record_get(group.get(), "children")); let updated = unsafe { perry_ffi::js_array_push( array_ptr(children.get()), JsValue::from_bits(child.get().to_bits()), ) }; - p::own_set(group.get(), "children", p::boxed_addr(updated as i64)); + p::record_set(group.get(), "children", p::boxed_addr(updated as i64)); p::own_set(owner.get(), "_connections", count + 1.0); if let Some(config) = tls_config { if let Err(message) = super::payload_tls::install_server(child.get(), config) { diff --git a/crates/perry-ext-net/src/payload_socket.rs b/crates/perry-ext-net/src/payload_socket.rs index 5f29a994a6..2b2774437a 100644 --- a/crates/perry-ext-net/src/payload_socket.rs +++ b/crates/perry-ext-net/src/payload_socket.rs @@ -44,7 +44,7 @@ pub(crate) fn transport_link(owner: f64) -> Result { return Ok(own); } let state = unsafe { p::socket_state(own, false) }; - let parent = p::own_get(state, "tlsParent"); + let parent = p::record_get(state, "tlsParent"); if JsValue::from_bits(parent.to_bits()).is_undefined() { Ok(own) } else { @@ -84,8 +84,8 @@ pub(crate) fn new_tls_wrapper(parent: f64) -> f64 { initialize_open_state(owner.get(), state.get()); p::own_set(owner.get(), "_parent", parent.get()); let parent_state = scope.root_nanbox(self::state(transport_owner.get())); - p::own_set(state.get(), "tlsParent", transport_owner.get()); - p::own_set(parent_state.get(), "tlsWrapper", owner.get()); + p::record_set(state.get(), "tlsParent", transport_owner.get()); + p::record_set(parent_state.get(), "tlsWrapper", owner.get()); if !open { for key in [ "bytesRead", @@ -99,8 +99,8 @@ pub(crate) fn new_tls_wrapper(parent: f64) -> f64 { "peerPort", "peerFamily", ] { - let value = scope.root_nanbox(p::own_get(parent_state.get(), key)); - p::own_set(state.get(), key, value.get()); + let value = scope.root_nanbox(p::record_get(parent_state.get(), key)); + p::record_set(state.get(), key, value.get()); } } if open { @@ -112,8 +112,8 @@ pub(crate) fn new_tls_wrapper(parent: f64) -> f64 { provider::CONNECT, provider::SHUTDOWN, ] { - let value = scope.root_nanbox(p::own_get(parent_state.get(), key)); - p::own_set(state.get(), key, value.get()); + let value = scope.root_nanbox(p::record_get(parent_state.get(), key)); + p::record_set(state.get(), key, value.get()); } unsafe { p::set_route(parent_link, super::payload_io::TLS_SUBSYSTEM) @@ -155,7 +155,7 @@ pub(crate) fn initialize_socket( None => p::alloc_socket(route, fields), }); let state = scope.root_nanbox(state(owner.get())); - p::own_set( + p::record_set( state.get(), "allowHalfOpen", f64::from_bits(JsValue::from_bool(allow_half_open).bits()), @@ -195,10 +195,10 @@ fn initialize_open_state(owner: f64, state: f64) { "writableEnded", "unconnectedWriteFailed", ] { - p::own_set(state.get(), key, f64::from_bits(JsValue::FALSE.bits())); + p::record_set(state.get(), key, f64::from_bits(JsValue::FALSE.bits())); } for key in ["bytesRead", "bytesWritten"] { - p::own_set(state.get(), key, 0.0); + p::record_set(state.get(), key, 0.0); } for key in [ "closeError", @@ -222,11 +222,11 @@ fn initialize_open_state(owner: f64, state: f64) { "tlsWrapper", "tlsParent", ] { - p::own_set(state.get(), key, p::undefined()); + p::record_set(state.get(), key, p::undefined()); } - p::own_set(state.get(), "refed", f64::from_bits(JsValue::TRUE.bits())); + p::record_set(state.get(), "refed", f64::from_bits(JsValue::TRUE.bits())); let callbacks = unsafe { perry_ffi::js_array_alloc(0) }; - p::own_set(state.get(), "callbacks", p::boxed_addr(callbacks as i64)); + p::record_set(state.get(), "callbacks", p::boxed_addr(callbacks as i64)); let _ = owner; } @@ -298,14 +298,15 @@ pub(crate) fn connect(owner: f64, arg1: f64, arg2: f64, arg3: f64) -> f64 { super::payload_prototype::throw_miss::<()>(PayloadMiss::Closed) }); let allow = - JsValue::from_bits(p::own_get(state.get(), "allowHalfOpen").to_bits()).to_bool(); + JsValue::from_bits(p::record_get(state.get(), "allowHalfOpen").to_bits()).to_bool(); (*p::socket_ptr(socket).expect("reopened Socket")) .ext .allow_half_open = allow; initialize_open_state(owner.get(), state.get()); } } - if JsValue::from_bits(p::own_get(state.get(), "unconnectedWriteFailed").to_bits()).to_bool() { + if JsValue::from_bits(p::record_get(state.get(), "unconnectedWriteFailed").to_bits()).to_bool() + { return owner.get(); } let busy = unsafe { @@ -332,7 +333,7 @@ pub(crate) fn connect(owner: f64, arg1: f64, arg2: f64, arg3: f64) -> f64 { let tcp = scope.root_nanbox(if needs_tcp { prepare_tcp(owner.get(), u64::MAX) } else { - p::own_get(state.get(), provider::TCP) + p::record_get(state.get(), provider::TCP) }); let connect = scope.root_nanbox(provider::publish( state.get(), @@ -420,14 +421,14 @@ pub(crate) fn cache_before_release(owner: f64, socket: OwnerLink) { }; let state = scope.root_nanbox(state(owner.get())); if let Some((read, written, read_ended, write_ended, local, peer)) = cached { - p::own_set(state.get(), "bytesRead", read as f64); - p::own_set(state.get(), "bytesWritten", written as f64); - p::own_set( + p::record_set(state.get(), "bytesRead", read as f64); + p::record_set(state.get(), "bytesWritten", written as f64); + p::record_set( state.get(), "readableEnded", f64::from_bits(JsValue::from_bool(read_ended).bits()), ); - p::own_set( + p::record_set( state.get(), "writableEnded", f64::from_bits(JsValue::from_bool(write_ended).bits()), @@ -435,9 +436,9 @@ pub(crate) fn cache_before_release(owner: f64, socket: OwnerLink) { for (key, address) in [("local", local), ("peer", peer)] { if let Some(address) = address { let endpoint = scope.root_nanbox(events::string(&address.ip().to_string())); - p::own_set(state.get(), &format!("{key}Address"), endpoint.get()); - p::own_set(state.get(), &format!("{key}Port"), address.port() as f64); - p::own_set( + p::record_set(state.get(), &format!("{key}Address"), endpoint.get()); + p::record_set(state.get(), &format!("{key}Port"), address.port() as f64); + p::record_set( state.get(), &format!("{key}Family"), events::string(if address.is_ipv4() { "IPv4" } else { "IPv6" }), @@ -459,11 +460,11 @@ pub(crate) fn cache_before_release(owner: f64, socket: OwnerLink) { "peerPort", "peerFamily", ] { - let value = scope.root_nanbox(p::own_get(parent_state.get(), key)); - p::own_set(state.get(), key, value.get()); + let value = scope.root_nanbox(p::record_get(parent_state.get(), key)); + p::record_set(state.get(), key, value.get()); } } - p::own_set( + p::record_set( state.get(), "destroyed", f64::from_bits(JsValue::TRUE.bits()), @@ -482,7 +483,7 @@ pub(crate) fn destroy(owner: f64, error_value: f64) -> f64 { } let state = scope.root_nanbox(state(owner.get())); if own == socket { - let wrapper = scope.root_nanbox(p::own_get(state.get(), "tlsWrapper")); + let wrapper = scope.root_nanbox(p::record_get(state.get(), "tlsWrapper")); if let Ok(wrapper_link) = p::socket_link(wrapper.get()) { if wrapper.get().to_bits() != owner.get().to_bits() && link(wrapper.get()) == socket @@ -507,8 +508,8 @@ pub(crate) fn destroy(owner: f64, error_value: f64) -> f64 { } else { [0; 4] })); - p::own_set(record.get(), "error", error_value.get()); - p::own_set( + p::record_set(record.get(), "error", error_value.get()); + p::record_set( record.get(), "hadHandle", f64::from_bits(JsValue::TRUE.bits()), @@ -535,7 +536,7 @@ pub(crate) fn destroy(owner: f64, error_value: f64) -> f64 { let close_record = scope.root_nanbox(super::payload_closed::record(parts.unwrap_or([0; 4]))); let parent = scope.root_nanbox(unsafe { np::link_event_owner(socket) }.unwrap_or(owner.get())); let parent_state = scope.root_nanbox(self::state(parent.get())); - let wrapper = scope.root_nanbox(p::own_get(parent_state.get(), "tlsWrapper")); + let wrapper = scope.root_nanbox(p::record_get(parent_state.get(), "tlsWrapper")); if p::socket_link(wrapper.get()).is_ok() { p::own_set(close_record.get(), "tlsWrapper", wrapper.get()); } @@ -563,7 +564,7 @@ pub(crate) fn destroy(owner: f64, error_value: f64) -> f64 { p::undefined() }, ); - let group = scope.root_nanbox(p::own_get(state.get(), "serverGroup")); + let group = scope.root_nanbox(p::record_get(state.get(), "serverGroup")); p::own_set(close_record.get(), "serverGroup", group.get()); } let result = unsafe { @@ -581,12 +582,12 @@ pub(crate) fn destroy(owner: f64, error_value: f64) -> f64 { } }; if had_error { - p::own_set( + p::record_set( state.get(), "hadError", f64::from_bits(JsValue::TRUE.bits()), ); - p::own_set(state.get(), "closeError", error_value.get()); + p::record_set(state.get(), "closeError", error_value.get()); } cache_before_release(owner.get(), socket); if own != socket { @@ -632,7 +633,7 @@ pub(crate) fn destroy(owner: f64, error_value: f64) -> f64 { let parent_record = scope.root_nanbox(super::payload_closed::record(parts.unwrap_or([0; 4]))); p::own_set(parent_record.get(), "tlsWrapper", owner.get()); - let group = scope.root_nanbox(p::own_get(parent_state.get(), "serverGroup")); + let group = scope.root_nanbox(p::record_get(parent_state.get(), "serverGroup")); p::own_set(parent_record.get(), "serverGroup", group.get()); provider::capture_socket(parent_state.get(), parent_record.get()); super::payload_closed::append(parent_state.get(), parent_record.get()); @@ -646,7 +647,7 @@ pub(crate) fn destroy(owner: f64, error_value: f64) -> f64 { "hadHandle", f64::from_bits(JsValue::FALSE.bits()), ); - let group = scope.root_nanbox(p::own_get(parent_state.get(), "serverGroup")); + let group = scope.root_nanbox(p::record_get(parent_state.get(), "serverGroup")); p::own_set(parent_record.get(), "serverGroup", group.get()); provider::capture_socket(parent_state.get(), parent_record.get()); super::payload_io::queue_socket_closed(parent.get(), parent_record.get()); @@ -679,7 +680,7 @@ fn register_callback(owner: f64, callback: f64) -> u64 { fields.callback_seq }; let state = scope.root_nanbox(state(owner.get())); - let callbacks = scope.root_nanbox(p::own_get(state.get(), "callbacks")); + let callbacks = scope.root_nanbox(p::record_get(state.get(), "callbacks")); let entry = scope.root_addr(unsafe { perry_ffi::js_array_alloc(2) } as i64); unsafe { let entry_ptr = perry_ffi::js_array_push( @@ -696,7 +697,7 @@ fn register_callback(owner: f64, callback: f64) -> u64 { array_ptr(callbacks.get()), JsValue::from_object_ptr(entry.get() as *mut ArrayHeader), ); - p::own_set(state.get(), "callbacks", p::boxed_addr(updated as i64)); + p::record_set(state.get(), "callbacks", p::boxed_addr(updated as i64)); } user } @@ -710,7 +711,7 @@ pub(crate) fn take_callback(owner: f64, user: u64) -> f64 { let scope = TransientRootScope::enter(); let owner = scope.root_nanbox(owner); let state = scope.root_nanbox(state(owner.get())); - let callbacks = scope.root_nanbox(p::own_get(state.get(), "callbacks")); + let callbacks = scope.root_nanbox(p::record_get(state.get(), "callbacks")); unsafe { let callbacks_ptr = array_ptr(callbacks.get()); if callbacks_ptr.is_null() || perry_ffi::js_array_length(callbacks_ptr) == 0 { @@ -758,12 +759,12 @@ pub(crate) fn cancel_callbacks(owner: f64, state: f64, error: f64) { let owner = scope.root_nanbox(owner); let state = scope.root_nanbox(state); let error = scope.root_nanbox(error); - let callbacks = scope.root_nanbox(p::own_get(state.get(), "callbacks")); + let callbacks = scope.root_nanbox(p::record_get(state.get(), "callbacks")); if unsafe { perry_ffi::js_array_length(array_ptr(callbacks.get())) } == 0 { return; } let replacement = unsafe { perry_ffi::js_array_alloc(0) }; - p::own_set(state.get(), "callbacks", p::boxed_addr(replacement as i64)); + p::record_set(state.get(), "callbacks", p::boxed_addr(replacement as i64)); let resource = scope.root_nanbox(provider::resource(owner.get())); let job = scope.root_addr(perry_ffi::alloc_closure( perry_ffi::js_function_info!(cancel_tick, 0; with_flags(perry_ffi::FN_BUILTIN)), @@ -792,10 +793,11 @@ fn unopened_write_failure(owner: f64, callback: f64) { let callback = scope.root_nanbox(callback); register_callback(owner.get(), callback.get()); let state = scope.root_nanbox(state(owner.get())); - if JsValue::from_bits(p::own_get(state.get(), "unconnectedWriteFailed").to_bits()).to_bool() { + if JsValue::from_bits(p::record_get(state.get(), "unconnectedWriteFailed").to_bits()).to_bool() + { return; } - p::own_set( + p::record_set( state.get(), "unconnectedWriteFailed", f64::from_bits(JsValue::TRUE.bits()), @@ -821,34 +823,39 @@ pub(crate) fn write(owner: f64, chunk: f64, encoding: f64, callback: f64) -> f64 } else { callback.get() }); - let socket = link(owner.get()); - let _account = p::AccountSocket(socket); - let lifecycle = unsafe { np::link_lifecycle(socket, &SOCKET) }; - if lifecycle == Ok(np::Lifecycle::Closed) { - if is_callback(callback.get()) { - let error = error( - "ERR_STREAM_DESTROYED", - "Cannot call write after a stream was destroyed", - ); - events::queue_call(owner.get(), callback.get(), &[error]); - } - return f64::from_bits(JsValue::FALSE.bits()); - } + // Conversion may materialize an SSO string. Prove the receiver only after + // that allocation, then retain the same native window until JS can run. let bytes = unsafe { crate::jsvalue_to_socket_bytes(chunk.get()) }.unwrap_or_default(); - let unopened = unsafe { - let fields = - &(*p::socket_ptr(socket).unwrap_or_else(super::payload_prototype::throw_miss)).ext; - !fields.opened && !fields.connecting && fields.cork_depth == 0 + let window = match unsafe { np::project::(owner.get(), &SOCKET) } { + Ok(window) => window, + Err(miss) => { + let socket = link(owner.get()); + if unsafe { np::link_lifecycle(socket, &SOCKET) } == Ok(np::Lifecycle::Closed) { + if is_callback(callback.get()) { + let error = error( + "ERR_STREAM_DESTROYED", + "Cannot call write after a stream was destroyed", + ); + events::queue_call(owner.get(), callback.get(), &[error]); + } + return f64::from_bits(JsValue::FALSE.bits()); + } + super::payload_prototype::throw_miss(miss) + } + }; + let socket = window.link; + let _account = p::AccountSocket(socket); + let (unopened, ended) = unsafe { + let fields = &(*window.payload).ext; + ( + !fields.opened && !fields.connecting && fields.cork_depth == 0, + fields.write_ended, + ) }; if unopened { unopened_write_failure(owner.get(), callback.get()); return f64::from_bits(JsValue::FALSE.bits()); } - let ended = unsafe { - (*p::socket_ptr(socket).unwrap_or_else(super::payload_prototype::throw_miss)) - .ext - .write_ended - }; if ended { let error = scope.root_nanbox(error("ERR_STREAM_WRITE_AFTER_END", "write after end")); if is_callback(callback.get()) { @@ -857,10 +864,19 @@ pub(crate) fn write(owner: f64, chunk: f64, encoding: f64, callback: f64) -> f64 destroy(owner.get(), error.get()); return f64::from_bits(JsValue::FALSE.bits()); } - let user = register_callback(owner.get(), callback.get()); + let (user, window) = if is_callback(callback.get()) { + let user = register_callback(owner.get(), callback.get()); + // Callback registration allocates, so its old projection ends here. + let window = unsafe { np::project::(owner.get(), &SOCKET) } + .unwrap_or_else(super::payload_prototype::throw_miss); + (user, window) + } else { + (0, window) + }; + let payload = window.payload; + let may_run_js = unsafe { (*payload).ext.tls.is_some() }; let corked = unsafe { - let fields = - &mut (*p::socket_ptr(socket).unwrap_or_else(super::payload_prototype::throw_miss)).ext; + let fields = &mut (*window.payload).ext; if fields.cork_depth != 0 { fields.queued = fields.queued.saturating_add(bytes.len()); fields.cork_bytes.extend_from_slice(&bytes); @@ -873,10 +889,12 @@ pub(crate) fn write(owner: f64, chunk: f64, encoding: f64, callback: f64) -> f64 } }; if !corked { - let result = super::payload_tls::write(owner.get(), &bytes, user); + let result = unsafe { super::payload_tls::write_proven(owner.get(), window, &bytes, user) }; match result { Ok(queued) => unsafe { - if let Ok(payload) = p::socket_ptr(socket) { + if !may_run_js { + (*payload).ext.queued = queued; + } else if let Ok(payload) = p::socket_ptr(socket) { (*payload).ext.queued = queued; } }, @@ -887,12 +905,18 @@ pub(crate) fn write(owner: f64, chunk: f64, encoding: f64, callback: f64) -> f64 } } } - if unsafe { p::socket_ptr(socket) }.is_err() { - return f64::from_bits(JsValue::FALSE.bits()); - } + // A TLS drive can invoke JS and replace the payload. Plain submission and + // corking cannot; use their proven pointer rather than validating again. + let payload = if may_run_js && !corked { + match unsafe { p::socket_ptr(socket) } { + Ok(payload) => payload, + Err(_) => return f64::from_bits(JsValue::FALSE.bits()), + } + } else { + payload + }; let below = unsafe { - let fields = - &mut (*p::socket_ptr(socket).unwrap_or_else(super::payload_prototype::throw_miss)).ext; + let fields = &mut (*payload).ext; let below = fields.queued < HIGH_WATER_MARK; fields.need_drain |= !below; below @@ -1025,7 +1049,7 @@ pub(crate) fn end(owner: f64, chunk: f64, encoding: f64, callback: f64) -> f64 { } let snapshot = super::payload_io::snapshot(socket); let state = scope.root_nanbox(state(owner.get())); - let tcp = scope.root_nanbox(p::own_get(state.get(), provider::TCP)); + let tcp = scope.root_nanbox(p::record_get(state.get(), provider::TCP)); let shutdown = scope.root_nanbox(provider::publish( state.get(), provider::SHUTDOWN, @@ -1154,7 +1178,7 @@ pub(crate) fn set_ref(owner: f64, referenced: bool) -> f64 { } } let state = scope.root_nanbox(state(owner.get())); - p::own_set( + p::record_set( state.get(), "refed", f64::from_bits(JsValue::from_bool(referenced).bits()), @@ -1169,18 +1193,18 @@ pub(crate) fn get(owner: f64, key: &str) -> f64 { let _account = p::AccountSocket(socket); let state = scope.root_nanbox(state(owner.get())); if key == "destroyed" { - return p::own_get(state.get(), key); + return p::record_get(state.get(), key); } if key == "writableHighWaterMark" || key == "readableHighWaterMark" { return HIGH_WATER_MARK as f64; } if key == "_readableState" || key == "_writableState" { - let cached = scope.root_nanbox(p::own_get(state.get(), key)); + let cached = scope.root_nanbox(p::record_get(state.get(), key)); if JsValue::from_bits(cached.get().to_bits()).is_pointer() { return cached.get(); } let object = scope.root_nanbox(f64::from_bits(perry_ffi::alloc_object().bits())); - p::own_set(state.get(), key, object.get()); + p::record_set(state.get(), key, object.get()); return object.get(); } enum Scalar { @@ -1291,7 +1315,7 @@ pub(crate) fn get(owner: f64, key: &str) -> f64 { "remoteFamily" => "peerFamily", key => key, }; - p::own_get(state.get(), key) + p::record_get(state.get(), key) } } } @@ -1330,11 +1354,15 @@ pub(crate) fn refresh_timeout(socket: OwnerLink) { let Ok(payload) = p::socket_ptr(socket) else { return; }; - let timeout = (*payload).ext.timeout_ms; - if timeout != 0 { - let core = std::ptr::addr_of_mut!((*payload).core); - let _ = tl::link_deadline_arm(&mut *core, socket, timeout); - } + refresh_timeout_proven(&mut *payload, socket); + } +} + +/// Call only inside a callback-free projected window. +pub(crate) fn refresh_timeout_proven(payload: &mut p::SocketPayload, socket: OwnerLink) { + let timeout = payload.ext.timeout_ms; + if timeout != 0 { + let _ = tl::link_deadline_arm(&mut payload.core, socket, timeout); } } @@ -1461,38 +1489,80 @@ pub(crate) fn flow(owner: f64) { queue_read_end(owner.get()); } +/// Owned policy copied out before allocating a chunk or invoking listeners. +enum DataDelivery { + Silent, + Readable, + Chunk(Option), +} + +fn data_delivery(payload: &mut p::SocketPayload, bytes: &[u8], listeners: usize) -> DataDelivery { + let fields = &mut payload.ext; + if fields.paused || fields.raw_consumer || (listeners == 0 && fields.flowing != Some(true)) { + fields.read_buffer.extend(bytes.iter().copied()); + return if fields.raw_consumer { + DataDelivery::Silent + } else { + DataDelivery::Readable + }; + } + if listeners == 0 { + DataDelivery::Silent + } else { + DataDelivery::Chunk(fields.encoding.clone()) + } +} + +/// The completion already proved this plain socket. No native borrow crosses +/// chunk allocation or a listener; its resource is rooted once for dispatch. +/// # Safety +/// window is a live callback-free projection and owner is rooted in scope. +pub(crate) unsafe fn data_proven( + scope: &TransientRootScope, + owner: &perry_ffi::TransientRootedNanbox, + window: np::PayloadWindow, + bytes: &[u8], + listeners: usize, +) { + let delivery = data_delivery(&mut *window.payload, bytes, listeners); + if !matches!(delivery, DataDelivery::Chunk(_)) { + p::account_socket(window.link); + } + let resource = scope.root_nanbox(p::record_get(window.state, provider::TCP)); + deliver_data(scope, owner, &resource, delivery, bytes); +} + pub(crate) fn data(owner: f64, bytes: &[u8]) { let scope = TransientRootScope::enter(); let owner = scope.root_nanbox(owner); + let listeners = events::listener_count(owner.get(), "data"); let socket = link(owner.get()); let _account = p::AccountSocket(socket); - let listeners = events::listener_count(owner.get(), "data"); - let mut buffered = false; - let encoding = unsafe { - p::socket_ptr(socket).ok().and_then(|payload| { - let fields = &mut (*payload).ext; - if fields.paused - || fields.raw_consumer - || (listeners == 0 && fields.flowing != Some(true)) - { - fields.read_buffer.extend(bytes.iter().copied()); - buffered = !fields.raw_consumer; - return None; - } - // Explicit resume consumes an unread stream, as in Node. Keeping - // these bytes would postpone EOF forever when there is no data - // listener, stranding its peer and a closing server's last child. - if listeners == 0 { - return None; - } - Some(fields.encoding.clone()) - }) + let delivery = unsafe { + p::socket_ptr(socket) + .ok() + .map(|payload| data_delivery(&mut *payload, bytes, listeners)) }; - let Some(encoding) = encoding else { - if buffered { - events::emit(owner.get(), "readable", &[]); + if let Some(delivery) = delivery { + let resource = scope.root_nanbox(provider::resource(owner.get())); + deliver_data(&scope, &owner, &resource, delivery, bytes); + } +} + +fn deliver_data( + scope: &TransientRootScope, + owner: &perry_ffi::TransientRootedNanbox, + resource: &perry_ffi::TransientRootedNanbox, + delivery: DataDelivery, + bytes: &[u8], +) { + let encoding = match delivery { + DataDelivery::Silent => return, + DataDelivery::Readable => { + events::emit_rooted(scope, resource, owner, "readable", &[]); + return; } - return; + DataDelivery::Chunk(encoding) => encoding, }; let value = match encoding.as_deref() { Some("hex") => events::string( @@ -1505,7 +1575,7 @@ pub(crate) fn data(owner: f64, bytes: &[u8]) { Some(_) => events::string(&String::from_utf8_lossy(bytes)), None => f64::from_bits(JsValue::from_object_ptr(perry_ffi::alloc_buffer(bytes)).bits()), }; - events::emit(owner.get(), "data", &[value]); + events::emit_rooted(scope, resource, owner, "data", &[value]); } fn base64_encode(bytes: &[u8]) -> String { diff --git a/crates/perry-ext-net/src/payload_tls.rs b/crates/perry-ext-net/src/payload_tls.rs index e450efae36..714c0a79ac 100644 --- a/crates/perry-ext-net/src/payload_tls.rs +++ b/crates/perry-ext-net/src/payload_tls.rs @@ -140,11 +140,27 @@ pub(crate) fn queued(link: OwnerLink) -> usize { pub(crate) fn write(owner: f64, bytes: &[u8], user: u64) -> Result { let scope = TransientRootScope::enter(); let owner = scope.root_nanbox(owner); - let link = socket::link(owner.get()); - let _account = p::AccountSocket(link); - unsafe { - let payload = p::socket_ptr(link).map_err(|_| bad_fd("write"))?; - let fields = &mut (*payload).ext; + let window = + unsafe { perry_ffi::native_payload::project::(owner.get(), &p::SOCKET) } + .map_err(|_| bad_fd("write"))?; + let _account = p::AccountSocket(window.link); + unsafe { write_proven(owner.get(), window, bytes, user) } +} + +/// Submit through the caller's proven payload. `owner` is rooted and the +/// window is current; the caller accounts retained bytes after submission. +/// No JS may run before the native borrow ends. TLS drive +/// runs only after that borrow, and may close or reopen the owner. +pub(crate) unsafe fn write_proven( + owner: f64, + window: perry_ffi::native_payload::PayloadWindow, + bytes: &[u8], + user: u64, +) -> Result { + let link = window.link; + { + let payload = &mut *window.payload; + let fields = &mut payload.ext; if let Some(layer) = fields.tls.as_mut() { layer.session.write(bytes); layer.pending.push_back(TlsWrite { @@ -160,15 +176,10 @@ pub(crate) fn write(owner: f64, bytes: &[u8], user: u64) -> Result) { let scope = TransientRootScope::enter(); let owner = scope.root_nanbox(owner); let state = scope.root_nanbox(socket::state(owner.get())); - let promise = scope.root_nanbox(p::own_get(state.get(), "upgradePromise")); + let promise = scope.root_nanbox(p::record_get(state.get(), "upgradePromise")); if !JsValue::from_bits(promise.get().to_bits()).is_pointer() { return; } - p::own_set(state.get(), "upgradePromise", p::undefined()); + p::record_set(state.get(), "upgradePromise", p::undefined()); let promise = unsafe { perry_ffi::JsPromise::from_raw( JsValue::from_bits(promise.get().to_bits()).as_pointer::(), diff --git a/crates/perry-ext-net/src/payload_transport.rs b/crates/perry-ext-net/src/payload_transport.rs index 631ce02130..d3cbbb855e 100644 --- a/crates/perry-ext-net/src/payload_transport.rs +++ b/crates/perry-ext-net/src/payload_transport.rs @@ -171,10 +171,14 @@ pub(crate) unsafe fn account_socket(link: OwnerLink) { let Ok(payload) = socket_ptr(link) else { return; }; - let fields = (*payload).ext.retained_bytes(); - let core = std::ptr::addr_of_mut!((*payload).core); - let bytes = - std::mem::size_of::() + fields + tl::link_retained_bytes(&mut *core, link); + account_socket_proven(&mut *payload, link); +} +/// Restate retained bytes without repeating a live payload proof. +pub(crate) unsafe fn account_socket_proven(payload: &mut SocketPayload, link: OwnerLink) { + let fields = payload.ext.retained_bytes(); + let bytes = std::mem::size_of::() + + fields + + tl::link_retained_bytes(&mut payload.core, link); np::link_set_external_bytes(link, &SOCKET, bytes); } pub(crate) unsafe fn account_server(link: OwnerLink) { @@ -253,6 +257,9 @@ pub(crate) fn attach_socket(owner: f64, route: u8, fields: SocketFields) -> bool super::payload_io::register(); let scope = TransientRootScope::enter(); let owner = scope.root_nanbox(owner); + // A subclass can be the family's first instance. Adopt and install the + // canonical prototype before attaching its cell, just as alloc_in does. + np::prototype(&SOCKET, "net"); let bytes = std::mem::size_of::() + fields.retained_bytes(); if !unsafe { np::attach_to_object( @@ -273,6 +280,7 @@ pub(crate) fn attach_server(owner: f64, route: u8, fields: ServerFields) -> bool super::payload_io::register(); let scope = TransientRootScope::enter(); let owner = scope.root_nanbox(owner); + np::prototype(&SERVER, "net"); let bytes = std::mem::size_of::() + fields.path.as_ref().map_or(0, String::capacity); if !unsafe { @@ -311,6 +319,9 @@ pub(crate) unsafe fn server_ptr(link: OwnerLink) -> Result<*mut ServerPayload, P pub(crate) fn own_get(owner: f64, key: &str) -> f64 { f64::from_bits(perry_ffi::object_field_by_name(JsValue::from_bits(owner.to_bits()), key).bits()) } +pub(crate) fn record_get(owner: f64, key: &str) -> f64 { + f64::from_bits(perry_ffi::object_record_field(JsValue::from_bits(owner.to_bits()), key).bits()) +} pub(crate) fn own_set(owner: f64, key: &str, value: f64) { let scope = TransientRootScope::enter(); let owner = scope.root_nanbox(owner); @@ -325,6 +336,20 @@ pub(crate) fn own_set(owner: f64, key: &str, value: f64) { } } +/// Set an opaque binding record. Existing data keys use the ordinary slot +/// barrier without allocating a key; only the record's birth adds keys. +pub(crate) fn record_set(record: f64, key: &str, value: f64) { + if !unsafe { + perry_ffi::object_record_set( + JsValue::from_bits(record.to_bits()), + key, + JsValue::from_bits(value.to_bits()), + ) + } { + own_set(record, key, value); + } +} + /// JS state follows the owner through moving collections and survives release. /// Callers root the result before any later allocation. pub(crate) unsafe fn socket_state(link: OwnerLink, create: bool) -> f64 { diff --git a/crates/perry-ffi/src/jsvalue.rs b/crates/perry-ffi/src/jsvalue.rs index d4050b737e..946f8138d3 100644 --- a/crates/perry-ffi/src/jsvalue.rs +++ b/crates/perry-ffi/src/jsvalue.rs @@ -335,6 +335,12 @@ impl std::fmt::Debug for JsValue { // ── string arguments for `*const StringHeader` natives ─────────── extern "C" { + fn js_object_record_set_by_bytes( + obj: *mut ObjectHeader, + key: *const u8, + len: usize, + value: JsValue, + ) -> i32; fn js_ffi_arg_ptr(value: f64) -> i64; } @@ -405,6 +411,11 @@ extern "C" { fn js_object_alloc(class_id: u32, field_count: u32) -> *mut ObjectHeader; fn js_object_alloc_null_proto(class_id: u32, field_count: u32) -> *mut ObjectHeader; fn js_object_set_keys(obj: *mut ObjectHeader, keys_array: *mut ArrayHeader); + fn js_object_record_get_by_bytes( + obj: *const ObjectHeader, + key: *const u8, + len: usize, + ) -> JsValue; fn js_object_get_field_by_name(obj: *const ObjectHeader, key: *const StringHeader) -> JsValue; #[link_name = "js_set_alloc"] @@ -465,6 +476,23 @@ pub fn set_delete(set: JsValue, value: JsValue) -> bool { && unsafe { runtime_js_set_delete(set_ptr, f64::from_bits(value.bits())) != 0 } } +/// Callback-free own-data lookup on an opaque binding record. No allocation +/// or prototype/accessor semantics; public property reads use object_field_by_name. +pub fn object_record_field(value: JsValue, key: &str) -> JsValue { + if !value.is_pointer() { + return JsValue::UNDEFINED; + } + unsafe { js_object_record_get_by_bytes(value.as_pointer(), key.as_ptr(), key.len()) } +} + +/// Overwrite an existing own data field without allocation or JS callbacks. +/// # Safety +/// record is a rooted, mutable opaque binding record inaccessible to JS. +pub unsafe fn object_record_set(record: JsValue, key: &str, value: JsValue) -> bool { + record.is_pointer() + && js_object_record_set_by_bytes(record.as_pointer(), key.as_ptr(), key.len(), value) != 0 +} + /// Compute `(packed_keys_bytes, shape_id)` for use with /// [`js_object_alloc_with_shape`]. /// diff --git a/crates/perry-ffi/src/lib.rs b/crates/perry-ffi/src/lib.rs index 2636c97633..ab20d278c9 100644 --- a/crates/perry-ffi/src/lib.rs +++ b/crates/perry-ffi/src/lib.rs @@ -105,7 +105,8 @@ pub use jsvalue::{ alloc_null_proto_object, alloc_object, alloc_set, build_object_shape, js_array_alloc, js_array_get, js_array_length, js_array_push, js_array_set, js_object_alloc_with_shape, js_object_get_field, js_object_live_slot_count, js_object_set_field, object_field_by_name, - set_add, set_delete, string_arg_ptr, JsValue, SHORT_STRING_MAX_LEN, + object_record_field, object_record_set, set_add, set_delete, string_arg_ptr, JsValue, + SHORT_STRING_MAX_LEN, }; mod closure; diff --git a/crates/perry-ffi/src/native_payload_links.rs b/crates/perry-ffi/src/native_payload_links.rs index c3b9bf0933..503bdf0cf6 100644 --- a/crates/perry-ffi/src/native_payload_links.rs +++ b/crates/perry-ffi/src/native_payload_links.rs @@ -176,6 +176,13 @@ extern "C" { family: *const PayloadFamily, bytes: usize, ); + fn js_perry_payload_project( + value: f64, + family: *const PayloadFamily, + link: *mut usize, + state: *mut f64, + miss: *mut i32, + ) -> *mut c_void; fn js_perry_payload_receiver_link( value: f64, family: *const PayloadFamily, @@ -251,6 +258,40 @@ pub fn receiver_link(value: f64, family: &'static PayloadFamily) -> Result { + /// The family's live native resource for this window. + pub payload: *mut T, + /// The receiver's stable owner link. + pub link: OwnerLink, + /// Its existing ordinary JS state, or undefined. + pub state: f64, +} + +/// Validate the receiver once and obtain its payload, link and existing state. +/// # Safety +/// T matches the family; the receiver is rooted. End the window before any +/// allocation, JS, release or reopen. No field is an independent GC root. +pub unsafe fn project( + value: f64, + family: &'static PayloadFamily, +) -> Result, PayloadMiss> { + let mut link = 0; + let mut state = f64::from_bits(crate::JsValue::UNDEFINED.bits()); + let mut miss = 0; + let payload = js_perry_payload_project(value, family, &mut link, &mut state, &mut miss); + if payload.is_null() { + Err(miss_code(miss)) + } else { + Ok(PayloadWindow { + payload: payload.cast(), + link: OwnerLink(link), + state, + }) + } +} + /// The OPEN payload behind a link. /// # Safety /// A rooted owner or an outstanding ref keeps the cell alive. T is the diff --git a/crates/perry-ffi/src/transient_roots.rs b/crates/perry-ffi/src/transient_roots.rs index 314a858c2f..3dcb280178 100644 --- a/crates/perry-ffi/src/transient_roots.rs +++ b/crates/perry-ffi/src/transient_roots.rs @@ -30,6 +30,11 @@ pub struct TransientRootScope { } impl TransientRootScope { + /// Existing scope token for a runtime dispatch that borrows these roots. + /// Valid only on this thread until this scope drops. + pub fn runtime_token(&self) -> usize { + self.base + } /// Snapshot the current stack depth; `Drop` truncates back to it. pub fn enter() -> Self { Self { @@ -85,6 +90,10 @@ pub struct TransientRootedNanbox { } impl TransientRootedNanbox { + /// Existing root token, valid only while its creating scope remains live. + pub fn runtime_token(&self) -> usize { + self.index + } /// The post-collection value. Re-read at every use. pub fn get(&self) -> f64 { f64::from_bits(unsafe { js_ffi_root_get_nanbox(self.index) }) diff --git a/crates/perry-runtime/src/async_hooks.rs b/crates/perry-runtime/src/async_hooks.rs index 44d3cff154..02aceaa27c 100644 --- a/crates/perry-runtime/src/async_hooks.rs +++ b/crates/perry-runtime/src/async_hooks.rs @@ -25,7 +25,6 @@ use arg_values::{ throw_apply_not_function, trigger_id_from_options, validate_bind_callback, }; mod provider_ffi; -pub(crate) use provider_ffi::owned_provider_scope; pub use provider_ffi::{ defer_destroy_after_check_turns, js_async_hooks_provider_defer_destroy, js_async_hooks_provider_destroy, js_async_hooks_provider_enter, js_async_hooks_provider_init, @@ -34,6 +33,7 @@ pub use provider_ffi::{ js_async_hooks_provider_run_catching_deferred_destroy_on_error, js_async_hooks_provider_run_catching_with_this, }; +pub(crate) use provider_ffi::{owned_provider_scope, owned_provider_scope_rooted}; mod scopes; pub use scopes::{ enter_resource_scope, leave_resource_scope, run_provider_completion, run_resource_scope, diff --git a/crates/perry-runtime/src/async_hooks/provider_ffi.rs b/crates/perry-runtime/src/async_hooks/provider_ffi.rs index b3cbdf5f78..fefa196a6f 100644 --- a/crates/perry-runtime/src/async_hooks/provider_ffi.rs +++ b/crates/perry-runtime/src/async_hooks/provider_ffi.rs @@ -90,6 +90,15 @@ pub(crate) fn owned_provider_scope( ) -> Result { let scope = crate::gc::RuntimeHandleScope::new(); let owner = scope.root_nanbox_f64(resource); + owned_provider_scope_rooted(&scope, &owner, callback) +} + +/// Share the event boundary's roots; before/after hooks may move its inputs. +pub(crate) fn owned_provider_scope_rooted( + scope: &crate::gc::RuntimeHandleScope, + owner: &crate::gc::RuntimeHandle<'_>, + callback: impl FnOnce() -> f64, +) -> Result { if !crate::value::JSValue::from_bits(owner.get_nanbox_f64().to_bits()).is_pointer() { return crate::exception::js_call_catching(callback); } diff --git a/crates/perry-runtime/src/gc/roots/runtime_handles.rs b/crates/perry-runtime/src/gc/roots/runtime_handles.rs index 7289d24119..5e52e51ba8 100644 --- a/crates/perry-runtime/src/gc/roots/runtime_handles.rs +++ b/crates/perry-runtime/src/gc/roots/runtime_handles.rs @@ -113,6 +113,29 @@ pub struct RuntimeHandleScope { } impl RuntimeHandleScope { + /// Borrow the binding's existing dispatch scope without owning its exit. + /// # Safety + /// base names a live FFI scope on this thread; the view cannot outlive it. + pub(crate) unsafe fn borrow_ffi(base: usize) -> std::mem::ManuallyDrop { + let stack = runtime_handle_stack(); + assert!(base <= stack.len()); + std::mem::ManuallyDrop::new(Self { base, stack }) + } + + /// View an existing FFI root instead of pushing the same value twice. + /// # Safety + /// index belongs to the live borrowed FFI scope and holds a NaN-box slot. + pub(crate) unsafe fn ffi_nanbox_handle(&self, index: usize) -> RuntimeHandle<'_> { + assert!( + index >= self.base + && matches!(self.stack.get(index), Some(RuntimeHandleSlot::Nanbox(_))) + ); + RuntimeHandle { + index, + stack: self.stack, + _scope: PhantomData, + } + } #[inline] pub fn new() -> Self { let stack = runtime_handle_stack(); diff --git a/crates/perry-runtime/src/gc/roots/runtime_handles/tests.rs b/crates/perry-runtime/src/gc/roots/runtime_handles/tests.rs index 4e50752d22..b6be0a632c 100644 --- a/crates/perry-runtime/src/gc/roots/runtime_handles/tests.rs +++ b/crates/perry-runtime/src/gc/roots/runtime_handles/tests.rs @@ -1,5 +1,31 @@ use super::*; +#[test] +fn borrowed_ffi_dispatch_keeps_one_root_and_leaves_exit_to_its_owner() { + let base = js_ffi_root_scope_enter(); + let index = js_ffi_root_push_nanbox(13.0f64.to_bits()); + { + let view = unsafe { RuntimeHandleScope::borrow_ffi(base) }; + let root = unsafe { view.ffi_nanbox_handle(index) }; + assert_eq!(RuntimeHandleScope::active_len_for_tests(), base + 1); + { + let nested = RuntimeHandleScope::new(); + for i in 0..257 { + nested.root_nanbox_f64(i as f64); + } + root.set_nanbox_f64(31.0); + } + assert_eq!(js_ffi_root_get_nanbox(index), 31.0f64.to_bits()); + view.root_nanbox_f64(17.0); + } + // Dropping the borrowed view cannot truncate either the binding's roots + // or the arguments/listener roots added while delivering its event. + assert_eq!(RuntimeHandleScope::active_len_for_tests(), base + 2); + assert_eq!(js_ffi_root_get_nanbox(index), 31.0f64.to_bits()); + js_ffi_root_scope_exit(base); + assert_eq!(RuntimeHandleScope::active_len_for_tests(), base); +} + #[test] fn heap_word_iterator_reads_refreshed_slots_after_stack_growth() { let scope = RuntimeHandleScope::new(); diff --git a/crates/perry-runtime/src/gc/tests/rooted_container_values.rs b/crates/perry-runtime/src/gc/tests/rooted_container_values.rs index 3daa88c2b2..4a0499aca3 100644 --- a/crates/perry-runtime/src/gc/tests/rooted_container_values.rs +++ b/crates/perry-runtime/src/gc/tests/rooted_container_values.rs @@ -43,6 +43,83 @@ fn register_handle_scanner() { ); } +#[test] +fn opaque_record_writes_preserve_inline_and_overflow_edges_when_they_move() { + let _guard = CopyingNurseryTestGuard::new(0); + let _triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + register_handle_scanner(); + let scope = RuntimeHandleScope::new(); + let record = scope.root_nanbox_f64(crate::value::js_nanbox_pointer( + crate::object::js_object_alloc_null_proto(0, 0) as i64, + )); + for i in 0..32 { + let name = format!("field{i}"); + let key = crate::string::js_string_from_bytes(name.as_ptr(), name.len() as u32); + unsafe { + crate::object::js_object_set_field_by_name( + crate::value::js_nanbox_get_pointer(record.get_nanbox_f64()) as *mut _, + key, + f64::from_bits(crate::value::TAG_UNDEFINED), + ); + } + } + let obj = crate::value::js_nanbox_get_pointer(record.get_nanbox_f64()) + as *mut crate::object::ObjectHeader; + assert!( + unsafe { crate::object::object_live_slot_count(obj) } < 31, + "premise: field31 must exercise overflow storage" + ); + let children = [ + scope.root_nanbox_f64(string_value("record-write-inline")), + scope.root_nanbox_f64(string_value("record-write-overflow")), + ]; + let before: Vec<_> = children + .iter() + .map(|child| child.get_nanbox_f64().to_bits()) + .collect(); + for (key, child) in [b"field0".as_slice(), b"field31".as_slice()] + .into_iter() + .zip(&children) + { + assert_eq!( + unsafe { + crate::object::js_object_record_set_by_bytes( + obj, + key.as_ptr(), + key.len(), + crate::JSValue::from_bits(child.get_nanbox_f64().to_bits()), + ) + }, + 1 + ); + } + let trace = collect_minor_trace(GcTriggerKind::Direct); + assert!( + trace.copying_nursery.copied_objects > 0, + "premise: the collection must copy" + ); + for ((key, child), old) in [b"field0".as_slice(), b"field31".as_slice()] + .into_iter() + .zip(&children) + .zip(before) + { + assert_ne!( + child.get_nanbox_f64().to_bits(), + old, + "premise: the child must move" + ); + let obj = crate::value::js_nanbox_get_pointer(record.get_nanbox_f64()) + as *const crate::object::ObjectHeader; + let stored = + unsafe { crate::object::js_object_record_get_by_bytes(obj, key.as_ptr(), key.len()) }; + assert_eq!( + stored.bits(), + child.get_nanbox_f64().to_bits(), + "record edge was not rewritten" + ); + } +} + #[test] fn rooted_values_elements_survive_a_collection_that_moved_them() { let _guard = CopyingNurseryTestGuard::new(0); diff --git a/crates/perry-runtime/src/native_payload.rs b/crates/perry-runtime/src/native_payload.rs index 94eb832449..9536a71942 100644 --- a/crates/perry-runtime/src/native_payload.rs +++ b/crates/perry-runtime/src/native_payload.rs @@ -1279,7 +1279,7 @@ unsafe fn raw_field_memo(obj: *mut ObjectHeader, key: &[u8], memo: &'static Stat } /// The JS state object of `obj` if it has one (no allocation). -unsafe fn raw_js_state(obj: *mut ObjectHeader) -> Option<*mut ObjectHeader> { +pub(crate) unsafe fn raw_js_state(obj: *mut ObjectHeader) -> Option<*mut ObjectHeader> { let state = raw_field_memo(obj, JS_STATE_KEY, &JS_STATE_MEMO); let js = crate::value::JSValue::from_bits(state.to_bits()); js.is_pointer() diff --git a/crates/perry-runtime/src/native_payload_event_abi.rs b/crates/perry-runtime/src/native_payload_event_abi.rs index 5145893dbb..6e0f2eb53f 100644 --- a/crates/perry-runtime/src/native_payload_event_abi.rs +++ b/crates/perry-runtime/src/native_payload_event_abi.rs @@ -6,6 +6,81 @@ fn undefined() -> f64 { bytes_undefined() } +/// Emit directly from a native argument span. The emitter and provider hooks +/// share one root scope; no intermediate JS array survives this dispatch. +/// +/// # Safety +/// args is readable for len NaN-boxed values, or null with len zero. +/// event_bytes is readable for event_len bytes. +#[no_mangle] +pub unsafe extern "C" fn js_perry_event_emit_span_in_resource( + resource: f64, + owner: f64, + event_bytes: *const u8, + event_len: usize, + args: *const f64, + len: usize, +) -> f64 { + let scope = crate::gc::RuntimeHandleScope::new(); + let resource = scope.root_nanbox_f64(resource); + let owner = scope.root_nanbox_f64(owner); + emit_span_rooted(&scope, &resource, &owner, event_bytes, event_len, args, len) +} + +/// Borrow the binding's one dispatch scope and existing owner/resource roots. +/// # Safety +/// scope_base and both slot tokens belong to a live FFI scope on this thread. +/// The byte/argument spans have the same contract as emit_span_in_resource. +#[no_mangle] +pub unsafe extern "C" fn js_perry_event_emit_span_rooted( + scope_base: usize, + resource_slot: usize, + owner_slot: usize, + event_bytes: *const u8, + event_len: usize, + args: *const f64, + len: usize, +) -> f64 { + let scope = crate::gc::RuntimeHandleScope::borrow_ffi(scope_base); + let resource = scope.ffi_nanbox_handle(resource_slot); + let owner = scope.ffi_nanbox_handle(owner_slot); + emit_span_rooted(&scope, &resource, &owner, event_bytes, event_len, args, len) +} + +unsafe fn emit_span_rooted( + scope: &crate::gc::RuntimeHandleScope, + resource: &crate::gc::RuntimeHandle<'_>, + owner: &crate::gc::RuntimeHandle<'_>, + event_bytes: *const u8, + event_len: usize, + args: *const f64, + len: usize, +) -> f64 { + let args = if args.is_null() { + &[] + } else { + std::slice::from_raw_parts(args, len) + }; + let args = crate::node_stream::RootedArgs::new(scope, args); + let event_bytes = std::slice::from_raw_parts(event_bytes, event_len); + let event = crate::value::JSValue::try_short_string(event_bytes).unwrap_or_else(|| { + crate::value::JSValue::string_ptr(crate::string::js_string_from_bytes( + event_bytes.as_ptr(), + event_len as u32, + )) + }); + let event = scope.root_nanbox_f64(f64::from_bits(event.bits())); + let emit = || crate::node_stream::emit_stream_event_rooted(scope, owner, &event, &args); + #[cfg(test)] + if std::env::var("PERRY_TEST_NET_SABOTAGE").as_deref() == Ok("event_catch") { + return emit(); + } + match crate::async_hooks::owned_provider_scope_rooted(scope, resource, emit) { + Ok(value) => value, + Err(error) => event_exception(error), + } +} + fn event_exception(error: f64) -> f64 { let scope = crate::gc::RuntimeHandleScope::new(); let error = scope.root_nanbox_f64(error); @@ -114,6 +189,22 @@ pub unsafe extern "C" fn js_perry_event_call_in_resource( } else { std::slice::from_raw_parts(args, len) }; + if args.len() <= 4 { + let args = crate::node_stream::RootedArgs::new(&scope, args); + return match crate::async_hooks::owned_provider_scope_rooted(&scope, &resource, || { + args.with_live(|args| { + crate::closure::native_call_value_this( + callback.get_nanbox_f64(), + crate::closure::JsThis::from_f64(receiver.get_nanbox_f64()), + args.as_ptr(), + args.len(), + ) + }) + }) { + Ok(value) => value, + Err(error) => event_exception(error), + }; + } let args = scope.root_nanbox_f64_slice(args); let mut values = Vec::with_capacity(args.len()); match crate::async_hooks::owned_provider_scope(resource.get_nanbox_f64(), || { diff --git a/crates/perry-runtime/src/native_payload_transport_abi.rs b/crates/perry-runtime/src/native_payload_transport_abi.rs index ed1db58d93..c2fa97a913 100644 --- a/crates/perry-runtime/src/native_payload_transport_abi.rs +++ b/crates/perry-runtime/src/native_payload_transport_abi.rs @@ -450,6 +450,51 @@ pub unsafe extern "C" fn js_perry_payload_proto_inherit(proto: *mut c_void, pare np::PayloadPrototype::from_raw(proto.cast()).inherit(parent); } +/// Project one callback-free window after one receiver/family validation. +/// The state and link outputs are borrowed stack values, never retained. +/// +/// # Safety +/// family is static; the receiver is rooted. Outputs are writable stack slots. +/// End all returned borrows before JS, allocation, close or reopen. +#[no_mangle] +pub unsafe extern "C" fn js_perry_payload_project( + value: f64, + family: *const PerryPayloadFamily, + out_link: *mut usize, + out_state: *mut f64, + out_miss: *mut i32, +) -> *mut c_void { + let Some(family) = checked_family(family) else { + write_miss(out_miss, -1); + return std::ptr::null_mut(); + }; + let Some(cell) = family_cell(value, family) else { + write_miss(out_miss, -1); + return std::ptr::null_mut(); + }; + if (*cell).finalized != 0 + || (*cell).creator_thread_id != crate::native_handle::current_thread_id() + || (*cell).flags & np::CLOSING != 0 + || (*cell).resource_ptr.is_null() + { + write_miss(out_miss, -2); + return std::ptr::null_mut(); + } + // family_cell proved the object's class encoding, descriptor and vtable. + // Reading its existing state cannot allocate or invoke user code. + let obj = crate::value::JSValue::from_bits(value.to_bits()) + .as_pointer::() + as *mut crate::object::ObjectHeader; + let state = np::raw_js_state(obj).map_or_else(bytes_undefined, |state| { + f64::from_bits(crate::value::JSValue::object_ptr(state.cast()).bits()) + }); + // GC_STORE_AUDIT(STACK): borrowed outputs consumed before any safepoint. + out_link.write(cell as usize); + // GC_STORE_AUDIT(STACK): caller roots state before allocating or calling JS. + out_state.write(state); + (*cell).resource_ptr +} + #[cfg(test)] mod tests { use super::*; @@ -580,7 +625,31 @@ mod tests { state.get_nanbox_f64().to_bits(), js_perry_payload_js_state(owner.get_nanbox_f64(), &family, 0).to_bits() ); + let mut projected_link = 0; + let mut projected_state = bytes_undefined(); + let mut miss = 0; + assert_eq!( + js_perry_payload_project( + owner.get_nanbox_f64(), + &family, + &mut projected_link, + &mut projected_state, + &mut miss + ), + data.cast() + ); + assert_eq!(projected_link, link); + assert_eq!(projected_state.to_bits(), state.get_nanbox_f64().to_bits()); assert_eq!(js_perry_payload_close(owner.get_nanbox_f64(), &family), 0); + assert!(js_perry_payload_project( + owner.get_nanbox_f64(), + &family, + &mut projected_link, + &mut projected_state, + &mut miss + ) + .is_null()); + assert_eq!(miss, -2); assert_eq!( js_perry_payload_lifecycle(owner.get_nanbox_f64(), &family), 3 diff --git a/crates/perry-runtime/src/node_stream.rs b/crates/perry-runtime/src/node_stream.rs index 6802afb947..851e85aefc 100644 --- a/crates/perry-runtime/src/node_stream.rs +++ b/crates/perry-runtime/src/node_stream.rs @@ -42,6 +42,7 @@ use event_emitter::{ ns_prepend_once_listener2, ns_raw_listeners, ns_remove_all_listeners1, ns_remove_listener2, ns_set_max_listeners, remove_stream_listener_for_event, stream_listener_count_for_event, }; +pub(crate) use event_emitter::{emit_stream_event_rooted, RootedArgs}; /// Dispatch `event` to the listeners registered on `stream` through node:stream's /// own emitter registry. @@ -1840,14 +1841,14 @@ mod destroy_state; pub(crate) mod native_hooks; mod proto_methods; mod state_record; -#[cfg(test)] -pub(crate) use state_record::{test_read_inert_slot, test_record_slot_bits, test_write_inert_slot}; +pub(crate) use proto_methods::{install_stream_prototype_methods, StreamProto}; +use state_record::*; pub(crate) use state_record::{ is_stream_record_word, record_alias_word, record_payload_cell, store_record_alias_word, store_record_payload_cell, }; -use state_record::*; -pub(crate) use proto_methods::{install_stream_prototype_methods, StreamProto}; +#[cfg(test)] +pub(crate) use state_record::{test_read_inert_slot, test_record_slot_bits, test_write_inert_slot}; mod write_state; pub use constructors::{init_transform_in_place, init_writable_payload_in_place}; use write_state::*; diff --git a/crates/perry-runtime/src/node_stream_event_emitter.rs b/crates/perry-runtime/src/node_stream_event_emitter.rs index 25084d41bc..7bf214fd3f 100644 --- a/crates/perry-runtime/src/node_stream_event_emitter.rs +++ b/crates/perry-runtime/src/node_stream_event_emitter.rs @@ -313,6 +313,23 @@ pub extern "C" fn js_node_stream_method_listener_count(stream_handle: i64, event stream_listener_count_for_event(stream, event) as f64 } +/// Callback-free listener facts for a short native event name. A negative +/// answer asks the binding to use the ordinary accessor-aware dispatch path. +/// # Safety +/// event is readable for len bytes; stream is live for this read. +#[no_mangle] +pub unsafe extern "C" fn js_node_stream_listener_count_fast( + stream: f64, + event: *const u8, + len: usize, +) -> i64 { + let Some(event) = JSValue::try_short_string(std::slice::from_raw_parts(event, len)) else { + return -1; + }; + listeners_of_fast(stream, f64::from_bits(event.bits())) + .map_or(-1, |list| listener_list_len(list) as i64) +} + pub(super) extern "C" fn ns_event_names( closure: *const ClosureHeader, this: crate::closure::JsThis, @@ -1624,7 +1641,7 @@ pub(super) fn has_stream_listeners(stream: f64, event: f64) -> bool { /// [`INLINE_ARGS`] are held without a heap allocation. const INLINE_ARGS: usize = 4; -enum RootedArgs<'scope> { +pub(crate) enum RootedArgs<'scope> { Inline( [Option>; INLINE_ARGS], usize, @@ -1633,7 +1650,7 @@ enum RootedArgs<'scope> { } impl<'scope> RootedArgs<'scope> { - fn new(scope: &'scope crate::gc::RuntimeHandleScope, args: &[f64]) -> Self { + pub(crate) fn new(scope: &'scope crate::gc::RuntimeHandleScope, args: &[f64]) -> Self { if args.len() > INLINE_ARGS { return RootedArgs::Heap(scope.root_nanbox_f64_slice(args)); } @@ -1645,7 +1662,7 @@ impl<'scope> RootedArgs<'scope> { } /// The arguments as they are now (a collection may have moved them). - fn with_live(&self, f: impl FnOnce(&[f64]) -> R) -> R { + pub(crate) fn with_live(&self, f: impl FnOnce(&[f64]) -> R) -> R { match self { RootedArgs::Inline(handles, len) => { let mut live = [0f64; INLINE_ARGS]; @@ -1671,6 +1688,18 @@ pub(super) fn emit_stream_event(stream: f64, event: f64, args: &[f64]) -> f64 { let event_h = scope.root_nanbox_f64(event); let arg_handles = RootedArgs::new(&scope, args); + emit_stream_event_rooted(&scope, &stream_h, &event_h, &arg_handles) +} + +/// Native events share their dispatch roots with the async-provider boundary. +pub(crate) fn emit_stream_event_rooted( + scope: &crate::gc::RuntimeHandleScope, + stream_h: &crate::gc::RuntimeHandle<'_>, + event_h: &crate::gc::RuntimeHandle<'_>, + arg_handles: &RootedArgs<'_>, +) -> f64 { + let stream = stream_h.get_nanbox_f64(); + let event = event_h.get_nanbox_f64(); let is_error = super::string_value_eq(event, b"error"); if !is_error { // The shapes' answer for `this._events[type]`: no listener, or one. @@ -1680,14 +1709,14 @@ pub(super) fn emit_stream_event(stream: f64, event: f64, args: &[f64]) -> f64 { } if is_callable_value(handler) { let handler = scope.root_nanbox_f64(handler); - dispatch_listener(&stream_h, &event_h, &arg_handles, &handler, false); + dispatch_listener(stream_h, event_h, arg_handles, &handler, false); return f64::from_bits(super::TAG_TRUE); } } } if is_error { - if let Some(first) = args.first() { - let first = scope.root_nanbox_f64(*first); + if let Some(first) = arg_handles.with_live(|args| args.first().copied()) { + let first = scope.root_nanbox_f64(first); let key = super::hidden_error_key(); super::set_hidden_value(stream_h.get_nanbox_f64(), key, first.get_nanbox_f64()); super::refresh_readable_aborted_flag(stream_h.get_nanbox_f64()); @@ -1701,7 +1730,7 @@ pub(super) fn emit_stream_event(stream: f64, event: f64, args: &[f64]) -> f64 { let found = scope.root_nanbox_f64(found); let monitor = error_monitor_event(); if !is_undefined(get_key(found.get_nanbox_f64(), monitor)) { - let mut monitor_args = Vec::with_capacity(args.len() + 1); + let mut monitor_args = Vec::new(); monitor_args.push(monitor); arg_handles.with_live(|live| monitor_args.extend_from_slice(live)); emit_via_method(stream_h.get_nanbox_f64(), &monitor_args); @@ -1732,7 +1761,7 @@ pub(super) fn emit_stream_event(stream: f64, event: f64, args: &[f64]) -> f64 { if is_callable_value(handler) { // One listener: nothing to snapshot. let handler = scope.root_nanbox_f64(handler); - dispatch_listener(&stream_h, &event_h, &arg_handles, &handler, is_error); + dispatch_listener(stream_h, event_h, arg_handles, &handler, is_error); return f64::from_bits(super::TAG_TRUE); } // node clones the array before dispatch, so listeners added or removed @@ -1747,7 +1776,7 @@ pub(super) fn emit_stream_event(stream: f64, event: f64, args: &[f64]) -> f64 { }; let listener_handles = scope.root_nanbox_f64_slice(&listener_values); for handle in &listener_handles { - dispatch_listener(&stream_h, &event_h, &arg_handles, handle, is_error); + dispatch_listener(stream_h, event_h, arg_handles, handle, is_error); } f64::from_bits(super::TAG_TRUE) } diff --git a/crates/perry-runtime/src/object/field_get_set.rs b/crates/perry-runtime/src/object/field_get_set.rs index 518439e14d..c8b36ee944 100644 --- a/crates/perry-runtime/src/object/field_get_set.rs +++ b/crates/perry-runtime/src/object/field_get_set.rs @@ -269,7 +269,6 @@ impl FieldLookupCaches { // paths keep resolving (a glob re-export does not reliably propagate through // `object/mod.rs`'s `pub use field_get_set::*`), and so sibling modules can // reach the cross-module helpers via their own `use super::*;`. -pub use accessors::js_object_get_field; pub(crate) use accessors::{ accessor_receiver_override_armed, accessor_receiver_override_begin, accessor_receiver_override_end, accessor_receiver_override_take, @@ -279,6 +278,9 @@ pub(crate) use accessors::{ primitive_builtin_prototype_property, primitive_object_prototype_accessor, primitive_tagged_prototype_property, string_index_value, }; +pub use accessors::{ + js_object_get_field, js_object_record_get_by_bytes, js_object_record_set_by_bytes, +}; pub(crate) use class_object_props::{ class_evaluation_prototype_class_id, class_object_default_to_string, class_object_has_prototype_property, class_object_materialized_prototype, diff --git a/crates/perry-runtime/src/object/field_get_set/accessors.rs b/crates/perry-runtime/src/object/field_get_set/accessors.rs index e9b5e1d1b3..a94d37b6ef 100644 --- a/crates/perry-runtime/src/object/field_get_set/accessors.rs +++ b/crates/perry-runtime/src/object/field_get_set/accessors.rs @@ -98,6 +98,59 @@ pub(crate) unsafe fn own_data_field_by_name( .map(OwnSlot::data_or_undefined) } +/// Callback-free read of an opaque binding record's own data property. +/// No string allocation, prototype lookup, accessor invocation or safepoint. +/// +/// # Safety +/// obj and key must be readable. The caller retains the object during the read. +#[no_mangle] +pub unsafe extern "C" fn js_object_record_get_by_bytes( + obj: *const ObjectHeader, + key: *const u8, + len: usize, +) -> JSValue { + let key = std::slice::from_raw_parts(key, len); + own_property_slot(obj, |keys, count| { + crate::object::keys_find_property_slot_by_bytes(keys, count, key) + }) + .map(OwnSlot::data_or_undefined) + .unwrap_or_else(JSValue::undefined) +} + +/// Overwrite an existing data field of an opaque binding-owned record. +/// Missing keys and accessors are refused; the caller can use ordinary Set. +/// The indexed store retains the runtime's exact-slot write barrier. +/// +/// # Safety +/// obj is a rooted, mutable ordinary binding record, inaccessible to JS; key +/// is readable for len bytes. This call neither allocates GC cells nor runs JS. +#[no_mangle] +pub unsafe extern "C" fn js_object_record_set_by_bytes( + obj: *mut ObjectHeader, + key: *const u8, + len: usize, + value: JSValue, +) -> i32 { + let key = std::slice::from_raw_parts(key, len); + let mut index = 0; + let Some(OwnSlot::Data(_)) = own_property_slot(obj, |keys, count| { + index = crate::object::keys_find_property_slot_by_bytes(keys, count, key)?; + Some(index) + }) else { + return 0; + }; + let inline_limit = + crate::object::object_live_slot_count(obj).max(crate::object::INLINE_SLOT_FLOOR as u32); + if index < inline_limit { + js_object_set_field(obj, index, value); + } else { + // The ordinary overflow store supplies the corresponding root/slot + // barrier. A key index need not be an inline physical field index. + crate::object::overflow_set(obj as usize, index as usize, value.bits()); + } + 1 +} + /// [[Get]] of an own property of `obj` named by the bytes `key` (a method /// name from rodata, so no key string is built): a data value as stored, /// `undefined` included; an accessor through its getter, called on diff --git a/scripts/gc_runtime_root_holders.json b/scripts/gc_runtime_root_holders.json index d0e2d4e031..560c115e9a 100644 --- a/scripts/gc_runtime_root_holders.json +++ b/scripts/gc_runtime_root_holders.json @@ -41,9 +41,16 @@ }, { "file": "crates/perry-ext-http/src/lib.rs", - "name": "HTTP_PENDING_EVENTS", + "name": "CLIENT_REQUESTS_INFLIGHT", "verdict": "not_a_gc_pointer", - "why": "Client-side pending-event queue. Every variant carries a perry-ffi registry Handle, strings, Bytes, or an errno i64 (rule S fired on TransportError.errno) \u2014 no NaN-boxed value. The closures the drain fires live in ClientRequestHandle (response_callback/end_callback/pending_write_callbacks/listeners), which scan_http_roots visits." + "why": "Set of (issuing AgentId u64, ClientRequest registry Handle i64) routing identities. mark_request inserts only Outbound.request_handle returned by perry-ffi register_handle; neither component is a GC heap address or NaN-boxed value. Request/response callbacks and actual Socket values remain in app records visited and rewritten by root_scanner::scan_http_roots. Per-agent retirement removes these scalar identities." + }, + { + "file": "crates/perry-ext-http/src/lib.rs", + "name": "HTTP_PENDING_EVENTS", + "verdict": "covered_elsewhere", + "why": "Agent-tagged pending events retain the actual Socket heap address in Upgrade.socket_handle. The registered HTTP root scanner visits and rewrites that slot for the issuing agent. Other variants carry registry request/response handles, strings or bytes; callbacks live in the app records scanned by the same scanner.", + "scanner": "root_scanner::scan_http_roots (crates/perry-ext-http/src/root_scanner.rs)" }, { "file": "crates/perry-ext-http/src/server/server.rs", From 09ff98a54ab7877c6f564fde0e7922edc65e7da4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 9 Oct 2026 12:44:24 +0200 Subject: [PATCH 16/18] perf(runtime): resolve native super heritage once Resolve a bound parent to either its immutable native class fact or the existing export metadata in one shared closure validation. Ordinary dynamic super calls no longer run two receiver probes and two fallback registry lookups. Constructor family selection still compares only static info identity and the numeric class capture. --- crates/perry-runtime/src/closure/mod.rs | 2 +- crates/perry-runtime/src/closure/registry.rs | 23 ------ .../src/object/global_this/fetch_globals.rs | 82 +++++++++---------- .../perry-runtime/src/object/native_module.rs | 18 ++-- .../object/native_module/callable_exports.rs | 34 ++++++++ 5 files changed, 81 insertions(+), 78 deletions(-) diff --git a/crates/perry-runtime/src/closure/mod.rs b/crates/perry-runtime/src/closure/mod.rs index c111e4fff5..cc1403837b 100644 --- a/crates/perry-runtime/src/closure/mod.rs +++ b/crates/perry-runtime/src/closure/mod.rs @@ -58,7 +58,7 @@ pub use registry::{ }; pub(crate) use registry::{ closure_body_is_non_constructor, info_receives_primitive_this, info_trusted_direct, - info_versioned_loop_direct, native_constructor_class_id, + info_versioned_loop_direct, }; pub(crate) use dispatch::{ diff --git a/crates/perry-runtime/src/closure/registry.rs b/crates/perry-runtime/src/closure/registry.rs index 0c5fa12d1e..0fd69dec52 100644 --- a/crates/perry-runtime/src/closure/registry.rs +++ b/crates/perry-runtime/src/closure/registry.rs @@ -520,29 +520,6 @@ pub static BOUND_METHOD_INFO: JsFunctionInfo = pub static BOUND_NATIVE_CONSTRUCTOR_INFO: JsFunctionInfo = unsafe { JsFunctionInfo::from_code(BOUND_METHOD_FUNC_PTR, 2) }; -/// A constructor family's immutable capture fact, independent of all JS -/// names and prototype properties. Export resolution installs this layout. -pub(crate) fn native_constructor_class_id(value: f64) -> Option { - let value = crate::value::JSValue::from_bits(value.to_bits()); - if !value.is_pointer() { - return None; - } - let address = value.as_pointer::() as usize; - let header = unsafe { crate::value::addr_class::try_read_tracked_gc_header(address) }?; - if unsafe { header.as_ref().obj_type } != crate::gc::GC_TYPE_CLOSURE { - return None; - } - unsafe { - let closure = address as *const ClosureHeader; - if !std::ptr::eq((*closure).info, &BOUND_NATIVE_CONSTRUCTOR_INFO) - || real_capture_count((*closure).capture_count) != 4 - { - return None; - } - Some(js_closure_get_capture_f64(closure, 3) as u32) - } -} - /// The info every `Function.prototype.bind` result points to (its "code" is /// the [`BOUND_FUNCTION_FUNC_PTR`] sentinel). // SAFETY: the dispatchers route this sentinel by value; it is never called. diff --git a/crates/perry-runtime/src/object/global_this/fetch_globals.rs b/crates/perry-runtime/src/object/global_this/fetch_globals.rs index 950f12b764..6414e2e535 100644 --- a/crates/perry-runtime/src/object/global_this/fetch_globals.rs +++ b/crates/perry-runtime/src/object/global_this/fetch_globals.rs @@ -662,51 +662,43 @@ pub unsafe extern "C" fn js_fetch_or_value_super( b"Super constructor null is not a constructor", ); } - // Resolve native net heritage from the immutable constructor class fact. - // Alias/import syntax and mutable constructor properties cannot choose it. - let native_class = crate::closure::native_constructor_class_id(parent_val).or_else(|| { - let obj = subclass_this_object_ptr(this_box)?; - crate::closure::native_constructor_class_id( - crate::object::class_registry::js_get_dynamic_parent_value( - crate::object::js_object_get_class_id(obj), - ), - ) - }); - if let Some(class) = native_class { - let options = if args_len > 0 && !args_ptr.is_null() { - *args_ptr - } else { - undef - }; - let callback = if args_len > 1 && !args_ptr.is_null() { - *args_ptr.add(1) - } else { - undef - }; - super::super::native_module_dispatch::js_net_subclass_init( - this_box, - options, - callback, - u32::from(class == crate::native_class_ids::NET_SERVER), - ); - return undef; - } - // Resolve the parent to a bound native-module export VALUE, independent - // of how the heritage expression reached it: a bare import, a local - // alias, a namespace member, and a CJS destructured `require()` all - // produce the identical bound-closure representation (see - // `bound_native_callable_module_and_method`), even though only the bare - // import shape is recognized statically at HIR-lowering time. - let bound_native_parent = super::super::native_module::bound_native_callable_module_and_method( - parent_val, - ) - .or_else(|| { - let obj = subclass_this_object_ptr(this_box)?; - let cid = crate::object::js_object_get_class_id(obj); - super::super::native_module::bound_native_callable_module_and_method( - crate::object::class_registry::js_get_dynamic_parent_value(cid), - ) - }); + // Resolve each candidate once. Ordinary parents must not pay a second + // closure/header probe or repeat the dynamic-parent registry lookup just + // because native net constructors now carry an immutable class fact. + let native_parent = + super::super::native_module::bound_native_parent(parent_val).or_else(|| { + let obj = subclass_this_object_ptr(this_box)?; + super::super::native_module::bound_native_parent( + crate::object::class_registry::js_get_dynamic_parent_value( + crate::object::js_object_get_class_id(obj), + ), + ) + }); + let bound_native_parent = match native_parent { + Some(super::super::native_module::BoundNativeParent::NativeClass(class)) => { + let options = if args_len > 0 && !args_ptr.is_null() { + *args_ptr + } else { + undef + }; + let callback = if args_len > 1 && !args_ptr.is_null() { + *args_ptr.add(1) + } else { + undef + }; + super::super::native_module_dispatch::js_net_subclass_init( + this_box, + options, + callback, + u32::from(class == crate::native_class_ids::NET_SERVER), + ); + return undef; + } + Some(super::super::native_module::BoundNativeParent::Export(module, method)) => { + Some((module, method)) + } + None => None, + }; if bound_native_parent .as_ref() .is_some_and(|(module, method)| { diff --git a/crates/perry-runtime/src/object/native_module.rs b/crates/perry-runtime/src/object/native_module.rs index 7b2337c4f6..401dae6047 100644 --- a/crates/perry-runtime/src/object/native_module.rs +++ b/crates/perry-runtime/src/object/native_module.rs @@ -43,20 +43,20 @@ pub(crate) use callable_exports::minted_native_callable_export; pub(crate) use callable_exports::test_collect_native_export_after_alloc; pub(crate) use callable_exports::{ bound_native_callable_module_and_method, bound_native_callable_value_arity, - buffer_constructor_value, buffer_intrinsic_prototype_parent, buffer_intrinsic_prototype_value, - buffer_original_prototype_value, builtin_closure_is_non_constructable_value, - builtin_closure_length, cached_buffer_intrinsic_prototype_value, - fs_namespace_descriptor_getter_value, fs_namespace_descriptor_setter_value, - is_buffer_constructor_value, is_cluster_emitter_method, module_builtin_modules_value, - module_cjs_cache_value, module_cjs_extensions_value, module_cjs_global_paths_value, - module_cjs_path_cache_value, module_cjs_prototype_for_instance, module_constants_value, - native_string_value, prune_dead_builtin_closure_metadata_owners, + bound_native_parent, buffer_constructor_value, buffer_intrinsic_prototype_parent, + buffer_intrinsic_prototype_value, buffer_original_prototype_value, + builtin_closure_is_non_constructable_value, builtin_closure_length, + cached_buffer_intrinsic_prototype_value, fs_namespace_descriptor_getter_value, + fs_namespace_descriptor_setter_value, is_buffer_constructor_value, is_cluster_emitter_method, + module_builtin_modules_value, module_cjs_cache_value, module_cjs_extensions_value, + module_cjs_global_paths_value, module_cjs_path_cache_value, module_cjs_prototype_for_instance, + module_constants_value, native_string_value, prune_dead_builtin_closure_metadata_owners, prune_dead_builtin_closure_metadata_owners_young, scan_builtin_closure_metadata_roots_mut, scan_tls_derived_prototype_roots_mut, set_bound_native_closure_metadata, set_bound_native_closure_name, set_builtin_closure_length, set_builtin_closure_non_constructable, timers_promises_parent_namespace, tls_constructor_prototype_is_instance_of, util_inspect_default_options_value, - zlib_codes_object, + zlib_codes_object, BoundNativeParent, }; pub(crate) use constants::{get_native_module_constant, native_module_constant_is_live}; pub(crate) use constructor_exports::{ diff --git a/crates/perry-runtime/src/object/native_module/callable_exports.rs b/crates/perry-runtime/src/object/native_module/callable_exports.rs index 64f150dc21..4ffccc836a 100644 --- a/crates/perry-runtime/src/object/native_module/callable_exports.rs +++ b/crates/perry-runtime/src/object/native_module/callable_exports.rs @@ -1175,9 +1175,37 @@ pub(crate) fn tls_constructor_prototype_is_instance_of(value: f64, parent_name: }) } +/// One validated parent fact for super dispatch. Native net constructors carry +/// class identity; other native exports keep their existing dispatch metadata. +pub(crate) enum BoundNativeParent { + NativeClass(u32), + Export(String, String), +} + +pub(crate) unsafe fn bound_native_parent(value: f64) -> Option { + let closure = bound_native_callable_closure(value)?; + if std::ptr::eq( + (*closure).info, + &crate::closure::BOUND_NATIVE_CONSTRUCTOR_INFO, + ) && crate::closure::real_capture_count((*closure).capture_count) == 4 + { + return Some(BoundNativeParent::NativeClass( + crate::closure::js_closure_get_capture_f64(closure, 3) as u32, + )); + } + let (module, method) = bound_native_export_metadata(closure)?; + Some(BoundNativeParent::Export(module, method)) +} + pub(crate) unsafe fn bound_native_callable_module_and_method( value: f64, ) -> Option<(String, String)> { + bound_native_export_metadata(bound_native_callable_closure(value)?) +} + +unsafe fn bound_native_callable_closure( + value: f64, +) -> Option<*const crate::closure::ClosureHeader> { let jv = JSValue::from_bits(value.to_bits()); if !jv.is_pointer() { return None; @@ -1197,6 +1225,12 @@ pub(crate) unsafe fn bound_native_callable_module_and_method( { return None; } + Some(closure) +} + +unsafe fn bound_native_export_metadata( + closure: *const crate::closure::ClosureHeader, +) -> Option<(String, String)> { let ns = crate::closure::js_closure_get_capture_f64(closure, 0); let module = get_module_name_from_namespace(ns); let method_ptr = crate::closure::js_closure_get_capture_ptr(closure, 1) as *const u8; From 0f981f054dd25e80a01308d4c0ee41934cb0dc3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 9 Oct 2026 13:31:11 +0200 Subject: [PATCH 17/18] docs(net): record round two proofs and remaining performance costs --- changelog.d/12064-net-transport-phase-a.md | 73 ++++++++++++++++------ 1 file changed, 53 insertions(+), 20 deletions(-) diff --git a/changelog.d/12064-net-transport-phase-a.md b/changelog.d/12064-net-transport-phase-a.md index f30e128273..a4694efb6a 100644 --- a/changelog.d/12064-net-transport-phase-a.md +++ b/changelog.d/12064-net-transport-phase-a.md @@ -31,23 +31,56 @@ lib tests fail to compile on main (`js_shadow_frame_push` is absent with native stack maps). Main also fails issue_9619's manual/callback-only path and `nested_object_literal_ws_inbound`: ws handleUpgrade has no raw-socket path. -Validation uses main a9834307e829 (the newest origin/main at the final rebase) -and separate qb6 targets. Script lint: main 102/125 pass, lane 103/125 pass; -the remaining failures and strict Clippy failures are inherited, with no new -diagnostics. Lean dependencies and pruned feature builds pass. The full lane -gap subset, with its echo fixture running, is 88 pass / 15 mismatch / 0 timeout -against the supplied main's 85 / 16 / 1: zero regressions. The worker completes -25 rounds, ws_client passes, and the new upgrade fixture passes. - -This is a review bundle, with performance acceptance still blocked. All eight -programs match Node for five interleaved instructions/RSS runs on CPUs 0-55 -with ASLR disabled. HTTP 10k adds 24.606% instructions and net echo 10k adds -36.939%; repeated payload validation/root access and event argument allocation -outweigh the deleted table lookups. Net's extra minor collection promotes an -old arena; its RSS increase survives THP-off. Fix-forward: reuse validation -within a callback-free operation, use existing rooted emitter arguments, and -avoid empty write-ack callback/provider work while preserving async_hooks. -The small tsc increase (+0.029%) is not fully attributed. P0's global -bound-method constructor guard also compares module/export names; replace -that special case with captured canonical constructor metadata to meet the -architecture policy. No performance acceptance is claimed for this bundle. +Round 2 selects net constructor families from immutable class captures in a +canonical constructor layout. Explicit receiver calls and dynamic super use +that fact even after function names and prototype constructor properties are +changed. Dynamic super shares one validated parent resolution with the existing +native-export dispatch; ordinary parents do not repeat the header or registry +lookup. + +Native completions use callback-free payload windows, ending every borrow +before JS, close or reopen. HTTP batches parser and native I/O steps within a +proof. Opaque record reads and overwrites avoid allocating property-key strings; +inline and overflow stores preserve the runtime write barriers and moving-root +edges. Emitter arguments and async provider hooks share one dispatch scope and +short argument spans stay inline. Empty write acknowledgements skip callback +work, and plaintext allocates a JS Buffer only for a listener that can receive it. +Deferred events keep their required traced captures. No cache, table or latch is +added. + +Round 2 validation: ext-net 22 (including 100k lifecycle), ext-http 208, moving +record stores and borrowed roots pass. Constructor mutation/heritage fixtures +pass after the shared-super fix. N2/N3/N4/N8 sabotage witnesses reject their +intended faults. The full gap subset has zero regressions: main 87 pass, 15 +mismatch, one timeout, two lane-only; lane 90 pass, 15 mismatch, no timeout. +The final heritage change rechecks its affected fixtures. Script lint is +102/125 on main and 103/125 in lane, with no new failing gates. Changed-crate +Clippy adds no warning/file pairs; strict workspace Clippy inherits main's +perry-dispatch large_const_arrays failure. Lean/pruned dependency gates pass. + +A/B is pinned to the required initial rebase base 678ab79517, with separate +Linux targets, CPUs 0–55, setarch -R, n=5 interleaved instructions:u/RSS. Main +advanced while this work ran. TSC +0.011%, Zod -0.006%, fastify +0.392%, +buffer +0.007% and worker +0.069% instructions are within the observed +within-arm spans. Hello has a reproducible +709-instruction startup offset +(+0.052%); exact symbol attribution is still unisolated. + +Net echo is improved to +1.10% instructions and HTTP 10k to +4.88%, but the +requested flat-micro/RSS target is unmet. Perf profiles still show payload +projection, opaque-record access and rooting outside the batched completions. +THP-off RSS remains +964 KiB net and +2,604 KiB HTTP. Net's anonymous heap +is smaller; its remaining footprint is fixed resident code pages. HTTP's +request/response records each grow by 40 bytes, amplifying existing app +registry retention proportionally with request count. Follow-up: carry proven +capabilities through Agent/request/response operations, compact or reclaim +app records according to traced JS lifetimes without breaking req.socket or +delayed-generation checks, and isolate the cold native-constructor branch +from common closure dispatch. No new side table, cache or latch is proposed. + +Real RSS controls: TSC's delta changes sign and worker ranges overlap with +THP off; fastify retains +2,324 KiB, chiefly resident executable mappings +(+2,404 KiB text at exit) plus +368 KiB anonymous memory. Buffer's anonymous +heap differs by only +4 KiB. Reduce duplicated inlined proof/dispatch bodies +in the follow-up and remeasure code residency; exit probes do not exactly +decompose peak RSS. Full-collection counts are hello/Zod/net/HTTP 0/0, +TSC/fastify 1/1, buffer 36/36, worker 42/41 (main/lane). From 491ecafdd89fd65adf7303135da4206f0bd9bf75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 9 Oct 2026 14:07:33 +0200 Subject: [PATCH 18/18] Add numbered changelog for native socket transport --- changelog.d/12267-native-socket-transport.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/12267-native-socket-transport.md diff --git a/changelog.d/12267-native-socket-transport.md b/changelog.d/12267-native-socket-transport.md new file mode 100644 index 0000000000..f3ffcafdc2 --- /dev/null +++ b/changelog.d/12267-native-socket-transport.md @@ -0,0 +1,3 @@ +Move net.Socket/net.Server and HTTP/1 connection ownership to native payloads with owner-link completion routing. HTTP upgrade keeps the same Socket and routes head/later data through it. Select native constructor families from immutable class identity, share payload proofs and event/provider roots, and retain exact write barriers, delayed-generation guards and one authoritative opened field. + +Linux lifecycle, upgrade, moving-root and independent sabotage witnesses pass; the 105-fixture Node subset has zero regressions against the measured base. The flat-micro/RSS target remains unmet: HTTP instructions +4.877%, net +1.097%, with persistent THP-off RSS increases. Follow-ups cover capability propagation, traced HTTP app-record lifetimes, cold constructor/code footprint and unused ws Client dispatch rows. Full measurements and known baseline failures remain in the PR description.