From 967c4c897c3ae3b6fd2395c81ab71d09ae0d4452 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Wed, 11 Mar 2026 13:24:49 +0100 Subject: [PATCH 01/25] 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 8f1f5927a9429321bb2916caf83e5c110c189c3e Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Wed, 11 Mar 2026 13:39:30 +0100 Subject: [PATCH 02/25] Update CHANGELOG.md --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ad01ff29..fa686b9f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,12 +1,13 @@ # Changelog -## Unreleased: +## Unreleased **Fixes**: - inproc: only the handling thread cleans up after the crash. ([#1579](https://github.com/getsentry/sentry-native/pull/1579)) - Propagate transport options (`ca_certs`, `proxy`, `user_agent`) and `handler_path` to the native backend crash daemon. Previously, the daemon did not receive SSL certificate or proxy settings from the parent process, causing SSL errors (curl code 60) when uploading crash reports. The daemon also ignored the user-configured handler path, requiring the `sentry-crash` binary to be placed next to the application executable. ([#1573](https://github.com/getsentry/sentry-native/pull/1573)) - Add module header pages to MemoryList and fix exception code in the native backend. ([#1576](https://github.com/getsentry/sentry-native/pull/1576)) +- 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 e1c7799c3dcb3096122a9d2225f210842bbde64f Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Wed, 11 Mar 2026 14:27:51 +0100 Subject: [PATCH 03/25] 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 c13706d9a9dad967564a5c6db86d1105ee01b306 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Wed, 11 Mar 2026 17:28:01 +0100 Subject: [PATCH 04/25] 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 46049f2fe558e090deed82c4699bd593c1f05f4a Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Wed, 11 Mar 2026 17:41:13 +0100 Subject: [PATCH 05/25] Update CHANGELOG.md --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fa686b9f1..79f2d2818 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ - inproc: only the handling thread cleans up after the crash. ([#1579](https://github.com/getsentry/sentry-native/pull/1579)) - Propagate transport options (`ca_certs`, `proxy`, `user_agent`) and `handler_path` to the native backend crash daemon. Previously, the daemon did not receive SSL certificate or proxy settings from the parent process, causing SSL errors (curl code 60) when uploading crash reports. The daemon also ignored the user-configured handler path, requiring the `sentry-crash` binary to be placed next to the application executable. ([#1573](https://github.com/getsentry/sentry-native/pull/1573)) - Add module header pages to MemoryList and fix exception code in the native backend. ([#1576](https://github.com/getsentry/sentry-native/pull/1576)) -- 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 97bd7a8f6de2dcda089b2da7d14e6b3afd511bf4 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Wed, 11 Mar 2026 18:08:03 +0100 Subject: [PATCH 06/25] 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 13a98fa66accbbe1597dcc5e5abff71e3b530bf8 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Thu, 12 Mar 2026 09:35:31 +0100 Subject: [PATCH 07/25] 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 b4c3b36590794d11dc9cca9910eae531022ed2b8 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Thu, 12 Mar 2026 10:04:25 +0100 Subject: [PATCH 08/25] 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 0044c2317ec27ae12141839b081a5edc45f2b9ba Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Thu, 12 Mar 2026 10:10:34 +0100 Subject: [PATCH 09/25] Update CHANGELOG.md --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 79f2d2818..64c592502 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ - inproc: only the handling thread cleans up after the crash. ([#1579](https://github.com/getsentry/sentry-native/pull/1579)) - Propagate transport options (`ca_certs`, `proxy`, `user_agent`) and `handler_path` to the native backend crash daemon. Previously, the daemon did not receive SSL certificate or proxy settings from the parent process, causing SSL errors (curl code 60) when uploading crash reports. The daemon also ignored the user-configured handler path, requiring the `sentry-crash` binary to be placed next to the application executable. ([#1573](https://github.com/getsentry/sentry-native/pull/1573)) - Add module header pages to MemoryList and fix exception code in the native backend. ([#1576](https://github.com/getsentry/sentry-native/pull/1576)) -- 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 ae8b07af1e8e97b91a724dcbf0d37b433c334b76 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Thu, 12 Mar 2026 10:15:48 +0100 Subject: [PATCH 10/25] 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 a40ffcdf66e8135d6bb6e23b9af69c934ce07464 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Fri, 13 Mar 2026 21:20:03 +0100 Subject: [PATCH 11/25] 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 137517bea33a78b653fb95342ee24b78371cd542 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Sat, 14 Mar 2026 09:43:37 +0100 Subject: [PATCH 12/25] 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 f2e3b6f75317dcf37b9b37d4a3ff824268d14168 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Sat, 14 Mar 2026 10:10:34 +0100 Subject: [PATCH 13/25] 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 176d4afcbf322d87e59a0a608c37537a31d02f19 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Sat, 14 Mar 2026 20:25:21 +0100 Subject: [PATCH 14/25] 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) From 3b3b5e2b2ca77060c908545dfed617e057940c64 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Mon, 16 Mar 2026 10:02:06 +0100 Subject: [PATCH 15/25] fix(inproc): disable CHAIN_AT_START on Android API < 26 On Android API < 26, the old debuggerd daemon kills the crashing process via SIGKILL after the chained signal handler triggers it. CHAIN_AT_START cannot regain control after invoking the previous handler, so fall back to DEFAULT which chains at the end instead. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/backends/sentry_backend_inproc.c | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/src/backends/sentry_backend_inproc.c b/src/backends/sentry_backend_inproc.c index 85c721674..270224997 100644 --- a/src/backends/sentry_backend_inproc.c +++ b/src/backends/sentry_backend_inproc.c @@ -27,6 +27,7 @@ # include #endif #ifdef SENTRY_PLATFORM_ANDROID +# include # include #endif #include @@ -1550,9 +1551,20 @@ dispatch_ucontext(const sentry_ucontext_t *uctx, static void process_ucontext(const sentry_ucontext_t *uctx) { + sentry_handler_strategy_t strategy = g_backend_config.handler_strategy; +#ifdef SENTRY_PLATFORM_ANDROID + // CHAIN_AT_START invokes the previous handler and expects to regain + // control. On Android API < 26, the old debuggerd daemon kills the + // crashing process via SIGKILL after the chained handler triggers + // it, so we fall back to DEFAULT which chains at the end instead. + if (strategy == SENTRY_HANDLER_STRATEGY_CHAIN_AT_START + && android_get_device_api_level() < 26) { + strategy = SENTRY_HANDLER_STRATEGY_DEFAULT; + } +#endif + #ifdef SENTRY_PLATFORM_LINUX - if (g_backend_config.handler_strategy - == SENTRY_HANDLER_STRATEGY_CHAIN_AT_START + if (strategy == SENTRY_HANDLER_STRATEGY_CHAIN_AT_START && uctx->signum != SIGABRT) { // SIGABRT is excluded: CLR/Mono never uses it for managed exception // translation. Chaining SIGABRT to a SIG_DFL previous handler calls @@ -1688,8 +1700,7 @@ process_ucontext(const sentry_ucontext_t *uctx) sentry__atomic_store(&g_crash_handling_state, CRASH_STATE_DONE); sentry__leave_signal_handler(); - if (g_backend_config.handler_strategy - != SENTRY_HANDLER_STRATEGY_CHAIN_AT_START) { + if (strategy != SENTRY_HANDLER_STRATEGY_CHAIN_AT_START) { invoke_signal_handler( uctx->signum, uctx->siginfo, (void *)uctx->user_context); } From 7fcc52659a126f01dbbb445b1279808cec7b940e Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Mon, 16 Mar 2026 10:25:38 +0100 Subject: [PATCH 16/25] fix(inproc): move CHAIN_AT_START API level check to init Co-Authored-By: Claude Opus 4.6 (1M context) --- src/backends/sentry_backend_inproc.c | 29 ++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/src/backends/sentry_backend_inproc.c b/src/backends/sentry_backend_inproc.c index 270224997..a5f32c23a 100644 --- a/src/backends/sentry_backend_inproc.c +++ b/src/backends/sentry_backend_inproc.c @@ -484,6 +484,17 @@ startup_inproc_backend( options ? sentry_options_get_handler_strategy(options) : # endif SENTRY_HANDLER_STRATEGY_DEFAULT; +# ifdef SENTRY_PLATFORM_ANDROID + // CHAIN_AT_START invokes the previous handler and expects to regain + // control. On Android API < 26, the old debuggerd daemon kills the + // crashing process via SIGKILL after the chained handler triggers + // it, so we fall back to DEFAULT which chains at the end instead. + if (g_backend_config.handler_strategy + == SENTRY_HANDLER_STRATEGY_CHAIN_AT_START + && android_get_device_api_level() < 26) { + g_backend_config.handler_strategy = SENTRY_HANDLER_STRATEGY_DEFAULT; + } +# endif if (backend) { backend->data = &g_backend_config; } @@ -1551,20 +1562,9 @@ dispatch_ucontext(const sentry_ucontext_t *uctx, static void process_ucontext(const sentry_ucontext_t *uctx) { - sentry_handler_strategy_t strategy = g_backend_config.handler_strategy; -#ifdef SENTRY_PLATFORM_ANDROID - // CHAIN_AT_START invokes the previous handler and expects to regain - // control. On Android API < 26, the old debuggerd daemon kills the - // crashing process via SIGKILL after the chained handler triggers - // it, so we fall back to DEFAULT which chains at the end instead. - if (strategy == SENTRY_HANDLER_STRATEGY_CHAIN_AT_START - && android_get_device_api_level() < 26) { - strategy = SENTRY_HANDLER_STRATEGY_DEFAULT; - } -#endif - #ifdef SENTRY_PLATFORM_LINUX - if (strategy == SENTRY_HANDLER_STRATEGY_CHAIN_AT_START + if (g_backend_config.handler_strategy + == SENTRY_HANDLER_STRATEGY_CHAIN_AT_START && uctx->signum != SIGABRT) { // SIGABRT is excluded: CLR/Mono never uses it for managed exception // translation. Chaining SIGABRT to a SIG_DFL previous handler calls @@ -1700,7 +1700,8 @@ process_ucontext(const sentry_ucontext_t *uctx) sentry__atomic_store(&g_crash_handling_state, CRASH_STATE_DONE); sentry__leave_signal_handler(); - if (strategy != SENTRY_HANDLER_STRATEGY_CHAIN_AT_START) { + if (g_backend_config.handler_strategy + != SENTRY_HANDLER_STRATEGY_CHAIN_AT_START) { invoke_signal_handler( uctx->signum, uctx->siginfo, (void *)uctx->user_context); } From d811108bb31b58a00369818cfea288846a2e1dcb Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Tue, 17 Mar 2026 10:40:45 +0100 Subject: [PATCH 17/25] try 36 --- .github/workflows/ci.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7500f6c94..9db51ee60 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -210,6 +210,12 @@ jobs: ANDROID_API: 35 ANDROID_NDK: 29.0.14206865 ANDROID_ARCH: x86_64 + - name: Android (API 36, NDK 29) + os: macos-15-large + ANDROID_API: 35 + ANDROID_EMU_API: 36 + ANDROID_NDK: 29.0.14206865 + ANDROID_ARCH: x86_64 name: ${{ matrix.name }} runs-on: ${{ matrix.os }} @@ -223,6 +229,7 @@ jobs: ERROR_ON_WARNINGS: ${{ matrix.ERROR_ON_WARNINGS }} RUN_ANALYZER: ${{ matrix.RUN_ANALYZER }} ANDROID_API: ${{ matrix.ANDROID_API }} + ANDROID_EMU_API: ${{ matrix.ANDROID_EMU_API }} ANDROID_NDK: ${{ matrix.ANDROID_NDK }} ANDROID_ARCH: ${{ matrix.ANDROID_ARCH }} CMAKE_DEFINES: ${{ matrix.CMAKE_DEFINES }} @@ -404,7 +411,7 @@ jobs: if: ${{ env['ANDROID_API'] }} uses: reactivecircus/android-emulator-runner@b530d96654c385303d652368551fb075bc2f0b6b # v2.35.0 with: - api-level: ${{ env.ANDROID_API }} + api-level: ${{ env.ANDROID_EMU_API || env.ANDROID_API }} ndk: ${{ env.ANDROID_NDK }} arch: ${{ env.ANDROID_ARCH }} target: default From 1a03a8cc20e034034ac4fc4a83d68249e11b5e13 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Wed, 18 Mar 2026 11:39:24 +0100 Subject: [PATCH 18/25] Revert "try 36" This reverts commit d811108bb31b58a00369818cfea288846a2e1dcb. --- .github/workflows/ci.yml | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9db51ee60..7500f6c94 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -210,12 +210,6 @@ jobs: ANDROID_API: 35 ANDROID_NDK: 29.0.14206865 ANDROID_ARCH: x86_64 - - name: Android (API 36, NDK 29) - os: macos-15-large - ANDROID_API: 35 - ANDROID_EMU_API: 36 - ANDROID_NDK: 29.0.14206865 - ANDROID_ARCH: x86_64 name: ${{ matrix.name }} runs-on: ${{ matrix.os }} @@ -229,7 +223,6 @@ jobs: ERROR_ON_WARNINGS: ${{ matrix.ERROR_ON_WARNINGS }} RUN_ANALYZER: ${{ matrix.RUN_ANALYZER }} ANDROID_API: ${{ matrix.ANDROID_API }} - ANDROID_EMU_API: ${{ matrix.ANDROID_EMU_API }} ANDROID_NDK: ${{ matrix.ANDROID_NDK }} ANDROID_ARCH: ${{ matrix.ANDROID_ARCH }} CMAKE_DEFINES: ${{ matrix.CMAKE_DEFINES }} @@ -411,7 +404,7 @@ jobs: if: ${{ env['ANDROID_API'] }} uses: reactivecircus/android-emulator-runner@b530d96654c385303d652368551fb075bc2f0b6b # v2.35.0 with: - api-level: ${{ env.ANDROID_EMU_API || env.ANDROID_API }} + api-level: ${{ env.ANDROID_API }} ndk: ${{ env.ANDROID_NDK }} arch: ${{ env.ANDROID_ARCH }} target: default From dd7b4f92525dfd94454b65f94a7895af5d84422a Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Wed, 18 Mar 2026 11:44:01 +0100 Subject: [PATCH 19/25] ci: switch Android emulator target back to google_apis --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7500f6c94..65f0b7723 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -407,7 +407,7 @@ jobs: api-level: ${{ env.ANDROID_API }} ndk: ${{ env.ANDROID_NDK }} arch: ${{ env.ANDROID_ARCH }} - target: default + target: google_apis emulator-boot-timeout: 1200 script: | # Sync emulator clock with host to avoid timestamp assertion failures From 88dcbf08dd2b8a7b6bc80893e97d28aa573c2c7c Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Wed, 18 Mar 2026 11:44:01 +0100 Subject: [PATCH 20/25] fix(inproc): use clone(CLONE_VM) for CHAIN_AT_START on Android MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Android, CHAIN_AT_START invokes the previous signal handler which typically chains through Mono to debuggerd. debuggerd's resend_signal() calls signal(signo, SIG_DFL), changing the signal disposition process- wide. Other threads that routinely generate SIGSEGV during normal operation (Mono JIT null checks, ART GC) hit SIG_DFL and kill the process before our crash processing completes. The previous approach masked the signal before chaining and restored our handler afterwards, but the window between resend_signal()'s signal(SIG_DFL) and our restoration was still exploitable — the test was randomly failing on arm64. Instead, run the chain in a disposable child process created with clone(CLONE_VM | CLONE_VFORK). The child shares the parent's address space (so Mono can modify the ucontext for managed exception translation and access thread-local state), but is in a separate thread group. signal(SIG_DFL) in the child only affects the child's disposition — the parent's threads keep their handlers. CLONE_VFORK blocks the parent until the child exits, providing a synchronization point to inspect the (possibly modified) IP/SP afterwards. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/backends/sentry_backend_inproc.c | 98 ++++++++++++++++------------ 1 file changed, 56 insertions(+), 42 deletions(-) diff --git a/src/backends/sentry_backend_inproc.c b/src/backends/sentry_backend_inproc.c index a5f32c23a..0ebe045a8 100644 --- a/src/backends/sentry_backend_inproc.c +++ b/src/backends/sentry_backend_inproc.c @@ -28,7 +28,8 @@ #endif #ifdef SENTRY_PLATFORM_ANDROID # include -# include +# include +# include #endif #include @@ -1535,6 +1536,25 @@ dispatch_ucontext(const sentry_ucontext_t *uctx, #endif } +#ifdef SENTRY_PLATFORM_ANDROID +/** + * Invokes the previous signal handler (e.g. Mono/.NET, debuggerd) in a cloned + * child process that shares the parent's address space. That way, debuggerd's + * resend_signal() setting SIG_DFL only affects this disposable child, not the + * parent's other threads: + * https://android.googlesource.com/platform/system/core/+/refs/tags/android-16.0.0_r1/debuggerd/handler/debuggerd_handler.cpp#580 + */ +static int +chain_at_start(void *arg) +{ + const sentry_ucontext_t *uctx = (const sentry_ucontext_t *)arg; + invoke_signal_handler( + uctx->signum, uctx->siginfo, (void *)uctx->user_context); + _exit(0); + return 0; +} +#endif + /** * This is the signal-safe part of the inproc handler. Everything in here should * not defer to more than the set of functions listed in: @@ -1579,33 +1599,44 @@ process_ucontext(const sentry_ucontext_t *uctx) uintptr_t ip = get_instruction_pointer(uctx); uintptr_t sp = get_stack_pointer(uctx); + bool chained = false; # 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); - // 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. + // On Android, the chain may invoke debuggerd whose resend_signal() + // calls signal(signo, SIG_DFL), changing the disposition process- + // wide. Other threads generating SIGSEGV during normal operation + // (Mono JIT, ART GC) would hit SIG_DFL and kill the process. // - // 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)); + // To avoid this, we run the chain in a disposable child process + // created with clone(CLONE_VM | CLONE_VFORK): + // - CLONE_VM: shares address space so Mono can modify the + // ucontext (IP/SP) for managed exception translation, and + // access thread-local state (jit_tls, etc.) + // - CLONE_VFORK: parent blocks until child exits, giving us + // a synchronization point to inspect the (possibly modified) + // ucontext afterwards + // - no CLONE_THREAD: child is a separate thread group, so + // signal(SIG_DFL) only affects the child + // - no CLONE_SIGHAND: child gets a copy of the handler table, + // so modifications don't affect the parent + { +# define CHAIN_STACK_SIZE 65536 + void *child_stack + = mmap(NULL, CHAIN_STACK_SIZE, PROT_READ | PROT_WRITE, + MAP_PRIVATE | MAP_ANONYMOUS | MAP_STACK, -1, 0); + if (child_stack != MAP_FAILED) { + chained = clone(chain_at_start, + (char *)child_stack + CHAIN_STACK_SIZE, + CLONE_VM | CLONE_VFORK, (void *)uctx) + >= 0; + munmap(child_stack, CHAIN_STACK_SIZE); + } +# undef CHAIN_STACK_SIZE + } # endif - - // invoke the previous handler (typically the CLR/Mono - // signal-to-managed-exception handler) - invoke_signal_handler( - uctx->signum, uctx->siginfo, (void *)uctx->user_context); + if (!chained) { + invoke_signal_handler( + uctx->signum, uctx->siginfo, (void *)uctx->user_context); + } // If the execution returns here in AOT mode, and the instruction // or stack pointer were changed, it means CLR/Mono converted the @@ -1617,23 +1648,6 @@ process_ucontext(const sentry_ucontext_t *uctx) return; } -# ifdef SENTRY_PLATFORM_ANDROID - // 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 }; - syscall(SYS_rt_sigtimedwait, &mask, NULL, &timeout, sizeof(sigset_t)); - - // unmask - syscall( - SYS_rt_sigprocmask, SIG_SETMASK, &old_mask, NULL, sizeof(sigset_t)); -# endif - // return from runtime handler; continue processing the crash on the // signal thread until the worker takes over } From dc833568d6be940a5de6d3123bab9c4e85185e74 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Wed, 18 Mar 2026 12:42:18 +0100 Subject: [PATCH 21/25] Revert "fix(inproc): use clone(CLONE_VM) for CHAIN_AT_START on Android" This reverts commit 88dcbf08dd2b8a7b6bc80893e97d28aa573c2c7c. --- src/backends/sentry_backend_inproc.c | 98 ++++++++++++---------------- 1 file changed, 42 insertions(+), 56 deletions(-) diff --git a/src/backends/sentry_backend_inproc.c b/src/backends/sentry_backend_inproc.c index 0ebe045a8..a5f32c23a 100644 --- a/src/backends/sentry_backend_inproc.c +++ b/src/backends/sentry_backend_inproc.c @@ -28,8 +28,7 @@ #endif #ifdef SENTRY_PLATFORM_ANDROID # include -# include -# include +# include #endif #include @@ -1536,25 +1535,6 @@ dispatch_ucontext(const sentry_ucontext_t *uctx, #endif } -#ifdef SENTRY_PLATFORM_ANDROID -/** - * Invokes the previous signal handler (e.g. Mono/.NET, debuggerd) in a cloned - * child process that shares the parent's address space. That way, debuggerd's - * resend_signal() setting SIG_DFL only affects this disposable child, not the - * parent's other threads: - * https://android.googlesource.com/platform/system/core/+/refs/tags/android-16.0.0_r1/debuggerd/handler/debuggerd_handler.cpp#580 - */ -static int -chain_at_start(void *arg) -{ - const sentry_ucontext_t *uctx = (const sentry_ucontext_t *)arg; - invoke_signal_handler( - uctx->signum, uctx->siginfo, (void *)uctx->user_context); - _exit(0); - return 0; -} -#endif - /** * This is the signal-safe part of the inproc handler. Everything in here should * not defer to more than the set of functions listed in: @@ -1599,44 +1579,33 @@ process_ucontext(const sentry_ucontext_t *uctx) uintptr_t ip = get_instruction_pointer(uctx); uintptr_t sp = get_stack_pointer(uctx); - bool chained = false; # ifdef SENTRY_PLATFORM_ANDROID - // On Android, the chain may invoke debuggerd whose resend_signal() - // calls signal(signo, SIG_DFL), changing the disposition process- - // wide. Other threads generating SIGSEGV during normal operation - // (Mono JIT, ART GC) would hit SIG_DFL and kill the process. + // 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); + // 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. // - // To avoid this, we run the chain in a disposable child process - // created with clone(CLONE_VM | CLONE_VFORK): - // - CLONE_VM: shares address space so Mono can modify the - // ucontext (IP/SP) for managed exception translation, and - // access thread-local state (jit_tls, etc.) - // - CLONE_VFORK: parent blocks until child exits, giving us - // a synchronization point to inspect the (possibly modified) - // ucontext afterwards - // - no CLONE_THREAD: child is a separate thread group, so - // signal(SIG_DFL) only affects the child - // - no CLONE_SIGHAND: child gets a copy of the handler table, - // so modifications don't affect the parent - { -# define CHAIN_STACK_SIZE 65536 - void *child_stack - = mmap(NULL, CHAIN_STACK_SIZE, PROT_READ | PROT_WRITE, - MAP_PRIVATE | MAP_ANONYMOUS | MAP_STACK, -1, 0); - if (child_stack != MAP_FAILED) { - chained = clone(chain_at_start, - (char *)child_stack + CHAIN_STACK_SIZE, - CLONE_VM | CLONE_VFORK, (void *)uctx) - >= 0; - munmap(child_stack, CHAIN_STACK_SIZE); - } -# undef CHAIN_STACK_SIZE - } + // 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 - if (!chained) { - invoke_signal_handler( - uctx->signum, uctx->siginfo, (void *)uctx->user_context); - } + + // invoke the previous handler (typically the CLR/Mono + // signal-to-managed-exception handler) + invoke_signal_handler( + uctx->signum, uctx->siginfo, (void *)uctx->user_context); // If the execution returns here in AOT mode, and the instruction // or stack pointer were changed, it means CLR/Mono converted the @@ -1648,6 +1617,23 @@ process_ucontext(const sentry_ucontext_t *uctx) return; } +# ifdef SENTRY_PLATFORM_ANDROID + // 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 }; + syscall(SYS_rt_sigtimedwait, &mask, NULL, &timeout, sizeof(sigset_t)); + + // unmask + syscall( + SYS_rt_sigprocmask, SIG_SETMASK, &old_mask, NULL, sizeof(sigset_t)); +# endif + // return from runtime handler; continue processing the crash on the // signal thread until the worker takes over } From d8e92a1a85460b21c6790847ac97ee77bdcf3291 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Wed, 18 Mar 2026 12:36:18 +0100 Subject: [PATCH 22/25] fix(inproc): use raw SYS_rt_sigaction to restore handler on Android resend_signal() sets SIG_DFL via libsigchain, updating both libsigchain's internal state and the kernel disposition. A regular sigaction() to restore our handler goes through libsigchain which may not propagate to the kernel if its internal state is stale. Use raw SYS_rt_sigaction to bypass libsigchain and set the kernel disposition directly. --- src/backends/sentry_backend_inproc.c | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/backends/sentry_backend_inproc.c b/src/backends/sentry_backend_inproc.c index a5f32c23a..fbf793ffc 100644 --- a/src/backends/sentry_backend_inproc.c +++ b/src/backends/sentry_backend_inproc.c @@ -1618,12 +1618,13 @@ process_ucontext(const sentry_ucontext_t *uctx) } # ifdef SENTRY_PLATFORM_ANDROID - // restore our handler - struct sigaction current; - sigaction(uctx->signum, NULL, ¤t); - if (current.sa_handler == SIG_DFL) { - sigaction(uctx->signum, &g_sigaction, NULL); - } + // Restore our handler via raw syscall to bypass libsigchain. + // resend_signal() sets SIG_DFL via libsigchain, which updates + // libsigchain's internal state but also the kernel disposition. + // A regular sigaction() call goes through libsigchain which may + // not propagate to the kernel if its internal state is stale. + syscall(SYS_rt_sigaction, uctx->signum, &g_sigaction, NULL, + sizeof(sigset_t)); // consume pending signal struct timespec timeout = { 0, 0 }; From 682f5a9628aac5fd189afd5349848b014ed8f722 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Thu, 19 Mar 2026 13:14:01 +0100 Subject: [PATCH 23/25] fix(tests): replace `pm clear` with force-stop + run-as on Android `pm clear` requires `CLEAR_APP_USER_DATA` permission which is denied on some OEM devices (e.g. OnePlus) even in developer mode. Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/test_dotnet_signals.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/test_dotnet_signals.py b/tests/test_dotnet_signals.py index b4bf6544d..ba313bebc 100644 --- a/tests/test_dotnet_signals.py +++ b/tests/test_dotnet_signals.py @@ -284,7 +284,10 @@ def run_android(args=None, timeout=30): if args is None: args = [] adb("logcat", "-c") - adb("shell", "pm", "clear", ANDROID_PACKAGE) + adb("shell", "am", "force-stop", ANDROID_PACKAGE) + adb( + "shell", "run-as {} sh -c 'rm -rf files/.sentry-native'".format(ANDROID_PACKAGE) + ) intent_args = [] for arg in args: intent_args += ["--es", "arg", arg] From ee61610f0da5212df17c857b87ebd9e68d1b3041 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Thu, 19 Mar 2026 14:14:21 +0100 Subject: [PATCH 24/25] fix(tests): filter Android logcat by app PID Unfiltered `logcat -d` returns the entire device buffer, which can cause assertions to fail when the app's output is missing or buried among unrelated system log entries. Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/test_dotnet_signals.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/test_dotnet_signals.py b/tests/test_dotnet_signals.py index ba313bebc..7614aa9c4 100644 --- a/tests/test_dotnet_signals.py +++ b/tests/test_dotnet_signals.py @@ -305,6 +305,9 @@ def run_android(args=None, timeout=30): ) except subprocess.TimeoutExpired: pass + pid = adb( + "shell", "pidof", ANDROID_PACKAGE, capture_output=True, text=True + ).stdout.strip() wait_for( lambda: adb( "shell", "pidof", ANDROID_PACKAGE, capture_output=True, text=True @@ -312,7 +315,10 @@ def run_android(args=None, timeout=30): != 0, timeout=timeout, ) - return adb("logcat", "-d", capture_output=True, text=True).stdout + logcat_args = ["logcat", "-d"] + if pid: + logcat_args += ["--pid=" + pid] + return adb(*logcat_args, capture_output=True, text=True).stdout def run_android_managed_exception(): From 3ba6a336c6997ae5c57ef4d49fb6c56fc655b772 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Thu, 19 Mar 2026 15:06:14 +0100 Subject: [PATCH 25/25] fix(inproc): use sigaction() instead of raw SYS_rt_sigaction The raw syscall passed Bionic's struct sigaction which has a different field layout than the kernel expects, installing a bogus handler. Plain sigaction() works here because libsigchain's state is consistent at this point (resend_signal() set SIG_DFL through it). Co-Authored-By: Claude Opus 4.6 (1M context) --- src/backends/sentry_backend_inproc.c | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/src/backends/sentry_backend_inproc.c b/src/backends/sentry_backend_inproc.c index fbf793ffc..4ba0c5a7c 100644 --- a/src/backends/sentry_backend_inproc.c +++ b/src/backends/sentry_backend_inproc.c @@ -1618,13 +1618,8 @@ process_ucontext(const sentry_ucontext_t *uctx) } # ifdef SENTRY_PLATFORM_ANDROID - // Restore our handler via raw syscall to bypass libsigchain. - // resend_signal() sets SIG_DFL via libsigchain, which updates - // libsigchain's internal state but also the kernel disposition. - // A regular sigaction() call goes through libsigchain which may - // not propagate to the kernel if its internal state is stale. - syscall(SYS_rt_sigaction, uctx->signum, &g_sigaction, NULL, - sizeof(sigset_t)); + // restore our handler after resend_signal() set SIG_DFL + sigaction(uctx->signum, &g_sigaction, NULL); // consume pending signal struct timespec timeout = { 0, 0 };