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 pub(crate) fn project_root(&self) -> &std::path::Path {
281 &self.project_root
282 }
283
284 fn timeout_key(&self, tool_name: &str) -> String {
286 self.resolve_binary(tool_name).map_or_else(
287 || tool_name.into(),
288 |path| {
289 std::fs::canonicalize(&path)
290 .unwrap_or(path)
291 .to_string_lossy()
292 .into_owned()
293 },
294 )
295 }
296
297 fn timeout_count(&self, tool_name: &str) -> u32 {
298 self.timeout_counts
299 .lock()
300 .unwrap()
301 .get(&self.timeout_key(tool_name))
302 .copied()
303 .unwrap_or(0)
304 }
305
306 fn record_timeout(&self, tool_name: &str) {
308 *self
309 .timeout_counts
310 .lock()
311 .unwrap()
312 .entry(self.timeout_key(tool_name))
313 .or_insert(0) += 1;
314 }
315
316 fn clear_timeouts(&self, tool_name: &str) {
318 self.timeout_counts.lock().unwrap().remove(&self.timeout_key(tool_name));
319 }
320
321 pub fn is_tool_available(&self, tool_name: &str) -> bool {
323 {
325 let cache = self.tool_cache.lock().unwrap();
326 if let Some(&available) = cache.get(tool_name) {
327 return available;
328 }
329 }
330
331 let available = self.check_tool_exists(tool_name);
334
335 {
337 let mut cache = self.tool_cache.lock().unwrap();
338 cache.insert(tool_name.to_string(), available);
339 }
340
341 available
342 }
343
344 fn check_tool_exists(&self, tool_name: &str) -> bool {
346 self.resolve_binary(tool_name).is_some()
347 }
348
349 pub fn execute(
360 &self,
361 tool_def: &ToolDefinition,
362 input: &str,
363 is_format_mode: bool,
364 timeout_ms: Option<u64>,
365 ) -> Result<ToolOutput, ExecutorError> {
366 if tool_def.command.is_empty() {
367 return Err(ExecutorError::ExecutionFailed {
368 tool: "unknown".to_string(),
369 message: "Empty command".to_string(),
370 });
371 }
372
373 let tool_name = &tool_def.command[0];
374
375 if tool_name == "bun" && tool_def.command.get(1).is_some_and(|arg| arg == "fmt") {
376 let cwd = std::env::current_dir().map_err(|error| ExecutorError::IoError {
377 message: error.to_string(),
378 })?;
379 check_bun_fmt_scripts(&cwd).map_err(|message| ExecutorError::ExecutionFailed {
380 tool: tool_name.clone(),
381 message,
382 })?;
383 }
384
385 if !self.is_tool_available(tool_name) {
387 return Err(ExecutorError::ToolNotFound {
388 tool: tool_name.clone(),
389 });
390 }
391
392 let effective_timeout_ms = timeout_ms.unwrap_or(self.default_timeout_ms);
398 let timeouts = self.timeout_count(tool_name);
399 if timeouts >= TIMEOUT_LIMIT {
400 return Err(ExecutorError::RepeatedTimeouts {
401 tool: tool_name.clone(),
402 timeout_ms: effective_timeout_ms,
403 timeouts,
404 });
405 }
406
407 let resolved_binary = self
409 .resolve_binary(tool_name)
410 .ok_or_else(|| ExecutorError::ToolNotFound {
411 tool: tool_name.clone(),
412 })?;
413 let mut cmd = Command::new(resolved_binary);
414
415 if tool_def.command.len() > 1 {
417 cmd.args(&tool_def.command[1..]);
418 }
419
420 let extra_args = if is_format_mode {
422 &tool_def.format_args
423 } else {
424 &tool_def.lint_args
425 };
426 if !extra_args.is_empty() {
427 cmd.args(extra_args);
428 }
429
430 if tool_def.stdin {
432 cmd.stdin(Stdio::piped());
433 }
434 cmd.stdout(Stdio::piped());
435 cmd.stderr(Stdio::piped());
436
437 let mut child = cmd.spawn().map_err(|e| ExecutorError::IoError {
439 message: format!("Failed to spawn '{tool_name}': {e}"),
440 })?;
441
442 if let Some(state) = &self.run_state {
443 state.record_execution();
444 }
445
446 let mut stdout_handle = child
447 .stdout
448 .take()
449 .map(|stdout| thread::spawn(move || read_pipe_to_string(stdout, true)));
450 let mut stderr_handle = child
451 .stderr
452 .take()
453 .map(|stderr| thread::spawn(move || read_pipe_to_string(stderr, false)));
454
455 if tool_def.stdin
461 && let Some(mut stdin) = child.stdin.take()
462 {
463 #[cfg(unix)]
464 let _sigpipe = SigpipeIgnored::new();
465
466 if let Err(e) = stdin.write_all(input.as_bytes())
467 && e.kind() != std::io::ErrorKind::BrokenPipe
468 {
469 return Err(ExecutorError::IoError {
470 message: format!("Failed to write to stdin: {e}"),
471 });
472 }
473 }
474
475 let timeout = Duration::from_millis(effective_timeout_ms);
477 let status = if timeout.is_zero() {
478 child.wait().map_err(|e| ExecutorError::IoError {
479 message: format!("Failed to wait for '{tool_name}': {e}"),
480 })?
481 } else {
482 let waited = wait::wait_timeout(&mut child, timeout).map_err(|e| ExecutorError::IoError {
483 message: format!("Failed to wait for '{tool_name}': {e}"),
484 })?;
485 match waited {
486 Some(status) => status,
487 None => {
488 let _ = child.kill();
489 let _ = child.wait();
490 drop(stdout_handle.take());
496 drop(stderr_handle.take());
497 self.record_timeout(tool_name);
498 return Err(ExecutorError::Timeout {
499 tool: tool_name.clone(),
500 timeout_ms: timeout.as_millis() as u64,
501 });
502 }
503 }
504 };
505
506 self.clear_timeouts(tool_name);
508
509 let stdout = join_reader(stdout_handle.take()).map_err(|e| ExecutorError::IoError { message: e })?;
510 let stderr = join_reader(stderr_handle.take()).map_err(|e| ExecutorError::IoError { message: e })?;
511 let exit_code = status.code().unwrap_or(-1);
512
513 Ok(ToolOutput {
514 stdout,
515 stderr,
516 exit_code,
517 success: status.success(),
518 })
519 }
520
521 pub(crate) fn format_check(
524 &self,
525 tool_def: &ToolDefinition,
526 native_check: Option<&ToolDefinition>,
527 input: &str,
528 timeout_ms: Option<u64>,
529 ) -> Result<FormatCheckOutput, ExecutorError> {
530 let Some(check) = native_check.filter(|check| self.native_check_available(check, timeout_ms)) else {
531 return self
532 .format(tool_def, input, timeout_ms)
533 .map(FormatCheckOutput::Formatted);
534 };
535 let output = self.execute(check, input, false, timeout_ms)?;
536 if output.success {
537 return Ok(if check.command[0] == "djlint" && !output.stdout.trim().is_empty() {
541 FormatCheckOutput::Formatted(output.stdout)
542 } else {
543 FormatCheckOutput::Native { changed: false }
544 });
545 }
546 let dirty = output.exit_code == 1
550 && output.stderr.trim().is_empty()
551 && if check.command[0] == "shuck" {
552 output.stdout.trim().is_empty()
553 } else {
554 !output.stdout.trim().is_empty() && output.stdout != input
555 };
556 if dirty {
557 Ok(if check.command[0] == "djlint" {
558 FormatCheckOutput::Formatted(output.stdout)
559 } else {
560 FormatCheckOutput::Native { changed: true }
561 })
562 } else {
563 Err(ExecutorError::ExecutionFailed {
564 tool: check.command[0].clone(),
565 message: format!(
566 "Exit code {}: {}",
567 output.exit_code,
568 if output.stderr.trim().is_empty() {
569 &output.stdout
570 } else {
571 &output.stderr
572 }
573 ),
574 })
575 }
576 }
577
578 fn native_check_available(&self, tool_def: &ToolDefinition, timeout_ms: Option<u64>) -> bool {
579 let Some(tool) = tool_def.command.first() else {
580 return false;
581 };
582 if !matches!(tool.as_str(), "djlint" | "shuck") {
583 return false;
584 }
585 let Some(path) = self.resolve_binary(tool).and_then(|path| path.canonicalize().ok()) else {
586 return false;
587 };
588 let Ok(metadata) = path.metadata() else {
589 return false;
590 };
591 let key = (tool.clone(), path, metadata.modified().ok(), metadata.len());
592 let mut capabilities = CHECK_CAPABILITIES
593 .lock()
594 .unwrap_or_else(std::sync::PoisonError::into_inner);
595 if let Some(available) = capabilities.get(&key) {
596 return *available;
597 }
598 let mut probe = tool_def.clone();
599 probe.command = vec![tool.clone(), "--version".to_string()];
600 probe.stdin = false;
601 probe.lint_args.clear();
602 probe.format_args.clear();
603 let available = self
604 .execute(&probe, "", false, timeout_ms)
605 .is_ok_and(|output| output.success && supports_native_check_version(tool, &output.stdout));
606 capabilities.insert(key, available);
607 available
608 }
609
610 pub fn format(
617 &self,
618 tool_def: &ToolDefinition,
619 input: &str,
620 timeout_ms: Option<u64>,
621 ) -> Result<String, ExecutorError> {
622 let output = self.execute(tool_def, input, true, timeout_ms)?;
623
624 if output.success && tool_def.stdout {
625 if output.stdout.trim().is_empty() && !input.trim().is_empty() {
626 return Err(ExecutorError::ExecutionFailed {
627 tool: tool_def.command.first().cloned().unwrap_or_default(),
628 message: "Formatter printed no output for a non-empty code block".to_string(),
629 });
630 }
631 Ok(output.stdout)
632 } else if !output.success {
633 let exit_code = output.exit_code;
634 let stderr = &output.stderr;
635 Err(ExecutorError::ExecutionFailed {
636 tool: tool_def.command.first().cloned().unwrap_or_default(),
637 message: format!("Exit code {exit_code}: {stderr}"),
638 })
639 } else {
640 Err(ExecutorError::ExecutionFailed {
642 tool: tool_def.command.first().cloned().unwrap_or_default(),
643 message: "Formatter doesn't output to stdout".to_string(),
644 })
645 }
646 }
647
648 pub fn lint(
650 &self,
651 tool_def: &ToolDefinition,
652 input: &str,
653 timeout_ms: Option<u64>,
654 ) -> Result<ToolOutput, ExecutorError> {
655 self.execute(tool_def, input, false, timeout_ms)
656 }
657}
658
659fn supports_native_check_version(tool: &str, output: &str) -> bool {
662 let prefix = match tool {
663 "djlint" => "djlint, version ",
664 "shuck" => "shuck ",
665 _ => return false,
666 };
667 let Some(version) = output.trim().strip_prefix(prefix) else {
668 return false;
669 };
670 let parts: Vec<_> = version.split('.').collect();
671 if parts.len() != 3 {
672 return false;
673 }
674 let Ok(parts) = parts
675 .iter()
676 .map(|part| part.parse::<u64>())
677 .collect::<Result<Vec<_>, _>>()
678 else {
679 return false;
680 };
681 let minimum = if tool == "djlint" { [1, 39, 5] } else { [0, 2, 2] };
682 parts.as_slice() >= minimum.as_slice()
683}
684
685fn read_pipe_to_string<R: Read>(mut pipe: R, strict_utf8: bool) -> std::io::Result<String> {
686 let mut buf = Vec::new();
687 pipe.read_to_end(&mut buf)?;
688 if strict_utf8 {
689 String::from_utf8(buf).map_err(|error| {
690 std::io::Error::new(
691 std::io::ErrorKind::InvalidData,
692 format!("Tool stdout is not valid UTF-8: {error}"),
693 )
694 })
695 } else {
696 Ok(String::from_utf8_lossy(&buf).to_string())
697 }
698}
699
700fn join_reader(handle: Option<thread::JoinHandle<std::io::Result<String>>>) -> Result<String, String> {
701 match handle {
702 Some(handle) => match handle.join() {
703 Ok(res) => res.map_err(|e| format!("Failed to read output: {e}")),
704 Err(_) => Err("Output reader thread panicked".to_string()),
705 },
706 None => Ok(String::new()),
707 }
708}
709
710impl Default for ToolExecutor {
711 fn default() -> Self {
712 Self::new(30_000) }
714}
715
716#[cfg(test)]
717mod tests {
718 use super::*;
719 use std::time::Instant;
720
721 #[test]
722 fn bun_fmt_rejects_local_entry_points_and_package_binaries() {
723 for entry in ["fmt", "fmt.ts", "fmt/index.js", "node_modules/.bin/fmt"] {
724 let directory = tempfile::tempdir().unwrap();
725 let path = directory.path().join(entry);
726 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
727 std::fs::write(path, "console.log('not formatted code')").unwrap();
728 let error = check_bun_fmt_scripts(directory.path()).unwrap_err();
729 assert!(error.contains("instead of the built-in formatter"), "{entry}: {error}");
730 }
731 }
732
733 #[test]
734 fn bun_fmt_rejects_scripts_in_the_project_or_an_ancestor() {
735 let directory = tempfile::tempdir().unwrap();
736 let nested = directory.path().join("docs");
737 std::fs::create_dir(&nested).unwrap();
738 let manifest = directory.path().join("package.json");
739 std::fs::write(&manifest, r#"{"scripts":{"fmt":"echo not a formatter"}}"#).unwrap();
740 let error = check_bun_fmt_scripts(&nested).unwrap_err();
741 assert!(error.contains("instead of the built-in formatter"));
742 std::fs::write(&manifest, r#"{"scripts":{"format":"prettier ."}}"#).unwrap();
743 assert!(check_bun_fmt_scripts(&nested).is_ok());
744 std::fs::write(&manifest, "invalid json").unwrap();
745 assert!(check_bun_fmt_scripts(&nested).is_err());
746 }
747
748 #[test]
749 fn test_executor_creation() {
750 let executor = ToolExecutor::new(10_000);
751 assert_eq!(executor.default_timeout_ms, 10_000);
753 }
754
755 #[test]
756 fn test_tool_not_found() {
757 let executor = ToolExecutor::default();
758 let tool_def = ToolDefinition {
759 command: vec!["nonexistent-tool-xyz123".to_string()],
760 stdin: true,
761 stdout: true,
762 lint_args: vec![],
763 format_args: vec![],
764 };
765
766 let result = executor.execute(&tool_def, "test", false, None);
767 assert!(matches!(result, Err(ExecutorError::ToolNotFound { .. })));
768 }
769
770 #[test]
771 fn test_empty_command() {
772 let executor = ToolExecutor::default();
773 let tool_def = ToolDefinition {
774 command: vec![],
775 stdin: true,
776 stdout: true,
777 lint_args: vec![],
778 format_args: vec![],
779 };
780
781 let result = executor.execute(&tool_def, "test", false, None);
782 assert!(matches!(result, Err(ExecutorError::ExecutionFailed { .. })));
783 }
784
785 #[test]
786 #[cfg(unix)]
787 fn test_execute_cat() {
788 let executor = ToolExecutor::isolated(30_000);
789 let tool_def = ToolDefinition {
790 command: vec!["cat".to_string()],
791 stdin: true,
792 stdout: true,
793 lint_args: vec![],
794 format_args: vec![],
795 };
796
797 let result = executor.execute(&tool_def, "hello world", false, None);
798 let output = result.expect("cat should succeed");
799 assert!(output.success);
800 assert_eq!(output.stdout.trim(), "hello world");
801 }
802
803 #[test]
804 #[cfg(unix)]
805 fn test_timeout() {
806 let executor = ToolExecutor::isolated(5);
807 let tool_def = ToolDefinition {
808 command: vec!["sleep".to_string(), "1".to_string()],
809 stdin: false,
810 stdout: true,
811 lint_args: vec![],
812 format_args: vec![],
813 };
814
815 let result = executor.execute(&tool_def, "", false, Some(5));
816 assert!(matches!(result, Err(ExecutorError::Timeout { .. })));
817 }
818
819 #[test]
825 #[cfg(unix)]
826 fn test_performance_exit_is_noticed_without_a_poll_interval() {
827 let executor = ToolExecutor::isolated(30_000);
828 let tool_def = ToolDefinition {
829 command: vec!["true".to_string()],
830 stdin: false,
831 stdout: true,
832 lint_args: vec![],
833 format_args: vec![],
834 };
835
836 let fastest = (0..20)
837 .map(|_| {
838 let started = Instant::now();
839 executor
840 .execute(&tool_def, "", false, None)
841 .expect("true should succeed");
842 started.elapsed()
843 })
844 .min()
845 .unwrap();
846 assert!(
847 fastest < Duration::from_millis(8),
848 "fastest of 20 runs of `true` took {fastest:?}"
849 );
850 }
851
852 #[cfg(unix)]
855 fn descendant_holds_stdout_tool() -> ToolDefinition {
856 ToolDefinition {
857 command: vec![
858 "sh".to_string(),
859 "-c".to_string(),
860 "sleep 30 & exec sleep 30".to_string(),
861 ],
862 stdin: true,
863 stdout: true,
864 lint_args: vec![],
865 format_args: vec![],
866 }
867 }
868
869 #[test]
873 #[cfg(unix)]
874 fn test_timeout_bounds_execution_when_a_descendant_holds_stdout() {
875 let executor = ToolExecutor::isolated(200);
876 let (tx, rx) = std::sync::mpsc::channel();
877 thread::spawn(move || {
878 let started = Instant::now();
879 let result = executor.execute(&descendant_holds_stdout_tool(), "input", true, Some(200));
880 let _ = tx.send((started.elapsed(), result));
881 });
882
883 let (elapsed, result) = rx
886 .recv_timeout(Duration::from_secs(10))
887 .expect("execute() did not return: the timeout bounded nothing");
888 assert!(
889 matches!(result, Err(ExecutorError::Timeout { .. })),
890 "expected a timeout, got {result:?}"
891 );
892 assert!(elapsed < Duration::from_secs(10), "execute() took {elapsed:?}");
893 }
894
895 #[test]
898 #[cfg(unix)]
899 fn test_a_hanging_tool_is_skipped_after_repeated_timeouts() {
900 let executor = ToolExecutor::isolated(50);
901 let tool_def = ToolDefinition {
902 command: vec!["sh".to_string(), "-c".to_string(), "exec sleep 30".to_string()],
903 stdin: true,
904 stdout: true,
905 lint_args: vec![],
906 format_args: vec![],
907 };
908
909 for attempt in 1..=TIMEOUT_LIMIT {
910 let result = executor.execute(&tool_def, "input", true, Some(50));
911 assert!(
912 matches!(result, Err(ExecutorError::Timeout { .. })),
913 "attempt {attempt} should time out, got {result:?}"
914 );
915 }
916
917 let started = Instant::now();
918 let result = executor.execute(&tool_def, "input", true, Some(50));
919 match result {
920 Err(ExecutorError::RepeatedTimeouts {
921 timeouts, timeout_ms, ..
922 }) => {
923 assert_eq!(timeouts, TIMEOUT_LIMIT);
924 assert_eq!(timeout_ms, 50);
925 }
926 other => panic!("expected the tool to be skipped, got {other:?}"),
927 }
928 assert!(
930 started.elapsed() < Duration::from_millis(50),
931 "skipping still took {:?}",
932 started.elapsed()
933 );
934 }
935
936 #[test]
939 #[cfg(unix)]
940 fn test_exiting_normally_clears_earlier_timeouts() {
941 let executor = ToolExecutor::isolated(50);
942 let hangs = ToolDefinition {
944 command: vec!["sh".to_string(), "-c".to_string(), "exec sleep 30".to_string()],
945 stdin: true,
946 stdout: true,
947 lint_args: vec![],
948 format_args: vec![],
949 };
950 let exits = ToolDefinition {
951 command: vec!["sh".to_string(), "-c".to_string(), "cat".to_string()],
952 stdin: true,
953 stdout: true,
954 lint_args: vec![],
955 format_args: vec![],
956 };
957
958 for _ in 0..TIMEOUT_LIMIT - 1 {
959 assert!(matches!(
960 executor.execute(&hangs, "input", true, Some(50)),
961 Err(ExecutorError::Timeout { .. })
962 ));
963 }
964 assert_eq!(executor.timeout_count("sh"), TIMEOUT_LIMIT - 1);
965
966 let output = executor
969 .execute(&exits, "hello", true, Some(10_000))
970 .expect("cat should exit");
971 assert_eq!(output.stdout.trim(), "hello");
972 assert_eq!(executor.timeout_count("sh"), 0, "a clean exit must clear the tally");
973 }
974
975 #[test]
978 fn test_the_shared_tally_carries_across_executors() {
979 let key = "rumdl-test-only-shared-tally-probe";
982 let first = ToolExecutor::new(50);
983 let second = ToolExecutor::new(50);
984 let alone = ToolExecutor::isolated(50);
985
986 let before = second.timeout_count(key);
987 first.record_timeout(key);
988
989 assert_eq!(
990 second.timeout_count(key),
991 before + 1,
992 "executors built for different files must share one tally"
993 );
994 assert_eq!(alone.timeout_count(key), 0, "an isolated executor keeps its own tally");
995
996 first.clear_timeouts(key);
997 assert_eq!(second.timeout_count(key), 0);
998 }
999}
1000
1001#[cfg(test)]
1002mod native_check_version_tests {
1003 use super::supports_native_check_version;
1004
1005 #[test]
1006 fn only_recognized_stable_versions_with_verified_stdin_support_are_admitted() {
1007 for (tool, version, expected) in [
1008 ("djlint", "djlint, version 1.39.4", false),
1009 ("djlint", "djlint, version 1.39.5", true),
1010 ("djlint", "djlint, version 1.100.0", true),
1011 ("shuck", "shuck 0.2.1", false),
1012 ("shuck", "shuck 0.2.2", true),
1013 ("shuck", "shuck 0.10.0", true),
1014 ("djlint", "djlint, version 1.46.4rc1", false),
1015 ("djlint", "djlint, version 1.39.5.post1", false),
1016 ("shuck", "shuck 0.2.3-beta.1", false),
1017 ("shuck", "shuck v0.2.2", false),
1018 ("shuck", "shuck 0.2.2+build", false),
1019 ("shuck", "shuck 0.2", false),
1020 ("shuck", "shuck 0.2.2.1", false),
1021 ("djlint", "shuck 1.46.4", false),
1022 ("djlint", "", false),
1023 ] {
1024 assert_eq!(supports_native_check_version(tool, version), expected, "{version}");
1025 }
1026 }
1027}