diff options
| author | akbash <akbash@herdr.dev> | 2026-08-19 20:39:24 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-08-19 20:39:24 +0300 |
| commit | e3c3d443584b629803a73ad66e0e298943c7f201 (patch) | |
| tree | 639abd8b3d8f03d07d05eef47ca025cafaeda02b | |
| parent | 5203a5dc0f39a082938ea0f9836d6257ea7e155f (diff) | |
fix(cli): exit quietly when output pipes close (#2996)
refs #2994
Co-authored-by: akbash-bot <300245827+akbash-bot@users.noreply.github.com>
| -rw-r--r-- | docs/next/CHANGELOG.md | 1 | ||||
| -rw-r--r-- | src/cli.rs | 15 | ||||
| -rw-r--r-- | src/cli/completion.rs | 2 | ||||
| -rw-r--r-- | src/cli/spec.rs | 14 | ||||
| -rw-r--r-- | src/main.rs | 4 | ||||
| -rw-r--r-- | src/platform/mod.rs | 8 | ||||
| -rw-r--r-- | src/platform/unix_common.rs | 19 | ||||
| -rw-r--r-- | tests/broken_pipe.rs | 49 |
8 files changed, 108 insertions, 4 deletions
diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md index efe9fead..276790f2 100644 --- a/docs/next/CHANGELOG.md +++ b/docs/next/CHANGELOG.md @@ -26,6 +26,7 @@ - Experimental pane graphics now support bounded named layers, acknowledged full-RGBA primary-layer direct file frames on audited local terminals, owned BGRA fallback, exact pixel mouse input, and placement-only resize replay. ### Fixed +- Unix CLI commands now exit quietly when a downstream pipe closes instead of panicking with exit 101. (#2994) - The terminal theme now keeps the active Space row fill visible when the Navigate cursor lands on it, in both expanded and collapsed sidebars. (#2987) - High-rate output from many hidden panes no longer floods the server loop with redundant wakeups, and terminal input-mode synchronization no longer formats pane scrollback to read one keyboard flag. - Chinese IME commits now reach panes on macOS when the focused application requests printable key-release events. (#2924) @@ -8,6 +8,20 @@ use crate::api::schema::{ ReadSource, Request, SplitDirection, }; +macro_rules! print { + ($($arg:tt)*) => {{ + crate::platform::begin_cli_output(); + std::print!($($arg)*); + }}; +} + +macro_rules! println { + ($($arg:tt)*) => {{ + crate::platform::begin_cli_output(); + std::println!($($arg)*); + }}; +} + mod agent; mod api; mod completion; @@ -194,6 +208,7 @@ fn channel_set(args: &[String]) -> std::io::Result<i32> { ChannelSetInstallAction::RunSelfUpdate => {} } + crate::platform::end_cli_output(); if let Err(err) = crate::update::self_update(crate::update::SelfUpdateOptions::default()) { eprintln!("update failed: {err}"); eprintln!("Run `herdr update` to retry."); diff --git a/src/cli/completion.rs b/src/cli/completion.rs index f049175d..02391ec8 100644 --- a/src/cli/completion.rs +++ b/src/cli/completion.rs @@ -35,8 +35,10 @@ pub(super) fn run_completion_command(args: &[String]) -> std::io::Result<i32> { let script = String::from_utf8(output).map_err(|err| { std::io::Error::new(std::io::ErrorKind::InvalidData, err.utf8_error()) })?; + crate::platform::begin_cli_output(); std::io::stdout().write_all(space_separated_zsh_long_options(&script).as_bytes())?; } else { + crate::platform::begin_cli_output(); generate(shell, &mut command, "herdr", &mut std::io::stdout()); } Ok(0) diff --git a/src/cli/spec.rs b/src/cli/spec.rs index b2b6cb03..134bf0bf 100644 --- a/src/cli/spec.rs +++ b/src/cli/spec.rs @@ -64,10 +64,14 @@ fn configure_help(command: Command, depth: usize) -> Command { pub(super) fn print_requested_help(args: &[String]) -> std::io::Result<bool> { let mut stdout = std::io::stdout().lock(); - write_requested_help(args, &mut stdout) + write_requested_help(args, &mut stdout, crate::platform::begin_cli_output) } -fn write_requested_help(args: &[String], output: &mut impl Write) -> std::io::Result<bool> { +fn write_requested_help( + args: &[String], + output: &mut impl Write, + before_write: impl FnOnce(), +) -> std::io::Result<bool> { let Some(help_index) = args .iter() .position(|arg| matches!(arg.as_str(), "--help" | "-h")) @@ -99,6 +103,7 @@ fn write_requested_help(args: &[String], output: &mut impl Write) -> std::io::Re } selected.set_bin_name(path.join(" ")); + before_write(); selected.write_long_help(&mut *output)?; writeln!(output)?; Ok(true) @@ -1083,7 +1088,7 @@ mod tests { args.push(flag.to_string()); let mut output = Vec::new(); assert!( - super::write_requested_help(&args, &mut output).unwrap(), + super::write_requested_help(&args, &mut output, || {}).unwrap(), "help was not handled for herdr {} {flag}", path.join(" ") ); @@ -1197,6 +1202,7 @@ mod tests { "--help".to_string(), ], &mut help, + || {}, ) .unwrap(); assert!(String::from_utf8(help) @@ -1304,7 +1310,7 @@ mod tests { args.push("--help".to_string()); let mut output = Vec::new(); assert!( - super::write_requested_help(&args, &mut output).unwrap(), + super::write_requested_help(&args, &mut output, || {}).unwrap(), "help was not handled for herdr {}", path.join(" ") ); diff --git a/src/main.rs b/src/main.rs index 36b1d021..56dbb8df 100644 --- a/src/main.rs +++ b/src/main.rs @@ -614,6 +614,7 @@ fn main() -> io::Result<()> { } if args.iter().any(|a| a == "--help" || a == "-h") { + platform::begin_cli_output(); println!("herdr — terminal workspace manager for AI coding agents"); println!(); println!("Usage: herdr [options]"); @@ -729,16 +730,19 @@ fn main() -> io::Result<()> { } if args.iter().any(|a| a == "--version" || a == "-V") { + platform::begin_cli_output(); println!("herdr {}", crate::build_info::version()); return Ok(()); } if args.iter().any(|a| a == "--default-config") { + platform::begin_cli_output(); print!("{DEFAULT_CONFIG}"); return Ok(()); } if args.iter().any(|a| a == "--skill") { + platform::begin_cli_output(); print!("{SKILL}"); return Ok(()); } diff --git a/src/platform/mod.rs b/src/platform/mod.rs index e3f9c7f4..a63b7565 100644 --- a/src/platform/mod.rs +++ b/src/platform/mod.rs @@ -198,6 +198,14 @@ pub(crate) struct RemoteSshConfigPaths { #[cfg(unix)] mod unix_common; +#[cfg(unix)] +pub(crate) use unix_common::{begin_cli_output, end_cli_output}; + +#[cfg(not(unix))] +pub(crate) fn begin_cli_output() {} + +#[cfg(not(unix))] +pub(crate) fn end_cli_output() {} #[cfg(target_os = "linux")] mod linux; diff --git a/src/platform/unix_common.rs b/src/platform/unix_common.rs index fa7b6d20..b997049f 100644 --- a/src/platform/unix_common.rs +++ b/src/platform/unix_common.rs @@ -1,5 +1,24 @@ use std::path::{Path, PathBuf}; +fn set_sigpipe_disposition(handler: libc::sighandler_t) { + let mut action: libc::sigaction = unsafe { std::mem::zeroed() }; + action.sa_sigaction = handler; + unsafe { + libc::sigemptyset(&mut action.sa_mask); + // Rust starts with SIGPIPE ignored. If this best-effort transition + // fails, stdout retains the existing Rust behavior. + libc::sigaction(libc::SIGPIPE, &action, std::ptr::null_mut()); + } +} + +pub(crate) fn begin_cli_output() { + set_sigpipe_disposition(libc::SIG_DFL); +} + +pub(crate) fn end_cli_output() { + set_sigpipe_disposition(libc::SIG_IGN); +} + pub(crate) fn remote_ssh_config_paths() -> super::RemoteSshConfigPaths { super::RemoteSshConfigPaths { user_config: std::env::var_os("HOME") diff --git a/tests/broken_pipe.rs b/tests/broken_pipe.rs new file mode 100644 index 00000000..66aa029e --- /dev/null +++ b/tests/broken_pipe.rs @@ -0,0 +1,49 @@ +#![cfg(unix)] + +use std::os::fd::{FromRawFd, OwnedFd}; +use std::os::unix::process::ExitStatusExt; +use std::process::{Command, Output, Stdio}; + +fn closed_pipe_writer() -> Stdio { + let mut fds = [-1; 2]; + assert_eq!(unsafe { libc::pipe(fds.as_mut_ptr()) }, 0); + // SAFETY: pipe initialized both descriptors, and each is assigned to one + // OwnedFd so it is closed exactly once. + let read_fd = unsafe { OwnedFd::from_raw_fd(fds[0]) }; + let write_fd = unsafe { OwnedFd::from_raw_fd(fds[1]) }; + drop(read_fd); + Stdio::from(write_fd) +} + +fn run_with_closed_stdout(args: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_herdr")) + .args(args) + .stdout(closed_pipe_writer()) + .stderr(Stdio::piped()) + .output() + .expect("run herdr CLI") +} + +fn assert_quiet_sigpipe(output: Output) { + assert_eq!(output.status.signal(), Some(libc::SIGPIPE)); + assert!( + output.stderr.is_empty(), + "closed stdout should not emit an error or panic: {}", + String::from_utf8_lossy(&output.stderr) + ); +} + +#[test] +fn cli_exits_via_sigpipe_without_panicking_when_stdout_closes() { + assert_quiet_sigpipe(run_with_closed_stdout(&["api", "schema", "--json"])); +} + +#[test] +fn terminal_help_uses_cli_sigpipe_behavior_before_attach_starts() { + assert_quiet_sigpipe(run_with_closed_stdout(&["terminal", "attach", "--help"])); +} + +#[test] +fn completion_direct_writer_uses_cli_sigpipe_behavior() { + assert_quiet_sigpipe(run_with_closed_stdout(&["completion", "bash"])); +} |
