perf(gc): emit the shadow-slot root store inline - #7088
Conversation
Prepares the inline slot store: Vec layout is unspecified and cannot be a codegen contract, so the three buffer words become explicit #[repr(C)] fields with published, offset_of!-asserted offsets. Dropping the Vec also drops the TLS drop glue (and its per-op lazy-registration check). Claude-Session: https://claude.ai/code/session_018ZFER8EEg8K7ez2n6oDrT9
The first version passed with the guard removed: after a balanced pop len is 0, so the bounds check alone skips the write. Forcing frame_top = usize::MAX while the frame's entries are still live reproduces the wrap the guard exists for -- idx-1 lands on the frame header -- and the test now fails when the guard is deleted. Claude-Session: https://claude.ai/code/session_018ZFER8EEg8K7ez2n6oDrT9
📝 WalkthroughWalkthroughThe PR replaces vector-backed shadow-stack state with a fixed raw-buffer layout, adds ChangesInline shadow-slot storage and code generation
Estimated code review effort: 5 (Critical) | ~120 minutes Sequence Diagram(s)sequenceDiagram
participant GeneratedCode
participant js_shadow_frame_enter
participant ShadowStackState
participant ShadowEntry
participant GC
GeneratedCode->>js_shadow_frame_enter: enter frame
js_shadow_frame_enter-->>GeneratedCode: return state pointer
GeneratedCode->>ShadowStackState: check frame and slot bounds
GeneratedCode->>ShadowEntry: write inline bind or clear
GC->>ShadowStackState: scan and rewrite active roots
Possibly related PRs
Suggested labels: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
🧹 Nitpick comments (4)
crates/perry-codegen/src/function.rs (1)
319-323: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value
ShadowFramePush::handle_regnow holds the state register, not a handle.
shadow_frame_push_linerenders%reg = call ptr@js_shadow_frame_enter(...), so this field is the state pointer register; the name will mislead the next reader ofreserve_shadow_slot. Consider renaming tostate_reg(field doc at Line 190 too).🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@crates/perry-codegen/src/function.rs` around lines 319 - 323, Rename the ShadowFramePush field handle_reg to state_reg and update all references, including reserve_shadow_slot and shadow_frame_push_line, so the field accurately describes the state pointer register. Update the field documentation near the ShadowFramePush definition to use the new name and terminology.crates/perry-runtime/src/gc/roots/shadow_stack.rs (1)
283-288: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueStale SAFETY comment contradicts the code.
The comment says the length is passed as 0, then immediately says the real length is re-declared; the code passes
s.len. Trim it to the accurate rationale.📝 Suggested comment fix
- // SAFETY: `ptr`/`cap` came from a `Vec<ShadowEntry>` built here. Length - // is passed as 0 because `ShadowEntry: Copy` has no drop glue, and the - // live prefix is copied by `reserve` from the raw allocation anyway — - // so re-declare the real length to keep the data. + // SAFETY: `ptr`/`cap` came from a `Vec<ShadowEntry>` built here. The + // real `len` is re-declared so `reserve` copies the live prefix into + // the new allocation.🤖 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/roots/shadow_stack.rs` around lines 283 - 288, Update the SAFETY comment immediately above Vec::from_raw_parts in the shadow-stack reconstruction to accurately describe that s.len is passed as the vector length; remove the contradictory claim that length is passed as 0 while retaining only the valid rationale about the Vec allocation, Copy element type, and preserved live prefix.crates/perry-codegen/src/lib.rs (1)
11-11: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winRevert
exprto crate-local visibility.
perryonly needsperry_codegen::expr_shadow_layout;pub use crate::expr::shadow_inline::{…}works whileexprremainspub(crate), so widening it makes the entire expression lowering namespace part ofperry-codegen’s public API unnecessarily.♻️ Proposed change
-pub mod expr; +pub(crate) mod expr;🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@crates/perry-codegen/src/lib.rs` at line 11, Change the expr module declaration back to crate-local visibility by replacing the public export in the module declarations. Preserve the existing public re-export of expr_shadow_layout through the established shadow_inline path so perry can access only that API without exposing the full expression-lowering namespace.crates/perry-codegen/src/expr/shadow_inline.rs (1)
428-547: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winBrittle hardcoded SSA register numbers in three unit tests.
frame_push_uses_frame_enter_and_derives_the_handle(Line 440, 447),inline_store_keeps_the_sentinel_and_bounds_guards(Lines 507, 512, 518), andinline_bind_keeps_the_gated_root_shading_barrier(Line 543) assert against exact register names (%r3,%r5,%r10,%r13,%r15,%r17,%r23). Any unrelated instruction added/removed earlier inemit_inline_slot_write(or in shared codegen helpers) will shift numbering and break these tests without the guarded property actually regressing. The file already demonstrates a more robust technique for other assertions — content-based block extraction (store_blocks/bind_block/clear_block/entry_reg) and a wildcard-register fallback for the frame_top offset check at Line 442 — that could be applied consistently here too (e.g., match"icmp eq i64 %", ", -1"and"sub i64 %", &SHADOW_STACK_HEADER_SLOTS.to_string()without pinning the exact register).♻️ Example direction for one of the assertions
- assert!( - body.contains(&format!("sub i64 %r5, {}", SHADOW_STACK_HEADER_SLOTS)), - "pop handle must be frame_top - {SHADOW_STACK_HEADER_SLOTS}; body:\n{body}" - ); + assert!( + body.contains("sub i64 %") && body.contains(&format!(", {}\n", SHADOW_STACK_HEADER_SLOTS)), + "pop handle must be frame_top - {SHADOW_STACK_HEADER_SLOTS}; body:\n{body}" + );🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@crates/perry-codegen/src/expr/shadow_inline.rs` around lines 428 - 547, Replace hardcoded SSA register names in frame_push_uses_frame_enter_and_derives_the_handle, inline_store_keeps_the_sentinel_and_bounds_guards, and inline_bind_keeps_the_gated_root_shading_barrier with content-based or wildcard-register assertions. Preserve each test’s validation of the relevant operation and constants, using existing helpers such as bind_block and entry_reg where appropriate, so unrelated codegen changes do not break the tests.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@crates/perry-codegen/src/expr/shadow_inline.rs`:
- Around line 428-547: Replace hardcoded SSA register names in
frame_push_uses_frame_enter_and_derives_the_handle,
inline_store_keeps_the_sentinel_and_bounds_guards, and
inline_bind_keeps_the_gated_root_shading_barrier with content-based or
wildcard-register assertions. Preserve each test’s validation of the relevant
operation and constants, using existing helpers such as bind_block and entry_reg
where appropriate, so unrelated codegen changes do not break the tests.
In `@crates/perry-codegen/src/function.rs`:
- Around line 319-323: Rename the ShadowFramePush field handle_reg to state_reg
and update all references, including reserve_shadow_slot and
shadow_frame_push_line, so the field accurately describes the state pointer
register. Update the field documentation near the ShadowFramePush definition to
use the new name and terminology.
In `@crates/perry-codegen/src/lib.rs`:
- Line 11: Change the expr module declaration back to crate-local visibility by
replacing the public export in the module declarations. Preserve the existing
public re-export of expr_shadow_layout through the established shadow_inline
path so perry can access only that API without exposing the full
expression-lowering namespace.
In `@crates/perry-runtime/src/gc/roots/shadow_stack.rs`:
- Around line 283-288: Update the SAFETY comment immediately above
Vec::from_raw_parts in the shadow-stack reconstruction to accurately describe
that s.len is passed as the vector length; remove the contradictory claim that
length is passed as 0 while retaining only the valid rationale about the Vec
allocation, Copy element type, and preserved live prefix.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: b9028ae2-20d2-4f88-8724-e73741398b84
📒 Files selected for processing (20)
changelog.d/7086-inline-shadow-slot-store.mdcrates/perry-codegen/src/codegen/closure.rscrates/perry-codegen/src/codegen/helpers.rscrates/perry-codegen/src/expr/mod.rscrates/perry-codegen/src/expr/shadow_inline.rscrates/perry-codegen/src/expr/shadow_slot.rscrates/perry-codegen/src/function.rscrates/perry-codegen/src/lib.rscrates/perry-codegen/src/module.rscrates/perry-codegen/src/runtime_decls/arrays.rscrates/perry-codegen/tests/shadow_slot_hygiene.rscrates/perry-runtime/src/gc/roots.rscrates/perry-runtime/src/gc/roots/shadow_stack.rscrates/perry-runtime/src/gc/tests/debt_pacer.rscrates/perry-runtime/src/gc/tests/shadow_stack_ops.rscrates/perry-runtime/src/gc/tests/support.rscrates/perry/src/commands/compile/build_cache.rscrates/perry/src/commands/compile/object_cache.rscrates/perry/src/main.rscrates/perry/src/shadow_layout_contract.rs
… barrier (#7158) The native-region-proof gate went red on main after #7088 moved the shadow-slot root store — and its incremental-mark root-shading barrier — from a js_shadow_slot_bind/js_shadow_slot_set runtime call to inline IR. The barrier was always emitted; it lived inside the runtime function and was invisible to the harness's static call counter. Inlining exposed the js_write_barrier_root_nanbox call site, so write_barriers_static jumped (h1_native_rep_equivalence 0->3, one per rooted Buffer local) and every affected workload tripped its heap-barrier budget. The same lowering adds ss.* blocks ahead of the module-init loops, shifting the deterministic per-function block counter by 12 and blanking the region labels (for.body.2/6/10 -> 14/18/22). Not a real regression: the barrier is gated behind PERRY_INCREMENTAL_MARK_BARRIER_ACTIVE_COUNT, never fires here (write_barriers_traced stays 0), sits in guarded ss.barrier blocks at root-bind sites (never inside the native loops, which keep raw load/store i8 + alias metadata and no runtime calls), and #7088 proves it observationally identical to the call it replaced. - structural_counters scores write_barriers_static on the optimizer-controlled heap barriers (js_write_barrier, js_write_barrier_slot) only; the shadow-stack root-shading barriers (js_write_barrier_root_nanbox, js_write_barrier_root_heap_word) move to a new, reported-but-non-gating root_shading_barriers_static field. Real regressions stay caught: heap barriers are still counted, and a root barrier that actually fires is caught by the write_barriers_traced budget. - h1_native_rep_equivalence region selectors follow the renumbered loop bodies. Bisected to 91f1e7c (#7088). Verified: harness unit tests green; h1_native_rep_equivalence + scalar_replacement_literals gate green; the full suite now matches the #7088 parent (CI-green) locally, the only residual failures being pre-existing macOS/clang-16 env noise (smax.i32, clang loop safepoint placement) identical on both.
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.
…rame Duplicate `var` declarations share one HIR local id but keep a Stmt::Let per declaration site. collect_pointer_typed_locals burned a slot index for every Let while the map kept one entry per id, and every caller sizes the frame with map.len() — so functions with redeclarations pushed a frame smaller than the highest index handed out (lodash's runInContext: frame 598, binds up to 769). js_shadow_slot_bind and the PerryTS#7088 inline store both bounds-check and silently no-op on an out-of-frame index, so those locals were live but invisible to the precise-root moving minor: an evacuating collection at a loop back-edge poll relocated the object and left the compiled local slot pointing into from-space — the mutator then re-injected the stale address through ordinary stores ("TypeError: value is not a function" once called). This is the sfw-registry --help crash under PERRY_GC_MOVING_LOOP_POLLS=1 traced in PerryTS#7154 (lldb: lodash.js:10751, castRest read from an unrooted local). Assign at most one slot per id (entry().or_insert_with), restoring the invariant map.len() == slots handed out == max index + 1, and assert it. Regression test compiles a duplicate-var function and asserts every emitted slot index is inside the pushed frame. Refs PerryTS#7154.
…rame (#7184) Duplicate `var` declarations share one HIR local id but keep a Stmt::Let per declaration site. collect_pointer_typed_locals burned a slot index for every Let while the map kept one entry per id, and every caller sizes the frame with map.len() — so functions with redeclarations pushed a frame smaller than the highest index handed out (lodash's runInContext: frame 598, binds up to 769). js_shadow_slot_bind and the #7088 inline store both bounds-check and silently no-op on an out-of-frame index, so those locals were live but invisible to the precise-root moving minor: an evacuating collection at a loop back-edge poll relocated the object and left the compiled local slot pointing into from-space — the mutator then re-injected the stale address through ordinary stores ("TypeError: value is not a function" once called). This is the sfw-registry --help crash under PERRY_GC_MOVING_LOOP_POLLS=1 traced in #7154 (lldb: lodash.js:10751, castRest read from an unrooted local). Assign at most one slot per id (entry().or_insert_with), restoring the invariant map.len() == slots handed out == max index + 1, and assert it. Regression test compiles a duplicate-var function and asserts every emitted slot index is inside the pushed frame. Refs #7154.
…7088's frame enter Two tests grepped for `call i64 @js_shadow_frame_push(i32 `, which PerryTS#7088 replaced with `call ptr @js_shadow_frame_enter(i32 ` (same slot-count operand; the pop handle is now derived from frame_top). Both panicked at the grep — "expected a shadow frame push" / "no frame push in the binding function" — BEFORE reaching the assertions they exist for, so the suite was red on main while the PerryTS#6968 rooting contract it guards was in fact intact. Only the callee name and return type change. Every real assertion (the field alloca is bound, the frame grows differentially, the bind is hoisted into the entry block ahead of the storing loop and after the push) is untouched and now actually executes: 11/11 pass. Refs PerryTS#7088, PerryTS#6968.
…frame enter (#7185) Two tests grepped for `call i64 @js_shadow_frame_push(i32 `, which #7088 replaced with `call ptr @js_shadow_frame_enter(i32 ` (same slot-count operand; the pop handle is now derived from frame_top). Both panicked at the grep — "expected a shadow frame push" / "no frame push in the binding function" — BEFORE reaching the assertions they exist for, so the suite was red on main while the #6968 rooting contract it guards was in fact intact. Only the callee name and return type change. Every real assertion (the field alloca is bound, the frame grows differentially, the bind is hoisted into the entry block ahead of the storing loop and after the push) is untouched and now actually executes: 11/11 pass. Refs #7088, #6968.
…frame enter Two tests grepped for `call i64 @js_shadow_frame_push(i32 `, which #7088 replaced with `call ptr @js_shadow_frame_enter(i32 ` (same slot-count operand; the pop handle is now derived from frame_top). Both panicked at the grep — "expected a shadow frame push" / "no frame push in the binding function" — BEFORE reaching the assertions they exist for, so the suite was red on main while the #6968 rooting contract it guards was in fact intact. Only the callee name and return type change. Every real assertion (the field alloca is bound, the frame grows differentially, the bind is hoisted into the entry block ahead of the storing loop and after the push) is untouched and now actually executes: 11/11 pass. Refs #7088, #6968.
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.
) * 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>
…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>
A ceiling measurement (#7079) put the shadow stack at +63.7 % instructions
on application-shaped code (
w6_records, Pi 5) versus not emitting it at all,and concluded — labelled as a reading, not a measurement — that what remained
was per-store
extern "C"calls that only a codegen change can touch. Thereare two codegen changes that touch it: replacing the shadow stack with stack
maps, and inlining the store. This PR is the second one.
It removes 56 % of that ceiling.
w6_recordsgoes from +60.8 % to+26.7 % against an identical toolchain with only the store form changed.
Why a call, not the work, was the cost
An
extern "C"call costs twice: the call itself, and the fact that it isopaque to LLVM, which forces a spill of every live value around it and
blocks hoisting across it. Read out of the shipped
aarch64archive,js_shadow_slot_bind's fast path was ~35 instructions, of which about six didany work:
bl+ 4-instruction prologue/epilogue pairadrp/ldr/add+ indirectblrinto the resolver,mrs tpidr_el0ldrb/cmp/b.eq+ apanic_access_erroredgestp, gated barrierHow the thread-local block is reached
Not by re-deriving the TLS address in generated code. That would mean
modelling Rust's TLS model per platform (TLSDESC on this Linux build,
tlvonmacOS) and would be a second, unverified path to the same memory.
Instead the address is obtained from the runtime and cached for the
activation.
js_shadow_frame_enterisjs_shadow_frame_pushreturning theaddress of this thread's
ShadowStackStateinstead of the frame handle, socodegen pays exactly the one thread-local lookup per activation that the push
already paid. The handle the matching
js_shadow_frame_popneeds is recoveredas
frame_top - SHADOW_STACK_HEADER_SLOTS, so the pop side is untouched.Caching it is sound because it is the address of a
const-initialized,drop-free
thread_local!: fixed for the thread's lifetime, never reallocated.The buffer it points at does move when a deeper frame grows it — which is
exactly why no frame base is cached and
ptr/len/frame_toparere-loaded from the state at every store. One activation of a compiled function
runs entirely on one thread (
perry/threadhands a whole call to a worker; aresumed async state machine re-enters through the function entry and calls
js_shadow_frame_enteragain), so a cached pointer never escapes its thread.Because the pointer comes from the runtime's own
SHADOW.with, the inlinestore addresses the same memory
js_shadow_slot_setwrites by construction.js_shadow_state_addrmakes that testable from Rust: a test writes an entrythrough the published offsets and reads it back with
js_shadow_slot_get, andvice versa.
ShadowStackStateis now#[repr(C)]with an explicitptr/len/capbuffer, because
Vec's layout is not a contract — in the archive read at thetime of writing it placed
capat 0,ptrat 8 andlenat 16, and a silentreorder would have codegen writing live GC roots through the wrong word.
Dropping the
Vecalso drops the type's drop glue, which is what forced theper-op lazy destructor-registration check; the buffer is freed at thread exit by
a separate guard thread-local armed only from the cold growth path.
Soundness, per root property
Liveness — the inline store writes the same
ShadowEntry.valueand sets thesame
SLOT_ACTIVEbit ofmeta, at the same index, sovisit_shadow_stack_root_slotsmarks it identically.Rewritability —
metastill carries the bound compiled-local address, withbound_slot_meta's alignment fallback intact (a tag-colliding address isrecorded active-but-unbound, never truncated), so an evacuating collection
rewrites the alloca the mutator reads after the safepoint, not just the mirror.
The value the mutator stored — the value is read from the local slot at the
store site, in the position the call occupied, and written immediately. Nothing
re-reads a slot at a later safepoint.
The incremental-mark root shading barrier is emitted inline behind the same
PERRY_INCREMENTAL_MARK_BARRIER_ACTIVE_COUNTgate the runtime andemit_persistent_shadow_root_barrieralready use.Guards. Both of the runtime function's guards are emitted: the
frame_top == usize::MAXsentinel and theslot < lenbounds check. Honestscope: the sentinel is unreachable in a balanced program (that state implies
len == 0, so the bounds check alone would skip). It is kept because if the twoever diverge the failure mode is silent corruption, not a skip —
usize::MAX + idxwraps toidx - 1, an in-bounds index into the frameheader, unlinking every outer frame from the root scan. Same wrap-around class
#7079 fixed in
frame_pop.Bounds and capacity are not dropped. Growth stays in the runtime's cold
path; the inline sequence never grows the buffer, it re-reads
lenand skipswhen out of range, exactly as
js_shadow_slot_setdoes.Each store also emits a null-state fallback arm calling the original runtime
function.
js_shadow_frame_enteris declarednonnull, so LLVM folds that armaway wherever the push dominates — verified in the linked binary, where
js_shadow_slot_setno longer appears in the symbol table at all.Measured
Pi 5 (Cortex-A76, governor
performance), instructions retired underperf stat,taskset -c 3, 12 interleaved reps per arm, runs dropped if the armclock moved >1 % or any throttle bit set. 0 of 300 runs dropped, 0 oracle
mismatches, clock 2 400 017–2 400 037 kHz (within 0.001 %), throttle word
0x0throughout, temp ≤ 67.5 °C, load average 0.93/2.54/3.34 at launch.Every arm's stdout diffed against pinned Node 26.5.0. Instruction-count cv is
0.00 % on every workload arm, which is also why the decaying 5/15-minute
load does not threaten these figures — instruction counts are load-insensitive,
unlike the cycles and wall columns.
Single-variable isolation — same compiler, same runtime archives, only
PERRY_INLINE_SHADOW_SLOTdiffers (it is in both cache keys, so the arms cannotshare a cached
.o; binary distinctness asserted per workload):w6_records(application-shaped)w1_calldepthw3_treewalkw5_purity(floor)w5_purityis exactly inert, as it must be: a pure numeric loop reserves noshadow slots, so there is nothing to inline.
The ceiling, measured directly rather than inferred.
PERRY_SHADOW_STACK=1vs
=0re-run with the change in. The #7079 reference arms were re-runinterleaved in the same session and reproduce their published numbers, which
is what makes the two columns comparable:
w6_recordsw3_treewalkw1_calldepthNot inlined, deliberately
The frame push/pop pair (per activation, not per store), and the parameter /
closure-prologue / persistent entry-setup binds. Those are emitted while block 0
is being built directly, before the lowering context that can create the guard
and barrier basic blocks exists.
What that leaves is checkable rather than asserted. In
w6_records' emitted IR:ss.slowarmsjs_shadow_slot_setjs_shadow_slot_bindjs_shadow_frame_enterjs_shadow_frame_popEvery opaque shadow call left in
w6is per-activation; none is per-store.js_shadow_slot_setis gone from the linked binary's symbol table entirely, andthe 11 surviving
js_shadow_slot_bindsites are all parameter and prologuebinds. Since
alwaysinlineleaf functions turn per-activation intoper-iteration, those are the natural next target — and they are why
w3_treewalk(deep recursion, few stores per activation) improves least here.Gates
scripts/gc_repsel_matrix.sh --arms all --pressure 8, pinned Node 26.5.0:PASS=339 UNVER=100 XFAIL=1 FAIL=0over 440 cells — the required numbersexactly, byte-exact against the oracle on 439/440 (the one non-match is the
registered
XFAIL,repsel_ptr_shape_localsunderrep_ptr_shape_off). Thearms were live, not inert: every
requires=movearm ran 21/22 rows withmoved-objectsandcopy-minorboth confirmed, and therequires=scavengearms 12/22 — so the evacuating configurations actually evacuated with the
inline stores in place.
gc-ratchet: all 72 gated retention/evacuation counters bit-identical topristine
origin/main. Rather than trust the pinned baseline artifact —which was captured on
darwin-arm64and which the harness itself refuses togate against a
linux-aarch64run —origin/main@404c1c948was built inthe same worktree, with the same package set, and measured on the same host.
heap_used_bytes,heap_total_bytes,minor_cycles,step_cycles,copied_objects,copied_bytes,promoted_objects,promoted_bytesandfreed_bytesare equal across all 8 probes: 0 of 72 differ. Nothing is keptalive or dropped that was not before.
The comparison is on the whole metric object — median, min, max, spread, stdev
and every sample — not just medians.
And the A/B was not vacuous: on the same two runs the ungated metrics
differ on 20 of 24 rows,
wall_msandrss_byteson all 8 probes (branchfaster on 6/8, median ≈ −1.7 % wall). Identical counters, demonstrably
different binaries. The four identical
peak_rss_bytesvalues arepage-quantised.
(Against the pinned artifact, 71 of 72 gated rows match to the byte and one —
05_closure_captureheap_used_bytes, +6.47 % — does not. That is theplatform, not this PR: the pre-#7088 pinned toolchain reproduces the branch's
exact figure, 1,107,552, on this host, and the branch reproduces
main'sexactly. The harness flags the platform mismatch itself and refuses to gate
across it.)
Tests — 6 new runtime, 5 new codegen, 3 new cross-crate contract. Every one
was verified to fail under a targeted sabotage of the thing it covers:
shadow_layout_contract_matches_the_runtimeSLOT_ACTIVEinline_bound_slot_survives_and_is_rewritten_by_a_copying_minor,inline_write_and_runtime_accessor_address_the_same_entryinline_write_with_no_frame_installed_is_skipped_not_wrappedinline_bind_keeps_the_gated_root_shading_barriermetainstead of maskingdead_local_clear_is_inline_and_preserves_the_bindingThe sentinel-guard test needed a second pass: the first version passed with the
guard deleted, because after a balanced pop
lenis 0 and the bounds checkalone skips the write. It now forces
frame_top = usize::MAXwhile the frame'sentries are still live, which is the state the guard actually exists for, and
fails when the guard is removed.
PERRY_INLINE_SHADOW_SLOT=0/off/falsereverts to the calls for bisection.