If I understand your problem correctly, the following may be a solution.
Simple timeout
Assume your mainline code looks like this:
send(msg1)
.then(() => receive(t1))
.then(() => send(msg2))
.then(() => receive(t2))
.catch(() => console.log("Didn't complete sequence"));
receive
would be something like:
function receive(t) {
return new Promise((resolve, reject) => {
setTimeout(() => reject("timed out"), t);
receiveMessage(resolve, reject);
});
}
This assumes the existence of an underlying API receiveMessage
, which takes two callbacks as parameters, one for success and one for failure. receive
simply wraps receiveMessage
with the addition of the timeout which rejects the promise if time t
passes before receiveMessage
resolves.
User cancellation
But how to structure this so that an external user can cancel the sequence? You have the right idea to use a promise instead of polling. Let's write our own cancelablePromise
:
function cancelablePromise(executor, canceler) {
return new Promise((resolve, reject) => {
canceler.then(e => reject(`cancelled for reason ${e}`));
executor(resolve, reject);
});
}
We pass an "executor" and a "canceler". "Executor" is the technical term for the parameter passed to the Promise constructor, a function with the signature (resolve, reject)
. The canceler we pass in is a promise, which when fulfilled, cancels (rejects) the promise we are creating. So cancelablePromise
works exactly like new Promise
, with the addition of a second parameter, a promise for use in canceling.
Now you can write your code as something like the following, depending on when you want to be able to cancel:
var canceler1 = new Promise(resolve =>
document.getElementById("cancel1", "click", resolve);
);
send(msg1)
.then(() => cancelablePromise(receiveMessage, canceler1))
.then(() => send(msg2))
.then(() => cancelablePromise(receiveMessage, canceler2))
.catch(() => console.log("Didn't complete sequence"));
If you are programming in ES6 and like using classes, you could write
class CancelablePromise extends Promise {
constructor(executor, canceler) {
super((resolve, reject) => {
canceler.then(reject);
executor(resolve, reject);
}
}
This would then obviously be used as in
send(msg1)
.then(() => new CancelablePromise(receiveMessage, canceler1))
.then(() => send(msg2))
.then(() => new CancelablePromise(receiveMessage, canceler2))
.catch(() => console.log("Didn't complete sequence"));
If programming in TypeScript, with the above code you will likely need to target ES6 and run the resulting code in an ES6-friendly environment which can handle the subclassing of built-ins like Promise
correctly. If you target ES5, the code TypeScript emits might not work.
The above approach has a minor (?) defect. Even if canceler
has fulfilled before we start the sequence, or invoke cancelablePromise(receiveMessage, canceler1)
, although the promise will still be canceled (rejected) as expected, the executor will nevertheless run, kicking off the receiving logic--which in the best case might consume network resources we would prefer not to. Solving this problem is left as an exercise.
"True" cancelation
But none of the above addresses what may be the real issue: to cancel an in-progress asynchronous computation. This kind of scenario was what motivated the proposals for cancelable promises, including the one which was recently withdrawn from the TC39 process. The assumption is that the computation provides some interface for cancelling it, such as xhr.abort()
.
Let's assume that we have a web worker to calculate the nth prime, which kicks off on receiving the go
message:
function findPrime(n) {
return new Promise(resolve => {
var worker = new Worker('./find-prime.js');
worker.addEventListener('message', evt => resolve(evt.data));
worker.postMessage({cmd: 'go', n});
}
}
> findPrime(1000000).then(console.log)
< 15485863
We can make this cancelable, assuming the worker responds to a "stop"
message to terminate its work, again using a canceler
promise, by doing:
function findPrime(n, canceler) {
return new Promise((resolve, reject) => {
// Initialize worker.
var worker = new Worker('./find-prime.js');
// Listen for worker result.
worker.addEventListener('message', evt => resolve(evt.data));
// Kick off worker.
worker.postMessage({cmd: 'go', n});
// Handle canceler--stop worker and reject promise.
canceler.then(e => {
worker.postMessage({cmd: 'stop')});
reject(`cancelled for reason ${e}`);
});
}
}
The same approach could be used for a network request, where the cancellation would involve calling xhr.abort()
, for example.
By the way, one rather elegant (?) proposal for handling this sort of situation, namely promises which know how to cancel themselves, is to have the executor, whose return value is normally ignored, instead return a function which can be used to cancel itself. Under this approach, we would write the findPrime
executor as follows:
const findPrimeExecutor = n => resolve => {
var worker = new Worker('./find-prime.js');
worker.addEventListener('message', evt => resolve(evt.data));
worker.postMessage({cmd: 'go', n});
return e => worker.postMessage({cmd: 'stop'}));
}
In other words, we need only to make a single change to the executor: a return
statement which provides a way to cancel the computation in progress.
Now we can write a generic version of cancelablePromise
, which we will call cancelablePromise2
, which knows how to work with these special executors that return a function to cancel the process:
function cancelablePromise2(executor, canceler) {
return new Promise((resolve, reject) => {
var cancelFunc = executor(resolve, reject);
canceler.then(e => {
if (typeof cancelFunc === 'function') cancelFunc(e);
reject(`cancelled for reason ${e}`));
});
});
}
Assuming a single canceler, your code can now be written as something like
var canceler = new Promise(resolve => document.getElementById("cancel", "click", resolve);
function chain(msg1, msg2, canceler) {
const send = n => () => cancelablePromise2(findPrimeExecutor(n), canceler);
const receive = () => cancelablePromise2(receiveMessage, canceler);
return send(msg1)()
.then(receive)
.then(send(msg2))
.then(receive)
.catch(e => console.log(`Didn't complete sequence for reason ${e}`));
}
chain(msg1, msg2, canceler);
At the moment that the user clicks on the "Cancel" button, and the canceler
promise is fulfilled, any pending sends will be canceled, with the worker stopping in midstream, and/or any pending receives will be canceled, and the promise will be rejected, that rejection cascading down the chain to the final catch
.
The various approaches that have been proposed for cancelable promise attempt to make the above more streamlined, more flexible, and more functional. To take just one example, some of them allow synchronous inspection of the cancellation state. To do this, some of them use the notion of "cancel tokens" which can be passed around, playing a role somewhat analogous to our canceler
promises. However, in most cases cancellation logic can be handled without too much complexity in pure userland code, as we have done here.