Skip to content

feat(async_hooks): Add AsyncLocalStorage and expand async resource tracking - #1789

Draft
nabetti1720 wants to merge 17 commits into
awslabs:mainfrom
nabetti1720:feat/async-local-storage
Draft

nabetti1720 wants to merge 17 commits into
awslabs:mainfrom
nabetti1720:feat/async-local-storage

Conversation

@nabetti1720

@nabetti1720 nabetti1720 commented Sep 20, 2026 •

Copy link
Copy Markdown
Contributor

Issue # (if available)

Closes #1170

Description of changes

NOTE: Given the immense scale and high level of complexity of this PR, we are fully leveraging AI (GPT-5.6 Luna). However, we are carefully directing the course of revisions and verifying their validity through human review whenever possible.

We are reviewing the functional placement regarding the asynchronous implementation, resulting in a large volume of changes associated with moving the code.

Summary

This change significantly expands LLRT’s async_hooks implementation and adds AsyncLocalStorage support.

It adds async lifecycle tracking for Promises, native resources, timers, DNS lookups, microtasks, and module loading while keeping tracking overhead low and making object ownership and cleanup explicit.

Background

The previous implementation had several limitations:

  • Promise and native resource IDs were managed through separate paths
  • Rust-side ownership of JavaScript objects made resource lifetime and cleanup difficult to reason about
  • Async resource cleanup and destroy callback handling were fragmented
  • Promise tracking could remain enabled when no tracking consumer was active
  • Native async operations did not share a consistent lifecycle
  • AsyncLocalStorage was not implemented

This change centralizes async resource metadata and uses JavaScript-managed weak references and finalization to observe object lifetimes. Rust retains only the metadata and callback handles required to bridge lifecycle events.

Technical Changes

Promise identity

  • Promise IDs are stored in a JavaScript WeakMap
  • Rust does not strongly retain Promise objects solely for identity tracking
  • Promise cleanup is driven by a JavaScript FinalizationRegistry
  • Promise and native resources use a shared async ID allocator

Native resource management

  • Native resources are referenced through JavaScript WeakRef
  • Resources that must remain alive while an operation is pending are held through Persistent<Object>
  • Timer metadata is represented by AsyncResource, containing:
    • the resource object
    • async ID
    • trigger ID

Finalization and destroy lifecycle

  • Finalization tokens contain:
    • resource kind
    • async ID
    • trigger ID
  • Promise and native resource cleanup use a unified finalization path
  • Native resource entries are removed from the Rust-side map during finalization
  • AsyncLocalStorage state associated with finalized async IDs is removed
  • destroy callbacks execute in the finalized resource’s async context and restore the previous context afterward

AsyncLocalStorage

The following APIs are implemented:

  • AsyncLocalStorage
  • AsyncLocalStorage.bind
  • AsyncLocalStorage.snapshot
  • disable
  • enterWith
  • exit
  • getStore
  • run
  • defaultValue
  • name

AsyncLocalStorage instances are registered through weak handles so discarded instances do not remain strongly reachable.

Propagation is limited to active storage instances:

  • Disabled storage instances are excluded
  • Dead weak handles are pruned
  • ALS tracking is disabled when no active storage remains
  • Tracking is re-enabled when a storage instance becomes active again

Tracking overhead

PromiseHook processing is gated by a tracking bitmask covering:

  • init
  • before
  • after
  • resolve
  • destroy
  • AsyncLocalStorage

When no hook or ALS tracking is required, PromiseHook processing returns immediately without allocating async IDs or registering finalization callbacks.

Native async operation integration

The following operations participate in async hook lifecycle tracking:

  • timers
  • queueMicrotask
  • DNS lookups
  • module loading

Timer and DNS callbacks attempt to invoke the corresponding after hook even when the user callback throws. The original callback error is returned after the after hook has been attempted.

Async and trigger IDs

  • Native resources use the execution async ID at creation time as their trigger ID
  • Async ID overflow is detected with checked_add
  • Finalization registration is skipped for zero IDs
  • Execution contexts are managed with a stack so nested async scopes are restored correctly

Hook callback lifetime

  • enable and disable closures capture only a hook ID and the enabled state
  • JavaScript callback functions are retained by AsyncHookState
  • Callback references are released during async hook cleanup
  • This avoids QuickJS runtime shutdown GC assertions caused by callback references captured by Rust closures

API and Type Definitions

Updated:

  • API.md
  • types/async_hooks.d.ts

The documentation and declarations include executionAsyncResource and AsyncLocalStorage.

Tests

Coverage includes:

  • async hook lifecycle
  • Promise async ID tracking
  • native timer async IDs
  • native trigger IDs
  • execution async resources
  • hook enable/disable behavior
  • hook callback failures
  • clean runtime shutdown after hook disable
  • AsyncLocalStorage Promise propagation
  • nested run calls
  • enterWith and exit
  • disable
  • bind and snapshot
  • isolation between multiple AsyncLocalStorage instances
  • cleanup of discarded storage registrations

Validation

  • async_hooks: 11/11 passed
  • async_local_storage: 16/16 passed
  • cargo test -p llrt_async_hooks
  • cargo check -p llrt_async_hooks
  • git diff --check

Known Limitation

QuickJS does not expose the Promise reaction job currently being executed. As a result, the bridge cannot always determine which Promise continuation is calling AsyncLocalStorage.getStore().

The synchronous execution scope created by AsyncLocalStorage.run() is restored immediately after the callback returns. The store for a returned Promise is retained separately so that its continuations can recover the context. This compatibility fallback stores only the latest Promise store and therefore cannot completely isolate concurrent Promise continuations.

The remaining limitation requires QuickJS support for identifying the currently executing Promise reaction job, or an equivalent async execution context. Once that runtime information is available, LLRT can use it to provide Node.js-compatible concurrent Promise propagation.

Reproduction code:

import { AsyncLocalStorage } from 'node:async_hooks'

const storage = new AsyncLocalStorage()

async function task(name) {
  await Promise.resolve()

  return {
    name,
    store: storage.getStore(),
  }
}

const results = await Promise.all([
  storage.run('A', () => task('alice')),
  storage.run('B', () => task('bob')),
  storage.run('C', () => task('charlie')),
])

console.log(results)

Expected:

[
  { name: 'alice', store: 'A' },
  { name: 'bob', store: 'B' },
  { name: 'charlie', store: 'C' }
]

LLRT:

[
  { name: 'alice', store: 'C' },
  { name: 'bob', store: 'C' },
  { name: 'charlie', store: 'C' }
]

Checklist

  • Created unit tests in tests/unit and/or in Rust for my feature if needed
  • Ran make fix to format JS and apply Clippy auto fixes
  • Made sure my code didn't add any additional warnings: make check
  • Added relevant type info in types/ directory
  • Updated documentation if needed (API.md/README.md/Other)

By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.

@nabetti1720
nabetti1720 marked this pull request as draft September 20, 2026 09:12
@nabetti1720
nabetti1720 force-pushed the feat/async-local-storage branch 2 times, most recently from bbc52ab to 764464d Compare September 20, 2026 12:28
@nabetti1720
nabetti1720 marked this pull request as ready for review September 20, 2026 12:34
@nabetti1720
nabetti1720 force-pushed the feat/async-local-storage branch from ad14e55 to af236cc Compare September 20, 2026 14:09
@nabetti1720 nabetti1720 changed the title feat(async_hooks): Add async_hooks and AsyncLocalStorage with low-overhead async resource tracking feat(async_hooks): Add AsyncLocalStorage and expand async resource tracking Sep 21, 2026
@nabetti1720
nabetti1720 force-pushed the feat/async-local-storage branch 3 times, most recently from 1b673e6 to 01d1301 Compare September 22, 2026 08:59
- Expose init_state, shutdown_state, and run_pending_jobs directly
- Simplify timer initialization and scheduling APIs
- Remove obsolete runtime wrappers and aliases
- Preserve ProviderType as a public hooking API
- Centralize hook registration and callback selection
- Consolidate AsyncLocalStorage state transitions and cleanup
- Simplify async resource ID parsing and runtime state cleanup
…ions

Keep a compatibility fallback for Promise jobs whose execution context
cannot be identified by QuickJS, and preserve timer context while draining
pending jobs. Document the fallback's concurrency limitation.
Store hook callbacks in AsyncHookState and capture only a hook ID in
enable/disable closures to prevent runtime shutdown GC assertions.

Add a subprocess regression test for clean hook shutdown.
@nabetti1720
nabetti1720 force-pushed the feat/async-local-storage branch from 3aa1311 to 3e688fd Compare September 23, 2026 11:47
@nabetti1720
nabetti1720 force-pushed the feat/async-local-storage branch from 3e688fd to a2ea16e Compare September 23, 2026 12:02
@nabetti1720
nabetti1720 marked this pull request as draft September 24, 2026 23:32
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.

Support for AsyncLocalStorage from async_hooks

1 participant