Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
164 changes: 164 additions & 0 deletions doc/api/async_context.md
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,110 @@ try {
}
```

### `asyncLocalStorage.withScope(store)`

<!-- YAML
added: REPLACEME
-->

> Stability: 1 - Experimental

* `store` {any}
* Returns: {RunScope}

Creates a disposable scope that enters the given store and automatically
restores the previous store value when the scope is disposed. This method is
designed to work with JavaScript's explicit resource management (`using` syntax).

Example:

```mjs
import { AsyncLocalStorage } from 'node:async_hooks';

const asyncLocalStorage = new AsyncLocalStorage();

{
using _ = asyncLocalStorage.withScope('my-store');
console.log(asyncLocalStorage.getStore()); // Prints: my-store
}

console.log(asyncLocalStorage.getStore()); // Prints: undefined
```

```cjs
const { AsyncLocalStorage } = require('node:async_hooks');

const asyncLocalStorage = new AsyncLocalStorage();

{
using _ = asyncLocalStorage.withScope('my-store');
console.log(asyncLocalStorage.getStore()); // Prints: my-store
}

console.log(asyncLocalStorage.getStore()); // Prints: undefined
```

The `withScope()` method is particularly useful for managing context in
synchronous code where you want to ensure the previous store value is restored
when exiting a block, even if an error is thrown.

```mjs
import { AsyncLocalStorage } from 'node:async_hooks';

const asyncLocalStorage = new AsyncLocalStorage();

try {
using _ = asyncLocalStorage.withScope('my-store');
console.log(asyncLocalStorage.getStore()); // Prints: my-store
throw new Error('test');
} catch (e) {
// Store is automatically restored even after error
console.log(asyncLocalStorage.getStore()); // Prints: undefined
}
```

```cjs
const { AsyncLocalStorage } = require('node:async_hooks');

const asyncLocalStorage = new AsyncLocalStorage();

try {
using _ = asyncLocalStorage.withScope('my-store');
console.log(asyncLocalStorage.getStore()); // Prints: my-store
throw new Error('test');
} catch (e) {
// Store is automatically restored even after error
console.log(asyncLocalStorage.getStore()); // Prints: undefined
}
```

**Important:** When using `withScope()` in async functions before the first
`await`, be aware that the scope change will affect the caller's context. The
synchronous portion of an async function (before the first `await`) runs
immediately when called, and when it reaches the first `await`, it returns the
promise to the caller. At that point, the scope change becomes visible in the
caller's context and will persist in subsequent synchronous code until something
else changes the scope value. For async operations, prefer using `run()` which
properly isolates context across async boundaries.
Copy link
Member

Choose a reason for hiding this comment

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

This makes me quite nervous as it seems very easy for users to get incorrect. In cases where the async context is propagating session or tracing details specific to a particular scope, this feels like a signficant footgun

Copy link
Member Author

Choose a reason for hiding this comment

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

I'm a bit less worried about it as it operates per-store rather than globally across all stores, so you're likely only ever going to run into it with your own code. Agreed that it has non-zero risk though.

I'm mainly adding this to enable optimizations to TracingChannel and runStores in diagnostics_channel to eliminate a bunch of closures (#61680) so could also consider just not documenting it if we're concerned about users using it wrong.

Could also pursue adding some more hooks to V8 to mark the boundaries of that segment of code and give us something to reset the context around. I've already had a bunch of prior conversations about this problem in the AsyncContext proposal channels--this part of the spec is just packed full of these landmines and it'd be really nice to have a way around the strange behaviour there. 🙈

Copy link
Member

Choose a reason for hiding this comment

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

Yeah, it's super tricky and there might not be anything we can reasonably do about the footguns except to document the hell out of them and hope for the best. At the very least, I'd say at the very least let's be sure to keep this experimental for a bit.

Copy link
Member Author

Choose a reason for hiding this comment

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

Yep, would definitely need to remain experimental until we're sure about it. And my feeling is that if we're not sure about it I would probably want to just go make a V8 change to make it possible to fix that async function prelude issue at least--that'd also unblock a more sync-feeling set/get model for ALS that I have been wanting to try for a while.


```mjs
import { AsyncLocalStorage } from 'node:async_hooks';

const asyncLocalStorage = new AsyncLocalStorage();

async function example() {
using _ = asyncLocalStorage.withScope('my-store');
console.log(asyncLocalStorage.getStore()); // Prints: my-store
await someAsyncOperation(); // Function pauses here and returns promise
console.log(asyncLocalStorage.getStore()); // Prints: my-store
}

// Calling without await
example(); // Synchronous portion runs, then pauses at first await
// After the promise is returned, the scope 'my-store' is now active in caller!
console.log(asyncLocalStorage.getStore()); // Prints: my-store (unexpected!)
```

### Usage with `async/await`

If, within an async function, only one `await` call is to run within a context,
Expand Down Expand Up @@ -420,6 +524,64 @@ of `asyncLocalStorage.getStore()` after the calls you suspect are responsible
for the loss. When the code logs `undefined`, the last callback called is
probably responsible for the context loss.

## Class: `RunScope`

<!-- YAML
added: REPLACEME
-->

> Stability: 1 - Experimental

A disposable scope returned by [`asyncLocalStorage.withScope()`][] that
automatically restores the previous store value when disposed. This class
implements the [Explicit Resource Management][] protocol and is designed to work
with JavaScript's `using` syntax.

The scope automatically restores the previous store value when the `using` block
exits, whether through normal completion or by throwing an error.
Copy link
Member

Choose a reason for hiding this comment

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

What happens if someone forgets to use using with this? That also feels like a bit of a footgun here.

Copy link
Member Author

Choose a reason for hiding this comment

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

Yes, that's a definite weakness of the resource management spec. I really wish they had some method of detecting if the expression being evaluated is targeting a using declaration and be able to throw an error if not. In my opinion the ability to create a using-based resource without actually using it with the syntax was a mistake. 😐

Copy link
Member

Choose a reason for hiding this comment

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

Yep. I'll be sure to raise this in the committee.

Copy link

@rbuckton rbuckton Feb 12, 2026

Choose a reason for hiding this comment

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

This has been discussed several times in committee. In general, we do not force you to use syntax in other cases, such as using await with a Promise-returning function, as there are perfectly reasonable situations where you don't want to use await (or using). Even if we were to introduce a mechanism to enforce using, ideally there would be some way to opt out.

There are two proposed solutions to this. One uses the current proposal as-is and suggests that [Symbol.dispose] be a getter that sets a flag on read before returning the disposer. That flag is then checked when performing any operation against the resource. In this scenario, the expectation is that a resource can be used with using or with DisposableStack.prototype.use as both will immediately read the [Symbol.dispose] property. In addition, a developer could also imperatively read the [Symbol.dispose] property themselves for any kind of manual resource tracking.

The second proposed solution would be to introduce a [Symbol.enter] method that must be invoked to unwrap the underlying resource to be disposed. In this proposal the object returned from something like storage.withScope might not be directly usable by the consumer and instead contains a [Symbol.enter]() method that is invoked by a statement like using to acquire the actual resource. As such, developers would still be able to imperatively invoke [Symbol.enter]() if they so choose as well as leverage composition mechanisms like DisposableStack. The intent is that the added complication would be to guide users towards using as the simplest path, without preventing advanced use cases.

I'm generally not in favor of a magical behavior that enforces a syntactic usage at the callee site, as it makes it much more complicated for users to reason over whether a resource is composable and makes the whole mechanism seem very fragile.

Choose a reason for hiding this comment

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

The main upside to the get [Symbol.dispose]() (getter) approach is that it can be leveraged immediately without depending on the advancement of a second proposal, with the obvious downside that it requires more internal plumbing.

Users could also leverage a type-aware linter to detect and flag untracked resources.

Copy link
Member Author

@Qard Qard Feb 12, 2026

Choose a reason for hiding this comment

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

Yes, I suggested a using.target so the creator of the usable type could opt to throw when it's not set, but it would not be mandatory to use it. I can understand why folks might be averse to syntax like that though.

I'd be happy with a [Symbol.enter] too, even if that is technically exposed to users, as it looks obvious enough that one should not be using it unless they know what they're doing. As it is presently, it's too easy to misuse ERM between forgetting the using and getting half-applied logic or its odd interactions with async function preludes.

Copy link
Member

Choose a reason for hiding this comment

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

Copy link
Member Author

Choose a reason for hiding this comment

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

So as this is presently, forgetting using or a manual dispose will result in the context persisting until it would next change, which would be basically any async boundary so I'm not too worried about leaking anywhere unexpected. All the task loop stuff at the end of an I/O tick will get their own contexts before execution, so there's really not that much context would persist to that is unexpected beyond just the rest of the sync execution of the current tick. Event handlers run in the same tick could leak context to each other, but I would argue that should be expected when not exiting the context. 🤷🏻


### `scope.dispose()`

<!-- YAML
added: REPLACEME
-->

Explicitly ends the scope and restores the previous store value. This method
is idempotent: calling it multiple times has the same effect as calling it once.

The `[Symbol.dispose]()` method defers to `dispose()`.

If `withScope()` is called without the `using` keyword, `dispose()` must be
called manually to restore the previous store value. Forgetting to call
`dispose()` will cause the store value to persist for the remainder of the
current execution context:

```mjs
import { AsyncLocalStorage } from 'node:async_hooks';

const storage = new AsyncLocalStorage();

// Without using, the scope must be disposed manually
const scope = storage.withScope('my-store');
// storage.getStore() === 'my-store' here

scope.dispose(); // Restore previous value
// storage.getStore() === undefined here
```

```cjs
const { AsyncLocalStorage } = require('node:async_hooks');

const storage = new AsyncLocalStorage();

// Without using, the scope must be disposed manually
const scope = storage.withScope('my-store');
// storage.getStore() === 'my-store' here

scope.dispose(); // Restore previous value
// storage.getStore() === undefined here
```

## Class: `AsyncResource`

<!-- YAML
Expand Down Expand Up @@ -905,8 +1067,10 @@ const server = createServer((req, res) => {
}).listen(3000);
```

[Explicit Resource Management]: https://github.com/tc39/proposal-explicit-resource-management
[`AsyncResource`]: #class-asyncresource
[`EventEmitter`]: events.md#class-eventemitter
[`Stream`]: stream.md#stream
[`Worker`]: worker_threads.md#class-worker
[`asyncLocalStorage.withScope()`]: #asynclocalstoragewithscopestore
[`util.promisify()`]: util.md#utilpromisifyoriginal
6 changes: 6 additions & 0 deletions lib/internal/async_local_storage/async_context_frame.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ const {
const AsyncContextFrame = require('internal/async_context_frame');
const { AsyncResource } = require('async_hooks');

const RunScope = require('internal/async_local_storage/run_scope');

class AsyncLocalStorage {
#defaultValue = undefined;
#name = undefined;
Expand Down Expand Up @@ -77,6 +79,10 @@ class AsyncLocalStorage {
}
return frame?.get(this);
}

withScope(store) {
return new RunScope(this, store);
}
}

module.exports = AsyncLocalStorage;
6 changes: 6 additions & 0 deletions lib/internal/async_local_storage/async_hooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ const {
executionAsyncResource,
} = require('async_hooks');

const RunScope = require('internal/async_local_storage/run_scope');

const storageList = [];
const storageHook = createHook({
init(asyncId, type, triggerAsyncId, resource) {
Expand Down Expand Up @@ -142,6 +144,10 @@ class AsyncLocalStorage {
}
return this.#defaultValue;
}

withScope(store) {
return new RunScope(this, store);
}
}

module.exports = AsyncLocalStorage;
31 changes: 31 additions & 0 deletions lib/internal/async_local_storage/run_scope.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
'use strict';

const {
SymbolDispose,
} = primordials;

class RunScope {
#storage;
#previousStore;
#disposed = false;

constructor(storage, store) {
this.#storage = storage;
this.#previousStore = storage.getStore();
storage.enterWith(store);
}

dispose() {
if (this.#disposed) {
return;
}
this.#disposed = true;
this.#storage.enterWith(this.#previousStore);
}

[SymbolDispose]() {
this.dispose();
}
}

module.exports = RunScope;
Loading
Loading