use std::io::{BufRead, BufReader, Read};
use std::path::PathBuf;
use std::process::{Child, Command, Stdio};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Mutex;
use std::time::{Duration, Instant};
const MISSING_PID: &str = "99999999";
struct Row {
args: &'static [&'static str],
json: bool,
}
const fn row(args: &'static [&'static str]) -> Row {
Row { args, json: true }
}
const fn plain(args: &'static [&'static str]) -> Row {
Row { args, json: false }
}
#[rustfmt::skip]
const ROWS: &[Row] = &[
plain(&[]),
plain(&["--help"]),
plain(&["-h"]),
plain(&["--version"]),
plain(&["list", "--help"]),
plain(&["completions", "bash"]),
plain(&["completions", "zsh"]),
plain(&["completions", "fish"]),
plain(&["completions", "elvish"]),
plain(&["completions", "powershell"]),
plain(&["manpage"]),
row(&["list", "--bogus"]),
row(&["on", ":{port}"]),
row(&["on", "{pid}"]),
row(&["on", "{pid}", ":{port}"]),
row(&["on", MISSING_PID]),
row(&["on", "{pid},:{port}"]),
row(&["for", "{exe}"]),
row(&["by", "{name}", "--in", "{dir}"]),
row(&["in", "{dir}"]),
row(&["list", "--in", "{dir}"]),
row(&["list", "--in", "{dir}", "--verbose"]),
row(&["info", "{pid}"]),
row(&["ports", "--in", "{dir}"]),
row(&["tree", "{pid}"]),
row(&["tree", "{pid}", "--ancestors"]),
plain(&["watch", "{pid}"]),
row(&["why", "{pid}"]),
row(&["why", ":{port}"]),
row(&["stuck", "--in", "{dir}"]),
row(&["stuck", "--kill", "--dry-run", "--in", "{dir}"]),
row(&["stuck", "--kill", "--yes", "--in", "{dir}"]),
row(&["orphans", "--in", "{dir}"]),
row(&["orphans", "--kill", "--yes", "--in", "{dir}"]),
row(&["kill", "{pid}", "--dry-run"]),
row(&["kill", "{pid}", "--yes"]),
row(&["kill", "{pid}", MISSING_PID, "--yes"]),
row(&["kill", MISSING_PID, "--yes"]),
row(&["kill", "{pid}"]),
row(&["stop", "{pid}", "--dry-run"]),
row(&["stop", "{pid}", "--yes", "--timeout", "2"]),
row(&["freeze", "{pid}", "--yes"]),
row(&["freeze", "{pid}", "--dry-run"]),
row(&["thaw", "{pid}", "--yes"]),
row(&["unstick", "{pid}", "--dry-run"]),
row(&["free", ":{port}", "--dry-run"]),
row(&["free", ":{port}", "--yes"]),
row(&["wait", "{pid}", "--timeout", "1"]),
];
struct Fixture {
child: Child,
port: u16,
_tmp: tempfile::TempDir,
dir: PathBuf,
}
impl Fixture {
fn start() -> Self {
let tmp = tempfile::tempdir().unwrap();
#[cfg(unix)]
let dir = tmp.path().canonicalize().unwrap();
#[cfg(not(unix))]
let dir = tmp.path().to_path_buf();
let mut child = Command::new(listener_path())
.current_dir(&dir)
.stdout(Stdio::piped())
.stdin(Stdio::null())
.spawn()
.expect("spawn the listener example");
let mut line = String::new();
let mut stdout = BufReader::new(child.stdout.take().unwrap());
let read = stdout.read_line(&mut line);
let port = read
.ok()
.and_then(|_| line.trim().strip_prefix("PORT "))
.and_then(|p| p.parse().ok());
match port {
Some(port) => Fixture {
child,
port,
_tmp: tmp,
dir,
},
None => {
let _ = child.kill();
let _ = child.wait();
panic!("the listener printed {line:?} instead of `PORT <n>`");
}
}
}
fn fill(&self, arg: &str) -> String {
arg.replace("{pid}", &self.child.id().to_string())
.replace("{port}", &self.port.to_string())
.replace("{dir}", &self.dir.to_string_lossy())
.replace("{exe}", &listener_path().to_string_lossy())
.replace("{name}", "listener")
}
fn gone_within_10s(&mut self) -> bool {
let deadline = Instant::now() + Duration::from_secs(10);
while Instant::now() < deadline {
if matches!(self.child.try_wait(), Ok(Some(_))) {
return true;
}
std::thread::sleep(Duration::from_millis(50));
}
false
}
}
impl Drop for Fixture {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
fn listener_path() -> PathBuf {
let mut path = std::env::current_exe().unwrap();
path.pop();
path.pop();
path.push("examples");
path.push(format!("listener{}", std::env::consts::EXE_SUFFIX));
assert!(path.exists(), "build the example first: {}", path.display());
path
}
fn needs_fixture(args: &[&str]) -> bool {
args.iter().any(|a| {
["{pid}", "{port}", "{dir}", "{exe}", "{name}"]
.iter()
.any(|p| a.contains(p))
})
}
fn filled(args: &[&str], json: bool, fixture: Option<&Fixture>) -> Vec<String> {
let mut argv: Vec<String> = args
.iter()
.map(|a| match fixture {
Some(f) => f.fill(a),
None => a.to_string(),
})
.collect();
if json {
argv.push("--json".to_string());
}
argv
}
#[derive(Clone, Copy, Debug, PartialEq)]
enum Sink {
Capture,
ClosedPipe,
#[cfg(target_os = "linux")]
Full,
}
impl Sink {
fn stdio(self) -> Stdio {
match self {
Sink::Capture => Stdio::piped(),
Sink::ClosedPipe => {
let (reader, writer) = std::io::pipe().expect("make a pipe");
drop(reader);
Stdio::from(writer)
}
#[cfg(target_os = "linux")]
Sink::Full => Stdio::from(
std::fs::OpenOptions::new()
.write(true)
.open("/dev/full")
.expect("open /dev/full"),
),
}
}
}
struct Ran {
code: Option<i32>,
stdout: String,
stderr: String,
}
fn run_proc(argv: &[String], stdout: Sink, stderr: Sink) -> Ran {
let mut child = Command::new(assert_cmd::cargo::cargo_bin("proc"))
.args(argv)
.env("NO_COLOR", "1")
.stdin(Stdio::null())
.stdout(stdout.stdio())
.stderr(stderr.stdio())
.spawn()
.expect("spawn proc");
let readers: Vec<_> = [
child
.stdout
.take()
.map(|s| Box::new(s) as Box<dyn Read + Send>),
child
.stderr
.take()
.map(|s| Box::new(s) as Box<dyn Read + Send>),
]
.into_iter()
.map(|stream| {
std::thread::spawn(move || {
let mut text = Vec::new();
if let Some(mut stream) = stream {
let _ = stream.read_to_end(&mut text);
}
String::from_utf8_lossy(&text).into_owned()
})
})
.collect();
let deadline = Instant::now() + Duration::from_secs(60);
let status = loop {
match child.try_wait().expect("wait for proc") {
Some(status) => break Some(status),
None if Instant::now() >= deadline => {
let _ = child.kill();
let _ = child.wait();
break None;
}
None => std::thread::sleep(Duration::from_millis(10)),
}
};
let mut texts = readers.into_iter().map(|r| r.join().unwrap());
let (stdout, stderr) = (texts.next().unwrap(), texts.next().unwrap());
assert!(
status.is_some(),
"proc {argv:?} did not finish in a minute\nstdout: {stdout}\nstderr: {stderr}"
);
Ran {
code: status.and_then(|s| s.code()),
stdout,
stderr,
}
}
fn run_row(row_args: &[&str], json: bool, stdout: Sink, stderr: Sink) -> (Ran, Vec<String>) {
let fixture = needs_fixture(row_args).then(Fixture::start);
let argv = filled(row_args, json, fixture.as_ref());
let mut ran = run_proc(&argv, stdout, stderr);
if let Some(fixture) = &fixture {
for text in [&mut ran.stdout, &mut ran.stderr] {
*text = text
.replace(&*fixture.dir.to_string_lossy(), "{dir}")
.replace(&*listener_path().to_string_lossy(), "{exe}");
}
}
(ran, argv)
}
fn without_numbers(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut prev = None;
for c in text.chars() {
let class = if c.is_ascii_digit() { '0' } else { c };
if class != prev.unwrap_or('\0') || !matches!(class, '0' | ' ') {
out.push(if class == '0' { 'N' } else { c });
}
prev = Some(class);
}
out
}
fn noise_in(stderr: &str) -> Option<&'static str> {
[
"panicked",
"RUST_BACKTRACE",
"Broken pipe",
"os error",
"BrokenPipe",
]
.into_iter()
.find(|bad| stderr.contains(bad))
}
fn shown(argv: &[String]) -> String {
format!("proc {}", argv.join(" "))
}
fn for_every_case(check: impl Fn(&Row, bool) -> Result<(), String> + Sync) {
let cases: Vec<(&Row, bool)> = ROWS
.iter()
.flat_map(|r| {
let modes: &[bool] = if r.json { &[false, true] } else { &[false] };
modes.iter().map(move |&json| (r, json))
})
.collect();
let next = AtomicUsize::new(0);
let failures = Mutex::new(Vec::<(usize, String)>::new());
std::thread::scope(|scope| {
for _ in 0..4 {
scope.spawn(|| loop {
let i = next.fetch_add(1, Ordering::SeqCst);
let Some((row, json)) = cases.get(i) else {
break;
};
if let Err(why) = check(row, *json) {
failures.lock().unwrap().push((i, why));
}
});
}
});
let mut failures = failures.into_inner().unwrap();
failures.sort();
let report: Vec<String> = failures.into_iter().map(|(_, why)| why).collect();
assert!(
report.is_empty(),
"{} of {} cases failed:\n\n{}",
report.len(),
cases.len(),
report.join("\n\n")
);
}
#[test]
fn a_closed_stdout_gives_the_exit_code_a_normal_stdout_gives() {
for_every_case(|row, json| {
let (normal, argv) = run_row(row.args, json, Sink::Capture, Sink::Capture);
let (closed, hostile_argv) = run_row(row.args, json, Sink::ClosedPipe, Sink::Capture);
let (name, hostile_name) = (shown(&argv), shown(&hostile_argv));
let code = closed
.code
.ok_or(format!("{hostile_name}: killed by a signal"))?;
if let Some(bad) = noise_in(&closed.stderr) {
return Err(format!(
"{hostile_name}: stderr has {bad:?}:\n{}",
closed.stderr
));
}
if !(0..=4).contains(&code) || Some(code) != normal.code {
return Err(format!(
"{hostile_name}: exit {code} with a closed stdout, {:?} with a normal one \
({name})\nstderr: {}",
normal.code, closed.stderr
));
}
if without_numbers(&closed.stderr) != without_numbers(&normal.stderr) {
return Err(format!(
"{hostile_name}: stderr differs from a normal stdout's ({name})\n\
closed: {:?}\nnormal: {:?}",
closed.stderr, normal.stderr
));
}
Ok(())
});
}
#[test]
fn both_streams_closed_change_no_exit_code() {
for_every_case(|row, json| {
let (normal, argv) = run_row(row.args, json, Sink::Capture, Sink::Capture);
let (closed, hostile_argv) = run_row(row.args, json, Sink::ClosedPipe, Sink::ClosedPipe);
let hostile_name = shown(&hostile_argv);
let code = closed
.code
.ok_or(format!("{hostile_name}: killed by a signal"))?;
if !(0..=4).contains(&code) || Some(code) != normal.code {
return Err(format!(
"{hostile_name}: exit {code} with both streams closed, {:?} with normal ones \
({})",
normal.code,
shown(&argv)
));
}
Ok(())
});
}
#[test]
fn a_closed_stderr_changes_no_exit_code() {
for_every_case(|row, json| {
let (normal, argv) = run_row(row.args, json, Sink::Capture, Sink::Capture);
let (closed, hostile_argv) = run_row(row.args, json, Sink::Capture, Sink::ClosedPipe);
let hostile_name = shown(&hostile_argv);
let code = closed
.code
.ok_or(format!("{hostile_name}: killed by a signal"))?;
if !(0..=4).contains(&code) || Some(code) != normal.code {
return Err(format!(
"{hostile_name}: exit {code} with a closed stderr, {:?} with a normal one ({})",
normal.code,
shown(&argv)
));
}
if closed.stdout.is_empty() != normal.stdout.is_empty() {
return Err(format!(
"{hostile_name}: stdout changed with a closed stderr"
));
}
Ok(())
});
}
#[cfg(target_os = "linux")]
#[test]
fn a_stdout_that_cannot_take_a_write_is_exit_1_with_a_message() {
for_every_case(|row, json| {
let (normal, argv) = run_row(row.args, json, Sink::Capture, Sink::Capture);
let (full, hostile_argv) = run_row(row.args, json, Sink::Full, Sink::Capture);
let hostile_name = shown(&hostile_argv);
let code = full
.code
.ok_or(format!("{hostile_name}: killed by a signal"))?;
if full.stderr.contains("panicked") {
return Err(format!("{hostile_name}: panicked:\n{}", full.stderr));
}
let wrote = !normal.stdout.is_empty();
let want = match normal.code {
Some(0) if wrote => 1,
Some(code) => code,
None => return Err(format!("{}: killed by a signal", shown(&argv))),
};
if code != want {
return Err(format!(
"{hostile_name}: exit {code} on /dev/full, expected {want} \
(a normal stdout gives {:?})\nstderr: {}",
normal.code, full.stderr
));
}
if want == 1 && normal.code == Some(0) {
let said = full
.stderr
.lines()
.filter(|l| l.starts_with("proc: cannot write to stdout: "))
.count();
if said != 1 {
return Err(format!(
"{hostile_name}: expected one `proc: cannot write to stdout: ...` line, \
got {:?}",
full.stderr
));
}
}
Ok(())
});
}
#[cfg(target_os = "linux")]
#[test]
fn a_stderr_that_cannot_take_a_write_is_exit_1() {
for_every_case(|row, json| {
let (normal, argv) = run_row(row.args, json, Sink::Capture, Sink::Capture);
let (full, hostile_argv) = run_row(row.args, json, Sink::Capture, Sink::Full);
let hostile_name = shown(&hostile_argv);
let code = full
.code
.ok_or(format!("{hostile_name}: killed by a signal"))?;
let want = match normal.code {
Some(0) if !normal.stderr.is_empty() => 1,
Some(code) => code,
None => return Err(format!("{}: killed by a signal", shown(&argv))),
};
if code != want {
return Err(format!(
"{hostile_name}: exit {code} with stderr on /dev/full, expected {want} \
(a normal stderr gives {:?}: {:?})",
normal.code, normal.stderr
));
}
if full.stdout.is_empty() != normal.stdout.is_empty() {
return Err(format!(
"{hostile_name}: stdout changed with stderr on /dev/full\n\
full: {:?}\nnormal: {:?}",
full.stdout, normal.stdout
));
}
Ok(())
});
}
#[test]
fn kill_stop_and_free_act_and_exit_0_when_the_reader_is_gone() {
for (command, extra) in [
("kill", vec![]),
("stop", vec!["--timeout", "2"]),
("free", vec![]),
] {
for json in [false, true] {
let mut fixture = Fixture::start();
let target = if command == "free" {
format!(":{}", fixture.port)
} else {
fixture.child.id().to_string()
};
let mut argv = vec![command.to_string(), target, "--yes".to_string()];
argv.extend(extra.iter().map(|s| s.to_string()));
if json {
argv.push("--json".to_string());
}
let ran = run_proc(&argv, Sink::ClosedPipe, Sink::Capture);
let name = shown(&argv);
assert_eq!(ran.code, Some(0), "{name}: stderr: {}", ran.stderr);
assert_eq!(ran.stderr, "", "{name} said something about the pipe");
assert!(
fixture.gone_within_10s(),
"{name} reported success but the process is still running"
);
}
}
}
#[test]
fn a_target_that_is_not_found_is_still_exit_2_when_the_reader_is_gone() {
for json in [false, true] {
let mut argv = vec!["on".to_string(), MISSING_PID.to_string()];
if json {
argv.push("--json".to_string());
}
let ran = run_proc(&argv, Sink::ClosedPipe, Sink::Capture);
assert_eq!(
ran.code,
Some(2),
"{}: stderr: {}",
shown(&argv),
ran.stderr
);
assert_eq!(noise_in(&ran.stderr), None, "{}", ran.stderr);
}
}
#[test]
fn help_and_version_end_quietly_when_the_reader_is_gone() {
for args in [["--help"], ["-h"], ["--version"]] {
let argv: Vec<String> = args.iter().map(|s| s.to_string()).collect();
let ran = run_proc(&argv, Sink::ClosedPipe, Sink::Capture);
assert_eq!(ran.code, Some(0), "{args:?}: stderr: {}", ran.stderr);
assert_eq!(ran.stderr, "", "{args:?}");
}
}
#[test]
fn a_stream_of_json_frames_ends_when_the_reader_is_gone() {
let fixture = Fixture::start();
let argv = vec![
"watch".to_string(),
fixture.child.id().to_string(),
"--json".to_string(),
];
let ran = run_proc(&argv, Sink::ClosedPipe, Sink::Capture);
assert_eq!(ran.code, Some(0), "stderr: {}", ran.stderr);
assert_eq!(noise_in(&ran.stderr), None, "{}", ran.stderr);
}
#[cfg(target_os = "linux")]
#[test]
fn a_stream_of_json_frames_fails_when_stdout_cannot_take_a_write() {
let fixture = Fixture::start();
let argv = vec![
"watch".to_string(),
fixture.child.id().to_string(),
"--json".to_string(),
];
let ran = run_proc(&argv, Sink::Full, Sink::Capture);
assert_eq!(ran.code, Some(1), "stderr: {}", ran.stderr);
assert!(
ran.stderr.starts_with("proc: cannot write to stdout: "),
"{}",
ran.stderr
);
}