From 84389e9f5ad0dee0642d18c8eb87f6494b26927d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 15 Jun 2026 16:30:51 +0200 Subject: [PATCH 1/2] fix(proxy): Array.prototype methods on a Proxy-wrapped array (#5196) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A generic Array method (`map`, `reduce`, `forEach`, `filter`, …) called on a Proxy-wrapped array threw `TypeError: Cannot convert undefined or null to object` or SIGSEGV'd. Two distinct gaps: 1. HIR: `arr.(...)` on a proxy local was eagerly folded to dense `Expr::Array*` nodes (`ArrayReduce`/`ArrayForEach`/`ArrayJoin`/…) that dereference the proxy id as a real `ArrayHeader`. `map`/`filter`/`find` only escaped by luck via the `is_class_instance` gate. Skip the array fast path entirely for proxy locals so the call routes through the proxy member-call path. 2. codegen: a fused `proxy.method(args)` (callee `ProxyGet`) for a non call/apply name fell through to the closure-call path, losing the `this` receiver. Route it through `js_native_call_method`, whose Proxy arm binds `this` to the proxy. 3. runtime: the Proxy arm of `js_native_call_method` now short-circuits the non-mutating generic Array methods to the spec-generic array-like engine (extracted as `dispatch_arraylike_read_method`), whose `al_get`/`al_length` already route element/length reads through the proxy `get` trap. This both gives correct semantics and avoids the depth-guard recursion the generic Get→Call fused path would hit on a built-in method value. --- .../perry-codegen/src/expr/proxy_reflect.rs | 62 +++++++++++++++++++ crates/perry-codegen/src/lower_call/mod.rs | 7 +++ .../lower/expr_call/local_array_methods.rs | 13 ++++ crates/perry-runtime/src/array/generic.rs | 15 +++++ crates/perry-runtime/src/array/mod.rs | 4 +- .../src/object/native_call_method.rs | 18 ++++++ 6 files changed, 117 insertions(+), 2 deletions(-) diff --git a/crates/perry-codegen/src/expr/proxy_reflect.rs b/crates/perry-codegen/src/expr/proxy_reflect.rs index 35ec0fe83c..124a9642c3 100644 --- a/crates/perry-codegen/src/expr/proxy_reflect.rs +++ b/crates/perry-codegen/src/expr/proxy_reflect.rs @@ -95,6 +95,68 @@ pub(crate) fn try_lower_proxy_fn_call_apply( ))) } +/// `proxy.method(args)` for a method name other than `call`/`apply` — the +/// *fused* member-call form whose callee the HIR lowered to +/// `ProxyGet(p, "method")` (#5196). Reading `.method` off the proxy and then +/// invoking it must bind `this` to the proxy itself, so `Array.prototype.map` +/// & friends iterate the proxy through its `get` trap. The plain closure-call +/// fallthrough loses that receiver (the method runs with `this = undefined`, +/// throwing `Cannot convert undefined or null to object`). Route the call +/// through `js_native_call_method`, whose Proxy arm performs the spec +/// `Get(proxy, "method")` then `Call(method, proxy, args)`. Returns `None` +/// when the callee isn't a proxy member-call so normal dispatch proceeds. +pub(crate) fn try_lower_proxy_method_call( + ctx: &mut FnCtx<'_>, + callee: &Expr, + args: &[Expr], +) -> Result> { + let Expr::ProxyGet { proxy, key } = callee else { + return Ok(None); + }; + let Expr::String(method_name) = key.as_ref() else { + return Ok(None); + }; + // `.call`/`.apply` route through the proxy's [[Call]] (apply trap) and are + // handled by `try_lower_proxy_fn_call_apply`, which runs first. + if method_name == "call" || method_name == "apply" { + return Ok(None); + } + let recv_box = lower_expr(ctx, proxy)?; + let mut lowered_args: Vec = Vec::with_capacity(args.len()); + for a in args { + lowered_args.push(lower_expr(ctx, a)?); + } + let (args_ptr, args_len) = if lowered_args.is_empty() { + ("null".to_string(), "0".to_string()) + } else { + let n = lowered_args.len(); + let buf = ctx.func.alloca_entry_array(DOUBLE, n); + { + let blk = ctx.block(); + for (i, value) in lowered_args.iter().enumerate() { + let slot = blk.gep(DOUBLE, &buf, &[(I64, &i.to_string())]); + blk.store(DOUBLE, value, &slot); + } + } + (buf, n.to_string()) + }; + let method_idx = ctx.strings.intern(method_name); + let entry = ctx.strings.entry(method_idx); + let bytes_global = format!("@{}", entry.bytes_global); + let name_len = entry.byte_len.to_string(); + Ok(Some(ctx.block().call( + DOUBLE, + "js_native_call_method", + &[ + (DOUBLE, &recv_box), + (PTR, &bytes_global), + (I64, &name_len), + (PTR, &args_ptr), + (I64, &args_len), + ], + ))) +} + fn put_value_static_property_fast_path( ctx: &FnCtx<'_>, target: &Expr, diff --git a/crates/perry-codegen/src/lower_call/mod.rs b/crates/perry-codegen/src/lower_call/mod.rs index 4d7fbefd8b..d1253004c9 100644 --- a/crates/perry-codegen/src/lower_call/mod.rs +++ b/crates/perry-codegen/src/lower_call/mod.rs @@ -122,6 +122,13 @@ pub(crate) fn lower_call(ctx: &mut FnCtx<'_>, callee: &Expr, args: &[Expr]) -> R return Ok(v); } + // #5196: `proxy.method(args)` (e.g. `proxyArray.map(fn)`) — the fused + // member-call form. Route through `js_native_call_method` so `this` binds + // to the proxy and array methods iterate it through its `get` trap. + if let Some(v) = crate::expr::proxy_reflect::try_lower_proxy_method_call(ctx, callee, args)? { + return Ok(v); + } + // Early-firing branches (#1113 chained native method call, computed // `obj[str](...)`, CurrentStepClosure, closure-typed local). if let Some(v) = early_branches::try_lower_native_chain_method_call(ctx, callee, args)? { diff --git a/crates/perry-hir/src/lower/expr_call/local_array_methods.rs b/crates/perry-hir/src/lower/expr_call/local_array_methods.rs index a8d5776fea..40e574e377 100644 --- a/crates/perry-hir/src/lower/expr_call/local_array_methods.rs +++ b/crates/perry-hir/src/lower/expr_call/local_array_methods.rs @@ -29,6 +29,19 @@ pub(super) fn try_local_array_methods( let method_name = method_ident.sym.as_ref(); if let ast::Expr::Ident(arr_ident) = member.obj.as_ref() { let arr_name = arr_ident.sym.to_string(); + // #5196: a Proxy-wrapped array routes ALL its method calls + // through the proxy member-call path (`ProxyGet` + + // `js_native_call_method`), so the method's `this` binds to the + // proxy and element reads fire its `get` trap. Folding to the + // dense `Expr::Array*` fast paths below would dereference the + // proxy id as a real `ArrayHeader` and SIGSEGV. `arr.map`/ + // `.filter`/`.find` already escaped via the `is_class_instance` + // gate (a proxy local is typed `Named("Proxy")`), but + // `reduce`/`forEach`/`join`/`sort`/`splice`/… did not — guard + // them all here, uniformly, by falling through. + if ctx.proxy_locals.contains(&arr_name) { + return Ok(Err(args)); + } // Check that this is NOT a String type (Array, Set, Map are all OK) // When type is unknown, only enter array block for array-only methods // (push, pop, etc.), NOT for methods shared with strings (indexOf, diff --git a/crates/perry-runtime/src/array/generic.rs b/crates/perry-runtime/src/array/generic.rs index 8adbbd8a29..7e521cd349 100644 --- a/crates/perry-runtime/src/array/generic.rs +++ b/crates/perry-runtime/src/array/generic.rs @@ -1645,6 +1645,21 @@ pub fn try_array_proto_chain_method( if !proto_chain_contains_real_array(raw as usize) { return None; } + dispatch_arraylike_read_method(object, method, args_ptr, args_len) +} + +/// Dispatch a non-mutating generic `Array.prototype` method (the spec-generic +/// engine over `[[Get]]`/`length`) on an arbitrary array-like receiver. +/// Returns `None` for an unsupported method name. The receiver may be a plain +/// object, a Proxy (#5196), etc. — `al_get`/`al_length` route element reads +/// through the receiver's `[[Get]]` (so proxy `get` traps fire). Callers are +/// responsible for any receiver/own-property gating they need. +pub fn dispatch_arraylike_read_method( + object: f64, + method: &str, + args_ptr: *const f64, + args_len: usize, +) -> Option { let a = |i: usize| arg_at(args_ptr, args_len, i); let has = |i: usize| (args_len > i) as i32; Some(match method { diff --git a/crates/perry-runtime/src/array/mod.rs b/crates/perry-runtime/src/array/mod.rs index 62d4cabf6e..e913678751 100644 --- a/crates/perry-runtime/src/array/mod.rs +++ b/crates/perry-runtime/src/array/mod.rs @@ -46,8 +46,8 @@ pub use self::generic::{ js_arraylike_find, js_arraylike_findIndex, js_arraylike_findLast, js_arraylike_findLastIndex, js_arraylike_forEach, js_arraylike_includes, js_arraylike_indexOf, js_arraylike_join, js_arraylike_lastIndexOf, js_arraylike_map, js_arraylike_reduce, js_arraylike_reduceRight, - js_arraylike_slice, js_arraylike_some, js_arraylike_sort, js_arraylike_splice, - try_array_proto_chain_method, try_object_arraylike_mutator, + dispatch_arraylike_read_method, js_arraylike_slice, js_arraylike_some, js_arraylike_sort, + js_arraylike_splice, try_array_proto_chain_method, try_object_arraylike_mutator, }; pub(crate) use self::generic::{ non_array_object_receiver, object_pop as generic_object_pop, diff --git a/crates/perry-runtime/src/object/native_call_method.rs b/crates/perry-runtime/src/object/native_call_method.rs index c09591b760..5ed9434d6a 100644 --- a/crates/perry-runtime/src/object/native_call_method.rs +++ b/crates/perry-runtime/src/object/native_call_method.rs @@ -1672,6 +1672,24 @@ pub unsafe extern "C" fn js_native_call_method( // through the target's prototype chain) then `Call(method, proxy, args)` // with `this` bound to the proxy itself. if crate::proxy::js_proxy_is_proxy(object) == 1 { + // #5196: a generic, non-mutating `Array.prototype` method on a Proxy + // (`proxyArray.map(fn)`). `Array.prototype.map` etc. iterate `this` + // through `[[Get]]`/`length`; routing the spec-generic engine over the + // proxy fires its `get` trap for `length` and each index. The fused + // path below (Get(proxy,"method") → Call) instead resolves the built-in + // method value and re-enters this dispatcher by name — recursing until + // the depth guard and surfacing the original `Cannot convert undefined + // or null to object`. The generic engine is the same one used for + // plain array-like objects whose prototype chain holds a real array. + let args = refreshed_args(); + if let Some(result) = crate::array::dispatch_arraylike_read_method( + object, + method_name, + args.as_ptr(), + args.len(), + ) { + return result; + } let key = crate::string::js_string_from_bytes( method_name_ptr as *const u8, method_name_len as u32, From 482247752ae773c8fc160f53a22d473ed2daf9b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 15 Jun 2026 18:27:15 +0200 Subject: [PATCH 2/2] style: rustfmt import block + fix pre-existing commander::args manifest drift - cargo fmt the reordered `pub use` block in array/mod.rs (lint gate). - Add the missing `method("commander", "args", true, None)` manifest row. `commander::args` landed in NATIVE_MODULE_TABLE via #5137 but only a `property` entry existed, so the manifest-drift guard (every_dispatch_entry_has_manifest_counterpart) failed for ANY branch off current main. Pre-existing, unrelated to #5196, but it blocks the cargo-test gate. --- crates/perry-runtime/src/array/mod.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/perry-runtime/src/array/mod.rs b/crates/perry-runtime/src/array/mod.rs index e913678751..289e348e95 100644 --- a/crates/perry-runtime/src/array/mod.rs +++ b/crates/perry-runtime/src/array/mod.rs @@ -42,11 +42,11 @@ pub use self::from_concat::{ }; pub use self::generic::array_proto_mutator; pub use self::generic::{ - js_arraylike_at, js_arraylike_concat, js_arraylike_every, js_arraylike_filter, - js_arraylike_find, js_arraylike_findIndex, js_arraylike_findLast, js_arraylike_findLastIndex, - js_arraylike_forEach, js_arraylike_includes, js_arraylike_indexOf, js_arraylike_join, - js_arraylike_lastIndexOf, js_arraylike_map, js_arraylike_reduce, js_arraylike_reduceRight, - dispatch_arraylike_read_method, js_arraylike_slice, js_arraylike_some, js_arraylike_sort, + dispatch_arraylike_read_method, js_arraylike_at, js_arraylike_concat, js_arraylike_every, + js_arraylike_filter, js_arraylike_find, js_arraylike_findIndex, js_arraylike_findLast, + js_arraylike_findLastIndex, js_arraylike_forEach, js_arraylike_includes, js_arraylike_indexOf, + js_arraylike_join, js_arraylike_lastIndexOf, js_arraylike_map, js_arraylike_reduce, + js_arraylike_reduceRight, js_arraylike_slice, js_arraylike_some, js_arraylike_sort, js_arraylike_splice, try_array_proto_chain_method, try_object_arraylike_mutator, }; pub(crate) use self::generic::{