From 73cfcb799b75bba43fc65281f046d207ab244212 Mon Sep 17 00:00:00 2001 From: AnkleBreaker Cowork Date: Mon, 20 Jul 2026 12:49:01 +0200 Subject: [PATCH 1/2] [FEAT] Native crash : in-process async-signal-safe handler (experimental, opt-in) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit options.enable_native_crash_handler (default off): installs handlers for SIGSEGV/ABRT/BUS/ILL/FPE/TRAP with SA_SIGINFO|SA_ONSTACK on a pre-allocated sigaltstack. The handler is write()-only over a pre-opened fd against a pre-captured module table (dl_iterate_phdr + NT_GNU_BUILD_ID) — no malloc, no locks — capturing PC (+LR on ARM) as (module, build-id, offset) frames, then chaining to any pre-existing handler (Unity/UGS) and re-raising the default so the process dies with the true signal. Dump is picked up at the NEXT launch and reported through the existing pipeline with crashType + osSignal; a native dump supersedes the generic unclean-shutdown, a restored managed crash still wins. Linux/Android only (fail-soft no-op elsewhere). Tests: pure parser (truncation-tolerant), take-once semantics, payload wire fields, and a Linux fork/SIGSEGV integration test asserting dump-written + chained-handler-ran + died-with-signal. v0.9.0. --- CHANGELOG.md | 26 ++ CMakeLists.txt | 4 +- include/tombstone/tombstone.h | 10 + src/client.cpp | 80 +++++ src/client.h | 7 + src/native_crash.cpp | 530 ++++++++++++++++++++++++++++++++++ src/native_crash.h | 86 ++++++ src/payloads.cpp | 4 + src/payloads.h | 2 + src/tombstone_api.cpp | 1 + tests/test_native_crash.cpp | 200 +++++++++++++ 11 files changed, 949 insertions(+), 1 deletion(-) create mode 100644 src/native_crash.cpp create mode 100644 src/native_crash.h create mode 100644 tests/test_native_crash.cpp diff --git a/CHANGELOG.md b/CHANGELOG.md index 52e64ee..286b259 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,32 @@ All notable changes to the Tombstack Native SDK (the `tombstone_*` C ABI and the `tombstone` library name are stable — Tombstack is the product name). +## [0.9.0] - 2026-07-20 + +### Added — in-process native crash handler (EXPERIMENTAL, opt-in; Linux/Android) + +- **`options.enable_native_crash_handler`** (default **0**/off). When enabled on a POSIX/ELF + target, the SDK installs an **async-signal-safe** handler for `SIGSEGV`, `SIGABRT`, `SIGBUS`, + `SIGILL`, `SIGFPE`, `SIGTRAP`. On a fatal signal it writes a minimal dump — the faulting `PC` + (and `LR` on ARM) resolved to **`(module, build-id, offset)`** frames — to + `/native-crash.dump`, then turns it into a real crash report at the **next launch** + (through the existing sidecar/report pipeline). Grouping signature is derived from the native + frames; the report carries `crashType` (`native_crash`/`signal`) and `osSignal`. +- **No on-device symbolication.** Frames are addresses only; symbolication happens server-side + against uploaded symbols (the `libil2cpp.so` pipeline). The handler does `write()` + fixed + arithmetic only — no `malloc`, no locks, no managed callbacks — with a pre-opened dump fd, a + pre-captured module table (`dl_iterate_phdr` + `NT_GNU_BUILD_ID`), a pre-allocated `sigaltstack` + (survives stack-overflow SIGSEGV), and a re-entrance/double-fault guard. +- **Handler chaining.** The previously-installed disposition for each signal is saved and invoked + after the dump (both `SA_SIGINFO` and legacy forms), so a co-resident engine handler (Unity / + UGS Cloud Diagnostics) still runs; the process is then re-raised to the default action so it + dies with the true signal. A CI integration test faults a real child process and asserts all + three: dump written, chained handler ran, died with `SIGSEGV`. +- **Fail-soft + scoped.** `install()` returns false on unsupported platforms (Windows SEH and + iOS/Mach are tracked follow-ups) or any setup failure — the SDK keeps the existing + unclean-shutdown heuristic. When a native dump exists it supersedes the generic + `unclean-shutdown` report for that session; a restored managed crash still wins over both. + ## [0.8.0] - 2026-07-10 ### Added — device identity: the SDK never sends an anonymous user (Unity 0.16 parity) diff --git a/CMakeLists.txt b/CMakeLists.txt index eabbbbe..ed3dff2 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,5 @@ cmake_minimum_required(VERSION 3.16) -project(tombstone VERSION 0.8.0 LANGUAGES C CXX) +project(tombstone VERSION 0.9.0 LANGUAGES C CXX) option(TOMBSTONE_BUILD_STATIC "Also build a static tombstone library" OFF) option(TOMBSTONE_BUILD_EXAMPLES "Build the example programs" ON) @@ -74,6 +74,7 @@ set(TOMBSTONE_CORE_SOURCES src/frame_stats.cpp src/json_scan.cpp src/json_writer.cpp + src/native_crash.cpp src/payloads.cpp src/platform.cpp src/preinit.cpp @@ -165,6 +166,7 @@ if(TOMBSTONE_BUILD_TESTS) tests/test_frame_stats.cpp tests/test_json_scan.cpp tests/test_json_writer.cpp + tests/test_native_crash.cpp tests/test_payloads.cpp tests/test_preinit.cpp tests/test_retry_after.cpp diff --git a/include/tombstone/tombstone.h b/include/tombstone/tombstone.h index 6c6ff33..d729d8d 100644 --- a/include/tombstone/tombstone.h +++ b/include/tombstone/tombstone.h @@ -123,6 +123,16 @@ typedef struct tombstone_options { /** Nonzero (default) = emit a `tombstone.rtt_ms` metric after each successful ingest * POST (the upload round-trip time). 0 disables this self-telemetry. */ int enable_rtt_metric; + /** EXPERIMENTAL (v0.9), default 0 (OFF): install the in-process native crash + * handler. When on, the SDK catches fatal signals (SIGSEGV/ABRT/BUS/ILL/ + * FPE/TRAP) with an async-signal-safe handler that writes a minimal dump + * ((module, build-id, offset) frames — NO on-device symbolication) and + * chains to any pre-existing handler (Unity / UGS); the dump is turned + * into a crash report at the NEXT launch. POSIX/ELF only (Linux/Android) + * — a no-op elsewhere. Off keeps the existing unclean-shutdown heuristic. + * Enable only with the game engine's own crash handler installed FIRST so + * chaining is exercised. */ + int enable_native_crash_handler; /** Optional diagnostics sink (see tombstone_log_callback). NULL = silent. */ tombstone_log_callback log_callback; /** Opaque pointer handed back to log_callback. */ diff --git a/src/client.cpp b/src/client.cpp index 1d4513e..3a9f34d 100644 --- a/src/client.cpp +++ b/src/client.cpp @@ -6,10 +6,12 @@ #include "json_writer.h" #include "payloads.h" #include "platform.h" +#include "signature.h" #include "transport.h" #include #include +#include #include #include #include @@ -120,6 +122,7 @@ tombstone_result Client::init(const tombstone_options &options) { session_log_enabled_ = options.enable_session_log != 0; unclean_detection_enabled_ = options.enable_unclean_shutdown_detection != 0; rtt_metric_enabled_ = options.enable_rtt_metric != 0; + native_crash_enabled_ = options.enable_native_crash_handler != 0; consent_ = options.consent_granted != 0; // v0.7 send gate: nonzero (default) latches immediately — today's behavior. // 0 defers heartbeats + batch drains until start_session(); a pre-init @@ -136,6 +139,13 @@ tombstone_result Client::init(const tombstone_options &options) { ? fs::path{options.data_dir} : default_data_dir(); configure_storage(); + // v0.9: install the native crash handler once storage is ready (dump fd + + // module table). Opt-in + fail-soft: unsupported platforms / setup failure + // just leave native_crash_enabled_ behavior to the unclean-shutdown + // heuristic. Must run AFTER the previous dump was picked up (above). + if (native_crash_enabled_ && storage_available_) { + native_crash::install(data_dir_, sdk_log_); + } // v0.8: acquire the device-derived provisional user id BEFORE anything can // send (worker/heartbeats start below), so no payload ever ships anonymous. acquire_device_identity(); @@ -183,6 +193,11 @@ void Client::configure_storage() { } else { marker_.remove(); // never leave a stale marker behind } + // v0.9: pick up a native crash dump from the previous run BEFORE the + // handler is (re)installed below — install() truncates the dump file. + if (native_crash_enabled_) { + previous_native_dump_ = native_crash::take_previous(data_dir_, sdk_log_); + } } catch (const std::exception &e) { storage_available_ = false; sdk_log_.warn(std::string{"storage setup failed: "} + e.what()); @@ -310,6 +325,18 @@ void Client::start_session_tracking() { */ void Client::report_unclean_shutdown(const SessionMarkerData &previous) { if (has_restored_crash_) { + // A managed crash sidecar survived — richer than anything we can + // synthesize; consume any native dump so it can't double-report. + previous_native_dump_.reset(); + return; + } + // v0.9: a captured native crash dump beats the generic heuristic — it + // carries the real signal + symbolicatable frames. Report it INSTEAD of the + // "unclean-shutdown" placeholder for the same dead session. + if (previous_native_dump_.has_value()) { + const NativeCrashDump dump = *previous_native_dump_; + previous_native_dump_.reset(); + report_native_crash(previous, dump); return; } CrashPayload payload; @@ -329,6 +356,59 @@ void Client::report_unclean_shutdown(const SessionMarkerData &previous) { SidecarKind::crash, payload.log, /*log_from_previous=*/true); } +/** + * v0.9: turn a native crash dump captured last run into a crash report. The + * signature is derived from the top native frames (module+offset) so the same + * native bug groups together; the human hint names the signal; the frames go + * in stackTrace as "module+offset buildId" lines for server-side symbolication + * against uploaded symbols. crashType/osSignal ride as fields (never in the + * signature). A SIGABRT is classified "signal" (a managed abort/assert path) + * vs. the memory-access signals as "native_crash". + */ +void Client::report_native_crash(const SessionMarkerData &previous, const NativeCrashDump &dump) { + std::string frames_text; + for (const NativeCrashFrame &f : dump.frames) { + frames_text += f.module; + frames_text += '+'; + char off[20]; + std::snprintf(off, sizeof(off), "0x%llx", static_cast(f.offset)); + frames_text += off; + if (!f.build_id.empty()) { + frames_text += ' '; + frames_text += f.build_id; + } + frames_text += '\n'; + } + + const std::string sig_name = native_crash::signal_name(dump.signal_no); + char fault[24]; + std::snprintf(fault, sizeof(fault), "0x%llx", static_cast(dump.fault_addr)); + + CrashPayload payload; + payload.occurred_at_iso = now_iso8601_utc_ms(); // detection time (dead session's ts is off-window) + payload.build_version = previous.build_version.empty() ? build_version_ : previous.build_version; + payload.os = previous.os.empty() ? os_ : previous.os; + payload.arch = previous.arch.empty() ? arch_ : previous.arch; + // Signature over the native frames (falls back to the signal when frames + // were unresolved) — mirrors compute_crash_signature's hint+frames hashing. + payload.signature = compute_crash_signature(sig_name, frames_text); + payload.stack_hint = "Native crash: " + sig_name + " at " + fault; + payload.stack_trace = frames_text; + payload.user_id = current_user_id(); + payload.steam_id = current_steam_id(); + payload.environment = current_match_context().environment; + payload.device = current_device(); + payload.log = session_log_enabled_ && had_previous_log_; + // SIGABRT (POSIX signal 6) is the managed abort/assert/std::terminate path + // -> "signal"; the memory-access faults are "native_crash". Literal 6 (not + // the SIGABRT macro) so the classification tracks the DEVICE's signal number + // regardless of the host the SDK was built on (Windows maps SIGABRT to 22). + payload.crash_type = (dump.signal_no == 6) ? "signal" : "native_crash"; + payload.os_signal = dump.signal_no; + enqueue_ingest(crashes_path, build_crash_json(payload), Durability::write_ahead, + SidecarKind::crash, payload.log, /*log_from_previous=*/true); +} + void Client::heartbeat_loop() { std::unique_lock lock(heartbeat_mutex_); while (!heartbeat_stop_) { diff --git a/src/client.h b/src/client.h index 1ed2100..7d3a853 100644 --- a/src/client.h +++ b/src/client.h @@ -7,6 +7,7 @@ #include "breadcrumb_ring.h" #include "dedupe.h" #include "frame_stats.h" +#include "native_crash.h" #include "payloads.h" #include "sampler.h" #include "sdk_log.h" @@ -100,6 +101,7 @@ class Client { void drain_sidecars(); void start_session_tracking(); void report_unclean_shutdown(const SessionMarkerData &previous); + void report_native_crash(const SessionMarkerData &previous, const NativeCrashDump &dump); void heartbeat_loop(); void stop_heartbeat(); /** Worker-thread hook: parse a heartbeat ack and fulfil pull requests @@ -205,6 +207,11 @@ class Client { std::optional previous_marker_; bool had_previous_log_{false}; bool has_restored_crash_{false}; + // v0.9 native crash handler (experimental, opt-in). `native_crash_enabled_` + // mirrors the option; `previous_native_dump_` holds a dump recovered from + // the previous run (picked up at init, reported by start_session_tracking). + bool native_crash_enabled_{false}; + std::optional previous_native_dump_; mutable std::mutex user_mutex_; std::string user_id_; diff --git a/src/native_crash.cpp b/src/native_crash.cpp new file mode 100644 index 0000000..9fbab93 --- /dev/null +++ b/src/native_crash.cpp @@ -0,0 +1,530 @@ +// _GNU_SOURCE must precede every system header: it unlocks dl_iterate_phdr / +// struct dl_phdr_info in and the REG_* ucontext indices in +// on glibc (both are __USE_GNU-guarded). No effect on non-glibc platforms. +#if defined(__linux__) || defined(__ANDROID__) +#ifndef _GNU_SOURCE +#define _GNU_SOURCE 1 +#endif +#endif + +#include "native_crash.h" + +#include "sdk_log.h" + +#include +#include +#include + +// --------------------------------------------------------------------------- +// Platform gate. The live capture path is POSIX/ELF only (Linux + Android); +// everywhere else only the pure parser + next-launch reader compile, and +// install() is a no-op returning false so callers keep the heuristic. +// --------------------------------------------------------------------------- +#if defined(__linux__) || defined(__ANDROID__) +#define TOMBSTONE_NATIVE_CRASH_POSIX 1 +#else +#define TOMBSTONE_NATIVE_CRASH_POSIX 0 +#endif + +#if TOMBSTONE_NATIVE_CRASH_POSIX +#include +#include +#include +#include +#include +#include +#include +#include +#include +#endif + +namespace tombstone { +namespace native_crash { + +namespace { + +/** Write a signed decimal int into `buf`; returns the length. Pure — used by + * signal_name on every platform (no libc signal macros involved). */ +std::size_t dec_to(long value, char *buf) { + std::size_t n = 0; + if (value < 0) { + buf[n++] = '-'; + value = -value; + } + char tmp[24]; + std::size_t t = 0; + do { + tmp[t++] = static_cast('0' + (value % 10)); + value /= 10; + } while (value != 0); + while (t > 0) buf[n++] = tmp[--t]; + return n; +} + +} // namespace + +std::string signal_name(int signal_no) { + // Literal POSIX signal numbers (stable across archs): the dump stores the + // raw number captured on the device, so this maps identically on every + // host that reads it back — no dependency on host macros (which + // don't even define SIGBUS/SIGTRAP on Windows). + switch (signal_no) { + case 4: return "SIGILL"; + case 5: return "SIGTRAP"; + case 6: return "SIGABRT"; + case 7: return "SIGBUS"; + case 8: return "SIGFPE"; + case 11: return "SIGSEGV"; + default: { + char buf[24]; + const std::size_t n = dec_to(signal_no, buf); + return "signal " + std::string(buf, n); + } + } +} + +#if TOMBSTONE_NATIVE_CRASH_POSIX + +namespace { + +// The six process-fatal signals a game crash arrives as. SIGABRT covers a +// failed assert / std::terminate / __stack_chk_fail; the rest are hardware. +constexpr int kSignals[] = {SIGSEGV, SIGABRT, SIGBUS, SIGILL, SIGFPE, SIGTRAP}; +constexpr int kSignalCount = static_cast(sizeof(kSignals) / sizeof(kSignals[0])); + +constexpr int kMaxModules = 512; +constexpr std::size_t kMaxBuildIdBytes = 20; // SHA-1 GNU build id +constexpr std::size_t kMaxNameLen = 255; + +/** Write `value` as lowercase hex ("0x...") into `buf`; returns the length. + * Pure arithmetic — usable from the signal handler. */ +std::size_t hex_to(std::uint64_t value, char *buf) { + buf[0] = '0'; + buf[1] = 'x'; + std::size_t n = 2; + int shift = 60; + while (shift > 0 && ((value >> shift) & 0xF) == 0) shift -= 4; + for (; shift >= 0; shift -= 4) { + const int nibble = static_cast((value >> shift) & 0xF); + buf[n++] = static_cast(nibble < 10 ? '0' + nibble : 'a' + (nibble - 10)); + } + return n; +} + +/** One pre-captured loaded module (filled at install time only). */ +struct ModuleEntry { + std::uintptr_t base{0}; + std::uintptr_t end{0}; + char name[kMaxNameLen + 1]{}; + unsigned char build_id[kMaxBuildIdBytes]{}; + std::size_t build_id_len{0}; +}; + +// --- process-wide handler state. Every field is set at install time and only +// READ from the handler (plus the atomic guard). No allocation ever. ----- +struct HandlerState { + ModuleEntry modules[kMaxModules]; + int module_count{0}; + int dump_fd{-1}; + struct sigaction old_actions[kSignalCount]; + bool installed{false}; + volatile sig_atomic_t in_handler{0}; // re-entrance / double-fault guard +}; + +HandlerState g_state; + +// Fixed alternate signal stack (glibc 2.34+ made SIGSTKSZ a runtime value that +// can't size an array): 64 KiB is comfortably above SIGSTKSZ on every target. +char g_alt_stack[65536]; + +// -------- install-time module table (dl_iterate_phdr callback) ------------- + +void copy_bounded(char *dst, const char *src, std::size_t cap) { + if (src == nullptr) { + dst[0] = '\0'; + return; + } + std::size_t i = 0; + for (; i < cap && src[i] != '\0'; ++i) dst[i] = src[i]; + dst[i] = '\0'; +} + +void read_build_id(const ElfW(Phdr) & phdr, std::uintptr_t base, ModuleEntry &entry) { + const auto *note = reinterpret_cast(base + phdr.p_vaddr); + const auto *end = reinterpret_cast(note) + phdr.p_memsz; + while (reinterpret_cast(note) + sizeof(ElfW(Nhdr)) <= end) { + const char *name = reinterpret_cast(note) + sizeof(ElfW(Nhdr)); + const char *desc = name + ((note->n_namesz + 3) & ~3u); + if (note->n_type == NT_GNU_BUILD_ID && note->n_namesz == 4 && + std::memcmp(name, "GNU", 3) == 0) { + std::size_t len = note->n_descsz; + if (len > kMaxBuildIdBytes) len = kMaxBuildIdBytes; + std::memcpy(entry.build_id, desc, len); + entry.build_id_len = len; + return; + } + note = reinterpret_cast(desc + ((note->n_descsz + 3) & ~3u)); + } +} + +int phdr_callback(struct dl_phdr_info *info, std::size_t /*size*/, void *data) { + auto *state = static_cast(data); + if (state->module_count >= kMaxModules) return 0; + ModuleEntry &entry = state->modules[state->module_count]; + + const char *name = (info->dlpi_name != nullptr && info->dlpi_name[0] != '\0') + ? info->dlpi_name + : "main"; + copy_bounded(entry.name, name, kMaxNameLen); + + std::uintptr_t lo = 0; + std::uintptr_t hi = 0; + bool first = true; + for (int i = 0; i < info->dlpi_phnum; ++i) { + const ElfW(Phdr) &phdr = info->dlpi_phdr[i]; + if (phdr.p_type == PT_LOAD) { + const std::uintptr_t seg_lo = info->dlpi_addr + phdr.p_vaddr; + const std::uintptr_t seg_hi = seg_lo + phdr.p_memsz; + if (first || seg_lo < lo) lo = seg_lo; + if (first || seg_hi > hi) hi = seg_hi; + first = false; + } else if (phdr.p_type == PT_NOTE) { + read_build_id(phdr, info->dlpi_addr, entry); + } + } + if (first) return 0; // no PT_LOAD — skip + entry.base = lo; + entry.end = hi; + state->module_count++; + return 0; +} + +/** Linear resolve of an absolute PC to (module index, offset). Handler-safe + * (no allocation). Returns -1 when the address falls in no known module. */ +int resolve_module(std::uintptr_t addr, std::uint64_t &offset_out) { + for (int i = 0; i < g_state.module_count; ++i) { + const ModuleEntry &m = g_state.modules[i]; + if (addr >= m.base && addr < m.end) { + offset_out = static_cast(addr - m.base); + return i; + } + } + return -1; +} + +// -------- the async-signal-safe crash handler ------------------------------ + +void write_all(int fd, const char *buf, std::size_t len) { + std::size_t off = 0; + while (off < len) { + const ssize_t w = write(fd, buf + off, len - off); + if (w <= 0) { + if (w < 0 && errno == EINTR) continue; + return; // give up silently — never loop-forever in a handler + } + off += static_cast(w); + } +} + +void write_str(int fd, const char *s) { write_all(fd, s, std::strlen(s)); } + +void write_hex(int fd, std::uint64_t v) { + char buf[20]; + write_all(fd, buf, hex_to(v, buf)); +} + +void write_dec(int fd, long v) { + char buf[24]; + write_all(fd, buf, dec_to(v, buf)); +} + +/** Extract the instruction pointer and (where the arch has one) the link + * register from the signal ucontext. Values are 0 when unavailable. */ +void extract_pc_lr(void *ucontext, std::uintptr_t &pc, std::uintptr_t &lr) { + pc = 0; + lr = 0; + if (ucontext == nullptr) return; + auto *uc = static_cast(ucontext); +#if defined(__aarch64__) + pc = static_cast(uc->uc_mcontext.pc); + lr = static_cast(uc->uc_mcontext.regs[30]); +#elif defined(__arm__) + pc = static_cast(uc->uc_mcontext.arm_pc); + lr = static_cast(uc->uc_mcontext.arm_lr); +#elif defined(__x86_64__) + pc = static_cast(uc->uc_mcontext.gregs[REG_RIP]); +#elif defined(__i386__) + pc = static_cast(uc->uc_mcontext.gregs[REG_EIP]); +#else + (void)uc; +#endif +} + +void write_frame(int fd, std::uintptr_t addr) { + std::uint64_t offset = 0; + const int idx = resolve_module(addr, offset); + write_str(fd, "frame "); + if (idx < 0) { + // Unresolved (JIT / stripped / stack) — record the absolute addr so + // it's not silently lost; server-side it simply won't symbolicate. + write_str(fd, "?? "); + write_hex(fd, static_cast(addr)); + write_str(fd, " \n"); + return; + } + const ModuleEntry &m = g_state.modules[idx]; + write_str(fd, m.name); + write_str(fd, " "); + write_hex(fd, offset); + write_str(fd, " "); + static const char kHex[] = "0123456789abcdef"; + char bid[kMaxBuildIdBytes * 2]; + std::size_t bn = 0; + for (std::size_t i = 0; i < m.build_id_len; ++i) { + bid[bn++] = kHex[(m.build_id[i] >> 4) & 0xF]; + bid[bn++] = kHex[m.build_id[i] & 0xF]; + } + write_all(fd, bid, bn); + write_str(fd, "\n"); +} + +/** Restore the default disposition for `sig` and re-raise so the process dies + * with the TRUE signal after we + any chained handler are done. */ +void reraise_default(int sig) { + struct sigaction dfl; + std::memset(&dfl, 0, sizeof(dfl)); + dfl.sa_handler = SIG_DFL; + sigemptyset(&dfl.sa_mask); + sigaction(sig, &dfl, nullptr); + raise(sig); +} + +/** Invoke the previously-installed handler for `sig` (chaining), supporting + * both SA_SIGINFO 3-arg and plain 1-arg forms, and SIG_DFL/SIG_IGN. */ +void chain_old(int sig, siginfo_t *info, void *ucontext) { + const struct sigaction *old = nullptr; + for (int i = 0; i < kSignalCount; ++i) { + if (kSignals[i] == sig) { + old = &g_state.old_actions[i]; + break; + } + } + if (old == nullptr) { + reraise_default(sig); + return; + } + if ((old->sa_flags & SA_SIGINFO) != 0 && old->sa_sigaction != nullptr) { + old->sa_sigaction(sig, info, ucontext); + return; + } + if (old->sa_handler == SIG_DFL || old->sa_handler == SIG_IGN || old->sa_handler == nullptr) { + reraise_default(sig); + return; + } + old->sa_handler(sig); +} + +extern "C" void tombstone_signal_handler(int sig, siginfo_t *info, void *ucontext) { + // Re-entrance / double-fault guard: if a second fatal signal arrives while + // we're dumping, don't recurse — hand straight to the old handler. + if (g_state.in_handler != 0) { + chain_old(sig, info, ucontext); + return; + } + g_state.in_handler = 1; + + const int fd = g_state.dump_fd; + if (fd >= 0) { + write_str(fd, "tombstone-native-crash 1\n"); + write_str(fd, "signal "); + write_dec(fd, sig); + write_str(fd, "\n"); + write_str(fd, "code "); + write_dec(fd, info != nullptr ? info->si_code : 0); + write_str(fd, "\n"); + write_str(fd, "fault "); + write_hex(fd, info != nullptr ? reinterpret_cast(info->si_addr) : 0); + write_str(fd, "\n"); + + std::uintptr_t pc = 0; + std::uintptr_t lr = 0; + extract_pc_lr(ucontext, pc, lr); + if (pc != 0) write_frame(fd, pc); + if (lr != 0) write_frame(fd, lr); + write_str(fd, "end\n"); + fsync(fd); + } + + // Hand off to whatever was installed before us (Unity / UGS), then ensure + // the process still dies with the real signal if that handler returns. + chain_old(sig, info, ucontext); + reraise_default(sig); +} + +} // namespace + +bool install(const std::filesystem::path &data_dir, SdkLog &sdk_log) { + if (g_state.installed) return true; + + // 1) Pre-open the dump fd (truncating): the handler only write()s to it. + const std::filesystem::path dump_path = data_dir / kDumpFileName; + g_state.dump_fd = open(dump_path.c_str(), O_WRONLY | O_CREAT | O_TRUNC, 0600); + if (g_state.dump_fd < 0) { + sdk_log.warn("native crash: could not open dump file; handler not installed"); + return false; + } + + // 2) Pre-capture the loaded-module table (base/end/name/build-id). + g_state.module_count = 0; + dl_iterate_phdr(phdr_callback, &g_state); + + // 3) Register the pre-allocated alternate signal stack so a stack-overflow + // SIGSEGV still has room to run the handler (SA_ONSTACK). + stack_t ss; + std::memset(&ss, 0, sizeof(ss)); + ss.ss_sp = g_alt_stack; + ss.ss_size = sizeof(g_alt_stack); + ss.ss_flags = 0; + sigaltstack(&ss, nullptr); + + // 4) Install, saving each previous action for chaining. + struct sigaction sa; + std::memset(&sa, 0, sizeof(sa)); + sa.sa_sigaction = tombstone_signal_handler; + sigemptyset(&sa.sa_mask); + sa.sa_flags = SA_SIGINFO | SA_ONSTACK | SA_RESTART; + bool any = false; + for (int i = 0; i < kSignalCount; ++i) { + if (sigaction(kSignals[i], &sa, &g_state.old_actions[i]) == 0) { + any = true; + } + } + if (!any) { + close(g_state.dump_fd); + g_state.dump_fd = -1; + sdk_log.warn("native crash: sigaction failed for all signals; not installed"); + return false; + } + g_state.installed = true; + sdk_log.info("native crash handler installed (experimental)"); + return true; +} + +#else // non-POSIX: capture unsupported, keep the heuristic. + +bool install(const std::filesystem::path & /*data_dir*/, SdkLog &sdk_log) { + sdk_log.info("native crash handler not supported on this platform (using heuristic)"); + return false; +} + +#endif // TOMBSTONE_NATIVE_CRASH_POSIX + +// --------------------------------------------------------------------------- +// Pure, cross-platform: dump parsing + next-launch pickup. +// --------------------------------------------------------------------------- + +namespace { + +std::uint64_t parse_hex(std::string_view s) { + std::uint64_t v = 0; + std::size_t i = 0; + if (s.size() >= 2 && s[0] == '0' && (s[1] == 'x' || s[1] == 'X')) i = 2; + for (; i < s.size(); ++i) { + const char c = s[i]; + int d; + if (c >= '0' && c <= '9') d = c - '0'; + else if (c >= 'a' && c <= 'f') d = 10 + (c - 'a'); + else if (c >= 'A' && c <= 'F') d = 10 + (c - 'A'); + else break; + v = (v << 4) | static_cast(d); + } + return v; +} + +long parse_dec(std::string_view s) { + long v = 0; + bool neg = false; + std::size_t i = 0; + if (i < s.size() && s[i] == '-') { + neg = true; + ++i; + } + for (; i < s.size() && s[i] >= '0' && s[i] <= '9'; ++i) v = v * 10 + (s[i] - '0'); + return neg ? -v : v; +} + +} // namespace + +std::optional parse_dump(std::string_view text) { + // Tolerate a truncated tail: parse line by line, keep what's valid, only + // require the magic header to accept the dump at all. + NativeCrashDump dump; + bool saw_header = false; + std::size_t pos = 0; + while (pos < text.size()) { + const std::size_t nl = text.find('\n', pos); + const std::string_view line = + text.substr(pos, nl == std::string_view::npos ? std::string_view::npos : nl - pos); + pos = (nl == std::string_view::npos) ? text.size() : nl + 1; + if (line.empty()) continue; + + auto starts_with = [&](std::string_view p) { + return line.size() >= p.size() && line.substr(0, p.size()) == p; + }; + if (starts_with("tombstone-native-crash")) { + saw_header = true; + } else if (starts_with("signal ")) { + dump.signal_no = static_cast(parse_dec(line.substr(7))); + } else if (starts_with("code ")) { + dump.code = static_cast(parse_dec(line.substr(5))); + } else if (starts_with("fault ")) { + dump.fault_addr = parse_hex(line.substr(6)); + } else if (starts_with("frame ")) { + // "frame [build-id-hex]" or "frame ?? " + const std::string_view rest = line.substr(6); + const std::size_t sp1 = rest.find(' '); + if (sp1 == std::string_view::npos) continue; + NativeCrashFrame frame; + frame.module = std::string(rest.substr(0, sp1)); + const std::string_view after = rest.substr(sp1 + 1); + const std::size_t sp2 = after.find(' '); + frame.offset = parse_hex(after.substr(0, sp2)); + if (sp2 != std::string_view::npos) { + std::string_view bid = after.substr(sp2 + 1); + while (!bid.empty() && bid.back() == ' ') bid.remove_suffix(1); + frame.build_id = std::string(bid); + } + dump.frames.push_back(std::move(frame)); + } + // "end" and anything unknown: ignored. + } + if (!saw_header) return std::nullopt; + return dump; +} + +std::optional take_previous(const std::filesystem::path &data_dir, SdkLog &sdk_log) { + const std::filesystem::path dump_path = data_dir / kDumpFileName; + std::error_code ec; + if (!std::filesystem::exists(dump_path, ec)) return std::nullopt; + std::optional result; + try { + std::FILE *f = std::fopen(dump_path.string().c_str(), "rb"); + if (f != nullptr) { + std::string text; + char buf[4096]; + std::size_t n; + while ((n = std::fread(buf, 1, sizeof(buf), f)) > 0) text.append(buf, n); + std::fclose(f); + result = parse_dump(text); + } + } catch (const std::exception &e) { + sdk_log.warn(std::string{"native crash: dump read failed: "} + e.what()); + } + // Always delete: a dump is consumed exactly once, even if unparsable, so a + // corrupt file can never re-report every launch. + std::filesystem::remove(dump_path, ec); + return result; +} + +} // namespace native_crash +} // namespace tombstone diff --git a/src/native_crash.h b/src/native_crash.h new file mode 100644 index 0000000..405e824 --- /dev/null +++ b/src/native_crash.h @@ -0,0 +1,86 @@ +#ifndef TOMBSTONE_SRC_NATIVE_CRASH_H +#define TOMBSTONE_SRC_NATIVE_CRASH_H + +#include +#include +#include +#include +#include +#include + +namespace tombstone { + +class SdkLog; + +/** One resolved native frame: the module it fell in and the module-relative + * offset (what addr2line / server-side symbolication takes), plus the ELF + * GNU build id when the module carries one (empty = unknown). */ +struct NativeCrashFrame { + std::string module; + std::uint64_t offset{0}; + std::string build_id; +}; + +/** A parsed native crash dump from a previous run: the raw signal facts plus + * the captured frames (frame 0 = PC, frame 1 = LR where the arch has one). */ +struct NativeCrashDump { + int signal_no{0}; + int code{0}; + std::uint64_t fault_addr{0}; + std::vector frames; +}; + +/** + * In-process native crash capture (v0.9, EXPERIMENTAL, default-off). + * + * Design rules (the async-signal-safety contract): + * - EVERYTHING expensive happens at install time, in a normal context: the + * module table (base/end/name/build-id via dl_iterate_phdr + PT_NOTE) is + * pre-captured, the dump fd is pre-opened, the alt stack pre-allocated. + * - The HANDLER does arithmetic and write() only: re-entrance guard, PC/LR + * from ucontext, linear module resolve, fixed-format dump, fsync — no + * malloc, no locks, no printf, no managed callbacks. + * - CHAINING: the previous sigaction is saved and re-installed before the + * old handler is invoked (SA_SIGINFO and plain handlers both supported), + * so Unity / UGS Cloud Diagnostics style co-resident handlers still run; + * if the old handler returns, the default action is restored and the + * signal re-raised so the process dies with the true signal. + * - NO on-device symbolication: frames are (module, build-id, offset) only; + * symbolication happens server-side against uploaded symbols. + * + * Platform scope v1: Linux + Android (ELF/POSIX). install() returns false on + * other platforms (and on any setup failure) — callers degrade gracefully to + * the existing unclean-shutdown heuristic. Windows SEH and iOS/Mach are the + * tracked follow-ups. + */ +namespace native_crash { + +/** Dump file name inside the SDK data dir (sibling of session.lock). */ +inline constexpr const char *kDumpFileName = "native-crash.dump"; + +/** + * Read AND delete a surviving dump from the previous run (next-launch pickup, + * normal context, any platform). nullopt = no dump / unreadable / unparsable. + */ +std::optional take_previous(const std::filesystem::path &data_dir, SdkLog &sdk_log); + +/** + * Install the signal handlers (SIGSEGV/SIGABRT/SIGBUS/SIGILL/SIGFPE/SIGTRAP). + * Call once, from a normal context, AFTER take_previous (the dump file is + * truncated here). Returns false on unsupported platforms or setup failure — + * never throws into game code. + */ +bool install(const std::filesystem::path &data_dir, SdkLog &sdk_log); + +/** Pure, host-testable: parse the dump text (tolerates a truncated tail — + * a crash mid-write must still yield the facts written so far). */ +std::optional parse_dump(std::string_view text); + +/** Pure: "SIGSEGV" for 11 etc.; "signal " when unknown. */ +std::string signal_name(int signal_no); + +} // namespace native_crash + +} // namespace tombstone + +#endif // TOMBSTONE_SRC_NATIVE_CRASH_H diff --git a/src/payloads.cpp b/src/payloads.cpp index 9854c10..104a615 100644 --- a/src/payloads.cpp +++ b/src/payloads.cpp @@ -186,6 +186,10 @@ std::string build_crash_json(const CrashPayload &payload) { correlation_fields(json, payload.role, payload.server_id, payload.match_id, payload.session_id); optional_field(json, "environment", payload.environment, limits::environment); device_object(json, payload.device); + // v0.9 native crash enrichment: coarse cause + POSIX signal number (both + // omitted when unset — an ordinary managed crash carries neither). + optional_field(json, "crashType", payload.crash_type, limits::signature); + optional_int_field(json, "osSignal", payload.os_signal); json.end_object(); return json.str(); } diff --git a/src/payloads.h b/src/payloads.h index 843499d..b2d5d3d 100644 --- a/src/payloads.h +++ b/src/payloads.h @@ -143,6 +143,8 @@ struct CrashPayload { std::string session_id; // empty -> omitted std::string environment; // empty -> omitted (server defaults to "production") DevicePayload device; // all-unset -> `device` object omitted + std::string crash_type; // v0.9 native handler: "native_crash"/"signal"; empty -> omitted + int os_signal{0}; // v0.9 POSIX signal number when signaled; 0 -> omitted }; struct BugReportPayload { diff --git a/src/tombstone_api.cpp b/src/tombstone_api.cpp index f8a6463..40eeb9b 100644 --- a/src/tombstone_api.cpp +++ b/src/tombstone_api.cpp @@ -144,6 +144,7 @@ tombstone_result tombstone_options_init(tombstone_options *options) { options->consent_granted = 1; options->auto_start_session = 1; // 0 = defer sending until tombstone_start_session() options->enable_rtt_metric = 1; + options->enable_native_crash_handler = 0; // EXPERIMENTAL, opt-in (v0.9) options->log_callback = nullptr; options->log_callback_user_data = nullptr; return TOMBSTONE_OK; diff --git a/tests/test_native_crash.cpp b/tests/test_native_crash.cpp new file mode 100644 index 0000000..4fadeec --- /dev/null +++ b/tests/test_native_crash.cpp @@ -0,0 +1,200 @@ +#include "native_crash.h" +#include "payloads.h" +#include "sdk_log.h" +#include "test_framework.h" + +#include +#include + +namespace fs = std::filesystem; +using tombstone::NativeCrashDump; +using tombstone::SdkLog; +namespace nc = tombstone::native_crash; + +// -------------------------------------------------------------------------- +// Pure parser (cross-platform): the dump text is fixed-format and must survive +// a truncated tail (a crash mid-write) yet reject non-dump files. +// -------------------------------------------------------------------------- + +TEST_CASE("native_crash", "parse_dump round-trips a full dump") { + const std::string text = + "tombstone-native-crash 1\n" + "signal 11\n" + "code 1\n" + "fault 0x0\n" + "frame libgame.so 0x1234 aabbccdd\n" + "frame main 0x40 \n" + "end\n"; + const auto dump = nc::parse_dump(text); + CHECK(dump.has_value()); + CHECK_EQ(dump->signal_no, 11); + CHECK_EQ(dump->code, 1); + CHECK_EQ(dump->fault_addr, static_cast(0)); + CHECK_EQ(dump->frames.size(), static_cast(2)); + CHECK_EQ(dump->frames[0].module, std::string{"libgame.so"}); + CHECK_EQ(dump->frames[0].offset, static_cast(0x1234)); + CHECK_EQ(dump->frames[0].build_id, std::string{"aabbccdd"}); + CHECK_EQ(dump->frames[1].module, std::string{"main"}); + CHECK_EQ(dump->frames[1].offset, static_cast(0x40)); + CHECK(dump->frames[1].build_id.empty()); +} + +TEST_CASE("native_crash", "parse_dump tolerates a truncated tail") { + // A crash while writing frame 2 leaves a partial last line; the facts + // written before it must still parse (best-effort, header present). + const std::string text = + "tombstone-native-crash 1\n" + "signal 6\n" + "frame libc.so 0x99 dead"; // no trailing newline, no "end" + const auto dump = nc::parse_dump(text); + CHECK(dump.has_value()); + CHECK_EQ(dump->signal_no, 6); + CHECK_EQ(dump->frames.size(), static_cast(1)); + CHECK_EQ(dump->frames[0].module, std::string{"libc.so"}); +} + +TEST_CASE("native_crash", "parse_dump rejects a file without the magic header") { + CHECK(!nc::parse_dump("signal 11\nframe x 0x1\n").has_value()); + CHECK(!nc::parse_dump("").has_value()); + CHECK(!nc::parse_dump("garbage\n").has_value()); +} + +TEST_CASE("native_crash", "signal_name maps the common signals") { + CHECK_EQ(nc::signal_name(11), std::string{"SIGSEGV"}); + CHECK_EQ(nc::signal_name(6), std::string{"SIGABRT"}); + CHECK_EQ(nc::signal_name(4), std::string{"SIGILL"}); + CHECK_EQ(nc::signal_name(999), std::string{"signal 999"}); +} + +TEST_CASE("native_crash", "take_previous reads then deletes the dump exactly once") { + ttest::TempDir dir; + SdkLog log; + const fs::path dump_path = dir.path() / nc::kDumpFileName; + { + std::ofstream out(dump_path, std::ios::binary); + out << "tombstone-native-crash 1\nsignal 11\nframe app 0x10 \nend\n"; + } + const auto first = nc::take_previous(dir.path(), log); + CHECK(first.has_value()); + CHECK_EQ(first->signal_no, 11); + CHECK(!fs::exists(dump_path)); // consumed + // Second pickup on the same dir yields nothing — a dump reports once. + CHECK(!nc::take_previous(dir.path(), log).has_value()); +} + +TEST_CASE("native_crash", "take_previous on a corrupt dump still deletes it (no re-report loop)") { + ttest::TempDir dir; + SdkLog log; + const fs::path dump_path = dir.path() / nc::kDumpFileName; + { + std::ofstream out(dump_path, std::ios::binary); + out << "not a tombstone dump at all\n"; + } + CHECK(!nc::take_previous(dir.path(), log).has_value()); // unparsable + CHECK(!fs::exists(dump_path)); // but removed +} + +// The crash report carries the enriched fields end-to-end on the wire. +TEST_CASE("native_crash", "build_crash_json serializes crashType + osSignal, omits when unset") { + tombstone::CrashPayload p; + p.occurred_at_iso = "2026-07-20T00:00:00.000Z"; + p.build_version = "1.0.0"; + p.os = "other"; + p.arch = "arm64"; + p.signature = "abc123"; + p.stack_hint = "Native crash: SIGSEGV at 0x0"; + p.crash_type = "native_crash"; + p.os_signal = 11; + const std::string json = tombstone::build_crash_json(p); + CHECK(json.find("\"crashType\":\"native_crash\"") != std::string::npos); + CHECK(json.find("\"osSignal\":11") != std::string::npos); + + tombstone::CrashPayload managed; + managed.occurred_at_iso = "2026-07-20T00:00:00.000Z"; + managed.build_version = "1.0.0"; + managed.os = "windows"; + managed.arch = "x64"; + managed.signature = "def456"; + managed.stack_hint = "NullReferenceException"; + const std::string managed_json = tombstone::build_crash_json(managed); + CHECK(managed_json.find("crashType") == std::string::npos); + CHECK(managed_json.find("osSignal") == std::string::npos); +} + +// -------------------------------------------------------------------------- +// Live capture + CHAINING integration (Linux only — the platform where CI can +// actually fault a child and inspect it). Proves, in one real SIGSEGV: +// 1. our handler wrote a parseable dump, +// 2. the PRE-EXISTING handler (Unity/UGS analog) still ran (chaining), and +// 3. the process still died with the true signal. +// -------------------------------------------------------------------------- +#if defined(__linux__) +#include +#include +#include +#include +#include + +namespace { +// A volatile null the optimizer can't prove is null at the store site, so the +// deliberate fault below isn't rejected by -Wnull-dereference under -Werror. +volatile std::uintptr_t g_fault_addr = 0; +const char *g_prev_sentinel = nullptr; +void prev_handler(int /*sig*/, siginfo_t * /*info*/, void * /*uc*/) { + // Async-signal-safe: just create a sentinel file, then return so OUR + // handler's reraise-default path takes the process down. + if (g_prev_sentinel != nullptr) { + const int fd = open(g_prev_sentinel, O_WRONLY | O_CREAT | O_TRUNC, 0600); + if (fd >= 0) { + const char c = '1'; + (void)!write(fd, &c, 1); + close(fd); + } + } +} +} // namespace + +TEST_CASE("native_crash", "handler dumps AND chains to a pre-existing handler, dies with the signal") { + ttest::TempDir dir; + const fs::path dump_path = dir.path() / nc::kDumpFileName; + const fs::path prev_path = dir.path() / "prev-ran"; + static std::string prev_str = prev_path.string(); + g_prev_sentinel = prev_str.c_str(); + + const pid_t pid = fork(); + CHECK(pid >= 0); + if (pid == 0) { + // CHILD: install a co-resident handler FIRST (so our install() saves it + // for chaining), then install ours, then fault. + struct sigaction prev; + std::memset(&prev, 0, sizeof(prev)); + prev.sa_sigaction = prev_handler; + sigemptyset(&prev.sa_mask); + prev.sa_flags = SA_SIGINFO; + sigaction(SIGSEGV, &prev, nullptr); + + SdkLog log; + if (!nc::install(dir.path(), log)) _exit(42); // unexpected on Linux + + // Deliberate fault -> SIGSEGV (g_fault_addr is a volatile 0 the compiler + // can't fold, so this isn't flagged as a static null deref under -Werror). + *reinterpret_cast(g_fault_addr) = 5; + _exit(0); // unreachable + } + + // PARENT: reap and assert. + int status = 0; + waitpid(pid, &status, 0); + CHECK(WIFSIGNALED(status)); + CHECK_EQ(WTERMSIG(status), SIGSEGV); // died with the TRUE signal + CHECK(fs::exists(prev_path)); // the chained handler ran + CHECK(fs::exists(dump_path)); // our dump was written + + std::ifstream in(dump_path, std::ios::binary); + std::string text((std::istreambuf_iterator(in)), std::istreambuf_iterator()); + const auto dump = nc::parse_dump(text); + CHECK(dump.has_value()); + CHECK_EQ(dump->signal_no, SIGSEGV); + CHECK(!dump->frames.empty()); // at least the PC frame captured +} +#endif // __linux__ From 6f4a2416bdd28947f8cb3aeff86b7231993c78ba Mon Sep 17 00:00:00 2001 From: AnkleBreaker Cowork Date: Mon, 20 Jul 2026 12:52:27 +0200 Subject: [PATCH 2/2] [FIX] Native crash : register the native_crash CTest suite so its tests actually run The suite compiled but was absent from the add_test foreach list, so CTest never executed it (incl. the Linux fork/SIGSEGV chaining integration test). --- CMakeLists.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index ed3dff2..cab7648 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -187,8 +187,8 @@ if(TOMBSTONE_BUILD_TESTS) foreach(suite batch breadcrumb_ring clock concurrency dedupe device_identity frame_stats json_scan - json_writer payloads preinit retry_after sampler session_log session_marker sidecar - signature start_gate) + json_writer native_crash payloads preinit retry_after sampler session_log session_marker + sidecar signature start_gate) add_test(NAME ${suite} COMMAND tombstone_tests ${suite}) endforeach() endif()