Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions crates/perry-hir/src/lower/expr_member.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2043,13 +2043,21 @@ fn lower_member_inner(ctx: &mut LoweringContext, member: &ast::MemberExpr) -> Re
// bare `GlobalGet(0)` — otherwise the predicate/dispatch runs
// against globalThis. The reroute above already resolved the
// receiver to `globalThis.<ctor>`; don't undo it here.
// #5135: `toString` is a universal inherited method too —
// `Function.toString` / `Array.toString` resolve to a real
// function in Node. Without keeping the reified constructor
// receiver the read collapses to `globalThis.toString`,
// which codegen folds to a number, so
// `Function.toString.call(Ctor)` (immer's `isPlainObject`)
// threw "call on a non-function".
let outer_is_inherited_object_proto_method = matches!(
outer_static_member,
Some(
"hasOwnProperty"
| "isPrototypeOf"
| "propertyIsEnumerable"
| "toLocaleString"
| "toString"
| "valueOf"
)
);
Expand Down
25 changes: 25 additions & 0 deletions crates/perry-runtime/src/array/header.rs
Original file line number Diff line number Diff line change
Expand Up @@ -600,6 +600,31 @@ pub(crate) fn clean_arr_ptr_mut(arr: *mut ArrayHeader) -> *mut ArrayHeader {
clean_arr_ptr(arr as *const ArrayHeader) as *mut ArrayHeader
}

/// #5135: detect a Proxy id arriving where an `ArrayHeader` pointer is
/// expected. immer's array drafts are Proxies typed (statically) as plain
/// arrays, so `draft.push(x)` / `draft.length` reach the native array helpers
/// with the masked proxy id instead of a real heap pointer. Deref-ing one as an
/// `ArrayHeader` reads unmapped memory and SIGSEGVs. Callers use this to detect
/// the case and route the operation through the proxy's traps. Returns the
/// re-boxed (`POINTER_TAG`) proxy value when `arr` is a *registered* proxy.
#[inline]
pub(crate) fn array_ptr_as_proxy(arr: *const ArrayHeader) -> Option<f64> {
let bits = arr as u64;
let raw = if (bits >> 48) >= 0x7FF8 {
bits & 0x0000_FFFF_FFFF_FFFF
} else {
bits
};
if crate::value::addr_class::is_proxy_id_band(raw as usize) {
const POINTER_TAG: u64 = 0x7FFD_0000_0000_0000;
let boxed = f64::from_bits(POINTER_TAG | raw);
if crate::proxy::js_proxy_is_proxy(boxed) != 0 {
return Some(boxed);
}
}
None
}

/// Normalize an Array.prototype method receiver into a real ArrayHeader.
///
/// `Array.prototype.<method>.call(arrayLike, ...)` lets a *generic array-like
Expand Down
13 changes: 13 additions & 0 deletions crates/perry-runtime/src/array/indexing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,19 @@ fn array_get_property_by_key(arr: *const ArrayHeader, key: *const crate::StringH

#[no_mangle]
pub extern "C" fn js_array_length(arr: *const ArrayHeader) -> u32 {
// #5135: a Proxy typed (statically) as an array (immer drafts) reaches here
// with the masked proxy id. Read `length` through the proxy `get` trap
// rather than deref-ing the id as an `ArrayHeader`.
if let Some(proxy) = array_ptr_as_proxy(arr) {
let key = crate::string::js_string_from_bytes(b"length".as_ptr(), 6);
let key_f64 = crate::value::js_nanbox_string(key as i64);
let n = crate::builtins::js_number_coerce(crate::proxy::js_proxy_get(proxy, key_f64));
return if n.is_finite() && n > 0.0 {
n.min(u32::MAX as f64) as u32
} else {
0
};
}
let arr = {
let bits = arr as u64;
let top16 = bits >> 48;
Expand Down
2 changes: 1 addition & 1 deletion crates/perry-runtime/src/array/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ pub(crate) use self::header::{
array_named_property_get, array_named_property_get_by_name, array_named_property_has,
array_named_property_names, array_named_property_set, array_numeric_raw_f64_get,
array_numeric_raw_f64_push_inbounds, array_numeric_raw_f64_set_inbounds, array_object_flags,
canonicalize_array_numeric_store_value, clean_arr_ptr, clean_arr_ptr_mut,
array_ptr_as_proxy, canonicalize_array_numeric_store_value, clean_arr_ptr, clean_arr_ptr_mut,
clear_array_numeric_layout, clear_array_numeric_layout_ptr, gc_element_slot_range,
mark_array_layout_unknown, normalize_array_receiver, note_array_slot,
note_array_slot_layout_only, rebuild_array_layout, rebuild_array_layout_exact,
Expand Down
38 changes: 38 additions & 0 deletions crates/perry-runtime/src/array/push_pop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -137,9 +137,47 @@ pub extern "C" fn js_array_grow(arr: *mut ArrayHeader, min_capacity: u32) -> *mu
}

/// Push an element to the end of an array, growing if needed
/// #5135: read `Get(proxy, "length")` and ToLength-coerce it. Used by the
/// proxy-array push path so immer drafts (Proxies typed as arrays) mutate
/// through their traps instead of a native ArrayHeader deref.
unsafe fn proxy_array_length(proxy: f64) -> u64 {
let key = crate::string::js_string_from_bytes(b"length".as_ptr(), 6);
let key_f64 = crate::value::js_nanbox_string(key as i64);
let n = crate::builtins::js_number_coerce(crate::proxy::js_proxy_get(proxy, key_f64));
if n.is_finite() && n >= 0.0 {
n as u64
} else {
0
}
}

/// #5135: `Set(proxy, <string key>, value)` through the proxy's `set` trap. The
/// key string is allocated fresh per call so an intervening GC can't leave a
/// stale interior pointer.
unsafe fn proxy_set_str_key(proxy: f64, key_bytes: &[u8], value: f64) {
let key = crate::string::js_string_from_bytes(key_bytes.as_ptr(), key_bytes.len() as u32);
let key_f64 = crate::value::js_nanbox_string(key as i64);
crate::proxy::js_proxy_set(proxy, key_f64, value);
Comment on lines +157 to +160

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Do not discard failed proxy set results. The new proxy write paths call js_proxy_set but ignore its boolean result, so a set trap returning false silently succeeds instead of failing the strict/Throw=true write.

  • crates/perry-runtime/src/array/push_pop.rs#L157-L160: make proxy_set_str_key return whether js_proxy_set was truthy.
  • crates/perry-runtime/src/array/push_pop.rs#L176-L178: check both the indexed-element write and the "length" write, and throw TypeError if either returns false.
  • crates/perry-runtime/src/object/field_set_by_name.rs#L198-L200: check the proxy assignment result and throw on false for the strict property-write path.
📍 Affects 2 files
  • crates/perry-runtime/src/array/push_pop.rs#L157-L160 (this comment)
  • crates/perry-runtime/src/array/push_pop.rs#L176-L178
  • crates/perry-runtime/src/object/field_set_by_name.rs#L198-L200
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-runtime/src/array/push_pop.rs` around lines 157 - 160, The
`js_proxy_set` function returns a boolean indicating whether the proxy set
operation succeeded, but the return values are being ignored, causing failed
proxy writes to silently succeed instead of throwing errors in strict mode. In
crates/perry-runtime/src/array/push_pop.rs#L157-L160, modify the
`proxy_set_str_key` function to return the boolean result from `js_proxy_set`
instead of discarding it. In
crates/perry-runtime/src/array/push_pop.rs#L176-L178, check the return value
from both the indexed-element write (the call to `proxy_set_str_key`) and the
"length" write (the `js_proxy_set` call), and throw a `TypeError` if either
returns false. In
crates/perry-runtime/src/object/field_set_by_name.rs#L198-L200, check the return
value from the proxy assignment (`js_proxy_set`) and throw a `TypeError` on
false for the strict property-write path.

}

/// Returns a pointer to the (possibly reallocated) array
#[no_mangle]
pub extern "C" fn js_array_push_f64(arr: *mut ArrayHeader, value: f64) -> *mut ArrayHeader {
// #5135: a Proxy whose static type is an array (immer drafts) reaches here
// with the masked proxy id. Perform the spec `Array.prototype.push` for a
// single element directly through the proxy's `get`/`set` traps:
// len = ToLength(Get(P, "length")); Set(P, len, value); Set(P, "length", len+1)
// Routing through the native push (`js_native_call_method`) would recurse
// back here with the same proxy. Return `arr` unchanged so the codegen's
// realloc write-back is a no-op (the proxy mutates its target in place).
if let Some(proxy) = array_ptr_as_proxy(arr) {
let len = unsafe { proxy_array_length(proxy) };
unsafe {
proxy_set_str_key(proxy, len.to_string().as_bytes(), value);
proxy_set_str_key(proxy, b"length", (len as f64) + 1.0);
}
Comment on lines +173 to +178

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Root value before proxy length/get/set work.

proxy_array_length and proxy_set_str_key can allocate and run proxy traps before value is rooted; if value is a NaN-boxed heap pointer, a moving GC can leave the later indexed write with stale bits.

Suggested fix
     if let Some(proxy) = array_ptr_as_proxy(arr) {
+        let scope = crate::gc::RuntimeHandleScope::new();
+        let value_handle = scope.root_nanbox_f64(value);
         let len = unsafe { proxy_array_length(proxy) };
         unsafe {
-            proxy_set_str_key(proxy, len.to_string().as_bytes(), value);
+            proxy_set_str_key(proxy, len.to_string().as_bytes(), value_handle.get_nanbox_f64());
             proxy_set_str_key(proxy, b"length", (len as f64) + 1.0);
         }
         return arr;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-runtime/src/array/push_pop.rs` around lines 173 - 178, The
`value` parameter must be rooted before any proxy operations that could trigger
allocation or garbage collection. In the unsafe block where `proxy_array_length`
and `proxy_set_str_key` are called, root `value` at the beginning of the
function or immediately after the `if let Some(proxy)` check (before calling
`proxy_array_length`) to prevent a moving GC from invalidating the NaN-boxed
heap pointer during the subsequent proxy operations in this code block.

return arr;
}
let arr = clean_arr_ptr_mut(arr);
if arr.is_null() {
return js_array_alloc(0);
Expand Down
21 changes: 21 additions & 0 deletions crates/perry-runtime/src/object/field_set_by_name.rs
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,27 @@ pub extern "C" fn js_object_set_field_by_name(
key: *const crate::StringHeader,
value: f64,
) {
// #5135: the receiver may be a Proxy id arriving with its NaN-box tag
// already masked off (the `obj.prop++` / `PropertyUpdate` codegen path
// hands us the bare pointer band, not the full POINTER_TAG value). A Proxy
// is encoded as a small registered id; deref-ing one as an `ObjectHeader`
// reads unmapped memory and SIGSEGVs. Mirror the read-side dispatch in
// `js_object_get_field_by_name` so a `proxy.foo = v` write goes through the
// `set` trap instead of corrupting the cell. `js_proxy_is_proxy` validates
// the value is a *registered* proxy so a real heap object whose masked
// address happens to be small isn't misrouted.
{
let addr = obj as u64;
if crate::value::addr_class::is_proxy_id_band(addr as usize) && !key.is_null() {
const POINTER_TAG: u64 = 0x7FFD_0000_0000_0000;
let boxed = f64::from_bits(POINTER_TAG | (addr & 0x0000_FFFF_FFFF_FFFF));
if crate::proxy::js_proxy_is_proxy(boxed) != 0 {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
let key_f64 = f64::from_bits(crate::value::js_nanbox_string(key as i64).to_bits());
crate::proxy::js_proxy_set(boxed, key_f64, value);
return;
}
}
}
// `Object.prototype["2"] = v` (stringified-index write) makes the index
// visible through array hole/OOB reads. Cheap gate: one relaxed flag
// load, then an address compare against the cached canonical
Expand Down
122 changes: 122 additions & 0 deletions crates/perry/tests/issue_5135_proxy_compound_and_function_tostring.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
//! Regression tests for #5135: importing `immer` and calling
//! `produce(base, draft => { draft.count++; draft.list.push(3) })` crashed with
//! SIGSEGV. immer's drafts are `Proxy` objects that are statically typed as the
//! plain base type, which exposed three independent Perry bugs. These tests
//! reproduce each with a plain `Proxy` (no immer dependency needed):
//!
//! 1. A compound-assignment write through a `Proxy` (`p.count++`) lowered to
//! `js_object_set_field_by_name` with the proxy's NaN-box tag masked off;
//! the runtime had no proxy branch there and dereferenced the masked id as
//! an `ObjectHeader` → SIGSEGV. (The read side already routed proxies.)
//! 2. The statically-typed `Function.toString` static-member read collapsed to
//! `globalThis.toString` and folded to a number, so
//! `Function.toString.call(Ctor)` (immer's `isPlainObject`) threw
//! "Function.prototype.call was called on a value that is not a function".
//! 3. A native array method / `length` read on a value that is a `Proxy` at
//! runtime (`draft.list.push(x)`) dereferenced the masked proxy id as an
//! `ArrayHeader` → SIGSEGV. The array helpers now route a proxy receiver
//! through its traps.

use std::path::PathBuf;
use std::process::Command;

fn perry_bin() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_perry"))
}

fn compile_and_run(source: &str) -> String {
let dir = tempfile::tempdir().expect("tempdir");
let root = dir.path();
let entry = root.join("main.ts");
let output = root.join("main_bin");
std::fs::write(&entry, source).expect("write entry");

let compile = Command::new(perry_bin())
.current_dir(root)
.arg("compile")
.arg(&entry)
.arg("-o")
.arg(&output)
.arg("--no-cache")
.output()
.expect("run perry compile");
assert!(
compile.status.success(),
"perry compile failed\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&compile.stdout),
String::from_utf8_lossy(&compile.stderr)
);

let run = Command::new(&output).output().expect("run compiled binary");
assert!(
run.status.success(),
"compiled binary failed (signal/exit) — likely a SIGSEGV regression\nstatus: {:?}\nstdout:\n{}\nstderr:\n{}",
run.status,
String::from_utf8_lossy(&run.stdout),
String::from_utf8_lossy(&run.stderr)
);
String::from_utf8_lossy(&run.stdout).to_string()
}

/// Fix #1: `proxy.count++` writes through the `set` trap instead of crashing.
#[test]
fn proxy_compound_assignment_routes_through_set_trap() {
let out = compile_and_run(
r#"
const target: any = { count: 0 };
const p: any = new Proxy(target, {
get(t: any, k: any) { return t[k]; },
set(t: any, k: any, v: any) { t[k] = v; return true; },
});
p.count++;
console.log(p.count, target.count);
"#,
);
assert_eq!(
out, "1 1\n",
"p.count++ must write through the proxy set trap"
);
}

/// Fix #2: `Function.toString` (and `Array.toString`) read as a value are real
/// functions, not numbers.
#[test]
fn function_tostring_static_member_is_callable() {
let out = compile_and_run(
r#"
console.log(typeof Function.toString);
console.log(typeof Array.toString);
// immer's isPlainObject reaches `Function.toString.call(Ctor)`:
console.log(typeof Function.toString.call(Array));
"#,
);
assert_eq!(
out, "function\nfunction\nstring\n",
"Function.toString / Array.toString must resolve to callable functions"
);
}

/// Fix #3: a native array method (`push`) on a value that is a Proxy at runtime
/// dispatches through the proxy's traps instead of dereferencing the masked
/// proxy id as an ArrayHeader. This mirrors immer's `draft.list.push(x)`, where
/// the receiver is a member access (`obj.list`) that returns a proxy array — the
/// `js_array_push_f64` runtime helper path the issue actually exercised.
#[test]
fn proxy_array_push_via_member_routes_through_traps() {
let out = compile_and_run(
r#"
const target: any = [1, 2];
const inner: any = new Proxy(target, {
get(t: any, k: any) { return t[k]; },
set(t: any, k: any, v: any) { t[k] = v; return true; },
});
const holder: any = { list: inner };
holder.list.push(3);
console.log(target.join(","), holder.list.length);
"#,
);
assert_eq!(
out, "1,2,3 3\n",
"obj.list.push must mutate the proxied array through its set trap"
);
}