Problem
crates/perry-codegen/src/gc_call_effects.rs (~L280–291) lists js_array_length as AllocNoReentry ("allocates but never re-enters user code"). The audit comment says it "takes a typed *const ArrayHeader, not a JSValue". That's no longer true. js_array_length (crates/perry-runtime/src/array/indexing.rs ~L225) has a #5135 arm for a Proxy statically typed as an array (immer drafts):
if let Some(proxy) = array_ptr_as_proxy(arr) {
let key = crate::string::js_string_from_bytes(b"length".as_ptr(), 6);
...
let n = crate::builtins::js_number_coerce(crate::proxy::js_proxy_get(proxy, key_f64));
js_proxy_get runs the user's get trap, and js_number_coerce can run valueOf/Symbol.toPrimitive. Both are arbitrary user JS, which can allocate, collect, and move objects. Under the safepoint-only contract (gc_safepoint_only_contract_enabled()), AllocNoReentry callees are marked "gc-leaf-function" by lower_roots_for_rs4gc (crates/perry-codegen/src/function/precise_roots.rs), so the caller's live GC values are not relocated across the call. A collection inside the trap then leaves the caller holding stale from-space pointers.
A measurement over the runtime's call graph also flagged js_object_alloc_class_inline_keys / js_object_alloc_class_inline_keys_stamped and js_object_get_own_field_or_undef (the latter listed around ~L209) as reaching JS. Verify each one.
Fix
- Move
js_array_length out of AllocNoReentry to the unknown (collecting) class, or split it: keep a leaf fast path (the plain-array lane at the top) and route the Proxy/Set/Map/object arms through a separately named collecting entry point that codegen calls on the slow path.
- Audit
js_object_alloc_class_inline_keys* and js_object_get_own_field_or_undef the same way, and fix the classification or the code.
- Update the audit comments so they state what was actually checked.
- Consider a test that fails when a classified helper's call graph reaches a JS-invoking symbol (
js_proxy_get, js_native_call_value, coercion entry points). That would make this whole class checkable.
Verify
- A regression test:
new Proxy([], { get(t, k) { if (k === "length") { /* allocate a lot and force a GC */ ... return 3 } return Reflect.get(t, k) } }), read .length through a path that lowers to js_array_length, with live heap values in the caller used afterwards. Run under PERRY_GC_FORCE_EVACUATE=1 PERRY_GC_VERIFY_EVACUATION=1, and assert a copying minor ran. It must match node, and must fail (wrong output or a verifier panic) before the fix. Sabotage-check it.
cargo test --release -p perry-codegen (the gc_call_effects tests) and the runtime suite pass.
Rules for the PR (repo conventions)
- Code + tests + a
changelog.d/<PR>-<slug>.md fragment. No version bump (don't touch [workspace.package].version, the CLAUDE.md version line, or Cargo.lock versions).
- Every new regression test must be sabotage-checked: revert the fix, confirm the test goes red, restore. Say so in the PR body.
perry-runtime tests must run with RUST_TEST_THREADS=1. Build -p perry -p perry-runtime-static -p perry-stdlib-static together (the .a archives come from the -static wrappers).
- Run
scripts/run_lint_gates.sh (or at least cargo fmt --all -- --check, scripts/check_file_size.sh, python3 scripts/raw_handle_debt.py, python3 scripts/addr_class_inventory.py, python3 scripts/gc_runtime_root_holders.py, python3 scripts/gc_rekeyed_key_tables.py).
- Self-contained: no private bundle or special host needed. A normal Linux or macOS dev box is enough.
Filed from the 2026-09 side-table / RSS audit; line numbers are against main e379a7a and may drift.
Problem
crates/perry-codegen/src/gc_call_effects.rs(~L280–291) listsjs_array_lengthasAllocNoReentry("allocates but never re-enters user code"). The audit comment says it "takes a typed*const ArrayHeader, not a JSValue". That's no longer true.js_array_length(crates/perry-runtime/src/array/indexing.rs~L225) has a #5135 arm for a Proxy statically typed as an array (immer drafts):js_proxy_getruns the user'sgettrap, andjs_number_coercecan runvalueOf/Symbol.toPrimitive. Both are arbitrary user JS, which can allocate, collect, and move objects. Under the safepoint-only contract (gc_safepoint_only_contract_enabled()),AllocNoReentrycallees are marked"gc-leaf-function"bylower_roots_for_rs4gc(crates/perry-codegen/src/function/precise_roots.rs), so the caller's live GC values are not relocated across the call. A collection inside the trap then leaves the caller holding stale from-space pointers.A measurement over the runtime's call graph also flagged
js_object_alloc_class_inline_keys/js_object_alloc_class_inline_keys_stampedandjs_object_get_own_field_or_undef(the latter listed around ~L209) as reaching JS. Verify each one.Fix
js_array_lengthout ofAllocNoReentryto the unknown (collecting) class, or split it: keep a leaf fast path (the plain-array lane at the top) and route the Proxy/Set/Map/object arms through a separately named collecting entry point that codegen calls on the slow path.js_object_alloc_class_inline_keys*andjs_object_get_own_field_or_undefthe same way, and fix the classification or the code.js_proxy_get,js_native_call_value, coercion entry points). That would make this whole class checkable.Verify
new Proxy([], { get(t, k) { if (k === "length") { /* allocate a lot and force a GC */ ... return 3 } return Reflect.get(t, k) } }), read.lengththrough a path that lowers tojs_array_length, with live heap values in the caller used afterwards. Run underPERRY_GC_FORCE_EVACUATE=1 PERRY_GC_VERIFY_EVACUATION=1, and assert a copying minor ran. It must match node, and must fail (wrong output or a verifier panic) before the fix. Sabotage-check it.cargo test --release -p perry-codegen(the gc_call_effects tests) and the runtime suite pass.Rules for the PR (repo conventions)
changelog.d/<PR>-<slug>.mdfragment. No version bump (don't touch[workspace.package].version, theCLAUDE.mdversion line, orCargo.lockversions).perry-runtimetests must run withRUST_TEST_THREADS=1. Build-p perry -p perry-runtime-static -p perry-stdlib-statictogether (the.aarchives come from the-staticwrappers).scripts/run_lint_gates.sh(or at leastcargo fmt --all -- --check,scripts/check_file_size.sh,python3 scripts/raw_handle_debt.py,python3 scripts/addr_class_inventory.py,python3 scripts/gc_runtime_root_holders.py,python3 scripts/gc_rekeyed_key_tables.py).Filed from the 2026-09 side-table / RSS audit; line numbers are against
maine379a7a and may drift.