Prior reading material:
- https://github.com/tc39/proposal-cancellation#architecture
- GCancellable which is a well-established API that informs a lot of how I think about cancellation
Same reasons as in (1), "Cancel Requests, not Results". Tying cancellation to a promise means that cancellation couldn't be used with callback-based APIs.
There shouldn't be a separate "cancelled" or other such third ending state for asynchronous operations, because there is no such concept in promises, and cancellations need to be able to work with promises.
(The Bluebird promises library does implement cancellation for promises, but it feels "bolted on" to the API. It's turned off by default and wouldn't work with async/await anyway.)
The operation ends when it is cancelled. Operations can end successfully or with an error. By definition, the operation can't be successful when it's cancelled: because then, you'd have to pass the operation's result to downstream subscribers, and where would you get that result? Therefore, cancellation has to be an error.
Treating cancellation as an error is also consistent with the precedent set in fetch()
.
That said, the now-withdrawn cancellable promises proposal presented some significant drawbacks to treating cancellation as an error, and it's true that with cancellation we'll probably see a lot of code like this:
try {
await operation();
} catch (err) {
if (!(err instanceof AbortError))
presentErrorToUser(err);
}
(Forgetting this check, the user might see error messages popping up that say "Error: Operation was cancelled". But if the error is just being logged to a server, then it doesn't much matter.)
This is not great, but I don't know that there's a good solution for this. The cancellable promises proposal had a syntax-based ("try/else") solution which is not open to us.
AbortSignal
, AbortController
, and AbortError
together seem like a good API that fits the above criteria, and is already standardized in the DOM.
Often, there will be a chain of asynchronous operations, executing one after another. (Example: downloading an archive, then unzipping it.)
These would all share the same AbortSignal
:
async function operation(uri, outDir) {
const abort = new AbortController();
try {
const stream = await download(uri, { signal: abort.signal });
await unzipTo(stream, outDir, { signal: abort.signal });
const totalSize = await getDirSize(outdir, { signal: abort.signal });
return totalSize;
} catch (e) {
if (e instanceof AbortError)
console.log('Operation was cancelled.');
else
throw e;
}
}
A well-behaved cancellable API will short-circuit the rest of the chain when one operation is cancelled.
We get this for free when using promises (when one of the async operations throws the AbortError
then we'll go to the catch clause.)
But in order to work with chained callback-style APIs, then a well-behaved API should check if any passed-in AbortSignal
was already aborted before starting its asynchronous operation, and error out early if so.
There are four kinds of async APIs that exist in Node.js that would benefit from cancellation.
Example: (many fs
methods)
fs.readFile(path, options, (err, data) => ...)
For APIs that take the familiar old (err, data) =>
callback, they should be able to take in an AbortSignal
via their options
parameter.
If they don't have an options
parameter, then one will have to be added.
In order to use these APIs with promises, util.promisify()
should continue to work without any modification needed.
Example: (streams)
const stream = fs.createReadStream(path, options);
stream.on('data', (chunk) => ...);
stream.on('error', (err) => ...);
This is a completely different kind of cancellation paradigm, than the cancellation of a callback-based API as described above.
Instead of being tracked during the lifetime of one asynchronous operation, here the AbortSignal
is associated with a long-lived object such as an input stream.
The most likely way of adding cancellation to this type of API is for its options
parameter to accept an AbortSignal
.
Then, when the signal is aborted, the stream is shut down and its error event is fired, with an AbortError
as its argument.
These APIs don't necessarily work with promises so nothing special needs to be done with util.promisify.custom
.
Example: (http.ClientRequest)
const req = https.get(url, options, (res) => ...);
req.on('error', (err) => ...);
req.on('abort', () => ...);
This is a special case of the previous kind of cancellation.
Some EventEmitter-based APIs have already implemented cancellation, as in the https.ClientRequest
returned from https.get()
which has an abort()
method.
Here, the abort
and error
events are treated separately.
I am not sure there's a better solution here than to declare that cancelling via an AbortSignal
and via the req.abort()
method are two different things, not necessarily mutually exclusive.
We could recommend AbortSignal
as more consistent and/or deprecate the abort()
method.
It might be preferable to emit both abort
and error
at the same time when the request is cancelled.
However, that wouldn't be API compatible.
Maybe it would be possible to emit both events only when the request was cancelled via an AbortSignal
, and continue emitting only abort
when the request was cancelled via the abort()
method.
There are a few callback-based asynchronous APIs that are not fallible, or at least don't have an error-first callback.
@joyeecheung pointed this case out and gave the solution.
Examples: (setTimeout, fs.exists)
fs.exists(path, (exists) => ...);
setTimeout(() => ..., delay);
These are maybe not great examples because to get a cancellable fs.exists()
you'd instead use fs.access()
, and setTimeout()
is a DOM thing that we probably shouldn't add cancellation to unless it is standardized.
But assuming there exists an API of this form that is desirable to add cancellation to, users would have to subscribe to the abort event of the AbortSignal
rather than receive an error if they wanted to react to cancellation.
Unfortunately, this would be a violation of the "Cancellation is an Error" principle, but I don't see another way to do it while keeping API compatibility.
signal.on('abort', () => {
dealWithCancellation();
});
fs.exists(path, (exists) => {
dealWithExistingFile(exists);
}, { signal });
By implementing util.promisify.custom
, promisifying such an API could work more like the other APIs:
fs.exists[util.promisify.custom] = (path, options) => {
return new Promise((resolve, reject) => {
const signal = options.signal;
if (signal)
signal.addEventListener('abort', () => reject(new AbortError()), { once: true });
fs.exists(path, resolve, options));
});
};
// ...
try {
const exists = await fs.exists(path, { signal });
dealWithExistingFile(exists);
} catch (err) {
if (err instanceof AbortError)
dealWithCancellation();
}