diff --git a/crates/perry-ext-http-server/src/response.rs b/crates/perry-ext-http-server/src/response.rs index 7c58314cc5..197c50d534 100644 --- a/crates/perry-ext-http-server/src/response.rs +++ b/crates/perry-ext-http-server/src/response.rs @@ -21,8 +21,9 @@ use tokio::sync::oneshot; use crate::request::{emit_no_arg_to_listeners, handle_to_pointer_f64}; use crate::types::{ - js_json_stringify, js_value_is_closure, jsvalue_to_body_bytes, jsvalue_to_owned_string, - read_string_header, PTR_MASK, STRING_TAG, TAG_FALSE, TAG_NULL, TAG_TRUE, TAG_UNDEFINED, + js_json_stringify, js_node_setheaders_entries_json, js_value_is_closure, jsvalue_to_body_bytes, + jsvalue_to_owned_string, read_string_header, PTR_MASK, STRING_TAG, TAG_FALSE, TAG_NULL, + TAG_TRUE, TAG_UNDEFINED, }; /// Node's default `highWaterMark` for an HTTP `OutgoingMessage` (16 KiB). @@ -198,6 +199,12 @@ pub struct ServerResponse { pub raw_header_names: HashMap, pub raw_trailer_names: HashMap, pub headers_sent: bool, + /// True once `writeHead()` has committed the status line + headers (Node's + /// `_header`). Distinct from `headers_sent` (the wire flush, set at + /// `write`/`end`) so the normal deferred-send path is unaffected, but a + /// post-`writeHead` `setHeaders()` still throws `ERR_HTTP_HEADERS_SENT` + /// like Node (#4965). + pub header_committed: bool, pub writable_ended: bool, pub writable_finished: bool, pub send_date: bool, @@ -365,6 +372,7 @@ impl ServerResponse { header_value_lists: HashMap::new(), trailers: HashMap::new(), raw_header_names: HashMap::new(), + header_committed: false, raw_trailer_names: HashMap::new(), headers_sent: false, writable_ended: false, @@ -724,27 +732,94 @@ pub extern "C" fn js_node_http_res_get_header_names_json(handle: i64) -> *mut St alloc_string(&s).as_raw() } -/// `res.setHeaders(headers)` — accepts any JSON-stringifiable object shape -/// Perry can inspect and returns the receiver. Native Node also accepts Map -/// and Headers; those stringify to `{}` in the current runtime, so this remains -/// a deterministic no-op for those inputs until iterable extraction lands. +/// `res.setHeaders(headers)` — Node accepts only a `Headers` or a `Map` +/// (anything else is `ERR_INVALID_ARG_TYPE`), and throws +/// `ERR_HTTP_HEADERS_SENT` if the head was already committed. The runtime's +/// `js_node_setheaders_entries_json` normalizes the argument into a JSON +/// `[name, value]` entries array (or null for an invalid type) WITHOUT ever +/// dereferencing a registry handle — the old path JSON-stringified the +/// `Headers` handle directly, walking its fetch-band id (`0x40000`+) as a heap +/// `GcHeader` and segfaulting nondeterministically (#4965). #[no_mangle] pub extern "C" fn js_node_http_res_set_headers(handle: i64, headers_value: f64) -> i64 { - let v = JsValue::from_bits(headers_value.to_bits()); - if v.is_undefined() || v.is_null() { - return handle; + // Node order: the headers-sent check fires before the argument is + // validated. `header_committed` covers a prior `writeHead`; `headers_sent` + // covers an already-flushed body. + let committed = get_handle::(handle) + .map(|sr| sr.headers_sent || sr.header_committed) + .unwrap_or(false); + if committed { + perry_ffi::throw_with_code( + "Cannot set headers after they are sent to the client", + "ERR_HTTP_HEADERS_SENT", + perry_ffi::ErrorKind::Error, + ); } - let Some(json) = perry_ffi::json_stringify(v) else { - return handle; - }; - if let Some(sr) = get_handle_mut::(handle) { - if !sr.headers_sent { - apply_headers_json(sr, &json); + let entries_ptr = unsafe { js_node_setheaders_entries_json(headers_value) }; + if entries_ptr.is_null() { + perry_ffi::throw_with_code( + "The \"headers\" argument must be an instance of Headers or Map.", + "ERR_INVALID_ARG_TYPE", + perry_ffi::ErrorKind::TypeError, + ); + } + if let Some(json) = read_string_header(entries_ptr) { + if let Some(sr) = get_handle_mut::(handle) { + if !sr.headers_sent { + apply_headers_entries(sr, &json); + } } } handle } +/// Apply a normalized `setHeaders` entries array: `[[name, value], …]` where +/// `value` is a string or (for `Set-Cookie`/multi-valued headers) an array of +/// strings. The pairwise (vs object) shape preserves a `Set-Cookie` array as a +/// per-element list so the wire layer emits one line each (#4826/#4965). +fn apply_headers_entries(sr: &mut ServerResponse, json: &str) { + let Ok(serde_json::Value::Array(items)) = serde_json::from_str::(json) + else { + return; + }; + for item in items { + let serde_json::Value::Array(pair) = item else { + continue; + }; + let mut pair = pair.into_iter(); + let (Some(name_v), Some(value_v)) = (pair.next(), pair.next()) else { + continue; + }; + let name = match name_v { + serde_json::Value::String(s) => s, + other => other.to_string(), + }; + if name.is_empty() { + continue; + } + let lower = name.to_lowercase(); + if let serde_json::Value::Array(elems) = value_v { + let elems: Vec = elems + .into_iter() + .map(|item| match item { + serde_json::Value::String(s) => s, + other => other.to_string(), + }) + .collect(); + sr.headers.insert(lower.clone(), elems.join(", ")); + sr.header_value_lists.insert(lower.clone(), elems); + } else { + let value = match value_v { + serde_json::Value::String(s) => s, + other => other.to_string(), + }; + sr.headers.insert(lower.clone(), value); + sr.header_value_lists.remove(&lower); + } + sr.raw_header_names.insert(lower, name); + } +} + /// `res.statusMessage` getter. #[no_mangle] pub extern "C" fn js_node_http_res_get_status_message(handle: i64) -> f64 { @@ -926,8 +1001,62 @@ pub unsafe extern "C" fn js_node_http_res_write_head( } } if let Some(json) = headers_json { - apply_headers_json(sr, &json); + // Node's `writeHead` accepts the headers as an object OR as a flat + // array `[name, value, name, value, …]` (even offsets are names, + // odd are values — NOT a list of tuples). Route the array form to + // the pairwise applier; objects keep the original path (#4965). + if json.trim_start().starts_with('[') { + apply_headers_flat_array(sr, &json); + } else { + apply_headers_json(sr, &json); + } } + // Mark the head committed (Node's `_header`) so a later + // `res.setHeaders(...)` throws `ERR_HTTP_HEADERS_SENT`. The actual wire + // flush still happens lazily at `write`/`end` (`headers_sent`), so the + // deferred-send path is unchanged (#4965). + sr.header_committed = true; + } +} + +/// Apply a Node `writeHead` flat-array headers value (`[name, value, …]`). +/// Even offsets are header names, odd offsets the associated values; an array +/// element may itself be an array (multi-valued header). Mirrors +/// `apply_headers_json`'s lowercase-key / original-case / array-list handling. +fn apply_headers_flat_array(sr: &mut ServerResponse, json: &str) { + let Ok(serde_json::Value::Array(items)) = serde_json::from_str::(json) + else { + return; + }; + let mut it = items.into_iter(); + while let (Some(name_v), Some(value_v)) = (it.next(), it.next()) { + let name = match name_v { + serde_json::Value::String(s) => s, + other => other.to_string(), + }; + if name.is_empty() { + continue; + } + let lower = name.to_lowercase(); + if let serde_json::Value::Array(elems) = value_v { + let elems: Vec = elems + .into_iter() + .map(|item| match item { + serde_json::Value::String(s) => s, + other => other.to_string(), + }) + .collect(); + sr.headers.insert(lower.clone(), elems.join(", ")); + sr.header_value_lists.insert(lower.clone(), elems); + } else { + let value = match value_v { + serde_json::Value::String(s) => s, + other => other.to_string(), + }; + sr.headers.insert(lower.clone(), value); + sr.header_value_lists.remove(&lower); + } + sr.raw_header_names.insert(lower, name); } } @@ -1755,4 +1884,50 @@ mod tests { apply_headers_json(&mut sr, "undefined"); assert!(sr.headers.is_empty()); } + + // #4965 — `setHeaders` entries normalizer output. + + #[test] + fn apply_headers_entries_lowercases_and_preserves_case() { + let mut sr = empty_response(); + apply_headers_entries(&mut sr, r#"[["Foo","1"],["Bar","2"]]"#); + assert_eq!(sr.headers.get("foo").map(String::as_str), Some("1")); + assert_eq!(sr.headers.get("bar").map(String::as_str), Some("2")); + assert_eq!( + sr.raw_header_names.get("foo").map(String::as_str), + Some("Foo") + ); + } + + #[test] + fn apply_headers_entries_set_cookie_array_keeps_per_element_list() { + let mut sr = empty_response(); + apply_headers_entries(&mut sr, r#"[["set-cookie",["a=b","c=d"]]]"#); + assert_eq!( + sr.header_value_lists.get("set-cookie").map(Vec::as_slice), + Some(["a=b".to_string(), "c=d".to_string()].as_slice()) + ); + assert_eq!( + sr.headers.get("set-cookie").map(String::as_str), + Some("a=b, c=d") + ); + } + + #[test] + fn apply_headers_entries_ignores_non_array_and_short_pairs() { + let mut sr = empty_response(); + apply_headers_entries(&mut sr, r#"[{"foo":"1"},["only-name"],["k","v"]]"#); + assert_eq!(sr.headers.get("k").map(String::as_str), Some("v")); + assert_eq!(sr.headers.len(), 1); + } + + #[test] + fn write_head_flat_array_applies_pairs_and_overrides() { + let mut sr = empty_response(); + sr.headers.insert("foo".into(), "1".into()); + apply_headers_flat_array(&mut sr, r#"["foo","3","X-New","z"]"#); + // even/odd offsets are name/value; `foo` overrides the prior value. + assert_eq!(sr.headers.get("foo").map(String::as_str), Some("3")); + assert_eq!(sr.headers.get("x-new").map(String::as_str), Some("z")); + } } diff --git a/crates/perry-ext-http-server/src/types.rs b/crates/perry-ext-http-server/src/types.rs index 8e964792ec..d8413e666b 100644 --- a/crates/perry-ext-http-server/src/types.rs +++ b/crates/perry-ext-http-server/src/types.rs @@ -30,6 +30,14 @@ extern "C" { /// from an options-object argument. Defined in /// `crates/perry-runtime/src/closure/dynamic_props.rs::js_value_is_closure`. pub fn js_value_is_closure(value_bits: i64) -> i32; + /// #4965 — normalize a `res.setHeaders(x)` argument into a JSON + /// `[name, value]` entries array (value is a string, or an array of + /// strings for multi-valued headers like `Set-Cookie`). Returns null when + /// `x` is neither a `Headers` nor a `Map` (→ `ERR_INVALID_ARG_TYPE`). + /// Classifies by address band so a `Headers` registry *handle* is never + /// dereferenced as a heap object. Defined in + /// `crates/perry-runtime/src/object/global_fetch.rs`. + pub fn js_node_setheaders_entries_json(value: f64) -> *mut StringHeader; } /// Opaque marker for the runtime's Promise struct — pass pointers diff --git a/crates/perry-runtime/src/object/global_fetch.rs b/crates/perry-runtime/src/object/global_fetch.rs index 9e956462d1..21fd079040 100644 --- a/crates/perry-runtime/src/object/global_fetch.rs +++ b/crates/perry-runtime/src/object/global_fetch.rs @@ -55,6 +55,15 @@ static GLOBAL_FETCH_RESPONSE_STATIC_JSON: AtomicPtr<()> = AtomicPtr::new(null_mu static GLOBAL_FETCH_RESPONSE_STATIC_REDIRECT: AtomicPtr<()> = AtomicPtr::new(null_mut()); static GLOBAL_FETCH_RESPONSE_STATIC_ERROR: AtomicPtr<()> = AtomicPtr::new(null_mut()); static GLOBAL_FETCH_BODY_INIT_PTR: AtomicPtr<()> = AtomicPtr::new(null_mut()); +/// #4965: perry-stdlib's `Headers` → `[name, value]` entries-JSON producer, +/// used by `res.setHeaders(headers)`. Registered separately from the fetch +/// constructors because the http-server crate (not the fetch crate) is the +/// consumer; routing through the always-linked runtime keeps http-server free +/// of a direct perry-stdlib symbol dependency (which would link-break a +/// stdlib-less build — the #5112 regression class). +static GLOBAL_HEADERS_ENTRIES_JSON: AtomicPtr<()> = AtomicPtr::new(null_mut()); + +type HeadersEntriesJsonFn = extern "C" fn(f64) -> *mut crate::StringHeader; /// Register the stdlib body-init coercion (`js_response_body_init_ptr`), which /// drains a `ReadableStream` body to a `*const StringHeader` (and falls back to @@ -227,6 +236,57 @@ pub(super) fn call_global_headers_init_from_value(handle: f64, init: f64) -> f64 warn_unregistered_fetch_symbol("js_headers_init_from_value") } +/// Register perry-stdlib's `Headers` → entries-JSON producer (#4965). The +/// producer takes a NaN-boxed `Headers` handle and returns a fresh +/// `StringHeader` holding a JSON array of `[name, value]` pairs (value is a +/// string, or an array of strings for multi-valued headers like `Set-Cookie`), +/// or null for an unknown handle. +#[no_mangle] +pub extern "C" fn js_register_global_headers_entries_json(f: HeadersEntriesJsonFn) { + GLOBAL_HEADERS_ENTRIES_JSON.store(f as *mut (), Ordering::Release); +} + +fn call_global_headers_entries_json(value: f64) -> *mut crate::StringHeader { + let f = GLOBAL_HEADERS_ENTRIES_JSON.load(Ordering::Acquire); + if f.is_null() { + return null_mut(); + } + let func: HeadersEntriesJsonFn = unsafe { std::mem::transmute(f) }; + func(value) +} + +/// Normalize a `res.setHeaders(x)` argument into a JSON array of +/// `[name, value]` entries. Node accepts only `Headers` and `Map`; this +/// returns null for anything else so the http layer can raise +/// `ERR_INVALID_ARG_TYPE`. +/// +/// #4965: the previous http-server path JSON-stringified `x` directly. A +/// `Headers` value is a fetch-band registry *handle* (its first id is +/// `0x40000`), not a heap pointer, so the generic stringify walker +/// dereferenced `id - 8` as a `GcHeader` and segfaulted nondeterministically. +/// Classify by address band BEFORE any dereference: a `Map` is a real heap +/// `MapHeader` (its entries are pair-arrays of real heap values — safe to +/// stringify), and a `Headers` handle is delegated to the registered +/// perry-stdlib producer which reads its own registry. No path ever +/// dereferences a handle id. +#[no_mangle] +pub extern "C" fn js_node_setheaders_entries_json(value: f64) -> *mut crate::StringHeader { + let bits = value.to_bits(); + if let Some(map) = crate::map::map_ptr_from_receiver_bits(bits) { + let entries = crate::map::js_map_entries(map); + let boxed = crate::value::js_nanbox_pointer(entries as i64); + return unsafe { crate::json::js_json_stringify(f64::from_bits(boxed.to_bits()), 0) }; + } + let jsv = crate::value::JSValue::from_bits(bits); + if jsv.is_pointer() { + let addr = (bits & 0x0000_FFFF_FFFF_FFFF) as usize; + if crate::value::addr_class::is_handle_band(addr) { + return call_global_headers_entries_json(value); + } + } + null_mut() +} + pub(super) fn call_global_request_new( url_ptr: *const crate::StringHeader, method_ptr: *const crate::StringHeader, diff --git a/crates/perry-stdlib/src/common/dispatch.rs b/crates/perry-stdlib/src/common/dispatch.rs index aa44766330..76b5d923ff 100644 --- a/crates/perry-stdlib/src/common/dispatch.rs +++ b/crates/perry-stdlib/src/common/dispatch.rs @@ -3095,6 +3095,11 @@ pub unsafe extern "C" fn js_stdlib_init_dispatch() { ); #[cfg(feature = "http-client")] fn js_register_global_fetch_body_init_ptr(f: extern "C" fn(f64) -> i64); + // #4965: Headers → `res.setHeaders` entries-JSON producer. + #[cfg(feature = "http-client")] + fn js_register_global_headers_entries_json( + f: extern "C" fn(f64) -> *mut perry_runtime::StringHeader, + ); fn js_register_worker_threads_namespace_getters( worker_data: extern "C" fn() -> f64, is_main_thread: extern "C" fn() -> f64, @@ -3129,6 +3134,8 @@ pub unsafe extern "C" fn js_stdlib_init_dispatch() { ); #[cfg(feature = "http-client")] js_register_global_fetch_body_init_ptr(crate::fetch::js_response_body_init_ptr); + #[cfg(feature = "http-client")] + js_register_global_headers_entries_json(crate::fetch::js_headers_setheaders_entries_json); // Probe / `on` hook / constructor all route through the shared // `extern "C"` events surface declared above dispatch_event_emitter_method // (#4995): the linker resolves them to whichever EventEmitter impl is in diff --git a/crates/perry-stdlib/src/fetch/headers.rs b/crates/perry-stdlib/src/fetch/headers.rs index eb37427e81..05bf01eab2 100644 --- a/crates/perry-stdlib/src/fetch/headers.rs +++ b/crates/perry-stdlib/src/fetch/headers.rs @@ -325,6 +325,47 @@ pub extern "C" fn js_headers_get_set_cookie(handle: f64) -> f64 { nanbox_array_pointer(arr) } +/// #4965: produce the `[name, value]` entries JSON that the http-server +/// `res.setHeaders(Headers)` path applies (routed through the runtime's +/// `js_node_setheaders_entries_json` registration). Mirrors Node's +/// `OutgoingMessage.setHeaders`: it walks the WHATWG sorted-by-name entries, +/// collapsing `Set-Cookie` into a single `["set-cookie", [c1, c2, …]]` entry +/// via `getSetCookie()` (the wire layer then emits one line per element). +/// Returns null for an unknown handle so the caller raises +/// `ERR_INVALID_ARG_TYPE`. +#[no_mangle] +pub extern "C" fn js_headers_setheaders_entries_json(handle: f64) -> *mut StringHeader { + let id = handle_id(handle); + let guard = HEADERS_REGISTRY.lock().unwrap(); + let Some(store) = guard.get(&id) else { + return std::ptr::null_mut(); + }; + let mut names: Vec = store.entries.iter().map(|(k, _)| k.clone()).collect(); + names.sort(); + names.dedup(); + let mut out: Vec = Vec::with_capacity(names.len()); + for name in names { + if name == "set-cookie" { + let cookies: Vec = store + .set_cookie_values() + .into_iter() + .map(serde_json::Value::String) + .collect(); + out.push(serde_json::Value::Array(vec![ + serde_json::Value::String(name), + serde_json::Value::Array(cookies), + ])); + } else if let Some(v) = store.get(&name) { + out.push(serde_json::Value::Array(vec![ + serde_json::Value::String(name), + serde_json::Value::String(v), + ])); + } + } + let s = serde_json::to_string(&out).unwrap_or_else(|_| "[]".to_string()); + js_string_from_bytes(s.as_ptr(), s.len() as u32) +} + #[no_mangle] pub unsafe extern "C" fn js_headers_has(handle: f64, key_ptr: *const StringHeader) -> f64 { let id = handle_id(handle);