Skip to content

fix(ext/node): wire async_hooks promise lifecycle and triggerAsyncId#34247

Open
divybot wants to merge 21 commits into
mainfrom
orch/divybot-151
Open

fix(ext/node): wire async_hooks promise lifecycle and triggerAsyncId#34247
divybot wants to merge 21 commits into
mainfrom
orch/divybot-151

Conversation

@divybot

@divybot divybot commented May 19, 2026

Copy link
Copy Markdown
Contributor

Summary

Implements async_hooks promise lifecycle and triggerAsyncId tracking so that several Node.js async_hooks compat tests that were previously absent from tests/node_compat/config.jsonc now pass.

Promise lifecycle via V8 promise hooks

When the first createHook(...).enable() runs, core.setPromiseHooks(init, before, after, resolve) wires the four V8 callbacks into async_hooks.emitInit/emitBefore/emitAfter/emitPromiseResolve. Each promise gets an async id pair on first observation (parent's id from V8 when present, otherwise the current executionAsyncId()). The top-level execution async id is 1 (Node's kRootAsyncId).

triggerAsyncId() returns real values

A parallel triggerAsyncIdStack mirrors executionAsyncIdStack; both are pushed/popped together in emitBefore/emitAfter. AsyncResource captures triggerAsyncId at construction and propagates it on runInAsyncScope().

AsyncResource argument validation

Throws ERR_INVALID_ARG_TYPE, ERR_ASYNC_TYPE, and ERR_INVALID_ASYNC_ID for the constructor cases tested by test-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 then2 reaction 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 internal then2 throwaway 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's then2 and synthetic_module_evaluation_steps's resolver) with the async_hooks promise-hook suppression counter. The incPromiseHooksSuppressed/decPromiseHooksSuppressed functions are captured into ContextState at init (while Deno.core is still reachable) so they can be driven from Rust. promiseResolveHook also 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 slot

Fixes test-async-local-storage-isolation.js.

Enabled tests

These were absent from tests/node_compat/config.jsonc and now pass on Linux/macOS/Windows:

  • parallel/test-async-hooks-async-await.js
  • parallel/test-async-hooks-asyncresource-constructor.js
  • parallel/test-async-hooks-correctly-switch-promise-hook.js
  • parallel/test-async-hooks-disable-during-promise.js
  • parallel/test-async-hooks-enable-disable-enable.js
  • parallel/test-async-hooks-promise.js
  • parallel/test-async-hooks-promise-enable-disable.js
  • parallel/test-async-hooks-promise-triggerid.js
  • parallel/test-async-hooks-recursive-stack-runInAsyncScope.js
  • parallel/test-async-hooks-top-level-clearimmediate.js
  • parallel/test-async-local-storage-isolation.js
  • parallel/test-async-wrap-promise-after-enabled.js
  • parallel/test-async-wrap-trigger-id.js

Out of scope (left absent)

The remaining async_hooks candidates fail because they require async_hooks init/before/after/destroy events 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 -- need after/destroy events for timers and process.nextTick resources.
  • test-async-hooks-enable-recursive.js -- Deno's fs.access is implemented as Deno.lstat().then(...), so it surfaces internal PROMISE inits where Node has a single FSReQCALLBACK resource; needs an fs async-resource model.
  • test-async-hooks-http-parser-destroy.js -- needs HTTP-parser async resources.
  • test-async-hooks-fatal-error.js -- needs fs.promises destroy events plus throw-in-hook fatal handling.
  • Tests requiring internalBinding('async_wrap') / --expose-internals (e.g. test-async-wrap-getasyncid.js) stay absent.

Test plan

  • cargo build --bin deno
  • Lint clean: ./tools/lint.js
  • All in-config test-async-hooks-*, test-async-local-storage-*, test-async-wrap-* node_compat tests pass (no regressions)
  • unit_node::async_hooks_test passes
  • Worker / dynamic-import / CJS-require / TLA smoke tests unaffected

Closes denoland/divybot#151

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>
@divybot divybot changed the title node compat: fix async_hooks promise lifecycle gaps for absent tests feat(ext/node): wire async_hooks promise lifecycle and triggerAsyncId May 19, 2026
@littledivy littledivy changed the title feat(ext/node): wire async_hooks promise lifecycle and triggerAsyncId fix(ext/node): wire async_hooks promise lifecycle and triggerAsyncId May 20, 2026
littledivy and others added 9 commits May 20, 2026 02:18
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 bartlomieju left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@divybot

divybot commented Jun 5, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the review @bartlomieju! Addressed the feedback in 97cf477:

  1. Perf — trackPromise running forever. promiseInitHook now early-returns when async_hook_fields[kTotals] === 0, so once every hook is disabled we skip the newAsyncId() + WeakMap.set per promise. As you noted, this stays balanced because promiseBefore/After/ResolveHook backfill via trackPromise(promise, null) if a hook is enabled before the promise settles.
  2. No uninstall path. Added a TODO on ensurePromiseHooks documenting that core.setPromiseHooks is additive-only and that the kTotals === 0 fast path keeps the residual cost minimal until a removal API exists.
  3. Reentrancy. Added a note on promiseInitHook that emitInitNative invokes user init() synchronously and can re-enter; the enabled tests don't exercise it.
  4. nit. Reverted the promiseResolve validation reflow back to a single line.

Re-ran the config-enabled async-hooks node_compat suite locally — all pass (including test-async-wrap-promise-after-enabled which exercises the backfill path). The acknowledged residuals (triggerid / Rust-created promises) remain follow-up scope.

@divybot divybot closed this Jun 5, 2026
@divybot divybot reopened this Jun 5, 2026
divybot added 9 commits June 5, 2026 20:43
# Conflicts:
#	ext/node/polyfills/async_hooks.ts
…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
…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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants