use std::io::Write;
use std::time::{Duration, Instant};
use anyhow::{Result, anyhow};
use colored::Colorize;
const PROBE_TIMEOUT: Duration = Duration::from_millis(750);
pub const DEFAULT_POLL_INTERVAL: Duration = Duration::from_millis(500);
pub const DEFAULT_STARTUP_TIMEOUT: Duration = Duration::from_secs(30);
const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
pub struct DaemonGuardConfig {
pub health_url: String,
pub service_name: String,
pub startup_timeout: Duration,
pub poll_interval: Duration,
pub timeout_hint: String,
}
impl DaemonGuardConfig {
pub fn new(
health_url: impl Into<String>,
service_name: impl Into<String>,
timeout_hint: impl Into<String>,
) -> Self {
Self {
health_url: health_url.into(),
service_name: service_name.into(),
startup_timeout: DEFAULT_STARTUP_TIMEOUT,
poll_interval: DEFAULT_POLL_INTERVAL,
timeout_hint: timeout_hint.into(),
}
}
}
pub async fn probe_once(health_url: &str) -> bool {
let client = match reqwest::Client::builder()
.timeout(PROBE_TIMEOUT)
.connect_timeout(PROBE_TIMEOUT)
.build()
{
Ok(c) => c,
Err(_) => return false,
};
matches!(
client.get(health_url).send().await,
Ok(r) if r.status().is_success()
)
}
pub fn spawn_current_exe(args: &[&str]) -> Result<u32> {
let exe = std::env::current_exe().map_err(|e| anyhow!("could not resolve current_exe: {e}"))?;
let child = std::process::Command::new(&exe)
.args(args)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.map_err(|e| {
anyhow!(
"could not spawn `{} {}`: {e}",
exe.display(),
args.join(" "),
)
})?;
Ok(child.id())
}
pub async fn spin_until_ready(config: &DaemonGuardConfig) -> Result<()> {
let deadline = Instant::now() + config.startup_timeout;
let start = Instant::now();
let mut frame = 0usize;
loop {
let elapsed = start.elapsed().as_secs();
let glyph = SPINNER_FRAMES[frame % SPINNER_FRAMES.len()];
eprint!(
"\r{} Waiting for {} to become ready… ({}s) ",
glyph.cyan(),
config.service_name,
elapsed
);
let _ = std::io::stderr().flush();
frame = frame.wrapping_add(1);
tokio::time::sleep(config.poll_interval).await;
if probe_once(&config.health_url).await {
eprint!("\r\x1b[2K");
let _ = std::io::stderr().flush();
eprintln!(
"{} {} ready ({}s)",
"✓".green(),
config.service_name,
start.elapsed().as_secs()
);
return Ok(());
}
if Instant::now() >= deadline {
eprint!("\r\x1b[2K");
let _ = std::io::stderr().flush();
return Err(anyhow!(
"{} did not become ready within {}s — {}",
config.service_name,
config.startup_timeout.as_secs(),
config.timeout_hint,
));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Instant;
#[tokio::test]
async fn probe_once_returns_false_for_refused_port() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
drop(listener);
let started = Instant::now();
let ok = probe_once(&format!("http://127.0.0.1:{port}/health")).await;
assert!(!ok, "probe must fail against an unbound port");
assert!(
started.elapsed() < Duration::from_secs(6),
"probe took too long: {:?}",
started.elapsed()
);
}
#[tokio::test]
async fn probe_once_returns_false_for_bad_url() {
let ok = probe_once("not-a-valid-url").await;
assert!(!ok);
}
#[tokio::test]
async fn spin_until_ready_returns_ok_for_live_server() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
tokio::spawn(async move {
loop {
if let Ok((mut stream, _)) = listener.accept().await {
tokio::spawn(async move {
use tokio::io::AsyncWriteExt;
let _ = stream
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n")
.await;
});
}
}
});
tokio::time::sleep(Duration::from_millis(20)).await;
let cfg = DaemonGuardConfig {
health_url: format!("http://127.0.0.1:{port}/health"),
service_name: "test-daemon".to_string(),
startup_timeout: Duration::from_secs(5),
poll_interval: Duration::from_millis(50),
timeout_hint: "run `test-daemon start` to debug".to_string(),
};
let result = spin_until_ready(&cfg).await;
assert!(
result.is_ok(),
"spin_until_ready must succeed when daemon is up: {result:?}"
);
}
#[tokio::test]
async fn spin_until_ready_times_out_for_down_daemon() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
drop(listener);
let cfg = DaemonGuardConfig {
health_url: format!("http://127.0.0.1:{port}/health"),
service_name: "test-daemon".to_string(),
startup_timeout: Duration::from_millis(200),
poll_interval: Duration::from_millis(50),
timeout_hint: "run `test-daemon start` to debug".to_string(),
};
let result = spin_until_ready(&cfg).await;
assert!(
result.is_err(),
"spin_until_ready must fail when daemon never starts"
);
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("test-daemon"),
"error must name the service; got: {msg}"
);
}
}