Description
Calling FirebaseMessaging.instance.requestPermission() from two code paths in quick succession crashes the app on Android with DuplicateTaskCompletionException. The crash is reproducible in production at meaningful scale and has not been addressed in any version through 16.2.0.
Stack trace
java.lang.IllegalStateException: DuplicateTaskCompletionException
at com.google.android.gms.tasks.TaskCompletionSource.setResult(...)
at io.flutter.plugins.firebase.messaging.FlutterFirebaseMessagingPlugin
.lambda$requestPermissions$10(FlutterFirebaseMessagingPlugin.java:371)
Root cause
requestPermissions() in FlutterFirebaseMessagingPlugin.java (line 358) creates a new TaskCompletionSource per call and submits work to cachedThreadPool. Two calls can execute concurrently on separate threads.
The race is in FlutterFirebasePermissionManager:
// Neither field is volatile or synchronized
@Nullable private RequestPermissionsSuccessCallback successCallback;
private boolean requestInProgress = false;
The guard at the top of requestPermissions():
if (requestInProgress) {
errorCallback.onError("A request for permissions is already running...");
return;
}
...is a non-atomic check-then-act on a non-volatile field. Two threads from cachedThreadPool can both read requestInProgress == false before either writes true, both passing the guard. From there:
- Both write
this.successCallback, the second overwriting the first
ActivityCompat.requestPermissions() may be called twice, delivering onRequestPermissionsResult twice
- Both completions attempt
taskCompletionSource.setResult() on the same instance → DuplicateTaskCompletionException
Suggested fix
The minimal fix is to make requestInProgress an AtomicBoolean with a compare-and-set:
private final AtomicBoolean requestInProgress = new AtomicBoolean(false);
// In requestPermissions():
if (!requestInProgress.compareAndSet(false, true)) {
errorCallback.onError("A request for permissions is already running...");
return;
}
Alternatively, defensively guard the TaskCompletionSource completions with trySetResult / trySetException (which return false instead of throwing on an already-completed task):
// Line 371
taskCompletionSource.trySetResult(permissions);
// Line 374
taskCompletionSource.trySetException(new Exception(errorDescription));
The AtomicBoolean fix is more correct (prevents the double OS dialog call). The trySet* fix is a safety net that prevents the crash even if the race occurs.
Versions affected
Verified identical code in firebase_messaging 15.1.6 through 16.2.0. No fix or acknowledgment found in any changelog entry.
Workaround
Cache the in-flight permission Future on the Dart side so only one call ever reaches the native layer:
Future<NotificationSettings>? _permissionFuture;
void _requestPermissionOnce() {
_permissionFuture ??= FirebaseMessaging.instance.requestPermission();
}
Description
Calling
FirebaseMessaging.instance.requestPermission()from two code paths in quick succession crashes the app on Android withDuplicateTaskCompletionException. The crash is reproducible in production at meaningful scale and has not been addressed in any version through 16.2.0.Stack trace
Root cause
requestPermissions()inFlutterFirebaseMessagingPlugin.java(line 358) creates a newTaskCompletionSourceper call and submits work tocachedThreadPool. Two calls can execute concurrently on separate threads.The race is in
FlutterFirebasePermissionManager:The guard at the top of
requestPermissions():...is a non-atomic check-then-act on a non-
volatilefield. Two threads fromcachedThreadPoolcan both readrequestInProgress == falsebefore either writestrue, both passing the guard. From there:this.successCallback, the second overwriting the firstActivityCompat.requestPermissions()may be called twice, deliveringonRequestPermissionsResulttwicetaskCompletionSource.setResult()on the same instance →DuplicateTaskCompletionExceptionSuggested fix
The minimal fix is to make
requestInProgressanAtomicBooleanwith a compare-and-set:Alternatively, defensively guard the
TaskCompletionSourcecompletions withtrySetResult/trySetException(which returnfalseinstead of throwing on an already-completed task):The
AtomicBooleanfix is more correct (prevents the double OS dialog call). ThetrySet*fix is a safety net that prevents the crash even if the race occurs.Versions affected
Verified identical code in
firebase_messaging15.1.6 through 16.2.0. No fix or acknowledgment found in any changelog entry.Workaround
Cache the in-flight permission
Futureon the Dart side so only one call ever reaches the native layer: