Repository navigation
gc: 21 unrooted-across-safepoint hazards in the NATIVE root lowering (the --max-unrooted budget's referent) #7664
Description
The --statepoints mode added in #7663 reads the native root lowering
(gc.statepoint "gc-live" relocation bundles) — the default on every target
whose frames the runtime can walk since #7370, and the lowering
gc-root-dominance was not checking until now.
Over the curated corpus (149 modules, 2452 functions, 30033 safepoints, 17478
with a live bundle, 40759 relocations) it reports 21 hazards under
--moving-only, all of class unrooted — meaning nothing in the register's
cast chain appears in the safepoint's live bundle, so the collector neither
marks nor rewrites the object. Unfiltered (adding the RECLAIM half of the
invariant to the MOVE half): 1444, of which 1123 unrooted and 321 stale.
gc-root-dominance-statepoints carries --max-unrooted 21 as a ratchet, not
an adjudication. This issue is that budget's referent: it can only be lowered,
and each shape below should get its own fix (and its own decrement) before the
job is promoted to a required context.
Reproduce:
cargo build --release -p perry -p perry-runtime-static -p perry-stdlib-static
./scripts/gc_root_dominance_corpus.sh ir-corpus-native --lowering native
python3 scripts/gc_root_dominance_check.py ir-corpus-native \
--statepoints --moving-only --max-unrooted 999 --max-stale 999 -vThe four shapes
1. String-literal handle globals held across a call — 9 hits
%r = load double, ptr @<mod>_.str.N.handle into a register, then a safepoint,
then the register is used. The handle global is a registered root, so the
string is never swept — but an evacuating cycle rewrites the global and the
register keeps the pre-move address.
This is #7240's exact shape. That fix (OperandProtection::Reload,
re-emitting the load below the collection point) landed for the shadow-stack
lowering; these say it does not cover the native one.
test_gap_closures_ts::Counter__increment .str.5 across js_object_get_field_by_name_f64
test_gap_closures_ts::Counter__increment$pshape .str.5 (same, repsel specialisation)
test_gap_object_get_own_property_descriptors_ts::main .str.11 across js_closure_call1
test_gap_object_methods_ts::main .str.113 across js_closure_call1
test_gap_object_string_wrappers_ts::main .str.25 across js_closure_call1
test_gap_repsel_element_shape_loop_clone_ts::main .str.0 across js_closure_call1
test_gap_repsel_element_shape_loop_clone_ts::main .str.30 across js_closure_call1
test_gap_repsel_ptr_shape_barriers_ts::defineProp .str.1 across js_closure_call1
test_gap_repsel_ptr_shape_barriers_ts::mutateRegistry .str.1 across js_closure_call1
test_gap_repsel_ptr_shape_locals_ts::Acc__add .str.9 across js_object_get_field_by_name_f64
2. A receiver unmasked out of ptr addrspace(1) and dereferenced below a property GET — 7 hits
%raw = ptrtoint ptr addrspace(1) %recv to i64, then the generic property-GET
dispatch (which resolves a user getter through a transmuted function pointer and
routes a Proxy to js_proxy_get — arbitrary JS), then %raw is used. RS4GC
relocates %recv and rewrites its uses; it cannot touch the i64.
This is #7280 / zod's clone, the shape that faults deterministically under
PERRY_GC_PROTECT_FROMSPACE=1 PERRY_GC_PROTECT_FROMSPACE_DEPTH=800.
test_gap_array_dyndispatch_…::__obj_method_toLocaleString_3
test_gap_class_extends_userland_stream_shim_ts::Readable
test_gap_closures_ts::Counter__increment
test_gap_closures_ts::Counter__increment$pshape
test_gap_computed_key_method_nested_this_ts::readCtx
test_gap_computed_key_method_nested_this_ts::__closure_5 across js_object_get_field_by_property_id_f64
test_gap_repsel_ptr_shape_locals_ts::Acc__add
3. A module global read held across a closure call — 2 hits
%r = load double, ptr @perry_global_<mod>__N into a register, then
js_closure_call1 (and js_object_alloc, js_closure_alloc), then the
register is used. @perry_global_* is a registered root that evacuation
rewrites; the register is not.
test_gap_arraybuffer_transfer_ts::main @perry_global_…__14
test_gap_arraybuffer_transfer_ts::main @perry_global_…__15
4. js_new_target_get() → js_new_target_set() across a constructor call — 1 hit
test_gap_class_advanced_ts::main
source (rootread): %r1069 = call double @js_new_target_get()
stale use : %r1072 = call double @js_new_target_set(double %r1069)
across safepoint : test_gap_class_advanced_ts__MyConstructable_constructor
Structurally identical to #7226's js_implicit_this_set prev_this bug —
a save/restore pair around a call, where the saved value is held in a bare
register across the call it is saving across — for new.target instead of
this.
Plus one capture read — 1 hit
test_gap_computed_key_method_nested_this_ts::__closure_9, a closure-capture
cell read held across js_number_coerce (which dispatches a user
[Symbol.toPrimitive] / valueOf).
Note on the two $pshape entries
Two of these live in representation-selection specialisations
(Counter__increment$pshape). They were invisible until #7663 fixed DEFINE_RE,
which could not match a quoted LLVM function name — and $ forces LLVM's
printer to quote. 175 of 2452 defines in the native corpus (7.1%) were being
skipped without a word, all of them repsel specialisations.
Activity
gc: give the --max-unrooted budget its referent (#7664)
gc: give the --max-unrooted budget its referent (#7664)
gc: teach the root-dominance checker to read statepoint relocation bu…
gc: close the strhandle, derived-mask and new.target unrooted hazards (…
gc: close the strhandle, derived-mask and new.target unrooted hazards (…
fix(test-isolation): the stream constructor tests read a side table t…
gc: close the strhandle, derived-mask and new.target unrooted hazards (…
gc: close the strhandle, derived-mask and new.target unrooted hazards…
proggeramlug commentedon Aug 9, 2026
Budget 11 → 8, and four of the remaining eight are checker false positives
#7696 roots the spread argument-bundle accumulator in
expr/call_spread.rs, which closes the wholeunrooted:allocpopulation. Measured on the native corpus (150 modules, 2538 functions, 32689 statepoints, 19593 live bundles), same binary in both arms:The three that disappear are exactly the three in
test_gap_array_splice_spread::main; the other eight are byte-identical. 71/71 gap tests containing a spread call stay byte-identical to the pinned Node 26.5.1 oracle.★ The four
unmaskedhits are FALSE POSITIVES, not a missing edge-insertion modelThe gate comment says they are "PHI-MEDIATED … the reload has to go in the PREDECESSOR, on the edge, which is a different insertion model". That is a fix for a problem that is not there.
"phi"is inTRANSPARENT_OPS(gc_root_dominance_check.py:1007), so taint flows from a phi operand to the phi result and the use is located at the join — but a phi operand is used on its own incoming edge. All four are the same&&join.readCtx, verbatim from the corpus:Every safepoint (
js_object_get_field_by_name_f64,…_ic_miss,js_typed_feedback_…) is on the%logical.then.1path, where the phi selects%r86. On the edge that carries%r2nothing collects between its definition and the join.Verified register-by-register on all four:
…nested_this::readCtx%r2.rs4iinentry.0%r87inlogical.merge.2entry.0✓…nested_this::__closure_5%r9.rs4iinshadow.root.barrier.done.2%r18inlogical.merge.4…stream_shim::Readable%r10.rs4iinshadow.root.barrier.done.2%r95inlogical.merge.4…dyndispatch::__obj_method_toLocaleString_3%r13.rs4iinshadow.root.barrier.done.2%r98inlogical.merge.4The correct rule is that a phi operand's window ends at the terminator of its incoming predecessor, not at the join. That still catches a real phi-mediated hazard (operand defined, safepoint runs, then the predecessor's terminator is reached).
It is deliberately not in #7696. A change that lowers a reported count has to arrive with a sabotage arm proving it still reports a phi operand that is live across a safepoint on its own edge — that belongs with the checker change, not riding along on a codegen fix.
So the real residual is 4, and it splits two ways
2 ×
unrooted:global—test_gap_arraybuffer_transfer::main,@perry_global_…__14and__15loaded into a register and held acrossjs_closure_call1/js_closure_alloc/js_object_allocintojs_data_view_new/js_uint8array_view. Real, and needs rooting not reloading, exactly as the gate comment already says (a_module_global_is_not_a_reload_sourcepins it).2 ×
unrooted:capture—test_gap_class_expr_dynamic_parent_ctor::perry_closure_…__21checked by hand and real:%r7/%r61appear in nogc-livebundle anywhere in the function.It is not reloadable the way a string-handle global is. A recipe would have to re-derive the closure pointer, and its root is
%this_closure— ani64function parameter that RS4GC does not relocate (%r3 = or i64 %this_closure, POINTER_TAGtheninttoptr). Re-executing that at the use site reproduces the pre-move closure address. That population needs the callee's own closure pointer to be a tracked root first, which is a signature/ABI change rather than a reload.Recommendation
Lower the budget to 8 now (#7696 does), then to 4 with an edge-sensitive phi rule plus its sabotage arm. The remaining 4 are two separable slices: the module-global rooting one, and the closure-parameter one.
gc(codegen): root the spread argument-bundle accumulator (#7664)
gc(codegen): root the spread argument-bundle accumulator (#7664) (#7696)
gc: fix the phi-edge checker false positives and the static-dispatch …
gate(gc): lower gc-root-dominance-statepoints' --max-unrooted to 2 (#…
gc: fix root-dominance phi false positives + 3 of 5 real hits, lower …
proggeramlug commentedon Aug 9, 2026
Closed by #7724 (v0.5.1420) — as superseded, not fully fixed, which is the honest framing.
The chain
2ddbc7b0e(#7667) →6086c9d40(#7696) →72ef47a07(#7719) → #7724 drove the corpus budget from 21 → 2. I verified the new budget on the merged tree rather than trusting the PR's own run (which had been measured against a build containing a fix later dropped in favour of #7719's):131/131 sources compiled, 0 skipped, then--statepoints --moving-only --max-unrooted 2→within budget: unrooted 2 <= 2, exit 0.The 2 remaining
unrooted:capturehits are tracked in #7725, and the diagnosis there is worth more than the count:js_closure_get_capture_bits's return value is never re-entered into the RS4GC-tracked domain or a temp root, unlike%this_closureitself. Fixing it needsroot_reload.rs'sFactsto model a call as reloadable, not just a load — a gap in the repair machinery rather than a missing store.Also worth recording: this issue's own "8 = 4 false positives + 4 real" split was stale by the time it was worked. A fifth real hit had joined after #7691 landed and nobody re-measured the budget. A budget above reality silently absorbs the next real hazard — same defect as the
MIN_COMPILED=90floor fixed in #7706.fix(codegen): root_reload's cost cap counts root loads, not derived v…
fix(gc-root-dominance): a phi edge that replaces the value is not in …
fix(gc-root-dominance): a phi edge that replaces the value is not in …
fix(gc-root-dominance): a phi edge that replaces the value is not in …