Skip to content

perf: reuse guarded ECS entity indices #3095

perf: reuse guarded ECS entity indices

perf: reuse guarded ECS entity indices #3095

# #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."