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.

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