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..0590896586077e --- /dev/null +++ b/doc/api/promise_hooks.md @@ -0,0 +1,181 @@ +# Promise hooks + + + +> Stability: 1 - Experimental + + + +The `promiseHooks` API is part of the `async_hooks` module: + +```mjs +import { promiseHooks } from 'async_hooks'; +``` + +```cjs +const { promiseHooks } = require('async_hooks'); +``` + +## Overview + +Following is a simple overview of the public API. + +```mjs +import { promiseHooks } from 'async_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 'async_hooks'; + +const stopAll = promiseHooks.createHook({ + init(promise, parent) {} +}); +``` + +```cjs +const { promiseHooks } = require('async_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. + +#### `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]: #promisehooks_hook_callbacks +[`after` callback]: #promisehooks_after_promise +[`before` callback]: #promisehooks_before_promise +[`resolve` callback]: #promisehooks_resolve_promise +[`init` callback]: #promisehooks_init_promise_parent diff --git a/lib/async_hooks.js b/lib/async_hooks.js index 13c32065fc9188..03bff2a3e2afa7 100644 --- a/lib/async_hooks.js +++ b/lib/async_hooks.js @@ -23,6 +23,7 @@ const { validateString, } = require('internal/validators'); const internal_async_hooks = require('internal/async_hooks'); +const promiseHooks = require('internal/promise_hook'); // Get functions // For userland AsyncResources, make sure to emit a destroy event when the @@ -348,6 +349,7 @@ module.exports = { executionAsyncId, triggerAsyncId, executionAsyncResource, + promiseHooks, // Embedder API AsyncResource, }; diff --git a/lib/internal/async_hooks.js b/lib/internal/async_hooks.js index a6d258cf25757a..fb06b9abc16fbc 100644 --- a/lib/internal/async_hooks.js +++ b/lib/internal/async_hooks.js @@ -8,6 +8,8 @@ const { Symbol, } = primordials; +const PromiseHooks = require('internal/promise_hook'); + 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/internal/promise_hook.js b/lib/internal/promise_hook.js new file mode 100644 index 00000000000000..a2d2829ab66eb4 --- /dev/null +++ b/lib/internal/promise_hook.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 0ca00f31adce8c..94dadd8a5ca2ca 100644 --- a/test/parallel/test-bootstrap-modules.js +++ b/test/parallel/test-bootstrap-modules.js @@ -96,6 +96,7 @@ const expectedModules = new Set([ 'NativeModule internal/process/signal', 'NativeModule internal/process/task_queues', 'NativeModule internal/process/warning', + 'NativeModule internal/promise_hook', 'NativeModule internal/querystring', 'NativeModule internal/source_map/source_map_cache', 'NativeModule internal/stream_base_commons', 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..e2d31e5a4531f0 --- /dev/null +++ b/test/parallel/test-promise-hook-create-hook.js @@ -0,0 +1,55 @@ +'use strict'; +// Flags: --expose-internals +const common = require('../common'); +const assert = require('assert'); +const { createHook } = require('internal/promise_hook'); + +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) +}); + +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); +assertState(parent, undefined, undefined, undefined, parent); + +const child = parent.then(() => { + assertState(undefined, undefined, child, undefined, undefined); +}); +assertState(child, parent); + +const grandChild = child.then(() => { + assertState(undefined, undefined, grandChild, child, child); + stop(); +}); +assertState(grandChild, child); 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..f5d33035299ef6 --- /dev/null +++ b/test/parallel/test-promise-hook-on-after.js @@ -0,0 +1,22 @@ +'use strict'; +// Flags: --expose-internals +const common = require('../common'); +const assert = require('assert'); +const { onAfter } = require('internal/promise_hook'); + +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..2b3a8bb1615951 --- /dev/null +++ b/test/parallel/test-promise-hook-on-before.js @@ -0,0 +1,18 @@ +'use strict'; +// Flags: --expose-internals +const common = require('../common'); +const assert = require('assert'); +const { onBefore } = require('internal/promise_hook'); + +let seen; + +const stop = onBefore(common.mustCall((promise) => { + seen = promise; +}, 1)); + +const promise = Promise.resolve().then(() => { + assert.strictEqual(seen, promise); + stop(); +}).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..5cca886610aa9b --- /dev/null +++ b/test/parallel/test-promise-hook-on-init.js @@ -0,0 +1,30 @@ +'use strict'; +// Flags: --expose-internals +const common = require('../common'); +const assert = require('assert'); +const { onInit } = require('internal/promise_hook'); + +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..4c13d1f1fecdba --- /dev/null +++ b/test/parallel/test-promise-hook-on-resolve.js @@ -0,0 +1,52 @@ +'use strict'; +// Flags: --expose-internals +const common = require('../common'); +const assert = require('assert'); +const { onResolve } = require('internal/promise_hook'); + +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(() => {}); +}