everyone, there is a task, where I need to terminate a function process when an event occurs.
Normally, when throwing an error in anything inside the process, for example, throwing an error from the delay
in any step of the next steps, the process will terminate without any problems.
But if I created an event listener, that’s responsible for terminating the process. If I make throw error inside it the process will keep running without any problems in the process. and it will be considered as an Unhandled Rejection
or DOMException
.
-
Because the error thrown was made inside the listener context and not the main function context everything will keep going without any problems so to make it clear I will explain what I mean with some examples
-
In The first example I’m tring to terminate the process within the event listener, but the process will continue normally without problems and it is treated as an
Unhandled Rejection
orDOMException
(() => {
class EventEmitter {
constructor() {
this.events = {};
}
on(event, listener) {
if (!this.events[event]) {
this.events[event] = [];
}
this.events[event].push(listener);
// Return a function that can be used to remove this specific listener
return () => {
this.off(event, listener);
};
}
emit(event, ...args) {
if (this.events[event]) {
this.events[event].forEach(listener => listener(...args));
}
}
removeListeners(event) {
delete this.events[event];
}
off(event, listener) {
if (this.events[event]) {
this.events[event] = this.events[event].filter(existingListener => existingListener !== listener);
}
}
}
const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));
const processEmitter = new EventEmitter();
async function startProcess(params) {
try {
const removeListener = processEmitter.on('stop', async () => {
removeListener();
throw new Error(`Process stopped due to external event`);
});
await delay(2000);
console.log('finished step 1');
await delay(2000);
console.log('finished step 2');
await delay(2000);
console.log('finished step 3');
await delay(2000);
console.log('finished step 4');
await delay(2000);
console.log('finished step 5');
await delay(2000);
console.log('finished step 6');
} catch (err) {
console.log('Process terminated:', err.message);
} finally {
console.log('done !!')
}
}
startProcess();
setTimeout(() => {
processEmitter.emit('stop');
}, 5000);
})();
- In The following example I’m trying to throw the error with proxy when the value changed
(() => {
const processEmitter = new EventEmitter();
async function startProcess(params) {
try {
// use proxy as a middleware when the value change
const abortHandler = new Proxy({ msg: '' }, {
set(target, key, value) {
target[key] = value;
throw new Error(value);
}
});
const removeListener = processEmitter.on('stop', async () => {
removeListener();
abortHandler.msg = `Process stopped due to external event`;
});
await delay(2000);
console.log('finished step 1');
await delay(2000);
console.log('finished step 2');
await delay(2000);
console.log('finished step 3');
await delay(2000);
console.log('finished step 4');
await delay(2000);
console.log('finished step 5');
await delay(2000);
console.log('finished step 6');
} catch (err) {
console.log('Process terminated:', err.message);
} finally {
console.log('done !!')
}
}
startProcess();
setTimeout(() => {
processEmitter.emit('stop');
}, 5000);
})();
- In the following example I’m trying to terminate the process with function that will throw the error when called but also it doesn’t work because it’s running inside the listener context not at the main function context
(() => {
const processEmitter = new EventEmitter();
async function startProcess(params) {
try {
// use proxy as a middleware when the value change
const reject = msg => {
throw new Error(msg);
}
const removeListener = processEmitter.on('stop', async () => {
removeListener();
reject(`Process stopped due to external event`);
});
await delay(2000);
console.log('finished step 1');
await delay(2000);
console.log('finished step 2');
await delay(2000);
console.log('finished step 3');
await delay(2000);
console.log('finished step 4');
await delay(2000);
console.log('finished step 5');
await delay(2000);
console.log('finished step 6');
} catch (err) {
console.log('Process terminated:', err.message);
} finally {
console.log('done !!')
}
}
startProcess();
setTimeout(() => {
processEmitter.emit('stop');
}, 5000);
})();
- I also tried to wrap the whole function in a
Promise
and usereject
with hope to stop the process when call reject but because of the Promise sync nature the process will keep going after calling reject without any problems
(() => {
const processEmitter = new EventEmitter();
async function startProcess(params) {
return new Promise(async (_resolve, reject) => {
try {
const removeListener = processEmitter.on('stop', async () => {
removeListener();
reject(`Process stopped due to external event`);
});
await delay(2000);
console.log('finished step 1');
await delay(2000);
console.log('finished step 2');
await delay(2000);
console.log('finished step 3');
await delay(2000);
console.log('finished step 4');
await delay(2000);
console.log('finished step 5');
await delay(2000);
console.log('finished step 6');
} catch (err) {
console.log('Process terminated:', err.message);
} finally {
console.log('done !!')
}
})
}
startProcess();
setTimeout(() => {
processEmitter.emit('stop');
}, 5000);
})();
-
I also tried intervals but the result is still the same because throwing the error will be within the callback context not at the main function context
-
The solution I found is a repetitive solution, which is the normal case, but I need to reduce repeating the same steps / code more than once, which is to use a variable or
AbortController
. However, these solutions will also be invalid, and it is not the best solution, because when I make a call tocontroller.abort()
Also,step 3
is executed even though it is not supposed to be executed, because the stop event is called in the first second of the function call, meaning that step 3 is executed while it’s suppose to not, and after that it goes to step 4 and wow it findout that signal is aborted, so it throws an error + that this step has a lot of repeativitive code, meaning that If eachdelay
represents a function alone with different code, then repeat the same step for all of them
(() => {
const delay = (ms, signal) => {
if (signal?.aborted) {
throw new Error(signal?.reason || 'Aborted')
}
return new Promise(resolve => setTimeout(resolve, ms))
};
const processEmitter = new EventEmitter();
const controller = new AbortController();
const { signal } = controller;
async function startProcess(params) {
try {
const removeListener = processEmitter.on('stop', async () => {
removeListener();
controller.abort('Process stopped due to external event');
});
await delay(2000, signal);
console.log('finished step 1');
await delay(2000, signal);
console.log('finished step 2');
await delay(2000, signal);
console.log('finished step 3');
await delay(2000, signal);
console.log('finished step 4');
await delay(2000, signal);
console.log('finished step 5');
await delay(2000, signal);
console.log('finished step 6');
} catch (err) {
console.log('Process terminated:', err.message);
} finally {
console.log('done !!')
}
}
startProcess();
setTimeout(() => {
processEmitter.emit('stop');
}, 5000);
})();
- The last solution is variable, which is also the same idea as aborted signals, but the difference is that abort controller signals make the application logic better and more elegant, and you can use it in differnt ways.
(() => {
const delay = (ms, err) => {
if (err) {
throw err;
}
return new Promise(resolve => setTimeout(resolve, ms))
};
const processEmitter = new EventEmitter();
async function startProcess(params) {
try {
let err = null;
const removeListener = processEmitter.on('stop', async () => {
removeListener();
err = new Error('Process stopped due to external event');
});
await delay(2000, err);
console.log('finished step 1');
await delay(2000, err);
console.log('finished step 2');
await delay(2000, err);
console.log('finished step 3');
await delay(2000, err);
console.log('finished step 4');
await delay(2000, err);
console.log('finished step 5');
await delay(2000, err);
console.log('finished step 6');
} catch (err) {
console.log('Process terminated:', err.message);
} finally {
console.log('done !!')
}
}
startProcess();
setTimeout(() => {
processEmitter.emit('stop');
}, 5000);
})();
2
Answers
Indeed, there can be several ways to approach this.
If this IIFE is the whole Node process, it can be stopped the regular way:
There may be no need for this because unhandled rejection results in process exit by default in up-to-date Node versions. In case this needs to be additionally handled for some reason, this can be done in
unhandledRejection
event handler. This behaviour can be enabled forEventEmitter
instance withcaptureRejections
flag.Since ES promises are not cancelable, asynchronous operations that stand behind them need to be explicitly aborted.
AbortController
is a conventional way to handle this. The promises can still be chained likeawait delay(2000, signal)
but abort signal needs to be integrated onto control flow.delay
needs to consistently return a rejected promise on abort, anderror?.name === 'AbortError'
is the way to check if an operation was aborted:new Promise(async () => ...
is promise construction antipattern. A constructed promise needs to not wrap other promises but be chained together with them. As originally noted,async
function won’t interrupt the execution when another promise is chained, this needs to be explicitly done alongside the chain. This way this doesn’t require fine-grained control as in the previous approach but won’t interrupt current operation: