aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorakbash <akbash@herdr.dev>2026-08-19 20:39:24 +0300
committerGitHub <noreply@github.com>2026-08-19 20:39:24 +0300
commite3c3d443584b629803a73ad66e0e298943c7f201 (patch)
tree639abd8b3d8f03d07d05eef47ca025cafaeda02b
parent5203a5dc0f39a082938ea0f9836d6257ea7e155f (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.md1
-rw-r--r--src/cli.rs15
-rw-r--r--src/cli/completion.rs2
-rw-r--r--src/cli/spec.rs14
-rw-r--r--src/main.rs4
-rw-r--r--src/platform/mod.rs8
-rw-r--r--src/platform/unix_common.rs19
-rw-r--r--tests/broken_pipe.rs49
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)
diff --git a/src/cli.rs b/src/cli.rs
index ac97edc5..b432528d 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -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"]));
+}