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
94 changes: 82 additions & 12 deletions .github/workflows/gc-native-roots.yml
Original file line number Diff line number Diff line change
Expand Up @@ -97,11 +97,14 @@ on:
workflow_dispatch:

jobs:
# Both host shapes Perry supports for native roots, on one job. macOS covers
# Every host shape Perry supports for native roots, on one job. macOS covers
# aarch64 + Mach-O; ubuntu covers x86-64 + ELF — and ELF is where every
# object-format bug in this design surfaced (SHF_GNU_RETAIN, SHF_WRITE, the
# Mach-O underscore convention in eh_walker). ARM64 Linux would cover the
# fourth corner, but those runners queue for hours here, and its two
# Mach-O underscore convention in eh_walker). Windows covers x86-64 + PE/COFF
# with the RtlVirtualUnwind walker (#7354) — the one walker with no Itanium
# unwinder under it, which is why its arm alone carries the
# `--require-locations` telemetry gate below. ARM64 Linux would cover a
# fifth corner, but those runners queue for hours here, and its two
# components are each covered above.
native-roots-rs4gc:
strategy:
Expand All @@ -114,7 +117,15 @@ jobs:
- os: ubuntu-latest
arch: x86-64
format: ELF
- os: windows-latest
arch: x86-64
format: PE
runs-on: ${{ matrix.os }}
# The ubuntu/macos steps were written for bash and windows-latest defaults
# to pwsh; one explicit default keeps a single script dialect per step.
defaults:
run:
shell: bash
# 120, not 90: the in-process step below builds a second time with the
# llvm-inprocess feature, which cargo cannot share with the build above.
timeout-minutes: 120
Expand Down Expand Up @@ -145,9 +156,23 @@ jobs:
# `nocreateundeforpoison`, Homebrew opt 22 feeding Apple clang, which
# is the pairing Perry's own independent discovery picks by default on
# a Mac. Anyone enabling this knob hits that; pin both here.
exe=""
if [ "$RUNNER_OS" = "macOS" ]; then
brew list llvm >/dev/null 2>&1 || brew install llvm
llvm_bin="$(brew --prefix llvm)/bin"
elif [ "$RUNNER_OS" = "Windows" ]; then
# windows-latest ships clang (the NSIS LLVM build) but NOT `opt`;
# the matched pair comes from the official clang+llvm release
# archive — one directory, so opt and clang cannot skew.
exe=".exe"
llvm_ver=22.1.3
llvm_root="$RUNNER_TEMP/clang+llvm-$llvm_ver-x86_64-pc-windows-msvc"
if [ ! -x "$llvm_root/bin/opt.exe" ]; then
curl -sSL --retry 3 -o "$RUNNER_TEMP/llvm.tar.xz" \
"https://github.com/llvm/llvm-project/releases/download/llvmorg-$llvm_ver/clang+llvm-$llvm_ver-x86_64-pc-windows-msvc.tar.xz"
tar -xJf "$RUNNER_TEMP/llvm.tar.xz" -C "$RUNNER_TEMP"
fi
llvm_bin="$llvm_root/bin"
else
# Ubuntu ships a matched opt+clang pair; take the newest present,
# and install one only if the image has none.
Expand All @@ -160,12 +185,12 @@ jobs:
llvm_bin="$(dirname "$(command -v opt)")"
fi
fi
if [ ! -x "$llvm_bin/opt" ] || [ ! -x "$llvm_bin/clang" ]; then
if [ ! -x "$llvm_bin/opt$exe" ] || [ ! -x "$llvm_bin/clang$exe" ]; then
echo "::error::no matched opt+clang pair under $llvm_bin — RS4GC cannot run, and silently skipping it is exactly the gate that cannot fail"
exit 1
fi
export PERRY_LLVM_OPT="$llvm_bin/opt"
export PERRY_LLVM_CLANG="$llvm_bin/clang"
export PERRY_LLVM_OPT="$llvm_bin/opt$exe"
export PERRY_LLVM_CLANG="$llvm_bin/clang$exe"
echo "RS4GC toolchain: $llvm_bin"
"$PERRY_LLVM_OPT" --version | head -2
"$PERRY_LLVM_CLANG" --version | head -2
Expand All @@ -176,22 +201,51 @@ jobs:
for probe in benchmarks/gc_ratchet/probes/*.ts; do
total=$((total+1))
name=$(basename "$probe" .ts)
if [ "$RUNNER_OS" = "Windows" ] && [ "$name" = "09_try_catch_roots" ]; then
# #7354 measured negative, pinned as a REFUSAL: windows-msvc
# `try` lowers to WinEH funclet pads, which crash LLVM's
# rewrite-statepoints-for-gc outright (access violation on opt
# 22.1.3, reproducible from an eight-line module). Perry refuses
# the module before the pass runs; this arm pins that it STAYS a
# refusal — never a crash, never a silently rootless binary. It
# goes red the day the pass learns funclet EH, which is the
# prompt to fold 09 into this matrix.
if PERRY_RS4GC=1 ./target/perry-dev/perry "$probe" \
-o "/tmp/rs4gc-$name" > "/tmp/rs4gc-$name.compile.log" 2>&1; then
echo "::error::$name compiled under RS4GC on Windows — the funclet refusal is gone: either rewrite-statepoints-for-gc learned funclet EH (fold 09 into the matrix) or the refusal was lost"
exit 1
fi
grep -q "funclet" "/tmp/rs4gc-$name.compile.log" \
|| { echo "::error::$name failed for a reason other than the funclet refusal:"; cat "/tmp/rs4gc-$name.compile.log"; exit 1; }
pass=$((pass+1))
continue
fi
node --expose-gc --experimental-strip-types "$probe" > "/tmp/rs4gc-$name.oracle"
PERRY_RS4GC=1 ./target/perry-dev/perry "$probe" -o "/tmp/rs4gc-$name"
# perry appends the platform default extension to an -o with none.
out="/tmp/rs4gc-$name$exe"
if [ "$RUNNER_OS" = "macOS" ]; then
otool -l "/tmp/rs4gc-$name" | grep -q "sectname __perry_gcmap" \
otool -l "$out" | grep -q "sectname __perry_gcmap" \
|| { echo "::error::$name has no __perry_gcmap section — RS4GC produced no native root map"; exit 1; }
otool -l "/tmp/rs4gc-$name" | grep -q "sectname __llvm_stackmaps" \
otool -l "$out" | grep -q "sectname __llvm_stackmaps" \
&& { echo "::error::$name still carries __llvm_stackmaps — the compact rewrite did not run"; exit 1; }
elif [ "$RUNNER_OS" = "Windows" ]; then
# PE: an image section header holds 8 name bytes — which is why
# the section is `.pgcmap` (gc_map.rs) — and a surviving LLVM
# stackmap section would appear truncated, so match the prefix.
"$llvm_bin/llvm-readobj$exe" --sections "$out" | grep -q "Name: .pgcmap" \
|| { echo "::error::$name has no .pgcmap section — RS4GC produced no native root map"; exit 1; }
"$llvm_bin/llvm-readobj$exe" --sections "$out" | grep -q "llvm_st" \
&& { echo "::error::$name still carries an llvm_stackmaps section — the compact rewrite did not run"; exit 1; }
else
readelf -S "/tmp/rs4gc-$name" | grep -q "\.perry_gcmap" \
readelf -S "$out" | grep -q "\.perry_gcmap" \
|| { echo "::error::$name has no .perry_gcmap section — RS4GC produced no native root map"; exit 1; }
readelf -S "/tmp/rs4gc-$name" | grep -q "\.llvm_stackmaps" \
readelf -S "$out" | grep -q "\.llvm_stackmaps" \
&& { echo "::error::$name still carries .llvm_stackmaps — the compact rewrite did not run"; exit 1; }
fi
PERRY_RS4GC=1 PERRY_GC_FORCE_EVACUATE=1 PERRY_GC_VERIFY_EVACUATION=1 \
PERRY_GC_HEAP_LIMIT=8 PERRY_GC_INCREMENTAL=0 PERRY_CONSERVATIVE_STACK_SCAN=off \
"/tmp/rs4gc-$name" > "/tmp/rs4gc-$name.out" 2> "/tmp/rs4gc-$name.err"
"$out" > "/tmp/rs4gc-$name.out" 2> "/tmp/rs4gc-$name.err"
diff "/tmp/rs4gc-$name.oracle" "/tmp/rs4gc-$name.out" \
|| { echo "::error::$name diverged from the pinned oracle under RS4GC"; exit 1; }
errs="$errs /tmp/rs4gc-$name.err"
Expand All @@ -213,9 +267,25 @@ jobs:
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 \
# windows-latest exposes the toolcache python as `python`, not python3.
py=python3; command -v python3 >/dev/null 2>&1 || py=python
"$py" scripts/statepoint_report_assert.py /tmp/rs4gc-report.json \
--only-backend rs4gc

# #7354: the Windows walker liveness gate. It is the one walker with
# no Itanium unwinder under it and no verify-mode cross-check, and a
# walker that visits zero frames still lets most probes print the
# right answer (other root sources cover them). Non-zero
# frames/records/locations telemetry is the only proof it ran.
if [ "$RUNNER_OS" = "Windows" ]; then
PERRY_GC_TRACE=1 PERRY_RS4GC=1 \
PERRY_GC_FORCE_EVACUATE=1 PERRY_GC_VERIFY_EVACUATION=1 \
PERRY_GC_HEAP_LIMIT=8 PERRY_GC_INCREMENTAL=0 PERRY_CONSERVATIVE_STACK_SCAN=off \
"/tmp/rs4gc-04_dead_after_deep_stack$exe" > /dev/null 2> /tmp/rs4gc-trace.err
"$py" scripts/gc_walker_trace_assert.py /tmp/rs4gc-trace.err \
--require-locations
fi

# #7327. Everything above pins PERRY_LLVM_OPT + PERRY_LLVM_CLANG to one
# brew install, because RS4GC piped IR through an external `opt` and a
# newer `opt` emits attributes an older `clang` cannot parse. That made
Expand Down
18 changes: 18 additions & 0 deletions changelog.d/7355-windows-gc-walker.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
`PERRY_RS4GC=1` native GC roots now work on x86-64 Windows (#7354): a new
`RtlVirtualUnwind`-based stack walker in `gc/roots/stack_maps.rs` steps native
frames and enumerates `.pgcmap` root slots, so the COFF refusal in
`compact_and_assemble` is lifted for `x86_64-pc-windows-msvc` (ARM64 Windows
stays refused — it still has no walker). Verified on a real Windows host: 9/9
runnable gc-ratchet probes byte-match the pinned Node oracle under forced
evacuation + evacuation verification, with walker telemetry live (probe 04:
5,626 frames visited, 5,449 records matched). `gc-native-roots.yml` gains a
`windows-latest` arm with a `--require-locations` telemetry liveness gate.

Two Windows-only compile hazards found on the way are now handled fail-closed:
the unguarded `eh_walker` calls in `exception.rs` that broke the whole Windows
build of perry-runtime are cfg-gated, and RS4GC refuses modules whose `try`
lowered to WinEH funclet pads *before* piping them to LLVM —
`rewrite-statepoints-for-gc` crashes outright on funclet EH (access violation
in opt 22.1.3, eight-line upstream repro in the PR). A failed RS4GC opt
pipeline now also writes its input IR to disk with a one-command repro line
instead of leaving only a symbol-less stack dump.
53 changes: 33 additions & 20 deletions crates/perry-codegen/src/gc_map.rs
Original file line number Diff line number Diff line change
Expand Up @@ -909,22 +909,24 @@ pub fn compact_and_assemble(
than report anything. Tracked for #7173."
));
}
// Windows is staged, not enabled. The compiler can emit a COFF `.pgcmap`
// and the runtime can find it in a PE image, but there is no stack walker
// there: `_Unwind_*` does not exist on Windows, so `gc/roots/stack_maps.rs`
// falls to the stub and no frame is ever visited.
// Windows x86-64 is enabled (#7354): the runtime walks native frames there
// with `RtlVirtualUnwind` (`gc/roots/stack_maps.rs`), verified on a real
// Windows host against the pinned oracle with non-zero walk telemetry.
//
// Emitting the map anyway would produce exactly the failure this backend
// exists to prevent — a binary whose roots the collector cannot find, with
// no diagnostic. Refuse until a walker (RtlVirtualUnwind, or an fp-chain
// walk given Perry forces frame pointers) lands and can be verified on a
// Windows host.
if matches!(format_for(target), ObjectFormat::Coff) {
// ARM64 Windows stays refused. It passes the `arch_supported` check above
// (aarch64) and it is COFF, but the runtime's Windows walker is x86-64
// only — the `CONTEXT` layout and the unwinder's register model differ on
// ARM64 — so that combination still has NO walker and falls to the stub
// that visits nothing. Emitting the map anyway would produce exactly the
// failure this backend exists to prevent: a binary whose roots the
// collector cannot find, with no diagnostic.
if matches!(format_for(target), ObjectFormat::Coff) && !target.starts_with("x86_64") {
return Err(anyhow!(
"perry: native GC roots (PERRY_RS4GC) are not enabled for target \
`{target}` yet — the COFF section and its PE lookup exist, but the \
runtime has no stack walker on Windows, so no frame would ever be \
visited and the collector would free live objects. Tracked for #7173."
runtime's Windows stack walker is x86-64 only, so no frame would \
ever be visited and the collector would free live objects. \
Tracked for #7173."
));
}

Expand Down Expand Up @@ -1064,22 +1066,33 @@ mod tests {
fn compact_and_assemble_refusal(target: &str) -> String {
// Mirrors the guard in `compact_and_assemble`; kept here so the test
// fails if that guard is removed rather than if a string changes.
if matches!(format_for(target), ObjectFormat::Coff) {
if matches!(format_for(target), ObjectFormat::Coff) && !target.starts_with("x86_64") {
return format!(
"perry: native GC roots (PERRY_RS4GC) are not enabled for target \
`{target}` yet — the runtime has no stack walker on Windows"
`{target}` yet — the runtime's Windows stack walker is x86-64 only"
);
}
String::new()
}

#[test]
fn windows_is_refused_until_it_has_a_walker() {
// The section and its PE lookup exist, but Windows has no stack walker,
// so every frame would go unvisited and the collector would free live
// objects. Staged is not enabled.
let err = compact_and_assemble_refusal("x86_64-pc-windows-msvc");
assert!(err.contains("no stack walker"), "{err}");
fn x86_64_windows_is_no_longer_refused() {
// #7354: the RtlVirtualUnwind walker landed and was verified on a
// Windows host, so the COFF refusal must not fire for x86-64 — a
// refusal here would silently disable the platform the walker exists
// for.
assert_eq!(compact_and_assemble_refusal("x86_64-pc-windows-msvc"), "");
}

#[test]
fn arm64_windows_is_refused_until_it_has_a_walker() {
// ARM64 Windows passes the arch gate (aarch64) and is COFF, but the
// runtime's Windows walker is x86-64 only — the CONTEXT layout and
// unwinder register model differ on ARM64 — so every frame would go
// unvisited and the collector would free live objects. Staged is not
// enabled.
let err = compact_and_assemble_refusal("aarch64-pc-windows-msvc");
assert!(err.contains("x86-64 only"), "{err}");
}

#[test]
Expand Down
9 changes: 9 additions & 0 deletions crates/perry-codegen/src/inprocess.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,15 @@ pub fn compile_ll_to_object_inprocess(
module_name: &str,
) -> Result<Vec<u8>> {
let (opt, mcpu_native, explicit_cpu, mllvm, emit_asm) = interpret_plan_args(clang_style_args)?;
// Same guard as the external `opt` path (`linker::rs4gc_funclet_refusal`):
// rewrite-statepoints-for-gc crashes on WinEH funclet pads, and here the
// pass runs inside THIS process — the crash would take the compiler down
// with it, not just a child.
if crate::codegen::helpers::rs4gc_enabled() {
if let Some(refusal) = crate::linker::rs4gc_funclet_refusal(ll_text) {
return Err(anyhow!(refusal));
}
}
let context = Context::create();
let module = parse_ir_text(&context, ll_text, module_name)?;
optimize_and_emit(
Expand Down
56 changes: 55 additions & 1 deletion crates/perry-codegen/src/linker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -496,10 +496,43 @@ fn build_clang_compile_plan(
/// relocation, and downstream-use rewrite. Fails the compile loudly when no
/// `opt` is available or the pass pipeline errors — a silent skip would be a
/// vacuous mode.
/// The refusal for a module whose EH lowered to WinEH funclet pads under
/// RS4GC, or `None` when the module is safe to pipe through the pass.
///
/// windows-msvc `try` lowers to `catchswitch`/`catchpad` funclets (#7302), and
/// LLVM's `rewrite-statepoints-for-gc` does not support funclet EH: it crashes
/// outright — measured on opt 22.1.3 as an access violation (0xC0000005) with
/// a symbol-less stack dump, reproducible from an eight-line module carrying
/// one `invoke` that unwinds to a `catchswitch` (#7354). Detect the shape
/// BEFORE spawning the pass and name the actual limitation; the alternative on
/// the external path is a crash pointing at LLVM's bug tracker, and on the
/// in-process path it would take the whole compiler process down.
///
/// Matched on the ` within ` instruction syntax rather than the bare opcode
/// names so a user string literal containing "catchpad" cannot trip it.
pub(crate) fn rs4gc_funclet_refusal(ll_text: &str) -> Option<String> {
["catchswitch within ", "catchpad within ", "cleanuppad within "]
.iter()
.any(|needle| ll_text.contains(needle))
.then(|| {
"PERRY_RS4GC: this module contains a `try`/`catch` that lowered to \
WinEH funclet pads (catchswitch/catchpad — the windows-msvc EH \
shape), and LLVM's rewrite-statepoints-for-gc pass does not \
support funclet EH: it crashes with an access violation rather \
than reporting anything. Refusing before the pass runs. \
Compile without PERRY_RS4GC, or keep `try` out of RS4GC-compiled \
modules on Windows. Tracked in #7354."
.to_string()
})
}

fn maybe_rs4gc_preprocess(ll_text: &str) -> Result<Option<String>> {
if !crate::codegen::helpers::rs4gc_enabled() {
return Ok(None);
}
if let Some(refusal) = rs4gc_funclet_refusal(ll_text) {
return Err(anyhow!(refusal));
}
// The in-process backend runs RS4GC itself, against the same LLVM that
// emits the object (see `inprocess::optimize_and_emit`). Shelling out to a
// separate `opt` here as well would both duplicate the rewrite and
Expand Down Expand Up @@ -545,8 +578,29 @@ fn maybe_rs4gc_preprocess(ll_text: &str) -> Result<Option<String>> {
.write_all(ll_text.as_bytes())?;
let output = child.wait_with_output()?;
if !output.status.success() {
// The IR went to `opt` through a pipe, so unlike a failed clang
// compile nothing was on disk to debug from — an `opt` crash (probe
// 09 on Windows: access violation inside rewrite-statepoints-for-gc)
// left only a symbol-less stack dump. Write the exact input next to
// the other failure artifacts and name it, so the crash is
// reproducible with one command.
let ir_path = env::temp_dir().join(format!(
"perry_rs4gc_failed_{}.ll",
std::process::id()
));
let ir_note = match fs::write(&ir_path, ll_text) {
Ok(()) => format!("input IR left at: {}", ir_path.display()),
Err(error) => format!("(could not write input IR: {error})"),
};
return Err(anyhow!(
"PERRY_RS4GC: opt pipeline failed:\n{}",
"PERRY_RS4GC: opt pipeline failed ({}).\n{}\n\
reproduce: {} -passes='function(mem2reg),rewrite-statepoints-for-gc' -S {}\n\
\n\
stderr:\n{}",
output.status,
ir_note,
opt.display(),
ir_path.display(),
String::from_utf8_lossy(&output.stderr)
));
}
Expand Down
Loading
Loading