use std::io::Write;
use std::path::Path;
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 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)
}
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> {
spawn_built(detached_command(program, args, None))
}
pub fn spawn_detached_forwarding_parent_link(
program: impl AsRef<std::ffi::OsStr>,
args: &[&str],
) -> Result<u32> {
let stamp = crate::parent_death::inherited_stamp();
spawn_built(detached_command(program, args, stamp.as_deref()))
}
fn detached_command(
program: impl AsRef<std::ffi::OsStr>,
args: &[&str],
stamp: Option<&str>,
) -> std::process::Command {
let mut cmd = std::process::Command::new(program);
cmd.args(args)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null());
start_in_new_session(&mut cmd);
match stamp {
Some(value) => cmd.env(crate::parent_death::ENV_EXIT_WITH_PARENT, value),
None => cmd.env_remove(crate::parent_death::ENV_EXIT_WITH_PARENT),
};
cmd
}
pub fn start_in_new_session(cmd: &mut std::process::Command) -> &mut std::process::Command {
#[cfg(unix)]
{
use std::os::unix::process::CommandExt as _;
unsafe {
cmd.pre_exec(become_session_leader);
}
}
cmd
}
#[cfg(unix)]
pub(crate) fn become_session_leader() -> std::io::Result<()> {
if unsafe { libc::setsid() } == -1 {
return Err(std::io::Error::last_os_error());
}
Ok(())
}
fn spawn_built(mut cmd: std::process::Command) -> Result<u32> {
let rendered = format!("{cmd:?}");
let child = cmd
.spawn()
.map_err(|e| anyhow!("could not spawn `{rendered}`: {e}"))?;
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 fn spawn_current_exe_forwarding_parent_link(args: &[&str]) -> Result<u32> {
let exe = std::env::current_exe().map_err(|e| anyhow!("could not resolve current_exe: {e}"))?;
spawn_detached_forwarding_parent_link(&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(all(test, unix))]
#[path = "daemon_guard_session_tests.rs"]
mod session_tests;
#[cfg(test)]
mod addr_tests {
use super::*;
#[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");
}
}
#[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}"
);
}
#[test]
fn detached_command_scrubs_the_parent_stamp() {
let cmd = detached_command("/bin/true", &["serve"], None);
let entry = cmd
.get_envs()
.find(|(k, _)| *k == std::ffi::OsStr::new(crate::parent_death::ENV_EXIT_WITH_PARENT));
assert_eq!(
entry.map(|(_, v)| v),
Some(None),
"the default detached spawn must REMOVE the parent-death stamp"
);
}
#[test]
fn detached_command_forwards_an_explicit_parent_stamp() {
let cmd = detached_command("/bin/true", &["serve"], Some("4242"));
let value = cmd
.get_envs()
.find(|(k, _)| *k == std::ffi::OsStr::new(crate::parent_death::ENV_EXIT_WITH_PARENT))
.and_then(|(_, v)| v)
.expect("the forwarding spawn must set the parent-death stamp");
assert_eq!(value, std::ffi::OsStr::new("4242"));
}
#[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}"
);
}
}