[] Fatal SIGABRT: picker callback invoked twice (`Callback arg cannot be called more than once`) on New Architecture — iOS and Android
Summary
On the New Architecture, ImagePickerManager's single-shot bridge callback can be invoked more than
once, which React Native treats as a hard LOG(FATAL) → abort(). The result is an unrecoverable,
uncatchable process termination — not a JS exception.
We are seeing this consistently in production across many users and a range of devices/OS versions. Both platforms are affected, by two independent races that produce the same symptom.
This report is based on reading the 8.2.1 source against production crash reports. I do not have a
packaged minimal reproduction — the trigger is timing-dependent (details under Reproduction below).
Line numbers cited are from the published v8.2.1 tag.
Environment
react-native-image-picker |
8.2.1 (latest published) |
| React Native | 0.85.x |
| Architecture | New Architecture / TurboModules enabled on both platforms |
| iOS | Seen on iOS 26.5.2 and 26.6 (arm64 devices) |
| Android | Seen on Android 16 |
| Build | Release / store builds |
Stack trace (iOS)
__pthread_kill
abort
google::logging_fail (logging.cc:1474)
google::LogMessage::Fail (logging.cc:1488)
google::LogMessage::SendToLog (logging.cc:1442)
google::LogMessage::Flush (logging.cc:1311)
google::LogMessageFatal::~LogMessageFatal (logging.cc:2022)
facebook::react::TurboModuleConvertUtils::convertJSIFunctionToCallback (RCTTurboModule.mm:158)
__78-[ImagePickerManager picker:didFinishPicking:]_block_invoke.303 (ImagePickerManager.mm:582)
_dispatch_call_block_and_release
...Some events surface the underlying message directly as the crash title:
SIGABRT: Callback arg cannot be called more than once.
Why this is fatal, not recoverable
RCTTurboModule.mm (RN 0.85) wraps the JS function in a block that aborts before entering JS:
__block std::optional<AsyncCallback<>> callback({rt, std::move(function), jsInvoker});
return ^(NSArray *args) {
if (!callback) {
LOG(FATAL) << "Callback arg cannot be called more than once"; // line 158 — aborts here
return;
}
callback->call([args](jsi::Runtime &rt, jsi::Function &jsFunction) {
jsFunction.call(rt, ...); // line 164 — JS runs here
});
callback = std::nullopt;
};Because the abort at line 158 precedes the JS invocation at line 164, no application-side mitigation
is possible — a try/catch, a promise .catch(), a React error boundary, an isMounted guard inside
the callback body, or ErrorUtils.setGlobalHandler are all unreachable. The only fix is to stop the
native side from invoking the callback a second time.
Android has the equivalent guard in JavaTurboModule.cpp (reported at line 125 in RN 0.84), reached
via JCxxCallbackImpl::invoke — same LOG(FATAL), same unrecoverable abort.
Note this is New-Architecture-specific severity: on the legacy bridge a double invocation logged an error; under TurboModules it terminates the process.
Root cause — iOS
ImagePickerManager.mm has two independent paths to self.callback, and only one of them is guarded:
presentationControllerDidDismiss:(line 497) — fires on interactive (swipe-down) dismissal. Line 499 is a bare, completely unguardedself.callback(@[@{@"didCancel": @YES}]);.picker:didFinishPicking:(line 507) — sets thephotoSelectedflag at lines 511/514, then kicks off asynchronous asset loading and invokes the callback from inside adispatch_group_notifyblock at lines 574 / 582.
The photoSelected flag only guards re-entry into picker:didFinishPicking: itself. It is never
consulted by presentationControllerDidDismiss:. So when a dismissal event and an in-flight async
asset load overlap, both paths invoke the same single-shot callback → abort.
The window is widest for video assets, where loadFileRepresentationForTypeIdentifier: can take
seconds (iCloud download and/or transcode), long after the sheet itself has gone away. Line 582 —
the terminal invoke inside that async block — is exactly the frame in the crash trace above.
Root cause — Android
ImagePickerModuleImpl.java has a separate set of double-invoke paths, same resulting symptom:
onAssetsObtained(lines 172–184) — invokescallback.invoke(...)at line 177 from anExecutorServicebackground task, but only nulls thecallbackfield at line 181, infinally, after the invoke returns. The field is never claimed atomically, and it is read from a background thread whileonActivityResultruns on the UI thread. Additionally, the catch block at line 179 re-invokes the same callback that just threw at line 177 — if line 177 partially succeeded before throwing, that catch block is the double invocation.onActivityResultcancel path (lines 194–206) — line 199 invokes insidetry; thecatchat line 201 invokes again at line 202 with noreturn, so control falls through into theswitch (requestCode)at lines 208–228, which can reachonAssetsObtainedfor a third invoke.launchCamera'ssaveToPhotospermission early-return (lines 74–77) —this.callbackis assigned at line 71, then line 75 invokes and returns without nullingthis.callback(unlike theActivityNotFoundExceptioncatch at lines 110–111, which correctly does). The stale-but-consumed field survives for a lateronActivityResultto invoke again — and the existingthis.callback == nullguard at line 190 cannot catch it, precisely because the field was left non-null.
Reproduction
I do not have a reliable packaged repro — the race is timing-dependent, which is likely why it has been hard to pin down. The conditions that produce it in the field:
iOS
- New Architecture enabled, release build, physical device.
launchImageLibrarywith video allowed (mediaType: 'video'or'mixed').- Select a large or iCloud-hosted video, so
loadFileRepresentationForTypeIdentifier:takes seconds. Throttling the network (Network Link Conditioner) widens the window. - Interactively swipe the sheet down while the asset is still loading.
Android
- New Architecture enabled, release build.
- Trigger a re-entrant
onActivityResultaround picker return (rapid background/foreground, or a configuration change), or exercise thesaveToPhotospermission-denied path inlaunchCamerafollowed by a later picker result.
Because it is a race, neither reproduces every attempt.
Suggested fix
The general fix is to make the callback single-consumption at the source: capture it into a local, null the field, and invoke only if it was non-null — so any subsequent attempt is a silent no-op rather than a second native invocation.
For iOS this is already written: #2406 introduces exactly that (invokeCallback:, serialized on the
main queue) and routes all ten self.callback(...) sites through it, plus adds the missing
photoSelected guard to presentationControllerDidDismiss:. #2413 is a narrower version of the same
guard. Both are open and unreviewed.
For Android, #2410 (filed against a different issue) closes parts of this by marshalling
onAssetsObtained onto the UI thread and adding null-guards, but does not address the launchCamera
saveToPhotos leak in (3) above, and the missing return in (2) remains.
Would maintainers be willing to review #2406? It is the more complete of the two iOS patches, and this crash is unrecoverable for every affected app — there is no application-level workaround available.
Related
Existing issues describing this same fault:
- #2391 —
[] SIGABRT ABORT 0x00000001eb21d2d4— open since 2025-11-10. iOS, same crash signature. - #2414 —
[Android] Fatal crash: "callback arg cannot be called more than once" — race in onAssetsObtained callback lifecycle— open since 2026-08-06. Android; already carries an independent confirmation from a second, unrelated app (RN 0.84.1,react-native-image-picker7.2.3), so this is not specific to 8.2.1.
Open fixes awaiting maintainer review:
- #2406 —
fix(ios): prevent fatal abort from double-invoked picker callback— the more complete iOS fix; recommended. - #2413 —
fix(ios): guard presentationControllerDidDismiss against double callback invocation— narrower subset of #2406. - #2410 —
fix(android): bridgeless-safe callbacks and null-safe mime type detection— filed against a different issue; partially covers the Android paths above.
For context on how long this has been outstanding: the latest published release is v8.2.1 (2025-05-04), so none of the fixes above are available to consumers of the package.
Source: react-native-image-picker/react-native-image-picker