diff --git a/CLAUDE.md b/CLAUDE.md index dd36ff0f47..c432efb73d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Perry is a native TypeScript compiler written in Rust that compiles TypeScript source code directly to native executables. It uses SWC for TypeScript parsing and LLVM for code generation. -**Current Version:** 0.5.1640 +**Current Version:** 0.5.1641 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index 0814134c3a..d487623294 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5447,7 +5447,7 @@ checksum = "1473d470930ed48574515a25df34900f3af89c6fa422d903e019121312a9f13e" [[package]] name = "perry" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "anyhow", "base64 0.22.1", @@ -5508,7 +5508,7 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "perry-dispatch", "serde", @@ -5516,7 +5516,7 @@ dependencies = [ [[package]] name = "perry-audio-miniaudio" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "cc", "libc", @@ -5525,7 +5525,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "aho-corasick", "anyhow", @@ -5542,7 +5542,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "anyhow", "perry-hir", @@ -5550,7 +5550,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "anyhow", "perry-hir", @@ -5558,7 +5558,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "anyhow", "perry-dispatch", @@ -5567,7 +5567,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "anyhow", "perry-hir", @@ -5575,7 +5575,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "anyhow", "base64 0.22.1", @@ -5587,7 +5587,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "anyhow", "perry-hir", @@ -5595,7 +5595,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "async-trait", "clap", @@ -5619,14 +5619,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "anyhow", ] [[package]] name = "perry-db-turnloop" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "perry-ffi", "perry-tls-turnloop", @@ -5634,7 +5634,7 @@ dependencies = [ [[package]] name = "perry-diagnostics" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "serde", "serde_json", @@ -5642,7 +5642,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1640" +version = "0.5.1641" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5653,7 +5653,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "anyhow", "clap", @@ -5668,7 +5668,7 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "block2", "objc2", @@ -5678,7 +5678,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "argon2", "perry-ffi", @@ -5687,7 +5687,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "bcrypt", "perry-ffi", @@ -5695,7 +5695,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "perry-ffi", "rusqlite", @@ -5703,7 +5703,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "perry-ffi", "scraper", @@ -5711,7 +5711,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "perry-ffi", "rust_decimal", @@ -5719,7 +5719,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "perry-ffi", "rand 0.10.2", @@ -5727,7 +5727,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "perry-ffi", "perry-runtime", @@ -5735,7 +5735,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "base64 0.22.1", "bytes", @@ -5767,7 +5767,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "lazy_static", "perry-db-turnloop", @@ -5780,7 +5780,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "bson", "futures-util", @@ -5795,7 +5795,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "bytes", "perry-ffi", @@ -5811,7 +5811,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "perry-ffi", "turnloop-smtp", @@ -5820,7 +5820,7 @@ dependencies = [ [[package]] name = "perry-ext-parcel-watcher" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "notify", "perry-ffi", @@ -5832,7 +5832,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "perry-ffi", "printpdf", @@ -5840,7 +5840,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "fast_image_resize", "image", @@ -5851,7 +5851,7 @@ dependencies = [ [[package]] name = "perry-ext-streams" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "lazy_static", "perry-ffi", @@ -5860,7 +5860,7 @@ dependencies = [ [[package]] name = "perry-ext-typescript" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "anyhow", "perry-ffi", @@ -5880,7 +5880,7 @@ dependencies = [ [[package]] name = "perry-ext-undici" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "perry-ffi", "perry-runtime", @@ -5889,7 +5889,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "lazy_static", "perry-ffi", @@ -5904,7 +5904,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "brotli", "flate2", @@ -5914,7 +5914,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "dashmap 6.2.1", "once_cell", @@ -5924,7 +5924,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "anyhow", "perry-api-manifest", @@ -5944,7 +5944,7 @@ dependencies = [ [[package]] name = "perry-http-client" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "base64 0.22.1", "perry-tls-session", @@ -5957,7 +5957,7 @@ dependencies = [ [[package]] name = "perry-http-server" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "http", "httpdate", @@ -5967,11 +5967,11 @@ dependencies = [ [[package]] name = "perry-native-registration" -version = "0.5.1640" +version = "0.5.1641" [[package]] name = "perry-parser" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "anyhow", "perry-diagnostics", @@ -5984,7 +5984,7 @@ dependencies = [ [[package]] name = "perry-perex" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "perex", "regex", @@ -5992,7 +5992,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "ahash", "base64 0.22.1", @@ -6051,14 +6051,14 @@ dependencies = [ [[package]] name = "perry-runtime-static" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "perry-runtime", ] [[package]] name = "perry-stdlib" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "aes 0.8.4", "aes 0.9.1", @@ -6140,21 +6140,21 @@ dependencies = [ [[package]] name = "perry-stdlib-static" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "perry-stdlib", ] [[package]] name = "perry-tls-session" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "turnloop-tls", ] [[package]] name = "perry-tls-turnloop" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "perry-ffi", "perry-tls-session", @@ -6163,14 +6163,14 @@ dependencies = [ [[package]] name = "perry-transform" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "perry-hir", ] [[package]] name = "perry-ui" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "dirs", "perry-ffi", @@ -6180,7 +6180,7 @@ dependencies = [ [[package]] name = "perry-ui-android" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "base64 0.22.1", "jni", @@ -6195,7 +6195,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "rand 0.10.2", "serde", @@ -6205,7 +6205,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "async-channel", "async-executor", @@ -6230,7 +6230,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "base64 0.22.1", "block2", @@ -6247,7 +6247,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "base64 0.22.1", "block2", @@ -6264,7 +6264,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1640" +version = "0.5.1641" [[package]] name = "perry-ui-test" @@ -6275,11 +6275,11 @@ dependencies = [ [[package]] name = "perry-ui-testkit" -version = "0.5.1640" +version = "0.5.1641" [[package]] name = "perry-ui-tvos" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "base64 0.22.1", "block2", @@ -6296,7 +6296,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "base64 0.22.1", "block2", @@ -6313,7 +6313,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "block2", "libc", @@ -6327,7 +6327,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "base64 0.22.1", "libc", @@ -6346,7 +6346,7 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "base64 0.22.1", "libc", @@ -6359,7 +6359,7 @@ dependencies = [ [[package]] name = "perry-updater" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "anyhow", "base64 0.22.1", @@ -6374,7 +6374,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1640" +version = "0.5.1641" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index f7ed172df0..3a49f64edc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -320,7 +320,7 @@ codegen-units = 1 codegen-units = 1 [workspace.package] -version = "0.5.1640" +version = "0.5.1641" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry" diff --git a/changelog.d/11091-node-http-client-turnloop-lane-1.md b/changelog.d/11091-node-http-client-turnloop-lane-1.md new file mode 100644 index 0000000000..cbc3509f62 --- /dev/null +++ b/changelog.d/11091-node-http-client-turnloop-lane-1.md @@ -0,0 +1,65 @@ +### `node:http` client: lane 1 on turnloop (`perry-ext-http`) + +`http.request()` / `http.get()` no longer reach `reqwest` for the most common +shape. `crates/perry-ext-http/src/client_turnloop.rs` drives +`turnloop_http::client::Http1Connection` over `perry_ffi::turnloop_net`, on the +agent's own loop, and is offered every exchange in `dispatch_request_snapshot` +before the reqwest path. This is the coexistence rule `fetch` already uses +(`perry-stdlib/src/fetch/turnloop_bridge.rs`): accept what is covered, decline +the rest by a named condition, shrink the decline set per lane. + +**Covered by lane 1**: cleartext `http://`, no request body, no explicit +`http.Agent`, no per-request timeout, no proxy, and none of the three headers +that already own a bypass. + +**Declined** — each condition is named in the module header, and each is a +later lane: `https://` (needs the `perry-tls-session` layer, lane 3); a request +body (upload framing, lane 2); an explicit Agent (lane 4); `options.timeout` / +`req.setTimeout` (`Lifecycle` deadlines on `tl::timer_arm`, lane 2); +`NODE_USE_ENV_PROXY=1` (the CONNECT tunnel, lane 5); a URL carrying credentials +and the `CONNECT`/`TRACE`/`TRACK` methods (`client::Request::new` refuses these +— declining keeps today's error text); an explicit `Host` header, because +`client::Request::head` drops a caller's `host` and substitutes the URL +authority (Fetch's rule, not `node:http`'s — reqwest sends what the caller set, +and a unit test pins that the codec really does rewrite it); and `TE: trailers`, +`Connection: Upgrade`, `Expect: 100-continue`, which keep their existing +raw-socket bypasses and are declined using the *same* predicates those modules +trigger on, so the routing cannot disagree with itself. + +Nothing about the JS surface moves. `PendingHttpEvent` was already +transport-agnostic, so the lane emits the same `ResponseHead` / +`ResponseChunk` / `ResponseEnd` / `TransportError` the reqwest task emitted, and +`js_http_process_pending` drains them unchanged — including the agent-admission +release that hangs off those terminal edges. + +Two properties come out for free rather than being ported. Node's client must +**never follow a redirect** (`test_gap_http_client_no_redirect_follow.ts`, the +Next.js `proxyRequest` infinite loop); the reqwest path spells that as +`redirect::Policy::none()`, while driving the codec directly delivers the 3xx +verbatim because nothing in this lane follows anything. And `res.statusMessage` +is unchanged: `http1::Head` carries no reason phrase, so the canonical one +stands in — which is exactly what the reqwest path already did. + +**Not in this lane: keep-alive.** 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 — releasing early hands a socket to the next request mid-message and +misattributes framing. The cost is invisible to JS: `req.reusedSocket` and +`agent.sockets` / `agent.freeSockets` are fed by `agent.rs`'s *facade* pool, +which was already decoupled from the physical connection. + +`scripts/tokio_inventory.json` keeps the `perry-ext-http -> reqwest` edge — +reqwest is still reached by everything in the decline set — but its +`reached_when` no longer says "always", and its `blocker` is corrected: the +older note called `agent.rs` "a second Node-semantics connection pool layered +over reqwest's own", which understates it in one direction and overstates it in +the other. It is a real per-origin admission engine (FIFO waiter queue, +`maxSockets` / `maxTotalSockets` / `maxFreeSockets`, socket facades) sitting +*above* the transport; it survives the migration nearly intact, and its reqwest +coupling is 9 call sites. The same note's "three raw `tokio::net::TcpStream` +bypasses" is two: `agent.createConnection` never used tokio's `TcpStream` — it +runs on perry-ext-net's `raw_net` vtable. + +New subsystem slot: `6`, the one free number below the database band (the +authority for that map is `perry-db-turnloop`'s `subsystem` module header). diff --git a/crates/perry-db-turnloop/src/lib.rs b/crates/perry-db-turnloop/src/lib.rs index 220f03512f..ec44dab419 100644 --- a/crates/perry-db-turnloop/src/lib.rs +++ b/crates/perry-db-turnloop/src/lib.rs @@ -124,9 +124,10 @@ pub mod subsystem { //! The database bindings' completion-sink slots. //! //! These are a contiguous band ABOVE the slots the server and client lanes - //! hardcode in their own crates (0 `perry-ext-net`, 1 `perry-ext-http`, 2 - //! `perry-stdlib`'s turnloop HTTP client, 3 `perry-stdlib`'s SMTP, 4 - //! `perry-ext-fastify`, 5 `perry-stdlib`'s framework server, 7 and 8 + //! hardcode in their own crates (0 `perry-ext-net`, 1 `perry-ext-http`'s + //! SERVER, 2 `perry-stdlib`'s turnloop HTTP client, 3 `perry-stdlib`'s + //! SMTP, 4 `perry-ext-fastify`, 5 `perry-stdlib`'s framework server, 6 + //! `perry-ext-http`'s node:http CLIENT (`client_turnloop`), 7 and 8 //! `perry-ext-ws`). They used to be 2/4/5/6, which collided with three of //! those, because the P7 database lane and the P5 server lane numbered //! from two different ledgers. A collision needs a program that links both diff --git a/crates/perry-ext-http/src/client_turnloop.rs b/crates/perry-ext-http/src/client_turnloop.rs new file mode 100644 index 0000000000..b3eee037ad --- /dev/null +++ b/crates/perry-ext-http/src/client_turnloop.rs @@ -0,0 +1,772 @@ +//! 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/lib.rs b/crates/perry-ext-http/src/lib.rs index 227f19825d..68f7a79026 100644 --- a/crates/perry-ext-http/src/lib.rs +++ b/crates/perry-ext-http/src/lib.rs @@ -74,6 +74,17 @@ mod continue_client; 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. +// +// `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 +// assert the subject was live. No C-ABI symbol is added. +pub mod client_turnloop; + // Client-request event drain helpers (#4905) — extracted from this file // to stay under the 2000-line lint cap. mod client_abort; @@ -303,7 +314,7 @@ fn proxy_enabled_from_env_value(value: Option<&str>) -> bool { /// `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. -fn node_env_proxy_enabled() -> bool { +pub(crate) fn node_env_proxy_enabled() -> bool { proxy_enabled_from_env_value(std::env::var("NODE_USE_ENV_PROXY").ok().as_deref()) } @@ -1598,6 +1609,24 @@ 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, diff --git a/crates/perry-ext-http/tests/turnloop_client_exchange.rs b/crates/perry-ext-http/tests/turnloop_client_exchange.rs new file mode 100644 index 0000000000..72677657f5 --- /dev/null +++ b/crates/perry-ext-http/tests/turnloop_client_exchange.rs @@ -0,0 +1,203 @@ +//! The `node:http` client lane carries a real exchange over turnloop +//! (`client_turnloop`, lane 1). +//! +//! # Why this is an integration binary with exactly ONE `#[test]` +//! +//! An agent's turnloop route is claimed **once per thread, by the first thread +//! to ask**, and every other thread acting for that agent is declined for the +//! rest of its life (`event_pump::agent_loop::claim_route`). A multi-threaded +//! `cargo test` therefore hands the route to whichever test thread asks first, +//! so a *unit* test that asserts `client_turnloop::available()` is a lottery. +//! CI pins `RUST_TEST_THREADS=1` for `perry-runtime` only — this crate's +//! `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. +//! +//! # 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: +//! +//! 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 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. + +use std::collections::HashMap; +use std::io::{Read, Write}; +use std::net::TcpListener; +use std::sync::mpsc; +use std::time::{Duration, Instant}; + +use perry_ext_http::client_turnloop; + +/// The `perry_ffi_*` async-bridge externs this crate references are normally +/// provided by the host stdlib archive, which a test binary does not link. The +/// lib test binary solves that with `#[cfg(test)] mod test_async_shims`; an +/// integration binary is a separate crate, so it includes the same file rather +/// than carrying a second copy that could drift from it. +#[path = "../src/test_async_shims.rs"] +mod test_async_shims; + +/// The Bun-server bridge lives in `perry-stdlib`, which a `perry-ext-http` +/// test binary deliberately does not link (that would be a dependency edge the +/// crate does not have). `server/bun_server.rs` stubs these under +/// `#[cfg(test)]` for the lib test binary; an integration binary links the +/// non-test lib, so it supplies the same stubs here. Nothing in this test +/// reaches the Bun server. +#[no_mangle] +unsafe extern "C" fn js_bun_http_request_from_json( + _snapshot: *const perry_ffi::StringHeader, +) -> f64 { + // TAG_UNDEFINED; see the NaN-boxing table in CLAUDE.md. + f64::from_bits(0x7FFC_0000_0000_0001) +} + +#[no_mangle] +unsafe extern "C" fn js_bun_http_response_snapshot_json( + _response_handle: f64, +) -> *mut perry_ffi::StringHeader { + 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; + +#[test] +fn a_cleartext_get_is_carried_end_to_end_and_a_307_is_not_followed() { + // ── A server that knows nothing about turnloop ─────────────────────── + 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. + 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 _ = stream.write_all( + b"HTTP/1.1 307 Temporary Redirect\r\n\ + location: /target\r\n\ + content-length: 8\r\n\ + connection: keep-alive\r\n\ + \r\n\ + 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 _ = 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" + ); + 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 + ); + } + + 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 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); + } + + 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")), + "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"), + "Node's default agent advertises keep-alive, got:\n{head}" + ); +} diff --git a/scripts/tokio_inventory.json b/scripts/tokio_inventory.json index 524bcd4b0c..4ce1ebf202 100644 --- a/scripts/tokio_inventory.json +++ b/scripts/tokio_inventory.json @@ -80,8 +80,8 @@ "optional": false, "target": null, "surface": "`http.request()` / `https.get()` / `https.request()` \u2014 the node:http CLIENT half", - "reached_when": "always: P5 migrated this crate's SERVER, the client was never migrated", - "blocker": "`agent.rs` alone is ~1,950 lines, a second Node-semantics connection pool layered over reqwest's own. Plus three raw `tokio::net::TcpStream` bypasses (`TE: trailers`, `Expect: 100-continue`, an `agent.createConnection` override) that are not reqwest at all.", + "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" },