server: Fix handling of errors during startup and shutdown

Before, an unhandled rejection or uncaught exception during startup
would cause `exports.exit()` to wait forever for startup completion.
Similarly, an error during shutdown would cause `exports.exit()` to
wait forever for shutdown to complete. Now any error during startup or
shutdown triggers an immediate exit.
pull/4740/head
Richard Hansen 2021-02-09 00:03:05 -05:00 committed by John McLear
parent 5999d8cd44
commit ebdb2798ff
1 changed files with 64 additions and 46 deletions

View File

@ -59,6 +59,7 @@ const State = {
STOPPED: 5, STOPPED: 5,
EXITING: 6, EXITING: 6,
WAITING_FOR_EXIT: 7, WAITING_FOR_EXIT: 7,
STATE_TRANSITION_FAILED: 8,
}; };
let state = State.INITIAL; let state = State.INITIAL;
@ -85,13 +86,15 @@ exports.start = async () => {
break; break;
case State.STARTING: case State.STARTING:
await startDoneGate; await startDoneGate;
// fall through // Retry. Don't fall through because it might have transitioned to STATE_TRANSITION_FAILED.
return await exports.start();
case State.RUNNING: case State.RUNNING:
return express.server; return express.server;
case State.STOPPING: case State.STOPPING:
case State.STOPPED: case State.STOPPED:
case State.EXITING: case State.EXITING:
case State.WAITING_FOR_EXIT: case State.WAITING_FOR_EXIT:
case State.STATE_TRANSITION_FAILED:
throw new Error('restart not supported'); throw new Error('restart not supported');
default: default:
throw new Error(`unknown State: ${state.toString()}`); throw new Error(`unknown State: ${state.toString()}`);
@ -99,48 +102,54 @@ exports.start = async () => {
logger.info('Starting Etherpad...'); logger.info('Starting Etherpad...');
startDoneGate = new Gate(); startDoneGate = new Gate();
state = State.STARTING; state = State.STARTING;
try {
// Check if Etherpad version is up-to-date
UpdateCheck.check();
// Check if Etherpad version is up-to-date // start up stats counting system
UpdateCheck.check(); const stats = require('./stats');
stats.gauge('memoryUsage', () => process.memoryUsage().rss);
stats.gauge('memoryUsageHeap', () => process.memoryUsage().heapUsed);
// start up stats counting system process.on('uncaughtException', (err) => exports.exit(err));
const stats = require('./stats'); // As of v14, Node.js does not exit when there is an unhandled Promise rejection. Convert an
stats.gauge('memoryUsage', () => process.memoryUsage().rss); // unhandled rejection into an uncaught exception, which does cause Node.js to exit.
stats.gauge('memoryUsageHeap', () => process.memoryUsage().heapUsed); process.on('unhandledRejection', (err) => { throw err; });
process.on('uncaughtException', (err) => exports.exit(err)); for (const signal of ['SIGINT', 'SIGTERM']) {
// As of v14, Node.js does not exit when there is an unhandled Promise rejection. Convert an // Forcibly remove other signal listeners to prevent them from terminating node before we are
// unhandled rejection into an uncaught exception, which does cause Node.js to exit. // done cleaning up. See https://github.com/andywer/threads.js/pull/329 for an example of a
process.on('unhandledRejection', (err) => { throw err; }); // problematic listener. This means that exports.exit is solely responsible for performing all
// necessary cleanup tasks.
for (const signal of ['SIGINT', 'SIGTERM']) { for (const listener of process.listeners(signal)) {
// Forcibly remove other signal listeners to prevent them from terminating node before we are removeSignalListener(signal, listener);
// done cleaning up. See https://github.com/andywer/threads.js/pull/329 for an example of a }
// problematic listener. This means that exports.exit is solely responsible for performing all process.on(signal, exports.exit);
// necessary cleanup tasks. // Prevent signal listeners from being added in the future.
for (const listener of process.listeners(signal)) { process.on('newListener', (event, listener) => {
removeSignalListener(signal, listener); if (event !== signal) return;
removeSignalListener(signal, listener);
});
} }
process.on(signal, exports.exit);
// Prevent signal listeners from being added in the future.
process.on('newListener', (event, listener) => {
if (event !== signal) return;
removeSignalListener(signal, listener);
});
}
await util.promisify(npm.load)(); await util.promisify(npm.load)();
await db.init(); await db.init();
await plugins.update(); await plugins.update();
const installedPlugins = Object.values(pluginDefs.plugins) const installedPlugins = Object.values(pluginDefs.plugins)
.filter((plugin) => plugin.package.name !== 'ep_etherpad-lite') .filter((plugin) => plugin.package.name !== 'ep_etherpad-lite')
.map((plugin) => `${plugin.package.name}@${plugin.package.version}`) .map((plugin) => `${plugin.package.name}@${plugin.package.version}`)
.join(', '); .join(', ');
logger.info(`Installed plugins: ${installedPlugins}`); logger.info(`Installed plugins: ${installedPlugins}`);
logger.debug(`Installed parts:\n${plugins.formatParts()}`); logger.debug(`Installed parts:\n${plugins.formatParts()}`);
logger.debug(`Installed hooks:\n${plugins.formatHooks()}`); logger.debug(`Installed hooks:\n${plugins.formatHooks()}`);
await hooks.aCallAll('loadSettings', {settings}); await hooks.aCallAll('loadSettings', {settings});
await hooks.aCallAll('createServer'); await hooks.aCallAll('createServer');
} catch (err) {
logger.error('Error occurred while starting Etherpad');
state = State.STATE_TRANSITION_FAILED;
startDoneGate.resolve();
return await exports.exit(err);
}
logger.info('Etherpad is running'); logger.info('Etherpad is running');
state = State.RUNNING; state = State.RUNNING;
@ -166,6 +175,7 @@ exports.stop = async () => {
case State.STOPPED: case State.STOPPED:
case State.EXITING: case State.EXITING:
case State.WAITING_FOR_EXIT: case State.WAITING_FOR_EXIT:
case State.STATE_TRANSITION_FAILED:
return; return;
default: default:
throw new Error(`unknown State: ${state.toString()}`); throw new Error(`unknown State: ${state.toString()}`);
@ -173,14 +183,21 @@ exports.stop = async () => {
logger.info('Stopping Etherpad...'); logger.info('Stopping Etherpad...');
let stopDoneGate = new Gate(); let stopDoneGate = new Gate();
state = State.STOPPING; state = State.STOPPING;
let timeout = null; try {
await Promise.race([ let timeout = null;
hooks.aCallAll('shutdown'), await Promise.race([
new Promise((resolve, reject) => { hooks.aCallAll('shutdown'),
timeout = setTimeout(() => reject(new Error('Timed out waiting for shutdown tasks')), 3000); new Promise((resolve, reject) => {
}), timeout = setTimeout(() => reject(new Error('Timed out waiting for shutdown tasks')), 3000);
]); }),
clearTimeout(timeout); ]);
clearTimeout(timeout);
} catch (err) {
logger.error('Error occurred while stopping Etherpad');
state = State.STATE_TRANSITION_FAILED;
stopDoneGate.resolve();
return await exports.exit(err);
}
logger.info('Etherpad stopped'); logger.info('Etherpad stopped');
state = State.STOPPED; state = State.STOPPED;
stopDoneGate.resolve(); stopDoneGate.resolve();
@ -214,6 +231,7 @@ exports.exit = async (err = null) => {
return await exports.exit(); return await exports.exit();
case State.INITIAL: case State.INITIAL:
case State.STOPPED: case State.STOPPED:
case State.STATE_TRANSITION_FAILED:
break; break;
case State.EXITING: case State.EXITING:
await exitGate; await exitGate;