mini_build/tool.rs
1use std::collections::BTreeMap;
2use std::ffi::OsString;
3use std::io::Read;
4use std::path::{Path, PathBuf};
5use std::process::{Child, Command, ExitStatus, Stdio};
6use std::time::{Duration, Instant};
7
8/// Files grouped by the directory that contains them, in deterministic order.
9///
10/// Grouping is what makes batching safe: a tool writing into one output directory
11/// distinguishes its results only by basename, and basenames collide across directories
12/// but never within one.
13pub(crate) fn group_by_parent(files: &[PathBuf]) -> BTreeMap<PathBuf, Vec<PathBuf>> {
14 let mut groups: BTreeMap<PathBuf, Vec<PathBuf>> = BTreeMap::new();
15 for file in files {
16 let parent = file.parent().unwrap_or(Path::new(".")).to_path_buf();
17 groups.entry(parent).or_default().push(file.clone());
18 }
19 groups
20}
21
22/// How often [`wait_bounded`] asks whether the child has exited.
23///
24/// Short enough that a fast tool is not held up waiting for the next poll, long enough
25/// that a 30-second timeout costs a few thousand cheap syscalls rather than a spin.
26const POLL_INTERVAL: Duration = Duration::from_millis(5);
27
28/// Default ceiling on how long a single external tool invocation may run before
29/// this crate gives up on it: a hung or misbehaving external process must not
30/// block a build indefinitely. Callers may inject a shorter timeout (tests do, to stay
31/// fast); production call sites should pass this constant.
32pub(crate) const TOOL_TIMEOUT: Duration = Duration::from_secs(30);
33
34/// Why an external tool invocation ([`execute`]) failed.
35///
36/// `tool` names the CLI tool (`"lightningcss"`, `"esbuild"`) in every variant so a
37/// caller juggling both CSS and JS pipelines can report which one broke without
38/// threading the name through separately.
39#[derive(Debug)]
40pub enum ToolError {
41 /// `program` was not found on `PATH`. `install_hint` is preset-specific guidance
42 /// (e.g. an npm install command) surfaced verbatim in the error message.
43 NotFound {
44 tool: &'static str,
45 install_hint: &'static str,
46 },
47 /// The process ran and exited with a non-zero status. `stderr` carries whatever
48 /// diagnostic the tool itself printed.
49 ExitNonZero {
50 tool: &'static str,
51 code: Option<i32>,
52 stderr: String,
53 },
54 /// The process did not finish within the given timeout. The process is killed
55 /// (`kill_on_drop`) rather than left to run in the background.
56 TimedOut {
57 tool: &'static str,
58 timeout: Duration,
59 },
60 /// The process exited successfully but `output_path` is missing or empty — the
61 /// tool silently produced nothing.
62 NoOutput { tool: &'static str },
63 /// A spawn/wait failure not attributable to a missing binary.
64 Io(std::io::Error),
65}
66
67impl std::fmt::Display for ToolError {
68 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
69 match self {
70 ToolError::NotFound { tool, install_hint } => {
71 write!(f, "{tool} not found on PATH ({install_hint})")
72 }
73 ToolError::ExitNonZero { tool, code, stderr } => {
74 write!(f, "{tool} exited with code {code:?}: {stderr}")
75 }
76 ToolError::TimedOut { tool, timeout } => {
77 write!(f, "{tool} timed out after {timeout:?}")
78 }
79 ToolError::NoOutput { tool } => {
80 write!(f, "{tool} exited successfully but produced no output")
81 }
82 ToolError::Io(e) => write!(f, "failed to run external tool: {e}"),
83 }
84 }
85}
86
87impl std::error::Error for ToolError {}
88
89/// Run `program args...`, expecting it to write every path in `expected_outputs`.
90///
91/// Bounded by `timeout` (A2): a hung process is killed and reported as `TimedOut`
92/// rather than blocking the caller forever. Captures stderr so a non-zero exit carries
93/// the tool's own diagnostic, not just a bare exit code (A5).
94///
95/// # Errors
96///
97/// - `NotFound` if `program` isn't on `PATH`.
98/// - `ExitNonZero` if the process exits with a non-zero code.
99/// - `TimedOut` if the process doesn't finish within `timeout`.
100/// - `NoOutput` if the process exits 0 but any expected output is missing or empty.
101/// - `Io` for any other spawn/wait failure.
102pub(crate) fn execute(
103 tool: &'static str,
104 install_hint: &'static str,
105 program: &str,
106 args: &[OsString],
107 expected_outputs: &[&Path],
108 timeout: Duration,
109) -> Result<(), ToolError> {
110 let spawned = Command::new(program)
111 .args(args)
112 .stdin(Stdio::null())
113 .stdout(Stdio::piped())
114 .stderr(Stdio::piped())
115 .spawn();
116
117 let mut child = match spawned {
118 Ok(child) => child,
119 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
120 return Err(ToolError::NotFound { tool, install_hint });
121 }
122 Err(e) => return Err(ToolError::Io(e)),
123 };
124
125 // Both pipes are drained on their own threads for the whole life of the process. A
126 // tool that writes more than a pipe buffer would otherwise block forever on a full
127 // pipe while we sit waiting for it to exit — a deadlock that looks exactly like a
128 // hung tool and would only surface on unusually chatty output. `wait_with_output`
129 // did this for us, but it cannot be combined with a timeout.
130 let stdout_reader = child.stdout.take().map(drain_on_thread);
131 let stderr_reader = child.stderr.take().map(drain_on_thread);
132 let collect = |reader: Option<std::thread::JoinHandle<Vec<u8>>>| {
133 reader
134 .and_then(|handle| handle.join().ok())
135 .unwrap_or_default()
136 };
137
138 let status = match wait_bounded(&mut child, timeout) {
139 Ok(Some(status)) => status,
140 Ok(None) => {
141 // `std::process::Child` does not kill on drop the way tokio's does, so a
142 // timed-out child must be killed explicitly or it outlives the build.
143 let _ = child.kill();
144 let _ = child.wait();
145 collect(stdout_reader);
146 collect(stderr_reader);
147 return Err(ToolError::TimedOut { tool, timeout });
148 }
149 Err(e) => {
150 let _ = child.kill();
151 let _ = child.wait();
152 collect(stdout_reader);
153 collect(stderr_reader);
154 return Err(ToolError::Io(e));
155 }
156 };
157
158 let stderr = collect(stderr_reader);
159 collect(stdout_reader);
160
161 if !status.success() {
162 return Err(ToolError::ExitNonZero {
163 tool,
164 code: status.code(),
165 stderr: String::from_utf8_lossy(&stderr).into_owned(),
166 });
167 }
168
169 // Every expected output must exist and be non-empty. A batched invocation that
170 // silently skipped one of its inputs would otherwise look like success and leave a
171 // hole in the build.
172 let produced_everything = expected_outputs
173 .iter()
174 .all(|path| matches!(std::fs::metadata(path), Ok(meta) if meta.len() > 0));
175
176 if produced_everything {
177 Ok(())
178 } else {
179 Err(ToolError::NoOutput { tool })
180 }
181}
182
183/// Read `pipe` to end on a dedicated thread, yielding whatever arrived.
184///
185/// Read errors collapse to the bytes received so far: the process's exit status and the
186/// output file are what decide success, and failing a build because its diagnostic
187/// output could not be captured would report the wrong problem.
188fn drain_on_thread<R: Read + Send + 'static>(mut pipe: R) -> std::thread::JoinHandle<Vec<u8>> {
189 std::thread::spawn(move || {
190 let mut buffer = Vec::new();
191 let _ = pipe.read_to_end(&mut buffer);
192 buffer
193 })
194}
195
196/// Wait for `child` to exit, giving up after `timeout`.
197///
198/// `Ok(None)` means the deadline passed with the child still running — the caller is
199/// responsible for killing it. The polling loop is bounded by the deadline, so it cannot
200/// spin indefinitely regardless of what the child does.
201fn wait_bounded(child: &mut Child, timeout: Duration) -> std::io::Result<Option<ExitStatus>> {
202 let deadline = Instant::now() + timeout;
203 loop {
204 if let Some(status) = child.try_wait()? {
205 return Ok(Some(status));
206 }
207 if Instant::now() >= deadline {
208 return Ok(None);
209 }
210 std::thread::sleep(POLL_INTERVAL);
211 }
212}
213
214/// True if `binary_name` resolves to an executable file somewhere on `PATH`.
215///
216/// A synchronous, no-subprocess scan (just directory listings) — cheap enough to run
217/// at server startup as a fail-fast precondition check (A4) before any build is
218/// attempted, so a missing tool is visible immediately rather than on the first
219/// rebuild that needs it.
220pub(crate) fn locate_on_path(binary_name: &str) -> bool {
221 let Some(path_var) = std::env::var_os("PATH") else {
222 return false;
223 };
224
225 std::env::split_paths(&path_var).any(|dir| is_executable_file(&dir.join(binary_name)))
226}
227
228#[cfg(unix)]
229fn is_executable_file(path: &Path) -> bool {
230 use std::os::unix::fs::PermissionsExt;
231 std::fs::metadata(path)
232 .map(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
233 .unwrap_or(false)
234}
235
236#[cfg(not(unix))]
237fn is_executable_file(path: &Path) -> bool {
238 path.is_file()
239}
240
241#[cfg(test)]
242#[path = "../tests/unit/tool.rs"]
243mod tests;