Repository navigation
gc: 21 unrooted-across-safepoint hazards in the NATIVE root lowering (the --max-unrooted budget's referent) #7664
Description
Activity
- added 8 commits that reference this issue
on Aug 8, 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:unrooted stale before 11 0 after 8 0 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:entry.0: %r2.rs4i = ptrtoint ptr addrspace(1) %rs4gc.s1 to i64 %r2 = bitcast i64 %r2.rs4i to double %r3 = call i32 @js_is_truthy(double %r2) #8 ; gc-leaf, no statepoint br i1 %r4, label %logical.then.1, label %logical.merge.2 logical.merge.2: ; preds = %pget.recv_merge.7, %entry.0 %r87 = phi double [ %r2, %entry.0 ], [ %r86, %pget.recv_merge.7 ] ret double %r87
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:
function source phi safepoint-free edge …nested_this::readCtx%r2.rs4iinentry.0%r87inlogical.merge.2entry.0✓…nested_this::__closure_5%r9.rs4iinshadow.root.barrier.done.2%r18inlogical.merge.4that block ✓ …stream_shim::Readable%r10.rs4iinshadow.root.barrier.done.2%r95inlogical.merge.4that block ✓ …dyndispatch::__obj_method_toLocaleString_3%r13.rs4iinshadow.root.barrier.done.2%r98inlogical.merge.4that block ✓ The 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:%statepoint_token = … @js_closure_get_capture_bits(i64 %r5, i32 0) … %r61 = call i64 @llvm.experimental.gc.result.i64(token %statepoint_token) %r7 = bitcast i64 %r61 to double ; the dynamic PARENT CLASS … ; ~60 lines %statepoint_token7 = … @js_object_alloc_class_inline_keys … %statepoint_token9 = … @…__AnonShape_1634a9bf7c12d700_constructor … ; USER CODE %statepoint_token13 = … @js_new_function_construct(double %r7, ptr %r41, i64 1) …
%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.
- added 5 commits that reference this issue
on 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.- added a commit that references this issue
on Sep 6, 2026 - added 3 commits that reference this issue
on Sep 28, 2026
The
--statepointsmode added in #7663 reads the native root lowering(
gc.statepoint"gc-live"relocation bundles) — the default on every targetwhose frames the runtime can walk since #7370, and the lowering
gc-root-dominancewas 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 classunrooted— meaning nothing in the register'scast 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
unrootedand 321stale.gc-root-dominance-statepointscarries--max-unrooted 21as a ratchet, notan 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:
The four shapes
1. String-literal handle globals held across a call — 9 hits
%r = load double, ptr @<mod>_.str.N.handleinto 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.
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-GETdispatch (which resolves a user getter through a transmuted function pointer and
routes a Proxy to
js_proxy_get— arbitrary JS), then%rawis used. RS4GCrelocates
%recvand rewrites its uses; it cannot touch thei64.This is #7280 / zod's
clone, the shape that faults deterministically underPERRY_GC_PROTECT_FROMSPACE=1 PERRY_GC_PROTECT_FROMSPACE_DEPTH=800.3. A module global read held across a closure call — 2 hits
%r = load double, ptr @perry_global_<mod>__Ninto a register, thenjs_closure_call1(andjs_object_alloc,js_closure_alloc), then theregister is used.
@perry_global_*is a registered root that evacuationrewrites; the register is not.
4.
js_new_target_get()→js_new_target_set()across a constructor call — 1 hitStructurally identical to #7226's
js_implicit_this_setprev_thisbug —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.targetinstead ofthis.Plus one capture read — 1 hit
test_gap_computed_key_method_nested_this_ts::__closure_9, a closure-capturecell read held across
js_number_coerce(which dispatches a user[Symbol.toPrimitive]/valueOf).Note on the two
$pshapeentriesTwo of these live in representation-selection specialisations
(
Counter__increment$pshape). They were invisible until #7663 fixedDEFINE_RE,which could not match a quoted LLVM function name — and
$forces LLVM'sprinter to quote. 175 of 2452 defines in the native corpus (7.1%) were being
skipped without a word, all of them repsel specialisations.