margelo / margelo/react-native-vision-camera

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

Open
#3,834 1 comment 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
TypeScript
Stars
9.6k
Forks
1.4k
Avg merge
1d 28m
Merged PRs (30d)
4

Description

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.

Contributor guide

No contributing guide indexed for this repository

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start in packages/react-native-vision-camera/android/src/main/java/com/margelo/nitro/camera/extensions/ReactApplicationContext+permissions.kt and inspect how PermissionAwareActivity stores permission listeners. Reproduce three concurrent requests on Android 14 or 15, then verify that every requestPermission promise settles and no JPromise destruction errors occur. No test file is named in the issue, so add or run coverage for concurrent permission results if the repository provides a suitable native test entry point.

Written by the indexing model from the issue text.

Assessment

Tech stack
android, kotlin, react-native, typescript
Domain
mobile, security
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Clearly specified
Newbie friendliness
55/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.