Native-frame GC roots via LLVM statepoints, opt-in (#7173, #7174) - #7314
Merged
Conversation
added 30 commits
August 2, 2026 06:35
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.
…INT_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.
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).
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.
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.
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.
…return 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.
…etadata trajectory
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.
…e 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.
…ext recovery 150KB not 439KB, shadow is the measured three-axis optimum today
… shadow traffic; measured-and-not-pursued
…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.
…pp metadata 3.76MB
…robes 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.
…ment 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.
…within 3.1% of the audited bridge, smallest native arm
…haracterization table complete
…d 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.
…fied, 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.
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.
…ming - shadow +14.7% ahead; default-flip needs a Pi-class gate
…ries per function; unwinder is the permanent Linux path
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.
…er, 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.
This was referenced Aug 4, 2026
proggeramlug
pushed a commit
that referenced
this pull request
Aug 4, 2026
…t is internal plumbing The env *spelling* was deleted under the kill policy (#7314) and the flag is the only entry point -- but the variable itself is still how the driver hands the format to the rayon module workers, and run_pipeline.rs remove_var's it when the flag is absent so an inherited value cannot switch reporting on. That block is a knob ledger. An entry reading "deleted" for a name still greppable in the tree makes the whole list look stale to the next auditor. Claude-Session: https://claude.ai/code/session_01EaD6yNwoinzdW1JbYNkMMF
proggeramlug
added a commit
that referenced
this pull request
Aug 4, 2026
#7360) * test(gc): collect at stack depth, and gate walker liveness on every arm The native-root walker had no probe that made it work. Every probe in the suite calls `gc()` at the end, from a shallow stack, so on macOS and Linux `04_dead_after_deep_stack` reported 7 frames visited and **zero** root locations. Both arms would have passed unchanged with a walker that visited nothing at all -- other root sources covered the probes. Windows only walked a deep stack (5,626 frames) by accident of heap sizing, which is why the `--require-locations` gate could be applied there and nowhere else. `11_collect_at_depth` makes that coverage deliberate. `descend` holds a heap value live ACROSS its recursive call and collects at the deepest point, so at collection time there is one live root per frame, all of them mid-frame rather than in the leaf. Every slot is read after the collection returns, so a walker that stops early -- or a map with a wrong base register -- produces a wrong checksum, not merely a slower run. Under `PERRY_GC_FORCE_EVACUATE=1` every survivor moves, so a stale pointer cannot be accidentally right. Measured, byte-matching the pinned Node oracle: macOS aarch64 228 frames, 222 records, 221 locations (was 7 / 0) x86-64 Linux 231 frames, 221 locations (was 7 / 0) With both Unix arms now walking a real stack, `--require-locations` moves from the Windows-only branch to the shared path and gates all three. Full ratchet suite: 11/11 byte-identical to the oracle under `PERRY_RS4GC=1 PERRY_GC_FORCE_EVACUATE=1 PERRY_GC_VERIFY_EVACUATION=1`. Claude-Session: https://claude.ai/code/session_01EaD6yNwoinzdW1JbYNkMMF * docs: changelog fragment for #7359 Claude-Session: https://claude.ai/code/session_01EaD6yNwoinzdW1JbYNkMMF * ci(gc): add the aarch64-Linux arm, the one uncovered corner of the map The matrix covered aarch64+Mach-O, x86-64+ELF and x86-64+PE, and the note explaining the gap said ARM64 Linux was skipped because "its two components are each covered above." That is the exact compositional fallacy `word_width_for` in `gc_map.rs` exists to warn about. `.word` is not a fixed size -- GNU `as` defines it as the target's natural machine word -- so LLVM's AArch64 ELF backend spells every 32-bit stack-map field `.word`, while both covered arms spell it `.long`: Mach-O uses `.long` on aarch64, and on x86 `.word` means *two* bytes so LLVM will not use it for a 32-bit field. The directive width is a property of the intersection, not of either component. What this arm does and does not add, stated precisely, because overclaiming in this file is how #7321's wrong explanation survived into an issue and a job name: the `.word` spelling is already unit-tested on every arm, against a hand-written sample. What no arm has ever exercised is the end-to-end chain on this target -- real LLVM asm output, real ELF linking, real runtime walking -- where the failure mode is not a parse error but a wrong answer. `ubuntu-24.04-arm` is already in use in release-packages.yml and the repo is public, so the runners are available; the "queue for hours" half of the old rationale is stale too. Also repairs a garbled sentence in the header comment, left by an edit that spliced two clauses about the pre-#7349 x86-64 refusal. Claude-Session: https://claude.ai/code/session_01EaD6yNwoinzdW1JbYNkMMF * docs(ci): the knob ledger said PERRY_STATEPOINT_REPORT was deleted; it is internal plumbing The env *spelling* was deleted under the kill policy (#7314) and the flag is the only entry point -- but the variable itself is still how the driver hands the format to the rayon module workers, and run_pipeline.rs remove_var's it when the flag is absent so an inherited value cannot switch reporting on. That block is a knob ledger. An entry reading "deleted" for a name still greppable in the tree makes the whole list look stale to the next auditor. Claude-Session: https://claude.ai/code/session_01EaD6yNwoinzdW1JbYNkMMF --------- Co-authored-by: Ralph Küpper <ralph@skelpo.com>
This was referenced Aug 4, 2026
Merged
This was referenced Aug 12, 2026
This was referenced Aug 15, 2026
proggeramlug
added a commit
that referenced
this pull request
Aug 15, 2026
) * fix(gc): prune the buffer own-property table when its buffer dies `finalize_collected_dead_buffer` exists to "drop every registry/side-table entry keyed by a dead buffer's address" — its own words — and it prunes eleven such tables. The own-property table (`buf.foo = v`, #6406) was not one of them. Its only clear site was `register_buffer`, which fires only when the recycled address is re-issued to another *buffer*. An entry whose address is never reused, or is reused by a plain object, therefore survived for the life of the process. Two consequences, and the second is why this table matters more than the identity registries beside it: * an unbounded leak — one permanent entry per property-carrying Buffer or DataView ever created; * `scan_buffer_own_props_roots_mut` traces the stored VALUES in every GC phase, so a dead buffer's expando closure, and everything it captures, stayed reachable forever; and its dead owner key kept being handed to `visit_metadata_usize_slot`, which re-resolves it against whatever now occupies those bytes — the #6080 ABA class this function exists to prevent. Three cargo-test-visible tests in the file that already covers this function's coverage, each watched fail with the one-line prune reverted: * `test_dead_buffer_own_property_entry_pruned_on_full_gc` (left `Some(7.0)`, right `None`); * `test_buffer_own_props_table_drains_after_owners_die` ("514 owners remain, expected at most the pre-test 2") — the leak regression, which a per-address probe cannot show; * `test_live_buffer_keeps_its_own_properties_across_full_gc` — the control that keeps the prune from passing by dropping everything. It stays green under the sabotage, so the two failures above are the prune and not the harness. `cargo test -p perry-runtime --lib`: 2392 passed, 0 failed, 4 ignored (baseline 2389 + these three). rustfmt, `scripts/check_file_size.sh` and all sixteen `lint` gate scripts clean. Found while bisecting #8117's two `pass -> crash` gap regressions. This is NOT a fix for those: they are SIGSEGVs that reproduce only on Linux, they bisect to #7314, and neither has been shown to depend on this table. Landing separately on its own merits. Claude-Session: https://claude.ai/code/session_01MsfDzkTEnuS2nh7ygsYkoi * docs(changelog): fragment for #8136 Claude-Session: https://claude.ai/code/session_01MsfDzkTEnuS2nh7ygsYkoi --------- Co-authored-by: Ralph Küpper <ralph@skelpo.com>
This was referenced Aug 15, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds a second precise-root mechanism alongside the shadow stack, entirely opt-in. Without
PERRY_STATEPOINTS=1nothing in this PR runs — the shadow stack remains the shipping root mechanism and the default path is byte-for-byte unchanged.Closes the research tracked in #7173 / #7174.
Why
Perry's shadow stack requires the compiler to remember to root every value live across a call. A missed root is silent and only manifests as a collection freeing a live object. Under
gc.statepoint, LLVM — which already knows what is live across each call — owns that knowledge and rewrites the values after a collection moves them, so the forgot-to-root bug class becomes structurally impossible rather than merely tested for.Every root path fails closed
The plain
llvm.experimental.stackmaplowering is deleted, not kept as a fallback. LLVM may record a root slot's address asRegister R#N— caller-saved and unrecoverable at collection time — so falling back to it silently loses roots (measured 3 of 60 locations on one probe). It survived in three places, all of which failed open:PreciseRootBackend::StackMapwas dead by construction: both sites settingstack_map_requestedare guarded bynative_stack_roots_enabled(), which is exactlystatepoints || rs4gc. Variant and emitter deleted.gc.statepointtakes its callee as aptroperand, soptr elementtype(T) %fnptris as valid as... @callee. Indirect targets are now statepoint-able; anything still unparseable is a hard compile error naming the call shape.__perry_gcmap, so those records sit in the binary unread and that module's roots go missing, while other modules still emit a valid section so even the runtime's "present but undecodable" guard stays quiet. Now a hard error.The metadata is re-encoded, not shipped as LLVM emits it
On
test-drizzle-pg,__llvm_stackmapswas 4.21 MB, and over half of it was data the runtime already discarded at startup: threeConstantslots per record (gc.statepoint's calling-convention preamble) and a duplicate of every root, since LLVM records base and derived and Perry has no interior pointers.Perry now rewrites that block at assembly time, where LLVM prints function addresses as symbol names — so one text parser replaces Mach-O and ELF relocation parsing plus a second link pass. It costs almost nothing:
-Stakes the same time as-c(codegen is the cost, printing text is free).The largest lever is that 77% of records have the identical live set as the record before them — consecutive safepoints in a function share their roots — so a repeat flag replaces the payload. That also lets the runtime point repeats at one copy instead of materialising 154k entries.
Instruction offsets are a fixed-width array rather than varints because at
-O3LLVM emits them as label differences (.long Ltmp9-_main) that only the assembler can evaluate.try/catch is covered
Now that exception lowering uses
invoke/landingpad(#7302), no jump can skip agc.relocate, so try-carrying functions take statepoints like any other. Under RS4GC they additionally needlandingpad token, because RS4GC uses the landing pad as the relocate token; that retype is sound only because the pad's value is dead, and it refuses if the pad register is referenced anywhere.benchmarks/gc_ratchet/probes/09_try_catch_roots.tsis new, and exists because nothing in the suite contained atryat all — 0 of 8 probes — so this path was covered by no test whatsoever. It exercises objects allocated inside atrysurviving a collection there, locals live across a throw and read in thecatch, a throw crossing several frames so the rewritten roots sit in a caller's frame,finallyon both edges, and a rethrow caught one frame up. Every survivor folds into the checksum, so a lost root is a wrong number rather than a crash.Evidence
test-drizzle-pg, 133 modules of real dependency code:All three arms pass 9/9 against the pinned Node oracle — explicit bridge, RS4GC, and the default shadow stack — and the two statepoint arms also pass under
PERRY_GC_FORCE_EVACUATE=1 PERRY_GC_VERIFY_EVACUATION=1 PERRY_STACKMAP_WALKER=verify, which is the check that fails if the format decodes to a smaller root set.The
gc-native-rootsgate asserts its subject was live rather than merely that nothing threw:.perry_gcmappresent and.llvm_stackmapsabsent, so it still fails if compaction silently stops running.What this does not claim
__text−151 KB / −240 KB, plus ~105 KB less__eh_frame), and that is cancelled by the remaining 189–221 KB of map. An earlier revision of this branch measured a 49–131 KB win; merging main moved the shadow baseline down by more than it moved the statepoint arms, and the honest current number is parity. Closing that axis needs fewer roots, not a tighter encoding — 221 KB for 154k roots is near this format's floor.Reviewer notes / open items
PERRY_STATEPOINTS,PERRY_RS4GC,PERRY_GC_SAFEPOINT_ONLY,PERRY_STACKMAP_WALKER,PERRY_STATEPOINT_REPORT. CLAUDE.md requires each to have a CI arm exercising its OFF state or be deleted after one release of soak.gc-native-rootsexercises the main ON path; the others do not yet have arms, and I would rather that be an explicit merge decision than something discovered later.gc-native-rootsshould not be promoted to required in this PR. A new gate has never been green, so making it required immediately blocks every open PR. Run it once on main, then promote.@anthropic-ai/claude-codebundle hitclang: translation unit is too largehowever finely it was split — 885 KB → 299 KB per unit), and bounded-parallel unit compilation (PERRY_CODEGEN_UNIT_JOBS). These help the default path and are reviewable independently if you would prefer them split out.Pre-flight, run locally in CI's shape
cargo test -p perry-runtime --libwithRUST_TEST_THREADS=1(ascargo-testruns it): 1,669 passed, 0 failed. Running it multi-threaded instead produces 3 failures inprop_plan/native_module_stream— the known shared-global-state flakes, which is why CI pins the thread count.cargo test -p perry-codegen --lib: 586 passed, 0 failed.perry-ext-fastify,-lru-cache,-pdf,-http: pass per-package. Acargo test --release --workspacerun instead fails to link several of these on undefinedjs_fetch_*/js_headers_*symbols — that is workspace-wide feature unification, not a regression; CI's per-package loop with a pre-built runtime is the shape that matters.crates/perry-codegen/tests/loop_safepoint_purity.rshas 6 failing tests, and they fail identically on pristineorigin/main(1 passed / 6 failed, same test names, verified in a clean worktree at2f31cca91). Pre-existing, not from this branch. Worth someone's attention separately: integration suites undercrates/*/tests/do not run per-PR, which is exactly how a red suite sits unnoticed.cargo fmt --all --checkclean;scripts/check_file_size.shadds no new oversized file (the 16 that are over the cap are all pre-existing).The
gc-native-rootsjob could not re-run on the branch after its triggers were corrected — branch-scopedpushwas removed so it can gatemain— so its first run against theSHF_GNU_RETAINfix will be this PR'spull_requestrun.Summary by CodeRabbit
New Features
--statepoint-reportwith text and JSON output for GC root-pressure metrics.Bug Fixes
Documentation