Skip to content

fix(codegen): a non-numeric key on a Uint8Array/Buffer local is a property read, not a byte (#7700) #1154

fix(codegen): a non-numeric key on a Uint8Array/Buffer local is a property read, not a byte (#7700)

fix(codegen): a non-numeric key on a Uint8Array/Buffer local is a property read, not a byte (#7700) #1154

Workflow file for this run

# #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.
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.
push:
branches: [main]
pull_request:
workflow_dispatch:
concurrency:
group: gc-native-roots-${{ github.event_name }}-${{ github.event_name == 'push' && github.sha || github.ref }}
# 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:
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@v4
- uses: actions/setup-node@v4
with:
node-version-file: .node-version
- uses: dtolnay/rust-toolchain@stable
- uses: ./.github/actions/setup-llvm22
- uses: Swatinem/rust-cache@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
- 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
llvm_root="$RUNNER_TEMP/clang+llvm-$llvm_ver-x86_64-pc-windows-msvc"
if [ ! -x "$llvm_root/bin/opt.exe" ]; then
curl -sSL --retry 3 -o "$RUNNER_TEMP/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"
tar -xJf "$RUNNER_TEMP/llvm.tar.xz" -C "$RUNNER_TEMP"
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
export PERRY_LLVM_OPT="$llvm_bin/opt$exe"
export PERRY_LLVM_CLANG="$llvm_bin/clang$exe"
echo "RS4GC toolchain: $llvm_bin"
"$PERRY_LLVM_OPT" --version | head -2
"$PERRY_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 -20 "/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_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
# 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()
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."