Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 60 additions & 11 deletions .github/workflows/gc-native-roots.yml
Original file line number Diff line number Diff line change
Expand Up @@ -119,11 +119,24 @@
# objects copied, and the whole step passes. The collector was
# never the problem.
#
# ubuntu-24.04-arm REAL DEFECT, filed as #7984. `PERRY_STACKMAP_WALKER=verify`
# caught the fast fp-chain walker and the unwinder resolving the
# same root to addresses 96 bytes apart. This arm is red because
# it found the bug it was built to find; it must STAY red until
# #7984 is fixed. Do not skip it.
# ubuntu-24.04-arm REAL DEFECT, filed as #7984 and FIXED (#7997).
# `PERRY_STACKMAP_WALKER=verify` caught the fast fp-chain
# walker and the unwinder resolving the same root 96 bytes
# apart, and the fast walker — the one that runs when `verify`
# is off — was the wrong one. Two blind spots in
# `fp_to_sp_offset`, both only reachable with SVE on, which is
# what `-mcpu=native` turns on for a Neoverse-class core and
# nothing on macOS or x86-64 ever does: a callee-save store
# ended the prologue's stack-adjustment run, and
# `addvl sp, sp, #-N` was not decoded at all. The 96 was never
# a constant — it is that frame's missed tail, and it scales
# with the vector length (208 at VL = 64 B).
#
# This arm should now be GREEN. If it goes red again, read the
# report `verify` prints before assuming a regression: it names
# the frame, the base register, both resolved bases and the
# prologue words, which is enough to say which walker is wrong
# without a second run.
Comment on lines +122 to +139

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Check whether the gc-native-roots ubuntu-24.04-arm arm has a completed run on this PR branch.
set -euo pipefail

branch="$(git rev-parse --abbrev-ref HEAD)"
gh run list --workflow gc-native-roots.yml --branch "$branch" \
  --limit 10 --json databaseId,status,conclusion,createdAt

for id in $(gh run list --workflow gc-native-roots.yml --branch "$branch" --limit 5 --json databaseId --jq '.[].databaseId'); do
  echo "== run $id =="
  gh run view "$id" --json jobs --jq '.jobs[] | {name, status, conclusion}'
done

Repository: PerryTS/perry

Length of output: 154


🏁 Script executed:

set -euo pipefail
printf '%s\n' '== repository state =='
git rev-parse --show-toplevel
git rev-parse --abbrev-ref HEAD
git remote -v | head -n 4

printf '%s\n' '== workflow references =='
git ls-files .github/workflows/gc-native-roots.yml
sed -n '110,160p' .github/workflows/gc-native-roots.yml

printf '%s\n' '== pull request context =='
gh pr view --json number,headRefName,headRepository,baseRefName,state,statusCheckRollup \
  --jq '{number,headRefName,headRepository,baseRefName,state,statusCheckRollup}'

Repository: PerryTS/perry

Length of output: 3708


🏁 Script executed:

set -euo pipefail
sha="$(git rev-parse HEAD)"
echo "HEAD=$sha"
git show -s --format='subject=%s%ncommitter=%cI' HEAD

echo '== runs for checked-out commit =='
gh run list --workflow gc-native-roots.yml --commit "$sha" --limit 20 \
  --json databaseId,headSha,status,conclusion,createdAt,updatedAt \
  --jq '.[]'

echo '== recent workflow runs and jobs =='
gh run list --workflow gc-native-roots.yml --limit 20 \
  --json databaseId,headSha,status,conclusion,createdAt,updatedAt \
  --jq '.[]' |
while IFS= read -r run; do
  id="$(printf '%s' "$run" | jq -r .databaseId)"
  printf '%s\n' "$run"
  gh run view "$id" --json jobs \
    --jq '.jobs[] | {name, status, conclusion, startedAt, completedAt}'
done

Repository: PerryTS/perry

Length of output: 10804


Do not mark ubuntu-24.04-arm as GREEN yet.

The current commit's workflow run is still pending. Recent completed runs on the real arm runner ended in failure or cancellation. Record the QEMU evidence separately and update the ledger only after a real-arm run passes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/gc-native-roots.yml around lines 122 - 139, The ledger
entry for ubuntu-24.04-arm must not claim GREEN while the current real-arm
workflow run is pending. Record the QEMU result separately, retain the arm
runner’s failure or cancellation status, and change the entry to GREEN only
after a completed real-arm run passes.

#
# windows-latest REAL DEFECT, filed as #7985 (`perry.exe` cannot link
# against the official LLVM 22 release: /MT-vs-/MD CRT mismatch,
Expand All @@ -133,11 +146,11 @@
# `D:\a\_temp` as a remote host — was a workflow bug and is
# fixed here with `--force-local`.
#
# So: after #7970 the macOS and ubuntu-latest arms should pass and the other two
# should remain red on their filed defects. This workflow is therefore NOT a
# promotion candidate yet — promoting it while #7984/#7985 are open would block
# every PR. Promote only once all four arms are green, and per CLAUDE.md, run it
# green once BEFORE adding it to branch protection.
# So: after #7970 and #7997 three of the four arms should pass and only
# windows-latest should remain red, on #7985. This workflow is therefore still
# NOT a promotion candidate — promoting it while #7985 is open would block every
# PR. Promote only once all four arms are green, and per CLAUDE.md, run it green
# once BEFORE adding it to branch protection.
name: gc-native-roots
on:
# Must run where it can actually gate something. Branch-scoped triggers were
Expand Down Expand Up @@ -262,6 +275,42 @@ jobs:
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

# The two aarch64 walkers, over a frame this repository wrote, on the host
# that has to walk it.
#
# Until this step, NOTHING `cargo test` runs ever called `fp_chain::visit`
# or `unwind::visit` — their only exercise anywhere was `verify` mode in
# the step further down, inside a workflow that has never had a successful
# run on any branch (#7970). The unit tests cover the decoder and the
# matcher; the step that turns a `(register, offset)` pair into a stack
# address had none, which is why #7984 could only ever be found by
# compiling TypeScript and collecting.
#
# This asks the same question in seconds and without a compiler: given a
# frame whose layout is known — the aarch64-ELF one, where LLVM puts the
# `x29,x30` pair below the other callee-saves, and the Mach-O one, where
# it sits at the top — do both walkers land on the word the record names?
# It runs BEFORE the probe matrix so a walker defect is reported as a
# walker defect rather than as an oracle diff twenty minutes later.
- name: Walker agreement (aarch64 hosts)
if: ${{ !cancelled() && matrix.arch == 'aarch64' && runner.os != 'Windows' }}
run: |
set -euo pipefail
export RUSTFLAGS="-C force-frame-pointers=yes -C force-unwind-tables=yes"
cargo test --profile perry-dev -p perry-runtime --lib gc::roots::stack_maps \
-- --test-threads=1 | tee /tmp/walker-agreement.log
# `--lib <filter>` is a substring match, so a rename makes it select
# nothing and `cargo test` still exits 0 having run zero tests — a
# gate that cannot fail. Require each test by name, including the
# sabotage arm that proves the sentinel check discriminates.
for name in both_walkers_resolve_an_sp_root_in_an_elf_shaped_frame \
both_walkers_resolve_an_sp_root_in_a_darwin_shaped_frame \
a_wrong_frame_offset_is_caught; do
grep -q "walker_agreement::$name ... ok" /tmp/walker-agreement.log \
|| { echo "::error::$name did not run — the filter matched nothing"; exit 1; }
done
Comment on lines +303 to +312

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Require the fallback test by name as well.

The loop exists so that a rename cannot leave the gate green with zero tests selected. an_undecodable_prologue_declines_the_fast_walk_and_the_unwinder_still_answers is missing from the list. That test pins the unwinder fallback the SVE fix depends on, so a rename of it reopens exactly the hole this loop closes.

🛠️ Proposed fix
           for name in both_walkers_resolve_an_sp_root_in_an_elf_shaped_frame \
                       both_walkers_resolve_an_sp_root_in_a_darwin_shaped_frame \
-                      a_wrong_frame_offset_is_caught; do
+                      a_wrong_frame_offset_is_caught \
+                      an_undecodable_prologue_declines_the_fast_walk_and_the_unwinder_still_answers; do
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
# `--lib <filter>` is a substring match, so a rename makes it select
# nothing and `cargo test` still exits 0 having run zero tests — a
# gate that cannot fail. Require each test by name, including the
# sabotage arm that proves the sentinel check discriminates.
for name in both_walkers_resolve_an_sp_root_in_an_elf_shaped_frame \
both_walkers_resolve_an_sp_root_in_a_darwin_shaped_frame \
a_wrong_frame_offset_is_caught; do
grep -q "walker_agreement::$name ... ok" /tmp/walker-agreement.log \
|| { echo "::error::$name did not run — the filter matched nothing"; exit 1; }
done
# `--lib <filter>` is a substring match, so a rename makes it select
# nothing and `cargo test` still exits 0 having run zero tests — a
# gate that cannot fail. Require each test by name, including the
# sabotage arm that proves the sentinel check discriminates.
for name in both_walkers_resolve_an_sp_root_in_an_elf_shaped_frame \
both_walkers_resolve_an_sp_root_in_a_darwin_shaped_frame \
a_wrong_frame_offset_is_caught \
an_undecodable_prologue_declines_the_fast_walk_and_the_unwinder_still_answers; do
grep -q "walker_agreement::$name ... ok" /tmp/walker-agreement.log \
|| { echo "::error::$name did not run — the filter matched nothing"; exit 1; }
done
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/gc-native-roots.yml around lines 303 - 312, Update the
test-name list in the loop validating /tmp/walker-agreement.log to include
an_undecodable_prologue_declines_the_fast_walk_and_the_unwinder_still_answers,
so the workflow fails when that fallback test is renamed or not executed.


- name: Probe matrix, RS4GC mode, forced evacuation
if: ${{ !cancelled() }}
run: |
Expand Down Expand Up @@ -515,7 +564,7 @@ jobs:
"/tmp/rs4gc-$name" > "/tmp/walker-$name-$mode.out" \
2> "/tmp/walker-$name-$mode.err" \
|| { echo "::error::$name crashed under PERRY_STACKMAP_WALKER=$mode"; \
tail -20 "/tmp/walker-$name-$mode.err"; exit 1; }
tail -120 "/tmp/walker-$name-$mode.err"; exit 1; }
diff "/tmp/rs4gc-$name.oracle" "/tmp/walker-$name-$mode.out" \
|| { echo "::error::$name diverged from the pinned oracle under PERRY_STACKMAP_WALKER=$mode"; exit 1; }
checked=$((checked+1))
Expand Down
90 changes: 90 additions & 0 deletions changelog.d/7997-verify-walker-report.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
### aarch64: the fast GC stack-map walker misread every SVE-shaped prologue (#7984)

`PERRY_STACKMAP_WALKER=verify` caught the frame-pointer-chain walker and the
Itanium unwinder resolving the same GC root 96 bytes apart on
`ubuntu-24.04-arm`. The fp-chain walker is the wrong one — it is also the one
that runs when `verify` is off — and it was blind twice in the same prologue.

Reproduced end to end on aarch64 Linux. The trigger is the tuning, not the
distro: Perry builds a host binary with `-mcpu=native`, and on a Neoverse-class
core that turns SVE on, which changes the shape LLVM emits. The same probe built
`-mcpu=neoverse-n1` passes; no Apple arm can see it at all. Measured on `main`
— the module body, 100 stack-map records — built `-mcpu=neoverse-n2`:

```
12479c: add x29, sp, #0x20 <- fp established; the decoder read 32
1247a0: stp x28, x27, [sp, #48] <- not a `sub sp`, so the run ended HERE
... four more callee-save pairs
1247b4: sub sp, sp, #0x50 <- 80 bytes, dropped
1247b8: addvl sp, sp, #-2 <- and two vector lengths more, dropped
```

1. **A callee-save store ended the accumulation run.** It does not move sp, so
it says nothing about whether the prologue's stack adjustments are finished,
but the rule was "the first instruction that is not a `sub sp` ends the run".
Stores through sp with no writeback are now transparent to it, enumerated by
opcode so an unrecognised instruction still ends the run — the safe
direction.
2. **`addvl`/`addpl` writing sp was not decoded at all.** Its multiplier is in
the instruction; its unit is the runtime SVE vector length, which is not.
That is now read once via `prctl(PR_SVE_GET_VL)` — a syscall rather than
`rdvl`, which faults on a core without SVE — and cached. Where it cannot be
read the whole decode fails and the frame goes to the platform unwinder,
which reads DWARF CFI and needs no VG for an fp-based frame.

**96 was never a constant.** It is that frame's missed tail and it scales with
the vector length: 96 on the runner (`0x50 + 1 x 16`), 208 under `qemu -cpu max`
at VL = 64 (`0x50 + 2 x 64`). A fix keyed on 96 would have been wrong on every
other vector length.

Validated at two vector lengths so the scaling is pinned rather than one
machine's answer: 28 `verify` runs (14 probes x VL 16 B and 64 B) under
`qemu-aarch64 -cpu max`, all byte-exact against the pinned Node oracle. The fast
walker is live there — `11_collect_at_depth` reports `fp_walks 12`,
`fallback_walks 0`, `records_matched 1338`, `locations_visited 2678` with
`verify` green, so 2678 slots were cross-checked against the unwinder and
agreed.

### `PERRY_STACKMAP_WALKER=verify` now names the disagreement it finds

All the gate could report was `fast walk visited 1 unique slots, unwinder
visited 1` and two addresses in decimal — not the frame, not the base register,
not the function whose prologue was decoded, and therefore not which walker was
wrong. Every candidate explanation predicts exactly that output.

Both walkers now hand back a `ResolvedRoot` rather than a bare
`MutableRootSlot`: the address, plus the frame return address it was matched on,
the record's function, the map's base register and frame offset, and the base
that walker resolved that register to. `visit_stack_map_root_slots` projects it
straight back, so the collector's view is unchanged. On a mismatch `verify`
prints every root from both walks, states that an equal slot count means a
*base* disagreement rather than a missed frame (with the per-slot delta), prints
each walk's frame and record counts so an early-terminating walk is
distinguishable, and on aarch64 dumps `fp_to_sp_offset`'s decode together with
the prologue words it read. That report is what identified #7984 in one run.

The prologue dump is gated on the parsed map vouching for the function address.
The first draft was not, and a unit test with a synthetic address turned the
diagnostic into a SIGSEGV with no output — which is what would happen in the
field for the one failure mode where a report matters most, a map whose
addresses are wrong.

### The two aarch64 walkers now have unit coverage at all

Nothing `cargo test` runs had ever called `fp_chain::visit` or `unwind::visit`;
the decoder and the matcher were covered, the step that turns a
`(register, offset)` pair into a stack address was not.
`stack_maps_walker_agreement.rs` drives both over `global_asm!` probe frames in
the two real layouts — the aarch64-ELF one with the frame record in the middle
of the frame, and the Mach-O one with it at the top — and requires each walker
to land on a word **holding a sentinel** the probe wrote into the slot its
record names. Set equality is satisfied by two empty sets and by two identically
wrong ones; the slot's contents are the discriminating quantity.
`a_wrong_frame_offset_is_caught` is the sabotage arm, and a third test pins that
an undecodable prologue declines the fast walk while the unwinder still
resolves the root — the fallback the SVE fix rests on.

`gc-native-roots.yml` runs them on both aarch64 arms, before the probe matrix,
requiring each test by name (`--lib <filter>` is a substring match, so a rename
would select nothing and cargo would still exit 0). Its crash path tailed 20
lines of the failing run's stderr, which truncates the report; now 120.
Loading
Loading