Skip to content

Commit 8902cc2

Browse files
authored
react-native: wait for the unhandled java exception report before process death (#393)
In the default configuration an unhandled Java exception report never left the device: the handler killed the process right after handing the exception to JS.
1 parent 71bb842 commit 8902cc2

3 files changed

Lines changed: 166 additions & 12 deletions

File tree

‎packages/react-native/android/src/main/java/backtrace/library/BacktraceAndroidBackgroundUnhandledExceptionHandler.java‎

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313

1414
import java.io.PrintWriter;
1515
import java.io.StringWriter;
16+
import java.util.concurrent.CountDownLatch;
17+
import java.util.concurrent.TimeUnit;
1618

1719
/**
1820
* Handle unhandled Android exceptions from background threads.
@@ -35,6 +37,13 @@ public class BacktraceAndroidBackgroundUnhandledExceptionHandler extends ReactCo
3537
* React native callback method
3638
*/
3739
private Callback _callback;
40+
41+
private boolean _callbackInvoked = false;
42+
43+
private final CountDownLatch _reportProcessed = new CountDownLatch(1);
44+
45+
private static final long REPORT_PROCESSED_TIMEOUT_MS = 5000;
46+
3847
public static final String NAME = "BacktraceAndroidBackgroundUnhandledExceptionHandler";
3948

4049
public BacktraceAndroidBackgroundUnhandledExceptionHandler(ReactApplicationContext reactContext) {
@@ -57,20 +66,40 @@ public void start(Callback callback) {
5766
}
5867

5968
@Override
60-
public void uncaughtException(final Thread thread, final Throwable throwable) {
69+
public synchronized void uncaughtException(final Thread thread, final Throwable throwable) {
6170
_lastCaughtBackgroundExceptionThread = thread;
6271
_lastCaughtBackgroundException = throwable;
6372
if (_shouldStop == true) {
6473
finish();
6574
return;
6675
}
67-
if (throwable instanceof Exception) {
76+
// React Native callbacks are single-use; invoking one twice throws.
77+
if (throwable instanceof Exception && !_callbackInvoked) {
78+
_callbackInvoked = true;
6879
String throwableType = throwable.getClass().getName();
6980
_callback.invoke(throwableType, throwable.getMessage(), stackTraceToString(throwable.getStackTrace()));
81+
waitForReportProcessing();
7082
}
7183
finish();
7284
}
7385

86+
private void waitForReportProcessing() {
87+
try {
88+
if (!_reportProcessed.await(REPORT_PROCESSED_TIMEOUT_MS, TimeUnit.MILLISECONDS)) {
89+
Log.d(LOG_TAG, "Timed out waiting for the unhandled exception report to be processed.");
90+
}
91+
} catch (InterruptedException ex) {
92+
Log.d(LOG_TAG, "Interrupted while waiting for the unhandled exception report to be processed.");
93+
}
94+
}
95+
96+
// not synchronized: the crashing thread holds this monitor while it waits
97+
@ReactMethod
98+
public void reportProcessed() {
99+
Log.d(LOG_TAG, "Unhandled exception report processed by the JavaScript side.");
100+
_reportProcessed.countDown();
101+
}
102+
74103
private static String stackTraceToString(StackTraceElement[] stackTrace) {
75104
StringWriter sw = new StringWriter();
76105
printStackTrace(stackTrace, new PrintWriter(sw));

‎packages/react-native/src/handlers/android/AndroidUnhandledExceptionHandler.ts‎

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,16 +17,22 @@ export class AndroidUnhandledExceptionHandler extends UnhandledExceptionHandler
1717
return;
1818
}
1919

20-
this._unhandledExceptionHandler.start((classifier: string, message: string, stackTrace: string) => {
21-
const report = new BacktraceReport(
22-
new AndroidUnhandledException(classifier, message, stackTrace),
23-
{
24-
'error.type': 'Unhandled exception',
25-
},
26-
[],
27-
);
28-
report.addStackTrace('main', this._androidStackTraceConverter.convert(stackTrace));
29-
client.send(report);
20+
this._unhandledExceptionHandler.start(async (classifier: string, message: string, stackTrace: string) => {
21+
try {
22+
const report = new BacktraceReport(
23+
new AndroidUnhandledException(classifier, message, stackTrace),
24+
{
25+
'error.type': 'Unhandled exception',
26+
},
27+
[],
28+
);
29+
report.addStackTrace('main', this._androidStackTraceConverter.convert(stackTrace));
30+
await client.send(report);
31+
} catch {
32+
// nothing to recover: the process is dying
33+
} finally {
34+
this._unhandledExceptionHandler.reportProcessed?.();
35+
}
3036
});
3137
}
3238

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import type { BacktraceReport } from '@backtrace/sdk-core';
2+
import type { BacktraceClient } from '../src/BacktraceClient';
3+
4+
jest.mock('react-native', () => ({
5+
NativeModules: {},
6+
Platform: {
7+
OS: 'android',
8+
select: (options: Record<string, unknown>) =>
9+
options.android !== undefined ? options.android : options.default,
10+
},
11+
}));
12+
13+
jest.mock('promise/setimmediate/rejection-tracking', () => ({
14+
enable: jest.fn(),
15+
}));
16+
17+
jest.mock('../src/crashReporter/CrashReporter', () => ({
18+
CrashReporter: { markFatalError: jest.fn() },
19+
}));
20+
21+
const mockIsNativeBridgeEnabled = jest.fn().mockReturnValue(true);
22+
jest.mock('../src/common/DebuggerHelper', () => ({
23+
DebuggerHelper: { isNativeBridgeEnabled: () => mockIsNativeBridgeEnabled() },
24+
}));
25+
26+
import { NativeModules } from 'react-native';
27+
28+
const nativeHandlerMock = {
29+
start: jest.fn(),
30+
stop: jest.fn(),
31+
reportProcessed: jest.fn(),
32+
};
33+
34+
NativeModules.BacktraceAndroidBackgroundUnhandledExceptionHandler = nativeHandlerMock;
35+
36+
// eslint-disable-next-line @typescript-eslint/no-var-requires
37+
const { AndroidUnhandledExceptionHandler } = require('../src/handlers/android/AndroidUnhandledExceptionHandler');
38+
39+
type NativeExceptionCallback = (classifier: string, message: string, stackTrace: string) => Promise<void>;
40+
41+
describe('AndroidUnhandledExceptionHandler', () => {
42+
let originalErrorUtils: unknown;
43+
44+
beforeEach(() => {
45+
jest.clearAllMocks();
46+
mockIsNativeBridgeEnabled.mockReturnValue(true);
47+
48+
originalErrorUtils = (global as unknown as { ErrorUtils?: unknown }).ErrorUtils;
49+
(global as unknown as { ErrorUtils: unknown }).ErrorUtils = {
50+
getGlobalHandler: () => jest.fn(),
51+
setGlobalHandler: jest.fn(),
52+
};
53+
});
54+
55+
afterEach(() => {
56+
(global as unknown as { ErrorUtils: unknown }).ErrorUtils = originalErrorUtils;
57+
});
58+
59+
function captureNativeCallback(client: BacktraceClient): NativeExceptionCallback {
60+
new AndroidUnhandledExceptionHandler().captureManagedErrors(client);
61+
return nativeHandlerMock.start.mock.calls[0][0];
62+
}
63+
64+
it('Should signal reportProcessed only after the report send resolves', async () => {
65+
let resolveSend!: () => void;
66+
const sendMock = jest.fn().mockReturnValue(
67+
new Promise<void>((resolve) => {
68+
resolveSend = resolve;
69+
}),
70+
);
71+
const callback = captureNativeCallback({ send: sendMock } as unknown as BacktraceClient);
72+
73+
const callbackPromise = callback('java.lang.RuntimeException', 'boom', 'a.b(C.java:1)');
74+
75+
expect(sendMock).toHaveBeenCalledTimes(1);
76+
expect(nativeHandlerMock.reportProcessed).not.toHaveBeenCalled();
77+
78+
resolveSend();
79+
await callbackPromise;
80+
81+
expect(nativeHandlerMock.reportProcessed).toHaveBeenCalledTimes(1);
82+
});
83+
84+
it('Should signal reportProcessed when the send fails', async () => {
85+
const sendMock = jest.fn().mockRejectedValue(new Error('offline'));
86+
const callback = captureNativeCallback({ send: sendMock } as unknown as BacktraceClient);
87+
88+
await callback('java.lang.RuntimeException', 'boom', 'a.b(C.java:1)');
89+
90+
expect(nativeHandlerMock.reportProcessed).toHaveBeenCalledTimes(1);
91+
});
92+
93+
it("Should send the exception as a report tagged with error.type 'Unhandled exception'", async () => {
94+
const sendMock = jest.fn().mockResolvedValue(undefined);
95+
const callback = captureNativeCallback({ send: sendMock } as unknown as BacktraceClient);
96+
97+
await callback('java.lang.IllegalStateException', 'boom', 'a.b(C.java:1)');
98+
99+
const report = sendMock.mock.calls[0][0] as BacktraceReport;
100+
expect(report.attributes['error.type']).toBe('Unhandled exception');
101+
});
102+
103+
it('Should not start the native handler when the native bridge is unavailable', () => {
104+
mockIsNativeBridgeEnabled.mockReturnValue(false);
105+
106+
new AndroidUnhandledExceptionHandler().captureManagedErrors({ send: jest.fn() } as unknown as BacktraceClient);
107+
108+
expect(nativeHandlerMock.start).not.toHaveBeenCalled();
109+
});
110+
111+
it('Should stop the native handler on dispose', () => {
112+
const handler = new AndroidUnhandledExceptionHandler();
113+
handler.captureManagedErrors({ send: jest.fn() } as unknown as BacktraceClient);
114+
115+
handler.dispose();
116+
117+
expect(nativeHandlerMock.stop).toHaveBeenCalledTimes(1);
118+
});
119+
});

0 commit comments

Comments
 (0)