Skip to content

gc: 21 unrooted-across-safepoint hazards in the NATIVE root lowering (the --max-unrooted budget's referent) #7664

Description

@proggeramlug

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 -v

The 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

  1. proggeramlug commented on Aug 9, 2026

    @proggeramlug
    ContributorAuthor

    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 whole unrooted:alloc population. 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 unmasked hits are FALSE POSITIVES, not a missing edge-insertion model

    The 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 in TRANSPARENT_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.1 path, where the phi selects %r86. On the edge that carries %r2 nothing collects between its definition and the join.

    Verified register-by-register on all four:

    function source phi safepoint-free edge
    …nested_this::readCtx %r2.rs4i in entry.0 %r87 in logical.merge.2 entry.0 ✓
    …nested_this::__closure_5 %r9.rs4i in shadow.root.barrier.done.2 %r18 in logical.merge.4 that block ✓
    …stream_shim::Readable %r10.rs4i in shadow.root.barrier.done.2 %r95 in logical.merge.4 that block ✓
    …dyndispatch::__obj_method_toLocaleString_3 %r13.rs4i in shadow.root.barrier.done.2 %r98 in logical.merge.4 that 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_…__14 and __15 loaded into a register and held across js_closure_call1/js_closure_alloc/js_object_alloc into js_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_source pins it).

    2 × unrooted:capture — test_gap_class_expr_dynamic_parent_ctor::perry_closure_…__21 checked 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 / %r61 appear in no gc-live bundle 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 — an i64 function parameter that RS4GC does not relocate (%r3 = or i64 %this_closure, POINTER_TAG then inttoptr). 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.

  2. proggeramlug commented on Aug 9, 2026

    @proggeramlug
    ContributorAuthor

    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:capture hits 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_closure itself. Fixing it needs root_reload.rs's Facts to 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=90 floor fixed in #7706.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions