Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
`<data_dir>/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)
Expand Down
8 changes: 5 additions & 3 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -185,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()
Expand Down
10 changes: 10 additions & 0 deletions include/tombstone/tombstone.h
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
80 changes: 80 additions & 0 deletions src/client.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@
#include "json_writer.h"
#include "payloads.h"
#include "platform.h"
#include "signature.h"
#include "transport.h"

#include <algorithm>
#include <cstdint>
#include <cstdio>
#include <fstream>
#include <random>
#include <vector>
Expand Down Expand Up @@ -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
Expand All @@ -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();
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -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;
Expand All @@ -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<unsigned long long>(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<unsigned long long>(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<std::mutex> lock(heartbeat_mutex_);
while (!heartbeat_stop_) {
Expand Down
7 changes: 7 additions & 0 deletions src/client.h
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -205,6 +207,11 @@ class Client {
std::optional<SessionMarkerData> 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<NativeCrashDump> previous_native_dump_;

mutable std::mutex user_mutex_;
std::string user_id_;
Expand Down
Loading
Loading