From fbb3ad9a7511abade4647fbf7871e972c82c6345 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 16 Jul 2026 09:05:15 +0000 Subject: [PATCH 1/6] node:worker_threads: rebind console output sink instead of replacing 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. --- src/codegen/generate-js2native.ts | 1 + src/js/node/worker_threads.ts | 8 +- src/jsc/ConsoleObject.rs | 182 +++++++++++++++--- src/jsc/web_worker.rs | 7 + src/runtime/dispatch_js2native.rs | 1 + .../worker-console-bun-api.test.ts | 124 ++++++++++++ 6 files changed, 295 insertions(+), 28 deletions(-) create mode 100644 test/js/node/worker_threads/worker-console-bun-api.test.ts diff --git a/src/codegen/generate-js2native.ts b/src/codegen/generate-js2native.ts index 2143557ef408..44fff6d7f341 100644 --- a/src/codegen/generate-js2native.ts +++ b/src/codegen/generate-js2native.ts @@ -45,6 +45,7 @@ const sourceFiles = readdirRecursiveWithExclusionsAndExtensionsSync( // requires adding its entry below. const rustIdentifierPaths: Record = { "bun.rs": "bun.rs", + "ConsoleObject.rs": "jsc/ConsoleObject.rs", "Counters.rs": "jsc/Counters.rs", "FrameworkRouter.rs": "runtime/bake/FrameworkRouter.rs", "Listener.rs": "runtime/socket/Listener.rs", diff --git a/src/js/node/worker_threads.ts b/src/js/node/worker_threads.ts index 7262da0f30f7..f8bea87a9c6f 100644 --- a/src/js/node/worker_threads.ts +++ b/src/js/node/worker_threads.ts @@ -469,10 +469,12 @@ function setupWorkerStdio(stdio) { enumerable: true, }); // node routes console.log through process.stdout/stderr; Bun's global console - // writes the fd directly, so rebind it to the captured streams when present. + // writes the fd directly. Rebind its output sink to the port-backed streams + // so console output is captured by worker.stdout/stderr, while keeping Bun's + // formatter and the Bun-specific console surface (console.write, + // Symbol.asyncIterator) intact. if (stdout || stderr) { - const { Console } = require("node:console"); - globalThis.console = new Console(process.stdout, process.stderr); + $newRustFunction("ConsoleObject.rs", "setOutputStreams", 2)(process.stdout, process.stderr); } } diff --git a/src/jsc/ConsoleObject.rs b/src/jsc/ConsoleObject.rs index 96dae5137792..036cf9b22151 100644 --- a/src/jsc/ConsoleObject.rs +++ b/src/jsc/ConsoleObject.rs @@ -8,8 +8,9 @@ use core::cell::{Cell, RefCell}; use core::ffi::c_void; use crate as jsc; +use crate::strong::Optional as StrongOptional; use crate::virtual_machine::VirtualMachine; -use crate::{EventType, JSGlobalObject, JSPromise, JSValue, JsResult, ZigString}; +use crate::{CallFrame, EventType, JSGlobalObject, JSPromise, JSValue, JsResult, ZigString}; use bun_collections::HashMap; use bun_core::{Output, StackCheck}; use bun_core::{OwnedString, String as BunString, strings}; @@ -98,6 +99,14 @@ pub struct ConsoleObject { counts: Counter, + /// When set, formatted console output is written to this JS Writable's + /// `.write()` instead of the fd-backed `writer_backing`. Used by + /// `node:worker_threads` so console output flows through the worker's + /// port-backed `process.stdout`/`stderr` without replacing the global + /// console object. + stdout_override: StrongOptional, + stderr_override: StrongOptional, + // The writer adapters above hold raw pointers into `{stderr,stdout}_buffer`; // moving the struct would dangle them. `PhantomPinned` opts out of `Unpin` // so `Pin>` (returned by `init`) actually enforces that. @@ -138,6 +147,8 @@ impl ConsoleObject { writer_backing: Output::QuietWriterAdapter::uninit(), default_indent: 0, counts: Counter::default(), + stdout_override: StrongOptional::empty(), + stderr_override: StrongOptional::empty(), _pin: core::marker::PhantomPinned, }); let p: *mut ConsoleObject = &raw mut *out; @@ -173,6 +184,8 @@ impl ConsoleObject { writer_backing: Output::QuietWriterAdapter::uninit(), default_indent: 0, counts: Counter::default(), + stdout_override: StrongOptional::empty(), + stderr_override: StrongOptional::empty(), _pin: core::marker::PhantomPinned, }); let p: *mut ConsoleObject = out; @@ -202,6 +215,63 @@ impl ConsoleObject { pub fn writer(&mut self) -> &mut bun_core::io::Writer { self.writer_backing.new_interface() } + + #[inline] + fn override_for(&self, use_stderr: bool) -> Option { + if use_stderr { + self.stderr_override.get() + } else { + self.stdout_override.get() + } + } + + /// Release the JS stream overrides. Must run while the JSC VM is still + /// alive; the `StrongOptional` Drop path would otherwise touch a freed + /// `HandleSet` when the `ConsoleObject` box is destroyed after VM teardown. + pub fn clear_output_streams(&mut self) { + self.stdout_override.deinit(); + self.stderr_override.deinit(); + } +} + +/// Hand the buffered bytes to a JS Writable stream's `.write()`. A stream +/// whose `write` is not callable is a programmer error in the binding, not a +/// user-visible condition, so fall through silently rather than throw. +fn write_to_js_stream(global: &JSGlobalObject, stream: JSValue, buf: &[u8]) -> JsResult<()> { + if buf.is_empty() { + return Ok(()); + } + let Some(write_fn) = stream.get(global, b"write")? else { + return Ok(()); + }; + if !write_fn.is_callable() { + return Ok(()); + } + let str = crate::bun_string_jsc::create_utf8_for_js(global, buf)?; + let _ = write_fn.call(global, stream, core::slice::from_ref(&str))?; + Ok(()) +} + +/// `$newRustFunction("ConsoleObject.rs", "setOutputStreams", 2)`. +/// Rebinds this VM's native console output sink to a pair of JS Writable +/// streams so formatting and the Bun-specific console surface are preserved +/// while output still flows through `process.stdout`/`stderr`. +pub fn set_output_streams(global: &JSGlobalObject, callframe: &CallFrame) -> JsResult { + let [stdout, stderr] = callframe.arguments_as_array::<2>(); + // SAFETY: top-level JS-thread host call ⇒ exclusive access to the + // set-once `VirtualMachine.console` box. + let this = unsafe { vm_console_mut(global) }; + if stdout.is_object() { + this.stdout_override.set(global, stdout); + } else { + this.stdout_override.deinit(); + } + if stderr.is_object() { + this.stderr_override.set(global, stderr); + } else { + this.stderr_override.deinit(); + } + Ok(JSValue::UNDEFINED) } #[repr(u32)] @@ -455,6 +525,42 @@ fn message_with_type_and_level_( // time. We do this the slightly annoying way to avoid assigning a pointer. let use_stderr = matches!(level, MessageLevel::Warning | MessageLevel::Error) || message_type == MessageType::Assert; + + // Snapshot before borrowing the writer; `default_indent` is not mutated + // again until the deferred `_indent_guard` runs on scope exit, so the two + // later reads (FormatOptions / TablePrinter) can use this cached copy + // instead of re-dereferencing the raw `console` pointer. + // SAFETY: see [`vm_console`] — single-JS-thread; no other `&mut` is live. + let (default_indent, override_stream) = { + let c = unsafe { vm_console_mut(global) }; + (c.default_indent, c.override_for(use_stderr)) + }; + + if let Some(stream) = override_stream { + // The override sink is a JS Writable (port-backed `process.stdout` in a + // `node:worker_threads` worker). It is not a shared fd, so the stream + // lock is unnecessary; and it is never a TTY, so colors are off. + if message_type == MessageType::Clear { + return Ok(()); + } + let mut buf: Vec = Vec::new(); + if message_type == MessageType::Assert && len == 0 { + buf.extend_from_slice(b"Assertion failed\n"); + } else { + write_message_body( + &mut buf, + message_type, + level, + global, + vals, + len, + false, + default_indent, + )?; + } + return write_to_js_stream(global, stream, &buf); + } + let _stream_lock = ConsoleStreamLock::acquire(use_stderr); if message_type == MessageType::Clear { @@ -483,13 +589,6 @@ fn message_with_type_and_level_( Output::enable_ansi_colors_stdout() }; - // Snapshot before borrowing the writer; `default_indent` is not mutated - // again until the deferred `_indent_guard` runs on scope exit, so the two - // later reads (FormatOptions / TablePrinter) can use this cached copy - // instead of re-dereferencing the raw `console` pointer. - // SAFETY: see [`vm_console`] — single-JS-thread; no other `&mut` is live. - let default_indent = unsafe { vm_console_mut(global) }.default_indent; - // SAFETY: see [`vm_console`] — `console` points at the live boxed // `ConsoleObject` for this VM; JS-thread-only. Kept as a raw deref (not // `vm_console_mut`) so the resulting `writer` borrow does not pin a @@ -513,6 +612,29 @@ fn message_with_type_and_level_( (hooks.console_on_before_print)(); } + write_message_body( + writer, + message_type, + level, + global, + vals, + len, + enable_colors, + default_indent, + ) +} + +#[allow(clippy::too_many_arguments)] +fn write_message_body( + writer: &mut dyn bun_io::Write, + message_type: MessageType, + level: MessageLevel, + global: &JSGlobalObject, + vals: *const JSValue, + len: usize, + enable_colors: bool, + default_indent: u16, +) -> JsResult<()> { let mut print_length = len; // Get console depth from CLI options or bunfig, fallback to default. let console_depth = bun_options_types::context::try_get() @@ -587,12 +709,8 @@ fn message_with_type_and_level_( print_options, )?; } else if message_type == MessageType::Log { - // SAFETY: see [`vm_console`]. `writer` (above) is dead in this arm — - // the only later uses are in the mutually-exclusive `Trace` block, and - // `message_type == Log` here. - let w = unsafe { (*console).writer() }; - let _ = w.write_all(b"\n"); - let _ = w.flush(); + let _ = writer.write_all(b"\n"); + let _ = writer.flush(); } else if message_type != MessageType::Trace { let _ = writer.write_all(b"undefined\n"); } @@ -5863,21 +5981,35 @@ pub extern "C" fn Bun__ConsoleObject__count( ptr: *const u8, len: usize, ) { - // SAFETY: top-level JS-thread host call ⇒ exclusive access to the - // set-once `VirtualMachine.console` box. - let this = unsafe { vm_console_mut(global_this) }; // SAFETY: caller passes a valid (ptr, len) pair. let slice = unsafe { bun_core::ffi::slice(ptr, len) }; let hash = bun_wyhash::hash(slice); - // we don't want to store these strings, it will take too much memory - let counter = this.counts.get_or_put(hash).expect("unreachable"); - let current: u32 = if counter.found_existing { - *counter.value_ptr - } else { - 0 - } + 1; - *counter.value_ptr = current; + // SAFETY: top-level JS-thread host call ⇒ exclusive access to the + // set-once `VirtualMachine.console` box. The borrow is scoped so it ends + // before the override path calls back into JS (which may re-enter here). + let (current, override_stream) = { + let this = unsafe { vm_console_mut(global_this) }; + // we don't want to store these strings, it will take too much memory + let counter = this.counts.get_or_put(hash).expect("unreachable"); + let current: u32 = if counter.found_existing { + *counter.value_ptr + } else { + 0 + } + 1; + *counter.value_ptr = current; + (current, this.stdout_override.get()) + }; + if let Some(stream) = override_stream { + let mut buf: Vec = Vec::new(); + let w: &mut dyn bun_io::Write = &mut buf; + let _ = writeln!(w, "{}: {}", bstr::BStr::new(slice), current); + let _ = write_to_js_stream(global_this, stream, &buf); + return; + } + + // SAFETY: fd path; no JS re-entry below. + let this = unsafe { vm_console_mut(global_this) }; let writer = this.writer(); if Output::enable_ansi_colors_stdout() { let _ = writeln!( diff --git a/src/jsc/web_worker.rs b/src/jsc/web_worker.rs index 28301a11f122..e34406f96000 100644 --- a/src/jsc/web_worker.rs +++ b/src/jsc/web_worker.rs @@ -1297,6 +1297,13 @@ impl WebWorker { // worker VM is dealloc'd-without-Drop so anything still in // self.tasks leaks. Mirrors the global_exit() ordering. vm.event_loop_mut().release_queued_tasks_for_shutdown(); + // The console's JS stream overrides hold Strong handles into this + // VM's heap; release them while JSC is still alive so the + // ConsoleObject box (freed in step 5) doesn't touch a dead HandleSet. + if !vm.console.is_null() { + // SAFETY: set-once per-VM box; sole owner. + unsafe { (*vm.console).clear_output_streams() }; + } exit_code = i32::from(vm.exit_handler.exit_code); global_object = Some(vm.global); } diff --git a/src/runtime/dispatch_js2native.rs b/src/runtime/dispatch_js2native.rs index 500fe8b5bbae..9eb28a1885fb 100644 --- a/src/runtime/dispatch_js2native.rs +++ b/src/runtime/dispatch_js2native.rs @@ -37,6 +37,7 @@ pub use bun_install_jsc::ini_jsc::ini_testing_load_npmrc_from_js as ini_ini_ini_ pub use bun_install_jsc::ini_jsc::ini_testing_parse as ini_ini_ini_testing_ap_is_parse; pub use bun_jsc::bindgen_test::get_bindgen_test_functions as jsc_bindgen_test_get_bindgen_test_functions; +pub use bun_jsc::console_object::set_output_streams as jsc_console_object_set_output_streams; pub use bun_jsc::counters::create_counters_object as jsc_counters_create_counters_object; pub use bun_jsc::event_loop::get_active_tasks as jsc_event_loop_get_active_tasks; pub use bun_jsc::virtual_machine_exports::Bun__setSyntheticAllocationLimitForTesting as jsc_virtual_machine_exports_bun__set_synthetic_allocation_limit_for_testing; diff --git a/test/js/node/worker_threads/worker-console-bun-api.test.ts b/test/js/node/worker_threads/worker-console-bun-api.test.ts new file mode 100644 index 000000000000..d1e3e50ddb32 --- /dev/null +++ b/test/js/node/worker_threads/worker-console-bun-api.test.ts @@ -0,0 +1,124 @@ +import { test, expect, describe } from "bun:test"; +import { bunEnv, bunExe, tempDir } from "harness"; + +// node:worker_threads used to replace the worker's global console wholesale +// with a node:console instance. That loses Bun's documented console APIs +// (console.write, console[Symbol.asyncIterator]) and switches formatting from +// Bun.inspect to Node's util.inspect, so a worker and the main thread of the +// same process printed different shapes for the same value. + +describe.concurrent("node:worker_threads console", () => { + test("keeps Bun console APIs and formatting inside workers", async () => { + using dir = tempDir("worker-console-api", { + "main.js": ` + const { Worker, isMainThread } = require("node:worker_threads"); + function report(tag) { + console.log(tag + ".write:", typeof console.write); + console.log(tag + ".asyncIterator:", typeof console[Symbol.asyncIterator]); + console.log(tag + ".Console:", typeof console.Console); + console.log(tag + ".map:", new Map([["k", "v"]])); + console.log(tag + ".deep:", { l1: { l2: { l3: { l4: 1 } } } }); + console.log(tag + ".bun.inspect:", JSON.stringify(Bun.inspect(new Map([["k","v"]])))); + } + if (isMainThread) { + report("main"); + const w = new Worker(__filename); + w.on("exit", code => { if (code !== 0) process.exitCode = 1; }); + } else { + report("worker"); + } + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "main.js"], + env: { ...bunEnv, NO_COLOR: "1" }, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + const lines = stdout.split("\n"); + const get = (prefix: string) => + lines + .filter(l => l.startsWith(prefix)) + .join("\n") + .slice(prefix.length); + + // Bun-specific console APIs are present inside the worker. + expect({ + write: get("worker.write: "), + asyncIterator: get("worker.asyncIterator: "), + Console: get("worker.Console: "), + }).toEqual({ write: "function", asyncIterator: "function", Console: "function" }); + + // Formatting matches the main thread (same process, same Bun.inspect backend). + expect(get("worker.map: ")).toBe(get("main.map: ")); + expect(get("worker.deep: ")).toBe(get("main.deep: ")); + expect(get("worker.bun.inspect: ")).toBe(get("main.bun.inspect: ")); + + // Guard against Node util.inspect formatting leaking in. + expect(stdout).not.toContain("=>"); + expect(stdout).not.toContain("[Object]"); + + expect({ stderr, exitCode }).toEqual({ stderr: "", exitCode: 0 }); + }); + + test("console.log is still captured by worker.stdout when { stdout: true }", async () => { + using dir = tempDir("worker-console-capture", { + "main.js": ` + const { Worker, isMainThread } = require("node:worker_threads"); + if (isMainThread) { + const w = new Worker(__filename, { stdout: true, stderr: true }); + let out = "", err = ""; + w.stdout.setEncoding("utf8").on("data", d => { out += d; }); + w.stderr.setEncoding("utf8").on("data", d => { err += d; }); + w.stdout.on("end", () => { + process.stdout.write("CAPTURED:" + JSON.stringify({ out, err })); + }); + } else { + console.log("via-console", new Map([["k","v"]])); + console.error("via-error"); + process.stdout.write("via-process\\n"); + } + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "main.js"], + env: { ...bunEnv, NO_COLOR: "1" }, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout.startsWith("CAPTURED:")).toBe(true); + const { out, err } = JSON.parse(stdout.slice("CAPTURED:".length)); + // Both console.log and process.stdout.write were routed through worker.stdout. + expect(out).toContain("via-console"); + expect(out).toContain("via-process"); + expect(err).toContain("via-error"); + // Bun formatting, not Node's `'k' => 'v'`. + expect(out).not.toContain("=>"); + + expect({ stderr, exitCode }).toEqual({ stderr: "", exitCode: 0 }); + }); + + test("web Worker global console is unaffected", async () => { + const src = ` + const w = new Worker("data:text/javascript," + encodeURIComponent( + 'postMessage({ write: typeof console.write, ai: typeof console[Symbol.asyncIterator] })' + )); + w.onmessage = e => { console.log(JSON.stringify(e.data)); w.terminate(); }; + `; + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", src], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(JSON.parse(stdout.trim())).toEqual({ write: "function", ai: "function" }); + expect({ stderr, exitCode }).toEqual({ stderr: "", exitCode: 0 }); + }); +}); From d3056fe01809c6c166d92eff68e7dd19a6bee57e Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 16 Jul 2026 09:07:41 +0000 Subject: [PATCH 2/6] [autofix.ci] apply automated fixes --- test/js/node/worker_threads/worker-console-bun-api.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/js/node/worker_threads/worker-console-bun-api.test.ts b/test/js/node/worker_threads/worker-console-bun-api.test.ts index d1e3e50ddb32..e6b7a940eede 100644 --- a/test/js/node/worker_threads/worker-console-bun-api.test.ts +++ b/test/js/node/worker_threads/worker-console-bun-api.test.ts @@ -1,4 +1,4 @@ -import { test, expect, describe } from "bun:test"; +import { describe, expect, test } from "bun:test"; import { bunEnv, bunExe, tempDir } from "harness"; // node:worker_threads used to replace the worker's global console wholesale From 5348bde52309f40d27b039d9bb881d1859cacca1 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 16 Jul 2026 09:22:06 +0000 Subject: [PATCH 3/6] route console.timeEnd/timeLog through the output stream override; fix clippy safety-comment placement --- src/jsc/ConsoleObject.rs | 88 +++++++++++++++---- .../worker-console-bun-api.test.ts | 8 ++ 2 files changed, 79 insertions(+), 17 deletions(-) diff --git a/src/jsc/ConsoleObject.rs b/src/jsc/ConsoleObject.rs index 036cf9b22151..6c0503390053 100644 --- a/src/jsc/ConsoleObject.rs +++ b/src/jsc/ConsoleObject.rs @@ -530,8 +530,8 @@ fn message_with_type_and_level_( // again until the deferred `_indent_guard` runs on scope exit, so the two // later reads (FormatOptions / TablePrinter) can use this cached copy // instead of re-dereferencing the raw `console` pointer. - // SAFETY: see [`vm_console`] — single-JS-thread; no other `&mut` is live. let (default_indent, override_stream) = { + // SAFETY: see [`vm_console`] — single-JS-thread; no other `&mut` is live. let c = unsafe { vm_console_mut(global) }; (c.default_indent, c.override_for(use_stderr)) }; @@ -5984,10 +5984,10 @@ pub extern "C" fn Bun__ConsoleObject__count( // SAFETY: caller passes a valid (ptr, len) pair. let slice = unsafe { bun_core::ffi::slice(ptr, len) }; let hash = bun_wyhash::hash(slice); - // SAFETY: top-level JS-thread host call ⇒ exclusive access to the - // set-once `VirtualMachine.console` box. The borrow is scoped so it ends - // before the override path calls back into JS (which may re-enter here). let (current, override_stream) = { + // SAFETY: top-level JS-thread host call ⇒ exclusive access to the + // set-once `VirtualMachine.console` box. Scoped so the borrow ends + // before the override path calls back into JS (which may re-enter). let this = unsafe { vm_console_mut(global_this) }; // we don't want to store these strings, it will take too much memory let counter = this.counts.get_or_put(hash).expect("unreachable"); @@ -6054,6 +6054,17 @@ thread_local! { static PENDING_TIME_LOGS_LOADED: Cell = const { Cell::new(false) }; } +/// `Output::print_elapsed`'s `[X.XXms]`/`[X.XXs]` shape, rendered into a +/// writer without ANSI so `timeEnd`/`timeLog` match the main thread when +/// routed through a JS stream override. +fn write_elapsed(w: &mut dyn bun_io::Write, elapsed_ms: f64) { + if (elapsed_ms.round() as i64) <= 1500 { + let _ = write!(w, "[{:>.2}ms]", elapsed_ms); + } else { + let _ = write!(w, "[{:>.2}s]", elapsed_ms / 1000.0); + } +} + #[unsafe(no_mangle)] #[crate::host_call] pub extern "C" fn Bun__ConsoleObject__time( @@ -6081,7 +6092,7 @@ pub extern "C" fn Bun__ConsoleObject__time( #[crate::host_call] pub extern "C" fn Bun__ConsoleObject__timeEnd( _console: *mut ConsoleObject, - _global: &JSGlobalObject, + global: &JSGlobalObject, chars: *const u8, len: usize, ) { @@ -6099,9 +6110,27 @@ pub extern "C" fn Bun__ConsoleObject__timeEnd( }; let Some(value) = prev else { return }; // get the duration in microseconds, then display it in milliseconds - Output::print_elapsed( - (value.read() / bun_core::time::NS_PER_US) as f64 / bun_core::time::US_PER_MS as f64, - ); + let elapsed_ms = + (value.read() / bun_core::time::NS_PER_US) as f64 / bun_core::time::US_PER_MS as f64; + + // SAFETY: see [`vm_console`] — single-JS-thread; no other `&mut` is live. + if let Some(stream) = unsafe { vm_console_mut(global) }.stderr_override.get() { + let mut buf: Vec = Vec::new(); + write_elapsed(&mut buf, elapsed_ms); + let w: &mut dyn bun_io::Write = &mut buf; + match len { + 0 => { + let _ = writeln!(w); + } + _ => { + let _ = writeln!(w, " {}", bstr::BStr::new(slice)); + } + } + let _ = write_to_js_stream(global, stream, &buf); + return; + } + + Output::print_elapsed(elapsed_ms); match len { 0 => Output::print_errorln(format_args!("")), _ => Output::print_errorln(format_args!(" {}", bstr::BStr::new(slice))), @@ -6131,16 +6160,12 @@ pub extern "C" fn Bun__ConsoleObject__timeLog( return; }; // get the duration in microseconds, then display it in milliseconds - Output::print_elapsed( - (value.read() / bun_core::time::NS_PER_US) as f64 / bun_core::time::US_PER_MS as f64, - ); - match len { - 0 => {} - _ => Output::print_error(format_args!(" {}", bstr::BStr::new(slice))), - } - Output::flush(); + let elapsed_ms = + (value.read() / bun_core::time::NS_PER_US) as f64 / bun_core::time::US_PER_MS as f64; + + // SAFETY: see [`vm_console`] — single-JS-thread; no other `&mut` is live. + let override_stream = unsafe { vm_console_mut(global) }.stderr_override.get(); - // print the arguments // `Formatter` has a `Drop` impl, so struct-update from a // temporary is rejected (E0509). Construct via `new()` then mutate. let mut fmt = Formatter::new(global); @@ -6149,6 +6174,35 @@ pub extern "C" fn Bun__ConsoleObject__timeLog( .unwrap_or(DEFAULT_CONSOLE_LOG_DEPTH); fmt.stack_check = StackCheck::init(); fmt.can_throw_stack_overflow = true; + + if let Some(stream) = override_stream { + let mut buf: Vec = Vec::new(); + write_elapsed(&mut buf, elapsed_ms); + let w: &mut dyn bun_io::Write = &mut buf; + if len > 0 { + let _ = write!(w, " {}", bstr::BStr::new(slice)); + } + // SAFETY: caller passes a valid (args, args_len) pair. + for &arg in unsafe { bun_core::ffi::slice(args, args_len) } { + let Ok(tag) = formatter::Tag::get(arg, global) else { + return; + }; + let _ = w.write_all(b" "); + let _ = fmt.format::(tag, w, arg, global); + } + let _ = w.write_all(b"\n"); + let _ = write_to_js_stream(global, stream, &buf); + return; + } + + Output::print_elapsed(elapsed_ms); + match len { + 0 => {} + _ => Output::print_error(format_args!(" {}", bstr::BStr::new(slice))), + } + Output::flush(); + + // print the arguments let console = vm_console(global); // SAFETY: see [`vm_console`] — points at the live boxed `ConsoleObject` for // this VM; JS-thread-only. Kept as a raw deref (not `vm_console_mut`) so the diff --git a/test/js/node/worker_threads/worker-console-bun-api.test.ts b/test/js/node/worker_threads/worker-console-bun-api.test.ts index e6b7a940eede..4dfd0e14b35f 100644 --- a/test/js/node/worker_threads/worker-console-bun-api.test.ts +++ b/test/js/node/worker_threads/worker-console-bun-api.test.ts @@ -79,6 +79,10 @@ describe.concurrent("node:worker_threads console", () => { } else { console.log("via-console", new Map([["k","v"]])); console.error("via-error"); + console.count("cnt"); + console.time("tmr"); + console.timeLog("tmr", "extra"); + console.timeEnd("tmr"); process.stdout.write("via-process\\n"); } `, @@ -97,7 +101,11 @@ describe.concurrent("node:worker_threads console", () => { // Both console.log and process.stdout.write were routed through worker.stdout. expect(out).toContain("via-console"); expect(out).toContain("via-process"); + expect(out).toContain("cnt: 1"); expect(err).toContain("via-error"); + // timeEnd/timeLog are captured on worker.stderr, not leaked to the parent fd. + expect(err).toMatch(/\[[\d.]+ms\] tmr extra\n/); + expect(err).toMatch(/\[[\d.]+ms\] tmr\n/); // Bun formatting, not Node's `'k' => 'v'`. expect(out).not.toContain("=>"); From e85b532a743fc2a4a52e011a7fd05fdb4641506e Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 16 Jul 2026 09:47:55 +0000 Subject: [PATCH 4/6] thread enable_colors into write_trace so captured console.trace is colorless; clarify worker stdio comment --- src/js/node/worker_threads.ts | 8 +++++--- src/jsc/ConsoleObject.rs | 6 +++--- src/runtime/server/RequestContext.rs | 6 +++++- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/src/js/node/worker_threads.ts b/src/js/node/worker_threads.ts index f8bea87a9c6f..18244f420935 100644 --- a/src/js/node/worker_threads.ts +++ b/src/js/node/worker_threads.ts @@ -470,9 +470,11 @@ function setupWorkerStdio(stdio) { }); // node routes console.log through process.stdout/stderr; Bun's global console // writes the fd directly. Rebind its output sink to the port-backed streams - // so console output is captured by worker.stdout/stderr, while keeping Bun's - // formatter and the Bun-specific console surface (console.write, - // Symbol.asyncIterator) intact. + // so console.log/warn/error/trace/count/time* are captured by + // worker.stdout/stderr, while keeping Bun's formatter and the global console + // object itself (so console.write, Symbol.asyncIterator, console.Console + // remain reachable). Those Bun-specific APIs address the process fds by + // design and are not routed through the port. if (stdout || stderr) { $newRustFunction("ConsoleObject.rs", "setOutputStreams", 2)(process.stdout, process.stderr); } diff --git a/src/jsc/ConsoleObject.rs b/src/jsc/ConsoleObject.rs index 6c0503390053..36a0f4708d4e 100644 --- a/src/jsc/ConsoleObject.rs +++ b/src/jsc/ConsoleObject.rs @@ -716,7 +716,7 @@ fn write_message_body( } if message_type == MessageType::Trace { - write_trace(writer, global); + write_trace(writer, global, enable_colors); let _ = writer.flush(); } @@ -1322,7 +1322,7 @@ impl<'a> DynWriteAdapter<'a> { } } -pub fn write_trace(writer: &mut dyn bun_io::Write, global: &JSGlobalObject) { +pub fn write_trace(writer: &mut dyn bun_io::Write, global: &JSGlobalObject, enable_colors: bool) { let mut holder = crate::zig_exception::Holder::init(); // SAFETY: per-thread VM; `console.trace()` only runs on the JS thread. let vm = VirtualMachine::get().as_mut(); @@ -1352,7 +1352,7 @@ pub fn write_trace(writer: &mut dyn bun_io::Write, global: &JSGlobalObject) { let _ = VirtualMachine::print_stack_trace( adapter.interface(), &holder.zig_exception().stack, - Output::enable_ansi_colors_stderr(), + enable_colors, ); // `ZigStringSlice` frees on `Drop`. diff --git a/src/runtime/server/RequestContext.rs b/src/runtime/server/RequestContext.rs index 43a8b24f750a..9722323ca677 100644 --- a/src/runtime/server/RequestContext.rs +++ b/src/runtime/server/RequestContext.rs @@ -715,7 +715,11 @@ where Output::flush(); if !global_this.has_exception() { - jsc::ConsoleObject::write_trace(writer, global_this); + jsc::ConsoleObject::write_trace( + writer, + global_this, + Output::enable_ansi_colors_stderr(), + ); } Output::flush(); } From e9a400f440eecffb1277eb8f1f824a8329f4d1e3 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 16 Jul 2026 09:54:23 +0000 Subject: [PATCH 5/6] only install console override for streams actually captured; wait for both worker streams in test --- src/js/node/worker_threads.ts | 5 ++++- .../node/worker_threads/worker-console-bun-api.test.ts | 9 ++++++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/js/node/worker_threads.ts b/src/js/node/worker_threads.ts index 18244f420935..3b76673d282d 100644 --- a/src/js/node/worker_threads.ts +++ b/src/js/node/worker_threads.ts @@ -476,7 +476,10 @@ function setupWorkerStdio(stdio) { // remain reachable). Those Bun-specific APIs address the process fds by // design and are not routed through the port. if (stdout || stderr) { - $newRustFunction("ConsoleObject.rs", "setOutputStreams", 2)(process.stdout, process.stderr); + $newRustFunction("ConsoleObject.rs", "setOutputStreams", 2)( + stdout ? process.stdout : undefined, + stderr ? process.stderr : undefined, + ); } } diff --git a/test/js/node/worker_threads/worker-console-bun-api.test.ts b/test/js/node/worker_threads/worker-console-bun-api.test.ts index 4dfd0e14b35f..4895f071c3b5 100644 --- a/test/js/node/worker_threads/worker-console-bun-api.test.ts +++ b/test/js/node/worker_threads/worker-console-bun-api.test.ts @@ -73,9 +73,12 @@ describe.concurrent("node:worker_threads console", () => { let out = "", err = ""; w.stdout.setEncoding("utf8").on("data", d => { out += d; }); w.stderr.setEncoding("utf8").on("data", d => { err += d; }); - w.stdout.on("end", () => { - process.stdout.write("CAPTURED:" + JSON.stringify({ out, err })); - }); + let done = 0; + const finish = () => { + if (++done === 2) process.stdout.write("CAPTURED:" + JSON.stringify({ out, err })); + }; + w.stdout.on("end", finish); + w.stderr.on("end", finish); } else { console.log("via-console", new Map([["k","v"]])); console.error("via-error"); From f07dd4c207f39868e42a5f93dd6f6fa132ba685c Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 16 Jul 2026 09:56:26 +0000 Subject: [PATCH 6/6] [autofix.ci] apply automated fixes --- src/js/node/worker_threads.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/js/node/worker_threads.ts b/src/js/node/worker_threads.ts index 3b76673d282d..bfd96c3db24c 100644 --- a/src/js/node/worker_threads.ts +++ b/src/js/node/worker_threads.ts @@ -476,10 +476,11 @@ function setupWorkerStdio(stdio) { // remain reachable). Those Bun-specific APIs address the process fds by // design and are not routed through the port. if (stdout || stderr) { - $newRustFunction("ConsoleObject.rs", "setOutputStreams", 2)( - stdout ? process.stdout : undefined, - stderr ? process.stderr : undefined, - ); + $newRustFunction( + "ConsoleObject.rs", + "setOutputStreams", + 2, + )(stdout ? process.stdout : undefined, stderr ? process.stderr : undefined); } }