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 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
14/// Timeouts of one tool that end further attempts at it.
15///
16/// A tool that hangs does so for every block it is handed, and each attempt costs the
17/// whole timeout. Three is enough to tell a hanging tool from one that is merely slow on
18/// an occasional large block.
19const TIMEOUT_LIMIT: u32 = 3;
20
21/// Timeout tallies keyed by tool name, shared by every executor in the process.
22///
23/// The tally has to outlive one executor: a fresh executor is built per file, so
24/// per-instance state would forget what the previous file just learned and every file
25/// would pay the timeout over again. A tool that exits on its own clears its own tally,
26/// so a single slow block never disables anything.
27static TIMEOUT_COUNTS: LazyLock<Arc<Mutex<HashMap<String, u32>>>> =
28    LazyLock::new(|| Arc::new(Mutex::new(HashMap::new())));
29
30/// Result of executing a tool.
31#[derive(Debug, Clone)]
32pub struct ToolOutput {
33    /// Standard output from the tool.
34    pub stdout: String,
35    /// Standard error from the tool.
36    pub stderr: String,
37    /// Exit code (0 typically means success).
38    pub exit_code: i32,
39    /// Whether the tool executed successfully (exit code 0).
40    pub success: bool,
41}
42
43/// Error during tool execution.
44#[derive(Debug, Clone)]
45pub enum ExecutorError {
46    /// Tool binary not found in PATH.
47    ToolNotFound { tool: String },
48    /// Tool execution failed.
49    ExecutionFailed { tool: String, message: String },
50    /// Tool execution timed out.
51    Timeout { tool: String, timeout_ms: u64 },
52    /// Tool skipped without being run, having already timed out repeatedly.
53    RepeatedTimeouts {
54        tool: String,
55        timeout_ms: u64,
56        timeouts: u32,
57    },
58    /// I/O error during execution.
59    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
93/// Executor for running external tools.
94///
95/// Caches tool availability checks for efficiency.
96pub struct ToolExecutor {
97    /// Cache of tool availability checks (tool name -> available).
98    tool_cache: Arc<Mutex<HashMap<String, bool>>>,
99    /// Timeouts recorded per tool since it last exited on its own.
100    timeout_counts: Arc<Mutex<HashMap<String, u32>>>,
101    /// Default timeout in milliseconds.
102    default_timeout_ms: u64,
103}
104
105impl ToolExecutor {
106    /// Create a new executor with the given default timeout.
107    ///
108    /// Timeouts are tallied process-wide, so a tool that hangs is attempted a bounded
109    /// number of times across every file of a run rather than once per file.
110    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    /// Create an executor that tallies timeouts only for itself.
119    ///
120    /// For callers that must not inherit or contribute to the process-wide tally, such
121    /// as tests, where one test's hanging tool would otherwise decide whether another
122    /// test's tool is run at all.
123    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    /// Timeouts recorded for a tool since it last exited on its own.
132    fn timeout_count(&self, tool_name: &str) -> u32 {
133        self.timeout_counts.lock().unwrap().get(tool_name).copied().unwrap_or(0)
134    }
135
136    /// Record that a tool had to be killed at its timeout.
137    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    /// Forget a tool's timeouts, after it exited without needing to be killed.
147    fn clear_timeouts(&self, tool_name: &str) {
148        self.timeout_counts.lock().unwrap().remove(tool_name);
149    }
150
151    /// Check if a tool is available (lazy, cached).
152    pub fn is_tool_available(&self, tool_name: &str) -> bool {
153        // Check cache first
154        {
155            let cache = self.tool_cache.lock().unwrap();
156            if let Some(&available) = cache.get(tool_name) {
157                return available;
158            }
159        }
160
161        // Check if tool exists using `which` on Unix or `where` on Windows
162        let available = self.check_tool_exists(tool_name);
163
164        // Cache the result
165        {
166            let mut cache = self.tool_cache.lock().unwrap();
167            cache.insert(tool_name.to_string(), available);
168        }
169
170        available
171    }
172
173    /// Check if a tool binary exists.
174    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            // WASM and other platforms: external tools not available
198            let _ = tool_name;
199            false
200        }
201    }
202
203    /// Execute a tool with the given input.
204    ///
205    /// # Arguments
206    /// * `tool_def` - Tool definition with command and arguments
207    /// * `input` - Content to pass via stdin
208    /// * `is_format_mode` - Whether to use format_args (true) or lint_args (false)
209    /// * `timeout_ms` - Optional timeout override
210    ///
211    /// # Returns
212    /// Tool output on success, or an error.
213    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        // Check tool availability (lazy, cached)
230        if !self.is_tool_available(tool_name) {
231            return Err(ExecutorError::ToolNotFound {
232                tool: tool_name.clone(),
233            });
234        }
235
236        // A tool that has hung this many times will hang again, and every further
237        // attempt costs the full timeout. Report it per block, but stop paying for it.
238        // Files are processed in parallel, so attempts already in flight when the limit
239        // is reached still run: the ceiling is the limit plus the worker count, which is
240        // a constant, rather than one timeout per code block in the run.
241        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        // Build command
252        let mut cmd = Command::new(tool_name);
253
254        // Add base arguments
255        if tool_def.command.len() > 1 {
256            cmd.args(&tool_def.command[1..]);
257        }
258
259        // Add mode-specific arguments
260        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        // Configure stdin/stdout
270        if tool_def.stdin {
271            cmd.stdin(Stdio::piped());
272        }
273        cmd.stdout(Stdio::piped());
274        cmd.stderr(Stdio::piped());
275
276        // Spawn process
277        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        // Write stdin if required.
291        // BrokenPipe is ignored: the tool may exit before consuming all input
292        // (e.g., `true` or a linter that validates without reading fully).
293        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        // Wait for completion with timeout
304        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                    // The reader threads are deliberately abandoned rather than joined.
321                    // `read_to_end` returns only once every write end of the pipe is
322                    // closed, and a killed tool can leave a descendant holding one, so
323                    // joining here waits on exactly the process the timeout exists to
324                    // bound. Each thread ends by itself once the pipe finally closes.
325                    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        // The tool exited on its own, so whatever made earlier runs hang is over.
338        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    /// Execute a tool for formatting (returns formatted content).
353    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            // Tool doesn't output to stdout, which is unusual for a formatter
372            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    /// Execute a tool for linting (returns diagnostics).
380    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) // 30 seconds default
409    }
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        // Just verify it creates successfully
420        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    /// A tool definition whose process outlives its own timeout, and leaves a child
488    /// holding the stdout pipe open after the tool itself is killed.
489    #[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    /// The configured timeout has to bound the call even when the killed tool leaves a
505    /// descendant holding the write end of the stdout pipe. Reading that pipe to EOF
506    /// waits for the descendant, which is precisely the process the timeout is for.
507    #[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        // Generous next to the 200ms timeout, and far below the 30s the descendant
519        // lives for, so this only fires if the call waited on the descendant.
520        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    /// A hanging tool costs its whole timeout every time it is invoked, so a run over
531    /// many code blocks must stop invoking it rather than pay that repeatedly.
532    #[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        // Skipping means not spawning it, so this must not cost another timeout.
564        assert!(
565            started.elapsed() < Duration::from_millis(50),
566            "skipping still took {:?}",
567            started.elapsed()
568        );
569    }
570
571    /// One slow block must not disable a tool for the rest of the run, so exiting on its
572    /// own clears whatever a tool accumulated before.
573    #[test]
574    #[cfg(unix)]
575    fn test_exiting_normally_clears_earlier_timeouts() {
576        let executor = ToolExecutor::isolated(50);
577        // Both definitions run through `sh`, which is what the tally is keyed on.
578        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    /// The tally outlives one executor, since a fresh executor is built per file and a
607    /// hanging tool would otherwise be retried from scratch for every file in the run.
608    #[test]
609    fn test_the_shared_tally_carries_across_executors() {
610        // A name no real tool answers to, so this neither reads nor disturbs the tally
611        // of any tool another test in this process may be running.
612        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}