0

I'm looking to cancel an asynchronous function based on a signal that can be controlled by anything, similar to how a fetch can be aborted through the use of an AbortController - effectively terminating the execution of the async function if the signal is aborted.

The only valid solution I've come across so far is to just continually check if the signal that I'm listening for is true. However, this introduces an extra amount of verbosity to the code which is the main problem with this solution. Otherwise, it looks like NodeJS can't actually terminate functions based on a signal.

I was hoping to use a solution that's syntatically similar to the following:

async function someLongFunctionWithSteps(abortSignal) {
    abortSignal.on("abort", () => throw new Error("aborted."));
    
    let one = await stepOneIsLongAndAsync();
    let two = await stepTwoIsAlsoLong().then(() => doAnotherThing());
    await stepThree(one+two).then(andAnother).catch(butCatch);
    let result = await stepFour();
    if (result) await stepFiveA();
    else await stepFiveB();
    
    try {
        await stepSix().then(stepSeven).then(stepEight);
        await Promise.all([stepNine, stepTen, stepEleven])
    } catch(e) {
        console.error(e);
        console.error("continuing...");
    }
    
    await someCloseOutTask();
}

This exact pattern doesn't work (I think because of how Errors are propogated up the call stack). So this solution doesn't actaully terminate the execution of the someLongFunctionWithSteps. I could place if(abortSignal.aborted) throw new Error("aborted."); in between each async step, however this seems inelegant and something I want to avoid.

I guess this is, in a way, similar to Promise.withResolvers(), however when a Promise is rejected, it still continues to execute its function; its return result is just the rejection instead of the resolution.

Promise.race([abortPromise, someLongFunctionWithSteps]) doesn't work here either, because it'll still execute the whole function which is what I'm trying to avoid.

I've heard that generators could work here, but they create the same code smell problem of putting a condition check between each step.

Is there an elegant method to cancelling/terminating/aborting a Promise/Async function while it's mid-execution? I'm not married to any solution design, although something which makes the code look clean is ideal.

17

1 Answer 1

2

The only valid solution I've come across so far is to just continually check if the signal that I'm listening for is true. I could place if(abortSignal.aborted) throw new Error("aborted."); in between each async step..

Yes, this is more or less still the way to go if your individual steps don't support cancellation themselves. Ideally, you would just pass the abort signal to each asynchronous function you're calling, and they would cancel on their own (and reject their result promise with the cancellation reason):

async function someLongFunctionWithSteps(abortSignal) {
    let one = await stepOneIsLongAndAsync(abortSignal);
    let two = await stepTwoIsAlsoLong(abortSignal).then(() => doAnotherThing(abortSignal));
    await stepThree(one+two, abortSignal).then(three => andAnother(three, abortSignal)).catch(butCatch);
    let result = await stepFour(abortSignal);
    if (result) await stepFiveA(abortSignal);
    else await stepFiveB(abortSignal);
    
    try {
        await stepSix(abortSignal).then(six => stepSeven(six, abortSignal)).then(seven => stepEight(seven, abortSignal));
        await Promise.all([stepNine(abortSignal), stepTen(abortSignal), stepEleven(abortSignal)])
    } catch(e) {
        console.error(e);
        console.error("continuing...");
    } finally {
        await someCloseOutTask();
    }
}

But for asynchronous functions that cannot be cancelled, checking your abort signal after they return is the best you can do. There is not really a way to avoid some syntactic overhead at every cancellation point, but the upside is that you can control where exactly these cancellation points are.

Instead of writing

if(abortSignal.aborted) throw new Error("aborted.");

you should use the simpler abortSignal.throwIfAborted(); helper method though.

And of course this means your function will always wait for the current step to finish before checking for cancellation, instead of racing the abort signal with each step and handling a cancellation immediately. If you want to do that, you'd have to wrap each promise step in a call to a helper function that handles this:

async function someLongFunctionWithSteps(abortSignal) {
    function unlessAborted(promise) {
        return new Promise((resolve, reject) => {
            abortSignal.throwIfAborted();
            const cancel = () => reject(abortSignal.reason);
            abortSignal.addEventListener("abort", cancel);
            promise.then(resolve, reject).finally(() => {
                signal.removeEventListener("abort", cancel);
            });
        });
    }

    let one = await unlessAborted(stepOneIsLongAndAsync());
    let two = await unlessAborted(stepTwoIsAlsoLong()).then(() => unlessAborted(doAnotherThing()));
    await unlessAborted(stepThree(one+two)).then(three => unlessAborted(andAnother(three))).catch(butCatch);
    let result = await unlessAborted(stepFour());
    if (result) await unlessAborted(stepFiveA());
    else await unlessAborted(stepFiveB());
    
    try {
        await unlessAborted(stepSix()).then(six => unlessAborted(stepSeven(six))).then(seven => unlessAborted(stepEight(seven)));
        await unlessAborted(Promise.all([stepNine(), stepTen(), stepEleven()]))
    } catch(e) {
        console.error(e);
        console.error("continuing...");
    } finally {
        await someCloseOutTask();
    }
}

However, this introduces an extra amount of verbosity to the code which is the main problem with this solution. […] I've heard that generators could work here, but they create the same code smell problem of putting a condition check between each step.

Actually they solve this problem. The point is to basically substitute await with yield, and having a runner execute the generator in asynchronous steps; where that runner can check the abort signal after every yield point without any syntactical overhead in the generator function.

Sign up to request clarification or add additional context in comments.

1 Comment

Oh, I wasn't aware of throwIfAborted(). It seems basically what OP had at the start. And might be the simplest way (in terms of code needed) to make a function cancellable. But I also agree that adding the manual cancel points is probably what you want in most cases as it gives more fine-grained control and less of a chance of ending up in a weird situation if a function is cancelled partway through (e.g., in the middle of some update).

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.