I ran into an interesting JavaScript problem today: How to detect a promise that will never resolve/ reject?
It is generally not desirable to ever have a promise that does not resolve/ reject. In this particular case, it was a bug in a third-party package.
The underlying problem was an interesting challenge and it could be generalised as:
const main = async () => {
const foo = new Promise(() => {});
foo
.catch((error) => {
console.log('error', error);
})
.then((response) => {
console.log('response', response);
});
};
main();
In this case foo
is not going to resolve and it will not going to reject. In fact, because there is nothing keeping the event loop alive, the program will just quit.
The solution that I came up was to add a timeout and listen for asynchronous events created in the same asynchronous context as where the promise is created.
const asyncHooks = require('async_hooks');
const timeoutIdlePromise = async (createPromise, maximumIdleTime) => {
return new Promise(async (resolve, reject) => {
let Timeout;
const parentAsyncIds = [];
const asyncHook = asyncHooks.createHook({
init: (asyncId, type, triggerAsyncId) => {
if (parentAsyncIds.includes(triggerAsyncId)) {
if (Timeout) {
Timeout.refresh();
}
if (!parentAsyncIds.includes(asyncId)) {
parentAsyncIds.push(asyncId);
}
}
},
});
Timeout = setTimeout(() => {
reject(new Error('Idle promise timeout.'));
asyncHook.disable();
}, maximumIdleTime);
asyncHook.enable();
// Force new async execution context.
await null;
const executionAsyncId = asyncHooks.executionAsyncId();
parentAsyncIds.push(executionAsyncId);
try {
const result = await createPromise();
resolve(result);
} catch (error) {
reject(error);
} finally {
asyncHook.disable();
}
})
};
// Rejected with Idle promise timeout.
timeoutIdlePromise(() => {
return new Promise((resolve) => {
});
}, 1000);
// Resolved.
timeoutIdlePromise(() => {
return new Promise((resolve) => {
setTimeout(() => {
setTimeout(() => {
setTimeout(() => {
resolve();
}, 500);
}, 500);
}, 500);
});
}, 1000);
async_hooks
are used here to check if the promise is creating any asynchronous events (and if the asynchronous events created by the promise create other asynchronous events themselves, etc) As long as there is some asynchronous activity within the promise (e.g. event listeners, network activity, timeouts), it will continue to hang. It will throw an error if there is no asynchronous activity within maximumIdleTime.
I have abstracted the above logic into a module timeout-idle-promise
.
import {
timeoutIdlePromise,
TimeoutError,
} from 'timeout-idle-promise';
// Rejected with TimeoutError error.
timeoutIdlePromise(() => {
return new Promise((resolve) => {
});
}, 1000);
// Resolved.
timeoutIdlePromise(() => {
return new Promise((resolve) => {
setTimeout(() => {
setTimeout(() => {
setTimeout(() => {
resolve();
}, 500);
}, 500);
}, 500);
});
}, 1000);