Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 1 addition & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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]
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "daemon-cli"
version = "0.6.0"
version = "0.7.0"
edition = "2024"

[dependencies]
Expand Down
35 changes: 32 additions & 3 deletions examples/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = 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
Expand All @@ -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(())
Expand Down
34 changes: 32 additions & 2 deletions examples/concurrent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = 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
Expand All @@ -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(())
Expand Down
164 changes: 123 additions & 41 deletions src/client.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -195,12 +211,15 @@ impl DaemonClient {
daemon_name: &str,
root_path: &str,
daemon_executable: &PathBuf,
startup_reason: StartupReason,
) -> Result<SocketClient> {
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) => {
Expand Down Expand Up @@ -231,6 +250,7 @@ impl DaemonClient {
daemon_name: &str,
root_path: &str,
daemon_executable: &PathBuf,
startup_reason: StartupReason,
) -> Result<SocketClient> {
// Spawn daemon process (detached - it will manage its own lifecycle)
// The daemon will auto-detect its binary mtime for version checking
Expand All @@ -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())
Expand Down Expand Up @@ -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?;

Expand All @@ -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<Self> {
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
Expand Down
Loading