diff --git a/changelog.d/headers-proxy-record-init.md b/changelog.d/headers-proxy-record-init.md new file mode 100644 index 0000000000..5922660182 --- /dev/null +++ b/changelog.d/headers-proxy-record-init.md @@ -0,0 +1 @@ +Accept a Proxy as a `Headers` record init. `new Headers(new Proxy({ "x-a": "1" }, {}))` raised "Headers constructor: init is not iterable": the record path required a plain heap object, and a proxy value is a proxy id rather than one, so neither the iterable nor the record branch applied. The constructor now reads a proxied init's own string keys and their values through the proxy's `ownKeys` and `get` traps, matching how the spec reads a record init through the object's internal methods. Plain objects, arrays, maps, sets and string inits are unchanged. This unblocks OpenCode's request path, where the AI SDK hands the fetch layer a proxied header record (#10107). diff --git a/crates/perry-stdlib/src/fetch/headers.rs b/crates/perry-stdlib/src/fetch/headers.rs index 792ed67c43..a9b6fad78d 100644 --- a/crates/perry-stdlib/src/fetch/headers.rs +++ b/crates/perry-stdlib/src/fetch/headers.rs @@ -82,6 +82,38 @@ fn has_sync_iterator(value: f64) -> bool { raw != 0 && perry_runtime::closure::is_closure_ptr(raw as usize) } +/// A short description of a rejected `Headers` init, so the thrown message +/// names what was passed instead of only saying it is not iterable. Kept cheap: +/// it runs only on the error path. +fn describe_headers_init(value: f64) -> String { + let jsval = JSValue::from_bits(value.to_bits()); + if jsval.is_any_string() { + return "string".to_string(); + } + if perry_runtime::proxy::js_proxy_is_proxy(value) != 0 { + return "Proxy".to_string(); + } + if perry_runtime::js_array_is_array(value).to_bits() == TAG_TRUE { + return "array".to_string(); + } + let raw = perry_runtime::js_nanbox_get_pointer(value); + if raw == 0 { + return format!("{:#018x}", value.to_bits()); + } + let addr = raw as usize; + if perry_runtime::map::is_registered_map(addr) { + return "Map".to_string(); + } + if perry_runtime::set::is_registered_set(addr) { + return "Set".to_string(); + } + match gc_type_for_raw_ptr(raw) { + Some(t) if t == perry_runtime::gc::GC_TYPE_OBJECT => "object".to_string(), + Some(t) => format!("gc type {t}"), + None => format!("non-heap {:#018x}", value.to_bits()), + } +} + fn is_headers_init_iterable(value: f64) -> bool { let jsval = JSValue::from_bits(value.to_bits()); if jsval.is_any_string() { @@ -121,6 +153,16 @@ fn read_headers_record_entries( if has_sync_iterator(value) { return None; } + // A Proxy wrapping a record (`new Headers(new Proxy(headers, {}))`) is a + // valid record init: the spec reads the init's own keys and values through + // the object's internal methods, which for a Proxy means its `ownKeys` and + // `get` traps. The pointer below is a proxy id, not a `GC_TYPE_OBJECT` + // heap object, so without this branch the record path bailed and the + // constructor reported "init is not iterable" for an ordinary header + // object (OpenCode's request path, tracker #10107). + if perry_runtime::proxy::js_proxy_is_proxy(value) != 0 { + return unsafe { read_proxy_record_entries(value, scope) }; + } let raw = perry_runtime::js_nanbox_get_pointer(value); if gc_type_for_raw_ptr(raw) != Some(perry_runtime::gc::GC_TYPE_OBJECT) { return None; @@ -155,18 +197,62 @@ fn read_headers_record_entries( } } +/// Own string-keyed properties of a Proxy init, read through its traps. +/// Symbol keys are skipped (a header name is always a string). Enumerability +/// is not re-queried per key: `ownKeys` on a plain wrapping Proxy already +/// reports the target's own keys, and a trap that hides a key omits it there. +unsafe fn read_proxy_record_entries( + value: f64, + scope: &perry_runtime::gc::RuntimeHandleScope, +) -> Option> { + let keys_value = perry_runtime::proxy::js_proxy_own_keys(value); + let keys_handle = scope.root_nanbox_f64(keys_value); + let proxy_handle = scope.root_nanbox_f64(value); + let keys_raw = perry_runtime::js_nanbox_get_pointer(keys_handle.get_nanbox_f64()); + if keys_raw == 0 { + return Some(Vec::new()); + } + let len = perry_runtime::js_array_length(keys_raw as *const perry_runtime::ArrayHeader); + let mut entries = Vec::with_capacity(len as usize); + for i in 0..len { + let keys_now = perry_runtime::js_nanbox_get_pointer(keys_handle.get_nanbox_f64()); + let key_value = perry_runtime::array::js_array_get_f64( + keys_now as *const perry_runtime::ArrayHeader, + i, + ); + if !JSValue::from_bits(key_value.to_bits()).is_any_string() { + continue; + } + let key_ptr = perry_runtime::builtins::js_string_coerce(key_value); + if key_ptr.is_null() { + continue; + } + let key = string_from_header(key_ptr as *const StringHeader).unwrap_or_default(); + let val_value = + perry_runtime::proxy::js_proxy_get(proxy_handle.get_nanbox_f64(), key_value); + entries.push((key, header_init_string(val_value))); + } + Some(entries) +} + unsafe fn materialize_headers_init_iterable( value: f64, scope: &perry_runtime::gc::RuntimeHandleScope, ) -> *const perry_runtime::ArrayHeader { if !is_headers_init_iterable(value) { - headers_init_type_error("Headers constructor: init is not iterable"); + headers_init_type_error(&format!( + "Headers constructor: init is not iterable (received {})", + describe_headers_init(value) + )); } let arr_value = perry_runtime::array::js_for_of_to_array(value); let arr_handle = scope.root_nanbox_f64(arr_value); let raw = perry_runtime::js_nanbox_get_pointer(arr_handle.get_nanbox_f64()); if raw == 0 { - headers_init_type_error("Headers constructor: init is not iterable"); + headers_init_type_error(&format!( + "Headers constructor: init is not iterable (received {})", + describe_headers_init(value) + )); } raw as *const perry_runtime::ArrayHeader } @@ -245,12 +331,20 @@ pub unsafe extern "C" fn js_headers_init_from_value(handle: f64, init: f64) -> f return f64::from_bits(TAG_UNDEFINED); } + // A Proxy value is not a Headers handle: its NaN-box would otherwise be + // masked into a registry id and could alias a live Headers entry, silently + // copying the wrong (or no) headers. Route it to the record path below. + let is_proxy_init = perry_runtime::proxy::js_proxy_is_proxy(init) != 0; let source_id = handle_id(init); - let cloned = HEADERS_REGISTRY - .lock() - .unwrap() - .get(&source_id) - .map(|store| store.entries.clone()); + let cloned = if is_proxy_init { + None + } else { + HEADERS_REGISTRY + .lock() + .unwrap() + .get(&source_id) + .map(|store| store.entries.clone()) + }; if let Some(entries) = cloned { append_header_entries(target_id, entries); return f64::from_bits(TAG_UNDEFINED); diff --git a/crates/perry/tests/headers_proxy_record_init.rs b/crates/perry/tests/headers_proxy_record_init.rs new file mode 100644 index 0000000000..4706deb0a7 --- /dev/null +++ b/crates/perry/tests/headers_proxy_record_init.rs @@ -0,0 +1,100 @@ +//! `new Headers(init)` must accept a Proxy wrapping a record, reading the init's +//! own keys and values through the proxy's traps. perry took the record path only +//! for plain heap objects, so a proxied header record raised +//! "Headers constructor: init is not iterable" — OpenCode's request path hit this +//! on every `run` (tracker #10107). +//! Two neighbours are deliberately out of scope here: a proxy wrapping an *array* +//! (`Array.from` over such a value segfaults, #10270) and a proxied init passed +//! through `new Request(url, { headers })`, which takes a different path (#10274). + +use std::path::PathBuf; +use std::process::Command; +use std::sync::Once; + +fn perry_bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_perry")) +} + +fn workspace_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .canonicalize() + .expect("canonicalize workspace root") +} + +fn runtime_dir() -> PathBuf { + static BUILD_RUNTIME: Once = Once::new(); + BUILD_RUNTIME.call_once(|| { + let cargo = std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into()); + let build = Command::new(cargo) + .current_dir(workspace_root()) + .arg("build") + .arg("-p") + .arg("perry-runtime-static") + .arg("-p") + .arg("perry-stdlib-static") + .output() + .expect("build static runtime archives"); + assert!( + build.status.success(), + "static runtime build failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&build.stdout), + String::from_utf8_lossy(&build.stderr) + ); + }); + let target = std::env::var_os("CARGO_TARGET_DIR") + .map(PathBuf::from) + .unwrap_or_else(|| workspace_root().join("target")); + target.join("debug") +} + +const SOURCE: &str = r#" +const t = (name: string, f: () => any) => { try { console.log(name, JSON.stringify(f())) } catch (e: any) { console.log(name, "THROW", e.message) } } +const dump = (h: any) => { const out: string[] = []; h.forEach((v: string, k: string) => out.push(k + "=" + v)); return out.sort() } +t("P1 plain proxy", () => dump(new Headers(new Proxy({ "x-a": "1", "x-b": "2" }, {}) as any))) +t("P2 proxy with get trap", () => dump(new Headers(new Proxy({ "x-a": "1" }, { get: (t: any, k: any) => (typeof k === "string" && k in t ? "trapped" : (t as any)[k]) }) as any))) +t("P3 proxy with ownKeys trap hiding a key", () => dump(new Headers(new Proxy({ "x-a": "1", "x-b": "2" }, { ownKeys: () => ["x-a"], getOwnPropertyDescriptor: () => ({ configurable: true, enumerable: true, value: "1" }) }) as any))) +t("P4 proxy over empty object", () => dump(new Headers(new Proxy({}, {}) as any))) +t("P6 nested proxy", () => dump(new Headers(new Proxy(new Proxy({ "x-a": "1" }, {}), {}) as any))) +t("P8 plain object still works", () => dump(new Headers({ "x-a": "1" }))) +t("P9 array still works", () => dump(new Headers([["x-a", "1"]]))) +t("P10 map still works", () => dump(new Headers(new Map([["x-a", "1"]]) as any))) +"#; + +const EXPECTED: &str = "P1 plain proxy [\"x-a=1\",\"x-b=2\"]\nP2 proxy with get trap [\"x-a=trapped\"]\nP3 proxy with ownKeys trap hiding a key [\"x-a=1\"]\nP4 proxy over empty object []\nP6 nested proxy [\"x-a=1\"]\nP8 plain object still works [\"x-a=1\"]\nP9 array still works [\"x-a=1\"]\nP10 map still works [\"x-a=1\"]\n"; + +#[test] +fn headers_accepts_a_proxied_record_init() { + let dir = tempfile::tempdir().expect("tempdir"); + let entry = dir.path().join("main.ts"); + let output = dir.path().join("main_bin"); + std::fs::write(&entry, SOURCE).expect("write entry"); + let compile = Command::new(perry_bin()) + .current_dir(dir.path()) + .arg("compile") + .arg(&entry) + .arg("-o") + .arg(&output) + .arg("--no-cache") + .env("PERRY_NO_AUTO_OPTIMIZE", "1") + .env("PERRY_RUNTIME_DIR", runtime_dir()) + .output() + .expect("run perry compile"); + assert!( + compile.status.success(), + "perry compile failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&compile.stdout), + String::from_utf8_lossy(&compile.stderr) + ); + let run = Command::new(&output) + .current_dir(dir.path()) + .output() + .expect("run compiled binary"); + assert!( + run.status.success(), + "compiled binary failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&run.stdout), + String::from_utf8_lossy(&run.stderr) + ); + assert_eq!(String::from_utf8_lossy(&run.stdout), EXPECTED); +}