fix(runtime): root JS values retained in Rust containers across allocations (#7949) - #7962
Conversation
…ations (#7949) `Object.groupBy` / `Map.groupBy` / `Object.defineProperties` accumulated raw NaN-boxed JS values into plain `Vec`s and then kept filling or walking them across calls that can allocate — a user callback, a `[[Get]]` accessor, a key coercion, a result-array build. A `Vec` on the Rust heap is neither a shadow slot nor a temp root nor reachable from any registered scanner, so an evacuating collection could neither keep those objects alive nor rewrite their addresses. Adds `gc::RootedValues`, a growable list whose elements are `RuntimeHandle`s (the handle stack is a registered mutable root scanner, so elements are marked and rewritten), and converts the three helpers to it.
`alloc_key_array` (Object.keys / getOwnPropertyNames / getOwnPropertySymbols / Reflect.ownKeys on a Proxy) grew the result array between key pushes, and `proxy_enum_own_keys` held the trap's key list across the proxy's `getOwnPropertyDescriptor` trap. Same container-retention shape as #7949's two named sites; found by the sweep in item 5. The gap probe is split in two because a groupBy arm followed by ANY defineProperty-with-getter work trips a separate pre-existing rooting defect — it reproduces with a hand-written Object.defineProperty loop, i.e. with none of the #7949 code on the path.
|
Warning Review limit reached
Next review available in: 2 minutes You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (11)
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. Comment |
* docs(gc-handoff): triage #7803 on a corpus that links again The zod dep-corpus links on main again (#7980), so #7803 is runnable for the first time since it was filed. Record what running it says: - #7803's own reproducer (seed 1, rate 1, quarantine off) no longer fails, but the class it reports does — 3 of 16 seeds fail, one with the same "Cannot read properties of undefined" shape. - The candidate cause on record (#7962/#7978, Object.defineProperti(es) rooting) is refuted by a sabotage A/B: reverting both fixes underneath current main does not bring the failure back. - Every failure is intermittent; a fixed seed does not replay. The reportable figure is the rate, not the seed. - Seed 15 is a separate, self-detecting bug: the #7645 pin latch aborts on a pinned young Map, and the FATAL's own suggested remediation (scripts/gc_pin_sites.py) reports OK. - Aside: PERRY_GC_DIAG=0 enables diagnostics (var_os(...).is_some()). * docs(changelog): fragment for #7989 * docs(gc-handoff): mark the protected-arm sweep as in-flight, not concluded * docs(gc-handoff): correct the seed-1 run count to 4/4 and qualify section 1 * docs(gc-handoff): final sweep counts and the #7990 cross-reference --------- Co-authored-by: Ralph Küpper <ralph@skelpo.com>
Closes #7949.
Raw NaN-boxed JS values were retained in ordinary Rust containers across calls that can allocate — a user callback, a
[[Get]]accessor, a key coercion, a result-array build. ThoseVecelements are neither runtime roots nor mutable shadow slots, so an evacuating collection could neither keep them alive nor rewrite their addresses.scripts/gc_root_dominance_check.pyis structurally blind to this: it reads emitted LLVM IR, and a Rust-side container is not in the IR.Sites
object/groupby.rs—group_by_collectreturnedVec<(f64, f64)>filled acrossjs_closure_call2, i.e. across arbitrary user JS, once per element. The audit found three more holes in the same family that the issue's summary did not name:Object.groupBy's result object had no root at all — it is reachable from nothing else until it is returned, and it spanned one array build plus one key-string intern per group;group_by_make_arrayreturned a freshly allocated array acrossrebuild_array_layout_from_slots, and the caller's boxed copy then spanned the key interning.Also: Symbol keys were coalesced through a
HashMapkeyed on the symbol's address. A freshSymbol(desc)is a GC allocation, so a symbol that moved mid-loop hashed to a new bucket and started a duplicate group. Now keyed onSymbolHeader::id, which an evacuation copies verbatim — the same reasoning #7246 used for symbol descriptions.object/object_ops/define_properties.rs—keys: Vec<f64>was walked acrossjs_string_coerce,js_dynamic_object_get_property(a user getter on the properties bag) andjs_object_define_property, with the receiver, the bag, the own-names array and the coerced key string all bare locals in the same window.proxy/own_keys.rs(issue item 5, found by sweeping for the same convention) —alloc_key_arraygrew the result array between key pushes, andproxy_enum_own_keysheld the trap's key list and the accepted-so-far list across the proxy'sgetOwnPropertyDescriptortrap.The reusable rule
gc::RootedValues(gc/roots/rooted_values.rs): a growable list whose elements areRuntimeHandles. The handle stack is a registered mutable root scanner, so elements are marked and their slots rewritten, andget(i)re-reads the slot. It is not a proof — Rust has no effect system for "this call may allocate" — but it removes the container as the hole and makes the correct shape the short one. The module docs record the one footgun: never push to an outerRootedValueswhile an innerRuntimeHandleScopeis alive, because the inner scope's drop truncates the stack.No new root holder (it borrows the existing handle stack, so
gc_runtime_root_holders.pyneeds no verdict) and no newget_raw_{mut,const}_ptrsites (raw_handle_debt.pyunmoved — it reads 3 below baseline).How the fix is proven
Not "it didn't crash". Every assertion is gated on a collection that actually moved something.
gc/tests/rooted_container_values.rs, four tests underCopyingNurseryTestGuardwith a forcedcollect_minor_trace:rooted_values_elements_survive_a_collection_that_moved_them— assertscopied_objects > 0, that every element's address changed, and that the post-collection pointer still reads the original bytes.plain_vec_of_values_is_not_a_root— the sabotage arm. The identical workload in a bareVec<f64>keeps naming pre-collection addresses while a rooted witness in the same cycle moves. This is what makes (1) non-vacuous.3./4.
object_group_by_…/map_group_by_…— end to end through the real#[no_mangle]entry points, callback forcing an evacuating minor on every element, groups verified by string bytes.Sabotage verification (fix committed first): with
object/groupby.rsreverted toorigin/mainand nothing else changed, both end-to-end tests abort withTypeError: value is not a function— the canonical late-surfacing form of this class. With the fix restored, all four pass.Compiled witness. Two gap tests, each A/B'd against a pristine
origin/mainbuild with identical-psets and a pinnedPERRY_RUNTIME_DIR, undertest_gap_gc_container_value_rooting.ts[gc-fromspace-protect] FAULT: signal 10naming a retired from-space addresstest_gap_gc_define_properties_key_rooting.tsThe instrument is shown live in both runs (
retired_set=#N … bytes_protected=…), so a green arm is a protected arm and not a run with zero copying minors.Why the two probes are separate programs
A
groupByarm followed by anydefineProperty-with-getter work faults under the witness configuration even with this fix in — and it faults identically whenObject.definePropertiesis replaced by a hand-writtenObject.definePropertyloop, i.e. with none of the code this PR touches on the path. That is a separate pre-existing defect in thedefineProperty/getter family — the wider window #6949's scope note names and defers (js_object_define_propertyholdsobj/descriptor_valueand the six rawJSValues insideDescViewacross its own laterjs_string_from_bytescalls). Keeping the programs apart means each gap test fails for its own reason. Filed separately.Validation
cargo test -p perry-runtime --libtest_gap_gc_*.ts+ 5test_gap_{proxy,reflect}*.ts, byte-exact vs node 26.5.1, in the default config and underPERRY_GC_PROTECT_FROMSPACE=1 DEPTH=800PERRY_GC_VERIFY_EVACUATION=1(+ seeded schedule)cargo fmt --check,check_file_size.sh,gc_runtime_root_holders.py,raw_handle_debt.pyInstrument liveness, because a run with zero copying minors protects nothing:
test_gap_gc_container_value_rooting: 164 safepoints / 164 copying minors / 67,889 objects moved, 164 quarantined retired sets.test_gap_gc_define_properties_key_rooting: 60 / 60 / 33,302 moved, 60 retired sets.Sweep (issue item 5)
A scan for the shape — a
Vec<f64>/Vec<(f64…)>accumulator pushed inside a loop that also calls something allocating or user-facing, with noRuntimeHandleScope— returns 14 functions.proxy/own_keys.rs(2) are fixed here.js_typed_array_filteris real and deliberately deferred (itskeptvector spans a user callback, andta/recvare hoisted raw locals across the same callback — thetahalf is #6949 shape (a), so both want one change). The remaining 11 are argument-gathering: theVecis filled from an already-materialized source with no allocating call between pushes, and converting them would cost a handle push per argument on the hottest dispatch paths for no demonstrated window.object/descriptors.rs— the function the issue quotes as carrying the "builder helpers follow this convention" comment — was already converted by #6943/#7341. Full table ingc-handoff/ROOTVEC-NOTES.md.#7803
Could not be reproduced:
test-files/gc-dep-corpus/main.tsdoes not link on this box (Undefined symbols: _perry_fn_node_modules_zod_src_v4_core_index_ts__NEVER,…__brand, … — theexport *re-export set in the currently pinnedzod@4.3.5), identically on a pristineorigin/mainbuild, so #7803's reproducer is not runnable as written.What is established: the zod workload does route through
js_object_define_properties(node_modules/zod/src/v4/core/util.ts:316,node_modules/zod/src/v4/classic/errors.ts:28), one of the two sites fixed here, and #7803's symptom —Cannot read properties of undefined (reading 'toString')— is exactly what a stale key string in that loop produces (the property is defined under a garbage name, the later read misses). That makes this a candidate cause of #7803, not a confirmed one. Retest once the corpus links again.