Skip to content

worker_threads: defer worker-side stdio/Console setup to first access - #34345

Open
robobun wants to merge 5 commits into
mainfrom
farm/876bb94d/worker-threads-lazy-stdio
Open

worker_threads: defer worker-side stdio/Console setup to first access#34345
robobun wants to merge 5 commits into
mainfrom
farm/876bb94d/worker-threads-lazy-stdio

Conversation

@robobun

@robobun robobun commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator

node:worker_threads spawn latency regressed ~3x after #31216 (captured stdio + process overrides). The report traced it to the unconditional per-worker stdio channels and the node:worker_threads preload; profiling shows the actual cost is two things that run in every worker's preload regardless of whether the worker ever touches stdio:

  1. Object.defineProperty(process, "stdout"/"stderr"/"stdin", ...) reifies the process static hash table, which invokes every PropertyCallback including constructStdout/Stderr/Stdin. Those cold-load node:stream just to build fd-backed streams the rebind immediately throws away.
  2. setupWorkerStdio then eagerly built two port-backed Writable streams and a fresh node:console Console instance.

The MessageChannel creation and port transfer themselves measure as effectively free.

Change

  • setupWorkerStdio now shadows the native PropertyCallback slots via plain [[Set]] first (which does not reify the table), then installs self-replacing lazy accessors for process.stdout/stderr/stdin and globalThis.console. The first access builds the real stream/Console and rewrites the slot to a plain writable data property, so later reads are direct and identity-stable.
  • Readable/Writable and the HeapSnapshotStream class are loaded on first use instead of at module top level; node:path is loaded inside validateWorkerFilename.
  • The lazy console captures the native console object up front and reads .Console off it, because require("node:console") is export default globalThis.console and would otherwise re-enter the getter.

worker.stdout/stderr remain Readables fed by the worker's process.stdout/stderr (including the default auto-pipe path); the channels are still always created.

Benchmark

30 sequential new Worker("require('worker_threads').parentPort.postMessage(1)", { eval: true }) round-trips, interleaved over 5 rounds on the same machine (local release build):

min (ms) p50 (ms)
bun 1.3.14 6.6-7.6 7.7-12.7
main 22.7-24.8 31.8-42.0
this PR 6.7-11.0 7.7-14.3

Verification

  • New test worker process.stdio and console are installed as lazy accessors asserts the properties are accessors before first read and self-replace to identity-stable value properties afterwards; fails on main with "value" in every slot.
  • test/js/node/worker_threads/ (107 tests) and test/js/web/workers/ pass.
  • test/js/node/test/parallel/test-worker-{stdio,stdio-flush,stdio-flush-inflight,no-stdin-stdout-interaction,safe-getters,console-listeners,message-port-drain,on-process-exit,unsupported-things}.js all pass.

[review] gate passed · iteration 2 · 2 files touched

fails on main (without fix)
ASAN without fix: 1 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/node/worker_threads/worker-stdio-lazy.test.ts
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (42403df70)

test/js/node/worker_threads/worker-stdio-lazy.test.ts:
30 |     });
31 |     `,
32 |     { eval: true },
33 |   );
34 |   const [msg] = await once(worker, "message");
35 |   expect(msg).toEqual({
                   ^
error: expect(received).toEqual(expected)

  {
    "afterStdout": "value",
    "before": {
-     "console": "accessor",
-     "stderr": "accessor",
-     "stdin": "accessor",
-     "stdout": "accessor",
+     "console": "value",
+     "stderr": "value",
+     "stdin": "value",
+     "stdout": "value",
    },
    "isWritable": true,
    "sameInstance": true,
  }

- Expected  - 4
+ Received  + 4

      at <anonymous> (/workspace/bun/test/js/node/worker_threads/worker-stdio-lazy.test.ts:35:1
... (truncated)

release without fix: all passed
bun test v1.4.0-canary.1 (c4be8150a)

test/js/node/worker_threads/worker-stdio-lazy.test.ts:
(pass) worker process.stdio and console are installed as lazy accessors [25.12ms]
(pass) require('node:console') in a worker still exposes Console and write [14.85ms]

 2 pass
 0 fail
 2 expect() calls
Ran 2 tests across 1 file. [224.00ms]
__F:0:S:0
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/node/worker_threads/worker-stdio-lazy.test.ts
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (42403df70)

test/js/node/worker_threads/worker-stdio-lazy.test.ts:
(pass) worker process.stdio and console are installed as lazy accessors [816.53ms]
(pass) require('node:console') in a worker still exposes Console and write [916.79ms]

 2 pass
 0 fail
 2 expect() calls
Ran 2 tests across 1 file. [3.84s]
__F:0:S:0

release with fix: all passed
$ bun scripts/build.ts --profile=release
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
[configured] bun-profile → bun (stripped) in 766ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/90] gen ErrorCode+*.h
[2/47] gen generated_host_exports.rs
generated_host_exports.rs: 91 exports (host=3, lazy=10, generic=78, rust=0); 243 extern-C blocks audited
[3/47] gen cpp.rs (cppbind)
[4/47] gen ZigGeneratedClasses.{cpp,h,rs}
Found 2 classes from /workspace/bun/src/jsc/resolve_message.classes.ts
  - ResolveMessage (13 fields)
  - BuildMessage (10 fields)
Found 1 classes from /workspace/bun/src/runtime/api/Archive.classes.ts
  - Archive (4 fields, 1 class fields)
Found 2 classes from /workspace/bun/src/runtime/api/BunObject.classes.ts
  - ResourceUsage (8 fields)
  - Subprocess (20 fields)
Found 1 classes from /workspace/bun/src/runtime/api/cron.classes.ts
  - CronJob (5 fields)
Found 3 classes from /workspace/bun/src/runtime/api/filesystem_ro
... (truncated)
diff hotspot
src/js/node/worker_threads.ts                      | 108 ++++++++++++---------
 .../node/worker_threads/worker-stdio-lazy.test.ts  |  63 ++++++++++++
 2 files changed, 126 insertions(+), 45 deletions(-)

gate history · 2 passed · 1 rejected · iteration 2

evidence per changed file
file                                                   reads  edits  tests
src/js/node/worker_threads.ts                              8     18      0
test/js/node/worker_threads/worker-stdio-lazy.test.ts      0      1      0

The worker preload rebinds process.stdout/stderr/stdin and console so
captured stdio reaches the parent. Doing that eagerly paid two costs on
every spawn regardless of whether the worker ever touched stdio:

- Object.defineProperty on process.stdout/stderr/stdin reifies the
  process static hash table, which invokes every PropertyCallback
  including constructStdout/Stderr/Stdin. Those cold-load node:stream
  just to build fd-backed streams the rebind immediately discards.
- setupWorkerStdio then built two port-backed Writable streams and a
  fresh node:console Console instance.

Install self-replacing lazy accessors instead, and shadow the stdio
PropertyCallback slots via plain [[Set]] first so the later
defineProperty does not reify them. Readable/Writable and the
HeapSnapshotStream class are loaded on first use.
@robobun

robobun commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator Author

Status: spawn latency restored to 1.3.14 parity (min 6.5-6.8ms vs 6.6-7.6ms for 1.3.14, down from 22.7-24.8ms on main). All worker stdio tests pass on a local release build.

Addressed review: require('node:console') is now primed before the lazy console accessor so a worker's require('console').Console keeps returning the constructor (covered in the new test file). Regression test moved to worker-stdio-lazy.test.ts so it runs cleanly under ASAN.

CI: diff is green. Build 73880 has 1 [pre-existing] + 7 [flaky] failures on unrelated lanes (net-memleak GC timing, install/registry/proxy flakes); none touch worker_threads. The fs.watchFile.test.ts terminate race from build 73853 did not recur on rerun (same bug class as #33418, timing-only, 160 local ASAN iterations pass). Ready for review.

@coderabbitai

coderabbitai Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 7 minutes

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 for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: b4836931-bc1a-4ebf-97c6-bb85453fee88

📥 Commits

Reviewing files that changed from the base of the PR and between 0ecd508 and 42403df.

📒 Files selected for processing (2)
  • src/js/node/worker_threads.ts
  • test/js/node/worker_threads/worker-stdio-lazy.test.ts

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

@robobun

robobun commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 7:06 AM PT - Jul 16th, 2026

@robobun, your commit 42403df has 2 failures in Build #73880 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 34345

That installs a local version of the PR into your bun-34345 executable, so you can run:

bun-34345 --bun

@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.

No bugs found, but this reworks code that runs unconditionally in every node:worker_threads worker's bootstrap and depends on a subtle JSC static-table detail (that plain [[Set]] shadows the PropertyCallback slots so the subsequent defineProperty doesn't fire constructStdout/Stderr/Stdin) — worth a human look.

What was reviewed:

  • defineLazy self-replacement: get/set both rewrite to a writable data property with matching enumerability; identity-stable on repeat reads.
  • proc.stdin = proc.stdout = proc.stderr = undefined unconditionally clears all three, but the Worker constructor always creates stdout/stderr channels so the if (stdout)/if (stderr) guards are never false in practice.
  • Lazy console captures the native console object before installing the accessor, so nativeConsole.Console cannot re-enter the getter; reading process.stdout/stderr inside it triggers their own lazy getters, not recursion.
  • Readable/Writable/node:path deferral and the HeapSnapshotStream factory are mechanical ??= conversions.
Extended reasoning...

Overview

The PR defers per-worker stdio/Console setup from eager to first-access to recover a ~3× spawn-latency regression. Changes are confined to src/js/node/worker_threads.ts (module-level requires made lazy, setupWorkerStdio rewritten around a new defineLazy helper, HeapSnapshotStream wrapped in a factory) plus one new test in test/js/node/worker_threads/worker_threads.test.ts.

Security risks

None identified. No new inputs are parsed and no trust boundaries change; the lazy accessors expose the same objects the eager path did, just later.

Level of scrutiny

High. setupWorkerStdio runs during preload in every node:worker_threads worker, so a mistake here breaks all worker stdio. The correctness of the fast path hinges on a JSC-internals claim: that assigning proc.stdin = proc.stdout = proc.stderr = undefined creates own properties shadowing the static-table PropertyCallback entries (verified they are PropertyCallback, not CustomAccessor, at src/jsc/bindings/BunProcess.cpp:4495-4497) without triggering table reification, so the subsequent Object.defineProperty calls in defineLazy don't invoke constructStdout/Stderr/Stdin. The benchmark and passing vendored tests support this empirically, but it's the kind of engine-coupling a maintainer should sign off on.

Other factors

  • The unconditional proc.stdin = proc.stdout = proc.stderr = undefined is a behavior change in the (currently unreachable) branch where a stdio port is absent — previously the native fd stream survived, now it would stay undefined. Since the Worker constructor always creates stdout/stderr channels this can't happen today, but it's worth a maintainer glance.
  • applyWorkerProcessOverrides also calls Object.defineProperty(process, ...) after setupWorkerStdio; by then the stdio slots are already own properties, so any reification it triggers won't re-fire the stdio callbacks.
  • The new test asserts the accessors are installed and self-replace, which guards the observable contract; it does not (and cannot easily) assert that reification was avoided, so the perf claim rests on the benchmark.
  • CI is still building; no prior human or bot review comments to address.

Pair each shadow assignment with the corresponding lazy getter so a
(currently unreachable) missing stdout/stderr port would leave the native
fd stream in place instead of undefined.
@robobun

robobun commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator Author

Addressed the review note in 7d727b7: the shadow assignments for process.stdout/stderr are now gated on the same port-present check as the getter installation, so an absent port (unreachable today since the Worker constructor always creates both channels) would leave the native fd stream intact rather than undefined.

On the [[Set]]-shadows-without-reifying claim: verified empirically that process.stdout = undefined followed by Object.defineProperty(process, "stdout", {get,set}) does not invoke constructStdout (timed at ~2ms in a fresh worker vs ~22ms for a direct defineProperty on the unshadowed slot). The delete in applyWorkerProcessOverrides does still reify the remaining table entries, but by that point the three stdio callbacks are already own properties and skip.

Comment thread src/js/node/worker_threads.ts
robobun and others added 2 commits July 16, 2026 09:43
…to own file

require('node:console') is export default globalThis.console; evaluating it
after the lazy console accessor is installed would cache a plain Console
instance that lacks .Console/.write/[Symbol.asyncIterator]. Capture via
require first so the module registry caches the native object.

Move the lazy-accessor regression test into its own file so the gate can run
it cleanly under ASAN (worker_threads.test.ts has subprocess-spawning cases
that exceed the 5s default under ASAN).

@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.

The node:console priming fix looks right — require('node:console') now runs before the lazy accessor is installed, so the module registry caches the native object and require('console').Console/.write/[Symbol.asyncIterator] keep working (covered by the new test). No further bugs found, but this leans on non-obvious JSC static-table semantics ([[Set]] shadowing vs. defineProperty reification, ordering vs. applyWorkerProcessOverrides' delete) and changes when the worker-side stdout/stderr port listeners attach, so it's worth a human pass.

Also checked: the parent-side auto-pipe path (this.stdout.on('data', …) in the constructor) still eagerly loads Readable via makePortReadable, and a worker that never touches stdio leaves its transferred ports listener-less (so they don't ref the loop) — matches the old net ref state. proc.stdin shadow + lazy accessor are both unconditional, matching the previous unconditional replace.

Extended reasoning...

Overview

Performance fix for a ~3x node:worker_threads spawn-latency regression. Touches src/js/node/worker_threads.ts only: converts the eager per-worker stdio/Console rebind in setupWorkerStdio into self-replacing lazy accessors, defers Readable/Writable/node:path/HeapSnapshotStream loads to first use, and shadows the native process.stdout/stderr/stdin PropertyCallback slots via plain [[Set]] before defineProperty so JSC doesn't reify the process static table and cold-load node:stream. Adds a two-case regression test.

Prior review

My earlier inline finding (require('node:console').Console becoming undefined in workers) was addressed in 1806acc by priming the module registry with require('node:console') before installing the lazy globalThis.console accessor — the second fix I'd suggested. The new test asserts .Console, .write, and [Symbol.asyncIterator] are still present on require('node:console') inside a worker. That thread is resolved.

Security risks

None. No auth/crypto/permissions surface; the change is timing of internal stream/Console construction inside a worker preload.

Level of scrutiny

High. This runs in every node:worker_threads worker's preload and its correctness rests on JSC implementation details: that proc.stdout = undefined creates an own data property without invoking constructStdout, that a subsequent defineProperty on that own slot doesn't reify the remaining table, and that applyWorkerProcessOverrides (which does reify via delete/defineProperty) runs after the stdio slots are already own. The author verified this empirically, but it's exactly the kind of engine-coupling a maintainer should sign off on. It also already produced one real regression during review.

Other factors

  • Deferring makePortWritable means the worker-side port.on('message', onAck) + port.unref() no longer run at preload; I traced the ref accounting and a never-touched port has no listener and so doesn't hold the loop — net-equivalent to the old attach-then-unref, but worth a second pair of eyes.
  • defineLazy's setter path preserves user assignment to process.stdout before first read.
  • Parent-side worker.stdout/.stderr getters and the auto-pipe in the constructor still materialize Readable on the parent immediately when auto-pipe is on (the default), so no behavior change there.
  • Evidence block shows the accessor test only fails on the ASAN/debug build without the fix and passes on release without the fix — expected, since the test asserts the shape of the optimization rather than the latency, but it means the test is a shape guard, not a regression guard for the perf claim itself.

@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.

The require('node:console') regression from my earlier review is fixed (1806acc primes the module registry before installing the accessor, and the new test covers .Console/.write/[Symbol.asyncIterator]). No further issues found, but this leans on JSC static-hash-table reification semantics and changes observable descriptor shapes on process/globalThis in every worker preload, so it's worth a human look.

Checked: defineLazy self-replacement preserves identity and writability; require('node:console') now runs before the accessor so the registry caches the native object; applyWorkerProcessOverrides's later defineProperty/delete reify only after stdio slots are already own properties; lazy Readable/Writable/HeapSnapshotStream sites all guard with ??=.

Extended reasoning...

Overview

Performance fix for a ~3x worker spawn-latency regression. setupWorkerStdio in src/js/node/worker_threads.ts now shadows the native process.std{in,out,err} PropertyCallback slots via plain [[Set]] (avoiding static-table reification), then installs self-replacing lazy accessors for the three stdio streams and globalThis.console. Readable/Writable, node:path, and the HeapSnapshotStream class are deferred to first use. A new test file asserts the accessor shape and that require('node:console') still exposes .Console/.write/[Symbol.asyncIterator] inside a worker.

Prior review resolution

My earlier inline finding — that require('node:console').Console became undefined in workers because the lazy getter was installed before the module registry was primed — has been addressed exactly as suggested: nativeConsole is now captured via require('node:console') before defineLazy, so the registry caches the native object (which carries .Console/.write) and later user requires return it. The second test in worker-stdio-lazy.test.ts locks this in. src/js/node/console.ts is still export default console;, so the priming call is a bare global read with no stream construction — the perf goal is preserved (benchmark confirms 1.3.14 parity).

Security risks

None. This is startup-ordering / lazy-initialization work in a built-in module; no auth, crypto, network, or untrusted-input parsing is touched.

Level of scrutiny

Medium-high. The change runs in every node:worker_threads worker's preload and depends on a JSC implementation detail: that plain [[Set]] on a PropertyCallback slot creates an own property without reifying the static table, whereas Object.defineProperty does reify. The author verified this empirically and reasoned through the interaction with applyWorkerProcessOverrides (its delete/defineProperty calls reify later, but stdio is already an own property by then so constructStdout/etc. skip). It also makes process.stdout/etc. observably start as accessor descriptors rather than data properties inside workers — a Node-compat surface change that a maintainer should sign off on.

Other factors

  • One real bug was caught and fixed during review, which argues for human eyes on the remaining subtleties.
  • Test coverage is good: the new file plus the listed test-worker-* parallel tests exercise the auto-pipe path, flush-on-exit, and safe-getters.
  • The one CI failure so far (fs.watchFile.test.ts sigabrt on x64-asan) is unrelated to this diff.
  • globalThis.console.Console remains undefined in workers after materialization, but that was already the case before this PR — not a regression here.

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

Cross-reference: #38987 takes the smaller route for the same startup cost (plain assignment for the stdio slots, and it also removes the deletes that still reify the whole process table here, by not defining those four internals on worker VMs in the first place), without the lazy accessors. #37128 covers the Console construction.

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

Two data points from measuring the 1.4 pre-tag multi-threaded memory regression, for whenever this gets rebased (#38987 now covers the stdio assignment and removes the delete that was reifying process):

  1. The console part of this PR is worth about 0.75 MB of RSS per worker on its own. Release builds of the same commit, steady-state RSS per idle worker (40 workers minus 8, divided by 32): 1.3.14 3.45 MB, main 4.77 MB, main with worker_threads: don't build the process object's lazy properties during worker startup #38987's changes 3.66 MB, main with worker_threads: don't build the process object's lazy properties during worker startup #38987's changes plus a console built on first use 2.91 MB. The JS heap of a bare worker after a full GC goes 1.01 MB -> 0.68 MB for that last step; the whole difference is node:util, which new Console() loads (the MIMEType class shows up in heapStats().objectTypeCounts exactly when it has been loaded).

  2. On require("node:console"): caching the native object so that a later require keeps returning it preserves what main does today, but main's behavior there is itself a regression from node:worker_threads: +48 Node.js tests passing — MessagePort, stdio, SHARE_ENV, exit codes, transfer semantics, postMessageToThread + inspector #31216. In a worker, node and Bun 1.3.14 both have typeof console.Console === "function", require("node:console") === console and console instanceof console.Console; on main console.Console is undefined in a worker and require("node:console") returns an object that is not the global console. Letting require("node:console") evaluate through the getter gets all three back, as long as the built instance is given the constructor the way the native global console has it (workerConsole.Console = nativeConsole.Console, an ordinary writable/enumerable/configurable property, which is also what node's global console has). Verified with node 26.3 and Bun 1.3.14 side by side; a worker that reads console first, then requires node:console, gets the same object either way.

One trap for the in-place variant, in case it helps: the property has to be redefined in place. Deleting globalThis.console (or any other static-table global) first makes JSC reify every lazy global of the worker's global object, about 2350 objects, which costs more than the Console it saves.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant