patchloom 0.37.2

Structured file editing library and CLI for AI agents: parser-backed JSON/YAML/TOML edits, AST-aware code operations, multi-file batching, markdown operations, and MCP server
Documentation
//! Shell command execution with proper process tree management.
//!
//! Provides platform-correct process spawning and cleanup:
//!
//! - **Unix**: spawns in a dedicated process group via `process_group(0)`,
//!   kills with `killpg(PGID, SIGKILL)` to catch compound commands.
//! - **Windows**: uses `taskkill /F /T /PID` to kill the entire process tree.
//!
//! The [`kill_process_tree`] function is the key correctness primitive: bare
//! `child.kill()` only terminates the shell process, leaving grandchildren
//! (pipelines, `&&` chains) alive as orphans.

use std::path::Path;
use std::sync::mpsc;
use std::time::Duration;

const POLL_INTERVAL: Duration = Duration::from_millis(10);

/// Maximum bytes of stderr captured from a shell command.
pub const STDERR_CAPTURE_MAX: usize = 512;

/// Build a platform-appropriate shell command.
///
/// - **Unix**: `sh -c <cmd>` with `process_group(0)` for clean signal delivery.
/// - **Windows**: `cmd /C <cmd>` with `raw_arg` for correct escaping.
pub fn shell_command(cmd: &str, cwd: &Path) -> std::process::Command {
    #[cfg(windows)]
    {
        use std::os::windows::process::CommandExt;
        let mut command = std::process::Command::new("cmd");
        // Use raw_arg so cmd.exe receives the command string without Rust's
        // MSVC-style argument quoting. Rust's .arg() wraps in double quotes
        // and escapes inner quotes with backslash, which cmd.exe does not
        // understand. raw_arg passes the string verbatim, letting cmd's /C
        // handler parse redirects (>), pipes (|), and inner quotes correctly.
        command.arg("/C").raw_arg(cmd).current_dir(cwd);
        command
    }
    #[cfg(not(windows))]
    {
        use std::os::unix::process::CommandExt;
        let mut command = std::process::Command::new("sh");
        command.arg("-c").arg(cmd).current_dir(cwd);
        // Spawn the child in its own process group so that
        // kill_process_tree can kill the entire group.
        command.process_group(0);
        command
    }
}

/// Result of a shell command execution with captured stderr.
#[derive(Debug)]
pub struct ShellResult {
    /// Exit status of the process.
    pub status: std::process::ExitStatus,
    /// First [`STDERR_CAPTURE_MAX`] bytes of stderr output (truncated for safety).
    pub stderr_head: String,
}

/// Run a shell command with a timeout (seconds).
///
/// Captures up to [`STDERR_CAPTURE_MAX`] bytes of stderr. Stdout is
/// discarded. Kills the process tree on timeout.
pub fn run_with_timeout(cmd: &str, timeout_secs: u64, cwd: &Path) -> anyhow::Result<ShellResult> {
    let mut child = shell_command(cmd, cwd)
        .stdout(std::process::Stdio::null())
        .stderr(std::process::Stdio::piped())
        .spawn()?;

    // Read stderr on another thread so a full pipe cannot stall the child.
    // The result arrives on a channel so the caller can stop waiting at the
    // deadline instead of joining forever when a descendant still holds the
    // write end (`sleep 30 >&2 &`).
    let stderr_handle = child.stderr.take().expect("stderr piped");
    let (stderr_tx, stderr_rx) = mpsc::channel();
    std::thread::spawn(move || {
        use std::io::Read;
        let mut buf = vec![0u8; STDERR_CAPTURE_MAX + 1];
        let mut reader = stderr_handle;
        let mut total = 0;
        loop {
            match reader.read(&mut buf[total..]) {
                Ok(0) => break,
                Ok(n) => {
                    total += n;
                    if total > STDERR_CAPTURE_MAX {
                        // Drain remaining stderr so the child is not blocked.
                        let mut discard = [0u8; 4096];
                        while reader.read(&mut discard).unwrap_or(0) > 0 {}
                        break;
                    }
                }
                Err(_) => break,
            }
        }
        let cap = total.min(STDERR_CAPTURE_MAX);
        let text = String::from_utf8_lossy(&buf[..cap]).to_string();
        let text = if total > STDERR_CAPTURE_MAX {
            format!("{text}... (truncated)")
        } else {
            text
        };
        let _ = stderr_tx.send(text);
    });

    let deadline = std::time::Instant::now() + Duration::from_secs(timeout_secs);
    let mut status = None;
    // Stderr EOF is not process exit. A command can close fd 2 and keep
    // running (`exec python3 -c 'os.close(2); sleep'`). Waiting here would
    // drop the deadline.
    let mut stderr_head: Option<String> = None;
    loop {
        if std::time::Instant::now() >= deadline {
            kill_process_tree(&mut child);
            let stderr_head = stderr_head.unwrap_or_else(|| {
                stderr_rx
                    .recv_timeout(Duration::from_millis(500))
                    .unwrap_or_default()
            });
            anyhow::bail!("timed out after {timeout_secs}s: {stderr_head}");
        }
        if status.is_none() {
            status = child.try_wait()?;
        }
        // Both must be done. A shell can exit while a grandchild still
        // holds stderr, and a process can close stderr and keep running.
        if status.is_some() && stderr_head.is_some() {
            let status = status.take().expect("checked is_some");
            let stderr_head = stderr_head.take().expect("checked is_some");
            return Ok(ShellResult {
                status,
                stderr_head,
            });
        }
        if stderr_head.is_none() {
            match stderr_rx.recv_timeout(POLL_INTERVAL) {
                Ok(text) => stderr_head = Some(text),
                Err(mpsc::RecvTimeoutError::Timeout) => {}
                Err(mpsc::RecvTimeoutError::Disconnected) => {
                    stderr_head = Some(String::new());
                }
            }
        } else {
            std::thread::sleep(POLL_INTERVAL);
        }
    }
}

/// Kill a child process and all its descendants.
///
/// On Unix, the child is spawned in its own process group (via
/// `process_group(0)`), so we send SIGKILL to the entire group with
/// `libc::killpg`. This ensures compound commands (pipelines, `&&`
/// chains) have all descendants killed, not just the immediate `sh`
/// process.
///
/// On Windows, `child.kill()` calls `TerminateProcess` which only kills
/// the immediate `cmd.exe` process, leaving grandchildren running as
/// orphans. We use `taskkill /F /T /PID` to kill the entire process tree.
pub fn kill_process_tree(child: &mut std::process::Child) {
    #[cfg(windows)]
    {
        let pid = child.id();
        let _ = std::process::Command::new("taskkill")
            .args(["/F", "/T", "/PID", &pid.to_string()])
            .stdout(std::process::Stdio::null())
            .stderr(std::process::Stdio::null())
            .status();
    }
    #[cfg(not(windows))]
    {
        // The child was spawned in its own process group, so its PID
        // equals its PGID. Kill the entire group.
        let pid = child.id() as i32;
        // `Child::id()` stays the spawned pid after `try_wait` reaps the
        // child. Skip a zero id only: `killpg(0, SIGKILL)` would signal
        // our own process group.
        if pid > 0 {
            // SAFETY: killpg is a POSIX function that sends a signal to a
            // process group. We pass the child's PID (which is also its
            // PGID due to process_group(0)) and SIGKILL (9).
            #[expect(unsafe_code)]
            unsafe {
                libc::killpg(pid, libc::SIGKILL);
            }
        }
    }
    let _ = child.wait();
}

#[cfg(test)]
mod tests {
    use super::*;
    #[test]
    fn shell_command_sets_cwd() {
        let dir = tempfile::TempDir::new().unwrap();
        let cmd = shell_command("echo hello", dir.path());
        assert_eq!(cmd.get_current_dir(), Some(dir.path()));
    }

    #[test]
    fn run_with_timeout_captures_exit_status() {
        let dir = tempfile::TempDir::new().unwrap();
        let result = run_with_timeout("exit 0", 5, dir.path()).unwrap();
        assert!(result.status.success());
    }

    #[test]
    fn run_with_timeout_captures_nonzero_exit() {
        let dir = tempfile::TempDir::new().unwrap();
        let result = run_with_timeout("exit 42", 5, dir.path()).unwrap();
        assert!(!result.status.success());
    }

    #[test]
    fn run_with_timeout_captures_stderr() {
        let dir = tempfile::TempDir::new().unwrap();
        let result = run_with_timeout("echo oops >&2", 5, dir.path()).unwrap();
        assert!(result.stderr_head.contains("oops"));
    }

    #[test]
    #[cfg(not(windows))]
    fn run_with_timeout_does_not_hang_when_background_child_holds_stderr() {
        let dir = tempfile::TempDir::new().unwrap();
        let started = std::time::Instant::now();
        let result = run_with_timeout("sleep 30 >&2 &", 1, dir.path());
        assert!(
            started.elapsed() < Duration::from_secs(5),
            "background stderr holder hung the timeout"
        );
        let err = result.unwrap_err().to_string();
        assert!(
            err.contains("timed out"),
            "expected timeout error, got: {err}"
        );
    }

    #[test]
    #[cfg(not(windows))]
    fn run_with_timeout_kills_process_that_closes_stderr() {
        let dir = tempfile::TempDir::new().unwrap();
        let started = std::time::Instant::now();
        let result = run_with_timeout(
            "exec python3 -c 'import os,time; os.close(2); time.sleep(30)'",
            1,
            dir.path(),
        );
        assert!(
            started.elapsed() < Duration::from_secs(5),
            "closing stderr must not disable the timeout"
        );
        let err = result.unwrap_err().to_string();
        assert!(err.contains("timed out"), "expected timeout, got: {err}");
    }

    #[test]
    fn run_with_timeout_kills_on_timeout() {
        let dir = tempfile::TempDir::new().unwrap();
        let result = run_with_timeout("sleep 60", 1, dir.path());
        let err = result.unwrap_err().to_string();
        assert!(
            err.contains("timed out"),
            "expected timeout error, got: {err}"
        );
    }

    #[test]
    fn kill_process_tree_handles_already_exited_child() {
        let dir = tempfile::TempDir::new().unwrap();
        let mut child = shell_command("exit 0", dir.path()).spawn().unwrap();
        // Wait for the child to exit naturally.
        let _ = child.wait();
        // Calling kill_process_tree on an already-exited child should not panic.
        kill_process_tree(&mut child);
    }

    #[test]
    fn shell_result_is_send_and_sync() {
        const _: () = {
            fn _assert<T: Send + Sync>() {}
            let _ = _assert::<ShellResult>;
        };
    }

    #[test]
    fn run_with_timeout_truncates_large_stderr() {
        let dir = tempfile::TempDir::new().unwrap();
        // Generate stderr output much larger than STDERR_CAPTURE_MAX (512 bytes).
        // Use printf to emit 800 'X' characters to stderr.
        let result = run_with_timeout("printf '%0800d' 0 >&2", 5, dir.path()).unwrap();
        // The captured stderr should be at most STDERR_CAPTURE_MAX bytes
        // plus the "... (truncated)" suffix.
        assert!(
            result.stderr_head.len() <= STDERR_CAPTURE_MAX + 20,
            "stderr should be truncated, got {} bytes",
            result.stderr_head.len()
        );
        assert!(
            result.stderr_head.contains("(truncated)"),
            "expected truncation marker, got: {}",
            &result.stderr_head[..result.stderr_head.len().min(100)]
        );
    }
}