[Android] Fatal crash: "callback arg cannot be called more than once" — race in onAssetsObtained callback lifecycle
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 onceSymbolized 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.runWorkerThe 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):
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:
- Race window between scheduling and execution.
onActivityResultguards re-entry withif (this.callback == null) return;, butcallbackis still non-null after the task is submitted and before it runs. IfonActivityResultis 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. - 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:
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