use std::path::Path;
use std::time::{Duration, Instant};
use thiserror::Error;
use super::state::{State, StateError};
pub const STOP_TIMEOUT: Duration = Duration::from_secs(30);
const POLL_INTERVAL: Duration = Duration::from_millis(100);
#[derive(Debug, Error)]
pub enum LifecycleError {
#[error(transparent)]
State(#[from] StateError),
#[error("no daemon state file for {path}")]
NotInstalled { path: std::path::PathBuf },
#[error("failed to signal daemon pid {pid}: {source}")]
Signal {
pid: u32,
#[source]
source: std::io::Error,
},
#[error("daemon pid {pid} did not exit within {timeout_ms} ms")]
StopTimedOut { pid: u32, timeout_ms: u128 },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StatusState {
Running,
Stale,
NotInstalled,
}
impl StatusState {
pub fn as_str(self) -> &'static str {
match self {
StatusState::Running => "running",
StatusState::Stale => "stale",
StatusState::NotInstalled => "not installed",
}
}
}
#[derive(Debug)]
pub struct StatusReport {
pub status: StatusState,
pub state_path: std::path::PathBuf,
pub state: Option<State>,
}
#[derive(Debug)]
pub enum RestartOutcome {
Stopped,
NotRunning,
}
pub fn status(state_path: &Path) -> Result<StatusReport, LifecycleError> {
match State::try_read(state_path)? {
Some(state) => {
let alive = pid_alive(state.pid);
let status = if alive {
StatusState::Running
} else {
StatusState::Stale
};
Ok(StatusReport {
status,
state_path: state_path.to_path_buf(),
state: Some(state),
})
},
None => Ok(StatusReport {
status: StatusState::NotInstalled,
state_path: state_path.to_path_buf(),
state: None,
}),
}
}
pub fn stop(state_path: &Path, deadline: Duration) -> Result<(), LifecycleError> {
let state = match State::try_read(state_path)? {
Some(s) => s,
None => {
return Err(LifecycleError::NotInstalled {
path: state_path.to_path_buf(),
});
},
};
stop_with_state(&state, state_path, deadline)
}
fn stop_with_state(state: &State, state_path: &Path, deadline: Duration) -> Result<(), LifecycleError> {
if !pid_alive(state.pid) {
State::remove(state_path)?;
return Ok(());
}
send_sigterm(state.pid)?;
let started = Instant::now();
while started.elapsed() < deadline {
if !pid_alive(state.pid) {
State::remove(state_path)?;
return Ok(());
}
std::thread::sleep(POLL_INTERVAL);
}
Err(LifecycleError::StopTimedOut {
pid: state.pid,
timeout_ms: deadline.as_millis(),
})
}
pub fn restart_stop_phase(state_path: &Path, deadline: Duration) -> Result<RestartOutcome, LifecycleError> {
let state = match State::try_read(state_path)? {
Some(s) => s,
None => return Ok(RestartOutcome::NotRunning),
};
if !pid_alive(state.pid) {
State::remove(state_path)?;
return Ok(RestartOutcome::NotRunning);
}
stop_with_state(&state, state_path, deadline)?;
Ok(RestartOutcome::Stopped)
}
pub fn uninstall(state_path: &Path, deadline: Duration) -> Result<(), LifecycleError> {
if let Some(state) = State::try_read(state_path)? {
if pid_alive(state.pid) {
stop_with_state(&state, state_path, deadline)?;
} else {
State::remove(state_path)?;
}
}
Ok(())
}
fn send_sigterm(pid: u32) -> Result<(), LifecycleError> {
super::signals::send_sigterm(pid).map_err(|source| LifecycleError::Signal { pid, source })
}
pub fn pid_alive(pid: u32) -> bool {
super::signals::pid_alive(pid)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn sample_state(pid: u32) -> State {
State {
workflow_path: "/tmp/workflow.yml".into(),
cwd: "/tmp".into(),
pid,
port: 3000,
bind_address: "127.0.0.1".into(),
started_at: "2026-05-09T10:00:00Z".parse().unwrap(),
log_dir: "/tmp/.vik/logs".into(),
sessions_dir: "/tmp/.vik/sessions".into(),
command: "vik run -d".into(),
}
}
#[test]
fn status_reports_not_installed_when_file_missing() {
let dir = TempDir::new().expect("tmpdir");
let path = dir.path().join("state.json");
let report = status(&path).expect("status ok");
assert_eq!(report.status, StatusState::NotInstalled);
assert!(report.state.is_none());
}
#[test]
fn status_reports_stale_when_pid_dead() {
let dir = TempDir::new().expect("tmpdir");
let path = dir.path().join("state.json");
let dead = 2_147_483_646u32;
sample_state(dead).write(&path).expect("write");
let report = status(&path).expect("status ok");
assert_eq!(report.status, StatusState::Stale);
assert_eq!(report.state.unwrap().pid, dead);
}
#[cfg(unix)]
#[test]
fn status_reports_running_for_own_pid() {
let dir = TempDir::new().expect("tmpdir");
let path = dir.path().join("state.json");
let me = std::process::id();
sample_state(me).write(&path).expect("write");
let report = status(&path).expect("status ok");
assert_eq!(report.status, StatusState::Running);
}
#[test]
fn stop_returns_not_installed_on_missing_file() {
let dir = TempDir::new().expect("tmpdir");
let path = dir.path().join("state.json");
let err = stop(&path, Duration::from_millis(50)).expect_err("must fail");
assert!(matches!(err, LifecycleError::NotInstalled { .. }));
}
#[test]
fn stop_removes_stale_file_without_signal() {
let dir = TempDir::new().expect("tmpdir");
let path = dir.path().join("state.json");
let dead = 2_147_483_646u32;
sample_state(dead).write(&path).expect("write");
stop(&path, Duration::from_millis(50)).expect("stale cleans up");
assert!(!path.exists());
}
#[test]
fn uninstall_is_noop_when_missing() {
let dir = TempDir::new().expect("tmpdir");
let path = dir.path().join("state.json");
uninstall(&path, Duration::from_millis(50)).expect("noop ok");
}
#[test]
fn uninstall_removes_stale_file() {
let dir = TempDir::new().expect("tmpdir");
let path = dir.path().join("state.json");
let dead = 2_147_483_646u32;
sample_state(dead).write(&path).expect("write");
uninstall(&path, Duration::from_millis(50)).expect("ok");
assert!(!path.exists());
}
#[test]
fn restart_stop_phase_reports_not_running_when_missing() {
let dir = TempDir::new().expect("tmpdir");
let path = dir.path().join("state.json");
let outcome = restart_stop_phase(&path, Duration::from_millis(50)).expect("ok");
assert!(matches!(outcome, RestartOutcome::NotRunning));
}
#[test]
fn restart_stop_phase_reports_not_running_when_stale() {
let dir = TempDir::new().expect("tmpdir");
let path = dir.path().join("state.json");
let dead = 2_147_483_646u32;
sample_state(dead).write(&path).expect("write");
let outcome = restart_stop_phase(&path, Duration::from_millis(50)).expect("ok");
assert!(matches!(outcome, RestartOutcome::NotRunning));
assert!(!path.exists(), "stale file removed by restart stop phase");
}
}