Skip to content

worker_threads: don't build the process object's lazy properties during worker startup - #38987

Open
robobun wants to merge 6 commits into
mainfrom
farm/34052bd6/worker-process-lazy-statics
Open

worker_threads: don't build the process object's lazy properties during worker startup#38987
robobun wants to merge 6 commits into
mainfrom
farm/34052bd6/worker-process-lazy-statics

Conversation

@robobun

@robobun robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • Every node:worker_threads Worker spends a large part of its startup building process properties that are thrown away immediately. Two lines of the worker-side bootstrap in src/js/node/worker_threads.ts were responsible:
    • setupWorkerStdio replaced process.stdout / stderr / stdin with Object.defineProperty. [[DefineOwnProperty]] reads the current property first, and these are lazy native properties, so the fd-backed streams were built just to be replaced: this loads node:tty, node:net, node:fs and the stream internals into the worker, dups fd 1 and fd 2 when stdio is piped (the dups stay open until the orphaned streams are collected), and on a tty leaves two SIGWINCH listeners on the worker's process.
    • applyWorkerProcessOverrides ran delete process._debugProcess (plus three more). Deleting a static-table property makes JSC reify the whole table (JSObject::deleteProperty -> reifyAllStaticProperties): every remaining lazy process property (env, versions, config, release, report, allowedNodeEnvironmentFlags, ...) is built, and process is switched to dictionary mode for the rest of the worker's life.
    • Building everything at that point also has a visible result: process.mainModule is built from the require map, and during the bootstrap the entry module is not in it yet, so it has been undefined in every worker since node:worker_threads: +48 Node.js tests passing — MessagePort, stdio, SHARE_ENV, exit codes, transfer semantics, postMessageToThread + inspector #31216 (node, and Bun 1.3: the worker's entry module).
  • Cost, for a worker whose entry just posts one message (median of 21, local release build): 31-36 ms per worker. Debug build: ~2.2 s wall / 2.55 s CPU per worker, of which the stdio construction alone is ~1 s (measured by instrumenting the built worker_threads.js). This is where most of the time of the worker-spawning tests on the debug/ASAN lanes goes.

Fix

  • setupWorkerStdio assigns the streams. JSObject::put on an unreified lazy entry stores the new value directly without building the old one; the own property it produces is { writable, enumerable, configurable }, the same shape defineProperty produced (asserted by the test).
  • _debugProcess, _debugEnd, _startProfilerIdleNotifier, _stopProfilerIdleNotifier leave processObjectTable; Process::finishCreation defines them as own properties unless clientData(vm)->isWorkerVM(), with the same function, name, length and attributes the table gave them. A worker's process never has them, so the bootstrap has nothing to delete. (Web Workers lose the four no-op stubs too; node only has them on the main thread.)
  • Why this is the right fix: the observable results are node's (a worker's process has no _debugProcess & co., its stdio properties are the port-backed streams, process.mainModule is the entry module), the only work removed is the construction of objects nothing could reach afterwards, and process stays a lazy, non-dictionary object in workers like it is on the main thread.
  • Effect: release 31-36 ms -> 20-25 ms wall per worker (41-43 -> 27-33 ms CPU; 3 interleaved rounds of 21 workers, before/after binaries). Debug build: 2.1-2.3 s -> 1.5-1.6 s per worker; test/js/node/worker_threads/worker_threads.test.ts (133 tests) went from 439 s to 306 s on the same machine.
  • Tests, in test/js/node/worker_threads/worker_threads.test.ts, each failing on main as noted:
    • inside a worker, hasNonReifiedStatic(process) (the invariant test/js/bun/util/BunObject.test.ts keeps for Bun) is true, the stdio descriptors have the previous shape, the four internals are absent, and they are still functions on the main thread (main: lazy: false);
    • POSIX: a worker started in a child process with piped stdio creates no fd aliasing fd 1 or 2 (main: [9, 10]). This is the part the first test cannot see, since building a single property does not reify the table;
    • process.mainModule in a file worker is the entry module (main: undefined).
  • Also run: the whole worker_threads.test.ts and process/process.test.js on the debug and release builds, test/js/web/workers (release: all pass; debug: the same load-dependent timeouts as an unmodified build), the vendored test-worker-*.js node tests (110 files) on the debug build, test-worker-unsupported-things.js (the four internals are absent in a worker), and a worker writing to stdout/stderr under a pty.

Related PRs

Background

  • process is a JSC object whose properties come from a generated static hash table (processObjectTable in BunProcess.cpp). A PropertyCallback entry is built the first time it is read ("reified") and then stored as an ordinary own property, so an untouched property costs nothing. A few operations cannot be answered from the table, and JSC handles them by reifying every entry and converting the object to dictionary mode: delete of a table entry is one of them (Object.entries and spread are others). hasNonReifiedStatic from bun:internal-for-testing reports whether an object's table is still unreified.
  • Assignment versus defineProperty on such an entry: [[Set]] replaces the entry with the new value directly; [[DefineOwnProperty]] first asks for the current descriptor, which builds the entry.
  • JSVMClientData::isWorkerVM() is set when a global is created for a Worker thread of either kind; it is false for the main thread's VM.

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

coderabbitai Bot commented Aug 15, 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: f46b9bb3-16a3-4136-9278-89fa84ebd041

📥 Commits

Reviewing files that changed from the base of the PR and between 732491c and e4ef2f7.

📒 Files selected for processing (3)
  • src/js/node/worker_threads.ts
  • src/jsc/bindings/BunProcess.cpp
  • test/js/node/worker_threads/worker_threads.test.ts

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

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

Status: fix complete, waiting for CI on e4ef2f7 (an empty re-run commit: the code is unchanged since 49c6057, and the fix itself since 6240baa). The 49c6057 run had no test failures: 177 jobs passed and the darwin 14 aarch64 lane expired waiting for an agent, hence the re-run.

Reproduced by timing new Worker(src, { eval: true }) until the worker's first message, and by instrumenting the built worker_threads.js with performance.now() marks on a debug build: of ~2.2 s per worker, ~1 s was spent in the three Object.defineProperty(process, ...) calls, and hasNonReifiedStatic(process) was false inside every node worker (true in web workers and on the main thread) because of the deletes in applyWorkerProcessOverrides. With piped stdio the discarded streams also left two dups of fd 1/2 open in each worker, on a tty two SIGWINCH listeners, and the early reification is why process.mainModule has been undefined in workers (node and Bun 1.3: the entry module).

The three new tests fail on main (lazy: false; stdout [9,10] for the fd aliases; mainModuleIsEntry: false) and pass with the fix, on the debug and release builds. Measured per-worker startup: release 31-36 ms -> 20-25 ms, debug 2.1-2.3 s -> 1.5-1.6 s; worker_threads.test.ts on the debug build 439 s -> 306 s.

Earlier CI runs were green apart from one unrelated red lane each (6240baa: alpine x64 missing docker in test/js/sql/sql.test.ts; 83166409: an ASAN use-after-free in test/js/bun/spawn/spawn-stdin-readable-stream.test.ts), both reported separately.

@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 moves four entries out of processObjectTable and changes Process::finishCreation — which also drops the _debugProcess & co. stubs from Web Workers, not just node workers — and overlaps with #37128 / #37336, a human sign-off would still be worthwhile.

What was reviewed

  • setupWorkerStdio: assignment vs defineProperty produces the same {writable, enumerable, configurable} own property — asserted by the new test.
  • applyWorkerProcessOverrides: the remaining Object.defineProperty calls (debugPort, abort, chdir, …) don't reify the table — the new hasNonReifiedStatic(process) assertion runs after them.
  • Main-thread coverage: process.test.js still exercises the four stubs (line 1275); worker absence is covered by test-worker-unsupported-things.js.
  • isWorkerVM() is set from worker_ptr != nullptr in ZigGlobalObject.cpp:518, so both node and Web workers are gated.
Extended reasoning...

Overview

Three files: src/js/node/worker_threads.ts (switch setupWorkerStdio from Object.defineProperty to plain assignment; drop the delete process._debugProcess loop from applyWorkerProcessOverrides), src/jsc/bindings/BunProcess.cpp (remove four Function entries from the @begin processObjectTable lut source and install them via putDirectNativeFunction in Process::finishCreation when !clientData(vm)->isWorkerVM()), and two new tests in worker_threads.test.ts (a hasNonReifiedStatic(process) invariant test mirroring the one for the Bun object, plus a POSIX fd-aliasing test that observes the stdio streams not being built).

Security risks

None. No untrusted input, no auth/crypto/permissions surface. The change removes work from a bootstrap path and relocates four no-op stub functions from a static table to a conditional putDirect.

Level of scrutiny

Moderate-to-high. The JS-side change is straightforward and the descriptor-shape equivalence is asserted by the test. The C++ side edits the generated static hash table for process and finishCreation, and intentionally changes behavior for all worker VMs (Web Worker included), not just node workers — the PR description flags this, but it's a Bun-native observable change made for node-compat convenience that a maintainer should sign off on. The PR also overlaps with three other in-flight PRs (#37128 rewrites setupWorkerStdio, #37336 gives _debugProcess a real implementation on top of this same finishCreation site), which is a coordination point a human should weigh.

Other factors

  • Existing tests cover both directions: process.test.js asserts the four stubs are callable and return undefined on the main thread; the vendored test-worker-unsupported-things.js asserts they are absent in a node worker.
  • The new hasNonReifiedStatic test runs after applyWorkerProcessOverrides (which still uses Object.defineProperty for debugPort/abort/chdir/…), so it also guards against those calls reifying the table.
  • .lut.h files are not checked in; the table change is picked up by the build's codegen.
  • putDirectNativeFunction(..., 0, Process_stubEmptyFunction, ..., 0) matches the previous Function 0 table entries' arity and uses attributes 0; the PR states the attributes match and process.test.js's process[stub]() check would still pass.

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

Re the review's two points: both are intentional. Dropping the four stubs from web Workers as well follows from gating on the VM (node itself only has them on the main thread, and nothing in-tree references them outside the main-thread process tests); gating on node workers only would need the Worker kind at process creation for no practical gain. The overlap with #37128 and #37336 is described under Related PRs in the description: #37128's rewrite of setupWorkerStdio makes the same assignment change, and #37336 would install its real _debugProcess from the same finishCreation branch.

Comment thread src/js/node/worker_threads.ts Outdated
Comment thread src/js/node/worker_threads.ts Outdated
Comment thread src/jsc/bindings/BunProcess.cpp Outdated
Comment thread src/js/node/worker_threads.ts Outdated
Comment thread src/js/node/worker_threads.ts Outdated
Comment thread src/jsc/bindings/BunProcess.cpp Outdated
Comment thread test/js/node/worker_threads/worker_threads.test.ts Outdated
Comment thread src/jsc/bindings/BunProcess.cpp Outdated

@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 re-reviewed after 49c6057 — both earlier inline notes (the fd test's JSON.parse(stdout) and the stale callLazyProcessBuilder comment) are addressed, and this pass found nothing new. Given that this moves four entries out of processObjectTable (regenerating Process.lut.h) and drops them from web Worker VMs as well, plus the stated overlap with #37128 / #37336, a maintainer sign-off on the isWorkerVM() gating and the table change is still worth having.

What was reviewed:

  • [[Set]] vs [[DefineOwnProperty]] on the PropertyCallback stdio entries — the new test asserts the resulting descriptor shape and hasNonReifiedStatic(process), and the fd-alias test proves the old streams aren't built.
  • The remaining Object.defineProperty(proc, "debugPort", …) in applyWorkerProcessOverrides is a CustomAccessor, so assignment there would hit the shared setter — correctly left as-is.
  • Process_stubEmptyFunction is still live via _linkedBinding / _tickCallback; no dead code introduced.
Extended reasoning...

Overview

Three files: src/js/node/worker_threads.ts switches the worker bootstrap's stdio replacement from Object.defineProperty to plain assignment and removes the delete process._debugProcess loop; src/jsc/bindings/BunProcess.cpp moves the four main-thread-only stub functions out of processObjectTable into a !isWorkerVM() branch of Process::finishCreation and updates the callLazyProcessBuilder comment; test/js/node/worker_threads/worker_threads.test.ts adds three tests (lazy-table invariant + descriptor shape, fd-alias check under piped stdio, process.mainModule in a file worker).

Security risks

None identified. No untrusted input parsing, no auth/crypto/permission surface. The change removes work from a bootstrap path and relocates four no-op stub functions; the fd-alias test reads /proc-equivalent fstat info in-process only.

Level of scrutiny

Medium-high. The process object's static hash table is load-bearing for every JS execution context, and editing the @begin processObjectTable block regenerates Process.lut.h. The mechanism (JSC's PropertyCallback reification under [[Set]] vs [[DefineOwnProperty]], and deletePropertyreifyAllStaticProperties) is subtle, though the PR's tests pin it empirically. The isWorkerVM() gate also changes web Worker behavior (the four stubs disappear there too), which the author defended as intentional but is a design call.

Other factors

  • Both inline findings from my earlier pass were fixed in 49c6057 (the fd test now compares stdout.trim() as text; the callLazyProcessBuilder comment names live reify-all triggers). The comment-cop bot's paragraph-length flags were addressed in f134ea6/83166409.
  • Three related open PRs (#37128, #34345, #37336) overlap this code; whichever lands first determines where _debugProcess's eventual real implementation goes. A maintainer should be aware of the merge order.
  • Test coverage is good: each of the three new tests is documented as failing on main with the specific pre-fix output, and the invariant test mirrors the existing BunObject.test.ts pattern.
  • I confirmed Process_stubEmptyFunction remains referenced (_linkedBinding, _tickCallback) so removing the four table entries does not orphan it, and that the remaining Object.defineProperty on debugPort targets a CustomAccessor (where assignment would call the shared setter), so it is correctly not converted.

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

Both earlier inline nits (the JSON.parse(stdout) diagnostic and the stale callLazyProcessBuilder comment) are addressed in 49c6057, and this pass found nothing new. Deferring to a human because the change edits processObjectTable and Process::finishCreation in native code, drops the four stubs from web Workers as well (intentional per the author, but a judgment call), and overlaps with #37128 / #37336 for merge ordering.

Checked: assignment vs. defineProperty on a lazy PropertyCallback slot — [[Set]] stores directly without building, so setupWorkerStdio no longer constructs the fd-backed streams; isWorkerVM() gating covers both worker kinds and is set from worker_ptr != nullptr in ZigGlobalObject.cpp; the main-thread process._debugProcess etc. remain callable (process.test.js:1275) and the vendored test-worker-unsupported-things.js still asserts they are absent in a worker; the fd-alias test now compares stdout as text so a crashed child surfaces stderr.

Extended reasoning...

Overview

The PR removes two sources of eager process property construction in the node:worker_threads bootstrap: (1) setupWorkerStdio now assigns process.stdout/stderr/stdin instead of using Object.defineProperty (which triggered the lazy fd-backed stream builders it was replacing), and (2) the four main-thread-only stubs (_debugProcess, _debugEnd, _startProfilerIdleNotifier, _stopProfilerIdleNotifier) move out of processObjectTable into a !isWorkerVM() branch of Process::finishCreation, so worker VMs never have them and the bootstrap's delete loop (which forced reifyAllStaticProperties) is removed. Three new tests cover the invariants: hasNonReifiedStatic(process) stays true in a worker, no fd aliases of 1/2 appear when stdio is piped, and process.mainModule in a file worker is the entry module (a user-visible regression fix).

Security risks

None identified. The change is startup-time property installation on process; no user input, no parsing, no privilege boundary. The stubs being moved are no-op functions.

Level of scrutiny

Medium-high. BunProcess.cpp's static hash table and finishCreation govern the shape of the global process object across every VM, and the isWorkerVM() gate applies to web Workers too, not just node workers — the author acknowledges this is intentional and matches Node (which only defines these on the main thread), but it is a small observable difference for web-Worker code that previously saw the stubs. The generated Process.lut.h changes when the table does. The overlap with #37128 (rewrites setupWorkerStdio with the same assignment change) and #37336 (real _debugProcess implementation, which would now be installed from finishCreation) means a maintainer should decide merge order.

Other factors

My two prior inline comments are resolved: the fd-alias test now asserts { stdout: stdout.trim(), stderr, exitCode } against { stdout: "[]", stderr: "", exitCode: 0 } so a crashed child surfaces its stderr in the diff, and the callLazyProcessBuilder comment names live reify-all triggers (delete process.x, Object.entries(process)) instead of the removed preload. I verified the remaining callers of the four names — process.test.js calls them on the main thread (still functions via putDirectNativeFunction), test-worker-unsupported-things.js asserts absence in a worker (now true natively), and the inspector-helper.js/test-debug-process.js uses run on the main thread. The Object.defineProperty(proc, "debugPort", ...) that remains in applyWorkerProcessOverrides targets a CustomAccessor table entry, not a PropertyCallback, so it does not build anything expensive (and the new test's lazy: true would fail if it reified the table). Given the native process table edit and the cross-PR coordination, this warrants a human look rather than auto-approval.

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 12:05 PM PT - Aug 15th, 2026

@robobun, your commit e4ef2f7 has some failures in Build #98203 (All Failures)


🧪   To try this PR locally:

bunx bun-pr 38987

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

bun-38987 --bun

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

Independently hit the same two lines while chasing the 1.4 pre-tag memory regression on multi-threaded workloads (peak RSS of Vite / rolldown style builds 1.4x to 1.9x higher than 1.3.14 while single-threaded runs are flat), so here are the memory numbers for this change, since the description only quantifies time. Same fix on my side (assignment in setupWorkerStdio, the four stubs out of processObjectTable and into finishCreation), so I am not opening a separate PR.

Where the per-worker regression comes from (bare node:worker_threads worker, heapStats() inside the worker after Bun.gc(true), release builds):

JS heap objects notes
1.3.14 0.55 MB 6331
main 1.50 MB 12899 NodeJSFS/Stats/Dirent/FileSink present: the fd-backed stdio got built; MIMEType present: node:util got loaded
main + this change 1.01 MB 9255 fs/stdio gone; what is left is new Console() loading node:util (~380 KB, ~9 ms per worker when measured on the main thread)
main + this change + a console built on first use 0.68 MB 6081 that part is what #34345 covers

Measured on the main thread for reference: reading process.stdout once costs +6363 objects / +801 KB / ~22 ms; delete process._debugProcess on an otherwise untouched process costs +7292 objects / +949 KB / ~24 ms (+460 objects / +44 KB once stdio is already shadowed, i.e. the part the table change here removes on its own).

Steady-state RSS per idle worker (40 workers minus 8 workers, all alive and parked in Atomics.wait, divided by 32; two passes each, release builds of the same commit with and without the patch):

RSS per worker 32 idle workers, peak 32 idle workers, wall
1.3.14 3.45 MB ~117 MB 0.08 s
main 4.77 MB ~180 MB 0.15 s
main + stdio assignment + stubs out of the table (this PR's scope) 3.66 MB ~135 MB 0.12 s
same + console built on first use 2.91 MB ~120 MB 0.10 s

So this recovers roughly 1.1 MB of the 1.3 MB per worker that every worker picked up since #31216 (the RSS deltas are larger than the JS heap deltas presumably because of what module loading allocates outside the JS heap: bytecode, source strings, and so on), and the lazy console accounts for the rest and then some.

For the record, the remaining gap on workers that allocate heavily is not in the bootstrap: on a 32-worker run that builds 200k objects per worker, 1.3.14 sits at ~12.1 MB/worker and main with both changes at ~14.4 MB/worker. About 1.2 MB of that is garbage (the array's previous backing stores) that happens to survive until the end of the loop on main but not on 1.3.14, which is purely where the collections land, and the rest is freed memory waiting out mimalloc's 100 ms purge delay (MIMALLOC_PURGE_DELAY=0 takes 50 to 70 MB off the peak of that run). Neither is something a worker_threads change can address.

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