Reputation: 1732
I'm working on implementing graceful shutdown for a node API. Typically, during development, this process is started using the common start
script, but I have noticed that this causes some annoying behavior that I'm surprised I've never really noticed before in my 3 or so years as a node developer.
To begin shutdown during development, we simply hit ctrl+C in the bash terminal, which of course causes a SIGINT
to be sent to the npm process and all of its child processes. I can capture this with a process.on
handler to initiate a graceful shutdown from there-- closing off new requests, waiting for existing requests to finish, then killing the database connection, all that good stuff.
However, if the developer hits ctrl+C a second time, npm behaves differently. It seems to be sending a SIGTERM to the sh
process it used to invoke my start
script in the first place. This causes that sh
process to print out Terminated
and exit, returning control of the terminal to the user without waiting for the node process to exit.
This is really annoying because it gives the impression that the node process has been stopped, but of course it hasn't. It will continue until shutdown is complete, or it is forcibly killed with something like SIGKILL or SIGQUIT. If it happens to print anything out to the console, it will do so directly in the middle of whatever else the developer might now be running in that terminal.
For a trivial example, try this:
package.json:
{
"private": true,
"scripts": {
"start": "node index.js"
}
}
index.js:
async function waitForever() {
while(true) {
console.log('waiting...');
await new Promise((resolve) => {
setTimeout(resolve, 5000);
});
}
}
process.on('SIGINT', () => {
console.log('SIGINT recieved');
});
process.on('SIGTERM', () => {
console.log('SIGTERM recieved');
})
waitForever();
Run npm start
in your terminal, then hit ctrl+c once. You'll see the signal make it through to node, but of course it won't exit. Now, do it a second time. You'll see the signal make it through again, but then you immediately see "Terminated" followed by your shell prompt. Until you find the process ID of the node process and kill it with kill -9
you'll keep seeing that waiting...
message every five seconds.
I did some more fiddling around with this example and it very much seems like npm is completely responsible for this. If you send kill -2
directly to the npm process twice, the termination of the shell process occurs, without SIGINT ever being received by the node process.
So I have two main questions:
What the heck is going on here? Am I missing something about how my shell works, or is this some kind of feature built in to npm run-script? If so, where I can find information about this? npm help run-script
shows nothing about it.
I know that start
scripts are pretty common in projects like this, so it seems like someone else should be encountering this problem. How do people typically deal with it? I've Googled around a bunch and it's been hard to find a clear answer.
This isn't a huge deal, of course. The start script is just a convenience to make sure a TS compilation runs before starting up. I can have developers run the built app directly in their shell after building, or write a script that performs the build and start outside of an npm script. But it would be nice to not have to do this.
Really I'm just puzzled and would appreciate some assistance. Thanks!
Upvotes: 5
Views: 1260
Reputation: 7431
To answer your 1 - looking at npm
code this is expected behavior of handling SIGINT
. First occurrence of signal is passed down to child process and also attaches one-time listener for subsequent SIGINT
that will kill the npm
parent process immediately. You can see code here.
I assume this is because npm start
is meant as development stage shorthand only and there it makes sense to have a "handbrake" to kill process immediately in cases where you e.g. get signal handling wrong (unfortunately even that doesn't work in all cases as you found out).
I don't have answer for 2 but sometime ago there was a lengthy debate about this going in various npm
issues regarding signal handling and npm start
. Official NPM statement was mostly that npm start
is not replacement for proper process manager (e.g. supervisor or systemd) and shouldn't be used in production environment like that.
EDIT: An answer for 2 from sripberger:
What I ended up doing follows. shutdown
is a function that performs the shutdown, returning a promise. The first SIGINT will begin the shutdown, but a second will forcibly kill the process whether or not shutdown is finished. This does not prevent npm from terminating the shell process, but it does make sure the node process dies with it when this happens:
process.once('SIGINT', () => {
console.log('\nShutting down, please wait...');
// Begin the graceful shutdown.
shutdown().catch((err) => {
console.error('Could not shut down gracefully:', err);
});
// Attach a subsequent handler to force kill.
process.on('SIGINT', () => {
console.log('\nProcess killed.');
process.exit(0);
});
});
Of course, as noted by blami, it is not recommended to use npm scripts to control services in production. This is simply a convenience for development environments.
Upvotes: 6