diff --git a/.github/workflows/gc-native-roots.yml b/.github/workflows/gc-native-roots.yml index e53d14fe9c..7a6dfa5963 100644 --- a/.github/workflows/gc-native-roots.yml +++ b/.github/workflows/gc-native-roots.yml @@ -1,13 +1,58 @@ -# #7173: native-root (statepoint) GC verification on Linux. +# #7173: native-frame GC root verification. # -# Runs the gc-ratchet probe matrix in statepoint mode under forced +# Runs the gc-ratchet probe matrix in every native-root mode under forced # evacuation + evacuation verification, byte-diffed against the pinned Node -# oracle, natively on the Linux runner — the same matrix the branch runs on -# macOS, webserver-class x86-64, and the Pi 5. Two liveness asserts keep -# this from being a gate that cannot fail (CLAUDE.md's four ways): -# the binary must carry a .perry_gcmap section (and no .llvm_stackmaps, -# proving the compact rewrite ran), and at least -# one probe must report a copying collection. +# 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 +# compact-map rewriter refuses the FIRST probe on x86-64 Linux: +# +# perry: this module emits an LLVM stack map that the compact-map rewriter +# could not parse, so its GC roots would be invisible to the collector +# (the runtime reads only the compact section). Refusing to emit a binary +# that would lose roots silently. +# +# That is the fail-closed path working exactly as designed — but it means the +# native-root mechanism does not compile on x86-64 today, so a matrix pointed +# there gates nothing while looking like it gates everything. gc_map.rs encodes +# its base registers in aarch64 terms throughout (`DWARF_REG_{FP,SP}_AARCH64`, +# "x19 on aarch64"), which is consistent with that, though this workflow does +# not prove the cause. The gap is asserted, not hidden: `statepoints-refuse-x86` +# below fails the day x86-64 starts working, so nobody has to remember to come +# back and widen the matrix. Tracked as #7321. +# +# ── 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_STATEPOINTS -> native-roots-aarch64, "statepoint mode" step +# PERRY_GC_SAFEPOINT_ONLY -> native-roots-aarch64, "safepoint-only" steps +# PERRY_STACKMAP_WALKER -> native-roots-aarch64, "both non-default walkers" +# PERRY_RS4GC -> native-roots-rs4gc-aarch64 +# PERRY_STATEPOINT_REPORT -> deleted. It was a second spelling of +# `--statepoint-report`; the flag is now the only +# entry point and the "fails closed" step is its +# arm. name: gc-native-roots on: # Must run where it can actually gate something. Branch-scoped triggers were @@ -21,9 +66,9 @@ on: workflow_dispatch: jobs: - statepoint-linux: - runs-on: ubuntu-latest - timeout-minutes: 45 + native-roots-aarch64: + runs-on: macos-14 + timeout-minutes: 90 steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 @@ -32,12 +77,18 @@ jobs: - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 with: - key: gc-native-roots + shared-key: gc-native-roots - name: Build compiler and static runtime (perry-dev profile) run: | - export RUSTFLAGS="-Cforce-frame-pointers=yes" + 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 + + # Every arm below carries `if: ${{ !cancelled() }}`. A job is a SEQUENCE + # of independent gates and a failed step takes every later step to + # `skipped`, so without this one red arm silently stops the other three + # from ever speaking — the same hazard `lint` documents at length. - name: Probe matrix, statepoint mode, forced evacuation + if: ${{ !cancelled() }} run: | set -euo pipefail export PERRY_RUNTIME_DIR="$PWD/target/perry-dev" @@ -54,10 +105,10 @@ jobs: # replaced LLVM's section, so assert BOTH facts — the new section # is present AND the old one is gone. Checking only the former # would still pass if compaction silently stopped running. - readelf -S "/tmp/$name" | grep -q "\.perry_gcmap" \ - || { echo "::error::$name has no .perry_gcmap section — statepoint mode was not live"; exit 1; } - readelf -S "/tmp/$name" | grep -q "\.llvm_stackmaps" \ - && { echo "::error::$name still carries .llvm_stackmaps — the compact rewrite did not run"; exit 1; } + otool -l "/tmp/$name" | grep -q "sectname __perry_gcmap" \ + || { echo "::error::$name has no __perry_gcmap section — statepoint mode was not live"; exit 1; } + otool -l "/tmp/$name" | grep -q "sectname __llvm_stackmaps" \ + && { echo "::error::$name still carries __llvm_stackmaps — the compact rewrite did not run"; exit 1; } PERRY_STATEPOINTS=1 PERRY_GC_FORCE_EVACUATE=1 PERRY_GC_VERIFY_EVACUATION=1 \ "/tmp/$name" > "/tmp/$name.out" 2> "/tmp/$name.err" diff "/tmp/$name.oracle" "/tmp/$name.out" \ @@ -78,3 +129,313 @@ jobs: # with a zero. grep -l "#gcmetric" $errs >/dev/null \ || { echo "::error::no probe emitted gc metrics — the collector never ran"; exit 1; } + + - name: Root-pressure report fails closed (--statepoint-report) + if: ${{ !cancelled() }} + run: | + set -euo pipefail + export PERRY_RUNTIME_DIR="$PWD/target/perry-dev" + export PERRY_NO_AUTO_OPTIMIZE=1 + # The arm for the report itself. `PERRY_STATEPOINT_REPORT` was deleted + # as a user-facing knob (a second spelling of this flag, with no arm), + # so the flag is the only entry point and it needs one. + # + # It also asserts #7314's headline claim — that every root path fails + # CLOSED. `plain_stack_maps` and `statepoint_fallbacks` must both be + # zero: LLVM's plain stackmap can record a root as `Register R#N` + # (caller-saved, unrecoverable at collection time), so a nonzero count + # here is silently lost roots, not a degraded-but-safe mode. + probe=benchmarks/gc_ratchet/probes/09_try_catch_roots.ts + PERRY_STATEPOINTS=1 ./target/perry-dev/perry "$probe" \ + -o /tmp/report-probe --statepoint-report=json 2> /tmp/statepoint-report.json + python3 scripts/statepoint_report_assert.py /tmp/statepoint-report.json \ + --only-backend statepoint \ + --require-positive statepoints \ + --require-positive relocations \ + --require-zero plain_stack_maps \ + --require-zero statepoint_fallbacks + + # PERRY_GC_SAFEPOINT_ONLY: the explicit-safepoint collection contract. + # Under it, audited allocate-but-never-reenter helpers need no statepoint + # so codegen emits fewer, and the runtime enforces that a precise-root + # collection only ever begins at a declared safepoint. `strict` is the + # mode that panics rather than heals — by the contract's own docs, the + # mode that proves enforcement is live — so that is the mode run here. + - name: Safepoint-only contract changes codegen (differential) + if: ${{ !cancelled() }} + run: | + set -euo pipefail + export PERRY_RUNTIME_DIR="$PWD/target/perry-dev" + export PERRY_NO_AUTO_OPTIMIZE=1 + # A `strict` run that never trips the panic proves the enforcement was + # armed but NOT that the contract did anything, because a build where + # it changed no code would be equally quiet. The differential is the + # assert with teeth: with the contract on, codegen must skip strictly + # MORE calls and emit strictly FEWER statepoints. + # + # Aggregated over the whole glob on purpose. Some individual probes + # contain no AllocNoReentry callee at all and show a zero delta + # (09_try_catch_roots is one, measured), so a per-probe assert would + # be a coin flip on which probe the author happened to pick. + off_sp=0; off_sk=0; on_sp=0; on_sk=0 + for probe in benchmarks/gc_ratchet/probes/*.ts; do + name=$(basename "$probe" .ts) + PERRY_STATEPOINTS=1 ./target/perry-dev/perry "$probe" \ + -o /dev/null --statepoint-report=json 2> "/tmp/off-$name.json" + PERRY_STATEPOINTS=1 PERRY_GC_SAFEPOINT_ONLY=strict ./target/perry-dev/perry "$probe" \ + -o /dev/null --statepoint-report=json 2> "/tmp/on-$name.json" + off_sp=$((off_sp + $(python3 scripts/statepoint_report_assert.py "/tmp/off-$name.json" --print statepoints))) + off_sk=$((off_sk + $(python3 scripts/statepoint_report_assert.py "/tmp/off-$name.json" --print skipped_non_safepoints))) + on_sp=$((on_sp + $(python3 scripts/statepoint_report_assert.py "/tmp/on-$name.json" --print statepoints))) + on_sk=$((on_sk + $(python3 scripts/statepoint_report_assert.py "/tmp/on-$name.json" --print skipped_non_safepoints))) + done + echo "contract off: statepoints=$off_sp skipped_non_safepoints=$off_sk" + echo "contract on : statepoints=$on_sp skipped_non_safepoints=$on_sk" + if [ "$on_sk" -le "$off_sk" ]; then + echo "::error::PERRY_GC_SAFEPOINT_ONLY skipped no additional calls ($on_sk <= $off_sk) — the contract never reached codegen and this arm asserted nothing" + exit 1 + fi + if [ "$on_sp" -ge "$off_sp" ]; then + echo "::error::PERRY_GC_SAFEPOINT_ONLY removed no statepoints ($on_sp >= $off_sp) — the contract never reached codegen" + exit 1 + fi + + - name: Probe matrix, safepoint-only contract in strict mode + if: ${{ !cancelled() }} + run: | + set -euo pipefail + export PERRY_RUNTIME_DIR="$PWD/target/perry-dev" + export PERRY_NO_AUTO_OPTIMIZE=1 + pass=0 + total=0 + errs="" + for probe in benchmarks/gc_ratchet/probes/*.ts; do + total=$((total+1)) + name=$(basename "$probe" .ts) + node --expose-gc --experimental-strip-types "$probe" > "/tmp/so-$name.oracle" + PERRY_STATEPOINTS=1 PERRY_GC_SAFEPOINT_ONLY=strict \ + ./target/perry-dev/perry "$probe" -o "/tmp/so-$name" + otool -l "/tmp/so-$name" | grep -q "sectname __perry_gcmap" \ + || { echo "::error::$name has no __perry_gcmap section — statepoint mode was not live"; exit 1; } + # strict PANICS if a precise-root collection ever begins outside a + # declared safepoint, so a clean exit here is the enforcement + # holding, not the enforcement being absent. + PERRY_STATEPOINTS=1 PERRY_GC_SAFEPOINT_ONLY=strict \ + PERRY_GC_FORCE_EVACUATE=1 PERRY_GC_VERIFY_EVACUATION=1 \ + "/tmp/so-$name" > "/tmp/so-$name.out" 2> "/tmp/so-$name.err" + diff "/tmp/so-$name.oracle" "/tmp/so-$name.out" \ + || { echo "::error::$name diverged from the oracle under the safepoint-only contract"; exit 1; } + errs="$errs /tmp/so-$name.err" + pass=$((pass+1)) + done + echo "safepoint-only strict 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; } + + # PERRY_STACKMAP_WALKER. This arm is aarch64-only for a second, unrelated + # reason: the x29 chain walk is compiled in for `any(macos, linux) + + # aarch64` and stubbed to `None` everywhere else, so on x86-64 `fast` + # silently degrades to the unwinder and `verify` panics outright on "fast + # walk unavailable". An arm for this knob on an x86-64 runner would assert + # the opposite of what it appears to. + - name: Probe matrix under both non-default walkers + if: ${{ !cancelled() }} + run: | + set -euo pipefail + export PERRY_RUNTIME_DIR="$PWD/target/perry-dev" + export PERRY_NO_AUTO_OPTIMIZE=1 + pass=0 + total=0 + for probe in benchmarks/gc_ratchet/probes/*.ts; do + total=$((total+1)) + name=$(basename "$probe" .ts) + node --expose-gc --experimental-strip-types "$probe" > "/tmp/w-$name.oracle" + # The walker is a RUNTIME knob — one binary, two walks over it. + PERRY_STATEPOINTS=1 ./target/perry-dev/perry "$probe" -o "/tmp/w-$name" + + # verify: runs the chain walk AND the unwinder and panics unless + # they visit the identical slot set. It is the only check that can + # catch a fast walk silently skipping frames — forced-evacuation + # verification enumerates roots through the same walker, so it + # cannot see a slot the walker never reached. + PERRY_STACKMAP_WALKER=verify PERRY_GC_TRACE=1 \ + PERRY_GC_FORCE_EVACUATE=1 PERRY_GC_VERIFY_EVACUATION=1 \ + "/tmp/w-$name" > "/tmp/w-$name.verify.out" 2> "/tmp/w-$name.verify.err" + diff "/tmp/w-$name.oracle" "/tmp/w-$name.verify.out" \ + || { echo "::error::$name diverged from the oracle under PERRY_STACKMAP_WALKER=verify"; exit 1; } + python3 scripts/gc_walker_trace_assert.py "/tmp/w-$name.verify.err" --require-fp-walks + + # unwind: the bisection control. Same roots, platform unwinder only. + PERRY_STACKMAP_WALKER=unwind PERRY_GC_TRACE=1 \ + PERRY_GC_FORCE_EVACUATE=1 PERRY_GC_VERIFY_EVACUATION=1 \ + "/tmp/w-$name" > "/tmp/w-$name.unwind.out" 2> "/tmp/w-$name.unwind.err" + diff "/tmp/w-$name.oracle" "/tmp/w-$name.unwind.out" \ + || { echo "::error::$name diverged from the oracle under PERRY_STACKMAP_WALKER=unwind"; exit 1; } + python3 scripts/gc_walker_trace_assert.py "/tmp/w-$name.unwind.err" --forbid-fp-walks + + pass=$((pass+1)) + done + echo "stackmap-walker verify+unwind matrix: $pass/$total" + [ "$total" -gt 0 ] \ + || { echo "::error::no probes matched — the matrix ran on nothing"; exit 1; } + [ "$pass" -eq "$total" ] + + # PERRY_RS4GC=1: LLVM's own RewriteStatepointsForGC inserts the statepoints + # instead of Perry's explicit bridge. Split from the job above because it is + # the one arm with an external-tool dependency (`opt`), so a Homebrew hiccup + # cannot take the core arms down with it. + native-roots-rs4gc-aarch64: + runs-on: macos-14 + timeout-minutes: 90 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version-file: .node-version + - uses: dtolnay/rust-toolchain@stable + - 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. + brew list llvm >/dev/null 2>&1 || brew install llvm + llvm_bin="$(brew --prefix llvm)/bin" + if [ ! -x "$llvm_bin/opt" ] || [ ! -x "$llvm_bin/clang" ]; 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" + export PERRY_LLVM_CLANG="$llvm_bin/clang" + 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) + node --expose-gc --experimental-strip-types "$probe" > "/tmp/rs4gc-$name.oracle" + PERRY_RS4GC=1 ./target/perry-dev/perry "$probe" -o "/tmp/rs4gc-$name" + otool -l "/tmp/rs4gc-$name" | grep -q "sectname __perry_gcmap" \ + || { echo "::error::$name has no __perry_gcmap section — RS4GC produced no native root map"; exit 1; } + otool -l "/tmp/rs4gc-$name" | grep -q "sectname __llvm_stackmaps" \ + && { echo "::error::$name still carries __llvm_stackmaps — the compact rewrite did not run"; exit 1; } + PERRY_RS4GC=1 PERRY_GC_FORCE_EVACUATE=1 PERRY_GC_VERIFY_EVACUATION=1 \ + "/tmp/rs4gc-$name" > "/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. + PERRY_RS4GC=1 ./target/perry-dev/perry \ + benchmarks/gc_ratchet/probes/09_try_catch_roots.ts \ + -o /tmp/rs4gc-report-probe --statepoint-report=json 2> /tmp/rs4gc-report.json + python3 scripts/statepoint_report_assert.py /tmp/rs4gc-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. + statepoints-refuse-x86: + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + with: + shared-key: gc-native-roots-x86 + - 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: Statepoints must refuse, not silently drop roots + run: | + set -uo pipefail + export PERRY_RUNTIME_DIR="$PWD/target/perry-dev" + export PERRY_NO_AUTO_OPTIMIZE=1 + probe=benchmarks/gc_ratchet/probes/01_nursery_churn.ts + set +e + PERRY_STATEPOINTS=1 ./target/perry-dev/perry "$probe" -o /tmp/x86-probe \ + > /tmp/x86.out 2> /tmp/x86.err + rc=$? + set -e + tail -40 /tmp/x86.out /tmp/x86.err || true + if [ "$rc" -eq 0 ]; then + echo "::error::PERRY_STATEPOINTS now compiles on x86-64. That is good news and this job is the wrong shape for it: move the x86-64 host into native-roots-aarch64's matrix (rename it) and delete this job." + exit 1 + fi + # Non-zero for the RIGHT reason. Any old failure (missing clang, a + # broken checkout) would also be non-zero, and a job green on an + # unrelated error is the hazard this whole workflow is about. + if ! grep -qiE "stack map|compact-map|gc roots would be invisible" /tmp/x86.out /tmp/x86.err; then + echo "::error::statepoint compilation failed on x86-64, but not with the compact-map refusal this job asserts. Read the output above: either the refusal message changed, or something unrelated is broken." + exit 1 + fi + echo "x86-64: statepoint compilation refuses, as expected, with the compact-map message." + + # Fan-in, mirroring `conformance-smoke-complete` in test.yml: ONE context for + # branch protection to require, so adding an arm never needs a protection edit + # and a red arm cannot hide behind a green sibling. + gc-native-roots-complete: + needs: [native-roots-aarch64, native-roots-rs4gc-aarch64, statepoints-refuse-x86] + 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-aarch64=${{ needs.native-roots-aarch64.result }}" \ + "native-roots-rs4gc-aarch64=${{ needs.native-roots-rs4gc-aarch64.result }}" \ + "statepoints-refuse-x86=${{ needs.statepoints-refuse-x86.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." diff --git a/changelog.d/7322-statepoint-prerequisites.md b/changelog.d/7322-statepoint-prerequisites.md new file mode 100644 index 0000000000..c64fd65866 --- /dev/null +++ b/changelog.d/7322-statepoint-prerequisites.md @@ -0,0 +1,72 @@ +### Statepoint prerequisites: file-size gate, GC knob arms, and the reason the gate was never green + +Clears the two mechanical prerequisites between #7314's opt-in statepoint roots +and their becoming the default, and prepares the third. No emitted byte changes +on the default path. + +**File-size gate.** #7314 pushed two files over the 2,000-line cap; both split +along a seam that already existed. `perry-codegen/src/function.rs` 2036 → **952** +(the statepoint/RS4GC lowering into `function/precise_roots.rs`), and +`linker.rs` 2082 → **1618** (its unit tests into `linker_tests.rs`, the same +`#[path]` device that file already used for `linker_temp_lifecycle_tests.rs`). +Pure move: no renames, no signature changes, three items widened to +`pub(super)`. + +Verified by **byte-identical emitted IR**, not by "it compiles": both arms built +with the identical package set, binaries hashed and confirmed different, then +`--trace llvm` over 15 modules × 3 modes (default, `PERRY_STATEPOINTS=1`, +`PERRY_RS4GC=1`) — the statepoint modes included because the moved code runs in +no other mode. 39 of 45 `.ll` files byte-identical; the remaining 6 differ +identically under a **same-binary control** (Perry's tagged-template site id and +`__perry_cap_` capture suffix are not stable across runs of one binary), so +the refactor's contribution to the diff is empty. + +**GC knob kill-policy.** Four of #7314's five knobs appeared in no workflow. +`PERRY_STATEPOINT_REPORT` is **deleted** — it was a second spelling of +`--statepoint-report`, so the environment read is gone and the flag is the only +entry point (the driver still sets the variable to reach the rayon workers, +which is now its only role). The other three get arms in `gc-native-roots.yml`, +and each asserts **its own subject was live**, because `PERRY_GC_FORCE_EVACUATE` +passed for months while inert (#6942/#6946): + +- `PERRY_RS4GC` — every function record must carry `backend: rs4gc`. RS4GC bails + *per function* to the explicit bridge on any unrecognised root-alloca shape, + so a 9/9 green matrix is compatible with RS4GC having rewritten nothing. +- `PERRY_GC_SAFEPOINT_ONLY=strict` — a codegen differential over the whole probe + glob (statepoints 568 → 530, skipped calls 726 → 764). A strict run that never + panics proves enforcement was *armed*, not that the contract *did* anything, + and individual probes show a zero delta, so the assert is aggregate. +- `PERRY_STACKMAP_WALKER` — from the `PERRY_GC_TRACE=1` stream, `verify` + requires `fp_walks > 0` and `unwind` requires `fp_walks == 0` with + `walks > 0`. Every walker produces identical program output, so output alone + can never say which one ran. + +New helpers `scripts/statepoint_report_assert.py` and +`scripts/gc_walker_trace_assert.py` carry those assertions; +`scripts/gc_gate_wiring_check.py` now covers this workflow, so `lint` asserts +its wiring. + +**Statepoints are aarch64-only today (#7321).** `gc-native-roots` had never gone +green, and not flakily: on x86-64 Linux the compact-map rewriter refuses the +*first* probe — "this module emits an LLVM stack map that the compact-map +rewriter could not parse … Refusing to emit a binary that would lose roots +silently". That is the fail-closed path working; what it changes is scope, since +#7314's drizzle evidence is aarch64 evidence. The matrix moves to `macos-14`, +and the gap is asserted rather than dropped: `statepoints-refuse-x86` requires +that compile to fail *for the compact-map reason specifically*, and goes red the +day x86-64 starts working. + +**A latent defect in the same workflow, fixed.** It set +`RUSTFLAGS="-Cforce-frame-pointers=yes"`, which replaces `.cargo/config.toml`'s +`[build] rustflags` wholesale and so dropped `-C force-unwind-tables=yes`. A/B'd +on one tree: without it `09_try_catch_roots` aborts with "unwind tables are +missing from this runtime build (0 frame(s) visible to the unwinder)" and 4 of 9 +probes fail `PERRY_STACKMAP_WALKER=verify` with the unwinder visiting **zero** +frames; with it, 9/9. On any host where the x29 chain walk is unavailable the +unwinder *is* the walker, so that configuration would find no roots while forced +evacuation stayed quiet — it enumerates through the same walker. + +`gc-native-roots-complete` is a new fan-in job so branch protection needs one +context rather than one per arm. It is deliberately **not** promoted to required +here: a context that has never been green blocks every open PR the day it +becomes required. diff --git a/crates/perry-codegen/src/function.rs b/crates/perry-codegen/src/function.rs index 1d64e3d1d6..1284027820 100644 --- a/crates/perry-codegen/src/function.rs +++ b/crates/perry-codegen/src/function.rs @@ -9,6 +9,14 @@ use std::rc::Rc; use crate::block::{FpFlags, LlBlock, RegCounter}; use crate::types::LlvmType; +/// Precise GC roots lowered onto the native frame (statepoints / RS4GC, +/// #7173 / #7174). A sibling file only because of the 2,000-line cap. +mod precise_roots; + +use precise_roots::{ + lower_precise_roots_to_native_stack, retype_landing_pads_for_statepoints, PreciseRootBackend, +}; + pub struct LlFunction { pub name: String, pub return_type: LlvmType, @@ -942,1095 +950,3 @@ pub enum FinalItem<'a> { /// A typed instruction — the native backend constructs it directly. Inst(&'a crate::inst::LlInst), } - -fn parse_shadow_bind(line: &str) -> Option<(usize, String)> { - let rest = line - .trim() - .strip_prefix("call void @js_shadow_slot_bind(i32 ")?; - let (idx, ptr) = rest.split_once(", ptr ")?; - let ptr = ptr.strip_suffix(')')?.trim(); - Some((idx.parse().ok()?, ptr.to_string())) -} - -fn parse_shadow_set(line: &str) -> Option<(usize, String)> { - let rest = line - .trim() - .strip_prefix("call void @js_shadow_slot_set(i32 ")?; - let (idx, value) = rest.split_once(", i64 ")?; - let value = value.strip_suffix(')')?.trim(); - Some((idx.parse().ok()?, value.to_string())) -} - -/// Compute a conservative set of active logical shadow slots before each IR -/// line. Joins use union ("active on any incoming path"), so a stale local can -/// be retained but a live root cannot be omitted. -fn stack_map_active_slots( - lines: &[&str], - slot_count: u32, -) -> Vec>> { - use std::collections::{HashMap, HashSet, VecDeque}; - - #[derive(Debug)] - struct Block { - first_line: usize, - end_line: usize, - successors: Vec, - } - - fn label_name(line: &str) -> Option<&str> { - if line.starts_with(char::is_whitespace) { - return None; - } - line.strip_suffix(':') - .filter(|name| !name.is_empty() && !name.starts_with(';')) - } - - fn referenced_labels(line: &str) -> Vec<&str> { - let mut labels = Vec::new(); - let mut rest = line; - while let Some(pos) = rest.find("label %") { - let after = &rest[pos + "label %".len()..]; - let len = after - .bytes() - .take_while(|byte| { - byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-' | b'$') - }) - .count(); - if len == 0 { - break; - } - labels.push(&after[..len]); - rest = &after[len..]; - } - labels - } - - let labels: Vec<(usize, &str)> = lines - .iter() - .enumerate() - .filter_map(|(idx, line)| label_name(line).map(|name| (idx, name))) - .collect(); - let mut states = vec![None; lines.len()]; - if labels.is_empty() { - return states; - } - - let label_to_block: HashMap<&str, usize> = labels - .iter() - .enumerate() - .map(|(block, (_, name))| (*name, block)) - .collect(); - let mut blocks: Vec = labels - .iter() - .enumerate() - .map(|(block, (label_line, _))| Block { - first_line: label_line + 1, - end_line: labels - .get(block + 1) - .map_or(lines.len(), |(next_line, _)| *next_line), - successors: Vec::new(), - }) - .collect(); - for block in &mut blocks { - let mut seen = HashSet::new(); - for line in &lines[block.first_line..block.end_line] { - for label in referenced_labels(line) { - if let Some(&successor) = label_to_block.get(label) { - if seen.insert(successor) { - block.successors.push(successor); - } - } - } - } - } - - fn apply_root_op(state: &mut HashSet, line: &str, slot_count: u32) { - if let Some((idx, _)) = parse_shadow_bind(line) { - if idx < slot_count as usize { - state.insert(idx); - } - } else if let Some((idx, value)) = parse_shadow_set(line) { - if idx < slot_count as usize { - if value == "0" { - state.remove(&idx); - } else { - state.insert(idx); - } - } - } - } - - let mut entries: Vec>> = vec![None; blocks.len()]; - entries[0] = Some(HashSet::new()); - let mut work = VecDeque::from([0usize]); - while let Some(block_idx) = work.pop_front() { - let Some(mut state) = entries[block_idx].clone() else { - continue; - }; - let block = &blocks[block_idx]; - for line in &lines[block.first_line..block.end_line] { - apply_root_op(&mut state, line, slot_count); - } - for &successor in &block.successors { - let changed = match &mut entries[successor] { - Some(existing) => { - let old_len = existing.len(); - existing.extend(state.iter().copied()); - existing.len() != old_len - } - entry @ None => { - *entry = Some(state.clone()); - true - } - }; - if changed { - work.push_back(successor); - } - } - } - - for (block_idx, block) in blocks.iter().enumerate() { - let Some(mut state) = entries[block_idx].clone() else { - continue; - }; - for (line_idx, line) in lines - .iter() - .enumerate() - .take(block.end_line) - .skip(block.first_line) - { - states[line_idx] = Some(state.clone()); - apply_root_op(&mut state, line, slot_count); - } - } - states -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum PreciseRootBackend { - Statepoint, - /// `PERRY_RS4GC=1` (#7174): retype every root alloca to - /// `ptr addrspace(1)` with cast surgery at its load/store sites, tag the - /// function `gc "statepoint-example"`, mark audited non-collecting - /// callees `"gc-leaf-function"` at the call site, and emit NO per-call - /// safepoint machinery — `opt -passes='function(mem2reg), - /// rewrite-statepoints-for-gc'` promotes the allocas to SSA and inserts - /// every statepoint, relocation, and downstream-use rewrite itself. - /// After mem2reg, each former load site is a cast site, which is exactly - /// the placement RS4GC needs to rewrite uses with relocated values. - /// Fail-closed: any use of a root alloca outside the recognized - /// load/store shapes bails the whole function to the Statepoint backend. - Rs4gc, -} - -impl PreciseRootBackend { - fn as_str(self) -> &'static str { - match self { - Self::Statepoint => "statepoint", - Self::Rs4gc => "rs4gc", - } - } -} - -/// RS4GC surgery (#7174): retype root allocas to `ptr addrspace(1)` and cast -/// at every recognized load/store site. Returns `None` when any root alloca -/// appears in an unrecognized shape (the caller falls back to the explicit -/// statepoint backend for the whole function). -fn lower_roots_for_rs4gc(lines: &[&str], root_ptrs: &[String]) -> Option { - let roots: std::collections::HashSet<&str> = root_ptrs.iter().map(String::as_str).collect(); - let mut out = String::with_capacity(lines.len() * 48 + root_ptrs.len() * 96); - let mut cast_counter = 0usize; - - for line in lines { - if parse_shadow_bind(line).is_some() || parse_shadow_set(line).is_some() { - continue; - } - let trimmed = line.trim_start(); - - // Root-alloca definition: retype + null-init (mem2reg needs a - // dominating definition for paths that read before the first bind, - // same reason the i64 zero-init existed). - // Root locals are emitted as `alloca double` (the NaN-box home) or - // occasionally `alloca i64`; both become an addrspace(1) slot. - if let Some(reg) = trimmed - .strip_suffix("= alloca i64") - .or_else(|| trimmed.strip_suffix("= alloca double")) - .map(str::trim_end) - .filter(|reg| roots.contains(reg)) - { - out.push_str(&format!(" {reg} = alloca ptr addrspace(1)\n")); - out.push_str(&format!(" store ptr addrspace(1) null, ptr {reg}\n")); - continue; - } - - let mut handled = false; - for ptr in root_ptrs { - if let Some(rest) = trimmed.strip_prefix("store i64 ") { - if let Some(value) = rest.strip_suffix(&format!(", ptr {ptr}")) { - let value = value.trim(); - if value == "0" { - out.push_str(&format!(" store ptr addrspace(1) null, ptr {ptr}\n")); - } else { - cast_counter += 1; - out.push_str(&format!( - " %rs4gc.s{cast_counter} = inttoptr i64 {value} to ptr addrspace(1)\n store ptr addrspace(1) %rs4gc.s{cast_counter}, ptr {ptr}\n" - )); - } - handled = true; - break; - } - } - if let Some(rest) = trimmed.strip_prefix("store double ") { - if let Some(value) = rest.strip_suffix(&format!(", ptr {ptr}")) { - let value = value.trim(); - cast_counter += 1; - out.push_str(&format!( - " %rs4gc.b{cast_counter} = bitcast double {value} to i64\n %rs4gc.s{cast_counter} = inttoptr i64 %rs4gc.b{cast_counter} to ptr addrspace(1)\n store ptr addrspace(1) %rs4gc.s{cast_counter}, ptr {ptr}\n" - )); - handled = true; - break; - } - } - if trimmed - == format!( - "{} = load i64, ptr {ptr}", - trimmed.split(' ').next().unwrap_or("") - ) - { - let result = trimmed.split(' ').next().unwrap_or(""); - out.push_str(&format!( - " {result}.rs4p = load ptr addrspace(1), ptr {ptr}\n {result} = ptrtoint ptr addrspace(1) {result}.rs4p to i64\n" - )); - handled = true; - break; - } - if trimmed - == format!( - "{} = load double, ptr {ptr}", - trimmed.split(' ').next().unwrap_or("") - ) - { - let result = trimmed.split(' ').next().unwrap_or(""); - out.push_str(&format!( - " {result}.rs4p = load ptr addrspace(1), ptr {ptr}\n {result}.rs4i = ptrtoint ptr addrspace(1) {result}.rs4p to i64\n {result} = bitcast i64 {result}.rs4i to double\n" - )); - handled = true; - break; - } - } - if handled { - continue; - } - - // Fail closed: any other appearance of a root alloca name. - if root_ptrs.iter().any(|ptr| { - line.contains(ptr.as_str()) - && line - .split(|c: char| !(c.is_alphanumeric() || c == '%' || c == '_' || c == '.')) - .any(|tok| tok == ptr) - }) { - return None; - } - - // Audited non-collecting callees become RS4GC leaf calls: the pass - // will not treat them as safepoints, transferring the call-effect - // table wholesale. AllocNoReentry keeps its contract gating. - let is_call = trimmed.starts_with("call ") - || trimmed.contains(" = call ") - || trimmed.starts_with("tail call ") - || trimmed.contains(" = tail call "); - // Inline asm must be marked leaf explicitly: RS4GC otherwise rewrites - // it into a statepoint whose callee is the asm value, which the - // verifier rejects outright ("Cannot take the address of an inline - // asm!"). Found on the Claude Code bundle, where other codegen paths - // emit zero-instruction asm barriers. - if is_call && trimmed.ends_with(')') && trimmed.contains(" asm ") { - out.push_str(line.trim_end()); - out.push_str(" \"gc-leaf-function\"\n"); - continue; - } - if is_call && trimmed.ends_with(')') && !trimmed.contains(" asm ") { - if let Some(callee) = direct_callee_name(line) { - let leaf = match crate::gc_call_effects::classify_direct_callee(callee) { - crate::gc_call_effects::GcCallEffect::CannotCollect - | crate::gc_call_effects::GcCallEffect::NeverReturns => true, - crate::gc_call_effects::GcCallEffect::AllocNoReentry => { - crate::codegen::helpers::gc_safepoint_only_contract_enabled() - } - crate::gc_call_effects::GcCallEffect::Unknown => false, - }; - if leaf && !callee.starts_with("llvm.") { - out.push_str(line.trim_end()); - out.push_str(" \"gc-leaf-function\"\n"); - continue; - } - } - } - - out.push_str(line); - out.push('\n'); - } - Some(out) -} - -#[derive(Debug, Eq, PartialEq)] -struct DirectCall<'a> { - result: Option<&'a str>, - return_type: &'a str, - callee: &'a str, - args: Vec<&'a str>, - arg_types: Vec<&'a str>, -} - -fn split_call_args(args: &str) -> Option> { - if args.trim().is_empty() { - return Some(Vec::new()); - } - let mut out = Vec::new(); - let mut depth = 0i32; - let mut start = 0usize; - for (idx, ch) in args.char_indices() { - match ch { - '(' | '[' | '{' | '<' => depth += 1, - ')' | ']' | '}' | '>' => { - depth -= 1; - if depth < 0 { - return None; - } - } - ',' if depth == 0 => { - out.push(args[start..idx].trim()); - start = idx + 1; - } - _ => {} - } - } - if depth != 0 { - return None; - } - out.push(args[start..].trim()); - Some(out) -} - -fn statepoint_scalar_type(arg: &str) -> Option<&str> { - let ty = arg.split_ascii_whitespace().next()?; - matches!( - ty, - "i1" | "i8" | "i16" | "i32" | "i64" | "i128" | "float" | "double" | "ptr" - ) - .then_some(ty) -} - -/// Parse the deliberately small direct-call subset emitted by `LlBlock`. -/// -/// Calls with tail markers, operand attributes, aggregate types, inline asm, -/// indirect targets, or call-site suffixes stay on the plain stack-map -/// fallback. That keeps the research mode correct while making its explicit -/// statepoint coverage measurable and easy to expand. -fn parse_direct_statepoint_call(line: &str) -> Option> { - let trimmed = line.trim(); - let (result, call) = if let Some(call) = trimmed.strip_prefix("call ") { - (None, call) - } else { - let (result, call) = trimmed.split_once(" = call ")?; - (Some(result.trim()), call) - }; - let (return_type, target_and_args) = call.split_once(' ')?; - if !matches!( - return_type, - "void" | "i1" | "i8" | "i16" | "i32" | "i64" | "i128" | "float" | "double" | "ptr" - ) { - return None; - } - if return_type != "void" && result.is_none() { - return None; - } - let open = target_and_args.find('(')?; - let close = target_and_args.rfind(')')?; - if close + 1 != target_and_args.len() { - return None; - } - let callee = target_and_args[..open].trim(); - // Indirect targets are statepoint-able: `gc.statepoint` takes the callee as - // a `ptr` operand, and `emit_statepoint` interpolates it verbatim, so - // `ptr elementtype(T) %fnptr` is as valid as `... @callee`. Rejecting them - // was a limitation of this textual parser, not of statepoints — and the - // fallback it forced is the unsound plain stack map. An unknown callee - // simply cannot be audited as non-collecting, which is the conservative - // (correct) answer anyway. - let direct = callee.starts_with('@'); - let indirect = callee.starts_with('%'); - if !(direct || indirect) - || callee.starts_with("@llvm.") - || matches!(callee, "@setjmp" | "@_setjmp" | "@longjmp" | "@_longjmp") - { - return None; - } - let args = split_call_args(&target_and_args[open + 1..close])?; - let arg_types = args - .iter() - .map(|arg| statepoint_scalar_type(arg)) - .collect::>>()?; - Some(DirectCall { - result, - return_type, - callee, - args, - arg_types, - }) -} - -/// Return a direct callee name without the leading `@`. -/// -/// This accepts more call syntax than the statepoint parser because the -/// GC-effect audit only needs to recognize a direct target. Unsupported and -/// indirect forms return `None` and therefore stay conservative. -fn direct_callee_name(line: &str) -> Option<&str> { - let call = line.trim().split_once("call ")?.1; - let args_open = call.find('(')?; - let target = call[..args_open].trim(); - let name = target.split_ascii_whitespace().last()?.strip_prefix('@')?; - (!name.is_empty() - && name - .chars() - .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '.' | '$'))) - .then_some(name) -} - -fn gc_result_suffix(ty: &str) -> Option<&'static str> { - match ty { - "i1" => Some("i1"), - "i8" => Some("i8"), - "i16" => Some("i16"), - "i32" => Some("i32"), - "i64" => Some("i64"), - "i128" => Some("i128"), - "float" => Some("f32"), - "double" => Some("f64"), - "ptr" => Some("p0"), - _ => None, - } -} - -/// Emit one explicit statepoint relocation sequence. -/// -/// Perry roots remain ordinary NaN-boxed `i64` values everywhere else. At -/// this boundary we load each live word, carry its exact bits through a -/// temporary addrspace(1) pointer, and convert the `gc.relocate` result back -/// into the existing slot. LLVM therefore owns the spill/reload and the -/// post-safepoint SSA transition without requiring a whole-program -/// representation change for this prototype. -fn emit_statepoint(out: &mut String, call: &DirectCall<'_>, live: &[&String], statepoint_id: u64) { - for (root_idx, ptr) in live.iter().enumerate() { - out.push_str(&format!( - " %perry_sp_bits_{statepoint_id}_{root_idx} = load i64, ptr {ptr}\n" - )); - out.push_str(&format!( - " %perry_sp_root_{statepoint_id}_{root_idx} = inttoptr i64 \ - %perry_sp_bits_{statepoint_id}_{root_idx} to ptr addrspace(1)\n" - )); - } - - let function_type = format!("{} ({})", call.return_type, call.arg_types.join(", ")); - let call_args = call - .args - .iter() - .map(|arg| format!(", {arg}")) - .collect::(); - let gc_live = live - .iter() - .enumerate() - .map(|(root_idx, _)| format!("ptr addrspace(1) %perry_sp_root_{statepoint_id}_{root_idx}")) - .collect::>() - .join(", "); - out.push_str(&format!( - " %perry_sp_token_{statepoint_id} = call token (i64, i32, ptr, i32, i32, ...) \ - @llvm.experimental.gc.statepoint.p0(i64 {statepoint_id}, i32 0, \ - ptr elementtype({function_type}) {}, i32 {}, i32 0{call_args}, i32 0, i32 0) \ - [\"gc-live\"({gc_live})]\n", - call.callee, - call.args.len() - )); - - if let Some(result) = call.result { - let suffix = gc_result_suffix(call.return_type) - .expect("non-void statepoint return type was validated by the parser"); - out.push_str(&format!( - " {result} = call {} @llvm.experimental.gc.result.{suffix}(token \ - %perry_sp_token_{statepoint_id})\n", - call.return_type - )); - } - - for (root_idx, ptr) in live.iter().enumerate() { - out.push_str(&format!( - " %perry_sp_relocated_{statepoint_id}_{root_idx} = call ptr addrspace(1) \ - @llvm.experimental.gc.relocate.p1(token %perry_sp_token_{statepoint_id}, \ - i32 {root_idx}, i32 {root_idx})\n" - )); - out.push_str(&format!( - " %perry_sp_relocated_bits_{statepoint_id}_{root_idx} = ptrtoint \ - ptr addrspace(1) %perry_sp_relocated_{statepoint_id}_{root_idx} to i64\n" - )); - out.push_str(&format!( - " store i64 %perry_sp_relocated_bits_{statepoint_id}_{root_idx}, ptr {ptr}\n" - )); - } -} - -/// Lower Perry's existing precise-root operations to native-stack metadata. -/// -/// The old binding calls already name exactly the mutable native alloca that a -/// moving collection must rewrite. We use them as compile-time markers: -/// -/// * collect `logical slot -> native alloca`; -/// * remove the runtime bind calls and shadow-frame traffic; -/// * compute conservative per-call liveness from bind/clear markers without -/// mutating the native slot; -/// * either place a plain stack map before a call, or replace a supported call -/// with a statepoint/result/relocate sequence. -/// -/// Statepoint mode deliberately retains a plain-stack-map fallback for call -/// forms outside the narrow parser above. The fallback preserves correctness -/// while the report records how much of real Perry code reaches the explicit -/// relocation path. -fn lower_precise_roots_to_native_stack( - ir: &str, - function_name: &str, - slot_count: u32, - backend: PreciseRootBackend, -) -> String { - let lines: Vec<&str> = ir.lines().collect(); - let active_slots = stack_map_active_slots(&lines, slot_count); - let mut roots: Vec> = vec![None; slot_count as usize]; - for line in &lines { - if let Some((idx, ptr)) = parse_shadow_bind(line) { - if let Some(root) = roots.get_mut(idx) { - match root { - Some(existing) => { - debug_assert_eq!( - existing, &ptr, - "one precise-root slot must not bind two native allocas" - ); - } - None => *root = Some(ptr), - } - } - } - } - - let slot_roots = roots; - let root_ptrs: Vec = slot_roots.iter().flatten().cloned().collect(); - let mut report = crate::statepoint_report::enabled().then(|| { - crate::statepoint_report::FunctionRecord::new( - function_name, - backend.as_str(), - slot_count, - root_ptrs.len(), - ) - }); - // RS4GC runs BEFORE the empty-roots early return on purpose: a function - // can reserve slots (so it carries `gc "statepoint-example"`) yet bind - // none, and it still contains inline asm that RS4GC would rewrite into an - // invalid statepoint. Found on the Claude Code bundle, where the early - // return skipped leaf-marking and the verifier aborted with "Cannot take - // the address of an inline asm!". - if backend == PreciseRootBackend::Rs4gc { - if let Some(out) = lower_roots_for_rs4gc(&lines, &root_ptrs) { - if let Some(mut report) = report { - report.note_call(root_ptrs.len()); - crate::statepoint_report::record(report); - } - return out; - } - return lower_precise_roots_to_native_stack( - ir, - function_name, - slot_count, - PreciseRootBackend::Statepoint, - ); - } - - if root_ptrs.is_empty() { - let out = ir - .lines() - .filter(|line| parse_shadow_bind(line).is_none() && parse_shadow_set(line).is_none()) - .map(|line| format!("{line}\n")) - .collect(); - if let Some(report) = report { - crate::statepoint_report::record(report); - } - return out; - } - - let mut out = String::with_capacity(ir.len() + root_ptrs.len() * 128); - let mut available = std::collections::HashSet::::new(); - let mut initialized = std::collections::HashSet::::new(); - let mut map_id = 0u64; - - for (line_idx, line) in lines.iter().enumerate() { - if parse_shadow_bind(line).is_some() { - // Compile-time marker only. The real slot is already populated by - // the local store immediately preceding this old bind. - continue; - } - if parse_shadow_set(line).is_some() { - // This marker changes stack-map liveness, not the program local. - // Shadow-stack clears only flipped SLOT_ACTIVE for the same - // reason: a value can be semantically read after its final - // GC-capable call. - continue; - } - - // A stack-map operand must dominate the intrinsic. Root allocas are - // normally entry-hoisted, but tracking definitions here also handles - // the few block-local scalar-replacement slots without emitting - // invalid SSA. - for ptr in &root_ptrs { - if line.trim_start().starts_with(&format!("{ptr} = ")) { - available.insert(ptr.clone()); - } - } - - out.push_str(line); - out.push('\n'); - - // Slots can be named by a stack map before their source-level `let` - // executes. Zero them directly after the alloca so an earlier - // safepoint never exposes uninitialized stack bytes as roots. - for ptr in &root_ptrs { - if available.contains(ptr) - && !initialized.contains(ptr) - && line.trim_start().starts_with(&format!("{ptr} = alloca ")) - { - out.push_str(&format!(" store i64 0, ptr {ptr}\n")); - initialized.insert(ptr.clone()); - } - } - - // Insert before calls, not after. Rebuild the tail when the line just - // appended is a call so the intrinsic's instruction offset is the - // actual call-site offset in the final machine function. - let trimmed = line.trim_start(); - let is_call = trimmed.starts_with("call ") - || trimmed.contains(" = call ") - || trimmed.starts_with("tail call ") - || trimmed.contains(" = tail call "); - if !is_call || trimmed.contains("@llvm.experimental.stackmap") { - continue; - } - let active = active_slots.get(line_idx).and_then(Option::as_ref); - let live: Vec<&String> = slot_roots - .iter() - .enumerate() - .filter(|(idx, _)| active.is_some_and(|slots| slots.contains(idx))) - .filter_map(|(_, ptr)| ptr.as_ref()) - .filter(|ptr| available.contains(*ptr) && initialized.contains(*ptr)) - .collect(); - if let Some(report) = report.as_mut() { - report.note_call(live.len()); - } - if live.is_empty() { - continue; - } - - let direct_callee = direct_callee_name(line); - let is_compiler_only = direct_callee.is_some_and(|callee| callee.starts_with("llvm.")) - || trimmed.contains("call void asm "); - let cannot_collect = direct_callee.is_some_and(|callee| { - match crate::gc_call_effects::classify_direct_callee(callee) { - crate::gc_call_effects::GcCallEffect::CannotCollect => true, - // Control never returns here: no relocation is consumed and - // the frame's roots are dead past the call. Deeper frames - // carry their own records. - crate::gc_call_effects::GcCallEffect::NeverReturns => true, - // Under the explicit-safepoint contract the runtime - // guarantees these helpers' triggers never consume this - // frame's precise roots (they defer to a declared safepoint - // or collect behind a forced conservative scan), so the - // call site needs no metadata. Without the contract they - // stay safepoints. - crate::gc_call_effects::GcCallEffect::AllocNoReentry => { - crate::codegen::helpers::gc_safepoint_only_contract_enabled() - } - crate::gc_call_effects::GcCallEffect::Unknown => false, - } - }); - if is_compiler_only || cannot_collect { - // LLVM intrinsics, zero-instruction compiler barriers, and - // runtime helpers in the audited GC-effect table cannot enter - // Perry's allocator. Neither native-stack backend needs metadata - // around them. - if let Some(report) = report.as_mut() { - report.note_skipped(direct_callee.unwrap_or("")); - } - continue; - } - - // Move the call line behind the intrinsic. - let call_len = line.len() + 1; - out.truncate(out.len() - call_len); - if backend == PreciseRootBackend::Statepoint { - if let Some(call) = parse_direct_statepoint_call(line) { - emit_statepoint(&mut out, &call, &live, map_id); - if let Some(report) = report.as_mut() { - report.note_statepoint(call.callee.trim_start_matches('@'), live.len()); - } - map_id += 1; - continue; - } - } - // No statepoint could be formed for a call that has live roots. The - // old behaviour was to fall back to a plain `llvm.experimental.stackmap`, - // which is UNSOUND: LLVM may record a root slot's address as - // `Register R#N`, caller-saved and unrecoverable at collection time, - // so the collector silently misses that root. - // - // Measured on test-drizzle-pg (133 modules): 23,301 safepoints, ALL - // statepoints, 0 plain stack maps, 0 parser fallbacks. The path is not - // taken by real code, so failing closed costs nothing and removes the - // last way this backend can lose a root. A loud compile failure beats - // silent heap corruption. - panic!( - "perry: native-root lowering could not express a safepoint for \ - `{}` in @{} ({} live roots). Falling back to a plain stack map \ - here would record roots in caller-saved registers that the \ - collector cannot recover, so the compile stops instead. Report \ - this call shape on #7174.", - direct_callee.unwrap_or(""), - function_name, - live.len(), - ); - } - if let Some(report) = report { - crate::statepoint_report::record(report); - } - out -} - -/// Retype Itanium landing pads to `token` for `statepoint-example`. -/// -/// RS4GC uses the unwind destination's landing pad **as the token** for the -/// relocates it inserts on the exceptional edge, so the pad must already be -/// `landingpad token`. Given `{ ptr, i32 }` it emits -/// `gc.relocate({ ptr, i32 } %lpad, ...)` and the verifier rejects the module, -/// which is why a try-carrying function failed to compile under RS4GC at all. -/// -/// This is only sound because the pad's value is **dead**: `try_stmt` emits it -/// to anchor the edge and branches straight on, taking the exception from the -/// runtime rather than the pad payload. So a pad whose register IS referenced -/// is left alone — retyping a value someone reads would swap a silent -/// miscompile for the loud one this fixes. -fn retype_landing_pads_for_statepoints(ir: &str) -> String { - const ITANIUM: &str = "landingpad { ptr, i32 } catch ptr null"; - if !ir.contains(ITANIUM) { - return ir.to_string(); - } - let mut out = String::with_capacity(ir.len()); - for line in ir.lines() { - let rewritten = match line.split_once(" = ") { - Some((reg, rest)) if rest.trim() == ITANIUM => { - let reg = reg.trim(); - // Referenced anywhere else? Then its payload is live. - let used = ir.lines().any(|other| { - !std::ptr::eq(other.as_ptr(), line.as_ptr()) && mentions_register(other, reg) - }); - if used { - None - } else { - Some(format!("{} = landingpad token cleanup", reg)) - } - } - _ => None, - }; - match rewritten { - Some(r) => out.push_str(&r), - None => out.push_str(line), - } - out.push('\n'); - } - out -} - -/// Whether `line` mentions SSA register `reg` as a whole token rather than as -/// a prefix of a longer name (`%r2` must not match `%r21`). -fn mentions_register(line: &str, reg: &str) -> bool { - let mut from = 0; - while let Some(idx) = line[from..].find(reg) { - let at = from + idx; - let after = line[at + reg.len()..].chars().next(); - if !matches!(after, Some(c) if c.is_ascii_alphanumeric() || c == '_' || c == '.') { - return true; - } - from = at + reg.len(); - } - false -} - -#[cfg(test)] -mod stack_map_tests { - - #[test] - fn retypes_dead_landing_pads_for_rs4gc() { - let ir = "define void @probe() {\n\ - entry:\n\ - %lp = landingpad { ptr, i32 } catch ptr null\n\ - br label %next\n\ - }\n"; - let out = super::retype_landing_pads_for_statepoints(ir); - assert!(out.contains("%lp = landingpad token cleanup"), "{out}"); - } - - #[test] - fn leaves_a_used_landing_pad_alone() { - // If the pad's payload is read, retyping it to `token` would break the - // consumer silently. Fail closed: RS4GC's loud verifier error is the - // better outcome. - let ir = "define void @probe() {\n\ - entry:\n\ - %lp = landingpad { ptr, i32 } catch ptr null\n\ - %exn = extractvalue { ptr, i32 } %lp, 0\n\ - br label %next\n\ - }\n"; - let out = super::retype_landing_pads_for_statepoints(ir); - assert!( - out.contains("%lp = landingpad { ptr, i32 } catch ptr null"), - "{out}" - ); - } - - #[test] - fn register_match_is_whole_token() { - // `%r2` must not be considered used by a mention of `%r21`. - assert!(super::mentions_register(" br label %r2", "%r2")); - assert!(!super::mentions_register(" %x = add i64 %r21, 1", "%r2")); - } - use super::{ - direct_callee_name, lower_precise_roots_to_native_stack, parse_direct_statepoint_call, - PreciseRootBackend, - }; - - fn lower_statepoints(input: &str, slots: u32) -> String { - lower_precise_roots_to_native_stack(input, "probe", slots, PreciseRootBackend::Statepoint) - } - - #[test] - fn lowers_bind_and_liveness_clear_to_native_frame_maps() { - let input = r#"define i64 @probe(i64 %arg) { -entry.0: - %r0 = alloca i64 - store i64 %arg, ptr %r0 - call void @js_shadow_slot_bind(i32 0, ptr %r0) - %r1 = call i64 @may_collect() - call void @js_shadow_slot_set(i32 0, i64 0) - call void @may_collect_again() - ret i64 %r1 -} -"#; - let output = lower_statepoints(input, 1); - assert!(!output.contains("@js_shadow_slot_bind")); - assert!(!output.contains("@js_shadow_slot_set")); - assert!(output.contains("%r0 = alloca i64\n store i64 0, ptr %r0")); - assert!( - output.contains("@llvm.experimental.gc.statepoint.p0"), - "the collecting call must become a statepoint:\n{output}" - ); - assert!( - output.contains("%r0"), - "the root slot must appear in the statepoint's live list:\n{output}" - ); - assert_eq!(output.matches("store i64 0, ptr %r0").count(), 1); - assert_eq!( - output - .matches("@llvm.experimental.gc.statepoint.p0") - .count(), - 1 - ); - assert!(output.contains("call void @may_collect_again()")); - } - - #[test] - fn does_not_reference_a_root_before_its_alloca_dominates() { - let input = r#"define void @probe() { -entry.0: - call void @early_call() - %r0 = alloca i64 - call void @js_shadow_slot_bind(i32 0, ptr %r0) - call void @late_call() - ret void -} -"#; - let output = lower_statepoints(input, 1); - let early = output.find("call void @early_call()").unwrap(); - let first_map = output.find("@llvm.experimental.gc.statepoint.p0").unwrap(); - assert!( - early < first_map, - "no safepoint may reference a root before its alloca dominates:\n{output}" - ); - assert!( - output.contains("@late_call"), - "the dominated call must still be mapped:\n{output}" - ); - } - - #[test] - fn unions_root_liveness_at_control_flow_joins() { - let input = r#"define void @probe(i1 %cond) { -entry.0: - %r0 = alloca i64 - call void @js_shadow_slot_bind(i32 0, ptr %r0) - br i1 %cond, label %live.1, label %dead.2 -live.1: - call void @live_call() - br label %merge.3 -dead.2: - call void @js_shadow_slot_set(i32 0, i64 0) - call void @dead_call() - br label %merge.3 -merge.3: - call void @merge_call() - ret void -} -"#; - let output = lower_statepoints(input, 1); - assert!(output.contains("@llvm.experimental.gc.statepoint.p0")); - assert!(!output.contains("@dead_call, ptr %r0")); - assert!(output.contains("@merge_call")); - } - - #[test] - fn parses_the_scalar_direct_call_subset() { - assert_eq!( - direct_callee_name(" %r7 = call double @foo(i64 %r1, ptr %r2)"), - Some("foo") - ); - assert_eq!( - direct_callee_name(" %r7 = call i64 ()* %fn()"), - None, - "an indirect target must not be inferred from its arguments" - ); - assert_eq!( - parse_direct_statepoint_call(" %r7 = call double @foo(i64 %r1, ptr %r2)"), - Some(super::DirectCall { - result: Some("%r7"), - return_type: "double", - callee: "@foo", - args: vec!["i64 %r1", "ptr %r2"], - arg_types: vec!["i64", "ptr"], - }) - ); - assert!(parse_direct_statepoint_call( - " %r7 = call double (i64, ptr)* %fn(i64 %r1, ptr %r2)" - ) - .is_none()); - assert!(parse_direct_statepoint_call(" call void @llvm.assume(i1 %ok)").is_none()); - assert!(parse_direct_statepoint_call(" %r7 = tail call i64 @foo()").is_none()); - } - - #[test] - fn lowers_direct_calls_to_explicit_statepoint_relocations() { - let input = r#"define i64 @probe(i64 %arg) { -entry.0: - %r0 = alloca i64 - store i64 %arg, ptr %r0 - call void @js_shadow_slot_bind(i32 0, ptr %r0) - %r1 = call i64 @may_collect(i64 %arg) - ret i64 %r1 -} -"#; - let output = lower_statepoints(input, 1); - assert!(!output.contains("call i64 @may_collect")); - assert!(!output.contains("asm sideeffect")); - assert!(output - .contains("%perry_sp_root_0_0 = inttoptr i64 %perry_sp_bits_0_0 to ptr addrspace(1)")); - assert!(output.contains( - "ptr elementtype(i64 (i64)) @may_collect, i32 1, i32 0, i64 %arg, i32 0, i32 0" - )); - assert!(output - .contains("%r1 = call i64 @llvm.experimental.gc.result.i64(token %perry_sp_token_0)")); - assert!(output - .contains("@llvm.experimental.gc.relocate.p1(token %perry_sp_token_0, i32 0, i32 0)")); - assert!(output.contains("store i64 %perry_sp_relocated_bits_0_0, ptr %r0")); - } - - #[test] - fn statepoint_mode_maps_indirect_calls() { - // An indirect call used to fall back to a plain stack map, which is the - // unsound lowering: LLVM may record the root's address in a - // caller-saved register. `gc.statepoint` takes its callee as a `ptr` - // operand, so an indirect target is expressible — the restriction was - // in this textual parser, not in statepoints. - let input = r#"define i64 @probe(i64 %arg, ptr %fn) { -entry.0: - %r0 = alloca i64 - store i64 %arg, ptr %r0 - call void @js_shadow_slot_bind(i32 0, ptr %r0) - %r1 = call i64 %fn() - ret i64 %r1 -} -"#; - let output = lower_statepoints(input, 1); - assert!( - output.contains("@llvm.experimental.gc.statepoint.p0"), - "an indirect call with live roots must become a statepoint:\n{output}" - ); - assert!( - output.contains("%fn"), - "the indirect target must survive as the statepoint callee:\n{output}" - ); - assert!( - !output.contains("@llvm.experimental.stackmap"), - "no plain (unsound) stack map may remain:\n{output}" - ); - } - - #[test] - fn statepoint_mode_does_not_map_non_allocating_llvm_intrinsics() { - let input = r#"define void @probe(i64 %arg, i1 %condition) { -entry.0: - %r0 = alloca i64 - store i64 %arg, ptr %r0 - call void @js_shadow_slot_bind(i32 0, ptr %r0) - call void @llvm.assume(i1 %condition) - call void @may_collect() - ret void -} -"#; - let output = lower_statepoints(input, 1); - assert!(output.contains("call void @llvm.assume(i1 %condition)")); - assert_eq!( - output - .matches("@llvm.experimental.gc.statepoint.p0") - .count(), - 1 - ); - assert!(!output.contains("@llvm.experimental.stackmap")); - } - - #[test] - fn audited_non_collecting_helpers_are_not_safepoints_in_either_backend() { - let input = r#"define void @probe(i64 %arg) { -entry.0: - %r0 = alloca i64 - store i64 %arg, ptr %r0 - call void @js_shadow_slot_bind(i32 0, ptr %r0) - call void @js_gc_temp_root_push(i64 %arg) - call void @js_write_barrier_root_nanbox(i64 %arg) - call void @js_gc_loop_safepoint() - ret void -} -"#; - for output in [lower_statepoints(input, 1)] { - assert!(output.contains("call void @js_gc_temp_root_push(i64 %arg)")); - assert!(output.contains("call void @js_write_barrier_root_nanbox(i64 %arg)")); - assert_eq!( - output.matches("@llvm.experimental.stackmap").count() - + output - .matches("@llvm.experimental.gc.statepoint.p0") - .count(), - 1, - "only the explicit collection boundary should be a safepoint:\n{output}" - ); - } - } -} diff --git a/crates/perry-codegen/src/function/precise_roots.rs b/crates/perry-codegen/src/function/precise_roots.rs new file mode 100644 index 0000000000..6531cc17c5 --- /dev/null +++ b/crates/perry-codegen/src/function/precise_roots.rs @@ -0,0 +1,1099 @@ +//! Precise GC roots lowered onto the native frame (#7173 / #7174). +//! +//! Split out of `function.rs` only because of the 2,000-line cap; this is the +//! statepoint/RS4GC half of the module and nothing else moved with it. The +//! entry points are [`lower_precise_roots_to_native_stack`] and +//! [`retype_landing_pads_for_statepoints`], both called from +//! `LlFunction::serialize`. + +fn parse_shadow_bind(line: &str) -> Option<(usize, String)> { + let rest = line + .trim() + .strip_prefix("call void @js_shadow_slot_bind(i32 ")?; + let (idx, ptr) = rest.split_once(", ptr ")?; + let ptr = ptr.strip_suffix(')')?.trim(); + Some((idx.parse().ok()?, ptr.to_string())) +} + +fn parse_shadow_set(line: &str) -> Option<(usize, String)> { + let rest = line + .trim() + .strip_prefix("call void @js_shadow_slot_set(i32 ")?; + let (idx, value) = rest.split_once(", i64 ")?; + let value = value.strip_suffix(')')?.trim(); + Some((idx.parse().ok()?, value.to_string())) +} + +/// Compute a conservative set of active logical shadow slots before each IR +/// line. Joins use union ("active on any incoming path"), so a stale local can +/// be retained but a live root cannot be omitted. +fn stack_map_active_slots( + lines: &[&str], + slot_count: u32, +) -> Vec>> { + use std::collections::{HashMap, HashSet, VecDeque}; + + #[derive(Debug)] + struct Block { + first_line: usize, + end_line: usize, + successors: Vec, + } + + fn label_name(line: &str) -> Option<&str> { + if line.starts_with(char::is_whitespace) { + return None; + } + line.strip_suffix(':') + .filter(|name| !name.is_empty() && !name.starts_with(';')) + } + + fn referenced_labels(line: &str) -> Vec<&str> { + let mut labels = Vec::new(); + let mut rest = line; + while let Some(pos) = rest.find("label %") { + let after = &rest[pos + "label %".len()..]; + let len = after + .bytes() + .take_while(|byte| { + byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-' | b'$') + }) + .count(); + if len == 0 { + break; + } + labels.push(&after[..len]); + rest = &after[len..]; + } + labels + } + + let labels: Vec<(usize, &str)> = lines + .iter() + .enumerate() + .filter_map(|(idx, line)| label_name(line).map(|name| (idx, name))) + .collect(); + let mut states = vec![None; lines.len()]; + if labels.is_empty() { + return states; + } + + let label_to_block: HashMap<&str, usize> = labels + .iter() + .enumerate() + .map(|(block, (_, name))| (*name, block)) + .collect(); + let mut blocks: Vec = labels + .iter() + .enumerate() + .map(|(block, (label_line, _))| Block { + first_line: label_line + 1, + end_line: labels + .get(block + 1) + .map_or(lines.len(), |(next_line, _)| *next_line), + successors: Vec::new(), + }) + .collect(); + for block in &mut blocks { + let mut seen = HashSet::new(); + for line in &lines[block.first_line..block.end_line] { + for label in referenced_labels(line) { + if let Some(&successor) = label_to_block.get(label) { + if seen.insert(successor) { + block.successors.push(successor); + } + } + } + } + } + + fn apply_root_op(state: &mut HashSet, line: &str, slot_count: u32) { + if let Some((idx, _)) = parse_shadow_bind(line) { + if idx < slot_count as usize { + state.insert(idx); + } + } else if let Some((idx, value)) = parse_shadow_set(line) { + if idx < slot_count as usize { + if value == "0" { + state.remove(&idx); + } else { + state.insert(idx); + } + } + } + } + + let mut entries: Vec>> = vec![None; blocks.len()]; + entries[0] = Some(HashSet::new()); + let mut work = VecDeque::from([0usize]); + while let Some(block_idx) = work.pop_front() { + let Some(mut state) = entries[block_idx].clone() else { + continue; + }; + let block = &blocks[block_idx]; + for line in &lines[block.first_line..block.end_line] { + apply_root_op(&mut state, line, slot_count); + } + for &successor in &block.successors { + let changed = match &mut entries[successor] { + Some(existing) => { + let old_len = existing.len(); + existing.extend(state.iter().copied()); + existing.len() != old_len + } + entry @ None => { + *entry = Some(state.clone()); + true + } + }; + if changed { + work.push_back(successor); + } + } + } + + for (block_idx, block) in blocks.iter().enumerate() { + let Some(mut state) = entries[block_idx].clone() else { + continue; + }; + for (line_idx, line) in lines + .iter() + .enumerate() + .take(block.end_line) + .skip(block.first_line) + { + states[line_idx] = Some(state.clone()); + apply_root_op(&mut state, line, slot_count); + } + } + states +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) enum PreciseRootBackend { + Statepoint, + /// `PERRY_RS4GC=1` (#7174): retype every root alloca to + /// `ptr addrspace(1)` with cast surgery at its load/store sites, tag the + /// function `gc "statepoint-example"`, mark audited non-collecting + /// callees `"gc-leaf-function"` at the call site, and emit NO per-call + /// safepoint machinery — `opt -passes='function(mem2reg), + /// rewrite-statepoints-for-gc'` promotes the allocas to SSA and inserts + /// every statepoint, relocation, and downstream-use rewrite itself. + /// After mem2reg, each former load site is a cast site, which is exactly + /// the placement RS4GC needs to rewrite uses with relocated values. + /// Fail-closed: any use of a root alloca outside the recognized + /// load/store shapes bails the whole function to the Statepoint backend. + Rs4gc, +} + +impl PreciseRootBackend { + fn as_str(self) -> &'static str { + match self { + Self::Statepoint => "statepoint", + Self::Rs4gc => "rs4gc", + } + } +} + +/// RS4GC surgery (#7174): retype root allocas to `ptr addrspace(1)` and cast +/// at every recognized load/store site. Returns `None` when any root alloca +/// appears in an unrecognized shape (the caller falls back to the explicit +/// statepoint backend for the whole function). +fn lower_roots_for_rs4gc(lines: &[&str], root_ptrs: &[String]) -> Option { + let roots: std::collections::HashSet<&str> = root_ptrs.iter().map(String::as_str).collect(); + let mut out = String::with_capacity(lines.len() * 48 + root_ptrs.len() * 96); + let mut cast_counter = 0usize; + + for line in lines { + if parse_shadow_bind(line).is_some() || parse_shadow_set(line).is_some() { + continue; + } + let trimmed = line.trim_start(); + + // Root-alloca definition: retype + null-init (mem2reg needs a + // dominating definition for paths that read before the first bind, + // same reason the i64 zero-init existed). + // Root locals are emitted as `alloca double` (the NaN-box home) or + // occasionally `alloca i64`; both become an addrspace(1) slot. + if let Some(reg) = trimmed + .strip_suffix("= alloca i64") + .or_else(|| trimmed.strip_suffix("= alloca double")) + .map(str::trim_end) + .filter(|reg| roots.contains(reg)) + { + out.push_str(&format!(" {reg} = alloca ptr addrspace(1)\n")); + out.push_str(&format!(" store ptr addrspace(1) null, ptr {reg}\n")); + continue; + } + + let mut handled = false; + for ptr in root_ptrs { + if let Some(rest) = trimmed.strip_prefix("store i64 ") { + if let Some(value) = rest.strip_suffix(&format!(", ptr {ptr}")) { + let value = value.trim(); + if value == "0" { + out.push_str(&format!(" store ptr addrspace(1) null, ptr {ptr}\n")); + } else { + cast_counter += 1; + out.push_str(&format!( + " %rs4gc.s{cast_counter} = inttoptr i64 {value} to ptr addrspace(1)\n store ptr addrspace(1) %rs4gc.s{cast_counter}, ptr {ptr}\n" + )); + } + handled = true; + break; + } + } + if let Some(rest) = trimmed.strip_prefix("store double ") { + if let Some(value) = rest.strip_suffix(&format!(", ptr {ptr}")) { + let value = value.trim(); + cast_counter += 1; + out.push_str(&format!( + " %rs4gc.b{cast_counter} = bitcast double {value} to i64\n %rs4gc.s{cast_counter} = inttoptr i64 %rs4gc.b{cast_counter} to ptr addrspace(1)\n store ptr addrspace(1) %rs4gc.s{cast_counter}, ptr {ptr}\n" + )); + handled = true; + break; + } + } + if trimmed + == format!( + "{} = load i64, ptr {ptr}", + trimmed.split(' ').next().unwrap_or("") + ) + { + let result = trimmed.split(' ').next().unwrap_or(""); + out.push_str(&format!( + " {result}.rs4p = load ptr addrspace(1), ptr {ptr}\n {result} = ptrtoint ptr addrspace(1) {result}.rs4p to i64\n" + )); + handled = true; + break; + } + if trimmed + == format!( + "{} = load double, ptr {ptr}", + trimmed.split(' ').next().unwrap_or("") + ) + { + let result = trimmed.split(' ').next().unwrap_or(""); + out.push_str(&format!( + " {result}.rs4p = load ptr addrspace(1), ptr {ptr}\n {result}.rs4i = ptrtoint ptr addrspace(1) {result}.rs4p to i64\n {result} = bitcast i64 {result}.rs4i to double\n" + )); + handled = true; + break; + } + } + if handled { + continue; + } + + // Fail closed: any other appearance of a root alloca name. + if root_ptrs.iter().any(|ptr| { + line.contains(ptr.as_str()) + && line + .split(|c: char| !(c.is_alphanumeric() || c == '%' || c == '_' || c == '.')) + .any(|tok| tok == ptr) + }) { + return None; + } + + // Audited non-collecting callees become RS4GC leaf calls: the pass + // will not treat them as safepoints, transferring the call-effect + // table wholesale. AllocNoReentry keeps its contract gating. + let is_call = trimmed.starts_with("call ") + || trimmed.contains(" = call ") + || trimmed.starts_with("tail call ") + || trimmed.contains(" = tail call "); + // Inline asm must be marked leaf explicitly: RS4GC otherwise rewrites + // it into a statepoint whose callee is the asm value, which the + // verifier rejects outright ("Cannot take the address of an inline + // asm!"). Found on the Claude Code bundle, where other codegen paths + // emit zero-instruction asm barriers. + if is_call && trimmed.ends_with(')') && trimmed.contains(" asm ") { + out.push_str(line.trim_end()); + out.push_str(" \"gc-leaf-function\"\n"); + continue; + } + if is_call && trimmed.ends_with(')') && !trimmed.contains(" asm ") { + if let Some(callee) = direct_callee_name(line) { + let leaf = match crate::gc_call_effects::classify_direct_callee(callee) { + crate::gc_call_effects::GcCallEffect::CannotCollect + | crate::gc_call_effects::GcCallEffect::NeverReturns => true, + crate::gc_call_effects::GcCallEffect::AllocNoReentry => { + crate::codegen::helpers::gc_safepoint_only_contract_enabled() + } + crate::gc_call_effects::GcCallEffect::Unknown => false, + }; + if leaf && !callee.starts_with("llvm.") { + out.push_str(line.trim_end()); + out.push_str(" \"gc-leaf-function\"\n"); + continue; + } + } + } + + out.push_str(line); + out.push('\n'); + } + Some(out) +} + +#[derive(Debug, Eq, PartialEq)] +struct DirectCall<'a> { + result: Option<&'a str>, + return_type: &'a str, + callee: &'a str, + args: Vec<&'a str>, + arg_types: Vec<&'a str>, +} + +fn split_call_args(args: &str) -> Option> { + if args.trim().is_empty() { + return Some(Vec::new()); + } + let mut out = Vec::new(); + let mut depth = 0i32; + let mut start = 0usize; + for (idx, ch) in args.char_indices() { + match ch { + '(' | '[' | '{' | '<' => depth += 1, + ')' | ']' | '}' | '>' => { + depth -= 1; + if depth < 0 { + return None; + } + } + ',' if depth == 0 => { + out.push(args[start..idx].trim()); + start = idx + 1; + } + _ => {} + } + } + if depth != 0 { + return None; + } + out.push(args[start..].trim()); + Some(out) +} + +fn statepoint_scalar_type(arg: &str) -> Option<&str> { + let ty = arg.split_ascii_whitespace().next()?; + matches!( + ty, + "i1" | "i8" | "i16" | "i32" | "i64" | "i128" | "float" | "double" | "ptr" + ) + .then_some(ty) +} + +/// Parse the deliberately small direct-call subset emitted by `LlBlock`. +/// +/// Calls with tail markers, operand attributes, aggregate types, inline asm, +/// indirect targets, or call-site suffixes stay on the plain stack-map +/// fallback. That keeps the research mode correct while making its explicit +/// statepoint coverage measurable and easy to expand. +fn parse_direct_statepoint_call(line: &str) -> Option> { + let trimmed = line.trim(); + let (result, call) = if let Some(call) = trimmed.strip_prefix("call ") { + (None, call) + } else { + let (result, call) = trimmed.split_once(" = call ")?; + (Some(result.trim()), call) + }; + let (return_type, target_and_args) = call.split_once(' ')?; + if !matches!( + return_type, + "void" | "i1" | "i8" | "i16" | "i32" | "i64" | "i128" | "float" | "double" | "ptr" + ) { + return None; + } + if return_type != "void" && result.is_none() { + return None; + } + let open = target_and_args.find('(')?; + let close = target_and_args.rfind(')')?; + if close + 1 != target_and_args.len() { + return None; + } + let callee = target_and_args[..open].trim(); + // Indirect targets are statepoint-able: `gc.statepoint` takes the callee as + // a `ptr` operand, and `emit_statepoint` interpolates it verbatim, so + // `ptr elementtype(T) %fnptr` is as valid as `... @callee`. Rejecting them + // was a limitation of this textual parser, not of statepoints — and the + // fallback it forced is the unsound plain stack map. An unknown callee + // simply cannot be audited as non-collecting, which is the conservative + // (correct) answer anyway. + let direct = callee.starts_with('@'); + let indirect = callee.starts_with('%'); + if !(direct || indirect) + || callee.starts_with("@llvm.") + || matches!(callee, "@setjmp" | "@_setjmp" | "@longjmp" | "@_longjmp") + { + return None; + } + let args = split_call_args(&target_and_args[open + 1..close])?; + let arg_types = args + .iter() + .map(|arg| statepoint_scalar_type(arg)) + .collect::>>()?; + Some(DirectCall { + result, + return_type, + callee, + args, + arg_types, + }) +} + +/// Return a direct callee name without the leading `@`. +/// +/// This accepts more call syntax than the statepoint parser because the +/// GC-effect audit only needs to recognize a direct target. Unsupported and +/// indirect forms return `None` and therefore stay conservative. +fn direct_callee_name(line: &str) -> Option<&str> { + let call = line.trim().split_once("call ")?.1; + let args_open = call.find('(')?; + let target = call[..args_open].trim(); + let name = target.split_ascii_whitespace().last()?.strip_prefix('@')?; + (!name.is_empty() + && name + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '.' | '$'))) + .then_some(name) +} + +fn gc_result_suffix(ty: &str) -> Option<&'static str> { + match ty { + "i1" => Some("i1"), + "i8" => Some("i8"), + "i16" => Some("i16"), + "i32" => Some("i32"), + "i64" => Some("i64"), + "i128" => Some("i128"), + "float" => Some("f32"), + "double" => Some("f64"), + "ptr" => Some("p0"), + _ => None, + } +} + +/// Emit one explicit statepoint relocation sequence. +/// +/// Perry roots remain ordinary NaN-boxed `i64` values everywhere else. At +/// this boundary we load each live word, carry its exact bits through a +/// temporary addrspace(1) pointer, and convert the `gc.relocate` result back +/// into the existing slot. LLVM therefore owns the spill/reload and the +/// post-safepoint SSA transition without requiring a whole-program +/// representation change for this prototype. +fn emit_statepoint(out: &mut String, call: &DirectCall<'_>, live: &[&String], statepoint_id: u64) { + for (root_idx, ptr) in live.iter().enumerate() { + out.push_str(&format!( + " %perry_sp_bits_{statepoint_id}_{root_idx} = load i64, ptr {ptr}\n" + )); + out.push_str(&format!( + " %perry_sp_root_{statepoint_id}_{root_idx} = inttoptr i64 \ + %perry_sp_bits_{statepoint_id}_{root_idx} to ptr addrspace(1)\n" + )); + } + + let function_type = format!("{} ({})", call.return_type, call.arg_types.join(", ")); + let call_args = call + .args + .iter() + .map(|arg| format!(", {arg}")) + .collect::(); + let gc_live = live + .iter() + .enumerate() + .map(|(root_idx, _)| format!("ptr addrspace(1) %perry_sp_root_{statepoint_id}_{root_idx}")) + .collect::>() + .join(", "); + out.push_str(&format!( + " %perry_sp_token_{statepoint_id} = call token (i64, i32, ptr, i32, i32, ...) \ + @llvm.experimental.gc.statepoint.p0(i64 {statepoint_id}, i32 0, \ + ptr elementtype({function_type}) {}, i32 {}, i32 0{call_args}, i32 0, i32 0) \ + [\"gc-live\"({gc_live})]\n", + call.callee, + call.args.len() + )); + + if let Some(result) = call.result { + let suffix = gc_result_suffix(call.return_type) + .expect("non-void statepoint return type was validated by the parser"); + out.push_str(&format!( + " {result} = call {} @llvm.experimental.gc.result.{suffix}(token \ + %perry_sp_token_{statepoint_id})\n", + call.return_type + )); + } + + for (root_idx, ptr) in live.iter().enumerate() { + out.push_str(&format!( + " %perry_sp_relocated_{statepoint_id}_{root_idx} = call ptr addrspace(1) \ + @llvm.experimental.gc.relocate.p1(token %perry_sp_token_{statepoint_id}, \ + i32 {root_idx}, i32 {root_idx})\n" + )); + out.push_str(&format!( + " %perry_sp_relocated_bits_{statepoint_id}_{root_idx} = ptrtoint \ + ptr addrspace(1) %perry_sp_relocated_{statepoint_id}_{root_idx} to i64\n" + )); + out.push_str(&format!( + " store i64 %perry_sp_relocated_bits_{statepoint_id}_{root_idx}, ptr {ptr}\n" + )); + } +} + +/// Lower Perry's existing precise-root operations to native-stack metadata. +/// +/// The old binding calls already name exactly the mutable native alloca that a +/// moving collection must rewrite. We use them as compile-time markers: +/// +/// * collect `logical slot -> native alloca`; +/// * remove the runtime bind calls and shadow-frame traffic; +/// * compute conservative per-call liveness from bind/clear markers without +/// mutating the native slot; +/// * either place a plain stack map before a call, or replace a supported call +/// with a statepoint/result/relocate sequence. +/// +/// Statepoint mode deliberately retains a plain-stack-map fallback for call +/// forms outside the narrow parser above. The fallback preserves correctness +/// while the report records how much of real Perry code reaches the explicit +/// relocation path. +pub(super) fn lower_precise_roots_to_native_stack( + ir: &str, + function_name: &str, + slot_count: u32, + backend: PreciseRootBackend, +) -> String { + let lines: Vec<&str> = ir.lines().collect(); + let active_slots = stack_map_active_slots(&lines, slot_count); + let mut roots: Vec> = vec![None; slot_count as usize]; + for line in &lines { + if let Some((idx, ptr)) = parse_shadow_bind(line) { + if let Some(root) = roots.get_mut(idx) { + match root { + Some(existing) => { + debug_assert_eq!( + existing, &ptr, + "one precise-root slot must not bind two native allocas" + ); + } + None => *root = Some(ptr), + } + } + } + } + + let slot_roots = roots; + let root_ptrs: Vec = slot_roots.iter().flatten().cloned().collect(); + let mut report = crate::statepoint_report::enabled().then(|| { + crate::statepoint_report::FunctionRecord::new( + function_name, + backend.as_str(), + slot_count, + root_ptrs.len(), + ) + }); + // RS4GC runs BEFORE the empty-roots early return on purpose: a function + // can reserve slots (so it carries `gc "statepoint-example"`) yet bind + // none, and it still contains inline asm that RS4GC would rewrite into an + // invalid statepoint. Found on the Claude Code bundle, where the early + // return skipped leaf-marking and the verifier aborted with "Cannot take + // the address of an inline asm!". + if backend == PreciseRootBackend::Rs4gc { + if let Some(out) = lower_roots_for_rs4gc(&lines, &root_ptrs) { + if let Some(mut report) = report { + report.note_call(root_ptrs.len()); + crate::statepoint_report::record(report); + } + return out; + } + return lower_precise_roots_to_native_stack( + ir, + function_name, + slot_count, + PreciseRootBackend::Statepoint, + ); + } + + if root_ptrs.is_empty() { + let out = ir + .lines() + .filter(|line| parse_shadow_bind(line).is_none() && parse_shadow_set(line).is_none()) + .map(|line| format!("{line}\n")) + .collect(); + if let Some(report) = report { + crate::statepoint_report::record(report); + } + return out; + } + + let mut out = String::with_capacity(ir.len() + root_ptrs.len() * 128); + let mut available = std::collections::HashSet::::new(); + let mut initialized = std::collections::HashSet::::new(); + let mut map_id = 0u64; + + for (line_idx, line) in lines.iter().enumerate() { + if parse_shadow_bind(line).is_some() { + // Compile-time marker only. The real slot is already populated by + // the local store immediately preceding this old bind. + continue; + } + if parse_shadow_set(line).is_some() { + // This marker changes stack-map liveness, not the program local. + // Shadow-stack clears only flipped SLOT_ACTIVE for the same + // reason: a value can be semantically read after its final + // GC-capable call. + continue; + } + + // A stack-map operand must dominate the intrinsic. Root allocas are + // normally entry-hoisted, but tracking definitions here also handles + // the few block-local scalar-replacement slots without emitting + // invalid SSA. + for ptr in &root_ptrs { + if line.trim_start().starts_with(&format!("{ptr} = ")) { + available.insert(ptr.clone()); + } + } + + out.push_str(line); + out.push('\n'); + + // Slots can be named by a stack map before their source-level `let` + // executes. Zero them directly after the alloca so an earlier + // safepoint never exposes uninitialized stack bytes as roots. + for ptr in &root_ptrs { + if available.contains(ptr) + && !initialized.contains(ptr) + && line.trim_start().starts_with(&format!("{ptr} = alloca ")) + { + out.push_str(&format!(" store i64 0, ptr {ptr}\n")); + initialized.insert(ptr.clone()); + } + } + + // Insert before calls, not after. Rebuild the tail when the line just + // appended is a call so the intrinsic's instruction offset is the + // actual call-site offset in the final machine function. + let trimmed = line.trim_start(); + let is_call = trimmed.starts_with("call ") + || trimmed.contains(" = call ") + || trimmed.starts_with("tail call ") + || trimmed.contains(" = tail call "); + if !is_call || trimmed.contains("@llvm.experimental.stackmap") { + continue; + } + let active = active_slots.get(line_idx).and_then(Option::as_ref); + let live: Vec<&String> = slot_roots + .iter() + .enumerate() + .filter(|(idx, _)| active.is_some_and(|slots| slots.contains(idx))) + .filter_map(|(_, ptr)| ptr.as_ref()) + .filter(|ptr| available.contains(*ptr) && initialized.contains(*ptr)) + .collect(); + if let Some(report) = report.as_mut() { + report.note_call(live.len()); + } + if live.is_empty() { + continue; + } + + let direct_callee = direct_callee_name(line); + let is_compiler_only = direct_callee.is_some_and(|callee| callee.starts_with("llvm.")) + || trimmed.contains("call void asm "); + let cannot_collect = direct_callee.is_some_and(|callee| { + match crate::gc_call_effects::classify_direct_callee(callee) { + crate::gc_call_effects::GcCallEffect::CannotCollect => true, + // Control never returns here: no relocation is consumed and + // the frame's roots are dead past the call. Deeper frames + // carry their own records. + crate::gc_call_effects::GcCallEffect::NeverReturns => true, + // Under the explicit-safepoint contract the runtime + // guarantees these helpers' triggers never consume this + // frame's precise roots (they defer to a declared safepoint + // or collect behind a forced conservative scan), so the + // call site needs no metadata. Without the contract they + // stay safepoints. + crate::gc_call_effects::GcCallEffect::AllocNoReentry => { + crate::codegen::helpers::gc_safepoint_only_contract_enabled() + } + crate::gc_call_effects::GcCallEffect::Unknown => false, + } + }); + if is_compiler_only || cannot_collect { + // LLVM intrinsics, zero-instruction compiler barriers, and + // runtime helpers in the audited GC-effect table cannot enter + // Perry's allocator. Neither native-stack backend needs metadata + // around them. + if let Some(report) = report.as_mut() { + report.note_skipped(direct_callee.unwrap_or("")); + } + continue; + } + + // Move the call line behind the intrinsic. + let call_len = line.len() + 1; + out.truncate(out.len() - call_len); + if backend == PreciseRootBackend::Statepoint { + if let Some(call) = parse_direct_statepoint_call(line) { + emit_statepoint(&mut out, &call, &live, map_id); + if let Some(report) = report.as_mut() { + report.note_statepoint(call.callee.trim_start_matches('@'), live.len()); + } + map_id += 1; + continue; + } + } + // No statepoint could be formed for a call that has live roots. The + // old behaviour was to fall back to a plain `llvm.experimental.stackmap`, + // which is UNSOUND: LLVM may record a root slot's address as + // `Register R#N`, caller-saved and unrecoverable at collection time, + // so the collector silently misses that root. + // + // Measured on test-drizzle-pg (133 modules): 23,301 safepoints, ALL + // statepoints, 0 plain stack maps, 0 parser fallbacks. The path is not + // taken by real code, so failing closed costs nothing and removes the + // last way this backend can lose a root. A loud compile failure beats + // silent heap corruption. + panic!( + "perry: native-root lowering could not express a safepoint for \ + `{}` in @{} ({} live roots). Falling back to a plain stack map \ + here would record roots in caller-saved registers that the \ + collector cannot recover, so the compile stops instead. Report \ + this call shape on #7174.", + direct_callee.unwrap_or(""), + function_name, + live.len(), + ); + } + if let Some(report) = report { + crate::statepoint_report::record(report); + } + out +} + +/// Retype Itanium landing pads to `token` for `statepoint-example`. +/// +/// RS4GC uses the unwind destination's landing pad **as the token** for the +/// relocates it inserts on the exceptional edge, so the pad must already be +/// `landingpad token`. Given `{ ptr, i32 }` it emits +/// `gc.relocate({ ptr, i32 } %lpad, ...)` and the verifier rejects the module, +/// which is why a try-carrying function failed to compile under RS4GC at all. +/// +/// This is only sound because the pad's value is **dead**: `try_stmt` emits it +/// to anchor the edge and branches straight on, taking the exception from the +/// runtime rather than the pad payload. So a pad whose register IS referenced +/// is left alone — retyping a value someone reads would swap a silent +/// miscompile for the loud one this fixes. +pub(super) fn retype_landing_pads_for_statepoints(ir: &str) -> String { + const ITANIUM: &str = "landingpad { ptr, i32 } catch ptr null"; + if !ir.contains(ITANIUM) { + return ir.to_string(); + } + let mut out = String::with_capacity(ir.len()); + for line in ir.lines() { + let rewritten = match line.split_once(" = ") { + Some((reg, rest)) if rest.trim() == ITANIUM => { + let reg = reg.trim(); + // Referenced anywhere else? Then its payload is live. + let used = ir.lines().any(|other| { + !std::ptr::eq(other.as_ptr(), line.as_ptr()) && mentions_register(other, reg) + }); + if used { + None + } else { + Some(format!("{} = landingpad token cleanup", reg)) + } + } + _ => None, + }; + match rewritten { + Some(r) => out.push_str(&r), + None => out.push_str(line), + } + out.push('\n'); + } + out +} + +/// Whether `line` mentions SSA register `reg` as a whole token rather than as +/// a prefix of a longer name (`%r2` must not match `%r21`). +fn mentions_register(line: &str, reg: &str) -> bool { + let mut from = 0; + while let Some(idx) = line[from..].find(reg) { + let at = from + idx; + let after = line[at + reg.len()..].chars().next(); + if !matches!(after, Some(c) if c.is_ascii_alphanumeric() || c == '_' || c == '.') { + return true; + } + from = at + reg.len(); + } + false +} + +#[cfg(test)] +mod stack_map_tests { + + #[test] + fn retypes_dead_landing_pads_for_rs4gc() { + let ir = "define void @probe() {\n\ + entry:\n\ + %lp = landingpad { ptr, i32 } catch ptr null\n\ + br label %next\n\ + }\n"; + let out = super::retype_landing_pads_for_statepoints(ir); + assert!(out.contains("%lp = landingpad token cleanup"), "{out}"); + } + + #[test] + fn leaves_a_used_landing_pad_alone() { + // If the pad's payload is read, retyping it to `token` would break the + // consumer silently. Fail closed: RS4GC's loud verifier error is the + // better outcome. + let ir = "define void @probe() {\n\ + entry:\n\ + %lp = landingpad { ptr, i32 } catch ptr null\n\ + %exn = extractvalue { ptr, i32 } %lp, 0\n\ + br label %next\n\ + }\n"; + let out = super::retype_landing_pads_for_statepoints(ir); + assert!( + out.contains("%lp = landingpad { ptr, i32 } catch ptr null"), + "{out}" + ); + } + + #[test] + fn register_match_is_whole_token() { + // `%r2` must not be considered used by a mention of `%r21`. + assert!(super::mentions_register(" br label %r2", "%r2")); + assert!(!super::mentions_register(" %x = add i64 %r21, 1", "%r2")); + } + use super::{ + direct_callee_name, lower_precise_roots_to_native_stack, parse_direct_statepoint_call, + PreciseRootBackend, + }; + + fn lower_statepoints(input: &str, slots: u32) -> String { + lower_precise_roots_to_native_stack(input, "probe", slots, PreciseRootBackend::Statepoint) + } + + #[test] + fn lowers_bind_and_liveness_clear_to_native_frame_maps() { + let input = r#"define i64 @probe(i64 %arg) { +entry.0: + %r0 = alloca i64 + store i64 %arg, ptr %r0 + call void @js_shadow_slot_bind(i32 0, ptr %r0) + %r1 = call i64 @may_collect() + call void @js_shadow_slot_set(i32 0, i64 0) + call void @may_collect_again() + ret i64 %r1 +} +"#; + let output = lower_statepoints(input, 1); + assert!(!output.contains("@js_shadow_slot_bind")); + assert!(!output.contains("@js_shadow_slot_set")); + assert!(output.contains("%r0 = alloca i64\n store i64 0, ptr %r0")); + assert!( + output.contains("@llvm.experimental.gc.statepoint.p0"), + "the collecting call must become a statepoint:\n{output}" + ); + assert!( + output.contains("%r0"), + "the root slot must appear in the statepoint's live list:\n{output}" + ); + assert_eq!(output.matches("store i64 0, ptr %r0").count(), 1); + assert_eq!( + output + .matches("@llvm.experimental.gc.statepoint.p0") + .count(), + 1 + ); + assert!(output.contains("call void @may_collect_again()")); + } + + #[test] + fn does_not_reference_a_root_before_its_alloca_dominates() { + let input = r#"define void @probe() { +entry.0: + call void @early_call() + %r0 = alloca i64 + call void @js_shadow_slot_bind(i32 0, ptr %r0) + call void @late_call() + ret void +} +"#; + let output = lower_statepoints(input, 1); + let early = output.find("call void @early_call()").unwrap(); + let first_map = output.find("@llvm.experimental.gc.statepoint.p0").unwrap(); + assert!( + early < first_map, + "no safepoint may reference a root before its alloca dominates:\n{output}" + ); + assert!( + output.contains("@late_call"), + "the dominated call must still be mapped:\n{output}" + ); + } + + #[test] + fn unions_root_liveness_at_control_flow_joins() { + let input = r#"define void @probe(i1 %cond) { +entry.0: + %r0 = alloca i64 + call void @js_shadow_slot_bind(i32 0, ptr %r0) + br i1 %cond, label %live.1, label %dead.2 +live.1: + call void @live_call() + br label %merge.3 +dead.2: + call void @js_shadow_slot_set(i32 0, i64 0) + call void @dead_call() + br label %merge.3 +merge.3: + call void @merge_call() + ret void +} +"#; + let output = lower_statepoints(input, 1); + assert!(output.contains("@llvm.experimental.gc.statepoint.p0")); + assert!(!output.contains("@dead_call, ptr %r0")); + assert!(output.contains("@merge_call")); + } + + #[test] + fn parses_the_scalar_direct_call_subset() { + assert_eq!( + direct_callee_name(" %r7 = call double @foo(i64 %r1, ptr %r2)"), + Some("foo") + ); + assert_eq!( + direct_callee_name(" %r7 = call i64 ()* %fn()"), + None, + "an indirect target must not be inferred from its arguments" + ); + assert_eq!( + parse_direct_statepoint_call(" %r7 = call double @foo(i64 %r1, ptr %r2)"), + Some(super::DirectCall { + result: Some("%r7"), + return_type: "double", + callee: "@foo", + args: vec!["i64 %r1", "ptr %r2"], + arg_types: vec!["i64", "ptr"], + }) + ); + assert!(parse_direct_statepoint_call( + " %r7 = call double (i64, ptr)* %fn(i64 %r1, ptr %r2)" + ) + .is_none()); + assert!(parse_direct_statepoint_call(" call void @llvm.assume(i1 %ok)").is_none()); + assert!(parse_direct_statepoint_call(" %r7 = tail call i64 @foo()").is_none()); + } + + #[test] + fn lowers_direct_calls_to_explicit_statepoint_relocations() { + let input = r#"define i64 @probe(i64 %arg) { +entry.0: + %r0 = alloca i64 + store i64 %arg, ptr %r0 + call void @js_shadow_slot_bind(i32 0, ptr %r0) + %r1 = call i64 @may_collect(i64 %arg) + ret i64 %r1 +} +"#; + let output = lower_statepoints(input, 1); + assert!(!output.contains("call i64 @may_collect")); + assert!(!output.contains("asm sideeffect")); + assert!(output + .contains("%perry_sp_root_0_0 = inttoptr i64 %perry_sp_bits_0_0 to ptr addrspace(1)")); + assert!(output.contains( + "ptr elementtype(i64 (i64)) @may_collect, i32 1, i32 0, i64 %arg, i32 0, i32 0" + )); + assert!(output + .contains("%r1 = call i64 @llvm.experimental.gc.result.i64(token %perry_sp_token_0)")); + assert!(output + .contains("@llvm.experimental.gc.relocate.p1(token %perry_sp_token_0, i32 0, i32 0)")); + assert!(output.contains("store i64 %perry_sp_relocated_bits_0_0, ptr %r0")); + } + + #[test] + fn statepoint_mode_maps_indirect_calls() { + // An indirect call used to fall back to a plain stack map, which is the + // unsound lowering: LLVM may record the root's address in a + // caller-saved register. `gc.statepoint` takes its callee as a `ptr` + // operand, so an indirect target is expressible — the restriction was + // in this textual parser, not in statepoints. + let input = r#"define i64 @probe(i64 %arg, ptr %fn) { +entry.0: + %r0 = alloca i64 + store i64 %arg, ptr %r0 + call void @js_shadow_slot_bind(i32 0, ptr %r0) + %r1 = call i64 %fn() + ret i64 %r1 +} +"#; + let output = lower_statepoints(input, 1); + assert!( + output.contains("@llvm.experimental.gc.statepoint.p0"), + "an indirect call with live roots must become a statepoint:\n{output}" + ); + assert!( + output.contains("%fn"), + "the indirect target must survive as the statepoint callee:\n{output}" + ); + assert!( + !output.contains("@llvm.experimental.stackmap"), + "no plain (unsound) stack map may remain:\n{output}" + ); + } + + #[test] + fn statepoint_mode_does_not_map_non_allocating_llvm_intrinsics() { + let input = r#"define void @probe(i64 %arg, i1 %condition) { +entry.0: + %r0 = alloca i64 + store i64 %arg, ptr %r0 + call void @js_shadow_slot_bind(i32 0, ptr %r0) + call void @llvm.assume(i1 %condition) + call void @may_collect() + ret void +} +"#; + let output = lower_statepoints(input, 1); + assert!(output.contains("call void @llvm.assume(i1 %condition)")); + assert_eq!( + output + .matches("@llvm.experimental.gc.statepoint.p0") + .count(), + 1 + ); + assert!(!output.contains("@llvm.experimental.stackmap")); + } + + #[test] + fn audited_non_collecting_helpers_are_not_safepoints_in_either_backend() { + let input = r#"define void @probe(i64 %arg) { +entry.0: + %r0 = alloca i64 + store i64 %arg, ptr %r0 + call void @js_shadow_slot_bind(i32 0, ptr %r0) + call void @js_gc_temp_root_push(i64 %arg) + call void @js_write_barrier_root_nanbox(i64 %arg) + call void @js_gc_loop_safepoint() + ret void +} +"#; + for output in [lower_statepoints(input, 1)] { + assert!(output.contains("call void @js_gc_temp_root_push(i64 %arg)")); + assert!(output.contains("call void @js_write_barrier_root_nanbox(i64 %arg)")); + assert_eq!( + output.matches("@llvm.experimental.stackmap").count() + + output + .matches("@llvm.experimental.gc.statepoint.p0") + .count(), + 1, + "only the explicit collection boundary should be a safepoint:\n{output}" + ); + } + } +} diff --git a/crates/perry-codegen/src/linker.rs b/crates/perry-codegen/src/linker.rs index ab42708e4f..a6355c744d 100644 --- a/crates/perry-codegen/src/linker.rs +++ b/crates/perry-codegen/src/linker.rs @@ -1603,476 +1603,12 @@ pub fn bitcode_link_pipeline( Ok(linked_obj) } +/// Clang discovery, version preflight, compile-plan shaping, and temp-path +/// naming. A sibling file only because of the 2,000-line cap; `use super::*` +/// gives it the same view of this module as the block above. #[cfg(test)] -mod tests { - use super::*; - - fn version_block(target_line: &str) -> String { - format!("clang version 18.0.0\n{}\nThread model: posix", target_line) - } - - #[test] - fn parses_common_clang_version_banners() { - assert_eq!( - parse_clang_major_version("Ubuntu clang version 14.0.0-1ubuntu1.1"), - Some(14) - ); - assert_eq!( - parse_clang_major_version("Apple clang version 17.0.0 (clang-1700.0.13.5)"), - Some(17) - ); - assert_eq!( - parse_clang_major_version("Debian clang version 18.1.8\nTarget: x86_64-linux-gnu"), - Some(18) - ); - assert_eq!(parse_clang_major_version("not a clang banner"), None); - } - - #[test] - fn version_banner_on_stderr_wins_over_stdout_wrapper_noise() { - let selected = select_clang_version_output( - "wrapper: selecting system toolchain", - "Ubuntu clang version 14.0.0-1ubuntu1.1", - ); - assert_eq!( - selected.as_deref(), - Some("Ubuntu clang version 14.0.0-1ubuntu1.1") - ); - assert_eq!( - select_clang_version_output("clang version 18.1.8", "warning").as_deref(), - Some("clang version 18.1.8") - ); - } - - #[test] - fn prefers_supported_versioned_clang_over_old_path_default() { - let candidates = vec![ - PathBuf::from("/usr/bin/clang"), - PathBuf::from("/usr/bin/clang-18"), - PathBuf::from("/usr/bin/clang-15"), - ]; - let selected = - select_clang_candidate_with(candidates, |path| match path.file_name()?.to_str()? { - "clang" => Some(14), - "clang-18" => Some(18), - "clang-15" => Some(15), - _ => None, - }); - assert_eq!(selected, Some(PathBuf::from("/usr/bin/clang-18"))); - } - - #[test] - fn retains_first_candidate_to_report_an_old_only_install() { - let candidates = vec![ - PathBuf::from("/usr/bin/clang"), - PathBuf::from("/usr/bin/clang-14"), - ]; - let selected = select_clang_candidate_with(candidates, |_| Some(14)); - assert_eq!(selected, Some(PathBuf::from("/usr/bin/clang"))); - } - - #[test] - fn old_clang_preflight_explains_the_opaque_pointer_requirement() { - let error = ensure_supported_clang_major(Path::new("/usr/bin/clang"), Some(14)) - .expect_err("clang 14 must be rejected"); - let message = error.to_string(); - assert!(message.contains("too old (14 < 15)")); - assert!(message.contains("opaque-pointer LLVM IR")); - assert!(message.contains("PERRY_LLVM_CLANG")); - assert!(ensure_supported_clang_major(Path::new("/usr/bin/clang-15"), Some(15)).is_ok()); - assert!(ensure_supported_clang_major(Path::new("/toolchain-wrapper"), None).is_ok()); - } - - #[test] - fn hint_for_mingw_clang_on_windows_targets_msvc() { - // Only the host-is-windows arm fires this hint. The build matrix runs - // these tests on every host, so we gate the assertion on cfg(windows). - // On non-Windows hosts the function falls through to the generic - // PERRY_LLVM_CLANG suggestion — also asserted below. - let v = version_block("Target: x86_64-w64-windows-gnu"); - let hint = build_clang_failure_hint( - "lld-link: error: undefined symbol: __main", - &v, - "x86_64-pc-windows-msvc", - ); - if cfg!(target_os = "windows") { - assert!( - hint.contains("MinGW/GNU"), - "expected MinGW hint, got: {}", - hint - ); - assert!(hint.contains("winget install LLVM.LLVM")); - assert!(hint.contains("PERRY_LLVM_CLANG")); - } else { - assert!(hint.contains("PERRY_LLVM_CLANG")); - } - } - - #[test] - fn hint_for_override_module_target_triple_warning() { - let v = version_block("Target: x86_64-pc-linux-gnu"); - let hint = build_clang_failure_hint( - "warning: overriding the module target triple with x86_64-pc-linux-gnu", - &v, - "x86_64-unknown-linux-gnu", - ); - // On non-Windows hosts the override-warning branch should win. - if !cfg!(target_os = "windows") { - assert!( - hint.contains("overriding the module target triple"), - "expected override hint, got: {}", - hint - ); - } - } - - #[test] - fn hint_for_missing_library_message() { - let v = version_block("Target: aarch64-apple-darwin23.0.0"); - let hint = build_clang_failure_hint( - "ld: library not found for -lSystem", - &v, - "arm64-apple-macosx15.0.0", - ); - assert!( - hint.contains("library") || hint.contains("PERRY_LLVM_CLANG"), - "got: {}", - hint - ); - } - - #[test] - fn hint_falls_back_when_no_pattern_matches() { - let v = version_block("Target: aarch64-apple-darwin23.0.0"); - let hint = build_clang_failure_hint( - "(some unrelated clang stderr)", - &v, - "arm64-apple-macosx15.0.0", - ); - assert!( - hint.contains("PERRY_LLVM_CLANG"), - "fallback hint should mention PERRY_LLVM_CLANG; got: {}", - hint - ); - assert!(hint.contains("arm64-apple-macosx15.0.0")); - } - - #[test] - fn compile_plan_records_effective_target_and_native_tuning() { - let plan = build_clang_compile_plan( - PathBuf::from("clang"), - PathBuf::from("/tmp/input.ll"), - PathBuf::from("/tmp/output.o"), - None, - 0, - 0, - false, - ); - assert!(plan.clang_args.contains(&"-fno-math-errno".to_string())); - // Small module → optimized at -O3 (#4880). - assert!(plan.clang_args.contains(&"-O3".to_string())); - assert!(plan.clang_args.contains(&"-target".to_string())); - assert!(plan.analysis_clang_args.contains(&"-target".to_string())); - assert_eq!( - plan.native_tuning_arg.as_deref(), - Some(native_tuning_arg_for_host()) - ); - assert!(!plan.effective_target.is_empty()); - } - - #[test] - fn compile_plan_size_optimizes_oversized_many_function_module() { - // An oversized unit made of many ordinary functions (a large minified - // bundle: low bytes-per-function) size-optimizes at -Os — far less - // __text than -O0 — rather than dropping to the speed pipeline or -O0. - let huge = ll_o0_threshold_bytes() + 1; - let many_funcs = huge / 1024; // ~1 KB/fn, well under the density cap - let plan = build_clang_compile_plan( - PathBuf::from("clang"), - PathBuf::from("/tmp/input.ll"), - PathBuf::from("/tmp/output.o"), - None, - huge, - many_funcs, - false, - ); - assert!(plan.clang_args.contains(&"-Os".to_string())); - assert!(!plan.clang_args.contains(&"-O3".to_string())); - assert!(!plan.clang_args.contains(&"-O0".to_string())); - } - - #[test] - fn compile_plan_keeps_o0_for_oversized_giant_function_monolith() { - // #4880: an oversized unit dominated by a few giant generated functions - // (a multi-thousand-element data literal: megabytes-per-function) keeps - // -O0, the only opt level whose pipeline finishes in practical time. - let huge = ll_o0_threshold_bytes() + 1; - let plan = build_clang_compile_plan( - PathBuf::from("clang"), - PathBuf::from("/tmp/input.ll"), - PathBuf::from("/tmp/output.o"), - None, - huge, - 2, // ~3 MB/fn — far above the density cap - false, - ); - assert!(plan.clang_args.contains(&"-O0".to_string())); - assert!(!plan.clang_args.contains(&"-O3".to_string())); - assert!(!plan.clang_args.contains(&"-Os".to_string())); - } - - #[test] - fn compile_plan_skips_native_tuning_for_explicit_target() { - let plan = build_clang_compile_plan( - PathBuf::from("clang"), - PathBuf::from("/tmp/input.ll"), - PathBuf::from("/tmp/output.o"), - Some("x86_64-unknown-linux-gnu"), - 0, - 0, - false, - ); - assert_eq!(plan.effective_target, "x86_64-unknown-linux-gnu"); - assert_eq!(plan.native_tuning_arg, None); - assert!(!plan - .clang_args - .iter() - .any(|arg| arg == "-march=native" || arg == "-mcpu=native")); - } - - #[test] - fn cpu_tuning_unset_keeps_historical_defaults() { - // Host build (no triple) → native tuning; explicit triple → none. - assert_eq!( - cpu_tuning_arg_for(None, None, "x86_64-apple-darwin").as_deref(), - Some(native_tuning_arg_for_host()) - ); - assert_eq!( - cpu_tuning_arg_for( - None, - Some("x86_64-unknown-linux-gnu"), - "x86_64-unknown-linux-gnu" - ), - None - ); - } - - #[test] - fn cpu_tuning_explicit_cpu_spells_march_or_mcpu_by_target_arch() { - // #6125: an explicit baseline applies to host AND cross builds, and - // the flag spelling follows the effective target's architecture. - assert_eq!( - cpu_tuning_arg_for(Some("x86-64-v2"), None, "x86_64-unknown-linux-gnu").as_deref(), - Some("-march=x86-64-v2") - ); - assert_eq!( - cpu_tuning_arg_for( - Some("x86-64-v3"), - Some("x86_64-unknown-linux-musl"), - "x86_64-unknown-linux-musl" - ) - .as_deref(), - Some("-march=x86-64-v3") - ); - assert_eq!( - cpu_tuning_arg_for(Some("apple-m1"), None, "arm64-apple-macosx15.0.0").as_deref(), - Some("-mcpu=apple-m1") - ); - } - - #[test] - fn cpu_tuning_generic_disables_native_tuning_on_host_builds() { - for off in ["generic", "off", "none", "0", "false"] { - assert_eq!( - cpu_tuning_arg_for(Some(off), None, "x86_64-apple-darwin"), - None, - "'{off}' should disable tuning" - ); - } - // Whitespace / empty values fall back to the default. - assert_eq!( - cpu_tuning_arg_for(Some(" "), None, "x86_64-apple-darwin").as_deref(), - Some(native_tuning_arg_for_host()) - ); - } - - #[test] - fn cpu_tuning_native_can_be_forced_for_explicit_triples() { - assert_eq!( - cpu_tuning_arg_for( - Some("native"), - Some("x86_64-unknown-linux-gnu"), - "x86_64-unknown-linux-gnu" - ) - .as_deref(), - Some("-march=native") - ); - } - - #[test] - fn compile_plan_metadata_json_contains_object_source() { - let temp = env::temp_dir().join(format!( - "perry_compile_plan_test_{}_{}.json", - std::process::id(), - TEMP_NONCE_COUNTER.fetch_add(1, Ordering::Relaxed) - )); - let plan = build_clang_compile_plan( - PathBuf::from("clang"), - PathBuf::from("/tmp/input.ll"), - PathBuf::from("/tmp/output.o"), - Some("x86_64-unknown-linux-gnu"), - 0, - 0, - false, - ); - write_compile_plan_metadata(&plan, &temp).unwrap(); - let text = fs::read_to_string(&temp).unwrap(); - let _ = fs::remove_file(&temp); - assert!(text.contains("\"clang_path\": \"clang\"")); - assert!(text.contains("\"effective_target\": \"x86_64-unknown-linux-gnu\"")); - assert!(text.contains("\"object_path\": \"/tmp/output.o\"")); - assert!(text.contains("\"stderr_remarks_path\": \"/tmp/output.o.clang-stderr\"")); - } - - #[test] - fn temp_nonce_counter_is_unique_across_concurrent_calls() { - // Regression test for #509: two rayon workers calling - // `compile_ll_to_object` concurrently must NOT generate the same - // **output** temp-file path. The counter is mixed into the `.o` - // basename (the `.ll` is content-addressed — see #7131). - use std::collections::HashSet; - use std::thread; - - let mut handles = Vec::new(); - for _ in 0..16 { - handles.push(thread::spawn(|| { - let mut local: Vec = Vec::with_capacity(16); - for _ in 0..16 { - local.push(TEMP_NONCE_COUNTER.fetch_add(1, Ordering::Relaxed)); - } - local - })); - } - let mut all: Vec = Vec::with_capacity(256); - for h in handles { - all.extend(h.join().unwrap()); - } - let unique: HashSet = all.iter().copied().collect(); - assert_eq!( - unique.len(), - all.len(), - "TEMP_NONCE_COUNTER produced duplicate values: total={}, unique={}", - all.len(), - unique.len(), - ); - } - - #[test] - fn ll_temp_basename_is_content_addressed_not_clocked() { - // #7131: two calls with identical IR must produce the same `.ll` - // basename (so clang embeds a deterministic source path). The `.o` - // basename still differs via the counter. - let tmp = env::temp_dir(); - let ir = "define void @f() {\n ret void\n}\n"; - let (a, _, _) = llvm_temp_paths(&tmp, ir); - let (b, _, _) = llvm_temp_paths(&tmp, ir); - let (ll_a, obj_a) = (&a.ll_path, &a.obj_path); - let (ll_b, obj_b) = (&b.ll_path, &b.obj_path); - assert_eq!( - ll_a.file_name(), - ll_b.file_name(), - "same IR must share the content-addressed .ll basename" - ); - assert_ne!( - obj_a.file_name(), - obj_b.file_name(), - ".o basenames must stay unique across calls (#509)" - ); - // Different IR → different .ll basename. - let (c, _, _) = llvm_temp_paths(&tmp, "define void @g() {\n ret void\n}\n"); - assert_ne!(ll_a.file_name(), c.ll_path.file_name()); - // No pid / wall-clock digits of variable width — only hex hash. - let name = ll_a.file_name().unwrap().to_string_lossy(); - assert!( - name.starts_with("perry_llvm_") && name.ends_with(".ll"), - "unexpected .ll name: {name}" - ); - let hex = name - .trim_start_matches("perry_llvm_") - .trim_end_matches(".ll"); - assert_eq!(hex.len(), 16, "hash must be 16 lowercase hex digits: {hex}"); - assert!( - hex.chars().all(|c| c.is_ascii_hexdigit()), - "hash must be hex: {hex}" - ); - } - - #[test] - fn object_temp_name_is_unique_across_processes_but_ll_is_not() { - // The regression this test exists for: #7135 content-addressed BOTH - // temp names, so the `.o` lost the pid it used to carry. Two `perry` - // processes compiling identical IR then agreed on the object path — - // and `compile_ll_to_object` deletes the object after reading it, so - // they deleted each other's. Measured on macOS before the fix: 8 of 12 - // concurrent same-source compiles failed with - // Failed to read clang output at …/perry_llvm__0.o - // Both processes start TEMP_NONCE_COUNTER at 0, so the counter cannot - // separate them; only the pid can. - let tmp = env::temp_dir(); - let ir = "define void @f() {\n ret void\n}\n"; - - // Same IR, same counter, DIFFERENT process. - let p1 = llvm_temp_paths_for(&tmp, ir, 1111, 0); - let p2 = llvm_temp_paths_for(&tmp, ir, 2222, 0); - let (ll_p1, obj_p1) = (&p1.ll_path, &p1.obj_path); - let (ll_p2, obj_p2) = (&p2.ll_path, &p2.obj_path); - assert_eq!( - ll_p1.file_name(), - ll_p2.file_name(), - "the .ll is what clang records into the object; it must stay a pure \ - function of the IR across processes (#7131)" - ); - assert_ne!( - obj_p1.file_name(), - obj_p2.file_name(), - "two processes with identical IR must NOT share an object path — \ - they delete it out from under each other (#509 across processes)" - ); - - // Same process, different call: the counter still has to separate - // in-process rayon workers. - let c0 = llvm_temp_paths_for(&tmp, ir, 1111, 0); - let c1 = llvm_temp_paths_for(&tmp, ir, 1111, 1); - assert_ne!(c0.obj_path.file_name(), c1.obj_path.file_name()); - - // The atomic-write staging name needs the same separation: both - // processes reach it with the same hash and the same counter, and - // `File::create` truncates. - assert_ne!( - ll_staging_path(ll_p1, 1111, 0).file_name(), - ll_staging_path(ll_p1, 2222, 0).file_name(), - "staging .tmp name must be per-process" - ); - assert_ne!( - ll_staging_path(ll_p1, 1111, 0).file_name(), - ll_staging_path(ll_p1, 1111, 1).file_name(), - "staging .tmp name must be per-call" - ); - - // …and the staging file must never be mistaken for the real `.ll`. - assert_ne!( - ll_staging_path(ll_p1, 1111, 0).file_name(), - ll_p1.file_name() - ); - } - - #[test] - fn ll_content_hash_is_stable_for_fixed_input() { - // Pin the FNV-1a value so a future hash swap is intentional. - assert_eq!(ll_content_hash(""), 0xcbf2_9ce4_8422_2325); - assert_eq!(ll_content_hash("a"), 0xaf63_dc4c_8601_ec8c); - } -} +#[path = "linker_tests.rs"] +mod tests; /// Temp-file *lifecycle* — who owns the `.ll`, and when it is removed (#7144). /// A sibling file only because of the 2,000-line cap; `use super::*` gives it diff --git a/crates/perry-codegen/src/linker_tests.rs b/crates/perry-codegen/src/linker_tests.rs new file mode 100644 index 0000000000..1fd00455d9 --- /dev/null +++ b/crates/perry-codegen/src/linker_tests.rs @@ -0,0 +1,473 @@ +//! Clang discovery, version preflight, compile-plan shaping, and temp-path +//! naming — the unit-test half of `linker.rs`. +//! +//! Split out for the 2,000-line file cap, not because it is a different +//! subject; `use super::*` gives it the same view of the module as before. + +use super::*; + +fn version_block(target_line: &str) -> String { + format!("clang version 18.0.0\n{}\nThread model: posix", target_line) +} + +#[test] +fn parses_common_clang_version_banners() { + assert_eq!( + parse_clang_major_version("Ubuntu clang version 14.0.0-1ubuntu1.1"), + Some(14) + ); + assert_eq!( + parse_clang_major_version("Apple clang version 17.0.0 (clang-1700.0.13.5)"), + Some(17) + ); + assert_eq!( + parse_clang_major_version("Debian clang version 18.1.8\nTarget: x86_64-linux-gnu"), + Some(18) + ); + assert_eq!(parse_clang_major_version("not a clang banner"), None); +} + +#[test] +fn version_banner_on_stderr_wins_over_stdout_wrapper_noise() { + let selected = select_clang_version_output( + "wrapper: selecting system toolchain", + "Ubuntu clang version 14.0.0-1ubuntu1.1", + ); + assert_eq!( + selected.as_deref(), + Some("Ubuntu clang version 14.0.0-1ubuntu1.1") + ); + assert_eq!( + select_clang_version_output("clang version 18.1.8", "warning").as_deref(), + Some("clang version 18.1.8") + ); +} + +#[test] +fn prefers_supported_versioned_clang_over_old_path_default() { + let candidates = vec![ + PathBuf::from("/usr/bin/clang"), + PathBuf::from("/usr/bin/clang-18"), + PathBuf::from("/usr/bin/clang-15"), + ]; + let selected = + select_clang_candidate_with(candidates, |path| match path.file_name()?.to_str()? { + "clang" => Some(14), + "clang-18" => Some(18), + "clang-15" => Some(15), + _ => None, + }); + assert_eq!(selected, Some(PathBuf::from("/usr/bin/clang-18"))); +} + +#[test] +fn retains_first_candidate_to_report_an_old_only_install() { + let candidates = vec![ + PathBuf::from("/usr/bin/clang"), + PathBuf::from("/usr/bin/clang-14"), + ]; + let selected = select_clang_candidate_with(candidates, |_| Some(14)); + assert_eq!(selected, Some(PathBuf::from("/usr/bin/clang"))); +} + +#[test] +fn old_clang_preflight_explains_the_opaque_pointer_requirement() { + let error = ensure_supported_clang_major(Path::new("/usr/bin/clang"), Some(14)) + .expect_err("clang 14 must be rejected"); + let message = error.to_string(); + assert!(message.contains("too old (14 < 15)")); + assert!(message.contains("opaque-pointer LLVM IR")); + assert!(message.contains("PERRY_LLVM_CLANG")); + assert!(ensure_supported_clang_major(Path::new("/usr/bin/clang-15"), Some(15)).is_ok()); + assert!(ensure_supported_clang_major(Path::new("/toolchain-wrapper"), None).is_ok()); +} + +#[test] +fn hint_for_mingw_clang_on_windows_targets_msvc() { + // Only the host-is-windows arm fires this hint. The build matrix runs + // these tests on every host, so we gate the assertion on cfg(windows). + // On non-Windows hosts the function falls through to the generic + // PERRY_LLVM_CLANG suggestion — also asserted below. + let v = version_block("Target: x86_64-w64-windows-gnu"); + let hint = build_clang_failure_hint( + "lld-link: error: undefined symbol: __main", + &v, + "x86_64-pc-windows-msvc", + ); + if cfg!(target_os = "windows") { + assert!( + hint.contains("MinGW/GNU"), + "expected MinGW hint, got: {}", + hint + ); + assert!(hint.contains("winget install LLVM.LLVM")); + assert!(hint.contains("PERRY_LLVM_CLANG")); + } else { + assert!(hint.contains("PERRY_LLVM_CLANG")); + } +} + +#[test] +fn hint_for_override_module_target_triple_warning() { + let v = version_block("Target: x86_64-pc-linux-gnu"); + let hint = build_clang_failure_hint( + "warning: overriding the module target triple with x86_64-pc-linux-gnu", + &v, + "x86_64-unknown-linux-gnu", + ); + // On non-Windows hosts the override-warning branch should win. + if !cfg!(target_os = "windows") { + assert!( + hint.contains("overriding the module target triple"), + "expected override hint, got: {}", + hint + ); + } +} + +#[test] +fn hint_for_missing_library_message() { + let v = version_block("Target: aarch64-apple-darwin23.0.0"); + let hint = build_clang_failure_hint( + "ld: library not found for -lSystem", + &v, + "arm64-apple-macosx15.0.0", + ); + assert!( + hint.contains("library") || hint.contains("PERRY_LLVM_CLANG"), + "got: {}", + hint + ); +} + +#[test] +fn hint_falls_back_when_no_pattern_matches() { + let v = version_block("Target: aarch64-apple-darwin23.0.0"); + let hint = build_clang_failure_hint( + "(some unrelated clang stderr)", + &v, + "arm64-apple-macosx15.0.0", + ); + assert!( + hint.contains("PERRY_LLVM_CLANG"), + "fallback hint should mention PERRY_LLVM_CLANG; got: {}", + hint + ); + assert!(hint.contains("arm64-apple-macosx15.0.0")); +} + +#[test] +fn compile_plan_records_effective_target_and_native_tuning() { + let plan = build_clang_compile_plan( + PathBuf::from("clang"), + PathBuf::from("/tmp/input.ll"), + PathBuf::from("/tmp/output.o"), + None, + 0, + 0, + false, + ); + assert!(plan.clang_args.contains(&"-fno-math-errno".to_string())); + // Small module → optimized at -O3 (#4880). + assert!(plan.clang_args.contains(&"-O3".to_string())); + assert!(plan.clang_args.contains(&"-target".to_string())); + assert!(plan.analysis_clang_args.contains(&"-target".to_string())); + assert_eq!( + plan.native_tuning_arg.as_deref(), + Some(native_tuning_arg_for_host()) + ); + assert!(!plan.effective_target.is_empty()); +} + +#[test] +fn compile_plan_size_optimizes_oversized_many_function_module() { + // An oversized unit made of many ordinary functions (a large minified + // bundle: low bytes-per-function) size-optimizes at -Os — far less + // __text than -O0 — rather than dropping to the speed pipeline or -O0. + let huge = ll_o0_threshold_bytes() + 1; + let many_funcs = huge / 1024; // ~1 KB/fn, well under the density cap + let plan = build_clang_compile_plan( + PathBuf::from("clang"), + PathBuf::from("/tmp/input.ll"), + PathBuf::from("/tmp/output.o"), + None, + huge, + many_funcs, + false, + ); + assert!(plan.clang_args.contains(&"-Os".to_string())); + assert!(!plan.clang_args.contains(&"-O3".to_string())); + assert!(!plan.clang_args.contains(&"-O0".to_string())); +} + +#[test] +fn compile_plan_keeps_o0_for_oversized_giant_function_monolith() { + // #4880: an oversized unit dominated by a few giant generated functions + // (a multi-thousand-element data literal: megabytes-per-function) keeps + // -O0, the only opt level whose pipeline finishes in practical time. + let huge = ll_o0_threshold_bytes() + 1; + let plan = build_clang_compile_plan( + PathBuf::from("clang"), + PathBuf::from("/tmp/input.ll"), + PathBuf::from("/tmp/output.o"), + None, + huge, + 2, // ~3 MB/fn — far above the density cap + false, + ); + assert!(plan.clang_args.contains(&"-O0".to_string())); + assert!(!plan.clang_args.contains(&"-O3".to_string())); + assert!(!plan.clang_args.contains(&"-Os".to_string())); +} + +#[test] +fn compile_plan_skips_native_tuning_for_explicit_target() { + let plan = build_clang_compile_plan( + PathBuf::from("clang"), + PathBuf::from("/tmp/input.ll"), + PathBuf::from("/tmp/output.o"), + Some("x86_64-unknown-linux-gnu"), + 0, + 0, + false, + ); + assert_eq!(plan.effective_target, "x86_64-unknown-linux-gnu"); + assert_eq!(plan.native_tuning_arg, None); + assert!(!plan + .clang_args + .iter() + .any(|arg| arg == "-march=native" || arg == "-mcpu=native")); +} + +#[test] +fn cpu_tuning_unset_keeps_historical_defaults() { + // Host build (no triple) → native tuning; explicit triple → none. + assert_eq!( + cpu_tuning_arg_for(None, None, "x86_64-apple-darwin").as_deref(), + Some(native_tuning_arg_for_host()) + ); + assert_eq!( + cpu_tuning_arg_for( + None, + Some("x86_64-unknown-linux-gnu"), + "x86_64-unknown-linux-gnu" + ), + None + ); +} + +#[test] +fn cpu_tuning_explicit_cpu_spells_march_or_mcpu_by_target_arch() { + // #6125: an explicit baseline applies to host AND cross builds, and + // the flag spelling follows the effective target's architecture. + assert_eq!( + cpu_tuning_arg_for(Some("x86-64-v2"), None, "x86_64-unknown-linux-gnu").as_deref(), + Some("-march=x86-64-v2") + ); + assert_eq!( + cpu_tuning_arg_for( + Some("x86-64-v3"), + Some("x86_64-unknown-linux-musl"), + "x86_64-unknown-linux-musl" + ) + .as_deref(), + Some("-march=x86-64-v3") + ); + assert_eq!( + cpu_tuning_arg_for(Some("apple-m1"), None, "arm64-apple-macosx15.0.0").as_deref(), + Some("-mcpu=apple-m1") + ); +} + +#[test] +fn cpu_tuning_generic_disables_native_tuning_on_host_builds() { + for off in ["generic", "off", "none", "0", "false"] { + assert_eq!( + cpu_tuning_arg_for(Some(off), None, "x86_64-apple-darwin"), + None, + "'{off}' should disable tuning" + ); + } + // Whitespace / empty values fall back to the default. + assert_eq!( + cpu_tuning_arg_for(Some(" "), None, "x86_64-apple-darwin").as_deref(), + Some(native_tuning_arg_for_host()) + ); +} + +#[test] +fn cpu_tuning_native_can_be_forced_for_explicit_triples() { + assert_eq!( + cpu_tuning_arg_for( + Some("native"), + Some("x86_64-unknown-linux-gnu"), + "x86_64-unknown-linux-gnu" + ) + .as_deref(), + Some("-march=native") + ); +} + +#[test] +fn compile_plan_metadata_json_contains_object_source() { + let temp = env::temp_dir().join(format!( + "perry_compile_plan_test_{}_{}.json", + std::process::id(), + TEMP_NONCE_COUNTER.fetch_add(1, Ordering::Relaxed) + )); + let plan = build_clang_compile_plan( + PathBuf::from("clang"), + PathBuf::from("/tmp/input.ll"), + PathBuf::from("/tmp/output.o"), + Some("x86_64-unknown-linux-gnu"), + 0, + 0, + false, + ); + write_compile_plan_metadata(&plan, &temp).unwrap(); + let text = fs::read_to_string(&temp).unwrap(); + let _ = fs::remove_file(&temp); + assert!(text.contains("\"clang_path\": \"clang\"")); + assert!(text.contains("\"effective_target\": \"x86_64-unknown-linux-gnu\"")); + assert!(text.contains("\"object_path\": \"/tmp/output.o\"")); + assert!(text.contains("\"stderr_remarks_path\": \"/tmp/output.o.clang-stderr\"")); +} + +#[test] +fn temp_nonce_counter_is_unique_across_concurrent_calls() { + // Regression test for #509: two rayon workers calling + // `compile_ll_to_object` concurrently must NOT generate the same + // **output** temp-file path. The counter is mixed into the `.o` + // basename (the `.ll` is content-addressed — see #7131). + use std::collections::HashSet; + use std::thread; + + let mut handles = Vec::new(); + for _ in 0..16 { + handles.push(thread::spawn(|| { + let mut local: Vec = Vec::with_capacity(16); + for _ in 0..16 { + local.push(TEMP_NONCE_COUNTER.fetch_add(1, Ordering::Relaxed)); + } + local + })); + } + let mut all: Vec = Vec::with_capacity(256); + for h in handles { + all.extend(h.join().unwrap()); + } + let unique: HashSet = all.iter().copied().collect(); + assert_eq!( + unique.len(), + all.len(), + "TEMP_NONCE_COUNTER produced duplicate values: total={}, unique={}", + all.len(), + unique.len(), + ); +} + +#[test] +fn ll_temp_basename_is_content_addressed_not_clocked() { + // #7131: two calls with identical IR must produce the same `.ll` + // basename (so clang embeds a deterministic source path). The `.o` + // basename still differs via the counter. + let tmp = env::temp_dir(); + let ir = "define void @f() {\n ret void\n}\n"; + let (a, _, _) = llvm_temp_paths(&tmp, ir); + let (b, _, _) = llvm_temp_paths(&tmp, ir); + let (ll_a, obj_a) = (&a.ll_path, &a.obj_path); + let (ll_b, obj_b) = (&b.ll_path, &b.obj_path); + assert_eq!( + ll_a.file_name(), + ll_b.file_name(), + "same IR must share the content-addressed .ll basename" + ); + assert_ne!( + obj_a.file_name(), + obj_b.file_name(), + ".o basenames must stay unique across calls (#509)" + ); + // Different IR → different .ll basename. + let (c, _, _) = llvm_temp_paths(&tmp, "define void @g() {\n ret void\n}\n"); + assert_ne!(ll_a.file_name(), c.ll_path.file_name()); + // No pid / wall-clock digits of variable width — only hex hash. + let name = ll_a.file_name().unwrap().to_string_lossy(); + assert!( + name.starts_with("perry_llvm_") && name.ends_with(".ll"), + "unexpected .ll name: {name}" + ); + let hex = name + .trim_start_matches("perry_llvm_") + .trim_end_matches(".ll"); + assert_eq!(hex.len(), 16, "hash must be 16 lowercase hex digits: {hex}"); + assert!( + hex.chars().all(|c| c.is_ascii_hexdigit()), + "hash must be hex: {hex}" + ); +} + +#[test] +fn object_temp_name_is_unique_across_processes_but_ll_is_not() { + // The regression this test exists for: #7135 content-addressed BOTH + // temp names, so the `.o` lost the pid it used to carry. Two `perry` + // processes compiling identical IR then agreed on the object path — + // and `compile_ll_to_object` deletes the object after reading it, so + // they deleted each other's. Measured on macOS before the fix: 8 of 12 + // concurrent same-source compiles failed with + // Failed to read clang output at …/perry_llvm__0.o + // Both processes start TEMP_NONCE_COUNTER at 0, so the counter cannot + // separate them; only the pid can. + let tmp = env::temp_dir(); + let ir = "define void @f() {\n ret void\n}\n"; + + // Same IR, same counter, DIFFERENT process. + let p1 = llvm_temp_paths_for(&tmp, ir, 1111, 0); + let p2 = llvm_temp_paths_for(&tmp, ir, 2222, 0); + let (ll_p1, obj_p1) = (&p1.ll_path, &p1.obj_path); + let (ll_p2, obj_p2) = (&p2.ll_path, &p2.obj_path); + assert_eq!( + ll_p1.file_name(), + ll_p2.file_name(), + "the .ll is what clang records into the object; it must stay a pure \ + function of the IR across processes (#7131)" + ); + assert_ne!( + obj_p1.file_name(), + obj_p2.file_name(), + "two processes with identical IR must NOT share an object path — \ + they delete it out from under each other (#509 across processes)" + ); + + // Same process, different call: the counter still has to separate + // in-process rayon workers. + let c0 = llvm_temp_paths_for(&tmp, ir, 1111, 0); + let c1 = llvm_temp_paths_for(&tmp, ir, 1111, 1); + assert_ne!(c0.obj_path.file_name(), c1.obj_path.file_name()); + + // The atomic-write staging name needs the same separation: both + // processes reach it with the same hash and the same counter, and + // `File::create` truncates. + assert_ne!( + ll_staging_path(ll_p1, 1111, 0).file_name(), + ll_staging_path(ll_p1, 2222, 0).file_name(), + "staging .tmp name must be per-process" + ); + assert_ne!( + ll_staging_path(ll_p1, 1111, 0).file_name(), + ll_staging_path(ll_p1, 1111, 1).file_name(), + "staging .tmp name must be per-call" + ); + + // …and the staging file must never be mistaken for the real `.ll`. + assert_ne!( + ll_staging_path(ll_p1, 1111, 0).file_name(), + ll_p1.file_name() + ); +} + +#[test] +fn ll_content_hash_is_stable_for_fixed_input() { + // Pin the FNV-1a value so a future hash swap is intentional. + assert_eq!(ll_content_hash(""), 0xcbf2_9ce4_8422_2325); + assert_eq!(ll_content_hash("a"), 0xaf63_dc4c_8601_ec8c); +} diff --git a/crates/perry-codegen/src/statepoint_report.rs b/crates/perry-codegen/src/statepoint_report.rs index b1b59a4cfa..d6e74e70ff 100644 --- a/crates/perry-codegen/src/statepoint_report.rs +++ b/crates/perry-codegen/src/statepoint_report.rs @@ -1,9 +1,15 @@ //! Observational root-pressure report for the native-stack GC experiments. //! -//! `PERRY_STATEPOINT_REPORT=1|text|json` records how many textual calls see -//! live roots, which ones can be omitted after the GC-effect audit, and how -//! much statepoint/stack-map metadata remains. Codegen never reads the data -//! back, so enabling the report cannot affect emitted IR. +//! `perry compile --statepoint-report[=text|json]` records how many textual +//! calls see live roots, which ones can be omitted after the GC-effect audit, +//! and how much statepoint/stack-map metadata remains. Codegen never reads the +//! data back, so enabling the report cannot affect emitted IR. +//! +//! `PERRY_STATEPOINT_REPORT` is how the driver carries that flag across to the +//! rayon module workers — the driver sets it, nothing else should. It is not a +//! user-facing knob: accepting it from the environment made it a fifth GC env +//! knob with no CI arm, so that spelling was deleted under CLAUDE.md's GC knob +//! kill policy. `gc-native-roots.yml` exercises the report through the flag. use std::collections::BTreeMap; use std::fmt::Write as _; diff --git a/crates/perry/src/commands/compile/run_pipeline.rs b/crates/perry/src/commands/compile/run_pipeline.rs index 4ee2318cf1..ced4263927 100644 --- a/crates/perry/src/commands/compile/run_pipeline.rs +++ b/crates/perry/src/commands/compile/run_pipeline.rs @@ -227,13 +227,14 @@ pub fn run_with_parse_cache( // observational and must be enabled before rayon starts module codegen. // Cache reuse is disabled because cached objects bypass the lowering that // records each function. - let statepoint_report_format = args.statepoint_report.or_else(|| { - match std::env::var("PERRY_STATEPOINT_REPORT").as_deref() { - Ok("json") => Some(StatepointReportFormat::Json), - Ok("1") | Ok("text") => Some(StatepointReportFormat::Text), - _ => None, - } - }); + // + // `PERRY_STATEPOINT_REPORT` is written here and read by the rayon module + // workers; it is NOT a user-facing knob. It used to be accepted from the + // environment as a second spelling of `--statepoint-report`, which made it + // a fifth GC env knob with no CI arm — deleted under CLAUDE.md's kill + // policy (#7314 review item), leaving the flag as the single entry point. + // `--opt-report` keeps its env spelling because that one is not a GC knob. + let statepoint_report_format = args.statepoint_report; if let Some(fmt) = statepoint_report_format { std::env::set_var( "PERRY_STATEPOINT_REPORT", diff --git a/docs/engine-plan.md b/docs/engine-plan.md index fd627ed7bc..5ab4b9766c 100644 --- a/docs/engine-plan.md +++ b/docs/engine-plan.md @@ -88,19 +88,50 @@ byte-for-byte unchanged. ### Blocking adoption — concrete, and neither is code -1. **Four of five new knobs have no CI arm.** `PERRY_STATEPOINTS` is exercised by - `gc-native-roots.yml`; **`PERRY_RS4GC`, `PERRY_GC_SAFEPOINT_ONLY`, - `PERRY_STACKMAP_WALKER` and `PERRY_STATEPOINT_REPORT` appear in no workflow at - all.** CLAUDE.md's kill-policy is binding: an arm each, or delete after one - release of soak. At most one diagnostic-only knob may exist, labelled untested. -2. **`gc-native-roots` is not a required status check**, so it reports without +1. ~~**Four of five new knobs have no CI arm.**~~ **Closed by #7319.** Every + surviving knob now has an arm that asserts its own subject was live, and the + fifth was deleted: `PERRY_STATEPOINT_REPORT` was a second spelling of + `--statepoint-report`, so the env spelling is gone and the flag is the only + entry point. `PERRY_RS4GC` asserts every function record carries + `backend: rs4gc` (it bails per function to the explicit bridge, so a green + 9/9 matrix proves nothing on its own); `PERRY_GC_SAFEPOINT_ONLY` asserts a + codegen differential (statepoints strictly down, skipped calls strictly up); + `PERRY_STACKMAP_WALKER` asserts `fp_walks > 0` under `verify` and + `fp_walks == 0` under `unwind`, from the GC trace. +2. ~~**#7314 broke the file-size gate.**~~ **Closed by #7319** — `function.rs` + 2036 → 952 (statepoint/RS4GC lowering into `function/precise_roots.rs`) and + `linker.rs` 2082 → 1618 (unit tests into `linker_tests.rs`, the pattern that + file already used for `linker_temp_lifecycle_tests.rs`). Verified by + byte-identical emitted IR over 45 modules × 3 modes. +3. **`gc-native-roots` is not a required status check**, so it reports without blocking — CLAUDE.md hazard 2, the one that let #6925's regression survive - three merges. Promote after its first green run on `main`, not before. -3. **#7314 broke the file-size gate on `main`**: `perry-codegen/src/function.rs` - went **847 → 2036** lines and `linker.rs` 1936 → 2082, both over the 2000-line - cap. `lint` therefore fails two gates now, not one. (It reports both rather - than hiding the second only because #7306 made every gate run independently — - before that, one red gate concealed the six below it.) + three merges. **Still open, and the reason changed**: promotion waits on a + green run, and the job had never been green *at all* (see below). Promote the + fan-in context `gc-native-roots-complete`, not the individual arms. + +### ★ Statepoints are aarch64-only today (#7321) + +Found while building those arms, and it is the largest single correction to the +picture above. **`PERRY_STATEPOINTS=1` cannot compile one module on x86-64 +Linux.** The compact-map rewriter refuses — *"this module emits an LLVM stack +map that the compact-map rewriter could not parse … Refusing to emit a binary +that would lose roots silently"* — on the **first** probe, which is why +`gc-native-roots` had failed every run since it was pointed at `ubuntu-latest`. +That is the fail-closed path working; the consequence is scope. #7314's evidence +(drizzle, 23,301 statepoints) is aarch64 evidence. `gc_map.rs` names its base +registers in aarch64 terms throughout, which is consistent, though not proven to +be the cause. + +The matrix therefore runs on `macos-14`, and `statepoints-refuse-x86` pins the +refusal *as a refusal* and goes red the day x86-64 starts working. + +A second latent defect, now fixed: the workflow set +`RUSTFLAGS="-Cforce-frame-pointers=yes"`, which **replaces** `.cargo/config.toml`'s +`[build] rustflags` wholesale and so dropped `-C force-unwind-tables=yes`. A/B'd +on one tree: without it `09_try_catch_roots` aborts outright 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, and forced evacuation stays +quiet because it enumerates through that same walker. ### The adoption decision itself diff --git a/docs/src/cli/flags.md b/docs/src/cli/flags.md index 46a9f3615b..60e31ae749 100644 --- a/docs/src/cli/flags.md +++ b/docs/src/cli/flags.md @@ -229,7 +229,12 @@ shrink less, proportionally. | `CI=true` | Auto-skip update checks (set by most CI systems) | | `RUST_LOG` | Debug logging level (`debug`, `info`, `trace`) | | `PERRY_OPT_REPORT` | `1`/`text` or `json` — same as `--opt-report[=json]`, for driving the report from an environment where adding a flag is awkward | -| `PERRY_STATEPOINT_REPORT` | `1`/`text` or `json` — same as `--statepoint-report[=json]`; observational root-pressure reporting for the native-stack GC experiments | + +The native-stack GC root-pressure report has **no** environment spelling: use +`--statepoint-report[=json]`. The `PERRY_STATEPOINT_REPORT` variable is set by +the driver to carry that flag to the codegen workers and is not read as user +input — it was a fifth GC env knob with no CI arm, and was deleted under +CLAUDE.md's GC knob kill policy. ## Configuration Files diff --git a/scripts/gc_gate_wiring_check.py b/scripts/gc_gate_wiring_check.py index 61a45519d4..1a6fa17d78 100644 --- a/scripts/gc_gate_wiring_check.py +++ b/scripts/gc_gate_wiring_check.py @@ -75,6 +75,14 @@ "gc-ratchet", "the pinned GC counter ratchet", ), + ( + ".github/workflows/gc-native-roots.yml", + "gc-native-roots-complete", + "the native-frame root arms (PERRY_STATEPOINTS / PERRY_RS4GC / " + "PERRY_GC_SAFEPOINT_ONLY / PERRY_STACKMAP_WALKER) — the fan-in that " + "makes one context speak for all four, so adding an arm later never " + "needs a branch-protection edit", + ), ] MAIN_LINE_EVENTS = ("push", "schedule") diff --git a/scripts/gc_walker_trace_assert.py b/scripts/gc_walker_trace_assert.py new file mode 100755 index 0000000000..927b9aec38 --- /dev/null +++ b/scripts/gc_walker_trace_assert.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 +"""Assert which native-stack-map walker actually ran, from a `PERRY_GC_TRACE=1` +stderr stream. + +`PERRY_STACKMAP_WALKER` selects between three walks over the same roots, and a +run under the wrong one is indistinguishable by program output alone — every +mode is supposed to produce identical results, which is exactly why "it passed" +proves nothing about which one executed. The GC trace does distinguish them: + + mode fp_walks walks + fast > 0 > 0 x29 chain walk (aarch64 only) + verify > 0 > 0 chain walk cross-checked against the unwinder + unwind == 0 > 0 platform unwinder only + +So `--require-fp-walks` is the liveness assert for `verify` (it is 0 the moment +the chain walk silently stops being used, and on a target where the chain walk +does not exist `verify` panics outright), and `--forbid-fp-walks` is the +liveness assert for `unwind` (nonzero means the mode did not take effect and +the arm was measuring `fast` all along). + +Usage: + 2> trace.err + gc_walker_trace_assert.py trace.err --require-fp-walks + gc_walker_trace_assert.py trace.err --forbid-fp-walks +""" + +from __future__ import annotations + +import argparse +import json +import sys + + +def totals(path: str) -> tuple[int, int, int]: + fp_walks = walks = locations = 0 + saw_event = False + with open(path, encoding="utf-8", errors="replace") as handle: + for line in handle: + line = line.strip() + if not line.startswith("{"): + continue + try: + event = json.loads(line) + except json.JSONDecodeError: + continue + stats = event.get("root_sources", {}).get("native_stack_maps") + if not isinstance(stats, dict): + continue + saw_event = True + fp_walks += stats.get("fp_walks", 0) + walks += stats.get("walks", 0) + locations += stats.get("locations_visited", 0) + if not saw_event: + sys.exit( + f"::error::{path} carries no GC trace events with root_sources — " + "PERRY_GC_TRACE=1 was not set, or no collection ran at all" + ) + return fp_walks, walks, locations + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("trace") + ap.add_argument("--require-fp-walks", action="store_true") + ap.add_argument("--forbid-fp-walks", action="store_true") + args = ap.parse_args() + + fp_walks, walks, locations = totals(args.trace) + print(f"{args.trace}: walks={walks} fp_walks={fp_walks} locations_visited={locations}") + + failures: list[str] = [] + if walks <= 0: + failures.append( + "the native stack-map walker never ran (walks == 0) — this arm " + "asserted nothing about the walker" + ) + if args.require_fp_walks and fp_walks <= 0: + failures.append( + "fp_walks == 0: the x29 chain walk did not run, so verify mode " + "cross-checked nothing" + ) + if args.forbid_fp_walks and fp_walks != 0: + failures.append( + f"fp_walks == {fp_walks}: PERRY_STACKMAP_WALKER=unwind did not take " + "effect, the fast walk ran anyway" + ) + + for message in failures: + print(f"::error::{message}", file=sys.stderr) + return 1 if failures else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/statepoint_report_assert.py b/scripts/statepoint_report_assert.py new file mode 100755 index 0000000000..ae6e356f46 --- /dev/null +++ b/scripts/statepoint_report_assert.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +"""Assert on a `perry compile --statepoint-report=json` report. + +The report is written to stderr, interleaved with linker warnings and driver +chatter, so this reads the whole stream and decodes the first JSON object in +it rather than assuming the file is pure JSON. + +Exists so the `gc-native-roots` CI arms can assert *their subject was live* +rather than merely that nothing threw (CLAUDE.md, "four ways a gate can be +unable to fail", #4). Each mode has a signature in this report that the other +modes cannot produce: + + * explicit statepoint bridge -> every record `"backend": "statepoint"`, + `statepoints > 0`, `plain_stack_maps == 0`, `statepoint_fallbacks == 0` + * RS4GC (`PERRY_RS4GC=1`) -> every record `"backend": "rs4gc"`. RS4GC + bails per function to the explicit bridge on any unrecognised root-alloca + shape, so "did it run" and "did it run everywhere" are different + questions and only `--only-backend` answers the second. + * safepoint-only contract -> `skipped_non_safepoints` strictly up and + `statepoints` strictly down against the same build without it, which is + what `--print` is for. + +Usage: + statepoint_report_assert.py REPORT [--only-backend NAME] + [--require-positive FIELD]... + [--require-zero FIELD]... + [--print FIELD] +""" + +from __future__ import annotations + +import argparse +import json +import sys + + +def load(path: str) -> dict: + text = open(path, encoding="utf-8", errors="replace").read() + start = text.find("{") + if start < 0: + sys.exit(f"::error::{path} contains no JSON report — was --statepoint-report=json passed?") + try: + report, _ = json.JSONDecoder().raw_decode(text[start:]) + except json.JSONDecodeError as exc: + sys.exit(f"::error::{path} does not decode as a statepoint report: {exc}") + if "totals" not in report or "functions" not in report: + sys.exit(f"::error::{path} is JSON but not a statepoint report (no totals/functions)") + return report + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("report") + ap.add_argument("--only-backend") + ap.add_argument("--require-positive", action="append", default=[]) + ap.add_argument("--require-zero", action="append", default=[]) + ap.add_argument("--print", dest="print_field") + args = ap.parse_args() + + report = load(args.report) + totals = report["totals"] + functions = report["functions"] + failures: list[str] = [] + + if args.only_backend is not None: + if not functions: + failures.append( + f"no function records at all — the {args.only_backend} lowering never ran" + ) + else: + counts: dict[str, int] = {} + for record in functions: + backend = record.get("backend", "") + counts[backend] = counts.get(backend, 0) + 1 + other = {k: v for k, v in counts.items() if k != args.only_backend} + if args.only_backend not in counts: + failures.append( + f"no function used backend {args.only_backend!r}; saw {counts}" + ) + elif other: + failures.append( + f"{sum(other.values())} function(s) fell back off backend " + f"{args.only_backend!r}: {other}" + ) + else: + print(f"backend {args.only_backend}: {counts[args.only_backend]} function(s)") + + for field in args.require_positive: + value = totals.get(field) + if not isinstance(value, int): + failures.append(f"totals.{field} missing from the report") + elif value <= 0: + failures.append(f"totals.{field} == {value}, expected > 0 (the mode did nothing)") + else: + print(f"totals.{field} = {value}") + + for field in args.require_zero: + value = totals.get(field) + if not isinstance(value, int): + failures.append(f"totals.{field} missing from the report") + elif value != 0: + failures.append(f"totals.{field} == {value}, expected 0") + else: + print(f"totals.{field} = 0") + + if args.print_field: + value = totals.get(args.print_field) + if not isinstance(value, int): + sys.exit(f"::error::totals.{args.print_field} missing from the report") + print(value) + + for message in failures: + print(f"::error::{message}", file=sys.stderr) + return 1 if failures else 0 + + +if __name__ == "__main__": + raise SystemExit(main())