From f481df24c3a5b9e93797455b76f6c85a3c5f0cc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 4 Sep 2026 05:40:13 +0000 Subject: [PATCH 1/2] fix(runtime): support proxy-wrapped array callbacks --- .../perry-runtime/src/array/iter_methods.rs | 13 ++- crates/perry-runtime/src/array/sort.rs | 24 +++++- .../test_gap_9681_proxy_array_callbacks.ts | 83 +++++++++++++++++++ 3 files changed, 115 insertions(+), 5 deletions(-) create mode 100644 test-files/test_gap_9681_proxy_array_callbacks.ts diff --git a/crates/perry-runtime/src/array/iter_methods.rs b/crates/perry-runtime/src/array/iter_methods.rs index 5684512767..9a8c6ef6e7 100644 --- a/crates/perry-runtime/src/array/iter_methods.rs +++ b/crates/perry-runtime/src/array/iter_methods.rs @@ -1429,15 +1429,22 @@ fn typeof_owned_string(v: f64) -> String { /// Resolve a higher-order callback argument to its `ClosureHeader*` (as /// `i64`). Returns `Some(ptr)` only for values the runtime can actually -/// invoke (real closures, bound methods/functions); `None` for any -/// non-callable so the caller can throw the spec `TypeError`. +/// invoke (real closures, bound methods/functions, callable Proxies); `None` +/// for any non-callable so the caller can throw the spec `TypeError`. #[inline] fn resolve_callback_ptr(cb_boxed: f64) -> Option { use crate::value::JSValue; let jv = JSValue::from_bits(cb_boxed.to_bits()); if jv.is_pointer() { let ptr = jv.as_pointer::(); - if !crate::closure::get_valid_func_ptr(ptr).is_null() { + // #9681: a callable Proxy is a small registry id, so the hardened + // closure validator correctly rejects it as a ClosureHeader. Return + // that bare id anyway: DirectCallN falls back to js_closure_callN, + // whose proxy-callee path performs the Proxy [[Call]]. + if !crate::closure::get_valid_func_ptr(ptr).is_null() + || (crate::proxy::js_proxy_is_proxy(cb_boxed) == 1 + && crate::proxy::proxy_wraps_callable(cb_boxed)) + { return Some(ptr as i64); } } diff --git a/crates/perry-runtime/src/array/sort.rs b/crates/perry-runtime/src/array/sort.rs index 09775fbbe2..8d7b410c38 100644 --- a/crates/perry-runtime/src/array/sort.rs +++ b/crates/perry-runtime/src/array/sort.rs @@ -601,10 +601,21 @@ pub extern "C" fn js_validate_array_comparator(cmp_boxed: f64) -> i64 { if jv.is_undefined() { return 0; } - // Callable function -> comparator path. + // Callable closure or callable Proxy -> comparator path. Use the shared + // validator rather than probing CLOSURE_MAGIC directly: Proxy values are + // small registry ids, not dereferenceable ClosureHeader pointers. if jv.is_pointer() { let ptr = jv.as_pointer::(); - if !ptr.is_null() && unsafe { (*ptr).type_tag == crate::closure::CLOSURE_MAGIC } { + if !crate::closure::get_valid_func_ptr(ptr).is_null() { + return ptr as i64; + } + // #9681: DirectCall2 deliberately falls back to js_closure_call2 for + // this bare proxy id, which then performs the Proxy [[Call]]. Check + // callability here so a Proxy of a non-callable still throws before + // sorting starts. + if crate::proxy::js_proxy_is_proxy(cmp_boxed) == 1 + && crate::proxy::proxy_wraps_callable(cmp_boxed) + { return ptr as i64; } } @@ -634,6 +645,15 @@ fn throw_invalid_comparator(cmp_boxed: f64) -> ! { } } }; + // V8's diagnostic renderer uses # for an ordinary object (and a + // transparent Proxy of one), while JavaScript ToString yields + // [object Object]. Keep the existing ToString spellings for arrays, + // symbols, primitives, and other object kinds. + let value_str = if value_str == "[object Object]" { + "#".to_string() + } else { + value_str + }; let message = format!( "The comparison function must be either a function or undefined: {}", value_str diff --git a/test-files/test_gap_9681_proxy_array_callbacks.ts b/test-files/test_gap_9681_proxy_array_callbacks.ts new file mode 100644 index 0000000000..cea0f336ec --- /dev/null +++ b/test-files/test_gap_9681_proxy_array_callbacks.ts @@ -0,0 +1,83 @@ +// #9681: Array callback validation must recognize callable Proxy values +// without dereferencing their small registry ids as ClosureHeader pointers. +// +// Exercise every Array.prototype higher-order method plus sort with a plain +// function, an empty-handler Proxy of that function, a bound function, and a +// genuine non-callable. A Proxy of that non-callable pins the callability +// boundary too. Both invalid forms also pin Node's TypeError text. + +const source = [3, 1, 2]; + +function render(value: any): string { + const json = JSON.stringify(value); + return json === undefined ? "undefined" : json; +} + +function exercise(label: string, callback: any, invoke: any): void { + const names = ["plain", "proxy", "bound", "non-callable", "non-callable proxy"]; + const callbacks = [ + callback, + new Proxy(callback, {}), + callback.bind(null), + {}, + new Proxy({}, {}), + ]; + + for (let i = 0; i < callbacks.length; i++) { + try { + console.log(label + "/" + names[i] + ": " + render(invoke(callbacks[i]))); + } catch (e: any) { + console.log(label + "/" + names[i] + ": " + e.name + ": " + e.message); + } + } +} + +let forEachTotal = 0; +exercise( + "forEach", + (value: number) => { + forEachTotal += value; + }, + (callback: any) => { + forEachTotal = 0; + source.forEach(callback); + return forEachTotal; + }, +); + +exercise("map", (value: number, index: number) => value + index, (callback: any) => + source.map(callback), +); +exercise("filter", (value: number) => value > 1, (callback: any) => + source.filter(callback), +); +exercise("some", (value: number) => value === 1, (callback: any) => + source.some(callback), +); +exercise("every", (value: number) => value > 0, (callback: any) => + source.every(callback), +); +exercise("find", (value: number) => value < 3, (callback: any) => + source.find(callback), +); +exercise("findIndex", (value: number) => value < 3, (callback: any) => + source.findIndex(callback), +); +exercise("findLast", (value: number) => value < 3, (callback: any) => + source.findLast(callback), +); +exercise("findLastIndex", (value: number) => value < 3, (callback: any) => + source.findLastIndex(callback), +); +exercise("flatMap", (value: number) => [value, value * 10], (callback: any) => + source.flatMap(callback), +); +exercise("reduce", (acc: number, value: number) => acc + value, (callback: any) => + source.reduce(callback, 10), +); +exercise("reduceRight", (acc: number, value: number) => acc + value, (callback: any) => + source.reduceRight(callback, 10), +); +exercise("sort", (a: number, b: number) => a - b, (callback: any) => + [3, 1, 2].sort(callback), +); From adbe949e895ba2a99682cacd177c13ab23c7aac1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 4 Sep 2026 05:41:41 +0000 Subject: [PATCH 2/2] docs(changelog): note proxy array callback fix --- changelog.d/9688-proxy-array-callbacks.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 changelog.d/9688-proxy-array-callbacks.md diff --git a/changelog.d/9688-proxy-array-callbacks.md b/changelog.d/9688-proxy-array-callbacks.md new file mode 100644 index 0000000000..727270b538 --- /dev/null +++ b/changelog.d/9688-proxy-array-callbacks.md @@ -0,0 +1,6 @@ +Array iteration methods and sorting now accept callable `Proxy` callbacks, +matching Node for proxy-wrapped functions and bound functions. + +Comparator validation no longer treats a Proxy registry handle as a closure +pointer, preventing a user-triggerable crash. Non-callable callbacks and +comparators continue to throw Node-compatible `TypeError`s.