use super::types::{Harness, HarnessName, HarnessRunConfig, HarnessRunResult};
use miette::{Result, miette};
use std::io::Write;
use std::process::{Command, Stdio};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::thread;
use std::time::{Duration, Instant};
pub const DEFAULT_INACTIVITY_TIMEOUT: Duration = Duration::from_secs(15 * 60);
pub trait CliHarness: std::fmt::Debug {
fn harness_name(&self) -> HarnessName;
fn binary(&self) -> &str;
fn build_args(&self, config: &HarnessRunConfig) -> Vec<String>;
}
impl<T: CliHarness> Harness for T {
fn name(&self) -> HarnessName {
self.harness_name()
}
fn run(&mut self, config: &HarnessRunConfig) -> Result<HarnessRunResult> {
let args = self.build_args(config);
run_streaming_cli(self.binary(), &args, config)
}
fn stop(&mut self) {
}
fn streams_output(&self) -> bool {
true
}
}
enum StreamTarget {
Stdout,
Stderr,
}
fn run_streaming_cli(
binary: &str,
args: &[String],
config: &HarnessRunConfig,
) -> Result<HarnessRunResult> {
let mut cmd = Command::new(binary);
cmd.args(args);
cmd.current_dir(&config.cwd);
cmd.envs(&config.env);
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
let start = Instant::now();
let mut child = cmd
.spawn()
.map_err(|e| miette!("Failed to spawn {binary}: {e}"))?;
let child_id = child.id();
let stdout_pipe = child.stdout.take();
let stderr_pipe = child.stderr.take();
let last_activity = Arc::new(std::sync::Mutex::new(Instant::now()));
let timed_out = Arc::new(AtomicBool::new(false));
let done = Arc::new(AtomicBool::new(false));
let last_activity_stdout = Arc::clone(&last_activity);
let stdout_handle = thread::spawn(move || {
stream_pipe(stdout_pipe, &last_activity_stdout, StreamTarget::Stdout)
});
let last_activity_stderr = Arc::clone(&last_activity);
let stderr_handle = thread::spawn(move || {
stream_pipe(stderr_pipe, &last_activity_stderr, StreamTarget::Stderr)
});
let timeout = config
.inactivity_timeout
.unwrap_or(DEFAULT_INACTIVITY_TIMEOUT);
let last_activity_monitor = Arc::clone(&last_activity);
let timed_out_monitor = Arc::clone(&timed_out);
let done_monitor = Arc::clone(&done);
let monitor_handle = thread::spawn(move || {
monitor_timeout(
child_id,
timeout,
&last_activity_monitor,
&timed_out_monitor,
&done_monitor,
)
});
let status = child
.wait()
.map_err(|e| miette!("Failed to wait for {binary}: {e}"))?;
done.store(true, Ordering::SeqCst);
let stdout = stdout_handle.join().unwrap_or_default();
let stderr = stderr_handle.join().unwrap_or_default();
let _ = monitor_handle.join();
let duration = start.elapsed();
let was_timed_out = timed_out.load(Ordering::SeqCst);
let exit_code = if was_timed_out {
-1
} else {
exit_code_from_status(&status)
};
Ok(HarnessRunResult {
stdout,
stderr,
exit_code,
duration,
timed_out: was_timed_out,
})
}
fn exit_code_from_status(status: &std::process::ExitStatus) -> i32 {
if let Some(code) = status.code() {
return code;
}
#[cfg(unix)]
{
use std::os::unix::process::ExitStatusExt;
if let Some(signal) = status.signal() {
return 128 + signal;
}
}
1
}
fn stream_pipe(
pipe: Option<impl std::io::Read>,
last_activity: &std::sync::Mutex<Instant>,
target: StreamTarget,
) -> String {
let mut collected = String::new();
let Some(mut pipe) = pipe else {
return collected;
};
let mut buf = [0u8; 4096];
let mut leftover = Vec::new();
loop {
let n = match pipe.read(&mut buf) {
Ok(0) => break,
Ok(n) => n,
Err(_err) => break,
};
if let Ok(mut last) = last_activity.lock() {
*last = Instant::now();
}
let data = if leftover.is_empty() {
&buf[..n]
} else {
leftover.extend_from_slice(&buf[..n]);
leftover.as_slice()
};
let (valid, remaining) = match std::str::from_utf8(data) {
Ok(s) => (s, &[][..]),
Err(e) => {
let valid_up_to = e.valid_up_to();
let valid = unsafe { std::str::from_utf8_unchecked(&data[..valid_up_to]) };
(valid, &data[valid_up_to..])
}
};
if !valid.is_empty() {
match target {
StreamTarget::Stdout => {
print!("{valid}");
let _ = std::io::stdout().flush();
}
StreamTarget::Stderr => {
eprint!("{valid}");
let _ = std::io::stderr().flush();
}
}
collected.push_str(valid);
}
leftover = remaining.to_vec();
}
if !leftover.is_empty() {
let tail = String::from_utf8_lossy(&leftover);
match target {
StreamTarget::Stdout => {
print!("{tail}");
let _ = std::io::stdout().flush();
}
StreamTarget::Stderr => {
eprint!("{tail}");
let _ = std::io::stderr().flush();
}
}
collected.push_str(&tail);
}
collected
}
fn monitor_timeout(
child_id: u32,
timeout: Duration,
last_activity: &std::sync::Mutex<Instant>,
timed_out: &AtomicBool,
done: &AtomicBool,
) {
let check_interval = Duration::from_secs(1);
loop {
thread::sleep(check_interval);
if done.load(Ordering::SeqCst) {
break;
}
let elapsed = match last_activity.lock() {
Ok(last) => last.elapsed(),
Err(_poisoned) => break,
};
if elapsed >= timeout {
eprintln!(
"\n=== Inactivity timeout ({:?}) reached, killing process... ===\n",
timeout
);
timed_out.store(true, Ordering::SeqCst);
#[cfg(unix)]
{
let _ = std::process::Command::new("kill")
.args(["-9", &child_id.to_string()])
.status();
}
#[cfg(windows)]
{
let _ = std::process::Command::new("taskkill")
.args(["/F", "/PID", &child_id.to_string()])
.status();
}
break;
}
}
}