//! Bounded, cancellable shell execution with owned-process-tree termination //! (`SMH-SPEC-SPEC0001`, Tools). //! //! Pipes are drained by dedicated threads so the deadline loop is never //! blocked by a silent child; per-stream caps keep the fitting prefix and //! bound all collection, including post-exit draining. use smith::error::{Result, SmithError}; use smith::tool::CancelHandle; use std::io::Read; use std::process::{Child, Command, ExitStatus, Stdio}; use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::mpsc; use std::time::{Duration, Instant}; const READ_CHUNK: usize = 16 * 1024; const GRACE_DRAIN: Duration = Duration::from_millis(500); const POLL_SLICE: Duration = Duration::from_millis(25); /// Result of a bounded shell execution. #[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)] pub struct BashResult { /// Exit status; `None` when killed by timeout, cancellation, or signal. pub exit_code: Option, /// Stdout, capped with the fitting prefix retained. pub stdout: String, /// Stderr, capped with the fitting prefix retained. pub stderr: String, /// Cancellation was requested. pub cancelled: bool, /// The deadline elapsed and the process tree was killed. pub timed_out: bool, /// Wall-clock duration in ms. pub duration_ms: u64, } /// Run `cmd` under `bash -c` in `workdir` with a hard deadline, per-stream /// output caps, and a shared cancellation handle. /// /// # Errors /// /// Returns [`SmithError::Tool`] when the process cannot be spawned. pub fn execute( cmd: &str, workdir: Option<&str>, timeout_ms: u64, max_output_bytes: usize, cancel: &CancelHandle, ) -> Result { let started = Instant::now(); let deadline = started + Duration::from_millis(timeout_ms.max(1)); let mut child = spawn(cmd, workdir)?; let (tx, rx) = mpsc::channel::<(Stream, Vec)>(); let stdout_remaining = Arc::new(AtomicUsize::new(max_output_bytes)); let stderr_remaining = Arc::new(AtomicUsize::new(max_output_bytes)); let handles = drain_pipes( &mut child, &tx, Arc::clone(&stdout_remaining), Arc::clone(&stderr_remaining), ); drop(tx); // reader threads hold the only senders let mut stdout: Vec = Vec::new(); let mut stderr: Vec = Vec::new(); let mut retain = |(stream, chunk): (Stream, Vec)| match stream { Stream::Stdout => stdout.extend_from_slice(&chunk), Stream::Stderr => stderr.extend_from_slice(&chunk), }; let mut status: Option = None; let mut cancelled = false; let mut timed_out = false; while status.is_none() { if cancel.is_cancelled() { cancelled = true; kill_tree(&mut child); status = child.wait().ok(); break; } let now = Instant::now(); if now >= deadline { timed_out = true; kill_tree(&mut child); status = child.wait().ok(); break; } let wait = POLL_SLICE.min(deadline - now); // A timeout or closed pipes fall through to polling the child, so // cancellation and the deadline stay live while it lingers. Closed // pipes return at once; pace the poll so the wait never spins. match rx.recv_timeout(wait) { Ok(item) => retain(item), Err(mpsc::RecvTimeoutError::Timeout) => {} Err(mpsc::RecvTimeoutError::Disconnected) => std::thread::sleep(wait), } if let Ok(Some(st)) = child.try_wait() { status = Some(st); } } // Bounded post-exit drain: retain late output until pipes close or the // grace window expires. Threads only ever send capped prefixes. let grace_end = Instant::now() + GRACE_DRAIN; while let Ok(item) = rx.recv_timeout(grace_end.saturating_duration_since(Instant::now())) { retain(item); } for handle in handles { let _ = handle.join(); } Ok(BashResult { exit_code: status.as_ref().and_then(ExitStatus::code), stdout: String::from_utf8_lossy(&stdout).into_owned(), stderr: String::from_utf8_lossy(&stderr).into_owned(), cancelled, timed_out, duration_ms: u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX), }) } #[derive(Clone, Copy)] enum Stream { Stdout, Stderr, } fn spawn(cmd: &str, workdir: Option<&str>) -> Result { let mut command = Command::new("bash"); command .arg("-c") .arg(cmd) .stdout(Stdio::piped()) .stderr(Stdio::piped()); if let Some(dir) = workdir { command.current_dir(dir); } #[cfg(unix)] { use std::os::unix::process::CommandExt; // Own the process tree: the child becomes a group leader so the // deadline path can signal every descendant. command.process_group(0); } command.spawn().map_err(|e| SmithError::Tool { code: "BASH_SPAWN".to_string(), message: e.to_string(), }) } /// Kill the whole owned process group where the platform supports it, /// falling back to the direct child. fn kill_tree(child: &mut Child) { #[cfg(unix)] { use nix::sys::signal::{Signal, killpg}; use nix::unistd::Pid; let pgid = Pid::from_raw(i32::try_from(child.id()).unwrap_or(i32::MAX)); let _ = killpg(pgid, Signal::SIGKILL); } let _ = child.kill(); } /// Spawn one reader thread per pipe. Each thread forwards capped prefixes /// (never more than `remaining` permits) and keeps draining to EOF so a /// chatty descendant can never deadlock the parent. fn drain_pipes( child: &mut Child, tx: &mpsc::Sender<(Stream, Vec)>, stdout_remaining: Arc, stderr_remaining: Arc, ) -> Vec> { let mut handles = Vec::new(); if let Some(out) = child.stdout.take() { handles.push(spawn_reader( out, tx.clone(), Stream::Stdout, stdout_remaining, )); } if let Some(err) = child.stderr.take() { handles.push(spawn_reader( err, tx.clone(), Stream::Stderr, stderr_remaining, )); } handles } /// Forward capped prefixes from one pipe until it closes. fn spawn_reader( mut pipe: R, tx: mpsc::Sender<(Stream, Vec)>, stream: Stream, remaining: Arc, ) -> std::thread::JoinHandle<()> { std::thread::spawn(move || { let mut buf = vec![0u8; READ_CHUNK]; loop { match pipe.read(&mut buf) { // End of pipe or a read failure both end this reader. Ok(0) | Err(_) => break, Ok(n) => { let keep = n.min(remaining.load(Ordering::Relaxed)); if keep > 0 { if tx.send((stream, buf[..keep].to_vec())).is_err() { break; } remaining.fetch_sub(keep, Ordering::Relaxed); } // Beyond the cap we keep reading (without retaining) so // the pipe never back-pressures the child indefinitely. } } } }) } #[cfg(test)] #[cfg(unix)] #[expect( clippy::unwrap_used, reason = "tests may panic on invariant violations" )] mod tests { use super::*; use crate::process_signal::{ProcessSignal, closed}; use std::net::TcpStream; use std::thread; fn run(cmd: &str, timeout_ms: u64, cap: usize) -> BashResult { execute(cmd, None, timeout_ms, cap, &CancelHandle::new()).unwrap() } #[test] fn completes_and_captures_output() { let res = run("printf 'hello'", 5_000, 1024); assert_eq!(res.exit_code, Some(0)); assert_eq!(res.stdout, "hello"); assert!(!res.timed_out && !res.cancelled); } #[test] fn timeout_kills_silent_child_promptly() { let res = run("sleep 30", 400, 1024); assert!(res.timed_out); assert_eq!(res.exit_code, None); // keep-with-deadline: time is the contract; wide margin over the 400ms timeout. assert!(res.duration_ms < 2_000, "took {}ms", res.duration_ms); } #[test] fn stderr_heavy_child_cannot_deadlock_the_deadline() { let res = run("while true; do echo noise >&2; done", 400, 4096); assert!(res.timed_out); // keep-with-deadline: time is the contract; wide margin over the 400ms timeout. assert!(res.duration_ms < 2_000, "took {}ms", res.duration_ms); } /// Run `script` (built around the signal's raise command) with a 30 s /// timeout, cancel it once it raises the signal, and return its result /// within the signal deadline together with the raising connection. fn cancel_once_raised(script: impl FnOnce(&str) -> String) -> (BashResult, TcpStream) { let cancel = CancelHandle::new(); let signal = ProcessSignal::new(); let script = script(&signal.raise()); let (done, finished) = mpsc::channel(); { let cancel = cancel.clone(); thread::spawn(move || { let _ = done.send(execute(&script, None, 30_000, 1024, &cancel).unwrap()); }); } // synchronize: cancel once the command reports it runs, never after a guessed delay. let socket = signal.wait(); cancel.cancel(); let res = finished .recv_timeout(Duration::from_secs(10)) .expect("cancelled command returned within the deadline"); (res, socket) } #[test] fn cancellation_stops_execution_with_pipes_open_or_closed() { for redirect in ["", "exec >&- 2>&-; "] { let (res, mut socket) = cancel_once_raised(|raise| format!("{redirect}{raise}; sleep 30")); assert!(res.cancelled && !res.timed_out, "{redirect:?}"); assert!( closed(&mut socket), "cancelled command kept running: {redirect:?}" ); } } /// User plus system CPU clock ticks spent by the calling thread. #[cfg(target_os = "linux")] fn thread_cpu_ticks() -> u64 { let stat = std::fs::read_to_string("/proc/thread-self/stat").unwrap(); // Fields after the parenthesised command name; utime and stime are // the 14th and 15th fields of the whole line. let fields: Vec<&str> = stat .rsplit_once(')') .unwrap() .1 .split_whitespace() .collect(); fields[11].parse::().unwrap() + fields[12].parse::().unwrap() } #[cfg(target_os = "linux")] #[test] fn waiting_on_a_command_that_closed_its_pipes_does_not_spin() { let before = thread_cpu_ticks(); let res = run("exec >&- 2>&-; sleep 30", 500, 1024); let spent = thread_cpu_ticks() - before; assert!(res.timed_out); // A spinning wait burns the whole 500 ms (≈50 ticks at 100 Hz). assert!(spent < 10, "waiting thread burned {spent} CPU ticks"); } #[test] fn output_written_after_exit_is_drained_within_the_grace_window() { // The background writer waits until the exited shell is reaped, so // its output can only arrive after the main loop has ended. let res = run( "(while kill -0 $$ 2>/dev/null; do :; done; printf late) & exit 0", 5_000, 1024, ); assert_eq!(res.exit_code, Some(0)); assert_eq!(res.stdout, "late"); } #[test] fn reader_forwards_whole_chunks_up_to_the_cap_and_nothing_beyond() { const CHUNK: usize = 16 * 1024; let (tx, rx) = mpsc::channel(); let remaining = Arc::new(AtomicUsize::new(CHUNK + 1)); spawn_reader( std::io::Cursor::new(vec![b'x'; 3 * CHUNK]), tx, Stream::Stdout, Arc::clone(&remaining), ) .join() .unwrap(); let forwarded: Vec = rx.iter().map(|(_, chunk)| chunk.len()).collect(); assert_eq!(forwarded, vec![CHUNK, 1]); assert_eq!(remaining.load(Ordering::Relaxed), 0); } #[test] fn output_cap_keeps_fitting_prefix() { let res = run("printf 'abcdefghij'", 5_000, 5); assert_eq!(res.stdout, "abcde"); } #[test] fn high_volume_output_is_not_throttled() { // keep-with-deadline: the command's own timeout is the throughput bound. let res = run("head -c 300000 /dev/zero | tr '\\0' 'x'", 3_000, 1 << 20); assert!( !res.timed_out, "throughput regression: {}ms", res.duration_ms ); assert_eq!(res.stdout.len(), 300_000); } #[test] fn descendant_processes_are_killed_with_the_tree() { // Only the background descendant holds the connection. let (res, mut socket) = cancel_once_raised(|raise| format!("({raise}; sleep 30) & sleep 30")); assert!(res.cancelled); assert!(closed(&mut socket), "descendant survived the tree kill"); } }