Skip to content

worker_threads: don't build fd stdio or reify all of process at worker startup - #39536

Merged
dylan-conway merged 7 commits into
mainfrom
claude/worker-threads-startup-fix
Aug 18, 2026
Merged

dylan-conway merged 7 commits into
mainfrom
claude/worker-threads-startup-fix

Conversation

@dylan-conway

Copy link
Copy Markdown
Member

What does this PR do?

Since #31216 every node:worker_threads Worker runs a bootstrap on the worker thread that rebinds process.stdout/stderr/stdin to port-backed streams. Two things in it were pure waste, paid per worker:

  • It used Object.defineProperty(process, "stdout", { value }), which first builds the real fd-backed stream (loads node:fs, internal/fs/streams, …) and then throws it away — for all three streams.
  • It deleted four main-thread-only process stubs. Deleting any static-table property reifies the whole table, which builds the nextTick queue and every other lazy process property.

Now 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 just not installed on a worker's process; and node:worker_threads is loaded by a native hook before preloads/entry instead of being injected as an ESM preload. Semantics are unchanged (stdio and console are still set up eagerly, as in Node).

new Worker() → first message, Linux x64, CI release artifacts on the same machine: main 15.9 ms → 10.2 ms (min), 18.4 → 11.8 ms (median).

Split out of #39528; process.moduleLoadList is now its own PR.

How did you verify your code works?

test/js/node/worker_threads/*.test.ts, test/js/web/workers/worker.test.ts and the node test-worker-*.js files match main on a release build; drove captured / auto-piped stdout (incl. process.exit() flush), stdin: true and parentPort echo on the debug build with BUN_JSC_validateExceptionChecks=1.

…r 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.
@coderabbitai

coderabbitai Bot commented Aug 18, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your current included review allowance is based on your included PR review attempts over the past 7 days.

Next review available in: 38 minutes

Limit details: You’ve used the included review currently available. Your 61 included PR review attempts over the past 7 days set your current allowance at 1 review per hour.

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits within each organization.

For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: abd90722-a2ae-4e35-83de-bcbda9280f17

📥 Commits

Reviewing files that changed from the base of the PR and between e0763a4 and e6dcc3f.

📒 Files selected for processing (9)
  • src/jsc/bindings/BunClientData.cpp
  • src/jsc/bindings/BunClientData.h
  • src/jsc/bindings/BunProcess.cpp
  • src/jsc/bindings/ZigGlobalObject.cpp
  • src/jsc/bindings/webcore/Worker.cpp
  • src/jsc/bindings/webcore/WorkerMessagingProxy.cpp
  • src/jsc/web_worker.rs
  • src/runtime/bake/BakeGlobalObject.cpp
  • src/runtime/jsc_hooks.rs

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 50d1d046-b06f-4e97-8403-e39b17d5427d

📥 Commits

Reviewing files that changed from the base of the PR and between 891c1c7 and e0763a4.

📒 Files selected for processing (10)
  • src/js/builtins/ProcessObjectInternals.ts
  • src/js/internal/worker/stdio.ts
  • src/js/node/worker_threads.ts
  • src/jsc/bindings/BunClientData.h
  • src/jsc/bindings/BunProcess.cpp
  • src/jsc/bindings/ZigGlobalObject.cpp
  • src/jsc/bindings/ZigGlobalObject.h
  • src/jsc/bindings/webcore/Worker.cpp
  • src/jsc/web_worker.rs
  • src/runtime/jsc_hooks.rs

Included review availability: 0 reviews are currently available. Your included PR review attempts over the past 7 days set your current allowance at 2 reviews per hour.


Walkthrough

Changes

The PR adds MessagePort-backed stdio streams for Node workers. It registers worker stdio ports through native bindings, constructs process streams from them, initializes node:worker_threads during worker bootstrap, and restricts profiler helpers in Node worker VMs.

Node worker stdio

Layer / File(s) Summary
MessagePort-backed stdio streams
src/js/internal/worker/stdio.ts, src/js/builtins/ProcessObjectInternals.ts
Readable and writable streams use worker ports for chunks, acknowledgements, EOF, cleanup, and synchronous flushing.
Worker VM port registration
src/jsc/bindings/BunClientData.h, src/jsc/bindings/ZigGlobalObject.*, src/jsc/bindings/webcore/Worker.cpp
Node worker VMs track their type and retain stdio ports through a new worker-thread binding.
Native process stdio construction
src/jsc/bindings/BunProcess.cpp, src/js/node/worker_threads.ts
Worker process streams use registered ports. The global console is rebound to these streams, and profiler helpers remain available only on non-Node workers.
Node worker bootstrap flow
src/jsc/web_worker.rs, src/jsc/bindings/webcore/Worker.cpp, src/js/node/worker_threads.ts, src/runtime/jsc_hooks.rs
Worker startup loads node:worker_threads before preloads and entry loading, and shuts down with exit handling when bootstrap fails.

Possibly related PRs

Suggested reviewers: robobun, cirospaciari

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main optimization: avoiding fd-backed stdio construction and full process reification during worker startup.
Description check ✅ Passed The description explains the changes, preserved behavior, performance impact, and verification steps required by the template.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Comment @coderabbitai help to get the list of available commands.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed this PR and didn't find any bugs. Because it restructures worker startup across Rust, C++, and built-in JS (bootstrap ordering, a new GC-visited WriteBarrier, and moving process static-table entries to putDirectNativeFunction), a human look would still be worthwhile.

Checked: m_nodeWorkerStdioPorts is in FOR_EACH_GLOBALOBJECT_GC_MEMBER so it's visited; the port.on → addEventListener move in internal/worker/stdio correctly unwraps event.data (and onAck ignores its arg); constructStdioWriteStream's early-return path yields the bare stream, which is what constructStdout/constructStderr pass through; Bun__NodeWorker__bootstrap's failure path mirrors the entry-load failure path (flush logs, entrySettled, shutdown).

Extended reasoning...

Overview

This PR optimizes node:worker_threads worker startup by (1) having process.std*'s existing lazy PropertyCallback getters build port-backed streams directly (via a new nodeWorkerStdioPorts slot on ZigGlobalObject and a new getNodeWorkerStdioStream builtin) instead of building fd-backed streams and overwriting them, (2) not installing four main-thread-only process stubs on worker VMs (moved from the static processObjectTable to putDirectNativeFunction in finishCreation, gated on a new isNodeWorkerVM flag on JSVMClientData) so the JS bootstrap no longer needs to delete them and reify the whole table, and (3) loading node:worker_threads from a native hook (Bun__NodeWorker__bootstrap, called from WebWorker::spin before preloads/entry) instead of injecting it as an ESM preload. The port-stream helpers move from worker_threads.ts to a new internal/worker/stdio.ts shared by both sides, switching from the fake-emitter .on/.off to raw addEventListener/removeEventListener.

Security risks

None identified. The change is internal to worker bootstrap and doesn't touch auth, crypto, permission checks, or untrusted-input parsing. jsFunctionSetNodeWorkerStdioPorts is only reachable via the internal $cpp binding.

Level of scrutiny

High. Worker startup is a critical path and the change spans Rust FFI, C++ JSC bindings (including a new GC-visited WriteBarrier<JSObject>), the process object's static hash table, and built-in JS. The bootstrap ordering change (native requireId before preloads vs. ESM preload injection) subtly shifts when worker_threads.ts module-load side effects (fake-emitter injection, applyWorkerProcessOverrides, parentPort registration) run relative to user preloads, and the processObjectTable edit changes property attributes for four stubs on the main thread. These are the kinds of things where a maintainer's intuition about downstream expectations is valuable.

Other factors

  • The new WriteBarrier is correctly declared in FOR_EACH_GLOBALOBJECT_GC_MEMBER, so visitChildrenImpl covers it.
  • WorkerMessagingProxy/WorkerOptions were already used in ZigGlobalObject.cpp, so the added static_cast compiles without new includes.
  • Process_stubEmptyFunction is still used by _linkedBinding/_tickCallback in the static table, so it's not orphaned.
  • The internal/worker/stdio move changes onMessage(payload) to onMessage(event) + event.data and .on/.off to addEventListener/removeEventListener; onAck doesn't read its argument so the ack path is unchanged; kFlushSync is now registered per-stream in getNodeWorkerStdioStream instead of one combined process.on('exit') — semantically equivalent.
  • The Readable import in worker_threads.ts is still used by HeapSnapshotStream.
  • The PR description says existing worker tests pass on release and were driven under BUN_JSC_validateExceptionChecks=1 on debug, but no new automated test is added in this PR.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed this PR and didn't find any bugs. Because it reworks worker thread startup across the Rust spin path, JSC bindings (a new WriteBarrier on ZigGlobalObject, the process static-table lut change), and the built-in JS bootstrap, a human look would still be worthwhile.

Checked: m_nodeWorkerStdioPorts is added to FOR_EACH_GLOBALOBJECT_GC_MEMBER so it's visited; the .on→addEventListener switch in internal/worker/stdio.ts correctly reads event.data and doesn't depend on injectFakeEmitter having run; the four stubs moved from the lut to putDirectNativeFunction use plain attributes (0) as noted in the follow-up commit; Bun__NodeWorker__bootstrap's failure path in spin() mirrors the existing entry-load failure path (exit_code, flush_logs, entrySettled, shutdown); the static_cast<WorkerMessagingProxy*>(worker_ptr) matches the existing cast at line 635 of the same function.

Extended reasoning...

Overview

This PR optimizes node:worker_threads worker startup by (1) having the native lazy process.stdout/stderr/stdin getters build port-backed streams directly instead of Object.defineProperty-ing over already-materialized fd streams, (2) not installing four main-thread-only stub functions on a worker's process (via a new isNodeWorkerVM() flag) instead of delete-ing them (which reified the whole static table), and (3) loading node:worker_threads via a native hook (Bun__NodeWorker__bootstrap) before preloads/entry instead of injecting it as an ESM preload. Port-stream helpers move to a new internal/worker/stdio.ts shared by both parent and worker sides. Touches 10 files across Rust (web_worker.rs, jsc_hooks.rs), C++ bindings (BunProcess.cpp, ZigGlobalObject.{h,cpp}, Worker.cpp, BunClientData.h), and built-in JS.

Security risks

None identified. No untrusted-input parsing, auth, or crypto changes. The new jsFunctionSetNodeWorkerStdioPorts is only reachable via the internal binding array.

Level of scrutiny

High. Worker thread startup is a critical path with a history of subtle bugs around termination races, exception-scope validation, and GC rooting. This change threads through the Rust spin() sequence (adding a new pre-preload hook with its own failure path), adds a WriteBarrier to ZigGlobalObject, moves properties out of a JSC static-table lut into putDirectNativeFunction calls, and changes how the process object's stdio getters branch. While each piece looks correct and follows existing patterns closely, the surface area and the interaction between the pieces (e.g., setupWorkerStdio now depends on _setStdioPorts having run before process.stdout is first read; the addEventListener switch means internal/worker/stdio no longer depends on injectFakeEmitter) warrant a maintainer's eye.

Other factors

The PR description says existing worker_threads / web-worker test suites and Node's test-worker-*.js files pass on release, and captured/auto-piped stdio + stdin: true were driven under BUN_JSC_validateExceptionChecks=1 on debug — good coverage for a refactor that claims semantics are unchanged. The follow-up commit fixing the PropertyAttribute::Function → plain-attribute issue for the moved stubs shows the author already caught one subtlety here. No prior human review comments to address. The bug hunting system found nothing; my own read confirmed GC rooting, exception handling in constructNodeWorkerStdioStream/Bun__NodeWorker__bootstrap, and the failure-path mirroring in spin() all look right — but this is exactly the kind of change a maintainer familiar with the worker lifecycle should sign off on.

- JSVMClientData::create takes the WorkerMessagingProxy and derives both
  isWorkerVM and isNodeWorkerVM itself.
- The worker thread knows whether it is a node:worker_threads worker (passed
  through WebWorker__create) and, if so, loads node:worker_threads through a
  plain hook that throws like require(); the caller takes and reports the
  exception instead of the hook returning a status.
- The four main-thread-only process stubs are installed with straight-line code.
BUN_DEBUG_TEST_NODE_WORKER_BOOTSTRAP_THROWS (debug-assertion builds only, like
BUN_DEBUG_TEST_WORKER_TEARDOWN_GATE) makes the bootstrap throw instead of loading
node:worker_threads; the test checks the Worker gets 'error' then exit code 1, the
entry never runs, and the parent exits normally.
@robobun

robobun commented Aug 18, 2026 •

Copy link
Copy Markdown
Collaborator
Updated 3:28 PM PT - Aug 18th, 2026

@dylan-conway, your commit e6dcc3f is building: #100791

@dylan-conway
dylan-conway merged commit 23b18dd into main Aug 18, 2026
9 of 10 checks passed
@dylan-conway
dylan-conway deleted the claude/worker-threads-startup-fix branch August 18, 2026 22:40
Comment thread src/jsc/web_worker.rs
Comment on lines +852 to +867
// Node runs its worker bootstrap (parentPort, stdio, process overrides)
// ahead of user code; ours is node:worker_threads' module body.
if self.is_node_worker {
let global = vm.global();
if let Err(err) = jsc::host_fn::from_js_host_call_generic(global, || {
Bun__Worker__loadNodeWorkerThreadsModule(global)
}) {
let exception = global.take_exception(err);
let _ = vm.as_mut().uncaught_exception(global, exception, false);
if !self.exit_called.load(Ordering::Relaxed) {
vm.as_mut().exit_handler.exit_code = 1;
}
self.flush_logs(vm);
WebWorker__entrySettled(global);
return self.shutdown();
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The new node-worker bootstrap-failure branch (is_node_worker → Bun__Worker__loadNodeWorkerThreadsModule throws → uncaught_exception → exit_code=1 → entrySettled → shutdown) ships without a test — c1abc1a added one for exactly this path and e756c97 reverted it 3 minutes later with no stated reason. Could you either restore the test or note in the PR why it was pulled? If it was flaky (or dropped because the debug env-flag felt like production-code-for-a-test), that's worth stating; there's precedent for the pattern (BUN_DEBUG_TEST_WORKER_TEARDOWN_GATE), and REVIEW.md wants every behavioral change covered in the same PR.

Extended reasoning...

What changed

Before this PR, a node:worker_threads Worker's bootstrap ran by injecting "node:worker_threads" at the front of options.preload on the parent side (removed in this diff at worker_threads.ts). Preloads are loaded inside load_entry_point_for_web_worker, so a throw during that load fell out of the existing Err(_) arm at web_worker.rs:874-883 — a path that's already exercised by the entry-point-throws tests.

This PR replaces that with a native call in spin():

if self.is_node_worker {
    let global = vm.global();
    if let Err(err) = jsc::host_fn::from_js_host_call_generic(global, || {
        Bun__Worker__loadNodeWorkerThreadsModule(global)
    }) {
        let exception = global.take_exception(err);
        let _ = vm.as_mut().uncaught_exception(global, exception, false);
        if !self.exit_called.load(Ordering::Relaxed) {
            vm.as_mut().exit_handler.exit_code = 1;
        }
        self.flush_logs(vm);
        WebWorker__entrySettled(global);
        return self.shutdown();
    }
}

That Err block is genuinely new control flow: it runs before load_entry_point_for_web_worker, so nothing in the existing suite reaches it.

The test that was added, then removed

git log on this branch shows:

  • c1abc1a ("Test the node worker bootstrap failure path") added BUN_DEBUG_TEST_NODE_WORKER_BOOTSTRAP_THROWS (debug-assertion builds only, mirroring the existing BUN_DEBUG_TEST_WORKER_TEARDOWN_GATE pattern) plus a test in test/js/node/worker_threads/worker_threads.test.ts asserting the parent Worker gets ['error', <msg>] then ['exit', 1], no 'online'/'message', and the parent process exits 0.
  • e756c97, 3 minutes later, reverts it with no message beyond "Revert …".

The final changed-files list contains zero test files.

Why this is worth flagging

REVIEW.md, Tests reviewers reject: "Every behavioral change ships an automated test in the same PR. 'Verified manually', unnamed 'existing tests', and benchmarks don't count, even for one-liners." The PR description cites exactly those two things (existing test/js/node/worker_threads/*.test.ts + "drove … on the debug build").

Nothing now covers that (a) the parent Worker receives an 'error' event carrying the bootstrap failure, (b) the exit code is 1 (not 0), (c) WebWorker__entrySettled fires so a parent's postMessageToThread waiting on this thread doesn't hang, and (d) 'online'/entry evaluation never happen. If the test was reverted because it flaked, that itself hints the path may not behave deterministically and is worth knowing.

Step-by-step: why no existing test reaches this branch

  1. Bun__Worker__loadNodeWorkerThreadsModule calls internalModuleRegistry()->requireId(…, NodeWorkerThreads) — it loads Bun's own built-in src/js/node/worker_threads.ts.
  2. That module body only throws on an internal Bun bug or a workerData deserialize failure inside createNodeWorkerThreadsBinding — neither is exercised by any test in test/js/node/worker_threads/ or the Node parallel test-worker-*.js set, which all pass valid workerData.
  3. The pre-existing "entry throws" / "preload throws" tests now go through load_entry_point_for_web_worker's Err arm (unchanged), after this new block has already succeeded.
  4. Therefore the only way to hit lines 856-865 in CI is to force the throw, which is precisely what the reverted BUN_DEBUG_TEST_NODE_WORKER_BOOTSTRAP_THROWS flag did.

Possible reason for the revert, and why it's still worth asking

REVIEW.md also says "Never add production code solely to make a test writable", and the reverted test required a cfg!(debug_assertions)-gated env flag in web_worker.rs. If that's why it was pulled, it's worth saying so in the PR — but note there's already in-tree precedent for exactly this pattern (BUN_DEBUG_TEST_WORKER_TEARDOWN_GATE, referenced two lines above the new block via arm_test_gate), and the flag compiles out of release builds. Alternatively, a user-reachable trigger (e.g. workerData that fails structured-clone deserialize on the worker side) would exercise the branch without a debug flag.

How to fix

Either restore c1abc1a (the debug-flag approach matches the neighboring teardown-gate test), or add a note to the PR description explaining why the test was dropped so the reviewer can weigh it. Not blocking — this is defensive handling whose observable behavior is designed to match the pre-existing preload-failure path, and merging without the test causes no concrete runtime failure — but the unexplained revert is a review question worth answering.

robobun added a commit that referenced this pull request Aug 21, 2026
constructNodeWorkerStdioStream (from #39536) builds process.stdout,
stderr and stdin of a node:worker_threads worker and had the same
clear, report and store undefined shape as the builders converted here.
robobun added a commit that referenced this pull request Aug 24, 2026
The worker test was vacuous on current main: #39536 removed the
bootstrap read of Bun.main, so a worker with an empty body never
populates main_resolved_path. The bun:test modifier test measured a
path that creates no WTF string since bind() takes a &'static str.

The SocketAddress test now goes through the shared RSS helper and
branches its bound on debug/ASAN builds. Fewer iterations with a
larger string keep the leaked signal at about 50 MiB per path while
the test runs in about 2.5 s under a debug ASAN build.
robobun added a commit that referenced this pull request Aug 25, 2026
constructNodeWorkerStdioStream (from #39536) builds process.stdout,
stderr and stdin of a node:worker_threads worker and had the same
clear, report and store undefined shape as the builders converted here.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants