fix(gc): root the array-store receiver across an allocating RHS #68
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # #7173: native-frame GC root verification. | |
| # | |
| # Runs on an ARM64 runner deliberately. The backend is aarch64-only: on x86-64 | |
| # every root is `Indirect [RSP + off]` (DWARF register 7), which the runtime | |
| # cannot resolve — `_Unwind_GetGR` does not reliably return the stack pointer — | |
| # so the collector segfaults. The compiler now refuses that combination | |
| # outright, which would make an x86-64 run of this gate test nothing but the | |
| # refusal. ARM64 exercises the configuration that is actually supported, and | |
| # still answers the question this gate exists for: whether the compact map | |
| # survives ELF linking. | |
| # | |
| # | |
| # Runs the gc-ratchet probe matrix in every native-root mode under forced | |
| # evacuation + evacuation verification, byte-diffed against the pinned Node | |
| # oracle. Each arm carries a liveness assert, because CLAUDE.md's fourth way a | |
| # gate cannot fail is the one that looks green: the job runs, but its subject | |
| # never did. `PERRY_GC_FORCE_EVACUATE` was inert for every `gc()`-driven test | |
| # for months (#6942/#6946) and the matrix's `--pressure` knob disabled the very | |
| # path it was measuring (#7024) — both were green the whole time. | |
| # | |
| # ── Why the matrix runs on macos-14 (aarch64) and not ubuntu-latest ───────── | |
| # | |
| # It used to say ubuntu-latest, and it had never once gone green there. The | |
| # first explanation written here — that the compact-map rewriter cannot parse an | |
| # x86-64 stack map, with `gc_map.rs`'s aarch64 register names as the suspect — | |
| # was WRONG, and is recorded as wrong because it survived into an issue (#7321) | |
| # and a job name before anyone measured it. | |
| # | |
| # What is actually true, measured both by cross-compiling a probe to | |
| # x86_64-unknown-linux-gnu and decoding the emitted map (#7324) and by five | |
| # clang versions x twelve `-march` settings x all nine probes from two hosts | |
| # (#7331): **x86-64 stack maps parse fine.** Every root is | |
| # `Indirect [RSP + off]`, DWARF register 7, which round-trips through the | |
| # compact format's explicit-register tag exactly. | |
| # | |
| # The defect is one layer down, at collection time. `chain_walkable` admits only | |
| # aarch64's DWARF 29/31, so on x86-64 every frame falls back to the platform | |
| # unwinder, which resolves the base with `_Unwind_GetGR(ctx, 7)` — and that does | |
| # not reliably return the stack pointer (`_Unwind_GetCFA` is the supported way). | |
| # Wild addresses, then a segfault when the collector writes through them. The | |
| # compiler now refuses that target outright (#7324) rather than emitting a | |
| # binary that crashes under collection, so an x86-64 run of this matrix would | |
| # test nothing but the refusal — which is what `statepoints-refuse-x86` is for. | |
| # | |
| # The same walk is unsound on aarch64 **Linux** too, where it is merely the | |
| # non-default path: #7333. | |
| # | |
| # ── RUSTFLAGS ─────────────────────────────────────────────────────────────── | |
| # | |
| # `-C force-unwind-tables=yes` is NOT optional and is NOT redundant with | |
| # .cargo/config.toml. Cargo takes rustflags from exactly one source, so setting | |
| # the RUSTFLAGS env var here REPLACES the config file's `[build] rustflags` | |
| # wholesale — the config file says so in a comment, and this workflow used to | |
| # set only `-Cforce-frame-pointers=yes` and lose it. Measured consequence, A/B'd | |
| # locally on the same tree: `09_try_catch_roots` aborts with "unwind tables are | |
| # missing from this runtime build (0 frame(s) visible to the unwinder)", and the | |
| # platform unwinder visits ZERO frames — so on any host where the x29 chain walk | |
| # is unavailable the native-root walker finds no roots at all, while forced | |
| # evacuation stays quiet because it enumerates roots through that same walker. | |
| # | |
| # ── The knobs this workflow exists to keep honest ─────────────────────────── | |
| # | |
| # CLAUDE.md's GC knob kill-policy: an arm exercising the non-default state, or | |
| # delete the mode. | |
| # | |
| # PERRY_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 | |
| # right while this lived only on exp/stackmap-viability; on main that same | |
| # filter would mean the job never runs at all — CLAUDE.md's second way a gate | |
| # cannot fail. Cancellation is deliberately NOT set here: a `main` run that | |
| # gets cancelled by the next merge is the third way. | |
| push: | |
| branches: [main] | |
| pull_request: | |
| workflow_dispatch: | |
| jobs: | |
| native-roots-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 | |
| # 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" | |
| export PERRY_NO_AUTO_OPTIMIZE=1 | |
| pass=0 | |
| total=0 | |
| errs="" | |
| for probe in benchmarks/gc_ratchet/probes/*.ts; do | |
| # #7335: the explicit bridge cannot root an `invoke`, so since #7330 it | |
| # REFUSES a try-carrying probe rather than emitting a frame with no | |
| # roots (#7327). Skip 09 here; the RS4GC job covers it, and the step | |
| # below asserts the refusal actually happens. | |
| [ "$(basename "$probe")" = "09_try_catch_roots.ts" ] && continue | |
| total=$((total+1)) | |
| name=$(basename "$probe" .ts) | |
| node --expose-gc --experimental-strip-types "$probe" > "/tmp/$name.oracle" | |
| PERRY_STATEPOINTS=1 ./target/perry-dev/perry "$probe" -o "/tmp/$name" | |
| # Liveness assert 1: the subject must exist. The compact map | |
| # 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. | |
| 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 \ | |
| PERRY_GC_HEAP_LIMIT=8 PERRY_GC_INCREMENTAL=0 PERRY_CONSERVATIVE_STACK_SCAN=off \ | |
| "/tmp/$name" > "/tmp/$name.out" 2> "/tmp/$name.err" | |
| diff "/tmp/$name.oracle" "/tmp/$name.out" \ | |
| || { echo "::error::$name output diverged from the pinned oracle"; exit 1; } | |
| errs="$errs /tmp/$name.err" | |
| pass=$((pass+1)) | |
| done | |
| # Derived from the glob, not hardcoded: a literal goes stale the | |
| # moment a probe is added (it did — 09_try_catch_roots), and if it is | |
| # ever lowered to match it silently stops asserting full coverage. | |
| echo "statepoint forced-evacuation matrix: $pass/$total" | |
| [ "$total" -gt 0 ] \ | |
| || { echo "::error::no probes matched — the matrix ran on nothing"; exit 1; } | |
| [ "$pass" -eq "$total" ] | |
| # Liveness assert 2: at least one probe actually collected (gcmetric | |
| # lines go to stderr). Collected during the loop rather than globbed | |
| # as /tmp/0*.err, which silently depends on every probe name starting | |
| # 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. | |
| # #7335: was 09_try_catch_roots, which the bridge now refuses (#7330). | |
| # Any non-try probe exercises the same report assertions. | |
| probe=benchmarks/gc_ratchet/probes/01_nursery_churn.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 | |
| # #7335: the explicit bridge cannot root an `invoke`, so since #7330 it | |
| # REFUSES a try-carrying probe rather than emitting a frame with no | |
| # roots (#7327). Skip 09 here; the RS4GC job covers it, and the step | |
| # below asserts the refusal actually happens. | |
| [ "$(basename "$probe")" = "09_try_catch_roots.ts" ] && continue | |
| 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 | |
| # #7335: the explicit bridge cannot root an `invoke`, so since #7330 it | |
| # REFUSES a try-carrying probe rather than emitting a frame with no | |
| # roots (#7327). Skip 09 here; the RS4GC job covers it, and the step | |
| # below asserts the refusal actually happens. | |
| [ "$(basename "$probe")" = "09_try_catch_roots.ts" ] && continue | |
| 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 \ | |
| PERRY_GC_HEAP_LIMIT=8 PERRY_GC_INCREMENTAL=0 PERRY_CONSERVATIVE_STACK_SCAN=off \ | |
| "/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 | |
| # #7335: the explicit bridge cannot root an `invoke`, so since #7330 it | |
| # REFUSES a try-carrying probe rather than emitting a frame with no | |
| # roots (#7327). Skip 09 here; the RS4GC job covers it, and the step | |
| # below asserts the refusal actually happens. | |
| [ "$(basename "$probe")" = "09_try_catch_roots.ts" ] && continue | |
| 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 \ | |
| PERRY_GC_HEAP_LIMIT=8 PERRY_GC_INCREMENTAL=0 PERRY_CONSERVATIVE_STACK_SCAN=off \ | |
| "/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 \ | |
| PERRY_GC_HEAP_LIMIT=8 PERRY_GC_INCREMENTAL=0 PERRY_CONSERVATIVE_STACK_SCAN=off \ | |
| "/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. | |
| # #7335 / #7327: the bridge cannot express a statepoint on an `invoke`, so | |
| # since #7330 it refuses a try-carrying module rather than emitting a frame | |
| # whose roots the collector cannot see. Assert the refusal HAPPENS — a skip | |
| # that is not also checked is just missing coverage, and this is the one | |
| # construct where the bridge is known to be unable to root anything. | |
| - name: The bridge must refuse a try-carrying probe, not silently skip it | |
| if: ${{ !cancelled() }} | |
| run: | | |
| set -uo pipefail | |
| probe=benchmarks/gc_ratchet/probes/09_try_catch_roots.ts | |
| if PERRY_STATEPOINTS=1 ./target/perry-dev/perry "$probe" \ | |
| -o /tmp/should-not-exist > /tmp/refuse.log 2>&1; then | |
| echo "::error::the bridge COMPILED a try-carrying probe. Either it learned" | |
| echo "::error::invokes (delete this step and re-enable 09 above) or it is" | |
| echo "::error::emitting unrooted frames again (#7327)." | |
| exit 1 | |
| fi | |
| if ! grep -q "7327" /tmp/refuse.log; then | |
| echo "::error::the bridge failed on $probe, but not with the #7327 refusal:" | |
| tail -20 /tmp/refuse.log | |
| exit 1 | |
| fi | |
| echo "bridge refused the try-carrying probe, as expected (#7327)" | |
| # #7336: the evacuation arm was VACUOUS. The probes drive collection with | |
| # `gc()`, which takes `manual_collect` — a full mark-sweep behind a forced | |
| # conservative scan — and `PERRY_GC_FORCE_EVACUATE` is read only on the | |
| # MINOR path. Measured: `copied_objects` and `moved_objects` were 0 on | |
| # every probe, while `--require-fp-walks` passed because it asserts a walk | |
| # HAPPENED, not that it FOUND anything. That is #6942/#6946 repeating, the | |
| # one CLAUDE.md records as costing months of meaningless green. | |
| # | |
| # The arms above now drive the minor path. This asserts they actually | |
| # moved something, so the gate fails if it ever goes inert again. | |
| - name: The evacuation arm must actually evacuate | |
| if: ${{ !cancelled() }} | |
| run: | | |
| set -uo pipefail | |
| export PERRY_RUNTIME_DIR="$PWD/target/perry-dev" | |
| export PERRY_NO_AUTO_OPTIMIZE=1 | |
| fail=0 | |
| for probe in benchmarks/gc_ratchet/probes/*.ts; do | |
| name=$(basename "$probe" .ts) | |
| [ "$name" = "09_try_catch_roots" ] && continue | |
| PERRY_STATEPOINTS=1 ./target/perry-dev/perry "$probe" -o "/tmp/ev-$name" >/dev/null 2>&1 || continue | |
| PERRY_STATEPOINTS=1 PERRY_GC_FORCE_EVACUATE=1 \ | |
| PERRY_GC_HEAP_LIMIT=8 PERRY_GC_INCREMENTAL=0 PERRY_CONSERVATIVE_STACK_SCAN=off \ | |
| PERRY_GC_DIAG=1 "/tmp/ev-$name" > /dev/null 2> "/tmp/ev-$name.err" || true | |
| python3 scripts/gc_evacuation_liveness_assert.py "/tmp/ev-$name.err" --probe "$name" || fail=1 | |
| done | |
| exit $fail | |
| 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 \ | |
| PERRY_GC_HEAP_LIMIT=8 PERRY_GC_INCREMENTAL=0 PERRY_CONSERVATIVE_STACK_SCAN=off \ | |
| "/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." |