From 90e0621021b377d65acad2e97e22f753a0843e78 Mon Sep 17 00:00:00 2001 From: Dylan Conway Date: Fri, 7 Aug 2026 04:21:44 -0700 Subject: [PATCH 01/26] console, process.stdout/stderr: one stdio sink per fd, console writes through the stream like Node MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit console.* and process.stdout / process.stderr now share a single per-thread FileSink per fd, and the global console delivers through the stream's write() whenever user code could observe it (patched write, console._stdout = x, corked/ended/backed-up stream) — Node's kWriteToConsole — while staying fully native otherwise. - FileSink stdio mode (create_stdio / stdio_sink_for / write_all_sync / drain_sync); Bun.stdout.writer(), Bun.file(1|2).writer(), console.write and Bun.write(Bun.stdout) are handles to the same sink; poll registered only while backed up; FIFO goes non-blocking only once a JS writer exists. - Console formats each message into a scratch buffer and delivers it once (spilling every 64 KiB on the native path); timeLog/timeEnd move to stdout with Node's format and warnings, trace to stderr, assert gets the "Assertion failed" prefix inline, clear() honours _stdout.isTTY. - process.stdout/stderr are honest Writables (no own write override): writableLength / needDrain / cork / 'drain' / EPIPE 'error' behave; chunks still buffered at exit are flushed; queued output is drained before exit, before fatal-error printing and before anything Bun prints to fd 1/2. - console._stdout/_stderr are lazy get/set accessors; process._rawDebug; diagnostics_channel console.* channels; _ignoreErrors. - EAGAIN never drops bytes: Output writer, non-pollable FileSink writes and fd copy loops poll and retry; O_NONBLOCK on stdio is snapshotted/restored at exit and on SIGINT/SIGTERM (never over an inherited SIG_IGN) and cleared on fds handed to children; open_for_writing no longer sets it on non-pollable fds. - worker_threads rebinds the native console to the port streams instead of replacing globalThis.console. Fixes #36419, fixes #21516, fixes #19952, fixes #12031, fixes #8036. --- docs/guides/write-file/stdout.mdx | 15 + src/bun_core/output.rs | 6 + src/codegen/generate-jssink.ts | 16 +- src/io/PipeWriter.rs | 149 ++- src/io/lib.rs | 2 + src/io/openForWriting.rs | 5 +- src/io/stdio_lock.rs | 74 ++ src/js/builtins/BunBuiltinNames.h | 3 + src/js/builtins/ConsoleObject.ts | 64 +- src/js/builtins/ProcessObjectInternals.ts | 156 ++- src/js/internal/fs/streams.ts | 122 ++- src/js/internal/streams/writable.ts | 14 + src/js/node/diagnostics_channel.ts | 25 + src/js/node/worker_threads.ts | 49 +- src/jsc/ConsoleObject.rs | 907 +++++++++++------- src/jsc/VirtualMachine.rs | 45 +- src/jsc/bindings/BunProcess.cpp | 304 +++++- src/jsc/bindings/BunProcess.h | 58 ++ src/jsc/bindings/ConsoleObject.cpp | 57 +- src/jsc/bindings/ZigGlobalObject.cpp | 49 +- src/jsc/bindings/c-bindings.cpp | 81 +- src/jsc/rare_data.rs | 30 + src/runtime/cli/test_command.rs | 7 + src/runtime/jsc_hooks.rs | 2 + src/runtime/node/node_fs.rs | 4 +- src/runtime/webcore/Blob.rs | 93 ++ src/runtime/webcore/FileSink.rs | 793 +++++++++++++-- src/runtime/webcore/blob/copy_file.rs | 14 +- src/spawn_sys/posix_spawn.rs | 15 + src/sys/lib.rs | 110 ++- src/sys/sys_uv.rs | 12 + test/js/node/console/console.test.ts | 294 +++++- test/js/node/process/process-stdio.test.ts | 460 ++++++++- .../node/test/parallel/test-console-clear.js | 24 + .../node/test/parallel/test-console-count.js | 65 ++ .../parallel/test-console-stdio-setters.js | 18 + .../test/parallel/test-process-raw-debug.js | 70 ++ .../worker_threads/worker_threads.test.ts | 58 ++ .../web/console/console-timeLog.expected.txt | 24 +- test/js/web/console/console-timeLog.test.ts | 44 +- test/js/web/workers/structured-clone.test.ts | 6 +- .../workers/structuredClone-classes.test.ts | 3 +- 42 files changed, 3649 insertions(+), 698 deletions(-) create mode 100644 src/io/stdio_lock.rs create mode 100644 test/js/node/test/parallel/test-console-clear.js create mode 100644 test/js/node/test/parallel/test-console-count.js create mode 100644 test/js/node/test/parallel/test-console-stdio-setters.js create mode 100644 test/js/node/test/parallel/test-process-raw-debug.js diff --git a/docs/guides/write-file/stdout.mdx b/docs/guides/write-file/stdout.mdx index 64937c9188c5..0513a3d1efc5 100644 --- a/docs/guides/write-file/stdout.mdx +++ b/docs/guides/write-file/stdout.mdx @@ -18,6 +18,21 @@ Bun also exposes `stdout` as a `BunFile` with the `Bun.stdout` property. Pass it await Bun.write(Bun.stdout, "Lorem ipsum"); ``` +For many small writes, `Bun.stdout.writer()` returns a buffered `FileSink` over the same destination. + +```ts +const writer = Bun.stdout.writer(); +writer.write("Lorem "); +writer.write("ipsum\n"); +writer.flush(); +``` + +--- + +`console.log`, `process.stdout.write()`, `Bun.write(Bun.stdout, ...)`, `Bun.stdout.writer()` and `console.write()` all share one output queue per thread, so their output comes out in the order the calls were made, however slowly the other end of a pipe reads. As in Node.js, `console.log` is a `write()` on `process.stdout` from the point of view of anything that replaces or wraps `process.stdout.write`. + +When the program exits — including through `process.exit()` or an uncaught exception — everything already written to `process.stdout` and `process.stderr` is flushed to the operating system first. (Node.js can truncate pending output to a slow pipe on `process.exit()`; Bun waits for it.) + --- See [`Bun.write()`](/runtime/file-io#writing-files-bun-write). diff --git a/src/bun_core/output.rs b/src/bun_core/output.rs index 3274e5c074cb..c058a6c995ce 100644 --- a/src/bun_core/output.rs +++ b/src/bun_core/output.rs @@ -2503,6 +2503,12 @@ impl ErrName for crate::CrateError { // ── ScopedDebugWriter ───────────────────────────────────────────────────── +/// True while this thread is inside a `scoped_log!` write (debug builds). +#[inline] +pub fn is_inside_scoped_log() -> bool { + crate::env::IS_DEBUG && scoped_debug_writer::DISABLE_INSIDE_LOG.get() > 0 +} + pub mod scoped_debug_writer { use super::*; diff --git a/src/codegen/generate-jssink.ts b/src/codegen/generate-jssink.ts index 5555d32e7e59..1299d70d6c3d 100644 --- a/src/codegen/generate-jssink.ts +++ b/src/codegen/generate-jssink.ts @@ -493,6 +493,7 @@ JSC_DEFINE_HOST_FUNCTION(${controller}__end, (JSC::JSGlobalObject * lexicalGloba } extern "C" JSC::EncodedJSValue ${name}__getInternalFd(void* sinkPtr); +${name === "FileSink" ? `extern "C" bool FileSink__isStdio(void* sinkPtr);` : ""} // TODO: how to make this a property callback. then, we can expose this as a documented field // It should not be shipped as a function call. @@ -532,7 +533,20 @@ JSC_DEFINE_HOST_FUNCTION(${name}__doClose, (JSC::JSGlobalObject * lexicalGlobalO if (ptr == nullptr) { return JSC::JSValue::encode(JSC::jsUndefined()); } - +${ + name === "FileSink" + ? ` + // The per-thread stdio sink is shared (process.stdout, Bun.stdout.writer(), + // console.*): closing one handle to it flushes, it does not take the + // wrapper away from the others. + if (FileSink__isStdio(ptr)) { + FileSink__close(lexicalGlobalObject, ptr); + RETURN_IF_EXCEPTION(scope, {}); + return JSC::JSValue::encode(JSC::jsUndefined()); + } +` + : "" +} sink->detach(); ${name}__close(lexicalGlobalObject, ptr); // detach() nulled m_sinkPtr so ~${className} won't finalize ptr; do the diff --git a/src/io/PipeWriter.rs b/src/io/PipeWriter.rs index d041e79edd57..0cf0c6e2f1de 100644 --- a/src/io/PipeWriter.rs +++ b/src/io/PipeWriter.rs @@ -73,9 +73,11 @@ pub trait PosixPipeWriter { FileType::File }; match ft { - FileType::NonblockingPipe | FileType::File => { - self.try_write_with_write_fn(buf, sys::write) - } + FileType::NonblockingPipe => self.try_write_with_write_fn(buf, sys::write), + // No poll backs this fd, so an `EAGAIN` (someone else made the + // description non-blocking) could never be resumed; behave as the + // blocking fd we asked for. + FileType::File => self.try_write_with_write_fn(buf, sys::write_retrying), FileType::Pipe => self.try_write_with_write_fn(buf, write_to_blocking_pipe), FileType::Socket => self.try_write_with_write_fn(buf, sys::send_non_block), } @@ -600,6 +602,12 @@ pub struct PosixStreamingWriter { pub is_done: bool, pub(crate) closed_without_reporting: bool, pub force_sync: bool, + /// Sub-`CHUNK_SIZE` writes are coalesced and normally also arm the poll, + /// so the next writable event flushes them. An owner that flushes at end + /// of tick itself (FileSink's `AutoFlusher` on the stdio sink, whose poll + /// is deliberately unregistered while idle) turns this off to save the + /// registration syscall per buffered write. + pub poll_flushes_buffer: bool, /// Last reported `WriteStatus == Pending` (i.e. write(2) returned EAGAIN). backed_up: core::cell::Cell, } @@ -613,6 +621,7 @@ impl Default for PosixStreamingWriter PosixStreamingWriter { self.handle.get_poll() } - pub(crate) fn get_fd(&self) -> Fd { + pub fn get_fd(&self) -> Fd { self.handle.get_fd() } @@ -742,6 +751,16 @@ impl PosixStreamingWriter { self.close(); } + /// A write the parent issued on this writer's fd itself (bypassing + /// `write()`) failed terminally; tear down exactly as if `write()` had hit + /// the error. + pub fn fail(&mut self, err: sys::Error) { + if self.is_done || self.closed_without_reporting { + return; + } + self._on_error(err); + } + fn _on_write(&mut self, written: usize, status: WriteStatus) { self.outgoing.wrote(written); @@ -800,6 +819,15 @@ impl PosixStreamingWriter { } pub fn write_utf16(&mut self, buf: &[u16]) -> WriteResult { + self.write_utf16_impl(buf, true) + } + + /// See [`write_now`](Self::write_now). + pub fn write_utf16_now(&mut self, buf: &[u16]) -> WriteResult { + self.write_utf16_impl(buf, false) + } + + fn write_utf16_impl(&mut self, buf: &[u16], may_buffer: bool) -> WriteResult { if self.is_done || self.closed_without_reporting { return WriteResult::Done(0); } @@ -812,16 +840,25 @@ impl PosixStreamingWriter { let buf_len = self.outgoing.size() - before_len; - self.maybe_write_newly_buffered_data(buf_len) + self.maybe_write_newly_buffered_data(buf_len, may_buffer) } pub fn write_latin1(&mut self, buf: &[u8]) -> WriteResult { + self.write_latin1_impl(buf, true) + } + + /// See [`write_now`](Self::write_now). + pub fn write_latin1_now(&mut self, buf: &[u8]) -> WriteResult { + self.write_latin1_impl(buf, false) + } + + fn write_latin1_impl(&mut self, buf: &[u8], may_buffer: bool) -> WriteResult { if self.is_done || self.closed_without_reporting { return WriteResult::Done(0); } if bun_core::strings::is_all_ascii(buf) { - return self.write(buf); + return self.write_impl(buf, may_buffer); } let before_len = self.outgoing.size(); @@ -833,15 +870,17 @@ impl PosixStreamingWriter { let buf_len = self.outgoing.size() - before_len; - self.maybe_write_newly_buffered_data(buf_len) + self.maybe_write_newly_buffered_data(buf_len, may_buffer) } - fn maybe_write_newly_buffered_data(&mut self, buf_len: usize) -> WriteResult { + fn maybe_write_newly_buffered_data(&mut self, buf_len: usize, may_buffer: bool) -> WriteResult { debug_assert!(!self.is_done); - if self.should_buffer(0) { + if may_buffer && self.should_buffer(0) { self.parent_on_write(buf_len, WriteStatus::Drained); - Self::register_poll(self); + if self.poll_flushes_buffer { + Self::register_poll(self); + } return WriteResult::Wrote(buf_len); } @@ -889,11 +928,22 @@ impl PosixStreamingWriter { } pub fn write(&mut self, buf: &[u8]) -> WriteResult { + self.write_impl(buf, true) + } + + /// `write` that never coalesces: the syscall is attempted now (anything + /// already queued goes first), the remainder queued on EAGAIN / short + /// write. What a stream's `_write` wants, as opposed to a batching writer. + pub fn write_now(&mut self, buf: &[u8]) -> WriteResult { + self.write_impl(buf, false) + } + + fn write_impl(&mut self, buf: &[u8], may_buffer: bool) -> WriteResult { if self.is_done || self.closed_without_reporting { return WriteResult::Done(0); } - if self.should_buffer(buf.len()) { + if may_buffer && self.should_buffer(buf.len()) { // this is streaming, but we buffer the data below `chunk_size` to // reduce the number of writes if self.outgoing.write(buf).is_err() { @@ -903,7 +953,9 @@ impl PosixStreamingWriter { // noop, but need this to have a chance // to register deferred tasks (onAutoFlush) self.parent_on_write(buf.len(), WriteStatus::Drained); - Self::register_poll(self); + if self.poll_flushes_buffer { + Self::register_poll(self); + } // it's buffered, but should be reported as written to // callers @@ -977,6 +1029,9 @@ impl PosixStreamingWriter { self.outgoing.wrote(written); if self.outgoing.is_empty() { self.outgoing.reset(); + } else { + // Backed up: the writable event is what drains the rest. + Self::register_poll(self); } } WriteResult::Wrote(written) => { @@ -1048,21 +1103,9 @@ impl PosixStreamingWriter { return sys::Result::Ok(()); } + let poll = self.ensure_poll(fd); // SAFETY: parent BACKREF set via set_parent; outlives this writer. let loop_ = unsafe { Parent::event_loop(self.parent()) }; - let poll = match self.get_poll() { - Some(p) => p, - None => { - let p = FilePollRef::init( - loop_, - fd, - Owner::new(Parent::POLL_OWNER_TAG, std::ptr::from_mut(self).cast()), - ); - self.handle = PollOrFd::Poll(p); - p - } - }; - match poll.register_with_fd(loop_.loop_(), FilePollKind::Writable, fd) { sys::Result::Err(err) => { return sys::Result::Err(err); @@ -1072,6 +1115,47 @@ impl PosixStreamingWriter { sys::Result::Ok(()) } + + /// `start` without registering the poll with the loop: the `FilePoll` + /// exists (so `FileType` and keep-alive work) but the kernel isn't asked + /// for writability until the first backpressure (`register_poll`). For a + /// long-lived writer whose fd is rarely full — an idle `EVFILT_WRITE` / + /// `EPOLLOUT` registration on a pipe makes every `write(2)` *and* the + /// reader's `read(2)` pay for knote/wakeup bookkeeping. Pair with + /// [`unregister_poll`](Self::unregister_poll) once drained. + pub fn start_lazy(&mut self, fd: Fd, is_pollable: bool) -> sys::Result<()> { + if !is_pollable { + return self.start(fd, false); + } + let _ = self.ensure_poll(fd); + sys::Result::Ok(()) + } + + /// Undo `register_poll` (keeps the `FilePoll`). No-op if not registered. + pub fn unregister_poll(&mut self) { + let Some(poll) = self.get_poll() else { return }; + if !poll.is_registered() { + return; + } + // SAFETY: parent BACKREF set via set_parent; outlives this writer. + let loop_ = unsafe { Parent::loop_(self.parent()) }.cast(); + let _ = poll.unregister(loop_, false); + } + + fn ensure_poll(&mut self, fd: Fd) -> FilePollRef { + if let Some(p) = self.get_poll() { + return p; + } + // SAFETY: parent BACKREF set via set_parent; outlives this writer. + let loop_ = unsafe { Parent::event_loop(self.parent()) }; + let p = FilePollRef::init( + loop_, + fd, + Owner::new(Parent::POLL_OWNER_TAG, std::ptr::from_mut(self).cast()), + ); + self.handle = PollOrFd::Poll(p); + p + } } impl Drop for PosixStreamingWriter { @@ -2457,6 +2541,21 @@ impl WindowsStreamingWriter { self.write_internal_u8(buffer, WriteKind::Bytes) } + /// Windows never coalesces (`uv_write` / `WriteFile` per call), so these + /// are the plain writes; see the posix `write_now`. + #[inline] + pub fn write_now(&mut self, buffer: &[u8]) -> WriteResult { + self.write(buffer) + } + #[inline] + pub fn write_latin1_now(&mut self, buffer: &[u8]) -> WriteResult { + self.write_latin1(buffer) + } + #[inline] + pub fn write_utf16_now(&mut self, buf: &[u16]) -> WriteResult { + self.write_utf16(buf) + } + pub fn flush(&mut self) -> WriteResult { if self.is_done { return WriteResult::Done(0); diff --git a/src/io/lib.rs b/src/io/lib.rs index b206fd57eb7c..68b2f6893021 100644 --- a/src/io/lib.rs +++ b/src/io/lib.rs @@ -482,8 +482,10 @@ pub mod pipes; #[cfg(windows)] #[path = "source.rs"] pub mod source; +pub mod stdio_lock; #[path = "write.rs"] pub mod write; +pub use stdio_lock::StdioLock; // ── re-exports for higher tiers ───────────────────────────────────────────── // Byte-level `Write` trait + helpers. Downstream diff --git a/src/io/openForWriting.rs b/src/io/openForWriting.rs index ac898e686bea..bd6c0d079b6e 100644 --- a/src/io/openForWriting.rs +++ b/src/io/openForWriting.rs @@ -147,7 +147,10 @@ where // this.force_sync = true; // this.writer.force_sync = true; on_force_sync_or_isa_tty(ctx); - } else if !is_nonblocking { + } else if !is_nonblocking && *pollable { + // O_NONBLOCK is meaningless on regular files / char devices + // and lives on the shared open file description, so only + // set it where it buys us EAGAIN semantics. let flags = match bun_sys::get_fcntl_flags(fd) { Ok(flags) => flags, Err(err) => { diff --git a/src/io/stdio_lock.rs b/src/io/stdio_lock.rs new file mode 100644 index 000000000000..0f7c83638a22 --- /dev/null +++ b/src/io/stdio_lock.rs @@ -0,0 +1,74 @@ +//! Process-wide, per-fd, reentrant lock around "one message to fd 1 / fd 2". +//! +//! Every JS thread has its own stdio sink for stdout and stderr (poll +//! registration, `'drain'` delivery and keep-alive are event-loop-affine), but +//! they all end at the same two file descriptors. Holding this lock for the +//! duration of one console message / one synchronous drain is what keeps a +//! worker's `console.log` from landing in the middle of the main thread's. +//! Reentrant per thread because formatting can re-enter (`console.log` inside +//! a getter that `console.log`s). + +use core::cell::Cell; + +use bun_sys::Fd; +use bun_threading::Mutex; + +static STDOUT_MUTEX: Mutex = Mutex::new(); +static STDERR_MUTEX: Mutex = Mutex::new(); + +thread_local! { + static DEPTH: [Cell; 2] = const { [Cell::new(0), Cell::new(0)] }; +} + +#[inline] +fn slot(fd: Fd) -> Option { + if fd == Fd::stdout() { + Some(0) + } else if fd == Fd::stderr() { + Some(1) + } else { + None + } +} + +#[inline] +fn mutex(slot: usize) -> &'static Mutex { + if slot == 0 { + &STDOUT_MUTEX + } else { + &STDERR_MUTEX + } +} + +/// RAII guard; no-op for fds other than 1 and 2. +pub struct StdioLock(Option); + +impl StdioLock { + #[inline] + pub fn acquire(fd: Fd) -> Self { + let slot = slot(fd); + if let Some(i) = slot { + DEPTH.with(|d| { + if d[i].get() == 0 { + mutex(i).lock(); + } + d[i].set(d[i].get() + 1); + }); + } + Self(slot) + } +} + +impl Drop for StdioLock { + #[inline] + fn drop(&mut self) { + if let Some(i) = self.0 { + DEPTH.with(|d| { + d[i].set(d[i].get() - 1); + if d[i].get() == 0 { + mutex(i).unlock(); + } + }); + } + } +} diff --git a/src/js/builtins/BunBuiltinNames.h b/src/js/builtins/BunBuiltinNames.h index c2efbaa08d25..d7fe2f46e53a 100644 --- a/src/js/builtins/BunBuiltinNames.h +++ b/src/js/builtins/BunBuiltinNames.h @@ -65,6 +65,7 @@ using namespace JSC; macro(close) \ macro(cmd) \ macro(code) \ + macro(consoleStream) \ macro(controller) \ macro(createCommonJSModule) \ macro(createFIFO) \ @@ -182,6 +183,8 @@ using namespace JSC; macro(statusCode) \ macro(statusMessage) \ macro(statusText) \ + macro(stderr) \ + macro(stdout) \ macro(stream) \ macro(syscall) \ macro(text) \ diff --git a/src/js/builtins/ConsoleObject.ts b/src/js/builtins/ConsoleObject.ts index 2ee129a587b8..3e2d36691735 100644 --- a/src/js/builtins/ConsoleObject.ts +++ b/src/js/builtins/ConsoleObject.ts @@ -118,16 +118,35 @@ export function asyncIterator(this: Console) { export function write(this: Console, input) { if (!$isObject(this)) throw $ERR_INVALID_THIS("Console"); + // Same routing as console.log: if the console's stdout is something user code + // can observe (a worker's port stream, console._stdout = x, a patched + // process.stdout.write, ...) go through its write(); otherwise the shared + // native stdout sink. + var consoleStream = $getByIdDirectPrivate(this, "consoleStream"); + if (!consoleStream) { + consoleStream = $newCppFunction("BunProcess.cpp", "jsFunctionConsoleStream", 1); + $putByIdDirectPrivate(this, "consoleStream", consoleStream); + } + const observed = consoleStream(1); + const count = $argumentCount(); + if (observed) { + var wrote = 0; + for (var i = 0; i < count; i++) { + const chunk = arguments[i]; + observed.write(chunk); + wrote += typeof chunk === "string" ? Buffer.byteLength(chunk) : $toLength(chunk?.byteLength ?? 0); + } + return wrote; + } + var writer = $getByIdDirectPrivate(this, "writer"); if (!writer) { - var length = $toLength(input?.length ?? 0); - writer = Bun.stdout.writer({ highWaterMark: length > 65536 ? length : 65536 }); + // The per-VM stdout sink, shared with console.log / process.stdout. + writer = Bun.stdout.writer(); $putByIdDirectPrivate(this, "writer", writer); } var wrote = writer.write(input); - - const count = $argumentCount(); for (var i = 1; i < count; i++) { wrote += writer.write(arguments[i]); } @@ -136,6 +155,43 @@ export function write(this: Console, input) { return wrote; } +// The global console's slow path: user code can observe the write (patched +// `process.stdout.write`, `console._stdout = other`, corked/ended stream), so +// deliver the already-formatted message through `stream.write()` exactly like +// Node's kWriteToConsole with `ignoreErrors: true`. +// https://github.com/nodejs/node/blob/v24.0.0/lib/internal/console/constructor.js#L280-L322 +$visibility = "Private"; +export function writeToObservedStream(stream, chunk: string) { + const noop = () => {}; + // There may be an error occurring synchronously (e.g. for files or TTYs + // on POSIX systems) or asynchronously (e.g. pipes on POSIX systems), so + // handle both situations. + const isEmitter = typeof stream?.listenerCount === "function" && typeof stream?.once === "function"; + try { + // Add and later remove a noop error handler to catch synchronous errors. + if (isEmitter && stream.listenerCount("error") === 0) { + stream.once("error", noop); + } + stream.write(chunk, err => { + // Errors that were not already emitted (async _write callback) surface + // as an 'error' event; a `once` noop keeps that from becoming an + // uncaught exception without swallowing it for other writers. + if (err != null && isEmitter && !stream._writableState?.errorEmitted) { + if (stream.listenerCount("error") === 0) { + stream.once("error", noop); + } + } + }); + } catch (e: any) { + // Console is a debugging utility, so it swallowing errors is not + // desirable even in edge cases such as low stack space. + if (e?.name === "RangeError" && e?.message === "Maximum call stack size exceeded.") throw e; + // Sorry, there's no proper way to pass along the error here. + } finally { + if (isEmitter) stream.removeListener("error", noop); + } +} + // This is the `console.Console` constructor. It is mostly copied from Node. // https://github.com/nodejs/node/blob/d2c7c367741bdcb6f7f77f55ce95a745f0b29fef/lib/internal/console/constructor.js // Some parts are copied from imported files and inlined here. Not too much of a performance issue diff --git a/src/js/builtins/ProcessObjectInternals.ts b/src/js/builtins/ProcessObjectInternals.ts index 37681a67f7c6..8f0f8dd0e754 100644 --- a/src/js/builtins/ProcessObjectInternals.ts +++ b/src/js/builtins/ProcessObjectInternals.ts @@ -38,6 +38,8 @@ export function getStdioWriteStream( ) { $assert(fd === 1 || fd === 2, `Expected fd to be 1 or 2, got ${fd}`); + // Both constructors below sit on the per-VM stdio FileSink for `fd` (via + // `Bun.file(fd).writer()`), the same object console.* writes through. let stream; if (isTTY) { const tty = require("node:tty"); @@ -65,54 +67,136 @@ export function getStdioWriteStream( // stdout/stderr don't produce readable data, so yield nothing })(); }; - } else { - // File-backed stdio: Node's SyncWriteStream runs end() -> finish -> - // destroy -> the _destroy override below -> _undestroy(), which resets - // writable state so later writes succeed. autoClose:false disabled that. - stream._writableState.autoDestroy = true; } } - if (fd === 1 || fd === 2) { - stream.destroySoon = stream.destroy; - stream._destroy = function (err, cb) { - cb(err); - this._undestroy(); + // Node's stdio streams (net.Socket / SyncWriteStream) run with autoDestroy: + // end() -> finish -> destroy -> the _destroy override below -> _undestroy() + // resets state so later writes succeed, and a failed write goes + // errorOrDestroy -> destroy -> _undestroy -> 'error' *every* time instead + // of latching on errorEmitted after the first. autoClose:false turned it off. + stream._writableState.autoDestroy = true; + + // Node terminates on the first unhandled 'error'; Bun keeps running after an + // uncaught exception, so a write loop on a dead pipe would otherwise print + // one uncaught EPIPE per write. Surface the first, drop the repeats. + let unhandledErrorSeen = false; + stream._destroy = function (err, cb) { + if (err && this.listenerCount("error") === 0) { + if (unhandledErrorSeen) err = null; + unhandledErrorSeen = true; + } + cb(err); + this._undestroy(); + updateObserved(this); - if (!this._writableState.emitClose) { - process.nextTick(() => { - this.emit("close"); - }); - } - }; + if (!this._writableState.emitClose) { + process.nextTick(() => { + this.emit("close"); + }); + } + }; - const kFastPath = require("internal/fs/streams").kWriteStreamFastPath; - stream._final = function (cb) { - try { - const sink = this[kFastPath]; - if (sink && sink !== true) { - const result = sink.flush(); - if ($isPromise(result)) { - result.then( - () => cb(null), - err => cb(err), - ); - return; - } + const { kWriteStreamFastPath, kOnPendingWrite } = require("internal/fs/streams"); + stream._final = function (cb) { + try { + const sink = this[kWriteStreamFastPath]; + if (sink && sink !== true) { + const result = sink.flush(); + if ($isPromise(result)) { + result.then( + () => cb(null), + err => cb(err), + ); + return; } - cb(null); - } catch (err) { - cb(err); } - }; + cb(null); + } catch (err) { + cb(err); + } + }; + + // console.* writes straight to the shared native sink unless doing so could + // be told apart from a `write()` call: while this Writable is corked, is + // ending/ended, or has a write in flight (later chunks queue in *its* + // buffer, and a native write would jump that queue). Keep the native side + // informed; it checks a bitfield, never these JS objects. + const setStdioObserved = $newCppFunction("BunProcess.cpp", "jsFunctionSetStdioObserved", 2); + let pendingWrite = false; + function updateObserved(stream) { + const state = stream._writableState; + setStdioObserved( + fd, + (state.corked ? 1 : 0) | (state.ending || state.ended || state.destroyed ? 2 : 0) | (pendingWrite ? 4 : 0), + ); } + stream[kOnPendingWrite] = function (pending) { + pendingWrite = pending; + updateObserved(this); + }; + const { cork, uncork, end, destroy } = stream; + stream.cork = function () { + cork.$call(this); + updateObserved(this); + }; + stream.uncork = function () { + uncork.$call(this); + updateObserved(this); + }; + stream.end = function (...args) { + const ret = end.$apply(this, args); + updateObserved(this); + return ret; + }; + stream.destroy = stream.destroySoon = function (...args) { + const ret = destroy.$apply(this, args); + updateObserved(this); + return ret; + }; stream._isStdio = true; stream.fd = fd; - const underlyingSink = stream[require("internal/fs/streams").kWriteStreamFastPath]; - $assert(underlyingSink); - return [stream, underlyingSink]; + $assert(stream[kWriteStreamFastPath], "stdio stream must be FileSink-backed"); + return stream; +} + +// A console.* write on the shared stdio sink failed (EPIPE etc.). Node's +// console swallows write errors, but the stream still emits 'error' for +// whoever listens; with nobody listening it must not become an uncaught +// exception (that is what console's once('error', noop) guard is for). +// https://github.com/nodejs/node/blob/v24.0.0/lib/internal/console/constructor.js#L380-L399 +export function reportStdioSinkError(stream, err) { + if (typeof stream?.listenerCount === "function" && stream.listenerCount("error") > 0) { + stream.destroy(err); + } +} + +// process.exit() / end of program: chunks this stdio Writable had queued in +// JS behind an in-flight write are handed to the native sink now, so the exit +// drain (FileSink::drain_sync) writes them instead of dropping them. Chunks +// held by an explicit, never-released cork() stay held. +export function flushStdioWriteStreamOnExit(stream) { + const state = stream?._writableState; + if (!state || state.corked) return; + const sink = stream[require("internal/fs/streams").kWriteStreamFastPath]; + if (!sink || sink === true) return; + const buffered = require("internal/streams/writable").takeBuffered(state); + for (let i = 0; i < buffered.length; i++) { + const { chunk, encoding } = buffered[i]; + // A failure here (rejected promise: the sink's latched EPIPE/EIO; throw: a + // closed sink) was already surfaced by the write ahead of these chunks; + // the exit must go on. + try { + const result = sink.write( + typeof chunk === "string" && encoding !== "utf8" && encoding !== "utf-8" && encoding !== "buffer" + ? Buffer.from(chunk, encoding) + : chunk, + ); + if ($isPromise(result)) result.then(undefined, () => {}); + } catch {} + } } export function getStdinStream( diff --git a/src/js/internal/fs/streams.ts b/src/js/internal/fs/streams.ts index dabec824b251..461a9516cfb5 100644 --- a/src/js/internal/fs/streams.ts +++ b/src/js/internal/fs/streams.ts @@ -468,8 +468,11 @@ function WriteStream(this: FSStream, path: string | null, options?: any): void { if (fastPath) { this[kWriteStreamFastPath] = fd != null ? fastWriter : true; this._write = underscoreWriteFast; - this._writev = undefined; - this.write = writeFast as any; + this._writev = underscoreWritevFast; + // The FileSink encodes strings straight into its own buffer, so skip the + // Buffer.from() round-trip. node's process.stdout/stderr (net.Socket) also + // run with decodeStrings: false. + options.decodeStrings = false; if (fd != null) { // Already-open fd (stdio): skip the async _construct round-trip so the // stream is born constructed, like node's stdio streams (net.Socket / @@ -587,13 +590,27 @@ function _write(data, encoding, cb) { } writeStreamPrototype._write = _write; -function underscoreWriteFast(this: FSStream, data: any, encoding: any, cb: any) { +// `_write` for FileSink-backed streams (process.stdout/stderr, tty.WriteStream, +// child.stdin). Hands the chunk to the fd now: completes synchronously when it +// all went out, so back-to-back writes don't pile up in the Writable buffer, +// and on the sink's promise when the fd is backed up — which is what keeps +// writableLength / writableNeedDrain / 'drain' honest. `kOnPendingWrite`, if the owner set one, +// hears about a write going async and settling (process.stdout uses it to +// keep console.* from overtaking chunks queued behind that write). +const kOnPendingWrite = Symbol("kOnPendingWrite"); +// `FileSink.prototype.write` coalesces small chunks until end of tick (right +// for a batching `Bun.file(fd).writer()`); a stream's `_write` wants the +// syscall now, with the same return contract. +const fileSinkWriteNow = $newRustFunction("runtime/webcore/FileSink.rs", "writeNow", 2); +function underscoreWriteFast(this: FSStream, chunk: any, encoding: any, cb: any) { let fileSink = this[kWriteStreamFastPath]; if (!fileSink) { // When the fast path is disabled, the write function gets reset. this._write = _write; - return this._write(data, encoding, cb); + return this._write(chunk, encoding, cb); } + + let maybePromise; try { if (fileSink === true) { fileSink = this[kWriteStreamFastPath] = Bun.file(this.path).writer(); @@ -601,83 +618,59 @@ function underscoreWriteFast(this: FSStream, data: any, encoding: any, cb: any) this.fd = fileSink._getFd(); } - const maybePromise = fileSink.write(data); - if ($isPromise(maybePromise)) { - maybePromise.then( - () => { - if (cb) cb(null); - this.emit("drain"); - }, - err => { - if (cb) cb(err); - require("internal/streams/destroy").errorOrDestroy(this, err); - }, - ); - return false; - } else { - if (cb) process.nextTick(cb, null); - return true; + // decodeStrings is off for this path: the sink encodes UTF-8 itself, but + // every other encoding has to be decoded here. + if (typeof chunk === "string" && encoding !== "utf8" && encoding !== "utf-8") { + chunk = Buffer.from(chunk, encoding); } - } catch (e) { - if (cb) process.nextTick(cb, e); - require("internal/streams/destroy").errorOrDestroy(this, e, true); - return false; - } -} - -// This function implementation is not correct. -const writablePrototypeWrite = Writable.prototype.write; -const kWriteMonkeyPatchDefense = Symbol("!"); -function writeFast(this: FSStream, data: any, encoding: any, cb: any) { - if (this[kWriteMonkeyPatchDefense]) return writablePrototypeWrite.$call(this, data, encoding, cb); - // After end()/destroy() the Writable contract requires write() to fail with - // ERR_STREAM_WRITE_AFTER_END / ERR_STREAM_DESTROYED and not reach the sink. - const state = this._writableState; - if (state !== undefined && (state.ending || state.destroyed)) { - return writablePrototypeWrite.$call(this, data, encoding, cb); + maybePromise = fileSinkWriteNow(fileSink, chunk); + } catch (e) { + cb(e); + return; } - if (typeof encoding === "function") { - cb = encoding; - encoding = undefined; - } - if (typeof cb !== "function") { - cb = streamNoop; - } + settleFastWrite(this, maybePromise, cb); +} - const fileSink = this[kWriteStreamFastPath]; - if (fileSink && fileSink !== true) { - const maybePromise = fileSink.write(data); - if ($isPromise(maybePromise)) { - // Two-arg then(): a throw from the fulfillment handler must not be - // mistaken for a write failure. +function settleFastWrite(stream, maybePromise, cb) { + if ($isPromise(maybePromise)) { + const onPending = stream[kOnPendingWrite]; + if (onPending) { + onPending.$call(stream, true); maybePromise.then( () => { - this.emit("drain"); // Emit drain event + onPending.$call(stream, false); cb(null); }, err => { + onPending.$call(stream, false); cb(err); - // Node.js onwriteError: callback AND destroy are both invoked; the - // callback is additive, not a replacement for the 'error' event. - require("internal/streams/destroy").errorOrDestroy(this, err); }, ); - return false; // Indicate backpressure } else { - cb(null); - return true; // No backpressure + maybePromise.then(() => cb(null), cb); } } else { - const result: any = this._write(data, encoding, cb); - if (this.write === writeFast) { - this.write = writablePrototypeWrite; - } else { - this[kWriteMonkeyPatchDefense] = true; - } - return result; + cb(null); + } +} + +// `_writev` for the same streams. A corked burst (and a backlog drained after +// backpressure) reaches the fd as one write — what cork() is for; node's stdio +// over a pipe coalesces the same burst into a single writev(2). +function underscoreWritevFast(this: FSStream, data: any, cb: any) { + const len = data.length; + if (len === 1) { + const { chunk, encoding } = data[0]; + return underscoreWriteFast.$call(this, chunk, encoding, cb); + } + const chunks = new Array(len); + for (let i = 0; i < len; i++) { + const { chunk, encoding } = data[i]; + chunks[i] = typeof chunk === "string" ? Buffer.from(chunk, encoding) : chunk; } + return underscoreWriteFast.$call(this, Buffer.concat(chunks), "buffer", cb); } writeStreamPrototype._writev = function (data, cb) { @@ -808,5 +801,6 @@ export default { ReadStream, WriteStream, kWriteStreamFastPath, + kOnPendingWrite, writableFromFileSink, }; diff --git a/src/js/internal/streams/writable.ts b/src/js/internal/streams/writable.ts index d0fe11d84d4a..c783050e4d67 100644 --- a/src/js/internal/streams/writable.ts +++ b/src/js/internal/streams/writable.ts @@ -334,6 +334,19 @@ function resetBuffer(state) { state[kState] &= ~kBuffered; } +// Detach and return whatever is queued behind the in-flight write (their +// callbacks will never run). For handing the chunks to another sink when this +// stream is being abandoned, e.g. stdio at process exit. +function takeBuffered(state) { + if ((state[kState] & kBuffered) === 0) return []; + const buffered = ArrayPrototypeSlice.$call(state.buffered, state.bufferedIndex); + for (let i = 0; i < buffered.length; i++) { + state.length -= (state[kState] & kObjectMode) !== 0 ? 1 : buffered[i].chunk.length; + } + resetBuffer(state); + return buffered; +} + WritableState.prototype.getBuffer = function getBuffer() { return (this[kState] & kBuffered) === 0 ? [] : ArrayPrototypeSlice.$call(this.buffered, this.bufferedIndex); }; @@ -397,6 +410,7 @@ function Writable(options): void { $toClass(Writable, "Writable", Stream); Writable.WritableState = WritableState; +Writable.takeBuffered = takeBuffered; ObjectDefineProperty(Writable, SymbolHasInstance, { __proto__: null, diff --git a/src/js/node/diagnostics_channel.ts b/src/js/node/diagnostics_channel.ts index 4c26ff1017fe..6b08d799c723 100644 --- a/src/js/node/diagnostics_channel.ts +++ b/src/js/node/diagnostics_channel.ts @@ -62,6 +62,7 @@ function markActive(channel) { ObjectSetPrototypeOf.$call(null, channel, ActiveChannel.prototype); channel._subscribers = []; channel._stores = new SafeMap(); + updateConsoleChannel(channel, true); } function maybeMarkInactive(channel) { @@ -70,9 +71,33 @@ function maybeMarkInactive(channel) { ObjectSetPrototypeOf.$call(null, channel, Channel.prototype); channel._subscribers = undefined; channel._stores = undefined; + updateConsoleChannel(channel, false); } } +// The global console publishes its arguments on these channels before +// formatting, but only while somebody is subscribed: +// https://github.com/nodejs/node/blob/v24.0.0/lib/internal/console/constructor.js#L66-L70 +// The console is native, so tell it which of the five currently have +// subscribers (a bitmask it can test for free) and hand it one function to +// publish through. +const kConsoleChannelNames = ["console.log", "console.warn", "console.error", "console.debug", "console.info"]; +let consoleChannelMask = 0; +let setConsoleChannels; +function publishToConsoleChannel(index: number, args: unknown[]) { + channels.get(kConsoleChannelNames[index])?.publish(args); +} +function updateConsoleChannel(channel, active: boolean) { + const index = kConsoleChannelNames.indexOf(channel.name); + if (index === -1) return; + const bit = 1 << index; + const next = active ? consoleChannelMask | bit : consoleChannelMask & ~bit; + if (next === consoleChannelMask) return; + consoleChannelMask = next; + setConsoleChannels ??= $newCppFunction("BunProcess.cpp", "jsFunctionSetConsoleChannels", 2); + setConsoleChannels(consoleChannelMask, publishToConsoleChannel); +} + function defaultTransform(data) { return data; } diff --git a/src/js/node/worker_threads.ts b/src/js/node/worker_threads.ts index f8b269dd56ae..391e6d3db869 100644 --- a/src/js/node/worker_threads.ts +++ b/src/js/node/worker_threads.ts @@ -444,43 +444,32 @@ function makePortWritable(port) { function setupWorkerStdio(stdio) { const { stdin, stdout, stderr } = stdio; + // Plain assignment, not Object.defineProperty: `process.stdout/stderr/stdin` + // are lazy native properties, and defineProperty would reify (and so build, + // and dup an fd for) the fd-backed stream we are replacing. Assignment just + // replaces the slot; the descriptor comes out {writable,enumerable,configurable} + // either way. The native console is then pointed at the port streams, which is + // what routes a worker's console.log to the parent (Node: console._stdout is + // process.stdout, which in a worker is a port-backed Writable). + const setConsoleStream = $newCppFunction("BunProcess.cpp", "jsFunctionSetConsoleStream", 2); if (stdout) { - Object.defineProperty(process, "stdout", { - value: makePortWritable(stdout), - writable: true, - configurable: true, - enumerable: true, - }); + (process as any).stdout = makePortWritable(stdout); + setConsoleStream(1, process.stdout); } if (stderr) { - Object.defineProperty(process, "stderr", { - value: makePortWritable(stderr), - writable: true, - configurable: true, - enumerable: true, - }); + (process as any).stderr = makePortWritable(stderr); + setConsoleStream(2, process.stderr); } // node always replaces a worker's process.stdin: port-backed when { stdin: true }, // otherwise an immediately-EOF'd stream — never the process-wide fd 0, which // would race the main thread (and hang on a TTY). - Object.defineProperty(process, "stdin", { - value: stdin - ? makePortReadable(stdin, true) - : new Readable({ - read() { - this.push(null); - }, - }), - writable: true, - configurable: true, - 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. - if (stdout || stderr) { - const { Console } = require("node:console"); - globalThis.console = new Console(process.stdout, process.stderr); - } + (process as any).stdin = stdin + ? makePortReadable(stdin, true) + : new Readable({ + read() { + this.push(null); + }, + }); } // Emulation of Node's JSTransferable protocol (kTransfer/kTransferList/kDeserialize) for diff --git a/src/jsc/ConsoleObject.rs b/src/jsc/ConsoleObject.rs index 29152e29c5cf..75fa566ea884 100644 --- a/src/jsc/ConsoleObject.rs +++ b/src/jsc/ConsoleObject.rs @@ -3,16 +3,17 @@ //! `console.count`/`time`/`timeEnd`, and the C ABI shims that JavaScriptCore //! calls into. -use crate::{ComptimeStringMapExt as _, ZigStringJsc as _}; +use crate::{ComptimeStringMapExt as _, StringJsc as _, ZigStringJsc as _}; use core::cell::{Cell, RefCell}; use core::ffi::c_void; use crate as jsc; use crate::virtual_machine::VirtualMachine; -use crate::{EventType, JSGlobalObject, JSPromise, JSValue, JsResult, ZigString}; +use crate::{EventType, JSGlobalObject, JSPromise, JSValue, JsError, JsResult, ZigString}; use bun_collections::HashMap; use bun_core::{Output, StackCheck}; use bun_core::{OwnedString, String as BunString, strings}; +use bun_io::Write as _; /// Thin facade over `bun_js_parser::lexer` / `bun_js_printer` so the call /// sites below can use the `JSLexer.isLatin1Identifier` / @@ -88,19 +89,17 @@ const DEFAULT_CONSOLE_LOG_DEPTH: u16 = 2; type Counter = HashMap; pub struct ConsoleObject { - stderr_buffer: [u8; 4096], - stdout_buffer: [u8; 4096], - - error_writer_backing: Output::QuietWriterAdapter, - writer_backing: Output::QuietWriterAdapter, + /// A console message is formatted in full into this buffer and only then + /// handed to the stdio sink (or, when the stream is observed by user code, + /// to JS) — the same shape as Node's `formatWithOptions` → `stream.write`. + /// Taken (`mem::take`) for the duration of a call so a `console.*` that + /// re-enters during formatting (a getter that logs) formats into its own + /// buffer and is emitted first, as in Node. + scratch: Vec, pub(crate) default_indent: u16, counts: Counter, - - // The writer adapters above hold raw pointers into `{stderr,stdout}_buffer`; - // moving the struct would dangle them, so opt out of `Unpin`. - _pin: core::marker::PhantomPinned, } impl core::fmt::Display for ConsoleObject { @@ -110,54 +109,243 @@ impl core::fmt::Display for ConsoleObject { } } +impl Default for ConsoleObject { + fn default() -> Self { + Self::new() + } +} + impl ConsoleObject { - /// `adapt_to_new_api(&mut self.stderr_buffer)` captures a raw pointer into - /// the buffer field, so the struct is self-referential once initialized: - /// the address of `*out` MUST be stable for the value's entire lifetime — - /// moving it afterwards leaves the writer adapters dangling. - pub(crate) fn init_in_place( - out: &mut core::mem::MaybeUninit, - error_writer: Output::StreamType, - writer: Output::StreamType, - ) -> &mut ConsoleObject { - let out = out.write(ConsoleObject { - stderr_buffer: [0; 4096], - stdout_buffer: [0; 4096], - error_writer_backing: Output::QuietWriterAdapter::uninit(), - writer_backing: Output::QuietWriterAdapter::uninit(), + pub fn new() -> ConsoleObject { + ConsoleObject { + scratch: Vec::new(), default_indent: 0, counts: Counter::default(), - _pin: core::marker::PhantomPinned, - }); - let p: *mut ConsoleObject = out; - // SAFETY: `out` is now fully initialized at its final address; the - // adapters store raw pointers into `out.stderr_buffer` / - // `out.stdout_buffer`, which remain valid for `out`'s lifetime - // *provided the caller never moves it* (see fn doc). + } + } + + /// Largest scratch capacity kept between calls; a one-off huge message + /// shouldn't pin its buffer for the life of the VM. + const SCRATCH_KEEP: usize = 64 * 1024; + + #[inline] + fn take_scratch(this: *mut ConsoleObject) -> Vec { + // SAFETY: `this` is the live per-VM console (see [`vm_console`]); + // single field move-out, no reference held past this statement. + unsafe { core::mem::take(&mut (*this).scratch) } + } + + #[inline] + fn put_scratch(this: *mut ConsoleObject, mut buf: Vec) { + buf.clear(); + if buf.capacity() > Self::SCRATCH_KEEP { + buf.shrink_to(Self::SCRATCH_KEEP); + } + // SAFETY: as above. If a re-entrant call left its (smaller) buffer + // here, keep whichever is larger. unsafe { - (*p).error_writer_backing = error_writer - .quiet_writer() - .adapt_to_new_api(&mut (*p).stderr_buffer); - (*p).writer_backing = writer - .quiet_writer() - .adapt_to_new_api(&mut (*p).stdout_buffer); - } - out + if (*this).scratch.capacity() < buf.capacity() { + (*this).scratch = buf; + } + } + } +} + +// ─────────────────────────────────────────────────────────────────────────── +// Delivery: stdio sink (fast) or the observed JS stream (Node's kWriteToConsole) +// ─────────────────────────────────────────────────────────────────────────── + +unsafe extern "Rust" { + /// `bun_runtime::webcore::file_sink::__bun_stdio_sink_write` — hand one + /// whole console message to this VM's stdio sink for `fd`; returns once it + /// has reached the fd (queued `process.stdout` bytes go out first). + fn __bun_stdio_sink_write( + vm: *mut VirtualMachine, + fd: bun_sys::Fd, + bytes: &[u8], + ) -> Result<(), bun_sys::Error>; + /// `bun_runtime::webcore::file_sink::__bun_stdio_sink_drain` — flush both + /// stdio sinks' queues now (no-op if they don't exist). + fn __bun_stdio_sink_drain(vm: *mut VirtualMachine); +} + +unsafe extern "C" { + /// `BunProcess.cpp` — the stream the console must go through via JS + /// `write()` for `fd`, or the empty value when the native sink may be used. + /// A structure compare in the steady state; the very first call may run + /// (and rethrow from) a user getter installed on `process.stdout`. + fn Bun__Process__consoleStream(global: &JSGlobalObject, fd: i32, threw: &mut bool) -> JSValue; + /// `BunProcess.cpp` — `consoleObjectWriteToObservedStream(stream, chunk)`. + fn Bun__Console__writeToStream(global: &JSGlobalObject, stream: JSValue, chunk: JSValue); + /// `BunProcess.cpp` — the console's stream *object* for `fd` if any exists + /// (custom binding or Bun's materialised stream), else empty. For + /// `console.clear()`'s `isTTY` check. Same throw contract as + /// `Bun__Process__consoleStream`. + fn Bun__Process__consoleStreamObject( + global: &JSGlobalObject, + fd: i32, + threw: &mut bool, + ) -> JSValue; +} + +/// Which of the two console streams a message goes to. Node: +/// `log/info/debug/dir/dirxml/table/count/timeLog/timeEnd/group` → stdout, +/// `warn/error/trace/assert` → stderr. +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum ConsoleStream { + Stdout, + Stderr, +} + +impl ConsoleStream { + /// 1 or 2 — the number `BunProcess.cpp` keys its per-stream state by. + #[inline] + pub fn number(self) -> i32 { + match self { + ConsoleStream::Stdout => 1, + ConsoleStream::Stderr => 2, + } } - /// Returns the buffered stderr writer interface. #[inline] - pub(crate) fn error_writer(&mut self) -> &mut bun_core::io::Writer { - self.error_writer_backing.new_interface() + pub fn fd(self) -> bun_sys::Fd { + match self { + ConsoleStream::Stdout => bun_sys::Fd::stdout(), + ConsoleStream::Stderr => bun_sys::Fd::stderr(), + } } - /// Returns the buffered stdout writer interface. #[inline] - pub(crate) fn writer(&mut self) -> &mut bun_core::io::Writer { - self.writer_backing.new_interface() + pub fn colors(self) -> bool { + match self { + ConsoleStream::Stdout => Output::enable_ansi_colors_stdout(), + ConsoleStream::Stderr => Output::enable_ansi_colors_stderr(), + } + } + + #[inline] + fn for_message(message_type: MessageType, level: MessageLevel) -> Self { + if matches!(level, MessageLevel::Warning | MessageLevel::Error) + || matches!(message_type, MessageType::Assert | MessageType::Trace) + { + ConsoleStream::Stderr + } else { + ConsoleStream::Stdout + } + } +} + +/// Where one console message is going: the stdio sink, or — when user code +/// could tell the difference — the bound stream's JS `write()` (Node's +/// `kWriteToConsole`). +#[inline] +fn console_target(global: &JSGlobalObject, stream: ConsoleStream) -> JsResult { + let mut threw = false; + // SAFETY: plain FFI; `threw` is its throw contract. + let target = unsafe { Bun__Process__consoleStream(global, stream.number(), &mut threw) }; + if threw { + Err(JsError::Thrown) + } else { + Ok(target) } } +/// Deliver one whole formatted message to `target` (see [`console_target`]). +/// Errors reaching the fd (EPIPE, ...) are the sink's to surface on +/// `process.stdout`/`stderr`; the console itself never throws for them +/// (`ignoreErrors: true`). +fn deliver_to( + global: &JSGlobalObject, + stream: ConsoleStream, + target: JSValue, + bytes: &[u8], +) -> JsResult<()> { + if bytes.is_empty() { + return Ok(()); + } + let vm: *mut VirtualMachine = global.bun_vm().as_mut(); + if target.is_empty() { + // Callers hold `StdioLock` for `stream` on this path (`emit`/`deliver`). + // SAFETY: `vm` is the live per-thread VM that owns `global`. + let _ = unsafe { __bun_stdio_sink_write(vm, stream.fd(), bytes) }; + return Ok(()); + } + // Switching to JS delivery: whatever the native side still has queued for + // either fd must land first or it would come out after this message. + // SAFETY: as above. + unsafe { __bun_stdio_sink_drain(vm) }; + let chunk = bun_core::String::borrow_utf8(bytes).to_js(global)?; + crate::from_js_host_call_generic(global, || { + // SAFETY: plain FFI; both values are live on this stack. + unsafe { Bun__Console__writeToStream(global, target, chunk) } + }) +} + +pub fn deliver(global: &JSGlobalObject, stream: ConsoleStream, bytes: &[u8]) -> JsResult<()> { + let target = console_target(global, stream)?; + let _lock = target + .is_empty() + .then(|| bun_io::StdioLock::acquire(stream.fd())); + deliver_to(global, stream, target, bytes) +} + +/// The writer console formatting goes through. Normally just the scratch +/// buffer; on the native path a message that outgrows [`SPILL_AT`] is written +/// out as it is produced (bounded memory, and a giant `console.log` starts +/// appearing before it is fully formatted) — the observed-stream path must +/// hand JS one string, as Node does, so it never spills. +pub struct ConsoleWriter<'a> { + buf: &'a mut Vec, + spill: Option<(*mut VirtualMachine, bun_sys::Fd)>, +} + +/// Big enough that ordinary messages are one `write(2)`, small enough that a +/// runaway one doesn't matter. +const SPILL_AT: usize = 64 * 1024; + +impl bun_io::Write for ConsoleWriter<'_> { + #[inline] + fn write_all(&mut self, bytes: &[u8]) -> bun_core::CrateResult<()> { + self.buf.extend_from_slice(bytes); + if self.buf.len() >= SPILL_AT { + if let Some((vm, fd)) = self.spill { + // SAFETY: `vm` is the live per-thread VM (set in `emit`). + let _ = unsafe { __bun_stdio_sink_write(vm, fd, self.buf) }; + self.buf.clear(); + } + } + Ok(()) + } +} + +/// Format a message with `f` and deliver it. If `f` fails (a JS exception +/// thrown while formatting — a throwing getter, `toJSON`, ...) nothing further +/// is written and the exception propagates, as in Node. +pub fn emit( + global: &JSGlobalObject, + stream: ConsoleStream, + f: impl FnOnce(&mut ConsoleWriter<'_>) -> JsResult<()>, +) -> JsResult<()> { + let target = console_target(global, stream)?; + let native = target.is_empty(); + // Held across formatting on the native path so spilled chunks of one + // message can't interleave with another thread's console output. + let _lock = native.then(|| bun_io::StdioLock::acquire(stream.fd())); + + let console = vm_console(global); + let mut buf = ConsoleObject::take_scratch(console); + let mut writer = ConsoleWriter { + buf: &mut buf, + spill: native.then(|| (global.bun_vm().as_mut() as *mut VirtualMachine, stream.fd())), + }; + let result = match f(&mut writer) { + Ok(()) => deliver_to(global, stream, target, &buf), + Err(err) => Err(err), + }; + ConsoleObject::put_scratch(console, buf); + result +} + #[repr(u32)] #[derive(Copy, Clone, Eq, PartialEq, strum::IntoStaticStr)] pub enum MessageLevel { @@ -238,8 +426,6 @@ impl MessageType { // Bun__ConsoleObject__* shims. // ─────────────────────────────────────────────────────────────────────────── -use bun_threading::Mutex; - /// `globalThis.bunVM().console` — `VirtualMachine.console` is typed /// `*mut c_void` (erased so `virtual_machine.rs` need not name this module's /// type). This is the single re-entry point that casts it back; every body @@ -270,63 +456,6 @@ unsafe fn vm_console_mut<'a>(global: &JSGlobalObject) -> &'a mut ConsoleObject { unsafe { &mut *vm_console(global) } } -static STDERR_MUTEX: Mutex = Mutex::new(); -static STDOUT_MUTEX: Mutex = Mutex::new(); - -thread_local! { - static STDERR_LOCK_COUNT: Cell = const { Cell::new(0) }; - static STDOUT_LOCK_COUNT: Cell = const { Cell::new(0) }; -} - -/// RAII guard for the per-stream reentrant console lock. Acquires on -/// construction (incrementing the thread-local count and locking the global -/// mutex on first entry), releases on `Drop` (decrementing and unlocking on -/// last exit). -struct ConsoleStreamLock { - use_stderr: bool, -} - -impl ConsoleStreamLock { - fn acquire(use_stderr: bool) -> Self { - if use_stderr { - STDERR_LOCK_COUNT.with(|c| { - if c.get() == 0 { - STDERR_MUTEX.lock(); - } - c.set(c.get() + 1); - }); - } else { - STDOUT_LOCK_COUNT.with(|c| { - if c.get() == 0 { - STDOUT_MUTEX.lock(); - } - c.set(c.get() + 1); - }); - } - Self { use_stderr } - } -} - -impl Drop for ConsoleStreamLock { - fn drop(&mut self) { - if self.use_stderr { - STDERR_LOCK_COUNT.with(|c| { - c.set(c.get() - 1); - if c.get() == 0 { - STDERR_MUTEX.unlock(); - } - }); - } else { - STDOUT_LOCK_COUNT.with(|c| { - c.set(c.get() - 1); - if c.get() == 0 { - STDOUT_MUTEX.unlock(); - } - }); - } - } -} - /// RAII flush of a borrowed `bun_io::Write` at scope exit when `enabled`. /// /// Owns the `&mut dyn Write` for its lifetime; the body of the scope must @@ -405,59 +534,33 @@ fn message_with_type_and_level_( return Ok(()); } - // Lock/unlock a mutex incase two JS threads are console.log'ing at the same - // 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; - let _stream_lock = ConsoleStreamLock::acquire(use_stderr); + let stream = ConsoleStream::for_message(message_type, level); if message_type == MessageType::Clear { - Output::reset_terminal(); - return Ok(()); - } - - if message_type == MessageType::Assert && len == 0 { - let text: &str = if Output::enable_ansi_colors_stderr() { - pfmt!("Assertion failed\n", true) + // Node: only when `this._stdout.isTTY`, `cursorTo(0, 0)` + `clearScreenDown()`. + // https://github.com/nodejs/node/blob/v24.0.0/lib/internal/console/constructor.js#L487-L501 + let mut threw = false; + // SAFETY: plain FFI; `threw` is its throw contract. + let stream_object = unsafe { Bun__Process__consoleStreamObject(global, 1, &mut threw) }; + if threw { + return Err(JsError::Thrown); + } + let is_tty = if stream_object.is_empty() { + Output::is_stdout_tty() } else { - "Assertion failed\n" + stream_object + .get(global, b"isTTY")? + .is_some_and(|v| v.to_boolean()) }; - // SAFETY: no other borrow of the console is live in this - // early-return arm (the deferred `_indent_guard` only holds the raw - // pointer, not a reference). - let ew = unsafe { vm_console_mut(global) }.error_writer(); - let _ = ew.write_all(text.as_bytes()); - let _ = ew.flush(); + if is_tty && bun_core::env_var::TERM::get().map_or(true, |t| t != b"dumb") { + return deliver(global, ConsoleStream::Stdout, b"\x1b[1;1H\x1b[0J"); + } return Ok(()); } - let enable_colors = if matches!(level, MessageLevel::Warning | MessageLevel::Error) { - Output::enable_ansi_colors_stderr() - } else { - 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 - // long-lived `&mut ConsoleObject` across the re-derive in the empty-`Log` - // arm below. - let raw_writer: &mut bun_core::io::Writer = unsafe { - if matches!(level, MessageLevel::Warning | MessageLevel::Error) { - (*console).error_writer() - } else { - (*console).writer() - } - }; - // `bun_core::io::Writer: bun_io::Write` — `&mut Writer` unsize-coerces directly. - let writer: &mut dyn bun_io::Write = raw_writer; + let default_indent = unsafe { (*console).default_indent }; + let enable_colors = stream.colors(); // LAYERING: `Jest::runner()` lives in `bun_runtime::test_runner` (forward // dep on the high tier). Dispatch through `RuntimeHooks` instead — the @@ -467,96 +570,112 @@ fn message_with_type_and_level_( (hooks.console_on_before_print)(); } - 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() - .and_then(|ctx| ctx.runtime_options.console_depth) - .unwrap_or(DEFAULT_CONSOLE_LOG_DEPTH); - - let mut print_options = FormatOptions { - enable_colors, - add_newline: true, - flush: true, - default_indent, - max_depth: console_depth, - error_display_level: match level { - MessageLevel::Error => ErrorDisplayLevel::Full, - MessageLevel::Warning => ErrorDisplayLevel::Warn, - _ => ErrorDisplayLevel::Normal, - }, - ..FormatOptions::default() - }; - // SAFETY: caller (JSC C++) guarantees `vals` points to `len` JSValues. let vals_slice = unsafe { bun_core::ffi::slice(vals, len) }; - if message_type == MessageType::Table && len >= 1 { - // if value is not an object/array/iterable, don't print a table and just print it - let tabular_data = vals_slice[0]; - if tabular_data.is_object() { - let properties: JSValue = if len >= 2 && vals_slice[1].js_type().is_array() { - vals_slice[1] - } else { - JSValue::UNDEFINED + emit(global, stream, |writer| { + if message_type == MessageType::Assert { + // Node prefixes the first argument and forwards to `warn`, so the + // prefix takes part in the same `%s` substitution pass: + // https://github.com/nodejs/node/blob/v24.0.0/lib/internal/console/constructor.js#L474-L485 + let first_is_string = len > 0 && vals_slice[0].is_string(); + let text: &str = match (len == 0, first_is_string, enable_colors) { + (true, _, true) => pfmt!("Assertion failed\n", true), + (true, _, false) => "Assertion failed\n", + // `Assertion failed: ${args[0]}` when it's a string, + (false, true, true) => pfmt!("Assertion failed: ", true), + (false, true, false) => "Assertion failed: ", + // else 'Assertion failed' is unshifted as its own argument. + (false, false, true) => pfmt!("Assertion failed ", true), + (false, false, false) => "Assertion failed ", }; - let mut table_printer = TablePrinter::init(global, level, tabular_data, properties)?; - table_printer.value_formatter.indent += u32::from(default_indent); - - if enable_colors { - let _ = table_printer.print_table::(writer); - } else { - let _ = table_printer.print_table::(writer); + let _ = writer.write_all(text.as_bytes()); + if len == 0 { + return Ok(()); } - let _ = writer.flush(); - return Ok(()); } - } - if message_type == MessageType::Dir && len >= 2 { - print_length = 1; - let opts = vals_slice[1]; - if opts.is_object() { - if let Some(depth_prop) = opts.get(global, b"depth")? { - if depth_prop.is_int32() || depth_prop.is_number() || depth_prop.is_big_int() { - // Clamp negatives to 0, then truncate (not saturate) to u16. - print_options.max_depth = depth_prop.to_int32().max(0) as u32 as u16; - } else if depth_prop.is_null() { - print_options.max_depth = u16::MAX; + 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() + .and_then(|ctx| ctx.runtime_options.console_depth) + .unwrap_or(DEFAULT_CONSOLE_LOG_DEPTH); + + let mut print_options = FormatOptions { + enable_colors, + add_newline: true, + flush: false, + default_indent, + max_depth: console_depth, + error_display_level: match level { + MessageLevel::Error => ErrorDisplayLevel::Full, + MessageLevel::Warning => ErrorDisplayLevel::Warn, + _ => ErrorDisplayLevel::Normal, + }, + ..FormatOptions::default() + }; + + if message_type == MessageType::Table && len >= 1 { + // if value is not an object/array/iterable, don't print a table and just print it + let tabular_data = vals_slice[0]; + if tabular_data.is_object() { + let properties: JSValue = if len >= 2 && vals_slice[1].js_type().is_array() { + vals_slice[1] + } else { + JSValue::UNDEFINED + }; + let mut table_printer = + TablePrinter::init(global, level, tabular_data, properties)?; + table_printer.value_formatter.indent += u32::from(default_indent); + + if enable_colors { + let _ = table_printer.print_table::(writer); + } else { + let _ = table_printer.print_table::(writer); } + return Ok(()); } - if let Some(colors_prop) = opts.get(global, b"colors")? { - if colors_prop.is_boolean() { - print_options.enable_colors = colors_prop.to_boolean(); + } + + if message_type == MessageType::Dir && len >= 2 { + print_length = 1; + let opts = vals_slice[1]; + if opts.is_object() { + if let Some(depth_prop) = opts.get(global, b"depth")? { + if depth_prop.is_int32() || depth_prop.is_number() || depth_prop.is_big_int() { + // Clamp negatives to 0, then truncate (not saturate) to u16. + print_options.max_depth = depth_prop.to_int32().max(0) as u32 as u16; + } else if depth_prop.is_null() { + print_options.max_depth = u16::MAX; + } + } + if let Some(colors_prop) = opts.get(global, b"colors")? { + if colors_prop.is_boolean() { + print_options.enable_colors = colors_prop.to_boolean(); + } } } } - } - if print_length > 0 { - format2( - level, - global, - &vals_slice[..print_length], - writer, - 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(); - } else if message_type != MessageType::Trace { - let _ = writer.write_all(b"undefined\n"); - } - - if message_type == MessageType::Trace { - write_trace(writer, global); - let _ = writer.flush(); - } + if print_length > 0 { + format2( + level, + global, + &vals_slice[..print_length], + writer, + print_options, + )?; + } else if message_type == MessageType::Log { + let _ = writer.write_all(b"\n"); + } else if message_type != MessageType::Trace { + let _ = writer.write_all(b"undefined\n"); + } - Ok(()) + if message_type == MessageType::Trace { + write_trace(writer, global); + } + Ok(()) + }) } // ─────────────────────────────────────────────────────────────────────────── @@ -5791,37 +5910,41 @@ pub(crate) 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; - - let writer = this.writer(); - if Output::enable_ansi_colors_stdout() { - let _ = writeln!( - writer, - "{}{}{}: {}{}{}", - pfmt!("", true), - bstr::BStr::new(slice), - pfmt!("", true), - pfmt!("", true), - current, - pfmt!("", true), - ); - } else { - let _ = writeln!(writer, "{}: {}", bstr::BStr::new(slice), current); - } - let _ = writer.flush(); + let current: u32 = { + // SAFETY: top-level JS-thread host call ⇒ exclusive access to the + // set-once `VirtualMachine.console` box; borrow ends before `emit`. + 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 = if counter.found_existing { + *counter.value_ptr + } else { + 0 + } + 1; + *counter.value_ptr = current; + current + }; + + let _ = emit(global_this, ConsoleStream::Stdout, |writer| { + if ConsoleStream::Stdout.colors() { + let _ = writeln!( + writer, + "{}{}{}: {}{}{}", + pfmt!("", true), + bstr::BStr::new(slice), + pfmt!("", true), + pfmt!("", true), + current, + pfmt!("", true), + ); + } else { + let _ = writeln!(writer, "{}: {}", bstr::BStr::new(slice), current); + } + Ok(()) + }); } #[unsafe(no_mangle)] @@ -5850,26 +5973,179 @@ thread_local! { static PENDING_TIME_LOGS_LOADED: Cell = const { Cell::new(false) }; } +/// `process.emitWarning(text)` for the console timers, as Node does; a +/// failure to warn is not the caller's problem. +fn console_warn(global: &JSGlobalObject, args: core::fmt::Arguments<'_>) { + let text = OwnedString::new(BunString::create_format(args)); + let Ok(js) = text.to_js(global) else { + return; + }; + let _ = global.emit_warning( + js, + JSValue::UNDEFINED, + JSValue::UNDEFINED, + JSValue::UNDEFINED, + ); +} + +/// Node's `formatTime` for `console.timeLog` / `console.timeEnd`. +/// https://github.com/nodejs/node/blob/v24.0.0/lib/internal/util/debuglog.js#L155-L187 +fn write_elapsed(writer: &mut dyn bun_io::Write, mut ms: f64) { + const SECOND: f64 = 1000.0; + const MINUTE: f64 = 60.0 * SECOND; + const HOUR: f64 = 60.0 * MINUTE; + + let mut hours = 0u64; + let mut minutes = 0u64; + let mut seconds = 0.0f64; + if ms >= SECOND { + if ms >= MINUTE { + if ms >= HOUR { + hours = (ms / HOUR).floor() as u64; + ms %= HOUR; + } + minutes = (ms / MINUTE).floor() as u64; + ms %= MINUTE; + } + seconds = ms / SECOND; + } + + if hours != 0 || minutes != 0 { + // `seconds.toFixed(3)` split on '.' → zero-padded whole seconds + millis. + let whole = seconds.trunc() as u64; + let mut millis = ((seconds - seconds.trunc()) * 1000.0).round() as u64; + let mut whole = whole; + if millis == 1000 { + millis = 0; + whole += 1; + } + if hours != 0 { + let _ = write!( + writer, + "{}:{:02}:{:02}.{:03} (h:mm:ss.mmm)", + hours, minutes, whole, millis + ); + } else { + let _ = write!(writer, "{}:{:02}.{:03} (m:ss.mmm)", minutes, whole, millis); + } + return; + } + + if seconds != 0.0 { + let _ = write!(writer, "{:.3}s", seconds); + return; + } + + // `Number(ms.toFixed(3))`: round to three decimals, print as a JS number. + let _ = write!( + writer, + "{}ms", + bun_core::fmt::double((ms * 1000.0).round() / 1000.0) + ); +} + #[unsafe(no_mangle)] #[crate::host_call] pub(crate) extern "C" fn Bun__ConsoleObject__time( _console: *mut ConsoleObject, - _global: &JSGlobalObject, + global: &JSGlobalObject, chars: *const u8, len: usize, ) { // SAFETY: caller passes a valid (ptr, len) pair. - let id = bun_wyhash::hash(unsafe { bun_core::ffi::slice(chars, len) }); + let label = unsafe { bun_core::ffi::slice(chars, len) }; + let id = bun_wyhash::hash(label); if !PENDING_TIME_LOGS_LOADED.with(|c| c.get()) { PENDING_TIME_LOGS.with_borrow_mut(|m| *m = PendingTimers::default()); PENDING_TIME_LOGS_LOADED.with(|c| c.set(true)); } - PENDING_TIME_LOGS.with_borrow_mut(|map| { + let existed = PENDING_TIME_LOGS.with_borrow_mut(|map| { let result = map.get_or_put(id).expect("unreachable"); if !result.found_existing || result.value_ptr.is_none() { *result.value_ptr = Some(bun_core::time::Timer::start()); + false + } else { + true + } + }); + if existed { + console_warn( + global, + format_args!( + "Label '{}' already exists for console.time()", + bstr::BStr::new(label) + ), + ); + } +} + +/// Shared tail of `timeEnd` / `timeLog`: `label: [ ...args]\n` on +/// stdout, exactly what Node's `timeLogImpl` → `console.log('%s: %s', ...)` +/// prints. +fn time_log_impl( + global: &JSGlobalObject, + implementation: &str, + label: &[u8], + take: bool, + args: &[JSValue], +) { + if !PENDING_TIME_LOGS_LOADED.with(|c| c.get()) { + console_warn( + global, + format_args!( + "No such label '{}' for {}", + bstr::BStr::new(label), + implementation + ), + ); + return; + } + let id = bun_wyhash::hash(label); + let timer = PENDING_TIME_LOGS.with_borrow_mut(|m| match m.get_mut(&id) { + Some(slot) if take => slot.take(), + Some(slot) => *slot, + None => None, + }); + let Some(timer) = timer else { + console_warn( + global, + format_args!( + "No such label '{}' for {}", + bstr::BStr::new(label), + implementation + ), + ); + return; + }; + let ms = timer.read() as f64 / bun_core::time::NS_PER_MS as f64; + + let _ = emit(global, ConsoleStream::Stdout, |writer| { + let _ = writer.write_all(label); + let _ = writer.write_all(b": "); + write_elapsed(writer, ms); + + if !args.is_empty() { + // `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); + fmt.max_depth = bun_options_types::context::try_get() + .and_then(|ctx| ctx.runtime_options.console_depth) + .unwrap_or(DEFAULT_CONSOLE_LOG_DEPTH); + fmt.stack_check = StackCheck::init(); + fmt.can_throw_stack_overflow = true; + for &arg in args { + let tag = formatter::Tag::get(arg, global)?; + let _ = writer.write_all(b" "); + if ConsoleStream::Stdout.colors() { + fmt.format::(tag, writer, arg, global)?; + } else { + fmt.format::(tag, writer, arg, global)?; + } + } } + let _ = writer.write_all(b"\n"); + Ok(()) }); } @@ -5877,33 +6153,13 @@ pub(crate) extern "C" fn Bun__ConsoleObject__time( #[crate::host_call] pub(crate) extern "C" fn Bun__ConsoleObject__timeEnd( _console: *mut ConsoleObject, - _global: &JSGlobalObject, + global: &JSGlobalObject, chars: *const u8, len: usize, ) { - if !PENDING_TIME_LOGS_LOADED.with(|c| c.get()) { - return; - } - // SAFETY: caller passes a valid (ptr, len) pair. - let slice = unsafe { bun_core::ffi::slice(chars, len) }; - let id = bun_wyhash::hash(slice); - // Replace the slot with `None`, returning the previous value. - let Some(prev) = PENDING_TIME_LOGS.with_borrow_mut(|m| m.get_mut(&id).map(|slot| slot.take())) - else { - return; - }; - 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, - ); - match len { - 0 => Output::print_errorln(format_args!("")), - _ => Output::print_errorln(format_args!(" {}", bstr::BStr::new(slice))), - } - - Output::flush(); + let label = unsafe { bun_core::ffi::slice(chars, len) }; + time_log_impl(global, "console.timeEnd()", label, true, &[]); } #[unsafe(no_mangle)] @@ -5916,55 +6172,58 @@ pub(crate) extern "C" fn Bun__ConsoleObject__timeLog( args: *const JSValue, args_len: usize, ) { - if !PENDING_TIME_LOGS_LOADED.with(|c| c.get()) { - return; - } + // SAFETY: caller passes valid (ptr, len) pairs. + let label = unsafe { bun_core::ffi::slice(chars, len) }; + let args = unsafe { bun_core::ffi::slice(args, args_len) }; + time_log_impl(global, "console.timeLog()", label, false, args); +} - // SAFETY: caller passes a valid (ptr, len) pair. - let slice = unsafe { bun_core::ffi::slice(chars, len) }; - let id = bun_wyhash::hash(slice); - let Some(Some(value)) = PENDING_TIME_LOGS.with_borrow(|m| m.get(&id).copied()) else { - return; +/// `process._rawDebug(...args)`: format like `console.log` (no colours, as +/// `util.format`) and write straight to fd 2 — no stdio sink, no +/// `process.stderr`, so it works when that stream is broken or replaced. +/// https://github.com/nodejs/node/blob/v24.0.0/lib/internal/process/per_thread.js#L118-L120 +#[unsafe(no_mangle)] +#[crate::host_call] +pub extern "C" fn Bun__Process__rawDebug( + global: &JSGlobalObject, + vals: *const JSValue, + len: usize, +) { + let console = vm_console(global); + let mut buf = ConsoleObject::take_scratch(console); + // SAFETY: caller (BunProcess.cpp) passes the call frame's argument span. + let vals = unsafe { bun_core::ffi::slice(vals, len) }; + let result = if vals.is_empty() { + buf.push(b'\n'); + Ok(()) + } else { + format2( + MessageLevel::Log, + global, + vals, + &mut buf, + FormatOptions { + enable_colors: false, + add_newline: true, + flush: false, + max_depth: bun_options_types::context::try_get() + .and_then(|ctx| ctx.runtime_options.console_depth) + .unwrap_or(DEFAULT_CONSOLE_LOG_DEPTH), + ..FormatOptions::default() + }, + ) }; - // 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))), + { + let _lock = bun_io::StdioLock::acquire(bun_sys::Fd::stderr()); + let _ = bun_sys::write_all_retrying(bun_sys::Fd::stderr(), &buf); } - Output::flush(); - - // 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); - fmt.max_depth = bun_options_types::context::try_get() - .and_then(|ctx| ctx.runtime_options.console_depth) - .unwrap_or(DEFAULT_CONSOLE_LOG_DEPTH); - fmt.stack_check = StackCheck::init(); - fmt.can_throw_stack_overflow = true; - 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 - // resulting `writer` borrow does not pin a long-lived `&mut ConsoleObject` - // across the `fmt.format(...)` calls below, which can re-enter JS. - let mut writer = unsafe { (*console).error_writer() }; - // 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 _ = bun_io::Write::write_all(&mut writer, b" "); - if Output::enable_ansi_colors_stderr() { - let _ = fmt.format::(tag, &mut writer, arg, global); - } else { - let _ = fmt.format::(tag, &mut writer, arg, global); + ConsoleObject::put_scratch(console, buf); + if let Err(err) = result { + if matches!(err, jsc::JsError::OutOfMemory) { + global.throw_out_of_memory_value(); } + debug_assert!(global.has_exception()); } - let _ = bun_io::Write::write_all(&mut writer, b"\n"); - let _ = bun_io::Write::flush(&mut writer); } /// Stamp out the empty `Bun__ConsoleObject__*` C-ABI hooks that JSC's diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index a33a8cc85096..540dfb8e8462 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -1463,6 +1463,22 @@ impl VirtualMachine { } } + /// Synchronously flush this VM's stdio sinks (fd 1/2). No-op when they were + /// never created. Called before anything that must not overtake or discard + /// already-accepted `process.stdout`/`stderr` bytes: fatal-error printing + /// and exit. + #[inline] + pub fn drain_stdio(&mut self) { + if self + .rare_data + .as_ref() + .is_some_and(|r| r.stdio_sinks.iter().any(Option::is_some)) + { + // SAFETY: `self` is the live per-thread VM. + unsafe { crate::rare_data::__bun_stdio_sink_drain(self) }; + } + } + pub fn on_exit(&mut self) { // Write CPU profile if profiling was enabled - do this FIRST before any // shutdown begins. Grab the config and null it out to make this @@ -1493,6 +1509,11 @@ impl VirtualMachine { self.is_inside_deferred_task_queue.set(false); } + // Whatever process.stdout/stderr still had queued behind a slow reader + // is written now (blocking), like console.log always has been: an exit + // must not silently truncate output that was accepted before it. + self.drain_stdio(); + self.is_shutting_down = true; // Make sure we run new cleanup hooks introduced by running cleanup @@ -2043,20 +2064,8 @@ impl VirtualMachine { MAIN_THREAD_VM.store(vm, core::sync::atomic::Ordering::Release); } - // ConsoleObject is self-referential (buffers + adapters) — allocate - // stable storage and init in place. - // `console.init(Output.rawErrorWriter(), Output.rawWriter())` must - // happen BEFORE the pointer is stored/passed; the previous port left - // it as raw `MaybeUninit` (UB on first C++ read). - let mut console_box: Box> = - Box::new(core::mem::MaybeUninit::uninit()); - crate::console_object::ConsoleObject::init_in_place( - &mut console_box, - bun_core::Output::raw_error_writer(), - bun_core::Output::raw_writer(), - ); let console = - bun_core::heap::into_raw(console_box).cast::(); + bun_core::heap::into_raw(Box::new(crate::console_object::ConsoleObject::new())); let context_id = opts .context_id @@ -4642,6 +4651,16 @@ impl VirtualMachine { }; } } + // The stdio sinks carry over (they own the fd state); their JS wrapper + // belongs to the outgoing global and must not. + if self + .rare_data + .as_ref() + .is_some_and(|r| r.stdio_sinks.iter().any(Option::is_some)) + { + // SAFETY: `self` is the live per-thread VM. + unsafe { crate::rare_data::__bun_stdio_sink_release_js(self) }; + } if let Some(rare) = self.rare_data.as_deref_mut() { rare.listening_sockets_for_watch_mode.lock().clear(); // `setCallbacks` is once-only (node/src/quic/bindingdata.cc diff --git a/src/jsc/bindings/BunProcess.cpp b/src/jsc/bindings/BunProcess.cpp index 86d9f8bb4e6f..d220069512ac 100644 --- a/src/jsc/bindings/BunProcess.cpp +++ b/src/jsc/bindings/BunProcess.cpp @@ -864,6 +864,19 @@ extern "C" void Process__dispatchOnBeforeExit(Zig::GlobalObject* globalObject, u } } +// Run an internal builtin whose failure is not the caller's to propagate: a +// throw is reported like any other uncaught exception. +static void callReportingException(Zig::GlobalObject* globalObject, JSFunction* fn, const MarkedArgumentBuffer& args) +{ + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); + JSC::call(globalObject, fn, JSC::getCallData(fn), jsUndefined(), args); + if (auto* exception = scope.exception()) [[unlikely]] { + (void)scope.tryClearException(); + Zig::GlobalObject::reportUncaughtExceptionAtEventLoop(globalObject, exception); + } +} + extern "C" void Process__dispatchOnExit(Zig::GlobalObject* globalObject, uint8_t exitCode) { if (!globalObject->hasProcessObject()) { @@ -874,6 +887,24 @@ extern "C" void Process__dispatchOnExit(Zig::GlobalObject* globalObject, uint8_t if (exitCode > 0) process->m_isExitCodeObservable = true; dispatchExitInternal(globalObject, process, exitCode); + + // 'exit' handlers may still write; after them, move whatever the stdio + // Writables have queued in JS into the native sinks so the exit drain + // (FileSink::drain_sync) delivers it. Only for streams that exist. + auto& vm = JSC::getVM(globalObject); + if (vm.hasTerminationRequest() || vm.hasExceptionsAfterHandlingTraps()) + return; + JSFunction* flush = nullptr; + for (int fd = 1; fd <= 2; ++fd) { + JSObject* stream = process->stdioStream(fd); + if (!stream) + continue; + if (!flush) + flush = JSFunction::create(vm, globalObject, processObjectInternalsFlushStdioWriteStreamOnExitCodeGenerator(vm), globalObject); + MarkedArgumentBuffer args; + args.append(stream); + callReportingException(globalObject, flush, args); + } } JSC_DEFINE_HOST_FUNCTION(Process_functionUptime, (JSC::JSGlobalObject * lexicalGlobalObject, JSC::CallFrame* callFrame)) @@ -1716,6 +1747,8 @@ static int persistStandardStream(int fd) } #endif +extern "C" void bun_restore_stdio(); + JSC_DEFINE_HOST_FUNCTION(Process_functionExecve, (JSGlobalObject * lexicalGlobalObject, CallFrame* callFrame)) { Zig::GlobalObject* globalObject = defaultGlobalObject(lexicalGlobalObject); @@ -1849,6 +1882,10 @@ JSC_DEFINE_HOST_FUNCTION(Process_functionExecve, (JSGlobalObject * lexicalGlobal savedStdioFlags[fd] = prev; } + // The new image gets our stdio; hand it over in the state we found it + // (termios, O_NONBLOCK), exactly as a normal exit would. + bun_restore_stdio(); + int savedErrno; #if OS(DARWIN) @@ -2702,7 +2739,6 @@ enum class BunProcessStdinFdType : int32_t { }; extern "C" BunProcessStdinFdType Bun__Process__getStdinFdType(void*, int fd); -extern "C" void Bun__ForceFileSinkToBeSynchronousForProcessObjectStdio(JSC::JSGlobalObject*, JSC::EncodedJSValue); static JSValue constructStdioWriteStream(JSC::JSGlobalObject* globalObject, JSC::JSObject* processObject, int fd) { auto& vm = JSC::getVM(globalObject); @@ -2725,31 +2761,244 @@ static JSValue constructStdioWriteStream(JSC::JSGlobalObject* globalObject, JSC: return jsUndefined(); } - ASSERT_WITH_MESSAGE(JSC::isJSArray(result), "Expected an array from getStdioWriteStream"); - JSC::JSArray* resultObject = uncheckedDowncast(result); + // Everything about how bytes reach fd 1/2 (sync vs. queued, fd flags, + // ordering against console.*) is decided by the per-VM stdio FileSink the + // stream was built over; see FileSink::create_stdio. + if (JSObject* streamObject = result.getObject()) + uncheckedDowncast(processObject)->setStdioStream(vm, fd, streamObject); + return result; +} - // process.stdout and process.stderr differ from other Node.js streams in important ways: - // 1. They are used internally by console.log() and console.error(), respectively. - // 2. Writes may be synchronous depending on what the stream is connected to and whether the system is Windows or POSIX: - // Files: synchronous on Windows and POSIX - // TTYs (Terminals): asynchronous on Windows, synchronous on POSIX - // Pipes (and sockets): synchronous on Windows, asynchronous on POSIX - bool forceSync = false; -#if OS(WINDOWS) - forceSync = fdType == BunProcessStdinFdType::file || fdType == BunProcessStdinFdType::pipe; -#else - // Note: files are always sync anyway. - // forceSync = fdType == BunProcessStdinFdType::file || bun_stdio_tty[fd]; +void Process::setStdioStream(JSC::VM& vm, int fd, JSObject* stream) +{ + ASSERT(fd == 1 || fd == 2); + unsigned slot = static_cast(fd) - 1; + m_stdioStream[slot].set(vm, this, stream); + m_stdioPristineStructureID[slot] = stream->structureID(); + // The O(1) "is `write` still ours" test in consoleStream() is a structure + // compare, which only works if a pristine stream has no *own* `write` + // (adding one transitions the structure). See internal/fs/streams.ts. + ASSERT_WITH_MESSAGE(stream->getDirectOffset(vm, WebCore::builtinNames(vm).writePublicName()) == invalidOffset, "stdio stream must inherit write() from its prototype"); +} - // TODO: once console.* is wired up to write/read through the same buffering mechanism as FileSink for process.stdout, process.stderr, we can make this non-blocking for sockets on POSIX. - // Until then, we have to force it to be sync EVEN for sockets or else console.log() may flush at a different time than process.stdout.write. - forceSync = true; -#endif - if (forceSync) { - Bun__ForceFileSinkToBeSynchronousForProcessObjectStdio(globalObject, JSValue::encode(resultObject->getIndex(globalObject, 1))); +void Process::setConsoleStream(JSC::VM& vm, int fd, JSValue value) +{ + ASSERT(fd == 1 || fd == 2); + unsigned slot = static_cast(fd) - 1; + if (value.isEmpty() || value.isUndefined() || (m_stdioStream[slot] && value == m_stdioStream[slot].get())) { + // Rebinding to Bun's own stream (or clearing) puts the fast path back. + m_consoleStreamState[slot] = ConsoleStreamState::Native; + m_consoleStream[slot].clear(); + return; } + m_consoleStreamState[slot] = ConsoleStreamState::Custom; + m_consoleStream[slot].set(vm, this, value); +} - return resultObject->getIndex(globalObject, 0); +JSValue Process::consoleStream(JSC::JSGlobalObject* globalObject, int fd) +{ + ASSERT(fd == 1 || fd == 2); + unsigned slot = static_cast(fd) - 1; + auto& vm = JSC::getVM(globalObject); + + switch (m_consoleStreamState[slot]) { + case ConsoleStreamState::Custom: + return m_consoleStream[slot].get(); + case ConsoleStreamState::Unresolved: { + // Node binds `console._stdout` to `process.stdout` on first use and + // caches it. If nobody has touched `process.stdout` yet the lazy + // property is unreified and would produce our own stream, so bind + // native *without* building the JS object; if user code already put + // something else there, that is what the console is bound to for good. + // https://github.com/nodejs/node/blob/v24.0.0/lib/internal/console/constructor.js#L205-L234 + const Identifier& name = fd == 1 ? WebCore::builtinNames(vm).stdoutPublicName() : WebCore::builtinNames(vm).stderrPublicName(); + unsigned attributes = 0; + JSValue existing; + if (invalidOffset != structure()->get(vm, name, attributes)) { + if (attributes & PropertyAttribute::Accessor) { + // `Object.defineProperty(process, "stdout", { get })`: do the + // one [[Get]] Node's lazy binding would. May throw. + auto scope = DECLARE_THROW_SCOPE(vm); + existing = get(globalObject, name); + RETURN_IF_EXCEPTION(scope, {}); + } else { + existing = getDirect(vm, name); + } + } + if (existing && existing.isObject() && (!m_stdioStream[slot] || existing != m_stdioStream[slot].get())) { + m_consoleStreamState[slot] = ConsoleStreamState::Custom; + m_consoleStream[slot].set(vm, this, existing); + return existing; + } + m_consoleStreamState[slot] = ConsoleStreamState::Native; + [[fallthrough]]; + } + case ConsoleStreamState::Native: { + JSObject* stream = m_stdioStream[slot].get(); + if (!stream) + return {}; // never materialised: nothing can be observing it + if (m_stdioObserved[slot]) + return stream; + // A dictionary structure no longer changes ID per added property, so + // it can't vouch for "no own write" — look every time (still O(1)). + if (stream->structureID() == m_stdioPristineStructureID[slot] && !stream->structure()->isDictionary()) [[likely]] + return {}; + // Some own property was added/removed. If it wasn't `write`, adopt the + // new structure as pristine so the next call is one compare again. + if (stream->getDirectOffset(vm, WebCore::builtinNames(vm).writePublicName()) != invalidOffset) + return stream; + m_stdioPristineStructureID[slot] = stream->structureID(); + return {}; + } + } + return {}; +} + +JSValue Process::consoleStreamForGetter(JSC::JSGlobalObject* globalObject, int fd) +{ + ASSERT(fd == 1 || fd == 2); + unsigned slot = static_cast(fd) - 1; + auto& vm = JSC::getVM(globalObject); + if (m_consoleStreamState[slot] == ConsoleStreamState::Unresolved) { + auto scope = DECLARE_THROW_SCOPE(vm); + (void)consoleStream(globalObject, fd); + RETURN_IF_EXCEPTION(scope, {}); + } + if (m_consoleStreamState[slot] == ConsoleStreamState::Custom) + return m_consoleStream[slot].get(); + // Native: the real stream object (materialising it is fine here — the + // caller asked for it by name). + return get(globalObject, fd == 1 ? WebCore::builtinNames(vm).stdoutPublicName() : WebCore::builtinNames(vm).stderrPublicName()); +} + +// Empty: use the native sink. Otherwise the stream to `write()` to. `*threw` +// is set (and empty returned) if user code made `process.stdout` a throwing +// getter and this was the console's first use. +extern "C" JSC::EncodedJSValue Bun__Process__consoleStream(Zig::GlobalObject* globalObject, int32_t fd, bool* threw) +{ + if (!globalObject->hasProcessObject()) [[unlikely]] + return JSValue::encode({}); + auto* process = globalObject->processObject(); + if (!process->consoleStreamIsResolved(fd)) [[unlikely]] { + auto scope = DECLARE_THROW_SCOPE(JSC::getVM(globalObject)); + JSValue result = process->consoleStream(globalObject, fd); + if (scope.exception()) [[unlikely]] { + *threw = true; + return JSValue::encode({}); + } + RELEASE_AND_RETURN(scope, JSValue::encode(result)); + } + return JSValue::encode(process->consoleStream(globalObject, fd)); +} + +// The console's JS slow path: `stream.write(chunk)` with Node's kWriteToConsole +// error handling. `chunk` is one fully formatted message as a JS string. +extern "C" void Bun__Console__writeToStream(Zig::GlobalObject* globalObject, JSC::EncodedJSValue stream, JSC::EncodedJSValue chunk) +{ + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + JSFunction* fn = globalObject->processObject()->consoleWriteFunction(); + JSC::MarkedArgumentBuffer args; + args.append(JSValue::decode(stream)); + args.append(JSValue::decode(chunk)); + JSC::call(globalObject, fn, JSC::getCallData(fn), jsUndefined(), args); + RETURN_IF_EXCEPTION(scope, ); +} + +// A console.* write on the shared stdio sink failed terminally (EPIPE, ...): +// deliver it as 'error' on our process.stdout/stderr object if that exists and +// is listened to (processObjectInternalsReportStdioSinkError decides). +extern "C" void Bun__Process__reportStdioSinkError(Zig::GlobalObject* globalObject, int32_t fd, JSC::EncodedJSValue err) +{ + if (!globalObject->hasProcessObject()) [[unlikely]] + return; + JSObject* stream = globalObject->processObject()->stdioStream(fd); + if (!stream) + return; + auto& vm = JSC::getVM(globalObject); + // A notification, not part of the console call's contract: a throwing + // 'error' listener is reported like any other uncaught exception rather + // than surfacing out of console.log(). + JSFunction* fn = JSFunction::create(vm, globalObject, processObjectInternalsReportStdioSinkErrorCodeGenerator(vm), globalObject); + MarkedArgumentBuffer args; + args.append(stream); + args.append(JSValue::decode(err)); + callReportingException(globalObject, fn, args); +} + +// (fd) -> the stream console output for `fd` must go through, or undefined +// when the native sink may be used. Builtin-JS face of Process::consoleStream. +JSC_DEFINE_HOST_FUNCTION(jsFunctionConsoleStream, (JSGlobalObject * lexicalGlobalObject, CallFrame* callFrame)) +{ + auto* globalObject = defaultGlobalObject(lexicalGlobalObject); + int32_t fd = callFrame->argument(0).asInt32(); + ASSERT(fd == 1 || fd == 2); + auto scope = DECLARE_THROW_SCOPE(JSC::getVM(globalObject)); + JSValue stream = globalObject->processObject()->consoleStream(globalObject, fd); + RETURN_IF_EXCEPTION(scope, {}); + return JSValue::encode(stream ? stream : jsUndefined()); +} + +// (mask, publish) — see m_consoleChannelMask. +JSC_DEFINE_HOST_FUNCTION(jsFunctionSetConsoleChannels, (JSGlobalObject * lexicalGlobalObject, CallFrame* callFrame)) +{ + auto* globalObject = defaultGlobalObject(lexicalGlobalObject); + auto* publish = dynamicDowncast(callFrame->argument(1)); + ASSERT(publish); + globalObject->processObject()->setConsoleChannels(JSC::getVM(globalObject), static_cast(callFrame->argument(0).toUInt32(lexicalGlobalObject)), publish); + return JSValue::encode(jsUndefined()); +} + +// console.clear() honours the console's *stream's* `isTTY` (Node checks +// `this._stdout.isTTY`), so hand back whatever object that is if one exists; +// empty means "nobody has a stream object, use the real fd". +extern "C" JSC::EncodedJSValue Bun__Process__consoleStreamObject(Zig::GlobalObject* globalObject, int32_t fd, bool* threw) +{ + if (!globalObject->hasProcessObject()) [[unlikely]] + return JSValue::encode({}); + auto* process = globalObject->processObject(); + auto scope = DECLARE_THROW_SCOPE(JSC::getVM(globalObject)); + JSValue custom = process->consoleStream(globalObject, fd); + if (scope.exception()) [[unlikely]] { + *threw = true; + return JSValue::encode({}); + } + if (custom) + RELEASE_AND_RETURN(scope, JSValue::encode(custom)); + if (JSObject* stream = process->stdioStream(fd)) + return JSValue::encode(stream); + return JSValue::encode({}); +} + +extern "C" void Bun__Process__rawDebug(JSC::JSGlobalObject*, const JSC::EncodedJSValue* values, size_t count); + +// process._rawDebug(...args): util.format the args and write straight to fd 2, +// skipping process.stderr so it works when that stream is broken or replaced. +// https://github.com/nodejs/node/blob/v24.0.0/lib/internal/process/per_thread.js#L118-L120 +JSC_DEFINE_HOST_FUNCTION(Process_functionRawDebug, (JSGlobalObject * globalObject, CallFrame* callFrame)) +{ + Bun__Process__rawDebug(globalObject, reinterpret_cast(callFrame->addressOfArgumentsStart()), callFrame->argumentCount()); + return JSValue::encode(jsUndefined()); +} + +// (fd, bits) — see m_stdioObserved. +JSC_DEFINE_HOST_FUNCTION(jsFunctionSetStdioObserved, (JSGlobalObject * lexicalGlobalObject, CallFrame* callFrame)) +{ + auto* globalObject = defaultGlobalObject(lexicalGlobalObject); + int32_t fd = callFrame->argument(0).asInt32(); + ASSERT(fd == 1 || fd == 2); + globalObject->processObject()->setStdioObserved(fd, static_cast(callFrame->argument(1).toUInt32(lexicalGlobalObject))); + return JSValue::encode(jsUndefined()); +} + +// (fd, stream | undefined) — worker_threads rebinding of the native console. +JSC_DEFINE_HOST_FUNCTION(jsFunctionSetConsoleStream, (JSGlobalObject * lexicalGlobalObject, CallFrame* callFrame)) +{ + auto* globalObject = defaultGlobalObject(lexicalGlobalObject); + int32_t fd = callFrame->argument(0).asInt32(); + ASSERT(fd == 1 || fd == 2); + globalObject->processObject()->setConsoleStream(JSC::getVM(globalObject), fd, callFrame->argument(1)); + return JSValue::encode(jsUndefined()); } static JSValue constructStdout(VM& vm, JSObject* processObject) @@ -3415,6 +3664,11 @@ void Process::visitChildrenImpl(JSCell* cell, Visitor& visitor) visitor.append(thisObject->m_cachedCwd); visitor.append(thisObject->m_argv); visitor.append(thisObject->m_execArgv); + for (unsigned i = 0; i < 2; ++i) { + visitor.append(thisObject->m_consoleStream[i]); + visitor.append(thisObject->m_stdioStream[i]); + } + visitor.append(thisObject->m_consolePublish); thisObject->m_cpuUsageStructure.visit(visitor); thisObject->m_resourceUsageStructure.visit(visitor); @@ -3422,6 +3676,7 @@ void Process::visitChildrenImpl(JSCell* cell, Visitor& visitor) thisObject->m_bindingUV.visit(visitor); thisObject->m_bindingNatives.visit(visitor); thisObject->m_emitHelperFunction.visit(visitor); + thisObject->m_consoleWriteFunction.visit(visitor); } DEFINE_VISIT_CHILDREN(Process); @@ -4483,7 +4738,7 @@ extern "C" void Process__emitErrorEvent(Zig::GlobalObject* global, EncodedJSValu _kill Process_functionReallyKill Function 2 _linkedBinding Process_stubEmptyFunction Function 0 _preload_modules Process_stubEmptyArray PropertyCallback - _rawDebug Process_stubEmptyFunction Function 0 + _rawDebug Process_functionRawDebug Function 0 _startProfilerIdleNotifier Process_stubEmptyFunction Function 0 _stopProfilerIdleNotifier Process_stubEmptyFunction Function 0 _tickCallback Process_stubEmptyFunction Function 0 @@ -4597,6 +4852,9 @@ void Process::finishCreation(JSC::VM& vm) m_emitHelperFunction.initLater([](const JSC::LazyProperty::Initializer& init) { init.set(JSFunction::create(init.vm, init.owner->globalObject(), 2, "emit"_s, Process_functionEmitHelper, ImplementationVisibility::Private)); }); + m_consoleWriteFunction.initLater([](const JSC::LazyProperty::Initializer& init) { + init.set(JSFunction::create(init.vm, init.owner->globalObject(), consoleObjectWriteToObservedStreamCodeGenerator(init.vm), init.owner->globalObject())); + }); putDirect(vm, vm.propertyNames->toStringTagSymbol, jsString(vm, String("process"_s)), 0); putDirect(vm, Identifier::fromString(vm, "_exiting"_s), jsBoolean(false), 0); diff --git a/src/jsc/bindings/BunProcess.h b/src/jsc/bindings/BunProcess.h index 476cd7ee151c..6df9138be3e8 100644 --- a/src/jsc/bindings/BunProcess.h +++ b/src/jsc/bindings/BunProcess.h @@ -26,6 +26,8 @@ class Process : public WebCore::JSEventEmitter { // Function that looks up "emit" on "process" and calls it with the provided arguments // Only used by internal code via passing to queueNextTick LazyProperty m_emitHelperFunction; + // consoleObjectWriteToObservedStream builtin (the console's JS slow path). + LazyProperty m_consoleWriteFunction; WriteBarrier m_uncaughtExceptionCaptureCallback; WriteBarrier m_nextTickFunction; // https://github.com/nodejs/node/blob/2eff28fb7a93d3f672f80b582f664a7c701569fb/lib/internal/bootstrap/switches/does_own_process_state.js#L113-L116 @@ -33,7 +35,55 @@ class Process : public WebCore::JSEventEmitter { WriteBarrier m_argv; WriteBarrier m_execArgv; + // ── console ⇄ process.stdout/stderr binding (index 0 = fd 1, 1 = fd 2) ── + // + // Node's global console writes through `this._stdout.write(chunk)`, where + // `_stdout` lazily binds to `process.stdout` on first use and can be + // reassigned. Bun's console formats natively and writes straight to the + // per-VM stdio sink *unless doing so would be observable*: the console's + // stream was reassigned or replaced, its `write` is no longer Bun's, or it + // is corked / ended. `consoleStream()` answers that per call in O(1). + enum class ConsoleStreamState : uint8_t { + Unresolved, // console has not written to this fd yet + Native, // bound to Bun's own stdio stream (whether or not the JS object exists yet) + Custom, // bound to m_consoleStream (console._stdout = x, or process.stdout replaced before first use) + }; + ConsoleStreamState m_consoleStreamState[2] = { ConsoleStreamState::Unresolved, ConsoleStreamState::Unresolved }; + // Bits set from JS while Bun's stdio stream is corked / ended, forcing the + // JS path so Writable semantics apply. See ProcessObjectInternals.ts. + uint8_t m_stdioObserved[2] = { 0, 0 }; + WriteBarrier m_consoleStream[2]; + // Bun's own process.stdout / process.stderr object once materialised, and + // the StructureID it had when pristine (no own `write`). + WriteBarrier m_stdioStream[2]; + StructureID m_stdioPristineStructureID[2] = {}; + // diagnostics_channel: bit i set while kConsoleChannelNames[i] has + // subscribers (log, warn, error, debug, info); m_consolePublish(i, args). + WriteBarrier m_consolePublish; + public: + uint8_t m_consoleChannelMask = 0; + JSFunction* consolePublish() { return m_consolePublish.get(); } + void setConsoleChannels(JSC::VM& vm, uint8_t mask, JSFunction* publish) + { + m_consoleChannelMask = mask; + m_consolePublish.set(vm, this, publish); + } + // fd is 1 or 2 for all of these. + void setStdioStream(JSC::VM&, int fd, JSObject* stream); + JSObject* stdioStream(int fd) { return m_stdioStream[fd - 1].get(); } + void setStdioObserved(int fd, uint8_t bits) { m_stdioObserved[fd - 1] = bits; } + // `console._stdout = value` / worker stdio rebinding. `value` may be anything. + void setConsoleStream(JSC::VM&, int fd, JSValue value); + // The stream the console must deliver through via JS `write()`, or the + // empty value when the native stdio sink may be used. Never runs user code. + JSValue consoleStream(JSC::JSGlobalObject*, int fd); + bool consoleStreamIsResolved(int fd) const { return m_consoleStreamState[fd - 1] != ConsoleStreamState::Unresolved; } + // What `console._stdout` / `console._stderr` evaluate to (may materialise + // process.stdout; can throw). + JSValue consoleStreamForGetter(JSC::JSGlobalObject*, int fd); + JSFunction* consoleWriteFunction() { return m_consoleWriteFunction.getInitializedOnMainThread(this); } + Process(JSC::Structure* structure, WebCore::JSDOMGlobalObject& globalObject, Ref&& impl) : Base(structure, globalObject, WTF::move(impl)) { @@ -132,5 +182,13 @@ class Process : public WebCore::JSEventEmitter { }; JSC_DECLARE_HOST_FUNCTION(Process_functionDlopen); +// $newCppFunction("BunProcess.cpp", "jsFunctionSetStdioObserved", 2) +JSC_DECLARE_HOST_FUNCTION(jsFunctionSetStdioObserved); +// $newCppFunction("BunProcess.cpp", "jsFunctionSetConsoleStream", 2) +JSC_DECLARE_HOST_FUNCTION(jsFunctionSetConsoleStream); +// $newCppFunction("BunProcess.cpp", "jsFunctionSetConsoleChannels", 2) +JSC_DECLARE_HOST_FUNCTION(jsFunctionSetConsoleChannels); +// $newCppFunction("BunProcess.cpp", "jsFunctionConsoleStream", 1) +JSC_DECLARE_HOST_FUNCTION(jsFunctionConsoleStream); } // namespace Bun diff --git a/src/jsc/bindings/ConsoleObject.cpp b/src/jsc/bindings/ConsoleObject.cpp index 059994012fd5..8383ffc995ae 100644 --- a/src/jsc/bindings/ConsoleObject.cpp +++ b/src/jsc/bindings/ConsoleObject.cpp @@ -3,6 +3,8 @@ #include "JavaScriptCore/ArgList.h" #include "headers.h" #include "ConsoleObject.h" +#include "BunProcess.h" +#include "ZigGlobalObject.h" #include #include @@ -43,7 +45,7 @@ void ConsoleObject::messageWithTypeAndLevel(MessageType type, MessageLevel level auto args = arguments.ptr(); JSC::EncodedJSValue jsArgs[255]; - auto count = std::min(args->argumentCount(), (size_t)255); + size_t count = std::min(args->argumentCount(), (size_t)255); for (size_t i = 0; i < count; i++) { auto val = args->argumentAt(i); jsArgs[i] = JSC::JSValue::encode(val); @@ -55,6 +57,59 @@ void ConsoleObject::messageWithTypeAndLevel(MessageType type, MessageLevel level return; } + // diagnostics_channel 'console.log' / .warn / .error / .debug / .info: + // publish the argument list before formatting, only while subscribed. + // https://github.com/nodejs/node/blob/v24.0.0/lib/internal/console/constructor.js#L409-L443 + if (type == MessageType::Log) { + auto* zigGlobal = defaultGlobalObject(globalObject); + if (zigGlobal->hasProcessObject()) [[likely]] { + auto* process = zigGlobal->processObject(); + if (uint8_t mask = process->m_consoleChannelMask) [[unlikely]] { + int index = -1; + switch (level) { + case MessageLevel::Log: + index = 0; + break; + case MessageLevel::Warning: + index = 1; + break; + case MessageLevel::Error: + index = 2; + break; + case MessageLevel::Debug: + index = 3; + break; + case MessageLevel::Info: + index = 4; + break; + default: + break; + } + if (index >= 0 && (mask & (1u << index))) { + auto scope = DECLARE_THROW_SCOPE(vm); + JSC::MarkedArgumentBuffer list; + for (size_t i = 0; i < count; i++) + list.append(JSC::JSValue::decode(jsArgs[i])); + JSC::JSArray* array = JSC::constructArray(globalObject, static_cast(nullptr), list); + RETURN_IF_EXCEPTION(scope, ); + JSC::MarkedArgumentBuffer publishArgs; + publishArgs.append(JSC::jsNumber(index)); + publishArgs.append(array); + JSC::JSFunction* publish = process->consolePublish(); + JSC::call(globalObject, publish, JSC::getCallData(publish), JSC::jsUndefined(), publishArgs); + RETURN_IF_EXCEPTION(scope, ); + // Subscribers get the live argument list in Node (the same + // array is then formatted), so take back whatever they did to it. + count = std::min(static_cast(array->length()), (size_t)255); + for (size_t i = 0; i < count; i++) { + jsArgs[i] = JSC::JSValue::encode(array->getIndex(globalObject, i)); + RETURN_IF_EXCEPTION(scope, ); + } + } + } + } + } + Bun__ConsoleObject__messageWithTypeAndLevel(this->m_client, static_cast(type), static_cast(level), globalObject, jsArgs, count); } void ConsoleObject::count(JSGlobalObject* globalObject, const String& label) diff --git a/src/jsc/bindings/ZigGlobalObject.cpp b/src/jsc/bindings/ZigGlobalObject.cpp index 0816273b7d64..e2256edaaf5c 100644 --- a/src/jsc/bindings/ZigGlobalObject.cpp +++ b/src/jsc/bindings/ZigGlobalObject.cpp @@ -2915,34 +2915,27 @@ JSC_DEFINE_CUSTOM_GETTER(getConsoleConstructor, (JSGlobalObject * globalObject, return JSValue::encode(result); } -// `console._stdout` is equal to `process.stdout` -JSC_DEFINE_CUSTOM_GETTER(getConsoleStdout, (JSGlobalObject * globalObject, EncodedJSValue thisValue, PropertyName property)) +// `console._stdout` / `console._stderr`: what the global console writes +// through. Accessors (not cached data properties) so that assignment rebinds +// the native console, exactly like Node's kBindStreamsLazy get/set pair: +// https://github.com/nodejs/node/blob/v24.0.0/lib/internal/console/constructor.js#L205-L234 +JSC_DEFINE_CUSTOM_GETTER(getConsoleStdout, (JSGlobalObject * globalObject, EncodedJSValue thisValue, PropertyName)) { - auto& vm = JSC::getVM(globalObject); - auto console = JSValue::decode(thisValue).getObject(); - auto global = uncheckedDowncast(globalObject); - - // instead of calling the constructor builtin, go through the process.stdout getter to ensure it's only created once. - auto stdoutValue = global->processObject()->get(globalObject, Identifier::fromString(vm, "stdout"_s)); - if (!stdoutValue) return {}; - - console->putDirect(vm, property, stdoutValue, PropertyAttribute::DontEnum | 0); - return JSValue::encode(stdoutValue); + return JSValue::encode(uncheckedDowncast(globalObject)->processObject()->consoleStreamForGetter(globalObject, 1)); } - -// `console._stderr` is equal to `process.stderr` -JSC_DEFINE_CUSTOM_GETTER(getConsoleStderr, (JSGlobalObject * globalObject, EncodedJSValue thisValue, PropertyName property)) +JSC_DEFINE_CUSTOM_SETTER(setConsoleStdout, (JSGlobalObject * globalObject, EncodedJSValue thisValue, EncodedJSValue value, PropertyName)) { - auto& vm = JSC::getVM(globalObject); - auto console = JSValue::decode(thisValue).getObject(); - auto global = uncheckedDowncast(globalObject); - - // instead of calling the constructor builtin, go through the process.stdout getter to ensure it's only created once. - auto stderrValue = global->processObject()->get(globalObject, Identifier::fromString(vm, "stderr"_s)); - if (!stderrValue) return {}; - - console->putDirect(vm, property, stderrValue, PropertyAttribute::DontEnum | 0); - return JSValue::encode(stderrValue); + uncheckedDowncast(globalObject)->processObject()->setConsoleStream(JSC::getVM(globalObject), 1, JSValue::decode(value)); + return true; +} +JSC_DEFINE_CUSTOM_GETTER(getConsoleStderr, (JSGlobalObject * globalObject, EncodedJSValue thisValue, PropertyName)) +{ + return JSValue::encode(uncheckedDowncast(globalObject)->processObject()->consoleStreamForGetter(globalObject, 2)); +} +JSC_DEFINE_CUSTOM_SETTER(setConsoleStderr, (JSGlobalObject * globalObject, EncodedJSValue thisValue, EncodedJSValue value, PropertyName)) +{ + uncheckedDowncast(globalObject)->processObject()->setConsoleStream(JSC::getVM(globalObject), 2, JSValue::decode(value)); + return true; } // The CommonJS `require()` machinery (`@requireESM`, `@loadEsmIntoCjs`, @@ -3226,8 +3219,10 @@ void GlobalObject::addBuiltinGlobals(JSC::VM& vm) consoleObject->putDirectBuiltinFunction(vm, this, vm.propertyNames->asyncIteratorSymbol, consoleObjectAsyncIteratorCodeGenerator(vm), PropertyAttribute::Builtin | 0); consoleObject->putDirectBuiltinFunction(vm, this, clientData->builtinNames().writePublicName(), consoleObjectWriteCodeGenerator(vm), PropertyAttribute::Builtin | 0); consoleObject->putDirectCustomAccessor(vm, Identifier::fromString(vm, "Console"_s), CustomGetterSetter::create(vm, getConsoleConstructor, nullptr), PropertyAttribute::CustomValue | 0); - consoleObject->putDirectCustomAccessor(vm, Identifier::fromString(vm, "_stdout"_s), CustomGetterSetter::create(vm, getConsoleStdout, nullptr), PropertyAttribute::DontEnum | PropertyAttribute::CustomValue | 0); - consoleObject->putDirectCustomAccessor(vm, Identifier::fromString(vm, "_stderr"_s), CustomGetterSetter::create(vm, getConsoleStderr, nullptr), PropertyAttribute::DontEnum | PropertyAttribute::CustomValue | 0); + consoleObject->putDirectCustomAccessor(vm, Identifier::fromString(vm, "_stdout"_s), CustomGetterSetter::create(vm, getConsoleStdout, setConsoleStdout), PropertyAttribute::DontEnum | PropertyAttribute::CustomAccessor | 0); + consoleObject->putDirectCustomAccessor(vm, Identifier::fromString(vm, "_stderr"_s), CustomGetterSetter::create(vm, getConsoleStderr, setConsoleStderr), PropertyAttribute::DontEnum | PropertyAttribute::CustomAccessor | 0); + // Node parity: the global console ignores stream errors. + consoleObject->putDirect(vm, Identifier::fromString(vm, "_ignoreErrors"_s), jsBoolean(true), PropertyAttribute::DontEnum | 0); } // ===================== start conditional builtin globals ===================== diff --git a/src/jsc/bindings/c-bindings.cpp b/src/jsc/bindings/c-bindings.cpp index f5270305621d..0021ec1ab288 100644 --- a/src/jsc/bindings/c-bindings.cpp +++ b/src/jsc/bindings/c-bindings.cpp @@ -497,12 +497,53 @@ static termios termios_to_restore_later[3]; // from normal execution; sig_atomic_t is the only integral type POSIX // guarantees can be accessed atomically across that boundary. extern "C" volatile sig_atomic_t bun_stdio_modified[3] = { 0, 0, 0 }; + +// Startup snapshot of each stdio fd's file status flags and identity, so the +// O_NONBLOCK bit can be put back on exit if we (or a child sharing the open +// file description) changed it. Same contract as Node's ResetStdio(): +// https://github.com/nodejs/node/blob/v24.0.0/src/node.cc#L662-L722 +static struct { + int flags; // fcntl(F_GETFL) at startup, or -1 + dev_t dev; + ino_t ino; +} bun_stdio_startup_state[3] = { { -1, 0, 0 }, { -1, 0, 0 }, { -1, 0, 0 } }; + +static void bun_restore_stdio_nonblock() +{ + for (int fd = 0; fd < 3; fd++) { + auto& s = bun_stdio_startup_state[fd]; + if (s.flags == -1) + continue; + + struct stat st; + if (fstat(fd, &st) == -1) + continue; // Program closed the file descriptor. + if (st.st_dev != s.dev || st.st_ino != s.ino) + continue; // Program reopened the file descriptor as something else. + + int flags; + do + flags = fcntl(fd, F_GETFL); + while (flags == -1 && errno == EINTR); + if (flags == -1) + continue; + + if ((flags ^ s.flags) & O_NONBLOCK) { + flags = (flags & ~O_NONBLOCK) | (s.flags & O_NONBLOCK); + int err; + do + err = fcntl(fd, F_SETFL, flags); + while (err == -1 && errno == EINTR); + } + } +} #endif extern "C" void bun_restore_stdio() { #if !OS(WINDOWS) + bun_restore_stdio_nonblock(); // Only suppress the restore when Bun is a pipeline producer (stdout is a // pipe, not a TTY) and it didn't touch termios itself. That's the #29592 @@ -589,7 +630,7 @@ extern "C" void bun_initialize_process() #if OS(LINUX) || OS(DARWIN) || OS(FREEBSD) int devNullFd_ = -1; - bool anyTTYs = false; + bool restoreOnSignal = false; const auto setDevNullFd = [&](int target_fd) -> void { bun_is_stdio_null[target_fd] = 1; @@ -621,8 +662,27 @@ extern "C" void bun_initialize_process() if (errno == EBADF) [[unlikely]] { // the fd is invalid, let's make sure it's always valid setDevNullFd(fd); + continue; } - } else { + } + + { + struct stat st; + int flags; + do + flags = fcntl(fd, F_GETFL); + while (flags == -1 && errno == EINTR); + if (flags != -1 && fstat(fd, &st) == 0) { + bun_stdio_startup_state[fd] = { flags, st.st_dev, st.st_ino }; + // A FIFO is the one stdio kind whose O_NONBLOCK bit we may flip + // (FileSink::stdio_go_nonblocking), so make sure the signal-exit + // path restores it. + if (S_ISFIFO(st.st_mode)) + restoreOnSignal = true; + } + } + + if (result != 0) { bun_stdio_tty[fd] = 1; int err = 0; @@ -631,7 +691,7 @@ extern "C" void bun_initialize_process() } while (err == -1 && errno == EINTR); if (err == 0) [[likely]] { - anyTTYs = true; + restoreOnSignal = true; } } } @@ -641,8 +701,8 @@ extern "C" void bun_initialize_process() close(devNullFd_); } - // Restore TTY state on exit - if (anyTTYs) { + // Restore TTY state / O_NONBLOCK on exit + if (restoreOnSignal) { struct sigaction sa; memset(&sa, 0, sizeof(sa)); sigemptyset(&sa.sa_mask); @@ -650,8 +710,15 @@ extern "C" void bun_initialize_process() sa.sa_flags = SA_RESETHAND; sa.sa_handler = onExitSignal; - sigaction(SIGTERM, &sa, nullptr); - sigaction(SIGINT, &sa, nullptr); + for (int sig : { SIGTERM, SIGINT }) { + // An inherited SIG_IGN (`trap '' INT TERM; bun ... | cat`) means the + // signal can't terminate us, so there is nothing to restore on it — + // and replacing it would make us (and our children) killable again. + struct sigaction current; + if (sigaction(sig, nullptr, ¤t) == 0 && current.sa_handler == SIG_IGN) + continue; + sigaction(sig, &sa, nullptr); + } } #elif OS(WINDOWS) for (int fd = 0; fd <= 2; ++fd) { diff --git a/src/jsc/rare_data.rs b/src/jsc/rare_data.rs index 91712de44360..b3882bfabb12 100644 --- a/src/jsc/rare_data.rs +++ b/src/jsc/rare_data.rs @@ -207,6 +207,13 @@ pub struct RareData { pub stdout_store: Option>, pub(crate) stdout_mode: Mode, + /// Erased `*mut bun_runtime::webcore::FileSink` — the per-VM stdio sink for + /// fd 1 (`[0]`) and fd 2 (`[1]`). Everything this JS thread writes to those + /// fds (console, `process.stdout/stderr`, `Bun.stdout.writer()`, ...) goes + /// through these; created lazily by `bun_runtime` (`stdio_sink_for`), one + /// intrusive ref owned here and released in `Drop`. + pub stdio_sinks: [Option>; 2], + pub(crate) entropy_cache: Option>, pub(crate) hot_map: Option, @@ -304,6 +311,7 @@ impl Default for RareData { stdin_mode: 0, stdout_store: None, stdout_mode: 0, + stdio_sinks: [None, None], entropy_cache: None, hot_map: None, cron_jobs: Vec::new(), @@ -624,6 +632,18 @@ impl RareData { pub(crate) fn release_js_handles(&mut self) { self.s3_default_client.deinit(); self.node_quic_callbacks.deinit(); + self.release_stdio_sinks(); + } + + /// Drop `RareData`'s ref on the stdio sinks. Must run while the event loop + /// (poll deregistration) and JSC (a live `JSFileSink` wrapper may hold the + /// other ref) are both still up — i.e. from `release_js_handles`, never + /// from `Drop`. + pub fn release_stdio_sinks(&mut self) { + for sink in self.stdio_sinks.iter_mut().filter_map(Option::take) { + // SAFETY: `sink` is the +1 pointer `stdio_sink_for` stored. + unsafe { __bun_stdio_sink_deinit(sink.as_ptr().cast()) }; + } } // ── trivial field accessors ──────────────────────────────────────────── @@ -905,6 +925,13 @@ impl RareData { unsafe extern "Rust" { safe fn __bun_stdio_blob_store_deinit(ptr: *mut ()); + /// Releases `RareData`'s ref on a stdio `FileSink` (defined in + /// `bun_runtime::webcore::file_sink`). `ptr` is the exact pointer stored in + /// `stdio_sinks`. + fn __bun_stdio_sink_deinit(ptr: *mut ()); + /// Synchronously drain both stdio sinks of `vm` (defined next to the above). + pub(crate) fn __bun_stdio_sink_drain(vm: *mut VirtualMachine); + pub(crate) fn __bun_stdio_sink_release_js(vm: *mut VirtualMachine); } impl RareData { @@ -1092,6 +1119,9 @@ impl Drop for RareData { { __bun_stdio_blob_store_deinit(store.as_ptr().cast()); } + // `release_js_handles()` ran before the loop / JSC went away; a sink + // still here would need both to deinit, so it is deliberately leaked. + debug_assert!(self.stdio_sinks.iter().all(Option::is_none)); // closeAllSocketGroups() must have already run (before JSC teardown) so // these are empty; deinit() asserts that in debug. diff --git a/src/runtime/cli/test_command.rs b/src/runtime/cli/test_command.rs index be81fc85e8b6..8d4e353d96a6 100644 --- a/src/runtime/cli/test_command.rs +++ b/src/runtime/cli/test_command.rs @@ -2365,6 +2365,13 @@ impl TestCommand { _ = vm.global().set_time_zone(&ZigString::init(tz_name)); } + // Materialise the runner's stdio sinks (a dup of fd 1 / fd 2 each) up + // front rather than at some test's first `console.log`, so tests that + // audit open fds around their own work see a stable set. + for fd in [bun_sys::Fd::stdout(), bun_sys::Fd::stderr()] { + let _ = crate::webcore::file_sink::stdio_sink_for(vm, fd); + } + if ctx.test_options.test_worker { // Worker mode: skip discovery; files arrive over stdin and // results go out over fd 3. Never returns. diff --git a/src/runtime/jsc_hooks.rs b/src/runtime/jsc_hooks.rs index cd6e9a438d65..cc0f1e2276ed 100644 --- a/src/runtime/jsc_hooks.rs +++ b/src/runtime/jsc_hooks.rs @@ -322,6 +322,8 @@ unsafe fn init_runtime_state( // PORTING.md §Forbidden permits // `into_raw`-without-reclaim only for true process-lifetime singletons via // `OnceLock`, which this is not (per-VM / per-Worker-thread). + bun_sys::set_stdio_write_hook(crate::webcore::file_sink::before_output_write); + let state = bun_core::heap::into_raw(Box::new(RuntimeState { timer: timer::All::init(), sql_rare: bun_sql_jsc::jsc::RareData { diff --git a/src/runtime/node/node_fs.rs b/src/runtime/node/node_fs.rs index da513be2cf3f..cd3ed84fa906 100644 --- a/src/runtime/node/node_fs.rs +++ b/src/runtime/node/node_fs.rs @@ -4891,7 +4891,7 @@ impl NodeFS { let mut broke = false; 'toplevel: while remain > 0 { let read_len = (buf.len() as u64).min(remain) as usize; - let amt = match Syscall::read(src_fd, &mut buf[..read_len]) { + let amt = match Syscall::read_retrying(src_fd, &mut buf[..read_len]) { Ok(result) => result, Err(err) => { return Err(if !src.is_empty() { @@ -4911,7 +4911,7 @@ impl NodeFS { let mut slice = &buf[..amt]; while !slice.is_empty() { - let written = match Syscall::write(dest_fd, slice) { + let written = match Syscall::write_retrying(dest_fd, slice) { Ok(result) => result, Err(err) => { return Err(if !dest.is_empty() { diff --git a/src/runtime/webcore/Blob.rs b/src/runtime/webcore/Blob.rs index ce3ead8dc495..7bda04aecc02 100644 --- a/src/runtime/webcore/Blob.rs +++ b/src/runtime/webcore/Blob.rs @@ -1799,6 +1799,16 @@ impl BlobExt for Blob { ); } + // fd 1 / fd 2: every writer on this VM shares the one stdio sink, so + // `Bun.stdout.writer()`, `process.stdout` and `console.log` can never + // reorder or hold separate queues (and never open a second dup / flip + // the description's flags a second time). + if let Some(stdio_fd) = stdio_fd_of_store(global_this, &store) { + if let Some(js) = webcore::file_sink::stdio_sink_js(global_this, stdio_fd) { + return Ok(js); + } + } + #[cfg(windows)] { use bun_io::pipe_writer::BaseWindowsPipeWriter as _; @@ -4985,6 +4995,33 @@ pub(crate) fn write_file_with_source_destination( /// - If `path_or_blob` is a detached blob /// ## Panics /// - If `path_or_blob` is a `Blob` backed by a byte store +/// `Some(Fd::stdout()|stderr())` when `store` is this VM's stdout/stderr store +/// or any fd-backed store on fd 1/2 — i.e. writes to it belong to the stdio sink. +pub(crate) fn stdio_fd_of_store(global_this: &JSGlobalObject, store: &Store) -> Option { + let store::Data::File(ref file) = store.data else { + return None; + }; + let PathOrFileDescriptor::Fd(fd) = file.pathlike else { + return None; + }; + match fd.stdio_tag() { + Some(bun_core::Stdio::StdOut) => return Some(Fd::stdout()), + Some(bun_core::Stdio::StdErr) => return Some(Fd::stderr()), + _ => {} + } + // SAFETY: bun_vm() never returns null for a Bun-owned global. + let vm = global_this.bun_vm().as_mut(); + let rare = vm.rare_data.as_ref()?; + let store_ptr = core::ptr::from_ref(store).cast::().cast_mut(); + if rare.stdout_store.map(|p| p.as_ptr()) == Some(store_ptr) { + Some(Fd::stdout()) + } else if rare.stderr_store.map(|p| p.as_ptr()) == Some(store_ptr) { + Some(Fd::stderr()) + } else { + None + } +} + pub(crate) fn write_file_internal( global_this: &JSGlobalObject, path_or_blob_: &mut PathOrBlob, @@ -5031,6 +5068,62 @@ pub(crate) fn write_file_internal( } } + // Bun.write(Bun.stdout | Bun.stderr, data): through the stdio sink, so it is + // ordered with console.* / process.stdout and gets the same EAGAIN handling + // (and never lands on the thread pool, whose LIFO queue reversed + // back-to-back writes to a file-backed stdout). + if let PathOrBlob::Blob(ref b) = *path_or_blob { + if b.offset.get() == 0 && !b.is_s3() { + let stdio_fd = b + .store + .get() + .as_deref() + .and_then(|st| stdio_fd_of_store(global_this, st)); + if let Some(stdio_fd) = stdio_fd { + // SAFETY: bun_vm() is the live VM owning `global_this`. + let vm = global_this.bun_vm().as_mut(); + if let Some(sink) = webcore::file_sink::stdio_sink_for(vm, stdio_fd) { + // SAFETY: canonical live pointer held by RareData. + if let Some((result, accepted)) = + unsafe { (*sink).write_js_value(global_this, data, true)? } + { + // `Bun.write` resolves once the bytes are written, with + // *this* call's byte count — not whenever (and with + // whatever total) the shared sink's queue drains. + let written = match result { + streams::Writable::Err(err) => Err(err), + other => { + if matches!(other, streams::Writable::Pending(_)) { + // Settled right here, not through the pending promise. + // SAFETY: as above. + unsafe { (*sink).uncredit_pending(accepted) }; + } + // SAFETY: as above. + unsafe { webcore::FileSink::drain_sync(sink) }.map(|()| accepted) + } + }; + return Ok(match written { + Ok(n) => JSPromise::resolved_promise_value( + global_this, + JSValue::js_number(n as f64), + ), + Err(err) => { + JSPromise::dangerously_create_rejected_promise_value_without_notifying_vm( + global_this, + err.to_js(global_this), + ) + } + }); + } + // A Blob / stream source takes the general path below; at + // least keep it behind anything already queued. + // SAFETY: as above. + let _ = unsafe { webcore::FileSink::drain_sync(sink) }; + } + } + } + } + // If you're doing Bun.write(), try to go fast by writing short input on the main thread. // This is a heuristic, but it's a good one. // diff --git a/src/runtime/webcore/FileSink.rs b/src/runtime/webcore/FileSink.rs index 1b0e09d8831a..c61ea173c3e2 100644 --- a/src/runtime/webcore/FileSink.rs +++ b/src/runtime/webcore/FileSink.rs @@ -55,6 +55,19 @@ pub struct FileSink { pub(crate) is_socket: Cell, pub(crate) fd: Cell, + /// `Fd::stdout()` / `Fd::stderr()` when this is the per-VM stdio sink for + /// that fd (see [`FileSink::create_stdio`]); `Fd::INVALID` otherwise. + pub(crate) stdio: Cell, + /// The one `JSFileSink` wrapper for a stdio sink, shared by + /// `process.stdout/stderr`, `Bun.stdout.writer()` and `Bun.file(1|2).writer()`. + /// Cleared before JSC teardown in `__bun_stdio_sink_deinit`. + pub(crate) stdio_js: JsCell, + /// A stdio sink's terminal write error (EPIPE, EIO, ...). Sticky: the fd + /// is gone, so every later write reports the same error again — which is + /// how Node's stdio streams behave (each write() re-fails) and what lets + /// `'error'` fire per call instead of the sink going quietly inert. + pub(crate) stdio_error: JsCell>, + pub(crate) auto_flusher: JsCell, pub(crate) run_pending_later: FlushPendingTask, @@ -223,73 +236,6 @@ impl FileSink { } } -#[unsafe(no_mangle)] -pub(crate) extern "C" fn Bun__ForceFileSinkToBeSynchronousForProcessObjectStdio( - _global: *mut JSGlobalObject, - jsvalue: JSValue, -) { - let Some(this_ptr) = JSSink::from_js(jsvalue) else { - return; - }; - // SAFETY: `from_js` returned a live `*mut JSSink` (= ThisSink); the - // first field is `sink: FileSink`, so `&(*this_ptr).sink` recovers the - // wrapped `*FileSink`. - let this: &FileSink = unsafe { &(*this_ptr).sink }; - - #[cfg(not(windows))] - { - this.force_sync.set(true); - // SAFETY(JsCell): single-field write; does not call into JS. - this.writer.with_mut(|w| w.force_sync = true); - if this.fd.get() != Fd::INVALID { - let _ = sys::update_nonblocking(this.fd.get(), false); - } - } - #[cfg(windows)] - { - // SAFETY(JsCell): closure does not call into JS — pure libuv FFI. - let did_set_blocking = this.writer.with_mut(|w| { - if let Some(source) = w.source.as_mut() { - match source { - bun_io::Source::Pipe(pipe) => { - // SAFETY: `pipe` is a live `Box` owned by `writer.source`; - // `uv_pipe_t` is `#[repr(C)]` with `uv_stream_t` as its first field - // (libuv handle subtyping), so the pointer cast is valid. - let rc = unsafe { - uv::uv_stream_set_blocking( - (&mut **pipe) as *mut uv::Pipe as *mut uv::uv_stream_t, - 1, - ) - }; - if rc == uv::ReturnCode::ZERO { - return true; - } - } - bun_io::Source::Tty(tty) => { - // SAFETY: `tty` is a live `NonNull` (heap or static stdin tty); - // `uv_tty_t` embeds `uv_stream_t` as its first field, so the cast is the - // libuv handle-subtype downcast. - let rc = unsafe { - uv::uv_stream_set_blocking(tty.as_ptr().cast::(), 1) - }; - if rc == uv::ReturnCode::ZERO { - return true; - } - } - _ => {} - } - } - false - }); - if did_set_blocking { - return; - } - - // Fallback to WriteFile() if it fails. - this.force_sync.set(true); - } -} - impl FileSink { /// `bun.spawn`'s subprocess exited while this `FileSink` was its stdin. /// @@ -408,6 +354,10 @@ impl FileSink { (*this) .writer .with_mut(|w| w.update_ref(evtloop, has_pending_data)); + #[cfg(not(windows))] + if !has_pending_data && (*this).is_stdio() { + (*this).writer.with_mut(|w| w.unregister_poll()); + } if has_pending_data { if let Some(vm) = (*this).js_vm() { @@ -417,7 +367,16 @@ impl FileSink { } } - // if we are not done yet and has pending data we just wait so we do not runPending twice + // Bytes still queued (backed up, or a small write coalesced behind an + // earlier remainder): whoever is waiting on the pending promise keeps + // waiting until they are actually out, so `'drain'` can't fire early. + // (Windows reports per completed `uv_write`; its queue drains through + // further `on_write`s, see the TODO above.) + #[cfg(not(windows))] + if has_pending_data && status != WriteStatus::EndOfFile { + return; + } + #[cfg(windows)] if status == WriteStatus::Pending && has_pending_data { return; } @@ -475,15 +434,25 @@ impl FileSink { // drop the last reference and free `this` before that `close()` runs. // SAFETY: caller contract — `this` is live with write+dealloc provenance. unsafe { + let err = if (*this).is_stdio() { + (*this).stdio_latch_error(err) + } else { + err + }; if (*this).pending.get().state == streams::PendingState::Pending { (*this) .pending .with_mut(|p| p.result = streams::Writable::Err(err)); - if let Some(vm) = (*this).js_vm() { - if vm.is_inside_deferred_task_queue.get() { - (*this).run_pending_later(); - return; - } + // A stdio sink can get here from inside `drain_sync` / + // `write_all_sync` (console.log, exit) with the writer borrowed + // and the stdio lock held: settle on the next tick, never here. + let defer = (*this).is_stdio() + || (*this) + .js_vm() + .is_some_and(|vm| vm.is_inside_deferred_task_queue.get()); + if defer { + (*this).run_pending_later(); + return; } FileSink::run_pending(this); @@ -600,6 +569,412 @@ impl FileSink { this } + // ── stdio sinks ──────────────────────────────────────────────────────── + // + // One `FileSink` per (VM, fd ∈ {1, 2}) is *the* owner of every byte this + // JS thread sends to that fd: `console.*` formats a whole message and hands + // it to `write_all_sync`, `process.stdout`/`stderr`, `Bun.stdout.writer()` + // and `Bun.write(Bun.stdout, ..)` are JS façades over the same object, and + // `process.exit()` / fatal-error printing call `drain_sync` first. That is + // what gives Node's ordering guarantees (console.log is literally + // `process.stdout.write` there) without routing the console through JS. + // + // fd mode is decided once here and nowhere else: + // tty / regular file / anything else → blocking `write(2)`, description + // flags left untouched; + // FIFO → eager (syscall on every write) but non-blocking so a slow + // reader queues + `'drain'`s instead of stalling the loop — via + // `RWF_NOWAIT` where the kernel has it, else `O_NONBLOCK` (restored at + // exit by `bun_restore_stdio`, cleared for children by spawn); + // socket → `send(MSG_DONTWAIT)`, no description flag needed; + // Windows → the existing synchronous `SyncFile` path for everything. + // Every write loop below treats `EAGAIN` as "wait for POLLOUT", so nothing + // here depends on the description actually being in the mode we chose. + + /// Whether this sink is the VM's stdout/stderr sink. + #[inline] + pub fn is_stdio(&self) -> bool { + self.stdio.get() != Fd::INVALID + } + + /// stdio sinks report every fd error as `syscall: 'write'` (Node's stdio + /// streams do; ours may have used `send(2)`/`pwritev2(2)` underneath) and + /// latch the first one — see `stdio_error`. + fn stdio_latch_error(&self, mut err: sys::Error) -> sys::Error { + debug_assert!(self.is_stdio()); + err.syscall = sys::Tag::write; + if self.stdio_error.get().is_none() { + self.stdio_error.with_mut(|e| *e = Some(err.clone())); + } + err + } + + /// The latched terminal error of a stdio sink, if any. + #[inline] + pub fn stdio_error(&self) -> Option { + let e = self.stdio_error.get(); + if e.is_none() { None } else { e.clone() } + } + + /// The shared JS wrapper for a stdio sink (created on first request, and + /// again if a previous holder `close()`d — which detaches the wrapper but, + /// for stdio, only flushes the sink). + /// + /// Handing out a JS writer is also the point where a FIFO goes + /// non-blocking: `process.stdout.write()` / `FileSink.write()` promise + /// Node's "returns false, emits 'drain'" instead of stalling the loop, and + /// that needs `EAGAIN` from the kernel. Console-only programs never get + /// here and keep a plain blocking fd (nothing shared with a parent shell + /// or sibling process changes under them). + /// + /// # Safety + /// `this` is the canonical live stdio sink pointer held by `RareData`. + pub unsafe fn stdio_js(this: *mut FileSink, global: &JSGlobalObject) -> JSValue { + // SAFETY: caller contract; each access is a scoped reborrow. + unsafe { + debug_assert!((*this).is_stdio()); + if let Some(v) = (*this).stdio_js.get().get() { + if JSSink::from_js(v).is_some() { + return v; + } + } + #[cfg(not(windows))] + (*this).stdio_go_nonblocking(); + let v = (*this).to_js(global); + // SAFETY(JsCell): `Strong::set` is a root-slot write; no JS re-entry. + (*this).stdio_js.with_mut(|s| s.set(global, v)); + v + } + } + + /// Forget the cached wrapper (it stays valid for whoever holds it; the next + /// `stdio_js` makes a new one in the then-current global). + pub fn release_stdio_js(&self) { + self.stdio_js.with_mut(|s| s.deinit()); + } + + /// See [`stdio_js`](Self::stdio_js). FIFO only; sockets get per-call + /// `MSG_DONTWAIT`, Linux ≥ 6.4 pipes honour `RWF_NOWAIT` on a blocking + /// description (torvalds/linux@afed6271f5b0, "pipe: set FMODE_NOWAIT on + /// pipes"), and ttys / files stay blocking as in Node. + #[cfg(not(windows))] + fn stdio_go_nonblocking(&self) { + if self.nonblocking.get() || !self.pollable.get() || self.is_socket.get() { + return; + } + #[cfg(any(target_os = "linux", target_os = "android"))] + { + let v = bun_core::linux_kernel_version(); + if (v.major > 6 || (v.major == 6 && v.minor >= 4)) + && sys::linux::RWFFlagSupport::is_maybe_supported() + { + return; + } + } + let fd = self.writer.get().get_fd(); + if fd == Fd::INVALID { + return; + } + let already = sys::get_fcntl_flags(fd) + .map(|f| f as i32 & sys::O::NONBLOCK != 0) + .unwrap_or(false); + if already || sys::set_nonblocking(fd).is_ok() { + self.nonblocking.set(true); + if let Some(poll) = self.writer.get().get_poll() { + poll.set_flag(bun_io::FilePollFlag::Nonblocking); + } + } + } + + /// Build the stdio sink for `stdio_fd` (1 or 2). Returns a +1 ref. + #[cfg(not(windows))] + pub fn create_stdio( + event_loop: impl Into, + stdio_fd: Fd, + ) -> sys::Result<*mut FileSink> { + debug_assert!(stdio_fd == Fd::stdout() || stdio_fd == Fd::stderr()); + + // Our own fd number for the same description, so a JS `close()`/GC can + // close *something* without ever closing 1/2, and so the poll has an + // fd it owns. + let fd = sys::dup(stdio_fd)?; + let (pollable, is_socket) = match sys::fstat(fd) { + Ok(st) => { + let mode = st.st_mode as sys::Mode; + ( + sys::S::ISFIFO(mode) || sys::S::ISSOCK(mode), + sys::S::ISSOCK(mode), + ) + } + Err(_) => (false, false), + }; + + let this = Self::create(event_loop, fd); + // SAFETY: `this` was just allocated and is the sole reference. + unsafe { + (*this).stdio.set(stdio_fd); + (*this).pollable.set(pollable); + (*this).is_socket.set(is_socket); + (*this).force_sync.set(!pollable); + (*this).writer.with_mut(|w| { + w.force_sync = !pollable; + // Idle stdio sinks keep no poll registered; `AutoFlusher` + // flushes coalesced `Bun.stdout.writer()` writes at end of tick. + w.poll_flushes_buffer = false; + }); + (*this).nonblocking.set( + pollable + && !is_socket + && sys::get_fcntl_flags(fd).is_ok_and(|f| f as i32 & sys::O::NONBLOCK != 0), + ); + + // Registered with the loop only while backed up (see `start_lazy`). + if let Err(err) = (*this).writer.with_mut(|w| w.start_lazy(fd, pollable)) { + fd.close(); + (*this).fd.set(Fd::INVALID); + FileSink::deref(this); + return Err(err); + } + if let Some(poll) = (*this).writer.get().get_poll() { + poll.set_flag(if is_socket { + bun_io::FilePollFlag::Socket + } else if (*this).nonblocking.get() { + bun_io::FilePollFlag::Nonblocking + } else { + bun_io::FilePollFlag::Fifo + }); + } + (*this).started.set(true); + } + Ok(this) + } + + #[cfg(windows)] + pub fn create_stdio( + event_loop: impl Into, + stdio_fd: Fd, + ) -> sys::Result<*mut FileSink> { + debug_assert!(stdio_fd == Fd::stdout() || stdio_fd == Fd::stderr()); + let this = Self::init(stdio_fd, event_loop); + // SAFETY: `this` was just allocated and is the sole reference. + unsafe { + (*this).stdio.set(stdio_fd); + (*this).force_sync.set(true); + (*this).writer.with_mut(|w| w.owns_fd = false); + if let Err(err) = (*this).writer.with_mut(|w| w.start_sync(stdio_fd, false)) { + FileSink::deref(this); + return Err(err); + } + let evtloop = (*this).io_evtloop(); + (*this).writer.with_mut(|w| w.update_ref(evtloop, false)); + (*this).started.set(true); + } + Ok(this) + } + + /// Write everything queued in the writer to the fd *now*, blocking (via + /// `poll`) if the description is non-blocking and the reader is slow. Used + /// before console output, before fatal-error printing and at exit, so that + /// bytes `process.stdout.write()` had to queue never come out after (or get + /// dropped in favour of) what follows. + /// + /// # Safety + /// `this` must be the canonical live `*mut FileSink` (see + /// [`on_attached_process_exit`](Self::on_attached_process_exit)); settling + /// a pending write schedules a task but never re-enters JS synchronously. + pub unsafe fn drain_sync(this: *mut FileSink) -> sys::Result<()> { + // SAFETY: caller contract. + unsafe { + if !(*this).writer.get().has_pending_data() { + return Ok(()); + } + let _lock = bun_io::StdioLock::acquire((*this).stdio.get()); + let _guard = FileSinkRef::new_ref(this); + + let mut result = Ok(()); + loop { + if !(*this).writer.get().has_pending_data() { + break; + } + // SAFETY(JsCell): `flush` is pure I/O; `drain_buffered_data` + // does not call `on_write`. It may call the writer's + // `on_error` (→ `FileSink::on_error`, which only schedules). + match (*this).writer.with_mut(|w| w.flush()) { + WriteResult::Err(err) => { + let err = (*this).stdio_latch_error(err); + if (*this).pending.get().state == streams::PendingState::Pending { + (*this) + .pending + .with_mut(|p| p.result = streams::Writable::Err(err.clone())); + (*this).run_pending_later(); + } + (*this).writer.with_mut(|w| w.end()); + result = Err(err); + break; + } + WriteResult::Done(n) => { + (*this).written.set((*this).written.get() + n); + break; + } + WriteResult::Wrote(n) | WriteResult::Pending(n) => { + (*this).written.set((*this).written.get() + n); + if (*this).writer.get().has_pending_data() { + #[cfg(unix)] + { + let fd = (*this).writer.get().get_fd(); + if fd == Fd::INVALID || !sys::wait_until_writable(fd) { + break; + } + } + } + } + } + } + + // The queue is empty (or the fd is gone): whatever JS was waiting + // on backpressure is settled on the next tick, never from here. + (*this).update_ref(false); + #[cfg(not(windows))] + (*this).writer.with_mut(|w| w.unregister_poll()); + if result.is_ok() && (*this).pending.get().state == streams::PendingState::Pending { + (*this).run_pending_later(); + } + if (*this).source_pending_pull.replace(false) { + let mut src = *(*this).source.get(); + src.ready(None, None); + } + // `drain_buffered_data` reports a write that failed part-way as the + // bytes it did push (routing the error through `on_error`); the + // latch is what says the rest never made it. + match (result, (*this).stdio_error()) { + (Ok(()), Some(err)) => Err(err), + (result, _) => result, + } + } + } + + /// The console path: one fully formatted message (or a spilled part of + /// one), delivered before this returns. Anything already queued goes out + /// first (ordering), then `bytes` are written straight from the caller's + /// buffer. The caller holds [`bun_io::StdioLock`] for this fd for the whole + /// message. + /// + /// # Safety + /// Same contract as [`drain_sync`](Self::drain_sync). + pub unsafe fn write_all_sync(this: *mut FileSink, bytes: &[u8]) -> sys::Result<()> { + // SAFETY: caller contract. + unsafe { + if (*this).writer.get().has_pending_data() { + FileSink::drain_sync(this)?; + } + if let Some(err) = (*this).stdio_error() { + return Err(err); + } + + #[cfg(windows)] + { + // `SyncFile` writes are already a blocking loop. + match (*this).writer.with_mut(|w| w.write(bytes)) { + WriteResult::Err(err) => Err((*this).stdio_latch_error(err)), + _ => { + (*this).written.set((*this).written.get() + bytes.len()); + Ok(()) + } + } + } + + #[cfg(not(windows))] + { + let mut bytes = bytes; + let fd = (*this).writer.get().get_fd(); + if fd == Fd::INVALID { + return Ok(()); + } + while !bytes.is_empty() { + match sys::write_retrying(fd, bytes) { + Ok(0) => break, + Ok(n) => { + bytes = &bytes[n..]; + (*this).written.set((*this).written.get() + n); + } + Err(e) => { + // Through the writer's error path, so the sink ends + // up exactly where a failed queued write would leave + // it (fd closed, error latched via `on_error`). + let e = (*this).stdio_latch_error(e); + (*this).writer.with_mut(|w| w.fail(e.clone())); + return Err(e); + } + } + } + Ok(()) + } + } + } + + /// A caller that got `Writable::Pending` back but settles the operation + /// itself (synchronously, via `drain_sync`) instead of taking the pending + /// promise gives its byte credit back, so the next real waiter's count is + /// its own. + pub fn uncredit_pending(&self, accepted: u64) { + self.pending + .with_mut(|p| p.consumed = p.consumed.saturating_sub(accepted)); + } + + /// `write()` for a JS `data` value that is a string / ArrayBuffer(View), + /// plus the encoded byte count accepted; `Ok(None)` for anything else so + /// the caller can take its general path. `now`: attempt the syscall + /// immediately (`IOWriter::write_now`) rather than coalescing small chunks + /// until end of tick. + pub fn write_js_value( + &self, + global: &JSGlobalObject, + data: JSValue, + now: bool, + ) -> JsResult> { + if let Some(buffer) = data.as_array_buffer(global) { + let _keep = bun_jsc::EnsureStillAlive(data); + let bytes = buffer.slice(); + if bytes.is_empty() { + return Ok(Some((streams::Writable::Owned(0), 0))); + } + return Ok(Some(self.write_with(|w| { + if now { + w.write_now(bytes) + } else { + w.write(bytes) + } + }))); + } + if !data.is_string() { + return Ok(None); + } + let str_ = data.to_js_string(global)?; + let view = str_.view(global); + if view.is_empty() { + return Ok(Some((streams::Writable::Owned(0), 0))); + } + let _keep = bun_jsc::EnsureStillAlive(str_.to_js()); + if view.is_16bit() { + let utf16 = view.utf16_slice_aligned(); + return Ok(Some(self.write_with(|w| { + if now { + w.write_utf16_now(utf16) + } else { + w.write_utf16(utf16) + } + }))); + } + let latin1 = view.slice(); + Ok(Some(self.write_with(|w| { + if now { + w.write_latin1_now(latin1) + } else { + w.write_latin1(latin1) + } + }))) + } + pub(crate) fn setup(&self, options: &Options) -> sys::Result<()> { // SAFETY: JsCell — `Strong::has` is a read-only GC-root probe; no JS re-entry. if unsafe { self.readable_stream.get_mut() }.has() { @@ -912,6 +1287,9 @@ impl FileSink { self.written.set(self.written.get() + written as usize); // @truncate written as u64 // @truncate } + WriteResult::Err(err) if self.is_stdio() => { + return sys::Result::Err(self.stdio_latch_error(err)); + } WriteResult::Err(err) => { return sys::Result::Err(err); } @@ -1017,36 +1395,35 @@ impl FileSink { } pub fn write(&self, data: &streams::Result) -> streams::Writable { - if self.done.get() { - return streams::Writable::Done; - } - let buffered_before = self.writer.get().buffered_len(); - // SAFETY(JsCell): `IOWriter::write` buffers/writes to fd; does not call JS. - let rc = self.writer.with_mut(|w| w.write(data.slice())); - let accepted = self.bytes_accepted(buffered_before, &rc); - self.to_result(rc, accepted) + self.write_with(|w| w.write(data.slice())).0 } pub(crate) fn write_latin1(&self, data: &streams::Result) -> streams::Writable { - if self.done.get() { - return streams::Writable::Done; - } - let buffered_before = self.writer.get().buffered_len(); - // SAFETY(JsCell): `IOWriter::write_latin1` buffers/writes; no JS. - let rc = self.writer.with_mut(|w| w.write_latin1(data.slice())); - let accepted = self.bytes_accepted(buffered_before, &rc); - self.to_result(rc, accepted) + self.write_with(|w| w.write_latin1(data.slice())).0 } pub(crate) fn write_utf16(&self, data: &streams::Result) -> streams::Writable { + self.write_with(|w| w.write_utf16(data.slice16())).0 + } + + /// The result plus the number of (encoded) bytes this call accepted. + #[inline] + fn write_with(&self, f: impl FnOnce(&mut IOWriter) -> WriteResult) -> (streams::Writable, u64) { + if let Some(err) = self.stdio_error() { + return (streams::Writable::Err(err), 0); + } if self.done.get() { - return streams::Writable::Done; + return (streams::Writable::Done, 0); } let buffered_before = self.writer.get().buffered_len(); - // SAFETY(JsCell): `IOWriter::write_utf16` buffers/writes; no JS. - let rc = self.writer.with_mut(|w| w.write_utf16(data.slice16())); - let accepted = self.bytes_accepted(buffered_before, &rc); - self.to_result(rc, accepted) + // SAFETY(JsCell): `IOWriter::write*` buffers/writes to fd; does not call JS. + let rc = self.writer.with_mut(f); + let accepted = match rc { + WriteResult::Pending(_) => self.bytes_accepted(buffered_before, &rc), + WriteResult::Wrote(n) | WriteResult::Done(n) => n as u64, + WriteResult::Err(_) => 0, + }; + (self.to_result(rc, accepted), accepted) } /// Native-path terminator called from `SinkHandle::end`. On upstream error @@ -1080,6 +1457,13 @@ impl FileSink { if self.done.get() { return sys::Result::Ok(()); } + if self.is_stdio() { + // The stdio sink outlives any one JS handle to it: ending + // `Bun.stdout.writer()` flushes, it does not take stdout away from + // `console.log` / `process.stdout`. + // SAFETY: `self` is the canonical RareData-held stdio sink. + return unsafe { FileSink::drain_sync(core::ptr::from_ref(self).cast_mut()) }; + } // A backpressured `write()` may have left its promise in `self.pending`; // `writer.end()` only re-enters `on_close`, which never touches it, so @@ -1163,6 +1547,12 @@ impl FileSink { } pub(crate) fn end_from_js(&self, global_this: &JSGlobalObject) -> sys::Result { + if self.is_stdio() { + // See `end()`: flush, report, stay open. + // SAFETY: `self` is the canonical RareData-held stdio sink. + unsafe { FileSink::drain_sync(core::ptr::from_ref(self).cast_mut()) }?; + return sys::Result::Ok(JSValue::js_number(self.written.get() as f64)); + } if self.done.get() { if self.pending.get().state == streams::PendingState::Pending { if let streams::WritableFuture::Promise { strong, .. } = &self.pending.get().future @@ -1396,7 +1786,12 @@ impl FileSink { } streams::Writable::Temporary(amt as u64) } - WriteResult::Err(err) => streams::Writable::Err(err), + WriteResult::Err(err) => { + if self.is_stdio() { + return streams::Writable::Err(self.stdio_latch_error(err)); + } + streams::Writable::Err(err) + } WriteResult::Pending(_) => { if !self.must_be_kept_alive_until_eof.get() { self.must_be_kept_alive_until_eof.set(true); @@ -1449,6 +1844,9 @@ impl FileSink { force_sync: Cell::new(false), is_socket: Cell::new(false), fd: Cell::new(Fd::INVALID), + stdio: Cell::new(Fd::INVALID), + stdio_js: JsCell::new(bun_jsc::strong::Optional::empty()), + stdio_error: JsCell::new(None), auto_flusher: JsCell::new(AutoFlusher::default()), run_pending_later: FlushPendingTask::default(), readable_stream: JsCell::new(readable_stream::Strong::default()), @@ -1683,3 +2081,208 @@ bun_jsc::jsc_host_abi! { } } } + +// ─────────────────────────────────────────────────────────────────────────── +// Per-VM stdio sinks: accessor + the link-time externs the lower tiers call. +// ─────────────────────────────────────────────────────────────────────────── + +#[inline] +fn stdio_slot(fd: Fd) -> usize { + debug_assert!(fd == Fd::stdout() || fd == Fd::stderr()); + (fd == Fd::stderr()) as usize +} + +/// The stdio sink for `fd` (1 or 2) on this VM, created on first use. `None` +/// only if the sink could not be created (e.g. `dup` failed because fd 1/2 is +/// closed), in which case callers write to the fd directly. +pub fn stdio_sink_for(vm: &mut bun_jsc::VirtualMachineRef, fd: Fd) -> Option<*mut FileSink> { + if let Some(existing) = existing_stdio_sink(vm, fd) { + return Some(existing); + } + let event_loop = EventLoopHandle::init(vm.event_loop().cast::<()>()); + match FileSink::create_stdio(event_loop, fd) { + Ok(sink) => { + vm.rare_data().stdio_sinks[stdio_slot(fd)] = core::ptr::NonNull::new(sink.cast()); + Some(sink) + } + Err(err) => { + bun_core::scoped_log!(FileSink, "create_stdio({}) failed: {:?}", fd, err); + None + } + } +} + +/// The stdio sink for `fd` if it already exists on this VM (never creates). +pub fn existing_stdio_sink(vm: &mut bun_jsc::VirtualMachineRef, fd: Fd) -> Option<*mut FileSink> { + let rare = vm.rare_data.as_deref_mut()?; + rare.stdio_sinks[stdio_slot(fd)].map(|p| p.as_ptr().cast()) +} + +/// `$newRustFunction("runtime/webcore/FileSink.rs", "writeNow", 2)` — `(sink, chunk)`: the +/// stdio streams' `_write`. Same contract and return values as +/// `FileSink.prototype.write`, except the syscall is attempted immediately +/// instead of coalescing small chunks until end of tick (Node's +/// `process.stdout.write` is a `write(2)` per call, and a child spawned right +/// after must find the bytes already there). +#[bun_jsc::host_fn] +pub(crate) fn write_now(global: &JSGlobalObject, frame: &CallFrame) -> JsResult { + let [sink_value, data] = frame.arguments_as_array::<2>(); + let Some(this) = JSSink::from_js(sink_value) else { + return Err(global.throw(format_args!("This FileSink has already been closed."))); + }; + // SAFETY: `from_js` returned the live `m_ctx` of a `JSFileSink` wrapper. + // (FileSink has no `get_pending_error`, unlike the socket sinks.) + let sink: &FileSink = unsafe { &(*this).sink }; + let _keep = bun_jsc::EnsureStillAlive(data); + match sink.write_js_value(global, data, true)? { + Some((result, _)) => Ok(result.to_js(global)), + None => Err(global.throw_value(global.to_type_error( + bun_jsc::ErrorCode::INVALID_ARG_TYPE, + format_args!("write() expects a string, ArrayBufferView, or ArrayBuffer"), + ))), + } +} + +/// `JSSink.cpp` `FileSink__doClose`: is this the shared per-thread stdio sink? +#[unsafe(no_mangle)] +extern "C" fn FileSink__isStdio(ptr: *const FileSink) -> bool { + // SAFETY: `ptr` is the live `m_ctx` of a `JSFileSink` wrapper. + unsafe { (*ptr).is_stdio() } +} + +/// `bun_jsc::rare_data::__bun_stdio_sink_deinit` body: release `RareData`'s ref. +#[unsafe(no_mangle)] +unsafe fn __bun_stdio_sink_deinit(ptr: *mut ()) { + if ptr.is_null() { + return; + } + let this = ptr.cast::(); + // SAFETY: `ptr` is the exact +1 `*mut FileSink` `stdio_sink_for` stored; + // JSC is still alive when `release_stdio_sinks` runs, so dropping the + // wrapper root here is sound (the wrapper's own +1 is released by its + // finalizer). + unsafe { + (*this).stdio_js.with_mut(|s| s.deinit()); + FileSink::deref(this); + } +} + +/// `bun_jsc::virtual_machine::__bun_stdio_sink_release_js` body: a new global +/// is taking over this VM (`bun test` isolation); the old global's wrapper must +/// not be what the new one's `process.stdout` is built over, nor keep the old +/// realm alive from a VM-lifetime root. +/// +/// # Safety +/// `vm` is the live per-thread VM. +#[unsafe(no_mangle)] +unsafe fn __bun_stdio_sink_release_js(vm: *mut bun_jsc::VirtualMachineRef) { + // SAFETY: caller contract. + let vm = unsafe { &mut *vm }; + for fd in [Fd::stdout(), Fd::stderr()] { + if let Some(sink) = existing_stdio_sink(vm, fd) { + // SAFETY: canonical live pointer held by RareData. + unsafe { (*sink).release_stdio_js() }; + } + } +} + +/// `Bun.file(1|2).writer()` / `Bun.stdout.writer()` / `process.stdout`'s sink: +/// the shared wrapper over this VM's stdio sink, or `None` to fall back to an +/// ordinary FileSink when the stdio sink can't be created. +pub fn stdio_sink_js(global: &JSGlobalObject, fd: Fd) -> Option { + // SAFETY: `bun_vm()` is the live VM owning `global`. + let vm = global.bun_vm().as_mut(); + let sink = stdio_sink_for(vm, fd)?; + // SAFETY: canonical live pointer held by RareData. + Some(unsafe { FileSink::stdio_js(sink, global) }) +} + +/// `bun_jsc::console_object::__bun_stdio_sink_write` body — the console fast +/// path. Delivers `bytes` to fd `fd` through this VM's stdio sink before +/// returning (see [`FileSink::write_all_sync`]). Caller holds `StdioLock(fd)`. +/// +/// # Safety +/// `vm` is the live per-thread VM. +#[unsafe(no_mangle)] +unsafe fn __bun_stdio_sink_write( + vm: *mut bun_jsc::VirtualMachineRef, + fd: Fd, + bytes: &[u8], +) -> Result<(), sys::Error> { + // SAFETY: caller contract. + let vm = unsafe { &mut *vm }; + match stdio_sink_for(vm, fd) { + // SAFETY: `sink` is the canonical live pointer held by RareData. + Some(sink) => match unsafe { FileSink::write_all_sync(sink, bytes) } { + Ok(()) => Ok(()), + Err(err) => { + // Node: a console write that fails surfaces as 'error' on + // process.stdout/stderr *if someone is listening* (the console + // itself swallows it). Same here; never an uncaught exception. + report_stdio_error(vm.global(), fd, &err); + Err(err) + } + }, + None => { + // No sink (fd 1/2 could not be dup'd): best effort straight to the + // fd; there is no stream to report a failure on. + let _ = sys::write_all_retrying(fd, bytes); + Ok(()) + } + } +} + +unsafe extern "C" { + /// `BunProcess.cpp` — `stream.destroy(err)` on Bun's `process.stdout`/ + /// `stderr` object for `fd` iff it exists and has an `'error'` listener. + fn Bun__Process__reportStdioSinkError(global: &JSGlobalObject, fd: i32, err: JSValue); +} + +/// See `__bun_stdio_sink_write`. +fn report_stdio_error(global: &JSGlobalObject, fd: Fd, err: &sys::Error) { + use bun_sys_jsc::ErrorJsc as _; + let Ok(js_err) = err.clone().to_js(global) else { + return; + }; + let n = if fd == Fd::stdout() { 1 } else { 2 }; + // SAFETY: `js_err` is a fresh error object; the C++ side runs a builtin + // under a top-level exception scope and reports (never propagates) a throw. + unsafe { Bun__Process__reportStdioSinkError(global, n, js_err) }; +} + +/// [`bun_sys::set_stdio_write_hook`] target; installed once per process from +/// `init_runtime_state`. Runs on whatever thread `Output` is writing from, so +/// it only ever looks at *that* thread's VM. +pub fn before_output_write(fd: Fd) { + let Some(vm) = bun_jsc::VirtualMachineRef::get_or_null() else { + return; + }; + // SAFETY: the thread-local VM pointer is live for the thread's lifetime; + // `existing_stdio_sink` only reads `rare_data`. + let vm = unsafe { &mut *vm }; + if let Some(sink) = existing_stdio_sink(vm, fd) { + // SAFETY: canonical live pointer held by RareData; `drain_sync` is a + // no-op unless something is queued. + let _ = unsafe { FileSink::drain_sync(sink) }; + } +} + +/// `bun_jsc::virtual_machine::__bun_stdio_sink_drain` body: synchronously drain +/// whatever `process.stdout`/`stderr` writes are still queued on this VM, so +/// what the caller prints next (a fatal error, the exit) cannot overtake or +/// discard them. No-op — not even an allocation — when the sinks were never +/// created. +/// +/// # Safety +/// `vm` is the live per-thread VM. +#[unsafe(no_mangle)] +unsafe fn __bun_stdio_sink_drain(vm: *mut bun_jsc::VirtualMachineRef) { + // SAFETY: caller contract. + let vm = unsafe { &mut *vm }; + for fd in [Fd::stdout(), Fd::stderr()] { + if let Some(sink) = existing_stdio_sink(vm, fd) { + // SAFETY: canonical live pointer held by RareData. + let _ = unsafe { FileSink::drain_sync(sink) }; + } + } +} diff --git a/src/runtime/webcore/blob/copy_file.rs b/src/runtime/webcore/blob/copy_file.rs index cf4aae0668e7..14129a95981e 100644 --- a/src/runtime/webcore/blob/copy_file.rs +++ b/src/runtime/webcore/blob/copy_file.rs @@ -401,10 +401,18 @@ impl<'a> CopyFile<'a> { match bun_sys::get_errno(written) { bun_sys::E::SUCCESS => {} + bun_sys::E::EINTR => continue, + // XDEV: cross-device copy not supported // NOSYS: syscall not available // OPNOTSUPP: filesystem doesn't support this operation - bun_sys::E::ENOSYS | bun_sys::E::EXDEV | bun_sys::E::ENOTSUP => { + // AGAIN: either end is a pipe whose description someone made + // O_NONBLOCK (a stdio pipe, typically); the read/write + // loop waits each side out properly. + bun_sys::E::ENOSYS + | bun_sys::E::EXDEV + | bun_sys::E::ENOTSUP + | bun_sys::E::EAGAIN => { // TODO: this should use non-blocking I/O. match read_write_fallback( src_fd, @@ -990,14 +998,14 @@ fn read_write_loop_capped( let mut remaining = cap; while remaining > 0 { let want = (buf.len() as SizeType).min(remaining) as usize; - let amt = bun_sys::read(src_fd, &mut buf[..want])?; + let amt = bun_sys::read_retrying(src_fd, &mut buf[..want])?; if amt == 0 { break; } remaining -= amt as SizeType; let mut slice = &buf[..amt]; while !slice.is_empty() { - match bun_sys::write(dest_fd, slice)? { + match bun_sys::write_retrying(dest_fd, slice)? { 0 => return Ok(()), n => { *total += n as u64; diff --git a/src/spawn_sys/posix_spawn.rs b/src/spawn_sys/posix_spawn.rs index 67935bfc0252..88c1a50be799 100644 --- a/src/spawn_sys/posix_spawn.rs +++ b/src/spawn_sys/posix_spawn.rs @@ -615,6 +615,21 @@ pub mod posix_spawn { let uid = attr.and_then(|a| a.uid); let gid = attr.and_then(|a| a.gid); + // A child that inherits one of our stdio fds shares its open file + // description, including an O_NONBLOCK bit our stdio sink may have set + // on a pipe. Hand it over blocking, like libuv does (state is shared, + // so this flips it for us too; our writers tolerate either mode): + // https://github.com/libuv/libuv/blob/v1.51.0/src/unix/process.c#L629-L631 + if let Some(act) = actions { + for action in act.actions.iter() { + if action.kind == bun_spawn::FileActionType::Dup2 + && (0..=2).contains(&action.fds[0]) + { + let _ = sys::update_nonblocking(Fd::from_native(action.fds[0]), false); + } + } + } + // Use posix_spawn_bun when: // - Linux: always (uses vfork which is fast and safe) // - macOS: for PTY spawns (pty_slave_fd >= 0) because PTY setup requires diff --git a/src/sys/lib.rs b/src/sys/lib.rs index cf908fe8fec6..dac6f4ee3441 100644 --- a/src/sys/lib.rs +++ b/src/sys/lib.rs @@ -3571,7 +3571,8 @@ pub mod sys_uv; #[cfg(not(windows))] pub mod sys_uv { pub use super::{ - close, fstat, lstat, mkdir, open, pread, pwrite, read, rename, stat, unlink, write, + close, fstat, lstat, mkdir, open, pread, pwrite, read, read_retrying, rename, stat, unlink, + write, write_retrying, }; } @@ -9497,19 +9498,126 @@ fn qw_set_fd(qw: &mut bun_core::output::QuietWriter, fd: Fd) { } } +/// Runs before this thread's `Output` bytes go to fd 1 / fd 2. The runtime +/// points it at "flush what this JS thread's `process.stdout`/`stderr` still +/// has queued for that fd" (`bun_runtime::webcore::file_sink`), which is what +/// keeps everything Bun itself prints — errors, the test reporter, prompts — +/// behind output the program already handed to its stdio streams. +static STDIO_WRITE_HOOK: core::sync::atomic::AtomicPtr<()> = + core::sync::atomic::AtomicPtr::new(core::ptr::null_mut()); + +pub fn set_stdio_write_hook(hook: fn(Fd)) { + STDIO_WRITE_HOOK.store(hook as *mut (), core::sync::atomic::Ordering::Release); +} + +#[inline] +fn run_stdio_write_hook(fd: Fd) { + if fd != Fd::stdout() && fd != Fd::stderr() { + return; + } + // Debug logging can fire from inside the sink's own I/O callbacks; never + // re-enter the sink from there. + if bun_core::output::is_inside_scoped_log() { + return; + } + let p = STDIO_WRITE_HOOK.load(core::sync::atomic::Ordering::Acquire); + if !p.is_null() { + // SAFETY: only ever stored from a `fn(Fd)` in `set_stdio_write_hook`. + let hook: fn(Fd) = unsafe { core::mem::transmute::<*mut (), fn(Fd)>(p) }; + hook(fd); + } +} + /// Best-effort write-all loop. Returns `false` on I/O error / zero-write so /// `ScopedLogger::log` can disable the scope; "quiet" callers discard the bool. +/// +/// `EAGAIN` is not an error here: `O_NONBLOCK` lives on the open file +/// description, so anything sharing fd 1/2 with us (a parent shell, a child, +/// libuv in a sibling process, another thread's stdio sink) can flip it at any +/// time. Wait for `POLLOUT` and keep going, which is exactly what a blocking +/// `write(2)` would have done. fn fd_write_all_quiet(fd: Fd, mut bytes: &[u8]) -> bool { + run_stdio_write_hook(fd); while !bytes.is_empty() { match write(fd, bytes) { Ok(0) => return false, // short write → give up Ok(n) => bytes = &bytes[n..], + #[cfg(unix)] + Err(e) if e.get_errno() == E::EINTR => continue, + #[cfg(unix)] + Err(e) if e.is_retry() => { + if !wait_until_writable(fd) { + return false; + } + } Err(_) => return false, } } true } +/// `write(2)` all of `bytes`, waiting out `EAGAIN`/`EINTR`; `false` on a real +/// error. Does not take ownership of (or close) `fd`. +#[inline] +pub fn write_all_retrying(fd: Fd, bytes: &[u8]) -> bool { + fd_write_all_quiet(fd, bytes) +} + +/// One `write(2)` that behaves as if `fd` were blocking regardless of the +/// description's `O_NONBLOCK` bit: `EAGAIN` waits for `POLLOUT`, `EINTR` +/// retries. For copy loops that run off the JS thread and may be handed a +/// stdio pipe someone else made non-blocking. +pub fn write_retrying(fd: Fd, bytes: &[u8]) -> Maybe { + loop { + match write(fd, bytes) { + #[cfg(unix)] + Err(e) if e.get_errno() == E::EINTR => continue, + #[cfg(unix)] + Err(e) if e.is_retry() => { + if !wait_until_writable(fd) { + return Err(e); + } + } + other => return other, + } + } +} + +/// `read(2)` counterpart of [`write_retrying`]. +pub fn read_retrying(fd: Fd, buf: &mut [u8]) -> Maybe { + loop { + match read(fd, buf) { + #[cfg(unix)] + Err(e) if e.get_errno() == E::EINTR => continue, + #[cfg(unix)] + Err(e) if e.is_retry() => { + if !wait_until(fd, posix::POLL_IN) { + return Err(e); + } + } + other => return other, + } + } +} + +/// Block until `fd` reports `POLLOUT` (or `POLLERR`/`POLLHUP`, which the next +/// `write` will turn into a real errno). `false` only if `poll` itself failed. +#[cfg(unix)] +#[inline] +pub fn wait_until_writable(fd: Fd) -> bool { + wait_until(fd, posix::POLL_OUT) +} + +#[cfg(unix)] +fn wait_until(fd: Fd, events: i16) -> bool { + let mut pfd = [posix::PollFd { + fd: fd.native(), + events, + revents: 0, + }]; + posix::poll(&mut pfd, -1).is_ok() +} + /// Concrete repr behind the opaque `bun_core::output::QuietWriterAdapter` /// (`[u8; 64]`). First field MUST be `io::Writer` so `new_interface()`'s /// pointer-cast is sound. Layout asserted below. diff --git a/src/sys/sys_uv.rs b/src/sys/sys_uv.rs index 5044e4abea6b..f4530e709a24 100644 --- a/src/sys/sys_uv.rs +++ b/src/sys/sys_uv.rs @@ -823,6 +823,18 @@ pub fn pwrite(fd: Fd, buf: &[u8], position: i64) -> Result { Result::Ok(total_written) } +/// libuv fs writes are synchronous/blocking here; same as `write`. +#[inline] +pub fn write_retrying(fd: Fd, buf: &[u8]) -> Result { + write(fd, buf) +} + +/// Same as `read` on this backend. +#[inline] +pub fn read_retrying(fd: Fd, buf: &mut [u8]) -> Result { + read(fd, buf) +} + pub fn write(fd: Fd, buf: &[u8]) -> Result { // If buffer fits in a single uv_buf_t, use the simple path if buf.len() <= MAX_BUF_LEN { diff --git a/test/js/node/console/console.test.ts b/test/js/node/console/console.test.ts index 2913817f23c8..3f7c534c6ae1 100644 --- a/test/js/node/console/console.test.ts +++ b/test/js/node/console/console.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, test } from "bun:test"; +import { describe, expect, spyOn, test } from "bun:test"; +import { bunEnv, bunExe } from "harness"; import { Console } from "node:console"; import { Writable } from "node:stream"; @@ -65,26 +66,285 @@ describe("console.Console", () => { }); }); -test("console._stdout", () => { +// The global console binds its streams lazily through a get/set accessor pair +// (kBindStreamsLazy), so `_stdout` / `_stderr` are accessors, not data +// properties, and assigning them redirects the console: +// https://github.com/nodejs/node/blob/v24.0.0/lib/internal/console/constructor.js#L205-L234 +test.each(["_stdout", "_stderr"] as const)("console.%s", key => { + const stream = key === "_stdout" ? process.stdout : process.stderr; // @ts-ignore - expect(console._stdout).toBe(process.stdout); + expect(console[key]).toBe(stream); - expect(Object.getOwnPropertyDescriptor(console, "_stdout")).toEqual({ - value: process.stdout, - writable: true, - enumerable: false, - configurable: true, - }); + const desc = Object.getOwnPropertyDescriptor(console, key)!; + expect(desc.enumerable).toBe(false); + expect(desc.configurable).toBe(true); + expect(typeof desc.get).toBe("function"); + expect(typeof desc.set).toBe("function"); + expect("value" in desc).toBe(false); }); -test("console._stderr", () => { - // @ts-ignore - expect(console._stderr).toBe(process.stderr); +// ───────────────────────────────────────────────────────────────────────────── +// The global console writes through process.stdout / process.stderr the way +// Node's does (`this._stdout.write(chunk)`), so anything that observes those +// streams observes the console — while, unobserved, it never builds a JS string +// or enters JS at all. Each case runs in a fresh process so stream state does +// not leak between them. +// ───────────────────────────────────────────────────────────────────────────── +async function run(src: string) { + 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]); + return { stdout, stderr, exitCode }; +} + +describe.concurrent("global console -> process.stdout / process.stderr", () => { + test("a replaced process.stdout.write / process.stderr.write sees every console method, one write() per call", async () => { + const { stdout, stderr, exitCode } = await run(` + const seen = { out: [], err: [] }; + const { write: ow } = process.stdout, { write: ew } = process.stderr; + process.stdout.write = function (chunk, ...rest) { seen.out.push(String(chunk)); return true; }; + process.stderr.write = function (chunk, ...rest) { seen.err.push(String(chunk)); return true; }; + console.log("log %d", 1); + console.info("info"); + console.debug("debug"); + console.dir({ dir: 1 }); + console.dirxml("dirxml"); + console.table([{ a: 1 }]); + console.count("cnt"); console.count("cnt"); + console.group("grp"); console.log("in group"); console.groupEnd(); + console.time("t"); console.timeLog("t", "extra"); console.timeEnd("t"); + console.warn("warn"); + console.error("error", { e: 1 }); + console.assert(false, "assert %s", "msg"); + console.assert(false); + console.trace("trace"); + process.stdout.write = ow; process.stderr.write = ew; + // Restoring puts the native path back: this arrives, and is not seen. + console.log("restored"); + process.stderr.write(JSON.stringify(seen)); + `); + expect(stdout).toBe("restored\n"); + const seen = JSON.parse(stderr); + // Elapsed times vary; the stack in trace() has a path. + seen.out = seen.out.map((s: string) => s.replace(/: [\d.]+m?s/, ": ")); + seen.err = seen.err.map((s: string) => (s.startsWith("trace\n") ? "trace\n" : s)); + expect(seen).toEqual({ + out: [ + "log 1\n", + "info\n", + "debug\n", + "{\n dir: 1,\n}\n", + "dirxml\n", + "┌───┬───┐\n│ │ a │\n├───┼───┤\n│ 0 │ 1 │\n└───┴───┘\n", + "cnt: 1\n", + "cnt: 2\n", + "grp\n", + " in group\n", + "t: extra\n", + "t: \n", + ], + err: [ + "warn\n", + "error {\n e: 1,\n}\n", + "Assertion failed: assert msg\n", + "Assertion failed\n", + "trace\n", + ], + }); + expect(exitCode).toBe(0); + }); + + test("console._stdout / console._stderr assignment redirects; assigning process.std* back restores", async () => { + const { stdout, stderr, exitCode } = await run(` + const { Writable } = require("node:stream"); + const chunks = []; + const sink = new Writable({ write(c, e, cb) { chunks.push(String(c)); cb(); } }); + console._stdout = sink; + console._stderr = sink; + console.log("to sink"); + console.error("err to sink"); + console._stdout = process.stdout; + console._stderr = process.stderr; + console.log("back"); + console.error(JSON.stringify(chunks)); + `); + expect(stdout).toBe("back\n"); + expect(stderr).toBe('["to sink\\n","err to sink\\n"]\n'); + expect(exitCode).toBe(0); + }); + + test("like Node, the console binds process.stdout on first use: replacing it before that is honoured, after that is not", async () => { + // https://github.com/nodejs/node/blob/v24.0.0/lib/internal/console/constructor.js#L205-L234 + const before = await run(` + const { Writable } = require("node:stream"); + const chunks = []; + Object.defineProperty(process, "stdout", { value: new Writable({ write(c, e, cb) { chunks.push(String(c)); cb(); } }), configurable: true, writable: true }); + console.log("first use"); + process.stderr.write(JSON.stringify(chunks)); + `); + expect(before).toEqual({ stdout: "", stderr: '["first use\\n"]', exitCode: 0 }); + + const after = await run(` + const { Writable } = require("node:stream"); + const chunks = []; + console.log("first use"); + Object.defineProperty(process, "stdout", { value: new Writable({ write(c, e, cb) { chunks.push(String(c)); cb(); } }), configurable: true, writable: true }); + console.log("second use"); + process.stderr.write(JSON.stringify(chunks)); + `); + expect(after).toEqual({ stdout: "first use\nsecond use\n", stderr: "[]", exitCode: 0 }); + + // An accessor (what spyOn(process, "stdout", "get") installs) is read + // through its getter once, at bind time; a throwing one surfaces from that + // first console call and binding is retried on the next. + const accessor = await run(` + const { Writable } = require("node:stream"); + const chunks = []; + const sink = new Writable({ write(c, e, cb) { chunks.push(String(c)); cb(); } }); + let calls = 0, armed = true; + Object.defineProperty(process, "stdout", { get() { calls++; if (armed) { armed = false; throw new Error("getter boom"); } return sink; }, configurable: true }); + let first; + try { console.log("lost"); } catch (e) { first = e.message; } + console.log("one"); console.info("two"); + process.stderr.write(JSON.stringify({ first, calls, chunks, same: console._stdout === sink })); + `); + expect(accessor.stdout).toBe(""); + expect(JSON.parse(accessor.stderr)).toEqual({ + first: "getter boom", + calls: 2, + chunks: ["one\n", "two\n"], + same: true, + }); + expect(accessor.exitCode).toBe(0); + }); - expect(Object.getOwnPropertyDescriptor(console, "_stderr")).toEqual({ - value: process.stderr, - writable: true, - enumerable: false, - configurable: true, + test("cork() holds console output with the stream's other writes until uncork()", async () => { + const { stdout, stderr, exitCode } = await run(` + process.stdout.cork(); + console.log("1 (corked)"); + process.stdout.write("2 (corked)\\n"); + process.stderr.write("[stderr while stdout corked]"); + process.stdout.uncork(); + console.log("3"); + `); + expect(stderr).toBe("[stderr while stdout corked]"); + expect(stdout).toBe("1 (corked)\n2 (corked)\n3\n"); + expect(exitCode).toBe(0); }); + + test("a write() that throws is swallowed; a stack overflow is not (Node kWriteToConsole)", async () => { + // A real runaway recursion takes minutes to overflow in debug/ASAN builds; + // what the console keys on is the engine's stack-overflow RangeError. + const { stdout, stderr, exitCode } = await run(` + process.stdout.write = () => { throw new Error("nope"); }; + console.log("swallowed"); + let threw; + process.stdout.write = () => { throw new RangeError("Maximum call stack size exceeded."); }; + try { console.log("overflow"); } catch (e) { threw = e.constructor.name; } + process.stderr.write(String(threw)); + `); + expect(stdout).toBe(""); + expect(stderr).toBe("RangeError"); + expect(exitCode).toBe(0); + }); + + test("adding unrelated own properties to process.stdout keeps the native path (structure re-cached), patching write leaves it", async () => { + // Observable only indirectly: a patched write counts calls; an unpatched + // stream with extra props must still print and must not be 'seen'. + const { stdout, stderr, exitCode } = await run(` + process.stdout._tag = 1; // structure transition, no own write + process.stdout.isTTY = false; + console.log("a"); + let n = 0; + const w = process.stdout.write; + process.stdout.write = function () { n++; return w.apply(this, arguments); }; + console.log("b"); + delete process.stdout.write; // back to the prototype's + console.log("c"); + process.stderr.write(String(n)); + `); + expect(stdout).toBe("a\nb\nc\n"); + expect(stderr).toBe("1"); + expect(exitCode).toBe(0); + }); + + test("console._ignoreErrors is true and console.clear() writes the escape through _stdout when it isTTY", async () => { + // https://github.com/nodejs/node/blob/v24.0.0/lib/internal/console/constructor.js#L487-L501 + const { stdout, stderr, exitCode } = await run(` + let buf = ""; + const w = process.stdout.write; + process.stdout.isTTY = true; + process.stdout.write = s => ((buf += s), true); + console.clear(); + process.stdout.isTTY = false; + console.clear(); + process.stdout.write = w; + process.stderr.write(JSON.stringify({ buf, ignore: console._ignoreErrors })); + `); + expect(JSON.parse(stderr)).toEqual({ buf: "\x1b[1;1H\x1b[0J", ignore: true }); + expect(stdout).toBe(""); + expect(exitCode).toBe(0); + }); + + test("process._rawDebug bypasses process.stderr entirely", async () => { + const { stdout, stderr, exitCode } = await run(` + process.stderr.write = () => { throw new Error("broken"); }; + console._stderr = { write() { throw new Error("also broken"); } }; + process._rawDebug("still %s", "here", { n: 1 }); + `); + expect(stdout).toBe(""); + expect(stderr).toBe("still here {\n n: 1,\n}\n"); + expect(exitCode).toBe(0); + }); + + test("diagnostics_channel console.* channels publish the argument list before formatting, only while subscribed", async () => { + // https://github.com/nodejs/node/blob/v24.0.0/lib/internal/console/constructor.js#L409-L443 + const { stdout, stderr, exitCode } = await run(` + const dc = require("node:diagnostics_channel"); + const seen = []; + const subs = {}; + for (const name of ["console.log", "console.info", "console.debug", "console.warn", "console.error"]) { + dc.subscribe(name, (subs[name] = args => { seen.push([name, [...args]]); args[0] = "[" + args[0] + "]"; })); + } + console.log("l", 1); console.info("i"); console.debug("d"); console.warn("w"); console.error("e"); + console.dir("not published"); console.table(["nor this"]); + for (const name in subs) dc.unsubscribe(name, subs[name]); + console.log("unsubscribed"); + process.stderr.write("\\n" + JSON.stringify(seen)); + `); + expect(stdout).toBe( + "[l] 1\n[i]\n[d]\nnot published\n" + + "┌───┬──────────┐\n│ │ Values │\n├───┼──────────┤\n│ 0 │ nor this │\n└───┴──────────┘\n" + + "unsubscribed\n", + ); + const nl = stderr.indexOf("\n[["); + expect(stderr.slice(0, nl)).toBe("[w]\n[e]\n"); + expect(JSON.parse(stderr.slice(nl + 1))).toEqual([ + ["console.log", ["l", 1]], + ["console.info", ["i"]], + ["console.debug", ["d"]], + ["console.warn", ["w"]], + ["console.error", ["e"]], + ]); + expect(exitCode).toBe(0); + }); +}); + +// In-process (serial, it patches this process's stdout): exactly what test +// suites (oclif, ink, ...) do. +test("bun:test spyOn(process.stdout, 'write') captures console.log", () => { + const spy = spyOn(process.stdout, "write").mockImplementation(() => true); + let calls: string[]; + try { + console.log("captured %s", "yes"); + console.info({ k: "v" }); + calls = spy.mock.calls.map((c: unknown[]) => String(c[0])); + } finally { + spy.mockRestore(); + } + expect(calls).toEqual(["captured yes\n", '{\n k: "v",\n}\n']); }); diff --git a/test/js/node/process/process-stdio.test.ts b/test/js/node/process/process-stdio.test.ts index ba35bc4f0484..0517487d52cf 100644 --- a/test/js/node/process/process-stdio.test.ts +++ b/test/js/node/process/process-stdio.test.ts @@ -1,6 +1,8 @@ import { spawn, spawnSync } from "bun"; +import { cc, ptr } from "bun:ffi"; import { describe, expect, test } from "bun:test"; -import { bunEnv, bunExe } from "harness"; +import { bunEnv, bunExe, isPosix, tempDirWithFiles } from "harness"; +import { closeSync, readSync } from "node:fs"; import path from "path"; import { isatty } from "tty"; describe.concurrent("process-stdio", () => { @@ -159,3 +161,459 @@ describe.concurrent("process-stdio", () => { ); }); }); + +// ───────────────────────────────────────────────────────────────────────────── +// One sink per fd: console.*, process.stdout/stderr, Bun.stdout.writer(), +// console.write and Bun.write(Bun.stdout) all go through the same per-VM stdio +// sink, so they can never reorder against each other, never drop bytes when +// the description is O_NONBLOCK, and are fully written before the process +// exits — however the reader paces itself. +// ───────────────────────────────────────────────────────────────────────────── +describe.skipIf(!isPosix).concurrent("stdio sink", () => { + // fcntl(2) is variadic; Apple's arm64 ABI passes variadic args on the stack, + // so a fixed-arity dlopen binding gets F_SETFL wrong there. Compile tiny + // non-variadic wrappers instead (shared with the spawned children). + const dir = tempDirWithFiles("stdio-sink", { + "fdutil.c": ` +#include +#include +int fd_is_nonblock(int fd) { int fl = fcntl(fd, F_GETFL); return fl >= 0 && (fl & O_NONBLOCK) != 0; } +int fd_set_nonblock(int fd, int on) { int fl = fcntl(fd, F_GETFL); if (fl < 0) return fl; return fcntl(fd, F_SETFL, on ? (fl | O_NONBLOCK) : (fl & ~O_NONBLOCK)); } +int fd_pipe(int* fds) { return pipe(fds); } +`, + }); + const fdutil = path.join(dir, "fdutil.c"); + const prelude = ` +const { fd_is_nonblock, fd_set_nonblock } = require("bun:ffi").cc({ + source: ${JSON.stringify(fdutil)}, + symbols: { + fd_is_nonblock: { args: ["int"], returns: "int" }, + fd_set_nonblock: { args: ["int", "int"], returns: "int" }, + }, +}).symbols; +const nonblock = fd => fd_is_nonblock(fd) !== 0; +`; + const { fd_pipe, fd_set_nonblock } = cc({ + source: fdutil, + symbols: { + fd_pipe: { args: ["ptr"], returns: "int" }, + fd_set_nonblock: { args: ["int", "int"], returns: "int" }, + }, + }).symbols; + + /** + * Run `src` in a child whose stdout is the write end of a raw pipe(2) that + * only this function reads, a small slice at a time, so a child writing more + * than the pipe holds is queued behind us for most of its life. (Tests that + * need proof of backpressure assert on `write()`'s return value.) + * `Bun.spawn({ stdout: "pipe" })` can't do this: it drains the child eagerly + * into memory. Returns everything the child wrote to fd 1, its stderr, and + * its exit code. + */ + async function runWithSlowStdout(src: string, opts: { env?: Record } = {}) { + const fds = new Int32Array(2); + expect(fd_pipe(ptr(fds))).toBe(0); + const [r, w] = fds; + fd_set_nonblock(r, 1); + let wClosed = false; + try { + const proc = spawn({ + cmd: [bunExe(), "-e", prelude + src], + env: { ...bunEnv, ...opts.env }, + stdio: ["ignore", w, "pipe"], + }); + closeSync(w); + wClosed = true; + let exited = false; + const exitedP = proc.exited.then(code => ((exited = true), code)); + const stderrP = proc.stderr.text(); + + const chunks: Buffer[] = []; + const slice = Buffer.alloc(16 * 1024); + // Poll until EOF (bounded by the test timeout). Correctness never depends + // on this pacing — only how much backpressure the child sees does. + for (;;) { + let n = 0; + try { + n = readSync(r, slice, 0, slice.length, null); + } catch (e: any) { + if (e?.code !== "EAGAIN") throw e; + if (exited) { + // Child is gone; anything left is already in the pipe. + try { + n = readSync(r, slice, 0, slice.length, null); + } catch { + break; + } + if (n === 0) break; + chunks.push(Buffer.from(slice.subarray(0, n))); + continue; + } + await Bun.sleep(2); + continue; + } + if (n === 0) break; + chunks.push(Buffer.from(slice.subarray(0, n))); + await Bun.sleep(1); + } + const [stderr, exitCode] = await Promise.all([stderrP, exitedP]); + return { stdout: Buffer.concat(chunks), stderr, exitCode }; + } finally { + if (!wClosed) closeSync(w); + closeSync(r); + } + } + + /** Collapse runs of a filler byte so mismatches print legibly. */ + const squash = (buf: Buffer) => buf.toString("latin1").replace(/([xyz])\1{15,}/g, (m, c) => `<${c}*${m.length}>`); + + const K256 = 256 * 1024; + + test("process.stdout.write / console.log interleaving keeps call order under pipe backpressure", async () => { + const { stdout, stderr, exitCode } = await runWithSlowStdout(` + const a = process.stdout.write(Buffer.alloc(${K256}, "x")); + console.log("\\nMARK1"); + const b = process.stdout.write(Buffer.alloc(${K256}, "y")); + console.log("\\nMARK2"); + process.stderr.write(JSON.stringify({ a, b, len: process.stdout.writableLength })); + `); + // write() reported backpressure (and writableLength counted the queued + // bytes) — i.e. the sink really was backed up while console.log ran. + expect(JSON.parse(stderr)).toEqual({ a: false, b: false, len: expect.any(Number) }); + expect(JSON.parse(stderr).len).toBeGreaterThan(0); + expect(squash(stdout)).toBe(`\nMARK1\n\nMARK2\n`); + expect(exitCode).toBe(0); + }); + + test("every stdout writer shares one queue: order is call order", async () => { + // console.*, Bun.stdout.writer(), Bun.write(Bun.stdout) and console.write + // hand their bytes to the sink directly; process.stdout.write() does too, + // except for what its Writable is still holding after a write() returned + // false — those chunks are behind the in-flight one *inside the stream*, + // and console.* (being a write() on that stream, as in Node) queues behind + // them. So: everything below is call-ordered, and the two console lines + // issued while `y` is queued come out after `y`. + const { stdout, stderr, exitCode } = await runWithSlowStdout(` + const big = (c) => Buffer.alloc(${K256}, c); + console.log("[console.log]"); + Bun.stdout.writer().write("[Bun.stdout.writer]\\n"); + await Bun.write(Bun.stdout, "[Bun.write]\\n"); + console.write("[console.write]\\n"); + process.stdout.write(big("x")); // fills the pipe, rest queues in the sink + console.log("\\n[after x]"); // drains the sink first, then writes + process.stdout.write(big("y")); // sink backed up -> held in the Writable + console.error("(stderr is separate)"); + console.info("\\n[console.info]"); // a write() on the stream: behind y + process.stdout.write("[end]\\n"); + `); + expect(stderr).toBe("(stderr is separate)\n"); + expect(squash(stdout)).toBe( + `[console.log]\n[Bun.stdout.writer]\n[Bun.write]\n[console.write]\n\n[after x]\n\n[console.info]\n[end]\n`, + ); + expect(exitCode).toBe(0); + }); + + test.each(["process.exit(0)", "throw new Error('boom')", "/* natural */"])( + "queued stdout is fully written before exit via %s", + async how => { + const { stdout, exitCode } = await runWithSlowStdout(` + process.stdout.write(Buffer.alloc(${K256}, "x")); + process.stdout.write(Buffer.alloc(${K256}, "y")); // buffered in the Writable behind the first + console.log("\\nlast console line"); + process.stdout.write("last stream line\\n"); + ${how}; + `); + expect(squash(stdout)).toBe(`\nlast console line\nlast stream line\n`); + expect(exitCode).toBe(how.startsWith("throw") ? 1 : 0); + }, + ); + + test("an uncaught exception is printed after already-written stdout/stderr", async () => { + const { stdout, stderr, exitCode } = await runWithSlowStdout(` + process.stdout.write(Buffer.alloc(${K256}, "x")); + process.stderr.write("before\\n"); + throw new Error("boom"); + `); + expect(squash(stdout)).toBe(``); + expect(stderr.startsWith("before\n")).toBe(true); + expect(stderr).toContain("error: boom"); + expect(exitCode).toBe(1); + }); + + test("materialising process.stdout / process.stderr does not touch fd flags of a tty/file, and stdio-inheriting children get blocking fds", async () => { + // stdout here is a pipe: Bun may make *its* description non-blocking + // (that is how a slow reader queues instead of stalling the loop), but a + // child handed the fd must see it blocking, and stderr (a file below) + // must never be touched. + const errPath = path.join(tempDirWithFiles("stdio-sink-err", { "err.txt": "" }), "err.txt"); + const { stdout, exitCode } = await runWithSlowStdout( + ` + const fs = require("node:fs"); + const errfd = fs.openSync(${JSON.stringify(errPath)}, "w"); + const before = { out: nonblock(1), file: nonblock(errfd) }; + void process.stdout; void process.stderr; + process.stdout.write("x"); + const after = { file: nonblock(errfd) }; + // A child that inherits fd 1 must find it blocking regardless. + const child = Bun.spawnSync([process.execPath, "-e", ${JSON.stringify(prelude + `process.stderr.write(String(nonblock(1)))`)}], { stdio: ["ignore", "inherit", "pipe"], env: process.env }); + console.log(JSON.stringify({ before, after, childSeesNonblock: child.stderr.toString() })); + `, + ); + const line = stdout.toString().trim().split("\n").pop()!; + expect(JSON.parse(line.replace(/^x/, ""))).toEqual({ + before: { out: false, file: false }, + after: { file: false }, + childSeesNonblock: "false", + }); + expect(exitCode).toBe(0); + }); + + test("console.log delivers every byte when something else made fd 1 O_NONBLOCK and the pipe is full", async () => { + const { stdout, stderr, exitCode } = await runWithSlowStdout(` + const fs = require("node:fs"); + fd_set_nonblock(1, 1); + // Fill the pipe until the kernel refuses. + const fill = Buffer.alloc(4096, "x"); + let filled = 0; + for (;;) { try { filled += fs.writeSync(1, fill); } catch { break; } } + process.stderr.write(String(filled)); + for (let i = 0; i < 10; i++) console.log("marker " + i); + `); + const filled = Number(stderr); + expect(filled).toBeGreaterThan(0); + expect(stdout.subarray(0, filled).equals(Buffer.alloc(filled, "x"))).toBe(true); + expect(stdout.subarray(filled).toString()).toBe(Array.from({ length: 10 }, (_, i) => `marker ${i}\n`).join("")); + expect(exitCode).toBe(0); + }); + + test("an idle Worker / worker_threads.Worker does not perturb the parent's stdio", async () => { + const { stdout, stderr, exitCode } = await runWithSlowStdout(` + const { Worker } = require("node:worker_threads"); + const before = [nonblock(1), nonblock(2)]; + const w = new Worker("setTimeout(() => {}, 10)", { eval: true }); + await new Promise(r => w.on("online", r)); + const during = [nonblock(1), nonblock(2)]; + await new Promise(r => w.on("exit", r)); + // and the parent's console still delivers everything afterwards + const filler = Buffer.alloc(4000, "p").toString(); + for (let i = 0; i < 40; i++) console.log("line " + i + " " + filler); + process.stderr.write(JSON.stringify({ before, during })); + `); + expect(JSON.parse(stderr)).toEqual({ before: [false, false], during: [false, false] }); + expect(stdout.toString().split("\n").filter(Boolean).length).toBe(40); + expect(exitCode).toBe(0); + }); + + test("Bun.stdout.writer() is the shared sink: same object every time; end()/close() only flush; writes coalesce until flushed", async () => { + await using proc = spawn({ + cmd: [ + bunExe(), + "-e", + ` + const { spawnSync } = require("child_process"); + // A child inheriting fd 1 shows what has actually reached the fd. + const probe = tag => spawnSync(process.execPath, ["-e", "process.stdout.write('<' + process.env.TAG + '>')"], { stdio: "inherit", env: { ...process.env, TAG: tag } }); + const a = Bun.stdout.writer(), b = Bun.stdout.writer(), c = Bun.file(1).writer(); + a.write("1 "); + probe("before-flush"); // "1 " is still in the writer's buffer + await a.end(); + console.log("2"); + a.close(); // must not take stdout away from anyone + process.stdout.write("3 "); // process.stdout.write is a syscall now... + probe("after-write"); // ...so the child lands after it + b.write("4\\n"); + await b.flush(); + console.write("5\\n"); + process.stderr.write(JSON.stringify([a === b, a === c, Bun.stdout.writer() === a])); + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe("[true,true,true]"); + expect(stdout).toBe("1 2\n3 4\n5\n"); + expect(exitCode).toBe(0); + }); + + test("FileSink.flush() / end-of-tick autoflush on a full pipe waits for the reader instead of spinning or stalling", async () => { + const { stdout, stderr, exitCode } = await runWithSlowStdout(` + process.stdout.write(Buffer.alloc(${K256}, "x")); // pipe is now full, remainder queued + const w = Bun.stdout.writer(); + w.write("\\n[coalesced]"); // small: sits in the sink's buffer + const flushed = await w.flush(); // must arm the poll and wait + w.write("\\n[autoflushed]\\n"); // left for the end-of-tick flush + await new Promise(r => setImmediate(r)); + process.stderr.write(JSON.stringify({ flushed: typeof flushed })); + `); + expect(JSON.parse(stderr)).toEqual({ flushed: "number" }); + expect(squash(stdout)).toBe(`\n[coalesced]\n[autoflushed]\n`); + expect(exitCode).toBe(0); + }); + + test("Bun.write(Bun.stdout, x) resolves with its own byte count once written, in order, even while process.stdout is backed up", async () => { + const { stdout, stderr, exitCode } = await runWithSlowStdout(` + const backedUp = !process.stdout.write(Buffer.alloc(${K256}, "x")); + const n = await Bun.write(Bun.stdout, "abc"); + const m = await Bun.write(Bun.stdout, new TextEncoder().encode("\\ndef!\\n")); + console.log("after"); + process.stderr.write(JSON.stringify({ backedUp, n, m })); + `); + expect(JSON.parse(stderr)).toEqual({ backedUp: true, n: 3, m: 6 }); + expect(squash(stdout)).toBe(`abc\ndef!\nafter\n`); + expect(exitCode).toBe(0); + }); + + test("writableLength / writableNeedDrain / cork() account for queued bytes, and 'drain' fires", async () => { + const { stdout, stderr, exitCode } = await runWithSlowStdout(` + const so = process.stdout; + const facts = { hwm: so.writableHighWaterMark }; + facts.writeRet = so.write(Buffer.alloc(${K256}, "x")); + facts.writableLength = so.writableLength; + facts.writableNeedDrain = so.writableNeedDrain; + so.cork(); + so.write(Buffer.alloc(1000, "y")); + console.log("\\n(held by cork)"); + facts.corkedLength = so.writableLength; + facts.writableCorked = so.writableCorked; + so.uncork(); + so.once("drain", () => { + facts.drained = { writableLength: so.writableLength, writableNeedDrain: so.writableNeedDrain }; + process.stderr.write(JSON.stringify(facts)); + }); + `); + expect(JSON.parse(stderr)).toEqual({ + hwm: 65536, + writeRet: false, + writableLength: K256, + writableNeedDrain: true, + // 1000 corked bytes + "\\n(held by cork)\\n" (16) queued *behind* them + corkedLength: K256 + 1000 + 16, + writableCorked: 1, + drained: { writableLength: 0, writableNeedDrain: false }, + }); + expect(squash(stdout)).toBe(`\n(held by cork)\n`); + expect(exitCode).toBe(0); + }); + + test("process.stdout.write() is inherited from the prototype (no own write), decodes its encoding argument", async () => { + await using proc = spawn({ + cmd: [ + bunExe(), + "-e", + ` + process.stdout.write("48490a", "hex"); + process.stdout.write("QUJD", "base64"); + process.stdout.setDefaultEncoding("hex"); + process.stdout.write("21"); + process.stderr.write(JSON.stringify({ + own: Object.prototype.hasOwnProperty.call(process.stdout, "write"), + proto: process.stdout.write === Object.getPrototypeOf(process.stdout).write, + })); + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(JSON.parse(stderr)).toEqual({ own: false, proto: true }); + expect(stdout).toBe("HI\nABC!"); + expect(exitCode).toBe(0); + }); + + test("write after end(): the sync write is ERR_STREAM_WRITE_AFTER_END; after finish the stream is undestroyed and writable again (Node file-stdio semantics)", async () => { + // https://github.com/nodejs/node/blob/v24.0.0/lib/internal/bootstrap/switches/is_main_thread.js#L114-L128 + // (dummyDestroy -> _undestroy). Over a pipe Node additionally shuts the + // socket down so later writes EPIPE; Bun keeps fd 1 open (pre-existing, + // deliberate) so it behaves like Node's file case everywhere. + await using proc = spawn({ + cmd: [ + bunExe(), + "-e", + ` + process.stdout.on("error", e => process.stderr.write("[" + e.code + "]")); + process.stdout.write("A"); + process.stdout.end("B"); + process.stdout.write("C"); // still ending -> ERR_STREAM_WRITE_AFTER_END ... + console.log("D"); // ... whose errorOrDestroy -> _destroy -> _undestroy() already made it writable again + setImmediate(() => { + process.stdout.write("E"); + console.log("F"); + process.stderr.write("[done]"); + }); + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe("[ERR_STREAM_WRITE_AFTER_END][done]"); + expect(stdout).toBe("ABD\nEF\n"); + expect(exitCode).toBe(0); + }); + + describe("EPIPE", () => { + // stdout's reader goes away before we write. Node: each failed write emits + // 'error' on process.stdout (code EPIPE, syscall write) when listened to; + // console.* never throws. Without a listener a failing process.stdout.write + // is an uncaught 'error'; a failing console.log stays silent (Bun keeps the + // process alive on uncaught errors, so it must not print one per call). + async function run(which: "console.log" | "process.stdout.write", listen: boolean) { + await using proc = spawn({ + cmd: [ + bunExe(), + "-e", + ` + ${listen ? `process.stdout.on("error", e => process.stderr.write("[" + e.code + "/" + e.syscall + "]"));` : ""} + process.on("exit", c => process.stderr.write("[exit " + c + "]")); + // Wait until the parent has closed our stdout. + const buf = Buffer.alloc(1); + require("node:fs").readSync(0, buf, 0, 1, null); + // One write per event-loop turn: an 'error' is delivered per turn + // (destroy -> nextTick emit -> _undestroy), in Node and here alike; + // several failing writes inside one tick coalesce into one 'error'. + for (let i = 0; i < 3; i++) { + ${which}("x" + i + "\\n"); + await new Promise(r => setImmediate(r)); + } + `, + ], + env: bunEnv, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + }); + // Drop the read end, then let the child go. + await proc.stdout.cancel(); + proc.stdin.write("g"); + await proc.stdin.end(); + const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + return { stderr, exitCode }; + } + + test("console.log with an 'error' listener: one 'error' per call, exit 0", async () => { + expect(await run("console.log", true)).toEqual({ + stderr: "[EPIPE/write][EPIPE/write][EPIPE/write][exit 0]", + exitCode: 0, + }); + }); + test("process.stdout.write with an 'error' listener: one 'error' per call, exit 0", async () => { + expect(await run("process.stdout.write", true)).toEqual({ + stderr: "[EPIPE/write][EPIPE/write][EPIPE/write][exit 0]", + exitCode: 0, + }); + }); + test("console.log without a listener is silent", async () => { + expect(await run("console.log", false)).toEqual({ stderr: "[exit 0]", exitCode: 0 }); + }); + test("process.stdout.write without a listener: one uncaught EPIPE, exit 1", async () => { + const { stderr, exitCode } = await run("process.stdout.write", false); + expect(stderr.match(/EPIPE: broken pipe, write/g)?.length).toBe(1); + expect(stderr).toContain("[exit 1]"); + expect(exitCode).toBe(1); + }); + }); +}); diff --git a/test/js/node/test/parallel/test-console-clear.js b/test/js/node/test/parallel/test-console-clear.js new file mode 100644 index 000000000000..8ded51595f65 --- /dev/null +++ b/test/js/node/test/parallel/test-console-clear.js @@ -0,0 +1,24 @@ +'use strict'; + +require('../common'); +const assert = require('assert'); + +const stdoutWrite = process.stdout.write; + +// The sequence for moving the cursor to 0,0 and clearing screen down +const check = '\u001b[1;1H\u001b[0J'; + +function doTest(isTTY, check) { + let buf = ''; + process.stdout.isTTY = isTTY; + process.stdout.write = (string) => buf += string; + console.clear(); + process.stdout.write = stdoutWrite; + assert.strictEqual(buf, check); +} + +// Fake TTY +if (process.env.TERM !== 'dumb') { + doTest(true, check); +} +doTest(false, ''); diff --git a/test/js/node/test/parallel/test-console-count.js b/test/js/node/test/parallel/test-console-count.js new file mode 100644 index 000000000000..5c7b26aaa20d --- /dev/null +++ b/test/js/node/test/parallel/test-console-count.js @@ -0,0 +1,65 @@ +'use strict'; + +require('../common'); +const assert = require('assert'); + +const stdoutWrite = process.stdout.write; + +let buf = ''; + +process.stdout.write = (string) => buf = string; + +console.count(); +assert.strictEqual(buf, 'default: 1\n'); + +// 'default' and undefined are equivalent +console.count('default'); +assert.strictEqual(buf, 'default: 2\n'); + +console.count('a'); +assert.strictEqual(buf, 'a: 1\n'); + +console.count('b'); +assert.strictEqual(buf, 'b: 1\n'); + +console.count('a'); +assert.strictEqual(buf, 'a: 2\n'); + +console.count(); +assert.strictEqual(buf, 'default: 3\n'); + +console.count({}); +assert.strictEqual(buf, '[object Object]: 1\n'); + +console.count(1); +assert.strictEqual(buf, '1: 1\n'); + +console.count(null); +assert.strictEqual(buf, 'null: 1\n'); + +console.count('null'); +assert.strictEqual(buf, 'null: 2\n'); + +console.countReset(); +console.count(); +assert.strictEqual(buf, 'default: 1\n'); + +console.countReset('a'); +console.count('a'); +assert.strictEqual(buf, 'a: 1\n'); + +// countReset('a') only reset the a counter +console.count(); +assert.strictEqual(buf, 'default: 2\n'); + +process.stdout.write = stdoutWrite; + +// Symbol labels do not work. Only check that the `Error` is a `TypeError`. Do +// not check the message because it is different depending on the JavaScript +// engine. +assert.throws( + () => console.count(Symbol('test')), + TypeError); +assert.throws( + () => console.countReset(Symbol('test')), + TypeError); diff --git a/test/js/node/test/parallel/test-console-stdio-setters.js b/test/js/node/test/parallel/test-console-stdio-setters.js new file mode 100644 index 000000000000..5a4f511ee97b --- /dev/null +++ b/test/js/node/test/parallel/test-console-stdio-setters.js @@ -0,0 +1,18 @@ +'use strict'; + +// Test that monkeypatching console._stdout and console._stderr works. +const common = require('../common'); + +const { Writable } = require('stream'); + +const streamToNowhere = new Writable({ write: common.mustCall() }); +const anotherStreamToNowhere = new Writable({ write: common.mustCall() }); + +// Overriding the lazy-loaded _stdout and _stderr properties this way is what we +// are testing. Don't change this to be a Console instance from calling a +// constructor. It has to be the global `console` object. +console._stdout = streamToNowhere; +console._stderr = anotherStreamToNowhere; + +console.log('fhqwhgads'); +console.error('fhqwhgads'); diff --git a/test/js/node/test/parallel/test-process-raw-debug.js b/test/js/node/test/parallel/test-process-raw-debug.js new file mode 100644 index 000000000000..6a98c5de6086 --- /dev/null +++ b/test/js/node/test/parallel/test-process-raw-debug.js @@ -0,0 +1,70 @@ +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +'use strict'; +const common = require('../common'); +const { hijackStderr } = require('../common/hijackstdio'); +const assert = require('assert'); +const os = require('os'); + +switch (process.argv[2]) { + case 'child': + return child(); + case undefined: + return parent(); + default: + throw new Error(`invalid: ${process.argv[2]}`); +} + +function parent() { + const spawn = require('child_process').spawn; + const child = spawn(process.execPath, [__filename, 'child']); + + let output = ''; + + child.stderr.on('data', function(c) { + output += c; + }); + + child.stderr.setEncoding('utf8'); + + child.stderr.on('end', common.mustCall(() => { + assert.strictEqual(output, `I can still debug!${os.EOL}`); + console.log('ok - got expected message'); + })); + + child.on('exit', common.mustCall(function(c) { + assert(!c); + console.log('ok - child exited nicely'); + })); +} + +function child() { + // Even when all hope is lost... + + process.nextTick = function() { + throw new Error('No ticking!'); + }; + + hijackStderr(common.mustNotCall('stderr.write must not be called.')); + + process._rawDebug('I can still %s!', 'debug'); +} diff --git a/test/js/node/worker_threads/worker_threads.test.ts b/test/js/node/worker_threads/worker_threads.test.ts index 68d6c6f3f103..98e94b9c95f6 100644 --- a/test/js/node/worker_threads/worker_threads.test.ts +++ b/test/js/node/worker_threads/worker_threads.test.ts @@ -1756,3 +1756,61 @@ test("the SHARE_ENV founding thread's process.env stays live after the swap", as expect(stdout.trim()).toBe("yes,unset"); expect(exitCode).toBe(0); }); + +// A worker's console goes through its process.stdout/stderr (port-backed +// Writables that post to the parent, as in Node) — by rebinding the *native* +// console's sink, not by swapping globalThis.console for a JS Console. So the +// worker keeps Bun's console (formatting, console.write, inspector) while its +// output still lands in worker.stdout / the parent's process.stdout. +describe.concurrent("worker console", () => { + test("keeps Bun's console surface and formatting; {stdout,stderr: true} captures it", async () => { + const worker = new Worker( + ` + const util = require("node:util"); + console.log(typeof console.write, typeof console[Symbol.asyncIterator]); + console.log(new Map([["k", "v"]])); // Bun: multi-line, JSON-ish keys + console.log(Bun.inspect(new Map([["k", "v"]])) === util.inspect(new Map([["k", "v"]])) ? "util-fmt" : "bun-fmt"); + console.error("to stderr"); + console.write("raw "); // Bun API, still through worker stdout + process.stdout.write("write\\n"); + `, + { eval: true, stdout: true, stderr: true }, + ); + let out = "", + err = ""; + worker.stdout.setEncoding("utf8").on("data", c => (out += c)); + worker.stderr.setEncoding("utf8").on("data", c => (err += c)); + await once(worker, "exit"); + expect({ out, err }).toEqual({ + out: 'function function\nMap(1) {\n "k": "v",\n}\nbun-fmt\nraw write\n', + err: "to stderr\n", + }); + }); + + test("without {stdout: true} it flows into the parent's process.stdout, so patching that captures it", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + const { Worker } = require("node:worker_threads"); + const seen = []; + const w0 = process.stdout.write; + process.stdout.write = function (c) { seen.push(String(c)); return true; }; + const w = new Worker('console.log("from worker"); process.stdout.write("write from worker\\\\n");', { eval: true }); + w.on("exit", () => { + process.stdout.write = w0; + console.log(JSON.stringify(seen)); + }); + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + expect(stdout).toBe('["from worker\\n","write from worker\\n"]\n'); + expect(exitCode).toBe(0); + }); +}); diff --git a/test/js/web/console/console-timeLog.expected.txt b/test/js/web/console/console-timeLog.expected.txt index 464475e83a60..9f61852a6bd0 100644 --- a/test/js/web/console/console-timeLog.expected.txt +++ b/test/js/web/console/console-timeLog.expected.txt @@ -1,18 +1,18 @@ -[0.00ms] label -[0.06ms] label Hello World! -[0.09ms] label a %s b c d -[0.11ms] label 0 -0 123 -123 123.567 -123.567 Infinity -Infinity -[0.14ms] label true false -[0.15ms] label null undefined -[0.17ms] label Symbol(Symbol Description) -[0.22ms] label 2000-06-27T02:24:34.304Z -[0.29ms] label [ 123, 456, 789 ] -[0.34ms] label { +label: 0.006ms +label: 0.06ms Hello World! +label: 0.09ms a %s b c d +label: 0.11ms 0 -0 123 -123 123.567 -123.567 Infinity -Infinity +label: 0.14ms true false +label: 0.15ms null undefined +label: 0.17ms Symbol(Symbol Description) +label: 0.22ms 2000-06-27T02:24:34.304Z +label: 0.29ms [ 123, 456, 789 ] +label: 0.34ms { name: "foo", } -[0.37ms] label { +label: 0.37ms { a: 123, b: 456, c: 789, } -[0.39ms] label +label: 0.39ms diff --git a/test/js/web/console/console-timeLog.test.ts b/test/js/web/console/console-timeLog.test.ts index bf7ddc485e5b..cee034034ced 100644 --- a/test/js/web/console/console-timeLog.test.ts +++ b/test/js/web/console/console-timeLog.test.ts @@ -3,6 +3,12 @@ import { expect, it } from "bun:test"; import { bunEnv, bunExe } from "harness"; import { join } from "node:path"; +// console.timeLog / console.timeEnd print `${label}: ${formatTime(ms)}` through +// console.log, i.e. to stdout: +// https://github.com/nodejs/node/blob/v24.0.0/lib/internal/console/constructor.js#L401-L407 +// https://github.com/nodejs/node/blob/v24.0.0/lib/internal/util/debuglog.js#L155-L187 +const elapsed = /^(.*?): \d+(?:\.\d{1,3})?(?:ms|s)/gm; + it.concurrent("console.timeEnd with empty label emits exactly one trailing newline", async () => { await using proc = Bun.spawn({ cmd: [bunExe(), "-e", `console.time(""); console.timeEnd("");`], @@ -11,8 +17,8 @@ it.concurrent("console.timeEnd with empty label emits exactly one trailing newli stderr: "pipe", }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect(stdout).toBe(""); - expect(stderr).toMatch(/^\[[\d.]+[mnµ]?s\]\n$/); + expect(stderr).toBe(""); + expect(stdout).toMatch(/^: \d+(\.\d{1,3})?ms\n$/); expect(exitCode).toBe(0); }); @@ -24,25 +30,47 @@ it.concurrent("console.timeEnd with non-empty label emits exactly one trailing n stderr: "pipe", }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect(stdout).toBe(""); - expect(stderr).toMatch(/^\[[\d.]+[mnµ]?s\] abc\n$/); + expect(stderr).toBe(""); + expect(stdout).toMatch(/^abc: \d+(\.\d{1,3})?ms\n$/); + expect(exitCode).toBe(0); +}); + +it.concurrent("console.timeEnd / timeLog for a missing label warn instead of printing", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `process.on("warning", w => process.stderr.write("warning: " + w.message + "\\n")); console.timeEnd("nope"); console.timeLog("nope"); console.time("dup"); console.time("dup"); console.timeEnd("dup");`, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe( + "warning: No such label 'nope' for console.timeEnd()\n" + + "warning: No such label 'nope' for console.timeLog()\n" + + "warning: Label 'dup' already exists for console.time()\n", + ); + expect(stdout).toMatch(/^dup: \d+(\.\d{1,3})?ms\n$/); expect(exitCode).toBe(0); }); it("should log to console correctly", async () => { - const { stderr, exited } = spawn({ + await using proc = spawn({ cmd: [bunExe(), join(import.meta.dir, "console-timeLog.js")], stdin: null, stdout: "pipe", stderr: "pipe", env: bunEnv, }); - expect(await exited).toBe(0); - const outText = await stderr.text(); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); const expectedText = (await file(join(import.meta.dir, "console-timeLog.expected.txt")).text()).replaceAll( "\r\n", "\n", ); - expect(outText.replace(/^\[.+?s\] /gm, "")).toBe(expectedText.replace(/^\[.+?s\] /gm, "")); + expect(stderr).toBe(""); + expect(stdout.replace(elapsed, "$1: [time]")).toBe(expectedText.replace(elapsed, "$1: [time]")); + expect(exitCode).toBe(0); }); diff --git a/test/js/web/workers/structured-clone.test.ts b/test/js/web/workers/structured-clone.test.ts index e6a305671110..4136667d3da7 100644 --- a/test/js/web/workers/structured-clone.test.ts +++ b/test/js/web/workers/structured-clone.test.ts @@ -47,7 +47,8 @@ function jscSerializeRoundtripCrossProcessCold(original: any) { import {deserialize, serialize} from "bun:jsc"; const serialized = deserialize(await Bun.stdin.bytes()); const cloned = serialize(serialized); - process.stdout.write(cloned); + // serialize() hands back a SharedArrayBuffer, which Writable rejects (as node does). + process.stdout.write(new Uint8Array(cloned)); `, ], env: bunEnv, @@ -75,7 +76,8 @@ const crossProcessChildScript = ` chunks = [buf]; break; } - const cloned = serialize(deserialize(buf.subarray(4, 4 + len))); + // serialize() hands back a SharedArrayBuffer, which Writable rejects (as node does). + const cloned = new Uint8Array(serialize(deserialize(buf.subarray(4, 4 + len)))); const header = Buffer.alloc(4); header.writeUInt32LE(cloned.byteLength, 0); process.stdout.write(header); diff --git a/test/js/web/workers/structuredClone-classes.test.ts b/test/js/web/workers/structuredClone-classes.test.ts index eff435ddf869..ceb244244bf4 100644 --- a/test/js/web/workers/structuredClone-classes.test.ts +++ b/test/js/web/workers/structuredClone-classes.test.ts @@ -81,7 +81,8 @@ describe("serialize & deserialize", () => { import {deserialize, serialize} from "bun:jsc"; const serialized = deserialize(await Bun.stdin.bytes()); const cloned = serialize(serialized); - process.stdout.write(cloned); + // serialize() hands back a SharedArrayBuffer, which Writable rejects (as node does). + process.stdout.write(new Uint8Array(cloned)); `, ], env: bunEnv, From 584ddb38ec2c73aad4ffa5f5b1ab1843d6318993 Mon Sep 17 00:00:00 2001 From: Dylan Conway Date: Fri, 7 Aug 2026 04:31:05 -0700 Subject: [PATCH 02/26] clippy: ref_as_ptr, unnecessary_map_or, undocumented unsafe blocks --- src/jsc/ConsoleObject.rs | 10 ++++++++-- src/runtime/webcore/Blob.rs | 5 ++--- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/jsc/ConsoleObject.rs b/src/jsc/ConsoleObject.rs index d77dd23c655b..4d263cef5eb7 100644 --- a/src/jsc/ConsoleObject.rs +++ b/src/jsc/ConsoleObject.rs @@ -336,7 +336,12 @@ pub fn emit( let mut buf = ConsoleObject::take_scratch(console); let mut writer = ConsoleWriter { buf: &mut buf, - spill: native.then(|| (global.bun_vm().as_mut() as *mut VirtualMachine, stream.fd())), + spill: native.then(|| { + ( + std::ptr::from_mut::(global.bun_vm().as_mut()), + stream.fd(), + ) + }), }; let result = match f(&mut writer) { Ok(()) => deliver_to(global, stream, target, &buf), @@ -552,7 +557,7 @@ fn message_with_type_and_level_( .get(global, b"isTTY")? .is_some_and(|v| v.to_boolean()) }; - if is_tty && bun_core::env_var::TERM::get().map_or(true, |t| t != b"dumb") { + if is_tty && bun_core::env_var::TERM::get().is_none_or(|t| t != b"dumb") { return deliver(global, ConsoleStream::Stdout, b"\x1b[1;1H\x1b[0J"); } return Ok(()); @@ -6174,6 +6179,7 @@ pub(crate) extern "C" fn Bun__ConsoleObject__timeLog( ) { // SAFETY: caller passes valid (ptr, len) pairs. let label = unsafe { bun_core::ffi::slice(chars, len) }; + // SAFETY: as above. let args = unsafe { bun_core::ffi::slice(args, args_len) }; time_log_impl(global, "console.timeLog()", label, false, args); } diff --git a/src/runtime/webcore/Blob.rs b/src/runtime/webcore/Blob.rs index 7bda04aecc02..9ec3bea9ab3a 100644 --- a/src/runtime/webcore/Blob.rs +++ b/src/runtime/webcore/Blob.rs @@ -5084,9 +5084,8 @@ pub(crate) fn write_file_internal( let vm = global_this.bun_vm().as_mut(); if let Some(sink) = webcore::file_sink::stdio_sink_for(vm, stdio_fd) { // SAFETY: canonical live pointer held by RareData. - if let Some((result, accepted)) = - unsafe { (*sink).write_js_value(global_this, data, true)? } - { + let wrote = unsafe { (*sink).write_js_value(global_this, data, true)? }; + if let Some((result, accepted)) = wrote { // `Bun.write` resolves once the bytes are written, with // *this* call's byte count — not whenever (and with // whatever total) the shared sink's queue drains. From 40db4161d978308ba4c270ba75e2d3e77d91d3d2 Mon Sep 17 00:00:00 2001 From: Dylan Conway Date: Fri, 7 Aug 2026 04:44:06 -0700 Subject: [PATCH 03/26] review: stdio sink follow-ups - StdioLock is !Send; consoleStream() header comment reflects that first resolution may run a user getter; emit() doc notes spilled messages can be torn by a formatting exception - FileSink: Ok(0) from write(2) is an error, not a silent stop; start_lazy failure leaves fd ownership with the writer; stdio deinit drops the backpressure keep-alive ref; writeNow reports STREAM_NULL_VALUES like write(); a spawn that put fd 1/2 back into blocking mode is noticed (STDIO_MADE_BLOCKING) so the sink stops assuming EAGAIN - drain stdio at the top of global_exit(); pass raw VM pointers to the sink externs - Console.write on an observed stream uses the console's ignore-errors policy; fast-path streams maintain bytesWritten; takeBuffered is a private name, not a public Writable static; worker_threads binds the local port stream - tests: guard POSIX-only fixture setup, dispose the child in runWithSlowStdout, await worker stdout/stderr end --- src/io/lib.rs | 4 ++ src/io/stdio_lock.rs | 7 +-- src/js/builtins/BunBuiltinNames.h | 1 + src/js/builtins/ConsoleObject.ts | 20 ++++++-- src/js/builtins/ProcessObjectInternals.ts | 5 +- src/js/internal/fs/streams.ts | 32 ++++++------ src/js/internal/streams/writable.ts | 4 +- src/js/node/worker_threads.ts | 10 ++-- src/jsc/ConsoleObject.rs | 6 ++- src/jsc/VirtualMachine.rs | 13 +++-- src/jsc/bindings/BunProcess.h | 4 +- src/runtime/webcore/Blob.rs | 3 ++ src/runtime/webcore/FileSink.rs | 51 +++++++++++++++++-- src/spawn_sys/posix_spawn.rs | 5 +- src/sys/lib.rs | 32 ++++++++++++ test/js/node/process/process-stdio.test.ts | 4 +- .../worker_threads/worker_threads.test.ts | 2 +- 17 files changed, 158 insertions(+), 45 deletions(-) diff --git a/src/io/lib.rs b/src/io/lib.rs index 68b2f6893021..245940463533 100644 --- a/src/io/lib.rs +++ b/src/io/lib.rs @@ -1892,6 +1892,10 @@ impl FilePollRef { self.inner().flags.insert(f); } #[inline] + pub fn clear_flag(self, f: FilePollFlag) { + self.inner().flags.remove(f); + } + #[inline] pub(crate) fn file_type(self) -> crate::pipes::FileType { #[cfg(not(windows))] { diff --git a/src/io/stdio_lock.rs b/src/io/stdio_lock.rs index 0f7c83638a22..d82fd022f8b2 100644 --- a/src/io/stdio_lock.rs +++ b/src/io/stdio_lock.rs @@ -40,8 +40,9 @@ fn mutex(slot: usize) -> &'static Mutex { } } -/// RAII guard; no-op for fds other than 1 and 2. -pub struct StdioLock(Option); +/// RAII guard; no-op for fds other than 1 and 2. `!Send`: the depth it +/// balances is this thread's. +pub struct StdioLock(Option, core::marker::PhantomData<*mut ()>); impl StdioLock { #[inline] @@ -55,7 +56,7 @@ impl StdioLock { d[i].set(d[i].get() + 1); }); } - Self(slot) + Self(slot, core::marker::PhantomData) } } diff --git a/src/js/builtins/BunBuiltinNames.h b/src/js/builtins/BunBuiltinNames.h index d7fe2f46e53a..7144cfe1036f 100644 --- a/src/js/builtins/BunBuiltinNames.h +++ b/src/js/builtins/BunBuiltinNames.h @@ -187,6 +187,7 @@ using namespace JSC; macro(stdout) \ macro(stream) \ macro(syscall) \ + macro(takeBuffered) \ macro(text) \ macro(textDecoder) \ macro(textDecoderStreamDecoder) \ diff --git a/src/js/builtins/ConsoleObject.ts b/src/js/builtins/ConsoleObject.ts index 3e2d36691735..e4a8fcff9330 100644 --- a/src/js/builtins/ConsoleObject.ts +++ b/src/js/builtins/ConsoleObject.ts @@ -130,11 +130,23 @@ export function write(this: Console, input) { const observed = consoleStream(1); const count = $argumentCount(); if (observed) { + // Same error policy as console.log on this path (`writeToObservedStream` + // below, Node's ignoreErrors): a broken stream neither throws out of + // console.write nor becomes an unhandled 'error'. + const noop = () => {}; + const isEmitter = typeof observed.listenerCount === "function" && typeof observed.once === "function"; var wrote = 0; - for (var i = 0; i < count; i++) { - const chunk = arguments[i]; - observed.write(chunk); - wrote += typeof chunk === "string" ? Buffer.byteLength(chunk) : $toLength(chunk?.byteLength ?? 0); + try { + if (isEmitter && observed.listenerCount("error") === 0) observed.once("error", noop); + for (var i = 0; i < count; i++) { + const chunk = arguments[i]; + observed.write(chunk); + wrote += typeof chunk === "string" ? Buffer.byteLength(chunk) : $toLength(chunk?.byteLength ?? 0); + } + } catch (e: any) { + if (e?.name === "RangeError" && e?.message === "Maximum call stack size exceeded.") throw e; + } finally { + if (isEmitter) observed.removeListener("error", noop); } return wrote; } diff --git a/src/js/builtins/ProcessObjectInternals.ts b/src/js/builtins/ProcessObjectInternals.ts index 95612429e021..9b8c36e00316 100644 --- a/src/js/builtins/ProcessObjectInternals.ts +++ b/src/js/builtins/ProcessObjectInternals.ts @@ -90,6 +90,9 @@ export function getStdioWriteStream( this._undestroy(); updateObserved(this); + // `_undestroy()` above reset the state before Writable's own close path + // ran, so with emitClose off nothing would announce the (transient) + // destruction; keep it observable for `finished()` / `pipeline()`. if (!this._writableState.emitClose) { process.nextTick(() => { this.emit("close"); @@ -182,7 +185,7 @@ export function flushStdioWriteStreamOnExit(stream) { if (!state || state.corked) return; const sink = stream[require("internal/fs/streams").kWriteStreamFastPath]; if (!sink || sink === true) return; - const buffered = require("internal/streams/writable").takeBuffered(state); + const buffered = $getByIdDirectPrivate(require("internal/streams/writable"), "takeBuffered")(state); for (let i = 0; i < buffered.length; i++) { const { chunk, encoding } = buffered[i]; // A failure here (rejected promise: the sink's latched EPIPE/EIO; throw: a diff --git a/src/js/internal/fs/streams.ts b/src/js/internal/fs/streams.ts index 461a9516cfb5..16fc739b407a 100644 --- a/src/js/internal/fs/streams.ts +++ b/src/js/internal/fs/streams.ts @@ -630,28 +630,26 @@ function underscoreWriteFast(this: FSStream, chunk: any, encoding: any, cb: any) return; } - settleFastWrite(this, maybePromise, cb); + settleFastWrite(this, maybePromise, typeof chunk === "string" ? Buffer.byteLength(chunk) : chunk.byteLength, cb); } -function settleFastWrite(stream, maybePromise, cb) { +function settleFastWrite(stream, maybePromise, size, cb) { if ($isPromise(maybePromise)) { const onPending = stream[kOnPendingWrite]; - if (onPending) { - onPending.$call(stream, true); - maybePromise.then( - () => { - onPending.$call(stream, false); - cb(null); - }, - err => { - onPending.$call(stream, false); - cb(err); - }, - ); - } else { - maybePromise.then(() => cb(null), cb); - } + if (onPending) onPending.$call(stream, true); + maybePromise.then( + () => { + stream.bytesWritten += size; + if (onPending) onPending.$call(stream, false); + cb(null); + }, + err => { + if (onPending) onPending.$call(stream, false); + cb(err); + }, + ); } else { + stream.bytesWritten += size; cb(null); } } diff --git a/src/js/internal/streams/writable.ts b/src/js/internal/streams/writable.ts index c783050e4d67..d0a1326dd85e 100644 --- a/src/js/internal/streams/writable.ts +++ b/src/js/internal/streams/writable.ts @@ -410,7 +410,9 @@ function Writable(options): void { $toClass(Writable, "Writable", Stream); Writable.WritableState = WritableState; -Writable.takeBuffered = takeBuffered; +// Internal-only (process stdio at exit); a private name keeps it off the public +// `stream.Writable` surface. +$putByIdDirectPrivate(Writable, "takeBuffered", takeBuffered); ObjectDefineProperty(Writable, SymbolHasInstance, { __proto__: null, diff --git a/src/js/node/worker_threads.ts b/src/js/node/worker_threads.ts index 391e6d3db869..bb72b0ab7ebf 100644 --- a/src/js/node/worker_threads.ts +++ b/src/js/node/worker_threads.ts @@ -453,12 +453,14 @@ function setupWorkerStdio(stdio) { // process.stdout, which in a worker is a port-backed Writable). const setConsoleStream = $newCppFunction("BunProcess.cpp", "jsFunctionSetConsoleStream", 2); if (stdout) { - (process as any).stdout = makePortWritable(stdout); - setConsoleStream(1, process.stdout); + const stream = makePortWritable(stdout); + (process as any).stdout = stream; + setConsoleStream(1, stream); } if (stderr) { - (process as any).stderr = makePortWritable(stderr); - setConsoleStream(2, process.stderr); + const stream = makePortWritable(stderr); + (process as any).stderr = stream; + setConsoleStream(2, stream); } // node always replaces a worker's process.stdin: port-backed when { stdin: true }, // otherwise an immediately-EOF'd stream — never the process-wide fd 0, which diff --git a/src/jsc/ConsoleObject.rs b/src/jsc/ConsoleObject.rs index 4d263cef5eb7..5f5b9a8a4db7 100644 --- a/src/jsc/ConsoleObject.rs +++ b/src/jsc/ConsoleObject.rs @@ -319,8 +319,10 @@ impl bun_io::Write for ConsoleWriter<'_> { } /// Format a message with `f` and deliver it. If `f` fails (a JS exception -/// thrown while formatting — a throwing getter, `toJSON`, ...) nothing further -/// is written and the exception propagates, as in Node. +/// thrown while formatting — a throwing getter, `toJSON`, ...) the exception +/// propagates and nothing further is written, as in Node — except that on the +/// native path a message already past [`SPILL_AT`] has had its earlier 64 KiB +/// blocks written, so the reader sees it torn (bounded memory wins there). pub fn emit( global: &JSGlobalObject, stream: ConsoleStream, diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index e59faac464cc..1314a9eff1b3 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -1478,8 +1478,9 @@ impl VirtualMachine { .as_ref() .is_some_and(|r| r.stdio_sinks.iter().any(Option::is_some)) { - // SAFETY: `self` is the live per-thread VM. - unsafe { crate::rare_data::__bun_stdio_sink_drain(self) }; + let vm = core::ptr::from_mut::(self); + // SAFETY: `vm` is the live per-thread VM; no `&mut` is held across the call. + unsafe { crate::rare_data::__bun_stdio_sink_drain(vm) }; } } @@ -1543,6 +1544,9 @@ impl VirtualMachine { pub fn global_exit(&mut self) -> ! { debug_assert!(self.is_shutting_down()); + // Paths that get here without `on_exit()` (early CLI/test-runner + // exits) still owe the fd whatever process.stdout/stderr queued. + self.drain_stdio(); // FIXME: we should be doing this, but we're not, but unfortunately // doing it causes like 50+ tests to break // self.event_loop().tick(); @@ -4662,8 +4666,9 @@ impl VirtualMachine { .as_ref() .is_some_and(|r| r.stdio_sinks.iter().any(Option::is_some)) { - // SAFETY: `self` is the live per-thread VM. - unsafe { crate::rare_data::__bun_stdio_sink_release_js(self) }; + let vm = core::ptr::from_mut::(self); + // SAFETY: `vm` is the live per-thread VM; no `&mut` is held across the call. + unsafe { crate::rare_data::__bun_stdio_sink_release_js(vm) }; } if let Some(rare) = self.rare_data.as_deref_mut() { rare.listening_sockets_for_watch_mode.lock().clear(); diff --git a/src/jsc/bindings/BunProcess.h b/src/jsc/bindings/BunProcess.h index 93803c5edb8c..ca1b2c47f6ec 100644 --- a/src/jsc/bindings/BunProcess.h +++ b/src/jsc/bindings/BunProcess.h @@ -76,7 +76,9 @@ class Process : public WebCore::JSEventEmitter { // `console._stdout = value` / worker stdio rebinding. `value` may be anything. void setConsoleStream(JSC::VM&, int fd, JSValue value); // The stream the console must deliver through via JS `write()`, or the - // empty value when the native stdio sink may be used. Never runs user code. + // empty value when the native stdio sink may be used. A structure compare + // once resolved; the first (Unresolved) call may run — and throw from — a + // user getter installed on `process.stdout`/`stderr`. JSValue consoleStream(JSC::JSGlobalObject*, int fd); bool consoleStreamIsResolved(int fd) const { return m_consoleStreamState[fd - 1] != ConsoleStreamState::Unresolved; } // What `console._stdout` / `console._stderr` evaluate to (may materialise diff --git a/src/runtime/webcore/Blob.rs b/src/runtime/webcore/Blob.rs index 9ec3bea9ab3a..f2b63e39d35e 100644 --- a/src/runtime/webcore/Blob.rs +++ b/src/runtime/webcore/Blob.rs @@ -1804,6 +1804,9 @@ impl BlobExt for Blob { // reorder or hold separate queues (and never open a second dup / flip // the description's flags a second time). if let Some(stdio_fd) = stdio_fd_of_store(global_this, &store) { + // The shared per-thread stdio sink: one object however many times + // it is asked for, so per-call options (`highWaterMark`) can't and + // don't apply. if let Some(js) = webcore::file_sink::stdio_sink_js(global_this, stdio_fd) { return Ok(js); } diff --git a/src/runtime/webcore/FileSink.rs b/src/runtime/webcore/FileSink.rs index c61ea173c3e2..54a9a49a9f7f 100644 --- a/src/runtime/webcore/FileSink.rs +++ b/src/runtime/webcore/FileSink.rs @@ -653,6 +653,22 @@ impl FileSink { self.stdio_js.with_mut(|s| s.deinit()); } + /// Something (spawn with inherited stdio) put our description back into + /// blocking mode after [`stdio_go_nonblocking`](Self::stdio_go_nonblocking): + /// stop treating the fd as `EAGAIN`-capable so a full pipe is handled by the + /// blocking-pipe strategy (`poll` before `write`) instead of a write that + /// was expected to return early. One relaxed load when nothing happened. + #[inline] + fn refresh_stdio_mode(&self) { + #[cfg(not(windows))] + if self.nonblocking.get() && self.is_stdio() && sys::stdio_made_blocking(self.stdio.get()) { + self.nonblocking.set(false); + if let Some(poll) = self.writer.get().get_poll() { + poll.clear_flag(bun_io::FilePollFlag::Nonblocking); + } + } + } + /// See [`stdio_js`](Self::stdio_js). FIFO only; sockets get per-call /// `MSG_DONTWAIT`, Linux ≥ 6.4 pipes honour `RWF_NOWAIT` on a blocking /// description (torvalds/linux@afed6271f5b0, "pipe: set FMODE_NOWAIT on @@ -730,8 +746,13 @@ impl FileSink { // Registered with the loop only while backed up (see `start_lazy`). if let Err(err) = (*this).writer.with_mut(|w| w.start_lazy(fd, pollable)) { - fd.close(); - (*this).fd.set(Fd::INVALID); + // The writer may or may not have adopted `fd`; make teardown the + // single owner of closing it. + (*this).writer.with_mut(|w| { + if w.get_fd() == Fd::INVALID { + w.handle = bun_io::pipes::PollOrFd::Fd(fd); + } + }); FileSink::deref(this); return Err(err); } @@ -788,6 +809,7 @@ impl FileSink { if !(*this).writer.get().has_pending_data() { return Ok(()); } + (*this).refresh_stdio_mode(); let _lock = bun_io::StdioLock::acquire((*this).stdio.get()); let _guard = FileSinkRef::new_ref(this); @@ -892,7 +914,16 @@ impl FileSink { } while !bytes.is_empty() { match sys::write_retrying(fd, bytes) { - Ok(0) => break, + Ok(0) => { + // No progress on a non-empty buffer: report it + // rather than pretend the tail went out. + let e = (*this).stdio_latch_error(sys::Error::from_code( + sys::E::EIO, + sys::Tag::write, + )); + (*this).writer.with_mut(|w| w.fail(e.clone())); + return Err(e); + } Ok(n) => { bytes = &bytes[n..]; (*this).written.set((*this).written.get() + n); @@ -1201,6 +1232,7 @@ impl FileSink { (*this).auto_flusher.with_mut(|a| a.registered.set(false)); return false; } + (*this).refresh_stdio_mode(); let _guard = FileSinkRef::new_ref(this); @@ -1277,6 +1309,7 @@ impl FileSink { return sys::Result::Ok(JSValue::UNDEFINED); } + self.refresh_stdio_mode(); // SAFETY(JsCell): `IOWriter::flush` is pure I/O; no JS re-entry while // the `&mut IOWriter` is held. let rc = self.writer.with_mut(|w| w.flush()); @@ -1412,6 +1445,7 @@ impl FileSink { if let Some(err) = self.stdio_error() { return (streams::Writable::Err(err), 0); } + self.refresh_stdio_mode(); if self.done.get() { return (streams::Writable::Done, 0); } @@ -2136,8 +2170,13 @@ pub(crate) fn write_now(global: &JSGlobalObject, frame: &CallFrame) -> JsResult< let _keep = bun_jsc::EnsureStillAlive(data); match sink.write_js_value(global, data, true)? { Some((result, _)) => Ok(result.to_js(global)), + // Same errors as `FileSink.prototype.write` (Sink.rs `js_write`). None => Err(global.throw_value(global.to_type_error( - bun_jsc::ErrorCode::INVALID_ARG_TYPE, + if data.is_empty_or_undefined_or_null() { + bun_jsc::ErrorCode::STREAM_NULL_VALUES + } else { + bun_jsc::ErrorCode::INVALID_ARG_TYPE + }, format_args!("write() expects a string, ArrayBufferView, or ArrayBuffer"), ))), } @@ -2163,6 +2202,10 @@ unsafe fn __bun_stdio_sink_deinit(ptr: *mut ()) { // finalizer). unsafe { (*this).stdio_js.with_mut(|s| s.deinit()); + // A stdio sink never reaches EOF/close, so a backpressure episode's + // keep-alive ref (`to_result` → `must_be_kept_alive_until_eof`) is + // still held; drop it with RareData's. + FileSink::clear_keep_alive_ref(this); FileSink::deref(this); } } diff --git a/src/spawn_sys/posix_spawn.rs b/src/spawn_sys/posix_spawn.rs index 88c1a50be799..8f565c65a858 100644 --- a/src/spawn_sys/posix_spawn.rs +++ b/src/spawn_sys/posix_spawn.rs @@ -618,14 +618,15 @@ pub mod posix_spawn { // A child that inherits one of our stdio fds shares its open file // description, including an O_NONBLOCK bit our stdio sink may have set // on a pipe. Hand it over blocking, like libuv does (state is shared, - // so this flips it for us too; our writers tolerate either mode): + // so this flips it for us too; `make_stdio_blocking` records that so + // the sinks stop expecting EAGAIN): // https://github.com/libuv/libuv/blob/v1.51.0/src/unix/process.c#L629-L631 if let Some(act) = actions { for action in act.actions.iter() { if action.kind == bun_spawn::FileActionType::Dup2 && (0..=2).contains(&action.fds[0]) { - let _ = sys::update_nonblocking(Fd::from_native(action.fds[0]), false); + sys::make_stdio_blocking(Fd::from_native(action.fds[0])); } } } diff --git a/src/sys/lib.rs b/src/sys/lib.rs index dac6f4ee3441..6346d689a1dd 100644 --- a/src/sys/lib.rs +++ b/src/sys/lib.rs @@ -7431,6 +7431,38 @@ pub fn get_fcntl_flags(_fd: Fd) -> Maybe { pub fn set_nonblocking(fd: Fd) -> Maybe<()> { update_nonblocking(fd, true) } +/// Bit `fd` (0–2) is set once something in this process has deliberately put +/// that stdio description back into blocking mode behind the stdio sinks' back +/// (spawn handing it to a child). Sinks that had gone non-blocking check it and +/// stop assuming `EAGAIN` semantics — see `FileSink::refresh_stdio_mode`. +pub static STDIO_MADE_BLOCKING: core::sync::atomic::AtomicU8 = core::sync::atomic::AtomicU8::new(0); + +#[cfg(unix)] +#[inline] +fn stdio_bit(fd: Fd) -> u8 { + match fd.native() { + n @ 0..=2 => 1u8 << n, + _ => 0, + } +} + +/// Clear `O_NONBLOCK` on one of our stdio fds and record it in +/// [`STDIO_MADE_BLOCKING`]. +#[cfg(unix)] +pub fn make_stdio_blocking(fd: Fd) { + debug_assert!(stdio_bit(fd) != 0); + if update_nonblocking(fd, false).is_ok() { + STDIO_MADE_BLOCKING.fetch_or(stdio_bit(fd), core::sync::atomic::Ordering::Release); + } +} + +/// See [`STDIO_MADE_BLOCKING`]. +#[cfg(unix)] +#[inline] +pub fn stdio_made_blocking(fd: Fd) -> bool { + STDIO_MADE_BLOCKING.load(core::sync::atomic::Ordering::Acquire) & stdio_bit(fd) != 0 +} + /// GETFL → toggle O_NONBLOCK → SETFL (only if changed). pub fn update_nonblocking(fd: Fd, nonblocking: bool) -> Maybe<()> { #[cfg(unix)] diff --git a/test/js/node/process/process-stdio.test.ts b/test/js/node/process/process-stdio.test.ts index 0517487d52cf..79e4a78b2d55 100644 --- a/test/js/node/process/process-stdio.test.ts +++ b/test/js/node/process/process-stdio.test.ts @@ -170,6 +170,8 @@ describe.concurrent("process-stdio", () => { // exits — however the reader paces itself. // ───────────────────────────────────────────────────────────────────────────── describe.skipIf(!isPosix).concurrent("stdio sink", () => { + // The describe body runs at collection time even when skipped. + if (!isPosix) return; // fcntl(2) is variadic; Apple's arm64 ABI passes variadic args on the stack, // so a fixed-arity dlopen binding gets F_SETFL wrong there. Compile tiny // non-variadic wrappers instead (shared with the spawned children). @@ -217,7 +219,7 @@ const nonblock = fd => fd_is_nonblock(fd) !== 0; fd_set_nonblock(r, 1); let wClosed = false; try { - const proc = spawn({ + await using proc = spawn({ cmd: [bunExe(), "-e", prelude + src], env: { ...bunEnv, ...opts.env }, stdio: ["ignore", w, "pipe"], diff --git a/test/js/node/worker_threads/worker_threads.test.ts b/test/js/node/worker_threads/worker_threads.test.ts index 4d3852ab808f..be3735c3c543 100644 --- a/test/js/node/worker_threads/worker_threads.test.ts +++ b/test/js/node/worker_threads/worker_threads.test.ts @@ -1798,7 +1798,7 @@ describe.concurrent("worker console", () => { err = ""; worker.stdout.setEncoding("utf8").on("data", c => (out += c)); worker.stderr.setEncoding("utf8").on("data", c => (err += c)); - await once(worker, "exit"); + await Promise.all([once(worker, "exit"), once(worker.stdout, "end"), once(worker.stderr, "end")]); expect({ out, err }).toEqual({ out: 'function function\nMap(1) {\n "k": "v",\n}\nbun-fmt\nraw write\n', err: "to stderr\n", From b704f3686c134ebc63a9a5fe8bc766f3cf838c6f Mon Sep 17 00:00:00 2001 From: Dylan Conway Date: Fri, 7 Aug 2026 04:59:48 -0700 Subject: [PATCH 04/26] ci: exception-scope discipline for console stream lookups, test fixes - Bun__Process__consoleStream / consoleStreamObject use a top-level scope (the Rust caller learns of a throw via the out-param); consoleStreamForGetter keeps one function-wide ThrowScope with RELEASE_AND_RETURN - VirtualMachine::destroy() releases the stdio sinks itself, for VMs torn down without release_js_handles() (bake) - node:fs copyFile fallback: the unknown-size loop retries EAGAIN/EINTR too - tests: stdio-sink suite reads through a mkfifo instead of bun:ffi in the runner and skips the cc()-using cases under ASAN; strip ANSI where the runner's stdout may be a colour TTY; timeLog warning test uses --no-warnings; write-after-end (piped) expectation matches node (dummyDestroy -> _undestroy leaves the stream writable) --- src/jsc/VirtualMachine.rs | 6 + src/jsc/bindings/BunProcess.cpp | 16 ++- src/runtime/node/node_fs.rs | 4 +- test/js/node/console/console.test.ts | 3 +- test/js/node/process/process-stdio.test.ts | 132 ++++++++++-------- .../process-stdout-write-after-end.test.ts | 10 +- .../worker_threads/worker_threads.test.ts | 3 + test/js/web/console/console-timeLog.test.ts | 1 + 8 files changed, 103 insertions(+), 72 deletions(-) diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 1314a9eff1b3..607b727e812c 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -4339,6 +4339,12 @@ impl VirtualMachine { } /// Worker-thread teardown. pub fn destroy(&mut self) { + // Stdio sinks need the loop (poll teardown) and `RareData.file_polls`; + // the usual exit paths released them in `release_js_handles()` already, + // this covers VMs torn down directly (e.g. `bun build`'s bake VM). + if let Some(rare) = self.rare_data.as_deref_mut() { + rare.release_stdio_sinks(); + } self.regular_event_loop.deinit(); self.macro_event_loop.deinit(); diff --git a/src/jsc/bindings/BunProcess.cpp b/src/jsc/bindings/BunProcess.cpp index fe66efa1c4d4..dd9d90f2a85f 100644 --- a/src/jsc/bindings/BunProcess.cpp +++ b/src/jsc/bindings/BunProcess.cpp @@ -3009,16 +3009,16 @@ JSValue Process::consoleStreamForGetter(JSC::JSGlobalObject* globalObject, int f ASSERT(fd == 1 || fd == 2); unsigned slot = static_cast(fd) - 1; auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); if (m_consoleStreamState[slot] == ConsoleStreamState::Unresolved) { - auto scope = DECLARE_THROW_SCOPE(vm); (void)consoleStream(globalObject, fd); RETURN_IF_EXCEPTION(scope, {}); } if (m_consoleStreamState[slot] == ConsoleStreamState::Custom) - return m_consoleStream[slot].get(); + RELEASE_AND_RETURN(scope, m_consoleStream[slot].get()); // Native: the real stream object (materialising it is fine here — the // caller asked for it by name). - return get(globalObject, fd == 1 ? WebCore::builtinNames(vm).stdoutPublicName() : WebCore::builtinNames(vm).stderrPublicName()); + RELEASE_AND_RETURN(scope, get(globalObject, fd == 1 ? WebCore::builtinNames(vm).stdoutPublicName() : WebCore::builtinNames(vm).stderrPublicName())); } // Empty: use the native sink. Otherwise the stream to `write()` to. `*threw` @@ -3030,13 +3030,15 @@ extern "C" JSC::EncodedJSValue Bun__Process__consoleStream(Zig::GlobalObject* gl return JSValue::encode({}); auto* process = globalObject->processObject(); if (!process->consoleStreamIsResolved(fd)) [[unlikely]] { - auto scope = DECLARE_THROW_SCOPE(JSC::getVM(globalObject)); + // Top scope: the Rust caller learns about a throw through `*threw` + // (and propagates the pending exception), not through a ThrowScope. + auto scope = DECLARE_TOP_EXCEPTION_SCOPE(JSC::getVM(globalObject)); JSValue result = process->consoleStream(globalObject, fd); if (scope.exception()) [[unlikely]] { *threw = true; return JSValue::encode({}); } - RELEASE_AND_RETURN(scope, JSValue::encode(result)); + return JSValue::encode(result); } return JSValue::encode(process->consoleStream(globalObject, fd)); } @@ -3107,14 +3109,14 @@ extern "C" JSC::EncodedJSValue Bun__Process__consoleStreamObject(Zig::GlobalObje if (!globalObject->hasProcessObject()) [[unlikely]] return JSValue::encode({}); auto* process = globalObject->processObject(); - auto scope = DECLARE_THROW_SCOPE(JSC::getVM(globalObject)); + auto scope = DECLARE_TOP_EXCEPTION_SCOPE(JSC::getVM(globalObject)); // see Bun__Process__consoleStream JSValue custom = process->consoleStream(globalObject, fd); if (scope.exception()) [[unlikely]] { *threw = true; return JSValue::encode({}); } if (custom) - RELEASE_AND_RETURN(scope, JSValue::encode(custom)); + return JSValue::encode(custom); if (JSObject* stream = process->stdioStream(fd)) return JSValue::encode(stream); return JSValue::encode({}); diff --git a/src/runtime/node/node_fs.rs b/src/runtime/node/node_fs.rs index cd3ed84fa906..1a4120165457 100644 --- a/src/runtime/node/node_fs.rs +++ b/src/runtime/node/node_fs.rs @@ -4930,7 +4930,7 @@ impl NodeFS { } if !broke { 'outer: loop { - let amt = match Syscall::read(src_fd, buf) { + let amt = match Syscall::read_retrying(src_fd, buf) { Ok(result) => result, Err(err) => { return Err(if !src.is_empty() { @@ -4949,7 +4949,7 @@ impl NodeFS { let mut slice = &buf[..amt]; while !slice.is_empty() { - let written = match Syscall::write(dest_fd, slice) { + let written = match Syscall::write_retrying(dest_fd, slice) { Ok(result) => result, Err(err) => { return Err(if !dest.is_empty() { diff --git a/test/js/node/console/console.test.ts b/test/js/node/console/console.test.ts index c99d49f0d1bf..d8277fe69e53 100644 --- a/test/js/node/console/console.test.ts +++ b/test/js/node/console/console.test.ts @@ -331,7 +331,8 @@ test("bun:test spyOn(process.stdout, 'write') captures console.log", () => { try { console.log("captured %s", "yes"); console.info({ k: "v" }); - calls = spy.mock.calls.map((c: unknown[]) => String(c[0])); + // The runner's own stdout may be a colour TTY; the capture is what matters. + calls = spy.mock.calls.map((c: unknown[]) => Bun.stripANSI(String(c[0]))); } finally { spy.mockRestore(); } diff --git a/test/js/node/process/process-stdio.test.ts b/test/js/node/process/process-stdio.test.ts index 79e4a78b2d55..7d928e1df2fa 100644 --- a/test/js/node/process/process-stdio.test.ts +++ b/test/js/node/process/process-stdio.test.ts @@ -1,8 +1,7 @@ import { spawn, spawnSync } from "bun"; -import { cc, ptr } from "bun:ffi"; import { describe, expect, test } from "bun:test"; -import { bunEnv, bunExe, isPosix, tempDirWithFiles } from "harness"; -import { closeSync, readSync } from "node:fs"; +import { bunEnv, bunExe, isASAN, isPosix, tempDirWithFiles } from "harness"; +import { closeSync, constants as fsConstants, openSync, readSync } from "node:fs"; import path from "path"; import { isatty } from "tty"; describe.concurrent("process-stdio", () => { @@ -172,22 +171,22 @@ describe.concurrent("process-stdio", () => { describe.skipIf(!isPosix).concurrent("stdio sink", () => { // The describe body runs at collection time even when skipped. if (!isPosix) return; + // Children that need to read/flip O_NONBLOCK on an fd get these helpers. // fcntl(2) is variadic; Apple's arm64 ABI passes variadic args on the stack, - // so a fixed-arity dlopen binding gets F_SETFL wrong there. Compile tiny - // non-variadic wrappers instead (shared with the spawned children). + // so a fixed-arity dlopen binding gets F_SETFL wrong there — compile tiny + // non-variadic wrappers instead. `cc()` is unavailable under ASAN, so tests + // that use the prelude skip there. const dir = tempDirWithFiles("stdio-sink", { "fdutil.c": ` #include #include int fd_is_nonblock(int fd) { int fl = fcntl(fd, F_GETFL); return fl >= 0 && (fl & O_NONBLOCK) != 0; } int fd_set_nonblock(int fd, int on) { int fl = fcntl(fd, F_GETFL); if (fl < 0) return fl; return fcntl(fd, F_SETFL, on ? (fl | O_NONBLOCK) : (fl & ~O_NONBLOCK)); } -int fd_pipe(int* fds) { return pipe(fds); } `, }); - const fdutil = path.join(dir, "fdutil.c"); const prelude = ` const { fd_is_nonblock, fd_set_nonblock } = require("bun:ffi").cc({ - source: ${JSON.stringify(fdutil)}, + source: ${JSON.stringify(path.join(dir, "fdutil.c"))}, symbols: { fd_is_nonblock: { args: ["int"], returns: "int" }, fd_set_nonblock: { args: ["int", "int"], returns: "int" }, @@ -195,37 +194,34 @@ const { fd_is_nonblock, fd_set_nonblock } = require("bun:ffi").cc({ }).symbols; const nonblock = fd => fd_is_nonblock(fd) !== 0; `; - const { fd_pipe, fd_set_nonblock } = cc({ - source: fdutil, - symbols: { - fd_pipe: { args: ["ptr"], returns: "int" }, - fd_set_nonblock: { args: ["int", "int"], returns: "int" }, - }, - }).symbols; + const needsPrelude = { skip: isASAN }; + let fifoCounter = 0; /** - * Run `src` in a child whose stdout is the write end of a raw pipe(2) that - * only this function reads, a small slice at a time, so a child writing more - * than the pipe holds is queued behind us for most of its life. (Tests that - * need proof of backpressure assert on `write()`'s return value.) + * Run `src` in a child whose stdout is the write end of a FIFO that only + * this function reads, a small slice at a time, so a child writing more than + * the pipe holds is queued behind us for most of its life. (Tests that need + * proof of backpressure assert on `write()`'s return value.) * `Bun.spawn({ stdout: "pipe" })` can't do this: it drains the child eagerly * into memory. Returns everything the child wrote to fd 1, its stderr, and * its exit code. */ async function runWithSlowStdout(src: string, opts: { env?: Record } = {}) { - const fds = new Int32Array(2); - expect(fd_pipe(ptr(fds))).toBe(0); - const [r, w] = fds; - fd_set_nonblock(r, 1); - let wClosed = false; + const fifo = path.join(dir, `stdout-${fifoCounter++}.fifo`); + expect(spawnSync({ cmd: ["mkfifo", fifo] }).exitCode).toBe(0); + // Reader first (a non-blocking open of the read side never waits for a + // writer), then the writer end for the child, which now opens instantly. + const r = openSync(fifo, fsConstants.O_RDONLY | fsConstants.O_NONBLOCK); + let w = -1; try { + w = openSync(fifo, "w"); await using proc = spawn({ - cmd: [bunExe(), "-e", prelude + src], + cmd: [bunExe(), "-e", src], env: { ...bunEnv, ...opts.env }, stdio: ["ignore", w, "pipe"], }); closeSync(w); - wClosed = true; + w = -1; let exited = false; const exitedP = proc.exited.then(code => ((exited = true), code)); const stderrP = proc.stderr.text(); @@ -261,7 +257,7 @@ const nonblock = fd => fd_is_nonblock(fd) !== 0; const [stderr, exitCode] = await Promise.all([stderrP, exitedP]); return { stdout: Buffer.concat(chunks), stderr, exitCode }; } finally { - if (!wClosed) closeSync(w); + if (w !== -1) closeSync(w); closeSync(r); } } @@ -342,14 +338,17 @@ const nonblock = fd => fd_is_nonblock(fd) !== 0; expect(exitCode).toBe(1); }); - test("materialising process.stdout / process.stderr does not touch fd flags of a tty/file, and stdio-inheriting children get blocking fds", async () => { - // stdout here is a pipe: Bun may make *its* description non-blocking - // (that is how a slow reader queues instead of stalling the loop), but a - // child handed the fd must see it blocking, and stderr (a file below) - // must never be touched. - const errPath = path.join(tempDirWithFiles("stdio-sink-err", { "err.txt": "" }), "err.txt"); - const { stdout, exitCode } = await runWithSlowStdout( - ` + test.skipIf(needsPrelude.skip)( + "materialising process.stdout / process.stderr does not touch fd flags of a tty/file, and stdio-inheriting children get blocking fds", + async () => { + // stdout here is a pipe: Bun may make *its* description non-blocking + // (that is how a slow reader queues instead of stalling the loop), but a + // child handed the fd must see it blocking, and stderr (a file below) + // must never be touched. + const errPath = path.join(tempDirWithFiles("stdio-sink-err", { "err.txt": "" }), "err.txt"); + const { stdout, exitCode } = await runWithSlowStdout( + prelude + + ` const fs = require("node:fs"); const errfd = fs.openSync(${JSON.stringify(errPath)}, "w"); const before = { out: nonblock(1), file: nonblock(errfd) }; @@ -360,18 +359,23 @@ const nonblock = fd => fd_is_nonblock(fd) !== 0; const child = Bun.spawnSync([process.execPath, "-e", ${JSON.stringify(prelude + `process.stderr.write(String(nonblock(1)))`)}], { stdio: ["ignore", "inherit", "pipe"], env: process.env }); console.log(JSON.stringify({ before, after, childSeesNonblock: child.stderr.toString() })); `, - ); - const line = stdout.toString().trim().split("\n").pop()!; - expect(JSON.parse(line.replace(/^x/, ""))).toEqual({ - before: { out: false, file: false }, - after: { file: false }, - childSeesNonblock: "false", - }); - expect(exitCode).toBe(0); - }); + ); + const line = stdout.toString().trim().split("\n").pop()!; + expect(JSON.parse(line.replace(/^x/, ""))).toEqual({ + before: { out: false, file: false }, + after: { file: false }, + childSeesNonblock: "false", + }); + expect(exitCode).toBe(0); + }, + ); - test("console.log delivers every byte when something else made fd 1 O_NONBLOCK and the pipe is full", async () => { - const { stdout, stderr, exitCode } = await runWithSlowStdout(` + test.skipIf(needsPrelude.skip)( + "console.log delivers every byte when something else made fd 1 O_NONBLOCK and the pipe is full", + async () => { + const { stdout, stderr, exitCode } = await runWithSlowStdout( + prelude + + ` const fs = require("node:fs"); fd_set_nonblock(1, 1); // Fill the pipe until the kernel refuses. @@ -380,16 +384,22 @@ const nonblock = fd => fd_is_nonblock(fd) !== 0; for (;;) { try { filled += fs.writeSync(1, fill); } catch { break; } } process.stderr.write(String(filled)); for (let i = 0; i < 10; i++) console.log("marker " + i); - `); - const filled = Number(stderr); - expect(filled).toBeGreaterThan(0); - expect(stdout.subarray(0, filled).equals(Buffer.alloc(filled, "x"))).toBe(true); - expect(stdout.subarray(filled).toString()).toBe(Array.from({ length: 10 }, (_, i) => `marker ${i}\n`).join("")); - expect(exitCode).toBe(0); - }); + `, + ); + const filled = Number(stderr); + expect(filled).toBeGreaterThan(0); + expect(stdout.subarray(0, filled).equals(Buffer.alloc(filled, "x"))).toBe(true); + expect(stdout.subarray(filled).toString()).toBe(Array.from({ length: 10 }, (_, i) => `marker ${i}\n`).join("")); + expect(exitCode).toBe(0); + }, + ); - test("an idle Worker / worker_threads.Worker does not perturb the parent's stdio", async () => { - const { stdout, stderr, exitCode } = await runWithSlowStdout(` + test.skipIf(needsPrelude.skip)( + "an idle Worker / worker_threads.Worker does not perturb the parent's stdio", + async () => { + const { stdout, stderr, exitCode } = await runWithSlowStdout( + prelude + + ` const { Worker } = require("node:worker_threads"); const before = [nonblock(1), nonblock(2)]; const w = new Worker("setTimeout(() => {}, 10)", { eval: true }); @@ -400,11 +410,13 @@ const nonblock = fd => fd_is_nonblock(fd) !== 0; const filler = Buffer.alloc(4000, "p").toString(); for (let i = 0; i < 40; i++) console.log("line " + i + " " + filler); process.stderr.write(JSON.stringify({ before, during })); - `); - expect(JSON.parse(stderr)).toEqual({ before: [false, false], during: [false, false] }); - expect(stdout.toString().split("\n").filter(Boolean).length).toBe(40); - expect(exitCode).toBe(0); - }); + `, + ); + expect(JSON.parse(stderr)).toEqual({ before: [false, false], during: [false, false] }); + expect(stdout.toString().split("\n").filter(Boolean).length).toBe(40); + expect(exitCode).toBe(0); + }, + ); test("Bun.stdout.writer() is the shared sink: same object every time; end()/close() only flush; writes coalesce until flushed", async () => { await using proc = spawn({ diff --git a/test/js/node/process/process-stdout-write-after-end.test.ts b/test/js/node/process/process-stdout-write-after-end.test.ts index 32ceb8f1cc7c..3a51589a5c59 100644 --- a/test/js/node/process/process-stdout-write-after-end.test.ts +++ b/test/js/node/process/process-stdout-write-after-end.test.ts @@ -24,9 +24,15 @@ test.concurrent.each(["stdout", "stderr"] as const)( const lines = reportPipe.trim().split("\n"); const report = JSON.parse(lines[lines.length - 1]); + // The synchronous write() right after end() is rejected (still ending). + // By report time the stream has gone finish -> destroy -> _undestroy (Node + // installs `dummyDestroy` on every stdio stream, pipes included: + // https://github.com/nodejs/node/blob/v24.0.0/lib/internal/bootstrap/switches/is_main_thread.js#L114-L128), + // so it reads as writable again — the same facts `node` (v22–v25) prints + // for this fixture. expect(report).toEqual({ - writableEnded: true, - writable: false, + writableEnded: false, + writable: true, ret: false, cbErr: "ERR_STREAM_WRITE_AFTER_END", ev: ["err:ERR_STREAM_WRITE_AFTER_END"], diff --git a/test/js/node/worker_threads/worker_threads.test.ts b/test/js/node/worker_threads/worker_threads.test.ts index be3735c3c543..1ce04e218f4d 100644 --- a/test/js/node/worker_threads/worker_threads.test.ts +++ b/test/js/node/worker_threads/worker_threads.test.ts @@ -1799,6 +1799,9 @@ describe.concurrent("worker console", () => { worker.stdout.setEncoding("utf8").on("data", c => (out += c)); worker.stderr.setEncoding("utf8").on("data", c => (err += c)); await Promise.all([once(worker, "exit"), once(worker.stdout, "end"), once(worker.stderr, "end")]); + // Colour follows the parent's real stdout (possibly a TTY under the runner). + out = Bun.stripANSI(out); + err = Bun.stripANSI(err); expect({ out, err }).toEqual({ out: 'function function\nMap(1) {\n "k": "v",\n}\nbun-fmt\nraw write\n', err: "to stderr\n", diff --git a/test/js/web/console/console-timeLog.test.ts b/test/js/web/console/console-timeLog.test.ts index cee034034ced..f255c227539d 100644 --- a/test/js/web/console/console-timeLog.test.ts +++ b/test/js/web/console/console-timeLog.test.ts @@ -39,6 +39,7 @@ it.concurrent("console.timeEnd / timeLog for a missing label warn instead of pri await using proc = Bun.spawn({ cmd: [ bunExe(), + "--no-warnings", // only our listener prints "-e", `process.on("warning", w => process.stderr.write("warning: " + w.message + "\\n")); console.timeEnd("nope"); console.timeLog("nope"); console.time("dup"); console.time("dup"); console.timeEnd("dup");`, ], From 342d2140af71fa290d8a029085b68a86e338371f Mon Sep 17 00:00:00 2001 From: Dylan Conway Date: Fri, 7 Aug 2026 05:01:27 -0700 Subject: [PATCH 05/26] aarch64 baseline: use the allowlisted acq_rel outline atomic for STDIO_MADE_BLOCKING; Console.write only removes the error guard it added --- src/js/builtins/ConsoleObject.ts | 13 ++++++++++--- src/sys/lib.rs | 7 +++++-- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/src/js/builtins/ConsoleObject.ts b/src/js/builtins/ConsoleObject.ts index e4a8fcff9330..ead5567fc220 100644 --- a/src/js/builtins/ConsoleObject.ts +++ b/src/js/builtins/ConsoleObject.ts @@ -134,10 +134,17 @@ export function write(this: Console, input) { // below, Node's ignoreErrors): a broken stream neither throws out of // console.write nor becomes an unhandled 'error'. const noop = () => {}; - const isEmitter = typeof observed.listenerCount === "function" && typeof observed.once === "function"; + const isEmitter = + typeof observed.listenerCount === "function" && + typeof observed.once === "function" && + typeof observed.removeListener === "function"; + let guarded = false; var wrote = 0; try { - if (isEmitter && observed.listenerCount("error") === 0) observed.once("error", noop); + if (isEmitter && observed.listenerCount("error") === 0) { + observed.once("error", noop); + guarded = true; + } for (var i = 0; i < count; i++) { const chunk = arguments[i]; observed.write(chunk); @@ -146,7 +153,7 @@ export function write(this: Console, input) { } catch (e: any) { if (e?.name === "RangeError" && e?.message === "Maximum call stack size exceeded.") throw e; } finally { - if (isEmitter) observed.removeListener("error", noop); + if (guarded) observed.removeListener("error", noop); } return wrote; } diff --git a/src/sys/lib.rs b/src/sys/lib.rs index 6346d689a1dd..9ca369ff78cd 100644 --- a/src/sys/lib.rs +++ b/src/sys/lib.rs @@ -7447,12 +7447,15 @@ fn stdio_bit(fd: Fd) -> u8 { } /// Clear `O_NONBLOCK` on one of our stdio fds and record it in -/// [`STDIO_MADE_BLOCKING`]. +/// [`STDIO_MADE_BLOCKING`]. Best effort, like libuv's equivalent: a child is +/// still better off spawned with a non-blocking stdio than not spawned. #[cfg(unix)] pub fn make_stdio_blocking(fd: Fd) { debug_assert!(stdio_bit(fd) != 0); if update_nonblocking(fd, false).is_ok() { - STDIO_MADE_BLOCKING.fetch_or(stdio_bit(fd), core::sync::atomic::Ordering::Release); + // AcqRel: the outline-atomics helper for it is already on the aarch64 + // baseline allowlist (scripts/verify-baseline-static). + STDIO_MADE_BLOCKING.fetch_or(stdio_bit(fd), core::sync::atomic::Ordering::AcqRel); } } From e6f489e5d7d38380b7903531a48aeaf05bc07961 Mon Sep 17 00:00:00 2001 From: Dylan Conway Date: Fri, 7 Aug 2026 05:09:27 -0700 Subject: [PATCH 06/26] FileSink::write_with: report this call's byte count, not the flushed total A write that also pushes out bytes an earlier Bun.stdout.writer().write() had coalesced returned their combined length, so Bun.write(Bun.stdout, s) could over-report. Callers that need the count (Bun.write) now supply the input's UTF-8 length; the pending credit used by to_result is unchanged. Also puts write_file_internal's doc comment back on write_file_internal. --- src/runtime/webcore/Blob.rs | 10 ++--- src/runtime/webcore/FileSink.rs | 49 ++++++++++++++-------- test/js/node/process/process-stdio.test.ts | 8 ++-- 3 files changed, 42 insertions(+), 25 deletions(-) diff --git a/src/runtime/webcore/Blob.rs b/src/runtime/webcore/Blob.rs index f2b63e39d35e..28b246a348f5 100644 --- a/src/runtime/webcore/Blob.rs +++ b/src/runtime/webcore/Blob.rs @@ -4994,10 +4994,6 @@ pub(crate) fn write_file_with_source_destination( // writeFileInternal / writeFile (Bun.write) // ────────────────────────────────────────────────────────────────────────── -/// ## Errors -/// - If `path_or_blob` is a detached blob -/// ## Panics -/// - If `path_or_blob` is a `Blob` backed by a byte store /// `Some(Fd::stdout()|stderr())` when `store` is this VM's stdout/stderr store /// or any fd-backed store on fd 1/2 — i.e. writes to it belong to the stdio sink. pub(crate) fn stdio_fd_of_store(global_this: &JSGlobalObject, store: &Store) -> Option { @@ -5025,6 +5021,10 @@ pub(crate) fn stdio_fd_of_store(global_this: &JSGlobalObject, store: &Store) -> } } +/// ## Errors +/// - If `path_or_blob` is a detached blob +/// ## Panics +/// - If `path_or_blob` is a `Blob` backed by a byte store pub(crate) fn write_file_internal( global_this: &JSGlobalObject, path_or_blob_: &mut PathOrBlob, @@ -5087,7 +5087,7 @@ pub(crate) fn write_file_internal( let vm = global_this.bun_vm().as_mut(); if let Some(sink) = webcore::file_sink::stdio_sink_for(vm, stdio_fd) { // SAFETY: canonical live pointer held by RareData. - let wrote = unsafe { (*sink).write_js_value(global_this, data, true)? }; + let wrote = unsafe { (*sink).write_js_value(global_this, data, true, true)? }; if let Some((result, accepted)) = wrote { // `Bun.write` resolves once the bytes are written, with // *this* call's byte count — not whenever (and with diff --git a/src/runtime/webcore/FileSink.rs b/src/runtime/webcore/FileSink.rs index 54a9a49a9f7f..c74720da4173 100644 --- a/src/runtime/webcore/FileSink.rs +++ b/src/runtime/webcore/FileSink.rs @@ -952,16 +952,18 @@ impl FileSink { .with_mut(|p| p.consumed = p.consumed.saturating_sub(accepted)); } - /// `write()` for a JS `data` value that is a string / ArrayBuffer(View), - /// plus the encoded byte count accepted; `Ok(None)` for anything else so - /// the caller can take its general path. `now`: attempt the syscall - /// immediately (`IOWriter::write_now`) rather than coalescing small chunks - /// until end of tick. + /// `write()` for a JS `data` value that is a string / ArrayBuffer(View); + /// `Ok(None)` for anything else so the caller can take its general path. + /// `now`: attempt the syscall immediately (`IOWriter::write_now`) rather + /// than coalescing small chunks until end of tick. `count`: also return + /// this call's UTF-8 byte count (a pass over the string; only `Bun.write` + /// wants it) — otherwise the second element is unspecified. pub fn write_js_value( &self, global: &JSGlobalObject, data: JSValue, now: bool, + count: bool, ) -> JsResult> { if let Some(buffer) = data.as_array_buffer(global) { let _keep = bun_jsc::EnsureStillAlive(data); @@ -969,7 +971,7 @@ impl FileSink { if bytes.is_empty() { return Ok(Some((streams::Writable::Owned(0), 0))); } - return Ok(Some(self.write_with(|w| { + return Ok(Some(self.write_with(Some(bytes.len() as u64), |w| { if now { w.write_now(bytes) } else { @@ -988,7 +990,9 @@ impl FileSink { let _keep = bun_jsc::EnsureStillAlive(str_.to_js()); if view.is_16bit() { let utf16 = view.utf16_slice_aligned(); - return Ok(Some(self.write_with(|w| { + let len = + count.then(|| bun_core::strings::element_length_utf16_into_utf8(utf16) as u64); + return Ok(Some(self.write_with(len, |w| { if now { w.write_utf16_now(utf16) } else { @@ -997,7 +1001,8 @@ impl FileSink { }))); } let latin1 = view.slice(); - Ok(Some(self.write_with(|w| { + let len = count.then(|| bun_core::strings::element_length_latin1_into_utf8(latin1) as u64); + Ok(Some(self.write_with(len, |w| { if now { w.write_latin1_now(latin1) } else { @@ -1428,20 +1433,26 @@ impl FileSink { } pub fn write(&self, data: &streams::Result) -> streams::Writable { - self.write_with(|w| w.write(data.slice())).0 + self.write_with(None, |w| w.write(data.slice())).0 } pub(crate) fn write_latin1(&self, data: &streams::Result) -> streams::Writable { - self.write_with(|w| w.write_latin1(data.slice())).0 + self.write_with(None, |w| w.write_latin1(data.slice())).0 } pub(crate) fn write_utf16(&self, data: &streams::Result) -> streams::Writable { - self.write_with(|w| w.write_utf16(data.slice16())).0 + self.write_with(None, |w| w.write_utf16(data.slice16())).0 } - /// The result plus the number of (encoded) bytes this call accepted. + /// The result, plus how many UTF-8 bytes *this call* handed the writer: + /// exact when the caller supplied `encoded_len` (the writer accepts all of + /// its input or errors), else only meaningful for `Pending`. #[inline] - fn write_with(&self, f: impl FnOnce(&mut IOWriter) -> WriteResult) -> (streams::Writable, u64) { + fn write_with( + &self, + encoded_len: Option, + f: impl FnOnce(&mut IOWriter) -> WriteResult, + ) -> (streams::Writable, u64) { if let Some(err) = self.stdio_error() { return (streams::Writable::Err(err), 0); } @@ -1452,12 +1463,16 @@ impl FileSink { let buffered_before = self.writer.get().buffered_len(); // SAFETY(JsCell): `IOWriter::write*` buffers/writes to fd; does not call JS. let rc = self.writer.with_mut(f); + // What `to_result` credits a pending operation with. + let pending_credit = self.bytes_accepted(buffered_before, &rc); let accepted = match rc { - WriteResult::Pending(_) => self.bytes_accepted(buffered_before, &rc), - WriteResult::Wrote(n) | WriteResult::Done(n) => n as u64, WriteResult::Err(_) => 0, + // `n` may include bytes coalesced by earlier calls and flushed now, + // or be a partial write with the rest queued: not this call's count. + WriteResult::Wrote(n) | WriteResult::Done(n) => encoded_len.unwrap_or(n as u64), + WriteResult::Pending(_) => encoded_len.unwrap_or(pending_credit), }; - (self.to_result(rc, accepted), accepted) + (self.to_result(rc, pending_credit), accepted) } /// Native-path terminator called from `SinkHandle::end`. On upstream error @@ -2168,7 +2183,7 @@ pub(crate) fn write_now(global: &JSGlobalObject, frame: &CallFrame) -> JsResult< // (FileSink has no `get_pending_error`, unlike the socket sinks.) let sink: &FileSink = unsafe { &(*this).sink }; let _keep = bun_jsc::EnsureStillAlive(data); - match sink.write_js_value(global, data, true)? { + match sink.write_js_value(global, data, true, false)? { Some((result, _)) => Ok(result.to_js(global)), // Same errors as `FileSink.prototype.write` (Sink.rs `js_write`). None => Err(global.throw_value(global.to_type_error( diff --git a/test/js/node/process/process-stdio.test.ts b/test/js/node/process/process-stdio.test.ts index 7d928e1df2fa..03f307acf691 100644 --- a/test/js/node/process/process-stdio.test.ts +++ b/test/js/node/process/process-stdio.test.ts @@ -471,11 +471,13 @@ const nonblock = fd => fd_is_nonblock(fd) !== 0; const backedUp = !process.stdout.write(Buffer.alloc(${K256}, "x")); const n = await Bun.write(Bun.stdout, "abc"); const m = await Bun.write(Bun.stdout, new TextEncoder().encode("\\ndef!\\n")); + Bun.stdout.writer().write("[coalesced]"); // still in the writer's buffer... + const k = await Bun.write(Bun.stdout, "h\\u00e9!\\n"); // ...flushed by this, but not counted in it console.log("after"); - process.stderr.write(JSON.stringify({ backedUp, n, m })); + process.stderr.write(JSON.stringify({ backedUp, n, m, k })); `); - expect(JSON.parse(stderr)).toEqual({ backedUp: true, n: 3, m: 6 }); - expect(squash(stdout)).toBe(`abc\ndef!\nafter\n`); + expect(JSON.parse(stderr)).toEqual({ backedUp: true, n: 3, m: 6, k: 5 }); + expect(squash(stdout)).toBe(`abc\ndef!\n[coalesced]h\u00c3\u00a9!\nafter\n`); expect(exitCode).toBe(0); }); From 8566d73d8e519e6f07d0f5c7d6b9ae68e1be00d4 Mon Sep 17 00:00:00 2001 From: Dylan Conway Date: Fri, 7 Aug 2026 05:26:15 -0700 Subject: [PATCH 07/26] stdio sink: only trust RWF_NOWAIT on pipefs pipes; tolerate an fd closed behind our back at teardown - Named FIFOs don't get FMODE_NOWAIT, so pwritev2(RWF_NOWAIT) on them is EOPNOTSUPP (and flips RWFFlagSupport off process-wide); a FIFO stdout on Linux >= 6.4 therefore fell back to blocking writes. Probe fstatfs for PIPEFS_MAGIC and use O_NONBLOCK for anything else. - If user code closed our dup'd stdio fd by number (or something reused and closed that number), teardown detaches instead of close()ing it again. --- src/runtime/webcore/FileSink.rs | 23 +++++++++++++++++++---- src/sys/lib.rs | 22 ++++++++++++++++++++++ 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/src/runtime/webcore/FileSink.rs b/src/runtime/webcore/FileSink.rs index c74720da4173..2c8f12c61155 100644 --- a/src/runtime/webcore/FileSink.rs +++ b/src/runtime/webcore/FileSink.rs @@ -678,19 +678,20 @@ impl FileSink { if self.nonblocking.get() || !self.pollable.get() || self.is_socket.get() { return; } + let fd = self.writer.get().get_fd(); + if fd == Fd::INVALID { + return; + } #[cfg(any(target_os = "linux", target_os = "android"))] { let v = bun_core::linux_kernel_version(); if (v.major > 6 || (v.major == 6 && v.minor >= 4)) && sys::linux::RWFFlagSupport::is_maybe_supported() + && sys::is_on_pipefs(fd) { return; } } - let fd = self.writer.get().get_fd(); - if fd == Fd::INVALID { - return; - } let already = sys::get_fcntl_flags(fd) .map(|f| f as i32 & sys::O::NONBLOCK != 0) .unwrap_or(false); @@ -2221,6 +2222,20 @@ unsafe fn __bun_stdio_sink_deinit(ptr: *mut ()) { // keep-alive ref (`to_result` → `must_be_kept_alive_until_eof`) is // still held; drop it with RareData's. FileSink::clear_keep_alive_ref(this); + // User code that closes fds by number behind our back (and anything + // that then reuses and closes that number) can leave our dup already + // closed; tearing down must not trip close()'s use-after-close check. + #[cfg(not(windows))] + { + let fd = (*this).writer.get().get_fd(); + if fd != Fd::INVALID && sys::get_fcntl_flags(fd).is_err() { + (*this).writer.with_mut(|w| { + w.handle + .close_impl(None, None::, false) + }); + (*this).fd.set(Fd::INVALID); + } + } FileSink::deref(this); } } diff --git a/src/sys/lib.rs b/src/sys/lib.rs index 9ca369ff78cd..b52a2dd6b5aa 100644 --- a/src/sys/lib.rs +++ b/src/sys/lib.rs @@ -7459,6 +7459,28 @@ pub fn make_stdio_blocking(fd: Fd) { } } +/// Whether `fd` is an anonymous pipe (lives on pipefs) rather than a named +/// FIFO opened from a real filesystem. Only the former get `FMODE_NOWAIT` +/// (torvalds/linux@afed6271f5b0), so only they honour `RWF_NOWAIT`; a FIFO +/// answers `EOPNOTSUPP`, which would also switch `RWFFlagSupport` off for the +/// whole process. +#[cfg(any(target_os = "linux", target_os = "android"))] +pub fn is_on_pipefs(fd: Fd) -> bool { + const PIPEFS_MAGIC: u32 = 0x5049_5045; + // SAFETY: all-zero is a valid `statfs`; fstatfs writes it fully on success. + let mut st: libc::statfs = unsafe { core::mem::zeroed() }; + loop { + // SAFETY: `fd` is a plain int; `st` is a live out-param. + let rc = unsafe { libc::fstatfs(fd.native(), &raw mut st) }; + if rc == 0 { + return st.f_type as u32 == PIPEFS_MAGIC; + } + if last_errno() != libc::EINTR { + return false; + } + } +} + /// See [`STDIO_MADE_BLOCKING`]. #[cfg(unix)] #[inline] From 70e70ac25466ad880116fb0494678c73894e4b92 Mon Sep 17 00:00:00 2001 From: Dylan Conway Date: Fri, 7 Aug 2026 05:51:12 -0700 Subject: [PATCH 08/26] writeToObservedStream: same removeListener guard as Console.write --- src/js/builtins/ConsoleObject.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/js/builtins/ConsoleObject.ts b/src/js/builtins/ConsoleObject.ts index ead5567fc220..25460cbf8243 100644 --- a/src/js/builtins/ConsoleObject.ts +++ b/src/js/builtins/ConsoleObject.ts @@ -185,11 +185,16 @@ export function writeToObservedStream(stream, chunk: string) { // There may be an error occurring synchronously (e.g. for files or TTYs // on POSIX systems) or asynchronously (e.g. pipes on POSIX systems), so // handle both situations. - const isEmitter = typeof stream?.listenerCount === "function" && typeof stream?.once === "function"; + const isEmitter = + typeof stream?.listenerCount === "function" && + typeof stream?.once === "function" && + typeof stream?.removeListener === "function"; + let guarded = false; try { // Add and later remove a noop error handler to catch synchronous errors. if (isEmitter && stream.listenerCount("error") === 0) { stream.once("error", noop); + guarded = true; } stream.write(chunk, err => { // Errors that were not already emitted (async _write callback) surface @@ -207,7 +212,7 @@ export function writeToObservedStream(stream, chunk: string) { if (e?.name === "RangeError" && e?.message === "Maximum call stack size exceeded.") throw e; // Sorry, there's no proper way to pass along the error here. } finally { - if (isEmitter) stream.removeListener("error", noop); + if (guarded) stream.removeListener("error", noop); } } From 7bd8448eec0126ca992d1d1ddd486181f4e7183f Mon Sep 17 00:00:00 2001 From: Dylan Conway Date: Fri, 7 Aug 2026 06:28:42 -0700 Subject: [PATCH 09/26] review: latch autoflush errors on stdio sinks; downgrade sinks after a pre-execve stdio restore; primordial indexOf - on_auto_flush's error arm was the one terminal-error site not routed through the stdio latch, so an EPIPE hit while autoflushing a coalesced Bun.stdout.writer().write() closed the shared sink silently - bun_restore_stdio_nonblock records the fds it puts back to blocking (Bun__stdioMadeBlocking), so a process that survives a failed execve stops expecting EAGAIN on them - diagnostics_channel: ArrayPrototypeIndexOf.$call like the rest of the file --- src/js/node/diagnostics_channel.ts | 2 +- src/jsc/bindings/c-bindings.cpp | 6 ++++++ src/runtime/webcore/FileSink.rs | 24 ++++++++++++++++++++---- src/sys/lib.rs | 12 ++++++++++++ 4 files changed, 39 insertions(+), 5 deletions(-) diff --git a/src/js/node/diagnostics_channel.ts b/src/js/node/diagnostics_channel.ts index 6b08d799c723..9cb41ccc4007 100644 --- a/src/js/node/diagnostics_channel.ts +++ b/src/js/node/diagnostics_channel.ts @@ -88,7 +88,7 @@ function publishToConsoleChannel(index: number, args: unknown[]) { channels.get(kConsoleChannelNames[index])?.publish(args); } function updateConsoleChannel(channel, active: boolean) { - const index = kConsoleChannelNames.indexOf(channel.name); + const index = ArrayPrototypeIndexOf.$call(kConsoleChannelNames, channel.name); if (index === -1) return; const bit = 1 << index; const next = active ? consoleChannelMask | bit : consoleChannelMask & ~bit; diff --git a/src/jsc/bindings/c-bindings.cpp b/src/jsc/bindings/c-bindings.cpp index 0021ec1ab288..2559ee91b6cf 100644 --- a/src/jsc/bindings/c-bindings.cpp +++ b/src/jsc/bindings/c-bindings.cpp @@ -508,6 +508,8 @@ static struct { ino_t ino; } bun_stdio_startup_state[3] = { { -1, 0, 0 }, { -1, 0, 0 }, { -1, 0, 0 } }; +extern "C" void Bun__stdioMadeBlocking(int fd); + static void bun_restore_stdio_nonblock() { for (int fd = 0; fd < 3; fd++) { @@ -534,6 +536,10 @@ static void bun_restore_stdio_nonblock() do err = fcntl(fd, F_SETFL, flags); while (err == -1 && errno == EINTR); + // If we keep running after this (a failed execve), the stdio sink + // that had set O_NONBLOCK must stop expecting EAGAIN. + if (err == 0 && !(flags & O_NONBLOCK)) + Bun__stdioMadeBlocking(fd); } } } diff --git a/src/runtime/webcore/FileSink.rs b/src/runtime/webcore/FileSink.rs index 2c8f12c61155..42b160a6d75b 100644 --- a/src/runtime/webcore/FileSink.rs +++ b/src/runtime/webcore/FileSink.rs @@ -1254,14 +1254,30 @@ impl FileSink { // `Owned(consumed)` result `to_result` seeded and // `run_pending_later()` alone would resolve it as if every // buffered byte had reached the reader. Latch the error and - // move the sink to its terminal state (mirrors `end_from_js`). - (*this).done.set(true); + // move the sink to its terminal state (mirrors `end_from_js`); + // a stdio sink stays "open" and re-fails per call instead. + let err = if (*this).is_stdio() { + (*this).stdio_latch_error(err) + } else { + (*this).done.set(true); + err + }; if (*this).pending.get().state == streams::PendingState::Pending { (*this) .pending - .with_mut(|p| p.result = streams::Writable::Err(err)); + .with_mut(|p| p.result = streams::Writable::Err(err.clone())); + } + #[cfg(not(windows))] + if (*this).is_stdio() { + (*this).writer.with_mut(|w| w.fail(err)); + } else { + (*this).writer.with_mut(|w| w.end()); + } + #[cfg(windows)] + { + let _ = err; + (*this).writer.with_mut(|w| w.end()); } - (*this).writer.with_mut(|w| w.end()); (*this).run_pending_later(); (*this).auto_flusher.with_mut(|a| a.registered.set(false)); return false; diff --git a/src/sys/lib.rs b/src/sys/lib.rs index b52a2dd6b5aa..b9993772413a 100644 --- a/src/sys/lib.rs +++ b/src/sys/lib.rs @@ -7481,6 +7481,18 @@ pub fn is_on_pipefs(fd: Fd) -> bool { } } +/// `c-bindings.cpp` `bun_restore_stdio_nonblock`: it just put `fd` back to +/// blocking (exit / signal / pre-execve restore); record it like +/// [`make_stdio_blocking`] so a process that survives (failed execve) has its +/// sinks downgrade. +#[cfg(unix)] +#[unsafe(no_mangle)] +pub extern "C" fn Bun__stdioMadeBlocking(fd: core::ffi::c_int) { + if let n @ 0..=2 = fd { + STDIO_MADE_BLOCKING.fetch_or(1u8 << n, core::sync::atomic::Ordering::AcqRel); + } +} + /// See [`STDIO_MADE_BLOCKING`]. #[cfg(unix)] #[inline] From b7a141f226de678244c843fa60019bf19486809e Mon Sep 17 00:00:00 2001 From: Dylan Conway Date: Fri, 7 Aug 2026 10:27:23 -0700 Subject: [PATCH 10/26] console: drop SCRATCH_KEEP; the scratch buffer shrinks back to SPILL_AT, the one bound the native path already has --- src/jsc/ConsoleObject.rs | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/jsc/ConsoleObject.rs b/src/jsc/ConsoleObject.rs index 5f5b9a8a4db7..d0d5c911877a 100644 --- a/src/jsc/ConsoleObject.rs +++ b/src/jsc/ConsoleObject.rs @@ -124,10 +124,6 @@ impl ConsoleObject { } } - /// Largest scratch capacity kept between calls; a one-off huge message - /// shouldn't pin its buffer for the life of the VM. - const SCRATCH_KEEP: usize = 64 * 1024; - #[inline] fn take_scratch(this: *mut ConsoleObject) -> Vec { // SAFETY: `this` is the live per-VM console (see [`vm_console`]); @@ -138,8 +134,11 @@ impl ConsoleObject { #[inline] fn put_scratch(this: *mut ConsoleObject, mut buf: Vec) { buf.clear(); - if buf.capacity() > Self::SCRATCH_KEEP { - buf.shrink_to(Self::SCRATCH_KEEP); + // The native path never holds more than `SPILL_AT`; only a message that + // had to be handed to JS whole (or one oversized chunk) grows past it, + // and that one shouldn't pin its buffer for the life of the VM. + if buf.capacity() > SPILL_AT { + buf.shrink_to(SPILL_AT); } // SAFETY: as above. If a re-entrant call left its (smaller) buffer // here, keep whichever is larger. @@ -299,8 +298,10 @@ pub struct ConsoleWriter<'a> { spill: Option<(*mut VirtualMachine, bun_sys::Fd)>, } -/// Big enough that ordinary messages are one `write(2)`, small enough that a -/// runaway one doesn't matter. +/// How much of one message to accumulate before writing it out on the native +/// path (and so the most scratch capacity kept between calls): the default +/// pipe capacity on Linux and macOS, so each spill is at most one pipe-full and +/// anything shorter is still a single `write(2)`. const SPILL_AT: usize = 64 * 1024; impl bun_io::Write for ConsoleWriter<'_> { From 0e78647c09ffae3b63c2af84d68f045070a5dd38 Mon Sep 17 00:00:00 2001 From: Dylan Conway Date: Fri, 7 Aug 2026 15:54:33 -0700 Subject: [PATCH 11/26] process.stdout/stderr.write keep accepting a bare ArrayBuffer / SharedArrayBuffer Bun's old own writeFast took these; Node's Writable rejects them. Keep the Bun behaviour for the two stdio streams (accepted in Writable's would-throw branch when the stream is stdio), without an extra prototype or own property so nothing about process.stdout's shape changes. Reverts the two test workarounds that had switched to Uint8Array. --- src/js/internal/streams/writable.ts | 5 +++ test/js/node/process/process-stdio.test.ts | 31 +++++++++++++++++++ test/js/web/workers/structured-clone.test.ts | 6 ++-- .../workers/structuredClone-classes.test.ts | 3 +- 4 files changed, 39 insertions(+), 6 deletions(-) diff --git a/src/js/internal/streams/writable.ts b/src/js/internal/streams/writable.ts index d0a1326dd85e..13d2a3dfb0bc 100644 --- a/src/js/internal/streams/writable.ts +++ b/src/js/internal/streams/writable.ts @@ -460,6 +460,11 @@ function _write(stream, chunk, encoding, cb?) { } else if (Stream._isArrayBufferView(chunk)) { chunk = Stream._uint8ArrayToBuffer(chunk); encoding = "buffer"; + } else if (stream._isStdio === true && (chunk instanceof ArrayBuffer || chunk instanceof SharedArrayBuffer)) { + // process.stdout / process.stderr have always taken a bare (Shared)ArrayBuffer + // in Bun; keep accepting it there (Node rejects it, so nothing relies on the throw). + chunk = new Uint8Array(chunk); + encoding = "buffer"; } else { throw $ERR_INVALID_ARG_TYPE("chunk", ["string", "Buffer", "TypedArray", "DataView"], chunk); } diff --git a/test/js/node/process/process-stdio.test.ts b/test/js/node/process/process-stdio.test.ts index 03f307acf691..aec40af3c9dc 100644 --- a/test/js/node/process/process-stdio.test.ts +++ b/test/js/node/process/process-stdio.test.ts @@ -539,6 +539,37 @@ const nonblock = fd => fd_is_nonblock(fd) !== 0; expect(exitCode).toBe(0); }); + test("process.stdout/stderr.write() still accept a bare ArrayBuffer / SharedArrayBuffer (Bun has always allowed it), with no extra prototype or own write", async () => { + await using proc = spawn({ + cmd: [ + bunExe(), + "-e", + ` + const ab = new TextEncoder().encode("array ").buffer; + const sab = new SharedArrayBuffer(7); + new Uint8Array(sab).set(new TextEncoder().encode("shared\\n")); + process.stdout.write(ab); + process.stdout.write(sab); + process.stderr.write(ab); + const tty = require("node:tty"), fs = require("node:fs"), { Writable } = require("node:stream"); + const proto = Object.getPrototypeOf(process.stdout); + process.stderr.write(String([ + Object.prototype.hasOwnProperty.call(process.stdout, "write"), + proto === tty.WriteStream.prototype || proto === fs.WriteStream.prototype, + process.stdout.write === Writable.prototype.write, + ])); + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout).toBe("array shared\n"); + expect(stderr).toBe("array false,true,true"); + expect(exitCode).toBe(0); + }); + test("write after end(): the sync write is ERR_STREAM_WRITE_AFTER_END; after finish the stream is undestroyed and writable again (Node file-stdio semantics)", async () => { // https://github.com/nodejs/node/blob/v24.0.0/lib/internal/bootstrap/switches/is_main_thread.js#L114-L128 // (dummyDestroy -> _undestroy). Over a pipe Node additionally shuts the diff --git a/test/js/web/workers/structured-clone.test.ts b/test/js/web/workers/structured-clone.test.ts index ba86218574c4..d78df66bcd49 100644 --- a/test/js/web/workers/structured-clone.test.ts +++ b/test/js/web/workers/structured-clone.test.ts @@ -47,8 +47,7 @@ function jscSerializeRoundtripCrossProcessCold(original: any) { import {deserialize, serialize} from "bun:jsc"; const serialized = deserialize(await Bun.stdin.bytes()); const cloned = serialize(serialized); - // serialize() hands back a SharedArrayBuffer, which Writable rejects (as node does). - process.stdout.write(new Uint8Array(cloned)); + process.stdout.write(cloned); `, ], env: bunEnv, @@ -76,8 +75,7 @@ const crossProcessChildScript = ` chunks = [buf]; break; } - // serialize() hands back a SharedArrayBuffer, which Writable rejects (as node does). - const cloned = new Uint8Array(serialize(deserialize(buf.subarray(4, 4 + len)))); + const cloned = serialize(deserialize(buf.subarray(4, 4 + len))); const header = Buffer.alloc(4); header.writeUInt32LE(cloned.byteLength, 0); process.stdout.write(header); diff --git a/test/js/web/workers/structuredClone-classes.test.ts b/test/js/web/workers/structuredClone-classes.test.ts index ceb244244bf4..eff435ddf869 100644 --- a/test/js/web/workers/structuredClone-classes.test.ts +++ b/test/js/web/workers/structuredClone-classes.test.ts @@ -81,8 +81,7 @@ describe("serialize & deserialize", () => { import {deserialize, serialize} from "bun:jsc"; const serialized = deserialize(await Bun.stdin.bytes()); const cloned = serialize(serialized); - // serialize() hands back a SharedArrayBuffer, which Writable rejects (as node does). - process.stdout.write(new Uint8Array(cloned)); + process.stdout.write(cloned); `, ], env: bunEnv, From 7eaf6ab61ab856bb6073821c7ba066f08de30055 Mon Sep 17 00:00:00 2001 From: Dylan Conway Date: Fri, 7 Aug 2026 18:47:08 -0700 Subject: [PATCH 12/26] Address risk review: writer coalescing, async Bun.write, killable exit drain, lock scope, colours MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bun.stdout.writer() / Bun.file(1|2).writer() coalesce small writes again when stdout is a file or /dev/null (only a TTY writes through), as before this branch; the console and process.stdout paths never coalesced anyway - Bun.write(Bun.stdout, x) no longer drains synchronously: it switches the sink to its async pipe mode like process.stdout does and, when the bytes had to queue, resolves (with its own count) once the queue drains - exit-time drain: JS can't run any more, so hand every catchable signal back to SIG_DFL first (Node's ResetSignalHandlers) — waiting on a reader that never reads stays killable with ^C / SIGTERM - the Output pre-write hook does nothing while panicking (crash reporter) - StdioLock is taken per write / spill inside write_all_sync, never across formatting or any JS, and the JS delivery path drains only its own fd: no thread holds one stdio lock while waiting on the other - poll dispatch re-checks a stdio sink's fd mode before the writable callback flushes (spawn may have made the description blocking) - Linux: if RWF_NOWAIT turns out to be refused at runtime, fall back to O_NONBLOCK instead of blocking full-chunk writes - colours for a foreign console stream (worker port, console._stdout = x) follow FORCE_COLOR/NO_COLOR, else that stream's isTTY, as Node does - non-stdio FileSink-backed streams (child.stdin, tty.WriteStream(fd)) keep end-of-tick coalescing and keep accepting a bare (Shared)ArrayBuffer - on_write's stricter "wait for the coalesced tail" rule is stdio-only - process.stdout's cork/uncork/end/destroy wrappers are named and non-enumerable; Windows write_all_sync reports a short write --- src/bun_core/output.rs | 19 +++ src/io/PipeWriter.rs | 6 + src/js/builtins/ProcessObjectInternals.ts | 43 +++--- src/js/internal/fs/streams.ts | 9 +- src/js/internal/streams/writable.ts | 10 +- src/jsc/ConsoleObject.rs | 62 +++++--- src/jsc/VirtualMachine.rs | 23 ++- src/jsc/bindings/BunProcess.cpp | 10 ++ src/jsc/bindings/BunProcess.h | 1 + src/jsc/bindings/c-bindings.cpp | 19 +++ src/runtime/dispatch.rs | 10 +- src/runtime/webcore/Blob.rs | 41 +++--- src/runtime/webcore/FileSink.rs | 168 ++++++++++++++++++---- 13 files changed, 324 insertions(+), 97 deletions(-) diff --git a/src/bun_core/output.rs b/src/bun_core/output.rs index c058a6c995ce..6b44e266cf6d 100644 --- a/src/bun_core/output.rs +++ b/src/bun_core/output.rs @@ -454,6 +454,19 @@ impl Source { Self::get_force_color_depth().unwrap_or(ColorDepth::None) != ColorDepth::None } + /// What `FORCE_COLOR` / `NO_COLOR` alone say (the part of the colour + /// decision that doesn't depend on which stream is being written to), for + /// callers colouring output bound for a stream other than fd 1/2. + pub fn env_color_override() -> Option { + if Self::get_force_color_depth().is_some() { + Some(Self::is_force_color()) + } else if Self::is_no_color() { + Some(false) + } else { + None + } + } + pub(crate) fn is_color_terminal() -> bool { #[cfg(windows)] { @@ -2318,6 +2331,12 @@ pub fn print_errorln(args: impl core::fmt::Display) { print_to(Destination::Stderr, format_args!("{args}\n")); } +/// See [`Source::env_color_override`]. +#[inline] +pub fn env_color_override() -> Option { + Source::env_color_override() +} + /// `Output.enable_ansi_colors_stdout` — safe relaxed-load wrapper over the /// startup-initialized atomic. #[inline] diff --git a/src/io/PipeWriter.rs b/src/io/PipeWriter.rs index 0cf0c6e2f1de..8f041e4bcbab 100644 --- a/src/io/PipeWriter.rs +++ b/src/io/PipeWriter.rs @@ -678,6 +678,12 @@ impl PosixStreamingWriter { self.parent } + /// [`parent`](Self::parent), for the poll dispatcher. + #[inline] + pub fn parent_ptr(&self) -> *mut Parent { + self.parent() + } + /// Single nonnull-asref dispatch for the set-once `parent` backref. /// /// Type invariant (encapsulated `unsafe`): `self.parent` is populated by diff --git a/src/js/builtins/ProcessObjectInternals.ts b/src/js/builtins/ProcessObjectInternals.ts index 9b8c36e00316..6e4597e1c999 100644 --- a/src/js/builtins/ProcessObjectInternals.ts +++ b/src/js/builtins/ProcessObjectInternals.ts @@ -138,25 +138,30 @@ export function getStdioWriteStream( pendingWrite = pending; updateObserved(this); }; - const { cork, uncork, end, destroy } = stream; - stream.cork = function () { - cork.$call(this); - updateObserved(this); - }; - stream.uncork = function () { - uncork.$call(this); - updateObserved(this); - }; - stream.end = function (...args) { - const ret = end.$apply(this, args); - updateObserved(this); - return ret; - }; - stream.destroy = stream.destroySoon = function (...args) { - const ret = destroy.$apply(this, args); - updateObserved(this); - return ret; - }; + const { cork: baseCork, uncork: baseUncork, end: baseEnd, destroy: baseDestroy } = stream; + // Same shape a user sees on any Writable: named, non-enumerable methods. + const hidden = (value: Function) => ({ value, writable: true, configurable: true, enumerable: false }); + Object.defineProperties(stream, { + cork: hidden(function cork(this: any) { + baseCork.$call(this); + updateObserved(this); + }), + uncork: hidden(function uncork(this: any) { + baseUncork.$call(this); + updateObserved(this); + }), + end: hidden(function end(this: any, ...args) { + const ret = baseEnd.$apply(this, args); + updateObserved(this); + return ret; + }), + destroy: hidden(function destroy(this: any, ...args) { + const ret = baseDestroy.$apply(this, args); + updateObserved(this); + return ret; + }), + }); + stream.destroySoon = stream.destroy; stream._isStdio = true; stream.fd = fd; diff --git a/src/js/internal/fs/streams.ts b/src/js/internal/fs/streams.ts index 693ee6cfe84f..54c12fbfce5a 100644 --- a/src/js/internal/fs/streams.ts +++ b/src/js/internal/fs/streams.ts @@ -601,9 +601,10 @@ writeStreamPrototype._write = _write; // hears about a write going async and settling (process.stdout uses it to // keep console.* from overtaking chunks queued behind that write). const kOnPendingWrite = Symbol("kOnPendingWrite"); -// `FileSink.prototype.write` coalesces small chunks until end of tick (right -// for a batching `Bun.file(fd).writer()`); a stream's `_write` wants the -// syscall now, with the same return contract. +// `FileSink.prototype.write` coalesces small chunks until end of tick — right +// for a batching `Bun.file(fd).writer()` and kept for `child.stdin` / +// `tty.WriteStream(fd)` (chatty pipe protocols); process.stdout/stderr's +// `_write` wants the syscall now like Node's, with the same return contract. const fileSinkWriteNow = $newRustFunction("runtime/webcore/FileSink.rs", "writeNow", 2); function underscoreWriteFast(this: FSStream, chunk: any, encoding: any, cb: any) { let fileSink = this[kWriteStreamFastPath]; @@ -627,7 +628,7 @@ function underscoreWriteFast(this: FSStream, chunk: any, encoding: any, cb: any) chunk = Buffer.from(chunk, encoding); } - maybePromise = fileSinkWriteNow(fileSink, chunk); + maybePromise = this._isStdio === true ? fileSinkWriteNow(fileSink, chunk) : fileSink.write(chunk); } catch (e) { cb(e); return; diff --git a/src/js/internal/streams/writable.ts b/src/js/internal/streams/writable.ts index 13d2a3dfb0bc..c34c7cec0b4b 100644 --- a/src/js/internal/streams/writable.ts +++ b/src/js/internal/streams/writable.ts @@ -460,9 +460,13 @@ function _write(stream, chunk, encoding, cb?) { } else if (Stream._isArrayBufferView(chunk)) { chunk = Stream._uint8ArrayToBuffer(chunk); encoding = "buffer"; - } else if (stream._isStdio === true && (chunk instanceof ArrayBuffer || chunk instanceof SharedArrayBuffer)) { - // process.stdout / process.stderr have always taken a bare (Shared)ArrayBuffer - // in Bun; keep accepting it there (Node rejects it, so nothing relies on the throw). + } else if ( + (chunk instanceof ArrayBuffer || chunk instanceof SharedArrayBuffer) && + (stream._isStdio === true || stream[require("internal/fs/streams").kWriteStreamFastPath]) + ) { + // Bun's FileSink-backed streams (process.stdout/stderr, child.stdin, + // tty.WriteStream(fd)) have always taken a bare (Shared)ArrayBuffer; keep + // accepting it there (Node rejects it, so nothing relies on the throw). chunk = new Uint8Array(chunk); encoding = "buffer"; } else { diff --git a/src/jsc/ConsoleObject.rs b/src/jsc/ConsoleObject.rs index d0d5c911877a..ad45ea0dee81 100644 --- a/src/jsc/ConsoleObject.rs +++ b/src/jsc/ConsoleObject.rs @@ -163,9 +163,9 @@ unsafe extern "Rust" { fd: bun_sys::Fd, bytes: &[u8], ) -> Result<(), bun_sys::Error>; - /// `bun_runtime::webcore::file_sink::__bun_stdio_sink_drain` — flush both - /// stdio sinks' queues now (no-op if they don't exist). - fn __bun_stdio_sink_drain(vm: *mut VirtualMachine); + /// `bun_runtime::webcore::file_sink::__bun_stdio_sink_drain_fd` — flush + /// this fd's stdio sink queue now (no-op if it doesn't exist). + fn __bun_stdio_sink_drain_fd(vm: *mut VirtualMachine, fd: bun_sys::Fd); } unsafe extern "C" { @@ -180,6 +180,9 @@ unsafe extern "C" { /// (custom binding or Bun's materialised stream), else empty. For /// `console.clear()`'s `isTTY` check. Same throw contract as /// `Bun__Process__consoleStream`. + /// `BunProcess.cpp` — the console's stream for `fd` is a foreign object + /// (not Bun's own stdio stream). Never runs user code. + safe fn Bun__Process__consoleStreamIsCustom(global: &JSGlobalObject, fd: i32) -> bool; fn Bun__Process__consoleStreamObject( global: &JSGlobalObject, fd: i32, @@ -249,6 +252,29 @@ fn console_target(global: &JSGlobalObject, stream: ConsoleStream) -> JsResult JsResult { + if target.is_empty() || !Bun__Process__consoleStreamIsCustom(global, stream.number()) { + return Ok(stream.colors()); + } + if let Some(forced) = Output::env_color_override() { + return Ok(forced); + } + if !target.is_object() { + return Ok(false); + } + Ok(target + .get(global, b"isTTY")? + .is_some_and(|v| v.to_boolean())) +} + /// Deliver one whole formatted message to `target` (see [`console_target`]). /// Errors reaching the fd (EPIPE, ...) are the sink's to surface on /// `process.stdout`/`stderr`; the console itself never throws for them @@ -264,15 +290,14 @@ fn deliver_to( } let vm: *mut VirtualMachine = global.bun_vm().as_mut(); if target.is_empty() { - // Callers hold `StdioLock` for `stream` on this path (`emit`/`deliver`). // SAFETY: `vm` is the live per-thread VM that owns `global`. let _ = unsafe { __bun_stdio_sink_write(vm, stream.fd(), bytes) }; return Ok(()); } // Switching to JS delivery: whatever the native side still has queued for - // either fd must land first or it would come out after this message. + // this fd must land first or it would come out after this message. // SAFETY: as above. - unsafe { __bun_stdio_sink_drain(vm) }; + unsafe { __bun_stdio_sink_drain_fd(vm, stream.fd()) }; let chunk = bun_core::String::borrow_utf8(bytes).to_js(global)?; crate::from_js_host_call_generic(global, || { // SAFETY: plain FFI; both values are live on this stack. @@ -282,9 +307,6 @@ fn deliver_to( pub fn deliver(global: &JSGlobalObject, stream: ConsoleStream, bytes: &[u8]) -> JsResult<()> { let target = console_target(global, stream)?; - let _lock = target - .is_empty() - .then(|| bun_io::StdioLock::acquire(stream.fd())); deliver_to(global, stream, target, bytes) } @@ -327,13 +349,13 @@ impl bun_io::Write for ConsoleWriter<'_> { pub fn emit( global: &JSGlobalObject, stream: ConsoleStream, - f: impl FnOnce(&mut ConsoleWriter<'_>) -> JsResult<()>, + f: impl FnOnce(&mut ConsoleWriter<'_>, bool) -> JsResult<()>, ) -> JsResult<()> { let target = console_target(global, stream)?; let native = target.is_empty(); - // Held across formatting on the native path so spilled chunks of one - // message can't interleave with another thread's console output. - let _lock = native.then(|| bun_io::StdioLock::acquire(stream.fd())); + // No lock here: formatting runs user JS (getters, toJSON, inspect.custom) + // that may log to the *other* stream or exit; each write / spill takes the + // per-fd lock for itself (`FileSink::write_all_sync`). let console = vm_console(global); let mut buf = ConsoleObject::take_scratch(console); @@ -346,7 +368,7 @@ pub fn emit( ) }), }; - let result = match f(&mut writer) { + let result = match f(&mut writer, console_colors(global, stream, target)?) { Ok(()) => deliver_to(global, stream, target, &buf), Err(err) => Err(err), }; @@ -568,8 +590,6 @@ fn message_with_type_and_level_( // SAFETY: see [`vm_console`] — single-JS-thread; no other `&mut` is live. let default_indent = unsafe { (*console).default_indent }; - let enable_colors = stream.colors(); - // LAYERING: `Jest::runner()` lives in `bun_runtime::test_runner` (forward // dep on the high tier). Dispatch through `RuntimeHooks` instead — the // high-tier hook checks `Jest.runner` and calls `onBeforePrint()`; no-op @@ -581,7 +601,7 @@ fn message_with_type_and_level_( // SAFETY: caller (JSC C++) guarantees `vals` points to `len` JSValues. let vals_slice = unsafe { bun_core::ffi::slice(vals, len) }; - emit(global, stream, |writer| { + emit(global, stream, |writer, enable_colors| { if message_type == MessageType::Assert { // Node prefixes the first argument and forwards to `warn`, so the // prefix takes part in the same `%s` substitution pass: @@ -5936,8 +5956,8 @@ pub(crate) extern "C" fn Bun__ConsoleObject__count( current }; - let _ = emit(global_this, ConsoleStream::Stdout, |writer| { - if ConsoleStream::Stdout.colors() { + let _ = emit(global_this, ConsoleStream::Stdout, |writer, colors| { + if colors { let _ = writeln!( writer, "{}{}{}: {}{}{}", @@ -6128,7 +6148,7 @@ fn time_log_impl( }; let ms = timer.read() as f64 / bun_core::time::NS_PER_MS as f64; - let _ = emit(global, ConsoleStream::Stdout, |writer| { + let _ = emit(global, ConsoleStream::Stdout, |writer, colors| { let _ = writer.write_all(label); let _ = writer.write_all(b": "); write_elapsed(writer, ms); @@ -6145,7 +6165,7 @@ fn time_log_impl( for &arg in args { let tag = formatter::Tag::get(arg, global)?; let _ = writer.write_all(b" "); - if ConsoleStream::Stdout.colors() { + if colors { fmt.format::(tag, writer, arg, global)?; } else { fmt.format::(tag, writer, arg, global)?; diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 597c19a2ed4f..5d4e97798134 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -384,6 +384,8 @@ unsafe extern "C" { safe fn Bun__WebView__closeAllForTermination(); safe fn Zig__GlobalObject__destructOnExit(global: &JSGlobalObject); safe fn Bun__JSCTaskScheduler__markShuttingDown(global: &JSGlobalObject); + /// `c-bindings.cpp`: every catchable signal back to `SIG_DFL` (no-op on Windows). + safe fn bun_reset_signal_handlers_for_exit(); } pub const HOT_RELOAD_HOT: u8 = 1; @@ -1512,6 +1514,23 @@ impl VirtualMachine { } } + /// The exit-time drain. JS will not run again, so its signal handlers + /// can't either: hand SIGINT/SIGTERM/... back to their defaults first (as + /// Node's `ResetSignalHandlers` does at teardown) so that waiting on a + /// reader that never reads stays killable the ordinary way. + fn drain_stdio_for_exit(&mut self) { + if self + .rare_data + .as_ref() + .is_some_and(|r| r.stdio_sinks.iter().any(Option::is_some)) + { + if self.is_main_thread() { + bun_reset_signal_handlers_for_exit(); + } + self.drain_stdio(); + } + } + pub fn on_exit(&mut self) { // Write CPU profile if profiling was enabled - do this FIRST before any // shutdown begins. Grab the config and null it out to make this @@ -1545,7 +1564,7 @@ impl VirtualMachine { // Whatever process.stdout/stderr still had queued behind a slow reader // is written now (blocking), like console.log always has been: an exit // must not silently truncate output that was accepted before it. - self.drain_stdio(); + self.drain_stdio_for_exit(); self.is_shutting_down = true; @@ -1580,7 +1599,7 @@ impl VirtualMachine { debug_assert!(self.is_shutting_down()); // Paths that get here without `on_exit()` (early CLI/test-runner // exits) still owe the fd whatever process.stdout/stderr queued. - self.drain_stdio(); + self.drain_stdio_for_exit(); // FIXME: we should be doing this, but we're not, but unfortunately // doing it causes like 50+ tests to break // self.event_loop().tick(); diff --git a/src/jsc/bindings/BunProcess.cpp b/src/jsc/bindings/BunProcess.cpp index bc9501032525..6fa00859e980 100644 --- a/src/jsc/bindings/BunProcess.cpp +++ b/src/jsc/bindings/BunProcess.cpp @@ -3057,6 +3057,16 @@ JSValue Process::consoleStreamForGetter(JSC::JSGlobalObject* globalObject, int f // Empty: use the native sink. Otherwise the stream to `write()` to. `*threw` // is set (and empty returned) if user code made `process.stdout` a throwing // getter and this was the console's first use. +// Whether the console's stream for `fd` is a *foreign* object (worker port +// stream, `console._stdout = x`, a replaced `process.stdout`) rather than Bun's +// own stdio stream in an observed state. Never runs user code. +extern "C" bool Bun__Process__consoleStreamIsCustom(Zig::GlobalObject* globalObject, int32_t fd) +{ + if (!globalObject->hasProcessObject()) [[unlikely]] + return false; + return globalObject->processObject()->consoleStreamIsCustom(fd); +} + extern "C" JSC::EncodedJSValue Bun__Process__consoleStream(Zig::GlobalObject* globalObject, int32_t fd, bool* threw) { if (!globalObject->hasProcessObject()) [[unlikely]] diff --git a/src/jsc/bindings/BunProcess.h b/src/jsc/bindings/BunProcess.h index cb5f63ea9bca..e90565bf3409 100644 --- a/src/jsc/bindings/BunProcess.h +++ b/src/jsc/bindings/BunProcess.h @@ -80,6 +80,7 @@ class Process : public WebCore::JSEventEmitter { // once resolved; the first (Unresolved) call may run — and throw from — a // user getter installed on `process.stdout`/`stderr`. JSValue consoleStream(JSC::JSGlobalObject*, int fd); + bool consoleStreamIsCustom(int fd) { return m_consoleStreamState[fd - 1] == ConsoleStreamState::Custom; } bool consoleStreamIsResolved(int fd) const { return m_consoleStreamState[fd - 1] != ConsoleStreamState::Unresolved; } // What `console._stdout` / `console._stderr` evaluate to (may materialise // process.stdout; can throw). diff --git a/src/jsc/bindings/c-bindings.cpp b/src/jsc/bindings/c-bindings.cpp index f9541f12b9c1..1d05d026ef11 100644 --- a/src/jsc/bindings/c-bindings.cpp +++ b/src/jsc/bindings/c-bindings.cpp @@ -586,6 +586,25 @@ static void bun_restore_stdio_nonblock() } #endif +// Node's ResetSignalHandlers(): at teardown, once JS can no longer run, put +// every catchable signal back to its default so a process blocked writing out +// the last of its stdio is still killable with ^C / SIGTERM. +extern "C" void bun_reset_signal_handlers_for_exit() +{ +#if !OS(WINDOWS) + struct sigaction act; + memset(&act, 0, sizeof(act)); + for (int nr = 1; nr < 32; nr++) { + if (nr == SIGKILL || nr == SIGSTOP) + continue; + // Writing to a closed pipe / an oversized file must keep failing with + // EPIPE / EFBIG rather than kill us mid-flush. + act.sa_handler = (nr == SIGPIPE || nr == SIGXFSZ) ? SIG_IGN : SIG_DFL; + sigaction(nr, &act, nullptr); + } +#endif +} + extern "C" void bun_restore_stdio() { diff --git a/src/runtime/dispatch.rs b/src/runtime/dispatch.rs index 654b15564441..9cd10aff07ec 100644 --- a/src/runtime/dispatch.rs +++ b/src/runtime/dispatch.rs @@ -816,7 +816,15 @@ pub(crate) unsafe fn __bun_run_file_poll(poll: *mut FilePoll, size_or_offset: i6 } } - poll_tag::FILE_SINK => poll_arm!(FileSinkPoll), + poll_tag::FILE_SINK => poll_arm!(FileSinkPoll, |h| { + // SAFETY: tag matched (see `poll_arm!`); `parent_ptr` is the owning + // FileSink. A stdio sink re-checks its fd mode before the writable + // callback flushes (spawn may have made the description blocking). + unsafe { + crate::webcore::FileSink::before_writable((*h).parent_ptr()); + (*h).on_poll(size_or_offset as isize, hup) + } + }), poll_tag::STATIC_PIPE_WRITER => poll_arm!(StaticPipeWriterPoll>), poll_tag::SHELL_STATIC_PIPE_WRITER => { poll_arm!(StaticPipeWriterPoll) diff --git a/src/runtime/webcore/Blob.rs b/src/runtime/webcore/Blob.rs index 28b246a348f5..13b4cc653920 100644 --- a/src/runtime/webcore/Blob.rs +++ b/src/runtime/webcore/Blob.rs @@ -5086,35 +5086,34 @@ pub(crate) fn write_file_internal( // SAFETY: bun_vm() is the live VM owning `global_this`. let vm = global_this.bun_vm().as_mut(); if let Some(sink) = webcore::file_sink::stdio_sink_for(vm, stdio_fd) { + // An async JS writer, like process.stdout: a pipe may queue + // and resolve later rather than stall the loop. // SAFETY: canonical live pointer held by RareData. + #[cfg(not(windows))] + unsafe { + (*sink).stdio_go_nonblocking() + }; + // SAFETY: as above. let wrote = unsafe { (*sink).write_js_value(global_this, data, true, true)? }; if let Some((result, accepted)) = wrote { - // `Bun.write` resolves once the bytes are written, with - // *this* call's byte count — not whenever (and with - // whatever total) the shared sink's queue drains. - let written = match result { - streams::Writable::Err(err) => Err(err), - other => { - if matches!(other, streams::Writable::Pending(_)) { - // Settled right here, not through the pending promise. - // SAFETY: as above. - unsafe { (*sink).uncredit_pending(accepted) }; - } - // SAFETY: as above. - unsafe { webcore::FileSink::drain_sync(sink) }.map(|()| accepted) - } - }; - return Ok(match written { - Ok(n) => JSPromise::resolved_promise_value( - global_this, - JSValue::js_number(n as f64), - ), - Err(err) => { + // Resolves with *this* call's byte count: right away if + // the fd took it, otherwise once the sink's queue has + // drained (the loop keeps running meanwhile). + return Ok(match result { + streams::Writable::Err(err) => { JSPromise::dangerously_create_rejected_promise_value_without_notifying_vm( global_this, err.to_js(global_this), ) } + // SAFETY: as above. + streams::Writable::Pending(_) => unsafe { + (*sink).add_stdio_waiter(global_this, accepted as f64) + }, + _ => JSPromise::resolved_promise_value( + global_this, + JSValue::js_number(accepted as f64), + ), }); } // A Blob / stream source takes the general path below; at diff --git a/src/runtime/webcore/FileSink.rs b/src/runtime/webcore/FileSink.rs index 42b160a6d75b..9a0a2ac39406 100644 --- a/src/runtime/webcore/FileSink.rs +++ b/src/runtime/webcore/FileSink.rs @@ -67,6 +67,14 @@ pub struct FileSink { /// how Node's stdio streams behave (each write() re-fails) and what lets /// `'error'` fire per call instead of the sink going quietly inert. pub(crate) stdio_error: JsCell>, + /// `Bun.write(Bun.stdout, x)` calls waiting for the queue to drain, each + /// with the byte count it is to resolve with (the shared `pending` slot's + /// count belongs to whoever awaits the JS wrapper's promise). + pub(crate) stdio_waiters: JsCell>, + /// This stdio sink is relying on `pwritev2(RWF_NOWAIT)` (Linux ≥ 6.4 + /// pipes) instead of `O_NONBLOCK`; see `refresh_stdio_mode`. + #[cfg_attr(not(any(target_os = "linux", target_os = "android")), allow(dead_code))] + pub(crate) stdio_rwf_nowait: Cell, pub(crate) auto_flusher: JsCell, pub(crate) run_pending_later: FlushPendingTask, @@ -316,10 +324,15 @@ impl FileSink { (*this).run_pending_later.has.set(false); let _entered = (*this).event_loop().entered(); + let failure = match (*this).pending.get().result { + streams::Writable::Err(ref err) => Some(err.clone()), + _ => (*this).stdio_error(), + }; // SAFETY(JsCell): `WritablePending::run` resolves a JSPromise which may // re-enter JS, but no other path holds a borrow of `self.pending` for // the duration (host-fns gate on `pending.state != Pending` first). (*this).pending.get_mut().run(); + FileSink::settle_stdio_waiters(this, failure.as_ref()); // Release the JS wrapper reference now that the pending operation is complete. // This was held to prevent GC from collecting the wrapper while the async @@ -367,18 +380,20 @@ impl FileSink { } } - // Bytes still queued (backed up, or a small write coalesced behind an - // earlier remainder): whoever is waiting on the pending promise keeps - // waiting until they are actually out, so `'drain'` can't fire early. - // (Windows reports per completed `uv_write`; its queue drains through - // further `on_write`s, see the TODO above.) - #[cfg(not(windows))] - if has_pending_data && status != WriteStatus::EndOfFile { + // Backed up: whoever is waiting on the pending promise keeps waiting. + // A stdio sink also waits out a small write coalesced behind an + // earlier remainder (its poll / autoflush is guaranteed to drain it), + // so process.stdout's `'drain'` can't fire early. + if has_pending_data + && (status == WriteStatus::Pending + || (cfg!(not(windows)) + && (*this).is_stdio() + && status != WriteStatus::EndOfFile)) + { return; } - #[cfg(windows)] - if status == WriteStatus::Pending && has_pending_data { - return; + if !has_pending_data { + FileSink::settle_stdio_waiters(this, None); } let was_pending = (*this).pending.get().state == streams::PendingState::Pending; @@ -606,6 +621,9 @@ impl FileSink { if self.stdio_error.get().is_none() { self.stdio_error.with_mut(|e| *e = Some(err.clone())); } + // SAFETY: `self` is the canonical RareData-held stdio sink; settling + // only rejects promises (reactions run later as microtasks). + unsafe { FileSink::settle_stdio_waiters(core::ptr::from_ref(self).cast_mut(), Some(&err)) }; err } @@ -651,6 +669,52 @@ impl FileSink { /// `stdio_js` makes a new one in the then-current global). pub fn release_stdio_js(&self) { self.stdio_js.with_mut(|s| s.deinit()); + self.stdio_waiters.with_mut(|w| w.clear()); + } + + /// `Bun.write(Bun.stdout|stderr, x)` whose bytes had to queue: a promise + /// resolved with `count` once the queue has drained (or rejected with the + /// error that stopped it). + pub fn add_stdio_waiter(&self, global: &JSGlobalObject, count: f64) -> JSValue { + let strong = bun_jsc::js_promise::Strong::init(global); + let value = strong.value(); + self.stdio_waiters.with_mut(|w| w.push((strong, count))); + value + } + + /// # Safety + /// `this` is the canonical live pointer; resolves promises (re-enters JS). + unsafe fn settle_stdio_waiters(this: *mut FileSink, failure: Option<&sys::Error>) { + // SAFETY: caller contract; the Vec is moved out before any JS runs. + let waiters = unsafe { (*this).stdio_waiters.replace(Vec::new()) }; + if waiters.is_empty() { + return; + } + // SAFETY: as above. + let Some(vm) = (unsafe { (*this).js_vm() }) else { + return; + }; + let global = vm.global(); + for (mut promise, count) in waiters { + let _ = match failure { + None => promise.resolve(global, JSValue::js_number(count)), + Some(err) => { + use bun_sys_jsc::ErrorJsc as _; + promise.reject(global, err.clone().to_js(global)) + } + }; + } + } + + /// Poll dispatch is about to run the writable callback for this sink. + /// + /// # Safety + /// `this` is the live FileSink owning the poll. + #[cfg(not(windows))] + #[inline] + pub unsafe fn before_writable(this: *mut FileSink) { + // SAFETY: caller contract; field reads/`Cell` writes only. + unsafe { (*this).refresh_stdio_mode() }; } /// Something (spawn with inherited stdio) put our description back into @@ -660,6 +724,16 @@ impl FileSink { /// was expected to return early. One relaxed load when nothing happened. #[inline] fn refresh_stdio_mode(&self) { + #[cfg(any(target_os = "linux", target_os = "android"))] + if self.stdio_rwf_nowait.get() && !sys::linux::RWFFlagSupport::is_maybe_supported() { + // The RWF_NOWAIT route was refused at runtime; take the portable one + // (unless a spawn has since asked for blocking stdio anyway). + self.stdio_rwf_nowait.set(false); + if !sys::stdio_made_blocking(self.stdio.get()) { + self.stdio_set_o_nonblock(self.writer.get().get_fd()); + } + return; + } #[cfg(not(windows))] if self.nonblocking.get() && self.is_stdio() && sys::stdio_made_blocking(self.stdio.get()) { self.nonblocking.set(false); @@ -674,7 +748,7 @@ impl FileSink { /// description (torvalds/linux@afed6271f5b0, "pipe: set FMODE_NOWAIT on /// pipes"), and ttys / files stay blocking as in Node. #[cfg(not(windows))] - fn stdio_go_nonblocking(&self) { + pub(crate) fn stdio_go_nonblocking(&self) { if self.nonblocking.get() || !self.pollable.get() || self.is_socket.get() { return; } @@ -689,9 +763,18 @@ impl FileSink { && sys::linux::RWFFlagSupport::is_maybe_supported() && sys::is_on_pipefs(fd) { + // `pwritev2(RWF_NOWAIT)` on a blocking description. Should the + // kernel turn out to refuse it after all (seccomp, gVisor, ...), + // `refresh_stdio_mode` falls back to O_NONBLOCK. + self.stdio_rwf_nowait.set(true); return; } } + self.stdio_set_o_nonblock(fd); + } + + #[cfg(not(windows))] + fn stdio_set_o_nonblock(&self, fd: Fd) { let already = sys::get_fcntl_flags(fd) .map(|f| f as i32 & sys::O::NONBLOCK != 0) .unwrap_or(false); @@ -732,9 +815,14 @@ impl FileSink { (*this).stdio.set(stdio_fd); (*this).pollable.set(pollable); (*this).is_socket.set(is_socket); - (*this).force_sync.set(!pollable); + // As for any FileSink: only a TTY writes through immediately; + // files / pipes / sockets coalesce `Bun.stdout.writer().write()`s + // until `flush()` / end of tick. (The console and process.stdout + // paths don't go through the coalescing entry point.) + let force_sync = !pollable && sys::isatty(fd); + (*this).force_sync.set(force_sync); (*this).writer.with_mut(|w| { - w.force_sync = !pollable; + w.force_sync = force_sync; // Idle stdio sinks keep no poll registered; `AutoFlusher` // flushes coalesced `Bun.stdout.writer()` writes at end of tick. w.poll_flushes_buffer = false; @@ -862,6 +950,9 @@ impl FileSink { if result.is_ok() && (*this).pending.get().state == streams::PendingState::Pending { (*this).run_pending_later(); } + if result.is_ok() { + FileSink::settle_stdio_waiters(this, None); + } if (*this).source_pending_pull.replace(false) { let mut src = *(*this).source.get(); src.ready(None, None); @@ -879,14 +970,16 @@ impl FileSink { /// The console path: one fully formatted message (or a spilled part of /// one), delivered before this returns. Anything already queued goes out /// first (ordering), then `bytes` are written straight from the caller's - /// buffer. The caller holds [`bun_io::StdioLock`] for this fd for the whole - /// message. + /// buffer. Holds [`bun_io::StdioLock`] for exactly this write — never while + /// formatting or any other JS runs — so no thread can hold one stdio lock + /// while waiting on the other. /// /// # Safety /// Same contract as [`drain_sync`](Self::drain_sync). pub unsafe fn write_all_sync(this: *mut FileSink, bytes: &[u8]) -> sys::Result<()> { // SAFETY: caller contract. unsafe { + let _lock = bun_io::StdioLock::acquire((*this).stdio.get()); if (*this).writer.get().has_pending_data() { FileSink::drain_sync(this)?; } @@ -899,6 +992,14 @@ impl FileSink { // `SyncFile` writes are already a blocking loop. match (*this).writer.with_mut(|w| w.write(bytes)) { WriteResult::Err(err) => Err((*this).stdio_latch_error(err)), + WriteResult::Done(n) if n < bytes.len() => { + // The handle went away part-way: say so rather than + // report the tail as written. + Err((*this).stdio_latch_error(sys::Error::from_code( + sys::E::EPIPE, + sys::Tag::write, + ))) + } _ => { (*this).written.set((*this).written.get() + bytes.len()); Ok(()) @@ -944,15 +1045,6 @@ impl FileSink { } } - /// A caller that got `Writable::Pending` back but settles the operation - /// itself (synchronously, via `drain_sync`) instead of taking the pending - /// promise gives its byte credit back, so the next real waiter's count is - /// its own. - pub fn uncredit_pending(&self, accepted: u64) { - self.pending - .with_mut(|p| p.consumed = p.consumed.saturating_sub(accepted)); - } - /// `write()` for a JS `data` value that is a string / ArrayBuffer(View); /// `Ok(None)` for anything else so the caller can take its general path. /// `now`: attempt the syscall immediately (`IOWriter::write_now`) rather @@ -1913,6 +2005,8 @@ impl FileSink { stdio: Cell::new(Fd::INVALID), stdio_js: JsCell::new(bun_jsc::strong::Optional::empty()), stdio_error: JsCell::new(None), + stdio_waiters: JsCell::new(Vec::new()), + stdio_rwf_nowait: Cell::new(false), auto_flusher: JsCell::new(AutoFlusher::default()), run_pending_later: FlushPendingTask::default(), readable_stream: JsCell::new(readable_stream::Strong::default()), @@ -2233,7 +2327,7 @@ unsafe fn __bun_stdio_sink_deinit(ptr: *mut ()) { // wrapper root here is sound (the wrapper's own +1 is released by its // finalizer). unsafe { - (*this).stdio_js.with_mut(|s| s.deinit()); + (*this).release_stdio_js(); // A stdio sink never reaches EOF/close, so a backpressure episode's // keep-alive ref (`to_result` → `must_be_kept_alive_until_eof`) is // still held; drop it with RareData's. @@ -2288,7 +2382,7 @@ pub fn stdio_sink_js(global: &JSGlobalObject, fd: Fd) -> Option { /// `bun_jsc::console_object::__bun_stdio_sink_write` body — the console fast /// path. Delivers `bytes` to fd `fd` through this VM's stdio sink before -/// returning (see [`FileSink::write_all_sync`]). Caller holds `StdioLock(fd)`. +/// returning (see [`FileSink::write_all_sync`]). /// /// # Safety /// `vm` is the live per-thread VM. @@ -2304,6 +2398,7 @@ unsafe fn __bun_stdio_sink_write( // SAFETY: `sink` is the canonical live pointer held by RareData. Some(sink) => match unsafe { FileSink::write_all_sync(sink, bytes) } { Ok(()) => Ok(()), + // (The lock is released by now: reporting may print to the other fd.) Err(err) => { // Node: a console write that fails surfaces as 'error' on // process.stdout/stderr *if someone is listening* (the console @@ -2315,6 +2410,7 @@ unsafe fn __bun_stdio_sink_write( None => { // No sink (fd 1/2 could not be dup'd): best effort straight to the // fd; there is no stream to report a failure on. + let _lock = bun_io::StdioLock::acquire(fd); let _ = sys::write_all_retrying(fd, bytes); Ok(()) } @@ -2343,6 +2439,12 @@ fn report_stdio_error(global: &JSGlobalObject, fd: Fd, err: &sys::Error) { /// `init_runtime_state`. Runs on whatever thread `Output` is writing from, so /// it only ever looks at *that* thread's VM. pub fn before_output_write(fd: Fd) { + // The crash reporter / a panic prints through `Output` too; it must not + // take the stdio lock or wait on a reader (or touch a writer that may be + // mid-flush on this very stack). + if bun_core::is_panicking() { + return; + } let Some(vm) = bun_jsc::VirtualMachineRef::get_or_null() else { return; }; @@ -2364,6 +2466,20 @@ pub fn before_output_write(fd: Fd) { /// /// # Safety /// `vm` is the live per-thread VM. +/// See [`__bun_stdio_sink_drain`]; one fd. +/// +/// # Safety +/// `vm` is the live per-thread VM. +#[unsafe(no_mangle)] +unsafe fn __bun_stdio_sink_drain_fd(vm: *mut bun_jsc::VirtualMachineRef, fd: Fd) { + // SAFETY: caller contract. + let vm = unsafe { &mut *vm }; + if let Some(sink) = existing_stdio_sink(vm, fd) { + // SAFETY: canonical live pointer held by RareData. + let _ = unsafe { FileSink::drain_sync(sink) }; + } +} + #[unsafe(no_mangle)] unsafe fn __bun_stdio_sink_drain(vm: *mut bun_jsc::VirtualMachineRef) { // SAFETY: caller contract. From f35776af4a428f84136e7daef6874d88782a3d43 Mon Sep 17 00:00:00 2001 From: Dylan Conway Date: Fri, 7 Aug 2026 18:49:08 -0700 Subject: [PATCH 13/26] FileSink: make the RWF_NOWAIT flag field Linux-only instead of allow(dead_code) --- src/runtime/webcore/FileSink.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/runtime/webcore/FileSink.rs b/src/runtime/webcore/FileSink.rs index 9a0a2ac39406..e14701dcfe35 100644 --- a/src/runtime/webcore/FileSink.rs +++ b/src/runtime/webcore/FileSink.rs @@ -73,7 +73,7 @@ pub struct FileSink { pub(crate) stdio_waiters: JsCell>, /// This stdio sink is relying on `pwritev2(RWF_NOWAIT)` (Linux ≥ 6.4 /// pipes) instead of `O_NONBLOCK`; see `refresh_stdio_mode`. - #[cfg_attr(not(any(target_os = "linux", target_os = "android")), allow(dead_code))] + #[cfg(any(target_os = "linux", target_os = "android"))] pub(crate) stdio_rwf_nowait: Cell, pub(crate) auto_flusher: JsCell, @@ -2006,6 +2006,7 @@ impl FileSink { stdio_js: JsCell::new(bun_jsc::strong::Optional::empty()), stdio_error: JsCell::new(None), stdio_waiters: JsCell::new(Vec::new()), + #[cfg(any(target_os = "linux", target_os = "android"))] stdio_rwf_nowait: Cell::new(false), auto_flusher: JsCell::new(AutoFlusher::default()), run_pending_later: FlushPendingTask::default(), From e63575bf3c8c012b535c5c9bdd8743f7b4aa25cc Mon Sep 17 00:00:00 2001 From: Dylan Conway Date: Fri, 7 Aug 2026 19:15:32 -0700 Subject: [PATCH 14/26] exit drain: only un-hook signals that were being forwarded to JS listeners Resetting every signal to SIG_DFL (Node-style) also reset the signal JSC uses to suspend threads for GC on Linux (SIGPWR/SIGUSR1), so a collection during teardown killed the process with SIGPWR on the destruct-on-exit / ASAN lane. Query each disposition and reset only those whose handler is Bun's forwardSignal. --- src/jsc/VirtualMachine.rs | 11 ++++++----- src/jsc/bindings/BunProcess.cpp | 26 ++++++++++++++++++++++++++ src/jsc/bindings/c-bindings.cpp | 19 ------------------- 3 files changed, 32 insertions(+), 24 deletions(-) diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 5d4e97798134..40620a5fb5e3 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -384,7 +384,8 @@ unsafe extern "C" { safe fn Bun__WebView__closeAllForTermination(); safe fn Zig__GlobalObject__destructOnExit(global: &JSGlobalObject); safe fn Bun__JSCTaskScheduler__markShuttingDown(global: &JSGlobalObject); - /// `c-bindings.cpp`: every catchable signal back to `SIG_DFL` (no-op on Windows). + /// `BunProcess.cpp`: signals that were only forwarded to JS listeners go + /// back to `SIG_DFL` (no-op on Windows). safe fn bun_reset_signal_handlers_for_exit(); } @@ -1514,10 +1515,10 @@ impl VirtualMachine { } } - /// The exit-time drain. JS will not run again, so its signal handlers - /// can't either: hand SIGINT/SIGTERM/... back to their defaults first (as - /// Node's `ResetSignalHandlers` does at teardown) so that waiting on a - /// reader that never reads stays killable the ordinary way. + /// The exit-time drain. JS will not run again, so its signal listeners + /// can't either: hand the signals they had claimed back to their defaults + /// first (cf. Node's `ResetSignalHandlers` at teardown) so that waiting on + /// a reader that never reads stays killable the ordinary way. fn drain_stdio_for_exit(&mut self) { if self .rare_data diff --git a/src/jsc/bindings/BunProcess.cpp b/src/jsc/bindings/BunProcess.cpp index 6fa00859e980..5311b5bf5a8c 100644 --- a/src/jsc/bindings/BunProcess.cpp +++ b/src/jsc/bindings/BunProcess.cpp @@ -1600,6 +1600,32 @@ extern "C" void Bun__installWatchModeSignalHandler(int signalNumber) } #endif +// At exit, once JS can no longer run, a signal that was only being forwarded +// to `process.on(...)` listeners would be swallowed; give those (and only +// those — not JSC's thread-suspend signal, our stdio-restore handler, or an +// inherited SIG_IGN) back their default action so a process still writing out +// the last of its stdio stays killable with ^C / SIGTERM. (Node resets every +// signal here: ResetSignalHandlers.) +extern "C" void bun_reset_signal_handlers_for_exit() +{ +#if !OS(WINDOWS) + for (int nr = 1; nr < NSIG; nr++) { + if (nr == SIGKILL || nr == SIGSTOP) + continue; + struct sigaction current; + if (sigaction(nr, nullptr, ¤t) != 0) + continue; + if ((current.sa_flags & SA_SIGINFO) || current.sa_handler != forwardSignal) + continue; + struct sigaction dfl; + memset(&dfl, 0, sizeof(dfl)); + dfl.sa_handler = SIG_DFL; + sigemptyset(&dfl.sa_mask); + sigaction(nr, &dfl, nullptr); + } +#endif +} + extern "C" void Bun__MemoryPressure__install(JSC::JSGlobalObject* global); extern "C" void Bun__MemoryPressure__uninstall(JSC::JSGlobalObject* global); diff --git a/src/jsc/bindings/c-bindings.cpp b/src/jsc/bindings/c-bindings.cpp index 1d05d026ef11..f9541f12b9c1 100644 --- a/src/jsc/bindings/c-bindings.cpp +++ b/src/jsc/bindings/c-bindings.cpp @@ -586,25 +586,6 @@ static void bun_restore_stdio_nonblock() } #endif -// Node's ResetSignalHandlers(): at teardown, once JS can no longer run, put -// every catchable signal back to its default so a process blocked writing out -// the last of its stdio is still killable with ^C / SIGTERM. -extern "C" void bun_reset_signal_handlers_for_exit() -{ -#if !OS(WINDOWS) - struct sigaction act; - memset(&act, 0, sizeof(act)); - for (int nr = 1; nr < 32; nr++) { - if (nr == SIGKILL || nr == SIGSTOP) - continue; - // Writing to a closed pipe / an oversized file must keep failing with - // EPIPE / EFBIG rather than kill us mid-flush. - act.sa_handler = (nr == SIGPIPE || nr == SIGXFSZ) ? SIG_IGN : SIG_DFL; - sigaction(nr, &act, nullptr); - } -#endif -} - extern "C" void bun_restore_stdio() { From c167a8bce5c05b64b118aed069bb354263aaa9af Mon Sep 17 00:00:00 2001 From: Dylan Conway Date: Fri, 7 Aug 2026 19:19:44 -0700 Subject: [PATCH 15/26] docs: put three misplaced doc comments back on the items they describe --- src/jsc/ConsoleObject.rs | 6 +++--- src/jsc/bindings/BunProcess.cpp | 6 +++--- src/runtime/webcore/FileSink.rs | 16 ++++++++-------- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/jsc/ConsoleObject.rs b/src/jsc/ConsoleObject.rs index ad45ea0dee81..f88886a6191f 100644 --- a/src/jsc/ConsoleObject.rs +++ b/src/jsc/ConsoleObject.rs @@ -176,13 +176,13 @@ unsafe extern "C" { fn Bun__Process__consoleStream(global: &JSGlobalObject, fd: i32, threw: &mut bool) -> JSValue; /// `BunProcess.cpp` — `consoleObjectWriteToObservedStream(stream, chunk)`. fn Bun__Console__writeToStream(global: &JSGlobalObject, stream: JSValue, chunk: JSValue); + /// `BunProcess.cpp` — the console's stream for `fd` is a foreign object + /// (not Bun's own stdio stream). Never runs user code. + safe fn Bun__Process__consoleStreamIsCustom(global: &JSGlobalObject, fd: i32) -> bool; /// `BunProcess.cpp` — the console's stream *object* for `fd` if any exists /// (custom binding or Bun's materialised stream), else empty. For /// `console.clear()`'s `isTTY` check. Same throw contract as /// `Bun__Process__consoleStream`. - /// `BunProcess.cpp` — the console's stream for `fd` is a foreign object - /// (not Bun's own stdio stream). Never runs user code. - safe fn Bun__Process__consoleStreamIsCustom(global: &JSGlobalObject, fd: i32) -> bool; fn Bun__Process__consoleStreamObject( global: &JSGlobalObject, fd: i32, diff --git a/src/jsc/bindings/BunProcess.cpp b/src/jsc/bindings/BunProcess.cpp index 5311b5bf5a8c..7771d2dce3fa 100644 --- a/src/jsc/bindings/BunProcess.cpp +++ b/src/jsc/bindings/BunProcess.cpp @@ -3080,9 +3080,6 @@ JSValue Process::consoleStreamForGetter(JSC::JSGlobalObject* globalObject, int f RELEASE_AND_RETURN(scope, get(globalObject, fd == 1 ? WebCore::builtinNames(vm).stdoutPublicName() : WebCore::builtinNames(vm).stderrPublicName())); } -// Empty: use the native sink. Otherwise the stream to `write()` to. `*threw` -// is set (and empty returned) if user code made `process.stdout` a throwing -// getter and this was the console's first use. // Whether the console's stream for `fd` is a *foreign* object (worker port // stream, `console._stdout = x`, a replaced `process.stdout`) rather than Bun's // own stdio stream in an observed state. Never runs user code. @@ -3093,6 +3090,9 @@ extern "C" bool Bun__Process__consoleStreamIsCustom(Zig::GlobalObject* globalObj return globalObject->processObject()->consoleStreamIsCustom(fd); } +// Empty: use the native sink. Otherwise the stream to `write()` to. `*threw` +// is set (and empty returned) if user code made `process.stdout` a throwing +// getter and this was the console's first use. extern "C" JSC::EncodedJSValue Bun__Process__consoleStream(Zig::GlobalObject* globalObject, int32_t fd, bool* threw) { if (!globalObject->hasProcessObject()) [[unlikely]] diff --git a/src/runtime/webcore/FileSink.rs b/src/runtime/webcore/FileSink.rs index e14701dcfe35..ece3db34f729 100644 --- a/src/runtime/webcore/FileSink.rs +++ b/src/runtime/webcore/FileSink.rs @@ -2459,14 +2459,6 @@ pub fn before_output_write(fd: Fd) { } } -/// `bun_jsc::virtual_machine::__bun_stdio_sink_drain` body: synchronously drain -/// whatever `process.stdout`/`stderr` writes are still queued on this VM, so -/// what the caller prints next (a fatal error, the exit) cannot overtake or -/// discard them. No-op — not even an allocation — when the sinks were never -/// created. -/// -/// # Safety -/// `vm` is the live per-thread VM. /// See [`__bun_stdio_sink_drain`]; one fd. /// /// # Safety @@ -2481,6 +2473,14 @@ unsafe fn __bun_stdio_sink_drain_fd(vm: *mut bun_jsc::VirtualMachineRef, fd: Fd) } } +/// `bun_jsc::virtual_machine::__bun_stdio_sink_drain` body: synchronously drain +/// whatever `process.stdout`/`stderr` writes are still queued on this VM, so +/// what the caller prints next (a fatal error, the exit) cannot overtake or +/// discard them. No-op — not even an allocation — when the sinks were never +/// created. +/// +/// # Safety +/// `vm` is the live per-thread VM. #[unsafe(no_mangle)] unsafe fn __bun_stdio_sink_drain(vm: *mut bun_jsc::VirtualMachineRef) { // SAFETY: caller contract. From b5a2451aab33a7734dc2ee2b2eca2631990e705c Mon Sep 17 00:00:00 2001 From: Dylan Conway Date: Fri, 7 Aug 2026 19:52:21 -0700 Subject: [PATCH 16/26] review: idempotent RWF_NOWAIT branch in stdio_go_nonblocking; Stream._isAnyArrayBuffer instead of instanceof --- src/js/internal/streams/legacy.ts | 3 ++- src/js/internal/streams/writable.ts | 2 +- src/runtime/webcore/FileSink.rs | 4 ++++ 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/js/internal/streams/legacy.ts b/src/js/internal/streams/legacy.ts index 2ed60fff0b9c..8686b2e79b53 100644 --- a/src/js/internal/streams/legacy.ts +++ b/src/js/internal/streams/legacy.ts @@ -1,7 +1,7 @@ "use strict"; const EE = require("node:events"); -const { isArrayBufferView, isUint8Array } = require("node:util/types"); +const { isArrayBufferView, isUint8Array, isAnyArrayBuffer } = require("node:util/types"); const ReflectOwnKeys = Reflect.ownKeys; const ArrayIsArray = Array.isArray; @@ -119,6 +119,7 @@ function prependListener(emitter, event, fn) { // Add helper methods to Stream Stream._isArrayBufferView = isArrayBufferView; +Stream._isAnyArrayBuffer = isAnyArrayBuffer; Stream._isUint8Array = isUint8Array; Stream._uint8ArrayToBuffer = function _uint8ArrayToBuffer(chunk) { return new $Buffer(chunk.buffer, chunk.byteOffset, chunk.byteLength); diff --git a/src/js/internal/streams/writable.ts b/src/js/internal/streams/writable.ts index c34c7cec0b4b..c86b2af318b8 100644 --- a/src/js/internal/streams/writable.ts +++ b/src/js/internal/streams/writable.ts @@ -461,7 +461,7 @@ function _write(stream, chunk, encoding, cb?) { chunk = Stream._uint8ArrayToBuffer(chunk); encoding = "buffer"; } else if ( - (chunk instanceof ArrayBuffer || chunk instanceof SharedArrayBuffer) && + Stream._isAnyArrayBuffer(chunk) && (stream._isStdio === true || stream[require("internal/fs/streams").kWriteStreamFastPath]) ) { // Bun's FileSink-backed streams (process.stdout/stderr, child.stdin, diff --git a/src/runtime/webcore/FileSink.rs b/src/runtime/webcore/FileSink.rs index ece3db34f729..4a86f9e945d5 100644 --- a/src/runtime/webcore/FileSink.rs +++ b/src/runtime/webcore/FileSink.rs @@ -752,6 +752,10 @@ impl FileSink { if self.nonblocking.get() || !self.pollable.get() || self.is_socket.get() { return; } + #[cfg(any(target_os = "linux", target_os = "android"))] + if self.stdio_rwf_nowait.get() { + return; + } let fd = self.writer.get().get_fd(); if fd == Fd::INVALID { return; From 7d7d6dcb401876f78fb2cc6974dd7e1236a226bb Mon Sep 17 00:00:00 2001 From: Dylan Conway Date: Fri, 7 Aug 2026 20:28:08 -0700 Subject: [PATCH 17/26] stdio_go_nonblocking: never re-set O_NONBLOCK once a spawn has handed the description over blocking + test: after an inherit spawn, Bun.write(Bun.stdout)/Bun.stdout.writer() leave fd 1 blocking --- src/runtime/webcore/FileSink.rs | 5 +++++ test/js/node/process/process-stdio.test.ts | 11 +++++++++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/runtime/webcore/FileSink.rs b/src/runtime/webcore/FileSink.rs index 4a86f9e945d5..e22cb0af59c5 100644 --- a/src/runtime/webcore/FileSink.rs +++ b/src/runtime/webcore/FileSink.rs @@ -752,6 +752,11 @@ impl FileSink { if self.nonblocking.get() || !self.pollable.get() || self.is_socket.get() { return; } + // A spawn handed this description to a child blocking; it stays that + // way (see `refresh_stdio_mode`) — don't flip it back under the child. + if sys::stdio_made_blocking(self.stdio.get()) { + return; + } #[cfg(any(target_os = "linux", target_os = "android"))] if self.stdio_rwf_nowait.get() { return; diff --git a/test/js/node/process/process-stdio.test.ts b/test/js/node/process/process-stdio.test.ts index aec40af3c9dc..0a975f617e7e 100644 --- a/test/js/node/process/process-stdio.test.ts +++ b/test/js/node/process/process-stdio.test.ts @@ -357,14 +357,21 @@ const nonblock = fd => fd_is_nonblock(fd) !== 0; const after = { file: nonblock(errfd) }; // A child that inherits fd 1 must find it blocking regardless. const child = Bun.spawnSync([process.execPath, "-e", ${JSON.stringify(prelude + `process.stderr.write(String(nonblock(1)))`)}], { stdio: ["ignore", "inherit", "pipe"], env: process.env }); - console.log(JSON.stringify({ before, after, childSeesNonblock: child.stderr.toString() })); + // ...and once handed over blocking it stays that way: a later async + // writer must not flip O_NONBLOCK back on under a (possibly still + // running) child. + await Bun.write(Bun.stdout, "y"); + Bun.stdout.writer(); + const stillBlocking = !nonblock(1); + console.log(JSON.stringify({ before, after, childSeesNonblock: child.stderr.toString(), stillBlocking })); `, ); const line = stdout.toString().trim().split("\n").pop()!; - expect(JSON.parse(line.replace(/^x/, ""))).toEqual({ + expect(JSON.parse(line.replace(/^xy/, ""))).toEqual({ before: { out: false, file: false }, after: { file: false }, childSeesNonblock: "false", + stillBlocking: true, }); expect(exitCode).toBe(0); }, From 67b116cfb9a73451bc18d251518806fcd339c745 Mon Sep 17 00:00:00 2001 From: Dylan Conway Date: Fri, 7 Aug 2026 21:35:02 -0700 Subject: [PATCH 18/26] console emit: decide colours before taking the scratch buffer --- src/jsc/ConsoleObject.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/jsc/ConsoleObject.rs b/src/jsc/ConsoleObject.rs index f88886a6191f..b30796d78306 100644 --- a/src/jsc/ConsoleObject.rs +++ b/src/jsc/ConsoleObject.rs @@ -353,6 +353,7 @@ pub fn emit( ) -> JsResult<()> { let target = console_target(global, stream)?; let native = target.is_empty(); + let colors = console_colors(global, stream, target)?; // No lock here: formatting runs user JS (getters, toJSON, inspect.custom) // that may log to the *other* stream or exit; each write / spill takes the // per-fd lock for itself (`FileSink::write_all_sync`). @@ -368,7 +369,7 @@ pub fn emit( ) }), }; - let result = match f(&mut writer, console_colors(global, stream, target)?) { + let result = match f(&mut writer, colors) { Ok(()) => deliver_to(global, stream, target, &buf), Err(err) => Err(err), }; From ab797acd40ad4a5efd6879c3c30c85fbb0369b41 Mon Sep 17 00:00:00 2001 From: Dylan Conway Date: Sat, 8 Aug 2026 06:17:27 -0700 Subject: [PATCH 19/26] console: stop (and report once) after a failed spill; docs: Bun.write/Bun.stdout.writer bypass process.stdout's stream-level state --- docs/guides/write-file/stdout.mdx | 2 +- src/jsc/ConsoleObject.rs | 10 +++++++++- src/runtime/webcore/Blob.rs | 4 +++- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/docs/guides/write-file/stdout.mdx b/docs/guides/write-file/stdout.mdx index 0513a3d1efc5..1394b28b4130 100644 --- a/docs/guides/write-file/stdout.mdx +++ b/docs/guides/write-file/stdout.mdx @@ -29,7 +29,7 @@ writer.flush(); --- -`console.log`, `process.stdout.write()`, `Bun.write(Bun.stdout, ...)`, `Bun.stdout.writer()` and `console.write()` all share one output queue per thread, so their output comes out in the order the calls were made, however slowly the other end of a pipe reads. As in Node.js, `console.log` is a `write()` on `process.stdout` from the point of view of anything that replaces or wraps `process.stdout.write`. +`console.log`, `process.stdout.write()`, `Bun.write(Bun.stdout, ...)`, `Bun.stdout.writer()` and `console.write()` all end at one output queue per thread, so bytes each of them has handed over come out in call order, however slowly the other end of a pipe reads. As in Node.js, `console.log` is a `write()` on `process.stdout` from the point of view of anything that replaces or wraps `process.stdout.write` — so it also respects `process.stdout.cork()` and waits behind chunks `process.stdout` is still buffering. `Bun.write(Bun.stdout, ...)` and `Bun.stdout.writer()` write to the queue directly and are not held back by `process.stdout`'s stream-level state. When the program exits — including through `process.exit()` or an uncaught exception — everything already written to `process.stdout` and `process.stderr` is flushed to the operating system first. (Node.js can truncate pending output to a slow pipe on `process.exit()`; Bun waits for it.) diff --git a/src/jsc/ConsoleObject.rs b/src/jsc/ConsoleObject.rs index b30796d78306..c63e881e32d6 100644 --- a/src/jsc/ConsoleObject.rs +++ b/src/jsc/ConsoleObject.rs @@ -318,6 +318,9 @@ pub fn deliver(global: &JSGlobalObject, stream: ConsoleStream, bytes: &[u8]) -> pub struct ConsoleWriter<'a> { buf: &'a mut Vec, spill: Option<(*mut VirtualMachine, bun_sys::Fd)>, + /// A spill already failed (EPIPE, ...) and was reported: the rest of this + /// message is dropped rather than re-failing (and re-reporting) per block. + failed: bool, } /// How much of one message to accumulate before writing it out on the native @@ -329,11 +332,14 @@ const SPILL_AT: usize = 64 * 1024; impl bun_io::Write for ConsoleWriter<'_> { #[inline] fn write_all(&mut self, bytes: &[u8]) -> bun_core::CrateResult<()> { + if self.failed { + return Ok(()); + } self.buf.extend_from_slice(bytes); if self.buf.len() >= SPILL_AT { if let Some((vm, fd)) = self.spill { // SAFETY: `vm` is the live per-thread VM (set in `emit`). - let _ = unsafe { __bun_stdio_sink_write(vm, fd, self.buf) }; + self.failed = unsafe { __bun_stdio_sink_write(vm, fd, self.buf) }.is_err(); self.buf.clear(); } } @@ -368,8 +374,10 @@ pub fn emit( stream.fd(), ) }), + failed: false, }; let result = match f(&mut writer, colors) { + Ok(()) if writer.failed => Ok(()), Ok(()) => deliver_to(global, stream, target, &buf), Err(err) => Err(err), }; diff --git a/src/runtime/webcore/Blob.rs b/src/runtime/webcore/Blob.rs index 1f342e052405..b99a0a1ad205 100644 --- a/src/runtime/webcore/Blob.rs +++ b/src/runtime/webcore/Blob.rs @@ -5045,7 +5045,9 @@ pub(crate) fn write_file_internal( } // Bun.write(Bun.stdout | Bun.stderr, data): through the stdio sink, so it is - // ordered with console.* / process.stdout and gets the same EAGAIN handling + // ordered with everything already handed to the sink (console.*, + // process.stdout — though not with chunks process.stdout's Writable is still + // holding: cork(), backpressure buffer) and gets the same EAGAIN handling // (and never lands on the thread pool, whose LIFO queue reversed // back-to-back writes to a file-backed stdout). if let PathOrBlob::Blob(ref b) = *path_or_blob { From f07b71ec81f63583ce2b6861f1b3f19371bfa7b9 Mon Sep 17 00:00:00 2001 From: Dylan Conway Date: Sat, 8 Aug 2026 06:45:43 -0700 Subject: [PATCH 20/26] napi test fixture: allocate the finalizer objects in a callee frame and scrub the stack before gc() The experimental-module finalizer test needs the wrapped objects collected by the explicit gc() so their finalizers run during GC. With 'let arr = ...; console.log(); arr = null; gc()' in one frame, a conservative scan of that frame kept them alive on darwin-x64 once the console.log native path's frame layout shifted, and the fixture reported 'Did not crash'. --- .../napi-app/test_experimental_with_timeout.js | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/test/napi/napi-app/test_experimental_with_timeout.js b/test/napi/napi-app/test_experimental_with_timeout.js index 8bd34862370e..a0ad8cea0513 100644 --- a/test/napi/napi-app/test_experimental_with_timeout.js +++ b/test/napi/napi-app/test_experimental_with_timeout.js @@ -8,10 +8,17 @@ const modulePath = path.join(__dirname, 'build/Debug/test_reference_unref_in_fin const proc = spawn(process.argv[0], ['--expose-gc', '-e', ` const m = require("${modulePath}"); console.log('Loading experimental module...'); -let arr = m.test_reference_unref_in_finalizer_experimental(); -console.log('Test function returned'); -arr = null; -global.gc ? global.gc() : (process.isBun && Bun.gc ? Bun.gc(true) : null); +// Allocate in a callee frame and scrub the stack afterwards so a conservative +// scan of this frame can't keep the wrapped objects alive past gc() (which is +// what decides whether their finalizers run *during* GC, i.e. whether this +// crashes as it must). +(function () { + m.test_reference_unref_in_finalizer_experimental(); + console.log('Test function returned'); +})(); +(function scrub(n) { return n > 0 ? scrub(n - 1) + 1 : 0; })(128); +const gc = () => (global.gc ? global.gc() : (process.isBun && Bun.gc ? Bun.gc(true) : null)); +gc(); gc(); console.log('GC triggered - should crash now'); console.log('ERROR: Did not crash! Test failed!'); process.exit(1); From 9033ecd4e2f95501cead9b0dfcee8cc1a3cd55c3 Mon Sep 17 00:00:00 2001 From: Dylan Conway Date: Tue, 13 Oct 2026 21:15:37 +0000 Subject: [PATCH 21/26] Cut the PR down to one change: console.* flushes what process.stdout/stderr queued before it writes to the same fd --- docs/guides/write-file/stdout.mdx | 15 - src/bun_core/output.rs | 25 - src/codegen/generate-jssink.ts | 16 +- src/io/PipeWriter.rs | 149 +-- src/io/lib.rs | 6 - src/io/openForWriting.rs | 5 +- src/io/stdio_lock.rs | 75 -- src/js/builtins/BunBuiltinNames.h | 4 - src/js/builtins/ConsoleObject.ts | 88 +- src/js/builtins/ProcessObjectInternals.ts | 132 +- src/js/internal/fs/streams.ts | 137 ++- src/js/internal/streams/legacy.ts | 3 +- src/js/internal/streams/writable.ts | 25 - src/js/node/diagnostics_channel.ts | 25 - src/js/node/worker_threads.ts | 12 +- src/jsc/ConsoleObject.rs | 924 ++++++-------- src/jsc/VirtualMachine.rs | 76 +- src/jsc/bindings/BunProcess.cpp | 335 +---- src/jsc/bindings/BunProcess.h | 66 +- src/jsc/bindings/ConsoleObject.cpp | 56 +- src/jsc/bindings/ZigGlobalObject.cpp | 49 +- src/jsc/bindings/c-bindings.cpp | 87 +- src/jsc/rare_data.rs | 30 - src/runtime/cli/test_command.rs | 7 - src/runtime/dispatch.rs | 10 +- src/runtime/jsc_hooks.rs | 2 - src/runtime/node/node_fs.rs | 8 +- src/runtime/webcore/Blob.rs | 94 -- src/runtime/webcore/FileSink.rs | 1081 +++-------------- src/runtime/webcore/blob/copy_file.rs | 14 +- src/spawn_sys/posix_spawn.rs | 16 - src/sys/lib.rs | 175 +-- src/sys/sys_uv.rs | 12 - test/js/node/console/console.test.ts | 284 +---- test/js/node/process/process-stdio.test.ts | 570 +-------- .../process-stdout-write-after-end.test.ts | 10 +- .../node/test/parallel/test-console-clear.js | 24 - .../node/test/parallel/test-console-count.js | 65 - .../parallel/test-console-stdio-setters.js | 18 - .../worker_threads/worker_threads.test.ts | 61 - .../web/console/console-timeLog.expected.txt | 24 +- test/js/web/console/console-timeLog.test.ts | 45 +- .../test_experimental_with_timeout.js | 15 +- 43 files changed, 832 insertions(+), 4043 deletions(-) delete mode 100644 src/io/stdio_lock.rs delete mode 100644 test/js/node/test/parallel/test-console-clear.js delete mode 100644 test/js/node/test/parallel/test-console-count.js delete mode 100644 test/js/node/test/parallel/test-console-stdio-setters.js diff --git a/docs/guides/write-file/stdout.mdx b/docs/guides/write-file/stdout.mdx index 1394b28b4130..64937c9188c5 100644 --- a/docs/guides/write-file/stdout.mdx +++ b/docs/guides/write-file/stdout.mdx @@ -18,21 +18,6 @@ Bun also exposes `stdout` as a `BunFile` with the `Bun.stdout` property. Pass it await Bun.write(Bun.stdout, "Lorem ipsum"); ``` -For many small writes, `Bun.stdout.writer()` returns a buffered `FileSink` over the same destination. - -```ts -const writer = Bun.stdout.writer(); -writer.write("Lorem "); -writer.write("ipsum\n"); -writer.flush(); -``` - ---- - -`console.log`, `process.stdout.write()`, `Bun.write(Bun.stdout, ...)`, `Bun.stdout.writer()` and `console.write()` all end at one output queue per thread, so bytes each of them has handed over come out in call order, however slowly the other end of a pipe reads. As in Node.js, `console.log` is a `write()` on `process.stdout` from the point of view of anything that replaces or wraps `process.stdout.write` — so it also respects `process.stdout.cork()` and waits behind chunks `process.stdout` is still buffering. `Bun.write(Bun.stdout, ...)` and `Bun.stdout.writer()` write to the queue directly and are not held back by `process.stdout`'s stream-level state. - -When the program exits — including through `process.exit()` or an uncaught exception — everything already written to `process.stdout` and `process.stderr` is flushed to the operating system first. (Node.js can truncate pending output to a slow pipe on `process.exit()`; Bun waits for it.) - --- See [`Bun.write()`](/runtime/file-io#writing-files-bun-write). diff --git a/src/bun_core/output.rs b/src/bun_core/output.rs index 140863d9b17d..ce9b6d7184a5 100644 --- a/src/bun_core/output.rs +++ b/src/bun_core/output.rs @@ -413,19 +413,6 @@ impl Source { Self::get_force_color_depth().unwrap_or(ColorDepth::None) != ColorDepth::None } - /// What `FORCE_COLOR` / `NO_COLOR` alone say (the part of the colour - /// decision that doesn't depend on which stream is being written to), for - /// callers colouring output bound for a stream other than fd 1/2. - pub fn env_color_override() -> Option { - if Self::get_force_color_depth().is_some() { - Some(Self::is_force_color()) - } else if Self::is_no_color() { - Some(false) - } else { - None - } - } - pub fn color_depth() -> ColorDepth { *LAZY_COLOR_DEPTH.get_or_init(compute_color_depth) } @@ -2275,12 +2262,6 @@ pub fn print_errorln(args: impl core::fmt::Display) { print_to(Destination::Stderr, format_args!("{args}\n")); } -/// See [`Source::env_color_override`]. -#[inline] -pub fn env_color_override() -> Option { - Source::env_color_override() -} - /// `Output.enable_ansi_colors_stdout` — safe relaxed-load wrapper over the /// startup-initialized atomic. #[inline] @@ -2473,12 +2454,6 @@ impl ErrName for crate::CrateError { // ── ScopedDebugWriter ───────────────────────────────────────────────────── -/// True while this thread is inside a `scoped_log!` write (debug builds). -#[inline] -pub fn is_inside_scoped_log() -> bool { - crate::env::IS_DEBUG && scoped_debug_writer::DISABLE_INSIDE_LOG.get() > 0 -} - pub mod scoped_debug_writer { use super::*; diff --git a/src/codegen/generate-jssink.ts b/src/codegen/generate-jssink.ts index a2a90adc347e..f4e795191f9e 100644 --- a/src/codegen/generate-jssink.ts +++ b/src/codegen/generate-jssink.ts @@ -530,7 +530,6 @@ JSC_DEFINE_HOST_FUNCTION(${controller}__end, (JSC::JSGlobalObject * lexicalGloba } extern "C" JSC::EncodedJSValue ${name}__getInternalFd(void* sinkPtr); -${name === "FileSink" ? `extern "C" bool FileSink__isStdio(void* sinkPtr);` : ""} // TODO: how to make this a property callback. then, we can expose this as a documented field // It should not be shipped as a function call. @@ -570,20 +569,7 @@ JSC_DEFINE_HOST_FUNCTION(${name}__doClose, (JSC::JSGlobalObject * lexicalGlobalO if (ptr == nullptr) { return JSC::JSValue::encode(JSC::jsUndefined()); } -${ - name === "FileSink" - ? ` - // The per-thread stdio sink is shared (process.stdout, Bun.stdout.writer(), - // console.*): closing one handle to it flushes, it does not take the - // wrapper away from the others. - if (FileSink__isStdio(ptr)) { - FileSink__close(lexicalGlobalObject, ptr, JSC::JSValue::encode(JSC::JSValue())); - RETURN_IF_EXCEPTION(scope, {}); - return JSC::JSValue::encode(JSC::jsUndefined()); - } -` - : "" -} + sink->detach(); ${name}__close(lexicalGlobalObject, ptr, JSC::JSValue::encode(JSC::JSValue())); // detach() nulled m_sinkPtr so ~${className} won't finalize ptr; do the diff --git a/src/io/PipeWriter.rs b/src/io/PipeWriter.rs index 73f1f68d308a..c70860b311c1 100644 --- a/src/io/PipeWriter.rs +++ b/src/io/PipeWriter.rs @@ -87,8 +87,6 @@ pub trait PosixPipeWriter { FileType::Pipe if !force_sync => { self.try_write_with_write_fn(buf, write_to_blocking_pipe) } - // Nothing resumes an `EAGAIN` here (someone else made the description non-blocking), so wait it out. - FileType::File => self.try_write_with_write_fn(buf, sys::write_retrying), _ => self.try_write_with_write_fn(buf, sys::write), } } @@ -686,12 +684,6 @@ pub struct PosixStreamingWriter { pub is_done: bool, pub(crate) closed_without_reporting: bool, pub force_sync: bool, - /// Sub-`CHUNK_SIZE` writes are coalesced and normally also arm the poll, - /// so the next writable event flushes them. An owner that flushes at end - /// of tick itself (FileSink's `AutoFlusher` on the stdio sink, whose poll - /// is deliberately unregistered while idle) turns this off to save the - /// registration syscall per buffered write. - pub poll_flushes_buffer: bool, /// Last reported `WriteStatus == Pending` (i.e. write(2) returned EAGAIN). backed_up: core::cell::Cell, rwf_unsupported: core::cell::Cell, @@ -706,7 +698,6 @@ impl Default for PosixStreamingWriter PosixStreamingWriter { self.parent } - /// [`parent`](Self::parent), for the poll dispatcher. - #[inline] - pub fn parent_ptr(&self) -> *mut Parent { - self.parent() - } - /// Single nonnull-asref dispatch for the set-once `parent` backref. /// /// Type invariant (encapsulated `unsafe`): `self.parent` is populated by @@ -863,16 +848,6 @@ impl PosixStreamingWriter { Self::r(this).close(); } - /// A write the parent issued on this writer's fd itself (bypassing - /// `write()`) failed terminally; tear down exactly as if `write()` had hit - /// the error. - pub fn fail(&mut self, err: sys::Error) { - if self.is_done || self.closed_without_reporting { - return; - } - self._on_error(err); - } - fn _on_write(&mut self, written: usize, status: WriteStatus) { self.outgoing.wrote(written); @@ -918,15 +893,6 @@ impl PosixStreamingWriter { } pub fn write_utf16(&mut self, buf: &[u16]) -> WriteResult { - self.write_utf16_impl(buf, true) - } - - /// See [`write_now`](Self::write_now). - pub fn write_utf16_now(&mut self, buf: &[u16]) -> WriteResult { - self.write_utf16_impl(buf, false) - } - - fn write_utf16_impl(&mut self, buf: &[u16], may_buffer: bool) -> WriteResult { if self.is_done || self.closed_without_reporting { return WriteResult::Done(0); } @@ -939,25 +905,16 @@ impl PosixStreamingWriter { let buf_len = self.outgoing.size() - before_len; - self.maybe_write_newly_buffered_data(buf_len, may_buffer) + self.maybe_write_newly_buffered_data(buf_len) } pub fn write_latin1(&mut self, buf: &[u8]) -> WriteResult { - self.write_latin1_impl(buf, true) - } - - /// See [`write_now`](Self::write_now). - pub fn write_latin1_now(&mut self, buf: &[u8]) -> WriteResult { - self.write_latin1_impl(buf, false) - } - - fn write_latin1_impl(&mut self, buf: &[u8], may_buffer: bool) -> WriteResult { if self.is_done || self.closed_without_reporting { return WriteResult::Done(0); } if bun_core::strings::is_all_ascii(buf) { - return self.write_impl(buf, may_buffer); + return self.write(buf); } let before_len = self.outgoing.size(); @@ -969,17 +926,15 @@ impl PosixStreamingWriter { let buf_len = self.outgoing.size() - before_len; - self.maybe_write_newly_buffered_data(buf_len, may_buffer) + self.maybe_write_newly_buffered_data(buf_len) } - fn maybe_write_newly_buffered_data(&mut self, buf_len: usize, may_buffer: bool) -> WriteResult { + fn maybe_write_newly_buffered_data(&mut self, buf_len: usize) -> WriteResult { debug_assert!(!self.is_done); - if may_buffer && self.should_buffer(0) { + if self.should_buffer(0) { self.parent_on_write(buf_len, WriteStatus::Drained); - if self.poll_flushes_buffer { - Self::register_poll(self); - } + Self::register_poll(self); return WriteResult::Wrote(buf_len); } @@ -1027,22 +982,11 @@ impl PosixStreamingWriter { } pub fn write(&mut self, buf: &[u8]) -> WriteResult { - self.write_impl(buf, true) - } - - /// `write` that never coalesces: the syscall is attempted now (anything - /// already queued goes first), the remainder queued on EAGAIN / short - /// write. What a stream's `_write` wants, as opposed to a batching writer. - pub fn write_now(&mut self, buf: &[u8]) -> WriteResult { - self.write_impl(buf, false) - } - - fn write_impl(&mut self, buf: &[u8], may_buffer: bool) -> WriteResult { if self.is_done || self.closed_without_reporting { return WriteResult::Done(0); } - if may_buffer && self.should_buffer(buf.len()) { + if self.should_buffer(buf.len()) { // this is streaming, but we buffer the data below `chunk_size` to // reduce the number of writes if self.outgoing.write(buf).is_err() { @@ -1052,9 +996,7 @@ impl PosixStreamingWriter { // noop, but need this to have a chance // to register deferred tasks (onAutoFlush) self.parent_on_write(buf.len(), WriteStatus::Drained); - if self.poll_flushes_buffer { - Self::register_poll(self); - } + Self::register_poll(self); // it's buffered, but should be reported as written to // callers @@ -1128,9 +1070,6 @@ impl PosixStreamingWriter { self.outgoing.wrote(written); if self.outgoing.is_empty() { self.outgoing.reset(); - } else { - // Backed up: the writable event is what drains the rest. - Self::register_poll(self); } } WriteResult::Wrote(written) => { @@ -1203,10 +1142,22 @@ impl PosixStreamingWriter { return sys::Result::Ok(()); } - let existing_poll = self.get_poll(); - let poll = self.ensure_poll(fd); // SAFETY: parent BACKREF set via set_parent; outlives this writer. let loop_ = unsafe { Parent::event_loop(self.parent()) }; + let existing_poll = self.get_poll(); + let poll = match existing_poll { + Some(p) => p, + None => { + let p = FilePollRef::init( + loop_, + fd, + Owner::new(Parent::POLL_OWNER_TAG, std::ptr::from_mut(self).cast()), + ); + self.handle = PollOrFd::Poll(p); + p + } + }; + match poll.register_with_fd(loop_.loop_(), FilePollKind::Writable, fd) { sys::Result::Err(err) => { // A poll from an earlier start() still holds that start's fd. @@ -1220,47 +1171,6 @@ impl PosixStreamingWriter { sys::Result::Ok(()) } - - /// `start` without registering the poll with the loop: the `FilePoll` - /// exists (so `FileType` and keep-alive work) but the kernel isn't asked - /// for writability until the first backpressure (`register_poll`). For a - /// long-lived writer whose fd is rarely full — an idle `EVFILT_WRITE` / - /// `EPOLLOUT` registration on a pipe makes every `write(2)` *and* the - /// reader's `read(2)` pay for knote/wakeup bookkeeping. Pair with - /// [`unregister_poll`](Self::unregister_poll) once drained. - pub fn start_lazy(&mut self, fd: Fd, is_pollable: bool) -> sys::Result<()> { - if !is_pollable { - return self.start(fd, false); - } - let _ = self.ensure_poll(fd); - sys::Result::Ok(()) - } - - /// Undo `register_poll` (keeps the `FilePoll`). No-op if not registered. - pub fn unregister_poll(&mut self) { - let Some(poll) = self.get_poll() else { return }; - if !poll.is_registered() { - return; - } - // SAFETY: parent BACKREF set via set_parent; outlives this writer. - let loop_ = unsafe { Parent::loop_(self.parent()) }.cast(); - let _ = poll.unregister(loop_, false); - } - - fn ensure_poll(&mut self, fd: Fd) -> FilePollRef { - if let Some(p) = self.get_poll() { - return p; - } - // SAFETY: parent BACKREF set via set_parent; outlives this writer. - let loop_ = unsafe { Parent::event_loop(self.parent()) }; - let p = FilePollRef::init( - loop_, - fd, - Owner::new(Parent::POLL_OWNER_TAG, std::ptr::from_mut(self).cast()), - ); - self.handle = PollOrFd::Poll(p); - p - } } impl Drop for PosixStreamingWriter { @@ -2678,21 +2588,6 @@ impl WindowsStreamingWriter { self.write_internal_u8(buffer, WriteKind::Bytes) } - /// Windows never coalesces (`uv_write` / `WriteFile` per call), so these - /// are the plain writes; see the posix `write_now`. - #[inline] - pub fn write_now(&mut self, buffer: &[u8]) -> WriteResult { - self.write(buffer) - } - #[inline] - pub fn write_latin1_now(&mut self, buffer: &[u8]) -> WriteResult { - self.write_latin1(buffer) - } - #[inline] - pub fn write_utf16_now(&mut self, buf: &[u16]) -> WriteResult { - self.write_utf16(buf) - } - pub fn flush(&mut self) -> WriteResult { if self.is_done { return WriteResult::Done(0); diff --git a/src/io/lib.rs b/src/io/lib.rs index f0af71fd32ad..d6f7ea9a3491 100644 --- a/src/io/lib.rs +++ b/src/io/lib.rs @@ -473,10 +473,8 @@ pub use pipe_read_scratch::{PipeReadScratch, PipeReadScratchGuard}; #[cfg(windows)] #[path = "source.rs"] pub mod source; -pub mod stdio_lock; #[path = "write.rs"] pub mod write; -pub use stdio_lock::StdioLock; // ── re-exports for higher tiers ───────────────────────────────────────────── // Byte-level `Write` trait + helpers. Downstream @@ -1902,10 +1900,6 @@ impl FilePollRef { self.inner().flags.insert(f); } #[inline] - pub fn clear_flag(self, f: FilePollFlag) { - self.inner().flags.remove(f); - } - #[inline] pub(crate) fn file_type(self) -> crate::pipes::FileType { #[cfg(not(windows))] { diff --git a/src/io/openForWriting.rs b/src/io/openForWriting.rs index 1410b1f54eba..8a29721c44d1 100644 --- a/src/io/openForWriting.rs +++ b/src/io/openForWriting.rs @@ -141,10 +141,7 @@ where // Ref: https://github.com/nodejs/node/pull/1771#issuecomment-119351671 let _ = bun_sys::update_nonblocking(fd, false); is_nonblocking = false; - } else if !is_nonblocking && *pollable { - // O_NONBLOCK is meaningless on regular files / char devices - // and lives on the shared open file description, so only - // set it where it buys us EAGAIN semantics. + } else if !is_nonblocking { let flags = match bun_sys::get_fcntl_flags(fd) { Ok(flags) => flags, Err(err) => { diff --git a/src/io/stdio_lock.rs b/src/io/stdio_lock.rs deleted file mode 100644 index d82fd022f8b2..000000000000 --- a/src/io/stdio_lock.rs +++ /dev/null @@ -1,75 +0,0 @@ -//! Process-wide, per-fd, reentrant lock around "one message to fd 1 / fd 2". -//! -//! Every JS thread has its own stdio sink for stdout and stderr (poll -//! registration, `'drain'` delivery and keep-alive are event-loop-affine), but -//! they all end at the same two file descriptors. Holding this lock for the -//! duration of one console message / one synchronous drain is what keeps a -//! worker's `console.log` from landing in the middle of the main thread's. -//! Reentrant per thread because formatting can re-enter (`console.log` inside -//! a getter that `console.log`s). - -use core::cell::Cell; - -use bun_sys::Fd; -use bun_threading::Mutex; - -static STDOUT_MUTEX: Mutex = Mutex::new(); -static STDERR_MUTEX: Mutex = Mutex::new(); - -thread_local! { - static DEPTH: [Cell; 2] = const { [Cell::new(0), Cell::new(0)] }; -} - -#[inline] -fn slot(fd: Fd) -> Option { - if fd == Fd::stdout() { - Some(0) - } else if fd == Fd::stderr() { - Some(1) - } else { - None - } -} - -#[inline] -fn mutex(slot: usize) -> &'static Mutex { - if slot == 0 { - &STDOUT_MUTEX - } else { - &STDERR_MUTEX - } -} - -/// RAII guard; no-op for fds other than 1 and 2. `!Send`: the depth it -/// balances is this thread's. -pub struct StdioLock(Option, core::marker::PhantomData<*mut ()>); - -impl StdioLock { - #[inline] - pub fn acquire(fd: Fd) -> Self { - let slot = slot(fd); - if let Some(i) = slot { - DEPTH.with(|d| { - if d[i].get() == 0 { - mutex(i).lock(); - } - d[i].set(d[i].get() + 1); - }); - } - Self(slot, core::marker::PhantomData) - } -} - -impl Drop for StdioLock { - #[inline] - fn drop(&mut self) { - if let Some(i) = self.0 { - DEPTH.with(|d| { - d[i].set(d[i].get() - 1); - if d[i].get() == 0 { - mutex(i).unlock(); - } - }); - } - } -} diff --git a/src/js/builtins/BunBuiltinNames.h b/src/js/builtins/BunBuiltinNames.h index 677e9926b50b..e2c71153286d 100644 --- a/src/js/builtins/BunBuiltinNames.h +++ b/src/js/builtins/BunBuiltinNames.h @@ -61,7 +61,6 @@ using namespace JSC; macro(close) \ macro(cmd) \ macro(code) \ - macro(consoleStream) \ macro(createCommonJSModule) \ macro(createFIFO) \ macro(createInternalModuleById) \ @@ -179,12 +178,9 @@ using namespace JSC; macro(statusCode) \ macro(statusMessage) \ macro(statusText) \ - macro(stderr) \ - macro(stdout) \ macro(storage) \ macro(stream) \ macro(syscall) \ - macro(takeBuffered) \ macro(text) \ macro(textDecoderStreamDecoder) \ macro(textEncoderStreamEncoder) \ diff --git a/src/js/builtins/ConsoleObject.ts b/src/js/builtins/ConsoleObject.ts index ae819819206c..3f84f23d0e73 100644 --- a/src/js/builtins/ConsoleObject.ts +++ b/src/js/builtins/ConsoleObject.ts @@ -122,54 +122,16 @@ interface ConsoleWriter extends Bun.FileSink { export function write(this: Console & { $writer: ConsoleWriter | undefined }, input) { if (!$isObject(this)) throw $ERR_INVALID_THIS("Console"); - // Same routing as console.log: if the console's stdout is something user code - // can observe (a worker's port stream, console._stdout = x, a patched - // process.stdout.write, ...) go through its write(); otherwise the shared - // native stdout sink. - var consoleStream = $getByIdDirectPrivate(this, "consoleStream"); - if (!consoleStream) { - consoleStream = $newCppFunction("BunProcess.cpp", "jsFunctionConsoleStream", 1); - $putByIdDirectPrivate(this, "consoleStream", consoleStream); - } - const observed = consoleStream(1); - const count = $argumentCount(); - if (observed) { - // Same error policy as console.log on this path (`writeToObservedStream` - // below, Node's ignoreErrors): a broken stream neither throws out of - // console.write nor becomes an unhandled 'error'. - const noop = () => {}; - const isEmitter = - typeof observed.listenerCount === "function" && - typeof observed.once === "function" && - typeof observed.removeListener === "function"; - let guarded = false; - var wrote = 0; - try { - if (isEmitter && observed.listenerCount("error") === 0) { - observed.once("error", noop); - guarded = true; - } - for (var i = 0; i < count; i++) { - const chunk = arguments[i]; - observed.write(chunk); - wrote += typeof chunk === "string" ? Buffer.byteLength(chunk) : $toLength(chunk?.byteLength ?? 0); - } - } catch (e: any) { - if (e?.name === "RangeError" && e?.message === "Maximum call stack size exceeded.") throw e; - } finally { - if (guarded) observed.removeListener("error", noop); - } - return wrote; - } - var writer = $getByIdDirectPrivate(this, "writer"); if (!writer) { - // The per-VM stdout sink, shared with console.log / process.stdout. - writer = Bun.stdout.writer(); + var length = $toLength(input?.length ?? 0); + writer = Bun.stdout.writer({ highWaterMark: length > 65536 ? length : 65536 }); $putByIdDirectPrivate(this, "writer", writer); } var wrote = writer.write(input) as number; + + const count = $argumentCount(); for (var i = 1; i < count; i++) { wrote += writer.write(arguments[i]) as number; } @@ -178,48 +140,6 @@ export function write(this: Console & { $writer: ConsoleWriter | undefined }, in return wrote; } -// The global console's slow path: user code can observe the write (patched -// `process.stdout.write`, `console._stdout = other`, corked/ended stream), so -// deliver the already-formatted message through `stream.write()` exactly like -// Node's kWriteToConsole with `ignoreErrors: true`. -// https://github.com/nodejs/node/blob/v24.0.0/lib/internal/console/constructor.js#L280-L322 -$visibility = "Private"; -export function writeToObservedStream(stream, chunk: string) { - const noop = () => {}; - // There may be an error occurring synchronously (e.g. for files or TTYs - // on POSIX systems) or asynchronously (e.g. pipes on POSIX systems), so - // handle both situations. - const isEmitter = - typeof stream?.listenerCount === "function" && - typeof stream?.once === "function" && - typeof stream?.removeListener === "function"; - let guarded = false; - try { - // Add and later remove a noop error handler to catch synchronous errors. - if (isEmitter && stream.listenerCount("error") === 0) { - stream.once("error", noop); - guarded = true; - } - stream.write(chunk, err => { - // Errors that were not already emitted (async _write callback) surface - // as an 'error' event; a `once` noop keeps that from becoming an - // uncaught exception without swallowing it for other writers. - if (err != null && isEmitter && !stream._writableState?.errorEmitted) { - if (stream.listenerCount("error") === 0) { - stream.once("error", noop); - } - } - }); - } catch (e: any) { - // Console is a debugging utility, so it swallowing errors is not - // desirable even in edge cases such as low stack space. - if (e?.name === "RangeError" && e?.message === "Maximum call stack size exceeded.") throw e; - // Sorry, there's no proper way to pass along the error here. - } finally { - if (guarded) stream.removeListener("error", noop); - } -} - // This is the `console.Console` constructor. It is mostly copied from Node. // https://github.com/nodejs/node/blob/d2c7c367741bdcb6f7f77f55ce95a745f0b29fef/lib/internal/console/constructor.js // Some parts are copied from imported files and inlined here. Not too much of a performance issue diff --git a/src/js/builtins/ProcessObjectInternals.ts b/src/js/builtins/ProcessObjectInternals.ts index 6fb515c9f8b8..8ea675755e15 100644 --- a/src/js/builtins/ProcessObjectInternals.ts +++ b/src/js/builtins/ProcessObjectInternals.ts @@ -38,8 +38,6 @@ export function getStdioWriteStream( ) { $assert(fd === 1 || fd === 2, `Expected fd to be 1 or 2, got ${fd}`); - // Both constructors below sit on the per-VM stdio FileSink for `fd` (via - // `Bun.file(fd).writer()`), the same object console.* writes through. let stream; if (isTTY) { const tty = require("node:tty"); @@ -67,126 +65,34 @@ export function getStdioWriteStream( // stdout/stderr don't produce readable data, so yield nothing })(); }; + } else { + // File-backed stdio: Node's SyncWriteStream runs end() -> finish -> + // destroy -> the _destroy override below -> _undestroy(), which resets + // writable state so later writes succeed. autoClose:false disabled that. + stream._writableState.autoDestroy = true; } } - // Node's stdio streams (net.Socket / SyncWriteStream) run with autoDestroy: - // end() -> finish -> destroy -> the _destroy override below -> _undestroy() - // resets state so later writes succeed, and a failed write goes - // errorOrDestroy -> destroy -> _undestroy -> 'error' *every* time instead - // of latching on errorEmitted after the first. autoClose:false turned it off. - stream._writableState.autoDestroy = true; - - // Node terminates on the first unhandled 'error'; Bun keeps running after an - // uncaught exception, so a write loop on a dead pipe would otherwise print - // one uncaught EPIPE per write. Surface the first, drop the repeats. - let unhandledErrorSeen = false; - stream._destroy = function (err, cb) { - if (err && this.listenerCount("error") === 0) { - if (unhandledErrorSeen) err = null; - unhandledErrorSeen = true; - } - cb(err); - this._undestroy(); - updateObserved(this); - - // `_undestroy()` above reset the state before Writable's own close path - // ran, so with emitClose off nothing would announce the (transient) - // destruction; keep it observable for `finished()` / `pipeline()`. - if (!this._writableState.emitClose) { - process.nextTick(() => { - this.emit("close"); - }); - } - }; + if (fd === 1 || fd === 2) { + stream.destroySoon = stream.destroy; + stream._destroy = function (err, cb) { + cb(err); + this._undestroy(); - const { kWriteStreamFastPath, kOnPendingWrite } = require("internal/fs/streams"); - - // console.* writes straight to the shared native sink unless doing so could - // be told apart from a `write()` call: while this Writable is corked, is - // ending/ended, or has a write in flight (later chunks queue in *its* - // buffer, and a native write would jump that queue). Keep the native side - // informed; it checks a bitfield, never these JS objects. - const setStdioObserved = $newCppFunction("BunProcess.cpp", "jsFunctionSetStdioObserved", 2); - let pendingWrite = false; - function updateObserved(stream) { - const state = stream._writableState; - setStdioObserved( - fd, - (state.corked ? 1 : 0) | (state.ending || state.ended || state.destroyed ? 2 : 0) | (pendingWrite ? 4 : 0), - ); + if (!this._writableState.emitClose) { + process.nextTick(() => { + this.emit("close"); + }); + } + }; } - stream[kOnPendingWrite] = function (pending) { - pendingWrite = pending; - updateObserved(this); - }; - const { cork: baseCork, uncork: baseUncork, end: baseEnd, destroy: baseDestroy } = stream; - // Same shape a user sees on any Writable: named, non-enumerable methods. - const hidden = (value: Function) => ({ value, writable: true, configurable: true, enumerable: false }); - Object.defineProperties(stream, { - cork: hidden(function cork(this: any) { - baseCork.$call(this); - updateObserved(this); - }), - uncork: hidden(function uncork(this: any) { - baseUncork.$call(this); - updateObserved(this); - }), - end: hidden(function end(this: any, ...args) { - const ret = baseEnd.$apply(this, args); - updateObserved(this); - return ret; - }), - destroy: hidden(function destroy(this: any, ...args) { - const ret = baseDestroy.$apply(this, args); - updateObserved(this); - return ret; - }), - }); - stream.destroySoon = stream.destroy; stream._isStdio = true; stream.fd = fd; - $assert(stream[kWriteStreamFastPath], "stdio stream must be FileSink-backed"); - return stream; -} - -// A console.* write on the shared stdio sink failed (EPIPE etc.). Node's -// console swallows write errors, but the stream still emits 'error' for -// whoever listens; with nobody listening it must not become an uncaught -// exception (that is what console's once('error', noop) guard is for). -// https://github.com/nodejs/node/blob/v24.0.0/lib/internal/console/constructor.js#L380-L399 -export function reportStdioSinkError(stream, err) { - if (typeof stream?.listenerCount === "function" && stream.listenerCount("error") > 0) { - stream.destroy(err); - } -} - -// process.exit() / end of program: chunks this stdio Writable had queued in -// JS behind an in-flight write are handed to the native sink now, so the exit -// drain (FileSink::drain_sync) writes them instead of dropping them. Chunks -// held by an explicit, never-released cork() stay held. -export function flushStdioWriteStreamOnExit(stream) { - const state = stream?._writableState; - if (!state || state.corked) return; - const sink = stream[require("internal/fs/streams").kWriteStreamFastPath]; - if (!sink || sink === true) return; - const buffered = $getByIdDirectPrivate(require("internal/streams/writable"), "takeBuffered")(state); - for (let i = 0; i < buffered.length; i++) { - const { chunk, encoding } = buffered[i]; - // A failure here (rejected promise: the sink's latched EPIPE/EIO; throw: a - // closed sink) was already surfaced by the write ahead of these chunks; - // the exit must go on. - try { - const result = sink.write( - typeof chunk === "string" && encoding !== "utf8" && encoding !== "utf-8" && encoding !== "buffer" - ? Buffer.from(chunk, encoding) - : chunk, - ); - if ($isPromise(result)) result.then(undefined, () => {}); - } catch {} - } + const underlyingSink = stream[require("internal/fs/streams").kWriteStreamFastPath]; + $assert(underlyingSink); + return [stream, underlyingSink]; } // node:worker_threads worker: process.stdout/stderr write to the parent Worker over a diff --git a/src/js/internal/fs/streams.ts b/src/js/internal/fs/streams.ts index b6b55e7d807a..8547718d143b 100644 --- a/src/js/internal/fs/streams.ts +++ b/src/js/internal/fs/streams.ts @@ -474,12 +474,9 @@ function WriteStream(this: FSStream, path: string | null | undefined, options?: if (fastPath) { this[kWriteStreamFastPath] = fd != null ? fastWriter : true; this._write = underscoreWriteFast; - this._writev = underscoreWritevFast; + this._writev = undefined; + this.write = writeFast as any; this._final = finalFast; - // The FileSink encodes strings straight into its own buffer, so skip the - // Buffer.from() round-trip. node's process.stdout/stderr (net.Socket) also - // run with decodeStrings: false. - options.decodeStrings = false; if (fd != null) { // Already-open fd (stdio): skip the async _construct round-trip so the // stream is born constructed, like node's stdio streams (net.Socket / @@ -605,28 +602,13 @@ function _write(data, encoding, cb) { } writeStreamPrototype._write = _write; -// `_write` for FileSink-backed streams (process.stdout/stderr, tty.WriteStream, -// child.stdin). Hands the chunk to the fd now: completes synchronously when it -// all went out, so back-to-back writes don't pile up in the Writable buffer, -// and on the sink's promise when the fd is backed up — which is what keeps -// writableLength / writableNeedDrain / 'drain' honest. `kOnPendingWrite`, if the owner set one, -// hears about a write going async and settling (process.stdout uses it to -// keep console.* from overtaking chunks queued behind that write). -const kOnPendingWrite = Symbol("kOnPendingWrite"); -// `FileSink.prototype.write` coalesces small chunks until end of tick — right -// for a batching `Bun.file(fd).writer()` and kept for `child.stdin` / -// `tty.WriteStream(fd)` (chatty pipe protocols); process.stdout/stderr's -// `_write` wants the syscall now like Node's, with the same return contract. -const fileSinkWriteNow = $newRustFunction("runtime/webcore/FileSink.rs", "writeNow", 2); -function underscoreWriteFast(this: FSStream, chunk: any, encoding: any, cb: any) { +function underscoreWriteFast(this: FSStream, data: any, encoding: any, cb: any) { let fileSink = this[kWriteStreamFastPath]; if (!fileSink) { // When the fast path is disabled, the write function gets reset. this._write = _write; - return this._write(chunk, encoding, cb); + return this._write(data, encoding, cb); } - - let maybePromise; try { if (fileSink === true) { fileSink = this[kWriteStreamFastPath] = Bun.file(this.path!).writer(); @@ -634,19 +616,28 @@ function underscoreWriteFast(this: FSStream, chunk: any, encoding: any, cb: any) this.fd = fileSink._getFd(); } - // decodeStrings is off for this path: the sink encodes UTF-8 itself, but - // every other encoding has to be decoded here. - if (typeof chunk === "string" && encoding !== "utf8" && encoding !== "utf-8") { - chunk = Buffer.from(chunk, encoding); + const maybePromise = fileSink.write(data); + if ($isPromise(maybePromise)) { + maybePromise.then( + () => { + if (cb) cb(null); + emitDrain(this); + }, + err => { + if (cb) cb(err); + require("internal/streams/destroy").errorOrDestroy(this, err); + }, + ); + return false; + } else { + if (cb) process.nextTick(cb, null); + return true; } - - maybePromise = this._isStdio === true ? fileSinkWriteNow(fileSink, chunk) : fileSink.write(chunk); } catch (e) { - cb(e); - return; + if (cb) process.nextTick(cb, e); + require("internal/streams/destroy").errorOrDestroy(this, e, true); + return false; } - - settleFastWrite(this, maybePromise, typeof chunk === "string" ? Buffer.byteLength(chunk) : chunk.byteLength, cb); } function finalFast(this: FSStream, cb: (err?: any) => void) { @@ -668,42 +659,59 @@ function finalFast(this: FSStream, cb: (err?: any) => void) { cb(null); } -function settleFastWrite(stream, maybePromise, size, cb) { - if ($isPromise(maybePromise)) { - const onPending = stream[kOnPendingWrite]; - if (onPending) onPending.$call(stream, true); - maybePromise.then( - () => { - stream.bytesWritten += size; - if (onPending) onPending.$call(stream, false); - cb(null); - }, - err => { - if (onPending) onPending.$call(stream, false); - cb(err); - }, - ); - } else { - stream.bytesWritten += size; - cb(null); +// This function implementation is not correct. +const writablePrototypeWrite = Writable.prototype.write; +const kWriteMonkeyPatchDefense = Symbol("!"); +function writeFast(this: FSStream, data: any, encoding: any, cb: any) { + if (this[kWriteMonkeyPatchDefense]) return writablePrototypeWrite.$call(this, data, encoding, cb); + + // After end()/destroy() the Writable contract requires write() to fail with + // ERR_STREAM_WRITE_AFTER_END / ERR_STREAM_DESTROYED and not reach the sink. + const state = this._writableState; + if (state !== undefined && (state.ending || state.destroyed)) { + return writablePrototypeWrite.$call(this, data, encoding, cb); } -} -// `_writev` for the same streams. A corked burst (and a backlog drained after -// backpressure) reaches the fd as one write — what cork() is for; node's stdio -// over a pipe coalesces the same burst into a single writev(2). -function underscoreWritevFast(this: FSStream, data: any, cb: any) { - const len = data.length; - if (len === 1) { - const { chunk, encoding } = data[0]; - return underscoreWriteFast.$call(this, chunk, encoding, cb); + if (typeof encoding === "function") { + cb = encoding; + encoding = undefined; } - const chunks = new Array(len); - for (let i = 0; i < len; i++) { - const { chunk, encoding } = data[i]; - chunks[i] = typeof chunk === "string" ? Buffer.from(chunk, encoding) : chunk; + if (typeof cb !== "function") { + cb = streamNoop; + } + + const fileSink = this[kWriteStreamFastPath]; + if (fileSink && fileSink !== true) { + const maybePromise = fileSink.write(data); + if ($isPromise(maybePromise)) { + // Two-arg then(): a throw from the fulfillment handler must not be + // mistaken for a write failure. + maybePromise.then( + () => { + emitDrain(this); + cb(null); + }, + err => { + cb(err); + // Node.js onwriteError: callback AND destroy are both invoked; the + // callback is additive, not a replacement for the 'error' event. + require("internal/streams/destroy").errorOrDestroy(this, err); + }, + ); + return false; // Indicate backpressure + } else { + cb(null); + return true; // No backpressure + } + } else { + const result: any = this._write!(data, encoding, cb); + if (this.write === writeFast) { + this.write = writablePrototypeWrite; + } else { + this[kWriteMonkeyPatchDefense] = true; + } + return result; } - return underscoreWriteFast.$call(this, Buffer.concat(chunks), "buffer", cb); } writeStreamPrototype._writev = function (data, cb) { @@ -834,6 +842,5 @@ export default { ReadStream, WriteStream, kWriteStreamFastPath, - kOnPendingWrite, writableFromFileSink, }; diff --git a/src/js/internal/streams/legacy.ts b/src/js/internal/streams/legacy.ts index 3979f635492f..afbe107f6a2d 100644 --- a/src/js/internal/streams/legacy.ts +++ b/src/js/internal/streams/legacy.ts @@ -1,7 +1,7 @@ "use strict"; const EE = require("node:events"); -const { isArrayBufferView, isUint8Array, isAnyArrayBuffer } = require("node:util/types"); +const { isArrayBufferView, isUint8Array } = require("node:util/types"); const ReflectOwnKeys = Reflect.ownKeys; const ArrayIsArray = Array.isArray; @@ -119,7 +119,6 @@ function prependListener(emitter, event, fn) { // Add helper methods to Stream Stream._isArrayBufferView = isArrayBufferView; -Stream._isAnyArrayBuffer = isAnyArrayBuffer; Stream._isUint8Array = isUint8Array; Stream._uint8ArrayToBuffer = function _uint8ArrayToBuffer(chunk) { return new $Buffer(chunk.buffer, chunk.byteOffset, chunk.byteLength); diff --git a/src/js/internal/streams/writable.ts b/src/js/internal/streams/writable.ts index 85e6191c3ccc..d192409e5c13 100644 --- a/src/js/internal/streams/writable.ts +++ b/src/js/internal/streams/writable.ts @@ -322,19 +322,6 @@ function resetBuffer(state) { state[kState] &= ~kBuffered; } -// Detach and return whatever is queued behind the in-flight write (their -// callbacks will never run). For handing the chunks to another sink when this -// stream is being abandoned, e.g. stdio at process exit. -function takeBuffered(state) { - if ((state[kState] & kBuffered) === 0) return []; - const buffered = ArrayPrototypeSlice.$call(state.buffered, state.bufferedIndex); - for (let i = 0; i < buffered.length; i++) { - state.length -= (state[kState] & kObjectMode) !== 0 ? 1 : buffered[i].chunk.length; - } - resetBuffer(state); - return buffered; -} - WritableState.prototype.getBuffer = function getBuffer() { return (this[kState] & kBuffered) === 0 ? [] : ArrayPrototypeSlice.$call(this.buffered, this.bufferedIndex); }; @@ -398,9 +385,6 @@ function Writable(options): void { $toClass(Writable, "Writable", Stream); Writable.WritableState = WritableState; -// Internal-only (process stdio at exit); a private name keeps it off the public -// `stream.Writable` surface. -$putByIdDirectPrivate(Writable, "takeBuffered", takeBuffered); ObjectDefineProperty(Writable, SymbolHasInstance, { __proto__: null, @@ -448,15 +432,6 @@ function _write(stream, chunk, encoding, cb?) { } else if (Stream._isArrayBufferView(chunk)) { chunk = Stream._uint8ArrayToBuffer(chunk); encoding = "buffer"; - } else if ( - Stream._isAnyArrayBuffer(chunk) && - (stream._isStdio === true || stream[require("internal/fs/streams").kWriteStreamFastPath]) - ) { - // Bun's FileSink-backed streams (process.stdout/stderr, child.stdin, - // tty.WriteStream(fd)) have always taken a bare (Shared)ArrayBuffer; keep - // accepting it there (Node rejects it, so nothing relies on the throw). - chunk = new Uint8Array(chunk); - encoding = "buffer"; } else { throw $ERR_INVALID_ARG_TYPE("chunk", ["string", "Buffer", "TypedArray", "DataView"], chunk); } diff --git a/src/js/node/diagnostics_channel.ts b/src/js/node/diagnostics_channel.ts index cc5a1bf3ef55..4c57694ffd2f 100644 --- a/src/js/node/diagnostics_channel.ts +++ b/src/js/node/diagnostics_channel.ts @@ -62,7 +62,6 @@ function markActive(channel) { ObjectSetPrototypeOf.$call(null, channel, ActiveChannel.prototype); channel._subscribers = []; channel._stores = new SafeMap(); - updateConsoleChannel(channel, true); } function maybeMarkInactive(channel) { @@ -71,33 +70,9 @@ function maybeMarkInactive(channel) { ObjectSetPrototypeOf.$call(null, channel, Channel.prototype); channel._subscribers = undefined; channel._stores = undefined; - updateConsoleChannel(channel, false); } } -// The global console publishes its arguments on these channels before -// formatting, but only while somebody is subscribed: -// https://github.com/nodejs/node/blob/v24.0.0/lib/internal/console/constructor.js#L66-L70 -// The console is native, so tell it which of the five currently have -// subscribers (a bitmask it can test for free) and hand it one function to -// publish through. -const kConsoleChannelNames = ["console.log", "console.warn", "console.error", "console.debug", "console.info"]; -let consoleChannelMask = 0; -let setConsoleChannels; -function publishToConsoleChannel(index: number, args: unknown[]) { - channels.get(kConsoleChannelNames[index])?.publish(args); -} -function updateConsoleChannel(channel, active: boolean) { - const index = ArrayPrototypeIndexOf.$call(kConsoleChannelNames, channel.name); - if (index === -1) return; - const bit = 1 << index; - const next = active ? consoleChannelMask | bit : consoleChannelMask & ~bit; - if (next === consoleChannelMask) return; - consoleChannelMask = next; - setConsoleChannels ??= $newCppFunction("BunProcess.cpp", "jsFunctionSetConsoleChannels", 2); - setConsoleChannels(consoleChannelMask, publishToConsoleChannel); -} - function defaultTransform(data) { return data; } diff --git a/src/js/node/worker_threads.ts b/src/js/node/worker_threads.ts index 17c7d534ff1c..12f2db6542d8 100644 --- a/src/js/node/worker_threads.ts +++ b/src/js/node/worker_threads.ts @@ -346,16 +346,16 @@ const { makePortReadable, makePortWritable } = require("internal/worker/stdio"); // thread would get); read them here so they exist before user code, as in node. function setupWorkerStdio(stdio) { _setStdioPorts(stdio); - // Point the native console at the port streams, which is what routes a - // worker's console.log to the parent (Node: console._stdout is - // process.stdout, which in a worker is a port-backed Writable). - const setConsoleStream = $newCppFunction("BunProcess.cpp", "jsFunctionSetConsoleStream", 2); - setConsoleStream(1, process.stdout); - setConsoleStream(2, process.stderr); + const stdoutStream = process.stdout; + const stderrStream = process.stderr; // node always replaces a worker's process.stdin: port-backed when { stdin: true }, // otherwise an immediately-EOF'd stream — never the process-wide fd 0, which // would race the main thread (and hang on a TTY). void process.stdin; + // node routes console.log through process.stdout/stderr; Bun's global console + // writes the fd directly, so rebind it to the port-backed streams. + const { Console } = require("node:console"); + (globalThis as { console: unknown }).console = new Console(stdoutStream, stderrStream); } // Emulation of Node's JSTransferable protocol (kTransfer/kTransferList/kDeserialize) for diff --git a/src/jsc/ConsoleObject.rs b/src/jsc/ConsoleObject.rs index d8bc4a91668c..2991950f5a28 100644 --- a/src/jsc/ConsoleObject.rs +++ b/src/jsc/ConsoleObject.rs @@ -3,17 +3,16 @@ //! `console.count`/`time`/`timeEnd`, and the C ABI shims that JavaScriptCore //! calls into. -use crate::{ComptimeStringMapExt as _, StringJsc as _}; +use crate::ComptimeStringMapExt as _; use core::cell::{Cell, RefCell}; use core::ffi::c_void; use crate as jsc; use crate::virtual_machine::VirtualMachine; -use crate::{EventType, JSGlobalObject, JSPromise, JSValue, JsError, JsResult}; +use crate::{EventType, JSGlobalObject, JSPromise, JSValue, JsResult}; use bun_collections::HashMap; use bun_core::{EncodedSlice, String as BunString, strings}; use bun_core::{Output, StackCheck}; -use bun_io::Write as _; /// Thin facade over `bun_js_parser::lexer` / `bun_js_printer` so the call /// sites below can use the `JSLexer.isLatin1Identifier` / @@ -89,17 +88,19 @@ const DEFAULT_CONSOLE_LOG_DEPTH: u16 = 2; type Counter = HashMap; pub struct ConsoleObject { - /// A console message is formatted in full into this buffer and only then - /// handed to the stdio sink (or, when the stream is observed by user code, - /// to JS) — the same shape as Node's `formatWithOptions` → `stream.write`. - /// Taken (`mem::take`) for the duration of a call so a `console.*` that - /// re-enters during formatting (a getter that logs) formats into its own - /// buffer and is emitted first, as in Node. - scratch: Vec, + stderr_buffer: [u8; 4096], + stdout_buffer: [u8; 4096], + + error_writer_backing: Output::QuietWriterAdapter, + writer_backing: Output::QuietWriterAdapter, pub(crate) default_indent: u16, counts: Counter, + + // The writer adapters above hold raw pointers into `{stderr,stdout}_buffer`; + // moving the struct would dangle them, so opt out of `Unpin`. + _pin: core::marker::PhantomPinned, } impl core::fmt::Display for ConsoleObject { @@ -109,282 +110,54 @@ impl core::fmt::Display for ConsoleObject { } } -impl Default for ConsoleObject { - fn default() -> Self { - Self::new() - } -} - impl ConsoleObject { - pub fn new() -> ConsoleObject { - ConsoleObject { - scratch: Vec::new(), + /// `adapt_to_new_api(&mut self.stderr_buffer)` captures a raw pointer into + /// the buffer field, so the struct is self-referential once initialized: + /// the address of `*out` MUST be stable for the value's entire lifetime — + /// moving it afterwards leaves the writer adapters dangling. + pub(crate) fn init_in_place( + out: &mut core::mem::MaybeUninit, + error_writer: Output::StreamType, + writer: Output::StreamType, + ) -> &mut ConsoleObject { + let out = out.write(ConsoleObject { + stderr_buffer: [0; 4096], + stdout_buffer: [0; 4096], + error_writer_backing: Output::QuietWriterAdapter::uninit(), + writer_backing: Output::QuietWriterAdapter::uninit(), default_indent: 0, counts: Counter::default(), - } - } - - #[inline] - fn take_scratch(this: *mut ConsoleObject) -> Vec { - // SAFETY: `this` is the live per-VM console (see [`vm_console`]); - // single field move-out, no reference held past this statement. - unsafe { core::mem::take(&mut (*this).scratch) } - } - - #[inline] - fn put_scratch(this: *mut ConsoleObject, mut buf: Vec) { - buf.clear(); - // The native path never holds more than `SPILL_AT`; only a message that - // had to be handed to JS whole (or one oversized chunk) grows past it, - // and that one shouldn't pin its buffer for the life of the VM. - if buf.capacity() > SPILL_AT { - buf.shrink_to(SPILL_AT); - } - // SAFETY: as above. If a re-entrant call left its (smaller) buffer - // here, keep whichever is larger. + _pin: core::marker::PhantomPinned, + }); + let p: *mut ConsoleObject = out; + // SAFETY: `out` is now fully initialized at its final address; the + // adapters store raw pointers into `out.stderr_buffer` / + // `out.stdout_buffer`, which remain valid for `out`'s lifetime + // *provided the caller never moves it* (see fn doc). unsafe { - if (*this).scratch.capacity() < buf.capacity() { - (*this).scratch = buf; - } - } + (*p).error_writer_backing = error_writer + .quiet_writer() + .adapt_to_new_api(&mut (*p).stderr_buffer); + (*p).writer_backing = writer + .quiet_writer() + .adapt_to_new_api(&mut (*p).stdout_buffer); + } + out } -} -// ─────────────────────────────────────────────────────────────────────────── -// Delivery: stdio sink (fast) or the observed JS stream (Node's kWriteToConsole) -// ─────────────────────────────────────────────────────────────────────────── - -unsafe extern "Rust" { - /// `bun_runtime::webcore::file_sink::__bun_stdio_sink_write` — hand one - /// whole console message to this VM's stdio sink for `fd`; returns once it - /// has reached the fd (queued `process.stdout` bytes go out first). - fn __bun_stdio_sink_write( - vm: *mut VirtualMachine, - fd: bun_sys::Fd, - bytes: &[u8], - ) -> Result<(), bun_sys::Error>; - /// `bun_runtime::webcore::file_sink::__bun_stdio_sink_drain_fd` — flush - /// this fd's stdio sink queue now (no-op if it doesn't exist). - fn __bun_stdio_sink_drain_fd(vm: *mut VirtualMachine, fd: bun_sys::Fd); -} - -unsafe extern "C" { - /// `BunProcess.cpp` — the stream the console must go through via JS - /// `write()` for `fd`, or the empty value when the native sink may be used. - /// A structure compare in the steady state; the very first call may run - /// (and rethrow from) a user getter installed on `process.stdout`. - fn Bun__Process__consoleStream(global: &JSGlobalObject, fd: i32, threw: &mut bool) -> JSValue; - /// `BunProcess.cpp` — `consoleObjectWriteToObservedStream(stream, chunk)`. - fn Bun__Console__writeToStream(global: &JSGlobalObject, stream: JSValue, chunk: JSValue); - /// `BunProcess.cpp` — the console's stream for `fd` is a foreign object - /// (not Bun's own stdio stream). Never runs user code. - safe fn Bun__Process__consoleStreamIsCustom(global: &JSGlobalObject, fd: i32) -> bool; - /// `BunProcess.cpp` — the console's stream *object* for `fd` if any exists - /// (custom binding or Bun's materialised stream), else empty. For - /// `console.clear()`'s `isTTY` check. Same throw contract as - /// `Bun__Process__consoleStream`. - fn Bun__Process__consoleStreamObject( - global: &JSGlobalObject, - fd: i32, - threw: &mut bool, - ) -> JSValue; -} - -/// Which of the two console streams a message goes to. Node: -/// `log/info/debug/dir/dirxml/table/count/timeLog/timeEnd/group` → stdout, -/// `warn/error/trace/assert` → stderr. -#[derive(Clone, Copy, PartialEq, Eq)] -pub enum ConsoleStream { - Stdout, - Stderr, -} - -impl ConsoleStream { - /// 1 or 2 — the number `BunProcess.cpp` keys its per-stream state by. + /// Returns the buffered stderr writer interface. #[inline] - pub fn number(self) -> i32 { - match self { - ConsoleStream::Stdout => 1, - ConsoleStream::Stderr => 2, - } + pub(crate) fn error_writer(&mut self) -> &mut bun_core::io::Writer { + self.error_writer_backing.new_interface() } + /// Returns the buffered stdout writer interface. #[inline] - pub fn fd(self) -> bun_sys::Fd { - match self { - ConsoleStream::Stdout => bun_sys::Fd::stdout(), - ConsoleStream::Stderr => bun_sys::Fd::stderr(), - } - } - - #[inline] - pub fn colors(self) -> bool { - match self { - ConsoleStream::Stdout => Output::enable_ansi_colors_stdout(), - ConsoleStream::Stderr => Output::enable_ansi_colors_stderr(), - } - } - - #[inline] - fn for_message(message_type: MessageType, level: MessageLevel) -> Self { - if matches!(level, MessageLevel::Warning | MessageLevel::Error) - || matches!(message_type, MessageType::Assert | MessageType::Trace) - { - ConsoleStream::Stderr - } else { - ConsoleStream::Stdout - } - } -} - -/// Where one console message is going: the stdio sink, or — when user code -/// could tell the difference — the bound stream's JS `write()` (Node's -/// `kWriteToConsole`). -#[inline] -fn console_target(global: &JSGlobalObject, stream: ConsoleStream) -> JsResult { - let mut threw = false; - // SAFETY: plain FFI; `threw` is its throw contract. - let target = unsafe { Bun__Process__consoleStream(global, stream.number(), &mut threw) }; - if threw { - Err(JsError::Thrown) - } else { - Ok(target) - } -} - -/// Whether to colour a message bound for `target`: Bun's own fd 1/2 (native -/// or observed) → the usual per-fd answer; a foreign stream (worker port, -/// `console._stdout = x`) → `FORCE_COLOR`/`NO_COLOR`, else its own `isTTY`, -/// as Node's `Console` does for `colorMode: 'auto'`. -fn console_colors( - global: &JSGlobalObject, - stream: ConsoleStream, - target: JSValue, -) -> JsResult { - if target.is_empty() || !Bun__Process__consoleStreamIsCustom(global, stream.number()) { - return Ok(stream.colors()); - } - if let Some(forced) = Output::env_color_override() { - return Ok(forced); - } - if !target.is_object() { - return Ok(false); - } - Ok(target - .get(global, b"isTTY")? - .is_some_and(|v| v.to_boolean())) -} - -/// Deliver one whole formatted message to `target` (see [`console_target`]). -/// Errors reaching the fd (EPIPE, ...) are the sink's to surface on -/// `process.stdout`/`stderr`; the console itself never throws for them -/// (`ignoreErrors: true`). -fn deliver_to( - global: &JSGlobalObject, - stream: ConsoleStream, - target: JSValue, - bytes: &[u8], -) -> JsResult<()> { - if bytes.is_empty() { - return Ok(()); - } - let vm: *mut VirtualMachine = global.bun_vm().as_mut(); - if target.is_empty() { - // SAFETY: `vm` is the live per-thread VM that owns `global`. - let _ = unsafe { __bun_stdio_sink_write(vm, stream.fd(), bytes) }; - return Ok(()); - } - // Switching to JS delivery: whatever the native side still has queued for - // this fd must land first or it would come out after this message. - // SAFETY: as above. - unsafe { __bun_stdio_sink_drain_fd(vm, stream.fd()) }; - let chunk = bun_core::String::borrow_utf8(bytes).to_js(global)?; - crate::from_js_host_call_generic(global, || { - // SAFETY: plain FFI; both values are live on this stack. - unsafe { Bun__Console__writeToStream(global, target, chunk) } - }) -} - -pub fn deliver(global: &JSGlobalObject, stream: ConsoleStream, bytes: &[u8]) -> JsResult<()> { - let target = console_target(global, stream)?; - deliver_to(global, stream, target, bytes) -} - -/// The writer console formatting goes through. Normally just the scratch -/// buffer; on the native path a message that outgrows [`SPILL_AT`] is written -/// out as it is produced (bounded memory, and a giant `console.log` starts -/// appearing before it is fully formatted) — the observed-stream path must -/// hand JS one string, as Node does, so it never spills. -pub struct ConsoleWriter<'a> { - buf: &'a mut Vec, - spill: Option<(*mut VirtualMachine, bun_sys::Fd)>, - /// A spill already failed (EPIPE, ...) and was reported: the rest of this - /// message is dropped rather than re-failing (and re-reporting) per block. - failed: bool, -} - -/// How much of one message to accumulate before writing it out on the native -/// path (and so the most scratch capacity kept between calls): the default -/// pipe capacity on Linux and macOS, so each spill is at most one pipe-full and -/// anything shorter is still a single `write(2)`. -const SPILL_AT: usize = 64 * 1024; - -impl bun_io::Write for ConsoleWriter<'_> { - #[inline] - fn write_all(&mut self, bytes: &[u8]) -> bun_core::CrateResult<()> { - if self.failed { - return Ok(()); - } - self.buf.extend_from_slice(bytes); - if self.buf.len() >= SPILL_AT { - if let Some((vm, fd)) = self.spill { - // SAFETY: `vm` is the live per-thread VM (set in `emit`). - self.failed = unsafe { __bun_stdio_sink_write(vm, fd, self.buf) }.is_err(); - self.buf.clear(); - } - } - Ok(()) + pub(crate) fn writer(&mut self) -> &mut bun_core::io::Writer { + self.writer_backing.new_interface() } } -/// Format a message with `f` and deliver it. If `f` fails (a JS exception -/// thrown while formatting — a throwing getter, `toJSON`, ...) the exception -/// propagates and nothing further is written, as in Node — except that on the -/// native path a message already past [`SPILL_AT`] has had its earlier 64 KiB -/// blocks written, so the reader sees it torn (bounded memory wins there). -pub fn emit( - global: &JSGlobalObject, - stream: ConsoleStream, - f: impl FnOnce(&mut ConsoleWriter<'_>, bool) -> JsResult<()>, -) -> JsResult<()> { - let target = console_target(global, stream)?; - let native = target.is_empty(); - let colors = console_colors(global, stream, target)?; - // No lock here: formatting runs user JS (getters, toJSON, inspect.custom) - // that may log to the *other* stream or exit; each write / spill takes the - // per-fd lock for itself (`FileSink::write_all_sync`). - - let console = vm_console(global); - let mut buf = ConsoleObject::take_scratch(console); - let mut writer = ConsoleWriter { - buf: &mut buf, - spill: native.then(|| { - ( - std::ptr::from_mut::(global.bun_vm().as_mut()), - stream.fd(), - ) - }), - failed: false, - }; - let result = match f(&mut writer, colors) { - Ok(()) if writer.failed => Ok(()), - Ok(()) => deliver_to(global, stream, target, &buf), - Err(err) => Err(err), - }; - ConsoleObject::put_scratch(console, buf); - result -} - #[repr(u32)] #[derive(Copy, Clone, Eq, PartialEq, strum::IntoStaticStr)] pub enum MessageLevel { @@ -465,6 +238,8 @@ impl MessageType { // Bun__ConsoleObject__* shims. // ─────────────────────────────────────────────────────────────────────────── +use bun_threading::Mutex; + /// `globalThis.bunVM().console` — `VirtualMachine.console` is typed /// `*mut c_void` (erased so `virtual_machine.rs` need not name this module's /// type). This is the single re-entry point that casts it back; every body @@ -495,6 +270,69 @@ unsafe fn vm_console_mut<'a>(global: &JSGlobalObject) -> &'a mut ConsoleObject { unsafe { &mut *vm_console(global) } } +#[cfg(unix)] +unsafe extern "Rust" { + /// `bun_runtime::webcore::FileSink`: what `process.stdout` / `process.stderr` queued for `fd` goes out before the console writes to it. + safe fn __bun_flush_stdio_sink(global: &JSGlobalObject, fd: bun_sys::Fd); +} + +static STDERR_MUTEX: Mutex = Mutex::new(); +static STDOUT_MUTEX: Mutex = Mutex::new(); + +thread_local! { + static STDERR_LOCK_COUNT: Cell = const { Cell::new(0) }; + static STDOUT_LOCK_COUNT: Cell = const { Cell::new(0) }; +} + +/// RAII guard for the per-stream reentrant console lock. Acquires on +/// construction (incrementing the thread-local count and locking the global +/// mutex on first entry), releases on `Drop` (decrementing and unlocking on +/// last exit). +struct ConsoleStreamLock { + use_stderr: bool, +} + +impl ConsoleStreamLock { + fn acquire(use_stderr: bool) -> Self { + if use_stderr { + STDERR_LOCK_COUNT.with(|c| { + if c.get() == 0 { + STDERR_MUTEX.lock(); + } + c.set(c.get() + 1); + }); + } else { + STDOUT_LOCK_COUNT.with(|c| { + if c.get() == 0 { + STDOUT_MUTEX.lock(); + } + c.set(c.get() + 1); + }); + } + Self { use_stderr } + } +} + +impl Drop for ConsoleStreamLock { + fn drop(&mut self) { + if self.use_stderr { + STDERR_LOCK_COUNT.with(|c| { + c.set(c.get() - 1); + if c.get() == 0 { + STDERR_MUTEX.unlock(); + } + }); + } else { + STDOUT_LOCK_COUNT.with(|c| { + c.set(c.get() - 1); + if c.get() == 0 { + STDOUT_MUTEX.unlock(); + } + }); + } + } +} + /// RAII flush of a borrowed `bun_io::Write` at scope exit when `enabled`. /// /// Owns the `&mut dyn Write` for its lifetime; the body of the scope must @@ -573,32 +411,70 @@ fn message_with_type_and_level_( return Ok(()); } - let stream = ConsoleStream::for_message(message_type, level); + // Lock/unlock a mutex incase two JS threads are console.log'ing at the same + // 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; + let _stream_lock = ConsoleStreamLock::acquire(use_stderr); if message_type == MessageType::Clear { - // Node: only when `this._stdout.isTTY`, `cursorTo(0, 0)` + `clearScreenDown()`. - // https://github.com/nodejs/node/blob/v24.0.0/lib/internal/console/constructor.js#L487-L501 - let mut threw = false; - // SAFETY: plain FFI; `threw` is its throw contract. - let stream_object = unsafe { Bun__Process__consoleStreamObject(global, 1, &mut threw) }; - if threw { - return Err(JsError::Thrown); - } - let is_tty = if stream_object.is_empty() { - Output::is_stdout_tty() + Output::reset_terminal(); + return Ok(()); + } + + if message_type == MessageType::Assert && len == 0 { + let text: &str = if Output::enable_ansi_colors_stderr() { + pfmt!("Assertion failed\n", true) } else { - stream_object - .get(global, b"isTTY")? - .is_some_and(|v| v.to_boolean()) + "Assertion failed\n" }; - if is_tty && bun_core::env_var::TERM::get().is_none_or(|t| t != b"dumb") { - return deliver(global, ConsoleStream::Stdout, b"\x1b[1;1H\x1b[0J"); - } + // SAFETY: no other borrow of the console is live in this + // early-return arm (the deferred `_indent_guard` only holds the raw + // pointer, not a reference). + let ew = unsafe { vm_console_mut(global) }.error_writer(); + let _ = ew.write_all(text.as_bytes()); + let _ = ew.flush(); return Ok(()); } + let enable_colors = if matches!(level, MessageLevel::Warning | MessageLevel::Error) { + Output::enable_ansi_colors_stderr() + } else { + 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 { (*console).default_indent }; + 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 + // long-lived `&mut ConsoleObject` across the re-derive in the empty-`Log` + // arm below. + let raw_writer: &mut bun_core::io::Writer = unsafe { + if matches!(level, MessageLevel::Warning | MessageLevel::Error) { + (*console).error_writer() + } else { + (*console).writer() + } + }; + // `bun_core::io::Writer: bun_io::Write` — `&mut Writer` unsize-coerces directly. + let writer: &mut dyn bun_io::Write = raw_writer; + + #[cfg(unix)] + __bun_flush_stdio_sink( + global, + if matches!(level, MessageLevel::Warning | MessageLevel::Error) { + bun_sys::Fd::stderr() + } else { + bun_sys::Fd::stdout() + }, + ); + // LAYERING: `Jest::runner()` lives in `bun_runtime::test_runner` (forward // dep on the high tier). Dispatch through `RuntimeHooks` instead — the // high-tier hook checks `Jest.runner` and calls `onBeforePrint()`; no-op @@ -607,112 +483,96 @@ fn message_with_type_and_level_( (hooks.console_on_before_print)(); } + 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() + .and_then(|ctx| ctx.runtime_options.console_depth) + .unwrap_or(DEFAULT_CONSOLE_LOG_DEPTH); + + let mut print_options = FormatOptions { + enable_colors, + add_newline: true, + flush: true, + default_indent, + max_depth: console_depth, + error_display_level: match level { + MessageLevel::Error => ErrorDisplayLevel::Full, + MessageLevel::Warning => ErrorDisplayLevel::Warn, + _ => ErrorDisplayLevel::Normal, + }, + ..FormatOptions::default() + }; + // SAFETY: caller (JSC C++) guarantees `vals` points to `len` JSValues. let vals_slice = unsafe { bun_core::ffi::slice(vals, len) }; - emit(global, stream, |writer, enable_colors| { - if message_type == MessageType::Assert { - // Node prefixes the first argument and forwards to `warn`, so the - // prefix takes part in the same `%s` substitution pass: - // https://github.com/nodejs/node/blob/v24.0.0/lib/internal/console/constructor.js#L474-L485 - let first_is_string = len > 0 && vals_slice[0].is_string(); - let text: &str = match (len == 0, first_is_string, enable_colors) { - (true, _, true) => pfmt!("Assertion failed\n", true), - (true, _, false) => "Assertion failed\n", - // `Assertion failed: ${args[0]}` when it's a string, - (false, true, true) => pfmt!("Assertion failed: ", true), - (false, true, false) => "Assertion failed: ", - // else 'Assertion failed' is unshifted as its own argument. - (false, false, true) => pfmt!("Assertion failed ", true), - (false, false, false) => "Assertion failed ", + if message_type == MessageType::Table && len >= 1 { + // if value is not an object/array/iterable, don't print a table and just print it + let tabular_data = vals_slice[0]; + if tabular_data.is_object() { + let properties: JSValue = if len >= 2 && vals_slice[1].js_type().is_array() { + vals_slice[1] + } else { + JSValue::UNDEFINED }; - let _ = writer.write_all(text.as_bytes()); - if len == 0 { - return Ok(()); - } - } - - 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() - .and_then(|ctx| ctx.runtime_options.console_depth) - .unwrap_or(DEFAULT_CONSOLE_LOG_DEPTH); + let mut table_printer = TablePrinter::init(global, level, tabular_data, properties)?; + table_printer.value_formatter.indent += u32::from(default_indent); - let mut print_options = FormatOptions { - enable_colors, - add_newline: true, - flush: false, - default_indent, - max_depth: console_depth, - error_display_level: match level { - MessageLevel::Error => ErrorDisplayLevel::Full, - MessageLevel::Warning => ErrorDisplayLevel::Warn, - _ => ErrorDisplayLevel::Normal, - }, - ..FormatOptions::default() - }; - - if message_type == MessageType::Table && len >= 1 { - // if value is not an object/array/iterable, don't print a table and just print it - let tabular_data = vals_slice[0]; - if tabular_data.is_object() { - let properties: JSValue = if len >= 2 && vals_slice[1].js_type().is_array() { - vals_slice[1] - } else { - JSValue::UNDEFINED - }; - let mut table_printer = - TablePrinter::init(global, level, tabular_data, properties)?; - table_printer.value_formatter.indent += u32::from(default_indent); - - if enable_colors { - let _ = table_printer.print_table::(writer); - } else { - let _ = table_printer.print_table::(writer); - } - return Ok(()); + if enable_colors { + let _ = table_printer.print_table::(writer); + } else { + let _ = table_printer.print_table::(writer); } + let _ = writer.flush(); + return Ok(()); } + } - if message_type == MessageType::Dir && len >= 2 { - print_length = 1; - let opts = vals_slice[1]; - if opts.is_object() { - if let Some(depth_prop) = opts.get(global, b"depth")? { - if depth_prop.is_int32() || depth_prop.is_number() || depth_prop.is_big_int() { - // Clamp negatives to 0, then truncate (not saturate) to u16. - print_options.max_depth = depth_prop.to_int32().max(0) as u32 as u16; - } else if depth_prop.is_null() { - print_options.max_depth = u16::MAX; - } + if message_type == MessageType::Dir && len >= 2 { + print_length = 1; + let opts = vals_slice[1]; + if opts.is_object() { + if let Some(depth_prop) = opts.get(global, b"depth")? { + if depth_prop.is_int32() || depth_prop.is_number() || depth_prop.is_big_int() { + // Clamp negatives to 0, then truncate (not saturate) to u16. + print_options.max_depth = depth_prop.to_int32().max(0) as u32 as u16; + } else if depth_prop.is_null() { + print_options.max_depth = u16::MAX; } - if let Some(colors_prop) = opts.get(global, b"colors")? { - if colors_prop.is_boolean() { - print_options.enable_colors = colors_prop.to_boolean(); - } + } + if let Some(colors_prop) = opts.get(global, b"colors")? { + if colors_prop.is_boolean() { + print_options.enable_colors = colors_prop.to_boolean(); } } } + } - if print_length > 0 { - format2( - level, - global, - &vals_slice[..print_length], - writer, - print_options, - )?; - } else if message_type == MessageType::Log { - let _ = writer.write_all(b"\n"); - } else if message_type != MessageType::Trace { - let _ = writer.write_all(b"undefined\n"); - } + if print_length > 0 { + format2( + level, + global, + &vals_slice[..print_length], + writer, + 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(); + } else if message_type != MessageType::Trace { + let _ = writer.write_all(b"undefined\n"); + } - if message_type == MessageType::Trace { - write_trace(writer, global); - } - Ok(()) - }) + if message_type == MessageType::Trace { + write_trace(writer, global); + let _ = writer.flush(); + } + + Ok(()) } // ─────────────────────────────────────────────────────────────────────────── @@ -5899,41 +5759,37 @@ pub(crate) 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); - let current: u32 = { - // SAFETY: top-level JS-thread host call ⇒ exclusive access to the - // set-once `VirtualMachine.console` box; borrow ends before `emit`. - 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 = if counter.found_existing { - *counter.value_ptr - } else { - 0 - } + 1; - *counter.value_ptr = current; - current - }; - - let _ = emit(global_this, ConsoleStream::Stdout, |writer, colors| { - if colors { - let _ = writeln!( - writer, - "{}{}{}: {}{}{}", - pfmt!("", true), - bstr::BStr::new(slice), - pfmt!("", true), - pfmt!("", true), - current, - pfmt!("", true), - ); - } else { - let _ = writeln!(writer, "{}: {}", bstr::BStr::new(slice), current); - } - Ok(()) - }); + // 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; + + let writer = this.writer(); + if Output::enable_ansi_colors_stdout() { + let _ = writeln!( + writer, + "{}{}{}: {}{}{}", + pfmt!("", true), + bstr::BStr::new(slice), + pfmt!("", true), + pfmt!("", true), + current, + pfmt!("", true), + ); + } else { + let _ = writeln!(writer, "{}: {}", bstr::BStr::new(slice), current); + } + let _ = writer.flush(); } #[unsafe(no_mangle)] @@ -5962,192 +5818,60 @@ thread_local! { static PENDING_TIME_LOGS_LOADED: Cell = const { Cell::new(false) }; } -/// `process.emitWarning(text)` for the console timers, as Node does; a -/// failure to warn is not the caller's problem. -fn console_warn(global: &JSGlobalObject, args: core::fmt::Arguments<'_>) { - let Ok(js) = BunString::create_format(args).into_js(global) else { - return; - }; - let _ = global.emit_warning( - js, - JSValue::UNDEFINED, - JSValue::UNDEFINED, - JSValue::UNDEFINED, - ); -} - -/// Node's `formatTime` for `console.timeLog` / `console.timeEnd`. -/// https://github.com/nodejs/node/blob/v24.0.0/lib/internal/util/debuglog.js#L155-L187 -fn write_elapsed(writer: &mut dyn bun_io::Write, mut ms: f64) { - const SECOND: f64 = 1000.0; - const MINUTE: f64 = 60.0 * SECOND; - const HOUR: f64 = 60.0 * MINUTE; - - let mut hours = 0u64; - let mut minutes = 0u64; - let mut seconds = 0.0f64; - if ms >= SECOND { - if ms >= MINUTE { - if ms >= HOUR { - hours = (ms / HOUR).floor() as u64; - ms %= HOUR; - } - minutes = (ms / MINUTE).floor() as u64; - ms %= MINUTE; - } - seconds = ms / SECOND; - } - - if hours != 0 || minutes != 0 { - // `seconds.toFixed(3)` split on '.' → zero-padded whole seconds + millis. - let whole = seconds.trunc() as u64; - let mut millis = ((seconds - seconds.trunc()) * 1000.0).round() as u64; - let mut whole = whole; - if millis == 1000 { - millis = 0; - whole += 1; - } - if hours != 0 { - let _ = write!( - writer, - "{}:{:02}:{:02}.{:03} (h:mm:ss.mmm)", - hours, minutes, whole, millis - ); - } else { - let _ = write!(writer, "{}:{:02}.{:03} (m:ss.mmm)", minutes, whole, millis); - } - return; - } - - if seconds != 0.0 { - let _ = write!(writer, "{:.3}s", seconds); - return; - } - - // `Number(ms.toFixed(3))`: round to three decimals, print as a JS number. - let _ = write!( - writer, - "{}ms", - bun_core::fmt::double((ms * 1000.0).round() / 1000.0) - ); -} - #[unsafe(no_mangle)] #[crate::host_call] pub(crate) extern "C" fn Bun__ConsoleObject__time( _console: *mut ConsoleObject, - global: &JSGlobalObject, + _global: &JSGlobalObject, chars: *const u8, len: usize, ) { // SAFETY: caller passes a valid (ptr, len) pair. - let label = unsafe { bun_core::ffi::slice(chars, len) }; - let id = bun_wyhash::hash(label); + let id = bun_wyhash::hash(unsafe { bun_core::ffi::slice(chars, len) }); if !PENDING_TIME_LOGS_LOADED.with(|c| c.get()) { PENDING_TIME_LOGS.with_borrow_mut(|m| *m = PendingTimers::default()); PENDING_TIME_LOGS_LOADED.with(|c| c.set(true)); } - let existed = PENDING_TIME_LOGS.with_borrow_mut(|map| { + PENDING_TIME_LOGS.with_borrow_mut(|map| { let result = map.get_or_put(id).expect("unreachable"); if !result.found_existing || result.value_ptr.is_none() { *result.value_ptr = Some(bun_core::time::Timer::start()); - false - } else { - true } }); - if existed { - console_warn( - global, - format_args!( - "Label '{}' already exists for console.time()", - bstr::BStr::new(label) - ), - ); - } -} - -/// Shared tail of `timeEnd` / `timeLog`: `label: [ ...args]\n` on -/// stdout, exactly what Node's `timeLogImpl` → `console.log('%s: %s', ...)` -/// prints. -fn time_log_impl( - global: &JSGlobalObject, - implementation: &str, - label: &[u8], - take: bool, - args: &[JSValue], -) { - if !PENDING_TIME_LOGS_LOADED.with(|c| c.get()) { - console_warn( - global, - format_args!( - "No such label '{}' for {}", - bstr::BStr::new(label), - implementation - ), - ); - return; - } - let id = bun_wyhash::hash(label); - let timer = PENDING_TIME_LOGS.with_borrow_mut(|m| match m.get_mut(&id) { - Some(slot) if take => slot.take(), - Some(slot) => *slot, - None => None, - }); - let Some(timer) = timer else { - console_warn( - global, - format_args!( - "No such label '{}' for {}", - bstr::BStr::new(label), - implementation - ), - ); - return; - }; - let ms = timer.read() as f64 / bun_core::time::NS_PER_MS as f64; - - let _ = emit(global, ConsoleStream::Stdout, |writer, colors| { - let _ = writer.write_all(label); - let _ = writer.write_all(b": "); - write_elapsed(writer, ms); - - if !args.is_empty() { - // `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); - fmt.max_depth = bun_options_types::context::try_get() - .and_then(|ctx| ctx.runtime_options.console_depth) - .unwrap_or(DEFAULT_CONSOLE_LOG_DEPTH); - fmt.stack_check = StackCheck::init(); - fmt.can_throw_stack_overflow = true; - for &arg in args { - let tag = formatter::Tag::get(arg, global)?; - let _ = writer.write_all(b" "); - if colors { - fmt.format::(tag, writer, arg, global)?; - } else { - fmt.format::(tag, writer, arg, global)?; - } - } - } - let _ = writer.write_all(b"\n"); - Ok(()) - }); } #[unsafe(no_mangle)] #[crate::host_call] pub(crate) extern "C" fn Bun__ConsoleObject__timeEnd( _console: *mut ConsoleObject, - global: &JSGlobalObject, + _global: &JSGlobalObject, chars: *const u8, len: usize, ) { + if !PENDING_TIME_LOGS_LOADED.with(|c| c.get()) { + return; + } + // SAFETY: caller passes a valid (ptr, len) pair. - let label = unsafe { bun_core::ffi::slice(chars, len) }; - time_log_impl(global, "console.timeEnd()", label, true, &[]); + let slice = unsafe { bun_core::ffi::slice(chars, len) }; + let id = bun_wyhash::hash(slice); + // Replace the slot with `None`, returning the previous value. + let Some(prev) = PENDING_TIME_LOGS.with_borrow_mut(|m| m.get_mut(&id).map(|slot| slot.take())) + else { + return; + }; + 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, + ); + match len { + 0 => Output::print_errorln(format_args!("")), + _ => Output::print_errorln(format_args!(" {}", bstr::BStr::new(slice))), + } + + Output::flush(); } #[unsafe(no_mangle)] @@ -6160,11 +5884,55 @@ pub(crate) extern "C" fn Bun__ConsoleObject__timeLog( args: *const JSValue, args_len: usize, ) { - // SAFETY: caller passes valid (ptr, len) pairs. - let label = unsafe { bun_core::ffi::slice(chars, len) }; - // SAFETY: as above. - let args = unsafe { bun_core::ffi::slice(args, args_len) }; - time_log_impl(global, "console.timeLog()", label, false, args); + if !PENDING_TIME_LOGS_LOADED.with(|c| c.get()) { + return; + } + + // SAFETY: caller passes a valid (ptr, len) pair. + let slice = unsafe { bun_core::ffi::slice(chars, len) }; + let id = bun_wyhash::hash(slice); + let Some(Some(value)) = PENDING_TIME_LOGS.with_borrow(|m| m.get(&id).copied()) 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, + ); + match len { + 0 => {} + _ => Output::print_error(format_args!(" {}", bstr::BStr::new(slice))), + } + Output::flush(); + + // 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); + fmt.max_depth = bun_options_types::context::try_get() + .and_then(|ctx| ctx.runtime_options.console_depth) + .unwrap_or(DEFAULT_CONSOLE_LOG_DEPTH); + fmt.stack_check = StackCheck::init(); + fmt.can_throw_stack_overflow = true; + 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 + // resulting `writer` borrow does not pin a long-lived `&mut ConsoleObject` + // across the `fmt.format(...)` calls below, which can re-enter JS. + let mut writer = unsafe { (*console).error_writer() }; + // 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 _ = bun_io::Write::write_all(&mut writer, b" "); + if Output::enable_ansi_colors_stderr() { + let _ = fmt.format::(tag, &mut writer, arg, global); + } else { + let _ = fmt.format::(tag, &mut writer, arg, global); + } + } + let _ = bun_io::Write::write_all(&mut writer, b"\n"); + let _ = bun_io::Write::flush(&mut writer); } /// Stamp out the empty `Bun__ConsoleObject__*` C-ABI hooks that JSC's diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 9c00f8054ed5..ba72576455fe 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -461,9 +461,6 @@ unsafe extern "C" { safe fn Zig__GlobalObject__retireForTestIsolation(global: &JSGlobalObject); safe fn Zig__GlobalObject__destructOnExit(global: &JSGlobalObject); safe fn WebWorker__teardownJSCVM(global: &JSGlobalObject); - /// `BunProcess.cpp`: signals that were only forwarded to JS listeners go - /// back to `SIG_DFL` (no-op on Windows). - safe fn bun_reset_signal_handlers_for_exit(); } bun_core::define_scoped_log!(teardown_log, Worker, hidden); @@ -2215,40 +2212,6 @@ impl VirtualMachine { } } - /// Synchronously flush this VM's stdio sinks (fd 1/2). No-op when they were - /// never created. Called before anything that must not overtake or discard - /// already-accepted `process.stdout`/`stderr` bytes: fatal-error printing - /// and exit. - #[inline] - pub fn drain_stdio(&mut self) { - if self - .rare_data - .as_ref() - .is_some_and(|r| r.stdio_sinks.iter().any(Option::is_some)) - { - let vm = core::ptr::from_mut::(self); - // SAFETY: `vm` is the live per-thread VM; no `&mut` is held across the call. - unsafe { crate::rare_data::__bun_stdio_sink_drain(vm) }; - } - } - - /// The exit-time drain. JS will not run again, so its signal listeners - /// can't either: hand the signals they had claimed back to their defaults - /// first (cf. Node's `ResetSignalHandlers` at teardown) so that waiting on - /// a reader that never reads stays killable the ordinary way. - fn drain_stdio_for_exit(&mut self) { - if self - .rare_data - .as_ref() - .is_some_and(|r| r.stdio_sinks.iter().any(Option::is_some)) - { - if self.is_main_thread() { - bun_reset_signal_handlers_for_exit(); - } - self.drain_stdio(); - } - } - pub fn on_exit(&mut self) { // Decide once whether the exit sequence may run script. It can be // entered with an exception pending: `process.exit()` from inside a @@ -2300,11 +2263,6 @@ impl VirtualMachine { self.is_inside_deferred_task_queue.set(false); } - // Whatever process.stdout/stderr still had queued behind a slow reader - // is written now (blocking), like console.log always has been: an exit - // must not silently truncate output that was accepted before it. - self.drain_stdio_for_exit(); - self.is_shutting_down = true; // Node's FreeEnvironment sets `is_stopping` before `RunCleanup`: the @@ -2362,9 +2320,6 @@ impl VirtualMachine { pub fn global_exit(&mut self) -> ! { debug_assert!(self.is_shutting_down()); - // Paths that get here without `on_exit()` (early CLI/test-runner - // exits) still owe the fd whatever process.stdout/stderr queued. - self.drain_stdio_for_exit(); // FIXME: we should be doing this, but we're not, but unfortunately // doing it causes like 50+ tests to break // self.event_loop().tick(); @@ -3214,8 +3169,20 @@ impl VirtualMachine { MAIN_THREAD_VM.store(vm, core::sync::atomic::Ordering::Release); } + // ConsoleObject is self-referential (buffers + adapters) — allocate + // stable storage and init in place. + // `console.init(Output.rawErrorWriter(), Output.rawWriter())` must + // happen BEFORE the pointer is stored/passed; the previous port left + // it as raw `MaybeUninit` (UB on first C++ read). + let mut console_box: Box> = + Box::new(core::mem::MaybeUninit::uninit()); + crate::console_object::ConsoleObject::init_in_place( + &mut console_box, + bun_core::Output::raw_error_writer(), + bun_core::Output::raw_writer(), + ); let console = - bun_core::heap::into_raw(Box::new(crate::console_object::ConsoleObject::new())); + bun_core::heap::into_raw(console_box).cast::(); let context_id = opts .context_id @@ -5577,12 +5544,6 @@ impl VirtualMachine { } /// Worker-thread teardown. pub fn destroy(&mut self) { - // Stdio sinks need the loop (poll teardown) and `RareData.file_polls`; - // the usual exit paths released them in `release_js_handles()` already, - // this covers VMs torn down directly (e.g. `bun build`'s bake VM). - if let Some(rare) = self.rare_data.as_deref_mut() { - rare.release_stdio_sinks(); - } self.regular_event_loop.deinit(); self.macro_event_loop.deinit(); // The VM's own clone of its handle; the shared inner is freed when the @@ -5928,17 +5889,6 @@ impl VirtualMachine { // The finished file's close handlers run, and may dial again. self.close_test_file_sockets(); - // The stdio sinks carry over (they own the fd state); their JS wrapper - // belongs to the outgoing global and must not. - if self - .rare_data - .as_ref() - .is_some_and(|r| r.stdio_sinks.iter().any(Option::is_some)) - { - let vm = core::ptr::from_mut::(self); - // SAFETY: `vm` is the live per-thread VM; no `&mut` is held across the call. - unsafe { crate::rare_data::__bun_stdio_sink_release_js(vm) }; - } if let Some(rare) = self.rare_data.as_deref_mut() { rare.listening_sockets_for_watch_mode.lock().clear(); // `setCallbacks` is once-only (node/src/quic/bindingdata.cc diff --git a/src/jsc/bindings/BunProcess.cpp b/src/jsc/bindings/BunProcess.cpp index 4306b1990cdd..d2675c999062 100644 --- a/src/jsc/bindings/BunProcess.cpp +++ b/src/jsc/bindings/BunProcess.cpp @@ -874,19 +874,6 @@ extern "C" void Process__dispatchOnBeforeExit(Zig::GlobalObject* globalObject, u } } -// Run an internal builtin whose failure is not the caller's to propagate: a -// throw is reported like any other uncaught exception. -static void callReportingException(Zig::GlobalObject* globalObject, JSFunction* fn, const MarkedArgumentBuffer& args) -{ - auto& vm = JSC::getVM(globalObject); - auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); - JSC::call(globalObject, fn, JSC::getCallData(fn), jsUndefined(), args); - if (auto* exception = scope.exception()) [[unlikely]] { - (void)scope.tryClearException(); - Zig::GlobalObject::reportUncaughtExceptionAtEventLoop(globalObject, exception); - } -} - extern "C" void Process__dispatchOnExit(Zig::GlobalObject* globalObject, uint8_t exitCode) { if (!globalObject->hasProcessObject()) { @@ -897,24 +884,6 @@ extern "C" void Process__dispatchOnExit(Zig::GlobalObject* globalObject, uint8_t if (exitCode > 0) process->m_isExitCodeObservable = true; dispatchExitInternal(globalObject, process, exitCode); - - // 'exit' handlers may still write; after them, move whatever the stdio - // Writables have queued in JS into the native sinks so the exit drain - // (FileSink::drain_sync) delivers it. Only for streams that exist. - auto& vm = JSC::getVM(globalObject); - if (vm.hasTerminationRequest() || vm.hasExceptionsAfterHandlingTraps()) - return; - JSFunction* flush = nullptr; - for (int fd = 1; fd <= 2; ++fd) { - JSObject* stream = process->stdioStream(fd); - if (!stream) - continue; - if (!flush) - flush = JSFunction::create(vm, globalObject, processObjectInternalsFlushStdioWriteStreamOnExitCodeGenerator(vm), globalObject); - MarkedArgumentBuffer args; - args.append(stream); - callReportingException(globalObject, flush, args); - } } JSC_DEFINE_HOST_FUNCTION(Process_functionUptime, (JSC::JSGlobalObject * lexicalGlobalObject, JSC::CallFrame* callFrame)) @@ -1614,32 +1583,6 @@ extern "C" void Bun__installWatchModeSignalHandler(int signalNumber) } #endif -// At exit, once JS can no longer run, a signal that was only being forwarded -// to `process.on(...)` listeners would be swallowed; give those (and only -// those — not JSC's thread-suspend signal, our stdio-restore handler, or an -// inherited SIG_IGN) back their default action so a process still writing out -// the last of its stdio stays killable with ^C / SIGTERM. (Node resets every -// signal here: ResetSignalHandlers.) -extern "C" void bun_reset_signal_handlers_for_exit() -{ -#if !OS(WINDOWS) - for (int nr = 1; nr < NSIG; nr++) { - if (nr == SIGKILL || nr == SIGSTOP) - continue; - struct sigaction current; - if (sigaction(nr, nullptr, ¤t) != 0) - continue; - if ((current.sa_flags & SA_SIGINFO) || current.sa_handler != forwardSignal) - continue; - struct sigaction dfl; - memset(&dfl, 0, sizeof(dfl)); - dfl.sa_handler = SIG_DFL; - sigemptyset(&dfl.sa_mask); - sigaction(nr, &dfl, nullptr); - } -#endif -} - extern "C" void Bun__MemoryPressure__install(JSC::JSGlobalObject* global); extern "C" void Bun__MemoryPressure__uninstall(JSC::JSGlobalObject* global); @@ -1964,8 +1907,6 @@ struct ExecveCloexecRestorer { }; #endif -extern "C" void bun_restore_stdio(); - JSC_DEFINE_HOST_FUNCTION_WITH_ATTRIBUTES(Process_functionExecve, __attribute__((minsize)), (JSGlobalObject * lexicalGlobalObject, CallFrame* callFrame)) { Zig::GlobalObject* globalObject = defaultGlobalObject(lexicalGlobalObject); @@ -2119,10 +2060,6 @@ JSC_DEFINE_HOST_FUNCTION_WITH_ATTRIBUTES(Process_functionExecve, __attribute__(( } } - // The new image gets our stdio; hand it over in the state we found it - // (termios, O_NONBLOCK), exactly as a normal exit would. - bun_restore_stdio(); - int savedErrno; #if OS(DARWIN) @@ -3029,6 +2966,7 @@ enum class BunProcessStdinFdType : int32_t { }; extern "C" BunProcessStdinFdType Bun__Process__getStdinFdType(void*, int fd); +extern "C" void Bun__ForceFileSinkToBeSynchronousForProcessObjectStdio(JSC::JSGlobalObject*, JSC::EncodedJSValue); // node:worker_threads worker: process.stdout/stderr forward to the parent Worker's // worker.stdout/stderr over a MessagePort; process.stdin is port-fed for { stdin: true } // and otherwise an already-ended Readable (never the process-wide fd 0). fd 0/1/2 selects the port. @@ -3076,245 +3014,45 @@ static JSValue constructStdioWriteStream(JSC::JSGlobalObject* globalObject, JSC: return jsUndefined(); } - // Everything about how bytes reach fd 1/2 (sync vs. queued, fd flags, - // ordering against console.*) is decided by the per-VM stdio FileSink the - // stream was built over; see FileSink::create_stdio. - if (JSObject* streamObject = result.getObject()) - uncheckedDowncast(processObject)->setStdioStream(vm, fd, streamObject); - return result; -} - -void Process::setStdioStream(JSC::VM& vm, int fd, JSObject* stream) -{ - ASSERT(fd == 1 || fd == 2); - unsigned slot = static_cast(fd) - 1; - m_stdioStream[slot].set(vm, this, stream); - m_stdioPristineStructureID[slot] = stream->structureID(); - // The O(1) "is `write` still ours" test in consoleStream() is a structure - // compare, which only works if a pristine stream has no *own* `write` - // (adding one transitions the structure). See internal/fs/streams.ts. - ASSERT_WITH_MESSAGE(stream->getDirectOffset(vm, WebCore::builtinNames(vm).writePublicName()) == invalidOffset, "stdio stream must inherit write() from its prototype"); -} - -void Process::setConsoleStream(JSC::VM& vm, int fd, JSValue value) -{ - ASSERT(fd == 1 || fd == 2); - unsigned slot = static_cast(fd) - 1; - if (value.isEmpty() || value.isUndefined() || (m_stdioStream[slot] && value == m_stdioStream[slot].get())) { - // Rebinding to Bun's own stream (or clearing) puts the fast path back. - m_consoleStreamState[slot] = ConsoleStreamState::Native; - m_consoleStream[slot].clear(); - return; - } - m_consoleStreamState[slot] = ConsoleStreamState::Custom; - m_consoleStream[slot].set(vm, this, value); -} + ASSERT_WITH_MESSAGE(JSC::isJSArray(result), "Expected an array from getStdioWriteStream"); + JSC::JSArray* resultObject = uncheckedDowncast(result); -JSValue Process::consoleStream(JSC::JSGlobalObject* globalObject, int fd) -{ - ASSERT(fd == 1 || fd == 2); - unsigned slot = static_cast(fd) - 1; - auto& vm = JSC::getVM(globalObject); - - switch (m_consoleStreamState[slot]) { - case ConsoleStreamState::Custom: - return m_consoleStream[slot].get(); - case ConsoleStreamState::Unresolved: { - // Node binds `console._stdout` to `process.stdout` on first use and - // caches it. If nobody has touched `process.stdout` yet the lazy - // property is unreified and would produce our own stream, so bind - // native *without* building the JS object; if user code already put - // something else there, that is what the console is bound to for good. - // https://github.com/nodejs/node/blob/v24.0.0/lib/internal/console/constructor.js#L205-L234 - const Identifier& name = fd == 1 ? WebCore::builtinNames(vm).stdoutPublicName() : WebCore::builtinNames(vm).stderrPublicName(); - unsigned attributes = 0; - JSValue existing; - if (invalidOffset != structure()->get(vm, name, attributes)) { - if (attributes & PropertyAttribute::Accessor) { - // `Object.defineProperty(process, "stdout", { get })`: do the - // one [[Get]] Node's lazy binding would. May throw. - auto scope = DECLARE_THROW_SCOPE(vm); - existing = get(globalObject, name); - RETURN_IF_EXCEPTION(scope, {}); - } else { - existing = getDirect(vm, name); - } - } - if (existing && existing.isObject() && (!m_stdioStream[slot] || existing != m_stdioStream[slot].get())) { - m_consoleStreamState[slot] = ConsoleStreamState::Custom; - m_consoleStream[slot].set(vm, this, existing); - return existing; - } - m_consoleStreamState[slot] = ConsoleStreamState::Native; - [[fallthrough]]; - } - case ConsoleStreamState::Native: { - JSObject* stream = m_stdioStream[slot].get(); - if (!stream) - return {}; // never materialised: nothing can be observing it - if (m_stdioObserved[slot]) - return stream; - // A dictionary structure no longer changes ID per added property, so - // it can't vouch for "no own write" — look every time (still O(1)). - if (stream->structureID() == m_stdioPristineStructureID[slot] && !stream->structure()->isDictionary()) [[likely]] - return {}; - // Some own property was added/removed. If it wasn't `write`, adopt the - // new structure as pristine so the next call is one compare again. - if (stream->getDirectOffset(vm, WebCore::builtinNames(vm).writePublicName()) != invalidOffset) - return stream; - m_stdioPristineStructureID[slot] = stream->structureID(); - return {}; - } - } - return {}; -} - -JSValue Process::consoleStreamForGetter(JSC::JSGlobalObject* globalObject, int fd) -{ - ASSERT(fd == 1 || fd == 2); - unsigned slot = static_cast(fd) - 1; - auto& vm = JSC::getVM(globalObject); - auto scope = DECLARE_THROW_SCOPE(vm); - if (m_consoleStreamState[slot] == ConsoleStreamState::Unresolved) { - (void)consoleStream(globalObject, fd); - RETURN_IF_EXCEPTION(scope, {}); - } - if (m_consoleStreamState[slot] == ConsoleStreamState::Custom) - RELEASE_AND_RETURN(scope, m_consoleStream[slot].get()); - // Native: the real stream object (materialising it is fine here — the - // caller asked for it by name). - RELEASE_AND_RETURN(scope, get(globalObject, fd == 1 ? WebCore::builtinNames(vm).stdoutPublicName() : WebCore::builtinNames(vm).stderrPublicName())); -} - -// Whether the console's stream for `fd` is a *foreign* object (worker port -// stream, `console._stdout = x`, a replaced `process.stdout`) rather than Bun's -// own stdio stream in an observed state. Never runs user code. -extern "C" bool Bun__Process__consoleStreamIsCustom(Zig::GlobalObject* globalObject, int32_t fd) -{ - if (!globalObject->hasProcessObject()) [[unlikely]] - return false; - return globalObject->processObject()->consoleStreamIsCustom(fd); -} + // process.stdout and process.stderr differ from other Node.js streams in important ways: + // 1. They are used internally by console.log() and console.error(), respectively. + // 2. Writes may be synchronous depending on what the stream is connected to and whether the system is Windows or POSIX: + // Files: synchronous on Windows and POSIX + // TTYs (Terminals): asynchronous on Windows, synchronous on POSIX + // Pipes (and sockets): synchronous on Windows, asynchronous on POSIX + bool forceSync = false; +#if OS(WINDOWS) + forceSync = fdType == BunProcessStdinFdType::file || fdType == BunProcessStdinFdType::pipe; +#else + // Note: files are always sync anyway. + // forceSync = fdType == BunProcessStdinFdType::file || bun_stdio_tty[fd]; -// Empty: use the native sink. Otherwise the stream to `write()` to. `*threw` -// is set (and empty returned) if user code made `process.stdout` a throwing -// getter and this was the console's first use. -extern "C" JSC::EncodedJSValue Bun__Process__consoleStream(Zig::GlobalObject* globalObject, int32_t fd, bool* threw) -{ - if (!globalObject->hasProcessObject()) [[unlikely]] - return JSValue::encode({}); - auto* process = globalObject->processObject(); - if (!process->consoleStreamIsResolved(fd)) [[unlikely]] { - // Top scope: the Rust caller learns about a throw through `*threw` - // (and propagates the pending exception), not through a ThrowScope. - auto scope = DECLARE_TOP_EXCEPTION_SCOPE(JSC::getVM(globalObject)); - JSValue result = process->consoleStream(globalObject, fd); - if (scope.exception()) [[unlikely]] { - *threw = true; - return JSValue::encode({}); - } - return JSValue::encode(result); + // TODO: once console.* is wired up to write/read through the same buffering mechanism as FileSink for process.stdout, process.stderr, we can make this non-blocking for sockets on POSIX. + // Until then, we have to force it to be sync EVEN for sockets or else console.log() may flush at a different time than process.stdout.write. + forceSync = true; +#endif + JSValue sink = resultObject->getIndex(globalObject, 1); + RETURN_IF_EXCEPTION(scope, jsUndefined()); + if (auto* sinkObject = sink.getObject()) + uncheckedDowncast(processObject)->setStdioSink(vm, fd, sinkObject); + if (forceSync) { + Bun__ForceFileSinkToBeSynchronousForProcessObjectStdio(globalObject, JSValue::encode(sink)); } - return JSValue::encode(process->consoleStream(globalObject, fd)); -} -// The console's JS slow path: `stream.write(chunk)` with Node's kWriteToConsole -// error handling. `chunk` is one fully formatted message as a JS string. -extern "C" void Bun__Console__writeToStream(Zig::GlobalObject* globalObject, JSC::EncodedJSValue stream, JSC::EncodedJSValue chunk) -{ - auto& vm = JSC::getVM(globalObject); - auto scope = DECLARE_THROW_SCOPE(vm); - JSFunction* fn = globalObject->processObject()->consoleWriteFunction(); - JSC::MarkedArgumentBuffer args; - args.append(JSValue::decode(stream)); - args.append(JSValue::decode(chunk)); - JSC::call(globalObject, fn, JSC::getCallData(fn), jsUndefined(), args); - RETURN_IF_EXCEPTION(scope, ); + JSValue stream = resultObject->getIndex(globalObject, 0); + RETURN_IF_EXCEPTION(scope, jsUndefined()); + return stream; } -// A console.* write on the shared stdio sink failed terminally (EPIPE, ...): -// deliver it as 'error' on our process.stdout/stderr object if that exists and -// is listened to (processObjectInternalsReportStdioSinkError decides). -extern "C" void Bun__Process__reportStdioSinkError(Zig::GlobalObject* globalObject, int32_t fd, JSC::EncodedJSValue err) -{ - if (!globalObject->hasProcessObject()) [[unlikely]] - return; - JSObject* stream = globalObject->processObject()->stdioStream(fd); - if (!stream) - return; - auto& vm = JSC::getVM(globalObject); - // A notification, not part of the console call's contract: a throwing - // 'error' listener is reported like any other uncaught exception rather - // than surfacing out of console.log(). - JSFunction* fn = JSFunction::create(vm, globalObject, processObjectInternalsReportStdioSinkErrorCodeGenerator(vm), globalObject); - MarkedArgumentBuffer args; - args.append(stream); - args.append(JSValue::decode(err)); - callReportingException(globalObject, fn, args); -} - -// (fd) -> the stream console output for `fd` must go through, or undefined -// when the native sink may be used. Builtin-JS face of Process::consoleStream. -JSC_DEFINE_HOST_FUNCTION(jsFunctionConsoleStream, (JSGlobalObject * lexicalGlobalObject, CallFrame* callFrame)) -{ - auto* globalObject = defaultGlobalObject(lexicalGlobalObject); - int32_t fd = callFrame->argument(0).asInt32(); - ASSERT(fd == 1 || fd == 2); - auto scope = DECLARE_THROW_SCOPE(JSC::getVM(globalObject)); - JSValue stream = globalObject->processObject()->consoleStream(globalObject, fd); - RETURN_IF_EXCEPTION(scope, {}); - return JSValue::encode(stream ? stream : jsUndefined()); -} - -// (mask, publish) — see m_consoleChannelMask. -JSC_DEFINE_HOST_FUNCTION(jsFunctionSetConsoleChannels, (JSGlobalObject * lexicalGlobalObject, CallFrame* callFrame)) +extern "C" JSC::EncodedJSValue Bun__Process__getStdioSink(JSC::JSGlobalObject* lexicalGlobalObject, int fd) { auto* globalObject = defaultGlobalObject(lexicalGlobalObject); - auto* publish = dynamicDowncast(callFrame->argument(1)); - ASSERT(publish); - globalObject->processObject()->setConsoleChannels(JSC::getVM(globalObject), static_cast(callFrame->argument(0).toUInt32(lexicalGlobalObject)), publish); - return JSValue::encode(jsUndefined()); -} - -// console.clear() honours the console's *stream's* `isTTY` (Node checks -// `this._stdout.isTTY`), so hand back whatever object that is if one exists; -// empty means "nobody has a stream object, use the real fd". -extern "C" JSC::EncodedJSValue Bun__Process__consoleStreamObject(Zig::GlobalObject* globalObject, int32_t fd, bool* threw) -{ - if (!globalObject->hasProcessObject()) [[unlikely]] - return JSValue::encode({}); - auto* process = globalObject->processObject(); - auto scope = DECLARE_TOP_EXCEPTION_SCOPE(JSC::getVM(globalObject)); // see Bun__Process__consoleStream - JSValue custom = process->consoleStream(globalObject, fd); - if (scope.exception()) [[unlikely]] { - *threw = true; + if (!globalObject->hasProcessObject()) return JSValue::encode({}); - } - if (custom) - return JSValue::encode(custom); - if (JSObject* stream = process->stdioStream(fd)) - return JSValue::encode(stream); - return JSValue::encode({}); -} - -// (fd, bits) — see m_stdioObserved. -JSC_DEFINE_HOST_FUNCTION(jsFunctionSetStdioObserved, (JSGlobalObject * lexicalGlobalObject, CallFrame* callFrame)) -{ - auto* globalObject = defaultGlobalObject(lexicalGlobalObject); - int32_t fd = callFrame->argument(0).asInt32(); - ASSERT(fd == 1 || fd == 2); - globalObject->processObject()->setStdioObserved(fd, static_cast(callFrame->argument(1).toUInt32(lexicalGlobalObject))); - return JSValue::encode(jsUndefined()); -} - -// (fd, stream | undefined) — worker_threads rebinding of the native console. -JSC_DEFINE_HOST_FUNCTION(jsFunctionSetConsoleStream, (JSGlobalObject * lexicalGlobalObject, CallFrame* callFrame)) -{ - auto* globalObject = defaultGlobalObject(lexicalGlobalObject); - int32_t fd = callFrame->argument(0).asInt32(); - ASSERT(fd == 1 || fd == 2); - globalObject->processObject()->setConsoleStream(JSC::getVM(globalObject), fd, callFrame->argument(1)); - return JSValue::encode(jsUndefined()); + return JSValue::encode(globalObject->processObject()->stdioSink(fd)); } static JSValue constructStdout(VM& vm, JSObject* processObject) @@ -4053,12 +3791,9 @@ void Process::visitChildrenImpl(JSCell* cell, Visitor& visitor) visitor.append(thisObject->m_cachedCwd); visitor.append(thisObject->m_argv); visitor.append(thisObject->m_execArgv); - for (unsigned i = 0; i < 2; ++i) { - visitor.append(thisObject->m_consoleStream[i]); - visitor.append(thisObject->m_stdioStream[i]); - } - visitor.append(thisObject->m_consolePublish); visitor.append(thisObject->m_onWarning); + visitor.append(thisObject->m_stdioSinks[0]); + visitor.append(thisObject->m_stdioSinks[1]); thisObject->m_cpuUsageStructure.visit(visitor); thisObject->m_resourceUsageStructure.visit(visitor); @@ -4066,7 +3801,6 @@ void Process::visitChildrenImpl(JSCell* cell, Visitor& visitor) thisObject->m_bindingUV.visit(visitor); thisObject->m_bindingNatives.visit(visitor); thisObject->m_emitHelperFunction.visit(visitor); - thisObject->m_consoleWriteFunction.visit(visitor); } DEFINE_VISIT_CHILDREN(Process); @@ -5417,9 +5151,6 @@ void Process::finishCreation(JSC::VM& vm) m_emitHelperFunction.initLater([](const JSC::LazyProperty::Initializer& init) { init.set(JSFunction::create(init.vm, init.owner->globalObject(), 2, "emit"_s, Process_functionEmitHelper, ImplementationVisibility::Private)); }); - m_consoleWriteFunction.initLater([](const JSC::LazyProperty::Initializer& init) { - init.set(JSFunction::create(init.vm, init.owner->globalObject(), consoleObjectWriteToObservedStreamCodeGenerator(init.vm), init.owner->globalObject())); - }); putDirect(vm, vm.propertyNames->toStringTagSymbol, jsString(vm, String("process"_s)), 0); putDirect(vm, Identifier::fromString(vm, "_exiting"_s), jsBoolean(false), 0); diff --git a/src/jsc/bindings/BunProcess.h b/src/jsc/bindings/BunProcess.h index 7f71f4c63ec3..266923fb00d1 100644 --- a/src/jsc/bindings/BunProcess.h +++ b/src/jsc/bindings/BunProcess.h @@ -28,8 +28,6 @@ class Process : public WebCore::JSEventEmitter { // Function that looks up "emit" on "process" and calls it with the provided arguments // Only used by internal code via passing to queueNextTick LazyProperty m_emitHelperFunction; - // consoleObjectWriteToObservedStream builtin (the console's JS slow path). - LazyProperty m_consoleWriteFunction; WriteBarrier m_uncaughtExceptionCaptureCallback; WriteBarrier m_nextTickFunction; // https://github.com/nodejs/node/blob/2eff28fb7a93d3f672f80b582f664a7c701569fb/lib/internal/bootstrap/switches/does_own_process_state.js#L113-L116 @@ -38,61 +36,12 @@ class Process : public WebCore::JSEventEmitter { WriteBarrier m_execArgv; // The JS warning printer (ProcessObjectInternals createOnWarning), built on the first warning. WriteBarrier m_onWarning; + // The FileSinks behind process.stdout and process.stderr: console.* flushes them before it writes to the same fd. + WriteBarrier m_stdioSinks[2]; void installDefaultWarningListener(JSC::VM&); - // ── console ⇄ process.stdout/stderr binding (index 0 = fd 1, 1 = fd 2) ── - // - // Node's global console writes through `this._stdout.write(chunk)`, where - // `_stdout` lazily binds to `process.stdout` on first use and can be - // reassigned. Bun's console formats natively and writes straight to the - // per-VM stdio sink *unless doing so would be observable*: the console's - // stream was reassigned or replaced, its `write` is no longer Bun's, or it - // is corked / ended. `consoleStream()` answers that per call in O(1). - enum class ConsoleStreamState : uint8_t { - Unresolved, // console has not written to this fd yet - Native, // bound to Bun's own stdio stream (whether or not the JS object exists yet) - Custom, // bound to m_consoleStream (console._stdout = x, or process.stdout replaced before first use) - }; - ConsoleStreamState m_consoleStreamState[2] = { ConsoleStreamState::Unresolved, ConsoleStreamState::Unresolved }; - // Bits set from JS while Bun's stdio stream is corked / ended, forcing the - // JS path so Writable semantics apply. See ProcessObjectInternals.ts. - uint8_t m_stdioObserved[2] = { 0, 0 }; - WriteBarrier m_consoleStream[2]; - // Bun's own process.stdout / process.stderr object once materialised, and - // the StructureID it had when pristine (no own `write`). - WriteBarrier m_stdioStream[2]; - StructureID m_stdioPristineStructureID[2] = {}; - // diagnostics_channel: bit i set while kConsoleChannelNames[i] has - // subscribers (log, warn, error, debug, info); m_consolePublish(i, args). - WriteBarrier m_consolePublish; - public: - uint8_t m_consoleChannelMask = 0; - JSFunction* consolePublish() { return m_consolePublish.get(); } - void setConsoleChannels(JSC::VM& vm, uint8_t mask, JSFunction* publish) - { - m_consoleChannelMask = mask; - m_consolePublish.set(vm, this, publish); - } - // fd is 1 or 2 for all of these. - void setStdioStream(JSC::VM&, int fd, JSObject* stream); - JSObject* stdioStream(int fd) { return m_stdioStream[fd - 1].get(); } - void setStdioObserved(int fd, uint8_t bits) { m_stdioObserved[fd - 1] = bits; } - // `console._stdout = value` / worker stdio rebinding. `value` may be anything. - void setConsoleStream(JSC::VM&, int fd, JSValue value); - // The stream the console must deliver through via JS `write()`, or the - // empty value when the native stdio sink may be used. A structure compare - // once resolved; the first (Unresolved) call may run — and throw from — a - // user getter installed on `process.stdout`/`stderr`. - JSValue consoleStream(JSC::JSGlobalObject*, int fd); - bool consoleStreamIsCustom(int fd) { return m_consoleStreamState[fd - 1] == ConsoleStreamState::Custom; } - bool consoleStreamIsResolved(int fd) const { return m_consoleStreamState[fd - 1] != ConsoleStreamState::Unresolved; } - // What `console._stdout` / `console._stderr` evaluate to (may materialise - // process.stdout; can throw). - JSValue consoleStreamForGetter(JSC::JSGlobalObject*, int fd); - JSFunction* consoleWriteFunction() { return m_consoleWriteFunction.getInitializedOnMainThread(this); } - Process(JSC::Structure* structure, WebCore::JSDOMGlobalObject& globalObject, Ref&& impl) : Base(structure, globalObject, WTF::move(impl)) { @@ -101,6 +50,9 @@ class Process : public WebCore::JSEventEmitter { DECLARE_EXPORT_INFO; bool m_reportOnUncaughtException = false; + JSObject* stdioSink(int fd) const { return m_stdioSinks[fd - 1].get(); } + void setStdioSink(JSC::VM& vm, int fd, JSObject* sink) { m_stdioSinks[fd - 1].set(vm, this, sink); } + static void destroy(JSC::JSCell* cell) { static_cast(cell)->Process::~Process(); @@ -194,14 +146,6 @@ class Process : public WebCore::JSEventEmitter { }; JSC_DECLARE_HOST_FUNCTION(Process_functionDlopen); -// $newCppFunction("BunProcess.cpp", "jsFunctionSetStdioObserved", 2) -JSC_DECLARE_HOST_FUNCTION(jsFunctionSetStdioObserved); -// $newCppFunction("BunProcess.cpp", "jsFunctionSetConsoleStream", 2) -JSC_DECLARE_HOST_FUNCTION(jsFunctionSetConsoleStream); -// $newCppFunction("BunProcess.cpp", "jsFunctionSetConsoleChannels", 2) -JSC_DECLARE_HOST_FUNCTION(jsFunctionSetConsoleChannels); -// $newCppFunction("BunProcess.cpp", "jsFunctionConsoleStream", 1) -JSC_DECLARE_HOST_FUNCTION(jsFunctionConsoleStream); // Routes its argument onto the uncaught-exception path. Used, via $newCppFunction, by the // node-style callback shims in src/js. diff --git a/src/jsc/bindings/ConsoleObject.cpp b/src/jsc/bindings/ConsoleObject.cpp index 157424119f76..5c0a357fb120 100644 --- a/src/jsc/bindings/ConsoleObject.cpp +++ b/src/jsc/bindings/ConsoleObject.cpp @@ -3,8 +3,6 @@ #include "JavaScriptCore/ArgList.h" #include "headers.h" #include "ConsoleObject.h" -#include "BunProcess.h" -#include "ZigGlobalObject.h" #include #include @@ -47,7 +45,7 @@ void ConsoleObject::messageWithTypeAndLevel(MessageType type, MessageLevel level auto args = arguments.ptr(); JSC::EncodedJSValue jsArgs[255]; - size_t count = std::min(args->argumentCount(), (size_t)255); + auto count = std::min(args->argumentCount(), (size_t)255); for (size_t i = 0; i < count; i++) { auto val = args->argumentAt(i); jsArgs[i] = JSC::JSValue::encode(val); @@ -58,58 +56,6 @@ void ConsoleObject::messageWithTypeAndLevel(MessageType type, MessageLevel level return; } - // diagnostics_channel 'console.log' / .warn / .error / .debug / .info: - // publish the argument list before formatting, only while subscribed. - // https://github.com/nodejs/node/blob/v24.0.0/lib/internal/console/constructor.js#L409-L443 - if (type == MessageType::Log) { - auto* zigGlobal = defaultGlobalObject(globalObject); - if (zigGlobal->hasProcessObject()) [[likely]] { - auto* process = zigGlobal->processObject(); - if (uint8_t mask = process->m_consoleChannelMask) [[unlikely]] { - int index = -1; - switch (level) { - case MessageLevel::Log: - index = 0; - break; - case MessageLevel::Warning: - index = 1; - break; - case MessageLevel::Error: - index = 2; - break; - case MessageLevel::Debug: - index = 3; - break; - case MessageLevel::Info: - index = 4; - break; - default: - break; - } - if (index >= 0 && (mask & (1u << index))) { - JSC::MarkedArgumentBuffer list; - for (size_t i = 0; i < count; i++) - list.append(JSC::JSValue::decode(jsArgs[i])); - JSC::JSArray* array = JSC::constructArray(globalObject, static_cast(nullptr), list); - RETURN_IF_EXCEPTION(scope, ); - JSC::MarkedArgumentBuffer publishArgs; - publishArgs.append(JSC::jsNumber(index)); - publishArgs.append(array); - JSC::JSFunction* publish = process->consolePublish(); - JSC::call(globalObject, publish, JSC::getCallData(publish), JSC::jsUndefined(), publishArgs); - RETURN_IF_EXCEPTION(scope, ); - // Subscribers get the live argument list in Node (the same - // array is then formatted), so take back whatever they did to it. - count = std::min(static_cast(array->length()), (size_t)255); - for (size_t i = 0; i < count; i++) { - jsArgs[i] = JSC::JSValue::encode(array->getIndex(globalObject, i)); - RETURN_IF_EXCEPTION(scope, ); - } - } - } - } - } - RELEASE_AND_RETURN(scope, Bun__ConsoleObject__messageWithTypeAndLevel(this->m_client, static_cast(type), static_cast(level), globalObject, jsArgs, count)); } void ConsoleObject::count(JSGlobalObject* globalObject, const String& label) diff --git a/src/jsc/bindings/ZigGlobalObject.cpp b/src/jsc/bindings/ZigGlobalObject.cpp index f0e01cac6b90..142b56310c62 100644 --- a/src/jsc/bindings/ZigGlobalObject.cpp +++ b/src/jsc/bindings/ZigGlobalObject.cpp @@ -2823,27 +2823,34 @@ JSC_DEFINE_CUSTOM_GETTER(getConsoleConstructor, (JSGlobalObject * globalObject, return JSValue::encode(result); } -// `console._stdout` / `console._stderr`: what the global console writes -// through. Accessors (not cached data properties) so that assignment rebinds -// the native console, exactly like Node's kBindStreamsLazy get/set pair: -// https://github.com/nodejs/node/blob/v24.0.0/lib/internal/console/constructor.js#L205-L234 -JSC_DEFINE_CUSTOM_GETTER(getConsoleStdout, (JSGlobalObject * globalObject, EncodedJSValue thisValue, PropertyName)) +// `console._stdout` is equal to `process.stdout` +JSC_DEFINE_CUSTOM_GETTER(getConsoleStdout, (JSGlobalObject * globalObject, EncodedJSValue thisValue, PropertyName property)) { - return JSValue::encode(uncheckedDowncast(globalObject)->processObject()->consoleStreamForGetter(globalObject, 1)); -} -JSC_DEFINE_CUSTOM_SETTER(setConsoleStdout, (JSGlobalObject * globalObject, EncodedJSValue thisValue, EncodedJSValue value, PropertyName)) -{ - uncheckedDowncast(globalObject)->processObject()->setConsoleStream(JSC::getVM(globalObject), 1, JSValue::decode(value)); - return true; -} -JSC_DEFINE_CUSTOM_GETTER(getConsoleStderr, (JSGlobalObject * globalObject, EncodedJSValue thisValue, PropertyName)) -{ - return JSValue::encode(uncheckedDowncast(globalObject)->processObject()->consoleStreamForGetter(globalObject, 2)); + auto& vm = JSC::getVM(globalObject); + auto console = JSValue::decode(thisValue).getObject(); + auto global = uncheckedDowncast(globalObject); + + // instead of calling the constructor builtin, go through the process.stdout getter to ensure it's only created once. + auto stdoutValue = global->processObject()->get(globalObject, Identifier::fromString(vm, "stdout"_s)); + if (!stdoutValue) return {}; + + console->putDirect(vm, property, stdoutValue, PropertyAttribute::DontEnum | 0); + return JSValue::encode(stdoutValue); } -JSC_DEFINE_CUSTOM_SETTER(setConsoleStderr, (JSGlobalObject * globalObject, EncodedJSValue thisValue, EncodedJSValue value, PropertyName)) + +// `console._stderr` is equal to `process.stderr` +JSC_DEFINE_CUSTOM_GETTER(getConsoleStderr, (JSGlobalObject * globalObject, EncodedJSValue thisValue, PropertyName property)) { - uncheckedDowncast(globalObject)->processObject()->setConsoleStream(JSC::getVM(globalObject), 2, JSValue::decode(value)); - return true; + auto& vm = JSC::getVM(globalObject); + auto console = JSValue::decode(thisValue).getObject(); + auto global = uncheckedDowncast(globalObject); + + // instead of calling the constructor builtin, go through the process.stdout getter to ensure it's only created once. + auto stderrValue = global->processObject()->get(globalObject, Identifier::fromString(vm, "stderr"_s)); + if (!stderrValue) return {}; + + console->putDirect(vm, property, stderrValue, PropertyAttribute::DontEnum | 0); + return JSValue::encode(stderrValue); } // The CommonJS `require()` machinery (`@requireESM`, `@loadEsmIntoCjs`, @@ -3078,10 +3085,8 @@ void GlobalObject::addBuiltinGlobals(JSC::VM& vm) consoleObject->putDirectBuiltinFunction(vm, this, vm.propertyNames->asyncIteratorSymbol, consoleObjectAsyncIteratorCodeGenerator(vm), PropertyAttribute::Builtin | 0); consoleObject->putDirectBuiltinFunction(vm, this, clientData->builtinNames().writePublicName(), consoleObjectWriteCodeGenerator(vm), PropertyAttribute::Builtin | 0); consoleObject->putDirectCustomAccessor(vm, Identifier::fromString(vm, "Console"_s), CustomGetterSetter::create(vm, getConsoleConstructor, nullptr), PropertyAttribute::CustomValue | 0); - consoleObject->putDirectCustomAccessor(vm, Identifier::fromString(vm, "_stdout"_s), CustomGetterSetter::create(vm, getConsoleStdout, setConsoleStdout), PropertyAttribute::DontEnum | PropertyAttribute::CustomAccessor | 0); - consoleObject->putDirectCustomAccessor(vm, Identifier::fromString(vm, "_stderr"_s), CustomGetterSetter::create(vm, getConsoleStderr, setConsoleStderr), PropertyAttribute::DontEnum | PropertyAttribute::CustomAccessor | 0); - // Node parity: the global console ignores stream errors. - consoleObject->putDirect(vm, Identifier::fromString(vm, "_ignoreErrors"_s), jsBoolean(true), PropertyAttribute::DontEnum | 0); + consoleObject->putDirectCustomAccessor(vm, Identifier::fromString(vm, "_stdout"_s), CustomGetterSetter::create(vm, getConsoleStdout, nullptr), PropertyAttribute::DontEnum | PropertyAttribute::CustomValue | 0); + consoleObject->putDirectCustomAccessor(vm, Identifier::fromString(vm, "_stderr"_s), CustomGetterSetter::create(vm, getConsoleStderr, nullptr), PropertyAttribute::DontEnum | PropertyAttribute::CustomValue | 0); } // ===================== start conditional builtin globals ===================== diff --git a/src/jsc/bindings/c-bindings.cpp b/src/jsc/bindings/c-bindings.cpp index 72bd05b0278e..a3fd0268c052 100644 --- a/src/jsc/bindings/c-bindings.cpp +++ b/src/jsc/bindings/c-bindings.cpp @@ -587,59 +587,12 @@ static termios termios_to_restore_later[3]; // from normal execution; sig_atomic_t is the only integral type POSIX // guarantees can be accessed atomically across that boundary. extern "C" volatile sig_atomic_t bun_stdio_modified[3] = { 0, 0, 0 }; - -// Startup snapshot of each stdio fd's file status flags and identity, so the -// O_NONBLOCK bit can be put back on exit if we (or a child sharing the open -// file description) changed it. Same contract as Node's ResetStdio(): -// https://github.com/nodejs/node/blob/v24.0.0/src/node.cc#L662-L722 -static struct { - int flags; // fcntl(F_GETFL) at startup, or -1 - dev_t dev; - ino_t ino; -} bun_stdio_startup_state[3] = { { -1, 0, 0 }, { -1, 0, 0 }, { -1, 0, 0 } }; - -extern "C" void Bun__stdioMadeBlocking(int fd); - -static void bun_restore_stdio_nonblock() -{ - for (int fd = 0; fd < 3; fd++) { - auto& s = bun_stdio_startup_state[fd]; - if (s.flags == -1) - continue; - - struct stat st; - if (fstat(fd, &st) == -1) - continue; // Program closed the file descriptor. - if (st.st_dev != s.dev || st.st_ino != s.ino) - continue; // Program reopened the file descriptor as something else. - - int flags; - do - flags = fcntl(fd, F_GETFL); - while (flags == -1 && errno == EINTR); - if (flags == -1) - continue; - - if ((flags ^ s.flags) & O_NONBLOCK) { - flags = (flags & ~O_NONBLOCK) | (s.flags & O_NONBLOCK); - int err; - do - err = fcntl(fd, F_SETFL, flags); - while (err == -1 && errno == EINTR); - // If we keep running after this (a failed execve), the stdio sink - // that had set O_NONBLOCK must stop expecting EAGAIN. - if (err == 0 && !(flags & O_NONBLOCK)) - Bun__stdioMadeBlocking(fd); - } - } -} #endif extern "C" void bun_restore_stdio() { #if !OS(WINDOWS) - bun_restore_stdio_nonblock(); // Only suppress the restore when Bun is a pipeline producer (stdout is a // pipe, not a TTY) and it didn't touch termios itself. That's the #29592 @@ -752,7 +705,7 @@ extern "C" void bun_initialize_process() #if OS(LINUX) || OS(DARWIN) || OS(FREEBSD) int devNullFd_ = -1; - bool restoreOnSignal = false; + bool anyTTYs = false; const auto setDevNullFd = [&](int target_fd) -> void { bun_is_stdio_null[target_fd] = 1; @@ -784,27 +737,8 @@ extern "C" void bun_initialize_process() if (errno == EBADF) [[unlikely]] { // the fd is invalid, let's make sure it's always valid setDevNullFd(fd); - continue; - } - } - - { - struct stat st; - int flags; - do - flags = fcntl(fd, F_GETFL); - while (flags == -1 && errno == EINTR); - if (flags != -1 && fstat(fd, &st) == 0) { - bun_stdio_startup_state[fd] = { flags, st.st_dev, st.st_ino }; - // A FIFO is the one stdio kind whose O_NONBLOCK bit we may flip - // (FileSink::stdio_go_nonblocking), so make sure the signal-exit - // path restores it. - if (S_ISFIFO(st.st_mode)) - restoreOnSignal = true; } - } - - if (result != 0) { + } else { bun_stdio_tty[fd] = 1; int err = 0; @@ -813,7 +747,7 @@ extern "C" void bun_initialize_process() } while (err == -1 && errno == EINTR); if (err == 0) [[likely]] { - restoreOnSignal = true; + anyTTYs = true; } } } @@ -823,8 +757,8 @@ extern "C" void bun_initialize_process() close(devNullFd_); } - // Restore TTY state / O_NONBLOCK on exit - if (restoreOnSignal) { + // Restore TTY state on exit + if (anyTTYs) { struct sigaction sa; memset(&sa, 0, sizeof(sa)); sigemptyset(&sa.sa_mask); @@ -832,15 +766,8 @@ extern "C" void bun_initialize_process() sa.sa_flags = SA_RESETHAND; sa.sa_handler = onExitSignal; - for (int sig : { SIGTERM, SIGINT }) { - // An inherited SIG_IGN (`trap '' INT TERM; bun ... | cat`) means the - // signal can't terminate us, so there is nothing to restore on it — - // and replacing it would make us (and our children) killable again. - struct sigaction current; - if (sigaction(sig, nullptr, ¤t) == 0 && current.sa_handler == SIG_IGN) - continue; - sigaction(sig, &sa, nullptr); - } + sigaction(SIGTERM, &sa, nullptr); + sigaction(SIGINT, &sa, nullptr); } #elif OS(WINDOWS) for (int fd = 0; fd <= 2; ++fd) { diff --git a/src/jsc/rare_data.rs b/src/jsc/rare_data.rs index 5859361156ba..bf86c154113c 100644 --- a/src/jsc/rare_data.rs +++ b/src/jsc/rare_data.rs @@ -206,13 +206,6 @@ pub struct RareData { pub stdout_store: Option>, pub(crate) stdout_mode: Mode, - /// Erased `*mut bun_runtime::webcore::FileSink` — the per-VM stdio sink for - /// fd 1 (`[0]`) and fd 2 (`[1]`). Everything this JS thread writes to those - /// fds (console, `process.stdout/stderr`, `Bun.stdout.writer()`, ...) goes - /// through these; created lazily by `bun_runtime` (`stdio_sink_for`), one - /// intrusive ref owned here and released in `Drop`. - pub stdio_sinks: [Option>; 2], - pub(crate) entropy_cache: Option>, pub(crate) hot_map: Option, @@ -297,7 +290,6 @@ impl Default for RareData { stdin_mode: 0, stdout_store: None, stdout_mode: 0, - stdio_sinks: [None, None], entropy_cache: None, hot_map: None, cleanup_hooks: Vec::new(), @@ -698,18 +690,6 @@ impl RareData { pub(crate) fn release_js_handles(&mut self) { self.s3_default_client.deinit(); self.node_quic_callbacks.deinit(); - self.release_stdio_sinks(); - } - - /// Drop `RareData`'s ref on the stdio sinks. Must run while the event loop - /// (poll deregistration) and JSC (a live `JSFileSink` wrapper may hold the - /// other ref) are both still up — i.e. from `release_js_handles`, never - /// from `Drop`. - pub fn release_stdio_sinks(&mut self) { - for sink in self.stdio_sinks.iter_mut().filter_map(Option::take) { - // SAFETY: `sink` is the +1 pointer `stdio_sink_for` stored. - unsafe { __bun_stdio_sink_deinit(sink.as_ptr().cast()) }; - } } // ── trivial field accessors ──────────────────────────────────────────── @@ -970,13 +950,6 @@ impl RareData { unsafe extern "Rust" { safe fn __bun_stdio_blob_store_deinit(ptr: *mut ()); - /// Releases `RareData`'s ref on a stdio `FileSink` (defined in - /// `bun_runtime::webcore::file_sink`). `ptr` is the exact pointer stored in - /// `stdio_sinks`. - fn __bun_stdio_sink_deinit(ptr: *mut ()); - /// Synchronously drain both stdio sinks of `vm` (defined next to the above). - pub(crate) fn __bun_stdio_sink_drain(vm: *mut VirtualMachine); - pub(crate) fn __bun_stdio_sink_release_js(vm: *mut VirtualMachine); } impl RareData { @@ -1160,9 +1133,6 @@ impl Drop for RareData { { __bun_stdio_blob_store_deinit(store.as_ptr().cast()); } - // `release_js_handles()` ran before the loop / JSC went away; a sink - // still here would need both to deinit, so it is deliberately leaked. - debug_assert!(self.stdio_sinks.iter().all(Option::is_none)); self.detach_socket_groups_from_loop(); } diff --git a/src/runtime/cli/test_command.rs b/src/runtime/cli/test_command.rs index ef73aa877c4b..17bbca36c5d1 100644 --- a/src/runtime/cli/test_command.rs +++ b/src/runtime/cli/test_command.rs @@ -2007,13 +2007,6 @@ impl TestCommand { Some(bun_jsc::virtual_machine::synthetic_allocation_limit()); } - // Materialise the runner's stdio sinks (a dup of fd 1 / fd 2 each) up - // front rather than at some test's first `console.log`, so tests that - // audit open fds around their own work see a stable set. - for fd in [bun_sys::Fd::stdout(), bun_sys::Fd::stderr()] { - let _ = crate::webcore::file_sink::stdio_sink_for(vm, fd); - } - if ctx.test_options.test_worker { // Worker mode: skip discovery; files arrive over stdin and // results go out over fd 3. Never returns. diff --git a/src/runtime/dispatch.rs b/src/runtime/dispatch.rs index 974bd8f3dabb..8623f45f375c 100644 --- a/src/runtime/dispatch.rs +++ b/src/runtime/dispatch.rs @@ -868,15 +868,7 @@ pub(crate) unsafe fn __bun_run_file_poll(poll: *mut FilePoll, size_or_offset: i6 } } - poll_tag::FILE_SINK => poll_arm!(FileSinkPoll, |h| { - // SAFETY: tag matched (see `poll_arm!`); `parent_ptr` is the owning - // FileSink. A stdio sink re-checks its fd mode before the writable - // callback flushes (spawn may have made the description blocking). - unsafe { - crate::webcore::FileSink::before_writable((*h).parent_ptr()); - (*h).on_poll(size_or_offset as isize, hup) - } - }), + poll_tag::FILE_SINK => poll_arm!(FileSinkPoll), poll_tag::STATIC_PIPE_WRITER => poll_arm!(StaticPipeWriterPoll>), poll_tag::SHELL_STATIC_PIPE_WRITER => { poll_arm!(StaticPipeWriterPoll) diff --git a/src/runtime/jsc_hooks.rs b/src/runtime/jsc_hooks.rs index e0a1178187b4..ad0c1ed8ee8b 100644 --- a/src/runtime/jsc_hooks.rs +++ b/src/runtime/jsc_hooks.rs @@ -338,8 +338,6 @@ unsafe fn init_runtime_state( // PORTING.md §Forbidden permits // `into_raw`-without-reclaim only for true process-lifetime singletons via // `OnceLock`, which this is not (per-VM / per-Worker-thread). - bun_sys::set_stdio_write_hook(crate::webcore::file_sink::before_output_write); - let state = bun_core::heap::into_raw(Box::new(RuntimeState { timer: timer::All::init(), sql_rare: bun_sql_jsc::jsc::RareData { diff --git a/src/runtime/node/node_fs.rs b/src/runtime/node/node_fs.rs index 9f9ee6128e1b..eb4d3298dae2 100644 --- a/src/runtime/node/node_fs.rs +++ b/src/runtime/node/node_fs.rs @@ -4802,7 +4802,7 @@ impl NodeFS { let mut broke = false; 'toplevel: while remain > 0 { let read_len = (buf.len() as u64).min(remain) as usize; - let amt = match Syscall::read_retrying(src_fd, &mut buf[..read_len]) { + let amt = match Syscall::read(src_fd, &mut buf[..read_len]) { Ok(result) => result, Err(err) => { return Err(if !src.is_empty() { @@ -4822,7 +4822,7 @@ impl NodeFS { let mut slice = &buf[..amt]; while !slice.is_empty() { - let written = match Syscall::write_retrying(dest_fd, slice) { + let written = match Syscall::write(dest_fd, slice) { Ok(result) => result, Err(err) => { return Err(if !dest.is_empty() { @@ -4841,7 +4841,7 @@ impl NodeFS { } if !broke { 'outer: loop { - let amt = match Syscall::read_retrying(src_fd, buf) { + let amt = match Syscall::read(src_fd, buf) { Ok(result) => result, Err(err) => { return Err(if !src.is_empty() { @@ -4860,7 +4860,7 @@ impl NodeFS { let mut slice = &buf[..amt]; while !slice.is_empty() { - let written = match Syscall::write_retrying(dest_fd, slice) { + let written = match Syscall::write(dest_fd, slice) { Ok(result) => result, Err(err) => { return Err(if !dest.is_empty() { diff --git a/src/runtime/webcore/Blob.rs b/src/runtime/webcore/Blob.rs index 7952e315856b..0481df6636cc 100644 --- a/src/runtime/webcore/Blob.rs +++ b/src/runtime/webcore/Blob.rs @@ -1658,20 +1658,10 @@ impl BlobExt for Blob { ); } - // Every writer for fd 1 / fd 2 on this VM is the one stdio sink, so per-call options (`highWaterMark`) do not apply. - let stdio_sink = || { - let fd = stdio_fd_of_store(global_this, &store)?; - webcore::file_sink::stdio_sink_js(global_this, fd) - }; - #[cfg(windows)] { use bun_io::pipe_writer::BaseWindowsPipeWriter as _; - if let Some(js) = stdio_sink() { - return Ok(js); - } - let pathlike = &store.data.as_file().pathlike; // SAFETY: bun_vm() never returns null for a Bun-owned global. let vm = global_this.bun_vm().as_mut(); @@ -1781,9 +1771,6 @@ impl BlobExt for Blob { }; if let streams::Start::FileSink(ref mut opts) = stream_start { opts.input_path = input_path; - if let Some(js) = stdio_sink() { - return Ok(js); - } } if let bun_sys::Result::Err(err) = sink.start(&stream_start, context) { @@ -4645,33 +4632,6 @@ pub(crate) fn write_file_with_source_destination( // writeFileInternal / writeFile (Bun.write) // ────────────────────────────────────────────────────────────────────────── -/// `Some(Fd::stdout()|stderr())` when `store` is this VM's stdout/stderr store -/// or any fd-backed store on fd 1/2 — i.e. writes to it belong to the stdio sink. -pub(crate) fn stdio_fd_of_store(global_this: &JSGlobalObject, store: &Store) -> Option { - let store::Data::File(ref file) = store.data else { - return None; - }; - let PathOrFileDescriptor::Fd(fd) = file.pathlike else { - return None; - }; - match fd.stdio_tag() { - Some(bun_core::Stdio::StdOut) => return Some(Fd::stdout()), - Some(bun_core::Stdio::StdErr) => return Some(Fd::stderr()), - _ => {} - } - // SAFETY: bun_vm() never returns null for a Bun-owned global. - let vm = global_this.bun_vm().as_mut(); - let rare = vm.rare_data.as_ref()?; - let store_ptr = core::ptr::from_ref(store).cast::().cast_mut(); - if rare.stdout_store.map(|p| p.as_ptr()) == Some(store_ptr) { - Some(Fd::stdout()) - } else if rare.stderr_store.map(|p| p.as_ptr()) == Some(store_ptr) { - Some(Fd::stderr()) - } else { - None - } -} - /// ## Errors /// - If `path_or_blob` is a detached blob /// ## Panics @@ -4725,60 +4685,6 @@ pub(crate) fn write_file_internal( } } - // Bun.write(Bun.stdout | Bun.stderr, data): through the stdio sink, so it is - // ordered with everything already handed to the sink (console.*, - // process.stdout — though not with chunks process.stdout's Writable is still - // holding: cork(), backpressure buffer) and gets the same EAGAIN handling - // (and never lands on the thread pool, whose LIFO queue reversed - // back-to-back writes to a file-backed stdout). - if let PathOrBlob::Blob(ref b) = *path_or_blob { - if b.offset.get() == 0 && !b.is_s3() { - let stdio_fd = b - .store - .get() - .as_deref() - .and_then(|st| stdio_fd_of_store(cx.global(), st)); - if let Some(stdio_fd) = stdio_fd { - // SAFETY: bun_vm() is the live VM owning `cx.global()`. - let vm = cx.global().bun_vm().as_mut(); - if let Some(sink) = webcore::file_sink::stdio_sink_for(vm, stdio_fd) { - // An async JS writer, like process.stdout: a pipe may queue - // and resolve later rather than stall the loop. - // SAFETY: canonical live pointer held by RareData. - #[cfg(not(windows))] - unsafe { - (*sink).stdio_go_nonblocking() - }; - // SAFETY: as above. - let wrote = unsafe { (*sink).write_js_value(cx.global(), data, true, true)? }; - if let Some((result, accepted)) = wrote { - // Resolves with *this* call's byte count: right away if - // the fd took it, otherwise once the sink's queue has - // drained (the loop keeps running meanwhile). - return Ok(match result { - streams::Writable::Err(err) => { - JSPromise::rejected_promise(cx.global(), err.to_js(cx.global())) - .to_js() - } - // SAFETY: as above. - streams::Writable::Pending(_) => unsafe { - (*sink).add_stdio_waiter(cx.global(), accepted as f64) - }, - _ => JSPromise::resolved_promise_value( - cx.global(), - JSValue::js_number(accepted as f64), - ), - }); - } - // A Blob / stream source takes the general path below; at - // least keep it behind anything already queued. - // SAFETY: as above. - let _ = unsafe { webcore::FileSink::drain_sync(sink) }; - } - } - } - } - // If you're doing Bun.write(), try to go fast by writing short input on the main thread. // This is a heuristic, but it's a good one. // diff --git a/src/runtime/webcore/FileSink.rs b/src/runtime/webcore/FileSink.rs index 064fd358f1eb..daf5352746a6 100644 --- a/src/runtime/webcore/FileSink.rs +++ b/src/runtime/webcore/FileSink.rs @@ -53,27 +53,6 @@ pub struct FileSink { pub(crate) is_socket: Cell, pub(crate) fd: Cell, - /// `Fd::stdout()` / `Fd::stderr()` when this is the per-VM stdio sink for - /// that fd (see [`FileSink::create_stdio`]); `Fd::INVALID` otherwise. - pub(crate) stdio: Cell, - /// The one `JSFileSink` wrapper for a stdio sink, shared by - /// `process.stdout/stderr`, `Bun.stdout.writer()` and `Bun.file(1|2).writer()`. - /// Cleared before JSC teardown in `__bun_stdio_sink_deinit`. - pub(crate) stdio_js: JsCell, - /// A stdio sink's terminal write error (EPIPE, EIO, ...). Sticky: the fd - /// is gone, so every later write reports the same error again — which is - /// how Node's stdio streams behave (each write() re-fails) and what lets - /// `'error'` fire per call instead of the sink going quietly inert. - pub(crate) stdio_error: JsCell>, - /// `Bun.write(Bun.stdout, x)` calls waiting for the queue to drain, each - /// with the byte count it is to resolve with (the shared `pending` slot's - /// count belongs to whoever awaits the JS wrapper's promise). - pub(crate) stdio_waiters: JsCell>, - /// This stdio sink is relying on `pwritev2(RWF_NOWAIT)` (Linux ≥ 6.4 - /// pipes) instead of `O_NONBLOCK`; see `refresh_stdio_mode`. - #[cfg(any(target_os = "linux", target_os = "android"))] - pub(crate) stdio_rwf_nowait: Cell, - pub(crate) auto_flusher: JsCell, pub(crate) run_pending_later: FlushPendingTask, @@ -231,6 +210,104 @@ impl FileSink { } } +#[cfg(unix)] +unsafe extern "C" { + /// The `FileSink` wrapper behind `process.stdout` (1) / `process.stderr` (2), or empty before that stream exists. + safe fn Bun__Process__getStdioSink(global: &JSGlobalObject, fd: i32) -> JSValue; +} + +/// `console.*` is about to write to `fd` (1 or 2): what `process.stdout` / `process.stderr` queued for it goes out first. +#[cfg(unix)] +#[unsafe(no_mangle)] +fn __bun_flush_stdio_sink(global: &JSGlobalObject, fd: Fd) { + let sink = Bun__Process__getStdioSink(global, fd.native()); + if sink.is_empty() { + return; + } + let Some(sink) = JSSink::from_js(sink) else { + return; + }; + // SAFETY: `from_js` returned the wrapper's live `m_ctx`, the sink's own allocation; `_guard` keeps it live. + unsafe { + let this = &raw mut (*sink).sink; + let _guard = RefPtr::init_ref(this); + while (*this).writer.get().has_pending_data() && !(*this).done.get() { + FileSink::flush_buffered(this); + let writer = (*this).writer.get(); + if writer.has_pending_data() && sys::wait_for_writable(writer.get_fd()).is_err() { + break; + } + } + } +} + +#[unsafe(no_mangle)] +pub(crate) extern "C" fn Bun__ForceFileSinkToBeSynchronousForProcessObjectStdio( + _global: *mut JSGlobalObject, + jsvalue: JSValue, +) { + let Some(this_ptr) = JSSink::from_js(jsvalue) else { + return; + }; + // SAFETY: `from_js` returned a live `*mut JSSink` (= ThisSink); the + // first field is `sink: FileSink`, so `&(*this_ptr).sink` recovers the + // wrapped `*FileSink`. + let this: &FileSink = unsafe { &(*this_ptr).sink }; + + #[cfg(not(windows))] + { + this.force_sync.set(true); + // SAFETY(JsCell): single-field write; does not call into JS. + this.writer.with_mut(|w| w.force_sync = true); + if this.fd.get() != Fd::INVALID { + let _ = sys::update_nonblocking(this.fd.get(), false); + } + } + #[cfg(windows)] + { + // SAFETY(JsCell): closure does not call into JS — pure libuv FFI. + let did_set_blocking = this.writer.with_mut(|w| { + if let Some(source) = w.source.as_mut() { + match source { + bun_io::Source::Pipe(pipe) => { + // SAFETY: `pipe` is a live `Box` owned by `writer.source`; + // `uv_pipe_t` is `#[repr(C)]` with `uv_stream_t` as its first field + // (libuv handle subtyping), so the pointer cast is valid. + let rc = unsafe { + uv::uv_stream_set_blocking( + (&mut **pipe) as *mut uv::Pipe as *mut uv::uv_stream_t, + 1, + ) + }; + if rc == uv::ReturnCode::ZERO { + return true; + } + } + bun_io::Source::Tty(tty) => { + // SAFETY: `tty` is a live `BackRef` (heap or static stdin tty); + // `Tty` (via its first field, `uv::uv_tty_t`) embeds `uv_stream_t` as + // its first member, so the cast is the libuv handle-subtype downcast. + let rc = unsafe { + uv::uv_stream_set_blocking(tty.as_ptr().cast::(), 1) + }; + if rc == uv::ReturnCode::ZERO { + return true; + } + } + _ => {} + } + } + false + }); + if did_set_blocking { + return; + } + + // Fallback to WriteFile() if it fails. + this.force_sync.set(true); + } +} + impl FileSink { /// `bun.spawn`'s subprocess exited while this `FileSink` was its stdin. /// @@ -304,15 +381,10 @@ impl FileSink { (*this).run_pending_later.has.set(false); let _entered = (*this).event_loop().entered(); - let failure = match (*this).pending.get().result { - streams::Writable::Err(ref err) => Some(err.clone()), - _ => (*this).stdio_error(), - }; // SAFETY(JsCell): `WritablePending::run` resolves a JSPromise which may // re-enter JS, but no other path holds a borrow of `self.pending` for // the duration (host-fns gate on `pending.state != Pending` first). (*this).pending.get_mut().run(); - FileSink::settle_stdio_waiters(this, failure.as_ref()); // Release the JS wrapper reference now that the pending operation is complete. // This was held to prevent GC from collecting the wrapper while the async @@ -347,10 +419,6 @@ impl FileSink { (*this) .writer .with_mut(|w| w.update_ref(evtloop, has_pending_data)); - #[cfg(not(windows))] - if !has_pending_data && (*this).is_stdio() { - (*this).writer.with_mut(|w| w.unregister_poll()); - } if has_pending_data { if let Some(vm) = (*this).js_vm() { @@ -360,21 +428,10 @@ impl FileSink { } } - // Backed up: whoever is waiting on the pending promise keeps waiting. - // A stdio sink also waits out a small write coalesced behind an - // earlier remainder (its poll / autoflush is guaranteed to drain it), - // so process.stdout's `'drain'` can't fire early. - if has_pending_data - && (status == WriteStatus::Pending - || (cfg!(not(windows)) - && (*this).is_stdio() - && status != WriteStatus::EndOfFile)) - { + // if we are not done yet and has pending data we just wait so we do not runPending twice + if status == WriteStatus::Pending && has_pending_data { return; } - if !has_pending_data { - FileSink::settle_stdio_waiters(this, None); - } let was_pending = (*this).pending.get().state == streams::PendingState::Pending; let resumes = was_pending || (status == WriteStatus::Drained && !has_pending_data); @@ -443,26 +500,16 @@ impl FileSink { // alive by the ref the writer holds until its own `close()` returns. // SAFETY: caller contract — `this` is live with write+dealloc provenance. unsafe { - let err = if (*this).is_stdio() { - (*this).stdio_latch_error(err) - } else { - err - }; (*this).record_stream_error(streams::StreamError::Error(err.clone())); if (*this).pending.get().state == streams::PendingState::Pending { (*this) .pending .with_mut(|p| p.result = streams::Writable::Err(err)); - // A stdio sink can get here from inside `drain_sync` / - // `write_all_sync` (console.log, exit) with the writer borrowed - // and the stdio lock held: settle on the next tick, never here. - let defer = (*this).is_stdio() - || (*this) - .js_vm() - .is_some_and(|vm| vm.is_inside_deferred_task_queue.get()); - if defer { - (*this).run_pending_later(); - return; + if let Some(vm) = (*this).js_vm() { + if vm.is_inside_deferred_task_queue.get() { + (*this).run_pending_later(); + return; + } } FileSink::run_pending(this); @@ -654,517 +701,6 @@ impl FileSink { Self::init(fd, event_loop_) } - // ── stdio sinks ──────────────────────────────────────────────────────── - // - // One `FileSink` per (VM, fd ∈ {1, 2}) is *the* owner of every byte this - // JS thread sends to that fd: `console.*` formats a whole message and hands - // it to `write_all_sync`, `process.stdout`/`stderr`, `Bun.stdout.writer()` - // and `Bun.write(Bun.stdout, ..)` are JS façades over the same object, and - // `process.exit()` / fatal-error printing call `drain_sync` first. That is - // what gives Node's ordering guarantees (console.log is literally - // `process.stdout.write` there) without routing the console through JS. - // - // fd mode is decided once here and nowhere else: - // tty / regular file / anything else → blocking `write(2)`, description - // flags left untouched; - // FIFO → eager (syscall on every write) but non-blocking so a slow - // reader queues + `'drain'`s instead of stalling the loop — via - // `RWF_NOWAIT` where the kernel has it, else `O_NONBLOCK` (restored at - // exit by `bun_restore_stdio`, cleared for children by spawn); - // socket → `send(MSG_DONTWAIT)`, no description flag needed; - // Windows → the existing synchronous `SyncFile` path for everything. - // Every write loop below treats `EAGAIN` as "wait for POLLOUT", so nothing - // here depends on the description actually being in the mode we chose. - - /// Whether this sink is the VM's stdout/stderr sink. - #[inline] - pub fn is_stdio(&self) -> bool { - self.stdio.get() != Fd::INVALID - } - - /// stdio sinks report every fd error as `syscall: 'write'` (Node's stdio - /// streams do; ours may have used `send(2)`/`pwritev2(2)` underneath) and - /// latch the first one — see `stdio_error`. - fn stdio_latch_error(&self, mut err: sys::Error) -> sys::Error { - debug_assert!(self.is_stdio()); - err.syscall = sys::Tag::write; - if self.stdio_error.get().is_none() { - self.stdio_error.with_mut(|e| *e = Some(err.clone())); - } - // SAFETY: `self` is the canonical RareData-held stdio sink; settling - // only rejects promises (reactions run later as microtasks). - unsafe { FileSink::settle_stdio_waiters(core::ptr::from_ref(self).cast_mut(), Some(&err)) }; - err - } - - /// The latched terminal error of a stdio sink, if any. - #[inline] - pub fn stdio_error(&self) -> Option { - let e = self.stdio_error.get(); - if e.is_none() { None } else { e.clone() } - } - - /// The shared JS wrapper for a stdio sink (created on first request, and - /// again if a previous holder `close()`d — which detaches the wrapper but, - /// for stdio, only flushes the sink). - /// - /// Handing out a JS writer is also the point where a FIFO goes - /// non-blocking: `process.stdout.write()` / `FileSink.write()` promise - /// Node's "returns false, emits 'drain'" instead of stalling the loop, and - /// that needs `EAGAIN` from the kernel. Console-only programs never get - /// here and keep a plain blocking fd (nothing shared with a parent shell - /// or sibling process changes under them). - /// - /// # Safety - /// `this` is the canonical live stdio sink pointer held by `RareData`. - pub unsafe fn stdio_js(this: *mut FileSink, global: &JSGlobalObject) -> JSValue { - // SAFETY: caller contract; each access is a scoped reborrow. - unsafe { - debug_assert!((*this).is_stdio()); - if let Some(v) = (*this).stdio_js.get().get() { - if JSSink::from_js(v).is_some() { - return v; - } - } - #[cfg(not(windows))] - (*this).stdio_go_nonblocking(); - let v = (*this).to_js(global); - // SAFETY(JsCell): `Strong::set` is a root-slot write; no JS re-entry. - (*this).stdio_js.with_mut(|s| s.set(global, v)); - v - } - } - - /// Forget the cached wrapper (it stays valid for whoever holds it; the next - /// `stdio_js` makes a new one in the then-current global). - pub fn release_stdio_js(&self) { - self.stdio_js.with_mut(|s| s.deinit()); - self.stdio_waiters.with_mut(|w| w.clear()); - } - - /// `Bun.write(Bun.stdout|stderr, x)` whose bytes had to queue: a promise - /// resolved with `count` once the queue has drained (or rejected with the - /// error that stopped it). - pub fn add_stdio_waiter(&self, global: &JSGlobalObject, count: f64) -> JSValue { - let strong = bun_jsc::js_promise::Strong::init(global); - let value = strong.value(); - self.stdio_waiters.with_mut(|w| w.push((strong, count))); - value - } - - /// # Safety - /// `this` is the canonical live pointer; resolves promises (re-enters JS). - unsafe fn settle_stdio_waiters(this: *mut FileSink, failure: Option<&sys::Error>) { - // SAFETY: caller contract; the Vec is moved out before any JS runs. - let waiters = unsafe { (*this).stdio_waiters.replace(Vec::new()) }; - if waiters.is_empty() { - return; - } - // SAFETY: as above. - let Some(vm) = (unsafe { (*this).js_vm() }) else { - return; - }; - let global = vm.global(); - for (mut promise, count) in waiters { - let _ = match failure { - None => promise.resolve(global, JSValue::js_number(count)), - Some(err) => { - use bun_sys_jsc::ErrorJsc as _; - promise.reject(global, err.clone().to_js(global)) - } - }; - } - } - - /// Poll dispatch is about to run the writable callback for this sink. - /// - /// # Safety - /// `this` is the live FileSink owning the poll. - #[cfg(not(windows))] - #[inline] - pub unsafe fn before_writable(this: *mut FileSink) { - // SAFETY: caller contract; field reads/`Cell` writes only. - unsafe { (*this).refresh_stdio_mode() }; - } - - /// Something (spawn with inherited stdio) put our description back into - /// blocking mode after [`stdio_go_nonblocking`](Self::stdio_go_nonblocking): - /// stop treating the fd as `EAGAIN`-capable so a full pipe is handled by the - /// blocking-pipe strategy (`poll` before `write`) instead of a write that - /// was expected to return early. One relaxed load when nothing happened. - #[inline] - fn refresh_stdio_mode(&self) { - #[cfg(any(target_os = "linux", target_os = "android"))] - if self.stdio_rwf_nowait.get() && !sys::linux::RWFFlagSupport::is_maybe_supported() { - // The RWF_NOWAIT route was refused at runtime; take the portable one - // (unless a spawn has since asked for blocking stdio anyway). - self.stdio_rwf_nowait.set(false); - if !sys::stdio_made_blocking(self.stdio.get()) { - self.stdio_set_o_nonblock(self.writer.get().get_fd()); - } - return; - } - #[cfg(not(windows))] - if self.nonblocking.get() && self.is_stdio() && sys::stdio_made_blocking(self.stdio.get()) { - self.nonblocking.set(false); - if let Some(poll) = self.writer.get().get_poll() { - poll.clear_flag(bun_io::FilePollFlag::Nonblocking); - } - } - } - - /// See [`stdio_js`](Self::stdio_js). FIFO only; sockets get per-call - /// `MSG_DONTWAIT`, Linux ≥ 6.4 pipes honour `RWF_NOWAIT` on a blocking - /// description (torvalds/linux@afed6271f5b0, "pipe: set FMODE_NOWAIT on - /// pipes"), and ttys / files stay blocking as in Node. - #[cfg(not(windows))] - pub(crate) fn stdio_go_nonblocking(&self) { - if self.nonblocking.get() || !self.pollable.get() || self.is_socket.get() { - return; - } - // A spawn handed this description to a child blocking; it stays that - // way (see `refresh_stdio_mode`) — don't flip it back under the child. - if sys::stdio_made_blocking(self.stdio.get()) { - return; - } - #[cfg(any(target_os = "linux", target_os = "android"))] - if self.stdio_rwf_nowait.get() { - return; - } - let fd = self.writer.get().get_fd(); - if fd == Fd::INVALID { - return; - } - #[cfg(any(target_os = "linux", target_os = "android"))] - { - let v = bun_core::linux_kernel_version(); - if (v.major > 6 || (v.major == 6 && v.minor >= 4)) - && sys::linux::RWFFlagSupport::is_maybe_supported() - && sys::is_on_pipefs(fd) - { - // `pwritev2(RWF_NOWAIT)` on a blocking description. Should the - // kernel turn out to refuse it after all (seccomp, gVisor, ...), - // `refresh_stdio_mode` falls back to O_NONBLOCK. - self.stdio_rwf_nowait.set(true); - return; - } - } - self.stdio_set_o_nonblock(fd); - } - - #[cfg(not(windows))] - fn stdio_set_o_nonblock(&self, fd: Fd) { - let already = sys::get_fcntl_flags(fd) - .map(|f| f as i32 & sys::O::NONBLOCK != 0) - .unwrap_or(false); - if already || sys::set_nonblocking(fd).is_ok() { - self.nonblocking.set(true); - if let Some(poll) = self.writer.get().get_poll() { - poll.set_flag(bun_io::FilePollFlag::Nonblocking); - } - } - } - - /// Build the stdio sink for `stdio_fd` (1 or 2). - #[cfg(not(windows))] - pub fn create_stdio( - event_loop: impl Into, - stdio_fd: Fd, - ) -> sys::Result> { - debug_assert!(stdio_fd == Fd::stdout() || stdio_fd == Fd::stderr()); - - // Our own fd number for the same description, so a JS `close()`/GC can - // close *something* without ever closing 1/2, and so the poll has an - // fd it owns. - let fd = sys::dup(stdio_fd)?; - let (pollable, is_socket) = match sys::fstat(fd) { - Ok(st) => { - let mode = st.st_mode as sys::Mode; - ( - sys::S::ISFIFO(mode) || sys::S::ISSOCK(mode), - sys::S::ISSOCK(mode), - ) - } - Err(_) => (false, false), - }; - - let this = Self::create(event_loop, fd); - this.stdio.set(stdio_fd); - this.pollable.set(pollable); - this.is_socket.set(is_socket); - // As for any FileSink: only a TTY writes through immediately; - // files / pipes / sockets coalesce `Bun.stdout.writer().write()`s - // until `flush()` / end of tick. (The console and process.stdout - // paths don't go through the coalescing entry point.) - let force_sync = !pollable && sys::isatty(fd); - this.force_sync.set(force_sync); - this.writer.with_mut(|w| { - w.force_sync = force_sync; - // Idle stdio sinks keep no poll registered; `AutoFlusher` - // flushes coalesced `Bun.stdout.writer()` writes at end of tick. - w.poll_flushes_buffer = false; - }); - this.nonblocking.set( - pollable - && !is_socket - && sys::get_fcntl_flags(fd).is_ok_and(|f| f as i32 & sys::O::NONBLOCK != 0), - ); - - // Registered with the loop only while backed up (see `start_lazy`). - if let Err(err) = this.writer.with_mut(|w| w.start_lazy(fd, pollable)) { - fd.close(); - return Err(err); - } - if let Some(poll) = this.writer.get().get_poll() { - poll.set_flag(if is_socket { - bun_io::FilePollFlag::Socket - } else if this.nonblocking.get() { - bun_io::FilePollFlag::Nonblocking - } else { - bun_io::FilePollFlag::Fifo - }); - } - this.started.set(true); - Ok(this) - } - - #[cfg(windows)] - pub fn create_stdio( - event_loop: impl Into, - stdio_fd: Fd, - ) -> sys::Result> { - debug_assert!(stdio_fd == Fd::stdout() || stdio_fd == Fd::stderr()); - let this = Self::init(stdio_fd, event_loop); - this.stdio.set(stdio_fd); - this.force_sync.set(true); - this.writer.with_mut(|w| w.owns_fd = false); - this.writer.with_mut(|w| w.start_sync(stdio_fd, false))?; - let evtloop = this.io_evtloop(); - this.writer.with_mut(|w| w.update_ref(evtloop, false)); - this.started.set(true); - Ok(this) - } - - /// Write everything queued in the writer to the fd *now*, blocking (via - /// `poll`) if the description is non-blocking and the reader is slow. Used - /// before console output, before fatal-error printing and at exit, so that - /// bytes `process.stdout.write()` had to queue never come out after (or get - /// dropped in favour of) what follows. - /// - /// # Safety - /// `this` must be the canonical live `*mut FileSink` (see - /// [`on_attached_process_exit`](Self::on_attached_process_exit)); settling - /// a pending write schedules a task but never re-enters JS synchronously. - pub unsafe fn drain_sync(this: *mut FileSink) -> sys::Result<()> { - // SAFETY: caller contract. - unsafe { - if !(*this).writer.get().has_pending_data() { - return Ok(()); - } - (*this).refresh_stdio_mode(); - let _lock = bun_io::StdioLock::acquire((*this).stdio.get()); - let _guard = RefPtr::init_ref(this); - - let mut result = Ok(()); - loop { - if !(*this).writer.get().has_pending_data() { - break; - } - // SAFETY(JsCell): `flush` is pure I/O; `drain_buffered_data` - // does not call `on_write`. It may call the writer's - // `on_error` (→ `FileSink::on_error`, which only schedules). - match (*this).writer.with_mut(|w| w.flush()) { - WriteResult::Err(err) => { - let err = (*this).stdio_latch_error(err); - if (*this).pending.get().state == streams::PendingState::Pending { - (*this) - .pending - .with_mut(|p| p.result = streams::Writable::Err(err.clone())); - (*this).run_pending_later(); - } - (*this).writer.with_mut(|w| w.end()); - result = Err(err); - break; - } - WriteResult::Done(n) => { - (*this).written.set((*this).written.get() + n); - break; - } - WriteResult::Wrote(n) | WriteResult::Pending(n) => { - (*this).written.set((*this).written.get() + n); - if (*this).writer.get().has_pending_data() { - #[cfg(unix)] - { - let fd = (*this).writer.get().get_fd(); - if fd == Fd::INVALID || !sys::wait_until_writable(fd) { - break; - } - } - } - } - } - } - - // The queue is empty (or the fd is gone): whatever JS was waiting - // on backpressure is settled on the next tick, never from here. - (*this).update_ref(false); - #[cfg(not(windows))] - (*this).writer.with_mut(|w| w.unregister_poll()); - if result.is_ok() && (*this).pending.get().state == streams::PendingState::Pending { - (*this).run_pending_later(); - } - if result.is_ok() { - FileSink::settle_stdio_waiters(this, None); - } - if (*this).source_pending_pull.replace(false) { - let mut src = *(*this).source.get(); - src.ready(None, None); - } - // `drain_buffered_data` reports a write that failed part-way as the - // bytes it did push (routing the error through `on_error`); the - // latch is what says the rest never made it. - match (result, (*this).stdio_error()) { - (Ok(()), Some(err)) => Err(err), - (result, _) => result, - } - } - } - - /// The console path: one fully formatted message (or a spilled part of - /// one), delivered before this returns. Anything already queued goes out - /// first (ordering), then `bytes` are written straight from the caller's - /// buffer. Holds [`bun_io::StdioLock`] for exactly this write — never while - /// formatting or any other JS runs — so no thread can hold one stdio lock - /// while waiting on the other. - /// - /// # Safety - /// Same contract as [`drain_sync`](Self::drain_sync). - pub unsafe fn write_all_sync(this: *mut FileSink, bytes: &[u8]) -> sys::Result<()> { - // SAFETY: caller contract. - unsafe { - let _lock = bun_io::StdioLock::acquire((*this).stdio.get()); - if (*this).writer.get().has_pending_data() { - FileSink::drain_sync(this)?; - } - if let Some(err) = (*this).stdio_error() { - return Err(err); - } - - #[cfg(windows)] - { - // `SyncFile` writes are already a blocking loop. - match (*this).writer.with_mut(|w| w.write(bytes)) { - WriteResult::Err(err) => Err((*this).stdio_latch_error(err)), - WriteResult::Done(n) if n < bytes.len() => { - // The handle went away part-way: say so rather than - // report the tail as written. - Err((*this).stdio_latch_error(sys::Error::from_code( - sys::E::EPIPE, - sys::Tag::write, - ))) - } - _ => { - (*this).written.set((*this).written.get() + bytes.len()); - Ok(()) - } - } - } - - #[cfg(not(windows))] - { - let mut bytes = bytes; - let fd = (*this).writer.get().get_fd(); - if fd == Fd::INVALID { - return Ok(()); - } - while !bytes.is_empty() { - match sys::write_retrying(fd, bytes) { - Ok(0) => { - // No progress on a non-empty buffer: report it - // rather than pretend the tail went out. - let e = (*this).stdio_latch_error(sys::Error::from_code( - sys::E::EIO, - sys::Tag::write, - )); - (*this).writer.with_mut(|w| w.fail(e.clone())); - return Err(e); - } - Ok(n) => { - bytes = &bytes[n..]; - (*this).written.set((*this).written.get() + n); - } - Err(e) => { - // Through the writer's error path, so the sink ends - // up exactly where a failed queued write would leave - // it (fd closed, error latched via `on_error`). - let e = (*this).stdio_latch_error(e); - (*this).writer.with_mut(|w| w.fail(e.clone())); - return Err(e); - } - } - } - Ok(()) - } - } - } - - /// `write()` for a JS `data` value that is a string / ArrayBuffer(View); - /// `Ok(None)` for anything else so the caller can take its general path. - /// `now`: attempt the syscall immediately (`IOWriter::write_now`) rather - /// than coalescing small chunks until end of tick. `count`: also return - /// this call's UTF-8 byte count (a pass over the string; only `Bun.write` - /// wants it) — otherwise the second element is unspecified. - pub fn write_js_value( - &self, - global: &JSGlobalObject, - data: JSValue, - now: bool, - count: bool, - ) -> JsResult> { - if let Some(buffer) = data.as_array_buffer(global) { - let _keep = bun_jsc::EnsureStillAlive(data); - let bytes = buffer.slice(); - if bytes.is_empty() { - return Ok(Some((streams::Writable::Owned(0), 0))); - } - return Ok(Some(self.write_with(Some(bytes.len() as u64), |w| { - if now { - w.write_now(bytes) - } else { - w.write(bytes) - } - }))); - } - if !data.is_string() { - return Ok(None); - } - let view = data.to_js_string_view(global)?; - if view.is_empty() { - return Ok(Some((streams::Writable::Owned(0), 0))); - } - if view.is_utf16() { - let utf16 = view.utf16(); - let len = - count.then(|| bun_core::strings::element_length_utf16_into_utf8(utf16) as u64); - return Ok(Some(self.write_with(len, |w| { - if now { - w.write_utf16_now(utf16) - } else { - w.write_utf16(utf16) - } - }))); - } - let latin1 = view.latin1(); - let len = count.then(|| bun_core::strings::element_length_latin1_into_utf8(latin1) as u64); - Ok(Some(self.write_with(len, |w| { - if now { - w.write_latin1_now(latin1) - } else { - w.write_latin1(latin1) - } - }))) - } - pub(crate) fn setup( &self, options: &Options, @@ -1427,21 +963,29 @@ impl FileSink { /// [`on_attached_process_exit`](Self::on_attached_process_exit)): live, /// with write+dealloc provenance over the allocation. pub(crate) unsafe fn on_auto_flush(this: *mut FileSink) -> bool { + // SAFETY: caller contract — `this` is live with write+dealloc provenance. + unsafe { + let _guard = RefPtr::init_ref(this); + let is_registered = FileSink::flush_buffered(this); + (*this) + .auto_flusher + .with_mut(|a| a.registered.set(is_registered)); + is_registered + } + } + + /// [`on_auto_flush`](Self::on_auto_flush) without the `AutoFlusher` bookkeeping, under its contract; the caller holds a ref across the call. + unsafe fn flush_buffered(this: *mut FileSink) -> bool { // SAFETY: caller contract — `this` is live with write+dealloc provenance. unsafe { if !(*this).writer.get().has_pending_data() { (*this).update_ref(false); - (*this).auto_flusher.with_mut(|a| a.registered.set(false)); return false; } // After `end()` the writable poll drains the tail and drops the ref. if (*this).done.get() { - (*this).auto_flusher.with_mut(|a| a.registered.set(false)); return false; } - (*this).refresh_stdio_mode(); - - let _guard = RefPtr::init_ref(this); let amount_buffered = (*this).writer.get().outgoing.size(); @@ -1455,33 +999,16 @@ impl FileSink { // `Owned(consumed)` result `to_result` seeded and // `run_pending_later()` alone would resolve it as if every // buffered byte had reached the reader. Latch the error and - // move the sink to its terminal state (mirrors `end_from_js`); - // a stdio sink stays "open" and re-fails per call instead. - let err = if (*this).is_stdio() { - (*this).stdio_latch_error(err) - } else { - (*this).record_stream_error(streams::StreamError::Error(err.clone())); - (*this).done.set(true); - err - }; + // move the sink to its terminal state (mirrors `end_from_js`). + (*this).record_stream_error(streams::StreamError::Error(err.clone())); + (*this).done.set(true); if (*this).pending.get().state == streams::PendingState::Pending { (*this) .pending - .with_mut(|p| p.result = streams::Writable::Err(err.clone())); - } - #[cfg(not(windows))] - if (*this).is_stdio() { - (*this).writer.with_mut(|w| w.fail(err)); - } else { - (*this).writer.with_mut(|w| w.end()); - } - #[cfg(windows)] - { - let _ = err; - (*this).writer.with_mut(|w| w.end()); + .with_mut(|p| p.result = streams::Writable::Err(err)); } + (*this).writer.with_mut(|w| w.end()); (*this).run_pending_later(); - (*this).auto_flusher.with_mut(|a| a.registered.set(false)); return false; } WriteResult::Done(_) => { @@ -1504,11 +1031,7 @@ impl FileSink { } } - let is_registered = !(*this).writer.get().has_pending_data(); - (*this) - .auto_flusher - .with_mut(|a| a.registered.set(is_registered)); - is_registered + !(*this).writer.get().has_pending_data() } } @@ -1533,7 +1056,6 @@ impl FileSink { return sys::Result::Ok(JSValue::UNDEFINED); } - self.refresh_stdio_mode(); let had_buffered_data = self.writer.get().has_pending_data(); // SAFETY(JsCell): `IOWriter::flush` is pure I/O; no JS re-entry while // the `&mut IOWriter` is held. @@ -1553,9 +1075,6 @@ impl FileSink { self.written.set(self.written.get() + written as usize); // @truncate written as u64 // @truncate } - WriteResult::Err(err) if self.is_stdio() => { - return sys::Result::Err(self.stdio_latch_error(err)); - } WriteResult::Err(err) => { return sys::Result::Err(err); } @@ -1681,10 +1200,17 @@ impl FileSink { } pub fn write(&self, data: &streams::Result) -> streams::Writable { - let len = self - .counting_stream_bytes() - .then(|| data.slice().len() as u64); - self.write_with(len, |w| w.write(data.slice())).0 + if self.done.get() { + return streams::Writable::Done; + } + let buffered_before = self.writer.get().buffered_len(); + // SAFETY(JsCell): `IOWriter::write` buffers/writes to fd; does not call JS. + let rc = self.writer.with_mut(|w| w.write(data.slice())); + if self.counting_stream_bytes() { + self.count_stream_bytes(&rc, data.slice().len()); + } + let accepted = self.bytes_accepted(buffered_before, &rc); + self.to_result(rc, accepted) } fn count_stream_bytes(&self, rc: &WriteResult, encoded_len: usize) { @@ -1718,53 +1244,37 @@ impl FileSink { } pub(crate) fn write_latin1(&self, data: &streams::Result) -> streams::Writable { - let len = self - .counting_stream_bytes() - .then(|| bun_core::strings::element_length_latin1_into_utf8(data.slice()) as u64); - self.write_with(len, |w| w.write_latin1(data.slice())).0 + if self.done.get() { + return streams::Writable::Done; + } + let buffered_before = self.writer.get().buffered_len(); + // SAFETY(JsCell): `IOWriter::write_latin1` buffers/writes; no JS. + let rc = self.writer.with_mut(|w| w.write_latin1(data.slice())); + if self.counting_stream_bytes() { + self.count_stream_bytes( + &rc, + bun_core::strings::element_length_latin1_into_utf8(data.slice()), + ); + } + let accepted = self.bytes_accepted(buffered_before, &rc); + self.to_result(rc, accepted) } pub(crate) fn write_utf16(&self, data: &streams::Result) -> streams::Writable { - let len = self - .counting_stream_bytes() - .then(|| bun_core::strings::element_length_utf16_into_utf8(data.slice16()) as u64); - self.write_with(len, |w| w.write_utf16(data.slice16())).0 - } - - /// The result, plus how many UTF-8 bytes *this call* handed the writer: - /// exact when the caller supplied `encoded_len` (the writer accepts all of - /// its input or errors), else only meaningful for `Pending`. - #[inline] - fn write_with( - &self, - encoded_len: Option, - f: impl FnOnce(&mut IOWriter) -> WriteResult, - ) -> (streams::Writable, u64) { - if let Some(err) = self.stdio_error() { - return (streams::Writable::Err(err), 0); - } - self.refresh_stdio_mode(); if self.done.get() { - return (streams::Writable::Done, 0); + return streams::Writable::Done; } let buffered_before = self.writer.get().buffered_len(); - // SAFETY(JsCell): `IOWriter::write*` buffers/writes to fd; does not call JS. - let rc = self.writer.with_mut(f); + // SAFETY(JsCell): `IOWriter::write_utf16` buffers/writes; no JS. + let rc = self.writer.with_mut(|w| w.write_utf16(data.slice16())); if self.counting_stream_bytes() { - if let Some(len) = encoded_len { - self.count_stream_bytes(&rc, len as usize); - } + self.count_stream_bytes( + &rc, + bun_core::strings::element_length_utf16_into_utf8(data.slice16()), + ); } - // What `to_result` credits a pending operation with. - let pending_credit = self.bytes_accepted(buffered_before, &rc); - let accepted = match rc { - WriteResult::Err(_) => 0, - // `n` may include bytes coalesced by earlier calls and flushed now, - // or be a partial write with the rest queued: not this call's count. - WriteResult::Wrote(n) | WriteResult::Done(n) => encoded_len.unwrap_or(n as u64), - WriteResult::Pending(_) => encoded_len.unwrap_or(pending_credit), - }; - (self.to_result(rc, pending_credit), accepted) + let accepted = self.bytes_accepted(buffered_before, &rc); + self.to_result(rc, accepted) } /// The source is done; `err` settles a piped stream. A failed ByteStream closes without a flush. @@ -1808,13 +1318,6 @@ impl FileSink { if self.done.get() { return sys::Result::Ok(()); } - if self.is_stdio() { - // The stdio sink outlives any one JS handle to it: ending - // `Bun.stdout.writer()` flushes, it does not take stdout away from - // `console.log` / `process.stdout`. - // SAFETY: `self` is the canonical RareData-held stdio sink. - return unsafe { FileSink::drain_sync(core::ptr::from_ref(self).cast_mut()) }; - } // A backpressured `write()` may have left its promise in `self.pending`; // `writer.end()` only re-enters `on_close`, which never touches it, so @@ -1880,12 +1383,6 @@ impl FileSink { } pub(crate) fn end_from_js(&self, cx: &bun_jsc::JsThread<'_>) -> sys::Result { - if self.is_stdio() { - // See `end()`: flush, report, stay open. - // SAFETY: `self` is the canonical RareData-held stdio sink. - unsafe { FileSink::drain_sync(core::ptr::from_ref(self).cast_mut()) }?; - return sys::Result::Ok(JSValue::js_number(self.written.get() as f64)); - } if self.done.get() { if self.pending.get().state == streams::PendingState::Pending { if let streams::WritableFuture::Promise { strong, .. } = &self.pending.get().future @@ -2119,11 +1616,6 @@ impl FileSink { streams::Writable::Temporary(amt as u64) } WriteResult::Err(err) => { - let err = if self.is_stdio() { - self.stdio_latch_error(err) - } else { - err - }; // A backpressured `write()` left its promise outstanding. `Writable::Err` becomes a // second, already rejected promise, and the caller of `write()` is awaiting the first: // the failure would be reported twice, once as an unhandled rejection. As in `end()`, @@ -2190,12 +1682,6 @@ impl FileSink { is_tty: Cell::new(false), is_socket: Cell::new(false), fd: Cell::new(fd), - stdio: Cell::new(Fd::INVALID), - stdio_js: JsCell::new(bun_jsc::strong::Optional::empty()), - stdio_error: JsCell::new(None), - stdio_waiters: JsCell::new(Vec::new()), - #[cfg(any(target_os = "linux", target_os = "android"))] - stdio_rwf_nowait: Cell::new(false), auto_flusher: JsCell::new(AutoFlusher::default()), run_pending_later: FlushPendingTask::default(), pipe: JsCell::new(streams::PipeCell::default()), @@ -2568,254 +2054,3 @@ bun_jsc::jsc_host_abi! { } } } - -// ─────────────────────────────────────────────────────────────────────────── -// Per-VM stdio sinks: accessor + the link-time externs the lower tiers call. -// ─────────────────────────────────────────────────────────────────────────── - -#[inline] -fn stdio_slot(fd: Fd) -> usize { - debug_assert!(fd == Fd::stdout() || fd == Fd::stderr()); - (fd == Fd::stderr()) as usize -} - -/// The stdio sink for `fd` (1 or 2) on this VM, created on first use. `None` -/// only if the sink could not be created (e.g. `dup` failed because fd 1/2 is -/// closed), in which case callers write to the fd directly. -pub(crate) fn stdio_sink_for(vm: &mut bun_jsc::VirtualMachineRef, fd: Fd) -> Option<*mut FileSink> { - if let Some(existing) = existing_stdio_sink(vm, fd) { - return Some(existing); - } - let event_loop = EventLoopHandle::init(vm.event_loop().cast::<()>()); - match FileSink::create_stdio(event_loop, fd) { - Ok(sink) => { - let sink = sink.into_non_null(); - vm.rare_data().stdio_sinks[stdio_slot(fd)] = Some(sink.cast()); - Some(sink.as_ptr()) - } - Err(err) => { - bun_core::scoped_log!(FileSink, "create_stdio({}) failed: {:?}", fd, err); - None - } - } -} - -/// The stdio sink for `fd` if it already exists on this VM (never creates). -fn existing_stdio_sink(vm: &mut bun_jsc::VirtualMachineRef, fd: Fd) -> Option<*mut FileSink> { - let rare = vm.rare_data.as_deref_mut()?; - rare.stdio_sinks[stdio_slot(fd)].map(|p| p.as_ptr().cast()) -} - -/// `$newRustFunction("runtime/webcore/FileSink.rs", "writeNow", 2)` — `(sink, chunk)`: the -/// stdio streams' `_write`. Same contract and return values as -/// `FileSink.prototype.write`, except the syscall is attempted immediately -/// instead of coalescing small chunks until end of tick (Node's -/// `process.stdout.write` is a `write(2)` per call, and a child spawned right -/// after must find the bytes already there). -#[bun_jsc::host_fn] -pub(crate) fn write_now(global: &JSGlobalObject, frame: &CallFrame) -> JsResult { - let [sink_value, data] = frame.arguments_as_array::<2>(); - let Some(this) = JSSink::from_js(sink_value) else { - return Err(global.throw(format_args!("This FileSink has already been closed."))); - }; - // SAFETY: `from_js` returned the live `m_ctx` of a `JSFileSink` wrapper. - // (FileSink has no `get_pending_error`, unlike the socket sinks.) - let sink: &FileSink = unsafe { &(*this).sink }; - let _keep = bun_jsc::EnsureStillAlive(data); - match sink.write_js_value(global, data, true, false)? { - Some((result, _)) => Ok(result.to_js(&global.js_thread_of_caller(frame))), - // Same errors as `FileSink.prototype.write` (Sink.rs `js_write`). - None => Err(global.throw_value(global.to_type_error( - if data.is_empty_or_undefined_or_null() { - bun_jsc::ErrorCode::STREAM_NULL_VALUES - } else { - bun_jsc::ErrorCode::INVALID_ARG_TYPE - }, - format_args!("write() expects a string, ArrayBufferView, or ArrayBuffer"), - ))), - } -} - -/// `JSSink.cpp` `FileSink__doClose`: is this the shared per-thread stdio sink? -#[unsafe(no_mangle)] -extern "C" fn FileSink__isStdio(ptr: *const FileSink) -> bool { - // SAFETY: `ptr` is the live `m_ctx` of a `JSFileSink` wrapper. - unsafe { (*ptr).is_stdio() } -} - -/// `bun_jsc::rare_data::__bun_stdio_sink_deinit` body: release `RareData`'s ref. -#[unsafe(no_mangle)] -unsafe fn __bun_stdio_sink_deinit(ptr: *mut ()) { - if ptr.is_null() { - return; - } - let this = ptr.cast::(); - // SAFETY: `ptr` is the exact +1 `*mut FileSink` `stdio_sink_for` stored; - // JSC is still alive when `release_stdio_sinks` runs, so dropping the - // wrapper root here is sound (the wrapper's own +1 is released by its - // finalizer). - unsafe { - (*this).release_stdio_js(); - // A stdio sink never reaches EOF/close, so a backpressure episode's - // keep-alive ref (`to_result` → `must_be_kept_alive_until_eof`) is - // still held; drop it with RareData's. - FileSink::clear_keep_alive_ref(this); - // User code that closes fds by number behind our back (and anything - // that then reuses and closes that number) can leave our dup already - // closed; tearing down must not trip close()'s use-after-close check. - #[cfg(not(windows))] - { - let fd = (*this).writer.get().get_fd(); - if fd != Fd::INVALID && sys::get_fcntl_flags(fd).is_err() { - (*this).writer.with_mut(|w| { - w.handle - .close_impl(None, None::, false) - }); - (*this).fd.set(Fd::INVALID); - } - } - FileSink::deref(this); - } -} - -/// `bun_jsc::virtual_machine::__bun_stdio_sink_release_js` body: a new global -/// is taking over this VM (`bun test` isolation); the old global's wrapper must -/// not be what the new one's `process.stdout` is built over, nor keep the old -/// realm alive from a VM-lifetime root. -/// -/// # Safety -/// `vm` is the live per-thread VM. -#[unsafe(no_mangle)] -unsafe fn __bun_stdio_sink_release_js(vm: *mut bun_jsc::VirtualMachineRef) { - // SAFETY: caller contract. - let vm = unsafe { &mut *vm }; - for fd in [Fd::stdout(), Fd::stderr()] { - if let Some(sink) = existing_stdio_sink(vm, fd) { - // SAFETY: canonical live pointer held by RareData. - unsafe { (*sink).release_stdio_js() }; - } - } -} - -/// `Bun.file(1|2).writer()` / `Bun.stdout.writer()` / `process.stdout`'s sink: -/// the shared wrapper over this VM's stdio sink, or `None` to fall back to an -/// ordinary FileSink when the stdio sink can't be created. -pub(crate) fn stdio_sink_js(global: &JSGlobalObject, fd: Fd) -> Option { - // SAFETY: `bun_vm()` is the live VM owning `global`. - let vm = global.bun_vm().as_mut(); - let sink = stdio_sink_for(vm, fd)?; - // SAFETY: canonical live pointer held by RareData. - Some(unsafe { FileSink::stdio_js(sink, global) }) -} - -/// `bun_jsc::console_object::__bun_stdio_sink_write` body — the console fast -/// path. Delivers `bytes` to fd `fd` through this VM's stdio sink before -/// returning (see [`FileSink::write_all_sync`]). -/// -/// # Safety -/// `vm` is the live per-thread VM. -#[unsafe(no_mangle)] -unsafe fn __bun_stdio_sink_write( - vm: *mut bun_jsc::VirtualMachineRef, - fd: Fd, - bytes: &[u8], -) -> Result<(), sys::Error> { - // SAFETY: caller contract. - let vm = unsafe { &mut *vm }; - match stdio_sink_for(vm, fd) { - // SAFETY: `sink` is the canonical live pointer held by RareData. - Some(sink) => match unsafe { FileSink::write_all_sync(sink, bytes) } { - Ok(()) => Ok(()), - // (The lock is released by now: reporting may print to the other fd.) - Err(err) => { - // Node: a console write that fails surfaces as 'error' on - // process.stdout/stderr *if someone is listening* (the console - // itself swallows it). Same here; never an uncaught exception. - report_stdio_error(vm.global(), fd, &err); - Err(err) - } - }, - None => { - // No sink (fd 1/2 could not be dup'd): best effort straight to the - // fd; there is no stream to report a failure on. - let _lock = bun_io::StdioLock::acquire(fd); - let _ = sys::write_all_retrying(fd, bytes); - Ok(()) - } - } -} - -unsafe extern "C" { - /// `BunProcess.cpp` — `stream.destroy(err)` on Bun's `process.stdout`/ - /// `stderr` object for `fd` iff it exists and has an `'error'` listener. - fn Bun__Process__reportStdioSinkError(global: &JSGlobalObject, fd: i32, err: JSValue); -} - -/// See `__bun_stdio_sink_write`. -fn report_stdio_error(global: &JSGlobalObject, fd: Fd, err: &sys::Error) { - use bun_sys_jsc::ErrorJsc as _; - let Ok(js_err) = err.clone().to_js(global) else { - return; - }; - let n = if fd == Fd::stdout() { 1 } else { 2 }; - // SAFETY: `js_err` is a fresh error object; the C++ side runs a builtin - // under a top-level exception scope and reports (never propagates) a throw. - unsafe { Bun__Process__reportStdioSinkError(global, n, js_err) }; -} - -/// [`bun_sys::set_stdio_write_hook`] target; installed once per process from -/// `init_runtime_state`. Runs on whatever thread `Output` is writing from, so -/// it only ever looks at *that* thread's VM. -pub(crate) fn before_output_write(fd: Fd) { - // The crash reporter / a panic prints through `Output` too; it must not - // take the stdio lock or wait on a reader (or touch a writer that may be - // mid-flush on this very stack). - if bun_core::is_panicking() { - return; - } - let Some(vm) = bun_jsc::VirtualMachineRef::get_or_null() else { - return; - }; - // SAFETY: the thread-local VM pointer is live for the thread's lifetime; - // `existing_stdio_sink` only reads `rare_data`. - let vm = unsafe { &mut *vm }; - if let Some(sink) = existing_stdio_sink(vm, fd) { - // SAFETY: canonical live pointer held by RareData; `drain_sync` is a - // no-op unless something is queued. - let _ = unsafe { FileSink::drain_sync(sink) }; - } -} - -/// See [`__bun_stdio_sink_drain`]; one fd. -/// -/// # Safety -/// `vm` is the live per-thread VM. -#[unsafe(no_mangle)] -unsafe fn __bun_stdio_sink_drain_fd(vm: *mut bun_jsc::VirtualMachineRef, fd: Fd) { - // SAFETY: caller contract. - let vm = unsafe { &mut *vm }; - if let Some(sink) = existing_stdio_sink(vm, fd) { - // SAFETY: canonical live pointer held by RareData. - let _ = unsafe { FileSink::drain_sync(sink) }; - } -} - -/// `bun_jsc::virtual_machine::__bun_stdio_sink_drain` body: synchronously drain -/// whatever `process.stdout`/`stderr` writes are still queued on this VM, so -/// what the caller prints next (a fatal error, the exit) cannot overtake or -/// discard them. No-op — not even an allocation — when the sinks were never -/// created. -/// -/// # Safety -/// `vm` is the live per-thread VM. -#[unsafe(no_mangle)] -unsafe fn __bun_stdio_sink_drain(vm: *mut bun_jsc::VirtualMachineRef) { - // SAFETY: caller contract. - let vm = unsafe { &mut *vm }; - for fd in [Fd::stdout(), Fd::stderr()] { - if let Some(sink) = existing_stdio_sink(vm, fd) { - // SAFETY: canonical live pointer held by RareData. - let _ = unsafe { FileSink::drain_sync(sink) }; - } - } -} diff --git a/src/runtime/webcore/blob/copy_file.rs b/src/runtime/webcore/blob/copy_file.rs index 57b91ee3c1dc..e41579856cdd 100644 --- a/src/runtime/webcore/blob/copy_file.rs +++ b/src/runtime/webcore/blob/copy_file.rs @@ -420,18 +420,10 @@ impl CopyFile { match bun_sys::get_errno(written) { bun_sys::E::SUCCESS => {} - bun_sys::E::EINTR => continue, - // XDEV: cross-device copy not supported // NOSYS: syscall not available // OPNOTSUPP: filesystem doesn't support this operation - // AGAIN: either end is a pipe whose description someone made - // O_NONBLOCK (a stdio pipe, typically); the read/write - // loop waits each side out properly. - bun_sys::E::ENOSYS - | bun_sys::E::EXDEV - | bun_sys::E::ENOTSUP - | bun_sys::E::EAGAIN => { + bun_sys::E::ENOSYS | bun_sys::E::EXDEV | bun_sys::E::ENOTSUP => { // TODO: this should use non-blocking I/O. return self.fallback_read_write(remain, unknown_size, &mut total_written); } @@ -995,14 +987,14 @@ fn read_write_loop_capped( let mut remaining = cap; while remaining > 0 { let want = (buf.len() as SizeType).min(remaining) as usize; - let amt = bun_sys::read_retrying(src_fd, &mut buf[..want])?; + let amt = bun_sys::read(src_fd, &mut buf[..want])?; if amt == 0 { break; } remaining -= amt as SizeType; let mut slice = &buf[..amt]; while !slice.is_empty() { - match bun_sys::write_retrying(dest_fd, slice)? { + match bun_sys::write(dest_fd, slice)? { 0 => return Ok(()), n => { *total += n as u64; diff --git a/src/spawn_sys/posix_spawn.rs b/src/spawn_sys/posix_spawn.rs index 41b5cac28e42..5a210eea3d7b 100644 --- a/src/spawn_sys/posix_spawn.rs +++ b/src/spawn_sys/posix_spawn.rs @@ -563,22 +563,6 @@ pub mod posix_spawn { let uid = attr.and_then(|a| a.uid); let gid = attr.and_then(|a| a.gid); - // A child that inherits one of our stdio fds shares its open file - // description, including an O_NONBLOCK bit our stdio sink may have set - // on a pipe. Hand it over blocking, like libuv does (state is shared, - // so this flips it for us too; `make_stdio_blocking` records that so - // the sinks stop expecting EAGAIN): - // https://github.com/libuv/libuv/blob/v1.51.0/src/unix/process.c#L629-L631 - if let Some(act) = actions { - for action in act.actions.iter() { - if action.kind == bun_spawn::FileActionType::Dup2 - && (0..=2).contains(&action.fds[0]) - { - sys::make_stdio_blocking(Fd::from_native(action.fds[0])); - } - } - } - // Use posix_spawn_bun when: // - Linux: always (uses vfork which is fast and safe) // - macOS: for PTY spawns (pty_slave_fd >= 0) because PTY setup requires diff --git a/src/sys/lib.rs b/src/sys/lib.rs index 17e3b31e1535..e6186e55caba 100644 --- a/src/sys/lib.rs +++ b/src/sys/lib.rs @@ -3592,8 +3592,7 @@ pub mod sys_uv; #[cfg(not(windows))] pub mod sys_uv { pub use super::{ - close, fstat, lstat, mkdir, open, pread, pwrite, read, read_retrying, rename, stat, unlink, - write, write_retrying, + close, fstat, lstat, mkdir, open, pread, pwrite, read, rename, stat, unlink, write, }; } @@ -7278,75 +7277,6 @@ pub fn get_fcntl_flags(_fd: Fd) -> Maybe { pub fn set_nonblocking(fd: Fd) -> Maybe<()> { update_nonblocking(fd, true) } -/// Bit `fd` (0–2) is set once something in this process has deliberately put -/// that stdio description back into blocking mode behind the stdio sinks' back -/// (spawn handing it to a child). Sinks that had gone non-blocking check it and -/// stop assuming `EAGAIN` semantics — see `FileSink::refresh_stdio_mode`. -pub static STDIO_MADE_BLOCKING: core::sync::atomic::AtomicU8 = core::sync::atomic::AtomicU8::new(0); - -#[cfg(unix)] -#[inline] -fn stdio_bit(fd: Fd) -> u8 { - match fd.native() { - n @ 0..=2 => 1u8 << n, - _ => 0, - } -} - -/// Clear `O_NONBLOCK` on one of our stdio fds and record it in -/// [`STDIO_MADE_BLOCKING`]. Best effort, like libuv's equivalent: a child is -/// still better off spawned with a non-blocking stdio than not spawned. -#[cfg(unix)] -pub fn make_stdio_blocking(fd: Fd) { - debug_assert!(stdio_bit(fd) != 0); - if update_nonblocking(fd, false).is_ok() { - // AcqRel: the outline-atomics helper for it is already on the aarch64 - // baseline allowlist (scripts/verify-baseline-static). - STDIO_MADE_BLOCKING.fetch_or(stdio_bit(fd), core::sync::atomic::Ordering::AcqRel); - } -} - -/// Whether `fd` is an anonymous pipe (lives on pipefs) rather than a named -/// FIFO opened from a real filesystem. Only the former get `FMODE_NOWAIT` -/// (torvalds/linux@afed6271f5b0), so only they honour `RWF_NOWAIT`; a FIFO -/// answers `EOPNOTSUPP`, which would also switch `RWFFlagSupport` off for the -/// whole process. -#[cfg(any(target_os = "linux", target_os = "android"))] -pub fn is_on_pipefs(fd: Fd) -> bool { - const PIPEFS_MAGIC: u32 = 0x5049_5045; - // SAFETY: all-zero is a valid `statfs`; fstatfs writes it fully on success. - let mut st: libc::statfs = unsafe { core::mem::zeroed() }; - loop { - // SAFETY: `fd` is a plain int; `st` is a live out-param. - let rc = unsafe { libc::fstatfs(fd.native(), &raw mut st) }; - if rc == 0 { - return st.f_type as u32 == PIPEFS_MAGIC; - } - if last_errno() != libc::EINTR { - return false; - } - } -} - -/// `c-bindings.cpp` `bun_restore_stdio_nonblock`: it just put `fd` back to -/// blocking (exit / signal / pre-execve restore); record it like -/// [`make_stdio_blocking`] so a process that survives (failed execve) has its -/// sinks downgrade. -#[cfg(unix)] -#[unsafe(no_mangle)] -pub extern "C" fn Bun__stdioMadeBlocking(fd: core::ffi::c_int) { - if let n @ 0..=2 = fd { - STDIO_MADE_BLOCKING.fetch_or(1u8 << n, core::sync::atomic::Ordering::AcqRel); - } -} - -/// See [`STDIO_MADE_BLOCKING`]. -#[cfg(unix)] -#[inline] -pub fn stdio_made_blocking(fd: Fd) -> bool { - STDIO_MADE_BLOCKING.load(core::sync::atomic::Ordering::Acquire) & stdio_bit(fd) != 0 -} - /// GETFL → toggle O_NONBLOCK → SETFL (only if changed). pub fn update_nonblocking(fd: Fd, nonblocking: bool) -> Maybe<()> { #[cfg(unix)] @@ -9294,55 +9224,17 @@ fn qw_set_fd(qw: &mut bun_core::output::QuietWriter, fd: Fd) { } } -/// Runs before this thread's `Output` bytes go to fd 1 / fd 2. The runtime -/// points it at "flush what this JS thread's `process.stdout`/`stderr` still -/// has queued for that fd" (`bun_runtime::webcore::file_sink`), which is what -/// keeps everything Bun itself prints — errors, the test reporter, prompts — -/// behind output the program already handed to its stdio streams. -static STDIO_WRITE_HOOK: core::sync::atomic::AtomicPtr<()> = - core::sync::atomic::AtomicPtr::new(core::ptr::null_mut()); - -pub fn set_stdio_write_hook(hook: fn(Fd)) { - STDIO_WRITE_HOOK.store(hook as *mut (), core::sync::atomic::Ordering::Release); -} - -#[inline] -fn run_stdio_write_hook(fd: Fd) { - if fd != Fd::stdout() && fd != Fd::stderr() { - return; - } - // Debug logging can fire from inside the sink's own I/O callbacks; never - // re-enter the sink from there. - if bun_core::output::is_inside_scoped_log() { - return; - } - let p = STDIO_WRITE_HOOK.load(core::sync::atomic::Ordering::Acquire); - if !p.is_null() { - // SAFETY: only ever stored from a `fn(Fd)` in `set_stdio_write_hook`. - let hook: fn(Fd) = unsafe { core::mem::transmute::<*mut (), fn(Fd)>(p) }; - hook(fd); - } -} - /// Best-effort write-all loop. Returns `false` on I/O error / zero-write so /// `ScopedLogger::log` can disable the scope; "quiet" callers discard the bool. -/// -/// `EAGAIN` is not an error here: `O_NONBLOCK` lives on the open file -/// description, so anything sharing fd 1/2 with us (a parent shell, a child, -/// libuv in a sibling process, another thread's stdio sink) can flip it at any -/// time. Wait for `POLLOUT` and keep going, which is exactly what a blocking -/// `write(2)` would have done. fn fd_write_all_quiet(fd: Fd, mut bytes: &[u8]) -> bool { - run_stdio_write_hook(fd); while !bytes.is_empty() { match write(fd, bytes) { Ok(0) => return false, // short write → give up Ok(n) => bytes = &bytes[n..], #[cfg(unix)] - Err(e) if e.get_errno() == E::EINTR => continue, - #[cfg(unix)] - Err(e) if e.is_retry() => { - if !wait_until_writable(fd) { + Err(e) if e.get_errno() == E::EAGAIN => { + // fd 1/2 are O_NONBLOCK once process.stdout/stderr exist (as in Node); wait instead of dropping output. + if wait_for_writable(fd).is_err() { return false; } } @@ -9352,66 +9244,15 @@ fn fd_write_all_quiet(fd: Fd, mut bytes: &[u8]) -> bool { true } -/// `write(2)` all of `bytes`, waiting out `EAGAIN`/`EINTR`; `false` on a real -/// error. Does not take ownership of (or close) `fd`. -#[inline] -pub fn write_all_retrying(fd: Fd, bytes: &[u8]) -> bool { - fd_write_all_quiet(fd, bytes) -} - -/// One `write(2)` that behaves as if `fd` were blocking regardless of the -/// description's `O_NONBLOCK` bit: `EAGAIN` waits for `POLLOUT`, `EINTR` -/// retries. For copy loops that run off the JS thread and may be handed a -/// stdio pipe someone else made non-blocking. -pub fn write_retrying(fd: Fd, bytes: &[u8]) -> Maybe { - loop { - match write(fd, bytes) { - #[cfg(unix)] - Err(e) if e.get_errno() == E::EINTR => continue, - #[cfg(unix)] - Err(e) if e.is_retry() => { - if !wait_until_writable(fd) { - return Err(e); - } - } - other => return other, - } - } -} - -/// `read(2)` counterpart of [`write_retrying`]. -pub fn read_retrying(fd: Fd, buf: &mut [u8]) -> Maybe { - loop { - match read(fd, buf) { - #[cfg(unix)] - Err(e) if e.get_errno() == E::EINTR => continue, - #[cfg(unix)] - Err(e) if e.is_retry() => { - if !wait_until(fd, posix::POLL_IN) { - return Err(e); - } - } - other => return other, - } - } -} - -/// Block until `fd` reports `POLLOUT` (or `POLLERR`/`POLLHUP`, which the next -/// `write` will turn into a real errno). `false` only if `poll` itself failed. -#[cfg(unix)] -#[inline] -pub fn wait_until_writable(fd: Fd) -> bool { - wait_until(fd, posix::POLL_OUT) -} - +/// Blocks until `fd` takes a write, or has an error for the next write to report. #[cfg(unix)] -fn wait_until(fd: Fd, events: i16) -> bool { +pub fn wait_for_writable(fd: Fd) -> Maybe<()> { let mut pfd = [posix::PollFd { fd: fd.native(), - events, + events: posix::POLL_OUT, revents: 0, }]; - posix::poll(&mut pfd, -1).is_ok() + posix::poll(&mut pfd, -1).map(|_| ()) } /// Concrete repr behind the opaque `bun_core::output::QuietWriterAdapter` diff --git a/src/sys/sys_uv.rs b/src/sys/sys_uv.rs index dcecb2dc2d7d..9cf9c4ef936b 100644 --- a/src/sys/sys_uv.rs +++ b/src/sys/sys_uv.rs @@ -820,18 +820,6 @@ pub fn pwrite(fd: Fd, buf: &[u8], position: i64) -> Result { Result::Ok(total_written) } -/// libuv fs writes are synchronous/blocking here; same as `write`. -#[inline] -pub fn write_retrying(fd: Fd, buf: &[u8]) -> Result { - write(fd, buf) -} - -/// Same as `read` on this backend. -#[inline] -pub fn read_retrying(fd: Fd, buf: &mut [u8]) -> Result { - read(fd, buf) -} - pub fn write(fd: Fd, buf: &[u8]) -> Result { // If buffer fits in a single uv_buf_t, use the simple path if buf.len() <= MAX_BUF_LEN { diff --git a/test/js/node/console/console.test.ts b/test/js/node/console/console.test.ts index d8277fe69e53..2913817f23c8 100644 --- a/test/js/node/console/console.test.ts +++ b/test/js/node/console/console.test.ts @@ -1,5 +1,4 @@ -import { describe, expect, spyOn, test } from "bun:test"; -import { bunEnv, bunExe } from "harness"; +import { describe, expect, test } from "bun:test"; import { Console } from "node:console"; import { Writable } from "node:stream"; @@ -66,275 +65,26 @@ describe("console.Console", () => { }); }); -// The global console binds its streams lazily through a get/set accessor pair -// (kBindStreamsLazy), so `_stdout` / `_stderr` are accessors, not data -// properties, and assigning them redirects the console: -// https://github.com/nodejs/node/blob/v24.0.0/lib/internal/console/constructor.js#L205-L234 -test.each(["_stdout", "_stderr"] as const)("console.%s", key => { - const stream = key === "_stdout" ? process.stdout : process.stderr; +test("console._stdout", () => { // @ts-ignore - expect(console[key]).toBe(stream); + expect(console._stdout).toBe(process.stdout); - const desc = Object.getOwnPropertyDescriptor(console, key)!; - expect(desc.enumerable).toBe(false); - expect(desc.configurable).toBe(true); - expect(typeof desc.get).toBe("function"); - expect(typeof desc.set).toBe("function"); - expect("value" in desc).toBe(false); -}); - -// ───────────────────────────────────────────────────────────────────────────── -// The global console writes through process.stdout / process.stderr the way -// Node's does (`this._stdout.write(chunk)`), so anything that observes those -// streams observes the console — while, unobserved, it never builds a JS string -// or enters JS at all. Each case runs in a fresh process so stream state does -// not leak between them. -// ───────────────────────────────────────────────────────────────────────────── -async function run(src: string) { - 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]); - return { stdout, stderr, exitCode }; -} - -describe.concurrent("global console -> process.stdout / process.stderr", () => { - test("a replaced process.stdout.write / process.stderr.write sees every console method, one write() per call", async () => { - const { stdout, stderr, exitCode } = await run(` - const seen = { out: [], err: [] }; - const { write: ow } = process.stdout, { write: ew } = process.stderr; - process.stdout.write = function (chunk, ...rest) { seen.out.push(String(chunk)); return true; }; - process.stderr.write = function (chunk, ...rest) { seen.err.push(String(chunk)); return true; }; - console.log("log %d", 1); - console.info("info"); - console.debug("debug"); - console.dir({ dir: 1 }); - console.dirxml("dirxml"); - console.table([{ a: 1 }]); - console.count("cnt"); console.count("cnt"); - console.group("grp"); console.log("in group"); console.groupEnd(); - console.time("t"); console.timeLog("t", "extra"); console.timeEnd("t"); - console.warn("warn"); - console.error("error", { e: 1 }); - console.assert(false, "assert %s", "msg"); - console.assert(false); - console.trace("trace"); - process.stdout.write = ow; process.stderr.write = ew; - // Restoring puts the native path back: this arrives, and is not seen. - console.log("restored"); - process.stderr.write(JSON.stringify(seen)); - `); - expect(stdout).toBe("restored\n"); - const seen = JSON.parse(stderr); - // Elapsed times vary; the stack in trace() has a path. - seen.out = seen.out.map((s: string) => s.replace(/: [\d.]+m?s/, ": ")); - seen.err = seen.err.map((s: string) => (s.startsWith("trace\n") ? "trace\n" : s)); - expect(seen).toEqual({ - out: [ - "log 1\n", - "info\n", - "debug\n", - "{\n dir: 1,\n}\n", - "dirxml\n", - "┌───┬───┐\n│ │ a │\n├───┼───┤\n│ 0 │ 1 │\n└───┴───┘\n", - "cnt: 1\n", - "cnt: 2\n", - "grp\n", - " in group\n", - "t: extra\n", - "t: \n", - ], - err: [ - "warn\n", - "error {\n e: 1,\n}\n", - "Assertion failed: assert msg\n", - "Assertion failed\n", - "trace\n", - ], - }); - expect(exitCode).toBe(0); - }); - - test("console._stdout / console._stderr assignment redirects; assigning process.std* back restores", async () => { - const { stdout, stderr, exitCode } = await run(` - const { Writable } = require("node:stream"); - const chunks = []; - const sink = new Writable({ write(c, e, cb) { chunks.push(String(c)); cb(); } }); - console._stdout = sink; - console._stderr = sink; - console.log("to sink"); - console.error("err to sink"); - console._stdout = process.stdout; - console._stderr = process.stderr; - console.log("back"); - console.error(JSON.stringify(chunks)); - `); - expect(stdout).toBe("back\n"); - expect(stderr).toBe('["to sink\\n","err to sink\\n"]\n'); - expect(exitCode).toBe(0); - }); - - test("like Node, the console binds process.stdout on first use: replacing it before that is honoured, after that is not", async () => { - // https://github.com/nodejs/node/blob/v24.0.0/lib/internal/console/constructor.js#L205-L234 - const before = await run(` - const { Writable } = require("node:stream"); - const chunks = []; - Object.defineProperty(process, "stdout", { value: new Writable({ write(c, e, cb) { chunks.push(String(c)); cb(); } }), configurable: true, writable: true }); - console.log("first use"); - process.stderr.write(JSON.stringify(chunks)); - `); - expect(before).toEqual({ stdout: "", stderr: '["first use\\n"]', exitCode: 0 }); - - const after = await run(` - const { Writable } = require("node:stream"); - const chunks = []; - console.log("first use"); - Object.defineProperty(process, "stdout", { value: new Writable({ write(c, e, cb) { chunks.push(String(c)); cb(); } }), configurable: true, writable: true }); - console.log("second use"); - process.stderr.write(JSON.stringify(chunks)); - `); - expect(after).toEqual({ stdout: "first use\nsecond use\n", stderr: "[]", exitCode: 0 }); - - // An accessor (what spyOn(process, "stdout", "get") installs) is read - // through its getter once, at bind time; a throwing one surfaces from that - // first console call and binding is retried on the next. - const accessor = await run(` - const { Writable } = require("node:stream"); - const chunks = []; - const sink = new Writable({ write(c, e, cb) { chunks.push(String(c)); cb(); } }); - let calls = 0, armed = true; - Object.defineProperty(process, "stdout", { get() { calls++; if (armed) { armed = false; throw new Error("getter boom"); } return sink; }, configurable: true }); - let first; - try { console.log("lost"); } catch (e) { first = e.message; } - console.log("one"); console.info("two"); - process.stderr.write(JSON.stringify({ first, calls, chunks, same: console._stdout === sink })); - `); - expect(accessor.stdout).toBe(""); - expect(JSON.parse(accessor.stderr)).toEqual({ - first: "getter boom", - calls: 2, - chunks: ["one\n", "two\n"], - same: true, - }); - expect(accessor.exitCode).toBe(0); - }); - - test("cork() holds console output with the stream's other writes until uncork()", async () => { - const { stdout, stderr, exitCode } = await run(` - process.stdout.cork(); - console.log("1 (corked)"); - process.stdout.write("2 (corked)\\n"); - process.stderr.write("[stderr while stdout corked]"); - process.stdout.uncork(); - console.log("3"); - `); - expect(stderr).toBe("[stderr while stdout corked]"); - expect(stdout).toBe("1 (corked)\n2 (corked)\n3\n"); - expect(exitCode).toBe(0); - }); - - test("a write() that throws is swallowed; a stack overflow is not (Node kWriteToConsole)", async () => { - // A real runaway recursion takes minutes to overflow in debug/ASAN builds; - // what the console keys on is the engine's stack-overflow RangeError. - const { stdout, stderr, exitCode } = await run(` - process.stdout.write = () => { throw new Error("nope"); }; - console.log("swallowed"); - let threw; - process.stdout.write = () => { throw new RangeError("Maximum call stack size exceeded."); }; - try { console.log("overflow"); } catch (e) { threw = e.constructor.name; } - process.stderr.write(String(threw)); - `); - expect(stdout).toBe(""); - expect(stderr).toBe("RangeError"); - expect(exitCode).toBe(0); - }); - - test("adding unrelated own properties to process.stdout keeps the native path (structure re-cached), patching write leaves it", async () => { - // Observable only indirectly: a patched write counts calls; an unpatched - // stream with extra props must still print and must not be 'seen'. - const { stdout, stderr, exitCode } = await run(` - process.stdout._tag = 1; // structure transition, no own write - process.stdout.isTTY = false; - console.log("a"); - let n = 0; - const w = process.stdout.write; - process.stdout.write = function () { n++; return w.apply(this, arguments); }; - console.log("b"); - delete process.stdout.write; // back to the prototype's - console.log("c"); - process.stderr.write(String(n)); - `); - expect(stdout).toBe("a\nb\nc\n"); - expect(stderr).toBe("1"); - expect(exitCode).toBe(0); + expect(Object.getOwnPropertyDescriptor(console, "_stdout")).toEqual({ + value: process.stdout, + writable: true, + enumerable: false, + configurable: true, }); +}); - test("console._ignoreErrors is true and console.clear() writes the escape through _stdout when it isTTY", async () => { - // https://github.com/nodejs/node/blob/v24.0.0/lib/internal/console/constructor.js#L487-L501 - const { stdout, stderr, exitCode } = await run(` - let buf = ""; - const w = process.stdout.write; - process.stdout.isTTY = true; - process.stdout.write = s => ((buf += s), true); - console.clear(); - process.stdout.isTTY = false; - console.clear(); - process.stdout.write = w; - process.stderr.write(JSON.stringify({ buf, ignore: console._ignoreErrors })); - `); - expect(JSON.parse(stderr)).toEqual({ buf: "\x1b[1;1H\x1b[0J", ignore: true }); - expect(stdout).toBe(""); - expect(exitCode).toBe(0); - }); +test("console._stderr", () => { + // @ts-ignore + expect(console._stderr).toBe(process.stderr); - test("diagnostics_channel console.* channels publish the argument list before formatting, only while subscribed", async () => { - // https://github.com/nodejs/node/blob/v24.0.0/lib/internal/console/constructor.js#L409-L443 - const { stdout, stderr, exitCode } = await run(` - const dc = require("node:diagnostics_channel"); - const seen = []; - const subs = {}; - for (const name of ["console.log", "console.info", "console.debug", "console.warn", "console.error"]) { - dc.subscribe(name, (subs[name] = args => { seen.push([name, [...args]]); args[0] = "[" + args[0] + "]"; })); - } - console.log("l", 1); console.info("i"); console.debug("d"); console.warn("w"); console.error("e"); - console.dir("not published"); console.table(["nor this"]); - for (const name in subs) dc.unsubscribe(name, subs[name]); - console.log("unsubscribed"); - process.stderr.write("\\n" + JSON.stringify(seen)); - `); - expect(stdout).toBe( - "[l] 1\n[i]\n[d]\nnot published\n" + - "┌───┬──────────┐\n│ │ Values │\n├───┼──────────┤\n│ 0 │ nor this │\n└───┴──────────┘\n" + - "unsubscribed\n", - ); - const nl = stderr.indexOf("\n[["); - expect(stderr.slice(0, nl)).toBe("[w]\n[e]\n"); - expect(JSON.parse(stderr.slice(nl + 1))).toEqual([ - ["console.log", ["l", 1]], - ["console.info", ["i"]], - ["console.debug", ["d"]], - ["console.warn", ["w"]], - ["console.error", ["e"]], - ]); - expect(exitCode).toBe(0); + expect(Object.getOwnPropertyDescriptor(console, "_stderr")).toEqual({ + value: process.stderr, + writable: true, + enumerable: false, + configurable: true, }); }); - -// In-process (serial, it patches this process's stdout): exactly what test -// suites (oclif, ink, ...) do. -test("bun:test spyOn(process.stdout, 'write') captures console.log", () => { - const spy = spyOn(process.stdout, "write").mockImplementation(() => true); - let calls: string[]; - try { - console.log("captured %s", "yes"); - console.info({ k: "v" }); - // The runner's own stdout may be a colour TTY; the capture is what matters. - calls = spy.mock.calls.map((c: unknown[]) => Bun.stripANSI(String(c[0]))); - } finally { - spy.mockRestore(); - } - expect(calls).toEqual(["captured yes\n", '{\n k: "v",\n}\n']); -}); diff --git a/test/js/node/process/process-stdio.test.ts b/test/js/node/process/process-stdio.test.ts index bc323958f15d..f2351ac1a264 100644 --- a/test/js/node/process/process-stdio.test.ts +++ b/test/js/node/process/process-stdio.test.ts @@ -1,7 +1,6 @@ import { spawn, spawnSync } from "bun"; import { describe, expect, test } from "bun:test"; -import { bunEnv, bunExe, isASAN, isLinux, isPosix, isWindows, tempDirWithFiles } from "harness"; -import { closeSync, constants as fsConstants, openSync, readSync } from "node:fs"; +import { bunEnv, bunExe, isLinux, isWindows } from "harness"; import { release } from "node:os"; import path from "path"; import { isatty } from "tty"; @@ -162,517 +161,6 @@ describe.concurrent("process-stdio", () => { }); }); -// ───────────────────────────────────────────────────────────────────────────── -// One sink per fd: console.*, process.stdout/stderr, Bun.stdout.writer(), -// console.write and Bun.write(Bun.stdout) all go through the same per-VM stdio -// sink, so they can never reorder against each other, never drop bytes when -// the description is O_NONBLOCK, and are fully written before the process -// exits — however the reader paces itself. -// ───────────────────────────────────────────────────────────────────────────── -describe.skipIf(!isPosix).concurrent("stdio sink", () => { - // The describe body runs at collection time even when skipped. - if (!isPosix) return; - // Children that need to read/flip O_NONBLOCK on an fd get these helpers. - // fcntl(2) is variadic; Apple's arm64 ABI passes variadic args on the stack, - // so a fixed-arity dlopen binding gets F_SETFL wrong there — compile tiny - // non-variadic wrappers instead. `cc()` is unavailable under ASAN, so tests - // that use the prelude skip there. - const dir = tempDirWithFiles("stdio-sink", { - "fdutil.c": ` -#include -#include -int fd_is_nonblock(int fd) { int fl = fcntl(fd, F_GETFL); return fl >= 0 && (fl & O_NONBLOCK) != 0; } -int fd_set_nonblock(int fd, int on) { int fl = fcntl(fd, F_GETFL); if (fl < 0) return fl; return fcntl(fd, F_SETFL, on ? (fl | O_NONBLOCK) : (fl & ~O_NONBLOCK)); } -`, - }); - const prelude = ` -const { fd_is_nonblock, fd_set_nonblock } = require("bun:ffi").cc({ - source: ${JSON.stringify(path.join(dir, "fdutil.c"))}, - symbols: { - fd_is_nonblock: { args: ["int"], returns: "int" }, - fd_set_nonblock: { args: ["int", "int"], returns: "int" }, - }, -}).symbols; -const nonblock = fd => fd_is_nonblock(fd) !== 0; -`; - const needsPrelude = { skip: isASAN }; - let fifoCounter = 0; - - /** - * Run `src` in a child whose stdout is the write end of a FIFO that only - * this function reads, a small slice at a time, so a child writing more than - * the pipe holds is queued behind us for most of its life. (Tests that need - * proof of backpressure assert on `write()`'s return value.) - * `Bun.spawn({ stdout: "pipe" })` can't do this: it drains the child eagerly - * into memory. Returns everything the child wrote to fd 1, its stderr, and - * its exit code. - */ - async function runWithSlowStdout(src: string, opts: { env?: Record } = {}) { - const fifo = path.join(dir, `stdout-${fifoCounter++}.fifo`); - expect(spawnSync({ cmd: ["mkfifo", fifo] }).exitCode).toBe(0); - // Reader first (a non-blocking open of the read side never waits for a - // writer), then the writer end for the child, which now opens instantly. - const r = openSync(fifo, fsConstants.O_RDONLY | fsConstants.O_NONBLOCK); - let w = -1; - try { - w = openSync(fifo, "w"); - await using proc = spawn({ - cmd: [bunExe(), "-e", src], - env: { ...bunEnv, ...opts.env }, - stdio: ["ignore", w, "pipe"], - }); - closeSync(w); - w = -1; - let exited = false; - const exitedP = proc.exited.then(code => ((exited = true), code)); - const stderrP = proc.stderr.text(); - - const chunks: Buffer[] = []; - const slice = Buffer.alloc(16 * 1024); - // Poll until EOF (bounded by the test timeout). Correctness never depends - // on this pacing — only how much backpressure the child sees does. - for (;;) { - let n = 0; - try { - n = readSync(r, slice, 0, slice.length, null); - } catch (e: any) { - if (e?.code !== "EAGAIN") throw e; - if (exited) { - // Child is gone; anything left is already in the pipe. - try { - n = readSync(r, slice, 0, slice.length, null); - } catch { - break; - } - if (n === 0) break; - chunks.push(Buffer.from(slice.subarray(0, n))); - continue; - } - await Bun.sleep(2); - continue; - } - if (n === 0) break; - chunks.push(Buffer.from(slice.subarray(0, n))); - await Bun.sleep(1); - } - const [stderr, exitCode] = await Promise.all([stderrP, exitedP]); - return { stdout: Buffer.concat(chunks), stderr, exitCode }; - } finally { - if (w !== -1) closeSync(w); - closeSync(r); - } - } - - /** Collapse runs of a filler byte so mismatches print legibly. */ - const squash = (buf: Buffer) => buf.toString("latin1").replace(/([xyz])\1{15,}/g, (m, c) => `<${c}*${m.length}>`); - - const K256 = 256 * 1024; - - test("process.stdout.write / console.log interleaving keeps call order under pipe backpressure", async () => { - const { stdout, stderr, exitCode } = await runWithSlowStdout(` - const a = process.stdout.write(Buffer.alloc(${K256}, "x")); - console.log("\\nMARK1"); - const b = process.stdout.write(Buffer.alloc(${K256}, "y")); - console.log("\\nMARK2"); - process.stderr.write(JSON.stringify({ a, b, len: process.stdout.writableLength })); - `); - // write() reported backpressure (and writableLength counted the queued - // bytes) — i.e. the sink really was backed up while console.log ran. - expect(JSON.parse(stderr)).toEqual({ a: false, b: false, len: expect.any(Number) }); - expect(JSON.parse(stderr).len).toBeGreaterThan(0); - expect(squash(stdout)).toBe(`\nMARK1\n\nMARK2\n`); - expect(exitCode).toBe(0); - }); - - test("every stdout writer shares one queue: order is call order", async () => { - // console.*, Bun.stdout.writer(), Bun.write(Bun.stdout) and console.write - // hand their bytes to the sink directly; process.stdout.write() does too, - // except for what its Writable is still holding after a write() returned - // false — those chunks are behind the in-flight one *inside the stream*, - // and console.* (being a write() on that stream, as in Node) queues behind - // them. So: everything below is call-ordered, and the two console lines - // issued while `y` is queued come out after `y`. - const { stdout, stderr, exitCode } = await runWithSlowStdout(` - const big = (c) => Buffer.alloc(${K256}, c); - console.log("[console.log]"); - Bun.stdout.writer().write("[Bun.stdout.writer]\\n"); - await Bun.write(Bun.stdout, "[Bun.write]\\n"); - console.write("[console.write]\\n"); - process.stdout.write(big("x")); // fills the pipe, rest queues in the sink - console.log("\\n[after x]"); // drains the sink first, then writes - process.stdout.write(big("y")); // sink backed up -> held in the Writable - console.error("(stderr is separate)"); - console.info("\\n[console.info]"); // a write() on the stream: behind y - process.stdout.write("[end]\\n"); - `); - expect(stderr).toBe("(stderr is separate)\n"); - expect(squash(stdout)).toBe( - `[console.log]\n[Bun.stdout.writer]\n[Bun.write]\n[console.write]\n\n[after x]\n\n[console.info]\n[end]\n`, - ); - expect(exitCode).toBe(0); - }); - - test.each(["process.exit(0)", "throw new Error('boom')", "/* natural */"])( - "queued stdout is fully written before exit via %s", - async how => { - const { stdout, exitCode } = await runWithSlowStdout(` - process.stdout.write(Buffer.alloc(${K256}, "x")); - process.stdout.write(Buffer.alloc(${K256}, "y")); // buffered in the Writable behind the first - console.log("\\nlast console line"); - process.stdout.write("last stream line\\n"); - ${how}; - `); - expect(squash(stdout)).toBe(`\nlast console line\nlast stream line\n`); - expect(exitCode).toBe(how.startsWith("throw") ? 1 : 0); - }, - ); - - test("an uncaught exception is printed after already-written stdout/stderr", async () => { - const { stdout, stderr, exitCode } = await runWithSlowStdout(` - process.stdout.write(Buffer.alloc(${K256}, "x")); - process.stderr.write("before\\n"); - throw new Error("boom"); - `); - expect(squash(stdout)).toBe(``); - expect(stderr.startsWith("before\n")).toBe(true); - expect(stderr).toContain("error: boom"); - expect(exitCode).toBe(1); - }); - - test.skipIf(needsPrelude.skip)( - "materialising process.stdout / process.stderr does not touch fd flags of a tty/file, and stdio-inheriting children get blocking fds", - async () => { - // stdout here is a pipe: Bun may make *its* description non-blocking - // (that is how a slow reader queues instead of stalling the loop), but a - // child handed the fd must see it blocking, and stderr (a file below) - // must never be touched. - const errPath = path.join(tempDirWithFiles("stdio-sink-err", { "err.txt": "" }), "err.txt"); - const { stdout, exitCode } = await runWithSlowStdout( - prelude + - ` - const fs = require("node:fs"); - const errfd = fs.openSync(${JSON.stringify(errPath)}, "w"); - const before = { out: nonblock(1), file: nonblock(errfd) }; - void process.stdout; void process.stderr; - process.stdout.write("x"); - const after = { file: nonblock(errfd) }; - // A child that inherits fd 1 must find it blocking regardless. - const child = Bun.spawnSync([process.execPath, "-e", ${JSON.stringify(prelude + `process.stderr.write(String(nonblock(1)))`)}], { stdio: ["ignore", "inherit", "pipe"], env: process.env }); - // ...and once handed over blocking it stays that way: a later async - // writer must not flip O_NONBLOCK back on under a (possibly still - // running) child. - await Bun.write(Bun.stdout, "y"); - Bun.stdout.writer(); - const stillBlocking = !nonblock(1); - console.log(JSON.stringify({ before, after, childSeesNonblock: child.stderr.toString(), stillBlocking })); - `, - ); - const line = stdout.toString().trim().split("\n").pop()!; - expect(JSON.parse(line.replace(/^xy/, ""))).toEqual({ - before: { out: false, file: false }, - after: { file: false }, - childSeesNonblock: "false", - stillBlocking: true, - }); - expect(exitCode).toBe(0); - }, - ); - - test.skipIf(needsPrelude.skip)( - "console.log delivers every byte when something else made fd 1 O_NONBLOCK and the pipe is full", - async () => { - const { stdout, stderr, exitCode } = await runWithSlowStdout( - prelude + - ` - const fs = require("node:fs"); - fd_set_nonblock(1, 1); - // Fill the pipe until the kernel refuses. - const fill = Buffer.alloc(4096, "x"); - let filled = 0; - for (;;) { try { filled += fs.writeSync(1, fill); } catch { break; } } - process.stderr.write(String(filled)); - for (let i = 0; i < 10; i++) console.log("marker " + i); - `, - ); - const filled = Number(stderr); - expect(filled).toBeGreaterThan(0); - expect(stdout.subarray(0, filled).equals(Buffer.alloc(filled, "x"))).toBe(true); - expect(stdout.subarray(filled).toString()).toBe(Array.from({ length: 10 }, (_, i) => `marker ${i}\n`).join("")); - expect(exitCode).toBe(0); - }, - ); - - test.skipIf(needsPrelude.skip)( - "an idle Worker / worker_threads.Worker does not perturb the parent's stdio", - async () => { - const { stdout, stderr, exitCode } = await runWithSlowStdout( - prelude + - ` - const { Worker } = require("node:worker_threads"); - const before = [nonblock(1), nonblock(2)]; - const w = new Worker("setTimeout(() => {}, 10)", { eval: true }); - await new Promise(r => w.on("online", r)); - const during = [nonblock(1), nonblock(2)]; - await new Promise(r => w.on("exit", r)); - // and the parent's console still delivers everything afterwards - const filler = Buffer.alloc(4000, "p").toString(); - for (let i = 0; i < 40; i++) console.log("line " + i + " " + filler); - process.stderr.write(JSON.stringify({ before, during })); - `, - ); - expect(JSON.parse(stderr)).toEqual({ before: [false, false], during: [false, false] }); - expect(stdout.toString().split("\n").filter(Boolean).length).toBe(40); - expect(exitCode).toBe(0); - }, - ); - - test("Bun.stdout.writer() is the shared sink: same object every time; end()/close() only flush; writes coalesce until flushed", async () => { - await using proc = spawn({ - cmd: [ - bunExe(), - "-e", - ` - const { spawnSync } = require("child_process"); - // A child inheriting fd 1 shows what has actually reached the fd. - const probe = tag => spawnSync(process.execPath, ["-e", "process.stdout.write('<' + process.env.TAG + '>')"], { stdio: "inherit", env: { ...process.env, TAG: tag } }); - const a = Bun.stdout.writer(), b = Bun.stdout.writer(), c = Bun.file(1).writer(); - a.write("1 "); - probe("before-flush"); // "1 " is still in the writer's buffer - await a.end(); - console.log("2"); - a.close(); // must not take stdout away from anyone - process.stdout.write("3 "); // process.stdout.write is a syscall now... - probe("after-write"); // ...so the child lands after it - b.write("4\\n"); - await b.flush(); - console.write("5\\n"); - process.stderr.write(JSON.stringify([a === b, a === c, Bun.stdout.writer() === a])); - `, - ], - env: bunEnv, - stdout: "pipe", - stderr: "pipe", - }); - const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect(stderr).toBe("[true,true,true]"); - expect(stdout).toBe("1 2\n3 4\n5\n"); - expect(exitCode).toBe(0); - }); - - test("FileSink.flush() / end-of-tick autoflush on a full pipe waits for the reader instead of spinning or stalling", async () => { - const { stdout, stderr, exitCode } = await runWithSlowStdout(` - process.stdout.write(Buffer.alloc(${K256}, "x")); // pipe is now full, remainder queued - const w = Bun.stdout.writer(); - w.write("\\n[coalesced]"); // small: sits in the sink's buffer - const flushed = await w.flush(); // must arm the poll and wait - w.write("\\n[autoflushed]\\n"); // left for the end-of-tick flush - await new Promise(r => setImmediate(r)); - process.stderr.write(JSON.stringify({ flushed: typeof flushed })); - `); - expect(JSON.parse(stderr)).toEqual({ flushed: "number" }); - expect(squash(stdout)).toBe(`\n[coalesced]\n[autoflushed]\n`); - expect(exitCode).toBe(0); - }); - - test("Bun.write(Bun.stdout, x) resolves with its own byte count once written, in order, even while process.stdout is backed up", async () => { - const { stdout, stderr, exitCode } = await runWithSlowStdout(` - const backedUp = !process.stdout.write(Buffer.alloc(${K256}, "x")); - const n = await Bun.write(Bun.stdout, "abc"); - const m = await Bun.write(Bun.stdout, new TextEncoder().encode("\\ndef!\\n")); - Bun.stdout.writer().write("[coalesced]"); // still in the writer's buffer... - const k = await Bun.write(Bun.stdout, "h\\u00e9!\\n"); // ...flushed by this, but not counted in it - console.log("after"); - process.stderr.write(JSON.stringify({ backedUp, n, m, k })); - `); - expect(JSON.parse(stderr)).toEqual({ backedUp: true, n: 3, m: 6, k: 5 }); - expect(squash(stdout)).toBe(`abc\ndef!\n[coalesced]h\u00c3\u00a9!\nafter\n`); - expect(exitCode).toBe(0); - }); - - test("writableLength / writableNeedDrain / cork() account for queued bytes, and 'drain' fires", async () => { - const { stdout, stderr, exitCode } = await runWithSlowStdout(` - const so = process.stdout; - const facts = { hwm: so.writableHighWaterMark }; - facts.writeRet = so.write(Buffer.alloc(${K256}, "x")); - facts.writableLength = so.writableLength; - facts.writableNeedDrain = so.writableNeedDrain; - so.cork(); - so.write(Buffer.alloc(1000, "y")); - console.log("\\n(held by cork)"); - facts.corkedLength = so.writableLength; - facts.writableCorked = so.writableCorked; - so.uncork(); - so.once("drain", () => { - facts.drained = { writableLength: so.writableLength, writableNeedDrain: so.writableNeedDrain }; - process.stderr.write(JSON.stringify(facts)); - }); - `); - expect(JSON.parse(stderr)).toEqual({ - hwm: 65536, - writeRet: false, - writableLength: K256, - writableNeedDrain: true, - // 1000 corked bytes + "\\n(held by cork)\\n" (16) queued *behind* them - corkedLength: K256 + 1000 + 16, - writableCorked: 1, - drained: { writableLength: 0, writableNeedDrain: false }, - }); - expect(squash(stdout)).toBe(`\n(held by cork)\n`); - expect(exitCode).toBe(0); - }); - - test("process.stdout.write() is inherited from the prototype (no own write), decodes its encoding argument", async () => { - await using proc = spawn({ - cmd: [ - bunExe(), - "-e", - ` - process.stdout.write("48490a", "hex"); - process.stdout.write("QUJD", "base64"); - process.stdout.setDefaultEncoding("hex"); - process.stdout.write("21"); - process.stderr.write(JSON.stringify({ - own: Object.prototype.hasOwnProperty.call(process.stdout, "write"), - proto: process.stdout.write === Object.getPrototypeOf(process.stdout).write, - })); - `, - ], - env: bunEnv, - stdout: "pipe", - stderr: "pipe", - }); - const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect(JSON.parse(stderr)).toEqual({ own: false, proto: true }); - expect(stdout).toBe("HI\nABC!"); - expect(exitCode).toBe(0); - }); - - test("process.stdout/stderr.write() still accept a bare ArrayBuffer / SharedArrayBuffer (Bun has always allowed it), with no extra prototype or own write", async () => { - await using proc = spawn({ - cmd: [ - bunExe(), - "-e", - ` - const ab = new TextEncoder().encode("array ").buffer; - const sab = new SharedArrayBuffer(7); - new Uint8Array(sab).set(new TextEncoder().encode("shared\\n")); - process.stdout.write(ab); - process.stdout.write(sab); - process.stderr.write(ab); - const tty = require("node:tty"), fs = require("node:fs"), { Writable } = require("node:stream"); - const proto = Object.getPrototypeOf(process.stdout); - process.stderr.write(String([ - Object.prototype.hasOwnProperty.call(process.stdout, "write"), - proto === tty.WriteStream.prototype || proto === fs.WriteStream.prototype, - process.stdout.write === Writable.prototype.write, - ])); - `, - ], - env: bunEnv, - stdout: "pipe", - stderr: "pipe", - }); - const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect(stdout).toBe("array shared\n"); - expect(stderr).toBe("array false,true,true"); - expect(exitCode).toBe(0); - }); - - test("write after end(): the sync write is ERR_STREAM_WRITE_AFTER_END; after finish the stream is undestroyed and writable again (Node file-stdio semantics)", async () => { - // https://github.com/nodejs/node/blob/v24.0.0/lib/internal/bootstrap/switches/is_main_thread.js#L114-L128 - // (dummyDestroy -> _undestroy). Over a pipe Node additionally shuts the - // socket down so later writes EPIPE; Bun keeps fd 1 open (pre-existing, - // deliberate) so it behaves like Node's file case everywhere. - await using proc = spawn({ - cmd: [ - bunExe(), - "-e", - ` - process.stdout.on("error", e => process.stderr.write("[" + e.code + "]")); - process.stdout.write("A"); - process.stdout.end("B"); - process.stdout.write("C"); // still ending -> ERR_STREAM_WRITE_AFTER_END ... - console.log("D"); // ... whose errorOrDestroy -> _destroy -> _undestroy() already made it writable again - setImmediate(() => { - process.stdout.write("E"); - console.log("F"); - process.stderr.write("[done]"); - }); - `, - ], - env: bunEnv, - stdout: "pipe", - stderr: "pipe", - }); - const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect(stderr).toBe("[ERR_STREAM_WRITE_AFTER_END][done]"); - expect(stdout).toBe("ABD\nEF\n"); - expect(exitCode).toBe(0); - }); - - describe("EPIPE", () => { - // stdout's reader goes away before we write. Node: each failed write emits - // 'error' on process.stdout (code EPIPE, syscall write) when listened to; - // console.* never throws. Without a listener a failing process.stdout.write - // is an uncaught 'error'; a failing console.log stays silent (Bun keeps the - // process alive on uncaught errors, so it must not print one per call). - async function run(which: "console.log" | "process.stdout.write", listen: boolean) { - await using proc = spawn({ - cmd: [ - bunExe(), - "-e", - ` - ${listen ? `process.stdout.on("error", e => process.stderr.write("[" + e.code + "/" + e.syscall + "]"));` : ""} - process.on("exit", c => process.stderr.write("[exit " + c + "]")); - // Wait until the parent has closed our stdout. - const buf = Buffer.alloc(1); - require("node:fs").readSync(0, buf, 0, 1, null); - // One write per event-loop turn: an 'error' is delivered per turn - // (destroy -> nextTick emit -> _undestroy), in Node and here alike; - // several failing writes inside one tick coalesce into one 'error'. - for (let i = 0; i < 3; i++) { - ${which}("x" + i + "\\n"); - await new Promise(r => setImmediate(r)); - } - `, - ], - env: bunEnv, - stdin: "pipe", - stdout: "pipe", - stderr: "pipe", - }); - // Drop the read end, then let the child go. - await proc.stdout.cancel(); - proc.stdin.write("g"); - await proc.stdin.end(); - const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); - return { stderr, exitCode }; - } - - test("console.log with an 'error' listener: one 'error' per call, exit 0", async () => { - expect(await run("console.log", true)).toEqual({ - stderr: "[EPIPE/write][EPIPE/write][EPIPE/write][exit 0]", - exitCode: 0, - }); - }); - test("process.stdout.write with an 'error' listener: one 'error' per call, exit 0", async () => { - expect(await run("process.stdout.write", true)).toEqual({ - stderr: "[EPIPE/write][EPIPE/write][EPIPE/write][exit 0]", - exitCode: 0, - }); - }); - test("console.log without a listener is silent", async () => { - expect(await run("console.log", false)).toEqual({ stderr: "[exit 0]", exitCode: 0 }); - }); - test("process.stdout.write without a listener: one uncaught EPIPE, exit 1", async () => { - const { stderr, exitCode } = await run("process.stdout.write", false); - expect(stderr.match(/EPIPE: broken pipe, write/g)?.length).toBe(1); - expect(stderr).toContain("[exit 1]"); - expect(exitCode).toBe(1); - }); - }); -}); - // Like Node, process.stdout/stderr put O_NONBLOCK on fd 1/2 when they are pipes or sockets. The flag lives on the shared open // file description, so spawn clears it again for a child's fds 0-2 (libuv does the same), and the native console writer must // wait on EAGAIN instead of dropping the rest of the line. @@ -883,5 +371,61 @@ describe.concurrent.skipIf(isWindows)( await expectDrainOverPipe(afterInheritSpawn, "{}", { drained: true }, 1 + (1 << 20)); }, ); + + // console.* writes synchronously while process.stdout/stderr queue what a full pipe refuses, so the console has to flush + // that queue first. Reports go to the other fd; nothing reads the fd under test until the first write() came back false. + const callOrderScript = (stream: "stdout" | "stderr", methods: string[]) => ` + const report = v => require("fs").writeSync(${stream === "stdout" ? 2 : 1}, JSON.stringify(v) + "\\n"); + ${JSON.stringify(methods)}.forEach((method, i) => { + const ret = process.${stream}.write(Buffer.alloc(1 << 20, String(i))); + if (i === 0) report({ ret }); + console[method]("<" + method + ">"); + });`; + const inCallOrder = (methods: string[]) => methods.map((method, i) => `[${i}*${1 << 20}]<${method}>\n`).join(""); + const squash = (bytes: Uint8Array) => + Buffer.from(bytes) + .toString() + .replace(/(\d)\1+/g, m => `[${m[0]}*${m.length}]`); + + test.each([ + ["stdout", ["log", "info", "debug"], `"$0" -e "$1" | "$0" -e "$2"`], + ["stderr", ["warn", "error"], `{ "$0" -e "$1" 2>&1 >&3 | "$0" -e "$2"; } 3>&2`], + ] as const)( + "console output on %s comes after what the stream queued on a full pipe", + async (stream, methods, pipeline) => { + await using proc = spawn({ + cmd: ["sh", "-c", pipeline, bunExe(), callOrderScript(stream, [...methods]), gatedReader], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const events = jsonLines(proc.stderr); + let readerPid: number | undefined, ret: boolean | undefined; + while (readerPid === undefined || ret === undefined) { + const v = await events.next(); + if ("reader" in v) readerPid = v.reader; + if ("ret" in v) ret = v.ret; + } + expect(ret).toBe(false); + process.kill(readerPid, "SIGUSR1"); + const [stdout, exitCode] = await Promise.all([proc.stdout.bytes(), proc.exited]); + expect(squash(stdout)).toBe(inCallOrder([...methods])); + expect(exitCode).toBe(0); + }, + ); + + test("console.log comes after what process.stdout.write() queued on a full socket", async () => { + await using proc = spawn({ + cmd: [bunExe(), "-e", callOrderScript("stdout", ["log", "info", "debug"])], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + lazy: true, + }); + expect(await jsonLines(proc.stderr).next()).toEqual({ ret: false }); + const [stdout, exitCode] = await Promise.all([proc.stdout.bytes(), proc.exited]); + expect(squash(stdout)).toBe(inCallOrder(["log", "info", "debug"])); + expect(exitCode).toBe(0); + }); }, ); diff --git a/test/js/node/process/process-stdout-write-after-end.test.ts b/test/js/node/process/process-stdout-write-after-end.test.ts index fc691a2a18ac..ceaa99467c7c 100644 --- a/test/js/node/process/process-stdout-write-after-end.test.ts +++ b/test/js/node/process/process-stdout-write-after-end.test.ts @@ -24,15 +24,9 @@ test.concurrent.each(["stdout", "stderr"] as const)( const lines = reportPipe.trim().split("\n"); const report = JSON.parse(lines[lines.length - 1]); - // The synchronous write() right after end() is rejected (still ending). - // By report time the stream has gone finish -> destroy -> _undestroy (Node - // installs `dummyDestroy` on every stdio stream, pipes included: - // https://github.com/nodejs/node/blob/v24.0.0/lib/internal/bootstrap/switches/is_main_thread.js#L114-L128), - // so it reads as writable again — the same facts `node` (v22–v25) prints - // for this fixture. expect(report).toEqual({ - writableEnded: false, - writable: true, + writableEnded: true, + writable: false, ret: false, cbErr: "ERR_STREAM_WRITE_AFTER_END", ev: ["err:ERR_STREAM_WRITE_AFTER_END"], diff --git a/test/js/node/test/parallel/test-console-clear.js b/test/js/node/test/parallel/test-console-clear.js deleted file mode 100644 index 8ded51595f65..000000000000 --- a/test/js/node/test/parallel/test-console-clear.js +++ /dev/null @@ -1,24 +0,0 @@ -'use strict'; - -require('../common'); -const assert = require('assert'); - -const stdoutWrite = process.stdout.write; - -// The sequence for moving the cursor to 0,0 and clearing screen down -const check = '\u001b[1;1H\u001b[0J'; - -function doTest(isTTY, check) { - let buf = ''; - process.stdout.isTTY = isTTY; - process.stdout.write = (string) => buf += string; - console.clear(); - process.stdout.write = stdoutWrite; - assert.strictEqual(buf, check); -} - -// Fake TTY -if (process.env.TERM !== 'dumb') { - doTest(true, check); -} -doTest(false, ''); diff --git a/test/js/node/test/parallel/test-console-count.js b/test/js/node/test/parallel/test-console-count.js deleted file mode 100644 index 5c7b26aaa20d..000000000000 --- a/test/js/node/test/parallel/test-console-count.js +++ /dev/null @@ -1,65 +0,0 @@ -'use strict'; - -require('../common'); -const assert = require('assert'); - -const stdoutWrite = process.stdout.write; - -let buf = ''; - -process.stdout.write = (string) => buf = string; - -console.count(); -assert.strictEqual(buf, 'default: 1\n'); - -// 'default' and undefined are equivalent -console.count('default'); -assert.strictEqual(buf, 'default: 2\n'); - -console.count('a'); -assert.strictEqual(buf, 'a: 1\n'); - -console.count('b'); -assert.strictEqual(buf, 'b: 1\n'); - -console.count('a'); -assert.strictEqual(buf, 'a: 2\n'); - -console.count(); -assert.strictEqual(buf, 'default: 3\n'); - -console.count({}); -assert.strictEqual(buf, '[object Object]: 1\n'); - -console.count(1); -assert.strictEqual(buf, '1: 1\n'); - -console.count(null); -assert.strictEqual(buf, 'null: 1\n'); - -console.count('null'); -assert.strictEqual(buf, 'null: 2\n'); - -console.countReset(); -console.count(); -assert.strictEqual(buf, 'default: 1\n'); - -console.countReset('a'); -console.count('a'); -assert.strictEqual(buf, 'a: 1\n'); - -// countReset('a') only reset the a counter -console.count(); -assert.strictEqual(buf, 'default: 2\n'); - -process.stdout.write = stdoutWrite; - -// Symbol labels do not work. Only check that the `Error` is a `TypeError`. Do -// not check the message because it is different depending on the JavaScript -// engine. -assert.throws( - () => console.count(Symbol('test')), - TypeError); -assert.throws( - () => console.countReset(Symbol('test')), - TypeError); diff --git a/test/js/node/test/parallel/test-console-stdio-setters.js b/test/js/node/test/parallel/test-console-stdio-setters.js deleted file mode 100644 index 5a4f511ee97b..000000000000 --- a/test/js/node/test/parallel/test-console-stdio-setters.js +++ /dev/null @@ -1,18 +0,0 @@ -'use strict'; - -// Test that monkeypatching console._stdout and console._stderr works. -const common = require('../common'); - -const { Writable } = require('stream'); - -const streamToNowhere = new Writable({ write: common.mustCall() }); -const anotherStreamToNowhere = new Writable({ write: common.mustCall() }); - -// Overriding the lazy-loaded _stdout and _stderr properties this way is what we -// are testing. Don't change this to be a Console instance from calling a -// constructor. It has to be the global `console` object. -console._stdout = streamToNowhere; -console._stderr = anotherStreamToNowhere; - -console.log('fhqwhgads'); -console.error('fhqwhgads'); diff --git a/test/js/node/worker_threads/worker_threads.test.ts b/test/js/node/worker_threads/worker_threads.test.ts index dc86c41203ef..c0c00f66098a 100644 --- a/test/js/node/worker_threads/worker_threads.test.ts +++ b/test/js/node/worker_threads/worker_threads.test.ts @@ -2152,67 +2152,6 @@ test("the SHARE_ENV founding thread's process.env stays live after the swap", as expect(exitCode).toBe(0); }); -// A worker's console goes through its process.stdout/stderr (port-backed -// Writables that post to the parent, as in Node) — by rebinding the *native* -// console's sink, not by swapping globalThis.console for a JS Console. So the -// worker keeps Bun's console (formatting, console.write, inspector) while its -// output still lands in worker.stdout / the parent's process.stdout. -describe.concurrent("worker console", () => { - test("keeps Bun's console surface and formatting; {stdout,stderr: true} captures it", async () => { - const worker = new Worker( - ` - const util = require("node:util"); - console.log(typeof console.write, typeof console[Symbol.asyncIterator]); - console.log(new Map([["k", "v"]])); // Bun: multi-line, JSON-ish keys - console.log(Bun.inspect(new Map([["k", "v"]])) === util.inspect(new Map([["k", "v"]])) ? "util-fmt" : "bun-fmt"); - console.error("to stderr"); - console.write("raw "); // Bun API, still through worker stdout - process.stdout.write("write\\n"); - `, - { eval: true, stdout: true, stderr: true }, - ); - let out = "", - err = ""; - worker.stdout.setEncoding("utf8").on("data", c => (out += c)); - worker.stderr.setEncoding("utf8").on("data", c => (err += c)); - await Promise.all([once(worker, "exit"), once(worker.stdout, "end"), once(worker.stderr, "end")]); - // Colour follows the parent's real stdout (possibly a TTY under the runner). - out = Bun.stripANSI(out); - err = Bun.stripANSI(err); - expect({ out, err }).toEqual({ - out: 'function function\nMap(1) {\n "k": "v",\n}\nbun-fmt\nraw write\n', - err: "to stderr\n", - }); - }); - - test("without {stdout: true} it flows into the parent's process.stdout, so patching that captures it", async () => { - await using proc = Bun.spawn({ - cmd: [ - bunExe(), - "-e", - ` - const { Worker } = require("node:worker_threads"); - const seen = []; - const w0 = process.stdout.write; - process.stdout.write = function (c) { seen.push(String(c)); return true; }; - const w = new Worker('console.log("from worker"); process.stdout.write("write from worker\\\\n");', { eval: true }); - w.on("exit", () => { - process.stdout.write = w0; - console.log(JSON.stringify(seen)); - }); - `, - ], - env: bunEnv, - stdout: "pipe", - stderr: "pipe", - }); - const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect(stderr).toBe(""); - expect(stdout).toBe('["from worker\\n","write from worker\\n"]\n'); - expect(exitCode).toBe(0); - }); -}); - test("terminating a worker stops the workers it spawned", async () => { // The leaf heartbeats to the main thread over a MessagePort routed through the // middle worker. Terminating the middle worker must stop the leaf, which the main diff --git a/test/js/web/console/console-timeLog.expected.txt b/test/js/web/console/console-timeLog.expected.txt index 9f61852a6bd0..464475e83a60 100644 --- a/test/js/web/console/console-timeLog.expected.txt +++ b/test/js/web/console/console-timeLog.expected.txt @@ -1,18 +1,18 @@ -label: 0.006ms -label: 0.06ms Hello World! -label: 0.09ms a %s b c d -label: 0.11ms 0 -0 123 -123 123.567 -123.567 Infinity -Infinity -label: 0.14ms true false -label: 0.15ms null undefined -label: 0.17ms Symbol(Symbol Description) -label: 0.22ms 2000-06-27T02:24:34.304Z -label: 0.29ms [ 123, 456, 789 ] -label: 0.34ms { +[0.00ms] label +[0.06ms] label Hello World! +[0.09ms] label a %s b c d +[0.11ms] label 0 -0 123 -123 123.567 -123.567 Infinity -Infinity +[0.14ms] label true false +[0.15ms] label null undefined +[0.17ms] label Symbol(Symbol Description) +[0.22ms] label 2000-06-27T02:24:34.304Z +[0.29ms] label [ 123, 456, 789 ] +[0.34ms] label { name: "foo", } -label: 0.37ms { +[0.37ms] label { a: 123, b: 456, c: 789, } -label: 0.39ms +[0.39ms] label diff --git a/test/js/web/console/console-timeLog.test.ts b/test/js/web/console/console-timeLog.test.ts index f255c227539d..bf7ddc485e5b 100644 --- a/test/js/web/console/console-timeLog.test.ts +++ b/test/js/web/console/console-timeLog.test.ts @@ -3,12 +3,6 @@ import { expect, it } from "bun:test"; import { bunEnv, bunExe } from "harness"; import { join } from "node:path"; -// console.timeLog / console.timeEnd print `${label}: ${formatTime(ms)}` through -// console.log, i.e. to stdout: -// https://github.com/nodejs/node/blob/v24.0.0/lib/internal/console/constructor.js#L401-L407 -// https://github.com/nodejs/node/blob/v24.0.0/lib/internal/util/debuglog.js#L155-L187 -const elapsed = /^(.*?): \d+(?:\.\d{1,3})?(?:ms|s)/gm; - it.concurrent("console.timeEnd with empty label emits exactly one trailing newline", async () => { await using proc = Bun.spawn({ cmd: [bunExe(), "-e", `console.time(""); console.timeEnd("");`], @@ -17,8 +11,8 @@ it.concurrent("console.timeEnd with empty label emits exactly one trailing newli stderr: "pipe", }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect(stderr).toBe(""); - expect(stdout).toMatch(/^: \d+(\.\d{1,3})?ms\n$/); + expect(stdout).toBe(""); + expect(stderr).toMatch(/^\[[\d.]+[mnµ]?s\]\n$/); expect(exitCode).toBe(0); }); @@ -30,48 +24,25 @@ it.concurrent("console.timeEnd with non-empty label emits exactly one trailing n stderr: "pipe", }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect(stderr).toBe(""); - expect(stdout).toMatch(/^abc: \d+(\.\d{1,3})?ms\n$/); - expect(exitCode).toBe(0); -}); - -it.concurrent("console.timeEnd / timeLog for a missing label warn instead of printing", async () => { - await using proc = Bun.spawn({ - cmd: [ - bunExe(), - "--no-warnings", // only our listener prints - "-e", - `process.on("warning", w => process.stderr.write("warning: " + w.message + "\\n")); console.timeEnd("nope"); console.timeLog("nope"); console.time("dup"); console.time("dup"); console.timeEnd("dup");`, - ], - env: bunEnv, - stdout: "pipe", - stderr: "pipe", - }); - const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect(stderr).toBe( - "warning: No such label 'nope' for console.timeEnd()\n" + - "warning: No such label 'nope' for console.timeLog()\n" + - "warning: Label 'dup' already exists for console.time()\n", - ); - expect(stdout).toMatch(/^dup: \d+(\.\d{1,3})?ms\n$/); + expect(stdout).toBe(""); + expect(stderr).toMatch(/^\[[\d.]+[mnµ]?s\] abc\n$/); expect(exitCode).toBe(0); }); it("should log to console correctly", async () => { - await using proc = spawn({ + const { stderr, exited } = spawn({ cmd: [bunExe(), join(import.meta.dir, "console-timeLog.js")], stdin: null, stdout: "pipe", stderr: "pipe", env: bunEnv, }); - const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(await exited).toBe(0); + const outText = await stderr.text(); const expectedText = (await file(join(import.meta.dir, "console-timeLog.expected.txt")).text()).replaceAll( "\r\n", "\n", ); - expect(stderr).toBe(""); - expect(stdout.replace(elapsed, "$1: [time]")).toBe(expectedText.replace(elapsed, "$1: [time]")); - expect(exitCode).toBe(0); + expect(outText.replace(/^\[.+?s\] /gm, "")).toBe(expectedText.replace(/^\[.+?s\] /gm, "")); }); diff --git a/test/napi/napi-app/test_experimental_with_timeout.js b/test/napi/napi-app/test_experimental_with_timeout.js index a0ad8cea0513..8bd34862370e 100644 --- a/test/napi/napi-app/test_experimental_with_timeout.js +++ b/test/napi/napi-app/test_experimental_with_timeout.js @@ -8,17 +8,10 @@ const modulePath = path.join(__dirname, 'build/Debug/test_reference_unref_in_fin const proc = spawn(process.argv[0], ['--expose-gc', '-e', ` const m = require("${modulePath}"); console.log('Loading experimental module...'); -// Allocate in a callee frame and scrub the stack afterwards so a conservative -// scan of this frame can't keep the wrapped objects alive past gc() (which is -// what decides whether their finalizers run *during* GC, i.e. whether this -// crashes as it must). -(function () { - m.test_reference_unref_in_finalizer_experimental(); - console.log('Test function returned'); -})(); -(function scrub(n) { return n > 0 ? scrub(n - 1) + 1 : 0; })(128); -const gc = () => (global.gc ? global.gc() : (process.isBun && Bun.gc ? Bun.gc(true) : null)); -gc(); gc(); +let arr = m.test_reference_unref_in_finalizer_experimental(); +console.log('Test function returned'); +arr = null; +global.gc ? global.gc() : (process.isBun && Bun.gc ? Bun.gc(true) : null); console.log('GC triggered - should crash now'); console.log('ERROR: Did not crash! Test failed!'); process.exit(1); From 3464a31850dc1dd378f5783b512bd30cf5f39270 Mon Sep 17 00:00:00 2001 From: Dylan Conway Date: Tue, 13 Oct 2026 21:29:49 +0000 Subject: [PATCH 22/26] FileSink: do not settle a pending write whose slot backed up again, and leave a gone reader for the event loop to report --- src/runtime/webcore/FileSink.rs | 16 ++++-- src/sys/lib.rs | 7 +-- test/js/node/process/process-stdio.test.ts | 61 ++++++++++++++++++---- 3 files changed, 68 insertions(+), 16 deletions(-) diff --git a/src/runtime/webcore/FileSink.rs b/src/runtime/webcore/FileSink.rs index daf5352746a6..ac51354fc9e7 100644 --- a/src/runtime/webcore/FileSink.rs +++ b/src/runtime/webcore/FileSink.rs @@ -232,11 +232,14 @@ fn __bun_flush_stdio_sink(global: &JSGlobalObject, fd: Fd) { let this = &raw mut (*sink).sink; let _guard = RefPtr::init_ref(this); while (*this).writer.get().has_pending_data() && !(*this).done.get() { - FileSink::flush_buffered(this); - let writer = (*this).writer.get(); - if writer.has_pending_data() && sys::wait_for_writable(writer.get_fd()).is_err() { + // A reader that is gone is left for the event loop to report, as it does without the console. + if !matches!( + sys::wait_for_writable((*this).writer.get().get_fd()), + Ok(true) + ) { break; } + FileSink::flush_buffered(this); } } } @@ -1756,7 +1759,12 @@ impl FlushPendingTask { // SAFETY: balances the `ref_()` taken in `run_pending_later()` when // this task was enqueued; `this` is live for at least that ref. let _guard = unsafe { RefPtr::from_raw(this) }; - if had { + // SAFETY: `_guard` keeps `this` live. + let sink = unsafe { &*this }; + // A `write()` has since joined the pending slot and backed up: `on_write` settles it once those bytes are out too. + let backed_up_again = sink.writer.get().has_pending_data() + && !matches!(sink.pending.get().result, streams::Writable::Err(_)); + if had && !backed_up_again { // SAFETY: `this` is the canonical `*mut FileSink` recovered via // `from_field_ptr!` from the embedded `run_pending_later` task; // `_guard` keeps it live for the call. diff --git a/src/sys/lib.rs b/src/sys/lib.rs index e6186e55caba..cd8bb5ec15bc 100644 --- a/src/sys/lib.rs +++ b/src/sys/lib.rs @@ -9244,15 +9244,16 @@ fn fd_write_all_quiet(fd: Fd, mut bytes: &[u8]) -> bool { true } -/// Blocks until `fd` takes a write, or has an error for the next write to report. +/// Blocks until `fd` takes a write; `false` if the next write would only report an error (the reader is gone). #[cfg(unix)] -pub fn wait_for_writable(fd: Fd) -> Maybe<()> { +pub fn wait_for_writable(fd: Fd) -> Maybe { let mut pfd = [posix::PollFd { fd: fd.native(), events: posix::POLL_OUT, revents: 0, }]; - posix::poll(&mut pfd, -1).map(|_| ()) + posix::poll(&mut pfd, -1)?; + Ok(pfd[0].revents & (libc::POLLERR | libc::POLLHUP | libc::POLLNVAL) == 0) } /// Concrete repr behind the opaque `bun_core::output::QuietWriterAdapter` diff --git a/test/js/node/process/process-stdio.test.ts b/test/js/node/process/process-stdio.test.ts index f2351ac1a264..ca8b0400859b 100644 --- a/test/js/node/process/process-stdio.test.ts +++ b/test/js/node/process/process-stdio.test.ts @@ -387,6 +387,17 @@ describe.concurrent.skipIf(isWindows)( .toString() .replace(/(\d)\1+/g, m => `[${m[0]}*${m.length}]`); + async function signalReaderOnceFull(events: ReturnType) { + let readerPid: number | undefined, ret: boolean | undefined; + while (readerPid === undefined || ret === undefined) { + const v = await events.next(); + if ("reader" in v) readerPid = v.reader; + if ("ret" in v) ret = v.ret; + } + expect(ret).toBe(false); + process.kill(readerPid, "SIGUSR1"); + } + test.each([ ["stdout", ["log", "info", "debug"], `"$0" -e "$1" | "$0" -e "$2"`], ["stderr", ["warn", "error"], `{ "$0" -e "$1" 2>&1 >&3 | "$0" -e "$2"; } 3>&2`], @@ -399,21 +410,53 @@ describe.concurrent.skipIf(isWindows)( stdout: "pipe", stderr: "pipe", }); - const events = jsonLines(proc.stderr); - let readerPid: number | undefined, ret: boolean | undefined; - while (readerPid === undefined || ret === undefined) { - const v = await events.next(); - if ("reader" in v) readerPid = v.reader; - if ("ret" in v) ret = v.ret; - } - expect(ret).toBe(false); - process.kill(readerPid, "SIGUSR1"); + await signalReaderOnceFull(jsonLines(proc.stderr)); const [stdout, exitCode] = await Promise.all([proc.stdout.bytes(), proc.exited]); expect(squash(stdout)).toBe(inCallOrder([...methods])); expect(exitCode).toBe(0); }, ); + // process.exit() drops what is still queued, so a callback that comes early loses the rest of the second write. + test("a write() after the console flushed the queue calls back once its own bytes are out", async () => { + const script = ` + require("fs").writeSync(2, JSON.stringify({ ret: process.stdout.write(Buffer.alloc(1 << 20, "0")) }) + "\\n"); + console.log(""); + process.stdout.write(Buffer.alloc(1 << 20, "1"), () => process.exit(0));`; + await using proc = spawn({ + cmd: ["sh", "-c", `"$0" -e "$1" | "$0" -e "$2"`, bunExe(), script, gatedReader], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + await signalReaderOnceFull(jsonLines(proc.stderr)); + const [stdout, exitCode] = await Promise.all([proc.stdout.bytes(), proc.exited]); + expect(squash(stdout)).toBe(`[0*${1 << 20}]\n[1*${1 << 20}]`); + expect(exitCode).toBe(0); + }); + + test("the reader going away while the console waits fails the queued write and the next one with EPIPE", async () => { + const script = ` + const report = v => require("fs").writeSync(2, JSON.stringify(v) + "\\n"); + process.stdout.on("error", () => {}); + report({ ret: process.stdout.write(Buffer.alloc(1 << 20, "0"), e => report({ queued: e?.code ?? null })) }); + console.log(""); + process.stdout.write("next", e => report({ next: e?.code ?? null }));`; + const leavingReader = `process.on("SIGUSR1", () => process.exit(0)); + setInterval(() => {}, 1 << 30); + require("fs").writeSync(2, JSON.stringify({ reader: process.pid }) + "\\n");`; + await using proc = spawn({ + cmd: ["sh", "-c", `"$0" -e "$1" | "$0" -e "$2"`, bunExe(), script, leavingReader], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const events = jsonLines(proc.stderr); + await signalReaderOnceFull(events); + expect([await events.next(), await events.next()]).toEqual([{ queued: "EPIPE" }, { next: "EPIPE" }]); + expect(await proc.exited).toBe(0); + }); + test("console.log comes after what process.stdout.write() queued on a full socket", async () => { await using proc = spawn({ cmd: [bunExe(), "-e", callOrderScript("stdout", ["log", "info", "debug"])], From 83e1363c8f5b078ae3724be884f821ad591cd740 Mon Sep 17 00:00:00 2001 From: Dylan Conway Date: Tue, 13 Oct 2026 23:41:27 +0000 Subject: [PATCH 23/26] console: flush process.stdout/stderr's sink the way the event loop does, before taking the console lock --- src/jsc/ConsoleObject.rs | 22 ++--- src/jsc/bindings/BunProcess.cpp | 27 +++--- src/jsc/bindings/BunProcess.h | 9 +- src/runtime/webcore/FileSink.rs | 62 +++++------- test/js/node/process/process-stdio.test.ts | 107 +++++++++++++-------- 5 files changed, 122 insertions(+), 105 deletions(-) diff --git a/src/jsc/ConsoleObject.rs b/src/jsc/ConsoleObject.rs index 2991950f5a28..d750f6ac8edd 100644 --- a/src/jsc/ConsoleObject.rs +++ b/src/jsc/ConsoleObject.rs @@ -272,7 +272,7 @@ unsafe fn vm_console_mut<'a>(global: &JSGlobalObject) -> &'a mut ConsoleObject { #[cfg(unix)] unsafe extern "Rust" { - /// `bun_runtime::webcore::FileSink`: what `process.stdout` / `process.stderr` queued for `fd` goes out before the console writes to it. + /// Defined in `bun_runtime::webcore::file_sink`. On Windows those sinks are `SyncFile`: every write blocks until it is out, so nothing is buffered. safe fn __bun_flush_stdio_sink(global: &JSGlobalObject, fd: bun_sys::Fd); } @@ -415,6 +415,16 @@ 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; + // Before the lock: this waits for the reader of this thread's own sink, and other threads' console output need not wait with it. + #[cfg(unix)] + __bun_flush_stdio_sink( + global, + if use_stderr { + bun_sys::Fd::stderr() + } else { + bun_sys::Fd::stdout() + }, + ); let _stream_lock = ConsoleStreamLock::acquire(use_stderr); if message_type == MessageType::Clear { @@ -465,16 +475,6 @@ fn message_with_type_and_level_( // `bun_core::io::Writer: bun_io::Write` — `&mut Writer` unsize-coerces directly. let writer: &mut dyn bun_io::Write = raw_writer; - #[cfg(unix)] - __bun_flush_stdio_sink( - global, - if matches!(level, MessageLevel::Warning | MessageLevel::Error) { - bun_sys::Fd::stderr() - } else { - bun_sys::Fd::stdout() - }, - ); - // LAYERING: `Jest::runner()` lives in `bun_runtime::test_runner` (forward // dep on the high tier). Dispatch through `RuntimeHooks` instead — the // high-tier hook checks `Jest.runner` and calls `onBeforePrint()`; no-op diff --git a/src/jsc/bindings/BunProcess.cpp b/src/jsc/bindings/BunProcess.cpp index d2675c999062..f0a7bf42c0f9 100644 --- a/src/jsc/bindings/BunProcess.cpp +++ b/src/jsc/bindings/BunProcess.cpp @@ -3030,16 +3030,17 @@ static JSValue constructStdioWriteStream(JSC::JSGlobalObject* globalObject, JSC: // Note: files are always sync anyway. // forceSync = fdType == BunProcessStdinFdType::file || bun_stdio_tty[fd]; - // TODO: once console.* is wired up to write/read through the same buffering mechanism as FileSink for process.stdout, process.stderr, we can make this non-blocking for sockets on POSIX. - // Until then, we have to force it to be sync EVEN for sockets or else console.log() may flush at a different time than process.stdout.write. + // Turns off FileSink's coalescing of small writes. What a full pipe or socket refuses is still buffered; console.* flushes that first (m_stdioSinks). forceSync = true; #endif - JSValue sink = resultObject->getIndex(globalObject, 1); - RETURN_IF_EXCEPTION(scope, jsUndefined()); - if (auto* sinkObject = sink.getObject()) - uncheckedDowncast(processObject)->setStdioSink(vm, fd, sinkObject); if (forceSync) { + JSValue sink = resultObject->getIndex(globalObject, 1); + RETURN_IF_EXCEPTION(scope, jsUndefined()); Bun__ForceFileSinkToBeSynchronousForProcessObjectStdio(globalObject, JSValue::encode(sink)); +#if !OS(WINDOWS) + if (auto* sinkObject = sink.getObject()) + uncheckedDowncast(processObject)->m_stdioSinks[fd - 1].set(vm, processObject, sinkObject); +#endif } JSValue stream = resultObject->getIndex(globalObject, 0); @@ -3047,13 +3048,15 @@ static JSValue constructStdioWriteStream(JSC::JSGlobalObject* globalObject, JSC: return stream; } +#if !OS(WINDOWS) extern "C" JSC::EncodedJSValue Bun__Process__getStdioSink(JSC::JSGlobalObject* lexicalGlobalObject, int fd) { + ASSERT(fd == 1 || fd == 2); auto* globalObject = defaultGlobalObject(lexicalGlobalObject); - if (!globalObject->hasProcessObject()) - return JSValue::encode({}); - return JSValue::encode(globalObject->processObject()->stdioSink(fd)); + JSObject* sink = globalObject->hasProcessObject() ? globalObject->processObject()->m_stdioSinks[fd - 1].get() : nullptr; + return JSValue::encode(sink ? JSValue(sink) : jsUndefined()); } +#endif static JSValue constructStdout(VM& vm, JSObject* processObject) { @@ -3792,8 +3795,10 @@ void Process::visitChildrenImpl(JSCell* cell, Visitor& visitor) visitor.append(thisObject->m_argv); visitor.append(thisObject->m_execArgv); visitor.append(thisObject->m_onWarning); - visitor.append(thisObject->m_stdioSinks[0]); - visitor.append(thisObject->m_stdioSinks[1]); +#if !OS(WINDOWS) + for (auto& sink : thisObject->m_stdioSinks) + visitor.append(sink); +#endif thisObject->m_cpuUsageStructure.visit(visitor); thisObject->m_resourceUsageStructure.visit(visitor); diff --git a/src/jsc/bindings/BunProcess.h b/src/jsc/bindings/BunProcess.h index 266923fb00d1..50f3566d9fd4 100644 --- a/src/jsc/bindings/BunProcess.h +++ b/src/jsc/bindings/BunProcess.h @@ -36,8 +36,6 @@ class Process : public WebCore::JSEventEmitter { WriteBarrier m_execArgv; // The JS warning printer (ProcessObjectInternals createOnWarning), built on the first warning. WriteBarrier m_onWarning; - // The FileSinks behind process.stdout and process.stderr: console.* flushes them before it writes to the same fd. - WriteBarrier m_stdioSinks[2]; void installDefaultWarningListener(JSC::VM&); @@ -49,9 +47,10 @@ class Process : public WebCore::JSEventEmitter { DECLARE_EXPORT_INFO; bool m_reportOnUncaughtException = false; - - JSObject* stdioSink(int fd) const { return m_stdioSinks[fd - 1].get(); } - void setStdioSink(JSC::VM& vm, int fd, JSObject* sink) { m_stdioSinks[fd - 1].set(vm, this, sink); } +#if !OS(WINDOWS) + // The FileSinks behind process.stdout ([0]) and process.stderr ([1]): console.* flushes what they buffered before it writes. + WriteBarrier m_stdioSinks[2]; +#endif static void destroy(JSC::JSCell* cell) { diff --git a/src/runtime/webcore/FileSink.rs b/src/runtime/webcore/FileSink.rs index ac51354fc9e7..5cd166580b92 100644 --- a/src/runtime/webcore/FileSink.rs +++ b/src/runtime/webcore/FileSink.rs @@ -212,34 +212,31 @@ impl FileSink { #[cfg(unix)] unsafe extern "C" { - /// The `FileSink` wrapper behind `process.stdout` (1) / `process.stderr` (2), or empty before that stream exists. + /// The `FileSink` wrapper behind `process.stdout` (1) / `process.stderr` (2), or `undefined` before that stream exists. safe fn Bun__Process__getStdioSink(global: &JSGlobalObject, fd: i32) -> JSValue; } -/// `console.*` is about to write to `fd` (1 or 2): what `process.stdout` / `process.stderr` queued for it goes out first. +/// `console.*` is about to write to `fd` (1 or 2): what `process.stdout` / `process.stderr` buffered for it goes out first. #[cfg(unix)] #[unsafe(no_mangle)] fn __bun_flush_stdio_sink(global: &JSGlobalObject, fd: Fd) { - let sink = Bun__Process__getStdioSink(global, fd.native()); - if sink.is_empty() { - return; - } - let Some(sink) = JSSink::from_js(sink) else { + use bun_io::pipe_writer::PosixPipeWriter as _; + + let Some(this_ptr) = JSSink::from_js(Bun__Process__getStdioSink(global, fd.native())) else { return; }; + // `on_write` settles the buffered write; its callbacks run after the JS that called the console, not inside it. + let _entered = global.bun_vm().enter_event_loop_scope_without_checkpoint(); // SAFETY: `from_js` returned the wrapper's live `m_ctx`, the sink's own allocation; `_guard` keeps it live. unsafe { - let this = &raw mut (*sink).sink; + let this = &raw mut (*this_ptr).sink; let _guard = RefPtr::init_ref(this); - while (*this).writer.get().has_pending_data() && !(*this).done.get() { - // A reader that is gone is left for the event loop to report, as it does without the console. - if !matches!( - sys::wait_for_writable((*this).writer.get().get_fd()), - Ok(true) - ) { - break; - } - FileSink::flush_buffered(this); + // What the event loop does once the fd is writable. A closed writer or a reader that is gone is left for it to report. + while (*this).writer.get().has_pending_data() + && (*this).writer.get().get_poll().is_some() + && sys::wait_for_writable((*this).writer.get().get_fd()).unwrap_or(false) + { + (*this).writer.with_mut(|w| w.on_poll(0, false)); } } } @@ -966,30 +963,21 @@ impl FileSink { /// [`on_attached_process_exit`](Self::on_attached_process_exit)): live, /// with write+dealloc provenance over the allocation. pub(crate) unsafe fn on_auto_flush(this: *mut FileSink) -> bool { - // SAFETY: caller contract — `this` is live with write+dealloc provenance. - unsafe { - let _guard = RefPtr::init_ref(this); - let is_registered = FileSink::flush_buffered(this); - (*this) - .auto_flusher - .with_mut(|a| a.registered.set(is_registered)); - is_registered - } - } - - /// [`on_auto_flush`](Self::on_auto_flush) without the `AutoFlusher` bookkeeping, under its contract; the caller holds a ref across the call. - unsafe fn flush_buffered(this: *mut FileSink) -> bool { // SAFETY: caller contract — `this` is live with write+dealloc provenance. unsafe { if !(*this).writer.get().has_pending_data() { (*this).update_ref(false); + (*this).auto_flusher.with_mut(|a| a.registered.set(false)); return false; } // After `end()` the writable poll drains the tail and drops the ref. if (*this).done.get() { + (*this).auto_flusher.with_mut(|a| a.registered.set(false)); return false; } + let _guard = RefPtr::init_ref(this); + let amount_buffered = (*this).writer.get().outgoing.size(); // SAFETY(JsCell): `IOWriter::flush` is pure I/O; the `on_write` @@ -1012,6 +1000,7 @@ impl FileSink { } (*this).writer.with_mut(|w| w.end()); (*this).run_pending_later(); + (*this).auto_flusher.with_mut(|a| a.registered.set(false)); return false; } WriteResult::Done(_) => { @@ -1034,7 +1023,11 @@ impl FileSink { } } - !(*this).writer.get().has_pending_data() + let is_registered = !(*this).writer.get().has_pending_data(); + (*this) + .auto_flusher + .with_mut(|a| a.registered.set(is_registered)); + is_registered } } @@ -1759,12 +1752,7 @@ impl FlushPendingTask { // SAFETY: balances the `ref_()` taken in `run_pending_later()` when // this task was enqueued; `this` is live for at least that ref. let _guard = unsafe { RefPtr::from_raw(this) }; - // SAFETY: `_guard` keeps `this` live. - let sink = unsafe { &*this }; - // A `write()` has since joined the pending slot and backed up: `on_write` settles it once those bytes are out too. - let backed_up_again = sink.writer.get().has_pending_data() - && !matches!(sink.pending.get().result, streams::Writable::Err(_)); - if had && !backed_up_again { + if had { // SAFETY: `this` is the canonical `*mut FileSink` recovered via // `from_field_ptr!` from the embedded `run_pending_later` task; // `_guard` keeps it live for the call. diff --git a/test/js/node/process/process-stdio.test.ts b/test/js/node/process/process-stdio.test.ts index ca8b0400859b..63433cc7da90 100644 --- a/test/js/node/process/process-stdio.test.ts +++ b/test/js/node/process/process-stdio.test.ts @@ -163,9 +163,9 @@ describe.concurrent("process-stdio", () => { // Like Node, process.stdout/stderr put O_NONBLOCK on fd 1/2 when they are pipes or sockets. The flag lives on the shared open // file description, so spawn clears it again for a child's fds 0-2 (libuv does the same), and the native console writer must -// wait on EAGAIN instead of dropping the rest of the line. +// wait on EAGAIN instead of dropping the rest of the line, and flush what the stream buffered before it writes. describe.concurrent.skipIf(isWindows)( - "O_NONBLOCK on process.stdout/stderr does not leak to children or truncate console output", + "process.stdout/stderr on a pipe or socket: O_NONBLOCK does not leak to children, console output is whole and in call order", () => { const probe = path.join(import.meta.dir, "fd-nonblock-fixture.js"); @@ -227,6 +227,17 @@ describe.concurrent.skipIf(isWindows)( expect(exitCode).toBe(0); } + async function signalReaderOnceFull(events: ReturnType) { + let readerPid: number | undefined, ret: boolean | undefined; + while (readerPid === undefined || ret === undefined) { + const v = await events.next(); + if ("reader" in v) readerPid = v.reader; + if ("ret" in v) ret = v.ret; + } + expect(ret).toBe(false); + process.kill(readerPid, "SIGUSR1"); + } + // Real pipe(2) via sh, reader gated on SIGUSR1. async function expectDrainOverPipe( prelude: string, @@ -241,14 +252,7 @@ describe.concurrent.skipIf(isWindows)( stderr: "pipe", }); const events = jsonLines(proc.stderr); - let readerPid: number | undefined, ret: boolean | undefined; - while (readerPid === undefined || ret === undefined) { - const v = await events.next(); - if ("reader" in v) readerPid = v.reader; - if ("ret" in v) ret = v.ret; - } - expect(ret).toBe(false); - process.kill(readerPid!, "SIGUSR1"); + await signalReaderOnceFull(events); expect(await events.next()).toEqual(expectedDrain); const [stdout, exitCode] = await Promise.all([proc.stdout.bytes(), proc.exited]); expect(stdout.byteLength).toBe(expectedBytes); @@ -372,53 +376,52 @@ describe.concurrent.skipIf(isWindows)( }, ); - // console.* writes synchronously while process.stdout/stderr queue what a full pipe refuses, so the console has to flush - // that queue first. Reports go to the other fd; nothing reads the fd under test until the first write() came back false. - const callOrderScript = (stream: "stdout" | "stderr", methods: string[]) => ` + // Each console call and what it prints. Reports go to the other fd; nothing reads the fd under test until the first write() returned false. + const stdoutCalls = [ + [`log("")`, ""], + [`info("")`, ""], + [`debug("")`, ""], + ] as const; + const stderrCalls = [ + [`warn("")`, ""], + [`error("")`, ""], + [`assert(false)`, "Assertion failed"], + ] as const; + type Calls = typeof stdoutCalls | typeof stderrCalls; + const callOrderScript = (stream: "stdout" | "stderr", calls: Calls) => ` const report = v => require("fs").writeSync(${stream === "stdout" ? 2 : 1}, JSON.stringify(v) + "\\n"); - ${JSON.stringify(methods)}.forEach((method, i) => { + [${calls.map(([call]) => `() => console.${call}`).join(", ")}].forEach((call, i) => { const ret = process.${stream}.write(Buffer.alloc(1 << 20, String(i))); if (i === 0) report({ ret }); - console[method]("<" + method + ">"); + call(); });`; - const inCallOrder = (methods: string[]) => methods.map((method, i) => `[${i}*${1 << 20}]<${method}>\n`).join(""); + const inCallOrder = (calls: Calls) => calls.map(([, printed], i) => `[${i}*${1 << 20}]${printed}\n`).join(""); const squash = (bytes: Uint8Array) => Buffer.from(bytes) .toString() .replace(/(\d)\1+/g, m => `[${m[0]}*${m.length}]`); - async function signalReaderOnceFull(events: ReturnType) { - let readerPid: number | undefined, ret: boolean | undefined; - while (readerPid === undefined || ret === undefined) { - const v = await events.next(); - if ("reader" in v) readerPid = v.reader; - if ("ret" in v) ret = v.ret; - } - expect(ret).toBe(false); - process.kill(readerPid, "SIGUSR1"); - } - test.each([ - ["stdout", ["log", "info", "debug"], `"$0" -e "$1" | "$0" -e "$2"`], - ["stderr", ["warn", "error"], `{ "$0" -e "$1" 2>&1 >&3 | "$0" -e "$2"; } 3>&2`], + ["stdout", stdoutCalls, `"$0" -e "$1" | "$0" -e "$2"`], + ["stderr", stderrCalls, `{ "$0" -e "$1" 2>&1 >&3 | "$0" -e "$2"; } 3>&2`], ] as const)( - "console output on %s comes after what the stream queued on a full pipe", - async (stream, methods, pipeline) => { + "console output on %s comes after what the stream buffered on a full pipe", + async (stream, calls, pipeline) => { await using proc = spawn({ - cmd: ["sh", "-c", pipeline, bunExe(), callOrderScript(stream, [...methods]), gatedReader], + cmd: ["sh", "-c", pipeline, bunExe(), callOrderScript(stream, calls), gatedReader], env: bunEnv, stdout: "pipe", stderr: "pipe", }); await signalReaderOnceFull(jsonLines(proc.stderr)); const [stdout, exitCode] = await Promise.all([proc.stdout.bytes(), proc.exited]); - expect(squash(stdout)).toBe(inCallOrder([...methods])); + expect(squash(stdout)).toBe(inCallOrder(calls)); expect(exitCode).toBe(0); }, ); - // process.exit() drops what is still queued, so a callback that comes early loses the rest of the second write. - test("a write() after the console flushed the queue calls back once its own bytes are out", async () => { + // process.exit() drops what is still buffered, so a callback that comes early loses the rest of the second write. + test("a write() after the console flushed the stream calls back once its own bytes are out", async () => { const script = ` require("fs").writeSync(2, JSON.stringify({ ret: process.stdout.write(Buffer.alloc(1 << 20, "0")) }) + "\\n"); console.log(""); @@ -435,11 +438,33 @@ describe.concurrent.skipIf(isWindows)( expect(exitCode).toBe(0); }); - test("the reader going away while the console waits fails the queued write and the next one with EPIPE", async () => { + // The result without the console too. Nothing has entered the event loop around a 'beforeExit' listener, so settling the write could drain microtasks. + test("the callback of a write the console flushed runs after the JS that called the console, not inside it", async () => { + const script = ` + const report = v => require("fs").writeSync(2, JSON.stringify(v) + "\\n"); + process.once("beforeExit", () => { + report({ ret: process.stdout.write(Buffer.alloc(1 << 20, "0"), () => report("callback")) }); + console.log(""); + report("after console.log"); + });`; + await using proc = spawn({ + cmd: ["sh", "-c", `"$0" -e "$1" | "$0" -e "$2"`, bunExe(), script, gatedReader], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const events = jsonLines(proc.stderr); + await signalReaderOnceFull(events); + expect([await events.next(), await events.next()]).toEqual(["after console.log", "callback"]); + expect(await proc.exited).toBe(0); + }); + + // The result without the console too: the flush leaves a reader that is gone for the event loop to report. + test("the reader going away while the console waits fails the buffered write and the next one with EPIPE", async () => { const script = ` const report = v => require("fs").writeSync(2, JSON.stringify(v) + "\\n"); process.stdout.on("error", () => {}); - report({ ret: process.stdout.write(Buffer.alloc(1 << 20, "0"), e => report({ queued: e?.code ?? null })) }); + report({ ret: process.stdout.write(Buffer.alloc(1 << 20, "0"), e => report({ buffered: e?.code ?? null })) }); console.log(""); process.stdout.write("next", e => report({ next: e?.code ?? null }));`; const leavingReader = `process.on("SIGUSR1", () => process.exit(0)); @@ -453,13 +478,13 @@ describe.concurrent.skipIf(isWindows)( }); const events = jsonLines(proc.stderr); await signalReaderOnceFull(events); - expect([await events.next(), await events.next()]).toEqual([{ queued: "EPIPE" }, { next: "EPIPE" }]); + expect([await events.next(), await events.next()]).toEqual([{ buffered: "EPIPE" }, { next: "EPIPE" }]); expect(await proc.exited).toBe(0); }); - test("console.log comes after what process.stdout.write() queued on a full socket", async () => { + test("console output on stdout comes after what the stream buffered on a full socket", async () => { await using proc = spawn({ - cmd: [bunExe(), "-e", callOrderScript("stdout", ["log", "info", "debug"])], + cmd: [bunExe(), "-e", callOrderScript("stdout", stdoutCalls)], env: bunEnv, stdout: "pipe", stderr: "pipe", @@ -467,7 +492,7 @@ describe.concurrent.skipIf(isWindows)( }); expect(await jsonLines(proc.stderr).next()).toEqual({ ret: false }); const [stdout, exitCode] = await Promise.all([proc.stdout.bytes(), proc.exited]); - expect(squash(stdout)).toBe(inCallOrder(["log", "info", "debug"])); + expect(squash(stdout)).toBe(inCallOrder(stdoutCalls)); expect(exitCode).toBe(0); }); }, From 865813122d965f0b1c258083523edcdf08d4a265 Mon Sep 17 00:00:00 2001 From: Dylan Conway Date: Wed, 14 Oct 2026 00:06:31 +0000 Subject: [PATCH 24/26] console: the flush only moves bytes and leaves a write error for the event loop to report --- src/runtime/webcore/FileSink.rs | 13 +++++++--- src/sys/lib.rs | 7 +++-- test/js/node/process/process-stdio.test.ts | 30 ++++++++++++++++------ 3 files changed, 35 insertions(+), 15 deletions(-) diff --git a/src/runtime/webcore/FileSink.rs b/src/runtime/webcore/FileSink.rs index 5cd166580b92..37af988deb2a 100644 --- a/src/runtime/webcore/FileSink.rs +++ b/src/runtime/webcore/FileSink.rs @@ -231,12 +231,19 @@ fn __bun_flush_stdio_sink(global: &JSGlobalObject, fd: Fd) { unsafe { let this = &raw mut (*this_ptr).sink; let _guard = RefPtr::init_ref(this); - // What the event loop does once the fd is writable. A closed writer or a reader that is gone is left for it to report. while (*this).writer.get().has_pending_data() && (*this).writer.get().get_poll().is_some() - && sys::wait_for_writable((*this).writer.get().get_fd()).unwrap_or(false) + && sys::wait_for_writable((*this).writer.get().get_fd()).is_ok() { - (*this).writer.with_mut(|w| w.on_poll(0, false)); + let (written, status) = + match (*this).writer.get().drain_buffered_data(usize::MAX, false) { + WriteResult::Pending(written) => (written, WriteStatus::Pending), + WriteResult::Wrote(written) => (written, WriteStatus::Drained), + WriteResult::Done(written) => (written, WriteStatus::EndOfFile), + // Nothing was written. The event loop's poll is still armed: it finds the same error and reports it between two turns of JS, not inside one. + WriteResult::Err(_) => break, + }; + (*this).writer.with_mut(|w| w.on_write(written, status)); } } } diff --git a/src/sys/lib.rs b/src/sys/lib.rs index cd8bb5ec15bc..e6186e55caba 100644 --- a/src/sys/lib.rs +++ b/src/sys/lib.rs @@ -9244,16 +9244,15 @@ fn fd_write_all_quiet(fd: Fd, mut bytes: &[u8]) -> bool { true } -/// Blocks until `fd` takes a write; `false` if the next write would only report an error (the reader is gone). +/// Blocks until `fd` takes a write, or has an error for the next write to report. #[cfg(unix)] -pub fn wait_for_writable(fd: Fd) -> Maybe { +pub fn wait_for_writable(fd: Fd) -> Maybe<()> { let mut pfd = [posix::PollFd { fd: fd.native(), events: posix::POLL_OUT, revents: 0, }]; - posix::poll(&mut pfd, -1)?; - Ok(pfd[0].revents & (libc::POLLERR | libc::POLLHUP | libc::POLLNVAL) == 0) + posix::poll(&mut pfd, -1).map(|_| ()) } /// Concrete repr behind the opaque `bun_core::output::QuietWriterAdapter` diff --git a/test/js/node/process/process-stdio.test.ts b/test/js/node/process/process-stdio.test.ts index 63433cc7da90..584a16023212 100644 --- a/test/js/node/process/process-stdio.test.ts +++ b/test/js/node/process/process-stdio.test.ts @@ -401,9 +401,17 @@ describe.concurrent.skipIf(isWindows)( .toString() .replace(/(\d)\1+/g, m => `[${m[0]}*${m.length}]`); + // A pipeline's status is its last command's, so the writer's own goes out with the reports. + const stdoutPipeline = `( "$0" -e "$1"; echo "{\\"writer\\":$?}" >&2 ) | "$0" -e "$2"`; + const stderrPipeline = `{ ( "$0" -e "$1"; echo "{\\"writer\\":$?}" ) 2>&1 >&3 | "$0" -e "$2"; } 3>&2`; + async function expectWriterExitedCleanly(events: ReturnType) { + expect(await events.next()).toEqual({ writer: 0 }); + expect(await events.rest()).toBe(""); + } + test.each([ - ["stdout", stdoutCalls, `"$0" -e "$1" | "$0" -e "$2"`], - ["stderr", stderrCalls, `{ "$0" -e "$1" 2>&1 >&3 | "$0" -e "$2"; } 3>&2`], + ["stdout", stdoutCalls, stdoutPipeline], + ["stderr", stderrCalls, stderrPipeline], ] as const)( "console output on %s comes after what the stream buffered on a full pipe", async (stream, calls, pipeline) => { @@ -413,9 +421,11 @@ describe.concurrent.skipIf(isWindows)( stdout: "pipe", stderr: "pipe", }); - await signalReaderOnceFull(jsonLines(proc.stderr)); + const events = jsonLines(proc.stderr); + await signalReaderOnceFull(events); const [stdout, exitCode] = await Promise.all([proc.stdout.bytes(), proc.exited]); expect(squash(stdout)).toBe(inCallOrder(calls)); + await expectWriterExitedCleanly(events); expect(exitCode).toBe(0); }, ); @@ -427,14 +437,16 @@ describe.concurrent.skipIf(isWindows)( console.log(""); process.stdout.write(Buffer.alloc(1 << 20, "1"), () => process.exit(0));`; await using proc = spawn({ - cmd: ["sh", "-c", `"$0" -e "$1" | "$0" -e "$2"`, bunExe(), script, gatedReader], + cmd: ["sh", "-c", stdoutPipeline, bunExe(), script, gatedReader], env: bunEnv, stdout: "pipe", stderr: "pipe", }); - await signalReaderOnceFull(jsonLines(proc.stderr)); + const events = jsonLines(proc.stderr); + await signalReaderOnceFull(events); const [stdout, exitCode] = await Promise.all([proc.stdout.bytes(), proc.exited]); expect(squash(stdout)).toBe(`[0*${1 << 20}]\n[1*${1 << 20}]`); + await expectWriterExitedCleanly(events); expect(exitCode).toBe(0); }); @@ -448,7 +460,7 @@ describe.concurrent.skipIf(isWindows)( report("after console.log"); });`; await using proc = spawn({ - cmd: ["sh", "-c", `"$0" -e "$1" | "$0" -e "$2"`, bunExe(), script, gatedReader], + cmd: ["sh", "-c", stdoutPipeline, bunExe(), script, gatedReader], env: bunEnv, stdout: "pipe", stderr: "pipe", @@ -456,10 +468,11 @@ describe.concurrent.skipIf(isWindows)( const events = jsonLines(proc.stderr); await signalReaderOnceFull(events); expect([await events.next(), await events.next()]).toEqual(["after console.log", "callback"]); + await expectWriterExitedCleanly(events); expect(await proc.exited).toBe(0); }); - // The result without the console too: the flush leaves a reader that is gone for the event loop to report. + // The result without the console too: the flush stops at a write error and leaves it for the event loop to report. test("the reader going away while the console waits fails the buffered write and the next one with EPIPE", async () => { const script = ` const report = v => require("fs").writeSync(2, JSON.stringify(v) + "\\n"); @@ -471,7 +484,7 @@ describe.concurrent.skipIf(isWindows)( setInterval(() => {}, 1 << 30); require("fs").writeSync(2, JSON.stringify({ reader: process.pid }) + "\\n");`; await using proc = spawn({ - cmd: ["sh", "-c", `"$0" -e "$1" | "$0" -e "$2"`, bunExe(), script, leavingReader], + cmd: ["sh", "-c", stdoutPipeline, bunExe(), script, leavingReader], env: bunEnv, stdout: "pipe", stderr: "pipe", @@ -479,6 +492,7 @@ describe.concurrent.skipIf(isWindows)( const events = jsonLines(proc.stderr); await signalReaderOnceFull(events); expect([await events.next(), await events.next()]).toEqual([{ buffered: "EPIPE" }, { next: "EPIPE" }]); + await expectWriterExitedCleanly(events); expect(await proc.exited).toBe(0); }); From 75e65110684bd5a887bb590bcfbe2b861997fa58 Mon Sep 17 00:00:00 2001 From: Dylan Conway Date: Wed, 14 Oct 2026 00:10:24 +0000 Subject: [PATCH 25/26] test: do not call console.assert(false), which fails exception validation on main too --- test/js/node/process/process-stdio.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/test/js/node/process/process-stdio.test.ts b/test/js/node/process/process-stdio.test.ts index 584a16023212..d4e0f82ceb47 100644 --- a/test/js/node/process/process-stdio.test.ts +++ b/test/js/node/process/process-stdio.test.ts @@ -385,7 +385,6 @@ describe.concurrent.skipIf(isWindows)( const stderrCalls = [ [`warn("")`, ""], [`error("")`, ""], - [`assert(false)`, "Assertion failed"], ] as const; type Calls = typeof stdoutCalls | typeof stderrCalls; const callOrderScript = (stream: "stdout" | "stderr", calls: Calls) => ` From 7f91520ada410d931cc5cc86d3f24648d62360b6 Mon Sep 17 00:00:00 2001 From: Dylan Conway Date: Wed, 14 Oct 2026 19:09:32 +0000 Subject: [PATCH 26/26] console: output for a backed-up process.stdout/stderr goes into that stream's buffer, as in Node --- src/io/PipeWriter.rs | 2 +- src/jsc/ConsoleObject.rs | 49 +++++++++----------- src/jsc/bindings/BunProcess.cpp | 8 +--- src/jsc/bindings/BunProcess.h | 4 +- src/runtime/webcore/FileSink.rs | 48 ++++++++------------ src/sys/lib.rs | 18 +++----- test/js/node/process/process-stdio.test.ts | 52 +++++++--------------- test/js/web/console/console-log.test.ts | 12 +++++ 8 files changed, 78 insertions(+), 115 deletions(-) diff --git a/src/io/PipeWriter.rs b/src/io/PipeWriter.rs index c70860b311c1..b298598ee8d3 100644 --- a/src/io/PipeWriter.rs +++ b/src/io/PipeWriter.rs @@ -786,7 +786,7 @@ impl PosixStreamingWriter { self.handle.get_poll() } - pub fn get_fd(&self) -> Fd { + pub(crate) fn get_fd(&self) -> Fd { self.handle.get_fd() } diff --git a/src/jsc/ConsoleObject.rs b/src/jsc/ConsoleObject.rs index d750f6ac8edd..c0ea436b5bd0 100644 --- a/src/jsc/ConsoleObject.rs +++ b/src/jsc/ConsoleObject.rs @@ -270,10 +270,11 @@ unsafe fn vm_console_mut<'a>(global: &JSGlobalObject) -> &'a mut ConsoleObject { unsafe { &mut *vm_console(global) } } -#[cfg(unix)] unsafe extern "Rust" { - /// Defined in `bun_runtime::webcore::file_sink`. On Windows those sinks are `SyncFile`: every write blocks until it is out, so nothing is buffered. - safe fn __bun_flush_stdio_sink(global: &JSGlobalObject, fd: bun_sys::Fd); + /// Defined in `bun_runtime::webcore::file_sink`: the sink behind `process.stdout` (1) / `process.stderr` (2) holds bytes a full pipe or socket refused. + safe fn __bun_stdio_sink_is_backed_up(global: &JSGlobalObject, fd: i32) -> bool; + /// Appends `message` behind those bytes; `false`, with nothing appended, if there are none any more. + safe fn __bun_stdio_sink_append(global: &JSGlobalObject, fd: i32, message: &[u8]) -> bool; } static STDERR_MUTEX: Mutex = Mutex::new(); @@ -415,16 +416,6 @@ 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; - // Before the lock: this waits for the reader of this thread's own sink, and other threads' console output need not wait with it. - #[cfg(unix)] - __bun_flush_stdio_sink( - global, - if use_stderr { - bun_sys::Fd::stderr() - } else { - bun_sys::Fd::stdout() - }, - ); let _stream_lock = ConsoleStreamLock::acquire(use_stderr); if message_type == MessageType::Clear { @@ -461,19 +452,27 @@ fn message_with_type_and_level_( 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 - // long-lived `&mut ConsoleObject` across the re-derive in the empty-`Log` - // arm below. - let raw_writer: &mut bun_core::io::Writer = unsafe { + // `ConsoleObject` for this VM; JS-thread-only. + let (raw_writer, fd): (&mut bun_core::io::Writer, i32) = unsafe { if matches!(level, MessageLevel::Warning | MessageLevel::Error) { - (*console).error_writer() + ((*console).error_writer(), 2) } else { - (*console).writer() + ((*console).writer(), 1) } }; + // As in Node, a message for a stream that is backed up is formatted first and then goes behind what the stream buffered. + let mut behind_sink = None; // `bun_core::io::Writer: bun_io::Write` — `&mut Writer` unsize-coerces directly. - let writer: &mut dyn bun_io::Write = raw_writer; + let writer: &mut dyn bun_io::Write = if __bun_stdio_sink_is_backed_up(global, fd) { + &mut **behind_sink.insert(scopeguard::guard(Vec::new(), move |message: Vec| { + if !__bun_stdio_sink_append(global, fd, &message) { + let _ = raw_writer.write_all(&message); + let _ = raw_writer.flush(); + } + })) + } else { + raw_writer + }; // LAYERING: `Jest::runner()` lives in `bun_runtime::test_runner` (forward // dep on the high tier). Dispatch through `RuntimeHooks` instead — the @@ -557,12 +556,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"); } diff --git a/src/jsc/bindings/BunProcess.cpp b/src/jsc/bindings/BunProcess.cpp index f0a7bf42c0f9..ae9a891998cb 100644 --- a/src/jsc/bindings/BunProcess.cpp +++ b/src/jsc/bindings/BunProcess.cpp @@ -3030,17 +3030,15 @@ static JSValue constructStdioWriteStream(JSC::JSGlobalObject* globalObject, JSC: // Note: files are always sync anyway. // forceSync = fdType == BunProcessStdinFdType::file || bun_stdio_tty[fd]; - // Turns off FileSink's coalescing of small writes. What a full pipe or socket refuses is still buffered; console.* flushes that first (m_stdioSinks). + // Turns off FileSink's coalescing of small writes. What a full pipe or socket refuses is still buffered, and console.* output goes behind it (m_stdioSinks). forceSync = true; #endif if (forceSync) { JSValue sink = resultObject->getIndex(globalObject, 1); RETURN_IF_EXCEPTION(scope, jsUndefined()); Bun__ForceFileSinkToBeSynchronousForProcessObjectStdio(globalObject, JSValue::encode(sink)); -#if !OS(WINDOWS) if (auto* sinkObject = sink.getObject()) uncheckedDowncast(processObject)->m_stdioSinks[fd - 1].set(vm, processObject, sinkObject); -#endif } JSValue stream = resultObject->getIndex(globalObject, 0); @@ -3048,7 +3046,6 @@ static JSValue constructStdioWriteStream(JSC::JSGlobalObject* globalObject, JSC: return stream; } -#if !OS(WINDOWS) extern "C" JSC::EncodedJSValue Bun__Process__getStdioSink(JSC::JSGlobalObject* lexicalGlobalObject, int fd) { ASSERT(fd == 1 || fd == 2); @@ -3056,7 +3053,6 @@ extern "C" JSC::EncodedJSValue Bun__Process__getStdioSink(JSC::JSGlobalObject* l JSObject* sink = globalObject->hasProcessObject() ? globalObject->processObject()->m_stdioSinks[fd - 1].get() : nullptr; return JSValue::encode(sink ? JSValue(sink) : jsUndefined()); } -#endif static JSValue constructStdout(VM& vm, JSObject* processObject) { @@ -3795,10 +3791,8 @@ void Process::visitChildrenImpl(JSCell* cell, Visitor& visitor) visitor.append(thisObject->m_argv); visitor.append(thisObject->m_execArgv); visitor.append(thisObject->m_onWarning); -#if !OS(WINDOWS) for (auto& sink : thisObject->m_stdioSinks) visitor.append(sink); -#endif thisObject->m_cpuUsageStructure.visit(visitor); thisObject->m_resourceUsageStructure.visit(visitor); diff --git a/src/jsc/bindings/BunProcess.h b/src/jsc/bindings/BunProcess.h index 50f3566d9fd4..74a04cbed05c 100644 --- a/src/jsc/bindings/BunProcess.h +++ b/src/jsc/bindings/BunProcess.h @@ -47,10 +47,8 @@ class Process : public WebCore::JSEventEmitter { DECLARE_EXPORT_INFO; bool m_reportOnUncaughtException = false; -#if !OS(WINDOWS) - // The FileSinks behind process.stdout ([0]) and process.stderr ([1]): console.* flushes what they buffered before it writes. + // The FileSinks behind process.stdout ([0]) and process.stderr ([1]): console.* output goes behind what they have buffered. WriteBarrier m_stdioSinks[2]; -#endif static void destroy(JSC::JSCell* cell) { diff --git a/src/runtime/webcore/FileSink.rs b/src/runtime/webcore/FileSink.rs index 37af988deb2a..524eceb8c644 100644 --- a/src/runtime/webcore/FileSink.rs +++ b/src/runtime/webcore/FileSink.rs @@ -210,42 +210,32 @@ impl FileSink { } } -#[cfg(unix)] unsafe extern "C" { /// The `FileSink` wrapper behind `process.stdout` (1) / `process.stderr` (2), or `undefined` before that stream exists. safe fn Bun__Process__getStdioSink(global: &JSGlobalObject, fd: i32) -> JSValue; } -/// `console.*` is about to write to `fd` (1 or 2): what `process.stdout` / `process.stderr` buffered for it goes out first. -#[cfg(unix)] +/// The writer of that sink, while it holds bytes a full pipe or socket refused. +fn backed_up_stdio_writer(global: &JSGlobalObject, fd: i32) -> Option<&JsCell> { + let this_ptr = JSSink::from_js(Bun__Process__getStdioSink(global, fd))?; + // SAFETY: `from_js` returned the wrapper's live `m_ctx`; `global`'s `Process` keeps the wrapper alive. + let writer = unsafe { &(*this_ptr).sink.writer }; + // A writer whose poll failed to register is closed with bytes left, and nothing writes them. + #[cfg(unix)] + writer.get().get_poll()?; + writer.get().has_pending_data().then_some(writer) +} + #[unsafe(no_mangle)] -fn __bun_flush_stdio_sink(global: &JSGlobalObject, fd: Fd) { - use bun_io::pipe_writer::PosixPipeWriter as _; +fn __bun_stdio_sink_is_backed_up(global: &JSGlobalObject, fd: i32) -> bool { + backed_up_stdio_writer(global, fd).is_some() +} - let Some(this_ptr) = JSSink::from_js(Bun__Process__getStdioSink(global, fd.native())) else { - return; - }; - // `on_write` settles the buffered write; its callbacks run after the JS that called the console, not inside it. - let _entered = global.bun_vm().enter_event_loop_scope_without_checkpoint(); - // SAFETY: `from_js` returned the wrapper's live `m_ctx`, the sink's own allocation; `_guard` keeps it live. - unsafe { - let this = &raw mut (*this_ptr).sink; - let _guard = RefPtr::init_ref(this); - while (*this).writer.get().has_pending_data() - && (*this).writer.get().get_poll().is_some() - && sys::wait_for_writable((*this).writer.get().get_fd()).is_ok() - { - let (written, status) = - match (*this).writer.get().drain_buffered_data(usize::MAX, false) { - WriteResult::Pending(written) => (written, WriteStatus::Pending), - WriteResult::Wrote(written) => (written, WriteStatus::Drained), - WriteResult::Done(written) => (written, WriteStatus::EndOfFile), - // Nothing was written. The event loop's poll is still armed: it finds the same error and reports it between two turns of JS, not inside one. - WriteResult::Err(_) => break, - }; - (*this).writer.with_mut(|w| w.on_write(written, status)); - } - } +/// The poll that is armed for the bytes already there writes `message` after them. +#[unsafe(no_mangle)] +fn __bun_stdio_sink_append(global: &JSGlobalObject, fd: i32, message: &[u8]) -> bool { + backed_up_stdio_writer(global, fd) + .is_some_and(|writer| writer.with_mut(|w| w.outgoing.write(message)).is_ok()) } #[unsafe(no_mangle)] diff --git a/src/sys/lib.rs b/src/sys/lib.rs index e6186e55caba..2bb95967754c 100644 --- a/src/sys/lib.rs +++ b/src/sys/lib.rs @@ -9234,7 +9234,12 @@ fn fd_write_all_quiet(fd: Fd, mut bytes: &[u8]) -> bool { #[cfg(unix)] Err(e) if e.get_errno() == E::EAGAIN => { // fd 1/2 are O_NONBLOCK once process.stdout/stderr exist (as in Node); wait instead of dropping output. - if wait_for_writable(fd).is_err() { + let mut pfd = [posix::PollFd { + fd: fd.native(), + events: posix::POLL_OUT, + revents: 0, + }]; + if posix::poll(&mut pfd, -1).is_err() { return false; } } @@ -9244,17 +9249,6 @@ fn fd_write_all_quiet(fd: Fd, mut bytes: &[u8]) -> bool { true } -/// Blocks until `fd` takes a write, or has an error for the next write to report. -#[cfg(unix)] -pub fn wait_for_writable(fd: Fd) -> Maybe<()> { - let mut pfd = [posix::PollFd { - fd: fd.native(), - events: posix::POLL_OUT, - revents: 0, - }]; - posix::poll(&mut pfd, -1).map(|_| ()) -} - /// Concrete repr behind the opaque `bun_core::output::QuietWriterAdapter` /// (`[u8; 64]`). First field MUST be `io::Writer` so `new_interface()`'s /// pointer-cast is sound. Layout asserted below. diff --git a/test/js/node/process/process-stdio.test.ts b/test/js/node/process/process-stdio.test.ts index d4e0f82ceb47..f7f41cc824a5 100644 --- a/test/js/node/process/process-stdio.test.ts +++ b/test/js/node/process/process-stdio.test.ts @@ -163,7 +163,7 @@ describe.concurrent("process-stdio", () => { // Like Node, process.stdout/stderr put O_NONBLOCK on fd 1/2 when they are pipes or sockets. The flag lives on the shared open // file description, so spawn clears it again for a child's fds 0-2 (libuv does the same), and the native console writer must -// wait on EAGAIN instead of dropping the rest of the line, and flush what the stream buffered before it writes. +// wait on EAGAIN instead of dropping the rest of the line, and its output goes behind what the stream has buffered. describe.concurrent.skipIf(isWindows)( "process.stdout/stderr on a pipe or socket: O_NONBLOCK does not leak to children, console output is whole and in call order", () => { @@ -381,10 +381,12 @@ describe.concurrent.skipIf(isWindows)( [`log("")`, ""], [`info("")`, ""], [`debug("")`, ""], + [`log()`, ""], ] as const; const stderrCalls = [ [`warn("")`, ""], [`error("")`, ""], + [`error()`, ""], ] as const; type Calls = typeof stdoutCalls | typeof stderrCalls; const callOrderScript = (stream: "stdout" | "stderr", calls: Calls) => ` @@ -429,8 +431,8 @@ describe.concurrent.skipIf(isWindows)( }, ); - // process.exit() drops what is still buffered, so a callback that comes early loses the rest of the second write. - test("a write() after the console flushed the stream calls back once its own bytes are out", async () => { + // process.exit() drops what is still buffered, so everything written before the callback has to be out by then. + test("the callback of a write() after console output runs once all of it is out", async () => { const script = ` require("fs").writeSync(2, JSON.stringify({ ret: process.stdout.write(Buffer.alloc(1 << 20, "0")) }) + "\\n"); console.log(""); @@ -449,14 +451,15 @@ describe.concurrent.skipIf(isWindows)( expect(exitCode).toBe(0); }); - // The result without the console too. Nothing has entered the event loop around a 'beforeExit' listener, so settling the write could drain microtasks. - test("the callback of a write the console flushed runs after the JS that called the console, not inside it", async () => { + // The result on main too. write() returns true once nothing is buffered, so by the end of formatting there is nothing to go behind. + test("a message whose formatting outlasts the backlog is still printed", async () => { const script = ` - const report = v => require("fs").writeSync(2, JSON.stringify(v) + "\\n"); - process.once("beforeExit", () => { - report({ ret: process.stdout.write(Buffer.alloc(1 << 20, "0"), () => report("callback")) }); - console.log(""); - report("after console.log"); + require("fs").writeSync(2, JSON.stringify({ ret: process.stdout.write(Buffer.alloc(1 << 20, "0")) }) + "\\n"); + console.log({ + [Symbol.for("nodejs.util.inspect.custom")]() { + while (!process.stdout.write("1")); + return ""; + }, });`; await using proc = spawn({ cmd: ["sh", "-c", stdoutPipeline, bunExe(), script, gatedReader], @@ -466,33 +469,10 @@ describe.concurrent.skipIf(isWindows)( }); const events = jsonLines(proc.stderr); await signalReaderOnceFull(events); - expect([await events.next(), await events.next()]).toEqual(["after console.log", "callback"]); - await expectWriterExitedCleanly(events); - expect(await proc.exited).toBe(0); - }); - - // The result without the console too: the flush stops at a write error and leaves it for the event loop to report. - test("the reader going away while the console waits fails the buffered write and the next one with EPIPE", async () => { - const script = ` - const report = v => require("fs").writeSync(2, JSON.stringify(v) + "\\n"); - process.stdout.on("error", () => {}); - report({ ret: process.stdout.write(Buffer.alloc(1 << 20, "0"), e => report({ buffered: e?.code ?? null })) }); - console.log(""); - process.stdout.write("next", e => report({ next: e?.code ?? null }));`; - const leavingReader = `process.on("SIGUSR1", () => process.exit(0)); - setInterval(() => {}, 1 << 30); - require("fs").writeSync(2, JSON.stringify({ reader: process.pid }) + "\\n");`; - await using proc = spawn({ - cmd: ["sh", "-c", stdoutPipeline, bunExe(), script, leavingReader], - env: bunEnv, - stdout: "pipe", - stderr: "pipe", - }); - const events = jsonLines(proc.stderr); - await signalReaderOnceFull(events); - expect([await events.next(), await events.next()]).toEqual([{ buffered: "EPIPE" }, { next: "EPIPE" }]); + const [stdout, exitCode] = await Promise.all([proc.stdout.bytes(), proc.exited]); + expect(squash(stdout)).toMatch(new RegExp(String.raw`^\[0\*${1 << 20}\](1|\[1\*\d+\])\n$`)); await expectWriterExitedCleanly(events); - expect(await proc.exited).toBe(0); + expect(exitCode).toBe(0); }); test("console output on stdout comes after what the stream buffered on a full socket", async () => { diff --git a/test/js/web/console/console-log.test.ts b/test/js/web/console/console-log.test.ts index 1152b1cb6cbc..ff641dcf0664 100644 --- a/test/js/web/console/console-log.test.ts +++ b/test/js/web/console/console-log.test.ts @@ -157,6 +157,18 @@ NamedError: console.error a named error `); }); +it("console.error() and console.warn() with no arguments print their newline on stderr", async () => { + await using proc = spawn({ + cmd: [bunExe(), "-e", "console.error(); console.warn(); console.log();"], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, stderr }).toEqual({ stdout: "\n", stderr: "\n\n" }); + expect(exitCode).toBe(0); +}); + it("console.log with SharedArrayBuffer", () => { // console.log(x) === Bun.inspect(x) + "\n" written to stdout. expect(Bun.inspect(new ArrayBuffer(0))).toBe("ArrayBuffer(0) []");