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/// Ignores `SIGPIPE` process-wide for as long as any instance is alive.
17///
18/// rumdl restores the default disposition of `SIGPIPE` at startup so that piping its
19/// own output into `head` ends the run quietly. Writing to a tool that has already
20/// exited raises the same signal, which kills rumdl mid-run instead of producing the
21/// `BrokenPipe` error the stdin write is written to tolerate.
22///
23/// The disposition has to be changed for the whole process, not masked for the writing
24/// thread: macOS raises a pipe-write `SIGPIPE` against the process rather than against
25/// the thread that wrote, so a thread mask only moves the death to whichever other
26/// thread does not block it (observed landing in a pipe-reader thread). Ignoring it
27/// discards the signal where it is raised.
28///
29/// The window is the write alone, and concurrent tool executions are counted so that
30/// the first to finish does not restore the disposition while another is still writing.
31#[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            // SAFETY: `signal` with `SIG_IGN` is async-signal-safe and the previous
45            // disposition is restored in `Drop`.
46            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            // SAFETY: as in `new`. Restores whatever disposition was in place, which is
62            // the default for the CLI and `SIG_IGN` for a library caller.
63            unsafe {
64                libc::signal(libc::SIGPIPE, state.1);
65            }
66        }
67    }
68}
69
70/// Timeouts of one tool that end further attempts at it.
71///
72/// A tool that hangs does so for every block it is handed, and each attempt costs the
73/// whole timeout. Three is enough to tell a hanging tool from one that is merely slow on
74/// an occasional large block.
75const TIMEOUT_LIMIT: u32 = 3;
76
77/// Timeout tallies keyed by tool name, shared by every executor in the process.
78///
79/// The tally has to outlive one executor: a fresh executor is built per file, so
80/// per-instance state would forget what the previous file just learned and every file
81/// would pay the timeout over again. A tool that exits on its own clears its own tally,
82/// so a single slow block never disables anything.
83static TIMEOUT_COUNTS: LazyLock<Arc<Mutex<HashMap<String, u32>>>> =
84    LazyLock::new(|| Arc::new(Mutex::new(HashMap::new())));
85
86/// Result of executing a tool.
87#[derive(Debug, Clone)]
88pub struct ToolOutput {
89    /// Standard output from the tool.
90    pub stdout: String,
91    /// Standard error from the tool.
92    pub stderr: String,
93    /// Exit code (0 typically means success).
94    pub exit_code: i32,
95    /// Whether the tool executed successfully (exit code 0).
96    pub success: bool,
97}
98
99/// Error during tool execution.
100#[derive(Debug, Clone)]
101pub enum ExecutorError {
102    /// Tool binary not found in PATH.
103    ToolNotFound { tool: String },
104    /// Tool execution failed.
105    ExecutionFailed { tool: String, message: String },
106    /// Tool execution timed out.
107    Timeout { tool: String, timeout_ms: u64 },
108    /// Tool skipped without being run, having already timed out repeatedly.
109    RepeatedTimeouts {
110        tool: String,
111        timeout_ms: u64,
112        timeouts: u32,
113    },
114    /// I/O error during execution.
115    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
149/// Executor for running external tools.
150///
151/// Caches tool availability checks for efficiency.
152pub struct ToolExecutor {
153    /// Cache of tool availability checks (tool name -> available).
154    tool_cache: Arc<Mutex<HashMap<String, bool>>>,
155    /// Timeouts recorded per tool since it last exited on its own.
156    timeout_counts: Arc<Mutex<HashMap<String, u32>>>,
157    /// Default timeout in milliseconds.
158    default_timeout_ms: u64,
159}
160
161impl ToolExecutor {
162    /// Create a new executor with the given default timeout.
163    ///
164    /// Timeouts are tallied process-wide, so a tool that hangs is attempted a bounded
165    /// number of times across every file of a run rather than once per file.
166    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    /// Create an executor that tallies timeouts only for itself.
175    ///
176    /// For callers that must not inherit or contribute to the process-wide tally, such
177    /// as tests, where one test's hanging tool would otherwise decide whether another
178    /// test's tool is run at all.
179    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    /// Timeouts recorded for a tool since it last exited on its own.
188    fn timeout_count(&self, tool_name: &str) -> u32 {
189        self.timeout_counts.lock().unwrap().get(tool_name).copied().unwrap_or(0)
190    }
191
192    /// Record that a tool had to be killed at its timeout.
193    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    /// Forget a tool's timeouts, after it exited without needing to be killed.
203    fn clear_timeouts(&self, tool_name: &str) {
204        self.timeout_counts.lock().unwrap().remove(tool_name);
205    }
206
207    /// Check if a tool is available (lazy, cached).
208    pub fn is_tool_available(&self, tool_name: &str) -> bool {
209        // Check cache first
210        {
211            let cache = self.tool_cache.lock().unwrap();
212            if let Some(&available) = cache.get(tool_name) {
213                return available;
214            }
215        }
216
217        // Resolved in-process the way the spawn itself would resolve it, so the
218        // answer does not depend on a `which`/`where` binary being installed.
219        let available = self.check_tool_exists(tool_name);
220
221        // Cache the result
222        {
223            let mut cache = self.tool_cache.lock().unwrap();
224            cache.insert(tool_name.to_string(), available);
225        }
226
227        available
228    }
229
230    /// Check if a tool binary exists where `Command::new` would look for it.
231    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    /// Execute a tool with the given input.
236    ///
237    /// # Arguments
238    /// * `tool_def` - Tool definition with command and arguments
239    /// * `input` - Content to pass via stdin
240    /// * `is_format_mode` - Whether to use format_args (true) or lint_args (false)
241    /// * `timeout_ms` - Optional timeout override
242    ///
243    /// # Returns
244    /// Tool output on success, or an error.
245    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        // Check tool availability (lazy, cached)
262        if !self.is_tool_available(tool_name) {
263            return Err(ExecutorError::ToolNotFound {
264                tool: tool_name.clone(),
265            });
266        }
267
268        // A tool that has hung this many times will hang again, and every further
269        // attempt costs the full timeout. Report it per block, but stop paying for it.
270        // Files are processed in parallel, so attempts already in flight when the limit
271        // is reached still run: the ceiling is the limit plus the worker count, which is
272        // a constant, rather than one timeout per code block in the run.
273        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        // Build command
284        let mut cmd = Command::new(tool_name);
285
286        // Add base arguments
287        if tool_def.command.len() > 1 {
288            cmd.args(&tool_def.command[1..]);
289        }
290
291        // Add mode-specific arguments
292        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        // Configure stdin/stdout
302        if tool_def.stdin {
303            cmd.stdin(Stdio::piped());
304        }
305        cmd.stdout(Stdio::piped());
306        cmd.stderr(Stdio::piped());
307
308        // Spawn process
309        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        // Write stdin if required.
323        // BrokenPipe is ignored: the tool may exit before consuming all input
324        // (e.g., `true` or a linter that validates without reading fully). On Unix that
325        // write raises SIGPIPE, so the signal is blocked for the duration or the error
326        // below is never reached.
327        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        // Wait for completion with timeout
343        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                    // The reader threads are deliberately abandoned rather than joined.
360                    // `read_to_end` returns only once every write end of the pipe is
361                    // closed, and a killed tool can leave a descendant holding one, so
362                    // joining here waits on exactly the process the timeout exists to
363                    // bound. Each thread ends by itself once the pipe finally closes.
364                    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        // The tool exited on its own, so whatever made earlier runs hang is over.
377        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    /// Execute a tool for formatting (returns formatted content).
392    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            // Tool doesn't output to stdout, which is unusual for a formatter
411            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    /// Execute a tool for linting (returns diagnostics).
419    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) // 30 seconds default
448    }
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        // Just verify it creates successfully
459        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    /// A tool definition whose process outlives its own timeout, and leaves a child
527    /// holding the stdout pipe open after the tool itself is killed.
528    #[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    /// The configured timeout has to bound the call even when the killed tool leaves a
544    /// descendant holding the write end of the stdout pipe. Reading that pipe to EOF
545    /// waits for the descendant, which is precisely the process the timeout is for.
546    #[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        // Generous next to the 200ms timeout, and far below the 30s the descendant
558        // lives for, so this only fires if the call waited on the descendant.
559        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    /// A hanging tool costs its whole timeout every time it is invoked, so a run over
570    /// many code blocks must stop invoking it rather than pay that repeatedly.
571    #[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        // Skipping means not spawning it, so this must not cost another timeout.
603        assert!(
604            started.elapsed() < Duration::from_millis(50),
605            "skipping still took {:?}",
606            started.elapsed()
607        );
608    }
609
610    /// One slow block must not disable a tool for the rest of the run, so exiting on its
611    /// own clears whatever a tool accumulated before.
612    #[test]
613    #[cfg(unix)]
614    fn test_exiting_normally_clears_earlier_timeouts() {
615        let executor = ToolExecutor::isolated(50);
616        // Both definitions run through `sh`, which is what the tally is keyed on.
617        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    /// The tally outlives one executor, since a fresh executor is built per file and a
646    /// hanging tool would otherwise be retried from scratch for every file in the run.
647    #[test]
648    fn test_the_shared_tally_carries_across_executors() {
649        // A name no real tool answers to, so this neither reads nor disturbs the tally
650        // of any tool another test in this process may be running.
651        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}