Skip to content

[iOS][Fabric] App hangs forever in RCTSurfaceTouchHandler _registerTouches: - RCTIdentifierPool::dequeue() spins in an infinite loop when all touch identifiers are leaked #59104

Open

Description

@m-szczepanek

Description

On iOS with the New Architecture (Fabric), the app can freeze permanently on the main thread inside -[RCTSurfaceTouchHandler touchesBegan:withEvent:] → -[RCTSurfaceTouchHandler _registerTouches:]. The process never recovers and is eventually killed by the watchdog.

The hang is in RCTIdentifierPool<size>::dequeue() (React/Fabric/Utils/RCTIdentifierPool.h):

int dequeue() {
  while (true) {
    if (!usage[lastIndex]) {
      usage[lastIndex] = true;
      return lastIndex;
    }
    lastIndex = (lastIndex + 1) % size;
  }
}

RCTSurfaceTouchHandler uses a fixed-size RCTIdentifierPool (RCTIdentifierPool<11> in 0.81, RCTIdentifierPool<17> in 0.86). When all slots are marked as used, dequeue() has no exit condition and spins forever on the main thread.

The pool can reach this state because identifiers can leak without ever being returned:

  1. _registerTouches: dequeues before checking for an existing entry.

    - (void)_registerTouches:(NSSet<UITouch *> *)touches
    {
      for (UITouch *touch in touches) {
        auto activeTouch = CreateTouchWithUITouch(touch, _rootComponentView, _viewOriginOffset);
        activeTouch.touch.identifier = _identifierPool.dequeue();
        _activeTouches.emplace(touch, activeTouch);
      }
    }

    if touchesBegan: is delivered for a UITouch already present in _activeTouches, emplace is a no-op, but the identifier has already been taken from the pool. It is never enqueued again, because _unregisterTouches: only returns the identifier stored in the (original) map entry.

  2. reset only resets the pool when _activeTouches is non-empty.

    - (void)reset
    {
      [super reset];
      if (!_activeTouches.empty()) {
        ...
        _activeTouches.clear();
        _identifierPool.reset();
      }
    }

    Identifiers leaked via (1) are not represented in _activeTouches, so if the recognizer resets while the map is empty, the leaked identifiers remain marked as used for the lifetime of the surface.

Once every slot has leaked, the next touch hangs the app. The larger pool in 0.86 only raises the threshold: the unbounded loop, the emplace leak and the conditional reset are unchanged in 0.86.3 and on main (packages/react-native/React/Fabric/Utils/RCTIdentifierPool.h and packages/react-native/React/Fabric/RCTSurfaceTouchHandler.mm)

Steps to reproduce

We have not found a deterministic UIKit sequence that triggers the leak on a real device. The hang itself reproduces deterministically on react-native@0.87.1 (iOS Simulator) by simulating a single leak per tap, which exhausts the pool:

  1. Clone the reproducer (link below) and install:
    cd ReproducerApp
    npm install            # postinstall applies patches/react-native+0.87.1.patch
    cd ios && bundle install && bundle exec pod install && cd ..
    npm start              
    npm run ios         

The Podfile sets RCT_USE_PREBUILT_RNCORE=0 and RCT_USE_RN_DEP=0 so React Native is built from source and the patched RCTSurfaceTouchHandler.mm is compiled.

  1. The patch only adds a second [self _registerTouches:touches]; call in -[RCTSurfaceTouchHandler touchesBegan:withEvent:]. This simulates UIKit delivering touchesBegan for a UITouch that is already registered: emplace is a no-op, so exactly one pool identifier leaks per tap.
  2. The app shows a full-screen Pressable with a tap counter. Tap it 16 times, the counter updates normally until 16 identifiers leaked, 1 free.
  3. Tap 17: the real touch takes the last free identifier, the duplicate registration calls dequeue() on a full pool, and the main thread hangs forever. The counter stops updating and the app no longer responds to input.
  4. Pause in Xcode (Debug → Pause): the main thread is spinning in RCTIdentifierPool<17>::dequeue() inlined into -[RCTSurfaceTouchHandler _registerTouches:], identical to the production stack below.

Expected: a bookkeeping inconsistency in the touch registry should, at worst, reuse a touch identifier not permanently freeze the main thread.
Actual: the app hangs indefinitely and is eventually killed by the watchdog.

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: (12) arm64 Apple M2 Max
  Memory: 9.08 GB / 64.00 GB
  Shell:
    version: "5.9"
    path: /bin/zsh
Binaries:
  Node:
    version: 24.9.0
    path: ~/.nvm/versions/node/v24.9.0/bin/node
  Yarn: Not Found
  npm:
    version: 11.6.0
    path: ~/.nvm/versions/node/v24.9.0/bin/npm
  Watchman:
    version: 2026.07.06.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: 2026.1 AI-261.23567.138.2611.15646644
  Xcode:
    version: 26.6/17F113
    path: /usr/bin/xcodebuild
Languages:
  Java:
    version: 17.0.19
  Ruby:
    version: 3.3.3
npmPackages:
  "@react-native-community/cli": Not Found
  react:
    installed: 19.1.0
    wanted: 19.1.0
  react-native:
    installed: 0.81.5
    wanted: 0.81.5
  react-native-macos: Not Found
npmGlobalPackages:
  "*react-native*": Not Found
Android:
  hermesEnabled: true
  newArchEnabled: true
iOS:
  hermesEnabled: true
  newArchEnabled: true

Stacktrace or Logs

App Hanging: App hanging for at least 5000 ms.

Thread 0 (main, current)
  React           -[RCTSurfaceTouchHandler _registerTouches:]        <- RCTIdentifierPool<11>::dequeue() inlined
  React           -[RCTSurfaceTouchHandler _registerTouches:]
  React           -[RCTSurfaceTouchHandler touchesBegan:withEvent:]
  UIKitCore       -[UIGestureRecognizer _componentsBegan:withEvent:]
  UIKitCore       -[UITouchesEvent _sendEventToGestureRecognizer:]
  UIKitCore       __47-[UIGestureEnvironment _updateForEvent:window:]_block_invoke
  UIKitCore       __47-[UIGestureEnvironment _updateForEvent:window:]_block_invoke.35
  Gestures        __swift_memcpy17_8
  Gestures        __swift_allocate_value_buffer
  UIKitCore       -[UIGestureEnvironment _updateForEvent:window:]
  UIKitCore       -[UIWindow sendEvent:]
  UIKitCore       -[UIApplication sendEvent:]
  UIKitCore       __dispatchPreprocessedEventFromEventQueue
  UIKitCore       __processEventQueue
  UIKitCore       updateCycleEntry
  UIKitCore       _UIUpdateSequenceRunNext
  UIKitCore       schedulerStepScheduledMainSectionContinue
  UpdateCycle     UC::DriverCore::continueProcessing
  CoreFoundation  __CFMachPortPerform
  CoreFoundation  __CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE1_PERFORM_FUNCTION__
  CoreFoundation  __CFRunLoopDoSource1
  CoreFoundation  __CFRunLoopRun
  CoreFoundation  _CFRunLoopRunSpecificWithOptions
  GraphicsServices GSEventRunModal
  UIKitCore       -[UIApplication _run]
  UIKitCore       UIApplicationMain
  <app>           main
  dyld            start

Thread 5 (com.facebook.react.runtime.JavaScript) — idle
  React           +[RCTJSThreadManager runRunLoop]
  CoreFoundation  _CFRunLoopRunSpecificWithOptions
  ...

MANDATORY Reproducer

https://github.com/m-szczepanek/rn-touch-identifier-pool-hang

Screenshots and Videos

N/A — the app freezes with no visual change and touches stop being processed

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions