From 9570e2b87a8c54f5ff82d9baab5f5d5ad9cd2241 Mon Sep 17 00:00:00 2001 From: Jonathan Pryor Date: Fri, 26 Jun 2020 14:47:02 -0400 Subject: [PATCH] [docs] Document Exception Handling semantics --- .../project-docs/exception-handling.md | 240 ++++++++++++++++++ 1 file changed, 240 insertions(+) create mode 100644 Documentation/project-docs/exception-handling.md diff --git a/Documentation/project-docs/exception-handling.md b/Documentation/project-docs/exception-handling.md new file mode 100644 index 00000000000..73edad79d19 --- /dev/null +++ b/Documentation/project-docs/exception-handling.md @@ -0,0 +1,240 @@ +# Exception Handling + +Outside of Xamarin.Android, .NET exception handling +[when a debugger is attached][0] potentially involves walking the runtime stack +*twice*, involving three interactions between the runtime and the debugger: + + 1. When the exception is first thrown, a *first chance notification* is raised + in the debugger, which provides the debugger with an opportunity to handle + breakpoint or single-step exceptions. + + 2. If the debugger doesn't handle or continues execution from the first chance + notification, then: + + a. The runtime will attempt to "find a frame-based exception handler that + handles the exception". + + b. If no frame-based exception handler is found, then a + *last-chance notification* is raised in the debugger. + + 3. If the debugger doesn't handle the last chance notification, then execution + will continue, causing the stack to be unwound. + +The first stack walk is step 2(a), while the second stack walk is step (3). + +Within Xamarin.Android, if a thread call-stack doesn't involve any calls to +or from Java code, the same semantics are present. + +When a thread call-stack involves calls to or from Java code, the above +"two-pass" semantics cannot be supported, as the Java Native Interface, which +is used to support calls to or from Java code, does not support them. +A cross-VM runtime stack can only be walked *while being unwound*; there is +no way to ask "is there any method which will handle this exception" before +code is executed and the stack is unwound. + + +## The Setup + +Consider the following Java code: + +```java +// Java +public class Demo { + public static void run(Runnable r) { + /* setup code */ + try { + r.run(); + } finally { + /* cleanup code */ + System.out.println("Demo.run() finally block!"); + } + } +} +``` + +`Demo.run()` is bound as: + +```csharp +partial class Demo { + public static unsafe void Run (global::Java.Lang.IRunnable p0) + { + const string __id = "run.(Ljava/lang/Runnable;)V"; + JniArgumentValue* __args = stackalloc JniArgumentValue [1]; + __args [0] = new JniArgumentValue ((p0 == null) ? IntPtr.Zero : ((global::Java.Lang.Object) p0).Handle); + _members.StaticMethods.InvokeVoidMethod (__id, __args); + } +} + +``` + +Now imagine the above Java class has been bound and is used from a +Xamarin.Android app: + +```csharp +Action a = () => { + throw new Exception ("Hm…"); +}; +Demo.Run(new Java.Lang.Runnable(a)); +``` + + +## Exception Handling *Without* A Debugger + +When a debugger is *not* attached, the following happens: + + 1. `Java.Lang.Runnable` has a Java Callable Wrapper generated at app + build time, `mono.java.lang.Runnable`, which implements the + `java.lang.Runnable` Java interface type. + + 2. When the `Java.Lang.Runnable` is created, a `mono.java.lang.Runnable` + instance is also created, and the two instances are associated with each other. + + 3. The `Demo.Run()` invocation invokes the Java `Demo.run()` method, passing + along the `mono.java.lang.Runnable` instance created in (2). + + 4. The `r.run()` invocation within `Demo.run()` eventually invokes the method + `Java.Lang.IRunnableInvoker.n_Run()`: + + ```csharp + static void n_Run (IntPtr jnienv, IntPtr native__this) + { + var __this = global::Java.Lang.Object.GetObject (jnienv, native__this, JniHandleOwnership.DoNotTransfer)!; + __this.Run (); + } + ``` + + 5. *However*, invocation of `n_Run()` is wrapped in a [runtime-generated][1] + `try`/`catch` block, which is equivalent to: + + ```csharp + static void Call_n_Run(IntPtr jnienv, IntPtr native__this) + { + try { + IRunnableInvoker.n_Run(jnienv, native__this); + } + catch (Exception e) { + AndroidEnvironment.UnhandledException(e); + } + } + ``` + + [`AndroidEnvironment.UnhandledException()`][2] is responsible for calling + the [`JNIEnv::Throw()`][3] JNI method. + + 6. At this point in time, the runtime call-stack is: + + * Top-level managed method, which calls > + * Java `Demo.run()` method, which calls > + * Runtime-generated `try`/`catch` block, which calls > + * C# `IRunnableInvoker.n_Run()` method, which calls > + * C# `Action` delegate. + + 7. The `Action` delegate is invoked, causing a C# exception to be thrown. + + 8. The exception thrown in (7) is caught by the method in (5). The managed + exception type is wrapped into a `JavaProxyThrowable` instance, which is + then raised in the Java code. + + 9. The Java `finally` block executes, and then the `Demo.run()` method is + unwound by the JVM. + +10. The `Demo.Run()` binding sees the "pending exception" from the JNI call, + "unwraps" the `JavaProxyThrowable` to obtain the original `System.Exception` + then raises the `System.Exception`. + +11. The process exits, because the `System.Exception` isn't handled. :-) + + +## Exception Handling *With* A Debugger + +When the debugger is attached, runtime behavior differs significantly. +Steps (1) through (4) are the same, then: + + 5. Invocation of `n_Run()` is wrapped in a [runtime-generated][1] + `try`/`catch` block which pulls in the debugger via an + *exception filter*, and is equivalent to: + + ```csharp + static void Call_n_Run(IntPtr jnienv, IntPtr native__this) + { + try { + IRunnableInvoker.n_Run(jnienv, native__this); + } + catch (Exception e) when (Debugger.Mono_UnhandledException(e)) { + AndroidEnvironment.UnhandledException(e); + } + } + ``` + + 6. The runtime call-stack is unchanged relative to execution without a debugger. + + 7. The `Action` delegate is invoked, causing a C# exception to be thrown. + + 8. No *first chance notification* is raised. Instead, Mono will + "find a frame-based exception handler that handles the exception," + and as part of this process will execute any exception filters. This + causes `Debugger.Mono_UnhandledException()` to be executed, which is what + triggers the "**System.Exception** has been thrown" message within the + debugger. + + If you look at the *Call Stack* Debug pad within Visual Studio for Mac, + `System.Diagnostics.Debugger.Mono_UnhandledException_internal()` and + `System.Diagnostics.Debugger.Mono_UnhandledException()` are the topmost + call stack entries. + + 9. The Java `finally` block within `Demo.run()` *has not executed yet*. + + If `Demo.run()` had a `catch(Throwable)` block instead of a `finally` + block, it likewise (1) would not have executed yet, and (2) will not + participate in the stack walking to determine whether or not the exception + is handled or unhandled in the first place. + +10. The exception is not yet "pending" in Java either, so it is safe to invoke + Java code in e.g. the Immediate window. + +11. If execution is continued, e.g. via **Continue Debugging**, then + `AndroidEnvironment.UnhandledException()` will be executed, causing the + exception to be wrapped and become a "pending exception" within Java code. + After this point, any invocation of Java code from the debugger will + *immediately* cause the process to abort: + + JNI DETECTED ERROR IN APPLICATION: JNI … called with pending exception android.runtime.JavaProxyThrowable: System.Exception: Hm… + +12. ***Furthermore***, *No* Java code can ever again execute within the process. + Once execution is continued, *Mono* will be unwinding the call stack + *without involvement of the Java VM*. + + The `finally` block within `Debug.run()` hasn't executed yet, and will + *never* execute. In particular, the `System.out.println()` message isn't + visible in `adb logcat`! + + If `Debug.run()` instead had a `catch` block, it will similarly never be + executed. + +13. Execution then "breaks" at the managed `Debug.Run()` method. At this point + there is a pending exception within Java; any invocations of Java code from + the debugger will *immediately* cause the process to abort. + + +14. If execution is continued again, the process will exit. + + Unexpectedly (2020-06-26), the exit is *also* due to a JNI error: + + JNI DETECTED ERROR IN APPLICATION: JNI NewString called with pending exception android.runtime.JavaProxyThrowable: System.Exception: Hm… + + + +[0]: https://docs.microsoft.com/en-us/windows/win32/debug/debugger-exception-handling +[1]: https://github.com/xamarin/xamarin-android/blob/402ae221be90fdb4b48c2aeb29170b745c30f60b/src/Mono.Android/Android.Runtime/JNINativeWrapper.cs#L34-L97 +[2]: https://github.com/xamarin/xamarin-android/blob/402ae221be90fdb4b48c2aeb29170b745c30f60b/src/Mono.Android/Android.Runtime/AndroidEnvironment.cs#L115-L129 +[3]: https://docs.oracle.com/javase/8/docs/technotes/guides/jni/spec/functions.html#Throw + + +--- + +Internal context: + + * https://bugzilla.xamarin.com/show_bug.cgi?id=7634 + * https://github.com/xamarin/monodroid/commit/b0f85970102d43bab9cd860a8e8884d136d766b3 + * https://github.com/xamarin/monodroid/commit/a9697ca2ac026b960b347a925fbe414efe3876f7 + * https://github.com/xamarin/monodroid/commit/12a012e00b4533d586ef31ced33351b63c9de883