Skip to content

node:worker_threads: rebind console output sink instead of replacing the global console - #34347

Closed
robobun wants to merge 6 commits into
mainfrom
claude/b711c0ca/worker-console-sink
Closed

node:worker_threads: rebind console output sink instead of replacing the global console#34347
robobun wants to merge 6 commits into
mainfrom
claude/b711c0ca/worker-console-sink

Conversation

@robobun

@robobun robobun commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator

What

Inside a node:worker_threads worker, console.write and console[Symbol.asyncIterator] (both documented Bun APIs) were gone, and console formatting silently switched to Node's util.inspect style while the main thread of the same process still printed Bun's. Bun.inspect() inside the worker was unchanged, proving it is the console binding and not a VM-wide inspect change. The web Worker global was unaffected; only node:worker_threads diverged.

// main thread:  Map(1) {\n  "k": "v",\n}
// worker:       Map(1) { 'k' => 'v' }
// main thread:  typeof console.write === "function"
// worker:       typeof console.write === "undefined"

Cause

setupWorkerStdio() in src/js/node/worker_threads.ts did:

if (stdout || stderr) {
  const { Console } = require("node:console");
  globalThis.console = new Console(process.stdout, process.stderr);
}

The guard is effectively unconditional (the Worker constructor always creates the stdout/stderr channels), and the swap replaces Bun's native console with a node:console instance, losing the Bun-specific surface and switching to util.formatWithOptions formatting. Introduced by #31216.

Fix

Rebind the native ConsoleObject's output sink instead of replacing the global. node:worker_threads now calls a native setter that stashes the port-backed process.stdout/process.stderr as Strong handles on the per-VM ConsoleObject. When an override is present, message_with_type_and_level (covering log/info/debug/warn/error/dir/dirxml/table/trace/assert/group*) and console.count format into a buffer with Bun's formatter and hand the bytes to stream.write(), so the Bun console surface and formatting are preserved while output still flows through worker.stdout/worker.stderr.

The overrides are released before JSC VM teardown in the worker shutdown path so the Strong handles never outlive the heap.

As a side effect of extracting the format body, console.error() with no arguments now writes its newline to stderr instead of stdout, matching Node.

Verification

bun bd test test/js/node/worker_threads/worker-console-bun-api.test.ts

Fails on main (console APIs undefined, 'k' => 'v' formatting captured), passes with this change. Node's test-worker-stdio*.js and test-worker-console-listeners.js continue to pass.


[review] gate passed · iteration 0 · 6 files touched

fails on main (without fix)
ASAN without fix: 2 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-console-bun-api.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 (5348bde52)

test/js/node/worker_threads/worker-console-bun-api.test.ts:
(pass) node:worker_threads console > web Worker global console is unaffected [726.84ms]
102 |     expect(out).toContain("via-console");
103 |     expect(out).toContain("via-process");
104 |     expect(out).toContain("cnt: 1");
105 |     expect(err).toContain("via-error");
106 |     // timeEnd/timeLog are captured on worker.stderr, not leaked to the parent fd.
107 |     expect(err).toMatch(/\[[\d.]+ms\] tmr extra\n/);
                      ^
error: expect(received).toMatch(expected)

Expected substring or pattern: /\[[\d.]+ms\] tmr extra\n/
Received: "via-error\n"

      at <anonymous> (/workspace/bun/test/js/node/worker_threads/worker-co
... (truncated)

release without fix: 1 FAILED
bun test v1.4.0-canary.1 (1498d7b77)

test/js/node/worker_threads/worker-console-bun-api.test.ts:
(pass) node:worker_threads console > web Worker global console is unaffected [18.09ms]
(pass) node:worker_threads console > keeps Bun console APIs and formatting inside workers [47.08ms]
94 |       stdout: "pipe",
95 |       stderr: "pipe",
96 |     });
97 |     const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
98 | 
99 |     expect(stdout.startsWith("CAPTURED:")).toBe(true);
                                                ^
error: expect(received).toBe(expected)

Expected: true
Received: false

      at <anonymous> (/workspace/bun/test/js/node/worker_threads/worker-console-bun-api.test.ts:99:44)
(fail) node:worker_threads console > console.log is still captured by worker.stdout when { stdout: true } [51.08ms]

 2 pass
 1 fail
 10 expect() calls
Ran 3 tests across 1 file. [228.00ms]
__F:1: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-console-bun-api.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 (5348bde52)

test/js/node/worker_threads/worker-console-bun-api.test.ts:
(pass) node:worker_threads console > web Worker global console is unaffected [647.09ms]
(pass) node:worker_threads console > console.log is still captured by worker.stdout when { stdout: true } [3188.46ms]
(pass) node:worker_threads console > keeps Bun console APIs and formatting inside workers [3422.36ms]

 3 pass
 0 fail
 18 expect() calls
Ran 3 tests across 1 file. [5.56s]
__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)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     5348bde523
  features     (none)

22 deps, 106 codegen, 1168 objects in 930ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1231] install /workspace/bun
bun install v1.4.0-canary.1 (1498d7b77)

Checked 124 installs across 170 packages (no changes) [23.00ms]
[2/1231] install /workspace/bun/packages/bun-error
bun install v1.4.0-canary.1 (1498d7b77)

Checked 1 install across 2 packages (no changes) [5.00ms]
[3/1231] gen bindgenv2
[4/1231] fetch tinycc
[tinycc] up to date
[5/1230] gen ErrorCode+*.h
[6/1230] install /workspace/bun/src/node-fallbacks
bun install v1.4.0-canary.1 (1498d7b77)

Checked 129 installs across 147 packages (no changes) [22.00ms]
[7/1230] fetch picohttpparser
[picohttpparser] up to date
[8/1230] gen .
... (truncated)
diff hotspot
src/codegen/generate-js2native.ts                  |   1 +
 src/js/node/worker_threads.ts                      |   8 +-
 src/jsc/ConsoleObject.rs                           | 262 ++++++++++++++++++---
 src/jsc/web_worker.rs                              |   7 +
 src/runtime/dispatch_js2native.rs                  |   1 +
 .../worker_threads/worker-console-bun-api.test.ts  | 132 +++++++++++
 6 files changed, 370 insertions(+), 41 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                                      reads  edits  tests
src/codegen/generate-js2native.ts                             3      1      0
src/js/node/worker_threads.ts                                 2      1      0
src/jsc/ConsoleObject.rs                                     15     18      0
src/jsc/web_worker.rs                                         2      1      0
src/runtime/dispatch_js2native.rs                             1      1      0
…t/js/node/worker_threads/worker-console-bun-api.test.ts      1      5      0

…the global console

Inside a node:worker_threads worker, the global console was being replaced
wholesale with a node:console instance so that console.log output would flow
through the port-backed process.stdout/stderr. That swap loses Bun's
documented console APIs (console.write, console[Symbol.asyncIterator],
console.Console) and silently switches formatting from Bun's inspector to
Node's util.inspect, so the same value printed differently on the worker and
the main thread of one process.

Rebind the native ConsoleObject's output sink instead: when a worker sets up
its port-backed stdio, it now calls a native setter that stashes the
Writable streams on the per-VM ConsoleObject. message_with_type_and_level
and console.count format into a buffer and hand the bytes to
stream.write() when an override is present, so Bun's formatter and console
surface survive while output still reaches worker.stdout/stderr.

As a side effect of extracting the format body, console.error() with no
arguments now writes its newline to stderr instead of stdout, matching
Node.
@coderabbitai

coderabbitai Bot commented Jul 16, 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 recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 4 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: 84f2d8d0-38a6-490c-aaeb-2dd5f51ecb34

📥 Commits

Reviewing files that changed from the base of the PR and between e85b532 and f07dd4c.

📒 Files selected for processing (2)
  • src/js/node/worker_threads.ts
  • test/js/node/worker_threads/worker-console-bun-api.test.ts

Walkthrough

Changes

Worker-thread stdio now routes the existing Bun console through JS stream overrides. ConsoleObject adds override lifecycle management and stream-aware formatting for messages, counts, traces, and timers. Worker shutdown clears overrides before VM teardown, with integration tests covering APIs, routing, formatting, and web Worker behavior.

Worker console routing

Layer / File(s) Summary
Worker stream wiring
src/codegen/generate-js2native.ts, src/runtime/dispatch_js2native.rs, src/js/node/worker_threads.ts
Registers the Rust console binding and uses it to route captured worker stdout and stderr without replacing the global console object.
Console override lifecycle and message routing
src/jsc/ConsoleObject.rs
Stores strong JS stream handles, supports setting and clearing overrides, and writes formatted console messages through override streams when present.
Trace, count, and timing output
src/jsc/ConsoleObject.rs, src/runtime/server/RequestContext.rs
Adds override-aware count and timer formatting and passes explicit ANSI color settings through trace rendering.
Worker cleanup and console behavior validation
src/jsc/web_worker.rs, test/js/node/worker_threads/worker-console-bun-api.test.ts
Releases console stream handles before VM teardown and tests worker console APIs, captured output routing, formatting, and web Worker isolation.

Possibly related PRs

  • oven-sh/bun#31216: Updates the same worker stdout/stderr wiring path used by this console routing change.
  • oven-sh/bun#31833: Relates to JSC teardown ordering and ConsoleObject strong-handle cleanup.
  • oven-sh/bun#34340: Also modifies worker stdio handling and port-backed output flushing.

Suggested reviewers: jarred-sumner, cirospaciari

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: rebinding worker console output instead of replacing the global console.
Description check ✅ Passed The description covers the change and verification, though it uses custom headings instead of the exact 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.

@robobun

robobun commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 6:10 AM PT - Jul 16th, 2026

@autofix-ci[bot], your commit f07dd4c has 2 failures in Build #73859 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 34347

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

bun-34347 --bun

@github-actions

Copy link
Copy Markdown
Contributor

Found 3 issues this PR may fix:

  1. worker_threads.Worker option "stdout" is not yet implemented in Bun #23875 - PR implements routing console output through worker.stdout/worker.stderr streams, which is the core of the "stdout option not implemented" complaint
  2. worker_threads stdout/stderr not implemented for Worker #28039 - Duplicate of worker_threads.Worker option "stdout" is not yet implemented in Bun #23875; same worker stdout/stderr not implemented issue addressed by this PR's console output sink rebinding
  3. worker_threads.Worker missing resourceLimits, stderr, stdout and eval options #10768 - The stdout and stderr portions of this issue (Worker missing stdout/stderr options) are directly addressed by this PR

If this is helpful, copy the block below into the PR description to auto-close these issues on merge.

Fixes #23875
Fixes #28039
Fixes #10768

🤖 Generated with Claude Code

Comment thread src/jsc/ConsoleObject.rs
Comment thread src/jsc/ConsoleObject.rs
Comment thread src/js/node/worker_threads.ts Outdated

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/js/node/worker_threads.ts`:
- Around line 478-479: Update the stream override logic around setOutputStreams
so each argument uses the captured stdout or stderr stream only when that
corresponding value exists; otherwise preserve the native process stream/fd path
by leaving the uncaptured side unoverridden. Avoid gating both streams on the
combined stdout || stderr condition.

In `@test/js/node/worker_threads/worker-console-bun-api.test.ts`:
- Around line 76-78: Update the worker stream completion logic around the stdout
and stderr handlers so captured output is reported only after both streams have
emitted their end events. Track completion for each stream, then perform the
existing process.stdout.write with the combined out and err values once both are
finished.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 8a921279-bfcd-4e73-a830-f387022429f3

📥 Commits

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

📒 Files selected for processing (7)
  • src/codegen/generate-js2native.ts
  • src/js/node/worker_threads.ts
  • src/jsc/ConsoleObject.rs
  • src/jsc/web_worker.rs
  • src/runtime/dispatch_js2native.rs
  • src/runtime/server/RequestContext.rs
  • test/js/node/worker_threads/worker-console-bun-api.test.ts

Comment thread src/js/node/worker_threads.ts Outdated
Comment thread test/js/node/worker_threads/worker-console-bun-api.test.ts Outdated
Comment thread src/jsc/ConsoleObject.rs

@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 in this revision, but this is a substantive native change — new Strong-rooted JS stream handles on the per-VM ConsoleObject, JS re-entry from console paths that previously only wrote fds, and worker-shutdown ordering for releasing those handles — so it's worth a human look.

What was reviewed:

  • Strong-handle lifecycle: stdout_override/stderr_override are released in clear_output_streams() from web_worker.rs shutdown step 2, before teardownJSCVM — ordering looks correct.
  • JS re-entry: count/timeEnd/timeLog and the override branch of message_with_type_and_level_ scope the vm_console_mut borrow before calling write_to_js_stream; the snapshotted JSValue stays rooted by the still-held Strong across formatting.
  • write_message_body extraction: the empty-args MessageType::Log newline now uses writer (so console.error() with no args goes to stderr); write_trace now honors the caller's enable_colors; the sole external caller in RequestContext.rs was updated.
  • All prior review threads (timeEnd/timeLog capture, trace ANSI, per-stream override gating, both-stream-end wait in the test, trace stdout routing) confirmed applied in the current diff.
Extended reasoning...

Overview

The PR replaces globalThis.console = new Console(...) in node:worker_threads workers with a native output-sink rebinding on the per-VM ConsoleObject. It touches:

  • src/jsc/ConsoleObject.rs (~260 lines): two new StrongOptional fields, a set_output_streams js2native entry point, a write_to_js_stream helper that invokes .write() on a JS Writable, extraction of write_message_body from message_with_type_and_level_, and override routing added to count/timeEnd/timeLog.
  • src/jsc/web_worker.rs: releases the Strong handles in shutdown step 2, before JSC VM teardown.
  • src/js/node/worker_threads.ts: calls the new native setter instead of swapping the global console.
  • src/codegen/generate-js2native.ts + src/runtime/dispatch_js2native.rs: wiring for the new $newRustFunction call site.
  • src/runtime/server/RequestContext.rs: updated for the write_trace signature change.
  • New test file exercising API surface, formatting parity, and worker.stdout/stderr capture including count/timeLog/timeEnd.

Security risks

None identified. No auth/crypto/permissions surface. The new set_output_streams binding is only reachable from the worker_threads builtin ($newRustFunction), not user code. write_to_js_stream does a property get + call on process.stdout/stderr (builtin-constructed port-backed Writables), not on arbitrary user objects.

Level of scrutiny

This warrants human review. It is not a mechanical change: it adds GC-rooted state to a per-VM object, introduces JS re-entry from console output paths that previously wrote fds directly, extracts and reshapes the body of message_with_type_and_level_ (a hot path hit by every console.log), and adds a step to worker shutdown ordering. The PR has already been through three rounds of review feedback (timeEnd/timeLog leaking to fd, write_trace ANSI, per-stream override gating, test stream-end race, trace stream routing), each of which was a real gap — which is evidence the change is subtle enough to merit a careful maintainer pass on the final shape.

Other factors

  • The bug-hunting system found nothing on this final revision.
  • All inline review threads are resolved and I verified each fix landed in the diff (5348bde routes timeEnd/timeLog through stderr_override; e85b532 threads enable_colors into write_trace; e9a400f gates each override on its own capture flag and waits for both stream end events in the test).
  • The let _ = write_to_js_stream(...) pattern in count/timeEnd/timeLog discards a JsResult, so a throwing .write() would leave a pending exception on a void-returning host call — in practice the port-backed Writable's write doesn't throw, so I'm noting it rather than flagging it as a bug.
  • Tests pass on both debug/ASAN and release per the PR's evidence block; the new test asserts capture of log/error/count/timeLog/timeEnd and formatting parity with the main thread.

@robobun

robobun commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator Author

CI build #73859 is complete. The new test worker-console-bun-api.test.ts passes on all lanes.

The two remaining red tests are unrelated to this diff:

  • test/js/node/test/parallel/test-net-connect-memleak.js (alpine x64, x64-baseline): pre-existing on main.
  • test/cli/run/require-cache.test.ts (darwin 26 aarch64): RSS threshold check hit 74 MB vs a 64 MB limit; the same test passed on retry on darwin 14. This PR does not touch module loading or the require cache.

Ready for review.

@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

Closing: #37128 reworks the stdio sinks and lists this PR as superseded (it takes the same approach: node:worker_threads rebinds the native console to the port-backed streams and the globalThis.console = new Console(...) swap is deleted, and its worker test checks console.write, console[Symbol.asyncIterator] and Bun formatting inside the worker, as the test here does).

If something here turns out not to be covered once #37128 lands, this can be reopened.

@robobun robobun closed this Aug 13, 2026
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