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
_______________________________________________
es-discuss mailing list
[email protected]
https://mail.mozilla.org/listinfo/es-discuss

Reply via email to