diff --git a/Cargo.lock b/Cargo.lock index 61bdc49c23..03e9dbd065 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5441,7 +5441,6 @@ dependencies = [ "turnloop-http", "turnloop-smtp", "turnloop-tls", - "turnloop-websocket", "url", "windows-sys 0.61.2", "x25519-dalek", diff --git a/changelog.d/11405-stdlib-delete-bundled-net-ws.md b/changelog.d/11405-stdlib-delete-bundled-net-ws.md new file mode 100644 index 0000000000..145380500a --- /dev/null +++ b/changelog.d/11405-stdlib-delete-bundled-net-ws.md @@ -0,0 +1,8 @@ +tokio removal, lane L4: perry-stdlib's bundled `node:net` and `ws` copies, and `tls_stream.rs` (the TLS stream they drove over tokio sockets), are deleted instead of ported. perry-ext-net and perry-ext-ws, which already run on turnloop, are now the only implementations, and the CLI routes `net` / `ws` / `tls` to them in every mode, including `PERRY_DISABLE_WELL_KNOWN=1`. + +- **Why delete rather than port.** The bundled copies were reached only with `PERRY_DISABLE_WELL_KNOWN=1`, which is an undocumented bisection switch (nothing under `docs/src` mentions it; the CLI comment says "for bisection"), or through the prebuilt `full` archive, where the ext archives are linked ahead of it and win. Release packaging ships `libperry_ext_net.a` and `libperry_ext_ws.a` (`scripts/release_ext_packages.sh`), and every auto-optimized build co-builds them. The C surface was a strict subset: all 35 `#[no_mangle]` entry points in `net/mod.rs` and `ws.rs` are also defined by perry-ext-net / perry-ext-ws, which export 196, and bundled `net` had no `createServer`. Keeping them duplicated symbols, and those twins caused the #5010 / #5021 bugs, where a shared `js_net_*` name bound to the bundled copy's empty registry. +- **perry-stdlib.** `src/net/`, `src/ws.rs`, `src/ws/codec.rs`, `src/tls_stream.rs` and `src/tls_stream/tests.rs` are removed, along with every `bundled-net` / `bundled-ws` arm in the async bridge's pump and keep-alive check, the handle method/property dispatch, the socket-handle probe and the `tls.connect` dispatch. The external (`external-net-pump`) adapters lose their `not(bundled-net)` guard. `bundled-net`, `bundled-ws`, `net` and `websocket` stay as empty features, because the CLI's feature table still keys `external-net-pump` / `external-ws-pump` on them. `tls` is now `tls-runtime` alone. `full` lists `async-runtime` explicitly, because it used to come in through bundled `net` / `ws` and the prebuilt archive still has to carry the tokio-backed `perry_ffi_*` shims. The unused `turnloop-websocket` dependency is dropped. +- **CLI.** `wrapper_is_sole_provider` (`net`, `ws`) and `retain_routed` keep those two wrappers in the well-known routing when the flip is disabled: the auto-optimize driver, its out-of-tree fallback, the no-auto path, `linked_ext_crates`, and the entry-prologue provider installs. Every other binding still reverts to perry-stdlib under `PERRY_DISABLE_WELL_KNOWN=1`. A missing `net` / `ws` crate source is now a hard error, as it already was for mongodb, because there is no longer a fallback. +- **`node:tls` without bundled net.** Before, a TLS-only program linked bundled `net` through the `tls` umbrella. Now a `tls` import routes perry-ext-net (`well_known_iteration_set`), and the entry prologue calls its install hook. The client-TLSSocket arms in `tls/dispatch.rs` that call `js_net_socket_*` are compiled only beside an `external-*` net / TLS feature, so the prebuilt `full` archive still links without libperry_ext_net.a. A non-net `PERRY_NO_AUTO_OPTIMIZE` program (fs + crypto) failed to link without this. When the `tls` module's dynamic `connect` has no link-time provider, it reaches perry-ext-net's `js_tls_connect` through a runtime slot, `js_set_tls_connect_provider`. perry-ext-net fills that slot from `js_ext_net_nm_install`, so `(tls as any)["connect"](…)` in a no-auto build still returns a socket. +- **Codegen.** The socket core (`js_net_socket_{alloc,connect,method_connect,on,read,write,end,destroy,upgrade_tls}`) and `js_tls_connect` are registered to the `net` wrapper in `ext_registry`. They had no row because the bundled copy also defined them. +- **Measured.** A `PERRY_DISABLE_WELL_KNOWN=1` auto-optimized net / tls / ws probe goes from 16 tokio members in its stdlib archive and 50 `tokio-1.` strings in the binary to 0 and 0 (on base, net / tls / ws did not even link in that mode). The net / tls / ws / socket / upgrade gap set goes from 4 to 35 PASS in that mode (0 PASS→worse). In default mode it is unchanged, as is `node-suite/{net,tls}`. perry-stdlib's tokio source sites (`tokio_inventory.json`) went from 62 to 21. diff --git a/crates/perry-codegen/src/ext_registry.rs b/crates/perry-codegen/src/ext_registry.rs index 3d8628c4ff..d3baae9e30 100644 --- a/crates/perry-codegen/src/ext_registry.rs +++ b/crates/perry-codegen/src/ext_registry.rs @@ -468,6 +468,21 @@ const FFI_REGISTRY: &[(&str, OwnerKind)] = &[ ("js_net_socket_raw_listeners", OwnerKind::WellKnown("net")), ("js_net_socket_reset_and_destroy", OwnerKind::WellKnown("net")), ("js_net_server_once", OwnerKind::WellKnown("net")), + // tokio lane L4: the socket-side core and `tls.connect`. These had no + // row because perry-stdlib's bundled `net` copy also defined them, so a + // 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_net_socket_alloc", OwnerKind::WellKnown("net")), + ("js_net_socket_connect", OwnerKind::WellKnown("net")), + ("js_net_socket_method_connect", OwnerKind::WellKnown("net")), + ("js_net_socket_on", OwnerKind::WellKnown("net")), + ("js_net_socket_read", OwnerKind::WellKnown("net")), + ("js_net_socket_write", OwnerKind::WellKnown("net")), + ("js_net_socket_end", OwnerKind::WellKnown("net")), + ("js_net_socket_destroy", OwnerKind::WellKnown("net")), + ("js_net_socket_upgrade_tls", OwnerKind::WellKnown("net")), + ("js_tls_connect", OwnerKind::WellKnown("net")), ("js_net_server_remove_listener", OwnerKind::WellKnown("net")), ("js_net_server_remove_all_listeners", OwnerKind::WellKnown("net")), ("js_net_server_listener_count", OwnerKind::WellKnown("net")), @@ -1235,6 +1250,28 @@ mod tests { } } + /// tokio lane L4: perry-stdlib's bundled `net` copy — the other definition + /// of the socket core and `tls.connect` — is deleted, so every one of these + /// must pull libperry_ext_net.a onto the link line when emitted. + #[test] + fn emitted_net_socket_core_symbols_route_to_net() { + let _guard = ProviderTestGuard::new(); + for symbol in [ + "js_net_socket_alloc", + "js_net_socket_connect", + "js_net_socket_method_connect", + "js_net_socket_on", + "js_net_socket_read", + "js_net_socket_write", + "js_net_socket_end", + "js_net_socket_destroy", + "js_net_socket_upgrade_tls", + "js_tls_connect", + ] { + assert_symbol_routes_to(symbol, OwnerKind::WellKnown("net")); + } + } + /// The prefix-family link gap: an AOT-compiled `perry.compilePackages` /// member (so never in `native_module_imports` and never in the /// well-known iteration set) can lower to a `js__*` family diff --git a/crates/perry-ext-net/src/native_dispatch.rs b/crates/perry-ext-net/src/native_dispatch.rs index 1ae18bd11d..3fe3985fdb 100644 --- a/crates/perry-ext-net/src/native_dispatch.rs +++ b/crates/perry-ext-net/src/native_dispatch.rs @@ -26,6 +26,7 @@ extern "C" { fn js_value_to_str_ptr_for_ffi(value: f64) -> i64; fn js_value_is_closure(value_bits: i64) -> i32; fn js_net_validate_create_server_options(value: f64); + fn js_set_tls_connect_provider(f: unsafe extern "C" fn(f64, f64, f64, f64) -> i64); } /// Install the runtime's `net` dispatch bucket together with this crate's @@ -34,6 +35,9 @@ extern "C" { pub unsafe extern "C" fn js_ext_net_nm_install() { js_set_native_net_dispatch(js_ext_net_native_dispatch); js_nm_install_net(); + // perry-stdlib's `tls` module dispatch reaches `tls.connect` through this + // when its archive has no link-time provider (the prebuilt `full` one). + js_set_tls_connect_provider(crate::tls::js_tls_connect); } /// Handle ids box exactly like the static table's `NR_HANDLE_ID` rows; a diff --git a/crates/perry-runtime/src/tls.rs b/crates/perry-runtime/src/tls.rs index 2826e445f4..6ef6a73655 100644 --- a/crates/perry-runtime/src/tls.rs +++ b/crates/perry-runtime/src/tls.rs @@ -24,6 +24,35 @@ static SHARED_SIGALGS_CACHE: AtomicU64 = AtomicU64::new(0); static DEFAULT_CA_CONFIGURED: AtomicBool = AtomicBool::new(false); 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; + +/// The `tls.connect` provider perry-ext-net registers when it installs itself. +/// A code address, never a heap pointer. +/// +/// perry-stdlib's `node:tls` module dispatch reaches `connect` through this +/// when it was built without a link-time provider — the prebuilt `full` +/// archive, which must link without libperry_ext_net.a. Before tokio lane L4 +/// that archive carried bundled `net`'s own `js_tls_connect` instead. +static TLS_CONNECT_PROVIDER: std::sync::atomic::AtomicPtr<()> = + std::sync::atomic::AtomicPtr::new(std::ptr::null_mut()); + +#[no_mangle] +pub extern "C" fn js_set_tls_connect_provider(f: TlsConnectProviderFn) { + TLS_CONNECT_PROVIDER.store(f as *mut (), Ordering::Release); +} + +pub fn tls_connect_provider() -> Option { + let p = TLS_CONNECT_PROVIDER.load(Ordering::Acquire); + if p.is_null() { + None + } else { + // SAFETY: only `js_set_tls_connect_provider` stores here, and it + // stores a `TlsConnectProviderFn`. + Some(unsafe { std::mem::transmute::<*mut (), TlsConnectProviderFn>(p) }) + } +} + #[derive(Clone, Debug)] pub struct TlsClientMetadata { pub servername: Option, diff --git a/crates/perry-stdlib/Cargo.toml b/crates/perry-stdlib/Cargo.toml index 2f3b74853e..429e0862ef 100644 --- a/crates/perry-stdlib/Cargo.toml +++ b/crates/perry-stdlib/Cargo.toml @@ -23,7 +23,13 @@ default = ["full"] # must stay out of this list: release archives enable `full` without linking # their per-program provider archives, and adding an external HTTP pump here # made HTTP-free Linux UI links require libperry_ext_http.a (#5983, #8587). -full = ["http-server", "http-client", "database", "crypto", "compression", "email", "websocket", "image", "scheduler", "ids", "html-parser", "net", "tls", "bundled-events", "bundled-streams", "streams-brotli", "turnloop-smtp-client"] +# +# `async-runtime` is listed explicitly since tokio lane L4: `full` used to get +# it through bundled `net` / `ws`, and the prebuilt full archive is what the +# no-auto and out-of-tree links resolve the tokio-backed `perry_ffi_*` shims +# from (perry-ext-http's and the db wrappers' decline paths). Dropping it is +# the final tokio-deletion step's call, not this one's. +full = ["async-runtime", "http-server", "http-client", "database", "crypto", "compression", "email", "websocket", "image", "scheduler", "ids", "html-parser", "net", "tls", "bundled-events", "bundled-streams", "streams-brotli", "turnloop-smtp-client"] # Minimal core - just what's needed for basic programs core = [] @@ -79,26 +85,16 @@ http-client = ["web-fetch"] bundled-streams = ["dep:flate2"] streams-brotli = ["bundled-streams", "dep:brotli"] -# WebSocket — `websocket` umbrella retained for backwards-compat; -# v0.5.571's well-known flip toggles `bundled-ws` instead so -# `import 'ws'` can route to perry-ext-ws without duplicate -# `_js_ws_*` symbols at link time. +# WebSocket — served only by perry-ext-ws (on turnloop). perry-stdlib's +# bundled `ws` copy — a tokio-socket client/server with a `wss://` connector +# over `src/tls_stream.rs` — was deleted in tokio lane L4: perry-ext-ws's +# symbol surface was a strict superset of it, and the CLI routes `import 'ws'` +# to perry-ext-ws in every mode, PERRY_DISABLE_WELL_KNOWN=1 included. Both +# names stay as EMPTY features, like `ids`: `websocket` for `--features` +# callers, `bundled-ws` because the CLI's feature table +# (`stdlib_features::module_to_features`) keys `external-ws-pump` on it. websocket = ["bundled-ws"] -# turnloop WS lane: the codec is `turnloop-websocket`'s sans-I/O protocol core, -# with `turnloop-http` supplying the HTTP/1 head codec the opening handshake is -# expressed in, driven over the tokio streams `ws.rs` already owned. It -# replaced the old `WebSocketStream` wrapper, which welded the codec to the -# transport. `dep:url` parses the connect target; `dep:perry-tls-session` -# (the sans-I/O rustls session `src/tls_stream.rs` drives over the tokio -# socket — turnloop P8 group H replaced tokio-rustls with it) + -# `dep:rustls-native-certs` are the outbound `wss://` client. perry-stdlib must NOT depend on perry-ext-ws: it is the bundled -# alternative to that crate, so the duplication between the two `ws` bindings -# is deliberate. -# #6117: `dep:rustls` so the client-connect path can install a process-level -# CryptoProvider before the first `wss://` handshake — feature unification -# enables BOTH `ring` and `aws-lc-rs` in the final link, so rustls panics -# unless one is installed explicitly (same as the `tls` feature's paths). -bundled-ws = ["dep:turnloop-websocket", "dep:turnloop-http", "dep:url", "dep:perry-tls-session", "dep:rustls", "dep:rustls-native-certs", "async-runtime"] +bundled-ws = [] # Activated by `optimized_libs::build_optimized_libs` when the # well-known flip strips `bundled-ws` and routes `import 'ws'` to @@ -107,12 +103,14 @@ bundled-ws = ["dep:turnloop-websocket", "dep:turnloop-http", "dep:url", "dep:per # carries no tokio, so this needs only the promise bridge (turnloop P8 lane L). external-ws-pump = ["async-bridge"] -# Raw TCP sockets (`net.Socket` — Postgres wire driver, custom protocols). -# `net` umbrella retained for backwards-compat; v0.5.571's -# well-known flip toggles `bundled-net` instead so `import 'net'` -# can route to perry-ext-net. +# Raw TCP sockets (`net.Socket` — Postgres wire driver, custom protocols) — +# served only by perry-ext-net (on turnloop, #11105). perry-stdlib's bundled +# `net` copy (tokio sockets, `tls.connect` / `upgradeToTLS` over +# `src/tls_stream.rs`) was deleted in tokio lane L4, for the same reason as +# bundled `ws` above. `net` and `bundled-net` stay as EMPTY features: the +# CLI keys `external-net-pump` on `bundled-net`. net = ["bundled-net"] -bundled-net = ["async-runtime"] +bundled-net = [] # Activated by `optimized_libs::build_optimized_libs` (v0.5.579) when # the well-known flip strips `bundled-net` and routes `import 'net'` @@ -157,16 +155,16 @@ external-http-client-pump = ["async-bridge"] # emitter construction. Issue #4995. external-events-construct = [] -# TLS — direct `tls.connect()` and `socket.upgradeToTLS()` (Postgres SSLRequest flow). +# TLS — the `node:tls` module surface, `tls.createServer()` and TLSSocket. # Uses rustls (not native-tls) to avoid OpenSSL on every platform and keep Android # cross-compile unblocked; matches the reqwest/mongodb feature flags. # # `tls-runtime` contains the shared TLS server/preflight implementation. The -# `external-net-tls` adapter uses that implementation while resolving -# `js_tls_connect` from perry-ext-net; compiling bundled net beside the wrapper -# would reintroduce duplicate `js_net_*` / `js_tls_connect` symbols. The public -# `tls` umbrella retains its historical bundled-net behavior. -tls = ["bundled-net", "tls-runtime"] +# client half — `tls.connect()` and `socket.upgradeToTLS()` — is perry-ext-net's; +# the `external-net-tls` adapter resolves `js_tls_connect` from it. The public +# `tls` umbrella used to add bundled `net` (the other `js_tls_connect`); since +# tokio lane L4 deleted that copy it is `tls-runtime` alone. +tls = ["tls-runtime"] external-net-tls = ["tls-runtime"] tls-runtime = [ "async-bridge", @@ -299,10 +297,10 @@ ids = [] async-bridge = [] # Async runtime (tokio) - internal feature. The bridge plus the tokio -# current-thread runtime (`common::tokio_bridge`), for the features that still -# hand it tokio futures: bundled net / tls / ws sockets, reqwest fetch, and the -# `perry_ffi_spawn_async` / `_with_reactor` ABI that -# perry-ext-net / perry-ext-http / the db wrappers' decline paths use. +# current-thread runtime (`common::tokio_bridge`), for what still hands it +# tokio futures: the `perry_ffi_spawn_async` / `_with_reactor` ABI that +# perry-ext-http / the db wrappers' decline paths use. (Bundled net / tls / ws +# sockets were the last in-crate users; tokio lane L4 deleted them.) async-runtime = ["async-bridge", "dep:tokio"] # OCI container subsystem (perry/container, perry/compose, perry/workloads). @@ -363,10 +361,8 @@ bytes = { workspace = true, optional = true } # so it must NOT be optional — every minimal-stdlib build needs it. dashmap.workspace = true -# WebSocket — the sans-I/O protocol core the `ws` module's codec is built on. -# `turnloop-http` is declared with the other turnloop client engines above. -turnloop-websocket = { workspace = true, optional = true } -# TLS (for net.Socket.upgradeToTLS, tls.connect, tls.createServer and wss://) +# TLS (tls.createServer, the TLSSocket surface, and the SNI/ALPN preflight +# perry-ext-net's client calls back into) # — rustls-only, no OpenSSL. The handshakes run on `perry-tls-session`'s # sans-I/O session (declared with the turnloop client engines above), not on # tokio-rustls (turnloop P8 group H). diff --git a/crates/perry-stdlib/src/common/async_bridge.rs b/crates/perry-stdlib/src/common/async_bridge.rs index 116c278c53..8f7778b738 100644 --- a/crates/perry-stdlib/src/common/async_bridge.rs +++ b/crates/perry-stdlib/src/common/async_bridge.rs @@ -434,23 +434,9 @@ pub extern "C" fn js_stdlib_process_pending() -> i32 { } } - // Process pending WebSocket events (server/client listener callbacks). - // External WebSocket implementations register their own pump with runtime. - #[cfg(feature = "websocket")] - { - count += unsafe { crate::ws::js_ws_process_pending() }; - } - - // Process pending bundled raw TCP socket events (net.Socket). - // External net implementations register their own pump with runtime. - #[cfg(all( - feature = "bundled-net", - not(target_os = "ios"), - not(target_os = "android") - ))] - { - count += unsafe { crate::net::js_net_process_pending() }; - } + // WebSocket and raw TCP socket events are pumped by perry-ext-ws / + // perry-ext-net, which register their own pumps with the runtime (the + // bundled copies that drained here were deleted in tokio lane L4). #[cfg(all( feature = "tls-runtime", @@ -524,32 +510,9 @@ pub extern "C" fn js_stdlib_has_active_handles() -> i32 { if crate::turnloop_smtp::has_pending() { return 1; } - // Check for active WebSocket servers/connections - #[cfg(feature = "websocket")] - { - // #854: removed an unused `js_ws_process_pending` extern decl here — - // this block only checks for active handles; the drain path with its - // own extra decl lives earlier in the pump. - // If there are pending WS events, keep running - // (we don't drain here — just check) - let has_ws = crate::ws::js_ws_has_active_handles(); - if has_ws != 0 { - return 1; - } - } - // Check bundled raw TCP sockets. External net implementations register - // their own keepalive contributor with runtime and remain invisible here. - #[cfg(all( - feature = "bundled-net", - not(target_os = "ios"), - not(target_os = "android") - ))] - { - let has_net = crate::net::js_net_has_active_handles(); - if has_net != 0 { - return 1; - } - } + // Active WebSocket / raw TCP handles keep the loop alive through the + // keepalive contributors perry-ext-ws / perry-ext-net register with the + // runtime (the bundled copies checked here were deleted in tokio lane L4). #[cfg(all( feature = "tls-runtime", not(target_os = "ios"), diff --git a/crates/perry-stdlib/src/common/dispatch.rs b/crates/perry-stdlib/src/common/dispatch.rs index 97abfc2795..e1093d5414 100644 --- a/crates/perry-stdlib/src/common/dispatch.rs +++ b/crates/perry-stdlib/src/common/dispatch.rs @@ -34,18 +34,11 @@ pub(crate) use emitter_als::{dispatch_event_emitter_method, dispatch_event_emitt pub(crate) use sqlite::{dispatch_sqlite_db, dispatch_sqlite_stmt}; #[cfg(all( - not(feature = "bundled-net"), feature = "external-net-pump", not(target_os = "ios"), not(target_os = "android") ))] pub(crate) use fastify_net_zlib::dispatch_external_net_socket; -#[cfg(all( - feature = "bundled-net", - not(target_os = "ios"), - not(target_os = "android") -))] -pub(crate) use fastify_net_zlib::dispatch_net_socket; #[cfg(feature = "compression-gzip")] pub(crate) use fastify_net_zlib::dispatch_zlib_stream; diff --git a/crates/perry-stdlib/src/common/dispatch/fastify_net_zlib.rs b/crates/perry-stdlib/src/common/dispatch/fastify_net_zlib.rs index c790fd014f..b81438381a 100644 --- a/crates/perry-stdlib/src/common/dispatch/fastify_net_zlib.rs +++ b/crates/perry-stdlib/src/common/dispatch/fastify_net_zlib.rs @@ -1,85 +1,3 @@ -/// Dispatch method calls on net.Socket handles when codegen couldn't tag -/// the receiver type. Mirrors the static NATIVE_MODULE_TABLE entries for -/// the same methods (write/end/destroy/on/upgradeToTLS). -/// -/// Args arrive as NaN-boxed `f64`s: BufferHeader / StringHeader / Closure -/// pointers in the low 48 bits with POINTER_TAG / STRING_TAG in the top. -/// We strip the tag and pass the raw `i64` to the FFI — same shape the -/// codegen path produces. -#[cfg(all( - feature = "bundled-net", - not(target_os = "ios"), - not(target_os = "android") -))] -pub(crate) unsafe fn dispatch_net_socket(handle: i64, method: &str, args: &[f64]) -> f64 { - /// Strip a NaN-box tag (POINTER / STRING / BIGINT) to get the raw 48-bit pointer. - fn unbox_to_i64(v: f64) -> i64 { - (v.to_bits() & 0x0000_FFFF_FFFF_FFFF) as i64 - } - - match method { - "read" => crate::net::js_net_socket_read( - handle, - args.first() - .copied() - .unwrap_or(f64::from_bits(0x7FFC_0000_0000_0001)), - ), - "write" if !args.is_empty() => { - // Issue #1131 — pass the full NaN-box bits; the runtime - // probes Buffer-vs-string and reads the correct layout. - crate::net::js_net_socket_write(handle, args[0].to_bits() as i64); - f64::from_bits(0x7FFC_0000_0000_0001) // undefined - } - "end" => { - // Issue #1852 — forward the optional `socket.end(data)` chunk. - let chunk = args - .first() - .copied() - .unwrap_or(f64::from_bits(0x7FFC_0000_0000_0001)); - crate::net::js_net_socket_end(handle, chunk.to_bits() as i64); - f64::from_bits(0x7FFC_0000_0000_0001) - } - "destroy" | "destroySoon" => { - crate::net::js_net_socket_destroy(handle); - f64::from_bits(0x7FFC_0000_0000_0001) - } - "getTypeOfService" => crate::net::js_net_socket_get_type_of_service(handle), - "setTypeOfService" => { - let value = args - .first() - .copied() - .unwrap_or(f64::from_bits(0x7FFC_0000_0000_0001)); - crate::net::js_net_socket_set_type_of_service(handle, value); - f64::from_bits(0x7FFD_0000_0000_0000u64 | (handle as u64 & 0x0000_FFFF_FFFF_FFFF)) - } - "on" if args.len() >= 2 => { - let event_ptr = unbox_to_i64(args[0]); - let cb_ptr = unbox_to_i64(args[1]); - crate::net::js_net_socket_on(handle, event_ptr, cb_ptr); - f64::from_bits(0x7FFC_0000_0000_0001) - } - // Issue #422: `sock.connect(port, host)` for the deferred-connect - // shape (`new net.Socket()` then `.connect(...)`). The first arg - // is the port (raw f64); the second is a string handle (NaN-boxed - // STRING_TAG'd f64) that we strip back to the StringHeader pointer. - "connect" if args.len() >= 2 => { - let port = args[0]; - let host_ptr = unbox_to_i64(args[1]); - crate::net::js_net_socket_method_connect(handle, port, host_ptr); - f64::from_bits(0x7FFC_0000_0000_0001) - } - "upgradeToTLS" if !args.is_empty() => { - // upgradeToTLS(servername, verify) → Promise. Default verify=1 - // when omitted, mirroring the safer default in the static table. - let servername_ptr = unbox_to_i64(args[0]); - let verify = if args.len() >= 2 { args[1] } else { 1.0 }; - let promise = crate::net::js_net_socket_upgrade_tls(handle, servername_ptr, verify); - f64::from_bits(0x7FFD_0000_0000_0000u64 | (promise as u64 & 0x0000_FFFF_FFFF_FFFF)) - } - _ => f64::from_bits(0x7FFC_0000_0000_0001), - } -} - /// Dispatch a method call on a zlib Transform-stream handle (#1843). /// /// `createGzip()` / `createDeflate()` / `createBrotliCompress()` / … return @@ -154,16 +72,15 @@ pub(crate) unsafe fn dispatch_zlib_stream(handle: i64, method: &str, args: &[f64 } /// Dispatch a method call on a perry-ext-net Socket handle via -/// extern "C" symbols. Same shape as `dispatch_net_socket` above -/// but the per-method functions resolve to perry-ext-net's archive -/// at link time, not perry-stdlib's `crate::net::*`. +/// extern "C" symbols that resolve to perry-ext-net's archive at link time. +/// (perry-stdlib's bundled `net` copy and its `dispatch_net_socket` twin +/// were deleted in tokio lane L4; perry-ext-net is the only provider.) /// /// Closes issue #91 regression for the well-known-flipped path: /// Map.get'd / struct-field / wrapper-function receivers where /// the static type was lost get caught by HANDLE_METHOD_DISPATCH /// and routed here. #[cfg(all( - not(feature = "bundled-net"), feature = "external-net-pump", not(target_os = "ios"), not(target_os = "android") diff --git a/crates/perry-stdlib/src/common/dispatch/method_dispatch.rs b/crates/perry-stdlib/src/common/dispatch/method_dispatch.rs index 82e7516753..c9cc61bae1 100644 --- a/crates/perry-stdlib/src/common/dispatch/method_dispatch.rs +++ b/crates/perry-stdlib/src/common/dispatch/method_dispatch.rs @@ -532,18 +532,6 @@ pub unsafe extern "C" fn js_handle_method_dispatch( } } - // net.Socket: covers wrapper-function, struct-field, and Map.get - // receivers where codegen lost the static type. Static NATIVE_MODULE_TABLE - // path is still preferred when types are visible. - #[cfg(all( - feature = "bundled-net", - not(target_os = "ios"), - not(target_os = "android") - ))] - if crate::net::is_net_socket_handle(handle) { - return dispatch_net_socket(handle, method_name, &args); - } - // zlib Transform streams (#1843): `zlib.createGzip()` etc. return handles // in the zlib small-handle range; their `.write`/`.end`/`.on`/`.pipe`/`.flush`/ // `.params`/`.reset`/`.close` calls lose their static type and route here. @@ -880,7 +868,6 @@ pub unsafe extern "C" fn js_handle_method_dispatch( // the well-known flip strips bundled-net. Same dispatch contract, // but routes through extern "C" symbols perry-ext-net provides. #[cfg(all( - not(feature = "bundled-net"), feature = "external-net-pump", not(target_os = "ios"), not(target_os = "android") diff --git a/crates/perry-stdlib/src/common/dispatch/property_dispatch.rs b/crates/perry-stdlib/src/common/dispatch/property_dispatch.rs index cf1559ad00..d9c814b222 100644 --- a/crates/perry-stdlib/src/common/dispatch/property_dispatch.rs +++ b/crates/perry-stdlib/src/common/dispatch/property_dispatch.rs @@ -64,26 +64,9 @@ pub unsafe extern "C" fn js_handle_property_dispatch( return crate::streams::dispatch_stream_property(handle as f64, property_name); } - // #9324: `WebSocketServer.clients` must resolve on the DYNAMIC path too. - // The statically-typed read lowers to a NativeMethodCall (#9325/#9335), but - // an UNTYPED receiver lands here instead — a compiled npm package (no types - // at all), an `any` alias, a computed `wss[key]`, or a helper that takes the - // server as a parameter. ws registers no handle-property surface, so every - // one of those read `undefined`, and `for (const c of wss.clients)` over - // that `undefined` threw `TypeError: is not iterable` from a timer callback - // — uncatchable by application code, so the process exited. - // - // `js_ws_server_clients` returns `undefined` for any handle that is not a - // live `WsServerHandle`, so this arm is inert for every other handle family - // and falls through to the dispatchers below. - #[cfg(all(feature = "bundled-ws", not(target_os = "ios")))] - if property_name == "clients" { - let clients = crate::ws::js_ws_server_clients(handle); - if !perry_runtime::JSValue::from_bits(clients.to_bits()).is_undefined() { - return clients; - } - } - + // #9324: `WebSocketServer.clients` on the dynamic path is served by + // perry-ext-ws's own handle-property extension (`dispatch.rs`); the + // bundled `ws` arm that sat here was deleted in tokio lane L4. if let Some(value) = super::super::net_socket_bridge::bind_net_socket_property(handle, property_name) { diff --git a/crates/perry-stdlib/src/common/net_method_values.rs b/crates/perry-stdlib/src/common/net_method_values.rs index 8a2f882f6b..ce123f9818 100644 --- a/crates/perry-stdlib/src/common/net_method_values.rs +++ b/crates/perry-stdlib/src/common/net_method_values.rs @@ -1,11 +1,17 @@ -//! net.Socket/net.Server method-value helpers for handle dispatch. +//! net.Socket/net.Server method-value helpers for handle dispatch — all of +//! them for perry-ext-net handles (`external-net-pump`), the only net +//! provider since tokio lane L4 deleted perry-stdlib's bundled copy. +#[cfg(all( + feature = "external-net-pump", + not(target_os = "ios"), + not(target_os = "android") +))] fn nanbox_handle(handle: i64) -> f64 { f64::from_bits(0x7FFD_0000_0000_0000u64 | (handle as u64 & 0x0000_FFFF_FFFF_FFFF)) } #[cfg(all( - not(feature = "bundled-net"), feature = "external-net-pump", not(target_os = "ios"), not(target_os = "android") @@ -15,7 +21,6 @@ fn undefined() -> f64 { } #[cfg(all( - not(feature = "bundled-net"), feature = "external-net-pump", not(target_os = "ios"), not(target_os = "android") @@ -24,6 +29,11 @@ fn null() -> f64 { f64::from_bits(0x7FFC_0000_0000_0002) } +#[cfg(all( + feature = "external-net-pump", + not(target_os = "ios"), + not(target_os = "android") +))] fn bind_handle_method(handle: i64, name: &'static [u8]) -> f64 { extern "C" { fn js_class_method_bind( @@ -36,7 +46,6 @@ fn bind_handle_method(handle: i64, name: &'static [u8]) -> f64 { } #[cfg(all( - not(feature = "bundled-net"), feature = "external-net-pump", not(target_os = "ios"), not(target_os = "android") @@ -46,7 +55,6 @@ fn unbox_to_i64(v: f64) -> i64 { } #[cfg(all( - not(feature = "bundled-net"), feature = "external-net-pump", not(target_os = "ios"), not(target_os = "android") @@ -58,6 +66,11 @@ fn json_str_to_value(s: *mut perry_runtime::StringHeader) -> f64 { f64::from_bits(unsafe { perry_runtime::json::js_json_parse_or_null(s).bits() }) } +#[cfg(all( + feature = "external-net-pump", + not(target_os = "ios"), + not(target_os = "android") +))] fn net_socket_method_name(prop: &str) -> Option<&'static [u8]> { match prop { "address" => Some(b"address"), @@ -98,7 +111,6 @@ fn net_socket_method_name(prop: &str) -> Option<&'static [u8]> { } #[cfg(all( - not(feature = "bundled-net"), feature = "external-net-pump", not(target_os = "ios"), not(target_os = "android") @@ -127,7 +139,6 @@ fn net_server_method_name(prop: &str) -> Option<&'static [u8]> { } #[cfg(all( - not(feature = "bundled-net"), feature = "external-net-pump", not(target_os = "ios"), not(target_os = "android") @@ -144,35 +155,30 @@ fn net_block_list_method_name(prop: &str) -> Option<&'static [u8]> { } } +#[cfg_attr( + not(all( + feature = "external-net-pump", + not(target_os = "ios"), + not(target_os = "android") + )), + allow(unused_variables) +)] pub(crate) fn dispatch_property(handle: i64, property_name: &str) -> Option { + #[cfg(all( + feature = "external-net-pump", + not(target_os = "ios"), + not(target_os = "android") + ))] if let Some(name) = net_socket_method_name(property_name) { - #[cfg(all( - feature = "bundled-net", - not(target_os = "ios"), - not(target_os = "android") - ))] - if crate::net::is_net_socket_handle(handle) { - return Some(bind_handle_method(handle, name)); + extern "C" { + fn js_ext_net_is_socket_handle(handle: i64) -> i32; } - - #[cfg(all( - not(feature = "bundled-net"), - feature = "external-net-pump", - not(target_os = "ios"), - not(target_os = "android") - ))] - { - extern "C" { - fn js_ext_net_is_socket_handle(handle: i64) -> i32; - } - if unsafe { js_ext_net_is_socket_handle(handle) } != 0 { - return Some(bind_handle_method(handle, name)); - } + if unsafe { js_ext_net_is_socket_handle(handle) } != 0 { + return Some(bind_handle_method(handle, name)); } } #[cfg(all( - not(feature = "bundled-net"), feature = "external-net-pump", not(target_os = "ios"), not(target_os = "android") @@ -187,7 +193,6 @@ pub(crate) fn dispatch_property(handle: i64, property_name: &str) -> Option } #[cfg(all( - not(feature = "bundled-net"), feature = "external-net-pump", not(target_os = "ios"), not(target_os = "android") @@ -202,7 +207,6 @@ pub(crate) fn dispatch_property(handle: i64, property_name: &str) -> Option } #[cfg(all( - not(feature = "bundled-net"), feature = "external-net-pump", not(target_os = "ios"), not(target_os = "android") @@ -218,7 +222,6 @@ pub(crate) fn dispatch_property(handle: i64, property_name: &str) -> Option } #[cfg(all( - not(feature = "bundled-net"), feature = "external-net-pump", not(target_os = "ios"), not(target_os = "android") @@ -238,7 +241,6 @@ pub(crate) fn dispatch_property(handle: i64, property_name: &str) -> Option } #[cfg(all( - not(feature = "bundled-net"), feature = "external-net-pump", not(target_os = "ios"), not(target_os = "android") @@ -255,7 +257,6 @@ pub(crate) fn dispatch_property(handle: i64, property_name: &str) -> Option } #[cfg(all( - not(feature = "bundled-net"), feature = "external-net-pump", not(target_os = "ios"), not(target_os = "android") @@ -290,7 +291,6 @@ pub(crate) fn dispatch_property(handle: i64, property_name: &str) -> Option } #[cfg(all( - not(feature = "bundled-net"), feature = "external-net-pump", not(target_os = "ios"), not(target_os = "android") @@ -323,7 +323,6 @@ pub(crate) unsafe fn dispatch_property_set(handle: i64, property_name: &str, val } #[cfg(not(all( - not(feature = "bundled-net"), feature = "external-net-pump", not(target_os = "ios"), not(target_os = "android") @@ -334,7 +333,6 @@ pub(crate) unsafe fn dispatch_property_set(handle: i64, property_name: &str, val } #[cfg(all( - not(feature = "bundled-net"), feature = "external-net-pump", not(target_os = "ios"), not(target_os = "android") @@ -406,7 +404,6 @@ pub(crate) unsafe fn dispatch_external_block_list_method( } #[cfg(all( - not(feature = "bundled-net"), feature = "external-net-pump", not(target_os = "ios"), not(target_os = "android") diff --git a/crates/perry-stdlib/src/common/net_socket_bridge.rs b/crates/perry-stdlib/src/common/net_socket_bridge.rs index 881d97b9e2..48c27e8910 100644 --- a/crates/perry-stdlib/src/common/net_socket_bridge.rs +++ b/crates/perry-stdlib/src/common/net_socket_bridge.rs @@ -1,8 +1,18 @@ +#[cfg(all( + feature = "external-net-pump", + not(target_os = "ios"), + not(target_os = "android") +))] #[inline] fn nanbox_small_handle(handle: i64) -> f64 { f64::from_bits(0x7FFD_0000_0000_0000u64 | (handle as u64 & 0x0000_FFFF_FFFF_FFFF)) } +#[cfg(all( + feature = "external-net-pump", + not(target_os = "ios"), + not(target_os = "android") +))] #[inline] unsafe fn bind_class_method(handle: i64, name_bytes: &'static [u8]) -> f64 { extern "C" { @@ -20,25 +30,6 @@ unsafe fn bind_class_method(handle: i64, name_bytes: &'static [u8]) -> f64 { } #[cfg(all( - feature = "bundled-net", - not(target_os = "ios"), - not(target_os = "android") -))] -fn bundled_socket_method_name(property_name: &str) -> Option<&'static [u8]> { - match property_name { - "connect" => Some(b"connect"), - "write" => Some(b"write"), - "end" => Some(b"end"), - "destroy" => Some(b"destroy"), - "on" => Some(b"on"), - "read" => Some(b"read"), - "upgradeToTLS" => Some(b"upgradeToTLS"), - _ => None, - } -} - -#[cfg(all( - not(feature = "bundled-net"), feature = "external-net-pump", not(target_os = "ios"), not(target_os = "android") @@ -78,20 +69,16 @@ fn external_socket_method_name(property_name: &str) -> Option<&'static [u8]> { } } -pub(super) unsafe fn bind_net_socket_property(handle: i64, property_name: &str) -> Option { - #[cfg(all( - feature = "bundled-net", +#[cfg_attr( + not(all( + feature = "external-net-pump", not(target_os = "ios"), not(target_os = "android") - ))] - if crate::net::is_net_socket_handle(handle) { - if let Some(name_bytes) = bundled_socket_method_name(property_name) { - return Some(bind_class_method(handle, name_bytes)); - } - } - + )), + allow(unused_variables) +)] +pub(super) unsafe fn bind_net_socket_property(handle: i64, property_name: &str) -> Option { #[cfg(all( - not(feature = "bundled-net"), feature = "external-net-pump", not(target_os = "ios"), not(target_os = "android") @@ -110,30 +97,20 @@ pub(super) unsafe fn bind_net_socket_property(handle: i64, property_name: &str) None } +/// Registers perry-ext-net's socket-handle probe with the runtime. With no +/// ext-net adapter compiled in there is nothing to register: perry-ext-net +/// registers its own probe (`gc_roots.rs`), and perry-stdlib's bundled `net` +/// probe that used to sit here was deleted in tokio lane L4. pub(super) unsafe fn register_net_socket_handle_probe() { - extern "C" { - fn js_register_net_socket_handle_probe(f: unsafe extern "C" fn(i64) -> bool); - } - #[cfg(all( - feature = "bundled-net", - not(target_os = "ios"), - not(target_os = "android") - ))] - { - unsafe extern "C" fn net_socket_probe(handle: i64) -> bool { - crate::net::is_net_socket_handle(handle) - } - js_register_net_socket_handle_probe(net_socket_probe); - } - - #[cfg(all( - not(feature = "bundled-net"), feature = "external-net-pump", not(target_os = "ios"), not(target_os = "android") ))] { + extern "C" { + fn js_register_net_socket_handle_probe(f: unsafe extern "C" fn(i64) -> bool); + } unsafe extern "C" fn external_net_socket_probe(handle: i64) -> bool { extern "C" { fn js_ext_net_is_socket_handle(handle: i64) -> i32; diff --git a/crates/perry-stdlib/src/lib.rs b/crates/perry-stdlib/src/lib.rs index 553a78972a..bde1345144 100644 --- a/crates/perry-stdlib/src/lib.rs +++ b/crates/perry-stdlib/src/lib.rs @@ -179,37 +179,17 @@ pub mod streams; #[cfg(feature = "bundled-streams")] pub use streams::*; -// === TLS over a tokio transport (turnloop P8 group H) === -// perry-tls-session's sans-I/O rustls session driven over the tokio sockets -// the bundled `net` client (`tls`) and `wss://` connector (`bundled-ws`) still -// use — the replacement for their former tokio-rustls streams. The `node:tls` -// server no longer needs it: its sockets are turnloop handles -// (`tls/turnloop_server.rs`, turnloop P8 lane L), so `tls-runtime` alone — -// what `external-net-tls` selects for every net / http program — links no -// tokio. -#[cfg(any(feature = "tls", feature = "bundled-ws"))] -pub(crate) mod tls_stream; - -// === WebSocket === -#[cfg(feature = "bundled-ws")] -pub mod ws; -#[cfg(feature = "bundled-ws")] -pub use ws::*; - -// === Raw TCP sockets (net.Socket) + TLS (tls.connect, socket.upgradeToTLS) === +// === WebSocket / raw TCP sockets (net.Socket) === +// Served only by perry-ext-ws and perry-ext-net, on turnloop. perry-stdlib's +// bundled copies (`ws.rs`, `net/`, and `tls_stream.rs`, the TLS stream they +// drove over tokio sockets) were deleted in tokio lane L4: the wrappers were a +// strict superset of their `js_ws_*` / `js_net_*` / `js_tls_connect` surface, +// and the CLI routes `ws` / `net` / `tls` to them in every mode, including +// PERRY_DISABLE_WELL_KNOWN=1. The `bundled-ws` / `bundled-net` features stay +// as empty markers the CLI's feature table still names. + +// === TLS: the `node:tls` module surface, server and TLSSocket === // Desktop only; iOS/Android stdlib are stubs for now. -#[cfg(all( - feature = "bundled-net", - not(target_os = "ios"), - not(target_os = "android") -))] -pub mod net; -#[cfg(all( - feature = "bundled-net", - not(target_os = "ios"), - not(target_os = "android") -))] -pub use net::*; #[cfg(all( feature = "tls-runtime", not(target_os = "ios"), diff --git a/crates/perry-stdlib/src/net/mod.rs b/crates/perry-stdlib/src/net/mod.rs deleted file mode 100644 index 71f52e059e..0000000000 --- a/crates/perry-stdlib/src/net/mod.rs +++ /dev/null @@ -1,1184 +0,0 @@ -//! Raw TCP socket module — Node-compatible `net.Socket` surface with -//! TLS upgrade support (A2). -//! -//! Event-driven, async over tokio, mirroring the proven pattern in `ws.rs`: -//! one tokio task per socket reads in a `select!` loop and drives an mpsc -//! command channel for writes/end/destroy/upgrade. Read data is queued as -//! raw `Vec` into `NET_PENDING_EVENTS` and converted to `Buffer` on the -//! main thread inside `js_net_process_pending` — see the arena-safety rule -//! in `common/async_bridge.rs`. -//! -//! The `Transport` enum lets a single socket id keep the same handle across -//! a plain→TLS upgrade: `SocketCommand::UpgradeTls` moves the `TcpStream` -//! into `crate::tls_stream::TlsStream::connect()` (perry-tls-session's -//! sans-I/O rustls session over the tokio socket — turnloop P8 group H), then -//! stores the resulting `TlsStream` back under the same id. This is what Postgres' `SSLRequest` flow needs — -//! write 8 bytes in plain, read one byte (`'S'`/`'N'`), then upgrade. -//! -//! FFI signature conventions (match NATIVE_MODULE_TABLE in perry-codegen): -//! - Receiver handles and `NA_PTR` args arrive as `i64` (codegen calls -//! `unbox_to_i64` on the NaN-boxed value before the FFI invocation). -//! - `NA_STR` args arrive as `i64` StringHeader pointers (pre-unboxed via -//! `js_get_string_pointer_unified`). -//! - `NA_F64` args arrive as `f64`. -//! - `NR_PTR` return is `i64` and the codegen NaN-boxes with POINTER_TAG; -//! `NR_VOID` returns nothing and the codegen substitutes `undefined`. - -use perry_runtime::buffer::{js_buffer_alloc, BufferHeader}; -use perry_runtime::{js_closure_call0, js_closure_call1, ClosureHeader, JSValue, StringHeader}; -use std::collections::{HashMap, VecDeque}; -use std::io; -use std::pin::Pin; -use std::sync::Mutex; -use std::task::{Context, Poll}; - -use tokio::io::{AsyncRead, AsyncWrite, ReadBuf}; -use tokio::net::TcpStream; -use tokio::sync::{mpsc, oneshot}; - -use crate::common::async_bridge::spawn; - -#[cfg(feature = "tls")] -mod tls_verifier; - -mod socket_task; -#[cfg(feature = "tls")] -mod tls_config; -mod value_helpers; - -#[cfg(feature = "tls")] -use crate::tls_stream::TlsStream; - -use socket_task::{run_socket_task, spawn_socket_task}; -#[cfg(feature = "tls")] -use tls_config::{ - tls_client_config_data, tls_preflight, tls_preflight_error, tls_signal_is_pre_aborted, -}; -use value_helpers::{ - build_error_object, get_object_bool_field, get_object_number_field, get_object_string_field, - get_object_value_field, is_nanboxed_pointer, jsvalue_to_socket_bytes, string_from_header_i64, - unbox_pointer, -}; - -#[cfg(feature = "tls")] -#[derive(Clone, Default)] -struct TlsClientConfigData { - ca: Option>>, - cert: Vec, - key: Vec, - alpn_protocols: Vec>, - version_mask: i32, - custom_identity: bool, -} - -// ─── Transport enum (plain or TLS, swappable at runtime) ───────────────────── - -enum Transport { - Plain(TcpStream), - #[cfg(feature = "tls")] - Tls(Box>), -} - -impl AsyncRead for Transport { - fn poll_read( - self: Pin<&mut Self>, - cx: &mut Context<'_>, - buf: &mut ReadBuf<'_>, - ) -> Poll> { - match self.get_mut() { - Transport::Plain(s) => Pin::new(s).poll_read(cx, buf), - #[cfg(feature = "tls")] - Transport::Tls(s) => Pin::new(&mut **s).poll_read(cx, buf), - } - } -} - -impl AsyncWrite for Transport { - fn poll_write( - self: Pin<&mut Self>, - cx: &mut Context<'_>, - buf: &[u8], - ) -> Poll> { - match self.get_mut() { - Transport::Plain(s) => Pin::new(s).poll_write(cx, buf), - #[cfg(feature = "tls")] - Transport::Tls(s) => Pin::new(&mut **s).poll_write(cx, buf), - } - } - fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - match self.get_mut() { - Transport::Plain(s) => Pin::new(s).poll_flush(cx), - #[cfg(feature = "tls")] - Transport::Tls(s) => Pin::new(&mut **s).poll_flush(cx), - } - } - fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - match self.get_mut() { - Transport::Plain(s) => Pin::new(s).poll_shutdown(cx), - #[cfg(feature = "tls")] - Transport::Tls(s) => Pin::new(&mut **s).poll_shutdown(cx), - } - } -} - -// ─── Handle storage ────────────────────────────────────────────────────────── - -static NET_SOCKETS: std::sync::LazyLock>> = - std::sync::LazyLock::new(|| Mutex::new(HashMap::new())); -static NET_LISTENERS: std::sync::LazyLock>>>> = - std::sync::LazyLock::new(|| Mutex::new(HashMap::new())); -static NET_PENDING_EVENTS: std::sync::LazyLock>> = - std::sync::LazyLock::new(|| Mutex::new(Vec::new())); -static NET_PENDING_READS: std::sync::LazyLock>>>> = - std::sync::LazyLock::new(|| Mutex::new(HashMap::new())); -static NET_PENDING_TLS_ABORTS: std::sync::LazyLock>> = - std::sync::LazyLock::new(|| Mutex::new(std::collections::HashSet::new())); -static NEXT_NET_ID: std::sync::LazyLock> = std::sync::LazyLock::new(|| Mutex::new(1)); - -thread_local! { - // The mutable-root scanner registry is thread-local, so this latch must be too. - static NET_GC_REGISTERED: std::cell::Cell = const { std::cell::Cell::new(false) }; -} - -/// Register the net GC root scanner once on each thread. -fn ensure_gc_scanner_registered() { - NET_GC_REGISTERED.with(|registered| { - if registered.get() { - return; - } - perry_runtime::gc::gc_register_mutable_root_scanner_named("stdlib:net", scan_net_roots_mut); - registered.set(true); - }); -} - -/// GC root scanner for net.Socket event listener closures. -/// -/// Socket event listeners (`sock.on('data', cb)` etc.) are closures that -/// may be garbage-collectible from the user's perspective after the call -/// to `.on()` returns — the closure literal is only referenced by the -/// native-side `NET_LISTENERS` map. Without this scanner, any GC cycle -/// between `.on()` and the next dispatch would sweep the closure; the -/// next `js_closure_call1` would dereference freed memory. This was a -/// latent bug until v0.5.25 made GC fire during synchronous decode -/// loops (issue #35). -#[allow(dead_code)] -fn scan_net_roots(mark: &mut dyn FnMut(f64)) { - let mut visitor = perry_runtime::gc::RuntimeRootVisitor::for_copy(mark); - scan_net_roots_mut(&mut visitor); -} - -fn scan_net_roots_mut(visitor: &mut perry_runtime::gc::RuntimeRootVisitor<'_>) { - if let Ok(mut listeners) = NET_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); - } - } - } - } -} - -struct SocketState { - cmd_tx: mpsc::UnboundedSender, - /// `Some` only between `js_net_socket_alloc` and the first - /// `js_net_socket_method_connect` — held here so the deferred connect - /// path (issue #422: `new net.Socket()` then `sock.connect(port, host)`) - /// can move it into the spawned tokio task at connect time. Stays - /// `None` for the eager factory paths (`createConnection` / `tls.connect`) - /// where the rx flows straight into the task. - pending_rx: Option>, - is_open: bool, - type_of_service: u8, -} - -enum SocketCommand { - Write(Vec), - End, - Destroy, - #[cfg(feature = "tls")] - UpgradeTls { - servername: String, - verify: bool, - config: TlsClientConfigData, - reply: oneshot::Sender>, - }, -} - -enum PendingNetEvent { - Connect(i64), - #[cfg(feature = "tls")] - SecureConnect(i64), - Data(i64, Vec), - End(i64), - Close(i64), - Error(i64, String), - Abort(i64), -} - -// ─── Helpers ───────────────────────────────────────────────────────────────── - -#[cfg(feature = "tls")] -fn begin_tls_upgrade( - handle: i64, - servername: String, - verify: bool, - config: TlsClientConfigData, -) -> Result<(), String> { - let cmd_tx = NET_SOCKETS - .lock() - .unwrap() - .get(&handle) - .map(|socket| socket.cmd_tx.clone()) - .ok_or_else(|| "socket is closed".to_string())?; - let (reply, _reply_rx) = oneshot::channel(); - cmd_tx - .send(SocketCommand::UpgradeTls { - servername, - verify, - config, - reply, - }) - .map_err(|_| "socket task is gone".to_string()) -} - -fn next_id() -> i64 { - let mut g = NEXT_NET_ID.lock().unwrap(); - let id = *g; - *g += 1; - id -} - -fn push_event(ev: PendingNetEvent) { - NET_PENDING_EVENTS.lock().unwrap().push(ev); - // Issue #84: wake the main thread so the event is dispatched on the - // very next loop iteration instead of after the old 10 ms sleep. - perry_runtime::event_pump::js_notify_main_thread(); -} - -#[cfg(feature = "tls")] -fn fire_pending_tls_abort(handle: i64) { - if NET_PENDING_TLS_ABORTS.lock().unwrap().remove(&handle) { - push_event(PendingNetEvent::Abort(handle)); - push_event(PendingNetEvent::Close(handle)); - } -} - -#[cfg(feature = "tls")] -unsafe fn schedule_tls_abort(handle: i64) { - NET_PENDING_TLS_ABORTS.lock().unwrap().insert(handle); - crate::common::async_bridge::spawn(async move { - tokio::time::sleep(std::time::Duration::from_millis(25)).await; - fire_pending_tls_abort(handle); - }); -} - -fn mark_closed(id: i64) { - if let Some(s) = NET_SOCKETS.lock().unwrap().get_mut(&id) { - s.is_open = false; - } -} - -// ─── FFI: net.createConnection / net.connect ───────────────────────────────── - -/// `net.createConnection(...)` / `net.connect(...)` — returns a handle -/// immediately; connection happens in the background and emits -/// `'connect'` or `'error'`. -/// -/// Supports both Node overloads (issue #770): -/// - Positional: `net.connect(port, host, cb?)` — `arg1_f64` is the -/// port, `arg2_f64` is the host (NaN-boxed string), `arg3_f64` is -/// the optional connectListener. -/// - Options object: `net.connect({ host, port }, cb?)` — `arg1_f64` -/// is a NaN-boxed pointer to the options object; `arg2_f64` is the -/// optional connectListener; `arg3_f64` is unused (the dispatch -/// table pads it with `undefined`). -/// -/// The `connectListener` is auto-registered as a `'connect'` listener -/// on the new socket handle, matching Node spec. -/// -/// Signature matches NATIVE_MODULE_TABLE entry -/// `{ module: "net", method: "connect" | "createConnection", args: &[NA_F64, NA_F64, NA_F64], ret: NR_PTR }`. -#[no_mangle] -pub unsafe extern "C" fn js_net_socket_connect(arg1_f64: f64, arg2_f64: f64, arg3_f64: f64) -> i64 { - fn register_connect_cb(handle: i64, cb_f64: f64) { - if handle == 0 || !is_nanboxed_pointer(cb_f64) { - return; - } - let cb_ptr = unsafe { unbox_pointer(cb_f64) } as i64; - if cb_ptr == 0 { - return; - } - let mut listeners = NET_LISTENERS.lock().unwrap(); - listeners - .entry(handle) - .or_default() - .entry("connect".to_string()) - .or_default() - .push(cb_ptr); - } - - if is_nanboxed_pointer(arg1_f64) { - let host = match get_object_string_field(arg1_f64, "host") - .or_else(|| get_object_string_field(arg1_f64, "hostname")) - { - Some(h) if !h.is_empty() => h, - _ => "localhost".to_string(), - }; - let port = match get_object_number_field(arg1_f64, "port") { - Some(p) => { - perry_runtime::net_validate::js_net_validate_connect_port(p); - p as u16 - } - None => return 0, - }; - let handle = spawn_socket_task(host, port, /* direct_tls: */ None); - register_connect_cb(handle, arg2_f64); - return handle; - } - // Positional form: arg2 is a NaN-boxed string, arg3 is the cb. - perry_runtime::net_validate::js_net_validate_connect_port(arg1_f64); - let host_ptr = perry_runtime::js_get_string_pointer_unified(arg2_f64); - let host = match string_from_header_i64(host_ptr) { - Some(h) => h, - None => return 0, - }; - let port = arg1_f64 as u16; - let handle = spawn_socket_task(host, port, /* direct_tls: */ None); - register_connect_cb(handle, arg3_f64); - handle -} - -// ─── FFI: new net.Socket() (alloc-only, deferred connect) ──────────────────── - -/// `new net.Socket()` — allocates an unconnected socket handle. The TCP -/// connection is deferred until `js_net_socket_method_connect` is called -/// (`sock.connect(port, host)`). -/// -/// Pre-issue-#422 the only path into the net module was the eager -/// `net.createConnection(port, host)` factory, which both allocates the -/// handle AND kicks off the connect in one shot. Real-world TS code -/// (including pure-TS Postgres / MySQL / MQTT drivers) commonly takes -/// the `new net.Socket()` + later `.connect(...)` shape, where listener -/// registration sits between the two — that pattern needs a separate -/// allocator. -/// -/// Signature matches NATIVE_MODULE_TABLE entry -/// `{ module: "net", method: "Socket", args: &[], ret: NR_PTR }`. -#[no_mangle] -pub unsafe extern "C" fn js_net_socket_alloc() -> i64 { - ensure_gc_scanner_registered(); - let id = next_id(); - let (tx, rx) = mpsc::unbounded_channel::(); - NET_SOCKETS.lock().unwrap().insert( - id, - SocketState { - cmd_tx: tx, - pending_rx: Some(rx), - is_open: false, - type_of_service: 0, - }, - ); - NET_LISTENERS.lock().unwrap().insert(id, HashMap::new()); - id -} - -// ─── FFI: socket.connect(port, host) (instance method on existing handle) ───── - -/// `socket.connect(port, host)` — initiates a TCP connection on a socket -/// previously allocated by `new net.Socket()`. Spawns the same tokio task -/// shape as `js_net_socket_connect`, but pulls its receiver out of the -/// `SocketState::pending_rx` slot rather than allocating a fresh channel, -/// so any listener already registered (`sock.on('data', cb)` etc.) sees -/// the same handle id once the connect completes. -/// -/// If `pending_rx` is already empty (already connected, or unknown handle) -/// this pushes an `'error'` event rather than silently dropping — matches -/// Node's behavior where calling `.connect()` twice on the same socket -/// emits `Error: already connected`. -/// -/// Signature matches NATIVE_MODULE_TABLE entry -/// `{ has_receiver: true, method: "connect", class_filter: Some("Socket"), -/// args: &[NA_F64, NA_STR], ret: NR_VOID }`. -#[no_mangle] -pub unsafe extern "C" fn js_net_socket_method_connect(handle: i64, port: f64, host_ptr: i64) { - perry_runtime::net_validate::js_net_validate_connect_port(port); - let host = match string_from_header_i64(host_ptr) { - Some(h) => h, - None => { - push_event(PendingNetEvent::Error( - handle, - "socket.connect: invalid host string".to_string(), - )); - return; - } - }; - let port = port as u16; - - // Move the deferred-connect rx out of the SocketState. After this - // take, subsequent .connect() calls land in the `None` arm below. - let mut rx = { - let mut guard = NET_SOCKETS.lock().unwrap(); - match guard.get_mut(&handle).and_then(|s| s.pending_rx.take()) { - Some(rx) => rx, - None => { - push_event(PendingNetEvent::Error( - handle, - "socket already connected (or unknown handle)".to_string(), - )); - return; - } - } - }; - - spawn(async move { - let addr = format!("{}:{}", host, port); - let tcp = match TcpStream::connect(&addr).await { - Ok(s) => s, - Err(e) => { - push_event(PendingNetEvent::Error(handle, format!("{}", e))); - push_event(PendingNetEvent::Close(handle)); - mark_closed(handle); - return; - } - }; - - if let Some(s) = NET_SOCKETS.lock().unwrap().get_mut(&handle) { - s.is_open = true; - } - push_event(PendingNetEvent::Connect(handle)); - - run_socket_task(handle, Transport::Plain(tcp), &mut rx).await; - }); -} - -// ─── FFI: tls.connect ──────────────────────────────────────────────────────── - -/// `tls.connect(...)` — opens a plain TCP socket and immediately runs the -/// TLS handshake before firing `'connect'`/`'secureConnect'`. Use this for -/// protocols that start TLS from byte 0 (HTTPS, SMTP with SMTPS, etc.). -/// -/// For protocols that negotiate TLS mid-stream (Postgres' `SSLRequest`, -/// SMTP STARTTLS), use `net.createConnection` then `socket.upgradeToTLS` -/// instead. -/// -/// Resolves Node's overloads plus Perry's legacy positional form — kept in -/// sync with the perry-ext-net copy, which is the live path after the -/// well-known flip (#4971): -/// -/// - `tls.connect(options[, callback])` — `port` required; `host`/`hostname` -/// default `"localhost"`; `servername` defaults to the host; -/// `rejectUnauthorized: false` disables cert verification. -/// - `tls.connect(port[, host][, options][, callback])` -/// - Legacy Perry positional: `tls.connect(host, port, servername?, verify?)`. -/// -/// Signature matches `{ module: "tls", method: "connect", -/// args: &[NA_F64, NA_F64, NA_F64, NA_F64], ret: NR_PTR }`. -#[cfg(feature = "tls")] -#[no_mangle] -pub unsafe extern "C" fn js_tls_connect(arg1: f64, arg2: f64, arg3: f64, arg4: f64) -> i64 { - perry_runtime::tls::js_tls_prepare_connect(); - extern "C" { - fn js_value_is_closure(value_bits: i64) -> i32; - } - let is_closure = - |v: f64| is_nanboxed_pointer(v) && js_value_is_closure(v.to_bits() as i64) != 0; - let as_string = |v: f64| -> Option { - if !JSValue::from_bits(v.to_bits()).is_string() { - return None; - } - string_from_header_i64(perry_runtime::js_get_string_pointer_unified(v)) - }; - // Cert verification only goes off when the caller says so explicitly — - // a missing/undefined flag keeps it on. - let explicitly_off = |v: f64| -> bool { - let j = JSValue::from_bits(v.to_bits()); - (j.is_bool() && !j.to_bool()) || (j.is_number() && j.as_number() == 0.0) - }; - - let (host, port, servername, verify, cb_f64, metadata_options); - if let Some(h) = as_string(arg1) { - // Legacy Perry positional: (host, port, servername?, verify?). - let p = JSValue::from_bits(arg2.to_bits()); - if !p.is_number() { - return 0; - } - port = p.as_number() as u16; - servername = as_string(arg3).unwrap_or_else(|| h.clone()); - host = h; - verify = !explicitly_off(arg4); - cb_f64 = None; - metadata_options = f64::from_bits(0x7FFC_0000_0000_0001); - } else if is_nanboxed_pointer(arg1) && !is_closure(arg1) { - // Node options form: tls.connect(options[, callback]). - perry_runtime::tls::js_tls_validate_connect_options(arg1); - if let Some(socket_value) = get_object_value_field(arg1, "socket") { - let socket_js = JSValue::from_bits(socket_value.to_bits()); - let handle = if socket_js.is_pointer() { - unbox_pointer(socket_value) as i64 - } else { - 0 - }; - if handle != 0 { - host = get_object_string_field(arg1, "host") - .or_else(|| get_object_string_field(arg1, "hostname")) - .unwrap_or_else(|| "localhost".to_string()); - servername = - get_object_string_field(arg1, "servername").unwrap_or_else(|| host.clone()); - verify = get_object_bool_field(arg1, "rejectUnauthorized").unwrap_or(true); - cb_f64 = is_closure(arg2).then_some(arg2); - metadata_options = arg1; - let config = tls_client_config_data(metadata_options); - perry_runtime::tls::js_tls_client_record_start( - handle, - metadata_options, - servername.as_ptr(), - servername.len(), - ); - if let Some(callback) = cb_f64 { - let callback = unbox_pointer(callback) as i64; - if callback != 0 { - NET_LISTENERS - .lock() - .unwrap() - .entry(handle) - .or_default() - .entry("secureConnect".to_string()) - .or_default() - .push(callback); - } - } - let preflight = tls_preflight(0, &servername, metadata_options); - if preflight != 0 { - push_event(PendingNetEvent::Error( - handle, - tls_preflight_error(preflight).to_string(), - )); - push_event(PendingNetEvent::Close(handle)); - } else if let Err(error) = begin_tls_upgrade(handle, servername, verify, config) { - push_event(PendingNetEvent::Error(handle, error)); - push_event(PendingNetEvent::Close(handle)); - } - return handle; - } - } - port = match get_object_number_field(arg1, "port") { - Some(p) => { - perry_runtime::net_validate::js_net_validate_connect_port(p); - p as u16 - } - None => return 0, - }; - host = match get_object_string_field(arg1, "host") - .or_else(|| get_object_string_field(arg1, "hostname")) - { - Some(h) if !h.is_empty() => h, - _ => "localhost".to_string(), - }; - servername = get_object_string_field(arg1, "servername").unwrap_or_else(|| host.clone()); - verify = get_object_bool_field(arg1, "rejectUnauthorized").unwrap_or(true); - cb_f64 = if is_closure(arg2) { Some(arg2) } else { None }; - metadata_options = arg1; - } else if JSValue::from_bits(arg1.to_bits()).is_number() - || JSValue::from_bits(arg1.to_bits()).is_int32() - { - // Node positional form: tls.connect(port[, host][, options][, cb]). - perry_runtime::net_validate::js_net_validate_connect_port(arg1); - let port_value = JSValue::from_bits(arg1.to_bits()); - port = if port_value.is_int32() { - port_value.as_int32() as u16 - } else { - arg1 as u16 - }; - let mut opt_host: Option = None; - let mut opts: Option = None; - let mut cb: Option = None; - for v in [arg2, arg3, arg4] { - if opt_host.is_none() { - if let Some(h) = as_string(v) { - opt_host = Some(h); - continue; - } - } - if is_closure(v) { - cb = cb.or(Some(v)); - } else if is_nanboxed_pointer(v) { - opts = opts.or(Some(v)); - } - } - if let Some(options) = opts { - perry_runtime::tls::js_tls_validate_positional_connect_options(options); - } - host = opt_host - .or_else(|| { - opts.and_then(|o| { - get_object_string_field(o, "host") - .or_else(|| get_object_string_field(o, "hostname")) - }) - }) - .filter(|h| !h.is_empty()) - .unwrap_or_else(|| "localhost".to_string()); - servername = opts - .and_then(|o| get_object_string_field(o, "servername")) - .unwrap_or_else(|| host.clone()); - verify = opts - .and_then(|o| get_object_bool_field(o, "rejectUnauthorized")) - .unwrap_or(true); - cb_f64 = cb; - metadata_options = opts.unwrap_or_else(|| f64::from_bits(0x7FFC_0000_0000_0001)); - } else { - return 0; - } - - let config = tls_client_config_data(metadata_options); - if tls_signal_is_pre_aborted(metadata_options) { - let handle = js_net_socket_alloc(); - perry_runtime::tls::js_tls_client_record_start( - handle, - metadata_options, - servername.as_ptr(), - servername.len(), - ); - schedule_tls_abort(handle); - return handle; - } - let preflight = tls_preflight(port, &servername, metadata_options); - if preflight != 0 { - let handle = js_net_socket_alloc(); - perry_runtime::tls::js_tls_client_record_start( - handle, - metadata_options, - servername.as_ptr(), - servername.len(), - ); - push_event(PendingNetEvent::Error( - handle, - tls_preflight_error(preflight).to_string(), - )); - push_event(PendingNetEvent::Close(handle)); - return handle; - } - let handle = spawn_socket_task(host, port, Some((servername.clone(), verify, config))); - perry_runtime::tls::js_tls_client_record_start( - handle, - metadata_options, - servername.as_ptr(), - servername.len(), - ); - crate::tls::record_tls_client_handle(handle); - if let Some(cb) = cb_f64 { - if handle != 0 { - let cb_ptr = unbox_pointer(cb) as i64; - if cb_ptr != 0 { - NET_LISTENERS - .lock() - .unwrap() - .entry(handle) - .or_default() - .entry("secureConnect".to_string()) - .or_default() - .push(cb_ptr); - } - } - } - handle -} - -// ─── FFI: socket.write(buf) ────────────────────────────────────────────────── - -/// `socket.write(chunk)` — enqueues bytes for the writer task. -/// Issue #1131 — `chunk_bits` is the full NaN-boxed JS value (codegen -/// passes `NA_JSV`; the dispatch shim passes `args[0].to_bits()`), not -/// a pre-stripped `BufferHeader` pointer. `jsvalue_to_socket_bytes` -/// probes Buffer-vs-string-vs-number and reads the correct layout so -/// `socket.write("ping")` sends the UTF-8 bytes instead of garbage. -/// Signature matches `{ has_receiver: true, method: "write", args: &[NA_JSV], ret: NR_VOID }`. -#[no_mangle] -pub unsafe extern "C" fn js_net_socket_write(handle: i64, chunk_bits: i64) { - let bytes = match jsvalue_to_socket_bytes(f64::from_bits(chunk_bits as u64)) { - Some(b) => b, - None => return, - }; - - let sockets = NET_SOCKETS.lock().unwrap(); - if let Some(s) = sockets.get(&handle) { - let _ = s.cmd_tx.send(SocketCommand::Write(bytes)); - } -} - -/// Paused-mode `net.Socket.read()`: return one queued Buffer or `null` when -/// no bytes are currently available. The optional size argument is accepted -/// for ABI parity; socket transport reads already define the queued chunks. -#[no_mangle] -pub unsafe extern "C" fn js_net_socket_read(handle: i64, _size: f64) -> f64 { - let chunk = { - let mut reads = NET_PENDING_READS.lock().unwrap(); - let Some(queue) = reads.get_mut(&handle) else { - return f64::from_bits(0x7FFC_0000_0000_0002); - }; - let chunk = queue.pop_front(); - if queue.is_empty() { - reads.remove(&handle); - } - chunk - }; - let Some(bytes) = chunk else { - return f64::from_bits(0x7FFC_0000_0000_0002); - }; - let buffer = js_buffer_alloc(bytes.len() as i32, 0); - if buffer.is_null() { - return f64::from_bits(0x7FFC_0000_0000_0002); - } - let data = (buffer as *mut u8).add(std::mem::size_of::()); - std::ptr::copy_nonoverlapping(bytes.as_ptr(), data, bytes.len()); - (*buffer).length = bytes.len() as u32; - f64::from_bits(JSValue::pointer(buffer as *const u8).bits()) -} - -// ─── FFI: socket.end([data]) ───────────────────────────────────────────────── - -/// `socket.end([data])` — optionally write a final chunk, then graceful -/// shutdown: stops further writes, lets reads drain. -/// -/// Issue #1852 — Node's `socket.end(data)` writes `data` then sends FIN. -/// `chunk_bits` is the full NaN-boxed JS value (NA_JSV); `undefined`/`null` -/// (the no-arg `socket.end()` form) yields no bytes. Kept in sync with the -/// live perry-ext-net copy so the `js_net_socket_end` symbol has one -/// signature regardless of which archive links. -/// Signature matches `{ has_receiver: true, method: "end", args: &[NA_JSV], ret: NR_VOID }`. -#[no_mangle] -pub unsafe extern "C" fn js_net_socket_end(handle: i64, chunk_bits: i64) { - let sockets = NET_SOCKETS.lock().unwrap(); - if let Some(s) = sockets.get(&handle) { - if let Some(bytes) = jsvalue_to_socket_bytes(f64::from_bits(chunk_bits as u64)) { - if !bytes.is_empty() { - let _ = s.cmd_tx.send(SocketCommand::Write(bytes)); - } - } - let _ = s.cmd_tx.send(SocketCommand::End); - } -} - -// ─── FFI: socket.destroy() ─────────────────────────────────────────────────── - -/// `socket.destroy()` — hard close, fires `'close'`. -/// Signature matches `{ has_receiver: true, method: "destroy", args: &[], ret: NR_VOID }`. -#[no_mangle] -pub unsafe extern "C" fn js_net_socket_destroy(handle: i64) { - let sockets = NET_SOCKETS.lock().unwrap(); - if let Some(s) = sockets.get(&handle) { - let _ = s.cmd_tx.send(SocketCommand::Destroy); - } -} - -#[no_mangle] -pub extern "C" fn js_net_socket_get_type_of_service(handle: i64) -> f64 { - NET_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 = perry_runtime::net_validate::js_net_validate_tos(tos) as u8; - if let Some(s) = NET_SOCKETS.lock().unwrap().get_mut(&handle) { - s.type_of_service = tos; - } - handle -} - -// ─── FFI: socket.on(event, callback) ───────────────────────────────────────── - -/// `socket.on(event, cb)` — registers a listener. Closures are stored as -/// raw `i64` pointers and invoked from `js_net_process_pending` on the -/// main thread. -/// -/// Signature matches `{ has_receiver: true, method: "on", args: &[NA_STR, NA_PTR], ret: NR_VOID }`. -#[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 = NET_LISTENERS.lock().unwrap(); - let entry = listeners.entry(handle).or_default(); - entry.entry(event.clone()).or_default().push(cb); - } - #[cfg(feature = "tls")] - if event == "close" { - fire_pending_tls_abort(handle); - } -} - -#[no_mangle] -pub unsafe extern "C" fn js_net_socket_once(handle: i64, event_ptr: i64, cb: i64) -> i64 { - // Net events in this transport are terminal or edge-triggered for the - // lifecycle cases TLSSocket uses. Register in the same provider map; the - // external provider supplies its full once-flag implementation when it is - // linked ahead of this bundled fallback. - js_net_socket_on(handle, event_ptr, cb); - handle -} - -#[no_mangle] -pub unsafe extern "C" fn js_net_socket_remove_listener( - handle: i64, - event_ptr: i64, - cb: i64, -) -> i64 { - if let Some(event) = string_from_header_i64(event_ptr) { - if let Some(callbacks) = NET_LISTENERS - .lock() - .unwrap() - .get_mut(&handle) - .and_then(|events| events.get_mut(&event)) - { - if let Some(index) = callbacks.iter().position(|candidate| *candidate == cb) { - callbacks.remove(index); - } - } - } - handle -} - -#[no_mangle] -pub unsafe extern "C" fn js_net_socket_remove_all_listeners(handle: i64, event_ptr: i64) -> i64 { - let mut all = NET_LISTENERS.lock().unwrap(); - if event_ptr == 0 { - all.entry(handle).or_default().clear(); - } else if let Some(event) = string_from_header_i64(event_ptr) { - all.entry(handle).or_default().remove(&event); - } - handle -} - -#[no_mangle] -pub unsafe extern "C" fn js_net_socket_listener_count(handle: i64, event_ptr: i64) -> f64 { - let Some(event) = string_from_header_i64(event_ptr) else { - return 0.0; - }; - NET_LISTENERS - .lock() - .unwrap() - .get(&handle) - .and_then(|events| events.get(&event)) - .map(|callbacks| callbacks.len() as f64) - .unwrap_or(0.0) -} - -#[no_mangle] -pub unsafe extern "C" fn js_net_socket_event_names(handle: i64) -> *mut StringHeader { - let names = NET_LISTENERS - .lock() - .unwrap() - .get(&handle) - .map(|events| { - events - .iter() - .filter(|(_, callbacks)| !callbacks.is_empty()) - .map(|(name, _)| format!("\"{}\"", name.replace('"', "\\\""))) - .collect::>() - }) - .unwrap_or_default(); - let json = format!("[{}]", names.join(",")); - perry_runtime::js_string_from_bytes(json.as_ptr(), json.len() as u32) -} - -// ─── FFI: socket.upgradeToTLS(servername) -> Promise ───────────────────────── - -/// `socket.upgradeToTLS(servername)` — sends an UpgradeTls command to the -/// socket's task and returns a Promise that resolves when the TLS handshake -/// completes (or rejects on failure). -/// -/// This is the Postgres-style primitive: after `SSLRequest` + `'S'` response, -/// the TS-side driver calls this to swap the transport from plain TCP to -/// TLS on the same connection. -/// -/// Signature matches `{ has_receiver: true, method: "upgradeToTLS", -/// args: &[NA_STR], ret: NR_PTR }` with an async Promise return. -#[cfg(feature = "tls")] -#[no_mangle] -pub unsafe extern "C" fn js_net_socket_upgrade_tls( - handle: i64, - servername_ptr: i64, - verify: f64, -) -> *mut perry_runtime::Promise { - let promise = perry_runtime::js_promise_new_cross_thread(); - let promise_ptr = promise as *mut u8; - - let servername = match string_from_header_i64(servername_ptr) { - Some(s) => s, - None => { - let err = "invalid servername".to_string(); - crate::common::async_bridge::spawn_for_promise(promise_ptr, async move { - Err::(err) - }); - return promise; - } - }; - - let cmd_tx = { - let sockets = NET_SOCKETS.lock().unwrap(); - match sockets.get(&handle) { - Some(s) => s.cmd_tx.clone(), - None => { - let err = format!("socket {} not found", handle); - crate::common::async_bridge::spawn_for_promise(promise_ptr, async move { - Err::(err) - }); - return promise; - } - } - }; - - let (reply_tx, reply_rx) = oneshot::channel::>(); - let verify = verify != 0.0; - if cmd_tx - .send(SocketCommand::UpgradeTls { - servername, - verify, - config: TlsClientConfigData::default(), - reply: reply_tx, - }) - .is_err() - { - let err = "socket task is gone".to_string(); - crate::common::async_bridge::spawn_for_promise(promise_ptr, async move { - Err::(err) - }); - return promise; - } - - crate::common::async_bridge::spawn_for_promise(promise_ptr, async move { - match reply_rx.await { - Ok(Ok(())) => { - // Resolve with undefined. Bits for TAG_UNDEFINED: - Ok(0x7FFC_0000_0000_0001u64) - } - Ok(Err(msg)) => Err(msg), - Err(_) => Err("upgrade reply dropped".to_string()), - } - }); - - promise -} - -// ─── Main-thread event pump ────────────────────────────────────────────────── - -/// Dispatches queued socket events to JS listeners on the main thread. -/// Called from `common::async_bridge::js_stdlib_process_pending`. -/// -/// Per the arena-safety rule: JSValue construction (Buffer, error string) -/// happens HERE on the main thread, never in the tokio read task. -/// -/// #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). -unsafe fn emit_socket_no_arg(handle: i64, event: &str) { - let receiver = f64::from_bits(0x7FFD_0000_0000_0000 | (handle as u64 & 0x0000_FFFF_FFFF_FFFF)); - // #10490: root the displaced `this` across the listeners (user code). - let this_scope = perry_runtime::gc::RuntimeHandleScope::new(); - let previous_this = - this_scope.root_nanbox_f64(perry_runtime::object::js_implicit_this_set(receiver)); - for callback in listeners_for(handle, event) { - if callback != 0 { - js_closure_call0(callback as *const ClosureHeader); - } - } - perry_runtime::object::js_implicit_this_set(previous_this.get_nanbox_f64()); -} - -#[cfg(feature = "tls")] -unsafe fn emit_tls_secure_connect(handle: i64) { - let identity_error = perry_runtime::tls::js_tls_client_check_identity_from_metadata(handle); - if !JSValue::from_bits(identity_error.to_bits()).is_undefined() { - for callback in listeners_for(handle, "error") { - if callback != 0 { - js_closure_call1(callback as *const ClosureHeader, identity_error); - } - } - if let Some(socket) = NET_SOCKETS.lock().unwrap().get(&handle) { - let _ = socket.cmd_tx.send(SocketCommand::Destroy); - } - return; - } - emit_socket_no_arg(handle, "secureConnect"); -} - -#[no_mangle] -pub unsafe extern "C" fn js_net_process_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 = NET_PENDING_EVENTS.lock().unwrap(); - events.append(&mut *g); - } - let count = events.len() as i32; - - for ev in events.drain(..) { - match ev { - PendingNetEvent::Connect(id) => { - emit_socket_no_arg(id, "connect"); - #[cfg(feature = "tls")] - if perry_runtime::tls::js_tls_client_is_connected(id) != 0 { - emit_tls_secure_connect(id); - } - } - #[cfg(feature = "tls")] - PendingNetEvent::SecureConnect(id) => emit_tls_secure_connect(id), - PendingNetEvent::Data(id, bytes) => { - let cbs = listeners_for(id, "data"); - if cbs.is_empty() { - NET_PENDING_READS - .lock() - .unwrap() - .entry(id) - .or_default() - .push_back(bytes); - emit_socket_no_arg(id, "readable"); - continue; - } - // Construct Buffer on the main thread. - let buf = js_buffer_alloc(bytes.len() as i32, 0); - if buf.is_null() { - continue; - } - let buf_data = (buf as *mut u8).add(std::mem::size_of::()); - std::ptr::copy_nonoverlapping(bytes.as_ptr(), buf_data, bytes.len()); - (*buf).length = bytes.len() as u32; - - let buf_f64 = f64::from_bits(JSValue::pointer(buf as *const u8).bits()); - for cb in cbs { - if cb != 0 { - js_closure_call1(cb as *const ClosureHeader, buf_f64); - } - } - } - PendingNetEvent::Error(id, msg) => { - let cbs = listeners_for(id, "error"); - if cbs.is_empty() { - continue; - } - // Issue #770 — emit an Error-shaped object `{message: msg}` - // so user code can read `err.message`. Pre-fix the listener - // received a raw NaN-boxed string and `err.message` came - // back as `undefined`. - let scope = perry_runtime::gc::RuntimeHandleScope::new(); - let error = scope.root_nanbox_f64(build_error_object(&msg)); - for cb in cbs { - if cb != 0 { - js_closure_call1(cb as *const ClosureHeader, error.get_nanbox_f64()); - } - } - } - PendingNetEvent::Abort(id) => { - let error = perry_runtime::url::js_abort_error_value(); - for callback in listeners_for(id, "error") { - if callback != 0 { - js_closure_call1(callback as *const ClosureHeader, error); - } - } - } - PendingNetEvent::End(id) => emit_socket_no_arg(id, "end"), - PendingNetEvent::Close(id) => { - perry_runtime::tls::js_tls_client_record_closed(id); - for cb in listeners_for(id, "close") { - if cb != 0 { - js_closure_call0(cb as *const ClosureHeader); - } - } - NET_LISTENERS.lock().unwrap().remove(&id); - NET_SOCKETS.lock().unwrap().remove(&id); - NET_PENDING_READS.lock().unwrap().remove(&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 -} - -fn listeners_for(id: i64, event: &str) -> Vec { - NET_LISTENERS - .lock() - .unwrap() - .get(&id) - .and_then(|m| m.get(event).cloned()) - .unwrap_or_default() -} - -/// Returns 1 if there are pending events or live sockets keeping the loop alive. -/// -/// "Live" here means *registered* — including sockets still establishing -/// their TCP connection. Counting only `is_open` sockets caused the runtime -/// to exit before async `connect` ever completed (the is_open flag flips -/// inside the spawned task, after `await TcpStream::connect`). -pub fn js_net_has_active_handles() -> i32 { - if !NET_PENDING_EVENTS.lock().unwrap().is_empty() { - return 1; - } - if !NET_SOCKETS.lock().unwrap().is_empty() { - return 1; - } - 0 -} - -/// True iff `handle` is a currently-registered net socket id. Used by -/// the runtime's HANDLE_METHOD_DISPATCH path to route `someSock.method(...)` -/// through to the right FFI when codegen couldn't statically tag the -/// receiver type (e.g. when the socket lives behind a wrapper function -/// or inside a struct field). -pub fn is_net_socket_handle(handle: i64) -> bool { - NET_SOCKETS.lock().unwrap().contains_key(&handle) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn root_scanner_emits_socket_listeners() { - { - let mut listeners = NET_LISTENERS.lock().unwrap(); - listeners.clear(); - listeners.insert( - 7, - HashMap::from([ - ("data".to_string(), vec![0x1234_5678]), - ("error".to_string(), vec![0x2345_6780]), - ]), - ); - } - - let mut emitted = Vec::new(); - scan_net_roots(&mut |value| emitted.push(value.to_bits())); - - assert!(emitted.contains(&(0x7FFD_0000_0000_0000 | 0x1234_5678))); - assert!(emitted.contains(&(0x7FFD_0000_0000_0000 | 0x2345_6780))); - NET_LISTENERS.lock().unwrap().clear(); - } -} diff --git a/crates/perry-stdlib/src/net/socket_task.rs b/crates/perry-stdlib/src/net/socket_task.rs deleted file mode 100644 index 7708cc2def..0000000000 --- a/crates/perry-stdlib/src/net/socket_task.rs +++ /dev/null @@ -1,281 +0,0 @@ -//! The per-socket tokio task: connect, optional TLS handshake, and the -//! read/write/command loop that drives a `net.Socket` handle. -//! -//! Split out of `net/mod.rs` (2000-line file cap). Pure move — the -//! functions keep their names, signatures and behaviour; only the two -//! `net` still calls widened to `pub(super)`. - -use std::collections::HashMap; - -use tokio::io::{AsyncReadExt, AsyncWriteExt}; -use tokio::net::TcpStream; -use tokio::sync::mpsc; - -use crate::common::async_bridge::spawn; - -use super::{ - ensure_gc_scanner_registered, mark_closed, next_id, push_event, PendingNetEvent, SocketCommand, - SocketState, TlsClientConfigData, Transport, NET_LISTENERS, NET_SOCKETS, -}; - -#[cfg(feature = "tls")] -use crate::tls_stream::TlsStream; - -#[cfg(feature = "tls")] -use super::tls_config::build_tls_connector; - -/// Internal: allocate the handle, spawn the tokio task. -/// `direct_tls` = Some((servername, verify)) runs a TLS handshake before -/// firing 'connect'; None keeps the socket in plain TCP mode. -pub(super) fn spawn_socket_task( - host: String, - port: u16, - direct_tls: Option<(String, bool, TlsClientConfigData)>, -) -> i64 { - ensure_gc_scanner_registered(); - let id = next_id(); - let (tx, mut rx) = mpsc::unbounded_channel::(); - - NET_SOCKETS.lock().unwrap().insert( - id, - SocketState { - cmd_tx: tx, - pending_rx: None, - is_open: false, - type_of_service: 0, - }, - ); - NET_LISTENERS.lock().unwrap().insert(id, HashMap::new()); - - spawn(async move { - let addr = format!("{}:{}", host, port); - let tcp = match TcpStream::connect(&addr).await { - Ok(s) => s, - Err(e) => { - push_event(PendingNetEvent::Error(id, format!("{}", e))); - push_event(PendingNetEvent::Close(id)); - mark_closed(id); - return; - } - }; - - // Direct-TLS path: run the TLS handshake before signalling connect. - let transport = match direct_tls { - #[cfg(feature = "tls")] - Some((servername, verify, config)) => { - match do_tls_handshake(tcp, &servername, verify, Some(&config)).await { - Ok(tls) => { - record_tls_handshake(id, &tls, verify, Some(&config)); - Transport::Tls(Box::new(tls)) - } - Err(e) => { - push_event(PendingNetEvent::Error(id, e)); - push_event(PendingNetEvent::Close(id)); - mark_closed(id); - return; - } - } - } - #[cfg(not(feature = "tls"))] - Some(_) => { - push_event(PendingNetEvent::Error( - id, - "tls feature not compiled in".to_string(), - )); - push_event(PendingNetEvent::Close(id)); - mark_closed(id); - return; - } - None => Transport::Plain(tcp), - }; - - if let Some(s) = NET_SOCKETS.lock().unwrap().get_mut(&id) { - s.is_open = true; - } - push_event(PendingNetEvent::Connect(id)); - - run_socket_task(id, transport, &mut rx).await; - }); - - id -} - -#[cfg(feature = "tls")] -async fn do_tls_handshake( - tcp: TcpStream, - servername: &str, - verify: bool, - data: Option<&TlsClientConfigData>, -) -> Result, String> { - let connector = build_tls_connector(verify, data)?; - let server_name = rustls::pki_types::ServerName::try_from(servername.to_string()) - .map_err(|e| format!("invalid servername '{}': {}", servername, e))?; - TlsStream::connect(tcp, connector, server_name) - .await - .map_err(|e| format!("tls handshake: {}", e)) -} - -#[cfg(feature = "tls")] -fn record_tls_handshake( - handle: i64, - stream: &TlsStream, - verify: bool, - data: Option<&TlsClientConfigData>, -) { - let connection = stream.session(); - let protocol = match connection.protocol_version() { - Some(rustls::ProtocolVersion::TLSv1_2) => "TLSv1.2", - Some(rustls::ProtocolVersion::TLSv1_3) => "TLSv1.3", - _ => "", - }; - let alpn = connection.alpn_protocol().unwrap_or_default(); - let peer = connection - .peer_certificates() - .and_then(|certs| certs.first()) - .map(|cert| cert.as_ref()) - .unwrap_or_default(); - let trusted_by_configured_ca = - data.and_then(|data| data.ca.as_ref()) - .is_some_and(|materials| { - materials.iter().any(|material| { - let mut cursor = std::io::Cursor::new(material); - let trusted = rustls_pemfile::certs(&mut cursor) - .flatten() - .any(|cert| cert.as_ref() == peer); - trusted - }) - }); - let authorized = verify || trusted_by_configured_ca; - let authorization_error = if authorized { - "" - } else { - "DEPTH_ZERO_SELF_SIGNED_CERT" - }; - let own_certificate = data - .map(|data| { - let mut cursor = std::io::Cursor::new(&data.cert); - let certificate = rustls_pemfile::certs(&mut cursor) - .flatten() - .next() - .map(|cert| cert.as_ref().to_vec()) - .unwrap_or_default(); - certificate - }) - .unwrap_or_default(); - unsafe { - perry_runtime::tls::js_tls_client_record_connected( - handle, - authorized as i32, - authorization_error.as_ptr(), - authorization_error.len(), - protocol.as_ptr(), - protocol.len(), - alpn.as_ptr(), - alpn.len(), - peer.as_ptr(), - peer.len(), - own_certificate.as_ptr(), - own_certificate.len(), - ); - } -} - -/// The read/write/command loop. Shared by plain-TCP and direct-TLS paths. -pub(super) async fn run_socket_task( - id: i64, - initial_transport: Transport, - rx: &mut mpsc::UnboundedReceiver, -) { - let mut transport: Option = Some(initial_transport); - let mut buf = vec![0u8; 16 * 1024]; - - loop { - let t = match transport.as_mut() { - Some(t) => t, - None => break, // transport taken and not restored → end task - }; - - tokio::select! { - read_result = t.read(&mut buf) => { - match read_result { - Ok(0) => { - // Node's default `allowHalfOpen: false` closes the - // writable side after peer EOF. On TLS transports this - // also sends close_notify instead of making the peer - // report an unclean close without an `end` event. - let _ = t.shutdown().await; - push_event(PendingNetEvent::End(id)); - push_event(PendingNetEvent::Close(id)); - mark_closed(id); - break; - } - Ok(n) => { - push_event(PendingNetEvent::Data(id, buf[..n].to_vec())); - } - Err(e) => { - push_event(PendingNetEvent::Error(id, format!("{}", e))); - push_event(PendingNetEvent::Close(id)); - mark_closed(id); - break; - } - } - } - cmd = rx.recv() => { - match cmd { - Some(SocketCommand::Write(bytes)) => { - if let Err(e) = t.write_all(&bytes).await { - push_event(PendingNetEvent::Error(id, format!("{}", e))); - push_event(PendingNetEvent::Close(id)); - mark_closed(id); - break; - } - } - Some(SocketCommand::End) => { - let _ = t.shutdown().await; - } - Some(SocketCommand::Destroy) | None => { - push_event(PendingNetEvent::Close(id)); - mark_closed(id); - break; - } - #[cfg(feature = "tls")] - Some(SocketCommand::UpgradeTls { servername, verify, config, reply }) => { - // Take the plain TcpStream out of the enum, run the - // handshake, and put a TlsStream back under the same id. - // Done inline (blocks reads until handshake completes), - // which is what the Postgres SSLRequest flow expects. - let old = transport.take(); - match old { - Some(Transport::Plain(tcp)) => { - match do_tls_handshake(tcp, &servername, verify, Some(&config)).await { - Ok(tls) => { - record_tls_handshake(id, &tls, verify, Some(&config)); - transport = Some(Transport::Tls(Box::new(tls))); - crate::tls::record_tls_client_handle(id); - let _ = reply.send(Ok(())); - push_event(PendingNetEvent::SecureConnect(id)); - } - Err(e) => { - let _ = reply.send(Err(e.clone())); - push_event(PendingNetEvent::Error(id, e)); - push_event(PendingNetEvent::Close(id)); - mark_closed(id); - break; - } - } - } - Some(already_tls @ Transport::Tls(_)) => { - transport = Some(already_tls); - let _ = reply.send(Err("socket is already TLS".to_string())); - } - None => { - let _ = reply.send(Err("socket closed".to_string())); - break; - } - } - } - } - } - } - } -} diff --git a/crates/perry-stdlib/src/net/tls_config.rs b/crates/perry-stdlib/src/net/tls_config.rs deleted file mode 100644 index 47b995ac48..0000000000 --- a/crates/perry-stdlib/src/net/tls_config.rs +++ /dev/null @@ -1,358 +0,0 @@ -//! TLS option parsing and rustls `ClientConfig`/`TlsConnector` construction -//! for the bundled `net`/`tls` socket surface. -//! -//! Split out of `net/mod.rs` (2000-line file cap). Pure move — the -//! functions keep their names, signatures, `#[cfg(feature = "tls")]` -//! gating and behaviour; only the ones `net` still calls widened to -//! `pub(super)`. - -use std::sync::Arc; - -use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}; - -use perry_runtime::{JSValue, StringHeader}; - -use super::tls_verifier::NodeConfiguredCaVerifier; -use super::value_helpers::{get_object_value_field, unbox_pointer}; -use super::TlsClientConfigData; - -/// What `build_tls_connector` produces: the client config a handshake runs -/// with (formerly wrapped in a `tokio_rustls::TlsConnector`). -type TlsConnector = Arc; - -#[cfg(feature = "tls")] -unsafe fn tls_value_bytes(value: f64) -> Option> { - let mut len = 0u32; - let data = perry_runtime::buffer::js_value_buffer_or_typedarray_data(value, &mut len); - if !data.is_null() { - return Some(std::slice::from_raw_parts(data, len as usize).to_vec()); - } - // `js_get_string_pointer_unified` deliberately returns the raw pointer for - // any POINTER_TAG value. Probe Buffer/TypedArray values first so their - // headers are never interpreted as StringHeaders. - let string_ptr = perry_runtime::js_get_string_pointer_unified(value); - (string_ptr != 0) - .then(|| crate::common::string_from_header(string_ptr as *const StringHeader)) - .flatten() - .map(String::into_bytes) -} - -#[cfg(feature = "tls")] -unsafe fn tls_material_list(value: f64) -> Option>> { - let js = JSValue::from_bits(value.to_bits()); - if js.is_undefined() || js.is_null() { - return Some(Vec::new()); - } - if JSValue::from_bits(perry_runtime::js_array_is_array(value).to_bits()).as_bool() { - let array = unbox_pointer(value) as *const perry_runtime::ArrayHeader; - let mut out = Vec::new(); - for index in 0..perry_runtime::js_array_length(array) { - out.extend(tls_material_list(perry_runtime::array::js_array_get_f64( - array, index, - ))?); - } - return Some(out); - } - tls_value_bytes(value).map(|bytes| vec![bytes]) -} - -#[cfg(feature = "tls")] -unsafe fn tls_option_value(options: f64, secure_context: f64, name: &str) -> Option { - get_object_value_field(options, name).and_then(|value| { - if JSValue::from_bits(value.to_bits()).is_undefined() { - get_object_value_field(secure_context, name) - } else { - Some(value) - } - }) -} - -#[cfg(feature = "tls")] -unsafe fn tls_parse_alpn(value: f64) -> Vec> { - if JSValue::from_bits(perry_runtime::js_array_is_array(value).to_bits()).as_bool() { - let array = unbox_pointer(value) as *const perry_runtime::ArrayHeader; - return (0..perry_runtime::js_array_length(array)) - .filter_map(|index| { - tls_value_bytes(perry_runtime::array::js_array_get_f64(array, index)) - }) - .collect(); - } - let Some(encoded) = tls_value_bytes(value) else { - return Vec::new(); - }; - let mut offset = 0usize; - let mut out = Vec::new(); - while offset < encoded.len() { - let len = encoded[offset] as usize; - offset += 1; - if len == 0 || offset + len > encoded.len() { - break; - } - out.push(encoded[offset..offset + len].to_vec()); - offset += len; - } - out -} - -#[cfg(feature = "tls")] -pub(super) unsafe fn tls_client_config_data(options: f64) -> TlsClientConfigData { - let secure_context = get_object_value_field(options, "secureContext") - .unwrap_or_else(|| f64::from_bits(0x7FFC_0000_0000_0001)); - let mut ca = - tls_option_value(options, secure_context, "ca").and_then(|value| tls_material_list(value)); - if ca.is_none() && perry_runtime::tls::js_tls_default_ca_is_configured() != 0 { - ca = tls_material_list(perry_runtime::tls::js_tls_get_ca_certificates( - f64::from_bits(0x7FFC_0000_0000_0001), - )); - } - TlsClientConfigData { - ca, - cert: tls_option_value(options, secure_context, "cert") - .and_then(|value| tls_value_bytes(value)) - .unwrap_or_default(), - key: tls_option_value(options, secure_context, "key") - .and_then(|value| tls_value_bytes(value)) - .unwrap_or_default(), - alpn_protocols: tls_option_value(options, secure_context, "ALPNProtocols") - .map(|value| tls_parse_alpn(value)) - .unwrap_or_default(), - version_mask: perry_runtime::tls::js_tls_effective_version_mask(options), - custom_identity: tls_option_value(options, secure_context, "checkServerIdentity") - .is_some_and(|value| { - let js = JSValue::from_bits(value.to_bits()); - !js.is_undefined() && !js.is_null() - }), - } -} - -#[cfg(feature = "tls")] -fn tls_protocol_versions(mask: i32) -> Vec<&'static rustls::SupportedProtocolVersion> { - let mask = if mask == 0 { 0b11 } else { mask }; - let mut versions = Vec::new(); - if mask & 0b10 != 0 { - versions.push(&rustls::version::TLS13); - } - if mask & 0b01 != 0 { - versions.push(&rustls::version::TLS12); - } - versions -} - -#[cfg(feature = "tls")] -pub(super) unsafe fn tls_signal_is_pre_aborted(options: f64) -> bool { - let Some(signal) = get_object_value_field(options, "signal") else { - return false; - }; - let signal = perry_runtime::url::js_abort_signal_resolve_ptr(signal); - if signal.is_null() { - return false; - } - perry_runtime::url::js_abort_signal_is_aborted(signal) != 0 -} - -#[cfg(feature = "tls")] -pub(super) unsafe fn tls_preflight(port: u16, servername: &str, options: f64) -> i32 { - crate::tls::js_tls_client_preflight(port as f64, servername.as_ptr(), servername.len(), options) -} - -#[cfg(feature = "tls")] -pub(super) fn tls_preflight_error(code: i32) -> &'static str { - match code { - 1 => "ERR_TLS_ALPN_CALLBACK_INVALID_RESULT", - 2 => "ERR_SSL_TLSV1_ALERT_NO_APPLICATION_PROTOCOL", - 3 => "ERR_TLS_SNI_CALLBACK_FAILED", - _ => "ERR_TLS_HANDSHAKE_FAILED", - } -} - -// ─── rustls config (TLS feature only) ──────────────────────────────────────── - -#[cfg(feature = "tls")] -pub(super) fn build_tls_connector( - verify: bool, - data: Option<&TlsClientConfigData>, -) -> Result { - // rustls panics resolving the process-level CryptoProvider when both - // `ring` and `aws-lc-rs` end up in the dep graph. Server paths install - // one before their first handshake; a client-only program (no tls/https - // server) reached `ClientConfig::builder()` with none installed once - // #4971 made `tls.connect` actually resolve its host. Idempotent — - // `install_default` errors (ignored) if a provider is already set. - let _ = rustls::crypto::ring::default_provider().install_default(); - if !verify { - return build_tls_connector_insecure(data); - } - // System trust store. Aligns with Perry's broader rustls-only stance - // (reqwest / tokio-tungstenite / mongodb all use rustls) — no OpenSSL. - let mut root_store = rustls::RootCertStore::empty(); - // rustls-native-certs 0.8 returns a CertificateResult with separate - // `.certs` and `.errors` fields; we accept per-cert failures rather - // than bail, matching the crate's own documented pattern. - if let Some(ca) = data.and_then(|data| data.ca.as_ref()) { - add_pem_roots(&mut root_store, ca); - } else { - let native = rustls_native_certs::load_native_certs(); - for cert in native.certs { - let _ = root_store.add(cert); - } - } - let configured = configured_ca_certificates(data); - let custom_identity = data.is_some_and(|data| data.custom_identity); - let node_verifier = if configured.is_empty() && !custom_identity { - None - } else { - Some(NodeConfiguredCaVerifier { - inner: rustls::client::WebPkiServerVerifier::builder(Arc::new(root_store.clone())) - .build() - .map_err(|error| format!("tls certificate verifier: {error}"))?, - roots: root_store.clone(), - configured, - custom_identity, - }) - }; - let versions = tls_protocol_versions(data.map_or(0b11, |data| data.version_mask)); - let builder = rustls::ClientConfig::builder_with_provider( - rustls::crypto::ring::default_provider().into(), - ) - .with_protocol_versions(&versions) - .map_err(|error| format!("tls protocol versions: {error}"))? - .with_root_certificates(root_store); - let mut config = if let Some((certs, key)) = data.and_then(client_auth_material) { - builder - .with_client_auth_cert(certs, key) - .map_err(|error| format!("tls client certificate: {error}"))? - } else { - builder.with_no_client_auth() - }; - if let Some(data) = data { - config.alpn_protocols = data.alpn_protocols.clone(); - } - if let Some(verifier) = node_verifier { - config - .dangerous() - .set_certificate_verifier(Arc::new(verifier)); - } - Ok(Arc::new(config)) -} - -#[cfg(feature = "tls")] -fn add_pem_roots(store: &mut rustls::RootCertStore, materials: &[Vec]) { - for material in materials { - let mut cursor = std::io::Cursor::new(material); - for cert in rustls_pemfile::certs(&mut cursor).flatten() { - let _ = store.add(cert); - } - } -} - -#[cfg(feature = "tls")] -fn configured_ca_certificates(data: Option<&TlsClientConfigData>) -> Vec> { - data.and_then(|data| data.ca.as_ref()) - .into_iter() - .flatten() - .flat_map(|material| { - let mut cursor = std::io::Cursor::new(material); - rustls_pemfile::certs(&mut cursor) - .flatten() - .map(|cert| cert.as_ref().to_vec()) - .collect::>() - }) - .collect() -} - -#[cfg(feature = "tls")] -fn client_auth_material( - data: &TlsClientConfigData, -) -> Option<( - Vec>, - rustls::pki_types::PrivateKeyDer<'static>, -)> { - let mut cert_cursor = std::io::Cursor::new(&data.cert); - let certs: Vec<_> = rustls_pemfile::certs(&mut cert_cursor).flatten().collect(); - if certs.is_empty() { - return None; - } - let mut key_cursor = std::io::Cursor::new(&data.key); - let key = rustls_pemfile::private_key(&mut key_cursor) - .ok() - .flatten()?; - Some((certs, key)) -} - -/// Insecure TLS — accept any server cert without verifying chain or hostname. -/// Maps to Postgres `sslmode=require` (encryption without auth) and is the -/// right default for local dev against self-signed certs. Real deployments -/// should pass `verify: true` (the default) so the system trust store and -/// hostname validation apply. -#[cfg(feature = "tls")] -fn build_tls_connector_insecure( - data: Option<&TlsClientConfigData>, -) -> Result { - use rustls::pki_types::{CertificateDer, ServerName, UnixTime}; - use rustls::{DigitallySignedStruct, SignatureScheme}; - - #[derive(Debug)] - struct NoVerify; - - impl ServerCertVerifier for NoVerify { - fn verify_server_cert( - &self, - _end_entity: &CertificateDer<'_>, - _intermediates: &[CertificateDer<'_>], - _server_name: &ServerName<'_>, - _ocsp: &[u8], - _now: UnixTime, - ) -> Result { - Ok(ServerCertVerified::assertion()) - } - fn verify_tls12_signature( - &self, - _message: &[u8], - _cert: &CertificateDer<'_>, - _dss: &DigitallySignedStruct, - ) -> Result { - Ok(HandshakeSignatureValid::assertion()) - } - fn verify_tls13_signature( - &self, - _message: &[u8], - _cert: &CertificateDer<'_>, - _dss: &DigitallySignedStruct, - ) -> Result { - Ok(HandshakeSignatureValid::assertion()) - } - fn supported_verify_schemes(&self) -> Vec { - vec![ - SignatureScheme::RSA_PKCS1_SHA256, - SignatureScheme::RSA_PKCS1_SHA384, - SignatureScheme::RSA_PKCS1_SHA512, - SignatureScheme::ECDSA_NISTP256_SHA256, - SignatureScheme::ECDSA_NISTP384_SHA384, - SignatureScheme::RSA_PSS_SHA256, - SignatureScheme::RSA_PSS_SHA384, - SignatureScheme::RSA_PSS_SHA512, - SignatureScheme::ED25519, - ] - } - } - - let versions = tls_protocol_versions(data.map_or(0b11, |data| data.version_mask)); - let builder = rustls::ClientConfig::builder_with_provider( - rustls::crypto::ring::default_provider().into(), - ) - .with_protocol_versions(&versions) - .map_err(|error| format!("tls protocol versions: {error}"))? - .dangerous() - .with_custom_certificate_verifier(Arc::new(NoVerify)); - let mut config = if let Some((certs, key)) = data.and_then(client_auth_material) { - builder - .with_client_auth_cert(certs, key) - .map_err(|error| format!("tls client certificate: {error}"))? - } else { - builder.with_no_client_auth() - }; - if let Some(data) = data { - config.alpn_protocols = data.alpn_protocols.clone(); - } - Ok(Arc::new(config)) -} diff --git a/crates/perry-stdlib/src/net/tls_verifier.rs b/crates/perry-stdlib/src/net/tls_verifier.rs deleted file mode 100644 index caae0048b4..0000000000 --- a/crates/perry-stdlib/src/net/tls_verifier.rs +++ /dev/null @@ -1,100 +0,0 @@ -//! Node-compatible rustls server-certificate verification. - -use std::sync::Arc; - -use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}; - -#[cfg(feature = "tls")] -#[derive(Debug)] -pub(super) struct NodeConfiguredCaVerifier { - pub(super) inner: Arc, - pub(super) roots: rustls::RootCertStore, - pub(super) configured: Vec>, - pub(super) custom_identity: bool, -} - -#[cfg(feature = "tls")] -fn is_ca_used_as_end_entity(error: &rustls::Error) -> bool { - let rustls::Error::InvalidCertificate(rustls::CertificateError::Other(other)) = error else { - return false; - }; - other.0.to_string() == "CaUsedAsEndEntity" -} - -#[cfg(feature = "tls")] -impl ServerCertVerifier for NodeConfiguredCaVerifier { - fn verify_server_cert( - &self, - end_entity: &rustls::pki_types::CertificateDer<'_>, - intermediates: &[rustls::pki_types::CertificateDer<'_>], - server_name: &rustls::pki_types::ServerName<'_>, - ocsp_response: &[u8], - now: rustls::pki_types::UnixTime, - ) -> Result { - if self.custom_identity { - let parsed = rustls::server::ParsedCertificate::try_from(end_entity)?; - let provider = rustls::crypto::ring::default_provider(); - match rustls::client::verify_server_cert_signed_by_trust_anchor( - &parsed, - &self.roots, - intermediates, - now, - provider.signature_verification_algorithms.all, - ) { - Ok(()) => return Ok(ServerCertVerified::assertion()), - Err(error) - if is_ca_used_as_end_entity(&error) - && self - .configured - .iter() - .any(|cert| cert.as_slice() == end_entity.as_ref()) => - { - return Ok(ServerCertVerified::assertion()); - } - Err(error) => return Err(error), - } - } - match self.inner.verify_server_cert( - end_entity, - intermediates, - server_name, - ocsp_response, - now, - ) { - Err(error) - if is_ca_used_as_end_entity(&error) - && self - .configured - .iter() - .any(|cert| cert.as_slice() == end_entity.as_ref()) => - { - let parsed = rustls::server::ParsedCertificate::try_from(end_entity)?; - rustls::client::verify_server_name(&parsed, server_name)?; - Ok(ServerCertVerified::assertion()) - } - result => result, - } - } - - fn verify_tls12_signature( - &self, - message: &[u8], - cert: &rustls::pki_types::CertificateDer<'_>, - dss: &rustls::DigitallySignedStruct, - ) -> Result { - self.inner.verify_tls12_signature(message, cert, dss) - } - - fn verify_tls13_signature( - &self, - message: &[u8], - cert: &rustls::pki_types::CertificateDer<'_>, - dss: &rustls::DigitallySignedStruct, - ) -> Result { - self.inner.verify_tls13_signature(message, cert, dss) - } - - fn supported_verify_schemes(&self) -> Vec { - self.inner.supported_verify_schemes() - } -} diff --git a/crates/perry-stdlib/src/net/value_helpers.rs b/crates/perry-stdlib/src/net/value_helpers.rs deleted file mode 100644 index bc728d50f3..0000000000 --- a/crates/perry-stdlib/src/net/value_helpers.rs +++ /dev/null @@ -1,247 +0,0 @@ -//! NaN-boxed JS value readers shared by the `net`/`tls` FFI surface. -//! -//! Split out of `net/mod.rs` (2000-line file cap). Pure move — the -//! helpers keep their names, signatures and behaviour; only their -//! visibility widened to `pub(super)` so `net` and its sibling -//! submodules can still reach them. - -use perry_runtime::buffer::BufferHeader; -use perry_runtime::{JSValue, StringHeader}; - -pub(super) unsafe fn string_from_header_i64(ptr: i64) -> Option { - crate::common::string_from_header(ptr as *const StringHeader) -} - -/// Issue #770 — true iff `val_f64` carries `POINTER_TAG` (0x7FFD), i.e. -/// it's a real heap-pointer NaN-box (object or closure). Plain `f64` -/// ports like `80.0` never reach this band, and `undefined` / `null` -/// land in `0x7FFC` so they're cleanly rejected — which matters -/// because the dispatch table pads missing user args with -/// `TAG_UNDEFINED`. -pub(super) fn is_nanboxed_pointer(val_f64: f64) -> bool { - (val_f64.to_bits() >> 48) == 0x7FFD -} - -pub(super) unsafe fn unbox_pointer(val_f64: f64) -> *mut u8 { - let bits = val_f64.to_bits(); - (bits & 0x0000_FFFF_FFFF_FFFF) as *mut u8 -} - -/// Issue #1131 — read a NaN-boxed JS value as the raw bytes for -/// `socket.write(chunk)`. Mirror of perry-ext-net's -/// `jsvalue_to_socket_bytes` (the live path for `node:net` imports is -/// the perry-ext-net copy after the well-known flip; this bundled-net -/// copy stays in sync so the HANDLE_METHOD_DISPATCH fallback through -/// `dispatch_net_socket` is correct too). A JS string is a 20-byte -/// `StringHeader`; a Buffer is an 8-byte `BufferHeader` — reading one -/// through the other's layout (the pre-#1131 unconditional -/// `*BufferHeader` cast) emits garbage. Probe `BUFFER_REGISTRY` first. -pub(super) unsafe fn jsvalue_to_socket_bytes(value: f64) -> Option> { - let v = JSValue::from_bits(value.to_bits()); - if v.is_undefined() || v.is_null() { - return None; - } - if v.is_string() { - let ptr = unbox_pointer(value) as *const StringHeader; - if ptr.is_null() { - return None; - } - let len = (*ptr).byte_len as usize; - let data = (ptr as *const u8).add(std::mem::size_of::()); - return Some(std::slice::from_raw_parts(data, len).to_vec()); - } - if v.is_pointer() { - let raw = (value.to_bits() & 0x0000_FFFF_FFFF_FFFF) as i64; - if perry_runtime::buffer::js_buffer_is_buffer(raw) != 0 { - let buf = raw as *const BufferHeader; - if !buf.is_null() { - let len = (*buf).length as usize; - let data = perry_runtime::buffer::buffer_data( - buf as *const perry_runtime::buffer::BufferHeader, - ); - return Some(std::slice::from_raw_parts(data, len).to_vec()); - } - } - let sptr = raw as *const StringHeader; - if !sptr.is_null() { - let len = (*sptr).byte_len as usize; - if len <= (1 << 30) { - let data = (sptr as *const u8).add(std::mem::size_of::()); - return Some(std::slice::from_raw_parts(data, len).to_vec()); - } - } - return None; - } - if v.is_number() { - return Some(v.to_number().to_string().into_bytes()); - } - if v.is_bool() { - return Some( - if v.to_bool() { "true" } else { "false" } - .to_string() - .into_bytes(), - ); - } - None -} - -pub(super) unsafe fn get_object_string_field(obj_f64: f64, field_name: &str) -> Option { - if !is_nanboxed_pointer(obj_f64) { - return None; - } - let obj_ptr = unbox_pointer(obj_f64) as *const perry_runtime::ObjectHeader; - if obj_ptr.is_null() { - return None; - } - let key = perry_runtime::js_string_from_bytes(field_name.as_ptr(), field_name.len() as u32); - let val = perry_runtime::js_object_get_field_by_name(obj_ptr, key); - if val.is_undefined() || val.is_null() { - return None; - } - if val.is_string() { - return string_from_header_i64(val.as_string_ptr() as i64); - } - if val.is_number() { - return Some(format!("{}", val.as_number() as i64)); - } - None -} - -pub(super) unsafe fn get_object_value_field(obj_f64: f64, field_name: &str) -> Option { - if !is_nanboxed_pointer(obj_f64) { - return None; - } - let obj_ptr = unbox_pointer(obj_f64) as *const perry_runtime::ObjectHeader; - if !perry_runtime::value::addr_class::is_above_handle_band(obj_ptr as usize) { - return None; - } - let key = perry_runtime::js_string_from_bytes(field_name.as_ptr(), field_name.len() as u32); - Some(f64::from_bits( - perry_runtime::js_object_get_field_by_name(obj_ptr, key).bits(), - )) -} - -pub(super) unsafe fn get_object_number_field(obj_f64: f64, field_name: &str) -> Option { - if !is_nanboxed_pointer(obj_f64) { - return None; - } - let obj_ptr = unbox_pointer(obj_f64) as *const perry_runtime::ObjectHeader; - if obj_ptr.is_null() { - return None; - } - let key = perry_runtime::js_string_from_bytes(field_name.as_ptr(), field_name.len() as u32); - let val = perry_runtime::js_object_get_field_by_name(obj_ptr, key); - if val.is_undefined() || val.is_null() { - return None; - } - if val.is_number() { - return Some(val.as_number()); - } - if val.is_string() { - if let Some(s) = string_from_header_i64(val.as_string_ptr() as i64) { - if let Ok(n) = s.parse::() { - return Some(n); - } - } - } - None -} - -/// Read a boolean option off a NaN-boxed JS object. Accepts real -/// booleans plus numbers (`rejectUnauthorized: 0` shows up in npm -/// code). `None` when the field is absent/undefined/null. #4971. -pub(super) unsafe fn get_object_bool_field(obj_f64: f64, field_name: &str) -> Option { - if !is_nanboxed_pointer(obj_f64) { - return None; - } - let obj_ptr = unbox_pointer(obj_f64) as *const perry_runtime::ObjectHeader; - if obj_ptr.is_null() { - return None; - } - let key = perry_runtime::js_string_from_bytes(field_name.as_ptr(), field_name.len() as u32); - let val = perry_runtime::js_object_get_field_by_name(obj_ptr, key); - if val.is_undefined() || val.is_null() { - return None; - } - if val.is_bool() { - return Some(val.to_bool()); - } - if val.is_number() { - return Some(val.as_number() != 0.0); - } - None -} - -/// Issue #770 — build an `Error`-shaped object `{ message: msg }` so -/// `socket.on('error', err => err.message)` works. Returns a NaN-boxed -/// f64 pointing at the object, falling back to a bare string on alloc -/// failure. Packed-keys format (NUL-delimited names + hash shape id) -/// mirrors `crates/perry-stdlib/src/sqlite.rs::build_packed_keys`. -pub(super) unsafe fn build_error_object(msg: &str) -> f64 { - use perry_runtime::JSValue; - let scope = perry_runtime::gc::RuntimeHandleScope::new(); - let keys = ["message", "code", "name"]; - let mut packed = Vec::new(); - for key in keys { - packed.extend_from_slice(key.as_bytes()); - packed.push(0); - } - let mut shape_id: u32 = 0x4E45_0000; // "NE" — net error - for &b in &packed { - shape_id = shape_id.wrapping_mul(31).wrapping_add(b as u32); - } - shape_id = shape_id.wrapping_add(3); - let s_msg = scope.root_string_ptr(perry_runtime::js_string_from_bytes( - msg.as_ptr(), - msg.len() as u32, - )); - let obj_ptr = perry_runtime::js_object_alloc_with_shape( - shape_id, - 3, - packed.as_ptr(), - packed.len() as u32, - ); - if obj_ptr.is_null() { - return s_msg.with_const_ptr(|s_msg: *const perry_runtime::StringHeader| { - f64::from_bits(0x7FFF_0000_0000_0000u64 | (s_msg as u64 & 0x0000_FFFF_FFFF_FFFF)) - }); - } - let obj = scope.root_raw_mut_ptr(obj_ptr); - obj.with_mut_ptr(|obj| { - s_msg.with_mut_ptr(|s_msg| { - perry_runtime::js_object_set_field(obj, 0, JSValue::string_ptr(s_msg)) - }) - }); - let code = if msg.starts_with("ERR_") { - Some(msg) - } else if msg.contains("UnknownIssuer") - || msg.contains("unknown issuer") - || msg.contains("invalid peer certificate") - { - Some("DEPTH_ZERO_SELF_SIGNED_CERT") - } else if msg.to_ascii_lowercase().contains("connection refused") { - Some("ECONNREFUSED") - } else { - None - }; - if let Some(code) = code { - let code = scope.root_string_ptr(perry_runtime::js_string_from_bytes( - code.as_ptr(), - code.len() as u32, - )); - obj.with_mut_ptr(|obj| { - code.with_mut_ptr(|code| { - perry_runtime::js_object_set_field(obj, 1, JSValue::string_ptr(code)) - }) - }); - } - let name = scope.root_string_ptr(perry_runtime::js_string_from_bytes(b"Error".as_ptr(), 5)); - obj.with_mut_ptr(|obj| { - name.with_mut_ptr(|name| { - perry_runtime::js_object_set_field(obj, 2, JSValue::string_ptr(name)) - }) - }); - obj.with_mut_ptr(|obj: *mut perry_runtime::ObjectHeader| { - f64::from_bits((obj as u64 & 0x0000_FFFF_FFFF_FFFF) | 0x7FFD_0000_0000_0000) - }) -} diff --git a/crates/perry-stdlib/src/tls/dispatch.rs b/crates/perry-stdlib/src/tls/dispatch.rs index 29da556a24..0cdfd91366 100644 --- a/crates/perry-stdlib/src/tls/dispatch.rs +++ b/crates/perry-stdlib/src/tls/dispatch.rs @@ -211,6 +211,19 @@ pub unsafe fn dispatch_tls_handle(handle: i64, method: &str, args: &[f64]) -> f6 // net provider. Keep their stream/event methods in that provider's // listener and command maps; the TLS-local maps below belong to // accepted/server-side and directly constructed TLS sockets. + // + // The provider is perry-ext-net, the only one since tokio lane L4 + // deleted bundled `net`, so these arms exist only in a build that + // links it (every `external-*` net / TLS feature implies it). The + // prebuilt `full` archive has none of them and must still link + // without libperry_ext_net.a; when ext-net IS linked beside it, its + // handle-method extension (`dispatch.rs`, registered with the + // runtime) answers these calls before this dispatcher is asked. + #[cfg(any( + feature = "external-net-tls", + feature = "external-tls-server", + feature = "external-net-pump" + ))] if perry_runtime::tls::is_tls_client_handle(handle) { match method { "write" if !args.is_empty() => { diff --git a/crates/perry-stdlib/src/tls/module_api.rs b/crates/perry-stdlib/src/tls/module_api.rs index dc53c40949..eb21ad49aa 100644 --- a/crates/perry-stdlib/src/tls/module_api.rs +++ b/crates/perry-stdlib/src/tls/module_api.rs @@ -17,12 +17,9 @@ use super::{ TLS_DISPATCH_MISSING_BITS, }; -#[cfg(feature = "bundled-net")] -unsafe fn dispatch_tls_connect(arg1: f64, arg2: f64, arg3: f64, arg4: f64) -> i64 { - crate::net::js_tls_connect(arg1, arg2, arg3, arg4) -} - -#[cfg(all(not(feature = "bundled-net"), feature = "external-net-tls"))] +// `tls.connect` is perry-ext-net's (`js_tls_connect`); perry-stdlib's bundled +// `net` copy that also defined it was deleted in tokio lane L4. +#[cfg(feature = "external-net-tls")] unsafe fn dispatch_tls_connect(arg1: f64, arg2: f64, arg3: f64, arg4: f64) -> i64 { unsafe extern "C" { fn js_tls_connect(arg1: f64, arg2: f64, arg3: f64, arg4: f64) -> i64; @@ -30,9 +27,17 @@ unsafe fn dispatch_tls_connect(arg1: f64, arg2: f64, arg3: f64, arg4: f64) -> i6 js_tls_connect(arg1, arg2, arg3, arg4) } -#[cfg(not(any(feature = "bundled-net", feature = "external-net-tls")))] -unsafe fn dispatch_tls_connect(_arg1: f64, _arg2: f64, _arg3: f64, _arg4: f64) -> i64 { - 0 +/// No link-time provider (the prebuilt `full` archive, which must link without +/// libperry_ext_net.a): use the one perry-ext-net registered with the runtime +/// from its install hook, which the entry prologue calls for every program +/// that imports `net` or `tls`. 0 (read as `undefined`) when ext-net is not +/// linked at all. +#[cfg(not(feature = "external-net-tls"))] +unsafe fn dispatch_tls_connect(arg1: f64, arg2: f64, arg3: f64, arg4: f64) -> i64 { + match perry_runtime::tls::tls_connect_provider() { + Some(connect) => connect(arg1, arg2, arg3, arg4), + None => 0, + } } fn split_subject_alt_names(san: &str) -> Vec<(String, String)> { diff --git a/crates/perry-stdlib/src/tls_stream.rs b/crates/perry-stdlib/src/tls_stream.rs deleted file mode 100644 index 6339a55759..0000000000 --- a/crates/perry-stdlib/src/tls_stream.rs +++ /dev/null @@ -1,296 +0,0 @@ -//! A TLS stream over a tokio transport, driven by `perry-tls-session`'s -//! sans-I/O [`TlsSession`] instead of `tokio_rustls` (turnloop P8 group H). -//! -//! The two bundled surfaces that negotiate TLS here — the bundled `net` -//! client's `tls.connect` / `upgradeToTLS` (`net/mod.rs`) and the `wss://` -//! connector (`ws.rs`) — still run on tokio sockets: those are -//! `async_bridge::RUNTIME`'s, which is group L and stays for now. (The -//! `node:tls` server that also used this moved to turnloop handles in lane L; -//! [`TlsStream::accept`] stays for this module's own server-side tests.) What moves is the TLS engine. It is the same `rustls::unbuffered` core -//! `turnloop-tls` wraps and perry-ext-net's turnloop path already drives, so -//! perry-stdlib no longer depends on `tokio-rustls` at all. -//! -//! The observable contract is `tokio_rustls`'s, kept on purpose so no caller -//! changes behaviour: -//! -//! * a handshake that fails flushes rustls's fatal alert before returning, and -//! the `io::Error` (`InvalidData`) displays rustls's own text; -//! * TCP EOF mid-handshake is `UnexpectedEof` / `"tls handshake eof"`; -//! * a read after the peer's `close_notify` is a clean EOF, while TCP EOF -//! without one is rustls's `UnexpectedEof` message; -//! * `poll_shutdown` sends `close_notify` before shutting the transport's -//! write half, and a transport that is already disconnected is not an error. -//! -//! `poll_read` keeps all its state in the struct, so it is cancel-safe inside a -//! `tokio::select!` exactly as `tokio_rustls`'s was. - -use std::future::poll_fn; -use std::io; -use std::pin::Pin; -use std::sync::Arc; -use std::task::{ready, Context, Poll}; - -use perry_tls_session::TlsSession; -use rustls::pki_types::ServerName; -use tokio::io::{AsyncRead, AsyncWrite, ReadBuf}; - -/// `rustls`'s message for TCP EOF without `close_notify` (what -/// `tokio_rustls`'s reader returned). Unbuffered connections have no reader, so -/// the adapter produces it itself. -const UNEXPECTED_EOF_MESSAGE: &str = "peer closed connection without sending TLS close_notify: \ -https://docs.rs/rustls/latest/rustls/manual/_03_howto/index.html#unexpected-eof"; - -/// Ciphertext read per transport read. -const READ_CHUNK: usize = 16 * 1024 + 256; - -pub(crate) struct TlsStream { - io: IO, - session: TlsSession, - /// Ciphertext produced by the session and not yet written. - out: Vec, - out_pos: usize, - /// Decrypted plaintext not yet handed to the reader. - plain: Vec, - plain_pos: usize, - read_buf: Box<[u8]>, - /// The transport reported EOF. - eof: bool, - /// `poll_shutdown` has queued `close_notify`. - write_closed: bool, -} - -impl TlsStream { - fn new(io: IO, session: TlsSession) -> Self { - Self { - io, - session, - out: Vec::new(), - out_pos: 0, - plain: Vec::new(), - plain_pos: 0, - read_buf: vec![0u8; READ_CHUNK].into_boxed_slice(), - eof: false, - write_closed: false, - } - } - - /// Client handshake over `io` (the `TlsConnector::connect` replacement). - /// Used by bundled `net` (`tls`) and `wss://` (`bundled-ws`). - #[cfg_attr(not(any(feature = "tls", feature = "bundled-ws")), allow(dead_code))] - pub(crate) async fn connect( - io: IO, - config: Arc, - server_name: ServerName<'static>, - ) -> io::Result { - let session = TlsSession::client(config, server_name).map_err(io::Error::other)?; - let mut stream = Self::new(io, session); - poll_fn(|cx| stream.poll_handshake(cx)).await?; - Ok(stream) - } - - /// Server handshake over an accepted `io` (the `TlsAcceptor::accept` - /// replacement). The `node:tls` server used it until it moved to turnloop - /// (`tls/turnloop_server.rs`); kept for the client's round-trip tests. - #[cfg(test)] - pub(crate) async fn accept(io: IO, config: Arc) -> io::Result { - let session = TlsSession::server(config).map_err(io::Error::other)?; - let mut stream = Self::new(io, session); - poll_fn(|cx| stream.poll_handshake(cx)).await?; - Ok(stream) - } - - /// The negotiated session, for protocol / ALPN / SNI / peer-chain queries. - #[cfg_attr(not(feature = "tls"), allow(dead_code))] - pub(crate) fn session(&self) -> &TlsSession { - &self.session - } - - /// Run the session and collect what it produced. - fn pump(&mut self) { - self.session.pump(); - if self.session.has_output() { - let produced = self.session.take_output(); - if self.out_pos == self.out.len() { - self.out = produced; - self.out_pos = 0; - } else { - self.out.extend_from_slice(&produced); - } - } - let plain = self.session.take_plaintext(); - if !plain.is_empty() { - if self.plain_pos == self.plain.len() { - self.plain = plain; - self.plain_pos = 0; - } else { - self.plain.extend_from_slice(&plain); - } - } - } - - fn failure_error(&self) -> Option { - self.session - .failure() - .map(|failure| io::Error::new(io::ErrorKind::InvalidData, failure.message.clone())) - } - - /// Write every pending ciphertext byte to the transport. - fn poll_write_out(&mut self, cx: &mut Context<'_>) -> Poll> { - while self.out_pos < self.out.len() { - let n = ready!(Pin::new(&mut self.io).poll_write(cx, &self.out[self.out_pos..]))?; - if n == 0 { - return Poll::Ready(Err(io::ErrorKind::WriteZero.into())); - } - self.out_pos += n; - } - self.out.clear(); - self.out_pos = 0; - Poll::Ready(Ok(())) - } - - /// Best effort, as `tokio_rustls` does when it reports a TLS error: push - /// the queued alert out without letting a transport problem replace the - /// TLS error the caller is about to see. - fn try_write_alert(&mut self, cx: &mut Context<'_>) { - let _ = self.poll_write_out(cx); - } - - /// Read one chunk of ciphertext into the session. `Ok(0)` is transport EOF. - fn poll_read_in(&mut self, cx: &mut Context<'_>) -> Poll> { - let mut buf = ReadBuf::new(&mut self.read_buf); - ready!(Pin::new(&mut self.io).poll_read(cx, &mut buf))?; - let n = buf.filled().len(); - if n == 0 { - self.eof = true; - } else { - let (session, bytes) = (&mut self.session, &self.read_buf[..n]); - session.receive(bytes); - self.pump(); - } - Poll::Ready(Ok(n)) - } - - fn poll_handshake(&mut self, cx: &mut Context<'_>) -> Poll> { - loop { - self.pump(); - if let Some(error) = self.failure_error() { - self.try_write_alert(cx); - return Poll::Ready(Err(error)); - } - ready!(self.poll_write_out(cx))?; - if !self.session.is_handshaking() { - return Poll::Ready(Ok(())); - } - if self.eof { - return Poll::Ready(Err(io::Error::new( - io::ErrorKind::UnexpectedEof, - "tls handshake eof", - ))); - } - ready!(self.poll_read_in(cx))?; - } - } -} - -impl AsyncRead for TlsStream { - fn poll_read( - self: Pin<&mut Self>, - cx: &mut Context<'_>, - buf: &mut ReadBuf<'_>, - ) -> Poll> { - let this = self.get_mut(); - loop { - if this.plain_pos < this.plain.len() { - let available = &this.plain[this.plain_pos..]; - let n = available.len().min(buf.remaining()); - buf.put_slice(&available[..n]); - this.plain_pos += n; - if this.plain_pos == this.plain.len() { - this.plain.clear(); - this.plain_pos = 0; - } - return Poll::Ready(Ok(())); - } - if let Some(error) = this.failure_error() { - this.try_write_alert(cx); - return Poll::Ready(Err(error)); - } - if this.session.peer_closed() { - return Poll::Ready(Ok(())); - } - if this.eof { - return Poll::Ready(Err(io::Error::new( - io::ErrorKind::UnexpectedEof, - UNEXPECTED_EOF_MESSAGE, - ))); - } - // Anything the session answered while reading (a TLS 1.3 - // KeyUpdate, say) goes out opportunistically. Neither a full - // transport nor a write error stops the read: like - // `tokio_rustls`, write failures surface from the write side. - if this.out_pos < this.out.len() { - let _ = this.poll_write_out(cx); - } - ready!(this.poll_read_in(cx))?; - } - } -} - -impl AsyncWrite for TlsStream { - fn poll_write( - self: Pin<&mut Self>, - cx: &mut Context<'_>, - buf: &[u8], - ) -> Poll> { - let this = self.get_mut(); - // Earlier records first: never let plaintext overtake ciphertext that - // is already queued, and exert backpressure while the transport is - // full. - ready!(this.poll_write_out(cx))?; - if let Some(error) = this.failure_error() { - return Poll::Ready(Err(error)); - } - if buf.is_empty() { - return Poll::Ready(Ok(0)); - } - this.session.write(buf); - this.pump(); - if let Some(error) = this.failure_error() { - this.try_write_alert(cx); - return Poll::Ready(Err(error)); - } - // The bytes are accepted once encrypted; writing them to the transport - // may complete on a later poll (flush / the next write), as with - // `tokio_rustls`, which also reports a write as done once rustls holds - // it. - if let Poll::Ready(Err(error)) = this.poll_write_out(cx) { - return Poll::Ready(Err(error)); - } - Poll::Ready(Ok(buf.len())) - } - - fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - let this = self.get_mut(); - this.pump(); - ready!(this.poll_write_out(cx))?; - Pin::new(&mut this.io).poll_flush(cx) - } - - fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - let this = self.get_mut(); - if !this.write_closed { - this.write_closed = true; - this.session.close_notify(); - this.pump(); - } - ready!(this.poll_write_out(cx))?; - match ready!(Pin::new(&mut this.io).poll_shutdown(cx)) { - Ok(()) => Poll::Ready(Ok(())), - Err(error) if error.kind() == io::ErrorKind::NotConnected => Poll::Ready(Ok(())), - Err(error) => Poll::Ready(Err(error)), - } - } -} - -#[cfg(test)] -mod tests; diff --git a/crates/perry-stdlib/src/tls_stream/tests.rs b/crates/perry-stdlib/src/tls_stream/tests.rs deleted file mode 100644 index ed4fbe509a..0000000000 --- a/crates/perry-stdlib/src/tls_stream/tests.rs +++ /dev/null @@ -1,165 +0,0 @@ -//! The `tokio_rustls` contract [`TlsStream`] keeps, over an in-memory duplex -//! pipe: real rustls records on both ends, no socket. - -use super::*; -use rustls::pki_types::{pem::PemObject, CertificateDer, PrivateKeyDer}; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; - -const TEST_CERT: &str = include_str!("../../../perry-tls-session/tests/test-cert.pem"); -const TEST_CA: &str = include_str!("../../../perry-tls-session/tests/test-ca.pem"); -const TEST_KEY: &str = include_str!("../../../perry-tls-session/tests/test-key.pem"); - -fn provider() -> Arc { - Arc::new(rustls::crypto::ring::default_provider()) -} - -fn server_config() -> Arc { - let chain: Vec> = CertificateDer::pem_slice_iter(TEST_CERT.as_bytes()) - .collect::>() - .unwrap(); - let key = PrivateKeyDer::from_pem_slice(TEST_KEY.as_bytes()).unwrap(); - Arc::new( - rustls::ServerConfig::builder_with_provider(provider()) - .with_safe_default_protocol_versions() - .unwrap() - .with_no_client_auth() - .with_single_cert(chain, key) - .unwrap(), - ) -} - -fn client_config(trust_test_ca: bool) -> Arc { - let mut roots = rustls::RootCertStore::empty(); - if trust_test_ca { - for cert in CertificateDer::pem_slice_iter(TEST_CA.as_bytes()) { - roots.add(cert.unwrap()).unwrap(); - } - } - Arc::new( - rustls::ClientConfig::builder_with_provider(provider()) - .with_safe_default_protocol_versions() - .unwrap() - .with_root_certificates(roots) - .with_no_client_auth(), - ) -} - -fn localhost() -> ServerName<'static> { - ServerName::try_from("localhost").unwrap() -} - -fn runtime() -> tokio::runtime::Runtime { - tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .unwrap() -} - -#[test] -fn round_trip_and_close_notify_is_a_clean_eof() { - runtime().block_on(async { - let (client_io, server_io) = tokio::io::duplex(1024); - let server = tokio::spawn(async move { - let mut tls = TlsStream::accept(server_io, server_config()).await.unwrap(); - assert_eq!(tls.session().server_name(), Some("localhost")); - let mut request = vec![0u8; 5]; - tls.read_exact(&mut request).await.unwrap(); - assert_eq!(&request, b"hello"); - // Larger than the duplex buffer and one TLS record: exercises - // backpressure and record splitting. - let big = vec![7u8; 100_000]; - tls.write_all(&big).await.unwrap(); - tls.flush().await.unwrap(); - // The client's close_notify reads as a clean EOF. - let mut rest = Vec::new(); - tls.read_to_end(&mut rest).await.unwrap(); - assert!(rest.is_empty()); - tls.shutdown().await.unwrap(); - }); - let mut tls = TlsStream::connect(client_io, client_config(true), localhost()) - .await - .unwrap(); - assert!(tls.session().protocol_version().is_some()); - tls.write_all(b"hello").await.unwrap(); - tls.flush().await.unwrap(); - let mut big = vec![0u8; 100_000]; - tls.read_exact(&mut big).await.unwrap(); - assert!(big.iter().all(|b| *b == 7)); - tls.shutdown().await.unwrap(); - let mut rest = Vec::new(); - tls.read_to_end(&mut rest).await.unwrap(); - assert!(rest.is_empty()); - server.await.unwrap(); - }); -} - -#[test] -fn rejected_certificate_reports_rustls_text_and_the_server_sees_the_alert() { - runtime().block_on(async { - let (client_io, server_io) = tokio::io::duplex(64 * 1024); - let server = tokio::spawn(async move { - TlsStream::accept(server_io, server_config()) - .await - .err() - .expect("the server handshake must fail") - }); - let client_error = TlsStream::connect(client_io, client_config(false), localhost()) - .await - .err() - .expect("an untrusted chain must fail"); - assert_eq!(client_error.kind(), io::ErrorKind::InvalidData); - assert_eq!( - client_error.to_string(), - "invalid peer certificate: UnknownIssuer" - ); - let server_error = server.await.unwrap(); - assert_eq!(server_error.kind(), io::ErrorKind::InvalidData); - assert!( - server_error.to_string().starts_with("received fatal alert"), - "the alert must reach the peer, not a bare EOF: {server_error}" - ); - }); -} - -#[test] -fn eof_mid_handshake_is_tls_handshake_eof() { - runtime().block_on(async { - let (client_io, server_io) = tokio::io::duplex(1024); - drop(client_io); - let error = TlsStream::accept(server_io, server_config()) - .await - .err() - .unwrap(); - assert_eq!(error.kind(), io::ErrorKind::UnexpectedEof); - assert_eq!(error.to_string(), "tls handshake eof"); - }); -} - -#[test] -fn eof_without_close_notify_is_unexpected_eof() { - runtime().block_on(async { - let (client_io, server_io) = tokio::io::duplex(64 * 1024); - let (read_tx, read_rx) = tokio::sync::oneshot::channel::<()>(); - let server = tokio::spawn(async move { - let mut tls = TlsStream::accept(server_io, server_config()).await.unwrap(); - let mut byte = [0u8; 1]; - tls.read_exact(&mut byte).await.unwrap(); - read_tx.send(()).unwrap(); - let mut rest = Vec::new(); - tls.read_to_end(&mut rest).await.err().unwrap() - }); - let mut tls = TlsStream::connect(client_io, client_config(true), localhost()) - .await - .unwrap(); - tls.write_all(b"x").await.unwrap(); - tls.flush().await.unwrap(); - // Once the server has the byte, drop the transport without - // close_notify. (Dropping earlier would make the server's post-handshake - // session tickets hit a closed pipe — as they would with tokio_rustls.) - read_rx.await.unwrap(); - drop(tls); - let error = server.await.unwrap(); - assert_eq!(error.kind(), io::ErrorKind::UnexpectedEof); - assert_eq!(error.to_string(), UNEXPECTED_EOF_MESSAGE); - }); -} diff --git a/crates/perry-stdlib/src/ws.rs b/crates/perry-stdlib/src/ws.rs deleted file mode 100644 index 981fda2c59..0000000000 --- a/crates/perry-stdlib/src/ws.rs +++ /dev/null @@ -1,1922 +0,0 @@ -//! WebSocket module (ws compatible) -//! -//! Native implementation of the 'ws' npm package on `turnloop-websocket`'s -//! sans-I/O protocol core (see [`codec`]), driven over the tokio streams this -//! module already owned. Provides WebSocket client and server functionality. -//! -//! This is the BUNDLED `ws` binding; `perry-ext-ws` is the other one, and the -//! two are deliberately independent implementations of the same surface — -//! perry-stdlib must not depend on the crate it is the alternative to. -//! -//! One thing this module does NOT do, and must not be "improved" into doing: -//! a binary frame reaches JS as `String::from_utf8_lossy`, because -//! `PendingWsEvent::Message` carries a `String`. Fixing that is an event-queue -//! change, not a codec change, and it is not this swap's business. - -#[cfg(not(target_os = "ios"))] -use perry_runtime::set::{js_set_add, js_set_alloc, js_set_delete, SetHeader}; -use perry_runtime::{ - js_closure_call0, js_closure_call1, js_closure_call2, js_string_from_bytes, ClosureHeader, - JSValue, StringHeader, -}; -use std::collections::HashMap; -use std::sync::Mutex; -#[cfg(not(target_os = "ios"))] -use tokio::io::{AsyncReadExt, AsyncWriteExt}; -#[cfg(not(target_os = "ios"))] -use tokio::sync::mpsc; - -/// The protocol state machine, with no I/O of its own. Read its header before -/// touching the receive loop: `Received` has two zero cases, and a host that -/// is wrong about either stalls or silently drops a message. -#[cfg(not(target_os = "ios"))] -mod codec; - -#[cfg(not(target_os = "ios"))] -use crate::common::async_bridge::{queue_deferred_resolution, queue_promise_resolution, spawn}; -use crate::common::string_from_header; -use crate::common::{for_each_handle_mut_of, get_handle_mut, register_handle, Handle}; - -/// #6117 — rustls panics resolving the process-level CryptoProvider on the -/// first `wss://` handshake when both `ring` and `aws-lc-rs` end up -/// feature-unified into the final link (perry-ext-http brings ring; -/// net/tls bring aws-lc-rs). Install one explicitly before connecting. -/// Idempotent — `install_default` errors (ignored) if a provider is already -/// set. Mirrors `net::mod` / `tls` (#4971) and `perry-ext-net`. -#[cfg(not(target_os = "ios"))] -fn ensure_tls_crypto_provider() { - let _ = rustls::crypto::ring::default_provider().install_default(); -} - -fn ws_file_log(msg: &str) { - use std::io::Write; - if let Ok(mut f) = std::fs::OpenOptions::new() - .create(true) - .append(true) - .open("/tmp/hone-ws-macos.log") - { - let _ = writeln!(f, "{}", msg); - } -} - -// On iOS, delegate to native NSURLSessionWebSocketTask implementation (provided by perry-ui-ios) -#[cfg(target_os = "ios")] -extern "C" { - fn perry_native_ws_connect(url_ptr: *const u8) -> f64; - fn perry_native_ws_is_open(handle: f64) -> f64; - fn perry_native_ws_send(handle: f64, msg_ptr: *const u8); - fn perry_native_ws_receive(handle: f64) -> f64; - fn perry_native_ws_message_count(handle: f64) -> f64; - fn perry_native_ws_close(handle: f64); -} - -// WebSocket handle storage -#[cfg(not(target_os = "ios"))] - -static WS_CONNECTIONS: std::sync::LazyLock>> = - std::sync::LazyLock::new(|| Mutex::new(HashMap::new())); -/// Map from client ws_id to parent server handle (for server-connected clients) -static WS_CLIENT_PARENT_SERVER: std::sync::LazyLock>> = - std::sync::LazyLock::new(|| Mutex::new(HashMap::new())); - -static NEXT_WS_ID: std::sync::LazyLock> = std::sync::LazyLock::new(|| Mutex::new(1)); -/// Per-client event listeners (for .on('message', cb) etc.) -static WS_CLIENT_LISTENERS: std::sync::LazyLock>> = - std::sync::LazyLock::new(|| Mutex::new(HashMap::new())); -/// Pending WebSocket events to be processed on the main thread -static WS_PENDING_EVENTS: std::sync::LazyLock>> = - std::sync::LazyLock::new(|| Mutex::new(Vec::new())); - -#[cfg(not(target_os = "ios"))] -thread_local! { - // The mutable-root scanner registry is thread-local, so this latch must be too. - static WS_GC_REGISTERED: std::cell::Cell = const { std::cell::Cell::new(false) }; -} - -/// Register the ws GC root scanner once on each thread. Mirrors -/// `net::ensure_gc_scanner_registered` -/// (issue #35) — user closures passed to `.on(event, cb)` are stored in -/// WS_CLIENT_LISTENERS (for client sockets) or inside a WsServerHandle -/// (for servers); neither is visible to the GC mark phase without this -/// scanner, so a malloc-triggered sweep between registration and -/// dispatch would free the closure and the next event would call freed -/// memory. -#[cfg(not(target_os = "ios"))] -fn ensure_gc_scanner_registered() { - WS_GC_REGISTERED.with(|registered| { - if registered.get() { - return; - } - perry_runtime::gc::gc_register_mutable_root_scanner_named("stdlib:ws", scan_ws_roots_mut); - registered.set(true); - }); -} - -/// GC root scanner for WebSocket event listener closures. Covers both -/// the global `WS_CLIENT_LISTENERS` map (for `WebSocket` clients) and -/// every `WsServerHandle` currently in the handle registry (for -/// `WebSocketServer` instances). -#[cfg(not(target_os = "ios"))] -#[allow(dead_code)] -fn scan_ws_roots(mark: &mut dyn FnMut(f64)) { - let mut visitor = perry_runtime::gc::RuntimeRootVisitor::for_copy(mark); - scan_ws_roots_mut(&mut visitor); -} - -#[cfg(not(target_os = "ios"))] -fn scan_ws_roots_mut(visitor: &mut perry_runtime::gc::RuntimeRootVisitor<'_>) { - 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() { - for cb in cb_vec.iter_mut() { - visitor.visit_i64_slot(cb); - } - } - } - } - - for_each_handle_mut_of::(|server| { - visitor.visit_nanbox_u64_slot(&mut server.clients_bits); - for cb_vec in server.listeners.values_mut() { - for cb in cb_vec.iter_mut() { - visitor.visit_i64_slot(cb); - } - } - }); -} - -/// Number of active WS servers — keeps the event loop alive. -static WS_ACTIVE_SERVERS: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(0); - -#[cfg(not(target_os = "ios"))] -struct WsConnection { - sender: mpsc::UnboundedSender, - messages: Vec, - is_open: bool, - /// #6117 — `close()` was called but the close handshake hasn't finished: - /// `readyState` reports CLOSING (2). - is_closing: bool, - /// #6117 — the connection terminated (close event, IO error, or connect - /// failure): `readyState` reports CLOSED (3). Distinguishes a dead entry - /// from a pre-open one (CONNECTING, 0) — both have `is_open == false`. - is_closed: bool, -} - -#[cfg(not(target_os = "ios"))] -enum WsCommand { - Send(String), - Close, -} - -/// Per-client event listeners -struct WsClientListeners { - listeners: HashMap>, -} - -/// WebSocketServer handle -#[cfg(not(target_os = "ios"))] -pub struct WsServerHandle { - /// Event name -> list of closure pointers (stored as i64 for Send + Sync) - pub listeners: HashMap>, - pub port: u16, - pub is_listening: bool, - /// Track connected client IDs for cleanup - pub client_ids: Vec, - /// Persistent JS `Set` exposed through `WebSocketServer.clients`. - /// NaN-boxed so the mutable-root scanner can rewrite a moved header. - pub clients_bits: u64, - /// Shutdown signal sender - pub shutdown_tx: Option>, -} - -/// Pending WebSocket event to be dispatched on the main thread -enum PendingWsEvent { - /// Server received a new connection: (server_handle, client_ws_id) - Connection(Handle, usize), - /// Client received a message: (client_ws_id, message) - Message(usize, String), - /// Client connection closed: (client_ws_id, code, reason) - Close(usize, u16, String), - /// Error on client: (client_ws_id, error_message) - Error(usize, String), - /// Server error: (server_handle, error_message) - ServerError(Handle, String), - /// Server started listening: (server_handle) - Listening(Handle), -} - -/// Push a WS event and wake the main-thread pump (issue #84). -/// -/// Every producer in this file runs inside a tokio-spawned task or -/// upgrade handler, so direct `.push()` without a notify would leave the -/// event invisible to the main thread until the next `js_wait_for_event` -/// timeout (old code: 10 ms). Wrapping here covers all 18 call sites at -/// once. -#[cfg(not(target_os = "ios"))] -fn push_ws_event(ev: PendingWsEvent) { - WS_PENDING_EVENTS.lock().unwrap().push(ev); - perry_runtime::event_pump::js_notify_main_thread(); -} - -#[cfg(not(target_os = "ios"))] -fn mark_ws_connection_closed(ws_id: usize) -> bool { - WS_CONNECTIONS - .lock() - .unwrap() - .get_mut(&ws_id) - .map(|conn| { - let was_open = conn.is_open; - conn.is_open = false; - conn.is_closed = true; - was_open - }) - .unwrap_or(false) -} - -#[cfg(not(target_os = "ios"))] -fn cleanup_ws_client(ws_id: usize) { - WS_CONNECTIONS.lock().unwrap().remove(&ws_id); - WS_CLIENT_LISTENERS.lock().unwrap().remove(&ws_id); - - let parent = WS_CLIENT_PARENT_SERVER.lock().unwrap().remove(&ws_id); - if let Some(server_handle) = parent { - let clients_bits = get_handle_mut::(server_handle).map(|server| { - server.client_ids.retain(|client_id| *client_id != ws_id); - server.clients_bits - }); - if let Some(clients_bits) = clients_bits { - let clients = - JSValue::from_bits(clients_bits).as_pointer::() as *mut SetHeader; - js_set_delete(clients, ws_id as f64); - } - } -} - -#[cfg(not(target_os = "ios"))] -fn new_server_clients_set() -> u64 { - JSValue::pointer(js_set_alloc(4) as *const u8).bits() -} - -#[cfg(not(target_os = "ios"))] -fn track_server_client(server_handle: Handle, ws_id: usize) { - let clients_bits = if let Some(server) = get_handle_mut::(server_handle) { - if !server.client_ids.contains(&ws_id) { - server.client_ids.push(ws_id); - } - server.clients_bits - } else { - return; - }; - let clients = JSValue::from_bits(clients_bits).as_pointer::() as *mut SetHeader; - let updated = js_set_add(clients, ws_id as f64); - let updated_bits = JSValue::pointer(updated as *const u8).bits(); - if updated_bits != clients_bits { - if let Some(server) = get_handle_mut::(server_handle) { - server.clients_bits = updated_bits; - } - } -} - -// ============================================================================ -// The tokio transport: [`codec::Codec`] driven over a split byte stream -// ============================================================================ - -/// Anything this transport can carry. `tokio::io::split` works for any -/// `AsyncRead + AsyncWrite`, which is what lets one loop serve a plain TCP -/// socket and a TLS one without naming either type at the call site. This is -/// what replaced `tokio_tungstenite::WebSocketStream::split()` plus a -/// `futures_util` `Sink`/`Stream` pair. -#[cfg(not(target_os = "ios"))] -trait WsTransport: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static {} -#[cfg(not(target_os = "ios"))] -impl WsTransport for T where - T: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static -{ -} - -/// A handshaken connection, plus whatever frame bytes arrived in the same read -/// as the upgrade head. Dropping the leftover loses the peer's first message. -#[cfg(not(target_os = "ios"))] -struct WsConnected { - stream: Box, - codec: codec::Codec, - leftover: Vec, -} - -/// Which of the three call sites a driver task is serving. The loop is shared; -/// these variants carry the exact behavioural differences the three inline -/// `split()` loops had, so a codec swap does not become a redesign. -#[cfg(not(target_os = "ios"))] -#[derive(Clone, Copy, PartialEq, Eq)] -enum IoFlavor { - /// `js_ws_connect`: logs under `[WS-io]`, buffers a message into - /// `conn.messages` when nothing is listening, ignores binary frames. - ClientLogged, - /// `js_ws_connect_start`: the same routing, with no logging. - ClientQuiet, - /// A server-accepted client: logs under `[WS-srv-io]`, always pushes the - /// message event, and reports a binary frame as lossy UTF-8 text. - ServerClient, -} - -/// One read's worth of wire bytes. Matches tungstenite's own default read -/// buffer, so a large message costs the same number of syscalls it used to. -#[cfg(not(target_os = "ios"))] -const WS_READ_CHUNK: usize = 128 * 1024; - -#[cfg(not(target_os = "ios"))] -struct WsTarget { - secure: bool, - host: String, - port: u16, - /// What goes in the `Host` header. `ws` omits a default port, like a browser. - authority: String, - path: String, -} - -#[cfg(not(target_os = "ios"))] -fn parse_ws_url(url: &str) -> Result { - let parsed = url::Url::parse(url).map_err(|e| format!("Invalid URL: {}", e))?; - let secure = match parsed.scheme() { - "ws" | "http" => false, - "wss" | "https" => true, - other => { - return Err(format!( - "The URL's protocol must be one of \"ws:\", \"wss:\", \"http:\", or \"https:\" (got \"{}:\")", - other - )) - } - }; - let host = parsed - .host_str() - .ok_or_else(|| "Invalid URL: no host".to_string())? - .to_string(); - let port = parsed - .port_or_known_default() - .unwrap_or(if secure { 443 } else { 80 }); - let authority = match parsed.port() { - Some(explicit) => format!("{}:{}", host, explicit), - None => host.clone(), - }; - let mut path = parsed.path().to_string(); - if path.is_empty() { - path.push('/'); - } - if let Some(query) = parsed.query() { - path.push('?'); - path.push_str(query); - } - Ok(WsTarget { - secure, - host, - port, - authority, - path, - }) -} - -/// The outbound `wss://` client config. -/// -/// `net::build_tls_connector` is private to `net` and gated on the `tls` -/// feature (which implies `bundled-net`), so a `bundled-ws` build builds its -/// own here from `rustls` + `rustls-native-certs`; the shape mirrors -/// `net::build_tls_connector`'s verifying path. The handshake runs through -/// `crate::tls_stream::TlsStream` — perry-tls-session's sans-I/O rustls -/// session over the tokio socket (turnloop P8 group H), no tokio-rustls. -/// -/// Cached: loading the system trust store per connect would be a syscall storm -/// on a reconnecting client. -#[cfg(not(target_os = "ios"))] -fn ws_tls_connector() -> Result, String> { - static CONNECTOR: std::sync::OnceLock, String>> = - std::sync::OnceLock::new(); - CONNECTOR - .get_or_init(|| { - let mut roots = rustls::RootCertStore::empty(); - // rustls-native-certs 0.8 reports per-cert failures alongside the - // certs it did load; accept the partial set, exactly as `net` does. - let native = rustls_native_certs::load_native_certs(); - for cert in native.certs { - let _ = roots.add(cert); - } - if roots.is_empty() { - return Err("no trusted root certificates available for wss://".to_string()); - } - let config = rustls::ClientConfig::builder_with_provider( - rustls::crypto::ring::default_provider().into(), - ) - .with_safe_default_protocol_versions() - .map_err(|e| format!("tls protocol versions: {}", e))? - .with_root_certificates(roots) - .with_no_client_auth(); - Ok(std::sync::Arc::new(config)) - }) - .clone() -} - -/// RFC 6455 §4.1's nonce must be unpredictable, not merely unique: a guessable -/// key lets an attacker who can make this client issue a request convince a -/// cache that the `101` belongs to an ordinary GET. Both connect entry points -/// call `ensure_tls_crypto_provider` first, so a default provider is installed -/// by the time this runs. -#[cfg(not(target_os = "ios"))] -fn ws_nonce() -> Result<[u8; 16], String> { - let provider = rustls::crypto::CryptoProvider::get_default() - .cloned() - .unwrap_or_else(|| std::sync::Arc::new(rustls::crypto::ring::default_provider())); - let mut nonce = [0u8; 16]; - provider - .secure_random - .fill(&mut nonce) - .map_err(|_| "no secure random source for the WebSocket key".to_string())?; - Ok(nonce) -} - -/// Open a connection and run the client half of the opening handshake. -/// -/// Replaces `tokio_tungstenite::connect_async`, which did four things in one -/// call: parse the URL, open the TCP connection, negotiate TLS for `wss://`, -/// and run the handshake. -#[cfg(not(target_os = "ios"))] -async fn ws_client_connect(url: &str) -> Result { - let target = parse_ws_url(url)?; - let tcp = tokio::net::TcpStream::connect((target.host.as_str(), target.port)) - .await - .map_err(|e| format!("{}", e))?; - // Node's `ws` sets TCP_NODELAY on its sockets; a handshake sitting in - // Nagle's queue would add a round trip to every connect. - let _ = tcp.set_nodelay(true); - let mut stream: Box = if target.secure { - let connector = ws_tls_connector()?; - let server_name = rustls::pki_types::ServerName::try_from(target.host.clone()) - .map_err(|_| format!("invalid TLS server name: {}", target.host))?; - Box::new( - crate::tls_stream::TlsStream::connect(tcp, connector, server_name) - .await - .map_err(|e| format!("TLS handshake failed: {}", e))?, - ) - } else { - Box::new(tcp) - }; - - let (handshake, head) = turnloop_websocket::ClientHandshake::new( - &target.authority, - &target.path, - ws_nonce()?, - Vec::new(), - ) - .map_err(|e| format!("{}", e))?; - stream - .write_all(&codec::encode_head(&head)?) - .await - .map_err(|e| format!("{}", e))?; - - let mut reader = codec::HeadReader::new(codec::Mode::Response); - let mut buffer = vec![0u8; 16 * 1024]; - loop { - let n = stream - .read(&mut buffer) - .await - .map_err(|e| format!("{}", e))?; - if n == 0 { - return Err("socket hang up before the upgrade completed".to_string()); - } - if let Some(response) = reader.receive(&buffer[..n])? { - handshake - .verify(&response) - .map_err(|e| format!("Unexpected server response: {} ({})", response.status, e))?; - // Bytes that followed the `101` in the same read are already frame - // data; dropping them loses the peer's first message. - return Ok(WsConnected { - stream, - codec: codec::Codec::new(codec::Role::Client), - leftover: reader.into_leftover(), - }); - } - } -} - -/// Read the upgrade request head and answer it with the `101`. -/// Replaces `tokio_tungstenite::accept_async`. -#[cfg(not(target_os = "ios"))] -async fn ws_server_accept(mut tcp: tokio::net::TcpStream) -> Result { - let mut reader = codec::HeadReader::new(codec::Mode::Request); - let mut buffer = vec![0u8; 16 * 1024]; - loop { - let n = tcp.read(&mut buffer).await.map_err(|e| format!("{}", e))?; - if n == 0 { - return Err("socket hang up before the upgrade request completed".to_string()); - } - if let Some(request) = reader.receive(&buffer[..n])? { - let (head, _protocol) = - turnloop_websocket::accept(&request, &[]).map_err(|e| format!("{}", e))?; - tcp.write_all(&codec::encode_head(&head)?) - .await - .map_err(|e| format!("{}", e))?; - return Ok(WsConnected { - stream: Box::new(tcp), - codec: codec::Codec::new(codec::Role::Server), - leftover: reader.into_leftover(), - }); - } - } -} - -/// Put whatever the codec queued on the wire. Nothing else will: the automatic -/// pong for a ping and the answering close are only encoded by a flush. -#[cfg(not(target_os = "ios"))] -async fn ws_flush(proto: &mut codec::Codec, writer: &mut W) -> Result<(), String> -where - W: tokio::io::AsyncWrite + Unpin, -{ - let out = proto.take_output(); - if out.is_empty() { - return Ok(()); - } - writer.write_all(&out).await.map_err(|e| format!("{}", e)) -} - -/// Route a decoded text payload the way the originating call site did. -#[cfg(not(target_os = "ios"))] -fn ws_deliver_message(ws_id: usize, text: String, flavor: IoFlavor) { - if flavor == IoFlavor::ServerClient { - push_ws_event(PendingWsEvent::Message(ws_id, text)); - return; - } - let has_listeners = WS_CLIENT_LISTENERS - .lock() - .unwrap() - .get(&ws_id) - .map(|l| { - l.listeners - .get("message") - .map(|v| !v.is_empty()) - .unwrap_or(false) - }) - .unwrap_or(false); - if has_listeners { - push_ws_event(PendingWsEvent::Message(ws_id, text)); - } else if let Some(conn) = WS_CONNECTIONS.lock().unwrap().get_mut(&ws_id) { - conn.messages.push(text); - } -} - -/// Feed wire bytes through the codec, emit what they decoded, and flush what -/// the codec wants to answer. `false` means the connection is finished. -#[cfg(not(target_os = "ios"))] -async fn ws_feed( - ws_id: usize, - proto: &mut codec::Codec, - bytes: &[u8], - writer: &mut W, - flavor: IoFlavor, -) -> bool -where - W: tokio::io::AsyncWrite + Unpin, -{ - let events = match proto.receive(bytes) { - Ok(events) => events, - Err(e) => { - mark_ws_connection_closed(ws_id); - push_ws_event(PendingWsEvent::Error(ws_id, format!("{}", e))); - push_ws_event(PendingWsEvent::Close(ws_id, 1006, String::new())); - // Still flush: the codec may have queued a close frame naming the - // protocol error, which the old Sink also put on the wire. - let _ = ws_flush(proto, writer).await; - return false; - } - }; - let mut alive = true; - for event in events { - match event { - codec::Incoming::Text(text) => { - if flavor == IoFlavor::ServerClient { - ws_file_log(&format!("[WS-srv-io] id={} recv len={}", ws_id, text.len())); - } - ws_deliver_message(ws_id, text, flavor); - } - // The event queue carries `String`, so a server-side binary frame - // is still reported as lossy UTF-8 and a client-side one is still - // dropped. See this module's header note. - codec::Incoming::Binary(data) => { - if flavor == IoFlavor::ServerClient { - ws_deliver_message(ws_id, String::from_utf8_lossy(&data).to_string(), flavor); - } - } - // A ping is answered inside `codec::Codec::receive`'s flush; neither - // ping nor pong reaches JS, exactly as the old `Some(Ok(_))` arm. - codec::Incoming::Ping(_) | codec::Incoming::Pong(_) => {} - codec::Incoming::Close(frame) => { - let (code, reason) = frame.unwrap_or((1000u16, String::new())); - mark_ws_connection_closed(ws_id); - push_ws_event(PendingWsEvent::Close(ws_id, code, reason)); - alive = false; - break; - } - } - } - if let Err(e) = ws_flush(proto, writer).await { - if mark_ws_connection_closed(ws_id) { - push_ws_event(PendingWsEvent::Error(ws_id, e)); - push_ws_event(PendingWsEvent::Close(ws_id, 1006, String::new())); - } - return false; - } - alive -} - -/// Apply one command from the JS side. `false` means the loop is done. -#[cfg(not(target_os = "ios"))] -async fn ws_apply( - ws_id: usize, - proto: &mut codec::Codec, - command: Option, - writer: &mut W, - flavor: IoFlavor, -) -> bool -where - W: tokio::io::AsyncWrite + Unpin, -{ - match command { - Some(WsCommand::Send(msg)) => { - match flavor { - IoFlavor::ClientLogged => { - ws_file_log(&format!("[WS-io] sending len={}", msg.len())) - } - IoFlavor::ServerClient => ws_file_log(&format!( - "[WS-srv-io] id={} sending len={}", - ws_id, - msg.len() - )), - IoFlavor::ClientQuiet => {} - } - let failure = match proto.send(codec::Message::text(msg)) { - Err(e) => Some(format!("{}", e)), - Ok(()) => ws_flush(proto, writer).await.err(), - }; - if let Some(e) = failure { - match flavor { - IoFlavor::ClientLogged => ws_file_log(&format!("[WS-io] send ERR: {}", e)), - IoFlavor::ServerClient => { - ws_file_log(&format!("[WS-srv-io] id={} send ERR: {}", ws_id, e)) - } - IoFlavor::ClientQuiet => {} - } - if mark_ws_connection_closed(ws_id) { - push_ws_event(PendingWsEvent::Error(ws_id, e)); - push_ws_event(PendingWsEvent::Close(ws_id, 1006, String::new())); - } - return false; - } - match flavor { - IoFlavor::ClientLogged => ws_file_log("[WS-io] send OK"), - IoFlavor::ServerClient => ws_file_log(&format!("[WS-srv-io] id={} send OK", ws_id)), - IoFlavor::ClientQuiet => {} - } - true - } - Some(WsCommand::Close) => { - if flavor == IoFlavor::ServerClient { - ws_file_log(&format!("[WS-srv-io] id={} closing", ws_id)); - } - // The old path sent `Message::Close(None)` and did NOT wait for the - // peer's answering close, so neither does this. - let _ = proto.close(None, ""); - let _ = ws_flush(proto, writer).await; - if mark_ws_connection_closed(ws_id) { - push_ws_event(PendingWsEvent::Close(ws_id, 1000, String::new())); - } - false - } - // Every sender dropped: the JS object is unreachable. - None => { - if mark_ws_connection_closed(ws_id) { - push_ws_event(PendingWsEvent::Close(ws_id, 1000, String::new())); - } - false - } - } -} - -/// Drive one connection until it closes. One task still handles both -/// directions; the stream is split by `tokio::io::split` instead of by -/// `WebSocketStream::split()`, and the framing is [`codec::Codec`]'s. -#[cfg(not(target_os = "ios"))] -async fn run_ws_io( - ws_id: usize, - connected: WsConnected, - mut rx: mpsc::UnboundedReceiver, - flavor: IoFlavor, -) { - let WsConnected { - stream, - codec: mut proto, - leftover, - } = connected; - let (mut reader, mut writer) = tokio::io::split(stream); - let mut buffer = vec![0u8; WS_READ_CHUNK]; - - // The leftover has to go through the codec before the first read, or a - // message that arrived with the `101` is delivered out of order. - let mut running = - leftover.is_empty() || ws_feed(ws_id, &mut proto, &leftover, &mut writer, flavor).await; - - while running && !proto.is_terminal() { - tokio::select! { - read = reader.read(&mut buffer) => match read { - Ok(0) => { - // tungstenite surfaced a bare FIN as - // `Protocol(ResetWithoutClosingHandshake)`, so the old loop - // took its error arm; a FIN after the closing handshake was - // the quiet stream-ended arm. - if proto.is_terminal() { - if mark_ws_connection_closed(ws_id) { - push_ws_event(PendingWsEvent::Close(ws_id, 1000, String::new())); - } - } else { - mark_ws_connection_closed(ws_id); - push_ws_event(PendingWsEvent::Error( - ws_id, - "WebSocket protocol error: Connection reset without closing handshake" - .to_string(), - )); - push_ws_event(PendingWsEvent::Close(ws_id, 1006, String::new())); - } - running = false; - } - Ok(n) => { - running = ws_feed(ws_id, &mut proto, &buffer[..n], &mut writer, flavor).await; - } - Err(e) => { - mark_ws_connection_closed(ws_id); - push_ws_event(PendingWsEvent::Error(ws_id, format!("{}", e))); - push_ws_event(PendingWsEvent::Close(ws_id, 1006, String::new())); - running = false; - } - }, - command = rx.recv() => { - running = ws_apply(ws_id, &mut proto, command, &mut writer, flavor).await; - } - } - } - - mark_ws_connection_closed(ws_id); - if flavor == IoFlavor::ClientLogged { - ws_file_log(&format!("[WS-io] task ended for id={}", ws_id)); - } -} - -/// Create a new WebSocket connection -/// new WebSocket(url) -> Promise -#[cfg(not(target_os = "ios"))] -#[no_mangle] -pub unsafe extern "C" fn js_ws_connect( - url_ptr: *const StringHeader, -) -> *mut perry_runtime::Promise { - ensure_gc_scanner_registered(); - ensure_tls_crypto_provider(); - #[cfg(target_os = "android")] - { - extern "C" { - fn __android_log_print(prio: i32, tag: *const u8, fmt: *const u8, ...) -> i32; - } - __android_log_print(3, b"PerryWS\0".as_ptr(), b"js_ws_connect called\0".as_ptr()); - } - let promise = perry_runtime::js_promise_new_cross_thread(); - let promise_ptr = promise as usize; - - let url = match string_from_header(url_ptr) { - Some(u) => u, - None => { - let err_msg = "Invalid URL"; - let err_str = js_string_from_bytes(err_msg.as_ptr(), err_msg.len() as u32); - let err_bits = JSValue::pointer(err_str as *const u8).bits(); - queue_promise_resolution(promise_ptr, false, err_bits); - return promise; - } - }; - - #[cfg(target_os = "android")] - { - extern "C" { - fn __android_log_print(prio: i32, tag: *const u8, fmt: *const u8, ...) -> i32; - } - __android_log_print( - 3, - b"PerryWS\0".as_ptr(), - b"ws_connect: spawning async for URL\0".as_ptr(), - ); - } - - let url_for_log = url.clone(); - spawn(async move { - #[cfg(target_os = "android")] - { - extern "C" { - fn __android_log_print(prio: i32, tag: *const u8, fmt: *const u8, ...) -> i32; - } - unsafe { - __android_log_print( - 3, - b"PerryWS\0".as_ptr(), - b"ws_connect: connect starting\0".as_ptr(), - ); - } - } - match ws_client_connect(&url_for_log).await { - Ok(connected) => { - #[cfg(target_os = "android")] - { - extern "C" { - fn __android_log_print( - prio: i32, - tag: *const u8, - fmt: *const u8, - ... - ) -> i32; - } - unsafe { - __android_log_print( - 3, - b"PerryWS\0".as_ptr(), - b"ws_connect: SUCCESS connected\0".as_ptr(), - ); - } - } - // Create command channel - let (tx, rx) = mpsc::unbounded_channel::(); - - // Allocate connection ID - let mut id_guard = NEXT_WS_ID.lock().unwrap(); - let ws_id = *id_guard; - *id_guard += 1; - drop(id_guard); - - // Store connection - WS_CONNECTIONS.lock().unwrap().insert( - ws_id, - WsConnection { - sender: tx, - messages: Vec::new(), - is_open: true, - is_closing: false, - is_closed: false, - }, - ); - - // Initialize client listeners - WS_CLIENT_LISTENERS.lock().unwrap().insert( - ws_id, - WsClientListeners { - listeners: HashMap::new(), - }, - ); - - // A single task handles both read and write over one split stream. - let ws_id_io = ws_id; - tokio::spawn(async move { - ws_file_log(&format!("[WS-io] started for id={}", ws_id_io)); - run_ws_io(ws_id_io, connected, rx, IoFlavor::ClientLogged).await; - }); - - // Return WebSocket handle - let result_bits = (ws_id as f64).to_bits(); - queue_promise_resolution(promise_ptr, true, result_bits); - } - Err(e) => { - #[cfg(target_os = "android")] - { - extern "C" { - fn __android_log_print( - prio: i32, - tag: *const u8, - fmt: *const u8, - ... - ) -> i32; - } - let msg = format!("ws_connect: FAILED: {}\0", e); - unsafe { - __android_log_print( - 6, - b"PerryWS\0".as_ptr(), - b"%s\0".as_ptr(), - msg.as_ptr(), - ); - } - } - let err_msg = format!("WebSocket connection error: {}", e); - let err_str = js_string_from_bytes(err_msg.as_ptr(), err_msg.len() as u32); - let err_bits = JSValue::pointer(err_str as *const u8).bits(); - queue_promise_resolution(promise_ptr, false, err_bits); - } - } - }); - - promise -} - -/// Create a new WebSocket connection (synchronous — returns handle immediately). -/// Connection happens in background. isOpen() returns 0 until connected. -/// connectStart(url) -> handle (number) -/// Accepts f64 NaN-boxed string (extracts pointer internally). -#[cfg(not(target_os = "ios"))] -#[no_mangle] -pub unsafe extern "C" fn js_ws_connect_start(url_nanboxed: f64) -> f64 { - ensure_gc_scanner_registered(); - ensure_tls_crypto_provider(); - #[cfg(target_os = "android")] - { - extern "C" { - fn __android_log_print(prio: i32, tag: *const u8, fmt: *const u8, ...) -> i32; - } - __android_log_print( - 3, - b"PerryWS\0".as_ptr(), - b"js_ws_connect_start called\0".as_ptr(), - ); - } - // Extract string pointer from NaN-boxed value - let url_ptr = perry_runtime::js_get_string_pointer_unified(url_nanboxed) as *const StringHeader; - let url = match string_from_header(url_ptr) { - Some(u) => u, - None => return 0.0, - }; - - // Allocate ws_id immediately (before async connection) - let mut id_guard = NEXT_WS_ID.lock().unwrap(); - let ws_id = *id_guard; - *id_guard += 1; - drop(id_guard); - - // Create command channel - let (tx, rx) = mpsc::unbounded_channel::(); - - // Store connection (initially NOT open) - WS_CONNECTIONS.lock().unwrap().insert( - ws_id, - WsConnection { - sender: tx, - messages: Vec::new(), - is_open: false, - is_closing: false, - is_closed: false, - }, - ); - - // Initialize client listeners - WS_CLIENT_LISTENERS.lock().unwrap().insert( - ws_id, - WsClientListeners { - listeners: HashMap::new(), - }, - ); - - // Connect in background - spawn(async move { - match ws_client_connect(&url).await { - Ok(connected) => { - // Mark as open - if let Some(conn) = WS_CONNECTIONS.lock().unwrap().get_mut(&ws_id) { - conn.is_open = true; - } - - // A single task handles both read and write over one split stream. - let ws_id_io = ws_id; - tokio::spawn(async move { - run_ws_io(ws_id_io, connected, rx, IoFlavor::ClientQuiet).await; - }); - } - Err(e) => { - // #6117 — readyState must report CLOSED (3), not - // CONNECTING (0), once the connect has failed. - mark_ws_connection_closed(ws_id); - push_ws_event(PendingWsEvent::Error( - ws_id, - format!("WebSocket connection error: {}", e), - )); - push_ws_event(PendingWsEvent::Close(ws_id, 1006, String::new())); - } - } - }); - - ws_id as f64 -} - -/// iOS: delegate to native NSURLSessionWebSocketTask -#[cfg(target_os = "ios")] -#[no_mangle] -pub unsafe extern "C" fn js_ws_connect_start(url_nanboxed: f64) -> f64 { - let url_ptr = perry_runtime::js_get_string_pointer_unified(url_nanboxed) as *const u8; - perry_native_ws_connect(url_ptr) -} - -/// iOS: delegate to native -#[cfg(target_os = "ios")] -#[no_mangle] -pub unsafe extern "C" fn js_ws_connect( - url_ptr: *const StringHeader, -) -> *mut perry_runtime::Promise { - let promise = perry_runtime::js_promise_new_cross_thread(); - let handle = perry_native_ws_connect(url_ptr as *const u8); - let result_bits = handle.to_bits(); - // Resolve immediately with the handle (connection happens async in native) - crate::common::async_bridge::queue_promise_resolution(promise as usize, true, result_bits); - promise -} - -/// Send a message through the WebSocket -/// ws.send(message) -> void -#[cfg(not(target_os = "ios"))] -#[no_mangle] -pub unsafe extern "C" fn js_ws_send(handle: i64, message_ptr: *const StringHeader) { - let ws_id = handle as usize; - let message = match string_from_header(message_ptr) { - Some(m) => { - ws_file_log(&format!("[WS-send] id={} len={}", ws_id, m.len())); - m - } - None => { - ws_file_log(&format!("[WS-send] id={} string_from_header=None", ws_id)); - return; - } - }; - - let guard = WS_CONNECTIONS.lock().unwrap(); - if let Some(conn) = guard.get(&ws_id) { - match conn.sender.send(WsCommand::Send(message)) { - Ok(()) => ws_file_log("[WS-send] channel send OK"), - Err(e) => ws_file_log(&format!("[WS-send] channel send ERR: {}", e)), - } - } else { - ws_file_log(&format!("[WS-send] no connection for id={}", ws_id)); - } -} - -#[cfg(target_os = "ios")] -#[no_mangle] -pub unsafe extern "C" fn js_ws_send(handle: i64, message_ptr: *const StringHeader) { - perry_native_ws_send(handle as f64, message_ptr as *const u8); -} - -/// Close the WebSocket connection or server -/// ws.close() / wss.close() -> void -/// Checks if handle is a server first, then falls back to client close -#[cfg(not(target_os = "ios"))] -#[no_mangle] -pub unsafe extern "C" fn js_ws_close(handle: i64) { - // Check if this is a server handle - if get_handle_mut::(handle).is_some() { - js_ws_server_close(handle); - return; - } - - // Otherwise close client connection - let ws_id = handle as usize; - let mut guard = WS_CONNECTIONS.lock().unwrap(); - if let Some(conn) = guard.get_mut(&ws_id) { - // #6117 — readyState reports CLOSING (2) until the close completes. - conn.is_closing = true; - let _ = conn.sender.send(WsCommand::Close); - } -} - -#[cfg(target_os = "ios")] -#[no_mangle] -pub unsafe extern "C" fn js_ws_close(handle: i64) { - unsafe { - perry_native_ws_close(handle as f64); - } -} - -/// Server-side bridges: `sendToClient(handle, msg)` / `closeClient(handle)`. -/// `ws.on('connection', cb)` delivers the client handle as a plain f64 -/// number (see `PendingWsEvent::Connection` dispatch — `client_ws_id as f64`, -/// not NaN-boxed), so the codegen passes f64 here rather than the i64 form -/// `js_ws_send`/`js_ws_close` use for receiver-style `ws.send(...)` calls. -#[no_mangle] -pub unsafe extern "C" fn js_ws_send_to_client(handle_f64: f64, message_ptr: *const StringHeader) { - js_ws_send(handle_f64 as i64, message_ptr); -} - -#[no_mangle] -pub unsafe extern "C" fn js_ws_close_client(handle_f64: f64) { - js_ws_close(handle_f64 as i64); -} - -/// Check if WebSocket is open -/// ws.readyState === WebSocket.OPEN -#[cfg(not(target_os = "ios"))] -#[no_mangle] -pub extern "C" fn js_ws_is_open(handle: i64) -> f64 { - let ws_id = handle as usize; - - let guard = WS_CONNECTIONS.lock().unwrap(); - match guard.get(&ws_id) { - Some(conn) => { - if conn.is_open { - 1.0 - } else { - 0.0 - } - } - None => 0.0, - } -} - -#[cfg(target_os = "ios")] -#[no_mangle] -pub extern "C" fn js_ws_is_open(handle: i64) -> f64 { - unsafe { perry_native_ws_is_open(handle as f64) } -} - -/// #6117 — `ws.readyState` per npm-ws semantics: CONNECTING=0, OPEN=1, -/// CLOSING=2, CLOSED=3. An id with no map entry is CLOSED — either the -/// entry was cleaned up after close, or the promise-path connect failed -/// before an entry was ever created. -#[cfg(not(target_os = "ios"))] -#[no_mangle] -pub extern "C" fn js_ws_ready_state(handle: i64) -> f64 { - let ws_id = handle as usize; - match WS_CONNECTIONS.lock().unwrap().get(&ws_id) { - Some(conn) if conn.is_closed => 3.0, - Some(conn) if conn.is_closing => 2.0, - Some(conn) if conn.is_open => 1.0, - Some(_) => 0.0, - None => 3.0, - } -} - -/// iOS: NSURLSessionWebSocketTask exposes no CONNECTING/CLOSING signal -/// through the existing native bridge — approximate with open/closed. -#[cfg(target_os = "ios")] -#[no_mangle] -pub extern "C" fn js_ws_ready_state(handle: i64) -> f64 { - if unsafe { perry_native_ws_is_open(handle as f64) } == 1.0 { - 1.0 - } else { - 3.0 - } -} - -/// Get the number of pending messages -/// Returns the count of received messages waiting to be read -#[cfg(not(target_os = "ios"))] -#[no_mangle] -pub extern "C" fn js_ws_message_count(handle: i64) -> f64 { - let ws_id = handle as usize; - - let guard = WS_CONNECTIONS.lock().unwrap(); - match guard.get(&ws_id) { - Some(conn) => conn.messages.len() as f64, - None => 0.0, - } -} - -#[cfg(target_os = "ios")] -#[no_mangle] -pub extern "C" fn js_ws_message_count(handle: i64) -> f64 { - unsafe { perry_native_ws_message_count(handle as f64) } -} - -/// Get the next message from the queue -/// Returns null if no messages available -#[cfg(not(target_os = "ios"))] -#[no_mangle] -pub extern "C" fn js_ws_receive(handle: i64) -> *mut StringHeader { - let ws_id = handle as usize; - - let mut guard = WS_CONNECTIONS.lock().unwrap(); - match guard.get_mut(&ws_id) { - Some(conn) => { - if conn.messages.is_empty() { - std::ptr::null_mut() - } else { - let msg = conn.messages.remove(0); - js_string_from_bytes(msg.as_ptr(), msg.len() as u32) - } - } - None => std::ptr::null_mut(), - } -} - -#[cfg(target_os = "ios")] -#[no_mangle] -pub extern "C" fn js_ws_receive(handle: i64) -> *mut StringHeader { - // perry_native_ws_receive returns a NaN-boxed string (f64). - // We need to return *mut StringHeader. Extract pointer from the f64. - let val = unsafe { perry_native_ws_receive(handle as f64) }; - let ptr = perry_runtime::js_get_string_pointer_unified(val); - ptr as *mut StringHeader -} - -/// Wait for a message (blocking with timeout) -/// ws.waitForMessage(timeoutMs) -> Promise -#[cfg(not(target_os = "ios"))] -#[no_mangle] -pub unsafe extern "C" fn js_ws_wait_for_message( - handle: i64, - timeout_ms: f64, -) -> *mut perry_runtime::Promise { - let promise = perry_runtime::js_promise_new_cross_thread(); - let promise_ptr = promise as usize; - let ws_id = handle as usize; - let timeout = std::time::Duration::from_millis(timeout_ms as u64); - - spawn(async move { - let start = std::time::Instant::now(); - - loop { - // Check for messages - { - let mut guard = WS_CONNECTIONS.lock().unwrap(); - if let Some(conn) = guard.get_mut(&ws_id) { - if !conn.messages.is_empty() { - let msg = conn.messages.remove(0); - // #1292 pattern (see bcrypt.rs): build the JS string on - // the MAIN thread via the deferred converter and tag it - // STRING_TAG. The old path allocated the StringHeader on - // this tokio worker's arena (cross-heap pointer — freed - // under the main thread by the worker's GC/exit) and - // used POINTER_TAG, so the awaited value was a - // string-like *object* (`typeof === "object"`). - queue_deferred_resolution(promise_ptr, true, move || { - let result_str = js_string_from_bytes(msg.as_ptr(), msg.len() as u32); - JSValue::string_ptr(result_str).bits() - }); - return; - } - - if !conn.is_open { - // Connection closed - let result_bits = JSValue::null().bits(); - queue_promise_resolution(promise_ptr, true, result_bits); - return; - } - } else { - // Invalid handle - let result_bits = JSValue::null().bits(); - queue_promise_resolution(promise_ptr, true, result_bits); - return; - } - } - - // Check timeout - if start.elapsed() >= timeout { - let result_bits = JSValue::null().bits(); - queue_promise_resolution(promise_ptr, true, result_bits); - return; - } - - // Wait a bit before checking again - tokio::time::sleep(std::time::Duration::from_millis(10)).await; - } - }); - - promise -} - -// ============================================================================ -// WebSocketServer (wss) implementation -// ============================================================================ - -/// Convert a WS value (f64 bits as i64) to the correct i64 handle. -/// Server handles are NaN-boxed pointers (tag 0x7FFD); client handles are plain f64 numbers. -#[cfg(not(target_os = "ios"))] -#[no_mangle] -pub unsafe extern "C" fn js_ws_handle_to_i64(val_f64: f64) -> i64 { - let bits = val_f64.to_bits(); - let ptr_tag: u64 = 0x7FFD_0000_0000_0000; - let mask: u64 = 0xFFFF_0000_0000_0000; - if (bits & mask) == ptr_tag { - // NaN-boxed pointer (server handle) — extract raw pointer - (bits & 0x0000_FFFF_FFFF_FFFF) as i64 - } else { - // Plain f64 number (client ws_id) — convert to integer - val_f64 as i64 - } -} - -/// Register an event listener on a WebSocket handle (server or client). -/// Unified function: checks handle type at runtime. -/// -/// js_ws_on(handle, event_name_ptr, callback_ptr) -> handle -#[cfg(not(target_os = "ios"))] -#[no_mangle] -pub unsafe extern "C" fn js_ws_on( - handle: i64, - event_name_ptr: *const StringHeader, - callback_ptr: i64, -) -> i64 { - ensure_gc_scanner_registered(); - let event_name = match string_from_header(event_name_ptr) { - Some(name) => name, - None => { - eprintln!( - "[ws_on] Failed to extract event name from handle={}", - handle - ); - return handle; - } - }; - - if callback_ptr == 0 { - return handle; - } - - // Try server handle first - if let Some(server) = get_handle_mut::(handle) { - server - .listeners - .entry(event_name) - .or_insert_with(Vec::new) - .push(callback_ptr); - return handle; - } - - // Otherwise treat as client ws_id - let ws_id = handle as usize; - let mut guard = WS_CLIENT_LISTENERS.lock().unwrap(); - let entry = guard.entry(ws_id).or_insert_with(|| WsClientListeners { - listeners: HashMap::new(), - }); - entry - .listeners - .entry(event_name) - .or_default() - .push(callback_ptr); - - handle -} - -/// Create a new WebSocketServer -/// new WebSocketServer({ port }) -> handle (synchronous, starts listening immediately) -#[cfg(not(target_os = "ios"))] -#[no_mangle] -pub unsafe extern "C" fn js_ws_server_new(opts_f64: f64) -> Handle { - ensure_gc_scanner_registered(); - // Extract port from options object - let port = { - let opts_bits = opts_f64.to_bits(); - // Check if it's a NaN-boxed pointer (object) - let ptr_tag: u64 = 0x7FFD_0000_0000_0000; - let mask: u64 = 0xFFFF_0000_0000_0000; - if (opts_bits & mask) == ptr_tag { - // Extract raw pointer - let ptr = (opts_bits & 0x0000_FFFF_FFFF_FFFF) as *const perry_runtime::ObjectHeader; - if !ptr.is_null() { - // Get 'port' field - let key = "port"; - let key_str = js_string_from_bytes(key.as_ptr(), key.len() as u32); - let val = perry_runtime::js_object_get_field_by_name(ptr, key_str); - let val_f64 = f64::from_bits(val.bits()); - if val_f64.is_finite() && val_f64 > 0.0 { - val_f64 as u16 - } else { - 0 - } - } else { - 0 - } - } else if opts_f64.is_finite() && opts_f64 > 0.0 { - // Maybe port was passed directly as a number - opts_f64 as u16 - } else { - 0 - } - }; - - let (shutdown_tx, mut shutdown_rx) = mpsc::unbounded_channel::<()>(); - - let server_handle = register_handle(WsServerHandle { - listeners: HashMap::new(), - port, - is_listening: false, - client_ids: Vec::new(), - clients_bits: new_server_clients_set(), - shutdown_tx: Some(shutdown_tx), - }); - WS_ACTIVE_SERVERS.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - // Tokio workers only enqueue raw Rust events here. JS closure dispatch - // happens later in `js_ws_process_pending` on the main thread, and the - // listener slots are covered by the ws mutable root scanner. - // Spawn the accept loop - let handle_id = server_handle; - spawn(async move { - let addr = format!("0.0.0.0:{}", port); - let listener = match tokio::net::TcpListener::bind(&addr).await { - Ok(l) => l, - Err(e) => { - push_ws_event(PendingWsEvent::ServerError( - handle_id, - format!("WebSocketServer bind error: {}", e), - )); - return; - } - }; - - // Queue 'listening' event - push_ws_event(PendingWsEvent::Listening(handle_id)); - - // Mark as listening - if let Some(server) = get_handle_mut::(handle_id) { - server.is_listening = true; - } - - loop { - tokio::select! { - accept_result = listener.accept() => { - match accept_result { - Ok((tcp_stream, _addr)) => { - // Upgrade to WebSocket - match ws_server_accept(tcp_stream).await { - Ok(connected) => { - let (tx, rx) = mpsc::unbounded_channel::(); - - // Allocate client ID - let mut id_guard = NEXT_WS_ID.lock().unwrap(); - let ws_id = *id_guard; - *id_guard += 1; - drop(id_guard); - - // Store connection - WS_CONNECTIONS.lock().unwrap().insert(ws_id, WsConnection { - sender: tx, - messages: Vec::new(), - is_open: true, - is_closing: false, - is_closed: false, - }); - - // Initialize client listeners - WS_CLIENT_LISTENERS.lock().unwrap().insert(ws_id, WsClientListeners { - listeners: HashMap::new(), - }); - - // Track client on server and record parent relationship - if let Some(server) = get_handle_mut::(handle_id) { - server.client_ids.push(ws_id); - } - WS_CLIENT_PARENT_SERVER.lock().unwrap().insert(ws_id, handle_id); - - // Queue 'connection' event - push_ws_event( - PendingWsEvent::Connection(handle_id, ws_id) - ); - - // A single task handles both read and write over one split stream. - let ws_id_io = ws_id; - ws_file_log(&format!("[WS-srv] spawning io task for id={}", ws_id_io)); - tokio::spawn(async move { - run_ws_io(ws_id_io, connected, rx, IoFlavor::ServerClient).await; - }); - } - Err(e) => { - push_ws_event( - PendingWsEvent::ServerError(handle_id, format!("WebSocket accept error: {}", e)) - ); - } - } - } - Err(e) => { - push_ws_event( - PendingWsEvent::ServerError(handle_id, format!("TCP accept error: {}", e)) - ); - } - } - } - _ = shutdown_rx.recv() => { - // Shutdown signal received - break; - } - } - } - }); - - server_handle -} - -/// Return the persistent `Set` exposed as `WebSocketServer.clients`. -#[cfg(not(target_os = "ios"))] -#[no_mangle] -pub extern "C" fn js_ws_server_clients(handle: i64) -> f64 { - get_handle_mut::(handle) - .map(|server| f64::from_bits(server.clients_bits)) - .unwrap_or_else(|| f64::from_bits(JSValue::undefined().bits())) -} - -/// Close the WebSocketServer and all its client connections -/// wss.close(callback?) -> void -#[cfg(not(target_os = "ios"))] -#[no_mangle] -pub unsafe extern "C" fn js_ws_server_close(handle: i64) { - WS_ACTIVE_SERVERS.fetch_sub(1, std::sync::atomic::Ordering::Relaxed); - if let Some(server) = get_handle_mut::(handle) { - server.is_listening = false; - - // Send shutdown signal - if let Some(tx) = server.shutdown_tx.take() { - let _ = tx.send(()); - } - - // Close all client connections - let client_ids: Vec = server.client_ids.clone(); - for ws_id in client_ids { - let guard = WS_CONNECTIONS.lock().unwrap(); - if let Some(conn) = guard.get(&ws_id) { - let _ = conn.sender.send(WsCommand::Close); - } - } - } -} - -/// Returns 1 if there are active WS servers or connections that need -/// the event loop to keep running. -#[cfg(not(target_os = "ios"))] -pub fn js_ws_has_active_handles() -> i32 { - // Check the active-server counter (set in js_ws_server_new) - if WS_ACTIVE_SERVERS.load(std::sync::atomic::Ordering::Relaxed) > 0 { - return 1; - } - // Check for active connections - let conns = WS_CONNECTIONS.lock().unwrap(); - if !conns.is_empty() { - return 1; - } - // Check for pending events - let pending = WS_PENDING_EVENTS.lock().unwrap(); - if !pending.is_empty() { - return 1; - } - 0 -} - -#[cfg(target_os = "ios")] -pub fn js_ws_has_active_handles() -> i32 { - 0 -} - -/// Process pending WebSocket events (called from js_stdlib_process_pending) -/// Drains the event queue and invokes closures on the main thread. -/// Returns number of events processed. -/// -/// #1114 followup: same per-tick scratch-Vec discipline as the fastify -/// (e538caa7) and net (this PR) pumps. Called every event-loop iteration -/// + every inline `await` poll iteration; the original -/// `Vec::drain(..).collect()` was a per-call heap alloc that contributed -/// to the GC `madvise` churn observed under shop-admin's realtime WS -/// broker + JobLoop combo. Reuse a per-thread scratch buffer (moved out -/// across dispatch so a re-entrant pump from inside a user callback is -/// safe). -#[cfg(not(target_os = "ios"))] -#[no_mangle] -pub unsafe extern "C" fn js_ws_process_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 guard = WS_PENDING_EVENTS.lock().unwrap(); - events.append(&mut *guard); - } - - let count = events.len() as i32; - - for event in events.drain(..) { - match event { - PendingWsEvent::Connection(server_handle, client_ws_id) => { - // Keep `clients` current before invoking user connection - // listeners, matching the ordering in the npm `ws` package. - track_server_client(server_handle, client_ws_id); - // Get 'connection' listeners from server - let listeners: Vec = get_handle_mut::(server_handle) - .and_then(|s| s.listeners.get("connection").cloned()) - .unwrap_or_default(); - - // Pass ws_id as a regular f64 number (not NaN-boxed) so === comparison works - let client_handle_f64 = client_ws_id as f64; - - for cb in listeners { - if cb != 0 { - let closure = cb as *const ClosureHeader; - js_closure_call1(closure, client_handle_f64); - } - } - } - PendingWsEvent::Message(ws_id, message) => { - // Get 'message' listeners from client - let listeners: Vec = { - let guard = WS_CLIENT_LISTENERS.lock().unwrap(); - guard - .get(&ws_id) - .and_then(|l| l.listeners.get("message").cloned()) - .unwrap_or_default() - }; - - // Create string on main thread and NaN-box with STRING_TAG - let msg_str = js_string_from_bytes(message.as_ptr(), message.len() as u32); - let msg_f64 = f64::from_bits( - 0x7FFF_0000_0000_0000u64 | (msg_str as u64 & 0x0000_FFFF_FFFF_FFFF), - ); - - if !listeners.is_empty() { - for cb in listeners { - if cb != 0 { - let closure = cb as *const ClosureHeader; - js_closure_call1(closure, msg_f64); - } - } - } else { - // Fall through to parent server's 'message' listeners (ws, data) - let parent = WS_CLIENT_PARENT_SERVER.lock().unwrap().get(&ws_id).copied(); - if let Some(server_handle) = parent { - let server_listeners: Vec = - get_handle_mut::(server_handle) - .and_then(|s| s.listeners.get("message").cloned()) - .unwrap_or_default(); - // Pass ws_id as regular f64 number (not NaN-boxed) so === comparison works - let client_handle_f64 = ws_id as f64; - for cb in server_listeners { - if cb != 0 { - let closure = cb as *const ClosureHeader; - js_closure_call2(closure, client_handle_f64, msg_f64); - } - } - } - } - } - PendingWsEvent::Close(ws_id, _code, _reason) => { - let listeners: Vec = { - let guard = WS_CLIENT_LISTENERS.lock().unwrap(); - guard - .get(&ws_id) - .and_then(|l| l.listeners.get("close").cloned()) - .unwrap_or_default() - }; - - if !listeners.is_empty() { - for cb in listeners { - if cb != 0 { - let closure = cb as *const ClosureHeader; - js_closure_call0(closure); - } - } - } else { - // Fall through to parent server's 'close' listeners (ws) - let parent = WS_CLIENT_PARENT_SERVER.lock().unwrap().get(&ws_id).copied(); - if let Some(server_handle) = parent { - let server_listeners: Vec = - get_handle_mut::(server_handle) - .and_then(|s| s.listeners.get("close").cloned()) - .unwrap_or_default(); - let client_handle_f64 = ws_id as f64; - for cb in server_listeners { - if cb != 0 { - let closure = cb as *const ClosureHeader; - js_closure_call1(closure, client_handle_f64); - } - } - } - } - - cleanup_ws_client(ws_id); - } - PendingWsEvent::Error(ws_id, error_msg) => { - let listeners: Vec = { - let guard = WS_CLIENT_LISTENERS.lock().unwrap(); - guard - .get(&ws_id) - .and_then(|l| l.listeners.get("error").cloned()) - .unwrap_or_default() - }; - - let err_str = js_string_from_bytes(error_msg.as_ptr(), error_msg.len() as u32); - let err_f64 = f64::from_bits( - 0x7FFF_0000_0000_0000u64 | (err_str as u64 & 0x0000_FFFF_FFFF_FFFF), - ); - - if !listeners.is_empty() { - for cb in listeners { - if cb != 0 { - let closure = cb as *const ClosureHeader; - js_closure_call1(closure, err_f64); - } - } - } else { - // Fall through to parent server's 'error' listeners (ws, error) - let parent = WS_CLIENT_PARENT_SERVER.lock().unwrap().get(&ws_id).copied(); - if let Some(server_handle) = parent { - let server_listeners: Vec = - get_handle_mut::(server_handle) - .and_then(|s| s.listeners.get("client_error").cloned()) - .unwrap_or_default(); - let client_handle_f64 = ws_id as f64; - for cb in server_listeners { - if cb != 0 { - let closure = cb as *const ClosureHeader; - js_closure_call2(closure, client_handle_f64, err_f64); - } - } - } - } - } - PendingWsEvent::ServerError(server_handle, error_msg) => { - let listeners: Vec = get_handle_mut::(server_handle) - .and_then(|s| s.listeners.get("error").cloned()) - .unwrap_or_default(); - - let err_str = js_string_from_bytes(error_msg.as_ptr(), error_msg.len() as u32); - let err_f64 = f64::from_bits( - 0x7FFF_0000_0000_0000u64 | (err_str as u64 & 0x0000_FFFF_FFFF_FFFF), - ); - - for cb in listeners { - if cb != 0 { - let closure = cb as *const ClosureHeader; - js_closure_call1(closure, err_f64); - } - } - } - PendingWsEvent::Listening(server_handle) => { - let listeners: Vec = get_handle_mut::(server_handle) - .and_then(|s| s.listeners.get("listening").cloned()) - .unwrap_or_default(); - - for cb in listeners { - if cb != 0 { - let closure = cb as *const ClosureHeader; - js_closure_call0(closure); - } - } - } - } - } - - // 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 -} - -/// iOS: no-op since native WebSocket handles events via NSURLSession callbacks -#[cfg(target_os = "ios")] -#[no_mangle] -pub unsafe extern "C" fn js_ws_process_pending() -> i32 { - 0 -} - -#[cfg(all(test, not(target_os = "ios")))] -mod tests { - use super::*; - - static TEST_LOCK: std::sync::LazyLock> = std::sync::LazyLock::new(|| Mutex::new(())); - - fn clear_test_state() { - WS_CONNECTIONS.lock().unwrap().clear(); - WS_CLIENT_LISTENERS.lock().unwrap().clear(); - WS_CLIENT_PARENT_SERVER.lock().unwrap().clear(); - WS_PENDING_EVENTS.lock().unwrap().clear(); - WS_ACTIVE_SERVERS.store(0, std::sync::atomic::Ordering::Relaxed); - } - - #[test] - fn root_scanner_emits_client_and_server_listeners() { - let _guard = TEST_LOCK.lock().unwrap(); - clear_test_state(); - - { - let mut clients = WS_CLIENT_LISTENERS.lock().unwrap(); - clients.insert( - 42, - WsClientListeners { - listeners: HashMap::from([("message".to_string(), vec![0x1234_5678])]), - }, - ); - } - let server_handle = register_handle(WsServerHandle { - listeners: HashMap::from([("connection".to_string(), vec![0x2345_6780])]), - port: 0, - is_listening: false, - client_ids: Vec::new(), - clients_bits: new_server_clients_set(), - shutdown_tx: None, - }); - - let mut emitted = Vec::new(); - scan_ws_roots(&mut |value| emitted.push(value.to_bits())); - - assert!(emitted.contains(&(0x7FFD_0000_0000_0000 | 0x1234_5678))); - assert!(emitted.contains(&(0x7FFD_0000_0000_0000 | 0x2345_6780))); - crate::common::drop_handle(server_handle); - clear_test_state(); - } - - #[test] - fn close_event_releases_server_client_bookkeeping() { - let _guard = TEST_LOCK.lock().unwrap(); - clear_test_state(); - - let client_id = 77usize; - let (tx, _rx) = mpsc::unbounded_channel::(); - let server_handle = register_handle(WsServerHandle { - listeners: HashMap::new(), - port: 0, - is_listening: false, - client_ids: vec![client_id], - clients_bits: new_server_clients_set(), - shutdown_tx: None, - }); - - WS_CONNECTIONS.lock().unwrap().insert( - client_id, - WsConnection { - sender: tx, - messages: Vec::new(), - is_open: false, - is_closing: false, - is_closed: false, - }, - ); - WS_CLIENT_LISTENERS.lock().unwrap().insert( - client_id, - WsClientListeners { - listeners: HashMap::new(), - }, - ); - WS_CLIENT_PARENT_SERVER - .lock() - .unwrap() - .insert(client_id, server_handle); - WS_PENDING_EVENTS - .lock() - .unwrap() - .push(PendingWsEvent::Close(client_id, 1000, String::new())); - - assert_eq!(js_ws_has_active_handles(), 1); - let processed = unsafe { js_ws_process_pending() }; - assert_eq!(processed, 1); - - assert!(!WS_CONNECTIONS.lock().unwrap().contains_key(&client_id)); - assert!(!WS_CLIENT_LISTENERS.lock().unwrap().contains_key(&client_id)); - assert!(!WS_CLIENT_PARENT_SERVER - .lock() - .unwrap() - .contains_key(&client_id)); - assert!(get_handle_mut::(server_handle) - .unwrap() - .client_ids - .is_empty()); - assert_eq!(js_ws_has_active_handles(), 0); - - crate::common::drop_handle(server_handle); - clear_test_state(); - } - - /// #6117 — `readyState` walks the npm-ws lifecycle: CONNECTING (0) - /// pre-open, OPEN (1), CLOSING (2) after `close()` is requested, - /// CLOSED (3) once the IO loop marks the connection dead, and CLOSED - /// for ids with no entry (cleaned up, or promise-path connect failed). - #[test] - fn ready_state_reports_npm_ws_lifecycle() { - let _guard = TEST_LOCK.lock().unwrap(); - clear_test_state(); - - let ws_id = 91usize; - let (tx, _rx) = mpsc::unbounded_channel::(); - WS_CONNECTIONS.lock().unwrap().insert( - ws_id, - WsConnection { - sender: tx, - messages: Vec::new(), - is_open: false, - is_closing: false, - is_closed: false, - }, - ); - - assert_eq!(js_ws_ready_state(ws_id as i64), 0.0); - WS_CONNECTIONS - .lock() - .unwrap() - .get_mut(&ws_id) - .unwrap() - .is_open = true; - assert_eq!(js_ws_ready_state(ws_id as i64), 1.0); - unsafe { js_ws_close(ws_id as i64) }; - assert_eq!(js_ws_ready_state(ws_id as i64), 2.0); - mark_ws_connection_closed(ws_id); - assert_eq!(js_ws_ready_state(ws_id as i64), 3.0); - cleanup_ws_client(ws_id); - assert_eq!(js_ws_ready_state(ws_id as i64), 3.0); - - clear_test_state(); - } -} diff --git a/crates/perry-stdlib/src/ws/codec.rs b/crates/perry-stdlib/src/ws/codec.rs deleted file mode 100644 index 433de07e23..0000000000 --- a/crates/perry-stdlib/src/ws/codec.rs +++ /dev/null @@ -1,419 +0,0 @@ -//! The WebSocket codec: `turnloop_websocket`'s sans-I/O `Connection`, wrapped -//! so the `ws` module's tokio transport can drive it. -//! -//! This duplicates `perry-ext-ws/src/codec.rs` on purpose. perry-stdlib is the -//! BUNDLED `ws` binding and perry-ext-ws is the external one; a dependency -//! from here to there would be backwards, so the two are deliberately -//! independent implementations of the same protocol wrapper. -//! -//! # The `Received` contract (PerryTS/turnloop#86) -//! -//! `Connection::receive` returns `Received { consumed, message }`. The reading -//! is **not** "an event came back, so keep going": -//! -//! | `consumed` | `message` | meaning | -//! |---|---|---| -//! | `0` | `None` | **wait.** No progress is possible until more bytes arrive. | -//! | `> 0` | `None` | **keep going.** Bytes were absorbed — a partial frame, or a control frame answered internally — and the next call may well produce a message from what is left. | -//! | `0` | `Some` | **keep going.** tungstenite had a whole frame buffered from an earlier call and needed no new bytes for it. | -//! | `> 0` | `Some` | **keep going.** One call yields at most one message, so a read carrying several needs several calls. | -//! -//! Only the first row terminates the loop. A host that stops as soon as -//! `message` is `None` stalls on a partial frame; a host that stops as soon as -//! `consumed` is `0` drops a message that was already decoded. -//! [`Codec::receive`] is the one place in this crate that gets it right, and -//! `receive_loop_handles_both_zero_cases` pins it. - -pub(super) use turnloop_http::http1::Mode; -pub(super) use turnloop_websocket::{Message, Role}; - -use turnloop_http::http1::{BodyLength, Decoder, Encoder, Event, Head, Limits}; -use turnloop_websocket::{Error as WsError, WebSocketConfig}; - -/// A decoded, application-visible WebSocket event. -/// -/// Deliberately not `turnloop_websocket::Message`: `ws`'s JS surface -/// distinguishes a close carrying a status code from one without. -#[derive(Clone, Debug, PartialEq, Eq)] -pub(super) enum Incoming { - Text(String), - Binary(Vec), - Ping(Vec), - Pong(Vec), - /// The peer's close frame; `None` when it sent no status code. - Close(Option<(u16, String)>), -} - -/// How long a `close()` waits for the peer's answering close frame. `ws`'s own -/// `closeTimeout` is 30 s. -const CLOSE_TIMEOUT_MS: u64 = 30_000; - -/// A WebSocket connection's protocol state, with no I/O of its own. -pub(super) struct Codec { - conn: turnloop_websocket::Connection, - /// Wire bytes received and not yet consumed by the state machine. - inbox: Vec, - /// Wire bytes the state machine produced and the transport has not sent. - outbox: Vec, - terminal: bool, -} - -impl Codec { - pub(super) fn new(role: Role) -> Self { - Self { - conn: turnloop_websocket::Connection::new(role, WebSocketConfig::default()), - inbox: Vec::new(), - outbox: Vec::new(), - terminal: false, - } - } - - /// Feed wire bytes in and drain every message they complete. - /// - /// Bytes that do not complete a frame stay in `inbox` for the next call, so - /// a transport may hand over whatever a single read produced. Automatic - /// replies (a pong for a ping, the answering close) land in `outbox`; the - /// caller must `take_output` after every call. - pub(super) fn receive(&mut self, bytes: &[u8]) -> Result, WsError> { - if !bytes.is_empty() { - self.inbox.extend_from_slice(bytes); - } - let mut events = Vec::new(); - if self.terminal { - return Ok(events); - } - let mut offset = 0usize; - loop { - let Codec { - conn, - inbox, - outbox, - .. - } = self; - let received = match conn.receive(&inbox[offset..], outbox) { - Ok(received) => received, - Err(WsError::ConnectionClosed | WsError::AlreadyClosed) => { - self.terminal = true; - break; - } - Err(e) => { - self.terminal = true; - self.inbox.drain(..offset); - return Err(e); - } - }; - offset += received.consumed; - // The whole point of this module. `consumed == 0 && - // message.is_none()` is the ONLY case that means "wait": everything - // else made progress and the state machine may have more to give. - let progressed = received.consumed > 0 || received.message.is_some(); - if let Some(message) = received.message { - let terminal = matches!(message, Message::Close(_)); - events.push(convert(message)); - if terminal { - // A close frame ends the message stream. Anything after it - // on the wire is a protocol error, not our business. - self.terminal = true; - break; - } - } - if !progressed { - break; - } - } - self.inbox.drain(..offset); - // tungstenite queues its pong/close answers inside `read`; they are only - // encoded by a flush, and a transport that never flushed would answer a - // ping only when the application happened to send something. - match self.conn.flush(&mut self.outbox) { - Ok(()) => {} - Err(WsError::ConnectionClosed | WsError::AlreadyClosed) => self.terminal = true, - Err(e) => { - self.terminal = true; - return Err(e); - } - } - Ok(events) - } - - /// Encode an application message. `ws` sends a message as one frame and so - /// does this. - pub(super) fn send(&mut self, message: Message) -> Result<(), WsError> { - if self.terminal { - return Err(WsError::AlreadyClosed); - } - self.conn.send(message, &mut self.outbox) - } - - /// Begin the closing handshake. The peer's answering close arrives through - /// [`Codec::receive`]. - pub(super) fn close(&mut self, code: Option, reason: &str) -> Result<(), WsError> { - if self.terminal { - return Ok(()); - } - let frame = code.map(|code| turnloop_websocket::CloseFrame { - code: code.into(), - reason: reason.to_string().into(), - }); - let deadline = - std::time::Instant::now() + std::time::Duration::from_millis(CLOSE_TIMEOUT_MS); - match self.conn.close(frame, deadline, &mut self.outbox) { - Ok(()) => Ok(()), - // Closing an already-closed connection is what `ws.close()` does - // after the peer closed first, and it is not an error there. - Err(WsError::ConnectionClosed | WsError::AlreadyClosed) => { - self.terminal = true; - Ok(()) - } - Err(e) => Err(e), - } - } - - /// Bytes to put on the wire. Always call this after `receive`, `send` or - /// `close` — the state machine has no other way out. - pub(super) fn take_output(&mut self) -> Vec { - std::mem::take(&mut self.outbox) - } - - pub(super) fn is_terminal(&self) -> bool { - self.terminal - } -} - -fn convert(message: Message) -> Incoming { - match message { - Message::Text(text) => Incoming::Text(text.as_str().to_string()), - Message::Binary(bytes) => Incoming::Binary(bytes.to_vec()), - Message::Ping(bytes) => Incoming::Ping(bytes.to_vec()), - Message::Pong(bytes) => Incoming::Pong(bytes.to_vec()), - Message::Close(frame) => { - Incoming::Close(frame.map(|f| (u16::from(f.code), f.reason.as_str().to_string()))) - } - // Only the raw frame API produces this, and this codec never uses it. - Message::Frame(_) => Incoming::Binary(Vec::new()), - } -} - -/// Reads exactly one HTTP head out of a byte stream, keeping whatever followed -/// it — which for an upgrade is already WebSocket frame data and must not be -/// dropped. Used in `Mode::Response` by the client and `Mode::Request` by the -/// server. -pub(super) struct HeadReader { - decoder: Decoder, - buffer: Vec, - done: bool, -} - -impl HeadReader { - pub(super) fn new(mode: Mode) -> Self { - let mut decoder = Decoder::new(mode, Limits::default()); - if mode == Mode::Response { - // The upgrade request is a GET, so the decoder must not expect a - // HEAD response's framing. - decoder.response_to("GET"); - } - Self { - decoder, - buffer: Vec::new(), - done: false, - } - } - - /// Feed bytes. `Ok(Some(head))` once the head is complete; the bytes that - /// followed it are then available from [`HeadReader::into_leftover`]. - /// - /// The loop has the same shape as [`Codec::receive`]'s: `consumed == 0` - /// with no event is the only "wait", and an `Informational` head (a `1xx` - /// before the `101`) is skipped rather than returned. - pub(super) fn receive(&mut self, bytes: &[u8]) -> Result, String> { - self.buffer.extend_from_slice(bytes); - if self.done { - return Ok(None); - } - let mut offset = 0usize; - let mut head = None; - while offset < self.buffer.len() { - let step = self - .decoder - .receive(&self.buffer[offset..]) - .map_err(|e| format!("invalid upgrade head: {}", e))?; - offset += step.consumed; - match step.event { - Some(Event::Head(h)) => { - head = Some(h); - break; - } - Some(Event::Informational(_)) => continue, - None if step.consumed == 0 => break, - _ => continue, - } - } - self.buffer.drain(..offset); - if head.is_some() { - self.done = true; - } - Ok(head) - } - - /// The bytes that arrived after the head — the first WebSocket frames. - pub(super) fn into_leftover(self) -> Vec { - self.buffer - } -} - -/// Encode a bodyless HTTP head: the upgrade request, and the `101`. -pub(super) fn encode_head(head: &Head) -> Result, String> { - let mut out = Vec::new(); - let mut encoder = Encoder::start(head, BodyLength::Empty, &mut out) - .map_err(|e| format!("cannot encode upgrade head: {}", e))?; - encoder - .finish(&[], &mut out) - .map_err(|e| format!("cannot encode upgrade head: {}", e))?; - Ok(out) -} - -#[cfg(test)] -mod tests { - use super::*; - - /// A client-role codec whose output is a server-role codec's input, so the - /// masking direction is real rather than assumed. - fn pair() -> (Codec, Codec) { - (Codec::new(Role::Client), Codec::new(Role::Server)) - } - - /// The whole reason this module exists. A message split across two reads - /// must not be lost, and a read carrying two messages must yield both. - #[test] - fn receive_loop_handles_both_zero_cases() { - let (mut client, mut server) = pair(); - client.send(Message::text("first")).unwrap(); - client.send(Message::text("second")).unwrap(); - let wire = client.take_output(); - - // Case A: `consumed > 0, message: None` — a partial frame. The first - // three bytes must be absorbed and produce nothing, WITHOUT the loop - // concluding that the connection is idle. - assert!(server.receive(&wire[..3]).unwrap().is_empty()); - - // Case B: the rest completes both messages. A loop that stopped at the - // first `consumed == 0` would return only "first". - let events = server.receive(&wire[3..]).unwrap(); - assert_eq!( - events, - vec![ - Incoming::Text("first".into()), - Incoming::Text("second".into()) - ] - ); - - // Case C: no bytes at all is the genuine "wait" case and must - // terminate. - assert!(server.receive(&[]).unwrap().is_empty()); - } - - /// A message arriving one byte at a time exercises the partial-frame path - /// on every boundary, which is where an off-by-one in the offset shows up. - #[test] - fn byte_at_a_time_delivery_loses_nothing() { - let (mut client, mut server) = pair(); - client - .send(Message::text("fragmented-by-the-transport")) - .unwrap(); - let wire = client.take_output(); - let mut seen = Vec::new(); - for byte in &wire { - seen.extend(server.receive(&[*byte]).unwrap()); - } - assert_eq!( - seen, - vec![Incoming::Text("fragmented-by-the-transport".into())] - ); - } - - #[test] - fn text_and_binary_round_trip() { - let (mut client, mut server) = pair(); - client.send(Message::text("hello")).unwrap(); - client - .send(Message::binary(vec![0u8, 159, 146, 150])) - .unwrap(); - let events = server.receive(&client.take_output()).unwrap(); - assert_eq!( - events, - vec![ - Incoming::Text("hello".into()), - Incoming::Binary(vec![0u8, 159, 146, 150]), - ] - ); - } - - #[test] - fn a_ping_is_answered_by_the_flush_inside_receive() { - let (mut client, mut server) = pair(); - client.send(Message::Ping(b"beat".to_vec().into())).unwrap(); - let events = server.receive(&client.take_output()).unwrap(); - assert_eq!(events, vec![Incoming::Ping(b"beat".to_vec())]); - // The pong must be on the wire already: nothing else is going to flush. - let back = server.take_output(); - assert!( - !back.is_empty(), - "a ping must be answered by the flush inside receive" - ); - assert_eq!( - client.receive(&back).unwrap(), - vec![Incoming::Pong(b"beat".to_vec())] - ); - } - - #[test] - fn close_carries_its_code_and_reason() { - let (mut client, mut server) = pair(); - client.close(Some(4001), "going away").unwrap(); - let events = server.receive(&client.take_output()).unwrap(); - assert_eq!( - events, - vec![Incoming::Close(Some((4001, "going away".into())))] - ); - } - - #[test] - fn a_close_with_no_code_is_reported_as_none() { - let (mut client, mut server) = pair(); - client.close(None, "").unwrap(); - assert_eq!( - server.receive(&client.take_output()).unwrap(), - vec![Incoming::Close(None)] - ); - } - - /// The two handshake halves meet, and the part that matters for a transport - /// holds: the bytes that rode along with the `101` survive. - #[test] - fn a_client_handshake_keeps_the_bytes_after_the_101() { - let (handshake, request_head) = - turnloop_websocket::ClientHandshake::new("example.com", "/chat", [7u8; 16], Vec::new()) - .unwrap(); - let request = encode_head(&request_head).unwrap(); - - let mut server_reader = HeadReader::new(Mode::Request); - let head = server_reader - .receive(&request) - .unwrap() - .expect("a complete head"); - assert_eq!(head.method, "GET"); - assert_eq!(head.target, "/chat"); - let (response_head, _) = turnloop_websocket::accept(&head, &[]).unwrap(); - let response = encode_head(&response_head).unwrap(); - - // Split the response so the client sees a partial head first: the - // `consumed == 0, no event` wait case has to hold here too. - let mut client_reader = HeadReader::new(Mode::Response); - assert!(client_reader.receive(&response[..12]).unwrap().is_none()); - let mut tail = response[12..].to_vec(); - tail.extend_from_slice(b"\x81\x03abc"); // an unmasked text frame riding along - let verified = client_reader.receive(&tail).unwrap().expect("the 101"); - assert_eq!(handshake.verify(&verified).unwrap(), None); - assert_eq!(client_reader.into_leftover(), b"\x81\x03abc"); - } -} diff --git a/crates/perry-tls-session/src/lib.rs b/crates/perry-tls-session/src/lib.rs index 5513461b39..04ce38f042 100644 --- a/crates/perry-tls-session/src/lib.rs +++ b/crates/perry-tls-session/src/lib.rs @@ -23,11 +23,11 @@ //! no `Endpoint` trait to abstract over one. The two-sided shape — a server //! session, or a client over a caller-built `rustls` config (Node's CA //! options, `rejectUnauthorized: false`) — is [`session::TlsSession`], which -//! `perry-stdlib`'s `node:tls` server and bundled `net` / `ws` clients use. +//! `perry-stdlib`'s `node:tls` server uses. //! * **The config comes from [`turnloop_tls::ClientConfig`]**, whose //! `ClientOptions` names the crypto provider explicitly — so it is unaffected -//! by the ring/aws-lc-rs default-provider ambiguity the `tls` / `bundled-ws` -//! paths install one for (#6117). +//! by the ring/aws-lc-rs default-provider ambiguity the `node:tls` paths +//! install one for (#6117). //! //! # GC //! diff --git a/crates/perry/src/commands/compile/optimized_libs.rs b/crates/perry/src/commands/compile/optimized_libs.rs index 2fb79d8c6a..2ab8d081ff 100644 --- a/crates/perry/src/commands/compile/optimized_libs.rs +++ b/crates/perry/src/commands/compile/optimized_libs.rs @@ -92,9 +92,46 @@ pub(crate) fn well_known_iteration_set(ctx: &CompilationContext) -> BTreeSet bool { + std::env::var_os("PERRY_DISABLE_WELL_KNOWN").is_none() +} + +/// Bindings whose wrapper crate is the ONLY implementation, so the flip routes +/// them even when PERRY_DISABLE_WELL_KNOWN=1 — disabling it reverts to +/// perry-stdlib's copies, and these have none. perry-stdlib's bundled `net` +/// (the other `js_net_socket_*` / `js_tls_connect`) and `ws` copies ran on +/// tokio sockets and were strict subsets of perry-ext-net / perry-ext-ws; +/// tokio lane L4 deleted them. A `tls` import is covered through `net`: +/// `tls.connect` is perry-ext-net's, and its symbols route to `net` +/// (`perry_codegen::ext_registry`). +pub(crate) fn wrapper_is_sole_provider(module: &str) -> bool { + matches!(module.strip_prefix("node:").unwrap_or(module), "net" | "ws") +} + +/// The modules of an iteration set the well-known flip routes to a wrapper +/// archive: every well-known import normally, only the +/// [`wrapper_is_sole_provider`] ones when PERRY_DISABLE_WELL_KNOWN=1. +pub(crate) fn retain_routed(mut set: BTreeSet) -> BTreeSet { + if !well_known_flip_enabled() { + set.retain(|module| wrapper_is_sole_provider(module)); + } + set +} + /// Name wrapper archives needed by emitted object-file symbols but absent from /// the link line. The caller has already scanned the runtime and stdlib too, /// so a symbol those archives define does not produce a false missing-wrapper diff --git a/crates/perry/src/commands/compile/optimized_libs/driver.rs b/crates/perry/src/commands/compile/optimized_libs/driver.rs index ca88aa324c..13c8782ac5 100644 --- a/crates/perry/src/commands/compile/optimized_libs/driver.rs +++ b/crates/perry/src/commands/compile/optimized_libs/driver.rs @@ -16,8 +16,11 @@ use super::super::{ /// Select the provider for TLS module/server state after well-known routing. /// /// perry-ext-net (directly or through perry-ext-http) owns the socket/connect -/// symbols, but calls back into perry-stdlib for TLS SNI/ALPN preflight. Keep -/// that provider without compiling bundled net beside the external wrapper. +/// symbols, but calls back into perry-stdlib for TLS SNI/ALPN preflight. +/// `external-net-tls` keeps that provider and binds the `tls` module's dynamic +/// `connect` to perry-ext-net's `js_tls_connect`. A TLS program that routes no +/// net transport (a server-only `node:tls` program) keeps the `tls` umbrella, +/// which since tokio lane L4 is `tls-runtime` alone — bundled net is gone. pub(super) fn finalize_tls_transport_features( features: &mut BTreeSet<&'static str>, imports_tls: bool, @@ -120,7 +123,9 @@ pub(crate) fn build_optimized_libs( // in-tree binding). The env-var gate (`PERRY_USE_WELL_KNOWN=1`) // that gated the introductory cycle is now inverted: // `PERRY_DISABLE_WELL_KNOWN=1` reverts to perry-stdlib's - // copies for bisection. If a bundled `.a` is missing on disk, + // copies for bisection — except for the bindings that no longer have + // one (`net`, `ws`: `wrapper_is_sole_provider`), which route to their + // wrapper either way. If a bundled `.a` is missing on disk, // each entry falls back to the perry-stdlib copy individually // (logged with `well-known: skipping` when verbose), so a // partially-built workspace still produces a working binary. @@ -151,8 +156,12 @@ pub(crate) fn build_optimized_libs( let mut external_net_transport = false; // Web Fetch is selected independently from the external node:http // binding. `uses_fetch` adds `web-fetch` in compute_required_features. - if use_well_known { - for module in &iteration_set { + // Was `if use_well_known { … }`; the gate is now per module + // (`retain_routed`), and the block is kept to leave the body's + // indentation — and its blame — as it was. + let routed_set = retain_routed(iteration_set.clone()); + { + for module in &routed_set { let module_normalized = module.strip_prefix("node:").unwrap_or(module); let Some(binding) = super::super::well_known::lookup_well_known(module) else { continue; @@ -245,8 +254,20 @@ pub(crate) fn build_optimized_libs( // exists on disk first (so we can actually build it). let crate_dir = workspace_root.join("crates").join(&binding.krate); if !crate_dir.is_dir() { - // turnloop P8 group H removed the bundled db copies, so - // the fall-back below has nothing to fall back to. + // tokio lane L4 deleted perry-stdlib's bundled `net` / `ws`, + // so for those the fall-back below has nothing to fall back to. + if wrapper_is_sole_provider(module_normalized) { + eprintln!( + "error: `import '{}'` requires the external {} wrapper, but its \ + source crate was not found at `{}`. perry-stdlib's bundled copy was \ + removed; build or restore {}.", + module, + binding.krate, + crate_dir.display(), + binding.krate + ); + std::process::exit(1); + } if matches!(format, OutputFormat::Text) && verbose > 0 { eprintln!( " well-known: skipping `{}` — crate `{}` source not on disk; \ @@ -633,11 +654,7 @@ pub(crate) fn build_optimized_libs( // (PERRY_LIB_DIR / PERRY_RUNTIME_DIR, the exe dir, Homebrew // `../lib`, …) and hand them back so they join the link line // after the full stdlib. - let well_known_libs = if use_well_known { - resolve_prebuilt_ext_libs(&iteration_set, target, format, verbose) - } else { - Vec::new() - }; + let well_known_libs = resolve_prebuilt_ext_libs(&routed_set, target, format, verbose); // Out-of-tree size salvage: release packaging ships a // panic=abort prebuilt runtime variant alongside the unwind // one (stage-npm.sh / release-packages.yml). When the app diff --git a/crates/perry/src/commands/compile/optimized_libs/no_auto.rs b/crates/perry/src/commands/compile/optimized_libs/no_auto.rs index 58710c8e56..7f12f16177 100644 --- a/crates/perry/src/commands/compile/optimized_libs/no_auto.rs +++ b/crates/perry/src/commands/compile/optimized_libs/no_auto.rs @@ -43,11 +43,14 @@ pub(crate) fn resolve_no_auto_optimized_libs( eprintln!(" auto-optimize: skipped; using prebuilt target/release/libperry_*.a"); } let iteration_set = well_known_iteration_set(ctx); - let mut well_known_libs = if std::env::var_os("PERRY_DISABLE_WELL_KNOWN").is_none() { - resolve_prebuilt_ext_libs(&iteration_set, target, format, verbose) - } else { - Vec::new() - }; + // PERRY_DISABLE_WELL_KNOWN=1 keeps only the wrappers that have no + // perry-stdlib copy to revert to (`net`, `ws`). + let mut well_known_libs = resolve_prebuilt_ext_libs( + &retain_routed(iteration_set.clone()), + target, + format, + verbose, + ); // #10458: native addons need every runtime-bearing archive rebuilt // together with the host feature. if !ctx.native_addons.is_empty() { @@ -202,12 +205,9 @@ pub(super) fn linked_ext_crates( iteration_set: &std::collections::BTreeSet, target: Option<&str>, ) -> Vec<(String, String)> { - if std::env::var_os("PERRY_DISABLE_WELL_KNOWN").is_some() { - return Vec::new(); - } let mut seen = std::collections::BTreeSet::new(); let mut crates = Vec::new(); - for module in iteration_set { + for module in &retain_routed(iteration_set.clone()) { let Some(binding) = super::super::well_known::lookup_well_known(module) else { continue; }; diff --git a/crates/perry/src/commands/compile/optimized_libs/tests.rs b/crates/perry/src/commands/compile/optimized_libs/tests.rs index 0fd5d6cbd3..a73a53ba96 100644 --- a/crates/perry/src/commands/compile/optimized_libs/tests.rs +++ b/crates/perry/src/commands/compile/optimized_libs/tests.rs @@ -915,6 +915,82 @@ fn no_auto_still_resolves_prebuilt_well_known_archives() { ); } +/// tokio lane L4: a `tls` import routes `net` — the TLS client and every +/// client TLSSocket method are perry-ext-net's, and bundled `net` is gone. +#[test] +fn tls_import_routes_net_wrapper() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut ctx = CompilationContext::new(dir.path().to_path_buf()); + ctx.native_module_imports.insert("node:tls".to_string()); + assert!(well_known_iteration_set(&ctx).contains("net")); +} + +/// tokio lane L4: perry-stdlib's bundled `net` / `ws` copies are deleted, so +/// PERRY_DISABLE_WELL_KNOWN=1 must still put those two wrappers on the link +/// line (there is no copy to revert to) while every other binding keeps +/// reverting to perry-stdlib. +#[test] +fn disabled_flip_still_routes_sole_provider_wrappers() { + let _guard = env_lock(); + let saved: Vec<_> = [ + "PERRY_LIB_DIR", + "PERRY_RUNTIME_DIR", + "PERRY_DISABLE_WELL_KNOWN", + ] + .iter() + .map(|k| (*k, std::env::var(k).ok())) + .collect(); + + let dir = tempfile::tempdir().expect("tempdir"); + let mut archives = Vec::new(); + for module in ["net", "ws", "events"] { + let binding = super::super::well_known::lookup_well_known(module).expect("binding"); + let lib = dir + .path() + .join(super::super::well_known::ext_staticlib_filename( + &binding.lib, + rust_target_triple(None), + )); + std::fs::write(&lib, b"!\n").expect("write fake archive"); + archives.push(lib); + } + set_env_var("PERRY_LIB_DIR", dir.path().to_str()); + set_env_var("PERRY_RUNTIME_DIR", None); + set_env_var("PERRY_DISABLE_WELL_KNOWN", Some("1")); + + let mut ctx = CompilationContext::new(dir.path().to_path_buf()); + for module in ["net", "ws", "events"] { + ctx.native_module_imports.insert(module.to_string()); + } + let libs = resolve_no_auto_optimized_libs(&ctx, None, OutputFormat::Json, 0); + let routed = super::retain_routed(well_known_iteration_set(&ctx)); + + for (key, value) in &saved { + set_env_var(key, value.as_deref()); + } + + assert!( + libs.well_known_libs.contains(&archives[0]), + "net: {libs:?}", + libs = libs.well_known_libs + ); + assert!( + libs.well_known_libs.contains(&archives[1]), + "ws: {libs:?}", + libs = libs.well_known_libs + ); + assert!( + !libs.well_known_libs.contains(&archives[2]), + "events has a perry-stdlib copy and must revert under the disabled flip" + ); + assert_eq!( + routed, + std::collections::BTreeSet::from(["net".to_string(), "ws".to_string()]) + ); + assert!(super::wrapper_is_sole_provider("node:net")); + assert!(!super::wrapper_is_sole_provider("tls")); +} + /// #10466 — the flip side of the test above: when the program DOES import /// `http`, no-auto now rebuilds `perry-stdlib-static` (with /// `external-http-client-pump`) and `perry-ext-http` together, and the diff --git a/crates/perry/src/commands/compile/run_pipeline.rs b/crates/perry/src/commands/compile/run_pipeline.rs index 79b4463a89..2813d578fd 100644 --- a/crates/perry/src/commands/compile/run_pipeline.rs +++ b/crates/perry/src/commands/compile/run_pipeline.rs @@ -1161,15 +1161,26 @@ pub fn run_with_parse_cache( // wrapper called from the entry prologue, so the provider's export // dispatcher is live for module objects the runtime creates itself (a // CommonJS `require('net')` goes through `createRequire`, not codegen). - // No flip, no provider on the link line: emit nothing. - let native_provider_installs: Vec = - if std::env::var_os("PERRY_DISABLE_WELL_KNOWN").is_some() { - Vec::new() - } else { - perry_codegen::native_provider_install_symbols( - ctx.native_module_imports.iter().map(String::as_str), - ) - }; + // No flip, no provider on the link line: emit nothing — except for the + // bindings whose wrapper is the only provider (`net`), which the flip + // routes even with PERRY_DISABLE_WELL_KNOWN=1 (tokio lane L4). + // A `tls` import installs the `net` provider too: `tls.connect` is + // perry-ext-net's, and its install hook registers it with the runtime + // for the `tls` module's dynamic dispatch (tokio lane L4). + let imports_tls = ctx + .native_module_imports + .iter() + .any(|m| m.strip_prefix("node:").unwrap_or(m) == "tls"); + let native_provider_installs: Vec = perry_codegen::native_provider_install_symbols( + ctx.native_module_imports + .iter() + .map(String::as_str) + .chain(imports_tls.then_some("net")) + .filter(|module| { + optimized_libs::well_known_flip_enabled() + || optimized_libs::wrapper_is_sole_provider(module) + }), + ); // Build a map of all exported enums from all modules (owned data, no borrows) // Key: (resolved_path, enum_name) -> Vec<(member_name, EnumValue)> diff --git a/scripts/gc_runtime_root_holders.json b/scripts/gc_runtime_root_holders.json index 5466b0160f..5cd91ae2f5 100644 --- a/scripts/gc_runtime_root_holders.json +++ b/scripts/gc_runtime_root_holders.json @@ -1429,24 +1429,6 @@ "verdict": "not_a_gc_pointer", "why": "Monotonic u64 counter used to allocate in-process Worker registry IDs. It contains no address." }, - { - "file": "crates/perry-stdlib/src/ws.rs", - "name": "NEXT_WS_ID", - "verdict": "not_a_gc_pointer", - "why": "Monotonic ws id counter." - }, - { - "file": "crates/perry-stdlib/src/ws.rs", - "name": "WS_CLIENT_PARENT_SERVER", - "verdict": "not_a_gc_pointer", - "why": "Client ws id -> parent server HANDLE id. No address." - }, - { - "file": "crates/perry-stdlib/src/ws.rs", - "name": "WS_CONNECTIONS", - "verdict": "not_a_gc_pointer", - "why": "Keyed by ws id; WsConnection is Rust-owned (command sender, buffered message Strings, state flags). Listener closures live in WS_CLIENT_LISTENERS, which scan_ws_roots_mut visits." - }, { "file": "crates/perry-stdlib/src/tls/turnloop_server.rs", "name": "ACCEPTORS", @@ -4287,14 +4269,6 @@ "file": "crates/perry-stdlib/src/fetch/mod.rs", "name": "PENDING_FETCH_BODY_STREAM_ID" }, - { - "file": "crates/perry-stdlib/src/net/mod.rs", - "name": "NET_GC_REGISTERED" - }, - { - "file": "crates/perry-stdlib/src/net/mod.rs", - "name": "SCRATCH" - }, { "file": "crates/perry-stdlib/src/readline/mod.rs", "name": "CLOSE_FIRED" @@ -4371,14 +4345,6 @@ "file": "crates/perry-stdlib/src/worker_threads.rs", "name": "WORKER_GC_REGISTERED" }, - { - "file": "crates/perry-stdlib/src/ws.rs", - "name": "SCRATCH" - }, - { - "file": "crates/perry-stdlib/src/ws.rs", - "name": "WS_GC_REGISTERED" - }, { "file": "crates/perry-stdlib/src/zlib.rs", "name": "ZLIB_GC_REGISTERED" diff --git a/scripts/string_payload_access_baseline.txt b/scripts/string_payload_access_baseline.txt index e845dbc617..4fe7ef880f 100644 --- a/scripts/string_payload_access_baseline.txt +++ b/scripts/string_payload_access_baseline.txt @@ -10,7 +10,7 @@ inline-offset | perry-ext-nodemailer | 1 inline-offset | perry-ext-zlib | 3 inline-offset | perry-ffi | 3 inline-offset | perry-runtime | 348 -inline-offset | perry-stdlib | 28 +inline-offset | perry-stdlib | 26 inline-offset | perry-updater | 5 reader-helper | perry-ext-ethers | 1 reader-helper | perry-runtime | 13 diff --git a/scripts/tokio_inventory.json b/scripts/tokio_inventory.json index b6d1254e80..4393ab6e65 100644 --- a/scripts/tokio_inventory.json +++ b/scripts/tokio_inventory.json @@ -31,10 +31,10 @@ "kind": "normal", "optional": true, "target": null, - "surface": "the tokio HALF of the async bridge only (turnloop P8 lane L split it out): `common::tokio_bridge` \u2014 the current-thread `RUNTIME`, its wait-driver tick, `spawn` / `spawn_for_promise*` \u2014 plus the `perry_ffi_spawn_async` / `perry_ffi_spawn_blocking_with_reactor` C ABI and `perry_ffi_spawn_blocking`'s tokio-pool arm. The promise bridge itself (`common::async_bridge`'s settle queue and pump, and the promise / pool / blocking `perry_ffi_*` shims) is tokio-free under the `async-bridge` feature, which is what the auto-optimize driver now force-enables. `web-fetch`, the `node:tls` server (`tls-runtime`, on turnloop handles since lane L part 2) and the external net / ws adapters compile against that bridge only.", - "reached_when": "the `async-runtime` feature, which is selected only by (1) a Cargo feature whose code hands tokio a future \u2014 `bundled-net` (and so the `net` / `tls` umbrellas) and `bundled-ws` (tokio sockets); or (2) the auto-optimize driver, for every wrapper that still bundles tokio (`binding_bundles_tokio`) \u2014 an empty set since #11337 deleted perry-ext-mongodb, the last member, together with its module-name rule. pg / mysql2 have had no wrapper since #10677 / #10680, lane L3 deleted their unreachable module-name rule, and mongodb followed in #11337; all three npm packages compile from source over `net` / `tls`, so an auto-optimized pg-, mysql2- or mongodb-only program links no tokio either. `full` still implies it, so every PERRY_NO_AUTO_OPTIMIZE / prebuilt-archive build links tokio. Lane L part 2 took `web-fetch`, `tls-runtime`, `external-tls-server`, `external-net-tls`, `external-net-pump`, `external-ws-pump` and (after lane D, #11265) `external-http-server-pump` / `external-http-client-pump` off it (the `node:tls` server's sockets are turnloop handles, and perry-ext-net / perry-ext-ws carry no tokio), so an auto-optimized program whose network imports are only `fetch`, `net`, `tls` (both route to perry-ext-net, with the TLS server here), `ws` and `http` / `https` / `http2` links no tokio \u2014 on top of part 1's crypto, bcrypt, argon2, zlib, readline, worker_threads, timers and UI programs, and lane K's `container`.", - "blocker": "each selector in `reached_when` has to go; then `tokio_bridge.rs`, `tls_stream.rs` and the three `cfg(feature = \"async-runtime\")` shims in `perry_ffi_async.rs` delete whole and `async-runtime` collapses into `async-bridge` \u2014 nothing in `async_bridge.rs` has to move. In order: (1) B is DONE: no db decline path is left \u2014 pg's and mysql2's went with their wrappers (#10677 / #10680), and perry-ext-mongodb's (`Handle::current().block_on` inside `perry_ffi_spawn_blocking`) went when #11337 deleted that wrapper. (2) P1 for the two bundled socket modules left \u2014 `net/mod.rs` (bundled `net.Socket`, `tls.connect`, `upgradeToTLS`) and `ws.rs`, both reached only with the well-known flip disabled or through `full`; `tls/turnloop_server.rs` is the worked example for moving a perry-stdlib socket surface onto `turnloop_net` with `perry-tls-session` above it. K took `container` off tokio (`perry_container_compose::rt`). PerryTS/turnloop#42 is NOT a blocker: `Occupancy::Long` shipped in turnloop 0.1.0-alpha.5, and the tokio-free `perry_ffi_spawn_blocking` runs on it through `turnloop_pool::submit_long`.", - "issue": "unfiled \u2014 P8; PerryTS/turnloop#42 closed (Occupancy::Long, turnloop 0.1.0-alpha.5+); lane L split the bridge from the runtime (#11115) and moved fetch / the TLS server / the net+ws adapters off tokio (part 2)", + "surface": "the tokio HALF of the async bridge only (turnloop P8 lane L split it out): `common::tokio_bridge` \u2014 the current-thread `RUNTIME`, its wait-driver tick, `spawn` / `spawn_for_promise*` \u2014 plus the `perry_ffi_spawn_async` / `perry_ffi_spawn_blocking_with_reactor` C ABI and `perry_ffi_spawn_blocking`'s tokio-pool arm. The promise bridge itself (`common::async_bridge`'s settle queue and pump, and the promise / pool / blocking `perry_ffi_*` shims) is tokio-free under the `async-bridge` feature, which is what the auto-optimize driver now force-enables. `web-fetch`, the `node:tls` server (`tls-runtime`, on turnloop handles since lane L part 2) and the external net / ws adapters compile against that bridge only. The bundled `net` / `ws` sockets that were the last in-crate tokio users (with `tls_stream.rs`, their TLS stream) were deleted in lane L4.", + "reached_when": "the `async-runtime` feature, which is selected only by (1) `full`, which lists it explicitly since lane L4 deleted bundled `net` / `ws` (the tokio-socket features it used to come through); or (2) the auto-optimize driver, for every wrapper that still bundles tokio (`binding_bundles_tokio`) \u2014 an empty set since #11337 deleted perry-ext-mongodb, the last member, together with its module-name rule. pg / mysql2 have had no wrapper since #10677 / #10680, lane L3 deleted their unreachable module-name rule, and mongodb followed in #11337; all three npm packages compile from source over `net` / `tls`, so an auto-optimized pg-, mysql2- or mongodb-only program links no tokio either. So every PERRY_NO_AUTO_OPTIMIZE / prebuilt-archive build still links tokio, and nothing else does: lane L4 routes `net` / `ws` / `tls` to perry-ext-net / perry-ext-ws even with PERRY_DISABLE_WELL_KNOWN=1, so that mode no longer links tokio for them. Lane L part 2 took `web-fetch`, `tls-runtime`, `external-tls-server`, `external-net-tls`, `external-net-pump`, `external-ws-pump` and (after lane D, #11265) `external-http-server-pump` / `external-http-client-pump` off it (the `node:tls` server's sockets are turnloop handles, and perry-ext-net / perry-ext-ws carry no tokio), so an auto-optimized program whose network imports are only `fetch`, `net`, `tls` (both route to perry-ext-net, with the TLS server here), `ws` and `http` / `https` / `http2` links no tokio \u2014 on top of part 1's crypto, bcrypt, argon2, zlib, readline, worker_threads, timers and UI programs, and lane K's `container`.", + "blocker": "each selector in `reached_when` has to go; then `tokio_bridge.rs` and the three `cfg(feature = \"async-runtime\")` shims in `perry_ffi_async.rs` delete whole and `async-runtime` collapses into `async-bridge` \u2014 nothing in `async_bridge.rs` has to move. In order: (1) B is DONE: no db decline path is left \u2014 pg's and mysql2's went with their wrappers (#10677 / #10680), and perry-ext-mongodb's (`Handle::current().block_on` inside `perry_ffi_spawn_blocking`) went when #11337 deleted that wrapper. (2) is DONE too: lane L4 deleted the two bundled socket modules (`net/`, `ws.rs`) instead of porting them \u2014 perry-ext-net / perry-ext-ws were strict supersets of their C surface. What is left is dropping `async-runtime` from `full`, then the deletions above. K took `container` off tokio (`perry_container_compose::rt`). PerryTS/turnloop#42 is NOT a blocker: `Occupancy::Long` shipped in turnloop 0.1.0-alpha.5, and the tokio-free `perry_ffi_spawn_blocking` runs on it through `turnloop_pool::submit_long`.", + "issue": "unfiled \u2014 P8; PerryTS/turnloop#42 closed (Occupancy::Long, turnloop 0.1.0-alpha.5+); lane L split the bridge from the runtime (#11115) and moved fetch / the TLS server / the net+ws adapters off tokio (part 2); lane L4 deleted bundled net / ws", "plan": "L" }, { @@ -66,7 +66,7 @@ "perry-container-compose": 27, "perry-ext-ads": 5, "perry-ffi": 2, - "perry-stdlib": 62, + "perry-stdlib": 21, "perry-ui-gtk4": 6 } }