|
| 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 | + |
| 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