Repository navigation
Conversation
`new Worker()` started the thread first and started the parent side of the stdout, stderr and public ports after it. A worker that ended inside that gap had its exit queued on the parent ahead of the first delivery of those ports. The exit handler then closed the stdio ports with the output still queued, and delivered the parentPort messages from inside itself. Start the three ports right before the thread starts. The worker's first write or post then queues the port's drain, and the worker's port close queues the delivery of the rest. Both come from the worker thread, ahead of its exit task.
With the public port started before the thread, the port delivers what the worker posted ahead of the worker's exit task. The drain in the exit handler has no path left, so remove it.
A worker can transfer its parentPort to another thread. Its end of the public channel then outlives the worker, so the parent's port gets no peer-close flush, and one drain task delivers at most 1,024 messages. The drain in the exit handler takes the rest. Restore it, and pin the case with a test.
…irst use
Since 1.4.0 a node:worker_threads worker evaluated the node:worker_threads
module before its entry point. That ran 27 builtin module bodies and built
process.stdin, stdout, stderr and a Console in every worker.
The start sequence is now native (NodeWorkerState::takeFromParent) and
evaluates no builtin module:
- The parent passes the worker's ends of its five channels (stdin, stdout,
stderr, the postMessageToThread hub, parentPort) through WorkerOptions,
not inside workerData. node:worker_threads makes its native Worker through
an internal function, so the public constructor takes no hidden argument.
- The console of a node worker stays the native one. Until the worker builds
process.stdout or stderr, the console posts to the parent's port from a
native queue, one chunk for each call, with the flow control of
internal/worker/stdio. After that it writes through the stream as the
Console of node:console does.
- process.stdin, stdout and stderr are getters on the worker's process. The
first read builds the stream, and the stream takes over the batch that the
console has in flight. A read does not change the structure of process.
- A message from the postMessageToThread hub, or a chunk for a
{ stdin: true } worker, builds its reader on arrival.
- The process restrictions of a worker are installed with the process object.
- A synchronous exit flushes stdio natively. process has no 'exit' listener of
its own.
Related changes:
- The reader of a stdio port asks for more data only after it got some. The
writer took an earlier request for the ack of its first batch.
- node:events defines EventEmitter.prototype.constructor. An assignment
failed after Object.freeze(Object.prototype).
- The write of a Console to a stream whose errors it ignores is in
internal/console_write, for node:console and for the worker's console.
- JSC's console.assert checks no exception after it calls the console
client, so the client leaves no throw scope unchecked.
Co-authored-by: Dylan Conway <dylan.conway567@gmail.com>
|
No actionable comments were generated in the recent review.
|
| Layer / File(s) | Summary |
|---|---|
Worker creation and channel transfer src/js/node/worker_threads.ts, src/js/builtins.d.ts, src/jsc/bindings/webcore/WorkerOptions.h, src/jsc/bindings/webcore/JSWorker.*, src/jsc/bindings/webcore/Worker.*, src/jsc/web_worker.rs, bench/snippets/worker-threads-start.mjs, test/js/node/worker_threads/worker_threads.test.ts |
Worker construction now passes dedicated channels and transfer metadata to a native worker factory. The startup path transfers worker data before preloads and entry evaluation. Tests cover startup ordering, worker data, and early completion; the benchmark measures six worker source variants. |
Native worker state and messaging src/jsc/bindings/NodeWorkerState.*, src/jsc/bindings/ZigGlobalObject.*, src/jsc/bindings/webcore/MessagePort.*, src/jsc/bindings/webcore/Worker.cpp, src/js/internal/worker/messaging.ts, test/js/node/worker_threads/parent-held-in-constructor-fixture.js, test/js/node/worker_threads/worker_threads.test.ts, test/js/web/workers/* |
NodeWorkerState stores worker data, ports, listeners, and startup flags. Entry settlement releases held port starts. Native bindings supply messaging state and dispatch incoming messages. Tests cover early worker completion, message delivery, port lifecycle, and late completion. |
Worker process streams and console output src/js/internal/worker/stdio.ts, src/js/internal/console_write.ts, src/js/internal/streams/state.ts, src/js/builtins/ConsoleObject.ts, src/js/builtins/ProcessObjectInternals.ts, src/js/node/events.ts, src/jsc/ConsoleObject.rs, src/jsc/bindings/BunProcess.cpp, src/jsc/bindings/ConsoleObject.cpp, test/js/node/worker_threads/worker_threads.test.ts |
Worker process streams are created lazily from worker ports. Console writes use worker-specific formatting and queued output. Acknowledgements and process-exit handling flush pending output. Tests cover stream construction, backpressure, process restrictions, and console output. |
Possibly related PRs
- oven-sh/bun#46921: Starts parent-side worker ports before the worker thread, matching this change’s channel startup sequence.
- oven-sh/bun#46997: Updates lazy worker stdio construction introduced by this change.
- oven-sh/bun#46948: Changes worker error delivery relative to exit listeners, which is related to this change’s exit-time output handling.
Suggested reviewers: dylan-conway, jarred-sumner
Priority:
Merge Risk: 
Minimal · up to 31071
-
This change moves Node worker startup and stdio setup to native code with lazy stream creation. The supplied evidence shows no concrete merge-blocking defect. Normal CI validation of the worker test suites remains the main check.
Pre-merge checks: 
4
-
Full details: Title check
-
Explanation
The title "worker_threads: start a node worker natively and build its stdio on first use" matches the main change. The PR starts Node workers through a native path (NodeWorkerState::takeFromParent) instead of evaluating the node:worker_threads module, and it builds process.stdin/stdout/stderr lazily on first read. The title is concise, specific, and does not use vague terms. Its wording is somewhat long but still readable.
Full details: Description check
-
Explanation
The description supplies a Problem section, a Fix section, measurements, background, downsides, and detailed verification notes. It covers the template's two required parts in substance. The template's 'What does this PR do?' content appears as Problem/Fix/Background, and 'How did you verify your code works?' appears in the Verified line and the Tests section. The description is very long and dense, and the verification section is more detailed than the template asks. It also lists two platform-specific tests the author could not run locally, deferred to CI. The template headings are not used verbatim, which is a minor deviation.
-
- Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.
Comment @coderabbitai help to get the list of available commands.
|
Status How I reproduced it: a script makes 100 and then 300 PR: #47052 |
|
Updated 12:36 PM PT - Oct 27th, 2026
✅ @robobun, your commit 31071fdedb194e27974e3b0b4711f4e87206c43b passed in 🧪 To try this PR locally: bunx bun-pr 47052That installs a local version of the PR into your bun-47052 --bun |
There was a problem hiding this comment.
Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.
Beyond the inline findings, I also checked GC rooting of the new NodeWorkerState: its four WriteBarrier members are all appended in NodeWorkerState::visit, and the unique_ptr on Zig::GlobalObject is reached through the existing gcMembers table, so the new state replaces five global-object fields with one visited owner. The lazy process.stdout/stderr getters in BunProcess.cpp were also checked for the "process exists before start sequence" and setter-in-strict-mode concerns; neither reproduces on the code as written.
Extended reasoning...
The change moves node:worker_threads worker startup from a JS bootstrap into a native NodeWorkerState (C++), reworks how the five parent/worker MessagePorts are passed through WorkerOptions, makes process stdio lazy getters, and routes the worker's native console through the stdout/stderr ports with its own flow-control queue. It touches no auth, crypto or injection surface, but it is a large cross-language change to worker lifecycle, port entanglement and GC-rooted state. Two confirmed flow-control findings are posted inline, so a human review is already indicated; this note only records what else was examined.
The queue of a worker's console held all output that the parent's reader had yet to acknowledge in one Vector. A Vector holds less than 2 GiB, so a worker whose parent did not read worker.stdout aborted the process after about 1.5 GiB of console output. The queue now holds segments of about 1 MiB. An acknowledgement still releases all that waits as one message, up to 256 MiB. A synchronous exit posts a message for each segment. The build of process.stdin, stdout or stderr now runs nothing that user code can replace once the stream's constructor returned. Native code puts the stream's listener on the port. A stream that is made while the console has a message in flight waits for the acknowledgement of that message ahead of its first write, and no longer writes the message to itself again. So a replaced MessagePort or Writable method that reads the stream does not start a second build, and a read of a stream from inside its own build gives one stream. A console call from inside a build posted a message that the new stream did not know of. The stream then took the acknowledgement of that message for the one of its first write. Such a call now waits in the queue. The stream writes it when the build is done. The queue posts it when the build throws, and at once when the worker exits.
There was a problem hiding this comment.
I re-reviewed after 31071fd and found no new bugs; the segmented ConsoleQueue and the m_buildingStream hold cover both points I raised inline earlier, and each has tests in worker_threads.test.ts. Given the scope (native worker start, MessagePort start holds, console sink rewiring, ~3k lines), a maintainer look is still worthwhile.
What was reviewed this pass:
- NodeWorkerState.cpp stdioStream / writeConsole / sendConsoleQueue: the in-flight ack now maps to exactly one posted message; console calls during a build wait in the queue and are written through the stream after the build, or posted if the build throws.
- ConsoleQueue::append / nextMessageSegments: per-segment Vector growth is capped at ~1 MiB with tryAppend, so a non-acking parent no longer trips the Vector size limit; the Deque itself still grows without bound, matching a Writable's buffer.
- Loop ref balance on the stdio ports across sendConsoleQueue, didReceiveMessage, releaseConsoleQueue and the stream handoff in stdioStream — each jsRef has a matching jsUnref on the ack, exit, and build paths.
Extended reasoning...
The PR replaces the JS bootstrap of node:worker_threads workers with a native NodeWorkerState (C++), passes five MessagePort ends through WorkerOptions::nodePorts, reroutes the native console sink to the parent's stdout/stderr ports, and builds process.stdin/stdout/stderr lazily; it touches no auth, crypto or injection surface. The newest commit 31071fd addresses both inline findings from the prior run and adds tests for them, and this run's hunt exited on dry_streak with no new findings. The change is still large and complex native lifecycle code (port entanglement, start holds, exception scopes, loop ref counting) that a maintainer should weigh, so defer rather than approve.
Problem
node:worker_threadsworker evaluates thenode:worker_threadsmodule before its entry point (node:worker_threads: +48 Node.js tests passing — MessagePort, stdio, SHARE_ENV, exit codes, transfer semantics, postMessageToThread + inspector #31216,Bun__Worker__loadNodeWorkerThreadsModuleinsrc/jsc/web_worker.rs): 27 builtin module bodies, three stdio streams and aConsole.Workerhas the same teardown (Worker / worker_threads: WebCore-shaped lifetimes, joined threads, one ordered VM teardown #37075) and costs 5.9M, so the teardown fix is not the cause.Fix
NodeWorkerState::takeFromParent) and evaluates no builtin module: 6.5M.worker.stdoutorworker.stderrport until the worker builds that stream.process.stdin,stdoutandstderrare getters that build the stream on the first read.worker_threads.test.ts(107 new tests, 34 fail on main), node'stest-worker-*.js,test/js/web/workers/. Self-reviewed: 75 concerns raised, 69 addressed (Notes).Background
MessageChannels to its parent: stdin, stdout, stderr, thepostMessageToThreadhub andparentPort.Downsides
util.inspectformat of 1.4 worker logs sees Bun's format.process.stdoutin a worker calls a native getter: 18 to 110 instructions. A firstprocess.nextTickafter hot code was optimized re-optimizes functions that readprocess.*: 11M instructions each, once.WorkerOptionsgrows by 16 bytes perWorker. The binary grows by 32 KB.Notes
Origin. No user report. A maintainer asked what a Worker's start does since 1.4.0 that it did not do on 1.3.14, with the empty file as the measure.
Cause. #31216 (1.4.0) added a JS bootstrap to every
node:worker_threadsworker. #39536 made it a native hook that requiresnode:worker_threadsbefore preloads and the entry point. #39628 moved part of the module's own work to first use. What a worker on an empty file runs on its thread:Worker(main and PR)Measurements. Release builds (LTO) of the merge base a0cc096 and of this PR, linux x64. The unit is user instructions (
perf_event_open, all threads). Per worker = slope between 100 and 300 sequential workers, median of 3 (spread under 0.3M).console.logonceparentPort.on, one replyprocess.stdout.write{ stdin: true }, stdin not usedconsole.logand a read ofprocess.stdinpostMessageToThreadarrivalworker.stdinWorkerbun <empty file>7,087,669 to 7,086,756.require("node:worker_threads")on the main thread 20,940,668 to 20,214,984. A process that makes its first node Worker 100.1M to 45.8M. A process that makes one WebWorker: 18.74M and 18.72M (median of 40, spread 0.5M).port.start()290.9 to 291.0.postMessageround trip 7,018 to 6,435.process.umask()on the main thread 396.0 to 396.0, in a worker 879 to 411.port.listenerCount()2,036 to 2,100.port.on()plusoff()8,904 to 8,858.console.login a worker, per call: 14,395 to 12,962 (JIT), 80,310 to 43,300 (no JIT).process.stdout.writein a worker: 12,973 to 12,889 (JIT), 55,731 to 55,525 (no JIT). A read ofprocess.stdoutin a worker: 18.2 to 109.9 (JIT), 325 to 713 (no JIT).log1,602.8 to 1,598.8,error1,604.8 to 1,600.8,count1,266.3 to 1,274.3,timeLog3,436.2 to 3,451.5,table16,621 to 16,671,log()with no argument 755 to 711. With the JIT:log1,408.4 to 1,404.1.Zig::GlobalObject9,736 to 9,712 bytes.MessagePort96 to 96.Process136 to 136.WorkerOptions128 to 144. One more internal module (8 bytes in each registry). Binary.text+27,328 bytes,.rodata+4,096,.bun_builtins-1,366, file +32,768.strace -f -c, slope): 813.5 to 485.5. Without futex, sched_yield, epoll and madvise: 79.0 to 61.1.node:worker_threads: 2.99 MB to 1.85 MB (1.3.14: 3.39 MB).console.logcalls in a worker: main 439 to 513 ms, PR 322 to 419 ms.bench/snippets/worker-threads-start.mjshas six worker shapes.process.pid, then a first use of a lazyprocessproperty: +1.0G instructions forprocess.configon main, +1.1G on this PR. On this PR also +1.2G for the firstprocess.nextTick, which the old bootstrap read at start. A first read ofprocess.stdoutafterprocess.nextTickwas used costs nothing (2,112M and 2,189M): the getter does not change the structure ofprocess.What the worker does at start now (
NodeWorkerState::takeFromParent, called fromWebWorker::spin()):Workermade and passed throughWorkerOptions::nodePorts,parentPortand the hub port, held until the entry point settles,workerDataandenvironmentData.Console. The sink in
ConsoleObject.rsis adapted from #39592 (credited in the commit). Unlike #39592, native code does not call intoprocess.stdoutbefore the worker has built that stream. Output goes as one chunk for each call, as the streams of 1.4 and of Node deliver it. When the worker buildsprocess.stdoutwhile the console has a message in flight, the stream waits for the ack of that message ahead of its first write. So'drain'and write callbacks keep the order of main.Console queue. What the console has written and the parent's reader has yet to acknowledge waits in segments of about 1 MiB, so no buffer comes near the 2 GiB that a
WTF::Vectorholds. An acknowledgement releases all that waits as one message (256 MiB at most), as thewritevof a stream does on main and in Node. A synchronous exit posts one message for each segment. The queue has no limit, like the Writable's buffer on main and in Node: a worker whose parent never reads a capturedworker.stdoutkeeps its output.process.exit(): peak RSS 163 MiB (main: 1,062 MiB).process.exit(): 683 MiB (main: 2,205 MiB).process.stdoutpast 1 MiB, was built and measured too. The first left the output of a busy worker behind and lost it atterminate(). The second took a worker that logs 2 MiB in one loop from 240M to 448M instructions.Stream builds. The build of a stdio stream runs nothing that user code can replace once the stream's constructor returned: native code puts the stream's
messagelistener on the port, and the stream writes nothing to itself. So aMessagePortorWritablemethod that user code replaced does not run inside the build, and a read of a stream from inside its own build gives the same stream. User code can still run inside a build through an accessor onObject.prototypethat a stream constructor hits. A console call from there waits in the queue, and the stream writes it when the build is done.Behaviour changes against 1.4.x in a node worker. In each case the worker now behaves as Bun's main thread does, or as 1.3.x workers did.
console.*formats natively, not withutil.inspect.console.timeLog()andconsole.timeEnd()write to stderr andconsole.trace()to stdout, as on the main thread.console.write()andfor await (const line of console)exist again and use fd 1 and fd 0 of the process, as in 1.3.x and as worker_threads: keep the native console in workers, routed through process.stdout/stderr #39592 chose.console._stdout = streamdoes not redirect the console.require("node:console") === console.processhas no'exit'listener of its own (Node: 1, 1.4.x: 2).FORCE_COLORin the worker's env and the process's color setting. 1.4.x read the worker'sprocess.envat each call.MessagePorthason()andonce()only afternode:worker_threadswas loaded in that thread, as on the main thread. A port that arrives throughprocess.on("workerMessage")loads the module first.new globalThis.Worker(url, options, third)is a WebWorker. The third argument was internal.node:events, the stream modules andnode:worker_threadsare evaluated at first use, in the realm as user code left it. The stdio streams take no option fromObject.prototypeand keep thehighWaterMarkandcaptureRejectionsdefaults of worker start.Object.entries(process)and{ ...process }liststdin,stdout,stderrright after_exiting.postMessageToThread()sent right afternew Worker()can reach a worker that already ended: the sender getsERR_WORKER_MESSAGING_FAILED. An unref'd worker with a capturedstdoutthat nothing reads keeps its parent alive once output arrived, as in Node. On main the parent usually went idle first.process.nextTickcallbacks still run after its microtasks, as since 1.4.0 (Node and 1.3.14 run them first). Atest.failingmarks it.process.stdout.writableLengthdoes not count a console message that was posted before the worker first readprocess.stdoutand that the parent has yet to acknowledge. On main and in Node the stream exists from the start and counts it.console.logafter anode:vmtimeout that landed in an earlier console call still prints. On main and in Node the stream stays mid-write.Not changed. The main thread's console: byte-identical output for a corpus of 30 scripts in default,
FORCE_COLORandNO_COLORruns. A bareconsole.error()on the main thread still writes its newline to stdout (#39592 changes that).Relation to open PRs.
process.stdinon first read): covered here, with its two stream pins.Self-review. Two review passes on the first commit and two on the second (the console queue and the stream builds). Not changed, with the reason:
console.write()and the console's async iterator stay bound to the process's fds in a worker: worker_threads: keep the native console in workers, routed through process.stdout/stderr #39592 made that choice, and 1.3.x did the same.console.timeEnd()andtimeLog()write to stderr: the worker has the main thread's console.stdoutpins an idle parent more often: the rule is main's and Node's, only the race moved.postMessageToThread()that is still queued when the entry point ends with an unhandled rejection gets no answer, as in Node. Main answered it from script that ran after the worker asked to stop. worker: take a listener's termination at the native boundary, end the tick pass on a stopped VM #45298 has the same ruling for its path. Answers from the hub side are a separate change.Object.prototypethat runs inside the build, reads the same stream there, and keeps the half-built object.SafeMap: itsrequireis what loadsinternal/primordialswith the module on the main thread, and an existing test depends on that.Tests. 107 new tests in
worker_threads.test.ts: 89 in "node worker start sequence", 18 from #46921. On the merge base 34 of them fail. On the first commit of this PR 12 of the tests of the second commit fail. The file passes on a debug build withBUN_JSC_validateExceptionChecks=1, except for the flake of "terminate(): nothing of the worker's runs after the request" that #38194 fixes (1 of 8 runs here). Release build:test/js/node/worker_threads/286 pass,test/js/web/workers/withbroadcastchannel,web/consoleandnode/events567 pass, node'stest-worker-*.js105 of 106 (the same file fails on main),test-console*,test-event*,test-stdio*,test-stdout*,test-stdin*as on main. On this machine's debug build some tests ofworker_destruction.test.ts,worker-late-completion.test.tsandmodule-graph-workers.test.tspass only with a longer timeout, on main too.Found on main during review, not changed here.
worker.stdout.destroy()in the middle of a batch leaves the worker alive (Node and 1.4.4 let it exit). A late unhandled rejection in a worker sometimes does not reach the parent's'error'listener. Both are reported separately.Follow-ups, not in this PR.
require("node:worker_threads")in a worker still evaluates the whole module (about 12M). A lazyWorkerexport and the emitter methods as a module of their own would cut that. Builtin bytecode for the plain binary (#35698) cuts every remaining first-use load.no test proof · iteration 0 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/web/workers/worker-late-completion.test.ts, test/js/node/worker_threads/worker_threads.test.ts