Radish alpha
h
Radicle Heartwood Protocol & Stack
Radicle
Git (anonymous pull)
Log in to clone via SSH
cli/test: Add broken pipe (SIGPIPE) tests
Fintan Halpenny committed 1 month ago
commit ab7ad8de948144f9f1d0be024177b9ed32d2879a
parent 22b2871f64ecf34a22d32add0dd59a0c7c96ad10
2 files changed +185 -0
modified crates/radicle-cli/tests/commands.rs
@@ -27,6 +27,7 @@ mod commands {
    mod patch;
    mod policy;
    mod remote;
+
    mod sigpipe;
    mod sync;
    mod utility;
    mod watch;
added crates/radicle-cli/tests/commands/sigpipe.rs
@@ -0,0 +1,184 @@
+
//! Test that `rad` exits cleanly when its stdout is a broken pipe.
+
//!
+
//! As evidenced from [Rust #6529], Rust (since 1.62) ignores SIGPIPE by default.
+
//! This means that writing to a closed pipe returns `io::ErrorKind::BrokenPipe`
+
//! instead of terminating the process with SIGPIPE (the standard Unix behaviour
+
//! for CLI tools).
+
//!
+
//! The `println!` macro panics on write errors, so commands like
+
//! `rad config | head -1` would produce a panic backtrace instead of exiting
+
//! silently.
+
//!
+
//! This test verifies that the `rad` binary handles broken pipes gracefully:
+
//! it should exit with a success status or with SIGPIPE (exit code 141),
+
//! and must not panic (exit code 101).
+
//!
+
//! [Rust #62569]: https://github.com/rust-lang/rust/issues/62569
+

+
#[cfg(unix)]
+
mod unix {
+
    /// A panicking process exits with code 101. A process killed by SIGPIPE
+
    /// shows an exit code of 141 (128 + 13). A clean exit is 0.
+
    /// Any of these except 101 is acceptable.
+
    mod broken_pipe {
+
        use std::io::Read;
+
        use std::process::{Command, Stdio};
+

+
        use radicle::profile;
+

+
        use crate::util::environment::Environment;
+

+
        #[ignore = "test fails"]
+
        #[test]
+
        fn rad_config_broken_pipe() {
+
            let mut environment = Environment::new();
+
            let profile = environment.profile("alice");
+

+
            let rad = env!("CARGO_BIN_EXE_rad");
+

+
            // Spawn `rad config` with stdout piped so we control it.
+
            let mut child = Command::new(rad)
+
                .arg("config")
+
                .env("RAD_HOME", profile.home.path())
+
                .env(profile::env::RAD_PASSPHRASE, "radicle")
+
                .stdout(Stdio::piped())
+
                .stderr(Stdio::piped())
+
                .spawn()
+
                .expect("failed to spawn rad");
+

+
            let mut stdout = child.stdout.take().unwrap();
+

+
            // Read just one byte, then drop stdout to close the pipe.
+
            // This simulates `head -1` closing the read end early.
+
            let mut buf = [0u8; 1];
+
            let _ = stdout.read(&mut buf);
+
            drop(stdout);
+

+
            let output = child.wait_with_output().expect("failed to wait on rad");
+

+
            // Capture stderr for diagnostics.
+
            let stderr = String::from_utf8_lossy(&output.stderr);
+

+
            // Exit code 101 is Rust's panic exit code — this must not happen.
+
            let code = output.status.code();
+

+
            assert!(
+
                code != Some(101),
+
                "rad panicked on broken pipe (exit code 101).\nstderr:\n{stderr}"
+
            );
+

+
            // Additionally, stderr should not contain panic messages.
+
            assert!(
+
                !stderr.contains("panicked at"),
+
                "rad panicked on broken pipe.\nstderr:\n{stderr}"
+
            );
+
        }
+

+
        /// `rad --help` exercises `println!` directly (via clap's help rendering),
+
        /// and not just `Element::print()`.
+
        #[test]
+
        fn rad_help() {
+
            let rad = env!("CARGO_BIN_EXE_rad");
+

+
            let mut child = Command::new(rad)
+
                .arg("--help")
+
                .stdout(Stdio::piped())
+
                .stderr(Stdio::piped())
+
                .spawn()
+
                .expect("failed to spawn rad");
+

+
            let mut stdout = child.stdout.take().unwrap();
+

+
            // Read a single byte and close.
+
            let mut buf = [0u8; 1];
+
            let _ = stdout.read(&mut buf);
+
            drop(stdout);
+

+
            let output = child.wait_with_output().expect("failed to wait on rad");
+
            let stderr = String::from_utf8_lossy(&output.stderr);
+
            let code = output.status.code();
+

+
            assert!(
+
                code != Some(101),
+
                "rad panicked on broken pipe (exit code 101).\nstderr:\n{stderr}"
+
            );
+
            assert!(
+
                !stderr.contains("panicked at"),
+
                "rad panicked on broken pipe.\nstderr:\n{stderr}"
+
            );
+
        }
+

+
        /// `rad self` uses `Element::print()` for table output.
+
        #[ignore = "test fails"]
+
        #[test]
+
        fn rad_self() {
+
            let mut environment = Environment::new();
+
            let profile = environment.profile("alice");
+

+
            let rad = env!("CARGO_BIN_EXE_rad");
+

+
            let mut child = Command::new(rad)
+
                .arg("self")
+
                .env("RAD_HOME", profile.home.path())
+
                .env(profile::env::RAD_PASSPHRASE, "radicle")
+
                .stdout(Stdio::piped())
+
                .stderr(Stdio::piped())
+
                .spawn()
+
                .expect("failed to spawn rad");
+

+
            let mut stdout = child.stdout.take().unwrap();
+
            let mut buf = [0u8; 1];
+
            let _ = stdout.read(&mut buf);
+
            drop(stdout);
+

+
            let output = child.wait_with_output().expect("failed to wait on rad");
+
            let stderr = String::from_utf8_lossy(&output.stderr);
+
            let code = output.status.code();
+

+
            assert!(
+
                code != Some(101),
+
                "rad panicked on broken pipe (exit code 101).\nstderr:\n{stderr}"
+
            );
+
            assert!(
+
                !stderr.contains("panicked at"),
+
                "rad panicked on broken pipe.\nstderr:\n{stderr}"
+
            );
+
        }
+
    }
+

+
    mod normal_pipe {
+
        use std::process::Command;
+

+
        use radicle::profile;
+

+
        use crate::util::environment::Environment;
+

+
        /// `rad config` produces valid output when stdout is NOT a broken pipe.
+
        #[test]
+
        fn rad_config() {
+
            let mut environment = Environment::new();
+
            let profile = environment.profile("alice");
+

+
            let rad = env!("CARGO_BIN_EXE_rad");
+

+
            let output = Command::new(rad)
+
                .arg("config")
+
                .env("RAD_HOME", profile.home.path())
+
                .env(profile::env::RAD_PASSPHRASE, "radicle")
+
                .output()
+
                .expect("failed to run rad");
+

+
            assert!(
+
                output.status.success(),
+
                "rad config failed: {}",
+
                String::from_utf8_lossy(&output.stderr)
+
            );
+

+
            let stdout = String::from_utf8_lossy(&output.stdout);
+
            assert!(
+
                stdout.contains("\"alias\""),
+
                "rad config output should contain alias"
+
            );
+
        }
+
    }
+
}