From d5db02515816d2be2ba882ecec21694ba16bec8c Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Mon, 29 Dec 2025 09:13:20 +0100 Subject: [PATCH 1/5] Use reverse p/invoke to propagate uncaught Java exceptions to the managed side --- src/Mono.Android/Android.Runtime/JNIEnv.cs | 4 +++- .../Android.Runtime/JNIEnvInit.cs | 6 +++++ src/native/clr/host/host-jni.cc | 4 ++-- src/native/clr/host/host.cc | 24 +++++++++++++++++++ src/native/clr/include/host/host.hh | 2 ++ .../common/include/managed-interface.hh | 1 + 6 files changed, 38 insertions(+), 3 deletions(-) diff --git a/src/Mono.Android/Android.Runtime/JNIEnv.cs b/src/Mono.Android/Android.Runtime/JNIEnv.cs index 1c35c0a3db9..cafd100a1a3 100644 --- a/src/Mono.Android/Android.Runtime/JNIEnv.cs +++ b/src/Mono.Android/Android.Runtime/JNIEnv.cs @@ -104,7 +104,9 @@ internal static void PropagateUncaughtException (IntPtr env, IntPtr javaThreadPt if (RuntimeFeature.IsMonoRuntime) { MonoDroidUnhandledException (innerException ?? javaException); } else if (RuntimeFeature.IsCoreClrRuntime) { - // TODO: what to do here? + // CoreCLR doesn't have mono_unhandled_exception, so we use Environment.FailFast + // to terminate the process with proper exception information. + Environment.FailFast ("Unhandled Java exception", innerException ?? javaException); } else { throw new NotSupportedException ("Internal error: unknown runtime not supported"); } diff --git a/src/Mono.Android/Android.Runtime/JNIEnvInit.cs b/src/Mono.Android/Android.Runtime/JNIEnvInit.cs index d932148e7f3..30831394a68 100644 --- a/src/Mono.Android/Android.Runtime/JNIEnvInit.cs +++ b/src/Mono.Android/Android.Runtime/JNIEnvInit.cs @@ -49,6 +49,12 @@ internal struct JnienvInitializeArgs { internal static JniRuntime? androidRuntime; + [UnmanagedCallersOnly] + static void PropagateUncaughtException (IntPtr env, IntPtr javaThread, IntPtr javaException) + { + JNIEnv.PropagateUncaughtException (env, javaThread, javaException); + } + [UnmanagedCallersOnly] static unsafe void RegisterJniNatives (IntPtr typeName_ptr, int typeName_len, IntPtr jniClass, IntPtr methods_ptr, int methods_len) { diff --git a/src/native/clr/host/host-jni.cc b/src/native/clr/host/host-jni.cc index b3fa81df164..a41c1c507cc 100644 --- a/src/native/clr/host/host-jni.cc +++ b/src/native/clr/host/host-jni.cc @@ -47,9 +47,9 @@ Java_mono_android_Runtime_initInternal (JNIEnv *env, jclass klass, jstring lang, } JNIEXPORT void -JNICALL Java_mono_android_Runtime_propagateUncaughtException ([[maybe_unused]] JNIEnv *env, [[maybe_unused]] jclass klass, [[maybe_unused]] jobject javaThread, [[maybe_unused]] jthrowable javaException) +JNICALL Java_mono_android_Runtime_propagateUncaughtException (JNIEnv *env, [[maybe_unused]] jclass klass, jobject javaThread, jthrowable javaException) { - // TODO: implement or remove + Host::propagate_uncaught_exception (env, javaThread, javaException); } JNIEXPORT void diff --git a/src/native/clr/host/host.cc b/src/native/clr/host/host.cc index 1a0dc8caebc..1a3805bb495 100644 --- a/src/native/clr/host/host.cc +++ b/src/native/clr/host/host.cc @@ -557,6 +557,20 @@ void Host::Java_mono_android_Runtime_initInternal ( } ); + log_debug (LOG_ASSEMBLY, "Creating UCO delegate to {}.PropagateUncaughtException"sv, Constants::JNIENVINIT_FULL_TYPE_NAME); + delegate = FastTiming::time_call ("create_delegate for PropagateUncaughtException"sv, create_delegate, Constants::MONO_ANDROID_ASSEMBLY_NAME, Constants::JNIENVINIT_FULL_TYPE_NAME, "PropagateUncaughtException"sv); + jnienv_propagate_uncaught_exception = reinterpret_cast (delegate); + abort_unless ( + jnienv_propagate_uncaught_exception != nullptr, + [] { + return detail::_format_message ( + "Failed to obtain unmanaged-callers-only pointer to the %s.%s.PropagateUncaughtException method.", + Constants::MONO_ANDROID_ASSEMBLY_NAME, + Constants::JNIENVINIT_FULL_TYPE_NAME + ); + } + ); + log_debug (LOG_DEFAULT, "Calling into managed runtime init"sv); FastTiming::time_call ("JNIEnv.Initialize UCO"sv, initialize, &init); @@ -613,3 +627,13 @@ auto HostCommon::Java_JNI_OnLoad (JavaVM *vm, [[maybe_unused]] void *reserved) n AndroidSystem::init_max_gref_count (); return JNI_VERSION_1_6; } + +void Host::propagate_uncaught_exception (JNIEnv *env, jobject javaThread, jthrowable javaException) noexcept +{ + if (jnienv_propagate_uncaught_exception == nullptr) { + log_warn (LOG_DEFAULT, "propagate_uncaught_exception called before JNIEnvInit.PropagateUncaughtException was initialized"sv); + return; + } + + jnienv_propagate_uncaught_exception (env, javaThread, javaException); +} diff --git a/src/native/clr/include/host/host.hh b/src/native/clr/include/host/host.hh index a05601b9bf6..5e873b1a54a 100644 --- a/src/native/clr/include/host/host.hh +++ b/src/native/clr/include/host/host.hh @@ -20,6 +20,7 @@ namespace xamarin::android { jstring runtimeNativeLibDir, jobjectArray appDirs, jint localDateTimeOffset, jobject loader, jobjectArray assembliesJava, jboolean isEmulator, jboolean haveSplitApks) noexcept; static void Java_mono_android_Runtime_register (JNIEnv *env, jstring managedType, jclass nativeClass, jstring methods) noexcept; + static void propagate_uncaught_exception (JNIEnv *env, jobject javaThread, jthrowable javaException) noexcept; static auto get_timing () -> std::shared_ptr { @@ -53,6 +54,7 @@ namespace xamarin::android { static inline std::shared_ptr _timing{}; static inline bool found_assembly_store = false; static inline jnienv_register_jni_natives_fn jnienv_register_jni_natives = nullptr; + static inline jnienv_propagate_uncaught_exception_fn jnienv_propagate_uncaught_exception = nullptr; static inline jclass java_TimeZone = nullptr; diff --git a/src/native/common/include/managed-interface.hh b/src/native/common/include/managed-interface.hh index f40690d4030..7e34404db63 100644 --- a/src/native/common/include/managed-interface.hh +++ b/src/native/common/include/managed-interface.hh @@ -44,4 +44,5 @@ namespace xamarin::android { using jnienv_initialize_fn = void (*) (JnienvInitializeArgs*); using jnienv_register_jni_natives_fn = void (*)(const jchar *typeName_ptr, int32_t typeName_len, jclass jniClass, const jchar *methods_ptr, int32_t methods_len); + using jnienv_propagate_uncaught_exception_fn = void (*)(JNIEnv *env, jobject javaThread, jthrowable javaException); } From 79509f03a87be1c7fbc28a70171829bf77dec5af Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Mon, 29 Dec 2025 10:35:37 +0100 Subject: [PATCH 2/5] Use ExceptionHandling.RaiseAppDomainUnhandledExceptionEvent instead of Environment.FailFast --- src/Mono.Android/Android.Runtime/JNIEnv.cs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/Mono.Android/Android.Runtime/JNIEnv.cs b/src/Mono.Android/Android.Runtime/JNIEnv.cs index cafd100a1a3..ec76a3f091f 100644 --- a/src/Mono.Android/Android.Runtime/JNIEnv.cs +++ b/src/Mono.Android/Android.Runtime/JNIEnv.cs @@ -104,9 +104,7 @@ internal static void PropagateUncaughtException (IntPtr env, IntPtr javaThreadPt if (RuntimeFeature.IsMonoRuntime) { MonoDroidUnhandledException (innerException ?? javaException); } else if (RuntimeFeature.IsCoreClrRuntime) { - // CoreCLR doesn't have mono_unhandled_exception, so we use Environment.FailFast - // to terminate the process with proper exception information. - Environment.FailFast ("Unhandled Java exception", innerException ?? javaException); + ExceptionHandling.RaiseAppDomainUnhandledExceptionEvent (innerException ?? javaException); } else { throw new NotSupportedException ("Internal error: unknown runtime not supported"); } From 77827ddf553f316ad9a5706b13051318a98918b7 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Mon, 29 Dec 2025 10:38:26 +0100 Subject: [PATCH 3/5] Re-enable intergration test --- tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs | 6 ------ 1 file changed, 6 deletions(-) diff --git a/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs b/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs index b0a1594b404..6d844c89635 100644 --- a/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs +++ b/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs @@ -213,15 +213,9 @@ void Button_ViewTreeObserver_GlobalLayout (object sender, EventArgs e) }, Path.Combine (Root, builder.ProjectDirectory, "startup-logcat.log"), 60), $"Output did not contain {expectedLogcatOutput}!"); } - // TODO: check if AppDomain.CurrentDomain.UnhandledException even works in CoreCLR [Test] public void SubscribeToAppDomainUnhandledException ([Values (AndroidRuntime.MonoVM, AndroidRuntime.CoreCLR)] AndroidRuntime runtime) { - if (runtime == AndroidRuntime.CoreCLR) { - Assert.Ignore ("AppDomain.CurrentDomain.UnhandledException doesn't work in CoreCLR"); - return; - } - proj = new XamarinAndroidApplicationProject () { IsRelease = true, }; From 780f3d114e7f3cc3f4c2c9ec7e17eccc03bf6fcb Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Mon, 29 Dec 2025 12:46:19 +0100 Subject: [PATCH 4/5] Pass UCO function pointer from managed to native code instead of using coreclr_create_delegate --- src/Mono.Android/Android.Runtime/JNIEnvInit.cs | 3 +++ src/native/clr/host/host.cc | 18 ++++-------------- src/native/common/include/managed-interface.hh | 4 +++- 3 files changed, 10 insertions(+), 15 deletions(-) diff --git a/src/Mono.Android/Android.Runtime/JNIEnvInit.cs b/src/Mono.Android/Android.Runtime/JNIEnvInit.cs index 30831394a68..8314aa2708c 100644 --- a/src/Mono.Android/Android.Runtime/JNIEnvInit.cs +++ b/src/Mono.Android/Android.Runtime/JNIEnvInit.cs @@ -35,6 +35,7 @@ internal struct JnienvInitializeArgs { public bool marshalMethodsEnabled; public IntPtr grefGCUserPeerable; public bool managedMarshalMethodsLookupEnabled; + public IntPtr propagateUncaughtExceptionFn; } #pragma warning restore 0649 @@ -163,6 +164,8 @@ internal static unsafe void Initialize (JnienvInitializeArgs* args) xamarin_app_init (args->env, getFunctionPointer); } + args->propagateUncaughtExceptionFn = (IntPtr)(delegate* unmanaged)&PropagateUncaughtException; + SetSynchronizationContext (); } diff --git a/src/native/clr/host/host.cc b/src/native/clr/host/host.cc index 1a3805bb495..7d19c7e9175 100644 --- a/src/native/clr/host/host.cc +++ b/src/native/clr/host/host.cc @@ -557,23 +557,13 @@ void Host::Java_mono_android_Runtime_initInternal ( } ); - log_debug (LOG_ASSEMBLY, "Creating UCO delegate to {}.PropagateUncaughtException"sv, Constants::JNIENVINIT_FULL_TYPE_NAME); - delegate = FastTiming::time_call ("create_delegate for PropagateUncaughtException"sv, create_delegate, Constants::MONO_ANDROID_ASSEMBLY_NAME, Constants::JNIENVINIT_FULL_TYPE_NAME, "PropagateUncaughtException"sv); - jnienv_propagate_uncaught_exception = reinterpret_cast (delegate); - abort_unless ( - jnienv_propagate_uncaught_exception != nullptr, - [] { - return detail::_format_message ( - "Failed to obtain unmanaged-callers-only pointer to the %s.%s.PropagateUncaughtException method.", - Constants::MONO_ANDROID_ASSEMBLY_NAME, - Constants::JNIENVINIT_FULL_TYPE_NAME - ); - } - ); - log_debug (LOG_DEFAULT, "Calling into managed runtime init"sv); FastTiming::time_call ("JNIEnv.Initialize UCO"sv, initialize, &init); + // PropagateUncaughtException is returned from Initialize to avoid an extra create_delegate call + jnienv_propagate_uncaught_exception = init.propagateUncaughtExceptionFn; + abort_unless (jnienv_propagate_uncaught_exception != nullptr, "Failed to obtain unmanaged-callers-only function pointer to the PropagateUncaughtException method."); + if (FastTiming::enabled ()) [[unlikely]] { internal_timing.end_event (); // native to managed internal_timing.end_event (); // total init time diff --git a/src/native/common/include/managed-interface.hh b/src/native/common/include/managed-interface.hh index 7e34404db63..b590fb6210e 100644 --- a/src/native/common/include/managed-interface.hh +++ b/src/native/common/include/managed-interface.hh @@ -14,6 +14,8 @@ namespace xamarin::android { Signals = 0x08, }; + using jnienv_propagate_uncaught_exception_fn = void (*)(JNIEnv *env, jobject javaThread, jthrowable javaException); + // NOTE: Keep this in sync with managed side in src/Mono.Android/Android.Runtime/JNIEnvInit.cs struct JnienvInitializeArgs { JavaVM *javaVm; @@ -33,6 +35,7 @@ namespace xamarin::android { bool marshalMethodsEnabled; jobject grefGCUserPeerable; bool managedMarshalMethodsLookupEnabled; + jnienv_propagate_uncaught_exception_fn propagateUncaughtExceptionFn; }; // Keep the enum values in sync with those in src/Mono.Android/AndroidRuntime/BoundExceptionType.cs @@ -44,5 +47,4 @@ namespace xamarin::android { using jnienv_initialize_fn = void (*) (JnienvInitializeArgs*); using jnienv_register_jni_natives_fn = void (*)(const jchar *typeName_ptr, int32_t typeName_len, jclass jniClass, const jchar *methods_ptr, int32_t methods_len); - using jnienv_propagate_uncaught_exception_fn = void (*)(JNIEnv *env, jobject javaThread, jthrowable javaException); } From 0f1d4196b465f7e622810e911a23c13df20657fe Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Mon, 29 Dec 2025 12:53:20 +0100 Subject: [PATCH 5/5] Update test expectations --- .../MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs b/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs index 6d844c89635..aed9aa41a21 100644 --- a/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs +++ b/tests/MSBuildDeviceIntegration/Tests/InstallAndRunTests.cs @@ -237,7 +237,13 @@ public void SubscribeToAppDomainUnhandledException ([Values (AndroidRuntime.Mono Assert.IsTrue (builder.Install (proj), "Install should have succeeded."); RunProjectAndAssert (proj, builder); - string expectedLogcatOutput = "# Unhandled Exception: sender=System.Object; e.IsTerminating=True; e.ExceptionObject=System.Exception: CRASH"; + string? expectedSender = runtime switch + { + AndroidRuntime.MonoVM => "System.Object", // MonoVM passes the current domain as the sender + AndroidRuntime.CoreCLR => null, // CoreCLR explicitly passes a `null` sender + _ => throw new NotImplementedException($"Test does not support runtime {runtime}"), + }; + string expectedLogcatOutput = $"# Unhandled Exception: sender={expectedSender}; e.IsTerminating=True; e.ExceptionObject=System.Exception: CRASH"; Assert.IsTrue ( MonitorAdbLogcat (CreateLineChecker (expectedLogcatOutput), logcatFilePath: Path.Combine (Root, builder.ProjectDirectory, "startup-logcat.log"), timeout: 60),