Few cents about my commits

Tutorial: Crash Reporters and java exceptions

|

Third party native SDK might report crash from following sources:

  • by sending crash report stored on device;
  • signals;
  • native unhandled exceptions.

Last two cases require special consideration to be taken to let SDK receive required events and keep RoboVM operations as expected.

Sending crash reports stored on device

Fabric/Crashlytic did/does this way. Once app crashed – nothing will be sent. On next launch it will pick up crash log and deliver it.

Signals - initializing SDKs right

Crash reporting SDK most of cases registers for signal to receive callback once crash situation happens. There could be several signals intercepted and SIGSEGV is one that always intercepted. Its segmentation fault or access violation fault conditions. Often this mean that EXC_BAD_ACCESS happened. Example of such report is bellow:

Exception Type:  EXC_BAD_ACCESS (SIGSEGV)
Exception Subtype: KERN_INVALID_ADDRESS at 0x0000000000000000
Termination Signal: Segmentation fault: 11
Termination Reason: Namespace SIGNAL, Code 0xb
Terminating Process: exc handler [0]
Triggered by Thread:  0

Signals also special case for RoboVM. It also intercepts SIGSEGV for special cases:

  • null pointer exception
  • stack overflow exception.

RoboVM installs signal handler during initialization. Any SDK initialized from RoboVM code will cause SDK’s handler to be set instead of RoboVMs one. This means that RoboVM will not be able to handle null pointer exception and application will be terminated.

Example of code that will cause crash

public static void testNPE() {
    String s = null;
    try {
        s.toString();
    } catch (NullPointerException e) {
        e.printStackTrace();
    }
}

This valid java code will cause application to be terminated. As NPE will not be handled by RoboVM but SDK. And for SDK it pretty valid failure case and subject for abort();

Proper way to initialize SDK that uses Signals

If SDK uses signal handler (most of crash reporting ones do) it has to be initialized in special code block:

Signals.installSignals(() -> {
    MySignalSDK.init("SDK-KEY");
});

Signals.installSignals calls InstallSignalsCallback.install(). User has to initialize all SDKs inside this method. And as final Signals.installSignals will restore RoboVM signals.

Native unhandled exceptions

SDKs also register themselves to receive unhandled exceptions with NSSetUncaughtExceptionHandler. It handles all exception that are being generated by native code and NSException in particular (all that goes through objc_exception_throw). SDKs are able to pick up useful information from exceptions such as reason and deliver it to their backend.

Unhandled RoboVM Java exceptions

Exceptions in RoboVM java code are not ObjC exceptions and will not go to objc_exception_throw by default. Default behavior in java is to terminate thread due unhandled exception. So if this happens in:

  • main thread – application will exit with negative exit code;
  • non-main thread – thread will die;

It is up to developer how to deal with this. There is Thread.UncaughtExceptionHandler as well as Thread.setDefaultUncaughtExceptionHandler. Best case scenario is to capture these and deliver with crash reporter API. Fast way scenario for mobile apps – it is to crash and allow dev to fix bug by finding it in crash report.

Fast way: turning Java exception into NSException and crashing App

Following API will do all needed: NSException.registerDefaultJavaUncaughtExceptionHandler(). Lets check what it does:

/**
 * Registers a default java uncaught exception handler that forwards exceptions to RoboVM's signal handlers.
 * Use this if you want Java exceptions to be logged by crash reporters.
 */
public static void registerDefaultJavaUncaughtExceptionHandler() {
    Thread.setDefaultUncaughtExceptionHandler(new java.lang.Thread.UncaughtExceptionHandler() {
        @Override
        public void uncaughtException (Thread thread, Throwable ex) {
            StringWriter sw = new StringWriter();
            PrintWriter pw = new PrintWriter(sw);
            ex.printStackTrace(pw);
            pw.flush();
            Foundation.log(sw.toString());
            NSException exception = new NSException(ex.getClass().getName(), sw.toString(), new NSDictionary<>());
            if (NSThread.getCurrentThread().isMainThread()) {
                exception.raise();
            } else {
                long handler = getUncaughtExceptionHandler();
                callUncaughtExceptionHandler(handler, exception);
                // We should never get to this line!
            }
        }
    });
}

Long story short:

  • it calls Exception.printStackTrace() to receive exception stack trace as string;
  • it creates NSException with exception information and stacktrace put into reason parameters;
  • raises NSException and allows SDK to receive this exception as native one and report it.

Whats wrong with reported Java exception

It will contain wrong call stack. In general there will be call stack to from VM start to default exception handler. Crash call stack will be available only in reason field of NSException.

0   CoreFoundation   0x182987164 __exceptionPreprocess + 124
1   libobjc.A.dylib  0x181bd0528 objc_exception_throw + 55
2   CoreFoundation   0x182986e2c -[NSException raise] + 11
3   SampleApp        0x1014c2f10 [J]org.robovm.objc.$M.void_objc_msgSendSuper+ 18083600 (Lorg/robovm/objc/ObjCSuper;Lorg/robovm/objc/Selector;)V + 243
4   SampleApp        0x1014c6cc4 [J]org.robovm.objc.$M.void_objc_msgSend_instance+ 18099396 (Lorg/robovm/apple/foundation/NSObject;Lorg/robovm/objc/Selector;)V + 167
5   SampleApp        0x1014c0bb0 [j]org.robovm.objc.$M.void_objc_msgSend_instance+ 18074544 (Lorg/robovm/apple/foundation/NSObject;Lorg/robovm/objc/Selector;)V[clinit] + 63
6   SampleApp        0x100e631d0 [j]org.robovm.objc.$M.void_objc_msgSend_instance(Lorg/robovm/apple/foundation/NSObject;Lorg/robovm/objc/Selector;)V[Invokestatic+ 11399632 (org/robovm/apple/foundation/NSException)] + 11
7   SampleApp        0x100e64074 [J]org.robovm.apple.foundation.NSException.raise+ 11403380 ()V + 83
8   SampleApp        0x100e65590 [j]org.robovm.apple.foundation.NSException.raise()V[Invokevirtual+ 11408784 (org/robovm/apple/foundation/NSException$1,org/robovm/apple/foundation/NSException$NSExceptionWrap)] + 11
9   SampleApp        0x100e65890 [J]org.robovm.apple.foundation.NSException$1.uncaughtException+ 11409552 (Ljava/lang/Thread;Ljava/lang/Throwable;)V + 547
10  SampleApp        0x1006fe670 [j]java.lang.Thread$UncaughtExceptionHandler.uncaughtException(Ljava/lang/Thread;Ljava/lang/Throwable;)V[Invokeinterface+ 3647088 (java/lang/ThreadGroup)] + 11
11  SampleApp        0x100705df4 [J]java.lang.ThreadGroup.uncaughtException+ 3677684 (Ljava/lang/Thread;Ljava/lang/Throwable;)V + 351
12  SampleApp        0x101576cf4 _call0 + 83
13  SampleApp        0x101560a0c callVoidMethod + 18729484 (method.c:623)
14  SampleApp        0x1015604c4 rvmCallVoidInstanceMethodA + 18728132 (method.c:722)
15  SampleApp        0x101560a8c rvmCallVoidInstanceMethodV + 18729612 (method.c:728)
16  SampleApp        0x101560e70 rvmCallVoidInstanceMethod + 18730608 (method.c:734)
17  SampleApp        0x1015760ac threadExitUncaughtException + 18817196 (thread.c:375)
18  SampleApp        0x101574ebc detachThread + 18812604 (thread.c:404)
19  SampleApp        0x101574de4 rvmDetachCurrentThread + 18812388 (thread.c:505)
20  SampleApp        0x101553cb8 rvmDestroyVM + 18676920 (init.c:513)
21  SampleApp        0x101553bec rvmRun + 18676716 (init.c:503)
22  SampleApp        0x101540284 bcmain + 18596484 (bc.c:97)
23  SampleApp        0x1015401b0 main + 18596272 (bc.c:103)

As result:

  • if SDK doesn’t report ‘reason’ from NSException then crash call stack will be not available;
  • Exception reason is not stored in crash report file on device. So these will be not useful;

What can be fixed with Java exception report

Not much. NSException.registerDefaultJavaUncaughtExceptionHandler() can be modified to throw subclass of NSException which will return proper PC addresses in ‘NSException.getCallStackReturnAddresses()’. Similar approach was described in RoboVM/RoboVM #540. This will help with SDKs that picks crash information from NSException as it will be filled with proper addresses of crash (and not related to UncaughtExceptionHandler).
But crash files on devise still will contain stack traces to UncaughtExceptionHandler. As objc_exception_throw doesn’t use stack addresses from NSException objects but obtains ones with backtrace call (proper way).

Artifacts from ‘NSException.getCallStackReturnAddresses()’ experiment

I did quick and dirty test of providing addresses in NSException, it is just simple POC as it includes Java SDK classes modification and is not final:

===================================================================
--- compiler/rt/libcore/luni/src/main/java/java/lang/StackTraceElement.java	(date 1518615691000)
+++ compiler/rt/libcore/luni/src/main/java/java/lang/StackTraceElement.java	(date 1518791455000)
@@ -40,6 +40,8 @@

     int lineNumber;

+    long nativePC;
+
     /**
      * Constructs a new {@code StackTraceElement} for a specified execution
      * point.
@@ -73,10 +75,11 @@
      * Private constructor used by RoboVM only.
      */
     @SuppressWarnings("unused")
-    private StackTraceElement(Class<?> cls, String method, String file, int line) {
+    private StackTraceElement(Class<?> cls, String method, String file, int line, long nativePC) {
         this(cls.getName(), method, file, line);
+        this.nativePC = nativePC;
     }

     /**
      * Compares this instance with the specified object and indicates if they
      * are equal. In order to be equal, the following conditions must be
@@ -176,6 +179,17 @@
         return (methodName == null) ? "<unknown method>" : methodName;
     }

+    /**
+     * RoboVM: returns the memory address of native PC this {@code
+     * StackTraceElement} belongs to.
+     * this functionality is used to convert Java stack frames to native exception (e.g. NSException)
+     * @return memory address of native frame
+     */
+    public long getNativePC() {
+        return nativePC;
+    }
+
+
     @Override
     public int hashCode() {
         /*
Index: compiler/vm/core/src/method.c
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
--- compiler/vm/core/src/method.c	(date 1518615691000)
+++ compiler/vm/core/src/method.c	(date 1518780431000)
@@ -117,7 +117,7 @@
         return FALSE;
     }
     java_lang_StackTraceElement_constructor = rvmGetInstanceMethod(env, java_lang_StackTraceElement, "<init>",
-                                      "(Ljava/lang/Class;Ljava/lang/String;Ljava/lang/String;I)V");
+                                      "(Ljava/lang/Class;Ljava/lang/String;Ljava/lang/String;IJ)V");
     if (!java_lang_StackTraceElement_constructor) {
         return FALSE;
     }
@@ -395,7 +395,7 @@
     if (!array) return NULL;

     if (length > 0) {
-        jvalue args[4];
+        jvalue args[5];
         index = first;
         jint i;
         for (i = 0; i < length; i++) {
@@ -409,6 +409,7 @@
                 return NULL;
             }
             args[3].i = frame->lineNumber;
+            args[4].l = frame->pc;
             array->values[i] = rvmNewObjectA(env, java_lang_StackTraceElement,
                 java_lang_StackTraceElement_constructor, args);
             if (!array->values[i]) return NULL;
Index: compiler/cocoatouch/src/main/java/org/robovm/apple/foundation/NSException.java
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
--- compiler/cocoatouch/src/main/java/org/robovm/apple/foundation/NSException.java	(date 1518615691000)
+++ compiler/cocoatouch/src/main/java/org/robovm/apple/foundation/NSException.java	(date 1518777686000)
@@ -105,7 +105,7 @@
                 ex.printStackTrace(pw);
                 pw.flush();
                 Foundation.log(sw.toString());
-                NSException exception = new NSException(ex.getClass().getName(), sw.toString(), new NSDictionary<>());
+                NSException exception = new NSExceptionWrap(ex, sw.toString());
                 if (NSThread.getCurrentThread().isMainThread()) {
                     exception.raise();
                 } else {
@@ -135,4 +135,30 @@
     @Method(selector = "initWithCoder:")
     protected native @Pointer long init(NSCoder decoder);
     /*</methods>*/
+
+    public static class NSExceptionWrap extends NSException {
+        private NSMutableArray<NSNumber> nativeCallStack;
+        private NSMutableArray<NSString> nativeCallStackSymbolds;
+        public NSExceptionWrap(Throwable ex, String aReason) {
+            super(ex.getClass().getName(), aReason, new NSDictionary<>());
+            nativeCallStack = new NSMutableArray<>();
+            nativeCallStackSymbolds = new NSMutableArray<>();
+            for (StackTraceElement ste : ex.getStackTrace()) {
+                nativeCallStack.add(NSNumber.valueOf(ste.getNativePC()));
+                nativeCallStackSymbolds.add(ste.toString());
+            }
+        }
+
+        @Override
+        public NSArray<NSNumber> getCallStackReturnAddresses() {
+            return nativeCallStack;
+        }
+
+        @Override
+        public NSArray<NSString> getCallStackSymbols() {
+            return nativeCallStackSymbolds;
+        }
+    }
 }

Comments