-
Notifications
You must be signed in to change notification settings - Fork 12
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Cancellation protocol #22
Comments
interface Cancelable {
[Symbol.cancelSignal](): Signal;
} Should be |
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? |
That sounds great! |
From user land perspective, in terms of rxjs, this looks very palatable to me. RxJS's |
Okay, the interesting thing from the RxJS side would be that we could make any RxJS I'll pitch it to the community. |
Would you mind explaining how that would look like and be used on subjects?
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
I've been playing around with On the other hand the lack of any utility methods does mean things like |
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 |
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 |
@benlesh a |
@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. |
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 |
@benlesh the subscription timing should not matter. If you have an event callback, it must check |
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 |
Another specific detail that needs to be considered for standard behavior is what happens if the same callback is passed to Current art:
Given that this would be a new protocol on |
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 |
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.
I would actually lean towards only executing it once when cancellation is requested since that would make the usage of 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 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 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 |
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 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 |
I'm not sure I agree on this bullet point:
VS Code uses a |
This is also a complicated point:
This is a bit of a catch-22, in that there aren't cancelable things because there's no language-level support for cancellation. However, areas currently under investigation like cooperative JS would likely make use of it if it existed. There's an entire untapped space of language-level primitives for asynchronous coordination would benefit from some language-level support for cancellation, and cooperative JS will likely need language-level primitives for thread synchronization as well. |
Its also possible that we could leverage https://github.com/tc39/proposal-explicit-resource-management with the protocol-based approach, replacing (or augmenting) the subscription object with {
const signal = abortSignal[Symbol.signal]();
using const subscription = signal.subscribe(() => {
// thing to do if canceled...
});
await someOperation(signal);
} // operation has completed, subscription is disposed and callback is removed. This is something we cannot do with |
I continue to claim that “cancel” is a poor term here. There’s a great many use cases for unregistering a callback; there’s a great many use cases for aborting work that has begun. It’s very unclear to me what mental model people are using to frame their arguments when they use the word “cancel”. |
@ljharb in RxJS we use a similar mechanism, and we've begun again calling it finalization, finalizers, finalizing, etc in various locations. Because ultimately that's how we end up using it. At the end of the day however, really what this is is a generic signaling mechanism. So maybe the entire thing should just be a signal, and you signal it, and after you signal it it's signaled. Because technically you could use it for anything, not just cancellation, finalization, etc. Those are just the stronger use cases. It's a primitive. If we allowed it to send a value, it would even be more useful. |
A signaling mechanism with or without a payload certainly seems like a clearer thing to discuss than a cancellation mechanism; altho isn’t that what promises already are? |
TL;DR: I agree with you @ljharb, but there's some quirks around it being a true promise.
Unfortunately, promises are inadequate for this, as you often need to finalize things as soon as possible (synchronously), and promises schedule. Otherwise, I'm totally with you that a promise would have been ideal. That said, perhaps a "thennable" signalling mechanism has merit here? The only gotcha would be the inconsistency with promise's always-async behavior. Basically we need to accommodate this: function hypothetical() {
const signal = new Signal();
eventTarget.addEventListener('click', () => console.log('wee'), { signal });
signal.notify(); // Akin to removeEventListener, really.
console.log(signal.signalled); // true
eventTarget.dispatchEvent(new Event('click')); // nothing is logged.
} But, it might be really cool if such a primitive was "thennable", though: async function gate(signal) {
console.log('closed');
await signal;
console.log('open');
}
const signal = new Signal();
gate(signal);
// Something to signal it
document.addEventListener('click', () => signal.notify()); Or a more interesting side-effect of it's thennability, maybe some day popular APIs will support any promise cancelling things? function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
const response = await fetch(url, { signal: sleep(1000) }); // Magic?!
someObservable.subscribe(console.log, { signal: sleep(1000) }); // Ooooo! However, now we're distinctly in close territory to making a Honestly, even if it's not thennable, it has merit as a primitive. The simpler the better, IMO. Another thing to consider about it being "thennable" is in this case, it would also need to be "unthennable", because sometimes you decide you no longer want to be notified by the signal. That might be a no-go, I don't know. But, the whole thing could be something like this: interface Signal<T> {
notify(value: T): void;
then<R>(fulfilled: (value: T) => R | PromiseLike<R>): Promise<R>;
unthen<R>(fulfilled: (value: T) => R | PromiseLike<R>): void;
signaled: boolean;
} Or just something like this: interface Signal<T> {
notify(value: T): void;
observe<R>(fulfilled: (value: T) => R | PromiseLike<R>): void;
unobserve<R>(fulfilled: (value: T) => R | PromiseLike<R>): void;
signaled: boolean;
} Otherwise, the only other thing I'd ask for here is mostly just to prevent a footgun. Make sure fulfillment callbacks are fired immediately if passed to an already-signaled Signal. This is because developers will forget to check whether or not it's signaled before adding a callback they intuitively expect to be called. This is actually something that was designed into RxJS Subscriptions since long before I knew RxJS existed. const signal = new Signal();
someCall(signal); // Deep in here, signal.notify() was called. oops.
signal.then(() => console.log('hi!')); // Immediately logs "hi!" The other way around this, which may be preferable is to split the Signal into a "controller" and the "signal", such that handing a third party a signal doesn't give them control over it: interface SignalController<T> {
signal: Signal<T>
notify(value: T): void;
}
interface Signal<T> {
then<R>(fulfilled: (value: T) => R | Promise<R>): Promise<R>;
unthen<R>(fulfilled: (value: T) => R | Promise<R>): void;
signaled: boolean;
}
const signalController = new SignalController();
const signal = signalController.signal;
someCall(signal); // This is safe, because `signal` has no `notify()`
signal.then(() => console.log('hi!')); But I'd still suggest enforcing the aforementioned behavior, because it's such a crazy footgun otherwise, and throwing an error or something when someone tries to register a handler on an already signaled Signal is unergonomic. |
I definitely have use cases for a "Result"-like thing - ie, a synchronous Promise, an object that holds a payload in one of two states. Obviously this is easy to construct in userland, but its value is that it'd be a coordination point, a shared type for APIs to consume and produce. A sync Result could even be easily thenable into an async Promise. When you say "unthenable", I think the challenge there is that in |
I would guess that it would reject with a known error type. But now we're into the cancelable promises debate.. so maybe having it be thennable is bad? Haha. Can it be thennable and not return a promise? |
No, if it has a |
Actually, wait. Just because someone unthens to handler does not mean that the return of then no longer has value. It just means the handler doesn't get called. When the signal is notified, |
Would it fulfill or reject? If it rejects, with perhaps some kind of special Error value, then you've basically got the cancelable promises proposal we almost had, before it hopped venues and congealed inside fetch. |
I think it would/should fulfill when the Signal is notified. normal happy path const sc = new SignalController();
const signal = sc.signal;
const handler = () => 'weee';
const p2 = signal.then(handler);
p2.then(console.log);
sc.notify(); // logs `"weee"` unthenned const sc = new SignalController();
const signal = sc.signal;
const handler = () => 'weee';
const p2 = signal.then(handler);
signal.unthen(handler);
p2.then(console.log);
sc.notify(); // logs `undefined` It's probably worth noting that it would be rare that someone needs to unthen a signal being used to create a promise chain. The unthen use-case is mostly there for different cancellation composition use cases. |
Alternatively above, |
Alternatively, alternatively. We'd just not make it thennable. Thennability is just sort of a "nice to have". |
I'm not sure I'd support cancellation being thenable. I'm also concerned this discussion is veering into a discussion about introducing a language-level core primitive, which is outside the scope of this issue. |
It doesn't need to be thenable. However, whatever is created here could be a language level primitive. Given how simple it is. What is the difference between signaling that something should stop or start? A signal is a signal. And some of the discussion in these topics have been around things like "I don't think it's just about cancellation, or just about aborting, or just about disinterest" etc. I mean, the primary use case should be cancellation, but if it's named appropriately, it'll be more usable. |
Also please let's keep in mind that in many cases this process, whatever it's called, will be hierarchical. Usually cancellation is pervasive on all levels, similar to async/await itself. As someone who's been using |
As much as I would prefer a language-level primitive, it has been made very clear in the past that a language-level primitive would be blocked from advancement. Without significant support from within the committee for advancing such a primitive, its unlikely to achieve consensus. Unfortunately, I am not convinced that a host hook is the correct approach, despite other comments on this thread. While One of the goals for the cancellation proposal was to provide a mechanism to cancel (abort, etc.) async operations and reclaim memory via unsubscription (when cancellation is unneeded), and detaching The obvious downside of not having a language-level primitive and relying on a host hook is that it becomes more difficult to write cross-platform JS. If we chose to use a host hook and Moddable decided to implement their own cancellation primitive to pass through a host hook that was not In lieu of a language-level primitive, a cancellation protocol is the next best option. Having a well-known symbol and a well-defined protocol means that package authors can simply do |
For something like
In .NET, a If we chose to allow you to "unthen" using a signal, we could either a) stick with the two-rail approach and just reject with an Error subclass, b) introduce a third rail for cancellation (i.e., One problem, however, would be the inconsistency between a language-level |
I would dearly love a third state; as I recall, there was only one solid objector to that proposal, and the proposal was changed before allowing plenary their chance to discuss that objection (only with private discussion inside one member organization). |
Thank you for the history, @rbuckton. If that's the case, it seems like focusing on a symbol and known signal shape is probably the most prudent thing. I think the known signal shape could be somewhat generic without sacrificing your goals, while the symbol would be more specific. The In RxJS, it's essential that cancellations can be tied to (and untied from) other cancellations ergonomically and efficiently. Such that:
This stuff is essential, and it's not at all covered by AbortSignal. At least not cleanly. |
Those were some of the original goals of the proposal, and the benefit of a protocol based approach is that a userland library could easily implement them (for a proof-of-concept, see CancelToken in |
Not a no-go, but a must-have! When using |
|
Note: the concept of a cancellation exception (like the DOM's This could be done via a similar protocol, or it could be done nominally. |
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.A
subscribe
method that accepts a function callback to be executed when cancellation is requested, and that returns a CancelSubscription object.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.The following TypeScript type definitions illustrate the relationships between these types:
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 asignaled
property indicating whether cancellation has been requested as well as asubscribe
method used to asynchronously wait for a cancellation signal. Thesubscribe
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
:RequestInit
's signal property would be changed fromAbortSignal
toCancelable
.Request
constructor leverages the follow steps in Step 30.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:
import
expression (i.e.,const x = await import("x", cancelable)
).Promise.prototype.then
continuations: (i.e.,p.then(onfulfilled, onrejected, cancelable)
).Without a standard protocol, its likely none of these would be possible.
The text was updated successfully, but these errors were encountered: