The Death of a Node.js Process
This post is based on content from my recently published book, Distributed Systems with Node.js. If you're interested in building Node.js services, especially those that interact with external systems, then I highly recommend checking it out. It contains a wealth of information, and this post provides but a tiny taste.
There are several things that can cause a Node.js process to terminate. Some of them are preventable, like when an error is thrown, while others cannot be prevented, like running out of memory. The process
global is an Event Emitter instance and, when a graceful exit is performed, will emit an exit
event. Application code can then listen to this event to perform some last minute synchronous cleanup work.
Here are some ways that a process termination can be intentionally triggered:
Operation | Example |
---|---|
Manual process exit | process.exit(1) |
Uncaught exception | throw new Error() |
Unhandled promise rejection | Promise.reject() |
Ignored error event | EventEmitter#emit('error') |
Unhandled signals | $ kill <PROCESS_ID> |
A lot of these are often triggered accidentally, like with uncaught errors or unhandled rejections, but one of them was created with the intention of directly causing a process to terminate.
Process Exit
The process.exit(code)
approach to process termination is the most straightforward tool at your disposal. It's very useful when building scripts when you know that your process has reached the end of its lifetime. The code
value is optional and defaults to 0 and can be set to a number as high as 255. A 0 represents a successful process run, while any non-zero number means a failure happened. These values are used by many different external tools. For example, when a test suite runs, a non-zero value means the tests have failed.
When process.exit()
is called directly there is no implicit text written to the console. If you have written code that calls this method in representation of an error, then your code should print an error for the user to help them out. For example, run the following code:
$ node -e "process.exit(42)"
$ echo $?
In this case no message was printed by the one-line Node.js application, though the shell did print the exit status. A user encountering such a process exit not going to understand what's happening. On the other hand, consider this code that might run when a program is configured incorrectly:
function checkConfig(config) {
if (!config.host) {
console.error("Configuration is missing 'host' parameter!");
process.exit(1);
}
}
In this situation there is no ambiguity for the user. They run the app, an error is printed to the console, and they're able to rectify the situation.
It's worth noting that the process.exit()
method is extremely powerful. While it has its purposes in application code it should really never make its way into a reusable library. If an error does happen in a library, it should be thrown so that the application may decide what to do with the error.
Exceptions, Rejections, and Emitted Errors
While process.exit()
is helpful when it comes to startup / configuration errors, for runtime errors you'll need to use a different tool. For example, when an application is handling an HTTP request, an error probably shouldn't terminate the process, instead it should just return an error response. Having information about where the error happened is also useful. This is where thrown Error
objects are useful.
Instances of the Error
class contain metadata that is useful for determining what caused the error, such as stack traces and message strings. It's common to extend from Error
with your own application-specific error classes. Instantiating an error on its own doesn't have much side effect, for that to happen an error must be thrown.
An error is thrown when the throw
keyword is used, or when certain logical errors occur. When this happens the current stack "unwinds", meaning each function exits until one of the calling functions has wrapped the call in a try/catch statement. Once this statement is encountered the catch branch is called. If the error is never wrapped in a try/catch then the error is considered uncaught.
While you should use the throw
keyword with an Error, like throw new Error('foo')
, you can technically throw anything. Once something has been thrown it is considered an exception. It's important to throw Error
instances as code that catches those errors will likely expect error properties.
Another pattern, made popular by internal Node.js libraries, is to provide a .code
property which is a documented string value that should remain consistent between releases. An example of an error code is ERR_INVALID_URI
which, even though the associated human-readable .message
property may change, this code
value shouldn't.
Sadly, one of the more common patterns used for differentiating errors is to inspect the .message
property, which is often dynamic and could require typo changes. This is risky and error prone. There is no perfect solution within the Node.js ecosystem for differentiating errors across all libraries.
When an uncaught error is thrown the stack trace is printed in the console and the process terminates with an exit status of 1. Here's an example of such an exception:
/tmp/foo.js:1
throw new TypeError('invalid foo');
^
Error: invalid foo
at Object.<anonymous> (/tmp/foo.js:2:11)
... TRUNCATED ...
at internal/main/run_main_module.js:17:47
This truncated stack traces suggests the error happened on line 2, column 11 in a file named foo.js
.
The process
global is an Event Emitter and can be used to intercept such uncaught errors by listening for the uncaughtException
event. Here's an example of how to use it, intercepting an error to send an asynchronous message before exiting:
const logger = require('./lib/logger.js');
process.on('uncaughtException', (error) => {
logger.send("An uncaught exception has occured", error, () => {
console.error(error);
process.exit(1);
});
});
Promise Rejections are pretty similar to thrown errors. A promise can reject either if the reject()
method in a promise is called, or an error is thrown inside of an asynchronous function. The following two examples are mostly equivalent in this regard:
Promise.reject(new Error('oh no'));
(async () => {
throw new Error('oh no');
})();
Here's an example of what the message printed to the console looks like:
(node:52298) UnhandledPromiseRejectionWarning: Error: oh no
at Object.<anonymous> (/tmp/reject.js:1:16)
... TRUNCATED ...
at internal/main/run_main_module.js:17:47
(node:52298) UnhandledPromiseRejectionWarning: Unhandled promise
rejection. This error originated either by throwing inside of an
async function without a catch block, or by rejecting a promise
which was not handled with .catch().
Unlike with an uncaught exception, these rejections won't crash a process as of Node.js v14. In future versions of Node.js this will crash the process. You can also intercept events when these unhandled rejections happen, listening for another event on the process
object:
process.on('unhandledRejection', (reason, promise) => {});
Event Emitters are a common pattern in Node.js, with many object instances that extend from this base class used in libraries and applications alike. They're so popular that they're worth discussing alongside errors and rejections.
When an Event Emitter emits an error
event that doesn't have a listener on it, the Event Emitter will throw the argument that was emitted. This will then spit out an error and cause the process to exit. Here's an example of what is printed in the console:
events.js:306
throw err; // Unhandled 'error' event
^
Error [ERR_UNHANDLED_ERROR]: Unhandled error. (undefined)
at EventEmitter.emit (events.js:304:17)
at Object.<anonymous> (/tmp/foo.js:1:40)
... TRUNCATED ...
at internal/main/run_main_module.js:17:47 {
code: 'ERR_UNHANDLED_ERROR',
context: undefined
}
Be sure to listen for error
events in the Event Emitter instances you work with so that your application may gracefully handle the event without crashing.
Signals
Signals are an operating system-provided mechanism for sending small numeric messages from one program to another. These numbers are often referred by a constant string equivalent. For example, the signal SIGKILL
represents a numeric signal of 9. Signals can have different purposes but are often used to terminate a program in some capacity.
Different operating systems can have different signals defined, but the following list is mostly universal:
Name | Number | Handleable | Node.js Default | Signal Purpose |
---|---|---|---|---|
SIGHUP |
1 | Yes | Terminate | Parent terminal has been closed |
SIGINT |
2 | Yes | Terminate | Terminal trying to interrupt, à la Ctrl + C |
SIGQUIT |
3 | Yes | Terminate | Terminal trying to quit, à la Ctrl + D |
SIGKILL |
9 | No | Terminate | Process is being forcefully killed |
SIGUSR1 |
10 | Yes | Start Debugger | User-defined signal 1 |
SIGUSR2 |
12 | Yes | Terminate | User-defined signal 2 |
SIGTERM |
12 | Yes | Terminate | Represents a graceful termination |
SIGSTOP |
19 | No | Terminate | Process is being forcefully stopped |
If a program may choose to implement a signal handler then the Handleable column contains a Yes. The two signals with a No cannot be handled. The Node.js Default column tells you what the default action is with a Node.js program when the signal is received. The last Signal Purpose states what the signal is usually used for in the wild.
Handling these signals in your Node.js programs can be done by listening for more events on the process
object:
#!/usr/bin/env node
console.log(`Process ID: ${process.pid}`);
process.on('SIGHUP', () => console.log('Received: SIGHUP'));
process.on('SIGINT', () => console.log('Received: SIGINT'));
setTimeout(() => {}, 5 * 60 * 1000); // keep process alive
Run this program in a terminal window, then press Ctrl + C, and the process won't die. Instead, it'll state that it has received the SIGINT
signal. Switch to another terminal window and execute the following command based on the printed Process ID value:
$ kill -s SIGHUP <PROCESS_ID>
This shows how one program is able to send a signal to another program and your Node.js program running in the first terminal will print that it received the SIGHUP
signal.
As you might have guessed, Node.js is also able to send commands to other programs. Execute the following command to send a signal from an ephemeral Node.js process to your existing process:
$ node -e "process.kill(<PROCESS_ID>, 'SIGHUP')"
This should also display the SIGHUP
message in your first program. Now, if you would like to actually terminate the first process, run the following command to send it an un-handleable SIGKILL
signal:
$ kill -9 <PROCESS_ID>
At this point the application should exit.
These signals are used a lot in Node.js applications for handling graceful shutdown events. For example, when a Kubernetes pod terminate, it will send a SIGTERM
signal to the applications, and then start a 30 second timer. The application can then gracefully close itself in those 30 seconds, closing connections and saving data. If the process remains alive after this timer then Kubernetes will send it a SIGKILL
.
If you like what you read here, then check out my book Distributed Systems with Node.js. It contains a ton of information about running a Node.js process in a setting where it needs to interact with external services.