use crate::error::StreamError;
use serde::Serialize;
use std::io::{BufRead, BufReader, Read};
use std::path::PathBuf;
use std::process::{Child, Stdio};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "kind", rename_all = "camelCase")]
#[non_exhaustive]
pub enum Event {
Started { run_id: String },
Stdout { run_id: String, line: String },
Stderr { run_id: String, line: String },
Error { run_id: String, message: String },
Exited {
run_id: String,
exit_code: Option<i32>,
cancelled: bool,
},
}
#[derive(Clone, Debug)]
pub struct ProcessHandle {
inner: Arc<HandleInner>,
}
const EVENT_BUFFER: usize = 1024;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Stdin {
#[default]
Closed,
Piped,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Stderr {
#[default]
Streamed,
Discarded,
}
#[derive(Debug, Clone)]
pub struct Command {
pub program: PathBuf,
pub args: Vec<String>,
pub env: Vec<(String, String)>,
pub cwd: PathBuf,
pub run_id: String,
pub stdin: Stdin,
pub stderr: Stderr,
pub timeout: Option<Duration>,
}
impl Command {
pub fn new(program: impl Into<PathBuf>) -> Self {
Self {
program: program.into(),
args: Vec::new(),
env: Vec::new(),
cwd: std::env::current_dir().unwrap_or_default(),
run_id: String::new(),
stdin: Stdin::Closed,
stderr: Stderr::Streamed,
timeout: None,
}
}
#[must_use]
pub fn cwd(mut self, cwd: impl Into<PathBuf>) -> Self {
self.cwd = cwd.into();
self
}
#[must_use]
pub fn run_id(mut self, run_id: impl Into<String>) -> Self {
self.run_id = run_id.into();
self
}
#[must_use]
pub fn args<I, S>(mut self, args: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.args = args.into_iter().map(Into::into).collect();
self
}
#[must_use]
pub fn env<I, K, V>(mut self, env: I) -> Self
where
I: IntoIterator<Item = (K, V)>,
K: Into<String>,
V: Into<String>,
{
self.env = env.into_iter().map(|(k, v)| (k.into(), v.into())).collect();
self
}
#[must_use]
pub fn stdin(mut self, stdin: Stdin) -> Self {
self.stdin = stdin;
self
}
#[must_use]
pub fn stderr(mut self, stderr: Stderr) -> Self {
self.stderr = stderr;
self
}
#[must_use]
pub fn timeout(mut self, timeout: Duration) -> Self {
self.timeout = Some(timeout);
self
}
pub fn start(self) -> Result<(ProcessHandle, std::sync::mpsc::Receiver<Event>), StreamError> {
let (tx, rx) = std::sync::mpsc::sync_channel(EVENT_BUFFER);
let handle = self.stream(move |event| {
let _ = tx.send(event);
})?;
Ok((handle, rx))
}
pub fn stream<F>(self, callback: F) -> Result<ProcessHandle, StreamError>
where
F: FnMut(Event) + Send + Sync + Clone + 'static,
{
spawn_streaming(self, callback)
}
}
#[derive(Debug)]
struct HandleInner {
child: Mutex<Option<Child>>,
stdin: Mutex<Option<std::process::ChildStdin>>,
cancelled: AtomicBool,
}
impl ProcessHandle {
pub fn cancel(&self) -> Result<(), StreamError> {
self.inner.cancelled.store(true, Ordering::SeqCst);
let mut guard = self
.inner
.child
.lock()
.map_err(|_| StreamError::CancelLockPoisoned)?;
let Some(child) = guard.as_mut() else {
return Ok(());
};
#[cfg(unix)]
{
let pid = child.id() as i32;
unsafe { libc::kill(pid, libc::SIGTERM) };
let inner = Arc::clone(&self.inner);
thread::spawn(move || {
thread::sleep(Duration::from_millis(1500));
if let Ok(mut guard) = inner.child.lock() {
if let Some(child) = guard.as_mut() {
let _ = child.kill();
}
}
});
}
#[cfg(not(unix))]
{
let _ = child.kill();
}
Ok(())
}
pub fn write_line(&self, line: &str) -> Result<(), StreamError> {
self.write(line.as_bytes())?;
self.write(b"\n")
}
pub fn write(&self, bytes: &[u8]) -> Result<(), StreamError> {
let mut guard = self.inner.stdin.lock().map_err(|_| StreamError::CancelLockPoisoned)?;
let stdin = guard.as_mut().ok_or(StreamError::PipeNotCaptured { stream: "stdin" })?;
use std::io::Write;
stdin.write_all(bytes).and_then(|()| stdin.flush()).map_err(|source| StreamError::Write { source })
}
pub fn was_cancelled(&self) -> bool {
self.inner.cancelled.load(Ordering::SeqCst)
}
pub fn pid(&self) -> Option<u32> {
self.inner
.child
.lock()
.ok()
.and_then(|guard| guard.as_ref().map(Child::id))
}
}
pub(crate) fn spawn_streaming<F>(spawn: Command, callback: F) -> Result<ProcessHandle, StreamError>
where
F: FnMut(Event) + Send + Sync + Clone + 'static,
{
let Command { program, args, env, cwd, run_id, stdin, stderr, timeout } = spawn;
let mut command = hidden_command(&program);
command
.args(&args)
.current_dir(&cwd)
.stdin(match stdin {
Stdin::Closed => Stdio::null(),
Stdin::Piped => Stdio::piped(),
})
.stdout(Stdio::piped())
.stderr(match stderr {
Stderr::Streamed => Stdio::piped(),
Stderr::Discarded => Stdio::null(),
});
for (key, value) in &env {
command.env(key, value);
}
let mut child = command.spawn().map_err(|source| StreamError::Spawn {
program: program.display().to_string(),
source,
})?;
let stdout = child
.stdout
.take()
.ok_or(StreamError::PipeNotCaptured { stream: "stdout" })?;
let stderr_pipe = child.stderr.take();
let child_stdin = child.stdin.take();
let inner = Arc::new(HandleInner {
child: Mutex::new(Some(child)),
stdin: Mutex::new(child_stdin),
cancelled: AtomicBool::new(false),
});
let handle = ProcessHandle {
inner: Arc::clone(&inner),
};
let mut started_cb = callback.clone();
started_cb(Event::Started {
run_id: run_id.clone(),
});
let stdout_cb = callback.clone();
let stdout_run_id = run_id.clone();
let stdout_handle = thread::spawn(move || {
pump_lines(stdout, stdout_run_id, true, stdout_cb);
});
let stderr_handle = stderr_pipe.map(|pipe| {
let stderr_cb = callback.clone();
let stderr_run_id = run_id.clone();
thread::spawn(move || pump_lines(pipe, stderr_run_id, false, stderr_cb))
});
let exit_inner = Arc::clone(&inner);
let timeout_handle = handle.clone();
let mut exit_cb = callback;
let exit_run_id = run_id;
thread::spawn(move || {
let started = std::time::Instant::now();
let wait_result = loop {
{
let mut guard = match exit_inner.child.lock() {
Ok(guard) => guard,
Err(_) => return, };
match guard.as_mut() {
Some(child) => match child.try_wait() {
Ok(Some(status)) => break Ok(status),
Ok(None) => {} Err(err) => break Err(err),
},
None => return, }
} if timeout.is_some_and(|limit| started.elapsed() >= limit) {
let _ = timeout_handle.cancel();
}
thread::sleep(Duration::from_millis(50));
};
let _ = stdout_handle.join();
if let Some(stderr_handle) = stderr_handle {
let _ = stderr_handle.join();
}
let cancelled = exit_inner.cancelled.load(Ordering::SeqCst);
match wait_result {
Ok(status) => exit_cb(Event::Exited {
run_id: exit_run_id.clone(),
exit_code: status.code(),
cancelled,
}),
Err(err) => exit_cb(Event::Error {
run_id: exit_run_id.clone(),
message: format!("wait failed: {err}"),
}),
}
if let Ok(mut guard) = exit_inner.child.lock() {
*guard = None;
}
});
Ok(handle)
}
fn pump_lines<R, F>(reader: R, run_id: String, is_stdout: bool, mut callback: F)
where
R: Read,
F: FnMut(Event),
{
let mut buffered = BufReader::new(reader);
let mut bytes = Vec::new();
loop {
bytes.clear();
match buffered.read_until(b'\n', &mut bytes) {
Ok(0) => return,
Ok(_) => {
strip_eol(&mut bytes);
let text = String::from_utf8_lossy(&bytes).into_owned();
let event = if is_stdout {
Event::Stdout {
run_id: run_id.clone(),
line: text,
}
} else {
Event::Stderr {
run_id: run_id.clone(),
line: text,
}
};
callback(event);
}
Err(err) => {
callback(Event::Error {
run_id: run_id.clone(),
message: format!("stream read failed: {err}"),
});
return;
}
}
}
}
fn strip_eol(bytes: &mut Vec<u8>) {
if bytes.last() == Some(&b'\n') {
bytes.pop();
if bytes.last() == Some(&b'\r') {
bytes.pop();
}
}
}
pub fn needs_terminal(line: &str) -> bool {
const SIGNS: &[&str] = &[
"not a tty",
"not a terminal",
"is not interactive",
"input device is not a tty",
"raw mode is not supported",
"non-tty environment",
"requires a tty",
];
let lowered = line.to_lowercase();
SIGNS.iter().any(|sign| lowered.contains(sign))
}
pub fn hidden_command(program: impl AsRef<std::ffi::OsStr>) -> std::process::Command {
#[allow(unused_mut)]
let mut command = std::process::Command::new(program);
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
command.creation_flags(CREATE_NO_WINDOW);
}
command
}
#[cfg(test)]
mod tests {
use proptest::prelude::*;
use super::*;
#[test]
fn hidden_command_runs_like_a_plain_command() {
let program = if cfg!(windows) { "cmd" } else { "echo" };
let args: &[&str] = if cfg!(windows) {
&["/C", "echo", "ok"]
} else {
&["ok"]
};
let out = hidden_command(program).args(args).output().unwrap();
assert!(out.status.success());
assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "ok");
}
use std::sync::Condvar;
use std::time::Instant;
type Done = Arc<(Mutex<bool>, Condvar)>;
fn collector() -> (
impl FnMut(Event) + Send + Sync + Clone + 'static,
Arc<Mutex<Vec<Event>>>,
Done,
) {
let events = Arc::new(Mutex::new(Vec::new()));
let done: Done = Arc::new((Mutex::new(false), Condvar::new()));
let cb = {
let events = Arc::clone(&events);
let done = Arc::clone(&done);
move |ev: Event| {
let terminal =
matches!(ev, Event::Exited { .. } | Event::Error { .. });
events.lock().unwrap().push(ev);
if terminal {
let (lock, cvar) = &*done;
*lock.lock().unwrap() = true;
cvar.notify_all();
}
}
};
(cb, events, done)
}
fn wait_done(done: &Done, secs: u64) {
let (lock, cvar) = &**done;
let mut finished = lock.lock().unwrap();
let deadline = Instant::now() + Duration::from_secs(secs);
while !*finished {
let now = Instant::now();
assert!(now < deadline, "process did not finish within {secs}s");
let (guard, _) = cvar.wait_timeout(finished, deadline - now).unwrap();
finished = guard;
}
}
fn run(program: &str, args: &[&str]) -> Vec<Event> {
let (cb, events, done) = collector();
let _handle = spawn_streaming(
Command::new(program).run_id("t").args(args.iter().copied()),
cb,
)
.expect("spawn");
wait_done(&done, 10);
let events = events.lock().unwrap();
events.clone()
}
fn two_lines() -> (&'static str, Vec<&'static str>) {
if cfg!(windows) {
("cmd", vec!["/C", "echo alpha&echo beta"])
} else {
("printf", vec!["%s\n", "alpha", "beta"])
}
}
#[test]
fn streams_stdout_lines_then_exits_zero() {
let (program, args) = two_lines();
let events = run(program, &args);
assert!(matches!(events.first(), Some(Event::Started { .. })));
assert!(matches!(
events.last(),
Some(Event::Exited {
exit_code: Some(0),
cancelled: false,
..
})
));
let lines: Vec<&str> = events
.iter()
.filter_map(|e| match e {
Event::Stdout { line, .. } => Some(line.as_str()),
_ => None,
})
.collect();
assert_eq!(lines, vec!["alpha", "beta"]);
}
#[test]
fn nonzero_exit_code_is_reported() {
let events = run("sh", &["-c", "exit 3"]);
assert!(matches!(
events.last(),
Some(Event::Exited {
exit_code: Some(3),
cancelled: false,
..
})
));
}
#[test]
fn env_vars_are_passed_to_the_child() {
let (cb, events, done) = collector();
let _handle = spawn_streaming(
Command::new("sh").run_id("t").args(vec![
"-c".to_owned(),
"printf '%s\\n' \"$CLI_STREAM_STUB\"".to_owned(),
]).env(vec![("CLI_STREAM_STUB".to_owned(), "from-env".to_owned())]),
cb,
)
.expect("spawn");
wait_done(&done, 10);
let events = events.lock().unwrap();
assert!(
events
.iter()
.any(|e| matches!(e, Event::Stdout { line, .. } if line == "from-env")),
"child should observe the injected env var, got {events:?}"
);
}
#[test]
fn stderr_is_streamed_and_not_misrouted_to_stdout() {
let events = run("sh", &["-c", "echo to-stderr 1>&2"]);
assert!(events
.iter()
.any(|e| matches!(e, Event::Stderr { line, .. } if line == "to-stderr")));
assert!(!events
.iter()
.any(|e| matches!(e, Event::Stdout { .. })));
assert!(events.iter().any(|e| matches!(
e,
Event::Exited {
exit_code: Some(0),
..
}
)));
}
fn long_sleeper() -> (&'static str, Vec<&'static str>) {
if cfg!(windows) {
("ping", vec!["-n", "11", "127.0.0.1"])
} else {
("sh", vec!["-c", "exec sleep 10"])
}
}
#[test]
fn cancel_promptly_terminates_the_run_and_flags_it() {
let (cb, events, done) = collector();
let (program, args) = long_sleeper();
let handle =
spawn_streaming(Command::new(program).run_id("t").args(args), cb).expect("spawn");
let canceller = handle.clone();
thread::spawn(move || {
thread::sleep(Duration::from_millis(100));
let _ = canceller.cancel();
});
wait_done(&done, 4);
assert!(handle.was_cancelled());
let events = events.lock().unwrap();
assert!(
matches!(
events.last(),
Some(Event::Exited {
cancelled: true,
..
})
),
"expected Exited(cancelled=true), got {:?}",
events.last()
);
}
#[test]
fn a_cli_asking_for_a_terminal_is_recognised_however_it_phrases_it() {
for complaint in [
"Error: stdin is not a TTY",
"the input device is not a TTY",
"Raw mode is not supported on the current process.stdin",
"Prompts cannot be rendered in a non-TTY environment",
"this command requires a TTY",
"warning: stdout is not a terminal",
] {
assert!(needs_terminal(complaint), "missed: {complaint}");
}
for ordinary in ["npm WARN deprecated foo@1.0.0", "compiling 12 files", "", "tty"] {
assert!(!needs_terminal(ordinary), "false positive: {ordinary}");
}
}
#[cfg(unix)]
#[test]
fn a_timeout_stops_a_child_that_would_otherwise_run_forever() {
let started = Instant::now();
let (_handle, events) = Command::new("sleep")
.run_id("hung")
.args(["30"])
.timeout(Duration::from_millis(200))
.start()
.expect("spawn");
let exit = events
.into_iter()
.find_map(|e| match e {
Event::Exited { cancelled, .. } => Some(cancelled),
_ => None,
})
.expect("the run ends");
assert!(exit, "a timed-out run reports as cancelled, not as a clean finish");
assert!(started.elapsed() < Duration::from_secs(10), "and does not wait out the sleep");
}
#[cfg(unix)]
#[test]
fn a_run_inside_its_timeout_is_untouched() {
let (_handle, events) = Command::new("echo")
.run_id("quick")
.args(["done"])
.timeout(Duration::from_secs(30))
.start()
.expect("spawn");
let seen: Vec<Event> = events.into_iter().collect();
assert!(seen.iter().any(|e| matches!(e, Event::Stdout { line, .. } if line == "done")));
assert!(
seen.iter().any(|e| matches!(e, Event::Exited { cancelled: false, .. })),
"finished on its own: {seen:?}"
);
}
#[cfg(unix)]
#[test]
fn discarded_stderr_never_reaches_the_caller() {
let noisy = "echo out; echo noise 1>&2";
let (_h, events) = Command::new("sh")
.run_id("quiet")
.args(["-c", noisy])
.stderr(Stderr::Discarded)
.start()
.expect("spawn");
let seen: Vec<Event> = events.into_iter().collect();
assert!(seen.iter().any(|e| matches!(e, Event::Stdout { line, .. } if line == "out")));
assert!(!seen.iter().any(|e| matches!(e, Event::Stderr { .. })), "got {seen:?}");
let (_h, events) = Command::new("sh").run_id("loud").args(["-c", noisy]).start().expect("spawn");
assert!(events.into_iter().any(|e| matches!(e, Event::Stderr { line, .. } if line == "noise")));
}
#[cfg(unix)]
#[test]
fn writing_needs_a_pipe_that_was_asked_for_and_a_child_still_listening() {
let quiet = Command::new("sleep").run_id("nostdin").args(["5"]).stream(|_| {}).expect("spawn");
let err = quiet.write_line("anyone there?").unwrap_err();
assert!(
matches!(err, StreamError::PipeNotCaptured { stream: "stdin" }),
"stdin was never piped, got {err}"
);
let _ = quiet.cancel();
let (handle, events) =
Command::new("cat").run_id("echoing").stdin(Stdin::Piped).start().expect("spawn");
handle.write_line("hello").expect("a live child takes input");
let deadline = Instant::now() + Duration::from_secs(5);
let echoed = loop {
let left = deadline
.checked_duration_since(Instant::now())
.expect("the child never echoed the line back");
match events.recv_timeout(left) {
Ok(Event::Stdout { line, .. }) => break line,
Ok(_) => continue,
Err(err) => panic!("nothing came back: {err}"),
}
};
assert_eq!(echoed, "hello", "and reads it back");
let _ = handle.cancel();
}
#[cfg(unix)]
#[test]
fn a_live_child_reports_a_pid_and_flips_when_cancelled() {
let handle = spawn_streaming(
Command::new("/bin/sleep").cwd(std::env::temp_dir()).run_id("pid").args(["30"]),
|_| {},
)
.expect("sleep should spawn");
let pid = handle.pid().expect("a live child has a pid");
assert!(pid > 1, "a real OS pid, not a placeholder: {pid}");
assert!(!handle.was_cancelled(), "nothing has stopped it yet");
handle.cancel().expect("cancel");
assert!(handle.was_cancelled(), "a stopped run says so");
}
#[test]
fn spawning_a_missing_binary_is_err() {
let result = spawn_streaming(
Command::new("cli-stream-no-such-binary-zzz").run_id("t"),
|_ev: Event| {},
);
match result {
Err(StreamError::Spawn { program, source }) => {
assert!(program.contains("cli-stream-no-such-binary-zzz"));
assert_eq!(source.kind(), std::io::ErrorKind::NotFound);
}
other => panic!("expected StreamError::Spawn, got {other:?}"),
}
}
fn pumped(bytes: &[u8]) -> Vec<Event> {
let mut events = Vec::new();
pump_lines(bytes, "t".to_owned(), true, |event| events.push(event));
events
}
fn lines_of(events: &[Event]) -> Vec<String> {
events
.iter()
.filter_map(|event| match event {
Event::Stdout { line, .. } => Some(line.clone()),
_ => None,
})
.collect()
}
#[test]
fn one_undecodable_byte_does_not_cost_us_the_rest_of_the_run() {
let mut bytes = b"first\n".to_vec();
bytes.extend_from_slice(&[0xff, 0xfe]);
bytes.extend_from_slice(b"\nlast\n");
let lines = lines_of(&pumped(&bytes));
assert_eq!(lines.first().map(String::as_str), Some("first"));
assert_eq!(
lines.last().map(String::as_str),
Some("last"),
"a line after the bad byte still arrives"
);
assert_eq!(lines.len(), 3, "the damaged line is kept, lossily");
}
fn stream_bytes() -> impl Strategy<Value = Vec<u8>> {
prop::collection::vec(
prop_oneof![
6 => 0x20u8..0x7f,
3 => Just(b'\n'),
1 => Just(b'\r'),
2 => 0x80u8..=0xff,
],
0..64,
)
}
fn line_count(bytes: &[u8]) -> usize {
if bytes.is_empty() {
return 0;
}
let newlines = bytes.iter().filter(|byte| **byte == b'\n').count();
newlines + usize::from(bytes.last() != Some(&b'\n'))
}
proptest! {
#[test]
fn every_line_the_child_wrote_is_one_the_caller_sees(bytes in stream_bytes()) {
let events = pumped(&bytes);
prop_assert_eq!(lines_of(&events).len(), line_count(&bytes));
prop_assert!(
!events.iter().any(|event| matches!(event, Event::Error { .. })),
"no byte sequence is a read failure",
);
}
#[test]
fn no_line_smuggles_its_delimiter(bytes in stream_bytes()) {
for line in lines_of(&pumped(&bytes)) {
prop_assert!(!line.contains('\n'), "got {line:?}");
}
}
#[test]
fn text_arrives_unchanged(lines in prop::collection::vec("[^\r\n]{0,24}", 0..8)) {
let written: String = lines.iter().map(|line| format!("{line}\n")).collect();
prop_assert_eq!(lines_of(&pumped(written.as_bytes())), lines);
}
}
}