diff --git a/src/bun_core/Global.rs b/src/bun_core/Global.rs index a04a6be1c9aa..00c061e66454 100644 --- a/src/bun_core/Global.rs +++ b/src/bun_core/Global.rs @@ -710,7 +710,7 @@ unsafe extern "C" { safe fn quick_exit(code: c_int) -> !; } -/// Flushes stdout and stderr (in exit/quick_exit callback) and exits with the given code. +/// Flushes stdout and stderr (in exit/quick_exit callback) and exits with the given code. A result that stdout lost turns 0 into 1. pub fn exit(code: u32) -> ! { IS_EXITING.store(true, Ordering::Relaxed); // MOVE_DOWN: bun_analytics::features → bun_core (move-in pass). @@ -722,6 +722,7 @@ pub fn exit(code: u32) -> ! { // Flush output before exiting to ensure all messages are visible Output::flush(); + let code = Output::exit_code_after_stdout_writes(code); #[cfg(target_os = "macos")] { diff --git a/src/bun_core/lib.rs b/src/bun_core/lib.rs index 2ce018548143..d949e61779c9 100644 --- a/src/bun_core/lib.rs +++ b/src/bun_core/lib.rs @@ -602,6 +602,7 @@ bun_dispatch::link_interface! { fn quiet_writer_fd(qw: &output::QuietWriter) -> Fd; fn tty_winsize(fd: Fd) -> Option; fn is_terminal(fd: Fd) -> bool; + fn reader_left(fd: Fd, errno: i32) -> bool; fn read(fd: Fd, buf: &mut [u8]) -> core::result::Result; } } diff --git a/src/bun_core/output.rs b/src/bun_core/output.rs index 8bcd66b2dfe9..d7a94ec6929e 100644 --- a/src/bun_core/output.rs +++ b/src/bun_core/output.rs @@ -1101,6 +1101,60 @@ pub fn flush_guard() -> FlushGuard { FlushGuard(()) } +/// errno of the first failed write to stdout, 0 if none. Process-wide, as fd 1 is. +static STDOUT_WRITE_ERRNO: AtomicI32 = AtomicI32::new(0); +static STDOUT_IS_THE_RESULT: AtomicBool = AtomicBool::new(false); + +/// Called by the sink when a write to stdout failed. +pub fn record_stdout_write_error(errno: i32) { + let _ = STDOUT_WRITE_ERRNO.compare_exchange(0, errno, Ordering::SeqCst, Ordering::SeqCst); +} + +/// For a form that exists to print: if a write to stdout fails, `Global::exit` reports it and exits 1. +pub fn stdout_is_the_result() { + // A write that failed before this call counts too: call it past every help and usage exit. + STDOUT_IS_THE_RESULT.store(true, Ordering::SeqCst); +} + +/// The exit code for a process that is about to exit with `code`, after its last flush. +#[inline] +pub(crate) fn exit_code_after_stdout_writes(code: u32) -> u32 { + let errno = STDOUT_WRITE_ERRNO.load(Ordering::SeqCst); + if errno == 0 { + return code; + } + report_stdout_write_error(errno, code) +} + +#[cold] +#[inline(never)] +fn report_stdout_write_error(errno: i32, code: u32) -> u32 { + if !STDOUT_IS_THE_RESULT.load(Ordering::SeqCst) + || output_sink().reader_left(Fd::stdout(), errno) + { + return code; + } + // Two threads can be here: the HTTP thread crashes on a CA file it cannot load while the main thread exits. + static REPORT: std::sync::Once = std::sync::Once::new(); + REPORT.call_once(|| { + // `flush` allows for a thread that has no `Source`. A print needs one. + Source::configure_thread_no_js(); + err_stdout_write_failed(errno); + flush(); + }); + code.max(1) +} + +/// Says on stderr that a write to stdout failed with `errno`, in the same words for every command. +pub fn err_stdout_write_failed(errno: i32) { + let name = crate::ErrnoNames::SYS.name(errno); + let why = name.and_then(crate::coreutils_error_map::get_by_name); + err_generic( + "Cannot write standard output: {}: {}", + (name.unwrap_or("UNKNOWN"), why.unwrap_or("unknown error")), + ); +} + // ────────────────────────────────────────────────────────────────────────── // ElapsedFormatter // ────────────────────────────────────────────────────────────────────────── diff --git a/src/install/PackageManager.rs b/src/install/PackageManager.rs index 48428c4bf93b..1ab19ae9e8cf 100644 --- a/src/install/PackageManager.rs +++ b/src/install/PackageManager.rs @@ -2861,6 +2861,10 @@ pub fn init( [None, None], subcommand, )?; + // What a dry run prints is its result. `bun pm` can still print its usage, so it declares later. + if manager.options.dry_run && subcommand != Subcommand::Pm { + Output::stdout_is_the_result(); + } // `install.prefer = "offline"` in bunfig (also what `bun --prefer-offline` sets // for the runtime's auto-install) means prefer-offline for `bun install` too, diff --git a/src/install/lockfile.rs b/src/install/lockfile.rs index 32bf4456c636..1bb95d96e2a2 100644 --- a/src/install/lockfile.rs +++ b/src/install/lockfile.rs @@ -2020,10 +2020,10 @@ impl<'a> Printer<'a> { crate::lockfile::LoadResult::Ok(_) => {} } + Output::stdout_is_the_result(); match printer::Yarn::print(&lockfile, Output::writer_buffered()) { Ok(()) => {} Err(crate::Error::Alloc(bun_alloc::AllocError)) => bun_core::out_of_memory(), - Err(crate::Error::WriteFailed) => return Ok(()), Err(e) => return Err(e), } Output::flush(); diff --git a/src/runtime/cli/audit_command.rs b/src/runtime/cli/audit_command.rs index 0e21504bc1ee..456fa2086420 100644 --- a/src/runtime/cli/audit_command.rs +++ b/src/runtime/cli/audit_command.rs @@ -177,6 +177,7 @@ impl AuditCommand { ); } + Output::stdout_is_the_result(); let code = Self::audit(manager, json_output, audit_level, audit_ignore_list)?; Global::exit(code); } diff --git a/src/runtime/cli/build_command.rs b/src/runtime/cli/build_command.rs index 6d6f7ece706c..54f14cc24369 100644 --- a/src/runtime/cli/build_command.rs +++ b/src/runtime/cli/build_command.rs @@ -878,6 +878,7 @@ impl BuildCommand { { // if --no-bundle is passed, it won't have an output dir if let options::OutputFileValue::Buffer { bytes } = &output_files[0].value { + Output::stdout_is_the_result(); writer.write_all(bytes)?; } Output::flush(); diff --git a/src/runtime/cli/install_completions_command.rs b/src/runtime/cli/install_completions_command.rs index 84e090100c2e..8dc1cd7f802d 100644 --- a/src/runtime/cli/install_completions_command.rs +++ b/src/runtime/cli/install_completions_command.rs @@ -5,7 +5,7 @@ use bun_core::{Global, Output, env_var}; use bun_core::{note, print_errorln}; #[cfg(not(windows))] use bun_paths::{platform, resolve_path}; -use bun_sys::{self, E, File}; +use bun_sys::{self, File}; use crate::shell_completions::{Shell, ShellCompletionsExt as _}; @@ -172,12 +172,10 @@ impl InstallCompletionsCommand { Output::print_errorln("See https://github.com/oven-sh/bun/issues/8939"); } - /// Exits, unless the write fails on something else than a closed pipe. - fn write_and_exit(stdout: &File, completions: &[u8]) -> Result<(), crate::Error> { - match stdout.write_all(completions) { - Err(err) if err.get_errno() != E::EPIPE => Err(err.into()), - _ => Global::exit(0), - } + fn write_and_exit(completions: &[u8]) -> ! { + Output::stdout_is_the_result(); + let _ = Output::writer().write_all(completions); + Global::exit(0); } pub(crate) fn exec() -> Result<(), crate::Error> { @@ -199,7 +197,7 @@ impl InstallCompletionsCommand { if fail_exit_code == 1 && !bun_sys::isatty(stdout.handle) { let completions = shell.completions(); if !completions.is_empty() { - return Self::write_and_exit(&stdout, completions); + Self::write_and_exit(completions); } } @@ -241,7 +239,7 @@ impl InstallCompletionsCommand { if !env_var::IS_BUN_AUTO_UPDATE.get().unwrap_or(false) && !bun_sys::isatty(stdout.handle) { - return Self::write_and_exit(&stdout, shell.completions()); + Self::write_and_exit(shell.completions()); } let mut completions_dir: &[u8]; diff --git a/src/runtime/cli/lint_command.rs b/src/runtime/cli/lint_command.rs index eec0a1962e37..b14d5a275db8 100644 --- a/src/runtime/cli/lint_command.rs +++ b/src/runtime/cli/lint_command.rs @@ -265,12 +265,9 @@ pub(crate) fn print(stdout: &[u8], stderr: &[u8], exit_code: u8) -> u8 { let write_result = bun_sys::write_all_to_stdio(bun_core::Fd::stdout(), stdout); let _ = Output::error_writer().write_all(stderr); let exit_code = match write_result { - Err(error) if error.get_errno() != bun_sys::E::EPIPE => { - let why = error.msg().unwrap_or(b"unknown error"); - Output::err_generic( - "Cannot write standard output: {}: {}", - (BStr::new(error.name()), BStr::new(why)), - ); + Err(error) if !bun_sys::reader_left(bun_core::Fd::stdout(), error.get_errno()) => { + Output::err_stdout_write_failed(i32::from(error.errno)); + // Not the 1 of `Output::stdout_is_the_result`: 1 means findings here. 2 } _ => exit_code, diff --git a/src/runtime/cli/mod.rs b/src/runtime/cli/mod.rs index cd269962dfc9..0f9d35a9234d 100644 --- a/src/runtime/cli/mod.rs +++ b/src/runtime/cli/mod.rs @@ -1743,6 +1743,7 @@ pub(crate) mod command { } } } + Output::stdout_is_the_result(); completions.print(); Ok(()) } @@ -2405,6 +2406,7 @@ pub(crate) fn print_version_and_exit() -> ! { // second `Vec`, all to print a ~10-byte constant. Write the bytes // straight to the buffered stdout writer instead. One `write_all` (the // `\n` is baked into the constant) → one syscall. + Output::stdout_is_the_result(); let w = Output::writer(); let _ = w.write_all(Global::package_json_version_nl.as_bytes()); Output::flush(); @@ -2414,6 +2416,7 @@ pub(crate) fn print_version_and_exit() -> ! { #[cold] pub(crate) fn print_revision_and_exit() -> ! { // See `print_version_and_exit` — plain bytes, no `` rewrite needed. + Output::stdout_is_the_result(); let w = Output::writer(); let _ = w.write_all(Global::package_json_version_with_revision.as_bytes()); let _ = w.write_all(b"\n"); diff --git a/src/runtime/cli/outdated_command.rs b/src/runtime/cli/outdated_command.rs index d4fb09393a24..86f0fd11078b 100644 --- a/src/runtime/cli/outdated_command.rs +++ b/src/runtime/cli/outdated_command.rs @@ -103,6 +103,7 @@ impl OutdatedCommand { Output::flush(); let cli = CommandLineArguments::parse(Subcommand::Outdated)?; + Output::stdout_is_the_result(); let (manager, original_cwd) = match PackageManager::init(&mut *ctx, &cli, Subcommand::Outdated) { diff --git a/src/runtime/cli/pack_command.rs b/src/runtime/cli/pack_command.rs index 2046c25a98b8..c041dd7c7099 100644 --- a/src/runtime/cli/pack_command.rs +++ b/src/runtime/cli/pack_command.rs @@ -102,6 +102,8 @@ impl<'a> Context<'a> { // Quiet/silent output must be only the tarball path so `$(bun pm pack --quiet)` works. if log_level != LogLevel::Silent && log_level != LogLevel::Quiet { bun_core::pretty!("\n"); + } else { + Output::stdout_is_the_result(); } bun_core::pretty!("{}\n", bun_fmt::EscapeControlChars(path)); } diff --git a/src/runtime/cli/package_manager_command.rs b/src/runtime/cli/package_manager_command.rs index 348752e8670d..9f99fa527c30 100644 --- a/src/runtime/cli/package_manager_command.rs +++ b/src/runtime/cli/package_manager_command.rs @@ -176,6 +176,7 @@ impl PackageManagerCommand { let load_lockfile = lockfile.load_from_bytes(None, bytes, log); Self::handle_load_lockfile_errors_for(&load_lockfile, LogLevel::Default, "hash"); + Output::stdout_is_the_result(); Output::flush(); Output::disable_buffering(); Output::writer().print(format_args!("{}", lockfile.fmt_meta_hash()))?; @@ -302,6 +303,32 @@ impl PackageManagerCommand { setup_global_dir(pm, &&mut *ctx)?; } + // `pm`, `pm version` and `pm pkg` print their usage when they get no argument. + let prints_usage = match subcommand { + b"" => true, + b"version" => positionals.len() <= 1, + b"pkg" => positionals.get(1).is_none_or(|word| *word == b"help"), + _ => false, + }; + // These subcommands only print, and what they print is their result. So is what a dry run prints. + if (pm.options.dry_run && !prints_usage) + || matches!( + subcommand, + b"bin" + | b"hash" + | b"hash-print" + | b"hash-string" + | b"ls" + | b"untrusted" + | b"default-trusted" + | b"diff" + | b"licenses" + | b"whoami" + ) + { + Output::stdout_is_the_result(); + } + if strings::eql_comptime(subcommand, b"scan") { ScanCommand::exec_with_manager(&mut *ctx, pm, &cwd)?; Global::exit(0); @@ -582,6 +609,7 @@ impl PackageManagerCommand { Ok(p) => &p[..], Err(err) => Self::crash_getting_cache_directory(err), }; + Output::stdout_is_the_result(); let _ = Output::writer().write_all(outpath); Global::exit(0); } else if strings::eql_comptime(subcommand, b"default-trusted") { diff --git a/src/runtime/cli/pm_pkg_command.rs b/src/runtime/cli/pm_pkg_command.rs index 750534b73522..0cf4a8230ee5 100644 --- a/src/runtime/cli/pm_pkg_command.rs +++ b/src/runtime/cli/pm_pkg_command.rs @@ -206,6 +206,7 @@ impl PmPkgCommand { } fn exec_get(ctx: &Context, args: &[&[u8]], cwd: &[u8]) -> Result<(), Error> { + Output::stdout_is_the_result(); let pkg = Self::load_package_json(ctx, cwd); if args.is_empty() { diff --git a/src/runtime/cli/pm_view_command.rs b/src/runtime/cli/pm_view_command.rs index f325b002a38f..c5d393145109 100644 --- a/src/runtime/cli/pm_view_command.rs +++ b/src/runtime/cli/pm_view_command.rs @@ -60,6 +60,7 @@ pub(crate) fn view( fields: &[&[u8]], json_output: bool, ) -> Result<(), crate::Error> { + Output::stdout_is_the_result(); let bump = Bump::new(); let (name, mut version) = dependency::split_name_and_version_or_latest('brk: { // Extremely best effort. diff --git a/src/runtime/cli/run_command.rs b/src/runtime/cli/run_command.rs index 136feadb3ead..5f59504f0964 100644 --- a/src/runtime/cli/run_command.rs +++ b/src/runtime/cli/run_command.rs @@ -3755,6 +3755,7 @@ impl RunCommand { Ok(Some(r)) => r, }; + Output::stdout_is_the_result(); let _ = Output::writer().write_all(&rendered); Output::flush(); // Temp files prefetchRemoteImages() wrote are deliberately NOT diff --git a/src/runtime/cli/scan_command.rs b/src/runtime/cli/scan_command.rs index 93300b7eb296..58d1644db191 100644 --- a/src/runtime/cli/scan_command.rs +++ b/src/runtime/cli/scan_command.rs @@ -25,6 +25,7 @@ impl ScanCommand { Global::exit(1); } + Output::stdout_is_the_result(); bun_core::pretty_error!( "bun pm scan v{}\n", Global::package_json_version_with_sha, diff --git a/src/runtime/cli/why_command.rs b/src/runtime/cli/why_command.rs index d3d060e9d0e6..45fe659475ad 100644 --- a/src/runtime/cli/why_command.rs +++ b/src/runtime/cli/why_command.rs @@ -265,6 +265,7 @@ Explain why a package is installed package_pattern: &[u8], top_only: bool, ) -> Result<(), crate::Error> { + Output::stdout_is_the_result(); let max_depth = if top_only { 1 } else { diff --git a/src/sys/lib.rs b/src/sys/lib.rs index 2815ad63bd10..0dd2a8d413da 100644 --- a/src/sys/lib.rs +++ b/src/sys/lib.rs @@ -9294,6 +9294,20 @@ pub fn write_all_to_stdio(fd: Fd, mut bytes: &[u8]) -> Maybe<()> { Ok(()) } +/// Whether a write to `fd` failed with `errno` because the reader of a pipe or a socket left (`| head`). +pub fn reader_left(fd: Fd, errno: E) -> bool { + // A socket whose reader left with unread bytes is reset. macOS says ENOTCONN for the write that the close lands in. + let hung_up = errno == E::EPIPE + || errno == E::ECONNRESET + || (cfg!(target_os = "macos") && errno == E::ENOTCONN); + // From a file the same errno is a loss: on Windows, ECONNRESET is also a network share that went away. + hung_up + && matches!( + File::borrow(&fd).kind(), + Ok(FileKind::NamedPipe | FileKind::UnixDomainSocket) + ) +} + /// 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. fn fd_write_all_quiet(fd: Fd, bytes: &[u8]) -> bool { @@ -9334,6 +9348,23 @@ impl SysQuietWriterAdapter { // `copy_nonoverlapping` and are initialized. Borrow tied to `&self`. unsafe { core::slice::from_raw_parts(self.buf, self.pos) } } + + #[inline] + fn write(&self, bytes: &[u8]) { + if let Err(err) = write_all_to_stdio(self.fd, bytes) { + self.write_failed(err); + } + } + + /// Keeps the errno of a failed write to stdout for `Output::stdout_is_the_result`, and drops the error out of line. + #[cold] + #[inline(never)] + fn write_failed(&self, err: Error) { + if self.fd == Fd::stdout() { + bun_core::output::record_stdout_write_error(i32::from(err.errno)); + } + drop(err); + } } unsafe fn adapter_write_all( @@ -9343,18 +9374,18 @@ unsafe fn adapter_write_all( // SAFETY: `w` points at the first field of a SysQuietWriterAdapter (repr(C)). let this = unsafe { &mut *w.cast::() }; if this.cap == 0 { - let _ = fd_write_all_quiet(this.fd, bytes); + this.write(bytes); return Ok(()); } if this.pos + bytes.len() > this.cap { // Drain buffered bytes first. if this.pos > 0 { - let _ = fd_write_all_quiet(this.fd, this.buffered()); + this.write(this.buffered()); this.pos = 0; } // Large writes bypass the buffer so the next small write still coalesces. if bytes.len() >= this.cap { - let _ = fd_write_all_quiet(this.fd, bytes); + this.write(bytes); return Ok(()); } } @@ -9371,7 +9402,7 @@ unsafe fn adapter_flush(w: *mut bun_core::io::Writer) -> core::result::Result<() // SAFETY: `w` points at the first field of a SysQuietWriterAdapter (repr(C)). let this = unsafe { &mut *w.cast::() }; if this.pos > 0 { - let _ = fd_write_all_quiet(this.fd, this.buffered()); + this.write(this.buffered()); this.pos = 0; } Ok(()) @@ -9451,6 +9482,7 @@ bun_core::link_impl_OutputSink! { quiet_writer_fd(qw) => qw_fd(qw), tty_winsize(fd) => sink_tty_winsize(fd), is_terminal(fd) => isatty(fd), + reader_left(fd, errno) => u16::try_from(errno).is_ok_and(|errno| reader_left(fd, E::from_raw(errno))), read(fd, buf) => read(fd, buf).map_err(|_| bun_core::Error::Unexpected), } } diff --git a/test/cli/bun.test.ts b/test/cli/bun.test.ts index 19a70548daee..4e006ca60e65 100644 --- a/test/cli/bun.test.ts +++ b/test/cli/bun.test.ts @@ -1,7 +1,19 @@ import { spawnSync } from "bun"; import { dlopen, FFIType } from "bun:ffi"; import { describe, expect, test } from "bun:test"; -import { bunEnv, bunExe, isDebug, isMusl, isWindows, tempDir } from "harness"; +import { + bunEnv, + bunExe, + isCI, + isDebug, + isLinux, + isMacOS, + isMusl, + isWindows, + tempDir, + unwritableFd, + type DirectoryTree, +} from "harness"; import fs from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -344,6 +356,23 @@ describe("bun", () => { expect(exitCode).toBe(1); }); + // `bun completions > _bun`: with a stdout that is not a terminal, the script is what the command prints. + test.concurrent.each(["bash", "zsh", "fish"])("prints the %s script when stdout is not a terminal", async shell => { + using home = tempDir("completions-print", {}); + // With a bunx on PATH, the command does not put a link to itself next to the executable. + fs.symlinkSync(bunExe(), join(String(home), bunxName)); + const env = { + HOME: String(home), + BUN_INSTALL: undefined, + SHELL: `/bin/${shell}`, + PATH: `${home}:${bunEnv.PATH}`, + }; + await using proc = Bun.spawn({ cmd: [bunExe(), "completions"], env: { ...bunEnv, ...env }, stderr: "pipe" }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + const script = fs.readFileSync(join(import.meta.dir, "..", "..", "completions", `bun.${shell}`), "utf8"); + expect({ stdout, stderr, exitCode }).toEqual({ stdout: script, stderr: "", exitCode: 0 }); + }); + // IS_BUN_AUTO_UPDATE is what `bun upgrade` sets: the file is installed although stdout is a pipe. async function installCompletions(args: string[], env: Record) { await using proc = Bun.spawn({ @@ -557,4 +586,545 @@ describe("bun", () => { expect(listed).toEqual(["check", "lint", "fmt"]); expect(exitCode).toBe(0); }); + + // `Bun.spawn` and Node give a child a socket as stdout. When its reader leaves, the write that waits is not + // always told EPIPE: Linux says ECONNRESET if that write sent nothing yet, and macOS can say ENOTCONN. + describe.concurrent("a reader that leaves a socket is not a failed write", () => { + const fmt = [bunExe(), "fmt", "--stdin-filepath", "a.js"]; + const ugly = Buffer.from("a = 1\n"); + const [AF_UNIX, SOCK_STREAM, MSG_DONTWAIT] = [1, 1, 0x40]; + + /** The socket calls of libc. Not for Windows. */ + function libcSockets() { + const machine = process.arch === "arm64" ? "aarch64" : "x86_64"; + const { symbols } = dlopen(isMacOS ? "libSystem.B.dylib" : isMusl ? `libc.musl-${machine}.so.1` : "libc.so.6", { + socket: { args: ["i32", "i32", "i32"], returns: "i32" }, + socketpair: { args: ["i32", "i32", "i32", "ptr"], returns: "i32" }, + send: { args: ["i32", "ptr", "u64", "i32"], returns: "i64" }, + close: { args: ["i32"], returns: "i32" }, + }); + return symbols; + } + + /** A stream socket that was never connected. A write to it fails with ENOTCONN. */ + function unconnectedSocket(): Disposable & { fd: number } { + const libc = libcSockets(); + const fd = libc.socket(AF_UNIX, SOCK_STREAM, 0); + if (fd < 0) throw new Error("socket failed"); + return { fd, [Symbol.dispose]: () => void libc.close(fd) }; + } + + /** Whether this process may read `/proc//syscall` of a child, as `spawnWithReaderThatLeaves` does. */ + function canWatchAChild(): boolean { + // The child waits for stdin, so it is alive for the read. + const child = Bun.spawn({ + cmd: [bunExe(), "-e", "await Bun.stdin.text()"], + env: bunEnv, + stdio: ["pipe", "ignore", "ignore"], + }); + try { + fs.readFileSync(`/proc/${child.pid}/syscall`); + return true; + } catch { + // Yama forbids the read when `kernel.yama.ptrace_scope` is 2 or 3. Some sandboxes do not have the file. + return false; + } finally { + child.kill(); + } + } + + /** + * Linux only. Runs `cmd` with a socket as stdout, like the one that `Bun.spawn` gives a child for `"pipe"`, and + * closes the reading end while the first write of the process waits with nothing sent. Linux fails that write + * with ECONNRESET. A write that starts after the reader left gets EPIPE. + */ + async function spawnWithReaderThatLeaves(options: { + cmd: string[]; + cwd?: string; + env?: Record; + stdin?: Buffer; + }) { + const libc = libcSockets(); + const ends = new Int32Array(2); + if (libc.socketpair(AF_UNIX, SOCK_STREAM, 0, ends) !== 0) throw new Error("socketpair failed"); + const [reader, writer] = ends; + // A full socket: the next write to it sleeps. + const filler = Buffer.alloc(64 * 1024); + while (Number(libc.send(writer, filler, filler.length, MSG_DONTWAIT)) > 0) {} + + await using proc = Bun.spawn({ ...options, stdin: options.stdin ?? "ignore", stdout: writer, stderr: "pipe" }); + libc.close(writer); + try { + // /proc//syscall starts with the number of the call that the main thread sleeps in and its first argument. + const write = process.arch === "arm64" ? "64" : "1"; + const sleepsInWrite = () => { + try { + return fs.readFileSync(`/proc/${proc.pid}/syscall`, "utf8").startsWith(`${write} 0x1 `); + } catch (error: any) { + // The process is gone. The loop reports it. + if (error?.code === "ENOENT" || error?.code === "ESRCH") return false; + throw error; + } + }; + while (!sleepsInWrite()) { + // A process that a signal ended has a `signalCode` and no `exitCode`. + if (proc.exitCode !== null || proc.signalCode !== null) { + const stderr = await proc.stderr.text(); + throw new Error(`${options.cmd.join(" ")} exited before it wrote to stdout: ${stderr}`); + } + await Bun.sleep(1); + } + } finally { + libc.close(reader); + } + const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + return { stderr, exitCode }; + } + + // A Linux host that forbids the read of `/proc//syscall` skips, except in CI. + const cannotWatchTheWrite = !isLinux || (!isCI && !canWatchAChild()); + + test.skipIf(cannotWatchTheWrite)("bun fmt, ECONNRESET", async () => { + using dir = tempDir("socket-reader-leaves", {}); + const result = await spawnWithReaderThatLeaves({ cmd: fmt, env: bunEnv, cwd: String(dir), stdin: ugly }); + expect(result).toEqual({ stderr: "", exitCode: 0 }); + }); + + test.skipIf(cannotWatchTheWrite)("bun completions, ECONNRESET", async () => { + using home = tempDir("socket-reader-leaves", {}); + // With a bunx on PATH, the command does not put a link to itself next to the executable. + fs.symlinkSync(bunExe(), join(String(home), isDebug ? "bunx-debug" : "bunx")); + const env = { HOME: String(home), BUN_INSTALL: undefined, SHELL: "/bin/bash", PATH: `${home}:${bunEnv.PATH}` }; + const result = await spawnWithReaderThatLeaves({ cmd: [bunExe(), "completions"], env: { ...bunEnv, ...env } }); + expect(result).toEqual({ stderr: "", exitCode: 0 }); + }); + + // macOS says ENOTCONN when the reader leaves during the write, and also when the socket never had a reader. + // A test can make only the second on purpose. Linux says ENOTCONN only for the second, so there the write + // failed. The test knows the errno of these two kernels only. + test.skipIf(!isLinux && !isMacOS)("bun fmt, ENOTCONN", async () => { + using dir = tempDir("socket-reader-leaves", {}); + using socket = unconnectedSocket(); + await using proc = Bun.spawn({ + cmd: fmt, + env: bunEnv, + cwd: String(dir), + stdin: ugly, + stdout: socket.fd, + stderr: "pipe", + }); + const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + const failed = "error: Cannot write standard output: ENOTCONN: Transport endpoint is not connected\n"; + expect({ stderr, exitCode }).toEqual(isMacOS ? { stderr: "", exitCode: 0 } : { stderr: failed, exitCode: 2 }); + }); + }); + + // A form that exists to print (a listing, a bundle on stdout, a dry run) declares that stdout holds its result. + // When stdout does not take it (a full disk behind `> file`), the form says so on stderr and exits 1. + // Help text, a log of work that bun did and the output of a script are not a result: their exit code stays. + describe.concurrent("a result that does not reach stdout", () => { + const dep = "github:example/repo#abc1234"; + const files = { + "package.json": JSON.stringify({ + name: "failed-stdout", + version: "1.2.3", + dependencies: { dep }, + scripts: { a: "echo out-a && echo err-a 1>&2", b: "echo out-b && echo err-b 1>&2" }, + }), + // A lockfile and a node_modules folder by hand: no command here installs or leaves the machine. + "bun.lock": JSON.stringify({ + lockfileVersion: 1, + configVersion: 1, + workspaces: { "": { name: "failed-stdout", dependencies: { dep } } }, + packages: { dep: [`dep@${dep}`, {}, "example-repo-abc1234"] }, + }), + "node_modules/dep/package.json": JSON.stringify({ name: "dep", version: "1.0.0", license: "MIT" }), + "old/package.json": JSON.stringify({ name: "x", version: "1.0.0" }), + "new/package.json": JSON.stringify({ name: "x", version: "1.0.1" }), + "entry.js": `import "./shared.js";\nconsole.log("the bundle");\n`, + "other.js": `import "./shared.js";\nconsole.log("the other bundle");\n`, + "shared.js": `console.log("shared");\n`, + "entry.ts": `const n: number = 1;\nconsole.log(n);\n`, + // More than the 4096 bytes that stdout buffers: the bundle is written at once, not at exit. + "big.js": `console.log("${Buffer.alloc(8192, "x").toString()}");\n`, + "pass.test.ts": `import { test } from "bun:test";\ntest("passes", () => console.log("from the test"));\n`, + "doc.md": `# Title\n\nSome *text*.\n`, + }; + // No dependency: `bun install` has nothing to fetch. + const noDependencies = { "package.json": JSON.stringify({ name: "failed-stdout", version: "1.2.3" }) }; + const lockb = join(import.meta.dir, "..", "integration", "sharp", "bun.lockb"); + const cannotWrite = (why: string) => `error: Cannot write standard output: ${why}\n`; + const reported = { stderr: cannotWrite("EBADF: Bad file descriptor"), exitCode: 1 }; + + async function run( + args: string[], + stdio: { stdin?: Buffer; stdout: number | Bun.BunFile; stderr?: number }, + dir: string, + env?: Record, + ) { + await using proc = Bun.spawn({ + cmd: [bunExe(), ...args], + cwd: dir, + // `bun repl` and `bun completions` write below the home directory. + env: { ...bunEnv, HOME: dir, USERPROFILE: dir, ...env }, + stdin: stdio.stdin ?? "ignore", + stdout: stdio.stdout, + stderr: stdio.stderr ?? "pipe", + }); + const [stderr, exitCode] = await Promise.all([ + stdio.stderr === undefined ? (proc.stderr as ReadableStream).text() : "", + proc.exited, + ]); + return { stderr, exitCode }; + } + + /** Runs `bun` with a stdout that rejects every write. */ + async function unwritable(args: string[], tree: DirectoryTree = files, env?: Record) { + using dir = tempDir("failed-stdout", tree); + using stdout = unwritableFd(); + return await run(args, { stdout: stdout.fd }, String(dir), env); + } + + const name = (args: string[]) => `bun ${args.join(" ").replace(lockb, "bun.lockb")}`; + + for (const args of [ + ["--version"], + ["--revision"], + ["build", "entry.js"], + ["build", "--no-bundle", "entry.ts"], + ["build", "big.js"], + ["doc.md"], + ["getcompletes"], + ["pm", "pkg", "get", "version"], + ["pm", "hash"], + ["pm", "hash-string"], + ["pm", "hash-print"], + ["pm", "bin"], + ["pm", "cache"], + ["pm", "ls"], + ["pm", "licenses"], + ["pm", "default-trusted"], + ["pm", "why", "dep"], + ["why", "dep"], + ["pm", "diff", "./old", "./new"], + [lockb], + [lockb, "--hash"], + // stdout is the name of the tarball and nothing else. + ["pm", "pack", "--quiet"], + ["pm", "pack", "--silent"], + // What a dry run prints is its result. + ["pm", "pack", "--dry-run"], + ["pm", "pack", "--dry-run", "--quiet"], + ["pm", "pkg", "set", "a=b", "--dry-run"], + ["pm", "version", "patch", "--dry-run"], + ["prune", "--check"], + ]) { + test(`${name(args)} reports it and exits 1`, async () => { + expect(await unwritable(args)).toEqual(reported); + }); + } + + test("bun install --dry-run reports it and exits 1", async () => { + expect(await unwritable(["install", "--dry-run"], noDependencies)).toEqual(reported); + }); + + test("dryRun in bunfig.toml makes bun pm pkg set a dry run, which reports it and exits 1", async () => { + const tree = { ...files, "bunfig.toml": "[install]\ndryRun = true\n" }; + expect(await unwritable(["pm", "pkg", "set", "a=b"], tree)).toEqual(reported); + }); + + test("bun pm untrusted reports it and exits 1", async () => { + const { stderr, exitCode } = await unwritable(["pm", "untrusted"]); + // After the header that it prints on stderr. + expect(stderr).toEndWith("\n" + reported.stderr); + expect(exitCode).toBe(1); + }); + + test("bun pm scan reports it and exits 1", async () => { + const tree = { + ...files, + "bunfig.toml": `[install.security]\nscanner = "./scanner.js"\n`, + "scanner.js": `export const scanner = { version: "1", scan: async () => [] };\n`, + }; + const { stderr, exitCode } = await unwritable(["pm", "scan"], tree); + // After the header that it prints on stderr. + expect(stderr).toEndWith("\n" + reported.stderr); + expect(exitCode).toBe(1); + }); + + // These ask the registry before they print. A registry in this process answers them. + async function withRegistry(args: string[]) { + const integrity = + "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="; + using registry = Bun.serve({ + port: 0, + fetch(req) { + const { origin, pathname } = new URL(req.url); + if (pathname === "/-/whoami") return Response.json({ username: "someone" }); + // No advisory for any package. + if (pathname === "/-/npm/v1/security/advisories/bulk") return Response.json({}); + if (pathname !== "/pkg") return new Response(null, { status: 404 }); + const version = (version: string) => ({ + name: "pkg", + version, + dist: { tarball: `${origin}/pkg/-/pkg-${version}.tgz`, integrity }, + }); + return Response.json({ + name: "pkg", + "dist-tags": { latest: "2.0.0" }, + versions: { "1.0.0": version("1.0.0"), "2.0.0": version("2.0.0") }, + }); + }, + }); + return await unwritable(args, { + "package.json": JSON.stringify({ name: "failed-stdout", version: "1.2.3", dependencies: { pkg: "^1.0.0" } }), + "bun.lock": JSON.stringify({ + lockfileVersion: 1, + configVersion: 1, + workspaces: { "": { name: "failed-stdout", dependencies: { pkg: "^1.0.0" } } }, + packages: { pkg: ["pkg@1.0.0", "", {}, integrity] }, + }), + "bunfig.toml": Bun.TOML.stringify({ + install: { cache: false, registry: { url: `http://localhost:${registry.port}`, token: "token" } }, + }), + }); + } + + for (const args of [ + ["pm", "whoami"], + ["pm", "view", "pkg", "version"], + ["info", "pkg", "version"], + ["outdated"], + ["audit"], + ["audit", "fix", "--dry-run", "--json"], + ]) { + test(`${name(args)} reports it and exits 1`, async () => { + expect(await withRegistry(args)).toEqual(reported); + }); + } + + // The HTTP thread ends the process here, while the main thread waits for the registry. + test("a thread that is not the main thread reports it", async () => { + expect(await withRegistry(["outdated", "--cafile=/nonexistent"])).toEqual({ + stderr: "HTTPThread: could not find CA file: '/nonexistent'\n" + reported.stderr, + exitCode: 1, + }); + }); + + test.skipIf(isWindows)("bun completions reports it and exits 1", async () => { + using dir = tempDir("failed-stdout", files); + // Without a bunx on PATH, the command puts a link to itself next to the executable. + const bin = join(String(dir), "bin"); + fs.mkdirSync(bin); + fs.symlinkSync(bunExe(), join(bin, isDebug ? "bunx-debug" : "bunx")); + using stdout = unwritableFd(); + const env = { SHELL: "/bin/bash", BUN_INSTALL: undefined, PATH: `${bin}:${bunEnv.PATH}` }; + expect(await run(["completions"], { stdout: stdout.fd }, String(dir), env)).toEqual(reported); + }); + + test.skipIf(!isLinux)("a full disk is reported as ENOSPC", async () => { + using dir = tempDir("failed-stdout", files); + expect(await run(["build", "entry.js"], { stdout: Bun.file("/dev/full") }, String(dir))).toEqual({ + stderr: cannotWrite("ENOSPC: No space left on device"), + exitCode: 1, + }); + }); + + // The file size limit stands for a disk that fills up in the middle of the bundle. + test.skipIf(isWindows)("a bundle that is cut in the middle of the file is reported", async () => { + using dir = tempDir("failed-stdout-cut", { + "big.js": `console.log("${Buffer.alloc(200 * 1024, "x").toString()}");\n`, + }); + await using proc = Bun.spawn({ + cmd: ["sh", "-c", 'ulimit -f 16 && exec "$0" build big.js > out.js', bunExe()], + cwd: String(dir), + env: bunEnv, + stdin: "ignore", + stdout: "ignore", + stderr: "pipe", + }); + const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(cannotWrite("EFBIG: File too large")); + const written = fs.statSync(join(String(dir), "out.js")).size; + expect(written).toBeGreaterThan(0); + expect(written).toBeLessThan(200 * 1024); + expect(exitCode).toBe(1); + }); + + // `bun build big.js | head -c 1`: the reader has what it wants. + test("a reader that hangs up is not an error", async () => { + using dir = tempDir("failed-stdout-hang-up", { + "big.js": `console.log("${Buffer.alloc(2 * 1024 * 1024, "x").toString()}");\n`, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "build", "big.js"], + cwd: String(dir), + env: bunEnv, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + + // Take one chunk, then close the read end while most of the bundle is still to be written. + const reader = proc.stdout.getReader(); + await reader.read(); + await reader.cancel(); + + const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + }); + + // Not every failed write to a pipe means that its reader left: this end is open for reading only. + test.skipIf(isWindows)("a pipe that is not open for writing is reported", async () => { + using dir = tempDir("failed-stdout-fifo", files); + const fifo = join(String(dir), "fifo"); + expect(Bun.spawnSync(["mkfifo", fifo]).exitCode).toBe(0); + // Without O_NONBLOCK, open() waits for a writer. + const stdout = fs.openSync(fifo, fs.constants.O_RDONLY | fs.constants.O_NONBLOCK); + try { + expect(await run(["build", "entry.js"], { stdout }, String(dir))).toEqual(reported); + } finally { + fs.closeSync(stdout); + } + }); + + // `bun pm untrusted` prints its header on stderr and its result on stdout. + test("a failed write to stderr is not reported", async () => { + using dir = tempDir("failed-stdout", files); + using stderr = unwritableFd(); + const out = join(String(dir), "out.txt"); + const { exitCode } = await run(["pm", "untrusted"], { stdout: Bun.file(out), stderr: stderr.fd }, String(dir)); + expect(fs.readFileSync(out, "utf8")).toContain("Found 0 untrusted dependencies"); + expect(exitCode).toBe(0); + }); + + const silent = { stderr: expect.not.stringContaining("Cannot write"), exitCode: 0 }; + + for (const [args, stdin] of [ + // stdout of a script belongs to the script. As in Node, a console.log that fails is not an exit code. + [["-e", "console.log('from the script')"]], + [["-p", "1 + 1"]], + [["-e", "alert('from the script')"]], + [["test", "pass.test.ts"]], + [["repl"], Buffer.from("1 + 1\n")], + // Help is not a result, and neither is the help of a form that declares. + [["--help"]], + [["run"]], + [["pm", "version"]], + [["build", "--help"]], + [["pm", "ls", "--help"]], + [["why", "--help"]], + [["info", "--help"]], + [["audit", "--help"]], + [["outdated", "--help"]], + [["outdated", "-h"]], + [["--help", "outdated"]], + [["install", "--dry-run", "--help"]], + [["pm", "--dry-run"]], + [["pm", "version", "--dry-run"]], + [["pm", "pkg", "--dry-run"]], + // A log of work that is done. + [["pm", "pack"]], + [["pm", "version", "patch", "--no-git-tag-version"]], + ] as [string[], Buffer?][]) { + test(`${name(args)} does not report it and exits 0`, async () => { + using dir = tempDir("failed-stdout", files); + using stdout = unwritableFd(); + expect(await run(args, { stdin, stdout: stdout.fd }, String(dir))).toEqual(silent); + }); + } + + test("bun install does not report it and exits 0", async () => { + expect(await unwritable(["install"], noDependencies)).toEqual(silent); + }); + + test("bun audit fix does not report it and exits 0", async () => { + expect(await withRegistry(["audit", "fix"])).toEqual(silent); + }); + + // A dependency with a postinstall script that is not trusted yet. + const untrusted = { + ...files, + "node_modules/dep/package.json": JSON.stringify({ + name: "dep", + version: "1.0.0", + scripts: { postinstall: "echo ran > ran" }, + }), + }; + + test("bun pm trust --all --dry-run reports it and exits 1", async () => { + const { stderr, exitCode } = await unwritable(["pm", "trust", "--all", "--dry-run"], untrusted); + // After the header that it prints on stderr. + expect(stderr).toEndWith("\n" + reported.stderr); + expect(exitCode).toBe(1); + }); + + test("bun pm trust --all runs the script and exits 0", async () => { + using dir = tempDir("failed-stdout", untrusted); + using stdout = unwritableFd(); + expect(await run(["pm", "trust", "--all"], { stdout: stdout.fd }, String(dir))).toEqual(silent); + expect(fs.existsSync(join(String(dir), "node_modules", "dep", "ran"))).toBe(true); + }); + + // A cache and a temporary directory of its own: the command also clears what bunx keeps there. + async function cacheRm(args: string[]) { + using dir = tempDir("failed-stdout", { ...files, cache: { "entry": "" }, tmp: {} }); + using stdout = unwritableFd(); + const tmp = join(String(dir), "tmp"); + const env = { BUN_INSTALL_CACHE_DIR: join(String(dir), "cache"), TMPDIR: tmp, TMP: tmp, TEMP: tmp }; + const result = await run(["pm", "cache", "rm", ...args], { stdout: stdout.fd }, String(dir), env); + return { ...result, cleared: !fs.existsSync(join(String(dir), "cache", "entry")) }; + } + + test("bun pm cache rm --dry-run reports it and exits 1", async () => { + expect(await cacheRm(["--dry-run"])).toEqual({ ...reported, cleared: false }); + }); + + test("bun pm cache rm does not report it and exits 0", async () => { + expect(await cacheRm([])).toEqual({ ...silent, cleared: true }); + }); + + test("bun build --outdir writes every output file and exits 0", async () => { + using dir = tempDir("failed-stdout", files); + using stdout = unwritableFd(); + const args = ["build", "./entry.js", "./other.js", "--outdir=out", "--splitting"]; + expect(await run(args, { stdout: stdout.fd }, String(dir))).toEqual({ stderr: "", exitCode: 0 }); + // Two entry points and the shared chunk. + const written = fs.readdirSync(join(String(dir), "out")); + expect(written).toHaveLength(3); + expect(written).toContain("entry.js"); + expect(written).toContain("other.js"); + }); + + test.each(["--parallel", "--sequential"])("bun run %s runs every script and exits 0", async mode => { + using dir = tempDir("failed-stdout", files); + using stdout = unwritableFd(); + const env = { NO_COLOR: "1" }; + const { stderr, exitCode } = await run(["run", mode, "a", "b"], { stdout: stdout.fd }, String(dir), env); + // Both scripts ran to their end and their status is on stderr. + expect(stderr).toContain("err-a"); + expect(stderr).toContain("err-b"); + expect(stderr.match(/Done in/g)).toHaveLength(2); + expect(stderr).not.toContain("Cannot write"); + expect(exitCode).toBe(0); + }); + + for (const mode of [["--filter", "*"], ["--workspaces"]]) { + test(`bun run ${mode.join(" ")} runs the script and exits 0`, async () => { + using dir = tempDir("failed-stdout-workspace", { + "package.json": JSON.stringify({ name: "root", version: "1.0.0", workspaces: ["packages/*"] }), + "packages/one/package.json": JSON.stringify({ + name: "one", + version: "1.0.0", + scripts: { a: "echo ran > ran" }, + }), + }); + using stdout = unwritableFd(); + expect(await run(["run", ...mode, "a"], { stdout: stdout.fd }, String(dir))).toEqual(silent); + expect(fs.existsSync(join(String(dir), "packages", "one", "ran"))).toBe(true); + }); + } + }); }); diff --git a/test/harness.ts b/test/harness.ts index 4278db15564c..017afd7a9b20 100644 --- a/test/harness.ts +++ b/test/harness.ts @@ -707,6 +707,15 @@ export function tempDirWithBunTypes( return dir; } +/** + * A file descriptor that is open for reading only: every write to it fails with EBADF, on every platform. + * Pass `.fd` as the `stdout` of a spawned process to see what the process does when it cannot write its output. + */ +export function unwritableFd(): Disposable & { fd: number } { + const fd = openSync(import.meta.path, "r"); + return { fd, [Symbol.dispose]: () => closeSync(fd) }; +} + /** * Windows only. Makes `dir` the root of a new drive with `subst`, and removes the drive on dispose. * A test cannot write to the root of a real drive.