Repository navigation
fix(gc): js_array_length is classified AllocNoReentry but its Proxy arm runs user JS (get trap + number coercion) #11522
Description
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_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. - Audit
js_object_alloc_class_inline_keys*andjs_object_get_own_field_or_undefthe 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.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)
- Code + tests + a
changelog.d/<PR>-<slug>.mdfragment. No version bump (don't touch[workspace.package].version, theCLAUDE.mdversion line, orCargo.lockversions). - Every new regression test must be sabotage-checked: revert the fix, confirm the test goes red, restore. Say so in the PR body.
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).- Run
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). - 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.
Activity
fix(gc): reclassify js_array_push_f64; add #11522 Proxy-trap evacuati…
docs(changelog): #11522 fragment (renamed to the PR number once opened)
docs(changelog): key the #11522 fragment to PR 11546
fix(gc): split js_array_length into a GC-leaf fast lane and a collect…
fix(gc): reclassify js_array_push_f64; add #11522 Proxy-trap evacuati…
docs(changelog): #11522 fragment (renamed to the PR number once opened)
docs(changelog): key the #11522 fragment to PR 11546
fix(gc): split js_array_length into a GC-leaf fast lane and a collect…
fix(gc): reclassify js_array_push_f64; add #11522 Proxy-trap evacuati…
docs(changelog): #11522 fragment (renamed to the PR number once opened)
docs(changelog): key the #11522 fragment to PR 11546
proggeramlug commentedon Sep 27, 2026
Status (S1, #11565): this is not fixed at the source yet; codegen now classifies these helpers conservatively.
The generated call-effects table built from the archives proves that
js_array_lengthreachesperry_runtime::proxy::get::proxy_get_with_receiver.js_object_alloc_class_inline_keys,js_object_alloc_class_inline_keys_stampedandjs_object_get_own_field_or_undefalso reach JS or the collector. All four areReentersin the table, so they are safepoints (notAllocNoReentry/CannotCollect).root_reload's hand list is now gated on the same table. The runtime-side split (a leaf fast path plus a collecting slow path) is still open. Once it lands, the regenerated table will classify the fast path automatically. To see the witness path:scripts/gc_call_effects/why.py --archive libperry_runtime.a --archive libperry_stdlib.a js_array_length.test(gc): keep #11522's split pin against the generated table
test(gc): keep #11522's split pin against the generated table
test(gc): keep #11522's split pin against the generated table
test(gc): keep #11522's split pin against the generated table
test(gc): keep #11522's split pin against the generated table
test(gc): keep #11522's split pin against the generated table
test(gc): keep #11522's split pin against the generated table