#![expect(unused_crate_dependencies, reason = "Didn't remove them yet")] use clap::Parser; use eyre::{Result, WrapErr, bail, eyre}; use fs4::fs_std::FileExt; use std::fs::{self, File, OpenOptions}; use std::io::Write; use std::path::{Path, PathBuf}; use std::time::{Duration, Instant}; use tokio::time::sleep; use turtle_daemon::{ aclient::{database::ClientSqlite, record::sqlite_store::SqliteStore, settings::Settings}, client::{DaemonClientErrorKind, HistoryClient, classify_error}, }; #[derive(Parser, Debug)] #[command(infer_subcommands = true)] pub(crate) enum Cmd { /// Start the daemon server Start { /// Also write daemon logs to the console (useful for debugging) #[arg(long)] show_logs: bool, }, /// Show the daemon's current status Status, /// Stop the daemon gracefully Stop, } #[tokio::main] async fn main() -> Result<()> { let settings = Settings::new().wrap_err("could not load client settings")?; let db_path = PathBuf::from(settings.db_path.as_str()); let record_store_path = PathBuf::from(settings.record_store_path.as_str()); let db = ClientSqlite::new(db_path, settings.local_timeout).await?; let sqlite_store = SqliteStore::new(record_store_path, settings.local_timeout).await?; match Cmd::parse() { Cmd::Start { show_logs, .. } => start_cmd(settings, store, history_db, show_logs).await, Cmd::Status => status_cmd(&settings).await, Cmd::Stop => stop_cmd(&settings).await, } } const STARTUP_POLL: Duration = Duration::from_millis(40); const LEGACY_DAEMON_RESTART_MESSAGE: &str = "legacy daemon detected; restart daemon manually"; struct PidfileGuard { file: File, } impl PidfileGuard { fn acquire(path: &Path) -> Result { let mut file = open_lock_file(path)?; if !file.try_lock_exclusive()? { bail!( "daemon already running (pidfile lock busy at {})", path.display() ); } file.set_len(0) .wrap_err_with(|| format!("could not truncate daemon pidfile {}", path.display()))?; writeln!(file, "{}", std::process::id()) .and_then(|()| writeln!(file, "{DAEMON_VERSION}")) .wrap_err_with(|| format!("could not write daemon pidfile {}", path.display()))?; Ok(Self { file }) } } impl Drop for PidfileGuard { fn drop(&mut self) { drop(self.file.unlock()); } } enum Probe { Ready(HistoryClient), NeedsRestart(String), Unreachable(eyre::Report), } fn is_legacy_daemon_error(err: &eyre::Report) -> bool { matches!(classify_error(err), DaemonClientErrorKind::Unimplemented) } fn open_lock_file(path: &Path) -> Result { if let Some(parent) = path.parent() { fs::create_dir_all(parent) .wrap_err_with(|| format!("could not create lock directory {}", parent.display()))?; } OpenOptions::new() .read(true) .write(true) .create(true) .truncate(false) .open(path) .wrap_err_with(|| format!("could not open lock file {}", path.display())) } async fn wait_for_lock(path: &Path, timeout: Duration) -> Result { const LOCK_POLL: Duration = Duration::from_millis(20); let file = open_lock_file(path)?; let start = Instant::now(); loop { match file.try_lock_exclusive() { Ok(true) => return Ok(file), Ok(false) => { if start.elapsed() >= timeout { bail!("timed out waiting for lock at {}", path.display()); } sleep(LOCK_POLL).await; } Err(err) => { return Err(eyre!("could not lock {}: {err}", path.display())); } } } } async fn wait_for_pidfile_available(path: &Path, timeout: Duration) -> Result<()> { let file = wait_for_lock(path, timeout).await?; file.unlock() .wrap_err_with(|| format!("failed to unlock {}", path.display()))?; Ok(()) } async fn request_shutdown(settings: &Settings) { if let Ok(mut client) = connect_client(settings).await { drop(client.shutdown().await); } } fn startup_timeout(settings: &Settings) -> Duration { Duration::from_secs_f64(settings.local_timeout.max(0.5) + 2.0) } async fn status_cmd(settings: &Settings) -> Result<()> { match probe(settings).await { Probe::Ready(mut client) => { let status = client.status().await?; println!("Daemon running"); println!(" PID: {}", status.pid); println!(" Version: {}", status.version); println!(" Protocol: {}", status.protocol); println!(" Healthy: {}", status.healthy); println!(" Socket: {}", settings.daemon.socket_path); } Probe::NeedsRestart(reason) => { println!("Daemon running (needs restart)"); println!(" Reason: {reason}"); } Probe::Unreachable(_) => { println!("Daemon is not running"); } } Ok(()) } async fn stop_cmd(settings: &Settings) -> Result<()> { let Ok(mut client) = connect_client(settings).await else { println!("Daemon is not running"); return Ok(()); }; match client.shutdown().await { Ok(true) => { println!("Shutdown requested"); let pidfile_path = PathBuf::from(&settings.daemon.pidfile_path); let timeout = Duration::from_secs(5); match wait_for_pidfile_available(&pidfile_path, timeout).await { Ok(()) => println!("Daemon stopped"), Err(_) => println!("Daemon may still be shutting down"), } Ok(()) } Ok(false) => bail!("Daemon rejected shutdown request"), Err(err) => Err(err.wrap_err("Failed to send shutdown request")), } } async fn start_cmd( settings: Settings, store: SqliteStore, history_db: ClientSqlite, show_logs: bool, ) -> Result<()> { let pidfile_path = PathBuf::from(&settings.daemon.pidfile_path); let _pidfile_guard = PidfileGuard::acquire(&pidfile_path)?; turtle_daemon::boot(settings, store, history_db).await?; Ok(()) } #[cfg(test)] mod tests { use super::{ DAEMON_PROTOCOL_VERSION, DAEMON_VERSION, daemon_matches_expected, daemon_mismatch_message, }; #[test] fn test_version_matches() { assert!(daemon_matches_expected( DAEMON_VERSION, DAEMON_PROTOCOL_VERSION )); } #[test] fn test_version_mismatch() { assert!(!daemon_matches_expected("0.0.0", DAEMON_PROTOCOL_VERSION)); assert!(!daemon_matches_expected(DAEMON_VERSION, 999)); assert!(!daemon_matches_expected("0.0.0", 999)); } #[test] fn test_mismatch_message_version() { let msg = daemon_mismatch_message("0.0.0", DAEMON_PROTOCOL_VERSION); assert!(msg.contains("out of date"), "got: {msg}"); assert!(msg.contains("0.0.0")); assert!(msg.contains(DAEMON_VERSION)); } #[test] fn test_mismatch_message_protocol() { let msg = daemon_mismatch_message(DAEMON_VERSION, 999); assert!(msg.contains("protocol mismatch"), "got: {msg}"); } }