diff --git a/src/js/builtins/CommonJS.ts b/src/js/builtins/CommonJS.ts index 7e2bf3eaa432..105ba4fe7c35 100644 --- a/src/js/builtins/CommonJS.ts +++ b/src/js/builtins/CommonJS.ts @@ -7,9 +7,16 @@ export function main() { // This function is bound when constructing instances of CommonJSModule $visibility = "Private"; -export function require(this: JSCommonJSModule, _: string) { +export function require(this: JSCommonJSModule, id: string, options: { paths?: string[] } | undefined = undefined) { // Do not use $tailCallForwardArguments here, it causes https://github.com/oven-sh/bun/issues/9225 - return $overridableRequire.$apply(this, arguments); + // $call with the named params avoids materializing an `arguments` object on + // every require(). The 1-arg form is forwarded as 1 arg so overridableRequire's + // $argumentCount() still distinguishes require(id) from require(id, options) + // and the native $require keeps its skip-options fast path. + if (options === undefined) { + return $overridableRequire.$call(this, id); + } + return $overridableRequire.$call(this, id, options); } // overridableRequire can be overridden by setting `Module.prototype.require` diff --git a/src/js/internal/http1_server_fallback.ts b/src/js/internal/http1_server_fallback.ts index f95a2f4c908a..0842017b7634 100644 --- a/src/js/internal/http1_server_fallback.ts +++ b/src/js/internal/http1_server_fallback.ts @@ -7,6 +7,29 @@ const { SafeSet } = require("internal/primordials"); const kHttp1Connections = Symbol("http1Connections"); const kHttp1ActiveRequests = Symbol("http1ActiveRequests"); +// dc.channel() returns the per-name singleton, so these are the same channel +// objects _http_server.ts publishes on from the Bun.serve-backed path; in +// Node both entry paths converge on parserOnIncoming, which publishes all +// three. +let onServerRequestStartChannel, onServerResponseCreatedChannel, onServerResponseFinishChannel; +function initHttp1FallbackChannels() { + const dc = require("node:diagnostics_channel"); + onServerRequestStartChannel = dc.channel("http.server.request.start"); + onServerResponseCreatedChannel = dc.channel("http.server.response.created"); + onServerResponseFinishChannel = dc.channel("http.server.response.finish"); +} + +function publishHttp1FallbackResponseFinish(this: any) { + if (!onServerResponseFinishChannel.hasSubscribers) return; + const socket = this.req?.socket ?? this.socket; + onServerResponseFinishChannel.publish({ + request: this.req, + response: this, + socket, + server: socket?.server, + }); +} + function createHttp1FallbackResponseHandle(socket, shouldKeepAlive, keepAliveTimeout) { const { _checkInvalidHeaderChar: checkInvalidHeaderChar } = require("node:_http_common"); let head = null; @@ -334,6 +357,26 @@ function connectionListenerHTTP1(server, socket, options) { this.detachSocket(socket); }); + // Accepted upgrades returned early above, so like the native dispatch + // path (and Node's parserOnIncoming) these fire for normal requests and + // for declined upgrades that fall through to 'request'. + if (!onServerResponseCreatedChannel) initHttp1FallbackChannels(); + if (onServerResponseCreatedChannel.hasSubscribers) { + onServerResponseCreatedChannel.publish({ + request: req, + response: res, + }); + } + if (onServerRequestStartChannel.hasSubscribers) { + onServerRequestStartChannel.publish({ + request: req, + response: res, + socket, + server, + }); + } + res.on("finish", publishHttp1FallbackResponseFinish); + // Node's parserOnIncoming Expect routing (the native dispatcher applies the // same at _http_server.ts's DISPATCH_HAS_EXPECT branch). const expect = req.headers.expect; diff --git a/src/js/internal/module_tracing.ts b/src/js/internal/module_tracing.ts new file mode 100644 index 000000000000..6aaf0363aca2 --- /dev/null +++ b/src/js/internal/module_tracing.ts @@ -0,0 +1,63 @@ +const setHasModuleImportSubscribers = $newCppFunction( + "NodeDiagnosticsChannel.cpp", + "jsSetHasModuleImportSubscribers", + 1, +); + +let requireChannel; +let importChannel; +let baseRequire; +let requireWrapped = false; + +function tracingRequire(this: any, originalId: string, options?: { paths?: string[] }) { + if (requireChannel !== undefined && requireChannel.hasSubscribers) { + return requireChannel.traceSync( + baseRequire, + { __proto__: null, parentFilename: this.filename, id: originalId }, + this, + originalId, + options, + ); + } + return baseRequire.$call(this, originalId, options); +} +Object.defineProperty(tracingRequire, "name", { value: "require" }); + +function onRequireSubscribersChanged() { + const has = requireChannel !== undefined && requireChannel.hasSubscribers; + if (has === requireWrapped) return; + const Module = require("node:module"); + if (has) { + baseRequire ??= Module.prototype.require; + Module.prototype.require = tracingRequire; + requireWrapped = true; + } else { + if (Module.prototype.require === tracingRequire) { + Module.prototype.require = baseRequire; + } + requireWrapped = false; + } +} + +function onImportSubscribersChanged() { + setHasModuleImportSubscribers(importChannel !== undefined && importChannel.hasSubscribers); +} + +const moduleTracing = { + traceImport(doImport, parentURL, url) { + if (importChannel === undefined || !importChannel.hasSubscribers) { + return doImport(); + } + return importChannel.tracePromise(doImport, { __proto__: null, parentURL, url }); + }, + install(requireCh, importCh, hookSubscriberChange) { + requireChannel = requireCh; + importChannel = importCh; + hookSubscriberChange(requireCh, onRequireSubscribersChanged); + hookSubscriberChange(importCh, onImportSubscribersChanged); + onRequireSubscribersChanged(); + onImportSubscribersChanged(); + }, +}; + +export default moduleTracing; diff --git a/src/js/node/_http_server.ts b/src/js/node/_http_server.ts index 7ea18c049190..af15ae49db34 100644 --- a/src/js/node/_http_server.ts +++ b/src/js/node/_http_server.ts @@ -21,6 +21,16 @@ const { const { ConnResetException, hasObserver, startPerf, stopPerf } = require("internal/shared"); const kServerResponseStatistics = Symbol("ServerResponseStatistics"); +let onServerRequestStartChannel, onServerResponseCreatedChannel, onServerResponseFinishChannel; +let netServerListenChannel; +function initHttpServerChannels() { + const dc = require("node:diagnostics_channel"); + onServerRequestStartChannel = dc.channel("http.server.request.start"); + onServerResponseCreatedChannel = dc.channel("http.server.response.created"); + onServerResponseFinishChannel = dc.channel("http.server.response.finish"); + netServerListenChannel = dc.tracingChannel("net.server.listen"); +} + const { isPrimary } = require("internal/cluster/isPrimary"); const { throwOnInvalidTLSArray, @@ -631,6 +641,14 @@ Server.prototype.listen = function () { onListen = lastArg; } + if (!netServerListenChannel) initHttpServerChannels(); + if (netServerListenChannel.hasSubscribers) { + const arg0 = arguments[0]; + const options = + typeof arg0 === "object" && arg0 !== null ? arg0 : socketPath != null ? { path: socketPath } : { port, host }; + netServerListenChannel.asyncStart.publish({ server: this, options }); + } + try { // listenInCluster @@ -677,6 +695,9 @@ Server.prototype.listen = function () { server[kRealListen](tls, port, host, socketPath, true, onListen); } catch (err) { + if (netServerListenChannel.hasSubscribers) { + netServerListenChannel.error.publish({ server: this, error: err }); + } setTimeout(() => server.emit("error", err), 1); } @@ -943,6 +964,26 @@ Server.prototype[kRealListen] = function (tls, port, host, socketPath, reusePort // 'upgrade' listener and false for a declined upgrade that falls // through to 'request'. http_req.upgrade = is_upgrade; + + if (!is_upgrade) { + if (!onServerResponseCreatedChannel) initHttpServerChannels(); + if (onServerResponseCreatedChannel.hasSubscribers) { + onServerResponseCreatedChannel.publish({ + request: http_req, + response: http_res, + }); + } + if (onServerRequestStartChannel.hasSubscribers) { + onServerRequestStartChannel.publish({ + request: http_req, + response: http_res, + socket, + server, + }); + } + http_res.on("finish", publishServerResponseFinish); + } + if (isPipelined) { // A previous response on this connection has not finished yet: like // Node.js, this response is queued (res.socket === null) and its @@ -1129,6 +1170,10 @@ Server.prototype[kRealListen] = function (tls, port, host, socketPath, reusePort isHTTPS = this[serverSymbol].protocol === "https"; applyServerCustomOptions(this); + if (netServerListenChannel.hasSubscribers) { + netServerListenChannel.asyncEnd.publish({ server: this }); + } + if (this?._unref) { this[serverSymbol]?.unref?.(); } @@ -2158,6 +2203,17 @@ function _writeHead(statusCode, reason, obj, response) { Object.defineProperty(NodeHTTPServerSocket, "name", { value: "Socket" }); +function publishServerResponseFinish(this: any) { + if (!onServerResponseFinishChannel.hasSubscribers) return; + const socket = this.req?.socket ?? this.socket; + onServerResponseFinishChannel.publish({ + request: this.req, + response: this, + socket, + server: socket?.server, + }); +} + function ServerResponse(req, options): void { if (!(this instanceof ServerResponse)) return new ServerResponse(req, options); OutgoingMessage.$call(this, options); diff --git a/src/js/node/child_process.ts b/src/js/node/child_process.ts index 4af87463bd8f..d34b96281c86 100644 --- a/src/js/node/child_process.ts +++ b/src/js/node/child_process.ts @@ -22,6 +22,13 @@ var BufferIsEncoding = Buffer.isEncoding; var kEmptyObject = ObjectCreate(null); var signals = OsModule.constants.signals; +let childProcessChannel, childProcessSpawn; +function initChildProcessChannels() { + const dc = require("node:diagnostics_channel"); + childProcessChannel = dc.channel("child_process"); + childProcessSpawn = dc.tracingChannel("child_process.spawn"); +} + var ArrayPrototypeJoin = Array.prototype.join; var ArrayPrototypeIncludes = Array.prototype.includes; var ArrayPrototypeSlice = Array.prototype.slice; @@ -1107,6 +1114,16 @@ class ChildProcess extends EventEmitter { channel; killed = false; + constructor() { + super(); + if (!childProcessChannel) initChildProcessChannels(); + if (childProcessChannel.hasSubscribers) { + childProcessChannel.publish({ + process: this, + }); + } + } + [Symbol.dispose]() { if (!this.killed) { this.kill(); @@ -1401,6 +1418,10 @@ class ChildProcess extends EventEmitter { // Bun.spawn() expects cmd[0] to be the command to run, and argv0 to replace the first arg when running the command, // so we have to set argv0 to spawnargs[0] and cmd[0] to file + if (childProcessSpawn.hasSubscribers) { + childProcessSpawn.start.publish({ process: this, options }); + } + try { this.#handle = Bun.spawn({ cmd: [file, ...Array.prototype.slice.$call(spawnargs, 1)], @@ -1441,6 +1462,10 @@ class ChildProcess extends EventEmitter { $debug("ChildProcess: spawn", this.pid, spawnargs); + if (childProcessSpawn.hasSubscribers) { + childProcessSpawn.end.publish({ process: this }); + } + process.nextTick(() => { this.emit("spawn"); }); @@ -1478,6 +1503,9 @@ class ChildProcess extends EventEmitter { this.#handle = null; ex.syscall = "spawn " + this.spawnfile; ex.spawnargs = Array.prototype.slice.$call(this.spawnargs, 1); + if (childProcessSpawn.hasSubscribers) { + childProcessSpawn.error.publish({ process: this, error: ex }); + } process.nextTick(() => { this.emit("error", ex); this.emit("close", (ex as SystemError).errno ?? -1); @@ -1494,6 +1522,9 @@ class ChildProcess extends EventEmitter { // synchronously, with `syscall: "spawn"` (no file appended). ex.syscall = "spawn"; } + if (childProcessSpawn.hasSubscribers) { + childProcessSpawn.error.publish({ process: this, error: ex }); + } throw ex; } } diff --git a/src/js/node/diagnostics_channel.ts b/src/js/node/diagnostics_channel.ts index 4c26ff1017fe..21da1d3b201c 100644 --- a/src/js/node/diagnostics_channel.ts +++ b/src/js/node/diagnostics_channel.ts @@ -1,35 +1,48 @@ // Hardcoded module "node:diagnostics_channel" -// Reference: https://github.com/nodejs/node/blob/fb47afc335ef78a8cef7eac52b8ee7f045300696/lib/diagnostics_channel.js +// Reference: https://github.com/nodejs/node/blob/v26.3.0/lib/diagnostics_channel.js const { validateFunction } = require("internal/validators"); const SafeMap = Map; const SafeFinalizationRegistry = FinalizationRegistry; +const SafeDisposableStack = DisposableStack; const ArrayPrototypeAt = Array.prototype.at; const ArrayPrototypeIndexOf = Array.prototype.indexOf; +const ArrayPrototypePush = Array.prototype.push; +const ArrayPrototypeSlice = Array.prototype.slice; const ArrayPrototypeSplice = Array.prototype.splice; +const ObjectDefineProperty = Object.defineProperty; const ObjectGetPrototypeOf = Object.getPrototypeOf; const ObjectSetPrototypeOf = Object.setPrototypeOf; -const SymbolHasInstance = Symbol.hasInstance; -const PromiseResolve = Promise.$resolve.bind(Promise); +const PromisePrototypeThen = Promise.prototype.then; const PromiseReject = Promise.$reject.bind(Promise); -const PromisePrototypeThen = (promise, onFulfilled, onRejected) => promise.then(onFulfilled, onRejected); +const SymbolDispose = Symbol.dispose; +const SymbolHasInstance = Symbol.hasInstance; -// TODO: https://github.com/nodejs/node/blob/fb47afc335ef78a8cef7eac52b8ee7f045300696/src/node_util.h#L13 -class WeakReference extends WeakRef { +class WeakReference { + #weak: WeakRef; + #strong: T | undefined = undefined; #refs = 0; + constructor(value: T) { + this.#weak = new WeakRef(value); + } + get() { - return this.deref(); + return this.#strong ?? this.#weak.deref(); } incRef() { - return ++this.#refs; + this.#refs++; + if (this.#refs === 1) this.#strong = this.#weak.deref(); + return this.#refs; } decRef() { - return --this.#refs; + this.#refs--; + if (this.#refs === 0) this.#strong = undefined; + return this.#refs; } } @@ -37,24 +50,30 @@ class WeakReference extends WeakRef { // Only GC can be used as a valid time to clean up the channels map. class WeakRefMap extends SafeMap { #finalizers = new SafeFinalizationRegistry(key => { - this.delete(key); + // Check that the key doesn't have any value before deleting, as the WeakRef for the key + // may have been replaced since finalization callbacks aren't synchronous with GC. + if (!this.has(key)) this.$delete(key); }); set(key, value) { this.#finalizers.register(value, key); - return super.set(key, new WeakReference(value)); + return this.$set(key, new WeakReference(value)); } get(key) { - return super.get(key)?.get(); + return this.$get(key)?.get(); + } + + has(key) { + return !!this.get(key); } incRef(key) { - return super.get(key)?.incRef(); + return this.$get(key)?.incRef(); } decRef(key) { - return super.get(key)?.decRef(); + return this.$get(key)?.decRef(); } } @@ -73,22 +92,48 @@ function maybeMarkInactive(channel) { } } -function defaultTransform(data) { - return data; -} +class RunStoresScope { + #stack; + + constructor(activeChannel, data) { + const stack = new SafeDisposableStack(); + const stores = activeChannel._stores; -function wrapStoreRun(store, data, next, transform = defaultTransform) { - return () => { - let context; try { - context = transform(data); + // Enter stores using withScope + if (stores) { + for (const entry of stores.entries()) { + const store = entry[0]; + const transform = entry[1]; + + let newContext = data; + if (transform) { + try { + newContext = transform(data); + } catch (err) { + process.nextTick(() => reportError(err)); + continue; + } + } + + stack.use(store.withScope(newContext)); + } + } + + // Publish data + activeChannel.publish(data); } catch (err) { - process.nextTick(() => reportError(err)); - return next(); + stack.dispose(); + throw err; } - return store.run(context, next); - }; + // Transfer ownership of the stack + this.#stack = stack.move(); + } + + [SymbolDispose]() { + this.#stack[SymbolDispose](); + } } class ActiveChannel { @@ -98,38 +143,45 @@ class ActiveChannel { subscribe(subscription) { validateFunction(subscription, "subscription"); - - $arrayPush(this._subscribers, subscription); + this._subscribers = ArrayPrototypeSlice.$call(this._subscribers); + ArrayPrototypePush.$call(this._subscribers, subscription); channels.incRef(this.name); + this._onSubscribersChanged?.(); } unsubscribe(subscription) { const index = ArrayPrototypeIndexOf.$call(this._subscribers, subscription); if (index === -1) return false; - ArrayPrototypeSplice.$call(this._subscribers, index, 1); + const before = ArrayPrototypeSlice.$call(this._subscribers, 0, index); + const after = ArrayPrototypeSlice.$call(this._subscribers, index + 1); + this._subscribers = before; + ArrayPrototypePush.$apply(this._subscribers, after); channels.decRef(this.name); maybeMarkInactive(this); + this._onSubscribersChanged?.(); return true; } bindStore(store, transform) { - const replacing = this._stores.has(store); + const replacing = this._stores.$has(store); if (!replacing) channels.incRef(this.name); - this._stores.set(store, transform); + this._stores.$set(store, transform); + this._onSubscribersChanged?.(); } unbindStore(store) { - if (!this._stores.has(store)) { + if (!this._stores.$has(store)) { return false; } - this._stores.delete(store); + this._stores.$delete(store); channels.decRef(this.name); maybeMarkInactive(this); + this._onSubscribersChanged?.(); return true; } @@ -139,9 +191,10 @@ class ActiveChannel { } publish(data) { - for (let i = 0; i < (this._subscribers?.length || 0); i++) { + const subscribers = this._subscribers; + for (let i = 0; i < (subscribers?.length || 0); i++) { try { - const onMessage = this._subscribers[i]; + const onMessage = subscribers[i]; onMessage(data, this.name); } catch (err) { process.nextTick(() => reportError(err)); @@ -149,36 +202,39 @@ class ActiveChannel { } } + withStoreScope(data) { + return new RunStoresScope(this, data); + } + runStores(data, fn, thisArg, ...args) { - let run = () => { - this.publish(data); + const scope = this.withStoreScope(data); + try { return fn.$apply(thisArg, args); - }; - - for (const entry of this._stores.entries()) { - const store = entry[0]; - const transform = entry[1]; - run = wrapStoreRun(store, data, run, transform); + } finally { + scope[SymbolDispose](); } - - return run(); } } class Channel { _subscribers; _stores; + _onSubscribersChanged; name; constructor(name) { this._subscribers = undefined; this._stores = undefined; + this._onSubscribersChanged = undefined; this.name = name; channels.set(name, this); } static [SymbolHasInstance](instance) { + if (instance == null) { + throw new TypeError("Cannot convert undefined or null to object"); + } const prototype = ObjectGetPrototypeOf.$call(null, instance); return prototype === Channel.prototype || prototype === ActiveChannel.prototype; } @@ -210,6 +266,13 @@ class Channel { runStores(data, fn, thisArg, ...args) { return fn.$apply(thisArg, args); } + + withStoreScope() { + // Return no-op disposable for inactive channels + return { + [SymbolDispose]() {}, + }; + } } const channels = new WeakRefMap(); @@ -240,49 +303,85 @@ function hasSubscribers(name) { return channel.hasSubscribers; } -const traceEvents = ["start", "end", "asyncStart", "asyncEnd", "error"]; +const boundedEvents = ["start", "end"]; function assertChannel(value, name) { if (!(value instanceof Channel)) { - throw $ERR_INVALID_ARG_TYPE(name, ["Channel"], value); + throw $ERR_INVALID_ARG_TYPE(name, "instance of Channel", value); } } -class TracingChannel { - start; - end; - asyncStart; - asyncEnd; - error; +function emitNonThenableWarning(fn) { + process.emitWarning( + `tracePromise was called with the function '${fn.name || ""}', ` + "which returned a non-thenable.", + ); +} + +function channelFromMap(nameOrChannels, name, className) { + if (typeof nameOrChannels === "string") { + return channel(`tracing:${nameOrChannels}:${name}`); + } + + if (typeof nameOrChannels === "object" && nameOrChannels !== null) { + const channel = nameOrChannels[name]; + assertChannel(channel, `nameOrChannels.${name}`); + return channel; + } + + throw $ERR_INVALID_ARG_TYPE("nameOrChannels", `string or an instance of ${className} or Object`, nameOrChannels); +} + +class BoundedChannelScope { + #context; + #end; + #scope; + + constructor(boundedChannel, context) { + // Only proceed if there are subscribers + if (!boundedChannel.hasSubscribers) { + return; + } + + const { start, end } = boundedChannel; + 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](); + this.#scope = undefined; + } +} +class BoundedChannel { constructor(nameOrChannels) { - if (typeof nameOrChannels === "string") { - this.start = channel(`tracing:${nameOrChannels}:start`); - this.end = channel(`tracing:${nameOrChannels}:end`); - this.asyncStart = channel(`tracing:${nameOrChannels}:asyncStart`); - this.asyncEnd = channel(`tracing:${nameOrChannels}:asyncEnd`); - this.error = channel(`tracing:${nameOrChannels}:error`); - } else if (typeof nameOrChannels === "object") { - const { start, end, asyncStart, asyncEnd, error } = nameOrChannels; - - assertChannel(start, "nameOrChannels.start"); - assertChannel(end, "nameOrChannels.end"); - assertChannel(asyncStart, "nameOrChannels.asyncStart"); - assertChannel(asyncEnd, "nameOrChannels.asyncEnd"); - assertChannel(error, "nameOrChannels.error"); - - this.start = start; - this.end = end; - this.asyncStart = asyncStart; - this.asyncEnd = asyncEnd; - this.error = error; - } else { - throw $ERR_INVALID_ARG_TYPE("nameOrChannels", ["string, object, or Channel"], nameOrChannels); + for (let i = 0; i < boundedEvents.length; ++i) { + const eventName = boundedEvents[i]; + ObjectDefineProperty.$call(null, this, eventName, { + __proto__: null, + value: channelFromMap(nameOrChannels, eventName, "BoundedChannel"), + }); } } + get hasSubscribers() { + return this.start?.hasSubscribers || this.end?.hasSubscribers; + } + subscribe(handlers) { - for (const name of traceEvents) { + for (let i = 0; i < boundedEvents.length; ++i) { + const name = boundedEvents[i]; if (!handlers[name]) continue; this[name]?.subscribe(handlers[name]); @@ -292,7 +391,8 @@ class TracingChannel { unsubscribe(handlers) { let done = true; - for (const name of traceEvents) { + for (let i = 0; i < boundedEvents.length; ++i) { + const name = boundedEvents[i]; if (!handlers[name]) continue; if (!this[name]?.unsubscribe(handlers[name])) { @@ -303,64 +403,218 @@ class TracingChannel { return done; } - traceSync(fn, context = {}, thisArg, ...args) { - const { start, end, error } = this; + withScope(context = {}) { + return new BoundedChannelScope(this, context); + } - return start.runStores(context, () => { - try { - const result = fn.$apply(thisArg, args); - context.result = result; - return result; - } catch (err) { - context.error = err; - error.publish(context); - throw err; - } finally { - end.publish(context); - } + run(context, fn, thisArg, ...args) { + context ??= {}; + const scope = this.withScope(context); + try { + return fn.$apply(thisArg, args); + } finally { + scope[SymbolDispose](); + } + } +} + +function boundedChannel(nameOrChannels) { + return new BoundedChannel(nameOrChannels); +} + +class TracingChannel { + #callWindow; + #continuationWindow; + + constructor(nameOrChannels) { + // Create a BoundedChannel for start/end (call window) + if (typeof nameOrChannels === "string") { + this.#callWindow = new BoundedChannel(nameOrChannels); + this.#continuationWindow = new BoundedChannel({ + start: channel(`tracing:${nameOrChannels}:asyncStart`), + end: channel(`tracing:${nameOrChannels}:asyncEnd`), + }); + } else if (typeof nameOrChannels === "object" && nameOrChannels !== null) { + this.#callWindow = new BoundedChannel({ + start: nameOrChannels.start, + end: nameOrChannels.end, + }); + this.#continuationWindow = new BoundedChannel({ + start: nameOrChannels.asyncStart, + end: nameOrChannels.asyncEnd, + }); + } + + // Create individual channel for error + ObjectDefineProperty.$call(null, 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) { + const { start, end, asyncStart, asyncEnd, error } = handlers; + + // Subscribe to call window (start/end) + if (start || end) { + this.#callWindow.subscribe({ start, end }); + } + + // Subscribe to continuation window (asyncStart/asyncEnd) + if (asyncStart || asyncEnd) { + this.#continuationWindow.subscribe({ + start: asyncStart, + end: asyncEnd, + }); + } + + // Subscribe to error channel + if (error) { + this.error.subscribe(error); + } + } + + unsubscribe(handlers) { + let done = true; + const { start, end, asyncStart, asyncEnd, error } = handlers; + + // Unsubscribe from call window + if (start || end) { + if (!this.#callWindow.unsubscribe({ start, end })) { + done = false; + } + } + + // Unsubscribe from continuation window + if (asyncStart || asyncEnd) { + if ( + !this.#continuationWindow.unsubscribe({ + start: asyncStart, + end: asyncEnd, + }) + ) { + done = false; + } + } + + // Unsubscribe from error channel + if (error) { + if (!this.error.unsubscribe(error)) { + done = false; + } + } + + return done; + } + + traceSync(fn, context = {}, thisArg, ...args) { + if (!this.hasSubscribers) { + return fn.$apply(thisArg, args); + } + + const { error } = this; + + const scope = this.#callWindow.withScope(context); + try { + const result = fn.$apply(thisArg, args); + context.result = result; + return result; + } catch (err) { + context.error = err; + error.publish(context); + throw err; + } finally { + scope[SymbolDispose](); + } + } + tracePromise(fn, context = {}, thisArg, ...args) { - const { start, end, asyncStart, asyncEnd, error } = this; + if (!this.hasSubscribers) { + const result = fn.$apply(thisArg, args); + if (typeof result?.then !== "function") { + emitNonThenableWarning(fn); + } + return result; + } + + const { error } = this; + const continuationWindow = this.#continuationWindow; function reject(err) { context.error = err; error.publish(context); - asyncStart.publish(context); - // TODO: Is there a way to have asyncEnd _after_ the continuation? - asyncEnd.publish(context); - return PromiseReject(err); + // Use continuation window for asyncStart/asyncEnd + const scope = continuationWindow.withScope(context); + try { + return PromiseReject(err); + } finally { + scope[SymbolDispose](); + } } function resolve(result) { context.result = result; - asyncStart.publish(context); - // TODO: Is there a way to have asyncEnd _after_ the continuation? - asyncEnd.publish(context); - return result; - } - - return start.runStores(context, () => { + // Use continuation window for asyncStart/asyncEnd + const scope = continuationWindow.withScope(context); try { - let promise = fn.$apply(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; + return result; } finally { - end.publish(context); + scope[SymbolDispose](); } - }); + } + + const scope = this.#callWindow.withScope(context); + try { + const result = fn.$apply(thisArg, args); + // If the return value is not a thenable, return it directly with a warning. + // Do not publish to asyncStart/asyncEnd. + if (typeof result?.then !== "function") { + emitNonThenableWarning(fn); + context.result = result; + return result; + } + // For native Promises use PromisePrototypeThen to avoid user overrides. + if ($isPromise(result)) { + return PromisePrototypeThen.$call(result, resolve, reject); + } + // For custom thenables, call .then() directly to preserve the thenable type. + return result.then(resolve, reject); + } catch (err) { + context.error = err; + error.publish(context); + throw err; + } finally { + scope[SymbolDispose](); + } } traceCallback(fn, position = -1, context = {}, thisArg, ...args) { - const { start, end, asyncStart, asyncEnd, error } = this; + if (!this.hasSubscribers) { + return fn.$apply(thisArg, args); + } + + const { error } = this; + const continuationWindow = this.#continuationWindow; function wrappedCallback(err, res) { if (err) { @@ -370,33 +624,29 @@ class TracingChannel { context.result = res; } - // Using runStores here enables manual context failure recovery - asyncStart.runStores(context, () => { - try { - if (callback) { - return callback.$apply(this, arguments); - } - } finally { - asyncEnd.publish(context); - } - }); + // Use continuation window for asyncStart/asyncEnd around callback + const scope = continuationWindow.withScope(context); + try { + return callback.$apply(this, arguments); + } finally { + scope[SymbolDispose](); + } } const callback = ArrayPrototypeAt.$call(args, position); validateFunction(callback, "callback"); ArrayPrototypeSplice.$call(args, position, 1, wrappedCallback); - return start.runStores(context, () => { - try { - return fn.$apply(thisArg, args); - } catch (err) { - context.error = err; - error.publish(context); - throw err; - } finally { - end.publish(context); - } - }); + const scope = this.#callWindow.withScope(context); + try { + return fn.$apply(thisArg, args); + } catch (err) { + context.error = err; + error.publish(context); + throw err; + } finally { + scope[SymbolDispose](); + } } } @@ -404,11 +654,21 @@ function tracingChannel(nameOrChannels) { return new TracingChannel(nameOrChannels); } +{ + const moduleTracing = require("internal/module_tracing"); + const names = ["start", "end", "asyncStart", "asyncEnd", "error"]; + moduleTracing.install(tracingChannel("module.require"), tracingChannel("module.import"), (tc, cb) => { + for (const n of names) tc[n]._onSubscribersChanged = cb; + }); +} + export default { channel, hasSubscribers, subscribe, tracingChannel, unsubscribe, + boundedChannel, Channel, + BoundedChannel, }; diff --git a/src/js/node/net.ts b/src/js/node/net.ts index 81f7b6317db9..e329ad3f0df0 100644 --- a/src/js/node/net.ts +++ b/src/js/node/net.ts @@ -64,6 +64,14 @@ const setDefaultAutoSelectFamilyAttemptTimeout = $rust("node_net_binding.rs", "s */ let tlsKeylogPath: string | undefined; let tlsKeylogWarned = false; + +let netClientSocketChannel, netServerSocketChannel, netServerListen; +function initNetChannels() { + const dc = require("node:diagnostics_channel"); + netClientSocketChannel = dc.channel("net.client.socket"); + netServerSocketChannel = dc.channel("net.server.socket"); + netServerListen = dc.tracingChannel("net.server.listen"); +} function appendTlsKeylog(line: Buffer) { if (!tlsKeylogWarned) { tlsKeylogWarned = true; @@ -1210,6 +1218,11 @@ function onconnection(err, clientHandle) { if (isTLS) initAcceptedTLSSocket(self, _socket); self.emit("connection", _socket); + if (netServerSocketChannel.hasSubscribers) { + netServerSocketChannel.publish({ + socket: _socket, + }); + } if (!pauseOnConnect && !isTLS) { _socket.read(0); } @@ -1875,6 +1888,14 @@ Socket.prototype.connect = function connect(...args) { { const [options, connectListener] = $isArray(args[0]) && args[0][normalizedArgsSymbol] ? args[0] : normalizeArgs(args); + + if (!netClientSocketChannel) initNetChannels(); + if (netClientSocketChannel.hasSubscribers) { + netClientSocketChannel.publish({ + socket: this, + }); + } + let connection = this[ksocket]; let upgradeDuplex = false; let { port, host, path, socket, rejectUnauthorized, checkServerIdentity, session, fd, pauseOnConnect } = options; @@ -3618,6 +3639,7 @@ Server.prototype.getConnections = function getConnections(callback) { Server.prototype.listen = function listen(port, hostname, onListen) { const argsLength = arguments.length; + const listenArg0 = port; if (typeof port === "string") { const numPort = Number(port); if (!Number.isNaN(numPort)) port = numPort; @@ -3770,6 +3792,17 @@ Server.prototype.listen = function listen(port, hostname, onListen) { throw $ERR_SERVER_ALREADY_LISTEN(); } + if (!netServerListen) initNetChannels(); + if (netServerListen.hasSubscribers) { + const options = + typeof listenArg0 === "object" && listenArg0 !== null + ? listenArg0 + : path != null + ? { path } + : { port, host: hostname }; + netServerListen.asyncStart.publish({ server: this, options }); + } + if (onListen != null) { this.once("listening", onListen); } @@ -3814,7 +3847,11 @@ Server.prototype.listen = function listen(port, hostname, onListen) { ); } catch (err) { const isUnix = path != null; - setTimeout(emitErrorNextTick, 1, this, formatListenError(err, isUnix ? path : hostname, isUnix ? undefined : port)); + const error = formatListenError(err, isUnix ? path : hostname, isUnix ? undefined : port); + if (netServerListen.hasSubscribers) { + netServerListen.error.publish({ server: this, error }); + } + setTimeout(emitErrorNextTick, 1, this, error); } return this; }; @@ -3912,6 +3949,10 @@ Server.prototype[kRealListen] = function ( } } + if (netServerListen.hasSubscribers) { + netServerListen.asyncEnd.publish({ server: this }); + } + // Unref the handle if the server was unref'ed prior to listening if (this._unref) this.unref(); diff --git a/src/js/node/v8.ts b/src/js/node/v8.ts index 5e03eee4584c..34f2b4bf3a8f 100644 --- a/src/js/node/v8.ts +++ b/src/js/node/v8.ts @@ -2,7 +2,7 @@ // This is a stub! None of this is actually implemented yet. const { hideFromStack, throwNotImplemented } = require("internal/shared"); -const { validateString, validateOneOf } = require("internal/validators"); +const { validateFunction, validateObject, validateString, validateOneOf } = require("internal/validators"); const { uncurryThis } = require("internal/primordials"); const { isDataView, isAnyArrayBuffer } = require("node:util/types"); const jsc: typeof import("bun:jsc") = require("bun:jsc"); @@ -29,6 +29,8 @@ const DataViewPrototypeGetByteLength = uncurryThis( ); const Uint8ArrayPrototypeSubarray = uncurryThis(Uint8ArrayCtor.prototype.subarray); +const queryObjectsNative = $newCppFunction("NodeV8Module.cpp", "jsFunctionQueryObjects", 1); + function notimpl(message) { throwNotImplemented("node:v8 " + message); } @@ -368,6 +370,39 @@ function writeHeapSnapshot(path, _options) { function setHeapSnapshotNearHeapLimit() { notimpl("setHeapSnapshotNearHeapLimit"); } + +let emittedQueryObjectsWarning = false; +// https://github.com/nodejs/node/blob/v26.3.0/lib/internal/heap_utils.js +function queryObjects(ctor, options) { + validateFunction(ctor, "constructor"); + if (options !== undefined) { + validateObject(options, "options"); + } + const format = options?.format || "count"; + if (format !== "count" && format !== "summary") { + throw $ERR_INVALID_ARG_VALUE("options.format", format); + } + if (!emittedQueryObjectsWarning) { + emittedQueryObjectsWarning = true; + process.emitWarning( + "v8.queryObjects() is an experimental feature and might change at any time", + "ExperimentalWarning", + ); + } + // Matching the console API behavior - just access the .prototype. + const objects = queryObjectsNative(ctor.prototype); + if (format === "count") { + return objects.length; + } + // options.format is 'summary'. + const { inspect } = require("node:util"); + const summaries = new Array(objects.length); + for (let i = 0; i < objects.length; i++) { + summaries[i] = inspect(objects[i], { depth: 0 }); + } + return summaries; +} + function throwNotBuildingSnapshot() { throw $ERR_NOT_BUILDING_SNAPSHOT("Operation cannot be invoked when not building startup snapshot"); } @@ -415,6 +450,7 @@ export default { writeHeapSnapshot, setHeapSnapshotNearHeapLimit, promiseHooks, + queryObjects, startupSnapshot, Deserializer, Serializer, @@ -438,6 +474,7 @@ hideFromStack( serialize, writeHeapSnapshot, setHeapSnapshotNearHeapLimit, + queryObjects, Deserializer, Serializer, DefaultDeserializer, diff --git a/src/js/node/worker_threads.ts b/src/js/node/worker_threads.ts index f8b269dd56ae..78ed7f3a0d17 100644 --- a/src/js/node/worker_threads.ts +++ b/src/js/node/worker_threads.ts @@ -2,6 +2,7 @@ declare const self: typeof globalThis; type WebWorker = InstanceType; const EventEmitter = require("node:events"); +let workerThreadsChannel; const { SafeMap } = require("internal/primordials"); const Readable = require("internal/streams/readable"); const Writable = require("internal/streams/writable"); @@ -1090,6 +1091,13 @@ class Worker extends EventEmitter { } urlRevokeRegistry.register(this.#worker, this.#urlToRevoke); } + + workerThreadsChannel ??= require("node:diagnostics_channel").channel("worker_threads"); + if (workerThreadsChannel.hasSubscribers) { + workerThreadsChannel.publish({ + worker: this, + }); + } } get threadId() { diff --git a/src/jsc/bindings/NodeDiagnosticsChannel.cpp b/src/jsc/bindings/NodeDiagnosticsChannel.cpp new file mode 100644 index 000000000000..e50585689f88 --- /dev/null +++ b/src/jsc/bindings/NodeDiagnosticsChannel.cpp @@ -0,0 +1,15 @@ +#include "NodeDiagnosticsChannel.h" + +namespace Bun { + +using namespace JSC; + +JSC_DEFINE_HOST_FUNCTION(jsSetHasModuleImportSubscribers, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame)) +{ + ASSERT(callFrame->argumentCount() == 1); + auto* global = uncheckedDowncast(globalObject); + global->hasModuleImportSubscribers = callFrame->uncheckedArgument(0).toBoolean(globalObject); + return JSC::JSValue::encode(JSC::jsUndefined()); +} + +} diff --git a/src/jsc/bindings/NodeDiagnosticsChannel.h b/src/jsc/bindings/NodeDiagnosticsChannel.h new file mode 100644 index 000000000000..b1814e8928c8 --- /dev/null +++ b/src/jsc/bindings/NodeDiagnosticsChannel.h @@ -0,0 +1,11 @@ +#pragma once + +#include "root.h" +#include "ZigGlobalObject.h" +#include + +namespace Bun { + +JSC_DECLARE_HOST_FUNCTION(jsSetHasModuleImportSubscribers); + +} diff --git a/src/jsc/bindings/NodeV8Module.cpp b/src/jsc/bindings/NodeV8Module.cpp new file mode 100644 index 000000000000..424bdd2e6abf --- /dev/null +++ b/src/jsc/bindings/NodeV8Module.cpp @@ -0,0 +1,63 @@ +#include "NodeV8Module.h" + +#include "JavaScriptCore/ArgList.h" +#include "JavaScriptCore/HeapIterationScope.h" +#include "JavaScriptCore/JSCInlines.h" +#include "JavaScriptCore/JSObject.h" +#include "JavaScriptCore/MarkedSpaceInlines.h" + +namespace Bun { + +using namespace JSC; + +JSC_DEFINE_HOST_FUNCTION(jsFunctionQueryObjects, (JSGlobalObject * globalObject, CallFrame* callFrame)) +{ + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + + JSValue prototypeValue = callFrame->argument(0); + if (!prototypeValue.isObject()) { + JSArray* empty = constructEmptyArray(globalObject, nullptr, 0); + RETURN_IF_EXCEPTION(scope, {}); + return JSValue::encode(empty); + } + + vm.heap.collectNow(Sync, CollectionScope::Full); + + // No GC allocation may happen while iterating the heap; collect matches + // into a MarkedArgumentBuffer (malloc-backed) and build the array after. + MarkedArgumentBuffer matches; + { + HeapIterationScope iterationScope(vm.heap); + vm.heap.objectSpace().forEachLiveCell(iterationScope, [&](HeapCell* cell, HeapCell::Kind kind) -> IterationStatus { + if (!isJSCellKind(kind)) + return IterationStatus::Continue; + JSCell* jsCell = static_cast(cell); + if (!jsCell->isObject()) + return IterationStatus::Continue; + JSObject* object = asObject(jsCell); + // Walk the prototype chain structurally; proxy traps and getters + // must not run during heap iteration. + JSValue prototype = object->getPrototypeDirect(); + while (prototype.isObject()) { + if (prototype == prototypeValue) { + matches.append(object); + break; + } + prototype = asObject(prototype)->getPrototypeDirect(); + } + return IterationStatus::Continue; + }); + } + + if (matches.hasOverflowed()) [[unlikely]] { + throwOutOfMemoryError(globalObject, scope); + return {}; + } + + JSArray* result = constructArray(globalObject, static_cast(nullptr), matches); + RETURN_IF_EXCEPTION(scope, {}); + return JSValue::encode(result); +} + +} diff --git a/src/jsc/bindings/NodeV8Module.h b/src/jsc/bindings/NodeV8Module.h new file mode 100644 index 000000000000..2685d10f53d9 --- /dev/null +++ b/src/jsc/bindings/NodeV8Module.h @@ -0,0 +1,11 @@ +#pragma once + +#include "root.h" +#include "ZigGlobalObject.h" +#include + +namespace Bun { + +JSC_DECLARE_HOST_FUNCTION(jsFunctionQueryObjects); + +} diff --git a/src/jsc/bindings/ZigGlobalObject.cpp b/src/jsc/bindings/ZigGlobalObject.cpp index 04724225b631..1f1bcf96d563 100644 --- a/src/jsc/bindings/ZigGlobalObject.cpp +++ b/src/jsc/bindings/ZigGlobalObject.cpp @@ -409,11 +409,11 @@ static void cleanupAsyncHooksData(JSC::VM& vm) auto* globalObject = defaultGlobalObject(); globalObject->m_asyncContextData.get()->putInternalField(vm, 0, jsUndefined()); globalObject->asyncHooksNeedsCleanup = false; - if (!globalObject->m_nextTickQueue) { - vm.setOnEachMicrotaskTick(&checkIfNextTickWasCalledDuringMicrotask); - checkIfNextTickWasCalledDuringMicrotask(vm); - } else { + if (auto* queue = globalObject->m_nextTickQueue.get()) { vm.setOnEachMicrotaskTick(nullptr); + queue->drain(vm, globalObject); + } else { + vm.setOnEachMicrotaskTick(&checkIfNextTickWasCalledDuringMicrotask); } } @@ -3704,16 +3704,11 @@ JSC::Identifier GlobalObject::moduleLoaderResolve(JSGlobalObject* jsGlobalObject } } -JSC::JSPromise* GlobalObject::moduleLoaderImportModule(JSGlobalObject* jsGlobalObject, - JSModuleLoader*, +static JSC::JSPromise* moduleLoaderImportModuleImpl(Zig::GlobalObject* globalObject, JSString* moduleNameValue, RefPtr parameters, - const SourceOrigin& sourceOrigin, - bool deferred) + const SourceOrigin& sourceOrigin) { - UNUSED_PARAM(deferred); - auto* globalObject = static_cast(jsGlobalObject); - VM& vm = JSC::getVM(globalObject); auto scope = DECLARE_THROW_SCOPE(vm); @@ -3823,6 +3818,72 @@ JSC::JSPromise* GlobalObject::moduleLoaderImportModule(JSGlobalObject* jsGlobalO return result; } +static JSC::JSPromise* tryTraceModuleImport(Zig::GlobalObject* globalObject, + JSString* moduleNameValue, + RefPtr parameters, + const SourceOrigin& sourceOrigin) +{ + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + + JSValue moduleTracing = globalObject->internalModuleRegistry()->internalField(InternalModuleRegistry::Field::InternalModuleTracing).get(); + if (!moduleTracing.isObject()) + return nullptr; + JSObject* moduleTracingObject = asObject(moduleTracing); + + JSValue traceImport = moduleTracingObject->getIfPropertyExists(globalObject, Identifier::fromString(vm, "traceImport"_s)); + if (scope.exception()) [[unlikely]] + return JSC::JSPromise::rejectedPromiseWithCaughtException(globalObject, scope); + if (!traceImport || !traceImport.isCallable()) + return nullptr; + + SourceOrigin sourceOriginCopy = sourceOrigin; + JSC::Strong strongModuleName(vm, moduleNameValue); + auto* doImport = JSC::JSNativeStdFunction::create(vm, globalObject, 0, String(), + [strongModuleName = WTF::move(strongModuleName), parameters, sourceOriginCopy](JSGlobalObject* lexicalGlobalObject, CallFrame*) -> JSC::EncodedJSValue { + auto* global = static_cast(lexicalGlobalObject); + RefPtr parametersCopy = parameters; + return JSValue::encode(moduleLoaderImportModuleImpl(global, strongModuleName.get(), WTF::move(parametersCopy), sourceOriginCopy)); + }); + + auto sourceURL = sourceOrigin.url(); + JSValue parentURL = sourceURL.isEmpty() ? jsEmptyString(vm) : jsString(vm, sourceURL.string()); + + MarkedArgumentBuffer args; + args.append(doImport); + args.append(parentURL); + args.append(moduleNameValue); + + auto callData = JSC::getCallData(traceImport); + JSValue result = JSC::call(globalObject, traceImport, callData, moduleTracingObject, args); + if (scope.exception()) [[unlikely]] + return JSC::JSPromise::rejectedPromiseWithCaughtException(globalObject, scope); + if (auto* promise = dynamicDowncast(result)) + return promise; + JSPromise* adopted = JSPromise::resolvedPromise(globalObject, result); + if (scope.exception()) [[unlikely]] + return JSC::JSPromise::rejectedPromiseWithCaughtException(globalObject, scope); + return adopted; +} + +JSC::JSPromise* GlobalObject::moduleLoaderImportModule(JSGlobalObject* jsGlobalObject, + JSModuleLoader*, + JSString* moduleNameValue, + RefPtr parameters, + const SourceOrigin& sourceOrigin, + bool deferred) +{ + UNUSED_PARAM(deferred); + auto* globalObject = static_cast(jsGlobalObject); + + if (globalObject->hasModuleImportSubscribers) [[unlikely]] { + if (auto* traced = tryTraceModuleImport(globalObject, moduleNameValue, parameters, sourceOrigin)) + return traced; + } + + return moduleLoaderImportModuleImpl(globalObject, moduleNameValue, WTF::move(parameters), sourceOrigin); +} + static JSC::JSPromise* rejectedInternalPromise(JSC::JSGlobalObject* globalObject, JSC::JSValue value) { auto& vm = JSC::getVM(globalObject); diff --git a/src/jsc/bindings/ZigGlobalObject.h b/src/jsc/bindings/ZigGlobalObject.h index abf6e834a36f..2d35d86fc590 100644 --- a/src/jsc/bindings/ZigGlobalObject.h +++ b/src/jsc/bindings/ZigGlobalObject.h @@ -450,6 +450,7 @@ class GlobalObject : public Bun::GlobalScope { } bool asyncHooksNeedsCleanup = false; + bool hasModuleImportSubscribers = false; double INSPECT_MAX_BYTES = 50; bool isInsideErrorPrepareStackTraceCallback = false; diff --git a/test/js/node/async_hooks/AsyncLocalStorage.test.ts b/test/js/node/async_hooks/AsyncLocalStorage.test.ts index b6dc51aa113e..4139d10e700e 100644 --- a/test/js/node/async_hooks/AsyncLocalStorage.test.ts +++ b/test/js/node/async_hooks/AsyncLocalStorage.test.ts @@ -13,6 +13,25 @@ describe("AsyncLocalStorage", () => { }).toThrow("error"); }); + test("process.nextTick scheduled alongside enterWith() still runs", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const { AsyncLocalStorage } = require("async_hooks"); + const als = new AsyncLocalStorage(); + als.enterWith(1); + process.nextTick(() => console.log("nextTick ran"));`, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout: stdout.trim(), exitCode }).toEqual({ stdout: "nextTick ran", exitCode: 0 }); + expect(stderr).not.toContain("AssertionError"); + }); + // The post-run restoration assert must account for getStore() falling // through to defaultValue once the entry is removed (debug builds only). test("run() works with a defaultValue and no prior context", () => { @@ -1003,7 +1022,7 @@ describe("async context passes through", () => { const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); expect({ stdout: stdout.trim(), exitCode }).toEqual({ stdout: "CLEARED", exitCode: 0 }); expect(stderr).not.toContain("AssertionError"); - }); + }, 15_000); // destroy(err) with no 'error' listener throws out of the emit, which must // not skip the frame clear (the 'close' tick after it never runs). @@ -1038,7 +1057,7 @@ describe("async context passes through", () => { const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); expect({ stdout: stdout.trim(), exitCode }).toEqual({ stdout: "CLEARED", exitCode: 0 }); expect(stderr).not.toContain("AssertionError"); - }); + }, 15_000); // _destroy emits 'aborted' (and can reach user code via end()/push(null)) // before it finishes; the clear must not sit downstream of that. The throw is @@ -1086,7 +1105,7 @@ describe("async context passes through", () => { const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); expect({ stdout: stdout.trim(), exitCode }).toEqual({ stdout: "CLEARED", exitCode: 0 }); expect(stderr).not.toContain("AssertionError"); - }); + }, 15_000); // The upgrade/connect branch emits before closeRequest(), which carries the // clear; a throwing handler must not leave the request pinning the store. @@ -1130,7 +1149,7 @@ describe("async context passes through", () => { const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); expect({ stdout: stdout.trim(), exitCode }).toEqual({ stdout: "CLEARED", exitCode: 0 }); expect(stderr).not.toContain("AssertionError"); - }); + }, 15_000); // run()'s same-value short-circuit must not spread its rest args, or a // tampered Array.prototype[Symbol.iterator] breaks it. The main path is diff --git a/test/js/node/diagnostics_channel/diagnostics_channel.test.ts b/test/js/node/diagnostics_channel/diagnostics_channel.test.ts index 37dfd54d7a8f..3f26efed270e 100644 --- a/test/js/node/diagnostics_channel/diagnostics_channel.test.ts +++ b/test/js/node/diagnostics_channel/diagnostics_channel.test.ts @@ -1,7 +1,10 @@ import { gc } from "bun"; import { beforeEach, describe, expect, mock, test } from "bun:test"; import { AsyncLocalStorage } from "node:async_hooks"; -import { channel, Channel, hasSubscribers, subscribe, unsubscribe } from "node:diagnostics_channel"; +import { channel, Channel, hasSubscribers, subscribe, tracingChannel, unsubscribe } from "node:diagnostics_channel"; +import { createServer, IncomingMessage, ServerResponse } from "node:http"; +import type { AddressInfo } from "node:net"; +import { connect as netConnect, createServer as netCreateServer } from "node:net"; describe("Channel", () => { // test-diagnostics-channel-has-subscribers.js @@ -343,6 +346,161 @@ describe("TracingChannel", () => { // Port tests from: // https://github.com/search?q=repo%3Anodejs%2Fnode+test-diagnostics-channel+AND+%2Ftracing%2F&type=code test.todo("TODO"); + + test("tracingChannel(null) throws ERR_INVALID_ARG_TYPE like Node", () => { + for (const bad of [null, 0, Symbol("x")]) { + expect(() => tracingChannel(bad as any)).toThrow( + expect.objectContaining({ + code: "ERR_INVALID_ARG_TYPE", + }), + ); + } + }); +}); + +describe("node:http server channels", () => { + test("http.server.response.created publishes the request and response", async () => { + const events: Array<{ request: unknown; response: unknown }> = []; + const onCreated = (message: any) => events.push(message); + subscribe("http.server.response.created", onCreated); + + const server = createServer((req, res) => res.end("ok")); + try { + const { promise, resolve, reject } = Promise.withResolvers(); + server.on("error", reject); + server.listen(0, "127.0.0.1", resolve); + await promise; + + const { port } = server.address() as AddressInfo; + const response = await fetch(`http://127.0.0.1:${port}/`); + expect(await response.text()).toBe("ok"); + } finally { + unsubscribe("http.server.response.created", onCreated); + server.close(); + } + + expect(events).toHaveLength(1); + expect(events[0].request).toBeInstanceOf(IncomingMessage); + expect(events[0].response).toBeInstanceOf(ServerResponse); + expect((events[0].response as ServerResponse).req).toBe(events[0].request); + }); + + test("http.server.* channels do not publish for accepted upgrades", async () => { + const counts = { created: 0, start: 0, finish: 0 }; + let upgradeSeen = false; + const onCreated = () => counts.created++; + const onStart = () => counts.start++; + const onFinish = () => counts.finish++; + subscribe("http.server.response.created", onCreated); + subscribe("http.server.request.start", onStart); + subscribe("http.server.response.finish", onFinish); + + const server = createServer((req, res) => res.end("ok")); + server.on("upgrade", (req, socket) => { + upgradeSeen = true; + socket.end("HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: test\r\n\r\n"); + }); + try { + const { promise: listening, resolve: onListening, reject } = Promise.withResolvers(); + server.on("error", reject); + server.listen(0, "127.0.0.1", onListening); + await listening; + const { port } = server.address() as AddressInfo; + + const { promise: upgraded, resolve: onUpgraded, reject: onSockErr } = Promise.withResolvers(); + const sock = netConnect(port, "127.0.0.1", () => { + sock.write("GET / HTTP/1.1\r\nHost: x\r\nConnection: Upgrade\r\nUpgrade: test\r\n\r\n"); + }); + sock.on("data", () => {}); + sock.on("error", onSockErr); + sock.on("close", onUpgraded); + await upgraded; + expect(upgradeSeen).toBe(true); + expect(counts).toEqual({ created: 0, start: 0, finish: 0 }); + + const response = await fetch(`http://127.0.0.1:${port}/`); + expect(await response.text()).toBe("ok"); + } finally { + unsubscribe("http.server.response.created", onCreated); + unsubscribe("http.server.request.start", onStart); + unsubscribe("http.server.response.finish", onFinish); + server.close(); + } + + expect(counts).toEqual({ created: 1, start: 1, finish: 1 }); + }); + + test("http.server.* channels publish on the emit('connection') fallback path", async () => { + // server.emit('connection', foreignSocket) routes through the llhttp-based + // fallback (internal/http1_server_fallback), which in Node converges on the + // same parserOnIncoming publishes as the native dispatch path. + const events: Array<{ name: string; message: any }> = []; + const onCreated = (message: any) => events.push({ name: "created", message }); + const onStart = (message: any) => events.push({ name: "start", message }); + const onFinish = (message: any) => events.push({ name: "finish", message }); + subscribe("http.server.response.created", onCreated); + subscribe("http.server.request.start", onStart); + subscribe("http.server.response.finish", onFinish); + + const httpServer = createServer((req, res) => res.end("ok")); + const tcp = netCreateServer(socket => httpServer.emit("connection", socket)); + try { + const { promise: listening, resolve: onListening, reject } = Promise.withResolvers(); + tcp.on("error", reject); + tcp.listen(0, "127.0.0.1", onListening); + await listening; + const { port } = tcp.address() as AddressInfo; + + const response = await fetch(`http://127.0.0.1:${port}/`); + expect(await response.text()).toBe("ok"); + } finally { + unsubscribe("http.server.response.created", onCreated); + unsubscribe("http.server.request.start", onStart); + unsubscribe("http.server.response.finish", onFinish); + tcp.close(); + httpServer.close(); + } + + expect(events.map(e => e.name)).toEqual(["created", "start", "finish"]); + for (const { message } of events) { + expect(message.request).toBeInstanceOf(IncomingMessage); + expect(message.response).toBeInstanceOf(ServerResponse); + } + expect(events[1].message.server).toBe(httpServer); + expect(events[2].message.server).toBe(httpServer); + }); + + test("http.Server.listen() publishes on net.server.listen", async () => { + const events: string[] = []; + let startMessage: any, endMessage: any; + const onStart = (m: any) => { + events.push("asyncStart"); + startMessage = m; + }; + const onEnd = (m: any) => { + events.push("asyncEnd"); + endMessage = m; + }; + subscribe("tracing:net.server.listen:asyncStart", onStart); + subscribe("tracing:net.server.listen:asyncEnd", onEnd); + + const server = createServer((req, res) => res.end("ok")); + try { + const { promise, resolve, reject } = Promise.withResolvers(); + server.on("error", reject); + server.listen(0, "127.0.0.1", resolve); + await promise; + } finally { + unsubscribe("tracing:net.server.listen:asyncStart", onStart); + unsubscribe("tracing:net.server.listen:asyncEnd", onEnd); + server.close(); + } + + expect(events).toEqual(["asyncStart", "asyncEnd"]); + expect(startMessage.server).toBe(server); + expect(startMessage.options).toEqual({ port: 0, host: "127.0.0.1" }); + expect(endMessage.server).toBe(server); + }); }); const mocks = new Map(); diff --git a/test/js/node/test/parallel/test-diagnostic-channel-http-response-created.js b/test/js/node/test/parallel/test-diagnostic-channel-http-response-created.js new file mode 100644 index 000000000000..158698c22ce7 --- /dev/null +++ b/test/js/node/test/parallel/test-diagnostic-channel-http-response-created.js @@ -0,0 +1,45 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const http = require('http'); +const dc = require('diagnostics_channel'); + +const isOutgoingMessage = (object) => object instanceof http.OutgoingMessage; +const isIncomingMessage = (object) => object instanceof http.IncomingMessage; + +dc.subscribe('http.server.response.created', common.mustCall(({ + request, + response, +}) => { + assert.strictEqual(request.headers.foo, 'bar'); + assert.strictEqual(response.getHeader('baz'), undefined); + assert.strictEqual(isIncomingMessage(request), true); + assert.strictEqual(isOutgoingMessage(response), true); +})); + +dc.subscribe('http.server.response.finish', common.mustCall(({ + request, + response, +}) => { + assert.strictEqual(request.headers.foo, 'bar'); + assert.strictEqual(response.getHeader('baz'), 'bar'); + assert.strictEqual(isIncomingMessage(request), true); + assert.strictEqual(isOutgoingMessage(response), true); +})); + +const server = http.createServer(common.mustCall((_, res) => { + res.setHeader('baz', 'bar'); + res.end('done'); +})); + +server.listen(common.mustCall(() => { + const { port } = server.address(); + http.get({ + port, + headers: { + 'foo': 'bar', + } + }, common.mustCall(() => { + server.close(); + })); +})); diff --git a/test/js/node/test/parallel/test-diagnostics-channel-bounded-channel-run-transform-error.js b/test/js/node/test/parallel/test-diagnostics-channel-bounded-channel-run-transform-error.js new file mode 100644 index 000000000000..e86dcf0da207 --- /dev/null +++ b/test/js/node/test/parallel/test-diagnostics-channel-bounded-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 BoundedChannel.run() with store transform error +// Transform errors are scheduled via process.nextTick(triggerUncaughtException) + +const boundedChannel = dc.boundedChannel('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'); +})); + +boundedChannel.subscribe({ + start(message) { + events.push({ type: 'start', data: message }); + }, + end(message) { + events.push({ type: 'end', data: message }); + }, +}); + +// Bind store with a transform that throws +boundedChannel.start.bindStore(store, () => { + throw transformError; +}); + +// Store should remain undefined since transform will fail +assert.strictEqual(store.getStore(), undefined); + +const result = boundedChannel.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/js/node/test/parallel/test-diagnostics-channel-bounded-channel-run.js b/test/js/node/test/parallel/test-diagnostics-channel-bounded-channel-run.js new file mode 100644 index 000000000000..3fd5422f8cc0 --- /dev/null +++ b/test/js/node/test/parallel/test-diagnostics-channel-bounded-channel-run.js @@ -0,0 +1,125 @@ +'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 boundedChannel = dc.boundedChannel('test-run-basic'); + const events = []; + + boundedChannel.subscribe({ + start(message) { + events.push({ type: 'start', data: message }); + }, + end(message) { + events.push({ type: 'end', data: message }); + }, + }); + + const context = { id: 123 }; + const result = boundedChannel.run(context, () => { + return 'success'; + }); + + assert.strictEqual(result, 'success'); + assert.strictEqual(events.length, 2); + assert.deepStrictEqual(events, [ + { type: 'start', data: { id: 123 } }, + { type: 'end', data: { id: 123 } }, + ]); +} + +// Test run with error +{ + const boundedChannel = dc.boundedChannel('test-run-error'); + const events = []; + + boundedChannel.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(() => { + boundedChannel.run(context, () => { + throw testError; + }); + }, testError); + + // BoundedChannel does not handle errors - they just propagate + // Only start and end events are published + assert.strictEqual(events.length, 2); + assert.deepStrictEqual(events, [ + { type: 'start', data: { id: 456 } }, + { type: 'end', data: { id: 456 } }, + ]); +} + +// Test run with thisArg and args +{ + const boundedChannel = dc.boundedChannel('test-run-args'); + + const obj = { value: 10 }; + const result = boundedChannel.run({}, function(a, b) { + return this.value + a + b; + }, obj, 5, 15); + + assert.strictEqual(result, 30); +} + +// Test run with AsyncLocalStorage +{ + const boundedChannel = dc.boundedChannel('test-run-store'); + const store = new AsyncLocalStorage(); + const events = []; + + boundedChannel.start.bindStore(store, (context) => { + return { traceId: context.traceId }; + }); + + boundedChannel.subscribe({ + start(message) { + events.push({ type: 'start', store: store.getStore() }); + }, + end(message) { + events.push({ type: 'end', store: store.getStore() }); + }, + }); + + const result = boundedChannel.run({ traceId: 'abc123' }, () => { + events.push({ type: 'inside', store: store.getStore() }); + return 'result'; + }); + + assert.strictEqual(result, 'result'); + assert.strictEqual(events.length, 3); + + // Innert events should have store set + assert.deepStrictEqual(events, [ + { type: 'start', store: { traceId: 'abc123' } }, + { type: 'inside', store: { traceId: 'abc123' } }, + { type: 'end', store: { traceId: 'abc123' } }, + ]); + + // Store should be undefined outside + assert.strictEqual(store.getStore(), undefined); +} + +// Test run without subscribers +{ + const boundedChannel = dc.boundedChannel('test-run-no-subs'); + + const result = boundedChannel.run({}, () => { + return 'fast path'; + }); + + assert.strictEqual(result, 'fast path'); +} diff --git a/test/js/node/test/parallel/test-diagnostics-channel-bounded-channel-scope-error.js b/test/js/node/test/parallel/test-diagnostics-channel-bounded-channel-scope-error.js new file mode 100644 index 000000000000..c5d00256ec96 --- /dev/null +++ b/test/js/node/test/parallel/test-diagnostics-channel-bounded-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 boundedChannel = dc.boundedChannel('test-scope-throw'); + const events = []; + + boundedChannel.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 = boundedChannel.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 boundedChannel = dc.boundedChannel('test-scope-store-error'); + const store = new AsyncLocalStorage(); + + boundedChannel.start.bindStore(store, (context) => context.value); + + boundedChannel.subscribe({ + start() {}, + end() {}, + }); + + store.enterWith('before'); + assert.strictEqual(store.getStore(), 'before'); + + const testError = new Error('test'); + + assert.throws(() => { + using scope = boundedChannel.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 boundedChannel = dc.boundedChannel('test-scope-dispose-exception'); + const events = []; + + boundedChannel.subscribe({ + start() { + events.push('start'); + }, + end() { + events.push('end'); + }, + }); + + // Dispose should complete even when exception is thrown + assert.throws(() => { + using scope = boundedChannel.withScope({}); + throw new Error('original error'); + }, /original error/); + + // End event should have been called + assert.deepStrictEqual(events, ['start', 'end']); +} diff --git a/test/js/node/test/parallel/test-diagnostics-channel-bounded-channel-scope-nested.js b/test/js/node/test/parallel/test-diagnostics-channel-bounded-channel-scope-nested.js new file mode 100644 index 000000000000..1e1c4f3e306f --- /dev/null +++ b/test/js/node/test/parallel/test-diagnostics-channel-bounded-channel-scope-nested.js @@ -0,0 +1,257 @@ +/* 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 boundedChannel = dc.boundedChannel('test-nested-basic'); + const events = []; + + boundedChannel.subscribe({ + start(message) { + events.push({ type: 'start', id: message.id }); + }, + end(message) { + events.push({ type: 'end', id: message.id }); + }, + }); + + { + using outer = boundedChannel.withScope({ id: 'outer' }); + events.push({ type: 'work', id: 'outer' }); + + { + using inner = boundedChannel.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 boundedChannel = dc.boundedChannel('test-nested-stores'); + const store = new AsyncLocalStorage(); + const storeValues = []; + + boundedChannel.start.bindStore(store, (context) => context.id); + + boundedChannel.subscribe({ + start() {}, + end() {}, + }); + + assert.strictEqual(store.getStore(), undefined); + + { + using outer = boundedChannel.withScope({ id: 'outer' }); + storeValues.push(store.getStore()); + + { + using inner = boundedChannel.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.boundedChannel('test-nested-chan1'); + const channel2 = dc.boundedChannel('test-nested-chan2'); + const events = []; + + channel1.subscribe({ + start({ ...data }) { + events.push({ channel: 1, type: 'start', data }); + }, + end({ ...data }) { + events.push({ channel: 1, type: 'end', data }); + }, + }); + + channel2.subscribe({ + start({ ...data }) { + events.push({ channel: 2, type: 'start', data }); + }, + end({ ...data }) { + events.push({ channel: 2, type: 'end', data }); + }, + }); + + const contextA = { id: 'A' }; + const contextB = { id: 'B' }; + { + using scope1 = channel1.withScope(contextA); + + { + using scope2 = channel2.withScope(contextB); + contextB.result = 'B-result'; + } + + contextA.result = 'A-result'; + } + + assert.strictEqual(events.length, 4); + assert.deepStrictEqual(events, [ + { channel: 1, type: 'start', data: { id: 'A' } }, + { channel: 2, type: 'start', data: { id: 'B' } }, + { channel: 2, type: 'end', data: { id: 'B', result: 'B-result' } }, + { channel: 1, type: 'end', data: { id: 'A', result: 'A-result' } }, + ]); +} + +// Test nested scopes with shared store +{ + const channel1 = dc.boundedChannel('test-nested-shared1'); + const channel2 = dc.boundedChannel('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 boundedChannel = dc.boundedChannel('test-nested-deep'); + const store = new AsyncLocalStorage(); + const depths = []; + + boundedChannel.start.bindStore(store, (context) => context.depth); + + boundedChannel.subscribe({ + start() {}, + end() {}, + }); + + { + using s1 = boundedChannel.withScope({ depth: 1 }); + depths.push(store.getStore()); + + { + using s2 = boundedChannel.withScope({ depth: 2 }); + depths.push(store.getStore()); + + { + using s3 = boundedChannel.withScope({ depth: 3 }); + depths.push(store.getStore()); + + { + using s4 = boundedChannel.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 boundedChannel = dc.boundedChannel('test-nested-error'); + const store = new AsyncLocalStorage(); + const events = []; + + boundedChannel.start.bindStore(store, (context) => context.id); + + boundedChannel.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 = boundedChannel.withScope({ id: 'outer' }); + events.push({ type: 'store', value: store.getStore() }); + + assert.throws(() => { + using inner = boundedChannel.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/js/node/test/parallel/test-diagnostics-channel-bounded-channel-scope-transform-error.js b/test/js/node/test/parallel/test-diagnostics-channel-bounded-channel-scope-transform-error.js new file mode 100644 index 000000000000..ca3d8c46d34e --- /dev/null +++ b/test/js/node/test/parallel/test-diagnostics-channel-bounded-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 BoundedChannelScope with transform error +// Transform errors are scheduled via process.nextTick(triggerUncaughtException) + +const boundedChannel = dc.boundedChannel('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'); +})); + +boundedChannel.subscribe({ + start(message) { + events.push({ type: 'start', data: message }); + }, + end(message) { + events.push({ type: 'end', data: message }); + }, +}); + +// Bind store with a transform that throws +boundedChannel.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 = boundedChannel.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/js/node/test/parallel/test-diagnostics-channel-bounded-channel-scope.js b/test/js/node/test/parallel/test-diagnostics-channel-bounded-channel-scope.js new file mode 100644 index 000000000000..9a6d8da4155e --- /dev/null +++ b/test/js/node/test/parallel/test-diagnostics-channel-bounded-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 boundedChannel = dc.boundedChannel('test-scope-basic'); + const events = []; + + boundedChannel.subscribe({ + start({ ...data }) { + events.push({ type: 'start', data }); + }, + end({ ...data }) { + events.push({ type: 'end', data }); + }, + }); + + const context = { id: 123 }; + + { + using scope = boundedChannel.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 boundedChannel = dc.boundedChannel('test-scope-result'); + const events = []; + + boundedChannel.subscribe({ + start(message) { + events.push({ type: 'start', data: message }); + }, + end(message) { + events.push({ type: 'end', data: message }); + }, + }); + + const context = {}; + + { + using scope = boundedChannel.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 boundedChannel = dc.boundedChannel('test-scope-error-setter'); + const events = []; + + boundedChannel.subscribe({ + start(message) { + events.push({ type: 'start', data: message }); + }, + end(message) { + events.push({ type: 'end', data: message }); + }, + }); + + const context = {}; + + { + using scope = boundedChannel.withScope(context); + context.result = 'test result'; + } + + // BoundedChannel 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 boundedChannel = dc.boundedChannel('test-scope-store'); + const store = new AsyncLocalStorage(); + const events = []; + + boundedChannel.start.bindStore(store, (context) => { + return { traceId: context.traceId }; + }); + + boundedChannel.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 = boundedChannel.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 boundedChannel = dc.boundedChannel('test-scope-no-subs'); + + const context = {}; + + { + using scope = boundedChannel.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 boundedChannel = dc.boundedChannel('test-scope-manual'); + const events = []; + + boundedChannel.subscribe({ + start(message) { + events.push('start'); + }, + end(message) { + events.push('end'); + }, + }); + + const scope = boundedChannel.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 boundedChannel = dc.boundedChannel('test-scope-restore'); + const store = new AsyncLocalStorage(); + + boundedChannel.start.bindStore(store, (context) => context.value); + + boundedChannel.subscribe({ + start() {}, + end() {}, + }); + + store.enterWith('initial'); + assert.strictEqual(store.getStore(), 'initial'); + + { + using scope = boundedChannel.withScope({ value: 'scoped' }); + assert.strictEqual(store.getStore(), 'scoped'); + } + + // Should restore to previous value + assert.strictEqual(store.getStore(), 'initial'); +} diff --git a/test/js/node/test/parallel/test-diagnostics-channel-bounded-channel.js b/test/js/node/test/parallel/test-diagnostics-channel-bounded-channel.js new file mode 100644 index 000000000000..90db374a4bf7 --- /dev/null +++ b/test/js/node/test/parallel/test-diagnostics-channel-bounded-channel.js @@ -0,0 +1,105 @@ +'use strict'; +require('../common'); +const assert = require('node:assert'); +const dc = require('node:diagnostics_channel'); + +// Test BoundedChannel exports +{ + assert.strictEqual(typeof dc.boundedChannel, 'function'); + assert.strictEqual(typeof dc.BoundedChannel, 'function'); + + const wc = dc.boundedChannel('test-export'); + assert.ok(wc instanceof dc.BoundedChannel); +} + +// Test basic BoundedChannel creation and properties +{ + const boundedChannel = dc.boundedChannel('test-window-basic'); + + assert.ok(boundedChannel.start); + assert.ok(boundedChannel.end); + + assert.strictEqual(boundedChannel.start.name, 'tracing:test-window-basic:start'); + assert.strictEqual(boundedChannel.end.name, 'tracing:test-window-basic:end'); + + assert.strictEqual(boundedChannel.hasSubscribers, false); + + assert.strictEqual(typeof boundedChannel.subscribe, 'function'); + assert.strictEqual(typeof boundedChannel.unsubscribe, 'function'); + assert.strictEqual(typeof boundedChannel.run, 'function'); + assert.strictEqual(typeof boundedChannel.withScope, 'function'); +} + +// Test BoundedChannel with channel objects +{ + const startChannel = dc.channel('custom:start'); + const endChannel = dc.channel('custom:end'); + + const boundedChannel = dc.boundedChannel({ + start: startChannel, + end: endChannel, + }); + + assert.strictEqual(boundedChannel.start, startChannel); + assert.strictEqual(boundedChannel.end, endChannel); +} + +// Test subscribe/unsubscribe +{ + const boundedChannel = dc.boundedChannel('test-window-subscribe'); + const events = []; + + const handlers = { + start(message) { + events.push({ type: 'start', message }); + }, + end(message) { + events.push({ type: 'end', message }); + }, + }; + + assert.strictEqual(boundedChannel.hasSubscribers, false); + + boundedChannel.subscribe(handlers); + + assert.strictEqual(boundedChannel.hasSubscribers, true); + + // Test that events are received + boundedChannel.start.publish({ test: 'start' }); + boundedChannel.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 = boundedChannel.unsubscribe(handlers); + assert.strictEqual(result, true); + assert.strictEqual(boundedChannel.hasSubscribers, false); + + // Test unsubscribe when not subscribed + const result2 = boundedChannel.unsubscribe(handlers); + assert.strictEqual(result2, false); +} + +// Test partial subscription +{ + const boundedChannel = dc.boundedChannel('test-window-partial'); + const events = []; + + boundedChannel.subscribe({ + start(message) { + events.push('start'); + }, + }); + + assert.strictEqual(boundedChannel.hasSubscribers, true); + + boundedChannel.start.publish({}); + boundedChannel.end.publish({}); + + assert.strictEqual(events.length, 1); + assert.strictEqual(events[0], 'start'); +} diff --git a/test/js/node/test/parallel/test-diagnostics-channel-child-process.js b/test/js/node/test/parallel/test-diagnostics-channel-child-process.js new file mode 100644 index 000000000000..1d16fd429877 --- /dev/null +++ b/test/js/node/test/parallel/test-diagnostics-channel-child-process.js @@ -0,0 +1,94 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const { spawn, ChildProcess } = require('child_process'); +const dc = require('diagnostics_channel'); +const path = require('path'); +const fs = require('fs'); +const tmpdir = require('../common/tmpdir'); + +const isChildProcess = (process) => process instanceof ChildProcess; + +function testDiagnosticChannel(subscribers, test, after) { + dc.tracingChannel('child_process.spawn').subscribe(subscribers); + + test(common.mustCall(() => { + dc.tracingChannel('child_process.spawn').unsubscribe(subscribers); + after?.(); + })); +} + +const testSuccessfulSpawn = common.mustCall(() => { + let cb; + + testDiagnosticChannel( + { + start: common.mustCall(({ process: childProcess, options }) => { + assert.strictEqual(isChildProcess(childProcess), true); + assert.strictEqual(options.file, process.execPath); + }), + end: common.mustCall(({ process: childProcess }) => { + assert.strictEqual(isChildProcess(childProcess), true); + }), + error: common.mustNotCall(), + }, + common.mustCall((callback) => { + cb = callback; + const child = spawn(process.execPath, ['-e', 'process.exit(0)']); + child.on('close', () => { + cb(); + }); + }), + testFailingSpawnENOENT + ); +}); + +const testFailingSpawnENOENT = common.mustCall(() => { + testDiagnosticChannel( + { + start: common.mustCall(({ process: childProcess, options }) => { + assert.strictEqual(isChildProcess(childProcess), true); + assert.strictEqual(options.file, 'does-not-exist'); + }), + end: common.mustNotCall(), + error: common.mustCall(({ process: childProcess, error }) => { + assert.strictEqual(isChildProcess(childProcess), true); + assert.strictEqual(error.code, 'ENOENT'); + }), + }, + common.mustCall((callback) => { + const child = spawn('does-not-exist'); + child.on('error', () => {}); + callback(); + }), + common.isWindows ? undefined : testFailingSpawnEACCES, + ); +}); + +const testFailingSpawnEACCES = !common.isWindows ? common.mustCall(() => { + tmpdir.refresh(); + const noExecFile = path.join(tmpdir.path, 'no-exec'); + fs.writeFileSync(noExecFile, ''); + fs.chmodSync(noExecFile, 0o644); + + testDiagnosticChannel( + { + start: common.mustCall(({ process: childProcess, options }) => { + assert.strictEqual(isChildProcess(childProcess), true); + assert.strictEqual(options.file, noExecFile); + }), + end: common.mustNotCall(), + error: common.mustCall(({ process: childProcess, error }) => { + assert.strictEqual(isChildProcess(childProcess), true); + assert.strictEqual(error.code, 'EACCES'); + }), + }, + common.mustCall((callback) => { + const child = spawn(noExecFile); + child.on('error', () => {}); + callback(); + }), + ); +}) : undefined; + +testSuccessfulSpawn(); diff --git a/test/js/node/test/parallel/test-diagnostics-channel-gc-maintains-subcriptions.js b/test/js/node/test/parallel/test-diagnostics-channel-gc-maintains-subcriptions.js new file mode 100644 index 000000000000..7a38e6fbf9bf --- /dev/null +++ b/test/js/node/test/parallel/test-diagnostics-channel-gc-maintains-subcriptions.js @@ -0,0 +1,21 @@ +// Flags: --expose-gc +'use strict'; + +const common = require('../common'); +const assert = require('assert'); +const { channel } = require('diagnostics_channel'); + +function test() { + function subscribe() { + channel('test-gc').subscribe(function noop() {}); + } + + subscribe(); + + setTimeout(common.mustCall(() => { + global.gc(); + assert.ok(channel('test-gc').hasSubscribers, 'Channel must have subscribers'); + })); +} + +test(); diff --git a/test/js/node/test/parallel/test-diagnostics-channel-gc-race-condition.js b/test/js/node/test/parallel/test-diagnostics-channel-gc-race-condition.js new file mode 100644 index 000000000000..67259b33037c --- /dev/null +++ b/test/js/node/test/parallel/test-diagnostics-channel-gc-race-condition.js @@ -0,0 +1,23 @@ +// Flags: --expose-gc +'use strict'; + +const common = require('../common'); +const assert = require('assert'); +const { channel } = require('diagnostics_channel'); + +function test() { + const testChannel = channel('test-gc'); + + setTimeout(common.mustCall(() => { + const testChannel2 = channel('test-gc'); + + assert.ok(testChannel === testChannel2, 'Channel instances must be the same'); + })); +} + +test(); + +setTimeout(() => { + global.gc(); + test(); +}, 10); diff --git a/test/js/node/test/parallel/test-diagnostics-channel-http-server-start.js b/test/js/node/test/parallel/test-diagnostics-channel-http-server-start.js new file mode 100644 index 000000000000..ad2f6ba48872 --- /dev/null +++ b/test/js/node/test/parallel/test-diagnostics-channel-http-server-start.js @@ -0,0 +1,62 @@ +'use strict'; + +const common = require('../common'); +const { AsyncLocalStorage } = require('async_hooks'); +const dc = require('diagnostics_channel'); +const assert = require('assert'); +const http = require('http'); + +const als = new AsyncLocalStorage(); +let context; + +// Bind requests to an AsyncLocalStorage context +dc.subscribe('http.server.request.start', common.mustCall((message) => { + als.enterWith(message); + context = message; +})); + +// When the request ends, verify the context has been maintained +// and that the messages contain the expected data +dc.subscribe('http.server.response.finish', common.mustCall((message) => { + const data = { + request, + response, + server, + socket: request.socket + }; + + // Context is maintained + compare(als.getStore(), context); + + compare(context, data); + compare(message, data); +})); + +let request; +let response; + +const server = http.createServer(common.mustCall((req, res) => { + request = req; + response = res; + + setTimeout(() => { + res.end('done'); + }, 1); +})); + +server.listen(() => { + const { port } = server.address(); + http.get(`http://localhost:${port}`, (res) => { + res.resume(); + res.on('end', () => { + server.close(); + }); + }); +}); + +function compare(a, b) { + assert.strictEqual(a.request, b.request); + assert.strictEqual(a.response, b.response); + assert.strictEqual(a.socket, b.socket); + assert.strictEqual(a.server, b.server); +} diff --git a/test/js/node/test/parallel/test-diagnostics-channel-http.js b/test/js/node/test/parallel/test-diagnostics-channel-http.js new file mode 100644 index 000000000000..fd371a5d259f --- /dev/null +++ b/test/js/node/test/parallel/test-diagnostics-channel-http.js @@ -0,0 +1,87 @@ +'use strict'; +const common = require('../common'); +const { addresses } = require('../common/internet'); +const assert = require('assert'); +const http = require('http'); +const net = require('net'); +const dc = require('diagnostics_channel'); + +const isHTTPServer = (server) => server instanceof http.Server; +const isIncomingMessage = (object) => object instanceof http.IncomingMessage; +const isOutgoingMessage = (object) => object instanceof http.OutgoingMessage; +const isNetSocket = (socket) => socket instanceof net.Socket; +const isError = (error) => error instanceof Error; + +dc.subscribe('http.client.request.start', common.mustCall(({ request }) => { + assert.strictEqual(isOutgoingMessage(request), true); +}, 2)); + +dc.subscribe('http.client.request.error', common.mustCall(({ request, error }) => { + assert.strictEqual(isOutgoingMessage(request), true); + assert.strictEqual(isError(error), true); +})); + +dc.subscribe('http.client.response.finish', common.mustCall(({ + request, + response +}) => { + assert.strictEqual(isOutgoingMessage(request), true); + assert.strictEqual(isIncomingMessage(response), true); +})); + +dc.subscribe('http.server.request.start', common.mustCall(({ + request, + response, + socket, + server, +}) => { + assert.strictEqual(isIncomingMessage(request), true); + assert.strictEqual(isOutgoingMessage(response), true); + assert.strictEqual(isNetSocket(socket), true); + assert.strictEqual(isHTTPServer(server), true); +})); + +dc.subscribe('http.server.response.finish', common.mustCall(({ + request, + response, + socket, + server, +}) => { + assert.strictEqual(isIncomingMessage(request), true); + assert.strictEqual(isOutgoingMessage(response), true); + assert.strictEqual(isNetSocket(socket), true); + assert.strictEqual(isHTTPServer(server), true); +})); + +dc.subscribe('http.server.response.created', common.mustCall(({ + request, + response, +}) => { + assert.strictEqual(isIncomingMessage(request), true); + assert.strictEqual(isOutgoingMessage(response), true); +})); + +dc.subscribe('http.client.request.created', common.mustCall(({ request }) => { + assert.strictEqual(isOutgoingMessage(request), true); + assert.strictEqual(isHTTPServer(server), true); +}, 2)); + +const server = http.createServer(common.mustCall((req, res) => { + res.end('done'); +})); + +server.listen(async () => { + const { port } = server.address(); + const invalidRequest = http.get({ + host: addresses.INVALID_HOST, + }); + await new Promise((resolve) => { + invalidRequest.on('error', resolve); + }); + http.get(`http://localhost:${port}`, (res) => { + res.resume(); + res.on('end', () => { + server.close(); + }); + }); +}); diff --git a/test/js/node/test/parallel/test-diagnostics-channel-memory-leak.js b/test/js/node/test/parallel/test-diagnostics-channel-memory-leak.js new file mode 100644 index 000000000000..06301847ed6b --- /dev/null +++ b/test/js/node/test/parallel/test-diagnostics-channel-memory-leak.js @@ -0,0 +1,22 @@ +// Flags: --max-old-space-size=16 +'use strict'; + +// This test ensures that diagnostic channel references aren't leaked. + +const common = require('../common'); + +const { subscribe, unsubscribe, Channel } = require('diagnostics_channel'); +const { checkIfCollectableByCounting } = require('../common/gc'); + +function noop() {} + +const outer = 64; +const inner = 256; +checkIfCollectableByCounting((i) => { + for (let j = 0; j < inner; j++) { + const key = String(i * inner + j); + subscribe(key, noop); + unsubscribe(key, noop); + } + return inner; +}, Channel, outer).then(common.mustCall()); diff --git a/test/js/node/test/parallel/test-diagnostics-channel-module-import-error.js b/test/js/node/test/parallel/test-diagnostics-channel-module-import-error.js new file mode 100644 index 000000000000..f7fb65737a8f --- /dev/null +++ b/test/js/node/test/parallel/test-diagnostics-channel-module-import-error.js @@ -0,0 +1,65 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const dc = require('diagnostics_channel'); +const { pathToFileURL } = require('url'); + +const trace = dc.tracingChannel('module.import'); +const events = []; +let lastEvent; + +function track(name) { + return common.mustCall((event) => { + // Verify every event after the first is the same object + if (events.length) { + assert.strictEqual(event, lastEvent); + } + lastEvent = event; + + events.push({ name, ...event }); + }); +} + +trace.subscribe({ + start: common.mustCall(track('start')), + end: common.mustCall(track('end')), + asyncStart: common.mustCall(track('asyncStart')), + asyncEnd: common.mustCall(track('asyncEnd')), + error: common.mustCall(track('error')), +}); + +assert.rejects(import('does-not-exist'), (error) => { + const expectedParentURL = pathToFileURL(module.filename).href; + // Verify order and contents of each event + assert.deepStrictEqual(events, [ + { + name: 'start', + parentURL: expectedParentURL, + url: 'does-not-exist', + }, + { + name: 'end', + parentURL: expectedParentURL, + url: 'does-not-exist', + }, + { + name: 'error', + parentURL: expectedParentURL, + url: 'does-not-exist', + error, + }, + { + name: 'asyncStart', + parentURL: expectedParentURL, + url: 'does-not-exist', + error, + }, + { + name: 'asyncEnd', + parentURL: expectedParentURL, + url: 'does-not-exist', + error, + }, + ]); + return true; +}).then(common.mustCall()); diff --git a/test/js/node/test/parallel/test-diagnostics-channel-module-require-error.js b/test/js/node/test/parallel/test-diagnostics-channel-module-require-error.js new file mode 100644 index 000000000000..818e5c975784 --- /dev/null +++ b/test/js/node/test/parallel/test-diagnostics-channel-module-require-error.js @@ -0,0 +1,56 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const dc = require('diagnostics_channel'); + +const trace = dc.tracingChannel('module.require'); +const events = []; +let lastEvent; + +function track(name) { + return common.mustCall((event) => { + // Verify every event after the first is the same object + if (events.length) { + assert.strictEqual(event, lastEvent); + } + lastEvent = event; + + events.push({ name, ...event }); + }); +} + +trace.subscribe({ + start: track('start'), + end: track('end'), + asyncStart: common.mustNotCall('asyncStart'), + asyncEnd: common.mustNotCall('asyncEnd'), + error: track('error'), +}); + +let error; +try { + require('does-not-exist'); +} catch (err) { + error = err; +} + +// Verify order and contents of each event +assert.deepStrictEqual(events, [ + { + name: 'start', + parentFilename: module.filename, + id: 'does-not-exist', + }, + { + name: 'error', + parentFilename: module.filename, + id: 'does-not-exist', + error, + }, + { + name: 'end', + parentFilename: module.filename, + id: 'does-not-exist', + error, + }, +]); diff --git a/test/js/node/test/parallel/test-diagnostics-channel-module-require.js b/test/js/node/test/parallel/test-diagnostics-channel-module-require.js new file mode 100644 index 000000000000..6b16a3b5de65 --- /dev/null +++ b/test/js/node/test/parallel/test-diagnostics-channel-module-require.js @@ -0,0 +1,45 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const dc = require('diagnostics_channel'); + +const trace = dc.tracingChannel('module.require'); +const events = []; +let lastEvent; + +function track(name) { + return common.mustCall((event) => { + // Verify every event after the first is the same object + if (events.length) { + assert.strictEqual(event, lastEvent); + } + lastEvent = event; + + events.push({ name, ...event }); + }); +} + +trace.subscribe({ + start: track('start'), + end: track('end'), + asyncStart: common.mustNotCall('asyncStart'), + asyncEnd: common.mustNotCall('asyncEnd'), + error: common.mustNotCall('error'), +}); + +const result = require('http'); + +// Verify order and contents of each event +assert.deepStrictEqual(events, [ + { + name: 'start', + parentFilename: module.filename, + id: 'http', + }, + { + name: 'end', + parentFilename: module.filename, + id: 'http', + result, + }, +]); diff --git a/test/js/node/test/parallel/test-diagnostics-channel-net-client-socket-tls.js b/test/js/node/test/parallel/test-diagnostics-channel-net-client-socket-tls.js new file mode 100644 index 000000000000..c887376fd288 --- /dev/null +++ b/test/js/node/test/parallel/test-diagnostics-channel-net-client-socket-tls.js @@ -0,0 +1,32 @@ +'use strict'; +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); + +// This test ensures that the 'net.client.socket' diagnostics channel publishes +// a message when tls.connect() is used to create a socket connection. + +const assert = require('assert'); +const dc = require('diagnostics_channel'); +const fixtures = require('../common/fixtures'); +const tls = require('tls'); + +const options = { + key: fixtures.readKey('agent1-key.pem'), + cert: fixtures.readKey('agent1-cert.pem'), + rejectUnauthorized: false, +}; + +dc.subscribe('net.client.socket', common.mustCall(({ socket }) => { + assert.strictEqual(socket instanceof tls.TLSSocket, true); +})); + +const server = tls.createServer(options, common.mustCall((socket) => { + socket.destroy(); + server.close(); +})); + +server.listen({ port: 0 }, common.mustCall(() => { + const { port } = server.address(); + tls.connect(port, options); +})); diff --git a/test/js/node/test/parallel/test-diagnostics-channel-net.js b/test/js/node/test/parallel/test-diagnostics-channel-net.js new file mode 100644 index 000000000000..85c5d8f8a99e --- /dev/null +++ b/test/js/node/test/parallel/test-diagnostics-channel-net.js @@ -0,0 +1,101 @@ +'use strict'; +const common = require('../common'); +const Countdown = require('../common/countdown'); +const assert = require('assert'); +const net = require('net'); +const dc = require('diagnostics_channel'); + +const isNetSocket = (socket) => socket instanceof net.Socket; +const isNetServer = (server) => server instanceof net.Server; + +function testDiagnosticChannel(subscribers, test, after) { + dc.tracingChannel('net.server.listen').subscribe(subscribers); + + test(common.mustCall(() => { + dc.tracingChannel('net.server.listen').unsubscribe(subscribers); + after?.(); + })); +} + +const testSuccessfulListen = common.mustCall(() => { + let cb; + const netClientSocketCount = 3; + const countdown = new Countdown(netClientSocketCount, () => { + server.close(); + cb(); + }); + const server = net.createServer(common.mustCall((socket) => { + socket.destroy(); + countdown.dec(); + }, netClientSocketCount)); + + dc.subscribe('net.client.socket', common.mustCall(({ socket }) => { + assert.strictEqual(isNetSocket(socket), true); + }, netClientSocketCount)); + + dc.subscribe('net.server.socket', common.mustCall(({ socket }) => { + assert.strictEqual(isNetSocket(socket), true); + }, netClientSocketCount)); + + testDiagnosticChannel( + { + asyncStart: common.mustCall(({ server: currentServer, options }) => { + assert.strictEqual(isNetServer(server), true); + assert.strictEqual(currentServer, server); + assert.strictEqual(options.customOption, true); + }), + asyncEnd: common.mustCall(({ server: currentServer }) => { + assert.strictEqual(isNetServer(server), true); + assert.strictEqual(currentServer, server); + }), + error: common.mustNotCall() + }, + common.mustCall((callback) => { + cb = callback; + server.listen({ port: 0, customOption: true }, () => { + // All supported ways of creating a net client socket connection. + const { port } = server.address(); + net.connect(port); + + net.createConnection(port); + + new net.Socket().connect(port); + }); + }), + testFailingListen + ); +}); + +const testFailingListen = common.mustCall(() => { + const originalServer = net.createServer(common.mustNotCall()); + + originalServer.listen(common.mustCall(() => { + const server = net.createServer(common.mustNotCall()); + + testDiagnosticChannel( + { + asyncStart: common.mustCall(({ server: currentServer, options }) => { + assert.strictEqual(isNetServer(server), true); + assert.strictEqual(currentServer, server); + assert.strictEqual(options.customOption, true); + }), + asyncEnd: common.mustNotCall(), + error: common.mustCall(({ server: currentServer }) => { + assert.strictEqual(isNetServer(server), true); + assert.strictEqual(currentServer, server); + }), + }, + common.mustCall((callback) => { + server.on('error', () => {}); + server.listen({ port: originalServer.address().port, customOption: true }); + callback(); + }), + common.mustCall(() => { + originalServer.close(); + server.close(); + }) + ); + })); +}); + +testSuccessfulListen(); diff --git a/test/js/node/test/parallel/test-diagnostics-channel-process.js b/test/js/node/test/parallel/test-diagnostics-channel-process.js new file mode 100644 index 000000000000..3ca6e2cd4f24 --- /dev/null +++ b/test/js/node/test/parallel/test-diagnostics-channel-process.js @@ -0,0 +1,21 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const cluster = require('cluster'); +const { ChildProcess } = require('child_process'); +const dc = require('diagnostics_channel'); + +if (cluster.isPrimary) { + dc.subscribe('child_process', common.mustCall(({ process }) => { + assert.strictEqual(process instanceof ChildProcess, true); + })); + const worker = cluster.fork(); + worker.on('online', common.mustCall(() => { + worker.send('disconnect'); + })); +} else { + process.on('message', common.mustCall((msg) => { + assert.strictEqual(msg, 'disconnect'); + process.disconnect(); + })); +} diff --git a/test/js/node/test/parallel/test-diagnostics-channel-run-stores-scope-transform-error.js b/test/js/node/test/parallel/test-diagnostics-channel-run-stores-scope-transform-error.js new file mode 100644 index 000000000000..04bc7eaee46b --- /dev/null +++ b/test/js/node/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/js/node/test/parallel/test-diagnostics-channel-run-stores-scope.js b/test/js/node/test/parallel/test-diagnostics-channel-run-stores-scope.js new file mode 100644 index 000000000000..54b4417882d9 --- /dev/null +++ b/test/js/node/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/js/node/test/parallel/test-diagnostics-channel-sync-unsubscribe.js b/test/js/node/test/parallel/test-diagnostics-channel-sync-unsubscribe.js index 87bf44249f5f..51db6a56c238 100644 --- a/test/js/node/test/parallel/test-diagnostics-channel-sync-unsubscribe.js +++ b/test/js/node/test/parallel/test-diagnostics-channel-sync-unsubscribe.js @@ -9,6 +9,7 @@ const published_data = 'some message'; const onMessageHandler = common.mustCall(() => dc.unsubscribe(channel_name, onMessageHandler)); dc.subscribe(channel_name, onMessageHandler); +dc.subscribe(channel_name, common.mustCall()); // This must not throw. dc.channel(channel_name).publish(published_data); diff --git a/test/js/node/test/parallel/test-diagnostics-channel-tracing-channel-args-types.js b/test/js/node/test/parallel/test-diagnostics-channel-tracing-channel-args-types.js new file mode 100644 index 000000000000..a96b303aa153 --- /dev/null +++ b/test/js/node/test/parallel/test-diagnostics-channel-tracing-channel-args-types.js @@ -0,0 +1,39 @@ +'use strict'; + +require('../common'); +const dc = require('diagnostics_channel'); +const assert = require('assert'); + +let channel; + +// tracingChannel creating with name +channel = dc.tracingChannel('test'); +assert.strictEqual(channel.start.name, 'tracing:test:start'); + +// tracingChannel creating with channels +channel = dc.tracingChannel({ + start: dc.channel('tracing:test:start'), + end: dc.channel('tracing:test:end'), + asyncStart: dc.channel('tracing:test:asyncStart'), + asyncEnd: dc.channel('tracing:test:asyncEnd'), + error: dc.channel('tracing:test:error'), +}); + +// tracingChannel creating without nameOrChannels must throw TypeError +assert.throws(() => (channel = dc.tracingChannel(0)), { + code: 'ERR_INVALID_ARG_TYPE', + name: 'TypeError', + message: + /The "nameOrChannels" argument must be of type string or an instance of TracingChannel or Object/, +}); + +// tracingChannel creating without instance of Channel must throw error +assert.throws(() => (channel = dc.tracingChannel({ start: '' })), { + code: 'ERR_INVALID_ARG_TYPE', + message: /The "nameOrChannels\.start" property must be an instance of Channel/, +}); + +// tracingChannel creating with empty nameOrChannels must throw error +assert.throws(() => (channel = dc.tracingChannel({})), { + message: /Cannot convert undefined or null to object/, +}); diff --git a/test/js/node/test/parallel/test-diagnostics-channel-tracing-channel-callback-early-exit.js b/test/js/node/test/parallel/test-diagnostics-channel-tracing-channel-callback-early-exit.js new file mode 100644 index 000000000000..6ba5fd17bb4e --- /dev/null +++ b/test/js/node/test/parallel/test-diagnostics-channel-tracing-channel-callback-early-exit.js @@ -0,0 +1,19 @@ +'use strict'; + +const common = require('../common'); +const dc = require('diagnostics_channel'); + +const channel = dc.tracingChannel('test'); + +const handlers = { + start: common.mustNotCall(), + end: common.mustNotCall(), + asyncStart: common.mustNotCall(), + asyncEnd: common.mustNotCall(), + error: common.mustNotCall() +}; + +// While subscribe occurs _before_ the callback executes, +// no async events should be published. +channel.traceCallback(setImmediate, 0, {}, null, common.mustCall()); +channel.subscribe(handlers); diff --git a/test/js/node/test/parallel/test-diagnostics-channel-tracing-channel-callback-error.js b/test/js/node/test/parallel/test-diagnostics-channel-tracing-channel-callback-error.js index 672500e76832..0767db3b7ad5 100644 --- a/test/js/node/test/parallel/test-diagnostics-channel-tracing-channel-callback-error.js +++ b/test/js/node/test/parallel/test-diagnostics-channel-tracing-channel-callback-error.js @@ -27,10 +27,10 @@ const handlers = { channel.subscribe(handlers); -channel.traceCallback(function(cb, err) { +channel.traceCallback(common.mustCall(function(cb, err) { assert.deepStrictEqual(this, thisArg); setImmediate(cb, err); -}, 0, input, thisArg, common.mustCall((err, res) => { +}), 0, input, thisArg, common.mustCall((err, res) => { assert.strictEqual(err, expectedError); assert.strictEqual(res, undefined); }), expectedError); diff --git a/test/js/node/test/parallel/test-diagnostics-channel-tracing-channel-callback.js b/test/js/node/test/parallel/test-diagnostics-channel-tracing-channel-callback.js index d306f0f51b7c..dbe2406475ac 100644 --- a/test/js/node/test/parallel/test-diagnostics-channel-tracing-channel-callback.js +++ b/test/js/node/test/parallel/test-diagnostics-channel-tracing-channel-callback.js @@ -30,10 +30,10 @@ const handlers = { channel.subscribe(handlers); -channel.traceCallback(function(cb, err, res) { +channel.traceCallback(common.mustCall(function(cb, err, res) { assert.deepStrictEqual(this, thisArg); setImmediate(cb, err, res); -}, 0, input, thisArg, common.mustCall((err, res) => { +}), 0, input, thisArg, common.mustCall((err, res) => { assert.strictEqual(err, null); assert.deepStrictEqual(res, expectedResult); }), null, expectedResult); diff --git a/test/js/node/test/parallel/test-diagnostics-channel-tracing-channel-has-subscribers.js b/test/js/node/test/parallel/test-diagnostics-channel-tracing-channel-has-subscribers.js new file mode 100644 index 000000000000..2ae25d9848c8 --- /dev/null +++ b/test/js/node/test/parallel/test-diagnostics-channel-tracing-channel-has-subscribers.js @@ -0,0 +1,51 @@ +'use strict'; + +const common = require('../common'); +const dc = require('diagnostics_channel'); +const assert = require('assert'); + +const handler = common.mustNotCall(); + +{ + const handlers = { + start: common.mustNotCall() + }; + + const channel = dc.tracingChannel('test'); + + assert.strictEqual(channel.hasSubscribers, false); + + channel.subscribe(handlers); + assert.strictEqual(channel.hasSubscribers, true); + + channel.unsubscribe(handlers); + assert.strictEqual(channel.hasSubscribers, false); + + channel.start.subscribe(handler); + assert.strictEqual(channel.hasSubscribers, true); + + channel.start.unsubscribe(handler); + assert.strictEqual(channel.hasSubscribers, false); +} + +{ + const handlers = { + asyncEnd: common.mustNotCall() + }; + + const channel = dc.tracingChannel('test'); + + assert.strictEqual(channel.hasSubscribers, false); + + channel.subscribe(handlers); + assert.strictEqual(channel.hasSubscribers, true); + + channel.unsubscribe(handlers); + assert.strictEqual(channel.hasSubscribers, false); + + channel.asyncEnd.subscribe(handler); + assert.strictEqual(channel.hasSubscribers, true); + + channel.asyncEnd.unsubscribe(handler); + assert.strictEqual(channel.hasSubscribers, false); +} diff --git a/test/js/node/test/parallel/test-diagnostics-channel-tracing-channel-promise-early-exit.js b/test/js/node/test/parallel/test-diagnostics-channel-tracing-channel-promise-early-exit.js new file mode 100644 index 000000000000..fce7f40b753b --- /dev/null +++ b/test/js/node/test/parallel/test-diagnostics-channel-tracing-channel-promise-early-exit.js @@ -0,0 +1,21 @@ +'use strict'; + +const common = require('../common'); +const dc = require('diagnostics_channel'); + +const channel = dc.tracingChannel('test'); + +const handlers = { + start: common.mustNotCall(), + end: common.mustNotCall(), + asyncStart: common.mustNotCall(), + asyncEnd: common.mustNotCall(), + error: common.mustNotCall() +}; + +// While subscribe occurs _before_ the promise resolves, +// no async events should be published. +channel.tracePromise(() => { + return new Promise(setImmediate); +}, {}); +channel.subscribe(handlers); diff --git a/test/js/node/test/parallel/test-diagnostics-channel-tracing-channel-promise-error.js b/test/js/node/test/parallel/test-diagnostics-channel-tracing-channel-promise-error.js index f1f52d72f800..4a193b52a764 100644 --- a/test/js/node/test/parallel/test-diagnostics-channel-tracing-channel-promise-error.js +++ b/test/js/node/test/parallel/test-diagnostics-channel-tracing-channel-promise-error.js @@ -27,12 +27,10 @@ const handlers = { channel.subscribe(handlers); -channel.tracePromise(function(value) { - assert.deepStrictEqual(this, thisArg); - return Promise.reject(value); -}, input, thisArg, expectedError).then( - common.mustNotCall(), - common.mustCall((value) => { - assert.deepStrictEqual(value, expectedError); - }) -); +assert.rejects( + channel.tracePromise(common.mustCall(function(value) { + assert.deepStrictEqual(this, thisArg); + return Promise.reject(value); + }), input, thisArg, expectedError), + expectedError, +).then(common.mustCall()); diff --git a/test/js/node/test/parallel/test-diagnostics-channel-tracing-channel-promise-non-thenable.js b/test/js/node/test/parallel/test-diagnostics-channel-tracing-channel-promise-non-thenable.js new file mode 100644 index 000000000000..dc00e0e5a97c --- /dev/null +++ b/test/js/node/test/parallel/test-diagnostics-channel-tracing-channel-promise-non-thenable.js @@ -0,0 +1,46 @@ +'use strict'; + +const common = require('../common'); +const dc = require('diagnostics_channel'); +const assert = require('assert'); + +const channel = dc.tracingChannel('test'); + +const expectedResult = { foo: 'bar' }; +const input = { foo: 'bar' }; +const thisArg = { baz: 'buz' }; + +function checkStart(found) { + assert.strictEqual(found, input); +} + +function checkEnd(found) { + checkStart(found); + assert.strictEqual(found.error, undefined); + assert.deepStrictEqual(found.result, expectedResult); +} + +const handlers = { + start: common.mustCall(checkStart), + end: common.mustCall(checkEnd), + asyncStart: common.mustNotCall(), + asyncEnd: common.mustNotCall(), + error: common.mustNotCall() +}; + +channel.subscribe(handlers); + +process.on('warning', common.mustCall((warning) => { + assert.strictEqual( + warning.message, + "tracePromise was called with the function '', which returned a non-thenable." + ); +})); + +assert.strictEqual( + channel.tracePromise(common.mustCall(function(value) { + assert.deepStrictEqual(this, thisArg); + return value; + }), input, thisArg, expectedResult), + expectedResult, +); diff --git a/test/js/node/test/parallel/test-diagnostics-channel-tracing-channel-promise-run-stores.js b/test/js/node/test/parallel/test-diagnostics-channel-tracing-channel-promise-run-stores.js index 5292a6fe096b..3fdacf2f275b 100644 --- a/test/js/node/test/parallel/test-diagnostics-channel-tracing-channel-promise-run-stores.js +++ b/test/js/node/test/parallel/test-diagnostics-channel-tracing-channel-promise-run-stores.js @@ -16,7 +16,7 @@ channel.start.bindStore(store, common.mustCall(() => { return firstContext; })); -channel.asyncStart.bindStore(store, common.mustNotCall(() => { +channel.asyncStart.bindStore(store, common.mustCall(() => { return secondContext; })); @@ -27,5 +27,7 @@ channel.tracePromise(common.mustCall(async () => { // Should _not_ switch to second context as promises don't have an "after" // point at which to do a runStores. assert.deepStrictEqual(store.getStore(), firstContext); +})).then(common.mustCall(() => { + assert.strictEqual(store.getStore(), undefined); })); assert.strictEqual(store.getStore(), undefined); diff --git a/test/js/node/test/parallel/test-diagnostics-channel-tracing-channel-promise-thenable.js b/test/js/node/test/parallel/test-diagnostics-channel-tracing-channel-promise-thenable.js new file mode 100644 index 000000000000..b93be1dd304c --- /dev/null +++ b/test/js/node/test/parallel/test-diagnostics-channel-tracing-channel-promise-thenable.js @@ -0,0 +1,55 @@ +'use strict'; + +const common = require('../common'); +const dc = require('diagnostics_channel'); +const assert = require('assert'); + +class ResolvedThenable { + #result; + constructor(value) { + this.#result = value; + } + then(resolve) { + return new ResolvedThenable(resolve(this.#result)); + } +} + +const channel = dc.tracingChannel('test'); + +const expectedResult = { foo: 'bar' }; +const input = { foo: 'bar' }; +const thisArg = { baz: 'buz' }; + +function check(found) { + assert.strictEqual(found, input); +} + +function checkAsync(found) { + check(found); + assert.strictEqual(found.error, undefined); + assert.deepStrictEqual(found.result, expectedResult); +} + +const handlers = { + start: common.mustCall(check), + end: common.mustCall(check), + asyncStart: common.mustCall(checkAsync), + asyncEnd: common.mustCall(checkAsync), + error: common.mustNotCall() +}; + +channel.subscribe(handlers); + +let innerThenable; + +const result = channel.tracePromise(common.mustCall(function(value) { + assert.deepStrictEqual(this, thisArg); + innerThenable = new ResolvedThenable(value); + return innerThenable; +}), input, thisArg, expectedResult); + +assert(result instanceof ResolvedThenable); +assert.notStrictEqual(result, innerThenable); +result.then(common.mustCall((value) => { + assert.deepStrictEqual(value, expectedResult); +})); diff --git a/test/js/node/test/parallel/test-diagnostics-channel-tracing-channel-promise-unhandled.js b/test/js/node/test/parallel/test-diagnostics-channel-tracing-channel-promise-unhandled.js new file mode 100644 index 000000000000..991e5d42091a --- /dev/null +++ b/test/js/node/test/parallel/test-diagnostics-channel-tracing-channel-promise-unhandled.js @@ -0,0 +1,38 @@ +'use strict'; + +const common = require('../common'); +const dc = require('diagnostics_channel'); +const assert = require('assert'); + +const channel = dc.tracingChannel('test'); + +const expectedError = new Error('test'); +const input = { foo: 'bar' }; +const thisArg = { baz: 'buz' }; + +process.on('unhandledRejection', common.mustCall((reason) => { + assert.deepStrictEqual(reason, expectedError); +})); + +function check(found) { + assert.deepStrictEqual(found, input); +} + +const handlers = { + start: common.mustCall(check), + end: common.mustCall(check), + asyncStart: common.mustCall(check), + asyncEnd: common.mustCall(check), + error: common.mustCall((found) => { + check(found); + assert.deepStrictEqual(found.error, expectedError); + }) +}; + +channel.subscribe(handlers); + +// Set no then/catch handler to verify unhandledRejection happens +channel.tracePromise(common.mustCall(function(value) { + assert.deepStrictEqual(this, thisArg); + return Promise.reject(value); +}), input, thisArg, expectedError); diff --git a/test/js/node/test/parallel/test-diagnostics-channel-tracing-channel-promise.js b/test/js/node/test/parallel/test-diagnostics-channel-tracing-channel-promise.js index 20892ca40f59..1ac1a5649628 100644 --- a/test/js/node/test/parallel/test-diagnostics-channel-tracing-channel-promise.js +++ b/test/js/node/test/parallel/test-diagnostics-channel-tracing-channel-promise.js @@ -30,12 +30,11 @@ const handlers = { channel.subscribe(handlers); -channel.tracePromise(function(value) { +channel.tracePromise(common.mustCall(function(value) { assert.deepStrictEqual(this, thisArg); return Promise.resolve(value); -}, input, thisArg, expectedResult).then( +}), input, thisArg, expectedResult).then( common.mustCall((value) => { assert.deepStrictEqual(value, expectedResult); }), - common.mustNotCall() ); diff --git a/test/js/node/test/parallel/test-diagnostics-channel-tracing-channel-sync-early-exit.js b/test/js/node/test/parallel/test-diagnostics-channel-tracing-channel-sync-early-exit.js new file mode 100644 index 000000000000..7568e6626d9f --- /dev/null +++ b/test/js/node/test/parallel/test-diagnostics-channel-tracing-channel-sync-early-exit.js @@ -0,0 +1,20 @@ +'use strict'; + +const common = require('../common'); +const dc = require('diagnostics_channel'); + +const channel = dc.tracingChannel('test'); + +const handlers = { + start: common.mustNotCall(), + end: common.mustNotCall(), + asyncStart: common.mustNotCall(), + asyncEnd: common.mustNotCall(), + error: common.mustNotCall() +}; + +// While subscribe occurs _before_ the sync call ends, +// no end event should be published. +channel.traceSync(() => { + channel.subscribe(handlers); +}, {}); diff --git a/test/js/node/test/parallel/test-diagnostics-channel-tracing-channel-sync-error.js b/test/js/node/test/parallel/test-diagnostics-channel-tracing-channel-sync-error.js index 0965bf3fb495..b7b5084bae61 100644 --- a/test/js/node/test/parallel/test-diagnostics-channel-tracing-channel-sync-error.js +++ b/test/js/node/test/parallel/test-diagnostics-channel-tracing-channel-sync-error.js @@ -27,11 +27,11 @@ const handlers = { channel.subscribe(handlers); try { - channel.traceSync(function(err) { + channel.traceSync(common.mustCall(function(err) { assert.deepStrictEqual(this, thisArg); assert.strictEqual(err, expectedError); throw err; - }, input, thisArg, expectedError); + }), input, thisArg, expectedError); throw new Error('It should not reach this error'); } catch (error) { diff --git a/test/js/node/test/parallel/test-diagnostics-channel-tracing-channel-sync.js b/test/js/node/test/parallel/test-diagnostics-channel-tracing-channel-sync.js index b28b47256b75..1cc5feb6d0dc 100644 --- a/test/js/node/test/parallel/test-diagnostics-channel-tracing-channel-sync.js +++ b/test/js/node/test/parallel/test-diagnostics-channel-tracing-channel-sync.js @@ -29,18 +29,18 @@ const handlers = { assert.strictEqual(channel.start.hasSubscribers, false); channel.subscribe(handlers); assert.strictEqual(channel.start.hasSubscribers, true); -const result1 = channel.traceSync(function(arg1) { +const result1 = channel.traceSync(common.mustCall(function(arg1) { assert.strictEqual(arg1, arg); assert.strictEqual(this, thisArg); return expectedResult; -}, input, thisArg, arg); +}), input, thisArg, arg); assert.strictEqual(result1, expectedResult); channel.unsubscribe(handlers); assert.strictEqual(channel.start.hasSubscribers, false); -const result2 = channel.traceSync(function(arg1) { +const result2 = channel.traceSync(common.mustCall(function(arg1) { assert.strictEqual(arg1, arg); assert.strictEqual(this, thisArg); return expectedResult; -}, input, thisArg, arg); +}), input, thisArg, arg); assert.strictEqual(result2, expectedResult); diff --git a/test/js/node/test/parallel/test-diagnostics-channel-worker-threads.js b/test/js/node/test/parallel/test-diagnostics-channel-worker-threads.js new file mode 100644 index 000000000000..786b77da1709 --- /dev/null +++ b/test/js/node/test/parallel/test-diagnostics-channel-worker-threads.js @@ -0,0 +1,11 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const { Worker } = require('worker_threads'); +const dc = require('diagnostics_channel'); + +dc.subscribe('worker_threads', common.mustCall(({ worker }) => { + assert.strictEqual(worker instanceof Worker, true); +})); + +new Worker('const a = 1;', { eval: true }); diff --git a/test/js/node/test/parallel/test-v8-query-objects.js b/test/js/node/test/parallel/test-v8-query-objects.js new file mode 100644 index 000000000000..8e9616b2f5a3 --- /dev/null +++ b/test/js/node/test/parallel/test-v8-query-objects.js @@ -0,0 +1,104 @@ +'use strict'; + +// This tests the v8.queryObjects() API. + +const common = require('../common'); +const v8 = require('v8'); +const assert = require('assert'); +const { inspect } = require('util'); + +function format(obj) { + return inspect(obj, { depth: 0 }); +} + +common.expectWarning( + 'ExperimentalWarning', + 'v8.queryObjects() is an experimental feature and might change at any time', +); + +{ + for (const invalid of [undefined, 1, null, false, {}, 'foo']) { + assert.throws(() => v8.queryObjects(invalid), { code: 'ERR_INVALID_ARG_TYPE' }); + } + for (const invalid of [1, null, false, 'foo']) { + assert.throws(() => v8.queryObjects(() => {}, invalid), { code: 'ERR_INVALID_ARG_TYPE' }); + } + assert.throws(() => v8.queryObjects(() => {}, { format: 'invalid' }), { code: 'ERR_INVALID_ARG_VALUE' }); +} + +{ + class TestV8QueryObjectsClass {} + // By default, returns count of objects with the constructor on the prototype. + assert.strictEqual(v8.queryObjects(TestV8QueryObjectsClass), 0); + assert.strictEqual(v8.queryObjects(TestV8QueryObjectsClass, { format: 'count' }), 0); + // 'summary' format returns an array. + assert.deepStrictEqual(v8.queryObjects(TestV8QueryObjectsClass, { format: 'summary' }), []); + + // Create an instance and check that it shows up in the results. + const obj = new TestV8QueryObjectsClass(); + assert.strictEqual(v8.queryObjects(TestV8QueryObjectsClass), 1); + assert.strictEqual(v8.queryObjects(TestV8QueryObjectsClass, { format: 'count' }), 1); + assert.deepStrictEqual( + v8.queryObjects(TestV8QueryObjectsClass, { format: 'summary' }), + [ format(obj)] + ); +} + +{ + // ES6 class inheritance. + class TestV8QueryObjectsBaseClass {} + class TestV8QueryObjectsChildClass extends TestV8QueryObjectsBaseClass {} + const summary = v8.queryObjects(TestV8QueryObjectsBaseClass, { format: 'summary' }); + // TestV8QueryObjectsChildClass's prototype's [[Prototype]] slot is + // TestV8QueryObjectsBaseClass's prototype so it shows up in the query. + assert.deepStrictEqual(summary, [ + format(TestV8QueryObjectsChildClass.prototype), + ]); + const obj = new TestV8QueryObjectsChildClass(); + assert.deepStrictEqual( + v8.queryObjects(TestV8QueryObjectsBaseClass, { format: 'summary' }).sort(), + [ + format(TestV8QueryObjectsChildClass.prototype), + format(obj), + ].sort() + ); + assert.deepStrictEqual( + v8.queryObjects(TestV8QueryObjectsChildClass, { format: 'summary' }), + [ format(obj) ], + ); +} + +{ + function TestV8QueryObjectsCtor() {} + assert.strictEqual(v8.queryObjects(TestV8QueryObjectsCtor), 0); + assert.strictEqual(v8.queryObjects(TestV8QueryObjectsCtor, { format: 'count' }), 0); + assert.deepStrictEqual(v8.queryObjects(TestV8QueryObjectsCtor, { format: 'summary' }), []); + + // Create an instance and check that it shows up in the results. + const obj = new TestV8QueryObjectsCtor(); + assert.strictEqual(v8.queryObjects(TestV8QueryObjectsCtor), 1); + assert.strictEqual(v8.queryObjects(TestV8QueryObjectsCtor, { format: 'count' }), 1); + assert.deepStrictEqual( + v8.queryObjects(TestV8QueryObjectsCtor, { format: 'summary' }), + [ format(obj)] + ); +} + +{ + // Classic inheritance. + function TestV8QueryObjectsBaseCtor() {} + + function TestV8QueryObjectsChildCtor() {} + Object.setPrototypeOf(TestV8QueryObjectsChildCtor.prototype, TestV8QueryObjectsBaseCtor.prototype); + Object.setPrototypeOf(TestV8QueryObjectsChildCtor, TestV8QueryObjectsBaseCtor); + + const summary = v8.queryObjects(TestV8QueryObjectsBaseCtor, { format: 'summary' }); + assert.deepStrictEqual(summary, [ + format(TestV8QueryObjectsChildCtor.prototype), + ]); + const obj = new TestV8QueryObjectsChildCtor(); + assert.deepStrictEqual( + v8.queryObjects(TestV8QueryObjectsChildCtor, { format: 'summary' }), + [ format(obj) ], + ); +} diff --git a/test/napi/node-napi-tests/harness.ts b/test/napi/node-napi-tests/harness.ts index b699c0173463..42468b6533e5 100644 --- a/test/napi/node-napi-tests/harness.ts +++ b/test/napi/node-napi-tests/harness.ts @@ -216,10 +216,14 @@ export function run(dir: string, test: string) { cmd: [bunExe(), "run", test], cwd: dir, stderr: "inherit", - stdout: "ignore", + stdout: "pipe", stdin: "inherit", env: envFor(test), }); + if (!result.success) { + const stdout = result.stdout.toString(); + if (stdout.length > 0) console.error(`--- ${test} stdout ---\n${stdout}`); + } expect(result.success).toBeTrue(); expect(result.exitCode).toBe(0); } @@ -230,11 +234,14 @@ export async function runAsync(dir: string, test: string) { cmd: [bunExe(), "run", test], cwd: dir, stderr: "inherit", - stdout: "ignore", + stdout: "pipe", stdin: "inherit", env: envFor(test), }); - const exitCode = await child.exited; + const [stdout, exitCode] = await Promise.all([new Response(child.stdout).text(), child.exited]); + if (exitCode !== 0 && stdout.length > 0) { + console.error(`--- ${test} stdout ---\n${stdout}`); + } expect(child.signalCode).toBeNull(); expect(exitCode).toBe(0); } diff --git a/test/napi/node-napi-tests/test/js-native-api/test_function/test.js b/test/napi/node-napi-tests/test/js-native-api/test_function/test.js index a976540f5d62..eba665c1285f 100644 --- a/test/napi/node-napi-tests/test/js-native-api/test_function/test.js +++ b/test/napi/node-napi-tests/test/js-native-api/test_function/test.js @@ -31,10 +31,15 @@ assert.strictEqual(test_function.TestCall(func4, 1), 2); assert.strictEqual(test_function.TestName.name, 'Name'); assert.strictEqual(test_function.TestNameShort.name, 'Name_'); -let tracked_function = test_function.MakeTrackedFunction(common.mustCall()); -assert(!!tracked_function); -tracked_function = null; -global.gc(); +// We use IIFE for the tracked_function scope instead of a block to be +// compatible with non-V8 JS engines whose conservative stack scan may keep +// the object alive while the creating frame is still on the stack. +(() => { + let tracked_function = test_function.MakeTrackedFunction(common.mustCall()); + assert(!!tracked_function); + tracked_function = null; +})(); +for (let i = 0; i < 10; ++i) global.gc(); assert.deepStrictEqual(test_function.TestCreateFunctionParameters(), { envIsNull: 'Invalid argument', diff --git a/test/napi/node-napi-tests/test/js-native-api/test_instance_data/test.js b/test/napi/node-napi-tests/test/js-native-api/test_instance_data/test.js index 630776088344..ad533d85466a 100644 --- a/test/napi/node-napi-tests/test/js-native-api/test_instance_data/test.js +++ b/test/napi/node-napi-tests/test/js-native-api/test_instance_data/test.js @@ -17,8 +17,13 @@ if (module !== require.main) { assert.strictEqual(test_instance_data.increment(), 42); // Test that the instance data can be accessed from a finalizer. - test_instance_data.objectWithFinalizer(common.mustCall()); - global.gc(); + // We use IIFE for the object's scope to be compatible with non-V8 JS + // engines whose conservative stack scan may keep the object alive while + // the creating frame is still on the stack. + (() => { + test_instance_data.objectWithFinalizer(common.mustCall()); + })(); + for (let i = 0; i < 10; ++i) global.gc(); } else { // When launched as a script, run tests in either a child process or in a // worker thread.