use std::path::{Path, PathBuf};
use std::time::Duration;
use anyhow::{Context, Result};
use crate::bootstrap::find_companion_binary;
use crate::commands::daemon_control;
use crate::{socket_client, theme};
const DAEMON_READY_TIMEOUT_SECS: u64 = 60;
const DAEMON_READY_POLL_MILLIS: u64 = 50;
const DAEMON_READY_POLL: Duration = Duration::from_millis(DAEMON_READY_POLL_MILLIS);
const DAEMON_READY_ATTEMPTS: u64 =
readiness_attempts(DAEMON_READY_TIMEOUT_SECS, DAEMON_READY_POLL_MILLIS);
const fn readiness_attempts(timeout_secs: u64, poll_millis: u64) -> u64 {
let Some(timeout_millis) = timeout_secs.checked_mul(1_000) else {
panic!("daemon readiness timeout overflow")
};
timeout_millis.div_ceil(poll_millis)
}
fn log_hint() -> String {
astrid_core::dirs::AstridHome::resolve()
.map(|h| format!(" Check logs: {}", h.log_dir().display()))
.unwrap_or_default()
}
fn boot_log_stderr() -> Option<std::process::Stdio> {
let home = astrid_core::dirs::AstridHome::resolve().ok()?;
let log_dir = home.log_dir();
std::fs::create_dir_all(&log_dir).ok()?;
let path = log_dir.join("daemon-boot.log");
let mut opts = std::fs::OpenOptions::new();
opts.create(true).append(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt as _;
opts.mode(0o600);
}
let file = opts.open(path).ok()?;
Some(std::process::Stdio::from(file))
}
pub(crate) async fn spawn_daemon(ready_path: &std::path::Path) -> Result<std::process::Child> {
spawn_daemon_inner(ready_path, true).await
}
async fn spawn_daemon_inner(
ready_path: &std::path::Path,
announce: bool,
) -> Result<std::process::Child> {
if announce {
println!("{}", theme::Theme::info("Booting Astrid daemon..."));
}
let ws = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
let daemon_bin = find_companion_binary("astrid-daemon")?;
let mut cmd = std::process::Command::new(daemon_bin);
cmd.arg("--ephemeral");
if let Some(ws_path) = ws.to_str() {
cmd.arg("--workspace").arg(ws_path);
}
let stderr = boot_log_stderr().unwrap_or_else(std::process::Stdio::null);
cmd.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(stderr);
let _ = std::fs::remove_file(ready_path);
let mut child = cmd
.spawn()
.context("Failed to spawn background Kernel daemon")?;
let mut ready = false;
for _ in 0..DAEMON_READY_ATTEMPTS {
tokio::time::sleep(DAEMON_READY_POLL).await;
if ready_path.exists() {
ready = true;
break;
}
if let Ok(Some(status)) = child.try_wait() {
anyhow::bail!("Daemon exited prematurely ({status}).{}", log_hint());
}
}
if !ready {
let _ = child.kill();
let _ = child.wait();
anyhow::bail!(
"Daemon failed to become ready within {} seconds.{}",
DAEMON_READY_TIMEOUT_SECS,
log_hint()
);
}
Ok(child)
}
pub(crate) async fn ensure_daemon(label: &str) -> Result<()> {
ensure_daemon_inner(label, true).await
}
pub(crate) async fn ensure_daemon_quiet(label: &str) -> Result<()> {
ensure_daemon_inner(label, false).await
}
async fn ensure_daemon_inner(label: &str, announce: bool) -> Result<()> {
let socket_path = socket_client::proxy_socket_path();
let ready_path = socket_client::readiness_path();
let needs_boot = if socket_path.exists() {
if tokio::net::UnixStream::connect(&socket_path).await.is_ok() {
if announce {
eprintln!("[{label}] Connected to existing daemon");
}
false
} else {
let _ = std::fs::remove_file(&socket_path);
let _ = std::fs::remove_file(&ready_path);
true
}
} else {
true
};
if needs_boot {
spawn_daemon_inner(&ready_path, announce).await?;
}
Ok(())
}
pub(crate) async fn spawn_persistent_daemon() -> Result<()> {
let ready_path = socket_client::readiness_path();
println!(
"{}",
theme::Theme::info("Starting Astrid daemon (persistent mode)...")
);
let ws = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
let daemon_bin = find_companion_binary("astrid-daemon")?;
let mut cmd = std::process::Command::new(daemon_bin);
if let Some(ws_path) = ws.to_str() {
cmd.arg("--workspace").arg(ws_path);
}
let stderr = boot_log_stderr().unwrap_or_else(std::process::Stdio::null);
cmd.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(stderr);
let _ = std::fs::remove_file(&ready_path);
let mut child = cmd.spawn().context("Failed to spawn Astrid daemon")?;
let mut ready = false;
for _ in 0..DAEMON_READY_ATTEMPTS {
tokio::time::sleep(DAEMON_READY_POLL).await;
if ready_path.exists() {
ready = true;
break;
}
if let Ok(Some(status)) = child.try_wait() {
anyhow::bail!("Daemon exited prematurely ({status}).{}", log_hint());
}
}
if !ready {
let _ = child.kill();
let _ = child.wait();
anyhow::bail!(
"Daemon failed to become ready within {} seconds.{}",
DAEMON_READY_TIMEOUT_SECS,
log_hint()
);
}
drop(child);
println!(
"{}",
theme::Theme::success("Astrid daemon started (persistent mode).")
);
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum StartAction {
AlreadyRunning,
RunningButUnreachable,
HealAndSpawn,
}
fn decide_start_action(socket_reachable: bool, recorded_pid_alive: bool) -> StartAction {
if socket_reachable {
StartAction::AlreadyRunning
} else if recorded_pid_alive {
StartAction::RunningButUnreachable
} else {
StartAction::HealAndSpawn
}
}
fn start_clears_sentinels(action: StartAction) -> bool {
matches!(action, StartAction::HealAndSpawn)
}
pub(crate) async fn handle_start() -> Result<()> {
let socket_path = socket_client::proxy_socket_path();
let ready_path = socket_client::readiness_path();
let pid_path = socket_client::pid_path();
let socket_reachable =
socket_path.exists() && tokio::net::UnixStream::connect(&socket_path).await.is_ok();
let recorded_pid_alive = daemon_control::read_pid_file(&pid_path)
.is_some_and(|(pid, _)| daemon_control::is_process_alive(pid));
match decide_start_action(socket_reachable, recorded_pid_alive) {
StartAction::AlreadyRunning => {
println!(
"{}",
theme::Theme::warning("Astrid daemon is already running.")
);
Ok(())
},
StartAction::RunningButUnreachable => {
println!(
"{}",
theme::Theme::warning(
"An Astrid daemon appears to be running but its socket is not reachable yet \
(it may be starting up). If it stays unreachable, run `astrid restart`.",
)
);
Ok(())
},
StartAction::HealAndSpawn => {
let _ = std::fs::remove_file(&socket_path);
let _ = std::fs::remove_file(&ready_path);
let _ = std::fs::remove_file(&pid_path);
spawn_persistent_daemon().await
},
}
}
pub(crate) async fn handle_status() -> Result<()> {
let socket_path = socket_client::proxy_socket_path();
if !socket_path.exists() {
println!("{}", theme::Theme::info("No Astrid daemon is running."));
return Ok(());
}
let session_id = astrid_core::SessionId::from_uuid(uuid::Uuid::new_v4());
match socket_client::SocketClient::connect(session_id, crate::principal::current()).await {
Ok(mut client) => {
let req = astrid_core::kernel_api::KernelRequest::GetStatus;
if let Ok(val) = serde_json::to_value(req) {
let msg = astrid_types::ipc::IpcMessage::new(
astrid_types::Topic::kernel_request("status"),
astrid_types::ipc::IpcPayload::RawJson(val),
uuid::Uuid::nil(),
);
client.send_message(msg).await?;
let raw = client
.read_until_topic(
astrid_types::Topic::kernel_response("status").as_str(),
std::time::Duration::from_secs(10),
)
.await?;
if let Some(astrid_core::kernel_api::KernelResponse::Status(status)) =
crate::socket_client::SocketClient::extract_kernel_response(&raw)
{
let uptime_display = format_uptime(status.uptime_secs);
println!(
"{}",
theme::Theme::success(&format!(
"Astrid daemon (PID {}, uptime {})",
status.pid, uptime_display
))
);
println!(" Version: {}", status.version);
println!(" Clients: {}", status.connected_clients);
println!(" Capsules: {} loaded", status.loaded_capsules.len());
for capsule in &status.loaded_capsules {
println!(" - {capsule}");
}
} else {
println!("{}", theme::Theme::error("Unexpected response from daemon"));
}
}
},
Err(_) => {
println!(
"{}",
theme::Theme::error(
"Daemon socket exists but connection failed. \
It may be starting up or in a bad state."
)
);
},
}
Ok(())
}
pub(crate) async fn handle_stop() -> Result<()> {
let socket_path = socket_client::proxy_socket_path();
let pid_path = socket_client::pid_path();
let recorded = daemon_control::read_pid_file(&pid_path);
let socket_present = socket_path.exists();
let recorded_alive = recorded
.as_ref()
.is_some_and(|(pid, _)| daemon_control::is_process_alive(*pid));
if !socket_present && !recorded_alive {
println!("{}", theme::Theme::info("No Astrid daemon is running."));
let _ = std::fs::remove_file(&pid_path); return Ok(());
}
if socket_present
&& let Ok(mut client) = socket_client::SocketClient::connect(
astrid_core::SessionId::from_uuid(uuid::Uuid::new_v4()),
crate::principal::current(),
)
.await
{
let req = astrid_core::kernel_api::KernelRequest::Shutdown {
reason: Some("astrid stop".to_string()),
};
if let Ok(val) = serde_json::to_value(req) {
let msg = astrid_types::ipc::IpcMessage::new(
astrid_types::Topic::kernel_request("shutdown"),
astrid_types::ipc::IpcPayload::RawJson(val),
uuid::Uuid::nil(),
);
client.send_message(msg).await?;
let raw = client
.read_until_topic(
astrid_types::Topic::kernel_response("shutdown").as_str(),
Duration::from_secs(10),
)
.await?;
match crate::socket_client::SocketClient::extract_kernel_response(&raw) {
Some(astrid_core::kernel_api::KernelResponse::Success(_)) => {
confirm_graceful_stop(recorded, &pid_path, &socket_path).await;
},
Some(astrid_core::kernel_api::KernelResponse::Error(reason)) => {
anyhow::bail!("daemon rejected shutdown: {reason}");
},
Some(other) => {
anyhow::bail!("unexpected response from daemon shutdown: {other:?}");
},
None => {
anyhow::bail!("daemon shutdown response did not deserialize");
},
}
}
return Ok(());
}
let outcome = match &recorded {
Some((pid, exe)) => daemon_control::terminate_known(*pid, exe.as_deref()).await,
None => daemon_control::KillOutcome::NotRunning,
};
report_orphan_stop(outcome, socket_present, &pid_path, &socket_path);
Ok(())
}
async fn confirm_graceful_stop(
recorded: Option<(u32, Option<PathBuf>)>,
pid_path: &Path,
socket_path: &Path,
) {
let Some((pid, exe)) = recorded else {
println!("{}", theme::Theme::success("Astrid daemon stopped."));
let _ = std::fs::remove_file(pid_path);
return;
};
if daemon_control::wait_for_exit(pid, daemon_control::GRACE).await {
println!("{}", theme::Theme::success("Astrid daemon stopped."));
remove_runtime_files(pid_path, socket_path);
return;
}
eprintln!(
"{}",
theme::Theme::warning(
"Daemon acknowledged shutdown but is still running; escalating with a signal so the \
state-db lock is released."
)
);
let outcome = daemon_control::terminate_known(pid, exe.as_deref()).await;
report_orphan_stop(outcome, true, pid_path, socket_path);
}
fn report_orphan_stop(
outcome: daemon_control::KillOutcome,
socket_present: bool,
pid_path: &Path,
socket_path: &Path,
) {
match &outcome {
daemon_control::KillOutcome::NotRunning => {
if socket_present {
println!("{}", theme::Theme::info("Cleaned up stale daemon socket."));
} else {
println!("{}", theme::Theme::info("No Astrid daemon is running."));
}
},
daemon_control::KillOutcome::TermExited | daemon_control::KillOutcome::KilledExited => {
println!(
"{}",
theme::Theme::success("Stopped an unresponsive Astrid daemon.")
);
},
daemon_control::KillOutcome::StillAlive => {
eprintln!(
"{}",
theme::Theme::error(
"An unresponsive Astrid daemon did not exit even after SIGKILL; the \
state-db lock may still be held. Inspect the process before retrying."
)
);
},
daemon_control::KillOutcome::Unverified(pid) => {
eprintln!(
"{}",
theme::Theme::warning(&format!(
"A process (PID {pid}) holds the recorded daemon PID but I can't confirm \
it's the Astrid daemon (possible PID reuse) — not killing it. If the daemon \
is genuinely stuck, inspect PID {pid} and stop it manually."
))
);
},
}
if stop_confirmed_gone(outcome) {
remove_runtime_files(pid_path, socket_path);
}
}
fn remove_runtime_files(pid_path: &Path, socket_path: &Path) {
let _ = std::fs::remove_file(socket_path);
let _ = std::fs::remove_file(socket_client::readiness_path());
let _ = std::fs::remove_file(pid_path);
}
fn stop_confirmed_gone(outcome: daemon_control::KillOutcome) -> bool {
matches!(
outcome,
daemon_control::KillOutcome::NotRunning
| daemon_control::KillOutcome::TermExited
| daemon_control::KillOutcome::KilledExited
)
}
pub(crate) fn format_uptime(secs: u64) -> String {
let hours = secs / 3600;
let minutes = (secs % 3600) / 60;
let seconds = secs % 60;
if hours > 0 {
format!("{hours}h{minutes:02}m{seconds:02}s")
} else if minutes > 0 {
format!("{minutes}m{seconds:02}s")
} else {
format!("{seconds}s")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn daemon_ready_attempts_match_timeout_window() {
assert_eq!(
DAEMON_READY_ATTEMPTS,
readiness_attempts(DAEMON_READY_TIMEOUT_SECS, DAEMON_READY_POLL_MILLIS)
);
assert_eq!(
DAEMON_READY_ATTEMPTS
.checked_mul(DAEMON_READY_POLL_MILLIS)
.expect("readiness window fits"),
60_000
);
}
#[test]
fn stop_cleans_up_only_when_confirmed_gone() {
use daemon_control::KillOutcome;
assert!(stop_confirmed_gone(KillOutcome::NotRunning));
assert!(stop_confirmed_gone(KillOutcome::TermExited));
assert!(stop_confirmed_gone(KillOutcome::KilledExited));
assert!(!stop_confirmed_gone(KillOutcome::StillAlive));
assert!(!stop_confirmed_gone(KillOutcome::Unverified(4242)));
}
#[test]
fn start_reachable_daemon_is_already_running() {
assert_eq!(
decide_start_action(true, false),
StartAction::AlreadyRunning
);
assert_eq!(decide_start_action(true, true), StartAction::AlreadyRunning);
assert!(!start_clears_sentinels(StartAction::AlreadyRunning));
}
#[test]
fn start_unreachable_with_dead_pid_heals_and_spawns() {
let action = decide_start_action(false, false);
assert_eq!(action, StartAction::HealAndSpawn);
assert!(start_clears_sentinels(action));
}
#[test]
fn start_unreachable_with_live_pid_defers_and_leaves_sentinels() {
let action = decide_start_action(false, true);
assert_eq!(action, StartAction::RunningButUnreachable);
assert!(!start_clears_sentinels(action));
}
}