use std::io::Write;
use std::path::{Path, PathBuf};
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(),
}
}
}
const ADDR_FALLBACK_HOST: &str = "127.0.0.1";
const ADDR_PROBE_TIMEOUT: Duration = Duration::from_millis(200);
#[non_exhaustive]
pub struct DaemonAddrLayout {
pub data_dir_env: &'static str,
pub home_subdir: &'static str,
pub data_local_subdir: &'static str,
pub addr_file_name: &'static str,
pub port_file_name: &'static str,
pub default_port: u16,
}
impl DaemonAddrLayout {
pub const TRUSTY_SEARCH: Self = Self {
data_dir_env: "TRUSTY_DATA_DIR",
home_subdir: ".trusty-search",
data_local_subdir: "trusty-search",
addr_file_name: "http_addr",
port_file_name: "daemon.port",
default_port: 7878,
};
pub fn discovery_file_path(&self) -> Option<PathBuf> {
if let Ok(dir) = std::env::var(self.data_dir_env) {
return Some(PathBuf::from(dir).join(self.addr_file_name));
}
dirs::home_dir().map(|h| h.join(self.home_subdir).join(self.addr_file_name))
}
pub fn port_file_path(&self) -> Option<PathBuf> {
if let Ok(dir) = std::env::var(self.data_dir_env) {
return Some(PathBuf::from(dir).join(self.port_file_name));
}
dirs::data_local_dir().map(|d| d.join(self.data_local_subdir).join(self.port_file_name))
}
pub fn resolve_base_url(&self) -> String {
if let Some(path) = self.discovery_file_path()
&& let Ok(raw) = std::fs::read_to_string(&path)
{
let addr = raw.trim();
if !addr.is_empty() && address_reachable_blocking(addr) {
return format!("http://{addr}");
}
}
let port = self
.port_file_path()
.and_then(|p| std::fs::read_to_string(p).ok())
.and_then(|s| s.trim().parse::<u16>().ok())
.unwrap_or(self.default_port);
let live_addr = format!("{ADDR_FALLBACK_HOST}:{port}");
if address_reachable_blocking(&live_addr)
&& let Some(path) = self.discovery_file_path()
{
let _ = write_addr_file_atomic(&path, &live_addr);
}
format!("http://{live_addr}")
}
}
pub fn write_addr_file_atomic(path: &Path, addr: &str) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let tmp = path.with_extension("addr.tmp");
{
let mut f = std::fs::File::create(&tmp)?;
writeln!(f, "{addr}")?;
f.sync_all()?;
}
std::fs::rename(&tmp, path)
}
fn address_reachable_blocking(host_port: &str) -> bool {
use std::net::{SocketAddr, TcpStream, ToSocketAddrs};
let Ok(mut iter) = host_port.to_socket_addrs() else {
return false;
};
let Some(addr): Option<SocketAddr> = iter.next() else {
return false;
};
TcpStream::connect_timeout(&addr, ADDR_PROBE_TIMEOUT).is_ok()
}
pub async fn probe_once(health_url: &str) -> bool {
let client = match crate::http_client::loopback_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_detached(program: impl AsRef<std::ffi::OsStr>, args: &[&str]) -> Result<u32> {
let program = program.as_ref();
let child = std::process::Command::new(program)
.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}",
program.to_string_lossy(),
args.join(" "),
)
})?;
Ok(child.id())
}
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}"))?;
spawn_detached(&exe, args)
}
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 addr_tests {
use super::*;
use serial_test::serial;
fn isolated_layout(env_var: &'static str, dir: &Path) -> DaemonAddrLayout {
unsafe { std::env::set_var(env_var, dir) };
DaemonAddrLayout {
data_dir_env: env_var,
..DaemonAddrLayout::TRUSTY_SEARCH
}
}
fn clear(env_var: &str) {
unsafe { std::env::remove_var(env_var) };
}
#[test]
fn trusty_search_layout_matches_shipped_paths() {
let l = DaemonAddrLayout::TRUSTY_SEARCH;
assert_eq!(l.data_dir_env, "TRUSTY_DATA_DIR");
assert_eq!(l.home_subdir, ".trusty-search");
assert_eq!(l.data_local_subdir, "trusty-search");
assert_eq!(l.addr_file_name, "http_addr");
assert_eq!(l.port_file_name, "daemon.port");
assert_eq!(l.default_port, 7878);
}
#[test]
#[serial]
fn layout_paths_honour_the_isolation_env_var() {
let tmp = tempfile::tempdir().unwrap();
let layout = isolated_layout("TRUSTY_TEST_ADDR_DIR_PATHS", tmp.path());
let discovery = layout.discovery_file_path().unwrap();
let port = layout.port_file_path().unwrap();
clear("TRUSTY_TEST_ADDR_DIR_PATHS");
assert_eq!(discovery, tmp.path().join("http_addr"));
assert_eq!(port, tmp.path().join("daemon.port"));
}
#[test]
#[serial]
fn resolve_base_url_prefers_a_live_discovery_file() {
let discovery_listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let discovery_addr = discovery_listener.local_addr().unwrap().to_string();
let port_listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let other_port = port_listener.local_addr().unwrap().port();
let tmp = tempfile::tempdir().unwrap();
let layout = isolated_layout("TRUSTY_TEST_ADDR_DIR_LIVE", tmp.path());
std::fs::write(tmp.path().join("http_addr"), &discovery_addr).unwrap();
std::fs::write(tmp.path().join("daemon.port"), other_port.to_string()).unwrap();
let url = layout.resolve_base_url();
clear("TRUSTY_TEST_ADDR_DIR_LIVE");
assert_eq!(url, format!("http://{discovery_addr}"));
}
#[test]
#[serial]
fn resolve_base_url_falls_back_when_discovery_file_is_dead() {
let live = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let live_port = live.local_addr().unwrap().port();
let tmp = tempfile::tempdir().unwrap();
let layout = isolated_layout("TRUSTY_TEST_ADDR_DIR_DEAD", tmp.path());
let discovery = tmp.path().join("http_addr");
std::fs::write(&discovery, "127.0.0.1:1").unwrap();
std::fs::write(tmp.path().join("daemon.port"), live_port.to_string()).unwrap();
let url = layout.resolve_base_url();
let refreshed = std::fs::read_to_string(&discovery).unwrap();
clear("TRUSTY_TEST_ADDR_DIR_DEAD");
assert_eq!(url, format!("http://127.0.0.1:{live_port}"));
assert_eq!(refreshed.trim(), format!("127.0.0.1:{live_port}"));
}
#[test]
#[serial]
fn resolve_base_url_falls_back_when_discovery_file_is_absent() {
let live = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let live_port = live.local_addr().unwrap().port();
let tmp = tempfile::tempdir().unwrap();
let layout = isolated_layout("TRUSTY_TEST_ADDR_DIR_ABSENT", tmp.path());
std::fs::write(tmp.path().join("daemon.port"), live_port.to_string()).unwrap();
let url = layout.resolve_base_url();
let created = std::fs::read_to_string(tmp.path().join("http_addr")).unwrap();
clear("TRUSTY_TEST_ADDR_DIR_ABSENT");
assert_eq!(url, format!("http://127.0.0.1:{live_port}"));
assert_eq!(created.trim(), format!("127.0.0.1:{live_port}"));
}
#[test]
#[serial]
fn resolve_base_url_falls_back_when_discovery_file_is_malformed() {
let live = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let live_port = live.local_addr().unwrap().port();
let tmp = tempfile::tempdir().unwrap();
let layout = isolated_layout("TRUSTY_TEST_ADDR_DIR_BAD", tmp.path());
let discovery = tmp.path().join("http_addr");
std::fs::write(tmp.path().join("daemon.port"), live_port.to_string()).unwrap();
for junk in ["", " \n", "not-a-host:port", "127.0.0.1"] {
std::fs::write(&discovery, junk).unwrap();
assert_eq!(
layout.resolve_base_url(),
format!("http://127.0.0.1:{live_port}"),
"a discovery file containing {junk:?} must fall through to the port file"
);
}
std::fs::write(&discovery, "not-a-host:port").unwrap();
std::fs::write(tmp.path().join("daemon.port"), "not-a-port").unwrap();
let url = layout.resolve_base_url();
clear("TRUSTY_TEST_ADDR_DIR_BAD");
assert_eq!(url, format!("http://127.0.0.1:{}", layout.default_port));
}
#[test]
#[serial]
fn resolve_base_url_uses_default_port_when_nothing_is_readable() {
let tmp = tempfile::tempdir().unwrap();
let layout = DaemonAddrLayout {
data_dir_env: "TRUSTY_TEST_ADDR_DIR_EMPTY",
default_port: 1,
..DaemonAddrLayout::TRUSTY_SEARCH
};
unsafe { std::env::set_var("TRUSTY_TEST_ADDR_DIR_EMPTY", tmp.path()) };
let url = layout.resolve_base_url();
let wrote_cache = tmp.path().join("http_addr").exists();
clear("TRUSTY_TEST_ADDR_DIR_EMPTY");
assert_eq!(url, "http://127.0.0.1:1");
assert!(
!wrote_cache,
"an unreachable fallback address must not be cached as if it were live"
);
}
#[test]
fn write_addr_file_atomic_round_trips() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("nested").join("http_addr");
write_addr_file_atomic(&path, "127.0.0.1:7878").unwrap();
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
"127.0.0.1:7878\n",
"the file carries exactly one trailing newline"
);
}
#[test]
fn write_addr_file_atomic_replaces_existing_content() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("http_addr");
write_addr_file_atomic(&path, "127.0.0.1:65535").unwrap();
write_addr_file_atomic(&path, "127.0.0.1:80").unwrap();
assert_eq!(std::fs::read_to_string(&path).unwrap(), "127.0.0.1:80\n");
}
#[test]
fn address_reachable_returns_false_for_dead_port() {
let start = Instant::now();
assert!(!address_reachable_blocking("127.0.0.1:1"));
assert!(
start.elapsed() < Duration::from_millis(1500),
"probe took too long: {:?}",
start.elapsed()
);
}
#[test]
fn address_reachable_returns_false_for_garbage_input() {
assert!(!address_reachable_blocking("not-a-host:port"));
assert!(!address_reachable_blocking(""));
assert!(!address_reachable_blocking("127.0.0.1"));
}
#[test]
fn address_reachable_returns_true_for_live_listener() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
assert!(address_reachable_blocking(&addr.to_string()));
}
}
#[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()
);
}
#[test]
fn spawn_detached_reports_a_missing_program() {
let err = spawn_detached("/nonexistent/trusty-nothing-here", &["serve"])
.expect_err("a program that does not exist cannot be spawned");
let msg = err.to_string();
assert!(
msg.contains("/nonexistent/trusty-nothing-here") && msg.contains("serve"),
"the error must name the program and its arguments; got: {msg}"
);
}
#[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}"
);
}
}