Skip to content

DuplicateTaskCompletionException crash in requestPermissions() on Android due to unsynchronized requestInProgress flag #18246

@akhil-afero

Description

@akhil-afero

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();
}

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions