From 618e54c6414b1213d0def47ecbc602c1e44ae8c0 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Tue, 19 May 2026 10:07:16 +0200 Subject: [PATCH 1/4] test: restart on crash --- examples/example.c | 46 ++++++++++++++++++++++++ tests/test_integration_crashpad.py | 28 +++++++++++++++ tests/test_integration_http.py | 56 ++++++++++++++++++++++++++++++ tests/test_integration_native.py | 45 ++++++++++++++++++------ 4 files changed, 165 insertions(+), 10 deletions(-) diff --git a/examples/example.c b/examples/example.c index 16c2edd22..80b4dc48d 100644 --- a/examples/example.c +++ b/examples/example.c @@ -20,6 +20,7 @@ #ifdef SENTRY_PLATFORM_WINDOWS # include +# include # include # define sleep_s(SECONDS) Sleep((SECONDS) * 1000) #else @@ -137,6 +138,47 @@ on_crash_callback( return event; } +static sentry_value_t +restart_on_crash( + const sentry_ucontext_t *uctx, sentry_value_t event, void *user_data) +{ + (void)uctx; + + int argc = 0; + char **argv = user_data; + assert(argv); + assert(argv[0]); + while (argv[argc]) { + argc++; + } + + int child_argc = 0; + char **child_argv = alloca((argc + 1) * sizeof(char *)); + for (int i = 0; argv[i]; i++) { + if (strcmp(argv[i], "restart-on-crash") != 0) { + child_argv[child_argc++] = argv[i]; + } + } + child_argv[child_argc] = NULL; + +#ifdef SENTRY_PLATFORM_WINDOWS + _spawnv(_P_NOWAIT, child_argv[0], (const char *const *)child_argv); +#else + if (fork() == 0) { + // The crashing signal is blocked while the crash handler runs. Do not + // let the restarted child inherit that mask. + sigset_t set; + sigfillset(&set); + sigprocmask(SIG_UNBLOCK, &set, NULL); + + execv(child_argv[0], child_argv); + _exit(127); + } +#endif + + return event; +} + static sentry_value_t before_transaction_callback(sentry_value_t tx, void *user_data) { @@ -637,6 +679,10 @@ main(int argc, char **argv) options, discarding_on_crash_callback, NULL); } + if (has_arg(argc, argv, "restart-on-crash")) { + sentry_options_set_on_crash(options, restart_on_crash, argv); + } + if (has_arg(argc, argv, "before-transaction")) { sentry_options_set_before_transaction( options, before_transaction_callback, NULL); diff --git a/tests/test_integration_crashpad.py b/tests/test_integration_crashpad.py index dd05892c5..b73f5e73e 100644 --- a/tests/test_integration_crashpad.py +++ b/tests/test_integration_crashpad.py @@ -1063,3 +1063,31 @@ def test_crashpad_cache_max_age(cmake, httpserver): assert len(cache_files) == 3 for f in cache_files: assert time.time() - f.stat().st_mtime <= 5 * 24 * 60 * 60 + + +@pytest.mark.skipif( + sys.platform == "darwin", + reason="crashpad doesn't provide SetFirstChanceExceptionHandler on macOS", +) +def test_crashpad_restart_on_crash(cmake, httpserver): + tmp_path = cmake(["sentry_example"], {"SENTRY_BACKEND": "crashpad"}) + + httpserver.expect_oneshot_request("/api/123456/minidump/").respond_with_data("OK") + httpserver.expect_oneshot_request("/api/123456/minidump/").respond_with_data("OK") + + with httpserver.wait(timeout=10) as waiting: + # The restarted child inherits stdio, so PIPE waits for it without a sleep. + run( + tmp_path, + "sentry_example", + ["crash", "restart-on-crash"], + expect_failure=True, + env=dict(os.environ, SENTRY_DSN=make_dsn(httpserver)), + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + + assert waiting.result + assert len(httpserver.log) == 2 + for req in httpserver.log: + assert_crashpad_upload(req[0]) diff --git a/tests/test_integration_http.py b/tests/test_integration_http.py index 81633bbb3..b42dec0d5 100644 --- a/tests/test_integration_http.py +++ b/tests/test_integration_http.py @@ -1,6 +1,7 @@ import itertools import json import os +import subprocess import time import pytest @@ -1317,3 +1318,58 @@ def test_http_retry_session_on_network_error(cmake, httpserver, unreachable_dsn) cache_files = list(cache_dir.glob("*.envelope")) assert len(cache_files) == 0 + + +@pytest.mark.parametrize( + "backend", + [ + "inproc", + pytest.param( + "breakpad", + marks=pytest.mark.skipif( + not has_breakpad or is_qemu, reason="test needs breakpad backend" + ), + ), + ], +) +@pytest.mark.skipif(is_qemu, reason="unreliable under qemu-user") +def test_restart_on_crash(cmake, httpserver, backend): + tmp_path = cmake(["sentry_example"], {"SENTRY_BACKEND": backend}) + + httpserver.expect_oneshot_request( + "/api/123456/envelope/", + headers={"x-sentry-auth": auth_header}, + ).respond_with_data("OK") + httpserver.expect_oneshot_request( + "/api/123456/envelope/", + headers={"x-sentry-auth": auth_header}, + ).respond_with_data("OK") + + with httpserver.wait(timeout=10) as waiting: + # The restarted child inherits stdio, so PIPE waits for it without a sleep. + run( + tmp_path, + "sentry_example", + ["crash", "restart-on-crash"], + expect_failure=True, + env=dict(os.environ, SENTRY_DSN=make_dsn(httpserver)), + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + run( + tmp_path, + "sentry_example", + ["log", "no-setup"], + env=dict(os.environ, SENTRY_DSN=make_dsn(httpserver)), + ) + + assert waiting.result + assert len(httpserver.log) == 2 + for req in httpserver.log: + envelope = Envelope.deserialize(req[0].get_data()) + if backend == "inproc": + assert_inproc_crash(envelope) + elif backend == "breakpad": + assert_breakpad_crash(envelope) + else: + assert False diff --git a/tests/test_integration_native.py b/tests/test_integration_native.py index 138564cd1..5625ebcfb 100644 --- a/tests/test_integration_native.py +++ b/tests/test_integration_native.py @@ -6,6 +6,7 @@ """ import os +import subprocess import sys import time import struct @@ -27,7 +28,7 @@ wait_for_file, assert_user_feedback, ) -from .conditions import has_native, has_oom, is_asan, is_tsan, is_qemu +from .conditions import has_native, has_oom, is_asan, is_kcov, is_tsan, is_qemu pytestmark = pytest.mark.skipif( not has_native or is_qemu, @@ -39,7 +40,7 @@ SANITIZER_ARGS = ["shutdown-timeout", "10000"] if is_asan or is_tsan else [] -def run_crash(tmp_path, exe, args, env, wait_for_daemon=False): +def run_crash(tmp_path, exe, args, env, **kwargs): """ Run a crash test. @@ -62,14 +63,14 @@ def run_crash(tmp_path, exe, args, env, wait_for_daemon=False): else: env["ASAN_OPTIONS"] = asan_signal_opts - run( - tmp_path, - exe, - args, - expect_failure=True, - env=env, - wait_for_daemon=wait_for_daemon, - ) + if is_kcov: + try: + run(tmp_path, exe, args, expect_failure=True, env=env, **kwargs) + 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, **kwargs) def test_native_capture_crash(cmake, httpserver): @@ -1024,3 +1025,27 @@ def test_native_cache_keep(cmake, cache_keep, unreachable_dsn): assert cache_files[0].stem == dmp_files[0].stem else: assert len(list(cache_dir.glob("*.envelope"))) == 0 + + +def test_native_restart_on_crash(cmake, httpserver): + tmp_path = cmake(["sentry_example"], {"SENTRY_BACKEND": "native"}) + + httpserver.expect_oneshot_request("/api/123456/envelope/").respond_with_data("OK") + httpserver.expect_oneshot_request("/api/123456/envelope/").respond_with_data("OK") + + with httpserver.wait(timeout=10) as waiting: + # The restarted child inherits stdio, so PIPE waits for it without a sleep. + run_crash( + tmp_path, + "sentry_example", + ["crash", "restart-on-crash"], + env=dict(os.environ, SENTRY_DSN=make_dsn(httpserver)), + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + + assert waiting.result + assert len(httpserver.log) == 2 + for req in httpserver.log: + envelope = Envelope.deserialize(req[0].get_data()) + assert_native_crash(envelope) From 1ecdf40c2763ce676ad70bdfece32393383eca98 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Tue, 26 May 2026 14:11:02 +0200 Subject: [PATCH 2/4] wide --- examples/example.c | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/examples/example.c b/examples/example.c index 80b4dc48d..24bb68ec3 100644 --- a/examples/example.c +++ b/examples/example.c @@ -11,6 +11,7 @@ #include #include #include +#include #ifdef NDEBUG # undef NDEBUG @@ -152,6 +153,28 @@ restart_on_crash( argc++; } +#ifdef SENTRY_PLATFORM_WINDOWS + wchar_t module_path[MAX_PATH]; + if (GetModuleFileNameW(NULL, module_path, MAX_PATH) > 0) { + int child_argc = 0; + wchar_t **child_argv = alloca((argc + 1) * sizeof(wchar_t *)); + for (int i = 0; argv[i]; i++) { + if (strcmp(argv[i], "restart-on-crash") == 0) { + continue; + } + if (i == 0) { + child_argv[child_argc++] = module_path; + } else { + size_t len = strlen(argv[i]) + 1; + child_argv[child_argc] = alloca(len * sizeof(wchar_t)); + mbstowcs(child_argv[child_argc++], argv[i], len); + } + } + child_argv[child_argc] = NULL; + + _wspawnv(_P_NOWAIT, child_argv[0], (const wchar_t *const *)child_argv); + } +#else int child_argc = 0; char **child_argv = alloca((argc + 1) * sizeof(char *)); for (int i = 0; argv[i]; i++) { @@ -161,9 +184,6 @@ restart_on_crash( } child_argv[child_argc] = NULL; -#ifdef SENTRY_PLATFORM_WINDOWS - _spawnv(_P_NOWAIT, child_argv[0], (const char *const *)child_argv); -#else if (fork() == 0) { // The crashing signal is blocked while the crash handler runs. Do not // let the restarted child inherit that mask. From 835a22196ee2216be49b46db77f9481f8f2e7cd9 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Tue, 26 May 2026 14:33:16 +0200 Subject: [PATCH 3/4] pre-process restart args --- examples/example.c | 92 +++++++++++++++++++++++++++------------------- 1 file changed, 55 insertions(+), 37 deletions(-) diff --git a/examples/example.c b/examples/example.c index 24bb68ec3..d3a083280 100644 --- a/examples/example.c +++ b/examples/example.c @@ -145,45 +145,16 @@ restart_on_crash( { (void)uctx; - int argc = 0; - char **argv = user_data; - assert(argv); - assert(argv[0]); - while (argv[argc]) { - argc++; - } - #ifdef SENTRY_PLATFORM_WINDOWS - wchar_t module_path[MAX_PATH]; - if (GetModuleFileNameW(NULL, module_path, MAX_PATH) > 0) { - int child_argc = 0; - wchar_t **child_argv = alloca((argc + 1) * sizeof(wchar_t *)); - for (int i = 0; argv[i]; i++) { - if (strcmp(argv[i], "restart-on-crash") == 0) { - continue; - } - if (i == 0) { - child_argv[child_argc++] = module_path; - } else { - size_t len = strlen(argv[i]) + 1; - child_argv[child_argc] = alloca(len * sizeof(wchar_t)); - mbstowcs(child_argv[child_argc++], argv[i], len); - } - } - child_argv[child_argc] = NULL; - - _wspawnv(_P_NOWAIT, child_argv[0], (const wchar_t *const *)child_argv); + wchar_t **argv = user_data; + if (argv && argv[0]) { + _wspawnv(_P_NOWAIT, argv[0], (const wchar_t *const *)argv); } #else - int child_argc = 0; - char **child_argv = alloca((argc + 1) * sizeof(char *)); - for (int i = 0; argv[i]; i++) { - if (strcmp(argv[i], "restart-on-crash") != 0) { - child_argv[child_argc++] = argv[i]; - } + char **argv = user_data; + if (!argv || !argv[0]) { + return event; } - child_argv[child_argc] = NULL; - if (fork() == 0) { // The crashing signal is blocked while the crash handler runs. Do not // let the restarted child inherit that mask. @@ -191,7 +162,7 @@ restart_on_crash( sigfillset(&set); sigprocmask(SIG_UNBLOCK, &set, NULL); - execv(child_argv[0], child_argv); + execv(argv[0], argv); _exit(127); } #endif @@ -199,6 +170,52 @@ restart_on_crash( return event; } +// Forward all original arguments except "restart-on-crash" +static void * +restart_args(int argc, char **argv) +{ +#ifdef SENTRY_PLATFORM_WINDOWS + wchar_t **child_argv = calloc((size_t)argc + 1, sizeof(wchar_t *)); + if (!child_argv) { + return NULL; + } + + child_argv[0] = calloc(MAX_PATH, sizeof(wchar_t)); + if (!child_argv[0] + || GetModuleFileNameW(NULL, child_argv[0], MAX_PATH) == 0) { + return child_argv; + } + + int child_argc = 1; + for (int i = 1; i < argc; i++) { + if (strcmp(argv[i], "restart-on-crash") == 0) { + continue; + } + + size_t len = strlen(argv[i]) + 1; + child_argv[child_argc] = calloc(len, sizeof(wchar_t)); + if (!child_argv[child_argc]) { + return child_argv; + } + mbstowcs(child_argv[child_argc++], argv[i], len); + } + return child_argv; +#else + char **child_argv = calloc((size_t)argc + 1, sizeof(char *)); + if (!child_argv) { + return NULL; + } + + int child_argc = 0; + for (int i = 0; i < argc; i++) { + if (strcmp(argv[i], "restart-on-crash") != 0) { + child_argv[child_argc++] = argv[i]; + } + } + return child_argv; +#endif +} + static sentry_value_t before_transaction_callback(sentry_value_t tx, void *user_data) { @@ -700,7 +717,8 @@ main(int argc, char **argv) } if (has_arg(argc, argv, "restart-on-crash")) { - sentry_options_set_on_crash(options, restart_on_crash, argv); + sentry_options_set_on_crash( + options, restart_on_crash, restart_args(argc, argv)); } if (has_arg(argc, argv, "before-transaction")) { From 355af095d0483d941859bea8435685e5b957f58e Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Wed, 27 May 2026 16:48:18 +0200 Subject: [PATCH 4/4] fix up rebase --- tests/test_integration_native.py | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/tests/test_integration_native.py b/tests/test_integration_native.py index 5625ebcfb..7a5075c77 100644 --- a/tests/test_integration_native.py +++ b/tests/test_integration_native.py @@ -28,7 +28,7 @@ wait_for_file, assert_user_feedback, ) -from .conditions import has_native, has_oom, is_asan, is_kcov, is_tsan, is_qemu +from .conditions import has_native, has_oom, is_asan, is_tsan, is_qemu pytestmark = pytest.mark.skipif( not has_native or is_qemu, @@ -63,14 +63,7 @@ def run_crash(tmp_path, exe, args, env, **kwargs): else: env["ASAN_OPTIONS"] = asan_signal_opts - if is_kcov: - try: - run(tmp_path, exe, args, expect_failure=True, env=env, **kwargs) - 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, **kwargs) + run(tmp_path, exe, args, expect_failure=True, env=env, **kwargs) def test_native_capture_crash(cmake, httpserver):