Skip to content

Instantly share code, notes, and snippets.

@littledan
Last active October 10, 2024 02:06
Show Gist options
  • Save littledan/47b4fe9cf9196abdcd53abee940e92df to your computer and use it in GitHub Desktop.
Save littledan/47b4fe9cf9196abdcd53abee940e92df to your computer and use it in GitHub Desktop.

Extending AbortController and AbortSignal

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.

Reliable reactions to abort

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 than abortSignal.onabort = abortAction because the latter code would overwrite any other reaction registered on the same AbortSignal.
  • 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 to throwIfAborted(), 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, whatever abortAction points to). Using { once: true } ensures that this memory is unlinked from the AbortSignal 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.]

Freeing unreachable reactions

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).

Implicit propagation of 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.

Usability from JavaScript specifications

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 called watch/unwatch. Rather than having an unwatch method, an alternative API shape is for watch to accept an AbortSignal as an argument, and unwatch the appropriate Signals when the AbortSignal 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 an AbortSignal as a parameter will reject the Promise that they return when the abort is triggered, and if this Promise is awaited within an async function, an exception will locally be thrown, preventing future unused work from being triggered. However, in various scenarios, there isn't a particular active await when abort is triggered, and to enable early exit, the AbortSignal.prototype.throwIfAborted method exists. A possible "abort-able" async function syntax opt-in could sprinkle throwIfAborted in certain places, based off the implicitly propagated AbortSignal.

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 the AbortSignal.prototype.whenAborted method, a very large subset of the AbortSignal/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 an EventTarget, or AbortSignal.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.

@rbuckton
Copy link

rbuckton commented Jun 16, 2024

  • After the abort is triggered, the AbortSignal will continue to hold onto its event listeners [...]

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.


abortSignal.whenAborted(abortAction);

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 or subscribe.


Note that such unregistration can also be done manually, by calling [Symbol.dispose] on the return value [...]

In general it's a good idea to pair [Symbol.dispose] with a more user-friendly alias, so if it were a method named register(), the result might have both a [Symbol.dispose]() and an unregister() method, or if the method were subscribe(), the result might have both a [Symbol.dispose]() and an unsubscribe() method. This is not unlike how DisposableStack also has a dispose() method, or how values() or entries() is usually an alias for [Symbol.iterator]().


One possibility to fix the API is to add a method, abortController.close(), [...]

If we're going to suggest adding a close(), I would make it an alias to a [Symbol.dispose]() method as well.

@domfarolino
Copy link

A few thoughts for now! Thanks for sharing.


Reliable reactions to abort

[...]
It would be great to enable JavaScript applications to use such a reliable reaction registration mechanism, [...] The syntax could be something like:
abortSignal.whenAborted(abortAction);

One comment I have would be to prefix the algorithm with add (maybe addAbortReaction()), to make it clear that the method is additive, and that it does not replace any other abort reactions previously added with this method. (Just seeing whenAborted() gives me onabort = feelings of accidentally replacing other code that got here first).

Freeing unreachable reactions

[...]
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`).

That last part I'm confused about. DOM has the "internal" abort algorithms hanging off of AbortSignal (it is these algorithms that I thought you were referencing when you said "the reactions" that "hang off the [...]"). And of course the same seems true even in a whenAborted() world, since your example introducing that API is on AbortSignal, not AbortController. It seems like I'm just missing something obvious here though, so please let me know where I'm wrong :)

AbortSignal.forkAmbient = cb => {
   let controller = new AbortController();
   let innerSignal = new AbortSignal();
   let signal = AbortSignal.any(innerSignal, AbortSignal.ambient);
   signal.asAmbient(() => cb(controller));
}

AbortSignal() is not constructible. I think you meant innerSignal = controller.signal?

Separately, I'm actually not sure I understand what forkAmbient does? The name makes me think it would return a new AbortController and(?) AbortSignal, where the new signal is dependent on (a) the new controller's signal as well as (b) the ambient signal. But instead it seems to take a callback, and immediately run that callback on the ambient AsyncContext.Variable? I don't follow this.

Usability from JavaScript specifications

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.

What platform-provided AbortSignal would be referenced — for internal abort algorithms to be added to — by this host hook at any given time? The ambient signal?

@littledan
Copy link
Author

Thanks for the review, @domfarolino ! (Somehow I missed it until now.)

addAbortReaction() sounds like a good name, thanks for the idea.

Sorry, when I talked about reactions hanging off of AbortController, I was speaking more in terms of how I thought things could be organized. In any case, @rbuckton 's motivation for making it possible to 'close' an AbortController is all about being able to GC abort reactions when it can no longer be aborted. Per the WeakRefs spec, we generally allow engines to GC things as long as they're logically unreachable, regardless of how the spec sets up the pointer graph. Abort reactions become logically unreachable when the AbortController becomes unreachable (even if AbortSignals are still alive). It would be an editorial change, to make this more obvious, to move internal reactions to the AbortController, wouldn't it?

Yeah I should write better examples with forkAmbient (and actually run and test code before sharing...). The idea is to run a callback, making the new "child" AbortSignal the ambient AbortSignal, passing in the AbortController which newly triggers it as a parameter to the callback. This lets the invoker of the API perform the abort when it wants to, but that capability isn't threaded down to all children. However, the ability to listen to, not this new AbortController but the "or" of the parent AbortSignal and this new one, is made available to all children.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment