Skip to main content

rx_runner/
lib.rs

1//! Runtime-neutral child-process execution for polling event loops.
2//!
3//! `rx-runner` tags stdout/stderr lines, bounds retained output, exposes
4//! non-blocking polling and cancellation, and reaps unfinished children on
5//! drop. It uses only the Rust standard library.
6//!
7//! ```no_run
8//! use std::time::Duration;
9//! use rx_runner::CommandSpec;
10//!
11//! # fn main() -> std::io::Result<()> {
12//! let mut process = CommandSpec::new("rustc").arg("--version").spawn()?;
13//! loop {
14//!     let update = process.poll()?;
15//!     for line in update.lines {
16//!         println!("{:?}: {}", line.stream, line.text);
17//!     }
18//!     if update.exit.is_some() {
19//!         break;
20//!     }
21//!     std::thread::sleep(Duration::from_millis(20));
22//! }
23//! # Ok(())
24//! # }
25//! ```
26
27use std::collections::VecDeque;
28use std::ffi::{OsStr, OsString};
29use std::fmt;
30use std::io::{self, BufRead, BufReader, Read};
31use std::path::{Path, PathBuf};
32use std::process::{Child, Command, ExitStatus, Stdio};
33use std::sync::{Arc, Mutex, MutexGuard};
34use std::thread::{self, JoinHandle};
35use std::time::{Duration, Instant};
36
37/// Default number of output lines retained between polls.
38pub const DEFAULT_OUTPUT_CAPACITY: usize = 1_024;
39
40/// A program invocation with separate arguments and process options.
41#[derive(Debug, Clone, PartialEq, Eq, Hash)]
42pub struct CommandSpec {
43    program: OsString,
44    arguments: Vec<OsString>,
45    working_directory: Option<PathBuf>,
46    output_capacity: usize,
47}
48
49impl CommandSpec {
50    /// Create a specification for `program`.
51    pub fn new(program: impl Into<OsString>) -> Self {
52        Self {
53            program: program.into(),
54            arguments: Vec::new(),
55            working_directory: None,
56            output_capacity: DEFAULT_OUTPUT_CAPACITY,
57        }
58    }
59
60    /// Append one argument.
61    #[must_use]
62    pub fn arg(mut self, arg: impl Into<OsString>) -> Self {
63        self.arguments.push(arg.into());
64        self
65    }
66
67    /// Append multiple arguments.
68    #[must_use]
69    pub fn args<I, S>(mut self, args: I) -> Self
70    where
71        I: IntoIterator<Item = S>,
72        S: Into<OsString>,
73    {
74        self.arguments.extend(args.into_iter().map(Into::into));
75        self
76    }
77
78    /// Set the child working directory.
79    #[must_use]
80    pub fn current_dir(mut self, path: impl Into<PathBuf>) -> Self {
81        self.working_directory = Some(path.into());
82        self
83    }
84
85    /// Set the maximum number of unread lines retained between polls.
86    ///
87    /// A capacity of zero discards all lines while still counting them in
88    /// [`ProcessUpdate::dropped_lines`].
89    #[must_use]
90    pub fn output_capacity(mut self, lines: usize) -> Self {
91        self.output_capacity = lines;
92        self
93    }
94
95    /// Return the executable name or path.
96    pub fn program(&self) -> &OsStr {
97        &self.program
98    }
99
100    /// Iterate over the separate process arguments.
101    pub fn arguments(&self) -> impl Iterator<Item = &OsStr> {
102        self.arguments.iter().map(OsString::as_os_str)
103    }
104
105    /// Return the configured child working directory.
106    pub fn working_directory(&self) -> Option<&Path> {
107        self.working_directory.as_deref()
108    }
109
110    /// Spawn the child with piped stdout and stderr.
111    ///
112    /// # Errors
113    ///
114    /// Returns an I/O error when the process or output-reader threads cannot
115    /// be spawned.
116    pub fn spawn(&self) -> io::Result<RunningProcess> {
117        RunningProcess::spawn(self)
118    }
119}
120
121/// Child output source.
122#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
123pub enum OutputStream {
124    /// Standard output.
125    Stdout,
126    /// Standard error.
127    Stderr,
128}
129
130/// One decoded child-output line.
131#[derive(Debug, Clone, PartialEq, Eq, Hash)]
132pub struct OutputLine {
133    /// Stream that produced the line.
134    pub stream: OutputStream,
135    /// UTF-8 text, with invalid bytes replaced and line endings removed.
136    pub text: String,
137}
138
139/// Terminal child status.
140#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
141pub struct ProcessExit {
142    /// Platform exit code, or `None` when terminated by a signal.
143    pub code: Option<i32>,
144    /// Whether the platform exit status reports success.
145    pub success: bool,
146    /// Wall-clock duration since spawn.
147    pub elapsed: Duration,
148}
149
150/// Output and optional terminal status produced by one poll.
151#[derive(Debug, Clone, PartialEq, Eq)]
152pub struct ProcessUpdate {
153    /// Lines retained since the previous poll.
154    pub lines: Vec<OutputLine>,
155    /// Lines dropped because the configured capacity was exceeded.
156    pub dropped_lines: usize,
157    /// Terminal status, returned exactly once.
158    pub exit: Option<ProcessExit>,
159}
160
161#[derive(Debug)]
162struct OutputBuffer {
163    capacity: usize,
164    lines: VecDeque<OutputLine>,
165    dropped_lines: usize,
166}
167
168impl OutputBuffer {
169    fn new(capacity: usize) -> Self {
170        Self {
171            capacity,
172            lines: VecDeque::with_capacity(capacity),
173            dropped_lines: 0,
174        }
175    }
176
177    fn push(&mut self, line: OutputLine) {
178        if self.capacity == 0 {
179            self.dropped_lines += 1;
180            return;
181        }
182        if self.lines.len() == self.capacity {
183            self.lines.pop_front();
184            self.dropped_lines += 1;
185        }
186        self.lines.push_back(line);
187    }
188
189    fn drain(&mut self) -> (Vec<OutputLine>, usize) {
190        let lines = self.lines.drain(..).collect();
191        let dropped_lines = std::mem::take(&mut self.dropped_lines);
192        (lines, dropped_lines)
193    }
194}
195
196/// A running child process with non-blocking output/status polling.
197pub struct RunningProcess {
198    child: Child,
199    output: Arc<Mutex<OutputBuffer>>,
200    readers: Vec<JoinHandle<()>>,
201    started_at: Instant,
202    exit: Option<ProcessExit>,
203    exit_reported: bool,
204}
205
206impl fmt::Debug for RunningProcess {
207    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
208        formatter
209            .debug_struct("RunningProcess")
210            .field("id", &self.child.id())
211            .field("finished", &self.exit.is_some())
212            .finish()
213    }
214}
215
216impl RunningProcess {
217    fn spawn(spec: &CommandSpec) -> io::Result<Self> {
218        let mut command = Command::new(&spec.program);
219        command
220            .args(&spec.arguments)
221            .stdout(Stdio::piped())
222            .stderr(Stdio::piped());
223        if let Some(path) = &spec.working_directory {
224            command.current_dir(path);
225        }
226
227        let mut child = command.spawn()?;
228        let stdout = child
229            .stdout
230            .take()
231            .ok_or_else(|| io::Error::other("child stdout was not piped"))?;
232        let stderr = child
233            .stderr
234            .take()
235            .ok_or_else(|| io::Error::other("child stderr was not piped"))?;
236        let output = Arc::new(Mutex::new(OutputBuffer::new(spec.output_capacity)));
237
238        let stdout_reader = match spawn_reader(stdout, OutputStream::Stdout, Arc::clone(&output)) {
239            Ok(reader) => reader,
240            Err(error) => {
241                let _ = child.kill();
242                let _ = child.wait();
243                return Err(error);
244            }
245        };
246        let stderr_reader = match spawn_reader(stderr, OutputStream::Stderr, Arc::clone(&output)) {
247            Ok(reader) => reader,
248            Err(error) => {
249                let _ = child.kill();
250                let _ = child.wait();
251                let _ = stdout_reader.join();
252                return Err(error);
253            }
254        };
255
256        Ok(Self {
257            child,
258            output,
259            readers: vec![stdout_reader, stderr_reader],
260            started_at: Instant::now(),
261            exit: None,
262            exit_reported: false,
263        })
264    }
265
266    /// Return the platform process identifier.
267    pub fn id(&self) -> u32 {
268        self.child.id()
269    }
270
271    /// Drain retained output and check for process completion without waiting.
272    ///
273    /// # Errors
274    ///
275    /// Returns an I/O error when process status cannot be read or the shared
276    /// output buffer is poisoned.
277    pub fn poll(&mut self) -> io::Result<ProcessUpdate> {
278        if self.exit.is_none()
279            && let Some(status) = self.child.try_wait()?
280        {
281            self.finish(status);
282        }
283
284        let readers_finished = self.readers.iter().all(JoinHandle::is_finished);
285        if readers_finished {
286            self.join_readers();
287        }
288        let (lines, dropped_lines) = self.output()?.drain();
289        let exit = if self.exit_reported || !readers_finished {
290            None
291        } else {
292            self.exit_reported = self.exit.is_some();
293            self.exit
294        };
295
296        Ok(ProcessUpdate {
297            lines,
298            dropped_lines,
299            exit,
300        })
301    }
302
303    /// Terminate and reap the child, returning its terminal status.
304    ///
305    /// # Errors
306    ///
307    /// Returns an I/O error when process status cannot be read or the child
308    /// cannot be terminated and reaped.
309    pub fn cancel(&mut self) -> io::Result<ProcessExit> {
310        if let Some(exit) = self.exit {
311            self.exit_reported = true;
312            return Ok(exit);
313        }
314
315        let status = match self.child.try_wait()? {
316            Some(status) => status,
317            None => {
318                self.child.kill()?;
319                self.child.wait()?
320            }
321        };
322        self.finish(status);
323        if self.readers.iter().all(JoinHandle::is_finished) {
324            self.join_readers();
325        }
326        self.exit_reported = true;
327        self.exit
328            .ok_or_else(|| io::Error::other("cancelled process has no exit status"))
329    }
330
331    fn finish(&mut self, status: ExitStatus) {
332        self.exit = Some(ProcessExit {
333            code: status.code(),
334            success: status.success(),
335            elapsed: self.started_at.elapsed(),
336        });
337    }
338
339    fn join_readers(&mut self) {
340        for reader in self.readers.drain(..) {
341            let _ = reader.join();
342        }
343    }
344
345    fn output(&self) -> io::Result<MutexGuard<'_, OutputBuffer>> {
346        self.output
347            .lock()
348            .map_err(|_| io::Error::other("process output buffer is poisoned"))
349    }
350}
351
352impl Drop for RunningProcess {
353    fn drop(&mut self) {
354        if self.exit.is_none() {
355            let _ = self.child.kill();
356            let _ = self.child.wait();
357        }
358        if self.readers.iter().all(JoinHandle::is_finished) {
359            self.join_readers();
360        }
361    }
362}
363
364fn spawn_reader<R>(
365    reader: R,
366    stream: OutputStream,
367    output: Arc<Mutex<OutputBuffer>>,
368) -> io::Result<JoinHandle<()>>
369where
370    R: Read + Send + 'static,
371{
372    thread::Builder::new()
373        .name(format!("rx-runner-{stream:?}"))
374        .spawn(move || {
375            let mut reader = BufReader::new(reader);
376            let mut bytes = Vec::new();
377            loop {
378                bytes.clear();
379                let count = match reader.read_until(b'\n', &mut bytes) {
380                    Ok(count) => count,
381                    Err(_) => break,
382                };
383                if count == 0 {
384                    break;
385                }
386                while matches!(bytes.last(), Some(b'\n' | b'\r')) {
387                    bytes.pop();
388                }
389                let line = OutputLine {
390                    stream,
391                    text: String::from_utf8_lossy(&bytes).into_owned(),
392                };
393                let Ok(mut buffer) = output.lock() else {
394                    break;
395                };
396                buffer.push(line);
397            }
398        })
399}
400
401#[cfg(test)]
402mod tests {
403    use super::*;
404
405    #[cfg(unix)]
406    fn fixture(script: &str) -> CommandSpec {
407        CommandSpec::new("sh").args(["-c", script])
408    }
409
410    #[cfg(windows)]
411    fn fixture(script: &str) -> CommandSpec {
412        CommandSpec::new("cmd").args(["/C", script])
413    }
414
415    fn collect(mut process: RunningProcess) -> io::Result<ProcessUpdate> {
416        let mut lines = Vec::new();
417        let mut dropped_lines = 0;
418        for _ in 0..200 {
419            let update = process.poll()?;
420            lines.extend(update.lines);
421            dropped_lines += update.dropped_lines;
422            if update.exit.is_some() {
423                return Ok(ProcessUpdate {
424                    lines,
425                    dropped_lines,
426                    exit: update.exit,
427                });
428            }
429            thread::sleep(Duration::from_millis(5));
430        }
431        Err(io::Error::new(
432            io::ErrorKind::TimedOut,
433            "fixture process did not exit",
434        ))
435    }
436
437    #[test]
438    fn command_spec_preserves_separate_arguments_and_directory() {
439        let spec = CommandSpec::new("tool")
440            .args(["one", "two words"])
441            .current_dir("workspace")
442            .output_capacity(12);
443
444        assert_eq!(spec.program(), OsStr::new("tool"));
445        assert_eq!(
446            spec.arguments().collect::<Vec<_>>(),
447            vec![OsStr::new("one"), OsStr::new("two words")]
448        );
449        assert_eq!(spec.working_directory(), Some(Path::new("workspace")));
450        assert_eq!(spec.output_capacity, 12);
451    }
452
453    #[test]
454    fn captures_stdout_and_stderr_lines() {
455        #[cfg(unix)]
456        let spec = fixture("printf 'out\\n'; printf 'err\\n' >&2");
457        #[cfg(windows)]
458        let spec = fixture("echo out & echo err 1>&2");
459
460        let update = collect(spec.spawn().expect("spawn fixture")).expect("collect fixture");
461
462        assert!(update.exit.is_some_and(|exit| exit.success));
463        assert!(update.lines.contains(&OutputLine {
464            stream: OutputStream::Stdout,
465            text: "out".to_string(),
466        }));
467        assert!(update.lines.contains(&OutputLine {
468            stream: OutputStream::Stderr,
469            text: "err".to_string(),
470        }));
471    }
472
473    #[test]
474    fn bounded_output_reports_dropped_lines() {
475        #[cfg(unix)]
476        let spec = fixture("printf '1\\n2\\n3\\n4\\n'").output_capacity(2);
477        #[cfg(windows)]
478        let spec = fixture("(echo 1 & echo 2 & echo 3 & echo 4)").output_capacity(2);
479
480        let update = collect(spec.spawn().expect("spawn fixture")).expect("collect fixture");
481
482        assert_eq!(update.lines.len(), 2);
483        assert_eq!(update.dropped_lines, 2);
484    }
485
486    #[cfg(unix)]
487    #[test]
488    fn cancel_terminates_and_reaps_running_process() {
489        let mut process = fixture("exec sleep 30").spawn().expect("spawn fixture");
490
491        let exit = process.cancel().expect("cancel fixture");
492
493        assert!(!exit.success);
494        assert!(
495            process
496                .poll()
497                .expect("poll cancelled process")
498                .exit
499                .is_none()
500        );
501    }
502
503    #[test]
504    fn terminal_exit_is_reported_once() {
505        #[cfg(unix)]
506        let spec = fixture("exit 7");
507        #[cfg(windows)]
508        let spec = fixture("exit /B 7");
509        let mut process = spec.spawn().expect("spawn fixture");
510
511        let update = collect_until_exit(&mut process).expect("collect terminal exit");
512
513        assert_eq!(update.exit.and_then(|exit| exit.code), Some(7));
514        assert!(process.poll().expect("poll after exit").exit.is_none());
515    }
516
517    fn collect_until_exit(process: &mut RunningProcess) -> io::Result<ProcessUpdate> {
518        for _ in 0..200 {
519            let update = process.poll()?;
520            if update.exit.is_some() {
521                return Ok(update);
522            }
523            thread::sleep(Duration::from_millis(5));
524        }
525        Err(io::Error::new(
526            io::ErrorKind::TimedOut,
527            "fixture process did not exit",
528        ))
529    }
530}