Skip to content

Fix use-after-free in ~SyncCallback when a callback is destroyed off the JS thread - #59579

Open
l2hyunwoo wants to merge 1 commit into
react:mainfrom
l2hyunwoo:fix/sync-callback-destructor-off-js-thread
Open

l2hyunwoo wants to merge 1 commit into
react:mainfrom
l2hyunwoo:fix/sync-callback-destructor-off-js-thread

Conversation

@l2hyunwoo

@l2hyunwoo l2hyunwoo commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

Summary:

Fixes #59578.

~SyncCallback can run on any thread. On Android, callbacks passed to a TurboModule are destroyed on the fbjni HybridData destructor thread. The destructor calls wrapper_.lock() and then destroy(). destroy() removes the reference held by the LongLivedObjectCollection, so the local shared_ptr is the last strong reference, and ~CallbackWrapper runs ~jsi::Function on that thread. If the runtime is torn down at the same time, jsi::Pointer::~Pointer calls invalidate() 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 CallInvoker that it gets in the constructor, and posts a task that holds only the weak_ptr to the JS thread. The task locks the wrapper and calls destroy() on the JS thread, so the last reference is released there.

  • If the invoker drops the task (for example, RuntimeSchedulerCallInvoker after the scheduler is gone), the task holds only a weak_ptr and does not touch JS values. The wrapper is released when the LongLivedObjectCollection is cleared during teardown.
  • The move constructor and move assignment also move the invoker. A moved-from object posts nothing.
  • If there is no invoker, the destructor works as before. Without an invoker, the callback can only be used on the JS thread.

Behavior change: when a SyncCallback (or an AsyncCallback) is destroyed on the JS thread, the wrapper is now released one JS task later instead of immediately.

Not in this PR: AsyncCallback::callWithArgs / callWithFunction and AsyncPromise::SharedState::~SharedState use the same lock() 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 save adb logcat -b crash -d.

Build Runs Crashes
Stock 0.84.0, prebuilt AAR 3 2
Stock 0.84.0, built from source 4 2
0.84.0 with this change, built from source 8 0

Every crash was SIGSEGV on the HybridData Dest thread, 6 to 11 s after launch. The symbolized stack (ndk-stack with the unstripped libreactnative.so) is jsi::Pointer::~Pointer ← ~CallbackWrapper ← ~SyncCallback ← ~AsyncCallback ← createJavaCallback lambda ← ~JCxxCallbackImpl ← fbjni DestructorThread, 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:

(cd node_modules/react-native && patch -p1 < ../../patches/sync-callback-destructor.patch)
cd android && ./gradlew :app:assembleRelease -PreactNativeArchitectures=arm64-v8a -PreactNativeFromSource=true

The patch in the reproducer is this change applied to 0.84.0. Function.h on main differs from 0.84.0 only in one #include line.

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.

@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 Oct 28, 2026
@facebook-github-tools facebook-github-tools Bot added the Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team. label Oct 28, 2026

This branch has not been deployed

No deployments
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.

SIGSEGV in ~CallbackWrapper when a TurboModule callback is destroyed off the JS thread during runtime teardown

1 participant