async-hook-domain
Advanced tools
Comparing version 2.0.4 to 3.0.0
264
index.js
@@ -6,23 +6,49 @@ const { executionAsyncId, createHook } = require('async_hooks') | ||
// things are quite common. | ||
const proc = typeof process === 'object' && process ? process : /* istanbul ignore next */ { | ||
env: {}, | ||
emit: /* istanbul ignore next */ () => {}, | ||
once: /* istanbul ignore next */ () => {}, | ||
_fatalException: /* istanbul ignore next */ () => {}, | ||
} | ||
const proc = | ||
typeof process === 'object' && process | ||
? process | ||
: /* istanbul ignore next */ { | ||
_handler: null, | ||
env: {}, | ||
execArgv: [], | ||
hasUncaughtExceptionCaptureCallback: /* istanbul ignore next */ () => | ||
!!proc._handler, | ||
setUncaughtExceptionCaptureCallback: /* istanbul ignore next */ fn => | ||
(proc._handler = fn), | ||
once: /* istanbul ignore next */ (_ev, _fn) => proc, | ||
on: /* istanbul ignore next */ (_ev, _fn) => proc, | ||
removeListener: /* istanbul ignore next */ _fn => proc, | ||
} | ||
const debug = proc.env.ASYNC_HOOK_DOMAIN_DEBUG !== '1' ? () => {} | ||
: (() => { | ||
const {writeSync} = require('fs') | ||
const {format} = require('util') | ||
const debugAlways = (() => { | ||
const { writeSync } = require('fs') | ||
const { format } = require('util') | ||
return (...args) => writeSync(2, format(...args) + '\n') | ||
})() | ||
const debug = proc.env.ASYNC_HOOK_DOMAIN_DEBUG !== '1' ? () => {} : debugAlways | ||
const domains = new Map() | ||
// this is to work around the fact that node loses the executionAsyncId | ||
// when a Promise rejects within an async context, for some reason. | ||
// See: https://github.com/nodejs/node/issues/26794 | ||
let promiseExecutionId = null | ||
let activePromise = null | ||
// possible values here: | ||
// throw (default) | ||
// we let our rejection handler call the domain handler | ||
// none, warn-with-error-code | ||
// same as default | ||
// warn | ||
// same as default (no way to make it any less noisy, sadly) | ||
// strict | ||
// set the uncaughtExceptionMonitor, because it will throw, | ||
// but do NOT set our rejection handler, or it'll double-handle | ||
const unhandledRejectionMode = (() => { | ||
let mode = 'throw' | ||
for (let i = 0; i < proc.execArgv.length; i++) { | ||
const m = process.execArgv[i] | ||
if (m.startsWith('--unhandled-rejections=')) { | ||
mode = m.substring('--unhandled-rejections='.length) | ||
} else if (m === '--unhandled-rejections') { | ||
mode = proc.execArgv[i + 1] | ||
} | ||
} | ||
return mode | ||
})() | ||
@@ -36,4 +62,6 @@ // the async hook activation and deactivation | ||
domainHook.enable() | ||
proc.emit = domainProcessEmit | ||
proc._fatalException = domainProcessFatalException | ||
proc.on('uncaughtExceptionMonitor', domainErrorHandler) | ||
if (unhandledRejectionMode !== 'strict') { | ||
proc.emit = domainProcessEmit | ||
} | ||
} | ||
@@ -46,13 +74,74 @@ } | ||
domainHook = null | ||
proc.emit = realProcessEmit | ||
proc._fatalException = realProcessFatalException | ||
proc.removeListener('uncaughtExceptionMonitor', domainErrorHandler) | ||
proc.emit = originalProcessEmit | ||
} | ||
} | ||
// monkey patch to silently listen on unhandledRejection, without | ||
// marking the event as 'handled' unless we handled it. | ||
// Do nothing if there's a user handler for the event, though. | ||
const originalProcessEmit = proc.emit | ||
const domainProcessEmit = (ev, ...args) => { | ||
if ( | ||
ev !== 'unhandledRejection' || | ||
proc.listeners('unhandledRejection').length | ||
) { | ||
return originalProcessEmit.call(proc, ev, ...args) | ||
} | ||
const er = args[0] | ||
return domainErrorHandler(er, 'unhandledRejection', true) | ||
} | ||
const domainErrorHandler = (er, ev, rejectionHandler = false) => { | ||
debug('AHD MAYBE HANDLE?', ev, er) | ||
// if anything else attached a handler, then it's their problem, | ||
// not ours. get out of the way. | ||
if ( | ||
proc.hasUncaughtExceptionCaptureCallback() || | ||
proc.listeners('uncaughtException').length > 0 | ||
) { | ||
debug('OTHER HANDLER ALREADY SET') | ||
return false | ||
} | ||
const domain = currentDomain() | ||
if (domain) { | ||
debug('HAVE DOMAIN') | ||
try { | ||
domain.onerror(er, ev) | ||
} catch (e) { | ||
debug('ONERROR THREW', e) | ||
domain.destroy() | ||
// this is pretty bad. treat it as a fatal exception, which | ||
// may or may not be caught in the next domain up. | ||
// We drop 'from promise', because now it's a throw. | ||
if (domainErrorHandler(e)) { | ||
return true | ||
} | ||
throw e | ||
} | ||
// at this point, we presumably handled the error, and attach a | ||
// no-op one-time handler to just prevent the crash from happening. | ||
if (!rejectionHandler) { | ||
proc.setUncaughtExceptionCaptureCallback(() => { | ||
debug('UECC ONCE') | ||
proc.setUncaughtExceptionCaptureCallback(null) | ||
}) | ||
// in strict mode, node raises the error *before* the uR event, | ||
// and it warns if the uR event is not handled. | ||
if (unhandledRejectionMode === 'strict') { | ||
process.once('unhandledRejection', () => {}) | ||
} | ||
} | ||
return true | ||
} | ||
return false | ||
} | ||
// the hook callbacks | ||
const hookMethods = { | ||
init (id, type, triggerId, resource) { | ||
init(id, type, triggerId) { | ||
debug('INIT', id, type, triggerId) | ||
const current = domains.get(triggerId) | ||
if (current) { | ||
debug('INIT', id, type, current) | ||
debug('INIT have current', current) | ||
current.ids.add(id) | ||
@@ -64,122 +153,21 @@ domains.set(id, current) | ||
promiseResolve (id) { | ||
debug('PROMISE RESOLVE', id) | ||
promiseExecutionId = id | ||
}, | ||
destroy (id) { | ||
destroy(id) { | ||
const domain = domains.get(id) | ||
debug('DESTROY', id, domain && domain.ids) | ||
if (!domain) | ||
debug('DESTROY', id) | ||
if (!domain) { | ||
return | ||
} | ||
domains.delete(id) | ||
domain.ids.delete(id) | ||
if (!domain.ids.size) | ||
if (!domain.ids.size) { | ||
domain.destroy() | ||
} | ||
}, | ||
} | ||
// Dangerous monkey-patching ahead. | ||
// Errors can bubble up to the top level in one of two ways: | ||
// 1. thrown | ||
// 2. promise rejection | ||
// | ||
// Thrown errors are easy. They emit `uncaughtException`, and | ||
// are considered nonfatal if there are listeners that don't throw. | ||
// Managing an event listener is relatively straightforward, but we | ||
// need to recognize when the error ISN'T handled by a domain, and | ||
// make the error fatal, which is tricky but doable. | ||
// | ||
// Promise rejections are harder. They do one of four possible things, | ||
// depending on the --unhandled-rejections argument passed to node. | ||
// - throw: | ||
// - call process._fatalException(er) and THEN emits unhandledRejection | ||
// - emit unhandledRejection | ||
// - if no handlers, warn | ||
// - ignore: emit only | ||
// - always warn: emit event, then warn | ||
// - default: | ||
// - emit event | ||
// - if not handled, print warning and deprecation | ||
// | ||
// When we're ready to make a hard break with the domains builtin, and | ||
// drop support for everything below 12.11.0, it'd be good to do this with | ||
// a process.setUncaughtExceptionCaptureCallback(). However, the downside | ||
// there is that any module that does this has to be a singleton, which | ||
// feels overly pushy for a library like this. | ||
// | ||
// Also, there's been changes in how this all works between v8 and now. | ||
// | ||
// To cover all cases, we monkey-patch process._fatalException and .emit | ||
const currentDomain = () => domains.get(executionAsyncId()) | ||
const _handled = Symbol('handled by async-hook-domain') | ||
const domainProcessEmit = (ev, ...args) => { | ||
if (ev === 'unhandledRejection') { | ||
debug('DOMAIN PROCESS EMIT', ev, ...args) | ||
const er = args[0] | ||
const p = args[1] | ||
// check to see if we have a domain | ||
const fromPromise = ev === 'unhandledRejection' | ||
const domain = currentDomain(fromPromise) | ||
if (domain) { | ||
debug('HAS DOMAIN', domain) | ||
if (promiseFatal) { | ||
// don't need to handle a second time when the event emits | ||
return realProcessEmit.call(proc, ev, ...args) || true | ||
} | ||
try { | ||
domain.onerror(er, ev) | ||
} catch (e) { | ||
domain.destroy() | ||
// this is pretty bad. treat it as a fatal exception, which | ||
// may or may not be caught in the next domain up. | ||
// We drop 'from promise', because now it's a throw. | ||
return domainProcessFatalException(e) | ||
} | ||
return realProcessEmit.call(proc, ev, ...args) || true | ||
} | ||
} | ||
return realProcessEmit.call(proc, ev, ...args) | ||
} | ||
const currentDomain = fromPromise => | ||
domains.get(executionAsyncId()) || | ||
(fromPromise ? domains.get(promiseExecutionId) : null) | ||
const realProcessEmit = proc.emit | ||
let promiseFatal = false | ||
const domainProcessFatalException = (er, fromPromise) => { | ||
debug('_FATAL EXCEPTION', er, fromPromise) | ||
const domain = currentDomain(fromPromise) | ||
if (domain) { | ||
const ev = fromPromise ? 'unhandledRejection' : 'uncaughtException' | ||
// if it's from a promise, then that means --unhandled-rejection=strict | ||
// we don't need to handle it a second time. | ||
promiseFatal = promiseFatal || fromPromise | ||
try { | ||
domain.onerror(er, ev) | ||
} catch (e) { | ||
domain.destroy() | ||
return domainProcessFatalException(e) | ||
} | ||
// we add a handler just to ensure that node knows the event will | ||
// be handled. otherwise we get async hook stack corruption. | ||
if (promiseFatal) { | ||
// don't blow up our process on a promise if we handled it. | ||
return true | ||
} | ||
proc.once(ev, () => {}) | ||
// this ||true is just a safety guard. it should always be true. | ||
return realProcessFatalException.call(proc, er, fromPromise) || | ||
/* istanbul ignore next */ true | ||
} | ||
return realProcessFatalException.call(proc, er, fromPromise) | ||
} | ||
const realProcessFatalException = proc._fatalException | ||
let id = 1 | ||
class Domain { | ||
constructor (onerror) { | ||
constructor(onerror) { | ||
if (typeof onerror !== 'function') { | ||
@@ -192,13 +180,18 @@ // point at where the wrong thing was actually done | ||
const eid = executionAsyncId() | ||
this.eid = eid | ||
this.id = id++ | ||
this.ids = new Set([eid]) | ||
this.onerror = onerror | ||
this.parent = domains.get(executionAsyncId()) | ||
this.parent = domains.get(eid) | ||
this.destroyed = false | ||
domains.set(eid, this) | ||
debug('NEW DOMAIN', this.id, this.eid, this.ids) | ||
activateDomains() | ||
} | ||
destroy () { | ||
if (this.destroyed) | ||
destroy() { | ||
if (this.destroyed) { | ||
return | ||
} | ||
debug('DESTROY DOMAIN', this.id, this.eid, this.ids) | ||
this.destroyed = true | ||
@@ -222,4 +215,5 @@ // find the nearest non-destroyed parent, assign all ids to it | ||
this.ids = new Set() | ||
if (!domains.size) | ||
if (!domains.size) { | ||
deactivateDomains() | ||
} | ||
} | ||
@@ -226,0 +220,0 @@ } |
{ | ||
"name": "async-hook-domain", | ||
"version": "2.0.4", | ||
"version": "3.0.0", | ||
"description": "An implementation of Domain-like error handling, built on async_hooks", | ||
"main": "index.js", | ||
"directories": { | ||
"test": "test" | ||
}, | ||
"devDependencies": { | ||
"source-map-support": "^0.5.16", | ||
"tap": "^15.0.9" | ||
"@types/node": "^18.11.9", | ||
"eslint-config-prettier": "^8.5.0", | ||
"prettier": "^2.7.1", | ||
"source-map-support": "^0.5.21", | ||
"tap": "^16.3.1" | ||
}, | ||
"scripts": { | ||
"format": "prettier --write . --loglevel warn", | ||
"test": "tap test/fixtures", | ||
@@ -20,2 +21,17 @@ "snap": "tap test/fixtures", | ||
}, | ||
"eslintIgnore": [ | ||
"/node_modules", | ||
"/tap-snapshots" | ||
], | ||
"prettier": { | ||
"semi": false, | ||
"printWidth": 80, | ||
"tabWidth": 2, | ||
"useTabs": false, | ||
"singleQuote": true, | ||
"jsxSingleQuote": false, | ||
"bracketSameLine": true, | ||
"arrowParens": "avoid", | ||
"endOfLine": "lf" | ||
}, | ||
"tap": { | ||
@@ -52,4 +68,4 @@ "node-arg": [ | ||
"engines": { | ||
"node": ">=10" | ||
"node": ">=16" | ||
} | ||
} |
187
README.md
# async-hook-domain | ||
An implementation of the error-handling properties of the (deprecated) `domain` | ||
node core module, re-implemented on top of | ||
An implementation of the error-handling properties of the | ||
deprecated `domain` node core module, re-implemented on top of | ||
[`async_hooks`](https://nodejs.org/api/async_hooks.html). | ||
[![Linux Build](https://travis-ci.org/tapjs/async-hook-domain.svg?branch=master)](https://travis-ci.org/tapjs/async-hook-domain) | ||
## USAGE | ||
@@ -38,8 +36,6 @@ | ||
fs.readFile('does not exist', (er, data) => { | ||
if (er) | ||
throw er | ||
if (er) throw er | ||
}) | ||
fs.readFile('also does not exist', (er, data) => { | ||
if (er) | ||
throw er | ||
if (er) throw er | ||
}) | ||
@@ -76,8 +72,121 @@ }) | ||
If you want to limit a Domain to a narrower scope, you can use node's | ||
If you want to limit a Domain to a narrower scope, you can use | ||
node's | ||
[`AsyncResource`](https://nodejs.org/api/async_hooks.html#async_hooks_class_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. | ||
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. | ||
## Important `new Promise()` Construction Method Caveat | ||
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: | ||
```js | ||
const Domain = require('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: | ||
```js | ||
const Domain = require('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: | ||
```js | ||
const Domain = require('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. | ||
## API | ||
@@ -87,29 +196,36 @@ | ||
Set the `ASYNC_HOOK_DOMAIN_DEBUG` environment variable to `'1'` to print a lot | ||
of debugging information to stderr. | ||
Set the `ASYNC_HOOK_DOMAIN_DEBUG` environment variable to `'1'` | ||
to print a lot of debugging information to stderr. | ||
### const d = new Domain(errorHandlerFunction(error, type)) | ||
Create a new Domain and assign it to the current execution context and all | ||
child contexts that the current one triggers. | ||
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. | ||
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. | ||
### d.parent Domain | ||
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. | ||
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. | ||
### d.onerror Function | ||
The `errorHandlerFunction` passed into the constructor. Called when an | ||
uncaughtException or unhandledRejection occurs in the scope of the Domain. | ||
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. | ||
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. | ||
@@ -122,12 +238,13 @@ ### d.destroyed Boolean | ||
A set of the `executionAsyncId` values corresponding to the execution contexts | ||
for which this Domain handles errors. | ||
A set of the `executionAsyncId` values corresponding to the | ||
execution contexts for which this Domain handles errors. | ||
### d.destroy() Function | ||
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. | ||
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. | ||
This is called implicitly when the domain's last covered | ||
execution context is destroyed, since at that point, the domain | ||
is unreachable anyway. |
Sorry, the diff of this file is not supported yet
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Found 1 instance in 1 package
17459
247
5
203
1