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
6 changes: 3 additions & 3 deletions examples/sdk/reactNative/src/actions/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
},
},
{
Expand Down
1 change: 1 addition & 0 deletions packages/react-native/ios/BacktraceCrashReporter.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
23 changes: 23 additions & 0 deletions packages/react-native/ios/BacktraceCrashReporter.mm
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down Expand Up @@ -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];
Expand Down Expand Up @@ -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.");
Expand Down
9 changes: 9 additions & 0 deletions packages/react-native/ios/BacktraceReactNative.mm
Original file line number Diff line number Diff line change
Expand Up @@ -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
5 changes: 5 additions & 0 deletions packages/react-native/src/crashReporter/CrashReporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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);
});
}
Expand Down
56 changes: 56 additions & 0 deletions packages/react-native/tests/unhandledExceptionHandlerTests.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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);
});
});
Loading