Skip to main content

rumdl_lib/code_block_tools/
executor.rs

1//! Tool execution engine for running external formatters and linters.
2//!
3//! This module handles the actual execution of external tools via stdin/stdout,
4//! with timeout support and lazy tool availability checking.
5
6use 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/// Timeouts of one tool that end further attempts at it.
17///
18/// A tool that hangs does so for every block it is handed, and each attempt costs the
19/// whole timeout. Three is enough to tell a hanging tool from one that is merely slow on
20/// an occasional large block.
21const TIMEOUT_LIMIT: u32 = 3;
22
23/// Timeout tallies keyed by tool name, shared by every executor in the process.
24///
25/// The tally has to outlive one executor: a fresh executor is built per file, so
26/// per-instance state would forget what the previous file just learned and every file
27/// would pay the timeout over again. A tool that exits on its own clears its own tally,
28/// so a single slow block never disables anything.
29static TIMEOUT_COUNTS: LazyLock<Arc<Mutex<HashMap<String, u32>>>> =
30    LazyLock::new(|| Arc::new(Mutex::new(HashMap::new())));
31
32/// Result of executing a tool.
33#[derive(Debug, Clone)]
34pub struct ToolOutput {
35    /// Standard output from the tool.
36    pub stdout: String,
37    /// Standard error from the tool.
38    pub stderr: String,
39    /// Exit code (0 typically means success).
40    pub exit_code: i32,
41    /// Whether the tool executed successfully (exit code 0).
42    pub success: bool,
43}
44
45/// Error during tool execution.
46#[derive(Debug, Clone)]
47pub enum ExecutorError {
48    /// Tool binary not found in PATH.
49    ToolNotFound { tool: String },
50    /// Tool execution failed.
51    ExecutionFailed { tool: String, message: String },
52    /// Tool execution timed out.
53    Timeout { tool: String, timeout_ms: u64 },
54    /// Tool skipped without being run, having already timed out repeatedly.
55    RepeatedTimeouts {
56        tool: String,
57        timeout_ms: u64,
58        timeouts: u32,
59    },
60    /// I/O error during execution.
61    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
95/// Executor for running external tools.
96///
97/// Caches tool availability checks for efficiency.
98pub struct ToolExecutor {
99    /// Cache of tool availability checks (tool name -> available).
100    tool_cache: Arc<Mutex<HashMap<String, bool>>>,
101    /// Timeouts recorded per tool since it last exited on its own.
102    timeout_counts: Arc<Mutex<HashMap<String, u32>>>,
103    /// Default timeout in milliseconds.
104    default_timeout_ms: u64,
105}
106
107impl ToolExecutor {
108    /// Create a new executor with the given default timeout.
109    ///
110    /// Timeouts are tallied process-wide, so a tool that hangs is attempted a bounded
111    /// number of times across every file of a run rather than once per file.
112    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    /// Create an executor that tallies timeouts only for itself.
121    ///
122    /// For callers that must not inherit or contribute to the process-wide tally, such
123    /// as tests, where one test's hanging tool would otherwise decide whether another
124    /// test's tool is run at all.
125    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    /// Timeouts recorded for a tool since it last exited on its own.
134    fn timeout_count(&self, tool_name: &str) -> u32 {
135        self.timeout_counts.lock().unwrap().get(tool_name).copied().unwrap_or(0)
136    }
137
138    /// Record that a tool had to be killed at its timeout.
139    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    /// Forget a tool's timeouts, after it exited without needing to be killed.
149    fn clear_timeouts(&self, tool_name: &str) {
150        self.timeout_counts.lock().unwrap().remove(tool_name);
151    }
152
153    /// Check if a tool is available (lazy, cached).
154    pub fn is_tool_available(&self, tool_name: &str) -> bool {
155        // Check cache first
156        {
157            let cache = self.tool_cache.lock().unwrap();
158            if let Some(&available) = cache.get(tool_name) {
159                return available;
160            }
161        }
162
163        // Resolved in-process the way the spawn itself would resolve it, so the
164        // answer does not depend on a `which`/`where` binary being installed.
165        let available = self.check_tool_exists(tool_name);
166
167        // Cache the result
168        {
169            let mut cache = self.tool_cache.lock().unwrap();
170            cache.insert(tool_name.to_string(), available);
171        }
172
173        available
174    }
175
176    /// Check if a tool binary exists where `Command::new` would look for it.
177    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    /// Execute a tool with the given input.
182    ///
183    /// # Arguments
184    /// * `tool_def` - Tool definition with command and arguments
185    /// * `input` - Content to pass via stdin
186    /// * `is_format_mode` - Whether to use format_args (true) or lint_args (false)
187    /// * `timeout_ms` - Optional timeout override
188    ///
189    /// # Returns
190    /// Tool output on success, or an error.
191    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        // Check tool availability (lazy, cached)
208        if !self.is_tool_available(tool_name) {
209            return Err(ExecutorError::ToolNotFound {
210                tool: tool_name.clone(),
211            });
212        }
213
214        // A tool that has hung this many times will hang again, and every further
215        // attempt costs the full timeout. Report it per block, but stop paying for it.
216        // Files are processed in parallel, so attempts already in flight when the limit
217        // is reached still run: the ceiling is the limit plus the worker count, which is
218        // a constant, rather than one timeout per code block in the run.
219        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        // Build command
230        let mut cmd = Command::new(tool_name);
231
232        // Add base arguments
233        if tool_def.command.len() > 1 {
234            cmd.args(&tool_def.command[1..]);
235        }
236
237        // Add mode-specific arguments
238        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        // Configure stdin/stdout
248        if tool_def.stdin {
249            cmd.stdin(Stdio::piped());
250        }
251        cmd.stdout(Stdio::piped());
252        cmd.stderr(Stdio::piped());
253
254        // Spawn process
255        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        // Write stdin if required.
269        // BrokenPipe is ignored: the tool may exit before consuming all input
270        // (e.g., `true` or a linter that validates without reading fully).
271        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        // Wait for completion with timeout
282        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                    // The reader threads are deliberately abandoned rather than joined.
299                    // `read_to_end` returns only once every write end of the pipe is
300                    // closed, and a killed tool can leave a descendant holding one, so
301                    // joining here waits on exactly the process the timeout exists to
302                    // bound. Each thread ends by itself once the pipe finally closes.
303                    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        // The tool exited on its own, so whatever made earlier runs hang is over.
316        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    /// Execute a tool for formatting (returns formatted content).
331    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            // Tool doesn't output to stdout, which is unusual for a formatter
350            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    /// Execute a tool for linting (returns diagnostics).
358    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) // 30 seconds default
387    }
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        // Just verify it creates successfully
398        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    /// A tool definition whose process outlives its own timeout, and leaves a child
466    /// holding the stdout pipe open after the tool itself is killed.
467    #[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    /// The configured timeout has to bound the call even when the killed tool leaves a
483    /// descendant holding the write end of the stdout pipe. Reading that pipe to EOF
484    /// waits for the descendant, which is precisely the process the timeout is for.
485    #[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        // Generous next to the 200ms timeout, and far below the 30s the descendant
497        // lives for, so this only fires if the call waited on the descendant.
498        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    /// A hanging tool costs its whole timeout every time it is invoked, so a run over
509    /// many code blocks must stop invoking it rather than pay that repeatedly.
510    #[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        // Skipping means not spawning it, so this must not cost another timeout.
542        assert!(
543            started.elapsed() < Duration::from_millis(50),
544            "skipping still took {:?}",
545            started.elapsed()
546        );
547    }
548
549    /// One slow block must not disable a tool for the rest of the run, so exiting on its
550    /// own clears whatever a tool accumulated before.
551    #[test]
552    #[cfg(unix)]
553    fn test_exiting_normally_clears_earlier_timeouts() {
554        let executor = ToolExecutor::isolated(50);
555        // Both definitions run through `sh`, which is what the tally is keyed on.
556        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    /// The tally outlives one executor, since a fresh executor is built per file and a
585    /// hanging tool would otherwise be retried from scratch for every file in the run.
586    #[test]
587    fn test_the_shared_tally_carries_across_executors() {
588        // A name no real tool answers to, so this neither reads nor disturbs the tally
589        // of any tool another test in this process may be running.
590        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}