use std::io::{self, Read};
use std::os::unix::process::{CommandExt, ExitStatusExt};
use std::process::{Child, Command, ExitStatus, Stdio};
use std::sync::mpsc;
use std::sync::{Arc, Mutex, PoisonError};
use std::thread;
use std::time::{Duration, Instant};
use rustix::process::{Pid, Signal, kill_process_group};
use super::Outcome;
use super::workspace::Workspace;
pub(super) const LIMIT: Duration = Duration::from_secs(120);
const POLL: Duration = Duration::from_millis(50);
pub(super) const MAX_OUTPUT_BYTES: usize = 30 * 1024;
pub(super) const MAX_OUTPUT_LINES: usize = 400;
const KEEP_BYTES: usize = 256 * 1024;
const GRACE: Duration = Duration::from_secs(2);
enum Ending {
Exited(ExitStatus),
Cancelled,
TimedOut,
Lost(io::Error),
}
#[derive(Default)]
struct Captured {
bytes: Vec<u8>,
dropped: bool,
}
pub(super) fn run(workspace: &Workspace, command: &str, cancelled: &dyn Fn() -> bool, limit: Duration) -> Outcome {
if command.trim().is_empty() {
return Outcome::failed("`run_command` needs a non-empty `command`.".to_owned());
}
let (mut process, reader) = match start(workspace, command) {
Ok(started) => started,
Err(error) => {
return Outcome::failed(format!("The command could not be started: {error}."));
}
};
let captured = Arc::new(Mutex::new(Captured::default()));
let (closed, closed_signal) = mpsc::channel();
let sink = Arc::clone(&captured);
thread::spawn(move || {
drain(reader, &sink);
let _ = closed.send(());
});
let started = Instant::now();
let ending = loop {
match process.try_wait() {
Ok(Some(status)) => break Ending::Exited(status),
Ok(None) => {}
Err(error) => {
stop(&mut process);
break Ending::Lost(error);
}
}
if cancelled() {
stop(&mut process);
break Ending::Cancelled;
}
if started.elapsed() >= limit {
stop(&mut process);
break Ending::TimedOut;
}
thread::sleep(POLL);
};
if closed_signal.recv_timeout(GRACE).is_err() {
signal_group(process.id(), Signal::KILL);
let _ = closed_signal.recv_timeout(GRACE);
}
let captured = captured.lock().unwrap_or_else(PoisonError::into_inner);
let output = tail(&captured.bytes, captured.dropped);
match ending {
Ending::Exited(status) => Outcome::done(format!("{output}\n{}", describe(status))),
Ending::Cancelled => Outcome::done(format!("{output}\nStopped: the person cancelled the command.")),
Ending::TimedOut => {
Outcome::done(format!("{output}\nStopped: the command ran longer than {} seconds.", limit.as_secs()))
}
Ending::Lost(error) => {
Outcome::failed(format!("{output}\nThe command was stopped because its state could not be read: {error}."))
}
}
}
fn start(workspace: &Workspace, command: &str) -> io::Result<(Child, io::PipeReader)> {
let (reader, writer) = io::pipe()?;
let error_writer = writer.try_clone()?;
let mut shell = Command::new("sh");
shell
.arg("-c")
.arg(command)
.current_dir(workspace.root())
.stdin(Stdio::null())
.stdout(writer)
.stderr(error_writer)
.process_group(0);
let child = shell.spawn()?;
drop(shell);
Ok((child, reader))
}
fn drain(mut reader: io::PipeReader, sink: &Mutex<Captured>) {
let mut buffer = [0_u8; 8192];
loop {
match reader.read(&mut buffer) {
Ok(0) | Err(_) => return,
Ok(read) => {
let mut captured = sink.lock().unwrap_or_else(PoisonError::into_inner);
captured.bytes.extend_from_slice(&buffer[..read]);
if captured.bytes.len() > KEEP_BYTES {
let excess = captured.bytes.len() - KEEP_BYTES;
captured.bytes.drain(..excess);
captured.dropped = true;
}
}
}
}
}
fn stop(process: &mut Child) {
signal_group(process.id(), Signal::TERM);
let deadline = Instant::now() + GRACE;
while Instant::now() < deadline {
if matches!(process.try_wait(), Ok(Some(_))) {
return;
}
thread::sleep(POLL);
}
signal_group(process.id(), Signal::KILL);
let _ = process.kill();
let _ = process.wait();
}
fn signal_group(group: u32, signal: Signal) {
let Some(group) = i32::try_from(group).ok().and_then(Pid::from_raw) else { return };
let _ = kill_process_group(group, signal);
}
fn describe(status: ExitStatus) -> String {
match (status.code(), status.signal()) {
(Some(code), _) => format!("Exit code: {code}."),
(None, Some(signal)) => format!("Ended by signal {signal}."),
(None, None) => "Ended without an exit code.".to_owned(),
}
}
pub(super) fn tail(bytes: &[u8], dropped: bool) -> String {
let start = bytes.len().saturating_sub(MAX_OUTPUT_BYTES);
let text = String::from_utf8_lossy(&bytes[start..]);
let lines: Vec<&str> = text.lines().collect();
if lines.is_empty() {
return "(no output)".to_owned();
}
let keep = lines.len().min(MAX_OUTPUT_LINES);
let cut = dropped || start > 0 || keep < lines.len();
let shown = lines[lines.len() - keep..].join("\n");
if cut { format!("(Output cut: only the last {keep} lines are shown.)\n{shown}") } else { shown }
}