Skip to content

Walk faulting thread's stack from ucontext in native crash handler - #230

Merged
winnerspiros merged 1 commit into
masterfrom
copilot/fix-application-start-crash
Apr 22, 2026
Merged

Walk faulting thread's stack from ucontext in native crash handler#230
winnerspiros merged 1 commit into
masterfrom
copilot/fix-application-start-crash

Conversation

Copilot AI commented Apr 22, 2026

Copy link
Copy Markdown

v2026.422.149's native_crash.log is non-diagnostic: every dump shows only crashHandler → libsigchain → vdso, because _Unwind_Backtrace walks the handler thread's stack and libgcc's unwinder terminates at the kernel signal trampoline (no CFI across the signal frame). The reporting user's crash is therefore reduced to "pc=0 (NULL function pointer call) on SDLThread ~5 s into startup, lr=0x75371fe050" — enough to know it's a NULL-fnptr crash, but not enough to identify the call site, which is why PRs #224#228 each shipped a plausible-but-unverified fix without resolving it.

This change rewrites the backtrace section of osu.Android/Native/crash_handler.cpp to recover the actual faulting call stack, scoped to that one file, no public API change.

Backtrace from saved ucontext (AArch64)

  • New walkContextStack(fd, ucontext) seeds frame 0 from mc.pc / mc.regs[30] (LR) and walks the x29 frame-pointer chain (prev_fp = *fp, prev_lr = *(fp+8)).
  • When pc == 0, emits a synthetic <NULL function pointer call> frame followed by the LR as <LR (return address of NULL call)> so the call site is the first symbolicated entry.
  • Validates each fp (non-NULL, 16-byte aligned, strictly monotonically increasing — stack grows down) before dereferencing; bad chains terminate the walk cleanly. Capped at 32 frames. Re-entrancy guard (already present) covers a SIGSEGV inside the walk.
  • Android requires frame pointers preserved on AArch64 (Android 10+ ABI), so this chain is reliable across libvulkan / libSDL3 / libosu_native / Mono boundaries.

/proc/self/maps dump

  • New dumpProcMaps(fd) appends the full process memory map to the crash log via open + read + write (signal-safe). Lets us match any PC dladdr couldn't symbolicate (anonymous-namespace functions, stripped .dynsym, JIT regions) to library + offset post-mortem.

Dump layout

The file order is now: signal info → registers → faulting-thread backtrace/proc/self/maps → secondary _Unwind_Backtrace (kept for parity, harmless when crashHandler→sigchain→vdso). fsync between sections so a partial-write loss is bounded.

Symbolication helper

  • Extracted writeFrame(fd, frameNo, pc, tagWhenUnresolved) so the new context walker and the existing _Unwind_Backtrace callback share identical formatting / dladdr resolution / logcat mirroring. tagWhenUnresolved is what surfaces the explicit <NULL function pointer call> and <LR (...)> annotations.

Not changed

No speculative startup-crash fix is included. With zero symbolicated frames the candidate hypotheses (Vulkan extension fnptr, SDL callback table, Veldrid device fnptr table, JNI marshal stub, …) are indistinguishable; another guess carries the same hit rate as #224#228 plus regression risk. Once the next crash log is captured with the new handler, the fix is direct.

…native crash handler

Agent-Logs-Url: https://github.com/winnerspiros/osu/sessions/2efd0d01-5093-4daf-9ed6-9fcd0635d185

Co-authored-by: winnerspiros <1675249+winnerspiros@users.noreply.github.com>
@winnerspiros
winnerspiros marked this pull request as ready for review April 22, 2026 06:51
Copilot AI review requested due to automatic review settings April 22, 2026 06:51
@winnerspiros
winnerspiros merged commit 1b7468f into master Apr 22, 2026
5 of 19 checks passed
@gitar-bot

gitar-bot Bot commented Apr 22, 2026

Copy link
Copy Markdown

Important

You are using the Gitar free plan. Upgrade to unlock code review, CI analysis, auto-apply, custom automations, and more.

Gitar

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Updates the Android native crash handler to produce a diagnostic crash log by recovering the faulting thread’s call stack from the signal ucontext (AArch64 FP chain), and by appending /proc/self/maps for post-mortem address-to-library correlation.

Changes:

  • Added a ucontext-based stack walker for AArch64 to log the crashing thread’s backtrace.
  • Added /proc/self/maps dumping to the crash log for improved offline symbolication/correlation.
  • Introduced a shared writeFrame() helper for consistent frame formatting and logcat mirroring, and reordered the dump layout to include both context-walk and _Unwind_Backtrace output.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +292 to +317
Dl_info info;
bool resolved = (pc != 0) && (dladdr(reinterpret_cast<void*>(pc), &info) != 0);

if (resolved && info.dli_fname) {
writeStr(fd, " ");
writeStr(fd, info.dli_fname);

if (info.dli_sname) {
uintptr_t off = pc - (uintptr_t)info.dli_saddr;
writeStr(fd, " (");
writeStr(fd, info.dli_sname);
writeStr(fd, "+0x");
writeHex64(fd, (uint64_t)off, 1);
writeStr(fd, ")");
} else if (info.dli_fbase) {
uintptr_t off = pc - (uintptr_t)info.dli_fbase;
writeStr(fd, " (lib+0x");
writeHex64(fd, (uint64_t)off, 1);
writeStr(fd, ")");
}
} else if (tagWhenUnresolved) {
writeStr(fd, " ");
writeStr(fd, tagWhenUnresolved);
} else {
writeStr(fd, " <unresolved>");
}

Copilot AI Apr 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tagWhenUnresolved is only emitted when symbolication fails, but call sites pass tags like "<LR (caller return address)>" and "" that are meant to annotate frames even when dladdr() succeeds. As written, those annotations will usually never appear (LR/PC typically resolve), making the output ambiguous and not matching the intent described in comments/PR description. Consider either always appending the tag (e.g., as a suffix) when non-null, or rename the parameter and only pass tags for truly-unresolved PCs (like pc==0).

Copilot uses AI. Check for mistakes.
Comment on lines +574 to +577
writeStr(fd, "Backtrace (from signal context):\n");
walkContextStack(fd, ucontext);
if (fd >= 0) fsync(fd);

Copilot AI Apr 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Calling fsync() from inside the signal handler is risky because fsync is not async-signal-safe and may block or deadlock on internal locks while the process is already in an unstable state. If the goal is to bound partial writes, consider removing the mid-handler fsync calls (and relying on the final flush/close), or gate them behind a debug/diagnostic build flag with documentation about the trade-off.

Copilot uses AI. Check for mistakes.
Comment on lines +279 to +283
// ----------------------------------------------------------------------------
// Symbolicate a single PC and write a " #NN pc=0xHEX /lib (sym+0xOFF)\n" line.
// `tagWhenUnresolved` lets the caller annotate frames whose PC is invalid
// (e.g. NULL function-pointer call → pc == 0).
// Also mirrors a short version to logcat.

Copilot AI Apr 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

writeFrame() duplicates the symbolication + formatting logic that already exists in unwindCallback(). To prevent drift between the “context” and “_Unwind_Backtrace” sections (and to match the intent of having identical formatting), consider refactoring unwindCallback() to call writeFrame() instead of maintaining two parallel implementations.

Copilot uses AI. Check for mistakes.
Comment on lines +431 to +432
if (frame == 0) writeStr(fd, " <empty — no recoverable context>\n");
}

Copilot AI Apr 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This frame == 0 branch looks unreachable: frame is always incremented by at least one writeFrame() call (even when pc == 0, the synthetic NULL-call frame is emitted). If you want an explicit “no frames” message, check for the specific case you care about (e.g., both pc and lr are 0 and the FP chain yields nothing), otherwise remove this dead branch.

Copilot uses AI. Check for mistakes.
Comment on lines +363 to +365
// - frame[0] is `pc` (or, if pc == 0 because of a NULL function pointer
// call, `lr` — the return address of that call, i.e. the call site).
// - subsequent frames come from following `x29 (fp)` chain:

Copilot AI Apr 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment describing “frame[0] is pc (or, if pc == 0, lr)” doesn’t match the implementation: when pc == 0 you currently emit a synthetic frame 0 for the NULL call and then emit lr as frame 1. Consider updating the comment to reflect the actual output ordering so the dump format is self-consistent.

Suggested change
// - frame[0] is `pc` (or, if pc == 0 because of a NULL function pointer
// call, `lr` — the return address of that call, i.e. the call site).
// - subsequent frames come from following `x29 (fp)` chain:
// - if `pc != 0`, frame[0] is `pc`.
// - if `pc == 0` (for example, a NULL function pointer call), we first emit
// a synthetic NULL-call frame as frame[0], then emit `lr` as frame[1]
// (the return address of that call, i.e. the call site).
// - subsequent frames come from following the `x29 (fp)` chain:

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants