[Android] Fatal crash: "callback arg cannot be called more than once" — race in onAssetsObtained callback lifecycle

Author: grimenCreated Aug 6, 2026Updated Aug 13, 2026

Description

We received a production Android crash (SIGABRT) caused by the picker's JS callback being invoked more than once. React Native's TurboModule guard aborts the process with:

callback arg cannot be called more than once

Symbolized native stack (top frames):

#00 abort (libc.so)
#04 google::LogMessageFatal::~LogMessageFatal() [logging.cc:2023]
#05 facebook::react::(anonymous namespace)::createJavaCallback(...)::$_0::operator()(folly::dynamic&&) [JavaTurboModule.cpp:125]
#06 facebook::react::JCxxCallbackImpl::invoke(NativeArray*)
#10 com.facebook.react.bridge.CxxCallbackImpl.invoke
#12 com.imagepicker.e.b   (R8-obfuscated; onAssetsObtained executor lambda)
#14 com.imagepicker.e.a
#16 com.imagepicker.d.run
#17 java.util.concurrent.Executors$RunnableAdapter.call
#19 java.util.concurrent.ThreadPoolExecutor.runWorker

The crashing thread is a ThreadPoolExecutor worker running the lambda submitted in ImagePickerModuleImpl.onAssetsObtained — i.e. the callback was invoked on the async result path after it had already been consumed.

Root cause

onAssetsObtained only clears the callback field inside the async executor task, and the invoke itself is unguarded (ImagePickerModuleImpl.java on main):

java
void onAssetsObtained(List<Uri> fileUris) {
    ExecutorService executor = Executors.newSingleThreadExecutor();
    executor.submit(() -> {
        try {
            callback.invoke(getResponseMap(fileUris, options, reactContext));
        } catch (RuntimeException exception) {
            callback.invoke(getErrorMap(errOthers, exception.getMessage()));
        } finally {
            callback = null;
        }
    });
}

Two ways this invokes the same callback twice:

  1. Race window between scheduling and execution. onActivityResult guards re-entry with if (this.callback == null) return;, but callback is still non-null after the task is submitted and before it runs. If onActivityResult is delivered again in that window (duplicate delivery, or a stale result landing after the picker is re-triggered), a second task is scheduled — each call even creates its own single-thread executor — and both tasks invoke the same callback. The second invoke hits RN's fatal guard.
  2. The catch block itself. If callback.invoke(...) throws after the native side has already consumed the callback, the catch handler invokes the same callback a second time.

Suggested fix

Take the callback atomically before submitting the task, so re-entrant onActivityResult calls see null immediately:

java
void onAssetsObtained(List<Uri> fileUris) {
    final Callback cb;
    synchronized (this) {
        cb = this.callback;
        this.callback = null;
    }
    if (cb == null) return;
    Executors.newSingleThreadExecutor().submit(() -> {
        try {
            cb.invoke(getResponseMap(fileUris, options, reactContext));
        } catch (RuntimeException exception) {
            // getResponseMap failed before invoke, so the callback is still unconsumed
            cb.invoke(getErrorMap(errOthers, exception.getMessage()));
        }
    });
}

The catch stays single-invoke-safe because the argument (getResponseMap) is evaluated before invoke; if it throws, the callback was never consumed. Happy to send a PR if this approach looks right.

Possibly related: #2390 reports the same fatal message on iOS, but the mechanism there is different — this issue is specifically the Android executor race.

Environment

  • react-native-image-picker: 7.1.2 (bug still present in current main / 8.2.1)
  • react-native: 0.85.3 (new architecture)
  • Platform: Android (arm64, production/R8 build)
  • Trigger: launchImageLibrary (picking an image from the gallery)

Source: react-native-image-picker/react-native-image-picker