react-native icon indicating copy to clipboard operation
react-native copied to clipboard

feat(tm): Reject Promise if Turbo Module method throws an Error

Open krystofwoldrich opened this issue 2 years ago • 6 comments

Summary:

This PR builds upon the previous work done in https://github.com/facebook/react-native/pull/36925, which introduced native stack traces to the JSError for synchronous functions.

The current modifications concentrate on functions that return Promises. Prior to this PR, errors within Promise-returning functions would be thrown at the platform layer crashing the app without a link to the JS stack.

After the implementation of this PR, errors thrown within Promise-returning functions are now captured and transformed into rejected Promises. These rejected Promises contain a JS Error object that contains both the JS stack trace and the cause, along with the platform stack trace.

Additionally, this PR ensures that rejections from native functions are now linked to the JS stack trace, providing a more comprehensive view of the rejection flow.

Changelog:

[GENERAL][ADDED] - Turbo Modules Promise-returning functions reject with JS and platform stack traces information

Test Plan:

iOS Android
function_promise_ios function_promise_android

Example of intentionally rejected promise on Android:

{
  "name": "Error",
  "message": "Exception in HostFunction: intentional promise rejection",
  "stack": "[native code]\ntryCallTwo@http://10.0.2.2:8081/js/RNTesterApp.android.bundle?platform=android&dev=true&lazy=true&minify=false&app=com.facebook.react.uiapp&modulesOnly=false&runModule=true:25844:9\ndoResolve@http://10.0.2.2:8081/js/RNTesterApp.android.bundle?platform=android&dev=true&lazy=true&minify=false&app=com.facebook.react.uiapp&modulesOnly=false&runModule=true:25975:25\nPromise@http://10.0.2.2:8081/js/RNTesterApp.android.bundle?platform=android&dev=true&lazy=true&minify=false&app=com.facebook.react.uiapp&modulesOnly=false&runModule=true:25863:14\n[native code]\nrejectPromise@http://10.0.2.2:8081/js/examples/TurboModule/SampleTurboModuleExample.bundle?platform=android&lazy=true&app=com.facebook.react.uiapp&modulesOnly=true&dev=true&minify=false&runModule=true&shallow=true:42:70\nonPress@http://10.0.2.2:8081/js/examples/TurboModule/SampleTurboModuleExample.bundle?platform=android&lazy=true&app=com.facebook.react.uiapp&modulesOnly=true&dev=true&minify=false&runModule=true&shallow=true:242:71\n_performTransitionSideEffects@http://10.0.2.2:8081/js/RNTesterApp.android.bundle?platform=android&dev=true&lazy=true&minify=false&app=com.facebook.react.uiapp&modulesOnly=false&runModule=true:51896:22\n_receiveSignal@http://10.0.2.2:8081/js/RNTesterApp.android.bundle?platform=android&dev=true&lazy=true&minify=false&app=com.facebook.react.uiapp&modulesOnly=false&runModule=true:51852:45\nonResponderRelease@http://10.0.2.2:8081/js/RNTesterApp.android.bundle?platform=android&dev=true&lazy=true&minify=false&app=com.facebook.react.uiapp&modulesOnly=false&runModule=true:51715:34\ninvokeGuardedCallbackProd@http://10.0.2.2:8081/js/RNTesterApp.android.bundle?platform=android&dev=true&lazy=true&minify=false&app=com.facebook.react.uiapp&modulesOnly=false&runModule=true:2962:21\ninvokeGuardedCallback@http://10.0.2.2:8081/js/RNTesterApp.android.bundle?platform=android&dev=true&lazy=true&minify=false&app=com.facebook.react.uiapp&modulesOnly=false&runModule=true:3048:42\ninvokeGuardedCallbackAndCatchFirstError@http://10.0.2.2:8081/js/RNTesterApp.android.bundle?platform=android&dev=true&lazy=true&minify=false&app=com.facebook.react.uiapp&modulesOnly=false&runModule=true:3051:36\nexecuteDispatch@http://10.0.2.2:8081/js/RNTesterApp.android.bundle?platform=android&dev=true&lazy=true&minify=false&app=com.facebook.react.uiapp&modulesOnly=false&runModule=true:3115:48\nexecuteDispatchesInOrder@http://10.0.2.2:8081/js/RNTesterApp.android.bundle?platform=android&dev=true&lazy=true&minify=false&app=com.facebook.react.uiapp&modulesOnly=false&runModule=true:3132:26\nexecuteDispatchesAndRelease@http://10.0.2.2:8081/js/RNTesterApp.android.bundle?platform=android&dev=true&lazy=true&minify=false&app=com.facebook.react.uiapp&modulesOnly=false&runModule=true:4856:35\nforEach@[native code]\nforEachAccumulated@http://10.0.2.2:8081/js/RNTesterApp.android.bundle?platform=android&dev=true&lazy=true&minify=false&app=com.facebook.react.uiapp&modulesOnly=false&runModule=true:3574:22\nrunEventsInBatch@http://10.0.2.2:8081/js/RNTesterApp.android.bundle?platform=android&dev=true&lazy=true&minify=false&app=com.facebook.react.uiapp&modulesOnly=false&runModule=true:4874:27\nrunExtractedPluginEventsInBatch@http://10.0.2.2:8081/js/RNTesterApp.android.bundle?platform=android&dev=true&lazy=true&minify=false&app=com.facebook.react.uiapp&modulesOnly=false&runModule=true:4896:25\nhttp://10.0.2.2:8081/js/RNTesterApp.android.bundle?platform=android&dev=true&lazy=true&minify=false&app=com.facebook.react.uiapp&modulesOnly=false&runModule=true:4914:42\nbatchedUpdates$1@http://10.0.2.2:8081/js/RNTesterApp.android.bundle?platform=android&dev=true&lazy=true&minify=false&app=com.facebook.react.uiapp&modulesOnly=false&runModule=true:14750:20\nbatchedUpdates@http://10.0.2.2:8081/js/RNTesterApp.android.bundle?platform=android&dev=true&lazy=true&minify=false&app=com.facebook.react.uiapp&modulesOnly=false&runModule=true:4845:36\ndispatchEvent@http://10.0.2.2:8081/js/RNTesterApp.android.bundle?platform=android&dev=true&lazy=true&minify=false&app=com.facebook.react.uiapp&modulesOnly=false&runModule=true:4907:23",
  "cause": {
    "nativeStackAndroid": [
      {
        "lineNumber": 173,
        "file": "SampleTurboModule.java",
        "methodName": "getValueWithPromise",
        "class": "com.facebook.fbreact.specs.SampleTurboModule"
      },
      {
        "lineNumber": -2,
        "file": "NativeRunnable.java",
        "methodName": "run",
        "class": "com.facebook.jni.NativeRunnable"
      },
      {
        "lineNumber": 942,
        "file": "Handler.java",
        "methodName": "handleCallback",
        "class": "android.os.Handler"
      },
      {
        "lineNumber": 99,
        "file": "Handler.java",
        "methodName": "dispatchMessage",
        "class": "android.os.Handler"
      },
      {
        "lineNumber": 27,
        "file": "MessageQueueThreadHandler.java",
        "methodName": "dispatchMessage",
        "class": "com.facebook.react.bridge.queue.MessageQueueThreadHandler"
      },
      {
        "lineNumber": 201,
        "file": "Looper.java",
        "methodName": "loopOnce",
        "class": "android.os.Looper"
      },
      {
        "lineNumber": 288,
        "file": "Looper.java",
        "methodName": "loop",
        "class": "android.os.Looper"
      },
      {
        "lineNumber": 228,
        "file": "MessageQueueThreadImpl.java",
        "methodName": "run",
        "class": "com.facebook.react.bridge.queue.MessageQueueThreadImpl$4"
      },
      {
        "lineNumber": 1012,
        "file": "Thread.java",
        "methodName": "run",
        "class": "java.lang.Thread"
      }
    ],
    "userInfo": null,
    "message": "intentional promise rejection",
    "code": "code 1"
  }
}

Example of intentionally rejected promise on iOS:

{
  "name": "Error",
  "message": "Exception in HostFunction: intentional promise rejection",
  "stack": "@[native code]\nrejectPromise@http://localhost:8081/js/examples/TurboModule/SampleTurboModuleExample.bundle:42:70\nonPress@http://localhost:8081/js/examples/TurboModule/SampleTurboModuleExample.bundle:242:71\n_performTransitionSideEffects@http://localhost:8081/js/RNTesterApp.ios.bundle:52614:22\n_receiveSignal@http://localhost:8081/js/RNTesterApp.ios.bundle:52570:45\nonResponderRelease@http://localhost:8081/js/RNTesterApp.ios.bundle:52433:34\ninvokeGuardedCallbackProd@http://localhost:8081/js/RNTesterApp.ios.bundle:3068:21\ninvokeGuardedCallback@http://localhost:8081/js/RNTesterApp.ios.bundle:3154:42\ninvokeGuardedCallbackAndCatchFirstError@http://localhost:8081/js/RNTesterApp.ios.bundle:3157:36\nexecuteDispatch@http://localhost:8081/js/RNTesterApp.ios.bundle:3221:48\nexecuteDispatchesInOrder@http://localhost:8081/js/RNTesterApp.ios.bundle:3238:26\nexecuteDispatchesAndRelease@http://localhost:8081/js/RNTesterApp.ios.bundle:4962:35\nforEach@[native code]\nforEachAccumulated@http://localhost:8081/js/RNTesterApp.ios.bundle:3680:22\nrunEventsInBatch@http://localhost:8081/js/RNTesterApp.ios.bundle:4980:27\nrunExtractedPluginEventsInBatch@http://localhost:8081/js/RNTesterApp.ios.bundle:5002:25\n@http://localhost:8081/js/RNTesterApp.ios.bundle:5020:42\nbatchedUpdates$1@http://localhost:8081/js/RNTesterApp.ios.bundle:14856:20\nbatchedUpdates@http://localhost:8081/js/RNTesterApp.ios.bundle:4951:36\ndispatchEvent@http://localhost:8081/js/RNTesterApp.ios.bundle:5013:23",
  "cause": {
    "code": "code_1",
    "stackSymbols": [
      "0   RNTester                            0x00000001031f9958 RCTJSErrorFromCodeMessageAndNSError + 112",
      "1   RNTester                            0x00000001034cecc8 ___ZZN8facebook5react15ObjCTurboModule13createPromiseERNS_3jsi7RuntimeENSt3__112basic_stringIcNS5_11char_traitsIcEENS5_9allocatorIcEEEEU13block_pointerFvU13block_pointerFvP11objc_objectEU13block_pointerFvP8NSStringSH_P7NSErrorEU13block_pointerFvP11NSExceptionEEENK3$_0clES4_RKNS2_5ValueEPSU_m_block_invoke.66 + 96",
      "2   RNTester                            0x000000010374e774 -[RCTSampleTurboModule getValueWithPromise:resolve:reject:] + 232",
      "3   CoreFoundation                      0x000000018043d6c0 __invoking___ + 144",
      "4   CoreFoundation                      0x000000018043aa44 -[NSInvocation invoke] + 276",
      "5   CoreFoundation                      0x000000018043acdc -[NSInvocation invokeWithTarget:] + 60",
      "6   RNTester                            0x00000001034c4de4 ___ZN8facebook5react15ObjCTurboModule23performMethodInvocationERNS_3jsi7RuntimeEbPKcP12NSInvocationP14NSMutableArrayU13block_pointerFvP11NSExceptionE_block_invoke + 240",
      "7   RNTester                            0x00000001034d5120 _ZZN8facebook5react15ObjCTurboModule23performMethodInvocationERNS_3jsi7RuntimeEbPKcP12NSInvocationP14NSMutableArrayU13block_pointerFvP11NSExceptionEENK3$_1clEv + 32",
      "8   RNTester                            0x00000001034d50f4 _ZNSt3__18__invokeB6v15006IRZN8facebook5react15ObjCTurboModule23performMethodInvocationERNS1_3jsi7RuntimeEbPKcP12NSInvocationP14NSMutableArrayU13block_pointerFvP11NSExceptionEE3$_1JEEEDTclclsr3stdE7declvalIT_EEspclsr3stdE7declvalIT0_EEEEOSJ_DpOSK_ + 24",
      "9   RNTester                            0x00000001034d50ac _ZNSt3__128__invoke_void_return_wrapperIvLb1EE6__callIJRZN8facebook5react15ObjCTurboModule23performMethodInvocationERNS3_3jsi7RuntimeEbPKcP12NSInvocationP14NSMutableArrayU13block_pointerFvP11NSExceptionEE3$_1EEEvDpOT_ + 24",
      "10  RNTester                            0x00000001034d5088 _ZNSt3__110__function12__alloc_funcIZN8facebook5react15ObjCTurboModule23performMethodInvocationERNS2_3jsi7RuntimeEbPKcP12NSInvocationP14NSMutableArrayU13block_pointerFvP11NSExceptionEE3$_1NS_9allocatorISI_EEFvvEEclB6v15006Ev + 28",
      "11  RNTester                            0x00000001034d3ce0 _ZNSt3__110__function6__funcIZN8facebook5react15ObjCTurboModule23performMethodInvocationERNS2_3jsi7RuntimeEbPKcP12NSInvocationP14NSMutableArrayU13block_pointerFvP11NSExceptionEE3$_1NS_9allocatorISI_EEFvvEEclEv + 28",
      "12  RNTester                            0x000000010308d65c _ZNKSt3__110__function12__value_funcIFvvEEclB6v15006Ev + 68",
      "13  RNTester                            0x000000010308d60c _ZNKSt3__18functionIFvvEEclEv + 24",
      "14  RNTester                            0x00000001034dbd24 ___ZN12_GLOBAL__N_128MethodQueueNativeCallInvoker11invokeAsyncEONSt3__18functionIFvvEEE_block_invoke + 44",
      "15  libdispatch.dylib                   0x0000000106820528 _dispatch_call_block_and_release + 24",
      "16  libdispatch.dylib                   0x0000000106821d50 _dispatch_client_callout + 16",
      "17  libdispatch.dylib                   0x0000000106832808 _dispatch_main_queue_drain + 1316",
      "18  libdispatch.dylib                   0x00000001068322d4 _dispatch_main_queue_callback_4CF + 40",
      "19  CoreFoundation                      0x000000018039a784 __CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__ + 12",
      "20  CoreFoundation                      0x0000000180394de4 __CFRunLoopRun + 1912",
      "21  CoreFoundation                      0x0000000180394254 CFRunLoopRunSpecific + 584",
      "22  GraphicsServices                    0x0000000188eb7c9c GSEventRunModal + 160",
      "23  UIKitCore                           0x0000000112b32ff0 -[UIApplication _run] + 868",
      "24  UIKitCore                           0x0000000112b36f3c UIApplicationMain + 124",
      "25  RNTester                            0x0000000102e7029c main + 96",
      "26  dyld                                0x0000000106721514 start_sim + 20",
      "27  ???                                 0x00000001068fde50 0x0 + 4405059152",
      "28  ???                                 0x7539800000000000 0x0 + 8446923313598431232"
    ],
    "message": "intentional promise rejection",
    "domain": "RCTSampleTurboModule",
    "userInfo": {}
  }
}

How I logged out the Errors:

console.log('Error in JS:', JSON.stringify({
  name: e.name,
  message: e.message,
  stack: e.stack,
  ...e,
}, null, 2));

krystofwoldrich avatar May 18 '23 15:05 krystofwoldrich

Platform Engine Arch Size (bytes) Diff
android hermes arm64-v8a 17,657,478 +12,413
android hermes armeabi-v7a n/a --
android hermes x86 n/a --
android hermes x86_64 n/a --
android jsc arm64-v8a 21,039,156 +12,418
android jsc armeabi-v7a n/a --
android jsc x86 n/a --
android jsc x86_64 n/a --

Base commit: 98e7ecd28057c8283778d81d68aafd4d9834917a Branch: main

analysis-bot avatar May 18 '23 16:05 analysis-bot

I'm not 100% convinced we need to use the Error callback for this, given the overheads it requires.

Consider the JS equivalent of this type of module invocation to be something like this, which also will not call the original reject handler.

new Promise(() => {
  setTimeout(() => {
    throw new Error('foo');
  }, 0)
).catch(err => console.error('error' + err));

My perspective is that the behaviour for all async native module calls in RN should be a Logbox in development (which we should fix if that's not happening here!) and a RN fatal error in production (which apps can still decide to handle by restarting the React app - without crashing the entire app).

javache avatar May 19 '23 09:05 javache

Making native exceptions more actionable in development by capturing a JS stacktrace to associate with it is a great idea by the way. I just don't think we want to do it in production - potentially we could make it a runtime flag so app authors can decide?

javache avatar May 19 '23 09:05 javache

@RSNara @javache Thank you for the feedback. I understand the performance overhead. I would like to go the runtime flag way. Could you point me toward an existing runtime flag where I could add this one?

@javache Yes, it would be an improved experience compared to standard JS behavior.

krystofwoldrich avatar May 23 '23 08:05 krystofwoldrich

On Android, we use ReactFeatureFlags - which you'll need to use JNI to access - see getFeatureFlag - on iOS, we use global helpers like RCTEnableTurboModule to control feature flags.

javache avatar May 23 '23 08:05 javache

I've updated the PR base on the comments. Thanks a lot for all the helpful suggestions. What do you think about the changes?

Examples of newly handled errors propagated to JS.

NSError

{
  "name": "Error",
  "message": "Exception in HostFunction: <unknown>",
  "stack": "",
  "cause": {
    "userInfo": {
      "Error reason": "Invalid Input"
    },
    "domain": "com.eezytutorials.iosTuts",
    "code": "200"
  }
}

std::exception

{
  "name": "Error",
  "message": "Exception in HostFunction: Output of e.what()",
  "stack": "",
  "cause": {
    "message": "Output of e.what()"
  }
}

Unknow Cpp exception

{
  "name": "Error",
  "message": "Exception in HostFunction: Unknown C++ exception thrown.",
  "stack": "",
  "cause": {
    "message": "Unknown C++ exception thrown."
  }
}

krystofwoldrich avatar Jun 15 '23 13:06 krystofwoldrich

Thank you for the feedback, I'm sorry for the delay, I'll fix the comments this week.

krystofwoldrich avatar Aug 01 '23 07:08 krystofwoldrich

Sorry for the delay on the reviews! I'd recommend splitting this in an iOS and Android PR to simplify the review.

javache avatar Oct 06 '23 14:10 javache

@javache Thank you for all the suggestions, corrections, and nits. I appreciate it.

I've removed iOS changes from this PR making it Android only. I'll link PR with iOS changes here.

Let me know if there is anything more! 🙏

iOS PR Here

krystofwoldrich avatar Oct 10 '23 12:10 krystofwoldrich

@javache

Is it expected that even "regular" rejects (eg the module implementation explicitly rejecting the promise) including the original caller's JS stack? Does that align with behaviour we see in other JS engines?

I would say it's expected because to get the caller's JS stack the user had to enable the feature by the flag traceTurboModulePromiseRejections that explicitly says trace promise rejections (meaning bot explicit rejections and ones based on thrown exception)

krystofwoldrich avatar Oct 19 '23 13:10 krystofwoldrich

@javache I don't know how exactly to use the new AsyncCallback. I'm getting undefined in JS instead of the JSError.

https://github.com/facebook/react-native/pull/37484#discussion_r1369906105

krystofwoldrich avatar Oct 24 '23 09:10 krystofwoldrich

@javache Thanks for the notes, I made the updates, let me know what you think. 🙏

krystofwoldrich avatar Oct 24 '23 11:10 krystofwoldrich

@javache has imported this pull request. If you are a Meta employee, you can view this diff on Phabricator.

facebook-github-bot avatar Oct 24 '23 19:10 facebook-github-bot

@javache merged this pull request in facebook/react-native@7612e6601a1c402c548dfe2fe548b1c481659993.

facebook-github-bot avatar Oct 27 '23 17:10 facebook-github-bot