feat(native): ARM32 registers and both frame-record shapes in the crash daemon - #2053
feat(native): ARM32 registers and both frame-record shapes in the crash daemon#2053GLinnik21 wants to merge 2 commits into
Conversation
87529c2 to
aaa8863
Compare
There was a problem hiding this comment.
🟡 Changes recommended
read_stack_value still uses overflow-prone unsigned addition in its bounds check, which can allow out-of-range reads when addr is corrupted (common in crash contexts).
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Improves Linux crash-daemon native backtraces on 32-bit ARM by (1) serializing ARM32 register state into events and (2) making the frame-pointer fallback unwinder handle both GCC and clang/LLVM ARM32 frame-record layouts (plus the stacked pointer-width stack reads prerequisite).
Changes:
- Add ARM32 (
__arm__) register serialization fromucontext_t(r0–r10, fp/ip/sp/lr/pc/cpsr). - Teach the FP walker on ARM32 to probe both possible (saved_fp, return_addr) frame-record shapes and select a plausible candidate.
- Switch stack reads/offsets in the FP walker to use
sizeof(uintptr_t)(stacked from #2052).
File summaries
| File | Description |
|---|---|
src/backends/native/sentry_crash_daemon.c |
Adds ARM32 register reporting and dual-shape ARM32 FP frame-record decoding; updates stack value reads to pointer width. |
CHANGELOG.md |
Documents the ARM32 registers + frame-record handling and the pointer-width read fix. |
Review details
- Files reviewed: 2/2 changed files
- Comments generated: 1
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| if (addr < stack_start | ||
| || addr + sizeof(uint64_t) > stack_start + stack_size) { | ||
| || addr + sizeof(uintptr_t) > stack_start + stack_size) { | ||
| return false; | ||
| } | ||
| uint64_t offset = addr - stack_start; |
`read_stack_value` bounds-checked and copied `sizeof(uint64_t)` bytes regardless of the target's pointer size, and the walk stepped to the return address with the same constant. On a 32-bit target every read therefore took two stack slots per pointer: the high half of each `saved_fp` and `return_addr` was the neighbouring word, the return address was read from the wrong slot, and the last legitimate frame record failed the bounds check. Read a `uintptr_t` and widen it, and step by pointer size; 64-bit targets are unchanged. While here, range-check by subtraction rather than `addr + size`: the address comes from a frame pointer in a crashed process, and a corrupted value near the top of the address space wraps the sum past the naive comparison.
aaa8863 to
6f1940f
Compare
|
Addressed both reviews:
Re-verified on ARM32 hardware with the updated daemon: GCC-built chain |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 6f1940f. Configure here.
6f1940f to
f4fb76f
Compare
|
Second pass on the reviews (Cursor's "walker can pick the wrong frame shape" and the follow-ups it implies):
Hardware: the permission-bit selection was verified on the ARM32 set ( |
|
Hardware confirmation for the current head (f4fb76f): daemon log |
…sh daemon Two scoped changes for 32-bit ARM on Linux, a supported target since getsentry#1659: - `build_registers_from_ctx` covered x86_64 and aarch64 only, so ARM32 events shipped an empty `registers` object. Add an `__arm__` branch (r0-r10, fp, ip, sp, lr, pc, cpsr from the saved ucontext). - The generic frame-pointer walk already runs on ARM32 but assumes one frame-record shape: [fp] = saved fp, [fp+4] = return address. That is clang's layout. GCC's `push {.., fp, lr}; add fp, sp, #N` leaves fp on the LR slot instead ([fp-4] = saved fp, [fp] = return address), and one ARM32 process routinely contains both (an application built by one compiler, a libc or shim built by the other). Read both candidate records and keep the one whose return address is outside the stack and whose saved fp is zero or further up the stack. Both are judged against the crashed process's mappings, recorded with their permission bits while /proc/<pid>/maps is parsed for modules: a return address must be in an executable, non-writable mapping (code, as opposed to a stack - writable, and on some systems executable too - or the heap), and a saved fp must be in a writable mapping above the current frame. That rejects the other shape's record, whose "saved fp" is really a return address, even when code sits numerically below the stack. The walk reads records out of the one captured stack window, so a chain is followed only while it stays inside it; signal stacks and split stacks end the walk. Only the crashing thread is stopped while the daemon works, so the mappings are recorded (ARM32 only) and re-read, field by field, once the stack bytes are copied; if they are incomplete or differ, the ARM32 walk stops rather than guess a shape. That narrows, but does not close, the window every post-crash capture in this daemon has: the stack bytes and the module inventory are read after the crash on every platform, while peer threads keep running and could in principle modify or remap the stopped thread's stack. Freezing the thread group during capture would close it for the generic walk too; that is a separate change. Thumb code using r7 as its frame pointer is not covered; the walk still seeds from r11. JIT code in writable mappings is not walked either. Measured on an LG webOS 4.x television (Cortex-A9, glibc 2.24, in-process libunwind disabled so the FP fallback runs), with daemons built from the same tree with and without this change, both including the pointer-width fix this is stacked on. A GCC-built chain (-O2 -fno-omit-frame-pointer, noinline f1 -> f2 -> f3 -> leaf, null write in leaf) goes from `leaf` plus a stack address to `leaf <- f2 <- f1 <- __libc_start_main`; a rustc-built application is unchanged (`gsignal <- plex_run <- __libc_start_main`), since its frames already match the assumed shape.
|
Good catch on |
f4fb76f to
0e66ba2
Compare
…pture (#65) sentry[bot] on getsentry/sentry-native#2053: `g_vma_alloc_failed` was never reset in `capture_modules_from_proc_maps`, so one allocation failure would disable the ARM32 frame-pointer walk for every later crash the same daemon instance handled. Reset it with the other per-capture state. Mirrors the same one-line change on the upstream PR. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

Summary
Two scoped changes to the Linux crash daemon for 32-bit ARM, which #1659 made a supported target:
build_registers_from_ctxcovered x86_64 and aarch64 only, so ARM32 events shipped an emptyregistersobject. This adds an__arm__branch (r0–r10, fp, ip, sp, lr, pc, cpsr from the saveducontext).[fp]as the saved fp and[fp + 4]as the return address. That is clang's layout; GCC's is the other way round, and one ARM32 process routinely contains both. This reads both candidate records and keeps the one shaped like a frame.Why both shapes
Both compilers emit
push {.., fp, lr}and then pointfpat a different slot of the same push:With a single assumed shape the walk misreads the first record of the other shape it meets — reporting a saved fp as a return address, or stopping. Both candidates are judged against the crashed process's mappings, which the daemon records with their permission bits (including anonymous and special mappings, in a dynamically sized daemon-local cache; ARM32 only, so no other architecture pays for it) while it parses
/proc/<pid>/mapsfor modules. A candidate is accepted only if its return address lies in an executable, non-writable mapping — code, as opposed to a stack (writable, and on some systems executable too) or the heap — and its saved fp is zero (outermost frame) or lies above the current frame in a writable mapping. That rejects the other compiler's record shape, whose "saved fp" is really a return address, even when code sits numerically below the stack. Nothing is bounded by the captured window (stack_endis where the capture stops, not where the stack does), and the existing post-read checks (is_valid_code_addr, monotonic fp) still apply after selection.Two things the walk does not do, stated so nobody reads more into it: it reads frame records out of the one captured stack window, so a chain is followed only while it stays inside that window — a signal stack or a split stack that links to a lower or non-contiguous segment ends the walk (as it did before this PR); and JIT code in writable mappings is not treated as code.
The daemon stops only the crashing thread, so the mappings can change under it. The walk therefore re-reads
/proc/<pid>/mapsonce the stack bytes have been copied and compares every mapping with the recorded one, field by field and in order; if the snapshot is incomplete (a line the parser cannot read, an allocation failure, a read error) or differs from the first read, the ARM32 walk stops rather than guess a shape. Maps lines are read as logical lines (an over-long pathname is truncated and the remainder consumed), so a long path can neither disable the walk nor be parsed as a mapping of its own; that reader also serves the existing module parser.What that does not cover, stated plainly: state that changed between the crash and the first read. The two reads narrow that window; they do not close it. Nor is it specific to this PR — the stack bytes the frame-pointer walk reads, and the module inventory, are captured after the crash on every platform while peer threads keep running, and a peer thread can in principle write to, unmap or remap the stopped thread's stack before the copy is taken. The shape selection here consumes those same post-crash inputs and inherits that exposure; it does not add a new one, but it also cannot promise more than the generic walk does. Freezing the whole thread group for the duration of capture would close the window for the generic walk as well; that is a daemon-wide change I'd propose separately rather than fold into this PR.
Not covered: Thumb code that uses r7 as its frame pointer (the walk still seeds from r11 /
arm_fp, as before this PR — the changelog says r11-based), and JIT code in writable mappings.Verification (ARM32 hardware, isolated from #2052)
LG webOS 4.x television, Cortex-A9, glibc 2.24, in-process libunwind disabled so the FP fallback is exercised. Two daemons were built from the same tree:
#2052only, and#2052 + this PR.-O2 -fno-omit-frame-pointer,noinlinef1 → f2 → f3 → leaf, null write inleaf):leaf(context) + one "frame" whose address is a stack address — 1 real frame.leaf ← f2 ← f1 ← __libc_start_main. (f3is elided because the crashingleafhas no frame record of its own;maintail-callsf1. Both are inherent to FP walking.)gsignal ← plex_run ← __libc_start_mainfor a SIGABRT — i.e. the second shape only changes the result when GCC-built frame-pointer frames are in the chain.Captured 11 modules and 66 mappings (complete); GCC chainleaf ← f2 ← f1 ← __libc_start_main; rustc applicationcrash_on_purpose ← plex_run ← __libc_start_main; 17 registers. This target's main-thread[stack]mapping isrwxp, so the "executable but not writable" rule for return addresses is exercised, not theoretical.Captured 66 mappings (complete); GCC chainleaf ← f2 ← f1 ← __libc_start_main; rustc applicationcrash_on_purpose ← plex_run ← __libc_start_main; 17 registers.sentry-crashcross-compiles forarm-linux-gnueabi(GCC 12); non-ARM paths are unchanged (#if defined(__arm__)/#else); macOS arm64 native-backend build andclang-formatclean.TEST_QEMU, which skips the native integration suite), and the walker is daemon-internal with no unit-test seam, so this PR adds no test; happy to add one in whatever shape you prefer.Stacked on #2052, which it needs for pointer-width stack reads on ARM32.
Note: this PR is stacked on #2052 and therefore includes its commit; it will rebase to a single commit once #2052 lands.