Part of what makes Dart great is its excellent async support, but it is in dire need of a unified mechanism for cancellation.
Currently, there are a variety of inconsistent cancellation APIs for different operations (e.g. ConnectionTask for Socket.startConnect(), StreamSubscription.cancel(), HttpClientRequest.abort()), and some lack any cancellation support at all (most notoriously, HttpClient.getUrl() as noted in dart-lang/sdk#51267). The facilities to write your own cancellable operations (CancelableCompleter to build a CancelableOperation) are also inadequate.
There have been many requests in the past for better cancellation, but shockingly little consideration of modern techniques. The most active discussion is in dart-lang/sdk#42855, which proposes a mechanism that is still needlessly complex and fragile.
I propose using cancellation tokens, as developed in C# and later adopted by JavaScript. These are explicit objects you pass around to represent the potential for cancellation, rather than adding that functionality to Future objects. Using them in Dart would have essentially no downsides (except implementation effort) while dramatically improving the cancellation story.
- Proposal: https://gist.github.com/arthur-tacca/accbd333a6378619936e34d184b0d152
- Discussion: dart-lang/sdk#63017
- Code examples: https://github.com/arthur-tacca/dart-cancel-examples
- Cancel tokens: Core idea
- Cancel tokens: More details
- Structured concurrency
- Why not the current API?
- Why not cancel scopes?
- Why not cancellable future (dart-lang/sdk#42855)?
- Appendix: Even more details
You would use a token like this:
Future<Uint8List> remoteRead() async {
final controller = AbortController();
mightCallLater(() => controller.abort());
try {
return await readBytes('example.com', 8080, 100, signal: controller.signal);
} on AbortException {
// It's rude to let an internal AbortException leak out of a function
throw MyException("Operation was interrupted");
}
}Here’s what's going on:
- First, we create an object that allows performing the cancellation, called an
AbortControllerin JavaScript andCancellationTokenSourcein C#. (I've used the JS naming here but I don't have a strong preference.) - Then we arrange for that to possibly be cancelled later. Here I've assumed there is a function in the application called
mightCallLater()that might call the callback passed to it later (e.g. when the user clicks a "cancel" button or a timer expires). - Then we get the actual cancel token, called
AbortSignalin JS andCancellationTokenin C#, and pass it to a function that supports it (here I've passed it to an application functionreadBytes(), which I'll describe below). - That cancellation-respecting function passes the token recursively to other functions. If the token is cancelled then whatever is being ultimately being awaited (e.g. networking IO or waiting for a timer) will be immediately interrupted, throwing an "AbortError"
DOMException(JS) /OperationCanceledException(C#) which propagates up until caught in the function that created the cancel token. I've called itAbortExceptionin the examples to adapt the JS exception to Dart naming.
To see how simple this makes cancellation-safe code, let's look at the implementation of that readBytes() function we called in the example above. Let's say that we want it to connect to a TCP socket, read some bytes, then disconnect; and imagine that Dart the Socket class has been updated to allow passing cancel tokens to Socket.connect() and a new method Socket.iterate() which you can iterate over with async for. Here's what it would look like:
Future<Uint8List> readBytes(
String host,
int port,
int byteCount, {
AbortSignal? signal,
}) async {
final socket = await Socket.connect(host, port, signal: signal);
try {
final buffer = BytesBuilder(copy: false);
await for (final chunk in socket.iterate(signal: signal)) {
buffer.add(chunk);
if (buffer.length >= byteCount) break;
}
if (buffer.length < byteCount) {
throw SocketException('Connection closed before $byteCount bytes received');
}
return buffer.takeBytes();
} finally {
socket.destroy();
}
}The application logic of this function isn't important… the key thing to notice is that it's identical to how you'd write this function without cancellation support, but with some care to pass cancel tokens around. Compare this against the CancelableCompleter version of readBytes() in section 4, where the control flow is lost in the noise of cancellation support.
- No changes to
Futureclass (clean separation of concerns) - Explicit about when cancel exception will be raised (not magically injected at any
await) - Cancellation propagation rules are obvious (propagates downwards if you pass a token; propagates upwards if passed-in token is cancelled, with a little boilerplate if you need to combine with your own token)
- Cancelled tokens stay cancelled ("level-triggered cancellation" rather than "edge-triggered cancellation" where an exception is injected as a one-time event; this avoids the race condition in the CancelableCompleter example below and is generally clearer)
- Proven pattern (used since 2009 in C# and adopted in 2017 in JavaScript)
- Matches JavaScript (so likely to be easier to implement on web target)
- Can be implemented incrementally (since it's obvious whether a function supports cancellation from whether it has a cancellation token parameter)
A very common use of cancellation is for timeouts. This is very easy with cancel tokens:
Future<Uint8List> remoteRead() async {
final timeout = const Duration(seconds: 1);
try {
return await readBytes('example.com', 8080, 100, signal: AbortSignal.timeout(timeout));
} on AbortException {
throw TimeoutException("Operation was timed out", timeout);
}
}A cancel token constructed with AbortSignal.timeout() will automatically become cancelled after the specified duration. (C# has the facility on CancellationTokenSource.CancelAfter() which is more flexible but a little more fiddly.) Note that this duration then applies absolutely from the point of constructing the token, so it works as expected even if it's used for multiple different operations, unlike per-operations timeouts.
Another helpful feature of cancel tokens is the ability to combine several to create a new one that fires when any of the original ones do (AbortSignal.any() in JS, CancellationTokenSource.CreateLinkedTokenSource() in C#). This is most often useful for when a function wants to support a token as a parameter, to allow the caller to cancel it, but also wants to support cancellation for its own internal reason. For example:
Future<Uint8List> remoteRead({AbortSignal? signal}) async {
final timeout = const Duration(seconds: 1);
try {
final combinedSignal = AbortSignal.any([
AbortSignal.timeout(timeout),
if (signal != null) signal,
]);
return await readBytes('example.com', 8080, 100, signal: combinedSignal);
} on AbortException {
if (signal?.aborted == true) {
rethrow;
}
throw TimeoutException("Operation was timed out", timeout);
}
}There are conventions that should be followed when using cancel tokens that ought to be noted in the docs:
- Don't allow your own
AbortExceptionto leak out; transform to another exception type if you want to report it with an exception. - Don't accidentally capture parent AbortException (even if current scope is cancelled); use the pattern in the snippet above to avoid that.
- Don't wait on a task that won't be cancelled by your token as it means that cancellation won't actually interrupt your operation. There should be a new method
Future<T>.waitDone(signal)to allow wait that can be cancelled with any token but bear in mind the underlying future will continue. - Don't allow AbortException to propagate from future with different token (e.g. in a different nursery).
- An advanced but valid technique is to deliberately not pass a cancel token when performing async cleanup from cancellation; but consider using a separate timeout to ensure this doesn't delay overall cancellation forever. (This is analogous to Trio shielded cancellation scopes.)
This is only a half-formed idea in my head, but as a fun extension you could also allow cancel tokens (not controllers / sources) to be sent to other isolates:
Future<Uint8List> processRemotely(String host, int port, {AbortSignal? signal}) async {
return await Isolate.run(() async {
// signal works exactly as it would in the parent isolate
final data = await readBytes(host, port, 100, signal: signal);
return expensiveTransform(data, signal: signal);
});
}This is a deeper change than most other things in this proposal because it adds a fundamentally new type of communication mechanism between isolates, which I presume would not be straightforward.
Having decent cancellation support unlocks an important technique for simpler and safer concurrent code: structured concurrency.
The idea is to introduce a new mechanism for spawning concurrent work called a nursery or sometimes a task group. The key property of a nursery is that it always waits for the tasks in it to finish. If one raises an exception then it cancels all the rest (but still waits for them all to finish). It then re-raises the exception(s).
Structured concurrency was pioneered in the excellent Python library Trio (in 2017), and has been widely adopted since then, e.g. Kotlin (CoroutineScope), Swift (TaskGroup / ThrowingTaskGroup) and Python asyncio (TaskGroup). The author of Trio introduced it in the seminal article Go statement considered harmful (highly recommended reading).
Here's what it might look like to use a nursery in Dart implemented using cancel tokens:
Future<Map<String, Uint8List>> remoteReads() async {
final results = <String, Uint8List>{};
await usingNursery((nursery) async {
nursery.spawn((signal) async {
results['alpha'] = await readBytes('alpha.example.com', 8080, 100, signal: signal);
});
nursery.spawn((signal) async {
results['beta'] = await readBytes('beta.example.com', 8080, 100, signal: signal);
});
});
return results;
}(The syntax is a little awkward because Dart has no native syntax to represent the lexical scope of an object (like Python async context managers, C# await using, JS await using) so the best we can do is an immediately executed closure, but I think it's usable)
What happens here is that two tasks are spawned, reading from two different sockets. The nursery waits for them both to complete, a little like Future<T>.wait(). You could think of it as the execution sitting there at the "close bracket" of the usingNursery function. If the tasks both complete, no problem, the nursery finishes. But if one fails then the other is cancelled and the nursery waits until the cancellation completes; this should be almost instant, since the operation has been cancelled, but it allows clean up to run e.g. in finally blocks. (Another difference from Future.wait() is that you can spawn arbitrarily many tasks into a nursery e.g. you could use it to create a task each time an incoming connection is accepted.)
The benefit of this is that if you only spawn tasks into nurseries (not free-standing background tasks), you never need be surprised that a function you've called has secretly spawned some task in the background that continues running after it returns. I really suggest reading the article for motivation.
Just to be clear: nurseries are extra functionality layered on top of cancellation support. Cancellation support, without structured concurrency, would still be extremely useful. Nurseries could always be added later (but it would certainly be a big bonus if they came at the same time).
Also, nurseries usually imply cancel scopes (see section 5) rather than cancel tokens. Certainly, that makes them a bit neater and less error prone to use. But I think you can see from the above example that they're still perfectly useable with cancel tokens.
For comparison, we'll look at the same example as we did for the cancel token (reading some byes from a socket), this time using only facilities currently present in Dart. Let's start by looking at how we could call a cancellable operation:
Future<Uint8List> remoteRead() async {
final op = readBytes('example.com', 8080, 100);
mightCallLater(() => unawaited(op.cancel()));
final data = await op.valueOrCancellation();
if (!op.isCanceled) {
return data!;
} else {
throw MyException("Operation was interrupted");
}
}It's a bit less tidy than the cancel token version, but overall it's fine. The real trouble starts when you want to compose more than one thing that can be cancelled, as in the implementation of readBytes():
CancelableOperation<Uint8List> readBytes(String host, int port, int byteCount) {
ConnectionTask<Socket>? connectTask;
StreamSubscription<Uint8List>? subscription;
Completer<void>? readDone;
final completer = CancelableCompleter<Uint8List>(
onCancel: () {
connectTask?.cancel();
subscription?.cancel();
readDone?.completeError(
SocketException('Operation cancelled'),
);
},
);
() async {
Socket? socket;
try {
connectTask = Socket.startConnect(host, port);
socket = await connectTask!.socket;
final buffer = BytesBuilder(copy: false);
readDone = Completer<void>();
subscription = socket.listen(
(chunk) {
buffer.add(chunk);
if (buffer.length >= byteCount) {
subscription!.cancel();
readDone!.complete();
}
},
onError: (e, st) {
readDone!.completeError(e, st);
},
onDone: () {
readDone!.completeError(
SocketException('Connection closed before $byteCount bytes received'),
);
},
cancelOnError: true,
);
await readDone!.future;
completer.complete(buffer.takeBytes());
} catch (e, st) {
completer.completeError(e, st);
} finally {
socket?.destroy();
}
}();
return completer.operation;
}Ouch.
This is obviously much more complex than the cancel token version; the actual logic is drowned out by cancellation admin. It was also hard to subscribe to the stream in a way that can be cancelled (see inside the fold below for more details).
Worst of all: it isn't even correct! There are a couple of very subtle bugs in this code (also discussed in the fold below) that just cannot arise in the cancel token version. I had to read the source code to the relevant Dart classes to be sure I'd fixed them.
This really isn't a reasonable situation.
Expand this fold to see the options I considered for iterating over the stream, and to see the bugs in this code and how to fix them. Challenge to anyone who thinks things are fine as they are: find and fix the bugs yourself before looking!
Options I considered for iterating over the stream:
- We can't just use
async forbecause there's no way to cancel the implicit stream subscription (except from within the loop by breaking out of it). - We can't use
await subscription.asFuture()(omittingonError/onDone), even though that would have been a lot simpler, because that future never completes if the subscription is cancelled, as ours is if the operation is cancelled or we get enough bytes. - We could directly call
completer.complete(buffer.takeBytes());in the mainonDatacallback and duplicate thesocket?.destroy()call there, inonError,onDoneand the overallonCancelhandler but that's a lot of duplication and doesn't generalise if this were part of a longer function that had more logic after this step. - So, overall, manually setting my own completer was the simplest I could think of. But I'm still not confident it's the simplest possible!
Bugs in the code above:
- First bug: If cancelled after completed (even much later),
onCancelcallsreadDone?.completeError()on an already completed Completer. This isn't allowed so it raises an exception, which propagates synchronously out to the caller ofCancelableOperation.cancel()(though I had to look at the source code to figure that out!). The fix to this is to check!readDone!.isCompleted()everywhere it's set. - Not a bug (I think): This naturally leads to the question: could the overall completer be completed twice? I'm pretty sure it can't because the only calls are in the
exceptblock and at the end of the corresponding block (i.e. when no exception). But the fact you have to check and think about it I think illustrates the complexity of this approach. - Also not a bug (I think): It's also not clear whether it's safe to complete the overall operation after it's cancelled (or, vice-versa, whether it's safe to call cancel after the result has been set) – again, I had to look at the source code to
CancelableCompleterto check! It turns out that it's safe to complete and cancel the sameCancelableCompleter(the first one "wins"). But it's very tempting to scatter checks for completion and cancellation all over the place to avoid odd errors. - Second bug (worse!): What happens if the socket connection happens to complete just as cancellation is requested? I had to look at the source code of
ConnectionTaskbecause the docs don't specify all cancellation semantics (sound familiar?). It turns out that, if it has already completed, thencancel()is silently discarded. We actually depend on this behaviour, because we callcancel()unconditionally inonCancel, even if we've got to the reading part. But, ifreadBytes()has been scheduled but not run with its result,subscriptionwill still be null (hasn't been created yet) so won't be cancelled. So the whole function will carry on, silently ignoring the cancellation!CancelableOperation.valueOrCancellationwill still return straight away so you won't be able tell. At best, you leak a network resource; in a more complex situation, a function with side effects might continue even though it's been cancelled.
Here is the fixed code. But is it really fixed? Would you feel comforable putting this in production?
CancelableOperation<Uint8List> readBytes(String host, int port, int byteCount) {
ConnectionTask<Socket>? connectTask;
StreamSubscription<Uint8List>? subscription;
Completer<void>? readDone;
final completer = CancelableCompleter<Uint8List>(
onCancel: () {
connectTask?.cancel();
subscription?.cancel();
if (readDone != null && !readDone!.isCompleted) { // fix for bug 1
readDone!.completeError(
SocketException('Operation cancelled'),
);
}
},
);
() async {
Socket? socket;
try {
connectTask = Socket.startConnect(host, port);
socket = await connectTask!.socket;
if (completer.isCanceled) {
throw SocketException('Operation cancelled'); // fix for bug 2
}
final buffer = BytesBuilder(copy: false);
readDone = Completer<void>();
subscription = socket.listen(
(chunk) {
buffer.add(chunk);
if (buffer.length >= byteCount && !readDone!.isCompleted) { // fix for bug 1
readDone!.complete();
}
},
onError: (e, st) {
if (!readDone!.isCompleted) readDone!.completeError(e, st); // fix for bug 1
},
onDone: () {
if (!readDone!.isCompleted) { // fix for bug 1
readDone!.completeError(
SocketException('Connection closed before $byteCount bytes received'),
);
}
},
cancelOnError: true,
);
await readDone!.future;
completer.complete(buffer.takeBytes());
} catch (e, st) {
completer.completeError(e, st);
} finally {
socket?.destroy();
}
}();
return completer.operation;
}The most modern technique for cancellation is actually not cancel tokens but cancel scopes.
A cancel scope is a lexical scope that implicitly applies to all tasks started within it (including subtasks recursively), and when cancelled will cancel all those tasks. It's a bit like a cancel token but, because it applies implicitly, it frees you from the boilerplate of passing cancel tokens around (and possibly forgetting).
Cancel scopes were first developed in Trio, which is the library referred to in the section above about structured concurrency. The creator also wrote an article about them Cancellation for humans (recommended reading).
If cancel scopes were implemented in Dart then using them would look like this:
Future<Uint8List> remoteRead() async {
Uint8List? result;
await usingCancelScope(
(cancelScope) async {
mightCallLater(() => cancelScope.abort());
result = await readBytes('example.com', 8080, 100);
},
onCancelCaught: () {
throw MyException('Operation was interrupted');
},
);
return result!;
}
This is arguably a bit neater than the token version, but admittedly not by much (and it's a pity we can't just return the result directly from within the scope because it's a nested function).
The real advantage is when you write a function composed of multiple operations, like readBytes() above. The cancellation-safe implementation of readBytes() is like the one for cancel tokens above, but with no mention of cancellation whatsoever! It just works! That's because cancellation scopes are inherited by tasks spawned within it, so all the calls are automatically cancellable (so long as the lowest-level APIs used support cancellation).
They also make using nurseries safer, which typically use cancel scopes (for cancelling other tasks when one throws an exception). The cancel token version we discussed earlier does work, but there can be a few tokens floating around when using nurseries in a complex situation so there's a risk of using the wrong one somewhere; this can't happen with cancel scopes.
As much as I think cancel scopes are superior to cancel tokens, there are a couple of issues which I think probably mean they're not suitable in Dart:
- It's a backwards incompatible change, in that some Dart SDK calls will now be able to throw a cancellation exception when they wouldn't in the past; there's a potential for resource loss in code that doesn't expect this, when called from within cancellation scopes or nurseries (like a "cancellation-aware sandwich"). For example, in
readBytes()above, if the socket was closed inexcept SocketExceptioninstead of afinallyblock then it would be missed when cancelled. Carefully written exception-safe code is unlikely to have this issue, but it's still a possibility. - There's no perfect (at least, non-surprising) solution of how to treat cancellation of async functions not explicitly spawned into nurseries. This can't just be ignored because
await foo()involves spawning a task as an intermediate step (like in C# and JavaScript – which also use tokens rather than scopes). It would probably be best to associate a task with a cancel scope based on when it was spawned (not when it's awaited) but even this is still fairly surprising and also not fully backwards compatible.
Expand this fold to see much more detail on the last point (options for applying cancellation to async functions within a cancel scope).
foo()is shorthand forenclosing_nursery.spawn(foo): This doesn't work because that would break functions that assume they can spawn tasks that last an arbitrarily long time (they would stop the nursery from finishing). Also, it still violates structured concurrency (because you can spawn into a nursery outside the current async function, which ought to be disallowed).- Tasks spawned without explicitly using a nursery never get cancellation: (In other words, if you don't use nurseries then you've decided to use legacy unsafe tasks and you're on your own – if that were possible then that would be a reasonable position.) This isn't viable because
await foo()involves spawning a task as an intermediate step and it would be unreasonable to ban simple function calls like this. - Directly awaited async function calls (
await foo()) are spawned into nurseries, any other use of futures (likeFuture<T> f = foo()) are never cancelled: Even if there were no obvious issues, this already feels terrible. But there's also the problem of yow to treat something likeawait bar(foo(n))wherefoo()andbar()are both async: It could be thatbar()storesfoo()to use later (i.e. it's more likeFuture<T> f = foo()), or it could be likeawait foo().then(…)(which is more likeawait foo()). - Tasks (not in nurseries) are cancelled based on when they're awaited: Now futures need to know how to be cancelled, which we were hoping to avoid (and it means we could cancel any task when given a Future representing it – and maybe will by accident if this nursery finishes with an exception). It also causes exactly the sort of race we saw earlier (though that can be avoided by only ever using non-nursery tasks in expressions that are immediately awaited).
- Tasks are cancelled based on when they were spawned: So if we run
foo()in a cancellation scope then, even if that is assigned straight into aFuture<T>and awaited much later (or never!), that function will be cancelled when this scope is cancelled. Now the question is: what happens when the scope exits with some associated tasks still running? I think the tasks shouldn't be immediately cancelled when the scope finishes (that would break calls that spawn long-running tasks). So now you have a scope that has completed but still has associated tasks. I think that's not actually a problem in itself (the scope designates which tasks it applies to, not the lifetime of the tasks) and you could even hold a reference to the scope and cancel it later, but it's certainly surprising (I'm not aware of any other async framework that does this). And it's still not backwards compatible overall (if the scope does get cancelled, especially if it's associated with a nursery that exits with an exception, then tasks intended to be long-running background tasks get cancelled).
This whole discussion is not an issue for any other languages with structured concurrency that I'm aware of (Python/Trio, Kotlin, Swift), because for those await foo() is essentially an atomic step (from a task point of view); it just directly runs the function (yields from it, in Python's case). The only other way to run a task is through a nursery/coroutineScope/TaskGroup. (Python asyncio, which has some structured concurrency features retrofitted, has all three types of call: await foo(), asyncio.create_task(foo()), and tg.create_task(foo()) where tg is an asyncio.TaskGroup, but uses await-based cancellation.)
Most discussion so far has been in issue dart-lang/sdk#42855 (Add possibility to truly cancel/dispose Future). This includes a concrete proposal (in this comment by lrhn and refined in the gist linked to from this comment by mraleph), which is an odd hybrid of task/await based cancellation (like Python asyncio) and cancel tokens with a bunch of special cases. I can see how it got to that point, but I think it's already clear that it's not sensible in light of the techniques discussed above. Still, here are a few specific issues with it.
- Modifies interface of Future – not necessary with cancel tokens or cancel scopes
- Injects cancel exception at any await point – if you're going to do this, you might as well use proper cancel scopes
- Propagates cancellation based on await point (like asyncio, as opposed to spawn point like cancel scopes) – experience with asyncio is that this is too fragile because cancellation relationships are constantly changing depending on the execution point e.g. if task A spawn tasks X and Y then waits for them in sequence, and if A is cancelled while still at X, then Y might continue.
- Propagates cancellation both down and up, like asyncio; this is a problem when combined with the previous point because it can create highly surprising situations e.g. if tasks A and B are both waiting on some common task X then cancelling A will cancel B!
Another issue with that proposal is that it has a very unusual and complicated mechanism to synchronously detect whether cancellation succeeded. It seems to be motivated by the misunderstanding that this is needed to avoid values being accidentally discarded. To quote the example from it:
Future<Resource> asyncAcquire();
void asyncConsume() async {
final r = await asyncAcquire();
r.release();
}The proposal seems to assume that there are three possibilities here:
- The function
asyncConsume()is not cancelled. This is fine: the resource is allocated and then deallocated. - The function
asyncConsume()is cancelled and (presumably as a consequence)asyncAcquire()is cancelled too. This is also fine: the resource never got allocated (orasyncAcquire()automatically released it as part of its cancellation) so doesn't need releasing. - The function
asyncConsume()is cancelled but, for some reason,asyncAcquire(), is not successfully cancelled. This is a problem: the resource is allocated and returned butasyncConsume()never sees it so it doesn't get released.
But the way to mitigate situation number 3 is not some mechanism to detect it happening at runtime. The actual solution is just not allow it in the first place: we never drop a running Future on the floor. Instead, we arrange that it ought to be cancelled (by whatever mechanism gets decided on) but then just allow the await to continue as usual. Usually, the inner function will raise cancelled exception, and so that will naturally propagate out of the Future. But, occasionally, a "cancelled" function will still return successfully (e.g. because it has already just finished at the time of cancellation but the caller hasn't had a chance to resume; or because it suppressed cancellation deliberately). And that's fine: its caller will see the return value (eventually) and process/release it as usual. If the caller then awaits another async function then it will have another chance to see the cancellation at that point. Overall, cases 1 and 2 are the only possibilities.
This section just has some sketched notes for the overall updates that would be needed if this proposal were accepted.
Cancel token:
See example implementation in this Gist
- For core cancel token class functionality, take inspiration from JS or C#. JS is probably simpler for integration in web target and has simpler timeout API. (It would be polite to the dio package to use the syntax of their
CancelTokenclass to make migration easier for them in future, but that class has HTTP-specific fields and no source/token split, so it's not suitable.) - But I object to the
reasonparameter and field though, as they encourage handling a cancellation based on triggered it first, rather than a more-parent reason that may have happened since then. - There needs to be a way to register and unregister for callbacks on cancel (which register would call immediately if already cancelled). In JS this is AbortSignal: abort event and in C# it's
CancellationToken.Register()(which returns an object that allows unregistering). Unregistration is important because a cancel token could live the length of a program with unbounded associated operations. - We could have a context manager to help combine with a parent token and correctly handle (including the correct check in the
AbortExceptionhandler), with cancelCaught callback for when it really is from this layer's token. It could potentially also detect a leaked cancellation exception from elsewhere, and optionally convert cancellation exception to timeout exception (maybe as a separate "fail after" helper liketrio.fail_after()).
Changes to Future API:
- Add
Future<T>.waitDone(signal)to allow wait that can be cancelled (even when Future cannot be cancelled with same signal) - Add
Future<T>.resultor some other sync method to get result afterwaitDone(ideally a way to get exception without it throwing, but less important). If not possible (due to web client), could haveFuture<T>.waitDone()return some specialCompleted<T>object that has sync methods. (This is more important for cancel scopes; for cancel tokens it's workable to use await future once you know it's done.) - Soft deprecate
CancelableCompleter; addCancelableOperation.canceledBy(signal)method to help transitioning to signals. (Returns the future, so can be used like:final result = await someOp().canceledBy(signal))
Update existing cancellable APIs to use cancel tokens:
- Timer / Future.delayed
- Many methods of
Stream. It should be possible to do cancellableawait foriteration, perhaps with a new method (like above example) - socket connect (and SecureSocket.connect() and web socket connect etc.)
- ...
Add cancel to APIs currently missing it:
- Socket.flush(), SocketServer.bind(), ...?
- HttpClient.getUrl() (and similar methods)
- perhaps reading a file (for large files, the reading thread would check for cancellation in between reading large chunks)
- InternetAddress.lookup
- ...
Most cancellable functions are created by just composing together other existing cancellable functions, like readBytes() above. Here is an example of actually implementing a cancellable function from scratch, with some API that allows cancellation through some other channel. This is a cancellable version of Socket.connect(), implemented in terms of the legacy Socket.startConnect() function:
Future<Socket> connectSocket(
String host,
int port, {
AbortSignal? abortSignal,
}) async {
abortSignal?.throwIfAborted();
final task = Socket.startConnect(host, port);
final abortRegistration = abortSignal?.register(() => task.cancel());
try {
return await task.socket;
} on SocketException {
if (abortSignal?.aborted ?? false) {
throw AbortException();
}
rethrow;
} finally {
abortRegistration?.unregister();
}
}What should the base class of AbortException be? Error is definitely not right as it is not a programmer error to cancel something. But there are still a few choices:
- Exception: The obvious choice, since all non-Error exceptions currently derive from this.
- Object: This has the benefit that any
try/catchblocks that catch all exceptions of type Exception will allow AbortException through, as they probably should. - BaseException: If deriving an exception directly from Object feels too sloppy, another option is to introduce a shared base class for all exception classes, newly used as the base for Exception and Error, and derive AbortException from that.
This has precedence in Python. That has an Exception type, which is the base class for almost all exceptions. The base class for that is BaseException, which has the actual functionality of exceptions (mainly relating to tracebacks, which is sorely missed from Dart exception classes). A very small number of exceptions are derived directly BaseException, such as KeyboardInterrupt (which is injected when Ctrl+C is hit by the user). It's so sparingly used that even AssertionError and SyntaxError derive from Exception (see Python exception hierarchy). But asyncio's CancelledError and Trio's Cancelled both derive directly from BaseException to avoid being caught accidentally. It seems sensible to follow this implementation experience.
C# does something similar by deriving OperationCanceledException from SystemException which is meant for system exceptions, but this is weaker because it derives from Exception, the base of user exceptions, and because SystemError includes a lot of exceptions you would want to catch in a generic catch-all handler.
In JavaScript, oddly, AbortError is not even it's own separate type; it's a DOMException.
Nursery implementations differ in how they work when multiple child tasks throw exceptions:
- Trio and asyncio: exceptions are rethrown together in a special container exception
ExceptionGroup. This follows from Trio's philosophy that no failure can be silently discarded. There is a special (and very awkward) syntaxexcept*that allows catching an exception within an exception group; see PEP 654 for details. C# hasAggregateExceptionfor similar reasons. - Swift and Kotlin: the first exception "wins" and is rethrown. (In Kotlin, any other exceptions are attached to this first one; in Swift, they are silently discarded.)
- Possible alternative: provide onErrors callback parameter and leave it to callers to decide what to do
This question is faced even by a simple function that waits on a static list of functions, such as waitAll() in the code examples repo.
Here's an example of using nested nurseries.
Future<void> runServer(List<int> ports) async {
await usingNursery((connectionNursery) async {
await usingNursery((listenerNursery) async {
for (final port in ports) {
listenerNursery.spawn((signal) async {
final server = await ServerSocket.bind('0.0.0.0', port, signal: signal);
try {
await for (final socket in server.iterate(signal: signal)) {
connectionNursery.spawn((signal) async {
try {
await handleConnection(socket, signal: signal);
} finally {
socket.destroy();
}
});
}
} finally {
await server.close();
}
});
}
await waitForShutdownSignal();
listenerNursery.cancel();
});
// Inner nursery has exited: all listeners closed, no new connections.
// Outer nursery now waits for in-flight handlers, with a grace period.
unawaited(
Future.delayed(const Duration(seconds: 30))
.then((_) => connectionNursery.abort()),
);
});
}This is a TCP socket server. It listens on multiple sockets and, for each incoming connection, spawns a task to handle those connections. There's an outer nursery for handling connections and an inner nursery for listening on ports. This nested feels a bit backwards but it's done this way round so that, on graceful shutdown, the ports are closed immediately (no more incoming connections) but the connections are given a grace period to finish up.
If it were only listening on a single socket then it could have used one nursery, with a linked token for the listening task (i.e. create its own AbortController then use AbortSignal.all(parentSignal, mySignal)).