1use super::config::ToolDefinition;
7use std::collections::HashMap;
8use std::io::{Read, Write};
9use std::process::{Command, Stdio};
10use std::sync::{Arc, LazyLock, Mutex};
11use std::thread;
12use std::time::{Duration, Instant};
13
14const TIMEOUT_LIMIT: u32 = 3;
20
21static TIMEOUT_COUNTS: LazyLock<Arc<Mutex<HashMap<String, u32>>>> =
28 LazyLock::new(|| Arc::new(Mutex::new(HashMap::new())));
29
30#[derive(Debug, Clone)]
32pub struct ToolOutput {
33 pub stdout: String,
35 pub stderr: String,
37 pub exit_code: i32,
39 pub success: bool,
41}
42
43#[derive(Debug, Clone)]
45pub enum ExecutorError {
46 ToolNotFound { tool: String },
48 ExecutionFailed { tool: String, message: String },
50 Timeout { tool: String, timeout_ms: u64 },
52 RepeatedTimeouts {
54 tool: String,
55 timeout_ms: u64,
56 timeouts: u32,
57 },
58 IoError { message: String },
60}
61
62impl std::fmt::Display for ExecutorError {
63 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64 match self {
65 Self::ToolNotFound { tool } => {
66 write!(f, "Tool '{tool}' not found in PATH")
67 }
68 Self::ExecutionFailed { tool, message } => {
69 write!(f, "Tool '{tool}' failed: {message}")
70 }
71 Self::Timeout { tool, timeout_ms } => {
72 write!(f, "Tool '{tool}' timed out after {timeout_ms}ms")
73 }
74 Self::RepeatedTimeouts {
75 tool,
76 timeout_ms,
77 timeouts,
78 } => {
79 write!(
80 f,
81 "Tool '{tool}' skipped after timing out {timeouts} times at {timeout_ms}ms; a tool that never exits is usually not reading its stdin"
82 )
83 }
84 Self::IoError { message } => {
85 write!(f, "I/O error: {message}")
86 }
87 }
88 }
89}
90
91impl std::error::Error for ExecutorError {}
92
93pub struct ToolExecutor {
97 tool_cache: Arc<Mutex<HashMap<String, bool>>>,
99 timeout_counts: Arc<Mutex<HashMap<String, u32>>>,
101 default_timeout_ms: u64,
103}
104
105impl ToolExecutor {
106 pub fn new(default_timeout_ms: u64) -> Self {
111 Self {
112 tool_cache: Arc::new(Mutex::new(HashMap::new())),
113 timeout_counts: Arc::clone(&TIMEOUT_COUNTS),
114 default_timeout_ms,
115 }
116 }
117
118 pub fn isolated(default_timeout_ms: u64) -> Self {
124 Self {
125 tool_cache: Arc::new(Mutex::new(HashMap::new())),
126 timeout_counts: Arc::new(Mutex::new(HashMap::new())),
127 default_timeout_ms,
128 }
129 }
130
131 fn timeout_count(&self, tool_name: &str) -> u32 {
133 self.timeout_counts.lock().unwrap().get(tool_name).copied().unwrap_or(0)
134 }
135
136 fn record_timeout(&self, tool_name: &str) {
138 *self
139 .timeout_counts
140 .lock()
141 .unwrap()
142 .entry(tool_name.to_string())
143 .or_insert(0) += 1;
144 }
145
146 fn clear_timeouts(&self, tool_name: &str) {
148 self.timeout_counts.lock().unwrap().remove(tool_name);
149 }
150
151 pub fn is_tool_available(&self, tool_name: &str) -> bool {
153 {
155 let cache = self.tool_cache.lock().unwrap();
156 if let Some(&available) = cache.get(tool_name) {
157 return available;
158 }
159 }
160
161 let available = self.check_tool_exists(tool_name);
163
164 {
166 let mut cache = self.tool_cache.lock().unwrap();
167 cache.insert(tool_name.to_string(), available);
168 }
169
170 available
171 }
172
173 fn check_tool_exists(&self, tool_name: &str) -> bool {
175 #[cfg(unix)]
176 {
177 Command::new("which")
178 .arg(tool_name)
179 .stdout(Stdio::null())
180 .stderr(Stdio::null())
181 .status()
182 .is_ok_and(|s| s.success())
183 }
184
185 #[cfg(windows)]
186 {
187 Command::new("where")
188 .arg(tool_name)
189 .stdout(Stdio::null())
190 .stderr(Stdio::null())
191 .status()
192 .is_ok_and(|s| s.success())
193 }
194
195 #[cfg(not(any(unix, windows)))]
196 {
197 let _ = tool_name;
199 false
200 }
201 }
202
203 pub fn execute(
214 &self,
215 tool_def: &ToolDefinition,
216 input: &str,
217 is_format_mode: bool,
218 timeout_ms: Option<u64>,
219 ) -> Result<ToolOutput, ExecutorError> {
220 if tool_def.command.is_empty() {
221 return Err(ExecutorError::ExecutionFailed {
222 tool: "unknown".to_string(),
223 message: "Empty command".to_string(),
224 });
225 }
226
227 let tool_name = &tool_def.command[0];
228
229 if !self.is_tool_available(tool_name) {
231 return Err(ExecutorError::ToolNotFound {
232 tool: tool_name.clone(),
233 });
234 }
235
236 let effective_timeout_ms = timeout_ms.unwrap_or(self.default_timeout_ms);
242 let timeouts = self.timeout_count(tool_name);
243 if timeouts >= TIMEOUT_LIMIT {
244 return Err(ExecutorError::RepeatedTimeouts {
245 tool: tool_name.clone(),
246 timeout_ms: effective_timeout_ms,
247 timeouts,
248 });
249 }
250
251 let mut cmd = Command::new(tool_name);
253
254 if tool_def.command.len() > 1 {
256 cmd.args(&tool_def.command[1..]);
257 }
258
259 let extra_args = if is_format_mode {
261 &tool_def.format_args
262 } else {
263 &tool_def.lint_args
264 };
265 if !extra_args.is_empty() {
266 cmd.args(extra_args);
267 }
268
269 if tool_def.stdin {
271 cmd.stdin(Stdio::piped());
272 }
273 cmd.stdout(Stdio::piped());
274 cmd.stderr(Stdio::piped());
275
276 let mut child = cmd.spawn().map_err(|e| ExecutorError::IoError {
278 message: format!("Failed to spawn '{tool_name}': {e}"),
279 })?;
280
281 let mut stdout_handle = child
282 .stdout
283 .take()
284 .map(|stdout| thread::spawn(move || read_pipe_to_string(stdout)));
285 let mut stderr_handle = child
286 .stderr
287 .take()
288 .map(|stderr| thread::spawn(move || read_pipe_to_string(stderr)));
289
290 if tool_def.stdin
294 && let Some(mut stdin) = child.stdin.take()
295 && let Err(e) = stdin.write_all(input.as_bytes())
296 && e.kind() != std::io::ErrorKind::BrokenPipe
297 {
298 return Err(ExecutorError::IoError {
299 message: format!("Failed to write to stdin: {e}"),
300 });
301 }
302
303 let timeout = Duration::from_millis(effective_timeout_ms);
305 let status = if timeout.is_zero() {
306 child.wait().map_err(|e| ExecutorError::IoError {
307 message: format!("Failed to wait for '{tool_name}': {e}"),
308 })?
309 } else {
310 let start = Instant::now();
311 loop {
312 if let Some(status) = child.try_wait().map_err(|e| ExecutorError::IoError {
313 message: format!("Failed to poll '{tool_name}': {e}"),
314 })? {
315 break status;
316 }
317 if start.elapsed() >= timeout {
318 let _ = child.kill();
319 let _ = child.wait();
320 drop(stdout_handle.take());
326 drop(stderr_handle.take());
327 self.record_timeout(tool_name);
328 return Err(ExecutorError::Timeout {
329 tool: tool_name.clone(),
330 timeout_ms: timeout.as_millis() as u64,
331 });
332 }
333 thread::sleep(Duration::from_millis(10));
334 }
335 };
336
337 self.clear_timeouts(tool_name);
339
340 let stdout = join_reader(stdout_handle.take()).map_err(|e| ExecutorError::IoError { message: e })?;
341 let stderr = join_reader(stderr_handle.take()).map_err(|e| ExecutorError::IoError { message: e })?;
342 let exit_code = status.code().unwrap_or(-1);
343
344 Ok(ToolOutput {
345 stdout,
346 stderr,
347 exit_code,
348 success: status.success(),
349 })
350 }
351
352 pub fn format(
354 &self,
355 tool_def: &ToolDefinition,
356 input: &str,
357 timeout_ms: Option<u64>,
358 ) -> Result<String, ExecutorError> {
359 let output = self.execute(tool_def, input, true, timeout_ms)?;
360
361 if output.success && tool_def.stdout {
362 Ok(output.stdout)
363 } else if !output.success {
364 let exit_code = output.exit_code;
365 let stderr = &output.stderr;
366 Err(ExecutorError::ExecutionFailed {
367 tool: tool_def.command.first().cloned().unwrap_or_default(),
368 message: format!("Exit code {exit_code}: {stderr}"),
369 })
370 } else {
371 Err(ExecutorError::ExecutionFailed {
373 tool: tool_def.command.first().cloned().unwrap_or_default(),
374 message: "Formatter doesn't output to stdout".to_string(),
375 })
376 }
377 }
378
379 pub fn lint(
381 &self,
382 tool_def: &ToolDefinition,
383 input: &str,
384 timeout_ms: Option<u64>,
385 ) -> Result<ToolOutput, ExecutorError> {
386 self.execute(tool_def, input, false, timeout_ms)
387 }
388}
389
390fn read_pipe_to_string<R: Read>(mut pipe: R) -> std::io::Result<String> {
391 let mut buf = Vec::new();
392 pipe.read_to_end(&mut buf)?;
393 Ok(String::from_utf8_lossy(&buf).to_string())
394}
395
396fn join_reader(handle: Option<thread::JoinHandle<std::io::Result<String>>>) -> Result<String, String> {
397 match handle {
398 Some(handle) => match handle.join() {
399 Ok(res) => res.map_err(|e| format!("Failed to read output: {e}")),
400 Err(_) => Err("Output reader thread panicked".to_string()),
401 },
402 None => Ok(String::new()),
403 }
404}
405
406impl Default for ToolExecutor {
407 fn default() -> Self {
408 Self::new(30_000) }
410}
411
412#[cfg(test)]
413mod tests {
414 use super::*;
415
416 #[test]
417 fn test_executor_creation() {
418 let executor = ToolExecutor::new(10_000);
419 assert_eq!(executor.default_timeout_ms, 10_000);
421 }
422
423 #[test]
424 fn test_tool_not_found() {
425 let executor = ToolExecutor::default();
426 let tool_def = ToolDefinition {
427 command: vec!["nonexistent-tool-xyz123".to_string()],
428 stdin: true,
429 stdout: true,
430 lint_args: vec![],
431 format_args: vec![],
432 };
433
434 let result = executor.execute(&tool_def, "test", false, None);
435 assert!(matches!(result, Err(ExecutorError::ToolNotFound { .. })));
436 }
437
438 #[test]
439 fn test_empty_command() {
440 let executor = ToolExecutor::default();
441 let tool_def = ToolDefinition {
442 command: vec![],
443 stdin: true,
444 stdout: true,
445 lint_args: vec![],
446 format_args: vec![],
447 };
448
449 let result = executor.execute(&tool_def, "test", false, None);
450 assert!(matches!(result, Err(ExecutorError::ExecutionFailed { .. })));
451 }
452
453 #[test]
454 #[cfg(unix)]
455 fn test_execute_cat() {
456 let executor = ToolExecutor::isolated(30_000);
457 let tool_def = ToolDefinition {
458 command: vec!["cat".to_string()],
459 stdin: true,
460 stdout: true,
461 lint_args: vec![],
462 format_args: vec![],
463 };
464
465 let result = executor.execute(&tool_def, "hello world", false, None);
466 let output = result.expect("cat should succeed");
467 assert!(output.success);
468 assert_eq!(output.stdout.trim(), "hello world");
469 }
470
471 #[test]
472 #[cfg(unix)]
473 fn test_timeout() {
474 let executor = ToolExecutor::isolated(5);
475 let tool_def = ToolDefinition {
476 command: vec!["sleep".to_string(), "1".to_string()],
477 stdin: false,
478 stdout: true,
479 lint_args: vec![],
480 format_args: vec![],
481 };
482
483 let result = executor.execute(&tool_def, "", false, Some(5));
484 assert!(matches!(result, Err(ExecutorError::Timeout { .. })));
485 }
486
487 #[cfg(unix)]
490 fn descendant_holds_stdout_tool() -> ToolDefinition {
491 ToolDefinition {
492 command: vec![
493 "sh".to_string(),
494 "-c".to_string(),
495 "sleep 30 & exec sleep 30".to_string(),
496 ],
497 stdin: true,
498 stdout: true,
499 lint_args: vec![],
500 format_args: vec![],
501 }
502 }
503
504 #[test]
508 #[cfg(unix)]
509 fn test_timeout_bounds_execution_when_a_descendant_holds_stdout() {
510 let executor = ToolExecutor::isolated(200);
511 let (tx, rx) = std::sync::mpsc::channel();
512 thread::spawn(move || {
513 let started = Instant::now();
514 let result = executor.execute(&descendant_holds_stdout_tool(), "input", true, Some(200));
515 let _ = tx.send((started.elapsed(), result));
516 });
517
518 let (elapsed, result) = rx
521 .recv_timeout(Duration::from_secs(10))
522 .expect("execute() did not return: the timeout bounded nothing");
523 assert!(
524 matches!(result, Err(ExecutorError::Timeout { .. })),
525 "expected a timeout, got {result:?}"
526 );
527 assert!(elapsed < Duration::from_secs(10), "execute() took {elapsed:?}");
528 }
529
530 #[test]
533 #[cfg(unix)]
534 fn test_a_hanging_tool_is_skipped_after_repeated_timeouts() {
535 let executor = ToolExecutor::isolated(50);
536 let tool_def = ToolDefinition {
537 command: vec!["sh".to_string(), "-c".to_string(), "exec sleep 30".to_string()],
538 stdin: true,
539 stdout: true,
540 lint_args: vec![],
541 format_args: vec![],
542 };
543
544 for attempt in 1..=TIMEOUT_LIMIT {
545 let result = executor.execute(&tool_def, "input", true, Some(50));
546 assert!(
547 matches!(result, Err(ExecutorError::Timeout { .. })),
548 "attempt {attempt} should time out, got {result:?}"
549 );
550 }
551
552 let started = Instant::now();
553 let result = executor.execute(&tool_def, "input", true, Some(50));
554 match result {
555 Err(ExecutorError::RepeatedTimeouts {
556 timeouts, timeout_ms, ..
557 }) => {
558 assert_eq!(timeouts, TIMEOUT_LIMIT);
559 assert_eq!(timeout_ms, 50);
560 }
561 other => panic!("expected the tool to be skipped, got {other:?}"),
562 }
563 assert!(
565 started.elapsed() < Duration::from_millis(50),
566 "skipping still took {:?}",
567 started.elapsed()
568 );
569 }
570
571 #[test]
574 #[cfg(unix)]
575 fn test_exiting_normally_clears_earlier_timeouts() {
576 let executor = ToolExecutor::isolated(50);
577 let hangs = ToolDefinition {
579 command: vec!["sh".to_string(), "-c".to_string(), "exec sleep 30".to_string()],
580 stdin: true,
581 stdout: true,
582 lint_args: vec![],
583 format_args: vec![],
584 };
585 let exits = ToolDefinition {
586 command: vec!["sh".to_string(), "-c".to_string(), "cat".to_string()],
587 stdin: true,
588 stdout: true,
589 lint_args: vec![],
590 format_args: vec![],
591 };
592
593 for _ in 0..TIMEOUT_LIMIT - 1 {
594 assert!(matches!(
595 executor.execute(&hangs, "input", true, Some(50)),
596 Err(ExecutorError::Timeout { .. })
597 ));
598 }
599 assert_eq!(executor.timeout_count("sh"), TIMEOUT_LIMIT - 1);
600
601 let output = executor.execute(&exits, "hello", true, None).expect("cat should exit");
602 assert_eq!(output.stdout.trim(), "hello");
603 assert_eq!(executor.timeout_count("sh"), 0, "a clean exit must clear the tally");
604 }
605
606 #[test]
609 fn test_the_shared_tally_carries_across_executors() {
610 let key = "rumdl-test-only-shared-tally-probe";
613 let first = ToolExecutor::new(50);
614 let second = ToolExecutor::new(50);
615 let alone = ToolExecutor::isolated(50);
616
617 let before = second.timeout_count(key);
618 first.record_timeout(key);
619
620 assert_eq!(
621 second.timeout_count(key),
622 before + 1,
623 "executors built for different files must share one tally"
624 );
625 assert_eq!(alone.timeout_count(key), 0, "an isolated executor keeps its own tally");
626
627 first.clear_timeouts(key);
628 assert_eq!(second.timeout_count(key), 0);
629 }
630}