Skip to content
Open
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
90e0621
console, process.stdout/stderr: one stdio sink per fd, console writes…
dylan-conway Aug 7, 2026
cde99d8
Merge branch 'main' into claude/node-console-stdout-compat-7d6001
dylan-conway Aug 7, 2026
584ddb3
clippy: ref_as_ptr, unnecessary_map_or, undocumented unsafe blocks
dylan-conway Aug 7, 2026
40db416
review: stdio sink follow-ups
dylan-conway Aug 7, 2026
b704f36
ci: exception-scope discipline for console stream lookups, test fixes
dylan-conway Aug 7, 2026
342d214
aarch64 baseline: use the allowlisted acq_rel outline atomic for STDI…
dylan-conway Aug 7, 2026
e6f489e
FileSink::write_with: report this call's byte count, not the flushed …
dylan-conway Aug 7, 2026
8566d73
stdio sink: only trust RWF_NOWAIT on pipefs pipes; tolerate an fd clo…
dylan-conway Aug 7, 2026
70e70ac
writeToObservedStream: same removeListener guard as Console.write
dylan-conway Aug 7, 2026
7bd8448
review: latch autoflush errors on stdio sinks; downgrade sinks after …
dylan-conway Aug 7, 2026
b7a141f
console: drop SCRATCH_KEEP; the scratch buffer shrinks back to SPILL_…
dylan-conway Aug 7, 2026
cec5076
Merge remote-tracking branch 'origin/main' into claude/node-console-s…
dylan-conway Aug 7, 2026
0e78647
process.stdout/stderr.write keep accepting a bare ArrayBuffer / Share…
dylan-conway Aug 7, 2026
7eaf6ab
Address risk review: writer coalescing, async Bun.write, killable exi…
dylan-conway Aug 8, 2026
f35776a
FileSink: make the RWF_NOWAIT flag field Linux-only instead of allow(…
dylan-conway Aug 8, 2026
4b248ac
Merge remote-tracking branch 'origin/main' into claude/node-console-s…
dylan-conway Aug 8, 2026
1e52b84
Merge remote-tracking branch 'origin/main' into claude/node-console-s…
dylan-conway Aug 8, 2026
e63575b
exit drain: only un-hook signals that were being forwarded to JS list…
dylan-conway Aug 8, 2026
c167a8b
docs: put three misplaced doc comments back on the items they describe
dylan-conway Aug 8, 2026
b5a2451
review: idempotent RWF_NOWAIT branch in stdio_go_nonblocking; Stream.…
dylan-conway Aug 8, 2026
7d7d6dc
stdio_go_nonblocking: never re-set O_NONBLOCK once a spawn has handed…
dylan-conway Aug 8, 2026
67b116c
console emit: decide colours before taking the scratch buffer
dylan-conway Aug 8, 2026
46f1c79
Merge remote-tracking branch 'origin/main' into claude/node-console-s…
dylan-conway Aug 8, 2026
ab797ac
console: stop (and report once) after a failed spill; docs: Bun.write…
dylan-conway Aug 8, 2026
f07b71e
napi test fixture: allocate the finalizer objects in a callee frame a…
dylan-conway Aug 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions docs/guides/write-file/stdout.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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).
6 changes: 6 additions & 0 deletions src/bun_core/output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::*;

Expand Down
16 changes: 15 additions & 1 deletion src/codegen/generate-jssink.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
149 changes: 124 additions & 25 deletions src/io/PipeWriter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}
Expand Down Expand Up @@ -600,6 +602,12 @@ pub struct PosixStreamingWriter<Parent: PosixStreamingWriterParent> {
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<bool>,
}
Expand All @@ -613,6 +621,7 @@ impl<Parent: PosixStreamingWriterParent> Default for PosixStreamingWriter<Parent
is_done: false,
closed_without_reporting: false,
force_sync: false,
poll_flushes_buffer: true,
backed_up: core::cell::Cell::new(false),
}
}
Expand Down Expand Up @@ -697,7 +706,7 @@ impl<Parent: PosixStreamingWriterParent> PosixStreamingWriter<Parent> {
self.handle.get_poll()
}

pub(crate) fn get_fd(&self) -> Fd {
pub fn get_fd(&self) -> Fd {
self.handle.get_fd()
}

Expand Down Expand Up @@ -742,6 +751,16 @@ impl<Parent: PosixStreamingWriterParent> PosixStreamingWriter<Parent> {
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);

Expand Down Expand Up @@ -800,6 +819,15 @@ impl<Parent: PosixStreamingWriterParent> PosixStreamingWriter<Parent> {
}

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);
}
Expand All @@ -812,16 +840,25 @@ impl<Parent: PosixStreamingWriterParent> PosixStreamingWriter<Parent> {

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();
Expand All @@ -833,15 +870,17 @@ impl<Parent: PosixStreamingWriterParent> PosixStreamingWriter<Parent> {

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);
}
Expand Down Expand Up @@ -889,11 +928,22 @@ impl<Parent: PosixStreamingWriterParent> PosixStreamingWriter<Parent> {
}

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() {
Expand All @@ -903,7 +953,9 @@ impl<Parent: PosixStreamingWriterParent> PosixStreamingWriter<Parent> {
// 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
Expand Down Expand Up @@ -977,6 +1029,9 @@ impl<Parent: PosixStreamingWriterParent> PosixStreamingWriter<Parent> {
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) => {
Expand Down Expand Up @@ -1048,21 +1103,9 @@ impl<Parent: PosixStreamingWriterParent> PosixStreamingWriter<Parent> {
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);
Expand All @@ -1072,6 +1115,47 @@ impl<Parent: PosixStreamingWriterParent> PosixStreamingWriter<Parent> {

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<Parent: PosixStreamingWriterParent> Drop for PosixStreamingWriter<Parent> {
Expand Down Expand Up @@ -2457,6 +2541,21 @@ impl<Parent: WindowsStreamingWriterParent> WindowsStreamingWriter<Parent> {
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);
Expand Down
6 changes: 6 additions & 0 deletions src/io/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -1890,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))]
{
Expand Down
5 changes: 4 additions & 1 deletion src/io/openForWriting.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
Loading