Skip to content

AsyncLocalStorage: real async-context tracking across await / microtasks / timers #788

@TheHypnoo

Description

@TheHypnoo

Background

#775 / #787 landed a compile-time shim note for node:async_hooks, mirroring the reflect-metadata 2a mitigation. That stops the silent-failure surface from growing, but the structural stub still does not track async context — AsyncLocalStorage.run() does not propagate across await / setImmediate / process.nextTick / Promise microtasks. Anything relying on context propagation (Sentry request scopes, OpenTelemetry trace propagation, NestJS request-scoped providers, pino child loggers) still silently loses context.

This issue tracks the real fix.

Design surface to nail down before writing code

  1. Context storage. Per-thread stack in perry-runtime (one entry per active .run() frame), snapshotted by value when we cross an async boundary. tokio::task_local! can layer on top later if needed.
  2. await propagation. Hook into the async-function state-machine lowering (perry-transform / perry-codegen) to capture context on suspend and restore on resume.
  3. Microtask / timer / immediate scheduling. .then / .catch / .finally (in perry-runtime/src/promise.rs), js_promise_run_microtasks(), setTimeout / setImmediate / process.nextTick: capture context at schedule time, restore at execute time.
  4. AsyncResource.runInAsyncScope / .bind. Thin wrappers around the per-thread stack.
  5. Threads (perry/thread). Node does not propagate ALS into workers — default to not serializing context across spawn / parallelMap / parallelFilter.
  6. createHook lifecycle. Out of scope for v1 (full init/before/after/destroy is a separate effort — tracked in its own follow-up issue).

Acceptance tests

  • als.run(store, async () => { await x(); als.getStore(); })
  • als.run(store, () => setTimeout(() => als.getStore(), 0))
  • als.run(store, () => setImmediate(() => als.getStore()))
  • als.run(store, () => process.nextTick(() => als.getStore()))
  • als.run(store, () => Promise.resolve().then(() => als.getStore()))
  • Nested als.run(a, () => als.run(b, () => ...)) (inner sees b, outer keeps a)
  • Two independent ALS instances coexisting on the same call stack
  • Context does NOT cross into perry/thread workers (Node-compatible behavior)
  • Drop the compile-time [perry] note: from fix: #775 — emit shim note when async_hooks is imported #787 once the above pass

Out of scope

  • createHook / executionAsyncId returning real (non-stub) IDs — tracked separately.
  • AsyncResource subclassing edge cases beyond runInAsyncScope / bind — depends on real createHook, tracked with it.

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