Skip to content

feat(gc): from-space protection, GC zeal, and verify-roots gap closures for #7154 - #7196

Merged
proggeramlug merged 1 commit into
PerryTS:mainfrom
jdalton:feat/gc-debug-instruments
Aug 1, 2026
Merged

feat(gc): from-space protection, GC zeal, and verify-roots gap closures for #7154#7196
proggeramlug merged 1 commit into
PerryTS:mainfrom
jdalton:feat/gc-debug-instruments

Conversation

@jdalton

@jdalton jdalton commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

When Perry's code generator forgets to tell the garbage collector about a value that is still in use, the compiled program keeps pointing at memory the collector has already recycled. The maddening part is that nothing goes wrong at that moment. The nursery resets, the bump allocator hands the same bytes to the next allocation, and the stale pointer now reads a perfectly valid — but completely unrelated — object. The program carries on and dies one or more collection cycles later, in a different function, as TypeError: value is not a function. Ten rounds of investigation on #7154 went into that gap between cause and symptom.

This PR adds three opt-in instruments that collapse the gap. On the known-broken reproducer, the strongest combination turns "wrong answer on 9 of 400 iterations, exit code 0" into a hard fault on iteration 1, at the instruction that misuses the stale pointer, with the holder still live on the stack. On the known-fixed build the same combination stays clean, so it is a detector rather than a noise generator.

All three are default-off and inert when off; no collector behaviour changes on a normal build. Nobody compiling or running a Perry program sees any difference unless they set one of these environment variables.

Instrument 1: from-space protectionPERRY_GC_PROTECT_FROMSPACE makes the stale read fault at the faulting instruction

New crates/perry-runtime/src/arena/quarantine.rs. After an evacuating minor collection, the retired Eden and active-survivor blocks are detached from the arena into a bounded quarantine ring (each vacated slot becomes a data = null tombstone, the exact shape C4b-δ dealloc already leaves, so block-index semantics — active_survivor_block_index_range, block_has_live, every walker — are untouched), poison-filled, and mprotect(PROT_NONE)d over their page-aligned interior.

A stale dereference now faults immediately:

[gc-fromspace-protect] FAULT: signal 10 at 0x377ddea0178
  This address is RETIRED FROM-SPACE. The evacuating minor moved or
  freed the object here and the holder kept the pre-collection address.
  block=0x377ddea0000 +376 retired_bytes=664 retired_by_minor=#0
  last-known object: user_ptr=0x377ddea00f0 obj_type=1 size=144
  The faulting instruction IS the stale use. Backtrace:
  ...

The reporter formats into a stack buffer and write(2)s it (no format! on the signal path), reads the census with try_lock so it can never block, uses the async-signal-safe backtrace / backtrace_symbols_fd pair, then restores SIG_DFL and returns so the instruction re-faults — a core file, debugger or crash reporter still sees the real site. The obj_type and size come from a census taken by walking each block before poisoning.

  • =poison selects poison without mprotect. That is also what the (at most two) sub-page block edges mprotect cannot cover always get — arena blocks are alloc'd at 16-byte alignment. Protected and poisoned byte totals are counted separately, so a run can never claim page protection it did not get.
  • PERRY_GC_PROTECT_FROMSPACE_DEPTH (default 4, 0 clamped to 1) bounds memory. Expired sets are restored to PROT_READ|PROT_WRITE and recycled back into Eden rather than freed, so the quarantine is a ring, footprint is depth × from-space bytes, and nothing that was ever mprotected is handed to dealloc.
Instrument 2: GC zealPERRY_GC_ZEAL=1 moves the value on its first exposure, not whenever pressure happens to line up

Forces an evacuating minor at every GC safepoint (loop back-edge polls and the outermost microtask-pump boundary) instead of only when pressure is due, so an unrooted value moves on its first exposure rather than whenever an unrelated allocation burst happens to line up. Modelled on V8 --stress-scavenge / SpiderMonkey gcZeal.

Zeal implies gc_force_evacuate_enabled() — a zealous minor that left survivors in place would move nothing and could not surface the bug, i.e. a gate that cannot fail — while still losing to an explicit PERRY_GEN_GC_EVACUATE=0, so the two knobs can never silently disagree about whether objects move.

There is deliberately no level 2. An "every allocation" zeal would go through the alloc-point arm, which takes ManualGcScanGuard::force_full_scan; a forced conservative scan makes the copying minor ineligible (CopiedMinorFallbackReason::ConservativeStack), so level 2 would run many non-moving minors and move nothing. That is exactly the PERRY_GC_FORCE_EVACUATE inertness this project already paid for in #6942 / #6946, so the level does not exist rather than existing untrustworthy.

Instrument 3: verify-roots gap closures — the abort switch was completely inert on its own

PERRY_GC_FROMSPACE_SCAN_ABORT=1 now implies PERRY_GC_FROMSPACE_SCAN=1. On its own it was completely inert: run_fromspace_scan returned at the enablement gate, the scan never ran, there was nothing to abort, and the run reported success — an investigator reaching for the abort switch mid-hunt got a green run and no scan.

The abort path now also prints a collector backtrace, and every offender sample reports the target's obj_type alongside the owner's (the field that separates a dead closure, 4, from a dead object, 2, when triaging value is not a function).

Validation on the known-broken / known-fixed pair — detection moves from iteration 9-of-400-and-silent to iteration 1-and-fatal

Ran. main is currently without #7192's lower_new_impl_inner fix, so it is the known-broken build. Same binary, same program (#7192's test_gap_gc_new_instance_rooting.ts), compiled and run with PERRY_GC_MOVING_LOOP_POLLS=1:

arm result detection
broken, no instruments bad 9 / 400, exit 0 none — silent wrong answer after all 400 iterations
broken, ZEAL=1 bad 400 / 400, exit 0 100% of iterations instead of 2.25%, still silent
broken, ZEAL=1 PROTECT_FROMSPACE=1 DEPTH=800 SIGBUS, exit 138 iteration 1 of 400, first use of an address retired by collection #0
fixed (#7192 applied), no instruments bad 0
fixed, ZEAL=1 bad 0
fixed, ZEAL=1 PROTECT_FROMSPACE=1 DEPTH=800 bad 0, exit 0 clean — no false positive

Depth matters and is now documented. The default 4 misses this reproducer silently: under zeal the constructor body crosses 600 back-edge polls, so the caller's stale register is 600 retirements old by the time js_ctor_return_override publishes it. Rule of thumb, now in CLAUDE.md and the memory-model page: depth >= the number of safepoints the suspect value survives.

Why this is not an instrument artifact. The quarantine covers exactly the bytes the ordinary reset declares reusable — every block of Eden and the active survivor, nothing more. Pinned young objects make the copying fast path ineligible (CopiedMinorFallbackReason::PinnedYoung*), so from-space is entirely dead when this runs. And in poison mode a program that reads retired from-space produces a wrong answer rather than a fault, which is a direct positive control: it proves the bytes were genuinely being consumed.

What each knob does NOT gate — two documented ways a protected run can be vacuously green

CLAUDE.md and docs/src/internals/memory-model.md gain a table stating what each knob gates exactly and — as importantly — what it does not, because prior rounds were misled by knobs whose real effect differed from their name.

Knob The trap
PERRY_GC_PROTECT_FROMSPACE Gates only the copying minor's from-space reset — not arena_reset_empty_blocks, not the full mark-sweep, not old-gen defrag, not the malloc sweep. A run with zero copying minors protects nothing; check for the [gc-fromspace-protect] retired_set=#N line under PERRY_GC_DIAG=1.
PERRY_GC_ZEAL Cannot emit loop polls codegen never produced (compile-time PERRY_GC_MOVING_LOOP_POLLS=1, default off since #7161). crate::gc::zeal_forced_collections() is the live-subject counter.

Also noted: quarantined bytes leave ARENA_TOTAL_BYTES, so a protected run under-reports arena bytes and RSS is genuinely higher. Do not benchmark under it.

Test coverage — every knob asserts both states, and the detector is sabotage-tested rather than merely exercised

crates/perry-runtime/src/gc/tests/fromspace_protect.rs, 10 tests. Every knob asserts both states as the kill-policy requires — the OFF arm proves the collector is unchanged when the instrument is off, and each ON arm asserts its subject was live (an object actually moved, or the safepoint was genuinely idle) before believing the result. Knob parsing is factored into pure functions so both states are covered without mutating process environment, since the live readers cache in a OnceLock.

quarantine_catches_a_planted_stale_from_space_deref is the sabotage arm: it plants a #7184/#7192-shaped stale from-space pointer and asserts the instrument distinguishes it from the live object that would otherwise be recycled into those bytes. A green protected run therefore means the detector works, not that nothing was tried.

New CI arm (gating): scripts/gc_instrument_smoke.sh, wired into .github/workflows/test.yml. It asserts both defaults — inert with the knobs unset, live with them set — and refuses to pass unless the zeal arm forced strictly more collections than the pressure-only arm, so it cannot go green having run zero copying minors (the #6942 / #7024 / #7025 failure mode).

Test-suite parity — order-flaky on both trees, so the failing set was characterised rather than compared once

cargo test -p perry-runtime is order-flaky on both trees, so the failing set was characterised rather than compared once: 33 runs on origin/main, 19 on this branch. Both produce 0–5 failures per run drawn from the same rotating pool, and every name observed on this branch was also observed on main (global_this_webassembly::namespace_members_exist_with_expected_shapes, native_module_stream::stream_constructors_expose_static_method_values, prop_plan::*, teardown::map_set_*, closure::dynamic_props::tests_1802::*, object::tests::closure_name_and_length_ignore_plain_assignment, gc::tests::runtime_roots::test_class_inheritance_side_table_roots_mark_and_rewrite). All pass in isolation. No name is unique to this branch.

scripts/check_file_size.sh passes. scripts/addr_class_inventory.py output is identical to main's (the two handle-floor ratchet failures in child_process/value_util.rs and fs/dirent.rs are pre-existing); the one new gcheader-cast — the quarantine census, which walks a detached block linearly exactly as arena/walk.rs does and can never see a NaN-box payload — has a justified allowlist entry.

Coordination — runtime-side only; nothing from #7192 is included here

Runtime-side only (crates/perry-runtime/src/gc/*, arena/*). No overlap with #7192's codegen work or scripts/gc_root_dominance_check.py. #7192's fix and gap test were used for validation and then reverted out of this branch; nothing from that PR is included here.

Summary by CodeRabbit

  • New Features

    • Added opt-in GC rooting diagnostics with from-space quarantine, poisoning, and stale-pointer fault reporting.
    • Added GC zeal mode to force evacuating collections at safepoints.
    • Enhanced stale-reference scans with object details and optional abort diagnostics.
    • Added configurable quarantine depth, protection modes, diagnostic statistics, and platform fallbacks.
  • Documentation

    • Documented diagnostic settings, interactions, limitations, and recommended configurations.
  • Tests

    • Added automated coverage and smoke tests for GC instrumentation.

@coderabbitai

coderabbitai Bot commented Aug 1, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Added default-off GC rooting diagnostics for from-space quarantine, poisoning, page protection, forced evacuating minors, and stale-reference scanning. Added runtime tests, an end-to-end smoke test, CI coverage, documentation, and a changelog.

Changes

GC rooting-bug instruments

Layer / File(s) Summary
From-space quarantine implementation
crates/perry-runtime/src/arena/*, crates/perry-runtime/src/arena/reset.rs, scripts/addr_class_allowlist.txt
Added configurable quarantine, poisoning, Unix page protection, bounded recycling, statistics, signal reporting, and quarantine-aware copying-minor reset behavior.
Safepoint-driven GC zeal
crates/perry-runtime/src/gc/zeal.rs, crates/perry-runtime/src/gc/mod.rs, crates/perry-runtime/src/gc/policy.rs
Added PERRY_GC_ZEAL, safepoint-triggered evacuating minors, test overrides, and forced-collection telemetry.
Stale-reference diagnostics
crates/perry-runtime/src/gc/fromspace_scan.rs
Unified scan and abort knob resolution, recorded target object types, and added collector backtraces to abort reports.
Instrumentation validation and documentation
crates/perry-runtime/src/gc/tests/*, scripts/gc_instrument_smoke.sh, .github/workflows/test.yml, docs/src/internals/memory-model.md, CLAUDE.md, changelog.d/7196-gc-rooting-bug-instruments.md, Cargo.toml
Added unit, integration, smoke, and CI tests. Documented configuration, platform behavior, compile-time poll requirements, diagnostic combinations, and the workspace version update.

Estimated code review effort: 5 (Critical) | ~90+ minutes

Possibly related PRs

Suggested labels: bug

Suggested reviewers: proggeramlug

Sequence Diagram(s)

sequenceDiagram
  participant MovingLoopPoll
  participant GCPolicy
  participant ArenaReset
  participant Quarantine
  MovingLoopPoll->>GCPolicy: reach enabled safepoint
  GCPolicy->>ArenaReset: force copying minor under zeal
  ArenaReset->>Quarantine: retire evacuated from-space
  Quarantine-->>GCPolicy: update quarantine statistics
Loading
sequenceDiagram
  participant StaleReference
  participant FromSpaceScan
  participant Collector
  participant DiagnosticReporter
  StaleReference->>FromSpaceScan: inspect from-space target
  FromSpaceScan->>Collector: resolve scan and abort knobs
  FromSpaceScan->>DiagnosticReporter: report target type and address
  DiagnosticReporter-->>Collector: emit backtrace before abort
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the three main changes: from-space protection, GC zeal, and verify-roots improvements.
Description check ✅ Passed The description is detailed and covers the changes, related issue, validation, tests, limitations, and coordination, but it does not use the template headings or checklist.
✨ 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: 8

🧹 Nitpick comments (3)
crates/perry-runtime/src/gc/tests/fromspace_protect.rs (1)

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

Read the sentinel through GcHeader.

The raw *const u8 read assumes obj_type is the first field of GcHeader. That holds today, but a field reorder would silently change what this test asserts.

♻️ Proposed change
-    let header_obj_type = unsafe { *((from_space_addr - GC_HEADER_SIZE) as *const u8) };
+    let header_obj_type =
+        unsafe { (*((from_space_addr - GC_HEADER_SIZE) as *const GcHeader)).obj_type };
🤖 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-runtime/src/gc/tests/fromspace_protect.rs` around lines 159 -
164, Update the retired-header assertion in the from-space protection test to
read the sentinel through the `GcHeader` field representing the object type,
rather than dereferencing a raw `*const u8`; preserve the existing
`QUARANTINE_POISON_OBJ_TYPE` comparison and failure message.
crates/perry-runtime/src/gc/fromspace_scan.rs (2)

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

The sentinel prints as 255, not 0xFF.

target_obj_type uses {}, so the no-header sentinel appears as decimal 255. The field documentation at Lines 65-69 states the value is "Reported as 0xFF". Align the two so an investigator can grep for the documented spelling.

♻️ Proposed change
-        "  owner={:`#x`} type={} space={:?} +{} {} -> {:`#x`} (type={} {:?}) {} [slot dirty_now={} ever_dirty={} owner_flags={:`#x`} marked={}]",
+        "  owner={:`#x`} type={} space={:?} +{} {} -> {:`#x`} (type={:`#x`} {:?}) {} [slot dirty_now={} ever_dirty={} owner_flags={:`#x`} marked={}]",

Note that this also changes the rendering of real types (4 becomes 0x4), so update the field documentation accordingly if you take this option.

🤖 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-runtime/src/gc/fromspace_scan.rs` around lines 318 - 325, Update
the format specifier for target_obj_type in the fromspace scan log to
hexadecimal so the no-header sentinel is rendered as 0xFF and matches its
documented spelling. Revise the target_obj_type field documentation to describe
hexadecimal rendering for real object types as well.

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

Cache the resolved knob pair once.

Both readers duplicate the same two std::env::var calls in separate OnceLocks. If the environment changes between the first call to fromspace_scan_enabled() and the first call to fromspace_scan_abort(), the two caches can disagree about the same knobs. A single cached pair removes the duplication and makes the pair atomic.

♻️ Proposed refactor
-pub(super) fn fromspace_scan_enabled() -> bool {
-    use std::sync::OnceLock;
-    static CACHED: OnceLock<bool> = OnceLock::new();
-    *CACHED.get_or_init(|| {
-        resolve_scan_knobs(
-            std::env::var("PERRY_GC_FROMSPACE_SCAN").ok().as_deref(),
-            std::env::var("PERRY_GC_FROMSPACE_SCAN_ABORT")
-                .ok()
-                .as_deref(),
-        )
-        .0
-    })
-}
-
-fn fromspace_scan_abort() -> bool {
-    use std::sync::OnceLock;
-    static CACHED: OnceLock<bool> = OnceLock::new();
-    *CACHED.get_or_init(|| {
-        resolve_scan_knobs(
-            std::env::var("PERRY_GC_FROMSPACE_SCAN").ok().as_deref(),
-            std::env::var("PERRY_GC_FROMSPACE_SCAN_ABORT")
-                .ok()
-                .as_deref(),
-        )
-        .1
-    })
-}
+fn scan_knobs() -> (bool, bool) {
+    use std::sync::OnceLock;
+    static CACHED: OnceLock<(bool, bool)> = OnceLock::new();
+    *CACHED.get_or_init(|| {
+        resolve_scan_knobs(
+            std::env::var("PERRY_GC_FROMSPACE_SCAN").ok().as_deref(),
+            std::env::var("PERRY_GC_FROMSPACE_SCAN_ABORT")
+                .ok()
+                .as_deref(),
+        )
+    })
+}
+
+pub(super) fn fromspace_scan_enabled() -> bool {
+    scan_knobs().0
+}
+
+fn fromspace_scan_abort() -> bool {
+    scan_knobs().1
+}
🤖 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-runtime/src/gc/fromspace_scan.rs` around lines 134 - 158,
Introduce one shared OnceLock-cached pair for the resolved from-space scan
knobs, and have both fromspace_scan_enabled and fromspace_scan_abort read their
respective values from that pair. Move the duplicated environment reads and
resolve_scan_knobs call into the shared initializer so both readers observe one
atomic result.
🤖 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 `@CLAUDE.md`:
- Line 143: Update the PERRY_GC_ZEAL row in CLAUDE.md to state that zeal only
bypasses the gc_budgeted_due_trigger() requirement, while
gc_safepoint_moving_minor entry guards still apply for in-allocation,
suppressed, unsafe FFI, non-zero root-lock depth, and budgeted-cycle states.
Revise the “does NOT” cell to include these guards and avoid implying every
safepoint always collects.

In `@crates/perry-runtime/src/arena/mod.rs`:
- Around line 16-18: Gate the `quarantine` module and its publicly exposed APIs
in `arena::mod.rs` to supported Unix targets, preventing unconditional
compilation of `libc` calls such as `sysconf`, `mprotect`, and `sigaction` on
Windows. For unsupported targets, add inert fallbacks for
`protect_fromspace_enabled` and `copying_quarantine_from_spaces_and_flip` that
preserve the existing API without performing quarantine actions.

In `@crates/perry-runtime/src/arena/quarantine.rs`:
- Around line 688-694: Update the census coverage filter in the entry lookup to
end at user_offset + size - GC_HEADER_SIZE, matching build_census’s user_offset
and size semantics; keep the existing reverse search and lower-bound check
unchanged so offsets in a following object’s header are not attributed to the
preceding object.
- Around line 392-409: Update push_set_and_evict so a poisoned REGISTRY lock
returns the incoming blocks in evicted for immediate recycling instead of
dropping them. Move SETS_RETIRED.fetch_add into the successful lock branch,
ensuring it increments only when the QuarantinedSet is actually registered.
- Around line 494-500: Repair the survivor arena’s current block before flipping
instead of assigning the first non-null position with unwrap_or(0). Update or
invoke ensure_usable_current_block for the arena handled by
with_survivor_arena_mut, ensuring a fresh usable block is installed when no
empty survivor block remains and the inactive-survivor metadata range is updated
consistently.

In `@crates/perry-runtime/src/gc/tests/fromspace_protect.rs`:
- Around line 107-123: Serialize the exact quarantine counter checks in
protection_off_retires_no_from_space by acquiring the existing
copying_nursery_isolation_lock before capturing the baseline and holding it
through collection and assertions. Apply the same lock to any related tests in
fromspace_protect.rs that compare quarantine_stats deltas, while leaving the
thread-local ProtectionModeGuard behavior unchanged.
- Around line 281-295: Update zeal_implies_forced_evacuation to read the
pre-zeal evacuation state without the redundant nested _zeal guard, then split
the assertion into separate enabled and disabled cases. When
gen_gc_evacuate_enabled() is true, assert that enabling zeal makes
gc_force_evacuate_enabled() true; when it is false, explicitly assert the
expected disabled-state behavior so the test reports that branch instead of
passing vacuously.

In `@docs/src/internals/memory-model.md`:
- Line 136: Update the PERRY_GC_ZEAL=1 documentation to state that an explicit
PERRY_GEN_GC_EVACUATE=0 takes precedence, disables survivor evacuation, and
prevents zeal from forcing objects to move. Keep the existing description of
zeal and its implied PERRY_GC_FORCE_EVACUATE behavior unchanged.

---

Nitpick comments:
In `@crates/perry-runtime/src/gc/fromspace_scan.rs`:
- Around line 318-325: Update the format specifier for target_obj_type in the
fromspace scan log to hexadecimal so the no-header sentinel is rendered as 0xFF
and matches its documented spelling. Revise the target_obj_type field
documentation to describe hexadecimal rendering for real object types as well.
- Around line 134-158: Introduce one shared OnceLock-cached pair for the
resolved from-space scan knobs, and have both fromspace_scan_enabled and
fromspace_scan_abort read their respective values from that pair. Move the
duplicated environment reads and resolve_scan_knobs call into the shared
initializer so both readers observe one atomic result.

In `@crates/perry-runtime/src/gc/tests/fromspace_protect.rs`:
- Around line 159-164: Update the retired-header assertion in the from-space
protection test to read the sentinel through the `GcHeader` field representing
the object type, rather than dereferencing a raw `*const u8`; preserve the
existing `QUARANTINE_POISON_OBJ_TYPE` comparison and failure message.
🪄 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: 31a2e1bf-975a-4764-8f9a-18ac6937cdba

📥 Commits

Reviewing files that changed from the base of the PR and between 42ce572 and 2f659c6.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (14)
  • CLAUDE.md
  • Cargo.toml
  • changelog.d/7154-gc-rooting-bug-instruments.md
  • crates/perry-runtime/src/arena/mod.rs
  • crates/perry-runtime/src/arena/quarantine.rs
  • crates/perry-runtime/src/arena/reset.rs
  • crates/perry-runtime/src/gc/fromspace_scan.rs
  • crates/perry-runtime/src/gc/mod.rs
  • crates/perry-runtime/src/gc/policy.rs
  • crates/perry-runtime/src/gc/tests/fromspace_protect.rs
  • crates/perry-runtime/src/gc/tests/mod.rs
  • crates/perry-runtime/src/gc/zeal.rs
  • docs/src/internals/memory-model.md
  • scripts/addr_class_allowlist.txt

Comment thread CLAUDE.md Outdated
Comment thread crates/perry-runtime/src/arena/mod.rs
Comment thread crates/perry-runtime/src/arena/quarantine.rs
Comment thread crates/perry-runtime/src/arena/quarantine.rs
Comment thread crates/perry-runtime/src/arena/quarantine.rs Outdated
Comment thread crates/perry-runtime/src/gc/tests/fromspace_protect.rs
Comment thread crates/perry-runtime/src/gc/tests/fromspace_protect.rs
Comment thread docs/src/internals/memory-model.md Outdated
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

Copy link
Copy Markdown
Contributor

Completion pass — review resolved, kill-policy settled, instruments proven against the known bugs

Merged main in (no rebase, no force-push; through #7199). All eight review threads answered inline — three fixed, two refuted with evidence, three doc/test corrections.

One blocker found, and it was real

mod quarantine; was ungated and perry-runtime is genuinely compiled for x86_64-pc-windows-msvctest.yml's windows-build job per-PR, and release-packages.yml via perry-ui-windowsperry-runtime. A crate depending only on libc, calling exactly the surface quarantine.rs used, fails on that target for every symbol: sysconf, _SC_PAGESIZE, mprotect, PROT_NONE/READ/WRITE, sigaction, SA_SIGINFO, SA_ONSTACK, SIGBUS, sigemptyset.

Fixed by gating the syscall helpers (not the whole module), so QuarantineStats, the knob parsers and their tests keep one definition and an identical public API everywhere. Off Unix ProtectPages degrades to poison-only visiblybytes_protected stays 0 while bytes_poisoned counts the whole retired range, which is what the separately-counted totals were built to reveal. Precedent is pty/mod.rs's #[cfg(unix)] mod native;.

Kill-policy: resolved by option (a), not by amending the policy

Three dark knobs was the right thing to push back on. Both directions are now covered by arms that can fail:

  • Sabotage test, quarantine_catches_a_planted_stale_from_space_deref, in the required cargo-test gate. It plants the fix(codegen): never let a shadow-frame slot index escape the pushed frame (#7154) #7184/fix(codegen): make every GC root store dominate the collection points after it #7192 shape (a mutator keeping a pre-collection address across an evacuating minor) and asserts the instrument reports poison where the un-instrumented control reads a valid recycled object. That control is what makes the verdict mean anything — it demonstrates the bug is genuinely invisible without the instrument.
  • scripts/gc_instrument_smoke.sh, gating in gc-stress, for the integrated path a unit test cannot reach: codegen emitting back-edge polls → zeal firing → copying minor → quarantine retirement in a real compiled program. It fails unless the zeal arm forces strictly more collections than the pressure-only arm, so it cannot go green having run zero copying minors. Locally: off = 0 retirements, zeal = 1230, all arms correct, ~20s.

Verified the gates can fail. With the quarantine branch disabled, 4 of 13 tests go red — including the sabotage test, at its live-subject assertion — while the knob-parse and OFF-arm tests correctly stay green.

Detect-the-known-bugs: red-then-green on the reverted fix

Same instrumented binary, test_gap_gc_new_instance_rooting.ts, compiled and run with PERRY_GC_MOVING_LOOP_POLLS=1:

arm result detection
#7192 reverted, no instruments bad 9/400, exit 0 none — silent wrong answer
#7192 reverted, ZEAL=1 PROTECT_FROMSPACE=1 DEPTH=800 SIGBUS, exit 138 fault at the stale deref, retired_by_minor=#0
fixed, no instruments bad 0, exit 0
fixed, ZEAL=1 bad 0, exit 0 (220s)
fixed, ZEAL=1 PROTECT_FROMSPACE=1 DEPTH=800 bad 0, exit 0 (343s) clean — no false positive

That run also surfaced a second census bug the first fix had masked. The faulting address is a raw header address (#7192 publishes %inst, the pre-header pointer), and bounding coverage at the payload end left it attributed to nobody — (no census entry covers this offset). Coverage now matches the object's whole extent, header included, with census_lookup extracted and pinned for all four cases: payload, last payload byte, own header, stride padding.

Also

  • Version bump reverted, then re-resolved to main's 0.5.1278 by the merge — Cargo.toml/Cargo.lock are now byte-identical to main, nothing for the maintainer to strip. Changelog fragment renamed to the PR-keyed 7196-.
  • The Critical survivor-current finding is refuted with a regression test: a tombstone has size == 0, so ArenaBlock::alloc fails bumped > self.size and returns None without dereferencing data. The ordinary reset_region_to_zero path sets current = 0 unconditionally, so tombstone-tolerant allocation is a property the allocator has always needed.
  • docs/CLAUDE.md: zeal does not bypass gc_safepoint_moving_minor's entry guards, and loses to an explicit PERRY_GEN_GC_EVACUATE=0.

Note for the maintainer, unrelated to this PR

scripts/check_file_size.sh fails on pristine origin/main (97e843f62) — 16 files over the 2000-line cap, none touched here, byte-identical to main, script unmodified. lint is red on main independently of this branch.

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
docs/src/internals/memory-model.md (1)

139-140: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Correct the caveat count.

The lead-in says "Two caveats", but the list contains four bullets: the copying-minor gating (Line 142), the depth guidance (Line 145), the loop-poll requirement (Line 154), and the Unix-only page protection (Line 158). Use a count-free lead-in so later additions cannot make it wrong again.

📝 Proposed wording
-Two caveats these instruments are explicit about, because both have burned
-prior investigations:
+Caveats these instruments are explicit about, because each has burned prior
+investigations:
🤖 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 `@docs/src/internals/memory-model.md` around lines 139 - 140, Update the
lead-in before the caveat list to remove the hard-coded “Two” count and use
count-free wording, leaving the four caveat bullets unchanged.
🧹 Nitpick comments (1)
CLAUDE.md (1)

139-146: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider trimming the knob table and pointing to the docs page.

Lines 141-143 restate docs/src/internals/memory-model.md Lines 133-162 and the changelog fragment nearly in full. Three copies of the same knob semantics will drift. Keep the "gates EXACTLY / does NOT" rows short here and link to the memory-model section for the long form.

As per coding guidelines: "Keep CLAUDE.md concise; put detailed change history in changelog.d/ fragments".

🤖 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 `@CLAUDE.md` around lines 139 - 146, Trim the knob table in CLAUDE.md to
concise “gates EXACTLY / does NOT” summaries for PERRY_GC_PROTECT_FROMSPACE,
PERRY_GC_PROTECT_FROMSPACE_DEPTH, PERRY_GC_ZEAL, and
PERRY_GC_FROMSPACE_SCAN_ABORT. Remove the duplicated implementation details and
changelog/reproducer history, and add a link to the relevant
docs/src/internals/memory-model.md section for the long-form semantics.

Source: Coding guidelines

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

Outside diff comments:
In `@docs/src/internals/memory-model.md`:
- Around line 139-140: Update the lead-in before the caveat list to remove the
hard-coded “Two” count and use count-free wording, leaving the four caveat
bullets unchanged.

---

Nitpick comments:
In `@CLAUDE.md`:
- Around line 139-146: Trim the knob table in CLAUDE.md to concise “gates
EXACTLY / does NOT” summaries for PERRY_GC_PROTECT_FROMSPACE,
PERRY_GC_PROTECT_FROMSPACE_DEPTH, PERRY_GC_ZEAL, and
PERRY_GC_FROMSPACE_SCAN_ABORT. Remove the duplicated implementation details and
changelog/reproducer history, and add a link to the relevant
docs/src/internals/memory-model.md section for the long-form semantics.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 2f983474-4699-4acd-86ad-bf52c58bfde2

📥 Commits

Reviewing files that changed from the base of the PR and between 2f659c6 and 4e319a6.

📒 Files selected for processing (9)
  • .github/workflows/test.yml
  • CLAUDE.md
  • changelog.d/7196-gc-rooting-bug-instruments.md
  • crates/perry-runtime/src/arena/mod.rs
  • crates/perry-runtime/src/arena/quarantine.rs
  • crates/perry-runtime/src/gc/tests/fromspace_protect.rs
  • crates/perry-runtime/src/gc/tests/mod.rs
  • docs/src/internals/memory-model.md
  • scripts/gc_instrument_smoke.sh
🚧 Files skipped from review as they are similar to previous changes (2)
  • crates/perry-runtime/src/gc/tests/mod.rs
  • crates/perry-runtime/src/arena/mod.rs

…es for PerryTS#7154

Squashed: from-space quarantine + mprotect reporter, GC zeal at safepoints, and verify-roots gap closures (SCAN_ABORT now actually runs). All knobs default-off. See PR description for the detection-latency evidence.
@jdalton
jdalton force-pushed the feat/gc-debug-instruments branch from 4e319a6 to 846ef23 Compare August 1, 2026 18:13

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Cargo.toml`:
- Line 295: Restore the [workspace.package] version in Cargo.toml to 0.5.1278,
leaving release/version metadata unchanged otherwise.
🪄 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: f1cdb427-b753-4060-ac36-17f3539c8624

📥 Commits

Reviewing files that changed from the base of the PR and between 4e319a6 and 846ef23.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (16)
  • .github/workflows/test.yml
  • CLAUDE.md
  • Cargo.toml
  • changelog.d/7196-gc-rooting-bug-instruments.md
  • crates/perry-runtime/src/arena/mod.rs
  • crates/perry-runtime/src/arena/quarantine.rs
  • crates/perry-runtime/src/arena/reset.rs
  • crates/perry-runtime/src/gc/fromspace_scan.rs
  • crates/perry-runtime/src/gc/mod.rs
  • crates/perry-runtime/src/gc/policy.rs
  • crates/perry-runtime/src/gc/tests/fromspace_protect.rs
  • crates/perry-runtime/src/gc/tests/mod.rs
  • crates/perry-runtime/src/gc/zeal.rs
  • docs/src/internals/memory-model.md
  • scripts/addr_class_allowlist.txt
  • scripts/gc_instrument_smoke.sh
🚧 Files skipped from review as they are similar to previous changes (13)
  • scripts/addr_class_allowlist.txt
  • crates/perry-runtime/src/gc/tests/mod.rs
  • .github/workflows/test.yml
  • CLAUDE.md
  • crates/perry-runtime/src/gc/policy.rs
  • crates/perry-runtime/src/arena/mod.rs
  • crates/perry-runtime/src/gc/zeal.rs
  • crates/perry-runtime/src/gc/tests/fromspace_protect.rs
  • crates/perry-runtime/src/arena/reset.rs
  • crates/perry-runtime/src/gc/mod.rs
  • docs/src/internals/memory-model.md
  • crates/perry-runtime/src/gc/fromspace_scan.rs
  • crates/perry-runtime/src/arena/quarantine.rs

Comment thread Cargo.toml
@proggeramlug

Copy link
Copy Markdown
Contributor

The three deferred gates, run on the idle mini (darwin-arm64, the gc-ratchet pinned-baseline host)

Built from this PR head (4e319a6c4) in its own worktree + isolated target dir. Oracle node 26.5.1 matches .node-version exactly.

1. Matrix --arms all --pressure 8 — EXONERATED by in-place A/B

Branch arm: PASS=296 UNVER=242 XFAIL=1 FAIL=70.

FAIL=70 is the number #7198 identified as the contended signature, so I did not trust it. I reverted only this PR's nine runtime files to origin/main in the same worktree and same target dir (true in-place A/B — same host, same package set, only the collector differing), rebuilt, and re-ran:

arm summary
this PR PASS=296 UNVER=242 XFAIL=1 FAIL=70
origin/main collector PASS=296 UNVER=242 XFAIL=1 FAIL=70

Byte-identical — and the failing sets are identical too, not just the counts:

10  test_gap_repsel_pshape_tower_delete        10  test_gap_repsel_p4a_logical_numeric
10  test_gap_repsel_p4b_field_store_elision    10  test_gap_repsel_p4a_inline_tiers
10  test_gap_repsel_p4a3_ptr_numarray          10  test_gap_repsel_module_init_canonical
10  test_gap_repsel_loop_bounded_i32            1  test_gap_repsel_ptr_shape_locals

#7196 causes none of them. Worth noting for #7194: the red is broader than that issue records. #7194 documents test_gap_repsel_p4a3_ptr_numarray across ten requires=move arms; in fact seven tests fail that way, all with evacuated=0. Same family, 7× the surface.

2. gc-ratchet — red for a demonstrably main-side reason, and I can prove the cause

Both profiles fail: check --profile pinned_host → exit 1, --profile shared_ci → exit 1, 86 gated regressions (heap_used_bytes +5371%, minor_cycles 80 → 0, copied_objects 18,294 → 0, rss +275%).

Every regression is an evacuation counter going to zero. Rather than assume, I re-measured this PR's own binary with PERRY_GC_MOVING_LOOP_POLLS=1 — the pre-#7161 default:

probe baseline current (default) with POLLS=1
01_nursery_churn 14 0 14
02_survivor_promotion 10 0 10
03_cross_gen_writes 22 0 22
04_dead_after_deep_stack 104 0 104
05_closure_capture 26 0 26
06_string_retention 64 0 64
07_array_grow_evacuate 80 0 80
08_map_set_sidetables 84 0 84

minor_cycles reproduces the baseline exactly on this PR's build, and copied_objects to within ~1% (allocation-order noise). The pinned baseline was captured at 88dcee83b, 2026-07-30, when loop polls were still on by default; #7161's flip is the entire delta. This is #7205's territory, not a verdict on #7196 — and per that issue the ratchet has not executed on main for three merges, so I am flagging rather than claiming a pass. The baseline needs re-pinning before the ratchet can gate anything again.

That table doubles as the non-vacuity evidence the instruments-off case needs: with the knobs unset and polls restored, this PR's collector reproduces the baseline collector's evacuation counters exactly.

3. Full runtime suite, both scan modes — clean

PERRY_CONSERVATIVE_STACK_SCAN result failures
0 (production / disabled) 1626 passed, 3 failed teardown::map_set_owner_records_follow_growth, teardown::map_set_side_allocations_release_exactly_once, webassembly::namespace_members_exist_with_expected_shapes
1 (Full) 1628 passed, 1 failed webassembly::namespace_members_exist_with_expected_shapes

All are the documented order-flaky pool, and all three pass in isolation (verified individually with --test-threads=1). Different subsets fail in the two modes, which is the signature of ordering rather than of either scan mode.

Verdict

All three gates are clean with respect to this PR. The two that are red are red on origin/main for reasons proven main-side — one by in-place A/B, one by reproducing the baseline exactly once the pre-#7161 default is restored.

@proggeramlug
proggeramlug merged commit 9117097 into PerryTS:main Aug 1, 2026
7 of 9 checks passed
proggeramlug pushed a commit to jdalton/perry that referenced this pull request Aug 1, 2026
…riant writeup

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

- CLAUDE.md called `lower_call/new.rs`'s inline-ctor `this_slot` "still
  open". PerryTS#7207 closed it. Point at `--unrooted-allocas` as the detector for
  that shape and name PerryTS#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 yet 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.
jdalton added a commit to jdalton/perry that referenced this pull request Aug 1, 2026
… allowlist fingerprint; restore the workspace version

Follow-ups to the review threads on PerryTS#7196 and PerryTS#7212, both of which merged
before the findings were worked through.

Cargo.toml — PerryTS#7196 set `[workspace.package].version` back to 0.5.1277,
undoing the 0.5.1278 bump PerryTS#7199 had landed four commits earlier. Nothing
since has touched the line, so main is currently shipping a version number
it already released. Restored.

docs/src/internals/gc-rooting-invariant.md — the checker description read
"reports any root store that does not dominate a preceding collection
point", which is vacuous: a store can never dominate anything that precedes
it, so the sentence is true of every store in the program. What the checker
actually reports is the collection point — `window_hits(origin, bind)`
collects the calls that can run between the instruction producing a GC value
and the `js_shadow_slot_bind` that publishes it. Reworded to name the
collection point as the reported object and to keep the true relation (the
root store must dominate it), which is the rule stated at the top of the same
page. Also noted that the gate command shown does not pass
`--stale-registers`, so cases 3 and 4 only surface when it is run by hand.

CLAUDE.md — the shape summary claimed all three failures present as "a
rooted slot holding a dangling pointer", contradicting its own two preceding
clauses: PerryTS#7184's bind is a silent no-op so nothing is bound, and the
`alloca_entry` shape is never a slot at all. Split per shape. It also said
the class "only bites under PERRY_GC_MOVING_LOOP_POLLS=1"; the checker's
own POLL_CAPABLE_RUNTIME set treats js_object_set_field_by_name,
js_object_get_property and js_call_function as moving-capable with no poll
involved, and PerryTS#7211's allowlist entry is exactly such a window. Polls widen
in-loop coverage; they are not a precondition.

docs/src/internals/rfc-rooting-by-construction.md — `Plain(String)` cannot
be `Copy`; the migration section costed it as if it were. It is `Clone`,
which still imposes nothing on the caller. Added the emitter/frame branding
gap to "What it cannot catch": PhantomData<&'e Emitter> records a lifetime,
not an instance, and `Rooted` carries a bare SlotIdx, so the design as
written catches ordering mistakes but not provenance ones.

scripts/gc_root_dominance_allowlist.json — the fingerprint format line said
"<first collector>". It is `sorted(set(callees))[0]`, the alphabetically
first, not the first in program order. A hand-derived entry that guesses
program order matches nothing, and an entry that matches nothing fails the
build — so the misleading line pointed straight at a red gate.

scripts/gc_root_dominance_check.py, three surgical changes:
  * the self-test failure text described sinking the root store; `_mutate`
    splices _SEED_CALL above the store and moves nothing.
  * `--stale-registers` returned before the `--unrooted-allocas` block, so
    passing both ran one pass and silently skipped the other. Now an
    argparse error, matching the --max-stale and --fatal-sinks guards
    directly above it.
  * the stale-allowlist-entry report returned 2 before the uncovered-
    violation report could run. Fix one violation and introduce another in
    the same PR and the log showed only the bookkeeping problem. Both
    reports now print; the exit code is unchanged (2 when an entry is
    stale, 1 when only uncovered violations remain).

Verified: `--self-test` OK. Against the parent, a corpus with one stale
entry and two uncovered violations printed only the stale entry and hid
both violations; it now prints all three and still exits 2.

Refs PerryTS#7196, PerryTS#7212, PerryTS#7199, PerryTS#7211.
jdalton added a commit to jdalton/perry that referenced this pull request Aug 1, 2026
main was at 0.5.1278 and regressed to 0.5.1277 when PerryTS#7196 merged from a
stale base. Five commits have landed since without catching it, so main
has been carrying a patch version it already shipped.

Bumped to 0.5.1279 — the next unused patch above the old high-water
mark — in Cargo.toml, Cargo.lock and the Current Version line.
jdalton added a commit to jdalton/perry that referenced this pull request Aug 1, 2026
… allowlist fingerprint; restore the workspace version

Follow-ups to the review threads on PerryTS#7196 and PerryTS#7212, both of which merged
before the findings were worked through.

Cargo.toml — PerryTS#7196 set `[workspace.package].version` back to 0.5.1277,
undoing the 0.5.1278 bump PerryTS#7199 had landed four commits earlier. Nothing
since has touched the line, so main is currently shipping a version number
it already released. Restored.

docs/src/internals/gc-rooting-invariant.md — the checker description read
"reports any root store that does not dominate a preceding collection
point", which is vacuous: a store can never dominate anything that precedes
it, so the sentence is true of every store in the program. What the checker
actually reports is the collection point — `window_hits(origin, bind)`
collects the calls that can run between the instruction producing a GC value
and the `js_shadow_slot_bind` that publishes it. Reworded to name the
collection point as the reported object and to keep the true relation (the
root store must dominate it), which is the rule stated at the top of the same
page. Also noted that the gate command shown does not pass
`--stale-registers`, so cases 3 and 4 only surface when it is run by hand.

CLAUDE.md — the shape summary claimed all three failures present as "a
rooted slot holding a dangling pointer", contradicting its own two preceding
clauses: PerryTS#7184's bind is a silent no-op so nothing is bound, and the
`alloca_entry` shape is never a slot at all. Split per shape. It also said
the class "only bites under PERRY_GC_MOVING_LOOP_POLLS=1"; the checker's
own POLL_CAPABLE_RUNTIME set treats js_object_set_field_by_name,
js_object_get_property and js_call_function as moving-capable with no poll
involved, and PerryTS#7211's allowlist entry is exactly such a window. Polls widen
in-loop coverage; they are not a precondition.

docs/src/internals/rfc-rooting-by-construction.md — `Plain(String)` cannot
be `Copy`; the migration section costed it as if it were. It is `Clone`,
which still imposes nothing on the caller. Added the emitter/frame branding
gap to "What it cannot catch": PhantomData<&'e Emitter> records a lifetime,
not an instance, and `Rooted` carries a bare SlotIdx, so the design as
written catches ordering mistakes but not provenance ones.

scripts/gc_root_dominance_allowlist.json — the fingerprint format line said
"<first collector>". It is `sorted(set(callees))[0]`, the alphabetically
first, not the first in program order. A hand-derived entry that guesses
program order matches nothing, and an entry that matches nothing fails the
build — so the misleading line pointed straight at a red gate.

scripts/gc_root_dominance_check.py, three surgical changes:
  * the self-test failure text described sinking the root store; `_mutate`
    splices _SEED_CALL above the store and moves nothing.
  * `--stale-registers` returned before the `--unrooted-allocas` block, so
    passing both ran one pass and silently skipped the other. Now an
    argparse error, matching the --max-stale and --fatal-sinks guards
    directly above it.
  * the stale-allowlist-entry report returned 2 before the uncovered-
    violation report could run. Fix one violation and introduce another in
    the same PR and the log showed only the bookkeeping problem. Both
    reports now print; the exit code is unchanged (2 when an entry is
    stale, 1 when only uncovered violations remain).

Verified: `--self-test` OK. Against the parent, a corpus with one stale
entry and two uncovered violations printed only the stale entry and hid
both violations; it now prints all three and still exits 2.

Refs PerryTS#7196, PerryTS#7212, PerryTS#7199, PerryTS#7211.
jdalton added a commit to jdalton/perry that referenced this pull request Aug 2, 2026
… allowlist fingerprint; restore the workspace version

Follow-ups to the review threads on PerryTS#7196 and PerryTS#7212, both of which merged
before the findings were worked through.

Cargo.toml — PerryTS#7196 set `[workspace.package].version` back to 0.5.1277,
undoing the 0.5.1278 bump PerryTS#7199 had landed four commits earlier. Nothing
since has touched the line, so main is currently shipping a version number
it already released. Restored.

docs/src/internals/gc-rooting-invariant.md — the checker description read
"reports any root store that does not dominate a preceding collection
point", which is vacuous: a store can never dominate anything that precedes
it, so the sentence is true of every store in the program. What the checker
actually reports is the collection point — `window_hits(origin, bind)`
collects the calls that can run between the instruction producing a GC value
and the `js_shadow_slot_bind` that publishes it. Reworded to name the
collection point as the reported object and to keep the true relation (the
root store must dominate it), which is the rule stated at the top of the same
page. Also noted that the gate command shown does not pass
`--stale-registers`, so cases 3 and 4 only surface when it is run by hand.

CLAUDE.md — the shape summary claimed all three failures present as "a
rooted slot holding a dangling pointer", contradicting its own two preceding
clauses: PerryTS#7184's bind is a silent no-op so nothing is bound, and the
`alloca_entry` shape is never a slot at all. Split per shape. It also said
the class "only bites under PERRY_GC_MOVING_LOOP_POLLS=1"; the checker's
own POLL_CAPABLE_RUNTIME set treats js_object_set_field_by_name,
js_object_get_property and js_call_function as moving-capable with no poll
involved, and PerryTS#7211's allowlist entry is exactly such a window. Polls widen
in-loop coverage; they are not a precondition.

docs/src/internals/rfc-rooting-by-construction.md — `Plain(String)` cannot
be `Copy`; the migration section costed it as if it were. It is `Clone`,
which still imposes nothing on the caller. Added the emitter/frame branding
gap to "What it cannot catch": PhantomData<&'e Emitter> records a lifetime,
not an instance, and `Rooted` carries a bare SlotIdx, so the design as
written catches ordering mistakes but not provenance ones.

scripts/gc_root_dominance_allowlist.json — the fingerprint format line said
"<first collector>". It is `sorted(set(callees))[0]`, the alphabetically
first, not the first in program order. A hand-derived entry that guesses
program order matches nothing, and an entry that matches nothing fails the
build — so the misleading line pointed straight at a red gate.

scripts/gc_root_dominance_check.py, three surgical changes:
  * the self-test failure text described sinking the root store; `_mutate`
    splices _SEED_CALL above the store and moves nothing.
  * `--stale-registers` returned before the `--unrooted-allocas` block, so
    passing both ran one pass and silently skipped the other. Now an
    argparse error, matching the --max-stale and --fatal-sinks guards
    directly above it.
  * the stale-allowlist-entry report returned 2 before the uncovered-
    violation report could run. Fix one violation and introduce another in
    the same PR and the log showed only the bookkeeping problem. Both
    reports now print; the exit code is unchanged (2 when an entry is
    stale, 1 when only uncovered violations remain).

Verified: `--self-test` OK. Against the parent, a corpus with one stale
entry and two uncovered violations printed only the stale entry and hid
both violations; it now prints all three and still exits 2.

Refs PerryTS#7196, PerryTS#7212, PerryTS#7199, PerryTS#7211.
jdalton added a commit to jdalton/perry that referenced this pull request Aug 2, 2026
… allowlist fingerprint; restore the workspace version

Follow-ups to the review threads on PerryTS#7196 and PerryTS#7212, both of which merged
before the findings were worked through.

Cargo.toml — PerryTS#7196 set `[workspace.package].version` back to 0.5.1277,
undoing the 0.5.1278 bump PerryTS#7199 had landed four commits earlier. Nothing
since has touched the line, so main is currently shipping a version number
it already released. Restored.

docs/src/internals/gc-rooting-invariant.md — the checker description read
"reports any root store that does not dominate a preceding collection
point", which is vacuous: a store can never dominate anything that precedes
it, so the sentence is true of every store in the program. What the checker
actually reports is the collection point — `window_hits(origin, bind)`
collects the calls that can run between the instruction producing a GC value
and the `js_shadow_slot_bind` that publishes it. Reworded to name the
collection point as the reported object and to keep the true relation (the
root store must dominate it), which is the rule stated at the top of the same
page. Also noted that the gate command shown does not pass
`--stale-registers`, so cases 3 and 4 only surface when it is run by hand.

CLAUDE.md — the shape summary claimed all three failures present as "a
rooted slot holding a dangling pointer", contradicting its own two preceding
clauses: PerryTS#7184's bind is a silent no-op so nothing is bound, and the
`alloca_entry` shape is never a slot at all. Split per shape. It also said
the class "only bites under PERRY_GC_MOVING_LOOP_POLLS=1"; the checker's
own POLL_CAPABLE_RUNTIME set treats js_object_set_field_by_name,
js_object_get_property and js_call_function as moving-capable with no poll
involved, and PerryTS#7211's allowlist entry is exactly such a window. Polls widen
in-loop coverage; they are not a precondition.

docs/src/internals/rfc-rooting-by-construction.md — `Plain(String)` cannot
be `Copy`; the migration section costed it as if it were. It is `Clone`,
which still imposes nothing on the caller. Added the emitter/frame branding
gap to "What it cannot catch": PhantomData<&'e Emitter> records a lifetime,
not an instance, and `Rooted` carries a bare SlotIdx, so the design as
written catches ordering mistakes but not provenance ones.

scripts/gc_root_dominance_allowlist.json — the fingerprint format line said
"<first collector>". It is `sorted(set(callees))[0]`, the alphabetically
first, not the first in program order. A hand-derived entry that guesses
program order matches nothing, and an entry that matches nothing fails the
build — so the misleading line pointed straight at a red gate.

scripts/gc_root_dominance_check.py, three surgical changes:
  * the self-test failure text described sinking the root store; `_mutate`
    splices _SEED_CALL above the store and moves nothing.
  * `--stale-registers` returned before the `--unrooted-allocas` block, so
    passing both ran one pass and silently skipped the other. Now an
    argparse error, matching the --max-stale and --fatal-sinks guards
    directly above it.
  * the stale-allowlist-entry report returned 2 before the uncovered-
    violation report could run. Fix one violation and introduce another in
    the same PR and the log showed only the bookkeeping problem. Both
    reports now print; the exit code is unchanged (2 when an entry is
    stale, 1 when only uncovered violations remain).

Verified: `--self-test` OK. Against the parent, a corpus with one stale
entry and two uncovered violations printed only the stale entry and hid
both violations; it now prints all three and still exits 2.

Refs PerryTS#7196, PerryTS#7212, PerryTS#7199, PerryTS#7211.
proggeramlug pushed a commit that referenced this pull request Aug 2, 2026
main was at 0.5.1278 and regressed to 0.5.1277 when #7196 merged from a
stale base. Five commits have landed since without catching it, so main
has been carrying a patch version it already shipped.

Bumped to 0.5.1279 — the next unused patch above the old high-water
mark — in Cargo.toml, Cargo.lock and the Current Version line.
proggeramlug pushed a commit that referenced this pull request Aug 2, 2026
… allowlist fingerprint; restore the workspace version (#7224)

Follow-ups to the review threads on #7196 and #7212, both of which merged
before the findings were worked through.

Cargo.toml — #7196 set `[workspace.package].version` back to 0.5.1277,
undoing the 0.5.1278 bump #7199 had landed four commits earlier. Nothing
since has touched the line, so main is currently shipping a version number
it already released. Restored.

docs/src/internals/gc-rooting-invariant.md — the checker description read
"reports any root store that does not dominate a preceding collection
point", which is vacuous: a store can never dominate anything that precedes
it, so the sentence is true of every store in the program. What the checker
actually reports is the collection point — `window_hits(origin, bind)`
collects the calls that can run between the instruction producing a GC value
and the `js_shadow_slot_bind` that publishes it. Reworded to name the
collection point as the reported object and to keep the true relation (the
root store must dominate it), which is the rule stated at the top of the same
page. Also noted that the gate command shown does not pass
`--stale-registers`, so cases 3 and 4 only surface when it is run by hand.

CLAUDE.md — the shape summary claimed all three failures present as "a
rooted slot holding a dangling pointer", contradicting its own two preceding
clauses: #7184's bind is a silent no-op so nothing is bound, and the
`alloca_entry` shape is never a slot at all. Split per shape. It also said
the class "only bites under PERRY_GC_MOVING_LOOP_POLLS=1"; the checker's
own POLL_CAPABLE_RUNTIME set treats js_object_set_field_by_name,
js_object_get_property and js_call_function as moving-capable with no poll
involved, and #7211's allowlist entry is exactly such a window. Polls widen
in-loop coverage; they are not a precondition.

docs/src/internals/rfc-rooting-by-construction.md — `Plain(String)` cannot
be `Copy`; the migration section costed it as if it were. It is `Clone`,
which still imposes nothing on the caller. Added the emitter/frame branding
gap to "What it cannot catch": PhantomData<&'e Emitter> records a lifetime,
not an instance, and `Rooted` carries a bare SlotIdx, so the design as
written catches ordering mistakes but not provenance ones.

scripts/gc_root_dominance_allowlist.json — the fingerprint format line said
"<first collector>". It is `sorted(set(callees))[0]`, the alphabetically
first, not the first in program order. A hand-derived entry that guesses
program order matches nothing, and an entry that matches nothing fails the
build — so the misleading line pointed straight at a red gate.

scripts/gc_root_dominance_check.py, three surgical changes:
  * the self-test failure text described sinking the root store; `_mutate`
    splices _SEED_CALL above the store and moves nothing.
  * `--stale-registers` returned before the `--unrooted-allocas` block, so
    passing both ran one pass and silently skipped the other. Now an
    argparse error, matching the --max-stale and --fatal-sinks guards
    directly above it.
  * the stale-allowlist-entry report returned 2 before the uncovered-
    violation report could run. Fix one violation and introduce another in
    the same PR and the log showed only the bookkeeping problem. Both
    reports now print; the exit code is unchanged (2 when an entry is
    stale, 1 when only uncovered violations remain).

Verified: `--self-test` OK. Against the parent, a corpus with one stale
entry and two uncovered violations printed only the stale entry and hid
both violations; it now prints all three and still exits 2.

Refs #7196, #7212, #7199, #7211.
proggeramlug pushed a commit that referenced this pull request Aug 2, 2026
… saved implicit `this` (#7226)

* fix(gc): root the interned typeof strings, the rawJSON key, and every saved implicit `this`

Three unrooted-value bugs behind #7154's registry crash, found by pointing
#7196's from-space reporter at `sfw-registry --help` rather than by grinding
the static checker's tail.

1. RUNTIME CACHES (the one that mattered). `js_value_typeof` interned its eight
   result strings in thread-local `Cell<*mut StringHeader>`s that nothing
   registered as GC roots, so the FIRST minor collection swept or evacuated
   them and every later `typeof x === "..."` compared against from-space.
   `json/raw_json.rs`'s cached `"rawJSON"` key had the identical defect. Both
   now go through `gc_register_mutable_root_scanner`.

   This is why the registry failed 10/10 rather than intermittently: an
   unrooted register goes bad only when a collection lands in its window, an
   unrooted cache goes bad at collection #0 and stays bad. It is also
   structurally invisible to `scripts/gc_root_dominance_check.py`, which reads
   emitted LLVM IR and cannot see a runtime table.

2. CODEGEN, implicit `this`. `js_implicit_this_set` returns the value it
   displaced from the `IMPLICIT_THIS` cell -- a scanned MUTABLE root -- and
   that value was then held in a bare SSA register across the whole call the
   bind exists to scope. The restore published a pre-move address back INTO a
   root. #7214 found this in `js_closure_callN` and left it; it was in fact
   seven lowerings with seven copies of the same three lines, now one shared
   `temp_root::implicit_this_save` / `implicit_this_restore` pair.

3. CODEGEN, ClassExprFresh (#7211). Its `protect_handle` predicate asked only
   whether the AUTHOR's static initializers could collect, never whether the
   `js_object_set_field_by_name` the lowering itself emits per static could --
   and that allocates. The four allowlist entries it covered are deleted,
   which is the ratchet working: the fix made them match nothing.

Checker: `js_implicit_this_set` is now a root READ (being non-collecting is
what makes a call one), which takes its sink from 214 hits to 0 and keeps the
class gated. `--stale-registers` now honours `--min-binds`, and `--any-def`
with it is a usage error -- both the "a silently ignored knob is a disarmed
knob" rule the mode already applied to `--max-stale` and `--fatal-sinks`.

Refs #7154, #7211, #7213, #7196, #7206, #7214, #7161

* fix(ci): apply the --min-funcs breadth floor in the stale-register and unrooted-alloca modes

Both modes return before the floor was evaluated, so
`--stale-registers --min-funcs 1200` and `--unrooted-allocas --min-funcs 1200`
ran to a verdict over a corpus too thin to have exercised anything and exited 0.
A documented liveness control silently disabled by a mode flag, in the script
whose job is to catch gates that cannot fail.

Reproduced on the self-test fixture (2 functions): stale and alloca modes both
exited 0 at --min-funcs 50 while the bind-anchored default correctly exited 2.
After the fix all three exit 2.

n_funcs is computed above the mode branches and the message lives in one
helper. self_test() gains a failing and a passing arm for each of the three
modes, so neither a skipped floor nor an always-on one can regress silently.

---------

Co-authored-by: Ralph Küpper <ralph@skelpo.com>
proggeramlug pushed a commit that referenced this pull request Aug 2, 2026
… against the runtime's real symbol table (#7227)

* fix(gc): root the interned typeof strings, the rawJSON key, and every saved implicit `this`

Three unrooted-value bugs behind #7154's registry crash, found by pointing
#7196's from-space reporter at `sfw-registry --help` rather than by grinding
the static checker's tail.

1. RUNTIME CACHES (the one that mattered). `js_value_typeof` interned its eight
   result strings in thread-local `Cell<*mut StringHeader>`s that nothing
   registered as GC roots, so the FIRST minor collection swept or evacuated
   them and every later `typeof x === "..."` compared against from-space.
   `json/raw_json.rs`'s cached `"rawJSON"` key had the identical defect. Both
   now go through `gc_register_mutable_root_scanner`.

   This is why the registry failed 10/10 rather than intermittently: an
   unrooted register goes bad only when a collection lands in its window, an
   unrooted cache goes bad at collection #0 and stays bad. It is also
   structurally invisible to `scripts/gc_root_dominance_check.py`, which reads
   emitted LLVM IR and cannot see a runtime table.

2. CODEGEN, implicit `this`. `js_implicit_this_set` returns the value it
   displaced from the `IMPLICIT_THIS` cell -- a scanned MUTABLE root -- and
   that value was then held in a bare SSA register across the whole call the
   bind exists to scope. The restore published a pre-move address back INTO a
   root. #7214 found this in `js_closure_callN` and left it; it was in fact
   seven lowerings with seven copies of the same three lines, now one shared
   `temp_root::implicit_this_save` / `implicit_this_restore` pair.

3. CODEGEN, ClassExprFresh (#7211). Its `protect_handle` predicate asked only
   whether the AUTHOR's static initializers could collect, never whether the
   `js_object_set_field_by_name` the lowering itself emits per static could --
   and that allocates. The four allowlist entries it covered are deleted,
   which is the ratchet working: the fix made them match nothing.

Checker: `js_implicit_this_set` is now a root READ (being non-collecting is
what makes a call one), which takes its sink from 214 hits to 0 and keeps the
class gated. `--stale-registers` now honours `--min-binds`, and `--any-def`
with it is a usage error -- both the "a silently ignored knob is a disarmed
knob" rule the mode already applied to `--max-stale` and `--fatal-sinks`.

Refs #7154, #7211, #7213, #7196, #7206, #7214, #7161

* fix(gc): root the regexp receiver across ToString, and audit ALLOC_RE

`Expr::RegExpTest` / `Expr::RegExpExec` unboxed the receiver to a raw
`RegExpHeader*` before emitting `js_jsvalue_to_string_coerce`, which
allocates and dispatches a user `toString`. Under a moving minor the
register named from-space by the time `js_regexp_test` dereferenced it —
#7154's residual, at `defineApiCall + 404` in the registry. Both take the
established `guard_store_operand_across` / `reread_store_operand` pair and
the unbox moves below the coerce.

The checker missed it because `ALLOC_RE` spelled the allocator
`regexp_alloc\w*` and the call is `js_regexp_new`. Reconciling every
alternative against the runtime's real symbol table found four that match
nothing at all (`regexp_alloc`, `promise_alloc`, `bigint_alloc`,
`typed_array_alloc`): the runtime materializes fresh GC objects under three
naming conventions (`_alloc*`, `_new*`, `_create*`) and the pattern modelled
one. All three are now matched as conventions, with the non-conforming
constructors enumerated explicitly.

Second blind spot, and the one that matters for CI: the ToPrimitive family
was not `POLL_CAPABLE_RUNTIME`, so even with `ALLOC_RE` widened the site was
invisible to `--moving-only`, which is the mode the gate runs.

Refs #7154, #7226, #7211, #7161

* fix(ci): apply the --min-funcs breadth floor in the stale-register and unrooted-alloca modes

Both modes return before the floor was evaluated, so
`--stale-registers --min-funcs 1200` and `--unrooted-allocas --min-funcs 1200`
ran to a verdict over a corpus too thin to have exercised anything and exited 0.
A documented liveness control silently disabled by a mode flag, in the script
whose job is to catch gates that cannot fail.

Reproduced on the self-test fixture (2 functions): stale and alloca modes both
exited 0 at --min-funcs 50 while the bind-anchored default correctly exited 2.
After the fix all three exit 2.

n_funcs is computed above the mode branches and the message lives in one
helper. self_test() gains a failing and a passing arm for each of the three
modes, so neither a skipped floor nor an always-on one can regress silently.

* ci(gc): gate ALLOC_RE's alternatives against the runtime's real symbol table

The audit this PR did by hand found four alternatives matching nothing. Running
the same reconciliation against the result found FIVE more that the audit itself
introduced or left: `array_of`, `array_group_by`, `bigint_\w+_op`,
`typed_array_from\w*`, `array_buffer_slice`. That is the argument for
automating it rather than repeating it — a prose audit does not survive its own
next edit.

`--audit-alloc-re` decomposes ALLOC_RE into its alternatives and requires each
to match at least one `extern "C" fn js_*` exported by perry-runtime or
perry-stdlib. It refuses to render a verdict over a symbol scan that found
implausibly few symbols, so it cannot pass by measuring itself, and it raises
rather than silently checking zero alternatives if ALLOC_RE is rewritten into a
shape it cannot decompose. Wired into gc-root-dominance.yml next to --self-test:
static, no toolchain, no corpus.

Verified both directions: green on the cleaned pattern (70 alternatives vs 3775
symbols), exit 2 with `regexp_alloc\w*` planted back in. self_test() gains
arms for detection and for the absence of false positives.

The five dead alternatives are deleted, which changes nothing about what the
checker matches — that is what "matches no symbol" means. BigInt arithmetic
(`js_bigint_add` and friends) allocates and is genuinely uncovered; widening
for it is a coverage decision with its own false positives and is left to a
change that can measure the new hits.

* test(gc): register the regexp witness, and triage its allocation-point reds

test_gap_gc_regexp_receiver_rooting.ts was added without a corpus entry, so
nothing ran it. Registered, which makes it a hard gate on `loop_polls` -- the
route this PR's fix is verified on: `bad 0` 8/8, byte-exact against the oracle,
with the copying minor live.

It is red on all TEN allocation-point arms (exit=139, deterministic, identical
evidence on every one). That route forces the collection inside the allocating
helper rather than at a loop safepoint, and this PR claims no fix for it. Triaged
to #7217 with the measurements rather than left to turn gc-stress red.

Second file with this exact signature -- #7216's assign_string_source witness is
triaged to #7217 for the same reason. Two independent sites where a rooting fix
that holds at a safepoint does not hold at the allocation point says something
about the route, not about either fix.

gc-stress verified green with the registration: --arms pr FAIL=0 XFAIL=2.

---------

Co-authored-by: Ralph Küpper <ralph@skelpo.com>
proggeramlug pushed a commit that referenced this pull request Aug 2, 2026
…hat walked 1 of 3 sibling slots

The #7231 enumeration, verified and closed. This is #7226's class -- a runtime
table holding a GC pointer that is not a registered root -- which is strictly
worse than #7154's stale-register class (it goes bad at collection #0 and stays
bad, rather than needing a collection to land in a narrow window) and which no
static instrument can find: `gc_root_dominance_check.py` reads emitted LLVM IR,
and a runtime table is not in it.

★ `CACHED_ENV`, the `process.env` object, is the load-bearing one and it is a
hard crash rather than a subtle wrong answer. `js_process_env_impl` builds it
once with `js_object_alloc` -- the NURSERY -- and caches it in a thread-local
`Cell<f64>` that is the ENTIRE reference graph: `process.env` is a
`js_process_env()` call, not a field of the `process` object. So the first
minor swept or evacuated it and every later `process.env.X = v` wrote through a
dangling pointer. The sibling `PROCESS_FINALIZATION_OBJECT` uses the identical
materialize-once idiom and was already rooted, which is what makes this an
omission rather than a design.

The observable is ENUMERATION -- `Object.keys(process.env)`, `for…in`, spread,
which is how `@next/env` and `dotenv` consume it. A direct `process.env.KEY`
read lowers to `js_getenv` and asks the OS, so a witness built on the read
would be a gate that cannot fail.

Also rooted: `CACHED_PERMISSION` and `CACHED_REPORT` (same shape; the
`runtime_write_barrier_root_nanbox` beside the first is an incremental MARK
barrier, not a root registration, and is now labelled as such);
`ERROR_CONSTRUCTOR_PTR` (a raw duplicate of a `globalThis` closure, outside the
object graph, stale after a move); `INPUT_HANDLER` (the inline `useInput`
arrow, which nothing else refers to); `RESIZE_CALLBACK` (a native slot that
bypasses the rooted EventEmitter listener array); `FRAME_CALLBACKS` (rooted
only transiently during registration -- its `unsafe impl Send` SAFETY comment
asserted the opposite and is corrected in place); `CURRENT_NEW_TARGET`;
`ACCESSOR_RECEIVER_OVERRIDE`; and `PENDING_FETCH_SIGNAL`.

Scanner gap, the shape #7230 found twice: `worker_threads.rs`'s
`scan_parent_port_event_roots_mut` visited `MESSAGE_EVENT_CALLBACKS` and
neither `MESSAGE_CALLBACK` nor `CLOSE_CALLBACK` -- three slots in the same
`thread_local!` block holding the same raw `ClosureHeader*`. The box/visit/
unbox dance is factored into one helper, because three copies of it is how the
fourth gets forgotten.

Two further windows closed in `frame.rs` while rooting its queue.
`js_frame_tick` drained into an unrooted local `Vec` and rooted each callback
only as it invoked it, leaving #2..#N naked while #1 ran arbitrary user code
(#7230's staging-buffer shape); one batch `RuntimeHandleScope` now covers the
set. And `js_on_frame_callback` held the queue mutex across an allocating
`capture_context()` -- harmless before, a self-deadlock once a scanner locks
the same mutex.

Verified against c9cd73b with `test_gap_gc_process_env_cache_rooting.ts`,
registered in `test-parity/gc_repsel_corpus.txt`. Compiled AND run under
`PERRY_GC_MOVING_LOOP_POLLS=1` with the evacuating base: SIGBUS (exit 138)
10/10 at base with no output at all, `bad 0` 10/10 after, byte-exact vs node
26.5.1. Clean 5/5 on the shipped default both sides, so this is a
`requires=move` witness rather than a pre-existing failure. Under
`ZEAL=1 PROTECT_FROMSPACE=1 DEPTH=800` the base arm faults at the stale
dereference (`obj_type=2 size=416`, RETIRED FROM-SPACE) and this build is
clean, so the instrument is a detector here and not a noise generator.

`scripts/gc_repsel_matrix.sh --arms loop_polls --filter test_gap_gc_`:
PASS=21 UNVER=0 FAIL=0, copy-minor live 21/21.

Refs #7231, #7226, #7230, #7210, #7154, #7196.
proggeramlug added a commit that referenced this pull request Aug 2, 2026
…hat walked 1 of 3 sibling slots (#7239)

The #7231 enumeration, verified and closed. This is #7226's class -- a runtime
table holding a GC pointer that is not a registered root -- which is strictly
worse than #7154's stale-register class (it goes bad at collection #0 and stays
bad, rather than needing a collection to land in a narrow window) and which no
static instrument can find: `gc_root_dominance_check.py` reads emitted LLVM IR,
and a runtime table is not in it.

★ `CACHED_ENV`, the `process.env` object, is the load-bearing one and it is a
hard crash rather than a subtle wrong answer. `js_process_env_impl` builds it
once with `js_object_alloc` -- the NURSERY -- and caches it in a thread-local
`Cell<f64>` that is the ENTIRE reference graph: `process.env` is a
`js_process_env()` call, not a field of the `process` object. So the first
minor swept or evacuated it and every later `process.env.X = v` wrote through a
dangling pointer. The sibling `PROCESS_FINALIZATION_OBJECT` uses the identical
materialize-once idiom and was already rooted, which is what makes this an
omission rather than a design.

The observable is ENUMERATION -- `Object.keys(process.env)`, `for…in`, spread,
which is how `@next/env` and `dotenv` consume it. A direct `process.env.KEY`
read lowers to `js_getenv` and asks the OS, so a witness built on the read
would be a gate that cannot fail.

Also rooted: `CACHED_PERMISSION` and `CACHED_REPORT` (same shape; the
`runtime_write_barrier_root_nanbox` beside the first is an incremental MARK
barrier, not a root registration, and is now labelled as such);
`ERROR_CONSTRUCTOR_PTR` (a raw duplicate of a `globalThis` closure, outside the
object graph, stale after a move); `INPUT_HANDLER` (the inline `useInput`
arrow, which nothing else refers to); `RESIZE_CALLBACK` (a native slot that
bypasses the rooted EventEmitter listener array); `FRAME_CALLBACKS` (rooted
only transiently during registration -- its `unsafe impl Send` SAFETY comment
asserted the opposite and is corrected in place); `CURRENT_NEW_TARGET`;
`ACCESSOR_RECEIVER_OVERRIDE`; and `PENDING_FETCH_SIGNAL`.

Scanner gap, the shape #7230 found twice: `worker_threads.rs`'s
`scan_parent_port_event_roots_mut` visited `MESSAGE_EVENT_CALLBACKS` and
neither `MESSAGE_CALLBACK` nor `CLOSE_CALLBACK` -- three slots in the same
`thread_local!` block holding the same raw `ClosureHeader*`. The box/visit/
unbox dance is factored into one helper, because three copies of it is how the
fourth gets forgotten.

Two further windows closed in `frame.rs` while rooting its queue.
`js_frame_tick` drained into an unrooted local `Vec` and rooted each callback
only as it invoked it, leaving #2..#N naked while #1 ran arbitrary user code
(#7230's staging-buffer shape); one batch `RuntimeHandleScope` now covers the
set. And `js_on_frame_callback` held the queue mutex across an allocating
`capture_context()` -- harmless before, a self-deadlock once a scanner locks
the same mutex.

Verified against c9cd73b with `test_gap_gc_process_env_cache_rooting.ts`,
registered in `test-parity/gc_repsel_corpus.txt`. Compiled AND run under
`PERRY_GC_MOVING_LOOP_POLLS=1` with the evacuating base: SIGBUS (exit 138)
10/10 at base with no output at all, `bad 0` 10/10 after, byte-exact vs node
26.5.1. Clean 5/5 on the shipped default both sides, so this is a
`requires=move` witness rather than a pre-existing failure. Under
`ZEAL=1 PROTECT_FROMSPACE=1 DEPTH=800` the base arm faults at the stale
dereference (`obj_type=2 size=416`, RETIRED FROM-SPACE) and this build is
clean, so the instrument is a detector here and not a noise generator.

`scripts/gc_repsel_matrix.sh --arms loop_polls --filter test_gap_gc_`:
PASS=21 UNVER=0 FAIL=0, copy-minor live 21/21.

Refs #7231, #7226, #7230, #7210, #7154, #7196.

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

2 participants