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