Skip to content

Commit fbeb895

Browse files
Stephen Belangertargos
Stephen Belanger
authored andcommitted
v8: multi-tenant promise hook api
PR-URL: #39283 Reviewed-By: Gerhard Stöbich <[email protected]> Reviewed-By: Vladimir de Turckheim <[email protected]> Reviewed-By: Joyee Cheung <[email protected]> Reviewed-By: Benjamin Gruenbaum <[email protected]>
1 parent 5020f63 commit fbeb895

12 files changed

+676
-10
lines changed

doc/api/v8.md

+264
Original file line numberDiff line numberDiff line change
@@ -564,8 +564,267 @@ added: v8.0.0
564564
A subclass of [`Deserializer`][] corresponding to the format written by
565565
[`DefaultSerializer`][].
566566

567+
## Promise hooks
568+
569+
The `promiseHooks` interface can be used to track promise lifecycle events.
570+
To track _all_ async activity, see [`async_hooks`][] which internally uses this
571+
module to produce promise lifecycle events in addition to events for other
572+
async resources. For request context management, see [`AsyncLocalStorage`][].
573+
574+
```mjs
575+
import { promiseHooks } from 'v8';
576+
577+
// There are four lifecycle events produced by promises:
578+
579+
// The `init` event represents the creation of a promise. This could be a
580+
// direct creation such as with `new Promise(...)` or a continuation such
581+
// as `then()` or `catch()`. It also happens whenever an async function is
582+
// called or does an `await`. If a continuation promise is created, the
583+
// `parent` will be the promise it is a continuation from.
584+
function init(promise, parent) {
585+
console.log('a promise was created', { promise, parent });
586+
}
587+
588+
// The `settled` event happens when a promise receives a resolution or
589+
// rejection value. This may happen synchronously such as when using
590+
// `Promise.resolve()` on non-promise input.
591+
function settled(promise) {
592+
console.log('a promise resolved or rejected', { promise });
593+
}
594+
595+
// The `before` event runs immediately before a `then()` or `catch()` handler
596+
// runs or an `await` resumes execution.
597+
function before(promise) {
598+
console.log('a promise is about to call a then handler', { promise });
599+
}
600+
601+
// The `after` event runs immediately after a `then()` handler runs or when
602+
// an `await` begins after resuming from another.
603+
function after(promise) {
604+
console.log('a promise is done calling a then handler', { promise });
605+
}
606+
607+
// Lifecycle hooks may be started and stopped individually
608+
const stopWatchingInits = promiseHooks.onInit(init);
609+
const stopWatchingSettleds = promiseHooks.onSettled(settled);
610+
const stopWatchingBefores = promiseHooks.onBefore(before);
611+
const stopWatchingAfters = promiseHooks.onAfter(after);
612+
613+
// Or they may be started and stopped in groups
614+
const stopHookSet = promiseHooks.createHook({
615+
init,
616+
settled,
617+
before,
618+
after
619+
});
620+
621+
// To stop a hook, call the function returned at its creation.
622+
stopWatchingInits();
623+
stopWatchingSettleds();
624+
stopWatchingBefores();
625+
stopWatchingAfters();
626+
stopHookSet();
627+
```
628+
629+
### `promiseHooks.onInit(init)`
630+
<!-- YAML
631+
added: REPLACEME
632+
-->
633+
634+
* `init` {Function} The [`init` callback][] to call when a promise is created.
635+
* Returns: {Function} Call to stop the hook.
636+
637+
**The `init` hook must be a plain function. Providing an async function will
638+
throw as it would produce an infinite microtask loop.**
639+
640+
```mjs
641+
import { promiseHooks } from 'v8';
642+
643+
const stop = promiseHooks.onInit((promise, parent) => {});
644+
```
645+
646+
```cjs
647+
const { promiseHooks } = require('v8');
648+
649+
const stop = promiseHooks.onInit((promise, parent) => {});
650+
```
651+
652+
### `promiseHooks.onSettled(settled)`
653+
<!-- YAML
654+
added: REPLACEME
655+
-->
656+
657+
* `settled` {Function} The [`settled` callback][] to call when a promise
658+
is resolved or rejected.
659+
* Returns: {Function} Call to stop the hook.
660+
661+
**The `settled` hook must be a plain function. Providing an async function will
662+
throw as it would produce an infinite microtask loop.**
663+
664+
```mjs
665+
import { promiseHooks } from 'v8';
666+
667+
const stop = promiseHooks.onSettled((promise) => {});
668+
```
669+
670+
```cjs
671+
const { promiseHooks } = require('v8');
672+
673+
const stop = promiseHooks.onSettled((promise) => {});
674+
```
675+
676+
### `promiseHooks.onBefore(before)`
677+
<!-- YAML
678+
added: REPLACEME
679+
-->
680+
681+
* `before` {Function} The [`before` callback][] to call before a promise
682+
continuation executes.
683+
* Returns: {Function} Call to stop the hook.
684+
685+
**The `before` hook must be a plain function. Providing an async function will
686+
throw as it would produce an infinite microtask loop.**
687+
688+
```mjs
689+
import { promiseHooks } from 'v8';
690+
691+
const stop = promiseHooks.onBefore((promise) => {});
692+
```
693+
694+
```cjs
695+
const { promiseHooks } = require('v8');
696+
697+
const stop = promiseHooks.onBefore((promise) => {});
698+
```
699+
700+
### `promiseHooks.onAfter(after)`
701+
<!-- YAML
702+
added: REPLACEME
703+
-->
704+
705+
* `after` {Function} The [`after` callback][] to call after a promise
706+
continuation executes.
707+
* Returns: {Function} Call to stop the hook.
708+
709+
**The `after` hook must be a plain function. Providing an async function will
710+
throw as it would produce an infinite microtask loop.**
711+
712+
```mjs
713+
import { promiseHooks } from 'v8';
714+
715+
const stop = promiseHooks.onAfter((promise) => {});
716+
```
717+
718+
```cjs
719+
const { promiseHooks } = require('v8');
720+
721+
const stop = promiseHooks.onAfter((promise) => {});
722+
```
723+
724+
### `promiseHooks.createHook(callbacks)`
725+
<!-- YAML
726+
added: REPLACEME
727+
-->
728+
729+
* `callbacks` {Object} The [Hook Callbacks][] to register
730+
* `init` {Function} The [`init` callback][].
731+
* `before` {Function} The [`before` callback][].
732+
* `after` {Function} The [`after` callback][].
733+
* `settled` {Function} The [`settled` callback][].
734+
* Returns: {Function} Used for disabling hooks
735+
736+
**The hook callbacks must be plain functions. Providing async functions will
737+
throw as it would produce an infinite microtask loop.**
738+
739+
Registers functions to be called for different lifetime events of each promise.
740+
741+
The callbacks `init()`/`before()`/`after()`/`settled()` are called for the
742+
respective events during a promise's lifetime.
743+
744+
All callbacks are optional. For example, if only promise creation needs to
745+
be tracked, then only the `init` callback needs to be passed. The
746+
specifics of all functions that can be passed to `callbacks` is in the
747+
[Hook Callbacks][] section.
748+
749+
```mjs
750+
import { promiseHooks } from 'v8';
751+
752+
const stopAll = promiseHooks.createHook({
753+
init(promise, parent) {}
754+
});
755+
```
756+
757+
```cjs
758+
const { promiseHooks } = require('v8');
759+
760+
const stopAll = promiseHooks.createHook({
761+
init(promise, parent) {}
762+
});
763+
```
764+
765+
### Hook callbacks
766+
767+
Key events in the lifetime of a promise have been categorized into four areas:
768+
creation of a promise, before/after a continuation handler is called or around
769+
an await, and when the promise resolves or rejects.
770+
771+
While these hooks are similar to those of [`async_hooks`][] they lack a
772+
`destroy` hook. Other types of async resources typically represent sockets or
773+
file descriptors which have a distinct "closed" state to express the `destroy`
774+
lifecycle event while promises remain usable for as long as code can still
775+
reach them. Garbage collection tracking is used to make promises fit into the
776+
`async_hooks` event model, however this tracking is very expensive and they may
777+
not necessarily ever even be garbage collected.
778+
779+
Because promises are asynchronous resources whose lifecycle is tracked
780+
via the promise hooks mechanism, the `init()`, `before()`, `after()`, and
781+
`settled()` callbacks *must not* be async functions as they create more
782+
promises which would produce an infinite loop.
783+
784+
While this API is used to feed promise events into [`async_hooks`][], the
785+
ordering between the two is considered undefined. Both APIs are multi-tenant
786+
and therefore could produce events in any order relative to each other.
787+
788+
#### `init(promise, parent)`
789+
790+
* `promise` {Promise} The promise being created.
791+
* `parent` {Promise} The promise continued from, if applicable.
792+
793+
Called when a promise is constructed. This _does not_ mean that corresponding
794+
`before`/`after` events will occur, only that the possibility exists. This will
795+
happen if a promise is created without ever getting a continuation.
796+
797+
#### `before(promise)`
798+
799+
* `promise` {Promise}
800+
801+
Called before a promise continuation executes. This can be in the form of
802+
`then()`, `catch()`, or `finally()` handlers or an `await` resuming.
803+
804+
The `before` callback will be called 0 to N times. The `before` callback
805+
will typically be called 0 times if no continuation was ever made for the
806+
promise. The `before` callback may be called many times in the case where
807+
many continuations have been made from the same promise.
808+
809+
#### `after(promise)`
810+
811+
* `promise` {Promise}
812+
813+
Called immediately after a promise continuation executes. This may be after a
814+
`then()`, `catch()`, or `finally()` handler or before an `await` after another
815+
`await`.
816+
817+
#### `settled(promise)`
818+
819+
* `promise` {Promise}
820+
821+
Called when the promise receives a resolution or rejection value. This may
822+
occur synchronously in the case of `Promise.resolve()` or `Promise.reject()`.
823+
567824
[HTML structured clone algorithm]: https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm
825+
[Hook Callbacks]: #hook_callbacks
568826
[V8]: https://developers.google.com/v8/
827+
[`AsyncLocalStorage`]: async_context.md#class_asynclocalstorage
569828
[`Buffer`]: buffer.md
570829
[`DefaultDeserializer`]: #class-v8defaultdeserializer
571830
[`DefaultSerializer`]: #class-v8defaultserializer
@@ -575,15 +834,20 @@ A subclass of [`Deserializer`][] corresponding to the format written by
575834
[`GetHeapSpaceStatistics`]: https://v8docs.nodesource.com/node-13.2/d5/dda/classv8_1_1_isolate.html#ac673576f24fdc7a33378f8f57e1d13a4
576835
[`NODE_V8_COVERAGE`]: cli.md#node_v8_coveragedir
577836
[`Serializer`]: #class-v8serializer
837+
[`after` callback]: #after_promise
838+
[`async_hooks`]: async_hooks.md
839+
[`before` callback]: #before_promise
578840
[`buffer.constants.MAX_LENGTH`]: buffer.md#bufferconstantsmax_length
579841
[`deserializer._readHostObject()`]: #deserializer_readhostobject
580842
[`deserializer.transferArrayBuffer()`]: #deserializertransferarraybufferid-arraybuffer
843+
[`init` callback]: #init_promise_parent
581844
[`serialize()`]: #v8serializevalue
582845
[`serializer._getSharedArrayBufferId()`]: #serializer_getsharedarraybufferidsharedarraybuffer
583846
[`serializer._writeHostObject()`]: #serializer_writehostobjectobject
584847
[`serializer.releaseBuffer()`]: #serializerreleasebuffer
585848
[`serializer.transferArrayBuffer()`]: #serializertransferarraybufferid-arraybuffer
586849
[`serializer.writeRawBytes()`]: #serializerwriterawbytesbuffer
850+
[`settled` callback]: #settled_promise
587851
[`v8.stopCoverage()`]: #v8stopcoverage
588852
[`v8.takeCoverage()`]: #v8takecoverage
589853
[`vm.Script`]: vm.md#new-vmscriptcode-options

lib/internal/async_hooks.js

+12-10
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ const {
88
Symbol,
99
} = primordials;
1010

11+
const promiseHooks = require('internal/promise_hooks');
12+
1113
const async_wrap = internalBinding('async_wrap');
1214
const { setCallbackTrampoline } = async_wrap;
1315
/* async_hook_fields is a Uint32Array wrapping the uint32_t array of
@@ -51,8 +53,6 @@ const {
5153
executionAsyncResource: executionAsyncResource_,
5254
clearAsyncIdStack,
5355
} = async_wrap;
54-
// For performance reasons, only track Promises when a hook is enabled.
55-
const { setPromiseHooks } = async_wrap;
5656
// Properties in active_hooks are used to keep track of the set of hooks being
5757
// executed in case another hook is enabled/disabled. The new set of hooks is
5858
// then restored once the active set of hooks is finished executing.
@@ -374,6 +374,7 @@ function enableHooks() {
374374
async_hook_fields[kCheck] += 1;
375375
}
376376

377+
let stopPromiseHook;
377378
function updatePromiseHookMode() {
378379
wantPromiseHook = true;
379380
let initHook;
@@ -383,12 +384,13 @@ function updatePromiseHookMode() {
383384
} else if (destroyHooksExist()) {
384385
initHook = destroyTracking;
385386
}
386-
setPromiseHooks(
387-
initHook,
388-
promiseBeforeHook,
389-
promiseAfterHook,
390-
promiseResolveHooksExist() ? promiseResolveHook : undefined,
391-
);
387+
if (stopPromiseHook) stopPromiseHook();
388+
stopPromiseHook = promiseHooks.createHook({
389+
init: initHook,
390+
before: promiseBeforeHook,
391+
after: promiseAfterHook,
392+
settled: promiseResolveHooksExist() ? promiseResolveHook : undefined
393+
});
392394
}
393395

394396
function disableHooks() {
@@ -402,8 +404,8 @@ function disableHooks() {
402404
}
403405

404406
function disablePromiseHookIfNecessary() {
405-
if (!wantPromiseHook) {
406-
setPromiseHooks(undefined, undefined, undefined, undefined);
407+
if (!wantPromiseHook && stopPromiseHook) {
408+
stopPromiseHook();
407409
}
408410
}
409411

0 commit comments

Comments
 (0)