Skip to content

async_hooks: real createHook lifecycle + asyncId tracking #789

@TheHypnoo

Description

@TheHypnoo

Background

Companion to #788 (AsyncLocalStorage real propagation). Once ALS lands, the remaining gap in node:async_hooks is the observability half of the API: createHook callbacks, real executionAsyncId() / triggerAsyncId(), and the full AsyncResource surface. These are what APM tools (Datadog, NewRelic, custom tracers) use to instrument every async resource in the process; ALS alone doesn't cover them.

This is a separate effort from #788 because the work is essentially disjoint: ALS needs a per-thread context stack at suspend/resume points; createHook needs a global async-resource registry with init/before/after/destroy callbacks fired at every resource creation and execution site. They share zero code beyond living in the same runtime.

Design surface

  1. AsyncId allocation. Monotonically increasing u64 per-process, assigned at resource creation time. Need a global counter (atomic) and a per-thread "current execution id" + "trigger id" pair maintained as a stack.

  2. Resource registry. Every async resource (Promise, timer handle, immediate handle, nextTick entry, I/O handle, user AsyncResource) gets an asyncId at construction. The registry maps asyncId → resource metadata (type string, triggerAsyncId, optional user resource ref). Lifecycle: alloc on construction, free on destroy callback.

  3. Hook registration. createHook({ init, before, after, destroy, promiseResolve }) returns an AsyncHook with .enable() / .disable(). Registered hooks live in a global list; enable/disable toggles per-hook bits. Must be cheap when zero hooks are enabled (the common case) — if (HOOKS_ACTIVE.load(Relaxed) == 0) skip at every callsite.

  4. Callback emission points. Every place that creates an async resource fires init(asyncId, type, triggerAsyncId, resource). Every place that executes a callback for that resource fires before(asyncId) → callback → after(asyncId). Promise resolution fires promiseResolve(asyncId). Destruction fires destroy(asyncId).

    • Concrete sites: promise.rs (Promise construction + resolution + .then chaining), js_promise_run_microtasks(), setTimeout / setImmediate / process.nextTick schedulers, AsyncResource constructor + runInAsyncScope / emitDestroy.
  5. executionAsyncId() / triggerAsyncId() real values. Per-thread "current execution id" updated on every before (push) / after (pop). triggerAsyncId is the execution id active at the moment the resource was created — captured at init time.

  6. AsyncResource full surface. asyncId(), triggerAsyncId(), emitDestroy(), bind(fn, type?) with the resource captured. Subclassing path: class MyRes extends AsyncResource works without extra runtime support once the constructor and methods are real.

  7. Eager destroy for settled / fired resources. Fire destroy when a Promise settles, a timer fires, an immediate runs, or AsyncResource.emitDestroy() is called. Simpler than GC-driven destroy and matches what most APM consumers actually observe.

  8. Performance. Every async-resource call site gets a check. The hot path when no hooks are enabled must be a single relaxed atomic load + branch. Behind a hook, the cost is unavoidable — that's what users opt into when they register a hook.

  9. GC-driven destroy for leaked resources. Resources that are never explicitly destroyed (Promises that never settle, lost timer handles, etc.) must still fire destroy when collected — otherwise APM "active handles" counts and leak detectors observably diverge from Node. Requires adding a finalization mechanism to the gen-GC in crates/perry-runtime/src/gc.rs: a per-allocation "has finalizer" bit, a finalizer queue drained after sweep, and a decision on order / resurrection semantics (Node's behavior: finalizers run on a separate turn, no resurrection). This is its own substantial subsystem and may warrant a sub-issue, but it must land before this issue closes — otherwise the [perry] note: from fix: #775 — emit shim note when async_hooks is imported #787 can't be retired.

Acceptance tests

  • createHook({ init }).enable() observes init callbacks for: new Promise, setTimeout, setImmediate, process.nextTick, new AsyncResource('Custom').
  • Each init callback receives a unique asyncId, the correct type string, and a triggerAsyncId matching the creating context.
  • before / after bracket every callback execution, with executionAsyncId() returning the resource's id inside the callback.
  • triggerAsyncId() inside a callback returns the id of the resource that scheduled it.
  • promiseResolve fires on Promise settlement.
  • destroy fires eagerly on Promise settle / timer fire / AsyncResource.emitDestroy().
  • destroy fires on GC for a Promise that is dropped without settling (leak path).
  • Multiple registered hooks fire in registration order.
  • .disable() stops a hook from firing without affecting others.
  • Zero-hook microbenchmark: overhead on a Promise-heavy workload stays within noise of the current stub.
  • Drop the compile-time [perry] note: from fix: #775 — emit shim note when async_hooks is imported #787 once AsyncLocalStorage: real async-context tracking across await / microtasks / timers #788 and this issue both land.

Out of scope

  • Legacy / deprecated paths from older Node versions: process.binding('async_wrap'), pre-stabilization Node 8 async_hooks shape, and other API forms superseded before LTS.

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions