margelo / margelo/react-native-vision-camera
Android: parallel requestPermission calls leak coroutines and surface as 'JPromise was destroyed'
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
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- 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