An implementation of the error-handling properties of the
deprecated domain
node core module, re-implemented on top of
async_hooks
.
// hybrid module, either works
import { Domain } from 'async-hook-domain'
// or: const { Domain } = require('async-hook-domain')
// instantiating a Domain attaches it to the current async execution
// context, and all child contexts that come off of it. You don't have
// to call d.enter() or d.run(cb), just instantiate and it's done.
// Pass an error-handling function to the constructor. This function
// will be called whenever there is an uncaught exception or an
// unhandled Promise rejection.
const d = new Domain(er => {
console.log('caught an error', er)
// if you re-throw, it's not going to be caught, and will probably
// cause the process to crash.
})
setTimeout(() => {
throw new Error('this is caught by the domain a few lines up')
})
process.nextTick(() => {
const d2 = new Domain(er => {
console.log('any contexts spawned from this nextTick are caught here', er)
// only catch one error. The next one will go to the parent
d2.destroy()
})
fs.readFile('does not exist', (er, data) => {
if (er) throw er
})
fs.readFile('also does not exist', (er, data) => {
if (er) throw er
})
})
// Adding a new domain here in the same context as the d above will
// take over for this current context, as well as any that are created
// from now on. But it won't affect the setTimeout above, because that
// async context was created before this domain existed.
const d3 = new Domain(er => console.log('d3', er))
// Unhandled promise rejections are handled, too.
Promise.reject(new Error('this will be handled by d3'))
// since a Promise rejection is an async hop, if we destroyed it right
// now, it would not be there to catch the Promise.reject event.
setTimeout(() => {
// destroying d3 makes it like it never happened, so this will
// be handled by the parent domain we created at the outset.
d3.destroy()
throw new Error('this will be handled by the parent')
})
// When all domains are destroyed either manually or by virtue of their
// async execution contexts being completed, or if no domain is active
// for the current execution context, then it reverts back to normal
// operation, with all event handlers removed and everything cleaned up.
setTimeout(() => {
d.destroy()
throw new Error('this crashes the process like normal')
}, 500) // time for the other contexts to wrap up
If you want to limit a Domain to a narrower scope, you can use
node's
AsyncResource
class, and instantiate the domain within its
runInAsyncScope(cb)
method. From then on, the domain will only
be active when running from that Async Resource's scope.
If you create a domain within a Promise
construction method,
then rejections of that promise will only be handled by the
domain that was active when the Promise constructor was
instantiated, and not the new domain you create within the
constructor.
This is because, even though the rejection happens later, and
any throws are deferred until that time, the Promise construction
method itself is run synchronously. So, the
executionAsyncId()
in that context is still the same as it was
when the Promise constructor was initiated.
For example:
import { Domain } from 'async-hook-domain'
const d1 = new Domain(() => console.log('handled by d1'))
new Promise((_, reject) => { // <-- Promise bound to d1 domain
// executionAsyncId identical to outside the Promise constructor
// domains created later have no effect, Promise already bound,
// as it was created at the instant of calling new Promise()
// this is actually a new domain handling any subsequent throws
// in the *parent* context! confusing!
const d2 = new Domain(() => console.log('handled by d2'))
// timeout created in d2's context, *sibling* of eventual
// promise resolution/rejection
setTimeout(() => {
// d3 created as child of d2, but nothing bound to it
// would handle any new async behaviors triggered by
// the setTimeout's async context
const d3 = new Domain(() => console.log('handled by d3'))
// rejection occurs in child context, triggered by
// execution context where new Promise was initiated.
reject(new Error('will be handled by d1!'))
})
})
Since Promise construction happens synchronously in the same
executionAsyncId()
contex as outside the function, domains
created within that context are as if they were created outside
of the Promise constructor, and will stack up for that context.
For example:
import { Domain } from 'async-hook-domain'
// executionAsyncId=1, domain added
const d1 = new Domain(() => console.log('handled by d1'))
new Promise((_, reject) => {
// still executionAsyncId=1, new child domain takes over
// this is the new active domain for executionAsyncId=1,
// even outside the Promise constructor!
const d2 = new Domain(() => console.log('handled by d2'))
// setTimeout creates new executionAsyncId=3, bound to d2
setTimeout(() => {
// executionAsyncId=3, d3 handling any errors in it
const d3 = new Domain(() => console.log('handled by d3'))
// resolve happens in executionAsyncId=2, the promise
// resolution context triggered by the new Promise call
resolve('this is fine')
})
})
// throw happens in executionAsyncId=1, current domain is d2!
throw new Error('will be handled by d2!')
Note that since a throw within a Promise
construction method is
treated as a call to reject()
, this also applies to thrown
errors within the construction method:
import { Domain } from 'async-hook-domain'
const d1 = new Domain(() => console.error('handled by d1'))
new Promise((_, reject) => {
const d2 = new Domain(() => console.error('handled by d2'))
throw 'this will be handled by d1, not d2!'
})
The execution context of the Promise itself is bound to the domain that was active at the time the Promise constructor started, so any rejection will be handled by that domain.
If this all sounds confusing and very deep in the weeds, a safe
approach is to never create a new Domain
within a Promise
construction function. Then everything will behave as you'd
expect.
I have explored the space here thoroughly, because this strikes me as counter-intuitive. As a user, I'd expect that a new domain created in a Promise constructor method would be a child of the domain that binds to the Promise resolution, and thus take over handling the subsequent Promise rejection, rather than binding to the context outside the Promise constructor.
But that isn't how it works, and as of version 19, Node.js and v8 do not provide adequate API surface to make it behave that way without making other behavior less reliable. A future SemVer-major change will address this caveat when and if it becomes possible to do so.
Set the ASYNC_HOOK_DOMAIN_DEBUG
environment variable to '1'
to print a lot of debugging information to stderr.
Create a new Domain and assign it to the current execution context and all child contexts that the current one triggers.
The handler function is called with two arguments. The first is
the error that was thrown or the rejection value of the rejected
Promise. The second is either 'uncaughtException'
or
'unhandledRejection'
, depending on the type of event that
raised the error.
Note that even if the Domain prevents the process from failing
entirely, Node.js may still print a warning about unhandled
rejections, depending on the --unhandled-rejections
option.
If a Domain is already assigned to the current context on
creation, then the current Domain set as the new Domain's
parent
. On destruction, any of a Domain's still-active
execution contexts are assigned to its parent.
The errorHandlerFunction
passed into the constructor. Called
when an uncaughtException or unhandledRejection occurs in the
scope of the Domain.
If this function throws, then the domain will be destroyed, and the thrown error will be raised. If the domain doesn't have a parent, then this will likely crash the process entirely.
Set to true
if the domain is destroyed.
A set of the executionAsyncId
values corresponding to the
execution contexts for which this Domain handles errors.
Call to destroy the domain. This removes it from the system entirely, assigning any outstanding ids to its parent, if it has one, or leaving them uncovered if not.
This is called implicitly when the domain's last covered execution context is destroyed, since at that point, the domain is unreachable anyway.