Skip to content

fix(codegen): make every GC root store dominate the collection points after it - #7192

Merged
proggeramlug merged 1 commit into
PerryTS:mainfrom
jdalton:fix/7154-root-store-dominance
Aug 1, 2026
Merged

fix(codegen): make every GC root store dominate the collection points after it#7192
proggeramlug merged 1 commit into
PerryTS:mainfrom
jdalton:fix/7154-root-store-dominance

Conversation

@jdalton

@jdalton jdalton commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

The invariant

A GC-managed value's root store must DOMINATE every subsequent site that can trigger a collection.

#7184 fixed one way to break it: the store is emitted, but its shadow-slot index falls outside the pushed frame, so js_shadow_slot_bind (and #7088's inline store) bounds-checks it and silently no-ops. This PR fixes its sibling: the store is emitted in-frame, but late — after a call that allocates, which under PERRY_GC_MOVING_LOOP_POLLS=1 reaches a back-edge poll and an evacuating minor.

Both present identically, and both are invisible to every runtime GC probe: at the moment of the collection there is nothing for the collector to find. That is why the #7154 hunt kept measuring correct layout coverage on offenders whose targets had already died — the miss is a mutator window, not a heap, root, or remembered-set defect.

The four sites

1. new C(…)lower_call/new.rs (the load-bearing one)

%inst = call i64 @js_object_alloc_class_inline_keys(...)   ; fresh Eden object
%box  = <nanbox %inst>
%ret  = call double @C_constructor(double %box, ...)       ; allocates; POLLS
call void @js_gc_init_typed_shape_layout(i64 %inst, ...)   ; %inst is now STALE
%v    = call double @js_ctor_return_override(%box, %ret, 0); publishes the STALE addr
store double %v, ptr %local
call void @js_shadow_slot_bind(i32 N, ptr %local)          ; a ROOTED dangling pointer

The instance is rooted inside the callee — the this parameter has a shadow slot — so the minor does not free it. It moves it and rewrites the callee's root. The caller's register is not a root, so it keeps the from-space address; js_gc_init_typed_shape_layout installs the layout descriptor on the abandoned copy, and js_ctor_return_override writes that dead address into the caller's shadow slot.

That is exactly #7154's fingerprint: a rooted slot holding a dangling pointer, so the from-space scan only ever sees offenders one or more cycles after the target died, with correct layout coverage, injected by the mutator between cycles.

Fixed by temp-rooting the instance across the constructor and re-reading it afterwards, on both the standalone-<Class>_constructor symbol path (the default since PERRY_INLINE_CTOR was inverted) and the inline-ctor path. A class with no constructor, no fields and no heritage runs no user code in that window and keeps its previous IR exactly — js_gc_init_typed_shape_layout is the only thing emitted in between and it does not allocate.

2. Expr::ObjectSpreadexpr/logical_collections.rs

{ ...a, k: f() } allocated the object and then wrote every field through a register held across each part's lowering, with no rooting at all. Expr::Object has used RootedHandle for this since #6951; the spread form's own comment says it takes "the same js_object_set_field_by_name path as Expr::Object" but it never copied the rooting.

3. Expr::ClassExprFreshexpr/static_field_meta.rs

Same shape for the fresh class object a class-expression factory returns, across its static-field initializers, captured-argument snapshot, symbol statics and static { … } blocks. The capture snapshot forces protection on its own: it allocates a js_array_alloc accumulator and grows it with js_array_push_f64 per element, which are collection points even when every element is an inert LocalGet.

4. Property / element stores — expr/property_set.rs, expr/index_set.rs

o.k = f() and o[k] = f() evaluate the reference first and the value second (spec order), leaving the receiver in a register across f(). The slot it was loaded from is a root and gets rewritten; the register does not, so the store lands in from-space and the field never appears on the object the program keeps.

This is #7114 with a receiver instead of a string literal — the same "property (2), a rewritten location, is worthless without property (3), reading that location again below the collection point" that temp_root.rs's own module header describes.

A new temp_root::ReceiverGuard roots the receiver only when the value expression can collect, so stores with an inert RHS keep their previous IR. A temp root, not a re-load, is required: re-lowering object would observe an assignment made by f() itself, which is a miscompile rather than a rooting fix.

temp_root_scope_begin now takes the caller's extra reason to open a scope — new C() with no arguments still needs a marker to cut the instance root against.

The instrument: scripts/gc_root_dominance_check.py

The checker that found all four, included as a debug tool. It parses perry-emitted LLVM IR, builds each function's CFG, computes real Cooper/Harvey/Kennedy dominance, and reports every shadow-slot root store that does not dominate a preceding collection point, naming the intervening call.

Real dominance and path-based windows matter: a naive line-order scan produced 8 false positives on this corpus where this reports none, because a loop back edge is not an intra-iteration path. It is one-sided by design — NONCOLLECTING is the only place a call is declared safe and every entry cites the runtime source line that proves it, so a missing entry costs a false positive and never a missed bug. Exits non-zero on any violation, so it can gate.

PERRY_GC_MOVING_LOOP_POLLS=1 PERRY_INLINE_SHADOW_SLOT=0 perry compile app.ts -o /tmp/app --trace llvm
python3 scripts/gc_root_dominance_check.py .perry-trace/llvm -v

Verification

result
test_gap_gc_new_instance_rooting.ts, POLLS=1, pure 73a9084ea bad 9 (expected 0) — 3/3, deterministic
same, default (no polls), pure 73a9084ea bad 0 — 5/5
same, POLLS=1, this PR bad 010/10
same, default, this PR bad 0 — 5/5
static checker over the 196-module sfw-registry IR corpus 234 → 1 violation
cargo test -p perry-codegen 6 failures, identical to main: the loop_safepoint_purity set from #7161's default flip
cargo test -p perry-runtime unchanged — this PR touches only perry-codegen, which perry-runtime does not depend on
sfw-registry --help, default arm clean 5/5 (no regression)
sfw-registry --help, POLLS=1 arm still red 5/5

New codegen regression test the_new_instance_is_rooted_across_the_constructor_body pins the def-use chain — the value js_ctor_return_override publishes must be re-derived from the instance's temp root — plus a negative test that a class running no user code emits no instance root at all. Assertions are on the def-use chain rather than textual order, because the override is emitted into the ctor.return.after block, which the writer appends below the block that re-reads the root.

What this does NOT close

sfw-registry --help under PERRY_GC_MOVING_LOOP_POLLS=1 is still red, so this does not close #7154 and stopgap #7161 stays. At least one more site of this class remains.

Two concrete leads for whoever picks it up:

  1. The residual is not another instance the alloc-anchored checker can see — it is down to 1 violation on the whole corpus. The remaining shapes it does not model are (a) heap values held in plain, non-shadow allocas (the inline-ctor path's this_slot/ctor_result_slot, and the [N x i64] closure-capture staging array), and (b) the general stale-register invariant — any register holding a heap value that is live across a collecting call and then used without being re-read from a root. A prototype of (b) is straightforward on top of the shipped checker's CFG/dominance layer; it reported ~1000 raw hits on the corpus before allowlist tuning, so it needs the NONCOLLECTING set extended before it is actionable.
  2. Separately worth a look while in this area: a repro of the form { ...src, k: churn() } read back with typeof o.k !== "number" reports a false typeof while String(o.k) prints the correct number, under POLLS=1 only and clean under PERRY_GEN_GC=0. The value survives; the typeof/string-comparison path does not. That is collector-mode-dependent and looks like its own bug, not this class.

Refs #7154, #7184, #7161, #7114, #6951.

Summary by CodeRabbit

  • Bug Fixes

    • Improved reliability during garbage collection for object construction, class initialization, object spreading, and property or element updates.
    • Prevented newly created objects and receivers from being lost or corrupted while allocation-heavy operations are in progress.
    • Added safeguards for constructors and static initialization that perform additional allocations.
  • Tests

    • Added regression coverage for object construction and garbage-collection scenarios.
  • Chores

    • Updated the application version to 0.5.1277.

… after it

PerryTS#7184 fixed one instance of "a live GC value is invisible to the moving minor
because its root store silently no-ops": the store's slot index fell outside the
pushed shadow frame, so js_shadow_slot_bind bounds-checked it away. This is its
sibling — the store is emitted in-frame, but LATE. The invariant is that a
GC-managed value's root store must DOMINATE every subsequent site that can
trigger a collection; four lowerings broke it by keeping the value in a bare SSA
register across a call that allocates, which under PERRY_GC_MOVING_LOOP_POLLS=1
reaches a back-edge poll and an evacuating minor.

new C(...) is the load-bearing one. The instance was a raw register while the
constructor body ran. It is rooted inside the callee (the `this` parameter has a
shadow slot), so the minor does not free it — it MOVES it and rewrites the
callee's root, leaving the caller's register naming from-space.
js_gc_init_typed_shape_layout then installed the layout descriptor on the
abandoned copy and js_ctor_return_override published that dead address into the
caller's shadow slot: a *rooted* slot holding a dangling pointer, which is
exactly why PerryTS#7154's from-space scan only ever saw offenders one or more cycles
after the target died, with correct layout coverage. The instance is now
temp-rooted across the constructor and re-read afterwards, on both the
standalone-<Class>_constructor symbol path and the inline-ctor path. A class
with no constructor, no fields and no heritage runs no user code in that window
and keeps its previous IR exactly.

Three more sites of the same shape:

  * Expr::ObjectSpread — `{ ...a, k: f() }` allocated the object and then wrote
    every field through a register held across each part's lowering, with no
    rooting at all. Expr::Object has used RootedHandle for this since PerryTS#6951; the
    spread form never got it.
  * Expr::ClassExprFresh — same for the fresh class object built by a
    class-expression factory, across its static-field initializers, captured
    arguments, symbol statics and `static { … }` blocks.
  * property / element stores — `o.k = f()` evaluates the reference first and
    the value second (spec order), leaving the receiver in a register across
    `f()`. The slot it was loaded from is a root and gets rewritten; the
    register does not, so the store landed in from-space and the field never
    appeared on the object the program kept. This is PerryTS#7114 with a receiver
    instead of a string literal. temp_root::ReceiverGuard roots it only when the
    value expression can collect, so an inert RHS keeps its previous IR.

temp_root_scope_begin now takes the caller's extra reason to open a scope,
because `new C()` with no arguments still needs a marker to cut against.

Verified by test-files/test_gap_gc_new_instance_rooting.ts: wrong 9 times in 400
iterations at pure 73a9084 under PERRY_GC_MOVING_LOOP_POLLS=1 (3/3 runs,
deterministic), clean 5/5 by default, clean 10/10 with this fix. A codegen
regression test pins the def-use chain: the value js_ctor_return_override
publishes must be re-derived from the instance's temp root.

Also adds scripts/gc_root_dominance_check.py, the static checker that found
these — it builds each function's CFG from the emitted LLVM, computes real
Cooper/Harvey/Kennedy dominance, and reports every root store that does not
dominate a preceding collection point. Both shipped bugs of this class are
invisible to runtime GC probes, because at the moment of the collection there is
nothing for the collector to find. Over the 196-module sfw-registry corpus it
reported 234 violations before this change and 1 after.

sfw-registry --help under PERRY_GC_MOVING_LOOP_POLLS=1 is still red — at least
one more site of this class remains — so this does not close PerryTS#7154 and stopgap
PerryTS#7161 stays. Its default arm is unchanged (clean 5/5).

Refs PerryTS#7154, PerryTS#7184, PerryTS#7161, PerryTS#7114, PerryTS#6951.
@jdalton
jdalton force-pushed the fix/7154-root-store-dominance branch from ed79df6 to 7eb2aeb Compare August 1, 2026 14:29
@coderabbitai

coderabbitai Bot commented Aug 1, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The change adds temporary rooting for receivers, object spreads, fresh classes, and constructed instances across GC-triggering operations. It adds regression tests, an LLVM IR dominance checker, changelog documentation, and version updates.

Changes

GC root relocation protection

Layer / File(s) Summary
Temporary-root infrastructure
crates/perry-codegen/src/expr/temp_root.rs
Added receiver guards, reread helpers, release helpers, and conditional temporary-root scopes.
Constructor instance rooting
crates/perry-codegen/src/lower_call/new.rs, crates/perry-codegen/tests/temp_root_operand_temporaries.rs
Constructed instances are rooted across user code and reloaded before layout and return handling. Tests cover rooting and the no-user-code path.
Store and expression rooting
crates/perry-codegen/src/expr/index_set.rs, crates/perry-codegen/src/expr/property_set.rs, crates/perry-codegen/src/expr/logical_collections.rs, crates/perry-codegen/src/expr/static_field_meta.rs
Property stores, index stores, object spreads, and fresh class expressions reload rooted objects after potentially collecting evaluations.
Validation and diagnostics
scripts/gc_root_dominance_check.py, test-files/test_gap_gc_new_instance_rooting.ts, changelog.d/7192-root-store-dominance.md, CLAUDE.md, Cargo.toml
Added moving-GC regression coverage and an LLVM IR dominance checker. Documented the changes and updated the version to 0.5.1277.

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

Sequence Diagram(s)

sequenceDiagram
  participant Codegen
  participant TempRootStack
  participant Runtime
  participant MovingGC
  Codegen->>TempRootStack: push receiver or allocated object
  Codegen->>Runtime: evaluate allocating RHS, initializer, or constructor
  Runtime->>MovingGC: trigger collection
  MovingGC-->>TempRootStack: relocate rooted value
  Codegen->>TempRootStack: reread relocated value
  Codegen->>Runtime: perform store or return operation
  Codegen->>TempRootStack: release temporary root
Loading

Possibly related issues

Possibly related PRs

  • PerryTS/perry#6972 — Both changes add precise temporary-rooting helpers in temp_root.rs.
  • PerryTS/perry#6983 — Both changes modify temporary-root handling, constructor lowering, and regression tests.
  • PerryTS/perry#7161 — Both changes address GC relocation safety in code generation.

Suggested labels: bug

Suggested reviewers: proggeramlug

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning The PR addresses a related code-generation failure but does not fix the linked issue's remembered-set or write-barrier defect and explicitly leaves the reproduction failing. Link this PR to a codegen-specific issue or implement the remaining #7154 remembered-set or write-barrier fix and pass the original reproduction.
Out of Scope Changes check ⚠️ Warning The PR includes unrelated release metadata changes to Cargo.toml, CLAUDE.md, and changelog.d/7192-root-store-dominance.md. Remove the version and release-metadata changes; maintainers should apply them during the merge or release process.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the code-generation change that makes GC root stores dominate later collection points.
Description check ✅ Passed The description clearly covers the scope, implementation, related issues, verification, and known limitations, despite omitting some template headings.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

@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: 5

🧹 Nitpick comments (4)
crates/perry-codegen/tests/temp_root_operand_temporaries.rs (2)

849-857: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Scope the gate assertion to the init function, and assert the scope marker.

The assertion searches the whole module IR. Any unrelated function that later emits js_gc_temp_root_push fails this test for the wrong reason. The other test in this file already narrows with init_ir.

The doc comment also promises "no scope marker either", but no assertion covers the scope marker.

♻️ Proposed refactor
 fn a_class_that_runs_no_user_code_emits_no_instance_root() {
     let ir = ir_for_new("new_inst_no_ctor.ts", vec![Expr::Number(1.0)]);
+    let f = init_ir(&ir);
     assert!(
-        !ir.contains("call i32 `@js_gc_temp_root_push`"),
+        !f.contains("call i32 `@js_gc_temp_root_push`"),
         "nothing can collect between the allocation and the `new` value, so \
-         rooting the instance would be pure TLS traffic:\n{ir}"
+         rooting the instance would be pure TLS traffic:\n{f}"
     );
+    assert!(
+        !f.contains("call void `@js_gc_temp_root_truncate`"),
+        "no temp-root scope marker may be opened for this class either:\n{f}"
+    );
 }
🤖 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/tests/temp_root_operand_temporaries.rs` around lines 849
- 857, Update a_class_that_runs_no_user_code_emits_no_instance_root to inspect
the init function IR via the existing init_ir helper rather than searching the
whole module. Assert that init_ir contains neither js_gc_temp_root_push nor the
promised scope marker, preserving the test’s no-root and no-scope behavior.

782-798: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Use a trim-based def lookup and include phi operands in the use chain.

def_of can miss valid two-space-emitted definitions if any later IR transformation changes spacing, and first_operand_reg drops all but the first operand. phi instructions use multiple operands, so the walk must push every operand onto the worklist or the test panics even for correct IR; report the visited set on failure.

🤖 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/tests/temp_root_operand_temporaries.rs` around lines 782
- 798, Update the temporary-register analysis helpers def_of and
first_operand_reg: make definition lookup trim each IR line before matching the
register assignment, and collect every register operand from phi instructions
rather than only the first. Ensure the worklist traverses all phi operands and
include the visited-register set in failure diagnostics.
scripts/gc_root_dominance_check.py (2)

514-514: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Remove the unused order map, and record the binds the checker cannot pair with a store.

Two points in check_func:

  • Line 514 builds order, and nothing reads it.
  • Lines 583-590 search for the activating store only inside bind_ins.block, at indices below the bind. If the emitter puts the store in a predecessor block, store_ins stays None and the bind is dropped without any record. That is another silent false negative against the one-sided soundness claim.

Count the skipped binds and report the count, so a coverage gap is visible instead of invisible.

Also applies to: 583-590

🤖 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 `@scripts/gc_root_dominance_check.py` at line 514, In check_func, remove the
unused order map. Update the bind-to-store search around bind_ins.block so binds
with no matching store, including stores in predecessor blocks, are counted
rather than silently dropped; report the skipped-bind count while preserving
existing pairing behavior.

84-84: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Clear the three Ruff findings.

Ruff 0.16.0 reports:

  • Line 84: Insn.__slots__ is not sorted (RUF023).
  • Line 257: and is chained with or without parentheses (RUF021). The precedence here is easy to misread, and the trailing startswith("%") and "= load" in d.text clause is already covered by the " load " in d.text test.
  • Line 338: node is unpacked but never used (RUF059).
🧹 Proposed fixes
-    __slots__ = ("text", "block", "idx", "result", "callee")
+    __slots__ = ("block", "callee", "idx", "result", "text")
-        if d.callee is not None or " load " in d.text or d.text.strip().startswith("%") and "= load" in d.text:
+        if d.callee is not None or " load " in d.text:
             origins.append(d)
-            node, it = stack[-1]
+            _node, it = stack[-1]

Also applies to: 257-257, 338-338

🤖 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 `@scripts/gc_root_dominance_check.py` at line 84, Clear the three Ruff findings
in the affected code: sort Insn.__slots__ lexicographically; parenthesize the
mixed and/or condition at line 257 and remove the redundant trailing
startswith("%") and "= load" clause, since the existing " load " check covers
it; and replace the unused node binding in the unpacking at line 338 with an
ignored binding.

Source: Linters/SAST tools

🤖 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/expr/index_set.rs`:
- Around line 1482-1494: Root the dynamically lowered key in the string-key arm
of crates/perry-codegen/src/expr/index_set.rs at lines 1482-1494 across
lower_value_for_dynamic_index_set, re-read it beside reread_store_receiver, and
release it before recv_guard at line 1530; the literal-key arm needs no change.
In crates/perry-codegen/src/expr/static_field_meta.rs lines 504-510, root k
across init lowering and re-read it before js_object_set_symbol_property,
alongside the existing obj rooted-handle handling.

In `@crates/perry-codegen/src/expr/logical_collections.rs`:
- Around line 925-927: Update the protection predicates at
crates/perry-codegen/src/expr/logical_collections.rs#L925-L927 and
crates/perry-codegen/src/expr/static_field_meta.rs#L439-L442 to account for
emitted collection points, not only operand expressions. In
logical_collections.rs, force protection when any spread part exists or when
there is more than one part. In static_field_meta.rs, hoist the block_fns
computation before rooted_handle_begin, include !block_fns.is_empty(), and force
protection when more than one named static is emitted.

In `@crates/perry-codegen/src/lower_call/new.rs`:
- Around line 1866-1870: Refresh the constructor result value after the inlined
constructor body may trigger GC: in the fall-through path around
reload_instance, reload ret.result_slot and use that refreshed value for
js_ctor_return_override, or store it back into ctor_result_slot before the call.
If retaining the slot, root it with the established nanbox root-store mechanism
so it cannot hold a pre-move obj_box.

In `@scripts/gc_root_dominance_check.py`:
- Around line 638-655: Update main to use argparse for the supported flags
instead of manually filtering sys.argv, and require at least one discovered .ll
file from the supplied paths. If no valid .ll files are found—including for
missing, invalid-directory, or --help-only invocations—emit an error and return
a nonzero status before printing the zero-file summary.
- Around line 124-135: Update the parser’s block-handling logic around LABEL_RE
and curblk so implicit numeric LLVM basic-block labels are either represented as
valid blocks with correct CFG relationships or cause parsing to reject the IR.
Do not map implicit labels to %entry.implicit or treat that synthetic name as
the real entry block; ensure explicit blocks with that name and explicit numeric
labels remain unambiguous for dominance analysis.

---

Nitpick comments:
In `@crates/perry-codegen/tests/temp_root_operand_temporaries.rs`:
- Around line 849-857: Update
a_class_that_runs_no_user_code_emits_no_instance_root to inspect the init
function IR via the existing init_ir helper rather than searching the whole
module. Assert that init_ir contains neither js_gc_temp_root_push nor the
promised scope marker, preserving the test’s no-root and no-scope behavior.
- Around line 782-798: Update the temporary-register analysis helpers def_of and
first_operand_reg: make definition lookup trim each IR line before matching the
register assignment, and collect every register operand from phi instructions
rather than only the first. Ensure the worklist traverses all phi operands and
include the visited-register set in failure diagnostics.

In `@scripts/gc_root_dominance_check.py`:
- Line 514: In check_func, remove the unused order map. Update the bind-to-store
search around bind_ins.block so binds with no matching store, including stores
in predecessor blocks, are counted rather than silently dropped; report the
skipped-bind count while preserving existing pairing behavior.
- Line 84: Clear the three Ruff findings in the affected code: sort
Insn.__slots__ lexicographically; parenthesize the mixed and/or condition at
line 257 and remove the redundant trailing startswith("%") and "= load" clause,
since the existing " load " check covers it; and replace the unused node binding
in the unpacking at line 338 with an ignored binding.
🪄 Autofix (Beta)

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: c9ba5bd3-f744-47d8-8f67-e0e6667577ec

📥 Commits

Reviewing files that changed from the base of the PR and between 5ef83bb and 7eb2aeb.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (12)
  • CLAUDE.md
  • Cargo.toml
  • changelog.d/7192-root-store-dominance.md
  • crates/perry-codegen/src/expr/index_set.rs
  • crates/perry-codegen/src/expr/logical_collections.rs
  • crates/perry-codegen/src/expr/property_set.rs
  • crates/perry-codegen/src/expr/static_field_meta.rs
  • crates/perry-codegen/src/expr/temp_root.rs
  • crates/perry-codegen/src/lower_call/new.rs
  • crates/perry-codegen/tests/temp_root_operand_temporaries.rs
  • scripts/gc_root_dominance_check.py
  • test-files/test_gap_gc_new_instance_rooting.ts

Comment thread crates/perry-codegen/src/expr/index_set.rs
Comment thread crates/perry-codegen/src/expr/logical_collections.rs
Comment thread crates/perry-codegen/src/lower_call/new.rs
Comment thread scripts/gc_root_dominance_check.py
Comment thread scripts/gc_root_dominance_check.py
@proggeramlug
proggeramlug merged commit eeb8ce4 into PerryTS:main Aug 1, 2026
6 of 7 checks passed
proggeramlug pushed a commit to proggeramlug/perry that referenced this pull request Aug 1, 2026
…tSpread reachability claim

PerryTS#7192's fragment is restored to what merged, minus nothing: the corrections and
the follow-up's own entries move into a separate PR-keyed fragment, per
changelog.d/README.md. The two corrections it records:

* Expr::ObjectSpread is reachable ONLY from a JSX spread attribute
  (crates/perry-hir/src/jsx.rs:67, its sole construction site). Since PerryTS#809 an
  object literal containing a spread lowers to a source-ordered IIFE built on
  js_object_assign_one, so { ...a, k: v } never reaches that arm. The claim that
  zod's 269-key spread runs through it is wrong.
* "At least one more site of this class remains" is replaced by three named
  residuals with reproducers, two of them SIGSEGVs under
  PERRY_GC_MOVING_LOOP_POLLS=1, all reproduced at 73a9084 (inherited, not
  caused) and all clean under the shipped default.
@proggeramlug

Copy link
Copy Markdown
Contributor

Post-merge verification of this PR's review round is in #7198. Summary of what the four findings turned out to be, so the threads above are not the only record:

  • lower_call/new.rs:1870 — real, and it defeated this PR's fix at its last instruction. ctor_result_slot is a plain alloca_entry the collector never rewrites, so seeding it with obj_box parked the pre-constructor instance address in unrooted memory; on fall-through js_ctor_return_override saw an object in raw and returned that, discarding the value reload_instance had just re-read. Not gated on PERRY_INLINE_CTORforce_ctor_call requires class.constructor.is_some(), so any class with fields or heritage but no own constructor takes the inline path by default. the_new_instance_is_rooted_across_the_constructor_body could not catch it: it walks the first operand of the override, which is the re-read one.
  • index_set.rs:1494 — real. The computed key sits in the same window as the receiver.
  • logical_collections.rs:927 — half real. User-code re-entry inside the helper (an accessor on a spread source, a static { … } body) is the route; "N js_object_set_field_by_name calls" is not — an allocation inside a runtime helper cannot initiate a moving collection in any shipped configuration.
  • gc_root_dominance_check.py:135 — rejected as written, but the adjacent defect was worse: a label-less function parsed to zero blocks and was silently skipped, so a planted violation vanished and the run exited 0. That plus four other ways the checker could not fail are fixed in fix(codegen): close #7192's own residual root-store hole, and make the dominance checker able to fail #7198, along with PERRY_SAVE_LL never being honoured for split modules (so --trace llvm emitted nothing for exactly the largest modules).

Two corrections to this PR's fragment, both in #7198:

  1. Expr::ObjectSpread is JSX-only. Since perry-codegen: object literal with computed-key props + computed-key methods + cross-module spread drops keys & mis-resolves methods — Effect HashRing.ts blocker (post-#740) #809 an object literal containing a spread lowers to a source-ordered IIFE built on js_object_assign_one (lower/expr_object.rs:844); the sole construction site of ObjectSpread is a JSX spread attribute (crates/perry-hir/src/jsx.rs:67). The zod-269-key-spread claim is wrong — the fix is still right, its blast radius is JSX.
  2. The version bump collided. main reached 0.5.1277 via fix(hir): fold an imported binding's array-named method only with array evidence #7188 while this was in review, so both sides matched and this merged with no net bump.

Verified as merged, on origin/main + #7198, release build, Node 26.5.1: test_gap_gc_new_instance_rooting.ts is bad 0 5/5 under PERRY_GC_MOVING_LOOP_POLLS=1 and bad 0 3/3 under the shipped default, byte-exact against the oracle — and it is bad 9 deterministically 3/3 at 73a9084ea under polls, so the =1 arm is demonstrably not dark.

#7161 is not revertible yet, and not only because of sfw-registry. Two locally-reproducible SIGSEGVs (exit=139) remain under polls, both present at 73a9084ea and both clean under the default: { ...src, k: v } where src carries an accessor, and a class expression with a static { … } block. Details and reproducers in #7198.

proggeramlug added a commit that referenced this pull request Aug 1, 2026
…e dominance checker able to fail (#7198)

* fix(codegen): close three more root-store dominance holes, and make the checker able to fail

Maintenance pass on #7192. Four review findings adjudicated reproducer-first;
three were real and are fixed here, one was rejected with evidence.

REAL — `Expr::ObjectSpread` / `Expr::ClassExprFresh` protect predicates.
`protect_handle` was computed from the operand expressions alone, so
`{ ...src, tail: 7 }` over inert parts pushed no root at all — and
`js_object_copy_own_fields` reads every own key of the source, which runs a
getter, which is arbitrary JS that can reach a back-edge poll. Reproduced at
the PR head under PERRY_GC_MOVING_LOOP_POLLS=1: `bad 10` deterministic 3/3,
clean by default. Same for a class expression carrying `static { … }` with
otherwise-inert statics: `bad 4` 3/3. A spread part and a static block now
force protection on their own; `block_fns` moves above `rooted_handle_begin`
so the predicate can see it. The rest of the predicate stays byte-identical to
`Expr::Object`'s, because an allocation inside a runtime helper provably
cannot INITIATE a moving collection — `gc_check_trigger`'s minor arm defers to
the loop safepoint under polls and is conservative-scanned or budgeted
non-moving otherwise.

REAL — the inline-ctor result slot. `ctor_result_slot` is a plain
`alloca_entry`: not a shadow slot, not a temp root, never rewritten. Seeding
it with `obj_box` parked the PRE-constructor instance address in unrooted
memory for the whole body, and on fall-through `js_ctor_return_override` saw
an *object* in `raw` and returned THAT — discarding the re-read instance the
new `reload_instance` had just recovered. The dominance fix was defeated at
its last instruction, and the PR's own regression test could not see it
because it walks the FIRST operand, which is the re-read one. The slot now
starts at `undefined`, which is exactly equivalent on all four paths
(fall-through, bare `return;`, `return <expr>`, inherited-symbol ctor) and
carries no address. A regression test pins it.

REAL — the key operand of a computed store. `o[k] = f()` and a class
expression's `[sym]: init` lower the key before the value, leaving it in an
SSA register across the same window the receiver had. Rooted with the same
guard, released inside-out so the temp-root cuts nest. No runtime reproducer
found (the value survives; the staleness is latent until the abandoned memory
is reused, which is #7154's fingerprint), so this rests on the same static
argument as #7114.

REJECTED — `%entry.implicit` in the checker. No such code exists; the parser
drops pre-label instructions rather than synthesising a block, and numeric
labels already parse correctly. Perry's IR writer provably cannot emit either
shape (`function.rs` emits a label before every block, always `name.counter`),
confirmed over 214 real functions / 3608 labels. But the adjacent defect is
real and worse: a label-less function parsed to zero blocks and was SILENTLY
SKIPPED — a planted violation vanished and the run exited 0. Both that and a
label-shaped line the strict regex declines now raise `MalformedIR`, and the
`; preds =` form LLVM itself prints is accepted rather than mis-parsed.

The checker could not fail in three other ways, all fixed: it exited 0 on no
arguments, on `--help`, on a typo'd flag and on a directory holding no `.ll`;
it had no liveness assertion, so a clean verdict over zero root stores was
indistinguishable from a clean one over the real corpus; and `PERRY_SAVE_LL`
was never honoured for modules past `MIN_CALLABLES_TO_SPLIT`, so `--trace llvm`
silently emitted nothing for exactly the largest modules. `main` now uses
argparse, requires `--min-files` / `--min-binds`, and grows a `--self-test`
that plants a violation and asserts the checker reports it. The split path
writes one `.ll` per codegen unit.

Wires the gate into a new `gc-root-dominance` workflow — deliberately NOT in
branch protection's required contexts yet, since a gate that has never been
green blocks every open PR the day it is promoted.

CLAUDE.md gains the invariant itself under known-weak areas, including the
third way it breaks that is still open: a heap value in a plain `alloca_entry`
(`lower_call/new.rs`'s inline-ctor `this_slot`).

Refs #7154, #7184, #7192, #7114, #6951.

* docs(changelog): follow-up fragment, and correct #7192's ObjectSpread reachability claim

#7192's fragment is restored to what merged, minus nothing: the corrections and
the follow-up's own entries move into a separate PR-keyed fragment, per
changelog.d/README.md. The two corrections it records:

* Expr::ObjectSpread is reachable ONLY from a JSX spread attribute
  (crates/perry-hir/src/jsx.rs:67, its sole construction site). Since #809 an
  object literal containing a spread lowers to a source-ordered IIFE built on
  js_object_assign_one, so { ...a, k: v } never reaches that arm. The claim that
  zod's 269-key spread runs through it is wrong.
* "At least one more site of this class remains" is replaced by three named
  residuals with reproducers, two of them SIGSEGVs under
  PERRY_GC_MOVING_LOOP_POLLS=1, all reproduced at 73a9084 (inherited, not
  caused) and all clean under the shipped default.

---------

Co-authored-by: Ralph Küpper <ralph@skelpo.com>
proggeramlug pushed a commit to jdalton/perry that referenced this pull request Aug 1, 2026
…sabotage test

Addresses the CodeRabbit review on PerryTS#7196.

- arena/quarantine.rs: `mprotect`/`sigaction`/`sysconf`/`_SC_PAGESIZE` do not
  exist in the `libc` crate on `x86_64-pc-windows-msvc`, and `perry-runtime` is
  genuinely compiled for that target (test.yml `windows-build`, and
  release-packages.yml via `perry-ui-windows` -> `perry-runtime`). Gate the
  syscall helpers per the existing `pty::native` precedent. `ProtectPages`
  degrades to poison-only off Unix, and the degradation is visible rather than
  silent because `bytes_protected` stays 0 while `bytes_poisoned` counts the
  whole retired range.
- arena/quarantine.rs: census coverage ended at `user_offset + size`, but
  `size` covers header+payload while `user_offset` already skips the header, so
  an address inside the NEXT object's header was attributed to the previous
  object and the fault report named the wrong last-known object.
- arena/quarantine.rs: `push_set_and_evict` incremented `SETS_RETIRED` before
  taking the registry lock and dropped the blocks on a poisoned lock -
  `QuarantinedBlock` has no `Drop`, so that leaked the whole from-space while
  inflating the counter that is supposed to be the live-subject evidence.
- arena/quarantine.rs: correct the `ensure_usable_current_block` doc. Allocation
  is tombstone-safe on every path; Eden needs the fixup for `INLINE_STATE`, not
  for `Arena::alloc`. New `alloc_is_correct_when_current_points_at_a_tombstone`
  pins that property.
- gc/tests/fromspace_protect.rs: `zeal_implies_forced_evacuation` was satisfied
  by its right operand alone under an ambient `PERRY_GEN_GC_EVACUATE=0` - split
  into a precedence arm and an implication arm so both assert something.
- gc/tests/fromspace_protect.rs: add
  `quarantine_catches_a_planted_stale_from_space_deref`, which plants a
  PerryTS#7184/PerryTS#7192-shaped stale deref and asserts the instrument distinguishes it
  from the live object recycled into those bytes, with the un-instrumented arm
  as the red control.
- Docs: zeal does not bypass `gc_safepoint_moving_minor`'s entry guards, and
  loses to an explicit `PERRY_GEN_GC_EVACUATE=0`.
- Revert the version bump (external contributor PRs do not bump; the maintainer
  does at merge) and rename the changelog fragment to the PR-keyed 7196-.
proggeramlug pushed a commit to jdalton/perry that referenced this pull request Aug 1, 2026
Follow-up to the census bound fix, found by re-running the reverted-PerryTS#7192
reproducer under the instrument.

Bounding coverage at `user_offset + size` overshoots by GC_HEADER_SIZE and
names the PREVIOUS object. Bounding at the payload end instead
(`user_offset + size - GC_HEADER_SIZE`) is correct for payload addresses but
leaves *header* addresses attributed to nobody - and a raw header address is
exactly what this family of bugs produces: PerryTS#7192's shape publishes `%inst`,
the pre-header allocation pointer. Measured on the reverted-PerryTS#7192 reproducer,
the payload-end bound turned a (wrong) attribution into
"(no census entry covers this offset)".

Match the object's whole extent instead, header included, and extract the
lookup into `census_lookup` so it is testable. `census_attributes_headers_and_
payloads_to_the_right_object` pins all four cases: payload, last payload byte,
an object's own header (previously the neighbour's), and stride padding
(nobody).

The "last-known object" line is what an investigator reads to tell a dead
closure (obj_type=4) from a dead object (2); an instrument that names the
wrong one there is worse than one that says nothing.
jdalton added a commit to jdalton/perry that referenced this pull request Aug 1, 2026
…eir sibling operands

Two more sites of PerryTS#7192's root-store-dominance class, found by extending its
checker with the general stale-register invariant and each reproduced in ~30
lines of TypeScript.

`recv.m(f())` and `o[f()]` evaluate the reference first and the second operand
after — spec order, and codegen follows it. That left the reference in a bare
SSA register while `f()` was lowered, and `f()` allocates. Under
PERRY_GC_MOVING_LOOP_POLLS=1 a loop back-edge poll inside it runs an evacuating
minor. The reference SURVIVES that minor — the closure capture cell, shadow slot
or module global holding it is a root — so it MOVES: the collector rewrites that
location and the register keeps naming from-space. Same "property (2), a
rewritten location, is worthless without property (3), reading that location
again below the collection point" that expr/temp_root.rs's module header
describes, and the same fix PerryTS#7192 applied to the property/element STORE
receiver.

  * lower_call/console_promise.rs — the js_native_call_method_by_id dispatch.
    The stale receiver makes the method lookup resolve against abandoned memory,
    so the call throws "TypeError: value is not a function". In sfw-registry
    this is zod classic/schemas.ts:301,
    `inst.regex = (...args) => inst.check(checks.regex(...args))`: `inst` is read
    out of the arrow's capture cell, held across `checks.regex(...args)` (a real
    user call, so it polls), then used as `.check`'s receiver. The arguments are
    rooted too — each before the NEXT one is lowered, per RootedOperands'
    incremental contract — so an earlier argument cannot go stale across a later
    one either.
  * expr/index_get.rs — the dynamic-string-key arm and the last-resort
    runtime-tag-check arm: the READ counterpart of PerryTS#7192's index_set /
    property_set guard, which only covered the store side. The stale base makes
    the field read walk the keys array of from-space memory — a SIGSEGV inside
    get_field_by_name_object_tail, or a silently wrong value. In sfw-registry
    this is zod core/checks.ts:68, `numericOriginMap[typeof def.value]`: a
    module-global base with a key expression that reads a property and therefore
    can collect.

Both use temp_root::guard_store_operand / reread_store_operand /
release_store_operand (PerryTS#7198's generalized naming). A temp root, not a re-lower:
re-lowering the reference would observe an assignment made by the second operand
itself, which is a miscompile rather than a rooting fix. The guard emits nothing
when the sibling expression cannot collect, so an inert argument list or key
keeps its previous IR exactly, and it is released AFTER the dispatch because the
dispatcher allocates while reading these values.

Verified by two new gap tests, each red on the parent commit and green after,
and each clean under a non-moving collector so the failure is proven to track
collector mode rather than luck:

  test_gap_gc_method_receiver_rooting.ts   POLLS=1  parent: TypeError 5/5
                                                    this:   bad 0 10/10
  test_gap_gc_index_get_receiver_rooting.ts POLLS=1 parent: TypeError 4/4
                                                    this:   bad 0 10/10
  both, POLLS=1 + PERRY_GEN_GC=0                    bad 0
  both, default (no polls)                          bad 0 5/5

scripts/gc_root_dominance_check.py grows --stale-registers. The shipped check
anchors on a shadow-slot bind, so it can only see values that are eventually
rooted; neither site above is. The new mode classifies every heap-value source
(an allocation, or a read of a collector-rewritten location: a shadow-slot load,
a closure capture cell, a temp-root slot, a module global, a mutable-capture
box), follows it forward through bit-level identity ops, and reports the first
real use below a collecting call. --fatal-sinks narrows to uses that DEREFERENCE
the value (a call receiver or callee), where a relocation is fatal rather than
merely wrong. Over the 141-module sfw-registry corpus the fatal-sink slice went
986 -> 729 with this change and the entire
js_typed_feedback_native_call_method_by_id class (257) is gone. The remaining
593 are js_closure_callN — the generic dynamic-value-call lowering, which holds
the callee AND the `this` receiver AND each argument in registers across the
argument list. That is the next site of this class and it is NOT fixed here.

That mode is a diagnostic, so it EXITS 0 and reports counts. It is not
calibrated to zero — the raw count is dominated by values the checker cannot
prove are pointers, and even the --fatal-sinks slice still carries the
js_closure_callN class above — so returning 1 on any hit would be a check that
can never pass, the mirror image of CLAUDE.md's four "a gate that cannot fail"
hazards and just as reliably ignored. Gating is opt-in via --max-stale N, which
exits 1 above the budget, so a calibrated slice can become a ratchet later
without the raw mode pretending to be one. --max-stale without --stale-registers
is a usage error (exit 2) rather than a silently ignored budget. --self-test
asserts all of it: the default must report 2 uses on the planted fixture and
still exit 0, --max-stale 0 must exit 1, --max-stale 2 must exit 0, and the
control fixture must report zero. The bind-anchored gate gc-root-dominance.yml
actually runs is untouched and still exits non-zero on any violation.

sfw-registry --help under PERRY_GC_MOVING_LOOP_POLLS=1 (compiled AND run with
the flag) is still red — 8/10 SIGSEGV before these fixes surfaced past the
TypeError, 5/10 after — so PerryTS#7161's stopgap stays. Its default arm is clean
10/10.

cargo test -p perry-codegen: the 6 loop_safepoint_purity failures from PerryTS#7161's
default flip, identical to main. cargo test -p perry-runtime: unchanged — this
commit touches only perry-codegen, which perry-runtime does not depend on.

Refs PerryTS#7154, PerryTS#7192, PerryTS#7198, PerryTS#7184, PerryTS#7161, PerryTS#7114, PerryTS#6951.
jdalton added a commit to jdalton/perry that referenced this pull request Aug 1, 2026
…eir sibling operands

Two more sites of PerryTS#7192's root-store-dominance class, found by extending its
checker with the general stale-register invariant and each reproduced in ~30
lines of TypeScript.

`recv.m(f())` and `o[f()]` evaluate the reference first and the second operand
after — spec order, and codegen follows it. That left the reference in a bare
SSA register while `f()` was lowered, and `f()` allocates. Under
PERRY_GC_MOVING_LOOP_POLLS=1 a loop back-edge poll inside it runs an evacuating
minor. The reference SURVIVES that minor — the closure capture cell, shadow slot
or module global holding it is a root — so it MOVES: the collector rewrites that
location and the register keeps naming from-space. Same "property (2), a
rewritten location, is worthless without property (3), reading that location
again below the collection point" that expr/temp_root.rs's module header
describes, and the same fix PerryTS#7192 applied to the property/element STORE
receiver.

  * lower_call/console_promise.rs — the js_native_call_method_by_id dispatch.
    The stale receiver makes the method lookup resolve against abandoned memory,
    so the call throws "TypeError: value is not a function". In sfw-registry
    this is zod classic/schemas.ts:301,
    `inst.regex = (...args) => inst.check(checks.regex(...args))`: `inst` is read
    out of the arrow's capture cell, held across `checks.regex(...args)` (a real
    user call, so it polls), then used as `.check`'s receiver. The arguments are
    rooted too — each before the NEXT one is lowered, per RootedOperands'
    incremental contract — so an earlier argument cannot go stale across a later
    one either.
  * expr/index_get.rs — the dynamic-string-key arm and the last-resort
    runtime-tag-check arm: the READ counterpart of PerryTS#7192's index_set /
    property_set guard, which only covered the store side. The stale base makes
    the field read walk the keys array of from-space memory — a SIGSEGV inside
    get_field_by_name_object_tail, or a silently wrong value. In sfw-registry
    this is zod core/checks.ts:68, `numericOriginMap[typeof def.value]`: a
    module-global base with a key expression that reads a property and therefore
    can collect.

Both use temp_root::guard_store_operand / reread_store_operand /
release_store_operand (PerryTS#7198's generalized naming). A temp root, not a re-lower:
re-lowering the reference would observe an assignment made by the second operand
itself, which is a miscompile rather than a rooting fix. The guard emits nothing
when the sibling expression cannot collect, so an inert argument list or key
keeps its previous IR exactly, and it is released AFTER the dispatch because the
dispatcher allocates while reading these values.

Verified by two new gap tests, each red on the parent commit and green after,
and each clean under a non-moving collector so the failure is proven to track
collector mode rather than luck:

  test_gap_gc_method_receiver_rooting.ts   POLLS=1  parent: TypeError 5/5
                                                    this:   bad 0 10/10
  test_gap_gc_index_get_receiver_rooting.ts POLLS=1 parent: TypeError 4/4
                                                    this:   bad 0 10/10
  both, POLLS=1 + PERRY_GEN_GC=0                    bad 0
  both, default (no polls)                          bad 0 5/5

scripts/gc_root_dominance_check.py grows --stale-registers. The shipped check
anchors on a shadow-slot bind, so it can only see values that are eventually
rooted; neither site above is. The new mode classifies every heap-value source
(an allocation, or a read of a collector-rewritten location: a shadow-slot load,
a closure capture cell, a temp-root slot, a module global, a mutable-capture
box), follows it forward through bit-level identity ops, and reports the first
real use below a collecting call. --fatal-sinks narrows to uses that DEREFERENCE
the value (a call receiver or callee), where a relocation is fatal rather than
merely wrong. Over the 141-module sfw-registry corpus the fatal-sink slice went
986 -> 729 with this change and the entire
js_typed_feedback_native_call_method_by_id class (257) is gone. The remaining
593 are js_closure_callN — the generic dynamic-value-call lowering, which holds
the callee AND the `this` receiver AND each argument in registers across the
argument list. That is the next site of this class and it is NOT fixed here.

That mode is a diagnostic, so it EXITS 0 and reports counts. It is not
calibrated to zero — the raw count is dominated by values the checker cannot
prove are pointers, and even the --fatal-sinks slice still carries the
js_closure_callN class above — so returning 1 on any hit would be a check that
can never pass, the mirror image of CLAUDE.md's four "a gate that cannot fail"
hazards and just as reliably ignored. Gating is opt-in via --max-stale N, which
exits 1 above the budget, so a calibrated slice can become a ratchet later
without the raw mode pretending to be one. --max-stale or --fatal-sinks without
--stale-registers is a usage error (exit 2) rather than a silently ignored
budget or an ignored filter. --self-test
asserts all of it: the default must report 2 uses on the planted fixture and
still exit 0, --max-stale 0 must exit 1, --max-stale 2 must exit 0, and the
control fixture must report zero. The bind-anchored gate gc-root-dominance.yml
actually runs is untouched and still exits non-zero on any violation.

sfw-registry --help under PERRY_GC_MOVING_LOOP_POLLS=1 (compiled AND run with
the flag) is still red — 8/10 SIGSEGV before these fixes surfaced past the
TypeError, 5/10 after — so PerryTS#7161's stopgap stays. Its default arm is clean
10/10.

cargo test -p perry-codegen: the 6 loop_safepoint_purity failures from PerryTS#7161's
default flip, identical to main. cargo test -p perry-runtime: unchanged — this
commit touches only perry-codegen, which perry-runtime does not depend on.

Refs PerryTS#7154, PerryTS#7192, PerryTS#7198, PerryTS#7184, PerryTS#7161, PerryTS#7114, PerryTS#6951.
proggeramlug pushed a commit that referenced this pull request Aug 1, 2026
…eir sibling operands (#7206)

* fix(codegen): root a call receiver and a computed-read base across their sibling operands

Two more sites of #7192's root-store-dominance class, found by extending its
checker with the general stale-register invariant and each reproduced in ~30
lines of TypeScript.

`recv.m(f())` and `o[f()]` evaluate the reference first and the second operand
after — spec order, and codegen follows it. That left the reference in a bare
SSA register while `f()` was lowered, and `f()` allocates. Under
PERRY_GC_MOVING_LOOP_POLLS=1 a loop back-edge poll inside it runs an evacuating
minor. The reference SURVIVES that minor — the closure capture cell, shadow slot
or module global holding it is a root — so it MOVES: the collector rewrites that
location and the register keeps naming from-space. Same "property (2), a
rewritten location, is worthless without property (3), reading that location
again below the collection point" that expr/temp_root.rs's module header
describes, and the same fix #7192 applied to the property/element STORE
receiver.

  * lower_call/console_promise.rs — the js_native_call_method_by_id dispatch.
    The stale receiver makes the method lookup resolve against abandoned memory,
    so the call throws "TypeError: value is not a function". In sfw-registry
    this is zod classic/schemas.ts:301,
    `inst.regex = (...args) => inst.check(checks.regex(...args))`: `inst` is read
    out of the arrow's capture cell, held across `checks.regex(...args)` (a real
    user call, so it polls), then used as `.check`'s receiver. The arguments are
    rooted too — each before the NEXT one is lowered, per RootedOperands'
    incremental contract — so an earlier argument cannot go stale across a later
    one either.
  * expr/index_get.rs — the dynamic-string-key arm and the last-resort
    runtime-tag-check arm: the READ counterpart of #7192's index_set /
    property_set guard, which only covered the store side. The stale base makes
    the field read walk the keys array of from-space memory — a SIGSEGV inside
    get_field_by_name_object_tail, or a silently wrong value. In sfw-registry
    this is zod core/checks.ts:68, `numericOriginMap[typeof def.value]`: a
    module-global base with a key expression that reads a property and therefore
    can collect.

Both use temp_root::guard_store_operand / reread_store_operand /
release_store_operand (#7198's generalized naming). A temp root, not a re-lower:
re-lowering the reference would observe an assignment made by the second operand
itself, which is a miscompile rather than a rooting fix. The guard emits nothing
when the sibling expression cannot collect, so an inert argument list or key
keeps its previous IR exactly, and it is released AFTER the dispatch because the
dispatcher allocates while reading these values.

Verified by two new gap tests, each red on the parent commit and green after,
and each clean under a non-moving collector so the failure is proven to track
collector mode rather than luck:

  test_gap_gc_method_receiver_rooting.ts   POLLS=1  parent: TypeError 5/5
                                                    this:   bad 0 10/10
  test_gap_gc_index_get_receiver_rooting.ts POLLS=1 parent: TypeError 4/4
                                                    this:   bad 0 10/10
  both, POLLS=1 + PERRY_GEN_GC=0                    bad 0
  both, default (no polls)                          bad 0 5/5

scripts/gc_root_dominance_check.py grows --stale-registers. The shipped check
anchors on a shadow-slot bind, so it can only see values that are eventually
rooted; neither site above is. The new mode classifies every heap-value source
(an allocation, or a read of a collector-rewritten location: a shadow-slot load,
a closure capture cell, a temp-root slot, a module global, a mutable-capture
box), follows it forward through bit-level identity ops, and reports the first
real use below a collecting call. --fatal-sinks narrows to uses that DEREFERENCE
the value (a call receiver or callee), where a relocation is fatal rather than
merely wrong. Over the 141-module sfw-registry corpus the fatal-sink slice went
986 -> 729 with this change and the entire
js_typed_feedback_native_call_method_by_id class (257) is gone. The remaining
593 are js_closure_callN — the generic dynamic-value-call lowering, which holds
the callee AND the `this` receiver AND each argument in registers across the
argument list. That is the next site of this class and it is NOT fixed here.

That mode is a diagnostic, so it EXITS 0 and reports counts. It is not
calibrated to zero — the raw count is dominated by values the checker cannot
prove are pointers, and even the --fatal-sinks slice still carries the
js_closure_callN class above — so returning 1 on any hit would be a check that
can never pass, the mirror image of CLAUDE.md's four "a gate that cannot fail"
hazards and just as reliably ignored. Gating is opt-in via --max-stale N, which
exits 1 above the budget, so a calibrated slice can become a ratchet later
without the raw mode pretending to be one. --max-stale or --fatal-sinks without
--stale-registers is a usage error (exit 2) rather than a silently ignored
budget or an ignored filter. --self-test
asserts all of it: the default must report 2 uses on the planted fixture and
still exit 0, --max-stale 0 must exit 1, --max-stale 2 must exit 0, and the
control fixture must report zero. The bind-anchored gate gc-root-dominance.yml
actually runs is untouched and still exits non-zero on any violation.

sfw-registry --help under PERRY_GC_MOVING_LOOP_POLLS=1 (compiled AND run with
the flag) is still red — 8/10 SIGSEGV before these fixes surfaced past the
TypeError, 5/10 after — so #7161's stopgap stays. Its default arm is clean
10/10.

cargo test -p perry-codegen: the 6 loop_safepoint_purity failures from #7161's
default flip, identical to main. cargo test -p perry-runtime: unchanged — this
commit touches only perry-codegen, which perry-runtime does not depend on.

Refs #7154, #7192, #7198, #7184, #7161, #7114, #6951.

* fix(codegen): adopt #7207's reread_store_operand signature at the two #7206 sites

Merge-time fix, not a change of intent. #7207 (`1679e22b4`, merged after this
PR branched) widened `reread_store_operand` to take the operand expression and
return `anyhow::Result<String>` so its new `Reload` arm can re-lower a
string-literal base instead of reusing a register taken before the collection
point. The two call sites added here still passed the old three-argument form.

git merged both files cleanly because the change is in a DIFFERENT file
(`temp_root.rs`) from the call sites (`index_get.rs`) -- a textually clean
merge that does not compile. Adopting the new signature also gives these two
sites #7207's strictly better treatment of a literal base (`"abc"[k]`).

* test(gc): register #7206's two witnesses in the GC x repsel corpus

Both are moving-only: clean on the shipped default on both sides of the fix,
so they certify nothing on the `default` arm and belong with the
`requires=move` rows. Measured red-then-green numbers are in the comment
block.

---------

Co-authored-by: Ralph Küpper <ralph@skelpo.com>
proggeramlug pushed a commit to jdalton/perry that referenced this pull request Aug 1, 2026
…e_callN

The generic dynamic-value-call lowering held THREE classes of GC value in bare
SSA registers across work that can collect. `js_closure_callN` is the central
dispatch path -- `f(g())`, `o.m(g())`, `curry(1)(2)`, every call whose callee is
a value rather than a statically resolved function -- and this is the site

An SSA register is not a GC root. Under PERRY_GC_MOVING_LOOP_POLLS=1 a back-edge
poll inside an argument runs an evacuating minor: each held value SURVIVES (the
capture cell, shadow slot or module global it was read from is a root) and
therefore MOVES. The collector rewrites that location; the register keeps naming
from-space.

  * the CALLEE, held across the whole argument list. The checked unbox masks a
    pre-move address and js_closure_callN reads a closure header out of
    abandoned memory: "TypeError: value is not a function".
  * the `this` RECEIVER, held across the read of the callee off it AND the
    argument list. PerryTS#7206 fixed this operand on the sibling
    js_native_call_method_by_id dispatch; this is the generic one.
  * each already-lowered ARGUMENT, held across the arguments after it AND
    across the rebind unbox.

The three live windows differ, so they are computed separately:

  receiver   | the callee read + every argument
  callee     | every argument
  argument i | the arguments after i + the rebind unbox

That last window is why this is not a copy of PerryTS#7206's fix.
js_closure_unbox_callee_checked_rebind calls clone_closure_rebind_this, which
ALLOCATES a replacement closure (closure/dynamic_props.rs:1040) when the callee
captures `this`. It sits below the last argument and above js_closure_callN, so
the arguments are re-read below it -- hence RootedOperands::reread_one, which
re-reads one operand at a caller-chosen point instead of the whole group at one.
Hoisting the unbox above the argument list would remove the window instead, but
its throw is observable and the spec evaluates arguments before it. On the
>16-arity path the argument stores into the stack buffer moved below the unbox
for the same reason: a stack buffer is not a root, so filling it above an
allocating rebind freezes pre-move addresses one indirection further out.

Receiverless calls take js_closure_unbox_callee_checked, a tag check and a mask
that allocates nothing, so `f(x, y)` on inert operands emits exactly its old IR.
Temp roots, not re-lowering: re-lowering the callee or receiver would observe an
assignment made by an argument, a miscompile rather than a rooting fix.

Three gap tests, one per held value, each red on the parent under a GENUINE
POLLS=1 build and green after. The flag is compile-time since PerryTS#7161 AND
runtime-armed (gc_moving_loop_polls_enabled(), gc/policy.rs:1759) -- setting
only one is a false green, and the first cut of these tests passed 10/10 for
exactly that reason.

  callee / this / argument, POLLS=1     parent: TypeError 10/10 each
                                        this:   bad 0    10/10 each
  all three, POLLS=1 + PERRY_GEN_GC=0   bad 0 5/5
  all three, default (no polls)         bad 0 5/5

Cost over the 141-module sfw-registry corpus, measured rather than assumed
because this is the hottest emitted call path. operand_protection emits nothing
for an operand whose window cannot collect, which is why the delta is small:
linked binary 39,216,688 -> 39,233,200 B (+0.042%), emitted IR 1,999,570 ->
2,001,607 lines (+0.10%), js_gc_temp_root_push sites 8,394 -> 8,885.

scripts/gc_root_dominance_check.py gains js_closure_unbox_callee_checked in
NONCOLLECTING, citing closure/unbox.rs:25 -- a tag check and a low-48 mask, no
allocation, no user code, no poll. It sits between every dynamic call's last
argument and its js_closure_callN, so its absence reported the whole argument
list of every 1-arg dynamic call as stale: 372 of the 729 fatal-sink hits were
that single false positive, all marked MOVING: no. The _rebind variant is
deliberately NOT added -- it allocates, and the fix above depends on it counting
as a collection point. Fatal-sink slice against the corrected list: 231 -> 205.

cargo test -p perry-codegen: failing set IDENTICAL to the parent (6
loop_safepoint_purity, 16 native_proof_regressions, 3 native_proof_buffer_views,
1 shadow_slot_hygiene, 1 typed_shape_descriptors), measured on the parent commit
rather than assumed; one lib unit test red on the parent passes here. The
bind-anchored gate reports the same single non-moving residual PerryTS#7192 left.

WHAT THIS DOES NOT CLOSE: sfw-registry --help under a genuine POLLS=1 build is
still red -- 3/10 pass, 7/10 SIGSEGV -- so PerryTS#7161's stopgap STAYS. Its default arm
is clean 10/10, so nothing was traded away. Two concrete leads are written up in
the changelog fragment: `prev_this` in this same lowering is the same bug
unfixed (js_implicit_this_set returns a value read from the scanned, rewritten
IMPLICIT_THIS cell and holds it across the entire user call), and the remaining
205 fatal sinks are no longer dominated by one class, with the spread dispatch
(expr/call_spread.rs) the obvious next site.

Refs PerryTS#7154, PerryTS#7206, PerryTS#7192, PerryTS#7198, PerryTS#7184, PerryTS#7161, PerryTS#7114, PerryTS#6951, PerryTS#519.
proggeramlug pushed a commit that referenced this pull request Aug 1, 2026
…e_callN (#7214)

* fix(codegen): root the callee, `this` and every argument of js_closure_callN

The generic dynamic-value-call lowering held THREE classes of GC value in bare
SSA registers across work that can collect. `js_closure_callN` is the central
dispatch path -- `f(g())`, `o.m(g())`, `curry(1)(2)`, every call whose callee is
a value rather than a statically resolved function -- and this is the site

An SSA register is not a GC root. Under PERRY_GC_MOVING_LOOP_POLLS=1 a back-edge
poll inside an argument runs an evacuating minor: each held value SURVIVES (the
capture cell, shadow slot or module global it was read from is a root) and
therefore MOVES. The collector rewrites that location; the register keeps naming
from-space.

  * the CALLEE, held across the whole argument list. The checked unbox masks a
    pre-move address and js_closure_callN reads a closure header out of
    abandoned memory: "TypeError: value is not a function".
  * the `this` RECEIVER, held across the read of the callee off it AND the
    argument list. #7206 fixed this operand on the sibling
    js_native_call_method_by_id dispatch; this is the generic one.
  * each already-lowered ARGUMENT, held across the arguments after it AND
    across the rebind unbox.

The three live windows differ, so they are computed separately:

  receiver   | the callee read + every argument
  callee     | every argument
  argument i | the arguments after i + the rebind unbox

That last window is why this is not a copy of #7206's fix.
js_closure_unbox_callee_checked_rebind calls clone_closure_rebind_this, which
ALLOCATES a replacement closure (closure/dynamic_props.rs:1040) when the callee
captures `this`. It sits below the last argument and above js_closure_callN, so
the arguments are re-read below it -- hence RootedOperands::reread_one, which
re-reads one operand at a caller-chosen point instead of the whole group at one.
Hoisting the unbox above the argument list would remove the window instead, but
its throw is observable and the spec evaluates arguments before it. On the
>16-arity path the argument stores into the stack buffer moved below the unbox
for the same reason: a stack buffer is not a root, so filling it above an
allocating rebind freezes pre-move addresses one indirection further out.

Receiverless calls take js_closure_unbox_callee_checked, a tag check and a mask
that allocates nothing, so `f(x, y)` on inert operands emits exactly its old IR.
Temp roots, not re-lowering: re-lowering the callee or receiver would observe an
assignment made by an argument, a miscompile rather than a rooting fix.

Three gap tests, one per held value, each red on the parent under a GENUINE
POLLS=1 build and green after. The flag is compile-time since #7161 AND
runtime-armed (gc_moving_loop_polls_enabled(), gc/policy.rs:1759) -- setting
only one is a false green, and the first cut of these tests passed 10/10 for
exactly that reason.

  callee / this / argument, POLLS=1     parent: TypeError 10/10 each
                                        this:   bad 0    10/10 each
  all three, POLLS=1 + PERRY_GEN_GC=0   bad 0 5/5
  all three, default (no polls)         bad 0 5/5

Cost over the 141-module sfw-registry corpus, measured rather than assumed
because this is the hottest emitted call path. operand_protection emits nothing
for an operand whose window cannot collect, which is why the delta is small:
linked binary 39,216,688 -> 39,233,200 B (+0.042%), emitted IR 1,999,570 ->
2,001,607 lines (+0.10%), js_gc_temp_root_push sites 8,394 -> 8,885.

scripts/gc_root_dominance_check.py gains js_closure_unbox_callee_checked in
NONCOLLECTING, citing closure/unbox.rs:25 -- a tag check and a low-48 mask, no
allocation, no user code, no poll. It sits between every dynamic call's last
argument and its js_closure_callN, so its absence reported the whole argument
list of every 1-arg dynamic call as stale: 372 of the 729 fatal-sink hits were
that single false positive, all marked MOVING: no. The _rebind variant is
deliberately NOT added -- it allocates, and the fix above depends on it counting
as a collection point. Fatal-sink slice against the corrected list: 231 -> 205.

cargo test -p perry-codegen: failing set IDENTICAL to the parent (6
loop_safepoint_purity, 16 native_proof_regressions, 3 native_proof_buffer_views,
1 shadow_slot_hygiene, 1 typed_shape_descriptors), measured on the parent commit
rather than assumed; one lib unit test red on the parent passes here. The
bind-anchored gate reports the same single non-moving residual #7192 left.

WHAT THIS DOES NOT CLOSE: sfw-registry --help under a genuine POLLS=1 build is
still red -- 3/10 pass, 7/10 SIGSEGV -- so #7161's stopgap STAYS. Its default arm
is clean 10/10, so nothing was traded away. Two concrete leads are written up in
the changelog fragment: `prev_this` in this same lowering is the same bug
unfixed (js_implicit_this_set returns a value read from the scanned, rewritten
IMPLICIT_THIS cell and holds it across the entire user call), and the remaining
205 fatal sinks are no longer dominated by one class, with the spread dispatch
(expr/call_spread.rs) the obvious next site.

Refs #7154, #7206, #7192, #7198, #7184, #7161, #7114, #6951, #519.

* chore(7214): merge-time fixes — fragment name, rustfmt, corpus registration

- changelog fragment was PR-misnumbered `7207-`; #7207 is a different, already
  merged change. Renamed to `7214-`. Content already referenced #7206
  correctly and is unchanged.
- `cargo fmt --all -- --check` is a required check on `lint`; one hand-wrapped
  `roots.push` call needed re-wrapping. No behaviour change.
- Registered the three witnesses in the GC x repsel corpus, next to #7206's
  pair. All three are moving-only: clean on the shipped default on both sides
  of the fix, so they belong with the `requires=move` rows and prove nothing
  on `default`.

---------

Co-authored-by: Ralph Küpper <ralph@skelpo.com>
proggeramlug pushed a commit that referenced this pull request Aug 1, 2026
…ly, and document the invariant (#7212)

* fix(codegen): root the callee, `this` and every argument of js_closure_callN

The generic dynamic-value-call lowering held THREE classes of GC value in bare
SSA registers across work that can collect. `js_closure_callN` is the central
dispatch path -- `f(g())`, `o.m(g())`, `curry(1)(2)`, every call whose callee is
a value rather than a statically resolved function -- and this is the site

An SSA register is not a GC root. Under PERRY_GC_MOVING_LOOP_POLLS=1 a back-edge
poll inside an argument runs an evacuating minor: each held value SURVIVES (the
capture cell, shadow slot or module global it was read from is a root) and
therefore MOVES. The collector rewrites that location; the register keeps naming
from-space.

  * the CALLEE, held across the whole argument list. The checked unbox masks a
    pre-move address and js_closure_callN reads a closure header out of
    abandoned memory: "TypeError: value is not a function".
  * the `this` RECEIVER, held across the read of the callee off it AND the
    argument list. #7206 fixed this operand on the sibling
    js_native_call_method_by_id dispatch; this is the generic one.
  * each already-lowered ARGUMENT, held across the arguments after it AND
    across the rebind unbox.

The three live windows differ, so they are computed separately:

  receiver   | the callee read + every argument
  callee     | every argument
  argument i | the arguments after i + the rebind unbox

That last window is why this is not a copy of #7206's fix.
js_closure_unbox_callee_checked_rebind calls clone_closure_rebind_this, which
ALLOCATES a replacement closure (closure/dynamic_props.rs:1040) when the callee
captures `this`. It sits below the last argument and above js_closure_callN, so
the arguments are re-read below it -- hence RootedOperands::reread_one, which
re-reads one operand at a caller-chosen point instead of the whole group at one.
Hoisting the unbox above the argument list would remove the window instead, but
its throw is observable and the spec evaluates arguments before it. On the
>16-arity path the argument stores into the stack buffer moved below the unbox
for the same reason: a stack buffer is not a root, so filling it above an
allocating rebind freezes pre-move addresses one indirection further out.

Receiverless calls take js_closure_unbox_callee_checked, a tag check and a mask
that allocates nothing, so `f(x, y)` on inert operands emits exactly its old IR.
Temp roots, not re-lowering: re-lowering the callee or receiver would observe an
assignment made by an argument, a miscompile rather than a rooting fix.

Three gap tests, one per held value, each red on the parent under a GENUINE
POLLS=1 build and green after. The flag is compile-time since #7161 AND
runtime-armed (gc_moving_loop_polls_enabled(), gc/policy.rs:1759) -- setting
only one is a false green, and the first cut of these tests passed 10/10 for
exactly that reason.

  callee / this / argument, POLLS=1     parent: TypeError 10/10 each
                                        this:   bad 0    10/10 each
  all three, POLLS=1 + PERRY_GEN_GC=0   bad 0 5/5
  all three, default (no polls)         bad 0 5/5

Cost over the 141-module sfw-registry corpus, measured rather than assumed
because this is the hottest emitted call path. operand_protection emits nothing
for an operand whose window cannot collect, which is why the delta is small:
linked binary 39,216,688 -> 39,233,200 B (+0.042%), emitted IR 1,999,570 ->
2,001,607 lines (+0.10%), js_gc_temp_root_push sites 8,394 -> 8,885.

scripts/gc_root_dominance_check.py gains js_closure_unbox_callee_checked in
NONCOLLECTING, citing closure/unbox.rs:25 -- a tag check and a low-48 mask, no
allocation, no user code, no poll. It sits between every dynamic call's last
argument and its js_closure_callN, so its absence reported the whole argument
list of every 1-arg dynamic call as stale: 372 of the 729 fatal-sink hits were
that single false positive, all marked MOVING: no. The _rebind variant is
deliberately NOT added -- it allocates, and the fix above depends on it counting
as a collection point. Fatal-sink slice against the corrected list: 231 -> 205.

cargo test -p perry-codegen: failing set IDENTICAL to the parent (6
loop_safepoint_purity, 16 native_proof_regressions, 3 native_proof_buffer_views,
1 shadow_slot_hygiene, 1 typed_shape_descriptors), measured on the parent commit
rather than assumed; one lib unit test red on the parent passes here. The
bind-anchored gate reports the same single non-moving residual #7192 left.

WHAT THIS DOES NOT CLOSE: sfw-registry --help under a genuine POLLS=1 build is
still red -- 3/10 pass, 7/10 SIGSEGV -- so #7161's stopgap STAYS. Its default arm
is clean 10/10, so nothing was traded away. Two concrete leads are written up in
the changelog fragment: `prev_this` in this same lowering is the same bug
unfixed (js_implicit_this_set returns a value read from the scanned, rewritten
IMPLICIT_THIS cell and holds it across the entire user call), and the remaining
205 fatal sinks are no longer dominated by one class, with the spread dispatch
(expr/call_spread.rs) the obvious next site.

Refs #7154, #7206, #7192, #7198, #7184, #7161, #7114, #6951, #519.

* chore(7214): merge-time fixes — fragment name, rustfmt, corpus registration

- changelog fragment was PR-misnumbered `7207-`; #7207 is a different, already
  merged change. Renamed to `7214-`. Content already referenced #7206
  correctly and is unchanged.
- `cargo fmt --all -- --check` is a required check on `lint`; one hand-wrapped
  `roots.push` call needed re-wrapping. No behaviour change.
- Registered the three witnesses in the GC x repsel corpus, next to #7206's
  pair. All three are moving-only: clean on the shipped default on both sides
  of the fix, so they belong with the `requires=move` rows and prove nothing
  on `default`.

* ci(gc): make the root-dominance gate able to fail, baseline it honestly, and document the invariant

Squashed. See PR description for the seeded-violation proof and the allowlist rationale.

* docs(gc): correct the post-#7207 staleness in the rooting-invariant writeup

Merge-time corrections to statements #7207 invalidated while this PR was in
review:

- CLAUDE.md called `lower_call/new.rs`'s inline-ctor `this_slot` "still
  open". #7207 closed it. Point at `--unrooted-allocas` as the detector for
  that shape and name #7210 as where its remaining hits are tracked.
- The rooting-invariant doc documented `--stale-registers` but not
  `--unrooted-allocas`, so the one mode the bind-anchored check is
  structurally blind to had no entry. Add it, and state plainly that the gate
  does NOT run it and that its hits are deliberately outside the allowlist —
  the allowlist covers the bind-anchored shape only.
- "all five known shapes" -> "every known shape", so the pointer cannot go
  stale the next time one is found.

---------

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

* ci(gc): make the moving-GC stale-root witnesses able to fail

`test-files/test_gap_gc_*.ts` are reproducers, not parity tests. Each was
written for a specific stale-root defect -- #6981, #7114, #7154, #7192,
#7200/#7201/#7202, #7206, #7208/#7209, #7214, #7216 -- and every one of their
headers says the same thing: the bug is only expressible under a moving
collector, which since #7161 means `PERRY_GC_MOVING_LOOP_POLLS=1` at COMPILE
and RUN time.

No CI job ran them that way. `gc-root-dominance` compiles with the flag but is
a static pass and never runs a program; `gc_instrument_smoke.sh` runs with it
but against its own synthetic fixture. The witnesses reach CI only through
`gc-stress`, whose PR arm subset contains no arm that compiles with the flag --
so on every pull request they were compiled into IR in which their bug is not
expressible, then run to completion, green, proving nothing. CLAUDE.md's
hazard 4: the job runs, its subject does not.

The arm is one line of existing machinery:

    scripts/gc_repsel_matrix.sh --arms loop_polls --filter test_gap_gc_

plus a checker that rejects UNVER as hard as FAIL. That second half is the
point: the matrix reports a cell that matched the oracle under an inert arm as
UNVER, but its own exit status counts only FAIL, so an all-UNVER table exits 0
-- a green gate over a subject that never ran. On a requires=move arm UNVER
means nothing relocated, and a stale-root witness that never sees a relocation
cannot fail however broken its rooting is.

RED-THEN-GREEN, on this branch, release build, idle host, node 26.5.1:

  main                                    15/15 PASS, copy-minor 15/15, exit 0
  #7214's two production codegen files
  reverted (tests untouched)              12 PASS / 3 FAIL, checker exit 1
  restored                                15/15 PASS, copy-minor 15/15, exit 0

The three reds are exactly #7214's three witnesses -- `TypeError: value is not
a function`, exit 1, with the copying minor demonstrably live (scavenged=408,
3931, 3934). Nothing else moved.

SCOPE. `--filter test_gap_gc_` excludes the representation corpus and with it
#7194. `--arms loop_polls` is the safepoint route only, so #7217's
allocation-point defect is out of scope rather than papered over. Both
exclusions fall out of the arm and the filter; there is no allowlist to rot.

TWO DARK WITNESSES, FOUND BY THE CHECKER'S REGISTRATION RULE. The matrix
auto-detects unregistered `test_gap_repsel_*`/`test_gap_specabi_*` files but not
this prefix, and `test_gap_gc_new_instance_rooting` (#7192) and
`test_gap_gc_assign_string_source_rooting` (#7216) were never registered -- they
ran nowhere at all. Both are registered here and both pass on `loop_polls`.
Registering the second surfaced a pre-existing red on every allocation-point
arm, which is #7217's mechanism on a second file; triaged with measurements,
ten entries that die together when #7217 is fixed.

NOT REQUIRED YET, deliberately: a new gate has never been green, so promoting
it immediately blocks every open PR. Promotion after its first green run on
`main` is the follow-up -- and per CLAUDE.md's corollary, leaving that step
undone is itself hazard 2.

Also fixes #7205 in gc-ratchet.yml and gc-root-dominance.yml: keying push runs
on the commit. `cancel-in-progress: false` does not protect a queued `main` run,
because GitHub allows at most one PENDING run per group and cancels the previous
one when a new run enters. Three consecutive gc-ratchet main runs ended
`cancelled` with `jobs: []`.

* docs(changelog): fragment for #7228

* test(gc): register #7226's two witnesses, which landed unregistered

#7226 added test_gap_gc_typeof_string_cache_rooting.ts and
test_gap_gc_closure_call_prev_this_rooting.ts without corpus entries, so they
ran nowhere -- the same omission this PR's registration check exists to catch,
landing while the PR was open. With these, all 17 test_gap_gc_*.ts on main are
registered and the check is satisfiable.

Both PASS on --arms loop_polls with the copying minor live. They are also the
class that most needs this arm: an unrooted CACHE goes bad at collection #0 and
is invisible to the static IR pass, so a workload run under
PERRY_GC_MOVING_LOOP_POLLS=1 is the only instrument that sees it.

---------

Co-authored-by: Ralph Küpper <ralph@skelpo.com>
proggeramlug pushed a commit that referenced this pull request Aug 2, 2026
…with honest attribution

Statepoints now beat shadow on every probe on the Pi 5, geo -1.74%.
Correctness 8/8 forced-evac and 8/8 verify-walker (prologue-decoded SP
agrees with the DWARF unwinder on aarch64-Linux).

Attribution is NOT the walker: a same-binary fast-vs-unwind A/B on the two
worst probes is a dead heat, so the DWARF parsing perf measured at ~22% is
no longer hot. The other variable is the rebase onto main's GC work
(#7192/#7196/#7148); shadow itself improved 469->429ms, which fits that
explanation and not 'the walker fixed it'.

Also records an instrument failure: a cycle-count grep reported 0 cycles
for both arms after main changed the diag format; raw output shows 81.9 MB
freed. A count that cannot fail is not evidence.
jdalton added a commit to jdalton/perry that referenced this pull request Aug 2, 2026
Four PRs in a row shipped a test file that ran nowhere. PerryTS#7192 and PerryTS#7216 each
added a `test_gap_gc_*` stale-root witness and no corpus line; PerryTS#7252 added a
third; PerryTS#7270/PerryTS#7271 added two more, caught by the maintainer at merge. Four
occurrences of one mistake is a missing gate, not carelessness.

An unregistered file is not a failing test, it is no test at all. The PR is
green, the reviewer sees a witness in the diff next to a passing run and reads
the two together as "covered", and nothing says otherwise because nothing ran.
This is CLAUDE.md's fourth hazard in its purest form: the gate runs, its subject
never does.

Registration checks already existed for two of the three prefixes
(`gc_repsel_matrix.sh` auto-detects `test_gap_repsel_*`/`test_gap_specabi_*`,
`gc-moving-witnesses.yml` adds `test_gap_gc_*`) and neither could catch the pull
request that needed it: both sit behind a 90-minute release build of the
compiler, behind a changed-paths relevance filter, and in workflows that are not
in branch protection's required contexts.

`scripts/check_test_registration.py` is the cheap half of those checks, pulled
out to where it can block, and generalised past that one corpus. Pure
filesystem and text — no compiler, no Node, ~0.2s — over four mechanisms:

  gc-repsel-corpus           test-files/test_gap_{gc,repsel,specabi}_*.ts
                             -> test-parity/gc_repsel_corpus.txt
  feature-matrix-probes      test-features/probes/**/*.ts
                             -> test-features/feature_matrix.toml
  compiler-output-workloads  benchmarks/compiler_output/fixtures/**/*.ts
                             -> benchmarks/compiler_output/workloads.toml
  rust-test-modules          crates/*/**/tests/**/*.rs below a suite root
                             -> the `mod` declaration in the parent module

The last is the Rust analogue and is worth naming: cargo auto-discovers
`crates/<c>/tests/<suite>.rs`, but a file one level deeper only compiles if a
`mod` names it. Without one rustc never parses it — not dead code, not code, no
warning.

Built to be able to fail, against all four hazards:

  1. no `continue-on-error`, no `|| true`; the step's exit status is the gate.
  2. it is a step in `lint`, which is ALREADY a required context. That
     placement is the point: forgetting to add a new job to branch protection
     is hazard 2, and it is what left `gc-root-dominance` red and blocking
     nothing for days. No admin action is needed here because the step that
     gets forgotten does not exist.
  3. `lint`'s concurrency already cancels pull-request runs only.
  4. the subject is asserted live. Each mechanism floors its candidate set and
     FAILS if the glob stops matching, so "0 dark over 0 candidates" cannot
     print the same verdict as "0 dark over 157". Every run states
     `checked N files against M registries`.

Exclusions are named with reasons rather than counted, because a threshold
cannot tell a new dark file from an old one — fix one, add one, tally
unchanged. A stale exclusion, one matching no file on disk, is itself a
failure, so an excuse cannot outlive the file it excuses. Same for the mirror
image: a registry entry whose file is gone fails as a rotted entry.

`--self-test` (32 cases, also run in `lint`) plants an unregistered file into
each of the four mechanisms over the REAL registries via an in-memory overlay,
asserts the gate names it, then removes it and asserts green. It also pins the
one false positive found while writing this: `resolve/tests/
declaration_sidecar_tests/compile_package.rs` IS declared, by a `mod` inside an
inline `mod … { }` block two levels up, and the first draft condemned it. A
gate that cries wolf gets deleted.

Verified end to end on disk, not just through the overlay: planted a real
unregistered file in each of the four mechanisms, watched the gate go red and
name it; registered one and watched it go green; deleted the file leaving the
line and watched the rotted-entry arm go red; restored and watched it go green.

Today's dark set is empty for all four, so this is green on `main` from the
first run and safe in a required context. Three feature probes and two
compiler-output fixtures are excluded, each with its reason: four are helper
modules imported by a registered test, and
`benchmarks/compiler_output/fixtures/raw_numeric_layout_smoke.ts` is registered
in a different registry (the `raw_numeric_layouts` target-collector workload in
`scripts/run_memory_stability_tests.sh`).

Deliberately out of scope: `tests/*.sh|py|ts`, where 143 of 171 files are
referenced by nothing in the tree. There is no registry there to diff against,
so "unregistered" is not even well defined; that is an archaeology problem
(triage each, wire it up or delete it) and inventing a registry for it
retroactively would make this gate red on day one for reasons unrelated to the
four dark witnesses. `--list` says so out loud rather than leaving the silence.

Docs where an author will actually meet the rule: a new
`docs/src/testing/test-registration.md`, a bullet in CONTRIBUTING.md's "what
goes in a PR", and a rewritten header on each of the three registry files.

Refs PerryTS#7192, PerryTS#7216, PerryTS#7252, PerryTS#7270, PerryTS#7271.
jdalton added a commit to jdalton/perry that referenced this pull request Aug 2, 2026
Four PRs in a row shipped a test file that ran nowhere. PerryTS#7192 and PerryTS#7216 each
added a `test_gap_gc_*` stale-root witness and no corpus line; PerryTS#7252 added a
third; PerryTS#7270/PerryTS#7271 added two more, caught by the maintainer at merge. Four
occurrences of one mistake is a missing gate, not carelessness.

An unregistered file is not a failing test, it is no test at all. The PR is
green, the reviewer sees a witness in the diff next to a passing run and reads
the two together as "covered", and nothing says otherwise because nothing ran.
This is CLAUDE.md's fourth hazard in its purest form: the gate runs, its subject
never does.

Registration checks already existed for two of the three prefixes
(`gc_repsel_matrix.sh` auto-detects `test_gap_repsel_*`/`test_gap_specabi_*`,
`gc-moving-witnesses.yml` adds `test_gap_gc_*`) and neither could catch the pull
request that needed it: both sit behind a 90-minute release build of the
compiler, behind a changed-paths relevance filter, and in workflows that are not
in branch protection's required contexts.

`scripts/check_test_registration.py` is the cheap half of those checks, pulled
out to where it can block, and generalised past that one corpus. Pure
filesystem and text — no compiler, no Node, ~0.2s — over four mechanisms:

  gc-repsel-corpus           test-files/test_gap_{gc,repsel,specabi}_*.ts
                             -> test-parity/gc_repsel_corpus.txt
  feature-matrix-probes      test-features/probes/**/*.ts
                             -> test-features/feature_matrix.toml
  compiler-output-workloads  benchmarks/compiler_output/fixtures/**/*.ts
                             -> benchmarks/compiler_output/workloads.toml
  rust-test-modules          crates/*/**/tests/**/*.rs below a suite root
                             -> the `mod` declaration in the parent module

The last is the Rust analogue and is worth naming: cargo auto-discovers
`crates/<c>/tests/<suite>.rs`, but a file one level deeper only compiles if a
`mod` names it. Without one rustc never parses it — not dead code, not code, no
warning.

Built to be able to fail, against all four hazards:

  1. no `continue-on-error`, no `|| true`; the step's exit status is the gate.
  2. it is a step in `lint`, which is ALREADY a required context. That
     placement is the point: forgetting to add a new job to branch protection
     is hazard 2, and it is what left `gc-root-dominance` red and blocking
     nothing for days. No admin action is needed here because the step that
     gets forgotten does not exist.
  3. `lint`'s concurrency already cancels pull-request runs only.
  4. the subject is asserted live. Each mechanism floors its candidate set and
     FAILS if the glob stops matching, so "0 dark over 0 candidates" cannot
     print the same verdict as "0 dark over 157". Every run states
     `checked N files against M registries`.

Exclusions are named with reasons rather than counted, because a threshold
cannot tell a new dark file from an old one — fix one, add one, tally
unchanged. A stale exclusion, one matching no file on disk, is itself a
failure, so an excuse cannot outlive the file it excuses. Same for the mirror
image: a registry entry whose file is gone fails as a rotted entry.

`--self-test` (32 cases, also run in `lint`) plants an unregistered file into
each of the four mechanisms over the REAL registries via an in-memory overlay,
asserts the gate names it, then removes it and asserts green. It also pins the
one false positive found while writing this: `resolve/tests/
declaration_sidecar_tests/compile_package.rs` IS declared, by a `mod` inside an
inline `mod … { }` block two levels up, and the first draft condemned it. A
gate that cries wolf gets deleted.

Verified end to end on disk, not just through the overlay: planted a real
unregistered file in each of the four mechanisms, watched the gate go red and
name it; registered one and watched it go green; deleted the file leaving the
line and watched the rotted-entry arm go red; restored and watched it go green.

Today's dark set is empty for all four, so this is green on `main` from the
first run and safe in a required context. Three feature probes and two
compiler-output fixtures are excluded, each with its reason: four are helper
modules imported by a registered test, and
`benchmarks/compiler_output/fixtures/raw_numeric_layout_smoke.ts` is registered
in a different registry (the `raw_numeric_layouts` target-collector workload in
`scripts/run_memory_stability_tests.sh`).

Deliberately out of scope: `tests/*.sh|py|ts`, where 143 of 171 files are
referenced by nothing in the tree. There is no registry there to diff against,
so "unregistered" is not even well defined; that is an archaeology problem
(triage each, wire it up or delete it) and inventing a registry for it
retroactively would make this gate red on day one for reasons unrelated to the
four dark witnesses. `--list` says so out loud rather than leaving the silence.

Docs where an author will actually meet the rule: a new
`docs/src/testing/test-registration.md`, a bullet in CONTRIBUTING.md's "what
goes in a PR", and a rewritten header on each of the three registry files.

Refs PerryTS#7192, PerryTS#7216, PerryTS#7252, PerryTS#7270, PerryTS#7271.
jdalton added a commit to jdalton/perry that referenced this pull request Aug 2, 2026
Four PRs in a row shipped a test file that ran nowhere. PerryTS#7192 and PerryTS#7216 each
added a `test_gap_gc_*` stale-root witness and no corpus line; PerryTS#7252 added a
third; PerryTS#7270/PerryTS#7271 added two more, caught by the maintainer at merge. Four
occurrences of one mistake is a missing gate, not carelessness.

An unregistered file is not a failing test, it is no test at all. The PR is
green, the reviewer sees a witness in the diff next to a passing run and reads
the two together as "covered", and nothing says otherwise because nothing ran.
This is CLAUDE.md's fourth hazard in its purest form: the gate runs, its subject
never does.

Registration checks already existed for two of the three prefixes
(`gc_repsel_matrix.sh` auto-detects `test_gap_repsel_*`/`test_gap_specabi_*`,
`gc-moving-witnesses.yml` adds `test_gap_gc_*`) and neither could catch the pull
request that needed it: both sit behind a 90-minute release build of the
compiler, behind a changed-paths relevance filter, and in workflows that are not
in branch protection's required contexts.

`scripts/check_test_registration.py` is the cheap half of those checks, pulled
out to where it can block, and generalised past that one corpus. Pure
filesystem and text — no compiler, no Node, ~0.2s — over four mechanisms:

  gc-repsel-corpus           test-files/test_gap_{gc,repsel,specabi}_*.ts
                             -> test-parity/gc_repsel_corpus.txt
  feature-matrix-probes      test-features/probes/**/*.ts
                             -> test-features/feature_matrix.toml
  compiler-output-workloads  benchmarks/compiler_output/fixtures/**/*.ts
                             -> benchmarks/compiler_output/workloads.toml
  rust-test-modules          crates/*/**/tests/**/*.rs below a suite root
                             -> the `mod` declaration in the parent module

The last is the Rust analogue and is worth naming: cargo auto-discovers
`crates/<c>/tests/<suite>.rs`, but a file one level deeper only compiles if a
`mod` names it. Without one rustc never parses it — not dead code, not code, no
warning.

Built to be able to fail, against all four hazards:

  1. no `continue-on-error`, no `|| true`; the step's exit status is the gate.
  2. it is a step in `lint`, which is ALREADY a required context. That
     placement is the point: forgetting to add a new job to branch protection
     is hazard 2, and it is what left `gc-root-dominance` red and blocking
     nothing for days. No admin action is needed here because the step that
     gets forgotten does not exist.
  3. `lint`'s concurrency already cancels pull-request runs only.
  4. the subject is asserted live. Each mechanism floors its candidate set and
     FAILS if the glob stops matching, so "0 dark over 0 candidates" cannot
     print the same verdict as "0 dark over 157". Every run states
     `checked N files against M registries`.

Exclusions are named with reasons rather than counted, because a threshold
cannot tell a new dark file from an old one — fix one, add one, tally
unchanged. A stale exclusion, one matching no file on disk, is itself a
failure, so an excuse cannot outlive the file it excuses. Same for the mirror
image: a registry entry whose file is gone fails as a rotted entry.

`--self-test` (32 cases, also run in `lint`) plants an unregistered file into
each of the four mechanisms over the REAL registries via an in-memory overlay,
asserts the gate names it, then removes it and asserts green. It also pins the
one false positive found while writing this: `resolve/tests/
declaration_sidecar_tests/compile_package.rs` IS declared, by a `mod` inside an
inline `mod … { }` block two levels up, and the first draft condemned it. A
gate that cries wolf gets deleted.

Verified end to end on disk, not just through the overlay: planted a real
unregistered file in each of the four mechanisms, watched the gate go red and
name it; registered one and watched it go green; deleted the file leaving the
line and watched the rotted-entry arm go red; restored and watched it go green.

Today's dark set is empty for all four, so this is green on `main` from the
first run and safe in a required context. Three feature probes and two
compiler-output fixtures are excluded, each with its reason: four are helper
modules imported by a registered test, and
`benchmarks/compiler_output/fixtures/raw_numeric_layout_smoke.ts` is registered
in a different registry (the `raw_numeric_layouts` target-collector workload in
`scripts/run_memory_stability_tests.sh`).

Deliberately out of scope: `tests/*.sh|py|ts`, where 143 of 171 files are
referenced by nothing in the tree. There is no registry there to diff against,
so "unregistered" is not even well defined; that is an archaeology problem
(triage each, wire it up or delete it) and inventing a registry for it
retroactively would make this gate red on day one for reasons unrelated to the
four dark witnesses. `--list` says so out loud rather than leaving the silence.

Docs where an author will actually meet the rule: a new
`docs/src/testing/test-registration.md`, a bullet in CONTRIBUTING.md's "what
goes in a PR", and a rewritten header on each of the three registry files.

Refs PerryTS#7192, PerryTS#7216, PerryTS#7252, PerryTS#7270, PerryTS#7271.
jdalton added a commit to jdalton/perry that referenced this pull request Aug 3, 2026
Four PRs in a row shipped a test file that ran nowhere. PerryTS#7192 and PerryTS#7216 each
added a `test_gap_gc_*` stale-root witness and no corpus line; PerryTS#7252 added a
third; PerryTS#7270/PerryTS#7271 added two more, caught by the maintainer at merge. Four
occurrences of one mistake is a missing gate, not carelessness.

An unregistered file is not a failing test, it is no test at all. The PR is
green, the reviewer sees a witness in the diff next to a passing run and reads
the two together as "covered", and nothing says otherwise because nothing ran.
This is CLAUDE.md's fourth hazard in its purest form: the gate runs, its subject
never does.

Registration checks already existed for two of the three prefixes
(`gc_repsel_matrix.sh` auto-detects `test_gap_repsel_*`/`test_gap_specabi_*`,
`gc-moving-witnesses.yml` adds `test_gap_gc_*`) and neither could catch the pull
request that needed it: both sit behind a 90-minute release build of the
compiler, behind a changed-paths relevance filter, and in workflows that are not
in branch protection's required contexts.

`scripts/check_test_registration.py` is the cheap half of those checks, pulled
out to where it can block, and generalised past that one corpus. Pure
filesystem and text — no compiler, no Node, ~0.2s — over four mechanisms:

  gc-repsel-corpus           test-files/test_gap_{gc,repsel,specabi}_*.ts
                             -> test-parity/gc_repsel_corpus.txt
  feature-matrix-probes      test-features/probes/**/*.ts
                             -> test-features/feature_matrix.toml
  compiler-output-workloads  benchmarks/compiler_output/fixtures/**/*.ts
                             -> benchmarks/compiler_output/workloads.toml
  rust-test-modules          crates/*/**/tests/**/*.rs below a suite root
                             -> the `mod` declaration in the parent module

The last is the Rust analogue and is worth naming: cargo auto-discovers
`crates/<c>/tests/<suite>.rs`, but a file one level deeper only compiles if a
`mod` names it. Without one rustc never parses it — not dead code, not code, no
warning.

Built to be able to fail, against all four hazards:

  1. no `continue-on-error`, no `|| true`; the step's exit status is the gate.
  2. it is a step in `lint`, which is ALREADY a required context. That
     placement is the point: forgetting to add a new job to branch protection
     is hazard 2, and it is what left `gc-root-dominance` red and blocking
     nothing for days. No admin action is needed here because the step that
     gets forgotten does not exist.
  3. `lint`'s concurrency already cancels pull-request runs only.
  4. the subject is asserted live. Each mechanism floors its candidate set and
     FAILS if the glob stops matching, so "0 dark over 0 candidates" cannot
     print the same verdict as "0 dark over 157". Every run states
     `checked N files against M registries`.

Exclusions are named with reasons rather than counted, because a threshold
cannot tell a new dark file from an old one — fix one, add one, tally
unchanged. A stale exclusion, one matching no file on disk, is itself a
failure, so an excuse cannot outlive the file it excuses. Same for the mirror
image: a registry entry whose file is gone fails as a rotted entry.

`--self-test` (32 cases, also run in `lint`) plants an unregistered file into
each of the four mechanisms over the REAL registries via an in-memory overlay,
asserts the gate names it, then removes it and asserts green. It also pins the
one false positive found while writing this: `resolve/tests/
declaration_sidecar_tests/compile_package.rs` IS declared, by a `mod` inside an
inline `mod … { }` block two levels up, and the first draft condemned it. A
gate that cries wolf gets deleted.

Verified end to end on disk, not just through the overlay: planted a real
unregistered file in each of the four mechanisms, watched the gate go red and
name it; registered one and watched it go green; deleted the file leaving the
line and watched the rotted-entry arm go red; restored and watched it go green.

Today's dark set is empty for all four, so this is green on `main` from the
first run and safe in a required context. Three feature probes and two
compiler-output fixtures are excluded, each with its reason: four are helper
modules imported by a registered test, and
`benchmarks/compiler_output/fixtures/raw_numeric_layout_smoke.ts` is registered
in a different registry (the `raw_numeric_layouts` target-collector workload in
`scripts/run_memory_stability_tests.sh`).

Deliberately out of scope: `tests/*.sh|py|ts`, where 143 of 171 files are
referenced by nothing in the tree. There is no registry there to diff against,
so "unregistered" is not even well defined; that is an archaeology problem
(triage each, wire it up or delete it) and inventing a registry for it
retroactively would make this gate red on day one for reasons unrelated to the
four dark witnesses. `--list` says so out loud rather than leaving the silence.

Docs where an author will actually meet the rule: a new
`docs/src/testing/test-registration.md`, a bullet in CONTRIBUTING.md's "what
goes in a PR", and a rewritten header on each of the three registry files.

Refs PerryTS#7192, PerryTS#7216, PerryTS#7252, PerryTS#7270, PerryTS#7271.
proggeramlug pushed a commit that referenced this pull request Aug 3, 2026
Four PRs in a row shipped a test file that ran nowhere. #7192 and #7216 each
added a `test_gap_gc_*` stale-root witness and no corpus line; #7252 added a
third; #7270/#7271 added two more, caught by the maintainer at merge. Four
occurrences of one mistake is a missing gate, not carelessness.

An unregistered file is not a failing test, it is no test at all. The PR is
green, the reviewer sees a witness in the diff next to a passing run and reads
the two together as "covered", and nothing says otherwise because nothing ran.
This is CLAUDE.md's fourth hazard in its purest form: the gate runs, its subject
never does.

Registration checks already existed for two of the three prefixes
(`gc_repsel_matrix.sh` auto-detects `test_gap_repsel_*`/`test_gap_specabi_*`,
`gc-moving-witnesses.yml` adds `test_gap_gc_*`) and neither could catch the pull
request that needed it: both sit behind a 90-minute release build of the
compiler, behind a changed-paths relevance filter, and in workflows that are not
in branch protection's required contexts.

`scripts/check_test_registration.py` is the cheap half of those checks, pulled
out to where it can block, and generalised past that one corpus. Pure
filesystem and text — no compiler, no Node, ~0.2s — over four mechanisms:

  gc-repsel-corpus           test-files/test_gap_{gc,repsel,specabi}_*.ts
                             -> test-parity/gc_repsel_corpus.txt
  feature-matrix-probes      test-features/probes/**/*.ts
                             -> test-features/feature_matrix.toml
  compiler-output-workloads  benchmarks/compiler_output/fixtures/**/*.ts
                             -> benchmarks/compiler_output/workloads.toml
  rust-test-modules          crates/*/**/tests/**/*.rs below a suite root
                             -> the `mod` declaration in the parent module

The last is the Rust analogue and is worth naming: cargo auto-discovers
`crates/<c>/tests/<suite>.rs`, but a file one level deeper only compiles if a
`mod` names it. Without one rustc never parses it — not dead code, not code, no
warning.

Built to be able to fail, against all four hazards:

  1. no `continue-on-error`, no `|| true`; the step's exit status is the gate.
  2. it is a step in `lint`, which is ALREADY a required context. That
     placement is the point: forgetting to add a new job to branch protection
     is hazard 2, and it is what left `gc-root-dominance` red and blocking
     nothing for days. No admin action is needed here because the step that
     gets forgotten does not exist.
  3. `lint`'s concurrency already cancels pull-request runs only.
  4. the subject is asserted live. Each mechanism floors its candidate set and
     FAILS if the glob stops matching, so "0 dark over 0 candidates" cannot
     print the same verdict as "0 dark over 157". Every run states
     `checked N files against M registries`.

Exclusions are named with reasons rather than counted, because a threshold
cannot tell a new dark file from an old one — fix one, add one, tally
unchanged. A stale exclusion, one matching no file on disk, is itself a
failure, so an excuse cannot outlive the file it excuses. Same for the mirror
image: a registry entry whose file is gone fails as a rotted entry.

`--self-test` (32 cases, also run in `lint`) plants an unregistered file into
each of the four mechanisms over the REAL registries via an in-memory overlay,
asserts the gate names it, then removes it and asserts green. It also pins the
one false positive found while writing this: `resolve/tests/
declaration_sidecar_tests/compile_package.rs` IS declared, by a `mod` inside an
inline `mod … { }` block two levels up, and the first draft condemned it. A
gate that cries wolf gets deleted.

Verified end to end on disk, not just through the overlay: planted a real
unregistered file in each of the four mechanisms, watched the gate go red and
name it; registered one and watched it go green; deleted the file leaving the
line and watched the rotted-entry arm go red; restored and watched it go green.

Today's dark set is empty for all four, so this is green on `main` from the
first run and safe in a required context. Three feature probes and two
compiler-output fixtures are excluded, each with its reason: four are helper
modules imported by a registered test, and
`benchmarks/compiler_output/fixtures/raw_numeric_layout_smoke.ts` is registered
in a different registry (the `raw_numeric_layouts` target-collector workload in
`scripts/run_memory_stability_tests.sh`).

Deliberately out of scope: `tests/*.sh|py|ts`, where 143 of 171 files are
referenced by nothing in the tree. There is no registry there to diff against,
so "unregistered" is not even well defined; that is an archaeology problem
(triage each, wire it up or delete it) and inventing a registry for it
retroactively would make this gate red on day one for reasons unrelated to the
four dark witnesses. `--list` says so out loud rather than leaving the silence.

Docs where an author will actually meet the rule: a new
`docs/src/testing/test-registration.md`, a bullet in CONTRIBUTING.md's "what
goes in a PR", and a rewritten header on each of the three registry files.

Refs #7192, #7216, #7252, #7270, #7271.
proggeramlug added a commit that referenced this pull request Aug 3, 2026
)

* experiment(gc): prototype stack maps and statepoints

* research(gc): measure and reduce native safepoints

* research(gc): x29-chain fast walker for native stack-map roots

The deep-stack telemetry showed 36,458 frames unwound to visit 104 root
locations: _Unwind_Backtrace pays full compact-unwind register recovery on
every native frame. Replace it with a raw x29-chain walk when the maps
allow it:

- codegen emits "frame-pointer"="non-leaf" on generated functions in
  native-root modes, so the [x29, x30] chain is guaranteed through
  generated frames (textual-IR input gets no frame-pointer default from
  the clang driver);
- the parser now records each function's stack size; LLVM's AArch64 frame
  keeps the FP/LR pair at the top of the frame, so SP-relative statepoint
  spills resolve as fp + 16 - stack_size from the same two chain loads;
- chain_walkable is decided once at parse: any location that is not
  FP-relative or sized-SP-relative disables the fast path for the image;
- every anomaly (misaligned, non-increasing, or out-of-bounds frame
  pointer) abandons the walk and re-runs the platform unwinder; slot
  visits are idempotent so the fallback is safe;
- PERRY_STACKMAP_WALKER=unwind forces the old walker (bisection control);
  PERRY_STACKMAP_WALKER=verify runs both and panics unless they visit the
  identical slot set - the liveness gate for the fast walker, since
  forced-evacuation verification enumerates roots through the same walker
  and cannot see a frame the walker skipped;
- telemetry gains fp_walks/fallback_walks so a run can prove which walker
  actually executed.

Finding recorded for the mode decision: plain-map mode emits Register
R#1 locations (root slot address in a caller-saved register) that the
parser must drop - those roots are invisible to the collector by
construction, which statepoint spill slots cannot exhibit.

* docs: record x29-chain walker results and the plain-map Register-location finding

* research(gc): explicit-safepoint collection contract (PERRY_GC_SAFEPOINT_ONLY)

The contract: a collection that skips the conservative stack scan consumes
only precise roots, and with native stack maps active those exist only at
mapped PCs - so such a collection may only begin at a declared safepoint
(loop back-edge poll, outermost microtask-pump boundary); anywhere else it
must scan conservatively. Today that property is emergent - every possibly-
collecting call happens to be mapped. The contract makes it enforced, which
is what allows call sites to become unmapped.

Runtime:
- GC_AT_DECLARED_SAFEPOINT thread-local + RAII guard, set by the moving-
  minor safepoint drain (covers both the loop poll and the microtask
  boundary) and by the contract poll extension.
- Enforcement at the root-scan subphase: an undeclared precise-root cycle
  either has the conservative scan forced for that cycle (heal mode, =1 -
  sound: the scan restores liveness and a conservatively-scanned cycle is
  non-moving) or panics (=strict, the gate mode that proves enforcement is
  live). The alloc-point valve and manual gc() force the scan already and
  are exempt by construction.
- Under the contract, loop polls also drain non-nursery triggers via
  gc_check_trigger so full collections migrate to declared safepoints.

Codegen:
- New audited GcCallEffect::AllocNoReentry class: helpers that may allocate
  (and so arm a trigger) but never collect synchronously and never re-enter
  generated JS. Under the contract their call sites need no statepoint;
  without it they remain safepoints. First audited set: closure/object
  allocation, js_array_push_f64/length/slice_values.
- PERRY_GC_SAFEPOINT_ONLY participates in build and object cache keys.

Census note (batch.ts): the bulk of remaining statepoints are property-
access diamonds that can re-enter via getters and must stay mapped; the
contract's reach is bounded by re-entry, and deleting those calls is
representation selection's job (Ptr<Shape>), not the contract's. The two
compose: repsel removes the calls, the contract unmaps what allocation
traffic remains.

* docs: explicit-safepoint contract design, enforcement levels, and census bound

* research(gc): enforce the safepoint contract on the copying-minor path

The copying minor evaluates eligibility in copying.rs and never reaches the
cycle.rs root-scan subphase - so the first enforcement point missed exactly
the MOVING path the contract exists to police. Add the same check at
eligibility evaluation: outside a declared safepoint a copying minor either
falls back to the non-moving cycle (heal - whose scan the cycle.rs heal
then forces) or panics (strict).

* fix(gc): heal the safepoint contract through the shared scan override

The first enforcement healed by overriding a LOCAL decision variable in the
root-scan subphase. Copying-minor eligibility and evacuation pinning read
conservative_stack_scan_decision() globally, concluded there were no
conservative roots to pin, and PERRY_GC_FORCE_EVACUATE moved objects that
raw native-stack words still pointed at - probe 04 span forever in
corrupted mutator code (109 CPU-minutes, zero GC frames in 1,489 samples).

Consolidate to one chokepoint: contract_scan_heal_guard() at the
synchronous collection entries returns a cycle-long ManualGcScanGuard, so
every consumer of the scan decision sees the same healed answer. Strict
mode panics at the same chokepoint. Deletes both scattered enforcement
sites - net less code than the broken version.

* fix(gc): delete the per-poll trigger drain from the safepoint contract

Draining non-nursery triggers at every allocating loop back-edge turned
nursery-churn loops into per-iteration collection work - O(n^2), probe 01
burned 20 CPU-minutes on a 200ms workload (sample: dominant runtime frames
+ TLS + memmove = collection work per iteration, unlike the split-brain
hang's pure-mutator signature). The extension was an optimization, not a
soundness requirement: an undeclared full at an alloc point heals with one
conservative scan. Polls return to their single job - draining the pending
moving minor.

* docs: record contract gate results and the three bugs the gates caught

* docs: quiet-host matrix results from the reserved M1 mini

Deep-stack closed (walker-attributed via the unwind control arm), compile
+5.3% claim withdrawn, RSS flat, statepoints at-worst-tied on wall clock;
metadata remains the only losing axis. 10ms timer quantum caveat recorded.

* research(gc): delete the plain-map user mode; elide statepoints at noreturn sites

PERRY_STACK_MAPS is gone per the GC knob kill-policy: after the quiet-host
matrix it was a losing mode (statepoints match it within timer quantization)
and it is structurally unsound - LLVM's stackmap intrinsic can record a
root slot's address as Register R#N (caller-saved, unrecoverable at
collection time), leaving those roots invisible to the collector. The
plain-map lowering survives only as statepoint mode's internal fallback for
try/setjmp functions; shrinking that fallback set is tracked follow-up
work. The env leaves both cache-key sets with it.

New audited GcCallEffect::NeverReturns class: every js_throw* helper
funnels into exception::js_throw (-> !), so control never returns to the
call site, no relocation is ever consumed, and the frame's roots are dead
past the call - the site needs no metadata in any mode. Deeper frames carry
their own records; values the helper holds are its own frame's
responsibility, as for every helper call. batch.ts carries 19 such sites.

* docs: post-matrix follow-through - mode deletion, noreturn elision, metadata trajectory

* research(gc): compact per-function root metadata (PERRY_COMPACT_ROOTS)

The file-size lever that does not wait for repsel. One stackmap intrinsic
in the entry block records every root alloca as a stable Direct location;
calls carry only zero-instruction memory barriers. Precision drops from
per-safepoint to per-function - sound because root allocas are already
zero-initialized at entry, so visiting a stale slot can only over-retain,
never corrupt. Metadata falls from ~64 B/safepoint + 24 B/root-pair to
~40 B/function + 12 B/slot: on the #7108 real-app model, 4.5-16.6 MB
becomes ~120 KB - below the shadow stack's 439 KB of hot text.

- Every generated function is lowered (rootless ones get a zero-operand
  entry record) so region matching can never attribute a frame to a
  neighboring function; block-local root slots fall back to the
  statepoint backend per function; has_try needs no exclusion because
  there is no per-call rewriting to conflict with setjmp.
- A __perry_gen_end sentinel object is linked after every generated
  object; its magic-ID record is both the region's exclusive upper bound
  and the runtime's compact-mode signal.
- The runtime matches frames by region (greatest record PC at or below
  the return address, bounded by the sentinel) instead of the +-16-byte
  per-safepoint heuristic; both walkers share the new match_records.
- Fail-closed: the parser counts register-recorded locations, and a
  compact image refuses to run with any present - in compact mode the
  entry record is the only description of the frame, so a register root
  would be silently invisible.
- PERRY_COMPACT_ROOTS participates in build and object cache keys.

Known pre-existing failure, not from this change: the branch's
gc::tests::shadow_stack_ops::out_of_range_frame_pop_is_ignored aborts
(panic inside a nounwind path, shadow_stack.rs:531, last touched by
main's #7088) - fails identically without this diff.

* research(gc): delete the compact per-function mode - measured negative result

The per-function metadata thesis was built (bd066d6), measured
(424-680 B vs 5.3-8.9 KB per probe, 10-13x), and disproven: a ten-line
churn loop deterministically corrupts under moving minors. The forensic
chain - retention clears, callee-saved clobbers, dead-slot zeroing, and
finally disabling walker visits entirely, all bit-identical failures -
proves the corruption vector is not the metadata machinery at all: the
mutator reads from-space through stale heap-derived values in optimized
SSA, which only relocation semantics can restore (the same module carries
79 gc.relocate under the statepoint backend). Barriers constrain memory
ordering, not dataflow.

Design law recorded in the doc: with an optimizing compiler between
source and safepoint, root metadata without relocation semantics is
unsound - per-call plain maps merely made the window small enough for
probes to pass; per-function maps made it wide enough to fail in ten
lines. The compact 10-13x is only reachable via RS4GC-style managed
SSA or repsel shrinking the recorded set.

Kept from the detour (mode-independent): the match_records refactor in
the walker, the copy-minor diag line (trigger kind + declared-safepoint
flag), and GcTriggerKind's Debug derive.

* docs: real-app remeasurement - metadata 3.83MB (below model floor), text recovery 150KB not 439KB, shadow is the measured three-axis optimum today

* docs: shadow-frame elision census - 7.7% of framed functions, 4.0% of shadow traffic; measured-and-not-pursued

* research(gc): second AllocNoReentry audit round - four admitted, two excluded with transitive-reentry evidence

Admitted: js_ctor_return_override (inspects the returned value, calls
nothing), js_array_indexOf_jsvalue (strict equality never runs user
code), js_validate_array_comparator / js_validate_array_map_callback
(type check + static-message throw through the audited noreturn funnel).

Excluded with the reason recorded in table and test:
js_value_length_f64 reaches js_object_get_field_by_name_f64 for plain
objects - a transitive getter path the smell-scan missed and the body
audit caught - and js_array_get_f64 has hole/accessor paths.

* docs: second audit round measurements - batch 442->172 (-61%), real-app metadata 3.76MB

* research(gc): first RS4GC pipeline slice (PERRY_RS4GC, #7174) - 5/8 probes green

Root allocas (alloca double / alloca i64) retype to ptr addrspace(1) with
cast surgery at recognized load/store sites; unrecognized shapes bail the
function to the explicit statepoint backend (fail-closed - and the bail
path was exercised for real: the first run silently fell back on every
function because the recognizer only knew the unit-test alloca i64 idiom,
caught by record-count comparison, 200 vs 55). Functions tag
gc statepoint-example; audited non-collecting callees carry
gc-leaf-function at call sites; compile_ll_to_object pipes modules
through opt -passes='default<O2>,rewrite-statepoints-for-gc' when
PERRY_RS4GC=1, failing loudly without an opt binary. Requires a
version-matched toolchain (PERRY_LLVM_CLANG=Homebrew clang 22: Apple
clang 21 cannot parse LLVM 22 attribute output). Cache keys wired.

Status, honestly: with the surgery genuinely engaged, 5/8 gc-ratchet
probes pass under forced evacuation + verification; 01/06/08 fail and are
the first concrete reproducers of the double-typed dataflow frontier
(NaN-box values crossing statepoints as double/i64 derivatives RS4GC does
not track). Metadata is not yet competitive (probe 01: 6,992 B vs the
explicit bridge's 5,320 B). Both are the #7174 work, now with failing
tests instead of projections.

* research(gc): RS4GC slice fully gated - 16/16 with mem2reg-only placement

O2-before-RS4GC fails 3/8 (GVN merges per-site cast chains across future
statepoint sites - the stale-double hazard recreated inside opt);
mem2reg-only is the sound pre-pass, clang optimizes safely after
statepoint insertion. The design law stated positively: relocation
semantics must exist before the optimizer may move heap-derived values.

* docs: RS4GC real-app measurement - text 248KB below shadow, metadata within 3.1% of the audited bridge, smallest native arm

* docs: RS4GC runtime and RSS cells - fastest arm measured, RSS flat; characterization table complete

* docs: measure the repsel-erasure projection - slope is ZERO for landed promotion classes

repsel-on vs knobs-off on batch.ts under statepoints: byte-identical
metadata (24,752 B / 198 statepoints / 33 slots). Landed promotions
remove calls, not roots - they prove values the rooter already knew were
non-pointers. Metadata erasure is paid only by maybe-pointer-population
promotions (untyped/temporaries/dep JS), where coverage is weakest.
Corrects the shared assumption in both campaigns' plans.

* research(gc): ELF/Linux stack-map scanner port (#7173) - compile-verified, runtime gates pending

Section discovery reads /proc/self/exe's section headers for
.llvm_stackmaps (sh_addr/sh_size) plus the main object's load bias from
the first dl_iterate_phdr callback - no weak linker symbols (unstable in
Rust) and no -rdynamic dependence. The unwinder path widens to Linux
(_Unwind_Backtrace via libgcc/llvm-libunwind); the x29 fast chain widens
to aarch64-linux (same AAPCS64 [fp, lr] pair) with stack bounds from
pthread_getattr_np/pthread_attr_getstack (low address + size = exclusive
top; any failure returns 0 and the walk falls back to the unwinder,
fail-closed like every other anomaly). x86-64 deliberately stays
unwinder-only - no frame re-derivation risk.

Status: native and x86_64-unknown-linux-gnu cargo check clean;
aarch64-unknown-linux-gnu cross-check blocked locally by the psm dep's
build script needing a cross C toolchain. Runtime verification (the
8-probe forced-evacuation matrix + verify-walker on a Linux host) is
what remains of #7173, plus -Cforce-frame-pointers for the Rust side.

* fix(gc): SP-relative fast-chain reconstruction is Darwin-only

The Pi 5's verify-walker run caught it exactly as designed: fast walk and
unwinder disagreed by the frame-layout delta on the same slot (80 bytes).
SP = FP + 16 - stack_size encodes the DARWIN AArch64 frame ([x29, x30] at
the top); aarch64-Linux lays the pair at the bottom. Off-Darwin,
SP-relative locations now disqualify the fast chain and the always-correct
unwinder serves, until the Linux constant is derived rather than ported.
With this, the aarch64-Linux forced-evacuation matrix is 8/8.

* docs: Linux verification (8/8 both arches) and Pi 5 small-hardware timing - shadow +14.7% ahead; default-flip needs a Pi-class gate

* docs: aarch64-Linux frame constant proven non-existent - FP offset varies per function; unwinder is the permanent Linux path

* ci(gc): native-root probe matrix on Linux (#7173)

Runs the statepoint-mode gc-ratchet matrix under forced evacuation +
verification against the pinned Node oracle, natively on ubuntu-latest,
with two liveness asserts per the four-ways-a-gate-cannot-fail rule: the
binary must carry a non-empty .llvm_stackmaps section, and the probes
must actually emit gc metrics. Completes #7173's remaining scope.

* docs: decompose the Pi +14.7% - it is DWARF CFI parsing in the unwinder, not the statepoint model

GC-suppressed runs leave deltas intact and cycle counts are identical
across arms, so it is not mutator codegen nor collection frequency. perf
resolves it: the statepoint arm's top symbols are libunwind CFI parsing
(parseCIE/getEncodedP/getULEB128/findFDE, ~22% combined on
string-retention) which the shadow arm never enters - each collection
walks the stack with the platform unwinder because the Linux fast chain
is disqualified. Fixable via an indexed walker or upstream FP-relative
spills. A libgcc-unwinder A/B was attempted and produced segfaulting
binaries (bad hand-rolled link line), so the specific unwinder's share
stays unquantified - recorded rather than guessed.

* docs: real-app scale finding - statepoint IR doubles and codegen-unit splitting does not scale

Claude Code 2.1.112 (13 MB bundle) compiles + runs under shadow (204 MB,
115 MB RSS) but the explicit statepoint bridge cannot: 1,083 MB IR, and
clang rejects the oversized unit. More units do not help - unit sizing is
by callable count, not IR bytes, and shared strings/globals are
replicated into EVERY unit (16 units still rendered ~400 MB each, >6 GB
total, which also exhausted disk). Two mode-agnostic fixes recorded.

* fix(gc): mark inline asm as gc-leaf-function under RS4GC (#7174)

Found on the Claude Code bundle: RS4GC rewrites every non-leaf call in a
gc-tagged function into a statepoint, including zero-instruction inline
asm barriers emitted by other codegen paths - producing a statepoint
whose callee is the asm value, which the verifier rejects outright
('Cannot take the address of an inline asm!'). The lowering previously
EXCLUDED asm lines from leaf marking; it must mark them leaf instead.
Probe suite stays 8/8 under forced evacuation.

* fix(gc): RS4GC leaf-marks inline asm even in rootless functions (#7174)

Two defects, both found on the Claude Code bundle:
- the string escape in the previous commit was mangled (it compiled only
  because the block sat in a position the parser accepted);
- more importantly the RS4GC lowering ran AFTER the empty-roots early
  return, so a function that reserves slots but binds none kept its
  gc 'statepoint-example' tag with UNMARKED inline asm - RS4GC then
  rewrote the asm into a statepoint and the verifier aborted with
  'Cannot take the address of an inline asm!'. Minimal opt repro
  confirms the attribute suppresses the rewrite (0 vs 3 occurrences).

RS4GC now runs before the early return. Probes 8/8 under forced
evacuation; codegen lowering tests 8/8.

* perf(codegen): emit each global into the units that reference it, not all of them

Codegen-unit splitting replicated EVERY string constant and global into
EVERY unit, so per-unit IR grew with the unit COUNT: on the 13 MB Claude
Code bundle each of 16 units still rendered ~400 MB (>6 GB total) and
clang refused the translation unit outright ('ran out of source
locations' / 'too large to process'), no matter how finely it was split.
Splitting could not fix a floor that splitting itself multiplied.

Now each bucket's function text is rendered first, its @symbol
references collected, and a global is emitted only into units that
reference it (unreferenced ones keep a home in unit 0). Definitions stay
linkonce_odr so the linker folds the rare multi-unit case.

An earlier variant emitted one definition plus  declarations
elsewhere; that is subtly wrong under -dead_strip, where the sole
definition can be discarded with its unit's atoms while a live reference
survives in another object - it showed up as an undefined
_perry_null_guard_zero linking probe 07 at 4 units. Reference-scoped
emission avoids the linkage question entirely.

gc-ratchet probes 8/8 at 1, 4 and 8 units; codegen suite 418/418.

* style: cargo fmt

* perf(gc): decode the prologue to recover SP, re-enabling the fast walker on Linux (#7173)

The Pi 5's +14.7% was DWARF CFI parsing: every collection walked the
stack with the platform unwinder because SP-relative statepoint spills
were unrecoverable off Darwin. Disassembly had shown x29 = sp + K with K
VARYING per function (0x30, 0x60 in adjacent functions), which killed the
constant-formula approach - but K is not unknowable, it is encoded in the
prologue's own 'add x29, sp, #imm', and the stack-map header already
gives every record its function's start address.

The walker now decodes that instruction (mask 0xFFC003FF, pattern
0x910003FD, immediate in bits 21:10; encoding verified against both
observed prologues) and takes the body SP as fp - imm. Bounded prologue
scan, stops at 'ret', fails closed to the platform unwinder when the
pattern is absent.

Decoding happens per FRAME in the walker, never at index time: deciding
chain-walkability up front would dereference every function address at
startup, which segfaults on records whose addresses are not live code.

macOS statepoint probes 8/8 run and 8/8 under PERRY_STACKMAP_WALKER=verify
(prologue-decoded SP agrees with the unwinder on every slot).

* fix(codegen): close global-to-global references transitively when splitting units

A global's initializer can name another global — a string header pointing
at its  payload, a closure record naming its thunk. Scoping
emission to function-text references alone therefore under-approximated
what a unit needs, and the 13 MB bundle failed with 'use of undefined
value @..._.str.10138.bytes'. Each unit's reference set is now closed
transitively over global initializers before deciding what to emit.

Also declares the safepoint-contract heal as its own
ConservativeScanSite (#7148's census enumerates every conservative-scan
site; main added the argument during the rebase).

Probes 8/8 at 1, 4 and 8 units; codegen suite 526/526.

* perf(codegen): compile codegen units concurrently, bounded

The split existed for peak memory (#5391) but the clang phase ran one
unit at a time: the 13 MB Claude Code bundle measured 4,939 s wall
against 4,672 s user - essentially single-threaded on a 10-core host,
with the dominant phase serialized.

Units are independent clang invocations, so they now run on a bounded
worker pool (std::thread::scope, no new dependency). Bounded rather than
one-thread-per-unit because each job parses a multi-hundred-megabyte
translation unit; unbounded fan-out would trade wall time for an OOM and
undo the peak-memory win the split was introduced for. Default is a
quarter of available parallelism clamped to [1, 4];
PERRY_CODEGEN_UNIT_JOBS overrides. Codegen suite 526/526.

* perf(codegen): scope each unit's declarations to what it references

Splitting a module MULTIPLIED total IR instead of dividing it, because
every unit carried the whole module's declaration list. Measured on
benchmarks/app-patterns/kernels/batch.ts: one unit = 431 KB, four units =
885 KB (2.05x), with 2,972 declares (149 KB) per unit against 4-7 actual
definitions. On the 13 MB Claude Code bundle each unit carried ~16,700
declares, which is why per-unit IR stayed above a gigabyte and clang
rejected it with 'translation unit is too large ... ran out of source
locations' (its SourceManager tops out near 2^31 bytes) at 6 units AND at
16 - more units could not fix a floor that more units also multiplied.

Units now emit only the declarations they reference, reusing the same
reference sets computed for the globals scoping, including names reached
through the initializers of the globals a unit emits. Result on the same
benchmark: four units = 299 KB (0.69x of a single unit, down from 2.05x),
31-71 declares per unit. Splitting now shrinks total work.

TRAP for anyone extending this: collect_symbol_refs yields '@name' while
decl_by_name is keyed on the bare name; comparing them directly filters
EVERY declare and the build fails loudly (it did).

gc-ratchet probes 8/8 shadow at 1, 4 and 8 units, and 8/8 statepoint at 4
units under forced evacuation + verification; codegen suite 526/526.

* docs: Pi small-hardware gap closed and inverted (+14.72% -> -1.74%), with honest attribution

Statepoints now beat shadow on every probe on the Pi 5, geo -1.74%.
Correctness 8/8 forced-evac and 8/8 verify-walker (prologue-decoded SP
agrees with the DWARF unwinder on aarch64-Linux).

Attribution is NOT the walker: a same-binary fast-vs-unwind A/B on the two
worst probes is a dead heat, so the DWARF parsing perf measured at ~22% is
no longer hot. The other variable is the rebase onto main's GC work
(#7192/#7196/#7148); shadow itself improved 469->429ms, which fits that
explanation and not 'the walker fixed it'.

Also records an instrument failure: a cycle-count grep reported 0 cycles
for both arms after main changed the diag format; raw output shows 81.9 MB
freed. A count that cannot fail is not evidence.

* gc: compact the stack map, closing the statepoint file-size gap

The statepoint backend's only losing axis was file size, and it was not
generated code: on test-drizzle-pg the RS4GC arm's __text is 248 KB SMALLER
than shadow's. The entire 3.5 MB loss is the __llvm_stackmaps section.

Measured composition of that section (scripts/stackmap_anatomy.py, which
asserts it parsed 100% of the bytes):

  40.6%  Constant location slots -- exactly 3 per record, gc.statepoint's
         CC / Flags / NumDeopt preamble
  13.3%  duplicate base/derived slots (Perry has no interior pointers)
  18.0%  record headers, incl. an 8-byte patchpoint ID nothing patches
  11.3%  inter-record padding

The runtime already discarded the constants and collapsed the base/derived
pair at parse time, so over half the section was shipped in the binary and
thrown away at startup. LLVM's stack map is a JIT-patching wire format; an
AOT collector needs {dwarf_reg, offset} per distinct root and nothing else.

Compaction measured on drizzle (4,214,384 B, 124 concatenated maps, 1,717
functions, 33,406 records, 154,020 distinct roots):

  flat varint                                387,199 B   10.9x
  + roots sorted and delta-encoded           286,258 B   14.7x
  + "same live set as previous record" flag  132,418 B   31.8x

The last step is a fact about real programs rather than a coding trick:
77% of records have exactly the live set of the record before them, because
consecutive safepoints in a function share their roots. The decoder points
repeats at one copy instead of materialising 154k entries, so it shrinks the
in-memory index too.

Projected onto the measured RS4GC arm: ~28.20 MB against shadow's 28.47 MB,
a ~271 KB win where there was a 3.5 MB loss. Statepoints then lead on all
three axes -- wall-clock -0.93%, RSS flat, size -271 KB.

The rewrite happens on assembly because that is where LLVM prints the map's
function addresses as symbol NAMES (.quad _main). One text parser replaces
Mach-O and ELF relocation parsing, llvm-objcopy, and a second link pass.
Two facts settled that empirically: the address fields are external symbol
relocations (otool -r: extern 1), so a separately assembled table resolves
at link; and -S costs the same 0.04s as -c, because codegen is the cost and
printing text is free.

Only the statepoint backends emit a stack map, so only they pay for it.
A module with no block, or one that does not parse, is assembled unchanged:
falling back costs bytes, never roots.

* gc: ship the compact map, measured -131 KB against the shadow stack

Completes the previous commit with the constraint that changed its design,
and replaces the projection with a measurement.

At -O3, LLVM does NOT emit a record's instruction offset as a literal: it
emits a label difference (`.long Ltmp9-_main`) that only the assembler can
evaluate. Those offsets therefore cannot be delta-varint-encoded at rewrite
time, and now live in a fixed-width u32 array (~4 B/record). That is 18.7x
compaction rather than 31.8x. Recovering the difference would mean
assembling twice -- once to learn the numbers the assembler just computed,
once to emit them -- which is more machinery than 92 KB is worth.

This was worth catching for a second reason: a prototype that treated any
non-integer operand as a symbol appeared to work while silently decoding
every such offset as ZERO. Literal offsets do appear without -O3, so a
hand-compiled probe hides the whole problem.

Measured on test-drizzle-pg, one compiler, identical flags, clean object
cache per arm (a clean-cache rebuild reproduced the cached shadow figure to
within 8 bytes, so this is not a stale-artifact reading):

  shadow (default)      28,737,536   __text 20,646,900   map       0
  statepoint + compact  28,688,464   __text 20,497,296   map 227,275   -49,072
  RS4GC + compact       28,605,912   __text 20,409,232   map 224,126  -131,624

Metadata 4,214,384 -> 227,275 B (18.5x), within 1% of what the encoder model
predicted. The file-size axis is flipped: the statepoint backend now leads on
ALL THREE axes -- wall-clock -0.93%, RSS flat, size -131,624 B -- where it
previously lost size by 3.5 MB.

Both arms pass the full gate: 8/8 probes byte-match the pinned Node oracle
normally and under PERRY_GC_FORCE_EVACUATE=1 PERRY_GC_VERIFY_EVACUATION=1
PERRY_STACKMAP_WALKER=verify. That last one is the check that can fail if the
format decoded to a smaller root set -- lost roots corrupt the heap under
forced evacuation rather than merely printing something different. The gate
also asserts its subject was live (__llvm_stackmaps absent AND __perry_gcmap
non-empty) before comparing any output; its first run correctly reported 0/8
because the rewrite had not run at all.

Compile time: 11.95s vs 10.43s for the whole application (+14.6%), covering
statepoint lowering plus the assembly round trip.

Also fixes a pre-existing bug on this branch: ConservativeScanSite::ALL was
missing SafepointContractHeal while COUNT already counted it, so that scan
site could never be enumerated -- and the mismatch broke every perry-runtime
test build.

The compaction driver lives in gc_map.rs rather than linker.rs, which keeps
linker.rs under the 2000-line lint cap.

* gc: fail loudly on an undecodable GC map, and skip compaction off Mach-O/ELF

Two holes left by the compact-map change, both silent by construction.

1. A GC map section that exists but does not decode returned an EMPTY index,
   which is indistinguishable downstream from "this is a shadow-stack build
   with no native frame roots". The consequences are not the same: with
   statepoints as the only root mechanism an empty index means the collector
   frees live objects and corrupts the heap with no diagnostic at all. That
   is CLAUDE.md's fourth gate-failure mode -- the gate runs, its subject
   never did. Now: no section at all still yields an empty index (correct for
   a shadow build), but a section that is present and undecodable panics at
   startup, naming the expected magic and version. In practice it can only
   mean a binary whose compiler and runtime disagree about the layout.

2. Compaction emitted the Mach-O `.section` directive for every target, so a
   COFF statepoint build would have failed to assemble. Rewriting is now
   gated to the two object formats whose syntax this module emits and whose
   section the runtime can find; anything else keeps LLVM's section, turning
   an unsupported-platform case back into a merely larger binary.

Gates re-run after the change: 8/8 probes on both the explicit-bridge and
RS4GC arms, byte-matching the pinned Node oracle normally and under
PERRY_GC_FORCE_EVACUATE=1 PERRY_GC_VERIFY_EVACUATION=1
PERRY_STACKMAP_WALKER=verify.

Note that the ELF path itself is still unverified on a Linux host (#7173):
ELF has no `.no_dead_strip`, so whether the linker keeps a section nothing
references is an open question, and the answer decides whether the map
survives at all there.

* gc: refuse to re-encode a stack map whose roots use a foreign register base

The compact format stores a root's base as a single bit, FP-or-SP, using
aarch64's DWARF numbers (29/31). Nothing checked that the incoming stack map
actually used those.

On x86-64 LLVM emits RBP=6 / RSP=7. Both would test false against SP, encode
as bit 0, and decode back as aarch64's FP=29 — a wrong base, which is a wrong
root address, which is a collector reading and rewriting the wrong words. No
diagnostic anywhere in that chain.

The native-frame-root backend is aarch64-only today (the runtime's prologue
decoder and fast walker are both cfg(target_arch = "aarch64")), so this was
dormant rather than live. It stops being dormant the moment anyone points
PERRY_STATEPOINTS at another architecture, and it would not announce itself.

Now any location whose base is neither FP nor SP aborts the rewrite and keeps
LLVM's section. Falling back costs bytes; guessing costs correctness.

Found by cross-compiling a probe with `--target linux` and reading the ELF:
the section, its 8-byte alignment and its `.rela.perry_gcmap` relocations all
came out right, but the object was x86-64 — which is what surfaced the
register assumption. That ELF check also confirms the assembly-syntax path
works for both object formats; what remains unverified there is whether the
linker retains a section nothing references (ELF has no `.no_dead_strip`) and
whether the runtime finds it, both of which need a real Linux host (#7173).

Gates: 8/8 on both arms, normally and under forced evacuation with the
verifying walker.

* gc: probe live roots across a throw, and record the RS4GC/landingpad gap

Nothing in the ratchet suite contained a `try` -- 0 of 8 probes -- so removing
the `!has_try` statepoint exclusion was covered by no test whatsoever. A green
run proved only that the eight try-free probes still worked.

09_try_catch_roots.ts exercises what the exclusion used to forbid: objects
allocated inside a try surviving a collection inside the same try; locals live
across a throw and read in the catch; a throw crossing several frames so the
roots being rewritten sit in a caller's frame; finally on both the normal and
unwinding edges; and a rethrow caught one frame up. Every survivor folds into
the checksum, so a lost or stale root is a wrong number, not a crash.

Its map is 1,116 bytes, the largest of any probe -- the liveness evidence that
try-carrying functions now really do carry statepoint records.

Explicit bridge: 9/9 against the oracle, normally and under forced evacuation
with the verifying walker.

RS4GC: 8/9. It cannot compile a try-carrying function -- the LLVM verifier
rejects gc.relocate taking a landingpad's { ptr, i32 } result where a token is
required, because statepoint-example expects a statepoint-invoke's unwind
destination to carry `landingpad token` rather than the Itanium form
try_stmt.rs emits. So the leanest arm on size (-131,624 B) is not the complete
one; the explicit bridge (-49,072 B) is. Recorded rather than patched: it is an
LLVM-convention problem, not something the compact map touches.

* gc: RS4GC accepts try functions (landingpad token), and fix a merge regression

Two things, both found by running arms I had not been running.

1. RS4GC could not compile any try-carrying function. It uses the unwind
   destination's landing pad AS the token for the relocates it inserts on the
   exceptional edge, so `statepoint-example` requires `landingpad token`.
   Perry emits the Itanium `landingpad { ptr, i32 }`, so RS4GC produced
   `gc.relocate({ ptr, i32 } %lpad, ...)` and the verifier rejected the module.

   Retyping is sound only because the pad's value is dead: try_stmt emits it to
   anchor the edge and branches straight on, taking the exception from the
   runtime rather than the pad payload. `retype_landing_pads_for_statepoints`
   therefore leaves a pad alone if its register is referenced anywhere —
   retyping a value someone reads would trade this loud failure for a silent
   miscompile. Whole-token register matching, so %r2 is not "used" by %r21.

   RS4GC goes 8/9 -> 9/9; the try probe's map is 1,931 B, the largest emitted.

2. The merge duplicated the return-site rewrite. main moved the shadow-stack
   pop into `for_each_final_item`, and the merge kept this branch's copy in
   `to_ir`, so both ran and every function with a shadow frame emitted
   `%shadow_pop_l_0` twice — clang rejected the module outright.

   This broke the DEFAULT path while all nine probes passed on both statepoint
   arms, because those arms route roots to statepoints and have no shadow
   frame. Verified now against the default arm too (9/9, both GC sections
   absent, which is what correct looks like there).

* docs: correct the size claim — statepoints tie, not win, after the main merge

Pre-merge the compact map measured -49,072 B (bridge) and -131,624 B (RS4GC)
against the shadow stack. Re-measured after merging main: +496 B and +50,064 B.

Main shrank every arm by ~1.7-1.8 MB but shrank SHADOW about 50 KB more than
the statepoint arms, which is the whole swing. The generated-code advantage is
intact (__text -151 KB bridge, -240 KB RS4GC, plus ~105 KB less __eh_frame);
it is now exactly cancelled by the 189-221 KB of remaining metadata.

The compaction is still load-bearing -- uncompacted that metadata is 4.2 MB
and the arm loses by ~4 MB. It converted a 3.5 MB loss into a tie, not a win.
Closing the axis needs fewer roots, not a tighter encoding: 221 KB for 154k
roots is near this format's floor.

* gc: unbreak the Linux build, and point the Linux gate at the compact map

The gc-native-roots gate has been red on every push to this branch since the
compact map landed, for two reasons I introduced.

perry-runtime did not COMPILE on Linux. Removing the LLVM v3 parser orphaned
read_u16 on macOS, so I deleted it -- but elf_section_vaddr is
cfg(target_os = "linux") and therefore invisible to a macOS `cargo check`.
Three E0425s plus one E0689 inference cascade. Restored, gated to Linux so it
does not warn as dead code on the host.

The gate's own liveness assert was stale: it required a non-empty
.llvm_stackmaps section, which the compact rewrite deliberately removes. It
now asserts BOTH directions -- .perry_gcmap present AND .llvm_stackmaps absent
-- because checking only the former would still pass if compaction silently
stopped running, and this project has been bitten by exactly that shape.

Nothing here changes what runs on macOS; both arms remain 9/9 locally. What it
buys is the first real ELF evidence: whether the linker retains a section
nothing references (ELF has no .no_dead_strip) and whether the runtime finds
it. That was the open question in #7173 and the gate answers it directly.

* gc: delete the unsound plain stack map — every root path now fails closed

The plain `llvm.experimental.stackmap` lowering was the last way this backend
could lose a root: LLVM may record a root slot's address as `Register R#N`,
caller-saved and unrecoverable at collection time, so the collector silently
misses it. Measured 3 of 60 locations on one probe. It survived as a fallback
in three places, all of which failed OPEN.

1. `PreciseRootBackend::StackMap` was dead by construction. Both sites that
   set `stack_map_requested` are guarded by `native_stack_roots_enabled()`,
   which IS `statepoints_enabled() || rs4gc_enabled()`, so the `else` branch
   could never be reached. Variant and emitter deleted.

2. The Statepoint backend fell back to a plain map whenever a call with live
   roots would not parse as a statepoint — chiefly INDIRECT calls. That was a
   limitation of this textual parser, not of statepoints: `gc.statepoint`
   takes its callee as a `ptr` operand and `emit_statepoint` interpolates it
   verbatim, so `ptr elementtype(T) %fnptr` is as valid as `... @callee`.
   Indirect targets are now statepoint-able; an unknown callee simply cannot
   be audited as non-collecting, which is the conservative answer anyway.
   Anything still unparseable is a hard compile failure naming the call shape,
   because a loud stop beats silent heap corruption.

3. The compact-map rewriter fell back to keeping LLVM's section, and the
   comment claimed that "costs bytes rather than roots". That was exactly
   backwards. The runtime reads ONLY `__perry_gcmap`, so such a module's
   records sit in the binary unread and its roots are invisible — and because
   other modules still emit a valid section, the runtime's "present but
   undecodable" guard stays quiet too. Now a hard error.

Evidence the removal is safe rather than merely bold, on test-drizzle-pg
(133 modules, real dependency code):

  23301 safepoints emitted: 23301 statepoints, 0 plain stack maps
  35951 non-collecting calls skipped; 0 statepoint parser fallback(s)
  129914 relocations, 0 plain-map operands

Both statepoint arms build that application, and all three arms (explicit
bridge, RS4GC, default shadow stack) pass 9/9 against the pinned Node oracle,
under forced evacuation with the verifying walker where applicable.

The report's fallback counters can now only ever read zero. Left in place
because that zero is the evidence, not noise — but they are a candidate for
deletion once this has soaked.

* gc: retain the compact map on ELF, and make the gate runnable on main

The Linux gate answered the open ELF question from #7173, and the answer was
that the map does not survive linking: `01_nursery_churn has no .perry_gcmap
section`.

Compaction was working — the object carries .perry_gcmap as PROGBITS/SHF_ALLOC
with its relocations intact. The linker was discarding it. Perry links with
-Wl,--gc-sections (link/build_and_run.rs), and nothing in the program
references this section: the collector finds it by name at runtime. On Mach-O
`.no_dead_strip` covers exactly this; ELF's analogue is SHF_GNU_RETAIN, so the
section is now emitted "aR" rather than "a". Verified the assembler accepts it
and emits flags AR.

This is the failure mode the whole map format is meant to make impossible, and
it was invisible on macOS: a binary that links fine, runs fine on every
macOS arm, and on Linux would have had no GC map at all.

Also makes the gate able to gate. It triggered only on
`push: [exp/stackmap-viability]`, so on main it would never run — CLAUDE.md's
second way a gate cannot fail. Now push:[main] + pull_request, with no
cancel-in-progress so a main run cannot be cancelled by the next merge.

Adds the changelog.d fragment the changeset-gate requires, and drops
gc_map_compaction_totals plus its counters — nothing read them, and the gate
asserting on the emitted binary's sections is stronger evidence than a
process-local counter.

* docs: key the changelog fragment to the actual PR number (#7314)

* gc: address CodeRabbit review — two hangs/holes, one real format gap

CodeRabbit found nine issues worth acting on. Three were mine and material.

**The gate could never pass.** `[ "$pass" -eq 8 ]` was hardcoded, and this PR
adds a ninth probe, so a fully green matrix would still fail the step. Both the
expected count and the stderr list are now derived from the glob, so adding a
probe cannot silently break the gate or, if the literal were lowered to match,
silently stop asserting full coverage.

**A malformed blob hung the process.** `total_len` comes straight from the
header; a zero (or too-small) value left `base` unchanged, and because the
magic still matched at that offset the resynchronisation path never ran. This
executes inside `OnceLock::get_or_init`, so it was a hang at the first
collection rather than the fail-closed panic. Now rejects a `total_len` that
cannot cover header + function table, and asserts forward progress regardless.

**`unwrap_or(0)` masked a truncated function table**, mis-sizing the offset
array so every later varint decoded from misaligned bytes — a wrong live set,
which the fail-closed policy exists to prevent. Propagates the failure now.

**COFF shipped roots the collector cannot read.** Assembling unchanged when
the target is neither Mach-O nor ELF leaves LLVM's section and no
`__perry_gcmap`, which is precisely the outcome the hard error two lines below
exists to prevent — reached with no diagnostic. This is the same silent-roots
class as the previous two commits, third instance. It refuses loudly now.

**The `js_throw*` prefix rule was already unsound, not merely fragile.**
CodeRabbit flagged that a future returning helper would match the prefix and
lose its statepoint. The audit it rested on is ALREADY false —
`js_throw_reference_error_tdz`, `js_throw_not_a_constructor` and others are
declared `-> f64`, not `-> !`. Worse, since #7302 a throw unwinds rather than
longjmps, so the call site is an `invoke` whose unwind edge needs relocations,
and these helpers allocate the Error they raise and can therefore collect.
Suppressing the safepoint left the catch handler's roots stale after a move.
The arm is deleted; the family falls through to `Unknown` and is conservatively
safepointed. Cost on test-drizzle-pg: 23,301 -> 24,809 statepoints.

**That change then exposed a real gap in the format**, via the fail-closed
error rather than via silent corruption. `@perryts/postgres/src/pool.ts`
refused to compile: LLVM uses **x19** as a frame base pointer in functions with
dynamic stack allocation — 66 root slots in that one module — and a single
FP-or-SP bit cannot express it. The base is now a 2-bit tag (0 = FP, 1 = SP,
2 = explicit DWARF register as a following varint), format version 3. The
runtime already handled arbitrary bases on the unwinder path and
`chain_walkable` already disables the fast x29 walk for them, so only the
encoding was the limit. The refusal added in 50408a9 is gone with the
restriction that motivated it.

**`caller_fp` was used before it was validated.** Every FP-relative root is
based on that word and `fp_to_sp_offset` subtracts from it, while the only
downstream filters were non-zero and 8-byte alignment — a corrupt frame could
yield out-of-stack addresses that the collector reads and rewrites. It now
gets the same bounds/alignment checks `fp` gets, before the root loop.

**The analysis script understated its own numbers.** `offv` is unpacked signed
and FP-relative offsets are negative; Python ints are unbounded, so `>> 31`
gave -1 and `varint_len` returned 1 for every negative input. Masked to 32
bits, and `varint_len` now rejects negatives instead of silently returning 1.
The reported ratios came from `otool` on real binaries rather than this model,
so they stand — and the same-build figure is now measured directly from the
per-module compaction log: 3,764,000 -> 203,296 B = 18.5x.

Plus: the empty-report message named PERRY_STATEPOINTS twice instead of
PERRY_RS4GC; `--statepoint-report`'s doc still pointed at the deleted
PERRY_STACK_MAPS mode; and the changelog claimed RS4GC needs PERRY_STATEPOINTS
when `native_stack_roots_enabled()` is `statepoints || rs4gc` and either
activates on its own.

Tests: perry-codegen 586, perry-runtime 1,673 (RUST_TEST_THREADS=1), and all
three arms 9/9 including the app that exposed the x19 gap.

---------

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

* experiment(gc): prototype stack maps and statepoints

* research(gc): measure and reduce native safepoints

* research(gc): x29-chain fast walker for native stack-map roots

The deep-stack telemetry showed 36,458 frames unwound to visit 104 root
locations: _Unwind_Backtrace pays full compact-unwind register recovery on
every native frame. Replace it with a raw x29-chain walk when the maps
allow it:

- codegen emits "frame-pointer"="non-leaf" on generated functions in
  native-root modes, so the [x29, x30] chain is guaranteed through
  generated frames (textual-IR input gets no frame-pointer default from
  the clang driver);
- the parser now records each function's stack size; LLVM's AArch64 frame
  keeps the FP/LR pair at the top of the frame, so SP-relative statepoint
  spills resolve as fp + 16 - stack_size from the same two chain loads;
- chain_walkable is decided once at parse: any location that is not
  FP-relative or sized-SP-relative disables the fast path for the image;
- every anomaly (misaligned, non-increasing, or out-of-bounds frame
  pointer) abandons the walk and re-runs the platform unwinder; slot
  visits are idempotent so the fallback is safe;
- PERRY_STACKMAP_WALKER=unwind forces the old walker (bisection control);
  PERRY_STACKMAP_WALKER=verify runs both and panics unless they visit the
  identical slot set - the liveness gate for the fast walker, since
  forced-evacuation verification enumerates roots through the same walker
  and cannot see a frame the walker skipped;
- telemetry gains fp_walks/fallback_walks so a run can prove which walker
  actually executed.

Finding recorded for the mode decision: plain-map mode emits Register
R#1 locations (root slot address in a caller-saved register) that the
parser must drop - those roots are invisible to the collector by
construction, which statepoint spill slots cannot exhibit.

* docs: record x29-chain walker results and the plain-map Register-location finding

* research(gc): explicit-safepoint collection contract (PERRY_GC_SAFEPOINT_ONLY)

The contract: a collection that skips the conservative stack scan consumes
only precise roots, and with native stack maps active those exist only at
mapped PCs - so such a collection may only begin at a declared safepoint
(loop back-edge poll, outermost microtask-pump boundary); anywhere else it
must scan conservatively. Today that property is emergent - every possibly-
collecting call happens to be mapped. The contract makes it enforced, which
is what allows call sites to become unmapped.

Runtime:
- GC_AT_DECLARED_SAFEPOINT thread-local + RAII guard, set by the moving-
  minor safepoint drain (covers both the loop poll and the microtask
  boundary) and by the contract poll extension.
- Enforcement at the root-scan subphase: an undeclared precise-root cycle
  either has the conservative scan forced for that cycle (heal mode, =1 -
  sound: the scan restores liveness and a conservatively-scanned cycle is
  non-moving) or panics (=strict, the gate mode that proves enforcement is
  live). The alloc-point valve and manual gc() force the scan already and
  are exempt by construction.
- Under the contract, loop polls also drain non-nursery triggers via
  gc_check_trigger so full collections migrate to declared safepoints.

Codegen:
- New audited GcCallEffect::AllocNoReentry class: helpers that may allocate
  (and so arm a trigger) but never collect synchronously and never re-enter
  generated JS. Under the contract their call sites need no statepoint;
  without it they remain safepoints. First audited set: closure/object
  allocation, js_array_push_f64/length/slice_values.
- PERRY_GC_SAFEPOINT_ONLY participates in build and object cache keys.

Census note (batch.ts): the bulk of remaining statepoints are property-
access diamonds that can re-enter via getters and must stay mapped; the
contract's reach is bounded by re-entry, and deleting those calls is
representation selection's job (Ptr<Shape>), not the contract's. The two
compose: repsel removes the calls, the contract unmaps what allocation
traffic remains.

* docs: explicit-safepoint contract design, enforcement levels, and census bound

* research(gc): enforce the safepoint contract on the copying-minor path

The copying minor evaluates eligibility in copying.rs and never reaches the
cycle.rs root-scan subphase - so the first enforcement point missed exactly
the MOVING path the contract exists to police. Add the same check at
eligibility evaluation: outside a declared safepoint a copying minor either
falls back to the non-moving cycle (heal - whose scan the cycle.rs heal
then forces) or panics (strict).

* fix(gc): heal the safepoint contract through the shared scan override

The first enforcement healed by overriding a LOCAL decision variable in the
root-scan subphase. Copying-minor eligibility and evacuation pinning read
conservative_stack_scan_decision() globally, concluded there were no
conservative roots to pin, and PERRY_GC_FORCE_EVACUATE moved objects that
raw native-stack words still pointed at - probe 04 span forever in
corrupted mutator code (109 CPU-minutes, zero GC frames in 1,489 samples).

Consolidate to one chokepoint: contract_scan_heal_guard() at the
synchronous collection entries returns a cycle-long ManualGcScanGuard, so
every consumer of the scan decision sees the same healed answer. Strict
mode panics at the same chokepoint. Deletes both scattered enforcement
sites - net less code than the broken version.

* fix(gc): delete the per-poll trigger drain from the safepoint contract

Draining non-nursery triggers at every allocating loop back-edge turned
nursery-churn loops into per-iteration collection work - O(n^2), probe 01
burned 20 CPU-minutes on a 200ms workload (sample: dominant runtime frames
+ TLS + memmove = collection work per iteration, unlike the split-brain
hang's pure-mutator signature). The extension was an optimization, not a
soundness requirement: an undeclared full at an alloc point heals with one
conservative scan. Polls return to their single job - draining the pending
moving minor.

* docs: record contract gate results and the three bugs the gates caught

* docs: quiet-host matrix results from the reserved M1 mini

Deep-stack closed (walker-attributed via the unwind control arm), compile
+5.3% claim withdrawn, RSS flat, statepoints at-worst-tied on wall clock;
metadata remains the only losing axis. 10ms timer quantum caveat recorded.

* research(gc): delete the plain-map user mode; elide statepoints at noreturn sites

PERRY_STACK_MAPS is gone per the GC knob kill-policy: after the quiet-host
matrix it was a losing mode (statepoints match it within timer quantization)
and it is structurally unsound - LLVM's stackmap intrinsic can record a
root slot's address as Register R#N (caller-saved, unrecoverable at
collection time), leaving those roots invisible to the collector. The
plain-map lowering survives only as statepoint mode's internal fallback for
try/setjmp functions; shrinking that fallback set is tracked follow-up
work. The env leaves both cache-key sets with it.

New audited GcCallEffect::NeverReturns class: every js_throw* helper
funnels into exception::js_throw (-> !), so control never returns to the
call site, no relocation is ever consumed, and the frame's roots are dead
past the call - the site needs no metadata in any mode. Deeper frames carry
their own records; values the helper holds are its own frame's
responsibility, as for every helper call. batch.ts carries 19 such sites.

* docs: post-matrix follow-through - mode deletion, noreturn elision, metadata trajectory

* research(gc): compact per-function root metadata (PERRY_COMPACT_ROOTS)

The file-size lever that does not wait for repsel. One stackmap intrinsic
in the entry block records every root alloca as a stable Direct location;
calls carry only zero-instruction memory barriers. Precision drops from
per-safepoint to per-function - sound because root allocas are already
zero-initialized at entry, so visiting a stale slot can only over-retain,
never corrupt. Metadata falls from ~64 B/safepoint + 24 B/root-pair to
~40 B/function + 12 B/slot: on the #7108 real-app model, 4.5-16.6 MB
becomes ~120 KB - below the shadow stack's 439 KB of hot text.

- Every generated function is lowered (rootless ones get a zero-operand
  entry record) so region matching can never attribute a frame to a
  neighboring function; block-local root slots fall back to the
  statepoint backend per function; has_try needs no exclusion because
  there is no per-call rewriting to conflict with setjmp.
- A __perry_gen_end sentinel object is linked after every generated
  object; its magic-ID record is both the region's exclusive upper bound
  and the runtime's compact-mode signal.
- The runtime matches frames by region (greatest record PC at or below
  the return address, bounded by the sentinel) instead of the +-16-byte
  per-safepoint heuristic; both walkers share the new match_records.
- Fail-closed: the parser counts register-recorded locations, and a
  compact image refuses to run with any present - in compact mode the
  entry record is the only description of the frame, so a register root
  would be silently invisible.
- PERRY_COMPACT_ROOTS participates in build and object cache keys.

Known pre-existing failure, not from this change: the branch's
gc::tests::shadow_stack_ops::out_of_range_frame_pop_is_ignored aborts
(panic inside a nounwind path, shadow_stack.rs:531, last touched by
main's #7088) - fails identically without this diff.

* research(gc): delete the compact per-function mode - measured negative result

The per-function metadata thesis was built (bd066d6), measured
(424-680 B vs 5.3-8.9 KB per probe, 10-13x), and disproven: a ten-line
churn loop deterministically corrupts under moving minors. The forensic
chain - retention clears, callee-saved clobbers, dead-slot zeroing, and
finally disabling walker visits entirely, all bit-identical failures -
proves the corruption vector is not the metadata machinery at all: the
mutator reads from-space through stale heap-derived values in optimized
SSA, which only relocation semantics can restore (the same module carries
79 gc.relocate under the statepoint backend). Barriers constrain memory
ordering, not dataflow.

Design law recorded in the doc: with an optimizing compiler between
source and safepoint, root metadata without relocation semantics is
unsound - per-call plain maps merely made the window small enough for
probes to pass; per-function maps made it wide enough to fail in ten
lines. The compact 10-13x is only reachable via RS4GC-style managed
SSA or repsel shrinking the recorded set.

Kept from the detour (mode-independent): the match_records refactor in
the walker, the copy-minor diag line (trigger kind + declared-safepoint
flag), and GcTriggerKind's Debug derive.

* docs: real-app remeasurement - metadata 3.83MB (below model floor), text recovery 150KB not 439KB, shadow is the measured three-axis optimum today

* docs: shadow-frame elision census - 7.7% of framed functions, 4.0% of shadow traffic; measured-and-not-pursued

* research(gc): second AllocNoReentry audit round - four admitted, two excluded with transitive-reentry evidence

Admitted: js_ctor_return_override (inspects the returned value, calls
nothing), js_array_indexOf_jsvalue (strict equality never runs user
code), js_validate_array_comparator / js_validate_array_map_callback
(type check + static-message throw through the audited noreturn funnel).

Excluded with the reason recorded in table and test:
js_value_length_f64 reaches js_object_get_field_by_name_f64 for plain
objects - a transitive getter path the smell-scan missed and the body
audit caught - and js_array_get_f64 has hole/accessor paths.

* docs: second audit round measurements - batch 442->172 (-61%), real-app metadata 3.76MB

* research(gc): first RS4GC pipeline slice (PERRY_RS4GC, #7174) - 5/8 probes green

Root allocas (alloca double / alloca i64) retype to ptr addrspace(1) with
cast surgery at recognized load/store sites; unrecognized shapes bail the
function to the explicit statepoint backend (fail-closed - and the bail
path was exercised for real: the first run silently fell back on every
function because the recognizer only knew the unit-test alloca i64 idiom,
caught by record-count comparison, 200 vs 55). Functions tag
gc statepoint-example; audited non-collecting callees carry
gc-leaf-function at call sites; compile_ll_to_object pipes modules
through opt -passes='default<O2>,rewrite-statepoints-for-gc' when
PERRY_RS4GC=1, failing loudly without an opt binary. Requires a
version-matched toolchain (PERRY_LLVM_CLANG=Homebrew clang 22: Apple
clang 21 cannot parse LLVM 22 attribute output). Cache keys wired.

Status, honestly: with the surgery genuinely engaged, 5/8 gc-ratchet
probes pass under forced evacuation + verification; 01/06/08 fail and are
the first concrete reproducers of the double-typed dataflow frontier
(NaN-box values crossing statepoints as double/i64 derivatives RS4GC does
not track). Metadata is not yet competitive (probe 01: 6,992 B vs the
explicit bridge's 5,320 B). Both are the #7174 work, now with failing
tests instead of projections.

* research(gc): RS4GC slice fully gated - 16/16 with mem2reg-only placement

O2-before-RS4GC fails 3/8 (GVN merges per-site cast chains across future
statepoint sites - the stale-double hazard recreated inside opt);
mem2reg-only is the sound pre-pass, clang optimizes safely after
statepoint insertion. The design law stated positively: relocation
semantics must exist before the optimizer may move heap-derived values.

* docs: RS4GC real-app measurement - text 248KB below shadow, metadata within 3.1% of the audited bridge, smallest native arm

* docs: RS4GC runtime and RSS cells - fastest arm measured, RSS flat; characterization table complete

* docs: measure the repsel-erasure projection - slope is ZERO for landed promotion classes

repsel-on vs knobs-off on batch.ts under statepoints: byte-identical
metadata (24,752 B / 198 statepoints / 33 slots). Landed promotions
remove calls, not roots - they prove values the rooter already knew were
non-pointers. Metadata erasure is paid only by maybe-pointer-population
promotions (untyped/temporaries/dep JS), where coverage is weakest.
Corrects the shared assumption in both campaigns' plans.

* research(gc): ELF/Linux stack-map scanner port (#7173) - compile-verified, runtime gates pending

Section discovery reads /proc/self/exe's section headers for
.llvm_stackmaps (sh_addr/sh_size) plus the main object's load bias from
the first dl_iterate_phdr callback - no weak linker symbols (unstable in
Rust) and no -rdynamic dependence. The unwinder path widens to Linux
(_Unwind_Backtrace via libgcc/llvm-libunwind); the x29 fast chain widens
to aarch64-linux (same AAPCS64 [fp, lr] pair) with stack bounds from
pthread_getattr_np/pthread_attr_getstack (low address + size = exclusive
top; any failure returns 0 and the walk falls back to the unwinder,
fail-closed like every other anomaly). x86-64 deliberately stays
unwinder-only - no frame re-derivation risk.

Status: native and x86_64-unknown-linux-gnu cargo check clean;
aarch64-unknown-linux-gnu cross-check blocked locally by the psm dep's
build script needing a cross C toolchain. Runtime verification (the
8-probe forced-evacuation matrix + verify-walker on a Linux host) is
what remains of #7173, plus -Cforce-frame-pointers for the Rust side.

* fix(gc): SP-relative fast-chain reconstruction is Darwin-only

The Pi 5's verify-walker run caught it exactly as designed: fast walk and
unwinder disagreed by the frame-layout delta on the same slot (80 bytes).
SP = FP + 16 - stack_size encodes the DARWIN AArch64 frame ([x29, x30] at
the top); aarch64-Linux lays the pair at the bottom. Off-Darwin,
SP-relative locations now disqualify the fast chain and the always-correct
unwinder serves, until the Linux constant is derived rather than ported.
With this, the aarch64-Linux forced-evacuation matrix is 8/8.

* docs: Linux verification (8/8 both arches) and Pi 5 small-hardware timing - shadow +14.7% ahead; default-flip needs a Pi-class gate

* docs: aarch64-Linux frame constant proven non-existent - FP offset varies per function; unwinder is the permanent Linux path

* ci(gc): native-root probe matrix on Linux (#7173)

Runs the statepoint-mode gc-ratchet matrix under forced evacuation +
verification against the pinned Node oracle, natively on ubuntu-latest,
with two liveness asserts per the four-ways-a-gate-cannot-fail rule: the
binary must carry a non-empty .llvm_stackmaps section, and the probes
must actually emit gc metrics. Completes #7173's remaining scope.

* docs: decompose the Pi +14.7% - it is DWARF CFI parsing in the unwinder, not the statepoint model

GC-suppressed runs leave deltas intact and cycle counts are identical
across arms, so it is not mutator codegen nor collection frequency. perf
resolves it: the statepoint arm's top symbols are libunwind CFI parsing
(parseCIE/getEncodedP/getULEB128/findFDE, ~22% combined on
string-retention) which the shadow arm never enters - each collection
walks the stack with the platform unwinder because the Linux fast chain
is disqualified. Fixable via an indexed walker or upstream FP-relative
spills. A libgcc-unwinder A/B was attempted and produced segfaulting
binaries (bad hand-rolled link line), so the specific unwinder's share
stays unquantified - recorded rather than guessed.

* docs: real-app scale finding - statepoint IR doubles and codegen-unit splitting does not scale

Claude Code 2.1.112 (13 MB bundle) compiles + runs under shadow (204 MB,
115 MB RSS) but the explicit statepoint bridge cannot: 1,083 MB IR, and
clang rejects the oversized unit. More units do not help - unit sizing is
by callable count, not IR bytes, and shared strings/globals are
replicated into EVERY unit (16 units still rendered ~400 MB each, >6 GB
total, which also exhausted disk). Two mode-agnostic fixes recorded.

* fix(gc): mark inline asm as gc-leaf-function under RS4GC (#7174)

Found on the Claude Code bundle: RS4GC rewrites every non-leaf call in a
gc-tagged function into a statepoint, including zero-instruction inline
asm barriers emitted by other codegen paths - producing a statepoint
whose callee is the asm value, which the verifier rejects outright
('Cannot take the address of an inline asm!'). The lowering previously
EXCLUDED asm lines from leaf marking; it must mark them leaf instead.
Probe suite stays 8/8 under forced evacuation.

* fix(gc): RS4GC leaf-marks inline asm even in rootless functions (#7174)

Two defects, both found on the Claude Code bundle:
- the string escape in the previous commit was mangled (it compiled only
  because the block sat in a position the parser accepted);
- more importantly the RS4GC lowering ran AFTER the empty-roots early
  return, so a function that reserves slots but binds none kept its
  gc 'statepoint-example' tag with UNMARKED inline asm - RS4GC then
  rewrote the asm into a statepoint and the verifier aborted with
  'Cannot take the address of an inline asm!'. Minimal opt repro
  confirms the attribute suppresses the rewrite (0 vs 3 occurrences).

RS4GC now runs before the early return. Probes 8/8 under forced
evacuation; codegen lowering tests 8/8.

* perf(codegen): emit each global into the units that reference it, not all of them

Codegen-unit splitting replicated EVERY string constant and global into
EVERY unit, so per-unit IR grew with the unit COUNT: on the 13 MB Claude
Code bundle each of 16 units still rendered ~400 MB (>6 GB total) and
clang refused the translation unit outright ('ran out of source
locations' / 'too large to process'), no matter how finely it was split.
Splitting could not fix a floor that splitting itself multiplied.

Now each bucket's function text is rendered first, its @symbol
references collected, and a global is emitted only into units that
reference it (unreferenced ones keep a home in unit 0). Definitions stay
linkonce_odr so the linker folds the rare multi-unit case.

An earlier variant emitted one definition plus  declarations
elsewhere; that is subtly wrong under -dead_strip, where the sole
definition can be discarded with its unit's atoms while a live reference
survives in another object - it showed up as an undefined
_perry_null_guard_zero linking probe 07 at 4 units. Reference-scoped
emission avoids the linkage question entirely.

gc-ratchet probes 8/8 at 1, 4 and 8 units; codegen suite 418/418.

* style: cargo fmt

* perf(gc): decode the prologue to recover SP, re-enabling the fast walker on Linux (#7173)

The Pi 5's +14.7% was DWARF CFI parsing: every collection walked the
stack with the platform unwinder because SP-relative statepoint spills
were unrecoverable off Darwin. Disassembly had shown x29 = sp + K with K
VARYING per function (0x30, 0x60 in adjacent functions), which killed the
constant-formula approach - but K is not unknowable, it is encoded in the
prologue's own 'add x29, sp, #imm', and the stack-map header already
gives every record its function's start address.

The walker now decodes that instruction (mask 0xFFC003FF, pattern
0x910003FD, immediate in bits 21:10; encoding verified against both
observed prologues) and takes the body SP as fp - imm. Bounded prologue
scan, stops at 'ret', fails closed to the platform unwinder when the
pattern is absent.

Decoding happens per FRAME in the walker, never at index time: deciding
chain-walkability up front would dereference every function address at
startup, which segfaults on records whose addresses are not live code.

macOS statepoint probes 8/8 run and 8/8 under PERRY_STACKMAP_WALKER=verify
(prologue-decoded SP agrees with the unwinder on every slot).

* fix(codegen): close global-to-global references transitively when splitting units

A global's initializer can name another global — a string header pointing
at its  payload, a closure record naming its thunk. Scoping
emission to function-text references alone therefore under-approximated
what a unit needs, and the 13 MB bundle failed with 'use of undefined
value @..._.str.10138.bytes'. Each unit's reference set is now closed
transitively over global initializers before deciding what to emit.

Also declares the safepoint-contract heal as its own
ConservativeScanSite (#7148's census enumerates every conservative-scan
site; main added the argument during the rebase).

Probes 8/8 at 1, 4 and 8 units; codegen suite 526/526.

* perf(codegen): compile codegen units concurrently, bounded

The split existed for peak memory (#5391) but the clang phase ran one
unit at a time: the 13 MB Claude Code bundle measured 4,939 s wall
against 4,672 s user - essentially single-threaded on a 10-core host,
with the dominant phase serialized.

Units are independent clang invocations, so they now run on a bounded
worker pool (std::thread::scope, no new dependency). Bounded rather than
one-thread-per-unit because each job parses a multi-hundred-megabyte
translation unit; unbounded fan-out would trade wall time for an OOM and
undo the peak-memory win the split was introduced for. Default is a
quarter of available parallelism clamped to [1, 4];
PERRY_CODEGEN_UNIT_JOBS overrides. Codegen suite 526/526.

* perf(codegen): scope each unit's declarations to what it references

Splitting a module MULTIPLIED total IR instead of dividing it, because
every unit carried the whole module's declaration list. Measured on
benchmarks/app-patterns/kernels/batch.ts: one unit = 431 KB, four units =
885 KB (2.05x), with 2,972 declares (149 KB) per unit against 4-7 actual
definitions. On the 13 MB Claude Code bundle each unit carried ~16,700
declares, which is why per-unit IR stayed above a gigabyte and clang
rejected it with 'translation unit is too large ... ran out of source
locations' (its SourceManager tops out near 2^31 bytes) at 6 units AND at
16 - more units could not fix a floor that more units also multiplied.

Units now emit only the declarations they reference, reusing the same
reference sets computed for the globals scoping, including names reached
through the initializers of the globals a unit emits. Result on the same
benchmark: four units = 299 KB (0.69x of a single unit, down from 2.05x),
31-71 declares per unit. Splitting now shrinks total work.

TRAP for anyone extending this: collect_symbol_refs yields '@name' while
decl_by_name is keyed on the bare name; comparing them directly filters
EVERY declare and the build fails loudly (it did).

gc-ratchet probes 8/8 shadow at 1, 4 and 8 units, and 8/8 statepoint at 4
units under forced evacuation + verification; codegen suite 526/526.

* docs: Pi small-hardware gap closed and inverted (+14.72% -> -1.74%), with honest attribution

Statepoints now beat shadow on every probe on the Pi 5, geo -1.74%.
Correctness 8/8 forced-evac and 8/8 verify-walker (prologue-decoded SP
agrees with the DWARF unwinder on aarch64-Linux).

Attribution is NOT the walker: a same-binary fast-vs-unwind A/B on the two
worst probes is a dead heat, so the DWARF parsing perf measured at ~22% is
no longer hot. The other variable is the rebase onto main's GC work
(#7192/#7196/#7148); shadow itself improved 469->429ms, which fits that
explanation and not 'the walker fixed it'.

Also records an instrument failure: a cycle-count grep reported 0 cycles
for both arms after main changed the diag format; raw output shows 81.9 MB
freed. A count that cannot fail is not evidence.

* gc: compact the stack map, closing the statepoint file-size gap

The statepoint backend's only losing axis was file size, and it was not
generated code: on test-drizzle-pg the RS4GC arm's __text is 248 KB SMALLER
than shadow's. The entire 3.5 MB loss is the __llvm_stackmaps section.

Measured composition of that section (scripts/stackmap_anatomy.py, which
asserts it parsed 100% of the bytes):

  40.6%  Constant location slots -- exactly 3 per record, gc.statepoint's
         CC / Flags / NumDeopt preamble
  13.3%  duplicate base/derived slots (Perry has no interior pointers)
  18.0%  record headers, incl. an 8-byte patchpoint ID nothing patches
  11.3%  inter-record padding

The runtime already discarded the constants and collapsed the base/derived
pair at parse time, so over half the section was shipped in the binary and
thrown away at startup. LLVM's stack map is a JIT-patching wire format; an
AOT collector needs {dwarf_reg, offset} per distinct root and nothing else.

Compaction measured on drizzle (4,214,384 B, 124 concatenated maps, 1,717
functions, 33,406 records, 154,020 distinct roots):

  flat varint                                387,199 B   10.9x
  + roots sorted and delta-encoded           286,258 B   14.7x
  + "same live set as previous record" flag  132,418 B   31.8x

The last step is a fact about real programs rather than a coding trick:
77% of records have exactly the live set of the record before them, because
consecutive safepoints in a function share their roots. The decoder points
repeats at one copy instead of materialising 154k entries, so it shrinks the
in-memory index too.

Projected onto the measured RS4GC arm: ~28.20 MB against shadow's 28.47 MB,
a ~271 KB win where there was a 3.5 MB loss. Statepoints then lead on all
three axes -- wall-clock -0.93%, RSS flat, size -271 KB.

The rewrite happens on assembly because that is where LLVM prints the map's
function addresses as symbol NAMES (.quad _main). One text parser replaces
Mach-O and ELF relocation parsing, llvm-objcopy, and a second link pass.
Two facts settled that empirically: the address fields are external symbol
relocations (otool -r: extern 1), so a separately assembled table resolves
at link; and -S costs the same 0.04s as -c, because codegen is the cost and
printing text is free.

Only the statepoint backends emit a stack map, so only they pay for it.
A module with no block, or one that does not parse, is assembled unchanged:
falling back costs bytes, never roots.

* gc: ship the compact map, measured -131 KB against the shadow stack

Completes the previous commit with the constraint that changed its design,
and replaces the projection with a measurement.

At -O3, LLVM does NOT emit a record's instruction offset as a literal: it
emits a label difference (`.long Ltmp9-_main`) that only the assembler can
evaluate. Those offsets therefore cannot be delta-varint-encoded at rewrite
time, and now live in a fixed-width u32 array (~4 B/record). That is 18.7x
compaction rather than 31.8x. Recovering the difference would mean
assembling twice -- once to learn the numbers the assembler just computed,
once to emit them -- which is more machinery than 92 KB is worth.

This was worth catching for a second reason: a prototype that treated any
non-integer operand as a symbol appeared to work while silently decoding
every such offset as ZERO. Literal offsets do appear without -O3, so a
hand-compiled probe hides the whole problem.

Measured on test-drizzle-pg, one compiler, identical flags, clean object
cache per arm (a clean-cache rebuild reproduced the cached shadow figure to
within 8 bytes, so this is not a stale-artifact reading):

  shadow (default)      28,737,536   __text 20,646,900   map       0
  statepoint + compact  28,688,464   __text 20,497,296   map 227,275   -49,072
  RS4GC + compact       28,605,912   __text 20,409,232   map 224,126  -131,624

Metadata 4,214,384 -> 227,275 B (18.5x), within 1% of what the encoder model
predicted. The file-size axis is flipped: the statepoint backend now leads on
ALL THREE axes -- wall-clock -0.93%, RSS flat, size -131,624 B -- where it
previously lost size by 3.5 MB.

Both arms pass the full gate: 8/8 probes byte-match the pinned Node oracle
normally and under PERRY_GC_FORCE_EVACUATE=1 PERRY_GC_VERIFY_EVACUATION=1
PERRY_STACKMAP_WALKER=verify. That last one is the check that can fail if the
format decoded to a smaller root set -- lost roots corrupt the heap under
forced evacuation rather than merely printing something different. The gate
also asserts its subject was live (__llvm_stackmaps absent AND __perry_gcmap
non-empty) before comparing any output; its first run correctly reported 0/8
because the rewrite had not run at all.

Compile time: 11.95s vs 10.43s for the whole application (+14.6%), covering
statepoint lowering plus the assembly round trip.

Also fixes a pre-existing bug on this branch: ConservativeScanSite::ALL was
missing SafepointContractHeal while COUNT already counted it, so that scan
site could never be enumerated -- and the mismatch broke every perry-runtime
test build.

The compaction driver lives in gc_map.rs rather than linker.rs, which keeps
linker.rs under the 2000-line lint cap.

* gc: fail loudly on an undecodable GC map, and skip compaction off Mach-O/ELF

Two holes left by the compact-map change, both silent by construction.

1. A GC map section that exists but does not decode returned an EMPTY index,
   which is indistinguishable downstream from "this is a shadow-stack build
   with no native frame roots". The consequences are not the same: with
   statepoints as the only root mechanism an empty index means the collector
   frees live objects and corrupts the heap with no diagnostic at all. That
   is CLAUDE.md's fourth gate-failure mode -- the gate runs, its subject
   never did. Now: no section at all still yields an empty index (correct for
   a shadow build), but a section that is present and undecodable panics at
   startup, naming the expected magic and version. In practice it can only
   mean a binary whose compiler and runtime disagree about the layout.

2. Compaction emitted the Mach-O `.section` directive for every target, so a
   COFF statepoint build would have failed to assemble. Rewriting is now
   gated to the two object formats whose syntax this module emits and whose
   section the runtime can find; anything else keeps LLVM's section, turning
   an unsupported-platform case back into a merely larger binary.

Gates re-run after the change: 8/8 probes on both the explicit-bridge and
RS4GC arms, byte-matching the pinned Node oracle normally and under
PERRY_GC_FORCE_EVACUATE=1 PERRY_GC_VERIFY_EVACUATION=1
PERRY_STACKMAP_WALKER=verify.

Note that the ELF path itself is still unverified on a Linux host (#7173):
ELF has no `.no_dead_strip`, so whether the linker keeps a section nothing
references is an open question, and the answer decides whether the map
survives at all there.

* gc: refuse to re-encode a stack map whose roots use a foreign register base

The compact format stores a root's base as a single bit, FP-or-SP, using
aarch64's DWARF numbers (29/31). Nothing checked that the incoming stack map
actually used those.

On x86-64 LLVM emits RBP=6 / RSP=7. Both would test false against SP, encode
as bit 0, and decode back as aarch64's FP=29 — a wrong base, which is a wrong
root address, which is a collector reading and rewriting the wrong words. No
diagnostic anywhere in that chain.

The native-frame-root backend is aarch64-only today (the runtime's prologue
decoder and fast walker are both cfg(target_arch = "aarch64")), so this was
dormant rather than live. It stops being dormant the moment anyone points
PERRY_STATEPOINTS at another architecture, and it would not announce itself.

Now any location whose base is neither FP nor SP aborts the rewrite and keeps
LLVM's section. Falling back costs bytes; guessing costs correctness.

Found by cross-compiling a probe with `--target linux` and reading the ELF:
the section, its 8-byte alignment and its `.rela.perry_gcmap` relocations all
came out right, but the object was x86-64 — which is what surfaced the
register assumption. That ELF check also confirms the assembly-syntax path
works for both object formats; what remains unverified there is whether the
linker retains a section nothing references (ELF has no `.no_dead_strip`) and
whether the runtime finds it, both of which need a real Linux host (#7173).

Gates: 8/8 on both arms, normally and under forced evacuation with the
verifying walker.

* gc: probe live roots across a throw, and record the RS4GC/landingpad gap

Nothing in the ratchet suite contained a `try` -- 0 of 8 probes -- so removing
the `!has_try` statepoint exclusion was covered by no test whatsoever. A green
run proved only that the eight try-free probes still worked.

09_try_catch_roots.ts exercises what the exclusion used to forbid: objects
allocated inside a try surviving a collection inside the same try; locals live
across a throw and read in the catch; a throw crossing several frames so the
roots being rewritten sit in a caller's frame; finally on both the normal and
unwinding edges; and a rethrow caught one frame up. Every survivor folds into
the checksum, so a lost or stale root is a wrong number, not a crash.

Its map is 1,116 bytes, the largest of any probe -- the liveness evidence that
try-carrying functions now really do carry statepoint records.

Explicit bridge: 9/9 against the oracle, normally and under forced evacuation
with the verifying walker.

RS4GC: 8/9. It cannot compile a try-carrying function -- the LLVM verifier
rejects gc.relocate taking a landingpad's { ptr, i32 } result where a token is
required, because statepoint-example expects a statepoint-invoke's unwind
destination to carry `landingpad token` rather than the Itanium form
try_stmt.rs emits. So the leanest arm on size (-131,624 B) is not the complete
one; the explicit bridge (-49,072 B) is. Recorded rather than patched: it is an
LLVM-convention problem, not something the compact map touches.

* gc: RS4GC accepts try functions (landingpad token), and fix a merge regression

Two things, both found by running arms I had not been running.

1. RS4GC could not compile any try-carrying function. It uses the unwind
   destination's landing pad AS the token for the relocates it inserts on the
   exceptional edge, so `statepoint-example` requires `landingpad token`.
   Perry emits the Itanium `landingpad { ptr, i32 }`, so RS4GC produced
   `gc.relocate({ ptr, i32 } %lpad, ...)` and the verifier rejected the module.

   Retyping is sound only because the pad's value is dead: try_stmt emits it to
   anchor the edge and branches straight on, taking the exception from the
   runtime rather than the pad payload. `retype_landing_pads_for_statepoints`
   therefore leaves a pad alone if its register is referenced anywhere —
   retyping a value someone reads would trade this loud failure for a silent
   miscompile. Whole-token register matching, so %r2 is not "used" by %r21.

   RS4GC goes 8/9 -> 9/9; the try probe's map is 1,931 B, the largest emitted.

2. The merge duplicated the return-site rewrite. main moved the shadow-stack
   pop into `for_each_final_item`, and the merge kept this branch's copy in
   `to_ir`, so both ran and every function with a shadow frame emitted
   `%shadow_pop_l_0` twice — clang rejected the module outright.

   This broke the DEFAULT path while all nine probes passed on both statepoint
   arms, because those arms route roots to statepoints and have no shadow
   frame. Verified now against the default arm too (9/9, both GC sections
   absent, which is what correct looks like there).

* docs: correct the size claim — statepoints tie, not win, after the main merge

Pre-merge the compact map measured -49,072 B (bridge) and -131,624 B (RS4GC)
against the shadow stack. Re-measured after merging main: +496 B and +50,064 B.

Main shrank every arm by ~1.7-1.8 MB but shrank SHADOW about 50 KB more than
the statepoint arms, which is the whole swing. The generated-code advantage is
intact (__text -151 KB bridge, -240 KB RS4GC, plus ~105 KB less __eh_frame);
it is now exactly cancelled by the 189-221 KB of remaining metadata.

The compaction is still load-bearing -- uncompacted that metadata is 4.2 MB
and the arm loses by ~4 MB. It converted a 3.5 MB loss into a tie, not a win.
Closing the axis needs fewer roots, not a tighter encoding: 221 KB for 154k
roots is near this format's floor.

* gc: unbreak the Linux build, and point the Linux gate at the compact map

The gc-native-roots gate has been red on every push to this branch since the
compact map landed, for two reasons I introduced.

perry-runtime did not COMPILE on Linux. Removing the LLVM v3 parser orphaned
read_u16 on macOS, so I deleted it -- but elf_section_vaddr is
cfg(target_os = "linux") and therefore invisible to a macOS `cargo check`.
Three E0425s plus one E0689 inference cascade. Restored, gated to Linux so it
does not warn as dead code on the host.

The gate's own liveness assert was stale: it required a non-empty
.llvm_stackmaps section, which the compact rewrite deliberately removes. It
now asserts BOTH directions -- .perry_gcmap present AND .llvm_stackmaps absent
-- because checking only the former would still pass if compaction silently
stopped running, and this project has been bitten by exactly that shape.

Nothing here changes what runs on macOS; both arms remain 9/9 locally. What it
buys is the first real ELF evidence: whether the linker retains a section
nothing references (ELF has no .no_dead_strip) and whether the runtime finds
it. That was the open question in #7173 and the gate answers it directly.

* gc: delete the unsound plain stack map — every root path now fails closed

The plain `llvm.experimental.stackmap` lowering was the last way this backend
could lose a root: LLVM may record a root slot's address as `Register R#N`,
caller-saved and unrecoverable at collection time, so the collector silently
misses it. Measured 3 of 60 locations on one probe. It survived as a fallback
in three places, all of which failed OPEN.

1. `PreciseRootBackend::StackMap` was dead by construction. Both sites that
   set `stack_map_requested` are guarded by `native_stack_roots_enabled()`,
   which IS `statepoints_enabled() || rs4gc_enabled()`, so the `else` branch
   could never be reached. Variant and emitter deleted.

2. The Statepoint backend fell back to a plain map whenever a call with live
   roots would not parse as a statepoint — chiefly INDIRECT calls. That was a
   limitation of this textual parser, not of statepoints: `gc.statepoint`
   takes its callee as a `ptr` operand and `emit_statepoint` interpolates it
   verbatim, so `ptr elementtype(T) %fnptr` is as valid as `... @callee`.
   Indirect targets are now statepoint-able; an unknown callee simply cannot
   be audited as non-collecting, which is the conservative answer anyway.
   Anything still unparseable is a hard compile failure naming the call shape,
   because a loud stop beats silent heap corruption.

3. The compact-map rewriter fell back to keeping LLVM's section, and the
   comment claimed that "costs bytes rather than roots". That was exactly
   backwards. The runtime reads ONLY `__perry_gcmap`, so such a module's
   records sit in the binary unread and its roots are invisible — and because
   other modules still emit a valid section, the runtime's "present but
   undecodable" guard stays quiet too. Now a hard error.

Evidence the removal is safe rather than merely bold, on test-drizzle-pg
(133 modules, real dependency code):

  23301 safepoints emitted: 23301 statepoints, 0 plain stack maps
  35951 non-collecting calls skipped; 0 statepoint parser fallback(s)
  129914 relocations, 0 plain-map operands

Both statepoint arms build that application, and all three arms (explicit
bridge, RS4GC, default shadow stack) pass 9/9 against the pinned Node oracle,
under forced evacuation with the verifying walker where applicable.

The report's fallback counters can now only ever read zero. Left in place
because that zero is the evidence, not noise — but they are a candidate for
deletion once this has soaked.

* gc: retain the compact map on ELF, and make the gate runnable on main

The Linux gate answered the open ELF question from #7173, and the answer was
that the map does not survive linking: `01_nursery_churn has no .perry_gcmap
section`.

Compaction was working — the object carries .perry_gcmap as PROGBITS/SHF_ALLOC
with its relocations intact. The linker was discarding it. Perry links with
-Wl,--gc-sections (link/build_and_run.rs), and nothing in the program
references this section: the collector finds it by name at runtime. On Mach-O
`.no_dead_strip` covers exactly this; ELF's analogue is SHF_GNU_RETAIN, so the
section is now emitted "aR" rather than "a". Verified the assembler accepts it
and emits flags AR.

This is the failure mode the whole map format is meant to make impossible, and
it was invisible on macOS: a binary that links fine, runs fine on every
macOS arm, and on Linux would have had no GC map at all.

Also makes the gate able to gate. It triggered only on
`push: [exp/stackmap-viability]`, so on main it would never run — CLAUDE.md's
second way a gate cannot fail. Now push:[main] + pull_request, with no
cancel-in-progress so a main run cannot be cancelled by the next merge.

Adds the changelog.d fragment the changeset-gate requires, and drops
gc_map_compaction_totals plus its counters — nothing read them, and the gate
asserting on the emitted binary's sections is stronger evidence than a
process-local counter.

* docs: key the changelog fragment to the actual PR number (#7314)

* gc: address CodeRabbit review — two hangs/holes, one real format gap

CodeRabbit found nine issues worth acting on. Three were mine and material.

**The gate could never pass.** `[ "$pass" -eq 8 ]` was hardcoded, and this PR
adds a ninth probe, so a fully green matrix would still fail the step. Both the
expected count and the stderr list are now derived from the glob, so adding a
probe cannot silently break the gate or, if the literal were lowered to match,
silently stop asserting full coverage.

**A malformed blob hung the process.** `total_len` comes straight from the
header; a zero (or too-small) value left `base` unchanged, and because the
magic still matched at that offset the resynchronisation path never ran. This
executes inside `OnceLock::get_or_init`, so it was a hang at the first
collection rather than the fail-closed panic. Now rejects a `total_len` that
cannot cover header + function table, and asserts forward progress regardless.

**`unwrap_or(0)` masked a truncated function table**, mis-sizing the offset
array so every later varint decoded from misaligned bytes — a wrong live set,
which the fail-closed policy exists to prevent. Propagates the failure now.

**COFF shipped roots the collector cannot read.** Assembling unchanged when
the target is neither Mach-O nor ELF leaves LLVM's section and no
`__perry_gcmap`, which is precisely the outcome the hard error two lines below
exists to prevent — reached with no diagnostic. This is the same silent-roots
class as the previous two commits, third instance. It refuses loudly now.

**The `js_throw*` prefix rule was already unsound, not merely fragile.**
CodeRabbit flagged that a future returning helper would match the prefix and
lose its statepoint. The audit it rested on is ALREADY false —
`js_throw_reference_error_tdz`, `js_throw_not_a_constructor` and others are
declared `-> f64`, not `-> !`. Worse, since #7302 a throw unwinds rather than
longjmps, so the call site is an `invoke` whose unwind edge needs relocations,
and these helpers allocate the Error they raise and can therefore collect.
Suppressing the safepoint left the catch handler's roots stale after a move.
The arm is deleted; the family falls through to `Unknown` and is conservatively
safepointed. Cost on test-drizzle-pg: 23,301 -> 24,809 statepoints.

**That change then exposed a real gap in the format**, via the fail-closed
error rather than via silent corruption. `@perryts/postgres/src/pool.ts`
refused to compile: LLVM uses **x19** as a frame base pointer in functions with
dynamic stack allocation — 66 root slots in that one module — and a single
FP-or-SP bit cannot express it. The base is now a 2-bit tag (0 = FP, 1 = SP,
2 = explicit DWARF register as a following varint), format version 3. The
runtime already handled arbitrary bases on the unwinder path and
`chain_walkable` already disables the fast x29 walk for them, so only the
encoding was the limit. The refusal added in 50408a9 is gone with the
restriction that motivated it.

**`caller_fp` was used before it was validated.** Every FP-relative root is
based on that word and `fp_to_sp_offset` subtracts from it, while the only
downstream filters were non-zero and 8-byte alignment — a corrupt frame could
yield out-of-stack addresses that the collector reads and rewrites. It now
gets the same bounds/alignment checks `fp` gets, before the root loop.

**The analysis script understated its own numbers.** `offv` is unpacked signed
and FP-relative offsets are negative; Python ints are unbounded, so `>> 31`
gave -1 and `varint_len` returned 1 for every negative input. Masked to 32
bits, and `varint_len` now rejects negatives instead of silently returning 1.
The reported ratios came from `otool` on real binaries rather than this model,
so they stand — and the same-build figure is now measured directly from the
per-module compaction log: 3,764,000 -> 203,296 B = 18.5x.

Plus: the empty-report message named PERRY_STATEPOINTS twice instead of
PERRY_RS4GC; `--statepoint-report`'s doc still pointed at the deleted
PERRY_STACK_MAPS mode; and the changelog claimed RS4GC needs PERRY_STATEPOINTS
when `native_stack_roots_enabled()` is `statepoints || rs4gc` and either
activates on its own.

Tests: perry-codegen 586, perry-runtime 1,673 (RUST_TEST_THREADS=1), and all
three arms 9/9 including the app that exposed the x19 gap.

* gc: a stack-map record must belong to the function the ip is in

CodeRabbit's remaining major finding on #7314, now measured rather than
assumed. `match_records` accepted the nearest safepoint within +-16 bytes, but
that window is a distance, not a containment check. Functions are adjacent in
.text, so an ip early in B can fall inside the window of a safepoint at the end
of A — and the walkers would then use A's frame offsets against B's frame and
rewrite unrelated stack words.

Instrumented the whole probe suite before changing anything, because the
obvious fix (require an exact pc) would have been wrong. Seven inexact matches
occur; six are already rejected as out-of-window (deltas 32..64) and one is
accepted at delta=8. All seven are same-function. So requiring an exact match
would have DISCARDED a legitimate root, and no cross-function match happens
today — the hazard is real but latent.

The fix is containment, not tightening: the matched record's function must be
the greatest mapped function start <= ip, which the index now precomputes. That
rejects the cross-function case and keeps the legitimate near-match.

Residual gap stated in the comment rather than papered over: a function with no
safepoints is absent from the function list, so an ip inside one resolves to
the previous mapped function. Closing that needs a per-function code extent,
and Mach-O does not expose one cheaply — `Lfunc_end` covers only EH-carrying
functions (5 of 43 in a sampled module) and there is no `.size` directive.

All three arms remain 9/9.

---------

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.

GC: evacuating minor drops an old-to-young field[1] edge, crashing with 'value is not a function'

2 participants