diff --git a/crates/perry-runtime/src/closure/dispatch.rs b/crates/perry-runtime/src/closure/dispatch.rs
index c3e1109c7..503881cce 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 f740d2487..ebefa09f2 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 c81468be3..5f0542692 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 acf1b819e..7b050f31e 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 e4429302e..04ce561b0 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 d04289151..12206ef94 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 ce9023bb6..0cc57d937 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 4aa135fa4..554a0075d 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 2c4d587db..d2308108d 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 d51c3bf0b..0bb1f1093 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 bb3045805..a079315cf 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 000000000..5ec854e00
--- /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 000000000..01bf65734
--- /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");