use std::path::{Path, PathBuf};
use std::time::Duration;
use anyhow::{Context, Result};
use astrid_core::kernel_api::{DaemonStatus, KernelRequest, KernelResponse};
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,
workspace_root: Option<&Path>,
) -> Result<std::process::Child> {
spawn_daemon_inner(ready_path, true, workspace_root).await
}
async fn spawn_daemon_inner(
ready_path: &std::path::Path,
announce: bool,
workspace_root: Option<&Path>,
) -> Result<std::process::Child> {
if announce {
println!("{}", theme::Theme::info("Booting Astrid daemon..."));
}
let ws = workspace_root.map_or_else(
|| std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")),
Path::to_path_buf,
);
let daemon_bin = find_companion_binary("astrid-daemon")?;
let mut cmd = ephemeral_daemon_command(&daemon_bin, &ws);
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)
}
fn ephemeral_daemon_command(daemon_bin: &Path, workspace_root: &Path) -> std::process::Command {
let mut cmd = std::process::Command::new(daemon_bin);
cmd.arg("--ephemeral")
.arg("--workspace")
.arg(workspace_root)
.env(
"ASTRID_WORKSPACE_STATE_DIR",
crate::workspace_layout::current().state_dir_name(),
);
cmd
}
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() {
ensure_daemon_workspace_matches(None).await?;
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, None).await?;
ensure_daemon_workspace_matches(None).await?;
}
Ok(())
}
pub(crate) async fn ensure_daemon_workspace_matches(workspace_root: Option<&Path>) -> Result<()> {
let expected = expected_workspace_fingerprint(workspace_root)?;
let ready_path = socket_client::readiness_path();
for _ in 0..DAEMON_READY_ATTEMPTS {
match std::fs::read_to_string(&ready_path) {
Ok(metadata) => return validate_daemon_workspace_metadata(&metadata, &expected),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
tokio::time::sleep(DAEMON_READY_POLL).await;
},
Err(error) => {
return Err(error).context("failed to read daemon workspace metadata");
},
}
}
anyhow::bail!(
"daemon workspace metadata was not available within {DAEMON_READY_TIMEOUT_SECS} seconds; run `astrid restart`"
)
}
fn expected_workspace_fingerprint(workspace_root: Option<&Path>) -> Result<String> {
let root = workspace_root.map_or_else(
|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
Path::to_path_buf,
);
astrid_core::dirs::checked_workspace_selection_fingerprint(
&root,
crate::workspace_layout::current(),
)
.context("selected workspace state path is unsafe")
}
fn validate_daemon_workspace_metadata(metadata: &str, expected: &str) -> Result<()> {
let Some(actual) = metadata.trim().strip_prefix("v1:") else {
anyhow::bail!(
"running daemon does not expose workspace selection metadata; run `astrid restart`"
);
};
if actual != expected {
anyhow::bail!(
"running daemon belongs to another project or workspace layout; run `astrid restart` from this project"
);
}
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);
cmd.env(
"ASTRID_WORKSPACE_STATE_DIR",
crate::workspace_layout::current().state_dir_name(),
);
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_probe = if socket_path.exists() {
tokio::net::UnixStream::connect(&socket_path).await.ok()
} else {
None
};
let socket_reachable = socket_probe.is_some();
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 => {
ensure_daemon_workspace_matches(None).await?;
drop(socket_probe);
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 mut client = socket_client::connect_kernel_for_workspace(None)
.await
.context("Daemon socket exists but connection failed")?;
let status = status_response(
client
.request(KernelRequest::GetStatus)
.await
.context("Failed to query daemon status")?,
)?;
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}");
}
Ok(())
}
fn status_response(response: KernelResponse) -> Result<DaemonStatus> {
match response {
KernelResponse::Status(status) => Ok(status),
KernelResponse::Error(message) => {
anyhow::bail!("daemon rejected status request: {message}")
},
other => anyhow::bail!("daemon returned an unexpected status response: {other:?}"),
}
}
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(client) = socket_client::connect_kernel_for_recovery().await {
let mut client = client.with_timeout(Duration::from_secs(10));
match client
.request(KernelRequest::Shutdown {
reason: Some("astrid stop".to_string()),
})
.await?
{
KernelResponse::Success(_) => {
confirm_graceful_stop(recorded, &pid_path, &socket_path).await;
},
KernelResponse::Error(reason) => anyhow::bail!("daemon rejected shutdown: {reason}"),
other => anyhow::bail!("unexpected response from daemon shutdown: {other:?}"),
}
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 daemon_workspace_metadata_rejects_unknown_or_different_selection() {
let expected = "a".repeat(64);
assert!(validate_daemon_workspace_metadata("", &expected).is_err());
assert!(
validate_daemon_workspace_metadata(&format!("v1:{}", "b".repeat(64)), &expected)
.is_err()
);
validate_daemon_workspace_metadata(&format!("v1:{expected}\n"), &expected).unwrap();
}
#[test]
fn explicit_workspace_selection_wins_over_current_directory() {
let current = std::env::current_dir().expect("current directory");
let explicit = tempfile::tempdir().expect("explicit workspace");
assert_ne!(explicit.path(), current);
assert_eq!(
expected_workspace_fingerprint(Some(explicit.path())).unwrap(),
astrid_core::dirs::checked_workspace_selection_fingerprint(
explicit.path(),
crate::workspace_layout::current(),
)
.unwrap()
);
assert_eq!(
expected_workspace_fingerprint(None).unwrap(),
astrid_core::dirs::checked_workspace_selection_fingerprint(
¤t,
crate::workspace_layout::current(),
)
.unwrap()
);
}
#[test]
fn ephemeral_boot_passes_the_selected_workspace_to_the_daemon() {
use std::ffi::OsStr;
let command = ephemeral_daemon_command(
Path::new("/installed/astrid-daemon"),
Path::new("/selected/project"),
);
let args = command.get_args().collect::<Vec<_>>();
assert_eq!(
args,
vec![
OsStr::new("--ephemeral"),
OsStr::new("--workspace"),
OsStr::new("/selected/project"),
]
);
assert_eq!(
command
.get_envs()
.find(|(name, _)| *name == OsStr::new("ASTRID_WORKSPACE_STATE_DIR"))
.and_then(|(_, value)| value),
Some(OsStr::new(
crate::workspace_layout::current().state_dir_name()
))
);
}
#[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));
}
#[test]
fn status_error_is_not_reported_as_a_successful_status() {
let error = status_response(KernelResponse::Error("denied".into()))
.expect_err("kernel status errors must fail the command");
assert!(
error
.to_string()
.contains("daemon rejected status request: denied")
);
}
}