Skip to content

RCTIdentifierPool::dequeue() spins forever when the pool is exhausted, hanging the main thread and freezing the device #58441

Open

Description

@jaismonmd

Description

RCTIdentifierPool::dequeue() is an unbounded while (true) loop that never terminates
once every slot is occupied. When that happens the main thread spins at 100% CPU forever,
the app stops servicing scene updates, and iOS — which waits on the frontmost app — leaves
the whole device unresponsive until FrontBoard's watchdog kills the app (0x8BADF00D).

packages/react-native/React/Fabric/Utils/RCTIdentifierPool.h:

int dequeue() {
  while (true) {
    if (!usage[lastIndex]) {
      usage[lastIndex] = true;
      return lastIndex;
    }
    lastIndex = (lastIndex + 1) % size;   // nothing in the loop ever frees a slot
  }
}

Nothing inside the loop clears a bit in usage, so if all size bits are set the loop can
never exit. This is unchanged in 0.82, 0.85, 0.87 and main.

How the pool gets exhausted

RCTSurfaceTouchHandler uses RCTIdentifierPool<11>. Two paths leak a slot permanently:

  1. _unregisterTouches: skips _identifierPool.enqueue(...) when the touch is not in
    _activeTouches — the continue after the RCTAssert. RCTAssert is compiled out in
    Release
    , so in production this leaks silently with no diagnostic at all.
  2. _registerTouches: always calls dequeue(), but _activeTouches.emplace(touch, ...) is a
    no-op if that UITouch * key already exists, so the freshly taken identifier is orphaned.

The registry desync that drives (1) is already reported in #53303, where it surfaces as a
crash because the assert fires in Debug. In Release it is invisible and simply leaks.

Slots only reset on process restart, so this accumulates over the lifetime of the process.

Evidence from a production hang

iPad (A16) / iPadOS 26.6 / RN 0.81.1, New Architecture, process alive ~43 hours.

Two reports for the same pid:

  • cpu_resource: 90 seconds cpu time over 113 seconds (80% cpu average), Num threads: 1,
    footprint 181 MB. Not memory pressure.
  • Hang report: FRONTBOARD 0x8BADF00D — "scene-update watchdog transgression: app exhausted
    real (wall clock) time allowance of 10.00 seconds"
    .

Symbolicated main thread:

-[RCTSurfaceTouchHandler _registerTouches:]         RCTSurfaceTouchHandler.mm:197
-[RCTSurfaceTouchHandler touchesBegan:withEvent:]   RCTSurfaceTouchHandler.mm:308
-[UIGestureRecognizer _componentsBegan:withEvent:]

Line 197 is activeTouch.touch.identifier = _identifierPool.dequeue();

Register state at the crash PC proves the pool was full:

Register Value Meaning
x9 2047 = 0b11111111111 all 11 slots occupied
x28 11 pool size
x27 0x2E8BA2E8BA2E8BA3 magic constant for % 11
x10 / x11 3 / 8 lastIndex, 1 << lastIndex

The PC was pinned to the tst/b.ne at the bottom of that loop in 14 of 15 CPU samples.

React Native Version

0.81.1 (verified unchanged in 0.82.0, 0.85.0, 0.87.0 and main)

Affected Platforms

iOS (New Architecture / Fabric)

Steps to reproduce

The loop is unbounded by inspection — no runtime reproducer is needed to see it cannot exit:

RCTIdentifierPool<11> pool;
for (int i = 0; i < 11; i++) pool.dequeue();  // fill every slot
pool.dequeue();                               // never returns

In the field it is reached by leaking 11 touch identifiers over a long-lived process.

Suggested fix

Bound the scan. After size steps lastIndex is back where it started, so a further
iteration cannot find anything new:

int dequeue() {
  for (size_t attempt = 0; attempt < size; attempt++) {
    if (!usage[lastIndex]) {
      usage[lastIndex] = true;
      return lastIndex;
    }
    lastIndex = (lastIndex + 1) % size;
  }
  // Every slot taken. Reclaim rather than hang: a reused touch identifier is a
  // transient glitch, an infinite loop takes the whole device down.
  usage.reset();
  usage[lastIndex] = true;
  return lastIndex;
}

This is behaviour-preserving wherever the current code terminates — I ran 200k randomised
allocate/free sequences against both and the returned identifiers are identical. They differ
only in the case that currently hangs forever.

int lastIndex; is also uninitialised and is used as a std::bitset subscript before any
assignment; int lastIndex{0}; would be worth including.

Fixing the leak in RCTSurfaceTouchHandler is worthwhile too, but the unbounded loop is what
turns a leaked identifier into an unresponsive device.

Activity

  1. react-native-bot commented on Sep 10, 2026

    @react-native-bot
    Collaborator

    Warning

    Missing reproducer: We could not detect a reproducible example in your issue report. Reproducers are mandatory and we can accept only one of those as a valid reproducer:


    You can read more about about it on our website: How to report a bug.

  2. jaismonmd commented on Sep 10, 2026

    @jaismonmd
    Author

    Reproducer, as requested.

    No app is needed — the loop is unbounded in isolation, so it can be reproduced
    against the header straight from main in about ten seconds:

    curl -sO https://raw.githubusercontent.com/facebook/react-native/main/packages/react-native/React/Fabric/Utils/RCTIdentifierPool.h
    
    cat > repro.cpp <<'CPP'
    #include "RCTIdentifierPool.h"
    #include <cstdio>
    
    int main() {
      facebook::react::RCTIdentifierPool<11> pool;
      for (int i = 0; i < 11; i++) pool.dequeue();   // occupy every slot
      printf("calling dequeue() on a full pool...\n");
      fflush(stdout);
      pool.dequeue();                                 // never returns
      printf("unreachable\n");
    }
    CPP
    
    clang++ -std=c++20 -o repro repro.cpp && ./repro

    Result:

    calling dequeue() on a full pool...
    <hangs forever at ~100% CPU, "unreachable" is never printed>
    

    <11> matches the instantiation in RCTSurfaceTouchHandler
    (RCTIdentifierPool<11> _identifierPool;), and the loop has no exit other than
    finding a clear bit, so any fully-occupied pool hangs the calling thread.

    On an app-level reproducer

    A UI-level repro isn't practical to hand over, and I don't think it's the useful
    artifact here. Reaching this state in an app requires leaking all 11 identifiers,
    which happens gradually through the _unregisterTouches: / _registerTouches:
    paths described above, over the lifetime of a single process. In our case the
    process had been running ~43 hours before it hit the wall.

    What the app-level behaviour adds is only how the pool empties. Once it is
    empty, the hang above is unconditional. The two are separable, and the unbounded
    loop is the part that turns a leaked identifier into an unresponsive device.

    The production evidence in the original report shows this exact state occurring
    on a real device: register x9 = 2047 (0b11111111111, all 11 slots taken),
    x28 = 11, with the PC pinned to the tst/b.ne at the bottom of this loop in
    14 of 15 CPU samples, ending in a 0x8BADF00D scene-update watchdog kill.

    Happy to open a PR with the bounded-scan fix if that's useful.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    Needs: AttentionIssues where the author has responded to feedback.Needs: ReproThis issue could be improved with a clear list of steps to reproduce the issue.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions