Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 2 additions & 1 deletion src/bun_core/Global.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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")]
{
Expand Down
1 change: 1 addition & 0 deletions src/bun_core/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -602,6 +602,7 @@ bun_dispatch::link_interface! {
fn quiet_writer_fd(qw: &output::QuietWriter) -> Fd;
fn tty_winsize(fd: Fd) -> Option<Winsize>;
fn is_terminal(fd: Fd) -> bool;
fn reader_left(fd: Fd, errno: i32) -> bool;
fn read(fd: Fd, buf: &mut [u8]) -> core::result::Result<usize, Error>;
}
}
Expand Down
54 changes: 54 additions & 0 deletions src/bun_core/output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ──────────────────────────────────────────────────────────────────────────
Expand Down
4 changes: 4 additions & 0 deletions src/install/PackageManager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion src/install/lockfile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
1 change: 1 addition & 0 deletions src/runtime/cli/audit_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
1 change: 1 addition & 0 deletions src/runtime/cli/build_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
16 changes: 7 additions & 9 deletions src/runtime/cli/install_completions_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 _};

Expand Down Expand Up @@ -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> {
Expand All @@ -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);
}
}

Expand Down Expand Up @@ -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];
Expand Down
9 changes: 3 additions & 6 deletions src/runtime/cli/lint_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions src/runtime/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1743,6 +1743,7 @@ pub(crate) mod command {
}
}
}
Output::stdout_is_the_result();
completions.print();
Ok(())
}
Expand Down Expand Up @@ -2405,6 +2406,7 @@ pub(crate) fn print_version_and_exit() -> ! {
// second `Vec<u8>`, 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();
Expand All @@ -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 `<tag>` 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");
Expand Down
1 change: 1 addition & 0 deletions src/runtime/cli/outdated_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
2 changes: 2 additions & 0 deletions src/runtime/cli/pack_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
Expand Down
28 changes: 28 additions & 0 deletions src/runtime/cli/package_manager_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()))?;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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") {
Expand Down
1 change: 1 addition & 0 deletions src/runtime/cli/pm_pkg_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
1 change: 1 addition & 0 deletions src/runtime/cli/pm_view_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions src/runtime/cli/run_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/runtime/cli/scan_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ impl ScanCommand {
Global::exit(1);
}

Output::stdout_is_the_result();
bun_core::pretty_error!(
"<r><b>bun pm scan <r><d>v{}<r>\n",
Global::package_json_version_with_sha,
Expand Down
1 change: 1 addition & 0 deletions src/runtime/cli/why_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
40 changes: 36 additions & 4 deletions src/sys/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}

unsafe fn adapter_write_all(
Expand 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::<SysQuietWriterAdapter>() };
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(());
}
}
Expand All @@ -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::<SysQuietWriterAdapter>() };
if this.pos > 0 {
let _ = fd_write_all_quiet(this.fd, this.buffered());
this.write(this.buffered());
this.pos = 0;
}
Ok(())
Expand Down Expand Up @@ -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),
}
}
Expand Down
Loading
Loading