Skip to content

Fix crash in async void TurboModule methods when NSException is thrown#55390

Open
Luxlorys wants to merge 1 commit intofacebook:mainfrom
Luxlorys:fix/turbomodule-void-method-exception-crash
Open

Fix crash in async void TurboModule methods when NSException is thrown#55390
Luxlorys wants to merge 1 commit intofacebook:mainfrom
Luxlorys:fix/turbomodule-void-method-exception-crash

Conversation

@Luxlorys
Copy link
Copy Markdown

@Luxlorys Luxlorys commented Feb 2, 2026

Summary

Fixes uncaught C++ exception crash in performVoidMethodInvocation when async void TurboModule methods throw NSException during app termination or rapid UI transitions.

Problem

In RCTTurboModule.mm, when an async void TurboModule method throws an NSException, the exception is caught and converted to a JSError which is then rethrown:

@catch (NSException *exception) {
  throw convertNSExceptionToJSError(runtime, exception, ...);
}

Since this code executes inside an async dispatch block via invokeAsync(), nothing in the call stack can catch the C++ exception, causing immediate app termination with SIGABRT / EXC_BAD_ACCESS.

How to Reproduce

  1. Navigate between screens rapidly while closing the app
  2. Focus a text input (triggering keyboard), then quickly close the app
  3. Any scenario where native modules are deallocated while async void methods are still executing

Common culprits include search/Spotlight indexing modules, analytics modules, and keyboard-related modules that fire async void methods during app lifecycle transitions.

Solution

Replace the throw with RCTLogError to log the error instead of crashing:

@catch (NSException *exception) {
  // Cannot rethrow C++ exceptions from async dispatch - nothing can catch them.
  // Log the error for debugging purposes instead of crashing.
  RCTLogError(
      @"Exception thrown while invoking async method %s.%s: %@",
      moduleName,
      methodNameStr.c_str(),
      exception);
}

Why This Fix is Safe

  1. Void methods have no return value - the caller doesn't expect a result
  2. Async dispatch - the original JS call has already returned; there's no Promise to reject
  3. Logging preserves visibility - developers can still debug issues via RCTLogError
  4. Consistent with existing patterns - performMethodInvocation already has special handling for async exceptions (re-throws NSException instead of converting to JSError)

Related Issues

Changelog

[IOS] [FIXED] - Fix crash when async void TurboModule methods throw NSException

Test Plan

  1. Create a TurboModule with an async void method that throws (e.g., voidFuncThrows in RCTSampleTurboModule.mm)
  2. Call the method and quickly terminate/background the app
  3. Verify app does NOT crash
  4. Verify error appears in console logs

When an async void TurboModule method throws an NSException (e.g., during
app termination or rapid UI transitions), the exception is caught and
converted to a JSError which is then rethrown. However, since this code
executes inside an async dispatch block via invokeAsync(), nothing in the
call stack can catch the C++ exception, causing immediate app termination
with SIGABRT/EXC_BAD_ACCESS.

This is particularly common during:
- Rapid screen transitions while closing the app
- Focusing an input (triggering keyboard) then quickly closing the app
- Any scenario where native modules are deallocated while async void
  methods are still executing

The fix replaces the throw with RCTLogError, which preserves error
visibility for debugging while preventing the crash. This is safe because:
- Void methods have no return value to the caller
- Async dispatch means the original JS call already returned
- There's no Promise to reject

This is consistent with the existing handling in performMethodInvocation,
which already has special handling for async exceptions (it re-throws
NSException instead of converting to JSError for async calls).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@meta-cla
Copy link
Copy Markdown

meta-cla bot commented Feb 2, 2026

Hi @Luxlorys!

Thank you for your pull request and welcome to our community.

Action Required

In order to merge any pull request (code, docs, etc.), we require contributors to sign our Contributor License Agreement, and we don't seem to have one on file for you.

Process

In order for us to review and merge your suggested changes, please sign at https://code.facebook.com/cla. If you are contributing on behalf of someone else (eg your employer), the individual CLA may not be sufficient and your employer may need to sign the corporate CLA.

Once the CLA is signed, our tooling will perform checks and validations. Afterwards, the pull request will be tagged with CLA signed. The tagging process may take up to 1 hour after signing. Please give it that time before contacting us about it.

If you have received this in error or have any questions, please contact us at cla@meta.com. Thanks!

@meta-cla
Copy link
Copy Markdown

meta-cla bot commented Feb 2, 2026

Thank you for signing our Contributor License Agreement. We can now accept your code for this (and any) Meta Open Source project. Thanks!

@meta-cla meta-cla bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Feb 2, 2026
@facebook-github-bot facebook-github-bot added the Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team. label Feb 2, 2026
Comment on lines +465 to +470
// Log the error for debugging purposes instead of crashing.
RCTLogError(
@"Exception thrown while invoking async method %s.%s: %@",
moduleName,
methodNameStr.c_str(),
exception);
Copy link
Copy Markdown
Contributor

@RSNara RSNara Feb 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Luxlorys, let's handle errors in async methods consistently.

Is there a strong reason for us to do something different in these changed lines vs here:

https://github.com/Luxlorys/react-native/blob/9f41b14761389b57f4947e89051a7865122ba4c6/packages/react-native/ReactCommon/react/nativemodule/core/platform/ios/ReactCommon/RCTTurboModule.mm#L399-L405

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lines 399-405 (Promise-based async methods) can re-throw NSException because they execute within the createPromise() wrapper infrastructure (lines 253-360).
When the exception is re-thrown at line 404, it's caught by the Promise wrapper which can then reject the Promise and communicate the error back to JavaScript
through the resolve/reject blocks.

Async void methods execute through a fundamentally different code path:

  1. They call performVoidMethodInvocation() which dispatches directly via invokeAsync() to a background queue
  2. There's no Promise wrapper, no resolve/reject blocks, no error communication channel
  3. The JS call has already returned undefined before the async work executes
  4. Any exception thrown (whether C++ or NSException) has nothing to catch it on the dispatch queue

If we used the same approach as line 404 (@throw exception;) for void methods, the app would still crash because there's no infrastructure to catch the
re-thrown NSException in the dispatch block's execution context.

Correct me if I missed something, I'm new here

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@RSNara Hey, check message above when you have time, please.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah! So you're saying that we should be throwing in the promise case.

@XChikuX
Copy link
Copy Markdown

XChikuX commented Feb 26, 2026

Hey Reviewers, I know you're busy. But the last update from your side has been more than 2 weeks ago. This is affecting a lot of users, especially on the newArch.

Can we bump up the priority and take a look at this asap? considering you guys recommend everyone switch to this architecture and have basically strong armed pretty much everyone running expo onto this.

@RSNara, if you are unable to be more quick about this, could you please tag some more people who can take a look at this! That would be great.

Copy link
Copy Markdown
Contributor

@RSNara RSNara left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good!

Comment on lines +465 to +470
// Log the error for debugging purposes instead of crashing.
RCTLogError(
@"Exception thrown while invoking async method %s.%s: %@",
moduleName,
methodNameStr.c_str(),
exception);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah! So you're saying that we should be throwing in the promise case.

mirrorphotosintern added a commit to mirrorphotosintern/shaale-app that referenced this pull request Feb 27, 2026
Build 24 still crashed with the same SIGABRT in
ObjCTurboModule::performVoidMethodInvocation — SDK 54 / RN 0.81
has New Architecture ON by default (since RN 0.76).

Two fixes applied:
1. Set "newArchEnabled": false in app.json to disable TurboModules
2. Patch RCTTurboModule.mm (from facebook/react-native#55390) to
   replace throw with RCTLogError in async void method catch block

Verified: 0 occurrences of RCT_NEW_ARCH_ENABLED in generated pods.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@matinzd
Copy link
Copy Markdown
Contributor

matinzd commented Apr 1, 2026

Should this be closed in favor of #56265?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants