From f99b6089828462afdc01e4e7040c9823ba96622b Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Thu, 21 May 2026 15:33:05 +0200 Subject: [PATCH 01/14] ref(native): let crashed app exit as soon as possible --- src/backends/native/sentry_crash_context.h | 3 ++- src/backends/native/sentry_crash_daemon.c | 6 +++++ src/backends/native/sentry_crash_handler.c | 4 ++-- src/backends/native/sentry_wer.c | 2 +- tests/assertions.py | 21 +++++++++++------ tests/test_dotnet_signals.py | 9 ++------ tests/test_integration_native.py | 27 ++++++++++++++++++++++ 7 files changed, 54 insertions(+), 18 deletions(-) diff --git a/src/backends/native/sentry_crash_context.h b/src/backends/native/sentry_crash_context.h index ee6af9707..0b34278ff 100644 --- a/src/backends/native/sentry_crash_context.h +++ b/src/backends/native/sentry_crash_context.h @@ -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; /** diff --git a/src/backends/native/sentry_crash_daemon.c b/src/backends/native/sentry_crash_daemon.c index b440aa1d2..c8882d170 100644 --- a/src/backends/native/sentry_crash_daemon.c +++ b/src/backends/native/sentry_crash_daemon.c @@ -3477,6 +3477,12 @@ sentry__crash_daemon_main(pid_t app_pid, uint64_t app_tid, HANDLE event_handle, sentry__process_crash(options, ipc); crash_processed = true; + // 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"); diff --git a/src/backends/native/sentry_crash_handler.c b/src/backends/native/sentry_crash_handler.c index fe588d938..fd8a4f47d 100644 --- a/src/backends/native/sentry_crash_handler.c +++ b/src/backends/native/sentry_crash_handler.c @@ -670,7 +670,7 @@ 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) { + } else if (state >= SENTRY_CRASH_STATE_CAPTURED) { // Daemon finished processing (no logging - signal-safe) goto daemon_handling; } @@ -954,7 +954,7 @@ 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) { + } else if (state >= SENTRY_CRASH_STATE_CAPTURED) { // Daemon finished processing (no logging - exception filter // context) break; diff --git a/src/backends/native/sentry_wer.c b/src/backends/native/sentry_wer.c index 85a0b23d1..37542528c 100644 --- a/src/backends/native/sentry_wer.c +++ b/src/backends/native/sentry_wer.c @@ -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); diff --git a/tests/assertions.py b/tests/assertions.py index 7ec07e876..6d8c609f0 100644 --- a/tests/assertions.py +++ b/tests/assertions.py @@ -666,13 +666,20 @@ def assert_failed_proxy_auth_request(stdout): ) -def wait_for_file(path, timeout=10.0, poll_interval=0.1): - import glob +def wait_for(condition, timeout=10.0, interval=0.1): import time - deadline = time.time() + timeout - while time.time() < deadline: - if glob.glob(str(path)): + start = time.time() + while time.time() - start < timeout: + if condition(): return True - time.sleep(poll_interval) - return False + time.sleep(interval) + return condition() + + +def wait_for_file(path, timeout=10.0, interval=0.1): + import glob + + return wait_for( + lambda: bool(glob.glob(str(path))), timeout=timeout, interval=interval + ) diff --git a/tests/test_dotnet_signals.py b/tests/test_dotnet_signals.py index ca8b26bc0..b01bef078 100644 --- a/tests/test_dotnet_signals.py +++ b/tests/test_dotnet_signals.py @@ -3,11 +3,11 @@ import shutil import subprocess import sys -import time import pytest from tests import adb +from tests.assertions import wait_for as _wait_for from tests.conditions import is_android, is_arm32, is_tsan, is_x86, is_asan project_fixture_path = pathlib.Path("tests/fixtures/dotnet_signal") @@ -282,12 +282,7 @@ def test_aot_signals_inproc(cmake): def wait_for(condition, timeout=10, interval=0.5): - start = time.time() - while time.time() - start < timeout: - if condition(): - return True - time.sleep(interval) - return condition() + return _wait_for(condition, timeout=timeout, interval=interval) def run_android(args=None, strategy=None, reinit=False, timeout=30): diff --git a/tests/test_integration_native.py b/tests/test_integration_native.py index 81d9e6a9a..b7227747e 100644 --- a/tests/test_integration_native.py +++ b/tests/test_integration_native.py @@ -25,6 +25,7 @@ assert_native_crash, assert_session, wait_for_file, + wait_for, assert_user_feedback, ) from .conditions import has_native, has_oom, is_kcov, is_asan, is_tsan, is_qemu @@ -39,6 +40,26 @@ SANITIZER_ARGS = ["shutdown-timeout", "10000"] if is_asan or is_tsan else [] +def wait_for_daemon(tmp_path, started_at, timeout=10.0): + db_dir = tmp_path / ".sentry-native" + + def is_done(): + 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: + return True + + return False + + return wait_for(is_done, timeout=timeout, interval=0.1) + + def run_crash(tmp_path, exe, args, env): """ Run a crash test, handling kcov's quirk of exiting with 0. @@ -47,6 +68,8 @@ def run_crash(tmp_path, exe, args, env): When running under ASAN, we configure it to not intercept crash signals so that our native crash handler can run and capture the crash. """ + started_at = time.time() + # When running under ASAN, disable ASAN's signal handling so our crash # handler can run. ASAN would otherwise intercept SIGSEGV/SIGABRT/etc # and terminate the process before our handler completes. @@ -72,6 +95,10 @@ def run_crash(tmp_path, exe, args, env): else: run(tmp_path, exe, args, expect_failure=True, env=env) + assert wait_for_daemon(tmp_path, started_at), ( + "native crash daemon did not finish before timeout" + ) + def test_native_capture_crash(cmake, httpserver): """Test basic crash capture with native backend""" From 514bb1ef942c85b6ba76cbd970a60777cb2dfa1b Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Thu, 21 May 2026 15:53:26 +0200 Subject: [PATCH 02/14] make format --- tests/test_integration_native.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_integration_native.py b/tests/test_integration_native.py index b7227747e..97368c538 100644 --- a/tests/test_integration_native.py +++ b/tests/test_integration_native.py @@ -95,9 +95,9 @@ def run_crash(tmp_path, exe, args, env): else: run(tmp_path, exe, args, expect_failure=True, env=env) - assert wait_for_daemon(tmp_path, started_at), ( - "native crash daemon did not finish before timeout" - ) + assert wait_for_daemon( + tmp_path, started_at + ), "native crash daemon did not finish before timeout" def test_native_capture_crash(cmake, httpserver): From 2a32aed7a05258b085387e473f6be0e16e7ebbe7 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Thu, 21 May 2026 15:57:40 +0200 Subject: [PATCH 03/14] stabilize test_tus_crash_native --- tests/assertions.py | 22 ++++++++++++++++++++++ tests/test_integration_native.py | 22 +--------------------- tests/test_integration_tus.py | 17 ++++++++++------- 3 files changed, 33 insertions(+), 28 deletions(-) diff --git a/tests/assertions.py b/tests/assertions.py index 6d8c609f0..f78d9ca4a 100644 --- a/tests/assertions.py +++ b/tests/assertions.py @@ -683,3 +683,25 @@ def wait_for_file(path, timeout=10.0, interval=0.1): return wait_for( lambda: bool(glob.glob(str(path))), timeout=timeout, interval=interval ) + + +def wait_for_daemon(tmp_path, started_at, timeout=10.0): + from pathlib import Path + + db_dir = Path(tmp_path) / ".sentry-native" + + def is_done(): + 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: + return True + + return False + + return wait_for(is_done, timeout=timeout) diff --git a/tests/test_integration_native.py b/tests/test_integration_native.py index 97368c538..d14c643e4 100644 --- a/tests/test_integration_native.py +++ b/tests/test_integration_native.py @@ -25,7 +25,7 @@ assert_native_crash, assert_session, wait_for_file, - wait_for, + wait_for_daemon, assert_user_feedback, ) from .conditions import has_native, has_oom, is_kcov, is_asan, is_tsan, is_qemu @@ -40,26 +40,6 @@ SANITIZER_ARGS = ["shutdown-timeout", "10000"] if is_asan or is_tsan else [] -def wait_for_daemon(tmp_path, started_at, timeout=10.0): - db_dir = tmp_path / ".sentry-native" - - def is_done(): - 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: - return True - - return False - - return wait_for(is_done, timeout=timeout, interval=0.1) - - def run_crash(tmp_path, exe, args, env): """ Run a crash test, handling kcov's quirk of exiting with 0. diff --git a/tests/test_integration_tus.py b/tests/test_integration_tus.py index 601ede97e..9f92ca749 100644 --- a/tests/test_integration_tus.py +++ b/tests/test_integration_tus.py @@ -11,7 +11,7 @@ Envelope, SENTRY_VERSION, ) -from .assertions import assert_attachment +from .assertions import assert_attachment, wait_for from .conditions import has_breakpad, has_files, has_http, has_native, is_qemu pytestmark = [ @@ -560,9 +560,12 @@ def test_tus_crash_native(cmake, httpserver): assert attachment_ref.payload.json["location"] == location db_dir = os.path.join(tmp_path, ".sentry-native") - run_dirs = [ - d - for d in os.listdir(db_dir) - if d.endswith(".run") and os.path.isdir(os.path.join(db_dir, d)) - ] - assert run_dirs == [] + + def run_dirs(): + return [ + d + for d in os.listdir(db_dir) + if d.endswith(".run") and os.path.isdir(os.path.join(db_dir, d)) + ] + + assert wait_for(lambda: run_dirs() == []), run_dirs() From ddb55f5b77d26b3cb0b2bbe5403936ae1249595f Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Thu, 21 May 2026 16:52:52 +0200 Subject: [PATCH 04/14] wait for native daemon in run() --- tests/__init__.py | 12 ++++++++++++ tests/cmake.py | 14 ++++++++++++++ tests/test_dotnet_signals.py | 6 +----- tests/test_e2e_sentry.py | 3 --- tests/test_integration_http.py | 3 --- tests/test_integration_native.py | 7 ------- tests/test_integration_tus.py | 17 +++++++---------- 7 files changed, 34 insertions(+), 28 deletions(-) diff --git a/tests/__init__.py b/tests/__init__.py index ecfd7f300..76fe0785d 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -4,6 +4,7 @@ import io import json import sys +import time import urllib import pytest import pprint @@ -24,6 +25,9 @@ def adb(*args, **kwargs): SENTRY_VERSION = "0.14.2" +from .assertions import wait_for_daemon +from .cmake import cmake_option + def make_dsn(httpserver, auth="uiaeosnrtdy", id=123456, proxy_host=False): url = urllib.parse.urlsplit(httpserver.url_for("/{}".format(id))) @@ -99,12 +103,16 @@ def extract_request(httpserver_log, cond): def run(cwd, exe, args, expect_failure=False, env=None, **kwargs): if env is None: env = dict(os.environ) + should_wait_for_daemon = ( + expect_failure and cmake_option(cwd, "SENTRY_BACKEND") == "native" + ) if kwargs.get("check"): raise pytest.fail.Exception( "`check` is inferred from `expect_failure`, and should not be passed in the kwargs" ) check = expect_failure == False __tracebackhide__ = True + started_at = time.time() if should_wait_for_daemon else None 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 @@ -179,6 +187,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 should_wait_for_daemon: + assert wait_for_daemon(cwd, started_at), ( + "native crash daemon did not finish before timeout" + ) if expect_failure: assert ( result.returncode != 0 diff --git a/tests/cmake.py b/tests/cmake.py index 350e4570b..75072ab3e 100644 --- a/tests/cmake.py +++ b/tests/cmake.py @@ -18,6 +18,18 @@ ) +_cmake_build_options = {} + + +def cmake_option(cwd, name): + options = _cmake_build_options.get(os.fspath(cwd)) + if options is None: + options = _cmake_build_options.get(os.path.realpath(cwd)) + if options is None: + return None + return options.get(name) + + class CMake: def __init__(self, factory): self.runs = dict() @@ -54,6 +66,7 @@ def compile(self, targets, options=None, cflags=None): # ensure that there are no left-overs from previous runs shutil.rmtree(build_tmp_path / ".sentry-native", ignore_errors=True) + _cmake_build_options[os.fspath(build_tmp_path)] = dict(options) # Inject a sub-path into the temporary build directory as the CWD for all tests to verify UTF-8 path handling. if os.environ.get("UTF8_TEST_CWD"): @@ -61,6 +74,7 @@ def compile(self, targets, options=None, cflags=None): utf8_parent = self.factory.mktemp("utf8") utf8_subpath = utf8_parent / "นี่คือไดเร็กทอรีทดสอบ" utf8_subpath.symlink_to(build_tmp_path, target_is_directory=True) + _cmake_build_options[os.fspath(utf8_subpath)] = dict(options) return utf8_subpath return build_tmp_path diff --git a/tests/test_dotnet_signals.py b/tests/test_dotnet_signals.py index b01bef078..69371ce30 100644 --- a/tests/test_dotnet_signals.py +++ b/tests/test_dotnet_signals.py @@ -7,7 +7,7 @@ import pytest from tests import adb -from tests.assertions import wait_for as _wait_for +from tests.assertions import wait_for from tests.conditions import is_android, is_arm32, is_tsan, is_x86, is_asan project_fixture_path = pathlib.Path("tests/fixtures/dotnet_signal") @@ -281,10 +281,6 @@ def test_aot_signals_inproc(cmake): ANDROID_PACKAGE = "io.sentry.ndk.dotnet.signal.test" -def wait_for(condition, timeout=10, interval=0.5): - return _wait_for(condition, timeout=timeout, interval=interval) - - def run_android(args=None, strategy=None, reinit=False, timeout=30): if args is None: args = [] diff --git a/tests/test_e2e_sentry.py b/tests/test_e2e_sentry.py index a222a5c9f..68fe50c35 100644 --- a/tests/test_e2e_sentry.py +++ b/tests/test_e2e_sentry.py @@ -440,9 +440,6 @@ def run_crash_and_send(self, mode_args): output = run_crash_e2e(self.tmp_path, "sentry_example", crash_args, env=env) 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() diff --git a/tests/test_integration_http.py b/tests/test_integration_http.py index 0b3352665..56693ea40 100644 --- a/tests/test_integration_http.py +++ b/tests/test_integration_http.py @@ -834,9 +834,6 @@ def test_native_crash_http(cmake, httpserver): env=get_asan_crash_env(env), ) - # Wait for crash to be processed (longer delay for TSAN) - time.sleep(2) - # Restart to send the crash run( tmp_path, diff --git a/tests/test_integration_native.py b/tests/test_integration_native.py index d14c643e4..81d9e6a9a 100644 --- a/tests/test_integration_native.py +++ b/tests/test_integration_native.py @@ -25,7 +25,6 @@ assert_native_crash, assert_session, wait_for_file, - wait_for_daemon, assert_user_feedback, ) from .conditions import has_native, has_oom, is_kcov, is_asan, is_tsan, is_qemu @@ -48,8 +47,6 @@ def run_crash(tmp_path, exe, args, env): When running under ASAN, we configure it to not intercept crash signals so that our native crash handler can run and capture the crash. """ - started_at = time.time() - # When running under ASAN, disable ASAN's signal handling so our crash # handler can run. ASAN would otherwise intercept SIGSEGV/SIGABRT/etc # and terminate the process before our handler completes. @@ -75,10 +72,6 @@ def run_crash(tmp_path, exe, args, env): else: run(tmp_path, exe, args, expect_failure=True, env=env) - assert wait_for_daemon( - tmp_path, started_at - ), "native crash daemon did not finish before timeout" - def test_native_capture_crash(cmake, httpserver): """Test basic crash capture with native backend""" diff --git a/tests/test_integration_tus.py b/tests/test_integration_tus.py index 9f92ca749..601ede97e 100644 --- a/tests/test_integration_tus.py +++ b/tests/test_integration_tus.py @@ -11,7 +11,7 @@ Envelope, SENTRY_VERSION, ) -from .assertions import assert_attachment, wait_for +from .assertions import assert_attachment from .conditions import has_breakpad, has_files, has_http, has_native, is_qemu pytestmark = [ @@ -560,12 +560,9 @@ def test_tus_crash_native(cmake, httpserver): assert attachment_ref.payload.json["location"] == location db_dir = os.path.join(tmp_path, ".sentry-native") - - def run_dirs(): - return [ - d - for d in os.listdir(db_dir) - if d.endswith(".run") and os.path.isdir(os.path.join(db_dir, d)) - ] - - assert wait_for(lambda: run_dirs() == []), run_dirs() + run_dirs = [ + d + for d in os.listdir(db_dir) + if d.endswith(".run") and os.path.isdir(os.path.join(db_dir, d)) + ] + assert run_dirs == [] From 84eb05a5ecfe3d6e0d01640cbcae39838d4f224f Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Thu, 21 May 2026 16:58:18 +0200 Subject: [PATCH 05/14] make format --- tests/__init__.py | 6 +++--- tests/cmake.py | 1 - 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/tests/__init__.py b/tests/__init__.py index 76fe0785d..e458c2277 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -188,9 +188,9 @@ 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 should_wait_for_daemon: - assert wait_for_daemon(cwd, started_at), ( - "native crash daemon did not finish before timeout" - ) + assert wait_for_daemon( + cwd, started_at + ), "native crash daemon did not finish before timeout" if expect_failure: assert ( result.returncode != 0 diff --git a/tests/cmake.py b/tests/cmake.py index 75072ab3e..ff640fd41 100644 --- a/tests/cmake.py +++ b/tests/cmake.py @@ -17,7 +17,6 @@ get_tsan_env, ) - _cmake_build_options = {} From 15f22a40e57d6c8e209fcade3551eeef7185cc81 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Thu, 21 May 2026 17:35:07 +0200 Subject: [PATCH 06/14] test: Handle daemon log timestamp truncation Allow a small timestamp margin when matching daemon logs so filesystem mtime precision does not make the wait helper skip the current log. Co-Authored-By: OpenAI Codex --- tests/assertions.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/assertions.py b/tests/assertions.py index f78d9ca4a..e2addc2cc 100644 --- a/tests/assertions.py +++ b/tests/assertions.py @@ -686,9 +686,9 @@ def wait_for_file(path, timeout=10.0, interval=0.1): def wait_for_daemon(tmp_path, started_at, timeout=10.0): - from pathlib import Path - db_dir = Path(tmp_path) / ".sentry-native" + # Account for filesystems that truncate mtimes below time.time() precision. + started_at -= 1.0 def is_done(): for log_path in db_dir.glob("sentry-daemon-*.log"): From c0e158474eab88071ec7abbad4edf3d02e88676a Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Fri, 22 May 2026 09:09:41 +0200 Subject: [PATCH 07/14] test: add native crash helper for daemon waits --- tests/__init__.py | 30 ++++++++++++++++++---------- tests/cmake.py | 13 ------------ tests/test_e2e_sentry.py | 19 +++++++++++++++--- tests/test_integration_http.py | 4 ++-- tests/test_integration_logger.py | 1 + tests/test_integration_logs.py | 4 ++-- tests/test_integration_metrics.py | 4 ++-- tests/test_integration_native.py | 15 ++++++++++++-- tests/test_integration_screenshot.py | 12 +++++++---- tests/test_integration_tus.py | 4 ++-- 10 files changed, 66 insertions(+), 40 deletions(-) diff --git a/tests/__init__.py b/tests/__init__.py index e458c2277..1b8e1bcae 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -25,8 +25,7 @@ def adb(*args, **kwargs): SENTRY_VERSION = "0.14.2" -from .assertions import wait_for_daemon -from .cmake import cmake_option +from .assertions import wait_for_daemon as _wait_for_daemon def make_dsn(httpserver, auth="uiaeosnrtdy", id=123456, proxy_host=False): @@ -100,19 +99,18 @@ 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 env is None: env = dict(os.environ) - should_wait_for_daemon = ( - expect_failure and cmake_option(cwd, "SENTRY_BACKEND") == "native" - ) if kwargs.get("check"): raise pytest.fail.Exception( "`check` is inferred from `expect_failure`, and should not be passed in the kwargs" ) check = expect_failure == False __tracebackhide__ = True - started_at = time.time() if should_wait_for_daemon else None + started_at = time.time() 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 @@ -187,10 +185,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 should_wait_for_daemon: - assert wait_for_daemon( + if wait_for_daemon: + assert _wait_for_daemon( cwd, started_at - ), "native crash daemon did not finish before timeout" + ), "native crash daemon did not finish within timeout" if expect_failure: assert ( result.returncode != 0 @@ -208,6 +206,18 @@ def run(cwd, exe, args, expect_failure=False, env=None, **kwargs): ) from None +def run_native_crash(cwd, exe, args, env=None, **kwargs): + return run( + cwd, + exe, + args, + expect_failure=True, + env=env, + wait_for_daemon=True, + **kwargs, + ) + + def check_output(*args, **kwargs): stdout = run(*args, stdout=subprocess.PIPE, **kwargs).stdout # capturing stdout on windows actually encodes "\n" as "\r\n", which we diff --git a/tests/cmake.py b/tests/cmake.py index ff640fd41..350e4570b 100644 --- a/tests/cmake.py +++ b/tests/cmake.py @@ -17,17 +17,6 @@ get_tsan_env, ) -_cmake_build_options = {} - - -def cmake_option(cwd, name): - options = _cmake_build_options.get(os.fspath(cwd)) - if options is None: - options = _cmake_build_options.get(os.path.realpath(cwd)) - if options is None: - return None - return options.get(name) - class CMake: def __init__(self, factory): @@ -65,7 +54,6 @@ def compile(self, targets, options=None, cflags=None): # ensure that there are no left-overs from previous runs shutil.rmtree(build_tmp_path / ".sentry-native", ignore_errors=True) - _cmake_build_options[os.fspath(build_tmp_path)] = dict(options) # Inject a sub-path into the temporary build directory as the CWD for all tests to verify UTF-8 path handling. if os.environ.get("UTF8_TEST_CWD"): @@ -73,7 +61,6 @@ def compile(self, targets, options=None, cflags=None): utf8_parent = self.factory.mktemp("utf8") utf8_subpath = utf8_parent / "นี่คือไดเร็กทอรีทดสอบ" utf8_subpath.symlink_to(build_tmp_path, target_is_directory=True) - _cmake_build_options[os.fspath(utf8_subpath)] = dict(options) return utf8_subpath return build_tmp_path diff --git a/tests/test_e2e_sentry.py b/tests/test_e2e_sentry.py index 68fe50c35..90ff25d1f 100644 --- a/tests/test_e2e_sentry.py +++ b/tests/test_e2e_sentry.py @@ -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. @@ -372,7 +372,14 @@ 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, + ) except AssertionError: if is_kcov: # kcov may exit with 0 even on crash, try without expect_failure @@ -437,7 +444,13 @@ 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) # Print daemon logs for debugging (especially useful for Windows thread duplication investigation) diff --git a/tests/test_integration_http.py b/tests/test_integration_http.py index 56693ea40..fa6e7f940 100644 --- a/tests/test_integration_http.py +++ b/tests/test_integration_http.py @@ -9,6 +9,7 @@ from . import ( make_dsn, run, + run_native_crash, Envelope, split_log_request_cond, is_feedback_envelope, @@ -826,11 +827,10 @@ def test_native_crash_http(cmake, httpserver): # Use stdout for initialization delay under TSAN # Configure ASAN to not intercept crash signals - run( + run_native_crash( tmp_path, "sentry_example", ["log", "stdout", "attachment", "crash"], - expect_failure=True, env=get_asan_crash_env(env), ) diff --git a/tests/test_integration_logger.py b/tests/test_integration_logger.py index 43fe80149..65acffb32 100644 --- a/tests/test_integration_logger.py +++ b/tests/test_integration_logger.py @@ -40,6 +40,7 @@ def _run_logger_crash_test(backend, cmake, logger_option): stdout=subprocess.PIPE, stderr=subprocess.STDOUT, expect_failure=True, + wait_for_daemon=backend == "native", ) # Process should have crashed (non-zero exit code) diff --git a/tests/test_integration_logs.py b/tests/test_integration_logs.py index 5bef52b3b..aa0f4761f 100644 --- a/tests/test_integration_logs.py +++ b/tests/test_integration_logs.py @@ -5,6 +5,7 @@ from . import ( make_dsn, run, + run_native_crash, Envelope, split_log_request_cond, is_logs_envelope, @@ -277,11 +278,10 @@ def test_logs_on_crash_native(cmake, httpserver, rerun): env = dict(os.environ, SENTRY_DSN=make_dsn(httpserver)) with httpserver.wait(timeout=10): - run( + run_native_crash( tmp_path, "sentry_example", ["log", "capture-log", "crash"], - expect_failure=True, env=env, ) diff --git a/tests/test_integration_metrics.py b/tests/test_integration_metrics.py index 91d789163..9ac495ae0 100644 --- a/tests/test_integration_metrics.py +++ b/tests/test_integration_metrics.py @@ -5,6 +5,7 @@ from . import ( make_dsn, run, + run_native_crash, Envelope, split_log_request_cond, is_metrics_envelope, @@ -431,11 +432,10 @@ def test_metrics_on_crash_native(cmake, httpserver, rerun): env = dict(os.environ, SENTRY_DSN=make_dsn(httpserver)) with httpserver.wait(timeout=10): - run( + run_native_crash( tmp_path, "sentry_example", ["log", "capture-metric", "crash"], - expect_failure=True, env=env, ) diff --git a/tests/test_integration_native.py b/tests/test_integration_native.py index 81d9e6a9a..a2bd03cf3 100644 --- a/tests/test_integration_native.py +++ b/tests/test_integration_native.py @@ -15,6 +15,7 @@ is_feedback_envelope, make_dsn, run, + run_native_crash, Envelope, split_log_request_cond, ) @@ -65,12 +66,22 @@ def run_crash(tmp_path, exe, args, env): if is_kcov: try: - run(tmp_path, exe, args, expect_failure=True, env=env) + run_native_crash( + tmp_path, + exe, + args, + env=env, + ) 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_native_crash( + tmp_path, + exe, + args, + env=env, + ) def test_native_capture_crash(cmake, httpserver): diff --git a/tests/test_integration_screenshot.py b/tests/test_integration_screenshot.py index 450ae006e..e6f26e97d 100644 --- a/tests/test_integration_screenshot.py +++ b/tests/test_integration_screenshot.py @@ -6,7 +6,7 @@ import pytest -from . import Envelope, make_dsn, run +from . import Envelope, make_dsn, run, run_native_crash def assert_screenshot_file(database_path): @@ -82,7 +82,12 @@ def test_capture_screenshot_native(cmake, httpserver): httpserver.expect_oneshot_request("/api/123456/envelope/").respond_with_data("OK") with httpserver.wait(timeout=10) as waiting: - run(tmp_path, "sentry_screenshot", ["crash"], expect_failure=True, env=env) + run_native_crash( + tmp_path, + "sentry_screenshot", + ["crash"], + env=env, + ) assert waiting.result @@ -172,11 +177,10 @@ def test_before_screenshot_native(cmake, httpserver): httpserver.expect_oneshot_request("/api/123456/envelope/").respond_with_data("OK") with httpserver.wait(timeout=10) as waiting: - run( + run_native_crash( tmp_path, "sentry_screenshot", ["crash", "before-screenshot"], - expect_failure=True, env=env, ) diff --git a/tests/test_integration_tus.py b/tests/test_integration_tus.py index 601ede97e..1b47b1e66 100644 --- a/tests/test_integration_tus.py +++ b/tests/test_integration_tus.py @@ -8,6 +8,7 @@ from . import ( make_dsn, run, + run_native_crash, Envelope, SENTRY_VERSION, ) @@ -522,11 +523,10 @@ def test_tus_crash_native(cmake, httpserver): env = dict(os.environ, SENTRY_DSN=make_dsn(httpserver)) with httpserver.wait(timeout=15) as waiting: - run( + run_native_crash( tmp_path, "sentry_example", ["log", "large-attachment", "crash"], - expect_failure=True, env=env, ) assert waiting.result From 81725f491a3de04ed8a65c64a8332204048d464f Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Fri, 22 May 2026 09:24:34 +0200 Subject: [PATCH 08/14] fix daemon wait timeout with sanitizers --- tests/assertions.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/assertions.py b/tests/assertions.py index e2addc2cc..14ce63dd8 100644 --- a/tests/assertions.py +++ b/tests/assertions.py @@ -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+)[-.]?(.*)") @@ -685,7 +685,10 @@ def wait_for_file(path, timeout=10.0, interval=0.1): ) -def wait_for_daemon(tmp_path, started_at, timeout=10.0): +def wait_for_daemon(tmp_path, started_at, timeout=None): + 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 From 00423cda800bd2eec32d9b456143427aa2899a4f Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Fri, 22 May 2026 09:32:54 +0200 Subject: [PATCH 09/14] Update CHANGELOG.md --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a3f379e72..cbc9e3341 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,10 @@ - POSIX: prevent condition-variable timeout overflow from busy-spinning flush and shutdown waits. ([#1731](https://github.com/getsentry/sentry-native/pull/1731)) - Native/macOS: fix thread stack descriptor. ([#1726](https://github.com/getsentry/sentry-native/pull/1726)) +**Improvements**: + +- Native: allow crashed processes exit after crash data is captured so crashed apps no longer remain stuck on screen while the crash daemon finishes potentially large uploads. ([#1739](https://github.com/getsentry/sentry-native/pull/1739)) + ## 0.14.2 **Fixes**: From ca47742d2b013a0f3c507e33c397363d5c95cef0 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Fri, 22 May 2026 09:35:21 +0200 Subject: [PATCH 10/14] kcov --- tests/test_e2e_sentry.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/test_e2e_sentry.py b/tests/test_e2e_sentry.py index 90ff25d1f..df555612f 100644 --- a/tests/test_e2e_sentry.py +++ b/tests/test_e2e_sentry.py @@ -383,7 +383,14 @@ def run_crash_e2e(tmp_path, exe, args, env, wait_for_daemon=False): 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 From c6d00c61047cbde0185eca9905c55ba401b9d25b Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Fri, 22 May 2026 12:26:12 +0200 Subject: [PATCH 11/14] crash upload mode --- CHANGELOG.md | 8 ++--- examples/example.c | 4 +++ include/sentry.h | 40 ++++++++++++++++++++++ src/backends/native/sentry_crash_context.h | 1 + src/backends/native/sentry_crash_daemon.c | 15 +++++--- src/backends/native/sentry_crash_handler.c | 4 +-- src/backends/sentry_backend_native.c | 1 + src/sentry_options.c | 20 +++++++++++ src/sentry_options.h | 1 + tests/__init__.py | 12 ------- tests/assertions.py | 30 +++++++--------- tests/test_dotnet_signals.py | 11 +++++- tests/test_integration_http.py | 7 ++-- tests/test_integration_logger.py | 1 - tests/test_integration_logs.py | 4 +-- tests/test_integration_metrics.py | 4 +-- tests/test_integration_native.py | 16 ++++----- tests/test_integration_screenshot.py | 12 +++---- tests/test_integration_tus.py | 16 ++++++--- tests/unit/test_native_backend.c | 7 ++++ 20 files changed, 146 insertions(+), 68 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cbc9e3341..e643d04d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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)) @@ -14,10 +18,6 @@ - POSIX: prevent condition-variable timeout overflow from busy-spinning flush and shutdown waits. ([#1731](https://github.com/getsentry/sentry-native/pull/1731)) - Native/macOS: fix thread stack descriptor. ([#1726](https://github.com/getsentry/sentry-native/pull/1726)) -**Improvements**: - -- Native: allow crashed processes exit after crash data is captured so crashed apps no longer remain stuck on screen while the crash daemon finishes potentially large uploads. ([#1739](https://github.com/getsentry/sentry-native/pull/1739)) - ## 0.14.2 **Fixes**: diff --git a/examples/example.c b/examples/example.c index 1c5ef292b..49dfa2fce 100644 --- a/examples/example.c +++ b/examples/example.c @@ -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 }; diff --git a/include/sentry.h b/include/sentry.h index dc68aab2a..97d024981 100644 --- a/include/sentry.h +++ b/include/sentry.h @@ -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. */ @@ -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. diff --git a/src/backends/native/sentry_crash_context.h b/src/backends/native/sentry_crash_context.h index 0b34278ff..687029e70 100644 --- a/src/backends/native/sentry_crash_context.h +++ b/src/backends/native/sentry_crash_context.h @@ -275,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 diff --git a/src/backends/native/sentry_crash_daemon.c b/src/backends/native/sentry_crash_daemon.c index c8882d170..c32e15e59 100644 --- a/src/backends/native/sentry_crash_daemon.c +++ b/src/backends/native/sentry_crash_daemon.c @@ -3477,11 +3477,16 @@ sentry__crash_daemon_main(pid_t app_pid, uint64_t app_tid, HANDLE event_handle, sentry__process_crash(options, ipc); crash_processed = true; - // 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); + 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) diff --git a/src/backends/native/sentry_crash_handler.c b/src/backends/native/sentry_crash_handler.c index fd8a4f47d..415f7575a 100644 --- a/src/backends/native/sentry_crash_handler.c +++ b/src/backends/native/sentry_crash_handler.c @@ -671,7 +671,7 @@ crash_signal_handler(int signum, siginfo_t *info, void *context) // Daemon started processing (no logging - signal-safe) processing_started = true; } else if (state >= SENTRY_CRASH_STATE_CAPTURED) { - // Daemon finished processing (no logging - signal-safe) + // Daemon captured crash data (no logging - signal-safe) goto daemon_handling; } @@ -955,7 +955,7 @@ crash_exception_filter(EXCEPTION_POINTERS *exception_info) // context) processing_started = true; } else if (state >= SENTRY_CRASH_STATE_CAPTURED) { - // Daemon finished processing (no logging - exception filter + // Daemon captured crash data (no logging - exception filter // context) break; } diff --git a/src/backends/sentry_backend_native.c b/src/backends/sentry_backend_native.c index fe1af9322..8dc0fd4a8 100644 --- a/src/backends/sentry_backend_native.c +++ b/src/backends/sentry_backend_native.c @@ -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; diff --git a/src/sentry_options.c b/src/sentry_options.c index 38d7beed8..eba3b4ac9 100644 --- a/src/sentry_options.c +++ b/src/sentry_options.c @@ -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; @@ -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) diff --git a/src/sentry_options.h b/src/sentry_options.h index 39e33dc47..406ba06c8 100644 --- a/src/sentry_options.h +++ b/src/sentry_options.h @@ -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); diff --git a/tests/__init__.py b/tests/__init__.py index 1b8e1bcae..47aefa34a 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -206,18 +206,6 @@ def run( ) from None -def run_native_crash(cwd, exe, args, env=None, **kwargs): - return run( - cwd, - exe, - args, - expect_failure=True, - env=env, - wait_for_daemon=True, - **kwargs, - ) - - def check_output(*args, **kwargs): stdout = run(*args, stdout=subprocess.PIPE, **kwargs).stdout # capturing stdout on windows actually encodes "\n" as "\r\n", which we diff --git a/tests/assertions.py b/tests/assertions.py index 14ce63dd8..0f505d25f 100644 --- a/tests/assertions.py +++ b/tests/assertions.py @@ -666,26 +666,21 @@ def assert_failed_proxy_auth_request(stdout): ) -def wait_for(condition, timeout=10.0, interval=0.1): +def wait_for_file(path, timeout=10.0, poll_interval=0.1): + import glob import time - start = time.time() - while time.time() - start < timeout: - if condition(): + deadline = time.time() + timeout + while time.time() < deadline: + if glob.glob(str(path)): return True - time.sleep(interval) - return condition() - - -def wait_for_file(path, timeout=10.0, interval=0.1): - import glob - - return wait_for( - lambda: bool(glob.glob(str(path))), timeout=timeout, interval=interval - ) + 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 @@ -693,7 +688,8 @@ def wait_for_daemon(tmp_path, started_at, timeout=None): # Account for filesystems that truncate mtimes below time.time() precision. started_at -= 1.0 - def is_done(): + 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: @@ -705,6 +701,6 @@ def is_done(): if "Marking crash state as DONE" in log: return True - return False + time.sleep(0.1) - return wait_for(is_done, timeout=timeout) + return False diff --git a/tests/test_dotnet_signals.py b/tests/test_dotnet_signals.py index 69371ce30..ca8b26bc0 100644 --- a/tests/test_dotnet_signals.py +++ b/tests/test_dotnet_signals.py @@ -3,11 +3,11 @@ import shutil import subprocess import sys +import time import pytest from tests import adb -from tests.assertions import wait_for from tests.conditions import is_android, is_arm32, is_tsan, is_x86, is_asan project_fixture_path = pathlib.Path("tests/fixtures/dotnet_signal") @@ -281,6 +281,15 @@ def test_aot_signals_inproc(cmake): ANDROID_PACKAGE = "io.sentry.ndk.dotnet.signal.test" +def wait_for(condition, timeout=10, interval=0.5): + start = time.time() + while time.time() - start < timeout: + if condition(): + return True + time.sleep(interval) + return condition() + + def run_android(args=None, strategy=None, reinit=False, timeout=30): if args is None: args = [] diff --git a/tests/test_integration_http.py b/tests/test_integration_http.py index fa6e7f940..0b3352665 100644 --- a/tests/test_integration_http.py +++ b/tests/test_integration_http.py @@ -9,7 +9,6 @@ from . import ( make_dsn, run, - run_native_crash, Envelope, split_log_request_cond, is_feedback_envelope, @@ -827,13 +826,17 @@ def test_native_crash_http(cmake, httpserver): # Use stdout for initialization delay under TSAN # Configure ASAN to not intercept crash signals - run_native_crash( + run( tmp_path, "sentry_example", ["log", "stdout", "attachment", "crash"], + expect_failure=True, env=get_asan_crash_env(env), ) + # Wait for crash to be processed (longer delay for TSAN) + time.sleep(2) + # Restart to send the crash run( tmp_path, diff --git a/tests/test_integration_logger.py b/tests/test_integration_logger.py index 65acffb32..43fe80149 100644 --- a/tests/test_integration_logger.py +++ b/tests/test_integration_logger.py @@ -40,7 +40,6 @@ def _run_logger_crash_test(backend, cmake, logger_option): stdout=subprocess.PIPE, stderr=subprocess.STDOUT, expect_failure=True, - wait_for_daemon=backend == "native", ) # Process should have crashed (non-zero exit code) diff --git a/tests/test_integration_logs.py b/tests/test_integration_logs.py index aa0f4761f..5bef52b3b 100644 --- a/tests/test_integration_logs.py +++ b/tests/test_integration_logs.py @@ -5,7 +5,6 @@ from . import ( make_dsn, run, - run_native_crash, Envelope, split_log_request_cond, is_logs_envelope, @@ -278,10 +277,11 @@ def test_logs_on_crash_native(cmake, httpserver, rerun): env = dict(os.environ, SENTRY_DSN=make_dsn(httpserver)) with httpserver.wait(timeout=10): - run_native_crash( + run( tmp_path, "sentry_example", ["log", "capture-log", "crash"], + expect_failure=True, env=env, ) diff --git a/tests/test_integration_metrics.py b/tests/test_integration_metrics.py index 9ac495ae0..91d789163 100644 --- a/tests/test_integration_metrics.py +++ b/tests/test_integration_metrics.py @@ -5,7 +5,6 @@ from . import ( make_dsn, run, - run_native_crash, Envelope, split_log_request_cond, is_metrics_envelope, @@ -432,10 +431,11 @@ def test_metrics_on_crash_native(cmake, httpserver, rerun): env = dict(os.environ, SENTRY_DSN=make_dsn(httpserver)) with httpserver.wait(timeout=10): - run_native_crash( + run( tmp_path, "sentry_example", ["log", "capture-metric", "crash"], + expect_failure=True, env=env, ) diff --git a/tests/test_integration_native.py b/tests/test_integration_native.py index a2bd03cf3..2c4c7e8b1 100644 --- a/tests/test_integration_native.py +++ b/tests/test_integration_native.py @@ -15,7 +15,6 @@ is_feedback_envelope, make_dsn, run, - run_native_crash, Envelope, split_log_request_cond, ) @@ -40,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. @@ -66,21 +65,25 @@ def run_crash(tmp_path, exe, args, env): if is_kcov: try: - run_native_crash( + 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_native_crash( + run( tmp_path, exe, args, + expect_failure=True, env=env, + wait_for_daemon=wait_for_daemon, ) @@ -1024,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: @@ -1034,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 diff --git a/tests/test_integration_screenshot.py b/tests/test_integration_screenshot.py index e6f26e97d..450ae006e 100644 --- a/tests/test_integration_screenshot.py +++ b/tests/test_integration_screenshot.py @@ -6,7 +6,7 @@ import pytest -from . import Envelope, make_dsn, run, run_native_crash +from . import Envelope, make_dsn, run def assert_screenshot_file(database_path): @@ -82,12 +82,7 @@ def test_capture_screenshot_native(cmake, httpserver): httpserver.expect_oneshot_request("/api/123456/envelope/").respond_with_data("OK") with httpserver.wait(timeout=10) as waiting: - run_native_crash( - tmp_path, - "sentry_screenshot", - ["crash"], - env=env, - ) + run(tmp_path, "sentry_screenshot", ["crash"], expect_failure=True, env=env) assert waiting.result @@ -177,10 +172,11 @@ def test_before_screenshot_native(cmake, httpserver): httpserver.expect_oneshot_request("/api/123456/envelope/").respond_with_data("OK") with httpserver.wait(timeout=10) as waiting: - run_native_crash( + run( tmp_path, "sentry_screenshot", ["crash", "before-screenshot"], + expect_failure=True, env=env, ) diff --git a/tests/test_integration_tus.py b/tests/test_integration_tus.py index 1b47b1e66..4c81a25b7 100644 --- a/tests/test_integration_tus.py +++ b/tests/test_integration_tus.py @@ -1,6 +1,7 @@ import json import os import threading +import time import pytest from werkzeug.wrappers import Response @@ -8,11 +9,10 @@ from . import ( make_dsn, run, - run_native_crash, Envelope, SENTRY_VERSION, ) -from .assertions import assert_attachment +from .assertions import assert_attachment, wait_for_daemon from .conditions import has_breakpad, has_files, has_http, has_native, is_qemu pytestmark = [ @@ -522,14 +522,22 @@ def test_tus_crash_native(cmake, httpserver): env = dict(os.environ, SENTRY_DSN=make_dsn(httpserver)) + started_at = time.time() with httpserver.wait(timeout=15) as waiting: - run_native_crash( + run( tmp_path, "sentry_example", - ["log", "large-attachment", "crash"], + [ + "log", + "large-attachment", + "async-crash-upload", + "crash", + ], + expect_failure=True, env=env, ) assert waiting.result + assert wait_for_daemon(tmp_path, started_at) create_req = upload_req = envelope_req = None for entry in httpserver.log: diff --git a/tests/unit/test_native_backend.c b/tests/unit/test_native_backend.c index b8eb0c569..b8a889124 100644 --- a/tests/unit/test_native_backend.c +++ b/tests/unit/test_native_backend.c @@ -362,6 +362,8 @@ SENTRY_TEST(crash_context_transport_fields) ctx->shutdown_timeout = 12345; TEST_CHECK_UINT64_EQUAL(ctx->shutdown_timeout, 12345); + ctx->crash_upload_mode = SENTRY_CRASH_UPLOAD_MODE_ASYNC; + TEST_CHECK_INT_EQUAL(ctx->crash_upload_mode, SENTRY_CRASH_UPLOAD_MODE_ASYNC); // Verify fields are zero-initialized when memset to 0 memset(ctx, 0, sizeof(*ctx)); @@ -369,6 +371,7 @@ SENTRY_TEST(crash_context_transport_fields) TEST_CHECK(ctx->proxy[0] == '\0'); TEST_CHECK(ctx->user_agent[0] == '\0'); TEST_CHECK_UINT64_EQUAL(ctx->shutdown_timeout, 0); + TEST_CHECK_INT_EQUAL(ctx->crash_upload_mode, SENTRY_CRASH_UPLOAD_MODE_SYNC); sentry_free(ctx); #else @@ -390,6 +393,8 @@ SENTRY_TEST(crash_context_options_propagation) sentry_options_set_ca_certs(options, "/path/to/ca-bundle.crt"); sentry_options_set_proxy(options, "http://myproxy:3128"); sentry_options_set_shutdown_timeout(options, 12345); + sentry_options_set_crash_upload_mode( + options, SENTRY_CRASH_UPLOAD_MODE_ASYNC); // Verify options were set correctly TEST_CHECK_STRING_EQUAL( @@ -417,6 +422,7 @@ SENTRY_TEST(crash_context_options_propagation) ctx->user_agent[sizeof(ctx->user_agent) - 1] = '\0'; } ctx->shutdown_timeout = options->shutdown_timeout; + ctx->crash_upload_mode = options->crash_upload_mode; // Verify crash context received the values TEST_CHECK_STRING_EQUAL(ctx->ca_certs, "/path/to/ca-bundle.crt"); @@ -424,6 +430,7 @@ SENTRY_TEST(crash_context_options_propagation) // user_agent should have the default SDK user agent TEST_CHECK(ctx->user_agent[0] != '\0'); TEST_CHECK_UINT64_EQUAL(ctx->shutdown_timeout, 12345); + TEST_CHECK_INT_EQUAL(ctx->crash_upload_mode, SENTRY_CRASH_UPLOAD_MODE_ASYNC); sentry_free(ctx); sentry_options_free(options); From 1e090ce5a46f6e7eacf2b35460a12f0f7379ed3b Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Fri, 22 May 2026 15:22:51 +0200 Subject: [PATCH 12/14] make format --- tests/unit/test_native_backend.c | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/unit/test_native_backend.c b/tests/unit/test_native_backend.c index b8a889124..5084b81e3 100644 --- a/tests/unit/test_native_backend.c +++ b/tests/unit/test_native_backend.c @@ -363,7 +363,8 @@ SENTRY_TEST(crash_context_transport_fields) ctx->shutdown_timeout = 12345; TEST_CHECK_UINT64_EQUAL(ctx->shutdown_timeout, 12345); ctx->crash_upload_mode = SENTRY_CRASH_UPLOAD_MODE_ASYNC; - TEST_CHECK_INT_EQUAL(ctx->crash_upload_mode, SENTRY_CRASH_UPLOAD_MODE_ASYNC); + TEST_CHECK_INT_EQUAL( + ctx->crash_upload_mode, SENTRY_CRASH_UPLOAD_MODE_ASYNC); // Verify fields are zero-initialized when memset to 0 memset(ctx, 0, sizeof(*ctx)); @@ -430,7 +431,8 @@ SENTRY_TEST(crash_context_options_propagation) // user_agent should have the default SDK user agent TEST_CHECK(ctx->user_agent[0] != '\0'); TEST_CHECK_UINT64_EQUAL(ctx->shutdown_timeout, 12345); - TEST_CHECK_INT_EQUAL(ctx->crash_upload_mode, SENTRY_CRASH_UPLOAD_MODE_ASYNC); + TEST_CHECK_INT_EQUAL( + ctx->crash_upload_mode, SENTRY_CRASH_UPLOAD_MODE_ASYNC); sentry_free(ctx); sentry_options_free(options); From 4c7ccc7557ea5de12f82ad8e60d9cd917389b132 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Fri, 22 May 2026 15:27:18 +0200 Subject: [PATCH 13/14] sentry_example + wait_for_daemon needs "log" arg --- tests/__init__.py | 4 ++++ tests/test_integration_tus.py | 6 ++---- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/__init__.py b/tests/__init__.py index 47aefa34a..f709ecabe 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -102,6 +102,10 @@ def extract_request(httpserver_log, cond): 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"): diff --git a/tests/test_integration_tus.py b/tests/test_integration_tus.py index 4c81a25b7..81318f13b 100644 --- a/tests/test_integration_tus.py +++ b/tests/test_integration_tus.py @@ -1,7 +1,6 @@ import json import os import threading -import time import pytest from werkzeug.wrappers import Response @@ -12,7 +11,7 @@ Envelope, SENTRY_VERSION, ) -from .assertions import assert_attachment, wait_for_daemon +from .assertions import assert_attachment from .conditions import has_breakpad, has_files, has_http, has_native, is_qemu pytestmark = [ @@ -522,7 +521,6 @@ def test_tus_crash_native(cmake, httpserver): env = dict(os.environ, SENTRY_DSN=make_dsn(httpserver)) - started_at = time.time() with httpserver.wait(timeout=15) as waiting: run( tmp_path, @@ -535,9 +533,9 @@ def test_tus_crash_native(cmake, httpserver): ], expect_failure=True, env=env, + wait_for_daemon=True, ) assert waiting.result - assert wait_for_daemon(tmp_path, started_at) create_req = upload_req = envelope_req = None for entry in httpserver.log: From 4c61fae5fcfe33a2a652843622c6a6ee91519fac Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Tue, 26 May 2026 20:37:25 +0200 Subject: [PATCH 14/14] fix(native): Gate async crash exit on captured envelope Only mark async native crashes as captured after the daemon hands the crash envelope to the external reporter or transport. This keeps failed capture paths from unblocking the crashed process as if crash data was durable. Co-Authored-By: OpenAI Codex --- src/backends/native/sentry_crash_daemon.c | 14 ++++++++++---- src/backends/native/sentry_crash_daemon.h | 3 ++- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/backends/native/sentry_crash_daemon.c b/src/backends/native/sentry_crash_daemon.c index 76c5ee8e6..f918d565e 100644 --- a/src/backends/native/sentry_crash_daemon.c +++ b/src/backends/native/sentry_crash_daemon.c @@ -2805,10 +2805,11 @@ write_envelope_with_minidump(const sentry_options_t *options, * * Called by the crash daemon (out-of-process on Linux/macOS). */ -void +bool sentry__process_crash(const sentry_options_t *options, sentry_crash_ipc_t *ipc) { SENTRY_DEBUG("Processing crash - START"); + bool crash_captured = false; sentry_crash_context_t *ctx = ipc->shmem; @@ -3119,11 +3120,14 @@ sentry__process_crash(const sentry_options_t *options, sentry_crash_ipc_t *ipc) if (options && options->transport && options->run) { SENTRY_DEBUG("Capturing crash envelope"); sentry__capture_envelope(options->transport, envelope, options); + crash_captured = true; SENTRY_DEBUG("Crash envelope captured (queued)"); } else { SENTRY_WARN("No transport available for sending envelope"); sentry_envelope_free(envelope); } + } else { + crash_captured = true; } // Clean up temporary envelope file (keep minidump for @@ -3194,6 +3198,7 @@ sentry__process_crash(const sentry_options_t *options, sentry_crash_ipc_t *ipc) done: SENTRY_DEBUG("Processing crash - END"); SENTRY_DEBUG("Crash processing complete"); + return crash_captured; } /** @@ -3481,11 +3486,12 @@ sentry__crash_daemon_main(pid_t app_pid, uint64_t app_tid, HANDLE event_handle, long state = sentry__atomic_fetch(&ipc->shmem->state); if (state == SENTRY_CRASH_STATE_CRASHED && !crash_processed) { SENTRY_DEBUG("Crash notification received, processing"); - sentry__process_crash(options, ipc); + bool crash_captured = sentry__process_crash(options, ipc); crash_processed = true; - if (ipc->shmem->crash_upload_mode - == SENTRY_CRASH_UPLOAD_MODE_ASYNC) { + if (crash_captured + && 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. diff --git a/src/backends/native/sentry_crash_daemon.h b/src/backends/native/sentry_crash_daemon.h index 69c93e9a2..2ae3a3576 100644 --- a/src/backends/native/sentry_crash_daemon.h +++ b/src/backends/native/sentry_crash_daemon.h @@ -65,8 +65,9 @@ int sentry__crash_daemon_main(pid_t app_pid, uint64_t app_tid, * * @param options Sentry options (DSN, transport, etc.) * @param ipc Crash IPC with crash context in shared memory + * @return true if the crash envelope was captured for upload or reporting */ -void sentry__process_crash( +bool sentry__process_crash( const struct sentry_options_s *options, sentry_crash_ipc_t *ipc); #endif