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:
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:
CancelTokens have no .promise which never rejects
but just a .subscribe method instead of .promise.thendomenic/cancelable-promise#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
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
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
I am proposing a new approach for promise cancellation:
<github.com/bergus/promise-cancellation>
The major points are
CancelToken
scancel()
for their associated token is calledthen
This has a few merits:
async
/await
Task
implementationsAn 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
andtoken.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:
then
callback to avoid any incompatibilities with popular legacy implementation that already use one.then
behaves to preserve compatibility with Promises/A+ semantics and prevent issues with assimilation.token.cancelIfRequested()
or wrap the callback body inif (!token.requested)
to prevent it from running when cancellation is requested, instead you pass thetoken
as the last argument tothen
..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 bergus/promise-cancellation#9CancelToken
s have no.promise
which never rejects but just a.subscribe
method instead of.promise.then
domenic/cancelable-promise#30There are three fundamental differences between Domenics approach and mine (covered in detail below):
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 thetoken
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 thepromise
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 thetoken
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). Iftoken
is cancelled andp
was not yet settled but does get cancelled now, thena
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 doingif (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:
.then
nonetheless) will get a rejectionA separate observable state would have the following issues:
ThirdStatePromise.resolve(AplusPromise.resolve(cancelledThirdStatePromise))
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