Skip to content

fix(runtime): route Proxy values through [[Call]] in the ad-hoc closure probes (#6320)#6346

Merged
proggeramlug merged 1 commit into
mainfrom
fix/6320-closure-magic-probe-handle-band
Jul 13, 2026
Merged

fix(runtime): route Proxy values through [[Call]] in the ad-hoc closure probes (#6320)#6346
proggeramlug merged 1 commit into
mainfrom
fix/6320-closure-magic-probe-handle-band

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Fixes #6320.

The bug

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).

Verified non-vacuously against a pristine origin/main build (735e894, own worktree + own target): both fixtures below exit 139 there and 0 here.

Why a band guard alone wasn't enough

As @-noted on #6321, these sites have no proxy-aware fallback below them: a guard stops the crash but yields "not a closure" instead of dispatching the trap. So each site decides what node does and routes there.

site value node perry now
symbol/iterator.rs js_to_primitive obj[Symbol.toPrimitive] = new Proxy(fn, {}) calls it: Call(method, obj, «hint») routes to Proxy [[Call]] with this = obj and the hint string as the single arg. Apply trap honored; revoked proxy throws TypeError; a proxy over a non-callable target falls through to the existing "not a method" return (Perry does not throw for a non-callable @@toPrimitive in general — unchanged, and out of scope here)
jsx.rs function component <P /> where P = new Proxy(Component, {}) React calls the component through [[Call]] dispatches via js_native_call_value's proxy branch (components take no this). 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 reserved this-slot patch n/a (a proxy has no capture array) one bind_reserved_this_slot helper that no-ops on anything that is not a real heap closure, and stores the value verbatim; the call site then dispatches it through [[Call]]

New proxy::call_proxy_value_with_this builds the spec argArray for these callers, keeping every operand rooted across the allocations.

Extra sites found by the sweep

Both fire on the same repro, so the fixture would still have crashed without them:

  • 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(fn).

All the probes now delegate to closure::is_closure_ptr, which owns the band + heap-range + alignment + magic checks.

The TypeError that guarding revealed

Guarding those two turned the SIGSEGV into a spurious TypeError: value is not a function. js_closure_callN receives a RAW *const ClosureHeader — codegen already stripped the tag in js_closure_unbox_callee_checked — and get_valid_func_ptr correctly refuses to dereference a proxy id (#5976/#6321). The compiler only emits a ProxyApply node when it can statically prove the callee is a proxy, so a proxy read out of any dynamically-typed slot could not be called at all:

const p: any = new Proxy(function () { return "x"; }, {});
p();                                  // worked (static hint)
const arr: any[] = [p]; arr[0]();     // TypeError: value is not a function
({ m: p }).m();                       // TypeError
new Map([["k", p]]).get("k")();       // TypeError

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 throws exactly as before. It runs only on the branch that previously threw unconditionally, so there is no hot-path cost. All four shapes above now match node.

Lint gate (scripts/addr_class_inventory.py)

The addr-class ratchet could not see any of the closure probes: HANDLE_FLOOR_RE only matches a floor written as a comparison, but these were written as (0x1000..HEAP_MAX).contains(&addr) — the literal is followed by .., and the address operand appears afterwards inside .contains(&…). That blind spot is exactly why they survived the #6279 sweep.

Added HANDLE_FLOOR_RANGE_RE for the range form, reported under the existing handle-floor rule so it shares that per-file ratchet. A band predicate guarding the next few code lines (comments skipped) clears it — that is the #6321 remediation shape, so the rule accepts a fix instead of blocking it. Self-tests cover the flag, the clear, and the correct-boundary non-finding.

The regenerated baseline drops the six sites fixed here and records ten previously-invisible range-form sites (native_abi.rs, buffer/query.rs, timer.rs, node_vm.rs, …) as debt. Those are raw-pointer classifiers followed by registry lookups, not derefs — surfaced, not introduced.

Tests

  • test-files/test_gap_6320_proxy_closure_probes.ts — node-compared gap test covering all five runtime sites (toPrimitive proxy + apply trap + hints + nested + revoked, computed-symbol methods, spread method-by-name, proxy under a computed key before/after a spread, detached proxy method). Byte-identical to node.
  • test-files/test_issue_6320_jsx_proxy_component.tsx — end-to-end JSX repro (run_parity_tests.sh only globs *.ts, and node can't run Perry's server-JSX runtime, so this is a checked-in repro, not a suite entry).
  • jsx.rs unit tests are the CI gate for the JSX site: proxy_wrapped_function_component_dispatches_through_call, proxy_over_non_callable_target_is_not_a_component, closure_function_component_renders (control), is_valid_closure_rejects_the_handle_band (every band id rejected without a deref).

Verification

  • Gap fixture and JSX repro: SIGSEGV (139) on pristine origin/main, exit 0 and byte-identical to node here.
  • PERRY_GC_FORCE_EVACUATE=1 PERRY_GC_VERIFY_EVACUATION=1 PERRY_GC_DIAG=1 on an allocation-heavy variant: 120 diagnosed GC cycles with live forwarding stubs, no verifier panic, output still matches node. (The plain fixture is too small to trigger a collection — a forced-evacuate run over it is vacuous.)
  • cargo test -p perry-runtime: 1288 passed / 1 failed — array::tests::test_array_exotic_descriptors_and_global_prototype_identity, the known parallel-suite flake (passes 5/5 standalone, unrelated to this change).
  • Full 297-test gap suite A/B against a pristine origin/main build: zero regressions. The only deltas are test_gap_6320_proxy_closure_probes (CRASH 139 → PASS) and test_gap_node_fs, the known parallel-harness flake — 5/5 serially on both sides. test_gap_http_overloads_3226plus aborts (134) on both sides (pre-existing).
  • cargo fmt --all -- --check, scripts/check_file_size.sh, scripts/addr_class_inventory.py --self-test and the audit itself all clean.

Summary by CodeRabbit

  • Bug Fixes

    • Improved support for calling functions wrapped in Proxy values, including proxied JSX components.
    • Prevented crashes when proxy values appear in closure-related properties and method calls.
    • Improved handling of proxied [Symbol.toPrimitive] methods and non-callable proxy targets.
    • Strengthened safety checks for invalid closure references.
  • Tests

    • Added coverage for proxy-wrapped functions, JSX components, symbol methods, nested proxies, and revocable proxies.

…re probes (#6320)

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): `<P />` 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.
@coderabbitai

coderabbitai Bot commented Jul 13, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The runtime now routes proxy-valued closure slots through proxy call semantics, centralizes closure-pointer validation, updates JSX and symbol handling, extends handle-floor detection, and adds regression tests for proxy-based methods and components.

Changes

Proxy closure dispatch

Layer / File(s) Summary
Proxy call dispatch
crates/perry-runtime/src/closure/dispatch/*, crates/perry-runtime/src/proxy/*
Adds rooted proxy invocation and routes all closure arities through proxy dispatch when direct function-pointer validation fails.
Centralized closure-pointer validation
crates/perry-runtime/src/closure/dynamic_props.rs, crates/perry-runtime/src/symbol/properties.rs
Replaces unsafe pointer-floor and magic probes with centralized closure or GC-header validation, including shared this binding logic.
Symbol.toPrimitive proxy flow
crates/perry-runtime/src/symbol/iterator.rs
Invokes callable proxy methods through proxy semantics and validates ordinary closure methods before dereferencing metadata.
JSX dispatch and validation coverage
crates/perry-runtime/src/jsx.rs, scripts/*, test-files/*
Adds callable-proxy JSX dispatch, handle-floor inventory detection and baselines, and regression coverage for proxy methods, JSX components, and low handle ids.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant JSXRuntime
  participant is_valid_closure
  participant js_proxy_is_proxy
  participant js_native_call_value
  JSXRuntime->>is_valid_closure: validate component pointer
  JSXRuntime->>js_proxy_is_proxy: classify proxy component
  js_proxy_is_proxy-->>JSXRuntime: callable proxy result
  JSXRuntime->>js_native_call_value: invoke callable proxy with props
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed Title matches the PR's main change: Proxy values are routed through [[Call]] to fix the closure-probe crashes.
Description check ✅ Passed The description covers the issue, fix rationale, changed areas, tests, and verification, though it isn't in the exact template shape.
Linked Issues check ✅ Passed The code addresses #6320 by fixing the three Proxy closure probes, preserving non-callable behavior, and adding proxy-call fallbacks/tests.
Out of Scope Changes check ✅ Passed The touched scripts, baseline updates, and test fixtures align with the stated Proxy-probe fix and lint-detection work.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
✨ 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/6320-closure-magic-probe-handle-band

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@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: 1

🤖 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/symbol/properties.rs`:
- Around line 590-606: Update bind_reserved_this_slot so the reserved capture
write goes through the existing barriered closure-capture setter, preserving the
same real_count - 1 slot and obj_f64 value. Reuse the path involving
note_closure_capture_slot/runtime_write_barrier_gc_slot instead of writing
directly through captures_ptr.
🪄 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: ba61f2ad-5bf4-4468-ad58-6b6ec9b4da3c

📥 Commits

Reviewing files that changed from the base of the PR and between be5ed2b and 2e0852e.

📒 Files selected for processing (13)
  • crates/perry-runtime/src/closure/dispatch.rs
  • crates/perry-runtime/src/closure/dispatch/calln.rs
  • crates/perry-runtime/src/closure/dispatch/validate.rs
  • crates/perry-runtime/src/closure/dynamic_props.rs
  • crates/perry-runtime/src/jsx.rs
  • crates/perry-runtime/src/proxy.rs
  • crates/perry-runtime/src/proxy/apply_construct.rs
  • crates/perry-runtime/src/symbol/iterator.rs
  • crates/perry-runtime/src/symbol/properties.rs
  • scripts/addr_class_inventory.py
  • scripts/addr_class_ratchet_baseline.txt
  • test-files/test_gap_6320_proxy_closure_probes.ts
  • test-files/test_issue_6320_jsx_proxy_component.tsx

Comment on lines +590 to 606
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::<crate::closure::ClosureHeader>())
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::<crate::closure::ClosureHeader>())
as *mut f64;
*captures_ptr.add((real_count - 1) as usize) = obj_f64;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Inspect how rebuild_closure_layout_and_barriers is implemented and whether an
# equivalent barrier is applied elsewhere for single-slot capture mutations.
rg -n "rebuild_closure_layout_and_barriers|GC_STORE_AUDIT|write_barrier" crates/perry-runtime/src -A3 -B3

Repository: PerryTS/perry

Length of output: 50370


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map the relevant files first.
ast-grep outline crates/perry-runtime/src/symbol/properties.rs --view expanded
ast-grep outline crates/perry-runtime/src/closure/dynamic_props.rs --view expanded
ast-grep outline crates/perry-runtime/src/gc/copying.rs --view expanded

# Read the exact function bodies and nearby comments.
sed -n '560,650p' crates/perry-runtime/src/symbol/properties.rs
sed -n '1,260p' crates/perry-runtime/src/closure/dynamic_props.rs
sed -n '1,260p' crates/perry-runtime/src/gc/copying.rs

# Find all closure capture-slot writes and any barrier helpers used with them.
rg -n "capture_count|capture slot|rebuild_closure_layout_and_barriers|runtime_write_barrier|GC_STORE_AUDIT\\(" crates/perry-runtime/src/closure crates/perry-runtime/src/gc crates/perry-runtime/src/symbol -A3 -B3

Repository: PerryTS/perry

Length of output: 50369


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find the allocation/call sites for the two helpers.
rg -n "js_object_set_symbol_method|js_object_set_method_by_name|bind_reserved_this_slot|captures_this=true|lower_object_literal|CAPTURES_THIS_FLAG" crates/perry-runtime crates/perry-codegen -A4 -B4

# Inspect the closure allocation and slot-barrier helpers.
rg -n "closure_capture_slots_mut|rebuild_closure_layout_and_barriers|js_closure_alloc|js_closure_alloc_with_captures|note_closure_capture_slot|runtime_write_barrier_slot|runtime_write_barrier_external_slot" crates/perry-runtime/src/closure crates/perry-runtime/src/gc -A4 -B4

# Inspect the copying GC remembered-set logic around old->young slot tracking.
sed -n '330,390p' crates/perry-runtime/src/gc/copying.rs
sed -n '560,720p' crates/perry-runtime/src/gc/copying.rs

# Read the caller-side codegen path if it exists in the repository.
fd -a 'expr.rs' crates/perry-codegen crates/perry-runtime | sed -n '1,20p'

Repository: PerryTS/perry

Length of output: 50370


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the object-literal lowering path that feeds js_object_set_*_method.
sed -n '306,520p' crates/perry-codegen/src/expr/object_literal.rs

# Inspect closure allocation and any post-write scanning/layout hooks.
sed -n '1,240p' crates/perry-runtime/src/closure/alloc.rs
sed -n '1,220p' crates/perry-runtime/src/closure/mod.rs

# Inspect how closure capture slots are scanned or remembered by the GC.
rg -n "closure_capture_slots_mut|scan_singleton_closure_roots_mut|scan_closure|rebuild_closure_layout_and_barriers|runtime_write_barrier_slot|StickyRememberedSet|remember_slot" crates/perry-runtime/src -A4 -B4

# If the codegen lowers methods through a helper, show that helper call site too.
rg -n "js_object_set_symbol_method|js_object_set_method_by_name|captures_this=true" crates/perry-codegen/src/expr crates/perry-runtime/src -A4 -B4

Repository: PerryTS/perry

Length of output: 50369


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Read the closure capture setter and related helpers.
rg -n "js_closure_set_capture_bits|js_closure_set_capture_f64|js_closure_set_capture_ptr|note_closure_capture_slot" crates/perry-runtime/src/closure/alloc.rs -A8 -B8

# Check whether closure capture slots are scanned as ordinary heap slots or only via special paths.
rg -n "gc_capture_slot_range|scan_singleton_closure_roots_mut|scan_closure_dynamic_props_roots_mut|visit_closure_dynamic_prop_value_slots_mut|rebuild_closure_layout_and_barriers" crates/perry-runtime/src/closure crates/perry-runtime/src/gc -A6 -B6

# Inspect the runtime method/property call path for these helpers.
sed -n '160,230p' crates/perry-runtime/src/object/property_key.rs
sed -n '1,120p' crates/perry-runtime/src/object/native_call_method.rs

Repository: PerryTS/perry

Length of output: 36939


Add a GC barrier for the reserved this slot write
bind_reserved_this_slot writes a heap pointer directly into a live closure capture slot, but that slot is part of the GC-tracked capture range and the usual setter path records the store via note_closure_capture_slot/runtime_write_barrier_gc_slot. Reuse the barriered setter here, or this can drop an old→young edge and let the receiver be collected while the closure still points at it.

🤖 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/symbol/properties.rs` around lines 590 - 606, Update
bind_reserved_this_slot so the reserved capture write goes through the existing
barriered closure-capture setter, preserving the same real_count - 1 slot and
obj_f64 value. Reuse the path involving
note_closure_capture_slot/runtime_write_barrier_gc_slot instead of writing
directly through captures_ptr.

@proggeramlug proggeramlug merged commit dd49fc3 into main Jul 13, 2026
25 checks passed
@proggeramlug proggeramlug deleted the fix/6320-closure-magic-probe-handle-band branch July 13, 2026 10:02
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.

SIGSEGV: Proxy value stored as a Symbol.toPrimitive / symbol method / JSX component derefs its handle id (closure-magic probes with a 0x1000 floor)

1 participant