fix(ext/node): wire async_hooks promise lifecycle and triggerAsyncId#34247
fix(ext/node): wire async_hooks promise lifecycle and triggerAsyncId#34247divybot wants to merge 21 commits into
Conversation
Hook V8 promise hooks into async_hooks so `init`/`before`/`after`/ `promiseResolve` fire for promises and PROMISE async ids propagate parent->child via triggerAsyncId. Adds a parallel triggerAsyncId stack that pushes/pops with executionAsyncId so `async_hooks.triggerAsyncId()` and `AsyncResource.prototype.triggerAsyncId()` report the right value inside callbacks. `AsyncResource` now validates `type` (string, non-empty) and `triggerAsyncId` (safe integer) and throws Node's `ERR_INVALID_ARG_TYPE`, `ERR_ASYNC_TYPE`, and `ERR_INVALID_ASYNC_ID`. The constructor honors the `triggerAsyncId` option (and the positional shorthand) and stores it so `runInAsyncScope()` propagates the right pair onto the async-id stack. `AsyncLocalStorage.run()` now restores only this instance's variable slot when it returns, so an inner `enterWith()` on a *different* ALS persists through the outer `run()` exit (matches Node's per-variable scoping in test-async-local-storage-isolation.js). deno_core grows an `incPromiseHooksSuppressed` / `decPromiseHooksSuppressed` depth counter on `Deno.core`. The async-op wrapper (`setPromise`), `loadExtScript`, and `createLazyLoader` use it to skip user-visible async_hooks callbacks for the internal v8 Promise objects they create behind user code's back. Enables 9 previously-absent node compat tests across all platforms: - parallel/test-async-hooks-async-await.js - parallel/test-async-hooks-asyncresource-constructor.js - parallel/test-async-hooks-disable-during-promise.js - parallel/test-async-hooks-enable-disable-enable.js - parallel/test-async-hooks-promise-enable-disable.js - parallel/test-async-hooks-recursive-stack-runInAsyncScope.js - parallel/test-async-local-storage-isolation.js - parallel/test-async-wrap-promise-after-enabled.js - parallel/test-async-wrap-trigger-id.js Closes denoland/orchid#151 Co-Authored-By: Divy Srivastava <me@littledivy.com>
Registering every V8 Promise with a FinalizationRegistry to fire async_hooks `destroy` swamped V8's finalizer cleanup queue and pushed unrelated FinalizationRegistry callbacks (notably `AsyncResource`'s own destroy) past the small `setImmediate -> gc() -> setImmediate` window the GC-tracking tests in `tests/node_compat/runner/suite/test/common/gc.js` rely on. That regression caused `parallel/test-zlib-invalid-input-memory.js` and `parallel/test-net-connect-memleak.js` to fail on macOS-aarch64 / Linux aarch64 / macOS-x86_64 (the slow shards). Both tests use `onGC()` which wraps an `AsyncResource(NODE_TEST_COMMON_GC_TRACKER)` inside a WeakMap and asserts the AsyncResource's `destroy` fires within two setImmediates after a forced GC. Drop the promise destroy registry. Promise `destroy` events were best-effort in Node anyway -- user code that needs to know when a specific promise is collected should use a FinalizationRegistry directly. Init/before/after/promiseResolve remain wired up exactly as before. `tests/node_compat/runner/suite/test/parallel/test-async-hooks-destroy-on-gc.js` (already enabled) continues to pass because it only relies on `AsyncResource`'s destroy, which is still registered via the separate `asyncResourceRegistry`. Co-Authored-By: Divy Srivastava <me@littledivy.com>
# Conflicts: # tests/node_compat/config.jsonc
# Conflicts: # ext/node/polyfills/internal/async_hooks.ts
bartlomieju
left a comment
There was a problem hiding this comment.
Reviewed the async_hooks promise lifecycle + triggerAsyncId work. Nicely scoped and well-documented, and CI is fully green across all platforms. A few non-blocking observations, mostly around the cost of the V8 promise hook once installed. None are merge blockers.
1. trackPromise runs for every promise forever, even after all hooks are disabled (perf)
In ext/node/polyfills/internal/async_hooks.ts, once ensurePromiseHooks() installs the V8 hooks they're never removed: promiseHooksInstalled latches true, and core.setPromiseHooks (this PR is its only caller) is additive-only with no removal path. On top of that, promiseInitHook calls trackPromise (a newAsyncId() + WeakMap.set + object alloc) on every promise even when async_hook_fields[kInit] === 0 and no hooks are active.
So a program that does createHook(...).enable() then .disable() keeps paying per-promise overhead for the rest of its lifetime.
Suggestion: early-return in promiseInitHook when no hooks are active (e.g. gate on enabledHooksExist() / kTotals === 0). It should be safe because promiseBeforeHook/promiseAfterHook/promiseResolveHook already backfill via trackPromise(promise, null), so a hook enabled mid-flight stays balanced.
2. No uninstall path for the V8 promise hooks
Related to (1): disabling async_hooks leaves the V8 hooks resident since deno_core has no remove API. Worth a short TODO comment acknowledging this so it's not mistaken for an oversight.
3. Potential reentrancy
promiseInitHook -> emitInitNative -> user init() — if a user init callback allocates a promise, V8 fires promiseInitHook synchronously and re-enters. Fine for the tests enabled here, but Node guards against this; worth a note.
4. nit
The AsyncHook promiseResolve validation hunk is a pure reflow (condition wrapped across lines) with no behavior change — minor churn that could be dropped.
The AsyncLocalStorage.run() per-slot restore fix and the suppression-counter design both look correct to me. The acknowledged residual failures (Rust-created promises, PromiseWrap resource shape) are reasonable follow-up scope.
Address review feedback on the V8 promise hook integration: - promiseInitHook now early-returns when async_hook_fields[kTotals] === 0, skipping the per-promise newAsyncId()/WeakMap.set cost once every hook has been disabled. before/after/resolve still backfill via trackPromise(_, null) so a hook enabled mid-flight stays balanced. - Document that core.setPromiseHooks is additive-only (no uninstall path) and note the synchronous-reentrancy behavior of emitInitNative. - Drop an unnecessary multi-line reflow of the promiseResolve validation.
|
Thanks for the review @bartlomieju! Addressed the feedback in 97cf477:
Re-ran the config-enabled async-hooks node_compat suite locally — all pass (including |
# Conflicts: # ext/node/polyfills/async_hooks.ts
…ult (prefer-primordials)
…ooks deno_core wraps every module (including the CommonJS/ESM entrypoint) in an evaluation promise and attaches an internal `then2` reaction to it. When user code enables an `async_hooks` promise hook inside the main module body, those deno_core-internal promises (the `then2` throwaway and the synthetic-module evaluation result) were observed by the hook as spurious PROMISE init/before/after/resolve events. Node's CommonJS entrypoint has no such evaluation promise, so these events don't exist there. Bracket the internal `then2` (in `ModuleMap::mod_evaluate`) and the synthetic module evaluation resolver (in `synthetic_module_evaluation_steps`) with the async_hooks promise-hook suppression counter. The `incPromiseHooksSuppressed`/ `decPromiseHooksSuppressed` functions are captured into `ContextState` at init (while `Deno.core` is reachable) so they can be driven from Rust. Also stop `promiseResolveHook` from backfilling+emitting for promises that reach resolve while still untracked -- those are the module-evaluation result promises created before the hook was installed. Enables these previously-absent node_compat tests: - parallel/test-async-hooks-promise.js - parallel/test-async-hooks-promise-triggerid.js - parallel/test-async-hooks-correctly-switch-promise-hook.js - parallel/test-async-hooks-top-level-clearimmediate.js
# Conflicts: # libs/core/00_infra.js
…allback Decide async_hooks before/after participation at timer fire time instead of creation time, and deliver a promise reaction's trailing `after` even when its `before` ran before the hook was installed (enable-during / enable-before cases). Adds emitAfterNoPush for the no-matching-before path so the executionAsyncId stack stays balanced. Enables node_compat: - test-async-hooks-enable-before-promise-resolve.js - test-async-hooks-enable-during-promise.js
Summary
Implements
async_hookspromise lifecycle andtriggerAsyncIdtracking so that several Node.jsasync_hookscompat tests that were previously absent fromtests/node_compat/config.jsoncnow pass.Promise lifecycle via V8 promise hooks
When the first
createHook(...).enable()runs,core.setPromiseHooks(init, before, after, resolve)wires the four V8 callbacks intoasync_hooks.emitInit/emitBefore/emitAfter/emitPromiseResolve. Each promise gets an async id pair on first observation (parent's id from V8 when present, otherwise the currentexecutionAsyncId()). The top-level execution async id is1(Node'skRootAsyncId).triggerAsyncId()returns real valuesA parallel
triggerAsyncIdStackmirrorsexecutionAsyncIdStack; both are pushed/popped together inemitBefore/emitAfter.AsyncResourcecapturestriggerAsyncIdat construction and propagates it onrunInAsyncScope().AsyncResourceargument validationThrows
ERR_INVALID_ARG_TYPE,ERR_ASYNC_TYPE, andERR_INVALID_ASYNC_IDfor the constructor cases tested bytest-async-hooks-asyncresource-constructor.js.Hiding deno_core's module-evaluation promises
deno_core wraps every module (including the CJS/ESM entrypoint) in an evaluation promise and attaches an internal
then2reaction to it. Node's CommonJS entrypoint has no such evaluation promise, so when user code enables a promise hook inside the main module body, Node sees only the user's promises -- but Deno's user hook also observed the internalthen2throwaway promise and the synthetic-module evaluation result promise as spurious PROMISE events.This is fixed by bracketing those two internal promise-creation sites (
ModuleMap::mod_evaluate'sthen2andsynthetic_module_evaluation_steps's resolver) with the async_hooks promise-hook suppression counter. TheincPromiseHooksSuppressed/decPromiseHooksSuppressedfunctions are captured intoContextStateat init (whileDeno.coreis still reachable) so they can be driven from Rust.promiseResolveHookalso no longer backfills+emits for promises that reach resolve while still untracked (the module-evaluation result promise, created before the hook was installed).AsyncLocalStorage.run()restores only its own slotFixes
test-async-local-storage-isolation.js.Enabled tests
These were absent from
tests/node_compat/config.jsoncand now pass on Linux/macOS/Windows:parallel/test-async-hooks-async-await.jsparallel/test-async-hooks-asyncresource-constructor.jsparallel/test-async-hooks-correctly-switch-promise-hook.jsparallel/test-async-hooks-disable-during-promise.jsparallel/test-async-hooks-enable-disable-enable.jsparallel/test-async-hooks-promise.jsparallel/test-async-hooks-promise-enable-disable.jsparallel/test-async-hooks-promise-triggerid.jsparallel/test-async-hooks-recursive-stack-runInAsyncScope.jsparallel/test-async-hooks-top-level-clearimmediate.jsparallel/test-async-local-storage-isolation.jsparallel/test-async-wrap-promise-after-enabled.jsparallel/test-async-wrap-trigger-id.jsOut of scope (left absent)
The remaining
async_hookscandidates fail because they requireasync_hooksinit/before/after/destroyevents for non-promise async resources -- a separate subsystem from promise lifecycle:test-async-hooks-enable-before-promise-resolve.js,test-async-hooks-enable-during-promise.js-- needafter/destroyevents for timers andprocess.nextTickresources.test-async-hooks-enable-recursive.js-- Deno'sfs.accessis implemented asDeno.lstat().then(...), so it surfaces internal PROMISE inits where Node has a singleFSReQCALLBACKresource; needs an fs async-resource model.test-async-hooks-http-parser-destroy.js-- needs HTTP-parser async resources.test-async-hooks-fatal-error.js-- needsfs.promisesdestroyevents plus throw-in-hook fatal handling.internalBinding('async_wrap')/--expose-internals(e.g.test-async-wrap-getasyncid.js) stay absent.Test plan
cargo build --bin deno./tools/lint.jstest-async-hooks-*,test-async-local-storage-*,test-async-wrap-*node_compat tests pass (no regressions)unit_node::async_hooks_testpassesCloses denoland/divybot#151