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