Synchronously forward chained cancellations (#239)
Add a test which chains through multiple `.then` calls and then cancels
the original operation. This test passes with the original code, but
starts failing after fixing a bug in `Completer.complete` in the SDK.
https://dart-review.googlesource.com/c/sdk/+/258929
- Add a list of extra completers to forward cancellation. In places
where the cancellation had been forwarded through a `whenComplete`,
add to the list of extra cancellations instead. This allows the Future
return by the outer cancellation to wait for the other callbacks to
complete before firing.
- When setting up forwarding on an already canceled completer,
immediately forward the cancellation.
- When forwarding cancellations, don't repeatedly call `_cancel()` on a
completer which has already been canceled.
- In `race()`, disable cancellation propagation to prevent a cyclic
cancellation.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 573d87f..210c876 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,5 @@
+## 2.12.0-dev
+
## 2.11.0
* Add `CancelableOperation.fromValue`.
diff --git a/lib/src/cancelable_operation.dart b/lib/src/cancelable_operation.dart
index 09997e0..8cc0c67 100644
--- a/lib/src/cancelable_operation.dart
+++ b/lib/src/cancelable_operation.dart
@@ -4,6 +4,8 @@
import 'dart:async';
+import 'package:collection/collection.dart';
+
/// An asynchronous operation that can be cancelled.
///
/// The value of this operation is exposed as [value]. When this operation is
@@ -81,7 +83,10 @@
// they're not actually cancelled by this.
Future<void> cancelAll() {
done = true;
- return Future.wait(operations.map((operation) => operation.cancel()));
+ return Future.wait([
+ for (var operation in operations)
+ if (!operation.isCanceled) operation.cancel()
+ ]);
}
var completer = CancelableCompleter<T>(onCancel: cancelAll);
@@ -93,7 +98,7 @@
cancelAll()
.whenComplete(() => completer.completeError(error, stackTrace));
}
- });
+ }, propagateCancel: false);
}
return completer.operation;
@@ -224,8 +229,8 @@
onError,
FutureOr<void> Function(CancelableCompleter<R>)? onCancel,
bool propagateCancel = true}) {
- final completer =
- CancelableCompleter<R>(onCancel: propagateCancel ? cancel : null);
+ final completer = CancelableCompleter<R>(
+ onCancel: propagateCancel ? _cancelIfNotCanceled : null);
// if `_completer._inner` completes before `completer` is cancelled
// call `onValue` or `onError` with the result, and complete `completer`
@@ -258,20 +263,16 @@
try {
await onError(error, stack, completer);
} catch (error2, stack2) {
- completer.completeError(
+ completer.completeErrorIfPending(
error2, identical(error, error2) ? stack : stack2);
}
});
- _completer._cancelCompleter?.future.whenComplete(onCancel == null
- ? completer._cancel
- : () async {
- if (completer.isCanceled) return;
- try {
- await onCancel(completer);
- } catch (error, stack) {
- completer.completeError(error, stack);
- }
- });
+ final cancelForwarder = _CancelForwarder<R>(completer, onCancel);
+ if (_completer.isCanceled) {
+ cancelForwarder._forward();
+ } else {
+ (_completer._cancelForwarders ??= []).add(cancelForwarder);
+ }
return completer.operation;
}
@@ -281,6 +282,8 @@
/// Returns the result of the `onCancel` callback, if one exists.
Future cancel() => _completer._cancel();
+ Future<void>? _cancelIfNotCanceled() => isCanceled ? null : cancel();
+
/// Whether this operation has been canceled before it completed.
bool get isCanceled => _completer._isCanceled;
@@ -337,11 +340,18 @@
/// ever complete.
/// Set to `null` when [_inner] is completed, because then it's
/// guaranteed that this completer will never complete.
- Completer<void>? _cancelCompleter = Completer<void>();
+ Completer<Object?>? _cancelCompleter = Completer<Object?>();
/// The callback to call if the operation is canceled.
final FutureOr<void> Function()? _onCancel;
+ /// Additional cancellations to forward during cancel.
+ ///
+ /// When a cancelable operation is chained through `then` or `thenOperation` a
+ /// cancellation on the original operation will synchronously cancel the
+ /// chained operations.
+ List<_CancelForwarder>? _cancelForwarders;
+
/// Whether [complete] or [completeError] may still be called.
///
/// Set to false when calling either.
@@ -497,9 +507,59 @@
if (_inner != null) {
_inner = null;
- var onCancel = _onCancel;
- cancelCompleter.complete(onCancel == null ? null : Future.sync(onCancel));
+ cancelCompleter.complete(_invokeCancelCallbacks());
}
return cancelCompleter.future;
}
+
+ /// Invoke [_onCancel] and forward to other completers in [_cancelForwarders].
+ ///
+ /// Returns the same value as [_onCancel]. Legacy uses may return a value
+ /// despite the signature having `void` return.
+ Future<Object?> _invokeCancelCallbacks() async {
+ final FutureOr<Object?> toReturn = _onCancel?.call();
+ final isFuture = toReturn is Future;
+ final cancelFutures = <Future<Object?>>[
+ if (isFuture) toReturn,
+ ...?_cancelForwarders?.map(_forward).whereNotNull()
+ ];
+ final results = (isFuture && cancelFutures.length == 1)
+ ? [await toReturn]
+ : cancelFutures.isNotEmpty
+ ? await Future.wait(cancelFutures)
+ : const [];
+ return isFuture ? results.first : toReturn;
+ }
+}
+
+class _CancelForwarder<T> {
+ final CancelableCompleter<T> completer;
+ final FutureOr<void> Function(CancelableCompleter<T>)? onCancel;
+ _CancelForwarder(this.completer, this.onCancel);
+
+ Future<void>? _forward() {
+ if (completer.isCanceled) return null;
+ final onCancel = this.onCancel;
+ if (onCancel == null) return completer._cancel();
+ try {
+ final result = onCancel(completer);
+ if (result is Future) {
+ return result.catchError(completer.completeErrorIfPending);
+ }
+ } catch (error, stack) {
+ completer.completeErrorIfPending(error, stack);
+ }
+ return null;
+ }
+}
+
+// Helper function to avoid a closure for `List<_CancelForwarder>.map`.
+Future<void>? _forward(_CancelForwarder<Object?> forwarder) =>
+ forwarder._forward();
+
+extension on CancelableCompleter {
+ void completeErrorIfPending(Object error, StackTrace stackTrace) {
+ if (isCompleted) return;
+ completeError(error, stackTrace);
+ }
}
diff --git a/pubspec.yaml b/pubspec.yaml
index 61dd362..9802f78 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,5 +1,5 @@
name: async
-version: 2.11.0
+version: 2.12.0-dev
description: Utility functions and classes related to the 'dart:async' library.
repository: https://github.com/dart-lang/async
diff --git a/test/cancelable_operation_test.dart b/test/cancelable_operation_test.dart
index 63ba2c3..f72f7e9 100644
--- a/test/cancelable_operation_test.dart
+++ b/test/cancelable_operation_test.dart
@@ -568,6 +568,17 @@
expect(operation.value, completion('foo'));
expect(operation.isCanceled, false);
});
+
+ test('waits for chained cancellation', () async {
+ var completer = CancelableCompleter<void>();
+ var chainedOperation = completer.operation
+ .then((_) => Future.delayed(Duration(milliseconds: 1)))
+ .then((_) => Future.delayed(Duration(milliseconds: 1)));
+
+ await completer.operation.cancel();
+ expect(completer.operation.isCanceled, true);
+ expect(chainedOperation.isCanceled, true);
+ });
});
group('returned operation canceled', () {