Skip to content

Commit 7bde3de

Browse files
proggeramlugRalph Küpper
andauthored
gc: teach the root-dominance checker to read statepoint relocation bundles (#7663)
* feat(gc): teach the root-dominance checker to read statepoint relocation bundles WIP: --statepoints mode, native-lowering corpus, CI job. Claude-Session: https://claude.ai/code/session_01Y1QZ5wUP9gRSwpiweT4Wix * feat(gc): statepoint mode — fixtures, sabotage-proven arms, CI job Claude-Session: https://claude.ai/code/session_01Y1QZ5wUP9gRSwpiweT4Wix * gc: register the statepoint job in the gate-wiring check; changelog Claude-Session: https://claude.ai/code/session_01Y1QZ5wUP9gRSwpiweT4Wix * gc: point the statepoint-mode references at the PR number Claude-Session: https://claude.ai/code/session_01Y1QZ5wUP9gRSwpiweT4Wix * gc: give the --max-unrooted budget its referent (#7664) Claude-Session: https://claude.ai/code/session_01Y1QZ5wUP9gRSwpiweT4Wix * chore: bump version to 0.5.1376 Claude-Session: https://claude.ai/code/session_01Y1QZ5wUP9gRSwpiweT4Wix --------- Co-authored-by: Ralph Küpper <ralph@skelpo.com>
1 parent 041b6a7 commit 7bde3de

9 files changed

Lines changed: 2197 additions & 131 deletions

File tree

.github/workflows/gc-root-dominance.yml

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -348,3 +348,187 @@ jobs:
348348
ir-corpus
349349
ir-corpus-dep
350350
retention-days: 7
351+
352+
# ★★★ THE LOWERING THAT ACTUALLY SHIPS (#7663).
353+
#
354+
# Everything in the job above compiles its corpus under `PERRY_RS4GC=0` — the
355+
# SHADOW-STACK lowering — and since #7370 that is not the default on any
356+
# target whose frames the runtime can walk. The reason was sound and is
357+
# stated inline in `gc_root_dominance_corpus.sh`: the checker anchored on
358+
# `@js_shadow_slot_bind`, the native lowering emits zero of them, and
359+
# `--min-binds` therefore failed the job. So the gate was pointed at the only
360+
# lowering it could read, and `docs/src/internals/gc-rooting-invariant.md`
361+
# records the consequence as a blind spot: **a green `gc-root-dominance` was
362+
# evidence about a lowering that does not ship.**
363+
#
364+
# This job closes that. It is a SEPARATE job, not more steps in the one
365+
# above, for three reasons: it needs a differently-generated corpus, its
366+
# floors are about safepoints rather than root stores, and — the one that
367+
# matters — a separate job is a separate branch-protection context, so this
368+
# can be promoted to required on its own schedule without dragging the
369+
# shadow arms with it. The price is a second compiler build; `needs:` would
370+
# avoid it and was rejected, because it would make this job unrunnable
371+
# whenever the shadow job is red — precisely when someone wants to know
372+
# whether the OTHER lowering is affected.
373+
#
374+
# NOT REQUIRED, deliberately, and this is the second step nobody took last
375+
# time. A gate that has never been green blocks every open PR the day it
376+
# becomes required (CLAUDE.md's corollary, which this repo has already paid
377+
# for once — see hazard 2 in the header of this file). Let it run on `main`
378+
# first, then promote. Promotion is an admin action and is tracked separately
379+
# from the PR that adds this.
380+
gc-root-dominance-statepoints:
381+
runs-on: macos-14
382+
timeout-minutes: 90
383+
steps:
384+
- uses: actions/checkout@v7
385+
with:
386+
persist-credentials: false
387+
388+
# Same fast structural failure first. `--self-test` now covers BOTH
389+
# lowerings: the statepoint half asserts the bundle parser reads the
390+
# wrapped callee and a non-empty live set (the two mistakes the Rust
391+
# reader of this construct already made and caught), that the two verdict
392+
# classes are distinguished by the bundle alone, that the relocated-value
393+
# control reports zero, and that `--min-statepoints` refuses the shadow
394+
# corpus while `--min-binds` refuses this one.
395+
- name: Checker self-test (can this gate still fail?)
396+
run: python3 scripts/gc_root_dominance_check.py --self-test
397+
398+
# The three static audits are shared vocabulary, not shadow-mode
399+
# details: `--statepoints` classifies its sources with the same ALLOC_RE,
400+
# decides MOVING with the same POLL_CAPABLE_RUNTIME, and honours the same
401+
# #7210 exemptions. A phantom entry in any of them is a hole in THIS gate
402+
# too, so all three run here as well rather than being inherited by
403+
# assumption from a job that may not have run.
404+
- name: ALLOC_RE alternatives must match real runtime symbols
405+
run: python3 scripts/gc_root_dominance_check.py --audit-alloc-re
406+
- name: POLL_CAPABLE_RUNTIME entries must name real runtime symbols
407+
run: python3 scripts/gc_root_dominance_check.py --audit-poll-capable
408+
- name: Immovable-source exemptions must still hold
409+
run: python3 scripts/gc_root_dominance_check.py --audit-immovable-sources
410+
411+
- name: Install Rust toolchain
412+
uses: dtolnay/rust-toolchain@stable
413+
# This also puts a matched LLVM 22 `opt` on disk and exports
414+
# LLVM_SYS_221_PREFIX, which is how the corpus script finds it. The
415+
# native corpus is `--trace llvm` output PLUS the production statepoint
416+
# rewrite, because codegen does not emit statepoints — LLVM does, later,
417+
# in the linker step.
418+
- uses: ./.github/actions/setup-llvm22
419+
420+
- name: Cache cargo
421+
uses: actions/cache@v6
422+
with:
423+
path: |
424+
~/.cargo/registry
425+
~/.cargo/git
426+
target
427+
key: ${{ runner.os }}-cargo-gcdom-${{ hashFiles('**/Cargo.lock') }}
428+
restore-keys: ${{ runner.os }}-cargo-
429+
430+
- name: Build perry and the runtime archives
431+
run: |
432+
set -euo pipefail
433+
cargo build --release -p perry -p perry-runtime-static -p perry-stdlib-static
434+
for artifact in perry libperry_runtime.a libperry_stdlib.a; do
435+
test -s "target/release/$artifact" \
436+
|| { echo "::error::target/release/$artifact was not produced"; exit 1; }
437+
done
438+
439+
# The script single-sources the pass string from
440+
# `STATEPOINT_REWRITE_PASSES` in perry-codegen and refuses to run if it
441+
# cannot read it, so this corpus cannot drift away from the pipeline
442+
# production uses. It also asserts its own subject: zero statepoints or
443+
# zero live bundles is an error at generation time, not a clean verdict
444+
# downstream.
445+
- name: Emit the NATIVE (statepoint) IR corpus
446+
run: ./scripts/gc_root_dominance_corpus.sh ir-corpus-native --lowering native
447+
448+
- name: Check GC values across safepoints (native roots)
449+
run: |
450+
set -euo pipefail
451+
# No pipe: the checker's own exit status is the job's.
452+
#
453+
# FLOORS, from the corpus as of this commit — 149 modules, 2452
454+
# functions, 30033 safepoints, 17478 with a live bundle, 40759
455+
# relocations — set below that with room for churn and far above
456+
# "something compiled". Three separate floors because they fail for
457+
# three different reasons and the message has to say which:
458+
# --min-statepoints this is the shadow corpus, or codegen stopped
459+
# marking functions gc "statepoint-example"
460+
# --min-relocates the corpus was copied through un-rewritten
461+
# (`opt` exits 0 on a module with nothing to do)
462+
# --min-live-bundles safepoints that record NO roots, which is what
463+
# an unrooted build looks like
464+
#
465+
# BUDGETS. `unrooted` (the object is in NO live bundle at all, so
466+
# nothing marks or rewrites it) is the serious class, and it is a
467+
# RATCHET at its measured value rather than a calibrated zero: this
468+
# is a new instrument pointed at a lowering nothing has ever checked
469+
# statically, and 21 is a population under triage, not a number
470+
# anyone has adjudicated. It is a budget rather than allowlist
471+
# entries for the reason `--stale-registers` records — 21 tombstones
472+
# with no issue numbers would be worse documentation than one number
473+
# that can only go down. Lower it as sites are fixed.
474+
#
475+
# The 21 are enumerated by shape in #7664, which is this budget's
476+
# referent: a number with nothing behind it is the thing CLAUDE.md
477+
# warns a threshold decays into.
478+
#
479+
# `stale` (the object survives and is relocated, but a raw copy of
480+
# its pre-move address is used below) reads 0 today and is held
481+
# there.
482+
#
483+
# --seeded-violations plants 40 safepoints into this very IR, between
484+
# a `ptrtoint ptr addrspace(1)` and its use, and requires all 40 to be
485+
# reported. It runs even when the budgets are already red, because
486+
# the run whose "can it still fail" arm you most want is the one that
487+
# is already telling you something. That is the arm that fails if the
488+
# mode has stopped understanding LLVM's output — the only way a green
489+
# verdict here could be a lie.
490+
python3 scripts/gc_root_dominance_check.py ir-corpus-native \
491+
--statepoints \
492+
--moving-only \
493+
--min-files 90 --min-funcs 1200 \
494+
--min-statepoints 15000 \
495+
--min-live-bundles 8000 \
496+
--min-relocates 20000 \
497+
--max-unrooted 21 \
498+
--max-stale 0 \
499+
--allowlist scripts/gc_root_dominance_allowlist.json \
500+
--seeded-violations 40 \
501+
-v
502+
503+
# ★ The unfiltered arm, as a DIAGNOSTIC rather than a gate.
504+
#
505+
# `--moving-only` keeps only hazards whose window reaches a safepoint
506+
# that can run an evacuating minor. The rest are still hazards under the
507+
# RECLAIM half of the invariant (a value held only in an untracked
508+
# register can be swept, not just moved), and the count is two orders of
509+
# magnitude larger: 1444 (1123 unrooted, 321 stale) against the gated
510+
# arm's 21. Printing it keeps the filtered number honest — if the gated
511+
# arm ever reads zero, this line says whether that is the corpus or the
512+
# filter. `|| true` is deliberate and is the ONLY one in this file —
513+
# this step is not a gate and must not be read as one.
514+
- name: Unfiltered hazard census (diagnostic, not a gate)
515+
# The explicit opt-out `gc_gate_wiring_check.py` looks for. Without it
516+
# that gate reads the `|| true` below as a swallowed failure -- which
517+
# is exactly right for a gating step and exactly wrong for this one, so
518+
# the difference has to be declared rather than argued in a comment.
519+
continue-on-error: true
520+
run: |
521+
python3 scripts/gc_root_dominance_check.py ir-corpus-native \
522+
--statepoints \
523+
--min-files 90 --min-funcs 1200 \
524+
--min-statepoints 15000 --min-live-bundles 8000 \
525+
--min-relocates 20000 \
526+
--max-unrooted 100000 --max-stale 100000 || true
527+
528+
- name: Upload the IR corpus on failure
529+
if: failure()
530+
uses: actions/upload-artifact@v7
531+
with:
532+
name: gc-root-dominance-statepoint-ir
533+
path: ir-corpus-native
534+
retention-days: 7

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
88

99
Perry is a native TypeScript compiler written in Rust that compiles TypeScript source code directly to native executables. It uses SWC for TypeScript parsing and LLVM for code generation.
1010

11-
**Current Version:** 0.5.1375
11+
**Current Version:** 0.5.1376
1212

1313

1414
## TypeScript Parity Status

0 commit comments

Comments
 (0)