Alternative Promise cancellation proposal

# Bergi (8 years ago)

I am proposing a new approach for promise cancellation:

<github.com/bergus/promise-cancellation>

The major points are

  • cancellation capability is separated from result promise through CancelTokens
  • targets of cancellation requests are made explicit by passing tokens
  • no downward propagation of cancellation as a promise result
  • promises are made cancellable by associating a token to them at their creation
  • promises get cancelled directly when cancel() for their associated token is called
  • promises can still be cancelled after being resolved to another pending promise
  • promises propagate their associated token to assimilated thenables
  • callbacks are made cancellable ("removable") by passing a token to then
  • callbacks get cancelled ("ignored") immediately when the respective cancellation is requested

This has a few merits:

  • simple, powerful and backwards-compatible semantics
  • the subscriber is in charge of cancellation of callbacks, not the promise
  • good integration with async/await
  • support for potential (userland) Task implementations

An important functionality, the race between cancellation and normal promise resolution and the distinction of the outcomes, is available as a separate helper method. (I'm still looking for a good name bergus/promise-cancellation#4).

It could be implemented by user code in terms of promise.then and token.subscribe, but that's cumbersome, error-prone and inconvenient.

Now let code speak for itself:

function example(token) {
     return new Promise(resolve => {
         const resolveUnlessCancelled = token.subscribeOrcall(() => {
             clearTimeout(timer);
         }, resolve);
         const timer = setTimeout(resolveUnlessCancelled, 500)
     }, token)
     .then(() => cancellableAction(token), token)
     .then(uncancellableAction, token);
}
const {token, cancel} = CancelToken.source();
setTimeout(cancel, 1000);
example(token)
.trifurcate(result => console.log(result),
             error => console.error(error),
             reason => console.log("cancelled because", reason));

Of course any new cancellation proposal has to contrasted with Domenic's current one domenic/cancelable-promise.

It's core semantical primitive is the race between fulfillment, rejection and cancellation, for which a new promise state is introduced.

TL;DR: My approach is fundamentally different, and - obviously - I believe it's better. (You may stop reading here if you didn't know Domenics approach, the rest of this is only discussion)

For quick comparison, the same thing as above would look like this:

function example(token) {
     return new Promise((resolve, reject, cancel) => {
         let timer = setTimeout(() => {
             timer = undefined;
             resolve();
         }, 500);
         token.promise.then(reason => {
             if (timer !== undefined) {
                 cancel(reason);
                 clearTimeout(timer);
             }
         });
     })
     .then(() => {
         token.cancelIfRequested();
         return cancellableAction(token);
     })
     .then(res => {
         token.cancelIfRequested();
         return uncancellableAction(res)
         .then(res => {
             token.cancelIfRequested();
             return res;
         });
     });
}
const {token, cancel} = CancelToken.source();
setTimeout(cancel, 1000);
example(token)
.then(result => console.log(result),
       error => console.error(error),
       reason => console.log("cancelled because", reason));

Here's what I'm doing different from Domenic:

  • Cancellation is no promise result that propagates downwards. Signalling cancellation through the token is enough to drop work.
  • There is no third then callback to avoid any incompatibilities with popular legacy implementation that already use one.
  • There is no third promise state affecting how then behaves to preserve compatibility with Promises/A+ semantics and prevent issues with assimilation.
  • There is no new synchronous "cancel" abrupt completion type to make it easy to polyfill without requiring a transpiler.
  • There is no need to call token.cancelIfRequested() or wrap the callback body in if (!token.requested) to prevent it from running when cancellation is requested, instead you pass the token as the last argument to then.
  • There is no .cancelIfRequested method on tokens which is unnecessary without a new completion type.

Some new ideas which I have incorporated in my proposal but don't feel strongly about:

There are three fundamental differences between Domenics approach and mine (covered in detail below):

  • a promise can be cancelled after being resolved to another promise
  • cancellation is not a result value - a promise is cancelled (only and directly) by the token, not by the operation
  • cancelled promises are rejected

The first major difference is how tokens affect promises. Instead of having an additional resolution type, when a promise is created a token can be associated to it. If there is no token, the promise is not cancellable. If there is one, it will cancel the promise at any point of time until it is settled, even after it is resolved. With my proposal, when doing

const promise = uncancellableActionA().then(uncancellableActionB, token)

the promise is cancelled exactly at the same time as the token is cancelled. If that happens during action A, then the action B is not started.

In contrast, when doing a similar thing with Domenics proposal

const promise = uncancellableActionA().then(res =>
     token.cancelIfRequested();
     return uncancellableActionB(res);
});

then the promise is either cancelled at exactly the time when action A fulfills, or if cancellation happens after that (during action B), then the promise is not cancelled at all!

The second major difference in functionality is how handlers can affect the promise return value in case of a cancellation request. Compare the following:

p = promise()
a = p.then(A, token)
b = a.finally(B)
c = b.then(C, token)
d = c.finally(D)
e = d.then(E, token)
f = e.trifurcate(null, null, F)
g = f.then(G, token)

If p is not yet resolved and the token is cancelled, then a-e and g are immediately cancelled. A, C, E and G are dropped and never executed as the token that accompanied them is cancelled. B, D and F are all immediately scheduled to be called asynchronously. When F is called, f is resolved with the result.

With Domenics proposal

p = promise(token)
a = p.then(A)
b = a.finally(B)
c = b.then(C)
d = c.finally(D)
e = d.then(E)
f = e.then(null, null, F)
g = f.then(G)

things do turn out differently (disregarding here that cancellation is ignored if it happens after p settles). If token is cancelled and p was not yet settled but does get cancelled now, then a is cancelled as well and B does get scheduled. After B is called and its result is awaited, b and c get cancelled as well, and D does get scheduled. After D is called and its result is awaited, d and e get cancelled as well, and F does get scheduled. After F is called, f is resolved with the result. Unless it re-cancels, G is scheduled, and when finally called then g is resolved with the result. If any of B, D or F did throw then a rejection would have propagated down the chain.

I do believe that my approach is more comfortable and the generally expected behaviour: When I cancel an action, I am ignoring its result. I don't want it to reject or fulfill (at an arbitrary later time) regardless of my cancellation request. It also means that cancellable callbacks can be attached to all promises, it doesn't matter whether the action does support cancellation itself or not. Admittedly, finally handlers waiting for another could be quite nice, though I guess being executed sequentially is enough. But if you really need that, you can still do it with my proposal by treating cancellation as a rejection that explicitly propagates downwards (and doing if (token.requested) throw token.reason in every uncancellable handler).

The third major difference is that cancellation causes rejection. We don't need a third state in promises, as the race between the cancellation request (cancel()) on the token and the fulfillment or rejection (resolve, reject) on the promise is enough to describe the semantics of cancelled promises. If cancellation wins, the resolution of the promise doesn't really matter any more, usually all (current) subscriptions are already ignoring the result.

There are several arguments favouring rejection however:

  • The result that was promised will not become available, which is naturally a reason for rejection.
  • If code you are depending on makes a breaking change and switches to use cancellation, your code does not expect cancellation and that's what will happen: an unexpected rejection, triggering error handlers. That's a much saner default than suddenly doing nothing at all.
  • Future subscribers that didn't expect the cancellation (and call .then nonetheless) will get a rejection
  • If there are multiple subscribers with multiple tokens (different cancellations), such as in caches or queues, they don't expect any cancellation that wasn't theirs, and need to handle it like an error

A separate observable state would have the following issues:

  • not compatible with Promises/A+ semantics (including ES6 and ES7)
  • A+ assimilation would translate cancellation into forever-pending promises: ThirdStatePromise.resolve(AplusPromise.resolve(cancelledThirdStatePromise))
  • it's confusing if it does not behave the least like the other states

So after all, I believe that my approach requires no changes to completion semantics, has better backward compatibility, offers nicer, simpler and more composable syntax to developers, and gives more predicability with cancellation semantics that are easier to reason about. If you want a particular behaviour from Domenics proposal, you still can model it fairly easy with explicit rejections; In contrast, you can't get the behaviour I desire with Domenics approach.

Feedback here on the mailing list and at the repo is warmly welcome. Bergi