diff --git a/doc/api/index.md b/doc/api/index.md index 448f6d599fc8f5..19cdbd9f838c03 100644 --- a/doc/api/index.md +++ b/doc/api/index.md @@ -46,6 +46,7 @@ * [Performance hooks](perf_hooks.md) * [Policies](policy.md) * [Process](process.md) +* [PromiseHooks](promise_hooks.md) * [Punycode](punycode.md) * [Query strings](querystring.md) * [Readline](readline.md) diff --git a/doc/api/promise_hooks.md b/doc/api/promise_hooks.md new file mode 100644 index 00000000000000..35ead0a8d46796 --- /dev/null +++ b/doc/api/promise_hooks.md @@ -0,0 +1,196 @@ +# Promise hooks + + + +> Stability: 1 - Experimental + + + +The `promise_hooks` module provides an API to track promise lifecycle events. +To track _all_ async activity, see [`async_hooks`][] which internally uses this +module to produce promise lifecycle events in addition to events for other +async resources. For request context management, see [`AsyncLocalStorage`][]. + +It can be accessed using: + +```mjs +import promiseHooks from 'promise_hooks'; +``` + +```cjs +const promiseHooks = require('promise_hooks'); +``` + +## Overview + +Following is a simple overview of the public API. + +```mjs +import promiseHooks from 'promise_hooks'; + +// There are four lifecycle events produced by promises: + +// The `init` event represents the creation of a promise. This could be a +// direct creation such as with `new Promise(...)` or a continuation such +// as `then()` or `catch()`. It also happens whenever an async function is +// called or does an `await`. If a continuation promise is created, the +// `parent` will be the promise it is a continuation from. +function init(promise, parent) { + console.log('a promise was created', { promise, parent }); +} + +// The `resolve` event happens when a promise receives a resolution or +// rejection value. This may happen synchronously such as when using +// `Promise.resolve()` on non-promise input. +function resolve(promise) { + console.log('a promise resolved or rejected', { promise }); +} + +// The `before` event runs immediately before a `then()` handler runs or +// an `await` resumes execution. +function before(promise) { + console.log('a promise is about to call a then handler', { promise }); +} + +// The `after` event runs immediately after a `then()` handler runs or when +// an `await` begins after resuming from another. +function after(promise) { + console.log('a promise is done calling a then handler', { promise }); +} + +// Lifecycle hooks may be started and stopped individually +const stopWatchingInits = promiseHooks.onInit(init); +const stopWatchingResolves = promiseHooks.onResolve(resolve); +const stopWatchingBefores = promiseHooks.onBefore(before); +const stopWatchingAfters = promiseHooks.onAfter(after); + +// Or they may be started and stopped in groups +const stopAll = promiseHooks.createHook({ + init, + resolve, + before, + after +}); + +// To stop a hook, call the function returned at its creation. +stopWatchingInits(); +stopWatchingResolves(); +stopWatchingBefores(); +stopWatchingAfters(); +stopAll(); +``` + +## `promiseHooks.createHook(callbacks)` + +* `callbacks` {Object} The [Hook Callbacks][] to register + * `init` {Function} The [`init` callback][]. + * `before` {Function} The [`before` callback][]. + * `after` {Function} The [`after` callback][]. + * `resolve` {Function} The [`resolve` callback][]. +* Returns: {Function} Used for disabling hooks + +Registers functions to be called for different lifetime events of each promise. + +The callbacks `init()`/`before()`/`after()`/`resolve()` are called for the +respective events during a promise's lifetime. + +All callbacks are optional. For example, if only promise creation needs to +be tracked, then only the `init` callback needs to be passed. The +specifics of all functions that can be passed to `callbacks` is in the +[Hook Callbacks][] section. + +```mjs +import promiseHooks from 'promise_hooks'; + +const stopAll = promiseHooks.createHook({ + init(promise, parent) {} +}); +``` + +```cjs +const promiseHooks = require('promise_hooks'); + +const stopAll = promiseHooks.createHook({ + init(promise, parent) {} +}); +``` + +### Hook callbacks + +Key events in the lifetime of a promise have been categorized into four areas: +creation of a promise, before/after a continuation handler is called or around +an await, and when the promise resolves or rejects. + +While these hooks are similar to those of [`async_hooks`][] they lack a +`destroy` hook. Other types of async resources typically represent sockets or +file descriptors which have a distinct "closed" state to express the `destroy` +lifecycle event while promises remain usable for as long as code can still +reach them. Garbage collection tracking is used to make promises fit into the +`async_hooks` event model, however this tracking is very expensive and they may +not necessarily ever even be garbage collected. + +#### `init(promise, parent)` + +* `promise` {Promise} The promise being created. +* `parent` {Promise} The promise continued from, if applicable. + +Called when a promise is constructed. This _does not_ mean that corresponding +`before`/`after` events will occur, only that the possibility exists. This will +happen if a promise is created without ever getting a continuation. + +#### `before(promise)` + +* `promise` {Promise} + +Called before a promise continuation executes. This can be in the form of a +`then()` handler or an `await` resuming. + +The `before` callback will be called 0 to N times. The `before` callback +will typically be called 0 times if no continuation was ever made for the +promise. The `before` callback may be called many times in the case where +many continuations have been made from the same promise. + +#### `after(promise)` + +* `promise` {Promise} + +Called immediately after a promise continuation executes. This may be after a +`then()` handler or before an `await` after another `await`. + +#### `resolve(promise)` + +* `promise` {Promise} + +Called when the promise receives a resolution or rejection value. This may +occur synchronously in the case of `Promise.resolve()` or `Promise.reject()`. + +## `promiseHooks.onInit(init)` + +* `init` {Function} The [`init` callback][] to call when a promise is created. +* Returns: {Function} Call to stop the hook. + +## `promiseHooks.onResolve(resolve)` + +* `resolve` {Function} The [`resolve` callback][] to call when a promise + is resolved or rejected. +* Returns: {Function} Call to stop the hook. + +## `promiseHooks.onBefore(before)` + +* `before` {Function} The [`before` callback][] to call before a promise + continuation executes. +* Returns: {Function} Call to stop the hook. + +## `promiseHooks.onAfter(after)` + +* `after` {Function} The [`after` callback][] to call after a promise + continuation executes. +* Returns: {Function} Call to stop the hook. + +[Hook Callbacks]: #hook_callbacks +[`AsyncLocalStorage`]: async_context.md#async_context_class_asynclocalstorage +[`after` callback]: #promisehooks_after_promise +[`async_hooks`]: async_hooks.md#async_hooks_async_hooks +[`before` callback]: #promisehooks_before_promise +[`init` callback]: #promisehooks_init_promise_parent +[`resolve` callback]: #promisehooks_resolve_promise diff --git a/lib/internal/async_hooks.js b/lib/internal/async_hooks.js index a6d258cf25757a..1ce49b8e98cade 100644 --- a/lib/internal/async_hooks.js +++ b/lib/internal/async_hooks.js @@ -8,6 +8,8 @@ const { Symbol, } = primordials; +const PromiseHooks = require('promise_hooks'); + const async_wrap = internalBinding('async_wrap'); const { setCallbackTrampoline } = async_wrap; /* async_hook_fields is a Uint32Array wrapping the uint32_t array of @@ -52,7 +54,7 @@ const { clearAsyncIdStack, } = async_wrap; // For performance reasons, only track Promises when a hook is enabled. -const { enablePromiseHook, disablePromiseHook, setPromiseHooks } = async_wrap; +const { enablePromiseHook, disablePromiseHook } = async_wrap; // Properties in active_hooks are used to keep track of the set of hooks being // executed in case another hook is enabled/disabled. The new set of hooks is // then restored once the active set of hooks is finished executing. @@ -353,19 +355,20 @@ function enableHooks() { async_hook_fields[kCheck] += 1; } +let stopPromiseHook; function updatePromiseHookMode() { wantPromiseHook = true; + if (stopPromiseHook) stopPromiseHook(); if (destroyHooksExist()) { enablePromiseHook(); - setPromiseHooks(undefined, undefined, undefined, undefined); } else { disablePromiseHook(); - setPromiseHooks( - initHooksExist() ? promiseInitHook : undefined, - promiseBeforeHook, - promiseAfterHook, - promiseResolveHooksExist() ? promiseResolveHook : undefined, - ); + stopPromiseHook = PromiseHooks.createHook({ + init: initHooksExist() ? promiseInitHook : undefined, + before: promiseBeforeHook, + after: promiseAfterHook, + resolve: promiseResolveHooksExist() ? promiseResolveHook : undefined + }); } } @@ -382,7 +385,9 @@ function disableHooks() { function disablePromiseHookIfNecessary() { if (!wantPromiseHook) { disablePromiseHook(); - setPromiseHooks(undefined, undefined, undefined, undefined); + if (stopPromiseHook) { + stopPromiseHook(); + } } } diff --git a/lib/promise_hooks.js b/lib/promise_hooks.js new file mode 100644 index 00000000000000..0711f33428328f --- /dev/null +++ b/lib/promise_hooks.js @@ -0,0 +1,97 @@ +'use strict'; + +const { + ArrayPrototypeIndexOf, + ArrayPrototypeSplice, + ArrayPrototypePush, + FunctionPrototypeBind +} = primordials; + +const { setPromiseHooks } = internalBinding('async_wrap'); + +const hooks = { + init: [], + before: [], + after: [], + resolve: [] +}; + +function initAll(promise, parent) { + for (const init of hooks.init) { + init(promise, parent); + } +} + +function beforeAll(promise) { + for (const before of hooks.before) { + before(promise); + } +} + +function afterAll(promise) { + for (const after of hooks.after) { + after(promise); + } +} + +function resolveAll(promise) { + for (const resolve of hooks.resolve) { + resolve(promise); + } +} + +function maybeFastPath(list, runAll) { + return list.length > 1 ? runAll : list[0]; +} + +function update() { + const init = maybeFastPath(hooks.init, initAll); + const before = maybeFastPath(hooks.before, beforeAll); + const after = maybeFastPath(hooks.after, afterAll); + const resolve = maybeFastPath(hooks.resolve, resolveAll); + setPromiseHooks(init, before, after, resolve); +} + +function stop(list, hook) { + const index = ArrayPrototypeIndexOf(list, hook); + if (index >= 0) { + ArrayPrototypeSplice(list, index, 1); + update(); + } +} + +function makeUseHook(list) { + return (hook) => { + ArrayPrototypePush(list, hook); + update(); + return FunctionPrototypeBind(stop, null, list, hook); + }; +} + +const onInit = makeUseHook(hooks.init); +const onBefore = makeUseHook(hooks.before); +const onAfter = makeUseHook(hooks.after); +const onResolve = makeUseHook(hooks.resolve); + +function createHook({ init, before, after, resolve } = {}) { + const hooks = []; + + if (init) ArrayPrototypePush(hooks, onInit(init)); + if (before) ArrayPrototypePush(hooks, onBefore(before)); + if (after) ArrayPrototypePush(hooks, onAfter(after)); + if (resolve) ArrayPrototypePush(hooks, onResolve(resolve)); + + return () => { + for (const stop of hooks) { + stop(); + } + }; +} + +module.exports = { + createHook, + onInit, + onBefore, + onAfter, + onResolve +}; diff --git a/test/parallel/test-bootstrap-modules.js b/test/parallel/test-bootstrap-modules.js index d02f0c71860554..34ad596150e92d 100644 --- a/test/parallel/test-bootstrap-modules.js +++ b/test/parallel/test-bootstrap-modules.js @@ -134,6 +134,7 @@ const expectedModules = new Set([ 'NativeModule async_hooks', 'NativeModule path', 'NativeModule perf_hooks', + 'NativeModule promise_hooks', 'NativeModule querystring', 'NativeModule stream', 'NativeModule stream/promises', diff --git a/test/parallel/test-promise-hook-create-hook.js b/test/parallel/test-promise-hook-create-hook.js new file mode 100644 index 00000000000000..4bc23a2e83193a --- /dev/null +++ b/test/parallel/test-promise-hook-create-hook.js @@ -0,0 +1,68 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const { createHook } = require('promise_hooks'); + +let init; +let initParent; +let before; +let after; +let resolve; + +const stop = createHook({ + init: common.mustCall((promise, parent) => { + init = promise; + initParent = parent; + }, 3), + before: common.mustCall((promise) => { + before = promise; + }, 2), + after: common.mustCall((promise) => { + after = promise; + }, 1), + resolve: common.mustCall((promise) => { + resolve = promise; + }, 2) +}); + +// Clears state on each check so only the delta needs to be checked. +function assertState(expectedInit, expectedInitParent, expectedBefore, + expectedAfter, expectedResolve) { + assert.strictEqual(init, expectedInit); + assert.strictEqual(initParent, expectedInitParent); + assert.strictEqual(before, expectedBefore); + assert.strictEqual(after, expectedAfter); + assert.strictEqual(resolve, expectedResolve); + init = undefined; + initParent = undefined; + before = undefined; + after = undefined; + resolve = undefined; +} + +const parent = Promise.resolve(1); +// After calling `Promise.resolve(...)`, the returned promise should have +// produced an init event with no parent and a resolve event. +assertState(parent, undefined, undefined, undefined, parent); + +const child = parent.then(() => { + // When a callback to `promise.then(...)` is called, the promise it resolves + // to should have produced a before event to mark the start of this callback. + assertState(undefined, undefined, child, undefined, undefined); +}); +// After calling `promise.then(...)`, the returned promise should have +// produced an init event with a parent of the promise the `then(...)` +// was called on. +assertState(child, parent); + +const grandChild = child.then(() => { + // Since the check for the before event in the `then(...)` call producing the + // `child` promise, there should have been both a before event for this + // promise but also resolve and after events for the `child` promise. + assertState(undefined, undefined, grandChild, child, child); + stop(); +}); +// After calling `promise.then(...)`, the returned promise should have +// produced an init event with a parent of the promise the `then(...)` +// was called on. +assertState(grandChild, child); diff --git a/test/parallel/test-promise-hook-exceptions.js b/test/parallel/test-promise-hook-exceptions.js new file mode 100644 index 00000000000000..573698cb06b8b1 --- /dev/null +++ b/test/parallel/test-promise-hook-exceptions.js @@ -0,0 +1,30 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const promiseHooks = require('promise_hooks'); + +const expected = []; + +function testHook(name) { + const hook = promiseHooks[name]; + const error = new Error(`${name} error`); + + const stop = hook(common.mustCall(() => { + throw error; + })); + + expected.push([ error, stop ]); +} + +process.on('uncaughtException', common.mustCall((received) => { + const [error, stop] = expected.shift(); + assert.strictEqual(received, error); + stop(); +}, 4)); + +testHook('onInit'); +testHook('onResolve'); +testHook('onBefore'); +testHook('onAfter'); + +Promise.resolve().then(() => {}); diff --git a/test/parallel/test-promise-hook-on-after.js b/test/parallel/test-promise-hook-on-after.js new file mode 100644 index 00000000000000..8ac967d3da7b8e --- /dev/null +++ b/test/parallel/test-promise-hook-on-after.js @@ -0,0 +1,21 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const { onAfter } = require('promise_hooks'); + +let seen; + +const stop = onAfter(common.mustCall((promise) => { + seen = promise; +}, 1)); + +const promise = Promise.resolve().then(() => { + assert.strictEqual(seen, undefined); +}); + +promise.then(() => { + assert.strictEqual(seen, promise); + stop(); +}); + +assert.strictEqual(seen, undefined); diff --git a/test/parallel/test-promise-hook-on-before.js b/test/parallel/test-promise-hook-on-before.js new file mode 100644 index 00000000000000..7fb7c6847a0b84 --- /dev/null +++ b/test/parallel/test-promise-hook-on-before.js @@ -0,0 +1,19 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const { onBefore } = require('promise_hooks'); + +let seen; + +const stop = onBefore(common.mustCall((promise) => { + seen = promise; +}, 1)); + +const promise = Promise.resolve().then(() => { + assert.strictEqual(seen, promise); + stop(); +}); + +promise.then(); + +assert.strictEqual(seen, undefined); diff --git a/test/parallel/test-promise-hook-on-init.js b/test/parallel/test-promise-hook-on-init.js new file mode 100644 index 00000000000000..abf13b7f21cb9c --- /dev/null +++ b/test/parallel/test-promise-hook-on-init.js @@ -0,0 +1,29 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const { onInit } = require('promise_hooks'); + +let seenPromise; +let seenParent; + +const stop = onInit(common.mustCall((promise, parent) => { + seenPromise = promise; + seenParent = parent; +}, 2)); + +const parent = Promise.resolve(); +assert.strictEqual(seenPromise, parent); +assert.strictEqual(seenParent, undefined); + +const child = parent.then(); +assert.strictEqual(seenPromise, child); +assert.strictEqual(seenParent, parent); + +seenPromise = undefined; +seenParent = undefined; + +stop(); + +Promise.resolve(); +assert.strictEqual(seenPromise, undefined); +assert.strictEqual(seenParent, undefined); diff --git a/test/parallel/test-promise-hook-on-resolve.js b/test/parallel/test-promise-hook-on-resolve.js new file mode 100644 index 00000000000000..7491161e4ab42d --- /dev/null +++ b/test/parallel/test-promise-hook-on-resolve.js @@ -0,0 +1,51 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const { onResolve } = require('promise_hooks'); + +let seen; + +const stop = onResolve(common.mustCall((promise) => { + seen = promise; +}, 4)); + +// Constructor resolve triggers hook +const promise = new Promise((resolve, reject) => { + assert.strictEqual(seen, undefined); + setImmediate(() => { + resolve(); + assert.strictEqual(seen, promise); + seen = undefined; + + constructorReject(); + }); +}); + +// Constructor reject triggers hook +function constructorReject() { + const promise = new Promise((resolve, reject) => { + assert.strictEqual(seen, undefined); + setImmediate(() => { + reject(); + assert.strictEqual(seen, promise); + seen = undefined; + + simpleResolveReject(); + }); + }); + promise.catch(() => {}); +} + +// Sync resolve/reject helpers trigger hook +function simpleResolveReject() { + const resolved = Promise.resolve(); + assert.strictEqual(seen, resolved); + seen = undefined; + + const rejected = Promise.reject(); + assert.strictEqual(seen, rejected); + seen = undefined; + + stop(); + rejected.catch(() => {}); +}