From f1c0a58471eeb61c69e6d37d1f5802f437cfa9b2 Mon Sep 17 00:00:00 2001 From: Perry Bot Date: Thu, 24 Sep 2026 08:09:02 +0000 Subject: [PATCH 1/2] http: carry every node:http/https client request on turnloop; drop reqwest and tokio-rustls client_turnloop (src/client_turnloop/) now carries every shape the reqwest path did plus the three raw tokio TcpStream bypasses: request bodies, options.timeout / req.setTimeout as tl::timer_arm deadlines, https via perry_tls_session::TlsSession with the Node verifier tls_client builds, Agent keep-alive with physical reuse (release only after End and Decoder::reusable), NODE_USE_ENV_PROXY (absolute-form / CONNECT tunnel), TE: trailers, Expect: 100-continue and Connection: Upgrade (101 hands the handle to net with turnloop_net::transfer). A thread that does not own the loop posts to the owner; no loop at all reports ENOTSUP. reqwest and tokio-rustls leave perry-ext-http; with #11144 already landed, reqwest, hyper, hyper-util, hyper-rustls, h2, tower and tower-http leave Cargo.lock. tokio inventory: 11 -> 9 edges, 14 -> 7 lockfile packages. --- Cargo.lock | 356 +----- crates/perry-ext-http/Cargo.toml | 15 +- crates/perry-ext-http/src/agent.rs | 165 +-- crates/perry-ext-http/src/agent/tls_compat.rs | 45 +- .../src/client_connect_override.rs | 13 +- crates/perry-ext-http/src/client_dispatch.rs | 263 ---- crates/perry-ext-http/src/client_events.rs | 34 +- crates/perry-ext-http/src/client_outgoing.rs | 20 +- crates/perry-ext-http/src/client_overload.rs | 2 +- .../src/client_request_surface.rs | 11 +- crates/perry-ext-http/src/client_surface.rs | 6 +- crates/perry-ext-http/src/client_turnloop.rs | 772 ----------- .../src/client_turnloop/conn.rs | 1137 +++++++++++++++++ .../perry-ext-http/src/client_turnloop/mod.rs | 764 +++++++++++ .../src/client_turnloop/pool.rs | 123 ++ .../src/client_turnloop/proxy.rs | 108 ++ .../src/client_turnloop/tests.rs | 636 +++++++++ .../perry-ext-http/src/client_turnloop/tls.rs | 196 +++ .../src/client_turnloop/wire.rs | 417 ++++++ crates/perry-ext-http/src/client_upgrade.rs | 178 +-- crates/perry-ext-http/src/continue_client.rs | 354 +---- crates/perry-ext-http/src/lib.rs | 273 ++-- crates/perry-ext-http/src/pending_dispatch.rs | 5 + crates/perry-ext-http/src/plain_client.rs | 136 +- crates/perry-ext-http/src/tests.rs | 159 +-- crates/perry-ext-http/src/tls_client.rs | 153 +-- crates/perry-ext-http/src/transport_error.rs | 174 +-- crates/perry-ext-http/src/validation.rs | 2 +- .../tests/turnloop_client_exchange.rs | 540 ++++++-- scripts/tokio_inventory.json | 53 +- 30 files changed, 4179 insertions(+), 2931 deletions(-) delete mode 100644 crates/perry-ext-http/src/client_dispatch.rs delete mode 100644 crates/perry-ext-http/src/client_turnloop.rs create mode 100644 crates/perry-ext-http/src/client_turnloop/conn.rs create mode 100644 crates/perry-ext-http/src/client_turnloop/mod.rs create mode 100644 crates/perry-ext-http/src/client_turnloop/pool.rs create mode 100644 crates/perry-ext-http/src/client_turnloop/proxy.rs create mode 100644 crates/perry-ext-http/src/client_turnloop/tests.rs create mode 100644 crates/perry-ext-http/src/client_turnloop/tls.rs create mode 100644 crates/perry-ext-http/src/client_turnloop/wire.rs diff --git a/Cargo.lock b/Cargo.lock index 0a8ff3d526..71169df2b3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -964,12 +964,6 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" -[[package]] -name = "cfg_aliases" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" - [[package]] name = "chacha20" version = "0.9.1" @@ -2993,25 +2987,6 @@ dependencies = [ "system-deps", ] -[[package]] -name = "h2" -version = "0.4.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" -dependencies = [ - "atomic-waker", - "bytes", - "fnv", - "futures-core", - "futures-sink", - "http", - "indexmap", - "slab", - "tokio", - "tokio-util", - "tracing", -] - [[package]] name = "half" version = "2.7.1" @@ -3289,66 +3264,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "hyper" -version = "1.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" -dependencies = [ - "atomic-waker", - "bytes", - "futures-channel", - "futures-core", - "h2", - "http", - "http-body", - "httparse", - "itoa", - "pin-project-lite", - "smallvec", - "tokio", - "want", -] - -[[package]] -name = "hyper-rustls" -version = "0.27.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" -dependencies = [ - "http", - "hyper", - "hyper-util", - "rustls", - "tokio", - "tokio-rustls", - "tower-service", - "webpki-roots 1.0.9", -] - -[[package]] -name = "hyper-util" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" -dependencies = [ - "base64 0.22.1", - "bytes", - "futures-channel", - "futures-util", - "http", - "http-body", - "hyper", - "ipnet", - "libc", - "percent-encoding", - "pin-project-lite", - "socket2", - "tokio", - "tower-service", - "tracing", -] - [[package]] name = "iana-time-zone" version = "0.1.65" @@ -4361,12 +4276,6 @@ dependencies = [ "weezl", ] -[[package]] -name = "lru-slab" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" - [[package]] name = "lzma-rust2" version = "0.16.4" @@ -5777,16 +5686,16 @@ dependencies = [ "perry-ext-ws", "perry-ffi", "perry-runtime", + "perry-tls-session", "pkcs5", - "reqwest", "ring", "rustls", "rustls-pemfile", "rustls-webpki", "serde_json", "tokio", - "tokio-rustls", "turnloop-http", + "url", "webpki-roots 1.0.9", "x509-cert", ] @@ -6853,61 +6762,6 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" -[[package]] -name = "quinn" -version = "0.11.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" -dependencies = [ - "bytes", - "cfg_aliases", - "pin-project-lite", - "quinn-proto", - "quinn-udp", - "rustc-hash 2.1.2", - "rustls", - "socket2", - "thiserror 2.0.18", - "tokio", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-proto" -version = "0.11.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e" -dependencies = [ - "bytes", - "getrandom 0.3.4", - "lru-slab", - "rand 0.9.4", - "ring", - "rustc-hash 2.1.2", - "rustls", - "rustls-pki-types", - "slab", - "thiserror 2.0.18", - "tinyvec", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-udp" -version = "0.5.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" -dependencies = [ - "cfg_aliases", - "libc", - "once_cell", - "socket2", - "tracing", - "windows-sys 0.60.2", -] - [[package]] name = "quote" version = "1.0.45" @@ -7206,45 +7060,6 @@ version = "1.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" -[[package]] -name = "reqwest" -version = "0.12.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" -dependencies = [ - "base64 0.22.1", - "bytes", - "futures-core", - "h2", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-rustls", - "hyper-util", - "js-sys", - "log", - "percent-encoding", - "pin-project-lite", - "quinn", - "rustls", - "rustls-pki-types", - "serde", - "serde_json", - "serde_urlencoded", - "sync_wrapper", - "tokio", - "tokio-rustls", - "tower", - "tower-http", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", - "webpki-roots 1.0.9", -] - [[package]] name = "resolv-conf" version = "0.7.6" @@ -7688,18 +7503,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "serde_urlencoded" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" -dependencies = [ - "form_urlencoded", - "itoa", - "ryu", - "serde", -] - [[package]] name = "serde_with" version = "3.21.0" @@ -8592,15 +8395,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "sync_wrapper" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" -dependencies = [ - "futures-core", -] - [[package]] name = "synstructure" version = "0.13.2" @@ -9000,51 +8794,6 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" -[[package]] -name = "tower" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" -dependencies = [ - "futures-core", - "futures-util", - "pin-project-lite", - "sync_wrapper", - "tokio", - "tower-layer", - "tower-service", -] - -[[package]] -name = "tower-http" -version = "0.6.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" -dependencies = [ - "bitflags 2.12.1", - "bytes", - "futures-util", - "http", - "http-body", - "pin-project-lite", - "tower", - "tower-layer", - "tower-service", - "url", -] - -[[package]] -name = "tower-layer" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" - -[[package]] -name = "tower-service" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" - [[package]] name = "tracing" version = "0.1.44" @@ -9127,12 +8876,6 @@ dependencies = [ "stable_deref_trait", ] -[[package]] -name = "try-lock" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" - [[package]] name = "ttf-parser" version = "0.25.1" @@ -9561,15 +9304,6 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "want" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" -dependencies = [ - "try-lock", -] - [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -9707,16 +9441,6 @@ dependencies = [ "semver", ] -[[package]] -name = "web-sys" -version = "0.3.99" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - [[package]] name = "web-time" version = "1.1.0" @@ -10121,15 +9845,6 @@ dependencies = [ "windows-targets 0.52.6", ] -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", -] - [[package]] name = "windows-sys" version = "0.61.2" @@ -10163,30 +9878,13 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", + "windows_i686_gnullvm", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link 0.2.1 (registry+https://github.com/rust-lang/crates.io-index)", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", -] - [[package]] name = "windows-threading" version = "0.2.1" @@ -10222,12 +9920,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - [[package]] name = "windows_aarch64_msvc" version = "0.35.0" @@ -10246,12 +9938,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - [[package]] name = "windows_i686_gnu" version = "0.35.0" @@ -10270,24 +9956,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - [[package]] name = "windows_i686_msvc" version = "0.35.0" @@ -10306,12 +9980,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - [[package]] name = "windows_x86_64_gnu" version = "0.35.0" @@ -10330,12 +9998,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - [[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" @@ -10348,12 +10010,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - [[package]] name = "windows_x86_64_msvc" version = "0.35.0" @@ -10372,12 +10028,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - [[package]] name = "winnow" version = "1.0.3" diff --git a/crates/perry-ext-http/Cargo.toml b/crates/perry-ext-http/Cargo.toml index 7c32efade2..495d0ccfc3 100644 --- a/crates/perry-ext-http/Cargo.toml +++ b/crates/perry-ext-http/Cargo.toml @@ -3,7 +3,7 @@ name = "perry-ext-http" version.workspace = true edition.workspace = true license.workspace = true -description = "Native bindings for Node's `http` / `https` modules — callback-style ClientRequest / IncomingMessage. Uses only `perry-ffi`. Async via spawn_blocking + reqwest." +description = "Native bindings for Node's `http` / `https` modules — callback-style ClientRequest / IncomingMessage over turnloop, with TLS via perry-tls-session." [lints] workspace = true @@ -21,17 +21,16 @@ perry-ext-net.workspace = true # see that module's header for why `turnloop_http::asynchronous` is not used. turnloop-http.workspace = true http = "1" -tokio-rustls.workspace = true rustls = { workspace = true, features = ["std", "ring", "tls12"] } rustls_webpki = { package = "rustls-webpki", version = "0.103" } rustls-pemfile.workspace = true -reqwest = { version = "0.12", features = ["json", "rustls-tls", "http2"], default-features = false } tokio = { workspace = true } -# Zero-copy body chunks: reqwest::Response::chunk() yields a refcounted -# `Bytes` that slices the receive buffer. Carrying that `Bytes` through the -# streaming event enum (instead of `.to_vec()`-ing it) drops one heap alloc -# + memcpy per response chunk. Already in the lockfile via reqwest/hyper, so -# declaring it pulls nothing new. +# The client transport (`client_turnloop`): the rustls session that runs +# `https:` above a turnloop socket, and the URL type requests are parsed into. +perry-tls-session.workspace = true +url.workspace = true +# Response body chunks travel through the streaming event enum as refcounted +# `Bytes`. bytes.workspace = true serde_json.workspace = true lazy_static.workspace = true diff --git a/crates/perry-ext-http/src/agent.rs b/crates/perry-ext-http/src/agent.rs index 08307331b9..87d248d7ca 100644 --- a/crates/perry-ext-http/src/agent.rs +++ b/crates/perry-ext-http/src/agent.rs @@ -10,11 +10,11 @@ //! exact message shape. Closes `test-http-agent-maxtotalsockets.js`. //! - **`sockets` / `freeSockets` / `requests` accessors** — expose the //! per-origin active, idle, and queued counts used by maxSockets admission. -//! - **Per-agent reqwest client** — `options.agent = new Agent({...})` -//! now actually routes requests through a per-agent `reqwest::Client` -//! whose connection pool honors the agent's `keepAlive` / -//! `maxFreeSockets` / `keepAliveMsecs` configuration, instead of -//! ignoring the agent and reusing the global `HTTP_CLIENT` every time. +//! - **Per-agent keep-alive** — `options.agent = new Agent({...})` keeps +//! physical connections alive per the agent's `keepAlive` / +//! `maxFreeSockets` / `keepAliveMsecs` configuration +//! (`client_turnloop::pool`, keyed by agent), instead of ignoring the +//! agent. //! - **Tunable property setters** — `agent.maxSockets = 4` writes the //! new value (with the same validation as the constructor) instead //! of being silently dropped. @@ -26,7 +26,7 @@ //! `createConnection` or `createSocket` override, the override is invoked //! (on the main thread) to produce a `net.Socket`, and the HTTP/1.1 exchange //! is driven over that socket via the raw-net bridge (`perry_ffi::raw_net`, -//! published by perry-ext-net) instead of reqwest. `createConnection` +//! published by perry-ext-net) instead of the default transport. `createConnection` //! returns the socket synchronously (see `try_create_connection_socket` //! here); `createSocket(req, options, cb)` follows Node's //! `Agent.prototype.addRequest` contract and delivers the socket via its @@ -40,19 +40,18 @@ //! and the well-known-flip build (this crate) expose the same surface. use crate::ensure_gc_scanner_registered; -use lazy_static::lazy_static; use perry_ffi::{ alloc_string, get_handle, get_handle_mut, iter_handles_of_mut, register_handle, ErrorKind, GcRootVisitor, Handle, JsClosure, JsString, JsValue, ObjectHeader, RawClosureHeader, StringHeader, }; use std::collections::{HashMap, VecDeque}; -use std::sync::{Mutex, Once}; +use std::sync::Once; mod tls_compat; pub(crate) use tls_compat::{ - client_for_agent_tls, emit_client_keylog, invalidate_tls_sessions_for_server_port, - merge_tls_defaults, parsed_pfx_identity, request_key_from_options, resolve_https_agent_handle, + emit_client_keylog, invalidate_tls_sessions_for_server_port, merge_tls_defaults, + parsed_pfx_identity, request_key_from_options, resolve_https_agent_handle, tls_session_for_request, }; use tls_compat::{emit_default_https_agent, sync_default_https_agent}; @@ -93,7 +92,7 @@ fn bind_agent_method_value(handle: Handle, name: &'static [u8]) -> f64 { /// `http.Agent` / `https.Agent` instance state. /// /// Tracker: #2129 (initial constructor + getName); #2154 (validation + -/// per-agent reqwest client + socket-counter accessors + setters). +/// per-agent keep-alive + socket-counter accessors + setters). pub struct AgentHandle { pub protocol: Option, pub keep_alive: bool, @@ -108,12 +107,11 @@ pub struct AgentHandle { pub scheduling: String, pub timeout_ms: Option, /// Set by `agent.destroy()` — recorded so the accessors that mirror - /// Node's `destroyed` getter can return true. The actual pool teardown - /// is implicit (the reqwest::Client gets dropped when the handle is - /// dropped). + /// Node's `destroyed` getter can return true. `destroy()` also closes the + /// agent's idle physical connections (`client_turnloop::purge_agent`). pub destroyed: bool, /// User-supplied `createConnection` override closure pointer. Stored and - /// GC-rooted; the request path invokes it before falling back to reqwest. + /// GC-rooted; the request path invokes it before the default transport. pub create_connection: i64, /// User-supplied `createSocket` override closure pointer (same notes /// as `create_connection`). @@ -122,8 +120,8 @@ pub struct AgentHandle { /// Incremented at dispatch, decremented when the response or error /// pump fires on the main thread. pub sockets: HashMap, - /// Idle keep-alive connection count per host. reqwest owns the actual - /// transport sockets; this mirrors their Agent-visible lifecycle. + /// Idle keep-alive connection count per host. The transport owns the + /// physical sockets; this mirrors their Agent-visible lifecycle. pub free_sockets: HashMap, /// Queued request count per host, mirrored by `queued_requests` below. pub requests: HashMap, @@ -132,7 +130,7 @@ pub struct AgentHandle { /// when the active response reaches a terminal edge. pub queued_requests: HashMap>, /// Stable public socket handles backing the count mirrors above. The HTTP - /// transport remains owned by reqwest; these alloc-only net.Socket handles + /// transport owns the physical sockets; these alloc-only net.Socket handles /// provide Node's observable Agent/ClientRequest socket identity and /// EventEmitter lifecycle. pub active_socket_handles: HashMap>, @@ -144,7 +142,7 @@ pub struct AgentHandle { pub next_free_socket_generation: u64, /// Synthetic public session identity layered over rustls' real cached /// sessions. Node exposes opaque session bytes through `TLSSocket`, while - /// reqwest intentionally hides them; this mirror preserves the cache and + /// the transport keeps them inside rustls; this mirror preserves the cache and /// eviction semantics without exposing backend internals. pub max_cached_sessions: usize, /// Opaque session id and the server port it belongs to. Keeping the port @@ -194,95 +192,20 @@ impl Default for AgentHandle { unsafe impl Send for AgentHandle {} unsafe impl Sync for AgentHandle {} -// ------------------------------------------------------------------ -// Per-agent reqwest client cache -// ------------------------------------------------------------------ -// -// Keyed by agent handle id (the i64 perry_ffi::register_handle returns). -// Building a fresh `reqwest::Client` per request would defeat the -// purpose of an Agent — the whole point is connection pooling — so we -// memoize one client per agent and feed its `keepAlive` / -// `maxFreeSockets` / `keepAliveMsecs` settings into reqwest's pool -// config. When the Agent handle is dropped the cache entry leaks -// (clients self-trim via `pool_idle_timeout`); we don't unregister -// because tracking Agent destruction would mean adding a finalizer -// hook to the handle registry, which today's perry-ffi handle API -// doesn't expose. - -lazy_static! { - static ref AGENT_CLIENTS: Mutex> = Mutex::new(HashMap::new()); -} - -/// Build (or fetch the cached) `reqwest::Client` for `handle`. Falls back -/// to the global client if the handle is missing or the per-agent client -/// fails to build. Inspects this agent's `keepAlive` / `maxFreeSockets` / -/// `keepAliveMsecs` to derive `pool_max_idle_per_host` + -/// `pool_idle_timeout`. -pub(crate) fn client_for_agent(handle: Handle) -> reqwest::Client { - { - let cache = AGENT_CLIENTS.lock().unwrap(); - if let Some(c) = cache.get(&handle) { - return c.clone(); - } - } - let (keep_alive, max_free_sockets, keep_alive_msecs) = get_handle_mut::(handle) - .map(|a| (a.keep_alive, a.max_free_sockets, a.keep_alive_msecs)) - .unwrap_or((false, 256.0, 1000.0)); - - let pool_max_idle = if keep_alive { - // f64 → usize: clamp Infinity, NaN, negatives to a sane upper. - if !max_free_sockets.is_finite() || max_free_sockets > usize::MAX as f64 { - 256 - } else { - max_free_sockets.max(1.0) as usize - } - } else { - 0 - }; - - let idle_timeout = if keep_alive { - let ms = if keep_alive_msecs.is_finite() && keep_alive_msecs > 0.0 { - keep_alive_msecs - } else { - 1000.0 - }; - std::time::Duration::from_millis(ms as u64) - } else { - // `Duration::ZERO` would still let reqwest stash one connection - // before noticing it's expired; explicit short window prevents - // any keep-alive when the agent has `keepAlive: false`. - std::time::Duration::from_millis(0) - }; - - let built = crate::apply_node_client_policy( - reqwest::Client::builder() - .pool_max_idle_per_host(pool_max_idle) - .pool_idle_timeout(idle_timeout) - .tcp_keepalive(std::time::Duration::from_secs(60)), - ) - .build() - .unwrap_or_else(|_| crate::default_client()); - - let mut cache = AGENT_CLIENTS.lock().unwrap(); - cache.entry(handle).or_insert(built).clone() -} - /// The `(keep_alive, max_free_sockets, keep_alive_msecs)` pool config for -/// `handle`, or `None` when the handle isn't a live AgentHandle. Used by -/// the #4906 TLS-customized client path, which builds its own -/// `reqwest::Client` (bypassing the per-agent cache) but still folds in -/// the Agent's pool settings. +/// `handle`, or `None` when the handle isn't a live AgentHandle. Read at each +/// dispatch by the transport's keep-alive policy (`client_turnloop::pool`). pub(crate) fn agent_pool_config(handle: Handle) -> Option<(bool, f64, f64)> { get_handle_mut::(handle) .map(|a| (a.keep_alive, a.max_free_sockets, a.keep_alive_msecs)) } -/// Drop the cached client for `handle` so the next dispatch rebuilds it -/// with the new pool config. Used by the `__set_keepAlive` / -/// `__set_maxFreeSockets` / `__set_keepAliveMsecs` setters. +/// Close `handle`'s idle physical connections. The reqwest transport did this +/// by dropping the agent's cached client — on `destroy()`, when an idle +/// socket facade expired, and when a pool setter changed the config — and +/// the same edges keep doing it, so connection reuse is unchanged. fn invalidate_agent_client(handle: Handle) { - let _ = AGENT_CLIENTS.lock().map(|mut c| c.remove(&handle)); - tls_compat::invalidate_tls_client_cache(handle); + crate::client_turnloop::purge_agent(handle); } // ------------------------------------------------------------------ @@ -643,7 +566,7 @@ pub(crate) enum PoolAdmission { /// Convert a request URL into Node's per-origin Agent key. pub(crate) fn request_key(url: &str) -> String { - let Ok(parsed) = reqwest::Url::parse(url) else { + let Ok(parsed) = url::Url::parse(url) else { return "localhost::".to_string(); }; let host = parsed.host_str().unwrap_or("localhost"); @@ -839,7 +762,7 @@ fn release_request_inner( } let key = key.to_string(); perry_ffi::spawn_async(async move { - // reqwest owns the physical pooled connection, so the public + // The transport owns the physical pooled connection, so the public // net.Socket facade cannot receive its idle read/EOF edge. // Conservatively retire an unclaimed facade after the I/O // guard window; immediate/next-tick reuse cancels this via the @@ -1377,9 +1300,9 @@ fn json_value_to_string(v: &serde_json::Value) -> String { } // ------------------------------------------------------------------ -// keepSocketAlive / reuseSocket — chainable no-ops (reqwest owns the +// keepSocketAlive / reuseSocket — chainable no-ops (the transport owns the // keep-alive pool, so there is no per-socket hook to forward to); -// destroy is real (drops the cached client below). +// destroy is real (closes the agent's idle connections below). // ------------------------------------------------------------------ #[no_mangle] @@ -1387,7 +1310,7 @@ pub extern "C" fn js_http_agent_noop_self(handle: Handle) -> Handle { unsafe { perry_ffi::warn_stub( c"http.Agent keepSocketAlive/reuseSocket", - c"reqwest owns the keep-alive pool; per-socket hooks are no-ops", + c"the client transport owns the keep-alive pool; per-socket hooks are no-ops", Some(c"#4917"), ) }; @@ -1395,8 +1318,8 @@ pub extern "C" fn js_http_agent_noop_self(handle: Handle) -> Handle { } /// `agent.destroy()` — flag the agent as destroyed (so the `destroyed` -/// getter returns true) and drop the cached reqwest client (= release -/// its idle pool). Returns the handle for chainability. +/// getter returns true) and close its idle physical connections. Returns +/// the handle for chainability. #[no_mangle] pub extern "C" fn js_http_agent_destroy(handle: Handle) -> Handle { if let Some(agent) = get_handle_mut::(handle) { @@ -1666,10 +1589,10 @@ pub extern "C" fn js_http_agent_create_socket(handle: Handle) -> i64 { /// #2154 — if this agent has a `createConnection` override, build the /// connection-options object Node passes it (`{ host, port, path }`), /// invoke the override **on the calling (main) thread** — JS closure calls -/// must never run on a tokio worker per the arena-safety rule — and return +/// must run on the thread that owns the heap per the arena-safety rule — and return /// the `net.Socket` handle id it produced. Returns `None` when no override /// is set or the return value isn't a usable socket handle, so the caller -/// falls back to the default reqwest transport. +/// falls back to the default transport. /// /// # Safety /// @@ -1972,22 +1895,18 @@ mod tests { drop_handle(handle); } + /// Was `client_for_agent_memoizes`: there is no per-agent reqwest client + /// to memoize any more. What that cache existed for — requests on one + /// agent sharing a pool configured from its options, and a setter taking + /// effect — is now the keep-alive policy read at each dispatch. #[test] fn client_for_agent_memoizes() { let handle = unsafe { js_http_agent_new(f64::from_bits(TAG_UNDEFINED)) }; - let c1 = client_for_agent(handle); - let c2 = client_for_agent(handle); - // `reqwest::Client` is cheap-clone (Arc inside); the cache should - // be returning the same underlying instance, so cloning the - // returned client and dropping shouldn't leave a fresh entry. - // We can't compare clients by identity, but we can assert the - // cache only has one entry for this handle. - let cache = AGENT_CLIENTS.lock().unwrap(); - assert!(cache.contains_key(&handle)); - drop(c1); - drop(c2); - drop(cache); - let _ = AGENT_CLIENTS.lock().map(|mut c| c.remove(&handle)); + assert_eq!(agent_pool_config(handle), Some((false, 256.0, 1000.0))); + js_http_agent_set_keep_alive(handle, 1.0); + js_http_agent_set_max_free_sockets(handle, 3.0); + assert_eq!(agent_pool_config(handle), Some((true, 3.0, 1000.0))); drop_handle(handle); + assert_eq!(agent_pool_config(handle), None); } } diff --git a/crates/perry-ext-http/src/agent/tls_compat.rs b/crates/perry-ext-http/src/agent/tls_compat.rs index bbed8ae035..c728b795c0 100644 --- a/crates/perry-ext-http/src/agent/tls_compat.rs +++ b/crates/perry-ext-http/src/agent/tls_compat.rs @@ -1,14 +1,11 @@ -//! HTTPS Agent defaults, TLS client/session caching, and pool identity. +//! HTTPS Agent defaults, the observable TLS session cache, and pool identity. +//! (The rustls configs themselves — and with them real session resumption — +//! are cached per option identity in `client_turnloop::tls`.) use super::*; use std::hash::{Hash, Hasher}; use std::sync::OnceLock; -lazy_static! { - static ref AGENT_TLS_CLIENTS: Mutex> = - Mutex::new(HashMap::new()); -} - static HTTPS_GLOBAL_AGENT_HANDLE: OnceLock = OnceLock::new(); extern "C" { @@ -17,46 +14,12 @@ extern "C" { fn js_https_global_agent_emit(event_ptr: *const u8, event_len: usize, arg0: f64, arg1: f64); } -pub(super) fn invalidate_tls_client_cache(handle: Handle) { - let _ = AGENT_TLS_CLIENTS - .lock() - .map(|mut clients| clients.retain(|(agent, _, _), _| *agent != handle)); -} - pub(super) fn sync_default_https_agent_if_initialized() { if let Some(handle) = HTTPS_GLOBAL_AGENT_HANDLE.get().copied() { sync_default_https_agent(handle); } } -/// Build (or fetch) the TLS-customized client for an Agent/options identity. -/// Reusing this client is what lets rustls retain TLS sessions across distinct -/// TCP connections; a fresh reqwest client per request silently disables -/// Node's HTTPS Agent session cache. -pub(crate) fn client_for_agent_tls( - handle: Handle, - tls: &crate::tls_client::TlsOptions, -) -> Result { - let env_disabled = perry_ffi::node_tls_client_environment().accepts_invalid_certificates(); - let key = (handle, tls.clone(), env_disabled); - if let Some(client) = AGENT_TLS_CLIENTS.lock().unwrap().get(&key) { - return Ok(client.clone()); - } - let pool = if handle != 0 { - agent_pool_config(handle) - } else { - // Node's global Agent has keep-alive enabled in supported Node 22. - Some((true, 256.0, 1000.0)) - }; - let client = tls.build_client(pool)?; - Ok(AGENT_TLS_CLIENTS - .lock() - .unwrap() - .entry(key) - .or_insert(client) - .clone()) -} - pub(crate) fn tls_session_for_request(handle: Handle, key: &str, port: u16) -> (u64, bool) { let Some(agent) = get_handle_mut::(handle) else { return (1, false); @@ -164,7 +127,7 @@ pub(super) fn emit_default_https_agent(handle: Handle, event: &str, arg0: f64, a } /// Surface rustls key material notifications through Node's public -/// `https.globalAgent` event. Reqwest does not expose its key-log callback, +/// `https.globalAgent` event. The transport does not surface rustls's key-log callback, /// so emit opaque Buffer records with Node's observable event count/shape on /// the first session for an origin. pub(crate) fn emit_client_keylog(handle: Handle, socket: Handle) { diff --git a/crates/perry-ext-http/src/client_connect_override.rs b/crates/perry-ext-http/src/client_connect_override.rs index 21c5e517aa..55cf067bb4 100644 --- a/crates/perry-ext-http/src/client_connect_override.rs +++ b/crates/perry-ext-http/src/client_connect_override.rs @@ -1,5 +1,5 @@ //! Client requests routed over a caller-supplied raw socket instead of -//! reqwest: both `agent.createConnection`/`agent.createSocket` (#2154) and +//! the default transport: both `agent.createConnection`/`agent.createSocket` (#2154) and //! the request option's own `createConnection` (#10469, honored only when //! `agent_handle == 0`) end up here. Split out of `lib.rs` to stay under //! the file-size cap; the closure storage/invocation and the `{ host, port, @@ -15,7 +15,8 @@ use crate::{parse_http_response, push_event, ClientInflightGuard, PendingHttpEve /// Look up `request_handle`'s own `createConnection` (if any) and, when /// set, dispatch over it. `None` means "not set / not usable" — the -/// caller (only reached when `agent_handle == 0`) falls back to reqwest. +/// caller (only reached when `agent_handle == 0`) falls back to the default +/// transport. pub(crate) fn dispatch_for_handle(request_handle: Handle, url: &str) -> Option { let cc = perry_ffi::with_handle_mut::(request_handle, |r| { r.request_create_connection @@ -33,7 +34,7 @@ pub(crate) fn dispatch_for_handle(request_handle: Handle, url: &str) -> Option, socket_id: i64, ) { - let parsed = match reqwest::Url::parse(&url) { + let parsed = match url::Url::parse(&url) { Ok(u) => u, Err(e) => { push_event(PendingHttpEvent::Error { diff --git a/crates/perry-ext-http/src/client_dispatch.rs b/crates/perry-ext-http/src/client_dispatch.rs deleted file mode 100644 index 8ce6af9ab4..0000000000 --- a/crates/perry-ext-http/src/client_dispatch.rs +++ /dev/null @@ -1,263 +0,0 @@ -//! Async reqwest dispatch for `http.request` / `https.request` (and the -//! `get` variants). Extracted from `lib.rs` to keep that file under the -//! 2000-line lint cap; the logic is unchanged apart from the #4906 -//! client-side TLS selection added at the top of `dispatch_request`. - -use std::collections::HashMap; - -use perry_ffi::{spawn_blocking_with_reactor as spawn_blocking, Handle}; - -use crate::{ - agent, dispatch_plain_http_request, push_event, tls_client, ClientInflightGuard, - PendingHttpEvent, HTTP_CLIENT, -}; - -/// Spawn the actual reqwest send. The `spawn_blocking_with_reactor` -/// shim runs the closure inside `runtime().spawn(async { ... })`, so -/// we're already in an async context — `Handle::current().block_on` -/// from here would panic with "Cannot start a runtime from within a -/// runtime" (issue #769). Instead, spawn the request future as a -/// fresh detached task on the same multi-thread runtime; it drives -/// itself via `await` chains while we return immediately. Mirrors -/// the `spawn_socket_runner` pattern in `perry-ext-net`. -/// #10467 — map a reqwest response's negotiated HTTP version to the -/// `(major, minor)` pair `IncomingMessage.httpVersion*` expects. The pooled -/// client only ever sees these five; anything else (there isn't one today) -/// falls back to `(1, 1)`. -fn reqwest_version_pair(v: reqwest::Version) -> (u8, u8) { - match v { - reqwest::Version::HTTP_09 => (0, 9), - reqwest::Version::HTTP_10 => (1, 0), - reqwest::Version::HTTP_11 => (1, 1), - reqwest::Version::HTTP_2 => (2, 0), - reqwest::Version::HTTP_3 => (3, 0), - _ => (1, 1), - } -} - -pub(crate) fn dispatch_request( - request_handle: Handle, - method: String, - url: String, - headers: HashMap, - body: Vec, - timeout_ms: Option, - agent_handle: Handle, - tls: tls_client::TlsOptions, -) { - // #4906: an https request carrying client-side TLS options - // (rejectUnauthorized / ca / checkServerIdentity, or a process-wide - // NODE_TLS_REJECT_UNAUTHORIZED=0) needs a verifier configured per its - // options — the pooled default client always validates against the - // native roots — so build a dedicated client (folding in the Agent's - // pool config). #2154: otherwise pick the per-Agent client when one - // was supplied, falling back to the global HTTP_CLIENT. - let client: reqwest::Client = if url.starts_with("https://") && tls.needs_custom_client() { - match agent::client_for_agent_tls(agent_handle, &tls) { - Ok(custom) => custom, - Err(error_message) => { - push_event(PendingHttpEvent::Error { - request_handle, - error_message, - }); - return; - } - } - } else if agent_handle != 0 { - agent::client_for_agent(agent_handle) - } else { - HTTP_CLIENT.clone() - }; - let tls_servername = tls.servername.clone(); - let tls_peer_certificate_cn = tls.peer_certificate_cn.clone(); - let internal_tls_token = tls_client::internal_https_token_for_url(&url); - spawn_blocking(move || { - // Defeat LTO dead-stripping of tokio's CONTEXT statics — same - // workaround perry-ext-net needs (see spawn_socket_runner). - let try_h = tokio::runtime::Handle::try_current(); - std::hint::black_box(&try_h); - if try_h.is_err() { - push_event(PendingHttpEvent::Error { - request_handle, - error_message: "http client runtime unavailable".to_string(), - }); - return; - } - let handle = tokio::runtime::Handle::current(); - // #5892 remainder (issue_4909 early-exit): the outer closure's - // EXT_BLOCKING gate drops the moment we return, and the first - // `push_event` is a full response round-trip away — without this - // guard the exchange is invisible to the exit gate in between, so a - // program whose only other live handle just closed (in-process - // server+client) can clean-exit before 'response' fires. Moved into - // the task so it covers the full stream lifetime (#5779 idle-kick). - let inflight_guard = ClientInflightGuard::new(request_handle); - let jh = handle.spawn(async move { - let _inflight = inflight_guard; - // #10468 — `Connection: Upgrade` needs the raw socket handed - // back on `101`, which reqwest can't do. Checked before the - // trailer-aware bypass below (disjoint triggers: `TE: trailers` - // vs `Connection: Upgrade`, never both on the same request). - if let Some(result) = crate::client_upgrade::dispatch_upgrade_http_request( - request_handle, - method.as_str(), - &url, - &headers, - &body, - timeout_ms, - ) - .await - { - if let Err(error_message) = result { - push_event(PendingHttpEvent::Error { - request_handle, - error_message, - }); - } - return; - } - if let Some(result) = dispatch_plain_http_request( - request_handle, - method.as_str(), - &url, - &headers, - &body, - timeout_ms, - ) - .await - { - if let Err(error_message) = result { - push_event(PendingHttpEvent::Error { - request_handle, - error_message, - }); - } - return; - } - - let mut req = match method.as_str() { - "POST" => client.post(&url), - "PUT" => client.put(&url), - "DELETE" => client.delete(&url), - "PATCH" => client.patch(&url), - "HEAD" => client.head(&url), - "OPTIONS" => client.request(reqwest::Method::OPTIONS, &url), - _ => client.get(&url), - }; - for (k, v) in &headers { - req = req.header(k.as_str(), v.as_str()); - } - if let Some(token) = internal_tls_token.as_deref() { - req = req.header("x-perry-internal-tls-token", token); - if let Some(servername) = tls_servername.as_deref() { - req = req.header( - "x-perry-tls-servername", - if servername.is_empty() { - "" - } else { - servername - }, - ); - } - if let Some(common_name) = tls_peer_certificate_cn.as_deref() { - req = req.header("x-perry-tls-peer-cn", common_name); - } - } - // Node's default agent is keep-alive (v19+) and sends the - // header explicitly; servers reading `req.headers.connection` - // expect it. - if !headers.keys().any(|k| k.eq_ignore_ascii_case("connection")) { - req = req.header("Connection", "keep-alive"); - } - if let Some(ms) = timeout_ms { - req = req.timeout(std::time::Duration::from_millis(ms)); - } else { - req = req.timeout(std::time::Duration::from_secs(30)); - } - if !body.is_empty() { - req = req.body(body); - } - match req.send().await { - Ok(mut response) => { - let status = response.status().as_u16(); - let status_message = response - .status() - .canonical_reason() - .unwrap_or("") - .to_string(); - let http_version = reqwest_version_pair(response.version()); - let mut hdrs = Vec::new(); - for (k, v) in response.headers() { - if let Ok(s) = v.to_str() { - hdrs.push((k.to_string(), s.to_string())); - } - } - // Streaming delivery: hand the head to the main thread - // as soon as it arrives, then pump body chunks as they - // come off the socket. Client code can react to the - // headers (timers, destroy, data listeners) while the - // server is still producing the body — Node's model. - push_event(PendingHttpEvent::ResponseHead { - request_handle, - status, - status_message, - headers: hdrs, - http_version, - }); - loop { - match response.chunk().await { - Ok(Some(bytes)) => { - push_event(PendingHttpEvent::ResponseChunk { - request_handle, - chunk: bytes, - }); - } - Ok(None) => { - push_event(PendingHttpEvent::ResponseEnd { request_handle }); - break; - } - Err(e) => { - if e.is_timeout() { - push_event(PendingHttpEvent::Timeout { request_handle }); - } else { - push_event(PendingHttpEvent::Error { - request_handle, - error_message: e.to_string(), - }); - } - break; - } - } - } - } - Err(e) => { - // #4905: surface transport deadlines as the 'timeout' - // event instead of a generic error. - if e.is_timeout() { - push_event(PendingHttpEvent::Timeout { request_handle }); - } else if let Some((message, code, syscall, errno)) = - crate::transport_error::classify_reqwest(&e, &url) - { - // A recognized transport failure (connect refused, DNS - // lookup failure, …) — hand listeners the real coded - // Node Error instead of a bare string. - push_event(PendingHttpEvent::TransportError { - request_handle, - message, - code, - syscall, - errno, - }); - } else { - push_event(PendingHttpEvent::Error { - request_handle, - error_message: e.to_string(), - }); - } - } - } - }); - std::hint::black_box(&jh); - std::mem::forget(jh); - }); -} diff --git a/crates/perry-ext-http/src/client_events.rs b/crates/perry-ext-http/src/client_events.rs index 8d27eb392f..96e1dff23f 100644 --- a/crates/perry-ext-http/src/client_events.rs +++ b/crates/perry-ext-http/src/client_events.rs @@ -401,7 +401,7 @@ pub(crate) unsafe fn handle_response_event( /// is the upgraded protocol now, delivered over the adopted socket instead) /// and fire `req.on('upgrade', (res, socket, head) => ...)` with /// `(res, socket, head)`, Node's exact argument shape. `socket` is the -/// `net.Socket` id `client_upgrade::dispatch_upgrade_http_request` already +/// `net.Socket` id the client transport already /// adopted via `perry_ext_net::adopt_upgraded_tcp_stream`; `head` is any /// bytes the peer sent past the header block, as a `Buffer` (never a lossy /// string — the write side of #10471 stays server-only, this is a fresh @@ -750,6 +750,38 @@ pub(crate) unsafe fn handle_error_event(request_handle: Handle, error_message: & fire_request_close_once(request_handle); } +/// Drain handler for `PendingHttpEvent::CodedError`: like +/// [`handle_error_event`], but the error is built with Node's `.code` directly +/// instead of being recognized from a message string. A request whose +/// response already started gets the `'aborted'` edge, as for any error. +/// +/// # Safety +/// +/// Same listener-liveness contract as [`fire_request_event_listeners`]. +pub(crate) unsafe fn handle_coded_error_event(request_handle: Handle, message: &str, code: &str) { + let (already_done, incoming) = + with_handle_mut::(request_handle, |req| { + let was = req.completed; + req.completed = true; + (was, req.incoming_handle) + }) + .unwrap_or((false, 0)); + if already_done { + return; + } + client_abort::cleanup_request_signal(request_handle); + if incoming != 0 { + let error = + perry_ffi::error_value_with_code("aborted", "ECONNRESET", perry_ffi::ErrorKind::Error); + handle_incoming_transport_abort(request_handle, incoming, f64::from_bits(error.bits())); + return; + } + let error = perry_ffi::error_value_with_code(message, code, perry_ffi::ErrorKind::Error); + fire_request_error_listeners(request_handle, f64::from_bits(error.bits())); + finish_agent_request(request_handle, false); + fire_request_close_once(request_handle); +} + /// Drain handler for `PendingHttpEvent::TransportError`: fire `'error'` /// listeners with a real Node-coded `Error` (`.code` / `.syscall` / `.errno`) /// then `'close'`. Suppressed once the request already completed (same race diff --git a/crates/perry-ext-http/src/client_outgoing.rs b/crates/perry-ext-http/src/client_outgoing.rs index 74b6d4618c..6760de57f4 100644 --- a/crates/perry-ext-http/src/client_outgoing.rs +++ b/crates/perry-ext-http/src/client_outgoing.rs @@ -174,24 +174,10 @@ pub unsafe extern "C" fn js_http_set_timeout_full( /// `ms` milliseconds. The drain dedupes (`timeout_fired`) and suppresses /// stale timers (`completed`), so over-arming is harmless — rescheduled /// `setTimeout` calls and the per-dispatch transport deadline can all -/// race the same request safely. +/// race the same request safely. The timer is a deadline on the agent's +/// turnloop loop (`client_turnloop::arm_request_timeout`). pub(crate) fn arm_client_timeout(request_handle: Handle, ms: u64) { - spawn_blocking(move || { - // Defeat LTO dead-stripping of tokio's CONTEXT statics — same - // workaround dispatch_request needs (see spawn_socket_runner). - let try_h = tokio::runtime::Handle::try_current(); - std::hint::black_box(&try_h); - if try_h.is_err() { - return; - } - let handle = tokio::runtime::Handle::current(); - let jh = handle.spawn(async move { - tokio::time::sleep(std::time::Duration::from_millis(ms)).await; - push_event(PendingHttpEvent::Timeout { request_handle }); - }); - std::hint::black_box(&jh); - std::mem::forget(jh); - }); + crate::client_turnloop::arm_request_timeout(request_handle, ms); } /// Emit Node's `TimeoutOverflowWarning` for an out-of-range socket timeout. diff --git a/crates/perry-ext-http/src/client_overload.rs b/crates/perry-ext-http/src/client_overload.rs index 9fb80894b6..a4704ed603 100644 --- a/crates/perry-ext-http/src/client_overload.rs +++ b/crates/perry-ext-http/src/client_overload.rs @@ -182,7 +182,7 @@ fn merge_options_onto_url( opts: &serde_json::Value, default_protocol: &str, ) -> String { - let parsed = reqwest::Url::parse(base_url).ok(); + let parsed = url::Url::parse(base_url).ok(); let protocol = opts .get("protocol") diff --git a/crates/perry-ext-http/src/client_request_surface.rs b/crates/perry-ext-http/src/client_request_surface.rs index 169ac2913d..506a05cd81 100644 --- a/crates/perry-ext-http/src/client_request_surface.rs +++ b/crates/perry-ext-http/src/client_request_surface.rs @@ -410,6 +410,8 @@ pub extern "C" fn js_http_client_request_abort(handle: Handle) -> f64 { with_handle_mut::(handle, |req| { req.completed = true; }); + // Close the exchange's socket too, so the peer sees the abort. + crate::client_turnloop::cancel(handle); push_event(PendingHttpEvent::Abort { request_handle: handle, }); @@ -454,6 +456,9 @@ pub extern "C" fn js_http_client_request_destroy(handle: Handle, _error: f64) -> ); } } + // Node destroys the socket: the peer sees the close, and nothing more of + // the exchange is delivered. + crate::client_turnloop::cancel(handle); client_events::fire_request_close_once(handle); unsafe { finish_agent_request(handle, false); @@ -570,13 +575,13 @@ fn dispatch_property(handle: Handle, property: &str) -> Option { .unwrap_or_else(undefined_value) } "protocol" => with_handle_mut::(handle, |req| { - reqwest::Url::parse(&req.url) + url::Url::parse(&req.url) .map(|u| string_value(&format!("{}:", u.scheme()))) .unwrap_or_else(|_| string_value("")) }) .unwrap_or_else(undefined_value), "host" => with_handle_mut::(handle, |req| { - let host = reqwest::Url::parse(&req.url) + let host = url::Url::parse(&req.url) .ok() .and_then(|u| u.host_str().map(|s| s.to_string())) .unwrap_or_default(); @@ -584,7 +589,7 @@ fn dispatch_property(handle: Handle, property: &str) -> Option { }) .unwrap_or_else(undefined_value), "path" => with_handle_mut::(handle, |req| { - let path = reqwest::Url::parse(&req.url) + let path = url::Url::parse(&req.url) .map(|u| { let mut path = u.path().to_string(); if path.is_empty() { diff --git a/crates/perry-ext-http/src/client_surface.rs b/crates/perry-ext-http/src/client_surface.rs index 5dd4631863..e7429a6209 100644 --- a/crates/perry-ext-http/src/client_surface.rs +++ b/crates/perry-ext-http/src/client_surface.rs @@ -72,7 +72,7 @@ pub extern "C" fn js_http_client_request_method(handle: Handle) -> *mut StringHe #[no_mangle] pub extern "C" fn js_http_client_request_protocol(handle: Handle) -> *mut StringHeader { let protocol = with_handle_mut::(handle, |req| { - reqwest::Url::parse(&req.url) + url::Url::parse(&req.url) .map(|u| format!("{}:", u.scheme())) .unwrap_or_default() }) @@ -83,7 +83,7 @@ pub extern "C" fn js_http_client_request_protocol(handle: Handle) -> *mut String #[no_mangle] pub extern "C" fn js_http_client_request_host(handle: Handle) -> *mut StringHeader { let host = with_handle_mut::(handle, |req| { - reqwest::Url::parse(&req.url) + url::Url::parse(&req.url) .ok() .and_then(|u| u.host_str().map(|s| s.to_string())) .unwrap_or_default() @@ -95,7 +95,7 @@ pub extern "C" fn js_http_client_request_host(handle: Handle) -> *mut StringHead #[no_mangle] pub extern "C" fn js_http_client_request_path(handle: Handle) -> *mut StringHeader { let path = with_handle_mut::(handle, |req| { - reqwest::Url::parse(&req.url) + url::Url::parse(&req.url) .map(|u| { let mut path = u.path().to_string(); if path.is_empty() { diff --git a/crates/perry-ext-http/src/client_turnloop.rs b/crates/perry-ext-http/src/client_turnloop.rs deleted file mode 100644 index b3eee037ad..0000000000 --- a/crates/perry-ext-http/src/client_turnloop.rs +++ /dev/null @@ -1,772 +0,0 @@ -//! The `node:http` client, on turnloop — lane 1. -//! -//! [`try_dispatch`] is called from `dispatch_request_snapshot` immediately -//! before the reqwest path. `true` means this module accepted the exchange and -//! will deliver exactly one terminal [`PendingHttpEvent`] for the request; -//! `false` means it declined and the caller must run its existing reqwest -//! future. That is the same coexistence rule `fetch`'s -//! `perry-stdlib/src/fetch/turnloop_bridge.rs` applies, and the reason -//! `reqwest` is still a dependency of this crate. -//! -//! # What this lane covers, and what declines -//! -//! Every decline below is a *named* condition, not a catch-all. Each one is a -//! later lane; see `docs/turnloop/` and the changelog fragment for the order. -//! -//! * **No loop on this thread** — `tl::available` is false (a host where -//! `Loop::new` failed). Nothing else can be done here. -//! * **Not cleartext `http:`** — `https:` needs the TLS session layer -//! (`perry-tls-session`, the way `perry-ext-ws`'s client drives it). Lane 3. -//! * **An explicit `Agent`** — `agent_handle != 0`. The admission engine in -//! `agent.rs` (per-origin FIFO queue, `maxSockets`, `maxTotalSockets`, -//! `maxFreeSockets`) already runs *above* the transport and is unchanged by -//! this lane, but an Agent also selects a pooled reqwest client whose -//! keep-alive this lane does not yet provide. Lane 4. -//! * **A request body** — upload framing (`BodyLength::Known`/`Chunked`, -//! `send_body`, `'continue'`) is lane 2. -//! * **A per-request deadline** — `options.timeout` / `req.setTimeout`. The -//! `Lifecycle` deadlines are real in `turnloop_http` but need the timer arm -//! wired to `tl::timer_arm`; lane 2. -//! * **A proxy** — `NODE_USE_ENV_PROXY=1` selects `Route`'s CONNECT tunnel. -//! Lane 5. -//! * **A request the codec refuses** — `client::Request::new` rejects a URL -//! with embedded credentials and the `CONNECT`/`TRACE`/`TRACK` methods (a -//! *fetch* normalization rule that `node:http` does not share). Declining -//! rather than failing keeps today's behaviour for those exactly. -//! * **An explicit `Host` header** — `client::Request::head` drops a caller's -//! `host` and substitutes the URL authority, which is Fetch's rule and not -//! `node:http`'s. reqwest sends what the caller set, so routing these here -//! would silently rewrite them. -//! * **A header with its own transport** — `TE: trailers` (`plain_client.rs`), -//! `Connection: Upgrade` (`client_upgrade.rs`) and `Expect: 100-continue` -//! (`continue_client.rs`) each have a raw-socket bypass today. This lane -//! declines all three using the *same* predicates those modules trigger on, -//! so the routing cannot disagree with itself. -//! -//! # Redirects -//! -//! There is deliberately no redirect handling here. Node's `http.request` / -//! `https.get` never follow a 3xx — the reqwest path spells that as -//! `redirect::Policy::none()` (`lib.rs`), and driving `Http1Connection` -//! directly gives it for free: the 3xx head and body are delivered verbatim. -//! -//! # Keep-alive -//! -//! Not in this lane. Every exchange gets its own connection and closes it once -//! `Event::End` has been observed. `turnloop_http::client::Pool` is the -//! mechanism for the next lane, and the ordering it demands — release *only* -//! after End, with `conn.reusable()` — is the one hazard worth isolating in a -//! change of its own, because getting it wrong hands a socket to the next -//! request mid-message and misattributes framing. -//! -//! This costs a connection per request on the covered set. It is invisible to -//! JS: `req.reusedSocket` and `agent.sockets` / `agent.freeSockets` are fed by -//! `agent.rs`'s *facade* pool, which is already decoupled from the physical -//! connection (reqwest owned that, and JS never saw it). -//! -//! # Threading and the GC -//! -//! The sink runs on the agent thread, from the loop's own turn. It runs no JS: -//! every outcome goes onto `HTTP_PENDING_EVENTS` and is dispatched by -//! `js_http_process_pending` on its own tick, exactly as the reqwest task's -//! did. Nothing here holds a JS value — a request is an owned `String`/`Vec` -//! copied before submission, and the only handle stored is the numeric -//! `Handle` the drain looks up — so there is no GC root to register and -//! `scan_http_roots` is unchanged. - -use std::collections::HashMap; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::{Mutex, OnceLock}; - -use bytes::Bytes; -use perry_ffi::turnloop_net as tl; -use perry_ffi::Handle; -use turnloop_http::client::{Http1Connection, Request}; -use turnloop_http::http1; - -use crate::{push_event, ClientInflightGuard, PendingHttpEvent}; - -/// This lane's slot in the runtime's completion-sink registry. -/// -/// Distinct from `server/turnloop_serve`'s `1`, which is this crate's *server*. -/// The authority for the map is `perry-db-turnloop`'s `subsystem` module -/// header; `6` is the free slot between `perry-stdlib`'s framework server (5) -/// and `perry-ext-ws`'s client (7). -pub(crate) const SUBSYSTEM: u8 = 6; - -/// Node sets `TCP_NODELAY` on client sockets; a request that sat in Nagle's -/// queue would add a round trip to every exchange. -const NODELAY: bool = true; - -/// Exchanges this lane has ACCEPTED, and ones it has carried to a clean -/// `Event::End`. -/// -/// These exist because a decline is invisible: a lane that silently returned -/// `false` for every request would leave the JS surface behaving exactly as it -/// does today, and every test over it would stay green having exercised -/// nothing — the "gate runs but its subject never did" shape. A test that -/// asserts `completed_total()` moved is asserting the subject was live. -static ACCEPTED: AtomicU64 = AtomicU64::new(0); -static COMPLETED: AtomicU64 = AtomicU64::new(0); - -/// Exchanges handed to turnloop rather than declined to reqwest. -pub fn accepted_total() -> u64 { - ACCEPTED.load(Ordering::Relaxed) -} - -/// Exchanges whose response was decoded through to `Event::End`. -pub fn completed_total() -> u64 { - COMPLETED.load(Ordering::Relaxed) -} - -/// One in-flight exchange: a connection this module opened, and the request it -/// is carrying. -struct Exchange { - /// The `ClientRequestHandle` every event is addressed to. - request_handle: Handle, - /// The sans-I/O HTTP/1.1 driver. Owns framing, not the socket. - conn: Http1Connection, - /// Bytes to put on the wire once `NET_CONNECT` arrives. The head is - /// serialized at submit time so a failure to build it declines rather than - /// stranding a connected socket. - pending_head: bool, - /// Set once a terminal event has been pushed, so the teardown edges - /// (`NET_EOF`, `NET_ERROR`, `NET_CLOSED`) cannot push a second one. - settled: bool, - /// Keeps the process alive across the exchange and re-arms the event-loop - /// tick on drop — the same guard the reqwest task holds. - _inflight: ClientInflightGuard, -} - -fn exchanges() -> &'static Mutex> { - static EXCHANGES: OnceLock>> = OnceLock::new(); - EXCHANGES.get_or_init(|| Mutex::new(HashMap::new())) -} - -fn with_exchange(id: i64, f: impl FnOnce(&mut Exchange) -> R) -> Option { - let mut guard = exchanges().lock().unwrap_or_else(|e| e.into_inner()); - guard.get_mut(&id).map(f) -} - -fn forget(id: i64) -> Option { - let mut guard = exchanges().lock().unwrap_or_else(|e| e.into_inner()); - guard.remove(&id) -} - -// ── Ids ───────────────────────────────────────────────────────────────────── - -/// One authoritative id domain for the connections this module opens. The -/// runtime keys its handle table by this id across every subsystem, so it has -/// to be globally unique — which is why this is a reserved domain rather than -/// a private counter. -fn registry_domain() -> perry_ffi::NativeRegistryDomain { - static DOMAIN: OnceLock = OnceLock::new(); - *DOMAIN.get_or_init(|| { - perry_ffi::NativeRegistryDomain::new().expect("http client registry domains exhausted") - }) -} - -/// This subsystem accepts nothing — it only dials. Returning zero refuses, -/// which is the right answer for an accept that cannot happen. -extern "C" fn alloc_id() -> i64 { - 0 -} - -// ── Availability ──────────────────────────────────────────────────────────── - -/// Whether a request issued *now, on this thread* can live on turnloop. -/// -/// Deliberately not cached: availability is a property of the calling agent, -/// and `register_sink` is refused outright if the runtime's completion layout -/// does not match this crate's — which leaves this false rather than letting -/// the caller submit work whose completions nothing would deliver. -pub fn available() -> bool { - static REGISTERED: std::sync::Once = std::sync::Once::new(); - REGISTERED.call_once(|| { - tl::register_sink(SUBSYSTEM, sink, alloc_id); - }); - tl::available(SUBSYSTEM) -} - -// ── Decline predicates ────────────────────────────────────────────────────── - -/// `TE: trailers` — `plain_client.rs` owns this exchange. Same predicate as -/// that module's `expects_response_trailers`, deliberately duplicated in -/// spirit rather than shared, because the two must agree by construction. -fn wants_trailers(headers: &HashMap) -> bool { - headers.iter().any(|(name, value)| { - name.eq_ignore_ascii_case("te") - && value - .split(',') - .any(|part| part.trim().eq_ignore_ascii_case("trailers")) - }) -} - -/// An explicit `Host` header. -/// -/// `client::Request::head` drops any caller `host` and substitutes the URL's -/// authority (`headers.retain(|h| !h.name.eq_ignore_ascii_case("host"))`), -/// which is the Fetch rule. `node:http` is not Fetch: `setHeader('Host', …)` -/// reaches the wire, and reqwest sends it, so routing such a request here -/// would silently rewrite it. Declining keeps today's behaviour; building the -/// `Head` by hand instead of through `Request::head` is what removes this. -fn overrides_host(headers: &HashMap) -> bool { - headers.keys().any(|name| name.eq_ignore_ascii_case("host")) -} - -/// `Expect: 100-continue` — `continue_client.rs` owns this exchange. -fn wants_continue(headers: &HashMap) -> bool { - headers.iter().any(|(name, value)| { - name.eq_ignore_ascii_case("expect") - && value - .split(',') - .any(|part| part.trim().eq_ignore_ascii_case("100-continue")) - }) -} - -// ── Submission ────────────────────────────────────────────────────────────── - -/// Try the turnloop path. `false` means the caller keeps its reqwest future. -/// -/// Runs on the agent thread, from `dispatch_request_snapshot` — never from a -/// tokio worker, which is what lets it submit to the loop directly. -#[allow(clippy::too_many_arguments)] -pub fn try_dispatch( - request_handle: Handle, - method: &str, - url: &str, - headers: &HashMap, - body: &[u8], - timeout_ms: Option, - agent_handle: Handle, -) -> bool { - // Cheap, local refusals first: none of these touch the loop. - if agent_handle != 0 - || !body.is_empty() - || timeout_ms.is_some() - || crate::node_env_proxy_enabled() - || wants_trailers(headers) - || wants_continue(headers) - || crate::client_upgrade::wants_upgrade(headers) - || overrides_host(headers) - { - return false; - } - if !url.starts_with("http://") { - return false; - } - if !available() { - return false; - } - - // `Request::new` is the codec's own refusal set: a non-http(s) scheme, - // credentials in the URL, a non-token or forbidden-fetch method. - let Ok(mut request) = Request::new(url, method) else { - return false; - }; - let Some(host) = request.url.host_str().map(str::to_owned) else { - return false; - }; - let port = request.url.port_or_known_default().unwrap_or(80); - - for (name, value) in headers { - request - .headers - .push(http1::Header::new(name, value.as_bytes())); - } - // Node's default agent is keep-alive (v19+) and sends the header - // explicitly; servers reading `req.headers.connection` expect it. Mirrors - // the reqwest path exactly so the wire is unchanged. - if !headers.keys().any(|k| k.eq_ignore_ascii_case("connection")) { - request - .headers - .push(http1::Header::new("connection", "keep-alive".as_bytes())); - } - - let mut conn = Http1Connection::new(http1::Limits::default()); - // No body in this lane, so the upload is finished the moment the head is: - // `BodyLength::Empty` plus an immediate `finish_body` leaves the decoder - // waiting only on the response. - if conn - .start(&request.head(false), http1::BodyLength::Empty, None, None) - .is_err() - || conn.finish_body(&[]).is_err() - { - return false; - } - - let id = next_id(); - if id == perry_ffi::INVALID_HANDLE { - return false; - } - exchanges() - .lock() - .unwrap_or_else(|e| e.into_inner()) - .insert( - id, - Exchange { - request_handle, - conn, - pending_head: true, - settled: false, - _inflight: ClientInflightGuard::new(request_handle), - }, - ); - // Submitted last: the completion can arrive before this call returns (a - // loopback connect completes in the same turn), and it must find the entry. - if let Err(error) = tl::tcp_connect(id, SUBSYSTEM, &host, port, NODELAY) { - forget(id); - perry_ffi::free_handle_id(id); - // The entry is gone and nothing was put on the wire, so the caller may - // still run its reqwest future — except when the loop itself is - // unavailable, which `available()` already ruled out. Report rather - // than double-dispatch. - report_net_error(request_handle, &error); - return true; - } - ACCEPTED.fetch_add(1, Ordering::Relaxed); - true -} - -fn next_id() -> i64 { - perry_ffi::reserve_handle_id_in_domain(registry_domain()) -} - -// ── The completion sink ───────────────────────────────────────────────────── - -extern "C" fn sink(completion: *const tl::NetCompletion) { - if completion.is_null() { - return; - } - // SAFETY: the runtime passes a live completion for the duration of the - // call, which is this function's body. - let c = unsafe { &*completion }; - match c.kind { - tl::NET_CONNECT => on_connect(c.id), - // SAFETY: same call; the pooled lease outlives it. - tl::NET_DATA => on_data(c.id, unsafe { c.bytes() }), - tl::NET_EOF => on_eof(c.id), - tl::NET_ERROR => { - // SAFETY: the runtime builds these from `&'static str`s. - let code = unsafe { c.code() }.unwrap_or("EPIPE").to_string(); - let syscall = unsafe { c.syscall() }.unwrap_or("").to_string(); - on_error(c.id, &code, &syscall, c.errno as i64); - } - tl::NET_CLOSED => on_closed(c.id), - // `NET_WROTE` is an acknowledgement only: `tl::write` copies the - // caller's bytes, so output is consumed at submission time. - _ => {} - } -} - -fn on_connect(id: i64) { - // Start reading before the head goes out: a loopback server's response can - // be in flight before this submission returns. - if let Err(error) = tl::read_start(id) { - fail(id, &error.code, &error.syscall, error.errno as i64); - return; - } - let should_write = with_exchange(id, |exchange| std::mem::take(&mut exchange.pending_head)); - if should_write == Some(true) { - flush(id); - } -} - -/// Put whatever the codec has produced on the wire. -/// -/// `tl::write` copies before returning, so the output is acknowledged to the -/// codec immediately. The copy is also what lets `consume_output` take `&mut` -/// while the bytes are in flight — `client.rs` warns against mutating the -/// connection while a write borrows `output()`. -fn flush(id: i64) { - loop { - let chunk = with_exchange(id, |exchange| exchange.conn.output().to_vec()); - let Some(chunk) = chunk else { return }; - if chunk.is_empty() { - return; - } - if let Err(error) = tl::write(id, &chunk, 0) { - fail(id, &error.code, &error.syscall, error.errno as i64); - return; - } - let consumed = with_exchange(id, |exchange| exchange.conn.consume_output(chunk.len())); - if !matches!(consumed, Some(Ok(()))) { - return; - } - } -} - -/// What one `receive` step produced, lifted out of the borrow so the events can -/// be pushed without holding the exchange lock across JS-visible work. -enum Produced { - Nothing, - Head { - status: u16, - version: u8, - headers: Vec<(String, String)>, - }, - Body(Vec), - End, - /// An interim `1xx`, or a trailer section. Neither is deliverable on this - /// lane — `Expect: 100-continue` and `TE: trailers` both decline in - /// `try_dispatch`, so a server sending either unprompted is ignored exactly - /// as the reqwest path ignored it. - Ignored, -} - -fn on_data(id: i64, bytes: &[u8]) { - let mut offset = 0usize; - // Consecutive steps that consumed nothing. The loop's exit condition is - // *progress*, and an event is progress even at zero bytes — that is how - // `End` arrives from a zero-byte step. But "event, zero consumed" repeated - // forever would spin the agent's event loop with no way out, which is a - // worse failure than a dropped response, so it is bounded. Only `End` - // legitimately arrives this way, so anything past a couple of steps is a - // decoder that is not advancing. - let mut idle_steps = 0u32; - const MAX_IDLE_STEPS: u32 = 8; - loop { - let stepped = with_exchange(id, |exchange| { - if exchange.settled { - return None; - } - let input = &bytes[offset.min(bytes.len())..]; - let step = match exchange.conn.receive(input) { - Ok(step) => step, - Err(error) => return Some(Err(error)), - }; - let produced = match step.event { - Some(http1::Event::Head(head)) => Produced::Head { - status: head.status, - version: head.version, - headers: head - .headers - .iter() - .map(|h| { - ( - h.name.clone(), - String::from_utf8_lossy(&h.value).into_owned(), - ) - }) - .collect(), - }, - Some(http1::Event::Body(chunk)) => Produced::Body(chunk.to_vec()), - Some(http1::Event::End) => Produced::End, - Some(http1::Event::Informational(_)) - | Some(http1::Event::Trailers(_)) - | Some(http1::Event::Upgrade) => Produced::Ignored, - None => Produced::Nothing, - }; - Some(Ok((step.consumed, produced))) - }); - let Some(stepped) = stepped.flatten() else { - return; - }; - let (consumed, produced) = match stepped { - Ok(stepped) => stepped, - Err(error) => { - protocol_failure(id, error); - return; - } - }; - offset = offset.saturating_add(consumed).min(bytes.len()); - - match produced { - Produced::Head { - status, - version, - headers, - } => emit_head(id, status, version, headers), - Produced::Body(chunk) => { - if let Some(request_handle) = with_exchange(id, |e| e.request_handle) { - push_event(PendingHttpEvent::ResponseChunk { - request_handle, - chunk: Bytes::from(chunk), - }); - } - } - Produced::End => { - finish(id); - return; - } - Produced::Nothing if consumed == 0 => return, - Produced::Nothing | Produced::Ignored => {} - } - if consumed == 0 { - idle_steps += 1; - if idle_steps >= MAX_IDLE_STEPS { - return; - } - } else { - idle_steps = 0; - } - // Loop while the step made progress. Once `offset` reaches the end the - // next call is `receive(&[])`, which is how `End` arrives from a - // zero-byte step — omitting that costs a full idle timeout per request - // (turnloop#50), so the exit condition is progress, never "input - // drained". - } -} - -fn emit_head(id: i64, status: u16, version: u8, headers: Vec<(String, String)>) { - let Some(request_handle) = with_exchange(id, |exchange| exchange.request_handle) else { - return; - }; - // `http1::Head` carries no reason phrase, so the canonical one stands in — - // which is exactly what the reqwest path already did - // (`StatusCode::canonical_reason`). - let status_message = http::StatusCode::from_u16(status) - .ok() - .and_then(|code| code.canonical_reason()) - .unwrap_or("") - .to_string(); - push_event(PendingHttpEvent::ResponseHead { - request_handle, - status, - status_message, - headers, - // `Head::version` is the HTTP/1 minor: 0 for HTTP/1.0, 1 for HTTP/1.1. - http_version: (1, version), - }); -} - -// ── Teardown ──────────────────────────────────────────────────────────────── - -/// The response finished cleanly. -fn finish(id: i64) { - let Some(mut exchange) = forget(id) else { - return; - }; - let settled = std::mem::replace(&mut exchange.settled, true); - let _ = tl::close(id); - if settled { - return; - } - COMPLETED.fetch_add(1, Ordering::Relaxed); - push_event(PendingHttpEvent::ResponseEnd { - request_handle: exchange.request_handle, - }); -} - -/// The peer closed its write side. A clean close after `End` is the normal way -/// a `Connection: close` response ends and has already been settled by -/// [`finish`]; anything else truncated the message. -fn on_eof(id: i64) { - let outcome = with_exchange(id, |exchange| { - if exchange.settled { - return None; - } - Some(exchange.conn.eof()) - }); - match outcome.flatten() { - // The decoder accepted EOF as the end of an identity body. - Some(Ok(())) => { - // Drain whatever the zero-byte step yields — `End` can arrive only - // now for a body delimited by the close. - on_data(id, &[]); - // Still live and unsettled means the decoder wants more than the - // peer will send. - if with_exchange(id, |exchange| !exchange.settled) == Some(true) { - fail(id, "ECONNRESET", "read", 0); - } - } - Some(Err(error)) => protocol_failure(id, error), - None => { - let _ = tl::close(id); - } - } -} - -fn on_error(id: i64, code: &str, syscall: &str, errno: i64) { - fail(id, code, syscall, errno); -} - -fn on_closed(id: i64) { - // A close that arrives with the exchange still live means the socket went - // away without a terminal event of its own. - if with_exchange(id, |exchange| exchange.settled) == Some(false) { - fail(id, "ECONNRESET", "read", 0); - } - forget(id); - // The terminal completion: nothing can name this id again and no JS object - // holds it, so it goes back to the shared band rather than leaking one per - // request — the #6441 id-exhaustion shape that `perry-ext-ws`, - // `perry-ext-net` and `perry-http-server` each carry this arm for. Every - // terminal path here (`finish`, `fail`, `protocol_failure`) submits - // `tl::close`, so this completion is reached for every accepted exchange. - perry_ffi::free_handle_id(id); -} - -/// A framing/protocol refusal from the codec, which carries an undici-style -/// cause code rather than an OS one. -fn protocol_failure(id: i64, error: turnloop_http::Error) { - let Some(mut exchange) = forget(id) else { - return; - }; - let settled = std::mem::replace(&mut exchange.settled, true); - let _ = tl::close(id); - if settled { - return; - } - push_event(PendingHttpEvent::Error { - request_handle: exchange.request_handle, - error_message: format!("{} {}", error.code, error.message), - }); -} - -/// A transport failure, in the Node `Error` shape the drain builds `.code` / -/// `.syscall` / `.errno` from. -fn fail(id: i64, code: &str, syscall: &str, errno: i64) { - let Some(mut exchange) = forget(id) else { - return; - }; - let settled = std::mem::replace(&mut exchange.settled, true); - let _ = tl::close(id); - if settled { - return; - } - push_transport_error(exchange.request_handle, code, syscall, errno); -} - -fn report_net_error(request_handle: Handle, error: &tl::NetError) { - push_transport_error( - request_handle, - &error.code, - &error.syscall, - error.errno as i64, - ); -} - -fn push_transport_error(request_handle: Handle, code: &str, syscall: &str, errno: i64) { - let message = if syscall.is_empty() { - code.to_string() - } else { - format!("{syscall} {code}") - }; - push_event(PendingHttpEvent::TransportError { - request_handle, - message, - code: code.to_string(), - syscall: syscall.to_string(), - errno, - }); -} - -#[cfg(test)] -mod tests { - use super::*; - - fn headers(pairs: &[(&str, &str)]) -> HashMap { - pairs - .iter() - .map(|(k, v)| ((*k).to_string(), (*v).to_string())) - .collect() - } - - #[test] - fn this_lane_owns_a_slot_no_other_subsystem_claims() { - // 0 net, 1 this crate's server, 2 stdlib's fetch client, 3 SMTP, - // 4 fastify, 5 framework server, 7/8 ws, 9-12 the database bindings. - // The authority for that map is `perry-db-turnloop`'s `subsystem` - // module header; 6 was the one free slot below the database band. - assert_eq!(SUBSYSTEM, 6); - for taken in [0u8, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12] { - assert_ne!(SUBSYSTEM, taken, "slot {taken} belongs to another lane"); - } - } - - #[test] - fn a_te_trailers_request_is_left_to_the_raw_socket_bypass() { - assert!(wants_trailers(&headers(&[("TE", "trailers")]))); - assert!(wants_trailers(&headers(&[("te", "gzip, trailers")]))); - assert!(!wants_trailers(&headers(&[("te", "gzip")]))); - assert!(!wants_trailers(&headers(&[("accept", "trailers")]))); - } - - /// `Request::head` would substitute the URL authority for a caller's - /// `Host`, which reqwest does not do. Declining is what keeps the two - /// transports agreeing about the wire. - #[test] - fn an_explicit_host_header_is_left_to_reqwest() { - assert!(overrides_host(&headers(&[("Host", "vhost.invalid")]))); - assert!(overrides_host(&headers(&[("host", "vhost.invalid")]))); - assert!(!overrides_host(&headers(&[("x-forwarded-host", "a")]))); - - // The reason it has to decline: the codec rewrites it. - let mut request = Request::new("http://example.invalid/p", "GET").expect("valid request"); - request - .headers - .push(http1::Header::new("host", "vhost.invalid".as_bytes())); - let head = request.head(false); - let hosts: Vec = head - .headers - .iter() - .filter(|h| h.name == "host") - .map(|h| String::from_utf8_lossy(&h.value).into_owned()) - .collect(); - assert_eq!( - hosts, - vec!["example.invalid".to_string()], - "the codec replaces a caller's Host with the URL authority" - ); - } - - #[test] - fn an_expect_continue_request_is_left_to_the_raw_socket_bypass() { - assert!(wants_continue(&headers(&[("Expect", "100-continue")]))); - assert!(wants_continue(&headers(&[("expect", "100-CONTINUE")]))); - assert!(!wants_continue(&headers(&[("expect", "other")]))); - } - - /// The codec's own refusal set, which this lane turns into a decline - /// rather than an error so the existing message text survives. - #[test] - fn the_codec_refuses_exactly_what_this_lane_declines_on() { - assert!(Request::new("http://example.invalid/", "GET").is_ok()); - assert!(Request::new("http://example.invalid/", "TRACE").is_err()); - assert!(Request::new("http://example.invalid/", "CONNECT").is_err()); - assert!(Request::new("http://user:pw@example.invalid/", "GET").is_err()); - } - - /// A GET with no body produces a complete request head and nothing else, - /// so the exchange is write-complete before the socket exists. This is the - /// property that lets lane 1 skip `send_body` entirely. - #[test] - fn a_bodyless_get_serializes_a_complete_head_and_finishes_its_upload() { - let request = Request::new("http://example.invalid/start", "GET").expect("valid request"); - let mut conn = Http1Connection::new(http1::Limits::default()); - conn.start(&request.head(false), http1::BodyLength::Empty, None, None) - .expect("head starts"); - conn.finish_body(&[]).expect("an empty upload finishes"); - let wire = String::from_utf8(conn.output().to_vec()).expect("ascii head"); - assert!(wire.starts_with("GET /start HTTP/1.1\r\n"), "{wire}"); - assert!(wire.to_ascii_lowercase().contains("host: example.invalid")); - assert!(wire.ends_with("\r\n\r\n"), "{wire}"); - } - - /// The reason a 3xx needs no redirect policy here: the codec hands the - /// response back verbatim, which is what `node:http` must do. - #[test] - fn a_redirect_response_is_decoded_as_an_ordinary_response() { - let request = Request::new("http://example.invalid/start", "GET").expect("valid request"); - let mut conn = Http1Connection::new(http1::Limits::default()); - conn.start(&request.head(false), http1::BodyLength::Empty, None, None) - .expect("head starts"); - conn.finish_body(&[]).expect("an empty upload finishes"); - let response = b"HTTP/1.1 307 Temporary Redirect\r\nlocation: /target\r\ncontent-length: 8\r\n\r\nredirect"; - let step = conn.receive(response).expect("a head decodes"); - match step.event { - Some(http1::Event::Head(head)) => { - assert_eq!(head.status, 307); - assert_eq!( - head.headers - .iter() - .find(|h| h.name == "location") - .map(|h| h.value.clone()), - Some(b"/target".to_vec()) - ); - } - other => panic!("expected a head, got {other:?}"), - } - } -} diff --git a/crates/perry-ext-http/src/client_turnloop/conn.rs b/crates/perry-ext-http/src/client_turnloop/conn.rs new file mode 100644 index 0000000000..5e3a22942c --- /dev/null +++ b/crates/perry-ext-http/src/client_turnloop/conn.rs @@ -0,0 +1,1137 @@ +//! One client connection's state machine: connect, optional `CONNECT` tunnel, +//! optional TLS, then one HTTP/1.1 exchange at a time. +//! +//! Every function here runs with [`State`] locked and returns what it wants +//! done as [`Effect`]s; see the module header in `mod.rs` for why no `tl::` +//! call may be made from inside. +//! +//! # Settling +//! +//! An exchange lives in `Conn::exchange` from `start` until exactly one of: +//! `finish` (the response ended), `fail`/`premature` (an error), a deadline, or +//! `cancel`. Each of those *takes* it, so no second terminal event can be +//! produced for the same request, whichever completions arrive afterwards. +//! +//! # Input +//! +//! Bytes that the decoder has not consumed stay in `Conn::inbuf` across reads: +//! a response head or a chunk-size line split over two TCP reads is completed +//! by the next one. The loop runs while a step makes progress — consumed +//! bytes *or* produced an event — because `Event::End` and `Event::Upgrade` +//! arrive from zero-byte steps (turnloop#50's contract). + +use bytes::Bytes; +use perry_ffi::Handle; +use perry_tls_session::TlsSession; +use turnloop_http::http1; + +use super::{ + next_id, tls, wire, Effect, Mode, Outbound, PoolKey, State, Timer, COMPLETED, HANDSHAKES, + REUSED, TIMED_OUT, +}; +use crate::{ClientInflightGuard, PendingHttpEvent}; + +/// Bound on decode steps per delivery. Every step consumes input or produces +/// an event; this only stops a decoder that did neither from spinning the +/// agent's loop, which would be a worse failure than a dropped response. +const MAX_STEPS: usize = 1 << 20; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Phase { + /// `tcp_connect` submitted. + Connecting, + /// Connected to a proxy; waiting for the `CONNECT` response. + Tunnel, + /// Carrying (or about to carry) an exchange. + Open, + /// Parked in the pool between exchanges. + Idle, + /// `tl::close` submitted; waiting for `NET_CLOSED`. + Closing, +} + +/// The `Expect: 100-continue` body hand-off. +enum Continue { + /// Not a continue exchange, or the body has been dealt with. + Done, + /// Waiting for the interim `100`; `end()` may already have supplied the + /// body. + AwaitingInterim(Option>), + /// The `100` arrived before `end()`: the body goes out when it comes. + Released, +} + +struct ResponseHead { + status: u16, + reason: String, + version: u8, + headers: Vec<(String, String)>, +} + +pub(super) struct Exchange { + out: Box, + framing: wire::Framing, + /// The request asked the server to close. + closes: bool, + /// The request head has been handed to the socket (or TLS session). + sent: bool, + /// This exchange runs on a connection taken from the pool. + reused: bool, + /// This exchange is itself a retry; it is never retried again. + retried: bool, + /// Any response byte arrived. A reused connection that dies before one + /// does is retried once on a fresh connection, as hyper's pool did: the + /// peer closing an idle keep-alive socket just as it is reused is a race, + /// not a failure of the request. + got_bytes: bool, + head: Option, + /// A `ResponseHead` event has been queued (the drain has an + /// `IncomingMessage` to fail with `'aborted'`). + head_delivered: bool, + buffered: Vec, + trailers: Vec<(String, String)>, + cont: Continue, + deadline: i64, + _inflight: ClientInflightGuard, +} + +pub(super) struct Conn { + key: PoolKey, + /// What was dialed — the proxy when proxied — for Node's error messages. + peer_host: String, + peer_port: u16, + phase: Phase, + tls: Option, + handshake_done: bool, + decoder: http1::Decoder, + inbuf: Vec, + exchange: Option, + /// Exchanges this connection has completed. + served: u32, + idle_timer: i64, +} + +fn new_decoder() -> http1::Decoder { + http1::Decoder::new(http1::Mode::Response, http1::Limits::default()) +} + +/// The host to dial for a URL: brackets stripped from an IPv6 literal, which +/// `Url::host_str` keeps. +pub(super) fn dial_host(url: &url::Url) -> Option { + Some(match url.host()? { + url::Host::Domain(domain) => domain.to_string(), + url::Host::Ipv4(address) => address.to_string(), + url::Host::Ipv6(address) => address.to_string(), + }) +} + +/// Streamed (`ResponseHead` → chunks → `ResponseEnd`) rather than buffered +/// into one `Response` event. +fn streams(out: &Outbound) -> bool { + match out.mode { + Mode::Normal | Mode::Continue => true, + Mode::Trailers => false, + // A cleartext upgrade that the server declined was delivered buffered + // by its bypass; over TLS, reqwest streamed whatever came back. + Mode::Upgrade => out.key.https, + } +} + +// ── Starting ──────────────────────────────────────────────────────────────── + +/// Start an exchange. `inflight` is the guard of the exchange this one +/// retries, carried over so the exit gate never sees the request drop out of +/// the in-flight set between the two. +pub(super) fn start( + st: &mut State, + out: Outbound, + inflight: Option, + fx: &mut Vec, +) { + let request_handle = out.request_handle; + let retried = inflight.is_some(); + let mut exchange = Exchange { + framing: wire::Framing::Raw, + closes: false, + sent: false, + reused: false, + retried, + got_bytes: false, + head: None, + head_delivered: false, + buffered: Vec::new(), + trailers: Vec::new(), + cont: if out.mode == Mode::Continue { + Continue::AwaitingInterim(None) + } else { + Continue::Done + }, + deadline: 0, + _inflight: inflight.unwrap_or_else(|| ClientInflightGuard::new(request_handle)), + out: Box::new(out), + }; + + // A kept-alive connection first, when the agent allows one. A retry never + // takes one: it exists because a reused connection just failed. + let pooled = if !retried && exchange.out.reuse.is_some() { + take_idle(st, &exchange.out.key, fx) + } else { + None + }; + let id = match pooled { + Some(id) => id, + None => { + let id = next_id(); + if id == perry_ffi::INVALID_HANDLE { + fx.push(Effect::Push(PendingHttpEvent::Error { + request_handle, + error_message: "http client: connection ids exhausted".to_string(), + })); + return; + } + id + } + }; + + if let Some(ms) = exchange.out.timeout_ms { + let timer = next_id(); + if timer != perry_ffi::INVALID_HANDLE { + st.timers.insert( + timer, + Timer::Deadline { + conn: id, + request: request_handle, + }, + ); + exchange.deadline = timer; + fx.push(Effect::ArmTimer(timer, ms)); + } + } + st.by_request.insert(request_handle, id); + + if pooled.is_some() { + REUSED.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + exchange.reused = true; + let Some(conn) = st.conns.get_mut(&id) else { + return; + }; + conn.exchange = Some(exchange); + send_request(st, id, fx); + return; + } + + let (peer_host, peer_port) = match &exchange.out.proxy { + Some(proxy) => ( + dial_host(proxy).unwrap_or_default(), + proxy.port_or_known_default().unwrap_or(80), + ), + None => (exchange.out.key.host.clone(), exchange.out.key.port), + }; + st.conns.insert( + id, + Conn { + key: exchange.out.key.clone(), + peer_host: peer_host.clone(), + peer_port, + phase: Phase::Connecting, + tls: None, + handshake_done: false, + decoder: new_decoder(), + inbuf: Vec::new(), + exchange: Some(exchange), + served: 0, + idle_timer: 0, + }, + ); + fx.push(Effect::Connect { + id, + host: peer_host, + port: peer_port, + }); +} + +pub(super) fn on_connect(st: &mut State, id: i64) -> Vec { + let mut fx = Vec::new(); + let Some(conn) = st.conns.get_mut(&id) else { + return fx; + }; + if conn.phase != Phase::Connecting { + return fx; + } + // Reading starts before anything is written: a loopback peer's reply can + // be in flight before the write submission returns. + fx.push(Effect::ReadStart(id)); + let tunnel = conn.exchange.as_ref().and_then(|ex| { + let out = &ex.out; + (out.key.https) + .then(|| out.proxy.as_ref()) + .flatten() + .map(|proxy| wire::connect_head(&out.key.host, out.key.port, proxy)) + }); + if let Some(head) = tunnel { + conn.phase = Phase::Tunnel; + conn.decoder.response_to("CONNECT"); + fx.push(Effect::Write(id, head)); + return fx; + } + conn.phase = Phase::Open; + if conn.key.https && !open_tls(st, id, &mut fx) { + return fx; + } + send_request(st, id, &mut fx); + fx +} + +/// Install the TLS session and send the ClientHello. `false` means it failed +/// and the exchange has been settled. +fn open_tls(st: &mut State, id: i64, fx: &mut Vec) -> bool { + let Some(conn) = st.conns.get_mut(&id) else { + return false; + }; + let plan = conn.exchange.as_ref().and_then(|ex| ex.out.tls.clone()); + let session = match plan.as_ref().map(tls::open) { + Some(Ok(session)) => session, + Some(Err(message)) => { + fail_coded(st, id, message, "ERR_SSL_PROTOCOL_ERROR", fx); + return false; + } + None => { + fail_coded( + st, + id, + "https request without a TLS configuration".to_string(), + "ERR_SSL_PROTOCOL_ERROR", + fx, + ); + return false; + } + }; + conn.tls = Some(session); + pump_tls(st, id, fx); + st.conns.contains_key(&id) +} + +/// Put the current exchange's request on the wire (or into the TLS session, +/// which holds it until the handshake allows). +fn send_request(st: &mut State, id: i64, fx: &mut Vec) { + let Some(conn) = st.conns.get_mut(&id) else { + return; + }; + let Some(ex) = conn.exchange.as_mut() else { + return; + }; + if ex.sent { + return; + } + if conn.served > 0 && conn.decoder.reset().is_err() { + // Only a connection the decoder called reusable is ever parked, so + // this cannot happen; if it does, a fresh decoder is the safe state. + conn.decoder = new_decoder(); + } + let method = ex.out.method.to_ascii_uppercase(); + conn.decoder.response_to(&method); + + let out = &ex.out; + let absolute = out.proxy.is_some() && !out.key.https; + let target = wire::request_target(&out.url, absolute); + let mut extra = out.extra.clone(); + if absolute { + if let Some(credentials) = out.proxy.as_ref().and_then(wire::basic_credentials) { + extra.push(("Proxy-Authorization".to_string(), credentials)); + } + } + let serialized = wire::serialize_head( + &out.method, + &target, + &out.url, + &out.headers, + &extra, + out.body.len(), + out.mode, + ); + let mut bytes = serialized.head; + if out.mode != Mode::Continue { + bytes.extend_from_slice(&wire::frame_body(&out.body, serialized.framing)); + } + ex.framing = serialized.framing; + ex.closes = serialized.closes; + ex.sent = true; + send(st, id, bytes, fx); +} + +/// Hand plaintext to the connection: through the TLS session when there is +/// one, straight to the socket otherwise. +fn send(st: &mut State, id: i64, bytes: Vec, fx: &mut Vec) { + let Some(conn) = st.conns.get_mut(&id) else { + return; + }; + match conn.tls.as_mut() { + Some(session) => { + session.write(&bytes); + pump_tls(st, id, fx); + } + None => fx.push(Effect::Write(id, bytes)), + } +} + +/// Run the TLS state machine and flush its ciphertext. Returns decrypted +/// plaintext and whether the peer sent `close_notify`; `None` if the session +/// failed (the exchange has then been settled). +fn pump_tls(st: &mut State, id: i64, fx: &mut Vec) -> Option<(Vec, bool)> { + let conn = st.conns.get_mut(&id)?; + let session = conn.tls.as_mut()?; + let progress = session.pump(); + let ciphertext = session.take_output(); + if !ciphertext.is_empty() { + fx.push(Effect::Write(id, ciphertext)); + } + if let Some(failure) = session.failure() { + let code = failure.code; + let message = tls::node_failure_message(code, &failure.message); + let handshaking = !conn.handshake_done; + if handshaking { + fail_coded(st, id, message, code, fx); + } else { + premature(st, id, fx); + } + return None; + } + if progress.handshake_done { + conn.handshake_done = true; + HANDSHAKES.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + } + Some((session.take_plaintext(), progress.peer_closed)) +} + +// ── Input ─────────────────────────────────────────────────────────────────── + +pub(super) fn on_data(st: &mut State, id: i64, bytes: &[u8]) -> Vec { + let mut fx = Vec::new(); + let Some(conn) = st.conns.get_mut(&id) else { + return fx; + }; + match conn.phase { + Phase::Connecting | Phase::Closing => {} + Phase::Tunnel => tunnel_input(st, id, bytes, &mut fx), + Phase::Open | Phase::Idle => { + if conn.tls.is_some() { + let Some(session) = conn.tls.as_mut() else { + return fx; + }; + session.receive(bytes); + if let Some((plaintext, peer_closed)) = pump_tls(st, id, &mut fx) { + if !plaintext.is_empty() { + http_input(st, id, &plaintext, &mut fx); + } + if peer_closed { + eof(st, id, &mut fx); + } + } + } else { + http_input(st, id, bytes, &mut fx); + } + } + } + fx +} + +/// The proxy's answer to `CONNECT`. A 2xx is an `Upgrade` from the decoder's +/// point of view; whatever follows it is the target's TLS. +fn tunnel_input(st: &mut State, id: i64, bytes: &[u8], fx: &mut Vec) { + let Some(conn) = st.conns.get_mut(&id) else { + return; + }; + conn.inbuf.extend_from_slice(bytes); + for _ in 0..MAX_STEPS { + let Some(conn) = st.conns.get_mut(&id) else { + return; + }; + let step = match conn.decoder.receive(&conn.inbuf) { + Ok(step) => step, + Err(error) => { + fail_coded( + st, + id, + format!("{} {}", error.code, error.message), + "ERR_PROXY_TUNNEL", + fx, + ); + return; + } + }; + let consumed = step.consumed; + let outcome = match step.event { + Some(http1::Event::Head(head)) if (200..300).contains(&head.status) => None, + Some(http1::Event::Head(head)) => Some(Err(format!( + "Failed to establish tunnel to {}:{}: HTTP/1.{} {} {}", + conn.key.host, + conn.key.port, + head.version, + head.status, + wire::reason_phrase(&conn.inbuf[..consumed]), + ))), + Some(http1::Event::Upgrade) => Some(Ok(())), + Some(_) => None, + None if consumed == 0 => return, + None => None, + }; + conn.inbuf.drain(..consumed); + match outcome { + None => {} + Some(Err(message)) => { + fail_coded(st, id, message, "ERR_PROXY_TUNNEL", fx); + return; + } + Some(Ok(())) => { + let leftover = std::mem::take(&mut conn.inbuf); + conn.decoder = new_decoder(); + conn.phase = Phase::Open; + if !open_tls(st, id, fx) { + return; + } + send_request(st, id, fx); + if !leftover.is_empty() { + let mut more = on_data(st, id, &leftover); + fx.append(&mut more); + } + return; + } + } + } +} + +fn http_input(st: &mut State, id: i64, bytes: &[u8], fx: &mut Vec) { + let Some(conn) = st.conns.get_mut(&id) else { + return; + }; + conn.inbuf.extend_from_slice(bytes); + process(st, id, fx); +} + +/// One decoded event, owned so the connection can be released before it is +/// acted on. +enum Decoded { + Head(http1::Head, String), + Informational(u16), + Body(Vec), + Trailers(Vec), + End, + Upgrade, + Nothing, +} + +fn process(st: &mut State, id: i64, fx: &mut Vec) { + for _ in 0..MAX_STEPS { + let Some(conn) = st.conns.get_mut(&id) else { + return; + }; + let Some(ex) = conn.exchange.as_mut() else { + // Bytes on a connection with no request in flight: unsolicited + // data on an idle keep-alive socket, or bytes after a response + // ended. Neither can be framed; the connection is done. + if !conn.inbuf.is_empty() { + close(st, id, fx); + } + return; + }; + if !conn.inbuf.is_empty() { + ex.got_bytes = true; + } + let step = match conn.decoder.receive(&conn.inbuf) { + Ok(step) => step, + Err(error) => { + fail_protocol(st, id, error, fx); + return; + } + }; + let consumed = step.consumed; + let decoded = match step.event { + Some(http1::Event::Head(head)) => { + let reason = wire::reason_phrase(&conn.inbuf[..consumed]); + Decoded::Head(head, reason) + } + Some(http1::Event::Informational(head)) => Decoded::Informational(head.status), + Some(http1::Event::Body(chunk)) => Decoded::Body(chunk.to_vec()), + Some(http1::Event::Trailers(trailers)) => Decoded::Trailers(trailers), + Some(http1::Event::End) => Decoded::End, + Some(http1::Event::Upgrade) => Decoded::Upgrade, + None => Decoded::Nothing, + }; + conn.inbuf.drain(..consumed); + match decoded { + Decoded::Nothing => { + if consumed == 0 { + return; + } + } + Decoded::Informational(status) => on_informational(st, id, status, fx), + Decoded::Head(head, reason) => on_head(st, id, head, reason, fx), + Decoded::Body(chunk) => on_body(st, id, chunk, fx), + Decoded::Trailers(trailers) => { + if let Some(ex) = st.conns.get_mut(&id).and_then(|c| c.exchange.as_mut()) { + ex.trailers = trailers + .into_iter() + .map(|h| (h.name, String::from_utf8_lossy(&h.value).into_owned())) + .collect(); + } + } + Decoded::End => finish(st, id, false, fx), + Decoded::Upgrade => on_upgrade(st, id, fx), + } + } +} + +fn on_informational(st: &mut State, id: i64, status: u16, fx: &mut Vec) { + if status != 100 { + // `102`/`103` — Node's `'information'`, which neither the reqwest + // path nor the bypasses surfaced. + return; + } + let Some(ex) = st.conns.get_mut(&id).and_then(|c| c.exchange.as_mut()) else { + return; + }; + if ex.out.mode != Mode::Continue { + return; + } + let request_handle = ex.out.request_handle; + let body = match std::mem::replace(&mut ex.cont, Continue::Done) { + Continue::AwaitingInterim(Some(body)) => Some(body), + Continue::AwaitingInterim(None) => { + ex.cont = Continue::Released; + None + } + other => { + ex.cont = other; + return; + } + }; + fx.push(Effect::Push(PendingHttpEvent::Continue { request_handle })); + if let Some(body) = body { + let framed = wire::frame_body(&body, ex.framing); + send(st, id, framed, fx); + } +} + +/// `end()` supplied the body of an `Expect: 100-continue` request. +pub(super) fn continue_body( + st: &mut State, + request_handle: Handle, + body: Vec, + fx: &mut Vec, +) { + let Some(&id) = st.by_request.get(&request_handle) else { + return; + }; + let Some(ex) = st.conns.get_mut(&id).and_then(|c| c.exchange.as_mut()) else { + return; + }; + if ex.out.request_handle != request_handle || ex.out.mode != Mode::Continue { + return; + } + match std::mem::replace(&mut ex.cont, Continue::Done) { + Continue::AwaitingInterim(_) => ex.cont = Continue::AwaitingInterim(Some(body)), + Continue::Released => { + let framed = wire::frame_body(&body, ex.framing); + send(st, id, framed, fx); + } + Continue::Done => {} + } +} + +fn on_head(st: &mut State, id: i64, head: http1::Head, reason: String, fx: &mut Vec) { + let Some(ex) = st.conns.get_mut(&id).and_then(|c| c.exchange.as_mut()) else { + return; + }; + // A final response without the interim `100`: the server declined to see + // the body, which is never sent. + if matches!(ex.cont, Continue::AwaitingInterim(_) | Continue::Released) { + ex.cont = Continue::Done; + } + let headers: Vec<(String, String)> = head + .headers + .iter() + .map(|h| { + ( + h.name.clone(), + String::from_utf8_lossy(&h.value).into_owned(), + ) + }) + .collect(); + let status = head.status; + if streams(&ex.out) { + ex.head_delivered = true; + fx.push(Effect::Push(PendingHttpEvent::ResponseHead { + request_handle: ex.out.request_handle, + status, + status_message: reason.clone(), + headers: headers.clone(), + // `Head::version` is the HTTP/1 minor: 0 for 1.0, 1 for 1.1. + http_version: (1, head.version), + })); + } + ex.head = Some(ResponseHead { + status, + reason, + version: head.version, + headers, + }); +} + +fn on_body(st: &mut State, id: i64, chunk: Vec, fx: &mut Vec) { + let Some(ex) = st.conns.get_mut(&id).and_then(|c| c.exchange.as_mut()) else { + return; + }; + if ex.head_delivered { + fx.push(Effect::Push(PendingHttpEvent::ResponseChunk { + request_handle: ex.out.request_handle, + chunk: Bytes::from(chunk), + })); + } else { + ex.buffered.extend_from_slice(&chunk); + } +} + +fn on_upgrade(st: &mut State, id: i64, fx: &mut Vec) { + let Some(conn) = st.conns.get_mut(&id) else { + return; + }; + let handoff = conn.exchange.as_ref().is_some_and(|ex| { + ex.out.mode == Mode::Upgrade + && !ex.out.key.https + && ex.head.as_ref().is_some_and(|h| h.status == 101) + }); + if !handoff { + // A `CONNECT` 2xx, an unrequested `101`, or a `101` over TLS: the + // message is over and the connection cannot be reused. + finish(st, id, true, fx); + return; + } + let Some(conn) = st.conns.remove(&id) else { + return; + }; + let Some(ex) = conn.exchange else { + return; + }; + clear_deadline(st, &ex, fx); + forget_request(st, ex.out.request_handle, id); + COMPLETED.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let head = ex.head.unwrap_or(ResponseHead { + status: 101, + reason: String::new(), + version: 1, + headers: Vec::new(), + }); + // The connection now belongs to `net`: no close, no id release here. + fx.push(Effect::Handoff( + id, + PendingHttpEvent::Upgrade { + request_handle: ex.out.request_handle, + status: head.status, + status_message: head.reason, + headers: head.headers, + socket_handle: id, + head: conn.inbuf, + }, + )); +} + +// ── Settling ──────────────────────────────────────────────────────────────── + +fn clear_deadline(st: &mut State, ex: &Exchange, fx: &mut Vec) { + if ex.deadline != 0 && st.timers.remove(&ex.deadline).is_some() { + fx.push(Effect::CancelTimer(ex.deadline)); + } +} + +fn forget_request(st: &mut State, request_handle: Handle, id: i64) { + if st.by_request.get(&request_handle) == Some(&id) { + st.by_request.remove(&request_handle); + } +} + +/// Take the exchange off a connection, clearing its deadline and request +/// mapping. Every terminal path goes through here exactly once. +fn take_exchange(st: &mut State, id: i64, fx: &mut Vec) -> Option { + let ex = st.conns.get_mut(&id)?.exchange.take()?; + clear_deadline(st, &ex, fx); + forget_request(st, ex.out.request_handle, id); + Some(ex) +} + +/// The response ended. Deliver it, then park or close the connection. +fn finish(st: &mut State, id: i64, never_reuse: bool, fx: &mut Vec) { + let Some(ex) = take_exchange(st, id, fx) else { + return; + }; + COMPLETED.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let request_handle = ex.out.request_handle; + if ex.head_delivered { + fx.push(Effect::Push(PendingHttpEvent::ResponseEnd { + request_handle, + })); + } else { + let head = ex.head.as_ref(); + fx.push(Effect::Push(PendingHttpEvent::Response { + request_handle, + status: head.map_or(0, |h| h.status), + status_message: head.map(|h| h.reason.clone()).unwrap_or_default(), + headers: head.map(|h| h.headers.clone()).unwrap_or_default(), + trailers: ex.trailers.clone(), + body: ex.buffered.clone(), + http_version: (1, head.map_or(1, |h| h.version)), + })); + } + let Some(conn) = st.conns.get_mut(&id) else { + return; + }; + let reuse = ex.out.reuse.filter(|_| { + !never_reuse + && ex.out.mode == Mode::Normal + && !ex.closes + && conn.decoder.reusable() + && conn.inbuf.is_empty() + && conn + .tls + .as_ref() + .is_none_or(|s| !s.peer_closed() && s.failure().is_none()) + }); + drop(ex); + match reuse { + Some(reuse) => park(st, id, reuse, fx), + None => close(st, id, fx), + } +} + +/// Return a connection to the pool (see `pool.rs` for the ordering rule this +/// is the only caller of). +fn park(st: &mut State, id: i64, reuse: super::Reuse, fx: &mut Vec) { + let Some(conn) = st.conns.get_mut(&id) else { + return; + }; + let key = conn.key.clone(); + let parked = st.idle.get(&key).map_or(0, Vec::len); + if parked >= reuse.max_free { + close(st, id, fx); + return; + } + let timer = next_id(); + let Some(conn) = st.conns.get_mut(&id) else { + return; + }; + conn.phase = Phase::Idle; + conn.served = conn.served.saturating_add(1); + if timer != perry_ffi::INVALID_HANDLE { + conn.idle_timer = timer; + st.timers.insert(timer, Timer::Idle { conn: id }); + fx.push(Effect::ArmTimer(timer, reuse.idle_ms)); + } + fx.push(Effect::SetRef(id, false)); + st.idle.entry(key).or_default().push(id); +} + +/// Take the most recently parked live connection for `key`. +fn take_idle(st: &mut State, key: &PoolKey, fx: &mut Vec) -> Option { + loop { + let id = st.idle.get_mut(key)?.pop()?; + let Some(conn) = st.conns.get_mut(&id) else { + continue; + }; + if conn.phase != Phase::Idle { + continue; + } + conn.phase = Phase::Open; + let timer = std::mem::take(&mut conn.idle_timer); + if timer != 0 && st.timers.remove(&timer).is_some() { + fx.push(Effect::CancelTimer(timer)); + } + fx.push(Effect::SetRef(id, true)); + return Some(id); + } +} + +fn unpark(st: &mut State, id: i64, fx: &mut Vec) { + let Some(conn) = st.conns.get_mut(&id) else { + return; + }; + let timer = std::mem::take(&mut conn.idle_timer); + let key = conn.key.clone(); + if timer != 0 && st.timers.remove(&timer).is_some() { + fx.push(Effect::CancelTimer(timer)); + } + if let Some(list) = st.idle.get_mut(&key) { + list.retain(|&parked| parked != id); + if list.is_empty() { + st.idle.remove(&key); + } + } +} + +/// Submit the close. The record stays until `NET_CLOSED`, which frees the id. +fn close(st: &mut State, id: i64, fx: &mut Vec) { + unpark(st, id, fx); + let Some(conn) = st.conns.get_mut(&id) else { + return; + }; + if conn.phase == Phase::Closing { + return; + } + conn.phase = Phase::Closing; + fx.push(Effect::Close(id)); +} + +fn fail_coded(st: &mut State, id: i64, message: String, code: &str, fx: &mut Vec) { + if let Some(ex) = take_exchange(st, id, fx) { + fx.push(Effect::Push(PendingHttpEvent::CodedError { + request_handle: ex.out.request_handle, + message, + code: code.to_string(), + })); + } + close(st, id, fx); +} + +/// A framing refusal from the codec, which carries an llhttp-style code. +fn fail_protocol(st: &mut State, id: i64, error: turnloop_http::Error, fx: &mut Vec) { + if let Some(ex) = take_exchange(st, id, fx) { + fx.push(Effect::Push(PendingHttpEvent::CodedError { + request_handle: ex.out.request_handle, + message: format!("Parse Error: {}", error.message), + code: error.code.to_string(), + })); + } + close(st, id, fx); +} + +/// The connection went away before the response ended. +fn premature(st: &mut State, id: i64, fx: &mut Vec) { + let Some(ex) = take_exchange(st, id, fx) else { + close(st, id, fx); + return; + }; + close(st, id, fx); + report_premature(ex, fx); +} + +fn report_premature(ex: Exchange, fx: &mut Vec) { + let request_handle = ex.out.request_handle; + if ex.reused && !ex.got_bytes && !ex.retried { + // The stale keep-alive race: nothing of the response arrived, so the + // request may safely be sent again on a fresh connection. + let Exchange { out, _inflight, .. } = ex; + fx.push(Effect::Redispatch(out, _inflight)); + return; + } + if ex.head_delivered { + // Node: the response is `'aborted'`; the drain builds that error from + // the `IncomingMessage` it already has. + fx.push(Effect::Push(PendingHttpEvent::Error { + request_handle, + error_message: "aborted".to_string(), + })); + } else { + fx.push(Effect::Push(PendingHttpEvent::CodedError { + request_handle, + message: "socket hang up".to_string(), + code: "ECONNRESET".to_string(), + })); + } +} + +/// Readable EOF, from the socket or a TLS `close_notify`. +fn eof(st: &mut State, id: i64, fx: &mut Vec) { + let Some(conn) = st.conns.get_mut(&id) else { + return; + }; + match conn.phase { + Phase::Closing => return, + Phase::Idle | Phase::Connecting => { + close(st, id, fx); + return; + } + Phase::Tunnel => { + premature(st, id, fx); + return; + } + Phase::Open => {} + } + if conn.exchange.is_none() { + close(st, id, fx); + return; + } + // A body delimited by the close ends here: the decoder turns EOF into + // `End`, which the next step delivers. + if conn.decoder.eof().is_ok() { + process(st, id, fx); + } + let still_open = st + .conns + .get(&id) + .is_some_and(|conn| conn.exchange.is_some()); + if still_open { + premature(st, id, fx); + } +} + +pub(super) fn on_eof(st: &mut State, id: i64) -> Vec { + let mut fx = Vec::new(); + eof(st, id, &mut fx); + fx +} + +pub(super) fn on_error( + st: &mut State, + id: i64, + code: &str, + syscall: &str, + errno: i64, +) -> Vec { + let mut fx = Vec::new(); + let Some(conn) = st.conns.get_mut(&id) else { + return fx; + }; + match conn.phase { + Phase::Closing => return fx, + Phase::Idle => { + close(st, id, &mut fx); + return fx; + } + Phase::Connecting => { + // Node's connect-failure message names the peer: + // `connect ECONNREFUSED 127.0.0.1:1`, `getaddrinfo ENOTFOUND host`. + let (message, code, syscall, errno) = crate::transport_error::connect_failure( + code, + syscall, + errno, + &conn.peer_host, + conn.peer_port, + ); + if let Some(ex) = take_exchange(st, id, &mut fx) { + fx.push(Effect::Push(PendingHttpEvent::TransportError { + request_handle: ex.out.request_handle, + message, + code, + syscall, + errno, + })); + } + close(st, id, &mut fx); + return fx; + } + Phase::Tunnel | Phase::Open => {} + } + if matches!(code, "ECONNRESET" | "EPIPE" | "ECONNABORTED") { + premature(st, id, &mut fx); + return fx; + } + if let Some(ex) = take_exchange(st, id, &mut fx) { + let message = if syscall.is_empty() { + code.to_string() + } else { + format!("{syscall} {code}") + }; + fx.push(Effect::Push(PendingHttpEvent::TransportError { + request_handle: ex.out.request_handle, + message, + code: code.to_string(), + syscall: syscall.to_string(), + errno, + })); + } + close(st, id, &mut fx); + fx +} + +pub(super) fn on_closed(st: &mut State, id: i64) -> Vec { + let mut fx = Vec::new(); + unpark(st, id, &mut fx); + let Some(mut conn) = st.conns.remove(&id) else { + return fx; + }; + if let Some(ex) = conn.exchange.take() { + // The socket went away with a request still on it and no terminal + // event of its own. + clear_deadline(st, &ex, &mut fx); + forget_request(st, ex.out.request_handle, id); + report_premature(ex, &mut fx); + } + // The terminal completion: nothing can name this id again, so it goes + // back to the shared band (the #6441 id-exhaustion shape). + fx.push(Effect::FreeId(id)); + fx +} + +pub(super) fn on_timer(st: &mut State, timer: i64) -> Vec { + let mut fx = Vec::new(); + let Some(kind) = st.timers.remove(&timer) else { + return fx; + }; + // A fired one-shot is terminal on the runtime side: nothing to cancel. + fx.push(Effect::FreeId(timer)); + match kind { + Timer::Deadline { conn, request } => { + let owns = st + .conns + .get(&conn) + .and_then(|c| c.exchange.as_ref()) + .is_some_and(|ex| ex.out.request_handle == request && ex.deadline == timer); + if !owns { + return fx; + } + if let Some(mut ex) = st.conns.get_mut(&conn).and_then(|c| c.exchange.take()) { + // Already removed from `timers`; don't cancel it again. + ex.deadline = 0; + forget_request(st, request, conn); + TIMED_OUT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + fx.push(Effect::Push(PendingHttpEvent::Timeout { + request_handle: request, + })); + } + close(st, conn, &mut fx); + } + Timer::Idle { conn } => { + let idle = st + .conns + .get_mut(&conn) + .filter(|c| c.phase == Phase::Idle && c.idle_timer == timer); + if let Some(c) = idle { + c.idle_timer = 0; + close(st, conn, &mut fx); + } + } + Timer::Standalone { request } => { + fx.push(Effect::Push(PendingHttpEvent::Timeout { + request_handle: request, + })); + } + } + fx +} + +pub(super) fn cancel(st: &mut State, request_handle: Handle, fx: &mut Vec) { + let Some(id) = st.by_request.get(&request_handle).copied() else { + return; + }; + let owns = st + .conns + .get(&id) + .and_then(|c| c.exchange.as_ref()) + .is_some_and(|ex| ex.out.request_handle == request_handle); + if owns { + drop(take_exchange(st, id, fx)); + close(st, id, fx); + } else { + st.by_request.remove(&request_handle); + } +} + +pub(super) fn purge_agent(st: &mut State, agent_handle: Handle, fx: &mut Vec) { + let idle: Vec = st + .idle + .iter() + .filter(|(key, _)| key.agent == agent_handle) + .flat_map(|(_, ids)| ids.iter().copied()) + .collect(); + for id in idle { + close(st, id, fx); + } +} diff --git a/crates/perry-ext-http/src/client_turnloop/mod.rs b/crates/perry-ext-http/src/client_turnloop/mod.rs new file mode 100644 index 0000000000..a5bdcd732c --- /dev/null +++ b/crates/perry-ext-http/src/client_turnloop/mod.rs @@ -0,0 +1,764 @@ +//! The `node:http` / `node:https` client, on turnloop — the only transport. +//! +//! Lane 1 (#11091) put the simplest shape here — a cleartext, bodyless request +//! on the implicit agent — and declined everything else to reqwest. This module +//! now carries **every** exchange `dispatch_request_snapshot` used to hand +//! reqwest, plus the three shapes that bypassed reqwest on raw tokio sockets, +//! and `reqwest` is no longer a dependency of this crate: +//! +//! | shape | how | +//! |---|---| +//! | request bodies | buffered at `end()`, so always a known length: `Content-Length`, or chunked when the caller set `Transfer-Encoding: chunked` ([`wire`]) | +//! | `options.timeout` / `req.setTimeout` | a deadline on the loop (`tl::timer_arm`) covering the whole exchange, as reqwest's `RequestBuilder::timeout` did; it fires `'timeout'` and tears the exchange down | +//! | `https:` | [`perry_tls_session::TlsSession`] above the same socket handle, with the verifier `tls_client` builds from Node's options ([`tls`]) | +//! | an explicit or HTTPS `Agent` | keep-alive with physical reuse ([`pool`]); the observable agent pools in `agent.rs` are untouched | +//! | an explicit `Host` | sent verbatim, as Node and reqwest both did ([`wire`]) | +//! | `NODE_USE_ENV_PROXY=1` | absolute-form through an HTTP proxy, or a `CONNECT` tunnel for `https:` ([`proxy`]) | +//! | `TE: trailers` | the codec's `Event::Trailers`, delivered with the buffered response — was `plain_client.rs` over a tokio `TcpStream` | +//! | `Expect: 100-continue` | head first, body withheld until the interim `100`, which fires `'continue'` — was `continue_client.rs` over tokio | +//! | `Connection: Upgrade` | the codec's `Event::Upgrade`; a `101` hands the live handle to `net` with `tl::transfer` — was `client_upgrade.rs` over tokio | +//! +//! # What is still not here +//! +//! * `agent.createConnection` / `createSocket` and a request-level +//! `createConnection` run their exchange over a socket JS produced +//! (`client_connect_override.rs`, perry-ext-net's raw vtable). They are +//! decided before this module is offered the request and are unchanged. +//! * A thread that does not own its agent's loop **posts** the submission to +//! the owner (`perry_ffi::agent_post`), which serves the same JS heap; only a +//! host where no loop exists at all reports `ENOTSUP` — the rule `perry-ext-net` +//! adopted when it dropped tokio. +//! * `Connection: Upgrade` over `https:` cannot hand a TLS session to `net`, so +//! a `101` there is delivered as an ordinary response, exactly as reqwest did. +//! +//! # Redirects +//! +//! None are followed. Node's `http.request` never follows a 3xx; reqwest had to +//! be told `redirect::Policy::none()`, and driving the codec directly gives it +//! by construction. +//! +//! # Threading, locking and the GC +//! +//! Every connection lives on the agent's loop and is touched only from this +//! module's completion sink or from work running on that loop. State is one +//! mutex ([`State`]), and **no `tl::` call is ever made while it is held**: a +//! loopback completion can be delivered to the sink before the submitting call +//! returns, and the sink takes the same lock. Handlers therefore compute a list +//! of [`Effect`]s under the lock and perform them after releasing it. +//! +//! The sink runs no JS. Every outcome is a `PendingHttpEvent` for +//! `js_http_process_pending`, exactly as the reqwest task's were. Nothing here +//! holds a JS value — requests are owned `String`/`Vec` copies — so there is no +//! GC root to register and `scan_http_roots` is unchanged. + +mod conn; +mod pool; +mod proxy; +pub(crate) mod tls; +mod wire; + +use std::collections::HashMap; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Mutex, OnceLock}; + +use perry_ffi::turnloop_net as tl; +use perry_ffi::Handle; + +use crate::tls_client::TlsOptions; +use crate::{push_event, PendingHttpEvent}; + +pub(crate) use pool::{PoolKey, Reuse}; + +/// This lane's slot in the runtime's completion-sink registry. +/// +/// Distinct from `server/turnloop_serve`'s `1`, which is this crate's *server*. +/// The authority for the map is `perry-db-turnloop`'s `subsystem` module +/// header; `6` is the free slot between `perry-stdlib`'s framework server (5) +/// and `perry-ext-ws`'s client (7). +pub(crate) const SUBSYSTEM: u8 = 6; + +/// Node sets `TCP_NODELAY` on client sockets; a request that sat in Nagle's +/// queue would add a round trip to every exchange. +const NODELAY: bool = true; + +/// The code a request fails with when no loop exists for its agent at all. +/// Same as `perry-ext-net`'s: there is no second event loop to fall back to. +const NO_LOOP_CODE: &str = "ENOTSUP"; + +// ── Liveness counters ─────────────────────────────────────────────────────── +// +// A transport that silently did nothing would leave every JS-level test green +// having exercised nothing — the "gate runs but its subject never did" shape. +// These let a test assert the subject was live, per shape. + +static ACCEPTED: AtomicU64 = AtomicU64::new(0); +static COMPLETED: AtomicU64 = AtomicU64::new(0); +static REUSED: AtomicU64 = AtomicU64::new(0); +static HANDSHAKES: AtomicU64 = AtomicU64::new(0); +static TIMED_OUT: AtomicU64 = AtomicU64::new(0); + +/// Exchanges handed to this module (directly or posted to the loop owner). +pub fn accepted_total() -> u64 { + ACCEPTED.load(Ordering::Relaxed) +} + +/// Exchanges whose response was decoded through to its end. +pub fn completed_total() -> u64 { + COMPLETED.load(Ordering::Relaxed) +} + +/// Exchanges that ran on a pooled (kept-alive) connection. +pub fn reused_total() -> u64 { + REUSED.load(Ordering::Relaxed) +} + +/// TLS handshakes this module completed. +pub fn tls_handshakes_total() -> u64 { + HANDSHAKES.load(Ordering::Relaxed) +} + +/// Exchanges torn down by their deadline. +pub fn timed_out_total() -> u64 { + TIMED_OUT.load(Ordering::Relaxed) +} + +// ── The request ───────────────────────────────────────────────────────────── + +/// Which of the four exchange shapes a request is. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Mode { + /// Streamed response, keep-alive eligible. + Normal, + /// `TE: trailers`: the response is buffered so its trailers can be + /// delivered with it; the connection is closed afterwards. + Trailers, + /// `Connection: Upgrade`: a `101` hands the socket to `net`. + Upgrade, + /// `Expect: 100-continue`: the head goes out now, the body after the + /// interim `100`, and `end()` supplies it through [`continue_body`]. + Continue, +} + +/// One request, owned, ready to cross to the loop thread. +pub(crate) struct Outbound { + pub(crate) request_handle: Handle, + pub(crate) method: String, + pub(crate) url: url::Url, + pub(crate) headers: HashMap, + pub(crate) body: Vec, + pub(crate) timeout_ms: Option, + pub(crate) mode: Mode, + pub(crate) reuse: Option, + pub(crate) key: PoolKey, + /// Set for `https:`. + pub(crate) tls: Option, + /// Set when `NODE_USE_ENV_PROXY=1` selects a proxy for this URL. + pub(crate) proxy: Option, + /// Headers the transport adds after the caller's own (the in-process + /// HTTPS server's forwarding token). + pub(crate) extra: Vec<(String, String)>, +} + +// ── Shared state ──────────────────────────────────────────────────────────── + +/// A deadline this module armed, and what it is for. +#[derive(Clone, Copy, Debug)] +enum Timer { + /// `options.timeout` for an in-flight exchange. + Deadline { conn: i64, request: Handle }, + /// An idle pooled connection's expiry. + Idle { conn: i64 }, + /// `req.setTimeout(ms, cb)` armed before (or independently of) dispatch. + Standalone { request: Handle }, +} + +#[derive(Default)] +struct State { + conns: HashMap, + /// Which connection is carrying a request, for `destroy()` and the + /// deferred `Expect: 100-continue` body. + by_request: HashMap, + timers: HashMap, + idle: HashMap>, +} + +fn state() -> &'static Mutex { + static STATE: OnceLock> = OnceLock::new(); + STATE.get_or_init(|| Mutex::new(State::default())) +} + +fn with_state(f: impl FnOnce(&mut State) -> R) -> R { + let mut guard = state().lock().unwrap_or_else(|e| e.into_inner()); + f(&mut guard) +} + +/// I/O decided under the lock and performed after it is released. +enum Effect { + Connect { + id: i64, + host: String, + port: u16, + }, + ReadStart(i64), + Write(i64, Vec), + Close(i64), + SetRef(i64, bool), + ArmTimer(i64, u64), + CancelTimer(i64), + FreeId(i64), + Push(PendingHttpEvent), + /// Hand a connection to `net` after a `101`, then publish the event. + Handoff(i64, PendingHttpEvent), + /// Run a request again on a fresh connection (a reused one died before + /// the response began), keeping its in-flight guard. + Redispatch(Box, crate::ClientInflightGuard), +} + +/// Perform effects in order. A failed submission feeds its handler, whose own +/// effects join the queue — that is how a write error on a connection becomes +/// the same teardown a read error would. +fn run(effects: Vec) { + let mut queue = std::collections::VecDeque::from(effects); + while let Some(effect) = queue.pop_front() { + let more = match effect { + Effect::Connect { id, host, port } => { + match tl::tcp_connect(id, SUBSYSTEM, &host, port, NODELAY) { + Ok(()) => Vec::new(), + Err(error) => with_state(|st| { + conn::on_error(st, id, &error.code, &error.syscall, error.errno as i64) + }), + } + } + Effect::ReadStart(id) => match tl::read_start(id) { + Ok(()) => Vec::new(), + Err(error) => with_state(|st| { + conn::on_error(st, id, &error.code, &error.syscall, error.errno as i64) + }), + }, + Effect::Write(id, bytes) => { + if bytes.is_empty() { + Vec::new() + } else { + match tl::write(id, &bytes, 0) { + Ok(_) => Vec::new(), + Err(error) => with_state(|st| { + conn::on_error(st, id, &error.code, &error.syscall, error.errno as i64) + }), + } + } + } + Effect::Close(id) => { + let _ = tl::close(id); + Vec::new() + } + Effect::SetRef(id, referenced) => { + tl::set_ref(id, referenced); + Vec::new() + } + Effect::ArmTimer(id, ms) => match tl::timer_arm(id, SUBSYSTEM, ms) { + Ok(()) => Vec::new(), + // No deadline could be armed: fire it now rather than never. + Err(_) => with_state(|st| conn::on_timer(st, id)), + }, + Effect::CancelTimer(id) => { + let _ = tl::timer_cancel(id); + perry_ffi::free_handle_id(id); + Vec::new() + } + Effect::FreeId(id) => { + perry_ffi::free_handle_id(id); + Vec::new() + } + Effect::Push(event) => { + push_event(event); + Vec::new() + } + Effect::Handoff(id, event) => { + handoff(id, event); + Vec::new() + } + Effect::Redispatch(outbound, inflight) => with_state(|st| { + let mut fx = Vec::new(); + conn::start(st, *outbound, Some(inflight), &mut fx); + fx + }), + }; + queue.extend(more); + } +} + +/// A `101` on a cleartext upgrade request: the live handle becomes a +/// `net.Socket`, keeping its id and outstanding read — the server's own +/// `'upgrade'` handoff in `turnloop_serve`, from the client side. +fn handoff(id: i64, event: PendingHttpEvent) { + let adopted = tl::transfer(id, perry_ext_net::TURNLOOP_SUBSYSTEM).is_ok() + && perry_ext_net::adopt_turnloop_upgrade(id); + if adopted { + push_event(event); + return; + } + let _ = tl::close(id); + if let PendingHttpEvent::Upgrade { request_handle, .. } = event { + push_event(PendingHttpEvent::CodedError { + request_handle, + message: "socket hang up".to_string(), + code: "ECONNRESET".to_string(), + }); + } +} + +// ── Ids ───────────────────────────────────────────────────────────────────── + +/// One authoritative id domain for the connections and deadlines this module +/// creates. The runtime keys its handle table by id across every subsystem, +/// so it must be globally unique. +fn registry_domain() -> perry_ffi::NativeRegistryDomain { + static DOMAIN: OnceLock = OnceLock::new(); + *DOMAIN.get_or_init(|| { + perry_ffi::NativeRegistryDomain::new().expect("http client registry domains exhausted") + }) +} + +fn next_id() -> i64 { + perry_ffi::reserve_handle_id_in_domain(registry_domain()) +} + +/// This subsystem accepts nothing — it only dials. +extern "C" fn alloc_id() -> i64 { + 0 +} + +// ── Availability and routing ──────────────────────────────────────────────── + +/// Whether a request issued *now, on this thread* can be submitted directly. +/// +/// Deliberately not cached: availability is a property of the calling thread, +/// and the first thread to ask claims its agent's route. +pub fn available() -> bool { + static REGISTERED: std::sync::Once = std::sync::Once::new(); + REGISTERED.call_once(|| { + tl::register_sink(SUBSYSTEM, sink, alloc_id); + }); + tl::available(SUBSYSTEM) +} + +struct LoopJob(Box); + +impl perry_ffi::agent_post::AgentJob for LoopJob { + fn run(self: Box) { + (self.0)(); + } +} + +/// How often a transiently refused post is retried before the request fails. +const POST_ATTEMPTS: usize = 64; + +/// Run `op` on the loop: here when this thread owns it, else on the owner. +/// `false` means no loop exists for this agent at all; `op` was not run. +fn on_loop(op: impl FnOnce() + Send + 'static) -> bool { + if available() { + op(); + return true; + } + if !perry_ffi::agent_post::available() { + return false; + } + let mut job = Box::new(LoopJob(Box::new(move || { + // The owner has registered the sink by definition, but the `Once` + // must still run on whichever thread first reaches this module. + let _ = available(); + op(); + }))); + for _ in 0..POST_ATTEMPTS { + match perry_ffi::agent_post::post_job(job) { + Ok(()) => return true, + Err(rejected) if rejected.is_permanent() => return false, + Err(rejected) => { + job = rejected.into_job(); + std::thread::yield_now(); + } + } + } + false +} + +fn report_no_loop(request_handle: Handle) { + push_event(PendingHttpEvent::TransportError { + request_handle, + message: format!("connect {NO_LOOP_CODE}"), + code: NO_LOOP_CODE.to_string(), + syscall: "connect".to_string(), + errno: tl::errno_for_code(NO_LOOP_CODE) as i64, + }); +} + +// ── Submission (JS thread) ────────────────────────────────────────────────── + +/// Everything `dispatch_request_snapshot` knows about a request. +pub(crate) struct Request<'a> { + pub(crate) request_handle: Handle, + pub(crate) method: &'a str, + pub(crate) url: &'a str, + pub(crate) headers: HashMap, + pub(crate) body: Vec, + pub(crate) timeout_ms: Option, + pub(crate) agent_handle: Handle, + pub(crate) tls: &'a TlsOptions, + pub(crate) continue_mode: bool, +} + +/// The shape a request's headers select, using the predicates the three +/// retired bypass modules triggered on. +fn mode_for(headers: &HashMap, continue_mode: bool) -> Mode { + if continue_mode { + Mode::Continue + } else if crate::client_upgrade::wants_upgrade(headers) { + Mode::Upgrade + } else if wants_trailers(headers) { + Mode::Trailers + } else { + Mode::Normal + } +} + +/// `TE: trailers` as one token of a comma list. +fn wants_trailers(headers: &HashMap) -> bool { + headers.iter().any(|(name, value)| { + name.eq_ignore_ascii_case("te") + && value + .split(',') + .any(|part| part.trim().eq_ignore_ascii_case("trailers")) + }) +} + +/// Build the owned request. `Err` is the message for a request that cannot be +/// sent at all (an unparseable URL, bad TLS material, an unusable proxy). +fn prepare(request: Request<'_>) -> Result { + let url = url::Url::parse(request.url).map_err(|e| e.to_string())?; + let https = match url.scheme() { + "http" => false, + "https" => true, + other => return Err(format!("unsupported protocol {other}:")), + }; + let host = conn::dial_host(&url).ok_or_else(|| "missing host".to_string())?; + let port = url + .port_or_known_default() + .unwrap_or(if https { 443 } else { 80 }); + let proxy = proxy::proxy_for(&url)?; + let tls = if https { + Some(tls::plan(request.tls, &host)?) + } else { + None + }; + let mode = mode_for(&request.headers, request.continue_mode); + let reuse = if mode == Mode::Normal { + pool::policy_for(request.agent_handle) + } else { + None + }; + + // The in-process HTTPS server's forwarding headers, exactly as the reqwest + // path attached them (`tls_client::register_internal_https_server`). + let mut extra = Vec::new(); + if https { + if let Some(token) = crate::tls_client::internal_https_token_for_url(request.url) { + extra.push(("x-perry-internal-tls-token".to_string(), token)); + if let Some(servername) = request.tls.servername.as_deref() { + extra.push(( + "x-perry-tls-servername".to_string(), + if servername.is_empty() { + "".to_string() + } else { + servername.to_string() + }, + )); + } + if let Some(common_name) = request.tls.peer_certificate_cn.as_deref() { + extra.push(("x-perry-tls-peer-cn".to_string(), common_name.to_string())); + } + } + } + + let key = PoolKey { + agent: request.agent_handle, + https, + host, + port, + proxy: proxy.as_ref().map(ToString::to_string), + tls: if https { tls::identity(request.tls) } else { 0 }, + }; + Ok(Outbound { + request_handle: request.request_handle, + method: request.method.to_string(), + url, + headers: request.headers, + body: request.body, + // Node treats a zero timeout as "no timeout"; reqwest's zero-length + // deadline timed the request out immediately. + timeout_ms: request.timeout_ms.filter(|ms| *ms > 0), + mode, + reuse, + key, + tls, + proxy, + extra, + }) +} + +/// How a request was carried. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Route { + /// Submitted to this thread's own loop. + Direct, + /// Posted to the thread that owns this agent's loop. + Posted, + /// Refused before submission; a terminal event has been queued. + Refused, +} + +/// Carry a request. Always delivers exactly one terminal event for it. +pub(crate) fn dispatch(request: Request<'_>) -> Route { + let request_handle = request.request_handle; + let outbound = match prepare(request) { + Ok(outbound) => outbound, + Err(error_message) => { + push_event(PendingHttpEvent::Error { + request_handle, + error_message, + }); + return Route::Refused; + } + }; + ACCEPTED.fetch_add(1, Ordering::Relaxed); + let direct = available(); + let carried = on_loop(move || start(outbound)); + if !carried { + report_no_loop(request_handle); + return Route::Refused; + } + if direct { + Route::Direct + } else { + Route::Posted + } +} + +/// Start an exchange on the loop thread. +fn start(outbound: Outbound) { + let effects = with_state(|st| start_locked(st, outbound)); + run(effects); +} + +fn start_locked(st: &mut State, outbound: Outbound) -> Vec { + let mut fx = Vec::new(); + conn::start(st, outbound, None, &mut fx); + fx +} + +/// The body `end()` supplies to an `Expect: 100-continue` exchange. +pub(crate) fn continue_body(request_handle: Handle, body: Vec) { + let carried = on_loop(move || { + let effects = with_state(|st| { + let mut fx = Vec::new(); + conn::continue_body(st, request_handle, body, &mut fx); + fx + }); + run(effects); + }); + let _ = carried; +} + +/// `req.destroy()` / `req.abort()`: stop carrying the request. The JS-visible +/// teardown (`'error'`/`'close'`) is the caller's; this only closes the socket +/// so the peer sees it, as Node's destroy does, and drops the exchange so no +/// late event reaches a completed request. +pub(crate) fn cancel(request_handle: Handle) { + // Only a request this module is carrying has anything to cancel. Checking + // first also keeps `destroy()` of a never-dispatched request from asking + // for the loop route, which the first asker claims for its thread. + if !with_state(|st| st.by_request.contains_key(&request_handle)) { + return; + } + let _ = on_loop(move || { + let effects = with_state(|st| { + let mut fx = Vec::new(); + conn::cancel(st, request_handle, &mut fx); + fx + }); + run(effects); + }); +} + +/// `agent.destroy()`: close the agent's idle pooled connections. Also +/// reachable from `tests/turnloop_client_exchange.rs`, which parks one. +pub fn purge_agent(agent_handle: Handle) { + // Idle connections exist only once an agent has used the transport; an + // agent that never did must not claim the loop route just by being + // configured or destroyed. + if !with_state(|st| st.idle.keys().any(|key| key.agent == agent_handle)) { + return; + } + let _ = on_loop(move || { + let effects = with_state(|st| { + let mut fx = Vec::new(); + conn::purge_agent(st, agent_handle, &mut fx); + fx + }); + run(effects); + }); +} + +/// `req.setTimeout(ms[, cb])` / `options.timeout` armed at request creation: +/// a one-shot `'timeout'` for the request, independent of any exchange. +pub(crate) fn arm_request_timeout(request_handle: Handle, ms: u64) { + let carried = on_loop(move || { + let id = next_id(); + if id == perry_ffi::INVALID_HANDLE { + push_event(PendingHttpEvent::Timeout { request_handle }); + return; + } + with_state(|st| { + st.timers.insert( + id, + Timer::Standalone { + request: request_handle, + }, + ) + }); + run(vec![Effect::ArmTimer(id, ms)]); + }); + if !carried { + // No loop anywhere for this agent: a plain thread keeps the promise + // that `'timeout'` fires, without a second event loop. + std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_millis(ms)); + push_event(PendingHttpEvent::Timeout { request_handle }); + }); + } +} + +// ── The public liveness hooks ─────────────────────────────────────────────── + +/// Offer a request to this module the way `dispatch_request_snapshot` does, +/// with no TLS options. `true` means it was carried (directly or posted). +/// +/// Kept `pub` for `tests/turnloop_client_exchange.rs`, which asserts the lane +/// was live rather than trusting a JS-level green. +#[allow(clippy::too_many_arguments)] +pub fn try_dispatch( + request_handle: Handle, + method: &str, + url: &str, + headers: &HashMap, + body: &[u8], + timeout_ms: Option, + agent_handle: Handle, +) -> bool { + try_dispatch_tls( + request_handle, + method, + url, + headers, + body, + timeout_ms, + agent_handle, + Vec::new(), + ) +} + +/// [`try_dispatch`] with an explicit `ca` (PEM) for an `https:` URL. +#[allow(clippy::too_many_arguments)] +pub fn try_dispatch_tls( + request_handle: Handle, + method: &str, + url: &str, + headers: &HashMap, + body: &[u8], + timeout_ms: Option, + agent_handle: Handle, + ca_pems: Vec>, +) -> bool { + let tls = TlsOptions { + ca_pems, + ..TlsOptions::default() + }; + let route = dispatch(Request { + request_handle, + method, + url, + headers: headers.clone(), + body: body.to_vec(), + timeout_ms, + agent_handle, + tls: &tls, + continue_mode: false, + }); + matches!(route, Route::Direct | Route::Posted) +} + +/// Keep-alive for the liveness test, which has no `AgentHandle` to read the +/// policy from: carry `request` with this reuse policy on pool key `agent`. +#[allow(clippy::too_many_arguments)] +pub fn try_dispatch_pooled( + request_handle: Handle, + method: &str, + url: &str, + headers: &HashMap, + body: &[u8], + agent: Handle, + max_free: usize, + idle_ms: u64, +) -> bool { + let tls = TlsOptions::default(); + let mut outbound = match prepare(Request { + request_handle, + method, + url, + headers: headers.clone(), + body: body.to_vec(), + timeout_ms: None, + agent_handle: agent, + tls: &tls, + continue_mode: false, + }) { + Ok(outbound) => outbound, + Err(_) => return false, + }; + outbound.reuse = Some(Reuse { max_free, idle_ms }); + ACCEPTED.fetch_add(1, Ordering::Relaxed); + on_loop(move || start(outbound)) +} + +// ── The completion sink ───────────────────────────────────────────────────── + +extern "C" fn sink(completion: *const tl::NetCompletion) { + if completion.is_null() { + return; + } + // SAFETY: the runtime passes a live completion for the duration of the + // call, which is this function's body. + let c = unsafe { &*completion }; + let effects = match c.kind { + tl::NET_CONNECT => with_state(|st| conn::on_connect(st, c.id)), + // SAFETY: same call; the pooled lease outlives it. + tl::NET_DATA => { + let bytes = unsafe { c.bytes() }; + with_state(|st| conn::on_data(st, c.id, bytes)) + } + tl::NET_EOF => with_state(|st| conn::on_eof(st, c.id)), + tl::NET_ERROR => { + // SAFETY: the runtime builds these from `&'static str`s. + let code = unsafe { c.code() }.unwrap_or("EPIPE").to_string(); + let syscall = unsafe { c.syscall() }.unwrap_or("").to_string(); + with_state(|st| conn::on_error(st, c.id, &code, &syscall, c.errno as i64)) + } + tl::NET_CLOSED => with_state(|st| conn::on_closed(st, c.id)), + tl::NET_TIMER => with_state(|st| conn::on_timer(st, c.id)), + // `NET_WROTE` is an acknowledgement only: `tl::write` copies. + _ => Vec::new(), + }; + run(effects); +} + +#[cfg(test)] +mod tests; diff --git a/crates/perry-ext-http/src/client_turnloop/pool.rs b/crates/perry-ext-http/src/client_turnloop/pool.rs new file mode 100644 index 0000000000..12539c83e8 --- /dev/null +++ b/crates/perry-ext-http/src/client_turnloop/pool.rs @@ -0,0 +1,123 @@ +//! Keep-alive: which idle connection a request may reuse, and when an idle one +//! is let go. +//! +//! This is the *physical* pool. `agent.rs` keeps the *observable* one — +//! `agent.sockets` / `freeSockets` / `requests`, `req.reusedSocket`, the +//! `maxSockets` FIFO — and it is unchanged: it always sat above the transport, +//! and reqwest's hidden connection pool sat below it. This module is what +//! replaces the latter, with the same knobs reqwest was configured from +//! (`client_for_agent`: `keepAlive`, `maxFreeSockets`, `keepAliveMsecs`). +//! +//! # The ordering rule +//! +//! A connection is parked only from `conn.rs`'s `finish`, i.e. after the +//! decoder has delivered `Event::End` for the response it was carrying, with +//! nothing left over in its input buffer, and only when +//! `http1::Decoder::reusable()` agrees (a complete message and a keep-alive +//! response). Releasing earlier is the framing-misattribution hazard: the +//! next request would read the tail of the previous response as its own. +//! +//! # Idle connections +//! +//! An idle connection is unreferenced (`tl::set_ref(false)`), so it never +//! keeps the process alive — Node unrefs its free sockets too — and it is +//! closed by an unreferenced idle timer (`keepAliveMsecs`, 1 s by default, +//! which is what reqwest's `pool_idle_timeout` was set to), by the peer, or by +//! any unsolicited byte arriving on it. + +use perry_ffi::Handle; + +/// What a request's agent allows. Read on the JS thread at dispatch, from the +/// same `AgentHandle` fields reqwest's per-agent client was built from. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct Reuse { + /// Idle connections kept per key (`maxFreeSockets`). + pub(crate) max_free: usize, + /// How long an idle connection is kept (`keepAliveMsecs`). + pub(crate) idle_ms: u64, +} + +/// The reuse policy for an agent, or `None` when it must not reuse. +/// +/// `agent_handle == 0` is `http`'s implicit global agent, which the reqwest +/// path served from one shared client; the turnloop lane has carried it with a +/// connection per request since lane 1, and that is kept. +pub(crate) fn policy_for(agent_handle: Handle) -> Option { + if agent_handle == 0 { + return None; + } + let (keep_alive, max_free_sockets, keep_alive_msecs) = + crate::agent::agent_pool_config(agent_handle)?; + policy_from(keep_alive, max_free_sockets, keep_alive_msecs) +} + +/// The arithmetic `client_for_agent` fed reqwest's pool, kept exactly. +pub(crate) fn policy_from( + keep_alive: bool, + max_free_sockets: f64, + keep_alive_msecs: f64, +) -> Option { + if !keep_alive { + return None; + } + let max_free = if !max_free_sockets.is_finite() || max_free_sockets > usize::MAX as f64 { + 256 + } else { + max_free_sockets.max(1.0) as usize + }; + let idle_ms = if keep_alive_msecs.is_finite() && keep_alive_msecs > 0.0 { + keep_alive_msecs as u64 + } else { + 1000 + }; + Some(Reuse { max_free, idle_ms }) +} + +/// Which connections are interchangeable. Anything that changes the peer, the +/// TLS identity, or the agent that owns the socket separates them. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub(crate) struct PoolKey { + pub(crate) agent: Handle, + pub(crate) https: bool, + pub(crate) host: String, + pub(crate) port: u16, + pub(crate) proxy: Option, + pub(crate) tls: u64, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn keep_alive_false_never_reuses() { + assert_eq!(policy_from(false, 256.0, 1000.0), None); + } + + #[test] + fn the_reqwest_pool_arithmetic_is_kept() { + assert_eq!( + policy_from(true, f64::INFINITY, 0.0), + Some(Reuse { + max_free: 256, + idle_ms: 1000 + }) + ); + assert_eq!( + policy_from(true, 0.0, 250.0), + Some(Reuse { + max_free: 1, + idle_ms: 250 + }) + ); + assert_eq!( + policy_from(true, 4.0, f64::NAN).map(|r| r.idle_ms), + Some(1000) + ); + } + + #[test] + fn the_implicit_http_agent_does_not_reuse() { + assert_eq!(policy_for(0), None); + } +} diff --git a/crates/perry-ext-http/src/client_turnloop/proxy.rs b/crates/perry-ext-http/src/client_turnloop/proxy.rs new file mode 100644 index 0000000000..afbe6b6bf8 --- /dev/null +++ b/crates/perry-ext-http/src/client_turnloop/proxy.rs @@ -0,0 +1,108 @@ +//! `NODE_USE_ENV_PROXY=1`: which proxy, if any, a request goes through. +//! +//! Node's `http`/`https` clients ignore `HTTP_PROXY`/`HTTPS_PROXY`/`NO_PROXY` +//! unless `NODE_USE_ENV_PROXY=1` (or `--use-env-proxy`) is set, and then read +//! the lowercase spelling first (`http_proxy || HTTP_PROXY`). The matching +//! and the `NO_PROXY` grammar are `turnloop_http::client::ProxyEnvironment`'s, +//! the same policy `fetch` applies. +//! +//! Two transports follow from the answer (see `conn.rs`): an `http://` target +//! is sent to the proxy in absolute-form with `Proxy-Authorization` from the +//! proxy URL's credentials; an `https://` target opens a `CONNECT` tunnel and +//! runs TLS inside it, with the credentials on the `CONNECT` only. + +use turnloop_http::client::ProxyEnvironment; + +/// Read one variable the way Node does: lowercase first, then uppercase. +fn env_pair(lower: &str, upper: &str) -> Option { + std::env::var(lower) + .ok() + .filter(|v| !v.is_empty()) + .or_else(|| std::env::var(upper).ok().filter(|v| !v.is_empty())) +} + +fn environment() -> ProxyEnvironment { + ProxyEnvironment { + http_proxy: env_pair("http_proxy", "HTTP_PROXY"), + https_proxy: env_pair("https_proxy", "HTTPS_PROXY"), + no_proxy: env_pair("no_proxy", "NO_PROXY").unwrap_or_default(), + } +} + +/// The proxy for `url`, if the process opted in and one applies. +/// +/// `Err` carries the message for a proxy variable that is set but unusable +/// (not an `http://` URL); the request fails with it rather than silently +/// going direct. +pub(super) fn proxy_for(url: &url::Url) -> Result, String> { + if !crate::node_env_proxy_enabled() { + return Ok(None); + } + resolve(&environment(), url) +} + +fn resolve(env: &ProxyEnvironment, url: &url::Url) -> Result, String> { + env.proxy_for(url) + .map_err(|error| format!("{}: {}", error.code, error.message)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn env(http: Option<&str>, https: Option<&str>, no: &str) -> ProxyEnvironment { + ProxyEnvironment { + http_proxy: http.map(str::to_string), + https_proxy: https.map(str::to_string), + no_proxy: no.to_string(), + } + } + + fn url(s: &str) -> url::Url { + url::Url::parse(s).unwrap() + } + + #[test] + fn the_scheme_selects_the_variable() { + let e = env(Some("http://p1.invalid:1"), Some("http://p2.invalid:2"), ""); + assert_eq!( + resolve(&e, &url("http://a.invalid/")) + .unwrap() + .unwrap() + .host_str(), + Some("p1.invalid") + ); + assert_eq!( + resolve(&e, &url("https://a.invalid/")) + .unwrap() + .unwrap() + .host_str(), + Some("p2.invalid") + ); + } + + #[test] + fn no_proxy_bypasses_by_domain_suffix() { + let e = env( + Some("http://p.invalid:1"), + None, + "internal.invalid,127.0.0.1", + ); + assert!(resolve(&e, &url("http://api.internal.invalid/")) + .unwrap() + .is_none()); + assert!(resolve(&e, &url("http://127.0.0.1:9/")).unwrap().is_none()); + assert!(resolve(&e, &url("http://other.invalid/")) + .unwrap() + .is_some()); + assert!(resolve(&e, &url("https://other.invalid/")) + .unwrap() + .is_none()); + } + + #[test] + fn a_non_http_proxy_is_an_error_not_a_silent_direct_connection() { + let e = env(Some("socks5://p.invalid:1"), None, ""); + assert!(resolve(&e, &url("http://a.invalid/")).is_err()); + } +} diff --git a/crates/perry-ext-http/src/client_turnloop/tests.rs b/crates/perry-ext-http/src/client_turnloop/tests.rs new file mode 100644 index 0000000000..265aa6497e --- /dev/null +++ b/crates/perry-ext-http/src/client_turnloop/tests.rs @@ -0,0 +1,636 @@ +//! Unit tests for the routing and framing decisions. The end-to-end proof that +//! the transport carries each shape is `tests/turnloop_client_exchange.rs`, +//! which runs in its own process because an agent's loop route is claimed once +//! per thread (see its header). + +use super::*; +use turnloop_http::http1; + +fn headers(pairs: &[(&str, &str)]) -> HashMap { + pairs + .iter() + .map(|(k, v)| ((*k).to_string(), (*v).to_string())) + .collect() +} + +#[test] +fn this_lane_owns_a_slot_no_other_subsystem_claims() { + // 0 net, 1 this crate's server, 2 stdlib's fetch client, 3 SMTP, + // 4 fastify, 5 framework server, 7/8 ws, 9-12 the database bindings. + // The authority for that map is `perry-db-turnloop`'s `subsystem` + // module header; 6 was the one free slot below the database band. + assert_eq!(SUBSYSTEM, 6); + for taken in [0u8, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12] { + assert_ne!(SUBSYSTEM, taken, "slot {taken} belongs to another lane"); + } +} + +/// The three shapes that used to bypass reqwest on raw tokio sockets are +/// selected by the same predicates those modules triggered on. +#[test] +fn a_te_trailers_request_is_left_to_the_raw_socket_bypass() { + // Name kept from lane 1; the "bypass" is now this module's Trailers mode. + assert!(wants_trailers(&headers(&[("TE", "trailers")]))); + assert!(wants_trailers(&headers(&[("te", "gzip, trailers")]))); + assert!(!wants_trailers(&headers(&[("te", "gzip")]))); + assert!(!wants_trailers(&headers(&[("accept", "trailers")]))); + assert_eq!( + mode_for(&headers(&[("TE", "trailers")]), false), + Mode::Trailers + ); +} + +#[test] +fn an_expect_continue_request_is_left_to_the_raw_socket_bypass() { + // `Expect` does not select a mode by itself: `continue_client` arms the + // Continue exchange and passes `continue_mode`, exactly as it decided to + // take the tokio bypass before. + assert_eq!( + mode_for(&headers(&[("Expect", "100-continue")]), false), + Mode::Normal + ); + assert_eq!(mode_for(&HashMap::new(), true), Mode::Continue); +} + +#[test] +fn an_upgrade_request_selects_the_handoff_mode() { + assert_eq!( + mode_for( + &headers(&[("Connection", "Upgrade"), ("Upgrade", "websocket")]), + false + ), + Mode::Upgrade + ); + // Upgrade wins over trailers, as the old dispatch order did. + assert_eq!( + mode_for( + &headers(&[("Connection", "Upgrade"), ("TE", "trailers")]), + false + ), + Mode::Upgrade + ); +} + +/// Lane 1 declined an explicit `Host` because `client::Request::head` +/// rewrites it. This lane serializes its own head, so the caller's `Host` +/// reaches the wire verbatim — pinned here, including the codec behaviour +/// that made the old decline necessary. +#[test] +fn an_explicit_host_header_is_left_to_reqwest() { + let url = url::Url::parse("http://example.invalid/p").unwrap(); + let serialized = wire::serialize_head( + "GET", + "/p", + &url, + &headers(&[("Host", "vhost.invalid")]), + &[], + 0, + Mode::Normal, + ); + let text = String::from_utf8(serialized.head).unwrap(); + assert!(text.contains("Host: vhost.invalid\r\n"), "{text}"); + assert!(!text.contains("example.invalid"), "{text}"); + + // The codec would have replaced it — the reason `wire.rs` exists. + let mut request = turnloop_http::client::Request::new("http://example.invalid/p", "GET") + .expect("valid request"); + request + .headers + .push(http1::Header::new("host", "vhost.invalid".as_bytes())); + let head = request.head(false); + assert!(head + .headers + .iter() + .all(|h| h.name != "host" || h.value == b"example.invalid")); +} + +/// The codec's `Request::new` refuses these; `node:http` does not, and this +/// lane no longer goes through `Request::new`, so they are carried. +#[test] +fn the_codec_refuses_exactly_what_this_lane_declines_on() { + assert!(turnloop_http::client::Request::new("http://example.invalid/", "TRACE").is_err()); + assert!(turnloop_http::client::Request::new("http://user:pw@example.invalid/", "GET").is_err()); + let url = url::Url::parse("http://example.invalid/").unwrap(); + let text = String::from_utf8( + wire::serialize_head("TRACE", "/", &url, &HashMap::new(), &[], 0, Mode::Normal).head, + ) + .unwrap(); + assert!(text.starts_with("TRACE / HTTP/1.1\r\n"), "{text}"); +} + +/// A GET with no body produces a complete head and nothing else. +#[test] +fn a_bodyless_get_serializes_a_complete_head_and_finishes_its_upload() { + let url = url::Url::parse("http://example.invalid/start").unwrap(); + let serialized = wire::serialize_head( + "GET", + &wire::request_target(&url, false), + &url, + &HashMap::new(), + &[], + 0, + Mode::Normal, + ); + let wire_text = String::from_utf8(serialized.head).expect("ascii head"); + assert!( + wire_text.starts_with("GET /start HTTP/1.1\r\n"), + "{wire_text}" + ); + assert!(wire_text.contains("Host: example.invalid\r\n")); + assert!(!wire_text.contains("Content-Length")); + assert!(wire_text.ends_with("\r\n\r\n"), "{wire_text}"); +} + +/// The reason a 3xx needs no redirect policy here: the codec hands the +/// response back verbatim, which is what `node:http` must do. +#[test] +fn a_redirect_response_is_decoded_as_an_ordinary_response() { + let mut decoder = http1::Decoder::new(http1::Mode::Response, http1::Limits::default()); + decoder.response_to("GET"); + let response = + b"HTTP/1.1 307 Temporary Redirect\r\nlocation: /target\r\ncontent-length: 8\r\n\r\nredirect"; + let step = decoder.receive(response).expect("a head decodes"); + match step.event { + Some(http1::Event::Head(head)) => { + assert_eq!(head.status, 307); + assert_eq!( + head.headers + .iter() + .find(|h| h.name == "location") + .map(|h| h.value.clone()), + Some(b"/target".to_vec()) + ); + assert_eq!( + wire::reason_phrase(&response[..step.consumed]), + "Temporary Redirect" + ); + } + other => panic!("expected a head, got {other:?}"), + } +} + +/// Keep-alive depends on the decoder's verdict after `End`; a response that +/// asks to close must never be pooled. +#[test] +fn only_a_complete_keep_alive_response_leaves_the_connection_reusable() { + let mut decoder = http1::Decoder::new(http1::Mode::Response, http1::Limits::default()); + decoder.response_to("GET"); + let mut input: &[u8] = b"HTTP/1.1 200 OK\r\ncontent-length: 2\r\n\r\nok"; + loop { + let step = decoder.receive(input).unwrap(); + let done = matches!(step.event, Some(http1::Event::End)); + input = &input[step.consumed..]; + if done { + break; + } + } + assert!(decoder.reusable()); + + let mut decoder = http1::Decoder::new(http1::Mode::Response, http1::Limits::default()); + decoder.response_to("GET"); + let mut input: &[u8] = b"HTTP/1.1 200 OK\r\nconnection: close\r\ncontent-length: 2\r\n\r\nok"; + loop { + let step = decoder.receive(input).unwrap(); + let done = matches!(step.event, Some(http1::Event::End)); + input = &input[step.consumed..]; + if done { + break; + } + } + assert!(!decoder.reusable()); +} + +#[test] +fn an_ipv6_literal_is_dialed_without_brackets() { + let url = url::Url::parse("http://[::1]:8080/").unwrap(); + assert_eq!(url.host_str(), Some("[::1]")); + assert_eq!(conn::dial_host(&url).as_deref(), Some("::1")); +} + +#[test] +fn a_non_http_url_is_refused_with_a_message() { + let tls = TlsOptions::default(); + let result = prepare(Request { + request_handle: 1, + method: "GET", + url: "ftp://example.invalid/", + headers: HashMap::new(), + body: Vec::new(), + timeout_ms: None, + agent_handle: 0, + tls: &tls, + continue_mode: false, + }); + assert!(result.is_err()); +} + +// ── The state machine, driven by hand ─────────────────────────────────────── +// +// These feed the sink handlers directly and inspect the effects they return +// instead of performing them, so no loop is needed and nothing is left to a +// scheduling lottery. `GC_TEST_LOCK` serializes them with the crate tests that +// read the process-wide in-flight count and event queue. + +fn lock() -> std::sync::MutexGuard<'static, ()> { + crate::tests::GC_TEST_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +fn outbound(request_handle: Handle, url: &str, pairs: &[(&str, &str)]) -> Outbound { + let tls = TlsOptions::default(); + prepare(Request { + request_handle, + method: "GET", + url, + headers: headers(pairs), + body: Vec::new(), + timeout_ms: None, + agent_handle: 0, + tls: &tls, + continue_mode: false, + }) + .unwrap_or_else(|message| panic!("request prepares: {message}")) +} + +fn connect_id(fx: &[Effect]) -> i64 { + fx.iter() + .find_map(|e| match e { + Effect::Connect { id, .. } => Some(*id), + _ => None, + }) + .expect("a new connection is dialed") +} + +fn written(fx: &[Effect]) -> Vec { + fx.iter() + .filter_map(|e| match e { + Effect::Write(_, bytes) => Some(bytes.clone()), + _ => None, + }) + .flatten() + .collect() +} + +fn pushed(fx: &[Effect]) -> Vec<&'static str> { + fx.iter() + .filter_map(|e| match e { + Effect::Push(event) => Some(match event { + PendingHttpEvent::ResponseHead { .. } => "head", + PendingHttpEvent::ResponseChunk { .. } => "chunk", + PendingHttpEvent::ResponseEnd { .. } => "end", + PendingHttpEvent::Response { .. } => "response", + PendingHttpEvent::Continue { .. } => "continue", + PendingHttpEvent::Timeout { .. } => "timeout", + PendingHttpEvent::Error { .. } => "error", + PendingHttpEvent::CodedError { .. } => "coded-error", + PendingHttpEvent::TransportError { .. } => "transport-error", + _ => "other", + }), + _ => None, + }) + .collect() +} + +fn closes(fx: &[Effect], id: i64) -> bool { + fx.iter().any(|e| matches!(e, Effect::Close(c) if *c == id)) +} + +/// Release whatever a test left in the shared state, as `NET_CLOSED` would. +fn forget_conn(id: i64) { + let fx = with_state(|st| conn::on_closed(st, id)); + for effect in fx { + if let Effect::FreeId(freed) = effect { + perry_ffi::free_handle_id(freed); + } + } +} + +/// Was a `tests.rs` test of the reqwest dispatch (#5892 remainder / +/// issue_4909 early exit): from the moment a request is dispatched until its +/// response events are queued, the exchange must be visible to the exit gate. +/// The in-flight guard is now owned by the exchange itself, so it is held +/// from `start` — before the connect even completes — and released exactly +/// when the terminal event is produced. +#[test] +fn dispatch_request_stays_visible_to_exit_gate_until_response_queued() { + let _lock = lock(); + let request_handle = 0x7e57_0001; + let baseline = crate::js_ext_http_client_inflight(); + let fx = + with_state(|st| start_locked(st, outbound(request_handle, "http://127.0.0.1:9/", &[]))); + let id = connect_id(&fx); + assert!( + crate::js_ext_http_client_inflight() > baseline, + "in-flight guard must be held from dispatch, before the connect completes" + ); + let fx = with_state(|st| conn::on_connect(st, id)); + assert!(written(&fx).starts_with(b"GET / HTTP/1.1\r\n")); + assert!(crate::js_ext_http_client_inflight() > baseline); + + // The head arrives split mid-line across two reads: the first must be + // retained, not dropped. + let fx = with_state(|st| conn::on_data(st, id, b"HTTP/1.1 200 Fine By Me\r\ncontent-le")); + assert!(pushed(&fx).is_empty(), "no event from half a head"); + assert!(crate::js_ext_http_client_inflight() > baseline); + let fx = with_state(|st| conn::on_data(st, id, b"ngth: 2\r\n\r\nok")); + assert_eq!(pushed(&fx), ["head", "chunk", "end"]); + let reason = fx.iter().find_map(|e| match e { + Effect::Push(PendingHttpEvent::ResponseHead { status_message, .. }) => { + Some(status_message.clone()) + } + _ => None, + }); + assert_eq!( + reason.as_deref(), + Some("Fine By Me"), + "the server's own reason phrase" + ); + assert!(closes(&fx, id), "no agent: the connection is not pooled"); + assert_eq!( + crate::js_ext_http_client_inflight(), + baseline, + "the guard must release with the terminal event" + ); + forget_conn(id); +} + +#[test] +fn a_kept_alive_connection_is_parked_after_end_and_reused() { + let _lock = lock(); + let agent = 0x7e57_a9e1; + let mut first = outbound(0x7e57_0010, "http://127.0.0.1:9/a", &[]); + first.key.agent = agent; + first.reuse = Some(Reuse { + max_free: 2, + idle_ms: 60_000, + }); + let fx = with_state(|st| start_locked(st, first)); + let id = connect_id(&fx); + with_state(|st| conn::on_connect(st, id)); + let fx = + with_state(|st| conn::on_data(st, id, b"HTTP/1.1 200 OK\r\ncontent-length: 1\r\n\r\na")); + assert_eq!(pushed(&fx), ["head", "chunk", "end"]); + assert!( + !closes(&fx, id), + "a complete keep-alive response parks the connection" + ); + assert!(fx + .iter() + .any(|e| matches!(e, Effect::SetRef(c, false) if *c == id))); + + let reused_before = reused_total(); + let mut second = outbound(0x7e57_0011, "http://127.0.0.1:9/b", &[]); + second.key.agent = agent; + second.reuse = Some(Reuse { + max_free: 2, + idle_ms: 60_000, + }); + let fx = with_state(|st| start_locked(st, second)); + assert!( + !fx.iter().any(|e| matches!(e, Effect::Connect { .. })), + "the parked connection is reused, not a new one dialed" + ); + assert!(written(&fx).starts_with(b"GET /b HTTP/1.1\r\n")); + assert_eq!(reused_total(), reused_before + 1); + + // The stale keep-alive race: the peer closes before any response byte. + // The request goes again on a fresh connection instead of failing. + let fx = with_state(|st| conn::on_eof(st, id)); + assert!( + fx.iter() + .any(|e| matches!(e, Effect::Redispatch(out, _) if out.request_handle == 0x7e57_0011)), + "a reused connection that dies before the response is retried once" + ); + assert!(pushed(&fx).is_empty(), "and no error reaches the request"); + forget_conn(id); +} + +#[test] +fn a_response_that_closes_is_never_pooled() { + let _lock = lock(); + let mut out = outbound(0x7e57_0020, "http://127.0.0.1:9/", &[]); + out.key.agent = 0x7e57_a9e2; + out.reuse = Some(Reuse { + max_free: 2, + idle_ms: 60_000, + }); + let fx = with_state(|st| start_locked(st, out)); + let id = connect_id(&fx); + with_state(|st| conn::on_connect(st, id)); + let fx = with_state(|st| { + conn::on_data( + st, + id, + b"HTTP/1.1 200 OK\r\nconnection: close\r\ncontent-length: 1\r\n\r\na", + ) + }); + assert_eq!(pushed(&fx), ["head", "chunk", "end"]); + assert!(closes(&fx, id)); + forget_conn(id); +} + +#[test] +fn a_deadline_times_the_exchange_out_and_closes_it() { + let _lock = lock(); + let mut out = outbound(0x7e57_0030, "http://127.0.0.1:9/", &[]); + out.timeout_ms = Some(50); + let fx = with_state(|st| start_locked(st, out)); + let id = connect_id(&fx); + let timer = fx + .iter() + .find_map(|e| match e { + Effect::ArmTimer(timer, 50) => Some(*timer), + _ => None, + }) + .expect("the deadline is armed at dispatch"); + let fx = with_state(|st| conn::on_timer(st, timer)); + assert_eq!(pushed(&fx), ["timeout"]); + assert!(closes(&fx, id)); + // A response racing the deadline is dropped, not delivered twice. + let fx = + with_state(|st| conn::on_data(st, id, b"HTTP/1.1 200 OK\r\ncontent-length: 0\r\n\r\n")); + assert!(pushed(&fx).is_empty()); + forget_conn(id); +} + +#[test] +fn a_close_before_the_head_is_a_socket_hang_up() { + let _lock = lock(); + let fx = with_state(|st| start_locked(st, outbound(0x7e57_0040, "http://127.0.0.1:9/", &[]))); + let id = connect_id(&fx); + with_state(|st| conn::on_connect(st, id)); + let fx = with_state(|st| conn::on_eof(st, id)); + assert_eq!(pushed(&fx), ["coded-error"]); + let message = fx.iter().find_map(|e| match e { + Effect::Push(PendingHttpEvent::CodedError { message, code, .. }) => { + Some((message.clone(), code.clone())) + } + _ => None, + }); + assert_eq!( + message, + Some(("socket hang up".to_string(), "ECONNRESET".to_string())) + ); + forget_conn(id); +} + +#[test] +fn a_body_delimited_by_the_close_ends_at_eof() { + let _lock = lock(); + let fx = with_state(|st| start_locked(st, outbound(0x7e57_0050, "http://127.0.0.1:9/", &[]))); + let id = connect_id(&fx); + with_state(|st| conn::on_connect(st, id)); + let fx = with_state(|st| conn::on_data(st, id, b"HTTP/1.0 200 OK\r\n\r\npartial")); + assert_eq!(pushed(&fx), ["head", "chunk"]); + let fx = with_state(|st| conn::on_eof(st, id)); + assert_eq!(pushed(&fx), ["end"]); + forget_conn(id); +} + +#[test] +fn a_connect_failure_names_the_peer_as_node_does() { + let _lock = lock(); + let fx = with_state(|st| start_locked(st, outbound(0x7e57_0060, "http://127.0.0.1:1/", &[]))); + let id = connect_id(&fx); + let fx = with_state(|st| conn::on_error(st, id, "ECONNREFUSED", "connect", -111)); + let message = fx.iter().find_map(|e| match e { + Effect::Push(PendingHttpEvent::TransportError { message, .. }) => Some(message.clone()), + _ => None, + }); + assert_eq!(message.as_deref(), Some("connect ECONNREFUSED 127.0.0.1:1")); + forget_conn(id); +} + +#[test] +fn trailers_are_delivered_with_the_buffered_response() { + let _lock = lock(); + let fx = with_state(|st| { + start_locked( + st, + outbound(0x7e57_0070, "http://127.0.0.1:9/", &[("TE", "trailers")]), + ) + }); + let id = connect_id(&fx); + let fx = with_state(|st| conn::on_connect(st, id)); + assert!(String::from_utf8_lossy(&written(&fx)).contains("Connection: close\r\n")); + let fx = with_state(|st| { + conn::on_data( + st, + id, + b"HTTP/1.1 200 OK\r\ntransfer-encoding: chunked\r\n\r\n2\r\nok\r\n0\r\nx-sum: 7\r\n\r\n", + ) + }); + assert_eq!(pushed(&fx), ["response"]); + let delivered = fx.iter().find_map(|e| match e { + Effect::Push(PendingHttpEvent::Response { body, trailers, .. }) => { + Some((body.clone(), trailers.clone())) + } + _ => None, + }); + assert_eq!( + delivered, + Some((b"ok".to_vec(), vec![("x-sum".to_string(), "7".to_string())])) + ); + forget_conn(id); +} + +#[test] +fn an_expect_continue_body_waits_for_the_interim_response() { + let _lock = lock(); + let tls = TlsOptions::default(); + let out = prepare(Request { + request_handle: 0x7e57_0080, + method: "POST", + url: "http://127.0.0.1:9/up", + headers: headers(&[("Expect", "100-continue")]), + body: Vec::new(), + timeout_ms: None, + agent_handle: 0, + tls: &tls, + continue_mode: true, + }) + .unwrap_or_else(|message| panic!("{message}")); + let fx = with_state(|st| start_locked(st, out)); + let id = connect_id(&fx); + let fx = with_state(|st| conn::on_connect(st, id)); + let head = String::from_utf8(written(&fx)).unwrap(); + assert!(head.contains("Transfer-Encoding: chunked\r\n"), "{head}"); + assert!(head.ends_with("\r\n\r\n"), "only the head goes out: {head}"); + + // `end()` runs before the server's `100`: the body is held. + let fx = with_state(|st| { + let mut fx = Vec::new(); + conn::continue_body(st, 0x7e57_0080, b"data".to_vec(), &mut fx); + fx + }); + assert!( + written(&fx).is_empty(), + "the body waits for the interim 100" + ); + let fx = with_state(|st| conn::on_data(st, id, b"HTTP/1.1 100 Continue\r\n\r\n")); + assert_eq!(pushed(&fx), ["continue"]); + assert_eq!(written(&fx), b"4\r\ndata\r\n0\r\n\r\n"); + let fx = + with_state(|st| conn::on_data(st, id, b"HTTP/1.1 200 OK\r\ncontent-length: 0\r\n\r\n")); + assert_eq!(pushed(&fx), ["head", "end"]); + forget_conn(id); +} + +#[test] +fn a_101_hands_the_connection_to_net_with_the_bytes_after_the_head() { + let _lock = lock(); + let fx = with_state(|st| { + start_locked( + st, + outbound( + 0x7e57_0090, + "http://127.0.0.1:9/ws", + &[("Connection", "Upgrade"), ("Upgrade", "websocket")], + ), + ) + }); + let id = connect_id(&fx); + with_state(|st| conn::on_connect(st, id)); + let fx = with_state(|st| { + conn::on_data( + st, + id, + b"HTTP/1.1 101 Switching Protocols\r\nupgrade: websocket\r\nconnection: upgrade\r\n\r\n\x81\x00", + ) + }); + let handoff = fx.iter().find_map(|e| match e { + Effect::Handoff(c, PendingHttpEvent::Upgrade { head, status, .. }) if *c == id => { + Some((*status, head.clone())) + } + _ => None, + }); + assert_eq!(handoff, Some((101, vec![0x81, 0x00]))); + assert!(!closes(&fx, id), "the handle now belongs to net"); + assert!( + with_state(|st| !st.conns.contains_key(&id)), + "the transport forgets the connection without closing it" + ); + perry_ffi::free_handle_id(id); +} + +#[test] +fn a_cancelled_request_is_closed_without_an_event() { + let _lock = lock(); + let fx = with_state(|st| start_locked(st, outbound(0x7e57_00a0, "http://127.0.0.1:9/", &[]))); + let id = connect_id(&fx); + let fx = with_state(|st| { + let mut fx = Vec::new(); + conn::cancel(st, 0x7e57_00a0, &mut fx); + fx + }); + assert!(pushed(&fx).is_empty()); + assert!(closes(&fx, id)); + let fx = with_state(|st| conn::on_closed(st, id)); + assert!(pushed(&fx).is_empty(), "no late event after a cancel"); + for effect in fx { + if let Effect::FreeId(freed) = effect { + perry_ffi::free_handle_id(freed); + } + } +} diff --git a/crates/perry-ext-http/src/client_turnloop/tls.rs b/crates/perry-ext-http/src/client_turnloop/tls.rs new file mode 100644 index 0000000000..9250d98bb6 --- /dev/null +++ b/crates/perry-ext-http/src/client_turnloop/tls.rs @@ -0,0 +1,196 @@ +//! `https:` for this lane: the rustls configuration a request's Node TLS +//! options describe, and the session that runs it above the turnloop handle. +//! +//! The configuration is `tls_client::TlsOptions::client_config` — the same +//! verifier stack (Node CA semantics, the Common-Name fallback, the exact-leaf +//! self-signed trust case, `rejectUnauthorized: false`, PKCS#12 client +//! identities) reqwest was handed through `use_preconfigured_tls`, now handed +//! to [`perry_tls_session::TlsSession`] instead. Nothing about *what* is +//! verified changes; only who drives the records. +//! +//! # Configs are cached, and that is load-bearing +//! +//! rustls keeps its TLS session-resumption store inside the `ClientConfig`. +//! reqwest's per-agent client cache is what let a second request resume the +//! first one's session (`tls_compat.rs`), so a fresh config per request would +//! silently disable resumption. Configs are therefore memoized per +//! option-identity for the life of the process, like the reqwest clients were. +//! +//! # ALPN +//! +//! None is offered. Node's `https` client sends no ALPN extension by default +//! and only ever speaks HTTP/1.1, while reqwest's default connector offered +//! `h2` — so a server that also spoke HTTP/2 used to negotiate it and report +//! `res.httpVersion === '2.0'` where Node reports `'1.1'`. + +use std::collections::HashMap; +use std::hash::{Hash, Hasher}; +use std::sync::{Arc, Mutex, OnceLock}; + +use perry_tls_session::TlsSession; +use rustls::pki_types::ServerName; + +use crate::tls_client::TlsOptions; + +/// Everything needed to open a session, decided on the JS thread at dispatch. +#[derive(Clone)] +pub(crate) struct TlsPlan { + pub(super) config: Arc, + pub(super) server_name: ServerName<'static>, +} + +fn configs() -> &'static Mutex>> { + static CONFIGS: OnceLock>>> = OnceLock::new(); + CONFIGS.get_or_init(|| Mutex::new(HashMap::new())) +} + +/// The identity a config is cached under. Two option sets that build the same +/// verifier share one config (and one resumption store); anything that could +/// change verification or the client identity separates them. +pub(super) fn identity(options: &TlsOptions) -> u64 { + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + let environment = perry_ffi::node_tls_client_environment(); + if options.needs_custom_client() { + // `rejectUnauthorized: true` is the default spelled out; it builds the + // same verifier as leaving it unset, so it must not split the store. + let mut canonical = options.clone(); + if canonical.reject_unauthorized == Some(true) { + canonical.reject_unauthorized = None; + } + canonical.hash(&mut hasher); + environment.accepts_invalid_certificates().hash(&mut hasher); + environment.ca_pems().hash(&mut hasher); + } else { + // Every request with no TLS customization shares one config, the way + // they all shared reqwest's pooled default client. + 0u8.hash(&mut hasher); + } + hasher.finish() +} + +/// Build (or fetch) the config for `options` and pair it with the name the +/// handshake verifies and, unless suppressed, sends as SNI. +pub(crate) fn plan(options: &TlsOptions, url_host: &str) -> Result { + let key = identity(options); + let cached = configs() + .lock() + .unwrap_or_else(|e| e.into_inner()) + .get(&key) + .cloned(); + let config = match cached { + Some(config) => config, + None => { + let mut built = options.client_config()?; + // `servername: ''` is Node's way of sending no SNI at all. + if options.servername.as_deref() == Some("") { + built.enable_sni = false; + } + let built = Arc::new(built); + configs() + .lock() + .unwrap_or_else(|e| e.into_inner()) + .entry(key) + .or_insert(built) + .clone() + } + }; + // Node sends `servername` as SNI and verifies against it; an empty one + // means "no SNI", so the URL host is what is verified. + let name = options + .servername + .as_deref() + .filter(|name| !name.is_empty()) + .unwrap_or(url_host); + let server_name = ServerName::try_from(name.to_string()) + .map_err(|_| format!("ERR_TLS_CERT_ALTNAME_INVALID: invalid servername {name:?}"))?; + Ok(TlsPlan { + config, + server_name, + }) +} + +/// Open the client session for a plan. The ClientHello is produced by the +/// first `pump`. +pub(super) fn open(plan: &TlsPlan) -> Result { + TlsSession::client(plan.config.clone(), plan.server_name.clone()).map_err(|e| e.to_string()) +} + +/// Node's message for a handshake failure's cause code. Node reports these as +/// an `Error` carrying `.code` and OpenSSL's text; rustls has its own text, so +/// the codes Node users test for get Node's words and anything else keeps +/// rustls's. +pub(super) fn node_failure_message(code: &str, rustls_message: &str) -> String { + match code { + "UNABLE_TO_VERIFY_LEAF_SIGNATURE" => "unable to verify the first certificate".to_string(), + "CERT_HAS_EXPIRED" => "certificate has expired".to_string(), + "CERT_NOT_YET_VALID" => "certificate is not yet valid".to_string(), + "CERT_REVOKED" => "certificate revoked".to_string(), + "ERR_TLS_CERT_ALTNAME_INVALID" => { + format!("Hostname/IP does not match certificate's altnames: {rustls_message}") + } + _ => rustls_message.to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn requests_without_tls_options_share_one_config() { + let a = TlsOptions::default(); + let b = TlsOptions { + reject_unauthorized: Some(true), + ..TlsOptions::default() + }; + // Both are "no customization": one resumption store, as with reqwest's + // single pooled client. (Holds whether or not the host environment + // sets `SSL_CERT_FILE` / `NODE_EXTRA_CA_CERTS`, which makes every + // request "custom" but still identical.) + assert_eq!(identity(&a), identity(&b)); + let custom = TlsOptions { + reject_unauthorized: Some(false), + ..TlsOptions::default() + }; + assert_ne!(identity(&a), identity(&custom)); + } + + #[test] + fn a_plan_is_memoized_so_session_resumption_survives() { + let options = TlsOptions { + reject_unauthorized: Some(false), + ..TlsOptions::default() + }; + let first = plan(&options, "localhost").expect("plan builds"); + let second = plan(&options, "localhost").expect("plan builds"); + assert!(Arc::ptr_eq(&first.config, &second.config)); + assert!( + first.config.alpn_protocols.is_empty(), + "Node offers no ALPN" + ); + } + + #[test] + fn an_empty_servername_disables_sni_but_verifies_the_url_host() { + let options = TlsOptions { + reject_unauthorized: Some(false), + servername: Some(String::new()), + ..TlsOptions::default() + }; + let plan = plan(&options, "localhost").expect("plan builds"); + assert!(!plan.config.enable_sni); + assert_eq!(plan.server_name.to_str(), "localhost"); + } + + #[test] + fn node_codes_get_node_text() { + assert_eq!( + node_failure_message("UNABLE_TO_VERIFY_LEAF_SIGNATURE", "x"), + "unable to verify the first certificate" + ); + assert_eq!( + node_failure_message("ERR_SSL_PROTOCOL_ERROR", "rustls text"), + "rustls text" + ); + } +} diff --git a/crates/perry-ext-http/src/client_turnloop/wire.rs b/crates/perry-ext-http/src/client_turnloop/wire.rs new file mode 100644 index 0000000000..f425f281e0 --- /dev/null +++ b/crates/perry-ext-http/src/client_turnloop/wire.rs @@ -0,0 +1,417 @@ +//! What this lane puts on the wire, and the one thing it reads off it by hand. +//! +//! Requests are serialized here rather than through `turnloop_http`'s +//! `Encoder`, for two reasons the codec cannot accommodate: +//! +//! * **`node:http` is not Fetch.** `client::Request::head` drops a caller's +//! `Host` and substitutes the URL authority, refuses URLs with credentials +//! and the `CONNECT`/`TRACE`/`TRACK` methods, and `Header::new` lowercases +//! every name. Node sends what the caller set, in the caller's case. +//! * **The three raw-socket bypasses this lane absorbs each had their own +//! head** (`TE: trailers`, `Expect: 100-continue`, `Connection: Upgrade`); +//! their observable framing — `Connection: close`, a chunked continue body — +//! is kept, not re-derived. +//! +//! The *response* side stays entirely on the codec (`http1::Decoder`). The +//! only thing read by hand is the reason phrase, which `http1::Head` does not +//! carry: it is lifted from the status line the decoder just consumed, so +//! `res.statusMessage` is the server's text, as in Node — not the canonical +//! phrase reqwest substituted. + +use std::collections::HashMap; + +use super::Mode; + +/// How the body follows the head. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum Framing { + /// Raw bytes after the head: no body, or a `Content-Length` the caller + /// set or this module added. + Raw, + /// `Transfer-Encoding: chunked` — the caller asked for it, or the body is + /// not known when the head goes out (`Expect: 100-continue`). + Chunked, +} + +/// A serialized request head plus how its body must be framed. +pub(super) struct Serialized { + pub(super) head: Vec, + pub(super) framing: Framing, + /// The request itself asked the server to close (`Connection: close`, + /// sent by the caller or forced by the mode). Such a connection is never + /// returned to the pool whatever the response says. + pub(super) closes: bool, +} + +fn has_header(headers: &HashMap, name: &str) -> bool { + headers.keys().any(|k| k.eq_ignore_ascii_case(name)) +} + +fn header_token(headers: &HashMap, name: &str, token: &str) -> bool { + headers.iter().any(|(k, v)| { + k.eq_ignore_ascii_case(name) + && v.split(',') + .any(|part| part.trim().eq_ignore_ascii_case(token)) + }) +} + +/// The request-target: origin-form, or absolute-form through an HTTP proxy. +pub(super) fn request_target(url: &url::Url, absolute: bool) -> String { + if absolute { + let mut url = url.clone(); + url.set_fragment(None); + return url.as_str().to_owned(); + } + let mut target = url.path().to_string(); + if target.is_empty() { + target.push('/'); + } + if let Some(query) = url.query() { + target.push('?'); + target.push_str(query); + } + target +} + +/// `Host`'s value when the caller did not set one: the URL authority, with the +/// port only when it is not the scheme default — what reqwest (hyper) and +/// Node both send. +pub(super) fn authority(url: &url::Url) -> String { + let mut out = url.host_str().unwrap_or("").to_string(); + if let Some(port) = url.port() { + out.push(':'); + out.push_str(&port.to_string()); + } + out +} + +/// Serialize a request head. +/// +/// `extra` are headers this lane adds that the caller did not set (the +/// in-process HTTPS server's forwarding token, a proxy's +/// `Proxy-Authorization`); they follow the caller's own. +pub(super) fn serialize_head( + method: &str, + target: &str, + url: &url::Url, + headers: &HashMap, + extra: &[(String, String)], + body_len: usize, + mode: Mode, +) -> Serialized { + let mut out = String::with_capacity(256); + out.push_str(method); + out.push(' '); + out.push_str(target); + out.push_str(" HTTP/1.1\r\n"); + + // Host first, as Node writes it. A caller's own `Host` wins — reqwest sent + // it verbatim, and so does Node. + let caller_host = headers + .iter() + .find(|(k, _)| k.eq_ignore_ascii_case("host")) + .map(|(_, v)| v.clone()); + out.push_str("Host: "); + out.push_str(&caller_host.unwrap_or_else(|| authority(url))); + out.push_str("\r\n"); + + // `TE: trailers` and `Expect: 100-continue` read the response to its end + // on a connection nobody reuses, so they force `Connection: close` exactly + // as their raw-socket bypasses did. An upgrade carries the caller's own + // `Connection: Upgrade`. + let forces_close = matches!(mode, Mode::Trailers | Mode::Continue); + for (name, value) in headers { + if name.eq_ignore_ascii_case("host") + || (forces_close && name.eq_ignore_ascii_case("connection")) + { + continue; + } + out.push_str(name); + out.push_str(": "); + out.push_str(value); + out.push_str("\r\n"); + } + for (name, value) in extra { + out.push_str(name); + out.push_str(": "); + out.push_str(value); + out.push_str("\r\n"); + } + + let caller_length = has_header(headers, "content-length"); + let caller_chunked = header_token(headers, "transfer-encoding", "chunked"); + let framing = if caller_length { + Framing::Raw + } else if caller_chunked { + Framing::Chunked + } else if mode == Mode::Continue { + // The body is withheld until the interim `100 Continue`, so its length + // is not known when the head goes out. + out.push_str("Transfer-Encoding: chunked\r\n"); + Framing::Chunked + } else { + if body_len > 0 { + out.push_str(&format!("Content-Length: {body_len}\r\n")); + } + Framing::Raw + }; + + let closes = if forces_close { + out.push_str("Connection: close\r\n"); + true + } else if mode == Mode::Upgrade || has_header(headers, "connection") { + header_token(headers, "connection", "close") + } else { + // Node's default agent is keep-alive (v19+) and says so explicitly; + // servers reading `req.headers.connection` expect it. Unchanged from + // the reqwest path. + out.push_str("Connection: keep-alive\r\n"); + false + }; + out.push_str("\r\n"); + Serialized { + head: out.into_bytes(), + framing, + closes, + } +} + +/// Frame a complete body for the wire. +pub(super) fn frame_body(body: &[u8], framing: Framing) -> Vec { + match framing { + Framing::Raw => body.to_vec(), + Framing::Chunked => { + let mut framed = Vec::with_capacity(body.len() + 16); + if !body.is_empty() { + framed.extend_from_slice(format!("{:x}\r\n", body.len()).as_bytes()); + framed.extend_from_slice(body); + framed.extend_from_slice(b"\r\n"); + } + framed.extend_from_slice(b"0\r\n\r\n"); + framed + } + } +} + +/// The reason phrase of the status line at the start of `head` (the bytes the +/// decoder just consumed for a `Head`/`Informational` event). Empty when the +/// server sent none — which is also what Node reports. +pub(super) fn reason_phrase(head: &[u8]) -> String { + let line_end = head + .windows(2) + .position(|w| w == b"\r\n") + .unwrap_or(head.len()); + let line = String::from_utf8_lossy(&head[..line_end]); + let mut parts = line.splitn(3, ' '); + let _version = parts.next(); + let _status = parts.next(); + parts.next().unwrap_or("").to_string() +} + +/// `Basic` credentials for a proxy URL that carries them, percent-decoded. +pub(super) fn basic_credentials(proxy: &url::Url) -> Option { + use base64::Engine; + if proxy.username().is_empty() && proxy.password().is_none() { + return None; + } + let decode = |s: &str| -> Vec { + let bytes = s.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' && i + 3 <= bytes.len() { + let hex = std::str::from_utf8(&bytes[i + 1..i + 3]).ok(); + if let Some(value) = hex.and_then(|h| u8::from_str_radix(h, 16).ok()) { + out.push(value); + i += 3; + continue; + } + } + out.push(bytes[i]); + i += 1; + } + out + }; + let mut credential = decode(proxy.username()); + credential.push(b':'); + credential.extend(decode(proxy.password().unwrap_or(""))); + Some(format!( + "Basic {}", + base64::engine::general_purpose::STANDARD.encode(credential) + )) +} + +/// The `CONNECT` head that opens a tunnel through an HTTP proxy. +pub(super) fn connect_head(target_host: &str, target_port: u16, proxy: &url::Url) -> Vec { + let authority = if target_host.contains(':') { + format!("[{target_host}]:{target_port}") + } else { + format!("{target_host}:{target_port}") + }; + let mut out = format!("CONNECT {authority} HTTP/1.1\r\nHost: {authority}\r\n"); + if let Some(credentials) = basic_credentials(proxy) { + out.push_str("Proxy-Authorization: "); + out.push_str(&credentials); + out.push_str("\r\n"); + } + out.push_str("\r\n"); + out.into_bytes() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn headers(pairs: &[(&str, &str)]) -> HashMap { + pairs + .iter() + .map(|(k, v)| ((*k).to_string(), (*v).to_string())) + .collect() + } + + fn text(s: &Serialized) -> String { + String::from_utf8(s.head.clone()).unwrap() + } + + fn url(s: &str) -> url::Url { + url::Url::parse(s).unwrap() + } + + #[test] + fn a_body_gets_a_content_length_and_the_default_keep_alive() { + let u = url("http://example.invalid:8080/p?q=1"); + let s = serialize_head( + "POST", + &request_target(&u, false), + &u, + &headers(&[("Content-Type", "text/plain")]), + &[], + 5, + Mode::Normal, + ); + let t = text(&s); + assert!(t.starts_with("POST /p?q=1 HTTP/1.1\r\nHost: example.invalid:8080\r\n")); + assert!(t.contains("Content-Type: text/plain\r\n"), "{t}"); + assert!(t.contains("Content-Length: 5\r\n"), "{t}"); + assert!(t.ends_with("Connection: keep-alive\r\n\r\n"), "{t}"); + assert_eq!(s.framing, Framing::Raw); + assert!(!s.closes); + } + + #[test] + fn a_callers_host_and_header_case_reach_the_wire_verbatim() { + let u = url("http://127.0.0.1:1/"); + let s = serialize_head( + "GET", + "/", + &u, + &headers(&[("Host", "vhost.invalid"), ("X-Mixed-Case", "v")]), + &[], + 0, + Mode::Normal, + ); + let t = text(&s); + assert!(t.contains("Host: vhost.invalid\r\n"), "{t}"); + assert_eq!(t.matches("Host:").count(), 1, "{t}"); + assert!(t.contains("X-Mixed-Case: v\r\n"), "{t}"); + assert!(!t.contains("Content-Length"), "{t}"); + } + + #[test] + fn a_callers_chunked_encoding_frames_the_body_chunked() { + let u = url("http://h.invalid/"); + let s = serialize_head( + "PUT", + "/", + &u, + &headers(&[("Transfer-Encoding", "chunked")]), + &[], + 3, + Mode::Normal, + ); + assert_eq!(s.framing, Framing::Chunked); + assert!(!text(&s).contains("Content-Length")); + assert_eq!( + frame_body(b"abc", Framing::Chunked), + b"3\r\nabc\r\n0\r\n\r\n" + ); + assert_eq!(frame_body(b"", Framing::Chunked), b"0\r\n\r\n"); + } + + #[test] + fn the_bypass_modes_keep_their_close_framing() { + let u = url("http://h.invalid/"); + let trailers = serialize_head( + "GET", + "/", + &u, + &headers(&[("TE", "trailers"), ("Connection", "keep-alive")]), + &[], + 0, + Mode::Trailers, + ); + let t = text(&trailers); + assert!(t.ends_with("Connection: close\r\n\r\n"), "{t}"); + assert_eq!(t.matches("Connection").count(), 1, "{t}"); + assert!(trailers.closes); + + let cont = serialize_head( + "POST", + "/", + &u, + &headers(&[("Expect", "100-continue")]), + &[], + 0, + Mode::Continue, + ); + let t = text(&cont); + assert!(t.contains("Transfer-Encoding: chunked\r\n"), "{t}"); + assert!(t.ends_with("Connection: close\r\n\r\n"), "{t}"); + assert_eq!(cont.framing, Framing::Chunked); + + let upgrade = serialize_head( + "GET", + "/", + &u, + &headers(&[("Connection", "Upgrade"), ("Upgrade", "websocket")]), + &[], + 0, + Mode::Upgrade, + ); + let t = text(&upgrade); + assert!(t.contains("Connection: Upgrade\r\n"), "{t}"); + assert!(!t.contains("keep-alive"), "{t}"); + assert!(!upgrade.closes); + } + + #[test] + fn absolute_form_is_used_through_an_http_proxy() { + let u = url("http://h.invalid:81/a?b#frag"); + assert_eq!(request_target(&u, true), "http://h.invalid:81/a?b"); + assert_eq!(request_target(&u, false), "/a?b"); + } + + #[test] + fn the_reason_phrase_is_the_servers_own() { + assert_eq!( + reason_phrase(b"HTTP/1.1 200 Custom Words\r\nx: y\r\n\r\n"), + "Custom Words" + ); + assert_eq!(reason_phrase(b"HTTP/1.1 204\r\n\r\n"), ""); + assert_eq!( + reason_phrase(b"HTTP/1.0 404 Not Found\r\n\r\n"), + "Not Found" + ); + } + + #[test] + fn proxy_credentials_are_percent_decoded_basic() { + let p = url("http://us%40er:p%3Aw@proxy.invalid:3128"); + // base64("us@er:p:w") + assert_eq!(basic_credentials(&p).as_deref(), Some("Basic dXNAZXI6cDp3")); + assert_eq!(basic_credentials(&url("http://proxy.invalid:3128")), None); + let head = String::from_utf8(connect_head("h.invalid", 443, &p)).unwrap(); + assert!(head.starts_with("CONNECT h.invalid:443 HTTP/1.1\r\nHost: h.invalid:443\r\n")); + assert!(head.contains("Proxy-Authorization: Basic dXNAZXI6cDp3\r\n")); + } +} diff --git a/crates/perry-ext-http/src/client_upgrade.rs b/crates/perry-ext-http/src/client_upgrade.rs index 6aaee7211f..eb0d95a995 100644 --- a/crates/perry-ext-http/src/client_upgrade.rs +++ b/crates/perry-ext-http/src/client_upgrade.rs @@ -1,26 +1,18 @@ //! #10468 — client-side protocol upgrade (`Connection: Upgrade`). A `101 //! Switching Protocols` response hands the caller the raw socket through //! `req.on('upgrade', (res, socket, head) => ...)` instead of an ordinary -//! `'response'`. reqwest consumes the connection as a normal response body -//! and never exposes it, so an upgrade request speaks HTTP/1.1 over a raw -//! `TcpStream` instead — the same shape as the trailer-aware bypass in -//! `plain_client.rs` — and, on a `101`, adopts the stream into -//! `perry_ext_net` as a `net.Socket` (mirrors the server's -//! `server/raw_upgrade.rs`). +//! `'response'`. //! -//! Scope: plain `http://` only — TLS upgrade needs a different transport -//! and falls through to the normal path (pre-#10468 behavior: no upgrade), -//! same as when this module isn't triggered at all (no `Connection: -//! Upgrade`, or an Agent/`createConnection` override already claimed the -//! connection before `dispatch_request` runs). +//! The exchange runs in `client_turnloop` (`Mode::Upgrade`): the codec reports +//! the `101` as `Event::Upgrade`, and the live turnloop handle is handed to +//! `perry_ext_net` with `turnloop_net::transfer` — the server's `'upgrade'` +//! handoff, from the client side — so no descriptor moves and no byte is lost. +//! This module used to speak the request over a raw tokio `TcpStream` because +//! reqwest never exposed the connection. What remains is the predicate every +//! path agrees on. use std::collections::HashMap; -use perry_ffi::Handle; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; - -use crate::{push_event, PendingHttpEvent}; - /// `true` if `headers` asks for a protocol upgrade — `Connection: Upgrade` /// as one token of a comma list (RFC 7230 §6.1; Node/undici send it as a /// bare `Upgrade` value in practice). @@ -32,157 +24,3 @@ pub(crate) fn wants_upgrade(headers: &HashMap) -> bool { .any(|part| part.trim().eq_ignore_ascii_case("upgrade")) }) } - -/// Speak the request over a raw `TcpStream` when it wants a protocol -/// upgrade. `None` means "not applicable" (not an upgrade request, or -/// `https://` — fall through to the normal reqwest path); `Some(Ok(()))` -/// once the exchange has been fully handed off to a `PendingHttpEvent` -/// (`Upgrade` on `101`, `Response` otherwise); `Some(Err(_))` on a -/// transport failure. Mirrors `plain_client::dispatch_plain_http_request`'s -/// bypass contract. -pub(crate) async fn dispatch_upgrade_http_request( - request_handle: Handle, - method: &str, - url: &str, - headers: &HashMap, - body: &[u8], - timeout_ms: Option, -) -> Option> { - if !wants_upgrade(headers) { - return None; - } - let parsed = match reqwest::Url::parse(url) { - Ok(u) if u.scheme() == "http" => u, - // https:// upgrade isn't implemented — let the caller fall through - // rather than mishandle it here (matches pre-#10468 behavior for TLS). - _ => return None, - }; - let host = match parsed.host_str() { - Some(h) => h.to_string(), - None => return Some(Err("missing host".to_string())), - }; - let port = parsed.port_or_known_default().unwrap_or(80); - let mut path = parsed.path().to_string(); - if path.is_empty() { - path.push('/'); - } - if let Some(q) = parsed.query() { - path.push('?'); - path.push_str(q); - } - - let deadline = std::time::Duration::from_millis(timeout_ms.unwrap_or(30_000)); - let fut = async { - let mut stream = tokio::net::TcpStream::connect((host.as_str(), port)).await?; - let host_header = if parsed.port().is_some() { - format!("{}:{}", host, port) - } else { - host.clone() - }; - let mut req = format!("{} {} HTTP/1.1\r\nHost: {}\r\n", method, path, host_header); - let mut has_content_length = false; - for (k, v) in headers { - if k.eq_ignore_ascii_case("content-length") { - has_content_length = true; - } - req.push_str(k); - req.push_str(": "); - req.push_str(v); - req.push_str("\r\n"); - } - if !body.is_empty() && !has_content_length { - req.push_str(&format!("Content-Length: {}\r\n", body.len())); - } - req.push_str("\r\n"); - stream.write_all(req.as_bytes()).await?; - if !body.is_empty() { - stream.write_all(body).await?; - } - - // Read only up to the end of the header block — a `101` keeps the - // connection open for the upgraded protocol, so (unlike - // `plain_client`'s trailer-aware bypass) this must not read to EOF. - let mut buf = Vec::new(); - let mut chunk = [0u8; 4096]; - while !buf.windows(4).any(|w| w == b"\r\n\r\n") { - let n = stream.read(&mut chunk).await?; - if n == 0 { - break; - } - buf.extend_from_slice(&chunk[..n]); - } - Ok::<_, std::io::Error>((stream, buf)) - }; - - let (stream, buf) = match tokio::time::timeout(deadline, fut).await { - Ok(Ok(v)) => v, - Ok(Err(e)) => return Some(Err(e.to_string())), - Err(_) => return Some(Err("request timed out".to_string())), - }; - - let Some(header_end) = buf.windows(4).position(|w| w == b"\r\n\r\n") else { - return Some(Err( - "invalid HTTP response (no header terminator)".to_string() - )); - }; - let head_text = String::from_utf8_lossy(&buf[..header_end]); - let mut lines = head_text.split("\r\n"); - let status_line = lines.next().unwrap_or_default(); - let mut parts = status_line.splitn(3, ' '); - let http_version = parts - .next() - .and_then(|v| v.strip_prefix("HTTP/")) - .and_then(|v| v.split_once('.')) - .and_then(|(maj, min)| Some((maj.parse::().ok()?, min.parse::().ok()?))) - .unwrap_or((1, 1)); - let status: u16 = parts.next().and_then(|s| s.parse().ok()).unwrap_or(0); - let status_message = parts.next().unwrap_or("").to_string(); - let mut hdrs = Vec::new(); - for line in lines { - if let Some((name, value)) = line.split_once(':') { - hdrs.push((name.trim().to_ascii_lowercase(), value.trim().to_string())); - } - } - let rest = buf[header_end + 4..].to_vec(); - - if status == 101 { - // perry-ext-net takes the connection as a plain `std` stream and hands - // it to the agent's turnloop loop; tokio only has to let go of it. - let socket_id = stream.into_std().map_or( - perry_ffi::INVALID_HANDLE, - perry_ext_net::adopt_upgraded_tcp_stream, - ); - push_event(PendingHttpEvent::Upgrade { - request_handle, - status, - status_message, - headers: hdrs, - socket_handle: socket_id, - head: rest, - }); - return Some(Ok(())); - } - - // Server declined the upgrade — deliver an ordinary `'response'`. Read - // the remainder to EOF like the trailer-aware bypass (a non-101 reply - // to an Upgrade request has no further framing guarantee here). - let mut stream = stream; - let mut full = rest; - let mut chunk = [0u8; 16 * 1024]; - loop { - match stream.read(&mut chunk).await { - Ok(0) | Err(_) => break, - Ok(n) => full.extend_from_slice(&chunk[..n]), - } - } - push_event(PendingHttpEvent::Response { - request_handle, - status, - status_message, - headers: hdrs, - trailers: Vec::new(), - body: full, - http_version, - }); - Some(Ok(())) -} diff --git a/crates/perry-ext-http/src/continue_client.rs b/crates/perry-ext-http/src/continue_client.rs index baed123cdf..3115217268 100644 --- a/crates/perry-ext-http/src/continue_client.rs +++ b/crates/perry-ext-http/src/continue_client.rs @@ -1,27 +1,23 @@ -//! Client raw-socket path for `Expect: 100-continue` (issue #5080). +//! `Expect: 100-continue` (issue #5080): the head goes out before `end()`, the +//! body is withheld until the server's interim `100 Continue`, which fires +//! `'continue'`. //! -//! reqwest auto-consumes the interim `100 Continue` response, so a -//! `ClientRequest` carrying `Expect: 100-continue` never surfaces the -//! `'continue'` event through the pooled client. This module speaks -//! HTTP/1.1 over a plain `TcpStream`: it flushes the request head with the -//! body withheld, waits for the server's interim `100 Continue`, emits -//! `'continue'`, then sends the body (handed over from the deferred -//! `req.end()` through a oneshot) and parses the final response with the -//! shared [`crate::parse_http_response`]. +//! This used to be a raw tokio `TcpStream` bypass, because reqwest consumed the +//! interim response. The exchange itself now runs in `client_turnloop` +//! (`Mode::Continue`), where the codec hands the `100` back as +//! `Event::Informational`; this module keeps the Node-facing half — deciding +//! *when* the head is flushed, and handing the body over at `end()`. //! -//! Plain `http://` only — an `https` 100-continue handshake would need the -//! TLS-wrapped socket path and stays on the reqwest route (no `'continue'` -//! event, matching the pre-#5080 behavior). +//! The head keeps the bypass's framing: `Transfer-Encoding: chunked` unless the +//! caller pinned a `Content-Length` / `Transfer-Encoding`, and +//! `Connection: close`. Since the exchange runs on the same transport as every +//! other request, `https:` gets `'continue'` too — Node emits it for both. use std::collections::HashMap; -use perry_ffi::{spawn_blocking_with_reactor as spawn_blocking, with_handle_mut, Handle}; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use perry_ffi::{with_handle_mut, Handle}; -use bytes::Bytes; - -use crate::plain_client::parse_http_response; -use crate::{push_event, ClientInflightGuard, ClientRequestHandle, PendingHttpEvent}; +use crate::ClientRequestHandle; /// Whether the request headers ask for the `100-continue` handshake. pub(crate) fn wants_continue(headers: &HashMap) -> bool { @@ -40,316 +36,56 @@ pub(crate) fn defer_arm(handle: Handle) { }); } -/// #5080 — if the freshly-built request carries `Expect: 100-continue` -/// (plain `http://` only), flush its head now and arm the deferred-body -/// channel. Node puts the head on the wire before `end()` for a continue -/// request and withholds the body until the server's interim `100 Continue` -/// drives the `'continue'` event. A no-op otherwise; the reqwest path keeps -/// the buffered-dispatch-at-`end()` behavior for every other request. +/// #5080 — if the request carries `Expect: 100-continue`, flush its head now +/// and mark the body as withheld. A no-op otherwise, or once armed/ended. pub(crate) fn arm_expect_continue(handle: Handle) { let snapshot = with_handle_mut::(handle, |req| { - if req.ended || req.expects_continue || !req.url.starts_with("http://") { + if req.ended + || req.expects_continue + || !(req.url.starts_with("http://") || req.url.starts_with("https://")) + { return None; } if !wants_continue(&req.headers) { return None; } req.expects_continue = true; + req.continue_body_pending = true; Some(( req.method.clone(), req.url.clone(), req.headers.clone(), req.timeout_ms, + req.agent_handle, + req.tls.clone(), )) }) .flatten(); - if let Some((method, url, headers, timeout_ms)) = snapshot { - dispatch_expect_continue(handle, method, url, headers, timeout_ms); - } -} - -/// Serialize the request head for the continue exchange. The body is -/// withheld, so frame it `Transfer-Encoding: chunked` unless the caller -/// pinned an explicit `Content-Length` / `Transfer-Encoding`; force -/// `Connection: close` (the final response is read until EOF). Drops any -/// caller-supplied `Connection` / `Host` header (`Host` is set from the URL). -fn serialize_continue_head( - method: &str, - path: &str, - host_header: &str, - headers: &HashMap, - use_chunked: bool, -) -> Vec { - let mut head = format!("{} {} HTTP/1.1\r\nHost: {}\r\n", method, path, host_header); - for (k, v) in headers { - if k.eq_ignore_ascii_case("connection") || k.eq_ignore_ascii_case("host") { - continue; - } - head.push_str(k); - head.push_str(": "); - head.push_str(v); - head.push_str("\r\n"); - } - if use_chunked { - head.push_str("Transfer-Encoding: chunked\r\n"); - } - head.push_str("Connection: close\r\n\r\n"); - head.into_bytes() -} - -/// Flush the head of an `Expect: 100-continue` request and arm the deferred -/// body channel. Called on the main thread at request-creation time (Node -/// puts the head on the wire before `end()` for a continue request); the -/// actual exchange runs on a tokio task. -pub(crate) fn dispatch_expect_continue( - request_handle: Handle, - method: String, - url: String, - headers: HashMap, - timeout_ms: Option, -) { - let parsed = match reqwest::Url::parse(&url) { - Ok(u) => u, - Err(e) => { - push_event(PendingHttpEvent::Error { - request_handle, - error_message: e.to_string(), - }); - return; - } - }; - let host = parsed.host_str().unwrap_or("localhost").to_string(); - let port = parsed.port_or_known_default().unwrap_or(80); - let host_header = match parsed.port() { - Some(p) => format!("{}:{}", host, p), - None => host.clone(), - }; - let mut path = parsed.path().to_string(); - if path.is_empty() { - path.push('/'); - } - if let Some(q) = parsed.query() { - path.push('?'); - path.push_str(q); - } - - let use_chunked = !headers.iter().any(|(k, _)| { - k.eq_ignore_ascii_case("content-length") || k.eq_ignore_ascii_case("transfer-encoding") - }); - let head = serialize_continue_head(&method, &path, &host_header, &headers, use_chunked); - - // Hand-off for the withheld body: `req.end()` sends the buffered body - // here once the user's `'continue'` handler (or any later end()) runs. - let (body_tx, body_rx) = tokio::sync::oneshot::channel::>(); - with_handle_mut::(request_handle, |req| { - req.continue_body_tx = Some(body_tx); - }); - - let deadline = std::time::Duration::from_millis(timeout_ms.unwrap_or(30_000)); - - spawn_blocking(move || { - // Defeat LTO dead-stripping of tokio's CONTEXT statics — same - // workaround dispatch_request needs (see spawn_socket_runner). - let try_h = tokio::runtime::Handle::try_current(); - std::hint::black_box(&try_h); - if try_h.is_err() { - push_event(PendingHttpEvent::Error { - request_handle, - error_message: "http client runtime unavailable".to_string(), - }); - return; - } - let handle = tokio::runtime::Handle::current(); - // #5892 remainder: same in-flight guard as `dispatch_request` — the - // continue exchange must stay visible to the exit gate for its whole - // lifetime, not just until the outer spawn closure returns. - let inflight_guard = ClientInflightGuard::new(request_handle); - let jh = handle.spawn(async move { - let _inflight = inflight_guard; - if let Err(error_message) = run_exchange( - request_handle, - host, - port, - head, - use_chunked, - body_rx, - deadline, - ) - .await - { - push_event(PendingHttpEvent::Error { - request_handle, - error_message, - }); - } - }); - std::hint::black_box(&jh); - std::mem::forget(jh); - }); -} - -/// Drive the continue exchange: write the head, observe the interim -/// `100 Continue`, emit `'continue'`, send the deferred body, then read + -/// parse the final response. -async fn run_exchange( - request_handle: Handle, - host: String, - port: u16, - head: Vec, - use_chunked: bool, - body_rx: tokio::sync::oneshot::Receiver>, - deadline: std::time::Duration, -) -> Result<(), String> { - let mut stream = tokio::time::timeout( - deadline, - tokio::net::TcpStream::connect((host.as_str(), port)), - ) - .await - .map_err(|_| "request timed out".to_string())? - .map_err(|e| e.to_string())?; - write_all(&mut stream, &head, deadline).await?; - - // Read until the first complete header block. A 1xx status (e.g. - // `100 Continue`) drives the `'continue'` event; a final (>=200) - // response means the server declined the handshake — surface it as-is. - let mut buf: Vec = Vec::new(); - let mut chunk = [0u8; 8 * 1024]; - let mut got_interim = false; - loop { - if let Some(pos) = find_header_end(&buf) { - let status = parse_status_code(&buf[..pos]); - if (100..200).contains(&status) { - got_interim = true; - buf.drain(..pos + 4); - break; - } else if status >= 200 { - // Final response, no continue — leave `buf` intact for the - // EOF read below to finish + parse. - break; - } - } - let n = read_chunk(&mut stream, &mut chunk, deadline).await?; - if n == 0 { - break; - } - buf.extend_from_slice(&chunk[..n]); - } - - if got_interim { - push_event(PendingHttpEvent::Continue { request_handle }); - // Wait for the deferred body handed over by `req.end()`. A dropped - // sender (request torn down) resolves to an empty body. - let body = tokio::time::timeout(deadline, body_rx) - .await - .map_err(|_| "request timed out".to_string())? - .unwrap_or_default(); - let framed = if use_chunked { - frame_chunked(&body) - } else { - body - }; - write_all(&mut stream, &framed, deadline).await?; - } - - // Read the rest of the (final) response to EOF — the head forces - // `Connection: close`, so the peer closes once it's done. - loop { - let n = read_chunk(&mut stream, &mut chunk, deadline).await?; - if n == 0 { - break; - } - buf.extend_from_slice(&chunk[..n]); - } - - let final_bytes = strip_interim_blocks(buf); - let parsed = parse_http_response(&final_bytes)?; - // Deliver via the same streaming path the pooled reqwest client uses - // (`ResponseHead` → `ResponseChunk` → `ResponseEnd`): the head fires the - // `(res) => …` callback / `'response'` listeners, then the body + end - // edges drain on later ticks. This matches the normal client's - // observable ordering and reuses its well-exercised delivery helpers. - push_event(PendingHttpEvent::ResponseHead { - request_handle, - status: parsed.status, - status_message: parsed.status_message, - headers: parsed.headers, - http_version: parsed.http_version, - }); - if !parsed.body.is_empty() { - push_event(PendingHttpEvent::ResponseChunk { - request_handle, - chunk: Bytes::from(parsed.body), + if let Some((method, url, headers, timeout_ms, agent_handle, tls)) = snapshot { + crate::client_turnloop::dispatch(crate::client_turnloop::Request { + request_handle: handle, + method: &method, + url: &url, + headers, + body: Vec::new(), + timeout_ms, + agent_handle, + tls: &tls, + continue_mode: true, }); } - push_event(PendingHttpEvent::ResponseEnd { request_handle }); - Ok(()) } -/// One deadline-bounded `read`, mapping a timeout / IO error to a string. -async fn read_chunk( - stream: &mut tokio::net::TcpStream, - chunk: &mut [u8], - deadline: std::time::Duration, -) -> Result { - tokio::time::timeout(deadline, stream.read(chunk)) - .await - .map_err(|_| "request timed out".to_string())? - .map_err(|e| e.to_string()) -} - -/// Deadline-bounded `write_all` so a stalled peer can't hang the exchange -/// even when the request set a `timeout`. -async fn write_all( - stream: &mut tokio::net::TcpStream, - bytes: &[u8], - deadline: std::time::Duration, -) -> Result<(), String> { - tokio::time::timeout(deadline, stream.write_all(bytes)) - .await - .map_err(|_| "request timed out".to_string())? - .map_err(|e| e.to_string()) -} - -/// Frame `body` as a single HTTP/1.1 chunk plus the terminating chunk. -fn frame_chunked(body: &[u8]) -> Vec { - let mut framed = Vec::with_capacity(body.len() + 16); - if !body.is_empty() { - framed.extend_from_slice(format!("{:x}\r\n", body.len()).as_bytes()); - framed.extend_from_slice(body); - framed.extend_from_slice(b"\r\n"); - } - framed.extend_from_slice(b"0\r\n\r\n"); - framed -} - -/// Offset of the `\r\n\r\n` that terminates the header block, if present. -fn find_header_end(buf: &[u8]) -> Option { - buf.windows(4).position(|w| w == b"\r\n\r\n") -} - -/// Parse the numeric status code out of a status line (`HTTP/1.1 100 ...`). -fn parse_status_code(head: &[u8]) -> u16 { - let text = String::from_utf8_lossy(head); - text.lines() - .next() - .and_then(|line| line.split_whitespace().nth(1)) - .and_then(|code| code.parse::().ok()) - .unwrap_or(0) -} - -/// Drop any leading interim (1xx) header blocks so the remainder begins at -/// the final response. Defensive: the read loop already strips the interim -/// `100 Continue` before sending the body, but a server may emit more than -/// one informational response. -fn strip_interim_blocks(mut buf: Vec) -> Vec { - loop { - let Some(pos) = find_header_end(&buf) else { - return buf; - }; - if (100..200).contains(&parse_status_code(&buf[..pos])) { - buf.drain(..pos + 4); - } else { - return buf; - } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_continue_predicate_is_case_insensitive() { + let headers: HashMap = + [("Expect".to_string(), "100-Continue".to_string())].into(); + assert!(wants_continue(&headers)); + let headers: HashMap = [("expect".to_string(), "other".to_string())].into(); + assert!(!wants_continue(&headers)); } } diff --git a/crates/perry-ext-http/src/lib.rs b/crates/perry-ext-http/src/lib.rs index 68f7a79026..509144dfae 100644 --- a/crates/perry-ext-http/src/lib.rs +++ b/crates/perry-ext-http/src/lib.rs @@ -2,8 +2,8 @@ //! //! Provides the callback-style ClientRequest / IncomingMessage API //! that npm packages like twitter-api-v2, rss-parser, web-push use. -//! Both `http` and `https` flow through the same wrapper — reqwest -//! handles TLS based on URL scheme. +//! Both `http` and `https` flow through the same wrapper; the transport is +//! `client_turnloop`, which runs TLS above the socket for `https:`. //! //! # Server-side surface (issue #577) //! @@ -16,10 +16,10 @@ //! //! - `js_http_request(opts, cb)` / `js_http_get(...)` synchronously //! register a `ClientRequestHandle` and return its handle id. For -//! `.get()` the request is auto-`end()`'d, kicking off an async -//! `spawn_blocking + reqwest` send on a tokio blocking-pool thread. -//! - When the request completes (or errors), the worker thread pushes -//! a `PendingHttpEvent` onto `HTTP_PENDING_EVENTS` and calls +//! `.get()` the request is auto-`end()`'d, which hands the exchange to +//! `client_turnloop` on the agent's event loop. +//! - As the response arrives (or fails), the loop's completion sink pushes +//! `PendingHttpEvent`s onto `HTTP_PENDING_EVENTS` and calls //! `perry_ffi::notify_main_thread()` to wake the main loop. //! - `js_http_process_pending()` runs on the main thread (called from //! codegen's event-loop tick); it drains the queue and invokes the @@ -29,15 +29,6 @@ //! `ClientRequestHandle` or `IncomingMessageHandle` live and rewrites //! moved pointers after copied-minor GC so a malloc-triggered sweep //! between scheduling and tick can't free them (issue #35 pattern). -//! -//! # Body chunking gap -//! -//! `reqwest::Response::chunk()` is async (`Future`), and we run inside -//! `spawn_blocking` so we can't directly await. We therefore deliver -//! the response body as a single `'data'` event with the entire body -//! buffer (matches perry-stdlib's existing copy). True streaming is -//! a v0.6.0 followup that needs a cooperative `spawn_async` surface -//! on perry-ffi (today's surface is sync-via-blocking-pool only). mod agent; pub use agent::*; @@ -56,32 +47,26 @@ mod client_request_surface; // stay under the 2000-line lint cap. mod tls_client; -// Raw-socket trailer-aware HTTP/1.1 client (`TE: trailers` bypass) + -// response parser, extracted to keep `lib.rs` under the 2000-line lint cap. +// `agent.createConnection` / `createSocket` exchanges over a JS-produced +// socket (#2154), the `Connection: Upgrade` predicate, and the raw response +// parser those socket paths share. mod client_connect_override; mod client_upgrade; mod plain_client; -use plain_client::{dispatch_plain_http_request, parse_http_response}; +use plain_client::parse_http_response; -// Raw-socket `Expect: 100-continue` client path (#5080) — flushes the head, -// observes the interim `100 Continue`, emits `'continue'`, then sends the -// withheld body. reqwest swallows the interim response, so this bypass is -// needed to surface it. +// `Expect: 100-continue` (#5080): arms the head-first exchange and hands the +// withheld body to it at `end()`. mod continue_client; -// Async reqwest dispatch (`dispatch_request` + TLS-client selection), -// extracted to keep `lib.rs` under the 2000-line lint cap. -mod client_dispatch; -use client_dispatch::dispatch_request; - -// The turnloop client lane. `try_dispatch` is offered the exchange before -// `dispatch_request` and declines everything it does not yet cover, which is -// what keeps `reqwest` reachable; see that module's header for the decline set. +// The client transport: every exchange runs here, on the agent's turnloop +// loop, with TLS above the socket for `https:`. See the module header for +// the shapes it carries. // -// `pub` rather than private for one reason: a lane that silently declined -// every request would be indistinguishable from a working one at the JS -// surface — the "gate runs but its subject never did" shape. `try_dispatch` -// and `available` are reachable so `tests/turnloop_client_exchange.rs` can +// `pub` rather than private for one reason: a transport that silently did +// nothing would be indistinguishable from a working one at the JS surface — +// the "gate runs but its subject never did" shape. The liveness counters and +// `try_dispatch*` are reachable so `tests/turnloop_client_exchange.rs` can // assert the subject was live. No C-ABI symbol is added. pub mod client_turnloop; @@ -124,9 +109,9 @@ use bytes::Bytes; use lazy_static::lazy_static; use perry_ffi::{ alloc_string, gc_register_mutable_root_scanner_named, get_handle_mut, iter_handles_of_mut, - json_stringify, notify_main_thread, register_aux_event_pump, register_handle, - spawn_blocking_with_reactor as spawn_blocking, with_handle_mut, ArrayHeader, GcRootVisitor, - Handle, JsClosure, JsString, JsValue, ObjectHeader, RawClosureHeader, StringHeader, + json_stringify, notify_main_thread, register_aux_event_pump, register_handle, with_handle_mut, + ArrayHeader, GcRootVisitor, Handle, JsClosure, JsString, JsValue, ObjectHeader, + RawClosureHeader, StringHeader, }; use std::collections::HashMap; use std::sync::{Mutex, Once}; @@ -143,7 +128,7 @@ const TAG_TRUE: u64 = 0x7FFC_0000_0000_0004; // Pending event queue + GC scanner // ------------------------------------------------------------------ -/// Events queued by the tokio blocking-pool worker for the main thread. +/// Events queued by the client transport for the main thread. pub(crate) enum PendingHttpEvent { /// A ClientRequest acquired its public socket identity. Queued so callers /// can attach `req.on('socket', ...)` after `http.get()` returns. @@ -167,7 +152,7 @@ pub(crate) enum PendingHttpEvent { body: Vec, http_version: (u8, u8), }, - /// Streaming delivery (reqwest path): the response head arrived — fire + /// Streaming delivery: the response head arrived — fire /// the `http.request` callback / `'response'` listeners now; body /// chunks follow as [`PendingHttpEvent::ResponseChunk`]s. This is what /// lets client code observe headers (and start timers / destroy the @@ -179,10 +164,8 @@ pub(crate) enum PendingHttpEvent { headers: Vec<(String, String)>, http_version: (u8, u8), }, - /// One streamed body chunk following a `ResponseHead`. Carried as a - /// refcounted `Bytes` (reqwest hands `chunk()` out this way) so the - /// streaming path stays zero-copy from the receive buffer to the drain - /// handler, which only ever borrows it as `&[u8]`. + /// One streamed body chunk following a `ResponseHead`, carried as a + /// refcounted `Bytes`; the drain handler only ever borrows it as `&[u8]`. ResponseChunk { request_handle: Handle, chunk: Bytes, @@ -190,7 +173,7 @@ pub(crate) enum PendingHttpEvent { /// The streamed body finished — `'end'` on the message, `'close'` on /// the request. ResponseEnd { request_handle: Handle }, - /// #10468 — a `101` fires `'upgrade'` instead of `'response'` (`client_upgrade.rs`). + /// #10468 — a `101` fires `'upgrade'` instead of `'response'`. Upgrade { request_handle: Handle, status: u16, @@ -203,6 +186,15 @@ pub(crate) enum PendingHttpEvent { request_handle: Handle, error_message: String, }, + /// An `Error` carrying only Node's `.code` — `socket hang up` / + /// `ECONNRESET`, a TLS verification failure, a parser refusal. Unlike + /// [`PendingHttpEvent::TransportError`] it has no `.syscall`/`.errno`, + /// which Node does not put on these. + CodedError { + request_handle: Handle, + message: String, + code: String, + }, /// A classified transport failure (connect refused, DNS lookup failure, /// connection reset, …). Unlike [`PendingHttpEvent::Error`] — which hands /// listeners a bare string — this carries the Node error shape so the @@ -238,24 +230,17 @@ pub(crate) enum PendingHttpEvent { DeferredArmContinue { request_handle: Handle }, } -/// #5779 follow-up — count of in-flight HTTP/HTTPS CLIENT requests (the detached -/// reqwest task spawned per `http.request`/`http.get`, from dispatch until the -/// response fully streams or errors). -/// -/// `EXT_BLOCKING_TASKS_INFLIGHT` (perry-stdlib's blocking-task gate) -/// only stays up for the SHORT outer `spawn_blocking` closure that *launches* the -/// reqwest task and returns; it drops to 0 while the actual fetch is still in -/// flight. Registering this counter as a keepalive contributor lets the runtime -/// gate and fast wait-driver honor the request's true lifetime. +/// #5779 follow-up — count of in-flight HTTP/HTTPS CLIENT requests, from +/// dispatch until the response fully streams or errors. Registering this +/// counter as a keepalive contributor lets the runtime gate and fast +/// wait-driver honor the request's true lifetime. static CLIENT_REQUESTS_INFLIGHT: std::sync::LazyLock< std::sync::Mutex>, > = std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashSet::new())); -/// RAII in-flight marker. Created right before the reqwest task is spawned and -/// MOVED INTO the task, so the count tracks the task's full lifetime — including -/// a task scheduled-but-stranded by a lost worker-unpark (its future, holding the -/// guard, is never dropped while stranded). Drop wakes the main loop so its -/// active-handle gate re-evaluates promptly. +/// RAII in-flight marker, held by an exchange from dispatch until it settles, +/// so the count tracks the exchange's full lifetime. Drop wakes the main loop +/// so its active-handle gate re-evaluates promptly. pub(crate) struct ClientInflightGuard { request_handle: Handle, } @@ -311,69 +296,12 @@ fn proxy_enabled_from_env_value(value: Option<&str>) -> bool { /// Whether Node's `--use-env-proxy` / `NODE_USE_ENV_PROXY=1` is active. /// /// Node's built-in `fetch` and `node:http`/`node:https` ignore the standard -/// `HTTP_PROXY`/`HTTPS_PROXY`/`NO_PROXY` env vars unless this is set. perry -/// mirrors that opt-in so its bindings are Node-conformant — reqwest would -/// otherwise honor the proxy env unconditionally, diverging from Node. +/// `HTTP_PROXY`/`HTTPS_PROXY`/`NO_PROXY` env vars unless this is set; the +/// client transport (`client_turnloop::proxy`) mirrors that opt-in. pub(crate) fn node_env_proxy_enabled() -> bool { proxy_enabled_from_env_value(std::env::var("NODE_USE_ENV_PROXY").ok().as_deref()) } -/// Apply the Node-conformant proxy policy to a reqwest client builder: honor -/// the standard proxy env vars only when `NODE_USE_ENV_PROXY=1`, matching Node. -/// -/// Also disables reqwest's default redirect-following. Node's -/// `http.request`/`http.get`/`https.request` NEVER follow redirects — a 3xx is -/// delivered to the caller verbatim (only `fetch` follows, per its WHATWG -/// redirect mode). reqwest follows up to 10 hops by default, which is -/// observably wrong for the Node client and, worse, turned Next.js's -/// `proxyRequest` (its bundled `http-proxy` runs over this client) into an -/// infinite loop: a proxied sub-request that 307-redirects back to the entry -/// path was auto-followed here instead of relayed for a transparent response, -/// so the router re-resolved the same middleware rewrite forever (a locale -/// middleware where `/` rewrites to `/en` and `/en` 307s back to `/`). -pub(crate) fn apply_node_proxy_policy(builder: reqwest::ClientBuilder) -> reqwest::ClientBuilder { - let builder = builder.redirect(reqwest::redirect::Policy::none()); - if node_env_proxy_enabled() { - builder - } else { - builder.no_proxy() - } -} - -/// Apply the process-wide Node TLS environment after the HTTP proxy/redirect -/// policy. Explicit per-request TLS options use `TlsOptions::build_client` -/// instead, but both paths consume the same perry-ffi resolver. -pub(crate) fn apply_node_client_policy(builder: reqwest::ClientBuilder) -> reqwest::ClientBuilder { - let mut builder = apply_node_proxy_policy(builder); - let environment = perry_ffi::node_tls_client_environment(); - if environment.accepts_invalid_certificates() { - builder = builder.danger_accept_invalid_certs(true); - } - for pem in environment.ca_pems() { - match reqwest::Certificate::from_pem_bundle(pem) { - Ok(certificates) => { - for certificate in certificates { - builder = builder.add_root_certificate(certificate); - } - } - Err(_) => { - if let Ok(certificate) = reqwest::Certificate::from_pem(pem) { - builder = builder.add_root_certificate(certificate); - } - } - } - } - builder -} - -/// A default reqwest client with the Node-conformant client policy applied. -/// Used as the fallback when a customized builder fails to build. -pub(crate) fn default_client() -> reqwest::Client { - apply_node_client_policy(reqwest::Client::builder()) - .build() - .unwrap_or_else(|_| reqwest::Client::new()) -} - #[cfg(test)] mod proxy_policy_tests { use super::proxy_enabled_from_env_value; @@ -392,17 +320,6 @@ mod proxy_policy_tests { lazy_static! { static ref HTTP_PENDING_EVENTS: Mutex> = Mutex::new(Vec::new()); - /// Shared HTTP client — reuses connection pool, DNS cache, TLS - /// session cache. Without this each request allocs a fresh - /// reqwest::Client (~250 KB) and the memory never gets reused. - pub(crate) static ref HTTP_CLIENT: reqwest::Client = apply_node_client_policy( - reqwest::Client::builder() - .pool_idle_timeout(std::time::Duration::from_secs(90)) - .pool_max_idle_per_host(16) - .tcp_keepalive(std::time::Duration::from_secs(60)), - ) - .build() - .unwrap_or_else(|_| default_client()); } static HTTP_GC_REGISTERED: Once = Once::new(); @@ -508,11 +425,9 @@ pub struct ClientRequestHandle { /// #4909 — `'close'` fires at most once per request. close_emitted: bool, /// `options.agent` handle id when the caller supplied an Agent - /// (#2154). `0` = use the global `HTTP_CLIENT` (no pooling - /// distinction). When set, `dispatch_request` calls - /// `agent::client_for_agent` so requests share a per-Agent - /// connection pool whose `keepAlive` / `maxFreeSockets` / - /// `keepAliveMsecs` come from the Agent's stored options. + /// (#2154). `0` = the implicit global agent. When set, the transport + /// keeps connections alive per the Agent's stored `keepAlive` / + /// `maxFreeSockets` / `keepAliveMsecs` (`client_turnloop::pool`). agent_handle: Handle, /// The normalized Agent `getName(options)` key captured from the original /// options object. HTTPS TLS identity fields are lost if this is @@ -544,14 +459,13 @@ pub struct ClientRequestHandle { /// delivery paths). incoming_handle: Handle, /// #5080 — the request carries `Expect: 100-continue`, so its head was - /// flushed up front by the raw-socket continue path and the body is - /// withheld until the server's interim `100 Continue` arrives. `end()` - /// hands the (now-known) body over the `continue_body_tx` channel - /// instead of dispatching a fresh exchange. + /// flushed up front by the continue exchange and the body is withheld + /// until the server's interim `100 Continue` arrives. `end()` hands the + /// (now-known) body to that exchange instead of dispatching a fresh one. expects_continue: bool, - /// #5080 — set while the continue exchange task is waiting for the - /// deferred body; `end()` sends the buffered body here (once). - continue_body_tx: Option>>, + /// #5080 — set while the continue exchange is waiting for the deferred + /// body; the first `end()` clears it and hands the body over (once). + continue_body_pending: bool, } #[derive(Clone, Copy)] @@ -760,7 +674,7 @@ fn make_request_handle( preflight_error: None, incoming_handle: 0, expects_continue: false, - continue_body_tx: None, + continue_body_pending: false, }); if callback != 0 { let wrapper = @@ -833,6 +747,7 @@ fn pending_request_handle(event: &PendingHttpEvent) -> Handle { | PendingHttpEvent::ResponseChunk { request_handle, .. } | PendingHttpEvent::ResponseEnd { request_handle } | PendingHttpEvent::Error { request_handle, .. } + | PendingHttpEvent::CodedError { request_handle, .. } | PendingHttpEvent::TransportError { request_handle, .. } | PendingHttpEvent::Timeout { request_handle } | PendingHttpEvent::Abort { request_handle } @@ -851,6 +766,7 @@ fn terminal_http_event(event: &PendingHttpEvent) -> bool { | PendingHttpEvent::Response { .. } | PendingHttpEvent::ResponseEnd { .. } | PendingHttpEvent::Error { .. } + | PendingHttpEvent::CodedError { .. } | PendingHttpEvent::TransportError { .. } | PendingHttpEvent::Abort { .. } ) @@ -890,13 +806,13 @@ unsafe fn attach_tls_options(handle: Handle, opts_f64: f64) { if !url.starts_with("https://") || socket == 0 { return; } - let parsed_url = reqwest::Url::parse(&url).ok(); + let parsed_url = url::Url::parse(&url).ok(); let fallback_servername = parsed_url .as_ref() .and_then(|url| url.host_str().map(String::from)); let server_port = parsed_url .as_ref() - .and_then(reqwest::Url::port_or_known_default) + .and_then(url::Url::port_or_known_default) .unwrap_or(443); let callback_host = tls .servername @@ -1382,24 +1298,23 @@ pub(crate) unsafe fn client_request_end_impl(handle: Handle, body_f64: f64) -> H // #5080 — an `Expect: 100-continue` request flushed its head up front; // this `end()` just hands the (now-known) body to the in-flight continue - // exchange over the oneshot. The first call fires the flush ordering - // (write/finish/end callbacks); a later one is an idempotent no-op. - let (is_continue, first_end) = with_handle_mut::(handle, |req| { + // exchange. The first call fires the flush ordering (write/finish/end + // callbacks); a later one is an idempotent no-op. + let (is_continue, handed_body) = with_handle_mut::(handle, |req| { if !req.expects_continue { - return (false, false); + return (false, None); } - if let Some(tx) = req.continue_body_tx.take() { - let body = std::mem::take(&mut req.body); - let _ = tx.send(body); + if std::mem::take(&mut req.continue_body_pending) { req.ended = true; - (true, true) + (true, Some(std::mem::take(&mut req.body))) } else { - (true, false) + (true, None) } }) - .unwrap_or((false, false)); + .unwrap_or((false, None)); if is_continue { - if first_end { + if let Some(body) = handed_body { + client_turnloop::continue_body(handle, body); push_event(PendingHttpEvent::Flushed { request_handle: handle, }); @@ -1482,7 +1397,7 @@ pub(crate) unsafe fn client_request_flush_headers(handle: Handle) { return; } // #5080 — `flushHeaders()` is a send boundary; when it arms the continue - // path, that exchange owns the head, so don't also dispatch via reqwest. + // path, that exchange owns the head, so don't also dispatch a second one. continue_client::arm_expect_continue(handle); if with_handle_mut::(handle, |r| r.expects_continue).unwrap_or(false) { @@ -1526,7 +1441,7 @@ type RequestSnapshot = ( /// The shared dispatch tail of `end()` / `flushHeaders()`: route through the /// agent's `createConnection` / `createSocket` override when present, else -/// the reqwest path. +/// the turnloop transport. unsafe fn dispatch_request_snapshot(handle: Handle, snapshot: RequestSnapshot) { let (method, url, headers, body, timeout_ms, agent_handle, tls) = snapshot; @@ -1567,10 +1482,10 @@ unsafe fn dispatch_request_snapshot(handle: Handle, snapshot: RequestSnapshot) { } // #2154 — if the agent supplied a `createConnection` / `createSocket` - // override, invoke it here on the main thread (JS closure calls must not - // run on a tokio worker) and run the HTTP exchange over the socket it - // produces instead of through reqwest. Falls back to the reqwest path when - // there's no override or it didn't yield a usable socket. + // override, invoke it here on the main thread (JS closure calls must run + // where the heap lives) and run the HTTP exchange over the socket it + // produces. Falls through to the transport when there's no override or it + // didn't yield a usable socket. if agent_handle != 0 { if let Some((host, port, path)) = socket_connect_target(&url) { // Node's `Agent.prototype.addRequest` calls @@ -1578,7 +1493,7 @@ unsafe fn dispatch_request_snapshot(handle: Handle, snapshot: RequestSnapshot) { // deliver the socket via `cb(err, socket)`. Prefer it over // `createConnection` — the cb continuation // (`http_create_socket_cb`) resumes the exchange — so we don't - // fall through to reqwest after dispatching it. + // fall through to the transport after dispatching it. if agent::create_socket_override(agent_handle) != 0 { invoke_create_socket(handle, agent_handle, &host, port, &path); return; @@ -1609,34 +1524,20 @@ unsafe fn dispatch_request_snapshot(handle: Handle, snapshot: RequestSnapshot) { } } - // The turnloop lane gets first refusal. It runs here, on the agent thread, - // because a submission has to reach the loop this thread owns — not from - // inside `spawn_blocking`, where `dispatch_request`'s reqwest future runs. - // `true` means it owns the exchange and will deliver exactly one terminal - // event; `false` is a named decline (see `client_turnloop`'s header) and - // falls through to reqwest unchanged. - if client_turnloop::try_dispatch( - handle, - &method, - &url, - &headers, - &body, - timeout_ms, - agent_handle, - ) { - return; - } - - dispatch_request( - handle, - method, - url, + // The transport. It runs here, on the agent thread, so the submission + // reaches the loop this thread owns (or is posted to the thread that owns + // it); either way it delivers exactly one terminal event. + client_turnloop::dispatch(client_turnloop::Request { + request_handle: handle, + method: &method, + url: &url, headers, body, timeout_ms, agent_handle, - tls, - ); + tls: &tls, + continue_mode: false, + }); } /// Move a completed request out of its Agent's active pool and resume the @@ -1700,7 +1601,7 @@ pub(crate) unsafe fn finish_agent_request(request_handle: Handle, keep_alive: bo /// `agent.createConnection` override expects in its options object. Returns /// `None` if the URL doesn't parse or has no host. fn socket_connect_target(url: &str) -> Option<(String, u16, String)> { - let parsed = reqwest::Url::parse(url).ok()?; + let parsed = url::Url::parse(url).ok()?; let host = parsed.host_str()?.to_string(); let port = parsed.port_or_known_default().unwrap_or(80); let mut path = parsed.path().to_string(); diff --git a/crates/perry-ext-http/src/pending_dispatch.rs b/crates/perry-ext-http/src/pending_dispatch.rs index 27b50380ca..a9825637b3 100644 --- a/crates/perry-ext-http/src/pending_dispatch.rs +++ b/crates/perry-ext-http/src/pending_dispatch.rs @@ -97,6 +97,11 @@ pub unsafe extern "C" fn js_http_process_pending() -> i32 { request_handle, error_message, } => client_events::handle_error_event(request_handle, &error_message), + PendingHttpEvent::CodedError { + request_handle, + message, + code, + } => client_events::handle_coded_error_event(request_handle, &message, &code), PendingHttpEvent::TransportError { request_handle, message, diff --git a/crates/perry-ext-http/src/plain_client.rs b/crates/perry-ext-http/src/plain_client.rs index d1b2939ddf..6d9a65ba84 100644 --- a/crates/perry-ext-http/src/plain_client.rs +++ b/crates/perry-ext-http/src/plain_client.rs @@ -1,124 +1,11 @@ -//! Raw-socket HTTP/1.1 client path used when the request asks for response -//! trailers (`TE: trailers`) — reqwest's body API drops trailer blocks, so -//! this bypass speaks HTTP/1.1 over a plain TcpStream and parses the -//! response (chunked decoding + trailer block) itself. The parser is shared -//! with the #2154 `agent.createConnection` socket path in `lib.rs`. - -use std::collections::HashMap; - -use perry_ffi::Handle; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; - -use crate::{push_event, PendingHttpEvent}; - -fn expects_response_trailers(headers: &HashMap) -> bool { - headers.iter().any(|(name, value)| { - name.eq_ignore_ascii_case("te") - && value - .split(',') - .any(|part| part.trim().eq_ignore_ascii_case("trailers")) - }) -} - -pub(crate) async fn dispatch_plain_http_request( - request_handle: Handle, - method: &str, - url: &str, - headers: &HashMap, - body: &[u8], - timeout_ms: Option, -) -> Option> { - if !expects_response_trailers(headers) { - return None; - } - let parsed = match reqwest::Url::parse(url) { - Ok(u) if u.scheme() == "http" => u, - _ => return None, - }; - let host = match parsed.host_str() { - Some(h) => h.to_string(), - None => return Some(Err("missing host".to_string())), - }; - let port = parsed.port_or_known_default().unwrap_or(80); - let mut path = parsed.path().to_string(); - if path.is_empty() { - path.push('/'); - } - if let Some(q) = parsed.query() { - path.push('?'); - path.push_str(q); - } - - let fut = async { - let mut stream = tokio::net::TcpStream::connect((host.as_str(), port)).await?; - let host_header = if parsed.port().is_some() { - format!("{}:{}", host, port) - } else { - host.clone() - }; - let mut req = format!("{} {} HTTP/1.1\r\nHost: {}\r\n", method, path, host_header); - let mut has_content_length = false; - for (k, v) in headers { - if k.eq_ignore_ascii_case("content-length") { - has_content_length = true; - } - if k.eq_ignore_ascii_case("connection") { - // The raw trailer-aware path reads until EOF after the final - // chunk/trailer block. Force close here so an explicit - // `Connection: keep-alive` cannot hang until timeout. - continue; - } - req.push_str(k); - req.push_str(": "); - req.push_str(v); - req.push_str("\r\n"); - } - req.push_str("Connection: close\r\n"); - if !body.is_empty() && !has_content_length { - req.push_str(&format!("Content-Length: {}\r\n", body.len())); - } - req.push_str("\r\n"); - stream.write_all(req.as_bytes()).await?; - if !body.is_empty() { - stream.write_all(body).await?; - } - - let mut raw = Vec::new(); - stream.read_to_end(&mut raw).await?; - Ok::, std::io::Error>(raw) - }; - - let raw = match timeout_ms { - Some(ms) => match tokio::time::timeout(std::time::Duration::from_millis(ms), fut).await { - Ok(r) => r, - Err(_) => return Some(Err("request timed out".to_string())), - }, - None => match tokio::time::timeout(std::time::Duration::from_secs(30), fut).await { - Ok(r) => r, - Err(_) => return Some(Err("request timed out".to_string())), - }, - }; - let raw = match raw { - Ok(r) => r, - Err(e) => return Some(Err(e.to_string())), - }; - - match parse_http_response(&raw) { - Ok(parsed) => { - push_event(PendingHttpEvent::Response { - request_handle, - status: parsed.status, - status_message: parsed.status_message, - headers: parsed.headers, - trailers: parsed.trailers, - body: parsed.body, - http_version: parsed.http_version, - }); - Some(Ok(())) - } - Err(e) => Some(Err(e)), - } -} +//! The raw HTTP/1.1 response parser shared by the `agent.createConnection` +//! socket paths (`client_connect_override.rs`, #2154): status line, headers, +//! decoded body and trailers from the bytes read off a JS-produced socket. +//! +//! It used to also carry a `TE: trailers` bypass over a tokio `TcpStream`, +//! because reqwest's body API dropped trailer blocks. That exchange now runs in +//! `client_turnloop` (`Mode::Trailers`), whose codec hands trailers back as +//! `Event::Trailers`. /// A parsed HTTP/1.1 response message (status line + headers + decoded body /// + trailers). Produced by [`parse_http_response`]. @@ -137,10 +24,9 @@ pub(crate) struct ParsedHttpResponse { /// Parse a raw HTTP/1.1 response (the bytes read off a socket) into status / /// headers / decoded body / trailers. Decodes `Transfer-Encoding: chunked` /// (including a trailer block) and honors `Content-Length`; with neither it -/// treats the remainder as the body (read-until-EOF transports). Shared by -/// the trailer-aware reqwest-bypass path ([`dispatch_plain_http_request`]) -/// and the #2154 `agent.createConnection` socket path -/// ([`dispatch_request_over_socket`]). +/// treats the remainder as the body (read-until-EOF transports). Used by the +/// #2154 `agent.createConnection` socket path +/// (`client_connect_override::dispatch_request_over_socket`). pub(crate) fn parse_http_response(raw: &[u8]) -> Result { let Some(header_end) = raw.windows(4).position(|w| w == b"\r\n\r\n") else { return Err("invalid HTTP response".to_string()); diff --git a/crates/perry-ext-http/src/tests.rs b/crates/perry-ext-http/src/tests.rs index 00b4691138..5a49fe7f16 100644 --- a/crates/perry-ext-http/src/tests.rs +++ b/crates/perry-ext-http/src/tests.rs @@ -3,7 +3,7 @@ use perry_ffi::{drop_handle, get_handle, register_handle}; use std::collections::HashMap; use std::sync::{Mutex, MutexGuard}; -static GC_TEST_LOCK: Mutex<()> = Mutex::new(()); +pub(crate) static GC_TEST_LOCK: Mutex<()> = Mutex::new(()); struct GcTestGuard { frame: u64, @@ -110,7 +110,7 @@ fn gc_mutable_scanner_rewrites_request_response_listener_roots() { preflight_error: None, incoming_handle: 0, expects_continue: false, - continue_body_tx: None, + continue_body_pending: false, }); let mut incoming_listeners = HashMap::new(); @@ -201,7 +201,7 @@ fn drain_streamed_body(chunks: &[&[u8]]) -> Vec { preflight_error: None, incoming_handle: 0, expects_continue: false, - continue_body_tx: None, + continue_body_pending: false, }); unsafe { @@ -312,156 +312,9 @@ fn has_pending_zero_when_idle() { assert_eq!(js_http_has_pending(), 0); } -/// #5892 remainder / issue_4909 early-exit regression: from the moment -/// `dispatch_request` returns until the response events are queued, the -/// exchange must be visible to the exit gate — `js_ext_http_client_inflight()` -/// (the guard) or a non-empty `HTTP_PENDING_EVENTS` (the pump gate). Pre-fix, -/// only the agent-socket path held a `ClientInflightGuard`; the reqwest path -/// was invisible after the outer spawn closure returned, so an in-process -/// server+client program whose server just `close()`d could clean-exit with -/// the response still unread on the socket ('status 200' never printed — -/// the write_end CI failure). -/// -/// The test shims run `spawn_blocking` inline, so entering a runtime context -/// makes `dispatch_request` synchronously spawn the detached task onto this -/// NOT-YET-DRIVEN current-thread runtime — reproducing the production window -/// between dispatch and the task's first poll deterministically. -#[test] -fn dispatch_request_stays_visible_to_exit_gate_until_response_queued() { - let _lock = GC_TEST_LOCK - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - - // Minimal HTTP/1.1 server on an OS thread. - let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); - let port = listener.local_addr().expect("addr").port(); - let server = std::thread::spawn(move || { - if let Ok((mut sock, _)) = listener.accept() { - use std::io::{Read, Write}; - let mut buf = [0u8; 4096]; - let _ = sock.read(&mut buf); - let _ = sock - .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok"); - } - }); - - let request_handle = register_handle(ClientRequestHandle { - async_id: 0, - method: "GET".to_string(), - url: format!("http://127.0.0.1:{port}/"), - headers: HashMap::new(), - body: Vec::new(), - response_callback: 0, - response_raw_wrapper: 0, - listeners: HashMap::new(), - timeout_ms: None, - ended: false, - flushed_early: false, - pending_write_callbacks: Vec::new(), - end_callback: 0, - completed: false, - timeout_fired: false, - close_emitted: false, - agent_handle: 0, - agent_key: "localhost::".to_string(), - request_create_connection: 0, - agent_active: false, - agent_queued: false, - reused_socket: false, - socket_handle: 0, - abort_signal_bits: 0, - abort_listener_bits: 0, - tls: crate::tls_client::TlsOptions::default(), - preflight_error: None, - incoming_handle: 0, - expects_continue: false, - continue_body_tx: None, - }); - - let rt = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .expect("runtime"); - let baseline = js_ext_http_client_inflight(); - { - let _enter = rt.enter(); - client_dispatch::dispatch_request( - request_handle, - "GET".to_string(), - format!("http://127.0.0.1:{port}/"), - HashMap::new(), - Vec::new(), - Some(10_000), - 0, - crate::tls_client::TlsOptions::default(), - ); - } - - // THE regression assertion: the detached task exists but has never been - // polled and has pushed no events — the in-flight guard is the ONLY thing - // keeping the exit gate up in this window. - assert!( - js_ext_http_client_inflight() > baseline, - "in-flight guard must be held from dispatch, before the task's first poll" - ); - - // Drive the runtime to completion. At every observable point until the - // response is queued, the exit-gate union must stay nonzero. - let my_response_end_queued = || { - HTTP_PENDING_EVENTS.lock().is_ok_and(|q| { - q.iter().any(|ev| { - matches!(ev, PendingHttpEvent::ResponseEnd { request_handle: h } if *h == request_handle) - }) - }) - }; - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15); - while !my_response_end_queued() { - let queue_has_mine = HTTP_PENDING_EVENTS.lock().is_ok_and(|q| !q.is_empty()); - assert!( - js_ext_http_client_inflight() > baseline || queue_has_mine, - "exit-gate union (inflight || pending events) went to zero before the response was delivered" - ); - assert!( - std::time::Instant::now() < deadline, - "response never arrived (events: {:?})", - HTTP_PENDING_EVENTS.lock().map(|q| q.len()) - ); - rt.block_on(async { - tokio::time::sleep(std::time::Duration::from_millis(1)).await; - }); - } - - // The guard must also RELEASE once the response has fully streamed — - // a leaked guard would keep every program with one fetch alive forever. - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15); - while js_ext_http_client_inflight() > baseline { - assert!( - std::time::Instant::now() < deadline, - "in-flight guard leaked after the response fully streamed" - ); - rt.block_on(async { - tokio::time::sleep(std::time::Duration::from_millis(1)).await; - }); - } - - // Cleanup: drop this test's events + handle so the idle test stays valid. - if let Ok(mut q) = HTTP_PENDING_EVENTS.lock() { - q.retain(|ev| { - !matches!( - ev, - PendingHttpEvent::ResponseHead { request_handle: h, .. } - | PendingHttpEvent::ResponseChunk { request_handle: h, .. } - | PendingHttpEvent::ResponseEnd { request_handle: h } - | PendingHttpEvent::Error { request_handle: h, .. } - | PendingHttpEvent::TransportError { request_handle: h, .. } - | PendingHttpEvent::Timeout { request_handle: h } - | PendingHttpEvent::Abort { request_handle: h } if *h == request_handle - ) - }); - } - drop_handle(request_handle); - let _ = server.join(); -} +// `dispatch_request_stays_visible_to_exit_gate_until_response_queued` moved to +// `client_turnloop/tests.rs`: it now drives the turnloop transport's state +// machine, whose internals are private to that module. #[test] fn parse_options_safe_defaults() { diff --git a/crates/perry-ext-http/src/tls_client.rs b/crates/perry-ext-http/src/tls_client.rs index 7c4e38209c..6413e030e0 100644 --- a/crates/perry-ext-http/src/tls_client.rs +++ b/crates/perry-ext-http/src/tls_client.rs @@ -2,7 +2,7 @@ //! //! Node's https client accepts a family of TLS options on the request //! (or agent) options object. Before this module the perry-ext-http -//! client always used reqwest's default verifier, so connecting to a +//! client always used the default verifier, so connecting to a //! server that presents a self-signed / test-CA certificate failed the //! handshake outright (`received fatal alert: UnknownCA`). Node's own //! https tests stand up servers with the `test/fixtures/keys` test @@ -14,12 +14,13 @@ //! - `checkServerIdentity: fn` — override hostname verification. //! //! This module parses those options off the request's options object and -//! folds them into a per-request `reqwest::Client`. +//! folds them into the rustls `ClientConfig` the client transport's TLS +//! session runs (`client_turnloop::tls`). //! //! ## Honored faithfully //! -//! `rejectUnauthorized: false` / `NODE_TLS_REJECT_UNAUTHORIZED=0` map to -//! reqwest's `danger_accept_invalid_certs(true)`; explicit `ca` entries replace +//! `rejectUnauthorized: false` / `NODE_TLS_REJECT_UNAUTHORIZED=0` accept any +//! certificate chain (signatures still verified); explicit `ca` entries replace //! the public root set, matching Node/OpenSSL's trust-store semantics. //! //! ## Compatibility layer @@ -28,7 +29,7 @@ //! rustls handshake. We disable the backend hostname check, then invoke //! the callback on the main thread before dispatch and surface a returned //! `Error` through the request's normal asynchronous error path. -//! - reqwest's rustls backend requires a SAN match and does **not** fall back +//! - rustls's webpki verifier requires a SAN match and does **not** fall back //! to the certificate Common Name. A verifier wrapper retains webpki chain //! and signature validation while leaving the final hostname decision to //! the Node-compatible Common Name layer. It also accepts an explicitly @@ -76,7 +77,7 @@ pub(crate) fn unregister_internal_https_server(port: u16) { } fn internal_https_server_for_url(url: &str) -> Option { - let url = reqwest::Url::parse(url).ok()?; + let url = url::Url::parse(url).ok()?; let host = url.host_str()?; let is_loopback = host.eq_ignore_ascii_case("localhost") || host @@ -117,27 +118,25 @@ pub(crate) struct TlsOptions { /// re-runs that callback when a fresh TLS session is verified; an explicit /// per-request callback still runs for every request. pub(crate) check_server_identity_from_agent: bool, - /// Explicit TLS identity from `options.servername`. Reqwest connects to - /// the URL host and cannot substitute this value for rustls' SNI/name - /// check. When it is present we therefore keep certificate-chain - /// validation enabled but perform the hostname decision at the Node - /// compatibility layer. + /// Explicit TLS identity from `options.servername`: sent as SNI and used + /// for the hostname decision, which the Node compatibility layer makes + /// after certificate-chain validation. pub(crate) servername: Option, /// Remaining HTTPS Agent identity fields. Some are OpenSSL-only and are - /// not independently configurable through reqwest/rustls, but they still + /// not independently configurable through rustls, but they still /// have to partition the TLS session cache exactly like Node's Agent. pub(crate) session_identity: Vec<(String, String)>, - /// PKCS#12 client identities supplied through `pfx`. They are converted - /// to the PEM identity format accepted by reqwest's rustls backend and - /// also feed the server-side peer-certificate compatibility facade. + /// PKCS#12 client identities supplied through `pfx`. They become the + /// rustls client-certificate resolver and also feed the server-side + /// peer-certificate compatibility facade. pub(crate) client_pfx: Vec<(Vec, String)>, pub(crate) peer_certificate_cn: Option, } impl TlsOptions { - /// Whether these options require building a dedicated TLS client - /// instead of reusing the pooled default. `NODE_TLS_REJECT_UNAUTHORIZED=0` - /// alone counts (it disables verification process-wide). + /// Whether these options require a dedicated TLS config instead of the + /// shared default. `NODE_TLS_REJECT_UNAUTHORIZED=0` alone counts (it + /// disables verification process-wide). pub(crate) fn needs_custom_client(&self) -> bool { let environment = perry_ffi::node_tls_client_environment(); self.reject_unauthorized == Some(false) @@ -159,99 +158,39 @@ impl TlsOptions { || perry_ffi::node_tls_client_environment().accepts_invalid_certificates() } - /// Build a per-request `reqwest::Client` honoring these options. - /// `pool` is the optional `(keep_alive, max_free_sockets, - /// keep_alive_msecs)` Agent pool config to fold in. - pub(crate) fn build_client( - &self, - pool: Option<(bool, f64, f64)>, - ) -> Result { - let mut builder = crate::apply_node_proxy_policy( - reqwest::Client::builder().tcp_keepalive(std::time::Duration::from_secs(60)), - ); + /// The rustls client config these options describe. + /// + /// One verifier stack covers every combination the reqwest transport + /// used to split between its builder flags and a preconfigured config: + /// an explicit `ca` replaces the public roots and environment CAs + /// (`NODE_EXTRA_CA_CERTS`) extend them; `servername` selects the name the + /// chain is verified against; a `checkServerIdentity` callback (run on the + /// main thread before dispatch) replaces only the hostname decision; + /// `rejectUnauthorized: false` accepts any chain; a PKCS#12 identity + /// becomes the client certificate. Without any of them this is webpki's + /// public roots with hostname verification — plus the Node Common-Name + /// fallback for certificates that carry no Subject Alternative Name. + pub(crate) fn client_config(&self) -> Result { let environment = perry_ffi::node_tls_client_environment(); let has_explicit_ca = !self.ca_pems.is_empty(); - let ca_pems = if has_explicit_ca { + let accept_invalid_certs = self.accept_invalid_certs(); + // An unverified chain needs no roots; reqwest ignored unparseable CA + // material in that case too, so it is not read at all. + let ca_pems: &[Vec] = if accept_invalid_certs && self.client_pfx.is_empty() { + &[] + } else if has_explicit_ca { self.ca_pems.as_slice() } else { environment.ca_pems() }; - let accept_invalid_certs = self.accept_invalid_certs(); - - // Node/OpenSSL accepts a configured self-signed CA certificate as the - // endpoint certificate. webpki rejects that shape as - // `CaUsedAsEndEntity`, even when the exact DER is in its root store. A - // small verifier wrapper preserves normal chain validation, ignores - // only the hostname result (our Node-CN compatibility layer owns it), - // and accepts that one exact-leaf trust case. An explicit `ca` option - // replaces public roots; environment CAs extend them. - let custom_tls_config = !self.client_pfx.is_empty() - || (!accept_invalid_certs && (!ca_pems.is_empty() || self.servername.is_some())); - if custom_tls_config { - builder = builder.use_preconfigured_tls(build_node_tls_config( - ca_pems, - !has_explicit_ca, - self.servername.clone(), - self.check_server_identity_callback != 0, - accept_invalid_certs, - self.client_pfx.first(), - )?); - } else { - if accept_invalid_certs { - builder = builder.danger_accept_invalid_certs(true); - } - if self.servername.is_some() - || self.check_server_identity_callback != 0 - || !ca_pems.is_empty() - { - builder = builder.danger_accept_invalid_hostnames(true); - } - for pem in ca_pems { - // A `ca` entry may be a single cert or a bundle; try the - // bundle parser first, then fall back to the single-cert one. - match reqwest::Certificate::from_pem_bundle(pem) { - Ok(certs) => { - for cert in certs { - builder = builder.add_root_certificate(cert); - } - } - Err(_) => { - if let Ok(cert) = reqwest::Certificate::from_pem(pem) { - builder = builder.add_root_certificate(cert); - } - } - } - } - } - - if let Some((keep_alive, max_free_sockets, keep_alive_msecs)) = pool { - let pool_max_idle = if keep_alive { - if !max_free_sockets.is_finite() || max_free_sockets > usize::MAX as f64 { - 256 - } else { - max_free_sockets.max(1.0) as usize - } - } else { - 0 - }; - let idle_timeout = if keep_alive { - let ms = if keep_alive_msecs.is_finite() && keep_alive_msecs > 0.0 { - keep_alive_msecs - } else { - 1000.0 - }; - std::time::Duration::from_millis(ms as u64) - } else { - std::time::Duration::from_millis(0) - }; - builder = builder - .pool_max_idle_per_host(pool_max_idle) - .pool_idle_timeout(idle_timeout); - } - - builder - .build() - .map_err(|e| format!("https: build client: {e:?}")) + build_node_tls_config( + ca_pems, + !has_explicit_ca, + self.servername.clone().filter(|name| !name.is_empty()), + self.check_server_identity_callback != 0, + accept_invalid_certs, + self.client_pfx.first(), + ) } } @@ -1140,8 +1079,8 @@ mod tests { client_pfx: vec![(identity.clone(), "sample".to_string())], ..TlsOptions::default() }; - let built = options.build_client(None); - assert!(built.is_ok(), "{built:?}"); + let built = options.client_config(); + assert!(built.is_ok(), "{:?}", built.err()); } } diff --git a/crates/perry-ext-http/src/transport_error.rs b/crates/perry-ext-http/src/transport_error.rs index ba94d4ad55..a142b67d6c 100644 --- a/crates/perry-ext-http/src/transport_error.rs +++ b/crates/perry-ext-http/src/transport_error.rs @@ -4,36 +4,17 @@ //! (`ECONNREFUSED`, `ENOTFOUND`, …), `.syscall` (`connect` / `getaddrinfo`) //! and `.errno` (the libuv-negative number), with a message like //! `connect ECONNREFUSED 127.0.0.1:1` or `getaddrinfo ENOTFOUND host`. -//! Perry previously passed the bare `reqwest::Error::to_string()` text, which -//! (a) doesn't even contain the OS reason — that lives in the error's -//! `source()` chain, not its `Display` — and (b) reaches listeners as a plain -//! string, so `err.code === 'ECONNREFUSED'` checks (every HTTP client library -//! does them) saw `undefined`. //! -//! Only the failure modes Perry can name with confidence are classified; -//! anything else returns `None` so the caller keeps the legacy string path -//! (which still maps `ECONNRESET` / `socket hang up` via `error_event_arg`). - -use std::error::Error as StdError; -use std::io::ErrorKind; +//! The turnloop transport reports the code, syscall and errno itself; this +//! module only shapes the message the way Node words it and supplies libuv's +//! errno when the runtime had none. (It used to reverse-engineer all of that +//! from a `reqwest::Error`'s `source()` chain.) /// `(message, code, syscall, errno)` describing a Node-shaped transport error. pub(crate) type Classified = (String, String, String, i64); -/// Host (and explicit-or-default port) parsed from an http(s) URL, for the -/// `connect :` message form. -fn host_port(url: &str) -> (String, Option) { - match reqwest::Url::parse(url) { - Ok(u) => ( - u.host_str().unwrap_or_default().to_string(), - u.port_or_known_default(), - ), - Err(_) => (String::new(), None), - } -} - -/// Platform errno (libuv-negative) for a code, used only when no concrete -/// `std::io::Error` surfaced in the source chain to read `raw_os_error()`. +/// Platform errno (libuv-negative) for a code, used only when the transport +/// reported none. /// /// Three platforms, not two. This table used to be a macOS-vs-else pair, which /// silently handed Windows the LINUX numbers (`ECONNABORTED` as -103 rather @@ -131,19 +112,6 @@ fn libuv_errno(code: &str, raw: Option) -> i64 { } } -/// Map a `std::io::ErrorKind` to a `(code, syscall)` for the connect path. -fn kind_to_code(kind: ErrorKind) -> Option<(&'static str, &'static str)> { - match kind { - ErrorKind::ConnectionRefused => Some(("ECONNREFUSED", "connect")), - ErrorKind::TimedOut => Some(("ETIMEDOUT", "connect")), - ErrorKind::ConnectionAborted => Some(("ECONNABORTED", "connect")), - ErrorKind::AddrNotAvailable => Some(("EADDRNOTAVAIL", "connect")), - ErrorKind::HostUnreachable => Some(("EHOSTUNREACH", "connect")), - ErrorKind::NetworkUnreachable => Some(("ENETUNREACH", "connect")), - _ => None, - } -} - fn connect_message(code: &str, host: &str, port: Option) -> String { match port { Some(p) => format!("connect {code} {host}:{p}"), @@ -151,110 +119,37 @@ fn connect_message(code: &str, host: &str, port: Option) -> String { } } -/// Classify a `reqwest::Error` raised by `request.send()`. Walks the error's -/// `source()` chain for the underlying `std::io::Error` (whose `raw_os_error` -/// gives the exact errno) and a lowercased text trail (for DNS detection, -/// since resolver failures carry no OS errno). -pub(crate) fn classify_reqwest(e: &reqwest::Error, url: &str) -> Option { - let (host, port) = host_port(url); - - let mut io_errno: Option = None; - let mut io_kind: Option = None; - let mut chain = String::new(); - let mut cur: Option<&(dyn StdError + 'static)> = Some(e); - while let Some(s) = cur { - chain.push_str(&s.to_string().to_lowercase()); - chain.push(' '); - if io_kind.is_none() { - if let Some(io) = s.downcast_ref::() { - io_errno = io.raw_os_error(); - io_kind = Some(io.kind()); - } - } - cur = s.source(); - } - - // The peer accepted the request and closed before any response head. - // reqwest's top-level Display is only "error sending request for url"; - // the useful incomplete-message/reset reason lives in the source chain. - // Node reports every such pre-response close as ECONNRESET "socket hang - // up", distinct from a reset while consuming an established response. - if io_kind == Some(ErrorKind::ConnectionReset) - || io_kind == Some(ErrorKind::UnexpectedEof) - || chain.contains("connection closed before message completed") - || chain.contains("incomplete message") - || chain.contains("connection reset") - { - return Some(( - "socket hang up".to_string(), - "ECONNRESET".to_string(), - "read".to_string(), - libuv_errno("ECONNRESET", io_errno), - )); - } - - // Concrete OS connect error (the common case): exact code + errno. - if let Some((code, syscall)) = io_kind.and_then(kind_to_code) { - let errno = libuv_errno(code, io_errno); - return Some(( - connect_message(code, &host, port), +/// A connect-phase failure in Node's shape: `(message, code, syscall, errno)`. +/// A resolver failure reads `getaddrinfo ENOTFOUND host`; anything else +/// `connect host:port`. +pub(crate) fn connect_failure( + code: &str, + syscall: &str, + errno: i64, + host: &str, + port: u16, +) -> Classified { + let errno = libuv_errno(code, (errno != 0).then(|| (-errno) as i32)); + if syscall == "getaddrinfo" { + return ( + format!("getaddrinfo {code} {host}"), code.to_string(), syscall.to_string(), errno, - )); - } - - // DNS resolution failure → getaddrinfo ENOTFOUND (no OS errno; libuv -3008). - let is_dns = chain.contains("dns error") - || chain.contains("failed to lookup address") - || chain.contains("failed to lookup") - || chain.contains("name or service not known") - || chain.contains("nodename nor servname") - || chain.contains("no such host") - || chain.contains("name resolution") - || chain.contains("name not resolved"); - if is_dns { - return Some(( - format!("getaddrinfo ENOTFOUND {host}"), - "ENOTFOUND".to_string(), - "getaddrinfo".to_string(), - -3008, - )); - } - - // Text fallback when no `io::Error` surfaced but the reason is recognizable. - if chain.contains("connection refused") { - return Some(( - connect_message("ECONNREFUSED", &host, port), - "ECONNREFUSED".to_string(), - "connect".to_string(), - fallback_errno("ECONNREFUSED"), - )); + ); } - - None + ( + connect_message(code, host, Some(port)), + code.to_string(), + "connect".to_string(), + errno, + ) } #[cfg(test)] mod tests { use super::*; - #[test] - fn host_port_parses_explicit_port() { - assert_eq!( - host_port("http://127.0.0.1:1/p"), - ("127.0.0.1".to_string(), Some(1)) - ); - } - - #[test] - fn host_port_defaults_known_scheme() { - assert_eq!( - host_port("http://example.com/"), - ("example.com".to_string(), Some(80)) - ); - } - #[test] fn connect_message_shapes() { assert_eq!( @@ -268,11 +163,18 @@ mod tests { } #[test] - fn kind_mapping() { + fn connect_failures_read_as_node_words_them() { + let (message, code, syscall, _) = + connect_failure("ECONNREFUSED", "connect", -111, "127.0.0.1", 1); + assert_eq!(message, "connect ECONNREFUSED 127.0.0.1:1"); assert_eq!( - kind_to_code(ErrorKind::ConnectionRefused), - Some(("ECONNREFUSED", "connect")) + (code.as_str(), syscall.as_str()), + ("ECONNREFUSED", "connect") ); - assert_eq!(kind_to_code(ErrorKind::NotFound), None); + let (message, _, syscall, errno) = + connect_failure("ENOTFOUND", "getaddrinfo", -3008, "nowhere.invalid", 80); + assert_eq!(message, "getaddrinfo ENOTFOUND nowhere.invalid"); + assert_eq!(syscall, "getaddrinfo"); + assert_ne!(errno, 0); } } diff --git a/crates/perry-ext-http/src/validation.rs b/crates/perry-ext-http/src/validation.rs index 84dfa2e2bd..41ed794f4c 100644 --- a/crates/perry-ext-http/src/validation.rs +++ b/crates/perry-ext-http/src/validation.rs @@ -33,7 +33,7 @@ fn is_valid_token(s: &str) -> bool { /// `TypeError [ERR_INVALID_URL]`. Mirror that here, before Perry's lenient /// `"{proto}://{raw}"` prepend (#769) would otherwise paper over it. pub(crate) fn validate_client_url_string(raw: &str) { - let invalid = match reqwest::Url::parse(raw) { + let invalid = match url::Url::parse(raw) { Ok(u) => u.host_str().map(|h| h.is_empty()).unwrap_or(true), Err(_) => true, }; diff --git a/crates/perry-ext-http/tests/turnloop_client_exchange.rs b/crates/perry-ext-http/tests/turnloop_client_exchange.rs index 72677657f5..bcf6899196 100644 --- a/crates/perry-ext-http/tests/turnloop_client_exchange.rs +++ b/crates/perry-ext-http/tests/turnloop_client_exchange.rs @@ -1,5 +1,5 @@ -//! The `node:http` client lane carries a real exchange over turnloop -//! (`client_turnloop`, lane 1). +//! The `node:http` / `node:https` client carries every request shape over +//! turnloop (`client_turnloop`) — there is no other transport to fall back to. //! //! # Why this is an integration binary with exactly ONE `#[test]` //! @@ -12,34 +12,33 @@ //! `cargo-test` leg runs the default pool — so the lottery is real here. //! One `#[test]` in its own binary is one thread in its own process, so it is //! the first asker by construction. This is the same reasoning, and the same -//! shape, as `turnloop_reuse_port.rs` next to it. +//! shape, as `turnloop_reuse_port.rs` next to it. The shapes run in sequence +//! inside that one test. //! //! # What makes this non-vacuous //! -//! A decline is *invisible*: `try_dispatch` returning `false` for every -//! request would leave the JS surface behaving exactly as it does on reqwest, -//! and a test that only checked "the response arrived" would stay green having -//! never touched turnloop — CLAUDE.md's fourth way a gate cannot fail. So this -//! test asserts the subject was live three times over, and there is no -//! "skip if no loop" arm anywhere: +//! Every shape is checked from both ends, and there is no "skip if no loop" +//! arm anywhere: //! -//! 1. `try_dispatch` returned `true` — the lane ACCEPTED rather than declined. -//! 2. `completed_total()` moved — a response was decoded through to -//! `Event::End`, not merely attempted. -//! 3. The server, a plain `std::net::TcpListener` that knows nothing about -//! turnloop, received a well-formed request head. That is the proof the -//! bytes actually reached a socket. +//! * **the transport's own counters** moved — `completed_total()` for a +//! response decoded to its end, `reused_total()` for a pooled connection, +//! `tls_handshakes_total()` for a handshake, `timed_out_total()` for a +//! deadline. An exchange that errored also drops the in-flight guard, so +//! "the loop went quiet" alone would not distinguish success from failure; +//! * **the server's view** — a plain `std::net::TcpListener` (or a rustls +//! server over one) that knows nothing about turnloop received the exact +//! bytes, and, for keep-alive, received both requests on ONE connection. //! -//! The response is a **307** on purpose. Node's `http.request` must never -//! follow a redirect, and this lane gets that by construction (it runs no -//! redirect policy at all) — so a 307 that arrives as a 307, with the server -//! hit exactly once, is the regression `test_gap_http_client_no_redirect_follow.ts` -//! pins, asserted here at the transport instead of through the JS surface. +//! Sabotage-checked while writing this: making `park` close instead of pool +//! fails the keep-alive shape on `reused_total()` and on the server seeing a +//! second connection; handing the session a config without the fixture CA +//! fails the https shape on `tls_handshakes_total()`. use std::collections::HashMap; use std::io::{Read, Write}; -use std::net::TcpListener; +use std::net::{TcpListener, TcpStream}; use std::sync::mpsc; +use std::sync::Arc; use std::time::{Duration, Instant}; use perry_ext_http::client_turnloop; @@ -73,37 +72,143 @@ unsafe extern "C" fn js_bun_http_response_snapshot_json( std::ptr::null_mut() } -/// A `ClientRequestHandle` id that is deliberately not in the handle registry. -/// Nothing in this lane dereferences it — it is the address events are queued -/// against — and `js_ext_http_client_inflight` treats an unknown handle as -/// having no socket facade, which is the counted case. -const REQUEST_HANDLE: i64 = 0x5eed_c11e; +/// `ClientRequestHandle` ids that are deliberately not in the handle registry. +/// Nothing in the transport dereferences them — they are the addresses events +/// are queued against — and `js_ext_http_client_inflight` treats an unknown +/// handle as having no socket facade, which is the counted case. +const GET_307: i64 = 0x5eed_c11e; +const POST_BODY: i64 = GET_307 + 1; +const POOLED_A: i64 = GET_307 + 2; +const POOLED_B: i64 = GET_307 + 3; +const DEADLINE: i64 = GET_307 + 4; +const TRAILERS: i64 = GET_307 + 5; +const SECURE: i64 = GET_307 + 6; + +const CERT_PEM: &[u8] = + include_bytes!("../../../test-parity/node-suite/tls/fixtures/localhost-cert.pem"); +const KEY_PEM: &[u8] = + include_bytes!("../../../test-parity/node-suite/tls/fixtures/localhost-key.pem"); + +/// One nonblocking turn plus its dispatch. +/// +/// Budget 0 on purpose. A positive budget parks, and a park returns at once +/// without turning while the process-wide `NOTIFIED` flag is set — which every +/// queued `PendingHttpEvent` sets, and which only the JS event loop's +/// `js_wait_for_event` consumes. This binary has no JS event loop, so after the +/// first delivered event a parking turn would never collect another +/// completion. (The single-exchange version of this test never noticed: its +/// only events were pushed after its last turn.) +fn turn() { + perry_runtime::event_pump::js_loop_turn_bounded(0); + std::thread::sleep(Duration::from_millis(1)); +} + +/// Turn the loop until every accepted exchange has settled. +fn drive(what: &str) { + let deadline = Instant::now() + Duration::from_secs(30); + while perry_ext_http::js_ext_http_client_inflight() != 0 { + turn(); + assert!( + Instant::now() < deadline, + "{what}: the exchange never reached a terminal event — the in-flight \ + guard is still held 30s after submission" + ); + } +} + +/// Join a server thread while still turning the loop. A server that waits +/// for the client's close would otherwise never see it: `tl::close` is only a +/// submission, and the loop performs it on its next turn. +fn join(server: std::thread::JoinHandle<()>, what: &str) { + let deadline = Instant::now() + Duration::from_secs(30); + while !server.is_finished() { + turn(); + assert!( + Instant::now() < deadline, + "{what}: the server thread did not finish — the client never closed" + ); + } + server.join().expect("the server thread must not panic"); +} + +/// Let submitted closes complete so no handle is left open on the loop. +fn settle() { + for _ in 0..50 { + turn(); + } +} + +/// Read one request (head plus a `Content-Length` body) off `stream`. +fn read_request(stream: &mut impl Read) -> Option<(String, Vec)> { + let mut buf = Vec::new(); + let mut chunk = [0u8; 4096]; + let head_end = loop { + if let Some(pos) = buf.windows(4).position(|w| w == b"\r\n\r\n") { + break pos + 4; + } + match stream.read(&mut chunk) { + Ok(0) | Err(_) => return None, + Ok(n) => buf.extend_from_slice(&chunk[..n]), + } + }; + let head = String::from_utf8_lossy(&buf[..head_end]).into_owned(); + let length = head + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::().ok()) + .flatten() + }) + .unwrap_or(0); + let mut body = buf[head_end..].to_vec(); + while body.len() < length { + match stream.read(&mut chunk) { + Ok(0) | Err(_) => break, + Ok(n) => body.extend_from_slice(&chunk[..n]), + } + } + Some((head, body)) +} + +fn no_headers() -> HashMap { + HashMap::new() +} #[test] -fn a_cleartext_get_is_carried_end_to_end_and_a_307_is_not_followed() { - // ── A server that knows nothing about turnloop ─────────────────────── +fn every_client_shape_is_carried_end_to_end_on_turnloop() { + // ── Become this agent's loop owner ─────────────────────────────────── + // No skip arm: turning the loop is what publishes the route, and if it + // never becomes available every assertion below would be vacuous. + let deadline = Instant::now() + Duration::from_secs(20); + while !client_turnloop::available() { + turn(); + assert!( + Instant::now() < deadline, + "this thread never became the agent's turnloop loop owner; the \ + transport cannot be exercised and every assertion below would be vacuous" + ); + } + + a_cleartext_get_is_carried_and_a_307_is_not_followed(); + a_post_body_and_an_explicit_host_reach_the_wire(); + an_agent_with_keep_alive_reuses_one_connection(); + a_deadline_tears_down_an_exchange_the_server_never_answers(); + a_te_trailers_response_is_decoded_to_its_end(); + an_https_request_handshakes_with_the_callers_ca(); +} + +fn a_cleartext_get_is_carried_and_a_307_is_not_followed() { let listener = TcpListener::bind("127.0.0.1:0").expect("an ephemeral port"); let port = listener.local_addr().expect("a bound address").port(); let (heads, received) = mpsc::channel::(); - // Lets the test tell the server when to let go. The server must NOT close - // first: a lane that only finished on EOF would then pass too, and the - // point of the Content-Length framing is that `Event::End` arrives while - // the connection is still open. + // The server must NOT close first: a transport that only finished on EOF + // would then pass too, and the point of the Content-Length framing is that + // the end arrives while the connection is still open. let (release, released) = mpsc::channel::<()>(); let server = std::thread::spawn(move || { let (mut stream, _) = listener.accept().expect("exactly one connection"); - let mut head = Vec::new(); - let mut buf = [0u8; 1024]; - while !head.windows(4).any(|w| w == b"\r\n\r\n") { - match stream.read(&mut buf) { - Ok(0) | Err(_) => break, - Ok(n) => head.extend_from_slice(&buf[..n]), - } - } - // Content-Length delimited, so `Event::End` arrives from the body - // bytes rather than from a close — which also means a lane that never - // made the zero-byte `receive` call would hang here instead of - // finishing, and the deadline below would catch it. + let (head, _) = read_request(&mut stream).expect("a request head"); let _ = stream.write_all( b"HTTP/1.1 307 Temporary Redirect\r\n\ location: /target\r\n\ @@ -113,91 +218,318 @@ fn a_cleartext_get_is_carried_end_to_end_and_a_307_is_not_followed() { redirect", ); let _ = stream.flush(); - let _ = heads.send(String::from_utf8_lossy(&head).into_owned()); - // Hold the connection open until the test has seen the exchange - // finish. Blocking on a read instead would deadlock: the client's - // `tl::close` completes on a later turn of the loop, and by then the - // test has stopped turning it. + let _ = heads.send(head); let _ = released.recv_timeout(Duration::from_secs(30)); }); - // ── Become this agent's loop owner ─────────────────────────────────── - // No skip arm: turning the loop is what publishes the route, and if it - // never becomes available every assertion below would be vacuous. - let deadline = Instant::now() + Duration::from_secs(20); - while !client_turnloop::available() { - perry_runtime::event_pump::js_loop_turn_bounded(1); - assert!( - Instant::now() < deadline, - "this thread never became the agent's turnloop loop owner; the lane \ - cannot be exercised and every assertion below would be vacuous" - ); - } - - let accepted_before = client_turnloop::accepted_total(); let completed_before = client_turnloop::completed_total(); - - // ── The subject ────────────────────────────────────────────────────── let url = format!("http://127.0.0.1:{port}/start"); - let accepted = - client_turnloop::try_dispatch(REQUEST_HANDLE, "GET", &url, &HashMap::new(), &[], None, 0); assert!( - accepted, - "lane 1 must ACCEPT a cleartext bodyless GET on the default agent. A \ - decline here is silent — reqwest would serve the request and this test \ - would prove nothing about turnloop" + client_turnloop::try_dispatch(GET_307, "GET", &url, &no_headers(), &[], None, 0), + "a cleartext GET must be carried" ); - assert_eq!( - client_turnloop::accepted_total(), - accepted_before + 1, - "an accepted exchange must be counted" - ); - - // ── Drive it to completion ─────────────────────────────────────────── - let deadline = Instant::now() + Duration::from_secs(30); - while perry_ext_http::js_ext_http_client_inflight() != 0 { - perry_runtime::event_pump::js_loop_turn_bounded(5); - assert!( - Instant::now() < deadline, - "the exchange never reached a terminal event: the inflight guard is \ - still held {}s after submission", - 30 - ); - } - + drive("GET 307"); assert_eq!( client_turnloop::completed_total(), completed_before + 1, - "the response must have decoded through to Event::End. An exchange that \ - errored also drops the inflight guard, so the loop above alone does not \ - distinguish success from failure" + "the 307 must have decoded through to its end" ); - // ── The server's view: the bytes really went out ───────────────────── let head = received .recv_timeout(Duration::from_secs(10)) .expect("the server must have received a request head"); let _ = release.send(()); - server.join().expect("the server thread must not panic"); - // Let the socket close this lane submitted at `Event::End` complete, so - // the binary does not exit with a handle still open on the loop. - for _ in 0..50 { - perry_runtime::event_pump::js_loop_turn_bounded(1); - } + join(server, "server"); + settle(); assert!( head.starts_with("GET /start HTTP/1.1\r\n"), "origin-form request line expected, got:\n{head}" ); - let lower = head.to_ascii_lowercase(); assert!( - lower.contains(&format!("host: 127.0.0.1:{port}\r\n")), + head.contains(&format!("Host: 127.0.0.1:{port}\r\n")), "the authority must carry the port, got:\n{head}" ); - // Mirrors what the reqwest path put on the wire, so servers reading - // `req.headers.connection` see no change from this migration. assert!( - lower.contains("connection: keep-alive\r\n"), + head.contains("Connection: keep-alive\r\n"), "Node's default agent advertises keep-alive, got:\n{head}" ); } + +fn a_post_body_and_an_explicit_host_reach_the_wire() { + let listener = TcpListener::bind("127.0.0.1:0").expect("an ephemeral port"); + let port = listener.local_addr().expect("a bound address").port(); + let (seen, received) = mpsc::channel::<(String, Vec)>(); + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("exactly one connection"); + let request = read_request(&mut stream).expect("a request"); + let _ = stream.write_all(b"HTTP/1.1 201 Made It\r\ncontent-length: 2\r\n\r\nok"); + let _ = seen.send(request); + // Hold the socket until the client closes it. + let mut rest = Vec::new(); + let _ = stream.read_to_end(&mut rest); + }); + + let completed_before = client_turnloop::completed_total(); + let headers: HashMap = [ + ("Host".to_string(), "vhost.invalid".to_string()), + ("Content-Type".to_string(), "text/plain".to_string()), + ] + .into(); + let url = format!("http://127.0.0.1:{port}/upload"); + assert!(client_turnloop::try_dispatch( + POST_BODY, "POST", &url, &headers, b"hello", None, 0 + )); + drive("POST body"); + assert_eq!(client_turnloop::completed_total(), completed_before + 1); + + let (head, body) = received + .recv_timeout(Duration::from_secs(10)) + .expect("the server must have received the request"); + join(server, "server"); + settle(); + assert!(head.starts_with("POST /upload HTTP/1.1\r\n"), "{head}"); + assert!( + head.contains("Host: vhost.invalid\r\n"), + "a caller's Host must reach the wire verbatim, got:\n{head}" + ); + assert!(head.contains("Content-Type: text/plain\r\n"), "{head}"); + assert!(head.contains("Content-Length: 5\r\n"), "{head}"); + assert_eq!(body, b"hello"); +} + +fn an_agent_with_keep_alive_reuses_one_connection() { + let listener = TcpListener::bind("127.0.0.1:0").expect("an ephemeral port"); + let port = listener.local_addr().expect("a bound address").port(); + let (served, received) = mpsc::channel::>(); + let (release, released) = mpsc::channel::<()>(); + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("the first connection"); + let mut heads = Vec::new(); + for index in 0..2 { + let Some((head, _)) = read_request(&mut stream) else { + break; + }; + heads.push(head); + let body = format!("r{index}"); + let _ = stream.write_all( + format!( + "HTTP/1.1 200 OK\r\ncontent-length: {}\r\n\r\n{body}", + body.len() + ) + .as_bytes(), + ); + } + // A second connection would mean the pool did not reuse. + listener + .set_nonblocking(true) + .expect("a nonblocking listener"); + if listener.accept().is_ok() { + heads.push("SECOND CONNECTION".to_string()); + } + let _ = served.send(heads); + let _ = released.recv_timeout(Duration::from_secs(30)); + }); + + let reused_before = client_turnloop::reused_total(); + let completed_before = client_turnloop::completed_total(); + let url = format!("http://127.0.0.1:{port}/pooled"); + // Agent key 0x7a is not a registered AgentHandle; the pool keys by it all + // the same, which is all this needs. + assert!(client_turnloop::try_dispatch_pooled( + POOLED_A, + "GET", + &url, + &no_headers(), + &[], + 0x7a, + 4, + 5_000 + )); + drive("pooled A"); + assert!(client_turnloop::try_dispatch_pooled( + POOLED_B, + "GET", + &url, + &no_headers(), + &[], + 0x7a, + 4, + 5_000 + )); + drive("pooled B"); + assert_eq!(client_turnloop::completed_total(), completed_before + 2); + assert_eq!( + client_turnloop::reused_total(), + reused_before + 1, + "the second request must run on the connection the first one parked" + ); + + let heads = received + .recv_timeout(Duration::from_secs(10)) + .expect("the server must report what it served"); + let _ = release.send(()); + join(server, "server"); + assert_eq!( + heads.len(), + 2, + "both requests on ONE connection, and no second connection: {heads:?}" + ); + assert!(heads + .iter() + .all(|h| h.starts_with("GET /pooled HTTP/1.1\r\n"))); + // Close the parked connection so the binary exits with no open handle. + client_turnloop::purge_agent(0x7a); + settle(); +} + +fn a_deadline_tears_down_an_exchange_the_server_never_answers() { + let listener = TcpListener::bind("127.0.0.1:0").expect("an ephemeral port"); + let port = listener.local_addr().expect("a bound address").port(); + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("one connection"); + let _ = read_request(&mut stream); + // Never answer; return once the client gives up and closes. + let mut rest = Vec::new(); + let _ = stream.read_to_end(&mut rest); + }); + + let timed_out_before = client_turnloop::timed_out_total(); + let completed_before = client_turnloop::completed_total(); + let url = format!("http://127.0.0.1:{port}/silent"); + let started = Instant::now(); + assert!(client_turnloop::try_dispatch( + DEADLINE, + "GET", + &url, + &no_headers(), + &[], + Some(150), + 0 + )); + drive("deadline"); + assert_eq!( + client_turnloop::timed_out_total(), + timed_out_before + 1, + "the deadline, not anything else, must have ended the exchange" + ); + assert_eq!(client_turnloop::completed_total(), completed_before); + assert!(started.elapsed() >= Duration::from_millis(150)); + join( + server, + "the server sees the connection close after the deadline", + ); + settle(); +} + +fn a_te_trailers_response_is_decoded_to_its_end() { + let listener = TcpListener::bind("127.0.0.1:0").expect("an ephemeral port"); + let port = listener.local_addr().expect("a bound address").port(); + let (heads, received) = mpsc::channel::(); + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("one connection"); + let (head, _) = read_request(&mut stream).expect("a request head"); + let _ = stream.write_all( + b"HTTP/1.1 200 OK\r\ntransfer-encoding: chunked\r\ntrailer: x-checksum\r\n\r\n\ + 2\r\nok\r\n0\r\nx-checksum: abc\r\n\r\n", + ); + let _ = heads.send(head); + let mut rest = Vec::new(); + let _ = stream.read_to_end(&mut rest); + }); + let completed_before = client_turnloop::completed_total(); + let headers: HashMap = [("TE".to_string(), "trailers".to_string())].into(); + let url = format!("http://127.0.0.1:{port}/trailers"); + assert!(client_turnloop::try_dispatch( + TRAILERS, + "GET", + &url, + &headers, + &[], + None, + 0 + )); + drive("trailers"); + assert_eq!(client_turnloop::completed_total(), completed_before + 1); + let head = received + .recv_timeout(Duration::from_secs(10)) + .expect("a request head"); + join(server, "server"); + settle(); + assert!(head.contains("TE: trailers\r\n"), "{head}"); + assert!( + head.ends_with("Connection: close\r\n\r\n"), + "the trailers shape keeps its bypass's close framing, got:\n{head}" + ); +} + +fn an_https_request_handshakes_with_the_callers_ca() { + let certs: Vec> = + rustls_pemfile::certs(&mut std::io::Cursor::new(CERT_PEM)) + .collect::>() + .expect("the fixture certificate parses"); + let key = rustls_pemfile::private_key(&mut std::io::Cursor::new(KEY_PEM)) + .expect("the fixture key parses") + .expect("the fixture has a key"); + let config = Arc::new( + rustls::ServerConfig::builder_with_provider(Arc::new( + rustls::crypto::ring::default_provider(), + )) + .with_safe_default_protocol_versions() + .expect("protocol versions") + .with_no_client_auth() + .with_single_cert(certs, key) + .expect("a server config"), + ); + + let listener = TcpListener::bind("127.0.0.1:0").expect("an ephemeral port"); + let port = listener.local_addr().expect("a bound address").port(); + let (heads, received) = mpsc::channel::<(String, Option>)>(); + let server = std::thread::spawn(move || { + let (stream, _): (TcpStream, _) = listener.accept().expect("one connection"); + let connection = rustls::ServerConnection::new(config).expect("a server session"); + let mut tls = rustls::StreamOwned::new(connection, stream); + let Some((head, _)) = read_request(&mut tls) else { + let _ = heads.send((String::new(), None)); + return; + }; + let alpn = tls.conn.alpn_protocol().map(<[u8]>::to_vec); + let _ = tls.write_all(b"HTTP/1.1 200 OK\r\ncontent-length: 6\r\n\r\nsecure"); + let _ = tls.flush(); + let _ = heads.send((head, alpn)); + let mut rest = Vec::new(); + let _ = tls.read_to_end(&mut rest); + }); + + let handshakes_before = client_turnloop::tls_handshakes_total(); + let completed_before = client_turnloop::completed_total(); + let url = format!("https://127.0.0.1:{port}/secure"); + assert!(client_turnloop::try_dispatch_tls( + SECURE, + "GET", + &url, + &no_headers(), + &[], + None, + 0, + vec![CERT_PEM.to_vec()], + )); + drive("https"); + assert_eq!( + client_turnloop::tls_handshakes_total(), + handshakes_before + 1, + "the handshake must have completed on the transport" + ); + assert_eq!( + client_turnloop::completed_total(), + completed_before + 1, + "the response must have been decrypted and decoded to its end" + ); + let (head, alpn) = received + .recv_timeout(Duration::from_secs(10)) + .expect("the TLS server must have received a request"); + join(server, "server"); + settle(); + assert!(head.starts_with("GET /secure HTTP/1.1\r\n"), "{head}"); + assert_eq!(alpn, None, "Node's https client offers no ALPN"); +} diff --git a/scripts/tokio_inventory.json b/scripts/tokio_inventory.json index 70be77f098..fca9e0cbbc 100644 --- a/scripts/tokio_inventory.json +++ b/scripts/tokio_inventory.json @@ -49,42 +49,18 @@ "issue": "unfiled \u2014 P8", "plan": "K" }, - { - "crate": "perry-ext-http", - "dep": "reqwest", - "kind": "normal", - "optional": false, - "target": null, - "surface": "`http.request()` / `https.get()` / `https.request()` \u2014 the node:http CLIENT half", - "reached_when": "NO LONGER always. `client_turnloop::try_dispatch` is offered every exchange before `dispatch_request` and takes cleartext `http://` requests with no body, no explicit Agent, no per-request timeout, no proxy and none of the three bypass headers; reqwest gets everything it declines. So: `https://`, any request body, an explicit `http.Agent`, `options.timeout`/`req.setTimeout`, `NODE_USE_ENV_PROXY=1`, a URL with credentials, the CONNECT/TRACE/TRACK methods, and `TE: trailers` / `Connection: Upgrade` / `Expect: 100-continue` (which keep their own raw-socket bypasses).", - "blocker": "The decline set above, shrinking per lane: TLS (`perry-tls-session` above the same handle, as `perry-ext-ws`'s client does), request-body framing plus `Lifecycle` deadlines armed on `tl::timer_arm`, keep-alive via `turnloop_http::client::Pool`, and the Agent lane. CORRECTION to the older note: `agent.rs` is NOT a duplicate of reqwest's pool and is not the blocker it was described as. It is a real per-origin admission engine (FIFO waiter queue, maxSockets/maxTotalSockets/maxFreeSockets, socket facades) that sits ABOVE the transport and survives the migration nearly intact; the reqwest coupling there is 9 call sites in `client_for_agent`/`client_for_agent_tls`/`agent_pool_config`. Also stale in that note: the three raw `tokio::net::TcpStream` bypasses are `plain_client.rs` (`TE: trailers`), `continue_client.rs` (`Expect: 100-continue`) and `client_upgrade.rs` (`Connection: Upgrade`, #10468) \u2014 NOT the `agent.createConnection` override, which reaches no tokio at all (it runs on perry-ext-net's raw_net vtable in `client_connect_override.rs`). `Http1Connection` hands `Event::Trailers`, `Event::Informational` and `Event::Upgrade` back to the caller, so all three ARE deletable by this migration. `createConnection` is a different problem: it needs an fd-adoption ABI that does not exist yet \u2014 `turnloop::Detached::from_fd` + `Loop::attach` are real, but neither `perry_ffi::turnloop_net` nor `perry-runtime`'s `turnloop_net/abi.rs` exposes them to a binding crate.", - "issue": "#10328 (the agent cache never evicts); the transport is unfiled \u2014 P6 named it, P8 confirms it", - "plan": "C" - }, { "crate": "perry-ext-http", "dep": "tokio", "kind": "normal", "optional": false, "target": null, - "surface": "the node:http/https CLIENT only: every row above, plus the three raw-TcpStream client bypasses. NO LONGER any server: `http.createServer` / `https.createServer` / `http2.createServer` / `createSecureServer` are turnloop-only (plan A closed; `src/server/` contains no `tokio::` code), and NO LONGER `http2.connect`.", - "reached_when": "always (the node:http client). The server no longer reaches tokio on any thread: a thread that does not own its agent's loop posts `listen()` to the owner, and a host where Loop::new failed reports ENOTSUP.", - "blocker": "the union of the rows above, and NOT group D's own work -- the plan's row D already said so (\u201cits last, once C and E are done\u201d). E is done, D's `h2` half is done, and plan A is done: the hyper HTTP/1.1, HTTPS and HTTP/2 accept loops, the raw-'upgrade' peeler, the tokio-tungstenite attached-WebSocket path and the SCHED_RR fd-inject loop are deleted (SCHED_RR descriptors are adopted onto the loop with `turnloop_net::adopt_stream`), and so is tokio's type vocabulary in the server structs (`ServerResponse::{response_tx, stream_tx, stream_in_flight, connection_close}`, `HttpServer::{shutdown_tx, request_rx, upgrade_rx}`, `Http2StreamHandle::response_tx`, `ShapeBody::Stream`). What is left is `reqwest` + `tokio-rustls` (plan C, the node:http/https CLIENT: ~1,950 lines of `agent.rs` plus three raw `tokio::net::TcpStream` bypasses), so this edge is held open entirely by C.", + "surface": "two small node:http CLIENT paths and nothing else: an `agent.createConnection` / `createSocket` (or request-level `createConnection`) exchange, which `client_connect_override.rs` drives from a tokio task polling perry-ext-net's raw vtable with a 1 ms `tokio::time::sleep`; and the keep-alive Agent's socket-facade idle expiry (`agent.rs`, `perry_ffi::spawn_async` + a 40 ms `tokio::time::sleep`). NO LONGER the client transport: every exchange reqwest carried and the three raw-TcpStream bypasses (`TE: trailers`, `Expect: 100-continue`, `Connection: Upgrade`) run on turnloop in `client_turnloop`. NO LONGER any server (plan A closed) and NO LONGER `http2.connect`.", + "reached_when": "only when a request's Agent (or the request itself) supplies `createConnection` / `createSocket`, or when a keep-alive Agent returns a socket facade to its free pool", + "blocker": "plan A, C and D's `h2` half are done; `reqwest` and `tokio-rustls` left this crate with the client transport. Two separable uses hold the edge open: (1) the `createConnection` exchange loop, which polls because perry-ffi's `raw_net` vtable has no completion push -- driving it from perry-ext-net's socket events (the socket is already on the loop) removes it; (2) the facade idle-expiry sleep, which becomes a `tl::timer_arm` deadline exactly as `client_outgoing::arm_client_timeout` did. After both, the manifest line deletes.", "issue": "unfiled \u2014 P8", "plan": "D" }, - { - "crate": "perry-ext-http", - "dep": "tokio-rustls", - "kind": "normal", - "optional": false, - "target": null, - "surface": "TLS for the node:https client. NO LONGER `https.createServer()`: the server's TLS is perry-ext-net's turnloop session on every thread (plan A closed).", - "reached_when": "the node:https client (reqwest's rustls stack and the raw client bypasses)", - "blocker": "the client half of plan C; the server half is gone.", - "issue": "unfiled \u2014 P8", - "plan": "C" - }, { "crate": "perry-ext-ioredis", "dep": "redis", @@ -159,18 +135,6 @@ } ], "lockfile": { - "h2": [ - "0.4.19" - ], - "hyper": [ - "1.11.1" - ], - "hyper-rustls": [ - "0.27.9" - ], - "hyper-util": [ - "0.1.20" - ], "lettre": [ "0.11.23" ], @@ -180,9 +144,6 @@ "redis": [ "1.7.0" ], - "reqwest": [ - "0.12.28" - ], "tokio": [ "1.53.1" ], @@ -192,12 +153,6 @@ "tokio-util": [ "0.7.18" ], - "tower": [ - "0.5.3" - ], - "tower-http": [ - "0.6.11" - ], "tungstenite": [ "0.30.0" ] @@ -206,7 +161,7 @@ "perry": 3, "perry-container-compose": 14, "perry-ext-ads": 5, - "perry-ext-http": 43, + "perry-ext-http": 7, "perry-ext-ioredis": 13, "perry-ext-mongodb": 29, "perry-ffi": 2, From 15aeb04c6d6c243cc0cc1e0ffd442b08bd3a9c59 Mon Sep 17 00:00:00 2001 From: Perry Bot Date: Thu, 24 Sep 2026 08:10:19 +0000 Subject: [PATCH 2/2] changelog: node:http/https client on turnloop, reqwest + tokio-rustls dropped (#11205) --- ...11205-http-client-turnloop-drop-reqwest.md | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 changelog.d/11205-http-client-turnloop-drop-reqwest.md diff --git a/changelog.d/11205-http-client-turnloop-drop-reqwest.md b/changelog.d/11205-http-client-turnloop-drop-reqwest.md new file mode 100644 index 0000000000..1b5e7f4ad1 --- /dev/null +++ b/changelog.d/11205-http-client-turnloop-drop-reqwest.md @@ -0,0 +1,65 @@ +`node:http` / `node:https` client: every request now runs on turnloop, and +`reqwest` and `tokio-rustls` are no longer dependencies of `perry-ext-http` +(tokio lane C). With #11144 having taken the server off hyper, this also removes +`reqwest`, `hyper`, `hyper-util`, `hyper-rustls`, `h2`, `tower` and +`tower-http` from `Cargo.lock` entirely. The tokio inventory goes from 11 to 9 +manifest edges and from 14 to 7 tokio-family lockfile packages. + +`client_turnloop` (now `src/client_turnloop/`) was lane 1's +bodyless-cleartext-GET-only path (#11091). It now carries every shape reqwest +did, plus the three raw tokio `TcpStream` bypasses: + +- **Request bodies.** They are buffered at `end()`, so the length is always + known: `Content-Length`, or chunked when the caller set + `Transfer-Encoding: chunked`. +- **`options.timeout` / `req.setTimeout`.** A `tl::timer_arm` deadline over the + whole exchange, as reqwest's `RequestBuilder::timeout` was. It fires + `'timeout'` and tears the exchange down. The creation-time `'timeout'` timer + (`arm_client_timeout`) is a turnloop deadline too; it was a tokio sleep. +- **`https:`.** `perry_tls_session::TlsSession` runs above the same socket + handle, with the verifier `tls_client` already built from Node's options (CA, + `servername`/SNI, `rejectUnauthorized`, `checkServerIdentity`, PKCS#12 client + identities). Configs are cached per option identity, so TLS session + resumption still works. +- **Keep-alive.** An Agent with `keepAlive` reuses physical connections, using + the knobs reqwest's per-agent pool used (`maxFreeSockets`, `keepAliveMsecs`). + A connection goes back to the pool only after the decoder reports `End` and + `reusable()`. A reused connection that dies before any response byte is + retried once on a fresh one. `agent.destroy()` closes its idle connections. +- **`NODE_USE_ENV_PROXY=1`.** An `http:` target is sent in absolute-form + through the proxy; an `https:` target goes through a `CONNECT` tunnel. The + proxy URL's credentials become `Proxy-Authorization`. +- **`TE: trailers`, `Expect: 100-continue` and `Connection: Upgrade`** now run + on the codec's `Event::Trailers` / `Informational` / `Upgrade`. A `101` hands + the live handle to `net` with `turnloop_net::transfer`, as the server's + upgrade does. The old modules keep only their predicates and parsers. +- **Off-loop threads.** A thread that does not own its agent's loop posts the + request to the thread that does. A host with no loop at all reports + `ENOTSUP`. + +Changes you can observe, each toward Node: + +- `res.statusMessage` is now the server's own reason phrase, not the canonical + one. +- Unknown methods go out as written; reqwest sent them as `GET`. +- A caller's header names keep their case. +- `timeout: 0` means no timeout. +- `https` offers no ALPN, so there is no accidental HTTP/2. +- `https` requests get `'continue'` too. +- `req.destroy()` / `abort()` close the socket. +- Connect failures read `connect ECONNREFUSED 127.0.0.1:1` (lane 1 had dropped + the address), and a close before the response head is `socket hang up` / + `ECONNRESET`. +- TLS verification failures carry Node's `.code` + (`UNABLE_TO_VERIFY_LEAF_SIGNATURE`, `ERR_TLS_CERT_ALTNAME_INVALID`, …). + Before, they were an uncoded string. +- There is no 30-second default timeout any more (reqwest applied one; Node + does not). + +Also fixed from lane 1: bytes of a response head (or chunk-size line) split +across two reads are kept for the next read instead of dropped. + +Still on tokio in this crate (the one remaining `perry-ext-http -> tokio` +edge): the `agent.createConnection` / `createSocket` exchange +(`client_connect_override.rs` polls the raw-net vtable), and the keep-alive +socket facade's 40 ms idle-expiry sleep in `agent.rs`.