From cbc885ea6688ce663680010716b0cfcf8c8658eb Mon Sep 17 00:00:00 2001 From: Dylan Conway Date: Tue, 18 Aug 2026 08:37:12 +0000 Subject: [PATCH 1/4] worker_threads: don't build fd stdio or reify all of process at worker startup A node:worker_threads Worker's bootstrap rebinds process.stdout/stderr/stdin to port-backed streams. It did so with Object.defineProperty, which first built the real fd-backed streams (node:fs + fs streams) and threw them away, and it deleted four main-thread-only process stubs, which reifies process's whole static table (building the nextTick queue and every other lazy property). Both ran on every worker thread. - The parent's stdio ports are registered on the worker's global and the existing lazy process.std* getters build the port-backed streams directly. - The four stubs are simply not installed on a worker's process. - node:worker_threads is loaded by a native hook before preloads/entry instead of being injected as an ESM preload. - process.moduleLoadList is real (was []) so tests can pin what a thread loads. --- src/codegen/bundle-modules.ts | 19 ++ src/js/builtins/ProcessObjectInternals.ts | 14 ++ src/js/internal/worker/stdio.ts | 149 ++++++++++++++ src/js/node/worker_threads.ts | 184 +----------------- src/jsc/bindings/BunClientData.h | 4 + src/jsc/bindings/BunProcess.cpp | 44 ++++- src/jsc/bindings/InternalModuleRegistry.cpp | 29 +++ src/jsc/bindings/InternalModuleRegistry.h | 10 + src/jsc/bindings/ZigGlobalObject.cpp | 3 + src/jsc/bindings/ZigGlobalObject.h | 5 + src/jsc/bindings/webcore/Worker.cpp | 33 +++- src/jsc/web_worker.rs | 18 +- src/runtime/jsc_hooks.rs | 4 +- test/js/node/process/process.test.js | 24 ++- .../worker_threads/worker_threads.test.ts | 14 ++ 15 files changed, 368 insertions(+), 186 deletions(-) create mode 100644 src/js/internal/worker/stdio.ts diff --git a/src/codegen/bundle-modules.ts b/src/codegen/bundle-modules.ts index 324dd673fdaf..e452a07f8b35 100644 --- a/src/codegen/bundle-modules.ts +++ b/src/codegen/bundle-modules.ts @@ -484,6 +484,25 @@ ${moduleSpans `, ); +// process.moduleLoadList names, indexed by Field. Node reports "NativeModule " / +// "Internal Binding "; JS builtins use their public specifier, native modules their id. +writeIfNotChanged( + path.join(CODEGEN_DIR, "InternalModuleRegistry+names.h"), + `// clang-format off +static constexpr ASCIILiteral internalModuleNames[] = { + ${moduleList + .map( + (id, n) => + JSON.stringify( + (n >= nativeStartIndex ? "Internal Binding " : "NativeModule ") + + (n >= nativeStartIndex ? id : idToPublicSpecifierOrEnumName(id)), + ) + "_s,", + ) + .join("\n ")} +}; +`, +); + // This code slice is used in InternalModuleRegistry.cpp. It defines the loading function for modules. writeIfNotChanged( path.join(CODEGEN_DIR, "InternalModuleRegistry+createInternalModuleById.h"), diff --git a/src/js/builtins/ProcessObjectInternals.ts b/src/js/builtins/ProcessObjectInternals.ts index 0e3793be2e14..37501f664d0b 100644 --- a/src/js/builtins/ProcessObjectInternals.ts +++ b/src/js/builtins/ProcessObjectInternals.ts @@ -115,6 +115,20 @@ export function getStdioWriteStream( return [stream, underlyingSink]; } +// node:worker_threads worker: process.stdout/stderr write to the parent Worker over a +// MessagePort; process.stdin reads from one for { stdin: true }, else is already ended. +export function getNodeWorkerStdioStream(process: typeof globalThis.process, fd: number, ports: any) { + const stdio = require("internal/worker/stdio"); + if (fd === 0) { + return ports.stdin ? stdio.makePortReadable(ports.stdin, true) : stdio.makeEndedReadable(); + } + const stream = stdio.makePortWritable(fd === 1 ? ports.stdout : ports.stderr); + // A synchronous exit leaves no loop turn for the reader's ack; complete the parked + // writev so buffered chunks are posted before the thread goes away (node's flushSync). + process.on("exit", stream[stdio.kFlushSync]); + return stream; +} + export function getStdinStream( process: typeof globalThis.process, fd: number, diff --git a/src/js/internal/worker/stdio.ts b/src/js/internal/worker/stdio.ts new file mode 100644 index 000000000000..a07790ebc8c6 --- /dev/null +++ b/src/js/internal/worker/stdio.ts @@ -0,0 +1,149 @@ +// Port-backed stdio streams shared by both ends of a node:worker_threads Worker: +// worker.stdout/stderr/stdin on the parent, process.stdout/stderr/stdin in the +// worker (built lazily from BunProcess.cpp via ProcessObjectInternals). +// +// Each stream rides its own MessageChannel with node's flow control +// (lib/internal/worker/io.js): the writer posts an array of chunks and withholds +// the writev callback until the reader acks 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. The payload is the +// bare chunk array, EOF is null, and any other message is the ack. +const Readable = require("internal/streams/readable"); +const Writable = require("internal/streams/writable"); + +const kFlushSync = Symbol("kFlushSync"); + +// Readable fed by a MessagePort (worker.stdout/stderr on the parent, process.stdin +// in the worker). The peer posts arrays of Buffers; null signals EOF. +function makePortReadable(port: MessagePort, incrementsPortRef: boolean) { + let ended = false; + let startedReading = false; + function onMessage(event: MessageEvent) { + const payload = event.data; + if (payload === null) { + if (ended === false) { + ended = true; + stream.push(null); + } + port.removeEventListener("message", onMessage); + } else if (ended === false) { + for (let i = 0; i < payload.length; i++) { + stream.push(Buffer.from(payload[i])); + } + } + } + const stream = new Readable({ + read() { + if (startedReading === false && incrementsPortRef) { + startedReading = true; + port.ref(); + } + // 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); + }, + }); + // Attach eagerly so the peer's writev is ack'd (via push -> maybeReadMore -> + // _read) even when no one consumes this stream; unref immediately so an + // unconsumed captured stream never pins the loop on its own (node's model). + port.addEventListener("message", onMessage); + port.unref(); + // 'close' covers natural EOF and destroy(); release the read-time ref and + // drop the listener so a destroyed captured stream can't pin an unref'd worker. + stream.on("close", () => { + ended = true; + port.removeEventListener("message", onMessage); + if (startedReading && incrementsPortRef) { + startedReading = false; + port.unref(); + } + }); + // Lets the parent end worker.stdout/stderr when the worker exits abruptly. + stream.endFromOwner = function () { + if (ended === false) { + ended = true; + stream.push(null); + port.removeEventListener("message", onMessage); + } + }; + return stream; +} + +// Writable that forwards chunks over a MessagePort (worker.stdin on the parent, +// process.stdout/stderr in the worker). final() posts null as EOF. +function makePortWritable(port: MessagePort) { + // 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.addEventListener("message", onAck); + port.unref(); + const stream = 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.removeEventListener("message", onAck); + cb(err); + }, + }); + // On a synchronous exit no ack can arrive; completing the parked writev lets + // the Writable clear its buffer through writev, which now completes + // synchronously because process._exiting is set (node's flushSync). + stream[kFlushSync] = onAck; + return stream; +} + +// A node worker's process.stdin without { stdin: true }. +function makeEndedReadable() { + return new Readable({ + read() { + this.push(null); + }, + }); +} + +export default { + kFlushSync, + makePortReadable, + makePortWritable, + makeEndedReadable, +}; diff --git a/src/js/node/worker_threads.ts b/src/js/node/worker_threads.ts index f9d221865a82..bd98b0529f15 100644 --- a/src/js/node/worker_threads.ts +++ b/src/js/node/worker_threads.ts @@ -4,7 +4,6 @@ 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, @@ -80,6 +79,7 @@ const { 9: _setEntryEvaluatedHook, 10: _isNodeWorker, 11: _setParentPort, + 12: _setStdioPorts, } = $cpp("Worker.cpp", "createNodeWorkerThreadsBinding") as [ unknown, number, @@ -93,6 +93,7 @@ const { (hook: () => void) => void, boolean, (port: MessagePort) => void, + (ports: object) => void, ]; type NodeWorkerOptions = import("node:worker_threads").WorkerOptions; @@ -326,175 +327,24 @@ const BUN_WORKER_MESSAGING_KEY = "@@bunWorkerThreadsMessaging"; // stdio and control ports. const BUN_WORKER_PARENT_PORT_KEY = "@@bunWorkerThreadsParentPort"; -// 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. - -const kFlushSync = Symbol("kFlushSync"); - -// 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, incrementsPortRef) { - let ended = false; - let startedReading = false; - function onMessage(payload) { - if (payload === null) { - if (ended === false) { - ended = true; - stream.push(null); - } - port.off("message", onMessage); - } else if (ended === false) { - for (let i = 0; i < payload.length; i++) { - stream.push(Buffer.from(payload[i])); - } - } - } - const stream = new Readable({ - read() { - if (startedReading === false && incrementsPortRef) { - startedReading = true; - port.ref(); - } - // 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); - }, - }); - // Attach eagerly so the peer's writev is ack'd (via push -> maybeReadMore -> - // _read) even when no one consumes this stream; unref immediately so an - // unconsumed captured stream never pins the loop on its own (node's model). - port.on("message", onMessage); - port.unref(); - // 'close' covers natural EOF and destroy(); release the read-time ref and - // drop the listener so a destroyed captured stream can't pin an unref'd worker. - stream.on("close", () => { - ended = true; - port.off("message", onMessage); - if (startedReading && incrementsPortRef) { - startedReading = false; - port.unref(); - } - }); - // Lets the parent end worker.stdout/stderr when the worker exits abruptly. - stream.endFromOwner = function () { - if (ended === false) { - ended = true; - stream.push(null); - 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(); - const stream = 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); - }, - }); - // On a synchronous exit no ack can arrive; completing the parked writev lets - // the Writable clear its buffer through writev, which now completes - // synchronously because process._exiting is set (node's flushSync). - stream[kFlushSync] = onAck; - return stream; -} +const { makePortReadable, makePortWritable } = require("internal/worker/stdio"); // The parent always sends stdout and stderr ports; stdin only for { stdin: true }. +// With the ports registered on the global, process.stdin/stdout/stderr's native +// lazy getters build the port-backed streams (never the fd-backed ones a plain +// thread would get); read them here so they exist before user code, as in node. function setupWorkerStdio(stdio) { - const { stdin, stdout, stderr } = stdio; - const stdoutStream = makePortWritable(stdout); - const stderrStream = makePortWritable(stderr); - Object.defineProperty(process, "stdout", { - value: stdoutStream, - writable: true, - configurable: true, - enumerable: true, - }); - Object.defineProperty(process, "stderr", { - value: stderrStream, - writable: true, - configurable: true, - enumerable: true, - }); + _setStdioPorts(stdio); + const stdoutStream = process.stdout; + const stderrStream = process.stderr; // 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, true) - : new Readable({ - read() { - this.push(null); - }, - }), - writable: true, - configurable: true, - enumerable: true, - }); + void process.stdin; // node routes console.log through process.stdout/stderr; Bun's global console // writes the fd directly, so rebind it to the port-backed streams. const { Console } = require("node:console"); globalThis.console = new Console(stdoutStream, stderrStream); - process.on("exit", () => { - stdoutStream[kFlushSync](); - stderrStream[kFlushSync](); - }); } // Emulation of Node's JSTransferable protocol (kTransfer/kTransferList/kDeserialize) for @@ -871,12 +721,6 @@ function applyWorkerProcessOverrides() { 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) { @@ -1080,14 +924,6 @@ class Worker extends EventEmitter { // 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); // Create the readables eagerly so the worker's writev is ack'd even when // worker.stdout/stderr is never touched; only captured streams ref their diff --git a/src/jsc/bindings/BunClientData.h b/src/jsc/bindings/BunClientData.h index 0140bf847c35..1478900fb0d9 100644 --- a/src/jsc/bindings/BunClientData.h +++ b/src/jsc/bindings/BunClientData.h @@ -241,10 +241,14 @@ class JSVMClientData : public JSC::VM::ClientData { SentinelLinkedList> m_clients; bool m_isWorkerVM { false }; + bool m_isNodeWorkerVM { false }; public: // upstream's `&vm != commonVMOrNull()` bool isWorkerVM() const { return m_isWorkerVM; } + // Created by node:worker_threads' Worker (WorkerOptions::Kind::Node), as opposed to the Web Worker constructor. + bool isNodeWorkerVM() const { return m_isNodeWorkerVM; } + void setIsNodeWorkerVM(bool value) { m_isNodeWorkerVM = value; } // VM thread. Unlinking is the client's own (`remove()` in its destructor). void addClient(JSVMClientDataClient& client) { m_clients.append(&client); } }; diff --git a/src/jsc/bindings/BunProcess.cpp b/src/jsc/bindings/BunProcess.cpp index 002582108907..e946b95aee99 100644 --- a/src/jsc/bindings/BunProcess.cpp +++ b/src/jsc/bindings/BunProcess.cpp @@ -2922,9 +2922,32 @@ enum class BunProcessStdinFdType : int32_t { extern "C" BunProcessStdinFdType Bun__Process__getStdinFdType(void*, int fd); extern "C" void Bun__ForceFileSinkToBeSynchronousForProcessObjectStdio(JSC::JSGlobalObject*, JSC::EncodedJSValue); +// node:worker_threads worker: process.stdout/stderr forward to the parent Worker's +// worker.stdout/stderr over a MessagePort; process.stdin is port-fed for { stdin: true } +// and otherwise an already-ended Readable (never the process-wide fd 0). fd 0/1/2 selects the port. +static JSValue constructNodeWorkerStdioStream(JSC::JSGlobalObject* globalObject, JSC::JSObject* processObject, JSObject* ports, int fd) +{ + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); + JSC::JSFunction* getStream = JSC::JSFunction::create(vm, globalObject, processObjectInternalsGetNodeWorkerStdioStreamCodeGenerator(vm), globalObject); + JSC::MarkedArgumentBuffer args; + args.append(processObject); + args.append(JSC::jsNumber(fd)); + args.append(ports); + auto result = JSC::profiledCall(globalObject, ProfilingReason::API, getStream, JSC::getCallData(getStream), globalObject->globalThis(), args); + if (auto* exception = scope.exception()) { + (void)scope.tryClearException(); + Zig::GlobalObject::reportUncaughtExceptionAtEventLoop(globalObject, exception); + return jsUndefined(); + } + return result; +} + static JSValue constructStdioWriteStream(JSC::JSGlobalObject* globalObject, JSC::JSObject* processObject, int fd) { auto& vm = JSC::getVM(globalObject); + if (auto* ports = defaultGlobalObject(globalObject)->nodeWorkerStdioPorts()) + return constructNodeWorkerStdioStream(globalObject, processObject, ports, fd); auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); JSC::JSFunction* getStdioWriteStream = JSC::JSFunction::create(vm, globalObject, processObjectInternalsGetStdioWriteStreamCodeGenerator(vm), globalObject); @@ -2971,6 +2994,13 @@ static JSValue constructStdioWriteStream(JSC::JSGlobalObject* globalObject, JSC: return resultObject->getIndex(globalObject, 0); } +static JSValue constructModuleLoadList(VM& vm, JSObject* processObject) +{ + auto* globalObject = defaultGlobalObject(processObject->globalObject()); + auto* list = globalObject->internalModuleRegistry()->moduleLoadList(globalObject); + return list ? JSValue(list) : jsUndefined(); +} + static JSValue constructStdout(VM& vm, JSObject* processObject) { return constructStdioWriteStream(processObject->globalObject(), processObject, 1); @@ -2988,6 +3018,8 @@ static JSValue constructStderr(VM& vm, JSObject* processObject) static JSValue constructStdin(VM& vm, JSObject* processObject) { auto* globalObject = processObject->globalObject(); + if (auto* ports = defaultGlobalObject(globalObject)->nodeWorkerStdioPorts()) + return constructNodeWorkerStdioStream(globalObject, processObject, ports, 0); auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); JSC::JSFunction* getStdinStream = JSC::JSFunction::create(vm, globalObject, processObjectInternalsGetStdinStreamCodeGenerator(vm), globalObject); JSC::MarkedArgumentBuffer args; @@ -4859,8 +4891,6 @@ extern "C" void Process__emitErrorEvent(Zig::GlobalObject* global, EncodedJSValu /* Source for Process.lut.h @begin processObjectTable - _debugEnd Process_stubEmptyFunction Function 0 - _debugProcess Process_stubEmptyFunction Function 0 _eval processGetEval CustomAccessor _getActiveHandles Process_stubFunctionReturningArray Function 0 _getActiveRequests Process_stubFunctionReturningArray Function 0 @@ -4868,8 +4898,6 @@ extern "C" void Process__emitErrorEvent(Zig::GlobalObject* global, EncodedJSValu _linkedBinding Process_stubEmptyFunction Function 0 _preload_modules Process_stubEmptyArray PropertyCallback _rawDebug constructRawDebug PropertyCallback - _startProfilerIdleNotifier Process_stubEmptyFunction Function 0 - _stopProfilerIdleNotifier Process_stubEmptyFunction Function 0 _tickCallback Process_stubEmptyFunction Function 0 abort Process_functionAbort Function 1 allowedNodeEnvironmentFlags constructAllowedNodeEnvironmentFlags PropertyCallback @@ -4910,7 +4938,7 @@ extern "C" void Process__emitErrorEvent(Zig::GlobalObject* global, EncodedJSValu kill Process_functionKill Function 2 mainModule constructMainModuleProperty PropertyCallback memoryUsage constructMemoryUsage PropertyCallback - moduleLoadList Process_stubEmptyArray PropertyCallback + moduleLoadList constructModuleLoadList PropertyCallback nextTick constructProcessNextTickFn PropertyCallback openStdin Process_functionOpenStdin Function 0 pid constructPid PropertyCallback @@ -4989,6 +5017,12 @@ void Process::finishCreation(JSC::VM& vm) putDirect(vm, vm.propertyNames->toStringTagSymbol, jsString(vm, String("process"_s)), 0); putDirect(vm, Identifier::fromString(vm, "_exiting"_s), jsBoolean(false), 0); + // Main-thread-only internals; absent on a node:worker_threads worker's process, as in Node. + if (!WebCore::clientData(vm)->isNodeWorkerVM()) { + auto* globalObject = this->globalObject(); + for (ASCIILiteral name : { "_debugEnd"_s, "_debugProcess"_s, "_startProfilerIdleNotifier"_s, "_stopProfilerIdleNotifier"_s }) + putDirectNativeFunction(vm, globalObject, Identifier::fromString(vm, name), 0, Process_stubEmptyFunction, ImplementationVisibility::Public, NoIntrinsic, PropertyAttribute::Function | 0); + } // Node's addReadOnlyProcessAlias: read-only so `process.noDeprecation = false` // is ignored, but a per-Process property — a Worker must not flip the main // thread. Unflagged it stays an ordinary undefined slot user code can set. diff --git a/src/jsc/bindings/InternalModuleRegistry.cpp b/src/jsc/bindings/InternalModuleRegistry.cpp index f62c5ba050f9..7e6a39f53d2f 100644 --- a/src/jsc/bindings/InternalModuleRegistry.cpp +++ b/src/jsc/bindings/InternalModuleRegistry.cpp @@ -146,6 +146,7 @@ void InternalModuleRegistry::visitChildrenImpl(JSCell* cell, Visitor& visitor) auto* thisObject = uncheckedDowncast(cell); ASSERT_GC_OBJECT_INHERITS(thisObject, info()); Base::visitChildren(thisObject, visitor); + visitor.append(thisObject->m_moduleLoadList); } DEFINE_VISIT_CHILDREN_WITH_MODIFIER(JS_EXPORT_PRIVATE, InternalModuleRegistry); @@ -177,11 +178,37 @@ JSValue InternalModuleRegistry::requireId(JSGlobalObject* globalObject, VM& vm, value = createInternalModuleById(globalObject, vm, id); RETURN_IF_EXCEPTION(throwScope, {}); internalField(id).set(vm, this, value); + didLoad(globalObject, vm, id); + RETURN_IF_EXCEPTION(throwScope, {}); } return value; } #include "InternalModuleRegistry+createInternalModuleById.h" +#include "InternalModuleRegistry+names.h" + +void InternalModuleRegistry::didLoad(JSGlobalObject* globalObject, VM& vm, Field id) +{ + ASSERT(m_loadCount < BUN_INTERNAL_MODULE_COUNT); + m_loadOrder[m_loadCount++] = static_cast(id); + if (auto* list = m_moduleLoadList.get()) + list->push(globalObject, jsString(vm, String(internalModuleNames[static_cast(id)]))); +} + +JSArray* InternalModuleRegistry::moduleLoadList(JSGlobalObject* globalObject) +{ + if (auto* list = m_moduleLoadList.get()) + return list; + auto& vm = globalObject->vm(); + auto throwScope = DECLARE_THROW_SCOPE(vm); + MarkedArgumentBuffer names; + for (uint16_t i = 0; i < m_loadCount; i++) + names.append(jsString(vm, String(internalModuleNames[m_loadOrder[i]]))); + auto* list = constructArray(globalObject, static_cast(nullptr), names); + RETURN_IF_EXCEPTION(throwScope, nullptr); + m_moduleLoadList.set(vm, this, list); + return list; +} // This is called like @getInternalField(@internalModuleRegistry, 1) ?? @createInternalModuleById(1) // so we want to write it to the internal field when loaded. @@ -195,6 +222,8 @@ JSC_DEFINE_HOST_FUNCTION(InternalModuleRegistry::jsCreateInternalModuleById, (JS auto mod = registry->createInternalModuleById(lexicalGlobalObject, vm, static_cast(id)); RETURN_IF_EXCEPTION(throwScope, {}); registry->internalField(static_cast(id)).set(vm, registry, mod); + registry->didLoad(lexicalGlobalObject, vm, static_cast(id)); + RETURN_IF_EXCEPTION(throwScope, {}); return JSValue::encode(mod); } diff --git a/src/jsc/bindings/InternalModuleRegistry.h b/src/jsc/bindings/InternalModuleRegistry.h index 108e8e7dbd23..25c546031a7b 100644 --- a/src/jsc/bindings/InternalModuleRegistry.h +++ b/src/jsc/bindings/InternalModuleRegistry.h @@ -56,11 +56,21 @@ class InternalModuleRegistry : public JSInternalFieldObjectImpl m_moduleLoadList; + uint16_t m_loadCount { 0 }; + uint8_t m_loadOrder[BUN_INTERNAL_MODULE_COUNT]; }; } // namespace Bun diff --git a/src/jsc/bindings/ZigGlobalObject.cpp b/src/jsc/bindings/ZigGlobalObject.cpp index bab0d2aae4ba..9cab2cb03dd5 100644 --- a/src/jsc/bindings/ZigGlobalObject.cpp +++ b/src/jsc/bindings/ZigGlobalObject.cpp @@ -515,6 +515,8 @@ extern "C" JSC::JSGlobalObject* Zig__GlobalObject__create(void* console_client, ASSERT(vmPtr->runLoop().kind() == WTF::RunLoop::Kind::Bun); WebCore::JSVMClientData::create(&vm, Bun__getVM(), /* isWorkerVM */ worker_ptr != nullptr); + if (auto* worker = static_cast(worker_ptr)) + WebCore::clientData(vm)->setIsNodeWorkerVM(worker->options().kind == WebCore::WorkerOptions::Kind::Node); const auto createGlobalObject = [&]() -> Zig::GlobalObject* { if (executionContextId == std::numeric_limits::max() || executionContextId > 1) [[unlikely]] { @@ -4225,6 +4227,7 @@ void GlobalObject::adoptNapiEnvsForTestIsolation(GlobalObject* oldGlobal) } void GlobalObject::setNodeWorkerEnvironmentData(JSMap* data) { m_nodeWorkerEnvironmentData.set(vm(), this, data); } +void GlobalObject::setNodeWorkerStdioPorts(JSObject* ports) { m_nodeWorkerStdioPorts.set(vm(), this, ports); } void GlobalObject::setNodeWorkerEntryEvaluatedHook(JSObject* hook) { if (hook) diff --git a/src/jsc/bindings/ZigGlobalObject.h b/src/jsc/bindings/ZigGlobalObject.h index 9c5b615cab57..ef6385d8dc64 100644 --- a/src/jsc/bindings/ZigGlobalObject.h +++ b/src/jsc/bindings/ZigGlobalObject.h @@ -545,6 +545,9 @@ class GlobalObject : public Bun::GlobalScope { /* setupMainThreadPort's drain callback; run once by WebWorker__entrySettled */ \ /* after entry-module evaluation. Stored here (not on globalThis) so user code can't clobber it. */ \ V(private, WriteBarrier, m_nodeWorkerEntryEvaluatedHook) \ + /* node:worker_threads worker: { stdin?, stdout, stderr } MessagePorts from the parent Worker; */ \ + /* process.stdin/stdout/stderr are built over these lazily (BunProcess.cpp constructStd*). */ \ + V(private, WriteBarrier, m_nodeWorkerStdioPorts) \ \ /* The original, unmodified Error.prepareStackTrace. */ \ /* */ \ @@ -782,6 +785,8 @@ class GlobalObject : public Bun::GlobalScope { JSObject* JSDOMFileConstructor() const { return m_JSDOMFileConstructor.getInitializedOnMainThread(this); } JSMap* nodeWorkerEnvironmentData() { return m_nodeWorkerEnvironmentData.get(); } + JSObject* nodeWorkerStdioPorts() { return m_nodeWorkerStdioPorts.get(); } + void setNodeWorkerStdioPorts(JSObject* ports); void setNodeWorkerEnvironmentData(JSMap* data); // node:worker_threads parentPort — the transferred MessagePort entangled with the parent // Worker's public port. Messages it dispatches are mirrored onto globalEventScope so the diff --git a/src/jsc/bindings/webcore/Worker.cpp b/src/jsc/bindings/webcore/Worker.cpp index a4b39d1b9df8..e034e7dc4436 100644 --- a/src/jsc/bindings/webcore/Worker.cpp +++ b/src/jsc/bindings/webcore/Worker.cpp @@ -28,6 +28,7 @@ #include "Worker.h" #include "BunClientData.h" +#include "InternalModuleRegistry.h" #include "ErrorCode.h" #include "ErrorEvent.h" #include "Event.h" @@ -202,6 +203,7 @@ extern "C" void WebWorker__dispatchError(Zig::GlobalObject* globalObject, Worker } JSC_DECLARE_HOST_FUNCTION(jsFunctionSetParentPort); +JSC_DECLARE_HOST_FUNCTION(jsFunctionSetNodeWorkerStdioPorts); JSC_DEFINE_HOST_FUNCTION(jsReceiveMessageOnPort, (JSGlobalObject * lexicalGlobalObject, CallFrame* callFrame)) { @@ -344,7 +346,7 @@ JSValue createNodeWorkerThreadsBinding(Zig::GlobalObject* globalObject) bool isNodeWorker = proxy && proxy->options().kind == WorkerOptions::Kind::Node; - JSObject* array = constructEmptyArray(globalObject, nullptr, 12); + JSObject* array = constructEmptyArray(globalObject, nullptr, 13); RETURN_IF_EXCEPTION(scope, {}); array->putDirectIndex(globalObject, 0, workerData); array->putDirectIndex(globalObject, 1, threadId); @@ -358,9 +360,38 @@ JSValue createNodeWorkerThreadsBinding(Zig::GlobalObject* globalObject) array->putDirectIndex(globalObject, 9, JSFunction::create(vm, globalObject, 1, "setEntryEvaluatedHook"_s, jsFunctionSetEntryEvaluatedHook, ImplementationVisibility::Public, NoIntrinsic)); array->putDirectIndex(globalObject, 10, jsBoolean(isNodeWorker)); array->putDirectIndex(globalObject, 11, JSFunction::create(vm, globalObject, 1, "setParentPort"_s, jsFunctionSetParentPort, ImplementationVisibility::Public, NoIntrinsic)); + array->putDirectIndex(globalObject, 12, JSFunction::create(vm, globalObject, 1, "setStdioPorts"_s, jsFunctionSetNodeWorkerStdioPorts, ImplementationVisibility::Public, NoIntrinsic)); return array; } +// worker_threads (worker side): { stdin?, stdout, stderr } ports from the parent Worker. +// process.stdin/stdout/stderr are created over them on first access (BunProcess.cpp). +JSC_DEFINE_HOST_FUNCTION(jsFunctionSetNodeWorkerStdioPorts, (JSGlobalObject * lexicalGlobalObject, CallFrame* callFrame)) +{ + if (auto* ports = callFrame->argument(0).getObject()) + defaultGlobalObject(lexicalGlobalObject)->setNodeWorkerStdioPorts(ports); + return JSValue::encode(jsUndefined()); +} + +// node runs its worker bootstrap before user code: on a node:worker_threads worker +// thread, load node:worker_threads (which rebinds process stdio, registers +// parentPort and the postMessageToThread port) before preloads and the entry +// point. Returns false with the exception reported. +extern "C" bool Bun__NodeWorker__bootstrap(Zig::GlobalObject* globalObject) +{ + auto& vm = globalObject->vm(); + if (!WebCore::clientData(vm)->isNodeWorkerVM()) + return true; + auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); + globalObject->internalModuleRegistry()->requireId(globalObject, vm, Bun::InternalModuleRegistry::Field::NodeWorkerThreads); + if (auto* exception = scope.exception()) [[unlikely]] { + (void)scope.tryClearException(); + Zig::GlobalObject::reportUncaughtExceptionAtEventLoop(globalObject, exception); + return false; + } + return true; +} + // worker_threads (worker side): register the transferred port as this thread's parentPort. JSC_DEFINE_HOST_FUNCTION(jsFunctionSetParentPort, (JSGlobalObject * lexicalGlobalObject, CallFrame* callFrame)) { diff --git a/src/jsc/web_worker.rs b/src/jsc/web_worker.rs index 0c631fb31c4d..f27638e9d921 100644 --- a/src/jsc/web_worker.rs +++ b/src/jsc/web_worker.rs @@ -160,6 +160,9 @@ unsafe extern "C" { ); safe fn WebWorker__parentContextWillDestroy(proxy: *mut c_void); safe fn WebWorker__entrySettled(global: &JSGlobalObject); + /// Loads node:worker_threads on a node:worker_threads worker (no-op for a + /// Web Worker). `false` = it threw; the exception has been reported. + safe fn Bun__NodeWorker__bootstrap(global: &JSGlobalObject) -> bool; safe fn WebWorker__dispatchError( global: &JSGlobalObject, proxy: *mut c_void, @@ -320,8 +323,7 @@ impl WebWorker { 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. + // module loader resolves them. if utf8_slice.slice().starts_with(b"node:") { preloads.push(utf8_slice.slice().to_vec().into_boxed_slice()); continue; @@ -842,6 +844,18 @@ impl WebWorker { return self.shutdown(); } + // node:worker_threads: wire parentPort / stdio / process overrides + // before preloads and the entry point (Node runs its worker bootstrap + // ahead of user code). A throw here is the worker's startup error. + if !Bun__NodeWorker__bootstrap(vm.global()) { + if !self.exit_called.load(Ordering::Relaxed) { + vm.as_mut().exit_handler.exit_code = 1; + } + self.flush_logs(vm); + WebWorker__entrySettled(vm.global()); + return self.shutdown(); + } + // `path` borrows the resolver's process-lifetime string store, the // standalone module graph, or `self.unresolved_specifier` — all of // which outlive the worker VM. `vm.main` stores it as a raw BACKREF diff --git a/src/runtime/jsc_hooks.rs b/src/runtime/jsc_hooks.rs index e7c48b864048..6133f5ceb3e8 100644 --- a/src/runtime/jsc_hooks.rs +++ b/src/runtime/jsc_hooks.rs @@ -768,9 +768,7 @@ unsafe fn load_preloads(vm: *mut VirtualMachine) -> bun_jsc::CrateResult<*mut JS .unwrap_or(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. + // resolves them internally, so `bun --import node:*` works like Node's. let module_name = if normalized.starts_with(b"node:") { bun_core::String::from_bytes(normalized) } else { diff --git a/test/js/node/process/process.test.js b/test/js/node/process/process.test.js index dcd568226a1f..d98687d351f6 100644 --- a/test/js/node/process/process.test.js +++ b/test/js/node/process/process.test.js @@ -1287,7 +1287,7 @@ describe.concurrent(() => { } const emptyObjectStubs = []; - const emptyArrayStubs = ["moduleLoadList", "_preload_modules"]; + const emptyArrayStubs = ["_preload_modules"]; for (const stub of emptyObjectStubs) { it(`process.${stub}`, () => { @@ -1322,6 +1322,28 @@ describe.concurrent(() => { }); } + it("process.moduleLoadList", async () => { + // Lists the builtin modules this thread has loaded, in load order, and keeps + // growing as more load (same array). Run in a fresh process so the list is small. + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const list = process.moduleLoadList; + const before = list.length; + require("node:zlib"); + if (process.moduleLoadList !== list) throw new Error("identity"); + console.log(JSON.stringify({ hadZlib: list.slice(0, before).includes("NativeModule node:zlib"), last: list.at(-1), grew: list.length > before }));`, + ], + env: bunEnv, + stderr: "inherit", + }); + expect(await proc.stdout.text()).toBe( + JSON.stringify({ hadZlib: false, last: "NativeModule node:zlib", grew: true }) + "\n", + ); + expect(await proc.exited).toBe(0); + }); + it("dlopen args parsing", () => { const notFound = join(tmpdirSync(), "not-found.so"); expect(() => process.dlopen({ module: "42" }, notFound)).toThrow(); diff --git a/test/js/node/worker_threads/worker_threads.test.ts b/test/js/node/worker_threads/worker_threads.test.ts index f4a126a6dd19..9c95272135cf 100644 --- a/test/js/node/worker_threads/worker_threads.test.ts +++ b/test/js/node/worker_threads/worker_threads.test.ts @@ -2844,3 +2844,17 @@ describe("no JS entry after a worker's termination has been thrown", () => { }); } }); + +// Startup-cost guard: a node worker's bootstrap must not build the fd-backed +// process.stdout/stderr/stdin (node:fs + fs streams) it immediately replaces, nor +// reify process's whole static table (which builds the nextTick queue). Each +// builtin here is re-parsed on every worker thread. +test("a Worker's bootstrap doesn't build fd stdio or reify all of process", async () => { + const w = new Worker("self.postMessage(process.moduleLoadList)", { eval: true }); + const [list] = await once(w, "message"); + await w.terminate(); + expect(list).toContain("NativeModule node:worker_threads"); + for (const unexpected of ["node:fs", "internal:fs/streams", "internal:fs/glob", "internal:fixed_queue"]) { + expect(list).not.toContain("NativeModule " + unexpected); + } +}); From 0621db919dd91211bedef8cb7b5719884cb163c6 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 18 Aug 2026 08:40:01 +0000 Subject: [PATCH 2/4] [autofix.ci] apply automated fixes --- src/jsc/bindings/ZigGlobalObject.h | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/jsc/bindings/ZigGlobalObject.h b/src/jsc/bindings/ZigGlobalObject.h index ef6385d8dc64..02cea87d7e94 100644 --- a/src/jsc/bindings/ZigGlobalObject.h +++ b/src/jsc/bindings/ZigGlobalObject.h @@ -545,9 +545,9 @@ class GlobalObject : public Bun::GlobalScope { /* setupMainThreadPort's drain callback; run once by WebWorker__entrySettled */ \ /* after entry-module evaluation. Stored here (not on globalThis) so user code can't clobber it. */ \ V(private, WriteBarrier, m_nodeWorkerEntryEvaluatedHook) \ - /* node:worker_threads worker: { stdin?, stdout, stderr } MessagePorts from the parent Worker; */ \ - /* process.stdin/stdout/stderr are built over these lazily (BunProcess.cpp constructStd*). */ \ - V(private, WriteBarrier, m_nodeWorkerStdioPorts) \ + /* node:worker_threads worker: { stdin?, stdout, stderr } MessagePorts from the parent Worker; */ \ + /* process.stdin/stdout/stderr are built over these lazily (BunProcess.cpp constructStd*). */ \ + V(private, WriteBarrier, m_nodeWorkerStdioPorts) \ \ /* The original, unmodified Error.prepareStackTrace. */ \ /* */ \ From 40aec9ced448d9e964293250714b03e156c431da Mon Sep 17 00:00:00 2001 From: Dylan Conway Date: Tue, 18 Aug 2026 09:43:59 +0000 Subject: [PATCH 3/4] process.moduleLoadList: don't let an exception escape the lazy property builder --- src/jsc/bindings/BunProcess.cpp | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/jsc/bindings/BunProcess.cpp b/src/jsc/bindings/BunProcess.cpp index e946b95aee99..9c59d9cdb230 100644 --- a/src/jsc/bindings/BunProcess.cpp +++ b/src/jsc/bindings/BunProcess.cpp @@ -2997,8 +2997,16 @@ static JSValue constructStdioWriteStream(JSC::JSGlobalObject* globalObject, JSC: static JSValue constructModuleLoadList(VM& vm, JSObject* processObject) { auto* globalObject = defaultGlobalObject(processObject->globalObject()); + // Lazy property builder: an exception (OOM allocating the array) must not + // propagate into reifyStaticProperty, which performs no exception check. + auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); auto* list = globalObject->internalModuleRegistry()->moduleLoadList(globalObject); - return list ? JSValue(list) : jsUndefined(); + if (auto* exception = scope.exception()) [[unlikely]] { + (void)scope.tryClearException(); + Zig::GlobalObject::reportUncaughtExceptionAtEventLoop(globalObject, exception); + return jsUndefined(); + } + return list; } static JSValue constructStdout(VM& vm, JSObject* processObject) From dd290d1f1f1fa1b4536106f58aa02e69902ff7a0 Mon Sep 17 00:00:00 2001 From: Dylan Conway Date: Tue, 18 Aug 2026 09:46:36 +0000 Subject: [PATCH 4/4] moduleLoadList: append with putDirectIndex so a frozen list or Array.prototype setters can't interfere with loading --- src/jsc/bindings/InternalModuleRegistry.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/jsc/bindings/InternalModuleRegistry.cpp b/src/jsc/bindings/InternalModuleRegistry.cpp index 7e6a39f53d2f..c43a5eeb7f6a 100644 --- a/src/jsc/bindings/InternalModuleRegistry.cpp +++ b/src/jsc/bindings/InternalModuleRegistry.cpp @@ -191,8 +191,10 @@ void InternalModuleRegistry::didLoad(JSGlobalObject* globalObject, VM& vm, Field { ASSERT(m_loadCount < BUN_INTERNAL_MODULE_COUNT); m_loadOrder[m_loadCount++] = static_cast(id); + // putDirectIndex, not push: appending must not run Array.prototype setters or + // fail on a frozen list in the middle of loading a builtin. if (auto* list = m_moduleLoadList.get()) - list->push(globalObject, jsString(vm, String(internalModuleNames[static_cast(id)]))); + list->putDirectIndex(globalObject, list->length(), jsString(vm, String(internalModuleNames[static_cast(id)]))); } JSArray* InternalModuleRegistry::moduleLoadList(JSGlobalObject* globalObject)