[] Fatal SIGABRT: picker callback invoked twice (`Callback arg cannot be called more than once`) on New Architecture — iOS and Android

Author: PrajwaltechversantCreated Aug 13, 2026Updated Aug 13, 2026
Labels:bug: Bug

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:

objc
__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:

  1. presentationControllerDidDismiss: (line 497) — fires on interactive (swipe-down) dismissal. Line 499 is a bare, completely unguarded self.callback(@[@{@"didCancel": @YES}]);.

  2. picker:didFinishPicking: (line 507) — sets the photoSelected flag at lines 511/514, then kicks off asynchronous asset loading and invokes the callback from inside a dispatch_group_notify block 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:

  1. onAssetsObtained (lines 172–184) — invokes callback.invoke(...) at line 177 from an ExecutorService background task, but only nulls the callback field at line 181, in finally, after the invoke returns. The field is never claimed atomically, and it is read from a background thread while onActivityResult runs 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.

  2. onActivityResult cancel path (lines 194–206) — line 199 invokes inside try; the catch at line 201 invokes again at line 202 with no return, so control falls through into the switch (requestCode) at lines 208–228, which can reach onAssetsObtained for a third invoke.

  3. launchCamera's saveToPhotos permission early-return (lines 74–77)this.callback is assigned at line 71, then line 75 invokes and returns without nulling this.callback (unlike the ActivityNotFoundException catch at lines 110–111, which correctly does). The stale-but-consumed field survives for a later onActivityResult to invoke again — and the existing this.callback == null guard 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

  1. New Architecture enabled, release build, physical device.
  2. launchImageLibrary with video allowed (mediaType: 'video' or 'mixed').
  3. Select a large or iCloud-hosted video, so loadFileRepresentationForTypeIdentifier: takes seconds. Throttling the network (Network Link Conditioner) widens the window.
  4. Interactively swipe the sheet down while the asset is still loading.

Android

  1. New Architecture enabled, release build.
  2. Trigger a re-entrant onActivityResult around picker return (rapid background/foreground, or a configuration change), or exercise the saveToPhotos permission-denied path in launchCamera followed 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:

Open fixes awaiting maintainer review:

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