Skip to main content

verctl/
process.rs

1use anyhow::{Context, Result, bail, ensure};
2use std::io::{self, Read, Write};
3use std::sync::Arc;
4use std::sync::atomic::{AtomicBool, Ordering};
5use std::thread::{self, JoinHandle};
6use std::time::Duration;
7
8pub const DEFAULT_TIMEOUT: Duration = Duration::from_mins(1);
9pub const DEFAULT_OUTPUT_LIMIT: usize = 8 * 1024 * 1024;
10
11/// Run an argv with stdin piped and stdout captured.
12///
13/// `duct` owns the pipe lifetime so a streaming child cannot deadlock
14/// the parent the way a sequential `write_all` + `wait_with_output` can.
15/// stdout/stderr are read incrementally and killed at `output_limit`.
16pub fn filter(argv: &[String], stdin: &str, env: &[(&str, &str)]) -> Result<String> {
17    filter_limited(argv, stdin, env, DEFAULT_TIMEOUT, DEFAULT_OUTPUT_LIMIT)
18}
19
20/// Run an argv with no stdin. Used by `publish`.
21pub fn run_limited(argv: &[String], env: &[(&str, &str)], timeout: Duration) -> Result<String> {
22    filter_limited(argv, "", env, timeout, DEFAULT_OUTPUT_LIMIT)
23}
24
25/// The most stderr [`run_inherit`] keeps for its error message.
26const STDERR_TAIL: usize = 64 * 1024;
27
28/// Inherit stdout and stream stderr so Actions show cargo/bun as they run.
29/// The last [`STDERR_TAIL`] bytes of stderr also go into the error, so a
30/// caller such as `publish` can tell an already-published version apart.
31pub fn run_inherit(argv: &[String], timeout: Duration) -> Result<()> {
32    ensure!(!argv.is_empty(), "driver argv is empty");
33    let (stderr_r, stderr_w) = os_pipe::pipe().context("command stderr pipe")?;
34    let handle = duct::cmd(&argv[0], &argv[1..])
35        .stderr_file(stderr_w)
36        .unchecked()
37        .start()
38        .context("spawn command")?;
39    let tail = thread::spawn(move || stream_tail(stderr_r));
40    let waited = match handle.wait_timeout(timeout) {
41        Ok(output) => output.is_some(),
42        Err(error) => {
43            let _ = handle.kill();
44            let _ = handle.wait();
45            let _ = tail.join();
46            return Err(error).context("command");
47        }
48    };
49    if !waited {
50        let _ = handle.kill();
51        let _ = handle.wait();
52        let _ = tail.join();
53        bail!("command timed out after {timeout:?}");
54    }
55    let stderr = tail
56        .join()
57        .map_err(|_| anyhow::anyhow!("stderr reader panicked"))?;
58    let status = handle
59        .try_wait()
60        .context("command")?
61        .context("command exited without a status")?
62        .status;
63    if !status.success() {
64        bail!(
65            "command failed: {}: {}",
66            argv.join(" "),
67            String::from_utf8_lossy(&stderr).trim()
68        );
69    }
70    Ok(())
71}
72
73/// Copies `reader` to this process's stderr and returns its last
74/// [`STDERR_TAIL`] bytes.
75fn stream_tail(mut reader: impl Read) -> Vec<u8> {
76    let mut tail = Vec::new();
77    let mut chunk = [0_u8; 8192];
78    let mut stderr = io::stderr();
79    loop {
80        match reader.read(&mut chunk) {
81            Ok(0) | Err(_) => return tail,
82            Ok(read) => {
83                let _ = stderr.write_all(&chunk[..read]);
84                tail.extend_from_slice(&chunk[..read]);
85                if tail.len() > STDERR_TAIL {
86                    tail.drain(..tail.len() - STDERR_TAIL);
87                }
88            }
89        }
90    }
91}
92
93/// `["cargo", "publish"]` without a `.into()` on every word.
94#[must_use]
95pub fn argv(parts: &[&str]) -> Vec<String> {
96    parts.iter().map(|part| (*part).to_owned()).collect()
97}
98
99pub fn filter_limited(
100    argv: &[String],
101    stdin: &str,
102    env: &[(&str, &str)],
103    timeout: Duration,
104    output_limit: usize,
105) -> Result<String> {
106    ensure!(!argv.is_empty(), "driver argv is empty");
107    let mut expr = duct::cmd(&argv[0], &argv[1..]);
108    for (key, value) in env {
109        expr = expr.env(key, value);
110    }
111    let (stdout_r, stdout_w) = os_pipe::pipe().context("driver stdout pipe")?;
112    let (stderr_r, stderr_w) = os_pipe::pipe().context("driver stderr pipe")?;
113    let handle = Arc::new(
114        expr.stdin_bytes(stdin.as_bytes().to_vec())
115            .stdout_file(stdout_w)
116            .stderr_file(stderr_w)
117            .unchecked()
118            .start()
119            .context("spawn driver command")?,
120    );
121    let mut guard = ChildGuard {
122        handle: Arc::clone(&handle),
123        reaped: false,
124    };
125    let exceeded = Arc::new(AtomicBool::new(false));
126    let stdout_h = spawn_capped(
127        stdout_r,
128        output_limit,
129        Arc::clone(&handle),
130        Arc::clone(&exceeded),
131    );
132    let stderr_h = spawn_capped(
133        stderr_r,
134        output_limit,
135        Arc::clone(&handle),
136        Arc::clone(&exceeded),
137    );
138
139    let waited = match handle.wait_timeout(timeout) {
140        Ok(output) => output.is_some(),
141        Err(error) => {
142            let _ = handle.kill();
143            let _ = handle.wait();
144            let _ = take_capped(stdout_h);
145            let _ = take_capped(stderr_h);
146            guard.reaped = true;
147            return Err(error).context("driver command");
148        }
149    };
150    if !waited {
151        let _ = handle.kill();
152        let _ = handle.wait();
153        let _ = take_capped(stdout_h);
154        let _ = take_capped(stderr_h);
155        guard.reaped = true;
156        bail!("driver command timed out after {timeout:?}");
157    }
158
159    let stdout = take_capped(stdout_h)?;
160    let stderr = take_capped(stderr_h)?;
161    guard.reaped = true;
162
163    if exceeded.load(Ordering::SeqCst) {
164        bail!("driver command output exceeded {output_limit} bytes");
165    }
166    let status = handle
167        .try_wait()
168        .context("driver command")?
169        .context("driver exited without a status")?
170        .status;
171    if !status.success() {
172        let command = argv.join(" ");
173        let stderr = String::from_utf8_lossy(&stderr);
174        let stderr = stderr.trim();
175        if stderr.is_empty() {
176            bail!("driver command failed: {command}: {status}");
177        }
178        bail!("driver command failed: {command}: {status}: {stderr}");
179    }
180    String::from_utf8(stdout).context("driver stdout is not UTF-8")
181}
182
183struct ChildGuard {
184    handle: Arc<duct::Handle>,
185    reaped: bool,
186}
187
188impl Drop for ChildGuard {
189    fn drop(&mut self) {
190        if !self.reaped {
191            let _ = self.handle.kill();
192            let _ = self.handle.try_wait();
193        }
194    }
195}
196
197enum CapRead {
198    Bytes(Vec<u8>),
199    Exceeded,
200}
201
202fn spawn_capped(
203    reader: os_pipe::PipeReader,
204    limit: usize,
205    handle: Arc<duct::Handle>,
206    exceeded: Arc<AtomicBool>,
207) -> JoinHandle<io::Result<CapRead>> {
208    thread::spawn(move || {
209        let result = read_capped(reader, limit);
210        if matches!(result, Ok(CapRead::Exceeded)) {
211            exceeded.store(true, Ordering::SeqCst);
212            let _ = handle.kill();
213        }
214        result
215    })
216}
217
218fn read_capped(mut reader: os_pipe::PipeReader, limit: usize) -> io::Result<CapRead> {
219    let mut buf = Vec::new();
220    let mut chunk = [0_u8; 8192];
221    loop {
222        let n = reader.read(&mut chunk)?;
223        if n == 0 {
224            return Ok(CapRead::Bytes(buf));
225        }
226        if buf.len().saturating_add(n) > limit {
227            return Ok(CapRead::Exceeded);
228        }
229        buf.extend_from_slice(&chunk[..n]);
230    }
231}
232
233fn take_capped(handle: JoinHandle<io::Result<CapRead>>) -> Result<Vec<u8>> {
234    match handle.join() {
235        Ok(Ok(CapRead::Bytes(bytes))) => Ok(bytes),
236        Ok(Ok(CapRead::Exceeded)) => bail!("driver command output exceeded the cap"),
237        Ok(Err(error)) => Err(error).context("read driver output"),
238        Err(_) => bail!("driver output reader panicked"),
239    }
240}
241
242#[cfg(test)]
243mod tests {
244    use super::{filter, filter_limited, run_inherit};
245    use std::time::{Duration, Instant};
246
247    #[test]
248    fn a_failed_command_reports_its_stderr() {
249        let error = run_inherit(
250            &[
251                "sh".into(),
252                "-c".into(),
253                "echo 'crate x@0.0.1 already exists on crates.io index' >&2; exit 1".into(),
254            ],
255            Duration::from_secs(5),
256        )
257        .expect_err("failure");
258        let message = format!("{error:#}");
259        assert!(
260            message.contains("already exists on crates.io index"),
261            "{message}"
262        );
263    }
264
265    #[test]
266    fn filter_returns_stdout() {
267        let out = filter(&["tr".into(), "-d".into(), "\n".into()], "1.2.3\n", &[]).expect("filter");
268        assert_eq!(out, "1.2.3");
269    }
270
271    #[test]
272    fn filter_times_out_and_kills() {
273        let start = Instant::now();
274        let error = filter_limited(
275            &["sleep".into(), "30".into()],
276            "",
277            &[],
278            Duration::from_millis(200),
279            1024,
280        )
281        .expect_err("timeout");
282        let message = format!("{error:#}");
283        assert!(message.contains("timed out"), "{message}");
284        assert!(
285            start.elapsed() < Duration::from_secs(5),
286            "kill did not return promptly: {:?}",
287            start.elapsed()
288        );
289    }
290
291    #[test]
292    fn filter_rejects_oversized_stdout_without_buffering_it() {
293        let start = Instant::now();
294        let error = filter_limited(
295            &[
296                "sh".into(),
297                "-c".into(),
298                format!("while :; do echo {}; done", "x".repeat(64)),
299            ],
300            "",
301            &[],
302            Duration::from_secs(10),
303            4096,
304        )
305        .expect_err("limit");
306        let message = format!("{error:#}");
307        assert!(message.contains("exceeded"), "{message}");
308        assert!(
309            start.elapsed() < Duration::from_secs(3),
310            "cap waited for the firehose instead of killing: {:?}",
311            start.elapsed()
312        );
313    }
314
315    #[test]
316    fn filter_reports_nonzero_status() {
317        let error = filter(&["false".into()], "", &[]).expect_err("fail");
318        assert_eq!(
319            format!("{error:#}"),
320            "driver command failed: false: exit status: 1"
321        );
322    }
323
324    #[test]
325    fn filter_reports_the_stderr_of_a_failed_command() {
326        let error = filter(
327            &["sh".into(), "-c".into(), "echo boom >&2; exit 3".into()],
328            "",
329            &[],
330        )
331        .expect_err("fail");
332        assert_eq!(
333            format!("{error:#}"),
334            "driver command failed: sh -c echo boom >&2; exit 3: exit status: 3: boom"
335        );
336    }
337}