1use 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
37pub const DEFAULT_OUTPUT_CAPACITY: usize = 1_024;
39
40#[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 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 #[must_use]
62 pub fn arg(mut self, arg: impl Into<OsString>) -> Self {
63 self.arguments.push(arg.into());
64 self
65 }
66
67 #[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 #[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 #[must_use]
90 pub fn output_capacity(mut self, lines: usize) -> Self {
91 self.output_capacity = lines;
92 self
93 }
94
95 pub fn program(&self) -> &OsStr {
97 &self.program
98 }
99
100 pub fn arguments(&self) -> impl Iterator<Item = &OsStr> {
102 self.arguments.iter().map(OsString::as_os_str)
103 }
104
105 pub fn working_directory(&self) -> Option<&Path> {
107 self.working_directory.as_deref()
108 }
109
110 pub fn spawn(&self) -> io::Result<RunningProcess> {
117 RunningProcess::spawn(self)
118 }
119}
120
121#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
123pub enum OutputStream {
124 Stdout,
126 Stderr,
128}
129
130#[derive(Debug, Clone, PartialEq, Eq, Hash)]
132pub struct OutputLine {
133 pub stream: OutputStream,
135 pub text: String,
137}
138
139#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
141pub struct ProcessExit {
142 pub code: Option<i32>,
144 pub success: bool,
146 pub elapsed: Duration,
148}
149
150#[derive(Debug, Clone, PartialEq, Eq)]
152pub struct ProcessUpdate {
153 pub lines: Vec<OutputLine>,
155 pub dropped_lines: usize,
157 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
196pub 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 pub fn id(&self) -> u32 {
268 self.child.id()
269 }
270
271 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 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}