perf(runtime): let the GC header pick the side-registry probe on dynamic dispatch (#7850) #1732
Workflow file for this run
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
| name: GC Root Dominance | |
| # Static gate for the invariant a GC-managed value's root store must DOMINATE | |
| # every subsequent site that can trigger a collection (#7154). | |
| # | |
| # Two bugs of this class shipped before there was an instrument for it. #7184: | |
| # the root store was emitted but its shadow-slot index fell outside the pushed | |
| # frame, so `js_shadow_slot_bind` bounds-checked it into a silent no-op. #7192: | |
| # the store was emitted in-frame but AFTER a call that allocates. Both present | |
| # identically — a *rooted* slot holding a dangling pointer, surfacing cycles | |
| # later as "TypeError: value is not a function" — and neither is visible to any | |
| # runtime GC probe, because at the moment of the collection there is nothing | |
| # for the collector to find. A static pass over the emitted IR is the only | |
| # instrument that sees them before they crash, which is why this is a gate and | |
| # not a benchmark. | |
| # | |
| # THIS JOB IS DESIGNED TO BE ABLE TO FAIL, and is checked against all four ways | |
| # a gate can be unable to (CLAUDE.md): | |
| # | |
| # 1. no `continue-on-error`, no `|| true`, no pipe between the checker and | |
| # the shell's exit status; | |
| # 2. NOT yet in branch protection's required contexts. This was deliberate at | |
| # first — a new gate has never been green, so promoting it immediately | |
| # blocks every open PR — but the second step was never taken, and in the | |
| # meantime the job WAS red on `main` and blocked nothing: the five | |
| # ClassExprFresh hits now tracked in #7211 have been reported on every run | |
| # since #7198 and went unread. That is hazard 2 doing exactly what the | |
| # corollary in CLAUDE.md warns about. | |
| # | |
| # **ACTION REQUIRED, and it is not something this workflow can do to | |
| # itself**: a repo admin must add `gc-root-dominance` to branch | |
| # protection's required contexts. Until that happens this file is | |
| # documentation, not a gate. | |
| # | |
| # Both conditions #7198 named are met as of #7236: the dominance check is | |
| # green with an EMPTY allowlist, and `--unrooted-allocas --moving-only` -- | |
| # which was 98 before #7235 and 2 after -- now reads 0 and is a step | |
| # below. Promote after this job's first green run on `main` WITH that | |
| # step, not before: a gate that has never been green in its current shape | |
| # blocks every open PR the day it becomes required, which is the corollary | |
| # that produced this whole paragraph. | |
| # See docs/src/internals/gc-rooting-invariant.md, "Promoting this gate". | |
| # 3. `concurrency` cancels pull-request runs only, never `main` runs; | |
| # 4. the subject is ASSERTED live, not assumed, at three levels. | |
| # `--self-test` proves the checker still reports a planted violation in | |
| # hand-written IR and still clears the control. `--seeded-violations` | |
| # goes further and plants collection points into the REAL corpus, | |
| # requiring every one to be reported -- that is what catches the case | |
| # where perry's emitted IR drifts to a shape the parser can no longer | |
| # read, which frozen fixtures cannot detect. And `--min-files` / | |
| # `--min-binds` / `--min-funcs` refuse a clean verdict over a corpus with | |
| # too few modules, root stores or functions to have exercised anything. | |
| # An empty `.perry-trace/llvm` is a routine outcome of a failed compile, | |
| # so "0 violations" over 0 files must be an error rather than a pass. | |
| # | |
| # Known-remaining violations live in scripts/gc_root_dominance_allowlist.json, | |
| # one named entry each with an issue and a written justification -- NOT a | |
| # numeric threshold, which cannot tell a new violation from an old one. An | |
| # entry that matches nothing fails the build, so a fixed bug's entry must be | |
| # deleted rather than left to widen coverage later. | |
| on: | |
| pull_request: | |
| push: | |
| branches: [main] | |
| workflow_dispatch: | |
| permissions: | |
| contents: read | |
| concurrency: | |
| # One group per main COMMIT, cancelling PR runs only. A shared group starves | |
| # `main` on a deep runner queue, and a gate that is always cancelled never | |
| # fails. `cancel-in-progress: false` is not enough on its own: GitHub allows | |
| # at most one PENDING run per group and cancels the previously pending one | |
| # when a new run enters, regardless of that setting (#7205 — measured on | |
| # gc-ratchet, whose shape this file copied). Keying push runs on the SHA gives | |
| # every merged commit a group of its own, so no two main runs can contend. | |
| # Same reasoning as gc-ratchet.yml, which carries the full writeup. | |
| group: gc-root-dominance-${{ github.event_name }}-${{ github.event_name == 'push' && github.sha || github.ref }} | |
| cancel-in-progress: ${{ github.event_name == 'pull_request' }} | |
| env: | |
| CARGO_TERM_COLOR: always | |
| MACOSX_DEPLOYMENT_TARGET: "13.0" | |
| jobs: | |
| gc-root-dominance: | |
| runs-on: macos-14 | |
| timeout-minutes: 90 | |
| steps: | |
| - uses: actions/checkout@v7 | |
| with: | |
| persist-credentials: false | |
| # Fast structural failure first: prove the checker can still fail before | |
| # spending a compiler build on it. This is the arm that would have caught | |
| # `PERRY_GC_FORCE_EVACUATE` being inert for every test that "exercised" | |
| # it (#6942/#6946). | |
| - name: Checker self-test (can this gate still fail?) | |
| run: python3 scripts/gc_root_dominance_check.py --self-test | |
| # The checker only reports a stale register when it recognises what | |
| # MATERIALIZED the value, and that recognition is one regex. An | |
| # alternative in it that matches no real symbol reads as coverage and is | |
| # not -- the gate-can't-fail pattern in regex form. It has shipped nine | |
| # times across two rounds: four (`regexp_alloc\w*`, `promise_alloc\w*`, | |
| # `bigint_alloc\w*`, `typed_array_alloc\w*`) found only because | |
| # `js_regexp_new` cost #7154 a whole investigation round, and five more | |
| # introduced by the change that removed those four. A prose audit does not | |
| # survive its own next edit, so it is a gate now: every alternative must | |
| # match at least one `extern "C" fn js_*` the runtime actually exports. | |
| # | |
| # Static and instant — no toolchain, no corpus — so it runs before the | |
| # build with the self-test. | |
| - name: ALLOC_RE alternatives must match real runtime symbols | |
| run: python3 scripts/gc_root_dominance_check.py --audit-alloc-re | |
| # The same hazard on the other axis, and a sharper one. `ALLOC_RE` | |
| # decides whether a register has a heap-value source; `POLL_CAPABLE_RUNTIME` | |
| # decides whether the window around it is MOVING -- which is the arm the | |
| # two `--moving-only` steps below actually gate on. An entry naming a | |
| # symbol that does not exist can never classify a window, so it reads as | |
| # coverage of an operation the gate is in fact blind to, and unlike a dead | |
| # regex alternative it survives review because the misspelling is a | |
| # PLAUSIBLE name for a real operation. | |
| # | |
| # Ten of twenty-eight entries were phantoms when this step was added, | |
| # including four separate spellings of "call a JS closure" (`js_call_closure`, | |
| # `js_invoke_closure`, `js_function_call`, `js_apply_function`) where the | |
| # real symbols are `js_closure_callN`. The consequence was measured at | |
| # zod's `clone`: the from-space protector faulted deterministically on a | |
| # window that `--stale-registers` classified `MOVING: no`, so the | |
| # `--moving-only` arm could not see it. | |
| # | |
| # Static and instant, like the audit above, so it runs before the build. | |
| - name: POLL_CAPABLE_RUNTIME entries must name real runtime symbols | |
| run: python3 scripts/gc_root_dominance_check.py --audit-poll-capable | |
| # ★ The THIRD audit, and the direction the other two structurally cannot | |
| # look (#7616). Both of those hunt a NAME WITH NO REFERENT: a dead | |
| # ALLOC_RE alternative, a phantom POLL_CAPABLE_RUNTIME entry. Nothing | |
| # hunted a REFERENT WITH NO NAME -- a real allocating symbol that is | |
| # missing from POLL_CAPABLE_RUNTIME -- and a missing entry suppresses | |
| # findings exactly as silently as a phantom one. | |
| # | |
| # #7453 is the worked example and it cost a shipped use-after-free. | |
| # `new URL(input, base)` held a raw `*mut StringHeader` across the | |
| # lowering of `base`; the fix added `url_coerce_string` to ALLOC_RE, | |
| # said in its own comment "that gap is why the checker did not flag | |
| # #7453", and stopped one list short. Re-planting that exact code and | |
| # running EVERY mode this workflow has (#7616's measurement): | |
| # | |
| # --moving-only (dominance) 0 clean -> 0 sabotaged | |
| # --unrooted-allocas --moving-only 0 -> 0 | |
| # --stale-registers --moving-only 2 -> 2 | |
| # --statepoints --moving-only 2 -> 2 <- the mode | |
| # that reads the lowering that SHIPS, added in #7663 | |
| # --stale-registers (unfiltered) 24 -> 35 | |
| # --statepoints (unfiltered) 15 -> 21 | |
| # | |
| # Every GATED arm blind, both UNFILTERED arms not. The single missing | |
| # list entry was all of it: adding `js_url_coerce_string` takes the | |
| # sabotaged arms to 13 and 8 and leaves the clean arms at 2 and 2. | |
| # | |
| # Static and instant, like the two above, so it runs before the build. | |
| - name: ALLOC_RE symbols that reach a poll-capable one must be listed | |
| run: python3 scripts/gc_root_dominance_check.py --audit-poll-reach | |
| # `--unrooted-allocas` exempts two heap-value sources whose objects can | |
| # neither move nor be reclaimed (#7210): the old-arena class-keys array, | |
| # and `js_box_alloc*`'s Box, which is not a GC-heap allocation at all. | |
| # Each exemption SUPPRESSES reports, and each is only as sound as | |
| # premises that live in the runtime crates and can change without anyone | |
| # touching this checker — the class-keys array staying `_longlived`, | |
| # old-page defrag staying off by default, the keys global staying a | |
| # registered root, and boxes staying `std::alloc::alloc` with no free | |
| # path. | |
| # | |
| # An exemption whose premise has quietly lapsed is strictly WORSE than no | |
| # exemption: it reads as a triaged false positive and is a live hazard. | |
| # So the premises are a gate rather than a paragraph. Static and instant, | |
| # like the audit above, so it runs before the build. | |
| - name: Immovable-source exemptions must still hold | |
| run: python3 scripts/gc_root_dominance_check.py --audit-immovable-sources | |
| - name: Install Rust toolchain | |
| uses: dtolnay/rust-toolchain@stable | |
| - uses: ./.github/actions/setup-llvm22 | |
| # ★ The dependency-scale corpus needs the dependency. | |
| # | |
| # `zod` is this repo's own package.json devDependency, pinned by | |
| # package-lock.json and governed by the same soak window as everything | |
| # else in that file -- not a fixture invented for this job. | |
| # `--ignore-scripts` because nothing here needs a lifecycle script to | |
| # run, and a corpus generator is a bad place to execute one. | |
| - uses: actions/setup-node@v6 | |
| with: | |
| node-version-file: .node-version | |
| - name: Install the npm dependencies the dep corpus compiles | |
| run: npm ci --ignore-scripts --no-audit --no-fund | |
| - name: Cache cargo | |
| uses: actions/cache@v6 | |
| with: | |
| path: | | |
| ~/.cargo/registry | |
| ~/.cargo/git | |
| target | |
| key: ${{ runner.os }}-cargo-gcdom-${{ hashFiles('**/Cargo.lock') }} | |
| restore-keys: ${{ runner.os }}-cargo- | |
| - name: Build perry and the runtime archives | |
| run: | | |
| set -euo pipefail | |
| # perry-runtime and perry-stdlib are rlib-only; the .a files come from | |
| # the -static wrapper crates. The package set is fixed so cargo | |
| # feature unification matches every other job that builds the | |
| # compiler. | |
| cargo build --release -p perry -p perry-runtime-static -p perry-stdlib-static | |
| for artifact in perry libperry_runtime.a libperry_stdlib.a; do | |
| test -s "target/release/$artifact" \ | |
| || { echo "::error::target/release/$artifact was not produced"; exit 1; } | |
| done | |
| # The source list and the env knobs live in the script, not here, so that | |
| # reproducing a CI failure is one command rather than a re-read of this | |
| # YAML. A retyped invocation that drops PERRY_GC_MOVING_LOOP_POLLS | |
| # produces IR in which the bug is not expressible at all, and the local | |
| # run then "cannot reproduce" a real finding. | |
| - name: Emit the IR corpus | |
| run: ./scripts/gc_root_dominance_corpus.sh ir-corpus | |
| - name: Check root-store dominance | |
| run: | | |
| set -euo pipefail | |
| # No pipe: the checker's own exit status is the job's. | |
| # | |
| # The floors are asserted, not hoped for. On the corpus as of this | |
| # commit the run reports ~1993 functions / 117 modules / 2501 root | |
| # stores, so these sit below that with room for churn and well above | |
| # "something compiled". Raise them when the corpus grows; never lower | |
| # one to make a run pass -- a shrinking corpus is the finding. | |
| # | |
| # --seeded-violations plants 40 collection points into this very IR | |
| # and requires all 40 to be reported. That is the arm that fails if | |
| # the checker has stopped understanding perry's output, which is the | |
| # only way a green verdict here could be a lie. | |
| python3 scripts/gc_root_dominance_check.py ir-corpus \ | |
| --moving-only \ | |
| --min-files 90 --min-binds 1500 --min-funcs 1200 \ | |
| --allowlist scripts/gc_root_dominance_allowlist.json \ | |
| --seeded-violations 40 \ | |
| -v | |
| # ★ The OTHER mode, and until #7236 it had never been at zero. | |
| # | |
| # `--unrooted-allocas` asks a different question from the step above: not | |
| # "is the root store late" but "is there a root store AT ALL". #7235 split | |
| # its heap-source predicate by movability and got the count from 98 to 2, | |
| # and both residuals were one bug -- `Type::Symbol` classified as an | |
| # immediate, so a `Symbol` local got no shadow slot. That is now fixed and | |
| # the corpus reads 0, which is the condition #7198 named for promoting | |
| # this job to a required context. | |
| # | |
| # A number that is the acceptance criterion for a promotion and is checked | |
| # by nothing will regress silently -- CLAUDE.md hazard 4, applied to the | |
| # measurement rather than to the job. So it is a step. It shares the | |
| # corpus, the exemptions and the allowlist file with the step above, and | |
| # carries the same liveness floors: "0 violations" over a corpus that did | |
| # not compile means nothing. | |
| # | |
| # If this arm ever goes red for a hazard that is real but deliberately | |
| # deferred, the answer is an entry in gc_root_dominance_allowlist.json | |
| # with an issue number, NOT deleting the step -- rule 1 in that file | |
| # (an entry matching nothing fails the build) is what makes the tolerance | |
| # temporary. | |
| - name: Check that every GC value in an alloca has a root store | |
| run: | | |
| set -euo pipefail | |
| python3 scripts/gc_root_dominance_check.py ir-corpus \ | |
| --unrooted-allocas \ | |
| --moving-only \ | |
| --min-files 90 --min-binds 1500 --min-funcs 1200 \ | |
| --allowlist scripts/gc_root_dominance_allowlist.json \ | |
| -v | |
| # ★★ The DEPENDENCY-SCALE corpus (#7280). | |
| # | |
| # Everything above this line runs over ~124 hand-written `test-files/` | |
| # sources. That corpus read ZERO in both gated modes while twenty lines of | |
| # stock `zod` faulted deterministically under the from-space protector, | |
| # and #7280 records the gap in one sentence: 25 curated files pass while | |
| # 20 lines of stock zod fail. | |
| # | |
| # It is not a size problem, it is a distribution problem. Measured with | |
| # `--stale-registers --moving-only` on the same compiler: | |
| # | |
| # curated (124 sources, 144 modules): 116 stale uses, and what | |
| # dominates is property-GET helper windows and js_number_coerce | |
| # dependency-scale (81 modules, 62 MB): 370 stale uses, and what | |
| # dominates is js_object_assign_one (object spread, 137) and | |
| # js_new_function_construct (102) -- populations the curated corpus | |
| # produces 12 and 1 of | |
| # | |
| # Nothing is sampled away: all 81 modules and all 62 MB are checked. The | |
| # cost is ~8s to emit and ~4s for the two gated arms below, because those | |
| # arms are linear in instruction count. The `--stale-registers` ratchet is | |
| # the expensive one (~5 min) and says so where it runs. | |
| - name: Emit the dependency-scale IR corpus | |
| run: ./scripts/gc_root_dominance_dep_corpus.sh ir-corpus-dep | |
| - name: Check root-store dominance (dependency-scale) | |
| run: | | |
| set -euo pipefail | |
| # Floors from the corpus as of this commit (81 modules, ~12900 | |
| # functions, ~7700 root stores), set below that with room for the | |
| # dependency's own churn. `zod` growing is fine; `zod` no longer | |
| # compiling natively is the finding, and these are what make it one. | |
| python3 scripts/gc_root_dominance_check.py ir-corpus-dep \ | |
| --moving-only \ | |
| --min-files 60 --min-binds 4000 --min-funcs 6000 \ | |
| --allowlist scripts/gc_root_dominance_allowlist.json \ | |
| --seeded-violations 40 \ | |
| -v | |
| - name: Check that every GC value in an alloca has a root store (dependency-scale) | |
| run: | | |
| set -euo pipefail | |
| python3 scripts/gc_root_dominance_check.py ir-corpus-dep \ | |
| --unrooted-allocas \ | |
| --moving-only \ | |
| --min-files 60 --min-binds 4000 --min-funcs 6000 \ | |
| --allowlist scripts/gc_root_dominance_allowlist.json \ | |
| -v | |
| # ★ The stale-register RATCHET, on both corpora. | |
| # | |
| # `--stale-registers` asks the third question: not "is the root store | |
| # late" and not "is there a root store at all", but "is a register holding | |
| # a rooted value used below a collection point". It is the mode that found | |
| # #7206's two bugs and the mode #7280's zod fault lives in, and until now | |
| # it ran only by hand -- so its number could move in either direction | |
| # between one investigation and the next with nothing to say so. | |
| # | |
| # It is a BUDGET rather than an allowlist because the residual is a | |
| # population, not a list of triaged sites: the remaining uses are the ones | |
| # whose slot the program itself reassigns inside the window, which need a | |
| # temp root rather than a re-read (see crate::root_reload). A budget can | |
| # only be lowered, and lowering it is the ratchet. | |
| # | |
| # This step is minutes, not seconds -- the scan is superlinear in | |
| # instruction count and the dependency corpus is 62 MB. That is the price | |
| # of checking the population that actually breaks. | |
| - name: Stale-register budget (curated) | |
| run: | | |
| set -euo pipefail | |
| python3 scripts/gc_root_dominance_check.py ir-corpus \ | |
| --stale-registers --moving-only \ | |
| --min-files 90 --min-binds 1500 --min-funcs 1200 \ | |
| --max-stale 39 | |
| # 86 -> 104 at #7616: widening POLL_CAPABLE_RUNTIME by 77 symbols makes | |
| # windows MOVING that `--moving-only` previously dropped. Still inside | |
| # the pinned 118, so the budget is deliberately NOT raised — a ratchet | |
| # you loosen every time it gets closer is not a ratchet. | |
| - name: Stale-register budget (dependency-scale) | |
| run: | | |
| set -euo pipefail | |
| python3 scripts/gc_root_dominance_check.py ir-corpus-dep \ | |
| --stale-registers --moving-only \ | |
| --min-files 60 --min-binds 4000 --min-funcs 6000 \ | |
| --max-stale 118 | |
| - name: Upload the IR corpus on failure | |
| if: failure() | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: gc-root-dominance-ir | |
| path: | | |
| ir-corpus | |
| ir-corpus-dep | |
| retention-days: 7 | |
| # ★★★ THE LOWERING THAT ACTUALLY SHIPS (#7663). | |
| # | |
| # Everything in the job above compiles its corpus under `PERRY_RS4GC=0` — the | |
| # SHADOW-STACK lowering — and since #7370 that is not the default on any | |
| # target whose frames the runtime can walk. The reason was sound and is | |
| # stated inline in `gc_root_dominance_corpus.sh`: the checker anchored on | |
| # `@js_shadow_slot_bind`, the native lowering emits zero of them, and | |
| # `--min-binds` therefore failed the job. So the gate was pointed at the only | |
| # lowering it could read, and `docs/src/internals/gc-rooting-invariant.md` | |
| # records the consequence as a blind spot: **a green `gc-root-dominance` was | |
| # evidence about a lowering that does not ship.** | |
| # | |
| # This job closes that. It is a SEPARATE job, not more steps in the one | |
| # above, for three reasons: it needs a differently-generated corpus, its | |
| # floors are about safepoints rather than root stores, and — the one that | |
| # matters — a separate job is a separate branch-protection context, so this | |
| # can be promoted to required on its own schedule without dragging the | |
| # shadow arms with it. The price is a second compiler build; `needs:` would | |
| # avoid it and was rejected, because it would make this job unrunnable | |
| # whenever the shadow job is red — precisely when someone wants to know | |
| # whether the OTHER lowering is affected. | |
| # | |
| # NOT REQUIRED, deliberately, and this is the second step nobody took last | |
| # time. A gate that has never been green blocks every open PR the day it | |
| # becomes required (CLAUDE.md's corollary, which this repo has already paid | |
| # for once — see hazard 2 in the header of this file). Let it run on `main` | |
| # first, then promote. Promotion is an admin action and is tracked separately | |
| # from the PR that adds this. | |
| gc-root-dominance-statepoints: | |
| runs-on: macos-14 | |
| timeout-minutes: 90 | |
| steps: | |
| - uses: actions/checkout@v7 | |
| with: | |
| persist-credentials: false | |
| # Same fast structural failure first. `--self-test` now covers BOTH | |
| # lowerings: the statepoint half asserts the bundle parser reads the | |
| # wrapped callee and a non-empty live set (the two mistakes the Rust | |
| # reader of this construct already made and caught), that the two verdict | |
| # classes are distinguished by the bundle alone, that the relocated-value | |
| # control reports zero, and that `--min-statepoints` refuses the shadow | |
| # corpus while `--min-binds` refuses this one. | |
| - name: Checker self-test (can this gate still fail?) | |
| run: python3 scripts/gc_root_dominance_check.py --self-test | |
| # The FOUR static audits are shared vocabulary, not shadow-mode | |
| # details: `--statepoints` classifies its sources with the same ALLOC_RE, | |
| # decides MOVING with the same POLL_CAPABLE_RUNTIME, and honours the same | |
| # #7210 exemptions. A phantom entry in any of them is a hole in THIS gate | |
| # too, so all four run here as well rather than being inherited by | |
| # assumption from a job that may not have run. | |
| # | |
| # `--audit-poll-reach` matters most HERE, because #7616's measurement was | |
| # taken in this job's mode: the re-planted #7453 shape reported 2 clean | |
| # and 2 sabotaged under `--statepoints --moving-only`, i.e. the newest | |
| # mode was as blind as the three it was added to cover for, and for the | |
| # same one-line reason. The full census is in the shadow job above. | |
| - name: ALLOC_RE alternatives must match real runtime symbols | |
| run: python3 scripts/gc_root_dominance_check.py --audit-alloc-re | |
| - name: POLL_CAPABLE_RUNTIME entries must name real runtime symbols | |
| run: python3 scripts/gc_root_dominance_check.py --audit-poll-capable | |
| - name: ALLOC_RE symbols that reach a poll-capable one must be listed | |
| run: python3 scripts/gc_root_dominance_check.py --audit-poll-reach | |
| - name: Immovable-source exemptions must still hold | |
| run: python3 scripts/gc_root_dominance_check.py --audit-immovable-sources | |
| - name: Install Rust toolchain | |
| uses: dtolnay/rust-toolchain@stable | |
| # This also puts a matched LLVM 22 `opt` on disk and exports | |
| # LLVM_SYS_221_PREFIX, which is how the corpus script finds it. The | |
| # native corpus is `--trace llvm` output PLUS the production statepoint | |
| # rewrite, because codegen does not emit statepoints — LLVM does, later, | |
| # in the linker step. | |
| - uses: ./.github/actions/setup-llvm22 | |
| - name: Cache cargo | |
| uses: actions/cache@v6 | |
| with: | |
| path: | | |
| ~/.cargo/registry | |
| ~/.cargo/git | |
| target | |
| key: ${{ runner.os }}-cargo-gcdom-${{ hashFiles('**/Cargo.lock') }} | |
| restore-keys: ${{ runner.os }}-cargo- | |
| - name: Build perry and the runtime archives | |
| run: | | |
| set -euo pipefail | |
| cargo build --release -p perry -p perry-runtime-static -p perry-stdlib-static | |
| for artifact in perry libperry_runtime.a libperry_stdlib.a; do | |
| test -s "target/release/$artifact" \ | |
| || { echo "::error::target/release/$artifact was not produced"; exit 1; } | |
| done | |
| # The script single-sources the pass string from | |
| # `STATEPOINT_REWRITE_PASSES` in perry-codegen and refuses to run if it | |
| # cannot read it, so this corpus cannot drift away from the pipeline | |
| # production uses. It also asserts its own subject: zero statepoints or | |
| # zero live bundles is an error at generation time, not a clean verdict | |
| # downstream. | |
| - name: Emit the NATIVE (statepoint) IR corpus | |
| run: ./scripts/gc_root_dominance_corpus.sh ir-corpus-native --lowering native | |
| - name: Check GC values across safepoints (native roots) | |
| run: | | |
| set -euo pipefail | |
| # No pipe: the checker's own exit status is the job's. | |
| # | |
| # FLOORS, from the corpus as of this commit — 149 modules, 2452 | |
| # functions, 30033 safepoints, 17478 with a live bundle, 40759 | |
| # relocations — set below that with room for churn and far above | |
| # "something compiled". Three separate floors because they fail for | |
| # three different reasons and the message has to say which: | |
| # --min-statepoints this is the shadow corpus, or codegen stopped | |
| # marking functions gc "statepoint-example" | |
| # --min-relocates the corpus was copied through un-rewritten | |
| # (`opt` exits 0 on a module with nothing to do) | |
| # --min-live-bundles safepoints that record NO roots, which is what | |
| # an unrooted build looks like | |
| # | |
| # BUDGETS. `unrooted` (the object is in NO live bundle at all, so | |
| # nothing marks or rewrites it) is the serious class, and it is a | |
| # RATCHET rather than a calibrated zero: this is a young instrument | |
| # pointed at a lowering nothing had ever checked statically, and the | |
| # remainder is a population under triage rather than a number anyone | |
| # has adjudicated. It is a budget rather than allowlist entries for | |
| # the reason `--stale-registers` records — tombstones with no issue | |
| # numbers would be worse documentation than one number that can only | |
| # go down. Lower it as sites are fixed. | |
| # | |
| # 21 at #7663. #7664 fixed 14 of them in `root_reload.rs` and | |
| # `lower_call/new.rs` — the whole `strhandle` population (10), the | |
| # `js_new_target_get` save/restore (1), and 3 of the 7 unmasked | |
| # receivers — so the budget is 7. #7616 (widening POLL_CAPABLE_RUNTIME) | |
| # and #7690 (rooting the spread argument-bundle accumulator) moved it | |
| # 7 -> 11 -> 8, tracked in the two changelog fragments for those PRs. | |
| # | |
| # ★ RE-VERIFIED AT #7664's SECOND PASS: the 8 above was already stale | |
| # by the time this ran. A fresh corpus read 9, not 8 — a static-method | |
| # receiver hazard in `test_gap_static_method_value_name_collision` | |
| # (added after the "8" snapshot, by the #7689/#7691 fix that armed a | |
| # receiver-sensitive static `this`) had joined the population without | |
| # anyone lowering — er, RAISING — the budget to match. That in itself | |
| # is the thing CLAUDE.md's hazard-4 corollary warns about: a budget | |
| # nobody re-measures silently absorbs the next hazard. | |
| # | |
| # Of the 9: 4 were CHECKER FALSE POSITIVES, not real hits. `chain` | |
| # (the untracked cast-closure a stale use is searched in) treated | |
| # `phi` as unconditionally transparent — one tainted incoming edge | |
| # blanket-tainted the phi's RESULT, and a downstream use of that | |
| # result was checked against ANY CFG path from source to use | |
| # (`between_blocks` is deliberately path-insensitive, which is sound | |
| # for an ordinary register but not a phi, whose dynamic value depends | |
| # on which edge was actually taken). All four were the identical | |
| # shape, e.g. `readCtx`: | |
| # | |
| # entry.0: %r2 = <unmask of the receiver> | |
| # br i1 %r4, label %then, label %merge | |
| # merge: %r87 = phi double [ %r2, %entry.0 ], | |
| # [ %r86, %pget.recv_merge ] | |
| # ret double %r87 | |
| # | |
| # The safepoints are all on the `%then` path, where the phi selects | |
| # `%r86`. On the edge that carries `%r2` nothing collects between its | |
| # definition and the join — the checker was reporting the OTHER | |
| # edge's safepoint against THIS edge's value. Verified register-by- | |
| # register on all four (`readCtx`, `__closure_5`, `Readable`, | |
| # `__obj_method_toLocaleString_3` — every one a `logical.merge` join | |
| # of an `&&`). | |
| # | |
| # Fixed: `_cast_closure` gained `phi_all_edges` — a phi joins `chain` | |
| # only once EVERY incoming edge is independently in it, closing the | |
| # false positive. That deliberately gives up the case of a SINGLE | |
| # tainted edge with its own intervening safepoint before its own | |
| # predecessor's terminator; `_phi_edge_hazard` covers that separately, | |
| # checking each edge's window on its own. Two sabotage-tested | |
| # self-test fixtures (`phi_safe_edge` / `phi_hazard_edge`) pin both | |
| # directions. | |
| # | |
| # The remaining 5 were real, and split two ways: | |
| # | |
| # 3 unrooted:global. `test_gap_arraybuffer_transfer::main` (2, | |
| # `new DataView`/`new Uint8Array` reading a module-global | |
| # receiver, then holding it across a sibling `{ valueOf() {...} }` | |
| # argument's allocation) — fixed upstream by #7719, which covers | |
| # the identical shape across all 30 `lower_call/builtin.rs` ctor | |
| # arms via a shared `RootedGroup`. `test_gap_static_method_value_ | |
| # name_collision::main` (1, `(Lexer as any).lex(...)`'s receiver | |
| # held across arg-bundling that always allocates for a rest | |
| # param) — fixed here, in | |
| # `lower_call/property_get/static_dispatch.rs`, the same | |
| # `RootedGroup::adopt`/`reread` shape on the receiver instead of | |
| # a constructor argument. | |
| # 2 unrooted:capture. `js_closure_get_capture_bits` returns a raw | |
| # `i64` that may be a NaN-boxed heap value, and unlike | |
| # `%this_closure` itself (which codegen already re-enters into | |
| # the `ptr addrspace(1)` tracked domain), its generic "read a | |
| # captured value" call sites never re-enter it into either that | |
| # domain or a temp root. | |
| # `test_gap_class_expr_dynamic_parent_ctor::__closure_21`: a | |
| # captured dynamic-parent-class reference read at the top of the | |
| # synthesized implicit ctor closure, used ~60 lines later as | |
| # `js_new_function_construct`'s callee, across | |
| # `js_object_alloc_class_inline_keys` AND the class's own user | |
| # constructor. | |
| # `test_gap_computed_key_method_nested_this::__closure_9`: a | |
| # captured numeric local read, then `total + x`-style `fadd` | |
| # after `js_number_coerce` (which can run a user | |
| # `Symbol.toPrimitive`) intervenes. NOT reloadable the way a | |
| # strhandle global is (re-deriving from `%this_closure` reads | |
| # the pre-move closure — RS4GC does not relocate a raw `i64` | |
| # parameter) — but IS reloadable the way OTHER root-derived | |
| # values are: re-calling `js_closure_get_capture_bits` with the | |
| # same closure pointer and index re-reads the same slot, sound | |
| # under the identical store side-condition | |
| # (`js_closure_set_capture_bits` to that index) `root_reload.rs` | |
| # already tracks for shadow slots and handle globals. | |
| # | |
| # ★ #7725 CLOSED both `unrooted:capture` hits, and the budget is | |
| # deleted rather than set to 0 — `--max-unrooted` already defaults | |
| # to 0, and a budget nobody re-measures is exactly the silently- | |
| # absorbing-the-next-hazard failure mode CLAUDE.md's hazard-4 | |
| # corollary warns about (the "8 was already stale, a fresh corpus | |
| # read 9" paragraph above is that failure mode, once). The fix: | |
| # `root_reload.rs`'s `Facts` gained `capture_get_key`, so a | |
| # `js_closure_get_capture_bits` call whose ptr operand already | |
| # belongs to a reloadable recipe (the closure-ptr shadow-slot load | |
| # chain #7055 already protects) extends that recipe like a | |
| # transparent bit op — the whole chain (slot load, mask, a FRESH | |
| # call to `js_closure_get_capture_bits` with the same index, and | |
| # any further cast) re-materialises at every reload point. The | |
| # store side-condition switches from "was the closure-ptr slot | |
| # reassigned" (never, in generated code) to "was THIS INDEX set" — | |
| # a synthetic per-index key a `js_closure_set_capture_bits` call | |
| # populates via the same `stores_to` field a plain store uses, | |
| # which needed the `recipe[0]`-only group key widened to | |
| # `(recipe[0], root_ptr)` so the two conditions don't get | |
| # conflated under one reachability walk. Verified: native corpus | |
| # 2 -> 0 (`stale` held at 0), `--lowering shadow` corpus clean, | |
| # `--self-test` still proves the checker can fail. | |
| # | |
| # --seeded-violations plants 40 safepoints into this very IR, between | |
| # a `ptrtoint ptr addrspace(1)` and its use, and requires all 40 to be | |
| # reported. It runs even when the budgets are already red, because | |
| # the run whose "can it still fail" arm you most want is the one that | |
| # is already telling you something. That is the arm that fails if the | |
| # mode has stopped understanding LLVM's output — the only way a green | |
| # verdict here could be a lie. | |
| python3 scripts/gc_root_dominance_check.py ir-corpus-native \ | |
| --statepoints \ | |
| --moving-only \ | |
| --min-files 90 --min-funcs 1200 \ | |
| --min-statepoints 15000 \ | |
| --min-live-bundles 8000 \ | |
| --min-relocates 20000 \ | |
| --max-stale 0 \ | |
| --allowlist scripts/gc_root_dominance_allowlist.json \ | |
| --seeded-violations 40 \ | |
| -v | |
| # ★ The unfiltered arm, as a DIAGNOSTIC rather than a gate. | |
| # | |
| # `--moving-only` keeps only hazards whose window reaches a safepoint | |
| # that can run an evacuating minor. The rest are still hazards under the | |
| # RECLAIM half of the invariant (a value held only in an untracked | |
| # register can be swept, not just moved), and the count is two orders of | |
| # magnitude larger: 1444 (1123 unrooted, 321 stale) against the gated | |
| # arm's 21. Printing it keeps the filtered number honest — if the gated | |
| # arm ever reads zero, this line says whether that is the corpus or the | |
| # filter. `|| true` is deliberate and is the ONLY one in this file — | |
| # this step is not a gate and must not be read as one. | |
| - name: Unfiltered hazard census (diagnostic, not a gate) | |
| # The explicit opt-out `gc_gate_wiring_check.py` looks for. Without it | |
| # that gate reads the `|| true` below as a swallowed failure -- which | |
| # is exactly right for a gating step and exactly wrong for this one, so | |
| # the difference has to be declared rather than argued in a comment. | |
| continue-on-error: true | |
| run: | | |
| python3 scripts/gc_root_dominance_check.py ir-corpus-native \ | |
| --statepoints \ | |
| --min-files 90 --min-funcs 1200 \ | |
| --min-statepoints 15000 --min-live-bundles 8000 \ | |
| --min-relocates 20000 \ | |
| --max-unrooted 100000 --max-stale 100000 || true | |
| - name: Upload the IR corpus on failure | |
| if: failure() | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: gc-root-dominance-statepoint-ir | |
| path: ir-corpus-native | |
| retention-days: 7 |