react / react/react-native

enableSchedulerDelegateInvalidation defaults to false, leaving Scheduler's deferred delegate callbacks exposed to use-after-free (EXC_BAD_ACCESS on the JS thread during teardown)

Ouverte
#58,601 1 commentaire 0 réactions 0 personnes assignées Voir sur GitHub

Personne n'a encore pris cette issue.

Needs: Author Feedback Needs: Repro
Langage dominant
C++
Étoiles
127k
Forks
25.3k
Merge moyen
1 j 23 h
PR mergées (30 j)
4

Description

Description

Scheduler::uiManagerDidDispatchCommand and Scheduler::uiManagerDidFinishTransaction copy the raw pointer delegate_ into a lambda that is deferred through runtimeScheduler_->scheduleRenderingUpdate(...). If the delegate is destroyed before that lambda is drained, the lambda dereferences freed memory.

On 0.87.1 the guard for this exists, but it is gated behind a feature flag that is off by default, so a default build is still exposed:

// Scheduler.cpp (v0.87.1)
auto guardEnabled =
    ReactNativeFeatureFlags::enableSchedulerDelegateInvalidation();
runtimeScheduler_->scheduleRenderingUpdate(
    shadowNode->getSurfaceId(),
    [delegate = delegate_,
     invalidated = delegateInvalidated_,
     guardEnabled, /* ... */]() {
      if (guardEnabled && *invalidated) {
        return;
      }
      delegate->schedulerDidDispatchCommand(shadowView, commandName, args);
    });
// ReactNativeFeatureFlagsDefaults.h (v0.87.1)
bool enableSchedulerDelegateInvalidation() override {
  return false;
}

With the flag off, neither setDelegate() nor ~Scheduler() neutralises lambdas that already captured the old pointer, so on iOS RCTScheduler's dealloc — which calls setDelegate(nullptr) and then frees the delegate proxy — leaves the RuntimeScheduler free to drain a stale lambda on the JS thread.

What I am asking: is there a reason the guard is still opt-in, and is there a plan to enable it by default? If the guard is considered correct, a default of false means every released app is still exposed to this.

Three further observations from reading the sources:

  1. main currently has no guard at all in uiManagerDidDispatchCommand — the guardEnabled / invalidated capture present in v0.86.3 and v0.87.1 is absent there. If that is not intentional, the fix may have been lost on main.
  2. On 0.81.x the flag does not exist at all (neither in ReactNativeFeatureFlags.h nor in the JS feature-flag API), so apps on that line have no mitigation available whatsoever.
  3. The same raw-pointer capture pattern is used in uiManagerDidFinishTransaction, which runs on every non-synchronous transaction, not just on commands.

Note on Expo: the reporting app uses Expo, but this is not an Expo defect — the faulting frame is React Native's own renderer C++ (Scheduler.cpp), which Expo does not patch. Filing here rather than on the Expo tracker for that reason.

Note on the version field: the production crash below was captured on 0.81.5, where no guard exists. I have filed this against 0.87.1 because I verified by reading v0.87.1's sources that the unguarded path is still the default there. I have not run 0.87.1 myself.

Steps to reproduce

This is a race with a window of a few milliseconds, so the steps below describe the production scenario rather than a deterministic recipe. Reconstructed from the breadcrumbs of the crashing session:

  1. Open a screen with a ScrollView containing several chained TextInputs, where onSubmitEditing calls nextRef.current?.focus(). Each focus() goes through UIManager.dispatchCommand, and therefore through Scheduler::uiManagerDidDispatchCommand.
  2. Type, moving focus between fields so the keyboard shows and hides repeatedly.
  3. Background the app immediately after a focus change. (In the observed session the user left the app to fetch an emailed sign-in code.)
  4. The app crashes on the JS thread while the main thread is still inside the UIApplicationDidEnterBackground observer.

The simultaneity is visible in the report: the main thread is inside __CFNOTIFICATIONCENTER_IS_CALLING_OUT_TO_AN_OBSERVER__ at the moment the JS thread faults.

React Native Version

0.87.1

Affected Platforms

Runtime - iOS

Output of npx @react-native-community/cli info
System:
  OS: macOS 26.6.2
  CPU: (14) arm64 Apple M3 Max
  Memory: 177.64 MB / 36.00 GB
  Shell:
    version: "5.9"
    path: /bin/zsh
Binaries:
  Node:
    version: 22.14.0
    path: /Users/garri/.nvm/versions/node/v22.14.0/bin/node
  Yarn:
    version: 1.22.22
    path: /Users/garri/.nvm/versions/node/v22.14.0/bin/yarn
  npm:
    version: 11.4.2
    path: /Users/garri/.nvm/versions/node/v22.14.0/bin/npm
  Watchman:
    version: 2026.07.27.00
    path: /opt/homebrew/bin/watchman
Managers:
  CocoaPods:
    version: 1.17.0
    path: /opt/homebrew/bin/pod
SDKs:
  iOS SDK:
    Platforms:
      - DriverKit 25.5
      - iOS 26.5
      - macOS 26.5
      - tvOS 26.5
      - visionOS 26.5
      - watchOS 26.5
  Android SDK: Not Found
IDEs:
  Android Studio: 2025.3 AI-253.29346.138.2531.14850935
  Xcode:
    version: 26.5/17F42
    path: /usr/bin/xcodebuild
Languages:
  Java:
    version: 17.0.18
    path: /usr/bin/javac
  Ruby:
    version: 4.0.6
    path: /opt/homebrew/bin/ruby
npmPackages:
  "@react-native-community/cli": Not Found
  react: Not Found
  react-native: Not Found
  react-native-macos: Not Found
npmGlobalPackages:
  "*react-native*": Not Found
Android:
  hermesEnabled: Not found
  newArchEnabled: Not found
iOS:
  hermesEnabled: Not found
  newArchEnabled: Not found

Additional context: Expo SDK 54, Hermes, New Architecture enabled (`newArchEnabled: true`),
`buildReactNativeFromSource: true` on iOS. Crash captured on react-native 0.81.5.
Stacktrace or Logs
EXC_BAD_ACCESS
KERN_INVALID_ADDRESS at 0x344e880dcd072e6e

Crashed thread: com.facebook.react.runtime.JavaScript

facebook::react::Scheduler::uiManagerDidDispatchCommand::lambda::operator()  Scheduler.cpp:286
std::__1::__invoke[abi:ne200100]<T>                                          invoke.h:179
std::__1::__invoke_void_return_wrapper<T>::__call[abi:ne200100]<T>           invoke.h:251
std::__1::__invoke_r[abi:ne200100]<T>                                        invoke.h:273
std::__1::__function::__alloc_func<T>::operator()[abi:ne200100]              function.h:167
std::__1::__function::__func<T>::operator()                                  function.h:319
std::__1::__function::__value_func<T>::operator()[abi:ne200100]              function.h:436
std::__1::function<T>::operator()                                            function.h:995
facebook::react::RCTMessageThread::runAsync                                  RCTMessageThread.mm:44
__CFRUNLOOP_IS_CALLING_OUT_TO_A_BLOCK__                                      CoreFoundation
__CFRunLoopDoBlocks                                                          CoreFoundation
__CFRunLoopRun                                                               CoreFoundation
_CFRunLoopRunSpecificWithOptions                                             CoreFoundation
+[RCTJSThreadManager runRunLoop]                                             RCTJSThreadManager.mm:102
__NSThread__start__                                                          Foundation
_pthread_start                                                               libsystem_pthread.dylib

Main thread at the same moment, processing the background transition:

__CFNOTIFICATIONCENTER_IS_CALLING_OUT_TO_AN_OBSERVER__   CoreFoundation
___CFXRegistrationPost_block_invoke                      CoreFoundation
_CFXRegistrationPost                                     CoreFoundation
_CFXNotificationPost                                     CoreFoundation
__CFRunLoopRun                                           CoreFoundation

Notes:
- Line 286 on 0.81.5 is exactly `delegate->schedulerDidDispatchCommand(shadowView, commandName, args);`
  inside the deferred lambda.
- The faulting address is arbitrary garbage rather than a small offset from null, which is consistent
  with freed-and-reused memory rather than a null delegate.
- Device: iPhone 15 Pro Max (iPhone16,2), iOS 27.0 (24A437, RELEASE kernel), App Store build,
  crash ~48 s after cold start, while the app was moving to the background.
MANDATORY Reproducer

I do not have a standalone reproducer. This is a production crash; the window between queueing the lambda and the delegate being freed is a few milliseconds and I have not been able to force it on demand. I am filing anyway because the defect is verifiable statically rather than empirically: the deferred lambda captures a raw SchedulerDelegate*, the invalidation guard that would neutralise it is compiled in but disabled by default, and the symbolicated crash lands on exactly that dereference. If a running reproducer is a hard requirement, I would appreciate a pointer to the existing tracking issue for enableSchedulerDelegateInvalidation so this report can be attached there instead.

Screenshots and Videos

N/A

Guide de contribution

Ouvrir le guide de contribution

Par où commencer

  1. Lisez l'issue en entier, puis le guide de contribution du projet.
  2. Signalez en commentaire que vous la prenez — cela évite que deux personnes fassent le même travail.
  3. Forkez le dépôt et travaillez sur une branche.
  4. Ouvrez une pull request qui référence le numéro de l'issue.

Piste de recherche

Lisez d’abord Scheduler.cpp, en vous concentrant sur uiManagerDidDispatchCommand et uiManagerDidFinishTransaction, puis comparez le code v0.87.1 référencé avec main. Vérifiez ReactNativeFeatureFlagsDefaults.h et le chemin de teardown de RCTScheduler ; le travail est terminé lorsque les callbacks différés ne peuvent pas déréférencer un Delegate détruit avec la configuration par défaut.

Rédigé par le modèle d'indexation à partir du texte de l'issue.

Évaluation

Stack technique
cpp, ios, react-native
Domaine
mobile
Type d'issue
Bug
Difficulté
4/5
Temps estimé
3-5 jours
Activité
Active
Clarté
Plutôt claire
Accessibilité débutants
48/100

Recevez les nouvelles issues par e-mail

Un résumé court des issues GitHub adaptées aux débutants.