From 2a2c08afdc4b70543cc0ebe033308d88d29ccb5a Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Tue, 4 Aug 2026 10:28:25 +0200 Subject: [PATCH 1/2] =?UTF-8?q?gc(windows):=20RtlVirtualUnwind=20native-ro?= =?UTF-8?q?ot=20stack=20walker=20=E2=80=94=20PERRY=5FRS4GC=20works=20on=20?= =?UTF-8?q?x86-64=20Windows=20(#7354)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The COFF `.pgcmap` section and its PE lookup existed (#7351) but Windows had no stack walker — `_Unwind_*` does not exist there — so RS4GC was refused for every COFF target. This adds the missing walker and enables the platform: - `gc/roots/stack_maps.rs`: `#[cfg(windows, x86_64)] mod unwind` steps a hand-declared `CONTEXT` (layout pinned by compile-time offset asserts) outward with RtlLookupFunctionEntry + RtlVirtualUnwind. SP-relative roots read the frame's real Rsp — no CFA derivation. Fail-closed: only Win64 nonvolatile base registers are trusted, every slot is bounded by GetCurrentThreadStackLimits, a frame without unwind info or a non-outward step ends the walk. - exception.rs: the unguarded `eh_walker` calls (Itanium-only module) broke the whole Windows build of perry-runtime; now `#[cfg(not(windows))]`. - gc_map.rs: the COFF refusal narrows to non-x86-64 (ARM64 Windows still has no walker and must stay refused); `windows_is_refused_until_it_has_a_walker` replaced by `x86_64_windows_is_no_longer_refused` + the ARM64 pin. - linker.rs/inprocess.rs: WinEH funclet pads (windows-msvc `try` lowering) crash LLVM's rewrite-statepoints-for-gc outright (0xC0000005, opt 22.1.3, 8-line repro), so RS4GC now refuses funclet modules BEFORE the pass runs, and a failed opt pipeline writes its input IR to disk for reproduction. - gc-native-roots.yml: windows-latest arm (PE section asserts via llvm-readobj, probe 09 pinned as the funclet refusal, and a `--require-locations` walker-liveness telemetry gate). Verified on a real Windows host (opt+clang 22.1.3 one-dir pair, Node 26.5.1 SRI-matched): 9/9 runnable probes byte-match the oracle under PERRY_GC_FORCE_EVACUATE=1 PERRY_GC_VERIFY_EVACUATION=1 with 5.9k-90k objects copied per probe; telemetry shows the walker ran (probe 04: frames_visited=5626, records_matched=5449, locations_visited=22). --- .github/workflows/gc-native-roots.yml | 94 +++++++- crates/perry-codegen/src/gc_map.rs | 53 ++-- crates/perry-codegen/src/inprocess.rs | 9 + crates/perry-codegen/src/linker.rs | 56 ++++- crates/perry-codegen/src/linker_tests.rs | 25 ++ crates/perry-runtime/src/exception.rs | 12 +- .../perry-runtime/src/gc/roots/stack_maps.rs | 228 +++++++++++++++++- scripts/gc_walker_trace_assert.py | 41 +++- 8 files changed, 477 insertions(+), 41 deletions(-) diff --git a/.github/workflows/gc-native-roots.yml b/.github/workflows/gc-native-roots.yml index 7a3c72a2a5..c654aa18ee 100644 --- a/.github/workflows/gc-native-roots.yml +++ b/.github/workflows/gc-native-roots.yml @@ -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: @@ -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 @@ -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. @@ -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 @@ -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" @@ -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 diff --git a/crates/perry-codegen/src/gc_map.rs b/crates/perry-codegen/src/gc_map.rs index 1a0778c30d..8fb1959b04 100644 --- a/crates/perry-codegen/src/gc_map.rs +++ b/crates/perry-codegen/src/gc_map.rs @@ -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." )); } @@ -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] diff --git a/crates/perry-codegen/src/inprocess.rs b/crates/perry-codegen/src/inprocess.rs index b838b66828..2bcd87d8ce 100644 --- a/crates/perry-codegen/src/inprocess.rs +++ b/crates/perry-codegen/src/inprocess.rs @@ -103,6 +103,15 @@ pub fn compile_ll_to_object_inprocess( module_name: &str, ) -> Result> { 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( diff --git a/crates/perry-codegen/src/linker.rs b/crates/perry-codegen/src/linker.rs index 782adb7f40..af882de79a 100644 --- a/crates/perry-codegen/src/linker.rs +++ b/crates/perry-codegen/src/linker.rs @@ -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 { + ["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> { 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 @@ -545,8 +578,29 @@ fn maybe_rs4gc_preprocess(ll_text: &str) -> Result> { .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) )); } diff --git a/crates/perry-codegen/src/linker_tests.rs b/crates/perry-codegen/src/linker_tests.rs index b544e4e5cc..17e178a3d0 100644 --- a/crates/perry-codegen/src/linker_tests.rs +++ b/crates/perry-codegen/src/linker_tests.rs @@ -477,3 +477,28 @@ fn ll_content_hash_is_stable_for_fixed_input() { assert_eq!(ll_content_hash(""), 0xcbf2_9ce4_8422_2325); assert_eq!(ll_content_hash("a"), 0xaf63_dc4c_8601_ec8c); } + +#[test] +fn rs4gc_refuses_wineh_funclet_modules_before_the_pass_runs() { + // #7354: rewrite-statepoints-for-gc crashes (0xC0000005) on WinEH funclet + // pads — reproduced from an eight-line module with one `invoke` unwinding + // to a `catchswitch`. The refusal must fire on the funclet instructions... + let funclets = "\ + pad:\n %cs = catchswitch within none [label %catch] unwind to caller\n\ + catch:\n %cp = catchpad within %cs [ptr @filter]\n"; + let refusal = rs4gc_funclet_refusal(funclets).expect("funclet module must be refused"); + assert!(refusal.contains("funclet"), "{refusal}"); + assert!(refusal.contains("#7354"), "{refusal}"); + assert!( + rs4gc_funclet_refusal(" %cp = cleanuppad within none []\n").is_some(), + "cleanup funclets take the same crash path" + ); + + // ...and must NOT fire on the Itanium EH shape RS4GC supports, nor on a + // user string literal that merely names the opcode. + assert!(rs4gc_funclet_refusal(" %lp = landingpad { ptr, i32 } cleanup\n").is_none()); + assert!( + rs4gc_funclet_refusal("@str = constant [10 x i8] c\"catchpad!\00\"\n").is_none(), + "a string literal naming the opcode is not a funclet" + ); +} diff --git a/crates/perry-runtime/src/exception.rs b/crates/perry-runtime/src/exception.rs index 77ded62247..dbcbb48671 100644 --- a/crates/perry-runtime/src/exception.rs +++ b/crates/perry-runtime/src/exception.rs @@ -342,8 +342,16 @@ pub extern "C" fn js_throw(value: f64) -> ! { // success. Declines (undecodable frame, disabled, or verification // mode) fall through to the system unwinder below — same semantics, // slower. - crate::eh_walker::predict_before_raise(); - crate::eh_walker::try_fast_transport(crate::eh::exception_object_addr()); + // + // Not on Windows: `eh_walker` is Itanium-unwind machinery and the module + // is `#[cfg(not(windows))]`; `crate::eh` there is `eh_windows.rs`, whose + // `raise_perry_exception` below is the whole transport. These two calls + // landing unguarded is what broke the Windows build of this crate (#7354). + #[cfg(not(windows))] + { + crate::eh_walker::predict_before_raise(); + crate::eh_walker::try_fast_transport(crate::eh::exception_object_addr()); + } let reason = crate::eh::raise_perry_exception(); eprintln!( "perry: FATAL: exception transport failed (reason={reason}): a try \ diff --git a/crates/perry-runtime/src/gc/roots/stack_maps.rs b/crates/perry-runtime/src/gc/roots/stack_maps.rs index 491c1f6cdb..b11269f3d1 100644 --- a/crates/perry-runtime/src/gc/roots/stack_maps.rs +++ b/crates/perry-runtime/src/gc/roots/stack_maps.rs @@ -15,6 +15,9 @@ use super::{MutableRootSlot, MutableRootSlotKind}; use crate::gc::telemetry::RootSourcesTraceStats; +// The Windows walker spells `core::ffi::c_void` inline; this import serves +// the Itanium/pthread declarations, which do not exist there. +#[cfg(not(target_os = "windows"))] use std::ffi::c_void; use std::sync::OnceLock; @@ -101,7 +104,10 @@ const DWARF_REG_SP_AARCH64: u16 = 31; // The aarch64 case is easy to miss because `chain_walkable` is true there, so // the fast x29 walker normally runs and this path is only the fallback — an // eight-byte error would stay latent until the fast walk bailed. +// Unused on Windows: `RtlVirtualUnwind` hands back the frame's real `Rsp`, so +// the Windows walker never derives SP from a CFA (there is no CFA query). #[cfg(target_arch = "x86_64")] +#[cfg_attr(target_os = "windows", allow(dead_code))] const CFA_RETURN_ADDRESS_BYTES: usize = std::mem::size_of::(); #[cfg(not(target_arch = "x86_64"))] const CFA_RETURN_ADDRESS_BYTES: usize = 0; @@ -1025,7 +1031,227 @@ mod unwind { } } -#[cfg(not(any(target_vendor = "apple", target_os = "linux", target_os = "windows")))] +/// Windows x86-64 (#7354): walk native frames with `RtlVirtualUnwind`, the +/// documented Win64 unwinder. `_Unwind_Backtrace` does not exist here. +/// +/// `RtlLookupFunctionEntry` + `RtlVirtualUnwind` step a `CONTEXT` outward one +/// frame at a time, and each step yields the frame's `Rip`, `Rsp` and `Rbp` +/// **directly** — so unlike the Itanium path above there is no CFA derivation: +/// an SP-relative root's base is the real `Rsp` the unwinder just restored. +/// (`Rip` after a step is the return address, same as `_Unwind_GetIP`, which +/// is what `match_records`' ±16 window plus containment check expects.) +/// +/// Fail-closed contract, stricter than the Itanium module because a wrong base +/// here has no verifying backstop: any anomaly — a base register the virtual +/// unwind cannot have restored, a slot outside this thread's stack, a frame +/// with no unwind info, a step that does not move outward — abandons the walk +/// and returns, rather than visiting a slot the collector would then *write* +/// through. +#[cfg(all(target_os = "windows", target_arch = "x86_64"))] +mod unwind { + use super::*; + + /// x86-64 `CONTEXT` (winnt.h): 1232 bytes, 16-byte aligned. Declared by + /// hand because perry-runtime links no Windows API crate; only the + /// integer registers are read, so the FP/vector tail is opaque padding. + /// The compile-time asserts below pin the offsets this module relies on. + #[repr(C, align(16))] + struct Context { + p_home: [u64; 6], + context_flags: u32, + mx_csr: u32, + seg: [u16; 6], + e_flags: u32, + dr: [u64; 6], + rax: u64, + rcx: u64, + rdx: u64, + rbx: u64, + rsp: u64, + rbp: u64, + rsi: u64, + rdi: u64, + r8: u64, + r9: u64, + r10: u64, + r11: u64, + r12: u64, + r13: u64, + r14: u64, + r15: u64, + rip: u64, + /// XMM_SAVE_AREA32 (512) + 26 `M128A` vector registers (416) + + /// VectorControl/DebugControl/LastBranch and LastException pairs (48). + tail: [u8; 512 + 26 * 16 + 6 * 8], + } + + // A drifted field offset would hand every walk garbage registers, so pin + // the layout at compile time rather than trusting the declaration above. + const _: () = assert!(std::mem::size_of::() == 1232); + const _: () = assert!(std::mem::offset_of!(Context, rax) == 0x78); + const _: () = assert!(std::mem::offset_of!(Context, rsp) == 0x98); + const _: () = assert!(std::mem::offset_of!(Context, rbp) == 0xA0); + const _: () = assert!(std::mem::offset_of!(Context, rip) == 0xF8); + const _: () = assert!(std::mem::offset_of!(Context, tail) == 0x100); + + const UNW_FLAG_NHANDLER: u32 = 0; + + unsafe extern "system" { + fn RtlCaptureContext(context: *mut Context); + fn RtlLookupFunctionEntry( + control_pc: u64, + image_base: *mut u64, + history_table: *mut core::ffi::c_void, + ) -> *mut core::ffi::c_void; + fn RtlVirtualUnwind( + handler_type: u32, + image_base: u64, + control_pc: u64, + function_entry: *mut core::ffi::c_void, + context: *mut Context, + handler_data: *mut *mut core::ffi::c_void, + establisher_frame: *mut u64, + context_pointers: *mut core::ffi::c_void, + ) -> *mut core::ffi::c_void; + fn GetCurrentThreadStackLimits(low_limit: *mut usize, high_limit: *mut usize); + } + + /// The frame's value of a root's base register, or `None` for a register + /// the virtual unwind cannot have restored. + /// + /// Register numbers are SysV x86-64 DWARF numbers — LLVM's stack maps use + /// that numbering on every x86-64 OS, Windows included (measured: every + /// probe root arrives as `Indirect [RSP + off]`, DWARF 7). Only the Win64 + /// *nonvolatile* set is trustworthy after an unwind step: RtlVirtualUnwind + /// restores exactly what the frame's unwind codes saved, and a nonvolatile + /// register a callee did not save was by definition not modified by it — + /// while a volatile register's `CONTEXT` slot still holds some inner + /// frame's value. Handing one out as a root base would give the collector + /// a wild address it then writes through, so the caller abandons the walk. + fn frame_base(context: &Context, dwarf_reg: u16) -> Option { + let value = match dwarf_reg { + 3 => context.rbx, + 4 => context.rsi, + 5 => context.rdi, + 6 => context.rbp, + ARCH_DWARF_SP => context.rsp, + 12 => context.r12, + 13 => context.r13, + 14 => context.r14, + 15 => context.r15, + _ => return None, + }; + Some(value as usize) + } + + /// This thread's committed stack bounds, `[low, high)`. Every candidate + /// slot must fall inside them — a mapped root lives in its own frame. + fn stack_limits() -> Option<(usize, usize)> { + let mut low = 0usize; + let mut high = 0usize; + unsafe { GetCurrentThreadStackLimits(&mut low, &mut high) }; + (low != 0 && low < high).then_some((low, high)) + } + + pub(super) fn visit( + index: &StackMapIndex, + visit: &mut F, + ) -> NativeStackWalkStats { + let mut stats = NativeStackWalkStats { + walks: 1, + ..NativeStackWalkStats::default() + }; + let Some((stack_low, stack_high)) = stack_limits() else { + return stats; + }; + // Zero-initialised is fine: RtlCaptureContext overwrites the whole + // structure, ContextFlags included. + let mut context: Context = unsafe { std::mem::zeroed() }; + unsafe { RtlCaptureContext(&mut context) }; + + loop { + stats.frames_visited = stats.frames_visited.saturating_add(1); + let matched = index.match_records(context.rip as usize); + if !matched.is_empty() { + stats.records_matched = stats.records_matched.saturating_add(matched.len()); + for record in matched { + for location in index.locations(record) { + stats.locations_visited = stats.locations_visited.saturating_add(1); + let Some(base) = frame_base(&context, location.dwarf_reg) else { + return stats; + }; + let address = if location.offset < 0 { + base.checked_sub(location.offset.unsigned_abs() as usize) + } else { + base.checked_add(location.offset as usize) + }; + let Some(address) = address else { + return stats; + }; + if address < stack_low + || address.saturating_add(std::mem::size_of::()) > stack_high + || address & (std::mem::align_of::() - 1) != 0 + { + return stats; + } + visit(MutableRootSlot { + kind: MutableRootSlotKind::NativeStack, + ptr: address as *mut u64, + }); + } + } + } + + let mut image_base = 0u64; + let entry = unsafe { + RtlLookupFunctionEntry(context.rip, &mut image_base, std::ptr::null_mut()) + }; + if entry.is_null() { + // No unwind info. On Win64 only the innermost frame can be a + // leaf (a function that has performed a call must carry + // .pdata), and frame 0 here is this Rust function, which + // called RtlCaptureContext — so this is either the end of the + // walkable stack or an unrecognised frame. Do not attempt the + // leaf `[Rsp]` pop heuristic mid-walk; stop. + break; + } + let previous_sp = context.rsp; + let mut handler_data: *mut core::ffi::c_void = std::ptr::null_mut(); + let mut establisher_frame = 0u64; + unsafe { + RtlVirtualUnwind( + UNW_FLAG_NHANDLER, + image_base, + context.rip, + entry, + &mut context, + &mut handler_data, + &mut establisher_frame, + std::ptr::null_mut(), + ); + } + if context.rip == 0 { + // Walked off the outermost frame — the ordinary end. + break; + } + let sp = context.rsp as usize; + // The stack grows down, so each caller's SP is strictly higher + // than its callee's. This check is also the loop's termination + // guarantee: SP increases monotonically and is bounded by the + // stack top, so the walk cannot cycle. + if context.rsp <= previous_sp || sp < stack_low || sp >= stack_high || sp & 7 != 0 { + break; + } + } + stats + } +} + +#[cfg(not(any( + target_vendor = "apple", + target_os = "linux", + all(target_os = "windows", target_arch = "x86_64") +)))] mod unwind { use super::*; diff --git a/scripts/gc_walker_trace_assert.py b/scripts/gc_walker_trace_assert.py index 927b9aec38..c21cb5499f 100755 --- a/scripts/gc_walker_trace_assert.py +++ b/scripts/gc_walker_trace_assert.py @@ -18,10 +18,18 @@ liveness assert for `unwind` (nonzero means the mode did not take effect and the arm was measuring `fast` all along). +`--require-locations` is the liveness assert for a walker that has never run +anywhere before (#7354, the Windows RtlVirtualUnwind arm): a walker that visits +zero frames still lets most probes print the right answer, because other root +sources cover them — so a green probe proves nothing. Non-zero +`frames_visited`, `records_matched` and `locations_visited` are what prove the +walker actually stepped mapped frames and enumerated their roots. + Usage: 2> trace.err gc_walker_trace_assert.py trace.err --require-fp-walks gc_walker_trace_assert.py trace.err --forbid-fp-walks + gc_walker_trace_assert.py trace.err --require-locations """ from __future__ import annotations @@ -31,8 +39,8 @@ import sys -def totals(path: str) -> tuple[int, int, int]: - fp_walks = walks = locations = 0 +def totals(path: str) -> tuple[int, int, int, int, int]: + fp_walks = walks = frames = records = locations = 0 saw_event = False with open(path, encoding="utf-8", errors="replace") as handle: for line in handle: @@ -49,13 +57,15 @@ def totals(path: str) -> tuple[int, int, int]: saw_event = True fp_walks += stats.get("fp_walks", 0) walks += stats.get("walks", 0) + frames += stats.get("frames_visited", 0) + records += stats.get("records_matched", 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 + return fp_walks, walks, frames, records, locations def main() -> int: @@ -63,10 +73,15 @@ def main() -> int: ap.add_argument("trace") ap.add_argument("--require-fp-walks", action="store_true") ap.add_argument("--forbid-fp-walks", action="store_true") + ap.add_argument("--require-locations", 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}") + fp_walks, walks, frames, records, locations = totals(args.trace) + print( + f"{args.trace}: walks={walks} fp_walks={fp_walks} " + f"frames_visited={frames} records_matched={records} " + f"locations_visited={locations}" + ) failures: list[str] = [] if walks <= 0: @@ -84,6 +99,22 @@ def main() -> int: f"fp_walks == {fp_walks}: PERRY_STACKMAP_WALKER=unwind did not take " "effect, the fast walk ran anyway" ) + if args.require_locations: + zeroed = [ + (name, meaning) + for name, value, meaning in ( + ("frames_visited", frames, "stepped a frame"), + ("records_matched", records, "matched a mapped safepoint"), + ("locations_visited", locations, "enumerated a root slot"), + ) + if value <= 0 + ] + for name, meaning in zeroed: + failures.append( + f"{name} == 0: the walker never {meaning} — green probes with " + "zero telemetry mean the walker did not run, and other root " + "sources covered for it (#7354)" + ) for message in failures: print(f"::error::{message}", file=sys.stderr) From daa14f7d127d54aa880d125c2bcba324d88ced5f Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Tue, 4 Aug 2026 10:30:39 +0200 Subject: [PATCH 2/2] changelog: fragment for #7355 (Windows RtlVirtualUnwind GC walker) --- changelog.d/7355-windows-gc-walker.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 changelog.d/7355-windows-gc-walker.md diff --git a/changelog.d/7355-windows-gc-walker.md b/changelog.d/7355-windows-gc-walker.md new file mode 100644 index 0000000000..3c72ecb64f --- /dev/null +++ b/changelog.d/7355-windows-gc-walker.md @@ -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.