react-native: Async JSI functions with promises block the event loop indefinitely
Description
While playing with the JSI I have found that using Promises in conjunction with async JSI functions can lead to the JS thread being blocked. According to the react native profiler the JS thread is still operating at 60fps but the JS thread is not responding to tap handlers and never resolves the promise. Note this happens sometimes and is hard to predict which suggests there is some race condition.
I am able to unblock the JS thread by periodically kicking the event loop into action with something like
const tick = async () => {
await new Promise(r => setTimeout(r, 1000))
tick()
}
tick()
I’ve also noticed that using Promises in conjunction with the JSI can occasionally lead to extremely long await times for the promise to be resolved, in the order of 250ms.
I have only tested this on iOS without Hermes. Occurs on both simulator and device.
Version
0.67.1
Output of npx react-native info
System:
OS: macOS 12.0.1
CPU: (10) x64 Apple M1 Max
Memory: 20.13 MB / 32.00 GB
Shell: 5.8 - /bin/zsh
Binaries:
Node: 14.18.2 - ~/.nvm/versions/node/v14.18.2/bin/node
Yarn: 1.22.17 - ~/.nvm/versions/node/v14.18.2/bin/yarn
npm: 6.14.6 - ~/.nvm/versions/node/v14.18.2/bin/npm
Watchman: 2021.12.20.00 - /usr/local/bin/watchman
Managers:
CocoaPods: 1.11.2 - /usr/local/bin/pod
SDKs:
iOS SDK:
Platforms: DriverKit 21.0.1, iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0
Android SDK: Not Found
IDEs:
Android Studio: Not Found
Xcode: 13.1/13A1030d - /usr/bin/xcodebuild
Languages:
Java: Not Found
Python: 2.7.18 - /usr/bin/python
npmPackages:
@react-native-community/cli: Not Found
react: 16.13.1 => 16.13.1
react-native: 0.63.4 => 0.63.4
react-native-macos: Not Found
npmGlobalPackages:
*react-native*: Not Found
Steps to reproduce
A minimal failing example can be found here https://github.com/mfbx9da4/react-native-jsi-promise
1
Create an async native JSI function which spawns a thread, does some work and then resolves once it’s done its work. You can use a callback or a promise to resolve from the native function. I’ve opted to use a callback as there are less moving parts this way. You could also use std::thread
instead of GCD’s dispatch_async
for spawning the thread, the behaviour is observed with both. I’ve opted to use dispatch_async
below.
auto foo = jsi::Function::createFromHostFunction(
jsiRuntime,
jsi::PropNameID::forAscii(jsiRuntime, "foo"),
1,
[cryptoPp, myqueue](jsi::Runtime& runtime, const jsi::Value& thisValue, const jsi::Value* arguments, size_t count) -> jsi::Value {
auto userCallbackRef = std::make_shared<jsi::Object>(arguments[0].getObject(runtime));
dispatch_async(myqueue, ^(void){
auto val = jsi::String::createFromUtf8(runtime, std::to_string(std::rand()));
auto error = jsi::Value::undefined();
userCallbackRef->asFunction(runtime).call(runtime, error, val);
});
return jsi::Value::undefined();
}
);
jsiRuntime.global().setProperty(jsiRuntime, "foo", std::move(foo));
2
Call the JSI function in a loop resolving the promise
for (let i = 0; i < 10; i++) {
const start = Date.now();
await new Promise(r => {
jsiPromise.foo((err, x) => {
console.log(i, 'err, x', err, x, Date.now() - start);
r(x);
});
});
}
It might take several refreshes to get stuck but the JS thread does eventually get stuck.
Snack, code example, screenshot, or link to a repository
About this issue
- Original URL
- State: open
- Created 2 years ago
- Reactions: 7
- Comments: 19 (12 by maintainers)
To provide an update on this, I found that we were able to expose the
jsCallInvoker
member of theRCTBridge
, which has a methodinvokeAsync
which calls a lambda you pass to it on the JS thread, then callsflush()
. This allows us to flush the UI queue without needing to modify React Native to exposeJSIExecutor::flush()
directly.The lambda to
invokeAsync
could just be a no-op, but in our case I’m using to reset a flag which prevents us making multiple calls toinvokeAsync
if one is already pending (e.g. if many C++ to JS calls occur in a short space of time).You can see how we’ve implemented this for Realm in this PR, essentially we create a
std::function
which callsinvokeAsync
and then pass this through our initialization code so that our “call JS function” abstraction (we abstract over multiple Javascript engines) can call it whenever we call into JS from C++.I’d be interested to know if there is a more “official” solution for this scenario planned though!
It looks like you call that method from a different thread. I guess you should schedule that callback to JS thread with Jscallinvoker which if I remember correctly is keeping count of tasks like that.
Thanks for flagging this. This is a great question. We also realized the original bridge-based microtask mechanism wouldn’t work for sync C++ -> JS call in the new architecture.
I added
drainMicrotasks
to JSI and thisperformMicrotaskCheckpoint
toJSIExecutor
a while ago for the exact purpose you mentioned (to flush microtasks on the JS side before getting back to C++).That being said,
JSTimer::callReactNativeMicrotask
so we have to be careful.So…I think it’s about right to directly call
require("react-native/Libraries/Core/Timers/JSTimers.js").callReactNativeMicrotasks()
for now (for Hermes) and for a while (for JSC, etc.) to unblock any development.If it became a norm, I think it may make sense to expose a
drainMicrotasks
toglobalThis
or something equivalent for library developers to call from the JS side as well. 🤔 Although technically, this probably shouldn’t be exposed to JavaScript.I’ll keep you posted whenever I have a update, but also feel free to follow-up!
Hey @mfbx9da4, I believe you’ve already seen how we approached fixing a similar issue in Realm: https://github.com/realm/realm-js/pull/4330. In our case, we’d see the UI being out of sync after any kind of async update had happened. As soon as you touched the screen, the UI would update. I’m not sure if that’s exactly the same as what you are seeing, but it sounds pretty similar.
Hopefully my description on our issue is clear – in essence it seems that React Native queues up async work created by e.g.
setTimeout
or promises (which usesetImmediate
under the hood) as “microtasks”, and then relies on messages coming via the bridge to flush that queue: https://github.com/facebook/react-native/blob/main/Libraries/BatchedBridge/MessageQueue.js#L108. This is the same on both JSC and Hermes, from what I can see.Because we bypass the React Native bridge in Realm, and instead work directly with the JavaScript engine, when an async function call from our native code to JS happened, this did not hit any React Native codepath, and so RN had no way of knowing it should flush this queue as a result of the change. I guess your situation could be similar, I wasn’t quite sure what I was looking for in your sample app, both buttons seemed to work OK.
We are currently thinking we will go with an approach of manually calling
require("react-native/Libraries/Core/Timers/JSTimers.js").callReactNativeMicrotasks()
whenever we make a native -> JS async call (or with some kind of throttling) – we couldn’t find a more elegant way to trigger this. One alternative we considered was sending a dummy message via the React Native bridge in the usual native module style, which would have the effect of triggering a flush of the microtask queue.I’d be interested to know if anyone has any other thoughts on more elegant ways to solve this though!
@Huxpro Thanks for the pointer to try calling
JSIExectuor::flush
! This seems to work perfectly if we call it after every C++ to JS call, so it seems that some of the extra workflush
does vscallReactNativeMicrotasks
is important.However, I couldn’t work out a way to call
flush
from our code without adding some extra code to React Native to exposeflush
on theRCTCxxBridge
: https://github.com/facebook/react-native/pull/33201/files. Perhaps you know of some other way we can access this from outside?Regarding “I don’t understand why TurboModule aren’t flushing things properly automatically for you” – we are calling directly from C++ to JS, using JSC’s
JSObjectCallAsFunction
in ourmaster
branch and the JSI equivalent,JSIFunc->call
in ourhermes
branch, so I suspect RN has no way of knowing that anything needs to be flushed.While this is probably a slightly unusual pattern for a React Native module to communicate to JS right now, our understanding is that with the new architecture, this kind of direct C++ to JS communication will become more commonplace, so others may hit the same issues – or is the intention that libraries still use the EventEmitter pattern with TurboModules?
@kelset we’re still seeing this on RN 0.71.0 (just tested with 0.71.0-rc.5 from our
bindgen
branch which doesn’t have the workaround we mentioned above).+1 to this. Also for anything New Architecture related like this one, posting on reactwg/react-native-new-architecture should be the preferred way as we’re getting a lot of noise on the React Native issue tracker, causing issue like this one to get lost.
Hey @Huxpro , thanks so much for the detailed answer and it’s great to know we were on the right track!
We implemented the solution of calling
callReactNativeMicrotasks()
immediately after we make any call from C++ to JS, and it improved things a lot, but we do still see occasional issues where the UI is not updated until something else (e.g. a touch) triggers a bridge message.I also tried implementing a solution where we send a “dummy” message over the bridge with every C++ -> JS update, using the usual native modules method, and this works perfectly, but feels a little inelegant.
I was wondering whether there might be more going on under the hood when you send a bridge message, i.e. we are missing some other important part when we just call
callReactNativeMicrotasks()
directly – but your response makes me think this could be a timing thing/race condition instead – e.g. sending a bridge message takes longer than a direct call into JS, and when wecallReactNativeMicrotasks()
directly, occasionally some microtask has not even been queued yet.I will experiment a little and report back. Thanks for the input!