Cancelable promises

# Anne van Kesteren (9 years ago)

As a heads up, there's some debate around the fetch() API how exactly request termination should work and how that affects promises:

slightlyoff/ServiceWorker#625

The WebRTC WG has also been discussing canceling in the context of terminating a request for permission from the user. I think they decided to postpone for now until there's a bit more progress on what cancelable promises means, but I would not expect everyone to wait forever.

# Kevin Smith (9 years ago)

The discussion on that github issue surrounding promise subclassing makes my head spin, especially when it comes to working out how cancelation is supposed to flow through a graph of promise dependencies. We should be wary of adding complexity to the core.

The simple way to view the situation is to say that promises are simply transparent containers for asynchronous values. Control capabilities should therefore be represented by a separate abstraction. This will help keep complexity at the edges.

Has any library experimented with the cancelation token approach yet?

# Andrea Giammarchi (9 years ago)

AFAIK bluebird did: petkaantonov/bluebird/blob/master/API.md#cancelerror-reason---promise

But I agree once you've made Promises more complex than events ( xhr in this case ) nobody wins :-/

Although, specially for fetch or anything network related, there must be a way to bloody cancel that!

....right?

# Jonas Sicking (9 years ago)

On Thu, Feb 26, 2015 at 11:43 PM, Andrea Giammarchi <andrea.giammarchi at gmail.com> wrote:

AFAIK bluebird did: petkaantonov/bluebird/blob/master/API.md#cancelerror-reason---promise

But I agree once you've made Promises more complex than events ( xhr in this case ) nobody wins :-/

Although, specially for fetch or anything network related, there must be a way to bloody cancel that!

....right?

Yeah. Most IO operations do need a way to cancel. And hopefully all of them will be returning Promises.

I definitely agree that providing cancelability on Promise will add complexity. That said, the pattern of making heavy long-running APIs return a Promise, and wanting to enable cancelling them, is likely going to be common.

So finding a good solution to that pattern seems worthwhile.

# Samuel Giles (9 years ago)

Re fetch: whatwg/fetch#20

# John Lenz (9 years ago)

Closure Library's promise implementation supports "cancel":

google/closure-library/blob/master/closure/goog/promise/promise.js#L502

A promise is cancelled only if all the "child" promises are also cancelled.

# John Lenz (9 years ago)

I did not say that correctly, a "parent" promise can be cancelled by a "child" it is the only child or all the children cancel. A parent can alway cancel its children (to the children this is simply "reject"). It does add a significant amount of complexity to the promise implementation but it is for the most part contained there.

I don't believe that "cancel" can be added after the fact and an alternative (subclass or otherwise) is needed.

# Ron Buckton (9 years ago)

AsyncJS (rbuckton/asyncjs) uses a separate abstraction for cancellation based on the .NET CancellationTokenSource/CancellationToken types. You can find more information about this abstraction in the MSDN documentation here: msdn.microsoft.com/en-us/library/dd997364(v=vs.110).aspx

# Frankie Bagnardi (9 years ago)

My understanding was that it'd look like this:

// presumably standard
class CancelReason extends Error {}

// Promise.cancelable? third party library?
function cancelable(p, onCancel){
  // todo: refactor into utility function allowing: return cancelable(resultPromise, () => {xhr.abort()})
  var doCancel;
  var cancelPromise = new Promise((resolve, reject) => {
    doCancel = reason => {
      reject(new CancelReason(reason));
      return raced;
    };
  });

  var raced = Promise.race([p, cancelPromise])
  .catch(reason => {
    if (reason instanceof CancelReason) {
        onCancel();
    }
    return Promise.reject(reason);
  });

  raced.cancel = doCancel;
  return raced;
}

function fetchAsync(url) {
  var resultPromise = new Promise((resolve, reject) => {
    xhr.open("GET", url, /*async:*/ true);
    xhr.onload = event => resolve(xhr.responseText);
    xhr.onerror = event => reject(xhr.statusText);
    xhr.send(null);
  });

  return cancelable(resultPromise, () => { xhr.abort() });
}

fetchAsync(...).then(...); // as expected
fetchAsync(...).cancel('reason').catch(...); // .catch gets the
CancelReason, and xhr is aborted
fetchAsync(...).then(...).cancel(); // TypeError .cancel is undefined

And that cancel wouldn't automatically propagate in any way; but it is a simple callback, so can be passed around as desired.

# Salvador de la Puente González (9 years ago)

What is cancelable is the value of the fetch() promise, not the fetch itself. Extending the promise API is extending the concept of control flow and I think this require a separated discussion. To make fetch() to be cancelable should not affect promises at all.

TL;DR; I think the cancelable promise makes no much sense. A promise is an abstraction for a control flow, it is not a value.

When you call a function f() returning a promise, f() is not returning a valid image* value. It is only returning an abstraction saying that, eventually, it will be "replaced" by the proper function or fail.

*image -> understood like in a mathematical context but allowing functions

to have side effects.

This is why we can jump from promise-based syntax to await/async syntax. In the latter we are emulating the synchronous syntax in which there is only to flows, the regular flow and the exception flow. The exception flow, which is born from the need of checking runtime conditions that forces emergency paths.

With this scheme, there is no way for reacting upon abortion / cancellation unless you do something like:

try { var response = await fetch(url); } ifAbort(reason) { // what if aborted... }

This resembles a lot to a simple exception and so, there should be treated like another rejection error.

But this does not end here.

If we extend Promises API we are adding new flows, we are changing the flow definition. The need of a cancelable promise rising from fetch() is coupling fetch()'s specific implementation with control flow. This is why you ended with FetchPromise.

If you are going to allow fetch() to be cancelable, what I actually think is the thing which is indeed to be cancelable is the promise value for the fetch() returned promise.

All these members mentioned in the discussion such as .isCancelled, .requestCancel() and .setCancelHandler() should be part of the promise's value and not part of the promise itself.

var response = await fetch(); // it waits for the value of the promise which represents a fetch in progress. f.abort (); // this is what is abortable, not the flow itself.

Aborting the value will affect the way in which other members behaves and those are implementation details. For instance, a successfully abort() will cause .isAborted() to resolve in true and .json() to fail with an instance of FetchAborted() reason.

This way you are not coupling specific implementations' needs with control flow abstractions.

Notice that extending a flow abstraction is not bad at all but remember that If you continue pushing for the cancelable Promise, you are actually extending a control flow. So, IMHO, you should:

  • Allow a third parameter for .then() to react upon the new flow path.
  • Change the Promise implementation to accept a second parameter which is the callback to be called when the flow is aborted.
  • Extend try syntax to take into account the new path to support await/async syntax**

**The need for an extension in try is another argument against. If you think carefully, an .abort() method is like controlling the flow from outside, in the await/async syntax is like controlling the "= await" part of the syntax which is simply unsupported.

var response = await fetch(url);

You can not abort() with await syntax from outside, it is only abortable by internal reasons. If it is only abortable by internal reasons, then it is almost the same than reject or accept. Just look at the value you are choosing for the abort result. If it is a valid response, you are accepting. If not, you are rejecting.

If you think with cancelable promises you're allowing richer execution models, think about Tasks as values of promises and use composition and not inheritance because they are not the same.

Hope it helps. El 28/02/2015 05:01, "John Lenz" <concavelenz at gmail.com> escribió:

# Kevin Smith (9 years ago)

AsyncJS (rbuckton/asyncjs) uses a separate abstraction for cancellation based on the .NET CancellationTokenSource/CancellationToken types. You can find more information about this abstraction in the MSDN documentation here: msdn.microsoft.com/en-us/library/dd997364(v=vs.110).aspx

That's what I was looking for : )

So the core idea is that the initiator establishes a one-way communication channel through which it can send control signals to the in-flight task. The task can choose to respond to those signals in whatever fashion it likes (or not at all).

That design seems far cleaner than adding functionality directly to promises, but can it be made JS-ergonomic?

# Benjamin (Inglor) Gruenbaum (9 years ago)

From: Andrea Giammarchi <andrea.giammarchi at gmail.com> AFAIK bluebird did: petkaantonov/bluebird /blob/master/API.md#cancelerror-reason---promise

For what it's worth - bluebird is swapping its cancellation implementation in a week or two. You can read about it here: petkaantonov/bluebird#415

# Kevin Smith (9 years ago)

That design seems far cleaner than adding functionality directly to promises, but can it be made JS-ergonomic?

The question we need to consider when it comes to a general design for canceling async tasks is this: how does one offer cancellation when defining an async task using an async function?

async function af() {
    // A bunch of awaits in here.
    // How can I allow the initiator to cancel?
}

Adding a "cancel" method to (some) promises doesn't work for async functions, but something like the cancellation token approach would.

So again the question is: can we come up with a cancellation-token-style pattern which is JS-ergonomic?

# Domenic Denicola (9 years ago)

From: Kevin Smith [mailto:zenparsing at gmail.com]

So again the question is: can we come up with a cancellation-token-style pattern which is JS-ergonomic?

I tried to discuss some stuff in that direction at slightlyoff/ServiceWorker#625

# Kevin Smith (9 years ago)

So again the question is: can we come up with a cancellation-token-style pattern which is JS-ergonomic?

I tried to discuss some stuff in that direction at slightlyoff/ServiceWorker#625

I think using a promise in place of a "token is a great idea. So a cancelable async function might look like:

async function af(cancel) {
    let abort = false;
    cancel.then(_=> abort = true);
    await op1();
    if (abort) {
        doCleanup();
        return something;
        // What is "something" here?
        // Should we return what cancel resolves with?
        // Should it throw/reject instead?
    }
    await op2(cancel); // Send cancellation to a nested task
    // etc.
}

Like you mention in the github issue, the task won't be able to immediately tell if cancel is already-resolved, but I think that's fine. If we're already cancelled, then we shouldn't call the async function to begin with.

Looks pretty good to me, although there are a few details that would need to be ironed out.