From f0527d04e5277103835d73508e7280f16b150f79 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Wed, 11 Mar 2026 13:24:49 +0100 Subject: [PATCH 01/14] fix: skip SA_NODEFER when CHAIN_AT_START is active SA_NODEFER (added in #1446) is incompatible with the CHAIN_AT_START signal handler strategy. When chaining to the runtime's signal handler (e.g. Mono), the runtime may reset the signal to SIG_DFL and re-raise. With SA_NODEFER the re-raised signal is delivered immediately, killing the process before our handler can regain control. Without SA_NODEFER, the re-raised signal is blocked during handler execution, allowing the runtime handler to return and sentry-native to proceed with crash capture. Co-Authored-By: Claude Opus 4.6 --- src/backends/sentry_backend_inproc.c | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/backends/sentry_backend_inproc.c b/src/backends/sentry_backend_inproc.c index 53aa0af54..1b13ad7bd 100644 --- a/src/backends/sentry_backend_inproc.c +++ b/src/backends/sentry_backend_inproc.c @@ -507,7 +507,16 @@ startup_inproc_backend( // running. This is needed for recursive crash detection to work - // without it, a crash during crash handling would block the signal // and leave the process in an undefined state. - g_sigaction.sa_flags = SA_SIGINFO | SA_ONSTACK | SA_NODEFER; + // However, SA_NODEFER is incompatible with CHAIN_AT_START: when we + // chain to the runtime's signal handler (e.g. Mono), it may reset + // the signal to SIG_DFL and re-raise. With SA_NODEFER the re-raised + // signal is delivered immediately (killing the process) before our + // handler can regain control. + g_sigaction.sa_flags = SA_SIGINFO | SA_ONSTACK; + if (g_backend_config.handler_strategy + != SENTRY_HANDLER_STRATEGY_CHAIN_AT_START) { + g_sigaction.sa_flags |= SA_NODEFER; + } for (size_t i = 0; i < SIGNAL_COUNT; ++i) { sigaction(SIGNAL_DEFINITIONS[i].signum, &g_sigaction, NULL); } From dd80f30445d7edc33900f9af26a4632ff255ab53 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Wed, 11 Mar 2026 13:39:30 +0100 Subject: [PATCH 02/14] Update CHANGELOG.md --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a57e0d348..855c31892 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,11 @@ # Changelog -## Unreleased: +## Unreleased **Fixes**: - inproc: only the handling thread cleans up after the crash. ([#1579](https://github.com/getsentry/sentry-native/pull/1579)) +- Skip `SA_NODEFER` when the `CHAIN_AT_START` handler strategy is used. The flag causes the runtime's re-raised signal to be delivered immediately, killing the process before `inproc` can capture the crash. ([#1572](https://github.com/getsentry/sentry-native/pull/1572)) ## 0.13.2 From 7a1c50eada2cf4c512300512dc5662f471e8aa57 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Wed, 11 Mar 2026 14:27:51 +0100 Subject: [PATCH 03/14] Revert "fix: skip SA_NODEFER when CHAIN_AT_START is active" This reverts commit 91afd1a751bd8cfa89469c2a4d0bf37d9ce6baa8. --- src/backends/sentry_backend_inproc.c | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/src/backends/sentry_backend_inproc.c b/src/backends/sentry_backend_inproc.c index 1b13ad7bd..53aa0af54 100644 --- a/src/backends/sentry_backend_inproc.c +++ b/src/backends/sentry_backend_inproc.c @@ -507,16 +507,7 @@ startup_inproc_backend( // running. This is needed for recursive crash detection to work - // without it, a crash during crash handling would block the signal // and leave the process in an undefined state. - // However, SA_NODEFER is incompatible with CHAIN_AT_START: when we - // chain to the runtime's signal handler (e.g. Mono), it may reset - // the signal to SIG_DFL and re-raise. With SA_NODEFER the re-raised - // signal is delivered immediately (killing the process) before our - // handler can regain control. - g_sigaction.sa_flags = SA_SIGINFO | SA_ONSTACK; - if (g_backend_config.handler_strategy - != SENTRY_HANDLER_STRATEGY_CHAIN_AT_START) { - g_sigaction.sa_flags |= SA_NODEFER; - } + g_sigaction.sa_flags = SA_SIGINFO | SA_ONSTACK | SA_NODEFER; for (size_t i = 0; i < SIGNAL_COUNT; ++i) { sigaction(SIGNAL_DEFINITIONS[i].signum, &g_sigaction, NULL); } From 52c253de8580c3280518e92e3a61e7015ff48d86 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Wed, 11 Mar 2026 17:28:01 +0100 Subject: [PATCH 04/14] fix: mask signal during CHAIN_AT_START to prevent re-raise from killing process With SA_NODEFER, the chained handler's re-raise is delivered immediately and kills the process before we regain control. Mask the signal via raw rt_sigprocmask (to bypass Android's libsigchain), then after the chain: reinstall our handler if it was reset to SIG_DFL, consume any pending signal with sigtimedwait, and unmask. Co-Authored-By: Claude Opus 4.6 --- src/backends/sentry_backend_inproc.c | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/backends/sentry_backend_inproc.c b/src/backends/sentry_backend_inproc.c index 53aa0af54..57d5bd782 100644 --- a/src/backends/sentry_backend_inproc.c +++ b/src/backends/sentry_backend_inproc.c @@ -25,6 +25,7 @@ #include #ifdef SENTRY_PLATFORM_UNIX # include +# include #endif #include @@ -1564,6 +1565,15 @@ process_ucontext(const sentry_ucontext_t *uctx) uintptr_t ip = get_instruction_pointer(uctx); uintptr_t sp = get_stack_pointer(uctx); + // Mask the signal so SA_NODEFER doesn't let re-raises from the chained + // handler to kill the process before we regain control. + sigset_t mask, old_mask; + sigemptyset(&mask); + sigaddset(&mask, uctx->signum); + // raw syscall to bypass libsigchain on Android + syscall( + SYS_rt_sigprocmask, SIG_BLOCK, &mask, &old_mask, sizeof(sigset_t)); + // invoke the previous handler (typically the CLR/Mono // signal-to-managed-exception handler) invoke_signal_handler( @@ -1579,6 +1589,21 @@ process_ucontext(const sentry_ucontext_t *uctx) return; } + // restore our handler + struct sigaction current; + sigaction(uctx->signum, NULL, ¤t); + if (current.sa_handler == SIG_DFL) { + sigaction(uctx->signum, &g_sigaction, NULL); + } + + // consume pending signal + struct timespec timeout = { 0, 0 }; + sigtimedwait(&mask, NULL, &timeout); + + // unmask + syscall( + SYS_rt_sigprocmask, SIG_SETMASK, &old_mask, NULL, sizeof(sigset_t)); + // return from runtime handler; continue processing the crash on the // signal thread until the worker takes over } From b84f76d757904d788c14186e3e0f094a11bd0727 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Wed, 11 Mar 2026 17:41:13 +0100 Subject: [PATCH 05/14] Update CHANGELOG.md --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 855c31892..1e2b6d03c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ **Fixes**: - inproc: only the handling thread cleans up after the crash. ([#1579](https://github.com/getsentry/sentry-native/pull/1579)) -- Skip `SA_NODEFER` when the `CHAIN_AT_START` handler strategy is used. The flag causes the runtime's re-raised signal to be delivered immediately, killing the process before `inproc` can capture the crash. ([#1572](https://github.com/getsentry/sentry-native/pull/1572)) +- Fix `CHAIN_AT_START` handler strategy crashing when the chained handler resets the signal handler and re-raises. ([#1572](https://github.com/getsentry/sentry-native/pull/1572)) ## 0.13.2 From 3c34734e9a4f3e1e52c8ba8f062a774fb3a974da Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Wed, 11 Mar 2026 18:08:03 +0100 Subject: [PATCH 06/14] fix: use raw syscall for sigtimedwait and correct sigset size sigtimedwait is not declared without _POSIX_C_SOURCE >= 199309L, so use the raw SYS_rt_sigtimedwait syscall instead. Also replace sizeof(sigset_t) with _NSIG/8 since the kernel expects 8 bytes, not glibc's 128. Co-Authored-By: Claude Opus 4.6 --- src/backends/sentry_backend_inproc.c | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/backends/sentry_backend_inproc.c b/src/backends/sentry_backend_inproc.c index 57d5bd782..791352596 100644 --- a/src/backends/sentry_backend_inproc.c +++ b/src/backends/sentry_backend_inproc.c @@ -1571,8 +1571,7 @@ process_ucontext(const sentry_ucontext_t *uctx) sigemptyset(&mask); sigaddset(&mask, uctx->signum); // raw syscall to bypass libsigchain on Android - syscall( - SYS_rt_sigprocmask, SIG_BLOCK, &mask, &old_mask, sizeof(sigset_t)); + syscall(SYS_rt_sigprocmask, SIG_BLOCK, &mask, &old_mask, _NSIG / 8); // invoke the previous handler (typically the CLR/Mono // signal-to-managed-exception handler) @@ -1598,11 +1597,10 @@ process_ucontext(const sentry_ucontext_t *uctx) // consume pending signal struct timespec timeout = { 0, 0 }; - sigtimedwait(&mask, NULL, &timeout); + syscall(SYS_rt_sigtimedwait, &mask, NULL, &timeout, _NSIG / 8); // unmask - syscall( - SYS_rt_sigprocmask, SIG_SETMASK, &old_mask, NULL, sizeof(sigset_t)); + syscall(SYS_rt_sigprocmask, SIG_SETMASK, &old_mask, NULL, _NSIG / 8); // return from runtime handler; continue processing the crash on the // signal thread until the worker takes over From 7edd97c54053fb074506406850bf66c5deffd8f4 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Thu, 12 Mar 2026 09:35:31 +0100 Subject: [PATCH 07/14] fix: gate raw syscalls to Android, use sigprocmask elsewhere libsigchain's sigprocmask guard is only active inside its own special handlers, so our signal handler still gets filtered. Keep the raw syscall on Android and use the standard sigprocmask on other platforms. Co-Authored-By: Claude Opus 4.6 --- src/backends/sentry_backend_inproc.c | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/backends/sentry_backend_inproc.c b/src/backends/sentry_backend_inproc.c index 791352596..348dafe59 100644 --- a/src/backends/sentry_backend_inproc.c +++ b/src/backends/sentry_backend_inproc.c @@ -1566,12 +1566,17 @@ process_ucontext(const sentry_ucontext_t *uctx) uintptr_t sp = get_stack_pointer(uctx); // Mask the signal so SA_NODEFER doesn't let re-raises from the chained - // handler to kill the process before we regain control. + // handler kill the process before we regain control. sigset_t mask, old_mask; sigemptyset(&mask); sigaddset(&mask, uctx->signum); - // raw syscall to bypass libsigchain on Android +# ifdef SENTRY_PLATFORM_ANDROID + // Raw syscall to bypass libsigchain, whose sigprocmask guard + // is only active inside its own special handlers. syscall(SYS_rt_sigprocmask, SIG_BLOCK, &mask, &old_mask, _NSIG / 8); +# else + sigprocmask(SIG_BLOCK, &mask, &old_mask); +# endif // invoke the previous handler (typically the CLR/Mono // signal-to-managed-exception handler) @@ -1600,7 +1605,11 @@ process_ucontext(const sentry_ucontext_t *uctx) syscall(SYS_rt_sigtimedwait, &mask, NULL, &timeout, _NSIG / 8); // unmask +# ifdef SENTRY_PLATFORM_ANDROID syscall(SYS_rt_sigprocmask, SIG_SETMASK, &old_mask, NULL, _NSIG / 8); +# else + sigprocmask(SIG_SETMASK, &old_mask, NULL); +# endif // return from runtime handler; continue processing the crash on the // signal thread until the worker takes over From 54ac0b7d6327702a1d3455e9aff2a6a8a8a23611 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Thu, 12 Mar 2026 10:04:25 +0100 Subject: [PATCH 08/14] fix: gate signal masking to Android where Mono resets handler and re-raises Co-Authored-By: Claude Opus 4.6 --- src/backends/sentry_backend_inproc.c | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/backends/sentry_backend_inproc.c b/src/backends/sentry_backend_inproc.c index 348dafe59..162ea6985 100644 --- a/src/backends/sentry_backend_inproc.c +++ b/src/backends/sentry_backend_inproc.c @@ -25,6 +25,8 @@ #include #ifdef SENTRY_PLATFORM_UNIX # include +#endif +#ifdef SENTRY_PLATFORM_ANDROID # include #endif #include @@ -1565,17 +1567,15 @@ process_ucontext(const sentry_ucontext_t *uctx) uintptr_t ip = get_instruction_pointer(uctx); uintptr_t sp = get_stack_pointer(uctx); +# ifdef SENTRY_PLATFORM_ANDROID // Mask the signal so SA_NODEFER doesn't let re-raises from the chained // handler kill the process before we regain control. sigset_t mask, old_mask; sigemptyset(&mask); sigaddset(&mask, uctx->signum); -# ifdef SENTRY_PLATFORM_ANDROID // Raw syscall to bypass libsigchain, whose sigprocmask guard // is only active inside its own special handlers. syscall(SYS_rt_sigprocmask, SIG_BLOCK, &mask, &old_mask, _NSIG / 8); -# else - sigprocmask(SIG_BLOCK, &mask, &old_mask); # endif // invoke the previous handler (typically the CLR/Mono @@ -1593,6 +1593,7 @@ process_ucontext(const sentry_ucontext_t *uctx) return; } +# ifdef SENTRY_PLATFORM_ANDROID // restore our handler struct sigaction current; sigaction(uctx->signum, NULL, ¤t); @@ -1605,10 +1606,7 @@ process_ucontext(const sentry_ucontext_t *uctx) syscall(SYS_rt_sigtimedwait, &mask, NULL, &timeout, _NSIG / 8); // unmask -# ifdef SENTRY_PLATFORM_ANDROID syscall(SYS_rt_sigprocmask, SIG_SETMASK, &old_mask, NULL, _NSIG / 8); -# else - sigprocmask(SIG_SETMASK, &old_mask, NULL); # endif // return from runtime handler; continue processing the crash on the From 3011de07629c6d049743bb6f4f8f93a58cf3d8cc Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Thu, 12 Mar 2026 10:10:34 +0100 Subject: [PATCH 09/14] Update CHANGELOG.md --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e2b6d03c..880dedccd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ **Fixes**: - inproc: only the handling thread cleans up after the crash. ([#1579](https://github.com/getsentry/sentry-native/pull/1579)) -- Fix `CHAIN_AT_START` handler strategy crashing when the chained handler resets the signal handler and re-raises. ([#1572](https://github.com/getsentry/sentry-native/pull/1572)) +- Fix `CHAIN_AT_START` handler strategy crashing on Android when the chained Mono handler resets the signal handler and re-raises. ([#1572](https://github.com/getsentry/sentry-native/pull/1572)) ## 0.13.2 From 1cb5d46b35e793b60af94b517845482bcc5c3268 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Thu, 12 Mar 2026 10:15:48 +0100 Subject: [PATCH 10/14] Use sizeof(sigset_t) instead of _NSIG/8 for raw syscalls --- src/backends/sentry_backend_inproc.c | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/backends/sentry_backend_inproc.c b/src/backends/sentry_backend_inproc.c index 162ea6985..cfd121141 100644 --- a/src/backends/sentry_backend_inproc.c +++ b/src/backends/sentry_backend_inproc.c @@ -1575,7 +1575,8 @@ process_ucontext(const sentry_ucontext_t *uctx) sigaddset(&mask, uctx->signum); // Raw syscall to bypass libsigchain, whose sigprocmask guard // is only active inside its own special handlers. - syscall(SYS_rt_sigprocmask, SIG_BLOCK, &mask, &old_mask, _NSIG / 8); + syscall( + SYS_rt_sigprocmask, SIG_BLOCK, &mask, &old_mask, sizeof(sigset_t)); # endif // invoke the previous handler (typically the CLR/Mono @@ -1603,10 +1604,11 @@ process_ucontext(const sentry_ucontext_t *uctx) // consume pending signal struct timespec timeout = { 0, 0 }; - syscall(SYS_rt_sigtimedwait, &mask, NULL, &timeout, _NSIG / 8); + syscall(SYS_rt_sigtimedwait, &mask, NULL, &timeout, sizeof(sigset_t)); // unmask - syscall(SYS_rt_sigprocmask, SIG_SETMASK, &old_mask, NULL, _NSIG / 8); + syscall( + SYS_rt_sigprocmask, SIG_SETMASK, &old_mask, NULL, sizeof(sigset_t)); # endif // return from runtime handler; continue processing the crash on the From c966d8e7af14949da86a696daabfbd427e5a4b63 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Fri, 13 Mar 2026 21:20:03 +0100 Subject: [PATCH 11/14] test: add Android emulator test for dotnet signal handling (#1574) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test: add Android emulator test for dotnet signal handling Extends the existing dotnet_signal fixture to build as an Android APK and run on an emulator. The test verifies CHAIN_AT_START signal handling with Mono: - Handled managed exception (NRE): no native crash registered - Unhandled managed exception: Mono aborts, native crash registered - Native crash (SIGSEGV in libcrash.so): crash envelope produced The fixture csproj now multi-targets net10.0 and net10.0-android. Program.cs exposes RunTest() for the Android MainActivity entry point. Database state is checked directly on-device via adb run-as. Co-Authored-By: Claude Opus 4.6 * fix: wrap skipif conditions in bool() to avoid pytest string evaluation CI sets TEST_X86/ANDROID_API/RUN_ANALYZER to empty strings. Python's `or` chain returns the last falsy value (empty string ""), which pytest then tries to compile() as a legacy condition expression, causing SyntaxError. Co-Authored-By: Claude Opus 4.6 * ci: install .NET SDK and Android workload for Android CI jobs Co-Authored-By: Claude Opus 4.6 * fix: only target net10.0-android when ANDROID_HOME is set Desktop CI runners have .NET 10 but no android workload. MSBuild validates all TFMs even when building for a specific one, causing NETSDK1147. Conditionally add the android TFM only when ANDROID_HOME is present. Co-Authored-By: Claude Opus 4.6 * fix: use ANDROID_API instead of ANDROID_HOME for android TFM condition Co-Authored-By: Claude Opus 4.6 * test: print logcat output for Android test diagnostics Co-Authored-By: Claude Opus 4.6 * fix: use sh -c for run-as commands on Android run-as can't find the `test` binary on older API levels. Use sh -c with a single quoted command string so shell builtins are available. Co-Authored-By: Claude Opus 4.6 * test: include stdout/stderr in dotnet run returncode assertions Co-Authored-By: Claude Opus 4.6 * fix: use am start -W to avoid race in Android test am start is asynchronous — it returns before the process starts. The pidof poll could find no process yet and break immediately, causing assertions to run before sentry_init, and the finally block's adb uninstall to kill the app mid-startup (deletePackageX). Co-Authored-By: Claude Opus 4.6 * fix: exclude Android sources from desktop dotnet build Microsoft.NET.Sdk (plain) compiles all .cs files regardless of directory conventions. Exclude Platforms/Android/ when not building for an Android target framework. Co-Authored-By: Claude Opus 4.6 * try arm64-v8a * try x86_64 on linux * Test 22-30 * fix: move test logic to OnResume to avoid am start -W hang OnCreate fires before the activity reports launch completion, so if the test crashes the app, am start -W blocks indefinitely on older API levels. Co-Authored-By: Claude Opus 4.6 * ci: use default emulator target instead of google_apis Co-Authored-By: Claude Opus 4.6 * fix: replace sleeps with retry loops in Android test Use wait_for() with polling instead of fixed sleeps to handle timing variations across emulator API levels. Co-Authored-By: Claude Opus 4.6 * fix: add timeout to am start -W with logcat dump on failure Co-Authored-By: Claude Opus 4.6 * fix: run test directly on UI thread instead of background thread Co-Authored-By: Claude Opus 4.6 * fix: use DecorView.Post + worker thread for Android test Post ensures OnResume returns before the test runs (so am start -W completes). Worker thread avoids Android's main thread uncaught exception handler killing the process with SIGKILL. Co-Authored-By: Claude Opus 4.6 * fix: simplify Android test app and handle am start -W timeout Move test back to OnResume with worker thread (no DecorView.Post). Silently handle am start -W timeout since older API levels may not report activity launch completion before the app exits. Co-Authored-By: Claude Opus 4.6 * fix: emulate MAUI abort behavior for unhandled exceptions on Android Run the test on the main thread via Handler.Post and catch unhandled managed exceptions with try-catch + abort(), matching how MAUI handles them. This ensures sentry-native's signal handler captures the crash across all Android API levels. Co-Authored-By: Claude Opus 4.6 * fix: expect no crash for unhandled managed exceptions on Android Unhandled managed exceptions on Android go through Mono's exit(1), not a catchable signal. sentry-dotnet handles these at the managed layer via UnhandledExceptionRaiser, so sentry-native should not register a crash. Remove the abort() workaround and align expectations with the desktop test. Co-Authored-By: Claude Opus 4.6 * ci: drop broken Android API 22 job Co-Authored-By: Claude Opus 4.6 * ci: switch Android emulator to google_apis, drop API 27 Switch emulator target from default to google_apis to fix stuck emulator boots on API 23-25. Drop API 27 which has no google_apis x86_64 system image. Co-Authored-By: Claude Opus 4.6 * test: skip Android dotnet signal test on API < 26 Pre-tombstoned Android (API < 26) uses debuggerd which kills the process before sentry-native's signal handler can run when using CHAIN_AT_START strategy. Co-Authored-By: Claude Opus 4.6 * Try macos-15-large again, drop others but 26 * test: clean up dotnet signal test assertions and comments Co-Authored-By: Claude Opus 4.6 * ci: switch Android emulator back to default target Co-Authored-By: Claude Opus 4.6 * fix: use double quotes in run-as shell wrapper to preserve inner quotes The find command uses single-quoted '*.envelope' glob pattern which broke when wrapped in single-quoted sh -c. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- .github/workflows/ci.yml | 33 ++- .../dotnet_signal/Directory.Build.props | 2 + .../Platforms/Android/MainActivity.cs | 31 +++ tests/fixtures/dotnet_signal/Program.cs | 25 ++- .../fixtures/dotnet_signal/test_dotnet.csproj | 17 +- tests/test_build_static.py | 4 +- tests/test_dotnet_signals.py | 206 +++++++++++++++++- 7 files changed, 297 insertions(+), 21 deletions(-) create mode 100644 tests/fixtures/dotnet_signal/Directory.Build.props create mode 100644 tests/fixtures/dotnet_signal/Platforms/Android/MainActivity.cs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a00ad83c2..7500f6c94 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -191,12 +191,17 @@ jobs: MINGW_ASM_MASM_COMPILER: llvm-ml MINGW_ASM_MASM_FLAGS: -m64 - name: Android (API 21, NDK 23) - os: macos-15-large + os: ubuntu-latest ANDROID_API: 21 ANDROID_NDK: 23.2.8568313 ANDROID_ARCH: x86_64 + - name: Android (API 26, NDK 27) + os: ubuntu-latest + ANDROID_API: 26 + ANDROID_NDK: 27.3.13750724 + ANDROID_ARCH: x86_64 - name: Android (API 31, NDK 27) - os: macos-15-large + os: ubuntu-latest ANDROID_API: 31 ANDROID_NDK: 27.3.13750724 ANDROID_ARCH: x86_64 @@ -242,12 +247,12 @@ jobs: cache: "pip" - name: Check Linux CC/CXX - if: ${{ runner.os == 'Linux' && !matrix.container }} + if: ${{ runner.os == 'Linux' && !env['ANDROID_API'] &&!matrix.container }} run: | [ -n "$CC" ] && [ -n "$CXX" ] || { echo "Ubuntu runner configurations require toolchain selection via CC and CXX" >&2; exit 1; } - name: Installing Linux Dependencies - if: ${{ runner.os == 'Linux' && !env['TEST_X86'] && !matrix.container }} + if: ${{ runner.os == 'Linux' && !env['TEST_X86'] && !env['ANDROID_API'] && !matrix.container }} run: | sudo apt update # Install common dependencies @@ -278,7 +283,7 @@ jobs: sudo make install - name: Installing Linux 32-bit Dependencies - if: ${{ runner.os == 'Linux' && env['TEST_X86'] && !matrix.container }} + if: ${{ runner.os == 'Linux' && env['TEST_X86'] && !env['ANDROID_API'] &&!matrix.container }} run: | sudo dpkg --add-architecture i386 sudo apt update @@ -357,6 +362,22 @@ jobs: with: gradle-home-cache-cleanup: true + - name: Setup .NET for Android + if: ${{ env['ANDROID_API'] }} + uses: actions/setup-dotnet@v5 + with: + dotnet-version: '10.0.x' + + - name: Install .NET Android workload + if: ${{ env['ANDROID_API'] }} + run: dotnet workload restore tests/fixtures/dotnet_signal/test_dotnet.csproj + + - name: Enable KVM group perms + if: ${{ runner.os == 'Linux' && env['ANDROID_API'] }} + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm - name: Add sentry.native.test hostname if: ${{ runner.os == 'Windows' }} @@ -386,7 +407,7 @@ jobs: api-level: ${{ env.ANDROID_API }} ndk: ${{ env.ANDROID_NDK }} arch: ${{ env.ANDROID_ARCH }} - target: google_apis + target: default emulator-boot-timeout: 1200 script: | # Sync emulator clock with host to avoid timestamp assertion failures diff --git a/tests/fixtures/dotnet_signal/Directory.Build.props b/tests/fixtures/dotnet_signal/Directory.Build.props new file mode 100644 index 000000000..cac7f5ab0 --- /dev/null +++ b/tests/fixtures/dotnet_signal/Directory.Build.props @@ -0,0 +1,2 @@ + + diff --git a/tests/fixtures/dotnet_signal/Platforms/Android/MainActivity.cs b/tests/fixtures/dotnet_signal/Platforms/Android/MainActivity.cs new file mode 100644 index 000000000..a6b35ceba --- /dev/null +++ b/tests/fixtures/dotnet_signal/Platforms/Android/MainActivity.cs @@ -0,0 +1,31 @@ +using Android.App; +using Android.OS; + +// Required for "adb shell run-as" to access the app's data directory in Release builds +[assembly: Application(Debuggable = true)] + +namespace dotnet_signal; + +[Activity(Name = "dotnet_signal.MainActivity", MainLauncher = true)] +public class MainActivity : Activity +{ + protected override void OnResume() + { + base.OnResume(); + + var arg = Intent?.GetStringExtra("arg"); + if (!string.IsNullOrEmpty(arg)) + { + var databasePath = FilesDir?.AbsolutePath + "/.sentry-native"; + + // Post to the message queue so the activity finishes starting + // before the crash test runs. Without this, "am start -W" may hang. + new Handler(Looper.MainLooper!).Post(() => + { + Program.RunTest(new[] { arg }, databasePath); + FinishAndRemoveTask(); + Java.Lang.JavaSystem.Exit(0); + }); + } + } +} diff --git a/tests/fixtures/dotnet_signal/Program.cs b/tests/fixtures/dotnet_signal/Program.cs index 4e6217ac3..c21e10ef7 100644 --- a/tests/fixtures/dotnet_signal/Program.cs +++ b/tests/fixtures/dotnet_signal/Program.cs @@ -20,10 +20,13 @@ class Program [DllImport("sentry", EntryPoint = "sentry_options_set_debug")] static extern IntPtr sentry_options_set_debug(IntPtr options, int debug); + [DllImport("sentry", EntryPoint = "sentry_options_set_database_path")] + static extern void sentry_options_set_database_path(IntPtr options, string path); + [DllImport("sentry", EntryPoint = "sentry_init")] static extern int sentry_init(IntPtr options); - static void Main(string[] args) + public static void RunTest(string[] args, string? databasePath = null) { var githubActions = Environment.GetEnvironmentVariable("GITHUB_ACTIONS") ?? string.Empty; if (githubActions == "true") { @@ -38,10 +41,13 @@ static void Main(string[] args) var options = sentry_options_new(); sentry_options_set_handler_strategy(options, 1); sentry_options_set_debug(options, 1); + if (databasePath != null) + { + sentry_options_set_database_path(options, databasePath); + } sentry_init(options); - var doNativeCrash = args is ["native-crash"]; - if (doNativeCrash) + if (args.Contains("native-crash")) { native_crash(); } @@ -51,9 +57,9 @@ static void Main(string[] args) { Console.WriteLine("dereference a NULL object from managed code"); var s = default(string); - var c = s.Length; + var c = s!.Length; } - catch (NullReferenceException exception) + catch (NullReferenceException) { } } @@ -61,7 +67,14 @@ static void Main(string[] args) { Console.WriteLine("dereference a NULL object from managed code (unhandled)"); var s = default(string); - var c = s.Length; + var c = s!.Length; } } + +#if !ANDROID + static void Main(string[] args) + { + RunTest(args); + } +#endif } \ No newline at end of file diff --git a/tests/fixtures/dotnet_signal/test_dotnet.csproj b/tests/fixtures/dotnet_signal/test_dotnet.csproj index 238f157e2..e266400d0 100644 --- a/tests/fixtures/dotnet_signal/test_dotnet.csproj +++ b/tests/fixtures/dotnet_signal/test_dotnet.csproj @@ -1,8 +1,23 @@ Exe - net10.0 + net10.0 + $(TargetFrameworks);net10.0-android enable enable + + + io.sentry.ndk.dotnet.signal.test + 21 + true + + + + + + + + + diff --git a/tests/test_build_static.py b/tests/test_build_static.py index 36d502c95..6dab8ca50 100644 --- a/tests/test_build_static.py +++ b/tests/test_build_static.py @@ -2,7 +2,7 @@ import sys import os import pytest -from .conditions import has_breakpad, has_crashpad, has_native +from .conditions import has_breakpad, has_crashpad, has_native, is_android def test_static_lib(cmake): @@ -16,7 +16,7 @@ def test_static_lib(cmake): ) # on linux we can use `ldd` to check that we don’t link to `libsentry.so` - if sys.platform == "linux": + if sys.platform == "linux" and not is_android: output = subprocess.check_output("ldd sentry_example", cwd=tmp_path, shell=True) assert b"libsentry.so" not in output diff --git a/tests/test_dotnet_signals.py b/tests/test_dotnet_signals.py index 7c4e2a70d..b4bf6544d 100644 --- a/tests/test_dotnet_signals.py +++ b/tests/test_dotnet_signals.py @@ -3,10 +3,11 @@ import shutil import subprocess import sys +import time import pytest -from tests.conditions import is_tsan, is_x86, is_asan +from tests.conditions import is_android, is_tsan, is_x86, is_asan project_fixture_path = pathlib.Path("tests/fixtures/dotnet_signal") @@ -49,19 +50,23 @@ def run_dotnet(tmp_path, args): def run_dotnet_managed_exception(tmp_path): - return run_dotnet(tmp_path, ["dotnet", "run", "managed-exception"]) + return run_dotnet( + tmp_path, ["dotnet", "run", "-f:net10.0", "--", "managed-exception"] + ) def run_dotnet_unhandled_managed_exception(tmp_path): - return run_dotnet(tmp_path, ["dotnet", "run", "unhandled-managed-exception"]) + return run_dotnet( + tmp_path, ["dotnet", "run", "-f:net10.0", "--", "unhandled-managed-exception"] + ) def run_dotnet_native_crash(tmp_path): - return run_dotnet(tmp_path, ["dotnet", "run", "native-crash"]) + return run_dotnet(tmp_path, ["dotnet", "run", "-f:net10.0", "--", "native-crash"]) @pytest.mark.skipif( - sys.platform != "linux" or is_x86 or is_asan or is_tsan, + bool(sys.platform != "linux" or is_x86 or is_asan or is_tsan or is_android), reason="dotnet signal handling is currently only supported on 64-bit Linux without sanitizers", ) def test_dotnet_signals_inproc(cmake): @@ -165,7 +170,7 @@ def run_aot_native_crash(tmp_path): @pytest.mark.skipif( - sys.platform != "linux" or is_x86 or is_asan or is_tsan, + bool(sys.platform != "linux" or is_x86 or is_asan or is_tsan or is_android), reason="dotnet AOT signal handling is currently only supported on 64-bit Linux without sanitizers", ) def test_aot_signals_inproc(cmake): @@ -199,6 +204,7 @@ def test_aot_signals_inproc(cmake): [ "dotnet", "publish", + "-f:net10.0", "-p:PublishAot=true", "-p:Configuration=Release", "-o", @@ -255,3 +261,191 @@ def test_aot_signals_inproc(cmake): shutil.rmtree(tmp_path / ".sentry-native", ignore_errors=True) shutil.rmtree(project_fixture_path / "bin", ignore_errors=True) shutil.rmtree(project_fixture_path / "obj", ignore_errors=True) + + +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 adb(*args, **kwargs): + adb_path = "{}/platform-tools/adb".format(os.environ["ANDROID_HOME"]) + return subprocess.run([adb_path, *args], **kwargs) + + +def run_android(args=None, timeout=30): + if args is None: + args = [] + adb("logcat", "-c") + adb("shell", "pm", "clear", ANDROID_PACKAGE) + intent_args = [] + for arg in args: + intent_args += ["--es", "arg", arg] + try: + adb( + "shell", + "am", + "start", + "-W", + "-n", + "{}/dotnet_signal.MainActivity".format(ANDROID_PACKAGE), + *intent_args, + check=True, + timeout=10, + ) + except subprocess.TimeoutExpired: + pass + wait_for( + lambda: adb( + "shell", "pidof", ANDROID_PACKAGE, capture_output=True, text=True + ).returncode + != 0, + timeout=timeout, + ) + return adb("logcat", "-d", capture_output=True, text=True).stdout + + +def run_android_managed_exception(): + return run_android(["managed-exception"]) + + +def run_android_unhandled_managed_exception(): + return run_android(["unhandled-managed-exception"]) + + +def run_android_native_crash(): + return run_android(["native-crash"]) + + +@pytest.mark.skipif( + not is_android or int(is_android) < 26, + reason="needs Android API 26+ (tombstoned)", +) +def test_android_signals_inproc(cmake): + if shutil.which("dotnet") is None: + pytest.skip("dotnet is not installed") + + arch = os.environ.get("ANDROID_ARCH", "x86_64") + rid_map = { + "x86_64": "android-x64", + "x86": "android-x86", + "arm64-v8a": "android-arm64", + "armeabi-v7a": "android-arm", + } + + try: + tmp_path = cmake( + ["sentry"], + {"SENTRY_BACKEND": "inproc", "SENTRY_TRANSPORT": "none"}, + ) + + # build libcrash.so with NDK clang + ndk_prebuilt = pathlib.Path( + "{}/ndk/{}/toolchains/llvm/prebuilt".format( + os.environ["ANDROID_HOME"], os.environ["ANDROID_NDK"] + ) + ) + triples = { + "x86_64": "x86_64-linux-android", + "x86": "i686-linux-android", + "arm64-v8a": "aarch64-linux-android", + "armeabi-v7a": "armv7a-linux-androideabi", + } + ndk_clang = str( + next(ndk_prebuilt.iterdir()) + / "bin" + / "{}{}-clang".format(triples[arch], os.environ["ANDROID_API"]) + ) + native_lib_dir = project_fixture_path / "native" / arch + native_lib_dir.mkdir(parents=True, exist_ok=True) + shutil.copy2(tmp_path / "libsentry.so", native_lib_dir / "libsentry.so") + subprocess.run( + [ + ndk_clang, + "-Wall", + "-Wextra", + "-fPIC", + "-shared", + str(project_fixture_path / "crash.c"), + "-o", + str(native_lib_dir / "libcrash.so"), + ], + check=True, + ) + + # build and install the APK + subprocess.run( + [ + "dotnet", + "build", + "-f:net10.0-android", + "-p:RuntimeIdentifier={}".format(rid_map[arch]), + "-p:Configuration=Release", + ], + cwd=project_fixture_path, + check=True, + ) + apk_dir = ( + project_fixture_path / "bin" / "Release" / "net10.0-android" / rid_map[arch] + ) + apk_path = next(apk_dir.glob("*-Signed.apk")) + adb("install", "-r", str(apk_path), check=True) + + def run_as(cmd, **kwargs): + return adb( + "shell", + 'run-as {} sh -c "{}"'.format(ANDROID_PACKAGE, cmd), + **kwargs, + ) + + db = "files/.sentry-native" + + def file_exists(path): + return run_as("test -f " + path, capture_output=True).returncode == 0 + + def dir_exists(path): + return run_as("test -d " + path, capture_output=True).returncode == 0 + + def has_envelope(): + result = run_as( + "find " + db + " -name '*.envelope'", capture_output=True, text=True + ) + return bool(result.stdout.strip()) + + # managed exception: handled, no crash + logcat = run_android_managed_exception() + assert not ( + "NullReferenceException" in logcat + ), f"Managed exception leaked.\nlogcat:\n{logcat}" + assert wait_for(lambda: dir_exists(db)), "No database-path exists" + assert not file_exists(db + "/last_crash"), "A crash was registered" + assert not has_envelope(), "Unexpected envelope found" + + # unhandled managed exception: Mono calls exit(1), the native SDK + # should not register a crash (sentry-dotnet handles this at the + # managed layer via UnhandledExceptionRaiser) + logcat = run_android_unhandled_managed_exception() + assert ( + "NullReferenceException" in logcat + ), f"Expected NullReferenceException.\nlogcat:\n{logcat}" + assert wait_for(lambda: dir_exists(db)), "No database-path exists" + assert not file_exists(db + "/last_crash"), "A crash was registered" + assert not has_envelope(), "Unexpected envelope found" + + # native crash + run_android_native_crash() + assert wait_for(lambda: file_exists(db + "/last_crash")), "Crash marker missing" + assert wait_for(has_envelope), "Crash envelope is missing" + + finally: + shutil.rmtree(project_fixture_path / "native", ignore_errors=True) + shutil.rmtree(project_fixture_path / "bin", ignore_errors=True) + shutil.rmtree(project_fixture_path / "obj", ignore_errors=True) + adb("uninstall", ANDROID_PACKAGE, check=False) From a8ba449b05cae98073040ce45670547470dca8c6 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Sat, 14 Mar 2026 09:43:37 +0100 Subject: [PATCH 12/14] Bump to API 28 to avoid debuggerd deadlocks --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7500f6c94..16062ff0d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -195,9 +195,9 @@ jobs: ANDROID_API: 21 ANDROID_NDK: 23.2.8568313 ANDROID_ARCH: x86_64 - - name: Android (API 26, NDK 27) + - name: Android (API 28, NDK 27) os: ubuntu-latest - ANDROID_API: 26 + ANDROID_API: 28 ANDROID_NDK: 27.3.13750724 ANDROID_ARCH: x86_64 - name: Android (API 31, NDK 27) From afb2a40cb8bb2f9eef4d37aa03c1610b4facadc5 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Sat, 14 Mar 2026 10:10:34 +0100 Subject: [PATCH 13/14] Clarify why raw syscall is needed to bypass libsigchain Co-Authored-By: Claude Opus 4.6 (1M context) --- src/backends/sentry_backend_inproc.c | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/backends/sentry_backend_inproc.c b/src/backends/sentry_backend_inproc.c index cfd121141..85c721674 100644 --- a/src/backends/sentry_backend_inproc.c +++ b/src/backends/sentry_backend_inproc.c @@ -1573,8 +1573,19 @@ process_ucontext(const sentry_ucontext_t *uctx) sigset_t mask, old_mask; sigemptyset(&mask); sigaddset(&mask, uctx->signum); - // Raw syscall to bypass libsigchain, whose sigprocmask guard - // is only active inside its own special handlers. + // Raw syscall because ART's libsigchain intercepts + // sigprocmask() and silently drops the request when called + // outside its own special handlers. Without the raw syscall + // the mask change would be ignored and SA_NODEFER would let + // the chained handler's raise() re-deliver the signal + // immediately, crashing the process before we can inspect + // the modified IP/SP. + // + // DANGER: this makes libsigchain's internal mask state + // diverge from the kernel's actual mask. If ART ever relies + // on that state for correctness (e.g. GC safepoints), this + // could cause subtle failures. We restore the mask right + // after the chained handler returns, limiting the window. syscall( SYS_rt_sigprocmask, SIG_BLOCK, &mask, &old_mask, sizeof(sigset_t)); # endif From eb5c4d206afe74ebc782ece54785dab3a72f788e Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Sat, 14 Mar 2026 20:25:21 +0100 Subject: [PATCH 14/14] Revert "Bump to API 28 to avoid debuggerd deadlocks" This reverts commit b20d459dcd7afb59757ee622b9dfced5644c51e9. --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 16062ff0d..7500f6c94 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -195,9 +195,9 @@ jobs: ANDROID_API: 21 ANDROID_NDK: 23.2.8568313 ANDROID_ARCH: x86_64 - - name: Android (API 28, NDK 27) + - name: Android (API 26, NDK 27) os: ubuntu-latest - ANDROID_API: 28 + ANDROID_API: 26 ANDROID_NDK: 27.3.13750724 ANDROID_ARCH: x86_64 - name: Android (API 31, NDK 27)