1use super::config::ToolDefinition;
7use super::wait;
8use std::collections::HashMap;
9use std::io::{Read, Write};
10use std::path::PathBuf;
11use std::process::{Command, Stdio};
12use std::sync::{Arc, LazyLock, Mutex};
13use std::thread;
14use std::time::{Duration, SystemTime};
15
16#[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 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 unsafe {
64 libc::signal(libc::SIGPIPE, state.1);
65 }
66 }
67 }
68}
69
70fn check_bun_fmt_scripts(start: &std::path::Path) -> Result<(), String> {
74 for directory in start.ancestors() {
75 let mut entries = vec![directory.join("fmt"), directory.join("fmt/package.json")];
79 for extension in ["tsx", "ts", "jsx", "cts", "cjs", "js", "mjs", "mts"] {
80 entries.push(directory.join(format!("fmt.{extension}")));
81 entries.push(directory.join(format!("fmt/index.{extension}")));
82 }
83 for suffix in ["", ".bunx", ".exe", ".cmd"] {
84 entries.push(directory.join(format!("node_modules/.bin/fmt{suffix}")));
85 }
86 if let Some(entry) = entries.into_iter().find(|entry| entry.is_file()) {
87 return Err(format!(
88 "bun fmt would run a project entry point at {} instead of the built-in formatter; rename that entry point before using bun-fmt",
89 entry.display()
90 ));
91 }
92 let manifest = directory.join("package.json");
93 let source = match std::fs::read_to_string(&manifest) {
94 Ok(source) => source,
95 Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
96 Err(error) => {
97 return Err(format!(
98 "Cannot inspect {} before running bun fmt: {error}",
99 manifest.display()
100 ));
101 }
102 };
103 let package: serde_json::Value = serde_json::from_str(&source)
104 .map_err(|error| format!("Cannot inspect {} before running bun fmt: {error}", manifest.display()))?;
105 if package.get("scripts").and_then(|scripts| scripts.get("fmt")).is_some() {
106 return Err(format!(
107 "bun fmt would run the package.json fmt script in {} instead of the built-in formatter; rename that script before using bun-fmt",
108 manifest.display()
109 ));
110 }
111 }
112 Ok(())
113}
114
115const TIMEOUT_LIMIT: u32 = 3;
121
122static TIMEOUT_COUNTS: LazyLock<Arc<Mutex<HashMap<String, u32>>>> =
129 LazyLock::new(|| Arc::new(Mutex::new(HashMap::new())));
130
131type CheckCapabilityKey = (String, PathBuf, Option<SystemTime>, u64);
135static CHECK_CAPABILITIES: LazyLock<Mutex<HashMap<CheckCapabilityKey, bool>>> =
136 LazyLock::new(|| Mutex::new(HashMap::new()));
137
138pub(crate) enum FormatCheckOutput {
140 Native { changed: bool },
141 Formatted(String),
142}
143
144#[derive(Debug, Clone)]
146pub struct ToolOutput {
147 pub stdout: String,
149 pub stderr: String,
151 pub exit_code: i32,
153 pub success: bool,
155}
156
157#[derive(Debug, Clone)]
159pub enum ExecutorError {
160 ToolNotFound { tool: String },
162 ExecutionFailed { tool: String, message: String },
164 Timeout { tool: String, timeout_ms: u64 },
166 RepeatedTimeouts {
168 tool: String,
169 timeout_ms: u64,
170 timeouts: u32,
171 },
172 IoError { message: String },
174}
175
176impl std::fmt::Display for ExecutorError {
177 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
178 match self {
179 Self::ToolNotFound { tool } => {
180 write!(f, "Tool '{tool}' not found in allowed lookup locations")
181 }
182 Self::ExecutionFailed { tool, message } => {
183 write!(f, "Tool '{tool}' failed: {message}")
184 }
185 Self::Timeout { tool, timeout_ms } => {
186 write!(f, "Tool '{tool}' timed out after {timeout_ms}ms")
187 }
188 Self::RepeatedTimeouts {
189 tool,
190 timeout_ms,
191 timeouts,
192 } => {
193 write!(
194 f,
195 "Tool '{tool}' skipped after timing out {timeouts} times at {timeout_ms}ms; a tool that never exits is usually not reading its stdin"
196 )
197 }
198 Self::IoError { message } => {
199 write!(f, "I/O error: {message}")
200 }
201 }
202 }
203}
204
205impl std::error::Error for ExecutorError {}
206
207pub struct ToolExecutor {
211 tool_cache: Arc<Mutex<HashMap<String, bool>>>,
213 timeout_counts: Arc<Mutex<HashMap<String, u32>>>,
215 default_timeout_ms: u64,
217 binary_preferences: std::collections::BTreeMap<String, super::config::BinaryPreference>,
218 project_root: std::path::PathBuf,
219 run_state: Option<Arc<super::run_state::RunState>>,
220}
221
222impl ToolExecutor {
223 pub fn new(default_timeout_ms: u64) -> Self {
228 Self {
229 tool_cache: Arc::new(Mutex::new(HashMap::new())),
230 timeout_counts: Arc::clone(&TIMEOUT_COUNTS),
231 default_timeout_ms,
232 binary_preferences: std::collections::BTreeMap::new(),
233 project_root: std::env::current_dir().unwrap_or_default(),
234 run_state: None,
235 }
236 }
237
238 pub fn isolated(default_timeout_ms: u64) -> Self {
244 Self {
245 tool_cache: Arc::new(Mutex::new(HashMap::new())),
246 timeout_counts: Arc::new(Mutex::new(HashMap::new())),
247 default_timeout_ms,
248 binary_preferences: std::collections::BTreeMap::new(),
249 project_root: std::env::current_dir().unwrap_or_default(),
250 run_state: None,
251 }
252 }
253
254 pub fn with_binary_resolution(
256 mut self,
257 preferences: std::collections::BTreeMap<String, super::config::BinaryPreference>,
258 root: std::path::PathBuf,
259 ) -> Self {
260 self.binary_preferences = preferences;
261 self.project_root = root;
262 self.tool_cache.lock().unwrap().clear();
263 self
264 }
265
266 pub fn with_run_state(mut self, state: Arc<super::run_state::RunState>) -> Self {
267 self.run_state = Some(state);
268 self
269 }
270
271 pub fn resolve_binary(&self, binary: &str) -> Option<std::path::PathBuf> {
272 super::binary::resolve(
273 binary,
274 &self.binary_preferences,
275 &self.project_root,
276 std::env::var_os("PATH").as_deref(),
277 )
278 }
279
280 fn timeout_key(&self, tool_name: &str) -> String {
282 self.resolve_binary(tool_name).map_or_else(
283 || tool_name.into(),
284 |path| {
285 std::fs::canonicalize(&path)
286 .unwrap_or(path)
287 .to_string_lossy()
288 .into_owned()
289 },
290 )
291 }
292
293 fn timeout_count(&self, tool_name: &str) -> u32 {
294 self.timeout_counts
295 .lock()
296 .unwrap()
297 .get(&self.timeout_key(tool_name))
298 .copied()
299 .unwrap_or(0)
300 }
301
302 fn record_timeout(&self, tool_name: &str) {
304 *self
305 .timeout_counts
306 .lock()
307 .unwrap()
308 .entry(self.timeout_key(tool_name))
309 .or_insert(0) += 1;
310 }
311
312 fn clear_timeouts(&self, tool_name: &str) {
314 self.timeout_counts.lock().unwrap().remove(&self.timeout_key(tool_name));
315 }
316
317 pub fn is_tool_available(&self, tool_name: &str) -> bool {
319 {
321 let cache = self.tool_cache.lock().unwrap();
322 if let Some(&available) = cache.get(tool_name) {
323 return available;
324 }
325 }
326
327 let available = self.check_tool_exists(tool_name);
330
331 {
333 let mut cache = self.tool_cache.lock().unwrap();
334 cache.insert(tool_name.to_string(), available);
335 }
336
337 available
338 }
339
340 fn check_tool_exists(&self, tool_name: &str) -> bool {
342 self.resolve_binary(tool_name).is_some()
343 }
344
345 pub fn execute(
356 &self,
357 tool_def: &ToolDefinition,
358 input: &str,
359 is_format_mode: bool,
360 timeout_ms: Option<u64>,
361 ) -> Result<ToolOutput, ExecutorError> {
362 if tool_def.command.is_empty() {
363 return Err(ExecutorError::ExecutionFailed {
364 tool: "unknown".to_string(),
365 message: "Empty command".to_string(),
366 });
367 }
368
369 let tool_name = &tool_def.command[0];
370
371 if tool_name == "bun" && tool_def.command.get(1).is_some_and(|arg| arg == "fmt") {
372 let cwd = std::env::current_dir().map_err(|error| ExecutorError::IoError {
373 message: error.to_string(),
374 })?;
375 check_bun_fmt_scripts(&cwd).map_err(|message| ExecutorError::ExecutionFailed {
376 tool: tool_name.clone(),
377 message,
378 })?;
379 }
380
381 if !self.is_tool_available(tool_name) {
383 return Err(ExecutorError::ToolNotFound {
384 tool: tool_name.clone(),
385 });
386 }
387
388 let effective_timeout_ms = timeout_ms.unwrap_or(self.default_timeout_ms);
394 let timeouts = self.timeout_count(tool_name);
395 if timeouts >= TIMEOUT_LIMIT {
396 return Err(ExecutorError::RepeatedTimeouts {
397 tool: tool_name.clone(),
398 timeout_ms: effective_timeout_ms,
399 timeouts,
400 });
401 }
402
403 let resolved_binary = self
405 .resolve_binary(tool_name)
406 .ok_or_else(|| ExecutorError::ToolNotFound {
407 tool: tool_name.clone(),
408 })?;
409 let mut cmd = Command::new(resolved_binary);
410
411 if tool_def.command.len() > 1 {
413 cmd.args(&tool_def.command[1..]);
414 }
415
416 let extra_args = if is_format_mode {
418 &tool_def.format_args
419 } else {
420 &tool_def.lint_args
421 };
422 if !extra_args.is_empty() {
423 cmd.args(extra_args);
424 }
425
426 if tool_def.stdin {
428 cmd.stdin(Stdio::piped());
429 }
430 cmd.stdout(Stdio::piped());
431 cmd.stderr(Stdio::piped());
432
433 let mut child = cmd.spawn().map_err(|e| ExecutorError::IoError {
435 message: format!("Failed to spawn '{tool_name}': {e}"),
436 })?;
437
438 if let Some(state) = &self.run_state {
439 state.record_execution();
440 }
441
442 let mut stdout_handle = child
443 .stdout
444 .take()
445 .map(|stdout| thread::spawn(move || read_pipe_to_string(stdout, true)));
446 let mut stderr_handle = child
447 .stderr
448 .take()
449 .map(|stderr| thread::spawn(move || read_pipe_to_string(stderr, false)));
450
451 if tool_def.stdin
457 && let Some(mut stdin) = child.stdin.take()
458 {
459 #[cfg(unix)]
460 let _sigpipe = SigpipeIgnored::new();
461
462 if let Err(e) = stdin.write_all(input.as_bytes())
463 && e.kind() != std::io::ErrorKind::BrokenPipe
464 {
465 return Err(ExecutorError::IoError {
466 message: format!("Failed to write to stdin: {e}"),
467 });
468 }
469 }
470
471 let timeout = Duration::from_millis(effective_timeout_ms);
473 let status = if timeout.is_zero() {
474 child.wait().map_err(|e| ExecutorError::IoError {
475 message: format!("Failed to wait for '{tool_name}': {e}"),
476 })?
477 } else {
478 let waited = wait::wait_timeout(&mut child, timeout).map_err(|e| ExecutorError::IoError {
479 message: format!("Failed to wait for '{tool_name}': {e}"),
480 })?;
481 match waited {
482 Some(status) => status,
483 None => {
484 let _ = child.kill();
485 let _ = child.wait();
486 drop(stdout_handle.take());
492 drop(stderr_handle.take());
493 self.record_timeout(tool_name);
494 return Err(ExecutorError::Timeout {
495 tool: tool_name.clone(),
496 timeout_ms: timeout.as_millis() as u64,
497 });
498 }
499 }
500 };
501
502 self.clear_timeouts(tool_name);
504
505 let stdout = join_reader(stdout_handle.take()).map_err(|e| ExecutorError::IoError { message: e })?;
506 let stderr = join_reader(stderr_handle.take()).map_err(|e| ExecutorError::IoError { message: e })?;
507 let exit_code = status.code().unwrap_or(-1);
508
509 Ok(ToolOutput {
510 stdout,
511 stderr,
512 exit_code,
513 success: status.success(),
514 })
515 }
516
517 pub(crate) fn format_check(
520 &self,
521 tool_def: &ToolDefinition,
522 native_check: Option<&ToolDefinition>,
523 input: &str,
524 timeout_ms: Option<u64>,
525 ) -> Result<FormatCheckOutput, ExecutorError> {
526 let Some(check) = native_check.filter(|check| self.native_check_available(check, timeout_ms)) else {
527 return self
528 .format(tool_def, input, timeout_ms)
529 .map(FormatCheckOutput::Formatted);
530 };
531 let output = self.execute(check, input, false, timeout_ms)?;
532 if output.success {
533 return Ok(if check.command[0] == "djlint" && !output.stdout.trim().is_empty() {
537 FormatCheckOutput::Formatted(output.stdout)
538 } else {
539 FormatCheckOutput::Native { changed: false }
540 });
541 }
542 let dirty = output.exit_code == 1
546 && output.stderr.trim().is_empty()
547 && if check.command[0] == "shuck" {
548 output.stdout.trim().is_empty()
549 } else {
550 !output.stdout.trim().is_empty() && output.stdout != input
551 };
552 if dirty {
553 Ok(if check.command[0] == "djlint" {
554 FormatCheckOutput::Formatted(output.stdout)
555 } else {
556 FormatCheckOutput::Native { changed: true }
557 })
558 } else {
559 Err(ExecutorError::ExecutionFailed {
560 tool: check.command[0].clone(),
561 message: format!(
562 "Exit code {}: {}",
563 output.exit_code,
564 if output.stderr.trim().is_empty() {
565 &output.stdout
566 } else {
567 &output.stderr
568 }
569 ),
570 })
571 }
572 }
573
574 fn native_check_available(&self, tool_def: &ToolDefinition, timeout_ms: Option<u64>) -> bool {
575 let Some(tool) = tool_def.command.first() else {
576 return false;
577 };
578 if !matches!(tool.as_str(), "djlint" | "shuck") {
579 return false;
580 }
581 let Some(path) = self.resolve_binary(tool).and_then(|path| path.canonicalize().ok()) else {
582 return false;
583 };
584 let Ok(metadata) = path.metadata() else {
585 return false;
586 };
587 let key = (tool.clone(), path, metadata.modified().ok(), metadata.len());
588 let mut capabilities = CHECK_CAPABILITIES
589 .lock()
590 .unwrap_or_else(std::sync::PoisonError::into_inner);
591 if let Some(available) = capabilities.get(&key) {
592 return *available;
593 }
594 let mut probe = tool_def.clone();
595 probe.command = vec![tool.clone(), "--version".to_string()];
596 probe.stdin = false;
597 probe.lint_args.clear();
598 probe.format_args.clear();
599 let available = self
600 .execute(&probe, "", false, timeout_ms)
601 .is_ok_and(|output| output.success && supports_native_check_version(tool, &output.stdout));
602 capabilities.insert(key, available);
603 available
604 }
605
606 pub fn format(
613 &self,
614 tool_def: &ToolDefinition,
615 input: &str,
616 timeout_ms: Option<u64>,
617 ) -> Result<String, ExecutorError> {
618 let output = self.execute(tool_def, input, true, timeout_ms)?;
619
620 if output.success && tool_def.stdout {
621 if output.stdout.trim().is_empty() && !input.trim().is_empty() {
622 return Err(ExecutorError::ExecutionFailed {
623 tool: tool_def.command.first().cloned().unwrap_or_default(),
624 message: "Formatter printed no output for a non-empty code block".to_string(),
625 });
626 }
627 Ok(output.stdout)
628 } else if !output.success {
629 let exit_code = output.exit_code;
630 let stderr = &output.stderr;
631 Err(ExecutorError::ExecutionFailed {
632 tool: tool_def.command.first().cloned().unwrap_or_default(),
633 message: format!("Exit code {exit_code}: {stderr}"),
634 })
635 } else {
636 Err(ExecutorError::ExecutionFailed {
638 tool: tool_def.command.first().cloned().unwrap_or_default(),
639 message: "Formatter doesn't output to stdout".to_string(),
640 })
641 }
642 }
643
644 pub fn lint(
646 &self,
647 tool_def: &ToolDefinition,
648 input: &str,
649 timeout_ms: Option<u64>,
650 ) -> Result<ToolOutput, ExecutorError> {
651 self.execute(tool_def, input, false, timeout_ms)
652 }
653}
654
655fn supports_native_check_version(tool: &str, output: &str) -> bool {
658 let prefix = match tool {
659 "djlint" => "djlint, version ",
660 "shuck" => "shuck ",
661 _ => return false,
662 };
663 let Some(version) = output.trim().strip_prefix(prefix) else {
664 return false;
665 };
666 let parts: Vec<_> = version.split('.').collect();
667 if parts.len() != 3 {
668 return false;
669 }
670 let Ok(parts) = parts
671 .iter()
672 .map(|part| part.parse::<u64>())
673 .collect::<Result<Vec<_>, _>>()
674 else {
675 return false;
676 };
677 let minimum = if tool == "djlint" { [1, 39, 5] } else { [0, 2, 2] };
678 parts.as_slice() >= minimum.as_slice()
679}
680
681fn read_pipe_to_string<R: Read>(mut pipe: R, strict_utf8: bool) -> std::io::Result<String> {
682 let mut buf = Vec::new();
683 pipe.read_to_end(&mut buf)?;
684 if strict_utf8 {
685 String::from_utf8(buf).map_err(|error| {
686 std::io::Error::new(
687 std::io::ErrorKind::InvalidData,
688 format!("Tool stdout is not valid UTF-8: {error}"),
689 )
690 })
691 } else {
692 Ok(String::from_utf8_lossy(&buf).to_string())
693 }
694}
695
696fn join_reader(handle: Option<thread::JoinHandle<std::io::Result<String>>>) -> Result<String, String> {
697 match handle {
698 Some(handle) => match handle.join() {
699 Ok(res) => res.map_err(|e| format!("Failed to read output: {e}")),
700 Err(_) => Err("Output reader thread panicked".to_string()),
701 },
702 None => Ok(String::new()),
703 }
704}
705
706impl Default for ToolExecutor {
707 fn default() -> Self {
708 Self::new(30_000) }
710}
711
712#[cfg(test)]
713mod tests {
714 use super::*;
715 use std::time::Instant;
716
717 #[test]
718 fn bun_fmt_rejects_local_entry_points_and_package_binaries() {
719 for entry in ["fmt", "fmt.ts", "fmt/index.js", "node_modules/.bin/fmt"] {
720 let directory = tempfile::tempdir().unwrap();
721 let path = directory.path().join(entry);
722 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
723 std::fs::write(path, "console.log('not formatted code')").unwrap();
724 let error = check_bun_fmt_scripts(directory.path()).unwrap_err();
725 assert!(error.contains("instead of the built-in formatter"), "{entry}: {error}");
726 }
727 }
728
729 #[test]
730 fn bun_fmt_rejects_scripts_in_the_project_or_an_ancestor() {
731 let directory = tempfile::tempdir().unwrap();
732 let nested = directory.path().join("docs");
733 std::fs::create_dir(&nested).unwrap();
734 let manifest = directory.path().join("package.json");
735 std::fs::write(&manifest, r#"{"scripts":{"fmt":"echo not a formatter"}}"#).unwrap();
736 let error = check_bun_fmt_scripts(&nested).unwrap_err();
737 assert!(error.contains("instead of the built-in formatter"));
738 std::fs::write(&manifest, r#"{"scripts":{"format":"prettier ."}}"#).unwrap();
739 assert!(check_bun_fmt_scripts(&nested).is_ok());
740 std::fs::write(&manifest, "invalid json").unwrap();
741 assert!(check_bun_fmt_scripts(&nested).is_err());
742 }
743
744 #[test]
745 fn test_executor_creation() {
746 let executor = ToolExecutor::new(10_000);
747 assert_eq!(executor.default_timeout_ms, 10_000);
749 }
750
751 #[test]
752 fn test_tool_not_found() {
753 let executor = ToolExecutor::default();
754 let tool_def = ToolDefinition {
755 command: vec!["nonexistent-tool-xyz123".to_string()],
756 stdin: true,
757 stdout: true,
758 lint_args: vec![],
759 format_args: vec![],
760 };
761
762 let result = executor.execute(&tool_def, "test", false, None);
763 assert!(matches!(result, Err(ExecutorError::ToolNotFound { .. })));
764 }
765
766 #[test]
767 fn test_empty_command() {
768 let executor = ToolExecutor::default();
769 let tool_def = ToolDefinition {
770 command: vec![],
771 stdin: true,
772 stdout: true,
773 lint_args: vec![],
774 format_args: vec![],
775 };
776
777 let result = executor.execute(&tool_def, "test", false, None);
778 assert!(matches!(result, Err(ExecutorError::ExecutionFailed { .. })));
779 }
780
781 #[test]
782 #[cfg(unix)]
783 fn test_execute_cat() {
784 let executor = ToolExecutor::isolated(30_000);
785 let tool_def = ToolDefinition {
786 command: vec!["cat".to_string()],
787 stdin: true,
788 stdout: true,
789 lint_args: vec![],
790 format_args: vec![],
791 };
792
793 let result = executor.execute(&tool_def, "hello world", false, None);
794 let output = result.expect("cat should succeed");
795 assert!(output.success);
796 assert_eq!(output.stdout.trim(), "hello world");
797 }
798
799 #[test]
800 #[cfg(unix)]
801 fn test_timeout() {
802 let executor = ToolExecutor::isolated(5);
803 let tool_def = ToolDefinition {
804 command: vec!["sleep".to_string(), "1".to_string()],
805 stdin: false,
806 stdout: true,
807 lint_args: vec![],
808 format_args: vec![],
809 };
810
811 let result = executor.execute(&tool_def, "", false, Some(5));
812 assert!(matches!(result, Err(ExecutorError::Timeout { .. })));
813 }
814
815 #[test]
821 #[cfg(unix)]
822 fn test_performance_exit_is_noticed_without_a_poll_interval() {
823 let executor = ToolExecutor::isolated(30_000);
824 let tool_def = ToolDefinition {
825 command: vec!["true".to_string()],
826 stdin: false,
827 stdout: true,
828 lint_args: vec![],
829 format_args: vec![],
830 };
831
832 let fastest = (0..20)
833 .map(|_| {
834 let started = Instant::now();
835 executor
836 .execute(&tool_def, "", false, None)
837 .expect("true should succeed");
838 started.elapsed()
839 })
840 .min()
841 .unwrap();
842 assert!(
843 fastest < Duration::from_millis(8),
844 "fastest of 20 runs of `true` took {fastest:?}"
845 );
846 }
847
848 #[cfg(unix)]
851 fn descendant_holds_stdout_tool() -> ToolDefinition {
852 ToolDefinition {
853 command: vec![
854 "sh".to_string(),
855 "-c".to_string(),
856 "sleep 30 & exec sleep 30".to_string(),
857 ],
858 stdin: true,
859 stdout: true,
860 lint_args: vec![],
861 format_args: vec![],
862 }
863 }
864
865 #[test]
869 #[cfg(unix)]
870 fn test_timeout_bounds_execution_when_a_descendant_holds_stdout() {
871 let executor = ToolExecutor::isolated(200);
872 let (tx, rx) = std::sync::mpsc::channel();
873 thread::spawn(move || {
874 let started = Instant::now();
875 let result = executor.execute(&descendant_holds_stdout_tool(), "input", true, Some(200));
876 let _ = tx.send((started.elapsed(), result));
877 });
878
879 let (elapsed, result) = rx
882 .recv_timeout(Duration::from_secs(10))
883 .expect("execute() did not return: the timeout bounded nothing");
884 assert!(
885 matches!(result, Err(ExecutorError::Timeout { .. })),
886 "expected a timeout, got {result:?}"
887 );
888 assert!(elapsed < Duration::from_secs(10), "execute() took {elapsed:?}");
889 }
890
891 #[test]
894 #[cfg(unix)]
895 fn test_a_hanging_tool_is_skipped_after_repeated_timeouts() {
896 let executor = ToolExecutor::isolated(50);
897 let tool_def = ToolDefinition {
898 command: vec!["sh".to_string(), "-c".to_string(), "exec sleep 30".to_string()],
899 stdin: true,
900 stdout: true,
901 lint_args: vec![],
902 format_args: vec![],
903 };
904
905 for attempt in 1..=TIMEOUT_LIMIT {
906 let result = executor.execute(&tool_def, "input", true, Some(50));
907 assert!(
908 matches!(result, Err(ExecutorError::Timeout { .. })),
909 "attempt {attempt} should time out, got {result:?}"
910 );
911 }
912
913 let started = Instant::now();
914 let result = executor.execute(&tool_def, "input", true, Some(50));
915 match result {
916 Err(ExecutorError::RepeatedTimeouts {
917 timeouts, timeout_ms, ..
918 }) => {
919 assert_eq!(timeouts, TIMEOUT_LIMIT);
920 assert_eq!(timeout_ms, 50);
921 }
922 other => panic!("expected the tool to be skipped, got {other:?}"),
923 }
924 assert!(
926 started.elapsed() < Duration::from_millis(50),
927 "skipping still took {:?}",
928 started.elapsed()
929 );
930 }
931
932 #[test]
935 #[cfg(unix)]
936 fn test_exiting_normally_clears_earlier_timeouts() {
937 let executor = ToolExecutor::isolated(50);
938 let hangs = ToolDefinition {
940 command: vec!["sh".to_string(), "-c".to_string(), "exec sleep 30".to_string()],
941 stdin: true,
942 stdout: true,
943 lint_args: vec![],
944 format_args: vec![],
945 };
946 let exits = ToolDefinition {
947 command: vec!["sh".to_string(), "-c".to_string(), "cat".to_string()],
948 stdin: true,
949 stdout: true,
950 lint_args: vec![],
951 format_args: vec![],
952 };
953
954 for _ in 0..TIMEOUT_LIMIT - 1 {
955 assert!(matches!(
956 executor.execute(&hangs, "input", true, Some(50)),
957 Err(ExecutorError::Timeout { .. })
958 ));
959 }
960 assert_eq!(executor.timeout_count("sh"), TIMEOUT_LIMIT - 1);
961
962 let output = executor
965 .execute(&exits, "hello", true, Some(10_000))
966 .expect("cat should exit");
967 assert_eq!(output.stdout.trim(), "hello");
968 assert_eq!(executor.timeout_count("sh"), 0, "a clean exit must clear the tally");
969 }
970
971 #[test]
974 fn test_the_shared_tally_carries_across_executors() {
975 let key = "rumdl-test-only-shared-tally-probe";
978 let first = ToolExecutor::new(50);
979 let second = ToolExecutor::new(50);
980 let alone = ToolExecutor::isolated(50);
981
982 let before = second.timeout_count(key);
983 first.record_timeout(key);
984
985 assert_eq!(
986 second.timeout_count(key),
987 before + 1,
988 "executors built for different files must share one tally"
989 );
990 assert_eq!(alone.timeout_count(key), 0, "an isolated executor keeps its own tally");
991
992 first.clear_timeouts(key);
993 assert_eq!(second.timeout_count(key), 0);
994 }
995}
996
997#[cfg(test)]
998mod native_check_version_tests {
999 use super::supports_native_check_version;
1000
1001 #[test]
1002 fn only_recognized_stable_versions_with_verified_stdin_support_are_admitted() {
1003 for (tool, version, expected) in [
1004 ("djlint", "djlint, version 1.39.4", false),
1005 ("djlint", "djlint, version 1.39.5", true),
1006 ("djlint", "djlint, version 1.100.0", true),
1007 ("shuck", "shuck 0.2.1", false),
1008 ("shuck", "shuck 0.2.2", true),
1009 ("shuck", "shuck 0.10.0", true),
1010 ("djlint", "djlint, version 1.46.4rc1", false),
1011 ("djlint", "djlint, version 1.39.5.post1", false),
1012 ("shuck", "shuck 0.2.3-beta.1", false),
1013 ("shuck", "shuck v0.2.2", false),
1014 ("shuck", "shuck 0.2.2+build", false),
1015 ("shuck", "shuck 0.2", false),
1016 ("shuck", "shuck 0.2.2.1", false),
1017 ("djlint", "shuck 1.46.4", false),
1018 ("djlint", "", false),
1019 ] {
1020 assert_eq!(supports_native_check_version(tool, version), expected, "{version}");
1021 }
1022 }
1023}