Cancel a vanilla ECMAScript 6 Promise chain

2019-01-01 15:30发布

Is there a method for clearing the .thens of a JavaScript Promise instance?

I've written a JavaScript test framework on top of QUnit. The framework runs tests synchronously by running each one in a Promise. (Sorry for the length of this code block. I commented it as best I can, so it feels less tedious.)

/* Promise extension -- used for easily making an async step with a
       timeout without the Promise knowing anything about the function 
       it's waiting on */
$$.extend(Promise, {
    asyncTimeout: function (timeToLive, errorMessage) {
        var error = new Error(errorMessage || "Operation timed out.");
        var res, // resolve()
            rej, // reject()
            t,   // timeout instance
            rst, // reset timeout function
            p,   // the promise instance
            at;  // the returned asyncTimeout instance

        function createTimeout(reject, tempTtl) {
            return setTimeout(function () {
                // triggers a timeout event on the asyncTimeout object so that,
                // if we want, we can do stuff outside of a .catch() block
                // (may not be needed?)
                $$(at).trigger("timeout");

                reject(error);
            }, tempTtl || timeToLive);
        }

        p = new Promise(function (resolve, reject) {
            if (timeToLive != -1) {
                t = createTimeout(reject);

                // reset function -- allows a one-time timeout different
                //    from the one original specified
                rst = function (tempTtl) {
                    clearTimeout(t);
                    t = createTimeout(reject, tempTtl);
                }
            } else {
                // timeToLive = -1 -- allow this promise to run indefinitely
                // used while debugging
                t = 0;
                rst = function () { return; };
            }

            res = function () {
                clearTimeout(t);
                resolve();
            };

            rej = reject;
        });

        return at = {
            promise: p,
            resolve: res,
            reject: rej,
            reset: rst,
            timeout: t
        };
    }
});

/* framework module members... */

test: function (name, fn, options) {
    var mod = this; // local reference to framework module since promises
                    // run code under the window object

    var defaultOptions = {
        // default max running time is 5 seconds
        timeout: 5000
    }

    options = $$.extend({}, defaultOptions, options);

    // remove timeout when debugging is enabled
    options.timeout = mod.debugging ? -1 : options.timeout;

    // call to QUnit.test()
    test(name, function (assert) {
        // tell QUnit this is an async test so it doesn't run other tests
        // until done() is called
        var done = assert.async();
        return new Promise(function (resolve, reject) {
            console.log("Beginning: " + name);

            var at = Promise.asyncTimeout(options.timeout, "Test timed out.");
            $$(at).one("timeout", function () {
                // assert.fail() is just an extension I made that literally calls
                // assert.ok(false, msg);
                assert.fail("Test timed out");
            });

            // run test function
            var result = fn.call(mod, assert, at.reset);

            // if the test returns a Promise, resolve it before resolving the test promise
            if (result && result.constructor === Promise) {
                // catch unhandled errors thrown by the test so future tests will run
                result.catch(function (error) {
                    var msg = "Unhandled error occurred."
                    if (error) {
                        msg = error.message + "\n" + error.stack;
                    }

                    assert.fail(msg);
                }).then(function () {
                    // resolve the timeout Promise
                    at.resolve();
                    resolve();
                });
            } else {
                // if test does not return a Promise, simply clear the timeout
                // and resolve our test Promise
                at.resolve();
                resolve();
            }
        }).then(function () {
            // tell QUnit that the test is over so that it can clean up and start the next test
            done();
            console.log("Ending: " + name);
        });
    });
}

If a test times out, my timeout Promise will assert.fail() on the test so that the test is marked as failed, which is all well and good, but the test continues to run because the test Promise (result) is still waiting to resolve it.

I need a good way to cancel my test. I can do it by creating a field on the framework module this.cancelTest or something, and checking every so often (e.g. at the beginning of each then() iteration) within the test whether to cancel out. However, ideally, I could use $$(at).on("timeout", /* something here */) to clear the remaining then()s on my result variable, so that none of the rest of the test is run.

Does something like this exist?

Quick Update

I tried using Promise.race([result, at.promise]). It didn't work.

Update 2 + confusion

To unblock me, I added a few lines with the mod.cancelTest/polling within the test idea. (I also removed the event trigger.)

return new Promise(function (resolve, reject) {
    console.log("Beginning: " + name);

    var at = Promise.asyncTimeout(options.timeout, "Test timed out.");
    at.promise.catch(function () {
        // end the test if it times out
        mod.cancelTest = true;
        assert.fail("Test timed out");
        resolve();
    });

    // ...

}).then(function () {
    // tell QUnit that the test is over so that it can clean up and start the next test
    done();
    console.log("Ending: " + name);
});

I set a breakpoint in the catch statement, and it's being hit. What's confusing me now is that the then() statement isn't being called. Ideas?

Update 3

Figured the last thing out. fn.call() was throwing an error which I didn't catch, so the test promise was rejecting before at.promise.catch() could resolve it.

10条回答
ら面具成の殇う
2楼-- · 2019-01-01 15:56

I'm really surprised that no-one mentions Promise.race as a candidate for this:

const actualPromise = new Promise((resolve, reject) => { setTimeout(resolve, 10000) });
let cancel;
const cancelPromise = new Promise((resolve, reject) => {
    cancel = reject.bind(null, { canceled: true })
})

const cancelablePromise = Object.assign(Promise.race([actualPromise, cancelPromise]), { cancel });
查看更多
回忆,回不去的记忆
3楼-- · 2019-01-01 15:57

If you want to stop all thens/catchs from being executed you can do this by injecting a promise that will never resolve. It probably has memory leak reprocusions but it will fix the issue and shouldn't cause too much wasted memory in most applications.

new Promise((resolve, reject) => {
    console.log('first chain link executed')
    resolve('daniel');
}).then(name => {
    console.log('second chain link executed')
    if (name === 'daniel') {
        // I don't want to continue the chain, return a new promise
        // that never calls its resolve function
        return new Promise((resolve, reject) => {
            console.log('unresolved promise executed')
        });
    }
}).then(() => console.log('last chain link executed'))

// VM492:2 first chain link executed
// VM492:5 second chain link executed
// VM492:8 unresolved promise executed
查看更多
长期被迫恋爱
4楼-- · 2019-01-01 16:00

If p is a variable that contains a Promise, then p.then(empty); should dismiss the promise when it eventually completes or if it is already complete (yes, I know this isn't the original question, but it is my question). "empty" is function empty() {} . I'm just a beginner and probably wrong, but these other answers seem too complicated. Promises are supposed to be simple.

查看更多
余生无你
5楼-- · 2019-01-01 16:01

Here's our implementation https://github.com/permettez-moi-de-construire/cancellable-promise

Used like

const {
  cancellablePromise,
  CancelToken,
  CancelError
} = require('@permettezmoideconstruire/cancellable-promise')

const cancelToken = new CancelToken()

const initialPromise = SOMETHING_ASYNC()
const wrappedPromise = cancellablePromise(initialPromise, cancelToken)


// Somewhere, cancel the promise...
cancelToken.cancel()


//Then catch it
wrappedPromise
.then((res) => {
  //Actual, usual fulfill
})
.catch((err) => {
  if(err instanceOf CancelError) {
    //Handle cancel error
  }

  //Handle actual, usual error
})

which :

  • Doesn't touch Promise API
  • Let us make further cancellation inside catch call
  • Rely on cancellation being rejected instead of resolved unlike any other proposal or implementation

Pulls and comments welcome

查看更多
初与友歌
6楼-- · 2019-01-01 16:06

Is there a method for clearing the .thens of a JavaScript Promise instance?

No. Not in ECMAScript 6 at least. Promises (and their then handlers) are uncancellable by default (unfortunately). There is a bit of discussion on es-discuss (e.g. here) about how to do this in the right way, but whatever approach will win it won't land in ES6.

The current standpoint is that subclassing will allow to create cancellable promises using your own implementation (not sure how well that'll work).

Until the language commitee has figured out the best way (ES7 hopefully?) you can still use userland Promise implementations, many of which feature cancellation.

Current discussion is in the https://github.com/domenic/cancelable-promise and https://github.com/bergus/promise-cancellation drafts.

查看更多
爱死公子算了
7楼-- · 2019-01-01 16:06

There are a few npm libraries for cancellable promises.

  1. p-cancelable https://github.com/sindresorhus/p-cancelable

  2. cancelable-promise https://github.com/alkemics/CancelablePromise

查看更多
登录 后发表回答