Skip to main content

rightkit_child_process/
run.rs

1//! One call that runs a program to completion with a deadline, bounded output capture, and no
2//! console window on Windows: the shape of the hand-rolled timeout loops that apps wrote around
3//! [`OwnedCommand`](crate::OwnedCommand) and [`OutputCapture`](crate::OutputCapture).
4//!
5//! The child is owned: it runs in its own process group (or session on Unix) inside a Job
6//! Object on Windows, and when the deadline passes its whole tree is terminated, not only the
7//! leader. Stdin is null. Output beyond each cap is drained and discarded, so a chatty child
8//! never blocks on a full pipe, and [`CapturedOutput::truncated`] says when that happened.
9
10use crate::{CapturedOutput, OutputCapture, OwnedCommand};
11use std::{
12    ffi::OsStr,
13    io,
14    process::{ExitStatus, Stdio},
15    time::Duration,
16};
17
18/// How long to wait for a pipe to end after the child has exited, before returning what was
19/// captured (a grandchild that inherited the pipe can keep it open).
20const DRAIN_WAIT: Duration = Duration::from_secs(2);
21
22/// Limits for [`run`].
23#[derive(Clone, Copy, Debug, Eq, PartialEq)]
24pub struct RunOptions {
25    /// How long the program may run before its tree is terminated.
26    pub timeout: Duration,
27    /// Most bytes kept from stdout (the rest is drained and discarded).
28    pub stdout_cap: usize,
29    /// Most bytes kept from stderr.
30    pub stderr_cap: usize,
31    /// Hide the console window on Windows. The default. `false` leaves it visible.
32    pub no_window: bool,
33}
34
35impl RunOptions {
36    /// A deadline with 1 MiB caps on each stream and no window.
37    pub fn new(timeout: Duration) -> Self {
38        Self {
39            timeout,
40            stdout_cap: 1024 * 1024,
41            stderr_cap: 1024 * 1024,
42            no_window: true,
43        }
44    }
45
46    pub fn stdout_cap(mut self, bytes: usize) -> Self {
47        self.stdout_cap = bytes;
48        self
49    }
50
51    pub fn stderr_cap(mut self, bytes: usize) -> Self {
52        self.stderr_cap = bytes;
53        self
54    }
55
56    pub fn no_window(mut self, hidden: bool) -> Self {
57        self.no_window = hidden;
58        self
59    }
60}
61
62/// What [`run`] observed.
63#[derive(Clone, Debug, Eq, PartialEq)]
64pub struct RunOutput {
65    /// The program's exit status. When `timed_out` is true, the status of the terminated tree's
66    /// leader (on Unix, usually a signal).
67    pub status: ExitStatus,
68    /// True when the deadline passed and the tree was terminated.
69    pub timed_out: bool,
70    pub stdout: CapturedOutput,
71    pub stderr: CapturedOutput,
72}
73
74/// Runs `program` with `args` to completion or until `options.timeout`, then returns its status
75/// and bounded output. Fails only when the program cannot be started; a non-zero exit is a
76/// normal `Ok` with `status` set, so the caller decides what it means.
77pub fn run<I, S>(program: impl AsRef<OsStr>, args: I, options: &RunOptions) -> io::Result<RunOutput>
78where
79    I: IntoIterator<Item = S>,
80    S: AsRef<OsStr>,
81{
82    let mut command = OwnedCommand::new(program);
83    command
84        .command_mut()
85        .args(args)
86        .stdin(Stdio::null())
87        .stdout(Stdio::piped())
88        .stderr(Stdio::piped());
89    if options.no_window {
90        command.windows_hide();
91    } else {
92        command.windows_visible();
93    }
94    let mut child = command.spawn()?;
95    let mut stdout = child
96        .take_stdout()
97        .map(|pipe| OutputCapture::capture(pipe, options.stdout_cap));
98    let mut stderr = child
99        .take_stderr()
100        .map(|pipe| OutputCapture::capture(pipe, options.stderr_cap));
101    // The command runs to completion within the deadline (`Exited`, no signal sent); only at
102    // the deadline is the tree killed and the outcome `Terminated`.
103    let (status, timed_out) = match child.wait_then_kill(options.timeout)? {
104        crate::WaitOutcome::Exited(status) => (status, false),
105        crate::WaitOutcome::Terminated(status) => (status, true),
106    };
107    let stdout = stdout
108        .as_mut()
109        .map_or_else(CapturedOutput::default, |capture| {
110            capture.finish_within(DRAIN_WAIT)
111        });
112    let stderr = stderr
113        .as_mut()
114        .map_or_else(CapturedOutput::default, |capture| {
115            capture.finish_within(DRAIN_WAIT)
116        });
117    Ok(RunOutput {
118        status,
119        timed_out,
120        stdout,
121        stderr,
122    })
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128
129    #[test]
130    fn defaults_hide_the_window_and_cap_both_streams() {
131        let options = RunOptions::new(Duration::from_secs(1));
132        assert!(options.no_window);
133        assert_eq!(options.stdout_cap, 1024 * 1024);
134        assert_eq!(options.stderr_cap, 1024 * 1024);
135        let options = options.stdout_cap(4).stderr_cap(8).no_window(false);
136        assert_eq!((options.stdout_cap, options.stderr_cap), (4, 8));
137        assert!(!options.no_window);
138    }
139
140    #[cfg(unix)]
141    #[test]
142    fn a_quick_program_returns_its_output_and_status() {
143        let options = RunOptions::new(Duration::from_secs(10));
144        let output = run("sh", ["-c", "printf hi; exit 3"], &options).unwrap();
145        assert!(!output.timed_out);
146        assert_eq!(output.status.code(), Some(3));
147        assert_eq!(output.stdout.bytes, b"hi".to_vec());
148    }
149
150    #[cfg(unix)]
151    #[test]
152    fn a_program_running_within_the_deadline_is_not_signalled() {
153        let options = RunOptions::new(Duration::from_secs(10));
154        let output = run("sh", ["-c", "sleep 1; printf done"], &options).unwrap();
155        assert!(!output.timed_out);
156        assert!(output.status.success());
157        assert_eq!(output.stdout.bytes, b"done".to_vec());
158    }
159
160    #[cfg(unix)]
161    #[test]
162    fn a_slow_program_is_terminated_at_the_deadline() {
163        let options = RunOptions::new(Duration::from_millis(200));
164        let output = run("sh", ["-c", "sleep 30"], &options).unwrap();
165        assert!(output.timed_out);
166    }
167
168    #[cfg(unix)]
169    #[test]
170    fn output_past_the_cap_is_truncated_not_blocking() {
171        let options = RunOptions::new(Duration::from_secs(10)).stdout_cap(4);
172        let output = run("sh", ["-c", "head -c 100000 /dev/zero"], &options).unwrap();
173        assert_eq!(output.stdout.bytes.len(), 4);
174        assert!(output.stdout.truncated);
175    }
176}