proposal-cancellation
proposal-cancellation copied to clipboard
Cancellation protocol
NOTE: This was originally discussed in #16, however I've created a fresh issue to help discuss this with a fresh perspective.
Introduction
As an alternative to adding a specific cancellation primitive in the language at this time, I propose we instead adopt a cancellation protocol in a fashion similar to how we have currently defined an iteration protocol.
Semantics
The cancellation protocol would have the following semantics:
-
There would exist a new @@cancelSignal built-in symbol.
-
A
cancelSignal
property would be added to Symbol whose value is @@cancelSignal. -
An object is said to be "cancelable" if it has an @@cancelSignal method that returns a CancelSignal object.
-
A CancelSignal object is an ordinary ECMAScript Object with the following members:
-
A
signaled
property (either a data property or a getter), that returns eithertrue
if cancellation was requested, orfalse
if it was not requested.NOTE:
signaled
offers a synchronous means to test whether cancellation has been requested. This would often be used in something like afor await..of
statement in between loop operations. -
A
subscribe
method that accepts a function callback to be executed when cancellation is requested, and that returns a CancelSubscription object.NOTE:
subscribe
offers an asynchronous means to test whether cancellation has been requested. This would often be used in aPromise
returning function that is interacting with a cancelable native/host API, such asXMLHttpRequest
)
-
-
A CancelSubscription object is an ordinary ECMAScript Object with the following members:
-
An
unsubscribe
method that removes its associated callback (added viasubscribe
above) from the CancelSignal.NOTE:
unsubscribe
allows for the removal of a subscribed cancellation callback once an operation reaches a point where it is no longer cancelable.
-
The following TypeScript type definitions illustrate the relationships between these types:
interface Cancelable {
[Symbol.cancelSignal](): CancelSignal;
}
interface CancelSignal {
signaled: boolean;
subscribe(cb: Function): CancelSubscription;
}
interface CancelSubscription {
unsubscribe(): void;
}
Interoperability
A "cancelable" object can be used as an interoperable means signaling cancellation requests. An object is "cancelable" if it has a [Symbol.cancelSignal]()
method that returns an object with both a signaled
property indicating whether cancellation has been requested as well as a subscribe
method used to asynchronously wait for a cancellation signal. The subscribe
method returns an object that can be used to terminate the subscription at such time as the operation reaches a point where cancellation is no longer possible.
WHATWG Interoperability
Defining a standard protocol allows WHATWG to continue along its current course of innovating new types of controllers and signals, while allowing TC39 to investigate adoption of cancellation within the ECMAScript language itself. This gives WHATWG the ability to special-case the handling of advanced features, such as progress notifications, while at the same time maintaining a minimal protocol for basic cancellation needs. To support a cancellation protocol, the WHATWG spec could be modified in the following ways:
-
DOM: 3.2. Interface AbortSignal - The
AbortSignal
interface would add a new[Symbol.cancelSignal]()
method that would return an object matching theCancelSignal
interface described above as a means of adapting anAbortSignal
into this protocol. -
DOM: 3.2. Interface AbortSignal - A new set of reusable steps would be added to adopt a
Cancelable
into anAbortSignal
:
A cancelable (a Cancelable) is adopted as an AbortSignal by running these steps:
- If
cancelable
is an instance ofAbortSignal
, returncancelable
.- Let
cancelSignal
becancelable[Symbol.cancelSignal]()
.- Let
abortSignal
be a newAbortSignal
.- If
cancelSignal.signaled
istrue
, then signal abort onabortSignal
.- Otherwise, call
cancelSignal
'ssubscribe
method with a function that performs the following steps:
- Signal abort on
abortSignal
.- Return
abortSignal
.
- DOM: 3.2. Interface AbortSignal "follow" - The steps would be modified as follows:
A
followingSignal
(anAbortSignal
) is made to follow aparentCancelable
(~~anAbortSignal
~~ aCancelable
) by running these steps:
- If
followingSignal
's aborted flag is set, then return.- Let
parentSignal
be the result of adoptingparentCancelable
.- If
parentSignal
's aborted flag is set, then signal abort onfollowingSignal
.- Otherwise, add the following abort steps to
parentSignal
:
- Signal abort on
followingSignal
.
- DOM: 3.3. Using AbortController and AbortSignal objects in APIs - Step 2 would be modified as follows:
- Let
signal
be the result of adopting option'ssignal
.- If ~~option's~~
signal
's aborted flag is set, then reject p with an "AbortError
"DOMException
and return p.- Add the following abort steps to ~~option's~~ signal:
- ...
- ...
- Fetch: 5.3. Request class
- The
RequestInit
's signal property would be changed fromAbortSignal
toCancelable
. - NOTE: No other changes need to be made as the
Request
constructor leverages the follow steps in Step 30. - Similar changes would need to be made to any other dependent APIs.
- The
Userland Interoperability
Defining a standard protocol would allow for userland libraries to experiment with and evolve cancellation while providing a way for different userland libraries to interoperate and interact with the DOM. By defining a standard protocol, existing in-the-wild cancellation approaches could be made to interoperate through signal adoption, similar to the behavior of Promise.prototype.then
and Promise/A+ promise libraries (with the added benefit of leveraging a unique and well-defined symbol over an easily stepped on identifier/string property name).
ECMAScript Interoperability
A standard protocol also allows for the possibility of future syntax and API in ECMAScript that could leverage this well defined protocol, without necessarily introducing a dependency on the DOM events system.
For example, this opens a way forward for the following possibilities:
- Canceling an asynchronous
import
expression (i.e.,const x = await import("x", cancelable)
). - Canceling
Promise.prototype.then
continuations: (i.e.,p.then(onfulfilled, onrejected, cancelable)
). - Possible syntactic abstractions for flowing cancellation through asynchronous operations.
Without a standard protocol, its likely none of these would be possible.
interface Cancelable {
[Symbol.cancelSignal](): Signal;
}
Should be :CancelSignal
?
Yes thanks, I just noticed that as well.
@Domenic, does this effectively cover what we discussed at the last TC39 meeting to your satisfaction? Is there anything I should add?
Canceling
Promise.prototype.then
continuations: (i.e.p.then(onfulfilled, onrejected, cancelable)
)
That sounds great!
From user land perspective, in terms of rxjs, this looks very palatable to me. RxJS's Subscription
object could easily implement this interface. The only weird part is it would make the Subscription
into an observable-like because of that subscribe
method... But that might be interesting, I'll have to investigate.
Okay, the interesting thing from the RxJS side would be that we could make any RxJS Subject
a valid CancelSignal
with the addition of a signaled
property. Sorta cool. Similarly, RxJS Subscriptions
could be considered "Observable-Like" which opens up a realm of weird possibilities. Like using takeUntil
with a Subscription, effectively (take this stream until this subscription is torn down) which wasn't really possible before. Interesting stuff.
I'll pitch it to the community.
Okay, the interesting thing from the RxJS side would be that we could make any RxJS Subject a valid CancelSignal with the addition of a signaled property.
Would you mind explaining how that would look like and be used on subjects?
Like using takeUntil with a Subscription, effectively (take this stream until this subscription is torn down) which wasn't really possible before. Interesting stuff.
This sounds really cool and useful. I've personally had to do this manually before in my own code (take this stream until the subscription is torn down).
Something not specified is how .subscribe
should behave for a signal that has already completed. Looking at the current solutions it's not consistent as to whether or not the callback will be called:
-
Observable
: Depends on the source observable - Previous
CancellationToken
Proposal:.register
did call the callback immediately if the signal was already cancelled -
AbortController
: Never fires theabort
event again if it's already been signaled.
I've been playing around with prex
a bit and personally I find the late subscription a good feature as it means I don't need a throwIfCancellationRequested
between every statement that might need to subscribe asynchronously.
On the other hand the lack of any utility methods does mean things like throwIfCancellationRequested
will need handled externally anyway so it might just be the case that people need to implement their own utility methods to accomplish it.
Would you mind explaining how that would look like and be used on subjects?
Basically, CancelSignal and Subject are both multicast Observables. But now that I think about it, CancelSIgnal is more of a specialized Subject, in RxJS terms.
As far as RxJS Subscriptions go... they're really both CancelSignal and CancelSubscription... because they have an add()
method that is effectively the same thing as subscribe()
, and a closed
property that is the same as signaled
. It would just be a matter of aliasing one method and adding the Symbol.cancellable
implementation that returned this
.
Previous CancellationToken Proposal: .register did call the callback immediately if the signal was already cancelled
This is what RxJS Subscriptions do.
If this proposal has async cancellations callbacks, i highly recommend moving to sync callbacks. This is because some things you want to cancel absolutely must be cancelled synchronously. For example, any data coming from an EventTarget. This is because you can addEventListener
and then dispatchEvent
in the same job. If your cancellation is set up to do the removeEventListener
, waiting for the next job or even "microtask" won't do.
@benlesh a signaled
is also exposed allowing synchronous inspection.
@benlesh the intent would be for subscriptions to be notified in the same turn that the object becomes signaled. This is harder to enforce except through documentation, as this proposal only specifies the protocol for cancellation and can't strictly enforce behavior.
[…] I find the late subscription a good feature […]
Similar to my other comment, handling late subscription when only specifying a protocol depends purely on documentation as we wouldn't be able to strictly enforce behavior.
@rbuckton wouldn't it make sense to specify the precise behaviour much the way promises did outside the language more formally?
@benjamingr: I think there is room enough in the ECMAScript spec to document the expected behavior of an API, as it is something we must clearly do when we describe the behavior of Symbol.iterator
and Symbol.asyncIterator
. It just becomes something that documentation repositories like MDN and MSDN would also need to clearly describe.
@benlesh the subscription timing should not matter. If you have an event callback, it must check .signaled
synchronously in the callback, instead of relying on the callback to be unsubscribed from the cancellation subscription.
I think this inversion of control is absolutely necessary, as propagation of a cancellation through synchronous callbacks (that may have arbitrary side effects) is too prone to race conditions.
It's also important to note that a cancellation signal indicates that cancellation was requested, not that it has completed (which is why we've chosen to use the more generic term signaled
as opposed to canceled
).
Another specific detail that needs to be considered for standard behavior is what happens if the same callback is passed to .subscribe
.
Current art:
-
Observable
: Subscribing twice causes the callback to be invoked twice on cancel - Previous
CancellationToken
proposal: Also causes the callback to be invoked twice -
AbortSignal
: Only invokes the callback once due to the nature of DOM events
Given that this would be a new protocol on AbortSignal
there's no reason AbortSignal
couldn't create a new wrapper function for each call to .subscribe
so I'd lean towards invoking twice.
Another another specific detail that also needs to be considered that I realized when playing around with the previous one is is the order of callback executions expected to be in latest subscription order.
All of the prior art's do call the callbacks in subscription order from what I could tell, so it's probably worth adding that implementations of cancelSignal
should call the callbacks in subscription order.
It's also important to note that a cancellation signal indicates that cancellation was requested, not that it has completed (which is why we've chosen to use the more generic term signaled as opposed to canceled).
In general, I think it is best if we phrase everything in "best effort" semantics rather than abort semantics. So I'm personally very 👍 on this sort of language.
@Jamesernator
Another specific detail that needs to be considered for standard behavior is what happens if the same callback is passed to .subscribe.
I would actually lean towards only executing it once when cancellation is requested since that would make the usage of signaled
obvious and people would know they need to handle the synchronous case.
Otherwise - I'd expect the synchronous case to be a bit footgunny since people might be subscribing too late.
I'd also recommend that given .signaled
we specify that the callback to subscribe MUST run at a future iteration of the event loop to prevent race bugs.
Note that in any case cancellation itself can propagate synchronously - this is strictly about subscribing to be notified of cancellation.
@benjamingr I'm meaning if .subscribe()
is called with the exact same callback twice, it's not related to about handling the synchronous case.
Example:
const logCancelled = _ => console.log("Cancelled!")
cancelSignal.subscribe(logCancelled)
cancelSignal.subscribe(logCancelled)
// What's printed? Just a single "Cancelled!" or two?
someCancelSignalSource.cancel()
Although I'm not sure if there's really any use cases where you wouldn't want a callback to .subscribe()
to be idempotent so calling it only once would probably make sense.
I had to double check and you're right - EventTarget only adds an event listener if there isn't already an event listener with the same callback and type. TIL.
I'm pretty sure this wasn't intentional for AbortController
(cc @jakearchibald @annevk)
The meeting notes for AbortController are here and the "Aborting a Fetch" discussion is here
That said, since this is only the case where the exact function reference is passed - I don't feel strongly either way.
@benjamingr I'm not sure I understand. It was intentional for AbortSignal
to be an EventTarget
subclass and have the exact same behavior as other EventTarget
subclasses.
@annevk thanks, that much is clear - do you happen to remember if there was a deeper reason for onabort
having this behaviour or did it just inherit it from EventTarget
?
Note I am not claiming this behaviour is bad (or good) - I'm just trying to understand if there was any other reason for it.
@benjamingr onabort
is an event handler attribute and allows you to specify a single event listener. And yes, its behavior is identical to other event handler attributes for consistency.
Thanks, I just didn't remember if there was more to it.
@benjamingr it comes from EventTarget
. It would seem weird for it to behave differently to every other event on the platform. Also, it'd confuse how to remove listeners.
Personally I'm not to fond of the idea of a symbol method on abortsignal-likes that return a cancel signal. It makes sense for Iterable
, because an Iterable
and an Iterator
(what is returned by [Symbol.iterator]()
) are different things. But an AbortSignal and a CancelSignal do the same thing.
I would much rather have an interface that is API-compatible. E.g. use aborted
instead of singaled
(who cares), and add a new subscribe
or register
method to DOM's AbortSignal
, and make that the recommended API. The fact that it's an EventTarget
in the browser would become an implementation detail.
Or add minimal addEventListener()
and removeEventListener()
to the AbortSignal spec just for interop. It can ignore options like capture
since they are not relevant.
Alternatively, it's not like Node has never shipped browser APIs before, like WHATWG URL
. A JavaScript implementation of EventTarget
is 350 lines long with comments.
To extend the specs of ECMAScript built-ins with cancellation support, does it even need a dependency on EventTarget? The spec about how APIs are supposed to handle cancellation doesn't mention EventTarget at all:
Any web platform API using promises to represent operations that can be aborted must adhere to the following:
- Accept AbortSignal objects through a signal dictionary member.
- Convey that the operation got aborted by rejecting the promise with an "AbortError" DOMException.
- Reject immediately if the AbortSignal's aborted flag is already set, otherwise:
- Use the abort algorithms mechanism to observe changes to the AbortSignal object and do so in a manner that does not lead to clashes with other observers.
@felixfbecker the problem with shipping EventTarget
in Node is that we already ship EventEmitter
for our APIs and EventTarget
has interesting semantics if we want to ship it "properly".
This would mean it'd have to be a "fake event target like" thing or people could get a reference to the constructor and things like propagation would be expected to work which I doubt we want to support.
@benjamingr is the shim I linked not actually compliant?