@@ -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
0 commit comments