Skip to content

feat(gc): make native roots (statepoints) the default - #7370

Merged
proggeramlug merged 1 commit into
mainfrom
feat/statepoints-default
Aug 4, 2026
Merged

feat(gc): make native roots (statepoints) the default#7370
proggeramlug merged 1 commit into
mainfrom
feat/statepoints-default

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Native GC roots are now the default. PERRY_RS4GC=1 is no longer needed; PERRY_RS4GC=0 reverts to the shadow stack for bisection.

This is the fork docs/engine-plan.md called "the plan's next real fork, and it is the owner's — but it should be made on a schedule, not left to drift." Every blocker it listed is now closed.

Evidence

Full 479-test gap suite, no env set, diffed against the pinned Node oracle:

statepoints default shadow baseline
pass 447 447
diff 19 19 (pre-existing)
node_fail 13 13
new regressions 0
compile failures 0

All 128 try-carrying tests compiled — the class the deleted bridge (#7348) could never handle at all. All 10 gc_ratchet probes byte-identical to Node. Runtime −1–2%; binary size +1.86% measured on a real dependency (zod, 81 native modules) rather than a synthetic.

The part that made this non-trivial

A blanket flip would have been wrong. gc_map deliberately refuses to emit a map for a target whose frame bases the runtime cannot resolve — a map nothing reads loses roots silently, which is the exact failure this backend exists to prevent. So flipping globally turns every watchOS arm64_32 and ARM64-Windows compile into a hard error.

The default is therefore native roots where the runtime can walk, shadow stack where it cannot. Falling back is not "no roots" — it is the other lowering of the same root-set analysis, which is only expressible because #7340 split analysis from lowering. Wired per module beside set_jscvt_for_target, which already had this exact shape.

PERRY_RS4GC=1 still reaches gc_map's refusal rather than being quietly downgraded to a fallback, because an A/B arm must measure what it asked for.

A test pins the support matrix in both directions. The one way this change breaks a platform is if my predicate is looser than gc_map's refusals — then the compile hard-fails instead of falling back — so that is what is under test.

Eight tests had to name their lowering

They assert on shadow-stack IR (js_shadow_slot_bind, frame pushes) and broke when the default moved. They were correct about what they assert; they had simply never had to say which lowering, because there was only one default. Each now pins it through a thread-local guard, mirroring arena::quarantine's ProtectionModeGuard — thread-local and restoring, so one test's pin cannot change another's.

That guard is deliberately separate from the per-target cell: compile_module sets the target decision per module, so a pin that wrote that cell would be erased the moment the test invoked codegen.

What this unlocks

With native roots as the default, the shadow stack's lowering becomes removable — the analysis stays, since both mechanisms consume it. That is the "delete the shadow stack" goal, now a mechanical follow-up rather than an open question.

Caveat

My sweep is aarch64-macOS. Linux and Windows correctness rests on gc-native-roots' ELF and PE arms, which are queued behind a deep runner backlog and have not reported yet.

Summary by CodeRabbit

  • New Features

    • Native garbage-collection roots are now enabled by default on supported targets.
    • Unsupported targets automatically fall back to shadow-stack handling.
    • Added configuration options to explicitly select native roots or shadow stacks.
  • Documentation

    • Documented defaults, fallback behavior, configuration options, compatibility details, performance, and binary-size measurements.

PERRY_RS4GC=1 is no longer needed. PERRY_RS4GC=0 reverts to the shadow
stack for bisection.

TARGET-AWARE, not blanket. gc_map REFUSES to emit a map for a target
whose frame bases the runtime cannot resolve, because a map nothing reads
loses roots silently -- so a global flip would turn every watchOS
arm64_32 and ARM64-Windows compile into a hard error. The default is
therefore native roots where the runtime can walk, shadow stack where it
cannot. That is only expressible because #7340 split the root-set
analysis from its lowering: falling back is not 'no roots', it is the
other lowering of the same analysis. A test pins the support matrix in
both directions, because the one way this breaks a platform is if the
predicate is LOOSER than gc_map's refusals.

An explicit PERRY_RS4GC=1 still reaches that refusal rather than being
silently downgraded, so an A/B arm measures what it asked for.

Evidence, full 479-test gap suite with no env set:

    pass       447    (shadow baseline: 447)
    diff        19    (pre-existing, unchanged)
    node_fail   13
    regressions  0    compile failures 0

All 128 try-carrying tests compiled -- the class the deleted bridge
(#7348) could never handle. All 10 gc_ratchet probes byte-identical to
Node. Runtime -1-2%; binary size +1.86% measured on zod's 81 modules.

Eight codegen tests assert on shadow-stack IR and now pin that lowering
through a thread-local guard, mirroring arena::quarantine's
ProtectionModeGuard. They were right about what they asserted -- they had
just never needed to name a lowering, because there was only one.
@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Changes

Native root lowering

Layer / File(s) Summary
Target capability and override policy
crates/perry-codegen/src/codegen/helpers.rs, changelog.d/7366-statepoints-default.md
PERRY_RS4GC now supports explicit overrides. Without an override, native roots are enabled only for supported targets. Tests cover target defaults and fallback behavior.
Per-module lowering configuration
crates/perry-codegen/src/codegen/mod.rs
compile_module applies the target-specific native-root decision.
Explicit shadow-stack test coverage
crates/perry-codegen/src/codegen/closure.rs, crates/perry-codegen/src/collectors/proven_this_routing_tests.rs, crates/perry-codegen/src/expr/shadow_inline.rs
IR-focused tests explicitly pin shadow-stack lowering before compilation.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant compile_module
  participant codegen_helpers
  participant target_runtime_support
  compile_module->>codegen_helpers: set_native_roots_for_target(target)
  codegen_helpers->>target_runtime_support: check frame-walker support
  target_runtime_support-->>codegen_helpers: supported or unsupported
  codegen_helpers-->>compile_module: select native roots or shadow stack
Loading

Possibly related issues

Possibly related PRs

  • PerryTS/perry#7314 — Adds the statepoint codegen behavior that this PR makes target-aware.
  • PerryTS/perry#7318 — Defines the RS4GC adoption and PERRY_RS4GC behavior implemented here.
  • PerryTS/perry#7349 — Implements runtime native-root support used by the target selection.

Suggested reviewers: thehypnoo, andrewtdiz

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely identifies the main change: making native GC roots the default.
Description check ✅ Passed The description thoroughly explains the change, implementation, evidence, limitations, and test results, despite omitting several template headings and checkboxes.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ 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 feat/statepoints-default

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
proggeramlug merged commit 148f97b into main Aug 4, 2026
21 of 45 checks passed
@proggeramlug
proggeramlug deleted the feat/statepoints-default branch August 4, 2026 11:33

@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.

Actionable comments posted: 1

🤖 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.

Inline comments:
In `@crates/perry-codegen/src/codegen/helpers.rs`:
- Around line 1577-1632: Refactor the target-default decision used by
rs4gc_enabled() into a pure helper that accepts an explicit optional PERRY_RS4GC
override and the target capability result, preserving override precedence.
Update native_roots_default_matches_the_targets_gc_map_will_emit_for() to
evaluate defaults with None so the process environment cannot affect assertions,
and replace the current tautological override check in
the_target_default_is_a_default_not_a_veto() with assertions that explicit
Some(false) and Some(true) override the target default.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 65e79afd-d78e-46ec-ba62-7255f3b703eb

📥 Commits

Reviewing files that changed from the base of the PR and between 3056986 and 02aadbf.

📒 Files selected for processing (6)
  • changelog.d/7366-statepoints-default.md
  • crates/perry-codegen/src/codegen/closure.rs
  • crates/perry-codegen/src/codegen/helpers.rs
  • crates/perry-codegen/src/codegen/mod.rs
  • crates/perry-codegen/src/collectors/proven_this_routing_tests.rs
  • crates/perry-codegen/src/expr/shadow_inline.rs

Comment on lines +1577 to +1632
#[test]
fn native_roots_default_matches_the_targets_gc_map_will_emit_for() {
for triple in [
"arm64-apple-macosx",
"aarch64-apple-darwin",
"aarch64-apple-ios",
"aarch64-unknown-linux-gnu",
"aarch64-unknown-linux-musl",
"x86_64-apple-darwin",
"x86_64-unknown-linux-gnu",
"x86_64-pc-windows-msvc",
] {
set_native_roots_for_target(triple);
assert!(
rs4gc_enabled(),
"{triple} has a working walker — native roots should be the default"
);
}

for triple in [
// ILP32: 32-bit pointers, and the runtime's map loader is gated to
// 64-bit Apple, so a map here would be read by nothing.
"arm64_32-apple-watchos",
// COFF + ARM64: no Windows walker for that register model, so no
// frame would ever be visited.
"aarch64-pc-windows-msvc",
// Architectures with no walker at all.
"riscv64gc-unknown-linux-gnu",
"wasm32-unknown-unknown",
] {
set_native_roots_for_target(triple);
assert!(
!rs4gc_enabled(),
"{triple} has no walker — must fall back to the shadow stack, \
not hard-fail in gc_map"
);
}
}

/// An explicit `PERRY_RS4GC=1` must still reach `gc_map`'s refusal for an
/// unsupported target. Turning that into a silent shadow-stack fallback
/// would hide exactly what the arm was set to measure.
#[test]
fn the_target_default_is_a_default_not_a_veto() {
set_native_roots_for_target("riscv64gc-unknown-linux-gnu");
assert!(
!rs4gc_enabled(),
"unset env + unsupported target = fall back"
);
// The override path is env-driven and process-cached, so it is asserted
// by the CI arms rather than re-read here; this pins the shape that the
// target decision is consulted ONLY when there is no explicit answer.
assert!(
rs4gc_env_override().is_none() || rs4gc_env_override().is_some(),
"override is a tri-state"
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/usr/bin/env bash
set -uo pipefail

cargo check -p perry --profile perry-dev || exit 1

status=0
for override in 0 1; do
  if ! env PERRY_RS4GC="$override" \
    cargo test -p perry-codegen --profile perry-dev \
    native_roots_target_tests -- --test-threads=1; then
    status=1
  fi
done
exit "$status"

Repository: PerryTS/perry

Length of output: 143


🏁 Script executed:

#!/usr/bin/env bash
set -uo pipefail

printf 'Repository files around helpers.rs:\n'
fd -a 'helpers.rs$' . | sed 's#^\./##'

printf '\nTarget symbols in crates/perry-codegen/src/codegen/helpers.rs:\n'
rg -n "rs4gc_(enabled|env_override|policy)|NATIVE_ROOTS_TARGET_OK|NATIVE_ROOTS_OVERRIDE|set_native_roots_for_target" crates/perry-codegen/src/codegen/helpers.rs || true

printf '\nRelevant source section:\n'
sed -n '1520,1645p' crates/perry-codegen/src/codegen/helpers.rs

printf '\nRelevant section above definitions:\n'
rg -n -C 8 "pub.*rs4gc|NATIVE_ROOTS_TARGET_OK|NATIVE_ROOTS_OVERRIDE|set_native_roots_for_target" crates/perry-codegen/src/codegen/helpers.rs

Repository: PerryTS/perry

Length of output: 15193


Isolate the native-roots target tests from PERRY_RS4GC.

rs4gc_enabled() uses cached PERRY_RS4GC before reading NATIVE_ROOTS_TARGET_OK, so PERRY_RS4GC=0 fails the walking-target assertions and PERRY_RS4GC=1 fails the unsupported-target assertions. Move the precedence decision into a pure helper, then test target defaults with explicit None and assert explicit overrides separately.

🤖 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/codegen/helpers.rs` around lines 1577 - 1632,
Refactor the target-default decision used by rs4gc_enabled() into a pure helper
that accepts an explicit optional PERRY_RS4GC override and the target capability
result, preserving override precedence. Update
native_roots_default_matches_the_targets_gc_map_will_emit_for() to evaluate
defaults with None so the process environment cannot affect assertions, and
replace the current tautological override check in
the_target_default_is_a_default_not_a_veto() with assertions that explicit
Some(false) and Some(true) override the target default.

Source: Coding guidelines

proggeramlug pushed a commit that referenced this pull request Aug 4, 2026
799,604,736 -> 245,055,488 bytes across the 8 gc_ratchet probes at +2%
wall, all 8 byte-identical to Node.

Measured as a 2x2, because the one-armed version of this measurement is
misleading:

                 no scavenge   scavenge
    no cap          baseline     +0% RSS
    cap 16 MB     -33%/+23%    -69%/+3%

Scavenge alone moves nothing; the cap alone trades a third of the
footprint for a quarter of the wall time. Together the cap makes
collections frequent and scavenge makes them evacuating (O(live) copying)
instead of O(heap) sweeps, so the frequency is cheap. #7056 measured the
cap and recommended decoupling it -- but it was gated behind two knobs
that both defaulted OFF, so it had never been active in a shipped build,
and following that recommendation literally ships the bad arm.

Enabling scavenge also defers alloc-point collections to a precise
safepoint instead of collecting behind a forced conservative scan, which
became reasonable only when #7370 made native roots the default.

TEST WORK, which was the real cost: this first produced 23 gc:: failures.
10 of them were one bug -- force_legacy_gc_pacing() pinned only the
moving-loop-polls flag, which used to be enough because the cap and the
deferral branch both hung off it. With the cap unconditional and scavenge
default-on the guard silently stopped pinning anything, so tests that
correctly declared their pacing mode were running in the wrong one. It
now pins all three. The other 13 drive the budgeted/incremental stepper
without any guard; they pin it explicitly now, since the shipped default
bypasses that path by design.

Remaining suite variance (3-4 failures) is the pre-existing flake in
#7365 -- clean main gives 1/2/2/3/4 on the same runs.
proggeramlug added a commit that referenced this pull request Aug 4, 2026
* prototype(gc): nursery cap + scavenge on by default — NOT landable as-is

Measured -69% RSS at +3% wall over the 8 gc_ratchet probes, 11/11 probes
byte-identical to Node, gap suite tracking the 447/19/13 baseline. See
#7372 for the full 2x2 and why neither half is worth shipping alone.

Blocked on 26 gc::tests failures (baseline flake is 1-4, #7365),
concentrated in the budgeted/incremental path that scavenge's deferral
bypasses. Includes the one cause already understood: force_legacy_gc_pacing
un-capped the trigger by pinning the polls flag, which stops working once
the cap is unconditional -- the guard now suppresses the cap directly.
Mechanically pinning legacy pacing across incremental_sweep_reclaim fixed
only 3 of 10, so the rest need individual judgement.

Pushed as a reference for #7372, not for merge.

* perf(gc): nursery cap + scavenge on by default — peak RSS -69%

799,604,736 -> 245,055,488 bytes across the 8 gc_ratchet probes at +2%
wall, all 8 byte-identical to Node.

Measured as a 2x2, because the one-armed version of this measurement is
misleading:

                 no scavenge   scavenge
    no cap          baseline     +0% RSS
    cap 16 MB     -33%/+23%    -69%/+3%

Scavenge alone moves nothing; the cap alone trades a third of the
footprint for a quarter of the wall time. Together the cap makes
collections frequent and scavenge makes them evacuating (O(live) copying)
instead of O(heap) sweeps, so the frequency is cheap. #7056 measured the
cap and recommended decoupling it -- but it was gated behind two knobs
that both defaulted OFF, so it had never been active in a shipped build,
and following that recommendation literally ships the bad arm.

Enabling scavenge also defers alloc-point collections to a precise
safepoint instead of collecting behind a forced conservative scan, which
became reasonable only when #7370 made native roots the default.

TEST WORK, which was the real cost: this first produced 23 gc:: failures.
10 of them were one bug -- force_legacy_gc_pacing() pinned only the
moving-loop-polls flag, which used to be enough because the cap and the
deferral branch both hung off it. With the cap unconditional and scavenge
default-on the guard silently stopped pinning anything, so tests that
correctly declared their pacing mode were running in the wrong one. It
now pins all three. The other 13 drive the budgeted/incremental stepper
without any guard; they pin it explicitly now, since the shipped default
bypasses that path by design.

Remaining suite variance (3-4 failures) is the pre-existing flake in
#7365 -- clean main gives 1/2/2/3/4 on the same runs.

---------

Co-authored-by: Ralph Küpper <ralph@skelpo.com>
proggeramlug added a commit that referenced this pull request Aug 4, 2026
…fork (#7371)

Three corrections, one of which is a number the plan explicitly warns
against quoting and was carrying anyway.

1. THE SIZE FIGURE. Only +18.95% appears on main -- a synthetic worst
   case with three heap values live across an allocation in EVERY one of
   2000 functions. The dependency-scale measurement is +1.86% (zod, 81
   native modules, 29 MB binary), an order of magnitude lower. The
   correction was written when the synthetic was retracted but never
   reached main: #7345 squash-merged as 24 insertions, the first commit
   only, so the follow-up correction commit was dropped. That is the same
   failure mode this document records for #7321 -- a wrong explanation
   outliving its own disproof -- so the real number now leads and the
   worst case is explicitly marked do-not-quote.

2. SEQUENCING STEP 2 said root density was a PREREQUISITE for adoption,
   reasoning from that retracted figure. Adoption shipped in #7370
   without it. Still worth doing, and still the same lever #7296 proved
   worth 9.9x, but it gates nothing.

3. THE ADOPTION FORK IS CLOSED. Every gate shut: llvm-inprocess default
   (#7353), x86-64 (#7349), Windows (#7355), bridge deleted (#7348), and
   the 479-test suite with no env matching the shadow baseline exactly.
   The target-aware shape is recorded because it is the part that
   generalises: native roots where the runtime can walk, shadow stack
   where it cannot.

Also: layer 2 now reads THE DEFAULT rather than landed opt-in, layer 3's
count is 41 rather than 54 after #7363, and the 2026-08-03 status header
no longer says 'not yet adopted'.

Co-authored-by: Ralph Küpper <ralph@skelpo.com>
proggeramlug added a commit that referenced this pull request Aug 5, 2026
…7415)

* fix(ci): pin the root-dominance corpus to the shadow-stack lowering

The GC Root Dominance gate has been unable to return a verdict since
#7370 made statepoints the default.

The checker's entire vocabulary is `call void @js_shadow_slot_bind(...)`.
Under the stack-map lowering the final IR pass resolves those indices to
native allocas and REMOVES the calls (FunctionCodegen::stack_map_slot_count),
so the corpus compiled 144 modules containing zero root stores. The gate
reported violations: 0 and then refused to pass, because its --min-binds
floor caught that its own subject never ran. That is CLAUDE.md's fourth
hazard working exactly as designed.

Pinning PERRY_RS4GC=0 is sound rather than a dodge: #7340 split the
root-set analysis from its lowering, this gate is about the analysis, and
both backends share it. The shadow stack is also still the production
lowering wherever the runtime cannot walk frames.

Measured, same binary and source, only the knob differing:

  arm=default   js_shadow_slot_bind calls = 0
  arm=rs4gc0    js_shadow_slot_bind calls = 9

#7370 already fixed this for the unit tests -- helpers.rs records that
eight broke when the default flipped and were given NativeRootsPin::shadow().
The corpus shell scripts were the same breakage in another idiom, missed.

* docs: name the fragment for its real PR (#7415)

---------

Co-authored-by: Ralph Küpper <ralph@skelpo.com>
proggeramlug added a commit that referenced this pull request Aug 5, 2026
…ject to the statepoint default (#7452)

* fix(ci): gc-root-dominance corpus emits statepoint IR, not shadow-slot binds

The checker anchors on `@js_shadow_slot_bind` call sites. Statepoints
became the default root lowering in #7370 and express roots as
`gc.statepoint` relocation bundles instead, so the corpus has carried
1251 statepoints and ZERO binds ever since. The checker's own vacuity
floor then fails the job:

  error: 0 root store(s) in the corpus, need at least 1500.
  The subject of this check never ran.

Selecting the shadow-stack lowering for the corpus restores the subject:
0 -> 3151 root stores, both gated arms exit 0, and the adversarial arm
reports 40 planted / 40 caught / 0 missed.

This gates the shadow-stack lowering only. The statepoint lowering now
has no equivalent static check; that gap is real and is named in the
script rather than hidden by lowering the floor.

* docs: changelog fragment for #7452

---------

Co-authored-by: Ralph Küpper <ralph@skelpo.com>
proggeramlug added a commit that referenced this pull request Aug 5, 2026
* fix(ci): the dependency-scale dominance corpus was vacuous too

#7452 fixed the curated corpus and missed this one. Same cause: the
checker anchors on @js_shadow_slot_bind call sites, statepoints became
the default root lowering in #7370 and express roots as gc.statepoint
relocation bundles instead, so the corpus carried 81 modules with ZERO
of the checker's subject.

  before: 81 modules, 0 bind call sites
  after:  81 modules, 7719 bind call sites

CI's own floors say what the corpus is supposed to look like -- the step
comment reads '81 modules, ~12900 functions, ~7700 root stores' and sets
--min-binds 4000. The fixed corpus measures 81 / 12899 / 7719, i.e. the
floors were written against the shadow-stack lowering and this restores
exactly the state they were set from.

Both gated arms exit 0 with 40/40 seeded violations caught.

This is the corpus #7280 created BECAUSE the curated one reads zero while
twenty lines of stock zod fault, so leaving it measuring nothing defeats
the reason it exists.

* docs: changelog fragment for #7460

---------

Co-authored-by: Ralph Küpper <ralph@skelpo.com>
proggeramlug added a commit that referenced this pull request Aug 6, 2026
…ed one failure into five (#7490) (#7492)

* fix(test): isolate typed_feedback failures — poisoned ENV_LOCK cascaded one failure into five (#7490)

`cargo test -p perry-codegen --test typed_feedback` reported five failures as
a suite and a wobbling set under default parallelism. The suspected cause was
process-global codegen state leaking between in-process compiles. It was not.

Two assertions had genuinely drifted from intentional codegen changes, and
both fail when run ALONE (contrary to the issue's premise):

* `typed_feedback_guards_direct_class_field_specialization` matched the
  numeric coercion in the textual window between the
  `class_field_get_number.fallback` and `.merge` labels. #7430 split that arm:
  `.fallback` now holds only the nullish-receiver check, and the by-name load
  plus coercion moved to `.fallback_lookup` — a block RENDERED AFTER `.merge`.
  The window could never match again.
* `typed_feedback_trace_dump_runs_before_entry_return` cut `main`'s body at
  the literal header `define i32 @main() {`. Since #7370 made native roots the
  default every emitted function carries `"frame-pointer"="non-leaf"`, so that
  header never matches.

The first of those panics while holding `ENV_LOCK`, which poisons the mutex
for the rest of the process; every later `ENV_LOCK.lock().unwrap()` then dies
with `PoisonError` regardless of its own subject. That is the whole of the
"order dependence": under `--test-threads=1` the alphabetically-early poisoner
takes three healthy tests down with it, and under default parallelism the
victim set shifts with the scheduler.

Fix, in three parts:

* `env_lock()` recovers a poisoned guard. Sound because each test declares its
  `EnvVarGuard` after the lock guard, so the env var is restored during unwind
  before the mutex is released — the protected state is already consistent at
  poison time. One test's failure must fail that test alone.
* Both drifted assertions are re-pointed at the current IR and made STRONGER,
  not looser. The class-field one now proves the data flow the positional
  window stood in for, end to end: `.fallback_lookup` records the fallback,
  loads by name and coerces; its terminator branches to the numeric merge; and
  the merge phi's fallback incoming IS the coerced register. `entry_fn_body`
  matches the exact signature and cuts at that line's opening brace, so
  unrelated attribute changes can no longer fail the test.
* A sabotage test plants the exact #7490 shape — an unwind out of a
  lock-holding test — asserts it really poisoned the mutex, and demands the
  accessor still hands out a guard. It fails against the pre-fix
  `.lock().unwrap()` (9 of 16 red), so a green run is evidence, not decoration.

No production codegen state leaks between compiles: `PERRY_TYPED_FEEDBACK` is
read live at each call site and `PERRY_FULL_OUTLINE_IC`'s decision is a
thread-local set once per `compile_module` — both already correct.

* docs(changelog): add fragment for #7490 typed_feedback test isolation

* docs(changelog): point the #7490 fragment at the split follow-ups with measured sweep data

* chore: bump version to 0.5.1285

---------

Co-authored-by: Ralph Küpper <ralph@skelpo.com>
proggeramlug pushed a commit that referenced this pull request Aug 6, 2026
…es (#7493)

#7370 made native roots (RS4GC statepoints) the default lowering.
`NativeRootsPin` was added so a test asserting on shadow-stack IR could say
so, the in-crate unit tests were repaired with it — and the five integration
suites that assert the same mechanics had no pin to reach for, because
`NativeRootsPin` is `#[cfg(test)]` and `tests/*.rs` link this crate as an
external consumer. They run nightly/at-tag only, so nothing went red at merge
time and `shadow_slot_hygiene` sat at 0/12 on `main`.

The pin is now reachable as `perry_codegen::testing::NativeRootsPin`, behind a
`testing` cargo feature that only this crate's own `[dev-dependencies]` entry
enables. With the feature off the pin, its thread-local and the branch it adds
to `rs4gc_enabled()` are `#[cfg]`-ed out of the artifact — not merely private,
absent — and cargo builds dev-dependencies for test/bench targets only, so no
production profile can reach it. `NativeRootsPin::native()` joins `shadow()`,
because a pin also outranks `PERRY_RS4GC` and that is what keeps an assertion
meaning the same thing during a `PERRY_RS4GC=0` sweep.

Per-test classification (not per file — the files disagree internally):

* `shadow_slot_hygiene` — 12/12 shadow. The file's subject IS the shadow
  frame; 0/12 -> 11/12.
* `scalar_replaced_slot_roots` — 11/11 shadow (every test counts
  `js_shadow_slot_bind` sites). 2/11 -> 5/11.
* `temp_root_operand_temporaries` — 2 shadow, the rest unpinned and
  lowering-independent. 12/19 -> 13/19.
* `temp_root_argument_temporaries` — none. `PERRY_RS4GC=0` moves it not at
  all; its failures are #7487's, not #7370's.
* `native_proof_regressions` — 2 shadow, 15 native in `invalidation`.
  249/253 -> 253/255 single-threaded, 198/253 -> 253/255 in parallel.
* `native_proof_buffer_views` — 1 native. 28/30 -> 30/32.

Two tests were pinned though they were PASSING:
`numeric_only_scalar_replaced_{object,array}_emits_no_rooting` and
`a_collection_free_construction_emits_no_this_slot_root` assert
`bind_calls(&ir) == 0` / `!contains("@js_shadow_slot_bind")`, which under the
native default is true of every program. They were green without their subject
running (CLAUDE.md hazard 4). Pinned, the first two now fail for a real reason
(#7497).

Also fixed here because it hid this suite's real signal: `native_proof_
regressions` reported 55 failures under default parallelism and 4 under
`--test-threads=1`. 51 of the 55 were `PoisonError` — #7490's shape again. The
`PERRY_NATIVE_REPS*` env vars are process-global and the restore was
hand-written after the compile, so a panic inside `compile_module` left them
installed and every later unlocked compile wrote artifact JSON into a directory
another test was reading; the torn read panicked inside the lock and poisoned
it. The harness is now one shared `tests/native_proof_support/mod.rs`: a
poison-tolerant accessor, an RAII env guard, and an artifact reader that treats
a foreign or half-written neighbour as noise. Two sabotage tests plant each
failure shape and assert the fix is what prevents it.

Finally, a tripwire in `src/` — so it runs in the REQUIRED `cargo-test` job,
which is the tier this whole issue is about not being in:
`host_target_lowering_default_is_native_roots` fails the moment the default
flips again, naming the suites that then need re-pinning. It asserts its subject
is live (both pins must give different answers; the unsupported-target arm must
give the opposite default), so a constant-folded `rs4gc_enabled()` fails it
rather than passing it. A second gate scans every workspace manifest and fails
if a non-dev dependency edge ever enables the `testing` feature.

Refs #7493.
proggeramlug added a commit that referenced this pull request Aug 6, 2026
…es, and say which lowering each asserts (#7493) (#7509)

* fix(test): make the root-lowering pin reachable from integration suites (#7493)

#7370 made native roots (RS4GC statepoints) the default lowering.
`NativeRootsPin` was added so a test asserting on shadow-stack IR could say
so, the in-crate unit tests were repaired with it — and the five integration
suites that assert the same mechanics had no pin to reach for, because
`NativeRootsPin` is `#[cfg(test)]` and `tests/*.rs` link this crate as an
external consumer. They run nightly/at-tag only, so nothing went red at merge
time and `shadow_slot_hygiene` sat at 0/12 on `main`.

The pin is now reachable as `perry_codegen::testing::NativeRootsPin`, behind a
`testing` cargo feature that only this crate's own `[dev-dependencies]` entry
enables. With the feature off the pin, its thread-local and the branch it adds
to `rs4gc_enabled()` are `#[cfg]`-ed out of the artifact — not merely private,
absent — and cargo builds dev-dependencies for test/bench targets only, so no
production profile can reach it. `NativeRootsPin::native()` joins `shadow()`,
because a pin also outranks `PERRY_RS4GC` and that is what keeps an assertion
meaning the same thing during a `PERRY_RS4GC=0` sweep.

Per-test classification (not per file — the files disagree internally):

* `shadow_slot_hygiene` — 12/12 shadow. The file's subject IS the shadow
  frame; 0/12 -> 11/12.
* `scalar_replaced_slot_roots` — 11/11 shadow (every test counts
  `js_shadow_slot_bind` sites). 2/11 -> 5/11.
* `temp_root_operand_temporaries` — 2 shadow, the rest unpinned and
  lowering-independent. 12/19 -> 13/19.
* `temp_root_argument_temporaries` — none. `PERRY_RS4GC=0` moves it not at
  all; its failures are #7487's, not #7370's.
* `native_proof_regressions` — 2 shadow, 15 native in `invalidation`.
  249/253 -> 253/255 single-threaded, 198/253 -> 253/255 in parallel.
* `native_proof_buffer_views` — 1 native. 28/30 -> 30/32.

Two tests were pinned though they were PASSING:
`numeric_only_scalar_replaced_{object,array}_emits_no_rooting` and
`a_collection_free_construction_emits_no_this_slot_root` assert
`bind_calls(&ir) == 0` / `!contains("@js_shadow_slot_bind")`, which under the
native default is true of every program. They were green without their subject
running (CLAUDE.md hazard 4). Pinned, the first two now fail for a real reason
(#7497).

Also fixed here because it hid this suite's real signal: `native_proof_
regressions` reported 55 failures under default parallelism and 4 under
`--test-threads=1`. 51 of the 55 were `PoisonError` — #7490's shape again. The
`PERRY_NATIVE_REPS*` env vars are process-global and the restore was
hand-written after the compile, so a panic inside `compile_module` left them
installed and every later unlocked compile wrote artifact JSON into a directory
another test was reading; the torn read panicked inside the lock and poisoned
it. The harness is now one shared `tests/native_proof_support/mod.rs`: a
poison-tolerant accessor, an RAII env guard, and an artifact reader that treats
a foreign or half-written neighbour as noise. Two sabotage tests plant each
failure shape and assert the fix is what prevents it.

Finally, a tripwire in `src/` — so it runs in the REQUIRED `cargo-test` job,
which is the tier this whole issue is about not being in:
`host_target_lowering_default_is_native_roots` fails the moment the default
flips again, naming the suites that then need re-pinning. It asserts its subject
is live (both pins must give different answers; the unsupported-target arm must
give the opposite default), so a constant-folded `rs4gc_enabled()` fails it
rather than passing it. A second gate scans every workspace manifest and fails
if a non-dev dependency edge ever enables the `testing` feature.

Refs #7493.

* docs(test): point the lowering notes at the filed follow-ups (#7502, #7503, #7504, #7505, #7506)

* docs(changelog): fragment for the root-lowering pin and the poison cascade (#7509)

* chore: bump version to 0.5.1291

---------

Co-authored-by: Ralph Küpper <ralph@skelpo.com>
proggeramlug pushed a commit that referenced this pull request Aug 8, 2026
… 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 pushed a commit that referenced this pull request Aug 8, 2026
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.
proggeramlug pushed a commit that referenced this pull request Aug 8, 2026
… 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 added a commit that referenced this pull request Aug 8, 2026
…assertions for #7502 (#7653)

* test(gc): cover the root lowering that actually ships (#7502)

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.

* docs(changelog): fragment for #7653

* docs: correct a sabotage note to the post-mechanic-9 count (10 of 14 tests)

* refactor: drop an unused Statepoints helper (new dead_code warning)

* build: gate the coverage module on llvm-inprocess as well as test

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.

* docs(changelog): note the llvm-inprocess feature gate

* docs(gc): name the fourth blind spot — the dominance corpus gates the 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.

* chore: bump version to 0.5.1372

Claude-Session: https://claude.ai/code/session_01Y1QZ5wUP9gRSwpiweT4Wix

---------

Co-authored-by: Ralph Küpper <ralph@skelpo.com>
proggeramlug pushed a commit that referenced this pull request Aug 8, 2026
…#7664)

`gc-root-dominance-statepoints`' `--max-unrooted` ratchet goes 21 -> 7.

#7663 pointed the root-dominance rule at the NATIVE root lowering -- the one
that ships since #7370 -- and reported 21 `unrooted` hazards. Fourteen were
shapes `root_reload.rs` looked straight through, because its rule is stated
over the load's own register and in both shapes the value at risk lives
somewhere else.

  1. The root is a GLOBAL, not an alloca (10 hits). A string literal lowers to
     `load double, ptr @<mod>_.str.N.handle`; the handle global is a registered
     root, so the string is never swept, and an evacuating cycle REWRITES the
     global while a register loaded beforehand keeps the pre-move address.
     #7240's shape, whose fix covered call operands only.

  2. The stale register is DERIVED from the load (3 of 7 unmasked receivers).
     `this.count++` holds the unmasked receiver across the property GET; the
     load's only use is the bitcast ABOVE the collecting call, so the window
     was empty and the function took zero reloads.

  3. `new.target`'s saved previous value (1 hit). `new.rs` saved
     `js_new_target_get()` in a bare register across the whole constructor
     body; the cell is a registered mutable root, so the restore publishes a
     pre-move address back INTO a root the collector scans. #7226's
     `prev_this` bug for `new.target`.

The reload rule is restated over the value's derivation rather than its
register: for a value read out of a collector-rewritten location -- a shadow
slot or a string-handle global -- and any value derived from it by pure bit
ops, every use a collection point can reach re-materialises the whole
derivation. A recipe is extended only through ops that are pure functions of
their operands and whose every register operand is already in the same single
root's recipe, which makes it self-contained and materialisable anywhere. Each
value's window is anchored at its own defining instruction, not at the root
load.

`new.target` gets `new_target_save`/`new_target_restore` in `crate::rooting`,
structurally `implicit_this_save`/`implicit_this_restore`. Re-reading the cell
would be the wrong repair: `js_new_target_set` has already overwritten it.

Measured on `Counter__increment`: before, all three statepoints carried an
EMPTY live set, so the receiver was marked by nothing; after, each carries a
"gc-live" bundle and a `gc.relocate`, and the SET reads a mask re-derived from
the relocated pointer plus a fresh load of the handle global.

Remaining 7, each its own slice: 4 unmasked are phi-mediated (the reload has to
go in the predecessor, on the edge); 2 `@perry_global_*` are module-level
variables the program assigns, so they need rooting rather than reloading
(pinned by `a_module_global_is_not_a_reload_source`); 1 capture read. #7664
stays open as the budget's referent.

Claude-Session: https://claude.ai/code/session_01Y1QZ5wUP9gRSwpiweT4Wix
proggeramlug pushed a commit that referenced this pull request Aug 8, 2026
…d re-derive the #7056 RSS numbers under statepoints

Two halves of the same job: they share the pinned quiet host and the same
harness.

#7481 named coverage this matrix did not have — "a live copying-minor
correctness signal at exactly the cadence the ratchet probes never exercise".
All twelve probes ran the shipped 16 MB nursery cap, so every copying minor
they had ever exercised was small and frequent. `13_large_eden_survivors`
closes that, via a per-probe `// gc-ratchet-env:` declaration that `check`
compares like a metric: delete the directive and every band is still
satisfied, so the arm itself has to be gated or it is not an arm.

The finding that shaped the probe: a large Eden on a *small* retained set runs
ZERO copying minors, because `arena_growth_full_escalation_due` escalates every
minor to a full mark-sweep once arena in-use clears 32 MB and exceeds twice the
post-full baseline. The first draft did exactly that and would have been pinned
on a collector it never reached.

#7056's RSS numbers were taken under the shadow stack, which stopped being the
default in #7370. Re-derived as a 2x2 (root lowering x nursery cap) over 12
probes, 7 repeats, 3 interleaved rotations: the root lowering is not an RSS
lever (peak RSS 1.002x, retention 1.000x, 104 of 108 deterministic cells
bit-identical), and the nursery cap still is (1.911x peak RSS at 128 MB).

Claude-Session: https://claude.ai/code/session_01Y1QZ5wUP9gRSwpiweT4Wix
proggeramlug added a commit that referenced this pull request Aug 8, 2026
…d re-derive the #7056 RSS numbers under statepoints (#7666)

* test(gc-ratchet): pin the large-Eden copying-minor cadence (#7481) and re-derive the #7056 RSS numbers under statepoints

Two halves of the same job: they share the pinned quiet host and the same
harness.

#7481 named coverage this matrix did not have — "a live copying-minor
correctness signal at exactly the cadence the ratchet probes never exercise".
All twelve probes ran the shipped 16 MB nursery cap, so every copying minor
they had ever exercised was small and frequent. `13_large_eden_survivors`
closes that, via a per-probe `// gc-ratchet-env:` declaration that `check`
compares like a metric: delete the directive and every band is still
satisfied, so the arm itself has to be gated or it is not an arm.

The finding that shaped the probe: a large Eden on a *small* retained set runs
ZERO copying minors, because `arena_growth_full_escalation_due` escalates every
minor to a full mark-sweep once arena in-use clears 32 MB and exceeds twice the
post-full baseline. The first draft did exactly that and would have been pinned
on a collector it never reached.

#7056's RSS numbers were taken under the shadow stack, which stopped being the
default in #7370. Re-derived as a 2x2 (root lowering x nursery cap) over 12
probes, 7 repeats, 3 interleaved rotations: the root lowering is not an RSS
lever (peak RSS 1.002x, retention 1.000x, 104 of 108 deterministic cells
bit-identical), and the nursery cap still is (1.911x peak RSS at 128 MB).

Claude-Session: https://claude.ai/code/session_01Y1QZ5wUP9gRSwpiweT4Wix

* test(gc-ratchet): re-pin the baseline with the large-Eden arm, on the pinned quiet host

Thirteen probes at a8f7312, `perry-macos`, driver-gated (clean tree, AC
power, CPU-active <= 25% for 60 s before each phase), node oracle v26.5.1.

CONTROL, taken with the same binary before the pin: `--check` against the
previous artifact (59d5220, #7657) reported every one of its 144 cells `ok`
and failed on exactly one line — "probes present now but absent from the
baseline: 13_large_eden_survivors". So nothing here is drift; the only new rows
are the new probe's, and `wt-scavtenure`'s re-pin is subsumed.

Every deterministic cell in the new artifact has spread 0, including all twelve
of the new probe's; peak RSS spread 0.169%, wall 4.69% against a 10% band (the
suite's widest, and its median reproduces to +0.5% on an independent session).

Also records the measurement-context trap this found: a probe compiled with a
`package.json` in scope retains one more 1 MiB arena block at `gc()`, which
makes an ad-hoc `measure --probes-dir <copy outside the repo>` report
09_try_catch_roots at -17.98% against the pin. Not a collector change; the
driver and CI always compile from the repo.

Claude-Session: https://claude.ai/code/session_01Y1QZ5wUP9gRSwpiweT4Wix

* chore(gc-ratchet): keep the diag regexes together, and say how a declared arm layers over the shell

Cosmetic move of PROBE_ENV_RE/RESERVED_PROBE_ENV below the diag-parsing
regexes they were splitting, plus a docstring line stating the layering an
ad-hoc knob sweep depends on: os.environ applies to every probe that does not
declare the knob, and the declaring probe wins for itself.

Claude-Session: https://claude.ai/code/session_01Y1QZ5wUP9gRSwpiweT4Wix

* docs(gc-ratchet): state the per-minor comparison exactly rather than approximately

"~16 MB per minor for every default-cap probe" understated 12_large_live_set,
which runs 21.8 MB because its tenured-proportional cap term already raises its
Eden without any knob. The real spread is 14.6-16.6 MB on eleven of twelve and
21.8 on the twelfth, against 49.7 MB per minor for the new probe -- and the
12_large_live_set row is worth naming, because it is the shipped path to a
larger Eden and shows where that path tops out.

Claude-Session: https://claude.ai/code/session_01Y1QZ5wUP9gRSwpiweT4Wix

* docs(gc-ratchet): correct the probe's own comment to the shipped numbers

Its inline sizing note carried figures from a pre-final tuning run (3 minors,
36/36/68 MB) and said the survivor reads fold into the checksum when they fold
into five separately-diffed lines. A stale number in the comment beside the
constant it justifies is exactly the shape this campaign keeps paying for.

Claude-Session: https://claude.ai/code/session_01Y1QZ5wUP9gRSwpiweT4Wix

* chore: bump version to 0.5.1379

Claude-Session: https://claude.ai/code/session_01Y1QZ5wUP9gRSwpiweT4Wix

---------

Co-authored-by: Ralph Küpper <ralph@skelpo.com>
proggeramlug pushed a commit that referenced this pull request Aug 9, 2026
…#7664)

`gc-root-dominance-statepoints`' `--max-unrooted` ratchet goes 21 -> 7.

#7663 pointed the root-dominance rule at the NATIVE root lowering -- the one
that ships since #7370 -- and reported 21 `unrooted` hazards. Fourteen were
shapes `root_reload.rs` looked straight through, because its rule is stated
over the load's own register and in both shapes the value at risk lives
somewhere else.

  1. The root is a GLOBAL, not an alloca (10 hits). A string literal lowers to
     `load double, ptr @<mod>_.str.N.handle`; the handle global is a registered
     root, so the string is never swept, and an evacuating cycle REWRITES the
     global while a register loaded beforehand keeps the pre-move address.
     #7240's shape, whose fix covered call operands only.

  2. The stale register is DERIVED from the load (3 of 7 unmasked receivers).
     `this.count++` holds the unmasked receiver across the property GET; the
     load's only use is the bitcast ABOVE the collecting call, so the window
     was empty and the function took zero reloads.

  3. `new.target`'s saved previous value (1 hit). `new.rs` saved
     `js_new_target_get()` in a bare register across the whole constructor
     body; the cell is a registered mutable root, so the restore publishes a
     pre-move address back INTO a root the collector scans. #7226's
     `prev_this` bug for `new.target`.

The window is anchored at the ROOT LOAD, not at the derived value. Anchoring at
the derivation looks more precise and is wrong: `main`'s class-object read has
the scope-end shadow-slot clear landing between the load and the mask, so a walk
starting at the mask never sees it and re-read a slot the program had just
nulled -- `(makeAnon(77) as any).v` became `undefined`. Caught by an A/B against
the branch point on `test_gap_class_expr_identity`, not by the dominance
checker, which cannot see a value-correctness bug.

The reload rule is restated over the value's derivation rather than its
register: for a value read out of a collector-rewritten location -- a shadow
slot or a string-handle global -- and any value derived from it by pure bit
ops, every use a collection point can reach re-materialises the whole
derivation. A recipe is extended only through ops that are pure functions of
their operands and whose every register operand is already in the same single
root's recipe, which makes it self-contained and materialisable anywhere.
Grouping by root load also puts the cost back at O(blocks x loads).

`new.target` gets `new_target_save`/`new_target_restore` in `crate::rooting`,
structurally `implicit_this_save`/`implicit_this_restore`. Re-reading the cell
would be the wrong repair: `js_new_target_set` has already overwritten it.

Measured on `Counter__increment`: before, all three statepoints carried an
EMPTY live set, so the receiver was marked by nothing; after, each carries a
"gc-live" bundle and a `gc.relocate`, and the SET reads a mask re-derived from
the relocated pointer plus a fresh load of the handle global.

Remaining 7, each its own slice: 4 unmasked are phi-mediated (the reload has to
go in the predecessor, on the edge); 2 `@perry_global_*` are module-level
variables the program assigns, so they need rooting rather than reloading
(pinned by `a_module_global_is_not_a_reload_source`); 1 capture read. #7664
stays open as the budget's referent.

Claude-Session: https://claude.ai/code/session_01Y1QZ5wUP9gRSwpiweT4Wix
proggeramlug pushed a commit that referenced this pull request Aug 9, 2026
…#7664)

`gc-root-dominance-statepoints`' `--max-unrooted` ratchet goes 21 -> 7.

#7663 pointed the root-dominance rule at the NATIVE root lowering -- the one
that ships since #7370 -- and reported 21 `unrooted` hazards. Fourteen were
shapes `root_reload.rs` looked straight through, because its rule is stated
over the load's own register and in both shapes the value at risk lives
somewhere else.

  1. The root is a GLOBAL, not an alloca (10 hits). A string literal lowers to
     `load double, ptr @<mod>_.str.N.handle`; the handle global is a registered
     root, so the string is never swept, and an evacuating cycle REWRITES the
     global while a register loaded beforehand keeps the pre-move address.
     #7240's shape, whose fix covered call operands only.

  2. The stale register is DERIVED from the load (3 of 7 unmasked receivers).
     `this.count++` holds the unmasked receiver across the property GET; the
     load's only use is the bitcast ABOVE the collecting call, so the window
     was empty and the function took zero reloads.

  3. `new.target`'s saved previous value (1 hit). `new.rs` saved
     `js_new_target_get()` in a bare register across the whole constructor
     body; the cell is a registered mutable root, so the restore publishes a
     pre-move address back INTO a root the collector scans. #7226's
     `prev_this` bug for `new.target`.

The window is anchored at the ROOT LOAD, not at the derived value. Anchoring at
the derivation looks more precise and is wrong: `main`'s class-object read has
the scope-end shadow-slot clear landing between the load and the mask, so a walk
starting at the mask never sees it and re-read a slot the program had just
nulled -- `(makeAnon(77) as any).v` became `undefined`. Caught by an A/B against
the branch point on `test_gap_class_expr_identity`, not by the dominance
checker, which cannot see a value-correctness bug.

The reload rule is restated over the value's derivation rather than its
register: for a value read out of a collector-rewritten location -- a shadow
slot or a string-handle global -- and any value derived from it by pure bit
ops, every use a collection point can reach re-materialises the whole
derivation. A recipe is extended only through ops that are pure functions of
their operands and whose every register operand is already in the same single
root's recipe, which makes it self-contained and materialisable anywhere.
Grouping by root load also puts the cost back at O(blocks x loads).

`new.target` gets `new_target_save`/`new_target_restore` in `crate::rooting`,
structurally `implicit_this_save`/`implicit_this_restore`. Re-reading the cell
would be the wrong repair: `js_new_target_set` has already overwritten it.

Measured on `Counter__increment`: before, all three statepoints carried an
EMPTY live set, so the receiver was marked by nothing; after, each carries a
"gc-live" bundle and a `gc.relocate`, and the SET reads a mask re-derived from
the relocated pointer plus a fresh load of the handle global.

Remaining 7, each its own slice: 4 unmasked are phi-mediated (the reload has to
go in the predecessor, on the edge); 2 `@perry_global_*` are module-level
variables the program assigns, so they need rooting rather than reloading
(pinned by `a_module_global_is_not_a_reload_source`); 1 capture read. #7664
stays open as the budget's referent.

Claude-Session: https://claude.ai/code/session_01Y1QZ5wUP9gRSwpiweT4Wix
proggeramlug added a commit that referenced this pull request Aug 9, 2026
… in the native lowering (#7664) (#7667)

* gc: close the strhandle, derived-mask and new.target unrooted hazards (#7664)

`gc-root-dominance-statepoints`' `--max-unrooted` ratchet goes 21 -> 7.

#7663 pointed the root-dominance rule at the NATIVE root lowering -- the one
that ships since #7370 -- and reported 21 `unrooted` hazards. Fourteen were
shapes `root_reload.rs` looked straight through, because its rule is stated
over the load's own register and in both shapes the value at risk lives
somewhere else.

  1. The root is a GLOBAL, not an alloca (10 hits). A string literal lowers to
     `load double, ptr @<mod>_.str.N.handle`; the handle global is a registered
     root, so the string is never swept, and an evacuating cycle REWRITES the
     global while a register loaded beforehand keeps the pre-move address.
     #7240's shape, whose fix covered call operands only.

  2. The stale register is DERIVED from the load (3 of 7 unmasked receivers).
     `this.count++` holds the unmasked receiver across the property GET; the
     load's only use is the bitcast ABOVE the collecting call, so the window
     was empty and the function took zero reloads.

  3. `new.target`'s saved previous value (1 hit). `new.rs` saved
     `js_new_target_get()` in a bare register across the whole constructor
     body; the cell is a registered mutable root, so the restore publishes a
     pre-move address back INTO a root the collector scans. #7226's
     `prev_this` bug for `new.target`.

The window is anchored at the ROOT LOAD, not at the derived value. Anchoring at
the derivation looks more precise and is wrong: `main`'s class-object read has
the scope-end shadow-slot clear landing between the load and the mask, so a walk
starting at the mask never sees it and re-read a slot the program had just
nulled -- `(makeAnon(77) as any).v` became `undefined`. Caught by an A/B against
the branch point on `test_gap_class_expr_identity`, not by the dominance
checker, which cannot see a value-correctness bug.

The reload rule is restated over the value's derivation rather than its
register: for a value read out of a collector-rewritten location -- a shadow
slot or a string-handle global -- and any value derived from it by pure bit
ops, every use a collection point can reach re-materialises the whole
derivation. A recipe is extended only through ops that are pure functions of
their operands and whose every register operand is already in the same single
root's recipe, which makes it self-contained and materialisable anywhere.
Grouping by root load also puts the cost back at O(blocks x loads).

`new.target` gets `new_target_save`/`new_target_restore` in `crate::rooting`,
structurally `implicit_this_save`/`implicit_this_restore`. Re-reading the cell
would be the wrong repair: `js_new_target_set` has already overwritten it.

Measured on `Counter__increment`: before, all three statepoints carried an
EMPTY live set, so the receiver was marked by nothing; after, each carries a
"gc-live" bundle and a `gc.relocate`, and the SET reads a mask re-derived from
the relocated pointer plus a fresh load of the handle global.

Remaining 7, each its own slice: 4 unmasked are phi-mediated (the reload has to
go in the predecessor, on the edge); 2 `@perry_global_*` are module-level
variables the program assigns, so they need rooting rather than reloading
(pinned by `a_module_global_is_not_a_reload_source`); 1 capture read. #7664
stays open as the budget's referent.

Claude-Session: https://claude.ai/code/session_01Y1QZ5wUP9gRSwpiweT4Wix

* chore: bump version to 0.5.1382

Claude-Session: https://claude.ai/code/session_01Y1QZ5wUP9gRSwpiweT4Wix

---------

Co-authored-by: Ralph Küpper <ralph@skelpo.com>
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.

1 participant