diff --git a/examples/sdk/reactNative/src/actions/actions.ts b/examples/sdk/reactNative/src/actions/actions.ts index a495e519..25c11556 100644 --- a/examples/sdk/reactNative/src/actions/actions.ts +++ b/examples/sdk/reactNative/src/actions/actions.ts @@ -78,9 +78,9 @@ export function generateActions(client: BacktraceClient) { { name: 'Throw an unhandled error', platform, - action: async () => { - notify('Sending an unhandled exception to Backtrace.'); - throwAnError(); + action: () => { + // fatal synchronous throw (ErrorUtils -> RCTFatal on iOS), not an async promise rejection + setTimeout(() => throwAnError(), 0); }, }, { diff --git a/packages/react-native/ios/BacktraceCrashReporter.h b/packages/react-native/ios/BacktraceCrashReporter.h index e798e8b7..1d456eae 100644 --- a/packages/react-native/ios/BacktraceCrashReporter.h +++ b/packages/react-native/ios/BacktraceCrashReporter.h @@ -2,5 +2,6 @@ - (instancetype)initWithBacktraceUrl:(NSString*) rawUrl andDatabasePath:(NSString*) databasePath andAttributes:(NSDictionary*) attributes andOomSupport:(bool) enableOomSupport andAttachments:(NSArray*) attachments; - (void)useAttachments:(NSArray*) attachments; - (void)setAttributes:(NSDictionary*) attributes; +- (void)markJsFatalError; - (void)start; @end diff --git a/packages/react-native/ios/BacktraceCrashReporter.mm b/packages/react-native/ios/BacktraceCrashReporter.mm index cd9174be..89c3f9ff 100644 --- a/packages/react-native/ios/BacktraceCrashReporter.mm +++ b/packages/react-native/ios/BacktraceCrashReporter.mm @@ -33,6 +33,9 @@ @implementation BacktraceCrashReporter Boolean disabled = TRUE; +// YES while a JS-reported fatal is escalating to a native crash. +Boolean _jsFatal = FALSE; + static void onCrash(siginfo_t *info, ucontext_t *uap, void *context) { if(_oomWatcher) { [_oomWatcher cleanup]; @@ -86,9 +89,23 @@ - (void) saveReportData { [_attributes setObject:@"Crash" forKey:@"error.type"]; [reportData setObject: _attributes forKey: @"attributes"]; [reportData setObject: _attachmentsPaths forKey: @"attachments"]; + [reportData setObject: @(_jsFatal) forKey: @"jsFatal"]; [_crashReporter setCustomData: [NSKeyedArchiver archivedDataWithRootObject:reportData]]; } +- (void) markJsFatalError { + _jsFatal = YES; + [self saveReportData]; + // Self-clear after 2s so the flag can't taint a later native crash if this fatal never crashes. + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + if (!_jsFatal) { + return; + } + _jsFatal = NO; + [self saveReportData]; + }); +} + - (void)useAttachments:(NSArray*) attachments { _attachmentsPaths = [attachments mutableCopy]; @@ -163,6 +180,12 @@ - (void) sendPendingReports { } if (report){ NSDictionary* reportData = (NSDictionary*) [NSKeyedUnarchiver unarchiveObjectWithData: [report customData]]; + NSNumber* jsFatal = reportData != nil ? [reportData objectForKey:@"jsFatal"] : nil; + if (jsFatal != nil && [jsFatal boolValue]) { + NSLog(@"Backtrace: pending crash is a JS-reported fatal; skipping the duplicate native report."); + [_crashReporter purgePendingCrashReport]; + return; + } NSArray* attachments = reportData != nil ? [reportData objectForKey:@"attachments"] : [NSMutableArray new]; NSDictionary* attributes = reportData != nil ? [reportData objectForKey:@"attributes"] : [NSDictionary new]; NSLog(@"Backtrace: Found a crash generated in the last user session. Sending data to Backtrace."); diff --git a/packages/react-native/ios/BacktraceReactNative.mm b/packages/react-native/ios/BacktraceReactNative.mm index 7cea5522..5f112928 100644 --- a/packages/react-native/ios/BacktraceReactNative.mm +++ b/packages/react-native/ios/BacktraceReactNative.mm @@ -40,4 +40,13 @@ @implementation BacktraceReactNative array[1]; } +// Synchronous so the flag is set before JS proceeds to RCTFatal. +RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(markFatalError) +{ + if (instance != nil) { + [instance markJsFatalError]; + } + return nil; +} + @end diff --git a/packages/react-native/src/crashReporter/CrashReporter.ts b/packages/react-native/src/crashReporter/CrashReporter.ts index ab9a9a99..4d9346aa 100644 --- a/packages/react-native/src/crashReporter/CrashReporter.ts +++ b/packages/react-native/src/crashReporter/CrashReporter.ts @@ -67,6 +67,11 @@ export class CrashReporter { } } + // Flags the imminent native crash as an already-reported JS fatal (iOS); no-op elsewhere. + public static markFatalError(): void { + CrashReporter.BacktraceReactNative?.markFatalError?.(); + } + public dispose(): void { this._enabled = false; } diff --git a/packages/react-native/src/handlers/UnhandledExceptionHandler.ts b/packages/react-native/src/handlers/UnhandledExceptionHandler.ts index 5845379a..93a4b7c9 100644 --- a/packages/react-native/src/handlers/UnhandledExceptionHandler.ts +++ b/packages/react-native/src/handlers/UnhandledExceptionHandler.ts @@ -1,6 +1,7 @@ import { BacktraceReport } from '@backtrace/sdk-core'; import { BacktraceClient } from '../BacktraceClient'; import { hermes } from '../common/hermesHelper'; +import { CrashReporter } from '../crashReporter/CrashReporter'; import { type ExceptionHandler } from './ExceptionHandler'; // eslint-disable-next-line @typescript-eslint/no-var-requires @@ -28,6 +29,10 @@ export class UnhandledExceptionHandler implements ExceptionHandler { 'error.type': 'Unhandled exception', fatal, }); + // iOS: RCTFatal turns a fatal into a native crash the reporter would double-report. + if (fatal) { + CrashReporter.markFatalError(); + } globalErrorHandler(error, fatal); }); } diff --git a/packages/react-native/tests/unhandledExceptionHandlerTests.spec.ts b/packages/react-native/tests/unhandledExceptionHandlerTests.spec.ts index d05a14ba..ae01897d 100644 --- a/packages/react-native/tests/unhandledExceptionHandlerTests.spec.ts +++ b/packages/react-native/tests/unhandledExceptionHandlerTests.spec.ts @@ -5,6 +5,10 @@ jest.mock('promise/setimmediate/rejection-tracking', () => ({ enable: jest.fn(), })); +jest.mock('../src/crashReporter/CrashReporter', () => ({ + CrashReporter: { markFatalError: jest.fn() }, +})); + const mockHermesInternal: { enablePromiseRejectionTracker?: jest.Mock; hasPromise?: jest.Mock; @@ -17,8 +21,11 @@ jest.mock('../src/common/hermesHelper', () => ({ // eslint-disable-next-line @typescript-eslint/no-var-requires const rejectionTracking = require('promise/setimmediate/rejection-tracking'); +import { CrashReporter } from '../src/crashReporter/CrashReporter'; import { UnhandledExceptionHandler } from '../src/handlers/UnhandledExceptionHandler'; +const markFatalErrorMock = CrashReporter.markFatalError as jest.Mock; + describe('UnhandledExceptionHandler labeling', () => { let sendMock: jest.Mock; let client: BacktraceClient; @@ -65,3 +72,52 @@ describe('UnhandledExceptionHandler labeling', () => { expect(report.classifiers).toContain('UnhandledPromiseRejection'); }); }); + +describe('UnhandledExceptionHandler managed errors', () => { + let sendMock: jest.Mock; + let client: BacktraceClient; + let handler: UnhandledExceptionHandler; + let registeredHandler: (error: Error, fatal?: boolean) => void; + let previousGlobalHandler: jest.Mock; + let originalErrorUtils: unknown; + + beforeEach(() => { + markFatalErrorMock.mockClear(); + sendMock = jest.fn(); + client = { send: sendMock } as unknown as BacktraceClient; + handler = new UnhandledExceptionHandler(); + previousGlobalHandler = jest.fn(); + + originalErrorUtils = (global as unknown as { ErrorUtils?: unknown }).ErrorUtils; + (global as unknown as { ErrorUtils: unknown }).ErrorUtils = { + getGlobalHandler: () => previousGlobalHandler, + setGlobalHandler: (fn: typeof registeredHandler) => { + registeredHandler = fn; + }, + }; + + handler.captureManagedErrors(client); + }); + + afterEach(() => { + (global as unknown as { ErrorUtils: unknown }).ErrorUtils = originalErrorUtils; + }); + + it('Should mark a fatal unhandled error so the native reporter skips the duplicate, then chain the previous handler', () => { + const error = new Error('boom'); + registeredHandler(error, true); + + expect(sendMock).toHaveBeenCalledWith(error, { 'error.type': 'Unhandled exception', fatal: true }); + expect(markFatalErrorMock).toHaveBeenCalledTimes(1); + expect(previousGlobalHandler).toHaveBeenCalledWith(error, true); + }); + + it('Should NOT mark a non-fatal unhandled error', () => { + const error = new Error('boom'); + registeredHandler(error, false); + + expect(sendMock).toHaveBeenCalledWith(error, { 'error.type': 'Unhandled exception', fatal: false }); + expect(markFatalErrorMock).not.toHaveBeenCalled(); + expect(previousGlobalHandler).toHaveBeenCalledWith(error, false); + }); +});