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