diff --git a/src/js/internal/async_context_frame.ts b/src/js/internal/async_context_frame.ts index 99fb875bfef6..f21b2fcd211e 100644 --- a/src/js/internal/async_context_frame.ts +++ b/src/js/internal/async_context_frame.ts @@ -1,16 +1,39 @@ -// Minimal port of node's lib/internal/async_context_frame.js surface for -// --expose-internals consumers (vendored node tests). +// Port of node's lib/internal/async_context_frame.js surface for +// --expose-internals consumers (vendored node tests) and for Bun's own +// built-ins that need to swap the active async-context frame around a +// callback — the JS-side equivalent of the native +// AsyncContextFrame::call / withAsyncContextIfNeeded. // // Bun tracks async context natively in the engine (AsyncLocalStorage rides -// JSC's async context), so context propagation is always enabled. Frame -// objects, however, are never materialized — current() has nothing to -// expose and returns undefined. This diverges from node, where enabled -// implies current() is non-null inside a frame; tests relying on that -// coupling (rather than on enabled/falsiness checks) will not pass. +// JSC's async context), so context propagation is always enabled and the +// "frame" is the raw internal-field value (an even-length [ALS, value, ...] +// array or undefined) — see the comment at the top of node/async_hooks.ts. const AsyncContextFrame = { enabled: true, current() { - return undefined; + return $getInternalField($asyncContext, 0); + }, + /** Install `frame` as the active async-context frame; returns the previous one. */ + exchange(frame) { + const prev = $getInternalField($asyncContext, 0); + $putInternalField($asyncContext, 0, frame); + return prev; + }, + /** + * Call `fn` with `frame` installed as the active async-context frame, + * restoring the previous frame afterwards. Fast-paths when `frame` is + * already active (which includes the "no ALS in use anywhere" case where + * both are undefined). + */ + run(frame, fn, thisArg?, ...args) { + const prev = $getInternalField($asyncContext, 0); + if (frame === prev) return fn.$apply(thisArg, args); + $putInternalField($asyncContext, 0, frame); + try { + return fn.$apply(thisArg, args); + } finally { + $putInternalField($asyncContext, 0, prev); + } }, }; diff --git a/src/js/internal/shared.ts b/src/js/internal/shared.ts index ad88418760dd..c4535f8b2d07 100644 --- a/src/js/internal/shared.ts +++ b/src/js/internal/shared.ts @@ -163,12 +163,55 @@ const observerCounts = new Map(); const kObservers = new Set(); /** Entry types routed through this JS-side registry instead of the native observer. */ -const kNodeEntryTypes = new Set(["net", "dns", "http"]); +const kNodeEntryTypes = new Set(["net", "dns", "http", "function"]); function hasObserver(type) { return (observerCounts.get(type) ?? 0) > 0; } +/** + * Hand a finished entry to every registered observer. Used by callers that + * construct the entry themselves (e.g. perf_hooks timerify) instead of the + * startPerf/stopPerf pair. + */ +function enqueueNodeEntry(entry) { + for (const observer of kObservers) { + observer.bufferEntry(entry); + } +} + +// Node's PerformanceNodeEntry — the shape used by every JS-side entry type +// ('function', 'net', 'dns', 'http'). Lives here (not in perf_hooks.ts) so +// stopPerf can construct it without a circular require. The prototype chain +// is linked to PerformanceEntry by perf_hooks.ts at load time using its +// captured global (every construction is gated behind hasObserver(), which +// is only true after perf_hooks has loaded). +class PerformanceNodeEntry { + name; + entryType; + startTime; + duration; + detail; + + constructor(name, entryType, startTime, duration, detail) { + this.name = name; + this.entryType = entryType; + this.startTime = startTime; + this.duration = duration; + this.detail = detail; + } + + toJSON() { + return { + name: this.name, + entryType: this.entryType, + startTime: this.startTime, + duration: this.duration, + detail: this.detail, + }; + } +} + function startPerf(target, key, context) { context.startTime = performance.now(); target[key] = context; @@ -181,19 +224,11 @@ function stopPerf(target, key, context) { } target[key] = undefined; const startTime = ctx.startTime; - const entry = { - name: ctx.name, - entryType: ctx.type, - startTime, - duration: performance.now() - startTime, - // Node.js merges the detail recorded at startPerf() with the detail - // passed to stopPerf() (e.g. http entries carry both req and res). - detail: - ctx.detail !== undefined || context?.detail !== undefined ? { ...ctx.detail, ...context?.detail } : undefined, - }; - for (const observer of kObservers) { - observer.bufferEntry(entry); - } + // Node.js merges the detail recorded at startPerf() with the detail + // passed to stopPerf() (e.g. http entries carry both req and res). + const detail = + ctx.detail !== undefined || context?.detail !== undefined ? { ...ctx.detail, ...context?.detail } : undefined; + enqueueNodeEntry(new PerformanceNodeEntry(ctx.name, ctx.type, startTime, performance.now() - startTime, detail)); } /** @@ -286,8 +321,10 @@ export default { hasObserver, startPerf, stopPerf, + enqueueNodeEntry, kNodeEntryTypes, NodeEntryObserver, + PerformanceNodeEntry, kHandle: Symbol("kHandle"), kAutoDestroyed: Symbol("kAutoDestroyed"), diff --git a/src/js/node/_http_client.ts b/src/js/node/_http_client.ts index 6deeeccfb5bf..958aa32404b1 100644 --- a/src/js/node/_http_client.ts +++ b/src/js/node/_http_client.ts @@ -83,6 +83,19 @@ class HTTPClientAsyncResource { } } +// Node's parser AsyncWrap + _http_agent asyncResetHandle() make every socket +// callback re-enter the current request's async scope; Bun bridges this in +// JS by snapshotting the frame at tickOnSocket and running each socket +// listener (data/end/error/close/drain/timeout) inside it. +const kClientAsyncContext = Symbol("kClientAsyncContext"); +const runInFrame = require("internal/async_context_frame").run; + +function closeRequest(req) { + if (req[kClientAsyncContext] !== undefined) req[kClientAsyncContext] = undefined; + req._closed = true; + req.emit("close"); +} + function isURLInstance(input) { return input != null && typeof input === "object" && input instanceof URL; } @@ -515,6 +528,10 @@ function emitAbortNT(req) { } function ondrain() { + return runInFrame(this._httpMessage?.[kClientAsyncContext], ondrainInner, this); +} + +function ondrainInner() { const msg = this._httpMessage; if (msg && !msg.finished && msg[kNeedDrain]) { msg[kNeedDrain] = false; @@ -523,6 +540,10 @@ function ondrain() { } function socketCloseListener() { + return runInFrame(this._httpMessage?.[kClientAsyncContext], socketCloseListenerInner, this); +} + +function socketCloseListenerInner() { const socket = this; const req = socket._httpMessage; $debug("HTTP socket close"); @@ -541,8 +562,7 @@ function socketCloseListener() { if (!res.complete) { res.destroy(new ConnResetException("aborted")); } - req._closed = true; - req.emit("close"); + closeRequest(req); if (!res.aborted && res.readable) { res.push(null); } @@ -554,8 +574,7 @@ function socketCloseListener() { req.socket._hadError = true; emitErrorEvent(req, new ConnResetException("socket hang up")); } - req._closed = true; - req.emit("close"); + closeRequest(req); } // Too bad. That output wasn't getting written. @@ -571,6 +590,10 @@ function socketCloseListener() { } function socketErrorListener(err) { + return runInFrame(this._httpMessage?.[kClientAsyncContext], socketErrorListenerInner, this, err); +} + +function socketErrorListenerInner(err) { const socket = this; const req = socket._httpMessage; $debug("SOCKET ERROR:", err); @@ -595,6 +618,10 @@ function socketErrorListener(err) { } function socketOnEnd() { + return runInFrame(this._httpMessage?.[kClientAsyncContext], socketOnEndInner, this); +} + +function socketOnEndInner() { const socket = this; const req = this._httpMessage; const parser = this.parser; @@ -613,6 +640,10 @@ function socketOnEnd() { } function socketOnData(d) { + return runInFrame(this._httpMessage?.[kClientAsyncContext], socketOnDataInner, this, d); +} + +function socketOnDataInner(d) { const socket = this; // HTTPParser.execute() is not reentrant. User code can synchronously push @@ -689,10 +720,12 @@ function processClientData(socket, d, parser) { socket._httpMessage = null; socket.readableFlowing = null; + // Clear before the emit: a throwing upgrade/connect handler would skip + // closeRequest() and leave the retained request pinning the store. + req[kClientAsyncContext] = undefined; req.emit(eventName, res, socket, bodyHead); req.destroyed = true; - req._closed = true; - req.emit("close"); + closeRequest(req); } else { // Requested Upgrade or used CONNECT method, but have no handler. socket.destroy(); @@ -901,6 +934,10 @@ function responseOnEnd() { } function responseOnTimeout() { + return runInFrame(this._httpMessage?.[kClientAsyncContext], responseOnTimeoutInner, this); +} + +function responseOnTimeoutInner() { const req = this._httpMessage; if (!req) return; const res = req.res; @@ -921,8 +958,7 @@ function requestOnFinish() { } function emitFreeNT(req) { - req._closed = true; - req.emit("close"); + closeRequest(req); const socket = req.socket; if (socket) { socket.emit("free"); @@ -932,6 +968,7 @@ function emitFreeNT(req) { function tickOnSocket(req, socket) { const parser = parsers.alloc(); req.socket = socket; + req[kClientAsyncContext] = $getInternalField($asyncContext, 0); const lenientFlags = calculateLenientFlags(req.httpValidation, req.insecureHTTPParser); parser.initialize( HTTPParser.RESPONSE, @@ -967,6 +1004,10 @@ function tickOnSocket(req, socket) { } function emitRequestTimeout() { + return runInFrame(this._httpMessage?.[kClientAsyncContext], emitRequestTimeoutInner, this); +} + +function emitRequestTimeoutInner() { const req = this._httpMessage; if (req) { req.emit("timeout"); @@ -1000,6 +1041,10 @@ ClientRequest.prototype.onSocket = function onSocket(socket, err) { // to be set so we set it here too. if (socket && !err) { socket._httpMessage = this; + // Capture the frame here, not just in tickOnSocket: onSocket runs in the + // request's context, and an error in the window before onSocketNT would + // otherwise run socketErrorListener with no frame and clear the context. + this[kClientAsyncContext] = $getInternalField($asyncContext, 0); socket.on("error", socketErrorListener); } process.nextTick(onSocketNT, this, socket, err); @@ -1017,8 +1062,7 @@ function destroyRequestOnSocketNT(req, socket, err) { // The request is dead with no parser: close the trace span on the paths // that skip emitErrorEvent above (proxy tunnel; error already emitted). traceClientResponseEnd(req); - req._closed = true; - req.emit("close"); + closeRequest(req); } function onSocketNT(req, socket, err) { diff --git a/src/js/node/async_hooks.ts b/src/js/node/async_hooks.ts index 049a9c7e560a..43917fa9e39d 100644 --- a/src/js/node/async_hooks.ts +++ b/src/js/node/async_hooks.ts @@ -25,6 +25,13 @@ const setAsyncHooksEnabled = $newCppFunction("NodeAsyncHooks.cpp", "jsSetAsyncHooksEnabled", 1); const cleanupLater = $newCppFunction("NodeAsyncHooks.cpp", "jsCleanupLater", 0); const { validateFunction, validateString, validateObject } = require("internal/validators"); +// SameValue in pure operators. Node compares stores with the primordial +// ObjectIs; capturing Object.is here would still inherit a patch applied +// before this module was lazily loaded. +function sameValue(a, b) { + if (a === b) return a !== 0 || 1 / a === 1 / b; + return a !== a && b !== b; +} // Only run during debug function assertValidAsyncContextArray(array: unknown): array is ReadonlyArray | undefined { @@ -73,10 +80,47 @@ function set(contextValue: ReadonlyArray | undefined) { return $putInternalField($asyncContext, 0, contextValue); } +// Node parity: dispose() is enterWith(previousStore), which on a fresh ALS +// installs [als, undefined] instead of splicing like run(). Bun's +// cleanupAsyncHooksData resets top-level next tick, so residue is bounded. +class RunScope { + #storage; + #previousStore; + #disposed = false; + + constructor(storage, store) { + this.#storage = storage; + this.#previousStore = storage.getStore(); + storage.enterWith(store); + } + + dispose() { + if (this.#disposed) { + return; + } + this.#disposed = true; + this.#storage.enterWith(this.#previousStore); + } + + [Symbol.dispose]() { + this.dispose(); + } +} + class AsyncLocalStorage { #disabled = false; - - constructor() { + #defaultValue = undefined; + #name = undefined; + + constructor(options) { + if (options !== undefined) { + validateObject(options, "options"); + this.#defaultValue = options.defaultValue; + const name = options.name; + if (name !== undefined) { + this.#name = `${name}`; + } + } setAsyncHooksEnabled(true); // In debug mode assign every AsyncLocalStorage a unique ID @@ -130,7 +174,7 @@ class AsyncLocalStorage { } } set(context.concat(this, store)); - $assert(this.getStore() === store); + $assert(sameValue(this.getStore(), store)); } exit(cb, ...args) { @@ -141,22 +185,37 @@ class AsyncLocalStorage { // is assumed to be true is *actually* true. run(store_value, callback, ...args) { $debug("run " + (this as any).__id__); + // Node short-circuits when the value is unchanged: no enterWith, no + // finally-restore. Observable when the callback calls enterWith() — + // the new value survives past run() (verified against Node v22/v26). + // Not while disabled: getStore() masks the frame with #defaultValue then, + // so a match here would skip installing store_value and let the callback + // read the unmasked frame value instead. + if (!this.#disabled && sameValue(this.getStore(), store_value)) { + return callback(...args); + } var context = get() as any[]; // we make sure to .slice() before mutating var hasPrevious = false; var previous_value; var i = 0; var contextWasAlreadyInit = !context; // we must renable it when asyncLocalStorage.run() is called https://nodejs.org/api/async_context.html#asynclocalstoragedisable - const wasDisabled = this.#disabled; this.#disabled = false; if (contextWasAlreadyInit) { set((context = [this, store_value])); } else { // it's safe to mutate context now that it was cloned context = context!.slice(); - i = context.indexOf(this); + // Scan even (key) slots only — a value slot can hold this storage when + // another ALS stored it via enterWith/run. + i = -1; + for (var j = 0, len = context.length; j < len; j += 2) { + if (context[j] === this) { + i = j; + break; + } + } if (i > -1) { - $assert(i % 2 === 0); hasPrevious = true; previous_value = context[i + 1]; context[i + 1] = store_value; @@ -169,34 +228,62 @@ class AsyncLocalStorage { set(context); } $assert(i > -1, "i was not set"); - $assert(this.getStore() === store_value, "run: store_value was not set"); + $assert(sameValue(this.getStore(), store_value), "run: store_value was not set"); try { return callback(...args); } finally { // Note: early `return` will prevent `throw` above from working. I think... - // Set AsyncContextFrame to undefined if we are out of context values - if (!wasDisabled) { + // Set AsyncContextFrame to undefined if we are out of context values. + // Restoration is unconditional, mirroring node's `finally { enterWith(prior) }`: + // entering a disabled storage must not leave store_value installed after run(). + { var context2 = get()! as any[]; // we make sure to .slice() before mutating if (context2 === context && contextWasAlreadyInit) { $assert(context2.length === 2, "context was mutated without copy"); set(undefined); } else { - context2 = context2.slice(); // array is cloned here - $assert(context2[i] === this); - if (hasPrevious) { - context2[i + 1] = previous_value; + // The context array can change shape during the callback (disable() + // splices storages out), so re-locate this storage by identity + // instead of trusting the index captured before the callback ran. + // This mirrors node's run(), whose finally is enterWith(prior): + // restore by value, re-adding the previous value even after a + // disable() during the callback. + context2 = context2 ? context2.slice() : []; // array is cloned here + // Scan even (key) slots only — a value slot can hold this storage + // when another ALS stored it via enterWith/run. + let idx = -1; + for (let j = 0, len = context2.length; j < len; j += 2) { + if (context2[j] === this) { + idx = j; + break; + } + } + if (idx > -1) { + if (hasPrevious) { + context2[idx + 1] = previous_value; + set(context2); + } else { + context2.splice(idx, 2); + $assert(context2.length % 2 === 0); + set(context2.length ? context2 : undefined); + } + } else if (hasPrevious) { + // disable() removed us mid-callback; node still restores the + // previous value (and the storage becomes enabled again). + this.#disabled = false; + context2.push(this, previous_value); set(context2); } else { - // i wonder if this is a fair assert to make - context2.splice(i, 2); - $assert(context2.length % 2 === 0); - set(context2.length ? context2 : undefined); + // idx===-1 && !hasPrevious: disable() removed us; Node's finally + // is unconditionally enterWith(prior), which re-enables regardless. + this.#disabled = false; } } + const expectedStore = hasPrevious ? previous_value : this.#defaultValue; $assert( - this.getStore() === previous_value, + sameValue(this.getStore(), expectedStore), "run: previous_value", - Bun.inspect(previous_value), + Bun.inspect(expectedStore), "was not restored, i see", this.getStore(), ); @@ -222,16 +309,28 @@ class AsyncLocalStorage { } } + get name() { + return this.#name || ""; + } + getStore() { $debug("getStore " + (this as any).__id__); - // disabled AsyncLocalStorage always returns undefined https://nodejs.org/api/async_context.html#asynclocalstoragedisable - if (this.#disabled) return; + // Node v26: both ALS impls return #defaultValue after disable() — the + // frame impl has no disabled flag; the legacy impl's not-enabled branch + // is `return this.#defaultValue`. + if (this.#disabled) return this.#defaultValue; var context = get(); - if (!context) return; - var { length } = context; - for (var i = 0; i < length; i += 2) { - if (context[i] === this) return context[i + 1]; + if (context) { + var { length } = context; + for (var i = 0; i < length; i += 2) { + if (context[i] === this) return context[i + 1]; + } } + return this.#defaultValue; + } + + withScope(store) { + return new RunScope(this, store); } // Node.js internal function. In Bun's implementation, calling this is not @@ -256,18 +355,16 @@ if (IS_BUN_DEVELOPMENT) { class AsyncResource { type; #snapshot; + #triggerAsyncId; constructor(type, opts?) { validateString(type, "type"); - let triggerAsyncId = opts; - if (opts != null) { - if (typeof opts !== "number") { - triggerAsyncId = opts.triggerAsyncId === undefined ? 1 : opts.triggerAsyncId; - } - if (!Number.isSafeInteger(triggerAsyncId) || triggerAsyncId < -1) { - throw $ERR_INVALID_ASYNC_ID("triggerAsyncId", triggerAsyncId); - } + // Node defaults to getDefaultTriggerAsyncId() (the current execution async + // id); Bun does not track async ids, so its executionAsyncId() is 0. + let triggerAsyncId = typeof opts === "number" ? opts : opts?.triggerAsyncId === undefined ? 0 : opts.triggerAsyncId; + if (!Number.isSafeInteger(triggerAsyncId) || triggerAsyncId < -1) { + throw $ERR_INVALID_ASYNC_ID("triggerAsyncId", triggerAsyncId); } if (hasEnabledCreateHook && type.length === 0) { throw $ERR_ASYNC_TYPE(type); @@ -276,6 +373,7 @@ class AsyncResource { setAsyncHooksEnabled(true); this.type = type; this.#snapshot = get(); + this.#triggerAsyncId = triggerAsyncId; } emitBefore() { @@ -291,7 +389,7 @@ class AsyncResource { } triggerAsyncId() { - return 0; + return this.#triggerAsyncId; } emitDestroy() { @@ -310,7 +408,25 @@ class AsyncResource { bind(fn, thisArg) { validateFunction(fn, "fn"); - return this.runInAsyncScope.bind(this, fn, thisArg ?? this); + let bound; + if (thisArg === undefined) { + const resource = this; + bound = function (this: unknown, ...args) { + return resource.runInAsyncScope(fn, this, ...args); + }; + } else { + bound = this.runInAsyncScope.bind(this, fn, thisArg); + } + Object.defineProperties(bound, { + length: { + __proto__: null, + configurable: true, + enumerable: false, + value: fn.length, + writable: false, + }, + }); + return bound; } static bind(fn, type, thisArg) { diff --git a/src/js/node/events.ts b/src/js/node/events.ts index 4ddc95614f72..ec3700fcbd25 100644 --- a/src/js/node/events.ts +++ b/src/js/node/events.ts @@ -30,6 +30,7 @@ const { validateNumber, validateBoolean, validateFunction, + validateString, } = require("internal/validators"); const types = require("node:util/types"); @@ -38,6 +39,7 @@ let inspect: typeof import("node:util").inspect | undefined; const SymbolFor = Symbol.for; const ArrayPrototypeSlice = Array.prototype.slice; const ArrayPrototypeSplice = Array.prototype.splice; +const ArrayPrototypeUnshift = Array.prototype.unshift; const ReflectOwnKeys = Reflect.ownKeys; const kCapture = Symbol("kCapture"); @@ -791,26 +793,80 @@ function addAbortListener(signal, listener) { }; } +let EventEmitterReferencingAsyncResource; +function lazyLoadAsyncResource() { + if (!AsyncResource) { + AsyncResource = require("node:async_hooks").AsyncResource; + EventEmitterReferencingAsyncResource = class EventEmitterReferencingAsyncResource extends AsyncResource { + #eventEmitter; + + constructor(ee, type, options) { + super(type, options); + this.#eventEmitter = ee; + } + + get eventEmitter() { + return this.#eventEmitter; + } + }; + } +} + class EventEmitterAsyncResource extends EventEmitter { - triggerAsyncId; - asyncResource; + #asyncResource; constructor(options) { - if (!AsyncResource) { - AsyncResource = require("node:async_hooks").AsyncResource; + lazyLoadAsyncResource(); + let name; + if (typeof options === "string") { + name = options; + options = undefined; + } else { + if (new.target === EventEmitterAsyncResource) { + validateString(options?.name, "options.name"); + } + name = options?.name || new.target.name; } - var { captureRejections = false, triggerAsyncId, name = new.target.name, requireManualDestroy } = options || {}; - super({ captureRejections }); - this.triggerAsyncId = triggerAsyncId ?? 0; - this.asyncResource = new AsyncResource(name, { triggerAsyncId, requireManualDestroy }); + super(options); + this.#asyncResource = new EventEmitterReferencingAsyncResource(this, name, options); + // EventEmitter's constructor stamps `this.emit = emitWithRejectionCapture` + // as an OWN property when captureRejections is on, which would shadow the + // prototype's runInAsyncScope-wrapped emit below. Remove it so listeners + // still run in the resource's async scope; the prototype emit re-checks + // this[kCapture] on every call, so rejection capture is preserved. delete + // is a no-op when the property is absent, so no own-property check needed. + delete (this as { emit? }).emit; + } + + // No explicit receiver guards: like node v26 (lib/events.js), the private + // field access itself brand-checks `this` and throws a TypeError on a wrong + // receiver, so an ERR_INVALID_THIS guard before it would be unreachable. + get asyncId() { + return this.#asyncResource.asyncId(); + } + + get triggerAsyncId() { + return this.#asyncResource.triggerAsyncId(); + } + + get asyncResource() { + return this.#asyncResource; } - emit(...args) { - this.asyncResource.runInAsyncScope(() => super.emit(...args)); + emit(event, ...args) { + const asyncResource = this.#asyncResource; + // The base EventEmitter picks its emit variant by stamping an own property; + // that own property is deleted in the constructor above, so pick per-call + // from this[kCapture]. The default branch reads super.emit at call time + // (Node routes through super.emit) so a userland monkeypatch of + // EventEmitter.prototype.emit is observed like it is for plain emitters. + const emit = this[kCapture] ? emitWithRejectionCapture : super.emit; + ArrayPrototypeUnshift.$call(args, emit, this, event); + return asyncResource.runInAsyncScope.$apply(asyncResource, args); } emitDestroy() { - this.asyncResource.emitDestroy(); + this.#asyncResource.emitDestroy(); } } diff --git a/src/js/node/http2.ts b/src/js/node/http2.ts index 5f953cf2ca04..8ce9f4576a84 100644 --- a/src/js/node/http2.ts +++ b/src/js/node/http2.ts @@ -379,12 +379,17 @@ const bunHTTP2Socket = Symbol.for("::bunhttp2socket::"); const bunHTTP2OriginSet = Symbol("::bunhttp2originset::"); const bunHTTP2StreamFinal = Symbol.for("::bunHTTP2StreamFinal::"); const bunHTTP2WaitForTrailers = Symbol("::bunhttp2waitfortrailers::"); -const bunHTTP2StreamAsyncContext = Symbol("::bunhttp2streamasynccontext::"); const bunHTTP2StreamStatus = Symbol.for("::bunhttp2StreamStatus::"); const bunHTTP2Session = Symbol.for("::bunhttp2session::"); const bunHTTP2Headers = Symbol.for("::bunhttp2headers::"); +const bunHTTP2AsyncContextFrame = Symbol("::bunhttp2asynccontextframe::"); +const bunHTTP2SessionTeardownFrame = Symbol("::bunhttp2sessionteardownframe::"); +// Sentinel for bunHTTP2SessionTeardownFrame: a captured frame can itself be +// undefined (the root context), so "no teardown in progress" needs its own value. +const kNoSessionTeardown = Symbol("::bunhttp2noteardown::"); +const runInFrame = require("internal/async_context_frame").run; const ReflectGetPrototypeOf = Reflect.getPrototypeOf; @@ -470,6 +475,14 @@ function emitEventNT(self: any, event: string, ...args: any[]) { self.emit(event, ...args); } } +// The frame is passed in: destroy() clears it off the session before emitting +// 'error', so a throwing listener on either event cannot leave a retained +// session pinning the store. +function emitSessionCloseNT(self: Http2Session, frame) { + if (self.listenerCount("close") > 0) { + runInFrame(frame, self.emit, self, "close"); + } +} function emitErrorNT(self: any, error: any, destroy: boolean) { if (destroy) { if (self.listenerCount("error") > 0) { @@ -2088,8 +2101,12 @@ type Settings = { }; class Http2Session extends EventEmitter { + [bunHTTP2SessionTeardownFrame] = kNoSessionTeardown; [bunHTTP2Socket]: TLSSocket | Socket | null; [bunHTTP2OriginSet]: Set | undefined = undefined; + // Session-level frame (Node's Http2Session AsyncWrap): destroy()'s emits + // run inside it so 'close' doesn't inherit the last stream's frame. + [bunHTTP2AsyncContextFrame] = $getInternalField($asyncContext, 0); [kDeferWriteCallback] = setImmediate; [EventEmitter.captureRejectionSymbol](err, event, ...args) { switch (event) { @@ -2359,32 +2376,18 @@ function destroyStreamForSessionDestroy(error: Error | undefined, rstCode: numbe // NGHTTP2_CANCEL while unread UNIMPLEMENTED streams are still around). stream.destroy(error !== undefined && stream.listenerCount("error") > 0 ? error : undefined); } -// node's Http2Stream is an async resource: events the native session dispatches on a client -// stream ('response', 'data', 'end') run in the async context that was active when request() was -// called, not in the context of the socket read that delivered the frames. The snapshot is taken -// in the ClientHttp2Stream constructor; these two helpers swap it in around a native dispatch and -// restore the dispatch's own context afterwards. -const kNoAsyncContextSwap = Symbol("noAsyncContextSwap"); -function enterStreamAsyncContext(stream: Http2Stream) { - const snapshot = stream[bunHTTP2StreamAsyncContext]; - // kNoAsyncContextSwap = never captured (server streams); a captured EMPTY - // context (undefined) must still be swapped to, like Node's AsyncResource. - if (snapshot === kNoAsyncContextSwap) return kNoAsyncContextSwap; - const previous = $getInternalField($asyncContext, 0); - if (previous === snapshot) return kNoAsyncContextSwap; - $putInternalField($asyncContext, 0, snapshot); - return previous; -} -function exitStreamAsyncContext(previous) { - if (previous !== kNoAsyncContextSwap) { - $putInternalField($asyncContext, 0, previous); - } -} class Http2Stream extends Duplex { #id: number; [bunHTTP2Session]: ClientHttp2Session | ServerHttp2Session | null = null; [bunHTTP2StreamFinal]: VoidFunction | null = null; [bunHTTP2StreamStatus]: number = 0; + // Async-context frame captured at construction so native-driven callbacks + // (response/data/end/…) on client streams observe the AsyncLocalStorage + // context that request() ran in, matching Node's Http2Stream AsyncWrap. + // Read only by withStreamFrame; user-initiated emit() is untouched. The raw + // frame is snapshotted directly so session.request() does not flip on + // async-context tracking when no AsyncLocalStorage is in use. + [bunHTTP2AsyncContextFrame] = $getInternalField($asyncContext, 0); rstCode: number | undefined = undefined; [bunHTTP2Headers]: any; @@ -2393,9 +2396,6 @@ class Http2Stream extends Duplex { [kSendingTrailers]: boolean = false; [kAborted]: boolean = false; [kHeadRequest]: boolean = false; - // Async-context snapshot for native dispatches (see enterStreamAsyncContext); only client - // streams capture one (possibly an empty context, i.e. undefined). - [bunHTTP2StreamAsyncContext] = kNoAsyncContextSwap; constructor(streamId, session, headers) { super({ decodeStrings: false, @@ -2663,6 +2663,10 @@ class Http2Stream extends Duplex { } } _destroy(err, callback) { + // Cleared first: everything below can reach user code ('aborted', end(), + // push(null)) and a throwing listener would otherwise skip the clear and + // leave a retained stream pinning the store. + this[bunHTTP2AsyncContextFrame] = undefined; const { ending } = this._writableState; this.push(null); // A pushed stream's request was synthesized by the server, so its local (writable) half is @@ -2972,13 +2976,23 @@ class Http2Stream extends Duplex { } } } -class ClientHttp2Stream extends Http2Stream { - constructor(streamId, session, headers) { - super(streamId, session, headers); - // Capture the async context active at request() time so the native dispatches for this stream - // ('response', 'data', 'end') run in it, like node's Http2Stream async resource. - this[bunHTTP2StreamAsyncContext] = $getInternalField($asyncContext, 0); - } +class ClientHttp2Stream extends Http2Stream {} + +// Wrap a native→JS #Handlers callback so its body runs inside the target +// stream's captured async-context frame — the JS-side equivalent of Node's +// AsyncWrap MakeCallback re-entering the resource scope. Applied only at the +// native seam, not to public emit(), so user-driven emit()/destroy() observe +// the caller's ALS context (matching Node). +function withStreamFrame(handler) { + return function (self, stream, a, b, c) { + if (typeof stream !== "object" || stream === null) return handler(self, stream, a, b, c); + // A session mid-destroy() fans onStreamError out via emitErrorToAllStreams + // under the destroy() caller's captured frame (Node's teardown context), + // scoped to that session so a coincident dispatch elsewhere is unaffected. + const teardownFrame = self != null ? self[bunHTTP2SessionTeardownFrame] : kNoSessionTeardown; + const frame = teardownFrame !== kNoSessionTeardown ? teardownFrame : stream[bunHTTP2AsyncContextFrame]; + return runInFrame(frame, handler, undefined, self, stream, a, b, c); + }; } function tryClose(fd) { try { @@ -4042,6 +4056,9 @@ class ServerHttp2Session extends Http2Session { // setStreamContext host call needed. return stream; }, + // Server handlers are NOT withStreamFrame-wrapped: streamStart runs in + // the native handler_pair! wrap's parser-construction context, so a + // peer-initiated stream's captured frame equals what every handler sees. frameError(self: ServerHttp2Session, stream: ServerHttp2Stream, frameType: number, errorCode: number) { if (!self || typeof stream !== "object") return; // Emit the frameError event with the frame type and error code @@ -4745,12 +4762,16 @@ class ServerHttp2Session extends Http2Session { } this[bunHTTP2Socket] = null; + // Read-and-clear the frame first: emitting 'error' with no listener throws, + // which would skip the clear and leave a retained session pinning the store. + const asyncFrame = this[bunHTTP2AsyncContextFrame]; + this[bunHTTP2AsyncContextFrame] = undefined; if (error) { - this.emit("error", error); + runInFrame(asyncFrame, this.emit, this, "error", error); } // node emits the session 'close' event asynchronously (a listener attached right after // close()/destroy() returns must still observe it). - process.nextTick(emitEventNT, this, "close"); + process.nextTick(emitSessionCloseNT, this, asyncFrame); } } function emitTimeout(session: ClientHttp2Session) { @@ -4983,12 +5004,14 @@ class ClientHttp2Session extends Http2Session { } self.emit("stream", pushedStream, headers, flags, rawheaders); }, - frameError(self: ClientHttp2Session, stream: ClientHttp2Stream, frameType: number, errorCode: number) { - if (!self || typeof stream !== "object") return; - // Emit the frameError event with the frame type and error code - process.nextTick(emitFrameErrorEventNT, stream, frameType, errorCode); - }, - aborted(self: ClientHttp2Session, stream: ClientHttp2Stream, error: any, old_state: number) { + frameError: withStreamFrame( + (self: ClientHttp2Session, stream: ClientHttp2Stream, frameType: number, errorCode: number) => { + if (!self || typeof stream !== "object") return; + // Emit the frameError event with the frame type and error code + process.nextTick(emitFrameErrorEventNT, stream, frameType, errorCode); + }, + ), + aborted: withStreamFrame((self: ClientHttp2Session, stream: ClientHttp2Stream, error: any, old_state: number) => { if (!self || typeof stream !== "object") return; stream.rstCode = constants.NGHTTP2_CANCEL; // if writable and not closed emit aborted @@ -4998,14 +5021,14 @@ class ClientHttp2Session extends Http2Session { } self.#connections--; process.nextTick(emitStreamErrorNT, self, stream, error, true, self.#connections === 0 && self.#closed); - }, - streamError(self: ClientHttp2Session, stream: ClientHttp2Stream, error: number) { + }), + streamError: withStreamFrame((self: ClientHttp2Session, stream: ClientHttp2Stream, error: number) => { if (!self || typeof stream !== "object") return; self.#connections--; process.nextTick(emitStreamErrorNT, self, stream, error, true, self.#connections === 0 && self.#closed); - }, - streamEnd(self: ClientHttp2Session, stream: ClientHttp2Stream, state: number) { + }), + streamEnd: withStreamFrame((self: ClientHttp2Session, stream: ClientHttp2Stream, state: number) => { if (!self || typeof stream !== "object") return; if (state === 7 && stream[kSendingTrailers]) { // The trailer frame submitted by an in-flight sendTrailers() fully closed the stream: @@ -5014,85 +5037,74 @@ class ClientHttp2Session extends Http2Session { process.nextTick(ClientHttp2Session.#Handlers.streamEnd, self, stream, state); return; } - const previousAsyncContext = enterStreamAsyncContext(stream); - try { - if (state == 6 || state == 7) { - if (stream.readable) { - if (!stream.rstCode) { - stream.rstCode = 0; - } - // Push a null so the stream can end whenever the client consumes - // it completely. - pushToStream(stream, null); - stream.read(0); + if (state == 6 || state == 7) { + if (stream.readable) { + if (!stream.rstCode) { + stream.rstCode = 0; } + // Push a null so the stream can end whenever the client consumes + // it completely. + pushToStream(stream, null); + stream.read(0); } + } - // 7 = closed, in this case we already send everything and received everything - if (state === 7) { - stream[bunHTTP2StreamStatus] |= StreamState.NativeClosed; - markStreamClosed(stream); - self.#connections--; - if (stream.readable && !stream.rstCode) { - // Clean close while data is still buffered on the readable side: node defers the - // destroy until the consumer drains it ('end'), so a late-attaching reader does not - // lose data. - stream.once("end", destroySelfOnEnd); - } else if (stream.writableEnded && !stream.writableFinished && !stream.destroyed) { - // The writable side is mid-finish (an in-flight _final settled the native stream - // synchronously): destroying now would swallow 'finish'. Node's kMaybeDestroy waits - // for the writable side to finish before destroying a cleanly closed stream. - stream.once("finish", destroySelfOnEnd); - } else { - stream.destroy(); - } - if (self.#connections === 0 && self.#closed) { - // Deferred like close()'s own destroy: this runs inside a native dispatch - // batch, and frames the engine already received but has not dispatched yet - // must still reach JS. An outstanding settings() ACK gets a bounded grace - // (see scheduleSettingsAckGraceNT); its arrival completes the destroy. - if (self.#pendingSettingsAckCount > 0) scheduleSettingsAckGraceNT(self); - else setImmediate(destroyIfNotDestroyedNT, self); - } - } else if (state === 5) { - // 5 = local closed aka write is closed - markWritableDone(stream); + // 7 = closed, in this case we already send everything and received everything + if (state === 7) { + stream[bunHTTP2StreamStatus] |= StreamState.NativeClosed; + markStreamClosed(stream); + self.#connections--; + if (stream.readable && !stream.rstCode) { + // Clean close while data is still buffered on the readable side: node defers the + // destroy until the consumer drains it ('end'), so a late-attaching reader does not + // lose data. + stream.once("end", destroySelfOnEnd); + } else if (stream.writableEnded && !stream.writableFinished && !stream.destroyed) { + // The writable side is mid-finish (an in-flight _final settled the native stream + // synchronously): destroying now would swallow 'finish'. Node's kMaybeDestroy waits + // for the writable side to finish before destroying a cleanly closed stream. + stream.once("finish", destroySelfOnEnd); + } else { + stream.destroy(); } - } finally { - exitStreamAsyncContext(previousAsyncContext); + if (self.#connections === 0 && self.#closed) { + // Deferred like close()'s own destroy: this runs inside a native dispatch + // batch, and frames the engine already received but has not dispatched yet + // must still reach JS. An outstanding settings() ACK gets a bounded grace + // (see scheduleSettingsAckGraceNT); its arrival completes the destroy. + if (self.#pendingSettingsAckCount > 0) scheduleSettingsAckGraceNT(self); + else setImmediate(destroyIfNotDestroyedNT, self); + } + } else if (state === 5) { + // 5 = local closed aka write is closed + markWritableDone(stream); } - }, - streamData(self: ClientHttp2Session, stream: ClientHttp2Stream, data: Buffer) { + }), + streamData: withStreamFrame((self: ClientHttp2Session, stream: ClientHttp2Stream, data: Buffer) => { if (!self || typeof stream !== "object" || !data) return; - const previousAsyncContext = enterStreamAsyncContext(stream); - try { - pushToStream(stream, data); - } finally { - exitStreamAsyncContext(previousAsyncContext); - } - }, - streamHeaders( - self: ClientHttp2Session, - stream: ClientHttp2Stream, - headersTuple: [string[], Record, string[] | undefined], - flags: number, - ) { - if (!self || typeof stream !== "object" || stream.rstCode) return; - let rawheaders = headersTuple[0]; - let headers = headersTuple[1]; - if (self.#strictFieldWhitespaceValidation) { - // stripInvalidWhitespaceFields returns its input by identity when nothing was - // stripped (the common case) — only then can the native-built object be reused. - const filtered = stripInvalidWhitespaceFields(rawheaders); - if (filtered !== rawheaders) { - rawheaders = filtered; - headers = toHeaderObject(filtered, headersTuple[2] || []); + pushToStream(stream, data); + }), + streamHeaders: withStreamFrame( + ( + self: ClientHttp2Session, + stream: ClientHttp2Stream, + headersTuple: [string[], Record, string[] | undefined], + flags: number, + ) => { + if (!self || typeof stream !== "object" || stream.rstCode) return; + let rawheaders = headersTuple[0]; + let headers = headersTuple[1]; + if (self.#strictFieldWhitespaceValidation) { + // stripInvalidWhitespaceFields returns its input by identity when nothing was + // stripped (the common case) — only then can the native-built object be reused. + const filtered = stripInvalidWhitespaceFields(rawheaders); + if (filtered !== rawheaders) { + rawheaders = filtered; + headers = toHeaderObject(filtered, headersTuple[2] || []); + } } - } - const status = stream[bunHTTP2StreamStatus]; - const header_status = headers[HTTP2_HEADER_STATUS]; - const previousAsyncContext = enterStreamAsyncContext(stream); - try { + const status = stream[bunHTTP2StreamStatus]; + const header_status = headers[HTTP2_HEADER_STATUS]; if (header_status === HTTP_STATUS_CONTINUE) { stream.emit("continue"); } @@ -5126,10 +5138,8 @@ class ClientHttp2Session extends Http2Session { } } } - } finally { - exitStreamAsyncContext(previousAsyncContext); - } - }, + }, + ), localSettings(self: ClientHttp2Session, settings: Settings) { if (!self) return; self.#localSettings = settings; @@ -5188,7 +5198,7 @@ class ClientHttp2Session extends Http2Session { self.destroy(error_instance); }, - wantTrailers(self: ClientHttp2Session, stream: ClientHttp2Stream) { + wantTrailers: withStreamFrame((self: ClientHttp2Session, stream: ClientHttp2Stream) => { if (!self || typeof stream !== "object") return; const status = stream[bunHTTP2StreamStatus]; if ((status & StreamState.WantTrailer) !== 0) return; @@ -5198,7 +5208,7 @@ class ClientHttp2Session extends Http2Session { } else { stream.emit("wantTrailers"); } - }, + }), goaway(self: ClientHttp2Session, errorCode: number, lastStreamId: number, opaqueData: Buffer) { if (!self) return; if (self.destroyed) return; @@ -5769,7 +5779,12 @@ class ClientHttp2Session extends Http2Session { } // Like Node's Http2Stream._destroy: a received GOAWAY's code takes // precedence over the destroy code when streams are torn down. - parser.emitErrorToAllStreams(this[kGoawayCode] || (code !== undefined ? code : constants.NGHTTP2_CANCEL)); + this[bunHTTP2SessionTeardownFrame] = $getInternalField($asyncContext, 0); + try { + parser.emitErrorToAllStreams(this[kGoawayCode] || (code !== undefined ? code : constants.NGHTTP2_CANCEL)); + } finally { + this[bunHTTP2SessionTeardownFrame] = kNoSessionTeardown; + } parser.detach(); } } catch (e) { @@ -5782,12 +5797,16 @@ class ClientHttp2Session extends Http2Session { this.#parser = null; this[bunHTTP2Socket] = null; + // Read-and-clear the frame first: emitting 'error' with no listener throws, + // which would skip the clear and leave a retained session pinning the store. + const asyncFrame = this[bunHTTP2AsyncContextFrame]; + this[bunHTTP2AsyncContextFrame] = undefined; if (error) { - this.emit("error", error); + runInFrame(asyncFrame, this.emit, this, "error", error); } // node emits the session 'close' event asynchronously (a listener attached right after // close()/destroy() returns must still observe it). - process.nextTick(emitEventNT, this, "close"); + process.nextTick(emitSessionCloseNT, this, asyncFrame); } request(headers: any, options?: any) { @@ -6011,17 +6030,23 @@ class ClientHttp2Session extends Http2Session { let rejectContentLengthOnNoPayload = false; if (NoPayloadMethods.has(method.toUpperCase())) { + // Like Node, a payload-meaningless method only defaults endStream to + // true when the caller expressed no preference; an explicit endStream + // (validated above) is honored, so { endStream: false } stays open. if (!options || !$isObject(options)) { options = { endStream: true }; - } else { + } else if (options.endStream === undefined) { options = { ...options, endStream: true }; } - // nghttp2 refuses content-length on a request that cannot carry a payload: the stream is - // reset with PROTOCOL_ERROR after creation (an async stream error, not a throw). - for (const key of Object.keys(headers)) { - if (key.toLowerCase() === "content-length") { - rejectContentLengthOnNoPayload = true; - break; + // nghttp2 refuses content-length on a request whose HEADERS carry END_STREAM (no payload + // can follow): reset with PROTOCOL_ERROR after creation (an async stream error, not a + // throw). An explicit endStream:false keeps the body legal, so only the ended case rejects. + if (options.endStream) { + for (const key of Object.keys(headers)) { + if (key.toLowerCase() === "content-length") { + rejectContentLengthOnNoPayload = true; + break; + } } } } diff --git a/src/js/node/perf_hooks.ts b/src/js/node/perf_hooks.ts index 23be4ec2364e..16206fe4a3b2 100644 --- a/src/js/node/perf_hooks.ts +++ b/src/js/node/perf_hooks.ts @@ -1,5 +1,14 @@ // Hardcoded module "node:perf_hooks" -const { throwNotImplemented, kNodeEntryTypes, NodeEntryObserver } = require("internal/shared"); +const { + throwNotImplemented, + kNodeEntryTypes, + NodeEntryObserver, + enqueueNodeEntry, + hasObserver, + PerformanceNodeEntry, + kEmptyObject, +} = require("internal/shared"); +const { validateFunction, validateObject } = require("internal/validators"); const cppCreateHistogram = $newCppFunction("JSNodePerformanceHooksHistogram.cpp", "jsFunction_createHistogram", 3) as ( min: number, @@ -16,6 +25,14 @@ var { PerformanceObserverEntryList, } = globalThis; +// `extends PerformanceEntry` can't work (WebCore ctor throws); link here via +// the captured global. Construction is gated by hasObserver() so this module +// has loaded first. Guarded so a pre-require delete degrades, not throws. +if (PerformanceEntry) { + Object.setPrototypeOf(PerformanceNodeEntry.prototype, PerformanceEntry.prototype); + Object.setPrototypeOf(PerformanceNodeEntry, PerformanceEntry); +} + var constants = { NODE_PERFORMANCE_ENTRY_TYPE_DNS: 4, NODE_PERFORMANCE_ENTRY_TYPE_GC: 0, @@ -200,7 +217,67 @@ Object.defineProperty(PerformanceObserverForNodeTypes, "name", { configurable: true, }); +// kEmptyObject (frozen, null-prototype) so the histogram read below cannot +// pick up a polluted Object.prototype, matching node's default. +function timerify(fn, options = kEmptyObject) { + validateFunction(fn, "fn"); + validateObject(options, "options"); + const { histogram } = options; + // Node brand-checks with isHistogram (kHandle presence); Bun duck-types on + // .record for now — a native brand-check helper on + // JSNodePerformanceHooksHistogram would tighten this if it ever matters. + if ( + histogram !== undefined && + (histogram === null || typeof histogram !== "object" || typeof histogram.record !== "function") + ) { + throw $ERR_INVALID_ARG_TYPE("options.histogram", "RecordableHistogram", histogram); + } + + function timerified(...args) { + const isConstructorCall = new.target !== undefined; + const start = performance.now(); + const result = isConstructorCall ? Reflect.construct(fn, args, fn) : fn.$apply(this, args); + if (!isConstructorCall && typeof result?.finally === "function") { + return result.finally(() => { + processTimerifyComplete(fn.name, start, args, histogram); + }); + } + processTimerifyComplete(fn.name, start, args, histogram); + return result; + } + + Object.defineProperties(timerified, { + length: { + __proto__: null, + configurable: false, + enumerable: true, + value: fn.length, + }, + name: { + __proto__: null, + configurable: false, + enumerable: true, + value: `timerified ${fn.name}`, + }, + }); + + return timerified; +} + +function processTimerifyComplete(name, start, args, histogram) { + const duration = performance.now() - start; + if (histogram !== undefined) { + histogram.record(Math.ceil(duration * 1e6)); + } + if (hasObserver("function")) { + const entry = new PerformanceNodeEntry(name, "function", start, duration, args); + for (let n = 0; n < args.length; n++) entry[n] = args[n]; + enqueueNodeEntry(entry); + } +} + export default { + timerify, performance: { mark(_) { return performance.mark(...arguments); @@ -233,6 +310,7 @@ export default { onresourcetimingbufferfull: performance.onresourcetimingbufferfull, nodeTiming: createPerformanceNodeTiming(), now: () => performance.now(), + timerify, eventLoopUtilization: eventLoopUtilization, clearResourceTimings: function () {}, }, @@ -259,22 +337,28 @@ export default { PerformanceObserver: PerformanceObserverForNodeTypes, PerformanceObserverEntryList, PerformanceNodeTiming, + eventLoopUtilization, monitorEventLoopDelay: function monitorEventLoopDelay(options?: { resolution?: number }) { const impl = require("internal/perf_hooks/monitorEventLoopDelay"); return impl(options); }, - createHistogram: function createHistogram(options?: { - lowest?: number | bigint; - highest?: number | bigint; - figures?: number; - }): import("node:perf_hooks").RecordableHistogram { - const opts = options || {}; + createHistogram: function createHistogram( + options: { + lowest?: number | bigint; + highest?: number | bigint; + figures?: number; + } = kEmptyObject, + ): import("node:perf_hooks").RecordableHistogram { + // kEmptyObject default, and validate rather than `options || {}`: the reads + // below must not see a polluted Object.prototype, and node rejects a + // non-object argument instead of silently ignoring it. + validateObject(options, "options"); let lowest = 1; let highest = Number.MAX_SAFE_INTEGER; let figures = 3; - const lowestOpt = opts.lowest; + const lowestOpt = options.lowest; if (lowestOpt !== undefined) { if (typeof lowestOpt === "bigint") { lowest = Number(lowestOpt); @@ -285,7 +369,7 @@ export default { } } - const highestOpt = opts.highest; + const highestOpt = options.highest; if (highestOpt !== undefined) { if (typeof highestOpt === "bigint") { highest = Number(highestOpt); @@ -296,7 +380,7 @@ export default { } } - const figuresOpt = opts.figures; + const figuresOpt = options.figures; if (figuresOpt !== undefined) { if (typeof figuresOpt !== "number") { throw $ERR_INVALID_ARG_TYPE("options.figures", "number", figuresOpt); diff --git a/test/js/node/async_hooks/AsyncLocalStorage.test.ts b/test/js/node/async_hooks/AsyncLocalStorage.test.ts index a0be6a2db4f0..142f3303a89e 100644 --- a/test/js/node/async_hooks/AsyncLocalStorage.test.ts +++ b/test/js/node/async_hooks/AsyncLocalStorage.test.ts @@ -1,6 +1,7 @@ import { AsyncLocalStorage, AsyncResource } from "async_hooks"; import { describe, expect, test } from "bun:test"; import { bunEnv, bunExe } from "harness"; +import http2 from "http2"; describe("AsyncLocalStorage", () => { test("throw inside of AsyncLocalStorage.run() will be passed out", () => { @@ -11,6 +12,194 @@ describe("AsyncLocalStorage", () => { }); }).toThrow("error"); }); + + // 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", () => { + const s = new AsyncLocalStorage({ defaultValue: "def" }); + expect(s.run("x", () => 42)).toBe(42); + expect(s.getStore()).toBe("def"); + + // nested inside another storage's context: entry is spliced, not cleared + const other = new AsyncLocalStorage(); + other.run(1, () => { + const inner = new AsyncLocalStorage({ defaultValue: "d2" }); + expect(inner.run("y", () => 7)).toBe(7); + expect(inner.getStore()).toBe("d2"); + }); + + // disable() during the callback: run() finally re-enables (Node's is + // unconditionally enterWith(prior)), so getStore() falls through to + // defaultValue. Verified against Node v26.4.0 (both --async-context-frame + // and --no-async-context-frame). + const s3 = new AsyncLocalStorage({ defaultValue: "d3" }); + expect( + s3.run("z", () => { + s3.disable(); + return 9; + }), + ).toBe(9); + expect(s3.getStore()).toBe("d3"); + + // Bare disable() without run(): getStore() returns defaultValue, not + // undefined (Node v26 both impls). + const s4 = new AsyncLocalStorage({ defaultValue: "d4" }); + s4.enterWith("v"); + s4.disable(); + expect(s4.getStore()).toBe("d4"); + }); + + // NaN is a legal store value in Node; === cannot compare it. + // Verified against Node v26.3.0. + test("NaN is usable as a store value and as defaultValue", () => { + const s = new AsyncLocalStorage(); + expect(s.run(NaN, () => s.getStore())).toBeNaN(); + + const withDefault = new AsyncLocalStorage({ defaultValue: NaN }); + expect(withDefault.run("x", () => 42)).toBe(42); + expect(withDefault.getStore()).toBeNaN(); + + const other = new AsyncLocalStorage(); + const s2 = new AsyncLocalStorage(); + try { + other.enterWith("keep"); + s2.enterWith(NaN); + expect(s2.getStore()).toBeNaN(); + expect(other.getStore()).toBe("keep"); + } finally { + // enterWith() is not scoped: splice both back out so later tests still + // start from an empty context. + s2.disable(); + other.disable(); + } + }); + + // Node compares stores with the primordial ObjectIs, which userland cannot + // reach. Subprocess: patches a global. Verified against Node v26.3.0. + test("run() is unaffected by a userland Object.is patch", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `Object.is = () => true; + const { AsyncLocalStorage } = require("async_hooks"); + const s = new AsyncLocalStorage(); + console.log(s.run("v", () => s.getStore()));`, + ], + 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: "v", exitCode: 0 }); + expect(stderr).not.toContain("AssertionError"); + }); + + // run() entered on a disabled storage must still restore on the way out: + // node's finally is an unconditional enterWith(prior), so the store must not + // survive past run(). Verified against Node v26.3.0. + test("run() on a disabled storage does not leak the store past the callback", () => { + const a = new AsyncLocalStorage(); + a.disable(); + expect(a.run("Y", () => a.getStore())).toBe("Y"); + expect(a.getStore()).toBeUndefined(); + + const b = new AsyncLocalStorage({ defaultValue: "D" }); + b.disable(); + expect(b.run("Y", () => b.getStore())).toBe("Y"); + expect(b.getStore()).toBe("D"); + + // ...including when the callback disables it again. + const c = new AsyncLocalStorage({ defaultValue: "D" }); + c.disable(); + c.run("Y", () => c.disable()); + expect(c.getStore()).toBe("D"); + }); + + // A snapshot-restored frame can hold a value for a disabled storage, which + // getStore() masks with defaultValue — run() must not short-circuit on that + // comparison and leave the masked value visible. Verified against Node v26.3.0. + test("run() inside a snapshot does not expose a disabled storage's frame value", () => { + const als = new AsyncLocalStorage(); + let snap!: (fn: () => T) => T; + als.run("X", () => { + snap = AsyncLocalStorage.snapshot(); + }); + als.disable(); + expect(snap(() => als.exit(() => als.getStore()))).toBeUndefined(); + + const withDefault = new AsyncLocalStorage({ defaultValue: "D" }); + let snap2!: (fn: () => T) => T; + withDefault.run("Y", () => { + snap2 = AsyncLocalStorage.snapshot(); + }); + withDefault.disable(); + expect(snap2(() => withDefault.run(undefined, () => withDefault.getStore()))).toBeUndefined(); + }); + + // run() on a disabled storage takes the full path and re-enables it. + // Verified against Node. + test("run(undefined)/exit() on a disabled storage re-enables it", () => { + const als = new AsyncLocalStorage(); + als.disable(); + als.exit(() => {}); + als.run("Y", () => {}); + expect(als.getStore()).toBeUndefined(); + }); + + // Behaviour verified against Node v26.4.0. + test("run() short-circuits when the store value is unchanged (Object.is)", () => { + const als1 = new AsyncLocalStorage(); + const als2 = new AsyncLocalStorage(); + const als3 = new AsyncLocalStorage({ defaultValue: "d" }); + try { + // enterWith inside a same-value run survives past the run + als1.enterWith("A"); + als1.run("A", () => als1.enterWith("B")); + expect(als1.getStore()).toBe("B"); + + // exit() on a fresh storage is a same-value (undefined) run + als2.exit(() => als2.enterWith("C")); + expect(als2.getStore()).toBe("C"); + + // defaultValue counts as the "current" store + als3.run("d", () => als3.enterWith("X")); + expect(als3.getStore()).toBe("X"); + } finally { + // enterWith() is not scoped: splice the entries back out so later + // tests still start from an empty context. + als1.disable(); + als2.disable(); + als3.disable(); + } + }); + + // Reaches the else-if(hasPrevious) re-enable branch in run()'s finally. + test("disable() mid-run then finally restores the previous value", () => { + const als = new AsyncLocalStorage(); + als.run("outer", () => { + als.run("inner", () => als.disable()); + // Node v26: run's finally re-enters the previous value. + expect(als.getStore()).toBe("outer"); + }); + expect(als.getStore()).toBeUndefined(); + + // hasPrevious=true via enterWith, disable() mid-run of ANOTHER storage's callback. + const alsA = new AsyncLocalStorage(); + const alsB = new AsyncLocalStorage(); + try { + alsA.enterWith("prev"); + alsA.run("a", () => { + alsB.run("b", () => alsA.disable()); + // Still inside alsA.run: finally hasn't fired yet, alsA is disabled. + expect(alsA.getStore()).toBeUndefined(); + }); + expect(alsA.getStore()).toBe("prev"); + } finally { + // alsB is spliced out by its own run(); enterWith('prev') is not scoped. + alsA.disable(); + } + }); }); test("AsyncResource", () => { @@ -547,6 +736,402 @@ describe("async context passes through", () => { expect(s.getStore()).toBe(undefined); expect(v).toBe("value"); }); + test("http2 client stream: native events see request-time context; user emit sees caller context", async () => { + const s = new AsyncLocalStorage(); + const server = http2.createServer(); + server.on("stream", stream => { + stream.respond({ ":status": 200 }); + stream.end("ok"); + }); + await new Promise(r => server.listen(0, r)); + const port = (server.address() as import("net").AddressInfo).port; + const client = http2.connect(`http://127.0.0.1:${port}`); + try { + const req = s.run("REQUEST", () => client.request({ ":path": "/" })); + + // Native-driven events observe the context captured at request() time. + const responseStore = new Promise(r => req.on("response", () => r(s.getStore()))); + const dataStore = new Promise(r => req.on("data", () => r(s.getStore()))); + const endStore = new Promise(r => req.on("end", () => r(s.getStore()))); + const closeStore = new Promise(r => req.on("close", () => r(s.getStore()))); + req.end(); + expect(await responseStore).toBe("REQUEST"); + expect(await dataStore).toBe("REQUEST"); + expect(await endStore).toBe("REQUEST"); + expect(await closeStore).toBe("REQUEST"); + + // User-initiated emit() observes the CALLER's context (Node semantics — + // only native→JS callbacks re-enter the resource scope; Bun swaps the + // frame at the #Handlers seam, not by overriding emit()). + let customStore; + req.on("custom", () => { + customStore = s.getStore(); + }); + s.run("USER", () => req.emit("custom")); + expect(customStore).toBe("USER"); + + // Session-level 'close' must observe the SESSION's construction-time + // context (undefined here), not the last stream's — Node fires it in + // the Http2Session AsyncWrap scope, not any Http2Stream's. The + // withStreamFrame-wrapped streamEnd calls self.destroy() while the + // stream's frame is installed, so destroy() must run its own emits in + // the session frame. + const sessionCloseStore = new Promise(r => client.on("close", () => r(s.getStore()))); + client.close(); + expect(await sessionCloseStore).toBeUndefined(); + } finally { + if (!client.destroyed) client.destroy(); + await new Promise(r => server.close(r)); + } + }); + // An error in the window between onSocket() and onSocketNT() must still see + // the request's context: the socket listeners are frame-wrapped, and an + // unset frame would clear it. Verified against Node v26.3.0. + test("http: a socket error before onSocketNT keeps the request's context", async () => { + const http = require("http"); + const net = require("net"); + const s = new AsyncLocalStorage(); + const { promise, resolve, reject } = Promise.withResolvers(); + + const agent = new http.Agent(); + let injected: import("net").Socket | undefined; + agent.createConnection = function () { + const sock = new net.Socket(); + injected = sock; + let armed = false; + const on = sock.on.bind(sock); + sock.on = function (ev, fn) { + const r = on(ev, fn); + // Arm once, during onSocket()'s own socket.on("error") registration, so + // it fires before onSocket()'s process.nextTick(onSocketNT, ...). + if (ev === "error" && !armed) { + armed = true; + process.nextTick(() => sock.emit("error", new Error("boom"))); + } + return r; + }; + return sock; + }; + + try { + s.run("X", () => { + const req = http.request({ host: "127.0.0.1", port: 1, agent }); + // Assert on the injected error specifically: a connect-refusal error + // reaching here instead means the pre-onSocketNT window was missed and + // the test would otherwise pass without exercising the fix. + req.on("error", err => resolve(`${(err as Error).message}|${s.getStore()}`)); + req.on("response", () => reject(new Error("unexpected response"))); + req.end(); + }); + + expect(await promise).toBe("boom|X"); + } finally { + injected?.destroy(); + agent.destroy(); + } + }); + + test("http agent reuse: req 'error'/'close' see the reused request's context", async () => { + const http = require("http"); + const net = require("net"); + const s = new AsyncLocalStorage(); + let dataHits = 0; + // Keep-alive reuses ONE connection: first request served, second RST'd. + const server = net.createServer(sock => { + sock.on("data", () => { + dataHits++; + if (dataHits === 1) sock.write("HTTP/1.1 200 OK\r\nConnection: keep-alive\r\nContent-Length: 2\r\n\r\nok"); + else sock.resetAndDestroy(); + }); + }); + await new Promise(r => server.listen(0, r)); + const port = (server.address() as import("net").AddressInfo).port; + const agent = new http.Agent({ keepAlive: true, maxSockets: 1 }); + try { + const { errorStore, closeStore } = await new Promise<{ errorStore: unknown; closeStore: unknown }>( + (resolve, reject) => { + s.run("first", () => { + const r1 = http.request({ host: "127.0.0.1", port, agent }, res => { + res.resume(); + res.on("end", () => { + // setImmediate() lets the agent register the freed socket. + setImmediate(() => { + s.run("second", () => { + const r2 = http.request({ host: "127.0.0.1", port, agent }); + let errorStore: unknown, closeStore: unknown; + r2.on("error", () => { + errorStore = s.getStore(); + }); + r2.on("close", () => { + closeStore = s.getStore(); + resolve({ errorStore, closeStore }); + }); + r2.end(); + }); + }); + }); + }); + r1.on("error", reject); + r1.end(); + }); + }, + ); + expect(errorStore).toBe("second"); + expect(closeStore).toBe("second"); + } finally { + agent.destroy(); + await new Promise(r => server.close(r)); + } + }); + test("http.request clears its captured async-context frame on 'close'", async () => { + const http = require("http"); + const server = http.createServer((_req, res) => res.end("ok")); + await new Promise(r => server.listen(0, r)); + const port = (server.address() as import("net").AddressInfo).port; + const agent = new http.Agent({ keepAlive: true }); + const s = new AsyncLocalStorage(); + let req: any; + try { + await s.run( + { marker: true }, + () => + new Promise((resolve, reject) => { + req = http.request({ host: "127.0.0.1", port, agent }, res => { + res.resume(); + }); + req.on("error", reject); + req.on("close", resolve); + req.end(); + }), + ); + // req is retained past 'close'; closeRequest() must have cleared the + // frame slot so the store is not pinned by the retained request. This + // is Bun's counterpart to Node's parser.initialize resource leak (Bun's + // parser binding ignores the resource argument, so the vendored + // test-async-local-storage-http-parser-leak.js is a compat-only no-op + // — this is the coverage that fails if the closeRequest cleanup drops). + const kClientAsyncContext = Object.getOwnPropertySymbols(req).find( + sym => sym.description === "kClientAsyncContext", + ); + expect(kClientAsyncContext).toBeDefined(); + expect(req[kClientAsyncContext!]).toBeUndefined(); + } finally { + agent.destroy(); + await new Promise(r => server.close(r)); + } + }); + // http2 counterpart of the http1 cleanup above: a stream/session retained + // past its terminal event must not pin the store. + test("http2 clears its captured async-context frame on stream and session close", async () => { + const server = http2.createServer((_req, res) => res.end("ok")); + await new Promise(r => server.listen(0, r)); + const port = (server.address() as import("net").AddressInfo).port; + const s = new AsyncLocalStorage(); + let stream: any, client: any; + let closed!: Promise; + try { + await s.run( + { marker: true }, + () => + new Promise((resolve, reject) => { + client = http2.connect(`http://127.0.0.1:${port}`); + client.on("error", reject); + // Registered before the await: the session can close on its own and + // this must not miss the event. + closed = new Promise(r => client.on("close", () => r())); + stream = client.request({ ":path": "/" }); + stream.on("error", reject); + stream.resume(); + stream.on("close", () => resolve()); + stream.end(); + }), + ); + const frameSym = Object.getOwnPropertySymbols(stream).find( + sym => sym.description === "::bunhttp2asynccontextframe::", + ); + expect(frameSym).toBeDefined(); + expect(stream[frameSym!]).toBeUndefined(); + + client.close(); + await closed; + expect(client[frameSym!]).toBeUndefined(); + } finally { + if (client && !client.destroyed) client.destroy(); + await new Promise(r => server.close(r)); + } + }); + + // The session frame is read-and-cleared before the emit, so a throwing + // 'close' listener cannot leave a retained session pinning the store. + // Subprocess: the listener throws, which the test runner would otherwise + // claim as its own failure. + test("http2 clears the session frame even if a 'close' listener throws", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const { AsyncLocalStorage } = require("async_hooks"); + const http2 = require("http2"); + const als = new AsyncLocalStorage(); + const server = http2.createServer((_q, r) => r.end("ok")); + server.listen(0, () => { + let client; + als.run({ marker: true }, () => { + client = http2.connect("http://127.0.0.1:" + server.address().port); + const s = client.request({ ":path": "/" }); + s.resume(); + s.on("close", () => client.close()); + s.end(); + }); + client.on("close", () => { throw new Error("listener boom"); }); + client.on("error", () => {}); + // The throw IS the condition: it can only come from the 'close' + // emit, which runs strictly after the read-and-clear. + process.on("uncaughtException", err => { + if (err.message !== "listener boom") throw err; + const sym = Object.getOwnPropertySymbols(client) + .find(x => x.description === "::bunhttp2asynccontextframe::"); + console.log(sym === undefined ? "SYMBOL-MISSING" : client[sym] === undefined ? "CLEARED" : "PINNED"); + server.close(); + }); + });`, + ], + 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: "CLEARED", exitCode: 0 }); + expect(stderr).not.toContain("AssertionError"); + }); + + // 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). + test("http2 clears the session frame when destroy(err) throws past the emit", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const { AsyncLocalStorage } = require("async_hooks"); + const http2 = require("http2"); + const als = new AsyncLocalStorage(); + const server = http2.createServer((_q, r) => r.end("ok")); + server.listen(0, () => { + let client; + als.run({ marker: true }, () => { + client = http2.connect("http://127.0.0.1:" + server.address().port); + }); + client.on("connect", () => { + try { client.destroy(new Error("boom")); } catch {} + const sym = Object.getOwnPropertySymbols(client) + .find(x => x.description === "::bunhttp2asynccontextframe::"); + console.log(sym === undefined ? "SYMBOL-MISSING" : client[sym] === undefined ? "CLEARED" : "PINNED"); + // Drain rather than process.exit(), like the siblings. + server.close(); + }); + });`, + ], + 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: "CLEARED", exitCode: 0 }); + expect(stderr).not.toContain("AssertionError"); + }); + + // _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 + // swallowed into the stream's 'error', so this leak is otherwise silent. + test("http2 clears the stream frame even if an 'aborted' listener throws", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const { AsyncLocalStorage } = require("async_hooks"); + const http2 = require("http2"); + const als = new AsyncLocalStorage(); + const server = http2.createServer((_q, r) => { r.write("a"); }); + server.listen(0, () => { + const client = http2.connect("http://127.0.0.1:" + server.address().port); + let st; + // endStream:false keeps the writable side open: like Node, destroy() + // only emits 'aborted' for a stream whose writable side has not ended + // (a plain GET ends it up front, so 'aborted' would never fire). + als.run({ marker: true }, () => { + st = client.request({ ":path": "/" }, { endStream: false }); + st.resume(); + }); + st.on("aborted", () => { throw new Error("aborted boom"); }); + st.on("response", () => st.destroy()); + // The throw is swallowed into the stream's 'error' — that event IS + // the condition, and it fires after _destroy has unwound. ('close' + // never arrives: the throw aborts the destroy.) + st.on("error", err => { + if (err.message !== "aborted boom") throw err; + const sym = Object.getOwnPropertySymbols(st) + .find(x => x.description === "::bunhttp2asynccontextframe::"); + console.log(sym === undefined ? "SYMBOL-MISSING" : st[sym] === undefined ? "CLEARED" : "PINNED"); + // Tear the session down and let the loop drain: exiting with the + // session still open leaks it, which aborts under ASAN. + client.destroy(); + server.close(); + }); + });`, + ], + 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: "CLEARED", exitCode: 0 }); + expect(stderr).not.toContain("AssertionError"); + }); + + // The upgrade/connect branch emits before closeRequest(), which carries the + // clear; a throwing handler must not leave the request pinning the store. + test("http clears the request frame even if an 'upgrade' handler throws", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const { AsyncLocalStorage } = require("async_hooks"); + const http = require("http"); + const net = require("net"); + const als = new AsyncLocalStorage(); + const server = net.createServer(sock => { + sock.once("data", () => sock.write("HTTP/1.1 101 Switching Protocols\\r\\nUpgrade: x\\r\\nConnection: Upgrade\\r\\n\\r\\n")); + }); + server.listen(0, "127.0.0.1", () => { + let req; + als.run({ marker: true }, () => { + req = http.request({ host: "127.0.0.1", port: server.address().port, headers: { Connection: "Upgrade", Upgrade: "x" } }); + req.end(); + }); + req.on("error", () => {}); + let upgraded; + req.on("upgrade", (_res, socket) => { upgraded = socket; throw new Error("upgrade boom"); }); + process.on("uncaughtException", err => { + if (err.message !== "upgrade boom") throw err; + const sym = Object.getOwnPropertySymbols(req) + .find(x => x.description === "kClientAsyncContext"); + console.log(sym === undefined ? "SYMBOL-MISSING" : req[sym] === undefined ? "CLEARED" : "PINNED"); + // Close the upgraded socket too: exiting with it open leaks it, + // which aborts under ASAN. + upgraded?.destroy(); + server.close(); + }); + });`, + ], + 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: "CLEARED", exitCode: 0 }); + expect(stderr).not.toContain("AssertionError"); + }); + test("Bun.build plugin", async () => { const s = new AsyncLocalStorage(); let a = undefined; diff --git a/test/js/node/async_hooks/EventEmitterAsyncResource.test.ts b/test/js/node/async_hooks/EventEmitterAsyncResource.test.ts index 1fcb05c468f0..672fad13023f 100644 --- a/test/js/node/async_hooks/EventEmitterAsyncResource.test.ts +++ b/test/js/node/async_hooks/EventEmitterAsyncResource.test.ts @@ -1,4 +1,4 @@ -import { AsyncLocalStorage } from "async_hooks"; +import { AsyncLocalStorage, AsyncResource } from "async_hooks"; import { describe, expect, test } from "bun:test"; import EventEmitter, { EventEmitterAsyncResource } from "events"; @@ -8,6 +8,21 @@ describe("EventEmitterAsyncResource", () => { expect(ee).toBeInstanceOf(EventEmitterAsyncResource); expect(ee).toBeInstanceOf(EventEmitter); }); + // triggerAsyncId echoes the constructor option like Node; Bun's default execution async id is 0. + test("triggerAsyncId reflects the option", () => { + expect(new EventEmitterAsyncResource({ name: "x", triggerAsyncId: 7 }).triggerAsyncId).toBe(7); + expect(new EventEmitterAsyncResource({ name: "x" }).triggerAsyncId).toBe(0); + expect(new AsyncResource("x", { triggerAsyncId: 7 }).triggerAsyncId()).toBe(7); + expect(new AsyncResource("x", 7).triggerAsyncId()).toBe(7); + expect(new AsyncResource("x").triggerAsyncId()).toBe(0); + let err; + try { + new EventEmitterAsyncResource({ name: "x", triggerAsyncId: -2 }); + } catch (e) { + err = e; + } + expect(err?.code).toBe("ERR_INVALID_ASYNC_ID"); + }); test("has context tracking", () => { let ee; const asl = new AsyncLocalStorage(); @@ -21,9 +36,67 @@ describe("EventEmitterAsyncResource", () => { }); asl.run(456, () => { - ee.emit("test"); + expect(ee.emit("test")).toBe(true); }); expect(val).toBe(123); + expect(ee.emit("nobody-listening")).toBe(false); + }); + + test("captureRejections", async () => { + let ee; + const asl = new AsyncLocalStorage(); + asl.run(123, () => { + ee = new EventEmitterAsyncResource({ name: "test", captureRejections: true }); + }); + + let listenerStore; + ee.on("test", async () => { + listenerStore = asl.getStore(); + throw new Error("boom"); + }); + + const { promise, resolve } = Promise.withResolvers(); + let rejectionStore; + ee[Symbol.for("nodejs.rejection")] = (err, event) => { + rejectionStore = asl.getStore(); + resolve({ err, event }); + }; + + asl.run(456, () => { + expect(ee.emit("test")).toBe(true); + }); + // Listener runs in the resource's async scope even with captureRejections on + // (own-property emit stamped by the base constructor must not shadow it). + expect(listenerStore).toBe(123); + + const { err, event } = await promise; + expect(err.message).toBe("boom"); + expect(event).toBe("test"); + expect(rejectionStore).toBe(123); + }); + + // Node routes EventEmitterAsyncResource.emit through super.emit, so a + // userland monkeypatch of EventEmitter.prototype.emit is observed like it + // is for plain EventEmitter instances. + test("emit routes through EventEmitter.prototype.emit", () => { + const original = EventEmitter.prototype.emit; + let calls = 0; + try { + EventEmitter.prototype.emit = function (...args) { + calls++; + return original.apply(this, args); + }; + const ee = new EventEmitterAsyncResource("test"); + let fired = false; + ee.on("x", () => { + fired = true; + }); + ee.emit("x"); + expect(fired).toBe(true); + expect(calls).toBe(1); + } finally { + EventEmitter.prototype.emit = original; + } }); }); diff --git a/test/js/node/http2/node-http2.test.js b/test/js/node/http2/node-http2.test.js index 35be7cb43740..2bd65f01fba3 100644 --- a/test/js/node/http2/node-http2.test.js +++ b/test/js/node/http2/node-http2.test.js @@ -1693,6 +1693,61 @@ it("client stream events observe the request-time async context, not the session } }); +// Like Node, session.destroy(err) tears open streams down synchronously from the caller's +// stack: their 'error'/'close' run in the destroy() caller's async context (not the +// request()-time one), while the session's own 'error'/'close' keep the connect-time context. +it("client session.destroy() emits open streams' error/close in the caller's async context", async () => { + const als = new AsyncLocalStorage(); + const CONNECT = { id: "connect" }; + const REQUEST = { id: "request" }; + const DESTROY = { id: "destroy" }; + const server = http2.createServer(); + let client; + try { + const { promise: streamsOpened, resolve: onStreamsOpened } = Promise.withResolvers(); + let openStreams = 0; + server.on("stream", stream => { + stream.on("error", () => {}); + if (++openStreams === 2) onStreamsOpened(); + }); + await new Promise(resolve => server.listen(0, resolve)); + const { promise: connected, resolve: onConnect } = Promise.withResolvers(); + client = als.run(CONNECT, () => http2.connect(`http://localhost:${server.address().port}`)); + client.on("connect", onConnect); + const sessionEvents = { error: null, close: null }; + const { promise: sessionClosed, resolve: onSessionClose } = Promise.withResolvers(); + client.on("error", () => (sessionEvents.error = als.getStore())); + client.on("close", () => { + sessionEvents.close = als.getStore(); + onSessionClose(); + }); + await connected; + const streamEvents = []; + als.run(REQUEST, () => { + for (let i = 0; i < 2; i++) { + const req = client.request({ ":path": `/${i}` }); + req.on("error", () => streamEvents.push({ i, event: "error", store: als.getStore() })); + req.on("close", () => streamEvents.push({ i, event: "close", store: als.getStore() })); + } + }); + await streamsOpened; + als.run(DESTROY, () => client.destroy(new Error("boom"))); + await sessionClosed; + // Emission order across the two streams is not the contract; the context each ran in is. + streamEvents.sort((a, b) => a.i - b.i || a.event.localeCompare(b.event)); + expect(streamEvents).toEqual([ + { i: 0, event: "close", store: DESTROY }, + { i: 0, event: "error", store: DESTROY }, + { i: 1, event: "close", store: DESTROY }, + { i: 1, event: "error", store: DESTROY }, + ]); + expect(sessionEvents).toEqual({ error: CONNECT, close: CONNECT }); + } finally { + client?.destroy?.(); + server.close(); + } +}); + it("sensitive headers should work", async () => { const server = http2.createServer(); let client; diff --git a/test/js/node/perf_hooks/perf_hooks.test.ts b/test/js/node/perf_hooks/perf_hooks.test.ts index 29e965523700..3ad9379d4960 100644 --- a/test/js/node/perf_hooks/perf_hooks.test.ts +++ b/test/js/node/perf_hooks/perf_hooks.test.ts @@ -1,5 +1,7 @@ import { expect, test } from "bun:test"; -import perf from "perf_hooks"; +import { bunEnv, bunExe } from "harness"; +import net from "net"; +import perf, { PerformanceObserver } from "perf_hooks"; test("stubs", () => { expect(perf.performance.nodeTiming).toBeObject(); @@ -21,3 +23,139 @@ test("doesn't throw", () => { expect(() => performance.timeOrigin).not.toThrow(); expect(() => performance.markResourceTiming()).not.toThrow(); }); + +test("timerify entry shape", async () => { + const { promise, resolve } = Promise.withResolvers(); + const observer = new PerformanceObserver(list => resolve(list.getEntries()[0])); + observer.observe({ entryTypes: ["function"] }); + + const fn = perf.performance.timerify(function work(_a, _b) {}); + fn(42, "hello"); + + const entry = await promise; + observer.disconnect(); + + expect(entry).toBeInstanceOf(PerformanceEntry); + expect(entry.constructor.name).toBe("PerformanceNodeEntry"); + expect(Object.getPrototypeOf(entry.constructor)).toBe(PerformanceEntry); + expect(entry.name).toBe("work"); + expect(entry.entryType).toBe("function"); + expect(typeof entry.startTime).toBe("number"); + expect(typeof entry.duration).toBe("number"); + expect(entry.detail).toEqual([42, "hello"]); + // Node also exposes the args as indexed own-properties on the entry. + expect(entry[0]).toBe(42); + expect(entry[1]).toBe("hello"); + expect(entry.toJSON()).toEqual({ + name: "work", + entryType: "function", + startTime: entry.startTime, + duration: entry.duration, + detail: [42, "hello"], + }); +}); + +test("timerify is exposed on both performance and as a top-level export (Node v25.2+)", () => { + expect(perf.performance.timerify).toBeFunction(); + expect(perf.timerify).toBeFunction(); +}); + +// Captured from the real node v26.3.0 binary: +// `node -p "Object.keys(require('perf_hooks')).sort()"`. +test("export surface matches Node v26.3.0", () => { + const nodeExports = [ + "Performance", + "PerformanceEntry", + "PerformanceMark", + "PerformanceMeasure", + "PerformanceObserver", + "PerformanceObserverEntryList", + "PerformanceResourceTiming", + "constants", + "createHistogram", + "eventLoopUtilization", + "monitorEventLoopDelay", + "performance", + "timerify", + ]; + for (const name of nodeExports) { + expect(perf).toHaveProperty(name); + } + // Node names the PerformanceNodeEntry class but does not export it. + expect(perf.PerformanceNodeEntry).toBeUndefined(); + // Known bun-only extra, pre-existing on main: PerformanceNodeTiming. + expect( + Object.keys(perf) + .filter(k => !nodeExports.includes(k)) + .sort(), + ).toEqual(["PerformanceNodeTiming"]); +}); + +// The options defaults must not read through a polluted Object.prototype. +// Node uses kEmptyObject for both; verified against Node v26.3.0. +test("timerify and createHistogram survive Object.prototype option pollution", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `Object.prototype.histogram = 1; + Object.prototype.lowest = 99; + Object.prototype.figures = 99; + const { performance, createHistogram } = require("perf_hooks"); + console.log("timerify=" + typeof performance.timerify(function f() {})); + console.log("histogram=" + typeof createHistogram().record);`, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, exitCode }).toEqual({ stdout: "timerify=function\nhistogram=function\n", exitCode: 0 }); + expect(stderr).not.toContain("ERR_INVALID_ARG_TYPE"); +}); + +test("timerify and AsyncResource.bind survive Object.prototype.get pollution", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const { performance } = require("perf_hooks"); + const { AsyncResource } = require("async_hooks"); + // Pollute after module load: this test targets the two defineProperties + // sites that timerify()/bind() call per invocation, not module init. + Object.prototype.get = function () {}; + const t = performance.timerify(function f(_a) {}); + console.log("timerified name=" + t.name + " length=" + t.length); + const bound = new AsyncResource("R").bind(function g(_a, _b) {}); + console.log("bound length=" + bound.length);`, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).not.toContain("Invalid property descriptor"); + expect(stdout).toBe("timerified name=timerified f length=1\nbound length=2\n"); + expect(exitCode).toBe(0); +}); + +test("net entries are instanceof PerformanceEntry", async () => { + const { promise, resolve } = Promise.withResolvers(); + const observer = new PerformanceObserver(list => resolve(list.getEntries()[0])); + observer.observe({ entryTypes: ["net"] }); + + const server = net.createServer(c => c.end()); + await new Promise(r => server.listen(0, r)); + const port = server.address().port; + const socket = net.connect(port, "127.0.0.1"); + await new Promise(r => socket.on("connect", r)); + + const entry = await promise; + observer.disconnect(); + socket.destroy(); + await new Promise(r => server.close(r)); + + expect(entry).toBeInstanceOf(PerformanceEntry); + expect(entry.constructor.name).toBe("PerformanceNodeEntry"); + expect(entry.entryType).toBe("net"); +}); diff --git a/test/js/node/test/common/index.mjs b/test/js/node/test/common/index.mjs index 898e6f2d2e80..b5c87d944e26 100644 --- a/test/js/node/test/common/index.mjs +++ b/test/js/node/test/common/index.mjs @@ -18,6 +18,7 @@ const { getBufferSources, getTTYfd, hasCrypto, + hasQuic, hasIntl, hasIPv6, hasMultiLocalhost, @@ -74,6 +75,7 @@ export { getPort, getTTYfd, hasCrypto, + hasQuic, hasIntl, hasIPv6, hasMultiLocalhost, diff --git a/test/js/node/test/common/repl.js b/test/js/node/test/common/repl.js new file mode 100644 index 000000000000..6ce4d993a4c4 --- /dev/null +++ b/test/js/node/test/common/repl.js @@ -0,0 +1,25 @@ +'use strict'; + +const ArrayStream = require('../common/arraystream'); +const repl = require('node:repl'); + +function startNewREPLServer(replOpts = {}) { + const input = new ArrayStream(); + const output = new ArrayStream(); + + output.accumulator = ''; + output.write = (data) => (output.accumulator += `${data}`.replaceAll('\r', '')); + + const replServer = repl.start({ + prompt: '', + input, + output, + terminal: true, + allowBlockingCompletions: true, + ...replOpts, + }); + + return { replServer, input, output }; +} + +module.exports = { startNewREPLServer }; diff --git a/test/js/node/test/parallel/test-als-defaultvalue.js b/test/js/node/test/parallel/test-als-defaultvalue.js new file mode 100644 index 000000000000..2565983ed6b0 --- /dev/null +++ b/test/js/node/test/parallel/test-als-defaultvalue.js @@ -0,0 +1,34 @@ +"use strict"; + +require("../common"); + +const { AsyncLocalStorage } = require("async_hooks"); + +const assert = require("assert"); + +// ============================================================================ +// The defaultValue option +const als1 = new AsyncLocalStorage(); +assert.strictEqual(als1.getStore(), undefined); + +const als2 = new AsyncLocalStorage({ defaultValue: "default" }); +assert.strictEqual(als2.getStore(), "default"); + +const als3 = new AsyncLocalStorage({ defaultValue: 42 }); +assert.strictEqual(als3.getStore(), 42); + +const als4 = new AsyncLocalStorage({ defaultValue: null }); +assert.strictEqual(als4.getStore(), null); + +assert.throws(() => new AsyncLocalStorage(null), { + code: "ERR_INVALID_ARG_TYPE", +}); + +// ============================================================================ +// The name option + +const als5 = new AsyncLocalStorage({ name: "test" }); +assert.strictEqual(als5.name, "test"); + +const als6 = new AsyncLocalStorage(); +assert.strictEqual(als6.name, ""); diff --git a/test/js/node/test/parallel/test-async-hooks-stack-overflow-nested-async.js b/test/js/node/test/parallel/test-async-hooks-stack-overflow-nested-async.js new file mode 100644 index 000000000000..779f8d75ae20 --- /dev/null +++ b/test/js/node/test/parallel/test-async-hooks-stack-overflow-nested-async.js @@ -0,0 +1,80 @@ +'use strict'; + +// This test verifies that stack overflow during deeply nested async operations +// with async_hooks enabled can be caught by try-catch. This simulates real-world +// scenarios like processing deeply nested JSON structures where each level +// creates async operations (e.g., database calls, API requests). + +require('../common'); +const assert = require('assert'); +const { spawnSync } = require('child_process'); + +if (process.argv[2] === 'child') { + const { createHook } = require('async_hooks'); + + // Enable async_hooks with all callbacks (simulates APM tools) + createHook({ + init() {}, + before() {}, + after() {}, + destroy() {}, + promiseResolve() {}, + }).enable(); + + // Simulate an async operation (like a database call or API request) + async function fetchThing(id) { + return { id, data: `data-${id}` }; + } + + // Recursively process deeply nested data structure + // This will cause stack overflow when the nesting is deep enough + function processData(data, depth = 0) { + if (Array.isArray(data)) { + for (const item of data) { + // Create a promise to trigger async_hooks init callback + fetchThing(depth); + processData(item, depth + 1); + } + } + } + + // Create deeply nested array structure iteratively (to avoid stack overflow + // during creation) + function createNestedArray(depth) { + let result = 'leaf'; + for (let i = 0; i < depth; i++) { + result = [result]; + } + return result; + } + + // Create a very deep nesting that will cause stack overflow during processing + const deeplyNested = createNestedArray(50000); + + try { + processData(deeplyNested); + // Should not complete successfully - the nesting is too deep + console.log('UNEXPECTED: Processing completed without error'); + process.exit(1); + } catch (err) { + assert.strictEqual(err.name, 'RangeError'); + assert.match(err.message, /Maximum call stack size exceeded/); + console.log('SUCCESS: try-catch caught the stack overflow in nested async'); + process.exit(0); + } +} else { + // Parent process - spawn the child and check exit code + const result = spawnSync( + process.execPath, + [__filename, 'child'], + { encoding: 'utf8', timeout: 30000 } + ); + + // Should exit successfully (try-catch worked) + assert.strictEqual(result.status, 0, + `Expected exit code 0, got ${result.status}.\n` + + `stdout: ${result.stdout}\n` + + `stderr: ${result.stderr}`); + // Verify the error was handled by try-catch + assert.match(result.stdout, /SUCCESS: try-catch caught the stack overflow/); +} diff --git a/test/js/node/test/parallel/test-async-hooks-stack-overflow-try-catch.js b/test/js/node/test/parallel/test-async-hooks-stack-overflow-try-catch.js new file mode 100644 index 000000000000..4531e1ffe8f0 --- /dev/null +++ b/test/js/node/test/parallel/test-async-hooks-stack-overflow-try-catch.js @@ -0,0 +1,51 @@ +'use strict'; + +// This test verifies that when a stack overflow occurs with async_hooks +// enabled, the exception can be caught by try-catch blocks in user code. + +require('../common'); +const assert = require('assert'); +const { spawnSync } = require('child_process'); + +if (process.argv[2] === 'child') { + const { createHook } = require('async_hooks'); + + createHook({ init() {} }).enable(); + + function recursive(depth = 0) { + // Create a promise to trigger async_hooks init callback + new Promise(() => {}); + // Bun: JavaScriptCore implements proper tail calls in strict mode, so + // `return recursive(...)` would never overflow the stack. Keep the call + // in non-tail position to actually trigger the stack overflow. + recursive(depth + 1); + return depth; + } + + try { + recursive(); + // Should not reach here + process.exit(1); + } catch (err) { + assert.strictEqual(err.name, 'RangeError'); + assert.match(err.message, /Maximum call stack size exceeded/); + console.log('SUCCESS: try-catch caught the stack overflow'); + process.exit(0); + } + + // Should not reach here + process.exit(2); +} else { + // Parent process - spawn the child and check exit code + const result = spawnSync( + process.execPath, + [__filename, 'child'], + { encoding: 'utf8', timeout: 30000 } + ); + + assert.strictEqual(result.status, 0, + `Expected exit code 0 (try-catch worked), got ${result.status}.\n` + + `stdout: ${result.stdout}\n` + + `stderr: ${result.stderr}`); + assert.match(result.stdout, /SUCCESS: try-catch caught the stack overflow/); +} diff --git a/test/js/node/test/parallel/test-async-hooks-stack-overflow.js b/test/js/node/test/parallel/test-async-hooks-stack-overflow.js new file mode 100644 index 000000000000..70bcc747109d --- /dev/null +++ b/test/js/node/test/parallel/test-async-hooks-stack-overflow.js @@ -0,0 +1,51 @@ +'use strict'; + +// This test verifies that when a stack overflow occurs with async_hooks +// enabled, the uncaughtException handler is still called instead of the +// process crashing with exit code 7. + +const common = require('../common'); +const assert = require('assert'); +const { spawnSync } = require('child_process'); + +if (process.argv[2] === 'child') { + const { createHook } = require('async_hooks'); + + let handlerCalled = false; + + function recursive() { + // Create a promise to trigger async_hooks init callback + new Promise(() => {}); + // Bun: JavaScriptCore implements proper tail calls in strict mode, so + // `return recursive()` would never overflow the stack. Keep the call in + // non-tail position to actually trigger the stack overflow. + recursive(); + return undefined; + } + + createHook({ init() {} }).enable(); + + process.on('uncaughtException', common.mustCall((err) => { + assert.strictEqual(err.name, 'RangeError'); + assert.match(err.message, /Maximum call stack size exceeded/); + // Ensure handler is only called once + assert.strictEqual(handlerCalled, false); + handlerCalled = true; + })); + + setImmediate(recursive); +} else { + // Parent process - spawn the child and check exit code + const result = spawnSync( + process.execPath, + [__filename, 'child'], + { encoding: 'utf8', timeout: 30000 } + ); + + // Should exit with code 0 (handler was called and handled the exception) + // Previously would exit with code 7 (kExceptionInFatalExceptionHandler) + assert.strictEqual(result.status, 0, + `Expected exit code 0, got ${result.status}.\n` + + `stdout: ${result.stdout}\n` + + `stderr: ${result.stderr}`); +} diff --git a/test/js/node/test/parallel/test-async-local-storage-http-agent.js b/test/js/node/test/parallel/test-async-local-storage-http-agent.js new file mode 100644 index 000000000000..8c008f71da92 --- /dev/null +++ b/test/js/node/test/parallel/test-async-local-storage-http-agent.js @@ -0,0 +1,83 @@ +'use strict'; +const common = require('../common'); +const assert = require('node:assert'); +const { AsyncLocalStorage } = require('node:async_hooks'); +const http = require('node:http'); + +// Similar as test-async-hooks-http-agent added via +// https://github.com/nodejs/node/issues/13325 but verifies +// AsyncLocalStorage functionality instead async_hooks + +const cls = new AsyncLocalStorage(); + +// Make sure a single socket is transparently reused for 2 requests. +const agent = new http.Agent({ + keepAlive: true, + keepAliveMsecs: Infinity, + maxSockets: 1 +}); + +const server = http.createServer(common.mustCall((req, res) => { + req.once('data', common.mustCallAtLeast(() => { + res.writeHead(200, { 'Content-Type': 'text/plain' }); + res.write('foo'); + })); + req.on('end', common.mustCall(() => { + res.end('bar'); + })); +}, 2)).listen(0, common.mustCall(() => { + const port = server.address().port; + const payload = 'hello world'; + + // First request. This is useless except for adding a socket to the + // agent’s pool for reuse. + cls.run('first', common.mustCall(() => { + assert.strictEqual(cls.getStore(), 'first'); + const r1 = http.request({ + agent, port, method: 'POST' + }, common.mustCall((res) => { + assert.strictEqual(cls.getStore(), 'first'); + res.on('data', common.mustCallAtLeast(() => { + assert.strictEqual(cls.getStore(), 'first'); + })); + res.on('end', common.mustCall(() => { + assert.strictEqual(cls.getStore(), 'first'); + // setImmediate() to give the agent time to register the freed socket. + setImmediate(common.mustCall(() => { + assert.strictEqual(cls.getStore(), 'first'); + + cls.run('second', common.mustCall(() => { + // Second request. To re-create the exact conditions from the + // referenced issue, we use a POST request without chunked encoding + // (hence the Content-Length header) and call .end() after the + // response header has already been received. + const r2 = http.request({ + agent, port, method: 'POST', headers: { + 'Content-Length': payload.length + } + }, common.mustCall((res) => { + assert.strictEqual(cls.getStore(), 'second'); + // Empty payload, to hit the “right” code path. + r2.end(''); + + res.on('data', common.mustCallAtLeast(() => { + assert.strictEqual(cls.getStore(), 'second'); + })); + res.on('end', common.mustCall(() => { + assert.strictEqual(cls.getStore(), 'second'); + // Clean up to let the event loop stop. + server.close(); + agent.destroy(); + })); + })); + + // Schedule a payload to be written immediately, but do not end the + // request just yet. + r2.write(payload); + })); + })); + })); + })); + r1.end(payload); + })); +})); diff --git a/test/js/node/test/parallel/test-async-local-storage-http-parser-leak.js b/test/js/node/test/parallel/test-async-local-storage-http-parser-leak.js new file mode 100644 index 000000000000..2992db7c73f5 --- /dev/null +++ b/test/js/node/test/parallel/test-async-local-storage-http-parser-leak.js @@ -0,0 +1,29 @@ +// Flags: --expose-gc +'use strict'; + +const common = require('../common'); +const { onGC } = require('../common/gc'); +const assert = require('node:assert'); +const { AsyncLocalStorage } = require('node:async_hooks'); +const { freeParser, parsers, HTTPParser } = require('_http_common'); + +let storeGCed = false; + +const als = new AsyncLocalStorage(); + +function test() { + const store = {}; + onGC(store, { ongc: common.mustCall(() => { storeGCed = true; }) }); + let parser; + als.run(store, common.mustCall(() => { + parser = parsers.alloc(); + parser.initialize(HTTPParser.RESPONSE, {}); + })); + freeParser(parser); +} + +test(); +global.gc(); +setImmediate(common.mustCall(() => { + assert.ok(storeGCed); +})); diff --git a/test/js/node/test/parallel/test-async-local-storage-isolation.js b/test/js/node/test/parallel/test-async-local-storage-isolation.js new file mode 100644 index 000000000000..ea87688b6117 --- /dev/null +++ b/test/js/node/test/parallel/test-async-local-storage-isolation.js @@ -0,0 +1,67 @@ +'use strict'; +const common = require('../common'); +const { AsyncLocalStorage } = require('node:async_hooks'); +const assert = require('node:assert'); + +// Verify that ALS instances are independent of each other. + +{ + // Verify als2.enterWith() and als2.run inside als1.run() + const als1 = new AsyncLocalStorage(); + const als2 = new AsyncLocalStorage(); + + assert.strictEqual(als1.getStore(), undefined); + assert.strictEqual(als2.getStore(), undefined); + + als1.run('store1', common.mustCall(() => { + assert.strictEqual(als1.getStore(), 'store1'); + assert.strictEqual(als2.getStore(), undefined); + + als2.run('store2', common.mustCall(() => { + assert.strictEqual(als1.getStore(), 'store1'); + assert.strictEqual(als2.getStore(), 'store2'); + })); + assert.strictEqual(als1.getStore(), 'store1'); + assert.strictEqual(als2.getStore(), undefined); + + als2.enterWith('store3'); + assert.strictEqual(als1.getStore(), 'store1'); + assert.strictEqual(als2.getStore(), 'store3'); + })); + + assert.strictEqual(als1.getStore(), undefined); + assert.strictEqual(als2.getStore(), 'store3'); +} + +{ + // Verify als1.disable() has no side effects to als2 and als3 + const als1 = new AsyncLocalStorage(); + const als2 = new AsyncLocalStorage(); + const als3 = new AsyncLocalStorage(); + + als3.enterWith('store3'); + + als1.run('store1', common.mustCall(() => { + assert.strictEqual(als1.getStore(), 'store1'); + assert.strictEqual(als2.getStore(), undefined); + assert.strictEqual(als3.getStore(), 'store3'); + + als2.run('store2', common.mustCall(() => { + assert.strictEqual(als1.getStore(), 'store1'); + assert.strictEqual(als2.getStore(), 'store2'); + assert.strictEqual(als3.getStore(), 'store3'); + + als1.disable(); + assert.strictEqual(als1.getStore(), undefined); + assert.strictEqual(als2.getStore(), 'store2'); + assert.strictEqual(als3.getStore(), 'store3'); + })); + assert.strictEqual(als1.getStore(), undefined); + assert.strictEqual(als2.getStore(), undefined); + assert.strictEqual(als3.getStore(), 'store3'); + })); + + assert.strictEqual(als1.getStore(), undefined); + assert.strictEqual(als2.getStore(), undefined); + assert.strictEqual(als3.getStore(), 'store3'); +} diff --git a/test/js/node/test/parallel/test-async-local-storage-run-scope.js b/test/js/node/test/parallel/test-async-local-storage-run-scope.js new file mode 100644 index 000000000000..1ae3e44aadf6 --- /dev/null +++ b/test/js/node/test/parallel/test-async-local-storage-run-scope.js @@ -0,0 +1,202 @@ +/* eslint-disable no-unused-vars */ +'use strict'; +require('../common'); +const assert = require('node:assert'); +const { AsyncLocalStorage } = require('node:async_hooks'); + +// Test basic RunScope with using +{ + const storage = new AsyncLocalStorage(); + + assert.strictEqual(storage.getStore(), undefined); + + { + using scope = storage.withScope('test'); + assert.strictEqual(storage.getStore(), 'test'); + } + + // Store should be restored to undefined + assert.strictEqual(storage.getStore(), undefined); +} + +// Test RunScope restores previous value +{ + const storage = new AsyncLocalStorage(); + + storage.enterWith('initial'); + assert.strictEqual(storage.getStore(), 'initial'); + + { + using scope = storage.withScope('scoped'); + assert.strictEqual(storage.getStore(), 'scoped'); + } + + // Should restore to previous value + assert.strictEqual(storage.getStore(), 'initial'); +} + +// Test nested RunScope +{ + const storage = new AsyncLocalStorage(); + const storeValues = []; + + { + using outer = storage.withScope('outer'); + storeValues.push(storage.getStore()); + + { + using inner = storage.withScope('inner'); + storeValues.push(storage.getStore()); + } + + // Should restore to outer + storeValues.push(storage.getStore()); + } + + // Should restore to undefined + storeValues.push(storage.getStore()); + + assert.deepStrictEqual(storeValues, ['outer', 'inner', 'outer', undefined]); +} + +// Test RunScope with error during usage +{ + const storage = new AsyncLocalStorage(); + + storage.enterWith('before'); + + const testError = new Error('test'); + + assert.throws(() => { + using scope = storage.withScope('during'); + assert.strictEqual(storage.getStore(), 'during'); + throw testError; + }, testError); + + // Store should be restored even after error + assert.strictEqual(storage.getStore(), 'before'); +} + +// Test idempotent disposal via named dispose() method +{ + const storage = new AsyncLocalStorage(); + + const scope = storage.withScope('test'); + assert.strictEqual(storage.getStore(), 'test'); + + // Dispose via named dispose() method + scope.dispose(); + assert.strictEqual(storage.getStore(), undefined); + + storage.enterWith('test2'); + assert.strictEqual(storage.getStore(), 'test2'); + + // Double dispose should be idempotent + scope.dispose(); + assert.strictEqual(storage.getStore(), 'test2'); +} + +// Test withScope without using keyword (scope leaks until manually disposed) +{ + const storage = new AsyncLocalStorage(); + + const scope = storage.withScope('leaked'); + assert.strictEqual(storage.getStore(), 'leaked'); + + // Without using, the scope persists + assert.strictEqual(storage.getStore(), 'leaked'); + + // Must manually dispose via named method + scope.dispose(); + assert.strictEqual(storage.getStore(), undefined); +} + +// Test that dispose undoes enterWith called inside scope +{ + const storage = new AsyncLocalStorage(); + + storage.enterWith('store1'); + assert.strictEqual(storage.getStore(), 'store1'); + + { + using _ = storage.withScope('store2'); + assert.strictEqual(storage.getStore(), 'store2'); + + storage.enterWith('store3'); + assert.strictEqual(storage.getStore(), 'store3'); + } + + // Restores to store1, undoing both withScope and the enterWith inside scope + assert.strictEqual(storage.getStore(), 'store1'); +} + +// Test RunScope with defaultValue +{ + const storage = new AsyncLocalStorage({ defaultValue: 'default' }); + + assert.strictEqual(storage.getStore(), 'default'); + + { + using scope = storage.withScope('custom'); + assert.strictEqual(storage.getStore(), 'custom'); + } + + // Should restore to default + assert.strictEqual(storage.getStore(), 'default'); +} + +// Test deeply nested RunScope +{ + const storage = new AsyncLocalStorage(); + + { + using s1 = storage.withScope(1); + assert.strictEqual(storage.getStore(), 1); + + { + using s2 = storage.withScope(2); + assert.strictEqual(storage.getStore(), 2); + + { + using s3 = storage.withScope(3); + assert.strictEqual(storage.getStore(), 3); + + { + using s4 = storage.withScope(4); + assert.strictEqual(storage.getStore(), 4); + } + + assert.strictEqual(storage.getStore(), 3); + } + + assert.strictEqual(storage.getStore(), 2); + } + + assert.strictEqual(storage.getStore(), 1); + } + + assert.strictEqual(storage.getStore(), undefined); +} + +// Test RunScope with multiple storages +{ + const storage1 = new AsyncLocalStorage(); + const storage2 = new AsyncLocalStorage(); + + { + using scope1 = storage1.withScope('A'); + + { + using scope2 = storage2.withScope('B'); + + assert.strictEqual(storage1.getStore(), 'A'); + assert.strictEqual(storage2.getStore(), 'B'); + } + + assert.strictEqual(storage1.getStore(), 'A'); + assert.strictEqual(storage2.getStore(), undefined); + } + + assert.strictEqual(storage1.getStore(), undefined); + assert.strictEqual(storage2.getStore(), undefined); +} diff --git a/test/js/node/test/parallel/test-asyncresource-bind.js b/test/js/node/test/parallel/test-asyncresource-bind.js new file mode 100644 index 000000000000..ada52728fc8c --- /dev/null +++ b/test/js/node/test/parallel/test-asyncresource-bind.js @@ -0,0 +1,57 @@ +'use strict'; + +const common = require('../common'); +const assert = require('assert'); +const { AsyncResource, executionAsyncId } = require('async_hooks'); + +const fn = common.mustCall(AsyncResource.bind(() => { + return executionAsyncId(); +})); + +setImmediate(common.mustCall(() => { + // Bun: executionAsyncId() is not implemented (always returns the same id), + // so the asyncId inequality assertions from the upstream test are dropped. + // The bound function is still invoked to verify it works when called from + // a different async scope. + fn(); +})); + +const asyncResource = new AsyncResource('test'); + +[1, false, '', {}, []].forEach((i) => { + assert.throws(() => asyncResource.bind(i), { + code: 'ERR_INVALID_ARG_TYPE' + }); +}); + +const fn2 = asyncResource.bind((a, b) => { + return executionAsyncId(); +}); + +assert.strictEqual(fn2.length, 2); + +setImmediate(common.mustCall(() => { + // Bun: asyncId comparisons dropped (see above); still invoke the bound fn. + fn2(); +})); + +const foo = {}; +const fn3 = asyncResource.bind(common.mustCall(function() { + assert.strictEqual(this, foo); +}), foo); +fn3(); + +const fn4 = asyncResource.bind(common.mustCall(function() { + assert.strictEqual(this, undefined); +})); +fn4(); + +const fn5 = asyncResource.bind(common.mustCall(function() { + assert.strictEqual(this, false); +}), false); +fn5(); + +const fn6 = asyncResource.bind(common.mustCall(function() { + assert.strictEqual(this, 'test'); +})); +fn6.call('test'); diff --git a/test/js/node/test/parallel/test-eventemitter-asyncresource.js b/test/js/node/test/parallel/test-eventemitter-asyncresource.js new file mode 100644 index 000000000000..dcbff4cee554 --- /dev/null +++ b/test/js/node/test/parallel/test-eventemitter-asyncresource.js @@ -0,0 +1,53 @@ +'use strict'; + +const common = require('../common'); +const { EventEmitterAsyncResource } = require('events'); + +const assert = require('assert'); + +// Bun: the upstream test verifies init/before/after/destroy async_hooks events +// fired by EventEmitterAsyncResource. Bun does not implement createHook event +// tracking, so those tracer assertions are dropped; this adaptation keeps the +// public API surface assertions. + +// Tracks emit() calls correctly +(async () => { + class Foo extends EventEmitterAsyncResource {} + + const foo = new Foo(); + + foo.on('someEvent', common.mustCall()); + foo.emit('someEvent'); + + assert.strictEqual(typeof foo.asyncId, 'number'); + assert.strictEqual(typeof foo.triggerAsyncId, 'number'); + assert.strictEqual(foo.asyncResource.eventEmitter, foo); + + foo.emitDestroy(); +})().then(common.mustCall()); + +// Can explicitly specify name as positional arg +(async () => { + class Foo extends EventEmitterAsyncResource {} + + const foo = new Foo('ResourceName'); + assert.strictEqual(foo.asyncResource.eventEmitter, foo); +})().then(common.mustCall()); + +// Can explicitly specify name as option +(async () => { + class Foo extends EventEmitterAsyncResource {} + + const foo = new Foo({ name: 'ResourceName' }); + assert.strictEqual(foo.asyncResource.eventEmitter, foo); +})().then(common.mustCall()); + +assert.throws( + () => EventEmitterAsyncResource.prototype.emit(), + { name: 'TypeError' } +); + +assert.throws( + () => EventEmitterAsyncResource.prototype.emitDestroy(), + { name: 'TypeError' } +); diff --git a/test/js/node/test/parallel/test-perf-hooks-timerify-histogram-async.mjs b/test/js/node/test/parallel/test-perf-hooks-timerify-histogram-async.mjs new file mode 100644 index 000000000000..2fc6b6a2e496 --- /dev/null +++ b/test/js/node/test/parallel/test-perf-hooks-timerify-histogram-async.mjs @@ -0,0 +1,17 @@ +// Test that timerify works with histogram option for asynchronous functions. + +import '../common/index.mjs'; +import assert from 'assert'; +import { createHistogram, timerify } from 'perf_hooks'; +import { setTimeout as sleep } from 'timers/promises'; + +const histogram = createHistogram(); +const m = async (a, b = 1) => { + await sleep(10); +}; +const n = timerify(m, { histogram }); +assert.strictEqual(histogram.max, 0); +for (let i = 0; i < 10; i++) { + await n(); +} +assert.notStrictEqual(histogram.max, 0); diff --git a/test/js/node/test/parallel/test-performance-function-async.js b/test/js/node/test/parallel/test-performance-function-async.js new file mode 100644 index 000000000000..c29a085f89b3 --- /dev/null +++ b/test/js/node/test/parallel/test-performance-function-async.js @@ -0,0 +1,39 @@ +'use strict'; + +const common = require('../common'); + +const { + PerformanceObserver, + performance: { + timerify, + }, +} = require('perf_hooks'); + +const assert = require('assert'); + +const { + setTimeout: sleep +} = require('timers/promises'); + +let check = false; + +async function doIt() { + await sleep(100); + check = true; + return check; +} + +const obs = new PerformanceObserver(common.mustCall((list) => { + const entry = list.getEntries()[0]; + assert.strictEqual(entry.name, 'doIt'); + assert(check); + obs.disconnect(); +})); + +obs.observe({ type: 'function' }); + +const timerified = timerify(doIt); + +const res = timerified(); +assert(res instanceof Promise); +res.then(assert).then(common.mustCall()); diff --git a/test/js/node/test/parallel/test-quic-callback-error-ondatagram-async.mjs b/test/js/node/test/parallel/test-quic-callback-error-ondatagram-async.mjs new file mode 100644 index 000000000000..23eade07161c --- /dev/null +++ b/test/js/node/test/parallel/test-quic-callback-error-ondatagram-async.mjs @@ -0,0 +1,42 @@ +// Flags: --experimental-quic --no-warnings + +// Test: async rejection in ondatagram destroys session. +// safeCallbackInvoke detects the returned promise and attaches a +// rejection handler that calls session.destroy(err). The error is +// delivered to the onerror callback. + +import { hasQuic, skip, mustCall } from '../common/index.mjs'; +import assert from 'node:assert'; + +if (!hasQuic) { + skip('QUIC is not enabled'); +} + +const { listen, connect } = await import('../common/quic.mjs'); + +const testError = new Error('async ondatagram rejection'); +const serverDone = Promise.withResolvers(); + +const serverEndpoint = await listen(mustCall(async (serverSession) => { + await assert.rejects(serverSession.closed, testError); + serverDone.resolve(); +}), { + transportParams: { maxDatagramFrameSize: 1200 }, + ondatagram: mustCall(async () => { + throw testError; + }), + onerror: mustCall((err) => { + assert.strictEqual(err, testError); + }), +}); + +const clientSession = await connect(serverEndpoint.address, { + transportParams: { maxIdleTimeout: 1, maxDatagramFrameSize: 1200 }, +}); +await clientSession.opened; + +await clientSession.sendDatagram(new Uint8Array([1, 2, 3])); + +await serverDone.promise; +await clientSession.closed; +await serverEndpoint.close(); diff --git a/test/js/node/test/parallel/test-quic-callback-error-onstream-async.mjs b/test/js/node/test/parallel/test-quic-callback-error-onstream-async.mjs new file mode 100644 index 000000000000..1505643e69a7 --- /dev/null +++ b/test/js/node/test/parallel/test-quic-callback-error-onstream-async.mjs @@ -0,0 +1,46 @@ +// Flags: --experimental-quic --experimental-stream-iter --no-warnings + +// Test: async rejection in onstream destroys session. +// safeCallbackInvoke detects the returned promise and attaches a +// rejection handler that calls session.destroy(err). The error is +// delivered to the onerror callback. + +import { hasQuic, skip, mustCall } from '../common/index.mjs'; +import assert from 'node:assert'; + +const { strictEqual, rejects } = assert; + +if (!hasQuic) { + skip('QUIC is not enabled'); +} + +const { listen, connect } = await import('../common/quic.mjs'); + +const testError = new Error('async onstream rejection'); + +const serverEndpoint = await listen(mustCall(async (serverSession) => { + serverSession.onerror = mustCall((err) => { + strictEqual(err, testError); + }); + + serverSession.onstream = async () => { + throw testError; + }; + + // Session closed rejects with the error from the async rejection. + await rejects(serverSession.closed, testError); +}), { transportParams: { maxIdleTimeout: 1 } }); + +const clientSession = await connect(serverEndpoint.address, { + transportParams: { maxIdleTimeout: 1 }, +}); +await clientSession.opened; + +const stream = await clientSession.createBidirectionalStream({ + body: new TextEncoder().encode('trigger onstream'), +}); + +// The client session closes via CONNECTION_CLOSE or idle timeout +// after the server session is destroyed by the async rejection. +await Promise.all([stream.closed, clientSession.closed]); +await serverEndpoint.close(); diff --git a/test/js/node/test/parallel/test-quic-callback-error-suppressed-async.mjs b/test/js/node/test/parallel/test-quic-callback-error-suppressed-async.mjs new file mode 100644 index 000000000000..f1578908e7d6 --- /dev/null +++ b/test/js/node/test/parallel/test-quic-callback-error-suppressed-async.mjs @@ -0,0 +1,53 @@ +// Flags: --experimental-quic --no-warnings + +// Test: SuppressedError when async onerror rejects. +// When session.onerror returns a Promise that rejects, a SuppressedError +// wrapping both the rejection reason and the original error is thrown +// via process.nextTick as an uncaught exception. + +import { hasQuic, skip, mustCall } from '../common/index.mjs'; +import assert from 'node:assert'; + +const { ok, rejects, strictEqual } = assert; + +if (!hasQuic) { + skip('QUIC is not enabled'); +} + +const { listen, connect } = await import('../common/quic.mjs'); + +const originalError = new Error('original destroy error'); +const onerrorRejection = new Error('async onerror rejected'); + +const transportParams = { maxIdleTimeout: 1 }; + +// The SuppressedError is thrown via process.nextTick after the +// onerror promise rejects, so it appears as an uncaught exception. +process.on('uncaughtException', mustCall((err) => { + ok(err instanceof SuppressedError); + // .error is the onerror rejection reason + strictEqual(err.error, onerrorRejection); + // .suppressed is the original error that triggered destroy + strictEqual(err.suppressed, originalError); +})); + +const serverEndpoint = await listen(mustCall(async (serverSession) => { + await serverSession.closed; +}), { transportParams }); + +const clientSession = await connect(serverEndpoint.address, { + transportParams, +}); +await clientSession.opened; + +// Async onerror: returns a promise that rejects. +clientSession.onerror = mustCall(async () => { + throw onerrorRejection; +}); + +clientSession.destroy(originalError); + +// Closed rejects with the original error (not the SuppressedError). +await rejects(clientSession.closed, originalError); + +await serverEndpoint.close(); diff --git a/test/js/node/test/parallel/test-quic-endpoint-async-dispose.mjs b/test/js/node/test/parallel/test-quic-endpoint-async-dispose.mjs new file mode 100644 index 000000000000..e97915aca5e2 --- /dev/null +++ b/test/js/node/test/parallel/test-quic-endpoint-async-dispose.mjs @@ -0,0 +1,39 @@ +// Flags: --experimental-quic --no-warnings + +// Test: Symbol.asyncDispose for endpoint and session. +// endpoint[Symbol.asyncDispose] closes the endpoint. +// session[Symbol.asyncDispose] closes the session. + +import { hasQuic, skip, mustCall } from '../common/index.mjs'; +import assert from 'node:assert'; + +const { strictEqual } = assert; + +if (!hasQuic) { + skip('QUIC is not enabled'); +} + +const { listen, connect } = await import('../common/quic.mjs'); + +const serverDone = Promise.withResolvers(); + +const serverEndpoint = await listen(mustCall(async (serverSession) => { + // Wait for the session to close (triggered by the client's close). + await serverSession.closed; + serverDone.resolve(); +})); + +const clientSession = await connect(serverEndpoint.address); +await clientSession.opened; + +// session[Symbol.asyncDispose] closes the session. +strictEqual(typeof clientSession[Symbol.asyncDispose], 'function'); +await clientSession[Symbol.asyncDispose](); +strictEqual(clientSession.destroyed, true); + +await serverDone.promise; + +// endpoint[Symbol.asyncDispose] closes the endpoint. +strictEqual(typeof serverEndpoint[Symbol.asyncDispose], 'function'); +await serverEndpoint[Symbol.asyncDispose](); +strictEqual(serverEndpoint.destroyed, true); diff --git a/test/js/node/test/parallel/test-quic-stream-body-async-error.mjs b/test/js/node/test/parallel/test-quic-stream-body-async-error.mjs new file mode 100644 index 000000000000..b84df950a349 --- /dev/null +++ b/test/js/node/test/parallel/test-quic-stream-body-async-error.mjs @@ -0,0 +1,46 @@ +// Flags: --experimental-quic --no-warnings + +// Test: async iterable source error destroys the stream. +// When the async iterable body source throws, the stream should be +// destroyed with the error and stream.closed should reject. + +import { hasQuic, skip, mustCall } from '../common/index.mjs'; +import assert from 'node:assert'; + +const { rejects } = assert; + +if (!hasQuic) { + skip('QUIC is not enabled'); +} + +const { listen, connect } = await import('../common/quic.mjs'); + +const encoder = new TextEncoder(); + +const serverEndpoint = await listen(mustCall(async (serverSession) => { + await serverSession.closed; +}), { transportParams: { maxIdleTimeout: 1 } }); + +const clientSession = await connect(serverEndpoint.address, { + transportParams: { maxIdleTimeout: 1 }, +}); +await clientSession.opened; + +const testError = new Error('async source error'); + +async function* failingSource() { + yield encoder.encode('partial '); + throw testError; +} + +const stream = await clientSession.createBidirectionalStream(); + +// Attach the closed handler BEFORE setBody so the rejection from +// stream.destroy(err) is caught before it becomes unhandled. +const closedPromise = rejects(stream.closed, testError); + +stream.setBody(failingSource()); + +// The stream should be destroyed with the source error. +await Promise.all([closedPromise, clientSession.closed]); +await serverEndpoint.close(); diff --git a/test/js/node/test/parallel/test-quic-stream-body-async-iterable.mjs b/test/js/node/test/parallel/test-quic-stream-body-async-iterable.mjs new file mode 100644 index 000000000000..b73cfd07b674 --- /dev/null +++ b/test/js/node/test/parallel/test-quic-stream-body-async-iterable.mjs @@ -0,0 +1,51 @@ +// Flags: --experimental-quic --experimental-stream-iter --no-warnings + +// Test: body from async iterable source. +// An async generator is used as the body source. The data is consumed +// via the streaming path in configureOutbound. + +import { hasQuic, skip, mustCall } from '../common/index.mjs'; +import * as assert from 'node:assert'; + +const { deepStrictEqual } = assert; + +if (!hasQuic) { + skip('QUIC is not enabled'); +} + +const { listen, connect } = await import('../common/quic.mjs'); +const { bytes } = await import('stream/iter'); + +const encoder = new TextEncoder(); +const chunks = ['hello ', 'from ', 'async ', 'iterable']; +const expected = encoder.encode(chunks.join('')); + +const serverDone = Promise.withResolvers(); + +const serverEndpoint = await listen(mustCall((serverSession) => { + serverSession.onstream = mustCall(async (stream) => { + const received = await bytes(stream); + deepStrictEqual(received, expected); + stream.writer.endSync(); + await stream.closed; + serverSession.close(); + serverDone.resolve(); + }); +})); + +const clientSession = await connect(serverEndpoint.address); +await clientSession.opened; + +async function* generateChunks() { + for (const chunk of chunks) { + yield encoder.encode(chunk); + } +} + +const stream = await clientSession.createBidirectionalStream(); +stream.setBody(generateChunks()); + +for await (const _ of stream) { /* drain */ } // eslint-disable-line no-unused-vars +await Promise.all([stream.closed, serverDone.promise]); +await clientSession.close(); +await serverEndpoint.close(); diff --git a/test/js/node/test/parallel/test-quic-writer-async-dispose-ended.mjs b/test/js/node/test/parallel/test-quic-writer-async-dispose-ended.mjs new file mode 100644 index 000000000000..04337343ea87 --- /dev/null +++ b/test/js/node/test/parallel/test-quic-writer-async-dispose-ended.mjs @@ -0,0 +1,46 @@ +// Flags: --experimental-quic --experimental-stream-iter --no-warnings + +// Test: Symbol.asyncDispose only fails if writable side not ended. +// If the writer was already ended (via endSync/end), asyncDispose +// should not fail — it's a no-op. + +import { hasQuic, skip, mustCall } from '../common/index.mjs'; + +if (!hasQuic) { + skip('QUIC is not enabled'); +} + +const { listen, connect } = await import('../common/quic.mjs'); + +const encoder = new TextEncoder(); + +const serverDone = Promise.withResolvers(); + +const serverEndpoint = await listen(mustCall((serverSession) => { + serverSession.onstream = mustCall(async (stream) => { + stream.writer.endSync(); + await stream.closed; + serverSession.close(); + serverDone.resolve(); + }); +})); + +const clientSession = await connect(serverEndpoint.address); +await clientSession.opened; + +const stream = await clientSession.createBidirectionalStream(); +const w = stream.writer; + +// End the writer normally. +w.writeSync(encoder.encode('data')); +w.endSync(); + +// After end, asyncDispose should be a no-op (writer already ended). +await w[Symbol.asyncDispose](); + +// The stream should close cleanly. +for await (const _ of stream) { /* drain */ } // eslint-disable-line no-unused-vars + +await Promise.all([stream.closed, serverDone.promise]); +await clientSession.close(); +await serverEndpoint.close(); diff --git a/test/js/node/test/parallel/test-webcrypto-methods-not-async.js b/test/js/node/test/parallel/test-webcrypto-methods-not-async.js new file mode 100644 index 000000000000..439d2f68d798 --- /dev/null +++ b/test/js/node/test/parallel/test-webcrypto-methods-not-async.js @@ -0,0 +1,48 @@ +'use strict'; + +const common = require('../common'); + +if (!common.hasCrypto) + common.skip('missing crypto'); + +const assert = require('assert'); +const { subtle } = globalThis.crypto; + +const AsyncFunction = async function() {}.constructor; + +const methods = [ + 'decrypt', + 'decapsulateBits', + 'decapsulateKey', + 'deriveBits', + 'deriveKey', + 'digest', + 'encapsulateBits', + 'encapsulateKey', + 'encrypt', + 'exportKey', + 'generateKey', + 'getPublicKey', + 'importKey', + 'sign', + 'unwrapKey', + 'verify', + 'wrapKey', +]; + +(async function() { + // Bun: getPublicKey and the ML-KEM encapsulate/decapsulate methods are not + // implemented yet; verify the non-async invariant for the methods that exist. + const implemented = methods.filter((name) => typeof subtle[name] === 'function'); + assert.ok(implemented.length >= 12); + + for (const name of implemented) { + assert.notStrictEqual(subtle[name].constructor, AsyncFunction); + + const promise = subtle[name].call({}); + assert.strictEqual(Object.getPrototypeOf(promise), Promise.prototype); + await assert.rejects(promise, { + code: 'ERR_INVALID_THIS', + }); + } +})().then(common.mustCall());