From 51f913d1336859c23e96d10a711edb6c936c1341 Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Tue, 15 Sep 2026 00:16:54 +0000 Subject: [PATCH 1/7] fix(compile): initialize the cycle partner of a dynamically imported module A module reached only through a dynamic `import()` that takes part in an import cycle never initialized its partner. The partner body did not run, so every export it assigns at run time stayed undefined and the first call through such a binding threw `TypeError: value is not a function`. `module_init_deps` drops init-call back-edges (#6463) so an `__init` wrapper does not re-enter a cycle member the entry has already run. That is sound only because the entry emits an eager init call for every Eager module in topological order. Deferred modules are filtered out of that loop, so nothing else runs them: dropping a wrapper edge to a Deferred dep left it with no caller at all. Apply the positional drop to Eager deps only. This cannot perturb the ordering #6463 fixed. A module statically imported by an Eager module is itself statically reachable from the entry and therefore Eager, so the new arm never fires for it. Inside a deferred cycle the existing `__perry_init_done_*` guard keeps the extra call idempotent and reproduces ESM order: the partner body runs first and the re-entrant call returns. The regression test pairs the defect with its control, the same cycle entered statically, which pins the #6463 ordering the fix must not disturb. Fixes #10278. Refs #10107. --- changelog.d/10278-deferred-cycle-init.md | 11 ++ .../src/commands/compile/run_pipeline.rs | 23 ++- .../issue_10278_dynamic_import_cycle_init.rs | 176 ++++++++++++++++++ 3 files changed, 209 insertions(+), 1 deletion(-) create mode 100644 changelog.d/10278-deferred-cycle-init.md create mode 100644 crates/perry/tests/issue_10278_dynamic_import_cycle_init.rs diff --git a/changelog.d/10278-deferred-cycle-init.md b/changelog.d/10278-deferred-cycle-init.md new file mode 100644 index 0000000000..a8aff81229 --- /dev/null +++ b/changelog.d/10278-deferred-cycle-init.md @@ -0,0 +1,11 @@ +Initialize the cycle partner of a module that is reached only through a dynamic +`import()`. Perry drops init-call back-edges from a module's `__init` wrapper so +that a cycle member the entry's eager init loop already ran is not re-entered +early, but that loop skips Deferred modules, so dropping the edge to one left it +with no caller at all: its body never ran and every export it assigns at run time +stayed undefined, surfacing as `TypeError: value is not a function` on the first +call through such a binding. The positional drop now applies only to Eager deps. +An Eager module's static imports are themselves statically reachable from the +entry and so are Eager, which is why the ordering this rule protects is +unaffected, and the existing per-module init guard keeps the extra call +idempotent and cycle-safe. diff --git a/crates/perry/src/commands/compile/run_pipeline.rs b/crates/perry/src/commands/compile/run_pipeline.rs index f751ace6dc..e25bd6ea3f 100644 --- a/crates/perry/src/commands/compile/run_pipeline.rs +++ b/crates/perry/src/commands/compile/run_pipeline.rs @@ -3319,8 +3319,29 @@ pub fn run_with_parse_cache( .enumerate() .map(|(i, name)| (sanitize_name(name), i)) .collect(); + // + // A Deferred dep is the exception (#10278). The back-edge drop is + // sound only because the entry's eager init loop runs every Eager + // module in `init_pos` order, so a dep positioned before this + // module has already initialized by the time this wrapper runs. + // Deferred modules are filtered OUT of that loop — nothing runs + // them but a dynamic-import dispatch site or another wrapper — so + // dropping the edge to one strands it: its body never runs and + // every export the body assigns at runtime stays undefined. A + // static `import()` entry into a Deferred cycle reproduced it + // (`useAssigned()` returned undefined, `obj.method` threw). Keep + // the edge whenever the dep is Deferred; the `__init` guard + // already makes the call idempotent and cycle-safe, and it + // reproduces ESM's order (the partner body runs first, the + // re-entrant call returns immediately). This cannot perturb the + // Eager ordering #6463 fixed: a module statically imported by an + // Eager module is itself statically reachable from the entry, so + // it is Eager, so this arm never fires for it. if let Some(&self_pos) = init_pos.get(&sanitize_name(&hir_module.name)) { - deps.retain(|dep| init_pos.get(dep).map_or(true, |&p| p < self_pos)); + deps.retain(|dep| { + deferred_module_prefixes.contains(dep) + || init_pos.get(dep).map_or(true, |&p| p < self_pos) + }); } deps }; diff --git a/crates/perry/tests/issue_10278_dynamic_import_cycle_init.rs b/crates/perry/tests/issue_10278_dynamic_import_cycle_init.rs new file mode 100644 index 0000000000..80960b9da3 --- /dev/null +++ b/crates/perry/tests/issue_10278_dynamic_import_cycle_init.rs @@ -0,0 +1,176 @@ +//! Regression test for #10278: a module reached only through a dynamic +//! `import()` (`ModuleInitKind::Deferred`) that takes part in an import cycle +//! must still initialize its cycle partner. +//! +//! `module_init_deps` (run_pipeline) drops init-call back-edges so that an +//! Eager module's `__init` wrapper never re-enters a cycle member the entry's +//! eager init loop has already run — that is #6463, and dropping the edge is +//! only sound *because* that loop exists. Deferred modules are filtered out of +//! it (`codegen/entry.rs`), so nothing else runs them: dropping a wrapper's +//! edge to a Deferred dep strands the dep forever. Its body never runs and +//! every export the body assigns at runtime stays undefined, which surfaces as +//! `TypeError: value is not a function` on the first call through such a slot. +//! +//! The two tests are deliberately a pair. `dynamic` is the defect: it fails on +//! the pre-fix compiler with `assigned-UNDEFINED`. `static_control` pins the +//! #6463 ordering it must not disturb — the same cycle entered statically, +//! where the eager loop is what initializes both members. + +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 target_debug_dir() -> PathBuf { + std::env::var_os("CARGO_TARGET_DIR") + .map(PathBuf::from) + .unwrap_or_else(|| workspace_root().join("target")) + .join("debug") +} + +fn ensure_runtime_archive() { + 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("run cargo build for static wrapper crates"); + assert!( + build.status.success(), + "cargo build -p perry-runtime-static -p perry-stdlib-static failed\n\ + stdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&build.stdout), + String::from_utf8_lossy(&build.stderr) + ); + }); +} + +fn runtime_dir() -> PathBuf { + ensure_runtime_archive(); + target_debug_dir() +} + +/// Cycle member whose body assigns two exports at run time. A function +/// declaration alone would not witness the bug: those are reachable without the +/// body ever running. +const CYCLE_A: &str = "\ +console.log(\"[init] a body start\"); +import { bFn } from \"./b.js\"; +export let assigned; +assigned = () => \"a-assigned-ok\"; +export const obj = {}; +obj.method = () => \"a-obj-method-ok\"; +export function aFn() { return \"a:\" + bFn(); } +console.log(\"[init] a body end\"); +"; + +const CYCLE_B: &str = "\ +console.log(\"[init] b body start\"); +import { aFn, assigned, obj } from \"./a.js\"; +export function bFn() { return \"b\"; } +export function useA() { return aFn(); } +export function useAssigned() { return assigned ? assigned() : \"assigned-UNDEFINED\"; } +export function useObj() { + return typeof obj.method === \"function\" ? obj.method() : \"obj-method-NOT-A-FUNCTION\"; +} +console.log(\"[init] b body end\"); +"; + +const DYNAMIC_ENTRY: &str = "\ +const m = await import(\"./b.js\"); +console.log(m.bFn(), m.useA(), m.useAssigned(), m.useObj()); +"; + +const STATIC_ENTRY: &str = "\ +import { bFn, useA, useAssigned, useObj } from \"./b.js\"; +console.log(bFn(), useA(), useAssigned(), useObj()); +"; + +/// Node and bun print the cycle partner's body first in both entry shapes. +const EXPECTED: &str = "\ +[init] a body start +[init] a body end +[init] b body start +[init] b body end +b a:b a-assigned-ok a-obj-method-ok +"; + +fn compile_and_run(entry_src: &str) -> String { + let dir = tempfile::tempdir().expect("tempdir"); + let root = dir.path(); + std::fs::write(root.join("a.js"), CYCLE_A).unwrap(); + std::fs::write(root.join("b.js"), CYCLE_B).unwrap(); + std::fs::write(root.join("entry.js"), entry_src).unwrap(); + + let entry = root.join("entry.js"); + let output = root.join("entry_bin"); + let out = Command::new(perry_bin()) + .current_dir(root) + .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!( + out.status.success(), + "import cycle must compile; stdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); + + let run = Command::new(&output).output().expect("run compiled binary"); + assert!( + run.status.success(), + "compiled cycle binary must run; stdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&run.stdout), + String::from_utf8_lossy(&run.stderr) + ); + String::from_utf8(run.stdout).expect("UTF-8 stdout") +} + +#[test] +fn dynamic_import_into_cycle_initializes_the_partner() { + let stdout = compile_and_run(DYNAMIC_ENTRY); + assert!( + stdout.contains("[init] a body start"), + "the cycle partner's body must run when the cycle is entered through \ + a dynamic import; stdout:\n{stdout}" + ); + assert!( + !stdout.contains("assigned-UNDEFINED") && !stdout.contains("obj-method-NOT-A-FUNCTION"), + "run-time-assigned exports of the cycle partner must be live; stdout:\n{stdout}" + ); + assert_eq!( + stdout, EXPECTED, + "dynamic-import cycle output must match node/bun byte-for-byte" + ); +} + +#[test] +fn static_import_into_cycle_keeps_the_6463_order() { + let stdout = compile_and_run(STATIC_ENTRY); + assert_eq!( + stdout, EXPECTED, + "static-import cycle output must match node/bun byte-for-byte" + ); +} From 091ad2bdb6c932cab07856ae0eb7145d3e6068c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 15 Sep 2026 03:14:28 +0200 Subject: [PATCH 2/7] Fix proxy array materialization and dynamic Request headers --- changelog.d/10270-proxy-value-shapes.md | 15 +++++ .../perry-codegen/src/lower_call/builtin.rs | 3 +- .../src/lower_call/options/mod.rs | 21 +++++++ crates/perry-runtime/src/array/flat_clone.rs | 11 +++- crates/perry-runtime/src/array/from_concat.rs | 58 ++++++++++++++++- crates/perry-runtime/src/array/iterator.rs | 7 +++ crates/perry-runtime/src/collection_iter.rs | 7 +++ crates/perry-runtime/src/object/arguments.rs | 4 +- .../src/object/polymorphic_index.rs | 10 ++- crates/perry-runtime/src/symbol/get.rs | 17 +++-- crates/perry-stdlib/src/fetch/headers.rs | 8 ++- .../tests/issue_10270_proxy_array_from.rs | 63 +++++++++++++++++++ .../issue_10274_request_proxy_headers.rs | 43 +++++++++++++ .../perry/tests/support/proxy_value_probe.rs | 44 +++++++++++++ scripts/addr_class_ratchet_baseline.txt | 2 +- 15 files changed, 295 insertions(+), 18 deletions(-) create mode 100644 changelog.d/10270-proxy-value-shapes.md create mode 100644 crates/perry/tests/issue_10270_proxy_array_from.rs create mode 100644 crates/perry/tests/issue_10274_request_proxy_headers.rs create mode 100644 crates/perry/tests/support/proxy_value_probe.rs diff --git a/changelog.d/10270-proxy-value-shapes.md b/changelog.d/10270-proxy-value-shapes.md new file mode 100644 index 0000000000..a2cd4a8800 --- /dev/null +++ b/changelog.d/10270-proxy-value-shapes.md @@ -0,0 +1,15 @@ +Fix Proxy values in `Array.from` and dynamic `RequestInit.headers` (#10270, +#10274; OpenCode bring-up tracker #10107). + +- Route array proxies through iterator materialization before inspecting heap + headers, including mapped `Array.from`, Headers iterable pairs, call spread, + and collection constructors. Preserve the proxy receiver when a get trap + forwards the default iterator, and safely dispatch array-like proxy indices. + Keep concat on trapped indexed reads, preserving + holes and ignoring a custom iterator. +- Convert dynamic Request header records/iterables with + `js_headers_init_from_value`; retain the existing inline literal-header path. +- Keep proxy registry lookups behind the existing proxy-id band predicate; + Array.from checks only within its existing small-handle branch. +- Add compiled regression probes for both issue reproductions, nested proxies, + get/ownKeys traps, custom iterators, mapped conversion, and sibling consumers. diff --git a/crates/perry-codegen/src/lower_call/builtin.rs b/crates/perry-codegen/src/lower_call/builtin.rs index 3996ef9dab..236b982c67 100644 --- a/crates/perry-codegen/src/lower_call/builtin.rs +++ b/crates/perry-codegen/src/lower_call/builtin.rs @@ -1374,7 +1374,8 @@ pub(super) fn lower_builtin_new<'a>( if let Some(hprops) = extract_options_fields(ctx, vexpr) { headers_handle = build_headers_from_object(ctx, &hprops)?; } else { - headers_handle = lower_expr(ctx, vexpr)?; + headers_handle = + super::options::build_headers_from_value(ctx, vexpr)?; } } "referrer" => { diff --git a/crates/perry-codegen/src/lower_call/options/mod.rs b/crates/perry-codegen/src/lower_call/options/mod.rs index 281d1d9fcb..e9492daaf3 100644 --- a/crates/perry-codegen/src/lower_call/options/mod.rs +++ b/crates/perry-codegen/src/lower_call/options/mod.rs @@ -67,6 +67,27 @@ pub(in crate::lower_call) fn build_headers_from_object( }) } +/// Convert a dynamic HeadersInit with the same helper as `new Headers(init)`. +/// The literal path stays in `build_headers_from_object`; a runtime value is +/// not necessarily a Headers registry handle (it may be a record or iterable). +pub(in crate::lower_call) fn build_headers_from_value( + ctx: &mut FnCtx<'_>, + init: &Expr, +) -> Result { + with_rooted_group(ctx, 1, |ctx, group| { + let h = ctx.block().call(DOUBLE, "js_headers_new", &[]); + let h_root = group.adopt_emitted(ctx, Repr::Boxed, &h, true); + let value = lower_expr(ctx, init)?; + let h = group.reread_emitted(ctx, h_root); + ctx.block().call( + DOUBLE, + "js_headers_init_from_value", + &[(DOUBLE, &h), (DOUBLE, &value)], + ); + Ok(group.reread_emitted(ctx, h_root)) + }) +} + /// Phase 3 compat: extract `{key: value, ...}` pairs from an options /// argument in a form that works whether the options literal reached us /// as a plain `Expr::Object(props)` (pre-Phase-3 / spread/dynamic shapes) diff --git a/crates/perry-runtime/src/array/flat_clone.rs b/crates/perry-runtime/src/array/flat_clone.rs index c69be29769..2365a2c250 100644 --- a/crates/perry-runtime/src/array/flat_clone.rs +++ b/crates/perry-runtime/src/array/flat_clone.rs @@ -458,7 +458,7 @@ pub extern "C" fn js_array_clone(src: *const ArrayHeader) -> *mut ArrayHeader { let top16 = (src as u64) >> 48; if top16 == 0x7FFF { true - } else if raw_addr >= crate::gc::GC_HEADER_SIZE + 0x1000 { + } else if crate::value::addr_class::is_above_handle_band(raw_addr) { unsafe { let hdr = (raw_addr as *const u8).sub(crate::gc::GC_HEADER_SIZE) as *const crate::gc::GcHeader; @@ -478,6 +478,15 @@ pub extern "C" fn js_array_clone(src: *const ArrayHeader) -> *mut ArrayHeader { // helper after codegen strips the tag, so ask the generic iterator resolver // before treating the id as a non-array and returning []. if crate::value::addr_class::is_small_handle(raw_addr) { + // #10270: a Proxy id has no GC/ArrayHeader. Only the existing handle + // arm probes the registry; plain arrays keep their classification and + // copy path. Array.from permits array-like proxies as well as iterables. + if let Some(proxy) = array_ptr_as_proxy(raw_addr as *const ArrayHeader) { + let undefined = f64::from_bits(crate::value::TAG_UNDEFINED); + let result = + super::from_concat::array_from_full(undefined, proxy, undefined, undefined); + return crate::value::js_nanbox_get_pointer(result) as *mut ArrayHeader; + } if let Some(dispatch) = crate::object::handle_property_dispatch() { let method = b"@@iterator"; let iter_fn = unsafe { dispatch(raw_addr as i64, method.as_ptr(), method.len()) }; diff --git a/crates/perry-runtime/src/array/from_concat.rs b/crates/perry-runtime/src/array/from_concat.rs index 0903c98cd0..3e6d17871c 100644 --- a/crates/perry-runtime/src/array/from_concat.rs +++ b/crates/perry-runtime/src/array/from_concat.rs @@ -555,6 +555,25 @@ enum IterSourceKind { /// class id (so a direct symbol read returns `undefined`) but which still /// drive `.next()`. Mirrors the iterable detection in `js_array_clone`. fn items_is_iterable(items: f64) -> bool { + if let Some(proxy) = crate::array::array_ptr_as_proxy( + crate::value::js_nanbox_get_pointer(items) as *const ArrayHeader, + ) { + // A proxy can replace or remove @@iterator even when IsArray is true, + // or add it to a record. Array.from falls back to indexed reads only + // when GetMethod is nullish; a non-callable method must still throw. + let symbol = crate::symbol::well_known_symbol("iterator"); + let method = unsafe { + crate::symbol::js_object_get_symbol_property( + proxy, + crate::value::js_nanbox_pointer(symbol as i64), + ) + }; + if matches!(method.to_bits(), TAG_UNDEFINED | TAG_NULL) { + return false; + } + resolve_callable(method); + return true; + } if crate::collection_iter::is_iterable(items) { return true; } @@ -579,6 +598,15 @@ fn items_is_iterable(items: f64) -> bool { } fn classify_iter_source(items: f64) -> IterSourceKind { + // IsArray unwraps proxies; LiveArray would read the id as an ArrayHeader + // and skip a trapped @@iterator. The band test excludes ordinary arrays. + if crate::array::array_ptr_as_proxy( + crate::value::js_nanbox_get_pointer(items) as *const ArrayHeader + ) + .is_some() + { + return IterSourceKind::Generic; + } if jsv_is_array(items) { return IterSourceKind::LiveArray; } @@ -1037,8 +1065,10 @@ unsafe fn try_append_spread_array_dense( src: *const ArrayHeader, ) -> Option<*mut ArrayHeader> { // A masked proxy id is not a dereferenceable ArrayHeader. - if crate::array::array_ptr_as_proxy(src).is_some() { - return None; + if let Some(proxy) = crate::array::array_ptr_as_proxy(src) { + // Concat uses HasProperty/Get, never @@iterator. Resolve this inside + // the existing proxy guard so the ordinary bulk-copy path is unchanged. + return Some(append_concat_proxy(result, proxy)); } let src = clean_arr_ptr(src); if src.is_null() { @@ -1131,6 +1161,30 @@ unsafe fn try_append_spread_array_dense( Some(result) } +/// Indexed concat of a proxy, preserving holes and observing its traps. +unsafe fn append_concat_proxy(result: *mut ArrayHeader, proxy: f64) -> *mut ArrayHeader { + let scope = crate::gc::RuntimeHandleScope::new(); + let result = scope.root_raw_mut_ptr(result); + let proxy = scope.root_nanbox_f64(proxy); + let len = array_like_length(proxy.get_nanbox_f64()); + for index in 0..len { + let entry_scope = crate::gc::RuntimeHandleScope::new(); + let name = index.to_string(); + let key = crate::string::js_string_from_bytes(name.as_ptr(), name.len() as u32); + let key = entry_scope.root_nanbox_f64(crate::value::js_nanbox_string(key as i64)); + let present = + crate::object::js_object_has_property(proxy.get_nanbox_f64(), key.get_nanbox_f64()); + let value = if crate::value::js_is_truthy(present) != 0 { + crate::proxy::js_proxy_get(proxy.get_nanbox_f64(), key.get_nanbox_f64()) + } else { + f64::from_bits(crate::value::TAG_HOLE) + }; + let grown = js_array_push_f64(result.get_raw_mut_ptr(), value); + result.set_raw_mut_ptr(grown); + } + result.get_raw_mut_ptr() +} + /// Append every element of the (already-materializable) source array `src` /// into `result`, returning the (possibly reallocated) result. `src` is /// materialized via `js_array_clone` so sets/maps/typed-arrays/buffers spread diff --git a/crates/perry-runtime/src/array/iterator.rs b/crates/perry-runtime/src/array/iterator.rs index bc277d6b8d..79ed386779 100644 --- a/crates/perry-runtime/src/array/iterator.rs +++ b/crates/perry-runtime/src/array/iterator.rs @@ -102,6 +102,13 @@ pub extern "C" fn js_for_of_to_array(val_f64: f64) -> f64 { throw_not_iterable(val_f64); } + // Proxy ids are not heap headers. Use the same trapped iterator as spread. + if let Some(proxy) = array_ptr_as_proxy(raw_ptr as *const ArrayHeader) { + return js_nanbox_pointer( + js_iterator_to_array(crate::symbol::js_get_iterator(proxy)) as i64 + ); + } + // Inspect the GC header's object kind to dispatch Array / Map / Set // without consulting any static type. let obj_type = unsafe { diff --git a/crates/perry-runtime/src/collection_iter.rs b/crates/perry-runtime/src/collection_iter.rs index 44d5c801b4..1e64d79e15 100644 --- a/crates/perry-runtime/src/collection_iter.rs +++ b/crates/perry-runtime/src/collection_iter.rs @@ -254,6 +254,13 @@ pub(crate) fn constructor_iter(value: f64) -> ConstructorIter { let jsv = JSValue::from_bits(value.to_bits()); let is_array = crate::array::js_array_is_array(value).to_bits() == crate::value::TAG_TRUE; if is_array { + if crate::array::array_ptr_as_proxy( + js_nanbox_get_pointer(value) as *const crate::array::ArrayHeader + ) + .is_some() + { + return ConstructorIter::Iterator(crate::symbol::js_get_iterator(value)); + } return ConstructorIter::Array(value); } if jsv.is_any_string() { diff --git a/crates/perry-runtime/src/object/arguments.rs b/crates/perry-runtime/src/object/arguments.rs index cf1ad044f6..41cf23d17f 100644 --- a/crates/perry-runtime/src/object/arguments.rs +++ b/crates/perry-runtime/src/object/arguments.rs @@ -717,7 +717,9 @@ pub extern "C" fn js_array_like_to_array(value: f64) -> *mut ArrayHeader { // sticky flag that is false until user code writes the prototype slot, // so the fast path is untouched in every ordinary program. if crate::array::js_array_is_array(value).to_bits() == crate::value::TAG_TRUE { - if crate::array::array_proto_iterator_modified() { + if crate::array::array_proto_iterator_modified() + || crate::array::array_ptr_as_proxy(raw as *const ArrayHeader).is_some() + { return crate::array::js_array_clone_for_spread(value); } return crate::array::clean_arr_ptr(raw as *const ArrayHeader) as *mut ArrayHeader; diff --git a/crates/perry-runtime/src/object/polymorphic_index.rs b/crates/perry-runtime/src/object/polymorphic_index.rs index 0468240fe0..bbb7daa4aa 100644 --- a/crates/perry-runtime/src/object/polymorphic_index.rs +++ b/crates/perry-runtime/src/object/polymorphic_index.rs @@ -174,7 +174,15 @@ pub extern "C" fn js_object_get_index_polymorphic(obj_handle: i64, idx: f64) -> } else { obj_handle as u64 }; - if raw < 0x1000 { + // Array.from's array-like fallback can receive a Proxy record. Its id is + // not a heap header; route it inside the existing low-address guard so + // ordinary heap receivers do not perform a proxy registry lookup. + if crate::value::addr_class::is_handle_band(raw as usize) { + if let Some(proxy) = + crate::array::array_ptr_as_proxy(raw as *const crate::array::ArrayHeader) + { + return crate::proxy::js_proxy_get(proxy, idx); + } return f64::from_bits(crate::value::TAG_UNDEFINED); } // Symbols share GC_TYPE_STRING storage for tracing, but they are primitive diff --git a/crates/perry-runtime/src/symbol/get.rs b/crates/perry-runtime/src/symbol/get.rs index 326f73dee6..ed72157767 100644 --- a/crates/perry-runtime/src/symbol/get.rs +++ b/crates/perry-runtime/src/symbol/get.rs @@ -1141,22 +1141,19 @@ pub unsafe extern "C" fn js_object_get_symbol_property(obj_f64: f64, sym_f64: f6 } } } - // #321: arrays expose `Symbol.iterator`. perry has no standalone array - // iterator object (for-of is special-cased), but `arr[Symbol.iterator]` - // must resolve to a callable so `Symbol.iterator in arr` is true - // (effect's `Predicate.isIterable`) and `typeof arr[Symbol.iterator]` is - // "function". Bind the array's `values` method as that callable. Pre-fix - // the symbol key fell through to the numeric/string paths and read back a - // number, so `isIterable([...])` was false and `Effect.all`'s - // predicate-`dual` `forEach` went data-last (returned a function). + // Return the actual prototype iterator, whose thunk reads call-time this. + // Binding `values` to this array makes a Proxy get trap returning + // target[Symbol.iterator] iterate the target, bypassing length/index traps + // even when GetIterator calls the method with the Proxy receiver (#10270). if crate::array::js_array_is_array(obj_f64).to_bits() == crate::value::TAG_TRUE { let iter_wk = well_known_symbol("iterator"); if !iter_wk.is_null() { let iter_f64 = f64::from_bits(crate::value::JSValue::pointer(iter_wk as *const u8).bits()); if sym_key_from_f64(sym_f64) == sym_key_from_f64(iter_f64) { - let mname = b"values"; - return crate::object::js_class_method_bind(obj_f64, mname.as_ptr(), mname.len()); + let proto = crate::object::builtin_prototype_value("Array"); + return own_symbol_property(proto, sym_f64) + .unwrap_or(f64::from_bits(crate::value::TAG_UNDEFINED)); } } } diff --git a/crates/perry-stdlib/src/fetch/headers.rs b/crates/perry-stdlib/src/fetch/headers.rs index eab946c46c..5ab77f3d50 100644 --- a/crates/perry-stdlib/src/fetch/headers.rs +++ b/crates/perry-stdlib/src/fetch/headers.rs @@ -293,7 +293,13 @@ unsafe fn materialize_header_pair( if !is_headers_init_iterable(pair_value) { headers_init_type_error("Headers constructor: expected name/value pair"); } - let pair_array_value = if perry_runtime::js_array_is_array(pair_value).to_bits() == TAG_TRUE { + // IsArray also accepts proxy ids and object-backed Array subclasses; only + // a real array header may bypass iterable materialization (#10270). + let raw = perry_runtime::js_nanbox_get_pointer(pair_value); + let pair_array_value = if matches!( + gc_type_for_raw_ptr(raw), + Some(perry_runtime::gc::GC_TYPE_ARRAY | perry_runtime::gc::GC_TYPE_LAZY_ARRAY) + ) { pair_value } else { perry_runtime::array::js_for_of_to_array(pair_value) diff --git a/crates/perry/tests/issue_10270_proxy_array_from.rs b/crates/perry/tests/issue_10270_proxy_array_from.rs new file mode 100644 index 0000000000..ba3e1593a8 --- /dev/null +++ b/crates/perry/tests/issue_10270_proxy_array_from.rs @@ -0,0 +1,63 @@ +//! #10270: IsArray is not proof that a Proxy id is an ArrayHeader. + +mod support { + include!("support/proxy_value_probe.rs"); +} + +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 pa = () => new Proxy([["x-a", "1"]], {}) +t("Q1 [...proxyOverArray]", () => [...(pa() as any)].length) +t("Q2 for..of proxyOverArray", () => { let n = 0; for (const _ of pa() as any) n++; return n }) +t("Q3 Array.isArray(proxyOverArray)", () => Array.isArray(pa())) +t("Q4 proxy[Symbol.iterator] typeof", () => typeof (pa() as any)[Symbol.iterator]) +t("Q5 Array.from(proxyOverArray)", () => Array.from(pa() as any).length) +t("nested", () => Array.from(new Proxy(new Proxy([1, 2], {}), {}))) +t("trapped indices", () => Array.from(new Proxy([1, 2], { get(t: any, k: any) { return k === "0" ? 9 : t[k]; } }))) +t("custom iterator", () => Array.from(new Proxy([1, 2], { get(t: any, k: any) { return k === Symbol.iterator ? () => [7, 8, 9].values() : t[k]; } }))) +t("mapped", () => Array.from(new Proxy([1, 2], {}), (v: number, i: number) => v + i)) +t("mapped custom iterator", () => Array.from(new Proxy([1, 2], { get(t: any, k: any) { return k === Symbol.iterator ? () => [7, 8, 9].values() : t[k]; } }), (v: number) => v * 2)) +t("arraylike", () => Array.from(new Proxy({ 0: "a", 1: "b", length: 2 }, {}))) +t("removed iterator", () => Array.from(new Proxy([1, 2], { get(t: any, k: any) { return k === Symbol.iterator ? undefined : t[k]; } }))) +t("record iterator", () => Array.from(new Proxy({}, { get(t: any, k: any) { return k === Symbol.iterator ? () => [5, 6].values() : t[k]; } }))) +t("headers outer", () => new Headers(new Proxy([["x-a", "1"]], {}) as any).get("x-a")) +t("headers pair", () => new Headers([new Proxy(["x-a", "1"], {})] as any).get("x-a")) +t("plain array", () => Array.from([3, 4])) +t("of preserves proxy", () => { const p = new Proxy([1, 2], {}); return Array.of(p)[0] === p; }) +t("concat indices", () => [0].concat(new Proxy([1, 2], { get(t: any, k: any) { return k === Symbol.iterator ? () => [9].values() : k === "0" ? 7 : t[k]; } }))) +t("concat holes", () => { const a = [0].concat(new Proxy([, 2], {})); return [a.length, 1 in a, a[2]]; }) +t("call spread", () => { const count = (...xs: any[]) => xs.length; return count(...new Proxy([1, 2], {})); }) +t("set iterator", () => Array.from(new Set(new Proxy([1, 2], { get(t: any, k: any) { return k === Symbol.iterator ? () => [7, 8].values() : t[k]; } })))) +Promise.all(new Proxy([Promise.resolve(1), Promise.resolve(2)], {})).then(v => console.log("all", JSON.stringify(v))) +"#; + +#[test] +fn proxy_array_from_and_headers_iterables() { + assert_eq!( + support::compile_and_run(SOURCE), + concat!( + "Q1 [...proxyOverArray] 1\n", + "Q2 for..of proxyOverArray 1\n", + "Q3 Array.isArray(proxyOverArray) true\n", + "Q4 proxy[Symbol.iterator] typeof \"function\"\n", + "Q5 Array.from(proxyOverArray) 1\n", + "nested [1,2]\n", + "trapped indices [9,2]\n", + "custom iterator [7,8,9]\n", + "mapped [1,3]\n", + "mapped custom iterator [14,16,18]\n", + "arraylike [\"a\",\"b\"]\n", + "removed iterator [1,2]\n", + "record iterator [5,6]\n", + "headers outer \"1\"\n", + "headers pair \"1\"\n", + "plain array [3,4]\n", + "of preserves proxy true\n", + "concat indices [0,7,2]\n", + "concat holes [3,false,2]\n", + "call spread 2\n", + "set iterator [7,8]\n", + "all [1,2]\n", + ) + ); +} diff --git a/crates/perry/tests/issue_10274_request_proxy_headers.rs b/crates/perry/tests/issue_10274_request_proxy_headers.rs new file mode 100644 index 0000000000..b25830cdfa --- /dev/null +++ b/crates/perry/tests/issue_10274_request_proxy_headers.rs @@ -0,0 +1,43 @@ +//! #10274: a dynamic HeadersInit in a literal RequestInit needs conversion. + +mod support { + include!("support/proxy_value_probe.rs"); +} + +const SOURCE: &str = r#" +const dump = (h: any) => { const out: string[] = []; h.forEach((v: string, k: string) => out.push(k + "=" + v)); return out.sort() } +const proxy = new Proxy({ "x-a": "1" }, {}) +console.log("direct", JSON.stringify(dump(new Headers(proxy as any)))) +console.log("request", JSON.stringify(dump(new Request("https://x.dev", { headers: proxy as any }).headers))) +function make(headers: any) { return new Request("https://x.dev", { headers }).headers; } +function init(headers: any): any { return { headers }; } +console.log("parameter", JSON.stringify(dump(make(proxy)))) +console.log("runtime init", JSON.stringify(dump(new Request("https://x.dev", init(proxy)).headers))) +const trapped = new Proxy({ "authorization": "secret", "x-hidden": "hidden" }, { + ownKeys() { return ["authorization"]; }, + get(t: any, k: any) { return k === "authorization" ? "Bearer token" : t[k]; } +}); +console.log("traps", JSON.stringify(dump(make(trapped)))) +console.log("record", JSON.stringify(dump(make({ "x-a": "1" })))) +console.log("pairs", JSON.stringify(dump(make([["x-a", "1"], ["x-a", "2"]])))) +console.log("handle", JSON.stringify(dump(make(new Headers({ "x-a": "1" }))))) +console.log("literal", JSON.stringify(dump(new Request("https://x.dev", { headers: { "x-a": "1" } }).headers))) +"#; + +#[test] +fn request_converts_dynamic_headers_init() { + assert_eq!( + support::compile_and_run(SOURCE), + concat!( + "direct [\"x-a=1\"]\n", + "request [\"x-a=1\"]\n", + "parameter [\"x-a=1\"]\n", + "runtime init [\"x-a=1\"]\n", + "traps [\"authorization=Bearer token\"]\n", + "record [\"x-a=1\"]\n", + "pairs [\"x-a=1, 2\"]\n", + "handle [\"x-a=1\"]\n", + "literal [\"x-a=1\"]\n", + ) + ); +} diff --git a/crates/perry/tests/support/proxy_value_probe.rs b/crates/perry/tests/support/proxy_value_probe.rs new file mode 100644 index 0000000000..c3d913c277 --- /dev/null +++ b/crates/perry/tests/support/proxy_value_probe.rs @@ -0,0 +1,44 @@ +use std::path::PathBuf; +use std::process::Command; + +pub fn compile_and_run(source: &str) -> String { + let compiler = PathBuf::from(env!("CARGO_BIN_EXE_perry")); + let runtime = std::env::var_os("PERRY_RUNTIME_DIR") + .map(PathBuf::from) + .unwrap_or_else(|| compiler.parent().expect("compiler directory").to_path_buf()); + 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(compiler) + .current_dir(dir.path()) + .arg("compile") + .arg(&entry) + .arg("-o") + .arg(&output) + .arg("--no-cache") + .arg("--no-codegen") + .env("PERRY_NO_AUTO_OPTIMIZE", "1") + .env("PERRY_RUNTIME_DIR", runtime) + .output() + .expect("compile probe"); + assert!( + compile.status.success(), + "compile failed: {:?}\n{}\n{}", + compile.status, + 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 probe"); + assert!( + run.status.success(), + "probe failed: {:?}\n{}\n{}", + run.status, + String::from_utf8_lossy(&run.stdout), + String::from_utf8_lossy(&run.stderr) + ); + String::from_utf8(run.stdout).expect("UTF-8 output") +} diff --git a/scripts/addr_class_ratchet_baseline.txt b/scripts/addr_class_ratchet_baseline.txt index 0562872ae5..32642c62a4 100644 --- a/scripts/addr_class_ratchet_baseline.txt +++ b/scripts/addr_class_ratchet_baseline.txt @@ -29,7 +29,7 @@ handle-floor | crates/perry-ext-http/src/agent.rs | 3 handle-floor | crates/perry-ext-http/src/lib.rs | 2 handle-floor | crates/perry-runtime/src/array/alloc.rs | 2 handle-floor | crates/perry-runtime/src/array/concat_reverse.rs | 1 -handle-floor | crates/perry-runtime/src/array/flat_clone.rs | 4 +handle-floor | crates/perry-runtime/src/array/flat_clone.rs | 3 handle-floor | crates/perry-runtime/src/array/generic.rs | 4 handle-floor | crates/perry-runtime/src/array/header.rs | 3 handle-floor | crates/perry-runtime/src/array/indexing.rs | 2 From 8e26eeea95a5f81184f3dd0046210cc1c56ab0c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 15 Sep 2026 03:15:17 +0200 Subject: [PATCH 3/7] Key proxy value changelog fragment to PR 10280 --- .../{10270-proxy-value-shapes.md => 10280-proxy-value-shapes.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/{10270-proxy-value-shapes.md => 10280-proxy-value-shapes.md} (100%) diff --git a/changelog.d/10270-proxy-value-shapes.md b/changelog.d/10280-proxy-value-shapes.md similarity index 100% rename from changelog.d/10270-proxy-value-shapes.md rename to changelog.d/10280-proxy-value-shapes.md From 3f3f5bd05853c47c689bb560a36e3837605298c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 15 Sep 2026 05:05:42 +0200 Subject: [PATCH 4/7] fix: keep train regression helpers registered and use matching runtime artifacts --- ...e-init.md => 10279-deferred-cycle-init.md} | 0 crates/perry-runtime/src/array/from_concat.rs | 36 +++++++------- .../tests/issue_10270_proxy_array_from.rs | 4 +- .../issue_10274_request_proxy_headers.rs | 4 +- .../issue_10278_dynamic_import_cycle_init.rs | 49 ++++--------------- crates/perry/tests/support/mod.rs | 2 + 6 files changed, 32 insertions(+), 63 deletions(-) rename changelog.d/{10278-deferred-cycle-init.md => 10279-deferred-cycle-init.md} (100%) create mode 100644 crates/perry/tests/support/mod.rs diff --git a/changelog.d/10278-deferred-cycle-init.md b/changelog.d/10279-deferred-cycle-init.md similarity index 100% rename from changelog.d/10278-deferred-cycle-init.md rename to changelog.d/10279-deferred-cycle-init.md diff --git a/crates/perry-runtime/src/array/from_concat.rs b/crates/perry-runtime/src/array/from_concat.rs index 3e6d17871c..e161c1b176 100644 --- a/crates/perry-runtime/src/array/from_concat.rs +++ b/crates/perry-runtime/src/array/from_concat.rs @@ -1166,23 +1166,25 @@ unsafe fn append_concat_proxy(result: *mut ArrayHeader, proxy: f64) -> *mut Arra let scope = crate::gc::RuntimeHandleScope::new(); let result = scope.root_raw_mut_ptr(result); let proxy = scope.root_nanbox_f64(proxy); - let len = array_like_length(proxy.get_nanbox_f64()); - for index in 0..len { - let entry_scope = crate::gc::RuntimeHandleScope::new(); - let name = index.to_string(); - let key = crate::string::js_string_from_bytes(name.as_ptr(), name.len() as u32); - let key = entry_scope.root_nanbox_f64(crate::value::js_nanbox_string(key as i64)); - let present = - crate::object::js_object_has_property(proxy.get_nanbox_f64(), key.get_nanbox_f64()); - let value = if crate::value::js_is_truthy(present) != 0 { - crate::proxy::js_proxy_get(proxy.get_nanbox_f64(), key.get_nanbox_f64()) - } else { - f64::from_bits(crate::value::TAG_HOLE) - }; - let grown = js_array_push_f64(result.get_raw_mut_ptr(), value); - result.set_raw_mut_ptr(grown); - } - result.get_raw_mut_ptr() + let (_, result) = result.across_mut(|| { + let len = array_like_length(proxy.get_nanbox_f64()); + for index in 0..len { + let entry_scope = crate::gc::RuntimeHandleScope::new(); + let name = index.to_string(); + let key = crate::string::js_string_from_bytes(name.as_ptr(), name.len() as u32); + let key = entry_scope.root_nanbox_f64(crate::value::js_nanbox_string(key as i64)); + let present = + crate::object::js_object_has_property(proxy.get_nanbox_f64(), key.get_nanbox_f64()); + let value = if crate::value::js_is_truthy(present) != 0 { + crate::proxy::js_proxy_get(proxy.get_nanbox_f64(), key.get_nanbox_f64()) + } else { + f64::from_bits(crate::value::TAG_HOLE) + }; + let grown = result.with_mut_ptr(|ptr| js_array_push_f64(ptr, value)); + result.set_raw_mut_ptr(grown); + } + }); + result } /// Append every element of the (already-materializable) source array `src` diff --git a/crates/perry/tests/issue_10270_proxy_array_from.rs b/crates/perry/tests/issue_10270_proxy_array_from.rs index ba3e1593a8..bc451e77cf 100644 --- a/crates/perry/tests/issue_10270_proxy_array_from.rs +++ b/crates/perry/tests/issue_10270_proxy_array_from.rs @@ -1,8 +1,6 @@ //! #10270: IsArray is not proof that a Proxy id is an ArrayHeader. -mod support { - include!("support/proxy_value_probe.rs"); -} +mod support; 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) } } diff --git a/crates/perry/tests/issue_10274_request_proxy_headers.rs b/crates/perry/tests/issue_10274_request_proxy_headers.rs index b25830cdfa..c827641acb 100644 --- a/crates/perry/tests/issue_10274_request_proxy_headers.rs +++ b/crates/perry/tests/issue_10274_request_proxy_headers.rs @@ -1,8 +1,6 @@ //! #10274: a dynamic HeadersInit in a literal RequestInit needs conversion. -mod support { - include!("support/proxy_value_probe.rs"); -} +mod support; const SOURCE: &str = r#" const dump = (h: any) => { const out: string[] = []; h.forEach((v: string, k: string) => out.push(k + "=" + v)); return out.sort() } diff --git a/crates/perry/tests/issue_10278_dynamic_import_cycle_init.rs b/crates/perry/tests/issue_10278_dynamic_import_cycle_init.rs index 80960b9da3..f56e39ed8e 100644 --- a/crates/perry/tests/issue_10278_dynamic_import_cycle_init.rs +++ b/crates/perry/tests/issue_10278_dynamic_import_cycle_init.rs @@ -18,52 +18,20 @@ 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 target_debug_dir() -> PathBuf { - std::env::var_os("CARGO_TARGET_DIR") - .map(PathBuf::from) - .unwrap_or_else(|| workspace_root().join("target")) - .join("debug") -} - -fn ensure_runtime_archive() { - 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("run cargo build for static wrapper crates"); - assert!( - build.status.success(), - "cargo build -p perry-runtime-static -p perry-stdlib-static failed\n\ - stdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&build.stdout), - String::from_utf8_lossy(&build.stderr) - ); - }); -} - fn runtime_dir() -> PathBuf { - ensure_runtime_archive(); - target_debug_dir() + std::env::var_os("PERRY_RUNTIME_DIR") + .map(PathBuf::from) + .unwrap_or_else(|| { + perry_bin() + .parent() + .expect("compiler directory") + .to_path_buf() + }) } /// Cycle member whose body assigns two exports at run time. A function @@ -127,6 +95,7 @@ fn compile_and_run(entry_src: &str) -> String { .arg("-o") .arg(&output) .arg("--no-cache") + .arg("--no-codegen") .env("PERRY_NO_AUTO_OPTIMIZE", "1") .env("PERRY_RUNTIME_DIR", runtime_dir()) .output() diff --git a/crates/perry/tests/support/mod.rs b/crates/perry/tests/support/mod.rs new file mode 100644 index 0000000000..b3bdd36e36 --- /dev/null +++ b/crates/perry/tests/support/mod.rs @@ -0,0 +1,2 @@ +mod proxy_value_probe; +pub use proxy_value_probe::compile_and_run; From 9a3c4d1b1a17318fa87e764d7c5b6f02fcc395ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 15 Sep 2026 05:11:54 +0200 Subject: [PATCH 5/7] fix(array): retain Proxy iterator methods and roots through Array.from callbacks --- changelog.d/10280-proxy-value-shapes.md | 4 + crates/perry-runtime/src/array/from_concat.rs | 37 +--- .../src/array/from_concat/proxy_from.rs | 181 ++++++++++++++++++ .../tests/issue_10270_proxy_array_from.rs | 27 +++ 4 files changed, 221 insertions(+), 28 deletions(-) create mode 100644 crates/perry-runtime/src/array/from_concat/proxy_from.rs diff --git a/changelog.d/10280-proxy-value-shapes.md b/changelog.d/10280-proxy-value-shapes.md index a2cd4a8800..f605cb41ef 100644 --- a/changelog.d/10280-proxy-value-shapes.md +++ b/changelog.d/10280-proxy-value-shapes.md @@ -13,3 +13,7 @@ Fix Proxy values in `Array.from` and dynamic `RequestInit.headers` (#10270, Array.from checks only within its existing small-handle branch. - Add compiled regression probes for both issue reproductions, nested proxies, get/ownKeys traps, custom iterators, mapped conversion, and sibling consumers. + +Read a Proxy source’s iterator method once before constructing the result, and +cache the iterator’s next method. Keep the method, iterator, intermediate values, +and pending mapping errors rooted across user callbacks and iterator closing. diff --git a/crates/perry-runtime/src/array/from_concat.rs b/crates/perry-runtime/src/array/from_concat.rs index e161c1b176..a2c5ae57b7 100644 --- a/crates/perry-runtime/src/array/from_concat.rs +++ b/crates/perry-runtime/src/array/from_concat.rs @@ -12,6 +12,8 @@ use super::{ use crate::closure::ClosureHeader; use crate::value::JSValue; +mod proxy_from; + const TAG_UNDEFINED: u64 = 0x7FFC_0000_0000_0001; const TAG_NULL: u64 = 0x7FFC_0000_0000_0002; @@ -555,25 +557,6 @@ enum IterSourceKind { /// class id (so a direct symbol read returns `undefined`) but which still /// drive `.next()`. Mirrors the iterable detection in `js_array_clone`. fn items_is_iterable(items: f64) -> bool { - if let Some(proxy) = crate::array::array_ptr_as_proxy( - crate::value::js_nanbox_get_pointer(items) as *const ArrayHeader, - ) { - // A proxy can replace or remove @@iterator even when IsArray is true, - // or add it to a record. Array.from falls back to indexed reads only - // when GetMethod is nullish; a non-callable method must still throw. - let symbol = crate::symbol::well_known_symbol("iterator"); - let method = unsafe { - crate::symbol::js_object_get_symbol_property( - proxy, - crate::value::js_nanbox_pointer(symbol as i64), - ) - }; - if matches!(method.to_bits(), TAG_UNDEFINED | TAG_NULL) { - return false; - } - resolve_callable(method); - return true; - } if crate::collection_iter::is_iterable(items) { return true; } @@ -598,15 +581,6 @@ fn items_is_iterable(items: f64) -> bool { } fn classify_iter_source(items: f64) -> IterSourceKind { - // IsArray unwraps proxies; LiveArray would read the id as an ArrayHeader - // and skip a trapped @@iterator. The band test excludes ordinary arrays. - if crate::array::array_ptr_as_proxy( - crate::value::js_nanbox_get_pointer(items) as *const ArrayHeader - ) - .is_some() - { - return IterSourceKind::Generic; - } if jsv_is_array(items) { return IterSourceKind::LiveArray; } @@ -748,6 +722,13 @@ pub fn array_from_full(c: f64, items: f64, mapfn: f64, this_arg: f64) -> f64 { throw_not_iterable("object null"); } + // Proxy GetMethod is observable. Resolve it once, retain it across + // construction, and root iterator state across callbacks that can collect. + if let Some(proxy) = crate::array::array_ptr_as_proxy( + crate::value::js_nanbox_get_pointer(items) as *const ArrayHeader, + ) { + return proxy_from::array_from_proxy(c, proxy, mapfn, this_arg, mapping); + } let is_ctor = is_constructor_value(c); if items_is_iterable(items) { diff --git a/crates/perry-runtime/src/array/from_concat/proxy_from.rs b/crates/perry-runtime/src/array/from_concat/proxy_from.rs new file mode 100644 index 0000000000..a65cfab5b3 --- /dev/null +++ b/crates/perry-runtime/src/array/from_concat/proxy_from.rs @@ -0,0 +1,181 @@ +//! Proxy-only Array.from: one GetMethod, with roots across user callbacks. +use super::*; +use crate::gc::{RuntimeHandle, RuntimeHandleScope}; + +fn get(value: &RuntimeHandle<'_>, name: &[u8]) -> f64 { + let key = crate::string::js_string_from_bytes(name.as_ptr(), name.len() as u32); + let raw = crate::value::js_nanbox_get_pointer(value.get_nanbox_f64()); + crate::object::js_object_get_field_by_name_f64(raw as *const crate::ObjectHeader, key) +} + +fn invoke(method: &RuntimeHandle<'_>, receiver: &RuntimeHandle<'_>) -> Result { + let scope = RuntimeHandleScope::new(); + let rebound = crate::closure::clone_closure_rebind_this( + method.get_nanbox_f64().to_bits(), + receiver.get_nanbox_f64(), + ); + let rebound = scope.root_nanbox_f64(f64::from_bits(rebound)); + crate::collection_iter::call_with_this_capturing_throw( + rebound.get_nanbox_f64(), + receiver.get_nanbox_f64(), + &[], + ) +} + +fn define(result: &RuntimeHandle<'_>, index: usize, value: f64, fresh_array: bool) { + if fresh_array { + let raw = crate::value::js_nanbox_get_pointer(result.get_nanbox_f64()); + let grown = js_array_set_f64_extend(raw as *mut ArrayHeader, index as u32, value); + result.set_nanbox_f64(crate::value::js_nanbox_pointer(grown as i64)); + return; + } + let scope = RuntimeHandleScope::new(); + let value = scope.root_nanbox_f64(value); + let desc = scope.root_raw_mut_ptr(crate::object::js_object_alloc(0, 4)); + for name in [ + b"value".as_slice(), + b"writable", + b"enumerable", + b"configurable", + ] { + let key = crate::string::js_string_from_bytes(name.as_ptr(), name.len() as u32); + let field = if name == b"value" { + value.get_nanbox_f64() + } else { + f64::from_bits(TAG_TRUE) + }; + desc.with_mut_ptr(|ptr| crate::object::js_object_set_field_by_name(ptr, key, field)); + } + let name = index.to_string(); + let key = crate::string::js_string_from_bytes(name.as_ptr(), name.len() as u32); + let key = scope.root_nanbox_f64(crate::value::js_nanbox_string(key as i64)); + let descriptor = desc.with_const_ptr::(|ptr| { + crate::value::js_nanbox_pointer(ptr as i64) + }); + if crate::proxy::js_reflect_define_property( + result.get_nanbox_f64(), + key.get_nanbox_f64(), + descriptor, + ) + .to_bits() + != TAG_TRUE + { + throw_cannot_define_property(index); + } +} + +fn finish(result: &RuntimeHandle<'_>, len: usize, fresh_array: bool) -> f64 { + if fresh_array { + let raw = crate::value::js_nanbox_get_pointer(result.get_nanbox_f64()); + crate::array::js_array_set_length(raw as *mut ArrayHeader, len as f64); + } else { + let key = crate::string::js_string_from_bytes(b"length".as_ptr(), 6); + let key = crate::value::js_nanbox_string(key as i64); + let current = result.get_nanbox_f64(); + crate::proxy::js_put_value_set(current, key, len as f64, current, 1); + } + result.get_nanbox_f64() +} + +pub(super) fn array_from_proxy( + c: f64, + items: f64, + mapfn: f64, + this_arg: f64, + mapping: bool, +) -> f64 { + let scope = RuntimeHandleScope::new(); + let constructor = scope.root_nanbox_f64(c); + let items = scope.root_nanbox_f64(items); + let mapfn = scope.root_nanbox_f64(mapfn); + let this_arg = scope.root_nanbox_f64(this_arg); + let symbol = crate::symbol::well_known_symbol("iterator"); + let method = unsafe { + crate::symbol::js_object_get_symbol_property( + items.get_nanbox_f64(), + crate::value::js_nanbox_pointer(symbol as i64), + ) + }; + let method = scope.root_nanbox_f64(method); + let iterable = !matches!(method.get_nanbox_f64().to_bits(), TAG_UNDEFINED | TAG_NULL); + if iterable { + resolve_callable(method.get_nanbox_f64()); + } + let fresh_array = !is_constructor_value(constructor.get_nanbox_f64()); + let len = if iterable { + 0 + } else { + array_like_length(items.get_nanbox_f64()) + }; + let result = if fresh_array { + crate::value::js_nanbox_pointer(js_array_alloc(0) as i64) + } else { + let args = [len as f64]; + unsafe { + crate::object::js_new_function_construct( + constructor.get_nanbox_f64(), + args.as_ptr(), + if iterable { 0 } else { 1 }, + ) + } + }; + let result = scope.root_nanbox_f64(result); + if !iterable { + for index in 0..len { + let value = crate::object::js_object_get_index_polymorphic( + items.get_nanbox_f64().to_bits() as i64, + index as f64, + ); + let value = if mapping { + call_map_fn( + mapfn.get_nanbox_f64(), + this_arg.get_nanbox_f64(), + value, + index, + ) + } else { + value + }; + define(&result, index, value, fresh_array); + } + return finish(&result, len, fresh_array); + } + // GetIteratorFromMethod uses the saved method even if construction mutates + // @@iterator. The iterator record likewise reads its next method only once. + let iter = invoke(&method, &items).unwrap_or_else(|error| crate::exception::js_throw(error)); + crate::symbol::js_iterator_result_validate(iter); + let iter = scope.root_nanbox_f64(iter); + let next = scope.root_nanbox_f64(get(&iter, b"next")); + let mut index = 0; + loop { + let step_scope = RuntimeHandleScope::new(); + let step = invoke(&next, &iter).unwrap_or_else(|error| crate::exception::js_throw(error)); + crate::symbol::js_iterator_result_validate(step); + let step = step_scope.root_nanbox_f64(step); + if crate::value::js_is_truthy(get(&step, b"done")) != 0 { + break; + } + let value = step_scope.root_nanbox_f64(get(&step, b"value")); + let completion = crate::collection_iter::call_capturing_throw(|| { + let mapped = if mapping { + call_map_fn( + mapfn.get_nanbox_f64(), + this_arg.get_nanbox_f64(), + value.get_nanbox_f64(), + index, + ) + } else { + value.get_nanbox_f64() + }; + define(&result, index, mapped, fresh_array); + f64::from_bits(TAG_UNDEFINED) + }); + if let Err(error) = completion { + let error = step_scope.root_nanbox_f64(error); + crate::collection_iter::iterator_close(iter.get_nanbox_f64()); + crate::exception::js_throw(error.get_nanbox_f64()); + } + index += 1; + } + finish(&result, index, fresh_array) +} diff --git a/crates/perry/tests/issue_10270_proxy_array_from.rs b/crates/perry/tests/issue_10270_proxy_array_from.rs index bc451e77cf..25de2c03da 100644 --- a/crates/perry/tests/issue_10270_proxy_array_from.rs +++ b/crates/perry/tests/issue_10270_proxy_array_from.rs @@ -59,3 +59,30 @@ fn proxy_array_from_and_headers_iterables() { ) ); } + +#[test] +fn proxy_array_from_reads_iterator_methods_once_and_closes_on_mapping_throw() { + let source = r#" +let reads = 0; +const p = new Proxy([1, 2], { get(t: any, k: any) { + if (k === Symbol.iterator) { reads++; return reads === 1 ? function() { return [4, 5].values(); } : undefined; } + return t[k]; +}}); +console.log("method", JSON.stringify(Array.from(p)), reads); +let nextReads = 0, steps = 0, closed = 0; +const it = { get next() { nextReads++; return function() { return steps++ < 2 ? {done:false, value:7} : {done:true}; }; }, return() { closed++; return {done:true}; } }; +const q = new Proxy({}, {get(t:any,k:any) { return k === Symbol.iterator ? () => it : undefined; }}); +console.log("next", JSON.stringify(Array.from(q)), nextReads); +steps = 0; +try { Array.from(q, () => { throw new Error("mapped"); }); } catch(e:any) { console.log("close",e.message,closed); } +let selected = false; +const r = new Proxy([1], {get(t:any,k:any) { if(k === Symbol.iterator) { selected = true; return function() { return [8].values(); }; } return t[k]; }}); +function C() { console.log("constructor", selected); } +const result:any = Array.from.call(C, r); +console.log("constructed", result[0], result.length); +"#; + assert_eq!( + support::compile_and_run(source), + "method [4,5] 1\nnext [7,7] 1\nclose mapped 1\nconstructor true\nconstructed 8 1\n" + ); +} From 226d03f422b64df75dc7358462427f886bdd6749 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 15 Sep 2026 05:11:55 +0200 Subject: [PATCH 6/7] chore: release merge train 194 as v0.5.1572 --- CLAUDE.md | 2 +- Cargo.lock | 162 ++++++++++++++++++++++++++--------------------------- Cargo.toml | 2 +- 3 files changed, 83 insertions(+), 83 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9b08a61640..b1bb8eb2a3 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.1571 +**Current Version:** 0.5.1572 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index 7142aaaa5d..3e72f191c2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5583,7 +5583,7 @@ checksum = "1542e48011813fbdf3c075da4a4ed53ee93c816eef62e36eb5064a6fd2be10a5" [[package]] name = "perry" -version = "0.5.1571" +version = "0.5.1572" dependencies = [ "anyhow", "base64 0.22.1", @@ -5647,7 +5647,7 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1571" +version = "0.5.1572" dependencies = [ "perry-dispatch", "serde", @@ -5655,7 +5655,7 @@ dependencies = [ [[package]] name = "perry-audio-miniaudio" -version = "0.5.1571" +version = "0.5.1572" dependencies = [ "cc", "libc", @@ -5664,7 +5664,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1571" +version = "0.5.1572" dependencies = [ "aho-corasick", "anyhow", @@ -5681,7 +5681,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1571" +version = "0.5.1572" dependencies = [ "anyhow", "perry-hir", @@ -5689,7 +5689,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1571" +version = "0.5.1572" dependencies = [ "anyhow", "perry-hir", @@ -5697,7 +5697,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1571" +version = "0.5.1572" dependencies = [ "anyhow", "perry-dispatch", @@ -5706,7 +5706,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1571" +version = "0.5.1572" dependencies = [ "anyhow", "perry-hir", @@ -5714,7 +5714,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1571" +version = "0.5.1572" dependencies = [ "anyhow", "base64 0.22.1", @@ -5726,7 +5726,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1571" +version = "0.5.1572" dependencies = [ "anyhow", "perry-hir", @@ -5734,7 +5734,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1571" +version = "0.5.1572" dependencies = [ "async-trait", "clap", @@ -5758,14 +5758,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1571" +version = "0.5.1572" dependencies = [ "anyhow", ] [[package]] name = "perry-diagnostics" -version = "0.5.1571" +version = "0.5.1572" dependencies = [ "serde", "serde_json", @@ -5773,7 +5773,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1571" +version = "0.5.1572" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5784,7 +5784,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1571" +version = "0.5.1572" dependencies = [ "anyhow", "clap", @@ -5799,7 +5799,7 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1571" +version = "0.5.1572" dependencies = [ "block2", "objc2", @@ -5809,7 +5809,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.1571" +version = "0.5.1572" dependencies = [ "argon2", "perry-ffi", @@ -5818,7 +5818,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.1571" +version = "0.5.1572" dependencies = [ "perry-ffi", "reqwest", @@ -5827,7 +5827,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1571" +version = "0.5.1572" dependencies = [ "bcrypt", "perry-ffi", @@ -5835,7 +5835,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1571" +version = "0.5.1572" dependencies = [ "perry-ffi", "rusqlite", @@ -5843,7 +5843,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1571" +version = "0.5.1572" dependencies = [ "perry-ffi", "scraper", @@ -5851,7 +5851,7 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.1571" +version = "0.5.1572" dependencies = [ "perry-ffi", "perry-runtime", @@ -5859,7 +5859,7 @@ dependencies = [ [[package]] name = "perry-ext-cron" -version = "0.5.1571" +version = "0.5.1572" dependencies = [ "chrono", "cron", @@ -5869,7 +5869,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.1571" +version = "0.5.1572" dependencies = [ "chrono", "perry-ffi", @@ -5877,7 +5877,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1571" +version = "0.5.1572" dependencies = [ "perry-ffi", "rust_decimal", @@ -5885,7 +5885,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.1571" +version = "0.5.1572" dependencies = [ "perry-ffi", "serde_json", @@ -5893,7 +5893,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1571" +version = "0.5.1572" dependencies = [ "perry-ffi", "rand 0.10.2", @@ -5901,7 +5901,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1571" +version = "0.5.1572" dependencies = [ "perry-ffi", "perry-runtime", @@ -5909,14 +5909,14 @@ dependencies = [ [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.1571" +version = "0.5.1572" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.1571" +version = "0.5.1572" dependencies = [ "bytes", "http-body-util", @@ -5933,7 +5933,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.1571" +version = "0.5.1572" dependencies = [ "bytes", "lazy_static", @@ -5946,7 +5946,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1571" +version = "0.5.1572" dependencies = [ "base64 0.22.1", "bytes", @@ -5978,7 +5978,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1571" +version = "0.5.1572" dependencies = [ "lazy_static", "perry-ffi", @@ -5988,7 +5988,7 @@ dependencies = [ [[package]] name = "perry-ext-jsonwebtoken" -version = "0.5.1571" +version = "0.5.1572" dependencies = [ "base64 0.22.1", "jsonwebtoken", @@ -5999,7 +5999,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.1571" +version = "0.5.1572" dependencies = [ "lru", "perry-ffi", @@ -6008,7 +6008,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.1571" +version = "0.5.1572" dependencies = [ "chrono", "perry-ffi", @@ -6016,7 +6016,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1571" +version = "0.5.1572" dependencies = [ "bson", "futures-util", @@ -6028,7 +6028,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.1571" +version = "0.5.1572" dependencies = [ "chrono", "perry-ffi", @@ -6040,7 +6040,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.1571" +version = "0.5.1572" dependencies = [ "nanoid", "perry-ffi", @@ -6049,7 +6049,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1571" +version = "0.5.1572" dependencies = [ "bytes", "perry-ffi", @@ -6064,7 +6064,7 @@ dependencies = [ [[package]] name = "perry-ext-node-forge" -version = "0.5.1571" +version = "0.5.1572" dependencies = [ "const-oid 0.10.2", "der 0.8.1", @@ -6083,7 +6083,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1571" +version = "0.5.1572" dependencies = [ "lettre", "perry-ffi", @@ -6093,7 +6093,7 @@ dependencies = [ [[package]] name = "perry-ext-parcel-watcher" -version = "0.5.1571" +version = "0.5.1572" dependencies = [ "notify", "perry-ffi", @@ -6105,7 +6105,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1571" +version = "0.5.1572" dependencies = [ "perry-ffi", "printpdf", @@ -6113,7 +6113,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.1571" +version = "0.5.1572" dependencies = [ "perry-ffi", "sqlx", @@ -6122,7 +6122,7 @@ dependencies = [ [[package]] name = "perry-ext-qs" -version = "0.5.1571" +version = "0.5.1572" dependencies = [ "perry-ffi", "perry-runtime", @@ -6131,7 +6131,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.1571" +version = "0.5.1572" dependencies = [ "governor", "perry-ffi", @@ -6139,7 +6139,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1571" +version = "0.5.1572" dependencies = [ "fast_image_resize", "image", @@ -6150,7 +6150,7 @@ dependencies = [ [[package]] name = "perry-ext-streams" -version = "0.5.1571" +version = "0.5.1572" dependencies = [ "lazy_static", "perry-ffi", @@ -6159,7 +6159,7 @@ dependencies = [ [[package]] name = "perry-ext-typescript" -version = "0.5.1571" +version = "0.5.1572" dependencies = [ "anyhow", "perry-ffi", @@ -6179,7 +6179,7 @@ dependencies = [ [[package]] name = "perry-ext-undici" -version = "0.5.1571" +version = "0.5.1572" dependencies = [ "perry-ffi", "perry-runtime", @@ -6188,7 +6188,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.1571" +version = "0.5.1572" dependencies = [ "perry-ffi", "uuid", @@ -6196,7 +6196,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.1571" +version = "0.5.1572" dependencies = [ "perry-ffi", "perry-validation", @@ -6205,7 +6205,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1571" +version = "0.5.1572" dependencies = [ "futures-util", "lazy_static", @@ -6218,7 +6218,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1571" +version = "0.5.1572" dependencies = [ "brotli", "flate2", @@ -6228,7 +6228,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1571" +version = "0.5.1572" dependencies = [ "dashmap 6.2.1", "once_cell", @@ -6238,7 +6238,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1571" +version = "0.5.1572" dependencies = [ "anyhow", "perry-api-manifest", @@ -6258,11 +6258,11 @@ dependencies = [ [[package]] name = "perry-native-registration" -version = "0.5.1571" +version = "0.5.1572" [[package]] name = "perry-parser" -version = "0.5.1571" +version = "0.5.1572" dependencies = [ "anyhow", "perry-diagnostics", @@ -6275,7 +6275,7 @@ dependencies = [ [[package]] name = "perry-perex" -version = "0.5.1571" +version = "0.5.1572" dependencies = [ "perex", "regex", @@ -6283,7 +6283,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1571" +version = "0.5.1572" dependencies = [ "ahash", "base64 0.22.1", @@ -6341,14 +6341,14 @@ dependencies = [ [[package]] name = "perry-runtime-static" -version = "0.5.1571" +version = "0.5.1572" dependencies = [ "perry-runtime", ] [[package]] name = "perry-stdlib" -version = "0.5.1571" +version = "0.5.1572" dependencies = [ "aes 0.8.4", "aes 0.9.1", @@ -6437,21 +6437,21 @@ dependencies = [ [[package]] name = "perry-stdlib-static" -version = "0.5.1571" +version = "0.5.1572" dependencies = [ "perry-stdlib", ] [[package]] name = "perry-transform" -version = "0.5.1571" +version = "0.5.1572" dependencies = [ "perry-hir", ] [[package]] name = "perry-ui" -version = "0.5.1571" +version = "0.5.1572" dependencies = [ "dirs", "perry-ffi", @@ -6461,7 +6461,7 @@ dependencies = [ [[package]] name = "perry-ui-android" -version = "0.5.1571" +version = "0.5.1572" dependencies = [ "base64 0.22.1", "jni", @@ -6476,7 +6476,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1571" +version = "0.5.1572" dependencies = [ "rand 0.10.2", "serde", @@ -6486,7 +6486,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1571" +version = "0.5.1572" dependencies = [ "base64 0.22.1", "cairo-rs 0.22.9", @@ -6509,7 +6509,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1571" +version = "0.5.1572" dependencies = [ "base64 0.22.1", "block2", @@ -6526,7 +6526,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1571" +version = "0.5.1572" dependencies = [ "base64 0.22.1", "block2", @@ -6543,7 +6543,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1571" +version = "0.5.1572" [[package]] name = "perry-ui-test" @@ -6554,11 +6554,11 @@ dependencies = [ [[package]] name = "perry-ui-testkit" -version = "0.5.1571" +version = "0.5.1572" [[package]] name = "perry-ui-tvos" -version = "0.5.1571" +version = "0.5.1572" dependencies = [ "base64 0.22.1", "block2", @@ -6575,7 +6575,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1571" +version = "0.5.1572" dependencies = [ "base64 0.22.1", "block2", @@ -6592,7 +6592,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1571" +version = "0.5.1572" dependencies = [ "block2", "libc", @@ -6606,7 +6606,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1571" +version = "0.5.1572" dependencies = [ "base64 0.22.1", "libc", @@ -6625,7 +6625,7 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1571" +version = "0.5.1572" dependencies = [ "base64 0.22.1", "libc", @@ -6638,7 +6638,7 @@ dependencies = [ [[package]] name = "perry-updater" -version = "0.5.1571" +version = "0.5.1572" dependencies = [ "anyhow", "base64 0.22.1", @@ -6653,7 +6653,7 @@ dependencies = [ [[package]] name = "perry-validation" -version = "0.5.1571" +version = "0.5.1572" dependencies = [ "idna", "regex", @@ -6663,7 +6663,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1571" +version = "0.5.1572" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index 1703902254..3735806113 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -338,7 +338,7 @@ codegen-units = 1 codegen-units = 1 [workspace.package] -version = "0.5.1571" +version = "0.5.1572" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry" From a2bac2d00f826bddeb1fc6a69820ad07d00d3013 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 15 Sep 2026 06:40:23 +0200 Subject: [PATCH 7/7] perf(fetch): pass an existing Headers handle straight to Request The dynamic RequestInit.headers path built a temporary Headers store for every input and copied the source into it, even when the input was already a Headers handle; the Request constructor then cloned that store again. Measured on train194 at +6.3% CPU and +6.0% peak RSS for 30,000 Request constructions from an existing Headers. js_headers_from_value returns a registered, non-Proxy Headers handle as is and builds a new store only for records, iterables, Proxies, undefined and null. The Request constructor clones the entries in every case, so the Request's headers stay independent of the source; the regression test now mutates both sides after construction. --- changelog.d/10280-proxy-value-shapes.md | 5 ++++ .../src/lower_call/options/mod.rs | 12 +++------ .../src/runtime_decls/strings_part2.rs | 1 + crates/perry-runtime/src/stdlib_stubs.rs | 7 ++++++ crates/perry-stdlib/src/fetch/headers.rs | 25 +++++++++++++++++++ .../compile/strip_dedup/stub_symbols.rs | 1 + .../issue_10274_request_proxy_headers.rs | 10 ++++++++ 7 files changed, 53 insertions(+), 8 deletions(-) diff --git a/changelog.d/10280-proxy-value-shapes.md b/changelog.d/10280-proxy-value-shapes.md index f605cb41ef..195cd7b323 100644 --- a/changelog.d/10280-proxy-value-shapes.md +++ b/changelog.d/10280-proxy-value-shapes.md @@ -17,3 +17,8 @@ Fix Proxy values in `Array.from` and dynamic `RequestInit.headers` (#10270, Read a Proxy source’s iterator method once before constructing the result, and cache the iterator’s next method. Keep the method, iterator, intermediate values, and pending mapping errors rooted across user callbacks and iterator closing. + +Pass an existing Headers handle straight to the Request constructor, which +already clones its entries, instead of copying it into a temporary Headers +store first. Records, iterables, Proxies, `undefined` and `null` still take the +full conversion path. diff --git a/crates/perry-codegen/src/lower_call/options/mod.rs b/crates/perry-codegen/src/lower_call/options/mod.rs index e9492daaf3..4574cbaa12 100644 --- a/crates/perry-codegen/src/lower_call/options/mod.rs +++ b/crates/perry-codegen/src/lower_call/options/mod.rs @@ -75,15 +75,11 @@ pub(in crate::lower_call) fn build_headers_from_value( init: &Expr, ) -> Result { with_rooted_group(ctx, 1, |ctx, group| { - let h = ctx.block().call(DOUBLE, "js_headers_new", &[]); - let h_root = group.adopt_emitted(ctx, Repr::Boxed, &h, true); let value = lower_expr(ctx, init)?; - let h = group.reread_emitted(ctx, h_root); - ctx.block().call( - DOUBLE, - "js_headers_init_from_value", - &[(DOUBLE, &h), (DOUBLE, &value)], - ); + let h = ctx + .block() + .call(DOUBLE, "js_headers_from_value", &[(DOUBLE, &value)]); + let h_root = group.adopt_emitted(ctx, Repr::Boxed, &h, true); Ok(group.reread_emitted(ctx, h_root)) }) } diff --git a/crates/perry-codegen/src/runtime_decls/strings_part2.rs b/crates/perry-codegen/src/runtime_decls/strings_part2.rs index 14d9ac9516..0316269426 100644 --- a/crates/perry-codegen/src/runtime_decls/strings_part2.rs +++ b/crates/perry-codegen/src/runtime_decls/strings_part2.rs @@ -970,6 +970,7 @@ pub(crate) fn declare_phase_b_strings_part2(module: &mut LlModule) { module.declare_function("js_response_body_init_ptr", I64, &[DOUBLE]); // new Headers() -> f64 module.declare_function("js_headers_new", DOUBLE, &[]); + module.declare_function("js_headers_from_value", DOUBLE, &[DOUBLE]); // headers.set(handle_f64, key_ptr, val_ptr) -> f64 (undefined-tag) module.declare_function("js_headers_set", DOUBLE, &[DOUBLE, I64, I64]); // headers.append(handle_f64, key_ptr, val_ptr) -> f64 (undefined-tag) diff --git a/crates/perry-runtime/src/stdlib_stubs.rs b/crates/perry-runtime/src/stdlib_stubs.rs index dd9fa2fa3e..7779a1ab34 100644 --- a/crates/perry-runtime/src/stdlib_stubs.rs +++ b/crates/perry-runtime/src/stdlib_stubs.rs @@ -192,6 +192,13 @@ pub extern "C" fn js_headers_new() -> f64 { f64::from_bits(crate::value::TAG_UNDEFINED) } +#[cfg(not(feature = "external-fetch-symbols"))] +#[no_mangle] +pub extern "C" fn js_headers_from_value(_init: f64) -> f64 { + perry_stub_warn("js_headers_from_value", FETCH_REASON, None); + f64::from_bits(crate::value::TAG_UNDEFINED) +} + #[cfg(not(feature = "external-fetch-symbols"))] #[no_mangle] pub extern "C" fn js_headers_init_from_value(_handle: f64, _init: f64) -> f64 { diff --git a/crates/perry-stdlib/src/fetch/headers.rs b/crates/perry-stdlib/src/fetch/headers.rs index 5ab77f3d50..1192b2dcc4 100644 --- a/crates/perry-stdlib/src/fetch/headers.rs +++ b/crates/perry-stdlib/src/fetch/headers.rs @@ -21,6 +21,31 @@ pub extern "C" fn js_headers_new() -> f64 { handle_to_f64(alloc_headers(HeadersStore::default())) } +/// Normalize a runtime `HeadersInit` value to a Headers handle. +/// +/// A genuine Headers handle is already in the representation consumed by the +/// Request constructor, which clones its entries into the Request record. Keep +/// that handle instead of allocating and filling a redundant intermediate +/// store. Records, iterables, and Proxies still take the full constructor path. +#[no_mangle] +pub extern "C" fn js_headers_from_value(init: f64) -> f64 { + let scope = perry_runtime::gc::RuntimeHandleScope::new(); + let init = scope.root_nanbox_f64(init); + let current = init.get_nanbox_f64(); + if perry_runtime::proxy::js_proxy_is_proxy(current) == 0 { + let id = handle_id(current); + if HEADERS_REGISTRY.lock().unwrap().contains_key(&id) { + return current; + } + } + + let handle = js_headers_new(); + unsafe { + js_headers_init_from_value(handle, init.get_nanbox_f64()); + } + handle +} + unsafe fn header_init_string(value: f64) -> String { let ptr = perry_runtime::value::js_jsvalue_to_string(value); string_from_header(ptr as *const StringHeader).unwrap_or_default() diff --git a/crates/perry/src/commands/compile/strip_dedup/stub_symbols.rs b/crates/perry/src/commands/compile/strip_dedup/stub_symbols.rs index 35b22442f6..762f0d5376 100644 --- a/crates/perry/src/commands/compile/strip_dedup/stub_symbols.rs +++ b/crates/perry/src/commands/compile/strip_dedup/stub_symbols.rs @@ -23,6 +23,7 @@ const STDLIB_STUB_SYMBOLS: &[&str] = &[ "js_fetch_with_options", "js_blob_new", "js_headers_new", + "js_headers_from_value", "js_headers_init_from_value", "js_request_new", "js_response_new", diff --git a/crates/perry/tests/issue_10274_request_proxy_headers.rs b/crates/perry/tests/issue_10274_request_proxy_headers.rs index c827641acb..de3c09438d 100644 --- a/crates/perry/tests/issue_10274_request_proxy_headers.rs +++ b/crates/perry/tests/issue_10274_request_proxy_headers.rs @@ -20,6 +20,13 @@ console.log("record", JSON.stringify(dump(make({ "x-a": "1" })))) console.log("pairs", JSON.stringify(dump(make([["x-a", "1"], ["x-a", "2"]])))) console.log("handle", JSON.stringify(dump(make(new Headers({ "x-a": "1" }))))) console.log("literal", JSON.stringify(dump(new Request("https://x.dev", { headers: { "x-a": "1" } }).headers))) +const source = new Headers({ "x-a": "1" }) +const copied = make(source) +source.set("x-b", "2") +copied.set("x-c", "3") +console.log("independent", JSON.stringify(dump(source)), JSON.stringify(dump(copied)), copied !== source) +console.log("absent", JSON.stringify(dump(make(undefined)))) +try { make(null); console.log("null accepted") } catch (e) { console.log("null", e instanceof TypeError) } "#; #[test] @@ -36,6 +43,9 @@ fn request_converts_dynamic_headers_init() { "pairs [\"x-a=1, 2\"]\n", "handle [\"x-a=1\"]\n", "literal [\"x-a=1\"]\n", + "independent [\"x-a=1\",\"x-b=2\"] [\"x-a=1\",\"x-c=3\"] true\n", + "absent []\n", + "null true\n", ) ); }