use super::{ToolExecError, truncate_tool_output};
use crossbeam_channel;
use std::{
io::Read,
os::fd::{AsFd, AsRawFd, OwnedFd},
os::unix::process::CommandExt,
path::{Path, PathBuf},
process::{Command, Output},
sync::mpsc,
time::Duration,
};
use tracing::{debug, trace, warn};
const DRAIN_POLL_MS: i32 = 100;
pub(crate) fn binary_exists(name: &str) -> bool {
std::env::var_os("PATH")
.map(|path| std::env::split_paths(&path).any(|dir| dir.join(name).is_file()))
.unwrap_or(false)
}
pub(crate) fn resolve_workdir(workdir: Option<&str>, working_dir: Option<&Path>) -> PathBuf {
super::resolve_path(workdir.unwrap_or("."), working_dir)
}
pub(crate) fn sanitize_env(cmd: &mut Command) {
for var in &[
"LD_PRELOAD",
"LD_LIBRARY_PATH",
"LD_AUDIT",
"LD_DEBUG",
"PYTHONPATH",
"PERL5LIB",
"RUBYLIB",
"DYLD_INSERT_LIBRARIES",
] {
cmd.env_remove(var);
}
}
fn setup_child(cmd: &mut Command) {
sanitize_env(cmd);
cmd.process_group(0);
}
#[cfg(target_os = "linux")]
fn open_pidfd(pid: u32) -> Option<OwnedFd> {
let pid = rustix::process::Pid::from_raw(pid as i32)?;
match rustix::process::pidfd_open(pid, rustix::process::PidfdFlags::empty()) {
Ok(fd) => Some(fd),
Err(e) => {
trace!(pid = pid.as_raw_pid(), error = %e, "pidfd_open unavailable; timeout kills fall back to PID-based killpg");
None
}
}
}
#[cfg(not(target_os = "linux"))]
fn open_pidfd(_pid: u32) -> Option<OwnedFd> {
None
}
#[cfg_attr(not(target_os = "linux"), allow(unused_variables))]
fn kill_child_tree(pid: u32, pidfd: Option<&OwnedFd>) -> bool {
let Some(pid) = rustix::process::Pid::from_raw(pid as i32) else {
debug!(raw_pid = pid, "refusing to signal pid 0");
return false;
};
#[cfg(target_os = "linux")]
if let Some(fd) = pidfd {
match rustix::process::pidfd_send_signal(fd, rustix::process::Signal::KILL) {
Ok(()) => {
let _ = rustix::process::kill_process_group(pid, rustix::process::Signal::KILL);
return true;
}
Err(rustix::io::Errno::SRCH) => {
debug!(
pid = pid.as_raw_pid(),
"pidfd_send_signal: child already exited on its own"
);
return false;
}
Err(e) => {
warn!(
pid = pid.as_raw_pid(),
error = %e,
"pidfd_send_signal failed; falling back to leader-checked killpg"
);
}
}
}
let is_group_leader = rustix::process::getpgid(Some(pid))
.map(|pgid| pgid == pid)
.unwrap_or(false);
if is_group_leader
&& rustix::process::kill_process_group(pid, rustix::process::Signal::KILL).is_ok()
{
return true;
}
rustix::process::kill_process(pid, rustix::process::Signal::KILL).is_ok()
}
fn spawn_watchdog(
timeout_ms: u64,
pid: u32,
pidfd: Option<OwnedFd>,
done_rx: mpsc::Receiver<()>,
killed_tx: mpsc::Sender<()>,
) -> std::thread::JoinHandle<()> {
std::thread::spawn(move || {
if done_rx
.recv_timeout(Duration::from_millis(timeout_ms))
.is_err()
&& kill_child_tree(pid, pidfd.as_ref())
{
warn!(
pid,
timeout_ms, "shell tool timed out; killed child process group"
);
let _ = killed_tx.send(());
}
})
}
fn poll_readable(
fd: rustix::fd::BorrowedFd<'_>,
stop_rx: &mpsc::Receiver<()>,
poll_ms: i32,
) -> bool {
let mut pfds = [rustix::event::PollFd::new(
&fd,
rustix::event::PollFlags::IN,
)];
let timeout = rustix::event::Timespec {
tv_sec: i64::from(poll_ms / 1000),
tv_nsec: i64::from(poll_ms % 1000) * 1_000_000,
};
loop {
match rustix::event::poll(&mut pfds, Some(&timeout)) {
Ok(0) => {
if stop_rx.try_recv().is_ok() {
return false;
}
continue;
}
Ok(_) => return true,
Err(rustix::io::Errno::INTR) => continue,
Err(e) => {
debug!(error = %e, "poll on child pipe failed; stopping drain");
return false;
}
}
}
}
fn drain_fd<R: Read + AsFd>(
mut reader: R,
stop_rx: mpsc::Receiver<()>,
poll_ms: i32,
on_data: &mut dyn FnMut(&[u8]),
) -> Vec<u8> {
let fd = reader.as_fd().as_raw_fd();
let nonblocking = set_nonblocking(reader.as_fd());
if !nonblocking {
debug!(
fd,
"could not set child pipe non-blocking; draining one chunk per poll"
);
}
let mut full: Vec<u8> = Vec::new();
let mut buf = [0u8; 8192];
loop {
if !poll_readable(reader.as_fd(), &stop_rx, poll_ms) {
break;
}
loop {
match reader.read(&mut buf) {
Ok(0) => return full, Ok(n) => {
on_data(&buf[..n]);
full.extend_from_slice(&buf[..n]);
if !nonblocking {
break; }
}
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => break, Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {
if !nonblocking {
break; }
continue;
}
Err(e) => {
debug!(error = %e, "read from child pipe failed; stopping drain");
return full;
}
}
}
}
full
}
fn set_nonblocking(fd: rustix::fd::BorrowedFd<'_>) -> bool {
match rustix::fs::fcntl_getfl(fd) {
Ok(flags) => rustix::fs::fcntl_setfl(fd, flags | rustix::fs::OFlags::NONBLOCK).is_ok(),
Err(_) => false,
}
}
fn forward_complete_lines(chunk: &[u8], pending: &mut Vec<u8>, on_line: &mut dyn FnMut(Vec<u8>)) {
for &b in chunk {
if b == b'\n' && pending.last() == Some(&b'\r') {
pending.pop();
}
pending.push(b);
if b == b'\n' {
let line = std::mem::take(pending);
on_line(line);
}
}
}
pub(crate) fn spawn_with_watchdog(
cmd: &mut Command,
timeout_ms: u64,
) -> Result<(Output, bool), ToolExecError> {
setup_child(cmd);
let mut child = cmd.spawn()?;
let pid = child.id();
let pidfd = open_pidfd(pid);
let (done_tx, done_rx) = mpsc::channel::<()>();
let (killed_tx, killed_rx) = mpsc::channel::<()>();
let watchdog = spawn_watchdog(timeout_ms, pid, pidfd, done_rx, killed_tx);
let (out_stop_tx, out_stop_rx) = mpsc::channel::<()>();
let (err_stop_tx, err_stop_rx) = mpsc::channel::<()>();
let stdout_thread = child
.stdout
.take()
.map(|s| std::thread::spawn(move || drain_fd(s, out_stop_rx, DRAIN_POLL_MS, &mut |_| {})));
let stderr_thread = child
.stderr
.take()
.map(|s| std::thread::spawn(move || drain_fd(s, err_stop_rx, DRAIN_POLL_MS, &mut |_| {})));
let status = child.wait()?;
let _ = done_tx.send(());
let _ = out_stop_tx.send(());
let _ = err_stop_tx.send(());
if let Err(e) = watchdog.join() {
warn!("watchdog thread panicked: {:?}", e);
}
let stdout = stdout_thread
.and_then(|t| t.join().ok())
.unwrap_or_default();
let stderr = stderr_thread
.and_then(|t| t.join().ok())
.unwrap_or_default();
let was_killed = killed_rx.try_recv().is_ok();
Ok((
Output {
stdout,
stderr,
status,
},
was_killed,
))
}
pub fn spawn_with_streaming(
cmd: &mut Command,
timeout_ms: u64,
output_tx: crossbeam_channel::Sender<Vec<u8>>,
) -> Result<(Output, bool), ToolExecError> {
setup_child(cmd);
let mut child = cmd.spawn()?;
let pid = child.id();
let pidfd = open_pidfd(pid);
let stdout = child
.stdout
.take()
.ok_or_else(|| std::io::Error::other("stdout not piped"))?;
let stderr = child
.stderr
.take()
.ok_or_else(|| std::io::Error::other("stderr not piped"))?;
let (out_stop_tx, out_stop_rx) = mpsc::channel::<()>();
let (err_stop_tx, err_stop_rx) = mpsc::channel::<()>();
let stdout_thread = std::thread::spawn(move || {
let mut pending: Vec<u8> = Vec::new();
let full = drain_fd(stdout, out_stop_rx, DRAIN_POLL_MS, &mut |chunk: &[u8]| {
forward_complete_lines(chunk, &mut pending, &mut |line| {
let _ = output_tx.send(line);
});
});
if !pending.is_empty() {
let _ = output_tx.send(pending);
}
full
});
let stderr_thread =
std::thread::spawn(move || drain_fd(stderr, err_stop_rx, DRAIN_POLL_MS, &mut |_| {}));
let (done_tx, done_rx) = mpsc::channel::<()>();
let (killed_tx, killed_rx) = mpsc::channel::<()>();
let watchdog = spawn_watchdog(timeout_ms, pid, pidfd, done_rx, killed_tx);
let status = child.wait()?;
let _ = done_tx.send(());
let _ = out_stop_tx.send(());
let _ = err_stop_tx.send(());
let stdout_buf = match stdout_thread.join() {
Ok(buf) => buf,
Err(e) => {
warn!("stdout reader thread panicked: {:?}", e);
Vec::new()
}
};
let stderr_buf = stderr_thread.join().unwrap_or_default();
if let Err(e) = watchdog.join() {
warn!("watchdog thread panicked: {:?}", e);
}
let was_killed = killed_rx.try_recv().is_ok();
Ok((
Output {
stdout: stdout_buf,
stderr: stderr_buf,
status,
},
was_killed,
))
}
pub fn run_shell_streaming(
cmd: &mut Command,
display_cmd: &str,
timeout_ms: u64,
output_tx: crossbeam_channel::Sender<Vec<u8>>,
) -> Result<String, ToolExecError> {
let (output, was_killed) = spawn_with_streaming(cmd, timeout_ms, output_tx)?;
Ok(format_shell_output(
display_cmd,
&output,
timeout_ms,
was_killed,
))
}
pub(crate) fn format_shell_output(
display_cmd: &str,
output: &Output,
timeout_ms: u64,
was_killed: bool,
) -> String {
if was_killed {
return truncate_tool_output(&format!(
"$ {display_cmd}\n\n[command timed out after {timeout_ms}ms]\n\nExit code: -1"
));
}
let mut combined = output.stdout.clone();
combined.extend_from_slice(&output.stderr);
let combined_str = String::from_utf8_lossy(&combined);
let exit_code = output.status.code().unwrap_or(-1);
truncate_tool_output(&format!(
"$ {display_cmd}\n{combined_str}\n\nExit code: {exit_code}"
))
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::{BufRead, BufReader, Write};
use std::os::unix::process::ExitStatusExt;
use std::process::Stdio;
struct ReapOnDrop(std::process::Child);
impl Drop for ReapOnDrop {
fn drop(&mut self) {
let _ = self.0.kill();
let _ = self.0.wait();
}
}
#[test]
fn setup_child_places_child_in_its_own_process_group() {
let mut cmd = std::process::Command::new("sh");
cmd.args(["-c", "echo $$; while :; do :; done"])
.stdout(Stdio::piped());
setup_child(&mut cmd);
let mut child = cmd.spawn().expect("spawn child");
let stdout = child.stdout.take().expect("take stdout");
let _reap = ReapOnDrop(child);
let mut reader = BufReader::new(stdout);
let mut pid_line = String::new();
reader.read_line(&mut pid_line).expect("read child pid");
let child_pid = pid_line.trim().parse::<i32>().expect("parse child pid");
let pgid = rustix::process::getpgid(Some(
rustix::process::Pid::from_raw(child_pid).expect("parsed child pid is nonzero"),
))
.expect("getpgid on live child");
assert_eq!(
pgid.as_raw_pid(),
child_pid,
"child must be leader of its own process group"
);
}
#[test]
fn kill_child_tree_falls_back_to_direct_kill_when_not_group_leader() {
let mut cmd = std::process::Command::new("sh");
cmd.args(["-c", "exec sleep 30"]);
let child = cmd.spawn().expect("spawn child");
let pid = child.id();
let mut _reap = ReapOnDrop(child);
assert!(
kill_child_tree(pid, None),
"direct-kill fallback must reap a non-leader child"
);
assert!(!_reap.0.wait().expect("wait on killed child").success());
}
#[test]
#[cfg(target_os = "linux")]
fn kill_child_tree_kills_group_through_pinned_pidfd() {
let mut cmd = std::process::Command::new("sh");
cmd.args(["-c", "sleep 30"]).stdout(Stdio::piped());
setup_child(&mut cmd);
let child = cmd.spawn().expect("spawn child");
let pid = child.id();
let mut _reap = ReapOnDrop(child);
let Some(pidfd) = open_pidfd(pid) else {
eprintln!("pidfd_open unavailable; skipping pinned-pidfd test");
return;
};
assert!(
kill_child_tree(pid, Some(&pidfd)),
"pinned pidfd kill must reap a leader child"
);
assert!(!_reap.0.wait().expect("wait on killed child").success());
}
#[test]
fn drain_fd_reads_to_eof_without_stop_signal() {
let (reader, mut writer) = std::io::pipe().expect("pipe");
writer.write_all(b"line1\nline2\n").expect("write");
drop(writer);
let (_stop_tx, stop_rx) = mpsc::channel::<()>();
let got = drain_fd(reader, stop_rx, 0, &mut |_| {});
assert_eq!(got, b"line1\nline2\n");
}
#[test]
fn drain_fd_captures_output_larger_than_one_chunk() {
let (reader, mut writer) = std::io::pipe().expect("pipe");
let payload = vec![b'x'; 20 * 1024];
writer.write_all(&payload).expect("write");
drop(writer);
let (_stop_tx, stop_rx) = mpsc::channel::<()>();
let got = drain_fd(reader, stop_rx, 0, &mut |_| {});
assert_eq!(got, payload);
}
#[test]
fn drain_fd_stops_when_signalled_even_with_open_writer() {
let (reader, mut writer) = std::io::pipe().expect("pipe");
writer.write_all(b"hello").expect("write");
let (stop_tx, stop_rx) = mpsc::channel::<()>();
stop_tx.send(()).expect("signal stop");
let got = drain_fd(reader, stop_rx, 0, &mut |_| {});
assert_eq!(
got, b"hello",
"buffered data must be drained before stopping"
);
drop(writer);
}
#[test]
fn forward_complete_lines_splits_lines_and_folds_crlf() {
let mut pending: Vec<u8> = Vec::new();
let mut lines: Vec<Vec<u8>> = Vec::new();
forward_complete_lines(b"a\r\nb", &mut pending, &mut |l| lines.push(l));
assert_eq!(lines, vec![b"a\n".to_vec()]);
assert_eq!(pending, b"b");
forward_complete_lines(b"\nc", &mut pending, &mut |l| lines.push(l));
assert_eq!(lines, vec![b"a\n".to_vec(), b"b\n".to_vec()]);
assert_eq!(pending, b"c");
if !pending.is_empty() {
lines.push(std::mem::take(&mut pending));
}
assert_eq!(lines, vec![b"a\n".to_vec(), b"b\n".to_vec(), b"c".to_vec()]);
}
#[test]
fn format_shell_output_was_killed_shows_timeout() {
let output = Output {
stdout: b"some output".to_vec(),
stderr: b"".to_vec(),
status: std::process::ExitStatus::from_raw(0),
};
let result = format_shell_output("sleep 10", &output, 5000, true);
assert!(result.contains("timed out after 5000ms"));
assert!(result.contains("Exit code: -1"));
}
#[test]
fn format_shell_output_not_killed_shows_exit_code() {
let output = Output {
stdout: b"hello\nworld".to_vec(),
stderr: b"".to_vec(),
status: std::process::ExitStatus::from_raw(0),
};
let result = format_shell_output("echo hello", &output, 5000, false);
assert!(!result.contains("timed out"));
assert!(result.contains("hello"));
assert!(result.contains("world"));
assert!(result.contains("Exit code: 0"));
}
#[test]
fn format_shell_output_includes_stderr() {
let output = Output {
stdout: b"stdout".to_vec(),
stderr: b"stderr".to_vec(),
status: std::process::ExitStatus::from_raw(1 << 8),
};
let result = format_shell_output("cmd", &output, 1000, false);
assert!(result.contains("stdout"));
assert!(result.contains("stderr"));
assert!(result.contains("Exit code: 1"));
}
}