Skip to content

test(gc): cover the root lowering that actually ships — native-roots assertions for #7502 - #7653

Merged
proggeramlug merged 8 commits into
mainfrom
fix/7502-native-root-coverage
Aug 8, 2026
Merged

test(gc): cover the root lowering that actually ships — native-roots assertions for #7502#7653
proggeramlug merged 8 commits into
mainfrom
fix/7502-native-root-coverage

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Closes #7502.

What was missing

Since #7370 native roots (RS4GC statepoints) are the lowering that ships on
every target whose frames the runtime can walk. #7493 correctly repaired
shadow_slot_hygiene and scalar_replaced_slot_roots by pinning them to the
shadow stack — but that repair made explicit that nine root-lowering mechanics
had no assertion at all against the lowering Perry emits
, and that three tests
which read as coverage were measuring nothing: they asserted
js_shadow_slot_bind was absent, which is true of every program under the
native default (CLAUDE.md hazard 4).

The shadow-pinned suites stay. Both lowerings are supported and both need
coverage; this is the missing half, not a replacement.

What this adds

crates/perry-codegen/src/native_root_coverage/8 mechanic tests + 5
harness self-tests
, in-crate #[cfg(all(test, feature = "llvm-inprocess"))]
so they run in the per-PR cargo-test gate rather than the nightly-only
tests/*.rs tier (#5960).

Assertions are made at three vantages, each catching what the previous one
cannot:

  1. Pre-opt IR — the ptr addrspace(1) allocas codegen asks for. The only
    vantage where "codegen never requested a root" and "LLVM removed one" are
    still distinguishable, which is why the negative claims live here.
  2. Post-RS4GC IR — each gc.statepoint's "gc-live" operand bundle, keyed
    by callee name, produced by running the production pass string
    (inprocess::STATEPOINT_REWRITE_PASSES). Turns "is this value a root across
    that call" into a direct question. The shadow suites never had an equivalent.
  3. The emitted stack map — the per-safepoint root lists decoded back out of
    the compact __perry_gcmap blob the collector actually reads
    (gc_map::decode_stack_map_roots, which round-trips through encode_stream +
    verify_roundtrip, so a test asserts on what the binary ships).

Both shipped native-roots targets (arm64-apple-macosx, x86_64-unknown-linux-gnu)
are compiled and emitted for on every host, pinned rather than host-derived:
cargo-test runs on x86_64 Linux and this repo is developed on arm64 macOS, and
a suite that silently changes subject with the host is how a target-specific
lowering bug reaches a release.

# #7502 row native assertion
1 a pointer local is a root root slot + live at the next allocation's statepoint + present in the map
2 a dead value stops being a root absent from the live set, against a live control
3 a numeric local reserves nothing slot counts (1, 2) against a heap twin
4 slot indices unshifted by a numeric local subsumed by 3 — native roots have no indices
5 entry roots begin after the init prelude first rooted safepoint follows js_gc_init and __perry_init_strings_*
6 a loop's roots do not cross the back edge in-loop live set is 1, against a 2-root control
7 scalar-replaced heap field is a root (#6968) extra root slot + non-empty map, against the numeric twin
8 scalar-replaced numeric literal pays nothing (#6997) empty map, against a one-heap-field twin
9 every reserved slot reaches the root set (#7184) two live locals ⇒ two map roots

Non-vacuity is the whole point, so it is structural

  • Positive claims assert their subject ran. Statepoints::at panics when the
    callee it is asked about produced no safepoint; map_records_for panics when
    the function is missing from the map. "Zero roots" is only ever asserted about
    a record that exists.
  • Every negative claim carries a differential control in the same test. A
    lowering that roots nothing fails the control half.
  • The harness has its own coverage, and it earned it twice during
    development. Both bugs would have made every "nothing is live here" assertion
    pass for the wrong reason: the callee parser read
    @llvm.experimental.gc.statepoint.p0 for every safepoint (the intrinsic's own
    signature contains ) @), and the live-set parser truncated at the ) inside
    ptr addrspace(1) and reported an empty live set for every statepoint in
    every program
    .

Sabotage results

Ten sabotages, each confirmed to compile (error[ count 0) and to reach
the test binary
(Running unittests present) before its verdict was believed.

sabotage result
root allocas emitted as alloca double instead of ptr addrspace(1) RED — 10 of 14 tests (all 8 mechanics + both pipeline self-tests)
retype every scalar alloca, not only bound roots RED — mechanics 1, 3, 7
conservative CFG-union liveness (reload every root before ret) RED — mechanic 2 only
root reloaded at each block head and used at its terminator RED — mechanic 6, in-loop live set 1 → 2
root_scalar_replaced_slot's root_entry_alloca removed (#6968 reintroduced) RED — mechanics 7, 8 (heap 3 vs numeric 3)
expr_is_known_non_pointer_shadow_value early-out removed RED — numeric literal's map went 0 → 2 roots
js_gc_init deleted from main RED — mechanic 5 only
__perry_init_strings_* moved to a pre-return call RED — mechanic 5 only (#32 vs #4)
roots vector sized slot_count - 1 RED — mechanic 9, map's largest live set 2 → 1
root alloca's address leaked to a call so mem2reg cannot promote it RED — promotion self-test, 2 allocas survived

One negative result is recorded in the code too, because it says something about
the mechanic: the weaker CFG-union sabotage leaves mechanic 6 green, and LLVM
is right about that — the previous iteration's value is genuinely dead at the
next iteration's allocation, since the phi carrying it to the return is redefined
in the body. Only a lowering that keeps a root live across the back edge itself
can break that row.

Three findings

#7502's table is wrong about row 9. It marks #7184's shape "unrepresentable"
because there is no frame bound under native roots. The frame bound is gone,
but the defect was never about a frame — it is about an index falling silently
outside the structure that collects roots, and
lower_precise_roots_to_native_stack still has one: it collects with
roots.get_mut(idx) over a slot_count-sized vector, so an out-of-range index
drops the alloca from root_ptrs and it is never retyped and never rooted, with
no diagnostic. Sizing that vector one element short removes a root from the
emitted map while the function still compiles, verifies and emits. Now tested.

mem2reg promoting every root alloca is a native-only precondition with no
shadow counterpart.
RS4GC relocates addrspace(1) SSA values and does not scan
allocas, so a root slot that escapes promotion is one the collector never
rewrites — the value reads as rooted and is not, which is the #7184/#7192
presentation exactly. Asserted directly in
no_root_alloca_survives_the_statepoint_rewrite.

docs/src/internals/gc-rooting-invariant.md was missing its most important
caveat.
gc_root_dominance_corpus.sh compiles the corpus under
PERRY_RS4GC=0 — the shadow lowering — and says so inline, for a good
reason (the checker anchors on @js_shadow_slot_bind, of which the native
lowering emits zero; under the default the corpus has 1251 statepoints, no binds,
and --min-binds fails the job). But the invariant page, which is what a reader
is told to read end to end, listed three blind spots and not that one — so a
green gc-root-dominance read as evidence about the shipped lowering. Added as
a fourth, starred, bullet, pointing at these tests as the native-side coverage
until the checker learns to read relocation bundles.

Production surface

Deliberately tiny, and it cannot change emitted IR:

  • gc_map::decode_stack_map_roots#[cfg(all(test, feature = "llvm-inprocess"))].
  • inprocess::statepoint_rewritten_ir#[cfg(test)] inside a feature-gated module.
  • inprocess::STATEPOINT_REWRITE_PASSES — a named constant replacing an inline
    string literal of the identical value, so the suite cannot go green against a
    pipeline production no longer uses.

Verification

  • cargo test -p perry-codegen --lib --no-fail-fast — 724 passed, 0 failed.
  • cargo test -p perry-runtime --lib --no-fail-fast — 1915 passed, 0 failed, 4 ignored.
  • cargo test -p perry-codegen --lib --no-default-features --no-run — builds clean
    (the feature gate exists so it does).
  • cargo check --all-targets --workspace (cross-host UI crates excluded) — clean;
    re-run under RUSTFLAGS=-D warnings and confirmed none of the residual
    macOS-host warnings names a file this PR adds or touches.
  • cargo fmt --all -- --check — clean.
  • All 22 lint-job commands extracted from .github/workflows/test.yml — 22 run, 0 failed.
  • gc_root_dominance_check.py --moving-only --seeded-violations 40 — see the
    comment below.

@coderabbitai

coderabbitai Bot commented Aug 8, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Added test-only RS4GC inspection and stack-map decoding helpers. Added native-root coverage for pointer liveness, dead values, numeric locals, entry ordering, loops, duplicate locals, and scalar replacement on macOS ARM64 and Linux x86_64.

Changes

Native Root Coverage

Layer / File(s) Summary
RS4GC inspection and map infrastructure
crates/perry-codegen/src/inprocess.rs, crates/perry-codegen/src/gc_map.rs, crates/perry-codegen/src/native_root_coverage/*, crates/perry-codegen/src/lib.rs
Added test-only LLVM rewriting, statepoint parsing, target-specific assembly emission, stack-map decoding, and native-root fixture helpers. Production RS4GC execution now uses a shared pass constant.
Harness and pipeline validation
crates/perry-codegen/src/native_root_coverage/harness_self_tests.rs
Added parser mutation tests, panic-contract tests, target-wide map checks, and a regression test for root-alloca promotion after mem2reg.
Native-root mechanics coverage
crates/perry-codegen/src/native_root_coverage/mechanics.rs, changelog.d/7653-native-root-coverage.md
Added coverage for root liveness, numeric-local exclusion, entry-module ordering, loop back edges, duplicate locals, scalar replacement, and non-vacuity checks. Documented findings and validation results.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant NativeRootTest
  participant InprocessRS4GC
  participant LLVM
  participant GCMapDecoder
  NativeRootTest->>InprocessRS4GC: build fixture and rewrite statepoints
  InprocessRS4GC->>LLVM: emit target assembly
  LLVM->>GCMapDecoder: provide stack-map records
  GCMapDecoder-->>NativeRootTest: decode safepoint root sets
Loading

Possibly related issues

Possibly related PRs

  • PerryTS/perry#7301: Provides the in-process RS4GC pipeline used by the new test helper.
  • PerryTS/perry#7314: Implements the native statepoint and stack-map lowering covered here.
  • PerryTS/perry#7370: Makes the native-roots/statepoint lowering covered here the default for supported targets.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The PR covers the linked issue objectives, including native-root mechanics, non-vacuous assertions, differential controls, and sabotage validation.
Out of Scope Changes check ✅ Passed The changes are limited to native-root coverage tests, test-only helpers, a shared production pass constant, and related changelog documentation.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Title check ✅ Passed The title clearly identifies the native-roots coverage tests and links them to issue #7502.
Description check ✅ Passed The description provides detailed context, changes, related issue, test plan, verification results, and implementation findings, although it does not use every template heading.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/7502-native-root-coverage

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

proggeramlug pushed a commit that referenced this pull request Aug 8, 2026
@proggeramlug

Copy link
Copy Markdown
Contributor Author

gc-root-dominance results (codegen was touched, so it was run)

Run against arm64-apple-macosx, release build
(cargo build --release -p perry -p perry-runtime-static -p perry-stdlib-static,
PERRY_RUNTIME_DIR pinned to that release dir so the archives being linked are
the ones just built).

python3 scripts/gc_root_dominance_check.py --audit-alloc-re
  === ALLOC_RE: 71 alternatives vs 3784 exported js_* symbols
  === every alternative matches at least one exported symbol            exit 0

python3 scripts/gc_root_dominance_check.py --audit-poll-capable
  === POLL_CAPABLE_RUNTIME: 54 entries vs 3784 exported js_* symbols
  === every entry names an exported runtime symbol                      exit 0

python3 scripts/gc_root_dominance_check.py --self-test                  exit 0

./scripts/gc_root_dominance_corpus.sh ir-corpus
  corpus: 129/129 sources compiled, 0 skipped, 149 .ll files            exit 0

python3 scripts/gc_root_dominance_check.py ir-corpus --moving-only \
  --allowlist scripts/gc_root_dominance_allowlist.json --seeded-violations 40 -v
  === checked 2452 functions / 149 modules (149 .ll files, 9799 root stores)
  === violations: 0   (moving-minor reachable: 0)
  === seeded violations: 40 planted, 40 caught, 0 MISSED                exit 0

python3 scripts/gc_root_dominance_check.py ir-corpus --unrooted-allocas --moving-only \
  --allowlist scripts/gc_root_dominance_allowlist.json -v
  === files: 149   gc-capable allocas: 7862   unrooted-alloca violations: 0
      (moving-minor reachable: 0)                                       exit 0

--moving-only matters here: without it the corpus is red on main, and the
checker's early return means --seeded-violations never executes — the seeded
arm would silently not run and the job would still print a verdict.

The expected result for this PR is "unchanged", and that is the point of running
it: the only production-visible edit is a string literal replaced by a named
constant of the identical value, so the emitted IR cannot differ. This confirms
it does not.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
crates/perry-codegen/src/native_root_coverage/mechanics.rs (1)

185-194: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Assert the control safepoint count before indexing.

The subject half asserts dead_allocs.len() == 2 at line 165 before it indexes element 1. The control half indexes live_allocs[1] with no such assertion. If the control program produces one js_map_alloc safepoint, the test fails with an index panic instead of the intended message. The same gap exists at line 293 for heap_alloc[1].

♻️ Proposed change
         let live_allocs = live_allocs.at("js_map_alloc");
+        assert_eq!(
+            live_allocs.len(),
+            2,
+            "[{target}] CONTROL: one safepoint per allocation: {live_allocs:?}"
+        );
         assert_eq!(
             live_allocs[1].live.len(),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-codegen/src/native_root_coverage/mechanics.rs` around lines 185
- 194, Add explicit safepoint-count assertions before indexing control results
in the relevant tests. In the control path around live_allocs, assert that
live_allocs contains two entries before accessing live_allocs[1], and apply the
same guard to the heap_alloc control path near the later assertion. Preserve the
existing diagnostic assertions after the bounds checks.
crates/perry-codegen/src/native_root_coverage/mod.rs (1)

306-320: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

Guard the function-body end detection.

function_slice searches for "\n}\n" from start. If the target function is the last one in the module and the IR ends with } and a single trailing newline, the search fails and the slice extends to the end of the module text. Later helpers then count allocas and statepoints from unrelated trailing content, such as attribute groups or metadata. The fallback is silent.

Consider making the fallback explicit so a mis-slice fails loudly.

♻️ Proposed change
     let end = ir[start..]
         .find("\n}\n")
         .map(|o| start + o + 3)
-        .unwrap_or(ir.len());
+        .or_else(|| ir[start..].find("\n}").map(|o| start + o + 2))
+        .unwrap_or_else(|| panic!("function `{name}` has no closing brace in IR:\n{ir}"));
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-codegen/src/native_root_coverage/mod.rs` around lines 306 - 320,
Update function_slice’s function-body end detection to require and validate the
target function’s closing brace, including when the IR ends with a closing brace
followed by only one newline. Replace the silent ir.len() fallback with an
explicit failure that identifies the function and indicates the closing
delimiter was not found, preventing unrelated trailing IR from entering the
slice.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@crates/perry-codegen/src/native_root_coverage/mechanics.rs`:
- Around line 185-194: Add explicit safepoint-count assertions before indexing
control results in the relevant tests. In the control path around live_allocs,
assert that live_allocs contains two entries before accessing live_allocs[1],
and apply the same guard to the heap_alloc control path near the later
assertion. Preserve the existing diagnostic assertions after the bounds checks.

In `@crates/perry-codegen/src/native_root_coverage/mod.rs`:
- Around line 306-320: Update function_slice’s function-body end detection to
require and validate the target function’s closing brace, including when the IR
ends with a closing brace followed by only one newline. Replace the silent
ir.len() fallback with an explicit failure that identifies the function and
indicates the closing delimiter was not found, preventing unrelated trailing IR
from entering the slice.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: e3cb0c62-c311-4529-beee-ef4ec2cd4a75

📥 Commits

Reviewing files that changed from the base of the PR and between 9617779 and 6f2ed9b.

📒 Files selected for processing (7)
  • changelog.d/7653-native-root-coverage.md
  • crates/perry-codegen/src/gc_map.rs
  • crates/perry-codegen/src/inprocess.rs
  • crates/perry-codegen/src/lib.rs
  • crates/perry-codegen/src/native_root_coverage/harness_self_tests.rs
  • crates/perry-codegen/src/native_root_coverage/mechanics.rs
  • crates/perry-codegen/src/native_root_coverage/mod.rs

proggeramlug pushed a commit that referenced this pull request Aug 8, 2026
It was written before the PR number was known and collided with #7653's
native-root-coverage fragment.

Claude-Session: https://claude.ai/code/session_01Y1QZ5wUP9gRSwpiweT4Wix
Ralph Küpper added 8 commits August 8, 2026 19:52
Native roots (RS4GC statepoints) have been the default on every target the
runtime can walk since #7370, and had no assertions anywhere. The two suites
that read as this area's coverage are pinned to the shadow stack (#7493) and
stay that way — both lowerings are supported — but that left nine root-lowering
mechanics untested against the lowering Perry emits, and three tests passing
vacuously because they counted `js_shadow_slot_bind` calls the native lowering
never emits.

Adds `perry-codegen/src/native_root_coverage`, eight mechanic tests plus five
harness self-tests, asserting at three vantages: the `ptr addrspace(1)` allocas
codegen asks for, the `"gc-live"` bundle of each `gc.statepoint` after the
production pass string, and the per-safepoint root lists decoded out of the
compact `__perry_gcmap` blob the collector reads at run time. In-crate `#[cfg(test)]`
so it runs in the per-PR `cargo-test` gate rather than the nightly-only tier.

Every test is sabotage-verified — ten sabotages, each one confirmed to compile
and to reach the test binary before its verdict was believed. Details per test
in the doc comments.

Two findings worth naming:

* #7502's table calls row 9 (#7184's out-of-range slot index) `n/a` under native
  roots. It is not. `lower_precise_roots_to_native_stack` collects roots with
  `roots.get_mut(idx)` over a `slot_count`-sized vector, so an out-of-range
  index still drops a root silently — the same failure one layer up from the
  runtime bounds check. Sizing that vector one short removes a root from the
  emitted map with no diagnostic, and now fails a test.
* `mem2reg` promoting every root alloca is a load-bearing precondition with no
  shadow-stack counterpart: RS4GC relocates `addrspace(1)` SSA values and does
  not scan allocas, so a root slot that escapes promotion is never rewritten.
  Asserted directly, and sabotage-verified by making the alloca's address
  escape.

Production changes are confined to two test seams and one named constant:
`gc_map::decode_stack_map_roots` and `inprocess::statepoint_rewritten_ir` are
`#[cfg(test)]`, and `STATEPOINT_REWRITE_PASSES` replaces an inline string
literal with the identical value so the suite cannot drift onto a pipeline
production stopped using. Emitted IR is unchanged.
Two of its three vantages run the statepoint rewrite and emit assembly
through that pipeline, so `cargo test -p perry-codegen --no-default-features`
(the text path, kept for bisection) had nothing for them to assert against.
Verified: that build now compiles clean with no new dead-code warnings.
… lowering that does not ship

scripts/gc_root_dominance_corpus.sh has said so inline since #7370 flipped the
default (it compiles under PERRY_RS4GC=0 because the checker anchors on
@js_shadow_slot_bind calls the native lowering never emits). The invariant page
did not, and that page is what a reader is told to read end to end — so a green
gc-root-dominance read as evidence about the shipped lowering. Names the gap
and points at the unit tests that now cover the native side.
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Audit — merging as v0.5.1372

Finding 3 is the most important thing produced today

gc_root_dominance_corpus.sh compiles the corpus under PERRY_RS4GC=0 — the shadow lowering — so a green gc-root-dominance is evidence about a lowering that does not ship.

I verified it: the script says so inline, and honestly ("KNOWN GAP, deliberately not papered over"), but gc-rooting-invariant.md — the page every brief, including mine, tells people to read end to end — listed three blind spots and not that one. I have cited that gate as authoritative on six PRs today. It was answering a different question than I thought, and the script knew while the doc did not.

Putting it on the page as a fourth, starred bullet is the right fix, and naming native_root_coverage there as what covers the native side until the checker learns relocation bundles (engine-plan item 8) makes this PR the stand-in for a static check that does not exist yet. That is a bigger deal than the coverage count.

Sabotage — verified independently, two of them

I re-ran two of your ten against my own tree rather than taking the table:

(My first attempt at the second sabotage didn't apply — my regex missed the actual vec![None; slot_count as usize] — and reported 14 green off an unmodified build. Fourth time today I've caught that shape; it is exactly why your error[-plus-Running unittests protocol earns its keep.)

Things you were right to push back on

The "nine mechanics, say which three you left" framing was mine and it was wrong#7502 marks seven rows no and two n/a. You covered all seven plus one n/a, and left row 4 with a reason that is better than coverage would have been: native roots have no slot indices, so "indices not shifted by an interleaved numeric local" has no object, and mechanic 3 asserts the substance. Saying that in the docs beats a test that pretends the shape exists.

Pinning both shipped triples rather than deriving from the host is the right call for the stated reason — a suite that changes subject with the host is how a target-specific bug ships.

Verified here

22/22 lint commands, cargo fmt --check clean, perry-codegen --lib 725 passed, perry-runtime --lib 1916 passed, all 14 new tests green. Your order-dependence check (10× at default parallelism, 5× at --test-threads=4) is the kind of thing that usually gets skipped and then bites six weeks later.

#7656 now tracks the sibling gap this shares a shape with: perry-ext-* link breakage is invisible per-PR for the same structural reason — the gate's scope excludes the thing that actually breaks. Both are "the gate runs but not on the subject".

@proggeramlug
proggeramlug force-pushed the fix/7502-native-root-coverage branch from 3defa9c to 5e468a3 Compare August 8, 2026 17:57
@proggeramlug
proggeramlug merged commit 441ed53 into main Aug 8, 2026
@proggeramlug
proggeramlug deleted the fix/7502-native-root-coverage branch August 8, 2026 17:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

The shipped root lowering (native/statepoints) has no coverage in the suites #7493 pinned to shadow — 9 mechanics, 3 of them found passing vacuously

1 participant