Skip to content
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

Open
rbuckton opened this issue Jul 17, 2018 · 92 comments
Open

Cancellation protocol #22

rbuckton opened this issue Jul 17, 2018 · 92 comments

Comments

@rbuckton
Copy link
Collaborator

rbuckton commented Jul 17, 2018

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 either true if cancellation was requested, or false 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 a for 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 a Promise returning function that is interacting with a cancelable native/host API, such as XMLHttpRequest)

  • A CancelSubscription object is an ordinary ECMAScript Object with the following members:

    • An unsubscribe method that removes its associated callback (added via subscribe 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 the CancelSignal interface described above as a means of adapting an AbortSignal into this protocol.

  • DOM: 3.2. Interface AbortSignal - A new set of reusable steps would be added to adopt a Cancelable into an AbortSignal:

A cancelable (a Cancelable) is adopted as an AbortSignal by running these steps:

  1. If cancelable is an instance of AbortSignal, return cancelable.
  2. Let cancelSignal be cancelable[Symbol.cancelSignal]().
  3. Let abortSignal be a new AbortSignal.
  4. If cancelSignal.signaled is true, then signal abort on abortSignal.
  5. Otherwise, call cancelSignal's subscribe method with a function that performs the following steps:
    1. Signal abort on abortSignal.
  6. Return abortSignal.

A followingSignal (an AbortSignal) is made to follow a parentCancelable (an AbortSignal a Cancelable) by running these steps:

  1. If followingSignal's aborted flag is set, then return.
  2. Let parentSignal be the result of adopting parentCancelable.
  3. If parentSignal's aborted flag is set, then signal abort on followingSignal.
  4. Otherwise, add the following abort steps to parentSignal:
    1. Signal abort on followingSignal.
  1. Let signal be the result of adopting option's signal.
  2. If option's signal's aborted flag is set, then reject p with an "AbortError" DOMException and return p.
  3. Add the following abort steps to option's signal:
    1. ...
  4. ...
  • Fetch: 5.3. Request class
    • The RequestInit's signal property would be changed from AbortSignal to Cancelable.
    • 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.

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.

@benjamingr
Copy link

interface Cancelable {
  [Symbol.cancelSignal](): Signal;
}

Should be :CancelSignal ?

@rbuckton
Copy link
Collaborator Author

Yes thanks, I just noticed that as well.

@rbuckton
Copy link
Collaborator Author

@domenic, does this effectively cover what we discussed at the last TC39 meeting to your satisfaction? Is there anything I should add?

@bergus
Copy link

bergus commented Jul 18, 2018

Canceling Promise.prototype.then continuations: (i.e. p.then(onfulfilled, onrejected, cancelable))

That sounds great!

@benlesh
Copy link

benlesh commented Jul 22, 2018

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.

@benlesh
Copy link

benlesh commented Jul 23, 2018

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.

@benjamingr
Copy link

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

@Jamesernator
Copy link

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 the abort 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.

@benlesh
Copy link

benlesh commented Aug 14, 2018

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.

@benlesh
Copy link

benlesh commented Aug 14, 2018

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.

@benjamingr
Copy link

@benlesh a signaled is also exposed allowing synchronous inspection.

@rbuckton
Copy link
Collaborator Author

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

@rbuckton
Copy link
Collaborator Author

[…] 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.

@benjamingr
Copy link

@rbuckton wouldn't it make sense to specify the precise behaviour much the way promises did outside the language more formally?

@rbuckton
Copy link
Collaborator Author

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

@bergus
Copy link

bergus commented Aug 14, 2018

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

@rbuckton
Copy link
Collaborator Author

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

@Jamesernator
Copy link

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.

@Jamesernator
Copy link

Jamesernator commented Aug 15, 2018

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.

@benjamingr
Copy link

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.

@Jamesernator
Copy link

Jamesernator commented Aug 15, 2018

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

@benjamingr
Copy link

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.

@annevk
Copy link
Member

annevk commented Aug 15, 2018

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

@rbuckton
Copy link
Collaborator Author

I'm not sure I agree on this bullet point:

  • Whether the protocol outlined above is actually good for cancellation (with .subscribe) is untested.

VS Code uses a CancellationToken API with a similar model (except their tokens return an object with a dispose instead of an unsubscribe) that is used by a significant number of extensions and has for a number of years. In fact, one of the values of having a protocol would be that something like VS Code's CancellationToken could adopt this same protocol and could be passed to a native fetch API in an extension host, rather than needing to write an adapter to an AbortSignal.

@rbuckton
Copy link
Collaborator Author

This is also a complicated point:

  • There are only very few examples of cancellable things in the ECMAScript language API.

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.

@rbuckton
Copy link
Collaborator Author

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 Symbol.dispose:

{
  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 AbortSignal directly, due to how subscription and unsubscription work with EventTarget.

@ljharb
Copy link
Member

ljharb commented Sep 22, 2021

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

@benlesh
Copy link

benlesh commented Sep 23, 2021

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

@ljharb
Copy link
Member

ljharb commented Sep 23, 2021

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?

@benlesh
Copy link

benlesh commented Sep 24, 2021

TL;DR: I agree with you @ljharb, but there's some quirks around it being a true promise.

  1. It needs to notify synchronously in some cases.
  2. it would be cool if it were "thennable". But that will require it being "unthennable" to suit some use cases.
  3. It should account for who control signaling
  4. It should account for registering handlers if already signaled.

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?

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 Deferred. Which I think caused some issues back in the JQuery days. And people might try to push for a "resolve/reject" sort of duality, which confuses the original intent here. There will be thoughts like "what signal cancels the signal? can it reject? etc". But I think if the type is relegated to be a "resolve only" synchronous thennable, it has merit on its own.

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.

@ljharb
Copy link
Member

ljharb commented Sep 24, 2021

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 const p2 = promise.then(a);, p2 only exists based on the return value of a - so while you could certainly promise.unthen(a), and what that does seems obvious, what is not obvious is "what happens to p2"?

@benlesh
Copy link

benlesh commented Sep 24, 2021

what is not obvious is "what happens to p2"?

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?

@ljharb
Copy link
Member

ljharb commented Sep 24, 2021

No, if it has a .then function then await weirdThenable or Promise.resolve(weirdThenable) would either make a promise or throw.

@benlesh
Copy link

benlesh commented Sep 24, 2021

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, p2 in your example above would be notified. I guess the only weird bit would be what value it got. I suppose it would need to get undefined in the case of its parent being unthenned. Different, but perhaps not unexpected.

@ljharb
Copy link
Member

ljharb commented Sep 24, 2021

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.

@benlesh
Copy link

benlesh commented Sep 24, 2021

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.

@benlesh
Copy link

benlesh commented Sep 24, 2021

Alternatively above, p2 could get some sort of special unthenned token/type, but still resolve. Unthennable promises would therefor always be Promise<T | UnthenToken> (or whatever you'd call it)

@benlesh
Copy link

benlesh commented Sep 24, 2021

Alternatively, alternatively. We'd just not make it thennable. Thennability is just sort of a "nice to have".

@rbuckton
Copy link
Collaborator Author

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.

@benlesh
Copy link

benlesh commented Sep 24, 2021

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.

@noseratio
Copy link

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 CancellationTokenSource.CreateLinkedTokenSource since its inception in .NET, I can't think of a single project where I wouldn't need it. Currently using this attempt at getting the same with AbortSignal while trying to reduce the leaks for orphaned abort even handlers.

@rbuckton
Copy link
Collaborator Author

rbuckton commented Sep 25, 2021

It doesn't need to be thenable. However, whatever is created here could be a language level primitive. Given how simple it is.

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, AbortSignal was not designed to be a language-level primitive due to its dependence on DOM-specific APIs. I proposed this issue as a way to interoperate with AbortSignal in a way that did not result in a dependence on DOM-specific APIs, while permitting some flexibility with respect to userland approaches to cancellation.

I am not convinced that a host hook is the correct approach, despite other comments on this thread. While AbortSignal may be the standard for the web, and is present in NodeJS and Deno, to my knowledge it does not exist in embedded JS implementations like Moddable's XS. I cannot speak to whether Moddable or any other embedded system will implement AbortSignal on their own, but XS does support Symbol and therefore could support a symbol-based API for cancellation using a userland cancellation signal. However, I imagine that @patrick-soquet or @phoddie would be a better be able to speak to Moddable's preference in that regard.

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 then/all/race/any callbacks (when the results no longer matter). Memory is often at a premium in IoT/embedded scenarios, so having some solution for that case would be valuable. An AbortSignal holding onto aborted event handlers seems somewhat antithetical to efficient memory management in an embedded system, so I'd be interested to hear from embedded engine developers as to whether they would consider porting an implementation.

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 AbortSignal, then package authors that wanted to leverage cancellation would need to increase code-size to feature test for cancellation by platform.

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 const signal = signalLike[Symbol.cancelSignal]?.(); and be compatible with the DOM, NodeJS, Deno, XS, etc. Host hooks just don't solve the problem of platform-interoperability.

@rbuckton
Copy link
Collaborator Author

rbuckton commented Sep 25, 2021

For something like const p2 = p1.then(onfulfilled, onrejected, { signal }), when signal becomes "canceled" (or "aborted", "signaled", etc.), I would expect the following to happen:

  • If p1 has not yet settled, the PromiseReaction records for onfulfilled and onrejected would be removed from p1's reaction queues.
  • p2 would be rejected with some kind of Error (be it a TypeError, a CancelError, or something else).

In .NET, a Task essentially has three rails: "RanToCompletion", "Faulted", and "Canceled", and you can configure a continuation to run based on which case was encountered using TaskContinuationOptions. However, regardless of whether the Task is in the Faulted or Canceled state, calling Wait() (for Task) or reading from Result (for Task<TResult>) will throw an exception.

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., .then(onfulfilled, onrejected, oncanceled, { signal })), or c) something else we haven't thought of yet. The .NET approach is very fine-grained, but a two-rail approach could still be reasonably well supported within JS.

One problem, however, would be the inconsistency between a language-level Error subclass for cancellation and a host-specific Error subclass as used in the DOM and NodeJS. We could find ways of working around that, such as a Promise.isCancelReason(reason) function that would have a host hook that could be employed to test whether the rejection reason is either the language-level Error or host-specific Error that indicates a canceled (or aborted) operation.

@ljharb
Copy link
Member

ljharb commented Sep 25, 2021

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

@benlesh
Copy link

benlesh commented Sep 25, 2021

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 then and unthen stuff I brought up was a red herring, I think, and got us off track. Really what I was aiming for was a way to compose cancellation.

In RxJS, it's essential that cancellations can be tied to (and untied from) other cancellations ergonomically and efficiently. Such that:

  • signals can be set up with N:N parent-child relationships.
  • children my be added or removed
  • cancelling a parent cancels all children
  • children remove themselves from parents if the child is cancelled first.
  • adding a child to an already cancelled parent immediately cancels the child, rather than add it.

This stuff is essential, and it's not at all covered by AbortSignal. At least not cleanly.

@rbuckton
Copy link
Collaborator Author

rbuckton commented Sep 25, 2021

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 esfx, which is based on this protocol (as implemented in Cancelable).

@bergus
Copy link

bergus commented Sep 25, 2021

@benlesh

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.

Not a no-go, but a must-have! When using p2 = p1.then(onfulfilled, onrejected, cancelSignal), once p2 is settled no handlers must be left behind on cancelSignal (or p1). Otherwise passing a signal into many promise chains without ever notifying it of cancellation would leak memory. There is an open (spec) bug in Promise.race where this exact problem occurs.
So yes, the cancellation protocol needs to aim for easy composability, but it must not sacrifice efficiency and memory.

@rbuckton
Copy link
Collaborator Author

rbuckton commented Apr 7, 2022

I accidentally updated the wrong issue's details. I'll have that fixed shortly. Fixed.

@dead-claudia
Copy link

dead-claudia commented May 18, 2024

Note: the concept of a cancellation exception (like the DOM's DOMException with name === "ABORT_ERR") needs to also have a way to detect it, so error reporting tools can ignore them (and so Node can skip the process abort steps for them).

This could be done via a similar protocol, or it could be done nominally.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests