Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@

import java.io.PrintWriter;
import java.io.StringWriter;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;

/**
* Handle unhandled Android exceptions from background threads.
Expand All @@ -35,6 +37,13 @@ public class BacktraceAndroidBackgroundUnhandledExceptionHandler extends ReactCo
* React native callback method
*/
private Callback _callback;

private boolean _callbackInvoked = false;

private final CountDownLatch _reportProcessed = new CountDownLatch(1);

private static final long REPORT_PROCESSED_TIMEOUT_MS = 5000;

public static final String NAME = "BacktraceAndroidBackgroundUnhandledExceptionHandler";

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

@Override
public void uncaughtException(final Thread thread, final Throwable throwable) {
public synchronized void uncaughtException(final Thread thread, final Throwable throwable) {
_lastCaughtBackgroundExceptionThread = thread;
_lastCaughtBackgroundException = throwable;
if (_shouldStop == true) {
finish();
return;
}
if (throwable instanceof Exception) {
// React Native callbacks are single-use; invoking one twice throws.
if (throwable instanceof Exception && !_callbackInvoked) {
_callbackInvoked = true;
String throwableType = throwable.getClass().getName();
_callback.invoke(throwableType, throwable.getMessage(), stackTraceToString(throwable.getStackTrace()));
waitForReportProcessing();
}
finish();
}

private void waitForReportProcessing() {
try {
if (!_reportProcessed.await(REPORT_PROCESSED_TIMEOUT_MS, TimeUnit.MILLISECONDS)) {
Log.d(LOG_TAG, "Timed out waiting for the unhandled exception report to be processed.");
}
} catch (InterruptedException ex) {
Log.d(LOG_TAG, "Interrupted while waiting for the unhandled exception report to be processed.");
}
}

// not synchronized: the crashing thread holds this monitor while it waits
@ReactMethod
public void reportProcessed() {
Log.d(LOG_TAG, "Unhandled exception report processed by the JavaScript side.");
_reportProcessed.countDown();
}

private static String stackTraceToString(StackTraceElement[] stackTrace) {
StringWriter sw = new StringWriter();
printStackTrace(stackTrace, new PrintWriter(sw));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,22 @@ export class AndroidUnhandledExceptionHandler extends UnhandledExceptionHandler
return;
}

this._unhandledExceptionHandler.start((classifier: string, message: string, stackTrace: string) => {
const report = new BacktraceReport(
new AndroidUnhandledException(classifier, message, stackTrace),
{
'error.type': 'Unhandled exception',
},
[],
);
report.addStackTrace('main', this._androidStackTraceConverter.convert(stackTrace));
client.send(report);
this._unhandledExceptionHandler.start(async (classifier: string, message: string, stackTrace: string) => {
try {
const report = new BacktraceReport(
new AndroidUnhandledException(classifier, message, stackTrace),
{
'error.type': 'Unhandled exception',
},
[],
);
report.addStackTrace('main', this._androidStackTraceConverter.convert(stackTrace));
await client.send(report);
} catch {
// nothing to recover: the process is dying
} finally {
this._unhandledExceptionHandler.reportProcessed?.();
}
});
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import type { BacktraceReport } from '@backtrace/sdk-core';
import type { BacktraceClient } from '../src/BacktraceClient';

jest.mock('react-native', () => ({
NativeModules: {},
Platform: {
OS: 'android',
select: (options: Record<string, unknown>) =>
options.android !== undefined ? options.android : options.default,
},
}));

jest.mock('promise/setimmediate/rejection-tracking', () => ({
enable: jest.fn(),
}));

jest.mock('../src/crashReporter/CrashReporter', () => ({
CrashReporter: { markFatalError: jest.fn() },
}));

const mockIsNativeBridgeEnabled = jest.fn().mockReturnValue(true);
jest.mock('../src/common/DebuggerHelper', () => ({
DebuggerHelper: { isNativeBridgeEnabled: () => mockIsNativeBridgeEnabled() },
}));

import { NativeModules } from 'react-native';

const nativeHandlerMock = {
start: jest.fn(),
stop: jest.fn(),
reportProcessed: jest.fn(),
};

NativeModules.BacktraceAndroidBackgroundUnhandledExceptionHandler = nativeHandlerMock;

// eslint-disable-next-line @typescript-eslint/no-var-requires
const { AndroidUnhandledExceptionHandler } = require('../src/handlers/android/AndroidUnhandledExceptionHandler');

type NativeExceptionCallback = (classifier: string, message: string, stackTrace: string) => Promise<void>;

describe('AndroidUnhandledExceptionHandler', () => {
let originalErrorUtils: unknown;

beforeEach(() => {
jest.clearAllMocks();
mockIsNativeBridgeEnabled.mockReturnValue(true);

originalErrorUtils = (global as unknown as { ErrorUtils?: unknown }).ErrorUtils;
(global as unknown as { ErrorUtils: unknown }).ErrorUtils = {
getGlobalHandler: () => jest.fn(),
setGlobalHandler: jest.fn(),
};
});

afterEach(() => {
(global as unknown as { ErrorUtils: unknown }).ErrorUtils = originalErrorUtils;
});

function captureNativeCallback(client: BacktraceClient): NativeExceptionCallback {
new AndroidUnhandledExceptionHandler().captureManagedErrors(client);
return nativeHandlerMock.start.mock.calls[0][0];
}

it('Should signal reportProcessed only after the report send resolves', async () => {
let resolveSend!: () => void;
const sendMock = jest.fn().mockReturnValue(
new Promise<void>((resolve) => {
resolveSend = resolve;
}),
);
const callback = captureNativeCallback({ send: sendMock } as unknown as BacktraceClient);

const callbackPromise = callback('java.lang.RuntimeException', 'boom', 'a.b(C.java:1)');

expect(sendMock).toHaveBeenCalledTimes(1);
expect(nativeHandlerMock.reportProcessed).not.toHaveBeenCalled();

resolveSend();
await callbackPromise;

expect(nativeHandlerMock.reportProcessed).toHaveBeenCalledTimes(1);
});

it('Should signal reportProcessed when the send fails', async () => {
const sendMock = jest.fn().mockRejectedValue(new Error('offline'));
const callback = captureNativeCallback({ send: sendMock } as unknown as BacktraceClient);

await callback('java.lang.RuntimeException', 'boom', 'a.b(C.java:1)');

expect(nativeHandlerMock.reportProcessed).toHaveBeenCalledTimes(1);
});

it("Should send the exception as a report tagged with error.type 'Unhandled exception'", async () => {
const sendMock = jest.fn().mockResolvedValue(undefined);
const callback = captureNativeCallback({ send: sendMock } as unknown as BacktraceClient);

await callback('java.lang.IllegalStateException', 'boom', 'a.b(C.java:1)');

const report = sendMock.mock.calls[0][0] as BacktraceReport;
expect(report.attributes['error.type']).toBe('Unhandled exception');
});

it('Should not start the native handler when the native bridge is unavailable', () => {
mockIsNativeBridgeEnabled.mockReturnValue(false);

new AndroidUnhandledExceptionHandler().captureManagedErrors({ send: jest.fn() } as unknown as BacktraceClient);

expect(nativeHandlerMock.start).not.toHaveBeenCalled();
});

it('Should stop the native handler on dispose', () => {
const handler = new AndroidUnhandledExceptionHandler();
handler.captureManagedErrors({ send: jest.fn() } as unknown as BacktraceClient);

handler.dispose();

expect(nativeHandlerMock.stop).toHaveBeenCalledTimes(1);
});
});
Loading