Skip to content

Instantly share code, notes, and snippets.

@ptomato
Created June 23, 2020 23:52
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save ptomato/10f3d35e6692a7febf917f4ec905dc58 to your computer and use it in GitHub Desktop.
Save ptomato/10f3d35e6692a7febf917f4ec905dc58 to your computer and use it in GitHub Desktop.
How I think about Cancellation in Node.js

How I think about Cancellation in Node.js

Prior reading material:

  1. https://github.com/tc39/proposal-cancellation#architecture
  2. GCancellable which is a well-established API that informs a lot of how I think about cancellation

Cancel an Operation, not a Promise

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.

Cancellation is an Error

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 and AbortController

AbortSignal, AbortController, and AbortError together seem like a good API that fits the above criteria, and is already standardized in the DOM.

Cancellation of chained operations

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.

Cancellation in the Node.js API

There are four kinds of async APIs that exist in Node.js that would benefit from cancellation.

Simple callback-based APIs

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.

APIs that return an EventEmitter

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.

APIs that already have cancellation

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.

Callback-based APIs that don't fail

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(); 
} 
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment