Skip to content

gc: the platform-unwinder stack-map walker segfaults on Linux/ELF — and on x86-64 it is the only walker #7333

Description

@proggeramlug

PERRY_STACKMAP_WALKER=unwind segfaults on Linux/ELF: 3 of the 8
statepoint-compilable probes die with SIGSEGV, on a build where the default
(fast x29-chain) walker runs all 8 clean against the pinned Node oracle.

Why this is worse than it looks

On aarch64 this is a bisection control going wrong, because the fast chain walk
is the default and it works. On x86-64 the unwinder IS the walker — fp_chain
is compiled only for any(macos, linux) + aarch64 and stubbed to None
everywhere else, so WalkerMode::Fast falls through to unwind::visit. #7328
already stated the general form of this hazard: "on any host where the x29 chain
walk is unavailable the unwinder is the walker, so a configuration that finds no
roots enumerates through the same path and forced evacuation stays quiet."

So this is very likely the next blocker for statepoints on x86-64, sitting
directly behind the compact-map defect (#7321 / #7331) — and it is one that
fails by crashing or scanning wrong memory, not by refusing.

Evidence

Linux/aarch64 (aarch64-unknown-linux-gnu, ELF), perry built in a Debian
container, probes compiled with PERRY_STATEPOINTS=1, run against Node 26.5.1:

baseline (fast walker):            exit=0
forced evac only:                  exit=0     (PERRY_GC_FORCE_EVACUATE + VERIFY_EVACUATION)
trace only:                        exit=0     (PERRY_GC_TRACE=1)
unwind only:                       exit=139   <-- SIGSEGV
unwind + forced evac:              exit=139
verify only:                       exit=139   (verify runs the unwinder too)

PERRY_STACKMAP_WALKER=unwind alone is sufficient; neither the trace flag
nor forced evacuation is involved. Failing probes: 02_survivor_promotion,
03_cross_gen_writes, 08_map_set_sidetables. Passing: 01, 04, 05, 06,
07. (09_try_catch_roots cannot compile under the explicit bridge at all
since #7330.)

The full matrix under the default walker on the same binaries is 8/8, output
byte-identical to the oracle, with .perry_gcmap present and .llvm_stackmaps
absent asserted per probe — so the maps themselves decode correctly and the
roots are right. It is specifically the unwinder's idea of where the frame is.

Suspected mechanism, not yet proven

Every root in a Perry aarch64 module is SP-relative (DWARF 31; measured 57
of 57 on one module, 92 of 92 on another). The walker resolves those with
_Unwind_GetGR(context, dwarf_reg).

For the stack-pointer column, an unwinder is entitled to return the frame's
CFA rather than the in-body SP — they differ by the frame size, and the
DWARF model defines the SP after unwinding as the CFA. fp_chain does not use
_Unwind_GetGR at all: it decodes the prologue's add x29, sp, #imm to
recover the body SP (#7173, refined by #7329). If libgcc's _Unwind_GetGR
returns the CFA where Apple's libunwind returns the body SP, that exactly
explains "clean on macOS/aarch64, wrong on Linux/aarch64, and wrong on any
x86-64" while leaving the fast path correct.

That is a hypothesis consistent with all the above; it has not been confirmed by
reading back the register value.

Reproducing

Needs a Linux host (or container). aarch64 Linux additionally needs
#7331, without which
perry-runtime does not link and the compact-map rewriter refuses every module.

PERRY_STATEPOINTS=1 perry benchmarks/gc_ratchet/probes/02_survivor_promotion.ts -o /tmp/p02
PERRY_STATEPOINTS=1 PERRY_STACKMAP_WALKER=unwind /tmp/p02   # SIGSEGV

Activity

  1. proggeramlug commented on Aug 3, 2026

    @proggeramlug
    ContributorAuthor

    Root cause, measured: _Unwind_GetGR segfaults for DWARF reg 7

    The walker does this once per root (gc/roots/stack_maps.rs:856):

    let base = _Unwind_GetGR(context, i32::from(location.dwarf_reg));

    On x86-64 every root is Indirect [RSP + off], i.e. dwarf_reg == 7 — that
    part is already established in #7324. What was not established is what that call
    does. It does not return an unreliable value. It crashes the process.

    Probed on x86-64 Linux (glibc 2.39, gcc 13.3.0), calling _Unwind_GetGR from an
    _Unwind_Backtrace callback on a live frame, one register per run:

    DWARF reg register result
    0 RAX SIGSEGV (139)
    3 RBX returns correctly
    6 RBP returns correctly
    7 RSP SIGSEGV (139)
    16 RIP returns correctly

    The pattern is callee-saved vs not: libgcc's unwind context tracks the registers
    CFI restores, and asking it for one it does not track dereferences a null column
    pointer. RSP is not a tracked column — it is derived from the CFA — so reg 7 is
    the one lookup guaranteed to fault.

    That makes the fault site the _Unwind_GetGR call itself, not a later write
    through a wild address, which is worth knowing because it rules out the
    "conservative scan writes through garbage" reading and means no amount of
    address validation after the call can help.

    Small correction to the framing carried in docs/engine-plan.md and #7324:
    "_Unwind_GetGR does not reliably return the stack pointer" understates it.
    It is not unreliable, it is fatal, and it is fatal 100% of the time on the only
    register x86-64 roots use.

    What this implies for the fix

    _Unwind_GetCFA works on this host, and reg 6 (RBP) works, and Perry already
    emits "frame-pointer"="non-leaf" on every generated function under native
    roots. So the recoverable bases exist — what is missing is the delta from either
    of them to the body RSP the stack map's offsets are relative to. That delta is
    per-function and constant, and the unwinder does not expose it.

    The cheapest place to close it looks like the compact-map rewriter (#7314),
    which already parses the emitted assembly — where the prologue is visible.
    Rebasing RSP + off onto a CFA- or RBP-relative offset at rewrite time is the
    same technique the aarch64 fast walker uses at runtime (decode the prologue,
    accumulate the frame adjustment), and #7329 just finished making that decoder
    correct — including the trailing sub sp, sp, #imm it originally missed, which
    is the exact analogue of x86-64's sub rsp, N.

    Doing it in the rewriter rather than the walker also keeps the runtime free of a
    second architecture-specific prologue decoder.

    Reproducer, if useful:

    static _Unwind_Reason_Code cb(struct _Unwind_Context *ctx, void *a) {
        if (hits++ != 1) return _URC_NO_REASON;      /* our caller's frame */
        printf("calling GetGR(%d)\n", which); fflush(stdout);
        uintptr_t v = _Unwind_GetGR(ctx, which);     /* faults for which==7 */
        printf("returned 0x%lx\n", (unsigned long)v);
        return _URC_NO_REASON;
    }

    Built gcc -O1 -fno-omit-frame-pointer -g, run once per register.

  2. proggeramlug commented on Aug 5, 2026

    @proggeramlug
    ContributorAuthor

    Fixed. The fatal call site is gone: gc/roots/stack_maps.rs:1071 now branches if location.dwarf_reg == ARCH_DWARF_SP { _Unwind_GetCFA(context) } instead of _Unwind_GetGR(context, 7) (#7349), and #7400 corrected the CFA base that commit introduced. Verified on current main.

    Note: gc-native-roots.yml still carries a comment naming this issue as open on aarch64-Linux — written ~2 hours before #7400 merged, so it is stale. One PERRY_STACKMAP_WALKER=unwind run on that arm would settle it beyond doubt.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions