#[cfg(unix)] use std::{ ffi::OsString, io::{self, Read}, os::{ fd::OwnedFd, unix::{net::UnixStream, process::CommandExt}, }, process::{Child, Command, Stdio}, sync::mpsc::{self, RecvTimeoutError}, thread, time::Duration, }; use std::{ path::PathBuf, sync::mpsc::{Receiver, Sender, TryRecvError}, thread::JoinHandle, }; #[cfg(unix)] const OUTPUT_LIMIT: usize = 4 * 1024 * 1024; #[cfg(unix)] const TRUNCATION_NOTE: &[u8] = b"\n[vamp: output truncated after 4 MiB]\n"; #[derive(Debug)] pub(crate) struct Output { pub(crate) code: Option, pub(crate) stdout: Vec, pub(crate) stderr: Vec, } pub(crate) struct Worker { cancel: Sender<()>, results: Receiver>, thread: Option>, finished: bool, } impl Worker { pub(crate) fn start(command: String, root: PathBuf) -> Result { #[cfg(unix)] { let shell = std::env::var_os("SHELL") .filter(|shell| !shell.is_empty()) .unwrap_or_else(|| "/bin/sh".into()); Self::start_with_shell(command, root, shell) } #[cfg(not(unix))] { let _ = (command, root); Err("Shell commands require Unix process-group cancellation on this platform".into()) } } #[cfg(unix)] fn start_with_shell(command: String, root: PathBuf, shell: OsString) -> Result { let (cancel, cancelled) = mpsc::channel(); let (send, results) = mpsc::channel(); let thread = thread::Builder::new() .name("vamp-shell".into()) .spawn(move || { let _ = send.send(run(command, root, shell, cancelled)); }) .map_err(|error| format!("Cannot start shell worker: {error}"))?; Ok(Self { cancel, results, thread: Some(thread), finished: false, }) } pub(crate) fn poll(&mut self) -> Option> { if self.finished { return None; } let result = match self.results.try_recv() { Ok(output) => output, Err(TryRecvError::Empty) => return None, Err(TryRecvError::Disconnected) => Err("Shell worker stopped unexpectedly".into()), }; self.finished = true; Some(result) } } impl Drop for Worker { fn drop(&mut self) { let _ = self.cancel.send(()); if let Some(thread) = self.thread.take() { let _ = thread.join(); } } } #[cfg(unix)] struct Process(Child); #[cfg(unix)] impl Drop for Process { fn drop(&mut self) { unsafe extern "C" { fn kill(pid: i32, signal: i32) -> i32; } // The shell and its descendants share a private process group; killing // only the leader would leave children holding the output descriptors. unsafe { kill(-(self.0.id() as i32), 9) }; let _ = self.0.kill(); let _ = self.0.wait(); } } #[cfg(unix)] struct Capture { reader: UnixStream, bytes: Vec, truncated: bool, eof: bool, } #[cfg(unix)] impl Capture { fn new() -> Result<(Self, Stdio), String> { let (reader, writer) = UnixStream::pair() .map_err(|error| format!("Cannot create shell output stream: {error}"))?; reader .set_nonblocking(true) .map_err(|error| format!("Cannot configure shell output stream: {error}"))?; Ok(( Self { reader, bytes: Vec::new(), truncated: false, eof: false, }, Stdio::from(OwnedFd::from(writer)), )) } fn read(&mut self) -> io::Result { if self.eof { return Ok(false); } let mut chunk = [0; 16 * 1024]; match self.reader.read(&mut chunk) { Ok(0) => { self.eof = true; Ok(false) } Ok(count) => { let keep = count.min(OUTPUT_LIMIT.saturating_sub(self.bytes.len())); self.bytes.extend_from_slice(&chunk[..keep]); self.truncated |= keep != count; Ok(true) } Err(error) if error.kind() == io::ErrorKind::WouldBlock => Ok(false), Err(error) if error.kind() == io::ErrorKind::Interrupted => Ok(true), Err(error) => Err(error), } } fn finish(mut self) -> Vec { if self.truncated { self.bytes.extend_from_slice(TRUNCATION_NOTE); } self.bytes } } #[cfg(unix)] fn run( command: String, root: PathBuf, shell: OsString, cancelled: Receiver<()>, ) -> Result { if cancelled.try_recv().is_ok() { return Err("Shell command cancelled".into()); } let (mut stdout, stdout_writer) = Capture::new()?; let (mut stderr, stderr_writer) = Capture::new()?; let child = Command::new(shell) .arg("-c") .arg(command) .current_dir(root) .stdin(Stdio::null()) .stdout(stdout_writer) .stderr(stderr_writer) .process_group(0) .spawn() .map_err(|error| format!("Cannot start shell command: {error}"))?; let mut process = Some(Process(child)); let mut status = None; loop { if cancelled.try_recv().is_ok() { return Err("Shell command cancelled".into()); } // Read each stream once per turn so a busy writer cannot starve the // other stream or cancellation. Overflow is still drained, not retained. let out = stdout .read() .map_err(|error| format!("Cannot read shell stdout: {error}"))?; let err = stderr .read() .map_err(|error| format!("Cannot read shell stderr: {error}"))?; if let Some(child) = process.as_mut() { status = child .0 .try_wait() .map_err(|error| format!("Cannot wait for shell command: {error}"))?; if status.is_some() { // Background jobs must not outlive the command. Killing the // remaining group also closes inherited output descriptors. drop(process.take()); } } if let Some(status) = status && stdout.eof && stderr.eof { return Ok(Output { code: status.code(), stdout: stdout.finish(), stderr: stderr.finish(), }); } if !out && !err { match cancelled.recv_timeout(Duration::from_millis(10)) { Ok(()) | Err(RecvTimeoutError::Disconnected) => { return Err("Shell command cancelled".into()); } Err(RecvTimeoutError::Timeout) => {} } } } } #[cfg(all(test, unix))] #[path = "../tests/unit/shell.rs"] mod tests;