use super::config::ToolDefinition;
use super::lookup;
use std::collections::HashMap;
use std::ffi::OsStr;
use std::io::{Read, Write};
use std::process::{Command, Stdio};
use std::sync::{Arc, LazyLock, Mutex};
use std::thread;
use std::time::{Duration, Instant};
const TIMEOUT_LIMIT: u32 = 3;
static TIMEOUT_COUNTS: LazyLock<Arc<Mutex<HashMap<String, u32>>>> =
LazyLock::new(|| Arc::new(Mutex::new(HashMap::new())));
#[derive(Debug, Clone)]
pub struct ToolOutput {
pub stdout: String,
pub stderr: String,
pub exit_code: i32,
pub success: bool,
}
#[derive(Debug, Clone)]
pub enum ExecutorError {
ToolNotFound { tool: String },
ExecutionFailed { tool: String, message: String },
Timeout { tool: String, timeout_ms: u64 },
RepeatedTimeouts {
tool: String,
timeout_ms: u64,
timeouts: u32,
},
IoError { message: String },
}
impl std::fmt::Display for ExecutorError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::ToolNotFound { tool } => {
write!(f, "Tool '{tool}' not found in PATH")
}
Self::ExecutionFailed { tool, message } => {
write!(f, "Tool '{tool}' failed: {message}")
}
Self::Timeout { tool, timeout_ms } => {
write!(f, "Tool '{tool}' timed out after {timeout_ms}ms")
}
Self::RepeatedTimeouts {
tool,
timeout_ms,
timeouts,
} => {
write!(
f,
"Tool '{tool}' skipped after timing out {timeouts} times at {timeout_ms}ms; a tool that never exits is usually not reading its stdin"
)
}
Self::IoError { message } => {
write!(f, "I/O error: {message}")
}
}
}
}
impl std::error::Error for ExecutorError {}
pub struct ToolExecutor {
tool_cache: Arc<Mutex<HashMap<String, bool>>>,
timeout_counts: Arc<Mutex<HashMap<String, u32>>>,
default_timeout_ms: u64,
}
impl ToolExecutor {
pub fn new(default_timeout_ms: u64) -> Self {
Self {
tool_cache: Arc::new(Mutex::new(HashMap::new())),
timeout_counts: Arc::clone(&TIMEOUT_COUNTS),
default_timeout_ms,
}
}
pub fn isolated(default_timeout_ms: u64) -> Self {
Self {
tool_cache: Arc::new(Mutex::new(HashMap::new())),
timeout_counts: Arc::new(Mutex::new(HashMap::new())),
default_timeout_ms,
}
}
fn timeout_count(&self, tool_name: &str) -> u32 {
self.timeout_counts.lock().unwrap().get(tool_name).copied().unwrap_or(0)
}
fn record_timeout(&self, tool_name: &str) {
*self
.timeout_counts
.lock()
.unwrap()
.entry(tool_name.to_string())
.or_insert(0) += 1;
}
fn clear_timeouts(&self, tool_name: &str) {
self.timeout_counts.lock().unwrap().remove(tool_name);
}
pub fn is_tool_available(&self, tool_name: &str) -> bool {
{
let cache = self.tool_cache.lock().unwrap();
if let Some(&available) = cache.get(tool_name) {
return available;
}
}
let available = self.check_tool_exists(tool_name);
{
let mut cache = self.tool_cache.lock().unwrap();
cache.insert(tool_name.to_string(), available);
}
available
}
fn check_tool_exists(&self, tool_name: &str) -> bool {
lookup::resolve_program(OsStr::new(tool_name), std::env::var_os("PATH").as_deref()).is_some()
}
pub fn execute(
&self,
tool_def: &ToolDefinition,
input: &str,
is_format_mode: bool,
timeout_ms: Option<u64>,
) -> Result<ToolOutput, ExecutorError> {
if tool_def.command.is_empty() {
return Err(ExecutorError::ExecutionFailed {
tool: "unknown".to_string(),
message: "Empty command".to_string(),
});
}
let tool_name = &tool_def.command[0];
if !self.is_tool_available(tool_name) {
return Err(ExecutorError::ToolNotFound {
tool: tool_name.clone(),
});
}
let effective_timeout_ms = timeout_ms.unwrap_or(self.default_timeout_ms);
let timeouts = self.timeout_count(tool_name);
if timeouts >= TIMEOUT_LIMIT {
return Err(ExecutorError::RepeatedTimeouts {
tool: tool_name.clone(),
timeout_ms: effective_timeout_ms,
timeouts,
});
}
let mut cmd = Command::new(tool_name);
if tool_def.command.len() > 1 {
cmd.args(&tool_def.command[1..]);
}
let extra_args = if is_format_mode {
&tool_def.format_args
} else {
&tool_def.lint_args
};
if !extra_args.is_empty() {
cmd.args(extra_args);
}
if tool_def.stdin {
cmd.stdin(Stdio::piped());
}
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
let mut child = cmd.spawn().map_err(|e| ExecutorError::IoError {
message: format!("Failed to spawn '{tool_name}': {e}"),
})?;
let mut stdout_handle = child
.stdout
.take()
.map(|stdout| thread::spawn(move || read_pipe_to_string(stdout)));
let mut stderr_handle = child
.stderr
.take()
.map(|stderr| thread::spawn(move || read_pipe_to_string(stderr)));
if tool_def.stdin
&& let Some(mut stdin) = child.stdin.take()
&& let Err(e) = stdin.write_all(input.as_bytes())
&& e.kind() != std::io::ErrorKind::BrokenPipe
{
return Err(ExecutorError::IoError {
message: format!("Failed to write to stdin: {e}"),
});
}
let timeout = Duration::from_millis(effective_timeout_ms);
let status = if timeout.is_zero() {
child.wait().map_err(|e| ExecutorError::IoError {
message: format!("Failed to wait for '{tool_name}': {e}"),
})?
} else {
let start = Instant::now();
loop {
if let Some(status) = child.try_wait().map_err(|e| ExecutorError::IoError {
message: format!("Failed to poll '{tool_name}': {e}"),
})? {
break status;
}
if start.elapsed() >= timeout {
let _ = child.kill();
let _ = child.wait();
drop(stdout_handle.take());
drop(stderr_handle.take());
self.record_timeout(tool_name);
return Err(ExecutorError::Timeout {
tool: tool_name.clone(),
timeout_ms: timeout.as_millis() as u64,
});
}
thread::sleep(Duration::from_millis(10));
}
};
self.clear_timeouts(tool_name);
let stdout = join_reader(stdout_handle.take()).map_err(|e| ExecutorError::IoError { message: e })?;
let stderr = join_reader(stderr_handle.take()).map_err(|e| ExecutorError::IoError { message: e })?;
let exit_code = status.code().unwrap_or(-1);
Ok(ToolOutput {
stdout,
stderr,
exit_code,
success: status.success(),
})
}
pub fn format(
&self,
tool_def: &ToolDefinition,
input: &str,
timeout_ms: Option<u64>,
) -> Result<String, ExecutorError> {
let output = self.execute(tool_def, input, true, timeout_ms)?;
if output.success && tool_def.stdout {
Ok(output.stdout)
} else if !output.success {
let exit_code = output.exit_code;
let stderr = &output.stderr;
Err(ExecutorError::ExecutionFailed {
tool: tool_def.command.first().cloned().unwrap_or_default(),
message: format!("Exit code {exit_code}: {stderr}"),
})
} else {
Err(ExecutorError::ExecutionFailed {
tool: tool_def.command.first().cloned().unwrap_or_default(),
message: "Formatter doesn't output to stdout".to_string(),
})
}
}
pub fn lint(
&self,
tool_def: &ToolDefinition,
input: &str,
timeout_ms: Option<u64>,
) -> Result<ToolOutput, ExecutorError> {
self.execute(tool_def, input, false, timeout_ms)
}
}
fn read_pipe_to_string<R: Read>(mut pipe: R) -> std::io::Result<String> {
let mut buf = Vec::new();
pipe.read_to_end(&mut buf)?;
Ok(String::from_utf8_lossy(&buf).to_string())
}
fn join_reader(handle: Option<thread::JoinHandle<std::io::Result<String>>>) -> Result<String, String> {
match handle {
Some(handle) => match handle.join() {
Ok(res) => res.map_err(|e| format!("Failed to read output: {e}")),
Err(_) => Err("Output reader thread panicked".to_string()),
},
None => Ok(String::new()),
}
}
impl Default for ToolExecutor {
fn default() -> Self {
Self::new(30_000) }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_executor_creation() {
let executor = ToolExecutor::new(10_000);
assert_eq!(executor.default_timeout_ms, 10_000);
}
#[test]
fn test_tool_not_found() {
let executor = ToolExecutor::default();
let tool_def = ToolDefinition {
command: vec!["nonexistent-tool-xyz123".to_string()],
stdin: true,
stdout: true,
lint_args: vec![],
format_args: vec![],
};
let result = executor.execute(&tool_def, "test", false, None);
assert!(matches!(result, Err(ExecutorError::ToolNotFound { .. })));
}
#[test]
fn test_empty_command() {
let executor = ToolExecutor::default();
let tool_def = ToolDefinition {
command: vec![],
stdin: true,
stdout: true,
lint_args: vec![],
format_args: vec![],
};
let result = executor.execute(&tool_def, "test", false, None);
assert!(matches!(result, Err(ExecutorError::ExecutionFailed { .. })));
}
#[test]
#[cfg(unix)]
fn test_execute_cat() {
let executor = ToolExecutor::isolated(30_000);
let tool_def = ToolDefinition {
command: vec!["cat".to_string()],
stdin: true,
stdout: true,
lint_args: vec![],
format_args: vec![],
};
let result = executor.execute(&tool_def, "hello world", false, None);
let output = result.expect("cat should succeed");
assert!(output.success);
assert_eq!(output.stdout.trim(), "hello world");
}
#[test]
#[cfg(unix)]
fn test_timeout() {
let executor = ToolExecutor::isolated(5);
let tool_def = ToolDefinition {
command: vec!["sleep".to_string(), "1".to_string()],
stdin: false,
stdout: true,
lint_args: vec![],
format_args: vec![],
};
let result = executor.execute(&tool_def, "", false, Some(5));
assert!(matches!(result, Err(ExecutorError::Timeout { .. })));
}
#[cfg(unix)]
fn descendant_holds_stdout_tool() -> ToolDefinition {
ToolDefinition {
command: vec![
"sh".to_string(),
"-c".to_string(),
"sleep 30 & exec sleep 30".to_string(),
],
stdin: true,
stdout: true,
lint_args: vec![],
format_args: vec![],
}
}
#[test]
#[cfg(unix)]
fn test_timeout_bounds_execution_when_a_descendant_holds_stdout() {
let executor = ToolExecutor::isolated(200);
let (tx, rx) = std::sync::mpsc::channel();
thread::spawn(move || {
let started = Instant::now();
let result = executor.execute(&descendant_holds_stdout_tool(), "input", true, Some(200));
let _ = tx.send((started.elapsed(), result));
});
let (elapsed, result) = rx
.recv_timeout(Duration::from_secs(10))
.expect("execute() did not return: the timeout bounded nothing");
assert!(
matches!(result, Err(ExecutorError::Timeout { .. })),
"expected a timeout, got {result:?}"
);
assert!(elapsed < Duration::from_secs(10), "execute() took {elapsed:?}");
}
#[test]
#[cfg(unix)]
fn test_a_hanging_tool_is_skipped_after_repeated_timeouts() {
let executor = ToolExecutor::isolated(50);
let tool_def = ToolDefinition {
command: vec!["sh".to_string(), "-c".to_string(), "exec sleep 30".to_string()],
stdin: true,
stdout: true,
lint_args: vec![],
format_args: vec![],
};
for attempt in 1..=TIMEOUT_LIMIT {
let result = executor.execute(&tool_def, "input", true, Some(50));
assert!(
matches!(result, Err(ExecutorError::Timeout { .. })),
"attempt {attempt} should time out, got {result:?}"
);
}
let started = Instant::now();
let result = executor.execute(&tool_def, "input", true, Some(50));
match result {
Err(ExecutorError::RepeatedTimeouts {
timeouts, timeout_ms, ..
}) => {
assert_eq!(timeouts, TIMEOUT_LIMIT);
assert_eq!(timeout_ms, 50);
}
other => panic!("expected the tool to be skipped, got {other:?}"),
}
assert!(
started.elapsed() < Duration::from_millis(50),
"skipping still took {:?}",
started.elapsed()
);
}
#[test]
#[cfg(unix)]
fn test_exiting_normally_clears_earlier_timeouts() {
let executor = ToolExecutor::isolated(50);
let hangs = ToolDefinition {
command: vec!["sh".to_string(), "-c".to_string(), "exec sleep 30".to_string()],
stdin: true,
stdout: true,
lint_args: vec![],
format_args: vec![],
};
let exits = ToolDefinition {
command: vec!["sh".to_string(), "-c".to_string(), "cat".to_string()],
stdin: true,
stdout: true,
lint_args: vec![],
format_args: vec![],
};
for _ in 0..TIMEOUT_LIMIT - 1 {
assert!(matches!(
executor.execute(&hangs, "input", true, Some(50)),
Err(ExecutorError::Timeout { .. })
));
}
assert_eq!(executor.timeout_count("sh"), TIMEOUT_LIMIT - 1);
let output = executor.execute(&exits, "hello", true, None).expect("cat should exit");
assert_eq!(output.stdout.trim(), "hello");
assert_eq!(executor.timeout_count("sh"), 0, "a clean exit must clear the tally");
}
#[test]
fn test_the_shared_tally_carries_across_executors() {
let key = "rumdl-test-only-shared-tally-probe";
let first = ToolExecutor::new(50);
let second = ToolExecutor::new(50);
let alone = ToolExecutor::isolated(50);
let before = second.timeout_count(key);
first.record_timeout(key);
assert_eq!(
second.timeout_count(key),
before + 1,
"executors built for different files must share one tally"
);
assert_eq!(alone.timeout_count(key), 0, "an isolated executor keeps its own tally");
first.clear_timeouts(key);
assert_eq!(second.timeout_count(key), 0);
}
}