diff --git a/packages/react-native/android/src/main/java/backtrace/library/BacktraceAndroidBackgroundUnhandledExceptionHandler.java b/packages/react-native/android/src/main/java/backtrace/library/BacktraceAndroidBackgroundUnhandledExceptionHandler.java index 22c92869..914cb06b 100644 --- a/packages/react-native/android/src/main/java/backtrace/library/BacktraceAndroidBackgroundUnhandledExceptionHandler.java +++ b/packages/react-native/android/src/main/java/backtrace/library/BacktraceAndroidBackgroundUnhandledExceptionHandler.java @@ -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. @@ -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) { @@ -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)); diff --git a/packages/react-native/src/handlers/android/AndroidUnhandledExceptionHandler.ts b/packages/react-native/src/handlers/android/AndroidUnhandledExceptionHandler.ts index 886c8170..76a6cbc8 100644 --- a/packages/react-native/src/handlers/android/AndroidUnhandledExceptionHandler.ts +++ b/packages/react-native/src/handlers/android/AndroidUnhandledExceptionHandler.ts @@ -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?.(); + } }); } diff --git a/packages/react-native/tests/androidUnhandledExceptionHandlerTests.spec.ts b/packages/react-native/tests/androidUnhandledExceptionHandlerTests.spec.ts new file mode 100644 index 00000000..4dea7597 --- /dev/null +++ b/packages/react-native/tests/androidUnhandledExceptionHandlerTests.spec.ts @@ -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) => + 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; + +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((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); + }); +});