diff --git a/doc/api/async_context.md b/doc/api/async_context.md index dd3b6a834ec950..a3d551f99269fe 100644 --- a/doc/api/async_context.md +++ b/doc/api/async_context.md @@ -386,6 +386,83 @@ try { } ``` +### `asyncLocalStorage.withScope(store)` + + + +> 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 scope = 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 scope = 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 scope = 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 scope = 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 +} +``` + ### Usage with `async/await` If, within an async function, only one `await` call is to run within a context, @@ -420,6 +497,22 @@ 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` + + + +> 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. + ## Class: `AsyncResource` + +> Stability: 1 - Experimental + +* `nameOrChannels` {string|WindowChannel} Channel name or + object containing all the [WindowChannel Channels][] +* Returns: {WindowChannel} Collection of channels to trace with + +Creates a [`WindowChannel`][] wrapper for the given channels. If a name is +given, the corresponding channels will be created in the form of +`tracing:${name}:${eventType}` where `eventType` is `start` or `end`. + +A `WindowChannel` is a simplified version of [`TracingChannel`][] that only +traces synchronous operations. It only has `start` and `end` events, without +`asyncStart`, `asyncEnd`, or `error` events, making it suitable for tracing +operations that don't involve asynchronous continuations or error handling. + +```mjs +import { windowChannel, channel } from 'node:diagnostics_channel'; + +const wc = windowChannel('my-operation'); + +// or... + +const wc2 = windowChannel({ + start: channel('tracing:my-operation:start'), + end: channel('tracing:my-operation:end'), +}); +``` + +```cjs +const { windowChannel, channel } = require('node:diagnostics_channel'); + +const wc = windowChannel('my-operation'); + +// or... + +const wc2 = windowChannel({ + start: channel('tracing:my-operation:start'), + end: channel('tracing:my-operation:end'), +}); +``` + ### Class: `Channel` + +> Stability: 1 - Experimental + +* `data` {any} Message to bind to stores +* Returns: {RunStoresScope} Disposable scope object + +Creates a disposable scope that binds the given data to any AsyncLocalStorage +instances bound to the channel and publishes it to subscribers. The scope +automatically restores the previous storage contexts when disposed. + +This method enables the use of JavaScript's explicit resource management +(`using` syntax with `Symbol.dispose`) to manage store contexts without +closure wrapping. + +```mjs +import { channel } from 'node:diagnostics_channel'; +import { AsyncLocalStorage } from 'node:async_hooks'; + +const store = new AsyncLocalStorage(); +const ch = channel('my-channel'); + +ch.bindStore(store, (message) => { + return { ...message, timestamp: Date.now() }; +}); + +{ + using scope = ch.withStoreScope({ request: 'data' }); + // Store is entered, data is published + console.log(store.getStore()); // { request: 'data', timestamp: ... } +} +// Store is automatically restored on scope exit +``` + +```cjs +const { channel } = require('node:diagnostics_channel'); +const { AsyncLocalStorage } = require('node:async_hooks'); + +const store = new AsyncLocalStorage(); +const ch = channel('my-channel'); + +ch.bindStore(store, (message) => { + return { ...message, timestamp: Date.now() }; +}); + +{ + using scope = ch.withStoreScope({ request: 'data' }); + // Store is entered, data is published + console.log(store.getStore()); // { request: 'data', timestamp: ... } +} +// Store is automatically restored on scope exit +``` + +### Class: `RunStoresScope` + + + +> Stability: 1 - Experimental + +The class `RunStoresScope` represents a disposable scope created by +[`channel.withStoreScope(data)`][]. It manages the lifecycle of store +contexts and ensures they are properly restored when the scope exits. + +The scope must be used with the `using` syntax to ensure proper disposal. + ### Class: `TracingChannel` + +> Stability: 1 - Experimental + +The class `WindowChannel` is a simplified version of [`TracingChannel`][] that +only traces synchronous operations. It consists of two channels (`start` and +`end`) instead of five, omitting the `asyncStart`, `asyncEnd`, and `error` +events. This makes it suitable for tracing operations that don't involve +asynchronous continuations or error handling. + +Like `TracingChannel`, it is recommended to create and reuse a single +`WindowChannel` at the top-level of the file rather than creating them +dynamically. + +#### `windowChannel.hasSubscribers` + + + +* Returns: {boolean} `true` if any of the individual channels has a subscriber, + `false` if not. + +Check if any of the `start` or `end` channels have subscribers. + +```mjs +import { windowChannel } from 'node:diagnostics_channel'; + +const wc = windowChannel('my-operation'); + +if (wc.hasSubscribers) { + // There are subscribers, perform traced operation +} +``` + +```cjs +const { windowChannel } = require('node:diagnostics_channel'); + +const wc = windowChannel('my-operation'); + +if (wc.hasSubscribers) { + // There are subscribers, perform traced operation +} +``` + +#### `windowChannel.subscribe(handlers)` + + + +* `handlers` {Object} Set of channel subscribers + * `start` {Function} The start event subscriber + * `end` {Function} The end event subscriber + +Subscribe to the window channel events. This is equivalent to calling +[`channel.subscribe(onMessage)`][] on each channel individually. + +```mjs +import { windowChannel } from 'node:diagnostics_channel'; + +const wc = windowChannel('my-operation'); + +wc.subscribe({ + start(message) { + // Handle start + }, + end(message) { + // Handle end + }, +}); +``` + +```cjs +const { windowChannel } = require('node:diagnostics_channel'); + +const wc = windowChannel('my-operation'); + +wc.subscribe({ + start(message) { + // Handle start + }, + end(message) { + // Handle end + }, +}); +``` + +#### `windowChannel.unsubscribe(handlers)` + + + +* `handlers` {Object} Set of channel subscribers + * `start` {Function} The start event subscriber + * `end` {Function} The end event subscriber +* Returns: {boolean} `true` if all handlers were successfully unsubscribed, + `false` otherwise. + +Unsubscribe from the window channel events. This is equivalent to calling +[`channel.unsubscribe(onMessage)`][] on each channel individually. + +```mjs +import { windowChannel } from 'node:diagnostics_channel'; + +const wc = windowChannel('my-operation'); + +const handlers = { + start(message) {}, + end(message) {}, +}; + +wc.subscribe(handlers); +wc.unsubscribe(handlers); +``` + +```cjs +const { windowChannel } = require('node:diagnostics_channel'); + +const wc = windowChannel('my-operation'); + +const handlers = { + start(message) {}, + end(message) {}, +}; + +wc.subscribe(handlers); +wc.unsubscribe(handlers); +``` + +#### `windowChannel.run(context, fn[, thisArg[, ...args]])` + + + +* `context` {Object} Shared object to correlate events through +* `fn` {Function} Function to wrap a trace around +* `thisArg` {any} The receiver to be used for the function call +* `...args` {any} Optional arguments to pass to the function +* Returns: {any} The return value of the given function + +Trace a synchronous function call. This will produce a `start` event and `end` +event around the execution. This runs the given function using +[`channel.runStores(context, ...)`][] on the `start` channel which ensures all +events have any bound stores set to match this trace context. + +```mjs +import { windowChannel } from 'node:diagnostics_channel'; + +const wc = windowChannel('my-operation'); + +const result = wc.run({ operationId: '123' }, () => { + // Perform operation + return 42; +}); +``` + +```cjs +const { windowChannel } = require('node:diagnostics_channel'); + +const wc = windowChannel('my-operation'); + +const result = wc.run({ operationId: '123' }, () => { + // Perform operation + return 42; +}); +``` + +#### `windowChannel.withScope([context])` + + + +* `context` {Object} Shared object to correlate events through +* Returns: {WindowChannelScope} Disposable scope object + +Create a disposable scope for tracing a synchronous operation using JavaScript's +explicit resource management (`using` syntax). The scope automatically publishes +`start` and `end` events, enters bound stores, and handles cleanup when disposed. + +```mjs +import { windowChannel } from 'node:diagnostics_channel'; + +const wc = windowChannel('my-operation'); + +const context = { operationId: '123' }; +{ + using scope = wc.withScope(context); + // Stores are entered, start event is published + + // Perform work and set result on context + context.result = 42; +} +// End event is published, stores are restored automatically +``` + +```cjs +const { windowChannel } = require('node:diagnostics_channel'); + +const wc = windowChannel('my-operation'); + +const context = { operationId: '123' }; +{ + using scope = wc.withScope(context); + // Stores are entered, start event is published + + // Perform work and set result on context + context.result = 42; +} +// End event is published, stores are restored automatically +``` + +### Class: `WindowChannelScope` + + + +> Stability: 1 - Experimental + +The class `WindowChannelScope` represents a disposable scope created by +[`windowChannel.withScope(context)`][]. It manages the lifecycle of a traced +operation, automatically publishing events and managing store contexts. + +The scope must be used with the `using` syntax to ensure proper disposal. + +```mjs +import { windowChannel } from 'node:diagnostics_channel'; + +const wc = windowChannel('my-operation'); + +const context = {}; +{ + using scope = wc.withScope(context); + // Start event is published, stores are entered + context.result = performOperation(); + // End event is automatically published at end of block +} +``` + +```cjs +const { windowChannel } = require('node:diagnostics_channel'); + +const wc = windowChannel('my-operation'); + +const context = {}; +{ + using scope = wc.withScope(context); + // Start event is published, stores are entered + context.result = performOperation(); + // End event is automatically published at end of block +} +``` + +### WindowChannel Channels + +A `WindowChannel` consists of two diagnostics channels representing the +lifecycle of a scope created with the `using` syntax: + +* `tracing:${name}:start` - Published when the `using` statement executes (scope creation) +* `tracing:${name}:end` - Published when exiting the block (scope disposal) + +When using the `using` syntax with \[`windowChannel.withScope([context])`]\[], the `start` +event is published immediately when the statement executes, and the `end` event +is automatically published when disposal occurs at the end of the block. All +events share the same context object, which can be extended with additional +properties like `result` during scope execution. + ### TracingChannel Channels A TracingChannel is a collection of several diagnostics\_channels representing @@ -1440,14 +1833,17 @@ added: v16.18.0 Emitted when a new thread is created. [TracingChannel Channels]: #tracingchannel-channels +[WindowChannel Channels]: #windowchannel-channels [`'uncaughtException'`]: process.md#event-uncaughtexception [`TracingChannel`]: #class-tracingchannel +[`WindowChannel`]: #class-windowchannel [`asyncEnd` event]: #asyncendevent [`asyncStart` event]: #asyncstartevent [`channel.bindStore(store)`]: #channelbindstorestore-transform [`channel.runStores(context, ...)`]: #channelrunstorescontext-fn-thisarg-args [`channel.subscribe(onMessage)`]: #channelsubscribeonmessage [`channel.unsubscribe(onMessage)`]: #channelunsubscribeonmessage +[`channel.withStoreScope(data)`]: #channelwithstorescopedata [`diagnostics_channel.channel(name)`]: #diagnostics_channelchannelname [`diagnostics_channel.subscribe(name, onMessage)`]: #diagnostics_channelsubscribename-onmessage [`diagnostics_channel.tracingChannel()`]: #diagnostics_channeltracingchannelnameorchannels @@ -1456,4 +1852,5 @@ Emitted when a new thread is created. [`net.Server.listen()`]: net.md#serverlisten [`process.execve()`]: process.md#processexecvefile-args-env [`start` event]: #startevent +[`windowChannel.withScope(context)`]: #windowchannelwithscopecontext [context loss]: async_context.md#troubleshooting-context-loss diff --git a/lib/diagnostics_channel.js b/lib/diagnostics_channel.js index 3deb301e7f3cd2..b341408b6edc60 100644 --- a/lib/diagnostics_channel.js +++ b/lib/diagnostics_channel.js @@ -17,9 +17,15 @@ const { ReflectApply, SafeFinalizationRegistry, SafeMap, + SymbolDispose, SymbolHasInstance, + globalThis, } = primordials; +// DisposableStack is a global, but we need to explicitly reference it from +// globalThis to avoid linter complaints about undefined globals. +const { DisposableStack } = globalThis; + const { codes: { ERR_INVALID_ARG_TYPE, @@ -81,24 +87,44 @@ function maybeMarkInactive(channel) { } } -function defaultTransform(data) { - return data; -} +class RunStoresScope { + #stack; + + constructor(activeChannel, data) { + using stack = new DisposableStack(); + + // Enter stores using withScope + if (activeChannel._stores) { + for (const entry of activeChannel._stores.entries()) { + const store = entry[0]; + const transform = entry[1]; + + let newContext = data; + if (transform) { + try { + newContext = transform(data); + } catch (err) { + process.nextTick(() => { + triggerUncaughtException(err, false); + }); + continue; + } + } -function wrapStoreRun(store, data, next, transform = defaultTransform) { - return () => { - let context; - try { - context = transform(data); - } catch (err) { - process.nextTick(() => { - triggerUncaughtException(err, false); - }); - return next(); + stack.use(store.withScope(newContext)); + } } - return store.run(context, next); - }; + // Publish data + activeChannel.publish(data); + + // Transfer ownership of the stack + this.#stack = stack.move(); + } + + [SymbolDispose]() { + this.#stack[SymbolDispose](); + } } // TODO(qard): should there be a C++ channel interface? @@ -162,19 +188,14 @@ class ActiveChannel { } } - runStores(data, fn, thisArg, ...args) { - let run = () => { - this.publish(data); - return ReflectApply(fn, thisArg, args); - }; - - for (const entry of this._stores.entries()) { - const store = entry[0]; - const transform = entry[1]; - run = wrapStoreRun(store, data, run, transform); - } + withStoreScope(data) { + return new RunStoresScope(this, data); + } - return run(); + runStores(data, fn, thisArg, ...args) { + // eslint-disable-next-line no-unused-vars + using scope = this.withStoreScope(data); + return ReflectApply(fn, thisArg, args); } } @@ -220,6 +241,13 @@ class Channel { runStores(data, fn, thisArg, ...args) { return ReflectApply(fn, thisArg, args); } + + withStoreScope() { + // Return no-op disposable for inactive channels + return { + [SymbolDispose]() {}, + }; + } } const channels = new WeakRefMap(); @@ -250,12 +278,9 @@ function hasSubscribers(name) { return channel.hasSubscribers; } -const traceEvents = [ +const windowEvents = [ 'start', 'end', - 'asyncStart', - 'asyncEnd', - 'error', ]; function assertChannel(value, name) { @@ -264,7 +289,7 @@ function assertChannel(value, name) { } } -function tracingChannelFrom(nameOrChannels, name) { +function channelFromMap(nameOrChannels, name, className) { if (typeof nameOrChannels === 'string') { return channel(`tracing:${nameOrChannels}:${name}`); } @@ -276,32 +301,61 @@ function tracingChannelFrom(nameOrChannels, name) { } throw new ERR_INVALID_ARG_TYPE('nameOrChannels', - ['string', 'object', 'TracingChannel'], + ['string', 'object', className], nameOrChannels); } -class TracingChannel { +class WindowChannelScope { + #context; + #end; + #scope; + + constructor(windowChannel, context) { + // Only proceed if there are subscribers + if (!windowChannel.hasSubscribers) { + return; + } + + const { start, end } = windowChannel; + this.#context = context; + this.#end = end; + + // Use RunStoresScope for the start channel + this.#scope = new RunStoresScope(start, context); + } + + [SymbolDispose]() { + if (!this.#scope) { + return; + } + + // Publish end event + this.#end.publish(this.#context); + + // Dispose the start scope to restore stores + this.#scope[SymbolDispose](); + } +} + +class WindowChannel { constructor(nameOrChannels) { - for (let i = 0; i < traceEvents.length; ++i) { - const eventName = traceEvents[i]; + for (let i = 0; i < windowEvents.length; ++i) { + const eventName = windowEvents[i]; ObjectDefineProperty(this, eventName, { __proto__: null, - value: tracingChannelFrom(nameOrChannels, eventName), + value: channelFromMap(nameOrChannels, eventName, 'WindowChannel'), }); } } get hasSubscribers() { return this.start?.hasSubscribers || - this.end?.hasSubscribers || - this.asyncStart?.hasSubscribers || - this.asyncEnd?.hasSubscribers || - this.error?.hasSubscribers; + this.end?.hasSubscribers; } subscribe(handlers) { - for (let i = 0; i < traceEvents.length; ++i) { - const name = traceEvents[i]; + for (let i = 0; i < windowEvents.length; ++i) { + const name = windowEvents[i]; if (!handlers[name]) continue; this[name]?.subscribe(handlers[name]); @@ -311,8 +365,8 @@ class TracingChannel { unsubscribe(handlers) { let done = true; - for (let i = 0; i < traceEvents.length; ++i) { - const name = traceEvents[i]; + for (let i = 0; i < windowEvents.length; ++i) { + const name = windowEvents[i]; if (!handlers[name]) continue; if (!this[name]?.unsubscribe(handlers[name])) { @@ -323,26 +377,148 @@ class TracingChannel { return done; } + withScope(context = {}) { + return new WindowChannelScope(this, context); + } + + run(context, fn, thisArg, ...args) { + context ??= {}; + // eslint-disable-next-line no-unused-vars + using scope = this.withScope(context); + return ReflectApply(fn, thisArg, args); + } +} + +function windowChannel(nameOrChannels) { + return new WindowChannel(nameOrChannels); +} + +class TracingChannel { + #callWindow; + #continuationWindow; + + constructor(nameOrChannels) { + // Create a WindowChannel for start/end (call window) + if (typeof nameOrChannels === 'string') { + this.#callWindow = new WindowChannel(nameOrChannels); + this.#continuationWindow = new WindowChannel({ + start: channel(`tracing:${nameOrChannels}:asyncStart`), + end: channel(`tracing:${nameOrChannels}:asyncEnd`), + }); + } else if (typeof nameOrChannels === 'object') { + this.#callWindow = new WindowChannel({ + start: nameOrChannels.start, + end: nameOrChannels.end, + }); + this.#continuationWindow = new WindowChannel({ + start: nameOrChannels.asyncStart, + end: nameOrChannels.asyncEnd, + }); + } + + // Create individual channel for error + ObjectDefineProperty(this, 'error', { + __proto__: null, + value: channelFromMap(nameOrChannels, 'error', 'TracingChannel'), + }); + } + + get start() { + return this.#callWindow.start; + } + + get end() { + return this.#callWindow.end; + } + + get asyncStart() { + return this.#continuationWindow.start; + } + + get asyncEnd() { + return this.#continuationWindow.end; + } + + get hasSubscribers() { + return this.#callWindow.hasSubscribers || + this.#continuationWindow.hasSubscribers || + this.error?.hasSubscribers; + } + + subscribe(handlers) { + // Subscribe to call window (start/end) + if (handlers.start || handlers.end) { + this.#callWindow.subscribe({ + start: handlers.start, + end: handlers.end, + }); + } + + // Subscribe to continuation window (asyncStart/asyncEnd) + if (handlers.asyncStart || handlers.asyncEnd) { + this.#continuationWindow.subscribe({ + start: handlers.asyncStart, + end: handlers.asyncEnd, + }); + } + + // Subscribe to error channel + if (handlers.error) { + this.error.subscribe(handlers.error); + } + } + + unsubscribe(handlers) { + let done = true; + + // Unsubscribe from call window + if (handlers.start || handlers.end) { + if (!this.#callWindow.unsubscribe({ + start: handlers.start, + end: handlers.end, + })) { + done = false; + } + } + + // Unsubscribe from continuation window + if (handlers.asyncStart || handlers.asyncEnd) { + if (!this.#continuationWindow.unsubscribe({ + start: handlers.asyncStart, + end: handlers.asyncEnd, + })) { + done = false; + } + } + + // Unsubscribe from error channel + if (handlers.error) { + if (!this.error.unsubscribe(handlers.error)) { + done = false; + } + } + + return done; + } + traceSync(fn, context = {}, thisArg, ...args) { if (!this.hasSubscribers) { return ReflectApply(fn, thisArg, args); } - const { start, end, error } = this; + const { error } = this; - return start.runStores(context, () => { - try { - const result = ReflectApply(fn, thisArg, args); - context.result = result; - return result; - } catch (err) { - context.error = err; - error.publish(context); - throw err; - } finally { - end.publish(context); - } - }); + // eslint-disable-next-line no-unused-vars + using scope = this.#callWindow.withScope(context); + try { + const result = ReflectApply(fn, thisArg, args); + context.result = result; + return result; + } catch (err) { + context.error = err; + error.publish(context); + throw err; + } } tracePromise(fn, context = {}, thisArg, ...args) { @@ -350,41 +526,42 @@ class TracingChannel { return ReflectApply(fn, thisArg, args); } - const { start, end, asyncStart, asyncEnd, error } = this; + const { error } = this; + const continuationWindow = this.#continuationWindow; function reject(err) { context.error = err; error.publish(context); - asyncStart.publish(context); + // Use continuation window for asyncStart/asyncEnd + // eslint-disable-next-line no-unused-vars + using scope = continuationWindow.withScope(context); // TODO: Is there a way to have asyncEnd _after_ the continuation? - asyncEnd.publish(context); return PromiseReject(err); } function resolve(result) { context.result = result; - asyncStart.publish(context); + // Use continuation window for asyncStart/asyncEnd + // eslint-disable-next-line no-unused-vars + using scope = continuationWindow.withScope(context); // TODO: Is there a way to have asyncEnd _after_ the continuation? - asyncEnd.publish(context); return result; } - return start.runStores(context, () => { - try { - let promise = ReflectApply(fn, thisArg, args); - // Convert thenables to native promises - if (!(promise instanceof Promise)) { - promise = PromiseResolve(promise); - } - return PromisePrototypeThen(promise, resolve, reject); - } catch (err) { - context.error = err; - error.publish(context); - throw err; - } finally { - end.publish(context); + // eslint-disable-next-line no-unused-vars + using scope = this.#callWindow.withScope(context); + try { + let promise = ReflectApply(fn, thisArg, args); + // Convert thenables to native promises + if (!(promise instanceof Promise)) { + promise = PromiseResolve(promise); } - }); + return PromisePrototypeThen(promise, resolve, reject); + } catch (err) { + context.error = err; + error.publish(context); + throw err; + } } traceCallback(fn, position = -1, context = {}, thisArg, ...args) { @@ -392,7 +569,8 @@ class TracingChannel { return ReflectApply(fn, thisArg, args); } - const { start, end, asyncStart, asyncEnd, error } = this; + const { error } = this; + const continuationWindow = this.#continuationWindow; function wrappedCallback(err, res) { if (err) { @@ -402,31 +580,25 @@ class TracingChannel { context.result = res; } - // Using runStores here enables manual context failure recovery - asyncStart.runStores(context, () => { - try { - return ReflectApply(callback, this, arguments); - } finally { - asyncEnd.publish(context); - } - }); + // Use continuation window for asyncStart/asyncEnd around callback + // eslint-disable-next-line no-unused-vars + using scope = continuationWindow.withScope(context); + return ReflectApply(callback, this, arguments); } const callback = ArrayPrototypeAt(args, position); validateFunction(callback, 'callback'); ArrayPrototypeSplice(args, position, 1, wrappedCallback); - return start.runStores(context, () => { - try { - return ReflectApply(fn, thisArg, args); - } catch (err) { - context.error = err; - error.publish(context); - throw err; - } finally { - end.publish(context); - } - }); + // eslint-disable-next-line no-unused-vars + using scope = this.#callWindow.withScope(context); + try { + return ReflectApply(fn, thisArg, args); + } catch (err) { + context.error = err; + error.publish(context); + throw err; + } } } @@ -440,5 +612,7 @@ module.exports = { subscribe, tracingChannel, unsubscribe, + windowChannel, Channel, + WindowChannel, }; diff --git a/lib/internal/async_local_storage/async_context_frame.js b/lib/internal/async_local_storage/async_context_frame.js index 518e955379ac54..8390ba92cbe848 100644 --- a/lib/internal/async_local_storage/async_context_frame.js +++ b/lib/internal/async_local_storage/async_context_frame.js @@ -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; diff --git a/lib/internal/async_local_storage/async_hooks.js b/lib/internal/async_local_storage/async_hooks.js index d227549412bf61..552092d89bf305 100644 --- a/lib/internal/async_local_storage/async_hooks.js +++ b/lib/internal/async_local_storage/async_hooks.js @@ -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; diff --git a/lib/internal/async_local_storage/run_scope.js b/lib/internal/async_local_storage/run_scope.js new file mode 100644 index 00000000000000..a2d024fc23d027 --- /dev/null +++ b/lib/internal/async_local_storage/run_scope.js @@ -0,0 +1,27 @@ +'use strict'; + +const { + SymbolDispose, +} = primordials; + +class RunScope { + #storage; + #previousStore; + #disposed = false; + + constructor(storage, store) { + this.#storage = storage; + this.#previousStore = storage.getStore(); + storage.enterWith(store); + } + + [SymbolDispose]() { + if (this.#disposed) { + return; + } + this.#disposed = true; + this.#storage.enterWith(this.#previousStore); + } +} + +module.exports = RunScope; diff --git a/test/parallel/test-async-local-storage-run-scope.js b/test/parallel/test-async-local-storage-run-scope.js new file mode 100644 index 00000000000000..e9f06147dc3b77 --- /dev/null +++ b/test/parallel/test-async-local-storage-run-scope.js @@ -0,0 +1,165 @@ +/* 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 +{ + const storage = new AsyncLocalStorage(); + + const scope = storage.withScope('test'); + assert.strictEqual(storage.getStore(), 'test'); + + // Dispose via Symbol.dispose + scope[Symbol.dispose](); + assert.strictEqual(storage.getStore(), undefined); + + // Double dispose should be idempotent + scope[Symbol.dispose](); + assert.strictEqual(storage.getStore(), undefined); +} + +// 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); +} diff --git a/test/parallel/test-diagnostics-channel-run-stores-scope-transform-error.js b/test/parallel/test-diagnostics-channel-run-stores-scope-transform-error.js new file mode 100644 index 00000000000000..04bc7eaee46b98 --- /dev/null +++ b/test/parallel/test-diagnostics-channel-run-stores-scope-transform-error.js @@ -0,0 +1,57 @@ +'use strict'; +const common = require('../common'); +const assert = require('node:assert'); +const dc = require('node:diagnostics_channel'); +const { AsyncLocalStorage } = require('node:async_hooks'); + +// Test RunStoresScope with transform error +// Transform errors are scheduled via process.nextTick(triggerUncaughtException) + +const channel = dc.channel('test-transform-error'); +const store = new AsyncLocalStorage(); +const events = []; + +const transformError = new Error('transform failed'); + +// Set up uncaughtException handler to catch the transform error +process.on('uncaughtException', common.mustCall((err) => { + assert.strictEqual(err, transformError); + events.push('uncaughtException'); +})); + +channel.subscribe((message) => { + events.push({ type: 'message', data: message }); +}); + +// Bind store with a transform that throws +channel.bindStore(store, () => { + throw transformError; +}); + +// Store should remain undefined since transform failed +assert.strictEqual(store.getStore(), undefined); + +{ + // eslint-disable-next-line no-unused-vars + using scope = channel.withStoreScope({ value: 'test' }); + + // Store should still be undefined because transform threw + assert.strictEqual(store.getStore(), undefined); + + events.push('inside-scope'); +} + +// Store should still be undefined after scope exit +assert.strictEqual(store.getStore(), undefined); + +// Message should still be published despite transform error +assert.strictEqual(events.length, 2); +assert.strictEqual(events[0].type, 'message'); +assert.strictEqual(events[0].data.value, 'test'); +assert.strictEqual(events[1], 'inside-scope'); + +// Validate uncaughtException was triggered via nextTick +process.on('beforeExit', common.mustCall(() => { + assert.strictEqual(events.length, 3); + assert.strictEqual(events[2], 'uncaughtException'); +})); diff --git a/test/parallel/test-diagnostics-channel-run-stores-scope.js b/test/parallel/test-diagnostics-channel-run-stores-scope.js new file mode 100644 index 00000000000000..54b4417882d9d3 --- /dev/null +++ b/test/parallel/test-diagnostics-channel-run-stores-scope.js @@ -0,0 +1,206 @@ +/* eslint-disable no-unused-vars */ +'use strict'; +require('../common'); +const assert = require('node:assert'); +const dc = require('node:diagnostics_channel'); +const { AsyncLocalStorage } = require('node:async_hooks'); + +// Test basic RunStoresScope with active channel +{ + const channel = dc.channel('test-run-stores-scope-basic'); + const store = new AsyncLocalStorage(); + const events = []; + + channel.subscribe((message) => { + events.push({ type: 'message', data: message, store: store.getStore() }); + }); + + channel.bindStore(store, (data) => { + return { transformed: data.value }; + }); + + assert.strictEqual(store.getStore(), undefined); + + { + using scope = channel.withStoreScope({ value: 'test' }); + + // Store should be set + assert.deepStrictEqual(store.getStore(), { transformed: 'test' }); + + events.push({ type: 'inside', store: store.getStore() }); + } + + // Store should be restored + assert.strictEqual(store.getStore(), undefined); + + assert.strictEqual(events.length, 2); + assert.strictEqual(events[0].type, 'message'); + assert.strictEqual(events[0].data.value, 'test'); + assert.deepStrictEqual(events[0].store, { transformed: 'test' }); + + assert.strictEqual(events[1].type, 'inside'); + assert.deepStrictEqual(events[1].store, { transformed: 'test' }); +} + +// Test RunStoresScope with inactive channel (no-op) +{ + const channel = dc.channel('test-run-stores-scope-inactive'); + + // No subscribers, channel is inactive + { + using scope = channel.withStoreScope({ value: 'test' }); + assert.ok(scope); + } + + // Should not throw +} + +// Test RunStoresScope restores previous store value +{ + const channel = dc.channel('test-run-stores-scope-restore'); + const store = new AsyncLocalStorage(); + + channel.subscribe(() => {}); + channel.bindStore(store, (data) => data); + + store.enterWith('initial'); + assert.strictEqual(store.getStore(), 'initial'); + + { + using scope = channel.withStoreScope('scoped'); + assert.strictEqual(store.getStore(), 'scoped'); + } + + // Should restore to previous value + assert.strictEqual(store.getStore(), 'initial'); +} + +// Test RunStoresScope with multiple stores +{ + const channel = dc.channel('test-run-stores-scope-multi'); + const store1 = new AsyncLocalStorage(); + const store2 = new AsyncLocalStorage(); + const store3 = new AsyncLocalStorage(); + + channel.subscribe(() => {}); + channel.bindStore(store1, (data) => `${data}-1`); + channel.bindStore(store2, (data) => `${data}-2`); + channel.bindStore(store3, (data) => `${data}-3`); + + { + using scope = channel.withStoreScope('test'); + + assert.strictEqual(store1.getStore(), 'test-1'); + assert.strictEqual(store2.getStore(), 'test-2'); + assert.strictEqual(store3.getStore(), 'test-3'); + } + + assert.strictEqual(store1.getStore(), undefined); + assert.strictEqual(store2.getStore(), undefined); + assert.strictEqual(store3.getStore(), undefined); +} + +// Test manual dispose via Symbol.dispose +{ + const channel = dc.channel('test-run-stores-scope-manual'); + const store = new AsyncLocalStorage(); + const events = []; + + channel.subscribe((message) => { + events.push(message); + }); + + channel.bindStore(store, (data) => data); + + const scope = channel.withStoreScope('test'); + + assert.strictEqual(events.length, 1); + assert.strictEqual(store.getStore(), 'test'); + + scope[Symbol.dispose](); + + // Store should be restored + assert.strictEqual(store.getStore(), undefined); + + // Double dispose should be idempotent + scope[Symbol.dispose](); + assert.strictEqual(store.getStore(), undefined); +} + +// Test nested RunStoresScope +{ + const channel = dc.channel('test-run-stores-scope-nested'); + const store = new AsyncLocalStorage(); + const storeValues = []; + + channel.subscribe(() => {}); + channel.bindStore(store, (data) => data); + + { + using outer = channel.withStoreScope('outer'); + storeValues.push(store.getStore()); + + { + using inner = channel.withStoreScope('inner'); + storeValues.push(store.getStore()); + } + + // Should restore to outer + storeValues.push(store.getStore()); + } + + // Should restore to undefined + storeValues.push(store.getStore()); + + assert.deepStrictEqual(storeValues, ['outer', 'inner', 'outer', undefined]); +} + +// Test RunStoresScope with error during usage +{ + const channel = dc.channel('test-run-stores-scope-error'); + const store = new AsyncLocalStorage(); + + channel.subscribe(() => {}); + channel.bindStore(store, (data) => data); + + store.enterWith('before'); + + const testError = new Error('test'); + + assert.throws(() => { + using scope = channel.withStoreScope('during'); + assert.strictEqual(store.getStore(), 'during'); + throw testError; + }, testError); + + // Store should be restored even after error + assert.strictEqual(store.getStore(), 'before'); +} + +// Test RunStoresScope with inactive channel (no stores or subscribers) +{ + const channel = dc.channel('test-run-stores-scope-inactive'); + + // Channel is inactive (no subscribers or bound stores) + { + using scope = channel.withStoreScope('test'); + // No-op disposable, nothing happens + assert.ok(scope); + } +} + +// Test RunStoresScope with Symbol.dispose +{ + const channel = dc.channel('test-run-stores-scope-symbol'); + const store = new AsyncLocalStorage(); + + channel.subscribe(() => {}); + channel.bindStore(store, (data) => data); + + const scope = channel.withStoreScope('test'); + assert.strictEqual(store.getStore(), 'test'); + + // Call Symbol.dispose directly + scope[Symbol.dispose](); + assert.strictEqual(store.getStore(), undefined); +} diff --git a/test/parallel/test-diagnostics-channel-window-channel-run-transform-error.js b/test/parallel/test-diagnostics-channel-window-channel-run-transform-error.js new file mode 100644 index 00000000000000..f66cdc23b3bc30 --- /dev/null +++ b/test/parallel/test-diagnostics-channel-window-channel-run-transform-error.js @@ -0,0 +1,66 @@ +'use strict'; +const common = require('../common'); +const assert = require('node:assert'); +const dc = require('node:diagnostics_channel'); +const { AsyncLocalStorage } = require('node:async_hooks'); + +// Test WindowChannel.run() with store transform error +// Transform errors are scheduled via process.nextTick(triggerUncaughtException) + +const windowChannel = dc.windowChannel('test-run-transform-error'); +const store = new AsyncLocalStorage(); +const events = []; + +const transformError = new Error('transform failed'); + +// Set up uncaughtException handler to catch the transform error +process.on('uncaughtException', common.mustCall((err) => { + assert.strictEqual(err, transformError); + events.push('uncaughtException'); +})); + +windowChannel.subscribe({ + start(message) { + events.push({ type: 'start', data: message }); + }, + end(message) { + events.push({ type: 'end', data: message }); + }, +}); + +// Bind store with a transform that throws +windowChannel.start.bindStore(store, () => { + throw transformError; +}); + +// Store should remain undefined since transform will fail +assert.strictEqual(store.getStore(), undefined); + +const result = windowChannel.run({ operationId: '123' }, common.mustCall(() => { + // Store should still be undefined because transform threw + assert.strictEqual(store.getStore(), undefined); + + events.push('inside-run'); + + return 42; +})); + +// Should still return the result despite transform error +assert.strictEqual(result, 42); + +// Store should still be undefined after run +assert.strictEqual(store.getStore(), undefined); + +// Start and end events should still be published despite transform error +assert.strictEqual(events.length, 3); +assert.strictEqual(events[0].type, 'start'); +assert.strictEqual(events[0].data.operationId, '123'); +assert.strictEqual(events[1], 'inside-run'); +assert.strictEqual(events[2].type, 'end'); +assert.strictEqual(events[2].data.operationId, '123'); + +// Validate uncaughtException was triggered via nextTick +process.on('beforeExit', common.mustCall(() => { + assert.strictEqual(events.length, 4); + assert.strictEqual(events[3], 'uncaughtException'); +})); diff --git a/test/parallel/test-diagnostics-channel-window-channel-run.js b/test/parallel/test-diagnostics-channel-window-channel-run.js new file mode 100644 index 00000000000000..944348c1887ec0 --- /dev/null +++ b/test/parallel/test-diagnostics-channel-window-channel-run.js @@ -0,0 +1,130 @@ +'use strict'; +require('../common'); +const assert = require('node:assert'); +const dc = require('node:diagnostics_channel'); +const { AsyncLocalStorage } = require('node:async_hooks'); + +// Test basic run functionality +{ + const windowChannel = dc.windowChannel('test-run-basic'); + const events = []; + + windowChannel.subscribe({ + start(message) { + events.push({ type: 'start', data: message }); + }, + end(message) { + events.push({ type: 'end', data: message }); + }, + }); + + const context = { id: 123 }; + const result = windowChannel.run(context, () => { + return 'success'; + }); + + assert.strictEqual(result, 'success'); + assert.strictEqual(context.result, 'success'); + assert.strictEqual(events.length, 2); + assert.strictEqual(events[0].type, 'start'); + assert.strictEqual(events[0].data.id, 123); + assert.strictEqual(events[1].type, 'end'); + assert.strictEqual(events[1].data.id, 123); + assert.strictEqual(events[1].data.result, 'success'); +} + +// Test run with error +{ + const windowChannel = dc.windowChannel('test-run-error'); + const events = []; + + windowChannel.subscribe({ + start(message) { + events.push({ type: 'start', data: message }); + }, + end(message) { + events.push({ type: 'end', data: message }); + }, + }); + + const context = { id: 456 }; + const testError = new Error('test error'); + + assert.throws(() => { + windowChannel.run(context, () => { + throw testError; + }); + }, testError); + + // WindowChannel does not handle errors - they just propagate + // Only start and end events are published + assert.strictEqual(events.length, 2); + assert.strictEqual(events[0].type, 'start'); + assert.strictEqual(events[1].type, 'end'); +} + +// Test run with thisArg and args +{ + const windowChannel = dc.windowChannel('test-run-args'); + + const obj = { value: 10 }; + const result = windowChannel.run({}, function(a, b) { + return this.value + a + b; + }, obj, 5, 15); + + assert.strictEqual(result, 30); +} + +// Test run with AsyncLocalStorage +{ + const windowChannel = dc.windowChannel('test-run-store'); + const store = new AsyncLocalStorage(); + const events = []; + + windowChannel.start.bindStore(store, (context) => { + return { traceId: context.traceId }; + }); + + windowChannel.subscribe({ + start(message) { + events.push({ type: 'start', store: store.getStore() }); + }, + end(message) { + events.push({ type: 'end', store: store.getStore() }); + }, + }); + + const result = windowChannel.run({ traceId: 'abc123' }, () => { + events.push({ type: 'inside', store: store.getStore() }); + return 'result'; + }); + + assert.strictEqual(result, 'result'); + assert.strictEqual(events.length, 3); + + // Start event should have the store set + assert.strictEqual(events[0].type, 'start'); + assert.deepStrictEqual(events[0].store, { traceId: 'abc123' }); + + // Inside function should have the store set + assert.strictEqual(events[1].type, 'inside'); + assert.deepStrictEqual(events[1].store, { traceId: 'abc123' }); + + // End event should have the store set + assert.strictEqual(events[2].type, 'end'); + assert.deepStrictEqual(events[2].store, { traceId: 'abc123' }); + + // Store should be undefined outside + assert.strictEqual(store.getStore(), undefined); +} + +// Test run without subscribers +{ + const windowChannel = dc.windowChannel('test-run-no-subs'); + + const result = windowChannel.run({}, () => { + return 'fast path'; + }); + + assert.strictEqual(result, 'fast path'); +} diff --git a/test/parallel/test-diagnostics-channel-window-channel-scope-error.js b/test/parallel/test-diagnostics-channel-window-channel-scope-error.js new file mode 100644 index 00000000000000..b11e643213a9d3 --- /dev/null +++ b/test/parallel/test-diagnostics-channel-window-channel-scope-error.js @@ -0,0 +1,90 @@ +/* eslint-disable no-unused-vars */ +'use strict'; +require('../common'); +const assert = require('node:assert'); +const dc = require('node:diagnostics_channel'); +const { AsyncLocalStorage } = require('node:async_hooks'); + +// Test scope with thrown error +{ + const windowChannel = dc.windowChannel('test-scope-throw'); + const events = []; + + windowChannel.subscribe({ + start(message) { + events.push({ type: 'start', data: message }); + }, + end(message) { + events.push({ type: 'end', data: message }); + }, + }); + + const context = { id: 1 }; + const testError = new Error('thrown error'); + + assert.throws(() => { + using scope = windowChannel.withScope(context); + context.result = 'partial'; + throw testError; + }, testError); + + // End event should still be published + assert.strictEqual(events.length, 2); + assert.strictEqual(events[0].type, 'start'); + assert.strictEqual(events[1].type, 'end'); + + // Context should have partial result but no error from throw + assert.strictEqual(context.result, 'partial'); + assert.strictEqual(context.error, undefined); +} + +// Test store restoration on error +{ + const windowChannel = dc.windowChannel('test-scope-store-error'); + const store = new AsyncLocalStorage(); + + windowChannel.start.bindStore(store, (context) => context.value); + + windowChannel.subscribe({ + start() {}, + end() {}, + }); + + store.enterWith('before'); + assert.strictEqual(store.getStore(), 'before'); + + const testError = new Error('test'); + + assert.throws(() => { + using scope = windowChannel.withScope({ value: 'during' }); + assert.strictEqual(store.getStore(), 'during'); + throw testError; + }, testError); + + // Store should be restored even after error + assert.strictEqual(store.getStore(), 'before'); +} + +// Test dispose during exception handling +{ + const windowChannel = dc.windowChannel('test-scope-dispose-exception'); + const events = []; + + windowChannel.subscribe({ + start() { + events.push('start'); + }, + end() { + events.push('end'); + }, + }); + + // Dispose should complete even when exception is thrown + assert.throws(() => { + using scope = windowChannel.withScope({}); + throw new Error('original error'); + }, /original error/); + + // End event should have been called + assert.deepStrictEqual(events, ['start', 'end']); +} diff --git a/test/parallel/test-diagnostics-channel-window-channel-scope-nested.js b/test/parallel/test-diagnostics-channel-window-channel-scope-nested.js new file mode 100644 index 00000000000000..21a2004add0f77 --- /dev/null +++ b/test/parallel/test-diagnostics-channel-window-channel-scope-nested.js @@ -0,0 +1,264 @@ +/* eslint-disable no-unused-vars */ +'use strict'; +require('../common'); +const assert = require('node:assert'); +const dc = require('node:diagnostics_channel'); +const { AsyncLocalStorage } = require('node:async_hooks'); + +// Test nested scopes +{ + const windowChannel = dc.windowChannel('test-nested-basic'); + const events = []; + + windowChannel.subscribe({ + start(message) { + events.push({ type: 'start', id: message.id }); + }, + end(message) { + events.push({ type: 'end', id: message.id }); + }, + }); + + { + using outer = windowChannel.withScope({ id: 'outer' }); + events.push({ type: 'work', id: 'outer' }); + + { + using inner = windowChannel.withScope({ id: 'inner' }); + events.push({ type: 'work', id: 'inner' }); + } + + events.push({ type: 'work', id: 'outer-after' }); + } + + assert.strictEqual(events.length, 7); + assert.deepStrictEqual(events[0], { type: 'start', id: 'outer' }); + assert.deepStrictEqual(events[1], { type: 'work', id: 'outer' }); + assert.deepStrictEqual(events[2], { type: 'start', id: 'inner' }); + assert.deepStrictEqual(events[3], { type: 'work', id: 'inner' }); + assert.deepStrictEqual(events[4], { type: 'end', id: 'inner' }); + assert.deepStrictEqual(events[5], { type: 'work', id: 'outer-after' }); + assert.deepStrictEqual(events[6], { type: 'end', id: 'outer' }); +} + +// Test nested scopes with stores +{ + const windowChannel = dc.windowChannel('test-nested-stores'); + const store = new AsyncLocalStorage(); + const storeValues = []; + + windowChannel.start.bindStore(store, (context) => context.id); + + windowChannel.subscribe({ + start() {}, + end() {}, + }); + + assert.strictEqual(store.getStore(), undefined); + + { + using outer = windowChannel.withScope({ id: 'outer' }); + storeValues.push(store.getStore()); + + { + using inner = windowChannel.withScope({ id: 'inner' }); + storeValues.push(store.getStore()); + } + + // Should restore to outer + storeValues.push(store.getStore()); + } + + // Should restore to undefined + storeValues.push(store.getStore()); + + assert.deepStrictEqual(storeValues, ['outer', 'inner', 'outer', undefined]); +} + +// Test nested scopes with different channels +{ + const channel1 = dc.windowChannel('test-nested-chan1'); + const channel2 = dc.windowChannel('test-nested-chan2'); + const events = []; + + channel1.subscribe({ + start(message) { + events.push({ channel: 1, type: 'start', data: message }); + }, + end(message) { + events.push({ channel: 1, type: 'end', data: message }); + }, + }); + + channel2.subscribe({ + start(message) { + events.push({ channel: 2, type: 'start', data: message }); + }, + end(message) { + events.push({ channel: 2, type: 'end', data: message }); + }, + }); + + { + using scope1 = channel1.withScope({ id: 'A' }); + + { + using scope2 = channel2.withScope({ id: 'B' }); + scope2.result = 'B-result'; + } + + scope1.result = 'A-result'; + } + + assert.strictEqual(events.length, 4); + assert.strictEqual(events[0].channel, 1); + assert.strictEqual(events[0].type, 'start'); + assert.strictEqual(events[0].data.id, 'A'); + + assert.strictEqual(events[1].channel, 2); + assert.strictEqual(events[1].type, 'start'); + assert.strictEqual(events[1].data.id, 'B'); + + assert.strictEqual(events[2].channel, 2); + assert.strictEqual(events[2].type, 'end'); + assert.strictEqual(events[2].data.result, 'B-result'); + + assert.strictEqual(events[3].channel, 1); + assert.strictEqual(events[3].type, 'end'); + assert.strictEqual(events[3].data.result, 'A-result'); +} + +// Test nested scopes with shared store +{ + const channel1 = dc.windowChannel('test-nested-shared1'); + const channel2 = dc.windowChannel('test-nested-shared2'); + const store = new AsyncLocalStorage(); + const storeValues = []; + + channel1.start.bindStore(store, (context) => ({ from: 'channel1', ...context })); + channel2.start.bindStore(store, (context) => ({ from: 'channel2', ...context })); + + channel1.subscribe({ start() {}, end() {} }); + channel2.subscribe({ start() {}, end() {} }); + + { + using scope1 = channel1.withScope({ id: 1 }); + storeValues.push({ ...store.getStore() }); + + { + using scope2 = channel2.withScope({ id: 2 }); + storeValues.push({ ...store.getStore() }); + } + + // Should restore to channel1's store value + storeValues.push({ ...store.getStore() }); + } + + assert.strictEqual(storeValues.length, 3); + assert.deepStrictEqual(storeValues[0], { from: 'channel1', id: 1 }); + assert.deepStrictEqual(storeValues[1], { from: 'channel2', id: 2 }); + assert.deepStrictEqual(storeValues[2], { from: 'channel1', id: 1 }); +} + +// Test deeply nested scopes +{ + const windowChannel = dc.windowChannel('test-nested-deep'); + const store = new AsyncLocalStorage(); + const depths = []; + + windowChannel.start.bindStore(store, (context) => context.depth); + + windowChannel.subscribe({ + start() {}, + end() {}, + }); + + { + using s1 = windowChannel.withScope({ depth: 1 }); + depths.push(store.getStore()); + + { + using s2 = windowChannel.withScope({ depth: 2 }); + depths.push(store.getStore()); + + { + using s3 = windowChannel.withScope({ depth: 3 }); + depths.push(store.getStore()); + + { + using s4 = windowChannel.withScope({ depth: 4 }); + depths.push(store.getStore()); + } + + depths.push(store.getStore()); + } + + depths.push(store.getStore()); + } + + depths.push(store.getStore()); + } + + depths.push(store.getStore()); + + assert.deepStrictEqual(depths, [1, 2, 3, 4, 3, 2, 1, undefined]); +} + +// Test nested scopes with errors +{ + const windowChannel = dc.windowChannel('test-nested-error'); + const store = new AsyncLocalStorage(); + const events = []; + + windowChannel.start.bindStore(store, (context) => context.id); + + windowChannel.subscribe({ + start(message) { + events.push({ type: 'start', id: message.id }); + }, + end(message) { + events.push({ type: 'end', id: message.id }); + }, + }); + + const testError = new Error('inner error'); + + assert.throws(() => { + using outer = windowChannel.withScope({ id: 'outer' }); + events.push({ type: 'store', value: store.getStore() }); + + assert.throws(() => { + using inner = windowChannel.withScope({ id: 'inner' }); + events.push({ type: 'store', value: store.getStore() }); + throw testError; + }, testError); + + // After inner error, should be back to outer store + events.push({ type: 'store', value: store.getStore() }); + + throw new Error('outer error'); + }, /outer error/); + + // Both start and end events should have been published for both scopes + assert.strictEqual(events[0].type, 'start'); + assert.strictEqual(events[0].id, 'outer'); + assert.strictEqual(events[1].type, 'store'); + assert.strictEqual(events[1].value, 'outer'); + + assert.strictEqual(events[2].type, 'start'); + assert.strictEqual(events[2].id, 'inner'); + assert.strictEqual(events[3].type, 'store'); + assert.strictEqual(events[3].value, 'inner'); + + assert.strictEqual(events[4].type, 'end'); + assert.strictEqual(events[4].id, 'inner'); + + assert.strictEqual(events[5].type, 'store'); + assert.strictEqual(events[5].value, 'outer'); + + assert.strictEqual(events[6].type, 'end'); + assert.strictEqual(events[6].id, 'outer'); + + // Store should be restored + assert.strictEqual(store.getStore(), undefined); +} diff --git a/test/parallel/test-diagnostics-channel-window-channel-scope-transform-error.js b/test/parallel/test-diagnostics-channel-window-channel-scope-transform-error.js new file mode 100644 index 00000000000000..d181d757869978 --- /dev/null +++ b/test/parallel/test-diagnostics-channel-window-channel-scope-transform-error.js @@ -0,0 +1,66 @@ +'use strict'; +const common = require('../common'); +const assert = require('node:assert'); +const dc = require('node:diagnostics_channel'); +const { AsyncLocalStorage } = require('node:async_hooks'); + +// Test WindowChannelScope with transform error +// Transform errors are scheduled via process.nextTick(triggerUncaughtException) + +const windowChannel = dc.windowChannel('test-transform-error'); +const store = new AsyncLocalStorage(); +const events = []; + +const transformError = new Error('transform failed'); + +// Set up uncaughtException handler to catch the transform error +process.on('uncaughtException', common.mustCall((err) => { + assert.strictEqual(err, transformError); + events.push('uncaughtException'); +})); + +windowChannel.subscribe({ + start(message) { + events.push({ type: 'start', data: message }); + }, + end(message) { + events.push({ type: 'end', data: message }); + }, +}); + +// Bind store with a transform that throws +windowChannel.start.bindStore(store, () => { + throw transformError; +}); + +// Store should remain undefined since transform will fail +assert.strictEqual(store.getStore(), undefined); + +const context = { id: 123 }; +{ + // eslint-disable-next-line no-unused-vars + using scope = windowChannel.withScope(context); + + // Store should still be undefined because transform threw + assert.strictEqual(store.getStore(), undefined); + + events.push('inside-scope'); + context.result = 42; +} + +// Store should still be undefined after scope exit +assert.strictEqual(store.getStore(), undefined); + +// Start and end events should still be published despite transform error +assert.strictEqual(events.length, 3); +assert.strictEqual(events[0].type, 'start'); +assert.strictEqual(events[0].data.id, 123); +assert.strictEqual(events[1], 'inside-scope'); +assert.strictEqual(events[2].type, 'end'); +assert.strictEqual(events[2].data.result, 42); + +// Validate uncaughtException was triggered via nextTick +process.on('beforeExit', common.mustCall(() => { + assert.strictEqual(events.length, 4); + assert.strictEqual(events[3], 'uncaughtException'); +})); diff --git a/test/parallel/test-diagnostics-channel-window-channel-scope.js b/test/parallel/test-diagnostics-channel-window-channel-scope.js new file mode 100644 index 00000000000000..27fbd9f0367d65 --- /dev/null +++ b/test/parallel/test-diagnostics-channel-window-channel-scope.js @@ -0,0 +1,206 @@ +/* eslint-disable no-unused-vars */ +'use strict'; +require('../common'); +const assert = require('node:assert'); +const dc = require('node:diagnostics_channel'); +const { AsyncLocalStorage } = require('node:async_hooks'); + +// Test basic scope with using +{ + const windowChannel = dc.windowChannel('test-scope-basic'); + const events = []; + + windowChannel.subscribe({ + start({ ...data }) { + events.push({ type: 'start', data }); + }, + end({ ...data }) { + events.push({ type: 'end', data }); + }, + }); + + const context = { id: 123 }; + + { + using scope = windowChannel.withScope(context); + assert.ok(scope); + context.value = 'inside'; + } + + assert.strictEqual(events.length, 2); + assert.deepStrictEqual(events, [ + { + type: 'start', + data: { id: 123 } + }, + { + type: 'end', + data: { + id: 123, + value: 'inside' + } + }, + ]); +} + +// Test scope with result setter +{ + const windowChannel = dc.windowChannel('test-scope-result'); + const events = []; + + windowChannel.subscribe({ + start(message) { + events.push({ type: 'start', data: message }); + }, + end(message) { + events.push({ type: 'end', data: message }); + }, + }); + + const context = {}; + + { + using scope = windowChannel.withScope(context); + context.result = 42; + } + + assert.strictEqual(context.result, 42); + assert.strictEqual(events.length, 2); + assert.strictEqual(events[1].data.result, 42); +} + +// Test scope with error setter +{ + const windowChannel = dc.windowChannel('test-scope-error-setter'); + const events = []; + + windowChannel.subscribe({ + start(message) { + events.push({ type: 'start', data: message }); + }, + end(message) { + events.push({ type: 'end', data: message }); + }, + }); + + const context = {}; + + { + using scope = windowChannel.withScope(context); + context.result = 'test result'; + } + + // WindowChannel does not handle errors - only start and end + assert.strictEqual(context.result, 'test result'); + assert.strictEqual(events.length, 2); + assert.strictEqual(events[0].type, 'start'); + assert.strictEqual(events[1].type, 'end'); +} + +// Test scope with AsyncLocalStorage +{ + const windowChannel = dc.windowChannel('test-scope-store'); + const store = new AsyncLocalStorage(); + const events = []; + + windowChannel.start.bindStore(store, (context) => { + return { traceId: context.traceId }; + }); + + windowChannel.subscribe({ + start(message) { + events.push({ type: 'start', store: store.getStore() }); + }, + end(message) { + events.push({ type: 'end', store: store.getStore() }); + }, + }); + + assert.strictEqual(store.getStore(), undefined); + + { + using scope = windowChannel.withScope({ traceId: 'xyz789' }); + + // Store should be set inside scope + assert.deepStrictEqual(store.getStore(), { traceId: 'xyz789' }); + + events.push({ type: 'inside', store: store.getStore() }); + } + + // Store should be restored after scope + assert.strictEqual(store.getStore(), undefined); + + assert.strictEqual(events.length, 3); + assert.strictEqual(events[0].type, 'start'); + assert.deepStrictEqual(events[0].store, { traceId: 'xyz789' }); + assert.strictEqual(events[1].type, 'inside'); + assert.deepStrictEqual(events[1].store, { traceId: 'xyz789' }); + assert.strictEqual(events[2].type, 'end'); + assert.deepStrictEqual(events[2].store, { traceId: 'xyz789' }); +} + +// Test scope without subscribers (no-op) +{ + const windowChannel = dc.windowChannel('test-scope-no-subs'); + + const context = {}; + + { + using scope = windowChannel.withScope(context); + context.result = 'value'; + } + + // Context should still be updated even without subscribers + assert.strictEqual(context.result, 'value'); +} + +// Test manual dispose via Symbol.dispose +{ + const windowChannel = dc.windowChannel('test-scope-manual'); + const events = []; + + windowChannel.subscribe({ + start(message) { + events.push('start'); + }, + end(message) { + events.push('end'); + }, + }); + + const scope = windowChannel.withScope({}); + assert.strictEqual(events.length, 1); + assert.strictEqual(events[0], 'start'); + + scope[Symbol.dispose](); + assert.strictEqual(events.length, 2); + assert.strictEqual(events[1], 'end'); + + // Double dispose should be idempotent + scope[Symbol.dispose](); + assert.strictEqual(events.length, 2); +} + +// Test scope with store restore to previous value +{ + const windowChannel = dc.windowChannel('test-scope-restore'); + const store = new AsyncLocalStorage(); + + windowChannel.start.bindStore(store, (context) => context.value); + + windowChannel.subscribe({ + start() {}, + end() {}, + }); + + store.enterWith('initial'); + assert.strictEqual(store.getStore(), 'initial'); + + { + using scope = windowChannel.withScope({ value: 'scoped' }); + assert.strictEqual(store.getStore(), 'scoped'); + } + + // Should restore to previous value + assert.strictEqual(store.getStore(), 'initial'); +} diff --git a/test/parallel/test-diagnostics-channel-window-channel.js b/test/parallel/test-diagnostics-channel-window-channel.js new file mode 100644 index 00000000000000..6a63bffc5fbf81 --- /dev/null +++ b/test/parallel/test-diagnostics-channel-window-channel.js @@ -0,0 +1,105 @@ +'use strict'; +require('../common'); +const assert = require('node:assert'); +const dc = require('node:diagnostics_channel'); + +// Test WindowChannel exports +{ + assert.strictEqual(typeof dc.windowChannel, 'function'); + assert.strictEqual(typeof dc.WindowChannel, 'function'); + + const wc = dc.windowChannel('test-export'); + assert.ok(wc instanceof dc.WindowChannel); +} + +// Test basic WindowChannel creation and properties +{ + const windowChannel = dc.windowChannel('test-window-basic'); + + assert.ok(windowChannel.start); + assert.ok(windowChannel.end); + + assert.strictEqual(windowChannel.start.name, 'tracing:test-window-basic:start'); + assert.strictEqual(windowChannel.end.name, 'tracing:test-window-basic:end'); + + assert.strictEqual(windowChannel.hasSubscribers, false); + + assert.strictEqual(typeof windowChannel.subscribe, 'function'); + assert.strictEqual(typeof windowChannel.unsubscribe, 'function'); + assert.strictEqual(typeof windowChannel.run, 'function'); + assert.strictEqual(typeof windowChannel.withScope, 'function'); +} + +// Test WindowChannel with channel objects +{ + const startChannel = dc.channel('custom:start'); + const endChannel = dc.channel('custom:end'); + + const windowChannel = dc.windowChannel({ + start: startChannel, + end: endChannel, + }); + + assert.strictEqual(windowChannel.start, startChannel); + assert.strictEqual(windowChannel.end, endChannel); +} + +// Test subscribe/unsubscribe +{ + const windowChannel = dc.windowChannel('test-window-subscribe'); + const events = []; + + const handlers = { + start(message) { + events.push({ type: 'start', message }); + }, + end(message) { + events.push({ type: 'end', message }); + }, + }; + + assert.strictEqual(windowChannel.hasSubscribers, false); + + windowChannel.subscribe(handlers); + + assert.strictEqual(windowChannel.hasSubscribers, true); + + // Test that events are received + windowChannel.start.publish({ test: 'start' }); + windowChannel.end.publish({ test: 'end' }); + + assert.strictEqual(events.length, 2); + assert.strictEqual(events[0].type, 'start'); + assert.strictEqual(events[0].message.test, 'start'); + assert.strictEqual(events[1].type, 'end'); + assert.strictEqual(events[1].message.test, 'end'); + + // Test unsubscribe + const result = windowChannel.unsubscribe(handlers); + assert.strictEqual(result, true); + assert.strictEqual(windowChannel.hasSubscribers, false); + + // Test unsubscribe when not subscribed + const result2 = windowChannel.unsubscribe(handlers); + assert.strictEqual(result2, false); +} + +// Test partial subscription +{ + const windowChannel = dc.windowChannel('test-window-partial'); + const events = []; + + windowChannel.subscribe({ + start(message) { + events.push('start'); + }, + }); + + assert.strictEqual(windowChannel.hasSubscribers, true); + + windowChannel.start.publish({}); + windowChannel.end.publish({}); + + assert.strictEqual(events.length, 1); + assert.strictEqual(events[0], 'start'); +} diff --git a/tools/doc/type-parser.mjs b/tools/doc/type-parser.mjs index db85a64e33a903..55e69f1e0ba4e0 100644 --- a/tools/doc/type-parser.mjs +++ b/tools/doc/type-parser.mjs @@ -65,6 +65,7 @@ const customTypesMap = { 'https://tc39.github.io/ecma262/#sec-module-namespace-exotic-objects', 'AsyncLocalStorage': 'async_context.html#class-asynclocalstorage', + 'RunScope': 'async_context.html#class-runscope', 'AsyncHook': 'async_hooks.html#async_hookscreatehookoptions', 'AsyncResource': 'async_hooks.html#class-asyncresource', @@ -133,6 +134,9 @@ const customTypesMap = { 'Channel': 'diagnostics_channel.html#class-channel', 'TracingChannel': 'diagnostics_channel.html#class-tracingchannel', + 'WindowChannel': 'diagnostics_channel.html#class-windowchannel', + 'WindowChannelScope': 'diagnostics_channel.html#class-windowchannelscope', + 'RunStoresScope': 'diagnostics_channel.html#class-runstoresscope', 'DatabaseSync': 'sqlite.html#class-databasesync', 'Domain': 'domain.html#class-domain',