Skip to content

fix: route Proxy receivers through traps; fix Function.toString static read (#5135)#5184

Merged
proggeramlug merged 1 commit into
mainfrom
fix/immer-5135-proxy-compound-assign
Jun 15, 2026
Merged

fix: route Proxy receivers through traps; fix Function.toString static read (#5135)#5184
proggeramlug merged 1 commit into
mainfrom
fix/immer-5135-proxy-compound-assign

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes #5135. Importing immer and calling produce(base, d => { d.count++; d.list.push(3) }) crashed with SIGSEGV (exit 139, no output).

immer's drafts are Proxy objects that are statically typed as the plain base type. That combination exposed three independent bugs in Perry. The exact issue repro now prints 0 1 1,2,3 2, matching Node.

Root causes & fixes

1. Compound assignment through a Proxy (d.count++) → SIGSEGV
PropertyUpdate codegen writes via js_object_set_field_by_name with the proxy's NaN-box tag masked off. The write path had no proxy branch (only the read path, js_object_get_field_by_name, did), so it dereferenced the masked proxy id as an ObjectHeader. Mirror the read-side dispatch: a registered proxy receiver routes to js_proxy_set.
crates/perry-runtime/src/object/field_set_by_name.rs

2. Function.toString static read folded to a number
The statically-typed Function.toString collapsed to globalThis.toString (folded to 0.0), so Function.toString.call(Ctor) in immer's isPlainObject threw "Function.prototype.call was called on a value that is not a function". toString is a universal inherited method; added it to the reroute-undo keep-list next to valueOf/hasOwnProperty/… so the reified constructor receiver is preserved.
crates/perry-hir/src/lower/expr_member.rs

3. Native array op on a runtime Proxy (d.list.push(3)) → SIGSEGV
js_array_push_f64 / js_array_length dereferenced the masked proxy id as an ArrayHeader. They now detect a registered proxy receiver and operate through its traps — push performs the spec single-element push (Get(P,"length"), Set(P,len,v), Set(P,"length",len+1)), and length reads through the get trap. (Delegating to the native method dispatcher would re-enter the same helper; the spec ops avoid the recursion.)
crates/perry-runtime/src/array/{push_pop,indexing,header,mod}.rs

Verification

  • Exact issue repro → 0 1 1,2,3 2 (matches Node), on origin/main.
  • New regression test crates/perry/tests/issue_5135_proxy_compound_and_function_tostring.rs (3 tests, plain Proxy, no immer dep) — pass.
  • cargo test green for perry-runtime (1035), perry-hir, perry-codegen, perry-transform.
  • The proxy guards are gated on the registered-proxy id band, so real arrays/objects take one extra range-compare and are otherwise untouched.

Known follow-ups (out of scope, same architectural class)

Some immer-shaped patterns still hit Perry's array fast paths that trust static types over a runtime Proxy:

  • a bare-local array-root draft (produce([1,2], d => d.push(3))) lowers to the inline ArrayPush LLVM path, which derefs the receiver directly;
  • an array-index → sub-draft mutation (d.items[0].n = 9) routes index-get through a native array path.

These are deeper (hot inline codegen) and tracked separately.

🤖 Generated with Claude Code

Summary by CodeRabbit

Release Notes

  • Bug Fixes

    • Fixed inherited prototype handling so toString preserves the correct receiver context.
    • Improved Proxy-wrapped arrays: correct length handling and Proxy-based push behavior.
    • Enhanced object property setting when the receiver is a registered Proxy, ensuring writes route through Proxy traps safely.
  • Tests

    • Added regression coverage for Proxy compound assignments and Function.toString/Array.toString, plus Proxy array mutation via push.

@coderabbitai

coderabbitai Bot commented Jun 15, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: cee9c5dd-03d2-4362-a1cd-fec1733d05ab

📥 Commits

Reviewing files that changed from the base of the PR and between d55fb41 and 6569620.

📒 Files selected for processing (7)
  • crates/perry-hir/src/lower/expr_member.rs
  • crates/perry-runtime/src/array/header.rs
  • crates/perry-runtime/src/array/indexing.rs
  • crates/perry-runtime/src/array/mod.rs
  • crates/perry-runtime/src/array/push_pop.rs
  • crates/perry-runtime/src/object/field_set_by_name.rs
  • crates/perry/tests/issue_5135_proxy_compound_and_function_tostring.rs
🚧 Files skipped from review as they are similar to previous changes (5)
  • crates/perry-runtime/src/array/indexing.rs
  • crates/perry-runtime/src/object/field_set_by_name.rs
  • crates/perry-runtime/src/array/header.rs
  • crates/perry-runtime/src/array/push_pop.rs
  • crates/perry/tests/issue_5135_proxy_compound_and_function_tostring.rs

📝 Walkthrough

Walkthrough

Fixes SIGSEGV in immer produce() by adding a array_ptr_as_proxy helper that detects NaN-boxed proxy IDs masked as array pointers. Array length and push operations, and object field writes, gain early proxy dispatch paths routing through proxy traps. The HIR inherited-method predicate is extended to include toString. Three regression tests are added.

Changes

Proxy Dispatch and toString HIR Fix (issue #5135)

Layer / File(s) Summary
Masked-proxy detection helper
crates/perry-runtime/src/array/header.rs, crates/perry-runtime/src/array/mod.rs
Adds array_ptr_as_proxy, which bit-unmasks an array pointer, checks the proxy-ID address band, re-boxes with POINTER_TAG, and validates via js_proxy_is_proxy. Re-export list reformatted to include the new function.
Proxy-aware array length and push
crates/perry-runtime/src/array/indexing.rs, crates/perry-runtime/src/array/push_pop.rs
js_array_length gains an early proxy branch reading "length" via the proxy get trap. js_array_push_f64 adds a proxy fast path using two new unsafe helpers (proxy_array_length, proxy_set_str_key) to route push through proxy get/set traps.
Proxy dispatch in object field set
crates/perry-runtime/src/object/field_set_by_name.rs
js_object_set_field_by_name adds an early address-band check; proxy-ID receivers are forwarded to js_proxy_set before the function can dereference the receiver as an ObjectHeader.
HIR: toString added to inherited method predicate
crates/perry-hir/src/lower/expr_member.rs
The reroute-undo set in lower_member_inner is extended to include toString, preventing receiver collapse to globalThis.toString.
Regression tests
crates/perry/tests/issue_5135_proxy_compound_and_function_tostring.rs
Three integration tests compile and run TypeScript snippets via the Perry CLI, covering proxy compound assignment, proxy-based array.push trap dispatch, and Function.toString callable resolution without SIGSEGV.

Sequence Diagram(s)

sequenceDiagram
    participant TS as TypeScript (immer draft)
    participant JSArrayPush as js_array_push_f64
    participant ArrayPtrAsProxy as array_ptr_as_proxy
    participant ProxyTraps as Proxy get/set traps
    participant JSObjectSet as js_object_set_field_by_name
    participant JSProxySet as js_proxy_set

    TS->>JSArrayPush: holder.list.push(3) → arr, value=3
    JSArrayPush->>ArrayPtrAsProxy: arr
    ArrayPtrAsProxy-->>JSArrayPush: Some(boxed proxy)
    JSArrayPush->>ProxyTraps: get proxy["length"] → len
    JSArrayPush->>ProxyTraps: set proxy[len] = 3
    JSArrayPush->>ProxyTraps: set proxy["length"] = len+1
    JSArrayPush-->>TS: arr (no native realloc)

    TS->>JSObjectSet: p.count++ → receiver=proxy_id, key="count"
    JSObjectSet->>JSObjectSet: address-band check → proxy detected
    JSObjectSet->>JSProxySet: NaN-boxed receiver, boxed key, value
    JSProxySet-->>TS: write routed through set trap
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • PerryTS/perry#5146: Both PRs modify js_object_set_field_by_name in crates/perry-runtime/src/object/field_set_by_name.rs, with this PR adding proxy-ID early dispatch and #5146 reordering accessor/frozen checks in the same function.

Poem

🐇 Hop hop, the proxy was masked in disguise,
A pointer that wore an array's disguise,
I sniffed out the band where the proxy IDs hide,
Re-boxed it with POINTER_TAG, let the traps guide.
toString joins the inherited crew,
No more SIGSEGV — immer drafts run true! 🥕

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and accurately summarizes the two main fixes: routing Proxy receivers through traps and fixing Function.toString static read, matching the core changes across the codebase.
Description check ✅ Passed The description comprehensively documents the three root causes, fixes, code locations, verification steps, and known follow-ups. It clearly explains the SIGSEGV issue, root causes, and solutions with proper section structure.
Linked Issues check ✅ Passed The PR fully addresses issue #5135 by fixing all three bugs (Proxy compound assignment, Function.toString folding, and array operations on Proxy) and includes a regression test that validates the exact reproduction case outputs the expected Node.js-compatible result.
Out of Scope Changes check ✅ Passed All changes are directly scoped to fixing the three identified bugs from issue #5135: Proxy dispatch in object writes, Function.toString in member access, and array operations on Proxy receivers. Known follow-ups are explicitly noted as out of scope.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/immer-5135-proxy-compound-assign

Comment @coderabbitai help to get the list of available commands and usage tips.

… + array ops; fix Function.toString static read (#5135)

Importing `immer` and calling `produce(base, d => { d.count++; d.list.push(3) })`
crashed with SIGSEGV. immer drafts are `Proxy` objects statically typed as the
plain base type, which exposed three independent bugs:

1. A compound-assignment write through a Proxy (`d.count++`) lowers to
   `js_object_set_field_by_name` with the proxy's NaN-box tag masked off. The
   write path had no proxy branch (only the read path did), so it dereferenced
   the masked proxy id as an `ObjectHeader` → SIGSEGV. Mirror the read-side
   dispatch: a registered proxy receiver routes to `js_proxy_set`.

2. The statically-typed `Function.toString` static-member read collapsed to
   `globalThis.toString` (folded to a number), so `Function.toString.call(Ctor)`
   in immer's `isPlainObject` threw "Function.prototype.call was called on a
   value that is not a function". `toString` is a universal inherited method;
   add it to the reroute-undo keep-list alongside `valueOf`/`hasOwnProperty`/…
   so the reified constructor receiver is preserved.

3. A native array method (`push`) / `length` read on a value that is a Proxy at
   runtime (`d.list.push(x)`) dereferenced the masked proxy id as an
   `ArrayHeader` → SIGSEGV. `js_array_push_f64` now performs the spec push for a
   single element through the proxy's `get`/`set` traps, and `js_array_length`
   reads `length` through the `get` trap, when the receiver is a registered
   proxy. (Recursing through the native method dispatcher would re-enter the
   same helper; the spec ops avoid that.)

The exact issue repro now prints `0 1 1,2,3 2`, matching Node.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@proggeramlug proggeramlug force-pushed the fix/immer-5135-proxy-compound-assign branch from d55fb41 to 6569620 Compare June 15, 2026 09:15

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Prompt for all review comments with 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.

Inline comments:
In `@crates/perry-runtime/src/array/push_pop.rs`:
- Around line 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.
- Around line 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.

In `@crates/perry-runtime/src/object/field_set_by_name.rs`:
- Around line 193-197: The proxy-band check in the function is being performed
on a masked address value before it's been properly unmasked. Move the unmasking
operation (creating the boxed value with POINTER_TAG) before the
is_proxy_id_band check in the if statement, so that the proxy ID band detection
works on the actual unmasked receiver value rather than the masked
representation. This ensures that NaN-boxed pointer receivers cannot bypass
proxy dispatch by remaining in their masked form.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 25d6cddc-293f-477c-be53-bb69ca8c8d9a

📥 Commits

Reviewing files that changed from the base of the PR and between 39e2166 and d55fb41.

📒 Files selected for processing (7)
  • crates/perry-hir/src/lower/expr_member.rs
  • crates/perry-runtime/src/array/header.rs
  • crates/perry-runtime/src/array/indexing.rs
  • crates/perry-runtime/src/array/mod.rs
  • crates/perry-runtime/src/array/push_pop.rs
  • crates/perry-runtime/src/object/field_set_by_name.rs
  • crates/perry/tests/issue_5135_proxy_compound_and_function_tostring.rs

Comment on lines +157 to +160
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);

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.

Comment on lines +173 to +178
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);
}

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.

Comment thread crates/perry-runtime/src/object/field_set_by_name.rs
@proggeramlug proggeramlug merged commit 51da716 into main Jun 15, 2026
15 checks passed
@proggeramlug proggeramlug deleted the fix/immer-5135-proxy-compound-assign branch June 15, 2026 09:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

compilePackages: immer produce() segfaults (SIGSEGV)

1 participant