use crate::{CapturedOutput, NativeOutputCapture, OwnedCommand};
use std::{
ffi::OsStr,
io,
process::{ChildStdin, ExitStatus, Stdio},
sync::{
atomic::{AtomicBool, Ordering},
mpsc, Arc,
},
thread::JoinHandle,
time::{Duration, Instant},
};
const DRAIN_WAIT: Duration = Duration::from_secs(2);
const STDIN_WAIT: Duration = Duration::from_millis(500);
const STDIN_CANCEL_WAIT: Duration = Duration::from_secs(2);
const CANCEL_RETRY: Duration = Duration::from_millis(25);
#[cfg(windows)]
const WRITE_CHUNK: usize = 64 * 1024;
#[cfg(unix)]
const WRITE_POLL: Duration = Duration::from_millis(50);
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct RunOptions {
pub timeout: Duration,
pub stdout_cap: usize,
pub stderr_cap: usize,
pub no_window: bool,
}
impl RunOptions {
pub fn new(timeout: Duration) -> Self {
Self {
timeout,
stdout_cap: 1024 * 1024,
stderr_cap: 1024 * 1024,
no_window: true,
}
}
pub fn stdout_cap(mut self, bytes: usize) -> Self {
self.stdout_cap = bytes;
self
}
pub fn stderr_cap(mut self, bytes: usize) -> Self {
self.stderr_cap = bytes;
self
}
pub fn no_window(mut self, hidden: bool) -> Self {
self.no_window = hidden;
self
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct RunOutput {
pub status: ExitStatus,
pub timed_out: bool,
pub stdout: CapturedOutput,
pub stderr: CapturedOutput,
}
pub fn run_with_stdin<I, S>(
program: impl AsRef<OsStr>,
args: I,
input: &[u8],
options: &RunOptions,
) -> io::Result<RunOutput>
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
run_inner(program, args, Some(input.to_vec()), options)
}
pub fn run<I, S>(program: impl AsRef<OsStr>, args: I, options: &RunOptions) -> io::Result<RunOutput>
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
run_inner(program, args, None, options)
}
fn run_inner<I, S>(
program: impl AsRef<OsStr>,
args: I,
input: Option<Vec<u8>>,
options: &RunOptions,
) -> io::Result<RunOutput>
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
let mut command = OwnedCommand::new(program);
command
.command_mut()
.args(args)
.stdin(if input.is_some() {
Stdio::piped()
} else {
Stdio::null()
})
.stdout(Stdio::piped())
.stderr(Stdio::piped());
if options.no_window {
command.windows_hide();
} else {
command.windows_visible();
}
let mut child = command.spawn()?;
let writer = match (input, child.take_stdin()) {
(Some(bytes), Some(stdin)) => Some(StdinWriter::spawn(stdin, bytes)?),
_ => None,
};
let mut stdout = child
.take_stdout()
.map(|pipe| NativeOutputCapture::stdout(pipe, options.stdout_cap))
.transpose()?;
let mut stderr = child
.take_stderr()
.map(|pipe| NativeOutputCapture::stderr(pipe, options.stderr_cap))
.transpose()?;
let (status, timed_out) = match child.wait_then_kill(options.timeout)? {
crate::WaitOutcome::Exited(status) => (status, false),
crate::WaitOutcome::Terminated(status) => (status, true),
};
let stdout = stdout
.as_mut()
.map_or_else(CapturedOutput::default, |capture| {
capture.finish_within(DRAIN_WAIT)
});
let stderr = stderr
.as_mut()
.map_or_else(CapturedOutput::default, |capture| {
capture.finish_within(DRAIN_WAIT)
});
if let Some(writer) = writer {
writer.finish()?;
}
Ok(RunOutput {
status,
timed_out,
stdout,
stderr,
})
}
struct WriterState {
cancel: AtomicBool,
#[cfg(windows)]
handle: std::sync::Mutex<Option<usize>>,
#[cfg(unix)]
pipe: std::sync::Mutex<Option<ChildStdin>>,
}
impl WriterState {
fn new() -> Self {
Self {
cancel: AtomicBool::new(false),
#[cfg(windows)]
handle: std::sync::Mutex::new(None),
#[cfg(unix)]
pipe: std::sync::Mutex::new(None),
}
}
#[cfg(unix)]
fn pipe(&self) -> std::sync::MutexGuard<'_, Option<ChildStdin>> {
self.pipe
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
#[cfg(unix)]
fn close_pipe(&self) {
drop(self.pipe().take());
}
fn cancelled(&self) -> bool {
self.cancel.load(Ordering::Acquire)
}
fn cancel(&self) {
self.cancel.store(true, Ordering::Release);
#[cfg(windows)]
{
let held = self
.handle
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(raw) = *held {
unsafe { crate::native_capture::platform::cancel_all_io(raw as _) };
}
}
}
}
#[cfg(test)]
static LIVE_WRITERS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
#[cfg(test)]
struct LiveWriter;
#[cfg(test)]
impl LiveWriter {
fn start() -> Self {
LIVE_WRITERS.fetch_add(1, Ordering::SeqCst);
Self
}
}
#[cfg(test)]
impl Drop for LiveWriter {
fn drop(&mut self) {
LIVE_WRITERS.fetch_sub(1, Ordering::SeqCst);
}
}
struct StdinWriter {
state: Arc<WriterState>,
done: mpsc::Receiver<io::Result<()>>,
handle: Option<JoinHandle<()>>,
}
impl StdinWriter {
fn spawn(stdin: ChildStdin, bytes: Vec<u8>) -> io::Result<Self> {
let state = Arc::new(WriterState::new());
let (sender, done) = mpsc::channel();
let shared = Arc::clone(&state);
#[cfg(unix)]
{
*state.pipe() = Some(stdin);
}
#[cfg(test)]
let live = LiveWriter::start();
let handle = std::thread::Builder::new()
.name("rightkit-run-stdin".into())
.spawn(move || {
#[cfg(test)]
let _live = live;
#[cfg(unix)]
let result = {
let result = write_input(&bytes, &shared);
shared.close_pipe();
result
};
#[cfg(windows)]
let result = {
let mut stdin = stdin;
let result = write_input(&mut stdin, &bytes, &shared);
drop(stdin);
result
};
let _ = sender.send(result);
})?;
Ok(Self {
state,
done,
handle: Some(handle),
})
}
fn finish(mut self) -> io::Result<()> {
let result = match self.done.recv_timeout(STDIN_WAIT) {
Err(mpsc::RecvTimeoutError::Timeout) => self.cancel_and_wait(),
other => other,
};
self.settle(result)
}
fn cancel_and_wait(&self) -> Result<io::Result<()>, mpsc::RecvTimeoutError> {
let deadline = Instant::now() + STDIN_CANCEL_WAIT;
loop {
self.state.cancel();
match self.done.recv_timeout(CANCEL_RETRY) {
Err(mpsc::RecvTimeoutError::Timeout) if Instant::now() < deadline => {}
other => return other,
}
}
}
fn settle(&mut self, result: Result<io::Result<()>, mpsc::RecvTimeoutError>) -> io::Result<()> {
let handle = self.handle.take();
match result {
Ok(result) => {
if let Some(handle) = handle {
let _ = handle.join();
}
match result {
Err(error) if error.kind() != io::ErrorKind::BrokenPipe => Err(error),
_ => Ok(()),
}
}
Err(mpsc::RecvTimeoutError::Timeout) => {
#[cfg(unix)]
self.state.close_pipe();
Err(io::Error::new(
io::ErrorKind::TimedOut,
"the stdin writer did not stop within its cancellation bound",
))
}
Err(mpsc::RecvTimeoutError::Disconnected) => {
if let Some(handle) = handle {
let _ = handle.join();
}
Err(io::Error::other("the stdin writer thread panicked"))
}
}
}
}
impl Drop for StdinWriter {
fn drop(&mut self) {
if self.handle.is_some() {
let result = self.cancel_and_wait();
let _ = self.settle(result);
}
}
}
#[cfg(unix)]
fn write_input(bytes: &[u8], state: &WriterState) -> io::Result<()> {
use std::os::fd::AsRawFd;
let fd = {
let guard = state.pipe();
let Some(pipe) = guard.as_ref() else {
return Ok(());
};
let fd = pipe.as_raw_fd();
unsafe {
let flags = libc::fcntl(fd, libc::F_GETFL);
if flags < 0 || libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) < 0 {
return Err(io::Error::last_os_error());
}
}
fd
};
let mut written = 0;
while written < bytes.len() {
if state.cancelled() {
return Ok(());
}
let rest = &bytes[written..];
let chunk = rest.len().min(1 << 20);
let outcome = {
let guard = state.pipe();
let Some(pipe) = guard.as_ref() else {
return Ok(());
};
let count = unsafe { libc::write(pipe.as_raw_fd(), rest.as_ptr().cast(), chunk) };
if count >= 0 {
Ok(count as usize)
} else {
Err(io::Error::last_os_error())
}
};
match outcome {
Ok(count) => written += count,
Err(error) => match error.kind() {
io::ErrorKind::Interrupted => {}
io::ErrorKind::WouldBlock => {
let mut entry = libc::pollfd {
fd,
events: libc::POLLOUT,
revents: 0,
};
unsafe { libc::poll(&mut entry, 1, WRITE_POLL.as_millis() as libc::c_int) };
}
_ => return Err(error),
},
}
}
Ok(())
}
#[cfg(windows)]
fn write_input(stdin: &mut ChildStdin, bytes: &[u8], state: &WriterState) -> io::Result<()> {
use std::{io::Write, os::windows::io::AsRawHandle};
let publish = |value: Option<usize>| {
*state
.handle
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = value;
};
publish(Some(stdin.as_raw_handle() as usize));
let mut result = Ok(());
for chunk in bytes.chunks(WRITE_CHUNK) {
if state.cancelled() {
break;
}
if let Err(error) = stdin.write_all(chunk) {
result = Err(error);
break;
}
}
publish(None);
match result {
Err(_) if state.cancelled() => Ok(()),
other => other,
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::Ordering::SeqCst;
static WRITER_TESTS: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn serial() -> std::sync::MutexGuard<'static, ()> {
WRITER_TESTS
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
struct KillOnDrop(std::process::Child);
impl Drop for KillOnDrop {
fn drop(&mut self) {
let _ = self.0.kill();
let _ = self.0.wait();
}
}
#[test]
fn defaults_hide_the_window_and_cap_both_streams() {
let options = RunOptions::new(Duration::from_secs(1));
assert!(options.no_window);
assert_eq!(options.stdout_cap, 1024 * 1024);
assert_eq!(options.stderr_cap, 1024 * 1024);
let options = options.stdout_cap(4).stderr_cap(8).no_window(false);
assert_eq!((options.stdout_cap, options.stderr_cap), (4, 8));
assert!(!options.no_window);
}
#[cfg(unix)]
#[test]
fn a_quick_program_returns_its_output_and_status() {
let options = RunOptions::new(Duration::from_secs(10));
let output = run("sh", ["-c", "printf hi; exit 3"], &options).unwrap();
assert!(!output.timed_out);
assert_eq!(output.status.code(), Some(3));
assert_eq!(output.stdout.bytes, b"hi".to_vec());
}
#[cfg(unix)]
#[test]
fn a_program_running_within_the_deadline_is_not_signalled() {
let options = RunOptions::new(Duration::from_secs(10));
let output = run("sh", ["-c", "sleep 1; printf done"], &options).unwrap();
assert!(!output.timed_out);
assert!(output.status.success());
assert_eq!(output.stdout.bytes, b"done".to_vec());
}
#[cfg(unix)]
#[test]
fn a_slow_program_is_terminated_at_the_deadline() {
let options = RunOptions::new(Duration::from_millis(200));
let output = run("sh", ["-c", "sleep 30"], &options).unwrap();
assert!(output.timed_out);
}
#[cfg(unix)]
#[test]
fn stdin_input_reaches_the_child_and_early_exit_is_not_an_error() {
let _serial = serial();
let options = RunOptions::new(Duration::from_secs(10));
let output = run_with_stdin("sh", ["-c", "cat"], b"hello", &options).unwrap();
assert!(output.status.success());
assert_eq!(output.stdout.bytes, b"hello".to_vec());
let big = vec![b'x'; 1 << 20];
let output = run_with_stdin("sh", ["-c", "exit 7"], &big, &options).unwrap();
assert_eq!(output.status.code(), Some(7));
assert_eq!(LIVE_WRITERS.load(SeqCst), 0);
}
#[test]
fn a_reader_that_never_reads_cannot_pin_the_writer() {
let _serial = serial();
#[cfg(unix)]
let mut command = {
let mut command = std::process::Command::new("sleep");
command.arg("30");
command
};
#[cfg(windows)]
let mut command = {
let mut command = std::process::Command::new("ping");
command.args(["-n", "30", "127.0.0.1"]);
command
};
let mut child = KillOnDrop(
command
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.unwrap(),
);
let stdin = child.0.stdin.take().unwrap();
let writer = StdinWriter::spawn(stdin, vec![b'x'; 2 << 20]).unwrap();
std::thread::sleep(Duration::from_millis(200));
assert_eq!(LIVE_WRITERS.load(SeqCst), 1, "the writer should be blocked");
let started = std::time::Instant::now();
writer.finish().unwrap();
let elapsed = started.elapsed();
assert_eq!(
LIVE_WRITERS.load(SeqCst),
0,
"the writer thread must be gone"
);
assert!(
elapsed < STDIN_WAIT + STDIN_CANCEL_WAIT + Duration::from_secs(3),
"finish took {elapsed:?}"
);
assert!(
child.0.try_wait().unwrap().is_none(),
"the reader must have stayed alive throughout"
);
}
#[cfg(unix)]
#[test]
fn a_writer_that_never_reports_times_out_and_the_parent_closes_the_write_end() {
let _serial = serial();
let mut child = KillOnDrop(
std::process::Command::new("cat")
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.unwrap(),
);
let state = Arc::new(WriterState::new());
*state.pipe() = Some(child.0.stdin.take().unwrap());
let (_sender, done) = mpsc::channel::<io::Result<()>>();
let (release, parked) = mpsc::channel::<()>();
let stuck = std::thread::spawn(move || {
let _ = parked.recv();
});
let writer = StdinWriter {
state: Arc::clone(&state),
done,
handle: Some(stuck),
};
let started = Instant::now();
let error = writer.finish().unwrap_err();
let elapsed = started.elapsed();
assert_eq!(error.kind(), io::ErrorKind::TimedOut);
assert!(
elapsed >= STDIN_WAIT + STDIN_CANCEL_WAIT
&& elapsed < STDIN_WAIT + STDIN_CANCEL_WAIT + Duration::from_secs(3),
"finish took {elapsed:?}"
);
assert!(state.cancelled(), "the cancel flag must stay set");
assert!(
state.pipe().is_none(),
"the parent must have closed the write end"
);
let deadline = Instant::now() + Duration::from_secs(5);
let status = loop {
if let Some(status) = child.0.try_wait().unwrap() {
break status;
}
assert!(Instant::now() < deadline, "the reader never saw EOF");
std::thread::sleep(Duration::from_millis(20));
};
assert!(status.success());
let _ = release.send(());
}
#[cfg(unix)]
struct Grandchild(std::path::PathBuf);
#[cfg(unix)]
impl Grandchild {
fn new(label: &str) -> Self {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |elapsed| elapsed.as_nanos());
Self(std::env::temp_dir().join(format!(
"rk-run-stdin-{label}-{}-{nanos}.pid",
std::process::id()
)))
}
fn pid(&self) -> Option<i32> {
let pid: i32 = std::fs::read_to_string(&self.0).ok()?.trim().parse().ok()?;
(pid > 1).then_some(pid)
}
fn count_path(&self) -> std::path::PathBuf {
let mut path = self.0.clone().into_os_string();
path.push(".count");
path.into()
}
fn alive(&self) -> bool {
self.pid()
.is_some_and(|pid| unsafe { libc::kill(pid, 0) } == 0)
}
}
#[cfg(unix)]
impl Drop for Grandchild {
fn drop(&mut self) {
if let Some(pid) = self.pid() {
unsafe { libc::kill(pid, libc::SIGKILL) };
}
let _ = std::fs::remove_file(&self.0);
let _ = std::fs::remove_file(self.count_path());
}
}
#[cfg(unix)]
fn holder_script(body: &str, tail: &str) -> String {
format!(
"if command -v setsid >/dev/null 2>&1; then \
setsid sh -c 'echo $$ > \"$1\"; {body}' sh \"$1\" <&0 >/dev/null 2>&1 & \
elif command -v perl >/dev/null 2>&1; then \
perl -MPOSIX -e 'POSIX::setsid(); exec @ARGV' sh -c 'echo $$ > \"$1\"; {body}' sh \"$1\" <&0 >/dev/null 2>&1 & \
else exit 99; fi; \
i=0; while [ ! -s \"$1\" ] && [ $i -lt 100 ]; do sleep 0.05; i=$((i+1)); done; {tail}"
)
}
#[cfg(unix)]
#[test]
fn a_session_escaped_stdin_holder_does_not_hold_run_past_its_exit() {
let _serial = serial();
let holder = Grandchild::new("exit");
let script = holder_script("sleep 2; wc -c > \"$1.count\"; exec sleep 30", "exit 0");
let big = vec![b'x'; 2 << 20];
let options = RunOptions::new(Duration::from_secs(10));
let started = std::time::Instant::now();
let output = run_with_stdin(
"sh",
["-c", &script, "sh", holder.0.to_str().unwrap()],
&big,
&options,
)
.unwrap();
let elapsed = started.elapsed();
if output.status.code() == Some(99) {
eprintln!("skipped: no setsid or perl to leave the session");
return;
}
assert!(
holder.alive(),
"the fixture's grandchild must still hold stdin"
);
assert!(!output.timed_out);
assert!(output.status.success(), "{:?}", output.status);
assert!(elapsed < Duration::from_secs(6), "run took {elapsed:?}");
assert_eq!(
LIVE_WRITERS.load(SeqCst),
0,
"the writer thread must be gone"
);
let deadline = std::time::Instant::now() + Duration::from_secs(10);
let received = loop {
if let Some(count) = std::fs::read_to_string(holder.count_path())
.ok()
.and_then(|text| text.trim().parse::<usize>().ok())
{
break count;
}
assert!(
std::time::Instant::now() < deadline,
"the grandchild never reached EOF: the write end is still open"
);
std::thread::sleep(Duration::from_millis(50));
};
assert!(
received < big.len() / 2,
"{received} bytes reached the grandchild after run returned"
);
}
#[cfg(unix)]
#[test]
fn a_session_escaped_stdin_holder_does_not_hold_run_past_its_timeout() {
let _serial = serial();
let holder = Grandchild::new("timeout");
let script = holder_script("exec sleep 30", "sleep 30");
let big = vec![b'x'; 2 << 20];
let timeout = Duration::from_secs(1);
let options = RunOptions::new(timeout);
let started = std::time::Instant::now();
let output = run_with_stdin(
"sh",
["-c", &script, "sh", holder.0.to_str().unwrap()],
&big,
&options,
)
.unwrap();
let elapsed = started.elapsed();
if !holder.alive() && !output.timed_out {
eprintln!("skipped: no setsid or perl to leave the session");
return;
}
assert!(
holder.alive(),
"the fixture's grandchild must still hold stdin"
);
assert!(output.timed_out);
assert!(
elapsed < timeout + Duration::from_secs(6),
"run took {elapsed:?}"
);
assert_eq!(
LIVE_WRITERS.load(SeqCst),
0,
"the writer thread must be gone"
);
}
#[cfg(unix)]
#[test]
fn output_past_the_cap_is_truncated_not_blocking() {
let options = RunOptions::new(Duration::from_secs(10)).stdout_cap(4);
let output = run("sh", ["-c", "head -c 100000 /dev/zero"], &options).unwrap();
assert_eq!(output.stdout.bytes.len(), 4);
assert!(output.stdout.truncated);
}
}