From 6240baac771c4ae525bfb2d185c4e6079c86464e Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 10:06:57 +0000 Subject: [PATCH 1/6] worker_threads: stop reifying every lazy process property during worker startup The worker bootstrap replaced process.stdout/stderr/stdin with Object.defineProperty, which first builds the fd-backed stream it is about to discard (loading tty, net, fs and their module graphs), and then deleted process._debugProcess & co., which makes JSC reify every remaining lazy property of the process object and turn it into a dictionary. Assign the stdio streams instead, and define the four main-thread-only stubs as own properties of non-worker process objects so there is nothing to delete in a worker. Cuts node:worker_threads Worker startup by roughly 30% (release: ~33ms to ~22ms; debug: ~2.2s to ~1.6s per worker). --- src/js/node/worker_threads.ts | 48 +++++------ src/jsc/bindings/BunProcess.cpp | 16 +++- .../worker_threads/worker_threads.test.ts | 80 ++++++++++++++++++- 3 files changed, 109 insertions(+), 35 deletions(-) diff --git a/src/js/node/worker_threads.ts b/src/js/node/worker_threads.ts index f9d221865a82..5971afc935e2 100644 --- a/src/js/node/worker_threads.ts +++ b/src/js/node/worker_threads.ts @@ -460,33 +460,23 @@ 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, - }); + const proc: any = process; + // Plain assignment, not Object.defineProperty: process.stdout/stderr/stdin are + // lazy native properties. Assigning replaces one unbuilt; defineProperty first + // builds the fd-backed stream it is about to discard (loading tty/net/fs to do + // so). Either way the result is a writable, enumerable, configurable own property. + proc.stdout = stdoutStream; + proc.stderr = stderrStream; // 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, - }); + proc.stdin = stdin + ? makePortReadable(stdin, true) + : new Readable({ + read() { + this.push(null); + }, + }); // 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"); @@ -864,6 +854,10 @@ function fakeParentPort() { if (!isMainThread && _isNodeWorker) { applyWorkerProcessOverrides(); } +// Only ever assigns or defines individual properties: `delete process.x` makes JSC +// reify every lazy property of the process object (stdio streams, env, versions, +// ...). The main-only internals node deletes in workers (process._debugProcess & co.) +// are therefore never defined on a worker's process to begin with (BunProcess.cpp). function applyWorkerProcessOverrides() { const proc: any = process; // node defaults debugPort to 9229 in workers (still settable). Per-object property: @@ -871,12 +865,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) { diff --git a/src/jsc/bindings/BunProcess.cpp b/src/jsc/bindings/BunProcess.cpp index dd5b7bda072f..8f890448d5c6 100644 --- a/src/jsc/bindings/BunProcess.cpp +++ b/src/jsc/bindings/BunProcess.cpp @@ -4840,8 +4840,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 @@ -4849,8 +4847,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 @@ -4970,6 +4966,18 @@ 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); + + // Node's worker threads have no process._debugProcess & co. These are own + // properties rather than processObjectTable entries because deleting a + // static-table property makes JSC reify every lazy property in the table + // (the stdio streams, env, versions, config, ...): exactly the work a + // worker's startup is supposed to skip. + if (!WebCore::clientData(vm)->isWorkerVM()) { + auto* globalObject = this->globalObject(); + for (auto name : { "_debugProcess"_s, "_debugEnd"_s, "_startProfilerIdleNotifier"_s, "_stopProfilerIdleNotifier"_s }) + putDirectNativeFunction(vm, globalObject, Identifier::fromString(vm, name), 0, Process_stubEmptyFunction, ImplementationVisibility::Public, NoIntrinsic, 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/test/js/node/worker_threads/worker_threads.test.ts b/test/js/node/worker_threads/worker_threads.test.ts index cc7acb16c723..54c7db143c62 100644 --- a/test/js/node/worker_threads/worker_threads.test.ts +++ b/test/js/node/worker_threads/worker_threads.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, setDefaultTimeout, test } from "bun:test"; -import { bunEnv, bunExe, isDebug, tempDir, tmpdirSync } from "harness"; +import { bunEnv, bunExe, isDebug, isWindows, tempDir, tmpdirSync } from "harness"; import { once } from "node:events"; import fs from "node:fs"; import { join, relative, resolve } from "node:path"; @@ -1925,6 +1925,84 @@ test("process.debugPort defaults to 9229 on the main thread", async () => { expect(exitCode).toBe(0); }); +// The worker bootstrap rebinds process.stdout/stderr/stdin and removes/replaces a few +// process methods. It must do so without building the process object's lazy native +// properties: Object.defineProperty over one constructs the fd-backed stdio stream it is +// about to replace, and `delete` makes JSC reify every lazy property (env, versions, +// config, the stdio streams, ...). Constructing all of that was most of a worker's +// startup. Same invariant test/js/bun/util/BunObject.test.ts holds for the Bun object. +test("the worker bootstrap leaves the process object's lazy properties unbuilt", async () => { + const mainOnlyInternals = ["_debugProcess", "_debugEnd", "_startProfilerIdleNotifier", "_stopProfilerIdleNotifier"]; + const worker = new Worker( + `const { hasNonReifiedStatic } = require("bun:internal-for-testing"); + const lazy = hasNonReifiedStatic(process); + const shape = name => { + const { value, writable, enumerable, configurable } = Object.getOwnPropertyDescriptor(process, name); + return [value.constructor.name, writable, enumerable, configurable]; + }; + require("worker_threads").parentPort.postMessage({ + lazy, + stdout: shape("stdout"), + stderr: shape("stderr"), + stdin: shape("stdin"), + presentInternals: ${JSON.stringify(mainOnlyInternals)}.filter(name => name in process), + });`, + { eval: true }, + ); + const [result] = await once(worker, "message"); + await worker.terminate(); + expect(result).toEqual({ + lazy: true, + // The same own data properties the main thread ends up with once it touches them. + stdout: ["Writable", true, true, true], + stderr: ["Writable", true, true, true], + stdin: ["Readable", true, true, true], + presentInternals: [], + }); + // ...while the main thread still has node's no-op stubs. + expect(mainOnlyInternals.map(name => typeof process[name])).toEqual(["function", "function", "function", "function"]); +}); + +// The previous test cannot see the stdio streams themselves being built, because building +// a single lazy property does not count as reifying the table. Building the fd-backed +// stdout/stderr streams of a piped stdio dups fd 1 and 2, so an fd aliasing either of them +// that did not exist before the worker was created is the streams having been built and +// thrown away. A child process is used so that stdio is piped whatever the test runner's is. +test.skipIf(isWindows)( + "the worker bootstrap does not build (and dup the fds of) the stdio streams it replaces", + async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const { Worker } = require("worker_threads"); + // Self-contained: its source is also evaluated inside the worker. + const aliasesOfStdio = () => { + const fs = require("fs"); + const id = fd => { try { const { dev, ino } = fs.fstatSync(fd); return dev + ":" + ino; } catch { return null; } }; + const stdio = [id(1), id(2)], out = []; + for (let fd = 3; fd < 256; fd++) if (stdio.includes(id(fd))) out.push(fd); + return out; + }; + const worker = new Worker( + "const wt = require('worker_threads'); wt.parentPort.postMessage((" + aliasesOfStdio + ")().filter(fd => !wt.workerData.includes(fd)));", + { eval: true, workerData: aliasesOfStdio() }, + ); + worker.on("message", created => { console.log(JSON.stringify(created)); worker.terminate(); });`, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ fdsAliasingStdioCreatedByTheWorker: JSON.parse(stdout), stderr, exitCode }).toEqual({ + fdsAliasingStdioCreatedByTheWorker: [], + stderr: "", + exitCode: 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), From d963a65c8d68acd0d385b14e35c90bb1d988e57c Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 12:10:01 +0000 Subject: [PATCH 2/6] test: process.mainModule in a worker is the entry module once the bootstrap stops reifying it --- .../node/worker_threads/worker_threads.test.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/test/js/node/worker_threads/worker_threads.test.ts b/test/js/node/worker_threads/worker_threads.test.ts index 54c7db143c62..f15268747abf 100644 --- a/test/js/node/worker_threads/worker_threads.test.ts +++ b/test/js/node/worker_threads/worker_threads.test.ts @@ -2003,6 +2003,23 @@ test.skipIf(isWindows)( }, ); +// A user-visible consequence of the reify-all: process.mainModule is built from the require +// map when it is first read. Built during the bootstrap, before the entry module exists, it +// was permanently undefined in every worker; built on first read it is the entry module, as +// in node (and as it was before the bootstrap reified it). +test("process.mainModule in a worker is the worker's entry module, like node", async () => { + using dir = tempDir("worker-main-module", { + "entry.js": `require("worker_threads").parentPort.postMessage({ + mainModuleIsEntry: process.mainModule === module, + requireMainIsEntry: require.main === module, + });`, + }); + const worker = new Worker(join(String(dir), "entry.js")); + const [result] = await once(worker, "message"); + await worker.terminate(); + expect(result).toEqual({ mainModuleIsEntry: true, requireMainIsEntry: true }); +}); + // 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), From f134ea6207b234686c6ca31661654895b519a38b Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 12:16:02 +0000 Subject: [PATCH 3/6] worker_threads, process: shorten the rationale comments --- src/js/node/worker_threads.ts | 12 ++++-------- src/jsc/bindings/BunProcess.cpp | 7 ++----- 2 files changed, 6 insertions(+), 13 deletions(-) diff --git a/src/js/node/worker_threads.ts b/src/js/node/worker_threads.ts index 5971afc935e2..17ec176235c1 100644 --- a/src/js/node/worker_threads.ts +++ b/src/js/node/worker_threads.ts @@ -461,10 +461,8 @@ function setupWorkerStdio(stdio) { const stdoutStream = makePortWritable(stdout); const stderrStream = makePortWritable(stderr); const proc: any = process; - // Plain assignment, not Object.defineProperty: process.stdout/stderr/stdin are - // lazy native properties. Assigning replaces one unbuilt; defineProperty first - // builds the fd-backed stream it is about to discard (loading tty/net/fs to do - // so). Either way the result is a writable, enumerable, configurable own property. + // Assignment replaces the lazy native stdio properties without building them; + // Object.defineProperty would construct the fd-backed streams first. proc.stdout = stdoutStream; proc.stderr = stderrStream; // node always replaces a worker's process.stdin: port-backed when { stdin: true }, @@ -854,10 +852,8 @@ function fakeParentPort() { if (!isMainThread && _isNodeWorker) { applyWorkerProcessOverrides(); } -// Only ever assigns or defines individual properties: `delete process.x` makes JSC -// reify every lazy property of the process object (stdio streams, env, versions, -// ...). The main-only internals node deletes in workers (process._debugProcess & co.) -// are therefore never defined on a worker's process to begin with (BunProcess.cpp). +// Never `delete` from process here: that builds every lazy process property. The +// main-only internals node deletes in workers are not defined on worker VMs (BunProcess.cpp). function applyWorkerProcessOverrides() { const proc: any = process; // node defaults debugPort to 9229 in workers (still settable). Per-object property: diff --git a/src/jsc/bindings/BunProcess.cpp b/src/jsc/bindings/BunProcess.cpp index 8f890448d5c6..6f9855e70cb9 100644 --- a/src/jsc/bindings/BunProcess.cpp +++ b/src/jsc/bindings/BunProcess.cpp @@ -4967,11 +4967,8 @@ 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); - // Node's worker threads have no process._debugProcess & co. These are own - // properties rather than processObjectTable entries because deleting a - // static-table property makes JSC reify every lazy property in the table - // (the stdio streams, env, versions, config, ...): exactly the work a - // worker's startup is supposed to skip. + // Main-thread-only in node. Own properties, not processObjectTable entries: + // deleting a table entry in workers would reify every lazy property of process. if (!WebCore::clientData(vm)->isWorkerVM()) { auto* globalObject = this->globalObject(); for (auto name : { "_debugProcess"_s, "_debugEnd"_s, "_startProfilerIdleNotifier"_s, "_stopProfilerIdleNotifier"_s }) From 83166409fe1bf3c7ae8dc546c841823731af445b Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 12:21:18 +0000 Subject: [PATCH 4/6] worker_threads, process: one-line rationale comments --- src/js/node/worker_threads.ts | 6 ++---- src/jsc/bindings/BunProcess.cpp | 3 +-- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/src/js/node/worker_threads.ts b/src/js/node/worker_threads.ts index 17ec176235c1..c5e0b4fa445c 100644 --- a/src/js/node/worker_threads.ts +++ b/src/js/node/worker_threads.ts @@ -461,8 +461,7 @@ function setupWorkerStdio(stdio) { const stdoutStream = makePortWritable(stdout); const stderrStream = makePortWritable(stderr); const proc: any = process; - // Assignment replaces the lazy native stdio properties without building them; - // Object.defineProperty would construct the fd-backed streams first. + // Assignment, unlike Object.defineProperty, does not build the lazy fd-backed streams first. proc.stdout = stdoutStream; proc.stderr = stderrStream; // node always replaces a worker's process.stdin: port-backed when { stdin: true }, @@ -852,8 +851,7 @@ function fakeParentPort() { if (!isMainThread && _isNodeWorker) { applyWorkerProcessOverrides(); } -// Never `delete` from process here: that builds every lazy process property. The -// main-only internals node deletes in workers are not defined on worker VMs (BunProcess.cpp). +// Never `delete` from process here (it builds every lazy property); worker VMs lack the main-only internals. function applyWorkerProcessOverrides() { const proc: any = process; // node defaults debugPort to 9229 in workers (still settable). Per-object property: diff --git a/src/jsc/bindings/BunProcess.cpp b/src/jsc/bindings/BunProcess.cpp index 6f9855e70cb9..2fc97a15881f 100644 --- a/src/jsc/bindings/BunProcess.cpp +++ b/src/jsc/bindings/BunProcess.cpp @@ -4967,8 +4967,7 @@ 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 in node. Own properties, not processObjectTable entries: - // deleting a table entry in workers would reify every lazy property of process. + // Main-thread-only in node; not table entries because deleting one in a worker would reify every lazy property. if (!WebCore::clientData(vm)->isWorkerVM()) { auto* globalObject = this->globalObject(); for (auto name : { "_debugProcess"_s, "_debugEnd"_s, "_startProfilerIdleNotifier"_s, "_stopProfilerIdleNotifier"_s }) From 49c605739520dbbf11c2e3f757ca348506441718 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 12:51:44 +0000 Subject: [PATCH 5/6] Compare the fd test's stdout as text; update the callLazyProcessBuilder comment's reify-all example --- src/jsc/bindings/BunProcess.cpp | 3 +-- test/js/node/worker_threads/worker_threads.test.ts | 7 ++----- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/src/jsc/bindings/BunProcess.cpp b/src/jsc/bindings/BunProcess.cpp index 2fc97a15881f..9f30b26509f7 100644 --- a/src/jsc/bindings/BunProcess.cpp +++ b/src/jsc/bindings/BunProcess.cpp @@ -1801,8 +1801,7 @@ static JSValue constructLoadEnvFile(VM& vm, JSObject* processObject) } // Lazy PropertyCallback builders that enter JS. reifyAllStaticProperties wraps these in -// DeferTerminationForAWhile; a non-termination throw is cleared+reported so the worker's -// reifyAllStaticProperties (node:worker_threads preload) doesn't leave a pending exception. +// DeferTerminationForAWhile; a non-termination throw is cleared+reported so a reify-all (`delete process.x`, `Object.entries(process)`) doesn't leave a pending exception. static JSValue callLazyProcessBuilder(VM& vm, JSC::JSGlobalObject* globalObject, JSC::FunctionExecutable* (*generator)(VM&), const JSC::ArgList& args) { auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); diff --git a/test/js/node/worker_threads/worker_threads.test.ts b/test/js/node/worker_threads/worker_threads.test.ts index f15268747abf..1a5b211b41c3 100644 --- a/test/js/node/worker_threads/worker_threads.test.ts +++ b/test/js/node/worker_threads/worker_threads.test.ts @@ -1995,11 +1995,8 @@ test.skipIf(isWindows)( stderr: "pipe", }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect({ fdsAliasingStdioCreatedByTheWorker: JSON.parse(stdout), stderr, exitCode }).toEqual({ - fdsAliasingStdioCreatedByTheWorker: [], - stderr: "", - exitCode: 0, - }); + // stdout is the JSON list of fds the worker created that alias fd 1 or 2. + expect({ stdout: stdout.trim(), stderr, exitCode }).toEqual({ stdout: "[]", stderr: "", exitCode: 0 }); }, ); From e4ef2f7fa98a03155e75cd2f48e057f8df13fdf8 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 14:48:11 +0000 Subject: [PATCH 6/6] ci: retrigger