From a9eacdf2e73b076f1e11a92c290a85c9454c70d9 Mon Sep 17 00:00:00 2001 From: Gleb Linnik Date: Thu, 3 Sep 2026 00:40:08 +0300 Subject: [PATCH 1/2] feat(native): ARM32 registers and both frame-record shapes in the crash daemon Two scoped changes for 32-bit ARM on Linux, a supported target since #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//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. --- CHANGELOG.md | 1 + src/backends/native/sentry_crash_daemon.c | 277 +++++++++++++++++++++- 2 files changed, 276 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a80a420c8..b63503929 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ - Native/Linux: parse minidump-writer ELF build-id notes with `sentry__elf_find_note`. ([#2055](https://github.com/getsentry/sentry-native/pull/2055)) - Native: Read frame records at pointer width in the crash daemon's frame-pointer walk, so 32-bit targets no longer read two stack slots per pointer. ([#2052](https://github.com/getsentry/sentry-native/pull/2052)) +- Native: Report ARM32 registers for Linux crash events, and walk both r11-based ARM32 frame-record shapes (GCC's and clang's; Thumb r7 chains are not walked) in the crash daemon. ([#2053](https://github.com/getsentry/sentry-native/pull/2053)) - Prevent backend state races when `sentry_reinstall_backend` runs concurrently with scope observer callbacks. ([#2041](https://github.com/getsentry/sentry-native/pull/2041)) - `sentry_set_trace` omits `parent_span_id` when the caller does not provide one, instead of serializing it as `null`. ([#2047](https://github.com/getsentry/sentry-native/pull/2047)) - Native/Linux i386: write valid thread stack descriptors to minidumps when stack addresses use the upper half of the 32-bit address space. ([#2054](https://github.com/getsentry/sentry-native/pull/2054)) diff --git a/src/backends/native/sentry_crash_daemon.c b/src/backends/native/sentry_crash_daemon.c index 980de372b..8e07a2d13 100644 --- a/src/backends/native/sentry_crash_daemon.c +++ b/src/backends/native/sentry_crash_daemon.c @@ -50,6 +50,131 @@ # if defined(SENTRY_PLATFORM_LINUX) # include "unwinder/sentry_unwinder.h" # endif +# if defined(SENTRY_PLATFORM_LINUX) || defined(SENTRY_PLATFORM_ANDROID) +/** + * Read one logical line of `/proc//maps`. A pathname longer than the + * buffer is truncated, and the rest of that line is consumed rather than + * handed to the next `fgets` as if it were a mapping of its own. + */ +static bool +read_maps_line(FILE *f, char *line, size_t size) +{ + if (!fgets(line, size, f)) { + return false; + } + size_t len = strlen(line); + if (len > 0 && line[len - 1] == '\n') { + return true; + } + int c; + while ((c = fgetc(f)) != EOF && c != '\n') { } + return true; +} +# endif +# if defined(__arm__) +/** + * Every mapping of the crashed process, recorded while `/proc//maps` is + * parsed for modules. Unlike the module inventory this includes anonymous and + * special mappings and keeps the permission bits, so the ARM32 frame-pointer + * walk can answer "is this a return address" (executable, not writable) and + * "is this a saved frame pointer" (writable, above the current frame) without + * guessing. `g_vma_complete` is true only when the whole file was read and + * recorded; `vma_snapshot_unchanged` lets the walk notice a snapshot that + * changed under it, since only the crashing thread is stopped while the + * daemon works. + * ARM32 only: nothing else consumes it, so nothing else pays for it. + */ +typedef struct { + uint64_t start; + uint64_t end; + bool write; + bool exec; +} vma_range_t; +static vma_range_t *g_vmas = NULL; +static size_t g_vma_count = 0; +static size_t g_vma_capacity = 0; +static bool g_vma_alloc_failed = false; +static bool g_vma_complete = false; + +static bool +vma_record(uint64_t start, uint64_t end, const char *perms) +{ + if (g_vma_alloc_failed) { + return false; + } + if (g_vma_count == g_vma_capacity) { + size_t capacity = g_vma_capacity ? g_vma_capacity * 2 : 256; + if (capacity < g_vma_capacity + || capacity > SIZE_MAX / sizeof(vma_range_t)) { + g_vma_alloc_failed = true; + return false; + } + vma_range_t *grown = sentry_malloc(capacity * sizeof(vma_range_t)); + if (!grown) { + g_vma_alloc_failed = true; + return false; + } + if (g_vmas) { + memcpy(grown, g_vmas, g_vma_count * sizeof(vma_range_t)); + sentry_free(g_vmas); + } + g_vmas = grown; + g_vma_capacity = capacity; + } + g_vmas[g_vma_count].start = start; + g_vmas[g_vma_count].end = end; + g_vmas[g_vma_count].write = perms[1] == 'w'; + g_vmas[g_vma_count].exec = perms[2] == 'x'; + g_vma_count++; + return true; +} + +static const vma_range_t * +vma_containing(uint64_t addr) +{ + for (size_t i = 0; i < g_vma_count; i++) { + if (addr >= g_vmas[i].start && addr < g_vmas[i].end) { + return &g_vmas[i]; + } + } + return NULL; +} + +/** + * Re-read the mappings and answer whether every one of them still matches + * the recorded snapshot, field by field and in order. Called after the stack + * bytes have been copied, so a mapping change between the two reads is + * detected rather than reasoned about. + */ +static bool +vma_snapshot_unchanged(pid_t pid) +{ + char maps_path[64]; + snprintf(maps_path, sizeof(maps_path), "/proc/%d/maps", pid); + FILE *f = fopen(maps_path, "r"); + if (!f) { + return false; + } + char line[1024]; + size_t i = 0; + bool same = true; + while (same && read_maps_line(f, line, sizeof(line))) { + unsigned long long start, end; + char perms[5]; + if (sscanf(line, "%llx-%llx %4s", &start, &end, perms) != 3) { + same = false; + break; + } + same = i < g_vma_count && g_vmas[i].start == start + && g_vmas[i].end == end && g_vmas[i].write == (perms[1] == 'w') + && g_vmas[i].exec == (perms[2] == 'x'); + i++; + } + same = same && i == g_vma_count && !ferror(f); + fclose(f); + return same; +} +# endif #elif defined(SENTRY_PLATFORM_WINDOWS) # include # include @@ -420,6 +545,41 @@ build_registers_from_ctx(const sentry_crash_context_t *ctx, size_t thread_idx) registers, "sp", sentry__value_new_addr(uctx->uc_mcontext.sp)); sentry_value_set_by_key( registers, "pc", sentry__value_new_addr(uctx->uc_mcontext.pc)); +# elif defined(__arm__) + sentry_value_set_by_key( + registers, "r0", sentry__value_new_addr(uctx->uc_mcontext.arm_r0)); + sentry_value_set_by_key( + registers, "r1", sentry__value_new_addr(uctx->uc_mcontext.arm_r1)); + sentry_value_set_by_key( + registers, "r2", sentry__value_new_addr(uctx->uc_mcontext.arm_r2)); + sentry_value_set_by_key( + registers, "r3", sentry__value_new_addr(uctx->uc_mcontext.arm_r3)); + sentry_value_set_by_key( + registers, "r4", sentry__value_new_addr(uctx->uc_mcontext.arm_r4)); + sentry_value_set_by_key( + registers, "r5", sentry__value_new_addr(uctx->uc_mcontext.arm_r5)); + sentry_value_set_by_key( + registers, "r6", sentry__value_new_addr(uctx->uc_mcontext.arm_r6)); + sentry_value_set_by_key( + registers, "r7", sentry__value_new_addr(uctx->uc_mcontext.arm_r7)); + sentry_value_set_by_key( + registers, "r8", sentry__value_new_addr(uctx->uc_mcontext.arm_r8)); + sentry_value_set_by_key( + registers, "r9", sentry__value_new_addr(uctx->uc_mcontext.arm_r9)); + sentry_value_set_by_key( + registers, "r10", sentry__value_new_addr(uctx->uc_mcontext.arm_r10)); + sentry_value_set_by_key( + registers, "fp", sentry__value_new_addr(uctx->uc_mcontext.arm_fp)); + sentry_value_set_by_key( + registers, "ip", sentry__value_new_addr(uctx->uc_mcontext.arm_ip)); + sentry_value_set_by_key( + registers, "sp", sentry__value_new_addr(uctx->uc_mcontext.arm_sp)); + sentry_value_set_by_key( + registers, "lr", sentry__value_new_addr(uctx->uc_mcontext.arm_lr)); + sentry_value_set_by_key( + registers, "pc", sentry__value_new_addr(uctx->uc_mcontext.arm_pc)); + sentry_value_set_by_key( + registers, "cpsr", sentry__value_new_addr(uctx->uc_mcontext.arm_cpsr)); # endif #elif defined(SENTRY_PLATFORM_MACOS) @@ -606,6 +766,41 @@ read_stack_value(const uint8_t *stack_buf, uint64_t stack_start, return true; } +#if defined(__arm__) +/** + * Does a (saved fp, return address) pair look like a frame record? + * + * Judged against the crashed process's mappings. A return address must be in + * an executable mapping that is not writable: that is what separates code + * from a stack (writable, and on some systems executable too) and from the + * heap. A non-zero saved fp must be above the current frame and in a + * writable mapping: stacks are writable, code is not, so the other compiler's + * record shape - whose "saved fp" is really a return address - is rejected + * even when code sits numerically below the stack. The walk reads frame + * records out of the one captured 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. Not walked either: JIT + * code in writable mappings. + */ +static bool +arm_frame_record_plausible( + uint64_t saved_fp, uint64_t return_addr, uint64_t current_fp) +{ + const vma_range_t *code = vma_containing(return_addr); + if (!code || !code->exec || code->write) { + return false; + } + if (saved_fp == 0) { + return true; + } + if (saved_fp <= current_fp) { + return false; + } + const vma_range_t *stack = vma_containing(saved_fp); + return stack && stack->write; +} +#endif + #if defined(SENTRY_PLATFORM_MACOS) && defined(__aarch64__) # define SENTRY__STRIP_PAC(addr) ((addr) & 0x00007FFFFFFFFFFFULL) #else @@ -1105,6 +1300,18 @@ build_stacktrace_for_thread( if (stack_buf && fp != 0 && frame_count < MAX_STACK_FRAMES) { uint64_t current_fp = fp; int walk_count = 0; +#if defined(__arm__) + // Fail closed: without a complete and still-current picture of the + // mappings neither frame-record shape can be told from the other, + // and guessing would emit fabricated frames rather than a shorter, + // honest stack. Only the crashing thread is stopped, so the mappings + // are re-read now that the stack bytes are in hand. + if (!g_vma_complete || !vma_snapshot_unchanged(ctx->crashed_pid)) { + SENTRY_TRACE("Mappings incomplete or changed; skipping the ARM32 " + "frame-pointer walk"); + walk_count = MAX_STACK_FRAMES; + } +#endif // Check if FP is within captured stack range uint64_t stack_end = stack_start + stack_size; @@ -1118,6 +1325,44 @@ build_stacktrace_for_thread( uint64_t saved_fp = 0; uint64_t return_addr = 0; +#if defined(__arm__) + // Two frame-record shapes coexist in one ARM32 process. GCC's + // `push {.., fp, lr}; add fp, sp, #N` leaves fp on the LR slot + // ([fp-4] = saved fp, [fp] = return address), while clang/LLVM's + // leaves it on the saved-fp slot ([fp] = saved fp, [fp+4] = return + // address). Read both records and keep the one shaped like a + // frame. + { + uint64_t fp_llvm = 0, ret_llvm = 0, fp_gcc = 0, ret_gcc = 0; + bool ok_llvm = read_stack_value(stack_buf, stack_start, + stack_size, current_fp, &fp_llvm) + && read_stack_value(stack_buf, stack_start, stack_size, + current_fp + sizeof(uintptr_t), &ret_llvm); + bool ok_gcc + = read_stack_value(stack_buf, stack_start, stack_size, + current_fp - sizeof(uintptr_t), &fp_gcc) + && read_stack_value(stack_buf, stack_start, stack_size, + current_fp, &ret_gcc); + if (ok_llvm + && arm_frame_record_plausible( + fp_llvm, ret_llvm, current_fp)) { + saved_fp = fp_llvm; + return_addr = ret_llvm; + } else if (ok_gcc + && arm_frame_record_plausible( + fp_gcc, ret_gcc, current_fp)) { + saved_fp = fp_gcc; + return_addr = ret_gcc; + } else { + SENTRY_TRACEF("No frame record at FP=0x%llx (stack: 0x%llx " + "- 0x%llx)", + (unsigned long long)current_fp, + (unsigned long long)stack_start, + (unsigned long long)stack_end); + break; + } + } +#else // Read saved frame pointer and return address // Frame layout: [FP] = saved FP, [FP + pointer size] = return addr if (!read_stack_value(stack_buf, stack_start, stack_size, @@ -1135,6 +1380,7 @@ build_stacktrace_for_thread( (unsigned long long)(current_fp + sizeof(uintptr_t))); break; } +#endif saved_fp = SENTRY__STRIP_PAC(saved_fp); return_addr = SENTRY__STRIP_PAC(return_addr); @@ -1718,9 +1964,14 @@ capture_modules_from_proc_maps(sentry_crash_context_t *ctx) char line[1024]; ctx->module_count = 0; +# if defined(__arm__) + g_vma_count = 0; + g_vma_complete = false; + g_vma_alloc_failed = false; + bool vma_ok = true; +# endif - while (fgets(line, sizeof(line), f) - && ctx->module_count < SENTRY_CRASH_MAX_MODULES) { + while (read_maps_line(f, line, sizeof(line))) { // Parse line: "start-end perms offset dev inode pathname" unsigned long long start, end, offset; @@ -1731,9 +1982,26 @@ capture_modules_from_proc_maps(sentry_crash_context_t *ctx) perms, &offset, &pathname_offset); if (parsed < 4) { +# if defined(__arm__) + vma_ok = false; // a line this parser cannot read is a mapping it + // does not know about +# endif continue; } +# if defined(__arm__) + if (!vma_record(start, end, perms)) { + vma_ok = false; + } + if (ctx->module_count >= SENTRY_CRASH_MAX_MODULES) { + continue; // module inventory is full; keep reading for mappings + } +# else + if (ctx->module_count >= SENTRY_CRASH_MAX_MODULES) { + break; + } +# endif + // Must have a valid pathname (not [stack], [heap], etc.) if (pathname_offset <= 0 || line[pathname_offset] == '\0' || line[pathname_offset] == '[' || line[pathname_offset] == '\n') { @@ -1808,6 +2076,11 @@ capture_modules_from_proc_maps(sentry_crash_context_t *ctx) ctx->module_count++; } +# if defined(__arm__) + g_vma_complete = vma_ok && !ferror(f); + SENTRY_DEBUGF("Captured %zu mappings (%s) from /proc/%d/maps", g_vma_count, + g_vma_complete ? "complete" : "INCOMPLETE", ctx->crashed_pid); +# endif fclose(f); SENTRY_DEBUGF("Captured %u modules from /proc/%d/maps", ctx->module_count, ctx->crashed_pid); From c2493bc7e51f747876dacbc8f3cae9fce892cff0 Mon Sep 17 00:00:00 2001 From: Gleb Linnik Date: Fri, 4 Sep 2026 11:47:14 +0300 Subject: [PATCH 2/2] fix(native): capture the ARM32 mapping view after the stack copy The walk compared a second read of /proc//maps against the snapshot taken during module capture and skipped every frame on any difference. Only the crashing thread is stopped while the daemon works, so an unrelated mmap or munmap on a peer thread in that window emptied the walk, including chains the single-shape walker used to report. Read the mappings once, immediately after the thread's stack bytes are copied out, and judge the copied bytes against that view. The module inventory no longer doubles as the mapping table, and the walk still fails closed when the file cannot be read completely. --- src/backends/native/sentry_crash_daemon.c | 100 +++++++++------------- 1 file changed, 41 insertions(+), 59 deletions(-) diff --git a/src/backends/native/sentry_crash_daemon.c b/src/backends/native/sentry_crash_daemon.c index 8e07a2d13..6894b5f79 100644 --- a/src/backends/native/sentry_crash_daemon.c +++ b/src/backends/native/sentry_crash_daemon.c @@ -73,15 +73,16 @@ read_maps_line(FILE *f, char *line, size_t size) # endif # if defined(__arm__) /** - * Every mapping of the crashed process, recorded while `/proc//maps` is - * parsed for modules. Unlike the module inventory this includes anonymous and - * special mappings and keeps the permission bits, so the ARM32 frame-pointer - * walk can answer "is this a return address" (executable, not writable) and - * "is this a saved frame pointer" (writable, above the current frame) without - * guessing. `g_vma_complete` is true only when the whole file was read and - * recorded; `vma_snapshot_unchanged` lets the walk notice a snapshot that - * changed under it, since only the crashing thread is stopped while the - * daemon works. + * Every mapping of the crashed process, with its permission bits, recorded + * by `vma_capture` immediately after a thread's stack bytes have been copied + * out. Unlike the module inventory this includes anonymous and special + * mappings, so the ARM32 frame-pointer walk can answer "is this a return + * address" (executable, not writable) and "is this a saved frame pointer" + * (writable, above the current frame) without guessing. It is read after + * the stack copy on purpose: only the crashing thread is stopped while the + * daemon works, so the freshest view of the mappings is the one taken next + * to the bytes it will be asked about. `g_vma_complete` is true only when + * the whole file was read and recorded. * ARM32 only: nothing else consumes it, so nothing else pays for it. */ typedef struct { @@ -141,38 +142,42 @@ vma_containing(uint64_t addr) } /** - * Re-read the mappings and answer whether every one of them still matches - * the recorded snapshot, field by field and in order. Called after the stack - * bytes have been copied, so a mapping change between the two reads is - * detected rather than reasoned about. + * Read `/proc//maps` into the VMA table, replacing whatever it held. + * Called right after a stack copy, so the walk judges the copied bytes + * against the mappings as they were at (nearly) the same moment. */ -static bool -vma_snapshot_unchanged(pid_t pid) +static void +vma_capture(pid_t pid) { + g_vma_count = 0; + g_vma_complete = false; + g_vma_alloc_failed = false; + char maps_path[64]; snprintf(maps_path, sizeof(maps_path), "/proc/%d/maps", pid); FILE *f = fopen(maps_path, "r"); if (!f) { - return false; + SENTRY_WARNF("Failed to open %s for mapping capture", maps_path); + return; } char line[1024]; - size_t i = 0; - bool same = true; - while (same && read_maps_line(f, line, sizeof(line))) { + bool ok = true; + while (read_maps_line(f, line, sizeof(line))) { unsigned long long start, end; char perms[5]; if (sscanf(line, "%llx-%llx %4s", &start, &end, perms) != 3) { - same = false; - break; + ok = false; // a line this parser cannot read is a mapping it + // does not know about + continue; + } + if (!vma_record(start, end, perms)) { + ok = false; } - same = i < g_vma_count && g_vmas[i].start == start - && g_vmas[i].end == end && g_vmas[i].write == (perms[1] == 'w') - && g_vmas[i].exec == (perms[2] == 'x'); - i++; } - same = same && i == g_vma_count && !ferror(f); + g_vma_complete = ok && !ferror(f); fclose(f); - return same; + SENTRY_DEBUGF("Captured %zu mappings (%s) from %s", g_vma_count, + g_vma_complete ? "complete" : "INCOMPLETE", maps_path); } # endif #elif defined(SENTRY_PLATFORM_WINDOWS) @@ -1060,6 +1065,9 @@ build_stacktrace_for_thread( stack_size = (uint64_t)bytes_read; SENTRY_DEBUGF( "Read %zd bytes of stack from process %d", bytes_read, pid); +# if defined(__arm__) + vma_capture(pid); +# endif } } } @@ -1301,14 +1309,12 @@ build_stacktrace_for_thread( uint64_t current_fp = fp; int walk_count = 0; #if defined(__arm__) - // Fail closed: without a complete and still-current picture of the - // mappings neither frame-record shape can be told from the other, - // and guessing would emit fabricated frames rather than a shorter, - // honest stack. Only the crashing thread is stopped, so the mappings - // are re-read now that the stack bytes are in hand. - if (!g_vma_complete || !vma_snapshot_unchanged(ctx->crashed_pid)) { - SENTRY_TRACE("Mappings incomplete or changed; skipping the ARM32 " - "frame-pointer walk"); + // Fail closed: without a complete picture of the mappings neither + // frame-record shape can be told from the other, and guessing would + // emit fabricated frames rather than a shorter, honest stack. + if (!g_vma_complete) { + SENTRY_TRACE( + "Mappings incomplete; skipping the ARM32 frame-pointer walk"); walk_count = MAX_STACK_FRAMES; } #endif @@ -1964,12 +1970,6 @@ capture_modules_from_proc_maps(sentry_crash_context_t *ctx) char line[1024]; ctx->module_count = 0; -# if defined(__arm__) - g_vma_count = 0; - g_vma_complete = false; - g_vma_alloc_failed = false; - bool vma_ok = true; -# endif while (read_maps_line(f, line, sizeof(line))) { @@ -1982,25 +1982,12 @@ capture_modules_from_proc_maps(sentry_crash_context_t *ctx) perms, &offset, &pathname_offset); if (parsed < 4) { -# if defined(__arm__) - vma_ok = false; // a line this parser cannot read is a mapping it - // does not know about -# endif continue; } -# if defined(__arm__) - if (!vma_record(start, end, perms)) { - vma_ok = false; - } - if (ctx->module_count >= SENTRY_CRASH_MAX_MODULES) { - continue; // module inventory is full; keep reading for mappings - } -# else if (ctx->module_count >= SENTRY_CRASH_MAX_MODULES) { break; } -# endif // Must have a valid pathname (not [stack], [heap], etc.) if (pathname_offset <= 0 || line[pathname_offset] == '\0' @@ -2076,11 +2063,6 @@ capture_modules_from_proc_maps(sentry_crash_context_t *ctx) ctx->module_count++; } -# if defined(__arm__) - g_vma_complete = vma_ok && !ferror(f); - SENTRY_DEBUGF("Captured %zu mappings (%s) from /proc/%d/maps", g_vma_count, - g_vma_complete ? "complete" : "INCOMPLETE", ctx->crashed_pid); -# endif fclose(f); SENTRY_DEBUGF("Captured %u modules from /proc/%d/maps", ctx->module_count, ctx->crashed_pid);