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
16#[cfg(unix)]
32struct SigpipeIgnored;
33
34#[cfg(unix)]
35static SIGPIPE_IGNORED: Mutex<(usize, libc::sighandler_t)> = Mutex::new((0, 0));
36
37#[cfg(unix)]
38impl SigpipeIgnored {
39 fn new() -> Self {
40 let mut state = SIGPIPE_IGNORED
41 .lock()
42 .unwrap_or_else(std::sync::PoisonError::into_inner);
43 if state.0 == 0 {
44 state.1 = unsafe { libc::signal(libc::SIGPIPE, libc::SIG_IGN) };
47 }
48 state.0 += 1;
49 Self
50 }
51}
52
53#[cfg(unix)]
54impl Drop for SigpipeIgnored {
55 fn drop(&mut self) {
56 let mut state = SIGPIPE_IGNORED
57 .lock()
58 .unwrap_or_else(std::sync::PoisonError::into_inner);
59 state.0 -= 1;
60 if state.0 == 0 {
61 unsafe {
64 libc::signal(libc::SIGPIPE, state.1);
65 }
66 }
67 }
68}
69
70const TIMEOUT_LIMIT: u32 = 3;
76
77static TIMEOUT_COUNTS: LazyLock<Arc<Mutex<HashMap<String, u32>>>> =
84 LazyLock::new(|| Arc::new(Mutex::new(HashMap::new())));
85
86#[derive(Debug, Clone)]
88pub struct ToolOutput {
89 pub stdout: String,
91 pub stderr: String,
93 pub exit_code: i32,
95 pub success: bool,
97}
98
99#[derive(Debug, Clone)]
101pub enum ExecutorError {
102 ToolNotFound { tool: String },
104 ExecutionFailed { tool: String, message: String },
106 Timeout { tool: String, timeout_ms: u64 },
108 RepeatedTimeouts {
110 tool: String,
111 timeout_ms: u64,
112 timeouts: u32,
113 },
114 IoError { message: String },
116}
117
118impl std::fmt::Display for ExecutorError {
119 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
120 match self {
121 Self::ToolNotFound { tool } => {
122 write!(f, "Tool '{tool}' not found in PATH")
123 }
124 Self::ExecutionFailed { tool, message } => {
125 write!(f, "Tool '{tool}' failed: {message}")
126 }
127 Self::Timeout { tool, timeout_ms } => {
128 write!(f, "Tool '{tool}' timed out after {timeout_ms}ms")
129 }
130 Self::RepeatedTimeouts {
131 tool,
132 timeout_ms,
133 timeouts,
134 } => {
135 write!(
136 f,
137 "Tool '{tool}' skipped after timing out {timeouts} times at {timeout_ms}ms; a tool that never exits is usually not reading its stdin"
138 )
139 }
140 Self::IoError { message } => {
141 write!(f, "I/O error: {message}")
142 }
143 }
144 }
145}
146
147impl std::error::Error for ExecutorError {}
148
149pub struct ToolExecutor {
153 tool_cache: Arc<Mutex<HashMap<String, bool>>>,
155 timeout_counts: Arc<Mutex<HashMap<String, u32>>>,
157 default_timeout_ms: u64,
159}
160
161impl ToolExecutor {
162 pub fn new(default_timeout_ms: u64) -> Self {
167 Self {
168 tool_cache: Arc::new(Mutex::new(HashMap::new())),
169 timeout_counts: Arc::clone(&TIMEOUT_COUNTS),
170 default_timeout_ms,
171 }
172 }
173
174 pub fn isolated(default_timeout_ms: u64) -> Self {
180 Self {
181 tool_cache: Arc::new(Mutex::new(HashMap::new())),
182 timeout_counts: Arc::new(Mutex::new(HashMap::new())),
183 default_timeout_ms,
184 }
185 }
186
187 fn timeout_count(&self, tool_name: &str) -> u32 {
189 self.timeout_counts.lock().unwrap().get(tool_name).copied().unwrap_or(0)
190 }
191
192 fn record_timeout(&self, tool_name: &str) {
194 *self
195 .timeout_counts
196 .lock()
197 .unwrap()
198 .entry(tool_name.to_string())
199 .or_insert(0) += 1;
200 }
201
202 fn clear_timeouts(&self, tool_name: &str) {
204 self.timeout_counts.lock().unwrap().remove(tool_name);
205 }
206
207 pub fn is_tool_available(&self, tool_name: &str) -> bool {
209 {
211 let cache = self.tool_cache.lock().unwrap();
212 if let Some(&available) = cache.get(tool_name) {
213 return available;
214 }
215 }
216
217 let available = self.check_tool_exists(tool_name);
220
221 {
223 let mut cache = self.tool_cache.lock().unwrap();
224 cache.insert(tool_name.to_string(), available);
225 }
226
227 available
228 }
229
230 fn check_tool_exists(&self, tool_name: &str) -> bool {
232 lookup::resolve_program(OsStr::new(tool_name), std::env::var_os("PATH").as_deref()).is_some()
233 }
234
235 pub fn execute(
246 &self,
247 tool_def: &ToolDefinition,
248 input: &str,
249 is_format_mode: bool,
250 timeout_ms: Option<u64>,
251 ) -> Result<ToolOutput, ExecutorError> {
252 if tool_def.command.is_empty() {
253 return Err(ExecutorError::ExecutionFailed {
254 tool: "unknown".to_string(),
255 message: "Empty command".to_string(),
256 });
257 }
258
259 let tool_name = &tool_def.command[0];
260
261 if !self.is_tool_available(tool_name) {
263 return Err(ExecutorError::ToolNotFound {
264 tool: tool_name.clone(),
265 });
266 }
267
268 let effective_timeout_ms = timeout_ms.unwrap_or(self.default_timeout_ms);
274 let timeouts = self.timeout_count(tool_name);
275 if timeouts >= TIMEOUT_LIMIT {
276 return Err(ExecutorError::RepeatedTimeouts {
277 tool: tool_name.clone(),
278 timeout_ms: effective_timeout_ms,
279 timeouts,
280 });
281 }
282
283 let mut cmd = Command::new(tool_name);
285
286 if tool_def.command.len() > 1 {
288 cmd.args(&tool_def.command[1..]);
289 }
290
291 let extra_args = if is_format_mode {
293 &tool_def.format_args
294 } else {
295 &tool_def.lint_args
296 };
297 if !extra_args.is_empty() {
298 cmd.args(extra_args);
299 }
300
301 if tool_def.stdin {
303 cmd.stdin(Stdio::piped());
304 }
305 cmd.stdout(Stdio::piped());
306 cmd.stderr(Stdio::piped());
307
308 let mut child = cmd.spawn().map_err(|e| ExecutorError::IoError {
310 message: format!("Failed to spawn '{tool_name}': {e}"),
311 })?;
312
313 let mut stdout_handle = child
314 .stdout
315 .take()
316 .map(|stdout| thread::spawn(move || read_pipe_to_string(stdout)));
317 let mut stderr_handle = child
318 .stderr
319 .take()
320 .map(|stderr| thread::spawn(move || read_pipe_to_string(stderr)));
321
322 if tool_def.stdin
328 && let Some(mut stdin) = child.stdin.take()
329 {
330 #[cfg(unix)]
331 let _sigpipe = SigpipeIgnored::new();
332
333 if let Err(e) = stdin.write_all(input.as_bytes())
334 && e.kind() != std::io::ErrorKind::BrokenPipe
335 {
336 return Err(ExecutorError::IoError {
337 message: format!("Failed to write to stdin: {e}"),
338 });
339 }
340 }
341
342 let timeout = Duration::from_millis(effective_timeout_ms);
344 let status = if timeout.is_zero() {
345 child.wait().map_err(|e| ExecutorError::IoError {
346 message: format!("Failed to wait for '{tool_name}': {e}"),
347 })?
348 } else {
349 let start = Instant::now();
350 loop {
351 if let Some(status) = child.try_wait().map_err(|e| ExecutorError::IoError {
352 message: format!("Failed to poll '{tool_name}': {e}"),
353 })? {
354 break status;
355 }
356 if start.elapsed() >= timeout {
357 let _ = child.kill();
358 let _ = child.wait();
359 drop(stdout_handle.take());
365 drop(stderr_handle.take());
366 self.record_timeout(tool_name);
367 return Err(ExecutorError::Timeout {
368 tool: tool_name.clone(),
369 timeout_ms: timeout.as_millis() as u64,
370 });
371 }
372 thread::sleep(Duration::from_millis(10));
373 }
374 };
375
376 self.clear_timeouts(tool_name);
378
379 let stdout = join_reader(stdout_handle.take()).map_err(|e| ExecutorError::IoError { message: e })?;
380 let stderr = join_reader(stderr_handle.take()).map_err(|e| ExecutorError::IoError { message: e })?;
381 let exit_code = status.code().unwrap_or(-1);
382
383 Ok(ToolOutput {
384 stdout,
385 stderr,
386 exit_code,
387 success: status.success(),
388 })
389 }
390
391 pub fn format(
393 &self,
394 tool_def: &ToolDefinition,
395 input: &str,
396 timeout_ms: Option<u64>,
397 ) -> Result<String, ExecutorError> {
398 let output = self.execute(tool_def, input, true, timeout_ms)?;
399
400 if output.success && tool_def.stdout {
401 Ok(output.stdout)
402 } else if !output.success {
403 let exit_code = output.exit_code;
404 let stderr = &output.stderr;
405 Err(ExecutorError::ExecutionFailed {
406 tool: tool_def.command.first().cloned().unwrap_or_default(),
407 message: format!("Exit code {exit_code}: {stderr}"),
408 })
409 } else {
410 Err(ExecutorError::ExecutionFailed {
412 tool: tool_def.command.first().cloned().unwrap_or_default(),
413 message: "Formatter doesn't output to stdout".to_string(),
414 })
415 }
416 }
417
418 pub fn lint(
420 &self,
421 tool_def: &ToolDefinition,
422 input: &str,
423 timeout_ms: Option<u64>,
424 ) -> Result<ToolOutput, ExecutorError> {
425 self.execute(tool_def, input, false, timeout_ms)
426 }
427}
428
429fn read_pipe_to_string<R: Read>(mut pipe: R) -> std::io::Result<String> {
430 let mut buf = Vec::new();
431 pipe.read_to_end(&mut buf)?;
432 Ok(String::from_utf8_lossy(&buf).to_string())
433}
434
435fn join_reader(handle: Option<thread::JoinHandle<std::io::Result<String>>>) -> Result<String, String> {
436 match handle {
437 Some(handle) => match handle.join() {
438 Ok(res) => res.map_err(|e| format!("Failed to read output: {e}")),
439 Err(_) => Err("Output reader thread panicked".to_string()),
440 },
441 None => Ok(String::new()),
442 }
443}
444
445impl Default for ToolExecutor {
446 fn default() -> Self {
447 Self::new(30_000) }
449}
450
451#[cfg(test)]
452mod tests {
453 use super::*;
454
455 #[test]
456 fn test_executor_creation() {
457 let executor = ToolExecutor::new(10_000);
458 assert_eq!(executor.default_timeout_ms, 10_000);
460 }
461
462 #[test]
463 fn test_tool_not_found() {
464 let executor = ToolExecutor::default();
465 let tool_def = ToolDefinition {
466 command: vec!["nonexistent-tool-xyz123".to_string()],
467 stdin: true,
468 stdout: true,
469 lint_args: vec![],
470 format_args: vec![],
471 };
472
473 let result = executor.execute(&tool_def, "test", false, None);
474 assert!(matches!(result, Err(ExecutorError::ToolNotFound { .. })));
475 }
476
477 #[test]
478 fn test_empty_command() {
479 let executor = ToolExecutor::default();
480 let tool_def = ToolDefinition {
481 command: vec![],
482 stdin: true,
483 stdout: true,
484 lint_args: vec![],
485 format_args: vec![],
486 };
487
488 let result = executor.execute(&tool_def, "test", false, None);
489 assert!(matches!(result, Err(ExecutorError::ExecutionFailed { .. })));
490 }
491
492 #[test]
493 #[cfg(unix)]
494 fn test_execute_cat() {
495 let executor = ToolExecutor::isolated(30_000);
496 let tool_def = ToolDefinition {
497 command: vec!["cat".to_string()],
498 stdin: true,
499 stdout: true,
500 lint_args: vec![],
501 format_args: vec![],
502 };
503
504 let result = executor.execute(&tool_def, "hello world", false, None);
505 let output = result.expect("cat should succeed");
506 assert!(output.success);
507 assert_eq!(output.stdout.trim(), "hello world");
508 }
509
510 #[test]
511 #[cfg(unix)]
512 fn test_timeout() {
513 let executor = ToolExecutor::isolated(5);
514 let tool_def = ToolDefinition {
515 command: vec!["sleep".to_string(), "1".to_string()],
516 stdin: false,
517 stdout: true,
518 lint_args: vec![],
519 format_args: vec![],
520 };
521
522 let result = executor.execute(&tool_def, "", false, Some(5));
523 assert!(matches!(result, Err(ExecutorError::Timeout { .. })));
524 }
525
526 #[cfg(unix)]
529 fn descendant_holds_stdout_tool() -> ToolDefinition {
530 ToolDefinition {
531 command: vec![
532 "sh".to_string(),
533 "-c".to_string(),
534 "sleep 30 & exec sleep 30".to_string(),
535 ],
536 stdin: true,
537 stdout: true,
538 lint_args: vec![],
539 format_args: vec![],
540 }
541 }
542
543 #[test]
547 #[cfg(unix)]
548 fn test_timeout_bounds_execution_when_a_descendant_holds_stdout() {
549 let executor = ToolExecutor::isolated(200);
550 let (tx, rx) = std::sync::mpsc::channel();
551 thread::spawn(move || {
552 let started = Instant::now();
553 let result = executor.execute(&descendant_holds_stdout_tool(), "input", true, Some(200));
554 let _ = tx.send((started.elapsed(), result));
555 });
556
557 let (elapsed, result) = rx
560 .recv_timeout(Duration::from_secs(10))
561 .expect("execute() did not return: the timeout bounded nothing");
562 assert!(
563 matches!(result, Err(ExecutorError::Timeout { .. })),
564 "expected a timeout, got {result:?}"
565 );
566 assert!(elapsed < Duration::from_secs(10), "execute() took {elapsed:?}");
567 }
568
569 #[test]
572 #[cfg(unix)]
573 fn test_a_hanging_tool_is_skipped_after_repeated_timeouts() {
574 let executor = ToolExecutor::isolated(50);
575 let tool_def = ToolDefinition {
576 command: vec!["sh".to_string(), "-c".to_string(), "exec sleep 30".to_string()],
577 stdin: true,
578 stdout: true,
579 lint_args: vec![],
580 format_args: vec![],
581 };
582
583 for attempt in 1..=TIMEOUT_LIMIT {
584 let result = executor.execute(&tool_def, "input", true, Some(50));
585 assert!(
586 matches!(result, Err(ExecutorError::Timeout { .. })),
587 "attempt {attempt} should time out, got {result:?}"
588 );
589 }
590
591 let started = Instant::now();
592 let result = executor.execute(&tool_def, "input", true, Some(50));
593 match result {
594 Err(ExecutorError::RepeatedTimeouts {
595 timeouts, timeout_ms, ..
596 }) => {
597 assert_eq!(timeouts, TIMEOUT_LIMIT);
598 assert_eq!(timeout_ms, 50);
599 }
600 other => panic!("expected the tool to be skipped, got {other:?}"),
601 }
602 assert!(
604 started.elapsed() < Duration::from_millis(50),
605 "skipping still took {:?}",
606 started.elapsed()
607 );
608 }
609
610 #[test]
613 #[cfg(unix)]
614 fn test_exiting_normally_clears_earlier_timeouts() {
615 let executor = ToolExecutor::isolated(50);
616 let hangs = ToolDefinition {
618 command: vec!["sh".to_string(), "-c".to_string(), "exec sleep 30".to_string()],
619 stdin: true,
620 stdout: true,
621 lint_args: vec![],
622 format_args: vec![],
623 };
624 let exits = ToolDefinition {
625 command: vec!["sh".to_string(), "-c".to_string(), "cat".to_string()],
626 stdin: true,
627 stdout: true,
628 lint_args: vec![],
629 format_args: vec![],
630 };
631
632 for _ in 0..TIMEOUT_LIMIT - 1 {
633 assert!(matches!(
634 executor.execute(&hangs, "input", true, Some(50)),
635 Err(ExecutorError::Timeout { .. })
636 ));
637 }
638 assert_eq!(executor.timeout_count("sh"), TIMEOUT_LIMIT - 1);
639
640 let output = executor.execute(&exits, "hello", true, None).expect("cat should exit");
641 assert_eq!(output.stdout.trim(), "hello");
642 assert_eq!(executor.timeout_count("sh"), 0, "a clean exit must clear the tally");
643 }
644
645 #[test]
648 fn test_the_shared_tally_carries_across_executors() {
649 let key = "rumdl-test-only-shared-tally-probe";
652 let first = ToolExecutor::new(50);
653 let second = ToolExecutor::new(50);
654 let alone = ToolExecutor::isolated(50);
655
656 let before = second.timeout_count(key);
657 first.record_timeout(key);
658
659 assert_eq!(
660 second.timeout_count(key),
661 before + 1,
662 "executors built for different files must share one tally"
663 );
664 assert_eq!(alone.timeout_count(key), 0, "an isolated executor keeps its own tally");
665
666 first.clear_timeouts(key);
667 assert_eq!(second.timeout_count(key), 0);
668 }
669}