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:

  1. Grab _fatalException() from the process object and invoke it.
  2. If process._fatalException() returns true (e.g. when the user attaches a uncaughtException 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) with process.exitCode (or 1 if process.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
2
3
4
5
6
7
8
TryCatchScope try_catch(env);
Local<Value> value;
if (!MakeCallback(env->on_some_event_string(), arraysize(argv), argv).ToLocal(&value)) {
if (try_catch.HasCaught() && !try_catch.HasTerminated()) {
errors::TriggerUncaughtException(env->isolate(), try_catch);
return;
}
}

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
2
3
4
5
6
7
function run(er) {
throw er; // the source line
}

const e = new Error('test');
e.data = 1;
run(e);

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
2
3
4
5
6
7
8
9
10
11
12
13
14
/path/to/file.js:2
throw er; // the source line
^

Error: test
at 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 {
data: 1
}

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
2
3
/path/to/file.js:2
throw er; // the source line
^

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

  1. The result of Error.prototype.toString() called on the exception, which returns error.name and error.message joint by a comma according to the ECMAScript specification:

    1
    Error: test
  2. 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
    7
    at 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:

  1. Appending the code [ERR_INVALID_ARG_TYPE] to the the error.name property so that it appears in the message line of the stack trace.
  2. Saving the error.stack (in v8 error.stack is generated lazily and is persisted once accessed)
  3. Restoring the error.name to TypeError.

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
2
3
{
data: 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 EventEmitters

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
2
3
const EventEmitter = require('events');
function run(er) { new EventEmitter().emit('error', er); }
run(new Error('test'));

Node.js would exit with something like this printed to the stderr:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
events.js:177
throw er; // Unhandled 'error' event
^

Error: test
at Object.<anonymous> (/path/to/file.js:3:5)
at Module._compile (internal/modules/cjs/loader.js:779:30)
at Object.Module._extensions..js (internal/modules/cjs/loader.js:790:10)
at Module.load (internal/modules/cjs/loader.js:642:32)
at Function.Module._load (internal/modules/cjs/loader.js:555:12)
at Function.Module.runMain (internal/modules/cjs/loader.js:842:10)
at internal/main/run_main_module.js:17:11
Emitted 'error' event at:
at run (/path/to/file.js:2:39)
at Object.<anonymous> (/path/to/file.js:3:1)
at Module._compile (internal/modules/cjs/loader.js:779:30)
[... lines matching original stack trace ...]
at internal/main/run_main_module.js:17:11

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.