Skip to main content

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;