use std::io::Write;
use std::time::{Duration, Instant};
use anyhow::{anyhow, Result};
use colored::Colorize;
const READY_TIMEOUT: Duration = Duration::from_secs(30);
const POLL_INTERVAL: Duration = Duration::from_millis(500);
const PROBE_TIMEOUT: Duration = Duration::from_millis(750);
const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
pub async fn probe_health(port: u16) -> bool {
let base = format!("http://127.0.0.1:{port}/health");
let client = match reqwest::Client::builder()
.timeout(PROBE_TIMEOUT)
.connect_timeout(PROBE_TIMEOUT)
.build()
{
Ok(c) => c,
Err(_) => return false,
};
match client.get(&base).send().await {
Ok(r) => r.status().is_success(),
Err(_) => false,
}
}
fn spawn_daemon(port: u16) -> 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)
.arg("serve")
.arg("--port")
.arg(port.to_string())
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.map_err(|e| anyhow!("could not spawn `{} serve`: {e}", exe.display()))?;
Ok(child.id())
}
pub async fn ensure_daemon_running(port: u16) -> Result<()> {
if probe_health(port).await {
return Ok(());
}
let already_running = super::daemon::pid_file_path()
.ok()
.and_then(|p| {
let raw = std::fs::read_to_string(&p).ok()?;
raw.trim().parse::<u32>().ok()
})
.is_some();
if already_running {
eprint!(
"{} trusty-analyze daemon already starting, waiting for it to become ready…",
"◉".cyan()
);
let _ = std::io::stderr().flush();
} else {
eprintln!("{} Starting trusty-analyze daemon…", "◉".cyan());
spawn_daemon(port)?;
}
let deadline = Instant::now() + READY_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 daemon to become ready… ({}s) ",
glyph.cyan(),
elapsed
);
let _ = std::io::stderr().flush();
frame = frame.wrapping_add(1);
tokio::time::sleep(POLL_INTERVAL).await;
if probe_health(port).await {
eprint!("\r\x1b[2K");
let _ = std::io::stderr().flush();
eprintln!(
"{} Daemon ready ({}s)",
"✓".green(),
start.elapsed().as_secs()
);
return Ok(());
}
if Instant::now() >= deadline {
eprint!("\r\x1b[2K");
let _ = std::io::stderr().flush();
return Err(anyhow!(
"trusty-analyze daemon did not become ready within {}s on port {} — \
try `trusty-analyze serve --port {}` manually to see the error",
READY_TIMEOUT.as_secs(),
port,
port,
));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn probe_health_returns_false_on_connection_refused() {
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_health(port).await;
assert!(!ok, "probe should fail against an unbound port");
assert!(
started.elapsed() < Duration::from_secs(6),
"probe took too long: {:?}",
started.elapsed()
);
}
#[tokio::test]
async fn ensure_daemon_running_returns_ok_when_already_healthy() {
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 response = b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n";
let _ = stream.write_all(response).await;
});
}
}
});
tokio::time::sleep(Duration::from_millis(50)).await;
let result = ensure_daemon_running(port).await;
assert!(
result.is_ok(),
"should succeed when daemon is already healthy"
);
}
#[tokio::test]
async fn probe_health_returns_false_quickly_for_free_port() {
let started = Instant::now();
let ok = probe_health(1).await;
assert!(!ok);
assert!(
started.elapsed() < Duration::from_secs(6),
"probe should be fast: {:?}",
started.elapsed()
);
}
}