Skip to content

Commit 796b335

Browse files
committed
Add abort controller post
1 parent 583c49a commit 796b335

File tree

3 files changed

+166
-0
lines changed

3 files changed

+166
-0
lines changed
19.2 KB
Loading
990 KB
Loading
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
---
2+
title: >-
3+
The heart breaking inadequacy of AbortController
4+
description: >-
5+
If AbortController is the official way to cancel operation in JavaScript,
6+
why doesn't anybody every use it their own apis?
7+
author: Charles Lowell
8+
tags: [ "JavaScript"]
9+
image: broken-heart.webp
10+
---
11+
12+
What is it about `AbortController` and its companion `AbortSignal` that keeps
13+
developers from reaching for it while designing their APIs? If it’s the official
14+
method of cancelling operations, then why don’t we see more of it in the wild?
15+
It’s hard to pinpoint any one thing that’s wrong about it. The API is simple
16+
enough and easy to understand. It builds on existing art that is widespread and
17+
well understood in the form of `EventTarget`, and yet its usage remains more the
18+
exception than the rule. I believe that this is because it is not only
19+
cumbersome and fragile, but also that it lacks fundamental capabilities that are
20+
required for a cancellation primitive.
21+
22+
## AbortController.abort() is an aspiration, not a constraint.
23+
24+
Sure, you can pass an `AbortSignal` to an async function but you must
25+
not only trust that it uses it correctly (more on what this means
26+
later…), but also that it properly passes the signal along to any of
27+
the async functions that _it_ calls. And, each of _those_ functions
28+
must in turn pass the signal to each of the async functions that
29+
_they_ call; and so on and so forth down an unbounded and
30+
exponentially expanding tree of function calls. Any breaking of the
31+
signal passing chain whatsoever, be it in your application code or a
32+
3rd party library and boom! You’re [stuck beyond the await event
33+
horizon](https://frontside.com/blog/2023-12-11-await-event-horizon/).
34+
35+
![A tree of asynchronous function calls cannot forget to pass the abort signal down to every operation, otherwise it risks becoming stuck forever.](control-flow.png)
36+
37+
A tree of asynchronous function calls cannot forget to pass the abort signal
38+
down to every operation, otherwise it risks becoming stuck forever.
39+
40+
Given the conspicuous non-presence of signal passing in JavaScript code
41+
everywhere, this outcome is more a mathematical certainty than anything else.
42+
But even if you could find a way to thread an abort signal through every single
43+
async function call in your codebase, what does using it correctly mean anyway?
44+
[There is no consensus.](https://news.ycombinator.com/item?id=13214487)
45+
46+
One common way is to race the abort signal against every piece of asynchronous
47+
work that the function does, and raise an error in the event of cancellation.
48+
This presents an API similar to `fetch()` and other platform apis that either
49+
return a value or raise an `AbortError` if cancelled.
50+
51+
```jsx
52+
async function work(signal) {
53+
let aborted = new Promise((_, reject) => {
54+
signal.addEventListener("abort", () => reject(new Error("AbortError"));
55+
});
56+
await Promise.race([doSomething(signal), aborted]);
57+
await Promise.race([doSomethingElse(signal), aborted]);
58+
return await Promise.race([doAnotherThing(signal), aborted]);
59+
}
60+
```
61+
62+
This works, but the sheer noise of it is enough to make most developers not even
63+
bother.
64+
65+
However, it isn’t just writing such functions that pose a problem, consuming
66+
them is also an issue. For example, what if you actually want to handle errors
67+
in your application (which is something that most of us end up wanting to do at
68+
_some_ point). Well, because cancellation is shoe-horned into the call stack as
69+
an error, you have to add boilerplate to every single `catch {}` statement in
70+
the application in order to propagate the cancellation properly.
71+
72+
```jsx
73+
try {
74+
return await work(signal);
75+
} catch (error) {
76+
if (error.name === "AbortError") {
77+
throw error; //propagate cancellation
78+
}
79+
console.log(`error doing work: ${error}`);
80+
}
81+
```
82+
83+
If we fail to do this even once, then cancellation is stopped dead in its
84+
tracks. Not great.
85+
86+
In fact, a GitHub search for the snippet
87+
[`if (error.name === "AbortError")`](https://github.com/search?q=error.name+%3D%3D%3D+AbortError&type=code)
88+
shows that this is a very common complexity added to your workday catch block.
89+
90+
There are many other ways to a consume an abort signal that I won’t go into such
91+
as wrapping every abortable function’s result in a
92+
“[maybe value](https://engineering.dollarshaveclub.com/typescript-maybe-type-and-module-627506ecc5c8)”.
93+
The point though is that there is no single way, and there never will be, and so
94+
functions written with one set of abort signal conventions will not be
95+
composable with functions written with another.
96+
97+
## Shutdown is part of the computation
98+
99+
Perhaps the greatest shortcoming of all is that `controller.abort()` is a
100+
synchronous function that _requests_ a cancellation, but it does not provide any
101+
mechanism to detect _when_ and _how_ the cancellation completed. This makes it
102+
exceedingly difficult to make programming decisions based on what happens when
103+
you abort an operation.
104+
105+
For example, suppose you want to write a simple supervisor that periodically
106+
restarts a set of three services every two minutes. In order to be correct, you
107+
can’t start the next set of three until the old set of three have been
108+
completely shutdown. Otherwise, you might leave file handles open, server ports
109+
still bound, or any other kind of resource leakage. To do that, we’d like to be
110+
able to “await” the outcome of the cancellation.
111+
112+
```tsx
113+
async function main() {
114+
while (true) {
115+
let controller = new AbortController();
116+
let { signal } = controller;
117+
118+
await startServiceOne(signal),
119+
await startServiceTwo(signal),
120+
await startServiceThree(signal),
121+
await sleep(120_000);
122+
123+
/* Alas, it does not work this way */
124+
await controller.abort();
125+
}
126+
}
127+
```
128+
129+
Not only would our supervision loop resume at just the right time, but also if
130+
there is a problem with the teardown itself, an error would be raised right at
131+
the moment of cancellation which we can either handle, or allow to propagate.
132+
133+
The bad news is of course, that no matter how much we wish abort controllers
134+
worked this way, the fact of the matter is that they do not. Instead of being a
135+
general tool for coordinating shutdown, they are nothing more than a channel
136+
that communicates an intent to do so. And when we use them, we are forced to
137+
muddle through the actual work of an orderly cancellation and hope that all the
138+
functions we pass our signal too can do the same. It should go without saying
139+
however that robust APIs are not built on hope.
140+
141+
There is some good news though: You don’t have to deal with the headaches of an
142+
abort controller _at all_ when you have a
143+
[structured concurrency](https://vorpus.org/blog/notes-on-structured-concurrency-or-go-statement-considered-harmful/)
144+
system such as [Effection](https://frontside.com/effection) at your disposal.
145+
That’s because the lifetime of an operation is not determined by a mutable
146+
object like an abort signal, but is instead determined by the _lexical
147+
structure_ of your program. So the above example could be written much more
148+
simply as:
149+
150+
```tsx
151+
function* main() {
152+
while (true) {
153+
yield* scoped(function* () {
154+
yield* startServiceOne(),
155+
yield* startServiceTwo(),
156+
yield* startServiceThree(),
157+
yield* sleep(120_000);
158+
});
159+
}
160+
}
161+
```
162+
163+
The key here is that the `scoped()` function declares that anything started
164+
inside its body needs to be shut down before it can return… which means that you
165+
get a fresh set of services with each turn of the loop. No awkward apis, no
166+
wobbly signal passing, just program execution mirroring program text.

0 commit comments

Comments
 (0)