AbortController
and AbortSignal
are very useful APIs in JavaScript enabling the coordination of cancellation of ongoing tasks. They already solve many problems well, but a few extensions could make them even better. This document outlines a few ideas to improve these interfaces. This is a very rough set of sketches and shouldn't be taken too seriously.
AbortSignal
maintains reactions to abort registered from JavaScript by being an EventTarget
. Reactions may be registered as follows:
if (abortSignal.aborted) { abortAction(); }
else {
abortSignal.addEventListener("abort", abortAction, { once: true })
}
This snippet already points to three issues:
- It is critical to use
addEventListener
rather thanabortSignal.onabort = abortAction
because the latter code would overwrite any other reaction registered on the sameAbortSignal
. - If the
AbortSignal
has already been aborted, then the event will never be triggered. This condition often shouldn't occur, because there should be a preceding call tothrowIfAborted()
, but it is possible to forget that call. Such a race may occur in unexpected scenarios, potentially leading to leaks. In most cases, it's appropriate to perform the abort-related action if we got to this point. - After the abort is triggered, the AbortSignal will continue to hold onto its event listeners, in case someone else calls
dispatchEvent
to invoke them again. This may hold memory alive for longer (here, whateverabortAction
points to). Using{ once: true }
ensures that this memory is unlinked from theAbortSignal
immediately when it is aborted.
Wait a minute--why would we want someone else to trigger the abort actions through dispatchEvent
? In fact, this is probably a bad idea. It flies in the face of the "separation of concerns" that the AbortController
/AbortSignal
design is based on: separate objects represent "the capability to abort" (AbortController
) vs "the capability to watch for when an abort is triggered" (AbortSignal
). As AbortSignal
gains ecosystem adoption, the risk increases that poorly behaved libraries may take advantage of this capability to fake an abort given just an AbortSignal
. The ability to delete an action which one didn't register, through .onabort =
, represents a similar risk.
AbortSignal
allows the web platform to register abort reactions which do not use this mechanism, and unlink appropriately. Aside from dispatchEvent
, this mechanism is observable by the ordering that reactions run in, where one phase is the native reactions and another phase is the ones related to event dispatch.
It would be great to enable JavaScript applications to use such a reliable reaction registration mechanism, which could resolve all three issues at once, behaving as the above snippet does but without showing up in dispatchEvent
and instead like native abort reactions. The syntax could be something like:
abortSignal.whenAborted(abortAction);
One facility that addEventListener
provides is an unregistration mechanism. The new JavaScript explicit resource management proposal provides a potential nice skin for such usage, as follows, to unregister abortAction
at the end of the enclosing lexical scope:
using _ = abortSignal.whenAborted(abortAction);
Note that such unregistration can also be done manually, by calling [Symbol.dispose]
on the return value of whenAborted
, rather than the using
statement, in a more flexible scenario. [If a performance need is demonstrated, a version of whenAborted
could be added which returns undefined
instead, for the common case where unregistration is not needed.]
Sometimes, an abort-able algorithm may pass a "point of no return", after which it cannot be meaningfully aborted. However, if an abort is not triggered, AbortController
will continue to retain references to all abort reactions until nothing is referring to it anymore, and AbortSignal
will retain all event listeners registered on them.
One possibility to fix the API is to add a method, abortController.close()
, to disable future abort actions on the AbortController
, implying that all reactions can be released. This might be accompanied by a method to query whether an AbortController
is closed.
Note that such an API might not be necessary if whenAborted
is used, and if AbortSignal
is implemented with a weak reference to its AbortController
, such that it will not hold the AbortController
alive. Then, if JS references to the AbortController
are nulled, then nothing will be holding the reactions alive (because they hang off the AbortController
, not AbortSignal
).
The JavaScript and Web ecosystems are gradually supporting AbortSignal
through more APIs, but a lot of work remains to be done. It's unclear whether application developers will be persuaded to thread through the AbortSignal
parameter into APIs that they will call.
One idea that's long been floating around is to maintain some sort of ambient AbortSignal
which can be implicitly passed around. It's important to be cautious of this idea: At some level, it should be opt-in, as libraries might not expect the ambient context to abort operations that they trigger. But this technique may provide opportunity to save more work.
One possibility is to have a platform-provided AsyncContext.Variable
which contains an AbortSignal
, exposed through APIs which might be polyfilled as follows:
let ambient = new AsyncContext.Variable();
Object.defineProperty(AbortSignal, "ambient", {
get() { return ambient.get(); }
})
AbortSignal.prototype.asAmbient = function(cb) {
// omitted: check that the receiver is an AbortSignal
ambient.run(this, cb);
}
AbortSignal.whenAborted = cb => AbortSignal.ambient.whenAborted;
AbortSignal.forkAmbient = cb => {
let controller = new AbortController();
let innerSignal = controller.signal;
let signal = AbortSignal.any(innerSignal, AbortSignal.ambient);
signal.asAmbient(() => cb(controller));
}
TODO: Include usage example
The latter API, forkAmbient
, is included to encapsulate the primary idiom that AbortSignal.any
was designed to enable (trees where subtrees or the parent tree may each be aborted), but which is not very apparent to JavaScript developers today due to the API shapes involved.
Platform APIs might include a shortcut for opting into using the ambient AbortSignal
. Rather than { signal: AbortSignal.ambient }
, they may accept { signal: "ambient" }
. The opt-in is explicit on all existing APIs due to the hazard mentioned above.
Sidebar: Ambient hierarchical ownership and disposal is common among reactive DOM rendering frameworks, which maintain an ownership tree rooted by a global variable, saved and restored when re-rendering. Maybe this is a good case for AbortSignal.any
and AsyncContext
, but more research is needed to understand the applicability.
As AbortSignal
/AbortController
is a API defined in WHATWG, the ECMAScript specification (ECMA-262) does not currently make any reference to it. This avoidance has the effect that other APIs which may be of interest for JavaScript cannot be defined as part of the JavaScript language, and no syntax can make reference to AbortSignal
. As examples of where such mixing may be useful:
- Signals have a subscription/unsubscription-based API on the
Signal.subtle.Watcher
class calledwatch
/unwatch
. Rather than having anunwatch
method, an alternative API shape is forwatch
to accept anAbortSignal
as an argument, and unwatch the appropriate Signals when theAbortSignal
is aborted. - Long ago, there was hope that "cancellable promises" could integrate with JavaScript
async
/await
syntax. Aside from syntax for reacting to cancellation, this integration could assist in avoiding unused work by breaking out of the function when cancellation occurs. Part of this "breaking out" is currently achieved in by propagating abort as an exception: some APIs which accept anAbortSignal
as a parameter will reject the Promise that they return when the abort is triggered, and if this Promise isawait
ed within anasync
function, an exception will locally be thrown, preventing future unused work from being triggered. However, in various scenarios, there isn't a particular activeawait
when abort is triggered, and to enable early exit, theAbortSignal.prototype.throwIfAborted
method exists. A possible "abort-able" async function syntax opt-in could sprinklethrowIfAborted
in certain places, based off the implicitly propagatedAbortSignal
.
There are multiple possible strategies for enabling ECMA-262 references to AbortSignal
, including:
- Host hooks: ECMAScript could require the existence of host-defined objects with the basic capability of
AbortSignal
--the ability to register an abort reaction. This might be a single host hook,HostRegisterAbortReaction
. - Partial venue shift: (Depends on
AbortSignal.whenAborted
, as a non-EventTarget
-based API for reactions.) With the addition of theAbortSignal.prototype.whenAborted
method, a very large subset of theAbortSignal
/AbortController
API could be defined in the ECMAScript specification without any other references up to WHATWG. Then, WHATWG specs would patch more behavior on top (e.g., being anEventTarget
, orAbortSignal.timeout
). - Both!: The ECMAScript specification could require that the host provide an API of exactly the form of
AbortSignal
/AbortController
, and say what that looks like. Future changes would take place in both.
Each of these strategies have significant downsides, but so does the current state of the world. Host hooks seems like the simplest place to start.
Not only does it hold onto memory, it can also potentially be used to trigger cleanup actions more than once if the user failed to add the
{ once: true }
option or used.onabort
. This could put an application into a bad state or be used maliciously by supposedly isolated code (i.e., if handing the same AbortSignal to both a trusted and untrusted function). Were the separation of concerns correct, this wouldn't be an issue.Also, we might want to mention that native DOM reactions and "abort" events are run in different buckets, so they don't process in sequential order, which can be confusing to users.
I agree with the premise, though I'm not sold on the naming as it furthers the usage of the "abort" term. I'd recommend something like
register
orsubscribe
.In general it's a good idea to pair
[Symbol.dispose]
with a more user-friendly alias, so if it were a method namedregister()
, the result might have both a[Symbol.dispose]()
and anunregister()
method, or if the method weresubscribe()
, the result might have both a[Symbol.dispose]()
and anunsubscribe()
method. This is not unlike howDisposableStack
also has adispose()
method, or howvalues()
orentries()
is usually an alias for[Symbol.iterator]()
.If we're going to suggest adding a
close()
, I would make it an alias to a[Symbol.dispose]()
method as well.