use std::{
net::{TcpStream, ToSocketAddrs},
process::{Child, Command, Stdio},
time::{Duration, Instant},
};
use anyhow::{Result, bail};
use crate::model::Readiness;
const POLL_INTERVAL: Duration = Duration::from_millis(25);
pub fn check(
readiness: &Readiness,
timeout: Duration,
pane_output: impl FnOnce() -> Result<String>,
) -> Result<bool> {
match readiness {
Readiness::Output { value } => Ok(pane_output()?.contains(value.as_str())),
Readiness::Port { value } => Ok(probe_port(*value, timeout)),
Readiness::Cmd { value } => probe_cmd(value, timeout),
}
}
pub fn probe_port(port: u16, timeout: Duration) -> bool {
let Ok(mut addresses) = ("127.0.0.1", port).to_socket_addrs() else {
return false;
};
let Some(address) = addresses.next() else {
return false;
};
TcpStream::connect_timeout(&address, timeout).is_ok()
}
pub fn probe_cmd(argv: &[String], timeout: Duration) -> Result<bool> {
let [program, args @ ..] = argv else {
bail!("cmd() readiness probe declares an empty argv");
};
let mut command = Command::new(program);
command
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
command.process_group(0);
}
let Ok(mut child) = command.spawn() else {
return Ok(false);
};
let deadline = Instant::now() + timeout;
loop {
if let Some(status) = child.try_wait()? {
return Ok(status.success());
}
if Instant::now() >= deadline {
kill_process_group(&mut child);
let _ = child.wait();
return Ok(false);
}
std::thread::sleep(POLL_INTERVAL);
}
}
#[cfg(unix)]
fn kill_process_group(child: &mut Child) {
let _ = Command::new("kill")
.arg("-KILL")
.arg(format!("-{}", child.id()))
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
}
#[cfg(windows)]
fn kill_process_group(child: &mut Child) {
let _ = Command::new("taskkill")
.args(["/T", "/F", "/PID", &child.id().to_string()])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
}
#[cfg(test)]
mod tests {
use std::{fs, net::TcpListener, thread};
use super::*;
#[test]
fn output_probe_matches_a_substring_of_pane_text() {
let readiness = Readiness::Output {
value: "watching".into(),
};
let ready = check(&readiness, Duration::from_millis(10), || {
Ok("scaffold: watching for changes".to_owned())
})
.expect("check");
assert!(ready);
}
#[test]
fn output_probe_rejects_a_missing_substring() {
let readiness = Readiness::Output {
value: "watching".into(),
};
let ready = check(&readiness, Duration::from_millis(10), || {
Ok("scaffold: starting up".to_owned())
})
.expect("check");
assert!(!ready);
}
#[test]
fn output_probe_propagates_backend_errors() {
let readiness = Readiness::Output {
value: "watching".into(),
};
let error = check(&readiness, Duration::from_millis(10), || bail!("pane gone"))
.expect_err("propagates");
assert!(error.to_string().contains("pane gone"));
}
#[test]
fn port_probe_finds_a_listening_socket() {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
let port = listener.local_addr().expect("addr").port();
let server = thread::spawn(move || {
let _ = listener.accept();
});
assert!(probe_port(port, Duration::from_millis(500)));
drop(TcpStream::connect(("127.0.0.1", port)));
server.join().ok();
}
#[test]
fn port_probe_fails_on_a_closed_port() {
assert!(!probe_port(0, Duration::from_millis(200)));
}
#[test]
fn cmd_probe_reports_success() {
let ready = probe_cmd(&["true".into()], Duration::from_secs(5)).expect("probe");
assert!(ready);
}
#[test]
fn cmd_probe_reports_failure() {
let ready = probe_cmd(&["false".into()], Duration::from_secs(5)).expect("probe");
assert!(!ready);
}
#[test]
fn cmd_probe_kills_a_command_that_outlives_the_timeout() {
let ready =
probe_cmd(&["sleep".into(), "5".into()], Duration::from_millis(100)).expect("probe");
assert!(!ready);
}
#[test]
fn cmd_probe_reports_not_ready_when_the_program_does_not_exist() {
let ready = probe_cmd(
&["drove-readiness-probe-does-not-exist".into()],
Duration::from_secs(1),
)
.expect("spawn failure is not an error");
assert!(!ready);
}
#[test]
#[cfg_attr(
windows,
ignore = "MSYS sh.exe fork emulation does not preserve the native parent-pid chain that taskkill /T walks, so the kill cannot be observed through this test on Windows; see D39"
)]
fn cmd_probe_kills_a_descendant_spawned_by_a_shell_probe() {
let directory = tempfile::tempdir().expect("tempdir");
let pid_file = directory.path().join("descendant-pid");
let pid_file_for_script = pid_file.to_str().expect("utf8 path").replace('\\', "/");
let script = format!("sleep 30 & echo $! > '{pid_file_for_script}'; wait");
let ready = probe_cmd(
&["sh".into(), "-c".into(), script],
Duration::from_millis(300),
)
.expect("probe");
assert!(!ready);
let pid = fs::read_to_string(&pid_file)
.expect("descendant pid file")
.trim()
.to_owned();
let mut still_alive = true;
for _ in 0..20 {
still_alive = Command::new("kill")
.args(["-0", &pid])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.expect("check descendant")
.success();
if !still_alive {
break;
}
thread::sleep(Duration::from_millis(50));
}
assert!(
!still_alive,
"descendant (pid {pid}) survived the probe's timeout"
);
}
#[test]
fn cmd_probe_rejects_an_empty_argv() {
let error = probe_cmd(&[], Duration::from_secs(1)).expect_err("empty argv");
assert!(error.to_string().contains("empty argv"));
}
}