Skip to content
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## Unreleased

**Features**:

- Native: add opt-in async crash upload mode so crashed apps can exit early after crash data is captured, while the crash daemon finishes potentially large uploads in the background. ([#1739](https://github.com/getsentry/sentry-native/pull/1739))

**Fixes**:

- Native/macOS: fix module `image_size` computation, which could have caused the symbolicator to misattribute every frame to the lowest-addressed image (typically `dyld` or `libsystem`). ([#1740](https://github.com/getsentry/sentry-native/pull/1740))
Expand Down
4 changes: 4 additions & 0 deletions examples/example.c
Original file line number Diff line number Diff line change
Expand Up @@ -783,6 +783,10 @@ main(int argc, char **argv)
}
}
}
if (has_arg(argc, argv, "async-crash-upload")) {
sentry_options_set_crash_upload_mode(
options, SENTRY_CRASH_UPLOAD_MODE_ASYNC);
}

// E2E test mode: generate unique test ID for event correlation
char e2e_test_id[37] = { 0 };
Expand Down
40 changes: 40 additions & 0 deletions include/sentry.h
Original file line number Diff line number Diff line change
Expand Up @@ -1073,6 +1073,25 @@ typedef enum {
SENTRY_CRASH_REPORTING_MODE_NATIVE_WITH_MINIDUMP = 2,
} sentry_crash_reporting_mode_t;

/**
* Crash upload mode for the native backend.
* Controls whether the crashed application remains blocked while upload and
* shutdown work finishes after crash data has been captured.
*/
typedef enum {
/**
* Keep the crashed application blocked until the native crash daemon
* finishes upload and shutdown work.
*/
SENTRY_CRASH_UPLOAD_MODE_SYNC = 0,

/**
* Allow the crashed application to terminate after crash data has been
* captured. The native crash daemon continues upload and shutdown work.
*/
SENTRY_CRASH_UPLOAD_MODE_ASYNC = 1,
} sentry_crash_upload_mode_t;

/**
* Controls if and when envelopes are kept in the persistent cache.
*/
Expand Down Expand Up @@ -1882,6 +1901,27 @@ SENTRY_API void sentry_options_set_crash_reporting_mode(
SENTRY_API sentry_crash_reporting_mode_t
sentry_options_get_crash_reporting_mode(const sentry_options_t *opts);

/**
* Sets the crash upload mode for the native backend.
*
* This setting controls what happens after crash data has been captured. In
* sync mode, the crashed application remains blocked while the native crash
* daemon finishes upload and shutdown work. In async mode, the crashed
* application can terminate after crash data has been captured while the daemon
* continues upload and shutdown work.
*
* This setting only has an effect when using the `native` backend.
* Default is `SENTRY_CRASH_UPLOAD_MODE_SYNC`.
*/
SENTRY_API void sentry_options_set_crash_upload_mode(
sentry_options_t *opts, sentry_crash_upload_mode_t mode);

/**
* Gets the crash upload mode for the native backend.
*/
SENTRY_API sentry_crash_upload_mode_t sentry_options_get_crash_upload_mode(
const sentry_options_t *opts);

/**
* Enables a wait for the crash report upload to be finished before shutting
* down. This is disabled by default.
Expand Down
4 changes: 3 additions & 1 deletion src/backends/native/sentry_crash_context.h
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,8 @@ typedef enum {
SENTRY_CRASH_STATE_READY = 0,
SENTRY_CRASH_STATE_CRASHED = 1,
SENTRY_CRASH_STATE_PROCESSING = 2,
SENTRY_CRASH_STATE_DONE = 3
SENTRY_CRASH_STATE_CAPTURED = 3,
SENTRY_CRASH_STATE_DONE = 4
} sentry_crash_state_t;

/**
Expand Down Expand Up @@ -274,6 +275,7 @@ typedef struct {
// Configuration (set by app during init)
sentry_minidump_mode_t minidump_mode;
int crash_reporting_mode; // sentry_crash_reporting_mode_t
int crash_upload_mode; // sentry_crash_upload_mode_t
bool debug_enabled; // Debug logging enabled in parent process
bool attach_screenshot; // Screenshot attachment enabled in parent process
bool attach_session_replay; // Session replay attachment enabled in parent
Expand Down
11 changes: 11 additions & 0 deletions src/backends/native/sentry_crash_daemon.c
Original file line number Diff line number Diff line change
Expand Up @@ -3477,6 +3477,17 @@ sentry__crash_daemon_main(pid_t app_pid, uint64_t app_tid, HANDLE event_handle,
sentry__process_crash(options, ipc);
crash_processed = true;

if (ipc->shmem->crash_upload_mode
== SENTRY_CRASH_UPLOAD_MODE_ASYNC) {
// Crash data is durable after processing returns;
// remaining daemon work does not require the crashed
// process.
SENTRY_DEBUG(
"Crash captured, allowing app process to exit");
sentry__atomic_store(
&ipc->shmem->state, SENTRY_CRASH_STATE_CAPTURED);
}

// After processing crash, exit regardless of parent state
// (parent has likely already exited after re-raising signal)
SENTRY_DEBUG("Crash processed, daemon exiting");
Expand Down
8 changes: 4 additions & 4 deletions src/backends/native/sentry_crash_handler.c
Original file line number Diff line number Diff line change
Expand Up @@ -670,8 +670,8 @@ crash_signal_handler(int signum, siginfo_t *info, void *context)
if (state == SENTRY_CRASH_STATE_PROCESSING && !processing_started) {
// Daemon started processing (no logging - signal-safe)
processing_started = true;
} else if (state == SENTRY_CRASH_STATE_DONE) {
// Daemon finished processing (no logging - signal-safe)
} else if (state >= SENTRY_CRASH_STATE_CAPTURED) {
// Daemon captured crash data (no logging - signal-safe)
goto daemon_handling;
}

Expand Down Expand Up @@ -954,8 +954,8 @@ crash_exception_filter(EXCEPTION_POINTERS *exception_info)
// Daemon started processing (no logging - exception filter
// context)
processing_started = true;
} else if (state == SENTRY_CRASH_STATE_DONE) {
// Daemon finished processing (no logging - exception filter
} else if (state >= SENTRY_CRASH_STATE_CAPTURED) {
// Daemon captured crash data (no logging - exception filter
// context)
break;
}
Expand Down
2 changes: 1 addition & 1 deletion src/backends/native/sentry_wer.c
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ process_wer_exception(
waited_ms += SENTRY_CRASH_HANDLER_POLL_INTERVAL_MS) {
if (InterlockedCompareExchange(&ctx->state,
SENTRY_CRASH_STATE_DONE, SENTRY_CRASH_STATE_DONE)
== SENTRY_CRASH_STATE_DONE) {
>= SENTRY_CRASH_STATE_CAPTURED) {
break;
}
Sleep(SENTRY_CRASH_HANDLER_POLL_INTERVAL_MS);
Expand Down
1 change: 1 addition & 0 deletions src/backends/sentry_backend_native.c
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,7 @@ native_backend_startup(

// Set crash reporting mode from options
ctx->crash_reporting_mode = options->crash_reporting_mode;
ctx->crash_upload_mode = options->crash_upload_mode;

// Pass debug logging setting to daemon
ctx->debug_enabled = options->debug;
Expand Down
20 changes: 20 additions & 0 deletions src/sentry_options.c
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ sentry_options_new(void)
opts->crash_reporting_mode
= SENTRY_CRASH_REPORTING_MODE_NATIVE_WITH_MINIDUMP; // Default: best of
// both worlds
opts->crash_upload_mode = SENTRY_CRASH_UPLOAD_MODE_SYNC;
opts->http_retry = false;
opts->send_client_reports = true;
opts->enable_large_attachments = false;
Expand Down Expand Up @@ -615,6 +616,25 @@ sentry_options_get_crash_reporting_mode(const sentry_options_t *opts)
return (sentry_crash_reporting_mode_t)opts->crash_reporting_mode;
}

void
sentry_options_set_crash_upload_mode(
sentry_options_t *opts, sentry_crash_upload_mode_t mode)
{
int imode = (int)mode;
if (imode < SENTRY_CRASH_UPLOAD_MODE_SYNC) {
imode = SENTRY_CRASH_UPLOAD_MODE_SYNC;
} else if (imode > SENTRY_CRASH_UPLOAD_MODE_ASYNC) {
imode = SENTRY_CRASH_UPLOAD_MODE_ASYNC;
}
opts->crash_upload_mode = imode;
}

sentry_crash_upload_mode_t
sentry_options_get_crash_upload_mode(const sentry_options_t *opts)
{
return (sentry_crash_upload_mode_t)opts->crash_upload_mode;
}

void
sentry_options_set_crashpad_wait_for_upload(
sentry_options_t *opts, int wait_for_upload)
Expand Down
1 change: 1 addition & 0 deletions src/sentry_options.h
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ struct sentry_options_s {
// sentry_crash_context.h)
int crash_reporting_mode; // 0=minidump, 1=native, 2=native_with_minidump
// (see sentry_crash_reporting_mode_t)
int crash_upload_mode; // 0=sync, 1=async (see sentry_crash_upload_mode_t)

#ifdef SENTRY_PLATFORM_NX
void (*network_connect_func)(void);
Expand Down
16 changes: 15 additions & 1 deletion tests/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import io
import json
import sys
import time
import urllib
import pytest
import pprint
Expand All @@ -24,6 +25,8 @@ def adb(*args, **kwargs):

SENTRY_VERSION = "0.14.2"

from .assertions import wait_for_daemon as _wait_for_daemon


def make_dsn(httpserver, auth="uiaeosnrtdy", id=123456, proxy_host=False):
url = urllib.parse.urlsplit(httpserver.url_for("/{}".format(id)))
Expand Down Expand Up @@ -96,7 +99,13 @@ def extract_request(httpserver_log, cond):
return (None, httpserver_log)


def run(cwd, exe, args, expect_failure=False, env=None, **kwargs):
def run(
cwd, exe, args, expect_failure=False, env=None, wait_for_daemon=False, **kwargs
):
if wait_for_daemon:
assert (
"log" in args or exe != "sentry_example"
), "sentry_example needs 'log' when waiting for the daemon"
if env is None:
env = dict(os.environ)
if kwargs.get("check"):
Expand All @@ -105,6 +114,7 @@ def run(cwd, exe, args, expect_failure=False, env=None, **kwargs):
)
check = expect_failure == False
__tracebackhide__ = True
started_at = time.time()
Comment thread
jpnurmi marked this conversation as resolved.
if os.environ.get("ANDROID_API"):
# older android emulators do not correctly pass down the returncode
# so we basically echo the return code, and parse it manually
Expand Down Expand Up @@ -179,6 +189,10 @@ def run(cwd, exe, args, expect_failure=False, env=None, **kwargs):
]
try:
result = subprocess.run([*cmd, *args], cwd=cwd, env=env, check=check, **kwargs)
if wait_for_daemon:
assert _wait_for_daemon(
cwd, started_at
), "native crash daemon did not finish within timeout"
if expect_failure:
assert (
result.returncode != 0
Expand Down
30 changes: 29 additions & 1 deletion tests/assertions.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
import msgpack

from . import SENTRY_VERSION
from .conditions import is_android
from .conditions import is_android, is_asan, is_tsan

VERSION_RE = re.compile(r"(\d+\.\d+\.\d+)[-.]?(.*)")

Expand Down Expand Up @@ -676,3 +676,31 @@ def wait_for_file(path, timeout=10.0, poll_interval=0.1):
return True
time.sleep(poll_interval)
return False


def wait_for_daemon(tmp_path, started_at, timeout=None):
import time

if timeout is None:
timeout = 30.0 if is_asan or is_tsan else 10.0

db_dir = Path(tmp_path) / ".sentry-native"
# Account for filesystems that truncate mtimes below time.time() precision.
started_at -= 1.0

deadline = time.time() + timeout
while time.time() < deadline:
for log_path in db_dir.glob("sentry-daemon-*.log"):
try:
if log_path.stat().st_mtime < started_at:
continue
log = log_path.read_text(errors="replace")
except OSError:
continue

if "Marking crash state as DONE" in log:
Comment thread
sentry[bot] marked this conversation as resolved.
return True
Comment thread
cursor[bot] marked this conversation as resolved.

time.sleep(0.1)

return False
31 changes: 24 additions & 7 deletions tests/test_e2e_sentry.py
Original file line number Diff line number Diff line change
Expand Up @@ -353,7 +353,7 @@ def extract_test_id(output):
raise ValueError(f"TEST_ID not found in output. Output was:\n{decoded[:500]}")


def run_crash_e2e(tmp_path, exe, args, env):
def run_crash_e2e(tmp_path, exe, args, env, wait_for_daemon=False):
"""
Run a crash test for E2E, capturing output for test ID extraction.

Expand All @@ -372,11 +372,25 @@ def run_crash_e2e(tmp_path, exe, args, env):

# Use check_output to capture stdout for test ID extraction
try:
output = check_output(tmp_path, exe, args, env=env, expect_failure=True)
output = check_output(
tmp_path,
exe,
args,
env=env,
expect_failure=True,
wait_for_daemon=wait_for_daemon,
)
Comment thread
cursor[bot] marked this conversation as resolved.
except AssertionError:
if is_kcov:
# kcov may exit with 0 even on crash, try without expect_failure
output = check_output(tmp_path, exe, args, env=env, expect_failure=False)
output = check_output(
tmp_path,
exe,
args,
env=env,
expect_failure=False,
wait_for_daemon=wait_for_daemon,
)
else:
raise

Expand Down Expand Up @@ -437,12 +451,15 @@ def run_crash_and_send(self, mode_args):
# Run with crash - capture output for test ID
# Enable structured logs and capture a log message before crashing
crash_args = ["log", "e2e-test", "capture-log"] + mode_args + ["crash"]
output = run_crash_e2e(self.tmp_path, "sentry_example", crash_args, env=env)
output = run_crash_e2e(
self.tmp_path,
"sentry_example",
crash_args,
env=env,
wait_for_daemon=True,
)
test_id = extract_test_id(output)

# Wait for crash daemon to process
time.sleep(2)

# Print daemon logs for debugging (especially useful for Windows thread duplication investigation)
self.print_daemon_logs()

Expand Down
25 changes: 18 additions & 7 deletions tests/test_integration_native.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
SANITIZER_ARGS = ["shutdown-timeout", "10000"] if is_asan or is_tsan else []


def run_crash(tmp_path, exe, args, env):
def run_crash(tmp_path, exe, args, env, wait_for_daemon=False):
"""
Run a crash test, handling kcov's quirk of exiting with 0.
kcov intercepts signals and may exit cleanly even when the program crashes.
Expand All @@ -65,12 +65,26 @@ def run_crash(tmp_path, exe, args, env):

if is_kcov:
try:
run(tmp_path, exe, args, expect_failure=True, env=env)
run(
tmp_path,
exe,
args,
expect_failure=True,
env=env,
wait_for_daemon=wait_for_daemon,
)
except AssertionError:
# kcov may exit with 0 even on crash, that's acceptable
pass
else:
run(tmp_path, exe, args, expect_failure=True, env=env)
run(
tmp_path,
exe,
args,
expect_failure=True,
env=env,
wait_for_daemon=wait_for_daemon,
)


def test_native_capture_crash(cmake, httpserver):
Expand Down Expand Up @@ -1013,6 +1027,7 @@ def test_native_cache_keep(cmake, cache_keep, unreachable_dsn):
"sentry_example",
["log", "stdout", "crash"] + (["cache-keep"] if cache_keep else []),
env=env,
wait_for_daemon=not cache_keep,
)

if cache_keep:
Expand All @@ -1023,8 +1038,4 @@ def test_native_cache_keep(cmake, cache_keep, unreachable_dsn):
assert len(dmp_files) == 1
assert cache_files[0].stem == dmp_files[0].stem
else:
# Best-effort wait for crash processing to finish. 2s is not
# guaranteed to be enough, but we cannot poll for the non-existence
# of a file.
time.sleep(2)
assert len(list(cache_dir.glob("*.envelope"))) == 0
Loading
Loading