owned this note
owned this note
Published
Linked with GitHub
# Cancellation Reform
*A multi-part project to improve AbortController/AbortSignal in JavaScript*
By Daniel Ehrenberg and Brian Terlson
It's great how WHATWG's AbortController/AbortSignal API has emerged as a multi-environment way to perform cancellation in JavaScript. This document proposes incremental additions to the API to meet needs we've found in application development.
This gist is heavily based off of Ron Buckton's [Stage 0 Promise Cancellation proposal](https://github.com/tc39/proposal-cancellation/blob/master/stage0/README.md) and discussions with Brian Terlson, Ben Lesh, Benjamin Gruenbaum and Yehuda Katz
## Linking AbortControllers
As is, AbortController and AbortSignal are quite useful. They provide a way for callers to send a signal to callees telling them they should cancel any ongoing work and clean up any resources they're holding. The web platform's `fetch` API supports these signals, allowing a nice ergonomic way to cancel an ongoing fetch operation. In the following example, the fetch will be cancelled if either a button is clicked or 5 seconds has elapsed:
```js
let c = new AbortController();
button.addEventListener('click', () => c.abort());
setTimeout(() => c.abort(), 5000);
await fetch('/index.json', { signal: c.signal });
```
In practice, developers often need separate signals for each cancellation event source. For example, they want a button to cancel a sequence of operations, and allow each individual operation 5 seconds to complete. A signal firing for the timeout should not affect other operations. Let's see how we would implement that today, for a sequence of two operations:
```
let clickController = new AbortController();
button.addEventListener('click', () => clickController.abort());
async function fetchItem(path, clickSignal) {
const timeoutController = new AbortController();
setTimeout(() => timeoutController.abort(), 5000);
clickSignal.addEventListener('abort', () => timeoutController.abort());
return await fetch(path, { signal: timeoutController.signal });
}
await fetchItem('item1.json', clickController.signal);
await fetchItem('item2.json', clickController.signal);
```
The developer has to manually link the click signal and the timeout signal together, which is quite verbose. Worse, however, is the fact that this leaks quite a bit of memory - after adding the handler inside `fetchItem`, `clickController.signal` contains a reference to each signal created inside `fetchItem`, so all of signals and all of its handlers will live until `clickController.signal` is no longer referenced. For long operation chains this can easily be a lot of memory, so it's a best practice to clean these up with `removeEventListener`. The code gets even longer!
In many applications, especially robust server-side applications, every async operation will receive an AbortSignal. It is also common for applications to have a single top-level AbortSignal that will fire when the process receives a SIGINT signal. Together this means that users have to write addEventListener/removeEventListener a TON, and also that memory leaks can be common and severe due to users forget the removeEventListener part.
To address these problems, we propose that AbortController's constructor take additional parameters where each parameter is a linked signal. If any linked signal cancels, then the controller's signal cancels. This is a simple addition that drastically improves ergonomics of this common scenario. A node application wanting to gracefully handle SIGINT might look like the following:
```js
const sigintController = new AbortController();
// global level - handle sigint
process.addListener('SIGINT', () => {
console.log('aborting');
sigintController.abort();
});
async function fetchItem(path, clickSignal) {
const timeoutController = new AbortController(clickSignal);
setTimeout(() => timeoutController.abort(), 5000);
return await fetch(path, { signal: timeoutController.signal });
}
await fetchItem('item1.json', clickController.signal);
await fetchItem('item2.json', clickController.signal);
```
Aborts on the client side may also be the result of multiple possible sources of cancellation, which may feed into each other in a similar way. For example, it is possible to navigate to another page of a SPA, or to click a cancel button within a page, both of which would cancel a fetch--but the page navigation may trigger a greater amount of cancellation.
```js
let outer = new AbortController();
whenNavigatingToAnotherSPAPage(() => outer.abort());
function fetchWithCancelButton() {
let inner = new AbortController(outer.signal);
let button = renderButton();
button.onclick = () => inner.abort();
return fetch("url", {signal: inner.signal});
}
```
In WebIDL:
```idl
partial interface AbortController {
constructor(AbortSignal... signals);
}
```
## Cleaning up subscriptions to AbortSignals
(Recruit motivation from Ben Lesh)
In general, in the DOM, it's important to remove event listeners, disconnect observers, etc in order to avoid memory leaks caused by the callbacks holding things alive too long. Although it is *possible* to express this pattern with existing Web APIs (and frameworks organize themselves to make this easier), it is far from intuitive, and leaks are common.
This gist builds off of Ron Buckton's [proposal](https://github.com/tc39/proposal-explicit-resource-management) for a protocol and syntax for resource disposal: `try using` and `[Symbol.dispose]`. It proposes that we add an `EventSubscription` interface in the DOM which implements the protocol to make it easier to unsubscribe to event listeners. Separately, it would probably be a good idea to
```js
// Code sample
async function fn(abortSignal) {
let response = sendStreamingRequest();
try using (abortSignal.listen('abort', () => response.close())) {
for await (let piece of response) {
process(piece);
}
}
}
```
(I'm not sure if this example really makes sense... we have to also close it when we leave normally. But it should be possible to think of some other case that's similar and is important to handle well.)
If we didn't have the `try using`, it would be very easy to keep the response alive for the whole lifetime of the AbortSignal. That might keep a lot of memory alive!
```idl
partial interface EventTarget {
EventSubscription listen(DOMString type, EventListener callback);
}
interface EventSubscription {
void [Symbol.dispose](); /* I guess this needs a WebIDL extension */
}
```
## Passing around the current AbortSignal
(Recruit motivation from Yehuda)
It's awkward to have to pass the cancellation token through the callstack. We've seen a lot of use of saved and restored globals in the JS community to avoid these problems, but it's not possible to automatically restore the global after `await`. To track the "current" cancellation token while meeting reasonable developer expectations about semantics, the [AsyncContext](https://github.com/legendecas/proposal-async-context) proposal gives us a fundamental building block. Note that the AsyncContext is preserved (saved/restored) automatically within an async function, and manually across callbacks (with `runInContext`).
This gist proposes a new attribute and new method to simplify the common pattern of nesting AbortControllers/AbortSignals and passing them around ambiently.
```js
// code sample
let outer = new AbortController();
whenNavigatingToAnotherSPAPage(() => outer.abort());
AbortSignal.current = outer.signal;
function fetchWithCancelButton() {
return AbortController.nest(inner => {
let button = renderButton();
button.onclick = () => inner.abort();
return fetch("url", {signal: AbortSignal.current});
})
}
```
By avoiding using the global `outer` in the original example, this function is more composable, by allowing it to be called in the context of any other signal.
`AbortSignal.current` is initially `null`. When it is `null`, it can be set to any AbortSignal. If it's non-null, it cannot be reset. Getting the AbortSignal will get it from the AsyncLocalContext.
`AbortController.nest` creates a `new AbortController(AbortSignal.current)`, and calls the callback in the context of a new AsyncContext where AbortSignal.current is set to the new controller's signal. It returns the return value of the callback.
```idl
partial interface AbortSignal {
attribute AbortSignal? current;
}
callback AbortControllerNestCallback = any (AbortController controller);
partial interface AbortController {
any nest(AbortControllerNestCallback fn);
}
```