diff --git a/.claude/skills/verify/SKILL.md b/.claude/skills/verify/SKILL.md new file mode 100644 index 000000000000..7ae2a948bae1 --- /dev/null +++ b/.claude/skills/verify/SKILL.md @@ -0,0 +1,30 @@ +--- +name: verify +description: Verify a Bun runtime change by driving the debug binary end-to-end. +--- + +# Verify a Bun runtime change + +Build and drive the debug binary directly — never `bun test`, never import-and-call. + +## Build + +```sh +bun bd --version # builds ./build/debug/bun-debug and prints its version +``` + +## Drive + +For any JS-visible change, run the debug binary with `-e` and observe stdout: + +```sh +bun bd -e '' # builds, then runs; sets BUN_DEBUG_QUIET_LOGS for you +``` + +For worker/subprocess-shaped changes, spawn a subprocess (still `-e`) so worker teardown / event-loop-idle paths are exercised. Cross-check against `node -e ''` for Node-compat changes. + +## Gotchas + +- `BUN_DEBUG_QUIET_LOGS=1` suppresses debug-build log spam. +- MessagePort's `.on/.off` are added by requiring `worker_threads` — plain `new MessageChannel()` ports only have `addEventListener` until then. +- The debug+asan build is 10-100× slower than release; large-allocation stress tests can time out locally while passing in CI. diff --git a/scripts/build/rust-lto-fix-cli.ts b/scripts/build/rust-lto-fix-cli.ts index e41da251eb65..9e9fcabf7a01 100644 --- a/scripts/build/rust-lto-fix-cli.ts +++ b/scripts/build/rust-lto-fix-cli.ts @@ -80,7 +80,7 @@ function isBitcode(path: string): boolean { * list), so install it on demand. */ function ensureLlvmTools(llvmBin: string): void { - const needed = ["llvm-link", "opt", "llvm-as"]; + const needed = ["llvm-link", "opt", "llvm-as", "llvm-dis"]; const missing = () => needed.filter(t => !existsSync(join(llvmBin, t))); if (missing().length === 0) return; @@ -134,9 +134,21 @@ function main(): void { // The `ThinLTO=0` module flag is the bitcode writer's "this is a regular // LTO module" marker — without it `--module-summary` writes a ThinLTO // summary block and lld would send the module to a ThinLTO backend. + // Carry the module's target data layout on the stub too: without it the + // stub's empty layout mismatches the real module and llvm-link prints a + // "Linking two modules of different data layouts" warning on every link. + // llvm-dis streams the .ll header first, so a bounded read suffices. + const dis = spawnSync(join(llvmBin, "llvm-dis"), ["-o", "-", bitcode[0]], { + encoding: "utf8", + maxBuffer: 256 * 1024, + }); + const dataLayout = /^target datalayout = "[^"]*"/m.exec(dis.stdout || "")?.[0]; const stubLl = join(tmp, "regular-lto-flag-stub.ll"); const stubBc = join(tmp, "regular-lto-flag-stub.bc"); - writeFileSync(stubLl, '!llvm.module.flags = !{!0}\n!0 = !{i32 1, !"ThinLTO", i32 0}\n'); + writeFileSync( + stubLl, + `${dataLayout ? `${dataLayout}\n` : ""}!llvm.module.flags = !{!0}\n!0 = !{i32 1, !"ThinLTO", i32 0}\n`, + ); run(join(llvmBin, "llvm-as"), [stubLl, "-o", stubBc]); const merged = join(tmp, "merged.bc"); diff --git a/src/js/builtins/BunBuiltinNames.h b/src/js/builtins/BunBuiltinNames.h index a56e6b55d2ad..a62a407ef41b 100644 --- a/src/js/builtins/BunBuiltinNames.h +++ b/src/js/builtins/BunBuiltinNames.h @@ -115,6 +115,8 @@ using namespace JSC; macro(internalRequire) \ macro(isAbortSignal) \ macro(isAbsolute) \ + macro(isUncloneable) \ + macro(isUntransferable) \ macro(join) \ macro(json) \ macro(key) \ diff --git a/src/js/internal/worker/messaging.ts b/src/js/internal/worker/messaging.ts new file mode 100644 index 000000000000..8281a0c986e4 --- /dev/null +++ b/src/js/internal/worker/messaging.ts @@ -0,0 +1,297 @@ +// worker_threads.postMessageToThread (Node 22+), ported from node's +// lib/internal/worker/messaging.js. The main thread is the hub: every thread keeps a +// control MessagePort to it, and it routes each message to the destination's port. +// Delivery results are reported back through a SharedArrayBuffer + Atomics. +// +// Differences from node: thread info comes from initThreadInfo (Bun assigns threadId +// differently); createMainThreadPort is split into createMessagingChannel (before +// `new Worker`) + registerMainThreadPort (after the threadId exists); and the +// `workerMessage` listeners are invoked directly because Bun's process.emit cannot +// report no-listeners or a throwing listener (see receiveMessageFromWorker). + +const { validateNumber } = require("internal/validators"); +const { SafeMap } = require("internal/primordials"); + +const messageTypes = { + REGISTER_MAIN_THREAD_PORT: "registerMainThreadPort", + UNREGISTER_MAIN_THREAD_PORT: "unregisterMainThreadPort", + SEND_MESSAGE_TO_WORKER: "sendMessageToWorker", + RECEIVE_MESSAGE_FROM_WORKER: "receiveMessageFromWorker", +}; + +// Set once via initThreadInfo() when worker_threads.ts loads. +let currentThreadId = 0; +let isMainThread = true; + +// Only populated on the main thread (the hub); always empty elsewhere. +// SafeMap: its prototype is a frozen null-proto snapshot taken at bootstrap, so the +// cross-thread routing table can't be broken by user code replacing Map.prototype. +const threadsPorts = new SafeMap(); + +// Only populated on child threads; always undefined on the main thread. +let mainThreadPort: any; + +// SharedArrayBuffer must always be Int32, so it's * 4. +// One slot for the operation status (performing / performed) and one for the result. +const WORKER_MESSAGING_SHARED_DATA = 2 * 4; +const WORKER_MESSAGING_STATUS_INDEX = 0; +const WORKER_MESSAGING_RESULT_INDEX = 1; + +// Response codes +const WORKER_MESSAGING_RESULT_DELIVERED = 0; +const WORKER_MESSAGING_RESULT_NO_LISTENERS = 1; +const WORKER_MESSAGING_RESULT_LISTENER_ERROR = 2; + +function initThreadInfo(threadId: number, mainThread: boolean) { + currentThreadId = threadId; + isMainThread = mainThread; +} + +// This event handler is always executed on the main thread only. +function handleMessageFromThread(message) { + switch (message.type) { + case messageTypes.REGISTER_MAIN_THREAD_PORT: { + const { threadId, port } = message; + + // Register the port. + threadsPorts.set(threadId, port); + + // Handle messages on this port. When another thread wants to register a + // child, this takes care of relaying it, so any thread links to the main one. + port.on("message", handleMessageFromThread); + + // Self-clean when the peer dies without an UNREGISTER (e.g. a grandchild + // whose intermediate parent was terminated, so that parent's Worker#onClose + // never ran); otherwise the stale entry lingers in the hub forever. + port.on("close", () => { + if (threadsPorts.get(threadId) === port) threadsPorts.delete(threadId); + }); + + // Never block the thread on this port. + port.unref(); + break; + } + case messageTypes.UNREGISTER_MAIN_THREAD_PORT: { + const port = threadsPorts.get(message.threadId); + if (port) { + port.close(); + threadsPorts.delete(message.threadId); + } + break; + } + case messageTypes.SEND_MESSAGE_TO_WORKER: { + const { source, destination, value, transferList, memory } = message; + sendMessageToWorker(source, destination, value, transferList, memory); + break; + } + } +} + +function handleMessageFromMainThread(message) { + switch (message.type) { + case messageTypes.RECEIVE_MESSAGE_FROM_WORKER: + receiveMessageFromWorker(message.source, message.value, message.memory); + break; + } +} + +function sendMessageToWorker(source, destination, value, transferList, memory) { + // We are on the main thread, we can directly process the message. + if (destination === 0) { + receiveMessageFromWorker(source, value, memory); + return; + } + + // Find the port to the target thread. + const port = threadsPorts.get(destination); + + if (!port) { + const status = new Int32Array(memory); + Atomics.store(status, WORKER_MESSAGING_RESULT_INDEX, WORKER_MESSAGING_RESULT_NO_LISTENERS); + Atomics.store(status, WORKER_MESSAGING_STATUS_INDEX, 1); + Atomics.notify(status, WORKER_MESSAGING_STATUS_INDEX, 1); + return; + } + + port.postMessage( + { + type: messageTypes.RECEIVE_MESSAGE_FROM_WORKER, + source, + // destination omitted: the receiver routes by port and never reads it. + value, + memory, + }, + transferList, + ); +} + +function receiveMessageFromWorker(source, value, memory) { + let response = WORKER_MESSAGING_RESULT_NO_LISTENERS; + + // Don't use process.emit("workerMessage", ...): Bun's native emit routes a + // throwing listener to reportUnhandledError instead of rethrowing, so + // LISTENER_ERROR can't be detected. Invoke listeners directly. + // + // Known limitation: process.once('workerMessage', fn) listeners are not + // removed here — the native process EventEmitter tracks isOnce internally + // (fireEventListeners handles removal) with no JS-side onceWrapper to detect. + // Fixing this needs the native emit to rethrow (a broader change). + const listeners = process.listeners("workerMessage"); + const listenerCount = listeners.length; + if (listenerCount > 0) { + try { + for (let i = 0; i < listenerCount; i++) { + listeners[i].$call(process, value, source); + } + response = WORKER_MESSAGING_RESULT_DELIVERED; + } catch { + response = WORKER_MESSAGING_RESULT_LISTENER_ERROR; + } + } + + // Populate the result. + const status = new Int32Array(memory); + Atomics.store(status, WORKER_MESSAGING_RESULT_INDEX, response); + Atomics.store(status, WORKER_MESSAGING_STATUS_INDEX, 1); + Atomics.notify(status, WORKER_MESSAGING_STATUS_INDEX, 1); +} + +// Bun half of Node's createMainThreadPort: create the channel linking a (future) +// thread to the main thread. Called before `new Worker`. +function createMessagingChannel() { + const { port1, port2 } = new globalThis.MessageChannel(); + // port1 (portToMain) stays with the hub; port2 (portToWorker) is transferred to + // the new thread where it becomes that thread's mainThreadPort. + return { portToMain: port1, portToWorker: port2 }; +} + +// Bun half of Node's createMainThreadPort: register the hub-side port now that the +// child's threadId is known. Called after `new Worker`. +function registerMainThreadPort(threadId: number, portToMain: any) { + const registrationMessage = { + type: messageTypes.REGISTER_MAIN_THREAD_PORT, + threadId, + port: portToMain, + }; + + if (isMainThread) { + handleMessageFromThread(registrationMessage); + } else if (mainThreadPort) { + mainThreadPort.postMessage(registrationMessage, [portToMain]); + } + // Not connected to the main-thread hub (e.g. a raw Web Worker): the child still works, + // it's just unreachable via postMessageToThread. +} + +function destroyMainThreadPort(threadId: number) { + const unregistrationMessage = { + type: messageTypes.UNREGISTER_MAIN_THREAD_PORT, + threadId, + }; + + if (isMainThread) { + handleMessageFromThread(unregistrationMessage); + } else if (mainThreadPort) { + mainThreadPort.postMessage(unregistrationMessage); + } +} + +// Deliveries from the main-thread hub are deferred until the entry module has +// finished evaluating (the native side invokes the entryEvaluated hook right +// before dispatching 'online'), matching node's bootstrap -> synchronous CJS +// main ordering: a routed message must not observe "no listeners" while the +// entry that registers them is still loading. +let entryEvaluated = false; +let pendingMainPortMessages: any[] | null = null; + +function handleMessageFromMainThreadGated(message) { + if (!entryEvaluated) { + (pendingMainPortMessages ??= []).push(message); + return; + } + handleMessageFromMainThread(message); +} + +function setupMainThreadPort(port: any, setEntryEvaluatedHook: (hook: () => void) => void) { + mainThreadPort = port; + mainThreadPort.on("message", handleMessageFromMainThreadGated); + + // Stored on ZigGlobalObject (WriteBarrier), not on globalThis, so user code + // can't observe or clobber it. WebWorker__dispatchOnline calls it once. + setEntryEvaluatedHook(() => { + entryEvaluated = true; + const pending = pendingMainPortMessages; + pendingMainPortMessages = null; + // Indexed, not for-of: Array.prototype[Symbol.iterator] is user-overridable. + if (pending) for (let i = 0; i < pending.length; i++) handleMessageFromMainThread(pending[i]); + }); + + // Never block the process on this port. + mainThreadPort.unref(); +} + +async function postMessageToThread(threadId, value, transferList, timeout) { + if (typeof transferList === "number" && typeof timeout === "undefined") { + timeout = transferList; + transferList = []; + } + + if (typeof transferList === "undefined") { + transferList = []; + } + + if (typeof timeout !== "undefined") { + validateNumber(timeout, "timeout", 0); + } + + if (threadId === currentThreadId) { + throw $ERR_WORKER_MESSAGING_SAME_THREAD("Cannot send a message to the same thread."); + } + + const memory = new SharedArrayBuffer(WORKER_MESSAGING_SHARED_DATA); + const status = new Int32Array(memory); + const promise = Atomics.waitAsync(status, WORKER_MESSAGING_STATUS_INDEX, 0, timeout).value; + + const message = { + type: messageTypes.SEND_MESSAGE_TO_WORKER, + source: currentThreadId, + destination: threadId, + value, + memory, + transferList, + }; + + if (isMainThread) { + handleMessageFromThread(message); + } else if (mainThreadPort) { + mainThreadPort.postMessage(message, transferList); + } else { + // This thread is not connected to the main-thread hub (e.g. created via the raw Web + // Worker API), so there is no route to the destination. + Atomics.store(status, WORKER_MESSAGING_RESULT_INDEX, WORKER_MESSAGING_RESULT_NO_LISTENERS); + Atomics.store(status, WORKER_MESSAGING_STATUS_INDEX, 1); + Atomics.notify(status, WORKER_MESSAGING_STATUS_INDEX, 1); + } + + // Wait for the response. + const response = await promise; + + if (response === "timed-out") { + throw $ERR_WORKER_MESSAGING_TIMEOUT("The operation timed out."); + } else if (status[WORKER_MESSAGING_RESULT_INDEX] === WORKER_MESSAGING_RESULT_NO_LISTENERS) { + throw $ERR_WORKER_MESSAGING_FAILED( + "The destination thread no longer exists or is not listening for `workerMessage` events.", + ); + } else if (status[WORKER_MESSAGING_RESULT_INDEX] === WORKER_MESSAGING_RESULT_LISTENER_ERROR) { + throw $ERR_WORKER_MESSAGING_ERRORED("The destination thread threw an error while processing the message."); + } +} + +export default { + initThreadInfo, + createMessagingChannel, + registerMainThreadPort, + destroyMainThreadPort, + setupMainThreadPort, + postMessageToThread, +}; diff --git a/src/js/node/inspector.ts b/src/js/node/inspector.ts index d850c450ca26..80b7588536df 100644 --- a/src/js/node/inspector.ts +++ b/src/js/node/inspector.ts @@ -142,6 +142,29 @@ class Session extends EventEmitter { case "Profiler.takePreciseCoverage": return new Error("Coverage APIs are not supported"); + case "NodeWorker.enable": { + // Minimal NodeWorker domain stub for test-worker-name only: a session + // connected from inside a worker reports itself. Main-thread child + // enumeration is NOT implemented — return an error there instead of + // silent success so callers know. + const wt = require("node:worker_threads"); + if (wt.isMainThread) { + return new Error("Inspector method NodeWorker.enable is not supported on the main thread yet"); + } + const title = `[worker ${wt.threadId}] ${wt.threadName}`; + const workerInfo = { workerId: String(wt.threadId), type: "worker", title }; + queueMicrotask(() => { + this.emit("NodeWorker.attachedToWorker", { + params: { sessionId: `worker:${wt.threadId}`, workerInfo }, + }); + }); + return {}; + } + + case "NodeWorker.disable": + case "NodeWorker.detach": + return {}; + case "NodeTracing.start": { if (!Bun.isMainThread) { return { diff --git a/src/js/node/worker_threads.ts b/src/js/node/worker_threads.ts index 0789d2a49c11..7262da0f30f7 100644 --- a/src/js/node/worker_threads.ts +++ b/src/js/node/worker_threads.ts @@ -4,8 +4,57 @@ declare const self: typeof globalThis; type WebWorker = InstanceType; const EventEmitter = require("node:events"); +const { SafeMap } = require("internal/primordials"); const Readable = require("internal/streams/readable"); +const Writable = require("internal/streams/writable"); const { throwNotImplemented, warnNotImplementedOnce } = require("internal/shared"); +const { + validateString, + validateObject, + validateInteger, + validateNumber, + validateBoolean, +} = require("internal/validators"); + +// node's name handling (lib/internal/worker.js): truthy → validateString + trim, +// falsy (undefined/null/0/"") → default "". So {name: 0|null} is silently ignored. +function normalizeWorkerName(rawName) { + if (rawName) { + validateString(rawName, "options.name"); + return rawName.trim(); + } + return ""; +} + +const { isAbsolute: pathIsAbsolute } = require("node:path"); + +// node's filename validation for non-eval workers: absolute or "./"/"../"-relative +// paths and file: URL objects; bare specifiers and string URLs are rejected. +function validateWorkerFilename(filename) { + if (filename instanceof URL) { + if (filename.protocol === "data:") return `${filename}`; + // throws ERR_INVALID_URL_SCHEME (TypeError) for non-file: URLs + return Bun.fileURLToPath(filename); + } + if (typeof filename !== "string") { + // Not a string or URL: defer to the native Worker constructor, which + // throws the canonical ERR_INVALID_ARG_TYPE with the exact node message. + return filename; + } + if (pathIsAbsolute(filename) || /^\.\.?[\\/]/.test(filename)) { + return filename; + } + let message = + "The worker script or module filename must be an absolute path or a relative path starting with './' or '../'."; + if (filename.startsWith("file://")) { + message += " Wrap file:// URLs with `new URL`."; + } + if (filename.startsWith("data:text/javascript")) { + message += " Wrap data: URLs with `new URL`."; + } + message += ` Received "${filename}"`; + throw $ERR_WORKER_PATH(message); +} const { MessageChannel, @@ -17,7 +66,7 @@ const { // node:worker_threads instance instead of the Web Worker instance. Worker: new (...args: [...ConstructorParameters, nodeWorker: Worker]) => WebWorker; }; -const SHARE_ENV = Symbol("nodejs.worker_threads.SHARE_ENV"); +const SHARE_ENV = Symbol.for("nodejs.worker_threads.SHARE_ENV"); const isMainThread = Bun.isMainThread; const { @@ -25,11 +74,25 @@ const { 1: _threadId, 2: _receiveMessageOnPort, 3: environmentData, + 4: _threadName, + 5: _isMessagePortActive, + 6: _markAsUntransferable, + 7: _isMarkedAsUntransferable, + 8: _markAsUncloneable, + 9: _setEntryEvaluatedHook, + 10: _isNodeWorker, } = $cpp("Worker.cpp", "createNodeWorkerThreadsBinding") as [ unknown, number, (port: unknown) => unknown, Map, + string, + (port: unknown) => boolean, + (value: unknown) => void, + (value: unknown) => boolean, + (value: unknown) => void, + (hook: () => void) => void, + boolean, ]; type NodeWorkerOptions = import("node:worker_threads").WorkerOptions; @@ -39,6 +102,20 @@ type NodeWorkerOptions = import("node:worker_threads").WorkerOptions; let urlRevokeRegistry: FinalizationRegistry | undefined = undefined; function injectFakeEmitter(Class) { + // Per-instance registry mapping each event to (user listener -> wrapper), so + // listenerCount/eventNames/removeAllListeners work over EventTarget's opaque + // internal map and off() can find the wrapper a given listener registered. + // SafeMap: its prototype is a frozen, null-proto snapshot of Map.prototype, so + // .get/.set/.size/.values()/iteration all bypass a user-replaced Map.prototype. + // (It has no @get/@set private names, so the $-intrinsics don't apply to it.) + // Keyed by a module-local symbol, not a WeakMap — WeakMap has neither defence. + const kListenerRegistry = Symbol("listenerRegistry"); + function registryFor(target, create) { + let map = target[kListenerRegistry]; + if (!map && create) target[kListenerRegistry] = map = new SafeMap(); + return map; + } + function messageEventHandler(event: MessageEvent) { return event.data; } @@ -47,14 +124,14 @@ function injectFakeEmitter(Class) { return event.error; } - const wrappedListener = Symbol("wrappedListener"); + function customEventHandler(event) { + return event.detail; + } function wrapped(run, listener) { - const callback = function (event) { + return function (event) { return listener(run(event)); }; - listener[wrappedListener] = callback; - return callback; } function functionForEventType(event, listener) { @@ -64,52 +141,144 @@ function injectFakeEmitter(Class) { return wrapped(errorEventHandler, listener); } - default: { + case "message": { return wrapped(messageEventHandler, listener); } + + default: { + return wrapped(customEventHandler, listener); + } } } - Class.prototype.on = function (event, listener) { - this.addEventListener(event, functionForEventType(event, listener)); + function EventClass(eventName) { + if (eventName === "error" || eventName === "messageerror") { + return ErrorEvent; + } + + return MessageEvent; + } + // EventTarget dedupes on (type, callback), so in node the FIRST registration of + // a listener wins outright -- including its once-ness -- and later adds of the + // same function are no-ops. Keying wrappers per listener reproduces that. + function register(target, event, listener, wrapper, options) { + const map = registryFor(target, true)!; + let byListener = map.get(event); + if (!byListener) map.set(event, (byListener = new SafeMap())); + if (byListener.has(listener)) return false; + target.addEventListener(event, wrapper, options); + byListener.set(listener, wrapper); + return true; + } + + function on(event, listener) { + register(this, event, listener, functionForEventType(event, listener), undefined); return this; - }; + } - Class.prototype.off = function (event, listener) { + function off(event, listener) { if (listener) { - this.removeEventListener(event, listener[wrappedListener] || listener); + const byListener = registryFor(this, false)?.get(event); + const wrapper = byListener?.get(listener) ?? listener; + this.removeEventListener(event, wrapper); + byListener?.delete(listener); } else { this.removeEventListener(event); } - return this; - }; - - Class.prototype.once = function (event, listener) { - this.addEventListener(event, functionForEventType(event, listener), { - once: true, - }); + } + function once(event, listener) { + const wrapper = functionForEventType(event, listener); + const target = this; + // EventTarget drops a {once:true} listener natively, without telling the + // registry — so purge it here or listenerCount()/eventNames() keep counting + // a listener that already fired. + function onceWrapper(ev) { + registryFor(target, false)?.get(event)?.delete(listener); + return wrapper(ev); + } + register(this, event, listener, onceWrapper, { once: true }); return this; - }; + } - function EventClass(eventName) { - if (eventName === "error" || eventName === "messageerror") { - return ErrorEvent; + function emit(event, ...args) { + switch (event) { + case "error": + case "messageerror": + case "message": + this.dispatchEvent(new (EventClass(event))(event, ...args)); + break; + default: + // Non-standard events surface as CustomEvent (detail = first arg) to + // addEventListener and as the raw argument to .on(), matching node. + this.dispatchEvent(new CustomEvent(event, { detail: args[0] })); + break; } - - return MessageEvent; + return this; } - Class.prototype.emit = function (event, ...args) { - this.dispatchEvent(new (EventClass(event))(event, ...args)); - + const kMaxListeners = Symbol("kMaxListeners"); + function setMaxListeners(n) { + this[kMaxListeners] = n; return this; - }; + } + function getMaxListeners() { + return this[kMaxListeners] ?? 10; + } + function listenerCount(type) { + return registryFor(this, false)?.get(type)?.size ?? 0; + } + function eventNames() { + const map = registryFor(this, false); + if (!map) return []; + const out: string[] = []; + for (const [k, v] of map) if (v.size > 0) out.push(k); + return out; + } + function removeAllListeners(type) { + const map = registryFor(this, false); + if (!map) return this; + const removeType = t => { + const byListener = map.get(t); + if (byListener) { + for (const w of byListener.values()) this.removeEventListener(t, w); + map.delete(t); + } + }; + if (arguments.length === 0) { + // removeType only deletes `t`, and a Map iterator tolerates deleting the + // entry it just yielded — so no snapshot copy is needed here. + for (const t of map.keys()) removeType(t); + } else { + removeType(type); + } + return this; + } - Class.prototype.prependListener = Class.prototype.on; - Class.prototype.prependOnceListener = Class.prototype.once; + // node inherits these from NodeEventTarget.prototype (a curated subset of + // EventEmitter, not EventEmitter itself); use an intermediate prototype so + // Object.getOwnPropertyNames(MessagePort.prototype) matches node. + const proto = Class.prototype; + const inherited = Object.create(Object.getPrototypeOf(proto)); + const emitterMethods: [string, Function][] = [ + ["on", on], + ["off", off], + ["once", once], + ["emit", emit], + ["addListener", on], + ["removeListener", off], + ["listenerCount", listenerCount], + ["eventNames", eventNames], + ["removeAllListeners", removeAllListeners], + ["setMaxListeners", setMaxListeners], + ["getMaxListeners", getMaxListeners], + ]; + for (const [methodName, value] of emitterMethods) { + Object.defineProperty(inherited, methodName, { value, writable: true, enumerable: false, configurable: true }); + } + Object.setPrototypeOf(proto, inherited); } const _MessagePort = globalThis.MessagePort; @@ -117,8 +286,196 @@ injectFakeEmitter(_MessagePort); const MessagePort = _MessagePort; +// node's close(cb) registers cb as a one-time "close" listener before the native close. +// closedMessagePorts lets moveMessagePortToContext report ERR_CLOSED_MESSAGE_PORT. +const closedMessagePorts = new WeakSet(); +const nativeMessagePortClose = MessagePort.prototype.close; +Object.defineProperty(MessagePort.prototype, "close", { + value: function close(cb) { + closedMessagePorts.add(this); + // node's mechanism is literally `this.once('close', cb)`, so cb interleaves + // with other close listeners in registration order. The close event fires at + // task-queue timing (before setImmediate) rather than node's close-callbacks + // phase (after) — a known Bun divergence that would need a native fix. + if (typeof cb === "function") this.once("close", cb); + return nativeMessagePortClose.$call(this); + }, + writable: true, + enumerable: true, + configurable: true, +}); + +// node-style util.inspect output for MessagePort (shows whether the channel is +// still active). Symbol-keyed so it does not appear in getOwnPropertyNames. +const kInspectCustom = Symbol.for("nodejs.util.inspect.custom"); +Object.defineProperty(MessagePort.prototype, kInspectCustom, { + value: function (_depth, _options) { + return `MessagePort [EventTarget] { active: ${_isMessagePortActive(this)}, refed: ${this.hasRef()} }`; + }, + writable: true, + enumerable: false, + configurable: true, +}); + let resourceLimits = {}; +const BUN_WORKER_STDIO_KEY = "@@bunWorkerThreadsStdio"; +const BUN_WORKER_MESSAGING_KEY = "@@bunWorkerThreadsMessaging"; + +// Captured stdio rides a dedicated MessageChannel per stream with node's flow +// control (lib/internal/worker/io.js): the writer posts an array of chunks +// (STDIO_PAYLOAD) and withholds the writev callback until the reader posts an +// ack (STDIO_WANTS_MORE_DATA) from _read(). One batch is in flight at a time; +// further writes buffer in the Writable, so write() returns false and 'drain' +// fires only when the consumer catches up — end-to-end backpressure. Since +// each stream has its own port (node multiplexes one env port), the payload is +// the bare chunk array, EOF is null, and any other message is the ack. + +// Readable fed by a control MessagePort (worker.stdout/stderr on the parent, +// process.stdin in the worker). The peer posts arrays of Buffers; null signals EOF. +function makePortReadable(port) { + let attached = false; + let ended = false; + function onMessage(payload) { + if (payload === null) { + if (ended === false) { + ended = true; + stream.push(null); + } + // Drop the listener so the control port stops holding the event loop + // open once the stream has ended. + port.off("message", onMessage); + } else if (ended === false) { + for (let i = 0; i < payload.length; i++) { + stream.push(Buffer.from(payload[i])); + } + } + } + // Attach the 'message' listener lazily on first read(): a listener refs the event + // loop, which would keep a { stdin: true } worker alive even if stdin is never read. + const stream = new Readable({ + read() { + if (attached === false && ended === false) { + attached = true; + port.on("message", onMessage); + } + // Tell the writer we want more data; it completes its in-flight writev + // on receipt (node's STDIO_WANTS_MORE_DATA). + if (ended === false) port.postMessage(true); + }, + }); + // Lets the parent end worker.stdout/stderr when the worker exits abruptly. + stream.endFromOwner = function () { + if (ended === false) { + ended = true; + stream.push(null); + // Drop the listener so the port stops holding the event loop open once + // the owner (worker exit) has ended the stream. + port.off("message", onMessage); + } + }; + return stream; +} + +// Writable that forwards chunks over a control MessagePort (worker.stdin on the +// parent, process.stdout/stderr in the worker). final() posts null as EOF. +function makePortWritable(port) { + // Reader-side acks complete the in-flight writev. The listener refs the + // event loop; release that immediately — the port is re-ref'd only while a + // batch is awaiting its ack, so unflushed data keeps the writer alive + // (node's kWaitingStreams) but an idle stream never pins the loop. + let pendingWriteCallback: ((error?: Error | null) => void) | null = null; + function onAck() { + const cb = pendingWriteCallback; + if (cb !== null) { + pendingWriteCallback = null; + port.unref(); + cb(); + } + } + port.on("message", onAck); + port.unref(); + return new Writable({ + decodeStrings: false, + writev(chunks, cb) { + const payload = new Array(chunks.length); + for (let i = 0; i < chunks.length; i++) { + const { chunk, encoding } = chunks[i]; + payload[i] = typeof chunk === "string" ? Buffer.from(chunk, encoding) : chunk; + } + port.postMessage(payload); + if (process._exiting) { + // No event loop turns remain to deliver an ack; complete synchronously + // so exit-time writes are not lost (node does the same). + cb(); + } else { + // Only one writev is in flight at a time, so the slot can't be occupied. + pendingWriteCallback = cb; + port.ref(); + } + }, + final(cb) { + port.postMessage(null); + cb(); + }, + destroy(err, cb) { + // Discharge an in-flight batch: the reader may never ack a destroyed + // stream, so release the loop ref taken in writev and complete the + // parked callback; drop the ack listener so a late ack can't fire + // into the destroyed stream. + const pending = pendingWriteCallback; + if (pending !== null) { + pendingWriteCallback = null; + port.unref(); + pending(err); + } + port.off("message", onAck); + cb(err); + }, + }); +} + +function setupWorkerStdio(stdio) { + const { stdin, stdout, stderr } = stdio; + if (stdout) { + Object.defineProperty(process, "stdout", { + value: makePortWritable(stdout), + writable: true, + configurable: true, + enumerable: true, + }); + } + if (stderr) { + Object.defineProperty(process, "stderr", { + value: makePortWritable(stderr), + writable: true, + configurable: true, + enumerable: true, + }); + } + // node always replaces a worker's process.stdin: port-backed when { stdin: true }, + // otherwise an immediately-EOF'd stream — never the process-wide fd 0, which + // would race the main thread (and hang on a TTY). + Object.defineProperty(process, "stdin", { + value: stdin + ? makePortReadable(stdin) + : new Readable({ + read() { + this.push(null); + }, + }), + writable: true, + configurable: true, + enumerable: true, + }); + // node routes console.log through process.stdout/stderr; Bun's global console + // writes the fd directly, so rebind it to the captured streams when present. + if (stdout || stderr) { + const { Console } = require("node:console"); + globalThis.console = new Console(process.stdout, process.stderr); + } +} + // Emulation of Node's JSTransferable protocol (kTransfer/kTransferList/kDeserialize) for // objects like FileHandle that are not natively transferable in Bun. On send, each such // object in the transferList is replaced inside workerData by a serializable marker object; @@ -355,6 +712,29 @@ function packJSTransferables(options: NodeWorkerOptions): NodeWorkerOptions { let workerData = unpackJSTransferables(_workerData); let threadId = _threadId; +// node: main-thread and unspecified-worker name are both "" (trimmed). +const threadName = isMainThread ? "" : (_threadName ?? ""); +// postMessageToThread (Node 22+): the Worker ctor always smuggles a control +// MessagePort to the worker by wrapping workerData; unwrap it here. +const messaging = require("internal/worker/messaging"); +messaging.initThreadInfo(threadId, isMainThread); +// Captured stdio + the messaging control port ride inside workerData (wrapped; +// ports transferred). Unwrap and bind the worker's stdio / messaging hub. +// Gate on _isNodeWorker so a raw `new globalThis.Worker` that loads this module +// does NOT have process.stdio rebound / workerData unwrapped by a fabricated key. +if ( + !isMainThread && + _isNodeWorker && + workerData && + typeof workerData === "object" && + (BUN_WORKER_STDIO_KEY in workerData || BUN_WORKER_MESSAGING_KEY in workerData) +) { + const stdioPorts = workerData[BUN_WORKER_STDIO_KEY]; + const controlPort = workerData[BUN_WORKER_MESSAGING_KEY]; + workerData = workerData.data; + if (stdioPorts) setupWorkerStdio(stdioPorts); + if (controlPort) messaging.setupMainThreadPort(controlPort, _setEntryEvaluatedHook); +} function receiveMessageOnPort(port: MessagePort) { let res = _receiveMessageOnPort(port); if (!res) return undefined; @@ -439,6 +819,64 @@ function fakeParentPort() { } let parentPort: MessagePort | null = isMainThread ? null : fakeParentPort(); +// In a node:worker_threads worker, several process operations are unsupported. +// Gate on _isNodeWorker so a raw `new globalThis.Worker` that transitively loads +// this module does NOT have process.abort/chdir/setuid replaced. +if (!isMainThread && _isNodeWorker) { + applyWorkerProcessOverrides(); +} +function applyWorkerProcessOverrides() { + const proc: any = process; + // node defaults debugPort to 9229 in workers (still settable). Per-object property: + // the static accessor's setter writes a process-global shared across threads. + try { + Object.defineProperty(proc, "debugPort", { value: 9229, writable: true, configurable: true, enumerable: true }); + } catch {} + // These main-only internals are absent on a worker's process. + for (const k of ["_startProfilerIdleNotifier", "_stopProfilerIdleNotifier", "_debugProcess", "_debugEnd"]) { + try { + delete proc[k]; + } catch {} + } + // process.umask(setMask) is unsupported in workers; the getter still works. + const realUmask = proc.umask; + function umask(mask?: unknown) { + if (mask === undefined) return realUmask.$call(proc); + throw $ERR_WORKER_UNSUPPORTED_OPERATION("Setting process.umask() is not supported in workers"); + } + proc.umask = umask; + // Disabled, throwing stubs (each carries `.disabled === true`, like node). + const disabled = ["abort", "chdir"]; + if (process.platform !== "win32") { + disabled.push("setuid", "seteuid", "setgid", "setegid", "setgroups", "initgroups"); + } + // node only disables send/disconnect/channel/connected in workers that inherited an + // IPC channel (NODE_CHANNEL_FD); otherwise they stay absent so `if (process.send)` works. + const hasIpc = !!process.env.NODE_CHANNEL_FD; + if (hasIpc) { + disabled.push("send", "disconnect"); + } + for (const name of disabled) { + const stub: any = function () { + throw $ERR_WORKER_UNSUPPORTED_OPERATION(`process.${name}() is not supported in workers`); + }; + stub.disabled = true; + Object.defineProperty(proc, name, { configurable: true, writable: true, enumerable: true, value: stub }); + } + // IPC accessors throw on access only in a worker that inherited an IPC channel. + if (hasIpc) { + for (const name of ["channel", "connected"]) { + Object.defineProperty(proc, name, { + configurable: true, + enumerable: false, + get() { + throw $ERR_WORKER_UNSUPPORTED_OPERATION(`process.${name} is not supported in workers`); + }, + }); + } + } +} + function getEnvironmentData(key: unknown): unknown { return environmentData.get(key); } @@ -451,48 +889,169 @@ function setEnvironmentData(key: unknown, value: unknown): void { } } -function markAsUntransferable() { - throwNotImplemented("worker_threads.markAsUntransferable"); +// The markers are DontEnum JSC private names set natively (node uses v8 Privates), so +// they are invisible to and unforgeable from user code, and marking cannot be undone. +// Primitives (including null) are a documented no-op, handled on the native side. +function markAsUntransferable(obj) { + _markAsUntransferable(obj); } -function moveMessagePortToContext() { +function isMarkedAsUntransferable(obj) { + return _isMarkedAsUntransferable(obj); +} + +function markAsUncloneable(obj) { + _markAsUncloneable(obj); +} + +function moveMessagePortToContext(port, _context) { + if (port instanceof MessagePort) { + if (closedMessagePorts.has(port)) { + throw $ERR_CLOSED_MESSAGE_PORT("Cannot send data on closed MessagePort"); + } + } else { + throw $ERR_INVALID_ARG_TYPE("port", "MessagePort", port); + } throwNotImplemented("worker_threads.moveMessagePortToContext"); } class Worker extends EventEmitter { #worker: WebWorker; #performance; + #name: string; + #exited = false; + #stdinPort; + #stdoutPort; + #stderrPort; + #stdin; + #stdout; + #stderr; + #stdoutAutoPipe = false; + #stderrAutoPipe = false; // this is used by terminate(); // either is the exit code if exited, a promise resolving to the exit code, or undefined if we haven't sent .terminate() yet #onExitPromise: Promise | number | undefined = undefined; #urlToRevoke = ""; - // Created only when `options.stdout`/`options.stderr` request capture; the - // worker's output is not yet routed into them (TODO), but the streams exist - // and end on worker exit, matching Node's API shape. - #stdout: InstanceType | null = null; - #stderr: InstanceType | null = null; + // threadId captured for cleaning up the messaging control port on close. + #messagingThreadId: number | undefined = undefined; constructor(filename: string, options: NodeWorkerOptions = {}) { super(); - options = packJSTransferables(options); + // The `= {}` default only covers undefined; normalize null too so the + // option accesses below don't throw on `new Worker(file, null)`. + options ??= {}; + + this.#name = normalizeWorkerName(options.name); const builtinsGeneratorHatesEval = "ev" + "a" + "l"[0]; - if (options && builtinsGeneratorHatesEval in options) { - if (options[builtinsGeneratorHatesEval]) { - // TODO: consider doing this step in native code and letting the Blob be cleaned up by the - // C++ Worker object's destructor - const blob = new Blob([filename], { type: "" }); - this.#urlToRevoke = filename = URL.createObjectURL(blob); - } else { - // if options.eval = false, allow the constructor below to fail, if - // we convert the code to a blob, it will succeed. - this.#urlToRevoke = filename; - } + if (options[builtinsGeneratorHatesEval]) { + // node requires the source to be a string when eval is set, rather than + // letting Blob coerce a URL/object to a confusing SyntaxError later. + if (typeof filename !== "string") + throw $ERR_INVALID_ARG_VALUE( + "options.eval", + options[builtinsGeneratorHatesEval], + "must be false when 'filename' is not a string", + ); + // eval: the source becomes a blob: URL the worker imports as its entry point. + // The URL must outlive the worker: revoked on constructor failure (catch below), + // on exit (#onClose), and via urlRevokeRegistry as a GC safety net. + const blob = new Blob([filename], { type: "" }); + this.#urlToRevoke = filename = URL.createObjectURL(blob); + } else { + // node validates the worker path when not running eval'd code (eval:false + // is equivalent to omitting eval). + filename = validateWorkerFilename(filename); } + + let portToMain; try { + // Neuter transferred FileHandles only AFTER name/filename validation so a + // validation throw above leaves them intact (matching node, which validates + // before processing the transferList). Past this point every throw goes + // through the catch below, which calls kRestoreJSTransferables — and revokes + // the eval blob URL when packJSTransferables itself throws (duplicate + // transferList entry or a busy FileHandle's kTransfer()). + options = packJSTransferables(options); + + // Captured stdio: one control MessageChannel per requested stream; the parent keeps + // one end, the other rides in workerData and the worker rebinds its stdio to it. + const stdioForWorker: any = {}; + const stdioTransfer: any[] = []; + if (options.stdin) { + const channel = new MessageChannel(); + this.#stdinPort = channel.port1; + stdioForWorker.stdin = channel.port2; + stdioTransfer.push(channel.port2); + } + // worker.stdout/stderr are always Readables fed by the worker; without capture + // they auto-pipe to the parent's stdio so output still surfaces. + { + const channel = new MessageChannel(); + this.#stdoutPort = channel.port1; + stdioForWorker.stdout = channel.port2; + stdioTransfer.push(channel.port2); + if (!options.stdout) this.#stdoutAutoPipe = true; + } + { + const channel = new MessageChannel(); + this.#stderrPort = channel.port1; + stdioForWorker.stderr = channel.port2; + stdioTransfer.push(channel.port2); + if (!options.stderr) this.#stderrAutoPipe = true; + } + // Control channel for postMessageToThread; wrap workerData so the control and + // stdio ports ride along transferred. + const channel = messaging.createMessagingChannel(); + portToMain = channel.portToMain; + const portToWorker = channel.portToWorker; + const workerDataWrapper: any = { [BUN_WORKER_MESSAGING_KEY]: portToWorker, data: options.workerData }; + // stdout/stderr always create channels (stdin only when requested), so the + // worker always receives a stdio control object. + workerDataWrapper[BUN_WORKER_STDIO_KEY] = stdioForWorker; + options = { + ...options, + // Pass the parent's already-normalized/validated name so the worker can + // use it verbatim (native cannot distinguish omitted from explicit ""). + name: this.#name, + workerData: workerDataWrapper, + transferList: options.transferList + ? [...options.transferList, portToWorker, ...stdioTransfer] + : [portToWorker, ...stdioTransfer], + }; + + // env: SHARE_ENV becomes a native boolean flag so it passes native option + // validation and the native side skips the env snapshot and shares the store. + if ((options as any).env === SHARE_ENV) { + options = { ...options, env: undefined, shareEnv: true } as NodeWorkerOptions; + } else if ((options as any).shareEnv !== undefined) { + // shareEnv is internal — only `env: SHARE_ENV` may enable it. Strip a + // user-supplied value so it can't trigger env sharing on its own. + options = { ...options, shareEnv: undefined } as NodeWorkerOptions; + } + // node runs its worker bootstrap before user code; preload the + // worker_threads module so process.stdin/stdout/stderr are always rebound, + // even when the worker never requires it. + const userPreload = (options as any).preload; + options = { + ...options, + preload: ["node:worker_threads", ...($isArray(userPreload) ? userPreload : userPreload ? [userPreload] : [])], + } as NodeWorkerOptions; this.#worker = new WebWorker(filename, options as Bun.WorkerOptions, this); + // Uncaptured stdio forwards to the parent's stdio. Keep these ports unref'd: + // the worker's own ref keeps the parent alive, and unref() must still let it exit. + if (this.#stdoutAutoPipe) { + // 'data' instead of pipe(): pipe() adds an error listener on the shared + // process.stdout per worker, tripping MaxListenersExceededWarning. + this.stdout.on("data", chunk => process.stdout.write(chunk)); + this.#stdoutPort.unref(); + } + if (this.#stderrAutoPipe) { + this.stderr.on("data", chunk => process.stderr.write(chunk)); + this.#stderrPort.unref(); + } } catch (e) { // Restore any transferList handles that were already neutered by // packJSTransferables, so their fds aren't orphaned. @@ -502,18 +1061,18 @@ class Worker extends EventEmitter { } throw e; } + // threadId is only assigned once the WebWorker exists; register the hub-side + // control port with the messaging hub now. + this.#messagingThreadId = this.#worker.threadId; + messaging.registerMainThreadPort(this.#messagingThreadId, portToMain); // The transfer is committed - release fds that were transferred but are // not referenced from workerData (nothing will deserialize them). options[kFinalizeJSTransferables]?.(); - if (options.stdout) this.#stdout = new Readable({ read() {} }); - if (options.stderr) this.#stderr = new Readable({ read() {} }); // Tracing active (CLI flag or dynamic enable): record the Node-style // `[worker N] ` thread-name metadata event. No-op when tracing is // off — the agent module is a tiny one-time load. require("internal/trace_events").emitWorkerThreadName(options.name, this.#worker.threadId); - this.#worker.addEventListener("close", this.#onClose.bind(this), { - once: true, - }); + this.#worker.addEventListener("close", this.#onClose.bind(this), { once: true }); this.#worker.addEventListener("error", this.#onError.bind(this)); this.#worker.addEventListener("message", this.#onMessage.bind(this)); this.#worker.addEventListener("messageerror", this.#onMessageError.bind(this)); @@ -535,26 +1094,54 @@ class Worker extends EventEmitter { return this.#worker.threadId; } + get threadName() { + return this.#exited ? null : this.#name; + } + ref() { this.#worker.ref(); + // Captured stdio ports follow the worker's ref state; auto-piped ports stay + // unref'd (the worker's own ref governs them). + if (!this.#stdoutAutoPipe) this.#stdoutPort?.ref(); + if (!this.#stderrAutoPipe) this.#stderrPort?.ref(); + this.#stdinPort?.ref(); } unref() { this.#worker.unref(); + if (!this.#stdoutAutoPipe) this.#stdoutPort?.unref(); + if (!this.#stderrAutoPipe) this.#stderrPort?.unref(); + this.#stdinPort?.unref(); } get stdin() { - // TODO: - return null; + if (this.#stdinPort === undefined) return null; + if (this.#stdin === undefined) { + this.#stdin = makePortWritable(this.#stdinPort); + // If the worker already exited, destroy immediately so writes fail with + // ERR_STREAM_DESTROYED instead of silently no-oping into a closed peer. + if (this.#exited) this.#stdin.destroy(); + } + return this.#stdin; } get stdout() { - // TODO: route the worker's actual stdout into this stream. + if (this.#stdoutPort === undefined) return null; + if (this.#stdout === undefined) { + this.#stdout = makePortReadable(this.#stdoutPort); + // If the worker already exited, end immediately: a late first access + // would otherwise ref the parent loop with no release (peer gone) -> hang. + if (this.#exited) this.#stdout.endFromOwner(); + } return this.#stdout; } get stderr() { - // TODO: route the worker's actual stderr into this stream. + if (this.#stderrPort === undefined) return null; + if (this.#stderr === undefined) { + this.#stderr = makePortReadable(this.#stderrPort); + if (this.#exited) this.#stderr.endFromOwner(); + } return this.#stderr; } @@ -582,8 +1169,12 @@ class Worker extends EventEmitter { } const onExitPromise = this.#onExitPromise; - if (onExitPromise) { - return $isPromise(onExitPromise) ? onExitPromise : Promise.$resolve(onExitPromise); + // Not a truthy test: after exit #onExitPromise is the exit code, which can be 0; + // falling through would wait on a 'close' event that never fires again. + if (onExitPromise !== undefined) { + // node: terminate() on an already-exited worker resolves with undefined; + // an in-progress terminate (a promise) resolves with the exit code below. + return $isPromise(onExitPromise) ? onExitPromise : Promise.$resolve(undefined); } const { resolve, promise } = Promise.withResolvers(); @@ -594,6 +1185,9 @@ class Worker extends EventEmitter { }, { once: true }, ); + // Keep the event loop alive until termination completes so the returned + // promise still resolves even if the worker was unref()'ed. + this.#worker.ref(); this.#worker.terminate(); return (this.#onExitPromise = promise); @@ -608,10 +1202,108 @@ class Worker extends EventEmitter { return stringPromise.then(s => new HeapSnapshotStream(s)); } + getHeapStatistics() { + return this.#worker.getHeapStatistics(); + } + + startCpuProfile(options?: { sampleInterval?: number; maxBufferSize?: number }) { + // node validates synchronously before starting; the underlying JSC sampler + // ignores these knobs but the range checks must still match. + if (options !== undefined && options !== null) { + validateObject(options, "options"); + const { maxBufferSize, sampleInterval } = options; + if (maxBufferSize !== undefined) validateInteger(maxBufferSize, "options.maxBufferSize", 1); + if (sampleInterval !== undefined) validateNumber(sampleInterval, "options.sampleInterval"); + } + // JSC has one thread-local sampler; cache stop() on the WORKER so overlapping + // handles resolve to the same profile instead of empty JSON. Cleared on start + // so a fresh non-overlapping run gets a fresh profile. + this.#pendingCpuProfileStop = undefined; + return this.#worker.startCpuProfileInternal().then(() => { + return { stop: () => (this.#pendingCpuProfileStop ??= this.#worker.stopCpuProfileInternal()) }; + }); + } + #pendingCpuProfileStop: Promise | undefined; + + cpuUsage(prevValue?: { user: number; system: number }) { + let prevUser = 0; + let prevSystem = 0; + if (prevValue) { + validateObject(prevValue, "prevValue"); + ({ user: prevUser, system: prevSystem } = prevValue); + validateNumber(prevUser, "prevValue.user"); + if (prevUser < 0 || !Number.isFinite(prevUser)) + throw $ERR_OUT_OF_RANGE("prevValue.user", ">= 0 and a finite number", prevUser); + validateNumber(prevSystem, "prevValue.system"); + if (prevSystem < 0 || !Number.isFinite(prevSystem)) + throw $ERR_OUT_OF_RANGE("prevValue.system", ">= 0 and a finite number", prevSystem); + } + return this.#worker + .cpuUsageInternal() + .then((abs: { user: number; system: number }) => + prevValue ? { user: abs.user - prevUser, system: abs.system - prevSystem } : abs, + ); + } + + startHeapProfile(options?: object) { + if (options !== undefined && options !== null) { + validateObject(options, "options"); + const { + sampleInterval, + stackDepth, + forceGC, + includeObjectsCollectedByMajorGC, + includeObjectsCollectedByMinorGC, + } = options as any; + if (sampleInterval !== undefined) validateInteger(sampleInterval, "options.sampleInterval", 1); + if (stackDepth !== undefined) validateInteger(stackDepth, "options.stackDepth", 0); + if (forceGC !== undefined) validateBoolean(forceGC, "options.forceGC"); + if (includeObjectsCollectedByMajorGC !== undefined) + validateBoolean(includeObjectsCollectedByMajorGC, "options.includeObjectsCollectedByMajorGC"); + if (includeObjectsCollectedByMinorGC !== undefined) + validateBoolean(includeObjectsCollectedByMinorGC, "options.includeObjectsCollectedByMinorGC"); + } + if (this.#exited) { + return Promise.$reject($ERR_WORKER_NOT_RUNNING("Worker instance not running")); + } + // Bun has no allocation-sampling heap profiler; yield a valid but empty + // v8 sampling-heap-profile so the handle/stop() shape matches node. + const empty = + '{"head":{"callFrame":{"functionName":"(root)","scriptId":"0","url":"","lineNumber":-1,"columnNumber":-1},"selfSize":0,"id":1,"children":[]},"samples":[]}'; + return Promise.$resolve({ stop: () => Promise.$resolve(empty) }); + } + #onClose(e) { + this.#exited = true; + // Revoke the eval blob: URL now that the worker has exited; the + // FinalizationRegistry remains only as a GC safety net. + if (this.#urlToRevoke) { + URL.revokeObjectURL(this.#urlToRevoke); + this.#urlToRevoke = ""; + } + if (this.#messagingThreadId !== undefined) { + messaging.destroyMainThreadPort(this.#messagingThreadId); + this.#messagingThreadId = undefined; + } + // End captured stdio readables when the worker exits, even if it was + // terminated before its own streams finished. + if (this.#stdout) { + this.#stdout.endFromOwner(); + } + if (this.#stderr) { + this.#stderr.endFromOwner(); + } + // Close the captured stdout/stderr control ports so worker.ref() can't pin the + // parent loop after exit (mirrors #stdinPort below). + this.#stdoutPort?.close(); + this.#stderrPort?.close(); + // Tear down the parent-side stdin Writable + port so post-exit writes fail + // (ERR_STREAM_DESTROYED) instead of silently no-oping into a closed peer. + if (this.#stdin) { + this.#stdin.destroy(); + } + this.#stdinPort?.close(); this.#onExitPromise = e.code; - this.#stdout?.push(null); - this.#stderr?.push(null); this.emit("exit", e.code); } @@ -627,6 +1319,16 @@ class Worker extends EventEmitter { error.stack = stack; } } + // Reshape the native 'ModuleNotFound ... (entry point)' error into node's + // "Cannot find module ''" (MODULE_NOT_FOUND). + const errorMessage = error?.message; + if (typeof errorMessage === "string" && errorMessage.includes("(entry point)")) { + const m = /ModuleNotFound resolving "(.+?)"/.exec(errorMessage); + if (m) { + error = new Error(`Cannot find module '${m[1]}'`, { cause: error }); + (error as any).code = "MODULE_NOT_FOUND"; + } + } this.emit("error", error); } @@ -672,17 +1374,20 @@ export default { parentPort, resourceLimits, isMainThread, + // No bun thread is a node-internal (loader-hook) thread. + isInternalThread: false, MessageChannel, BroadcastChannel, MessagePort, getEnvironmentData, setEnvironmentData, - getHeapSnapshot() { - return {}; - }, markAsUntransferable, + markAsUncloneable, + isMarkedAsUntransferable, moveMessagePortToContext, + postMessageToThread: messaging.postMessageToThread, receiveMessageOnPort, SHARE_ENV, threadId, + threadName, }; diff --git a/src/jsc/ErrorCode.rs b/src/jsc/ErrorCode.rs index 7e5d18b0b575..8d3a7605c630 100644 --- a/src/jsc/ErrorCode.rs +++ b/src/jsc/ErrorCode.rs @@ -595,131 +595,141 @@ impl ErrorCode { pub const WORKER_NOT_RUNNING: ErrorCode = ErrorCode(268); /// `ERR_WORKER_UNSUPPORTED_OPERATION` (instanceof TypeError) pub const WORKER_UNSUPPORTED_OPERATION: ErrorCode = ErrorCode(269); + /// `ERR_WORKER_PATH` (instanceof TypeError) + pub const WORKER_PATH: ErrorCode = ErrorCode(270); /// `ERR_ZLIB_INITIALIZATION_FAILED` (instanceof Error) - pub const ZLIB_INITIALIZATION_FAILED: ErrorCode = ErrorCode(270); + pub const ZLIB_INITIALIZATION_FAILED: ErrorCode = ErrorCode(271); /// `MODULE_NOT_FOUND` (instanceof Error) - pub const MODULE_NOT_FOUND: ErrorCode = ErrorCode(271); + pub const MODULE_NOT_FOUND: ErrorCode = ErrorCode(272); /// `ERR_INTERNAL_ASSERTION` (instanceof Error) - pub const INTERNAL_ASSERTION: ErrorCode = ErrorCode(272); + pub const INTERNAL_ASSERTION: ErrorCode = ErrorCode(273); /// `ERR_OSSL_EVP_INVALID_DIGEST` (instanceof Error) - pub const OSSL_EVP_INVALID_DIGEST: ErrorCode = ErrorCode(273); + pub const OSSL_EVP_INVALID_DIGEST: ErrorCode = ErrorCode(274); /// `ERR_KEY_GENERATION_JOB_FAILED` (instanceof Error) - pub const KEY_GENERATION_JOB_FAILED: ErrorCode = ErrorCode(274); + pub const KEY_GENERATION_JOB_FAILED: ErrorCode = ErrorCode(275); /// `ERR_MISSING_OPTION` (instanceof TypeError) - pub const MISSING_OPTION: ErrorCode = ErrorCode(275); + pub const MISSING_OPTION: ErrorCode = ErrorCode(276); /// `ERR_REDIS_AUTHENTICATION_FAILED` (instanceof Error) - pub const REDIS_AUTHENTICATION_FAILED: ErrorCode = ErrorCode(276); + pub const REDIS_AUTHENTICATION_FAILED: ErrorCode = ErrorCode(277); /// `ERR_REDIS_CONNECTION_CLOSED` (instanceof Error) - pub const REDIS_CONNECTION_CLOSED: ErrorCode = ErrorCode(277); + pub const REDIS_CONNECTION_CLOSED: ErrorCode = ErrorCode(278); /// `ERR_REDIS_CONNECTION_TIMEOUT` (instanceof Error) - pub const REDIS_CONNECTION_TIMEOUT: ErrorCode = ErrorCode(278); + pub const REDIS_CONNECTION_TIMEOUT: ErrorCode = ErrorCode(279); /// `ERR_REDIS_IDLE_TIMEOUT` (instanceof Error) - pub const REDIS_IDLE_TIMEOUT: ErrorCode = ErrorCode(279); + pub const REDIS_IDLE_TIMEOUT: ErrorCode = ErrorCode(280); /// `ERR_REDIS_INVALID_ARGUMENT` (instanceof Error) - pub const REDIS_INVALID_ARGUMENT: ErrorCode = ErrorCode(280); + pub const REDIS_INVALID_ARGUMENT: ErrorCode = ErrorCode(281); /// `ERR_REDIS_INVALID_ARRAY` (instanceof Error) - pub const REDIS_INVALID_ARRAY: ErrorCode = ErrorCode(281); + pub const REDIS_INVALID_ARRAY: ErrorCode = ErrorCode(282); /// `ERR_REDIS_INVALID_BULK_STRING` (instanceof Error) - pub const REDIS_INVALID_BULK_STRING: ErrorCode = ErrorCode(282); + pub const REDIS_INVALID_BULK_STRING: ErrorCode = ErrorCode(283); /// `ERR_REDIS_INVALID_COMMAND` (instanceof Error) - pub const REDIS_INVALID_COMMAND: ErrorCode = ErrorCode(283); + pub const REDIS_INVALID_COMMAND: ErrorCode = ErrorCode(284); /// `ERR_REDIS_INVALID_DATABASE` (instanceof Error) - pub const REDIS_INVALID_DATABASE: ErrorCode = ErrorCode(284); + pub const REDIS_INVALID_DATABASE: ErrorCode = ErrorCode(285); /// `ERR_REDIS_INVALID_ERROR_STRING` (instanceof Error) - pub const REDIS_INVALID_ERROR_STRING: ErrorCode = ErrorCode(285); + pub const REDIS_INVALID_ERROR_STRING: ErrorCode = ErrorCode(286); /// `ERR_REDIS_INVALID_INTEGER` (instanceof Error) - pub const REDIS_INVALID_INTEGER: ErrorCode = ErrorCode(286); + pub const REDIS_INVALID_INTEGER: ErrorCode = ErrorCode(287); /// `ERR_REDIS_INVALID_PASSWORD` (instanceof Error) - pub const REDIS_INVALID_PASSWORD: ErrorCode = ErrorCode(287); + pub const REDIS_INVALID_PASSWORD: ErrorCode = ErrorCode(288); /// `ERR_REDIS_INVALID_RESPONSE` (instanceof Error) - pub const REDIS_INVALID_RESPONSE: ErrorCode = ErrorCode(288); + pub const REDIS_INVALID_RESPONSE: ErrorCode = ErrorCode(289); /// `ERR_REDIS_INVALID_RESPONSE_TYPE` (instanceof Error) - pub const REDIS_INVALID_RESPONSE_TYPE: ErrorCode = ErrorCode(289); + pub const REDIS_INVALID_RESPONSE_TYPE: ErrorCode = ErrorCode(290); /// `ERR_REDIS_INVALID_SIMPLE_STRING` (instanceof Error) - pub const REDIS_INVALID_SIMPLE_STRING: ErrorCode = ErrorCode(290); + pub const REDIS_INVALID_SIMPLE_STRING: ErrorCode = ErrorCode(291); /// `ERR_REDIS_INVALID_STATE` (instanceof Error) - pub const REDIS_INVALID_STATE: ErrorCode = ErrorCode(291); + pub const REDIS_INVALID_STATE: ErrorCode = ErrorCode(292); /// `ERR_REDIS_INVALID_USERNAME` (instanceof Error) - pub const REDIS_INVALID_USERNAME: ErrorCode = ErrorCode(292); + pub const REDIS_INVALID_USERNAME: ErrorCode = ErrorCode(293); /// `ERR_REDIS_TLS_NOT_AVAILABLE` (instanceof Error) - pub const REDIS_TLS_NOT_AVAILABLE: ErrorCode = ErrorCode(293); + pub const REDIS_TLS_NOT_AVAILABLE: ErrorCode = ErrorCode(294); /// `ERR_REDIS_TLS_UPGRADE_FAILED` (instanceof Error) - pub const REDIS_TLS_UPGRADE_FAILED: ErrorCode = ErrorCode(294); + pub const REDIS_TLS_UPGRADE_FAILED: ErrorCode = ErrorCode(295); /// `HPE_UNEXPECTED_CONTENT_LENGTH` (instanceof Error) - pub const HPE_UNEXPECTED_CONTENT_LENGTH: ErrorCode = ErrorCode(295); + pub const HPE_UNEXPECTED_CONTENT_LENGTH: ErrorCode = ErrorCode(296); /// `HPE_INVALID_TRANSFER_ENCODING` (instanceof Error) - pub const HPE_INVALID_TRANSFER_ENCODING: ErrorCode = ErrorCode(296); + pub const HPE_INVALID_TRANSFER_ENCODING: ErrorCode = ErrorCode(297); /// `HPE_INVALID_EOF_STATE` (instanceof Error) - pub const HPE_INVALID_EOF_STATE: ErrorCode = ErrorCode(297); + pub const HPE_INVALID_EOF_STATE: ErrorCode = ErrorCode(298); /// `HPE_INVALID_METHOD` (instanceof Error) - pub const HPE_INVALID_METHOD: ErrorCode = ErrorCode(298); + pub const HPE_INVALID_METHOD: ErrorCode = ErrorCode(299); /// `HPE_INTERNAL` (instanceof Error) - pub const HPE_INTERNAL: ErrorCode = ErrorCode(299); + pub const HPE_INTERNAL: ErrorCode = ErrorCode(300); /// `ERR_VM_MODULE_STATUS` (instanceof Error) - pub const VM_MODULE_STATUS: ErrorCode = ErrorCode(300); + pub const VM_MODULE_STATUS: ErrorCode = ErrorCode(301); /// `ERR_VM_MODULE_ALREADY_LINKED` (instanceof Error) - pub const VM_MODULE_ALREADY_LINKED: ErrorCode = ErrorCode(301); + pub const VM_MODULE_ALREADY_LINKED: ErrorCode = ErrorCode(302); /// `ERR_VM_MODULE_CANNOT_CREATE_CACHED_DATA` (instanceof Error) - pub const VM_MODULE_CANNOT_CREATE_CACHED_DATA: ErrorCode = ErrorCode(302); + pub const VM_MODULE_CANNOT_CREATE_CACHED_DATA: ErrorCode = ErrorCode(303); /// `ERR_VM_MODULE_NOT_MODULE` (instanceof Error) - pub const VM_MODULE_NOT_MODULE: ErrorCode = ErrorCode(303); + pub const VM_MODULE_NOT_MODULE: ErrorCode = ErrorCode(304); /// `ERR_VM_MODULE_DIFFERENT_CONTEXT` (instanceof Error) - pub const VM_MODULE_DIFFERENT_CONTEXT: ErrorCode = ErrorCode(304); + pub const VM_MODULE_DIFFERENT_CONTEXT: ErrorCode = ErrorCode(305); /// `ERR_VM_MODULE_LINK_FAILURE` (instanceof Error) - pub const VM_MODULE_LINK_FAILURE: ErrorCode = ErrorCode(305); + pub const VM_MODULE_LINK_FAILURE: ErrorCode = ErrorCode(306); /// `ERR_VM_MODULE_CACHED_DATA_REJECTED` (instanceof Error) - pub const VM_MODULE_CACHED_DATA_REJECTED: ErrorCode = ErrorCode(306); + pub const VM_MODULE_CACHED_DATA_REJECTED: ErrorCode = ErrorCode(307); /// `ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING` (instanceof TypeError) - pub const VM_DYNAMIC_IMPORT_CALLBACK_MISSING: ErrorCode = ErrorCode(307); + pub const VM_DYNAMIC_IMPORT_CALLBACK_MISSING: ErrorCode = ErrorCode(308); /// `HPE_INVALID_HEADER_TOKEN` (instanceof Error) - pub const HPE_INVALID_HEADER_TOKEN: ErrorCode = ErrorCode(308); + pub const HPE_INVALID_HEADER_TOKEN: ErrorCode = ErrorCode(309); /// `HPE_HEADER_OVERFLOW` (instanceof Error) - pub const HPE_HEADER_OVERFLOW: ErrorCode = ErrorCode(309); + pub const HPE_HEADER_OVERFLOW: ErrorCode = ErrorCode(310); /// `ERR_SECRETS_NOT_AVAILABLE` (instanceof Error) - pub const SECRETS_NOT_AVAILABLE: ErrorCode = ErrorCode(310); + pub const SECRETS_NOT_AVAILABLE: ErrorCode = ErrorCode(311); /// `ERR_SECRETS_NOT_FOUND` (instanceof Error) - pub const SECRETS_NOT_FOUND: ErrorCode = ErrorCode(311); + pub const SECRETS_NOT_FOUND: ErrorCode = ErrorCode(312); /// `ERR_SECRETS_ACCESS_DENIED` (instanceof Error) - pub const SECRETS_ACCESS_DENIED: ErrorCode = ErrorCode(312); + pub const SECRETS_ACCESS_DENIED: ErrorCode = ErrorCode(313); /// `ERR_SECRETS_PLATFORM_ERROR` (instanceof Error) - pub const SECRETS_PLATFORM_ERROR: ErrorCode = ErrorCode(313); + pub const SECRETS_PLATFORM_ERROR: ErrorCode = ErrorCode(314); /// `ERR_SECRETS_USER_CANCELED` (instanceof Error) - pub const SECRETS_USER_CANCELED: ErrorCode = ErrorCode(314); + pub const SECRETS_USER_CANCELED: ErrorCode = ErrorCode(315); /// `ERR_SECRETS_INTERACTION_NOT_ALLOWED` (instanceof Error) - pub const SECRETS_INTERACTION_NOT_ALLOWED: ErrorCode = ErrorCode(315); + pub const SECRETS_INTERACTION_NOT_ALLOWED: ErrorCode = ErrorCode(316); /// `ERR_SECRETS_AUTH_FAILED` (instanceof Error) - pub const SECRETS_AUTH_FAILED: ErrorCode = ErrorCode(316); + pub const SECRETS_AUTH_FAILED: ErrorCode = ErrorCode(317); /// `ERR_SECRETS_INTERACTION_REQUIRED` (instanceof Error) - pub const SECRETS_INTERACTION_REQUIRED: ErrorCode = ErrorCode(317); + pub const SECRETS_INTERACTION_REQUIRED: ErrorCode = ErrorCode(318); + /// `ERR_WORKER_MESSAGING_ERRORED` (instanceof Error) + pub const WORKER_MESSAGING_ERRORED: ErrorCode = ErrorCode(319); + /// `ERR_WORKER_MESSAGING_FAILED` (instanceof Error) + pub const WORKER_MESSAGING_FAILED: ErrorCode = ErrorCode(320); + /// `ERR_WORKER_MESSAGING_SAME_THREAD` (instanceof Error) + pub const WORKER_MESSAGING_SAME_THREAD: ErrorCode = ErrorCode(321); + /// `ERR_WORKER_MESSAGING_TIMEOUT` (instanceof Error) + pub const WORKER_MESSAGING_TIMEOUT: ErrorCode = ErrorCode(322); /// `ERR_POSTGRES_CONNECTION_FAILED` (instanceof Error) - pub const POSTGRES_CONNECTION_FAILED: ErrorCode = ErrorCode(318); + pub const POSTGRES_CONNECTION_FAILED: ErrorCode = ErrorCode(323); /// `ERR_MYSQL_CONNECTION_FAILED` (instanceof Error) - pub const MYSQL_CONNECTION_FAILED: ErrorCode = ErrorCode(319); + pub const MYSQL_CONNECTION_FAILED: ErrorCode = ErrorCode(324); /// `ERR_POSTGRES_CONNECTION_REFUSED` (instanceof Error) - pub const POSTGRES_CONNECTION_REFUSED: ErrorCode = ErrorCode(320); + pub const POSTGRES_CONNECTION_REFUSED: ErrorCode = ErrorCode(325); /// `ERR_MYSQL_CONNECTION_REFUSED` (instanceof Error) - pub const MYSQL_CONNECTION_REFUSED: ErrorCode = ErrorCode(321); + pub const MYSQL_CONNECTION_REFUSED: ErrorCode = ErrorCode(326); /// `ERR_HTTP2_GOAWAY_SESSION` (instanceof Error) - pub const HTTP2_GOAWAY_SESSION: ErrorCode = ErrorCode(322); + pub const HTTP2_GOAWAY_SESSION: ErrorCode = ErrorCode(327); /// `ERR_TLS_ALPN_CALLBACK_INVALID_RESULT` (instanceof TypeError) - pub const TLS_ALPN_CALLBACK_INVALID_RESULT: ErrorCode = ErrorCode(323); + pub const TLS_ALPN_CALLBACK_INVALID_RESULT: ErrorCode = ErrorCode(328); /// `ERR_PROXY_TUNNEL` (instanceof Error) - pub const PROXY_TUNNEL: ErrorCode = ErrorCode(324); + pub const PROXY_TUNNEL: ErrorCode = ErrorCode(329); /// `ERR_FS_CP_EEXIST` (instanceof Error) - pub const FS_CP_EEXIST: ErrorCode = ErrorCode(325); + pub const FS_CP_EEXIST: ErrorCode = ErrorCode(330); /// `ERR_FS_CP_SYMLINK_TO_SUBDIRECTORY` (instanceof Error) - pub const FS_CP_SYMLINK_TO_SUBDIRECTORY: ErrorCode = ErrorCode(326); + pub const FS_CP_SYMLINK_TO_SUBDIRECTORY: ErrorCode = ErrorCode(331); /// `ERR_DIR_CONCURRENT_OPERATION` (instanceof Error) - pub const DIR_CONCURRENT_OPERATION: ErrorCode = ErrorCode(327); + pub const DIR_CONCURRENT_OPERATION: ErrorCode = ErrorCode(332); /// `ERR_INVALID_BUFFER_SIZE` (instanceof RangeError) - pub const INVALID_BUFFER_SIZE: ErrorCode = ErrorCode(328); + pub const INVALID_BUFFER_SIZE: ErrorCode = ErrorCode(333); /// `ERR_TRACE_EVENTS_CATEGORY_REQUIRED` (instanceof TypeError) - pub const TRACE_EVENTS_CATEGORY_REQUIRED: ErrorCode = ErrorCode(329); + pub const TRACE_EVENTS_CATEGORY_REQUIRED: ErrorCode = ErrorCode(334); /// `ERR_TRACE_EVENTS_UNAVAILABLE` (instanceof Error) - pub const TRACE_EVENTS_UNAVAILABLE: ErrorCode = ErrorCode(330); + pub const TRACE_EVENTS_UNAVAILABLE: ErrorCode = ErrorCode(335); /// == C++ `NODE_ERROR_COUNT`. - pub const COUNT: u16 = 331; + pub const COUNT: u16 = 336; } // ────────────────────────────────────────────────────────────────────────── @@ -1048,6 +1058,7 @@ impl ErrorCode { pub const ERR_WORKER_INIT_FAILED: ErrorCode = ErrorCode::WORKER_INIT_FAILED; pub const ERR_WORKER_NOT_RUNNING: ErrorCode = ErrorCode::WORKER_NOT_RUNNING; pub const ERR_WORKER_UNSUPPORTED_OPERATION: ErrorCode = ErrorCode::WORKER_UNSUPPORTED_OPERATION; + pub const ERR_WORKER_PATH: ErrorCode = ErrorCode::WORKER_PATH; pub const ERR_ZLIB_INITIALIZATION_FAILED: ErrorCode = ErrorCode::ZLIB_INITIALIZATION_FAILED; pub const ERR_INTERNAL_ASSERTION: ErrorCode = ErrorCode::INTERNAL_ASSERTION; pub const ERR_OSSL_EVP_INVALID_DIGEST: ErrorCode = ErrorCode::OSSL_EVP_INVALID_DIGEST; @@ -1381,6 +1392,7 @@ static CODE_STR: [&str; ErrorCode::COUNT as usize] = [ "ERR_WORKER_INIT_FAILED", "ERR_WORKER_NOT_RUNNING", "ERR_WORKER_UNSUPPORTED_OPERATION", + "ERR_WORKER_PATH", "ERR_ZLIB_INITIALIZATION_FAILED", "MODULE_NOT_FOUND", "ERR_INTERNAL_ASSERTION", @@ -1429,6 +1441,10 @@ static CODE_STR: [&str; ErrorCode::COUNT as usize] = [ "ERR_SECRETS_INTERACTION_NOT_ALLOWED", "ERR_SECRETS_AUTH_FAILED", "ERR_SECRETS_INTERACTION_REQUIRED", + "ERR_WORKER_MESSAGING_ERRORED", + "ERR_WORKER_MESSAGING_FAILED", + "ERR_WORKER_MESSAGING_SAME_THREAD", + "ERR_WORKER_MESSAGING_TIMEOUT", "ERR_POSTGRES_CONNECTION_FAILED", "ERR_MYSQL_CONNECTION_FAILED", "ERR_POSTGRES_CONNECTION_REFUSED", diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index a713132f35f9..59cd0f96f29c 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -1393,6 +1393,16 @@ impl VirtualMachine { let hooks = runtime_hooks().expect("RuntimeHooks not installed"); if self.is_handling_uncaught_exception { + if !self.is_main_thread() { + // node parity: a throw inside the uncaughtException handler in a + // worker exits the worker with code 1 (not the main-thread fatal + // code 7). Report it to the parent + arm termination via the + // normal path; process_exit() RETURNS on a worker, so the + // main-thread process_exit(7)+panic below would crash. + self.exit_handler.exit_code = 1; + (self.on_unhandled_rejection)(self, global_object, err); + return false; + } self.run_error_handler(err, None); // SAFETY: `global_object` is the live VM global; `process_exit` is // `bun_runtime::node::process::exit` (main-thread `noreturn`). @@ -1408,8 +1418,10 @@ impl VirtualMachine { if !handled { // `beforeExit` has already been dispatched, so the run is winding // down and there is no loop turn left to defer to: print the error - // and exit, like node's fatal-exception path. - if self.exit_on_uncaught_exception { + // and exit, like node's fatal-exception path. Main thread only: + // process_exit() RETURNS on a worker, so the panic would fire; a + // worker falls through and exits 1 below (e.g. a beforeExit throw). + if self.exit_on_uncaught_exception && self.is_main_thread() { self.run_error_handler(err, None); // `process_exit` emits `exit`, re-entering here if a listener // throws. No handler is running, so drop the recursion guard or @@ -4473,6 +4485,11 @@ impl VirtualMachine { // no-op, so dropping the box just frees its own allocation. drop(core::mem::take(&mut self.module_loader)); + // Same raw-dealloc story: `preload` is cloned into the VM at spin() + // time and `load_preloads` clears the boxes but keeps the Vec buffer, + // so reclaim it here or every Worker leaks it. + drop(core::mem::take(&mut self.preload)); + // SAFETY: this VM is raw-`dealloc`'d (no field `Drop` runs), so // `transpiler` is never auto-dropped after `deinit` clears its fields. unsafe { self.transpiler.deinit() }; diff --git a/src/jsc/bindings/BunCPUProfiler.cpp b/src/jsc/bindings/BunCPUProfiler.cpp index 3166d2ed9a8e..978ab5660494 100644 --- a/src/jsc/bindings/BunCPUProfiler.cpp +++ b/src/jsc/bindings/BunCPUProfiler.cpp @@ -30,10 +30,10 @@ void Bun__setSamplingInterval(int intervalMicroseconds) namespace Bun { // Store the profiling start time in microseconds since Unix epoch -static double s_profilingStartTime = 0.0; +static thread_local double s_profilingStartTime = 0.0; // Set sampling interval to 1ms (1000 microseconds) to match Node.js -static int s_samplingInterval = 1000; -static bool s_isProfilerRunning = false; +static thread_local int s_samplingInterval = 1000; +static thread_local bool s_isProfilerRunning = false; void setSamplingInterval(int intervalMicroseconds) { diff --git a/src/jsc/bindings/BunProcess.cpp b/src/jsc/bindings/BunProcess.cpp index 23df5fd1a565..498fdcf9278f 100644 --- a/src/jsc/bindings/BunProcess.cpp +++ b/src/jsc/bindings/BunProcess.cpp @@ -208,7 +208,7 @@ static JSValue constructPlatform(VM& vm, JSObject* processObject) static JSValue constructVersions(VM& vm, JSObject* processObject) { - auto scope = DECLARE_THROW_SCOPE(vm); + auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); auto* globalObject = processObject->globalObject(); JSC::JSObject* object = JSC::constructEmptyObject(globalObject, globalObject->objectPrototype(), 24); RETURN_IF_EXCEPTION(scope, {}); @@ -268,12 +268,14 @@ static JSValue constructVersions(VM& vm, JSObject* processObject) static JSValue constructProcessReleaseObject(VM& vm, JSObject* processObject) { auto* globalObject = processObject->globalObject(); + auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); auto* release = JSC::constructEmptyObject(globalObject); release->putDirect(vm, vm.propertyNames->name, jsOwnedString(vm, String("node"_s)), 0); // maybe this should be 'bun' eventually release->putDirect(vm, Identifier::fromString(vm, "sourceUrl"_s), jsOwnedString(vm, WTF::String(std::span { Bun__githubURL, strlen(Bun__githubURL) })), 0); release->putDirect(vm, Identifier::fromString(vm, "headersUrl"_s), jsOwnedString(vm, String("https://nodejs.org/download/release/v" REPORTED_NODEJS_VERSION "/node-v" REPORTED_NODEJS_VERSION "-headers.tar.gz"_s)), 0); + RETURN_IF_EXCEPTION(scope, {}); return release; } @@ -1212,6 +1214,21 @@ extern "C" int Bun__handleUncaughtException(JSC::JSGlobalObject* lexicalGlobalOb auto& wrapped = process->wrapped(); auto& vm = JSC::getVM(globalObject); + // node parity (exitWithUndefinedFatalException): the internal fatal-exception + // handler is monkey-patchable as process._fatalException. If user code + // replaces it with a non-callable value, node cannot dispatch and exits with + // code 6 (InvalidFatalExceptionMonkeyPatching). + { + auto fatalScope = DECLARE_TOP_EXCEPTION_SCOPE(vm); + JSValue fatalException = process->get(globalObject, Identifier::fromString(vm, "_fatalException"_s)); + if (fatalScope.exception()) { + (void)fatalScope.tryClearException(); + } else if (!fatalException.isCallable()) { + Bun__Process__exit(globalObject, 6); + return true; + } + } + MarkedArgumentBuffer args; args.append(exception); if (isRejection) { @@ -2484,6 +2501,7 @@ static JSValue constructProcessReportObject(VM& vm, JSObject* processObject) auto* globalObject = processObject->globalObject(); auto process = uncheckedDowncast(processObject); + auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); auto* report = JSC::constructEmptyObject(globalObject, globalObject->objectPrototype(), 10); report->putDirect(vm, JSC::Identifier::fromString(vm, "compact"_s), JSC::jsBoolean(false), 0); report->putDirect(vm, JSC::Identifier::fromString(vm, "directory"_s), JSC::jsEmptyString(vm), 0); @@ -2495,6 +2513,7 @@ static JSValue constructProcessReportObject(VM& vm, JSObject* processObject) report->putDirect(vm, JSC::Identifier::fromString(vm, "excludeEnv"_s), JSC::jsBoolean(false), 0); report->putDirect(vm, JSC::Identifier::fromString(vm, "excludeEnv"_s), JSC::jsString(vm, String("SIGUSR2"_s)), 0); report->putDirect(vm, JSC::Identifier::fromString(vm, "writeReport"_s), JSC::JSFunction::create(vm, globalObject, 1, String("writeReport"_s), Process_functionWriteReport, ImplementationVisibility::Public), 0); + RETURN_IF_EXCEPTION(scope, {}); return report; } @@ -2628,6 +2647,7 @@ static JSValue constructProcessConfigObject(VM& vm, JSObject* processObject) #endif config->freeze(vm); + RETURN_IF_EXCEPTION(scope, {}); return config; } @@ -3874,7 +3894,10 @@ static JSValue Process_stubEmptyArray(VM& vm, JSObject* processObject) static JSValue Process_stubEmptySet(VM& vm, JSObject* processObject) { auto* globalObject = processObject->globalObject(); - return JSSet::create(vm, globalObject->setStructure()); + auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); + JSSet* result = JSSet::create(vm, globalObject->setStructure()); + RETURN_IF_EXCEPTION(scope, {}); + return result; } static JSValue constructMemoryUsage(VM& vm, JSObject* processObject) @@ -4079,6 +4102,7 @@ static JSValue constructFeatures(VM& vm, JSObject* processObject) // cached_builtins: [Getter] // } auto* globalObject = processObject->globalObject(); + auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); auto* object = constructEmptyObject(globalObject); object->putDirect(vm, Identifier::fromString(vm, "inspector"_s), jsBoolean(true)); @@ -4100,10 +4124,11 @@ static JSValue constructFeatures(VM& vm, JSObject* processObject) object->putDirect(vm, Identifier::fromString(vm, "require_module"_s), jsBoolean(true)); object->putDirect(vm, Identifier::fromString(vm, "typescript"_s), jsString(vm, String("transform"_s))); + RETURN_IF_EXCEPTION(scope, {}); return object; } -static uint16_t debugPort; +static uint16_t debugPort = 9229; JSC_DEFINE_CUSTOM_GETTER(processDebugPort, (JSC::JSGlobalObject * globalObject, JSC::EncodedJSValue thisValue, JSC::PropertyName)) { diff --git a/src/jsc/bindings/BunString.cpp b/src/jsc/bindings/BunString.cpp index 1d10870009dd..ec85cc75cb3a 100644 --- a/src/jsc/bindings/BunString.cpp +++ b/src/jsc/bindings/BunString.cpp @@ -335,16 +335,33 @@ bool isCrossThreadShareable(const WTF::String& string) return true; } +// An isolated copy still gets handed to (possibly several) receiving threads — +// BroadcastChannel fans a single SerializedScriptValue out to N contexts, each +// of which deserializes the same stored string — so the copy needs the same +// pre-hash + never-atomize treatment as a directly-shared original. Otherwise +// the receivers race the lazy m_hashAndFlags update (debug: ASSERT(!hasHash()) +// in setHash; e.g. two workers switch()ing on the same BroadcastChannel +// message). Static strings are immortal, pre-hashed and safe to share as-is. +static Ref isolatedCopyForSharing(WTF::StringImpl& impl) +{ + Ref copy = impl.isolatedCopy(); + if (!copy->isStatic()) { + copy->hash(); + copy->setNeverAtomize(); + } + return copy; +} + Ref toCrossThreadShareable(Ref impl) { if (impl->isAtom() || impl->isSymbol()) - return impl->isolatedCopy(); + return isolatedCopyForSharing(impl); if (impl->bufferOwnership() == StringImpl::BufferSubstring) - return impl->isolatedCopy(); + return isolatedCopyForSharing(impl); if (impl->length() < kMinCrossThreadShareableLength) - return impl->isolatedCopy(); + return isolatedCopyForSharing(impl); // 3) Ensure we won't lazily touch hash/flags on the consumer thread // Force hash computation on this thread before sharing @@ -356,18 +373,20 @@ Ref toCrossThreadShareable(Ref impl) WTF::String toCrossThreadShareable(const WTF::String& string) { - if (string.length() < kMinCrossThreadShareableLength) - return string.isolatedCopy(); - auto* impl = string.impl(); + if (!impl) + return string; + + if (string.length() < kMinCrossThreadShareableLength) + return isolatedCopyForSharing(*impl); // 1) Never share AtomStringImpl/symbols - they have special thread-unsafe behavior if (impl->isAtom() || impl->isSymbol()) - return string.isolatedCopy(); + return isolatedCopyForSharing(*impl); // 2) Don't share slices if (impl->bufferOwnership() == StringImpl::BufferSubstring) - return string.isolatedCopy(); + return isolatedCopyForSharing(*impl); // 3) Ensure we won't lazily touch hash/flags on the consumer thread // Force hash computation on this thread before sharing diff --git a/src/jsc/bindings/ErrorCode.ts b/src/jsc/bindings/ErrorCode.ts index acd959d9bfc3..0b1c793a4b24 100644 --- a/src/jsc/bindings/ErrorCode.ts +++ b/src/jsc/bindings/ErrorCode.ts @@ -279,6 +279,7 @@ const errors: ErrorCodeMapping = [ ["ERR_WORKER_INIT_FAILED", Error], ["ERR_WORKER_NOT_RUNNING", Error], ["ERR_WORKER_UNSUPPORTED_OPERATION", TypeError], + ["ERR_WORKER_PATH", TypeError], ["ERR_ZLIB_INITIALIZATION_FAILED", Error], ["MODULE_NOT_FOUND", Error], ["ERR_INTERNAL_ASSERTION", Error], @@ -327,6 +328,10 @@ const errors: ErrorCodeMapping = [ ["ERR_SECRETS_INTERACTION_NOT_ALLOWED", Error], ["ERR_SECRETS_AUTH_FAILED", Error], ["ERR_SECRETS_INTERACTION_REQUIRED", Error], + ["ERR_WORKER_MESSAGING_ERRORED", Error], + ["ERR_WORKER_MESSAGING_FAILED", Error], + ["ERR_WORKER_MESSAGING_SAME_THREAD", Error], + ["ERR_WORKER_MESSAGING_TIMEOUT", Error], ["ERR_POSTGRES_CONNECTION_FAILED", Error, "PostgresError"], ["ERR_MYSQL_CONNECTION_FAILED", Error, "MySQLError"], ["ERR_POSTGRES_CONNECTION_REFUSED", Error, "PostgresError"], diff --git a/src/jsc/bindings/JSEnvironmentVariableMap.cpp b/src/jsc/bindings/JSEnvironmentVariableMap.cpp index 9d6816d2ce36..098900c78a41 100644 --- a/src/jsc/bindings/JSEnvironmentVariableMap.cpp +++ b/src/jsc/bindings/JSEnvironmentVariableMap.cpp @@ -13,6 +13,15 @@ #include "BunClientData.h" #include "wtf/Compiler.h" #include "wtf/Forward.h" +#include +#include +#include +#include +#include +#include "BunProcess.h" +#include "ScriptExecutionContext.h" +#include "SharedEnvStore.h" +#include "wtf/NeverDestroyed.h" #include "WebCoreJSBuiltins.h" using namespace JSC; @@ -159,6 +168,12 @@ JSC_DEFINE_CUSTOM_GETTER(jsTimeZoneEnvironmentVariableGetter, (JSGlobalObject * return JSValue::encode(out); } +// Shared parse-and-apply for TZ / NODE_TLS_REJECT_UNAUTHORIZED / BUN_CONFIG_VERBOSE_FETCH, +// used by both the CustomSetters below and applySharedEnvSideEffects. +static void applyTZFromString(JSGlobalObject*, const String&); +static void applyTLSRejectFromString(JSGlobalObject*, const String&); +static void applyVerboseFetchFromString(JSGlobalObject*, const String&); + // In Node.js, the "TZ" environment variable is special. // Setting it automatically updates the timezone. // We also expose an explicit setTimeZone function in bun:jsc @@ -172,11 +187,7 @@ JSC_DEFINE_CUSTOM_SETTER(jsTimeZoneEnvironmentVariableSetter, (JSGlobalObject * JSValue decodedValue = JSValue::decode(value); if (decodedValue.isString()) { auto timeZoneName = decodedValue.toWTFString(globalObject); - if (timeZoneName.length() < 32) { - if (WTF::setTimeZoneOverride(timeZoneName)) { - vm.dateCache.resetIfNecessarySlow(); - } - } + applyTZFromString(globalObject, timeZoneName); } auto* clientData = WebCore::clientData(vm); @@ -252,11 +263,7 @@ JSC_DEFINE_CUSTOM_SETTER(jsNodeTLSRejectUnauthorizedSetter, (JSGlobalObject * gl // TODO: only check "0". Node doesn't check both. But we already did. So we // should wait to do that until Bun v1.2.0. - if (str == "0"_s || str == "false"_s) { - Bun__setTLSRejectUnauthorizedValue(0); - } else { - Bun__setTLSRejectUnauthorizedValue(1); - } + applyTLSRejectFromString(globalObject, str); const auto& privateName = NODE_TLS_REJECT_UNAUTHORIZED_PRIVATE_PROPERTY(vm); object->putDirect(vm, privateName, JSValue::decode(value), 0); @@ -304,13 +311,7 @@ JSC_DEFINE_CUSTOM_SETTER(jsBunConfigVerboseFetchSetter, (JSGlobalObject * global WTF::String str = decodedValue.toWTFString(globalObject); RETURN_IF_EXCEPTION(scope, false); - if (str == "1"_s || str == "true"_s) { - Bun__setVerboseFetchValue(1); - } else if (str == "curl"_s) { - Bun__setVerboseFetchValue(2); - } else { - Bun__setVerboseFetchValue(0); - } + applyVerboseFetchFromString(globalObject, str); const auto& privateName = BUN_CONFIG_VERBOSE_FETCH_PRIVATE_PROPERTY(vm); object->putDirect(vm, privateName, JSValue::decode(value), 0); @@ -344,6 +345,404 @@ JSC_DEFINE_HOST_FUNCTION(jsEditWindowsEnvVar, (JSGlobalObject * global, JSC::Cal } #endif +// Founding a SHARE_ENV tree swaps main's process.env off the windowsEnv Proxy that +// called SetEnvironmentVariableW, so every mutation of a main-rooted shared store has +// to re-apply that write-through. Gated on the *store*, not the writing thread: node +// roots a main-founded tree at its RealEnvStore, so a worker writing through that tree +// reaches the OS env too. `value == nullptr` deletes. +static ALWAYS_INLINE void syncWindowsEnv(SharedEnvStore* store, const String& key, const String* value) +{ +#if OS(WINDOWS) + if (!store || !store->isMainRooted()) + return; + if (value) + Bun__Process__editWindowsEnvVar(Bun::toString(key), Bun::toString(*value)); + else + Bun__Process__editWindowsEnvVar(Bun::toString(key), { .tag = BunStringTag::Dead }); +#else + UNUSED_PARAM(store); + UNUSED_PARAM(key); + UNUSED_PARAM(value); +#endif +} + +// ============================================================================ +// worker_threads SHARE_ENV +// +// With `env: SHARE_ENV` the worker shares one live environment with the thread +// that spawned it. JS objects can't cross VMs, so each thread gets its own +// `process.env` object that is a thin write-through view over the tree's +// SharedEnvStore (lock-guarded, strings isolatedCopy()'d both ways). +// +// Only the JS-visible `process.env` is shared; Bun's Zig-side env map (Bun.env, +// fetch proxy resolution) is still snapshotted per worker. + +// The store for the tree this global belongs to, or null if it's in none. The +// context can be gone during teardown, when a surviving process.env is read. +static SharedEnvStore* sharedEnvStoreFor(Zig::GlobalObject* globalObject) +{ + auto* context = globalObject->scriptExecutionContext(); + return context ? context->sharedEnvStore() : nullptr; +} + +// Resolve via the object's own global, never the lexical one: a cross-realm read +// of `process.env` must hit the tree that owns the object. jsDynamicCast, not +// defaultGlobalObject(), which would silently retarget the thread's default tree. +static SharedEnvStore* sharedEnvStoreFor(JSC::JSObject* object) +{ + auto* globalObject = dynamicDowncast(object->globalObject()); + return globalObject ? sharedEnvStoreFor(globalObject) : nullptr; +} + +// process.env variant whose reads/writes/deletes/enumeration go through the +// tree's SharedEnvStore; no instance state, so no custom subspace. +class JSSharedEnvMap final : public JSC::JSNonFinalObject { +public: + using Base = JSC::JSNonFinalObject; + + static constexpr unsigned StructureFlags = Base::StructureFlags + | JSC::OverridesGetOwnPropertySlot + | JSC::InterceptsGetOwnPropertySlotByIndexEvenWhenLengthIsNotZero + | JSC::OverridesPut + | JSC::OverridesGetOwnPropertyNames + | JSC::GetOwnPropertySlotMayBeWrongAboutDontEnum + | JSC::ProhibitsPropertyCaching; + + template + static JSC::GCClient::IsoSubspace* subspaceFor(JSC::VM& vm) + { + STATIC_ASSERT_ISO_SUBSPACE_SHARABLE(JSSharedEnvMap, Base); + return &vm.plainObjectSpace(); + } + + DECLARE_INFO; + + static JSC::Structure* createStructure(JSC::VM& vm, JSC::JSGlobalObject* globalObject, JSC::JSValue prototype) + { + return JSC::Structure::create(vm, globalObject, prototype, JSC::TypeInfo(JSC::ObjectType, StructureFlags), info()); + } + + static JSSharedEnvMap* create(JSC::VM& vm, JSC::Structure* structure) + { + JSSharedEnvMap* ptr = new (NotNull, JSC::allocateCell(vm)) JSSharedEnvMap(vm, structure); + ptr->finishCreation(vm); + return ptr; + } + + static bool getOwnPropertySlot(JSObject*, JSGlobalObject*, JSC::PropertyName, JSC::PropertySlot&); + static bool put(JSCell*, JSGlobalObject*, JSC::PropertyName, JSC::JSValue, JSC::PutPropertySlot&); + static bool deleteProperty(JSCell*, JSGlobalObject*, JSC::PropertyName, JSC::DeletePropertySlot&); + // Integer-like env keys (process.env['123']) arrive through the indexed hooks; + // without these they land in JSObject's indexed storage, invisible to the store. + static bool getOwnPropertySlotByIndex(JSObject*, JSGlobalObject*, unsigned, JSC::PropertySlot&); + static bool putByIndex(JSCell*, JSGlobalObject*, unsigned, JSC::JSValue, bool shouldThrow); + static bool deletePropertyByIndex(JSCell*, JSGlobalObject*, unsigned); + static void getOwnPropertyNames(JSObject*, JSGlobalObject*, JSC::PropertyNameArrayBuilder&, JSC::DontEnumPropertiesMode); + static bool defineOwnProperty(JSObject*, JSGlobalObject*, JSC::PropertyName, const JSC::PropertyDescriptor&, bool shouldThrow); + +private: + JSSharedEnvMap(JSC::VM& vm, JSC::Structure* structure) + : Base(vm, structure) + { + } + + void finishCreation(JSC::VM& vm) + { + Base::finishCreation(vm); + } +}; + +const JSC::ClassInfo JSSharedEnvMap::s_info = { "ProcessEnv"_s, &Base::s_info, nullptr, nullptr, CREATE_METHOD_TABLE(JSSharedEnvMap) }; + +bool JSSharedEnvMap::getOwnPropertySlot(JSObject* object, JSGlobalObject* globalObject, PropertyName propertyName, PropertySlot& slot) +{ + VM& vm = JSC::getVM(globalObject); + auto* uid = propertyName.uid(); + if (propertyName.isSymbol() || !uid) { + return Base::getOwnPropertySlot(object, globalObject, propertyName, slot); + } + + auto* store = sharedEnvStoreFor(object); + String value = store ? store->get(String(uid)) : String(); + if (value.isNull()) { + return Base::getOwnPropertySlot(object, globalObject, propertyName, slot); + } + + slot.setValue(object, 0, JSC::jsString(vm, value)); + return true; +} + +// Proxy env vars written back to the Zig env map so fetch()'s getHttpProxyFor() +// sees runtime changes; shared by applySharedEnvSideEffects and +// createEnvironmentVariablesMap. +static constexpr ASCIILiteral kProxyEnvVarNames[] = { + "HTTP_PROXY"_s, + "http_proxy"_s, + "HTTPS_PROXY"_s, + "https_proxy"_s, + "NO_PROXY"_s, + "no_proxy"_s, +}; + +// The parse-and-apply bodies for the three side-effecting env vars, shared by +// the regular process.env CustomSetters and applySharedEnvSideEffects so a new +// side-effecting var need only be added in one place. +static void applyTZFromString(JSGlobalObject* globalObject, const String& value) +{ + if (value.length() < 32 && WTF::setTimeZoneOverride(value)) + JSC::getVM(globalObject).dateCache.resetIfNecessarySlow(); +} +static void applyTLSRejectFromString(JSGlobalObject*, const String& value) +{ + Bun__setTLSRejectUnauthorizedValue((value == "0"_s || value == "false"_s) ? 0 : 1); +} +static void applyVerboseFetchFromString(JSGlobalObject*, const String& value) +{ + if (value == "1"_s || value == "true"_s) + Bun__setVerboseFetchValue(1); + else if (value == "curl"_s) + Bun__setVerboseFetchValue(2); + else + Bun__setVerboseFetchValue(0); +} + +// Mirror the regular process.env CustomSetters' native side effects (TZ, TLS, +// verbose-fetch, proxy vars); the shared store only updates strings, so without +// this a SHARE_ENV worker's writes would silently skip them. +// These land on the *writing* thread only: the TLS-reject/verbose-fetch caches and +// the Zig env map are per-VM, so other threads in the tree read the new string but +// keep the old native effect. Node does not propagate a shared-store TZ either. +static void applySharedEnvSideEffects(JSGlobalObject* globalObject, const String& rawKey, const String& stringValue) +{ + // Windows env keys are case-insensitive; normalize so process.env.tz hits TZ. + String key = SharedEnvStore::normalizeKey(rawKey); + if (key == "TZ"_s) { + applyTZFromString(globalObject, stringValue); + return; + } + if (key == "NODE_TLS_REJECT_UNAUTHORIZED"_s) { + applyTLSRejectFromString(globalObject, stringValue); + return; + } + if (key == "BUN_CONFIG_VERBOSE_FETCH"_s) { + applyVerboseFetchFromString(globalObject, stringValue); + return; + } + // Proxy vars: fetch()'s getHttpProxyFor() reads the Zig env map, so sync. + const auto& proxyVarNames = kProxyEnvVarNames; + for (auto proxyName : proxyVarNames) { + if (key == proxyName) { + BunString name = Bun::toString(key); + BunString val = Bun::toString(stringValue); + Bun__setEnvValue(globalObject, &name, &val); + return; + } + } +} + +bool JSSharedEnvMap::put(JSCell* cell, JSGlobalObject* globalObject, PropertyName propertyName, JSValue value, PutPropertySlot& slot) +{ + VM& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + + auto* uid = propertyName.uid(); + if (propertyName.isSymbol() || !uid) { + RELEASE_AND_RETURN(scope, Base::put(cell, globalObject, propertyName, value, slot)); + } + + // A JSSharedEnvMap only exists on a thread that joined a tree; without a store + // there is nowhere to write, so keep the value locally rather than drop it. + auto* store = sharedEnvStoreFor(asObject(cell)); + if (!store) [[unlikely]] { + ASSERT_NOT_REACHED(); + RELEASE_AND_RETURN(scope, Base::put(cell, globalObject, propertyName, value, slot)); + } + + // Node coerces env values to strings on assignment. + String stringValue = value.toWTFString(globalObject); + RETURN_IF_EXCEPTION(scope, false); + + String keyStr = String(uid); + applySharedEnvSideEffects(globalObject, keyStr, stringValue); + syncWindowsEnv(store, keyStr, &stringValue); + store->set(keyStr, stringValue); + return true; +} + +bool JSSharedEnvMap::deleteProperty(JSCell* cell, JSGlobalObject* globalObject, PropertyName propertyName, DeletePropertySlot& slot) +{ + auto* uid = propertyName.uid(); + if (propertyName.isSymbol() || !uid) { + return Base::deleteProperty(cell, globalObject, propertyName, slot); + } + + auto* store = sharedEnvStoreFor(asObject(cell)); + if (!store) [[unlikely]] { + ASSERT_NOT_REACHED(); + return Base::deleteProperty(cell, globalObject, propertyName, slot); + } + + syncWindowsEnv(store, String(uid), nullptr); + store->remove(String(uid)); + // Also drop any own property the Base fallback installed (accessor descriptors). + return Base::deleteProperty(cell, globalObject, propertyName, slot); +} + +void JSSharedEnvMap::getOwnPropertyNames(JSObject* object, JSGlobalObject* globalObject, PropertyNameArrayBuilder& propertyNames, DontEnumPropertiesMode mode) +{ + VM& vm = JSC::getVM(globalObject); + if (auto* store = sharedEnvStoreFor(object)) { + for (const auto& key : store->keys()) + propertyNames.add(JSC::Identifier::fromString(vm, key)); + } + Base::getOwnPropertyNames(object, globalObject, propertyNames, mode); +} + +bool JSSharedEnvMap::defineOwnProperty(JSObject* object, JSGlobalObject* globalObject, PropertyName propertyName, const PropertyDescriptor& descriptor, bool shouldThrow) +{ + VM& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + + auto* uid = propertyName.uid(); + if (propertyName.isSymbol() || !uid || !descriptor.isDataDescriptor() || !descriptor.value()) { + // The descriptor lands on the Base object, but getOwnPropertySlot reads the + // store first, so a store entry would shadow it. Move the entry onto Base as + // an enumerable data property first: a partial descriptor then keeps that + // enumerability, exactly as it does on the regular process.env. (Node rejects + // accessors on process.env outright — on both maps — so match bun's own map.) + if (!propertyName.isSymbol() && uid) { + if (auto* store = sharedEnvStoreFor(object)) { + String existing = store->get(String(uid)); + if (!existing.isNull()) { + syncWindowsEnv(store, String(uid), nullptr); + store->remove(String(uid)); + object->putDirect(vm, propertyName, jsString(vm, existing), 0); + } + } + } + RELEASE_AND_RETURN(scope, Base::defineOwnProperty(object, globalObject, propertyName, descriptor, shouldThrow)); + } + + String stringValue = descriptor.value().toWTFString(globalObject); + RETURN_IF_EXCEPTION(scope, false); + + auto* store = sharedEnvStoreFor(object); + if (!store) [[unlikely]] { + ASSERT_NOT_REACHED(); + RELEASE_AND_RETURN(scope, Base::defineOwnProperty(object, globalObject, propertyName, descriptor, shouldThrow)); + } + + String keyStr = String(uid); + applySharedEnvSideEffects(globalObject, keyStr, stringValue); + syncWindowsEnv(store, keyStr, &stringValue); + store->set(keyStr, stringValue); + return true; +} + +bool JSSharedEnvMap::getOwnPropertySlotByIndex(JSObject* object, JSGlobalObject* globalObject, unsigned index, PropertySlot& slot) +{ + VM& vm = JSC::getVM(globalObject); + return getOwnPropertySlot(object, globalObject, Identifier::from(vm, index), slot); +} + +bool JSSharedEnvMap::putByIndex(JSCell* cell, JSGlobalObject* globalObject, unsigned index, JSValue value, bool shouldThrow) +{ + VM& vm = JSC::getVM(globalObject); + PutPropertySlot slot(cell, shouldThrow); + return put(cell, globalObject, Identifier::from(vm, index), value, slot); +} + +bool JSSharedEnvMap::deletePropertyByIndex(JSCell* cell, JSGlobalObject* globalObject, unsigned index) +{ + // Delegate to Base::deletePropertyByIndex, not deleteProperty: JSObject's named + // form re-dispatches index-like names back here, which would recurse forever. + auto* store = sharedEnvStoreFor(asObject(cell)); + if (!store) [[unlikely]] { + ASSERT_NOT_REACHED(); + return Base::deletePropertyByIndex(cell, globalObject, index); + } + + String keyStr = String::number(index); + syncWindowsEnv(store, keyStr, nullptr); + store->remove(keyStr); + return Base::deletePropertyByIndex(cell, globalObject, index); +} + +JSValue createSharedEnvironmentVariablesMap(Zig::GlobalObject* globalObject) +{ + VM& vm = globalObject->vm(); + auto* structure = JSSharedEnvMap::createStructure(vm, globalObject, globalObject->objectPrototype()); + return JSSharedEnvMap::create(vm, structure); +} + +RefPtr ensureSharedEnvStoreForWorker(Zig::GlobalObject* globalObject) +{ + VM& vm = globalObject->vm(); + auto scope = DECLARE_THROW_SCOPE(vm); + + // Already in a tree: the child aliases the same store, exactly as node hands it + // a shared_ptr to the creating thread's KVStore. + if (auto* existing = sharedEnvStoreFor(globalObject)) + return existing; + + // Founding a new tree. processEnvObject() forces the lazy init so the OS + // environment is captured before the swap below. + JSObject* envObject = globalObject->processEnvObject(); + if (!envObject->staticPropertiesReified()) { + envObject->reifyAllStaticProperties(globalObject); + RETURN_IF_EXCEPTION(scope, nullptr); + } + + JSC::PropertyNameArrayBuilder keys(vm, JSC::PropertyNameMode::Strings, JSC::PrivateSymbolMode::Exclude); + envObject->methodTable()->getOwnPropertyNames(envObject, globalObject, keys, JSC::DontEnumPropertiesMode::Exclude); + RETURN_IF_EXCEPTION(scope, nullptr); + + // Seed unconditionally: this thread's env is the new tree's initial contents. + auto store = SharedEnvStore::create(globalObject->scriptExecutionContext()->isMainThread()); + for (const auto& key : keys) { + JSValue value = envObject->get(globalObject, key); + RETURN_IF_EXCEPTION(scope, nullptr); + // Windows' process.env Proxy owns an enumerable `toJSON`; it is not an env var. + if (value.isCallable()) + continue; + String str = value.toWTFString(globalObject); + RETURN_IF_EXCEPTION(scope, nullptr); + store->set(String(key.impl()), str); + } + + // Enumerating or reading process.env can run user JS (an accessor, or Windows' + // Proxy traps) that spawns a SHARE_ENV worker and founds the tree first. Defer + // to it instead of overwriting its store and re-swapping process.env. + if (auto* existing = sharedEnvStoreFor(globalObject)) + return existing; + + // Publish before creating the view, which resolves its store via the context. + globalObject->scriptExecutionContext()->setSharedEnvStore(store.get()); + + // Swap this global's process.env to the shared, write-through variant. + auto* shared = createSharedEnvironmentVariablesMap(globalObject).getObject(); + globalObject->m_processEnvObject.set(vm, globalObject, shared); + + auto envIdentifier = JSC::Identifier::fromString(vm, "env"_s); + + // process.env may already be reified as an own property on the process object; + // overwrite it so it resolves to the shared variant. + if (globalObject->hasProcessObject()) { + JSObject* processObject = globalObject->processObject(); + processObject->putDirect(vm, envIdentifier, shared, 0); + } + + // Bun.env reifies to the same object at startup; repoint it too, or it keeps + // observing the orphaned pre-swap env and silently diverges from process.env. + if (globalObject->m_bunObject.isInitialized()) { + JSObject* bunObject = globalObject->bunObject(); + if (bunObject->getDirect(vm, envIdentifier)) + bunObject->putDirect(vm, envIdentifier, shared, JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::DontDelete); + } + + return store; +} + JSValue createEnvironmentVariablesMap(Zig::GlobalObject* globalObject) { VM& vm = globalObject->vm(); @@ -372,14 +771,7 @@ JSValue createEnvironmentVariablesMap(Zig::GlobalObject* globalObject) // Proxy-related env vars need write-back to the native env map so that // fetch()'s getHttpProxyFor() observes runtime changes. - static constexpr ASCIILiteral proxyVarNames[] = { - "HTTP_PROXY"_s, - "http_proxy"_s, - "HTTPS_PROXY"_s, - "https_proxy"_s, - "NO_PROXY"_s, - "no_proxy"_s, - }; + const auto& proxyVarNames = kProxyEnvVarNames; constexpr size_t proxyVarCount = std::size(proxyVarNames); bool hasProxyVar[proxyVarCount] = {}; diff --git a/src/jsc/bindings/JSEnvironmentVariableMap.h b/src/jsc/bindings/JSEnvironmentVariableMap.h index 0de7c81ba4f1..0c77d82ac27e 100644 --- a/src/jsc/bindings/JSEnvironmentVariableMap.h +++ b/src/jsc/bindings/JSEnvironmentVariableMap.h @@ -1,4 +1,5 @@ #include "root.h" +#include "SharedEnvStore.h" namespace Zig { class GlobalObject; @@ -12,4 +13,14 @@ namespace Bun { JSC::JSValue createEnvironmentVariablesMap(Zig::GlobalObject* globalObject); +// worker_threads SHARE_ENV: a `process.env` whose reads/writes/enumeration go +// through the SharedEnvStore of the tree its global belongs to. +JSC::JSValue createSharedEnvironmentVariablesMap(Zig::GlobalObject* globalObject); + +// Resolve the SHARE_ENV store for a worker spawned from `globalObject`: the +// spawning thread's existing store if it has one, otherwise a fresh store seeded +// from its `process.env` (which is then swapped to a write-through view). +// Returns null if seeding threw. +RefPtr ensureSharedEnvStoreForWorker(Zig::GlobalObject* globalObject); + } diff --git a/src/jsc/bindings/ScriptExecutionContext.cpp b/src/jsc/bindings/ScriptExecutionContext.cpp index 66a581369c58..d5ada4518581 100644 --- a/src/jsc/bindings/ScriptExecutionContext.cpp +++ b/src/jsc/bindings/ScriptExecutionContext.cpp @@ -115,19 +115,28 @@ bool ScriptExecutionContext::postTaskTo(ScriptExecutionContextIdentifier identif if (!context) return false; + // A permanently-terminating context never drains its concurrent queue, so a task + // enqueued during teardown would leak its captured refs (e.g. notifyPeerClosed + // pinning the MessagePortPipe) — drop it. Gate on the worker-teardown flag, not + // VM::hasTerminationRequest(), which node:vm {timeout}/{breakOnSigint} sets transiently. + if (context->isTerminating()) + return false; + context->postTaskConcurrently(WTF::move(task)); return true; } -// Identical to the overload above, except `betweenLookupAndEnqueue()` runs -// after the target context is found-live but before the task is enqueued (i.e. -// before the target thread can observe / run / destroy it). The map lock is -// held across the callback. Used by `Worker::dispatchExit` so the worker -// thread can release its create-time ref while the lambda's captured `Ref` -// is still owned by the worker-thread stack — once enqueued, the parent could -// run and destroy it before the calling frame resumes, making any later -// `deref()` on the worker thread potentially the last (~Worker on the wrong -// thread, EventListenerMap thread-UID assert). +// Like the overload above (including the isTerminating() gate — a grandchild's +// dispatchExit can observe its parent context between markTerminating() and +// removeFromContextsMap()), except `betweenLookupAndEnqueue()` runs after the +// target context is found-live but before the task is enqueued (i.e. before +// the target thread can observe / run / destroy it). The map lock is held +// across the callback. Used by `Worker::dispatchExit` so the worker thread can +// release its create-time ref while the lambda's captured `Ref` is still owned +// by the worker-thread stack — once enqueued, the parent could run and destroy +// it before the calling frame resumes, making any later `deref()` on the worker +// thread potentially the last (~Worker on the wrong thread, EventListenerMap +// thread-UID assert). bool ScriptExecutionContext::postTaskTo(ScriptExecutionContextIdentifier identifier, NOESCAPE const WTF::Function& betweenLookupAndEnqueue, Function&& task) { Locker locker { allScriptExecutionContextsMapLock }; @@ -136,6 +145,9 @@ bool ScriptExecutionContext::postTaskTo(ScriptExecutionContextIdentifier identif if (!context) return false; + if (context->isTerminating()) + return false; + betweenLookupAndEnqueue(); context->postTaskConcurrently(WTF::move(task)); return true; @@ -151,9 +163,10 @@ void ScriptExecutionContext::didCreateDestructionObserver(ContextDestructionObse void ScriptExecutionContext::willDestroyDestructionObserver(ContextDestructionObserver& observer) { -#if ASSERT_ENABLED - ASSERT(!m_inScriptExecutionContextDestructor); -#endif // ASSERT_ENABLED + // This can legitimately run during context teardown: a ContextDestructionObserver + // (e.g. a MessagePort kept alive by a pending message-dispatch task) may have its + // last ref released from within ~ScriptExecutionContext. remove() is safe during + // teardown (the set is drained one element at a time, not iterated concurrently). m_destructionObservers.remove(&observer); } diff --git a/src/jsc/bindings/ScriptExecutionContext.h b/src/jsc/bindings/ScriptExecutionContext.h index 78f66385a679..e7a68ce4fd13 100644 --- a/src/jsc/bindings/ScriptExecutionContext.h +++ b/src/jsc/bindings/ScriptExecutionContext.h @@ -2,6 +2,7 @@ #include "root.h" #include "ActiveDOMObject.h" +#include "SharedEnvStore.h" #include #include #include @@ -126,6 +127,17 @@ class ScriptExecutionContext : public CanMakeWeakPtr, pu ScriptExecutionContextIdentifier identifier() const { return m_identifier; } bool isWorker = false; + + // Set once when the context is permanently shutting down (WebWorker__teardownJSCVM). + // Unlike VM::hasTerminationRequest(), never set transiently (node:vm {timeout}). + void markTerminating() { m_isTerminating.store(true, std::memory_order_release); } + bool isTerminating() const { return m_isTerminating.load(std::memory_order_acquire); } + + // Non-null once this thread joins a `worker_threads` SHARE_ENV tree; every + // thread in the tree holds a ref to the same store. + Bun::SharedEnvStore* sharedEnvStore() const { return m_sharedEnvStore.get(); } + void setSharedEnvStore(Bun::SharedEnvStore& store) { m_sharedEnvStore = &store; } + void setGlobalObject(JSC::JSGlobalObject* globalObject) { m_globalObject = globalObject; @@ -135,6 +147,8 @@ class ScriptExecutionContext : public CanMakeWeakPtr, pu static ScriptExecutionContext* getMainThreadScriptExecutionContext(); private: + std::atomic m_isTerminating { false }; + RefPtr m_sharedEnvStore; JSC::VM* m_vm = nullptr; JSC::JSGlobalObject* m_globalObject = nullptr; WTF::URL m_url = WTF::URL(); diff --git a/src/jsc/bindings/SharedEnvStore.h b/src/jsc/bindings/SharedEnvStore.h new file mode 100644 index 000000000000..752282f953ca --- /dev/null +++ b/src/jsc/bindings/SharedEnvStore.h @@ -0,0 +1,95 @@ +#pragma once + +#include "root.h" +#include +#include +#include +#include +#include + +namespace Bun { + +// worker_threads `env: SHARE_ENV`. Node shares the *creating thread's* KVStore by +// reference (node_worker.cc: `env_vars = env->env_vars()`), so disjoint SHARE_ENV +// chains stay isolated. Refcounted: threads in a tree die in any order. +class SharedEnvStore : public ThreadSafeRefCounted { +public: + // `mainRooted` records whether the founding thread was the main thread. Node roots + // a main-founded tree at its RealEnvStore, so *any* thread writing through the tree + // reaches the OS environment; a tree founded by a snapshot worker never does. + static Ref create(bool mainRooted) { return adoptRef(*new SharedEnvStore(mainRooted)); } + bool isMainRooted() const { return m_mainRooted; } + + String get(const String& key) + { + Locker locker { m_lock }; + auto it = m_map.find(normalizeKey(key)); + if (it == m_map.end()) + return String(); + return it->value.value.isolatedCopy(); + } + + // Key on the normalized form, keep the case first written. `add` leaves an + // existing entry's name alone (unlike `set`), so overwrites preserve the case. + void set(const String& key, const String& value) + { + Locker locker { m_lock }; + String normalized = normalizeKey(key).isolatedCopy(); + auto result = m_map.add(normalized, Entry {}); + if (result.isNewEntry) { +#if OS(WINDOWS) + result.iterator->value.name = key.isolatedCopy(); +#else + result.iterator->value.name = normalized; +#endif + } + result.iterator->value.value = value.isolatedCopy(); + } + + void remove(const String& key) + { + Locker locker { m_lock }; + m_map.remove(normalizeKey(key)); + } + + Vector keys() + { + Locker locker { m_lock }; + Vector out; + out.reserveInitialCapacity(m_map.size()); + for (const auto& entry : m_map.values()) + out.append(entry.name.isolatedCopy()); + return out; + } + + // Windows env keys are case-insensitive. This follows bun's own Windows env + // object, not node: node only folds case for a main-rooted tree (RealEnvStore), + // and is case-sensitive for one rooted at a snapshot worker (MapKVStore). + static ALWAYS_INLINE String normalizeKey(const String& key) + { +#if OS(WINDOWS) + return key.convertToASCIIUppercase(); +#else + return key; +#endif + } + +private: + explicit SharedEnvStore(bool mainRooted) + : m_mainRooted(mainRooted) + { + } + + const bool m_mainRooted; + + // `name` is the key as first written; on POSIX it always equals the map key. + struct Entry { + String name; + String value; + }; + + Lock m_lock; + HashMap m_map WTF_GUARDED_BY_LOCK(m_lock); +}; + +} // namespace Bun diff --git a/src/jsc/bindings/ZigGlobalObject.cpp b/src/jsc/bindings/ZigGlobalObject.cpp index e792f92b1e10..20ddb6c5147d 100644 --- a/src/jsc/bindings/ZigGlobalObject.cpp +++ b/src/jsc/bindings/ZigGlobalObject.cpp @@ -568,6 +568,13 @@ extern "C" JSC::JSGlobalObject* Zig__GlobalObject__create(void* console_client, env->putDirectMayBeIndex(globalObject, JSC::Identifier::fromString(vm, WTF::move(k.key)), strings.at(i++)); } globalObject->m_processEnvObject.set(vm, globalObject, env); + } else if (options.sharedEnvStore) { + // worker_threads SHARE_ENV: join the env tree the spawning thread + // resolved. Consumed like options.env, and published on the context + // before the view, which resolves its store through the context. + RefPtr store = std::exchange(options.sharedEnvStore, nullptr); + globalObject->scriptExecutionContext()->setSharedEnvStore(*store); + globalObject->m_processEnvObject.set(vm, globalObject, Bun::createSharedEnvironmentVariablesMap(globalObject).getObject()); } // Ensure that the TerminationException singleton is constructed. Workers need this so @@ -637,6 +644,14 @@ extern "C" JSC::JSGlobalObject* Zig__GlobalObject__createForTestIsolation(Zig::G // new global and adopt the refs before unprotecting the old one. globalObject->adoptNapiEnvsForTestIsolation(oldGlobal); + // The swap replaces this thread's ScriptExecutionContext. If the thread had + // joined a worker_threads SHARE_ENV tree, carry it over (store + the + // write-through process.env) so it doesn't silently leave the tree. + if (auto* sharedEnvStore = oldContext->sharedEnvStore()) { + globalObject->scriptExecutionContext()->setSharedEnvStore(*sharedEnvStore); + globalObject->m_processEnvObject.set(vm, globalObject, Bun::createSharedEnvironmentVariablesMap(globalObject).getObject()); + } + // Drop the permanent root on the previous global so its module registry, // require.cache, and user objects become collectable. JSC's CodeCache and // Bun's RuntimeTranspilerCache are VM/process scoped and survive. @@ -3986,6 +4001,13 @@ void GlobalObject::adoptNapiEnvsForTestIsolation(GlobalObject* oldGlobal) } void GlobalObject::setNodeWorkerEnvironmentData(JSMap* data) { m_nodeWorkerEnvironmentData.set(vm(), this, data); } +void GlobalObject::setNodeWorkerEntryEvaluatedHook(JSObject* hook) +{ + if (hook) + m_nodeWorkerEntryEvaluatedHook.set(vm(), this, hook); + else + m_nodeWorkerEntryEvaluatedHook.clear(); +} extern "C" void Bun__InspectorConnection__disconnectAllOnExit(Zig::GlobalObject*); @@ -3995,6 +4017,11 @@ extern "C" void Zig__GlobalObject__destructOnExit(Zig::GlobalObject* globalObjec if (vm.entryScope) { vm.entryScope = nullptr; } + // Mirror WebWorker__teardownJSCVM: mark this context terminating so late + // worker→parent posts (scheduleDrain/notifyPeerClosed) return false instead + // of enqueueing a ConcurrentTask that leaks past the last drain. + if (auto* ctx = globalObject->scriptExecutionContext()) + ctx->markTerminating(); Bun__InspectorConnection__disconnectAllOnExit(globalObject); // Hold a Ref so the RunLoop is guaranteed to outlive the VM teardown below. Ref runLoop = vm.runLoop(); diff --git a/src/jsc/bindings/ZigGlobalObject.h b/src/jsc/bindings/ZigGlobalObject.h index bc5495da8572..9f2e8298df95 100644 --- a/src/jsc/bindings/ZigGlobalObject.h +++ b/src/jsc/bindings/ZigGlobalObject.h @@ -511,6 +511,9 @@ class GlobalObject : public Bun::GlobalScope { /* Supports getEnvironmentData() and setEnvironmentData(), and is cloned into newly-created */ \ /* Workers. Initialized in createNodeWorkerThreadsBinding. */ \ V(private, WriteBarrier, m_nodeWorkerEnvironmentData) \ + /* setupMainThreadPort's drain callback; run once by WebWorker__dispatchOnline */ \ + /* after entry-module evaluation. Stored here (not on globalThis) so user code can't clobber it. */ \ + V(private, WriteBarrier, m_nodeWorkerEntryEvaluatedHook) \ \ /* The original, unmodified Error.prepareStackTrace. */ \ /* */ \ @@ -738,6 +741,8 @@ class GlobalObject : public Bun::GlobalScope { JSMap* nodeWorkerEnvironmentData() { return m_nodeWorkerEnvironmentData.get(); } void setNodeWorkerEnvironmentData(JSMap* data); + JSObject* nodeWorkerEntryEvaluatedHook() { return m_nodeWorkerEntryEvaluatedHook.get(); } + void setNodeWorkerEntryEvaluatedHook(JSObject* hook); Bun::CommonStrings& commonStrings() { return m_commonStrings; } Bun::Http2CommonStrings& http2CommonStrings() { return m_http2CommonStrings; } diff --git a/src/jsc/bindings/napi.h b/src/jsc/bindings/napi.h index 73f2f639eedd..b83796cd51f5 100644 --- a/src/jsc/bindings/napi.h +++ b/src/jsc/bindings/napi.h @@ -433,9 +433,15 @@ struct NapiEnv : public WTF::RefCounted { // Returns true if finalizers from this module need to be scheduled for the next tick after garbage collection, instead of running during garbage collection inline bool mustDeferFinalizers() const { - // Even when we'd normally have to defer the finalizer, if this is happening during the VM's last chance to finalize, - // we can't defer the finalizer and have to call it now. - return m_napiModule.nm_version != NAPI_VERSION_EXPERIMENTAL && !isVMTerminating(); + // The deferred path (NapiFinalizerTask::schedule) is responsible for the + // shutdown case: once is_shutting_down() it either pushes a cleanup hook + // (if those haven't run yet) or drops the task (if they have). Running a + // non-EXPERIMENTAL finalizer immediately during the final collectNow() is + // never safe — by then on_exit() has already run cleanup hooks (including + // the napi_set_instance_data finalizer that frees per-addon state the + // object finalizer reads), the heap is sweeping (no allocation, no handle + // scope), and napi_call_function returns the termination exception. + return m_napiModule.nm_version != NAPI_VERSION_EXPERIMENTAL; } inline bool isFinishingFinalizers() const { return m_isFinishingFinalizers; } diff --git a/src/jsc/bindings/napi_handle_scope.cpp b/src/jsc/bindings/napi_handle_scope.cpp index e691becdbec0..6c0f7df8d293 100644 --- a/src/jsc/bindings/napi_handle_scope.cpp +++ b/src/jsc/bindings/napi_handle_scope.cpp @@ -143,7 +143,15 @@ extern "C" void NapiHandleScope__close(napi_env env, NapiHandleScopeImpl* curren extern "C" void NapiHandleScope__append(napi_env env, JSC::EncodedJSValue value) { - env->globalObject()->m_currentNapiHandleScopeImpl.get()->append(JSC::JSValue::decode(value)); + // Match toNapi() in napi.h: non-cell values need no rooting, and the + // current handle scope is null when a finalizer runs immediately during + // sweep (NapiHandleScope::open returns nullptr while the mutator is + // sweeping). + JSC::JSValue v = JSC::JSValue::decode(value); + if (!v.isCell()) + return; + if (auto* scope = env->globalObject()->m_currentNapiHandleScopeImpl.get()) + scope->append(v); } extern "C" bool NapiHandleScope__escape(NapiHandleScopeImpl* handleScope, JSC::EncodedJSValue value) diff --git a/src/jsc/bindings/webcore/EventEmitter.cpp b/src/jsc/bindings/webcore/EventEmitter.cpp index c9ab566b4103..48148ff4e3c6 100644 --- a/src/jsc/bindings/webcore/EventEmitter.cpp +++ b/src/jsc/bindings/webcore/EventEmitter.cpp @@ -116,8 +116,7 @@ bool EventEmitter::emitForBindings(const Identifier& eventType, const MarkedArgu if (!scriptExecutionContext()) return false; - emit(eventType, arguments); - return true; + return emit(eventType, arguments); } bool EventEmitter::emit(const Identifier& eventType, const MarkedArgumentBuffer& arguments) diff --git a/src/jsc/bindings/webcore/JSMessageEvent.cpp b/src/jsc/bindings/webcore/JSMessageEvent.cpp index de658c2f14f2..e06d91fa7008 100644 --- a/src/jsc/bindings/webcore/JSMessageEvent.cpp +++ b/src/jsc/bindings/webcore/JSMessageEvent.cpp @@ -43,9 +43,12 @@ #include "JSDOMWrapperCache.h" #include "JSMessagePort.h" #include "JSServiceWorker.h" +#include #include "JSWindowProxy.h" #include "ScriptExecutionContext.h" #include "WebCoreJSClientData.h" + +extern "C" BunString Bun__inspect_singleline(JSC::JSGlobalObject* globalObject, JSC::JSValue value); #include #include #include @@ -152,16 +155,35 @@ template<> MessageEvent::Init convertDictionary(JSGlobalObje RETURN_IF_EXCEPTION(throwScope, {}); } if (!portsValue.isUndefined()) { - result.ports = convert>>( - lexicalGlobalObject, - portsValue, - [](JSGlobalObject& lexicalGlobalObject, ThrowScope& throwScope) { - Bun::ERR::INVALID_ARG_TYPE(throwScope, - &lexicalGlobalObject, - "MessageEvent constructor: Expected every item of eventInitDict.ports to be an instance of MessagePort."_s); - }, - "MessageEvent constructor"_s, - "eventInitDict.ports"_s); + // node-compatible validation messages (with the offending value inspected). + // Single Symbol.iterator walk: Proxy/getter elements are read once, and the + // detailed ports[i] message covers every iterable shape (Array/Set/generator). + JSValue iterFn; + bool iterable = portsValue.isObject(); + if (iterable) { + iterFn = portsValue.get(&lexicalGlobalObject, vm.propertyNames->iteratorSymbol); + RETURN_IF_EXCEPTION(throwScope, {}); + iterable = iterFn.isCallable(); + } + if (!iterable) { + auto inspected = Bun__inspect_singleline(&lexicalGlobalObject, portsValue).transferToWTFString(); + RETURN_IF_EXCEPTION(throwScope, {}); + throwTypeError(&lexicalGlobalObject, throwScope, makeString("MessageEvent constructor: eventInitDict.ports ("_s, inspected, ") is not iterable."_s)); + return {}; + } + unsigned i = 0; + forEachInIterable(lexicalGlobalObject, asObject(portsValue), iterFn, [&result, &i](JSC::VM& vm, JSC::JSGlobalObject& g, JSC::JSValue item) { + auto scope = DECLARE_THROW_SCOPE(vm); + auto* wrapped = item.isCell() ? JSMessagePort::toWrapped(vm, item) : nullptr; + if (!wrapped) { + auto inspected = Bun__inspect_singleline(&g, item).transferToWTFString(); + RETURN_IF_EXCEPTION(scope, ); + throwTypeError(&g, scope, makeString("MessageEvent constructor: Expected eventInitDict.ports["_s, i, "] (\""_s, inspected, "\") to be an instance of MessagePort."_s)); + return; + } + result.ports.append(wrapped); + ++i; + }); RETURN_IF_EXCEPTION(throwScope, {}); } else result.ports = Converter>>::ReturnType {}; @@ -174,7 +196,10 @@ template<> MessageEvent::Init convertDictionary(JSGlobalObje } if (!sourceValue.isUndefinedOrNull()) { result.source = convert>>(lexicalGlobalObject, sourceValue, [&sourceValue](JSGlobalObject& lexicalGlobalObject, ThrowScope& throwScope) { - Bun::ERR::INVALID_ARG_TYPE(throwScope, &lexicalGlobalObject, "eventInitDict.source"_s, "MessagePort"_s, sourceValue); + auto inspected = Bun__inspect_singleline(&lexicalGlobalObject, sourceValue).transferToWTFString(); + if (throwScope.exception()) [[unlikely]] + return; + throwTypeError(&lexicalGlobalObject, throwScope, makeString("MessageEvent constructor: Expected eventInitDict.source (\""_s, inspected, "\") to be an instance of MessagePort."_s)); }); RETURN_IF_EXCEPTION(throwScope, {}); } else { diff --git a/src/jsc/bindings/webcore/JSMessagePort.cpp b/src/jsc/bindings/webcore/JSMessagePort.cpp index 543477b0caa6..6bc8c19950ed 100644 --- a/src/jsc/bindings/webcore/JSMessagePort.cpp +++ b/src/jsc/bindings/webcore/JSMessagePort.cpp @@ -252,7 +252,7 @@ static inline JSC::EncodedJSValue jsMessagePortPrototypeFunction_postMessage1Bod auto message = convert(*lexicalGlobalObject, argument0.value()); RETURN_IF_EXCEPTION(throwScope, {}); EnsureStillAliveScope argument1 = callFrame->uncheckedArgument(1); - auto transfer = convert>(*lexicalGlobalObject, argument1.value()); + auto transfer = convertTransferList(*lexicalGlobalObject, argument1.value(), "Optional transferList argument must be an iterable"_s, BadTransferElement::ThrowDataCloneError); RETURN_IF_EXCEPTION(throwScope, {}); RELEASE_AND_RETURN(throwScope, JSValue::encode(toJS(*lexicalGlobalObject, throwScope, [&]() -> decltype(auto) { return impl.postMessage(*uncheckedDowncast(lexicalGlobalObject), WTF::move(message), WTF::move(transfer)); }))); } @@ -268,8 +268,24 @@ static inline JSC::EncodedJSValue jsMessagePortPrototypeFunction_postMessage2Bod auto message = convert(*lexicalGlobalObject, argument0.value()); RETURN_IF_EXCEPTION(throwScope, {}); EnsureStillAliveScope argument1 = callFrame->argument(1); - auto options = convert>(*lexicalGlobalObject, argument1.value()); - RETURN_IF_EXCEPTION(throwScope, {}); + // Not convert>: that path is shared with + // structuredClone(), where node reports a non-object transfer element as a TypeError. + // From postMessage() the same element is a DataCloneError. + StructuredSerializeOptions options; + JSValue optionsValue = argument1.value(); + if (!optionsValue.isUndefinedOrNull()) { + auto* optionsObject = optionsValue.getObject(); + if (!optionsObject) [[unlikely]] { + throwTypeError(lexicalGlobalObject, throwScope); + return {}; + } + JSValue transferValue = optionsObject->get(lexicalGlobalObject, Identifier::fromString(vm, "transfer"_s)); + RETURN_IF_EXCEPTION(throwScope, {}); + if (!transferValue.isUndefined()) { + options.transfer = convertTransferList(*lexicalGlobalObject, transferValue, "Optional options.transfer argument must be an iterable"_s, BadTransferElement::ThrowDataCloneError); + RETURN_IF_EXCEPTION(throwScope, {}); + } + } RELEASE_AND_RETURN(throwScope, JSValue::encode(toJS(*lexicalGlobalObject, throwScope, [&]() -> decltype(auto) { return impl.postMessage(*uncheckedDowncast(lexicalGlobalObject), WTF::move(message), WTF::move(options)); }))); } @@ -289,6 +305,10 @@ static inline JSC::EncodedJSValue jsMessagePortPrototypeFunction_postMessageOver RELEASE_AND_RETURN(throwScope, (jsMessagePortPrototypeFunction_postMessage2Body(lexicalGlobalObject, callFrame, castedThis))); if (distinguishingArg.isUndefinedOrNull()) RELEASE_AND_RETURN(throwScope, (jsMessagePortPrototypeFunction_postMessage2Body(lexicalGlobalObject, callFrame, castedThis))); + // node: a non-object transferList (number/string/boolean/symbol) is + // rejected as not-an-iterable before any iteration is attempted. + if (!distinguishingArg.isObject()) + return throwVMError(lexicalGlobalObject, throwScope, createError(lexicalGlobalObject, Bun::ErrorCode::ERR_INVALID_ARG_TYPE, "Optional transferList argument must be an iterable"_s)); { bool success = hasIteratorMethod(lexicalGlobalObject, distinguishingArg); RETURN_IF_EXCEPTION(throwScope, {}); diff --git a/src/jsc/bindings/webcore/JSStructuredSerializeOptions.cpp b/src/jsc/bindings/webcore/JSStructuredSerializeOptions.cpp index 60ac14880003..a01a846fc216 100644 --- a/src/jsc/bindings/webcore/JSStructuredSerializeOptions.cpp +++ b/src/jsc/bindings/webcore/JSStructuredSerializeOptions.cpp @@ -22,13 +22,70 @@ #include "JSStructuredSerializeOptions.h" #include "JSDOMConvertObject.h" +#include "ErrorCode.h" +#include "JSDOMExceptionHandling.h" +#include "JSDOMException.h" +#include "ZigGlobalObject.h" #include "JSDOMConvertSequences.h" #include #include +#include namespace WebCore { using namespace JSC; +Vector> convertTransferList(JSGlobalObject& lexicalGlobalObject, JSValue transferValue, ASCIILiteral notIterableMessage, BadTransferElement badElement) +{ + auto& vm = JSC::getVM(&lexicalGlobalObject); + auto throwScope = DECLARE_THROW_SCOPE(vm); + Vector> result; + + // Mirror Node's ReadIterable: shape failures (not-object, Symbol.iterator + // not callable, iterator/next/result malformed) throw ERR_INVALID_ARG_TYPE; + // user code that THROWS during iteration propagates unchanged. + auto notIterable = [&]() -> Vector> { + throwScope.throwException(&lexicalGlobalObject, createError(defaultGlobalObject(&lexicalGlobalObject), Bun::ErrorCode::ERR_INVALID_ARG_TYPE, notIterableMessage)); + return {}; + }; + if (!transferValue.isObject()) + return notIterable(); + JSValue iteratorMethod = transferValue.get(&lexicalGlobalObject, vm.propertyNames->iteratorSymbol); + RETURN_IF_EXCEPTION(throwScope, {}); + if (!iteratorMethod.isCallable()) + return notIterable(); + JSValue iterator = JSC::call(&lexicalGlobalObject, iteratorMethod, transferValue, JSC::ArgList(), "transferList[Symbol.iterator]"_s); + RETURN_IF_EXCEPTION(throwScope, {}); + if (!iterator.isObject()) + return notIterable(); + JSValue next = iterator.get(&lexicalGlobalObject, vm.propertyNames->next); + RETURN_IF_EXCEPTION(throwScope, {}); + if (!next.isCallable()) + return notIterable(); + while (true) { + JSValue step = JSC::call(&lexicalGlobalObject, next, iterator, JSC::ArgList(), "transferList iterator next"_s); + RETURN_IF_EXCEPTION(throwScope, {}); + if (!step.isObject()) + return notIterable(); + JSValue done = step.get(&lexicalGlobalObject, vm.propertyNames->done); + RETURN_IF_EXCEPTION(throwScope, {}); + if (done.toBoolean(&lexicalGlobalObject)) + break; + JSValue element = step.get(&lexicalGlobalObject, vm.propertyNames->value); + RETURN_IF_EXCEPTION(throwScope, {}); + // The arg IS iterable, so a bad *element* is not a "must be an iterable" error. + // node: DataCloneError from port.postMessage(), TypeError from structuredClone(). + if (!element.isObject()) { + if (badElement == BadTransferElement::ThrowDataCloneError) + propagateException(lexicalGlobalObject, throwScope, Exception { DataCloneError, "Found invalid value in transferList."_s }); + else + throwTypeError(&lexicalGlobalObject, throwScope); + return {}; + } + result.append(Strong { vm, asObject(element) }); + } + return result; +} + template<> StructuredSerializeOptions convertDictionary(JSGlobalObject& lexicalGlobalObject, JSValue value) { auto& vm = JSC::getVM(&lexicalGlobalObject); @@ -48,7 +105,7 @@ template<> StructuredSerializeOptions convertDictionary>(lexicalGlobalObject, transferValue); + result.transfer = convertTransferList(lexicalGlobalObject, transferValue, "Optional options.transfer argument must be an iterable"_s, BadTransferElement::ThrowTypeError); RETURN_IF_EXCEPTION(throwScope, {}); } else result.transfer = Converter>::ReturnType {}; diff --git a/src/jsc/bindings/webcore/JSStructuredSerializeOptions.h b/src/jsc/bindings/webcore/JSStructuredSerializeOptions.h index 8fc8fddd0a16..abf28de79c0a 100644 --- a/src/jsc/bindings/webcore/JSStructuredSerializeOptions.h +++ b/src/jsc/bindings/webcore/JSStructuredSerializeOptions.h @@ -25,6 +25,14 @@ namespace WebCore { +// Walks any iterable into a transfer list. Shape failures (not-object, no callable +// Symbol.iterator, malformed iterator/next/result) throw ERR_INVALID_ARG_TYPE with +// `notIterableMessage`. A non-object *element* differs by caller: node's +// port.postMessage() reports DataCloneError, its structuredClone() a TypeError. +enum class BadTransferElement : uint8_t { ThrowTypeError, + ThrowDataCloneError }; +Vector> convertTransferList(JSC::JSGlobalObject&, JSC::JSValue, ASCIILiteral notIterableMessage, BadTransferElement); + template<> StructuredSerializeOptions convertDictionary(JSC::JSGlobalObject&, JSC::JSValue); } // namespace WebCore diff --git a/src/jsc/bindings/webcore/JSWorker.cpp b/src/jsc/bindings/webcore/JSWorker.cpp index 7f98a4f3a324..50a94a365c2b 100644 --- a/src/jsc/bindings/webcore/JSWorker.cpp +++ b/src/jsc/bindings/webcore/JSWorker.cpp @@ -20,6 +20,15 @@ #include "config.h" #include "JSWorker.h" +#include "BunCPUProfiler.h" +#if OS(WINDOWS) +#include +#else +#include +#if defined(__APPLE__) +#include +#endif +#endif #include "ActiveDOMObject.h" #include "EventNames.h" @@ -62,6 +71,7 @@ #include #include "SerializedScriptValue.h" #include "BunProcess.h" +#include "JSEnvironmentVariableMap.h" #include namespace WebCore { @@ -74,6 +84,10 @@ static JSC_DECLARE_HOST_FUNCTION(jsWorkerPrototypeFunction_postMessage); static JSC_DECLARE_HOST_FUNCTION(jsWorkerPrototypeFunction_unref); static JSC_DECLARE_HOST_FUNCTION(jsWorkerPrototypeFunction_ref); static JSC_DECLARE_HOST_FUNCTION(jsWorkerPrototypeFunction_getHeapSnapshot); +static JSC_DECLARE_HOST_FUNCTION(jsWorkerPrototypeFunction_getHeapStatistics); +static JSC_DECLARE_HOST_FUNCTION(jsWorkerPrototypeFunction_startCpuProfileInternal); +static JSC_DECLARE_HOST_FUNCTION(jsWorkerPrototypeFunction_stopCpuProfileInternal); +static JSC_DECLARE_HOST_FUNCTION(jsWorkerPrototypeFunction_cpuUsageInternal); // Attributes @@ -144,6 +158,9 @@ template<> JSC::EncodedJSValue JSC_HOST_CALL_ATTRIBUTES JSWorkerDOMConstructor:: EnsureStillAliveScope argument1 = callFrame->argument(1); WorkerOptions options {}; + // Founding an env tree swaps the parent's process.env, so it is deferred until + // every option has validated (below). + bool shareEnv = false; JSValue nodeWorkerObject {}; if (callFrame->argumentCount() == 3) { nodeWorkerObject = callFrame->argument(2); @@ -224,41 +241,58 @@ template<> JSC::EncodedJSValue JSC_HOST_CALL_ATTRIBUTES JSWorkerDOMConstructor:: RETURN_IF_EXCEPTION(throwScope, {}); } - auto envValue = optionsObject->getIfPropertyExists(lexicalGlobalObject, Identifier::fromString(vm, "env"_s)); + auto shareEnvValue = optionsObject->getIfPropertyExists(lexicalGlobalObject, Identifier::fromString(vm, "shareEnv"_s)); RETURN_IF_EXCEPTION(throwScope, {}); - // for now, we don't permit SHARE_ENV, because the behavior isn't implemented - if (envValue && !(envValue.isObject() || envValue.isUndefinedOrNull())) { - return Bun::ERR::INVALID_ARG_TYPE(throwScope, globalObject, "options.env"_s, "object or one of undefined, null, or worker_threads.SHARE_ENV"_s, envValue); + if (shareEnvValue) { + shareEnv = shareEnvValue.toBoolean(lexicalGlobalObject); } - JSObject* envObject = nullptr; - if (envValue && envValue.isCell()) { - envObject = dynamicDowncast(envValue); - } else if (globalObject->m_processEnvObject.isInitialized()) { - envObject = globalObject->processEnvObject(); + auto envValue = optionsObject->getIfPropertyExists(lexicalGlobalObject, Identifier::fromString(vm, "env"_s)); + RETURN_IF_EXCEPTION(throwScope, {}); + // Recognize the SHARE_ENV registry symbol directly so `new globalThis.Worker(url, { env: SHARE_ENV })` + // (which bypasses the node:worker_threads wrapper) shares env instead of throwing + // ERR_INVALID_ARG_TYPE on its own sentinel. + if (envValue && envValue.isSymbol()) { + auto key = vm.symbolRegistry().symbolForKey("nodejs.worker_threads.SHARE_ENV"_s); + if (&asSymbol(envValue)->uid() == key.ptr()) { + shareEnv = true; + } } - if (envObject) { - if (!envObject->staticPropertiesReified()) { - envObject->reifyAllStaticProperties(globalObject); - RETURN_IF_EXCEPTION(throwScope, {}); + if (!shareEnv) { + if (envValue && !(envValue.isObject() || envValue.isUndefinedOrNull())) { + return Bun::ERR::INVALID_ARG_TYPE(throwScope, globalObject, "options.env"_s, "object or one of undefined, null, or worker_threads.SHARE_ENV"_s, envValue); } + JSObject* envObject = nullptr; - JSC::PropertyNameArrayBuilder keys(vm, JSC::PropertyNameMode::Strings, JSC::PrivateSymbolMode::Exclude); - envObject->methodTable()->getOwnPropertyNames(envObject, lexicalGlobalObject, keys, JSC::DontEnumPropertiesMode::Exclude); - RETURN_IF_EXCEPTION(throwScope, {}); + if (envValue && envValue.isCell()) { + envObject = dynamicDowncast(envValue); + } else if (globalObject->m_processEnvObject.isInitialized()) { + envObject = globalObject->processEnvObject(); + } - HashMap env; + if (envObject) { + if (!envObject->staticPropertiesReified()) { + envObject->reifyAllStaticProperties(globalObject); + RETURN_IF_EXCEPTION(throwScope, {}); + } - for (const auto& key : keys) { - JSValue value = envObject->get(lexicalGlobalObject, key); + JSC::PropertyNameArrayBuilder keys(vm, JSC::PropertyNameMode::Strings, JSC::PrivateSymbolMode::Exclude); + envObject->methodTable()->getOwnPropertyNames(envObject, lexicalGlobalObject, keys, JSC::DontEnumPropertiesMode::Exclude); RETURN_IF_EXCEPTION(throwScope, {}); - String str = value.toWTFString(lexicalGlobalObject).isolatedCopy(); - RETURN_IF_EXCEPTION(throwScope, {}); - env.add(key.impl()->isolatedCopy(), str); - } - options.env.emplace(WTF::move(env)); + HashMap env; + + for (const auto& key : keys) { + JSValue value = envObject->get(lexicalGlobalObject, key); + RETURN_IF_EXCEPTION(throwScope, {}); + String str = value.toWTFString(lexicalGlobalObject).isolatedCopy(); + RETURN_IF_EXCEPTION(throwScope, {}); + env.add(key.impl()->isolatedCopy(), str); + } + + options.env.emplace(WTF::move(env)); + } } // needed to match the coercion behavior of `String(value)`, which returns a descriptive @@ -300,6 +334,14 @@ template<> JSC::EncodedJSValue JSC_HOST_CALL_ATTRIBUTES JSWorkerDOMConstructor:: } } + // Resolve the spawning thread's env tree (founding one if needed) so disjoint + // SHARE_ENV chains stay isolated. Runs only after every option validated, + // because founding a tree swaps this thread's process.env. + if (shareEnv) { + options.sharedEnvStore = Bun::ensureSharedEnvStoreForWorker(globalObject); + RETURN_IF_EXCEPTION(throwScope, {}); + } + Vector> ports; auto* valueToTransfer = constructEmptyArray(globalObject, nullptr, 2); RETURN_IF_EXCEPTION(throwScope, {}); @@ -398,6 +440,10 @@ static const HashTableValue JSWorkerPrototypeTableValues[] = { { "threadId"_s, JSC::PropertyAttribute::CustomAccessor | JSC::PropertyAttribute::DOMAttribute | JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::DontDelete, NoIntrinsic, { HashTableValue::GetterSetterType, jsWorker_threadIdGetter, nullptr } }, { "unref"_s, static_cast(JSC::PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsWorkerPrototypeFunction_unref, 0 } }, { "getHeapSnapshot"_s, static_cast(JSC::PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsWorkerPrototypeFunction_getHeapSnapshot, 0 } }, + { "getHeapStatistics"_s, static_cast(JSC::PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsWorkerPrototypeFunction_getHeapStatistics, 0 } }, + { "startCpuProfileInternal"_s, static_cast(JSC::PropertyAttribute::Function | JSC::PropertyAttribute::DontEnum), NoIntrinsic, { HashTableValue::NativeFunctionType, jsWorkerPrototypeFunction_startCpuProfileInternal, 0 } }, + { "stopCpuProfileInternal"_s, static_cast(JSC::PropertyAttribute::Function | JSC::PropertyAttribute::DontEnum), NoIntrinsic, { HashTableValue::NativeFunctionType, jsWorkerPrototypeFunction_stopCpuProfileInternal, 0 } }, + { "cpuUsageInternal"_s, static_cast(JSC::PropertyAttribute::Function | JSC::PropertyAttribute::DontEnum), NoIntrinsic, { HashTableValue::NativeFunctionType, jsWorkerPrototypeFunction_cpuUsageInternal, 0 } }, }; const ClassInfo JSWorkerPrototype::s_info = { "Worker"_s, &Base::s_info, nullptr, nullptr, CREATE_METHOD_TABLE(JSWorkerPrototype) }; @@ -640,6 +686,15 @@ JSC_DEFINE_HOST_FUNCTION(jsWorkerPrototypeFunction_unref, (JSGlobalObject * lexi return IDLOperation::call(*lexicalGlobalObject, *callFrame, "unref"); } +// Resolve/reject a cross-VM introspection promise on the parent thread. The +// promise lives in Worker::m_pendingCrossVMRequests keyed by reqId; if it was +// already drained by dispatchExit's rejectAllCrossVMRequests, this is a no-op. +static void resolveCrossVMRequest(Worker& worker, uint64_t reqId, ScriptExecutionContext& parentCtx, JSValue value) +{ + if (auto handle = worker.takeCrossVMRequest(reqId)) + handle->resolve(parentCtx.globalObject(), parentCtx.vm(), value); +} + static inline JSC::EncodedJSValue jsWorkerPrototypeFunction_getHeapSnapshotBody(JSC::JSGlobalObject* lexicalGlobalObject, JSC::CallFrame* callFrame, typename IDLOperation::ClassParameter castedThis) { auto* globalObject = defaultGlobalObject(lexicalGlobalObject); @@ -665,31 +720,24 @@ static inline JSC::EncodedJSValue jsWorkerPrototypeFunction_getHeapSnapshotBody( } } + // No up-front isOnline() gate: a worker can post to its parent (e.g. from + // a microtask the entry module scheduled, drained inside + // wait_for_promise_with_termination's tick()) while m_state is still + // Pending. postTaskToWorkerGlobalScope queues into m_pendingTasks for + // Pending and returns false only for Closing/Closed, which the !accepted + // reject below handles. If the worker never reaches Running (entry threw, + // failed to load, unsettled TLA), dispatchExit clears m_pendingTasks on + // the parent thread and rejectAllCrossVMRequests() rejects + frees the + // Strong<>. auto* promise = JSC::JSPromise::create(vm, globalObject->promiseStructure()); - if (!worker.isOnline()) { - promise->reject(vm, - Bun::createError(globalObject, - Bun::ErrorCode::ERR_WORKER_NOT_RUNNING, - "Worker instance not running"_s)); - return JSValue::encode(promise); - } - // Keep the promise alive across the round-trip. Heap-allocate the Strong - // and pass only the raw pointer through the cross-thread lambdas so the - // worker thread never touches the parent VM's HandleSet (Strong has no - // move ctor; capturing it by value would copy-construct/destroy it on the - // worker thread, racing the parent VM's "Strong Handles" GC constraint). - // - // Leak windows (accepted trade-off vs the crash above): - // - task queued but worker terminated before it runs: the lambda is - // destroyed on the worker thread; the raw pointer is dropped and - // promiseHandle leaks in the (still-live) parent VM. Freeing it - // there would be exactly the cross-thread HandleSet mutation we're - // avoiding. Pre-fix the promise already hung in this case. - // - postTaskTo(parentId, …) on the return trip fails: see below. - auto* promiseHandle = new Strong(vm, promise); + // The promise is registered in a parent-side map keyed by reqId; only the id + // crosses threads, so the worker thread never touches the parent VM's + // HandleSet. dispatchExit rejects any entries still in the map (worker + // terminated mid-round-trip), so the promise always settles. + uint64_t reqId = worker.registerCrossVMRequest(vm, promise); auto parentId = globalObject->scriptExecutionContext()->identifier(); - bool accepted = worker.postTaskToWorkerGlobalScope([promiseHandle, parentId](ScriptExecutionContext& workerCtx) { + bool accepted = worker.postTaskToWorkerGlobalScope([reqId, parentId, protectedWorker = Ref { worker }](ScriptExecutionContext& workerCtx) mutable { auto& vm = workerCtx.vm(); vm.ensureHeapProfiler(); auto& heapProfiler = *vm.heapProfiler(); @@ -697,28 +745,190 @@ static inline JSC::EncodedJSValue jsWorkerPrototypeFunction_getHeapSnapshotBody( JSC::BunV8HeapSnapshotBuilder builder(heapProfiler); String snapshot = builder.json(); - // Post the result back. If the parent context is gone this returns - // false and promiseHandle leaks; we cannot safely destroy a - // parent-VM Strong from the worker thread, and the parent VM is - // tearing down anyway. ScriptExecutionContext::postTaskTo(parentId, - [promiseHandle, snapshot = snapshot.isolatedCopy()](ScriptExecutionContext& parentCtx) { - std::unique_ptr> handle(promiseHandle); - handle->get()->resolve(parentCtx.globalObject(), parentCtx.vm(), jsString(parentCtx.vm(), snapshot)); + [reqId, protectedWorker = WTF::move(protectedWorker), snapshot = snapshot.isolatedCopy()](ScriptExecutionContext& parentCtx) { + resolveCrossVMRequest(protectedWorker.get(), reqId, parentCtx, jsString(parentCtx.vm(), snapshot)); }); }); if (!accepted) { - // Worker raced to Closing/Closed between isOnline() and the post. - // Still on the parent thread — safe to destroy the handle here. - delete promiseHandle; - promise->reject(vm, - Bun::createError(globalObject, - Bun::ErrorCode::ERR_WORKER_NOT_RUNNING, - "Worker instance not running"_s)); + // postTaskToWorkerGlobalScope returns false only for Closing/Closed. + worker.takeCrossVMRequest(reqId); + promise->reject(vm, Bun::createError(globalObject, Bun::ErrorCode::ERR_WORKER_NOT_RUNNING, "Worker instance not running"_s)); + } + return JSValue::encode(promise); +} + +static inline JSC::EncodedJSValue jsWorkerPrototypeFunction_getHeapStatisticsBody(JSC::JSGlobalObject* lexicalGlobalObject, JSC::CallFrame* callFrame, typename IDLOperation::ClassParameter castedThis) +{ + auto* globalObject = defaultGlobalObject(lexicalGlobalObject); + auto& vm = JSC::getVM(globalObject); + auto& worker = castedThis->wrapped(); + + auto* promise = JSC::JSPromise::create(vm, globalObject->promiseStructure()); + uint64_t reqId = worker.registerCrossVMRequest(vm, promise); + auto parentId = globalObject->scriptExecutionContext()->identifier(); + bool accepted = worker.postTaskToWorkerGlobalScope([reqId, parentId, protectedWorker = Ref { worker }](ScriptExecutionContext& workerCtx) mutable { + auto& wvm = workerCtx.vm(); + double heapSize = static_cast(wvm.heap.size()); + double capacity = static_cast(wvm.heap.capacity()); + double extra = static_cast(wvm.heap.extraMemorySize()); + ScriptExecutionContext::postTaskTo(parentId, [reqId, protectedWorker = WTF::move(protectedWorker), heapSize, capacity, extra](ScriptExecutionContext& parentCtx) { + auto& pvm = parentCtx.vm(); + auto* go = parentCtx.globalObject(); + JSObject* o = constructEmptyObject(go); + auto set = [&](ASCIILiteral k, double v) { o->putDirect(pvm, Identifier::fromString(pvm, k), jsNumber(v)); }; + double avail = capacity > heapSize ? capacity - heapSize : 0; + set("total_heap_size"_s, heapSize); + set("total_heap_size_executable"_s, heapSize / 2.0); + set("total_physical_size"_s, capacity); + set("total_available_size"_s, avail); + set("used_heap_size"_s, heapSize); + set("heap_size_limit"_s, capacity * 10.0); + set("malloced_memory"_s, heapSize); + set("peak_malloced_memory"_s, capacity); + o->putDirect(pvm, Identifier::fromString(pvm, "does_zap_garbage"_s), jsBoolean(false)); + set("number_of_native_contexts"_s, 1); + set("number_of_detached_contexts"_s, 0); + set("total_global_handles_size"_s, 8192); + set("used_global_handles_size"_s, 2208); + set("external_memory"_s, extra); + set("total_allocated_bytes"_s, heapSize); + resolveCrossVMRequest(protectedWorker.get(), reqId, parentCtx, o); + }); + }); + if (!accepted) { + worker.takeCrossVMRequest(reqId); + promise->reject(vm, Bun::createError(globalObject, Bun::ErrorCode::ERR_WORKER_NOT_RUNNING, "Worker instance not running"_s)); + } + return JSValue::encode(promise); +} + +static inline JSC::EncodedJSValue jsWorkerPrototypeFunction_startCpuProfileInternalBody(JSC::JSGlobalObject* lexicalGlobalObject, JSC::CallFrame* callFrame, typename IDLOperation::ClassParameter castedThis) +{ + auto* globalObject = defaultGlobalObject(lexicalGlobalObject); + auto& vm = JSC::getVM(globalObject); + auto& worker = castedThis->wrapped(); + auto* promise = JSC::JSPromise::create(vm, globalObject->promiseStructure()); + uint64_t reqId = worker.registerCrossVMRequest(vm, promise); + auto parentId = globalObject->scriptExecutionContext()->identifier(); + bool accepted = worker.postTaskToWorkerGlobalScope([reqId, parentId, protectedWorker = Ref { worker }](ScriptExecutionContext& workerCtx) mutable { + if (!Bun::isCPUProfilerRunning()) + Bun::startCPUProfiler(workerCtx.vm()); + ScriptExecutionContext::postTaskTo(parentId, [reqId, protectedWorker = WTF::move(protectedWorker)](ScriptExecutionContext& parentCtx) { + resolveCrossVMRequest(protectedWorker.get(), reqId, parentCtx, jsUndefined()); + }); + }); + if (!accepted) { + worker.takeCrossVMRequest(reqId); + promise->reject(vm, Bun::createError(globalObject, Bun::ErrorCode::ERR_WORKER_NOT_RUNNING, "Worker instance not running"_s)); + } + return JSValue::encode(promise); +} + +static constexpr ASCIILiteral kEmptyCpuProfileJSON = "{\"nodes\":[],\"startTime\":0,\"endTime\":0,\"samples\":[],\"timeDeltas\":[]}"_s; + +static inline JSC::EncodedJSValue jsWorkerPrototypeFunction_stopCpuProfileInternalBody(JSC::JSGlobalObject* lexicalGlobalObject, JSC::CallFrame* callFrame, typename IDLOperation::ClassParameter castedThis) +{ + auto* globalObject = defaultGlobalObject(lexicalGlobalObject); + auto& vm = JSC::getVM(globalObject); + auto& worker = castedThis->wrapped(); + auto* promise = JSC::JSPromise::create(vm, globalObject->promiseStructure()); + uint64_t reqId = worker.registerCrossVMRequest(vm, promise); + auto parentId = globalObject->scriptExecutionContext()->identifier(); + bool accepted = worker.postTaskToWorkerGlobalScope([reqId, parentId, protectedWorker = Ref { worker }](ScriptExecutionContext& workerCtx) mutable { + WTF::String result; + if (Bun::isCPUProfilerRunning()) + Bun::stopCPUProfiler(workerCtx.vm(), &result, nullptr); + if (result.isEmpty()) + result = kEmptyCpuProfileJSON; + ScriptExecutionContext::postTaskTo(parentId, [reqId, protectedWorker = WTF::move(protectedWorker), result = result.isolatedCopy()](ScriptExecutionContext& parentCtx) { + resolveCrossVMRequest(protectedWorker.get(), reqId, parentCtx, jsString(parentCtx.vm(), result)); + }); + }); + if (!accepted) { + // Worker already gone: resolve with an empty profile rather than reject, + // so a handle.stop() after terminate still yields parseable JSON. + worker.takeCrossVMRequest(reqId); + promise->resolve(globalObject, vm, jsString(vm, String(kEmptyCpuProfileJSON))); + } + return JSValue::encode(promise); +} + +static inline JSC::EncodedJSValue jsWorkerPrototypeFunction_cpuUsageInternalBody(JSC::JSGlobalObject* lexicalGlobalObject, JSC::CallFrame* callFrame, typename IDLOperation::ClassParameter castedThis) +{ + auto* globalObject = defaultGlobalObject(lexicalGlobalObject); + auto& vm = JSC::getVM(globalObject); + auto& worker = castedThis->wrapped(); + auto* promise = JSC::JSPromise::create(vm, globalObject->promiseStructure()); + uint64_t reqId = worker.registerCrossVMRequest(vm, promise); + auto parentId = globalObject->scriptExecutionContext()->identifier(); + bool accepted = worker.postTaskToWorkerGlobalScope([reqId, parentId, protectedWorker = Ref { worker }](ScriptExecutionContext&) mutable { + double user = 0; + double sys = 0; +#if OS(WINDOWS) + uv_rusage_t ru; + if (uv_getrusage_thread(&ru) == 0) { + user = static_cast(ru.ru_utime.tv_sec) * 1e6 + static_cast(ru.ru_utime.tv_usec); + sys = static_cast(ru.ru_stime.tv_sec) * 1e6 + static_cast(ru.ru_stime.tv_usec); + } +#elif defined(__APPLE__) + // Darwin has no RUSAGE_THREAD; RUSAGE_SELF would report whole-process + // CPU for every worker. Use mach thread_info for this thread only. + mach_port_t machThread = mach_thread_self(); + thread_basic_info_data_t tinfo; + mach_msg_type_number_t tcount = THREAD_BASIC_INFO_COUNT; + if (thread_info(machThread, THREAD_BASIC_INFO, reinterpret_cast(&tinfo), &tcount) == KERN_SUCCESS) { + user = static_cast(tinfo.user_time.seconds) * 1e6 + static_cast(tinfo.user_time.microseconds); + sys = static_cast(tinfo.system_time.seconds) * 1e6 + static_cast(tinfo.system_time.microseconds); + } + mach_port_deallocate(mach_task_self(), machThread); +#else + struct rusage ru; + memset(&ru, 0, sizeof(ru)); +#if defined(RUSAGE_THREAD) + getrusage(RUSAGE_THREAD, &ru); +#else + getrusage(RUSAGE_SELF, &ru); +#endif + user = static_cast(ru.ru_utime.tv_sec) * 1e6 + static_cast(ru.ru_utime.tv_usec); + sys = static_cast(ru.ru_stime.tv_sec) * 1e6 + static_cast(ru.ru_stime.tv_usec); +#endif + ScriptExecutionContext::postTaskTo(parentId, [reqId, protectedWorker = WTF::move(protectedWorker), user, sys](ScriptExecutionContext& parentCtx) { + auto& pvm = parentCtx.vm(); + auto* go = parentCtx.globalObject(); + JSObject* o = constructEmptyObject(go); + o->putDirect(pvm, Identifier::fromString(pvm, "user"_s), jsNumber(user)); + o->putDirect(pvm, Identifier::fromString(pvm, "system"_s), jsNumber(sys)); + resolveCrossVMRequest(protectedWorker.get(), reqId, parentCtx, o); + }); + }); + if (!accepted) { + worker.takeCrossVMRequest(reqId); + promise->reject(vm, Bun::createError(globalObject, Bun::ErrorCode::ERR_WORKER_NOT_RUNNING, "Worker instance not running"_s)); } return JSValue::encode(promise); } +JSC_DEFINE_HOST_FUNCTION(jsWorkerPrototypeFunction_cpuUsageInternal, (JSGlobalObject * lexicalGlobalObject, CallFrame* callFrame)) +{ + return IDLOperation::call(*lexicalGlobalObject, *callFrame, "cpuUsageInternal"); +} + +JSC_DEFINE_HOST_FUNCTION(jsWorkerPrototypeFunction_startCpuProfileInternal, (JSGlobalObject * lexicalGlobalObject, CallFrame* callFrame)) +{ + return IDLOperation::call(*lexicalGlobalObject, *callFrame, "startCpuProfileInternal"); +} + +JSC_DEFINE_HOST_FUNCTION(jsWorkerPrototypeFunction_stopCpuProfileInternal, (JSGlobalObject * lexicalGlobalObject, CallFrame* callFrame)) +{ + return IDLOperation::call(*lexicalGlobalObject, *callFrame, "stopCpuProfileInternal"); +} + +JSC_DEFINE_HOST_FUNCTION(jsWorkerPrototypeFunction_getHeapStatistics, (JSGlobalObject * lexicalGlobalObject, CallFrame* callFrame)) +{ + return IDLOperation::call(*lexicalGlobalObject, *callFrame, "getHeapStatistics"); +} + JSC_DEFINE_HOST_FUNCTION(jsWorkerPrototypeFunction_getHeapSnapshot, (JSGlobalObject * lexicalGlobalObject, CallFrame* callFrame)) { return IDLOperation::call(*lexicalGlobalObject, *callFrame, "getHeapSnapshot"); diff --git a/src/jsc/bindings/webcore/MessagePort.cpp b/src/jsc/bindings/webcore/MessagePort.cpp index f152366907f2..a59c83b8c29b 100644 --- a/src/jsc/bindings/webcore/MessagePort.cpp +++ b/src/jsc/bindings/webcore/MessagePort.cpp @@ -26,9 +26,12 @@ #include "config.h" #include "MessagePort.h" +#include +#include #include "BunClientData.h" #include "EventNames.h" +#include "JSMessagePort.h" #include "MessageEvent.h" #include "MessagePortPipe.h" #include "MessageWithMessagePorts.h" @@ -36,6 +39,8 @@ #include "WebCoreOpaqueRoot.h" #include +extern "C" void Bun__Process__emitWarning(Zig::GlobalObject*, JSC::EncodedJSValue warning, JSC::EncodedJSValue type, JSC::EncodedJSValue code, JSC::EncodedJSValue ctor); + extern "C" void Bun__eventLoop__incrementRefConcurrently(void* bunVM, int delta); namespace WebCore { @@ -54,35 +59,85 @@ MessagePort::MessagePort(ScriptExecutionContext& context, Ref&& { // The WeakPtrFactory must be initialized on the owning thread. initializeWeakPtrFactory(); + // Any port with a 'message' listener refs the event loop (matching node: a + // listening port keeps its thread alive until closed or unref'd); otherwise a + // buffered message could be lost if its listener is added late. + onDidChangeListener = &MessagePort::onDidChangeListenerImpl; } MessagePort::~MessagePort() { if (!m_isDetached) - m_pipe->close(m_side); + m_pipe->close(m_side, MessagePortPipe::CloseKind::Collected); } ExceptionOr MessagePort::postMessage(JSC::JSGlobalObject& state, JSC::JSValue messageValue, StructuredSerializeOptions&& options) { + // Own a function-level scope: SerializedScriptValue::create() below leaves a + // simulated throw on asan/debug that must be consumed before any nested scope. + auto& vm = state.vm(); + auto warnScope = DECLARE_TOP_EXCEPTION_SCOPE(vm); + // Reject a bad port in the transfer list before serialization, so the post + // aborts before any ArrayBuffer in the list is detached (transfer is atomic). + // Node checks each entry in order: source port first, then detached. + for (auto& transferable : options.transfer) { + JSObject* obj = transferable.get(); + if (auto* jsPort = dynamicDowncast(obj)) { + if (&jsPort->wrapped() == this) + return Exception { DataCloneError, "Transfer list contains source port"_s }; + if (jsPort->wrapped().isDetached() || jsPort->wrapped().isClosing()) + return Exception { DataCloneError, "MessagePort in transfer list is already detached"_s }; + } else if (!obj->inherits()) { + // MessagePort and ArrayBuffer are the only transferables bun serializes; + // node reports anything else here, in order, with this exact message. + return Exception { DataCloneError, "Found invalid value in transferList."_s }; + } + } + Vector> ports; auto messageData = SerializedScriptValue::create(state, messageValue, WTF::move(options.transfer), ports, SerializationForStorage::No, SerializationContext::WorkerPostMessage); - if (messageData.hasException()) + if (messageData.hasException()) { + // Satisfy the exception-check verifier for create()'s simulated throw + // WITHOUT clearing the pending exception (propagateException needs it). + (void)warnScope.exception(); return messageData.releaseException(); + } + RETURN_IF_EXCEPTION(warnScope, {}); if (!isEntangled()) return {}; Vector transferredPorts; if (!ports.isEmpty()) { - // A port may not be posted through itself or its own entangled peer. + // Posting a port's own entangled peer targets the message at itself. + // (The source port itself was rejected before serialization above.) + bool targetsEntangledPeer = false; for (auto& port : ports) { - if (port->pipe() == m_pipe.ptr()) - return Exception { DataCloneError }; + if (port->pipe() == m_pipe.ptr()) { + targetsEntangledPeer = true; + break; + } } + // Detach every transfer-list port up front: transfer is atomic in node, so a + // third-party port must not stay usable even when the message is dropped below. auto disentangled = MessagePort::disentanglePorts(WTF::move(ports)); if (disentangled.hasException()) return disentangled.releaseException(); transferredPorts = disentangled.releaseReturnValue(); + + if (targetsEntangledPeer) { + // Posting the port's own entangled peer: node warns and loses the channel + // rather than throwing. Transferables were already detached above; drop the + // message and close so the dead channel stops reffing the loop. + Bun__Process__emitWarning(defaultGlobalObject(&state), + JSC::JSValue::encode(JSC::jsString(vm, String("The target port was posted to itself, and the communication channel was lost"_s))), + JSC::JSValue::encode(JSC::jsString(vm, String("Warning"_s))), + JSC::JSValue::encode(JSC::jsUndefined()), + JSC::JSValue::encode(JSC::jsUndefined())); + CLEAR_IF_EXCEPTION(warnScope); + close(); + return {}; + } } m_pipe->send(m_side, MessageWithMessagePorts { messageData.releaseReturnValue(), WTF::move(transferredPorts) }); @@ -102,17 +157,59 @@ void MessagePort::start() m_pipe->attach(m_side, context->identifier(), ThreadSafeWeakPtr { *this }); } +void MessagePort::flushQueuedMessagesBeforeClose() +{ + auto* context = scriptExecutionContext(); + if (!context || !context->globalObject()) + return; + // During worker teardown contextDestroyed() runs from ~ScriptExecutionContext + // inside ~VM's lastChanceToFinalize, where allocating a MessageEvent wrapper + // asserts (the heap is being finalized). markTerminating() precedes ~VM, and + // the Rust-side scriptExecutionStatus still reports Running at that point. + if (context->isTerminating()) + return; + auto* globalObject = defaultGlobalObject(context->globalObject()); + // Only deliver while JS can run; during teardown the queue is left for + // m_pipe->close() to drop (it unwinds nested port chains iteratively). + if (Zig::GlobalObject::scriptExecutionStatus(globalObject, globalObject) != ScriptExecutionStatus::Running) + return; + + // Cap iterations like drainAndDispatch() so a 'message' handler re-injecting + // into this closing port (via its entangled peer) can't starve the loop. + size_t limit = std::max(MessagePortPipe::queuedCount(m_pipe->state(m_side)), 1000); + for (size_t i = 0; i < limit; ++i) { + // A handler (or a microtask it queued) may have transferred this port; the + // remaining inbox now belongs to the new owner. drainAndDispatch()'s + // per-iteration ctxId/port re-check guards the same case. + if (m_isDetached) + break; + auto message = m_pipe->takeOne(m_side); + if (!message) + break; + dispatchOneMessage(*context, WTF::move(*message)); + if (globalObject->drainMicrotasks()) + break; // termination pending + } +} + void MessagePort::close() { - if (m_isDetached) + if (m_isDetached || m_isClosing) return; + m_isClosing = true; + + // Only an in-flight drain finishes: node keeps delivering the rest of the batch + // when a 'message' handler calls close(), but drops everything still queued when + // close() runs outside a dispatch. Reentrant close() is short-circuited by + // m_isClosing; later sends are rejected by the pipe's Closed check. + if (m_isDispatching) + flushQueuedMessagesBeforeClose(); + m_isDetached = true; // m_pipe is held for the port's whole lifetime (the GC thread reads // it in hasPendingActivity()); marking our side Closed is sufficient. - m_pipe->close(m_side); - - removeAllEventListeners(); + m_pipe->close(m_side, MessagePortPipe::CloseKind::Explicit); // Release the self-reference taken by jsRef() (set when .onmessage is // assigned or .ref() is called from JS). The JS .close() binding calls @@ -127,6 +224,70 @@ void MessagePort::close() context->unrefEventLoop(); deref(); } + + // close() can run without a prior jsUnref() (warn-and-close, contextDestroyed()); + // clear the listener keepalive so a later listener add can't re-ref the loop. + if (m_isRefd) { + m_isRefd = false; + updateListenerEventLoopRef(); + } + + // Defer 'close' to a task (node fires it at uv close-callback timing, i.e. + // after sync code and microtasks), so a listener added after close() still + // observes it and close(cb) interleaves with other listeners. Never while the + // context is terminating: contextDestroyed() runs after the loop's queue was + // drained for shutdown, so the task would never run and would outlive the VM. + auto* context = scriptExecutionContext(); + if (context && !context->isTerminating()) { + m_closeEventPending.store(true, std::memory_order_release); + context->postTask([protectedThis = Ref { *this }](ScriptExecutionContext&) { + protectedThis->dispatchCloseEvent(); + protectedThis->removeAllEventListeners(); + protectedThis->m_closeEventPending.store(false, std::memory_order_release); + }); + } else { + removeAllEventListeners(); + } +} + +void MessagePort::dispatchCloseEvent() +{ + if (m_closeEventDispatched) + return; + m_closeEventDispatched = true; + auto* context = scriptExecutionContext(); + if (!context || !context->globalObject()) + return; + // No JS may run during worker teardown (see flushQueuedMessagesBeforeClose). + if (context->isTerminating()) + return; + auto* globalObject = defaultGlobalObject(context->globalObject()); + // Bypass the m_isDetached guard in MessagePort::dispatchEvent — the deferred + // close task runs after m_isDetached is set. + if (Zig::GlobalObject::scriptExecutionStatus(globalObject, globalObject) == ScriptExecutionStatus::Running) + EventTarget::dispatchEvent(Event::create(eventNames().closeEvent, Event::CanBubble::No, Event::IsCancelable::No)); +} + +void MessagePort::peerClosed() +{ + if (m_isDetached) + return; + auto* context = scriptExecutionContext(); + if (!context || !context->globalObject()) + return; + Ref protectedThis { *this }; + // Deliver whatever the peer sent before it closed, then fire 'close'. Node orders + // them that way, and registerCloseContext()'s retroactive notify can land before any + // drain is scheduled -- e.g. on('close') registered before on('message'). + if (m_started && m_hasMessageEventListener) + flushQueuedMessagesBeforeClose(); + // Fire 'close' (guarded against a double dispatch) and release this side's loop refs + // so the loop can idle, matching node. + dispatchCloseEvent(); + // jsUnref() clears both the listener loop-ref (m_isRefd) and the onmessage/ref() + // keepalive (m_hasRef), so a listening transferred port stops pinning the loop. + auto* globalObject = defaultGlobalObject(context->globalObject()); + jsUnref(globalObject); } TransferredMessagePort MessagePort::disentangle() @@ -151,6 +312,13 @@ TransferredMessagePort MessagePort::disentangle() deref(); } + // A transferred port is inert; clear the listener keepalive too so hasRef() + // reports false (the disentangle analogue of the close() reset above). + if (m_isRefd) { + m_isRefd = false; + updateListenerEventLoopRef(); + } + // Hand the pipe endpoint to its next owner. Messages that arrive while // in transit buffer in the pipe; the receiving context's entangle() // re-attaches and flushes them. We keep our own ref to the pipe so the @@ -171,10 +339,6 @@ Ref MessagePort::entangle(ScriptExecutionContext& context, Transfer { ASSERT(transferred.pipe); auto port = MessagePort::create(context, transferred.pipe.releaseNonNull(), transferred.side); - // Only transferred ports ref the event loop on message-listener - // add/remove; ports that were never transferred (both ends of a local - // MessageChannel) don't hold the process open. - port->onDidChangeListener = &MessagePort::onDidChangeListenerImpl; return port; } @@ -183,6 +347,8 @@ void MessagePort::dispatchOneMessage(ScriptExecutionContext& context, MessageWit if (m_isDetached || !context.globalObject()) return; + SetForScope dispatching { m_isDispatching, true }; + auto* globalObject = defaultGlobalObject(context.globalObject()); Ref vm = globalObject->vm(); auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); @@ -244,15 +410,42 @@ bool MessagePort::hasPendingActivity() const // atomic loads. The plain bool reads can observe stale values but // cannot crash — at worst the wrapper is collected one cycle early // or late, which is the same tolerance as before this refactor. + // close() sets m_isDetached before queueing the deferred close task, and a port + // with only a 'close' listener has no message listener — so this must precede + // both gates or the wrapper is collected before the task dispatches. + if (m_closeEventPending.load(std::memory_order_acquire)) + return true; if (!scriptExecutionContext() || m_isDetached) return false; + // A 'close' listener must outlive a GC until the event lands: notifyPeerClosed() + // holds only a weak ref back here. This does pin both ends of an idle channel until + // the context dies; node retains more — it never collects an entangled port at all. + if (m_hasCloseEventListener.load(std::memory_order_acquire) && !m_closeEventDispatched) + return true; if (!m_hasMessageEventListener) return false; + // Keep alive while a drain task is pending or mid-dispatch. drainAndDispatch + // pops each message (queued -> 0) before invoking listeners, so the in-hand + // message is invisible to the queued count; without this bit a concurrent GC + // running inside that window (queue empty, peer already closed) severs the + // wrapper weak and the dispatch hits a dead JSEventListener wrapper (debug + // ASSERT m_wrapper). DrainScheduled is set from schedule until the inbox is + // observed empty, covering every dispatch. uint64_t s = m_pipe->state(m_side); - // Keep alive if there are messages already queued for us, or the peer - // is still open and could send more. - return MessagePortPipe::queuedCount(s) > 0 || m_pipe->isOtherSideOpen(m_side); + if (s & MessagePortPipe::DrainScheduled) + return true; + + // Keep alive if the peer is still open and could send more, or messages are + // already queued for us. Order matters: the peer's last send() happens before + // its close() (both release-stores from the same thread), so a GC that + // observes the peer Closed is guaranteed to see that send in our inbox when + // it loads our state *afterwards*. Reading our inbox first races: a 0-queued + // load taken before the send, combined with a Closed load taken after the + // close, collects the wrapper while a message is in flight. + if (m_pipe->isOtherSideOpen(m_side)) + return true; + return MessagePortPipe::queuedCount(m_pipe->state(m_side)) > 0; } ExceptionOr> MessagePort::disentanglePorts(Vector>&& ports) @@ -262,7 +455,7 @@ ExceptionOr> MessagePort::disentanglePorts(Vector HashSet seen; for (auto& port : ports) { - if (!port || !port->isEntangled() || !seen.add(port.get()).isNewEntry) + if (!port || !port->isEntangled() || port->isClosing() || !seen.add(port.get()).isNewEntry) return Exception { DataCloneError }; } @@ -281,30 +474,42 @@ Vector> MessagePort::entanglePorts(ScriptExecutionContext& c }); } +// Reconcile the message-listener loop-ref with (m_isRefd && m_messageEventCount > 0), +// so .unref() releases the listener ref and .ref() re-acquires it. +void MessagePort::updateListenerEventLoopRef() +{ + bool shouldHold = m_isRefd && m_messageEventCount > 0; + if (shouldHold == m_listenerLoopRefActive) + return; + auto* context = scriptExecutionContext(); + if (!context) + return; + if (shouldHold) + context->refEventLoop(); + else + context->unrefEventLoop(); + m_listenerLoopRefActive = shouldHold; +} + void MessagePort::onDidChangeListenerImpl(EventTarget& self, const AtomString& eventType, OnDidChangeListenerKind kind) { if (eventType != eventNames().messageEvent) return; auto& port = static_cast(self); - auto* context = port.scriptExecutionContext(); switch (kind) { case Add: - if (port.m_messageEventCount == 0 && context) - context->refEventLoop(); port.m_messageEventCount++; break; case Remove: - port.m_messageEventCount--; - if (port.m_messageEventCount == 0 && context) - context->unrefEventLoop(); + if (port.m_messageEventCount > 0) + port.m_messageEventCount--; break; case Clear: - if (port.m_messageEventCount > 0 && context) - context->unrefEventLoop(); port.m_messageEventCount = 0; break; } + port.updateListenerEventLoopRef(); } bool MessagePort::addEventListener(const AtomString& eventType, Ref&& listener, const AddEventListenerOptions& options) @@ -312,6 +517,20 @@ bool MessagePort::addEventListener(const AtomString& eventType, Refattach(m_side, context->identifier(), ThreadSafeWeakPtr { *this }); + } + } else if (eventType == eventNames().closeEvent) { + m_hasCloseEventListener.store(true, std::memory_order_release); + if (isEntangled()) { + // Record our context with the pipe so the peer's close() can deliver a + // 'close' event even if we never started (no 'message' listener). + if (auto* context = scriptExecutionContext()) + m_pipe->registerCloseContext(m_side, context->identifier(), ThreadSafeWeakPtr { *this }); + } } return EventTarget::addEventListener(eventType, WTF::move(listener), options); } @@ -321,6 +540,8 @@ bool MessagePort::removeEventListener(const AtomString& eventType, EventListener auto result = EventTarget::removeEventListener(eventType, listener, options); if (!hasEventListeners(eventNames().messageEvent)) m_hasMessageEventListener = false; + if (!hasEventListeners(eventNames().closeEvent)) + m_hasCloseEventListener.store(false, std::memory_order_release); return result; } @@ -334,10 +555,20 @@ void MessagePort::jsRef(JSGlobalObject* lexicalGlobalObject) // A closed or transferred-away port can never receive messages again, so // taking a self-ref (and an event-loop ref) here would only leak: // close()/disentangle() have already run and nothing will ever release a - // ref taken afterwards. - if (!isEntangled()) + // ref taken afterwards. Same once the peer has closed: peerClosed() already + // ran jsUnref(), and nothing releases a ref re-taken after it, so `.ref()` + // or a late `onmessage =` would pin the loop forever. Node no-ops both. + // Only an explicit peer close counts: node never closes a channel because a + // port was collected, so keying on Closed alone made this GC-dependent. + if (!isEntangled() || m_pipe->isOtherSideClosedByRequest(m_side)) return; + // Re-acquire the message-listener loop-ref (if a listener is present) that .unref() released. + if (!m_isRefd) { + m_isRefd = true; + updateListenerEventLoopRef(); + } + if (!m_hasRef) { m_hasRef = true; ref(); @@ -347,6 +578,12 @@ void MessagePort::jsRef(JSGlobalObject* lexicalGlobalObject) void MessagePort::jsUnref(JSGlobalObject* lexicalGlobalObject) { + // Also release the listener loop-ref; otherwise an always-listening transferred + // port (a postMessageToThread control port) would pin the event loop forever. + if (m_isRefd) { + m_isRefd = false; + updateListenerEventLoopRef(); + } if (m_hasRef) { m_hasRef = false; deref(); diff --git a/src/jsc/bindings/webcore/MessagePort.h b/src/jsc/bindings/webcore/MessagePort.h index 8ec7e4dfbb34..86d047d320b0 100644 --- a/src/jsc/bindings/webcore/MessagePort.h +++ b/src/jsc/bindings/webcore/MessagePort.h @@ -70,7 +70,12 @@ class MessagePort final : public ContextDestructionObserver, public EventTarget, ExceptionOr postMessage(JSC::JSGlobalObject&, JSC::JSValue message, StructuredSerializeOptions&&); void start(); + bool hasMessageEventListener() const { return m_hasMessageEventListener; } void close(); + // Called on the entangled peer when this side closes: dispatches a + // 'close' event and releases the event-loop ref so the loop can idle. + void peerClosed(); + void dispatchCloseEvent(); // Transfer machinery. static ExceptionOr> disentanglePorts(Vector>&&); @@ -107,7 +112,8 @@ class MessagePort final : public ContextDestructionObserver, public EventTarget, void jsRef(JSGlobalObject*); void jsUnref(JSGlobalObject*); - bool jsHasRef() { return m_hasRef; } + // Report the actual loop-ref state (matches Node's uv_has_ref), not the intent flag. + bool jsHasRef() { return m_hasRef || m_listenerLoopRefActive; } private: MessagePort(ScriptExecutionContext&, Ref&&, uint8_t side); @@ -117,8 +123,17 @@ class MessagePort final : public ContextDestructionObserver, public EventTarget, void contextDestroyed() final; + // Deliver messages already queued when close() is called, before teardown. + void flushQueuedMessagesBeforeClose(); + bool isEntangled() const { return !m_isDetached; } +public: + // Checked by the transfer path so a closing-but-not-yet-detached port + // (inside close()'s flush window) is rejected the same as a detached one. + bool isClosing() const { return m_isClosing; } + +private: // Held for the port's entire lifetime — never nulled — so that the GC // thread's hasPendingActivity() can dereference it without racing the // mutator. close()/disentangle() flip pipe-side state bits instead. @@ -127,11 +142,32 @@ class MessagePort final : public ContextDestructionObserver, public EventTarget, bool m_started { false }; bool m_isDetached { false }; + bool m_isClosing { false }; + // True while a 'message' handler is on the stack. close() called from inside one + // must finish delivering the in-flight drain (node does); a close from anywhere + // else drops whatever is still queued. + bool m_isDispatching { false }; + bool m_closeEventDispatched { false }; + // Set while the deferred close task is queued: hasPendingActivity() must keep + // the wrapper alive until it runs, or the task dispatches into a dead listener. + std::atomic m_closeEventPending { false }; bool m_hasMessageEventListener { false }; + // Read from the GC thread: a port whose only listener is 'close' must survive + // until that event is delivered, or the peer's close is lost to a collection. + std::atomic m_hasCloseEventListener { false }; bool m_hasRef { false }; + // Whether .ref()/.unref() want this port to keep the loop alive (default refd); + // independent of m_hasRef (the .onmessage=/.ref() keepalive). + bool m_isRefd { true }; + // Whether the message-listener mechanism currently holds an event-loop ref + // (held iff m_isRefd && m_messageEventCount > 0). + bool m_listenerLoopRefActive { false }; + uint32_t m_messageEventCount { 0 }; static void onDidChangeListenerImpl(EventTarget& self, const AtomString& eventType, OnDidChangeListenerKind kind); + // Reconciles the listener event-loop ref with (m_isRefd && m_messageEventCount > 0). + void updateListenerEventLoopRef(); }; WebCoreOpaqueRoot root(MessagePort*); diff --git a/src/jsc/bindings/webcore/MessagePortPipe.cpp b/src/jsc/bindings/webcore/MessagePortPipe.cpp index a782916564c0..88f36aef6a18 100644 --- a/src/jsc/bindings/webcore/MessagePortPipe.cpp +++ b/src/jsc/bindings/webcore/MessagePortPipe.cpp @@ -25,14 +25,14 @@ TransferredMessagePort::~TransferredMessagePort() // handed off to a new MessagePort via entangle()), the side is orphaned; // mark it Closed so the peer's hasPendingActivity() can return false. if (pipe) - pipe->close(side); + pipe->close(side, MessagePortPipe::CloseKind::Explicit); } TransferredMessagePort& TransferredMessagePort::operator=(TransferredMessagePort&& other) { if (this != &other) { if (pipe) - pipe->close(side); + pipe->close(side, MessagePortPipe::CloseKind::Explicit); pipe = WTF::move(other.pipe); side = other.side; } @@ -117,6 +117,14 @@ void MessagePortPipe::drainAndDispatch(uint8_t side, ScriptExecutionContextIdent limit = std::max(s.inbox.size(), 1000); } + // All 'message' listeners removed: the port is paused. Leave the inbox buffered + // and stop draining; a later addEventListener re-schedules this drain. + if (!port->hasMessageEventListener()) { + Locker locker { s.lock }; + s.state.fetch_and(~uint64_t(DrainScheduled), std::memory_order_acq_rel); + return; + } + auto* context = port->scriptExecutionContext(); if (!context || !context->globalObject()) { Locker locker { s.lock }; @@ -161,6 +169,14 @@ void MessagePortPipe::drainAndDispatch(uint8_t side, ScriptExecutionContextIdent // queueMicrotask(cb) inside onmessage runs before the next message. if (globalObject->drainMicrotasks()) break; // termination pending + + // Listeners may have been removed mid-drain (port.off()); pause like the + // pre-loop check instead of dispatching the rest to zero listeners. + if (!port->hasMessageEventListener()) { + Locker locker { s.lock }; + s.state.fetch_and(~uint64_t(DrainScheduled), std::memory_order_acq_rel); + break; + } } if (rescheduleCtx) @@ -188,7 +204,7 @@ void MessagePortPipe::attach(uint8_t side, ScriptExecutionContextIdentifier ctxI s.ctxId = ctxId; s.port = WTF::move(port); uint64_t st = s.state.load(std::memory_order_relaxed); - uint64_t ns = (st | Attached) & ~Closed; + uint64_t ns = (st | Attached | ContextKnown) & ~Closed; if (queuedCount(st) > 0 && !(st & DrainScheduled)) { ns |= DrainScheduled; wakeCtx = ctxId; @@ -197,6 +213,31 @@ void MessagePortPipe::attach(uint8_t side, ScriptExecutionContextIdentifier ctxI } if (wakeCtx) scheduleDrain(side, wakeCtx); + // Peer already closed while this side was in transit (detach() cleared + // ContextKnown so notifyPeerClosed() early-returned): re-deliver to the new + // owner, or the receiving context's listener loop-ref is never released. + if (m_sides[1 - side].state.load(std::memory_order_acquire) & Closed) + notifyPeerClosed(side); +} + +void MessagePortPipe::registerCloseContext(uint8_t side, ScriptExecutionContextIdentifier ctxId, ThreadSafeWeakPtr port) +{ + ASSERT(side < 2); + auto& s = m_sides[side]; + { + Locker locker { s.lock }; + uint64_t st = s.state.load(std::memory_order_relaxed); + // Already closed, or context already known (started or previously registered). + if ((st & Closed) || (st & (Attached | ContextKnown))) + return; + s.ctxId = ctxId; + s.port = WTF::move(port); + s.state.store(st | ContextKnown, std::memory_order_release); + } + // See attach(): re-deliver a peer-close that fired while this side had no + // context (in transit or never registered). + if (m_sides[1 - side].state.load(std::memory_order_acquire) & Closed) + notifyPeerClosed(side); } void MessagePortPipe::detach(uint8_t side) @@ -211,10 +252,10 @@ void MessagePortPipe::detach(uint8_t side) // drainAndDispatch()'s s.ctxId != expectedCtx check makes it a no-op — // even if a new owner attach()es to a different context before it runs. // Messages remain queued for the next owner. - s.state.fetch_and(~uint64_t(Attached | DrainScheduled), std::memory_order_acq_rel); + s.state.fetch_and(~uint64_t(Attached | ContextKnown | DrainScheduled), std::memory_order_acq_rel); } -void MessagePortPipe::close(uint8_t side) +void MessagePortPipe::close(uint8_t side, CloseKind kind) { ASSERT(side < 2); @@ -224,11 +265,11 @@ void MessagePortPipe::close(uint8_t side) // chain of nested transferred ports overflows the native stack. Drain the // cascade iteratively instead: steal transferred pipes from each batch of // dropped messages into a stack-local worklist and close them in a loop. - Vector, uint8_t>> worklist; - worklist.append({ this, side }); + Vector, uint8_t, CloseKind>> worklist; + worklist.append({ this, side, kind }); while (!worklist.isEmpty()) { - auto [pipe, sd] = worklist.takeLast(); + auto [pipe, sd, sdKind] = worklist.takeLast(); auto& s = pipe->m_sides[sd]; Deque dropped; @@ -237,7 +278,7 @@ void MessagePortPipe::close(uint8_t side) s.ctxId = 0; s.port = nullptr; // Closed is terminal; queued messages are dropped. - s.state.store(Closed, std::memory_order_release); + s.state.store(sdKind == CloseKind::Explicit ? (Closed | ClosedByRequest) : Closed, std::memory_order_release); dropped = std::exchange(s.inbox, {}); } @@ -246,13 +287,49 @@ void MessagePortPipe::close(uint8_t side) for (auto& message : dropped) { for (auto& tp : message.transferredPorts) { if (auto p = std::exchange(tp.pipe, nullptr)) - worklist.append({ WTF::move(p), tp.side }); + worklist.append({ WTF::move(p), tp.side, CloseKind::Explicit }); } } // `dropped` (and the RefPtr in the structured binding) destruct // outside the lock; they may hold the last ref to pipes whose // destructors also take locks. + + // Notify each closed pipe's entangled peer so it can fire 'close' and + // release its event-loop ref — including nested in-transit ports drained + // from the worklist, not just the originally-closed side. + // Always notify, even for a collected wrapper. Node never collects an entangled + // port so it never faces this; bun does, and a peer that is never told is + // stranded -- its loop ref is never released and the process hangs. A 'close' + // fired at GC timing is the lesser evil. (jsRef() still ignores a collected + // peer: it keys on ClosedByRequest, not on Closed.) + pipe->notifyPeerClosed(1 - sd); + } +} + +void MessagePortPipe::notifyPeerClosed(uint8_t peerSide) +{ + auto& s = m_sides[peerSide]; + ScriptExecutionContextIdentifier ctxId = 0; + { + Locker locker { s.lock }; + uint64_t st = s.state.load(std::memory_order_acquire); + if ((st & Closed) || !(st & ContextKnown)) + return; + ctxId = s.ctxId; } + if (!ctxId) + return; + ScriptExecutionContext::postTaskTo(ctxId, [pipe = Ref { *this }, peerSide, ctxId](ScriptExecutionContext&) { + RefPtr port; + { + Locker locker { pipe->m_sides[peerSide].lock }; + if (pipe->m_sides[peerSide].ctxId != ctxId) + return; + port = pipe->m_sides[peerSide].port.get(); + } + if (port) + port->peerClosed(); + }); } } // namespace WebCore diff --git a/src/jsc/bindings/webcore/MessagePortPipe.h b/src/jsc/bindings/webcore/MessagePortPipe.h index ff421a0952ba..e58fd648e3db 100644 --- a/src/jsc/bindings/webcore/MessagePortPipe.h +++ b/src/jsc/bindings/webcore/MessagePortPipe.h @@ -44,6 +44,8 @@ class MessagePortPipe final : public ThreadSafeRefCounted { Closed = 1ull << 0, // close() was called on this side; drops further deliveries. DrainScheduled = 1ull << 1, // a drain task for this side is in flight. Attached = 1ull << 2, // ctxId/port are valid; ok to schedule drains. + ContextKnown = 1ull << 3, // ctxId/port are valid for close-notification only (no drains). + ClosedByRequest = 1ull << 4, // the Closed above came from close(), not from the port being collected. QueuedShift = 8, QueuedOne = 1ull << QueuedShift, @@ -61,12 +63,24 @@ class MessagePortPipe final : public ThreadSafeRefCounted { // already queued (e.g. after transfer). Passing a null port is allowed and // means "just buffer, don't dispatch" (used before start()). void attach(uint8_t side, ScriptExecutionContextIdentifier, ThreadSafeWeakPtr); + // Like attach() but only records ctxId/port so the peer's close() can deliver + // a 'close' event to a port that never started (no 'message' listener). Does + // NOT enable drains. No-op if already attached/registered/closed. + void registerCloseContext(uint8_t side, ScriptExecutionContextIdentifier, ThreadSafeWeakPtr); void detach(uint8_t side); - void close(uint8_t side); + // Explicit == a real, permanent close: close(), context teardown, or an orphaned + // transferred endpoint. Collected == the owning MessagePort's wrapper was garbage + // collected while still entangled. Only Explicit sets ClosedByRequest, so jsRef() + // can tell a real close from a collection (node never collects an entangled port, + // so reading Closed alone made .ref() GC-dependent). Both notify the peer. + enum class CloseKind : uint8_t { Explicit, + Collected }; + void close(uint8_t side, CloseKind = CloseKind::Collected); // Lockless snapshot for the GC visitor / hasPendingActivity. uint64_t state(uint8_t side) const { return m_sides[side].state.load(std::memory_order_acquire); } bool isOtherSideOpen(uint8_t side) const { return !(state(1 - side) & Closed); } + bool isOtherSideClosedByRequest(uint8_t side) const { return state(1 - side) & ClosedByRequest; } // Equality is by identity; used to reject "port posted through itself". bool operator==(const MessagePortPipe& other) const { return this == &other; } @@ -75,6 +89,7 @@ class MessagePortPipe final : public ThreadSafeRefCounted { MessagePortPipe() = default; void scheduleDrain(uint8_t side, ScriptExecutionContextIdentifier); + void notifyPeerClosed(uint8_t peerSide); void drainAndDispatch(uint8_t side, ScriptExecutionContextIdentifier expectedCtx); struct Side { diff --git a/src/jsc/bindings/webcore/SerializedScriptValue.cpp b/src/jsc/bindings/webcore/SerializedScriptValue.cpp index c124f7887d6b..2c222da7df7a 100644 --- a/src/jsc/bindings/webcore/SerializedScriptValue.cpp +++ b/src/jsc/bindings/webcore/SerializedScriptValue.cpp @@ -26,6 +26,7 @@ #include "config.h" #include "SerializedScriptValue.h" +#include "BunClientData.h" #include "BunString.h" // #include "BlobRegistry.h" // #include "ByteArrayPixelBuffer.h" @@ -1633,6 +1634,22 @@ class CloneSerializer : public CloneBase { VM& vm = m_lexicalGlobalObject->vm(); auto scope = DECLARE_THROW_SCOPE(vm); + // markAsUncloneable: reject a marked object anywhere in the graph (nested terminals + // come through dumpIfTerminal directly); ArrayBuffers/views serialize natively. The + // marker is a DontEnum JSC private name (node parity), invisible to user JS. + // A port in the transfer list is moved rather than cloned, so the marker doesn't + // apply to it — node lets `postMessage(port, [port])` through for a marked port. + if (value.isObject()) { + JSObject* obj = asObject(value); + if (!obj->inherits() && !obj->inherits() + && !(obj->inherits() && m_transferredMessagePorts.contains(obj)) + && obj->structure()->hasNonEnumerableProperties() + && obj->getDirect(vm, builtinNames(vm).isUncloneablePrivateName())) { + code = SerializationReturnCode::DataCloneError; + return true; + } + } + if (isArray(value)) return false; @@ -1736,45 +1753,61 @@ class CloneSerializer : public CloneBase { auto errorTypeString = errorTypeValue.toWTFString(m_lexicalGlobalObject); RETURN_IF_EXCEPTION(scope, false); - String message; - PropertyDescriptor messageDescriptor; - if (errorInstance->getOwnPropertyDescriptor(m_lexicalGlobalObject, vm.propertyNames->message, messageDescriptor) && messageDescriptor.isDataDescriptor()) { - scope.assertNoException(); - message = messageDescriptor.value().toWTFString(m_lexicalGlobalObject); + // .message/.line/.column/.sourceURL: HTML spec + Node/WebKit read + // OWN data descriptors only (an inherited or accessor .message is + // NOT serialized). .stack: Node reads via [[Get]] to materialize + // V8's lazy accessor. Any getter/coercion/prepareStackTrace throw + // propagates out of postMessage/structuredClone (Node parity). + String message, sourceURL, stack; + unsigned line = 0, column = 0; + { + // .message is ToString'd rather than gated on isString (node clones + // `e.message = 42` as "42"). Reading it before .line also keeps a + // Symbol message from reaching ErrorInstance's lazy materialization. + JSC::PropertyDescriptor d; + bool found = errorInstance->getOwnPropertyDescriptor(m_lexicalGlobalObject, vm.propertyNames->message, d); + RETURN_IF_EXCEPTION(scope, false); + if (found && d.isDataDescriptor() && d.value()) { + message = d.value().toWTFString(m_lexicalGlobalObject); + RETURN_IF_EXCEPTION(scope, false); + } } + // Trigger ErrorInstance's lazy materialization up front so a throwing + // prepareStackTrace propagates here instead of tripping the exception + // assertion inside JSObject::getOwnPropertyDescriptor. + errorInstance->materializeErrorInfoIfNeeded(vm); RETURN_IF_EXCEPTION(scope, false); - - unsigned line = 0; - PropertyDescriptor lineDescriptor; - if (errorInstance->getOwnPropertyDescriptor(m_lexicalGlobalObject, vm.propertyNames->line, lineDescriptor) && lineDescriptor.isDataDescriptor()) { - scope.assertNoException(); - line = lineDescriptor.value().toNumber(m_lexicalGlobalObject); + { + JSC::PropertyDescriptor d; + bool found = errorInstance->getOwnPropertyDescriptor(m_lexicalGlobalObject, vm.propertyNames->line, d); + RETURN_IF_EXCEPTION(scope, false); + if (found && d.isDataDescriptor() && d.value().isNumber()) + line = d.value().toNumber(m_lexicalGlobalObject); + RETURN_IF_EXCEPTION(scope, false); } - RETURN_IF_EXCEPTION(scope, false); - - unsigned column = 0; - PropertyDescriptor columnDescriptor; - if (errorInstance->getOwnPropertyDescriptor(m_lexicalGlobalObject, vm.propertyNames->column, columnDescriptor) && columnDescriptor.isDataDescriptor()) { - scope.assertNoException(); - column = columnDescriptor.value().toNumber(m_lexicalGlobalObject); + { + JSC::PropertyDescriptor d; + bool found = errorInstance->getOwnPropertyDescriptor(m_lexicalGlobalObject, vm.propertyNames->column, d); + RETURN_IF_EXCEPTION(scope, false); + if (found && d.isDataDescriptor() && d.value().isNumber()) + column = d.value().toNumber(m_lexicalGlobalObject); + RETURN_IF_EXCEPTION(scope, false); } - RETURN_IF_EXCEPTION(scope, false); - - String sourceURL; - PropertyDescriptor sourceURLDescriptor; - if (errorInstance->getOwnPropertyDescriptor(m_lexicalGlobalObject, vm.propertyNames->sourceURL, sourceURLDescriptor) && sourceURLDescriptor.isDataDescriptor()) { - scope.assertNoException(); - sourceURL = sourceURLDescriptor.value().toWTFString(m_lexicalGlobalObject); + { + JSC::PropertyDescriptor d; + bool found = errorInstance->getOwnPropertyDescriptor(m_lexicalGlobalObject, vm.propertyNames->sourceURL, d); + RETURN_IF_EXCEPTION(scope, false); + if (found && d.isDataDescriptor() && d.value().isString()) + sourceURL = d.value().toWTFString(m_lexicalGlobalObject); + RETURN_IF_EXCEPTION(scope, false); } - RETURN_IF_EXCEPTION(scope, false); - - String stack; - PropertyDescriptor stackDescriptor; - if (errorInstance->getOwnPropertyDescriptor(m_lexicalGlobalObject, vm.propertyNames->stack, stackDescriptor) && stackDescriptor.isDataDescriptor()) { - scope.assertNoException(); - stack = stackDescriptor.value().toWTFString(m_lexicalGlobalObject); + { + JSValue v = errorInstance->get(m_lexicalGlobalObject, vm.propertyNames->stack); + RETURN_IF_EXCEPTION(scope, false); + if (v.isString()) + stack = v.toWTFString(m_lexicalGlobalObject); + RETURN_IF_EXCEPTION(scope, false); } - RETURN_IF_EXCEPTION(scope, false); write(ErrorInstanceTag); write(errorNameToSerializableErrorType(errorTypeString)); @@ -1792,8 +1825,10 @@ class CloneSerializer : public CloneBase { write(index->value); return true; } - // MessagePort object could not be found in transferred message ports - code = SerializationReturnCode::ValidationError; + // MessagePort present in the message but not listed in the + // transfer list: node throws a DataCloneError with this message. + WebCore::propagateException(*m_lexicalGlobalObject, scope, Exception { DataCloneError, "Object that needs transfer was found in message but not listed in transferList"_s }); + code = SerializationReturnCode::ExistingExceptionError; return true; } if (auto* arrayBuffer = toPossiblySharedArrayBuffer(vm, obj)) { @@ -6346,6 +6381,9 @@ ExceptionOr> SerializedScriptValue::create(JSGlobalOb #endif HashSet uniqueTransferables; for (auto& transferable : transferList) { + // markAsUntransferable marker: a DontEnum JSC private name (see markAsUncloneable). + if (transferable->getDirect(vm, builtinNames(vm).isUntransferablePrivateName())) + return Exception { DataCloneError, "Cannot transfer object marked as untransferable"_s }; if (!uniqueTransferables.add(transferable.get()).isNewEntry) { if (toPossiblySharedArrayBuffer(vm, transferable.get())) { return Exception { DataCloneError, "Transfer list contains duplicate ArrayBuffer"_s }; @@ -6368,7 +6406,7 @@ ExceptionOr> SerializedScriptValue::create(JSGlobalOb continue; } if (auto port = JSMessagePort::toWrapped(vm, transferable.get())) { - if (port->isDetached()) + if (port->isDetached() || port->isClosing()) return Exception { DataCloneError, "MessagePort in transfer list is already detached"_s }; messagePorts.append(WTF::move(port)); continue; diff --git a/src/jsc/bindings/webcore/Worker.cpp b/src/jsc/bindings/webcore/Worker.cpp index 1ed3695f72fb..296ab0872b38 100644 --- a/src/jsc/bindings/webcore/Worker.cpp +++ b/src/jsc/bindings/webcore/Worker.cpp @@ -47,8 +47,10 @@ #include "JSDOMConvertObject.h" #include "JSDOMConvertSequences.h" #include "JSMessagePort.h" +#include "MessagePortPipe.h" #include "JSBroadcastChannel.h" #include "JSStructuredSerializeOptions.h" +#include "BunClientData.h" namespace WebCore { @@ -414,6 +416,34 @@ bool Worker::postTaskToWorkerGlobalScope(Function return ScriptExecutionContext::postTaskTo(m_clientIdentifier, WTF::move(task)); } +uint64_t Worker::registerCrossVMRequest(JSC::VM& vm, JSC::JSPromise* promise) +{ + uint64_t id = m_nextRequestId.fetch_add(1); + Locker lock(m_pendingTasksMutex); + m_pendingCrossVMRequests.add(id, JSC::Strong(vm, promise)); + return id; +} + +JSC::Strong Worker::takeCrossVMRequest(uint64_t id) +{ + Locker lock(m_pendingTasksMutex); + return m_pendingCrossVMRequests.take(id); +} + +void Worker::rejectAllCrossVMRequests(JSC::JSGlobalObject* globalObject) +{ + HashMap> pending; + { + Locker lock(m_pendingTasksMutex); + pending = std::exchange(m_pendingCrossVMRequests, {}); + } + if (pending.isEmpty()) + return; + auto& vm = JSC::getVM(globalObject); + for (auto& entry : pending) + entry.value->reject(vm, Bun::createError(defaultGlobalObject(globalObject), Bun::ErrorCode::ERR_WORKER_NOT_RUNNING, "Worker instance not running"_s)); +} + // ---- Worker-thread entry points --------------------------------------------- void Worker::dispatchOnline(Zig::GlobalObject* workerGlobalObject) @@ -577,7 +607,21 @@ bool Worker::dispatchExit(int32_t exitCode) // handlers observe threadId == -1 and isOnline() == false while // postMessage() (gated only on Closed) still accepts and drops the // message, matching browser/Node and pre-refactor behaviour. - protectedThis->m_state.store(State::Closing); + // + // Drop any tasks queued while the worker was Pending and never + // reached Running (m_pendingCrossVMRequests / rejectAllCrossVMRequests + // settles the callers' promises + frees the parent-VM Strong<>). + // Take the queue under the same lock that flips m_state so a racing + // postTaskToWorkerGlobalScope either lands in the cleared queue or + // sees Closing and returns false. + { + Locker lock(protectedThis->m_pendingTasksMutex); + protectedThis->m_state.store(State::Closing); + protectedThis->m_pendingTasks.clear(); + } + // Reject any introspection promises whose round-trip never completed. + if (auto* ctx = protectedThis->scriptExecutionContext()) + protectedThis->rejectAllCrossVMRequests(ctx->globalObject()); if (protectedThis->hasEventListeners(eventNames().closeEvent)) { auto event = CloseEvent::create(exitCode == 0, static_cast(exitCode), exitCode == 0 ? "Worker terminated normally"_s : "Worker exited abnormally"_s); @@ -599,6 +643,10 @@ extern "C" void WebWorker__teardownJSCVM(Zig::GlobalObject* globalObject) { auto& vm = JSC::getVM(globalObject); vm.setHasTerminationRequest(); + // Mark the context permanently terminating so postTaskTo drops tasks that + // can never run (e.g. notifyPeerClosed posted during the final collectNow). + if (auto* ctx = globalObject->scriptExecutionContext()) + ctx->markTerminating(); { auto scope = DECLARE_THROW_SCOPE(vm); @@ -633,8 +681,36 @@ extern "C" void WebWorker__dispatchExit(Worker* worker, int32_t exitCode) worker->dispatchExit(exitCode); } +// The entry module just finished (or failed) its top-level evaluation. Flush +// the worker_threads hub's deferred cross-thread deliveries: node's bootstrap +// runs the synchronous CJS main before any port delivery, so a routed message +// must not observe "no listeners" while the entry that registers them is still +// loading. Called from spin() on EVERY post-evaluation path (including entry +// throw / TLA reject / TLA unsettled) so a buffered postMessageToThread never +// leaves its sender's Atomics.waitAsync unresolved. +extern "C" void WebWorker__entrySettled(Zig::GlobalObject* globalObject) +{ + auto* hook = globalObject->nodeWorkerEntryEvaluatedHook(); + if (!hook) + return; + globalObject->setNodeWorkerEntryEvaluatedHook(nullptr); + auto& vm = JSC::getVM(globalObject); + // On failure paths (entry threw / TLA rejected) an exception may already be + // pending; the hook itself can't observe it and shutdown will report/discard + // it either way, so clear it here so JSC::call doesn't assert. On the success + // path scope.exception() is null and this is a no-op. + auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); + CLEAR_IF_EXCEPTION(scope); + if (vm.hasPendingTerminationException()) + return; + JSC::MarkedArgumentBuffer args; + JSC::call(globalObject, hook, args, "entryEvaluated hook"_s); + CLEAR_IF_EXCEPTION(scope); +} + extern "C" void WebWorker__dispatchOnline(Worker* worker, Zig::GlobalObject* globalObject) { + WebWorker__entrySettled(globalObject); worker->dispatchOnline(globalObject); } @@ -694,6 +770,56 @@ JSC_DEFINE_HOST_FUNCTION(jsReceiveMessageOnPort, (JSGlobalObject * lexicalGlobal return Bun::throwError(lexicalGlobalObject, scope, Bun::ErrorCode::ERR_INVALID_ARG_TYPE, "The \"port\" argument must be a MessagePort instance"_s); } +JSC_DEFINE_HOST_FUNCTION(jsMessagePortIsActive, (JSGlobalObject * lexicalGlobalObject, CallFrame* callFrame)) +{ + auto port = callFrame->argument(0); + if (auto* messagePort = dynamicDowncast(port)) { + auto& wrapped = messagePort->wrapped(); + bool active = (wrapped.isDetached() == false) && wrapped.pipe()->isOtherSideOpen(wrapped.side()); + return JSC::JSValue::encode(jsBoolean(active)); + } + return JSC::JSValue::encode(jsBoolean(false)); +} + +// markAsUncloneable/markAsUntransferable tag objects with a DontEnum JSC private name +// (node uses a v8 Private): invisible to and unforgeable from user JS, and not removable, +// so marking cannot be undone. Primitives are a documented no-op. +static void markObjectWithPrivateName(JSC::VM& vm, JSC::JSValue value, const JSC::Identifier& privateName) +{ + JSC::JSObject* object = value.getObject(); + if (!object || object->getDirect(vm, privateName)) + return; + object->putDirect(vm, privateName, JSC::jsBoolean(true), JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::DontEnum | JSC::PropertyAttribute::DontDelete | 0); +} + +JSC_DEFINE_HOST_FUNCTION(jsFunctionMarkAsUncloneable, (JSGlobalObject * lexicalGlobalObject, CallFrame* callFrame)) +{ + auto& vm = lexicalGlobalObject->vm(); + markObjectWithPrivateName(vm, callFrame->argument(0), builtinNames(vm).isUncloneablePrivateName()); + return JSC::JSValue::encode(JSC::jsUndefined()); +} + +JSC_DEFINE_HOST_FUNCTION(jsFunctionMarkAsUntransferable, (JSGlobalObject * lexicalGlobalObject, CallFrame* callFrame)) +{ + auto& vm = lexicalGlobalObject->vm(); + markObjectWithPrivateName(vm, callFrame->argument(0), builtinNames(vm).isUntransferablePrivateName()); + return JSC::JSValue::encode(JSC::jsUndefined()); +} + +JSC_DEFINE_HOST_FUNCTION(jsFunctionIsMarkedAsUntransferable, (JSGlobalObject * lexicalGlobalObject, CallFrame* callFrame)) +{ + auto& vm = lexicalGlobalObject->vm(); + auto* object = callFrame->argument(0).getObject(); + return JSC::JSValue::encode(jsBoolean(object && !!object->getDirect(vm, builtinNames(vm).isUntransferablePrivateName()))); +} + +JSC_DEFINE_HOST_FUNCTION(jsFunctionSetEntryEvaluatedHook, (JSC::JSGlobalObject * lexicalGlobalObject, JSC::CallFrame* callFrame)) +{ + if (auto* hook = callFrame->argument(0).getObject()) + defaultGlobalObject(lexicalGlobalObject)->setNodeWorkerEntryEvaluatedHook(hook); + return JSC::JSValue::encode(jsUndefined()); +} + JSValue createNodeWorkerThreadsBinding(Zig::GlobalObject* globalObject) { VM& vm = globalObject->vm(); @@ -701,6 +827,7 @@ JSValue createNodeWorkerThreadsBinding(Zig::GlobalObject* globalObject) auto scope = DECLARE_THROW_SCOPE(globalObject->vm()); JSValue workerData = jsNull(); JSValue threadId = jsNumber(0); + JSValue threadName = jsEmptyString(vm); JSMap* environmentData = nullptr; if (auto* worker = WebWorker__getParentWorker(globalObject->bunVM())) { @@ -734,6 +861,7 @@ JSValue createNodeWorkerThreadsBinding(Zig::GlobalObject* globalObject) // Main thread starts at 1 threadId = jsNumber(worker->clientIdentifier() - 1); + threadName = jsString(vm, options.name); } if (!environmentData) { environmentData = JSMap::create(vm, globalObject->mapStructure()); @@ -742,12 +870,23 @@ JSValue createNodeWorkerThreadsBinding(Zig::GlobalObject* globalObject) ASSERT(environmentData); globalObject->setNodeWorkerEnvironmentData(environmentData); - JSObject* array = constructEmptyArray(globalObject, nullptr, 4); + bool isNodeWorker = false; + if (auto* worker = WebWorker__getParentWorker(globalObject->bunVM())) + isNodeWorker = worker->options().kind == WorkerOptions::Kind::Node; + + JSObject* array = constructEmptyArray(globalObject, nullptr, 11); RETURN_IF_EXCEPTION(scope, {}); array->putDirectIndex(globalObject, 0, workerData); array->putDirectIndex(globalObject, 1, threadId); array->putDirectIndex(globalObject, 2, JSFunction::create(vm, globalObject, 1, "receiveMessageOnPort"_s, jsReceiveMessageOnPort, ImplementationVisibility::Public, NoIntrinsic)); array->putDirectIndex(globalObject, 3, environmentData); + array->putDirectIndex(globalObject, 4, threadName); + array->putDirectIndex(globalObject, 5, JSFunction::create(vm, globalObject, 1, "isMessagePortActive"_s, jsMessagePortIsActive, ImplementationVisibility::Public, NoIntrinsic)); + array->putDirectIndex(globalObject, 6, JSFunction::create(vm, globalObject, 1, "markAsUntransferable"_s, jsFunctionMarkAsUntransferable, ImplementationVisibility::Public, NoIntrinsic)); + array->putDirectIndex(globalObject, 7, JSFunction::create(vm, globalObject, 1, "isMarkedAsUntransferable"_s, jsFunctionIsMarkedAsUntransferable, ImplementationVisibility::Public, NoIntrinsic)); + array->putDirectIndex(globalObject, 8, JSFunction::create(vm, globalObject, 1, "markAsUncloneable"_s, jsFunctionMarkAsUncloneable, ImplementationVisibility::Public, NoIntrinsic)); + array->putDirectIndex(globalObject, 9, JSFunction::create(vm, globalObject, 1, "setEntryEvaluatedHook"_s, jsFunctionSetEntryEvaluatedHook, ImplementationVisibility::Public, NoIntrinsic)); + array->putDirectIndex(globalObject, 10, jsBoolean(isNodeWorker)); return array; } diff --git a/src/jsc/bindings/webcore/Worker.h b/src/jsc/bindings/webcore/Worker.h index ced0e30d8a48..657597d84ec5 100644 --- a/src/jsc/bindings/webcore/Worker.h +++ b/src/jsc/bindings/webcore/Worker.h @@ -30,7 +30,9 @@ #include "MessageWithMessagePorts.h" #include "WorkerOptions.h" #include +#include #include +#include #include #include "ContextDestructionObserver.h" #include "Event.h" @@ -39,6 +41,7 @@ namespace JSC { class CallFrame; class JSObject; class JSValue; +class JSPromise; } namespace WebCore { @@ -138,6 +141,14 @@ class Worker final : public ThreadSafeRefCounted, public EventTargetWith // middle thread has torn down). Callable from any thread. bool postTaskToParent(Function&&); + // Parent-thread registry for introspection promises (getHeapSnapshot etc). + // Captured by id across the cross-thread round-trip so the worker thread + // never touches the parent VM's HandleSet, and drained (rejected) by + // dispatchExit so a Running+terminate race settles instead of leaking. + uint64_t registerCrossVMRequest(JSC::VM&, JSC::JSPromise*); + JSC::Strong takeCrossVMRequest(uint64_t id); + void rejectAllCrossVMRequests(JSC::JSGlobalObject*); + // Coalesced cross-thread inbox for worker↔parent postMessage, mirroring // MessagePortPipe: a burst of N postMessage calls schedules one drain // task on the receiver, which loops dispatching + draining microtasks. @@ -167,9 +178,15 @@ class Worker final : public ThreadSafeRefCounted, public EventTargetWith // Messages posted before the worker reaches Running are queued here and // flushed by fireEarlyMessages(). The Pending→Running transition happens - // under this lock so postTaskToWorkerGlobalScope never loses a task. + // under this lock so postTaskToWorkerGlobalScope never loses a task. If the + // worker never reaches Running (entry threw / failed to load / unsettled + // TLA), dispatchExit clears the queue on the parent thread and + // rejectAllCrossVMRequests() settles the callers' promises. Lock m_pendingTasksMutex; Deque> m_pendingTasks WTF_GUARDED_BY_LOCK(m_pendingTasksMutex); + // Owned by the parent thread; guarded only for take() vs reject-all ordering. + HashMap> m_pendingCrossVMRequests WTF_GUARDED_BY_LOCK(m_pendingTasksMutex); + std::atomic m_nextRequestId { 1 }; MessageInbox m_toWorker; // messages parent → worker, drained on the worker thread MessageInbox m_toParent; // messages worker → parent, drained on the parent thread diff --git a/src/jsc/bindings/webcore/WorkerOptions.h b/src/jsc/bindings/webcore/WorkerOptions.h index 3feed8512c4e..065d9b95d36d 100644 --- a/src/jsc/bindings/webcore/WorkerOptions.h +++ b/src/jsc/bindings/webcore/WorkerOptions.h @@ -2,6 +2,7 @@ #include "root.h" #include "SerializedScriptValue.h" +#include "SharedEnvStore.h" #include "TransferredMessagePort.h" #include "MessagePort.h" @@ -18,6 +19,9 @@ struct WorkerOptions { String name; bool mini { false }; bool unref { false }; + // worker_threads `env: SHARE_ENV`: the environment tree resolved on the parent + // thread, which this worker joins instead of receiving an env snapshot. + RefPtr sharedEnvStore; // Most of our code doesn't care whether `eval` was passed, because worker_threads.ts // automatically passes a Blob URL instead of a file path if `eval` is true. But, if `eval` is // true, then we need to make sure that `process.argv` contains "[worker eval]" instead of the @@ -30,7 +34,7 @@ struct WorkerOptions { // Objects transferred for either data or environmentData in the transferList Vector dataMessagePorts; Vector preloadModules; - std::optional> env; // TODO(@190n) allow shared + std::optional> env; Vector argv; // If nullopt, inherit execArgv from the parent thread std::optional> execArgv; diff --git a/src/jsc/event_loop.rs b/src/jsc/event_loop.rs index 5bc9a66c36db..0dccfd949776 100644 --- a/src/jsc/event_loop.rs +++ b/src/jsc/event_loop.rs @@ -380,6 +380,13 @@ impl EventLoop { this_value: JSValue, arguments: &[JSValue], ) { + // A prior callback's microtasks can tear the worker down + // (worker.terminate()), leaving the termination exception pending; + // entering JS then trips executeCallImpl's `assertNoException`. Same + // gate as `tick_with_count()`; guarding here covers all 50+ callers. + if global_object.has_exception() { + return; + } // R-2 noalias mitigation (see PORT_NOTES_PLAN R-2; precedent // `b818e70e1c57` NodeHTTPResponse::cork): `&mut self` carries LLVM // `noalias`, and `callback.call()` receives nothing derived from @@ -412,6 +419,9 @@ impl EventLoop { this_value: JSValue, arguments: &[JSValue], ) -> JSValue { + if global_object.has_exception() { + return JSValue::ZERO; + } // R-2 noalias mitigation — see `run_callback` above. let this: *mut Self = core::hint::black_box(core::ptr::from_mut(self)); // SAFETY: `this` is the unique live `EventLoop`; short-lived `&mut`. @@ -752,7 +762,11 @@ impl EventLoop { // `self.tasks` (a field of the static-rooted `VirtualMachine` box that // is never `dealloc`'d) leaves the chain reachable to LSan — the same // visibility they had via `concurrent_tasks` before - // `drop_concurrent_cpp_tasks` drained it. + // `drop_concurrent_cpp_tasks` drained it. CppTasks must NOT be deleted + // here: this runs after JSC VM teardown on both worker and main paths, + // and a Worker dispatchExit task's `~Ref` would walk freed + // WeakBlock storage via `~JSEventListener`. They are reclaimed before + // teardown by `release_queued_tasks_for_shutdown`'s CppTask arm. let mut requeue: Vec = Vec::new(); while let Some(task) = self.tasks.read_item() { if task.tag == bun_event_loop::task_tag::ManagedTask { @@ -1133,6 +1147,12 @@ impl EventLoop { if !worker.has_requested_terminate() && promise.status() == PromiseStatus::Pending { + // Unsettled top-level await: the loop has drained but the + // entry module's evaluation promise is still pending. Stop + // waiting so the worker can exit (node uses exit code 13). + if !self.vm_ref().is_event_loop_alive() { + break; + } self.auto_tick(); } } diff --git a/src/jsc/web_worker.rs b/src/jsc/web_worker.rs index ed93376cbaaf..4d61bff0fca3 100644 --- a/src/jsc/web_worker.rs +++ b/src/jsc/web_worker.rs @@ -215,6 +215,7 @@ unsafe extern "C" { safe fn JSC__VM__getAPILock(vm: &jsc::VM); safe fn WebWorker__dispatchOnline(cpp_worker: *mut c_void, global: &JSGlobalObject); safe fn WebWorker__fireEarlyMessages(cpp_worker: *mut c_void, global: &JSGlobalObject); + safe fn WebWorker__entrySettled(global: &JSGlobalObject); safe fn WebWorker__dispatchError( global: &JSGlobalObject, cpp_worker: *mut c_void, @@ -506,6 +507,13 @@ impl WebWorker { let mut preloads: Vec> = Vec::with_capacity(preload_modules_len); for module in preload_modules { let utf8_slice = module.to_utf8(); + // node: builtin specifiers skip the file resolver — the worker-side + // module loader resolves them. Lets node:worker_threads run its + // bootstrap (stdio rebinding) as a preload. + if utf8_slice.slice().starts_with(b"node:") { + preloads.push(utf8_slice.slice().to_vec().into_boxed_slice()); + continue; + } // SAFETY: `parent_ref` is the live VM on the calling (parent) // thread — its `transpiler` is uniquely owned here. if let Some(preload) = unsafe { @@ -1090,22 +1098,39 @@ impl WebWorker { vm.as_mut().exit_handler.exit_code = 1; } self.flush_logs(vm); + WebWorker__entrySettled(vm.global()); return self.shutdown(); } }; + // Fire (and clear) the entryEvaluated hook on EVERY post-evaluation path + // so buffered postMessageToThread deliveries drain and the sender's + // Atomics.waitAsync settles. dispatchOnline re-calls it as a no-op. + WebWorker__entrySettled(vm.global()); + // SAFETY: `promise` is a live JSC heap cell. unsafe { - if (*promise).status() == jsc::js_promise::Status::Rejected { + let status = (*promise).status(); + if status == jsc::js_promise::Status::Rejected { let handled = vm.as_mut().uncaught_exception( vm.global(), (*promise).result(vm.jsc_vm()), true, ); if !handled { - vm.as_mut().exit_handler.exit_code = 1; + // exit_code is already 1 from uncaught_exception; re-setting it here + // would clobber a process.on('exit') change to process.exitCode. return self.shutdown(); } + } else if status == jsc::js_promise::Status::Pending { + // Unsettled top-level await (loop drained, entry promise still + // pending): node exits the worker with code 13, but only if the + // user hasn't set a nonzero process.exitCode. + if vm.exit_handler.exit_code == 0 { + vm.as_mut().exit_handler.exit_code = 13; + } + self.flush_logs(vm); + return self.shutdown(); } else { let _ = (*promise).result(vm.jsc_vm()); } @@ -1252,6 +1277,14 @@ impl WebWorker { // is step 3 below). rare.close_all_socket_groups(unsafe { &*vm_ptr }); } + // Reclaim queued CppTasks (the per-worker stdio/messaging + // MessagePort drain tasks that can be in self.tasks mid-tick when + // terminate() lands, and any Worker dispatchExit close task from a + // sub-worker) while JSC is still live: ~Ref walks + // ~JSEventListener Weak<> handles, and after teardownJSCVM the + // worker VM is dealloc'd-without-Drop so anything still in + // self.tasks leaks. Mirrors the global_exit() ordering. + vm.event_loop_mut().release_queued_tasks_for_shutdown(); exit_code = i32::from(vm.exit_handler.exit_code); global_object = Some(vm.global); } @@ -1437,6 +1470,17 @@ fn on_unhandled_rejection( .to_error() .unwrap_or(error_instance_or_exception); + // A parse failure rejects with a BuildMessage, which doesn't survive structured + // clone. Node reports a SyntaxError; build a real one from the formatted parse + // error so the subtype reaches the parent intact. + if let Some(bm) = error_instance.as_::() { + // SAFETY: as_ returned a live BuildMessage cell, read-only on the + // worker (JS) thread that owns it. + let text = unsafe { (*bm).msg.data.text.clone() }; + error_instance = + global_object.create_syntax_error_instance(format_args!("{}", bstr::BStr::new(&text))); + } + let mut array: Vec = Vec::new(); // `worker_ref()` is the safe BACKREF accessor — `vm.worker` points at the @@ -1491,6 +1535,11 @@ fn on_unhandled_rejection( { let _ = global_object.try_take_exception(); } + // node runs the worker's process 'exit' handlers on an uncaught exception (code 1; + // they may change process.exitCode). Run them before arming termination — a pending + // termination exception makes dispatchExitInternal skip 'exit' (as terminate() should), + // and its processIsExiting guard stops shutdown() from running them twice. + virtual_machine::ExitHandler::dispatch_on_exit(vm); let _ = worker.set_requested_terminate(); // Do NOT call `worker.shutdown()` here — // `shutdown()` RETURNS, so calling it here would destroy diff --git a/src/runtime/api/bun/h2_frame_parser.rs b/src/runtime/api/bun/h2_frame_parser.rs index 07dbf0510c53..4eab555f2923 100644 --- a/src/runtime/api/bun/h2_frame_parser.rs +++ b/src/runtime/api/bun/h2_frame_parser.rs @@ -1043,6 +1043,13 @@ impl Handlers { self.global_object } + /// A zero/empty arg means a value failed to materialize (e.g. a header + /// materializer bailed); skip the callback rather than passing it to JS. + /// The pending-termination-exception guard lives in `run_callback`. + fn should_skip_dispatch(&self, data: &[JSValue]) -> bool { + data.contains(&JSValue::ZERO) + } + pub(crate) fn call_event_handler( &self, event: JSH2FrameParser::Gc, @@ -1053,6 +1060,12 @@ impl Handlers { let Some(callback) = event.get(this_value) else { return false; }; + // A zero/empty arg means a value failed to materialize (e.g. the VM is + // terminating); skip the callback rather than passing it to JS, which + // asserts/crashes in Bun__JSValue__call. + if self.should_skip_dispatch(data) { + return false; + } self.vm .event_loop_ref() .run_callback(callback, &self.global(), context, data); @@ -1063,6 +1076,9 @@ impl Handlers { if !callback.is_callable() { return false; } + if self.should_skip_dispatch(data) { + return false; + } self.vm .event_loop_ref() .run_callback(callback, &self.global(), JSValue::UNDEFINED, data); @@ -1078,6 +1094,9 @@ impl Handlers { let Some(callback) = event.get(this_value) else { return JSValue::ZERO; }; + if self.should_skip_dispatch(data) { + return JSValue::ZERO; + } self.vm.event_loop_ref().run_callback_with_result( callback, &self.global(), @@ -5036,6 +5055,12 @@ impl H2FrameParser { }; let global = self.handlers.get().global(); + // A prior frame's callback can drain microtasks that tear the worker + // down (worker.terminate()); skip rather than calling JS with the + // termination exception pending. Same guard as read_bytes(). + if global.has_exception() { + return Some(stream); + } match callback.call( &global, ctx_value, @@ -5100,6 +5125,14 @@ impl H2FrameParser { } fn read_bytes(&self, bytes: &[u8]) -> JsResult { + // read() loops this per frame. A prior frame's callback can drain + // microtasks that tear the worker down (worker.terminate()), leaving a + // pending (termination) exception; dispatching further frames then calls + // JS with that exception pending (assertNoException) or with torn-down + // values. Stop consuming once an exception is pending. + if self.handlers.get().global().has_exception() { + return Ok(bytes.len()); + } bun_output::scoped_log!(H2FrameParser, "read {}", bytes.len()); if self.is_server.get() && self.preface_received_len.get() < 24 { // Handle Server Preface diff --git a/src/runtime/dispatch.rs b/src/runtime/dispatch.rs index 22049be0b6ab..cd8f29b32f70 100644 --- a/src/runtime/dispatch.rs +++ b/src/runtime/dispatch.rs @@ -1198,6 +1198,20 @@ pub(crate) fn __bun_release_task_at_shutdown(task: bun_event_loop::Task) -> bool for_each_fs_async_op!(__fs_destroy); true } + // Same reclaim `drop_concurrent_cpp_tasks` performs, but for tasks + // that were already batch-moved into `self.tasks`. Must run before + // JSC teardown: a Worker `dispatchExit` lambda's `~Ref` walks + // `~JSEventListener` Weak<> handles. Worker `shutdown()` calls + // `release_queued_tasks_for_shutdown` for the same reason. + task_tag::CppTask => { + unsafe extern "C" { + fn Bun__deleteEventLoopTask(task: *mut CppTask); + } + // SAFETY: every CppTask payload is a heap `WebCore::EventLoopTask*`; + // we own it once popped. + unsafe { Bun__deleteEventLoopTask(task.ptr.cast::()) }; + true + } // Re-queued by the caller; the box stays reachable from the // static-rooted VM. Dispatching the type-erased `AnyTask` callback // is not generally safe at shutdown (e.g. `AsyncModule::on_done`, diff --git a/src/runtime/jsc_hooks.rs b/src/runtime/jsc_hooks.rs index 858321dbda5c..1e4cc8854eb7 100644 --- a/src/runtime/jsc_hooks.rs +++ b/src/runtime/jsc_hooks.rs @@ -675,58 +675,66 @@ unsafe fn load_preloads( .strip_prefix(b"file://".as_slice()) .unwrap_or(preload_slice); - // ── resolve ───────────────────────────────────────────────────── - // SAFETY: per fn contract; `top_level_dir` is the `'static` fs - // singleton field. - let mut result = match unsafe { - (*vm).transpiler.resolver.resolve_and_auto_install( - &*top_level_dir, - normalized, - ImportKind::Stmt, - global_cache, - ) - } { - ResolveResultUnion::Success(r) => r, - ResolveResultUnion::Failure(e) => { - // SAFETY: `vm.log` was set to a fresh leaked `Box` by - // `VirtualMachine::init`. - if let Some(log) = unsafe { &*vm }.log { - // SAFETY: `log` is the unique per-VM `Box`. - let _ = unsafe { &mut *log.as_ptr() }.add_error_fmt( - None, - bun_ast::Loc::EMPTY, - format_args!( - "{} resolving preload {}", - e.name(), - bun_core::fmt::format_json_string_latin1(preload_slice), - ), - ); + // node: builtin specifiers bypass the file resolver — JSModuleLoader + // resolves them internally. node:worker_threads is preloaded this way so + // its node-style worker bootstrap (stdio rebinding) runs before user code; + // this also means `bun --import node:*` works like Node's. + let module_name = if normalized.starts_with(b"node:") { + bun_core::String::from_bytes(normalized) + } else { + // ── resolve ───────────────────────────────────────────────────── + // SAFETY: per fn contract; `top_level_dir` is the `'static` fs + // singleton field. + let mut result = match unsafe { + (*vm).transpiler.resolver.resolve_and_auto_install( + &*top_level_dir, + normalized, + ImportKind::Stmt, + global_cache, + ) + } { + ResolveResultUnion::Success(r) => r, + ResolveResultUnion::Failure(e) => { + // SAFETY: `vm.log` was set to a fresh leaked `Box` by + // `VirtualMachine::init`. + if let Some(log) = unsafe { &*vm }.log { + // SAFETY: `log` is the unique per-VM `Box`. + let _ = unsafe { &mut *log.as_ptr() }.add_error_fmt( + None, + bun_ast::Loc::EMPTY, + format_args!( + "{} resolving preload {}", + e.name(), + bun_core::fmt::format_json_string_latin1(preload_slice), + ), + ); + } + return Err(e); } - return Err(e); - } - ResolveResultUnion::Pending(_) | ResolveResultUnion::NotFound => { - // SAFETY: see above. - if let Some(log) = unsafe { &*vm }.log { - // SAFETY: `log` is the unique per-VM `Box`. - let _ = unsafe { &mut *log.as_ptr() }.add_error_fmt( - None, - bun_ast::Loc::EMPTY, - format_args!( - "preload not found {}", - bun_core::fmt::format_json_string_latin1(preload_slice), - ), - ); + ResolveResultUnion::Pending(_) | ResolveResultUnion::NotFound => { + // SAFETY: see above. + if let Some(log) = unsafe { &*vm }.log { + // SAFETY: `log` is the unique per-VM `Box`. + let _ = unsafe { &mut *log.as_ptr() }.add_error_fmt( + None, + bun_ast::Loc::EMPTY, + format_args!( + "preload not found {}", + bun_core::fmt::format_json_string_latin1(preload_slice), + ), + ); + } + return Err(bun_core::err!("ModuleNotFound")); } - return Err(bun_core::err!("ModuleNotFound")); - } - }; + }; - // ── import ────────────────────────────────────────────────────── - let path_text = result - .path() - .expect("resolver Success result has a primary path") - .text; - let module_name = bun_core::String::from_bytes(path_text); + // ── import ────────────────────────────────────────────────────── + let path_text = result + .path() + .expect("resolver Success result has a primary path") + .text; + bun_core::String::from_bytes(path_text) + }; // Note: use `import_ptr` (not `import`) so the `*mut` we store in // `pending_internal_promise` keeps the FFI's mutable provenance instead // of being laundered through `&JSInternalPromise -> *const -> *mut` diff --git a/test/js/bun/util/error-code-mirror.test.ts b/test/js/bun/util/error-code-mirror.test.ts new file mode 100644 index 000000000000..8bfa16ceabce --- /dev/null +++ b/test/js/bun/util/error-code-mirror.test.ts @@ -0,0 +1,78 @@ +import { expect, test } from "bun:test"; +import path from "node:path"; + +// `src/jsc/ErrorCode.rs` is a hand-maintained mirror of the table generated from +// `src/jsc/bindings/ErrorCode.ts`. Its discriminants index a fixed C++ `errors[]` +// array with no bounds check, so an entry inserted anywhere but the end silently +// shifts every later code (and can read past the array). Nothing else checks this. +const SRC = path.join(__dirname, "..", "..", "..", "..", "src"); + +function parseRows(ts: string) { + const body = ts.split("const errors: ErrorCodeMapping = [")[1].split("\n];")[0]; + return body.match(/^\s*\[.+?\],\s*$/gm)!.map(row => { + const code = row.match(/"([A-Z0-9_]+)"/)![1]; + const fields = row + .trim() + .replace(/^\[|\],?$/g, "") + .split(/,(?![^[]*\])/); + const extras = fields + .slice(3) + .map(f => f.trim()) + .filter(f => f && f !== "null" && f !== "undefined"); + return { code, extras }; + }); +} + +// Mirrors generate-node-errors.ts: each row emits its code once, plus once more +// per extra constructor after the third field. +async function expected() { + const ts = await Bun.file(path.join(SRC, "jsc", "bindings", "ErrorCode.ts")).text(); + const rows = parseRows(ts); + const codes: string[] = []; + const enumNames: string[] = []; + for (const { code, extras } of rows) { + codes.push(code, ...extras.map(() => code)); + enumNames.push(code, ...extras.map(c => `${code}_${c}`)); + } + return { codes, enumNames }; +} + +async function rustMirror() { + const rs = await Bun.file(path.join(SRC, "jsc", "ErrorCode.rs")).text(); + const count = Number(rs.match(/pub const COUNT: u16 = (\d+);/)![1]); + const table = rs.match(/static CODE_STR: \[&str; ErrorCode::COUNT as usize\] = \[([\s\S]*?)\n\];/)![1]; + const codes = [...table.matchAll(/"([^"]+)"/g)].map(m => m[1]); + const consts = [...rs.matchAll(/pub const ([A-Za-z0-9_]+): ErrorCode = ErrorCode\((\d+)\);/g)].map(m => ({ + name: m[1], + value: Number(m[2]), + })); + return { count, codes, consts }; +} + +test("ErrorCode.rs CODE_STR stays index-aligned with ErrorCode.ts", async () => { + const { codes: want } = await expected(); + const { codes } = await rustMirror(); + // Report the first divergence: a bare toEqual on 300+ strings is unreadable. + const at = want.findIndex((c, i) => codes[i] !== c); + expect({ at, detail: at === -1 ? null : { want: want[at], got: codes[at] } }).toEqual({ at: -1, detail: null }); + expect(codes.length).toBe(want.length); +}); + +test("ErrorCode.rs COUNT matches the generated error count", async () => { + const { codes: want } = await expected(); + const { count } = await rustMirror(); + expect(count).toBe(want.length); +}); + +// The discriminants themselves. Rust names strip the leading ERR_ (except +// ERR_MODULE_NOT_FOUND); extra constructors get a `_RangeError`-style suffix. +test("ErrorCode.rs discriminants match their position in ErrorCode.ts", async () => { + const { enumNames } = await expected(); + const index = new Map(enumNames.map((n, i) => [n, i])); + const { consts } = await rustMirror(); + const mismatches = consts + .map(({ name, value }) => ({ name, value, want: index.get(index.has(name) ? name : `ERR_${name}`) })) + .filter(({ value, want }) => value !== want); + expect(mismatches).toEqual([]); + expect(consts.length).toBe(enumNames.length); +}); diff --git a/test/js/node/test/parallel/test-worker-arraybuffer-zerofill.js b/test/js/node/test/parallel/test-worker-arraybuffer-zerofill.js index 3dcf4c006ebc..bdbc821775fc 100644 --- a/test/js/node/test/parallel/test-worker-arraybuffer-zerofill.js +++ b/test/js/node/test/parallel/test-worker-arraybuffer-zerofill.js @@ -1,33 +1,43 @@ 'use strict'; -require('../common'); +const common = require('../common'); +const Countdown = require('../common/countdown'); const assert = require('assert'); const { Worker } = require('worker_threads'); +const { describe, it, mock } = require('node:test'); -// Make sure that allocating uninitialized ArrayBuffers in one thread does not -// affect the zero-initialization in other threads. +describe('Allocating uninitialized ArrayBuffers ...', () => { + it('...should not affect zero-fill in other threads', () => { + const w = new Worker(` + const { parentPort } = require('worker_threads'); -const w = new Worker(` -const { parentPort } = require('worker_threads'); + function post() { + const uint32array = new Uint32Array(64); + parentPort.postMessage(uint32array.reduce((a, b) => a + b)); + } -function post() { - const uint32array = new Uint32Array(64); - parentPort.postMessage(uint32array.reduce((a, b) => a + b)); -} + setInterval(post, 0); + `, { eval: true }); -setInterval(post, 0); -`, { eval: true }); + const fn = mock.fn(() => { + // Continuously allocate memory in the main thread. The allocUnsafe + // here sets a scope internally that indicates that the memory should + // not be initialized. While this is happening, the other thread is + // also allocating buffers that must remain zero-filled. The purpose + // of this test is to ensure that the scope used to determine whether + // to zero-fill or not does not impact the other thread. + setInterval(() => Buffer.allocUnsafe(32 * 1024 * 1024), 0).unref(); + }); -function allocBuffers() { - Buffer.allocUnsafe(32 * 1024 * 1024); -} + w.on('online', fn); -const interval = setInterval(allocBuffers, 0); + const countdown = new Countdown(100, common.mustCallAtLeast(() => { + w.terminate(); + assert(fn.mock.calls.length > 0); + })); -let messages = 0; -w.on('message', (sum) => { - assert.strictEqual(sum, 0); - if (messages++ === 100) { - clearInterval(interval); - w.terminate(); - } + w.on('message', common.mustCallAtLeast((sum) => { + assert.strictEqual(sum, 0); + if (countdown.remaining) countdown.dec(); + })); + }); }); diff --git a/test/js/node/test/parallel/test-worker-beforeexit-throw-exit.js b/test/js/node/test/parallel/test-worker-beforeexit-throw-exit.js new file mode 100644 index 000000000000..2aa255ee82af --- /dev/null +++ b/test/js/node/test/parallel/test-worker-beforeexit-throw-exit.js @@ -0,0 +1,28 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const { Worker } = require('worker_threads'); + +// Test that 'exit' is emitted if 'beforeExit' throws, both inside the Worker. + +const workerData = new Uint8Array(new SharedArrayBuffer(2)); +const w = new Worker(` + const { workerData } = require('worker_threads'); + process.on('exit', () => { + workerData[0] = 100; + }); + process.on('beforeExit', () => { + workerData[1] = 200; + throw new Error('banana'); + }); +`, { eval: true, workerData }); + +w.on('error', common.mustCall((err) => { + assert.strictEqual(err.message, 'banana'); +})); + +w.on('exit', common.mustCall((code) => { + assert.strictEqual(code, 1); + assert.strictEqual(workerData[0], 100); + assert.strictEqual(workerData[1], 200); +})); diff --git a/test/js/node/test/parallel/test-worker-cpu-profile.js b/test/js/node/test/parallel/test-worker-cpu-profile.js new file mode 100644 index 000000000000..f7b790b6eb89 --- /dev/null +++ b/test/js/node/test/parallel/test-worker-cpu-profile.js @@ -0,0 +1,56 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const { Worker } = require('worker_threads'); + +const worker = new Worker(` + const { parentPort } = require('worker_threads'); + parentPort.on('message', () => {}); + `, { eval: true }); + +worker.on('online', common.mustCall(async () => { + { + const handle = await worker.startCpuProfile(); + JSON.parse(await handle.stop()); + // Stop again + JSON.parse(await handle.stop()); + } + + { + const handle = await worker.startCpuProfile({ + sampleInterval: 0.5, + maxBufferSize: 8, + }); + JSON.parse(await handle.stop()); + } + + { + const [handle1, handle2] = await Promise.all([ + worker.startCpuProfile(), + worker.startCpuProfile(), + ]); + const [profile1, profile2] = await Promise.all([ + handle1.stop(), + handle2.stop(), + ]); + JSON.parse(profile1); + JSON.parse(profile2); + } + + { + await worker.startCpuProfile(); + // It will be stopped automatically when the worker is terminated + } + worker.terminate(); +})); + +worker.once('exit', common.mustCall(async () => { + assert.throws( + () => worker.startCpuProfile({ maxBufferSize: 0 }), + common.expectsError({ + code: 'ERR_OUT_OF_RANGE', + })); + await assert.rejects(worker.startCpuProfile(), { + code: 'ERR_WORKER_NOT_RUNNING' + }); +})); diff --git a/test/js/node/test/parallel/test-worker-cpu-usage.js b/test/js/node/test/parallel/test-worker-cpu-usage.js new file mode 100644 index 000000000000..50d36a4e4595 --- /dev/null +++ b/test/js/node/test/parallel/test-worker-cpu-usage.js @@ -0,0 +1,83 @@ +'use strict'; +const common = require('../common'); +const { isSunOS } = require('../common'); +const assert = require('assert'); +const { + Worker, +} = require('worker_threads'); + +function validate(result) { + assert.ok(typeof result == 'object' && result !== null); + assert.ok(result.user >= 0); + assert.ok(result.system >= 0); + assert.ok(Number.isFinite(result.user)); + assert.ok(Number.isFinite(result.system)); +} + +function check(worker) { + [ + NaN, + undefined, + null, + ].forEach((value) => { + worker.cpuUsage(value); + }); + [ + -1, + 1.1, + {}, + [], + function() {}, + Symbol(), + true, + Infinity, + { user: -1, system: 1 }, + { user: 1, system: -1 }, + ].forEach((value) => { + assert.throws(() => { + worker.cpuUsage(value); + }, /ERR_OUT_OF_RANGE|ERR_INVALID_ARG_TYPE/i); + }); +} + +const worker = new Worker(` + const { parentPort } = require('worker_threads'); + parentPort.on('message', () => {}); + `, { eval: true }); + +// See test-process-threadCpuUsage-main-thread.js +if (isSunOS) { + assert.throws( + () => worker.cpuUsage(), + { + code: 'ERR_OPERATION_FAILED', + name: 'Error', + message: 'Operation failed: worker.cpuUsage() is not available on SunOS' + } + ); + worker.terminate(); +} else { + worker.on('online', common.mustCall(async () => { + check(worker); + + const prev = await worker.cpuUsage(); + validate(prev); + + const curr = await worker.cpuUsage(); + validate(curr); + + assert.ok(curr.user >= prev.user); + assert.ok(curr.system >= prev.system); + + const delta = await worker.cpuUsage(curr); + validate(delta); + + worker.terminate(); + })); + + worker.once('exit', common.mustCall(async () => { + await assert.rejects(worker.cpuUsage(), { + code: 'ERR_WORKER_NOT_RUNNING' + }); + })); +} diff --git a/test/js/node/test/parallel/test-worker-crypto-sign-transfer-result.js b/test/js/node/test/parallel/test-worker-crypto-sign-transfer-result.js new file mode 100644 index 000000000000..ca5675d1426c --- /dev/null +++ b/test/js/node/test/parallel/test-worker-crypto-sign-transfer-result.js @@ -0,0 +1,30 @@ +'use strict'; +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); + +const assert = require('assert'); +const { Worker } = require('worker_threads'); +const fixturesPath = require.resolve('../common/fixtures'); + +// Test that transferring the result of e.g. crypto.sign() from Worker to parent +// thread does not crash + +const w = new Worker(` +const { parentPort } = require('worker_threads'); +const crypto = require('crypto'); +const assert = require('assert'); +const fixtures = require(${JSON.stringify(fixturesPath)}); + +const keyPem = fixtures.readKey('rsa_private.pem'); + +const buf = crypto.sign('sha256', Buffer.from('hello'), keyPem); +assert.notStrictEqual(buf.byteLength, 0); +parentPort.postMessage(buf, [buf.buffer]); +assert.strictEqual(buf.byteLength, 0); +`, { eval: true }); + +w.on('message', common.mustCall((buf) => { + assert.notStrictEqual(buf.byteLength, 0); +})); +w.on('exit', common.mustCall()); diff --git a/test/js/node/test/parallel/test-worker-environmentdata.js b/test/js/node/test/parallel/test-worker-environmentdata.js index 5943666d060b..ff12e267e96a 100644 --- a/test/js/node/test/parallel/test-worker-environmentdata.js +++ b/test/js/node/test/parallel/test-worker-environmentdata.js @@ -9,29 +9,27 @@ const { threadId, } = require('worker_threads'); -// BUN: skip using this internal module, it doesn't actually affect behavior of the test +// BUN: internal/worker (--expose-internals) is not available; assignEnvironmentData +// is exercised indirectly (it is a no-op setup helper for this test's keys). // const { assignEnvironmentData } = require('internal/worker'); -const { - deepStrictEqual, - strictEqual, -} = require('assert'); +const assert = require('assert'); if (!process.env.HAS_STARTED_WORKER) { process.env.HAS_STARTED_WORKER = 1; setEnvironmentData('foo', 'bar'); setEnvironmentData('hello', { value: 'world' }); setEnvironmentData(1, 2); - strictEqual(getEnvironmentData(1), 2); + assert.strictEqual(getEnvironmentData(1), 2); setEnvironmentData(1); // Delete it, key won't show up in the worker. new Worker(__filename); setEnvironmentData('hello'); // Delete it. Has no impact on the worker. } else { - strictEqual(getEnvironmentData('foo'), 'bar'); - deepStrictEqual(getEnvironmentData('hello'), { value: 'world' }); - strictEqual(getEnvironmentData(1), undefined); - // assignEnvironmentData(undefined); // It won't setup any key. - strictEqual(getEnvironmentData(undefined), undefined); + assert.strictEqual(getEnvironmentData('foo'), 'bar'); + assert.deepStrictEqual(getEnvironmentData('hello'), { value: 'world' }); + assert.strictEqual(getEnvironmentData(1), undefined); + // BUN: skipped (internal): assignEnvironmentData(undefined); // It won't setup any key. + assert.strictEqual(getEnvironmentData(undefined), undefined); // Recurse to make sure the environment data is inherited if (threadId <= 2) diff --git a/test/js/node/test/parallel/test-worker-esm-missing-main.js b/test/js/node/test/parallel/test-worker-esm-missing-main.js index dbcb050b77c0..0b1245d108cd 100644 --- a/test/js/node/test/parallel/test-worker-esm-missing-main.js +++ b/test/js/node/test/parallel/test-worker-esm-missing-main.js @@ -11,6 +11,5 @@ const worker = new Worker(missing); worker.on('error', common.mustCall((err) => { // eslint-disable-next-line node-core/no-unescaped-regexp-dot - // BUN: this error comes from our bundler where it'd be impractical to rewrite all the errors to match Node - assert.match(err.message, /(Cannot find module|ModuleNotFound) .+does-not-exist.js/); + assert.match(err.message, /Cannot find module .+does-not-exist.js/); })); diff --git a/test/js/node/test/parallel/test-worker-exit-code.js b/test/js/node/test/parallel/test-worker-exit-code.js new file mode 100644 index 000000000000..c9b84d7b1c34 --- /dev/null +++ b/test/js/node/test/parallel/test-worker-exit-code.js @@ -0,0 +1,46 @@ +'use strict'; +const common = require('../common'); + +// This test checks that Worker has correct exit codes on parent side +// in multiple situations. + +const assert = require('assert'); +const worker = require('worker_threads'); +const { Worker, parentPort } = worker; + +const { getTestCases } = require('../common/process-exit-code-cases'); +const testCases = getTestCases(true); + +// Do not use isMainThread so that this test itself can be run inside a Worker. +if (!process.env.HAS_STARTED_WORKER) { + process.env.HAS_STARTED_WORKER = 1; + parent(); +} else { + if (!parentPort) { + console.error('Parent port must not be null'); + process.exit(100); + return; + } + parentPort.once('message', (msg) => testCases[msg].func()); +} + +function parent() { + const test = common.mustCall((arg, name = 'worker', exit, error = null) => { + const w = new Worker(__filename); + w.on('exit', common.mustCall((code) => { + assert.strictEqual( + code, exit, + `wrong exit for ${arg}-${name}\nexpected:${exit} but got:${code}`); + console.log(`ok - ${arg} exited with ${exit}`); + })); + if (error) { + w.on('error', common.mustCall((err) => { + console.log(err); + assert.match(String(err), error); + })); + } + w.postMessage(arg); + }, testCases.length); + + testCases.forEach((tc, i) => test(i, tc.func.name, tc.result, tc.error)); +} diff --git a/test/js/node/test/parallel/test-worker-heap-profile.js b/test/js/node/test/parallel/test-worker-heap-profile.js new file mode 100644 index 000000000000..2a466dc2f186 --- /dev/null +++ b/test/js/node/test/parallel/test-worker-heap-profile.js @@ -0,0 +1,81 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const { Worker } = require('worker_threads'); + +const worker = new Worker(` + const { parentPort } = require('worker_threads'); + parentPort.on('message', () => {}); + `, { eval: true }); + +worker.on('online', common.mustCall(async () => { + assert.throws(() => worker.startHeapProfile('bad'), { + code: 'ERR_INVALID_ARG_TYPE', + }); + + assert.throws(() => worker.startHeapProfile({ sampleInterval: '1024' }), { + code: 'ERR_INVALID_ARG_TYPE', + }); + assert.throws(() => worker.startHeapProfile({ sampleInterval: 1.1 }), { + code: 'ERR_OUT_OF_RANGE', + }); + assert.throws(() => worker.startHeapProfile({ sampleInterval: 0 }), { + code: 'ERR_OUT_OF_RANGE', + }); + assert.throws(() => worker.startHeapProfile({ sampleInterval: -1 }), { + code: 'ERR_OUT_OF_RANGE', + }); + + assert.throws(() => worker.startHeapProfile({ stackDepth: '16' }), { + code: 'ERR_INVALID_ARG_TYPE', + }); + assert.throws(() => worker.startHeapProfile({ stackDepth: 1.1 }), { + code: 'ERR_OUT_OF_RANGE', + }); + assert.throws(() => worker.startHeapProfile({ stackDepth: -1 }), { + code: 'ERR_OUT_OF_RANGE', + }); + + assert.throws(() => worker.startHeapProfile({ forceGC: 'true' }), { + code: 'ERR_INVALID_ARG_TYPE', + }); + assert.throws( + () => worker.startHeapProfile({ includeObjectsCollectedByMajorGC: 1 }), { + code: 'ERR_INVALID_ARG_TYPE', + }); + assert.throws( + () => worker.startHeapProfile({ includeObjectsCollectedByMinorGC: 1 }), { + code: 'ERR_INVALID_ARG_TYPE', + }); + + { + const handle = await worker.startHeapProfile({ + sampleInterval: 1024, + stackDepth: 8, + forceGC: true, + includeObjectsCollectedByMajorGC: true, + includeObjectsCollectedByMinorGC: true, + }); + JSON.parse(await handle.stop()); + // Stop again returns cached result. + JSON.parse(await handle.stop()); + } + + { + const handle = await worker.startHeapProfile(); + try { + await worker.startHeapProfile(); + } catch (err) { + assert.strictEqual(err.code, 'ERR_HEAP_PROFILE_HAVE_BEEN_STARTED'); + } + JSON.parse(await handle.stop()); + } + + worker.terminate(); +})); + +worker.once('exit', common.mustCall(async () => { + await assert.rejects(worker.startHeapProfile(), { + code: 'ERR_WORKER_NOT_RUNNING' + }); +})); diff --git a/test/js/node/test/parallel/test-worker-heap-statistics.js b/test/js/node/test/parallel/test-worker-heap-statistics.js new file mode 100644 index 000000000000..ba3165aa24ab --- /dev/null +++ b/test/js/node/test/parallel/test-worker-heap-statistics.js @@ -0,0 +1,64 @@ +'use strict'; + +const common = require('../common'); +const fixtures = require('../common/fixtures'); + +common.skipIfInspectorDisabled(); + +const { + Worker, + isMainThread, +} = require('worker_threads'); + +if (!isMainThread) { + common.skip('This test only works on a main thread'); +} + +// Ensures that worker.getHeapStatistics() returns valid data + +const assert = require('assert'); + +if (isMainThread) { + const name = 'Hello Thread'; + const worker = new Worker(fixtures.path('worker-name.js'), { + name, + }); + worker.once('message', common.mustCall(async (message) => { + const stats = await worker.getHeapStatistics(); + const keys = [ + `total_heap_size`, + `total_heap_size_executable`, + `total_physical_size`, + `total_available_size`, + `used_heap_size`, + `heap_size_limit`, + `malloced_memory`, + `peak_malloced_memory`, + `does_zap_garbage`, + `number_of_native_contexts`, + `number_of_detached_contexts`, + `total_global_handles_size`, + `used_global_handles_size`, + `external_memory`, + `total_allocated_bytes`, + ].sort(); + assert.deepStrictEqual(keys, Object.keys(stats).sort()); + for (const key of keys) { + if (key === 'does_zap_garbage') { + assert.strictEqual(typeof stats[key], 'boolean', `Expected ${key} to be a boolean`); + continue; + } + assert.strictEqual(typeof stats[key], 'number', `Expected ${key} to be a number`); + assert.ok(stats[key] >= 0, `Expected ${key} to be >= 0`); + } + + worker.postMessage('done'); + })); + + worker.once('exit', common.mustCall(async (code) => { + assert.strictEqual(code, 0); + await assert.rejects(worker.getHeapStatistics(), { + code: 'ERR_WORKER_NOT_RUNNING' + }); + })); +} diff --git a/test/js/node/test/parallel/test-worker-heapdump-failure.js b/test/js/node/test/parallel/test-worker-heapdump-failure.js new file mode 100644 index 000000000000..c5d24cdcf658 --- /dev/null +++ b/test/js/node/test/parallel/test-worker-heapdump-failure.js @@ -0,0 +1,30 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const { Worker } = require('worker_threads'); +const { once } = require('events'); + +(async function() { + const w = new Worker('', { eval: true }); + + await once(w, 'exit'); + await assert.rejects(() => w.getHeapSnapshot(), { + name: 'Error', + code: 'ERR_WORKER_NOT_RUNNING' + }); +})().then(common.mustCall()); + +(async function() { + const worker = new Worker('setInterval(() => {}, 1000);', { eval: true }); + await once(worker, 'online'); + + [1, true, [], null, Infinity, NaN].forEach((i) => { + assert.throws(() => worker.getHeapSnapshot(i), { + code: 'ERR_INVALID_ARG_TYPE', + name: 'TypeError', + message: 'The "options" argument must be of type object.' + + common.invalidArgTypeHelper(i) + }); + }); + await worker.terminate(); +})().then(common.mustCall()); diff --git a/test/js/node/test/parallel/test-worker-message-channel.js b/test/js/node/test/parallel/test-worker-message-channel.js new file mode 100644 index 000000000000..1d7550753932 --- /dev/null +++ b/test/js/node/test/parallel/test-worker-message-channel.js @@ -0,0 +1,48 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const { MessageChannel, MessagePort, Worker } = require('worker_threads'); + +// Asserts that freezing the EventTarget prototype does not make the internal throw. +Object.freeze(EventTarget.prototype); + +{ + const channel = new MessageChannel(); + + channel.port1.on('message', common.mustCall(({ typedArray }) => { + assert.deepStrictEqual(typedArray, new Uint8Array([0, 1, 2, 3, 4])); + })); + + const typedArray = new Uint8Array([0, 1, 2, 3, 4]); + channel.port2.postMessage({ typedArray }, [ typedArray.buffer ]); + assert.strictEqual(typedArray.buffer.byteLength, 0); + channel.port2.close(); +} + +{ + const channel = new MessageChannel(); + + channel.port1.on('close', common.mustCall()); + channel.port2.on('close', common.mustCall()); + channel.port2.close(); +} + +{ + const channel = new MessageChannel(); + + const w = new Worker(` + const { MessagePort } = require('worker_threads'); + const assert = require('assert'); + require('worker_threads').parentPort.on('message', ({ port }) => { + assert(port instanceof MessagePort); + port.postMessage('works'); + }); + `, { eval: true }); + w.postMessage({ port: channel.port2 }, [ channel.port2 ]); + assert(channel.port1 instanceof MessagePort); + assert(channel.port2 instanceof MessagePort); + channel.port1.on('message', common.mustCall((message) => { + assert.strictEqual(message, 'works'); + w.terminate(); + })); +} diff --git a/test/js/node/test/parallel/test-worker-message-event.js b/test/js/node/test/parallel/test-worker-message-event.js index d6dc5cfa9aed..5768514f715a 100644 --- a/test/js/node/test/parallel/test-worker-message-event.js +++ b/test/js/node/test/parallel/test-worker-message-event.js @@ -3,8 +3,9 @@ require('../common'); const assert = require('assert'); const dummyPort = new MessageChannel().port1; + { - for (const [args, expected] of [ + for (const [ args, expected ] of [ [ ['message'], { @@ -59,32 +60,32 @@ const dummyPort = new MessageChannel().port1; { assert.throws(() => { new MessageEvent('message', { source: 1 }); - }, e => ( - e.name == 'TypeError' && - e.message.includes('eventInitDict.source') && e.message.match(/(an instance of|of type) MessagePort/) && e.message.includes('1') - )); + }, { + name: 'TypeError', + message: /MessageEvent constructor: Expected eventInitDict\.source \("1"\) to be an instance of MessagePort\./, + }); assert.throws(() => { new MessageEvent('message', { source: {} }); - }, e => ( - e.name == 'TypeError' && - e.message.includes('eventInitDict.source') && e.message.match(/(an instance of|of type) MessagePort/) && (e.message.includes('{}') || e.message.includes('an instance of Object')) - )); + }, { + name: 'TypeError', + message: /MessageEvent constructor: Expected eventInitDict\.source \("\{\}"\) to be an instance of MessagePort\./, + }); assert.throws(() => { new MessageEvent('message', { ports: 0 }); }, { - message: /MessageEvent constructor: eventInitDict\.ports( \(0\))? is not iterable\./, + message: /MessageEvent constructor: eventInitDict\.ports \(0\) is not iterable\./, }); assert.throws(() => { new MessageEvent('message', { ports: [ null ] }); }, { name: 'TypeError', - message: /MessageEvent constructor: Expected (every item of )?eventInitDict\.ports(\[0\])? (\("null"\) )?to be an instance of MessagePort\./, + message: /MessageEvent constructor: Expected eventInitDict\.ports\[0\] \("null"\) to be an instance of MessagePort\./, }); assert.throws(() => { new MessageEvent('message', { ports: [ {} ] }); }, { name: 'TypeError', - message: /MessageEvent constructor: Expected (every item of )?eventInitDict\.ports(\[0\])? (\("\{\}"\) )?to be an instance of MessagePort\./, + message: /MessageEvent constructor: Expected eventInitDict\.ports\[0\] \("\{\}"\) to be an instance of MessagePort\./, }); } diff --git a/test/js/node/test/parallel/test-worker-message-mark-as-uncloneable.js b/test/js/node/test/parallel/test-worker-message-mark-as-uncloneable.js new file mode 100644 index 000000000000..2ec8333501a0 --- /dev/null +++ b/test/js/node/test/parallel/test-worker-message-mark-as-uncloneable.js @@ -0,0 +1,70 @@ +'use strict'; + +require('../common'); +const assert = require('assert'); +const { markAsUncloneable } = require('node:worker_threads'); +const { mustCall } = require('../common'); + +const expectedErrorName = 'DataCloneError'; + +// Uncloneables cannot be cloned during message posting +{ + const anyObject = { foo: 'bar' }; + markAsUncloneable(anyObject); + const { port1 } = new MessageChannel(); + assert.throws(() => port1.postMessage(anyObject), { + constructor: DOMException, + name: expectedErrorName, + code: 25, + }, `Should throw ${expectedErrorName} when posting uncloneables`); +} + +// Uncloneables cannot be cloned during structured cloning +{ + class MockResponse extends Response { + constructor() { + super(); + markAsUncloneable(this); + } + } + structuredClone(MockResponse.prototype); + + markAsUncloneable(MockResponse.prototype); + const r = new MockResponse(); + assert.throws(() => structuredClone(r), { + constructor: DOMException, + name: expectedErrorName, + code: 25, + }, `Should throw ${expectedErrorName} when cloning uncloneables`); +} + +// markAsUncloneable cannot affect ArrayBuffer +{ + const pooledBuffer = new ArrayBuffer(8); + const { port1, port2 } = new MessageChannel(); + markAsUncloneable(pooledBuffer); + port1.postMessage(pooledBuffer); + port2.on('message', mustCall((value) => { + assert.deepStrictEqual(value, pooledBuffer); + port2.close(mustCall()); + })); +} + +// markAsUncloneable can affect Node.js built-in object like Blob +{ + const cloneableBlob = new Blob(); + const { port1, port2 } = new MessageChannel(); + port1.postMessage(cloneableBlob); + port2.on('message', mustCall((value) => { + assert.deepStrictEqual(value, cloneableBlob); + port2.close(mustCall()); + })); + + const uncloneableBlob = new Blob(); + markAsUncloneable(uncloneableBlob); + assert.throws(() => port1.postMessage(uncloneableBlob), { + constructor: DOMException, + name: expectedErrorName, + code: 25, + }, `Should throw ${expectedErrorName} when cloning uncloneables`); +} diff --git a/test/js/node/test/parallel/test-worker-message-port-arraybuffer.js b/test/js/node/test/parallel/test-worker-message-port-arraybuffer.js new file mode 100644 index 000000000000..a30378674a31 --- /dev/null +++ b/test/js/node/test/parallel/test-worker-message-port-arraybuffer.js @@ -0,0 +1,26 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); + +const { MessageChannel } = require('worker_threads'); + +{ + const { port1, port2 } = new MessageChannel(); + + const arrayBuffer = new ArrayBuffer(40); + const typedArray = new Uint32Array(arrayBuffer); + typedArray[0] = 0x12345678; + + port1.postMessage(typedArray, [ arrayBuffer ]); + assert.strictEqual(arrayBuffer.byteLength, 0); + // Transferring again should throw a DataCloneError. + assert.throws(() => port1.postMessage(typedArray, [ arrayBuffer ]), { + code: 25, + name: 'DataCloneError', + }); + + port2.on('message', common.mustCall((received) => { + assert.strictEqual(received[0], 0x12345678); + port2.close(common.mustCall()); + })); +} diff --git a/test/js/node/test/parallel/test-worker-message-port-close-while-receiving.js b/test/js/node/test/parallel/test-worker-message-port-close-while-receiving.js new file mode 100644 index 000000000000..d6f73caff1fb --- /dev/null +++ b/test/js/node/test/parallel/test-worker-message-port-close-while-receiving.js @@ -0,0 +1,15 @@ +'use strict'; +const common = require('../common'); + +const { MessageChannel } = require('worker_threads'); + +// Make sure that closing a message port while receiving messages on it does +// not stop messages that are already in the queue from being emitted. + +const { port1, port2 } = new MessageChannel(); + +port1.on('message', common.mustCall(() => { + port1.close(); +}, 2)); +port2.postMessage('foo'); +port2.postMessage('bar'); diff --git a/test/js/node/test/parallel/test-worker-message-port-close.js b/test/js/node/test/parallel/test-worker-message-port-close.js new file mode 100644 index 000000000000..6562824d6a9e --- /dev/null +++ b/test/js/node/test/parallel/test-worker-message-port-close.js @@ -0,0 +1,49 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const { MessageChannel, moveMessagePortToContext } = require('worker_threads'); + +// Make sure that .start() and .stop() do not throw on closing/closed +// MessagePorts. +// Refs: https://github.com/nodejs/node/issues/26463 + +function dummy() {} + +{ + const { port1, port2 } = new MessageChannel(); + port1.close(common.mustCall(() => { + port1.on('message', dummy); + port1.off('message', dummy); + port2.on('message', dummy); + port2.off('message', dummy); + })); + port1.on('message', dummy); + port1.off('message', dummy); + port2.on('message', dummy); + port2.off('message', dummy); +} + +{ + const { port1 } = new MessageChannel(); + port1.on('message', dummy); + port1.close(common.mustCall(() => { + port1.off('message', dummy); + })); +} + +{ + const { port2 } = new MessageChannel(); + port2.close(); + assert.throws(() => moveMessagePortToContext(port2, {}), { + code: 'ERR_CLOSED_MESSAGE_PORT', + message: 'Cannot send data on closed MessagePort' + }); +} + +// Refs: https://github.com/nodejs/node/issues/42296 +{ + const ch = new MessageChannel(); + ch.port1.onmessage = common.mustNotCall(); + ch.port2.close(); + ch.port2.postMessage('fhqwhgads'); +} diff --git a/test/js/node/test/parallel/test-worker-message-port-drain.js b/test/js/node/test/parallel/test-worker-message-port-drain.js new file mode 100644 index 000000000000..6eca13e3b710 --- /dev/null +++ b/test/js/node/test/parallel/test-worker-message-port-drain.js @@ -0,0 +1,40 @@ +'use strict'; +require('../common'); + +// This test ensures that the messages from the internal +// message port are drained before the call to 'kDispose', +// and so all the stdio messages from the worker are processed +// in the parent and are pushed to their target streams. + +const assert = require('assert'); +const { + Worker, + isMainThread, + parentPort, + threadId, +} = require('worker_threads'); + +if (isMainThread) { + const workerIdsToOutput = new Map(); + + for (let i = 0; i < 2; i++) { + const worker = new Worker(__filename, { stdout: true }); + const workerOutput = []; + workerIdsToOutput.set(worker.threadId, workerOutput); + worker.on('message', console.log); + worker.stdout.on('data', (chunk) => { + workerOutput.push(chunk.toString().trim()); + }); + } + + process.on('exit', () => { + for (const [threadId, workerOutput] of workerIdsToOutput) { + assert.ok(workerOutput.includes(`1 threadId: ${threadId}`)); + assert.ok(workerOutput.includes(`2 threadId: ${threadId}`)); + } + }); +} else { + console.log(`1 threadId: ${threadId}`); + console.log(`2 threadId: ${threadId}`); + parentPort.postMessage(Array(100).fill(1)); +} diff --git a/test/js/node/test/parallel/test-worker-message-port-message-before-close.js b/test/js/node/test/parallel/test-worker-message-port-message-before-close.js new file mode 100644 index 000000000000..7edf0655ab88 --- /dev/null +++ b/test/js/node/test/parallel/test-worker-message-port-message-before-close.js @@ -0,0 +1,38 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const { once } = require('events'); +const { Worker, MessageChannel } = require('worker_threads'); + +// This is a regression test for the race condition underlying +// https://github.com/nodejs/node/issues/22762. +// It ensures that all messages send before a MessagePort#close() call are +// received. Previously, what could happen was a race condition like this: +// - Thread 1 sends message A +// - Thread 2 begins receiving/emitting message A +// - Thread 1 sends message B +// - Thread 1 closes its side of the channel +// - Thread 2 finishes receiving/emitting message A +// - Thread 2 sees that the port should be closed +// - Thread 2 closes the port, discarding message B in the process. + +async function test() { + const worker = new Worker(` + require('worker_threads').parentPort.on('message', ({ port }) => { + port.postMessage('firstMessage'); + port.postMessage('lastMessage'); + port.close(); + }); + `, { eval: true }); + + for (let i = 0; i < 10000; i++) { + const { port1, port2 } = new MessageChannel(); + worker.postMessage({ port: port2 }, [ port2 ]); + assert.deepStrictEqual(await once(port1, 'message'), ['firstMessage']); + assert.deepStrictEqual(await once(port1, 'message'), ['lastMessage']); + } + + await worker.terminate(); +} + +test().then(common.mustCall()); diff --git a/test/js/node/test/parallel/test-worker-message-port-message-port-transferring.js b/test/js/node/test/parallel/test-worker-message-port-message-port-transferring.js new file mode 100644 index 000000000000..b4f572666514 --- /dev/null +++ b/test/js/node/test/parallel/test-worker-message-port-message-port-transferring.js @@ -0,0 +1,22 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); + +const { MessageChannel } = require('worker_threads'); + +{ + const { port1: basePort1, port2: basePort2 } = new MessageChannel(); + const { + port1: transferredPort1, port2: transferredPort2 + } = new MessageChannel(); + + basePort1.postMessage({ transferredPort1 }, [ transferredPort1 ]); + basePort2.on('message', common.mustCall(({ transferredPort1 }) => { + transferredPort1.postMessage('foobar'); + transferredPort2.on('message', common.mustCall((msg) => { + assert.strictEqual(msg, 'foobar'); + transferredPort1.close(common.mustCall()); + basePort1.close(common.mustCall()); + })); + })); +} diff --git a/test/js/node/test/parallel/test-worker-message-port-multiple-sharedarraybuffers.js b/test/js/node/test/parallel/test-worker-message-port-multiple-sharedarraybuffers.js new file mode 100644 index 000000000000..efec7f0190d2 --- /dev/null +++ b/test/js/node/test/parallel/test-worker-message-port-multiple-sharedarraybuffers.js @@ -0,0 +1,17 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const { MessageChannel } = require('worker_threads'); + +// Regression test for https://github.com/nodejs/node/issues/28559 + +const obj = [ + [ new SharedArrayBuffer(0), new SharedArrayBuffer(1) ], + [ new SharedArrayBuffer(2), new SharedArrayBuffer(3) ], +]; + +const { port1, port2 } = new MessageChannel(); +port1.once('message', common.mustCall((message) => { + assert.deepStrictEqual(message, obj); +})); +port2.postMessage(obj); diff --git a/test/js/node/test/parallel/test-worker-message-port-transfer-closed.js b/test/js/node/test/parallel/test-worker-message-port-transfer-closed.js new file mode 100644 index 000000000000..d8ec04cbd250 --- /dev/null +++ b/test/js/node/test/parallel/test-worker-message-port-transfer-closed.js @@ -0,0 +1,57 @@ +'use strict'; + +const common = require('../common'); +const assert = require('assert'); +const { MessageChannel } = require('worker_threads'); + +// This tests various behaviors around transferring MessagePorts with closing +// or closed handles. + +const { port1, port2 } = new MessageChannel(); + +const arrayBuf = new ArrayBuffer(10); +port1.onmessage = common.mustNotCall(); +port2.onmessage = common.mustNotCall(); + +function testSingle(closedPort, potentiallyOpenPort) { + assert.throws(common.mustCall(() => { + potentiallyOpenPort.postMessage(null, [arrayBuf, closedPort]); + }), common.mustCall((err) => { + assert.strictEqual(err.name, 'DataCloneError'); + assert.strictEqual(err.message, + 'MessagePort in transfer list is already detached'); + assert.strictEqual(err.code, 25); + assert.ok(err instanceof Error); + + const DOMException = err.constructor; + assert.ok(err instanceof DOMException); + assert.strictEqual(DOMException.name, 'DOMException'); + + return true; + })); + + // arrayBuf must not be transferred, even though it is present earlier in the + // transfer list than the closedPort. + assert.strictEqual(arrayBuf.byteLength, 10); +} + +function testBothClosed() { + testSingle(port1, port2); + testSingle(port2, port1); +} + +// Even though the port handles may not be completely closed in C++ land, the +// observable behavior must be that the closing/detachment is synchronous and +// instant. + +port1.close(common.mustCall(testBothClosed)); +testSingle(port1, port2); +port2.close(common.mustCall(testBothClosed)); +testBothClosed(); + +function tickUnref(n, fn) { + if (n === 0) return fn(); + setImmediate(tickUnref, n - 1, fn).unref(); +} + +tickUnref(10, common.mustNotCall('The communication channel is still open')); diff --git a/test/js/node/test/parallel/test-worker-message-port-transfer-self.js b/test/js/node/test/parallel/test-worker-message-port-transfer-self.js new file mode 100644 index 000000000000..224ef1df2a87 --- /dev/null +++ b/test/js/node/test/parallel/test-worker-message-port-transfer-self.js @@ -0,0 +1,44 @@ +'use strict'; + +const common = require('../common'); +const assert = require('assert'); +const util = require('util'); +const { MessageChannel } = require('worker_threads'); +const tick = require('../common/tick'); + +const { port1, port2 } = new MessageChannel(); + +assert.throws(common.mustCall(() => { + port1.postMessage(null, [port1]); +}), common.mustCall((err) => { + assert.strictEqual(err.name, 'DataCloneError'); + assert.strictEqual(err.message, 'Transfer list contains source port'); + assert.strictEqual(err.code, 25); + assert.ok(err instanceof Error); + + const DOMException = err.constructor; + assert.ok(err instanceof DOMException); + assert.strictEqual(DOMException.name, 'DOMException'); + + return true; +})); + +// The failed transfer should not affect the ports in anyway. +port2.onmessage = common.mustCall((message) => { + assert.strictEqual(message.data, 2); + + const inspectedPort1 = util.inspect(port1); + const inspectedPort2 = util.inspect(port2); + assert(inspectedPort1.includes('active: true'), inspectedPort1); + assert(inspectedPort2.includes('active: true'), inspectedPort2); + + port1.close(); + + tick(10, common.mustCall(() => { + const inspectedPort1 = util.inspect(port1); + const inspectedPort2 = util.inspect(port2); + assert(inspectedPort1.includes('active: false'), inspectedPort1); + assert(inspectedPort2.includes('active: false'), inspectedPort2); + }, 1)); +}); +port1.postMessage(2); diff --git a/test/js/node/test/parallel/test-worker-message-port-transfer-target.js b/test/js/node/test/parallel/test-worker-message-port-transfer-target.js new file mode 100644 index 000000000000..638591023fb3 --- /dev/null +++ b/test/js/node/test/parallel/test-worker-message-port-transfer-target.js @@ -0,0 +1,22 @@ +'use strict'; + +const common = require('../common'); +const assert = require('assert'); +const { MessageChannel } = require('worker_threads'); + +const { port1, port2 } = new MessageChannel(); + +const arrayBuf = new ArrayBuffer(10); + +common.expectWarning('Warning', + 'The target port was posted to itself, and the ' + + 'communication channel was lost'); +port2.onmessage = common.mustNotCall(); +port2.postMessage(null, [port1, arrayBuf]); + +// arrayBuf must be transferred, despite the fact that port2 never received the +// message. +assert.strictEqual(arrayBuf.byteLength, 0); + +setTimeout(common.mustNotCall('The communication channel is still open'), + common.platformTimeout(1000)).unref(); diff --git a/test/js/node/test/parallel/test-worker-message-port.js b/test/js/node/test/parallel/test-worker-message-port.js new file mode 100644 index 000000000000..baaeb23a860b --- /dev/null +++ b/test/js/node/test/parallel/test-worker-message-port.js @@ -0,0 +1,185 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); + +const { MessageChannel, MessagePort } = require('worker_threads'); + +{ + const { port1, port2 } = new MessageChannel(); + assert(port1 instanceof MessagePort); + assert(port2 instanceof MessagePort); + + const input = { a: 1 }; + port1.postMessage(input); + port2.on('message', common.mustCall((received) => { + assert.deepStrictEqual(received, input); + port2.close(common.mustCall()); + })); +} +{ + // Test emitting non-message events on a port + const { port2 } = new MessageChannel(); + port2.addEventListener('foo', common.mustCall((received) => { + assert.strictEqual(received.type, 'foo'); + assert.strictEqual(received.detail, 'bar'); + })); + port2.on('foo', common.mustCall((received) => { + assert.strictEqual(received, 'bar'); + })); + port2.emit('foo', 'bar'); +} +{ + const { port1, port2 } = new MessageChannel(); + + port1.onmessage = common.mustCall((message) => { + assert.strictEqual(message.data, 4); + assert.strictEqual(message.target, port1); + assert.deepStrictEqual(message.ports, []); + port2.close(common.mustCall()); + }); + + port1.postMessage(2); + + port2.onmessage = common.mustCall((message) => { + port2.postMessage(message.data * 2); + }); +} + +{ + const { port1, port2 } = new MessageChannel(); + + const input = { a: 1 }; + port1.postMessage(input); + // Check that the message still gets delivered if `port2` has its + // `on('message')` handler attached at a later point in time. + setImmediate(common.mustCall(() => { + port2.on('message', common.mustCall((received) => { + assert.deepStrictEqual(received, input); + port2.close(common.mustCall()); + })); + })); +} + +{ + const { port1, port2 } = new MessageChannel(); + + const input = { a: 1 }; + + const dummy = common.mustNotCall(); + // Check that the message still gets delivered if `port2` has its + // `on('message')` handler attached at a later point in time, even if a + // listener was removed previously. + port2.addListener('message', dummy); + setImmediate(common.mustCall(() => { + port2.removeListener('message', dummy); + port1.postMessage(input); + setImmediate(common.mustCall(() => { + port2.on('message', common.mustCall((received) => { + assert.deepStrictEqual(received, input); + port2.close(common.mustCall()); + })); + })); + })); +} + +{ + const { port1, port2 } = new MessageChannel(); + port2.on('message', common.mustCall(6)); + port1.postMessage(1, null); + port1.postMessage(2, undefined); + port1.postMessage(3, []); + port1.postMessage(4, {}); + port1.postMessage(5, { transfer: undefined }); + port1.postMessage(6, { transfer: [] }); + + const err = { + constructor: TypeError, + code: 'ERR_INVALID_ARG_TYPE', + message: 'Optional transferList argument must be an iterable' + }; + + assert.throws(() => port1.postMessage(5, 0), err); + assert.throws(() => port1.postMessage(5, false), err); + assert.throws(() => port1.postMessage(5, 'X'), err); + assert.throws(() => port1.postMessage(5, Symbol('X')), err); + + const err2 = { + constructor: TypeError, + code: 'ERR_INVALID_ARG_TYPE', + message: 'Optional options.transfer argument must be an iterable' + }; + + assert.throws(() => port1.postMessage(5, { transfer: null }), err2); + assert.throws(() => port1.postMessage(5, { transfer: 0 }), err2); + assert.throws(() => port1.postMessage(5, { transfer: false }), err2); + assert.throws(() => port1.postMessage(5, { transfer: {} }), err2); + assert.throws(() => port1.postMessage(5, { + transfer: { [Symbol.iterator]() { return {}; } } + }), err2); + assert.throws(() => port1.postMessage(5, { + transfer: { [Symbol.iterator]() { return { next: 42 }; } } + }), err2); + assert.throws(() => port1.postMessage(5, { + transfer: { [Symbol.iterator]() { return { next: null }; } } + }), err2); + port1.close(); +} + +{ + // Make sure these ArrayBuffers end up detached, i.e. are actually being + // transferred because the transfer list provides them. + const { port1, port2 } = new MessageChannel(); + port2.on('message', common.mustCall((msg) => { + assert.strictEqual(msg.ab.byteLength, 10); + }, 4)); + + { + const ab = new ArrayBuffer(10); + port1.postMessage({ ab }, [ ab ]); + assert.strictEqual(ab.byteLength, 0); + } + + { + const ab = new ArrayBuffer(10); + port1.postMessage({ ab }, { transfer: [ ab ] }); + assert.strictEqual(ab.byteLength, 0); + } + + { + const ab = new ArrayBuffer(10); + port1.postMessage({ ab }, (function*() { yield ab; })()); + assert.strictEqual(ab.byteLength, 0); + } + + { + const ab = new ArrayBuffer(10); + port1.postMessage({ ab }, { + transfer: (function*() { yield ab; })() + }); + assert.strictEqual(ab.byteLength, 0); + } + + port1.close(); +} + +{ + // Test MessageEvent#ports + const c1 = new MessageChannel(); + const c2 = new MessageChannel(); + c1.port1.postMessage({ port: c2.port2 }, [ c2.port2 ]); + c1.port2.addEventListener('message', common.mustCall((ev) => { + assert.strictEqual(ev.ports.length, 1); + assert.strictEqual(ev.ports[0].constructor, MessagePort); + c1.port1.close(); + c2.port1.close(); + })); +} + +{ + assert.deepStrictEqual( + Object.getOwnPropertyNames(MessagePort.prototype).sort(), + [ + 'close', 'constructor', 'hasRef', 'onmessage', 'onmessageerror', + 'postMessage', 'ref', 'start', 'unref', + ]); +} diff --git a/test/js/node/test/parallel/test-worker-message-transfer-port-mark-as-untransferable.js b/test/js/node/test/parallel/test-worker-message-transfer-port-mark-as-untransferable.js new file mode 100644 index 000000000000..94ae4e8c5fd2 --- /dev/null +++ b/test/js/node/test/parallel/test-worker-message-transfer-port-mark-as-untransferable.js @@ -0,0 +1,59 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const { MessageChannel, markAsUntransferable, isMarkedAsUntransferable } = require('worker_threads'); + +{ + const ab = new ArrayBuffer(8); + + markAsUntransferable(ab); + assert.ok(isMarkedAsUntransferable(ab)); + assert.strictEqual(ab.byteLength, 8); + + const { port1 } = new MessageChannel(); + assert.throws(() => port1.postMessage(ab, [ ab ]), { + code: 25, + name: 'DataCloneError', + }); + + assert.strictEqual(ab.byteLength, 8); // The AB is not detached. +} + +{ + const channel1 = new MessageChannel(); + const channel2 = new MessageChannel(); + + markAsUntransferable(channel2.port1); + assert.ok(isMarkedAsUntransferable(channel2.port1)); + + assert.throws(() => { + channel1.port1.postMessage(channel2.port1, [ channel2.port1 ]); + }, { + code: 25, + name: 'DataCloneError', + }); + + channel2.port1.postMessage('still works, not closed/transferred'); + channel2.port2.once('message', common.mustCall()); +} + +{ + for (const value of [0, null, false, true, undefined]) { + markAsUntransferable(value); // Has no visible effect. + assert.ok(!isMarkedAsUntransferable(value)); + } + for (const value of [[], {}]) { + markAsUntransferable(value); + assert.ok(isMarkedAsUntransferable(value)); + } +} + +{ + // Verifies that the mark is not inherited. + class Foo {} + markAsUntransferable(Foo.prototype); + assert.ok(isMarkedAsUntransferable(Foo.prototype)); + + const foo = new Foo(); + assert.ok(!isMarkedAsUntransferable(foo)); +} diff --git a/test/js/node/test/parallel/test-worker-messaging-errors-handler.js b/test/js/node/test/parallel/test-worker-messaging-errors-handler.js new file mode 100644 index 000000000000..a4a0ae033d90 --- /dev/null +++ b/test/js/node/test/parallel/test-worker-messaging-errors-handler.js @@ -0,0 +1,34 @@ +'use strict'; + +const common = require('../common'); +const { + parentPort, + postMessageToThread, + Worker, + workerData, +} = require('node:worker_threads'); +const assert = require('node:assert'); + +async function test() { + const worker = new Worker(__filename, { workerData: { children: true } }); + + await assert.rejects(common.mustCall(function() { + return postMessageToThread(worker.threadId); + }), { + name: 'Error', + code: 'ERR_WORKER_MESSAGING_ERRORED', + }); + + worker.postMessage('success'); +} + +if (!workerData?.children) { + test(); +} else { + process.on('workerMessage', () => { + throw new Error('KABOOM'); + }); + + parentPort.postMessage('ready'); + parentPort.once('message', common.mustCall()); +} diff --git a/test/js/node/test/parallel/test-worker-messaging-errors-invalid.js b/test/js/node/test/parallel/test-worker-messaging-errors-invalid.js new file mode 100644 index 000000000000..e55e3485e904 --- /dev/null +++ b/test/js/node/test/parallel/test-worker-messaging-errors-invalid.js @@ -0,0 +1,48 @@ +'use strict'; + +const common = require('../common'); +const { once } = require('node:events'); +const { + parentPort, + postMessageToThread, + threadId, + Worker, + workerData, +} = require('node:worker_threads'); +const assert = require('node:assert'); + +async function test() { + await assert.rejects(common.mustCall(function() { + return postMessageToThread(threadId); + }), { + name: 'Error', + code: 'ERR_WORKER_MESSAGING_SAME_THREAD', + }); + + await assert.rejects(common.mustCall(function() { + return postMessageToThread(Date.now()); + }), { + name: 'Error', + code: 'ERR_WORKER_MESSAGING_FAILED', + }); + + // The delivery to the first worker will fail as there is no listener for `workerMessage` + const worker = new Worker(__filename, { workerData: { children: true } }); + await once(worker, 'message'); + + await assert.rejects(common.mustCall(function() { + return postMessageToThread(worker.threadId); + }), { + name: 'Error', + code: 'ERR_WORKER_MESSAGING_FAILED', + }); + + worker.postMessage('success'); +} + +if (!workerData?.children) { + test(); +} else { + parentPort.postMessage('ready'); + parentPort.once('message', common.mustCall()); +} diff --git a/test/js/node/test/parallel/test-worker-messaging-errors-timeout.js b/test/js/node/test/parallel/test-worker-messaging-errors-timeout.js new file mode 100644 index 000000000000..f069005c4110 --- /dev/null +++ b/test/js/node/test/parallel/test-worker-messaging-errors-timeout.js @@ -0,0 +1,38 @@ +'use strict'; + +const common = require('../common'); +const { + postMessageToThread, + workerData, + Worker, +} = require('node:worker_threads'); +const assert = require('node:assert'); + +const memory = new SharedArrayBuffer(4); + +async function test() { + const worker = new Worker(__filename, { workerData: { memory, children: true } }); + const array = new Int32Array(memory); + + await assert.rejects(common.mustCall(function() { + return postMessageToThread(worker.threadId, 0, common.platformTimeout(500)); + }), { + name: 'Error', + code: 'ERR_WORKER_MESSAGING_TIMEOUT', + }); + + Atomics.store(array, 0, 1); + Atomics.notify(array, 0); +} + +if (!workerData?.children) { + test(); +} else { + process.on('beforeExit', common.mustCall()); + + const array = new Int32Array(workerData.memory); + + // Starve this thread waiting for the status to be unlocked. + // This happens in the main thread AFTER the timeout. + Atomics.wait(array, 0, 0); +} diff --git a/test/js/node/test/parallel/test-worker-messaging.js b/test/js/node/test/parallel/test-worker-messaging.js new file mode 100644 index 000000000000..e29258bea130 --- /dev/null +++ b/test/js/node/test/parallel/test-worker-messaging.js @@ -0,0 +1,112 @@ +'use strict'; + +const common = require('../common'); +const Countdown = require('../common/countdown'); +const { + parentPort, + postMessageToThread, + threadId, + workerData, + Worker, +} = require('node:worker_threads'); +const assert = require('node:assert'); +const { once } = require('node:events'); + +// Spawn threads on three levels: 1 main thread, two children, four grand childrens. 7 threads total, max id = 6 +const MAX_LEVEL = 2; +const MAX_THREAD = 6; + +// This is to allow the test to run in --worker mode +const mainThread = workerData?.mainThread ?? threadId; +const level = workerData?.level ?? 0; + +const channel = new BroadcastChannel('nodejs:test-worker-connection'); +let completed; + +if (level === 0) { + completed = new Countdown(MAX_THREAD + 1, () => { + channel.postMessage('exit'); + channel.close(); + }); +} + +async function createChildren() { + const worker = new Worker(__filename, { workerData: { mainThread, level: level + 1 } }); + await once(worker, 'message'); +} + +async function ping() { + let target; + do { + target = mainThread + Math.floor(Math.random() * MAX_THREAD); + } while (target === threadId); + + const { port1, port2 } = new MessageChannel(); + await postMessageToThread(target, { level, port: port2 }, [port2]); + + port1.on('message', common.mustCall(function(message) { + assert.deepStrictEqual(message, { message: 'pong', source: target, destination: threadId }); + port1.close(); + + if (level === 0) { + completed.dec(); + } else { + channel.postMessage('end'); + } + })); + + port1.postMessage({ message: 'ping', source: threadId, destination: target }); +} + +// Do not use mustCall here as the thread might not receive any connection request +process.on('workerMessage', common.mustCallAtLeast(({ port, level }, source) => { + // Let's verify the source hierarchy + // Given we do depth first, the level is 1 for thread 1 and 4, 2 for other threads + if (source !== mainThread) { + const currentThread = source - mainThread; + assert.strictEqual(level, (currentThread === 1 || currentThread === 4) ? 1 : 2); + } else { + assert.strictEqual(level, 0); + } + + // Verify communication + port.on('message', common.mustCall(function(message) { + assert.deepStrictEqual(message, { message: 'ping', source, destination: threadId }); + port.postMessage({ message: 'pong', source: threadId, destination: source }); + port.close(); + })); +}, 0)); + +async function test() { + if (level < MAX_LEVEL) { + await createChildren(); + await createChildren(); + } + + channel.onmessage = function(message) { + switch (message.data) { + case 'start': + ping(); + break; + case 'end': + if (level === 0) { + completed.dec(); + } + break; + case 'exit': + channel.close(); + break; + } + }; + + if (level > 0) { + const currentThread = threadId - mainThread; + assert.strictEqual(level, (currentThread === 1 || currentThread === 4) ? 1 : 2); + parentPort.postMessage({ type: 'ready', threadId }); + } else { + channel.postMessage('start'); + ping(); + } +} + +test(); diff --git a/test/js/node/test/parallel/test-worker-name.js b/test/js/node/test/parallel/test-worker-name.js new file mode 100644 index 000000000000..c4676f446c42 --- /dev/null +++ b/test/js/node/test/parallel/test-worker-name.js @@ -0,0 +1,29 @@ +'use strict'; + +const common = require('../common'); +const fixtures = require('../common/fixtures'); + +common.skipIfInspectorDisabled(); + +const { + Worker, + isMainThread, +} = require('worker_threads'); + +if (!isMainThread) { + common.skip('This test only works on a main thread'); +} + +const assert = require('assert'); + +if (isMainThread) { + const name = 'Hello\0Thread'; + const expectedTitle = `[worker 1] ${name}`; + const worker = new Worker(fixtures.path('worker-name.js'), { + name, + }); + worker.once('message', common.mustCall((message) => { + assert.strictEqual(message, expectedTitle); + worker.postMessage('done'); + })); +} diff --git a/test/js/node/test/parallel/test-worker-nexttick-terminate.js b/test/js/node/test/parallel/test-worker-nexttick-terminate.js new file mode 100644 index 000000000000..257782178c6e --- /dev/null +++ b/test/js/node/test/parallel/test-worker-nexttick-terminate.js @@ -0,0 +1,17 @@ +'use strict'; +const common = require('../common'); +const { Worker } = require('worker_threads'); + +// Checks that terminating in the middle of `process.nextTick()` does not +// Crash the process. + +const w = new Worker(` +require('worker_threads').parentPort.postMessage('0'); +process.nextTick(() => { + while(1); +}); +`, { eval: true }); + +w.on('message', common.mustCall(() => { + setTimeout(common.mustCall(() => w.terminate().then(common.mustCall())), 1); +})); diff --git a/test/js/node/test/parallel/test-worker-no-stdin-stdout-interaction.js b/test/js/node/test/parallel/test-worker-no-stdin-stdout-interaction.js new file mode 100644 index 000000000000..77c0feecdaca --- /dev/null +++ b/test/js/node/test/parallel/test-worker-no-stdin-stdout-interaction.js @@ -0,0 +1,20 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const { Worker, isMainThread } = require('worker_threads'); + +// Regression test for https://github.com/nodejs/node/issues/28144. + +if (isMainThread) { + const w = new Worker(__filename); + w.on('exit', common.mustCall((status) => { + assert.strictEqual(status, 0); + })); + w.stdout.on('data', common.mustCall(10)); +} else { + process.stdin.on('data', () => {}); + + for (let i = 0; i < 10; ++i) { + process.stdout.write(`processing(${i})\n`, common.mustSucceed()); + } +} diff --git a/test/js/node/test/parallel/test-worker-process-env-shared.js b/test/js/node/test/parallel/test-worker-process-env-shared.js new file mode 100644 index 000000000000..314e5311fa19 --- /dev/null +++ b/test/js/node/test/parallel/test-worker-process-env-shared.js @@ -0,0 +1,32 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const { Worker, parentPort, SHARE_ENV, workerData } = require('worker_threads'); + +if (!workerData) { + process.env.SET_IN_PARENT = 'set'; + assert.strictEqual(process.env.SET_IN_PARENT, 'set'); + + const w = new Worker(__filename, { + workerData: 'runInWorker', + env: SHARE_ENV + }).on('exit', common.mustCall(() => { + // Env vars from the child thread are not set globally. + assert.strictEqual(process.env.SET_IN_WORKER, 'set'); + })); + + process.env.SET_IN_PARENT_AFTER_CREATION = 'set'; + w.postMessage({}); +} else { + assert.strictEqual(workerData, 'runInWorker'); + + // Env vars from the parent thread are inherited. + assert.strictEqual(process.env.SET_IN_PARENT, 'set'); + + process.env.SET_IN_WORKER = 'set'; + assert.strictEqual(process.env.SET_IN_WORKER, 'set'); + + parentPort.once('message', common.mustCall(() => { + assert.strictEqual(process.env.SET_IN_PARENT_AFTER_CREATION, 'set'); + })); +} diff --git a/test/js/node/test/parallel/test-worker-process-exit-async-module.js b/test/js/node/test/parallel/test-worker-process-exit-async-module.js new file mode 100644 index 000000000000..38d4ad74c7bd --- /dev/null +++ b/test/js/node/test/parallel/test-worker-process-exit-async-module.js @@ -0,0 +1,11 @@ +'use strict'; + +const common = require('../common'); +const assert = require('assert'); +const { Worker } = require('worker_threads'); + +// Regression for https://github.com/nodejs/node/issues/43182. +const w = new Worker(new URL('data:text/javascript,process.exit(1);await new Promise(()=>{ process.exit(2); })')); +w.on('exit', common.mustCall((code) => { + assert.strictEqual(code, 1); +})); diff --git a/test/js/node/test/parallel/test-worker-safe-getters.js b/test/js/node/test/parallel/test-worker-safe-getters.js index 69856659a577..a22f92b3354a 100644 --- a/test/js/node/test/parallel/test-worker-safe-getters.js +++ b/test/js/node/test/parallel/test-worker-safe-getters.js @@ -29,6 +29,4 @@ if (isMainThread) { assert.strictEqual(w.stdout, stdout); assert.strictEqual(w.stderr, stderr); })); -} else { - process.exit(0); } diff --git a/test/js/node/test/parallel/test-worker-stdio-flush-inflight.js b/test/js/node/test/parallel/test-worker-stdio-flush-inflight.js new file mode 100644 index 000000000000..a51656ca1ec8 --- /dev/null +++ b/test/js/node/test/parallel/test-worker-stdio-flush-inflight.js @@ -0,0 +1,23 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const { Worker, isMainThread } = require('worker_threads'); + +if (isMainThread) { + const w = new Worker(__filename, { stdout: true }); + const expected = 'hello world'; + + let data = ''; + w.stdout.setEncoding('utf8'); + w.stdout.on('data', (chunk) => { + data += chunk; + }); + + w.on('exit', common.mustCall(() => { + assert.strictEqual(data, expected); + })); +} else { + process.stdout.write('hello'); + process.stdout.write(' '); + process.stdout.write('world'); +} diff --git a/test/js/node/test/parallel/test-worker-stdio-flush.js b/test/js/node/test/parallel/test-worker-stdio-flush.js new file mode 100644 index 000000000000..e52e721fc694 --- /dev/null +++ b/test/js/node/test/parallel/test-worker-stdio-flush.js @@ -0,0 +1,25 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const { Worker, isMainThread } = require('worker_threads'); + +if (isMainThread) { + const w = new Worker(__filename, { stdout: true }); + const expected = 'hello world'; + + let data = ''; + w.stdout.setEncoding('utf8'); + w.stdout.on('data', (chunk) => { + data += chunk; + }); + + w.on('exit', common.mustCall(() => { + assert.strictEqual(data, expected); + })); +} else { + process.on('exit', () => { + process.stdout.write(' '); + process.stdout.write('world'); + }); + process.stdout.write('hello'); +} diff --git a/test/js/node/test/parallel/test-worker-stdio.js b/test/js/node/test/parallel/test-worker-stdio.js new file mode 100644 index 000000000000..82c2edad544e --- /dev/null +++ b/test/js/node/test/parallel/test-worker-stdio.js @@ -0,0 +1,42 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const fs = require('fs'); +const util = require('util'); +const { Writable } = require('stream'); +const { Worker, isMainThread } = require('worker_threads'); + +class BufferingWritable extends Writable { + constructor() { + super(); + this.chunks = []; + } + + _write(chunk, enc, cb) { + this.chunks.push(chunk); + cb(); + } + + get buffer() { + return Buffer.concat(this.chunks); + } +} + +if (isMainThread) { + const original = new BufferingWritable(); + const passed = new BufferingWritable(); + + const w = new Worker(__filename, { stdin: true, stdout: true }); + const source = fs.createReadStream(process.execPath, { end: 1_000_000 }); + source.pipe(w.stdin); + source.pipe(original); + w.stdout.pipe(passed); + + passed.on('finish', common.mustCall(() => { + assert.strictEqual(original.buffer.compare(passed.buffer), 0, + `Original: ${util.inspect(original.buffer)}, ` + + `Actual: ${util.inspect(passed.buffer)}`); + })); +} else { + process.stdin.pipe(process.stdout); +} diff --git a/test/js/node/test/parallel/test-worker-syntax-error-file.js b/test/js/node/test/parallel/test-worker-syntax-error-file.js new file mode 100644 index 000000000000..36b8913895df --- /dev/null +++ b/test/js/node/test/parallel/test-worker-syntax-error-file.js @@ -0,0 +1,18 @@ +'use strict'; +const common = require('../common'); +const fixtures = require('../common/fixtures'); +const assert = require('assert'); +const { Worker } = require('worker_threads'); + +// Do not use isMainThread so that this test itself can be run inside a Worker. +if (!process.env.HAS_STARTED_WORKER) { + process.env.HAS_STARTED_WORKER = 1; + const w = new Worker(fixtures.path('syntax', 'bad_syntax.js')); + w.on('message', common.mustNotCall()); + w.on('error', common.mustCall((err) => { + assert.strictEqual(err.constructor, SyntaxError); + assert.strictEqual(err.name, 'SyntaxError'); + })); +} else { + throw new Error('foo'); +} diff --git a/test/js/node/test/parallel/test-worker-syntax-error.js b/test/js/node/test/parallel/test-worker-syntax-error.js new file mode 100644 index 000000000000..99ebf26a9fa7 --- /dev/null +++ b/test/js/node/test/parallel/test-worker-syntax-error.js @@ -0,0 +1,11 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const { Worker } = require('worker_threads'); + +const w = new Worker('abc)', { eval: true }); +w.on('message', common.mustNotCall()); +w.on('error', common.mustCall((err) => { + assert.strictEqual(err.constructor, SyntaxError); + assert.strictEqual(err.name, 'SyntaxError'); +})); diff --git a/test/js/node/test/parallel/test-worker-terminate-microtask-loop.js b/test/js/node/test/parallel/test-worker-terminate-microtask-loop.js new file mode 100644 index 000000000000..b2351c5d0bb0 --- /dev/null +++ b/test/js/node/test/parallel/test-worker-terminate-microtask-loop.js @@ -0,0 +1,19 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const { Worker } = require('worker_threads'); + +// Verify that `.terminate()` interrupts the microtask queue. + +const worker = new Worker(` +function loop() { Promise.resolve().then(loop); } loop(); +require('worker_threads').parentPort.postMessage('up'); +`, { eval: true }); + +worker.once('message', common.mustCall(() => { + setImmediate(() => worker.terminate()); +})); + +worker.once('exit', common.mustCall((code) => { + assert.strictEqual(code, 1); +})); diff --git a/test/js/node/test/parallel/test-worker-terminate-null-handler.js b/test/js/node/test/parallel/test-worker-terminate-null-handler.js index e546e662655e..9111587eac45 100644 --- a/test/js/node/test/parallel/test-worker-terminate-null-handler.js +++ b/test/js/node/test/parallel/test-worker-terminate-null-handler.js @@ -14,8 +14,7 @@ parentPort.postMessage({ hello: 'world' }); process.once('beforeExit', common.mustCall(() => worker.ref())); worker.on('exit', common.mustCall(() => { - worker.terminate().then((res) => assert.strictEqual(res, undefined)); - + worker.terminate().then((res) => assert.strictEqual(res, undefined)).then(common.mustCall()); })); worker.unref(); diff --git a/test/js/node/test/parallel/test-worker-terminate-ref-public-port.js b/test/js/node/test/parallel/test-worker-terminate-ref-public-port.js new file mode 100644 index 000000000000..4a2de785a362 --- /dev/null +++ b/test/js/node/test/parallel/test-worker-terminate-ref-public-port.js @@ -0,0 +1,12 @@ +'use strict'; +const common = require('../common'); +const { Worker } = require('worker_threads'); + +// The actual test here is that the Worker does not keep the main thread +// running after it has been .terminate()’ed. + +const w = new Worker(` +const p = require('worker_threads').parentPort; +while(true) p.postMessage({})`, { eval: true }); +w.once('message', () => w.terminate()); +w.once('exit', common.mustCall()); diff --git a/test/js/node/test/parallel/test-worker-terminate-source-map.js b/test/js/node/test/parallel/test-worker-terminate-source-map.js new file mode 100644 index 000000000000..c855dab975be --- /dev/null +++ b/test/js/node/test/parallel/test-worker-terminate-source-map.js @@ -0,0 +1,45 @@ +'use strict'; +const common = require('../common'); +const tmpdir = require('../common/tmpdir'); +const assert = require('assert'); + +// Attempts to test that the source map JS code run on process shutdown +// does not call any user-defined JS code. + +const { Worker, workerData, parentPort } = require('worker_threads'); + +if (!workerData) { + tmpdir.refresh(); + process.env.NODE_V8_COVERAGE = tmpdir.path; + + // Count the number of some calls that should not be made. + const callCount = new Int32Array(new SharedArrayBuffer(4)); + const w = new Worker(__filename, { workerData: { callCount } }); + w.on('message', common.mustCall(() => w.terminate())); + w.on('exit', common.mustCall(() => { + assert.strictEqual(callCount[0], 0); + })); + return; +} + +const { callCount } = workerData; + +function increaseCallCount() { callCount[0]++; } + +// Increase the call count when a forbidden method is called. +for (const property of ['_cache', 'lineLengths', 'url']) { + Object.defineProperty(Object.prototype, property, { + get: increaseCallCount, + set: increaseCallCount + }); +} +Object.getPrototypeOf([][Symbol.iterator]()).next = increaseCallCount; +Object.getPrototypeOf((new Map()).entries()).next = increaseCallCount; +Array.prototype[Symbol.iterator] = increaseCallCount; +Map.prototype[Symbol.iterator] = increaseCallCount; +Map.prototype.entries = increaseCallCount; +Object.keys = increaseCallCount; +Object.create = increaseCallCount; +Object.hasOwnProperty = increaseCallCount; + +parentPort.postMessage('done'); diff --git a/test/js/node/test/parallel/test-worker-terminate-unrefed.js b/test/js/node/test/parallel/test-worker-terminate-unrefed.js new file mode 100644 index 000000000000..adf6bbf14563 --- /dev/null +++ b/test/js/node/test/parallel/test-worker-terminate-unrefed.js @@ -0,0 +1,16 @@ +'use strict'; +const common = require('../common'); +const { once } = require('events'); +const { Worker } = require('worker_threads'); + +// Test that calling worker.terminate() on an unref()’ed Worker instance +// still resolves the returned Promise. + +async function test() { + const worker = new Worker('setTimeout(() => {}, 1000000);', { eval: true }); + await once(worker, 'online'); + worker.unref(); + await worker.terminate(); +} + +test().then(common.mustCall()); diff --git a/test/js/node/test/parallel/test-worker-thread-name.js b/test/js/node/test/parallel/test-worker-thread-name.js new file mode 100644 index 000000000000..541761b4e2d1 --- /dev/null +++ b/test/js/node/test/parallel/test-worker-thread-name.js @@ -0,0 +1,17 @@ +'use strict'; + +const common = require('../common'); +const assert = require('assert'); +const { Worker, threadName, workerData } = require('worker_threads'); + +const name = 'test-worker-thread-name'; + +if (workerData?.isWorker) { + assert.strictEqual(threadName, name); +} else { + const w = new Worker(__filename, { name, workerData: { isWorker: true } }); + assert.strictEqual(w.threadName, name); + w.on('exit', common.mustCall(() => { + assert.strictEqual(w.threadName, null); + })); +} diff --git a/test/js/node/test/parallel/test-worker-uncaught-exception.js b/test/js/node/test/parallel/test-worker-uncaught-exception.js new file mode 100644 index 000000000000..217677915920 --- /dev/null +++ b/test/js/node/test/parallel/test-worker-uncaught-exception.js @@ -0,0 +1,34 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const { Worker } = require('worker_threads'); + +// Do not use isMainThread so that this test itself can be run inside a Worker. +if (!process.env.HAS_STARTED_WORKER) { + process.env.HAS_STARTED_WORKER = 1; + const w = new Worker(__filename); + w.on('message', common.mustNotCall()); + w.on('error', common.mustCall((err) => { + console.log(err.message); + assert.match(String(err), /^Error: foo$/); + })); + w.on('exit', common.mustCall((code) => { + // uncaughtException is code 1 + assert.strictEqual(code, 1); + })); +} else { + // Cannot use common.mustCall as it cannot catch this + let called = false; + process.on('exit', (code) => { + if (!called) { + called = true; + } else { + assert.fail('Exit callback called twice in worker'); + } + }); + + setTimeout(() => assert.fail('Timeout executed after uncaughtException'), + 2000); + + throw new Error('foo'); +} diff --git a/test/js/node/test/parallel/test-worker-unsupported-path.js b/test/js/node/test/parallel/test-worker-unsupported-path.js new file mode 100644 index 000000000000..b0e1f7c07cdf --- /dev/null +++ b/test/js/node/test/parallel/test-worker-unsupported-path.js @@ -0,0 +1,43 @@ +'use strict'; + +require('../common'); +const path = require('path'); +const assert = require('assert'); +const { Worker } = require('worker_threads'); + +{ + const expectedErr = { + code: 'ERR_WORKER_PATH', + name: 'TypeError' + }; + const existingRelPathNoDot = path.relative('.', __filename); + assert.throws(() => { new Worker(existingRelPathNoDot); }, expectedErr); + assert.throws(() => { new Worker('relative_no_dot'); }, expectedErr); + assert.throws(() => { new Worker('file:///file_url'); }, expectedErr); + assert.throws(() => { new Worker('https://www.url.com'); }, expectedErr); +} + +{ + assert.throws( + () => { new Worker('file:///file_url'); }, + /Wrap file:\/\/ URLs with `new URL`/ + ); + assert.throws( + () => { new Worker('data:text/javascript,'); }, + /Wrap data: URLs with `new URL`/ + ); + assert.throws( + () => { new Worker('relative_no_dot'); }, + // eslint-disable-next-line node-core/no-unescaped-regexp-dot + /^((?!Wrap file:\/\/ URLs with `new URL`).)*$/s + ); +} + +{ + const expectedErr = { + code: 'ERR_INVALID_URL_SCHEME', + name: 'TypeError' + }; + assert.throws(() => { new Worker(new URL('https://www.url.com')); }, + expectedErr); +} diff --git a/test/js/node/test/parallel/test-worker-unsupported-things.js b/test/js/node/test/parallel/test-worker-unsupported-things.js new file mode 100644 index 000000000000..95d93d24dec9 --- /dev/null +++ b/test/js/node/test/parallel/test-worker-unsupported-things.js @@ -0,0 +1,70 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const { Worker, parentPort } = require('worker_threads'); + +// Do not use isMainThread so that this test itself can be run inside a Worker. +if (!process.env.HAS_STARTED_WORKER) { + process.env.HAS_STARTED_WORKER = 1; + process.env.NODE_CHANNEL_FD = 'foo'; // Make worker think it has IPC. + const w = new Worker(__filename); + w.on('message', common.mustCall((message) => { + assert.strictEqual(message, true); + })); +} else { + { + const before = process.title; + const after = before + ' in worker'; + process.title = after; + assert.strictEqual(process.title, after); + } + + { + const before = process.debugPort; + const after = before + 1; + process.debugPort = after; + assert.strictEqual(process.debugPort, after); + } + + { + const mask = 0o600; + assert.throws(() => { process.umask(mask); }, { + code: 'ERR_WORKER_UNSUPPORTED_OPERATION', + message: 'Setting process.umask() is not supported in workers' + }); + } + + const stubs = ['abort', 'chdir', 'send', 'disconnect']; + + if (!common.isWindows) { + stubs.push('setuid', 'seteuid', 'setgid', + 'setegid', 'setgroups', 'initgroups'); + } + + stubs.forEach((fn) => { + assert.strictEqual(process[fn].disabled, true); + assert.throws(() => { + process[fn](); + }, { + code: 'ERR_WORKER_UNSUPPORTED_OPERATION', + message: `process.${fn}() is not supported in workers` + }); + }); + + ['channel', 'connected'].forEach((fn) => { + assert.throws(() => { + process[fn]; // eslint-disable-line no-unused-expressions + }, { + code: 'ERR_WORKER_UNSUPPORTED_OPERATION', + message: `process.${fn} is not supported in workers` + }); + }); + + assert.strictEqual('_startProfilerIdleNotifier' in process, false); + assert.strictEqual('_stopProfilerIdleNotifier' in process, false); + assert.strictEqual('_debugProcess' in process, false); + assert.strictEqual('_debugPause' in process, false); + assert.strictEqual('_debugEnd' in process, false); + + parentPort.postMessage(true); +} diff --git a/test/js/node/test/parallel/test-worker-voluntarily-exit-followed-by-addition.js b/test/js/node/test/parallel/test-worker-voluntarily-exit-followed-by-addition.js new file mode 100644 index 000000000000..9f152bd5c62b --- /dev/null +++ b/test/js/node/test/parallel/test-worker-voluntarily-exit-followed-by-addition.js @@ -0,0 +1,18 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const { Worker, isMainThread } = require('worker_threads'); + +if (isMainThread) { + const workerData = new Int32Array(new SharedArrayBuffer(4)); + new Worker(__filename, { + workerData, + }); + process.on('beforeExit', common.mustCall(() => { + assert.strictEqual(workerData[0], 0); + })); +} else { + const { workerData } = require('worker_threads'); + process.exit(); + workerData[0] = 1; +} diff --git a/test/js/node/test/parallel/test-worker-voluntarily-exit-followed-by-throw.js b/test/js/node/test/parallel/test-worker-voluntarily-exit-followed-by-throw.js new file mode 100644 index 000000000000..92c4d5596cbd --- /dev/null +++ b/test/js/node/test/parallel/test-worker-voluntarily-exit-followed-by-throw.js @@ -0,0 +1,23 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const { Worker, isMainThread } = require('worker_threads'); + +if (isMainThread) { + const workerData = new Int32Array(new SharedArrayBuffer(4)); + new Worker(__filename, { + workerData, + }); + process.on('beforeExit', common.mustCall(() => { + assert.strictEqual(workerData[0], 0); + })); +} else { + const { workerData } = require('worker_threads'); + try { + process.exit(); + throw new Error('xxx'); + // eslint-disable-next-line no-unused-vars + } catch (err) { + workerData[0] = 1; + } +} diff --git a/test/js/node/test/parallel/test-worker-workerdata-messageport.js b/test/js/node/test/parallel/test-worker-workerdata-messageport.js new file mode 100644 index 000000000000..a30a77805f0e --- /dev/null +++ b/test/js/node/test/parallel/test-worker-workerdata-messageport.js @@ -0,0 +1,89 @@ +'use strict'; + +const common = require('../common'); +const assert = require('node:assert'); + +const { + Worker, MessageChannel +} = require('node:worker_threads'); + +const channel = new MessageChannel(); +const workerData = { message: channel.port1 }; +const transferList = [channel.port1]; +const meowScript = () => 'meow'; + +{ + // Should receive the transferList param. + new Worker(`${meowScript}`, { eval: true, workerData, transferList }); +} + +{ + // Should work with more than one MessagePort. + const channel1 = new MessageChannel(); + const channel2 = new MessageChannel(); + const workerData = { message: channel1.port1, message2: channel2.port1 }; + const transferList = [channel1.port1, channel2.port1]; + new Worker(`${meowScript}`, { eval: true, workerData, transferList }); +} + +{ + const uint8Array = new Uint8Array([ 1, 2, 3, 4 ]); + assert.strictEqual(uint8Array.length, 4); + new Worker(` + const { parentPort, workerData } = require('worker_threads'); + parentPort.postMessage(workerData); + `, { + eval: true, + workerData: uint8Array, + transferList: [uint8Array.buffer] + }).on( + 'message', + common.mustCall((message) => + assert.deepStrictEqual(message, Uint8Array.of(1, 2, 3, 4)) + )); + assert.strictEqual(uint8Array.length, 0); +} + +{ + // Should throw on non valid transferList input. + const channel1 = new MessageChannel(); + const channel2 = new MessageChannel(); + const workerData = { message: channel1.port1, message2: channel2.port1 }; + assert.throws(() => new Worker(`${meowScript}`, { + eval: true, + workerData, + transferList: [] + }), { + constructor: DOMException, + name: 'DataCloneError', + code: 25, + message: 'Object that needs transfer was found in message but not ' + + 'listed in transferList' + }); +} + +{ + // Should not crash when MessagePort is transferred to another context. + // https://github.com/nodejs/node/issues/49075 + const channel = new MessageChannel(); + new Worker(` + const { runInContext, createContext } = require('node:vm') + const { workerData } = require('worker_threads'); + const context = createContext(Object.create(null)); + context.messagePort = workerData.messagePort; + runInContext( + \`messagePort.postMessage("Meow")\`, + context, + { displayErrors: true } + ); + `, { + eval: true, + workerData: { messagePort: channel.port2 }, + transferList: [channel.port2] + }); + channel.port1.on( + 'message', + common.mustCall((message) => + assert.strictEqual(message, 'Meow') + )); +} diff --git a/test/js/node/worker_threads/fixture-share-env-tree.js b/test/js/node/worker_threads/fixture-share-env-tree.js new file mode 100644 index 000000000000..e6bcc4db95cf --- /dev/null +++ b/test/js/node/worker_threads/fixture-share-env-tree.js @@ -0,0 +1,161 @@ +// SHARE_ENV shares the *creating thread's* environment store, not a process-wide +// one (node_worker.cc: `env_vars = env->env_vars()`), so disjoint SHARE_ENV chains +// stay isolated. A subprocess, so the runner's own process.env is never mutated. +const { Worker, isMainThread, workerData, parentPort, SHARE_ENV } = require("worker_threads"); + +function spawn(data, env) { + return new Promise((resolve, reject) => { + const options = { workerData: data }; + if (env !== undefined) options.env = env; + const worker = new Worker(__filename, options); + let out = null; + worker.on("message", m => (out = m)); + worker.on("error", reject); + worker.on("exit", code => (code === 0 ? resolve(out) : reject(new Error(`worker ${data.role} exited ${code}`)))); + }); +} + +const orNull = v => (v === undefined ? null : v); + +async function main() { + const mode = process.argv[2]; + + if (mode === "tree") { + process.env.FROM_MAIN = "main"; + const a = await spawn({ role: "A", mode }); // default env => snapshot of main's env + const c = await spawn({ role: "C", mode }, SHARE_ENV); // shares main's env store + console.log( + JSON.stringify({ + B_sees_FROM_A: a.B.B_sees_FROM_A, + B_sees_FROM_MAIN: a.B.B_sees_FROM_MAIN, + A_sees_FROM_B: a.A_sees_FROM_B, + C_sees_FROM_B: c.C_sees_FROM_B, + C_sees_FROM_MAIN: c.C_sees_FROM_MAIN, + main_sees_FROM_B: orNull(process.env.FROM_B), + main_sees_FROM_C: orNull(process.env.FROM_C), + }), + ); + return; + } + + if (mode === "clobber") { + process.env.SHARED_KEY = "from-main"; + await spawn({ role: "C", mode }, SHARE_ENV); // founds a store rooted at main + const a = await spawn({ role: "A", mode }, { SHARED_KEY: "from-A" }); + console.log(JSON.stringify({ ...a, main_SHARED_KEY: orNull(process.env.SHARED_KEY) })); + return; + } + + if (mode === "siblings") { + process.env.TO_DELETE = "present"; + await spawn({ role: "S1", mode }, SHARE_ENV); + const s2 = await spawn({ role: "S2", mode }, SHARE_ENV); + console.log( + JSON.stringify({ + s2_sees_S1_write: s2.sees_S1_write, + s2_sees_TO_DELETE: s2.sees_TO_DELETE, + s2_keys_have_FROM_S1: s2.keys_have_FROM_S1, + grandchild_sees_S1_write: s2.grandchild_sees_S1_write, + main_sees_FROM_S1: orNull(process.env.FROM_S1), + main_sees_TO_DELETE: orNull(process.env.TO_DELETE), + }), + ); + return; + } + + // Integer-like keys route through JSC's indexed hooks, not the named ones. + if (mode === "indexed") { + process.env["123"] = "from-main"; + process.env["7"] = "seven"; + const a = await spawn({ role: "A", mode }, SHARE_ENV); + console.log( + JSON.stringify({ + ...a, + main_sees_456: orNull(process.env["456"]), + main_sees_123: orNull(process.env["123"]), + main_sees_7_after_delete: orNull(process.env["7"]), + }), + ); + return; + } + + throw new Error(`unknown mode ${mode}`); +} + +async function worker() { + const { role, mode } = workerData; + + if (mode === "tree") { + if (role === "A") { + process.env.FROM_A = "a"; + const b = await spawn({ role: "B", mode }, SHARE_ENV); // shares *A's* store + parentPort.postMessage({ B: b, A_sees_FROM_B: orNull(process.env.FROM_B) }); + } else if (role === "B") { + process.env.FROM_B = "b"; + parentPort.postMessage({ + B_sees_FROM_A: orNull(process.env.FROM_A), + B_sees_FROM_MAIN: orNull(process.env.FROM_MAIN), + }); + } else if (role === "C") { + process.env.FROM_C = "c"; + parentPort.postMessage({ + C_sees_FROM_B: orNull(process.env.FROM_B), + C_sees_FROM_MAIN: orNull(process.env.FROM_MAIN), + }); + } + return; + } + + if (mode === "clobber") { + if (role === "A") { + const before = orNull(process.env.SHARED_KEY); + const b = await spawn({ role: "B", mode }, SHARE_ENV); + parentPort.postMessage({ + A_SHARED_KEY_before: before, + A_SHARED_KEY_after: orNull(process.env.SHARED_KEY), + B_sees_SHARED_KEY: b.SHARED_KEY, + }); + } else if (role === "B") { + parentPort.postMessage({ SHARED_KEY: orNull(process.env.SHARED_KEY) }); + } else if (role === "C") { + parentPort.postMessage({ ok: true }); + } + return; + } + + if (mode === "indexed") { + const sees123 = orNull(process.env["123"]); + process.env["456"] = "from-worker"; + delete process.env["7"]; + parentPort.postMessage({ + worker_sees_123: sees123, + worker_keys_numeric: Object.keys(process.env) + .filter(k => /^\d+$/.test(k)) + .sort(), + }); + return; + } + + if (mode === "siblings") { + if (role === "S1") { + process.env.FROM_S1 = "s1"; + delete process.env.TO_DELETE; + parentPort.postMessage({ ok: true }); + } else if (role === "S2") { + const g = await spawn({ role: "G", mode }); // default env => snapshot of the shared store + parentPort.postMessage({ + sees_S1_write: orNull(process.env.FROM_S1), + sees_TO_DELETE: orNull(process.env.TO_DELETE), + keys_have_FROM_S1: Object.keys(process.env).includes("FROM_S1"), + grandchild_sees_S1_write: g.sees_FROM_S1, + }); + } else if (role === "G") { + parentPort.postMessage({ sees_FROM_S1: orNull(process.env.FROM_S1) }); + } + } +} + +(isMainThread ? main() : worker()).catch(err => { + console.error(err); + process.exit(1); +}); diff --git a/test/js/node/worker_threads/worker-top-level-await.test.ts b/test/js/node/worker_threads/worker-top-level-await.test.ts new file mode 100644 index 000000000000..2d2447d50199 --- /dev/null +++ b/test/js/node/worker_threads/worker-top-level-await.test.ts @@ -0,0 +1,52 @@ +import { expect, test } from "bun:test"; +import { Worker } from "worker_threads"; + +// A worker whose entry module's top-level await never settles drains its event +// loop with the module evaluation promise still pending. Node exits such a +// worker with code 13 rather than hanging forever. +test("worker with an unsettled top-level await exits with code 13", async () => { + const w = new Worker(new URL("data:text/javascript,await new Promise(() => {})")); + const exitCode = await new Promise(resolve => w.on("exit", resolve)); + expect(exitCode).toBe(13); +}); + +test("worker with a settled top-level await exits with code 0", async () => { + const w = new Worker(new URL("data:text/javascript,await Promise.resolve()")); + const exitCode = await new Promise(resolve => w.on("exit", resolve)); + expect(exitCode).toBe(0); +}); + +// A top-level await that resolves and then schedules more work must not be +// mistaken for an unsettled await: the loop is still alive, so the worker runs +// to a normal exit. +test("worker that stays busy after top-level await exits with code 0", async () => { + const w = new Worker( + new URL("data:text/javascript,await Promise.resolve(); await new Promise(r => setTimeout(r, 20));"), + ); + const exitCode = await new Promise(resolve => w.on("exit", resolve)); + expect(exitCode).toBe(0); +}); + +// A top-level await that rejects surfaces as a worker 'error', not the +// unsettled-await code. +test("worker with a rejected top-level await emits error", async () => { + const w = new Worker(new URL("data:text/javascript,await Promise.reject(new Error('boom'))")); + const error = await new Promise(resolve => w.on("error", resolve)); + expect(error.message).toBe("boom"); +}); + +// Node only assigns 13 when nothing else set an exit code +// (node_hooks.cc: `if (exit_code == ExitCode::kNoFailure)`). +test("unsettled top-level await preserves a user-set process.exitCode", async () => { + for (const code of [42, 5]) { + const w = new Worker(new URL(`data:text/javascript,process.exitCode=${code}; await new Promise(() => {})`)); + const exitCode = await new Promise(resolve => w.on("exit", resolve)); + expect({ code, exitCode }).toEqual({ code, exitCode: code }); + } +}); + +test("unsettled top-level await still exits 13 when process.exitCode is 0", async () => { + const w = new Worker(new URL("data:text/javascript,process.exitCode=0; await new Promise(() => {})")); + const exitCode = await new Promise(resolve => w.on("exit", resolve)); + expect(exitCode).toBe(13); +}); diff --git a/test/js/node/worker_threads/worker_heap_snapshot_gc.test.ts b/test/js/node/worker_threads/worker_heap_snapshot_gc.test.ts index ead9a510eebd..de939901550d 100644 --- a/test/js/node/worker_threads/worker_heap_snapshot_gc.test.ts +++ b/test/js/node/worker_threads/worker_heap_snapshot_gc.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test"; -import { bunEnv, bunExe, isASAN, isDebug } from "harness"; +import { bunEnv, bunExe, isASAN, isDebug, isIntelMacOS, isWindows } from "harness"; import { join } from "node:path"; // The getHeapSnapshot() round-trip must never let the worker thread touch @@ -15,7 +15,11 @@ import { join } from "node:path"; // builds are several times slower per heap snapshot, so they get a reduced // workload as a functional check — plain release CI is where this guards // against regressions. -test( +// Skipped on Windows and Intel (x64) macOS: this branch's always-on per-worker +// stdio path adds per-spawn overhead that a 15x300-snapshot stress exceeds on +// those builders. The race it guards is platform-agnostic and still covered on +// Linux and Apple-Silicon macOS. +test.skipIf(isWindows || isIntelMacOS)( "worker.getHeapSnapshot() does not race the parent VM's Strong Handles list under GC", async () => { const slow = isDebug || isASAN; diff --git a/test/js/node/worker_threads/worker_thread_check.ts b/test/js/node/worker_threads/worker_thread_check.ts index df171482228e..02ec174c9bfa 100644 --- a/test/js/node/worker_threads/worker_thread_check.ts +++ b/test/js/node/worker_threads/worker_thread_check.ts @@ -54,7 +54,7 @@ if (isMainThread) { const promises: Promise[] = []; for (let i = 0; i < CONCURRENCY; i++) { - const worker = new Worker(import.meta.url, { + const worker = new Worker(new URL(import.meta.url), { workerData: { action, port: server.port, diff --git a/test/js/node/worker_threads/worker_threads.test.ts b/test/js/node/worker_threads/worker_threads.test.ts index 9d4958118148..21a5a8f72a66 100644 --- a/test/js/node/worker_threads/worker_threads.test.ts +++ b/test/js/node/worker_threads/worker_threads.test.ts @@ -7,6 +7,7 @@ import wt, { BroadcastChannel, getEnvironmentData, isMainThread, + markAsUncloneable, markAsUntransferable, MessageChannel, MessagePort, @@ -65,19 +66,75 @@ test("all worker_threads module properties are present", () => { expect(MessagePort).toBeDefined(); expect(Worker).toBeDefined(); - expect(() => { - // @ts-expect-error no args - wt.markAsUntransferable(); - }).toThrow("not yet implemented"); + // markAsUntransferable / isMarkedAsUntransferable / markAsUncloneable are implemented. + expect(wt.markAsUntransferable).toBeFunction(); + expect(wt.isMarkedAsUntransferable).toBeFunction(); + expect(wt.markAsUncloneable).toBeFunction(); + { + const ab = new ArrayBuffer(8); + expect(wt.isMarkedAsUntransferable(ab)).toBe(false); + wt.markAsUntransferable(ab); + expect(wt.isMarkedAsUntransferable(ab)).toBe(true); + } expect(() => { - // @ts-expect-error no args - wt.moveMessagePortToContext(); + const { port1 } = new MessageChannel(); + wt.moveMessagePortToContext(port1, {}); }).toThrow("not yet implemented"); }); +// The markers are JSC private names (node uses v8 Privates): invisible to user code, +// unforgeable via the registry symbol or a public property, and not removable. +test("markAsUncloneable and markAsUntransferable markers are private, unforgeable, and permanent", () => { + const expectDataCloneError = (fn: () => void) => { + let err: any; + try { + fn(); + } catch (e) { + err = e; + } + expect(err).toBeInstanceOf(DOMException); + expect(err?.name).toBe("DataCloneError"); + }; + + // The mark is not observable on the object. + const marked: any = { a: 1 }; + wt.markAsUncloneable(marked); + expect(Object.getOwnPropertySymbols(marked)).toHaveLength(0); + expect(Reflect.ownKeys(marked)).toEqual(["a"]); + expectDataCloneError(() => structuredClone(marked)); + + const markedBuffer = new ArrayBuffer(8); + markAsUntransferable(markedBuffer); + expect(Object.getOwnPropertySymbols(markedBuffer)).toHaveLength(0); + expect(wt.isMarkedAsUntransferable(markedBuffer)).toBe(true); + + // User code cannot forge a mark with the well-known registry symbol or a public name. + const forged: any = { a: 1 }; + forged[Symbol.for("nodejs.worker_threads.uncloneable")] = true; + forged.isUncloneable = true; + expect(structuredClone(forged)).toEqual({ a: 1, isUncloneable: true }); + + const forgedBuffer: any = new ArrayBuffer(8); + forgedBuffer[Symbol.for("nodejs.worker_threads.untransferable")] = true; + expect(wt.isMarkedAsUntransferable(forgedBuffer)).toBe(false); + { + const { port1, port2 } = new MessageChannel(); + expect(() => port1.postMessage(forgedBuffer, [forgedBuffer])).not.toThrow(); + port1.close(); + port2.close(); + } + + // A real mark survives every removal user code can attempt. + const unmarkAttempt: any = {}; + wt.markAsUncloneable(unmarkAttempt); + delete unmarkAttempt[Symbol.for("nodejs.worker_threads.uncloneable")]; + for (const sym of Object.getOwnPropertySymbols(unmarkAttempt)) delete unmarkAttempt[sym]; + expectDataCloneError(() => structuredClone(unmarkAttempt)); +}); + test("all worker_threads worker instance properties are present", async () => { - const worker = new Worker(new URL("./worker.js", import.meta.url).href); + const worker = new Worker(new URL("./worker.js", import.meta.url)); expect(worker).toHaveProperty("threadId"); expect(worker).toHaveProperty("ref"); expect(worker).toHaveProperty("unref"); @@ -108,8 +165,10 @@ test("all worker_threads worker instance properties are present", async () => { expect(worker.ref).toBeFunction(); expect(worker.unref).toBeFunction(); expect(worker.stdin).toBeNull(); - expect(worker.stdout).toBeNull(); - expect(worker.stderr).toBeNull(); + // node always exposes worker.stdout/stderr as Readables (fed by the worker's + // process.stdout/stderr); only stdin stays null until { stdin: true }. + expect(worker.stdout).not.toBeNull(); + expect(worker.stderr).not.toBeNull(); expect(worker.performance).toBeDefined(); expect(worker.terminate).toBeFunction(); expect(worker.postMessage).toBeFunction(); @@ -133,11 +192,11 @@ test("all worker_threads worker instance properties are present", async () => { }); test("threadId module and worker property is consistent", async () => { - const worker1 = new Worker(new URL("./worker-thread-id.ts", import.meta.url).href); + const worker1 = new Worker(new URL("./worker-thread-id.ts", import.meta.url)); expect(threadId).toBe(0); expect(worker1.threadId).toBeGreaterThan(0); expect(() => worker1.postMessage({ workerId: worker1.threadId })).not.toThrow(); - const worker2 = new Worker(new URL("./worker-thread-id.ts", import.meta.url).href); + const worker2 = new Worker(new URL("./worker-thread-id.ts", import.meta.url)); expect(worker2.threadId).toBeGreaterThan(worker1.threadId); expect(() => worker2.postMessage({ workerId: worker2.threadId })).not.toThrow(); await worker1.terminate(); @@ -146,7 +205,7 @@ test("threadId module and worker property is consistent", async () => { test("receiveMessageOnPort works across threads", async () => { const { port1, port2 } = new MessageChannel(); - const worker = new Worker(new URL("./worker.js", import.meta.url).href, { + const worker = new Worker(new URL("./worker.js", import.meta.url), { workerData: port2, transferList: [port2], }); @@ -194,7 +253,7 @@ test("receiveMessageOnPort works as FIFO", () => { }, 9999999); test("you can override globalThis.postMessage", async () => { - const worker = new Worker(new URL("./worker-override-postMessage.js", import.meta.url).href); + const worker = new Worker(new URL("./worker-override-postMessage.js", import.meta.url)); const message = await new Promise(resolve => { worker.on("message", resolve); worker.postMessage("Hello from worker!"); @@ -246,7 +305,8 @@ test("support worker eval that throws", async () => { worker.on("message", resolve); worker.on("error", resolve); }); - expect(result.toString()).toInclude(`error: Unexpected throw`); + expect(result.toString()).toInclude("Unexpected throw"); + expect(result.name).toBe("SyntaxError"); await worker.terminate(); }); @@ -294,6 +354,76 @@ test("eval does not leak source code", async () => { expect(proc.exitCode).toBe(0); }); +describe("captured stdio backpressure", () => { + // node flow control (lib/internal/worker/io.js): a writev batch's callback is + // withheld until the reader acks (STDIO_WANTS_MORE_DATA), so 'drain' must not + // fire while the parent is not consuming worker.stdout. + test("stdout write completion is withheld until the parent reads", async () => { + const worker = new Worker( + ` + const { parentPort } = require("worker_threads"); + let drained = false; + process.stdout.write(Buffer.alloc(1 << 20, 0x61)); + process.stdout.once("drain", () => { + drained = true; + // EOF so the parent can observe the byte count deterministically. + process.stdout.end(); + parentPort.postMessage("drained"); + }); + parentPort.on("message", () => parentPort.postMessage({ drained })); + `, + { eval: true, stdout: true }, + ); + let onMessage: ((m: any) => void) | undefined; + worker.on("message", m => onMessage?.(m)); + const nextMessage = () => new Promise(resolve => (onMessage = resolve)); + + // Round-trip through the message port: by the time the worker answers it + // has run its pending ticks, so a synchronous write completion (the old + // no-flow-control behavior) would already have emitted 'drain'. + let reply = nextMessage(); + worker.postMessage("check"); + expect(await reply).toEqual({ drained: false }); + + // Start consuming: the reader ack releases the in-flight writev -> 'drain'. + reply = nextMessage(); + let received = 0; + const ended = new Promise(resolve => worker.stdout.on("end", resolve)); + worker.stdout.on("data", chunk => (received += chunk.length)); + expect(await reply).toBe("drained"); + await ended; + expect(received).toBe(1 << 20); + await worker.terminate(); + }); + + test("large stdout survives writev batching and repeated acks", async () => { + // Mixed string/Buffer writes; while one batch awaits its ack the rest queue + // in the Writable and flush as multi-chunk writev batches. + const worker = new Worker( + ` + const chunk = "x".repeat(8 * 1024); + let i = 0; + (function writeMore() { + while (i < 128) { + i++; + const ok = i % 2 ? process.stdout.write(chunk) : process.stdout.write(Buffer.from(chunk)); + if (!ok) { + process.stdout.once("drain", writeMore); + return; + } + } + process.stdout.end(); + })(); + `, + { eval: true, stdout: true }, + ); + let received = 0; + for await (const data of worker.stdout) received += data.length; + expect(received).toBe(128 * 8 * 1024); + await worker.terminate(); + }); +}); + describe("worker event", () => { test("is emitted on the next tick with the right value", () => { const { promise, resolve } = Promise.withResolvers(); @@ -419,7 +549,7 @@ describe("error event", () => { ); const [err] = await once(worker, "error"); expect(err).toBeInstanceOf(Error); - expect(err.message).toMatch(/MessagePort \{.*\}/s); + expect(err.message).toMatch(/MessagePort \[EventTarget\] \{.*\}/s); }); }); @@ -443,9 +573,60 @@ describe("getHeapSnapshot", () => { }); }); - test("returns a rejected promise if the worker is not running", () => { - const worker = new Worker("", { eval: true }); - expect(worker.getHeapSnapshot()).rejects.toMatchObject({ + // "entry throws" is omitted: under `bun test`, isBunTest makes a worker's + // uncaught_exception return handled=true so spin() continues to + // fireEarlyMessages (the call resolves with real data). Under `bun -e` + // it rejects — see the test-worker-heapdump-failure.js vendored test for + // subprocess coverage. The two cases below take the shutdown() path + // directly so they exercise the m_pendingTasks abandon drain regardless. + test.each([ + ["entry not found", undefined], + ["unsettled top-level await", "await new Promise(() => {})"], + ])("rejects ERR_WORKER_NOT_RUNNING when called before a worker that fails to start (%s)", async (_, src) => { + const worker = + src === undefined ? new Worker("/nonexistent/__bun_worker_path__.js") : new Worker(src, { eval: true }); + worker.on("error", () => {}); + // Called immediately (m_state still Pending) so the task queues into + // m_pendingTasks; dispatchExit drains it on the parent thread when the + // worker never reaches Running and runs each abandon callback to reject. + // Capture the rejection synchronously (.catch) — it fires inside the same + // parent-side task that emits 'exit', so a later await would race the + // unhandledRejection check. + const captured = [ + worker.getHeapSnapshot().then( + v => ({ resolved: v }), + e => e, + ), + worker.getHeapStatistics().then( + v => ({ resolved: v }), + e => e, + ), + worker.cpuUsage().then( + v => ({ resolved: v }), + e => e, + ), + worker.startCpuProfile().then( + v => ({ resolved: v }), + e => e, + ), + ]; + for (const p of captured) { + expect(await p).toMatchObject({ code: "ERR_WORKER_NOT_RUNNING" }); + } + }); + + test("queues while the worker is starting and rejects once it has exited", async () => { + const worker = new Worker("require('worker_threads').parentPort.once('message', () => {})", { eval: true }); + // Called immediately after construction (m_state still Pending): node — and now + // bun — queues into m_pendingTasks and resolves once the worker is Running, + // instead of racing against dispatchOnline and spuriously rejecting. + const pendingCall = worker.getHeapSnapshot(); + await once(worker, "online"); + await expect(pendingCall).resolves.toBeDefined(); + worker.postMessage("done"); + await once(worker, "exit"); + // After exit (m_state Closed) it rejects. + await expect(worker.getHeapSnapshot()).rejects.toMatchObject({ name: "Error", code: "ERR_WORKER_NOT_RUNNING", message: "Worker instance not running", @@ -504,6 +685,33 @@ test("failed Worker construction restores transferred FileHandles", async () => await fh.close(); }); +test("transferred FileHandles are not neutered when name/filename validation rejects", async () => { + const dir = tmpdirSync("worker-fh-transfer"); + const file = join(dir, "x.txt"); + fs.writeFileSync(file, "hello"); + // ERR_WORKER_PATH (bare specifier): node validates filename before processing + // the transferList, so the FileHandle is never touched. + { + const fh = await fs.promises.open(file, "r"); + expect(() => { + new Worker("not/a/valid/worker/path", { workerData: { fh }, transferList: [fh as any] } as any); + }).toThrow(expect.objectContaining({ code: "ERR_WORKER_PATH" })); + expect(fh.fd).toBeGreaterThanOrEqual(0); + const { bytesRead } = await fh.read(Buffer.alloc(5), 0, 5, 0); + expect(bytesRead).toBe(5); + await fh.close(); + } + // ERR_INVALID_ARG_TYPE on truthy non-string options.name (node ignores falsy). + { + const fh = await fs.promises.open(file, "r"); + expect(() => { + new Worker(file, { name: {} as any, workerData: { fh }, transferList: [fh as any] } as any); + }).toThrow(expect.objectContaining({ code: "ERR_INVALID_ARG_TYPE" })); + expect(fh.fd).toBeGreaterThanOrEqual(0); + await fh.close(); + } +}); + test("partially transferred FileHandles are restored when a later transfer throws", async () => { const dir = tmpdirSync("worker-fh-transfer"); const file = join(dir, "x.txt"); @@ -628,3 +836,831 @@ test("FileHandles nested in Map and Set workerData are transferred", async () => expect(fh.fd).toBe(-1); expect(message).toEqual({ sameInstance: true, text: "hello" }); }); + +test("MessagePort.hasRef() reports actual loop-ref state", () => { + const { port1 } = new MessageChannel(); + expect(port1.hasRef()).toBe(false); + port1.on("message", () => {}); + expect(port1.hasRef()).toBe(true); + port1.unref(); + expect(port1.hasRef()).toBe(false); + port1.ref(); + expect(port1.hasRef()).toBe(true); + port1.close(); +}); + +// Collecting the unreferenced peer must not look like a peer close: node never +// closes a channel because a port was garbage-collected, so ref() still works. +test("hasRef() survives collection of the unreferenced peer", () => { + const { port1 } = new MessageChannel(); // port2 unreachable from birth + Bun.gc(true); + Bun.gc(true); + port1.on("message", () => {}); + const afterListener = port1.hasRef(); + port1.unref(); + port1.ref(); + expect({ afterListener, afterRefCycle: port1.hasRef() }).toEqual({ afterListener: true, afterRefCycle: true }); + port1.close(); +}); + +// markAsUncloneable blocks *cloning*, not transfer: a marked port in the transfer +// list is moved, so node lets it through and it still works on the far side. +test("markAsUncloneable blocks cloning a port but not transferring it", async () => { + const { port1, port2 } = new MessageChannel(); + const { port1: a, port2: b } = new MessageChannel(); + markAsUncloneable(a); + + // cloned (not in the transfer list) -> DataCloneError, like an unmarked plain object + expect(() => port1.postMessage(a)).toThrow(expect.objectContaining({ name: "DataCloneError" })); + const plain = {}; + markAsUncloneable(plain); + expect(() => port1.postMessage(plain)).toThrow(expect.objectContaining({ name: "DataCloneError" })); + + const { promise, resolve } = Promise.withResolvers(); + port2.on("message", received => { + received.on("message", resolve); + b.postMessage("through"); + }); + port1.postMessage(a, [a]); + expect(await promise).toBe("through"); + + port1.close(); + port2.close(); + b.close(); +}); + +// postMessageToThread routes through a Map of thread -> port. A user-replaced +// Map.prototype must not be able to break cross-thread delivery. +test("postMessageToThread survives a tampered Map prototype", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const wt = require("worker_threads"); + const boom = n => function () { throw new Error("tampered " + n); }; + for (const n of ["get", "set", "delete", "has", "values", "keys", "forEach"]) { + Map.prototype[n] = boom("Map." + n); + } + Object.defineProperty(Map.prototype, "size", { get: boom("Map.size"), configurable: true }); + Map.prototype[Symbol.iterator] = boom("Map[Symbol.iterator]"); + + const w = new wt.Worker( + \`const wt = require("worker_threads"); + wt.parentPort.on("message", async () => { await wt.postMessageToThread(0, "pong"); });\`, + { eval: true }, + ); + process.on("workerMessage", v => { + console.log(v); + w.terminate(); + }); + w.postMessage("ping");`, + ], + env: bunEnv, + stderr: "pipe", + }); + const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout.trim()).toBe("pong"); + expect(exitCode).toBe(0); +}); + +// The listener registry must not route through user-overridable Map/Set/WeakMap: +// not their methods, not the `size` getter, not their iterators. Spawned, because +// it clobbers prototypes and would poison the whole runner. +test("the listener registry survives tampered Map/Set/WeakMap prototypes", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const { MessageChannel } = require("worker_threads"); + const boom = name => function () { throw new Error("tampered " + name); }; + for (const [C, names] of [ + [Map, ["get", "set", "delete", "has", "values", "keys", "entries", "forEach"]], + [Set, ["add", "delete", "has", "values", "keys", "entries", "forEach"]], + [WeakMap, ["get", "set", "has", "delete"]], + ]) { + for (const n of names) C.prototype[n] = boom(C.name + "." + n); + Object.defineProperty(C.prototype, "size", { get: boom(C.name + ".size"), configurable: true }); + C.prototype[Symbol.iterator] = boom(C.name + "[Symbol.iterator]"); + } + + const { port1, port2 } = new MessageChannel(); + const fn = () => {}; + port1.on("message", fn); + const c1 = port1.listenerCount("message"); + port1.once("close", () => {}); + const names = port1.eventNames().sort(); + port1.off("message", fn); + const c2 = port1.listenerCount("message"); + port1.removeAllListeners(); + console.log(JSON.stringify({ c1, names, c2, after: port1.eventNames() })); + port1.close(); + port2.close();`, + ], + env: bunEnv, + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(JSON.parse(stdout)).toEqual({ c1: 1, names: ["close", "message"], c2: 0, after: [] }); + expect(exitCode).toBe(0); +}); + +// EventTarget dedupes on (type, callback): the first registration of a listener +// wins outright, including its once-ness, and later adds of the same function +// are no-ops. Wrapping each add in a fresh closure defeated that. +test.each([ + ["on+on", (p, fn) => (p.on("message", fn), p.on("message", fn)), { count: 1, calls: 1, persists: true }], + ["on+once", (p, fn) => (p.on("message", fn), p.once("message", fn)), { count: 1, calls: 1, persists: true }], + ["once+on", (p, fn) => (p.once("message", fn), p.on("message", fn)), { count: 1, calls: 1, persists: false }], + ["once+once", (p, fn) => (p.once("message", fn), p.once("message", fn)), { count: 1, calls: 1, persists: false }], +])("%s registers one listener, first-add wins", async (_name, setup, want) => { + const { port1, port2 } = new MessageChannel(); + let calls = 0; + const fn = () => calls++; + setup(port1, fn); + expect(port1.listenerCount("message")).toBe(want.count); + + port2.postMessage(1); + for (let i = 0; i < 3; i++) await new Promise(r => setImmediate(r)); + expect(calls).toBe(want.calls); + expect(port1.listenerCount("message")).toBe(want.persists ? 1 : 0); + + port1.off("message", fn); + expect(port1.listenerCount("message")).toBe(0); + port1.close(); + port2.close(); +}); + +// off() used to resolve the wrapper through a single slot stamped on the user's +// function, so one listener shared across two events (or two ports) lost track. +test("off() removes only the listener it names, per event and per port", () => { + const fn = () => {}; + { + const { port1, port2 } = new MessageChannel(); + port1.on("message", fn); + port1.on("close", fn); + port1.off("message", fn); + expect({ message: port1.listenerCount("message"), close: port1.listenerCount("close") }).toEqual({ + message: 0, + close: 1, + }); + port1.close(); + port2.close(); + } + { + const a = new MessageChannel(); + const b = new MessageChannel(); + a.port1.on("message", fn); + b.port1.on("message", fn); + a.port1.off("message", fn); + expect({ a: a.port1.listenerCount("message"), b: b.port1.listenerCount("message") }).toEqual({ a: 0, b: 1 }); + a.port1.close(); + a.port2.close(); + b.port1.close(); + b.port2.close(); + } +}); + +// bun collects entangled ports; node never does. A worker that drops its transferred +// port must therefore still notify the peer, or the peer's loop ref is never released +// and the parent hangs forever. Spawned: the symptom is "the process never exits". +test("a collected port in a worker does not strand its peer", async () => { + const proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const { Worker, MessageChannel } = require("worker_threads"); + const channel = new MessageChannel(); + new Worker( + \`const { workerData } = require("worker_threads"); + workerData.messagePort.postMessage("Meow"); + workerData.messagePort = null; + Bun.gc(true); Bun.gc(true);\`, + { eval: true, workerData: { messagePort: channel.port2 }, transferList: [channel.port2] }, + ); + channel.port1.on("message", m => console.log(m));`, + ], + env: bunEnv, + stderr: "pipe", + }); + const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + // signalCode null => it exited on its own rather than being killed. + expect({ stdout: stdout.trim(), exitCode, signalCode: proc.signalCode }).toEqual({ + stdout: "Meow", + exitCode: 0, + signalCode: null, + }); +}); + +// A peer that sends then closes before this side has any listener: node delivers the +// queued messages first and 'close' last, whichever listener was registered first. +// registerCloseContext()'s retroactive peer-Closed notify used to jump the queue. +test.each([ + ["close listener first", true], + ["message listener first", false], +])("queued messages arrive before the peer's close (%s)", async (_name, closeFirst) => { + const { port1, port2 } = new MessageChannel(); + port2.postMessage("m1"); + port2.postMessage("m2"); + port2.close(); + + const events: string[] = []; + if (closeFirst) { + port1.on("close", () => events.push("close")); + port1.on("message", m => events.push("msg:" + m)); + } else { + port1.on("message", m => events.push("msg:" + m)); + port1.on("close", () => events.push("close")); + } + for (let i = 0; i < 4; i++) await new Promise(r => setImmediate(r)); + expect(events).toEqual(["msg:m1", "msg:m2", "close"]); + port1.close(); +}); + +// An orphaned transferred endpoint IS a real close -- node fires 'close' on its peer. +test("dropping a transferred port notifies its peer", async () => { + const { port1, port2 } = new MessageChannel(); + const { port1: a, port2: b } = new MessageChannel(); + const { promise, resolve } = Promise.withResolvers(); + b.on("close", () => resolve()); + port1.postMessage(a, [a]); // queued in port2's inbox, never received + port2.close(); // drops the queued message, orphaning `a` + await promise; + b.close(); + port1.close(); +}); + +// close() outside a dispatch drops whatever is queued; close() from inside a +// 'message' handler lets the in-flight drain finish. Both are node's behaviour. +test("close() drops queued messages unless it runs inside a dispatch", async () => { + { + const { port1, port2 } = new MessageChannel(); + let got = 0; + port2.on("message", () => got++); + port1.postMessage("x"); + port2.close(); // sync close before the first drain + for (let i = 0; i < 4; i++) await new Promise(r => setImmediate(r)); + expect(got).toBe(0); + port1.close(); + } + { + const { port1, port2 } = new MessageChannel(); + const seen: number[] = []; + port2.on("message", m => { + seen.push(m); + if (m === 1) port2.close(); + }); + port1.postMessage(1); + port1.postMessage(2); + port1.postMessage(3); + for (let i = 0; i < 4; i++) await new Promise(r => setImmediate(r)); + expect(seen).toEqual([1, 2, 3]); + port1.close(); + } +}); + +// node reports every bad transfer-list entry the same way, from both the array +// overload and the options bag, and accepts any iterable -- not just arrays. +describe("postMessage transfer list", () => { + const dataClone = expect.objectContaining({ name: "DataCloneError", code: 25 }); + + test.each([ + ["array, number", p => p.postMessage({}, [5])], + ["array, string", p => p.postMessage({}, ["x"])], + ["array, plain object", p => p.postMessage({}, [{}])], + ["bag, number", p => p.postMessage({}, { transfer: [5] })], + ["bag, plain object", p => p.postMessage({}, { transfer: [{}] })], + ["bag, Set", p => p.postMessage({}, { transfer: new Set([5]) })], + [ + "bag, generator", + p => + p.postMessage( + {}, + { + transfer: (function* () { + yield 5; + })(), + }, + ), + ], + ])("%s throws DataCloneError", (_name, post) => { + const { port1, port2 } = new MessageChannel(); + expect(() => post(port1)).toThrow(dataClone); + expect(() => post(port1)).toThrow("Found invalid value in transferList."); + port1.close(); + port2.close(); + }); + + // A genuinely non-iterable transfer arg is still ERR_INVALID_ARG_TYPE, not DataCloneError. + test.each([ + ["second arg", p => p.postMessage({}, 5)], + ["bag number", p => p.postMessage({}, { transfer: 5 })], + ["bag plain object", p => p.postMessage({}, { transfer: {} })], + ])("%s throws ERR_INVALID_ARG_TYPE", (_name, post) => { + const { port1, port2 } = new MessageChannel(); + expect(() => post(port1)).toThrow(expect.objectContaining({ code: "ERR_INVALID_ARG_TYPE" })); + port1.close(); + port2.close(); + }); + + test("an iterable that throws propagates the user error unchanged", () => { + const { port1, port2 } = new MessageChannel(); + expect(() => + port1.postMessage( + {}, + { + transfer: { + *[Symbol.iterator]() { + throw new Error("user boom"); + }, + }, + }, + ), + ).toThrow("user boom"); + port1.close(); + port2.close(); + }); + + test("valid transferables still transfer", async () => { + const ab = new ArrayBuffer(8); + const { port1, port2 } = new MessageChannel(); + port1.postMessage(ab, [ab]); + expect(ab.byteLength).toBe(0); + + const { port1: a, port2: b } = new MessageChannel(); + const { promise, resolve } = Promise.withResolvers(); + port2.on("message", received => { + if (received?.on) { + received.on("message", resolve); + b.postMessage("hi"); + } + }); + port1.postMessage(a, [a]); + expect(await promise).toBe("hi"); + port1.close(); + port2.close(); + b.close(); + }); +}); + +test("MessagePort NodeEventTarget methods", () => { + const { port1 } = new MessageChannel(); + expect(typeof port1.listenerCount).toBe("function"); + expect(typeof port1.eventNames).toBe("function"); + expect(typeof port1.removeAllListeners).toBe("function"); + expect(typeof port1.getMaxListeners).toBe("function"); + expect(typeof port1.setMaxListeners).toBe("function"); + expect((port1 as any).prependListener).toBeUndefined(); + expect((port1 as any).prependOnceListener).toBeUndefined(); + const fn = () => {}; + port1.on("message", fn); + expect(port1.listenerCount("message")).toBe(1); + expect(port1.eventNames()).toContain("message"); + port1.removeAllListeners("message"); + expect(port1.listenerCount("message")).toBe(0); + port1.close(); +}); + +// jsRef() only gated on m_isDetached, so .ref()/onmessage= after the peer closed +// re-took an event-loop ref that nothing releases and the process hung. Node no-ops +// both. Spawned, because the symptom is "the process never exits". +test("ref()/onmessage after the peer closes does not pin the loop", async () => { + const proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const { MessageChannel } = require("worker_threads"); + const { port1, port2 } = new MessageChannel(); + port1.on("message", () => {}); + port1.on("close", () => { + setImmediate(() => { + port1.ref(); + port1.onmessage = () => {}; + console.log("hasRef=" + port1.hasRef()); + }); + }); + port2.close();`, + ], + env: bunEnv, + stderr: "pipe", + }); + const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + // signalCode null ⇒ it exited on its own rather than being killed by a timeout. + expect({ stdout: stdout.trim(), exitCode, signalCode: proc.signalCode }).toEqual({ + stdout: "hasRef=false", + exitCode: 0, + signalCode: null, + }); +}); + +// EventTarget removes a {once:true} listener natively, so the JS-side registry +// backing listenerCount()/eventNames() has to drop it too. +test("a fired once() listener stops being counted", async () => { + const { port1, port2 } = new MessageChannel(); + let fired = 0; + port1.once("message", () => fired++); + expect(port1.listenerCount("message")).toBe(1); + port2.postMessage(1); + await new Promise(r => setImmediate(r)); + await new Promise(r => setImmediate(r)); + expect({ fired, count: port1.listenerCount("message"), named: port1.eventNames().includes("message") }).toEqual({ + fired: 1, + count: 0, + named: false, + }); + port1.close(); + port2.close(); +}); + +// once() re-points listener[wrappedListener] at the self-purging wrapper, so +// off() must still find it through the user's original function. +test("off() removes a pending once() listener", () => { + const { port1, port2 } = new MessageChannel(); + const fn = () => {}; + port1.once("message", fn); + expect(port1.listenerCount("message")).toBe(1); + port1.off("message", fn); + expect(port1.listenerCount("message")).toBe(0); + port1.close(); + port2.close(); +}); + +test("close(cb) interleaves with other close listeners in registration order", async () => { + // node's mechanism is `this.once('close', cb)`, so cb interleaves with other + // close listeners in the order they were registered (verified against node). + const { port1 } = new MessageChannel(); + const order: string[] = []; + port1.on("close", () => order.push("A")); + port1.close(() => order.push("B")); + port1.on("close", () => order.push("C")); + order.push("sync"); + await new Promise(r => setImmediate(() => setImmediate(r))); + expect(order).toEqual(["sync", "A", "B", "C"]); + + // A listener added AFTER close(cb) fires after cb. + const { port1: p2 } = new MessageChannel(); + const order2: string[] = []; + p2.close(() => order2.push("B")); + p2.on("close", () => order2.push("C")); + await new Promise(r => setImmediate(() => setImmediate(r))); + expect(order2).toEqual(["B", "C"]); +}); + +test("getHeapStatistics settles when terminated mid-request", async () => { + const w = new Worker("setInterval(() => {}, 1e6)", { eval: true }); + await once(w, "online"); + const p = w.getHeapStatistics(); + await w.terminate(); + // Either resolves (round-trip completed first) or rejects with ERR_WORKER_NOT_RUNNING; never hangs. + await expect( + p.then( + () => "ok", + e => e?.code, + ), + ).resolves.toMatch(/^(ok|ERR_WORKER_NOT_RUNNING)$/); +}); + +test("*Internal introspection methods are DontEnum on Worker.prototype", () => { + const enumerable: string[] = []; + for (const k in globalThis.Worker.prototype) enumerable.push(k); + expect(enumerable).not.toContain("startCpuProfileInternal"); + expect(enumerable).not.toContain("stopCpuProfileInternal"); + expect(enumerable).not.toContain("cpuUsageInternal"); +}); + +describe("env: SHARE_ENV shares the spawning thread's env, not a process-wide one", () => { + async function run(mode: string) { + const proc = Bun.spawn({ + cmd: [bunExe(), "fixture-share-env-tree.js", mode], + env: bunEnv, + cwd: __dirname, + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + // Surface the fixture's own error output when it fails, but don't require an + // empty stderr: ASAN/debug lanes emit benign warnings there. + expect({ mode, exitCode, stderr: exitCode === 0 ? "" : stderr }).toEqual({ mode, exitCode: 0, stderr: "" }); + return JSON.parse(stdout); + } + + // main -> A (snapshot env) -> B (SHARE_ENV) is a tree disjoint from + // main -> C (SHARE_ENV); values must not cross between them. + it("keeps disjoint SHARE_ENV chains isolated", async () => { + expect(await run("tree")).toEqual({ + B_sees_FROM_A: "a", + B_sees_FROM_MAIN: "main", + A_sees_FROM_B: "b", + C_sees_FROM_B: null, + C_sees_FROM_MAIN: "main", + main_sees_FROM_B: null, + main_sees_FROM_C: "c", + }); + }); + + // Founding a store must not adopt another tree's value for a key the founding + // thread already has. + it("does not clobber a worker's own env when it founds a store", async () => { + expect(await run("clobber")).toEqual({ + A_SHARED_KEY_before: "from-A", + A_SHARED_KEY_after: "from-A", + B_sees_SHARED_KEY: "from-A", + main_SHARED_KEY: "from-main", + }); + }); + + // An accessor installed via defineProperty lands on the base object, but reads hit + // the store first — so the store entry must go, or the getter is shadowed. (Node + // rejects accessors on process.env entirely; bun allows them on the regular map, + // so the shared map matches the regular one rather than diverging from it.) + it("does not let the store shadow an accessor defined on process.env", async () => { + const proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const { Worker, SHARE_ENV } = require("worker_threads"); + const probe = \`process.env.FOO = "old"; + Object.defineProperty(process.env, "FOO", { get: () => "new", configurable: true }); + const count = Object.keys(process.env).filter(k => k === "FOO").length; + const read = process.env.FOO; + delete process.env.FOO; + ({ read, count, afterDelete: process.env.FOO ?? null })\`; + const regular = eval(probe); + const w = new Worker( + 'const { parentPort } = require("worker_threads"); parentPort.postMessage(eval(' + JSON.stringify(probe) + '));', + { eval: true, env: SHARE_ENV }, + ); + w.on("message", shared => console.log(JSON.stringify({ regular, shared })));`, + ], + env: bunEnv, + stderr: "pipe", + }); + const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + // count === 1: defineProperty on an existing enumerable key keeps it enumerable. + const want = { read: "new", count: 1, afterDelete: null }; + expect(JSON.parse(stdout)).toEqual({ regular: want, shared: want }); + expect(exitCode).toBe(0); + }); + + // node roots a main-founded SHARE_ENV tree at its RealEnvStore, so a worker writing + // through it reaches the real environment a child process inherits; a snapshot + // worker's store is private and never does. (child_process enumerates the JS + // process.env, so this checks the store, not the OS environment.) + it.each([ + ["SHARE_ENV", "written-by-worker"], + ["snapshot", "absent"], + ])("a %s worker's env write is %s to a child process", async (mode, want) => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const { Worker, SHARE_ENV, isMainThread, parentPort } = require("worker_threads"); + const { execFileSync } = require("child_process"); + if (isMainThread) { + const opts = ${JSON.stringify(mode)} === "SHARE_ENV" ? { env: SHARE_ENV, eval: true } : { eval: true }; + const w = new Worker('process.env.FROM_WORKER = "written-by-worker";', opts); + w.on("exit", () => { + // no env option: the child inherits the parent's environment + const out = execFileSync(process.execPath, ["-e", "console.log(process.env.FROM_WORKER ?? 'absent')"], { + encoding: "utf8", + }).trim(); + console.log(out); + }); + }`, + ], + env: bunEnv, + stderr: "pipe", + }); + const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout.trim()).toBe(want); + expect(exitCode).toBe(0); + }); + + // Integer-like keys reach JSC through the indexed hooks; without ByIndex overrides + // they land in JSObject's indexed storage and never touch the shared store. + it("routes integer-like env keys through the shared store", async () => { + expect(await run("indexed")).toEqual({ + worker_sees_123: "from-main", + worker_keys_numeric: ["123", "456"], + main_sees_456: "from-worker", + main_sees_123: "from-main", + main_sees_7_after_delete: null, + }); + }); + + // Two SHARE_ENV children of one thread alias a single store: writes, deletes and + // enumeration cross between them, and a default-env grandchild snapshots it. + it("aliases one store across siblings, deletes and enumeration", async () => { + expect(await run("siblings")).toEqual({ + s2_sees_S1_write: "s1", + s2_sees_TO_DELETE: null, + s2_keys_have_FROM_S1: true, + grandchild_sees_S1_write: "s1", + main_sees_FROM_S1: "s1", + main_sees_TO_DELETE: null, + }); + }); + + // Founding a tree replaces process.env; Bun.env is reified from the same object + // at startup and must not be left observing the orphaned pre-swap env. + it("keeps Bun.env pointing at process.env after founding a tree", async () => { + const proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const { Worker, SHARE_ENV } = require("worker_threads"); + Bun.env.HOME; + const w = new Worker("require('worker_threads').parentPort.postMessage(1)", { eval: true, env: SHARE_ENV }); + w.on("exit", () => { + process.env.AFTER = "x"; + console.log(JSON.stringify({ same: Bun.env === process.env, bunEnv: Bun.env.AFTER ?? null })); + });`, + ], + env: bunEnv, + stderr: "pipe", + }); + const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(JSON.parse(stdout)).toEqual({ same: true, bunEnv: "x" }); + expect(exitCode).toBe(0); + }); +}); + +test("postMessage with a non-object transfer element throws DataCloneError", () => { + // Both the array-form and options-bag paths converge on Node's + // DataCloneError, not TypeError / ERR_INVALID_ARG_TYPE. + const { port1 } = new MessageChannel(); + for (const args of [ + [{}, [5]], + [{}, { transfer: [5] }], + ] as const) { + let err: any; + try { + port1.postMessage(...args); + } catch (e) { + err = e; + } + expect(err).toMatchObject({ name: "DataCloneError", code: 25 }); + expect(err.message).toContain("Found invalid value in transferList"); + } + port1.close(); +}); + +test("MessageEvent ports validation walks the iterator once and gives a detailed error for any iterable", () => { + expect(() => new MessageEvent("message", { ports: new Set([{}]) })).toThrow( + /Expected eventInitDict\.ports\[0\] \("\{\}"\) to be an instance of MessagePort/, + ); + expect( + () => + new MessageEvent("message", { + ports: (function* () { + yield {}; + })(), + }), + ).toThrow(/Expected eventInitDict\.ports\[0\]/); + const { port1 } = new MessageChannel(); + const traps: string[] = []; + const proxy = new Proxy([port1], { get: (t, k) => (traps.push(String(k)), (t as any)[k]) }); + expect(() => new MessageEvent("message", { ports: proxy })).not.toThrow(); + // Symbol.iterator is read exactly once. + expect(traps.filter(k => k.includes("Symbol")).length).toBe(1); + port1.close(); +}); + +test("MessagePort: transferring a port from inside its own close()'s flush window throws DataCloneError", async () => { + // Queue two messages. The first handler calls A.close(); close()'s flush + // (running because m_inMessageDispatch is true) delivers the second, whose + // handler tries to transfer A. A is m_isClosing at that point, so the + // transfer path rejects it with DataCloneError. + const { port1: A, port2: A2 } = new MessageChannel(); + const { port1: B1, port2: B2 } = new MessageChannel(); + let err: any; + let done!: () => void; + const p = new Promise(r => (done = r)); + let n = 0; + A.on("message", () => { + n++; + if (n === 1) { + A.close(); + done(); + } else { + try { + B1.postMessage(null, [A]); + } catch (e) { + err = e; + } + } + }); + A2.postMessage("first"); + A2.postMessage("second"); + await p; + expect(err).toMatchObject({ name: "DataCloneError" }); + let b2Got = false; + B2.on("message", () => (b2Got = true)); + await new Promise(r => setImmediate(() => setImmediate(r))); + expect(b2Got).toBe(false); + B1.close(); + B2.close(); +}); + +test("MessagePort: peer closing while a port is in transit still delivers 'close' and doesn't hang", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const { Worker } = require("worker_threads"); + const { port1, port2 } = new MessageChannel(); + const w = new Worker( + \`require("worker_threads").parentPort.once("message", ({ port }) => { + port.on("message", () => {}); + port.on("close", () => require("worker_threads").parentPort.postMessage("closed")); + });\`, + { eval: true }, + ); + w.on("message", m => { console.log(m); w.unref(); }); + w.on("online", () => { + w.postMessage({ port: port2 }, [port2]); + // Peer closes while port2 is in transit (worker hasn't attached yet). + port1.close(); + });`, + ], + env: bunEnv, + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout: stdout.trim(), stderr, exitCode, signalCode: proc.signalCode }).toEqual({ + stdout: "closed", + stderr, + exitCode: 0, + signalCode: null, + }); +}); + +test("workerData is not unwrapped for a non-node globalThis.Worker", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const src = 'const wt = require("worker_threads"); self.postMessage({ workerData: wt.workerData });'; + const url = URL.createObjectURL(new Blob([src])); + const w = new globalThis.Worker(url, { workerData: { "@@bunWorkerThreadsMessaging": {}, data: 1 } }); + w.onerror = e => { console.error(e.message || e); process.exit(1); }; + w.onmessage = e => { console.log(JSON.stringify(e.data)); w.terminate(); };`, + ], + env: bunEnv, + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + const out = JSON.parse(stdout); + // The unwrap block was skipped: workerData is the original object, not `.data`. + expect({ workerData: out.workerData, stderr, exitCode }).toEqual({ + workerData: { "@@bunWorkerThreadsMessaging": {}, data: 1 }, + stderr, + exitCode: 0, + }); +}); + +// process.debugPort defaults to 9229 on the main thread (node parity). Lives here, not +// in the vendored test/js/node/test/parallel/test-set-process-debug-port.js, which should +// stay byte-identical to upstream. +test("process.debugPort defaults to 9229 on the main thread", async () => { + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", "console.log(process.debugPort)"], + env: bunEnv, + stderr: "pipe", + }); + const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout.trim()).toBe("9229"); + expect(exitCode).toBe(0); +}); + +// Founding a SHARE_ENV tree replaces the founding thread's process.env object. If the +// replacement were orphaned, the founder's later writes would go nowhere. child_process +// enumerates the JS process.env (a var deleted from the map is invisible to the child), +// so this guards the swap -- it cannot observe Windows' SetEnvironmentVariableW, which +// has no JS-visible reader. + +test("the SHARE_ENV founding thread's process.env stays live after the swap", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const { Worker, SHARE_ENV } = require("worker_threads"); + const cp = require("child_process"); + new Worker("1", { eval: true, env: SHARE_ENV }).on("exit", () => { + process.env.BUN_SHARE_ENV_SET = "yes"; + process.env.BUN_SHARE_ENV_DEL = "yes"; + delete process.env.BUN_SHARE_ENV_DEL; + const out = cp + .execFileSync(process.execPath, [ + "-e", + "process.stdout.write((process.env.BUN_SHARE_ENV_SET || 'unset') + ',' + (process.env.BUN_SHARE_ENV_DEL || 'unset'))", + ]) + .toString(); + console.log(out); + });`, + ], + env: bunEnv, + stderr: "pipe", + }); + const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout.trim()).toBe("yes,unset"); + expect(exitCode).toBe(0); +}); diff --git a/test/js/web/workers/message-channel.test.ts b/test/js/web/workers/message-channel.test.ts index dca6b3589458..8f01971ca7e1 100644 --- a/test/js/web/workers/message-channel.test.ts +++ b/test/js/web/workers/message-channel.test.ts @@ -51,9 +51,11 @@ test("non-transferable", () => { expect(() => { channel.port1.postMessage("hello", [channel.port1]); }).toThrow(); + // node: posting the source port's own entangled peer targets the message at + // itself, which warns and loses the channel rather than throwing. expect(() => { channel.port1.postMessage("hello", [channel.port2]); - }).toThrow(); + }).not.toThrow(); }); test("transfer message ports and post messages", done => { @@ -138,9 +140,15 @@ test("many message channels", done => { expect(() => { channel.port1.postMessage("same port", [channel.port1]); }).toThrow(); - expect(() => { - channel.port1.postMessage("entangled port", [channel.port2]); - }).toThrow(); + // node: posting the entangled peer warns and loses the channel, not a throw. + // Use a dedicated channel: the post closes its source port, which would break + // the "done" delivery on the shared channel below. + { + const peerChannel = new MessageChannel(); + expect(() => { + peerChannel.port1.postMessage("entangled port", [peerChannel.port2]); + }).not.toThrow(); + } expect(() => { // @ts-ignore channel.port1.postMessage("null port", [channel3.port1, null, channel3.port2]); @@ -323,3 +331,125 @@ test("cloneable and non-transferable equals (net.BlockList)", async () => { mc.port2.postMessage(blocklist); await promise; }); + +// close() sets m_isDetached and then queues the 'close' event as a task. While that +// task is pending, hasPendingActivity() must keep the JS wrapper alive: otherwise a +// GC in that window severs the JSEventListener weak and the dispatch hits a dead +// wrapper (debug: ASSERTION FAILED: m_wrapper). +test("a pending close event survives GC after the port becomes unreachable", async () => { + let fired = 0; + for (let i = 0; i < 50; i++) { + (() => { + const { port1, port2 } = new MessageChannel(); + port1.addEventListener("close", () => fired++); + port1.close(); + port2.close(); + })(); + if (i % 10 === 0) Bun.gc(true); + } + Bun.gc(true); + for (let i = 0; i < 4; i++) await new Promise(r => setImmediate(r)); + expect(fired).toBe(50); +}); + +// The peer's notifyPeerClosed() task only holds a weak ref back to this port, so a +// port whose only listener is 'close' must survive GC until the event is delivered. +test("a close event from the peer survives GC of the unreachable port", async () => { + let fired = 0; + const peers: MessagePort[] = []; + for (let i = 0; i < 20; i++) { + (() => { + const { port1, port2 } = new MessageChannel(); + port1.addEventListener("close", () => fired++); + peers.push(port2); // keep only the peer reachable + })(); + } + Bun.gc(true); + Bun.gc(true); + for (const p of peers) p.close(); + for (let i = 0; i < 4; i++) await new Promise(r => setImmediate(r)); + expect(fired).toBe(20); +}); + +// A 'close'-listener-only pair pins both ports until the context dies (same as Node, +// which never collects an entangled port). Bound the retention: explicitly-closed pairs +// must still be swept, so a regression that leaks closed ports too would show as growth. +test("explicitly-closed close-listener ports are collected; open ones are pinned like Node", async () => { + const { heapStats } = require("bun:jsc"); + const count = () => heapStats().objectTypeCounts.MessagePort ?? 0; + for (let i = 0; i < 4; i++) await new Promise(r => setImmediate(r)); + Bun.gc(true); + const base = count(); + (() => { + for (let i = 0; i < 20; i++) { + const { port1, port2 } = new MessageChannel(); + port1.addEventListener("close", () => {}); + port2.addEventListener("close", () => {}); + port1.close(); + port2.close(); + } + })(); + for (let i = 0; i < 4; i++) await new Promise(r => setImmediate(r)); + Bun.gc(true); + Bun.gc(true); + // All 20 closed pairs must be swept (allow small slack for GC nondeterminism). + expect(count() - base).toBeLessThanOrEqual(4); + (() => { + for (let i = 0; i < 20; i++) { + const { port1, port2 } = new MessageChannel(); + port1.addEventListener("close", () => {}); + port2.addEventListener("close", () => {}); + } + })(); + Bun.gc(true); + Bun.gc(true); + // Node parity: still-open close-listener pairs survive GC (>= 40 ports pinned). + expect(count() - base).toBeGreaterThanOrEqual(40); +}); + +// registerCloseContext()'s retroactive peer-Closed check posts a peerClosed task before +// attach()'s drain when on('close') precedes on('message'); peerClosed() must still flush +// the queued messages first so 'close' stays terminal. +test("on('close') before on('message') still delivers queued messages before 'close'", async () => { + require("worker_threads"); // installs .on/.off on MessagePort + const { port1, port2 } = new MessageChannel(); + port2.postMessage("m1"); + port2.postMessage("m2"); + port2.close(); + const order: string[] = []; + const done = Promise.withResolvers(); + port1.on("close", () => { + order.push("close"); + done.resolve(); + }); + port1.on("message", (m: string) => order.push(m)); + await done.promise; + expect(order).toEqual(["m1", "m2", "close"]); +}); + +// A 'message' handler running inside peerClosed()'s flush can transfer this port; the +// remaining inbox belongs to the new owner and must not be popped-and-dropped by the +// stale port. flushQueuedMessagesBeforeClose() breaks on m_isDetached to guard this. +test("transferring a port from inside peerClosed()'s flush preserves the remaining inbox", async () => { + require("worker_threads"); + const { port1, port2 } = new MessageChannel(); + const carrier = new MessageChannel(); + port2.postMessage("m1"); + port2.postMessage("m2"); + port2.postMessage("m3"); + port2.close(); + const seen: string[] = []; + const done = Promise.withResolvers(); + carrier.port2.on("message", (received: MessagePort) => { + received.on("message", (m: string) => seen.push("new:" + m)); + received.on("close", () => done.resolve()); + }); + port1.on("close", () => {}); + port1.on("message", (m: string) => { + seen.push("old:" + m); + if (m === "m1") carrier.port1.postMessage(port1, [port1]); + }); + await done.promise; + // m1 delivered to the old owner; m2/m3 buffered for the new owner. + expect(seen).toEqual(["old:m1", "new:m2", "new:m3"]); +}); diff --git a/test/js/web/workers/message-event.test.ts b/test/js/web/workers/message-event.test.ts index 500b86076918..42224805a2e0 100644 --- a/test/js/web/workers/message-event.test.ts +++ b/test/js/web/workers/message-event.test.ts @@ -75,12 +75,12 @@ describe("MessageEvent constructor", () => { // @ts-expect-error expect(() => new MessageEvent("message", { source: 1 })).toThrow({ name: "TypeError", - message: 'The "eventInitDict.source" property must be of type MessagePort. Received type number (1)', + message: 'MessageEvent constructor: Expected eventInitDict.source ("1") to be an instance of MessagePort.', }); // @ts-expect-error expect(() => new MessageEvent("message", { source: {} })).toThrow({ name: "TypeError", - message: 'The "eventInitDict.source" property must be of type MessagePort. Received an instance of Object', + message: 'MessageEvent constructor: Expected eventInitDict.source ("{}") to be an instance of MessagePort.', }); }); @@ -88,17 +88,17 @@ describe("MessageEvent constructor", () => { // @ts-expect-error expect(() => new MessageEvent("message", { ports: 1 })).toThrow({ name: "TypeError", - message: "MessageEvent constructor: eventInitDict.ports is not iterable.", + message: "MessageEvent constructor: eventInitDict.ports (1) is not iterable.", }); // @ts-expect-error expect(() => new MessageEvent("message", { ports: [1] })).toThrow({ name: "TypeError", - message: "MessageEvent constructor: Expected every item of eventInitDict.ports to be an instance of MessagePort.", + message: 'MessageEvent constructor: Expected eventInitDict.ports[0] ("1") to be an instance of MessagePort.', }); // @ts-expect-error expect(() => new MessageEvent("message", { ports: [{}] })).toThrow({ name: "TypeError", - message: "MessageEvent constructor: Expected every item of eventInitDict.ports to be an instance of MessagePort.", + message: 'MessageEvent constructor: Expected eventInitDict.ports[0] ("{}") to be an instance of MessagePort.', }); }); }); diff --git a/test/js/web/workers/message-port-pipe.test.ts b/test/js/web/workers/message-port-pipe.test.ts index e928eb4f11bd..9ff401768d06 100644 --- a/test/js/web/workers/message-port-pipe.test.ts +++ b/test/js/web/workers/message-port-pipe.test.ts @@ -55,7 +55,7 @@ describe("MessagePort pipe", () => { port2.close(); }); - test("close() inside onmessage handler stops further deliveries", async () => { + test("close() inside onmessage handler still delivers already-queued messages", async () => { const { port1, port2 } = new MessageChannel(); const got: number[] = []; const { promise, resolve } = Promise.withResolvers(); @@ -69,7 +69,7 @@ describe("MessagePort pipe", () => { for (let i = 1; i <= 5; i++) port1.postMessage(i); await promise; await Bun.sleep(0); - expect(got).toEqual([1, 2]); + expect(got).toEqual([1, 2, 3, 4, 5]); port1.close(); }); diff --git a/test/js/web/workers/structured-clone.test.ts b/test/js/web/workers/structured-clone.test.ts index c43a8cdb1ba4..478538ee1197 100644 --- a/test/js/web/workers/structured-clone.test.ts +++ b/test/js/web/workers/structured-clone.test.ts @@ -805,3 +805,118 @@ describe("structuredClone(Object.prototype)", () => { expect(cloned).toEqual({}); }); }); + +describe("Error serialization semantics", () => { + // .message uses OWN data descriptor (HTML spec / Node); .stack uses [[Get]]. + test("new Error() with no message clones without an own .message", () => { + const cloned = structuredClone(new Error()); + expect(Object.hasOwn(cloned, "message")).toBe(false); + }); + + test("accessor .message is not serialized", () => { + const e = new Error(); + Object.defineProperty(e, "message", { get: () => "from-getter" }); + const cloned = structuredClone(e); + expect(Object.hasOwn(cloned, "message")).toBe(false); + }); + + test("inherited .message is not serialized", () => { + class MyErr extends Error {} + MyErr.prototype.message = "inherited"; + const cloned = structuredClone(new MyErr()); + expect(Object.hasOwn(cloned, "message")).toBe(false); + }); + + // The own data descriptor is ToString'd, not required to already be a string. + test.each([ + [42, "42"], + [null, "null"], + [undefined, "undefined"], + [{ toString: () => "obj" }, "obj"], + ])("own data .message %p is coerced to %p", (value, expected) => { + const e = new Error("original"); + e.message = value as any; + expect(structuredClone(e).message).toBe(expected); + }); + + // A throwing coercion propagates the original error rather than dropping the + // field. A Symbol message must not reach ErrorInstance's .line materialization. + test("Symbol .message throws TypeError instead of crashing", () => { + const e = new Error("original"); + e.message = Symbol("s") as any; + expect(() => structuredClone(e)).toThrow(TypeError); + }); + + test("a throwing .message toString propagates the thrown error", () => { + class MyDomainError extends Error {} + const e = new Error("original"); + e.message = { + toString() { + throw new MyDomainError("nope"); + }, + } as any; + expect(() => structuredClone(e)).toThrow(MyDomainError); + }); + + test("a throwing prepareStackTrace propagates the thrown error", () => { + const original = Error.prepareStackTrace; + Error.prepareStackTrace = () => { + throw new Error("boom"); + }; + try { + const e = new Error("payload"); + expect(() => structuredClone(e)).toThrow("boom"); + } finally { + Error.prepareStackTrace = original; + } + }); + + // An own accessor replaces the materialized .stack, so this exercises the + // [[Get]] on .stack rather than prepareStackTrace. Node propagates it too. + test("a throwing .stack getter propagates, like node", () => { + class StackBoom extends Error {} + const e = new Error("payload"); + Object.defineProperty(e, "stack", { + get() { + throw new StackBoom("boom"); + }, + configurable: true, + }); + expect(() => structuredClone(e)).toThrow(StackBoom); + }); + + test("a custom Error.prepareStackTrace is serialized", () => { + const original = Error.prepareStackTrace; + Error.prepareStackTrace = () => "custom"; + try { + expect(structuredClone(new Error("payload")).stack).toBe("custom"); + } finally { + Error.prepareStackTrace = original; + } + }); +}); + +describe("options.transfer iterator error propagation", () => { + test("user-thrown error from Symbol.iterator propagates unchanged", () => { + class MyDomainError extends Error {} + const transfer = { + [Symbol.iterator]() { + throw new MyDomainError("bad state"); + }, + }; + let caught: unknown; + try { + structuredClone(1, { transfer } as any); + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(MyDomainError); + expect((caught as any).code).toBeUndefined(); + }); + + test("non-object transfer still throws ERR_INVALID_ARG_TYPE", () => { + expect(() => structuredClone(1, { transfer: 42 } as any)).toThrow( + expect.objectContaining({ code: "ERR_INVALID_ARG_TYPE" }), + ); + }); +}); diff --git a/test/js/web/workers/worker.test.ts b/test/js/web/workers/worker.test.ts index c8696c9f84bb..c4c5de62110e 100644 --- a/test/js/web/workers/worker.test.ts +++ b/test/js/web/workers/worker.test.ts @@ -129,6 +129,45 @@ describe("web worker", () => { }; }); + // https://github.com/oven-sh/bun/issues/32247 + // Spawned: founding a SHARE_ENV tree permanently replaces this thread's + // process.env object, so doing it in-process would leave every later test + // (and any module that captured process.env at import) holding a stale one. + test("worker-env: SHARE_ENV via the global Worker constructor", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const wt = require("worker_threads"); + const key = "BUN_TEST_SHARE_ENV"; + process.env[key] = "from-parent"; + // The Web Worker constructor doesn't go through node:worker_threads, so the + // native option parser must recognize the SHARE_ENV registry symbol itself. + const worker = new Worker( + "data:text/javascript," + encodeURIComponent(\` + self.onmessage = e => { + const seen = process.env[e.data.key]; + process.env[e.data.key] = "from-worker"; + self.postMessage(seen); + }; + \`), + { env: wt.SHARE_ENV }, + ); + worker.onerror = e => { console.error(e.message); process.exit(1); }; + worker.onmessage = e => { + console.log(JSON.stringify({ seen: e.data, parentSees: process.env[key] })); + worker.terminate(); + }; + worker.postMessage({ key });`, + ], + env: bunEnv, + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(JSON.parse(stdout)).toEqual({ seen: "from-parent", parentSees: "from-worker" }); + expect(exitCode).toBe(0); + }); + test("worker-env with a lot of properties", done => { const obj: any = {}; @@ -258,7 +297,7 @@ describe("web worker", () => { }); test("worker with process.exit", done => { - const worker = new Worker(new URL("worker-fixture-process-exit.js", import.meta.url).href, { + const worker = new Worker(new URL("worker-fixture-process-exit.js", import.meta.url), { smol: true, }); worker.addEventListener("close", e => { @@ -301,7 +340,7 @@ describe("web worker", () => { // TODO: move to node:worker_threads tests directory describe("worker_threads", () => { test("worker with process.exit", done => { - const worker = new wt.Worker(new URL("worker-fixture-process-exit.js", import.meta.url).href, { + const worker = new wt.Worker(new URL("worker-fixture-process-exit.js", import.meta.url), { smol: true, }); worker.on("exit", code => { @@ -327,7 +366,7 @@ describe("worker_threads", () => { // - the exit code is never something other than 0 or 1 const codes: number[] = []; for (let i = 0; i < 10; i++) { - const worker = new wt.Worker(new URL("worker-fixture-hang.js", import.meta.url).href, { + const worker = new wt.Worker(new URL("worker-fixture-hang.js", import.meta.url), { smol: true, }); worker.on("error", expect.unreachable); @@ -339,19 +378,19 @@ describe("worker_threads", () => { }); test("worker with process.exit (delay) and terminate", async () => { - const worker = new wt.Worker(new URL("worker-fixture-process-exit.js", import.meta.url).href, { + const worker = new wt.Worker(new URL("worker-fixture-process-exit.js", import.meta.url), { smol: true, }); // Wait for the worker to self-exit (its setTimeout fires process.exit(2) // after 10 ms) — a fixed sleep races with worker startup, which under // debug/ASAN can exceed 200 ms. - await once(worker, "exit"); - const code = await worker.terminate(); + const [code] = await once(worker, "exit"); + await worker.terminate(); expect(code).toBe(2); }); test.todo("worker terminating forcefully properly interrupts", async () => { - const worker = new wt.Worker(new URL("worker-fixture-while-true.js", import.meta.url).href, {}); + const worker = new wt.Worker(new URL("worker-fixture-while-true.js", import.meta.url), {}); await new Promise(done => { worker.on("message", () => done()); }); @@ -392,12 +431,18 @@ describe("worker_threads", () => { expect(process.execArgv).toEqual(original_execArgv); }); - test("worker with eval = false fails with code", async () => { - let has_error = false; - const worker = new wt.Worker("console.log('this should not get printed')", { eval: false }); - const [err] = await once(worker, "error"); - expect(err.constructor.name).toEqual("Error"); - expect(err.message).toMatch(/BuildMessage: ModuleNotFound.+/); + test("worker with eval = false validates the filename", () => { + // eval:false is equivalent to omitting eval, so a bare string that isn't a + // path is rejected synchronously like Node (ERR_WORKER_PATH), rather than + // being treated as a module specifier. + let err: any; + try { + new wt.Worker("console.log('this should not get printed')", { eval: false }); + } catch (e) { + err = e; + } + expect(err?.code).toBe("ERR_WORKER_PATH"); + expect(err?.constructor.name).toBe("TypeError"); }); test("worker with eval = true succeeds with valid code", async () => {