Skip to content

Commit 1aa0ea7

Browse files
authored
[Mono.Android] Marshal .NET stack trace to Throwable.getStackTrace() (#8185)
Context: #1198 Context: #1188 (comment) Context: #4877 Context: #4927 (comment) What happens with unhandled exceptions? throw new InvalidOperationException ("oops!"); This is a surprisingly complicated question: If this happens when a debugger is attached, the debugger will get a "first chance notification" at the `throw` site. If execution continues, odds are high that the app will abort if there is a JNI transition in the callstack. If no debugger is attached, then it depends on which thread threw the unhandled exception. If the thread which threw the unhandled exception is a .NET Thread: static void ThrowFromAnotherManagedThread() { var t = new System.Threading.Thread(() => { throw new new Java.Lang.Error ("from another thread?!"); }); t.Start (); t.Join (); } Then .NET will report the unhandled exception, *and* the app will restart: F mono-rt : [ERROR] FATAL UNHANDLED EXCEPTION: System.InvalidOperationException: oops! F mono-rt : at android_unhandled_exception.MainActivity.<>c.<ThrowFromAnotherManagedThread>b__1_0() F mono-rt : at System.Threading.Thread.StartCallback() # app restarts If the thread which threw the unhandled exception is a *Java* thread, which could be the UI thread (e.g. thrown from an `Activity.OnCreate()` override) or via a `Java.Lang.Thread` instance: static void ThrowFromAnotherJavaThread() { var t = new Java.Lang.Thread(() => { throw new InvalidOperationException ("oops!"); }); t.Start (); t.Join (); } Then .NET will report the unhandled exception, *and* the app will *not* restart (which differs from using .NET threads): E AndroidRuntime: Process: com.companyname.android_unhandled_exception, PID: 5436 E AndroidRuntime: android.runtime.JavaProxyThrowable: System.InvalidOperationException: oops! E AndroidRuntime: at android_unhandled_exception.MainActivity.<>c.<ThrowFromAnotherJavaThread>b__2_0() E AndroidRuntime: at Java.Lang.Thread.RunnableImplementor.Run() E AndroidRuntime: at Java.Lang.IRunnableInvoker.n_Run(IntPtr , IntPtr ) E AndroidRuntime: at Android.Runtime.JNINativeWrapper.Wrap_JniMarshal_PP_V(_JniMarshal_PP_V , IntPtr , IntPtr ) E AndroidRuntime: at mono.java.lang.RunnableImplementor.n_run(Native Method) E AndroidRuntime: at mono.java.lang.RunnableImplementor.run(RunnableImplementor.java:31) E AndroidRuntime: at java.lang.Thread.run(Thread.java:1012) I MonoDroid: Android.Runtime.JavaProxyThrowable: Exception_WasThrown, Android.Runtime.JavaProxyThrowable I MonoDroid: I MonoDroid: --- End of managed Android.Runtime.JavaProxyThrowable stack trace --- I MonoDroid: android.runtime.JavaProxyThrowable: System.InvalidOperationException: oops! I MonoDroid: at android_unhandled_exception.MainActivity.<>c.<ThrowFromAnotherJavaThread>b__2_0() I MonoDroid: at Java.Lang.Thread.RunnableImplementor.Run() I MonoDroid: at Java.Lang.IRunnableInvoker.n_Run(IntPtr , IntPtr ) I MonoDroid: at Android.Runtime.JNINativeWrapper.Wrap_JniMarshal_PP_V(_JniMarshal_PP_V , IntPtr , IntPtr ) I MonoDroid: at mono.java.lang.RunnableImplementor.n_run(Native Method) I MonoDroid: at mono.java.lang.RunnableImplementor.run(RunnableImplementor.java:31) I MonoDroid: at java.lang.Thread.run(Thread.java:1012) I MonoDroid: I MonoDroid: --- End of managed Android.Runtime.JavaProxyThrowable stack trace --- I MonoDroid: android.runtime.JavaProxyThrowable: System.InvalidOperationException: oops! I MonoDroid: at android_unhandled_exception.MainActivity.<>c.<ThrowFromAnotherJavaThread>b__2_0() I MonoDroid: at Java.Lang.Thread.RunnableImplementor.Run() I MonoDroid: at Java.Lang.IRunnableInvoker.n_Run(IntPtr , IntPtr ) I MonoDroid: at Android.Runtime.JNINativeWrapper.Wrap_JniMarshal_PP_V(_JniMarshal_PP_V , IntPtr , IntPtr ) I MonoDroid: at mono.java.lang.RunnableImplementor.n_run(Native Method) I MonoDroid: at mono.java.lang.RunnableImplementor.run(RunnableImplementor.java:31) I MonoDroid: at java.lang.Thread.run(Thread.java This "works", until we enter the world of crash logging for later diagnosis and fixing. The problem with our historical approach is that we would "stuff" the .NET stack trace into the "message" of the Java-side `Throwable` instance, and the "message" may not be transmitted as part of the crash logging! (This is noticeable by the different indentation levels for the `at …` lines in the crash output. Three space indents are from the `Throwable.getMessage()` output, while four space indents are from the Java-side stack trace.) We *think* that we can improve this by replacing the Java-side stack trace with a "merged" stack trace which includes both the Java-side and .NET-side stack traces. This does nothing for unhandled exceptions on .NET threads, but does alter the output from Java threads: E AndroidRuntime: FATAL EXCEPTION: Thread-3 E AndroidRuntime: Process: com.companyname.android_unhandled_exception, PID: 12321 E AndroidRuntime: android.runtime.JavaProxyThrowable: [System.InvalidOperationException]: oops! E AndroidRuntime: at android_unhandled_exception.MainActivity+<>c.<ThrowFromAnotherJavaThread>b__2_0(Unknown Source:0) E AndroidRuntime: at Java.Lang.Thread+RunnableImplementor.Run(Unknown Source:0) E AndroidRuntime: at Java.Lang.IRunnableInvoker.n_Run(Unknown Source:0) E AndroidRuntime: at Android.Runtime.JNINativeWrapper.Wrap_JniMarshal_PP_V(Unknown Source:0) E AndroidRuntime: at mono.java.lang.RunnableImplementor.n_run(Native Method) E AndroidRuntime: at mono.java.lang.RunnableImplementor.run(RunnableImplementor.java:31) E AndroidRuntime: at java.lang.Thread.run(Thread.java:1012) I MonoDroid: UNHANDLED EXCEPTION: I MonoDroid: Android.Runtime.JavaProxyThrowable: Exception_WasThrown, Android.Runtime.JavaProxyThrowable I MonoDroid: I MonoDroid: --- End of managed Android.Runtime.JavaProxyThrowable stack trace --- I MonoDroid: android.runtime.JavaProxyThrowable: [System.InvalidOperationException]: oops! I MonoDroid: at android_unhandled_exception.MainActivity+<>c.<ThrowFromAnotherJavaThread>b__2_0(Unknown Source:0) I MonoDroid: at Java.Lang.Thread+RunnableImplementor.Run(Unknown Source:0) I MonoDroid: at Java.Lang.IRunnableInvoker.n_Run(Unknown Source:0) I MonoDroid: at Android.Runtime.JNINativeWrapper.Wrap_JniMarshal_PP_V(Unknown Source:0) I MonoDroid: at mono.java.lang.RunnableImplementor.n_run(Native Method) I MonoDroid: at mono.java.lang.RunnableImplementor.run(RunnableImplementor.java:31) I MonoDroid: at java.lang.Thread.run(Thread.java:1012) I MonoDroid: I MonoDroid: --- End of managed Android.Runtime.JavaProxyThrowable stack trace --- I MonoDroid: android.runtime.JavaProxyThrowable: [System.InvalidOperationException]: oops! I MonoDroid: at android_unhandled_exception.MainActivity+<>c.<ThrowFromAnotherJavaThread>b__2_0(Unknown Source:0) I MonoDroid: at Java.Lang.Thread+RunnableImplementor.Run(Unknown Source:0) I MonoDroid: at Java.Lang.IRunnableInvoker.n_Run(Unknown Source:0) I MonoDroid: at Android.Runtime.JNINativeWrapper.Wrap_JniMarshal_PP_V(Unknown Source:0) I MonoDroid: at mono.java.lang.RunnableImplementor.n_run(Native Method) I MonoDroid: at mono.java.lang.RunnableImplementor.run(RunnableImplementor.java:31) I MonoDroid: at java.lang.Thread.run(Thread.java:1012) Note how `at …` is always a four-space indent and always lines up. *Hopefully* this means that crash loggers can provide more useful information. TODO: * Create an "end-to-end" test which uses an actual crash logger (which one?) in order to better understand what the "end user" experience is. * The "merged" stack trace always places the managed stack trace above the Java-side stack trace. This means things will look "weird"/"wrong" if you have an *intermixed* stack trace, e.g. (Java code calls .NET code which calls Java code)+ which eventually throws from .NET.
1 parent 4061928 commit 1aa0ea7

File tree

4 files changed

+109
-12
lines changed

4 files changed

+109
-12
lines changed

src/Mono.Android/Android.Runtime/AndroidRuntime.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ public override void RaisePendingException (Exception pendingException)
7979
{
8080
var je = pendingException as JavaProxyThrowable;
8181
if (je == null) {
82-
je = new JavaProxyThrowable (pendingException);
82+
je = JavaProxyThrowable.Create (pendingException);
8383
}
8484
var r = new JniObjectReference (je.Handle);
8585
JniEnvironment.Exceptions.Throw (r);
Lines changed: 67 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,83 @@
11
using System;
2+
using System.Diagnostics;
3+
using System.Reflection;
4+
5+
using StackTraceElement = Java.Lang.StackTraceElement;
26

37
namespace Android.Runtime {
48

5-
class JavaProxyThrowable : Java.Lang.Error {
9+
sealed class JavaProxyThrowable : Java.Lang.Error {
610

711
public readonly Exception InnerException;
812

9-
public JavaProxyThrowable (Exception innerException)
10-
: base (GetDetailMessage (innerException))
13+
JavaProxyThrowable (string message, Exception innerException)
14+
: base (message)
1115
{
1216
InnerException = innerException;
1317
}
1418

15-
static string GetDetailMessage (Exception innerException)
19+
public static JavaProxyThrowable Create (Exception innerException)
20+
{
21+
if (innerException == null) {
22+
throw new ArgumentNullException (nameof (innerException));
23+
}
24+
25+
// We prepend managed exception type to message since Java will see `JavaProxyThrowable` instead.
26+
var proxy = new JavaProxyThrowable ($"[{innerException.GetType ()}]: {innerException.Message}", innerException);
27+
28+
try {
29+
proxy.TranslateStackTrace ();
30+
} catch (Exception ex) {
31+
// We shouldn't throw here, just try to do the best we can do
32+
Console.WriteLine ($"JavaProxyThrowable: translation threw an exception: {ex}");
33+
proxy = new JavaProxyThrowable (innerException.ToString (), innerException);
34+
}
35+
36+
return proxy;
37+
}
38+
39+
void TranslateStackTrace ()
1640
{
17-
if (innerException == null)
18-
throw new ArgumentNullException ("innerException");
41+
var trace = new StackTrace (InnerException, fNeedFileInfo: true);
42+
if (trace.FrameCount <= 0) {
43+
return;
44+
}
45+
46+
StackTraceElement[]? javaTrace = null;
47+
try {
48+
javaTrace = GetStackTrace ();
49+
} catch (Exception ex) {
50+
// Report...
51+
Console.WriteLine ($"JavaProxyThrowable: obtaining Java stack trace threw an exception: {ex}");
52+
// ..but ignore
53+
}
54+
55+
56+
StackFrame[] frames = trace.GetFrames ();
57+
int nElements = frames.Length + (javaTrace?.Length ?? 0);
58+
StackTraceElement[] elements = new StackTraceElement[nElements];
59+
60+
for (int i = 0; i < frames.Length; i++) {
61+
StackFrame managedFrame = frames[i];
62+
MethodBase? managedMethod = managedFrame.GetMethod ();
63+
64+
var throwableFrame = new StackTraceElement (
65+
declaringClass: managedMethod?.DeclaringType?.FullName,
66+
methodName: managedMethod?.Name,
67+
fileName: managedFrame?.GetFileName (),
68+
lineNumber: managedFrame?.GetFileLineNumber () ?? -1
69+
);
70+
71+
elements[i] = throwableFrame;
72+
}
73+
74+
if (javaTrace != null) {
75+
for (int i = frames.Length; i < nElements; i++) {
76+
elements[i] = javaTrace[i - frames.Length];
77+
}
78+
}
1979

20-
return innerException.ToString ();
80+
SetStackTrace (elements);
2181
}
2282
}
2383
}

src/Mono.Android/Java.Lang/Throwable.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -253,7 +253,7 @@ public static Throwable FromException (System.Exception e)
253253
if (e is Throwable)
254254
return (Throwable) e;
255255

256-
return new Android.Runtime.JavaProxyThrowable (e);
256+
return Android.Runtime.JavaProxyThrowable.Create (e);
257257
}
258258

259259
public static System.Exception ToException (Throwable e)

tests/Mono.Android-Tests/System/ExceptionTest.cs

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
using System;
2+
using System.Diagnostics;
23
using System.Globalization;
4+
using System.Reflection;
35

46
using Android.App;
57
using Android.Content;
@@ -17,18 +19,53 @@ static Java.Lang.Throwable CreateJavaProxyThrowable (Exception e)
1719
var JavaProxyThrowable_type = typeof (Java.Lang.Object)
1820
.Assembly
1921
.GetType ("Android.Runtime.JavaProxyThrowable");
20-
return (Java.Lang.Throwable) Activator.CreateInstance (JavaProxyThrowable_type, e);
22+
MethodInfo? create = JavaProxyThrowable_type.GetMethod (
23+
"Create",
24+
BindingFlags.Static | BindingFlags.Public,
25+
new Type[] { typeof (Exception) }
26+
);
27+
28+
Assert.AreNotEqual (null, create, "Unable to find the Android.Runtime.JavaProxyThrowable.Create(Exception) method");
29+
return (Java.Lang.Throwable)create.Invoke (null, new object[] { e }); // Don't append Java stack trace
2130
}
2231

2332
[Test]
2433
public void InnerExceptionIsSet ()
2534
{
26-
var ex = new InvalidOperationException ("boo!");
27-
using (var source = new Java.Lang.Throwable ("detailMessage", CreateJavaProxyThrowable (ex)))
35+
Exception ex;
36+
try {
37+
throw new InvalidOperationException ("boo!");
38+
} catch (Exception e) {
39+
ex = e;
40+
}
41+
42+
using (Java.Lang.Throwable proxy = CreateJavaProxyThrowable (ex))
43+
using (var source = new Java.Lang.Throwable ("detailMessage", proxy))
2844
using (var alias = new Java.Lang.Throwable (source.Handle, JniHandleOwnership.DoNotTransfer)) {
45+
CompareStackTraces (ex, proxy);
2946
Assert.AreEqual ("detailMessage", alias.Message);
3047
Assert.AreSame (ex, alias.InnerException);
3148
}
3249
}
50+
51+
void CompareStackTraces (Exception ex, Java.Lang.Throwable throwable)
52+
{
53+
var managedTrace = new StackTrace (ex);
54+
StackFrame[] managedFrames = managedTrace.GetFrames ();
55+
Java.Lang.StackTraceElement[] javaFrames = throwable.GetStackTrace ();
56+
57+
// Java
58+
Assert.IsTrue (javaFrames.Length >= managedFrames.Length,
59+
$"Java should have at least as many frames as .NET does; java({javaFrames.Length}) < managed({managedFrames.Length})");
60+
for (int i = 0; i < managedFrames.Length; i++) {
61+
var mf = managedFrames[i];
62+
var jf = javaFrames[i];
63+
64+
Assert.AreEqual (mf.GetMethod ()?.Name, jf.MethodName, $"Frame {i}: method names differ");
65+
Assert.AreEqual (mf.GetMethod ()?.DeclaringType.FullName, jf.ClassName, $"Frame {i}: class names differ");
66+
Assert.AreEqual (mf.GetFileName (), jf.FileName, $"Frame {i}: file names differ");
67+
Assert.AreEqual (mf.GetFileLineNumber (), jf.LineNumber, $"Frame {i}: line numbers differ");
68+
}
69+
}
3370
}
3471
}

0 commit comments

Comments
 (0)