From 7a5322db19b52f8f45761c587d51caeba03627b6 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Thu, 12 Mar 2026 14:40:14 +0100 Subject: [PATCH 01/29] 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 --- .../dotnet_signal/Directory.Build.props | 2 + .../Platforms/Android/MainActivity.cs | 32 +++ tests/fixtures/dotnet_signal/Program.cs | 25 ++- .../fixtures/dotnet_signal/test_dotnet.csproj | 12 +- tests/test_build_static.py | 4 +- tests/test_dotnet_signals.py | 189 +++++++++++++++++- 6 files changed, 249 insertions(+), 15 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/tests/fixtures/dotnet_signal/Directory.Build.props b/tests/fixtures/dotnet_signal/Directory.Build.props new file mode 100644 index 0000000000..cac7f5ab06 --- /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 0000000000..39b117b8f4 --- /dev/null +++ b/tests/fixtures/dotnet_signal/Platforms/Android/MainActivity.cs @@ -0,0 +1,32 @@ +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 OnCreate(Bundle? savedInstanceState) + { + base.OnCreate(savedInstanceState); + + var arg = Intent?.GetStringExtra("arg"); + if (!string.IsNullOrEmpty(arg)) + { + var databasePath = FilesDir?.AbsolutePath + "/.sentry-native"; + + new Thread(() => + { + Program.RunTest(new[] { arg }, databasePath); + RunOnUiThread(() => + { + FinishAndRemoveTask(); + Java.Lang.JavaSystem.Exit(0); + }); + }).Start(); + } + } +} diff --git a/tests/fixtures/dotnet_signal/Program.cs b/tests/fixtures/dotnet_signal/Program.cs index 4e6217ac3e..c21e10ef71 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 238f157e2e..c6574ca0c7 100644 --- a/tests/fixtures/dotnet_signal/test_dotnet.csproj +++ b/tests/fixtures/dotnet_signal/test_dotnet.csproj @@ -1,8 +1,18 @@ Exe - net10.0 + net10.0;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 36d502c957..6dab8ca505 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 7c4e2a70dd..53406044b0 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, + 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, + 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,174 @@ 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 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] + adb( + "shell", + "am", + "start", + "-n", + "{}/dotnet_signal.MainActivity".format(ANDROID_PACKAGE), + *intent_args, + check=True, + ) + start = time.time() + while time.time() - start < timeout: + result = adb("shell", "pidof", ANDROID_PACKAGE, capture_output=True, text=True) + if result.returncode != 0 or not result.stdout.strip(): + break + time.sleep(0.5) + time.sleep(1) + 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, reason="needs Android") +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(*args, **kwargs): + return adb("shell", "run-as", ANDROID_PACKAGE, *args, **kwargs) + + db = "files/.sentry-native" + + # managed exception: handled, no crash + logcat = run_android_managed_exception() + assert ( + "NullReferenceException" not in logcat + ), "Managed exception leaked.\nlogcat:\n{}".format(logcat) + assert ( + run_as("test", "-d", db, capture_output=True).returncode == 0 + ), "No database-path exists" + assert ( + run_as("test", "-f", db + "/last_crash", capture_output=True).returncode + != 0 + ), "A crash was registered" + result = run_as( + "find", db, "-name", "*.envelope", capture_output=True, text=True + ) + assert not result.stdout.strip(), "Unexpected envelope found" + + # unhandled managed exception: Mono calls abort(), captured by the native SDK + logcat = run_android_unhandled_managed_exception() + assert ( + "NullReferenceException" in logcat + ), "Expected NullReferenceException.\nlogcat:\n{}".format(logcat) + assert ( + run_as("test", "-d", db, capture_output=True).returncode == 0 + ), "No database-path exists" + assert ( + run_as("test", "-f", db + "/last_crash", capture_output=True).returncode + == 0 + ), "Crash marker missing" + + # native crash + run_android_native_crash() + assert ( + run_as("test", "-f", db + "/last_crash", capture_output=True).returncode + == 0 + ), "Crash marker missing" + result = run_as( + "find", db, "-name", "*.envelope", capture_output=True, text=True + ) + assert result.stdout.strip(), "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 09a5a3aa62e5b70abea8cc32a6e3e9ecb551702e Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Thu, 12 Mar 2026 16:16:19 +0100 Subject: [PATCH 02/29] 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 --- tests/test_dotnet_signals.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_dotnet_signals.py b/tests/test_dotnet_signals.py index 53406044b0..572c492722 100644 --- a/tests/test_dotnet_signals.py +++ b/tests/test_dotnet_signals.py @@ -66,7 +66,7 @@ def run_dotnet_native_crash(tmp_path): @pytest.mark.skipif( - sys.platform != "linux" or is_x86 or is_asan or is_tsan or is_android, + 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): @@ -170,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 or is_android, + 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): From 43a5fe2614bf609d269eb2834b857f26b3c11bb6 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Thu, 12 Mar 2026 16:48:17 +0100 Subject: [PATCH 03/29] ci: install .NET SDK and Android workload for Android CI jobs Co-Authored-By: Claude Opus 4.6 --- .github/workflows/ci.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a00ad83c2c..d07e54a1ab 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -357,6 +357,15 @@ 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: Add sentry.native.test hostname if: ${{ runner.os == 'Windows' }} From 956ceb558fe9091478567a32bda5ae815ec23629 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Thu, 12 Mar 2026 17:20:12 +0100 Subject: [PATCH 04/29] 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 --- tests/fixtures/dotnet_signal/test_dotnet.csproj | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/fixtures/dotnet_signal/test_dotnet.csproj b/tests/fixtures/dotnet_signal/test_dotnet.csproj index c6574ca0c7..2a1d5bff42 100644 --- a/tests/fixtures/dotnet_signal/test_dotnet.csproj +++ b/tests/fixtures/dotnet_signal/test_dotnet.csproj @@ -1,7 +1,8 @@ Exe - net10.0;net10.0-android + net10.0 + $(TargetFrameworks);net10.0-android enable enable From 03a16f7fdb0280af72d24341aa6cbe8676570392 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Thu, 12 Mar 2026 17:54:31 +0100 Subject: [PATCH 05/29] fix: use ANDROID_API instead of ANDROID_HOME for android TFM condition Co-Authored-By: Claude Opus 4.6 --- tests/fixtures/dotnet_signal/test_dotnet.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/fixtures/dotnet_signal/test_dotnet.csproj b/tests/fixtures/dotnet_signal/test_dotnet.csproj index 2a1d5bff42..9dafe35cbb 100644 --- a/tests/fixtures/dotnet_signal/test_dotnet.csproj +++ b/tests/fixtures/dotnet_signal/test_dotnet.csproj @@ -2,7 +2,7 @@ Exe net10.0 - $(TargetFrameworks);net10.0-android + $(TargetFrameworks);net10.0-android enable enable From 236914df37ac5ef5d70ea55d2a0bba770ddb3202 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Thu, 12 Mar 2026 17:57:43 +0100 Subject: [PATCH 06/29] test: print logcat output for Android test diagnostics Co-Authored-By: Claude Opus 4.6 --- tests/test_dotnet_signals.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/tests/test_dotnet_signals.py b/tests/test_dotnet_signals.py index 572c492722..d7d9621326 100644 --- a/tests/test_dotnet_signals.py +++ b/tests/test_dotnet_signals.py @@ -388,12 +388,13 @@ def run_as(*args, **kwargs): # managed exception: handled, no crash logcat = run_android_managed_exception() + print("=== managed exception logcat ===\n", logcat) assert ( "NullReferenceException" not in logcat ), "Managed exception leaked.\nlogcat:\n{}".format(logcat) assert ( run_as("test", "-d", db, capture_output=True).returncode == 0 - ), "No database-path exists" + ), "No database-path exists.\nlogcat:\n{}".format(logcat) assert ( run_as("test", "-f", db + "/last_crash", capture_output=True).returncode != 0 @@ -405,23 +406,25 @@ def run_as(*args, **kwargs): # unhandled managed exception: Mono calls abort(), captured by the native SDK logcat = run_android_unhandled_managed_exception() + print("=== unhandled managed exception logcat ===\n", logcat) assert ( "NullReferenceException" in logcat ), "Expected NullReferenceException.\nlogcat:\n{}".format(logcat) assert ( run_as("test", "-d", db, capture_output=True).returncode == 0 - ), "No database-path exists" + ), "No database-path exists.\nlogcat:\n{}".format(logcat) assert ( run_as("test", "-f", db + "/last_crash", capture_output=True).returncode == 0 ), "Crash marker missing" # native crash - run_android_native_crash() + logcat = run_android_native_crash() + print("=== native crash logcat ===\n", logcat) assert ( run_as("test", "-f", db + "/last_crash", capture_output=True).returncode == 0 - ), "Crash marker missing" + ), "Crash marker missing.\nlogcat:\n{}".format(logcat) result = run_as( "find", db, "-name", "*.envelope", capture_output=True, text=True ) From e2f516c20fd7b661b50ca4f67b39ad39f2272726 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Thu, 12 Mar 2026 18:24:24 +0100 Subject: [PATCH 07/29] 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 --- tests/test_dotnet_signals.py | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/tests/test_dotnet_signals.py b/tests/test_dotnet_signals.py index d7d9621326..23c403f452 100644 --- a/tests/test_dotnet_signals.py +++ b/tests/test_dotnet_signals.py @@ -381,8 +381,12 @@ def test_android_signals_inproc(cmake): apk_path = next(apk_dir.glob("*-Signed.apk")) adb("install", "-r", str(apk_path), check=True) - def run_as(*args, **kwargs): - return adb("shell", "run-as", ANDROID_PACKAGE, *args, **kwargs) + def run_as(cmd, **kwargs): + return adb( + "shell", + "run-as {} sh -c '{}'".format(ANDROID_PACKAGE, cmd), + **kwargs, + ) db = "files/.sentry-native" @@ -393,14 +397,13 @@ def run_as(*args, **kwargs): "NullReferenceException" not in logcat ), "Managed exception leaked.\nlogcat:\n{}".format(logcat) assert ( - run_as("test", "-d", db, capture_output=True).returncode == 0 + run_as("test -d " + db, capture_output=True).returncode == 0 ), "No database-path exists.\nlogcat:\n{}".format(logcat) assert ( - run_as("test", "-f", db + "/last_crash", capture_output=True).returncode - != 0 + run_as("test -f " + db + "/last_crash", capture_output=True).returncode != 0 ), "A crash was registered" result = run_as( - "find", db, "-name", "*.envelope", capture_output=True, text=True + "find " + db + " -name '*.envelope'", capture_output=True, text=True ) assert not result.stdout.strip(), "Unexpected envelope found" @@ -411,22 +414,20 @@ def run_as(*args, **kwargs): "NullReferenceException" in logcat ), "Expected NullReferenceException.\nlogcat:\n{}".format(logcat) assert ( - run_as("test", "-d", db, capture_output=True).returncode == 0 + run_as("test -d " + db, capture_output=True).returncode == 0 ), "No database-path exists.\nlogcat:\n{}".format(logcat) assert ( - run_as("test", "-f", db + "/last_crash", capture_output=True).returncode - == 0 + run_as("test -f " + db + "/last_crash", capture_output=True).returncode == 0 ), "Crash marker missing" # native crash logcat = run_android_native_crash() print("=== native crash logcat ===\n", logcat) assert ( - run_as("test", "-f", db + "/last_crash", capture_output=True).returncode - == 0 + run_as("test -f " + db + "/last_crash", capture_output=True).returncode == 0 ), "Crash marker missing.\nlogcat:\n{}".format(logcat) result = run_as( - "find", db, "-name", "*.envelope", capture_output=True, text=True + "find " + db + " -name '*.envelope'", capture_output=True, text=True ) assert result.stdout.strip(), "Crash envelope is missing" From 4206e149eb8266a75e46db538ccb9094f08bc29d Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Thu, 12 Mar 2026 18:28:38 +0100 Subject: [PATCH 08/29] test: include stdout/stderr in dotnet run returncode assertions Co-Authored-By: Claude Opus 4.6 --- tests/test_dotnet_signals.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/tests/test_dotnet_signals.py b/tests/test_dotnet_signals.py index 23c403f452..34c7c5a302 100644 --- a/tests/test_dotnet_signals.py +++ b/tests/test_dotnet_signals.py @@ -102,9 +102,11 @@ def test_dotnet_signals_inproc(cmake): dotnet_run_stdout, dotnet_run_stderr = dotnet_run.communicate() # the program handles the `NullReferenceException`, so the Native SDK won't register a crash. - assert dotnet_run.returncode == 0 - assert not ( - "NullReferenceException" in dotnet_run_stderr + assert ( + dotnet_run.returncode == 0 + ), f"Managed exception run failed.\nstdout:\n{dotnet_run_stdout}\nstderr:\n{dotnet_run_stderr}" + assert ( + "NullReferenceException" not in dotnet_run_stderr ), f"Managed exception run failed.\nstdout:\n{dotnet_run_stdout}\nstderr:\n{dotnet_run_stderr}" database_path = project_fixture_path / ".sentry-native" assert database_path.exists(), "No database-path exists" @@ -221,9 +223,11 @@ def test_aot_signals_inproc(cmake): dotnet_run_stdout, dotnet_run_stderr = dotnet_run.communicate() # the program handles the `NullReferenceException`, so the Native SDK won't register a crash. - assert dotnet_run.returncode == 0 - assert not ( - "NullReferenceException" in dotnet_run_stderr + assert ( + dotnet_run.returncode == 0 + ), f"Managed exception run failed.\nstdout:\n{dotnet_run_stdout}\nstderr:\n{dotnet_run_stderr}" + assert ( + "NullReferenceException" not in dotnet_run_stderr ), f"Managed exception run failed.\nstdout:\n{dotnet_run_stdout}\nstderr:\n{dotnet_run_stderr}" database_path = tmp_path / ".sentry-native" assert database_path.exists(), "No database-path exists" From e0642e73da13a726fc3e680c620dec3bfa66b678 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Thu, 12 Mar 2026 18:43:36 +0100 Subject: [PATCH 09/29] fix: use am start -W to avoid race in Android test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- tests/test_dotnet_signals.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_dotnet_signals.py b/tests/test_dotnet_signals.py index 34c7c5a302..05d649b3cc 100644 --- a/tests/test_dotnet_signals.py +++ b/tests/test_dotnet_signals.py @@ -287,6 +287,7 @@ def run_android(args=None, timeout=30): "shell", "am", "start", + "-W", "-n", "{}/dotnet_signal.MainActivity".format(ANDROID_PACKAGE), *intent_args, From 39361fc63889faa713356c6329cdc351377b860b Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Thu, 12 Mar 2026 19:12:35 +0100 Subject: [PATCH 10/29] 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 --- tests/fixtures/dotnet_signal/test_dotnet.csproj | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/fixtures/dotnet_signal/test_dotnet.csproj b/tests/fixtures/dotnet_signal/test_dotnet.csproj index 9dafe35cbb..e266400d00 100644 --- a/tests/fixtures/dotnet_signal/test_dotnet.csproj +++ b/tests/fixtures/dotnet_signal/test_dotnet.csproj @@ -13,6 +13,10 @@ true + + + + From e03edad64a3f6ad56f826032622b9ac6ad1a360e Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Thu, 12 Mar 2026 21:09:25 +0100 Subject: [PATCH 11/29] try arm64-v8a --- .github/workflows/ci.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d07e54a1ab..08d7dda12b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -191,20 +191,20 @@ jobs: MINGW_ASM_MASM_COMPILER: llvm-ml MINGW_ASM_MASM_FLAGS: -m64 - name: Android (API 21, NDK 23) - os: macos-15-large + os: macos-15-xlarge ANDROID_API: 21 ANDROID_NDK: 23.2.8568313 - ANDROID_ARCH: x86_64 + ANDROID_ARCH: arm64-v8a - name: Android (API 31, NDK 27) - os: macos-15-large + os: macos-15-xlarge ANDROID_API: 31 ANDROID_NDK: 27.3.13750724 - ANDROID_ARCH: x86_64 + ANDROID_ARCH: arm64-v8a - name: Android (API 35, NDK 29) - os: macos-15-large + os: macos-15-xlarge ANDROID_API: 35 ANDROID_NDK: 29.0.14206865 - ANDROID_ARCH: x86_64 + ANDROID_ARCH: arm64-v8a name: ${{ matrix.name }} runs-on: ${{ matrix.os }} From 8dc739fa5f14d238ce7ac97157e3c4e390c4a4ff Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Thu, 12 Mar 2026 21:16:09 +0100 Subject: [PATCH 12/29] try x86_64 on linux --- .github/workflows/ci.yml | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 08d7dda12b..34cd9c11ae 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -191,20 +191,20 @@ jobs: MINGW_ASM_MASM_COMPILER: llvm-ml MINGW_ASM_MASM_FLAGS: -m64 - name: Android (API 21, NDK 23) - os: macos-15-xlarge + os: ubuntu-latest ANDROID_API: 21 ANDROID_NDK: 23.2.8568313 - ANDROID_ARCH: arm64-v8a + ANDROID_ARCH: x86_64 - name: Android (API 31, NDK 27) - os: macos-15-xlarge + os: ubuntu-latest ANDROID_API: 31 ANDROID_NDK: 27.3.13750724 - ANDROID_ARCH: arm64-v8a + ANDROID_ARCH: x86_64 - name: Android (API 35, NDK 29) - os: macos-15-xlarge + os: ubuntu-latest ANDROID_API: 35 ANDROID_NDK: 29.0.14206865 - ANDROID_ARCH: arm64-v8a + ANDROID_ARCH: x86_64 name: ${{ matrix.name }} runs-on: ${{ matrix.os }} @@ -242,12 +242,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 +278,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 @@ -367,6 +367,13 @@ jobs: 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' }} # The path is usually C:\Windows\System32\drivers\etc\hosts From 5480f0aca65587e3b14d42ee0e46f66259d99791 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Fri, 13 Mar 2026 08:13:36 +0100 Subject: [PATCH 13/29] Test 22-30 --- .github/workflows/ci.yml | 45 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 34cd9c11ae..565bc2db1f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -195,6 +195,51 @@ jobs: ANDROID_API: 21 ANDROID_NDK: 23.2.8568313 ANDROID_ARCH: x86_64 + - name: Android (API 22, NDK 27) + os: ubuntu-latest + ANDROID_API: 22 + ANDROID_NDK: 27.3.13750724 + ANDROID_ARCH: x86_64 + - name: Android (API 23, NDK 27) + os: ubuntu-latest + ANDROID_API: 23 + ANDROID_NDK: 27.3.13750724 + ANDROID_ARCH: x86_64 + - name: Android (API 24, NDK 27) + os: ubuntu-latest + ANDROID_API: 24 + ANDROID_NDK: 27.3.13750724 + ANDROID_ARCH: x86_64 + - name: Android (API 25, NDK 27) + os: ubuntu-latest + ANDROID_API: 25 + ANDROID_NDK: 27.3.13750724 + 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 27, NDK 27) + os: ubuntu-latest + ANDROID_API: 27 + ANDROID_NDK: 27.3.13750724 + ANDROID_ARCH: x86_64 + - name: Android (API 28, NDK 27) + os: ubuntu-latest + ANDROID_API: 28 + ANDROID_NDK: 27.3.13750724 + ANDROID_ARCH: x86_64 + - name: Android (API 29, NDK 27) + os: ubuntu-latest + ANDROID_API: 29 + ANDROID_NDK: 27.3.13750724 + ANDROID_ARCH: x86_64 + - name: Android (API 30, NDK 27) + os: ubuntu-latest + ANDROID_API: 30 + ANDROID_NDK: 27.3.13750724 + ANDROID_ARCH: x86_64 - name: Android (API 31, NDK 27) os: ubuntu-latest ANDROID_API: 31 From 594bd41127b4dc6a6be3c393f3ab045ced5c0482 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Fri, 13 Mar 2026 08:48:13 +0100 Subject: [PATCH 14/29] 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 --- .../fixtures/dotnet_signal/Platforms/Android/MainActivity.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/fixtures/dotnet_signal/Platforms/Android/MainActivity.cs b/tests/fixtures/dotnet_signal/Platforms/Android/MainActivity.cs index 39b117b8f4..7db95e7008 100644 --- a/tests/fixtures/dotnet_signal/Platforms/Android/MainActivity.cs +++ b/tests/fixtures/dotnet_signal/Platforms/Android/MainActivity.cs @@ -9,9 +9,9 @@ namespace dotnet_signal; [Activity(Name = "dotnet_signal.MainActivity", MainLauncher = true)] public class MainActivity : Activity { - protected override void OnCreate(Bundle? savedInstanceState) + protected override void OnResume() { - base.OnCreate(savedInstanceState); + base.OnResume(); var arg = Intent?.GetStringExtra("arg"); if (!string.IsNullOrEmpty(arg)) From 17714edbaa2d58ceffdf4308dab958c8bf2393af Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Fri, 13 Mar 2026 08:48:58 +0100 Subject: [PATCH 15/29] ci: use default emulator target instead of google_apis Co-Authored-By: Claude Opus 4.6 --- .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 565bc2db1f..6368431a98 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -447,7 +447,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 From cdf3474db686da406368679407bf47d0a38f128d Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Fri, 13 Mar 2026 09:02:52 +0100 Subject: [PATCH 16/29] 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 --- tests/test_dotnet_signals.py | 65 +++++++++++++++++++++--------------- 1 file changed, 38 insertions(+), 27 deletions(-) diff --git a/tests/test_dotnet_signals.py b/tests/test_dotnet_signals.py index 05d649b3cc..5850a6351d 100644 --- a/tests/test_dotnet_signals.py +++ b/tests/test_dotnet_signals.py @@ -270,6 +270,15 @@ def test_aot_signals_inproc(cmake): ANDROID_PACKAGE = "io.sentry.ndk.dotnet.signal.test" +def wait_for(condition, timeout=10, interval=0.5): + start = time.time() + while time.time() - start < timeout: + if condition(): + return True + time.sleep(interval) + return condition() + + def adb(*args, **kwargs): adb_path = "{}/platform-tools/adb".format(os.environ["ANDROID_HOME"]) return subprocess.run([adb_path, *args], **kwargs) @@ -293,13 +302,13 @@ def run_android(args=None, timeout=30): *intent_args, check=True, ) - start = time.time() - while time.time() - start < timeout: - result = adb("shell", "pidof", ANDROID_PACKAGE, capture_output=True, text=True) - if result.returncode != 0 or not result.stdout.strip(): - break - time.sleep(0.5) - time.sleep(1) + 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 @@ -395,22 +404,29 @@ def run_as(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() print("=== managed exception logcat ===\n", logcat) assert ( "NullReferenceException" not in logcat ), "Managed exception leaked.\nlogcat:\n{}".format(logcat) - assert ( - run_as("test -d " + db, capture_output=True).returncode == 0 + assert wait_for( + lambda: dir_exists(db) ), "No database-path exists.\nlogcat:\n{}".format(logcat) - assert ( - run_as("test -f " + db + "/last_crash", capture_output=True).returncode != 0 - ), "A crash was registered" - result = run_as( - "find " + db + " -name '*.envelope'", capture_output=True, text=True - ) - assert not result.stdout.strip(), "Unexpected envelope found" + assert not file_exists(db + "/last_crash"), "A crash was registered" + assert not has_envelope(), "Unexpected envelope found" # unhandled managed exception: Mono calls abort(), captured by the native SDK logcat = run_android_unhandled_managed_exception() @@ -418,23 +434,18 @@ def run_as(cmd, **kwargs): assert ( "NullReferenceException" in logcat ), "Expected NullReferenceException.\nlogcat:\n{}".format(logcat) - assert ( - run_as("test -d " + db, capture_output=True).returncode == 0 + assert wait_for( + lambda: dir_exists(db) ), "No database-path exists.\nlogcat:\n{}".format(logcat) - assert ( - run_as("test -f " + db + "/last_crash", capture_output=True).returncode == 0 - ), "Crash marker missing" + assert wait_for(lambda: file_exists(db + "/last_crash")), "Crash marker missing" # native crash logcat = run_android_native_crash() print("=== native crash logcat ===\n", logcat) - assert ( - run_as("test -f " + db + "/last_crash", capture_output=True).returncode == 0 + assert wait_for( + lambda: file_exists(db + "/last_crash") ), "Crash marker missing.\nlogcat:\n{}".format(logcat) - result = run_as( - "find " + db + " -name '*.envelope'", capture_output=True, text=True - ) - assert result.stdout.strip(), "Crash envelope is missing" + assert wait_for(has_envelope), "Crash envelope is missing" finally: shutil.rmtree(project_fixture_path / "native", ignore_errors=True) From 23e5bfef344252463c269c07087e8902f59c0a3d Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Fri, 13 Mar 2026 09:05:26 +0100 Subject: [PATCH 17/29] fix: add timeout to am start -W with logcat dump on failure Co-Authored-By: Claude Opus 4.6 --- tests/test_dotnet_signals.py | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/tests/test_dotnet_signals.py b/tests/test_dotnet_signals.py index 5850a6351d..e3c2b5a461 100644 --- a/tests/test_dotnet_signals.py +++ b/tests/test_dotnet_signals.py @@ -292,16 +292,23 @@ def run_android(args=None, timeout=30): intent_args = [] for arg in args: intent_args += ["--es", "arg", arg] - adb( - "shell", - "am", - "start", - "-W", - "-n", - "{}/dotnet_signal.MainActivity".format(ANDROID_PACKAGE), - *intent_args, - check=True, - ) + try: + adb( + "shell", + "am", + "start", + "-W", + "-n", + "{}/dotnet_signal.MainActivity".format(ANDROID_PACKAGE), + *intent_args, + check=True, + timeout=timeout, + ) + except subprocess.TimeoutExpired: + logcat = adb("logcat", "-d", capture_output=True, text=True).stdout + raise AssertionError( + "am start -W timed out after {}s.\nlogcat:\n{}".format(timeout, logcat) + ) wait_for( lambda: adb( "shell", "pidof", ANDROID_PACKAGE, capture_output=True, text=True From 41a24dd2a040f3bbdae5ff790bc470bb45fb253a Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Fri, 13 Mar 2026 09:21:15 +0100 Subject: [PATCH 18/29] fix: run test directly on UI thread instead of background thread Co-Authored-By: Claude Opus 4.6 --- .../dotnet_signal/Platforms/Android/MainActivity.cs | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/tests/fixtures/dotnet_signal/Platforms/Android/MainActivity.cs b/tests/fixtures/dotnet_signal/Platforms/Android/MainActivity.cs index 7db95e7008..443ab0284f 100644 --- a/tests/fixtures/dotnet_signal/Platforms/Android/MainActivity.cs +++ b/tests/fixtures/dotnet_signal/Platforms/Android/MainActivity.cs @@ -17,16 +17,9 @@ protected override void OnResume() if (!string.IsNullOrEmpty(arg)) { var databasePath = FilesDir?.AbsolutePath + "/.sentry-native"; - - new Thread(() => - { - Program.RunTest(new[] { arg }, databasePath); - RunOnUiThread(() => - { - FinishAndRemoveTask(); - Java.Lang.JavaSystem.Exit(0); - }); - }).Start(); + Program.RunTest(new[] { arg }, databasePath); + FinishAndRemoveTask(); + Java.Lang.JavaSystem.Exit(0); } } } From ad29174d5ac452a5525f371c76a7a955c3fdca6c Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Fri, 13 Mar 2026 10:09:18 +0100 Subject: [PATCH 19/29] 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 --- .../Platforms/Android/MainActivity.cs | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/tests/fixtures/dotnet_signal/Platforms/Android/MainActivity.cs b/tests/fixtures/dotnet_signal/Platforms/Android/MainActivity.cs index 443ab0284f..8388c54b3c 100644 --- a/tests/fixtures/dotnet_signal/Platforms/Android/MainActivity.cs +++ b/tests/fixtures/dotnet_signal/Platforms/Android/MainActivity.cs @@ -16,10 +16,19 @@ protected override void OnResume() var arg = Intent?.GetStringExtra("arg"); if (!string.IsNullOrEmpty(arg)) { - var databasePath = FilesDir?.AbsolutePath + "/.sentry-native"; - Program.RunTest(new[] { arg }, databasePath); - FinishAndRemoveTask(); - Java.Lang.JavaSystem.Exit(0); + Window?.DecorView?.Post(() => + { + var databasePath = FilesDir?.AbsolutePath + "/.sentry-native"; + new Thread(() => + { + Program.RunTest(new[] { arg }, databasePath); + RunOnUiThread(() => + { + FinishAndRemoveTask(); + Java.Lang.JavaSystem.Exit(0); + }); + }).Start(); + }); } } } From dd0654453a1d78cad4cfaa16dc8cad6b11db9780 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Fri, 13 Mar 2026 10:57:46 +0100 Subject: [PATCH 20/29] 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 --- .../Platforms/Android/MainActivity.cs | 20 +++++++++---------- tests/test_dotnet_signals.py | 7 ++----- 2 files changed, 11 insertions(+), 16 deletions(-) diff --git a/tests/fixtures/dotnet_signal/Platforms/Android/MainActivity.cs b/tests/fixtures/dotnet_signal/Platforms/Android/MainActivity.cs index 8388c54b3c..7db95e7008 100644 --- a/tests/fixtures/dotnet_signal/Platforms/Android/MainActivity.cs +++ b/tests/fixtures/dotnet_signal/Platforms/Android/MainActivity.cs @@ -16,19 +16,17 @@ protected override void OnResume() var arg = Intent?.GetStringExtra("arg"); if (!string.IsNullOrEmpty(arg)) { - Window?.DecorView?.Post(() => + var databasePath = FilesDir?.AbsolutePath + "/.sentry-native"; + + new Thread(() => { - var databasePath = FilesDir?.AbsolutePath + "/.sentry-native"; - new Thread(() => + Program.RunTest(new[] { arg }, databasePath); + RunOnUiThread(() => { - Program.RunTest(new[] { arg }, databasePath); - RunOnUiThread(() => - { - FinishAndRemoveTask(); - Java.Lang.JavaSystem.Exit(0); - }); - }).Start(); - }); + FinishAndRemoveTask(); + Java.Lang.JavaSystem.Exit(0); + }); + }).Start(); } } } diff --git a/tests/test_dotnet_signals.py b/tests/test_dotnet_signals.py index e3c2b5a461..b6868c91c7 100644 --- a/tests/test_dotnet_signals.py +++ b/tests/test_dotnet_signals.py @@ -302,13 +302,10 @@ def run_android(args=None, timeout=30): "{}/dotnet_signal.MainActivity".format(ANDROID_PACKAGE), *intent_args, check=True, - timeout=timeout, + timeout=10, ) except subprocess.TimeoutExpired: - logcat = adb("logcat", "-d", capture_output=True, text=True).stdout - raise AssertionError( - "am start -W timed out after {}s.\nlogcat:\n{}".format(timeout, logcat) - ) + pass wait_for( lambda: adb( "shell", "pidof", ANDROID_PACKAGE, capture_output=True, text=True From d399edb86d74ed582bb6f5141b59dca3ddab6ec6 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Fri, 13 Mar 2026 14:51:38 +0100 Subject: [PATCH 21/29] 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 --- .../Platforms/Android/MainActivity.cs | 25 +++++++++++++++---- tests/test_dotnet_signals.py | 3 ++- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/tests/fixtures/dotnet_signal/Platforms/Android/MainActivity.cs b/tests/fixtures/dotnet_signal/Platforms/Android/MainActivity.cs index 7db95e7008..5d3a97aca3 100644 --- a/tests/fixtures/dotnet_signal/Platforms/Android/MainActivity.cs +++ b/tests/fixtures/dotnet_signal/Platforms/Android/MainActivity.cs @@ -1,3 +1,4 @@ +using System.Runtime.InteropServices; using Android.App; using Android.OS; @@ -9,6 +10,9 @@ namespace dotnet_signal; [Activity(Name = "dotnet_signal.MainActivity", MainLauncher = true)] public class MainActivity : Activity { + [DllImport("libc", EntryPoint = "abort")] + static extern void abort(); + protected override void OnResume() { base.OnResume(); @@ -18,15 +22,26 @@ protected override void OnResume() { var databasePath = FilesDir?.AbsolutePath + "/.sentry-native"; - new Thread(() => + // Post the test to the main thread's message queue so it runs + // after OnResume returns and the activity is fully started. + new Handler(Looper.MainLooper!).Post(() => { - Program.RunTest(new[] { arg }, databasePath); - RunOnUiThread(() => + try { + Program.RunTest(new[] { arg }, databasePath); FinishAndRemoveTask(); Java.Lang.JavaSystem.Exit(0); - }); - }).Start(); + } + catch (Exception e) + { + // Emulate what MAUI does: call abort() for unhandled + // exceptions so that the native crash handler can + // capture them. Without this, the main thread's + // UncaughtExceptionHandler would kill with SIGKILL. + Console.Error.WriteLine(e); + abort(); + } + }); } } } diff --git a/tests/test_dotnet_signals.py b/tests/test_dotnet_signals.py index b6868c91c7..d04ee3349f 100644 --- a/tests/test_dotnet_signals.py +++ b/tests/test_dotnet_signals.py @@ -432,7 +432,8 @@ def has_envelope(): assert not file_exists(db + "/last_crash"), "A crash was registered" assert not has_envelope(), "Unexpected envelope found" - # unhandled managed exception: Mono calls abort(), captured by the native SDK + # unhandled managed exception: the app catches and calls abort() + # (emulating MAUI), captured by the native SDK logcat = run_android_unhandled_managed_exception() print("=== unhandled managed exception logcat ===\n", logcat) assert ( From c4b63fad4eb358355b8df3300931a6449bbff505 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Fri, 13 Mar 2026 15:36:09 +0100 Subject: [PATCH 22/29] 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 --- .../Platforms/Android/MainActivity.cs | 22 +++---------------- tests/test_dotnet_signals.py | 8 ++++--- 2 files changed, 8 insertions(+), 22 deletions(-) diff --git a/tests/fixtures/dotnet_signal/Platforms/Android/MainActivity.cs b/tests/fixtures/dotnet_signal/Platforms/Android/MainActivity.cs index 5d3a97aca3..8c330003a5 100644 --- a/tests/fixtures/dotnet_signal/Platforms/Android/MainActivity.cs +++ b/tests/fixtures/dotnet_signal/Platforms/Android/MainActivity.cs @@ -1,4 +1,3 @@ -using System.Runtime.InteropServices; using Android.App; using Android.OS; @@ -10,9 +9,6 @@ namespace dotnet_signal; [Activity(Name = "dotnet_signal.MainActivity", MainLauncher = true)] public class MainActivity : Activity { - [DllImport("libc", EntryPoint = "abort")] - static extern void abort(); - protected override void OnResume() { base.OnResume(); @@ -26,21 +22,9 @@ protected override void OnResume() // after OnResume returns and the activity is fully started. new Handler(Looper.MainLooper!).Post(() => { - try - { - Program.RunTest(new[] { arg }, databasePath); - FinishAndRemoveTask(); - Java.Lang.JavaSystem.Exit(0); - } - catch (Exception e) - { - // Emulate what MAUI does: call abort() for unhandled - // exceptions so that the native crash handler can - // capture them. Without this, the main thread's - // UncaughtExceptionHandler would kill with SIGKILL. - Console.Error.WriteLine(e); - abort(); - } + Program.RunTest(new[] { arg }, databasePath); + FinishAndRemoveTask(); + Java.Lang.JavaSystem.Exit(0); }); } } diff --git a/tests/test_dotnet_signals.py b/tests/test_dotnet_signals.py index d04ee3349f..37ad82363b 100644 --- a/tests/test_dotnet_signals.py +++ b/tests/test_dotnet_signals.py @@ -432,8 +432,9 @@ def has_envelope(): assert not file_exists(db + "/last_crash"), "A crash was registered" assert not has_envelope(), "Unexpected envelope found" - # unhandled managed exception: the app catches and calls abort() - # (emulating MAUI), captured by the native SDK + # 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() print("=== unhandled managed exception logcat ===\n", logcat) assert ( @@ -442,7 +443,8 @@ def has_envelope(): assert wait_for( lambda: dir_exists(db) ), "No database-path exists.\nlogcat:\n{}".format(logcat) - assert wait_for(lambda: file_exists(db + "/last_crash")), "Crash marker missing" + assert not file_exists(db + "/last_crash"), "A crash was registered" + assert not has_envelope(), "Unexpected envelope found" # native crash logcat = run_android_native_crash() From d51ab3fb734c7f938827e4ace60ea70fdc263c3a Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Fri, 13 Mar 2026 15:37:08 +0100 Subject: [PATCH 23/29] ci: drop broken Android API 22 job Co-Authored-By: Claude Opus 4.6 --- .github/workflows/ci.yml | 5 ----- 1 file changed, 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6368431a98..94b21fb6a0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -195,11 +195,6 @@ jobs: ANDROID_API: 21 ANDROID_NDK: 23.2.8568313 ANDROID_ARCH: x86_64 - - name: Android (API 22, NDK 27) - os: ubuntu-latest - ANDROID_API: 22 - ANDROID_NDK: 27.3.13750724 - ANDROID_ARCH: x86_64 - name: Android (API 23, NDK 27) os: ubuntu-latest ANDROID_API: 23 From f45a661ec6033f9791e26145ff00fc03aeb1be94 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Fri, 13 Mar 2026 15:42:15 +0100 Subject: [PATCH 24/29] 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 --- .github/workflows/ci.yml | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 94b21fb6a0..1c439f537c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -215,11 +215,6 @@ jobs: ANDROID_API: 26 ANDROID_NDK: 27.3.13750724 ANDROID_ARCH: x86_64 - - name: Android (API 27, NDK 27) - os: ubuntu-latest - ANDROID_API: 27 - ANDROID_NDK: 27.3.13750724 - ANDROID_ARCH: x86_64 - name: Android (API 28, NDK 27) os: ubuntu-latest ANDROID_API: 28 @@ -442,7 +437,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 694760717ef19f95ab79c67b9d1c8dbe9ada5832 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Fri, 13 Mar 2026 15:55:19 +0100 Subject: [PATCH 25/29] 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 --- 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 37ad82363b..20855832db 100644 --- a/tests/test_dotnet_signals.py +++ b/tests/test_dotnet_signals.py @@ -328,7 +328,10 @@ def run_android_native_crash(): return run_android(["native-crash"]) -@pytest.mark.skipif(not is_android, reason="needs Android") +@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") From 286b9228ee58bc059b4646a064a37b9dbc6d2684 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Fri, 13 Mar 2026 16:00:15 +0100 Subject: [PATCH 26/29] Try macos-15-large again, drop others but 26 --- .github/workflows/ci.yml | 32 +------------------------------- 1 file changed, 1 insertion(+), 31 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1c439f537c..65f0b77238 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -195,48 +195,18 @@ jobs: ANDROID_API: 21 ANDROID_NDK: 23.2.8568313 ANDROID_ARCH: x86_64 - - name: Android (API 23, NDK 27) - os: ubuntu-latest - ANDROID_API: 23 - ANDROID_NDK: 27.3.13750724 - ANDROID_ARCH: x86_64 - - name: Android (API 24, NDK 27) - os: ubuntu-latest - ANDROID_API: 24 - ANDROID_NDK: 27.3.13750724 - ANDROID_ARCH: x86_64 - - name: Android (API 25, NDK 27) - os: ubuntu-latest - ANDROID_API: 25 - ANDROID_NDK: 27.3.13750724 - 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 28, NDK 27) - os: ubuntu-latest - ANDROID_API: 28 - ANDROID_NDK: 27.3.13750724 - ANDROID_ARCH: x86_64 - - name: Android (API 29, NDK 27) - os: ubuntu-latest - ANDROID_API: 29 - ANDROID_NDK: 27.3.13750724 - ANDROID_ARCH: x86_64 - - name: Android (API 30, NDK 27) - os: ubuntu-latest - ANDROID_API: 30 - ANDROID_NDK: 27.3.13750724 - ANDROID_ARCH: x86_64 - name: Android (API 31, NDK 27) os: ubuntu-latest ANDROID_API: 31 ANDROID_NDK: 27.3.13750724 ANDROID_ARCH: x86_64 - name: Android (API 35, NDK 29) - os: ubuntu-latest + os: macos-15-large ANDROID_API: 35 ANDROID_NDK: 29.0.14206865 ANDROID_ARCH: x86_64 From cd82d46548f9f52ad4431e0b7d40b13842ac2106 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Fri, 13 Mar 2026 16:16:15 +0100 Subject: [PATCH 27/29] test: clean up dotnet signal test assertions and comments Co-Authored-By: Claude Opus 4.6 --- .../Platforms/Android/MainActivity.cs | 4 +- tests/test_dotnet_signals.py | 41 +++++++------------ 2 files changed, 16 insertions(+), 29 deletions(-) diff --git a/tests/fixtures/dotnet_signal/Platforms/Android/MainActivity.cs b/tests/fixtures/dotnet_signal/Platforms/Android/MainActivity.cs index 8c330003a5..a6b35ceba3 100644 --- a/tests/fixtures/dotnet_signal/Platforms/Android/MainActivity.cs +++ b/tests/fixtures/dotnet_signal/Platforms/Android/MainActivity.cs @@ -18,8 +18,8 @@ protected override void OnResume() { var databasePath = FilesDir?.AbsolutePath + "/.sentry-native"; - // Post the test to the main thread's message queue so it runs - // after OnResume returns and the activity is fully started. + // 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); diff --git a/tests/test_dotnet_signals.py b/tests/test_dotnet_signals.py index 20855832db..c47b1b1c1d 100644 --- a/tests/test_dotnet_signals.py +++ b/tests/test_dotnet_signals.py @@ -102,11 +102,9 @@ def test_dotnet_signals_inproc(cmake): dotnet_run_stdout, dotnet_run_stderr = dotnet_run.communicate() # the program handles the `NullReferenceException`, so the Native SDK won't register a crash. - assert ( - dotnet_run.returncode == 0 - ), f"Managed exception run failed.\nstdout:\n{dotnet_run_stdout}\nstderr:\n{dotnet_run_stderr}" - assert ( - "NullReferenceException" not in dotnet_run_stderr + assert dotnet_run.returncode == 0 + assert not ( + "NullReferenceException" in dotnet_run_stderr ), f"Managed exception run failed.\nstdout:\n{dotnet_run_stdout}\nstderr:\n{dotnet_run_stderr}" database_path = project_fixture_path / ".sentry-native" assert database_path.exists(), "No database-path exists" @@ -223,11 +221,9 @@ def test_aot_signals_inproc(cmake): dotnet_run_stdout, dotnet_run_stderr = dotnet_run.communicate() # the program handles the `NullReferenceException`, so the Native SDK won't register a crash. - assert ( - dotnet_run.returncode == 0 - ), f"Managed exception run failed.\nstdout:\n{dotnet_run_stdout}\nstderr:\n{dotnet_run_stderr}" - assert ( - "NullReferenceException" not in dotnet_run_stderr + assert dotnet_run.returncode == 0 + assert not ( + "NullReferenceException" in dotnet_run_stderr ), f"Managed exception run failed.\nstdout:\n{dotnet_run_stdout}\nstderr:\n{dotnet_run_stderr}" database_path = tmp_path / ".sentry-native" assert database_path.exists(), "No database-path exists" @@ -425,13 +421,10 @@ def has_envelope(): # managed exception: handled, no crash logcat = run_android_managed_exception() - print("=== managed exception logcat ===\n", logcat) - assert ( - "NullReferenceException" not in logcat - ), "Managed exception leaked.\nlogcat:\n{}".format(logcat) - assert wait_for( - lambda: dir_exists(db) - ), "No database-path exists.\nlogcat:\n{}".format(logcat) + 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" @@ -439,22 +432,16 @@ def has_envelope(): # should not register a crash (sentry-dotnet handles this at the # managed layer via UnhandledExceptionRaiser) logcat = run_android_unhandled_managed_exception() - print("=== unhandled managed exception logcat ===\n", logcat) assert ( "NullReferenceException" in logcat - ), "Expected NullReferenceException.\nlogcat:\n{}".format(logcat) - assert wait_for( - lambda: dir_exists(db) - ), "No database-path exists.\nlogcat:\n{}".format(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 - logcat = run_android_native_crash() - print("=== native crash logcat ===\n", logcat) - assert wait_for( - lambda: file_exists(db + "/last_crash") - ), "Crash marker missing.\nlogcat:\n{}".format(logcat) + 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: From d337b497e051ee5c2f02f95ece536a1aff3bd142 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Fri, 13 Mar 2026 17:47:09 +0100 Subject: [PATCH 28/29] ci: switch Android emulator back to default target Co-Authored-By: Claude Opus 4.6 --- .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 65f0b77238..7500f6c949 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: google_apis + target: default emulator-boot-timeout: 1200 script: | # Sync emulator clock with host to avoid timestamp assertion failures From c03b5afdb4baf8021dd5e5c93ffe3d06dfa039dd Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Fri, 13 Mar 2026 18:23:42 +0100 Subject: [PATCH 29/29] 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 --- tests/test_dotnet_signals.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_dotnet_signals.py b/tests/test_dotnet_signals.py index c47b1b1c1d..b4bf6544da 100644 --- a/tests/test_dotnet_signals.py +++ b/tests/test_dotnet_signals.py @@ -401,7 +401,7 @@ def test_android_signals_inproc(cmake): def run_as(cmd, **kwargs): return adb( "shell", - "run-as {} sh -c '{}'".format(ANDROID_PACKAGE, cmd), + 'run-as {} sh -c "{}"'.format(ANDROID_PACKAGE, cmd), **kwargs, )