From 2e0852e297f80cd4d081b98e67fb1442849ce006 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 13 Jul 2026 07:39:00 +0200 Subject: [PATCH] fix(runtime): route Proxy values through [[Call]] in the ad-hoc closure probes (#6320) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #5976/#6321. Ad-hoc CLOSURE_MAGIC / GcHeader probes still floored the candidate address at 0x1000 (or 0x10000) — an order of magnitude below HANDLE_BAND_MAX (0x100000). A Proxy is a NaN-boxed registry id (POINTER_TAG | PROXY_ID_BAND_START + id), so storing one where a closure is expected let the probe read *(0xF000D + 12) — unmapped low memory — and SIGSEGV (EXC_BAD_ACCESS at 0x000f000d). Confirmed against a pristine origin/main build: both fixtures below exit 139 there and 0 here. Unlike #5976, these sites have no proxy-aware fallback below them, so a band guard alone would stop the crash without dispatching the trap. Each site now routes a proxy through the Proxy [[Call]] path, matching node: * symbol/iterator.rs (js_to_primitive): `obj[Symbol.toPrimitive] = new Proxy(fn, {})` calls the proxy with `this` bound to the object and the hint string as its single argument — apply trap honored, revoked proxy throws TypeError. New proxy::call_proxy_value_with_this builds the spec argArray with every operand rooted across the allocations. * jsx.rs (function component): `

` where `P = new Proxy(Component, {})` dispatches through js_native_call_value's proxy branch; a proxy over a non-callable target is not a component (undefined). * symbol/properties.rs (js_object_set_symbol_method / js_object_set_method_by_name): the reserved this-slot patch is now one bind_reserved_this_slot helper that no-ops on anything that is not a real heap closure, and stores the value verbatim. Two further sites the sweep found, both on the same repro: * symbol/properties.rs register_closure_name_if_absent — a `<= 0x10000` floor in front of a `*(addr - 8)` GcHeader read. `{ [Symbol.toPrimitive]: proxyFn }` routes the proxy VALUE through it for fn.name inference. Now uses addr_class::try_read_gc_header. * closure/dynamic_props.rs js_closure_unbind_this — a `< 0x10000` floor; faulted on `const g = obj.m` where obj.m is a Proxy of a function. Guarding those turned the SIGSEGV into a spurious `TypeError: value is not a function`, because js_closure_callN receives a RAW pointer (codegen already stripped the tag) and get_valid_func_ptr correctly refuses to deref a proxy id. The compiler only emits ProxyApply when it can statically prove the callee is a proxy, so a proxy read out of any dynamically-typed slot — `const g = obj.m`, `arr[0]`, `map.get(k)` — could not be called at all. The 17 js_closure_callN entry points now fall back to dispatch_proxy_callee_or_throw, which re-boxes the id, asks the proxy registry, and calls [[Call]]; a non-proxy still throws exactly as before. This runs only on the branch that used to throw unconditionally, so there is no hot-path cost. The addr-class lint gate could not see any of the closure probes: HANDLE_FLOOR_RE only matches a floor written as a comparison, and these were written as `(0x1000..HEAP_MAX).contains(&addr)`. Added HANDLE_FLOOR_RANGE_RE for the range form — cleared when a band predicate guards the next few code lines, i.e. the #6321 fix shape — plus self-tests. The regenerated ratchet baseline drops the six fixed sites and records ten previously-invisible range-form sites as debt. Verification: test_gap_6320_proxy_closure_probes is byte-identical to node, including under PERRY_GC_FORCE_EVACUATE=1 PERRY_GC_VERIFY_EVACUATION=1 over 120 diagnosed GC cycles with live forwarding stubs. Full 297-test gap suite A/B against a pristine origin/main build: zero regressions. --- crates/perry-runtime/src/closure/dispatch.rs | 2 +- .../src/closure/dispatch/calln.rs | 79 ++++++++++--- .../src/closure/dispatch/validate.rs | 26 +++++ .../src/closure/dynamic_props.rs | 15 ++- crates/perry-runtime/src/jsx.rs | 89 ++++++++++++-- crates/perry-runtime/src/proxy.rs | 2 +- .../src/proxy/apply_construct.rs | 27 +++++ crates/perry-runtime/src/symbol/iterator.rs | 48 ++++++-- crates/perry-runtime/src/symbol/properties.rs | 93 +++++++-------- scripts/addr_class_inventory.py | 85 ++++++++++++++ scripts/addr_class_ratchet_baseline.txt | 24 ++-- .../test_gap_6320_proxy_closure_probes.ts | 109 ++++++++++++++++++ .../test_issue_6320_jsx_proxy_component.tsx | 36 ++++++ 13 files changed, 528 insertions(+), 107 deletions(-) create mode 100644 test-files/test_gap_6320_proxy_closure_probes.ts create mode 100644 test-files/test_issue_6320_jsx_proxy_component.tsx diff --git a/crates/perry-runtime/src/closure/dispatch.rs b/crates/perry-runtime/src/closure/dispatch.rs index c3e1109c7a..503881cce8 100644 --- a/crates/perry-runtime/src/closure/dispatch.rs +++ b/crates/perry-runtime/src/closure/dispatch.rs @@ -24,7 +24,7 @@ pub use bound::{dispatch_bound_function, dispatch_bound_method, js_function_bind pub(crate) use errors::reset_throw_not_callable_counter; pub use errors::throw_not_callable; -pub use validate::{clean_closure_ptr, get_valid_func_ptr}; +pub use validate::{clean_closure_ptr, dispatch_proxy_callee_or_throw, get_valid_func_ptr}; pub(crate) use calln::{ dispatch_registered_call, dispatch_rest_or_declared_arity, resolve_call2_direct, diff --git a/crates/perry-runtime/src/closure/dispatch/calln.rs b/crates/perry-runtime/src/closure/dispatch/calln.rs index f740d24873..ebefa09f25 100644 --- a/crates/perry-runtime/src/closure/dispatch/calln.rs +++ b/crates/perry-runtime/src/closure/dispatch/calln.rs @@ -10,7 +10,7 @@ use super::*; pub extern "C" fn js_closure_call0(closure: *const ClosureHeader) -> f64 { let func_ptr = get_valid_func_ptr(closure); if func_ptr.is_null() { - throw_not_callable(); + return dispatch_proxy_callee_or_throw(closure, &[]); } match resolve_strategy(func_ptr) { DispatchStrategy::BoundMethod => unsafe { dispatch_bound_method(closure, &[]) }, @@ -34,7 +34,7 @@ pub extern "C" fn js_closure_call0(closure: *const ClosureHeader) -> f64 { pub extern "C" fn js_closure_call1(closure: *const ClosureHeader, arg0: f64) -> f64 { let func_ptr = get_valid_func_ptr(closure); if func_ptr.is_null() { - throw_not_callable(); + return dispatch_proxy_callee_or_throw(closure, &[arg0]); } match resolve_strategy(func_ptr) { DispatchStrategy::BoundMethod => unsafe { dispatch_bound_method(closure, &[arg0]) }, @@ -88,7 +88,7 @@ pub(crate) fn resolve_call2_direct( pub extern "C" fn js_closure_call2(closure: *const ClosureHeader, arg0: f64, arg1: f64) -> f64 { let func_ptr = get_valid_func_ptr(closure); if func_ptr.is_null() { - throw_not_callable(); + return dispatch_proxy_callee_or_throw(closure, &[arg0, arg1]); } match resolve_strategy(func_ptr) { DispatchStrategy::BoundMethod => unsafe { dispatch_bound_method(closure, &[arg0, arg1]) }, @@ -119,7 +119,7 @@ pub extern "C" fn js_closure_call3( ) -> f64 { let func_ptr = get_valid_func_ptr(closure); if func_ptr.is_null() { - throw_not_callable(); + return dispatch_proxy_callee_or_throw(closure, &[arg0, arg1, arg2]); } match resolve_strategy(func_ptr) { DispatchStrategy::BoundMethod => unsafe { @@ -153,7 +153,7 @@ pub extern "C" fn js_closure_call4( ) -> f64 { let func_ptr = get_valid_func_ptr(closure); if func_ptr.is_null() { - throw_not_callable(); + return dispatch_proxy_callee_or_throw(closure, &[arg0, arg1, arg2, arg3]); } match resolve_strategy(func_ptr) { DispatchStrategy::BoundMethod => unsafe { @@ -194,7 +194,7 @@ pub extern "C" fn js_closure_call5( ) -> f64 { let func_ptr = get_valid_func_ptr(closure); if func_ptr.is_null() { - throw_not_callable(); + return dispatch_proxy_callee_or_throw(closure, &[arg0, arg1, arg2, arg3, arg4]); } if func_ptr == BOUND_METHOD_FUNC_PTR { return unsafe { dispatch_bound_method(closure, &[arg0, arg1, arg2, arg3, arg4]) }; @@ -238,7 +238,7 @@ pub extern "C" fn js_closure_call6( ) -> f64 { let func_ptr = get_valid_func_ptr(closure); if func_ptr.is_null() { - throw_not_callable(); + return dispatch_proxy_callee_or_throw(closure, &[arg0, arg1, arg2, arg3, arg4, arg5]); } if func_ptr == BOUND_METHOD_FUNC_PTR { return unsafe { dispatch_bound_method(closure, &[arg0, arg1, arg2, arg3, arg4, arg5]) }; @@ -321,7 +321,10 @@ pub extern "C" fn js_closure_call7( ) -> f64 { let func_ptr = get_valid_func_ptr(closure); if func_ptr.is_null() { - throw_not_callable(); + return dispatch_proxy_callee_or_throw( + closure, + &[arg0, arg1, arg2, arg3, arg4, arg5, arg6], + ); } let args = [arg0, arg1, arg2, arg3, arg4, arg5, arg6]; if let Some(result) = dispatch_registered_call(closure, func_ptr, &args) { @@ -351,7 +354,10 @@ pub extern "C" fn js_closure_call8( ) -> f64 { let func_ptr = get_valid_func_ptr(closure); if func_ptr.is_null() { - throw_not_callable(); + return dispatch_proxy_callee_or_throw( + closure, + &[arg0, arg1, arg2, arg3, arg4, arg5, arg6, arg7], + ); } let args = [arg0, arg1, arg2, arg3, arg4, arg5, arg6, arg7]; if let Some(result) = dispatch_registered_call(closure, func_ptr, &args) { @@ -382,7 +388,10 @@ pub extern "C" fn js_closure_call9( ) -> f64 { let func_ptr = get_valid_func_ptr(closure); if func_ptr.is_null() { - throw_not_callable(); + return dispatch_proxy_callee_or_throw( + closure, + &[arg0, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8], + ); } let args = [arg0, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8]; if let Some(result) = dispatch_registered_call(closure, func_ptr, &args) { @@ -426,7 +435,10 @@ pub extern "C" fn js_closure_call10( ) -> f64 { let func_ptr = get_valid_func_ptr(closure); if func_ptr.is_null() { - throw_not_callable(); + return dispatch_proxy_callee_or_throw( + closure, + &[arg0, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9], + ); } let args = [arg0, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9]; if let Some(result) = dispatch_registered_call(closure, func_ptr, &args) { @@ -472,7 +484,12 @@ pub extern "C" fn js_closure_call11( ) -> f64 { let func_ptr = get_valid_func_ptr(closure); if func_ptr.is_null() { - throw_not_callable(); + return dispatch_proxy_callee_or_throw( + closure, + &[ + arg0, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10, + ], + ); } let args = [ arg0, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10, @@ -524,7 +541,12 @@ pub extern "C" fn js_closure_call12( ) -> f64 { let func_ptr = get_valid_func_ptr(closure); if func_ptr.is_null() { - throw_not_callable(); + return dispatch_proxy_callee_or_throw( + closure, + &[ + arg0, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10, arg11, + ], + ); } let args = [ arg0, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10, arg11, @@ -578,7 +600,12 @@ pub extern "C" fn js_closure_call13( ) -> f64 { let func_ptr = get_valid_func_ptr(closure); if func_ptr.is_null() { - throw_not_callable(); + return dispatch_proxy_callee_or_throw( + closure, + &[ + arg0, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10, arg11, arg12, + ], + ); } let args = [ arg0, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10, arg11, arg12, @@ -634,7 +661,13 @@ pub extern "C" fn js_closure_call14( ) -> f64 { let func_ptr = get_valid_func_ptr(closure); if func_ptr.is_null() { - throw_not_callable(); + return dispatch_proxy_callee_or_throw( + closure, + &[ + arg0, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10, arg11, arg12, + arg13, + ], + ); } let args = [ arg0, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10, arg11, arg12, arg13, @@ -693,7 +726,13 @@ pub extern "C" fn js_closure_call15( ) -> f64 { let func_ptr = get_valid_func_ptr(closure); if func_ptr.is_null() { - throw_not_callable(); + return dispatch_proxy_callee_or_throw( + closure, + &[ + arg0, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10, arg11, arg12, + arg13, arg14, + ], + ); } let args = [ arg0, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10, arg11, arg12, arg13, @@ -756,7 +795,13 @@ pub extern "C" fn js_closure_call16( ) -> f64 { let func_ptr = get_valid_func_ptr(closure); if func_ptr.is_null() { - throw_not_callable(); + return dispatch_proxy_callee_or_throw( + closure, + &[ + arg0, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10, arg11, arg12, + arg13, arg14, arg15, + ], + ); } let args = [ arg0, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9, arg10, arg11, arg12, arg13, diff --git a/crates/perry-runtime/src/closure/dispatch/validate.rs b/crates/perry-runtime/src/closure/dispatch/validate.rs index c81468be3c..5f05426923 100644 --- a/crates/perry-runtime/src/closure/dispatch/validate.rs +++ b/crates/perry-runtime/src/closure/dispatch/validate.rs @@ -132,3 +132,29 @@ pub fn get_valid_func_ptr(closure: *const ClosureHeader) -> *const u8 { } func_ptr } + +/// Last resort for a callee that [`get_valid_func_ptr`] rejected: it may be a +/// **Proxy of a function**, not a broken pointer. Route it through the Proxy +/// `[[Call]]` (apply trap, else forwarded to the target); otherwise throw +/// `TypeError: value is not a function` exactly as before. +/// +/// #6320. The `js_closure_callN` entry points take a RAW `*const ClosureHeader` +/// — codegen has already stripped the NaN-box tag (`js_closure_unbox_callee_ +/// checked`) — so a proxy callee arrives here as its bare registry id +/// (`PROXY_ID_BAND_START + id`). The compiler emits a `ProxyApply` node only +/// when it can statically prove the callee is a proxy; a proxy read out of a +/// dynamically-typed slot (`const g = obj.m` / `arr[0]` / `map.get(k)`, or a +/// method detached by `js_closure_unbind_this`) reaches this generic path with +/// no static hint, and `get_valid_func_ptr` correctly refuses to dereference the +/// id (#5976/#6321) — which turned the SIGSEGV into a spurious TypeError. Node +/// calls the proxy. Re-boxing the id and asking the proxy registry costs nothing +/// on the hot path: this runs only where the code used to throw unconditionally. +pub fn dispatch_proxy_callee_or_throw(closure: *const ClosureHeader, args: &[f64]) -> f64 { + let boxed = + f64::from_bits(crate::value::POINTER_TAG | (closure as u64 & crate::value::POINTER_MASK)); + if crate::proxy::js_proxy_is_proxy(boxed) == 1 { + let this_arg = f64::from_bits(crate::value::TAG_UNDEFINED); + return crate::proxy::call_proxy_value_with_this(boxed, this_arg, args); + } + throw_not_callable() +} diff --git a/crates/perry-runtime/src/closure/dynamic_props.rs b/crates/perry-runtime/src/closure/dynamic_props.rs index acf1b819e1..7b050f31ef 100644 --- a/crates/perry-runtime/src/closure/dynamic_props.rs +++ b/crates/perry-runtime/src/closure/dynamic_props.rs @@ -779,15 +779,18 @@ pub extern "C" fn js_closure_unbind_this(val: f64) -> f64 { return val; } let ptr = (bits & 0x0000_FFFF_FFFF_FFFF) as usize; - if ptr < 0x10000 { + // #6320: the old `< 0x10000` floor is an order of magnitude below + // `HANDLE_BAND_MAX`, so a registry handle NaN-boxed under POINTER_TAG — most + // sharply a revocable-Proxy id at `0xF0000 + id` — passed it and the + // CLOSURE_MAGIC probe below dereferenced unmapped low memory. Detaching a + // proxy-valued method (`const g = obj.m` where `obj.m = new Proxy(fn, {})`) + // reaches exactly here. `is_closure_ptr` subsumes the band, heap-range, + // alignment and magic checks; a non-closure value has no `this` slot to + // unbind, so it flows through untouched. + if !is_closure_ptr(ptr) { return val; } - // Check CLOSURE_MAGIC unsafe { - let type_tag = *((ptr as *const u8).add(CLOSURE_TYPE_TAG_OFFSET) as *const u32); - if type_tag != CLOSURE_MAGIC { - return val; - } let header = ptr as *const ClosureHeader; let raw_count = (*header).capture_count; // Only unbind if the closure has the CAPTURES_THIS_FLAG diff --git a/crates/perry-runtime/src/jsx.rs b/crates/perry-runtime/src/jsx.rs index e4429302e3..04ce561b0a 100644 --- a/crates/perry-runtime/src/jsx.rs +++ b/crates/perry-runtime/src/jsx.rs @@ -23,7 +23,7 @@ //! `lower_call.rs` passes both args as `double` (NaN-boxed), so both adapters //! are `(f64, f64) -> f64`. -use crate::closure::{js_closure_call1, ClosureHeader, CLOSURE_MAGIC}; +use crate::closure::{js_closure_call1, ClosureHeader}; use crate::object::ObjectHeader; use crate::value::{JSValue, TAG_UNDEFINED}; @@ -49,6 +49,20 @@ pub extern "C" fn js_jsxs(type_arg: f64, props: f64) -> f64 { fn dispatch(type_arg: f64, props: f64) -> f64 { let jsval = JSValue::from_bits(type_arg.to_bits()); + // #6320: `

` where `P` is a `Proxy()`. A proxy value is a + // small registry id NaN-boxed under POINTER_TAG, not a `ClosureHeader*`; + // `is_valid_closure` used to probe `*(id + 12)` for CLOSURE_MAGIC behind an + // 0x1000 floor and SIGSEGV'd on the unmapped low address. React calls the + // component through the proxy's [[Call]] (apply trap, else forwarded to the + // target), so do the same — components take no `this`, which is exactly what + // the value-call path binds. + if crate::proxy::js_proxy_is_proxy(type_arg) == 1 { + if crate::proxy::proxy_wraps_callable(type_arg) { + return unsafe { crate::closure::js_native_call_value(type_arg, &props, 1) }; + } + return f64::from_bits(crate::value::TAG_UNDEFINED); + } + // Function component: call it with props. Its return value is already a // JSX node (or any value the component produced). if jsval.is_pointer() { @@ -302,17 +316,15 @@ fn render_props_attrs(props: f64) -> String { /// Validate that `ptr` points at a real `ClosureHeader` (CLOSURE_MAGIC at /// offset 12) before invoking it as a function component. +/// +/// #6320: this used to hand-roll the probe behind an `0x1000` floor, which is +/// an order of magnitude below `HANDLE_BAND_MAX` — every registry handle (proxy +/// id, fetch/zlib stream, stdlib id) sailed through and the `*(addr + 12)` read +/// faulted. `closure::is_closure_ptr` is the single owner of this check: it +/// rejects the handle band, the non-heap addresses, and misaligned pointers +/// before touching memory. fn is_valid_closure(ptr: *const ClosureHeader) -> bool { - let addr = ptr as u64; - if !(0x1000..0x0001_0000_0000_0000).contains(&addr) { - return false; - } - let tag = unsafe { - std::ptr::read_volatile( - (ptr as *const u8).add(crate::closure::CLOSURE_TYPE_TAG_OFFSET) as *const u32, - ) - }; - tag == CLOSURE_MAGIC + crate::closure::is_closure_ptr(ptr as usize) } #[cfg(test)] @@ -384,4 +396,59 @@ mod tests { let node = js_jsx(str_val("br"), f64::from_bits(TAG_UNDEFINED)); assert_eq!(node_html(node), "
"); } + + /// A function component: `(props) => {props.children}`. + extern "C" fn span_component(_closure: *const ClosureHeader, props: f64) -> f64 { + js_jsx(str_val("span"), props) + } + + /// Allocate `span_component` as a real capture-free closure value. + fn span_component_value() -> f64 { + let c = crate::closure::js_closure_alloc(span_component as *const u8, 0); + f64::from_bits(JSValue::pointer(c as *const u8).bits()) + } + + /// Control: a plain closure component still renders. + #[test] + fn closure_function_component_renders() { + let props = obj_with(&[("children", str_val("hi"))]); + let node = js_jsx(span_component_value(), props); + assert_eq!(node_html(node), "hi"); + } + + /// #6320: `

` where `P = new Proxy(Component, {})`. The proxy value is a + /// small registry id, not a `ClosureHeader*`; the old `is_valid_closure` + /// probe read `*(id + 12)` behind an `0x1000` floor and SIGSEGV'd. It must + /// dispatch through the proxy's [[Call]] (here: no trap → forward to the + /// target) and render the component. + #[test] + fn proxy_wrapped_function_component_dispatches_through_call() { + let proxy = crate::proxy::js_proxy_new(span_component_value(), obj_with(&[])); + let props = obj_with(&[("children", str_val("hi"))]); + let node = js_jsx(proxy, props); + assert_eq!(node_html(node), "hi"); + } + + /// A proxy over a NON-callable target is not a component — it must return + /// undefined rather than fault or render garbage. + #[test] + fn proxy_over_non_callable_target_is_not_a_component() { + let proxy = crate::proxy::js_proxy_new(obj_with(&[]), obj_with(&[])); + let node = js_jsx(proxy, obj_with(&[])); + assert_eq!(node.to_bits(), TAG_UNDEFINED); + } + + /// The validator itself must reject every small-handle id (proxy ids, fetch + /// / zlib streams, stdlib registry handles) WITHOUT dereferencing them. + #[test] + fn is_valid_closure_rejects_the_handle_band() { + for handle in [ + 0x1usize, 0x1000, 0x10000, 0x40000, 0xE0000, 0xF000D, 0xF_FFF8, + ] { + assert!( + !is_valid_closure(handle as *const ClosureHeader), + "handle {handle:#x} must not be probed as a ClosureHeader" + ); + } + } } diff --git a/crates/perry-runtime/src/proxy.rs b/crates/perry-runtime/src/proxy.rs index d042891514..12206ef946 100644 --- a/crates/perry-runtime/src/proxy.rs +++ b/crates/perry-runtime/src/proxy.rs @@ -23,8 +23,8 @@ use std::collections::HashMap; use crate::closure::{js_closure_call0, js_closure_call1, js_closure_call2, js_closure_call3}; mod apply_construct; +pub use apply_construct::{call_proxy_value_with_this, js_proxy_apply, js_proxy_construct}; pub(crate) use apply_construct::{is_callable_function, is_constructor_function}; -pub use apply_construct::{js_proxy_apply, js_proxy_construct}; mod has_delete; pub(crate) use has_delete::reflect_ordinary_delete_property_key; pub use has_delete::{js_proxy_delete, js_proxy_has}; diff --git a/crates/perry-runtime/src/proxy/apply_construct.rs b/crates/perry-runtime/src/proxy/apply_construct.rs index ce9023bb63..0cc57d9376 100644 --- a/crates/perry-runtime/src/proxy/apply_construct.rs +++ b/crates/perry-runtime/src/proxy/apply_construct.rs @@ -99,6 +99,33 @@ fn forward_apply(target: f64, this_arg: f64, args_array: f64) -> f64 { result } +/// Call a Proxy VALUE as a function with an explicit `this` and a plain +/// argument slice — the `Call(F, V, args)` shape that runtime sites use when a +/// proxy turns up where a raw `ClosureHeader*` was expected (#6320: a Proxy +/// stored at `obj[Symbol.toPrimitive]`, or used as a JSX function component). +/// +/// [`js_proxy_apply`] takes the spec `argArray`; this builds it, keeping every +/// operand rooted across the allocations so a GC in `js_array_push_f64` cannot +/// invalidate an already-boxed argument. +pub fn call_proxy_value_with_this(proxy: f64, this_arg: f64, args: &[f64]) -> f64 { + let scope = crate::gc::RuntimeHandleScope::new(); + let proxy_h = scope.root_nanbox_f64(proxy); + let this_h = scope.root_nanbox_f64(this_arg); + let arg_handles: Vec<_> = args.iter().map(|a| scope.root_nanbox_f64(*a)).collect(); + let arr_h = scope + .root_nanbox_u64(POINTER_TAG | (crate::array::js_array_alloc(0) as u64 & POINTER_MASK)); + for h in &arg_handles { + let arr = (arr_h.get_nanbox_u64() & POINTER_MASK) as *mut crate::ArrayHeader; + let grown = crate::array::js_array_push_f64(arr, h.get_nanbox_f64()); + arr_h.set_nanbox_u64(POINTER_TAG | (grown as u64 & POINTER_MASK)); + } + js_proxy_apply( + proxy_h.get_nanbox_f64(), + this_h.get_nanbox_f64(), + f64::from_bits(arr_h.get_nanbox_u64()), + ) +} + /// `proxy(arg0, arg1)` / `p.call(thisArg, …)` / `Reflect.apply(p, thisArg, …)`. /// /// Implements the Proxy `[[Call]]` exotic behavior (#3656): diff --git a/crates/perry-runtime/src/symbol/iterator.rs b/crates/perry-runtime/src/symbol/iterator.rs index 4aa135fa47..554a0075d4 100644 --- a/crates/perry-runtime/src/symbol/iterator.rs +++ b/crates/perry-runtime/src/symbol/iterator.rs @@ -440,17 +440,6 @@ pub unsafe extern "C" fn js_to_primitive(value: f64, hint: i32) -> f64 { return value_handle.get_nanbox_f64(); } let method_handle = scope.root_nanbox_f64(method); - let closure_ptr = (method_bits & POINTER_MASK) as *const crate::closure::ClosureHeader; - if closure_ptr.is_null() || (closure_ptr as usize) < 0x1000 { - return value_handle.get_nanbox_f64(); - } - // Validate CLOSURE_MAGIC before calling. - let type_tag = std::ptr::read_volatile( - (closure_ptr as *const u8).add(crate::closure::CLOSURE_TYPE_TAG_OFFSET) as *const u32, - ); - if type_tag != crate::closure::CLOSURE_MAGIC { - return value_handle.get_nanbox_f64(); - } let hint_str: &[u8] = match hint { 1 => b"number", 2 => b"string", @@ -458,10 +447,45 @@ pub unsafe extern "C" fn js_to_primitive(value: f64, hint: i32) -> f64 { }; let hint_ptr = js_string_from_bytes(hint_str.as_ptr(), hint_str.len() as u32); let hint_handle = scope.root_string_ptr(hint_ptr); + + // #6320: `obj[Symbol.toPrimitive]` may hold a *Proxy* of a function. A + // proxy is a small registry id NaN-boxed under POINTER_TAG (`PROXY_ID_BAND_ + // START + id`), NOT a `ClosureHeader*` — the CLOSURE_MAGIC probe below used + // to accept anything above an 0x1000 floor, so it read `*(0xF000D + 12)` and + // SIGSEGV'd (`EXC_BAD_ACCESS at 0x000f000d`). Node calls the proxy's + // `[[Call]]` here (`Call(method, obj, «hint»)`), so route it through the + // apply trap / target forwarding with `this` bound to the object, exactly + // like a closure method would be. A proxy whose (possibly nested) target is + // not callable falls through to the "not a method" return below, matching + // how this path already treats a non-callable `@@toPrimitive` value. + let method_now = method_handle.get_nanbox_f64(); + if crate::proxy::js_proxy_is_proxy(method_now) == 1 { + if !crate::proxy::proxy_wraps_callable(method_now) { + return value_handle.get_nanbox_f64(); + } + let hint_f64 = f64::from_bits( + STRING_TAG | (hint_handle.get_raw_const_ptr::() as u64 & POINTER_MASK), + ); + return crate::proxy::call_proxy_value_with_this( + method_handle.get_nanbox_f64(), + value_handle.get_nanbox_f64(), + &[hint_f64], + ); + } + + // Not a proxy: validate a real heap `ClosureHeader` (band-safe floor + + // CLOSURE_MAGIC) before calling. Every other small-handle band (fetch, + // zlib, stdlib registry ids) is rejected here too — none of them is a + // closure, and all of them fault when probed at `+12`. + // (`method_bits` above is stale — the hint-string allocation may have moved + // the closure — so re-read every operand from its handle.) + let method_bits = method_handle.get_nanbox_f64().to_bits(); + if !crate::closure::is_closure_ptr((method_bits & POINTER_MASK) as usize) { + return value_handle.get_nanbox_f64(); + } let hint_f64 = f64::from_bits( STRING_TAG | (hint_handle.get_raw_const_ptr::() as u64 & POINTER_MASK), ); - let method_bits = method_handle.get_nanbox_f64().to_bits(); let closure_ptr = (method_bits & POINTER_MASK) as *const crate::closure::ClosureHeader; // Spec says the return value must be a primitive; if it's still an diff --git a/crates/perry-runtime/src/symbol/properties.rs b/crates/perry-runtime/src/symbol/properties.rs index 2c4d587db3..d2308108df 100644 --- a/crates/perry-runtime/src/symbol/properties.rs +++ b/crates/perry-runtime/src/symbol/properties.rs @@ -184,14 +184,20 @@ unsafe fn register_closure_name_if_absent(val_bits: u64, name: &str) { if val_tag != POINTER_TAG { return; } - let val_ptr = (val_bits & POINTER_MASK) as *const u8; - if val_ptr.is_null() || (val_ptr as usize) <= 0x10000 { + let val_addr = (val_bits & POINTER_MASK) as usize; + // #6320: the old `<= 0x10000` floor sits an order of magnitude below + // `HANDLE_BAND_MAX`, so a registry id NaN-boxed under POINTER_TAG passed it + // and the `*(addr - 8)` GcHeader read faulted on unmapped low memory: + // `{ [Symbol.toPrimitive]: new Proxy(fn, {}) }` routes the proxy VALUE + // through here for `fn.name` inference (proxy id 0xF000D → read at 0xF0005). + // `try_read_gc_header` owns the band + heap-range + slab checks. + let Some(gc_header) = crate::value::addr_class::try_read_gc_header(val_addr) else { return; - } - let gc_header = val_ptr.sub(crate::gc::GC_HEADER_SIZE) as *const crate::gc::GcHeader; - if (*gc_header).obj_type != crate::gc::GC_TYPE_CLOSURE { + }; + if gc_header.obj_type != crate::gc::GC_TYPE_CLOSURE { return; } + let val_ptr = val_addr as *const u8; let closure_ptr = val_ptr as *const crate::closure::ClosureHeader; let func_ptr = (*closure_ptr).func_ptr; if func_ptr.is_null() { @@ -563,28 +569,41 @@ pub unsafe extern "C" fn js_object_set_symbol_method( sym_f64: f64, closure_f64: f64, ) -> f64 { + bind_reserved_this_slot(closure_f64, obj_f64); + js_object_set_symbol_property_infer_name(obj_f64, sym_f64, closure_f64) +} + +/// Patch the closure's reserved (LAST) capture slot with `obj_f64` so a +/// `this`-reading object-literal method reads its container. No-op for any +/// value that is not a real heap `ClosureHeader` — shared by +/// [`js_object_set_symbol_method`] and [`js_object_set_method_by_name`]. +/// +/// #6320: both call sites hand-rolled the CLOSURE_MAGIC probe behind an +/// `0x1000` floor — an order of magnitude below `HANDLE_BAND_MAX`, so a +/// registry handle (revocable-proxy id, fetch/zlib stream, stdlib id) NaN-boxed +/// under POINTER_TAG passed the floor and the `*(addr + 12)` read faulted on +/// unmapped low memory. `closure::is_closure_ptr` owns the check: it rejects the +/// handle band, non-heap addresses and misalignment before any dereference. A +/// non-closure value (e.g. a Proxy of a function) has no capture array to patch, +/// so it is simply stored as-is; the call site then dispatches it through the +/// proxy `[[Call]]` path. +unsafe fn bind_reserved_this_slot(closure_f64: f64, obj_f64: f64) { let c_bits = closure_f64.to_bits(); - let c_tag = c_bits & 0xFFFF_0000_0000_0000; - if c_tag == POINTER_TAG { - let c_ptr = (c_bits & POINTER_MASK) as *mut crate::closure::ClosureHeader; - if !c_ptr.is_null() && (c_ptr as usize) >= 0x1000 { - // Read the type_tag at offset 12 (layout: func_ptr u64, capture_count u32, type_tag u32). - let type_tag = std::ptr::read_volatile( - (c_ptr as *const u8).add(crate::closure::CLOSURE_TYPE_TAG_OFFSET) as *const u32, - ); - if type_tag == crate::closure::CLOSURE_MAGIC { - let raw_count = (*c_ptr).capture_count; - let real_count = crate::closure::real_capture_count(raw_count); - if real_count >= 1 { - let captures_ptr = (c_ptr as *mut u8) - .add(std::mem::size_of::()) - as *mut f64; - *captures_ptr.add((real_count - 1) as usize) = obj_f64; - } - } - } + if c_bits & 0xFFFF_0000_0000_0000 != POINTER_TAG { + return; + } + let c_addr = (c_bits & POINTER_MASK) as usize; + if !crate::closure::is_closure_ptr(c_addr) { + return; + } + let c_ptr = c_addr as *mut crate::closure::ClosureHeader; + let real_count = crate::closure::real_capture_count((*c_ptr).capture_count); + if real_count >= 1 { + let captures_ptr = (c_ptr as *mut u8) + .add(std::mem::size_of::()) + as *mut f64; + *captures_ptr.add((real_count - 1) as usize) = obj_f64; } - js_object_set_symbol_property_infer_name(obj_f64, sym_f64, closure_f64) } /// #809: string-key analog of [`js_object_set_symbol_method`]. Sets @@ -607,26 +626,8 @@ pub unsafe extern "C" fn js_object_set_method_by_name( closure_f64: f64, ) -> f64 { // 1) Patch the closure's reserved (last) `this` capture slot with obj. - let c_bits = closure_f64.to_bits(); - let c_tag = c_bits & 0xFFFF_0000_0000_0000; - if c_tag == POINTER_TAG { - let c_ptr = (c_bits & POINTER_MASK) as *mut crate::closure::ClosureHeader; - if !c_ptr.is_null() && (c_ptr as usize) >= 0x1000 { - let type_tag = std::ptr::read_volatile( - (c_ptr as *const u8).add(crate::closure::CLOSURE_TYPE_TAG_OFFSET) as *const u32, - ); - if type_tag == crate::closure::CLOSURE_MAGIC { - let raw_count = (*c_ptr).capture_count; - let real_count = crate::closure::real_capture_count(raw_count); - if real_count >= 1 { - let captures_ptr = (c_ptr as *mut u8) - .add(std::mem::size_of::()) - as *mut f64; - *captures_ptr.add((real_count - 1) as usize) = obj_f64; - } - } - } - } + // No-op for anything that is not a real heap closure (#6320). + bind_reserved_this_slot(closure_f64, obj_f64); // 2) Set the field by name. `js_object_set_field_by_name` strips the // NaN-box tag off `obj` itself, so passing the raw bits is fine; the @@ -634,7 +635,7 @@ pub unsafe extern "C" fn js_object_set_method_by_name( let key_bits = key_f64.to_bits(); let key_ptr = (key_bits & POINTER_MASK) as *const StringHeader; let obj_ptr = obj_f64.to_bits() as *mut crate::object::ObjectHeader; - if !key_ptr.is_null() && (key_ptr as usize) >= 0x1000 { + if crate::value::addr_class::is_above_handle_band(key_ptr as usize) { crate::object::js_object_set_field_by_name(obj_ptr, key_ptr, closure_f64); } obj_f64 diff --git a/scripts/addr_class_inventory.py b/scripts/addr_class_inventory.py index d51c3bf0be..0bb1f10931 100644 --- a/scripts/addr_class_inventory.py +++ b/scripts/addr_class_inventory.py @@ -81,6 +81,23 @@ re.IGNORECASE, ) +# HANDLE FLOOR, RANGE FORM (#6320) — the same too-low floor written as a Rust +# range test instead of a comparison: +# +# if !(0x1000..0x0001_0000_0000_0000).contains(&addr) { return false; } +# +# HANDLE_FLOOR_RE structurally cannot see this: the literal is followed by `..`, +# never by a comparison operator, and the address operand appears AFTER it +# inside `.contains(&…)`. That blind spot is exactly why the closure-validation +# probes in `symbol/iterator.rs`, `symbol/properties.rs` and `jsx.rs` survived +# the #6279 sweep and kept SIGSEGV-ing on a Proxy id (#5976 / #6320). +# +# The established remediation (#6321) keeps the range and adds a band predicate +# next to it, so a band predicate in the surrounding lines clears the finding — +# same pairing contract as `lone-valid-obj-ptr` below. Reported under the +# `handle-floor` rule so it shares that rule's per-file ratchet. +HANDLE_FLOOR_RANGE_RE = re.compile(r"\(\s*0x1_?0?000\s*\.\.") + # LONE is_valid_obj_ptr (#6279) — `is_valid_obj_ptr` used as the ONLY guard # before a dereference. Its own doc says it is not sufficient: # @@ -99,6 +116,10 @@ ) # How many lines above the call may satisfy the pairing requirement. BAND_PREDICATE_LOOKBACK = 5 +# ...and how many CODE lines below (blank lines and comments don't count: the +# #6321 shape puts the band guard right after the coarse range pre-filter, but +# behind a long justification comment). +BAND_PREDICATE_LOOKAHEAD_CODE = 6 DEFAULT_RATCHET_BASELINE = REPO_ROOT / "scripts" / "addr_class_ratchet_baseline.txt" @@ -140,6 +161,26 @@ def strip_comment(line: str) -> str: return LINE_COMMENT_RE.sub("", line) +def band_predicate_near(lines: list[str], idx: int) -> bool: + """True when a band predicate guards the statement at `lines[idx]`. + + Looks back a few raw lines and forward over the next few CODE lines + (comments/blanks skipped) — the guard is a statement, not a neighbour. + """ + + start = max(0, idx - BAND_PREDICATE_LOOKBACK) + context = [strip_comment(line) for line in lines[start : idx + 1]] + taken = 0 + cursor = idx + 1 + while cursor < len(lines) and taken < BAND_PREDICATE_LOOKAHEAD_CODE: + code = strip_comment(lines[cursor]) + if code.strip(): + context.append(code) + taken += 1 + cursor += 1 + return bool(BAND_PREDICATE_RE.search("\n".join(context))) + + def scan_text(rel_path: str, text: str) -> list[Finding]: findings: list[Finding] = [] if any(rel_path.startswith(prefix) for prefix in EXCLUDED_PREFIXES): @@ -154,6 +195,10 @@ def scan_text(rel_path: str, text: str) -> list[Finding]: findings.append(Finding(rel_path, line_no, "gcheader-cast", raw)) if HANDLE_FLOOR_RE.search(code): findings.append(Finding(rel_path, line_no, "handle-floor", raw)) + elif HANDLE_FLOOR_RANGE_RE.search(code) and not band_predicate_near(lines, idx): + # A band predicate in the enclosing guard clears it — the range test + # is then just a coarse pre-filter, not the real gate (#6321). + findings.append(Finding(rel_path, line_no, "handle-floor", raw)) if VALID_OBJ_PTR_RE.search(code) and "fn is_valid_obj_ptr" not in code: # A band predicate anywhere in the enclosing guard clears it. start = max(0, idx - BAND_PREDICATE_LOOKBACK) @@ -278,6 +323,46 @@ def expect(cond: bool, message: str) -> None: "handle-floor must ignore comments", ) + # --- handle-floor, RANGE form (#6320) ----------------------------------- + # The same too-low floor written as a range test. The comparison-operator + # regex structurally cannot see it, which is how the closure-validation + # probes survived the #6279 sweep and kept faulting on a Proxy id. + for src in ( + " if !(0x1000..0x0001_0000_0000_0000).contains(&addr) {\n return false;\n }\n", + " } else if (0x10000..=RAW_PTR_MAX).contains(&bits) {\n deref(bits)\n", + ): + expect( + any(f.rule == "handle-floor" for f in scan_text(runtime, src)), + f"handle-floor should flag the range form: {src.splitlines()[0].strip()}", + ) + # Paired with a band predicate on the next statement -> cleared. This is the + # #6321 fix shape (coarse range pre-filter, real gate right below, behind a + # long justification comment), so the rule must accept it. + paired_range = ( + " if !(0x1000..0x0001_0000_0000_0000).contains(&addr) {\n" + " return std::ptr::null();\n" + " }\n" + " // #5976: reject the small-handle band BEFORE the magic probe.\n" + " // Revocable-proxy ids and stdlib registry ids are NaN-boxed\n" + " // POINTER_TAG values, not heap pointers.\n" + "\n" + " if crate::value::addr_class::is_handle_band(addr as usize) {\n" + " return std::ptr::null();\n" + " }\n" + ) + expect( + not any(f.rule == "handle-floor" for f in scan_text(runtime, paired_range)), + "handle-floor must accept a range pre-filter paired with a band predicate", + ) + # A range whose floor is already the CORRECT boundary is not a finding. + expect( + not any( + f.rule == "handle-floor" + for f in scan_text(runtime, "if (0x100000..MAX).contains(&addr) {\n") + ), + "handle-floor must not fire on a range starting at HANDLE_BAND_MAX", + ) + # --- lone-valid-obj-ptr rule (#6279) ------------------------------------ lone = " if is_valid_obj_ptr(ptr as *const u8) {\n (*ptr).class_id\n" expect( diff --git a/scripts/addr_class_ratchet_baseline.txt b/scripts/addr_class_ratchet_baseline.txt index bb30458056..a079315cf5 100644 --- a/scripts/addr_class_ratchet_baseline.txt +++ b/scripts/addr_class_ratchet_baseline.txt @@ -41,13 +41,13 @@ handle-floor | crates/perry-runtime/src/buffer/copy_bytes.rs | 1 handle-floor | crates/perry-runtime/src/buffer/encode.rs | 6 handle-floor | crates/perry-runtime/src/buffer/from.rs | 10 handle-floor | crates/perry-runtime/src/buffer/iter.rs | 1 -handle-floor | crates/perry-runtime/src/buffer/query.rs | 5 -handle-floor | crates/perry-runtime/src/buffer/transcode.rs | 2 +handle-floor | crates/perry-runtime/src/buffer/query.rs | 7 +handle-floor | crates/perry-runtime/src/buffer/transcode.rs | 3 handle-floor | crates/perry-runtime/src/buffer/u8_codec.rs | 7 handle-floor | crates/perry-runtime/src/buffer/validate.rs | 1 handle-floor | crates/perry-runtime/src/builtins/arithmetic.rs | 1 handle-floor | crates/perry-runtime/src/builtins/console.rs | 7 -handle-floor | crates/perry-runtime/src/builtins/formatting.rs | 4 +handle-floor | crates/perry-runtime/src/builtins/formatting.rs | 5 handle-floor | crates/perry-runtime/src/builtins/formatting/collection_equality.rs | 1 handle-floor | crates/perry-runtime/src/builtins/formatting/collections.rs | 1 handle-floor | crates/perry-runtime/src/builtins/formatting/identity_equality.rs | 1 @@ -62,7 +62,6 @@ handle-floor | crates/perry-runtime/src/child_process/registry.rs | 1 handle-floor | crates/perry-runtime/src/child_process/v8_serde.rs | 1 handle-floor | crates/perry-runtime/src/child_process/value_util.rs | 1 handle-floor | crates/perry-runtime/src/closure/dispatch/validate.rs | 1 -handle-floor | crates/perry-runtime/src/closure/dynamic_props.rs | 1 handle-floor | crates/perry-runtime/src/cluster.rs | 1 handle-floor | crates/perry-runtime/src/collection_iter_object.rs | 1 handle-floor | crates/perry-runtime/src/date.rs | 3 @@ -80,8 +79,8 @@ handle-floor | crates/perry-runtime/src/json/replacer.rs | 1 handle-floor | crates/perry-runtime/src/json/reviver.rs | 1 handle-floor | crates/perry-runtime/src/json/stringify.rs | 2 handle-floor | crates/perry-runtime/src/jsx.rs | 4 -handle-floor | crates/perry-runtime/src/native_abi.rs | 1 -handle-floor | crates/perry-runtime/src/native_arena.rs | 1 +handle-floor | crates/perry-runtime/src/native_abi.rs | 3 +handle-floor | crates/perry-runtime/src/native_arena.rs | 2 handle-floor | crates/perry-runtime/src/native_handle.rs | 1 handle-floor | crates/perry-runtime/src/net_validate.rs | 1 handle-floor | crates/perry-runtime/src/node_inspector.rs | 1 @@ -99,9 +98,9 @@ handle-floor | crates/perry-runtime/src/node_submodules/fs_promises.rs | 1 handle-floor | crates/perry-runtime/src/node_submodules/test.rs | 2 handle-floor | crates/perry-runtime/src/node_submodules/timers.rs | 1 handle-floor | crates/perry-runtime/src/node_submodules/trace_events.rs | 1 -handle-floor | crates/perry-runtime/src/node_submodules/zlib.rs | 2 +handle-floor | crates/perry-runtime/src/node_submodules/zlib.rs | 3 handle-floor | crates/perry-runtime/src/node_v8.rs | 2 -handle-floor | crates/perry-runtime/src/node_vm.rs | 2 +handle-floor | crates/perry-runtime/src/node_vm.rs | 3 handle-floor | crates/perry-runtime/src/object/alloc.rs | 4 handle-floor | crates/perry-runtime/src/object/arguments.rs | 1 handle-floor | crates/perry-runtime/src/object/array_object_ops.rs | 1 @@ -167,13 +166,12 @@ handle-floor | crates/perry-runtime/src/string/locale.rs | 1 handle-floor | crates/perry-runtime/src/string/mod.rs | 1 handle-floor | crates/perry-runtime/src/string/raw.rs | 1 handle-floor | crates/perry-runtime/src/symbol.rs | 3 -handle-floor | crates/perry-runtime/src/symbol/constructors.rs | 2 +handle-floor | crates/perry-runtime/src/symbol/constructors.rs | 3 handle-floor | crates/perry-runtime/src/symbol/get.rs | 6 -handle-floor | crates/perry-runtime/src/symbol/iterator.rs | 2 -handle-floor | crates/perry-runtime/src/symbol/properties.rs | 4 +handle-floor | crates/perry-runtime/src/symbol/iterator.rs | 1 handle-floor | crates/perry-runtime/src/text.rs | 2 handle-floor | crates/perry-runtime/src/thread.rs | 13 -handle-floor | crates/perry-runtime/src/timer.rs | 1 +handle-floor | crates/perry-runtime/src/timer.rs | 2 handle-floor | crates/perry-runtime/src/tls.rs | 1 handle-floor | crates/perry-runtime/src/tty.rs | 1 handle-floor | crates/perry-runtime/src/typed_feedback.rs | 3 @@ -219,7 +217,7 @@ handle-floor | crates/perry-stdlib/src/streams.rs | 6 handle-floor | crates/perry-stdlib/src/streams/byob.rs | 1 handle-floor | crates/perry-stdlib/src/streams/subclass.rs | 1 handle-floor | crates/perry-stdlib/src/streams/transform.rs | 1 -handle-floor | crates/perry-stdlib/src/string_decoder.rs | 4 +handle-floor | crates/perry-stdlib/src/string_decoder.rs | 5 handle-floor | crates/perry-stdlib/src/tls.rs | 1 handle-floor | crates/perry-stdlib/src/webcrypto/aes.rs | 4 handle-floor | crates/perry-stdlib/src/webcrypto/hmac.rs | 1 diff --git a/test-files/test_gap_6320_proxy_closure_probes.ts b/test-files/test_gap_6320_proxy_closure_probes.ts new file mode 100644 index 0000000000..5ec854e006 --- /dev/null +++ b/test-files/test_gap_6320_proxy_closure_probes.ts @@ -0,0 +1,109 @@ +// #6320: a Proxy value stored where a *closure* was expected reached an ad-hoc +// CLOSURE_MAGIC probe with an `0x1000` address floor — far below +// `HANDLE_BAND_MAX` (0x100000). A proxy is a NaN-boxed registry id +// (`POINTER_TAG | (PROXY_ID_BAND_START + id)`), so the probe read `*(0xF000D + +// 12)` — unmapped low memory — and SIGSEGV'd (EXC_BAD_ACCESS at 0x000f000d). +// +// Sites: `js_to_primitive` (symbol/iterator.rs), `js_object_set_symbol_method` / +// `js_object_set_method_by_name` (symbol/properties.rs), `js_closure_unbind_this` +// (closure/dynamic_props.rs). Every line below must print and the program must +// exit 0. + +const fn1 = (): string => "prim"; + +// --- js_to_primitive: a Proxy at [Symbol.toPrimitive] ------------------------ +const obj: any = {}; +obj[Symbol.toPrimitive] = new Proxy(fn1, {}); +console.log("toPrimitive:", `${obj}`); + +// The proxied method still sees the container as `this`, and the hint argument. +const holder: any = { value: 42 }; +holder[Symbol.toPrimitive] = new Proxy(function (this: any, hint: string) { + return hint === "number" ? this.value : "H:" + hint; +}, {}); +console.log("hint string:", `${holder}`); +console.log("hint number:", +holder); +console.log("hint default:", holder + ""); + +// An `apply` trap intercepts the call and sees the hint in its args array. +const trapped: any = {}; +trapped[Symbol.toPrimitive] = new Proxy(fn1, { + apply(_t: any, _thisArg: any, args: any[]) { + return "trap:" + args[0]; + }, +}); +console.log("apply trap string:", `${trapped}`); +console.log("apply trap number:", +trapped); + +// A nested proxy forwards through both layers. +const nested: any = {}; +nested[Symbol.toPrimitive] = new Proxy(new Proxy(fn1, {}), {}); +console.log("nested proxy:", `${nested}`); + +// A revoked proxy throws instead of crashing. +const rv = Proxy.revocable(fn1, {}); +const revObj: any = {}; +revObj[Symbol.toPrimitive] = rv.proxy; +rv.revoke(); +try { + console.log("revoked:", `${revObj}`); +} catch (e: unknown) { + console.log("revoked throws TypeError:", e instanceof TypeError); +} + +// --- object-literal method binding (js_object_set_*_method) ------------------ +const sym: symbol = Symbol.toPrimitive; + +// Computed-key method that reads `this` → js_object_set_symbol_method. +const o1: any = { + value: "v1", + [sym](hint: string) { + return "m:" + hint + ":" + this.value; + }, +}; +console.log("computed symbol method:", `${o1}`); + +// Spread + `this`-reading methods → js_object_set_method_by_name and the +// symbol variant, both in the ordered-IIFE lowering. +const base = { a: 1 }; +const o2: any = { + ...base, + value: "v2", + m() { + return "byname:" + this.value + ":" + this.a; + }, + [sym](hint: string) { + return "spread-sym:" + hint + ":" + this.value; + }, +}; +console.log("spread method by name:", o2.m()); +console.log("spread symbol method:", `${o2}`); + +// A Proxy stored under a computed symbol key (a value, not a method) — it has +// no capture array to patch, so it must be stored verbatim and called through +// its [[Call]]. +const pfn: any = new Proxy(function () { + return "pv"; +}, {}); +const o3: any = { [sym]: pfn }; +console.log("proxy computed key:", `${o3}`); + +const o4: any = { + ...base, + [sym]: pfn, + m() { + return "m4:" + this.a; + }, +}; +console.log("proxy after spread:", `${o4}`, o4.m()); + +// --- js_closure_unbind_this: detaching a proxy-valued method ---------------- +const detachable: any = { + m: new Proxy(function () { + return "unbound"; + }, {}), +}; +const g = detachable.m; +console.log("detached proxy method:", g()); + +console.log("END"); diff --git a/test-files/test_issue_6320_jsx_proxy_component.tsx b/test-files/test_issue_6320_jsx_proxy_component.tsx new file mode 100644 index 0000000000..01bf657347 --- /dev/null +++ b/test-files/test_issue_6320_jsx_proxy_component.tsx @@ -0,0 +1,36 @@ +// #6320: a JSX function component reached as a `Proxy(Component)` value. +// +// `js_jsx`'s `is_valid_closure` probe floored the candidate address at 0x1000 — +// far below `HANDLE_BAND_MAX` (0x100000) — so a proxy's registry id +// (`POINTER_TAG | (PROXY_ID_BAND_START + id)`) reached the `*(addr + 12)` +// CLOSURE_MAGIC read and SIGSEGV'd on unmapped low memory. React calls a +// component through the proxy's [[Call]]; `js_jsx` now does the same. +// +// Not part of the node-compared gap suite (`run_parity_tests.sh` only globs +// `test-files/*.ts`, and node cannot run Perry's server-JSX runtime) — the CI +// gate for this site is the `jsx.rs` unit tests +// (`proxy_wrapped_function_component_dispatches_through_call`, +// `is_valid_closure_rejects_the_handle_band`). This file is the end-to-end +// repro: it must print the three lines below and exit 0. + +function Hello(props: any) { + return

hi {props.name}
; +} + +// No trap: the [[Call]] forwards to the target component. +const Forwarded: any = new Proxy(Hello, {}); +console.log(String()); + +// An `apply` trap intercepts the render and rewrites the props. +const Trapped: any = new Proxy(Hello, { + apply(target: any, _thisArg: any, args: any[]) { + return target({ name: "trapped-" + args[0].name }); + }, +}); +console.log(String()); + +// A proxy of a proxy forwards through both layers. +const Nested: any = new Proxy(new Proxy(Hello, {}), {}); +console.log(String()); + +console.log("END");