Uncaught exceptions in Node.js
In this post, I’ll jot down some notes that I took when refactoring the uncaught exception handling routines in Node.js. Hopefully it could be useful for other people who are interested in this part of the code base, or for code archaeologists in the future.
The global escape catch for uncaught exceptions
The core of the uncaught exception handling in Node.js currently resides in node::errors::TriggerUncaughtException()
- this was recently renamed from node::FatalException()
, which was not an accurate name anymore because the exceptions passed to it were not necessarily fatal after Node.js implemented the process.on('uncaughtException')
escape hatch about 10 years ago.
The current node::errors::TriggerUncaughtException()
basically does two things:
- Grab
_fatalException()
from theprocess
object and invoke it. - If
process._fatalException()
returns true (e.g. when the user attaches auncaughtException
listener), continue execution, otherwise print the exception to the stderr, then exit the current Node.js instance (either the worker thread or the main thread) withprocess.exitCode
(or 1 ifprocess.exitCode
is not set).
process._fatalException()
has grown quite a bit over the years, but its primary purpose is still emitting uncaughtException
events to the users.
Just like many other properties prefixed with underscores, process._fatalException()
is an implementation detail that has been accidentally exposed to users - it probably should be deprecated at this point.
Where are these uncaught exceptions coming from?
At the moment, there are several places where node::errors::TriggerUncaughtException()
can be invoked.
1. The per-isolate message listener
In Node.js there is only one per-isolate message listener added through isolate->AddMessageListenerWithErrorLevel()
for each V8 isolate (each Node.js instance, either the main instance or a worker instance, is associated with one V8 isolate). When there is a JavaScript exception thrown (either by users or by Node.js) in the isolate without being caught, V8 triggers this C++ land listener, and we’ll pass it into node::errors::TriggerUncaughtException()
to give users a chance to handle it in the global escape hatch.
2. C++ internals
There are several places in C++ land where node::errors::TriggerUncaughtException()
are invoked directly. In Node.js asynchronous callbacks are usually invoked by C++ when certain asynchronous operations are completed. These callbacks are not supposed to throw exceptions themselves - instead they are supposed to pass exceptions down to user-provided callbacks. When they do throw unexpected exceptions (e.g. when there is a bug), these exceptions are caught by the C++ caller, which pass them down to node::errors::TriggerUncaughtException()
so they can be handled promptly. It usually looks like this:
1 | TryCatchScope try_catch(env); |
Note that this v8::TryCatch
must not be verbose - if it’s verbose, V8 is going to invoke the per-isolate message listener with that exception as well then we’ll end up handling the same exception twice.
3. JavaScript internals
It’s also possible to trigger node::errors::TriggerUncaughtException()
from JavaScript internals of Node.js using internalBinding('errors').triggerUncaughtException()
- which is basically just an internal binding for node::errors::TriggerUncaughtException()
. This is used to implement e.g. --unhandled-rejection=strict
, which allows you to catch unhandled promise rejections in the uncaughtException
event.
In case you didn’t realize - the task queues and the error handling routines in Node.js are mostly implemented in JavaScript, which is why we need to make this routine available in the internal JavaScript land.
Printing exceptions to stderr with extra information
If you run a file named throw.js
that looks like this:
1 | function run(er) { |
When the embedder does not attach a message listener, by default V8 will print the uncaught exception to stdout with something like this:
1 | /path/to/file.js:2: Uncaught Error: test |
It’s Node.js’s job to reformat the stack trace and print something more informative to users, like this:
1 | /path/to/file.js:2 |
How Node.js enhances the stack trace of a fatal exception
The enhanced stack trace demonstrated above consists of at least 2 sections, and 2 optional sections:
1. The source code where the throw
happens.
1 | /path/to/file.js:2 |
This is obtained via v8::Message::GetSourceLine()
, while the location of the arrow ^
is computed with various offset gathered from the V8 API.
2. error.stack
By default error.stack
consists of
-
The result of
Error.prototype.toString()
called on the exception, which returnserror.name
anderror.message
joint by a comma according to the ECMAScript specification:1
Error: test
-
The stack trace of the place where the exception is created (note that this is different from where the
throw
happens).1
2
3
4
5
6
7at Object.<anonymous> (/path/to/file.js:5:11)
at Module._compile (internal/modules/cjs/loader.js:936:30)
at Object.Module._extensions..js (internal/modules/cjs/loader.js:947:10)
at Module.load (internal/modules/cjs/loader.js:789:32)
at Function.Module._load (internal/modules/cjs/loader.js:702:12)
at Function.Module.runMain (internal/modules/cjs/loader.js:999:10)
at internal/main/run_main_module.js:17:11
Arguably, the internal frames below /path/to/throw.js
are not quite helpful and probably should be hidden. The counter argument in Node.js core is that bug reports submitted by users less helpful if these internal frames are hidden by default. Node.js currently uses a dimmer color to print these internal frames when the stderr is a TTY, and that reduces the visual overhead of these internal frames to a certain extent.
Node.js also does several tricks to enhance stack traces for Error
objects created internally. For instance, you can see error.code
attached to the name of a Node.js Error
(e.g. one created with Buffer.from(1)
) in the first line of the stack trace:
1 | TypeError [ERR_INVALID_ARG_TYPE]: The first argument must be one of type string, Buffer, ArrayBuffer, Array, or Array-like Object. Received type number |
This is done by:
- Appending the code
[ERR_INVALID_ARG_TYPE]
to the theerror.name
property so that it appears in the message line of the stack trace. - Saving the
error.stack
(in v8error.stack
is generated lazily and is persisted once accessed) - Restoring the
error.name
toTypeError
.
Node.js used to leave the code in the error.name
but this behavior was changed to match the error names on the Web and to allow users make use of cleaner names.
The format of error.stack
can be altered by users with the global Error.prepareStackTrace
hook, which is an V8 API that is now polyfilled by Node.js with some unfortunate quirks maintained. Since Node.js does the enhancement after error.stack
is generated, be careful that it may not play along with a user-defined Error.prepareStackTrace
.
3. (optional) Additional properties on the Error
object
When there are additional enumerable properties on the Error
object, like error.code
, they will be displayed using a format similar to the output of util.inspect()
:
1 | { |
In fact, 2 and 3 are both part of the output of util.inspect(error)
, because that’s what Node.js uses to print the exceptions.
4. (optional) enhancements for exceptions emitted on EventEmitter
s
If the exception is fatal because it is emitted on a EventEmitter
instance but not handled, Node.js now also displays where it is emitted. For example when you run a snippet like this:
1 | const EventEmitter = require('events'); |
Node.js would exit with something like this printed to the stderr:
1 | events.js:177 |
The first stack trace tells you where the exception is created, while the second stack tells you where the exception is emitted. The second stack trace is saved during the call of emitter.emit('error', err)
, and displayed when Node.js finds out that the fatal exception comes with some saved information - this comes with a bit of overhead, of course, but it is quite useful when debugging this kinds of exceptions.
Notifying the inspector
Other than printing exceptions to stderr, Node.js also needs to inform the V8 inspector about these exceptions by calling v8_inspector::V8Inspector::exceptionThrown()
- by default this causes the inspector to log the exceptions in the console.
The aforementioned enhancements are actually split into two stages based on when the inspector is notified. For instance, we only add ANSI color codes to the stack traces after the inspector displays them, since the inspector client (e.g. Chrome DevTools) can stylize the exception differently and they don’t necessarily recognize the color codes.
Summary
The current exception handling routine in Node.js comes from the hard work of many people. Not every bit of it is the best choice in hindsight and it is still constantly revolving, but looking into the code behind it (and it’s surprisingly a lot of code) made me appreciate the work from Node.js contributors over the years even more.