Alternative Promise cancellation proposal

Bergi a.d.bergi at web.de
Mon Jul 25 21:32:43 UTC 2016


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 
`CancelToken`s
* 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](https://github.com/bergus/promise-cancellation/blob/master/trifurcation.md) 
and the distinction of the outcomes, is available as a separate helper 
method.
(I'm still looking for a good name 
<https://github.com/bergus/promise-cancellation/issues/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:
```js
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 <https://github.com/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:
```js
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:
* `cancel()` result allows the canceller to handle errors from
   the cancellation phase
   <https://github.com/bergus/promise-cancellation/issues/9>
* `CancelToken`s have no `.promise` which never rejects
   but just a `.subscribe` method instead of `.promise.then`
   <https://github.com/domenic/cancelable-promise/issues/30>

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
```js
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
```js
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:
```js
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
```js
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


More information about the es-discuss mailing list