From 0019a310d6de573d51a3e52959af580e9b083c47 Mon Sep 17 00:00:00 2001 From: tupe12334 Date: Sun, 15 Feb 2026 20:06:08 +0200 Subject: [PATCH 1/2] fix: update timestamps and issue status for centy-daemon restart --- .centy/.centy-manifest.json | 2 +- .centy/issues/1c5bb26f-adcd-4a61-92a5-b6dcf383814e.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.centy/.centy-manifest.json b/.centy/.centy-manifest.json index fbd4ee6..e4ff9f5 100644 --- a/.centy/.centy-manifest.json +++ b/.centy/.centy-manifest.json @@ -2,5 +2,5 @@ "schemaVersion": 1, "centyVersion": "0.5.0", "createdAt": "2026-02-05T19:53:31.935069+00:00", - "updatedAt": "2026-02-15T18:01:55.090621+00:00" + "updatedAt": "2026-02-15T18:05:42.383342+00:00" } \ No newline at end of file diff --git a/.centy/issues/1c5bb26f-adcd-4a61-92a5-b6dcf383814e.md b/.centy/issues/1c5bb26f-adcd-4a61-92a5-b6dcf383814e.md index 19ea03e..af97b46 100644 --- a/.centy/issues/1c5bb26f-adcd-4a61-92a5-b6dcf383814e.md +++ b/.centy/issues/1c5bb26f-adcd-4a61-92a5-b6dcf383814e.md @@ -1,9 +1,9 @@ --- displayNumber: 5 -status: open +status: in-progress priority: 1 createdAt: 2026-02-15T18:01:55.088993+00:00 -updatedAt: 2026-02-15T18:01:55.088993+00:00 +updatedAt: 2026-02-15T18:05:42.383051+00:00 --- # Restart centy-daemon after installation to use the updated binary From 3b02c07ab0f6b6af3586f1dc440bdf061d150bb1 Mon Sep 17 00:00:00 2001 From: tupe12334 Date: Sun, 15 Feb 2026 20:17:42 +0200 Subject: [PATCH 2/2] feat: restart centy-daemon after install to use updated binary After installing/upgrading the binary, the installer now detects if centy-daemon is running (via PID file or process search), stops it gracefully with SIGTERM (falling back to SIGKILL), and restarts it. Adds --no-restart flag to skip this behavior. Closes #5 Co-Authored-By: Claude Opus 4.6 --- install.sh | 8 +- lib/src/daemon.rs | 255 +++++++++++++++++++++++++++++++++++++++ lib/src/lib.rs | 32 ++++- lib/src/main.rs | 6 +- lib/tests/e2e_install.rs | 8 +- 5 files changed, 297 insertions(+), 12 deletions(-) create mode 100644 lib/src/daemon.rs diff --git a/install.sh b/install.sh index ffdef0b..5d32818 100755 --- a/install.sh +++ b/install.sh @@ -10,10 +10,12 @@ main() { target="${ARCH}-${OS}" pre_flag="" + no_restart_flag="" version="" for arg in "$@"; do case "$arg" in --pre) pre_flag="--pre" ;; + --no-restart) no_restart_flag="--no-restart" ;; *) version="$arg" ;; esac done @@ -44,11 +46,7 @@ main() { fi chmod +x "$binary" - if [ -n "$pre_flag" ]; then - "$binary" "$pre_flag" - else - "$binary" - fi + "$binary" $pre_flag $no_restart_flag } detect_os() { diff --git a/lib/src/daemon.rs b/lib/src/daemon.rs new file mode 100644 index 0000000..b858fb6 --- /dev/null +++ b/lib/src/daemon.rs @@ -0,0 +1,255 @@ +use std::path::Path; +use std::process::Command; +use std::thread; +use std::time::Duration; + +/// Check if a process with the given PID is still running. +#[cfg(unix)] +fn is_process_running(pid: u32) -> bool { + Command::new("kill") + .args(["-0", &pid.to_string()]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .is_ok_and(|s| s.success()) +} + +#[cfg(windows)] +fn is_process_running(pid: u32) -> bool { + Command::new("tasklist") + .args(["/FI", &format!("PID eq {pid}"), "/NH"]) + .output() + .map_or(false, |o| { + String::from_utf8_lossy(&o.stdout).contains(&pid.to_string()) + }) +} + +/// Find the PID of a running `centy-daemon` process. +/// +/// Checks the PID file at `~/.centy/daemon.pid` first, then falls back to +/// searching for the process by name. +fn find_daemon_pid(home_dir: &Path) -> Option { + // Check PID file first + let pid_file = home_dir.join(".centy").join("daemon.pid"); + if let Ok(contents) = std::fs::read_to_string(&pid_file) { + if let Ok(pid) = contents.trim().parse::() { + if is_process_running(pid) { + return Some(pid); + } + } + } + + find_daemon_pid_by_name() +} + +/// Search for a running `centy-daemon` process by name. +#[cfg(unix)] +fn find_daemon_pid_by_name() -> Option { + let output = Command::new("pgrep") + .args(["-x", "centy-daemon"]) + .output() + .ok()?; + + if !output.status.success() { + return None; + } + + let stdout = String::from_utf8_lossy(&output.stdout); + stdout.trim().lines().next()?.trim().parse().ok() +} + +#[cfg(windows)] +fn find_daemon_pid_by_name() -> Option { + let output = Command::new("tasklist") + .args([ + "/FI", + "IMAGENAME eq centy-daemon.exe", + "/NH", + "/FO", + "CSV", + ]) + .output() + .ok()?; + + if !output.status.success() { + return None; + } + + let stdout = String::from_utf8_lossy(&output.stdout); + for line in stdout.lines() { + if !line.contains("centy-daemon") { + continue; + } + let parts: Vec<&str> = line.split(',').collect(); + if let Some(pid_field) = parts.get(1) { + if let Ok(pid) = pid_field.trim_matches('"').parse() { + return Some(pid); + } + } + } + + None +} + +/// Stop a daemon process gracefully, falling back to a forced kill. +fn stop_daemon(pid: u32) -> Result<(), String> { + send_term_signal(pid)?; + + // Wait up to 5 seconds for graceful shutdown + for _ in 0..10 { + thread::sleep(Duration::from_millis(500)); + if !is_process_running(pid) { + return Ok(()); + } + } + + // Force kill if still running + send_kill_signal(pid); + + thread::sleep(Duration::from_millis(500)); + + if is_process_running(pid) { + return Err(format!( + "failed to stop daemon (PID {pid}) after forced kill" + )); + } + + Ok(()) +} + +#[cfg(unix)] +fn send_term_signal(pid: u32) -> Result<(), String> { + let status = Command::new("kill") + .args(["-TERM", &pid.to_string()]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .map_err(|e| format!("failed to send SIGTERM to daemon (PID {pid}): {e}"))?; + + if !status.success() { + return Err(format!("failed to send SIGTERM to daemon (PID {pid})")); + } + + Ok(()) +} + +#[cfg(windows)] +fn send_term_signal(pid: u32) -> Result<(), String> { + let status = Command::new("taskkill") + .args(["/PID", &pid.to_string()]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .map_err(|e| format!("failed to terminate daemon (PID {pid}): {e}"))?; + + if !status.success() { + return Err(format!("failed to terminate daemon (PID {pid})")); + } + + Ok(()) +} + +#[cfg(unix)] +fn send_kill_signal(pid: u32) { + let _ = Command::new("kill") + .args(["-KILL", &pid.to_string()]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status(); +} + +#[cfg(windows)] +fn send_kill_signal(pid: u32) { + let _ = Command::new("taskkill") + .args(["/F", "/PID", &pid.to_string()]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status(); +} + +/// Start the daemon process in the background. +fn start_daemon(binary_path: &Path) -> Result<(), String> { + Command::new(binary_path) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .map_err(|e| format!("failed to start daemon: {e}"))?; + + Ok(()) +} + +/// Restart the daemon if it is currently running. +/// +/// Returns `true` if the daemon was found and restarted, `false` if it was not +/// running. Errors indicate that the daemon was found but could not be stopped +/// or restarted. +pub fn restart_if_running(binary_path: &Path) -> Result { + let home = dirs::home_dir().ok_or("could not determine home directory")?; + + let Some(pid) = find_daemon_pid(&home) else { + return Ok(false); + }; + + stop_daemon(pid)?; + start_daemon(binary_path)?; + + Ok(true) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod tests { + use super::*; + + #[test] + fn is_process_running_nonexistent() { + assert!(!is_process_running(4_294_967_295)); + } + + #[cfg(unix)] + #[test] + fn is_process_running_self() { + let pid = std::process::id(); + assert!(is_process_running(pid)); + } + + #[test] + fn find_daemon_pid_stale_pid_file_skips_dead_process() { + let tmp = tempfile::tempdir().unwrap(); + let centy_dir = tmp.path().join(".centy"); + std::fs::create_dir_all(¢y_dir).unwrap(); + // PID that almost certainly doesn't exist + std::fs::write(centy_dir.join("daemon.pid"), "4294967295").unwrap(); + + let result = find_daemon_pid(tmp.path()); + // The PID file should be ignored since the process is dead. + // The result depends on whether a centy-daemon is actually running + // (found via pgrep fallback), so we only verify no panic occurs. + let _ = result; + } + + #[test] + fn find_daemon_pid_invalid_pid_file_is_ignored() { + let tmp = tempfile::tempdir().unwrap(); + let centy_dir = tmp.path().join(".centy"); + std::fs::create_dir_all(¢y_dir).unwrap(); + std::fs::write(centy_dir.join("daemon.pid"), "not-a-number").unwrap(); + + // Invalid PID file should not cause errors + let _ = find_daemon_pid(tmp.path()); + } + + #[cfg(unix)] + #[test] + fn find_daemon_pid_valid_pid_file_for_running_process() { + let tmp = tempfile::tempdir().unwrap(); + let centy_dir = tmp.path().join(".centy"); + std::fs::create_dir_all(¢y_dir).unwrap(); + // Write our own PID - it's a running process + let pid = std::process::id(); + std::fs::write(centy_dir.join("daemon.pid"), pid.to_string()).unwrap(); + + let result = find_daemon_pid(tmp.path()); + assert_eq!(result, Some(pid)); + } +} diff --git a/lib/src/lib.rs b/lib/src/lib.rs index ed3a0bc..55701ec 100644 --- a/lib/src/lib.rs +++ b/lib/src/lib.rs @@ -1,3 +1,4 @@ +mod daemon; mod download; mod extract; mod github; @@ -22,6 +23,9 @@ pub enum InstallerError { #[error("installation failed: {0}")] Installation(String), + + #[error("daemon restart failed: {0}")] + DaemonRestart(String), } pub(crate) fn extract_binary( @@ -40,8 +44,13 @@ pub(crate) fn extract_binary( /// /// If `version` is `None`, the latest stable release is used by default. /// Set `prerelease` to `true` to allow installing pre-release versions. +/// When `restart` is `true`, the daemon is restarted if it was already running. /// Returns the path to the installed binary (`~/.centy/bin/centy-daemon`). -pub fn install(version: Option<&str>, prerelease: bool) -> Result { +pub fn install( + version: Option<&str>, + prerelease: bool, + restart: bool, +) -> Result { let platform = platform::detect().map_err(InstallerError::Platform)?; let client = reqwest::blocking::Client::new(); @@ -63,6 +72,18 @@ pub fn install(version: Option<&str>, prerelease: bool) -> Result { + eprintln!("Restarted centy-daemon to use the updated version"); + } + Ok(false) => {} + Err(e) => { + eprintln!("Warning: {e}"); + } + } + } + Ok(path) } @@ -107,6 +128,15 @@ mod tests { ); } + #[test] + fn error_display_daemon_restart() { + let err = InstallerError::DaemonRestart("process not found".to_string()); + assert_eq!( + err.to_string(), + "daemon restart failed: process not found" + ); + } + #[test] fn error_is_debug() { let err = InstallerError::Platform("test".to_string()); diff --git a/lib/src/main.rs b/lib/src/main.rs index ff43438..63dd31c 100644 --- a/lib/src/main.rs +++ b/lib/src/main.rs @@ -1,9 +1,11 @@ use std::process::ExitCode; fn main() -> ExitCode { - let prerelease = std::env::args().any(|a| a == "--pre"); + let args: Vec = std::env::args().collect(); + let prerelease = args.iter().any(|a| a == "--pre"); + let restart = !args.iter().any(|a| a == "--no-restart"); - match centy_installer::install(None, prerelease) { + match centy_installer::install(None, prerelease, restart) { Ok(path) => { println!("{}", path.display()); ExitCode::SUCCESS diff --git a/lib/tests/e2e_install.rs b/lib/tests/e2e_install.rs index f67fa10..d35ab28 100644 --- a/lib/tests/e2e_install.rs +++ b/lib/tests/e2e_install.rs @@ -35,7 +35,7 @@ fn install_pinned_version() { cleanup(); let path = - centy_installer::install(Some("v0.1.6"), false).expect("install v0.1.6 should succeed"); + centy_installer::install(Some("v0.1.6"), false, false).expect("install v0.1.6 should succeed"); assert!(path.exists(), "binary should exist at {}", path.display()); @@ -58,7 +58,7 @@ fn install_pinned_version() { fn install_latest_version() { cleanup(); - let path = centy_installer::install(None, false).expect("install latest should succeed"); + let path = centy_installer::install(None, false, false).expect("install latest should succeed"); assert!(path.exists(), "binary should exist at {}", path.display()); } @@ -67,7 +67,7 @@ fn install_latest_version() { fn install_version_without_v_prefix() { cleanup(); - let path = centy_installer::install(Some("0.1.6"), false) + let path = centy_installer::install(Some("0.1.6"), false, false) .expect("install without v prefix should succeed"); assert!(path.exists(), "binary should exist at {}", path.display()); @@ -77,7 +77,7 @@ fn install_version_without_v_prefix() { fn install_nonexistent_version_fails() { cleanup(); - let result = centy_installer::install(Some("v99.99.99"), false); + let result = centy_installer::install(Some("v99.99.99"), false, false); assert!( result.is_err(),