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
2 changes: 1 addition & 1 deletion .centy/.centy-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
4 changes: 2 additions & 2 deletions .centy/issues/1c5bb26f-adcd-4a61-92a5-b6dcf383814e.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
8 changes: 3 additions & 5 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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() {
Expand Down
255 changes: 255 additions & 0 deletions lib/src/daemon.rs
Original file line number Diff line number Diff line change
@@ -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<u32> {
// 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::<u32>() {
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<u32> {
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<u32> {
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<bool, String> {
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(&centy_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(&centy_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(&centy_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));
}
}
32 changes: 31 additions & 1 deletion lib/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
mod daemon;
mod download;
mod extract;
mod github;
Expand All @@ -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(
Expand All @@ -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<PathBuf, InstallerError> {
pub fn install(
version: Option<&str>,
prerelease: bool,
restart: bool,
) -> Result<PathBuf, InstallerError> {
let platform = platform::detect().map_err(InstallerError::Platform)?;

let client = reqwest::blocking::Client::new();
Expand All @@ -63,6 +72,18 @@ pub fn install(version: Option<&str>, prerelease: bool) -> Result<PathBuf, Insta
let path = install::install_binary(&binary_bytes)
.map_err(InstallerError::Installation)?;

if restart {
match daemon::restart_if_running(&path) {
Ok(true) => {
eprintln!("Restarted centy-daemon to use the updated version");
}
Ok(false) => {}
Err(e) => {
eprintln!("Warning: {e}");
}
}
}

Ok(path)
}

Expand Down Expand Up @@ -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());
Expand Down
6 changes: 4 additions & 2 deletions lib/src/main.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
use std::process::ExitCode;

fn main() -> ExitCode {
let prerelease = std::env::args().any(|a| a == "--pre");
let args: Vec<String> = 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
Expand Down
Loading