use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use processkit::{Command, Finished, Outcome, OutputBufferPolicy};
use crate::common::*;
#[tokio::test]
#[ignore = "spawns a real subprocess that outlives its timeout"]
async fn streaming_honors_timeout() {
use tokio_stream::StreamExt;
let cmd = if cfg!(windows) {
Command::new("cmd").args(["/c", "echo one& ping -n 30 127.0.0.1 >NUL"])
} else {
Command::new("sh").args(["-c", "echo one; sleep 30"])
}
.timeout(Duration::from_millis(500));
let start = Instant::now();
let mut run = cmd.start().await.expect("start");
let mut lines = run.stdout_lines().unwrap();
let mut seen = Vec::new();
while let Some(line) = lines.next().await {
seen.push(line);
}
drop(lines);
let Finished { outcome, .. } = run.finish().await.expect("finish");
assert!(
start.elapsed() < Duration::from_secs(15),
"stream did not end at the deadline (took {:?})",
start.elapsed()
);
assert_eq!(
outcome,
Outcome::TimedOut,
"a timed-out streamed run must report TimedOut (got {outcome:?})"
);
assert!(seen.iter().any(|l| l.contains("one")), "saw: {seen:?}");
}
#[tokio::test]
#[ignore = "spawns a real subprocess in a shared group that outlives its timeout"]
async fn shared_group_streaming_honors_timeout() {
use processkit::ProcessGroup;
use tokio_stream::StreamExt;
let group = ProcessGroup::new().expect("group");
let cmd = if cfg!(windows) {
Command::new("ping").args(["-n", "30", "127.0.0.1"])
} else {
Command::new("sh").args(["-c", "echo one; exec sleep 30"])
}
.timeout(Duration::from_millis(500));
let start = Instant::now();
let mut run = group.start(&cmd).await.expect("start");
let mut lines = run.stdout_lines().unwrap();
let seen = tokio::time::timeout(Duration::from_secs(15), async {
let mut seen = Vec::new();
while let Some(line) = lines.next().await {
seen.push(line);
}
seen
})
.await
.expect("the deadline must end the stream, not hang");
drop(lines);
let Finished { outcome, .. } = run.finish().await.expect("finish");
assert!(
start.elapsed() < Duration::from_secs(15),
"shared-group stream did not end at the deadline (took {:?})",
start.elapsed()
);
assert_eq!(
outcome,
Outcome::TimedOut,
"a timed-out shared-group streamed run must report TimedOut (got {outcome:?})"
);
assert!(
!seen.is_empty(),
"the stream should have seen some output first"
);
}
#[tokio::test]
#[ignore = "spawns a real subprocess"]
async fn stdout_line_handler_sees_every_line() {
let seen = Arc::new(Mutex::new(Vec::<String>::new()));
let captured = seen.clone();
let result = five_lines()
.on_stdout_line(move |line| captured.lock().unwrap().push(line.to_owned()))
.output_string()
.await
.expect("run");
assert!(result.is_success());
let lines = seen.lock().unwrap();
assert_eq!(lines.len(), 5, "handler saw: {lines:?}");
}
#[tokio::test]
#[ignore = "spawns a real subprocess"]
async fn stdout_line_handler_panic_is_isolated_on_a_real_subprocess() {
use std::sync::atomic::{AtomicUsize, Ordering};
let calls = Arc::new(AtomicUsize::new(0));
let counted = calls.clone();
let result = five_lines()
.on_stdout_line(move |_line| {
if counted.fetch_add(1, Ordering::SeqCst) == 1 {
panic!("boom on the second line");
}
})
.output_string()
.await
.expect("a panicking handler must not fail the run");
assert!(result.is_success(), "result: {result:?}");
assert_eq!(
result.stdout().lines().count(),
5,
"every line is still captured despite the panic: {:?}",
result.stdout()
);
assert_eq!(
calls.load(Ordering::SeqCst),
2,
"the handler is disabled after its panic (called for lines 1 and 2 only)"
);
}
#[tokio::test]
#[ignore = "spawns a real subprocess fed stdin from a file"]
async fn stdin_from_file_round_trips_end_to_end() {
let path = std::env::temp_dir().join(format!(
"processkit_t052_stdin_from_file_{}.txt",
std::process::id()
));
std::fs::write(&path, "banana\napple\n").expect("write fixture file");
let program = if cfg!(windows) {
Command::new("cmd").args(["/c", "sort"])
} else {
Command::new("sort")
};
let result = program
.stdin(processkit::Stdin::from_file(&path))
.output_string()
.await
.expect("run sort fed from a file");
let _ = std::fs::remove_file(&path);
assert!(result.is_success(), "result: {result:?}");
let first = result
.stdout()
.lines()
.next()
.unwrap_or("")
.trim()
.to_owned();
assert_eq!(first, "apple", "sorted output: {:?}", result.stdout());
}
#[cfg(windows)]
#[tokio::test]
#[ignore = "spawns a real subprocess and writes to its stdin after it exits"]
async fn write_after_child_exit_reports_the_windows_pipe_error_code() {
let exits_zero = Command::new("cmd").args(["/c", "exit", "0"]);
let mut process = exits_zero.keep_stdin_open().start().await.expect("start");
let mut stdin = process.take_stdin().expect("stdin kept open");
let outcome = completes_within(Duration::from_secs(10), "child exit", process.wait())
.await
.expect("wait");
assert_eq!(outcome, Outcome::Exited(0));
let err = stdin
.write_line("data")
.await
.expect_err("a write after the child exited must fail");
assert!(
err.kind() == std::io::ErrorKind::BrokenPipe
|| matches!(err.raw_os_error(), Some(109 | 232)),
"expected a broken-pipe-shaped error, got kind={:?} raw={:?}",
err.kind(),
err.raw_os_error()
);
}
#[tokio::test]
#[ignore = "spawns a real subprocess"]
async fn output_buffer_drops_oldest_lines() {
let result = five_lines()
.output_buffer(OutputBufferPolicy::bounded(2))
.output_string()
.await
.expect("run");
let kept: Vec<&str> = result.stdout().lines().collect();
assert_eq!(kept.len(), 2, "retained: {:?}", result.stdout());
assert!(kept.iter().all(|l| l.trim() == "4" || l.trim() == "5"));
}
#[tokio::test]
#[ignore = "spawns a real subprocess driven via interactive stdin"]
async fn interactive_stdin_round_trips() {
let program = if cfg!(windows) {
Command::new("cmd").args(["/c", "sort"])
} else {
Command::new("sort")
};
let mut process = program.keep_stdin_open().start().await.expect("start sort");
let mut stdin = process.take_stdin().expect("stdin kept open");
stdin.write_line("banana").await.expect("write");
stdin.write_line("apple").await.expect("write");
stdin.finish().await.expect("eof");
let result = process.output_string().await.expect("collect");
assert!(result.is_success());
let first = result
.stdout()
.lines()
.next()
.unwrap_or("")
.trim()
.to_owned();
assert_eq!(first, "apple", "sorted output: {:?}", result.stdout());
}
#[tokio::test]
#[ignore = "spawns a real subprocess and streams its stdout"]
async fn stdout_lines_streams_incrementally() {
use tokio_stream::StreamExt;
let mut process = two_line_echo().start().await.expect("start echo");
let mut lines = process.stdout_lines().unwrap();
let mut collected: Vec<String> = Vec::new();
while let Some(line) = lines.next().await {
collected.push(line);
}
assert!(
collected.iter().any(|l| l.contains("first")),
"lines: {collected:?}"
);
assert!(
collected.iter().any(|l| l.contains("second")),
"lines: {collected:?}"
);
}
#[tokio::test]
#[ignore = "spawns a real subprocess: stream stdout, then collect exit + stderr"]
async fn finish_returns_code_and_stderr() {
use tokio_stream::StreamExt;
let cmd = if cfg!(windows) {
Command::new("cmd").args(["/c", "echo out& echo err 1>&2"])
} else {
Command::new("sh").args(["-c", "echo out; echo err 1>&2"])
};
let mut process = cmd.start().await.expect("start");
let mut lines = process.stdout_lines().unwrap();
let mut out = Vec::new();
while let Some(line) = lines.next().await {
out.push(line);
}
drop(lines);
let Finished {
outcome, stderr, ..
} = process.finish().await.expect("finish");
assert_eq!(outcome, Outcome::Exited(0));
assert!(out.iter().any(|l| l.contains("out")), "stdout: {out:?}");
assert!(stderr.contains("err"), "stderr: {stderr:?}");
}
#[tokio::test]
#[ignore = "spawns a real subprocess"]
async fn second_stdout_lines_call_is_a_loud_error() {
use tokio_stream::StreamExt;
let mut process = five_lines().start().await.expect("start");
let mut first = process.stdout_lines().expect("first stdout_lines");
let mut seen = 0;
while tokio::time::timeout(Duration::from_secs(10), first.next())
.await
.expect("first stream ends")
.is_some()
{
seen += 1;
}
assert_eq!(seen, 5);
let err = process
.stdout_lines()
.expect_err("a second stdout_lines must be a loud error");
assert!(
matches!(err, processkit::Error::Io(_)),
"expected Error::Io, got {err:?}"
);
let _ = process.finish().await;
}
#[tokio::test]
#[ignore = "spawns a real subprocess"]
async fn finish_without_streaming_first_drains_and_exits() {
let process = two_line_echo().start().await.expect("start");
let finish = tokio::time::timeout(Duration::from_secs(15), process.finish())
.await
.expect("finish must not hang without a prior stdout_lines")
.expect("finish");
assert_eq!(finish.outcome, Outcome::Exited(0));
}