perf: reuse guarded ECS entity indices #3095
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
| # #7173: native-frame GC root verification. | |
| # | |
| # Runs on an ARM64 runner deliberately. The backend is aarch64-only: on x86-64 | |
| # every root is `Indirect [RSP + off]` (DWARF register 7), which the runtime | |
| # cannot resolve — `_Unwind_GetGR` does not reliably return the stack pointer — | |
| # so the collector segfaults. The compiler now refuses that combination | |
| # outright, which would make an x86-64 run of this gate test nothing but the | |
| # refusal. ARM64 exercises the configuration that is actually supported, and | |
| # still answers the question this gate exists for: whether the compact map | |
| # survives ELF linking. | |
| # | |
| # | |
| # Runs the gc-ratchet probe matrix in every native-root mode under forced | |
| # evacuation + evacuation verification, byte-diffed against the pinned Node | |
| # oracle. Each arm carries a liveness assert, because CLAUDE.md's fourth way a | |
| # gate cannot fail is the one that looks green: the job runs, but its subject | |
| # never did. `PERRY_GC_FORCE_EVACUATE` was inert for every `gc()`-driven test | |
| # for months (#6942/#6946) and the matrix's `--pressure` knob disabled the very | |
| # path it was measuring (#7024) — both were green the whole time. | |
| # | |
| # ── Why the matrix runs on macos-14 (aarch64) and not ubuntu-latest ───────── | |
| # | |
| # It used to say ubuntu-latest, and it had never once gone green there. The | |
| # first explanation written here — that the compact-map rewriter cannot parse an | |
| # x86-64 stack map, with `gc_map.rs`'s aarch64 register names as the suspect — | |
| # was WRONG, and is recorded as wrong because it survived into an issue (#7321) | |
| # and a job name before anyone measured it. | |
| # | |
| # What is actually true, measured both by cross-compiling a probe to | |
| # x86_64-unknown-linux-gnu and decoding the emitted map (#7324) and by five | |
| # clang versions x twelve `-march` settings x all nine probes from two hosts | |
| # (#7331): **x86-64 stack maps parse fine.** Every root is | |
| # `Indirect [RSP + off]`, DWARF register 7, which round-trips through the | |
| # compact format's explicit-register tag exactly. | |
| # | |
| # The defect is one layer down, at collection time. `chain_walkable` admits only | |
| # aarch64's DWARF 29/31, so on x86-64 every frame falls back to the platform | |
| # unwinder, which resolves the base with `_Unwind_GetGR(ctx, 7)`. | |
| # | |
| # MEASURED 2026-08-03 (#7333), and it is worse than the "unreliable value" this | |
| # comment used to claim: that call SEGFAULTS. Probed on x86-64 Linux (glibc 2.39, | |
| # gcc 13.3.0), one register per run from an `_Unwind_Backtrace` callback — RBX | |
| # (3), RBP (6) and RIP (16) return correctly; RAX (0) and RSP (7) both SIGSEGV. | |
| # The split is callee-saved versus not: libgcc tracks only the columns CFI | |
| # restores, and RSP is not one of them (it is *derived* from the CFA), so reg 7 | |
| # is the single lookup guaranteed to fault — and it is the only register x86-64 | |
| # roots use. | |
| # | |
| # So the fault is IN the `_Unwind_GetGR` call, not in a later write through a | |
| # wild address, and no address validation after it can help. The wording here | |
| # before was a guess, and it pointed at the wrong fix. | |
| # | |
| # x86-64 was refused outright until #7349 taught the runtime to derive an | |
| # SP-relative base from the CFA; it is a first-class arm of the matrix now. The | |
| # note that used to sit here — that an x86-64 run could only ever produce a | |
| # binary crashing under collection, so such a run would test nothing but the | |
| # refusal — described the world before #7349 and is no longer true. | |
| # | |
| # The same walk is unsound on aarch64 **Linux** too, where it is merely the | |
| # non-default path: #7333. | |
| # | |
| # ── RUSTFLAGS ─────────────────────────────────────────────────────────────── | |
| # | |
| # `-C force-unwind-tables=yes` is NOT optional and is NOT redundant with | |
| # .cargo/config.toml. Cargo takes rustflags from exactly one source, so setting | |
| # the RUSTFLAGS env var here REPLACES the config file's `[build] rustflags` | |
| # wholesale — the config file says so in a comment, and this workflow used to | |
| # set only `-Cforce-frame-pointers=yes` and lose it. Measured consequence, A/B'd | |
| # locally on the same tree: `09_try_catch_roots` aborts with "unwind tables are | |
| # missing from this runtime build (0 frame(s) visible to the unwinder)", and the | |
| # platform unwinder visits ZERO frames — so on any host where the x29 chain walk | |
| # is unavailable the native-root walker finds no roots at all, while forced | |
| # evacuation stays quiet because it enumerates roots through that same walker. | |
| # | |
| # ── The knobs this workflow exists to keep honest ─────────────────────────── | |
| # | |
| # CLAUDE.md's GC knob kill-policy: an arm exercising the non-default state, or | |
| # delete the mode. | |
| # | |
| # PERRY_GC_SAFEPOINT_ONLY -> NOTHING. This entry was false: no step in this | |
| # file, or any other, ever set the variable. Left | |
| # spelled out rather than quietly deleted, because | |
| # a ledger that has been wrong once has to say so. | |
| # PERRY_STACKMAP_WALKER -> native-roots-rs4gc, "Both non-default walkers" | |
| # step. Also false until #7392 — the entry claimed | |
| # an arm that did not exist, and both walkers it | |
| # named were carrying real bugs the whole time: | |
| # `unwind` placed every SP-relative root one frame | |
| # too low, and `verify` could not run at all | |
| # because the fast walk bailed on a legal frame | |
| # record. Measured on aarch64-Linux the day the | |
| # step was added: 2 of 11 probes passed all three | |
| # walkers before the fix, 11 of 11 after. | |
| # PERRY_RS4GC -> native-roots-rs4gc | |
| # PERRY_STATEPOINT_REPORT -> not a knob. It survives as the driver's | |
| # internal handoff to the rayon module workers, | |
| # and `run_pipeline.rs` `remove_var`s it when the | |
| # flag is absent so a value inherited from the | |
| # user's environment cannot switch reporting on. | |
| # The env *spelling* was deleted (#7314); the | |
| # `--statepoint-report` flag is the only entry | |
| # point, and the "fails closed" step is its arm. | |
| # Said precisely because this block is a ledger: | |
| # an entry reading "deleted" for a name still | |
| # greppable in the tree makes the whole list | |
| # look stale. | |
| # ── STATUS as of #7970 (read before believing a red run) ─────────────────── | |
| # | |
| # This workflow had NEVER had a successful run on any branch. Three of its four | |
| # arms failed, for three unrelated reasons, and they are NOT one bug: | |
| # | |
| # macos-14 GATE DEFECT, fixed here. The in-process step asserted | |
| # evacuation liveness without setting `PERRY_GC_DIAG=1`, and | |
| # `[gc-copy-minor]` — the assert's only input — is printed only | |
| # under that flag. So the arm reported "evacuated NOTHING (0 | |
| # copying minors)" on every run since it was written. Measured | |
| # on macOS aarch64 at b847afd1c: with the flag, the same binary | |
| # under the same GC env reports 75 copying minors and 16277 | |
| # objects copied, and the whole step passes. The collector was | |
| # never the problem. | |
| # | |
| # ubuntu-24.04-arm REAL DEFECT, filed as #7984 and FIXED (#7997). | |
| # `PERRY_STACKMAP_WALKER=verify` caught the fast fp-chain | |
| # walker and the unwinder resolving the same root 96 bytes | |
| # apart, and the fast walker — the one that runs when `verify` | |
| # is off — was the wrong one. Two blind spots in | |
| # `fp_to_sp_offset`, both only reachable with SVE on, which is | |
| # what `-mcpu=native` turns on for a Neoverse-class core and | |
| # nothing on macOS or x86-64 ever does: a callee-save store | |
| # ended the prologue's stack-adjustment run, and | |
| # `addvl sp, sp, #-N` was not decoded at all. The 96 was never | |
| # a constant — it is that frame's missed tail, and it scales | |
| # with the vector length (208 at VL = 64 B). | |
| # | |
| # This arm should now be GREEN. If it goes red again, read the | |
| # report `verify` prints before assuming a regression: it names | |
| # the frame, the base register, both resolved bases and the | |
| # prologue words, which is enough to say which walker is wrong | |
| # without a second run. | |
| # | |
| # windows-latest FIXES LANDED; GREEN RUN PENDING. #8017 stopped linking the official archive's | |
| # incompatible /MT + rpmalloc static objects, restricted | |
| # inkwell to Perry's x86/AArch64 targets, and linked the | |
| # archive's LLVM-C.dll instead. #7985's release follow-up | |
| # keeps that DLL beside perry.exe in the Windows zip/npm | |
| # package. A SECOND, separate failure in the same arm — | |
| # Git-bash `tar` reading `D:\a\_temp` as a remote host — was | |
| # fixed by #8028, which normalizes shell paths with `cygpath` | |
| # and exports native Windows paths for Perry. `--force-local` | |
| # was ineffective with the runner's bsdtar. | |
| # | |
| # So: all known defects now have fixes in the intended combined state, but the | |
| # windows-latest arm remains unproven until a complete run measures it. Per | |
| # CLAUDE.md this becomes a promotion candidate only AFTER one complete green | |
| # run demonstrates all four arms; do not add it to branch protection based on | |
| # a comment. | |
| name: gc-native-roots | |
| on: | |
| # Must run where it can actually gate something. Branch-scoped triggers were | |
| # right while this lived only on exp/stackmap-viability; on main that same | |
| # filter would mean the job never runs at all — CLAUDE.md's second way a gate | |
| # cannot fail. Cancellation is deliberately NOT set here: a `main` run that | |
| # gets cancelled by the next merge is the third way. | |
| # | |
| # And the FOURTH way is what `push: branches: [main]` turned out to be here: | |
| # STARVED (#7856). Neither cancelled nor failing — simply never scheduled, | |
| # because fourteen workflows enqueued ~29 jobs on every one of 58 daily merges | |
| # against a repo that runs ~9 jobs at a time. The post-merge arm is now a | |
| # staggered six-hourly sweep; the pull-request arm is unchanged, so every PR is | |
| # still measured. ***DO NOT RESTORE `push: branches: [main]`.*** | |
| # Rationale, measurement and cost: docs/src/testing/ci-gate-scheduling.md | |
| schedule: | |
| - cron: "37 */6 * * *" | |
| push: | |
| tags: ["v*"] | |
| pull_request: | |
| # PR arm is OPT-IN via the `run-extended-tests` label (see the header of | |
| # test.yml and docs/src/testing/ci-tiers.md): an unlabelled PR still gets | |
| # a run, but every job in it is skipped, which costs no runner slot. The | |
| # main-line arm (schedule / tags) is unchanged. `labeled` re-fires the run | |
| # when the label lands. | |
| types: [opened, synchronize, reopened, labeled] | |
| workflow_dispatch: | |
| concurrency: | |
| # ***#7966: KEY EVERY MAIN-LINE RUN ON `github.run_id`, NOT `github.sha`.*** | |
| # The previous expression read `github.event_name == 'push' && github.sha || | |
| # github.ref`. That was #7205's fix and it keyed on the event being `push` -- | |
| # correct while the main-line arm WAS `push: branches: [main]`. #7856 moved the | |
| # main-line arm to `schedule:`, which falls through to `github.ref` (constant | |
| # `refs/heads/main`), so every scheduled run shared one group again and #7205 | |
| # came straight back. Measured 2026-08-12 on all ten scheduled gates, the same | |
| # shape every time: oldest run `queued` holding the group, the two after it | |
| # `cancelled` with `jobs: 0`, newest `pending`. `github.run_id` is unique per | |
| # run, so schedule / tag-push / workflow_dispatch each get a group of their own | |
| # and none can supersede another. PR runs keep the shared per-ref group and | |
| # keep superseding themselves, which is still what we want. | |
| group: gc-native-roots-${{ github.event_name }}-${{ github.event_name == 'pull_request' && github.ref || github.run_id }} | |
| # Same shape as llvm-inprocess (#7357), and for the same measured reason. | |
| # | |
| # This workflow had NO concurrency group at all, so nothing ever superseded a | |
| # stale run. Its four-arm matrix therefore multiplied: ten consecutive runs | |
| # were checked and the macos-14 arm was `queued` in every one of them -- | |
| # never executed, not once. ubuntu-latest and windows-latest likewise. Only | |
| # the aarch64 arm ever reached a runner, which is why it was the only arm | |
| # ever seen red or green. | |
| # | |
| # That is CLAUDE.md's fourth hazard wearing a different hat: three quarters of | |
| # this matrix has been reporting nothing while looking like platform coverage. | |
| # It also made #7392 unanswerable -- whether that segfault is ELF-specific | |
| # cannot be told apart from "the macOS arm has never run the probe". | |
| # | |
| # `cancel-in-progress: false` alone would not fix it: 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). Keying push runs on the SHA gives | |
| # every merged commit a group of its own; PR runs supersede freely. | |
| cancel-in-progress: ${{ github.event_name == 'pull_request' }} | |
| jobs: | |
| # Every host shape Perry supports for native roots, on one job. macOS covers | |
| # aarch64 + Mach-O; ubuntu covers x86-64 + ELF — and ELF is where every | |
| # object-format bug in this design surfaced (SHF_GNU_RETAIN, SHF_WRITE, the | |
| # Mach-O underscore convention in eh_walker). Windows covers x86-64 + PE/COFF | |
| # with the RtlVirtualUnwind walker (#7354) — the one walker with no Itanium | |
| # unwinder under it, which is why its arm alone carries the | |
| # `--require-locations` telemetry gate below. | |
| # | |
| # ARM64 Linux is the fourth arm, and the note that used to sit here — that | |
| # its "two components are each covered above" — was 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 previously-covered arms spell it `.long` (Mach-O uses | |
| # `.long` on aarch64; 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. | |
| # | |
| # To be precise about what this arm adds, because overclaiming here is how | |
| # #7321's wrong explanation survived into an issue and a job name: the | |
| # `.word` spelling IS unit-tested, by `aarch64_elf_word_directives_decode_to_ | |
| # the_right_root` and `word_width_is_load_bearing_not_cosmetic`, and those | |
| # run on every arm. What they use is 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: two bytes of drift per field | |
| # silently relocates every root that follows, and every arm stays green. | |
| native-roots-rs4gc: | |
| # PR arm is opt-in (label `run-extended-tests`); see the `on:` block. | |
| if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'run-extended-tests') | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| include: | |
| - os: macos-14 | |
| arch: aarch64 | |
| format: Mach-O | |
| - os: ubuntu-latest | |
| arch: x86-64 | |
| format: ELF | |
| - os: windows-latest | |
| arch: x86-64 | |
| format: PE | |
| - os: ubuntu-24.04-arm | |
| arch: aarch64 | |
| format: ELF | |
| runs-on: ${{ matrix.os }} | |
| # The ubuntu/macos steps were written for bash and windows-latest defaults | |
| # to pwsh; one explicit default keeps a single script dialect per step. | |
| defaults: | |
| run: | |
| shell: bash | |
| # 120, not 90: the in-process step below builds a second time with the | |
| # llvm-inprocess feature, which cargo cannot share with the build above. | |
| timeout-minutes: 120 | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 | |
| - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 | |
| with: | |
| node-version-file: .node-version | |
| - name: Install Rust toolchain | |
| run: rustup toolchain install nightly-2026-08-20 --profile minimal | |
| - uses: ./.github/actions/setup-llvm22 | |
| - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 | |
| with: | |
| shared-key: gc-native-roots | |
| - name: Build compiler and static runtime (perry-dev profile) | |
| run: | | |
| export RUSTFLAGS="-C force-frame-pointers=yes -C force-unwind-tables=yes" | |
| cargo build --profile perry-dev -p perry -p perry-runtime-static -p perry-stdlib-static | |
| # #8075/#8038 are loader shapes, not standalone-executable probes: the | |
| # runtime and stdlib are process-wide providers, generated frames live in | |
| # a later-loaded app-only dylib, and full GC runs from a clean host | |
| # boundary. The gate owns the exact two-module JSON/Buffer fixture plus | |
| # #8038's streamed Response fixture. It requires 32,768 valid responses, | |
| # >=10 full collections, retained and temporary Buffer classifications, | |
| # concurrent producers serialized on one Perry executor, reclaimed | |
| # temporary bytes, a flat live slope, and exact normal/forced-GC Response | |
| # output including two chunks, EOF, headers, cookies, and rejection. | |
| - name: Provider dylib host-boundary GC and Response | |
| if: ${{ !cancelled() && runner.os != 'Windows' }} | |
| run: scripts/gc_provider_dylib_gate.sh | |
| # The two aarch64 walkers, over a frame this repository wrote, on the host | |
| # that has to walk it. | |
| # | |
| # Until this step, NOTHING `cargo test` runs ever called `fp_chain::visit` | |
| # or `unwind::visit` — their only exercise anywhere was `verify` mode in | |
| # the step further down, inside a workflow that has never had a successful | |
| # run on any branch (#7970). The unit tests cover the decoder and the | |
| # matcher; the step that turns a `(register, offset)` pair into a stack | |
| # address had none, which is why #7984 could only ever be found by | |
| # compiling TypeScript and collecting. | |
| # | |
| # This asks the same question in seconds and without a compiler: given a | |
| # frame whose layout is known — the aarch64-ELF one, where LLVM puts the | |
| # `x29,x30` pair below the other callee-saves, and the Mach-O one, where | |
| # it sits at the top — do both walkers land on the word the record names? | |
| # It runs BEFORE the probe matrix so a walker defect is reported as a | |
| # walker defect rather than as an oracle diff twenty minutes later. | |
| - name: Walker agreement (aarch64 hosts) | |
| if: ${{ !cancelled() && matrix.arch == 'aarch64' && runner.os != 'Windows' }} | |
| run: | | |
| set -euo pipefail | |
| export RUSTFLAGS="-C force-frame-pointers=yes -C force-unwind-tables=yes" | |
| cargo test --profile perry-dev -p perry-runtime --lib gc::roots::stack_maps \ | |
| -- --test-threads=1 | tee /tmp/walker-agreement.log | |
| # `--lib <filter>` is a substring match, so a rename makes it select | |
| # nothing and `cargo test` still exits 0 having run zero tests — a | |
| # gate that cannot fail. Require each test by name, including the | |
| # sabotage arm that proves the sentinel check discriminates. | |
| for name in both_walkers_resolve_an_sp_root_in_an_elf_shaped_frame \ | |
| both_walkers_resolve_an_sp_root_in_a_darwin_shaped_frame \ | |
| a_wrong_frame_offset_is_caught; do | |
| grep -q "walker_agreement::$name ... ok" /tmp/walker-agreement.log \ | |
| || { echo "::error::$name did not run — the filter matched nothing"; exit 1; } | |
| done | |
| - name: Probe matrix, RS4GC mode, forced evacuation | |
| if: ${{ !cancelled() }} | |
| run: | | |
| set -euo pipefail | |
| export PERRY_RUNTIME_DIR="$PWD/target/perry-dev" | |
| export PERRY_NO_AUTO_OPTIMIZE=1 | |
| # `opt` and `clang` MUST come from the same LLVM install. RS4GC pipes | |
| # each module through `opt` and hands the result to `clang`, so a | |
| # newer `opt` emits attributes an older `clang` rejects — measured | |
| # locally as `error: unterminated attribute group` on | |
| # `nocreateundeforpoison`, Homebrew opt 22 feeding Apple clang, which | |
| # is the pairing Perry's own independent discovery picks by default on | |
| # a Mac. Anyone enabling this knob hits that; pin both here. | |
| exe="" | |
| if [ "$RUNNER_OS" = "macOS" ]; then | |
| brew list llvm >/dev/null 2>&1 || brew install llvm | |
| llvm_bin="$(brew --prefix llvm)/bin" | |
| elif [ "$RUNNER_OS" = "Windows" ]; then | |
| # windows-latest ships clang (the NSIS LLVM build) but NOT `opt`; | |
| # the matched pair comes from the official clang+llvm release | |
| # archive — one directory, so opt and clang cannot skew. | |
| exe=".exe" | |
| llvm_ver=22.1.3 | |
| # Git Bash exposes RUNNER_TEMP as a native `D:\...` path. Convert | |
| # it before any Unix tool sees it: the runner's bsdtar still treats | |
| # `D:` as a remote host even when passed GNU tar's --force-local. | |
| runner_temp_posix="$(cygpath -u "$RUNNER_TEMP")" | |
| llvm_root="$runner_temp_posix/clang+llvm-$llvm_ver-x86_64-pc-windows-msvc" | |
| if [ ! -x "$llvm_root/bin/opt.exe" ]; then | |
| curl -sSL --retry 3 -o "$runner_temp_posix/llvm.tar.xz" \ | |
| "https://github.com/llvm/llvm-project/releases/download/llvmorg-$llvm_ver/clang+llvm-$llvm_ver-x86_64-pc-windows-msvc.tar.xz" | |
| # The old --force-local workaround was ineffective because the | |
| # Windows runner supplies bsdtar, not GNU tar. Passing the | |
| # cygpath-normalized archive and destination avoids the remote | |
| # `host:path` grammar entirely. The previous failure was: | |
| # tar (child): Cannot connect to D: resolve failed | |
| # xz: (stdin): File format not recognized | |
| # tar: Error is not recoverable: exiting now (exit 2) | |
| # Verify extraction rather than trusting it: a half-extracted | |
| # tree otherwise becomes a misleading tool-pair error below. | |
| tar -xJf "$runner_temp_posix/llvm.tar.xz" -C "$runner_temp_posix" | |
| if [ ! -x "$llvm_root/bin/opt.exe" ]; then | |
| echo "::error::extracted $runner_temp_posix/llvm.tar.xz but $llvm_root/bin/opt.exe is still missing — the archive layout changed, or the download was truncated" | |
| ls -la "$runner_temp_posix" | head -20 | |
| exit 1 | |
| fi | |
| fi | |
| llvm_bin="$llvm_root/bin" | |
| else | |
| # The setup-llvm22 action (uses: above) installs, co-locates, and | |
| # verifies the matched LLVM 22 opt+clang pair under | |
| # $LLVM_SYS_221_PREFIX. Consume that prefix directly — hand-rediscovery | |
| # could resolve an unversioned /usr/bin/opt and run RS4GC on the | |
| # distro's LLVM 18, a green gate on the wrong LLVM. The pair-check | |
| # below stays as the assertion. | |
| llvm_bin="${LLVM_SYS_221_PREFIX:-/usr/lib/llvm-22}/bin" | |
| fi | |
| if [ ! -x "$llvm_bin/opt$exe" ] || [ ! -x "$llvm_bin/clang$exe" ]; then | |
| echo "::error::no matched opt+clang pair under $llvm_bin — RS4GC cannot run, and silently skipping it is exactly the gate that cannot fail" | |
| exit 1 | |
| fi | |
| llvm_opt="$llvm_bin/opt$exe" | |
| llvm_clang="$llvm_bin/clang$exe" | |
| if [ "$RUNNER_OS" = "Windows" ]; then | |
| # Shell tools need the POSIX path above, while native perry.exe | |
| # reads these environment values directly as Windows PathBufs. | |
| export PERRY_LLVM_OPT="$(cygpath -w "$llvm_opt")" | |
| export PERRY_LLVM_CLANG="$(cygpath -w "$llvm_clang")" | |
| else | |
| export PERRY_LLVM_OPT="$llvm_opt" | |
| export PERRY_LLVM_CLANG="$llvm_clang" | |
| fi | |
| echo "RS4GC toolchain: $llvm_bin" | |
| "$llvm_opt" --version | head -2 | |
| "$llvm_clang" --version | head -2 | |
| pass=0 | |
| total=0 | |
| errs="" | |
| for probe in benchmarks/gc_ratchet/probes/*.ts; do | |
| total=$((total+1)) | |
| name=$(basename "$probe" .ts) | |
| if [ "$RUNNER_OS" = "Windows" ] && [ "$name" = "09_try_catch_roots" ]; then | |
| # #7354 measured negative, pinned as a REFUSAL: windows-msvc | |
| # `try` lowers to WinEH funclet pads, which crash LLVM's | |
| # rewrite-statepoints-for-gc outright (access violation on opt | |
| # 22.1.3, reproducible from an eight-line module). Perry refuses | |
| # the module before the pass runs; this arm pins that it STAYS a | |
| # refusal — never a crash, never a silently rootless binary. It | |
| # goes red the day the pass learns funclet EH, which is the | |
| # prompt to fold 09 into this matrix. | |
| if PERRY_RS4GC=1 ./target/perry-dev/perry "$probe" \ | |
| -o "/tmp/rs4gc-$name" > "/tmp/rs4gc-$name.compile.log" 2>&1; then | |
| echo "::error::$name compiled under RS4GC on Windows — the funclet refusal is gone: either rewrite-statepoints-for-gc learned funclet EH (fold 09 into the matrix) or the refusal was lost" | |
| exit 1 | |
| fi | |
| grep -q "funclet" "/tmp/rs4gc-$name.compile.log" \ | |
| || { echo "::error::$name failed for a reason other than the funclet refusal:"; cat "/tmp/rs4gc-$name.compile.log"; exit 1; } | |
| pass=$((pass+1)) | |
| continue | |
| fi | |
| node --expose-gc --experimental-strip-types "$probe" > "/tmp/rs4gc-$name.oracle" | |
| PERRY_RS4GC=1 ./target/perry-dev/perry "$probe" -o "/tmp/rs4gc-$name" | |
| # perry appends the platform default extension to an -o with none. | |
| out="/tmp/rs4gc-$name$exe" | |
| if [ "$RUNNER_OS" = "macOS" ]; then | |
| otool -l "$out" | grep -q "sectname __perry_gcmap" \ | |
| || { echo "::error::$name has no __perry_gcmap section — RS4GC produced no native root map"; exit 1; } | |
| otool -l "$out" | grep -q "sectname __llvm_stackmaps" \ | |
| && { echo "::error::$name still carries __llvm_stackmaps — the compact rewrite did not run"; exit 1; } | |
| elif [ "$RUNNER_OS" = "Windows" ]; then | |
| # PE: an image section header holds 8 name bytes — which is why | |
| # the section is `.pgcmap` (gc_map.rs) — and a surviving LLVM | |
| # stackmap section would appear truncated, so match the prefix. | |
| "$llvm_bin/llvm-readobj$exe" --sections "$out" | grep -q "Name: .pgcmap" \ | |
| || { echo "::error::$name has no .pgcmap section — RS4GC produced no native root map"; exit 1; } | |
| "$llvm_bin/llvm-readobj$exe" --sections "$out" | grep -q "llvm_st" \ | |
| && { echo "::error::$name still carries an llvm_stackmaps section — the compact rewrite did not run"; exit 1; } | |
| else | |
| readelf -S "$out" | grep -q "\.perry_gcmap" \ | |
| || { echo "::error::$name has no .perry_gcmap section — RS4GC produced no native root map"; exit 1; } | |
| readelf -S "$out" | grep -q "\.llvm_stackmaps" \ | |
| && { echo "::error::$name still carries .llvm_stackmaps — the compact rewrite did not run"; exit 1; } | |
| fi | |
| PERRY_RS4GC=1 PERRY_GC_FORCE_EVACUATE=1 PERRY_GC_VERIFY_EVACUATION=1 \ | |
| PERRY_GC_HEAP_LIMIT=8 PERRY_GC_INCREMENTAL=0 PERRY_CONSERVATIVE_STACK_SCAN=off \ | |
| "$out" > "/tmp/rs4gc-$name.out" 2> "/tmp/rs4gc-$name.err" | |
| diff "/tmp/rs4gc-$name.oracle" "/tmp/rs4gc-$name.out" \ | |
| || { echo "::error::$name diverged from the pinned oracle under RS4GC"; exit 1; } | |
| errs="$errs /tmp/rs4gc-$name.err" | |
| pass=$((pass+1)) | |
| done | |
| echo "RS4GC forced-evacuation matrix: $pass/$total" | |
| [ "$total" -gt 0 ] \ | |
| || { echo "::error::no probes matched — the matrix ran on nothing"; exit 1; } | |
| [ "$pass" -eq "$total" ] | |
| grep -l "#gcmetric" $errs >/dev/null \ | |
| || { echo "::error::no probe emitted gc metrics — the collector never ran"; exit 1; } | |
| # Liveness assert: RS4GC bails PER FUNCTION to the explicit statepoint | |
| # bridge on any unrecognised root-alloca shape. The matrix above could | |
| # therefore be 9/9 green with RS4GC having rewritten nothing at all — | |
| # every function quietly lowered by the other backend, the arm | |
| # measuring the mode it was not testing. `--only-backend rs4gc` | |
| # rejects a single such fallback. | |
| # windows-latest exposes the toolcache python as `python`, not python3. | |
| py=python3; command -v python3 >/dev/null 2>&1 || py=python | |
| # The PORTABLE assertion, on every arm. `11_collect_at_depth` is | |
| # deliberate: it contains no `try`, so it compiles under RS4GC | |
| # everywhere. `09_try_catch_roots` does NOT — RS4GC cannot rewrite | |
| # WinEH funclet pads, so `linker.rs`'s `rs4gc_funclet_refusal` rejects | |
| # it on windows-msvc, and the probe loop above only tolerates that | |
| # because it greps the compile log for "funclet". A report assertion | |
| # pinned to a probe that cannot compile on one arm is a gate that | |
| # fails for a reason unrelated to its subject. | |
| # | |
| # --only-backend proves the lowering ran on every function; the two | |
| # --require-positive checks prove it PRODUCED something. Those counts | |
| # come from the compact-map rewrite parsing the assembly LLVM | |
| # emitted, which is the only honest source now that RS4GC decides | |
| # what becomes a safepoint. Until #7368 the report counted at | |
| # IR-emission time, #7348 deleted those writers with the bridge, and | |
| # every compile printed `0 statepoints emitted` while its binary | |
| # carried hundreds. A label check could not see that; these can. | |
| PERRY_RS4GC=1 ./target/perry-dev/perry \ | |
| benchmarks/gc_ratchet/probes/11_collect_at_depth.ts \ | |
| -o /tmp/rs4gc-report-probe --statepoint-report=json 2> /tmp/rs4gc-report.json | |
| "$py" scripts/statepoint_report_assert.py /tmp/rs4gc-report.json \ | |
| --only-backend rs4gc \ | |
| --require-positive records \ | |
| --require-positive roots | |
| # The try-specific arm, everywhere RS4GC can compile a `try`. This is | |
| # the coverage the probe above cannot give: 128 of 479 gap tests | |
| # contain `try {}`, and RS4GC being the only backend that handles them | |
| # is the reason the bridge could be deleted (#7339, #7348). | |
| if [ "$RUNNER_OS" != "Windows" ]; then | |
| PERRY_RS4GC=1 ./target/perry-dev/perry \ | |
| benchmarks/gc_ratchet/probes/09_try_catch_roots.ts \ | |
| -o /tmp/rs4gc-try-probe --statepoint-report=json 2> /tmp/rs4gc-try.json | |
| "$py" scripts/statepoint_report_assert.py /tmp/rs4gc-try.json \ | |
| --only-backend rs4gc \ | |
| --require-positive records \ | |
| --require-positive roots | |
| fi | |
| # Walker liveness, on EVERY arm. A walker that visits zero frames | |
| # still lets most probes print the right answer, because other root | |
| # sources cover them — so a green matrix is not evidence the walker | |
| # ran. Only non-zero frames/records/locations telemetry is. | |
| # | |
| # This used to be Windows-only (#7354) for a good reason: it was the | |
| # only arm that could pass it. Measured on `04_dead_after_deep_stack`, | |
| # macOS and Linux reported 7 frames and ZERO locations, because every | |
| # probe in the suite collected from a shallow stack at exit. Windows | |
| # only walked deep by accident of heap sizing. | |
| # | |
| # `11_collect_at_depth` collects at maximum recursion depth with a | |
| # live root in every frame, so all three arms now walk a real stack — | |
| # 228 frames and 221 locations on macOS, where the old best was 0. | |
| PERRY_GC_TRACE=1 PERRY_RS4GC=1 \ | |
| PERRY_GC_FORCE_EVACUATE=1 PERRY_GC_VERIFY_EVACUATION=1 \ | |
| PERRY_GC_HEAP_LIMIT=8 PERRY_GC_INCREMENTAL=0 PERRY_CONSERVATIVE_STACK_SCAN=off \ | |
| "/tmp/rs4gc-11_collect_at_depth$exe" > /dev/null 2> /tmp/rs4gc-trace.err | |
| "$py" scripts/gc_walker_trace_assert.py /tmp/rs4gc-trace.err \ | |
| --require-locations | |
| # #7392. `PERRY_STACKMAP_WALKER` selects between three walks over the same | |
| # roots, and until this step nothing anywhere set it — the ledger at the | |
| # top of this file said otherwise for months. Both non-default walks were | |
| # broken the whole time, on every platform, and could not have been | |
| # noticed: | |
| # | |
| # unwind resolved SP-relative roots against `CFA - stack_size`, but the | |
| # CFA an `_Unwind_Backtrace` callback reports IS the frame's | |
| # stack pointer, so every such root landed one frame too low. A | |
| # wrong stack word looks exactly like a right one to everything | |
| # downstream — no code knows what a root slot should contain. | |
| # verify runs both walks and compares the slot sets, i.e. it is the | |
| # only check that can catch the above. It could not run: the | |
| # fast walk rejected a legal 8-mod-16 frame record (which is | |
| # what AArch64 ELF frame lowering produces whenever an odd | |
| # number of callee-saved GPRs sits below the pair) and returned | |
| # "unavailable", which verify turns into a panic. | |
| # | |
| # So the default walker was the only one anyone exercised, and on | |
| # aarch64-Linux its bail-out landed in the broken fallback: the roots of | |
| # that frame were never rewritten after an evacuation, and the mutator | |
| # dereferenced a stale from-space pointer (`02_survivor_promotion`, | |
| # SIGSEGV). Measured on aarch64-Linux before the fix: 2 of 11 probes | |
| # passed all three walkers. After: 11 of 11. | |
| # | |
| # `verify` needs the fp-chain walk to exist, which is aarch64-only, so it | |
| # is gated on the arch rather than skipped quietly. Windows has neither | |
| # walker (`RtlVirtualUnwind` is its own module) and is excluded outright. | |
| - name: Both non-default walkers | |
| if: ${{ !cancelled() && runner.os != 'Windows' }} | |
| run: | | |
| set -euo pipefail | |
| modes="unwind" | |
| if [ "${{ matrix.arch }}" = "aarch64" ]; then | |
| modes="unwind verify" | |
| fi | |
| echo "walkers under test: $modes" | |
| checked=0 | |
| for probe in benchmarks/gc_ratchet/probes/*.ts; do | |
| name=$(basename "$probe" .ts) | |
| # Binaries and oracles come from the matrix step above, same job and | |
| # same runner — as the walker-liveness assert already does. Missing | |
| # ones are a hard error: silently checking nothing is the failure | |
| # mode this whole step exists to close. | |
| [ -x "/tmp/rs4gc-$name" ] \ | |
| || { echo "::error::$name has no binary from the probe matrix step"; exit 1; } | |
| [ -s "/tmp/rs4gc-$name.oracle" ] \ | |
| || { echo "::error::$name has no pinned oracle from the probe matrix step"; exit 1; } | |
| for mode in $modes; do | |
| PERRY_STACKMAP_WALKER="$mode" \ | |
| PERRY_RS4GC=1 PERRY_GC_FORCE_EVACUATE=1 PERRY_GC_VERIFY_EVACUATION=1 \ | |
| PERRY_GC_HEAP_LIMIT=8 PERRY_GC_INCREMENTAL=0 PERRY_CONSERVATIVE_STACK_SCAN=off \ | |
| "/tmp/rs4gc-$name" > "/tmp/walker-$name-$mode.out" \ | |
| 2> "/tmp/walker-$name-$mode.err" \ | |
| || { echo "::error::$name crashed under PERRY_STACKMAP_WALKER=$mode"; \ | |
| tail -120 "/tmp/walker-$name-$mode.err"; exit 1; } | |
| diff "/tmp/rs4gc-$name.oracle" "/tmp/walker-$name-$mode.out" \ | |
| || { echo "::error::$name diverged from the pinned oracle under PERRY_STACKMAP_WALKER=$mode"; exit 1; } | |
| checked=$((checked+1)) | |
| done | |
| done | |
| echo "non-default walker runs, all oracle-diffed: $checked" | |
| [ "$checked" -gt 0 ] \ | |
| || { echo "::error::no probe ran under a non-default walker — the step measured nothing"; exit 1; } | |
| # Everything above proves a process exited zero and printed what the | |
| # oracle printed. It does NOT prove `PERRY_STACKMAP_WALKER=$mode` | |
| # selected that walker, that the walker reached a mapped frame, or | |
| # that anything was evacuated — and all three modes are supposed to | |
| # produce identical output, so program output cannot tell them apart. | |
| # That is CLAUDE.md's fourth hazard, and the very shape of #7392: the | |
| # walker under test read the wrong words for months while every probe | |
| # stayed green. | |
| # | |
| # So assert the subject was live, per mode, off one traced run of | |
| # `11_collect_at_depth` (deep stack, a live root in every frame, so | |
| # the telemetry is non-trivial on every arm): | |
| # | |
| # fp_walks == 0 proves `unwind` took effect — nonzero means the | |
| # chain walk ran anyway and the mode did nothing. | |
| # fp_walks > 0 proves `verify` cross-checked something rather | |
| # than quietly not running the chain walk. | |
| # --require-locations the walker stepped frames, matched | |
| # safepoints and enumerated roots, rather than | |
| # visiting nothing while other root sources covered. | |
| # evacuation liveness a copying minor ran and MOVED an object, so | |
| # the roots being enumerated were roots that had to | |
| # be rewritten (#6942/#6946, #7336). | |
| # | |
| # python3 unqualified: this step never runs on Windows, which is the | |
| # only runner where the toolcache spells it `python`. | |
| for mode in $modes; do | |
| case "$mode" in | |
| unwind) fp_flag="--forbid-fp-walks" ;; | |
| verify) fp_flag="--require-fp-walks" ;; | |
| *) echo "::error::no liveness assert defined for walker $mode"; exit 1 ;; | |
| esac | |
| PERRY_GC_TRACE=1 PERRY_GC_DIAG=1 PERRY_STACKMAP_WALKER="$mode" \ | |
| PERRY_RS4GC=1 PERRY_GC_FORCE_EVACUATE=1 PERRY_GC_VERIFY_EVACUATION=1 \ | |
| PERRY_GC_HEAP_LIMIT=8 PERRY_GC_INCREMENTAL=0 PERRY_CONSERVATIVE_STACK_SCAN=off \ | |
| /tmp/rs4gc-11_collect_at_depth > /dev/null 2> "/tmp/walker-trace-$mode.err" | |
| python3 scripts/gc_walker_trace_assert.py "/tmp/walker-trace-$mode.err" \ | |
| --require-locations $fp_flag | |
| python3 scripts/gc_evacuation_liveness_assert.py "/tmp/walker-trace-$mode.err" \ | |
| --probe "11_collect_at_depth (PERRY_STACKMAP_WALKER=$mode)" | |
| done | |
| # #7327. Everything above pins PERRY_LLVM_OPT + PERRY_LLVM_CLANG to one | |
| # brew install, because RS4GC piped IR through an external `opt` and a | |
| # newer `opt` emits attributes an older `clang` cannot parse. That made | |
| # RS4GC reachable only on a hand-pinned toolchain -- and RS4GC is the only | |
| # backend that can root an `invoke`, i.e. every call inside a `try`. | |
| # | |
| # The in-process backend runs the pass at the pinned LLVM with no IR | |
| # crossing a toolchain boundary, so the pinning is no longer needed. This | |
| # step asserts exactly that, and it is the one arm that must run with the | |
| # PERRY_LLVM_* variables UNSET -- otherwise it proves nothing the steps | |
| # above have not already proven. | |
| - name: RS4GC works on a stock toolchain via the in-process backend | |
| # macOS only: the assertions below read Mach-O section names. The point | |
| # of the step is the stock-toolchain path, which the ELF arm covers by | |
| # using the system LLVM in the first place. | |
| if: ${{ !cancelled() && runner.os == 'macOS' }} | |
| run: | | |
| set -euo pipefail | |
| export PERRY_RUNTIME_DIR="$PWD/target/perry-dev" | |
| export PERRY_NO_AUTO_OPTIMIZE=1 | |
| unset PERRY_LLVM_OPT PERRY_LLVM_CLANG | |
| export LLVM_SYS_221_PREFIX="$(brew --prefix llvm)" | |
| export RUSTFLAGS="-C force-frame-pointers=yes -C force-unwind-tables=yes" | |
| cargo build --profile perry-dev -p perry -p perry-runtime-static \ | |
| -p perry-stdlib-static --features perry-codegen/llvm-inprocess | |
| # Probe 09 is the whole point: it carries `try`, so every call in it | |
| # is an `invoke`, which the explicit bridge refuses outright (#7330). | |
| probe=benchmarks/gc_ratchet/probes/09_try_catch_roots.ts | |
| PERRY_RS4GC=1 PERRY_LLVM_INPROCESS=1 \ | |
| ./target/perry-dev/perry "$probe" -o /tmp/inproc-09 | |
| otool -l /tmp/inproc-09 | grep -q "sectname __perry_gcmap" \ | |
| || { echo "::error::no __perry_gcmap — the in-process route produced no native root map"; exit 1; } | |
| otool -l /tmp/inproc-09 | grep -q "sectname __llvm_stackmaps" \ | |
| && { echo "::error::__llvm_stackmaps survived — the compact rewrite did not run on the in-process path"; exit 1; } | |
| # Same answer as the shadow stack, and a collection that actually | |
| # moved something. Without the movement assert this passes with the | |
| # conservative scan doing all the rooting (#7336, #7338). | |
| ./target/perry-dev/perry "$probe" -o /tmp/inproc-09-control | |
| PERRY_GC_HEAP_LIMIT=8 PERRY_GC_INCREMENTAL=0 PERRY_CONSERVATIVE_STACK_SCAN=off \ | |
| /tmp/inproc-09-control > /tmp/inproc-09.control.out 2>/dev/null | |
| # PERRY_GC_DIAG=1 is LOAD-BEARING, not decoration: `[gc-copy-minor]` | |
| # is emitted only under it, and that line is the entire input to | |
| # `gc_evacuation_liveness_assert.py`. Without it the assert reads an | |
| # empty trace and reports "evacuated NOTHING (0 copying minors, 0 | |
| # objects copied)" no matter what the collector actually did — so | |
| # this arm could never pass, which is a large part of why | |
| # `gc-native-roots` has never had a green run (#7970). | |
| # | |
| # Measured on macOS aarch64 at this commit: with the flag, the same | |
| # binary under the same GC env reports 75 copying minors and 16277 | |
| # objects copied. The collector was evacuating the whole time; the | |
| # gate was asserting on telemetry it had not switched on. The sibling | |
| # call site in the walker-trace step above always set it — this one | |
| # was missed. | |
| # | |
| # `--probe` likewise: without it the failure message says the literal | |
| # `<probe>`, which is what the 2026-08-11 logs show. | |
| PERRY_GC_DIAG=1 \ | |
| PERRY_GC_FORCE_EVACUATE=1 PERRY_GC_VERIFY_EVACUATION=1 \ | |
| PERRY_GC_HEAP_LIMIT=8 PERRY_GC_INCREMENTAL=0 PERRY_CONSERVATIVE_STACK_SCAN=off \ | |
| /tmp/inproc-09 > /tmp/inproc-09.out 2> /tmp/inproc-09.err | |
| diff /tmp/inproc-09.control.out /tmp/inproc-09.out \ | |
| || { echo "::error::in-process RS4GC diverged from the shadow-stack control"; exit 1; } | |
| python3 scripts/gc_evacuation_liveness_assert.py /tmp/inproc-09.err \ | |
| --probe "09_try_catch_roots (in-process RS4GC)" | |
| # And it must be RS4GC doing the lowering, not a per-function bail to | |
| # the bridge -- which would make this arm green while testing the | |
| # backend it is not named after. | |
| PERRY_RS4GC=1 PERRY_LLVM_INPROCESS=1 ./target/perry-dev/perry "$probe" \ | |
| -o /tmp/inproc-09-report --statepoint-report=json 2> /tmp/inproc-09-report.json | |
| python3 scripts/statepoint_report_assert.py /tmp/inproc-09-report.json \ | |
| --only-backend rs4gc | |
| # The x86-64 gap, asserted rather than left as folklore. Statepoints do not | |
| # compile on x86-64 Linux today — the compact-map rewriter refuses, which is | |
| # the fail-closed path doing its job. This job pins that refusal so it stays a | |
| # REFUSAL (never a silently rootless binary), and goes red the day x86-64 | |
| # starts working, which is the prompt to widen the aarch64 matrix above (#7321). | |
| # Deliberately cheap: one probe, no runtime, no oracle. | |
| gc-native-roots-complete: | |
| needs: [native-roots-rs4gc] | |
| if: (always()) && (github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'run-extended-tests')) | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 5 | |
| steps: | |
| - name: Require every native-root arm to pass | |
| run: | | |
| set -euo pipefail | |
| failed=0 | |
| for arm in \ | |
| "native-roots-rs4gc=${{ needs.native-roots-rs4gc.result }}" \ | |
| ; do | |
| echo "$arm" | |
| case "$arm" in | |
| *=success) ;; | |
| *) failed=1 ;; | |
| esac | |
| done | |
| if [ "$failed" -ne 0 ]; then | |
| echo "::error::a native-root arm failed, was cancelled, or was skipped" | |
| exit 1 | |
| fi | |
| echo "All native-root arms passed." |