From 5dc58e43ff09f9707c1d5ab73f2f77867398b77e Mon Sep 17 00:00:00 2001 From: Henrik Date: Wed, 3 Dec 2025 09:34:03 +0100 Subject: [PATCH 1/3] Add detailed StartupReason enum for daemon lifecycle tracking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the flawed socket-existence-based detection with explicit startup reason communication from client to daemon via CLI arg. New StartupReason variants: - FirstStart: daemon starting for first time - BinaryUpdated: binary was rebuilt (mtime changed) - Recovered: previous daemon crashed/was killed - ForceRestarted: user called restart() Breaking change: DaemonServer::new() now requires startup_reason param. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- examples/cli.rs | 35 +++++++- examples/concurrent.rs | 34 +++++++- src/client.rs | 164 +++++++++++++++++++++++++++---------- src/lib.rs | 66 ++++++++++++++- src/server.rs | 22 ++++- tests/integration_tests.rs | 2 + tests/version_tests.rs | 8 ++ 7 files changed, 279 insertions(+), 52 deletions(-) diff --git a/examples/cli.rs b/examples/cli.rs index d63f67f..a8da66f 100644 --- a/examples/cli.rs +++ b/examples/cli.rs @@ -66,7 +66,36 @@ async fn run_stop_mode() -> Result<()> { } async fn run_daemon_mode() -> Result<()> { - let root_path = env::current_dir()?.to_string_lossy().to_string(); + // Parse daemon arguments: daemon --daemon-name X --root-path Y --startup-reason Z + let args: Vec = env::args().collect(); + let mut root_path = env::current_dir()?.to_string_lossy().to_string(); + let mut startup_reason = StartupReason::default(); + + // Simple argument parsing + let mut i = 2; // Skip program name and "daemon" + while i < args.len() { + match args[i].as_str() { + "--daemon-name" => { + // Skip daemon name - DaemonServer auto-detects it + i += 2; + } + "--root-path" => { + if i + 1 < args.len() { + root_path = args[i + 1].clone(); + } + i += 2; + } + "--startup-reason" => { + if i + 1 < args.len() { + startup_reason = args[i + 1].parse().unwrap_or_default(); + } + i += 2; + } + _ => { + i += 1; + } + } + } // Initialize tracing subscriber for daemon logs // Logs go to stderr with compact format @@ -79,11 +108,11 @@ async fn run_daemon_mode() -> Result<()> { .compact() .init(); - tracing::info!(root_path, "Starting daemon"); + tracing::info!(root_path, startup_reason = %startup_reason, "Starting daemon"); let handler = CommandProcessor::new(); // Automatically detects daemon name and binary mtime - let (server, _handle) = DaemonServer::new(&root_path, handler); + let (server, _handle) = DaemonServer::new(&root_path, handler, startup_reason); server.run().await?; Ok(()) diff --git a/examples/concurrent.rs b/examples/concurrent.rs index a1d8b94..eff5f31 100644 --- a/examples/concurrent.rs +++ b/examples/concurrent.rs @@ -251,7 +251,36 @@ fn print_usage() { } async fn run_daemon_mode() -> Result<()> { - let root_path = env::current_dir()?.to_string_lossy().to_string(); + // Parse daemon arguments: daemon --daemon-name X --root-path Y --startup-reason Z + let args: Vec = env::args().collect(); + let mut root_path = env::current_dir()?.to_string_lossy().to_string(); + let mut startup_reason = StartupReason::default(); + + // Simple argument parsing + let mut i = 2; // Skip program name and "daemon" + while i < args.len() { + match args[i].as_str() { + "--daemon-name" => { + // Skip daemon name - DaemonServer auto-detects it + i += 2; + } + "--root-path" => { + if i + 1 < args.len() { + root_path = args[i + 1].clone(); + } + i += 2; + } + "--startup-reason" => { + if i + 1 < args.len() { + startup_reason = args[i + 1].parse().unwrap_or_default(); + } + i += 2; + } + _ => { + i += 1; + } + } + } // Initialize tracing subscriber for daemon logs // Logs go to stderr with compact format @@ -266,12 +295,13 @@ async fn run_daemon_mode() -> Result<()> { tracing::info!( root_path, + startup_reason = %startup_reason, "Starting task queue daemon with concurrent request handling" ); let handler = TaskQueueHandler::new(); // Automatically detects daemon name and binary mtime - let (server, _handle) = DaemonServer::new(&root_path, handler); + let (server, _handle) = DaemonServer::new(&root_path, handler, startup_reason); server.run().await?; Ok(()) diff --git a/src/client.rs b/src/client.rs index 636b22d..4d11543 100644 --- a/src/client.rs +++ b/src/client.rs @@ -1,3 +1,4 @@ +use crate::StartupReason; use crate::error_context::{ErrorContextBuffer, get_or_init_global_error_context}; use crate::process::{TerminateResult, kill_process, process_exists, terminate_process}; use crate::terminal::TerminalInfo; @@ -92,34 +93,45 @@ impl DaemonClient { // Try to connect to existing daemon first let socket_path = socket_path(daemon_name, root_path); - let mut socket_client = if let Ok(existing_client) = - SocketClient::connect(daemon_name, root_path).await - { - // Daemon is already running and responsive - use it - tracing::debug!("Connected to existing daemon"); - existing_client - } else { - // Daemon not running or not responsive - spawn our own - tracing::debug!("No existing daemon found, spawning new daemon"); - - if daemon_socket_exists(daemon_name, root_path) { - // Clean up stale socket file - tracing::debug!("Cleaning up stale socket file"); - let _ = fs::remove_file(&socket_path); - } - - // Kill any zombie processes (best effort) - Self::cleanup_stale_processes(daemon_name, root_path).await; + let mut socket_client = + if let Ok(existing_client) = SocketClient::connect(daemon_name, root_path).await { + // Daemon is already running and responsive - use it + tracing::debug!("Connected to existing daemon"); + existing_client + } else { + // Daemon not running or not responsive - spawn our own + tracing::debug!("No existing daemon found, spawning new daemon"); + + // Determine startup reason based on whether stale socket exists + let startup_reason = if daemon_socket_exists(daemon_name, root_path) { + // Clean up stale socket file - daemon crashed or was killed + tracing::debug!("Cleaning up stale socket file"); + let _ = fs::remove_file(&socket_path); + StartupReason::Recovered + } else { + // No socket exists - fresh start + StartupReason::FirstStart + }; - // Spawn new daemon - match Self::spawn_and_wait_for_ready(daemon_name, root_path, &daemon_executable).await { - Ok(client) => client, - Err(e) => { - error_context.dump_to_stderr(); - return Err(e); + // Kill any zombie processes (best effort) + Self::cleanup_stale_processes(daemon_name, root_path).await; + + // Spawn new daemon + match Self::spawn_and_wait_for_ready( + daemon_name, + root_path, + &daemon_executable, + startup_reason, + ) + .await + { + Ok(client) => client, + Err(e) => { + error_context.dump_to_stderr(); + return Err(e); + } } - } - }; + }; // Perform version handshake socket_client @@ -146,17 +158,21 @@ impl DaemonClient { let _ = fs::remove_file(&socket_path); Self::cleanup_stale_processes(daemon_name, root_path).await; - // Spawn new daemon with correct version - socket_client = - match Self::spawn_and_wait_for_ready(daemon_name, root_path, &daemon_executable) - .await - { - Ok(client) => client, - Err(e) => { - error_context.dump_to_stderr(); - return Err(e); - } - }; + // Spawn new daemon with correct version - reason is BinaryUpdated + socket_client = match Self::spawn_and_wait_for_ready( + daemon_name, + root_path, + &daemon_executable, + StartupReason::BinaryUpdated, + ) + .await + { + Ok(client) => client, + Err(e) => { + error_context.dump_to_stderr(); + return Err(e); + } + }; // Retry handshake socket_client @@ -195,12 +211,15 @@ impl DaemonClient { daemon_name: &str, root_path: &str, daemon_executable: &PathBuf, + startup_reason: StartupReason, ) -> Result { - tracing::debug!(daemon_exe = ?daemon_executable, "Spawning daemon"); + tracing::debug!(daemon_exe = ?daemon_executable, startup_reason = %startup_reason, "Spawning daemon"); // Retry daemon spawning to handle race conditions with concurrent test cleanup for retry_attempt in 0..3 { - let result = Self::try_spawn_daemon(daemon_name, root_path, daemon_executable).await; + let result = + Self::try_spawn_daemon(daemon_name, root_path, daemon_executable, startup_reason) + .await; match result { Ok(client) => { @@ -231,6 +250,7 @@ impl DaemonClient { daemon_name: &str, root_path: &str, daemon_executable: &PathBuf, + startup_reason: StartupReason, ) -> Result { // Spawn daemon process (detached - it will manage its own lifecycle) // The daemon will auto-detect its binary mtime for version checking @@ -241,6 +261,8 @@ impl DaemonClient { .arg(daemon_name) .arg("--root-path") .arg(root_path) + .arg("--startup-reason") + .arg(startup_reason.as_str()) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()) @@ -420,12 +442,13 @@ impl DaemonClient { // Force stop existing daemon (ignore errors if already dead) let _ = self.force_stop().await; - // Reconnect to fresh daemon - let new_client = Self::connect_with_name_and_timestamp( + // Reconnect to fresh daemon with ForceRestarted reason + let new_client = Self::spawn_fresh_daemon( &self.daemon_name, &self.root_path, self.daemon_executable.clone(), self.build_timestamp, + StartupReason::ForceRestarted, ) .await?; @@ -437,6 +460,65 @@ impl DaemonClient { Ok(()) } + /// Spawn a fresh daemon with an explicit startup reason (used by restart). + async fn spawn_fresh_daemon( + daemon_name: &str, + root_path: &str, + daemon_executable: PathBuf, + build_timestamp: u64, + startup_reason: StartupReason, + ) -> Result { + let error_context = get_or_init_global_error_context(); + let socket_path = socket_path(daemon_name, root_path); + + // Clean up any stale files + let _ = fs::remove_file(&socket_path); + Self::cleanup_stale_processes(daemon_name, root_path).await; + + // Spawn new daemon with specified startup reason + let mut socket_client = match Self::spawn_and_wait_for_ready( + daemon_name, + root_path, + &daemon_executable, + startup_reason, + ) + .await + { + Ok(client) => client, + Err(e) => { + error_context.dump_to_stderr(); + return Err(e); + } + }; + + // Perform version handshake + socket_client + .send_message(&SocketMessage::VersionCheck { build_timestamp }) + .await?; + + match socket_client.receive_message().await? { + Some(SocketMessage::VersionCheck { + build_timestamp: daemon_ts, + }) if daemon_ts == build_timestamp => { + tracing::debug!("Version handshake successful"); + } + _ => { + error_context.dump_to_stderr(); + bail!("Version handshake failed"); + } + } + + Ok(Self { + socket_client, + daemon_name: daemon_name.to_string(), + root_path: root_path.to_string(), + daemon_executable, + build_timestamp, + error_context, + auto_restart_on_error: false, + }) + } + /// Enable or disable automatic daemon restart on fatal connection errors. /// /// When enabled, if `execute_command()` encounters a fatal connection error diff --git a/src/lib.rs b/src/lib.rs index c8d6a2a..4942bd7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -75,7 +75,8 @@ //! async fn main() -> Result<()> { //! let handler = MyHandler; //! // Automatically detects daemon name and binary mtime -//! let (server, _handle) = DaemonServer::new("/path/to/project", handler); +//! // startup_reason is passed from client via --startup-reason CLI arg +//! let (server, _handle) = DaemonServer::new("/path/to/project", handler, StartupReason::default()); //! server.run().await?; //! Ok(()) //! } @@ -99,7 +100,7 @@ use anyhow::Result; use async_trait::async_trait; -use std::{env, fs, time::UNIX_EPOCH}; +use std::{env, fs, str::FromStr, time::UNIX_EPOCH}; use tokio::io::AsyncWrite; use tokio_util::sync::CancellationToken; @@ -115,6 +116,56 @@ pub use error_context::ErrorContextBuffer; pub use server::{DaemonHandle, DaemonServer}; pub use terminal::{ColorSupport, TerminalInfo}; +/// Reason why daemon was started. +/// +/// This is passed to [`CommandHandler::on_startup`] to indicate +/// the circumstances under which the daemon started. The client +/// determines the reason and passes it via `--startup-reason` CLI arg. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum StartupReason { + /// Fresh start - daemon starting for first time in this location + #[default] + FirstStart, + /// Binary was updated (mtime changed), old daemon was replaced + BinaryUpdated, + /// Previous daemon crashed or was killed unexpectedly + Recovered, + /// User explicitly called `restart()` on the client + ForceRestarted, +} + +impl StartupReason { + /// Convert to CLI argument string value. + pub fn as_str(&self) -> &'static str { + match self { + Self::FirstStart => "first-start", + Self::BinaryUpdated => "binary-updated", + Self::Recovered => "recovered", + Self::ForceRestarted => "force-restarted", + } + } +} + +impl FromStr for StartupReason { + type Err = (); + + fn from_str(s: &str) -> std::result::Result { + match s { + "first-start" => Ok(Self::FirstStart), + "binary-updated" => Ok(Self::BinaryUpdated), + "recovered" => Ok(Self::Recovered), + "force-restarted" => Ok(Self::ForceRestarted), + _ => Err(()), + } + } +} + +impl std::fmt::Display for StartupReason { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.as_str()) + } +} + #[cfg(test)] mod tests; @@ -124,7 +175,7 @@ mod tests; pub mod prelude { pub use crate::{ ColorSupport, CommandHandler, DaemonClient, DaemonHandle, DaemonServer, ErrorContextBuffer, - TerminalInfo, + StartupReason, TerminalInfo, }; pub use anyhow::Result; pub use async_trait::async_trait; @@ -276,4 +327,13 @@ pub trait CommandHandler: Send + Sync { output: impl AsyncWrite + Send + Unpin, cancel_token: CancellationToken, ) -> Result; + + /// Called once when the daemon starts, before accepting connections. + /// + /// Override this method to log the startup reason or perform + /// initialization that depends on whether this is a fresh start + /// or a restart. + /// + /// The default implementation does nothing. + fn on_startup(&self, _reason: StartupReason) {} } diff --git a/src/server.rs b/src/server.rs index 6f25e98..29fb6f5 100644 --- a/src/server.rs +++ b/src/server.rs @@ -59,7 +59,7 @@ static CLIENT_COUNTER: AtomicU64 = AtomicU64::new(1); /// /// // Demonstrate server creation - automatically detects daemon name and binary mtime /// let daemon = MyDaemon; -/// let (server, _handle) = DaemonServer::new("/path/to/project", daemon); +/// let (server, _handle) = DaemonServer::new("/path/to/project", daemon, StartupReason::FirstStart); /// // Use handle.shutdown() to stop the server, or drop it to run indefinitely /// ``` pub struct DaemonServer { @@ -69,6 +69,8 @@ pub struct DaemonServer { pub root_path: String, /// Binary modification time (mtime) for version compatibility checking pub build_timestamp: u64, + /// Reason why this daemon instance was started + pub startup_reason: StartupReason, handler: H, shutdown_rx: oneshot::Receiver<()>, connection_semaphore: Arc, @@ -106,16 +108,24 @@ where /// /// * `root_path` - Project root directory path used as unique identifier/scope for this daemon instance /// * `handler` - Your command handler implementation + /// * `startup_reason` - Why the daemon is starting (passed from client via `--startup-reason` CLI arg) /// /// # Returns /// /// A tuple of (DaemonServer, DaemonHandle). Call `shutdown()` on the handle /// to gracefully stop the server, or drop it to let the server run indefinitely. /// - pub fn new(root_path: &str, handler: H) -> (Self, DaemonHandle) { + pub fn new(root_path: &str, handler: H, startup_reason: StartupReason) -> (Self, DaemonHandle) { let daemon_name = crate::auto_detect_daemon_name(); let build_timestamp = crate::get_build_timestamp(); - Self::new_with_name_and_timestamp(&daemon_name, root_path, build_timestamp, handler, 100) + Self::new_with_name_and_timestamp( + &daemon_name, + root_path, + build_timestamp, + handler, + startup_reason, + 100, + ) } /// Create a new daemon server instance with explicit name, timestamp, and connection limit (primarily for testing). @@ -130,6 +140,7 @@ where /// * `root_path` - Project root directory path used as unique identifier/scope for this daemon instance /// * `build_timestamp` - Binary mtime (seconds since Unix epoch) for version checking /// * `handler` - Your command handler implementation + /// * `startup_reason` - Why the daemon is starting (passed from client via `--startup-reason` CLI arg) /// * `max_connections` - Maximum number of concurrent client connections /// /// # Returns @@ -142,6 +153,7 @@ where root_path: &str, build_timestamp: u64, handler: H, + startup_reason: StartupReason, max_connections: usize, ) -> (Self, DaemonHandle) { let (shutdown_tx, shutdown_rx) = oneshot::channel(); @@ -153,6 +165,7 @@ where daemon_name: daemon_name.to_string(), root_path: root_path.to_string(), build_timestamp, + startup_reason, handler, shutdown_rx, connection_semaphore, @@ -211,6 +224,9 @@ where "Daemon started and listening" ); + // Notify handler of startup reason + self.handler.on_startup(self.startup_reason); + loop { // Select between accepting connection and shutdown signal let accept_result = select! { diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index 84d3f0f..fb6f17a 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -30,6 +30,7 @@ async fn start_test_daemon( root_path, build_timestamp, handler, + StartupReason::FirstStart, 100, ); let join_handle = spawn(async move { @@ -55,6 +56,7 @@ async fn start_test_daemon_with_limit( root_path, build_timestamp, handler, + StartupReason::FirstStart, max_connections, ); let join_handle = spawn(async move { diff --git a/tests/version_tests.rs b/tests/version_tests.rs index 36d0244..81db60e 100644 --- a/tests/version_tests.rs +++ b/tests/version_tests.rs @@ -41,6 +41,7 @@ async fn test_version_handshake_success() -> Result<()> { root_path, build_timestamp, handler, + StartupReason::FirstStart, 100, ); let _server_handle = spawn(async move { @@ -86,6 +87,7 @@ async fn test_version_mismatch_detection() -> Result<()> { root_path, daemon_build_timestamp, handler, + StartupReason::FirstStart, 100, ); let _server_handle = spawn(async move { @@ -137,6 +139,7 @@ async fn test_multiple_version_handshakes() -> Result<()> { root_path, build_timestamp, handler, + StartupReason::FirstStart, 100, ); let _server_handle = spawn(async move { @@ -187,6 +190,7 @@ async fn test_version_handshake_before_command() -> Result<()> { root_path, build_timestamp, handler, + StartupReason::FirstStart, 100, ); let _server_handle = spawn(async move { @@ -245,6 +249,7 @@ async fn test_command_without_handshake_fails() -> Result<()> { root_path, build_timestamp, handler, + StartupReason::FirstStart, 100, ); let _server_handle = spawn(async move { @@ -293,6 +298,7 @@ async fn test_concurrent_version_handshakes() -> Result<()> { root_path, build_timestamp, handler, + StartupReason::FirstStart, 100, ); let _server_handle = spawn(async move { @@ -360,6 +366,7 @@ async fn test_version_mismatch_triggers_client_action() -> Result<()> { root_path, daemon_timestamp, handler, + StartupReason::FirstStart, 100, ); let _server_handle = spawn(async move { @@ -412,6 +419,7 @@ async fn test_multiple_commands_same_connection() -> Result<()> { root_path, build_timestamp, handler, + StartupReason::FirstStart, 100, ); let _server_handle = spawn(async move { From bfa64829a32fa10f47cd4e10fb98199304ba35ef Mon Sep 17 00:00:00 2001 From: Henrik Date: Wed, 3 Dec 2025 09:38:27 +0100 Subject: [PATCH 2/3] Run fmt/clippy only on Ubuntu, parallelize all CI jobs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .github/workflows/ci.yml | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 270900f..eadbfe8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,10 +21,7 @@ jobs: - run: cargo fmt --check clippy: - strategy: - matrix: - os: [ubuntu-latest, macos-latest, windows-latest] - runs-on: ${{ matrix.os }} + runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable @@ -34,7 +31,6 @@ jobs: - run: cargo clippy --all-targets --all-features -- -D warnings test: - needs: [fmt, clippy] strategy: matrix: os: [ubuntu-latest, macos-latest, windows-latest] From 57431a132b7e2899da9bbc19e0a680ed1692e711 Mon Sep 17 00:00:00 2001 From: Henrik Date: Wed, 3 Dec 2025 09:39:02 +0100 Subject: [PATCH 3/3] Bump version to 0.7.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- CHANGELOG.md | 8 ++++++++ Cargo.toml | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index efda7ce..1aa6589 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.7.0] - 2025-12-03 + +### Changed (BREAKING) + +- `DaemonServer::new()` now requires `startup_reason: StartupReason` parameter +- `StartupReason` enum redesigned with four variants: `FirstStart`, `BinaryUpdated`, `Recovered`, `ForceRestarted` +- Client passes `--startup-reason` CLI arg when spawning daemon + ## [0.6.0] - 2025-11-28 ### Added diff --git a/Cargo.toml b/Cargo.toml index abde2fc..d9afeee 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "daemon-cli" -version = "0.6.0" +version = "0.7.0" edition = "2024" [dependencies]