Skip to content

Android: parallel requestPermission calls leak coroutines and surface as 'JPromise was destroyed' #3834

@qutrek

Description

@qutrek

Opening the camera in our app fires camera, microphone, and location permission requests from the same useEffect. On Android the JS console gets these almost immediately:

ERROR  [Error: Uncaught (in promise, id: 0) Error: java.lang.RuntimeException: Timeouted: JPromise was destroyed!
        at com.facebook.jni.HybridData$Destructor.deleteNative(Native Method)
        at com.facebook.jni.HybridData$Destructor.destruct(HybridData.java:82)
        at com.facebook.jni.DestructorThread$1.run(DestructorThread.java:78)
]
ERROR  [Error: Uncaught (in promise, id: 1) Error: java.lang.RuntimeException: Timeouted: JPromise was destroyed!
... (same)
ERROR  [Error: Uncaught (in promise, id: 2) Error: java.lang.RuntimeException: Timeouted: JPromise was destroyed!
... (same)

The dialogs themselves show, user grants/denies, and the hook selectors (hasPermission / status) update correctly. Only the requestPermission() promises hang.

Setup

const camera = useCameraPermission();
const mic = useMicrophonePermission();
const location = useLocation(); // from react-native-vision-camera-location

useEffect(() => {
  if (!visible) return;
  if (camera.status === 'not-determined') void camera.requestPermission();
  if (mic.status === 'not-determined') void mic.requestPermission();
  if (!location.hasPermission) void location.requestPermission();
}, [visible, camera, mic, location]);
Package Version
react-native-vision-camera 5.0.8
react-native-vision-camera-location 5.0.8
react-native-nitro-modules 0.35.6
react-native 0.83.6
Tested on Android 14 + Android 15

Diagnosis

packages/react-native-vision-camera/android/src/main/java/com/margelo/nitro/camera/extensions/ReactApplicationContext+permissions.kt:

suspend fun ReactApplicationContext.requestPermission(permission: String): Boolean {
  return suspendCoroutine { continuation ->
    val activity = currentActivity ?: throw Error("No Activity!")
    if (activity is PermissionAwareActivity) {
      ...
      val currentRequestCode = permissionRequestCode++
      val listener = PermissionListener { requestCode, _, grantResults ->
        if (requestCode == currentRequestCode) {
          ...
          continuation.resume(hasPermission)
          return@PermissionListener true
        }
        return@PermissionListener false
      }
      activity.requestPermissions(arrayOf(permission), currentRequestCode, listener)
    }
  }
}

Each call passes its own PermissionListener, but RN's PermissionAwareActivity only stores one in-flight PermissionListener field. Calling requestPermissions(...) again before the first result comes back overwrites the previous listener. With three parallel calls, listener A is replaced by B, B is replaced by C. Only listener C's continuation resumes when the user finishes the dialogs; A and B's coroutines suspend forever, the JPromises stay pending until GC, and Nitro's JPromise.hpp destructor produces the rejection above:

~JPromise() override {
  if (isPending()) [[unlikely]] {
    std::runtime_error error("Timeouted: JPromise was destroyed!");
    this->reject(...);
  }
}

The "Timeouted" wording is misleading; there's no actual timeout, it's the destructor catching a never-resolved promise.

Suggested fix

Keep a Map<Int, Continuation<Boolean>> keyed by requestCode and a single dispatcher PermissionListener that resumes whichever continuation matches the incoming code. Sketch:

private val pendingContinuations = ConcurrentHashMap<Int, Continuation<Boolean>>()
private val dispatcher = PermissionListener { requestCode, _, grantResults ->
  val cont = pendingContinuations.remove(requestCode) ?: return@PermissionListener false
  cont.resume(grantResults.firstOrNull() == PackageManager.PERMISSION_GRANTED)
  true
}

requestPermission(...) then registers its continuation in the map and passes the same dispatcher every time, so PermissionAwareActivity's single-listener slot only ever holds the dispatcher.

Alternatively serialize with a Mutex so requests run one at a time. Simpler but the dialogs show sequentially which is worse UX.

Workaround for now

Catching the rejection silences the noise, since the hook's hasPermission / status is the actual source of truth:

if (camera.status === 'not-determined') void camera.requestPermission().catch(() => {});
if (mic.status === 'not-determined')    void mic.requestPermission().catch(() => {});
if (!location.hasPermission)            void location.requestPermission().catch(() => {});

Happy to send a PR with the dispatcher approach if it's the direction you want to take.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions