From 806e3794126c0fc26fb1b4641c75b3eafd8803d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 3 Aug 2026 19:03:05 +0200 Subject: [PATCH] gc: refuse native roots off aarch64, and stop the ELF map forcing DT_TEXTREL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gc-native-roots gate went red on main with a SIGSEGV rather than a missing section: SHF_GNU_RETAIN did keep .perry_gcmap through --gc-sections and .llvm_stackmaps was gone, so the ELF retention fix worked. Two defects behind the crash. The backend is aarch64-only and did not say so. Cross-compiled a probe to x86_64-unknown-linux-gnu and decoded the emitted map: all 178 root slots are Indirect [RSP + off], DWARF register 7. chain_walkable admits only aarch64's 29/31, so every frame falls back to the unwinder, which resolves the base with _Unwind_GetGR(ctx, 7) — 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 mode is opt-in, so refuse rather than ship a binary that crashes under collection. The ELF section was read-only while holding relocated function addresses: ld warned 'relocation against main in read-only section .perry_gcmap' and created a DT_TEXTREL in a PIE. Now "awR". Gate moves to an ARM64 runner — on x86-64 it would now test only the refusal. macOS arms remain 9/9; x86-64 Linux now fails the compile with a message naming the target instead of segfaulting at collection time. --- .github/workflows/gc-native-roots.yml | 10 ++++++++ changelog.d/7321-statepoints-aarch64-only.md | 24 +++++++++++++++++++ crates/perry-codegen/src/gc_map.rs | 25 +++++++++++++++++++- 3 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 changelog.d/7321-statepoints-aarch64-only.md diff --git a/.github/workflows/gc-native-roots.yml b/.github/workflows/gc-native-roots.yml index 72749ff248..f84fe31ed0 100644 --- a/.github/workflows/gc-native-roots.yml +++ b/.github/workflows/gc-native-roots.yml @@ -1,5 +1,15 @@ # #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 diff --git a/changelog.d/7321-statepoints-aarch64-only.md b/changelog.d/7321-statepoints-aarch64-only.md new file mode 100644 index 0000000000..f3af89a088 --- /dev/null +++ b/changelog.d/7321-statepoints-aarch64-only.md @@ -0,0 +1,24 @@ +### Native GC roots: refuse non-aarch64, and stop the ELF map forcing DT_TEXTREL + +The `gc-native-roots` gate went red on `main` with a **SIGSEGV**, not a missing +section — `SHF_GNU_RETAIN` did keep `.perry_gcmap` through `--gc-sections`, and +`.llvm_stackmaps` was gone as intended. Two separate defects behind the crash. + +**The backend is aarch64-only and did not say so.** Measured by cross-compiling +a probe to `x86_64-unknown-linux-gnu` and decoding the emitted stack map: every +one of its 178 root slots is `Indirect [RSP + off]`, DWARF register 7. The +runtime's `chain_walkable` admits only aarch64's FP/SP (29 and 31), so every +frame falls back to the 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). The walker therefore computed wild +addresses and the collector segfaulted writing through them. The mode is +opt-in, so the compiler now refuses the combination outright rather than +emitting a binary that crashes under collection. + +**The ELF section was read-only but holds relocated addresses.** `ld` reported +`relocation against 'main' in read-only section '.perry_gcmap'` and created a +DT_TEXTREL in a PIE. It is now `"awR"` (SHF_ALLOC | SHF_WRITE | SHF_GNU_RETAIN). + +The gate moves to an ARM64 Linux runner. On x86-64 it would now exercise only +the refusal; on ARM64 it tests the supported configuration and still answers +the question it exists for — whether the compact map survives ELF linking. diff --git a/crates/perry-codegen/src/gc_map.rs b/crates/perry-codegen/src/gc_map.rs index a006c74d5b..6c3ff707ce 100644 --- a/crates/perry-codegen/src/gc_map.rs +++ b/crates/perry-codegen/src/gc_map.rs @@ -62,13 +62,18 @@ const GC_MAP_VERSION: u8 = 3; /// Section the compact map is emitted into, and the label it is given. const GC_MAP_LABEL: &str = "_perry_gc_map"; const MACHO_SECTION: &str = "__PERRY_GCMAP,__perry_gcmap"; +/// `w` because the section holds **relocated function addresses**: without +/// SHF_WRITE the linker reports `relocation against \`main\` in read-only +/// section \`.perry_gcmap\`` and creates a DT_TEXTREL in a PIE, which is both +/// a hardening regression and a portability hazard. +/// /// `R` is SHF_GNU_RETAIN, the ELF analogue of Mach-O's `.no_dead_strip`. /// Perry links with `-Wl,--gc-sections`, and nothing in the program /// references this section — the collector finds it by name at runtime — so /// without RETAIN the linker discards it and the binary ships with no GC map /// at all. Measured: the section is present in the object (PROGBITS, SHF_ALLOC, /// with relocations) and absent from the linked binary. -const ELF_SECTION: &str = ".perry_gcmap,\"aR\",@progbits"; +const ELF_SECTION: &str = ".perry_gcmap,\"awR\",@progbits"; /// LLVM stack-map v3 location kinds. Only these two describe a frame slot; /// `Constant`/`ConstIndex` carry the statepoint preamble and `Register` cannot @@ -834,6 +839,24 @@ pub fn compact_and_assemble( // the collector finds no native roots at all — the exact outcome the hard // error below exists to prevent, reached with no diagnostic. The mode is // opt-in, so refusing loudly costs nothing. + // The runtime can only resolve aarch64 frame bases. Measured on x86-64: + // every root is `Indirect [RSP + off]` (DWARF register 7), so + // `chain_walkable` is false — it admits only aarch64's FP/SP, 29 and 31 — + // and every frame falls back to `_Unwind_GetGR(ctx, 7)`. That call does not + // reliably return the stack pointer (`_Unwind_GetCFA` is the supported way + // to obtain it), so the walker computes wild addresses and the collector + // segfaults writing through them. Observed exactly that on the Linux gate. + // + // The mode is opt-in, so refusing here is free; emitting a binary that + // crashes under collection is not. + if !target.starts_with("aarch64") && !target.starts_with("arm64") { + return Err(anyhow!( + "perry: native GC roots (PERRY_STATEPOINTS / PERRY_RS4GC) are \ + aarch64-only — target `{target}` records roots against frame \ + bases this runtime cannot resolve, and the collector would \ + segfault rather than report anything. Tracked for #7173." + )); + } let macho = target.contains("apple") || target.contains("darwin"); let elf = !macho && !target.contains("windows") && !target.contains("msvc"); if !macho && !elf {