Skip to content

Commit 01ed254

Browse files
authored
Merge pull request #436 from thefrontside/cl/abort-controller-blog
📝 Add post on the deficiencies of Abort Controller
2 parents 583c49a + af73106 commit 01ed254

File tree

3 files changed

+191
-0
lines changed

3 files changed

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

0 commit comments

Comments
 (0)