Repository navigation
Conversation
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary:
Fixes #59578.
~SyncCallbackcan run on any thread. On Android, callbacks passed to a TurboModule are destroyed on the fbjniHybridDatadestructor thread. The destructor callswrapper_.lock()and thendestroy().destroy()removes the reference held by theLongLivedObjectCollection, so the localshared_ptris the last strong reference, and~CallbackWrapperruns~jsi::Functionon that thread. If the runtime is torn down at the same time,jsi::Pointer::~Pointercallsinvalidate()on memory that Hermes has already freed, and the app crashes with SIGSEGV.This breaks the contract in
LongLivedObject.h: outside the JS thread, hold only weak references to the object.With this change, the destructor does not take a strong reference. It keeps the
CallInvokerthat it gets in the constructor, and posts a task that holds only theweak_ptrto the JS thread. The task locks the wrapper and callsdestroy()on the JS thread, so the last reference is released there.RuntimeSchedulerCallInvokerafter the scheduler is gone), the task holds only aweak_ptrand does not touch JS values. The wrapper is released when theLongLivedObjectCollectionis cleared during teardown.Behavior change: when a
SyncCallback(or anAsyncCallback) is destroyed on the JS thread, the wrapper is now released one JS task later instead of immediately.Not in this PR:
AsyncCallback::callWithArgs/callWithFunctionandAsyncPromise::SharedState::~SharedStateuse the samelock()pattern on a non-JS thread. I can send a follow-up for them.Changelog:
[GENERAL] [FIXED] - Fix a use-after-free crash when a TurboModule callback is destroyed off the JS thread during runtime teardown
Test Plan:
I used the Android reproducer from the issue: https://github.com/l2hyunwoo/react-native-sync-callback-repro
It is the React Native 0.84.0 template, release build, arm64. A native module keeps 20,000 JS callbacks, then drops them, runs GC, and calls
ReactHost.reload()while busy threads keep the CPU loaded. This repeats about once per second. Device: Galaxy S26 Ultra, Android 17.For each run:
adb shell pm clear com.synccallbackrepro, launch the app, wait up to 150 s, then saveadb logcat -b crash -d.Every crash was SIGSEGV on the
HybridData Destthread, 6 to 11 s after launch. The symbolized stack (ndk-stackwith the unstrippedlibreactnative.so) isjsi::Pointer::~Pointer←~CallbackWrapper←~SyncCallback←~AsyncCallback←createJavaCallbacklambda ←~JCxxCallbackImpl← fbjniDestructorThread, the same as in the issue. With the change, each run completed 86 to 108 cycles in 150 s. These 8 runs used an earlier revision of this change without the no-invoker branch. That branch does not run on this path, because Android always passes an invoker. I rebuilt the reproducer with the final revision to check that it compiles.To build the reproducer with this change:
The patch in the reproducer is this change applied to 0.84.0.
Function.honmaindiffers from 0.84.0 only in one#includeline.Videos:
repro-base.mp4
Stock 0.84.0: the process dies during cycle 65.
repro-fix.mp4
With this change: about 120 cycles over 170 s without a crash.