From 1d6373df9a46cdb12b0bdc9dd3e1107cc4d223c9 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 26 Jul 2026 14:04:17 +0000 Subject: [PATCH 1/6] Bun.write: poll instead of spin when a caller-supplied fd returns EAGAIN When fd 1 is a pipe that has been flipped to O_NONBLOCK (process.stdout.write does this on the shared open file description via a dup), a Bun.write that overflows the pipe buffer reaches the async WriteFile path with could_block=false, and the EAGAIN handler there spun on a cached result without re-issuing write() or polling: every pool worker pegged a core with the returned promise never settling. - do_write: EAGAIN implies the fd is pollable; set could_block and wait_for_writable instead of looping. - run_with_fd: derive could_block from the store's mode (stdio stores set mode but not seekable), and dup a caller-supplied pollable fd so concurrent WriteFile instances each own a distinct fd number for the shared IO-thread epoll/kqueue. - write_{string,bytes}_to_file_fast: only hand off to the async path when nothing has been written yet; after a partial write, poll(POLLOUT) and continue so bytes already committed are not re-sent. --- src/runtime/webcore/Blob.rs | 31 ++++++- src/runtime/webcore/blob/write_file.rs | 109 +++++++++++++++---------- test/js/bun/io/bun-write.test.js | 40 +++++++++ 3 files changed, 131 insertions(+), 49 deletions(-) diff --git a/src/runtime/webcore/Blob.rs b/src/runtime/webcore/Blob.rs index ec3d76a55648..829032d5bc64 100644 --- a/src/runtime/webcore/Blob.rs +++ b/src/runtime/webcore/Blob.rs @@ -5445,8 +5445,22 @@ fn write_string_to_file_fast( bun_sys::Result::Err(err) => { truncate.set(false); if err.get_errno() == bun_sys::E::EAGAIN { - *needs_async = true; - return JSValue::ZERO; + if written.get() == 0 { + *needs_async = true; + return JSValue::ZERO; + } + // Part of the payload is already committed; handing the rest to the + // async path would re-send the whole input and duplicate bytes. This + // branch can only be reached on a pollable fd, so block here until + // it drains (equivalent to the blocking write() we would have issued + // had the fd not been flipped O_NONBLOCK behind our back). + let mut pfd = [bun_sys::posix::PollFd { + fd: fd.native(), + events: bun_sys::posix::POLL_OUT, + revents: 0, + }]; + let _ = bun_sys::posix::poll(&mut pfd, -1); + continue; } let err_js = if !NEEDS_OPEN { err.to_js(global_this) @@ -5520,8 +5534,17 @@ fn write_bytes_to_file_fast( bun_sys::Result::Err(err) => { #[cfg(not(windows))] if err.get_errno() == bun_sys::E::EAGAIN { - *_needs_async = true; - return JSValue::ZERO; + if written == 0 { + *_needs_async = true; + return JSValue::ZERO; + } + let mut pfd = [bun_sys::posix::PollFd { + fd: fd.native(), + events: bun_sys::posix::POLL_OUT, + revents: 0, + }]; + let _ = bun_sys::posix::poll(&mut pfd, -1); + continue; } let err_js = if !NEEDS_OPEN { err.to_js(global_this) diff --git a/src/runtime/webcore/blob/write_file.rs b/src/runtime/webcore/blob/write_file.rs index 1f6795af3729..3044473a29b8 100644 --- a/src/runtime/webcore/blob/write_file.rs +++ b/src/runtime/webcore/blob/write_file.rs @@ -338,35 +338,25 @@ impl WriteFile { // // On macOS, it is an error to use pwrite() on a // non-seekable file. - let result: bun_sys::Result = - sys::write(fd, &self.bytes_blob.shared_view()[off..off + len]); - - loop { - match &result { - bun_sys::Result::Ok(res) => { - *wrote = *res; - self.total_written += *res; - } - bun_sys::Result::Err(err) => { - if err.get_errno() == io::RETRY { - if !self.could_block { - // regular files cannot use epoll. - // this is fine on kqueue, but not on epoll. - continue; - } - self.wait_for_writable(); - return false; - } else { - self.errno = Some(bun_errno::from_errno(err.errno as i32).into()); - self.system_error = Some(err.to_system_error().into()); - return false; - } + match sys::write(fd, &self.bytes_blob.shared_view()[off..off + len]) { + bun_sys::Result::Ok(res) => { + *wrote = res; + self.total_written += res; + true + } + bun_sys::Result::Err(err) => { + if err.get_errno() == io::RETRY { + // write(2) on a regular file never returns EAGAIN, so reaching here means + // the fd is pollable regardless of what `could_block` was initialised to. + self.could_block = true; + self.wait_for_writable(); + } else { + self.errno = Some(bun_errno::from_errno(err.errno as i32).into()); + self.system_error = Some(err.to_system_error().into()); } + false } - break; } - - true } pub fn then(mut this: Box, _global: &JSGlobalObject) -> Result<(), JsTerminated> { @@ -414,7 +404,8 @@ impl WriteFile { } pub fn is_allowed_to_close(&self) -> bool { - self.file_blob + match self + .file_blob .store .get() .as_ref() @@ -422,7 +413,14 @@ impl WriteFile { .data .as_file() .pathlike - .is_path() + { + PathOrFileDescriptor::Path(_) => true, + // A caller-supplied fd stays owned by the caller unless `run_with_fd` duped it + // for polling, in which case `opened_fd` is the private dup and must be closed. + PathOrFileDescriptor::Fd(fd) => { + self.opened_fd != Fd::INVALID && self.opened_fd != fd + } + } } #[cfg(not(windows))] @@ -450,26 +448,47 @@ impl WriteFile { let fd = self.opened_fd; - self.could_block = 'brk: { - if let Some(store) = self.file_blob.store.get().as_ref() { - if let blob::store::Data::File(file) = &store.data { - if file.pathlike.is_fd() { - // If seekable was set, then so was mode - if file.seekable.is_some() { - // This is mostly to handle pipes which were passsed to the process somehow - // such as stderr, stdout. Bun.stdin and Bun.stderr will automatically set `mode` for us. - break 'brk !bun_sys::is_regular_file(file.mode); - } + let caller_supplied_fd = match self.file_blob.store.get().as_ref() { + Some(store) => match &store.data { + blob::store::Data::File(file) => match file.pathlike { + PathOrFileDescriptor::Fd(_) => { + // Bun.stdout/Bun.stderr fstat() up front and set `mode` (but not + // `seekable`), so gate on `mode` being populated. + self.could_block = + file.mode != 0 && !bun_sys::is_regular_file(file.mode); + true } + PathOrFileDescriptor::Path(_) => { + // We opened the file descriptor with O_NONBLOCK, so we + // shouldn't have to worry about blocking reads/writes + // + // We do not call fstat() because that is very expensive. + self.could_block = false; + false + } + }, + _ => false, + }, + None => false, + }; + + // A caller-supplied pollable fd (e.g. Bun.stdout backed by a pipe) may be the target + // of several concurrent Bun.write() calls. Each WriteFile carries its own `io::Poll`, + // and the IO thread's epoll/kqueue keys interest by fd number, so two instances + // registering the same fd collide (EEXIST on Linux; last-wins udata on kqueue). Dup it + // so this instance owns a private fd number for the same open file description; the dup + // is closed in `on_finish` via `is_allowed_to_close`. + if self.could_block && caller_supplied_fd { + match bun_sys::dup(fd) { + bun_sys::Result::Ok(duped) => self.opened_fd = duped, + bun_sys::Result::Err(err) => { + self.errno = Some(bun_errno::from_errno(err.errno as i32).into()); + self.system_error = Some(err.to_system_error().into()); + self.on_finish(); + return; } } - - // We opened the file descriptor with O_NONBLOCK, so we - // shouldn't have to worry about blocking reads/writes - // - // We do not call fstat() because that is very expensive. - false - }; + } // We have never supported offset in Bun.write(). // and properly adding support means we need to also support it diff --git a/test/js/bun/io/bun-write.test.js b/test/js/bun/io/bun-write.test.js index a4c3fb2f551b..6d83a0eb858c 100644 --- a/test/js/bun/io/bun-write.test.js +++ b/test/js/bun/io/bun-write.test.js @@ -728,3 +728,43 @@ int posix_fadvise(int fd, off_t offset, off_t len, int advice) { expect(f.name).toBe(filePath); }); }); + +// The POSIX async WriteFile path used to compute `could_block = false` for Bun.stdout (the stdio +// store sets `mode` but not `seekable`), and its EAGAIN handler re-matched a cached write() +// result in a tight loop instead of re-issuing the syscall or polling. Once fd 1 was O_NONBLOCK +// (process.stdout.write's FileSink sets it on the shared open file description via a dup), every +// Bun.write that overflowed the pipe buffer wedged a thread-pool worker at 100% CPU with the +// returned promise never settling. +// +// Runs outside `describe.concurrent` because on an unfixed build the child pegs every core until +// the spawn timeout fires, which would starve the concurrent neighbours into their 5s default. +it.skipIf(isWindows)("Bun.write(Bun.stdout, ...) to a full nonblocking pipe completes", async () => { + // Two payload sizes: below 256 KiB exercises the sync fast path's EAGAIN -> needs_async + // fallback; at/above 256 KiB goes straight to the thread-pool WriteFile path. + const script = ` + process.stdout.write("x"); // constructs the fd 1 FileSink, which flips the pipe O_NONBLOCK + const small = Buffer.alloc(64 * 1024, 65).toString(); + const large = Buffer.alloc(256 * 1024, 66).toString(); + const ps = []; + for (let i = 0; i < 32; i++) ps.push(Bun.write(Bun.stdout, small)); + for (let i = 0; i < 8; i++) ps.push(Bun.write(Bun.stdout, large)); + const wrote = await Promise.all(ps); + process.stderr.write("wrote=" + wrote.reduce((a, b) => a + b, 0)); + `; + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", script], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + timeout: 10_000, + killSignal: "SIGKILL", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.bytes(), proc.stderr.text(), proc.exited]); + const expected = 1 + 32 * 64 * 1024 + 8 * 256 * 1024; + expect({ length: stdout.length, stderr, exitCode, signalCode: proc.signalCode }).toEqual({ + length: expected, + stderr: "wrote=" + (expected - 1), + exitCode: 0, + signalCode: null, + }); +}, 15_000); From 5df5c91ec64dfee3c0624f196a3f6e38ab26f6e1 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sun, 26 Jul 2026 14:06:26 +0000 Subject: [PATCH 2/6] [autofix.ci] apply automated fixes --- src/runtime/webcore/blob/write_file.rs | 7 ++-- test/js/bun/io/bun-write.test.js | 46 ++++++++++++++------------ 2 files changed, 27 insertions(+), 26 deletions(-) diff --git a/src/runtime/webcore/blob/write_file.rs b/src/runtime/webcore/blob/write_file.rs index 3044473a29b8..d603b2c346b0 100644 --- a/src/runtime/webcore/blob/write_file.rs +++ b/src/runtime/webcore/blob/write_file.rs @@ -417,9 +417,7 @@ impl WriteFile { PathOrFileDescriptor::Path(_) => true, // A caller-supplied fd stays owned by the caller unless `run_with_fd` duped it // for polling, in which case `opened_fd` is the private dup and must be closed. - PathOrFileDescriptor::Fd(fd) => { - self.opened_fd != Fd::INVALID && self.opened_fd != fd - } + PathOrFileDescriptor::Fd(fd) => self.opened_fd != Fd::INVALID && self.opened_fd != fd, } } @@ -454,8 +452,7 @@ impl WriteFile { PathOrFileDescriptor::Fd(_) => { // Bun.stdout/Bun.stderr fstat() up front and set `mode` (but not // `seekable`), so gate on `mode` being populated. - self.could_block = - file.mode != 0 && !bun_sys::is_regular_file(file.mode); + self.could_block = file.mode != 0 && !bun_sys::is_regular_file(file.mode); true } PathOrFileDescriptor::Path(_) => { diff --git a/test/js/bun/io/bun-write.test.js b/test/js/bun/io/bun-write.test.js index 6d83a0eb858c..d8ace18fc608 100644 --- a/test/js/bun/io/bun-write.test.js +++ b/test/js/bun/io/bun-write.test.js @@ -738,10 +738,12 @@ int posix_fadvise(int fd, off_t offset, off_t len, int advice) { // // Runs outside `describe.concurrent` because on an unfixed build the child pegs every core until // the spawn timeout fires, which would starve the concurrent neighbours into their 5s default. -it.skipIf(isWindows)("Bun.write(Bun.stdout, ...) to a full nonblocking pipe completes", async () => { - // Two payload sizes: below 256 KiB exercises the sync fast path's EAGAIN -> needs_async - // fallback; at/above 256 KiB goes straight to the thread-pool WriteFile path. - const script = ` +it.skipIf(isWindows)( + "Bun.write(Bun.stdout, ...) to a full nonblocking pipe completes", + async () => { + // Two payload sizes: below 256 KiB exercises the sync fast path's EAGAIN -> needs_async + // fallback; at/above 256 KiB goes straight to the thread-pool WriteFile path. + const script = ` process.stdout.write("x"); // constructs the fd 1 FileSink, which flips the pipe O_NONBLOCK const small = Buffer.alloc(64 * 1024, 65).toString(); const large = Buffer.alloc(256 * 1024, 66).toString(); @@ -751,20 +753,22 @@ it.skipIf(isWindows)("Bun.write(Bun.stdout, ...) to a full nonblocking pipe comp const wrote = await Promise.all(ps); process.stderr.write("wrote=" + wrote.reduce((a, b) => a + b, 0)); `; - await using proc = Bun.spawn({ - cmd: [bunExe(), "-e", script], - env: bunEnv, - stdout: "pipe", - stderr: "pipe", - timeout: 10_000, - killSignal: "SIGKILL", - }); - const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.bytes(), proc.stderr.text(), proc.exited]); - const expected = 1 + 32 * 64 * 1024 + 8 * 256 * 1024; - expect({ length: stdout.length, stderr, exitCode, signalCode: proc.signalCode }).toEqual({ - length: expected, - stderr: "wrote=" + (expected - 1), - exitCode: 0, - signalCode: null, - }); -}, 15_000); + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", script], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + timeout: 10_000, + killSignal: "SIGKILL", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.bytes(), proc.stderr.text(), proc.exited]); + const expected = 1 + 32 * 64 * 1024 + 8 * 256 * 1024; + expect({ length: stdout.length, stderr, exitCode, signalCode: proc.signalCode }).toEqual({ + length: expected, + stderr: "wrote=" + (expected - 1), + exitCode: 0, + signalCode: null, + }); + }, + 15_000, +); From 8935f9e0c88154e6d3710f09648714f79e484b04 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 26 Jul 2026 14:10:51 +0000 Subject: [PATCH 3/6] trim comments; drop test-level timeout --- src/runtime/webcore/Blob.rs | 7 +-- src/runtime/webcore/blob/write_file.rs | 21 ++------- test/js/bun/io/bun-write.test.js | 65 ++++++++++++-------------- 3 files changed, 36 insertions(+), 57 deletions(-) diff --git a/src/runtime/webcore/Blob.rs b/src/runtime/webcore/Blob.rs index 829032d5bc64..29744cdc05a0 100644 --- a/src/runtime/webcore/Blob.rs +++ b/src/runtime/webcore/Blob.rs @@ -5449,11 +5449,8 @@ fn write_string_to_file_fast( *needs_async = true; return JSValue::ZERO; } - // Part of the payload is already committed; handing the rest to the - // async path would re-send the whole input and duplicate bytes. This - // branch can only be reached on a pollable fd, so block here until - // it drains (equivalent to the blocking write() we would have issued - // had the fd not been flipped O_NONBLOCK behind our back). + // The async path re-sends the whole input, so after a partial + // write finish synchronously via poll(POLLOUT). let mut pfd = [bun_sys::posix::PollFd { fd: fd.native(), events: bun_sys::posix::POLL_OUT, diff --git a/src/runtime/webcore/blob/write_file.rs b/src/runtime/webcore/blob/write_file.rs index d603b2c346b0..f56b647a9d00 100644 --- a/src/runtime/webcore/blob/write_file.rs +++ b/src/runtime/webcore/blob/write_file.rs @@ -346,8 +346,7 @@ impl WriteFile { } bun_sys::Result::Err(err) => { if err.get_errno() == io::RETRY { - // write(2) on a regular file never returns EAGAIN, so reaching here means - // the fd is pollable regardless of what `could_block` was initialised to. + // EAGAIN is impossible on a regular file, so the fd is pollable. self.could_block = true; self.wait_for_writable(); } else { @@ -415,8 +414,7 @@ impl WriteFile { .pathlike { PathOrFileDescriptor::Path(_) => true, - // A caller-supplied fd stays owned by the caller unless `run_with_fd` duped it - // for polling, in which case `opened_fd` is the private dup and must be closed. + // opened_fd differs from the caller's fd only when run_with_fd duped it. PathOrFileDescriptor::Fd(fd) => self.opened_fd != Fd::INVALID && self.opened_fd != fd, } } @@ -450,16 +448,11 @@ impl WriteFile { Some(store) => match &store.data { blob::store::Data::File(file) => match file.pathlike { PathOrFileDescriptor::Fd(_) => { - // Bun.stdout/Bun.stderr fstat() up front and set `mode` (but not - // `seekable`), so gate on `mode` being populated. self.could_block = file.mode != 0 && !bun_sys::is_regular_file(file.mode); true } PathOrFileDescriptor::Path(_) => { - // We opened the file descriptor with O_NONBLOCK, so we - // shouldn't have to worry about blocking reads/writes - // - // We do not call fstat() because that is very expensive. + // Opened with O_NONBLOCK; don't fstat. self.could_block = false; false } @@ -469,12 +462,8 @@ impl WriteFile { None => false, }; - // A caller-supplied pollable fd (e.g. Bun.stdout backed by a pipe) may be the target - // of several concurrent Bun.write() calls. Each WriteFile carries its own `io::Poll`, - // and the IO thread's epoll/kqueue keys interest by fd number, so two instances - // registering the same fd collide (EEXIST on Linux; last-wins udata on kqueue). Dup it - // so this instance owns a private fd number for the same open file description; the dup - // is closed in `on_finish` via `is_allowed_to_close`. + // The IO thread's epoll/kqueue keys interest by fd number, so concurrent WriteFiles on + // one caller-supplied fd would collide; dup so this instance polls a private fd number. if self.could_block && caller_supplied_fd { match bun_sys::dup(fd) { bun_sys::Result::Ok(duped) => self.opened_fd = duped, diff --git a/test/js/bun/io/bun-write.test.js b/test/js/bun/io/bun-write.test.js index d8ace18fc608..3684f285e154 100644 --- a/test/js/bun/io/bun-write.test.js +++ b/test/js/bun/io/bun-write.test.js @@ -729,46 +729,39 @@ int posix_fadvise(int fd, off_t offset, off_t len, int advice) { }); }); -// The POSIX async WriteFile path used to compute `could_block = false` for Bun.stdout (the stdio -// store sets `mode` but not `seekable`), and its EAGAIN handler re-matched a cached write() -// result in a tight loop instead of re-issuing the syscall or polling. Once fd 1 was O_NONBLOCK -// (process.stdout.write's FileSink sets it on the shared open file description via a dup), every -// Bun.write that overflowed the pipe buffer wedged a thread-pool worker at 100% CPU with the -// returned promise never settling. -// -// Runs outside `describe.concurrent` because on an unfixed build the child pegs every core until -// the spawn timeout fires, which would starve the concurrent neighbours into their 5s default. -it.skipIf(isWindows)( - "Bun.write(Bun.stdout, ...) to a full nonblocking pipe completes", - async () => { - // Two payload sizes: below 256 KiB exercises the sync fast path's EAGAIN -> needs_async - // fallback; at/above 256 KiB goes straight to the thread-pool WriteFile path. - const script = ` +// Once fd 1 is O_NONBLOCK (process.stdout.write's FileSink sets it on the shared open file +// description via a dup), a Bun.write that overflowed the pipe buffer used to wedge a +// thread-pool worker at 100% CPU: the async WriteFile path's could_block stayed false for +// stdio and its EAGAIN handler re-matched a cached write() result without re-issuing the +// syscall. Runs outside describe.concurrent so an unfixed build's spin doesn't starve +// neighbours into their default timeout. +it.skipIf(isWindows)("Bun.write(Bun.stdout, ...) to a full nonblocking pipe completes", async () => { + // Below 256 KiB exercises the sync fast path's EAGAIN -> needs_async fallback; at/above + // 256 KiB goes straight to the thread-pool WriteFile path. + const script = ` process.stdout.write("x"); // constructs the fd 1 FileSink, which flips the pipe O_NONBLOCK const small = Buffer.alloc(64 * 1024, 65).toString(); const large = Buffer.alloc(256 * 1024, 66).toString(); const ps = []; - for (let i = 0; i < 32; i++) ps.push(Bun.write(Bun.stdout, small)); - for (let i = 0; i < 8; i++) ps.push(Bun.write(Bun.stdout, large)); + for (let i = 0; i < 16; i++) ps.push(Bun.write(Bun.stdout, small)); + for (let i = 0; i < 4; i++) ps.push(Bun.write(Bun.stdout, large)); const wrote = await Promise.all(ps); process.stderr.write("wrote=" + wrote.reduce((a, b) => a + b, 0)); `; - await using proc = Bun.spawn({ - cmd: [bunExe(), "-e", script], - env: bunEnv, - stdout: "pipe", - stderr: "pipe", - timeout: 10_000, - killSignal: "SIGKILL", - }); - const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.bytes(), proc.stderr.text(), proc.exited]); - const expected = 1 + 32 * 64 * 1024 + 8 * 256 * 1024; - expect({ length: stdout.length, stderr, exitCode, signalCode: proc.signalCode }).toEqual({ - length: expected, - stderr: "wrote=" + (expected - 1), - exitCode: 0, - signalCode: null, - }); - }, - 15_000, -); + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", script], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + timeout: 4_000, + killSignal: "SIGKILL", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.bytes(), proc.stderr.text(), proc.exited]); + const expected = 1 + 16 * 64 * 1024 + 4 * 256 * 1024; + expect({ length: stdout.length, stderr, exitCode, signalCode: proc.signalCode }).toEqual({ + length: expected, + stderr: "wrote=" + (expected - 1), + exitCode: 0, + signalCode: null, + }); +}); From 0f4ca9d87568ae2aa18c3d0d55ce3c2c6a196e63 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 26 Jul 2026 14:29:16 +0000 Subject: [PATCH 4/6] dup caller-supplied fds unconditionally; drop stale-result loop in ReadFile::do_read; assert fdDelta in test --- src/runtime/webcore/blob/read_file.rs | 71 +++++++++++--------------- src/runtime/webcore/blob/write_file.rs | 2 +- test/js/bun/io/bun-write.test.js | 21 +++++--- 3 files changed, 44 insertions(+), 50 deletions(-) diff --git a/src/runtime/webcore/blob/read_file.rs b/src/runtime/webcore/blob/read_file.rs index c7469512233d..5b663901938e 100644 --- a/src/runtime/webcore/blob/read_file.rs +++ b/src/runtime/webcore/blob/read_file.rs @@ -508,54 +508,41 @@ impl ReadFile { /// Never touches `self.buffer`; the caller moves it out for the duration. pub fn do_read(&mut self, buf: &mut [u8], read_len: &mut usize, retry: &mut bool) -> bool { - let result: bun_sys::Result = 'brk: { - if bun_sys::S::ISSOCK(self.file_store.mode) { - break 'brk bun_sys::recv_non_block(self.opened_fd, buf); - } - break 'brk bun_sys::read(self.opened_fd, buf); + let result: bun_sys::Result = if bun_sys::S::ISSOCK(self.file_store.mode) { + bun_sys::recv_non_block(self.opened_fd, buf) + } else { + bun_sys::read(self.opened_fd, buf) }; - loop { - match &result { - Ok(res) => { - *read_len = *res as usize; // @truncate — usize→usize is identity here - self.read_eof = *res == 0; + match result { + Ok(res) => { + *read_len = res; + self.read_eof = res == 0; + true + } + Err(err) => match err.get_errno() { + e if e == io::RETRY => { + self.could_block = true; + *retry = true; + self.read_eof = false; + true } - Err(err) => { - match err.get_errno() { - e if e == io::RETRY => { - if !self.could_block { - // regular files cannot use epoll. - // this is fine on kqueue, but not on epoll. - continue; - } - *retry = true; - self.read_eof = false; - return true; - } - _ => { - self.errno = Some(bun_errno::from_errno(err.errno as i32).into()); - self.system_error = Some(err.to_system_error().into()); - if self.system_error.as_ref().unwrap().path.is_empty() { - self.system_error.as_mut().unwrap().path = - if self.file_store.pathlike.is_path() { - BunString::clone_utf8( - self.file_store.pathlike.path().slice(), - ) - .into() - } else { - BunString::EMPTY.into() - }; - } - return false; - } + _ => { + self.errno = Some(bun_errno::from_errno(err.errno as i32).into()); + self.system_error = Some(err.to_system_error().into()); + if self.system_error.as_ref().unwrap().path.is_empty() { + self.system_error.as_mut().unwrap().path = + if self.file_store.pathlike.is_path() { + BunString::clone_utf8(self.file_store.pathlike.path().slice()) + .into() + } else { + BunString::EMPTY.into() + }; } + false } - } - break; + }, } - - true } pub fn then(this: Box, _: &JSGlobalObject) -> jsc::JsTerminatedResult<()> { diff --git a/src/runtime/webcore/blob/write_file.rs b/src/runtime/webcore/blob/write_file.rs index f56b647a9d00..f16c72b45fb1 100644 --- a/src/runtime/webcore/blob/write_file.rs +++ b/src/runtime/webcore/blob/write_file.rs @@ -464,7 +464,7 @@ impl WriteFile { // The IO thread's epoll/kqueue keys interest by fd number, so concurrent WriteFiles on // one caller-supplied fd would collide; dup so this instance polls a private fd number. - if self.could_block && caller_supplied_fd { + if caller_supplied_fd { match bun_sys::dup(fd) { bun_sys::Result::Ok(duped) => self.opened_fd = duped, bun_sys::Result::Err(err) => { diff --git a/test/js/bun/io/bun-write.test.js b/test/js/bun/io/bun-write.test.js index 3684f285e154..ac7c5a4ec818 100644 --- a/test/js/bun/io/bun-write.test.js +++ b/test/js/bun/io/bun-write.test.js @@ -740,13 +740,20 @@ it.skipIf(isWindows)("Bun.write(Bun.stdout, ...) to a full nonblocking pipe comp // 256 KiB goes straight to the thread-pool WriteFile path. const script = ` process.stdout.write("x"); // constructs the fd 1 FileSink, which flips the pipe O_NONBLOCK + const fdDir = process.platform === "darwin" ? "/dev/fd" : "/proc/self/fd"; + const fdCount = () => require("fs").readdirSync(fdDir).length; const small = Buffer.alloc(64 * 1024, 65).toString(); const large = Buffer.alloc(256 * 1024, 66).toString(); - const ps = []; - for (let i = 0; i < 16; i++) ps.push(Bun.write(Bun.stdout, small)); - for (let i = 0; i < 4; i++) ps.push(Bun.write(Bun.stdout, large)); - const wrote = await Promise.all(ps); - process.stderr.write("wrote=" + wrote.reduce((a, b) => a + b, 0)); + let wrote = 0, fdsBefore, fdsAfter; + for (let round = 0; round < 2; round++) { + fdsBefore = fdCount(); + const ps = []; + for (let i = 0; i < 16; i++) ps.push(Bun.write(Bun.stdout, small)); + for (let i = 0; i < 4; i++) ps.push(Bun.write(Bun.stdout, large)); + for (const n of await Promise.all(ps)) wrote += n; + fdsAfter = fdCount(); + } + process.stderr.write("wrote=" + wrote + " fdDelta=" + (fdsAfter - fdsBefore)); `; await using proc = Bun.spawn({ cmd: [bunExe(), "-e", script], @@ -757,10 +764,10 @@ it.skipIf(isWindows)("Bun.write(Bun.stdout, ...) to a full nonblocking pipe comp killSignal: "SIGKILL", }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.bytes(), proc.stderr.text(), proc.exited]); - const expected = 1 + 16 * 64 * 1024 + 4 * 256 * 1024; + const expected = 1 + 2 * (16 * 64 * 1024 + 4 * 256 * 1024); expect({ length: stdout.length, stderr, exitCode, signalCode: proc.signalCode }).toEqual({ length: expected, - stderr: "wrote=" + (expected - 1), + stderr: "wrote=" + (expected - 1) + " fdDelta=0", exitCode: 0, signalCode: null, }); From 2305fc48749f0d9afc3303f2842a4ae1204cb6e0 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 26 Jul 2026 14:58:35 +0000 Subject: [PATCH 5/6] widen hang-guard spawn timeout to match repo convention --- test/js/bun/io/bun-write.test.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/js/bun/io/bun-write.test.js b/test/js/bun/io/bun-write.test.js index ac7c5a4ec818..fce631dd1669 100644 --- a/test/js/bun/io/bun-write.test.js +++ b/test/js/bun/io/bun-write.test.js @@ -760,7 +760,7 @@ it.skipIf(isWindows)("Bun.write(Bun.stdout, ...) to a full nonblocking pipe comp env: bunEnv, stdout: "pipe", stderr: "pipe", - timeout: 4_000, + timeout: 20_000, killSignal: "SIGKILL", }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.bytes(), proc.stderr.text(), proc.exited]); @@ -771,4 +771,4 @@ it.skipIf(isWindows)("Bun.write(Bun.stdout, ...) to a full nonblocking pipe comp exitCode: 0, signalCode: null, }); -}); +}, 30_000); From 82c7c826d051ae67a4e6acd96dc1536137ca128c Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sun, 26 Jul 2026 15:00:37 +0000 Subject: [PATCH 6/6] [autofix.ci] apply automated fixes --- test/js/bun/io/bun-write.test.js | 46 +++++++++++++++++--------------- 1 file changed, 25 insertions(+), 21 deletions(-) diff --git a/test/js/bun/io/bun-write.test.js b/test/js/bun/io/bun-write.test.js index fce631dd1669..3dd4d8a6932d 100644 --- a/test/js/bun/io/bun-write.test.js +++ b/test/js/bun/io/bun-write.test.js @@ -735,10 +735,12 @@ int posix_fadvise(int fd, off_t offset, off_t len, int advice) { // stdio and its EAGAIN handler re-matched a cached write() result without re-issuing the // syscall. Runs outside describe.concurrent so an unfixed build's spin doesn't starve // neighbours into their default timeout. -it.skipIf(isWindows)("Bun.write(Bun.stdout, ...) to a full nonblocking pipe completes", async () => { - // Below 256 KiB exercises the sync fast path's EAGAIN -> needs_async fallback; at/above - // 256 KiB goes straight to the thread-pool WriteFile path. - const script = ` +it.skipIf(isWindows)( + "Bun.write(Bun.stdout, ...) to a full nonblocking pipe completes", + async () => { + // Below 256 KiB exercises the sync fast path's EAGAIN -> needs_async fallback; at/above + // 256 KiB goes straight to the thread-pool WriteFile path. + const script = ` process.stdout.write("x"); // constructs the fd 1 FileSink, which flips the pipe O_NONBLOCK const fdDir = process.platform === "darwin" ? "/dev/fd" : "/proc/self/fd"; const fdCount = () => require("fs").readdirSync(fdDir).length; @@ -755,20 +757,22 @@ it.skipIf(isWindows)("Bun.write(Bun.stdout, ...) to a full nonblocking pipe comp } process.stderr.write("wrote=" + wrote + " fdDelta=" + (fdsAfter - fdsBefore)); `; - await using proc = Bun.spawn({ - cmd: [bunExe(), "-e", script], - env: bunEnv, - stdout: "pipe", - stderr: "pipe", - timeout: 20_000, - killSignal: "SIGKILL", - }); - const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.bytes(), proc.stderr.text(), proc.exited]); - const expected = 1 + 2 * (16 * 64 * 1024 + 4 * 256 * 1024); - expect({ length: stdout.length, stderr, exitCode, signalCode: proc.signalCode }).toEqual({ - length: expected, - stderr: "wrote=" + (expected - 1) + " fdDelta=0", - exitCode: 0, - signalCode: null, - }); -}, 30_000); + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", script], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + timeout: 20_000, + killSignal: "SIGKILL", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.bytes(), proc.stderr.text(), proc.exited]); + const expected = 1 + 2 * (16 * 64 * 1024 + 4 * 256 * 1024); + expect({ length: stdout.length, stderr, exitCode, signalCode: proc.signalCode }).toEqual({ + length: expected, + stderr: "wrote=" + (expected - 1) + " fdDelta=0", + exitCode: 0, + signalCode: null, + }); + }, + 30_000, +);