Repository: nodejs/node
Pull request: async_hooks: add using scopes to AsyncLocalStorage

Adds support for `using scope = storage.withScope(data)` to do the equivalent of a `storage.run(data, fn)` with using syntax. This enables avoiding unnecessary closures.

cc @nodejs/diagnostics 

Changed files:
filename: doc/api/async_context.md
patch: @@ -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.
+
+```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,
@@ -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.
+
+### `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
@@ -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

filename: lib/internal/async_local_storage/async_context_frame.js
patch: @@ -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;
@@ -77,6 +79,10 @@ class AsyncLocalStorage {
     }
     return frame?.get(this);
   }
+
+  withScope(store) {
+    return new RunScope(this, store);
+  }
 }
 
 module.exports = AsyncLocalStorage;

filename: lib/internal/async_local_storage/async_hooks.js
patch: @@ -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) {
@@ -142,6 +144,10 @@ class AsyncLocalStorage {
     }
     return this.#defaultValue;
   }
+
+  withScope(store) {
+    return new RunScope(this, store);
+  }
 }
 
 module.exports = AsyncLocalStorage;

filename: lib/internal/async_local_storage/run_scope.js
patch: @@ -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;

filename: test/parallel/test-async-local-storage-run-scope.js
patch: @@ -0,0 +1,202 @@
+/* eslint-disable no-unused-vars */
+'use strict';
+require('../common');
+const assert = require('node:assert');
+const { AsyncLocalStorage } = require('node:async_hooks');
+
+// Test basic RunScope with using
+{
+  const storage = new AsyncLocalStorage();
+
+  assert.strictEqual(storage.getStore(), undefined);
+
+  {
+    using scope = storage.withScope('test');
+    assert.strictEqual(storage.getStore(), 'test');
+  }
+
+  // Store should be restored to undefined
+  assert.strictEqual(storage.getStore(), undefined);
+}
+
+// Test RunScope restores previous value
+{
+  const storage = new AsyncLocalStorage();
+
+  storage.enterWith('initial');
+  assert.strictEqual(storage.getStore(), 'initial');
+
+  {
+    using scope = storage.withScope('scoped');
+    assert.strictEqual(storage.getStore(), 'scoped');
+  }
+
+  // Should restore to previous value
+  assert.strictEqual(storage.getStore(), 'initial');
+}
+
+// Test nested RunScope
+{
+  const storage = new AsyncLocalStorage();
+  const storeValues = [];
+
+  {
+    using outer = storage.withScope('outer');
+    storeValues.push(storage.getStore());
+
+    {
+      using inner = storage.withScope('inner');
+      storeValues.push(storage.getStore());
+    }
+
+    // Should restore to outer
+    storeValues.push(storage.getStore());
+  }
+
+  // Should restore to undefined
+  storeValues.push(storage.getStore());
+
+  assert.deepStrictEqual(storeValues, ['outer', 'inner', 'outer', undefined]);
+}
+
+// Test RunScope with error during usage
+{
+  const storage = new AsyncLocalStorage();
+
+  storage.enterWith('before');
+
+  const testError = new Error('test');
+
+  assert.throws(() => {
+    using scope = storage.withScope('during');
+    assert.strictEqual(storage.getStore(), 'during');
+    throw testError;
+  }, testError);
+
+  // Store should be restored even after error
+  assert.strictEqual(storage.getStore(), 'before');
+}
+
+// Test idempotent disposal via named dispose() method
+{
+  const storage = new AsyncLocalStorage();
+
+  const scope = storage.withScope('test');
+  assert.strictEqual(storage.getStore(), 'test');
+
+  // Dispose via named dispose() method
+  scope.dispose();
+  assert.strictEqual(storage.getStore(), undefined);
+
+  storage.enterWith('test2');
+  assert.strictEqual(storage.getStore(), 'test2');
+
+  // Double dispose should be idempotent
+  scope.dispose();
+  assert.strictEqual(storage.getStore(), 'test2');
+}
+
+// Test withScope without using keyword (scope leaks until manually disposed)
+{
+  const storage = new AsyncLocalStorage();
+
+  const scope = storage.withScope('leaked');
+  assert.strictEqual(storage.getStore(), 'leaked');
+
+  // Without using, the scope persists
+  assert.strictEqual(storage.getStore(), 'leaked');
+
+  // Must manually dispose via named method
+  scope.dispose();
+  assert.strictEqual(storage.getStore(), undefined);
+}
+
+// Test that dispose undoes enterWith called inside scope
+{
+  const storage = new AsyncLocalStorage();
+
+  storage.enterWith('store1');
+  assert.strictEqual(storage.getStore(), 'store1');
+
+  {
+    using _ = storage.withScope('store2');
+    assert.strictEqual(storage.getStore(), 'store2');
+
+    storage.enterWith('store3');
+    assert.strictEqual(storage.getStore(), 'store3');
+  }
+
+  // Restores to store1, undoing both withScope and the enterWith inside scope
+  assert.strictEqual(storage.getStore(), 'store1');
+}
+
+// Test RunScope with defaultValue
+{
+  const storage = new AsyncLocalStorage({ defaultValue: 'default' });
+
+  assert.strictEqual(storage.getStore(), 'default');
+
+  {
+    using scope = storage.withScope('custom');
+    assert.strictEqual(storage.getStore(), 'custom');
+  }
+
+  // Should restore to default
+  assert.strictEqual(storage.getStore(), 'default');
+}
+
+// Test deeply nested RunScope
+{
+  const storage = new AsyncLocalStorage();
+
+  {
+    using s1 = storage.withScope(1);
+    assert.strictEqual(storage.getStore(), 1);
+
+    {
+      using s2 = storage.withScope(2);
+      assert.strictEqual(storage.getStore(), 2);
+
+      {
+        using s3 = storage.withScope(3);
+        assert.strictEqual(storage.getStore(), 3);
+
+        {
+          using s4 = storage.withScope(4);
+          assert.strictEqual(storage.getStore(), 4);
+        }
+
+        assert.strictEqual(storage.getStore(), 3);
+      }
+
+      assert.strictEqual(storage.getStore(), 2);
+    }
+
+    assert.strictEqual(storage.getStore(), 1);
+  }
+
+  assert.strictEqual(storage.getStore(), undefined);
+}
+
+// Test RunScope with multiple storages
+{
+  const storage1 = new AsyncLocalStorage();
+  const storage2 = new AsyncLocalStorage();
+
+  {
+    using scope1 = storage1.withScope('A');
+
+    {
+      using scope2 = storage2.withScope('B');
+
+      assert.strictEqual(storage1.getStore(), 'A');
+      assert.strictEqual(storage2.getStore(), 'B');
+    }
+
+    assert.strictEqual(storage1.getStore(), 'A');
+    assert.strictEqual(storage2.getStore(), undefined);
+  }
+
+  assert.strictEqual(storage1.getStore(), undefined);
+  assert.strictEqual(storage2.getStore(), undefined);
+}