Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions changelog.d/10668-http-client-response-surface.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
Fixed three `node:http`/`node:https` client-side defects. The client `IncomingMessage` now exposes
`rawHeaders`/`httpVersion`/`httpVersionMajor`/`httpVersionMinor`/`complete` (previously `undefined` on both the
typed and dynamically-dispatched surface); `httpVersion*`/`complete` fall back to the server-side accessor when
the handle is a server `IncomingMessage`, since the codegen native table shares one `class_filter` namespace
across client and server (#10467 — `rawHeaders` header-name casing on the pooled reqwest transport is a known
remaining gap, documented in the PR). `http.request`'s client now fires `req.on('upgrade', (res, socket, head) =>
...)` on a `101 Switching Protocols` response instead of delivering it as an ordinary `'response'`: an upgrade
request speaks HTTP/1.1 over a raw socket (mirroring the existing trailer-aware bypass), and on `101` adopts the
stream as a `net.Socket` via `perry_ext_net::adopt_upgraded_tcp_stream` — write, inbound data delivery, and the
`head` Buffer (always a Buffer, never `undefined`, even zero-length) all match Node (#10468). The request option
`options.createConnection` (distinct from `agent.createConnection`) is now honored when the request has no
explicit Agent, taking the same raw-socket path the Agent-level override already used (#10469).

Follow-up (unrooted-local-shape ratchet, caught before merge): `build_raw_headers_array`
(`res.rawHeaders`, added for #10467 above) held its result array's raw pointer in a plain local across
`alloc_string`/`js_array_push` calls that can allocate and therefore collect — a stale-pointer-after-collection
shape (`scripts/unrooted_local_shape.py`), not merely a scanner nit. Rooted it through
`perry_ffi::TransientRootScope::root_nanbox` and re-derive the pointer via `.get()` after each allocating call
instead of reusing the pre-call copy, matching the pattern already used throughout this crate (e.g.
`agent.rs`, `client_events.rs`). Found and fixed the same pre-existing shape in the neighboring
`set-cookie` array builder in `build_response_headers_object` (unrelated to this PR's diff, same file); extracted
it into its own top-level `build_set_cookie_array` so the rooting lines aren't deep enough for `rustfmt` to wrap a
`let` binding across lines, which had been hiding the second half of the binding from the ratchet's
line-oriented scanner. `scripts/unrooted_local_shape.py --check` now reports 558 (down from the pre-PR baseline
of 561; response_headers.rs's own ceiling drops from 1 to 0).
68 changes: 62 additions & 6 deletions crates/perry-codegen/src/lower_call/native_table/http_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -691,7 +691,12 @@ pub(super) const HTTP_SERVER_ROWS: &[NativeModSig] = &[
has_receiver: true,
method: "httpVersion",
class_filter: Some("IncomingMessage"),
runtime: "js_node_http_im_http_version",
// #10467 — route through the client accessor (falls back to the
// server one internally, `server_incoming_property`) so a
// client-side `res.httpVersion` resolves instead of reading the
// server-only registry and returning the "1.1" default for every
// client response.
runtime: "js_http_response_http_version",
args: &[],
ret: NR_STR,
},
Expand Down Expand Up @@ -732,12 +737,14 @@ pub(super) const HTTP_SERVER_ROWS: &[NativeModSig] = &[
args: &[],
ret: NR_STR,
},
// #10467 — same client-accessor-with-server-fallback shape as the
// bare `httpVersion` entry above.
NativeModSig {
module: "http",
has_receiver: true,
method: "__get_httpVersion",
class_filter: Some("IncomingMessage"),
runtime: "js_node_http_im_http_version",
runtime: "js_http_response_http_version",
args: &[],
ret: NR_STR,
},
Expand All @@ -746,7 +753,16 @@ pub(super) const HTTP_SERVER_ROWS: &[NativeModSig] = &[
has_receiver: true,
method: "__get_httpVersionMajor",
class_filter: Some("IncomingMessage"),
runtime: "js_node_http_im_http_version_major",
runtime: "js_http_response_http_version_major",
args: &[],
ret: NR_F64,
},
NativeModSig {
module: "http",
has_receiver: true,
method: "httpVersionMajor",
class_filter: Some("IncomingMessage"),
runtime: "js_http_response_http_version_major",
args: &[],
ret: NR_F64,
},
Expand All @@ -755,18 +771,58 @@ pub(super) const HTTP_SERVER_ROWS: &[NativeModSig] = &[
has_receiver: true,
method: "__get_httpVersionMinor",
class_filter: Some("IncomingMessage"),
runtime: "js_node_http_im_http_version_minor",
runtime: "js_http_response_http_version_minor",
args: &[],
ret: NR_F64,
},
NativeModSig {
module: "http",
has_receiver: true,
method: "httpVersionMinor",
class_filter: Some("IncomingMessage"),
runtime: "js_http_response_http_version_minor",
args: &[],
ret: NR_F64,
},
// `js_http_response_complete` already returns a boxed JS boolean (f64
// bit pattern), not a raw C `i32` like the server-only accessor this
// replaced — hence `NR_F64`, matching `headers`/`trailers`/`socket`
// below (also client accessors returning pre-boxed values).
NativeModSig {
module: "http",
has_receiver: true,
method: "__get_complete",
class_filter: Some("IncomingMessage"),
runtime: "js_node_http_im_complete",
runtime: "js_http_response_complete",
args: &[],
ret: NR_I32,
ret: NR_F64,
},
NativeModSig {
module: "http",
has_receiver: true,
method: "complete",
class_filter: Some("IncomingMessage"),
runtime: "js_http_response_complete",
args: &[],
ret: NR_F64,
},
NativeModSig {
module: "http",
has_receiver: true,
method: "__get_rawHeaders",
class_filter: Some("IncomingMessage"),
runtime: "js_http_response_raw_headers",
args: &[],
ret: NR_F64,
},
NativeModSig {
module: "http",
has_receiver: true,
method: "rawHeaders",
class_filter: Some("IncomingMessage"),
runtime: "js_http_response_raw_headers",
args: &[],
ret: NR_F64,
},
NativeModSig {
module: "http",
Expand Down
6 changes: 6 additions & 0 deletions crates/perry-codegen/src/runtime_decls/stdlib_ffi/net_http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,12 @@ pub(crate) fn declare_net_http(module: &mut LlModule) {
);
module.declare_function("js_http_response_headers", DOUBLE, &[I64]);
module.declare_function("js_http_response_trailers", DOUBLE, &[I64]);
// #10467 — client rawHeaders / httpVersion* / complete accessors.
module.declare_function("js_http_response_raw_headers", DOUBLE, &[I64]);
module.declare_function("js_http_response_http_version", I64, &[I64]);
module.declare_function("js_http_response_http_version_major", DOUBLE, &[I64]);
module.declare_function("js_http_response_http_version_minor", DOUBLE, &[I64]);
module.declare_function("js_http_response_complete", DOUBLE, &[I64]);
module.declare_function("js_http_incoming_message_socket", DOUBLE, &[I64]);
module.declare_function("js_http_incoming_message_req", DOUBLE, &[I64]);
module.declare_function("js_http_incoming_message_set_encoding", I64, &[I64, I64]);
Expand Down
58 changes: 52 additions & 6 deletions crates/perry-ext-http/src/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -478,6 +478,15 @@ unsafe fn read_closure_field(obj_f64: f64, field: &str) -> i64 {
}
}

/// Extract `options.createConnection` (#10469) — the request-level socket
/// override Node honors when the caller does not pass an explicit `agent`.
/// Like `options.agent`, a closure doesn't survive the `options` JSON
/// round-trip (`parse_options_object`), so this reads the NaN-boxed field
/// straight off the original object instead.
pub(crate) unsafe fn request_create_connection_from_options(options_f64: f64) -> i64 {
read_closure_field(options_f64, "createConnection")
}

/// Extract an `options.agent` handle from `options_f64`. Returns `None`
/// when the field is missing, not a pointer, or doesn't resolve to an
/// AgentHandle.
Expand Down Expand Up @@ -1675,9 +1684,43 @@ pub(crate) unsafe fn try_create_connection_socket(
if cc == 0 {
return None;
}
invoke_create_connection_closure(cc, Some(handle), host, port, path)
}

/// #10469 — invoke the request option's own `createConnection` override
/// (no explicit Agent involved, so there's no `AgentHandle` to pull
/// `keepAlive` defaults from — Node's own default Agent has `keepAlive:
/// false`, matched by `build_connect_options(None, ...)`).
pub(crate) unsafe fn try_request_create_connection_socket(
closure_ptr: i64,
host: &str,
port: u16,
path: &str,
) -> Option<i64> {
if closure_ptr == 0 {
return None;
}
invoke_create_connection_closure(closure_ptr, None, host, port, path)
}

/// Shared tail of both `createConnection` invocation paths: root
/// `closure_ptr` *before* calling `build_connect_options` (it allocates —
/// without rooting first, a GC during that allocation could move the
/// closure out from under the raw `i64` copy, matching the ordering the
/// original #2154 code used), call it with `{ host, port, path, keepAlive,
/// keepAliveInitialDelay }`, and extract the `net.Socket` handle id it
/// returns. Main thread only — JS closure calls must not run on a tokio
/// worker.
unsafe fn invoke_create_connection_closure(
closure_ptr: i64,
agent_handle: Option<Handle>,
host: &str,
port: u16,
path: &str,
) -> Option<i64> {
let scope = perry_ffi::TransientRootScope::enter();
let cc = scope.root_addr(cc);
let options = scope.root_nanbox(build_connect_options(handle, host, port, path));
let cc = scope.root_addr(closure_ptr);
let options = scope.root_nanbox(build_connect_options(agent_handle, host, port, path));
let closure = JsClosure::from_raw(cc.get() as *const RawClosureHeader);
let ret = closure.call1(options.get());

Expand Down Expand Up @@ -1711,7 +1754,7 @@ pub(crate) fn create_socket_override(handle: Handle) -> i64 {
/// Returns a NaN-boxed object pointer as `f64`, or NaN-boxed `undefined` on
/// allocation failure.
pub(crate) unsafe fn build_connect_options(
handle: Handle,
handle: Option<Handle>,
host: &str,
port: u16,
path: &str,
Expand Down Expand Up @@ -1750,9 +1793,12 @@ pub(crate) unsafe fn build_connect_options(
2,
JsValue::from_string_ptr(path_s.as_raw()),
);
let (keep_alive, keep_alive_msecs) = agent_field(handle, (false, 1000.0), |agent| {
(agent.keep_alive, agent.keep_alive_msecs)
});
let (keep_alive, keep_alive_msecs) = match handle {
Some(h) => agent_field(h, (false, 1000.0), |agent| {
(agent.keep_alive, agent.keep_alive_msecs)
}),
None => (false, 1000.0),
};
perry_ffi::js_object_set_field(
JsValue::from_bits(obj.get().to_bits()).as_pointer(),
3,
Expand Down
Loading
Loading