use std::path::Path;
use anyhow::Result;
use serde::{Deserialize, Serialize};
use sysinfo::{ProcessRefreshKind, RefreshKind};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DaemonAction {
Spawned { port: u16 },
Joined { port: u16 },
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct DaemonState {
pub daemon_pid: u32,
pub port: u16,
pub clients: Vec<u32>,
pub auth_token: String,
}
fn state_path(data_dir: &Path) -> std::path::PathBuf {
data_dir.join("web.state")
}
pub fn read_state(data_dir: &Path) -> Result<Option<DaemonState>> {
let path = state_path(data_dir);
match std::fs::read_to_string(&path) {
Ok(contents) => match serde_json::from_str(&contents) {
Ok(state) => Ok(Some(state)),
Err(e) => {
tracing::warn!("corrupt daemon state file {}: {e}", path.display());
Ok(None)
}
},
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e.into()),
}
}
fn write_state(data_dir: &Path, state: &DaemonState) -> Result<()> {
use anyhow::Context;
let path = state_path(data_dir);
let tmp_path = data_dir.join(format!(".web.state.{}.tmp", std::process::id()));
let json = serde_json::to_string_pretty(state).context("failed to serialize daemon state")?;
std::fs::write(&tmp_path, json.as_bytes())
.with_context(|| format!("failed to write temp state: {}", tmp_path.display()))?;
let result = std::fs::rename(&tmp_path, &path);
if result.is_err() {
let _ = std::fs::remove_file(&tmp_path);
}
result.with_context(|| {
format!(
"failed to rename {} to {}",
tmp_path.display(),
path.display()
)
})?;
Ok(())
}
fn remove_state(data_dir: &Path) -> Result<()> {
let path = state_path(data_dir);
match std::fs::remove_file(&path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e.into()),
}
}
fn lock_path(data_dir: &Path) -> std::path::PathBuf {
data_dir.join(".web.state.lock")
}
pub fn update_state<F>(data_dir: &Path, f: F) -> Result<Option<DaemonState>>
where
F: FnOnce(Option<DaemonState>) -> Option<DaemonState>,
{
use anyhow::Context;
use fs2::FileExt;
let lock_file_path = lock_path(data_dir);
let lock_file = std::fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(false)
.open(&lock_file_path)
.with_context(|| format!("failed to open lock file: {}", lock_file_path.display()))?;
lock_file
.lock_exclusive()
.context("failed to acquire exclusive lock")?;
let current = read_state(data_dir)?;
let new_state = f(current);
match &new_state {
Some(state) => write_state(data_dir, state)?,
None => remove_state(data_dir)?,
}
Ok(new_state)
}
pub fn is_pid_alive(pid: u32) -> bool {
let s = sysinfo::System::new_with_specifics(
RefreshKind::nothing().with_processes(ProcessRefreshKind::nothing()),
);
s.process(sysinfo::Pid::from_u32(pid)).is_some()
}
pub fn register_client(data_dir: &Path, client_pid: u32) -> Result<()> {
let result = update_state(data_dir, |state| {
let mut state = state?;
if !state.clients.contains(&client_pid) {
state.clients.push(client_pid);
}
Some(state)
})?;
if result.is_none() {
anyhow::bail!("cannot register client: no daemon state file exists");
}
Ok(())
}
pub fn deregister_client(data_dir: &Path, client_pid: u32) -> Result<()> {
let new_state = update_state(data_dir, |state| {
let mut state = state?;
state.clients.retain(|&pid| pid != client_pid);
Some(state)
})?;
if let Some(state) = new_state
&& state.clients.is_empty()
{
signal_daemon_shutdown(state.daemon_pid);
}
Ok(())
}
fn signal_daemon_shutdown(daemon_pid: u32) {
#[cfg(unix)]
{
unsafe {
libc::kill(daemon_pid as i32, libc::SIGTERM);
}
}
#[cfg(windows)]
{
let _ = daemon_pid;
}
}
pub fn ensure_daemon(data_dir: &Path, port: u16, bind: &str) -> Result<DaemonAction> {
cleanup_stale_state(data_dir)?;
let (action, need_spawn) = {
let state = update_state(data_dir, |state| {
if let Some(state) = state {
Some(state)
} else {
Some(DaemonState {
daemon_pid: 0, port,
clients: vec![],
auth_token: String::new(), })
}
})?;
let state = state.expect("update_state always returns Some here");
if state.daemon_pid == 0 {
(DaemonAction::Spawned { port }, true)
} else {
(DaemonAction::Joined { port: state.port }, false)
}
};
if need_spawn {
spawn_daemon(data_dir, port, bind)?;
let mut alive = false;
for _ in 0..10 {
std::thread::sleep(std::time::Duration::from_millis(500));
if let Some(state) = read_state(data_dir)?
&& state.daemon_pid != 0
{
let sys = sysinfo::System::new_with_specifics(
sysinfo::RefreshKind::nothing()
.with_processes(sysinfo::ProcessRefreshKind::nothing()),
);
if sys
.process(sysinfo::Pid::from_u32(state.daemon_pid))
.is_some()
{
alive = true;
break;
}
}
}
if !alive {
let _ = std::fs::remove_file(data_dir.join("web.state"));
let log_path = data_dir.join("daemon.log");
let hint = if log_path.exists() {
let log = std::fs::read_to_string(&log_path).unwrap_or_default();
log.lines()
.rev()
.find(|l| !l.trim().is_empty())
.map(|l| format!("\n Last log line: {l}"))
.unwrap_or_default()
} else {
String::new()
};
anyhow::bail!("daemon failed to start. Check {}{hint}", log_path.display());
}
}
register_client(data_dir, std::process::id())?;
Ok(action)
}
fn spawn_daemon(data_dir: &Path, port: u16, bind: &str) -> Result<u32> {
use anyhow::Context;
let exe = std::env::current_exe().context("failed to get current executable path")?;
let log_file = std::fs::File::create(data_dir.join("daemon.log"))
.context("failed to create daemon.log")?;
let mut cmd = std::process::Command::new(exe);
cmd.arg("--data-dir")
.arg(data_dir.as_os_str())
.arg("_daemon")
.arg("--port")
.arg(port.to_string())
.arg("--bind")
.arg(bind)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::from(log_file));
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
cmd.process_group(0);
}
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
cmd.creation_flags(0x00000008); }
let child = cmd.spawn().context("failed to spawn daemon process")?;
Ok(child.id())
}
pub fn should_shutdown(
state: &DaemonState,
grace_start: &mut Option<std::time::Instant>,
grace_period: std::time::Duration,
) -> bool {
if state.clients.is_empty() {
let start = grace_start.get_or_insert_with(std::time::Instant::now);
start.elapsed() >= grace_period
} else {
*grace_start = None;
false
}
}
pub fn cleanup_stale_state(data_dir: &Path) -> Result<Option<DaemonState>> {
let sys = sysinfo::System::new_with_specifics(
RefreshKind::nothing().with_processes(ProcessRefreshKind::nothing()),
);
update_state(data_dir, |state| {
let mut state = state?;
sys.process(sysinfo::Pid::from_u32(state.daemon_pid))?;
state
.clients
.retain(|&pid| sys.process(sysinfo::Pid::from_u32(pid)).is_some());
Some(state)
})
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_state() -> DaemonState {
DaemonState {
daemon_pid: 1234,
port: 9090,
clients: vec![5678, 9012],
auth_token: "test-token-abc123".to_string(),
}
}
#[test]
fn daemon_read_returns_none_when_no_state_file() {
let dir = tempfile::tempdir().unwrap();
let result = read_state(dir.path()).unwrap();
assert_eq!(result, None);
}
#[test]
fn daemon_write_and_read_round_trip() {
let dir = tempfile::tempdir().unwrap();
let state = sample_state();
write_state(dir.path(), &state).unwrap();
let read_back = read_state(dir.path()).unwrap();
assert_eq!(read_back, Some(state));
}
#[test]
fn daemon_write_is_atomic_no_temp_file_remains() {
let dir = tempfile::tempdir().unwrap();
let state = sample_state();
write_state(dir.path(), &state).unwrap();
let entries: Vec<_> = std::fs::read_dir(dir.path())
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().to_string())
.collect();
assert_eq!(entries, vec!["web.state"]);
}
#[test]
fn daemon_read_returns_none_for_corrupt_state_file() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("web.state"), b"not json at all").unwrap();
let result = read_state(dir.path()).unwrap();
assert_eq!(result, None);
}
#[test]
fn daemon_remove_deletes_state_file() {
let dir = tempfile::tempdir().unwrap();
let state = sample_state();
write_state(dir.path(), &state).unwrap();
assert!(read_state(dir.path()).unwrap().is_some());
remove_state(dir.path()).unwrap();
assert_eq!(read_state(dir.path()).unwrap(), None);
}
#[test]
fn daemon_remove_is_idempotent() {
let dir = tempfile::tempdir().unwrap();
remove_state(dir.path()).unwrap();
remove_state(dir.path()).unwrap();
}
#[test]
fn daemon_update_creates_state_from_none() {
let dir = tempfile::tempdir().unwrap();
let result = update_state(dir.path(), |prev| {
assert_eq!(prev, None);
Some(sample_state())
})
.unwrap();
assert_eq!(result, Some(sample_state()));
assert_eq!(read_state(dir.path()).unwrap(), Some(sample_state()));
}
#[test]
fn daemon_update_modifies_existing_state() {
let dir = tempfile::tempdir().unwrap();
write_state(dir.path(), &sample_state()).unwrap();
let result = update_state(dir.path(), |prev| {
let mut s = prev.expect("should have existing state");
s.clients.push(3333);
Some(s)
})
.unwrap();
let expected = DaemonState {
daemon_pid: 1234,
port: 9090,
clients: vec![5678, 9012, 3333],
auth_token: "test-token-abc123".to_string(),
};
assert_eq!(result, Some(expected.clone()));
assert_eq!(read_state(dir.path()).unwrap(), Some(expected));
}
#[test]
fn daemon_update_can_delete_state() {
let dir = tempfile::tempdir().unwrap();
write_state(dir.path(), &sample_state()).unwrap();
let result = update_state(dir.path(), |prev| {
assert!(prev.is_some());
None })
.unwrap();
assert_eq!(result, None);
assert_eq!(read_state(dir.path()).unwrap(), None);
}
#[test]
fn is_pid_alive_returns_true_for_current_process() {
assert!(is_pid_alive(std::process::id()));
}
#[test]
fn is_pid_alive_returns_false_for_nonexistent_pid() {
assert!(!is_pid_alive(u32::MAX - 1));
}
#[test]
fn cleanup_stale_state_returns_none_when_no_state_file() {
let dir = tempfile::tempdir().unwrap();
let result = cleanup_stale_state(dir.path()).unwrap();
assert_eq!(result, None);
}
#[test]
fn cleanup_stale_state_removes_state_when_daemon_pid_is_dead() {
let dir = tempfile::tempdir().unwrap();
let state = DaemonState {
daemon_pid: u32::MAX - 1, port: 9090,
clients: vec![100, 200],
auth_token: "tok".to_string(),
};
write_state(dir.path(), &state).unwrap();
let result = cleanup_stale_state(dir.path()).unwrap();
assert_eq!(result, None);
assert_eq!(read_state(dir.path()).unwrap(), None);
}
#[test]
fn cleanup_stale_state_sweeps_dead_client_pids() {
let dir = tempfile::tempdir().unwrap();
let our_pid = std::process::id();
let dead_pid = u32::MAX - 1;
let state = DaemonState {
daemon_pid: our_pid, port: 9090,
clients: vec![our_pid, dead_pid],
auth_token: "tok".to_string(),
};
write_state(dir.path(), &state).unwrap();
let result = cleanup_stale_state(dir.path()).unwrap();
let expected = DaemonState {
daemon_pid: our_pid,
port: 9090,
clients: vec![our_pid], auth_token: "tok".to_string(),
};
assert_eq!(result, Some(expected));
}
#[test]
fn cleanup_stale_state_handles_corrupt_state_file() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("web.state"), b"not json at all").unwrap();
let result = cleanup_stale_state(dir.path()).unwrap();
assert_eq!(result, None);
assert_eq!(read_state(dir.path()).unwrap(), None);
}
#[test]
fn register_client_adds_pid_and_deregister_removes_it() {
let dir = tempfile::tempdir().unwrap();
let fake_daemon_pid = u32::MAX - 1;
write_state(
dir.path(),
&DaemonState {
daemon_pid: fake_daemon_pid,
port: 9090,
clients: vec![],
auth_token: "tok".to_string(),
},
)
.unwrap();
register_client(dir.path(), 1111).unwrap();
let state = read_state(dir.path()).unwrap().expect("state should exist");
assert_eq!(state.clients, vec![1111]);
deregister_client(dir.path(), 1111).unwrap();
let state = read_state(dir.path()).unwrap().expect("state should exist");
assert!(state.clients.is_empty());
}
#[test]
fn deregister_client_removes_pid_and_detects_empty() {
let dir = tempfile::tempdir().unwrap();
let fake_daemon_pid = u32::MAX - 1;
write_state(
dir.path(),
&DaemonState {
daemon_pid: fake_daemon_pid,
port: 9090,
clients: vec![1111, 2222],
auth_token: "tok".to_string(),
},
)
.unwrap();
deregister_client(dir.path(), 1111).unwrap();
let state = read_state(dir.path()).unwrap().unwrap();
assert_eq!(state.clients, vec![2222]);
deregister_client(dir.path(), 2222).unwrap();
let state = read_state(dir.path()).unwrap().unwrap();
assert!(state.clients.is_empty());
}
#[test]
fn deregister_client_is_idempotent() {
let dir = tempfile::tempdir().unwrap();
let fake_daemon_pid = u32::MAX - 1;
write_state(
dir.path(),
&DaemonState {
daemon_pid: fake_daemon_pid,
port: 9090,
clients: vec![1111],
auth_token: "tok".to_string(),
},
)
.unwrap();
deregister_client(dir.path(), 9999).unwrap();
let state = read_state(dir.path()).unwrap().unwrap();
assert_eq!(state.clients, vec![1111]);
deregister_client(dir.path(), 1111).unwrap();
let state = read_state(dir.path()).unwrap().unwrap();
assert!(state.clients.is_empty());
deregister_client(dir.path(), 1111).unwrap();
let state = read_state(dir.path()).unwrap().unwrap();
assert!(state.clients.is_empty());
}
#[test]
fn register_client_is_idempotent() {
let dir = tempfile::tempdir().unwrap();
let our_pid = std::process::id();
write_state(
dir.path(),
&DaemonState {
daemon_pid: our_pid,
port: 9090,
clients: vec![],
auth_token: "tok".to_string(),
},
)
.unwrap();
register_client(dir.path(), 1111).unwrap();
register_client(dir.path(), 1111).unwrap();
let state = read_state(dir.path()).unwrap().expect("state should exist");
assert_eq!(state.clients, vec![1111], "PID should appear only once");
}
#[test]
fn ensure_daemon_joins_existing_alive_daemon() {
let dir = tempfile::tempdir().unwrap();
let our_pid = std::process::id();
write_state(
dir.path(),
&DaemonState {
daemon_pid: our_pid,
port: 9090,
clients: vec![],
auth_token: "tok".to_string(),
},
)
.unwrap();
let action = ensure_daemon(dir.path(), 9090, "127.0.0.1").unwrap();
assert_eq!(action, DaemonAction::Joined { port: 9090 });
let state = read_state(dir.path()).unwrap().expect("state should exist");
assert!(
state.clients.contains(&our_pid),
"current process should be registered as a client"
);
}
#[test]
fn cleanup_stale_state_removes_dead_daemon_state() {
let dir = tempfile::tempdir().unwrap();
write_state(
dir.path(),
&DaemonState {
daemon_pid: u32::MAX - 1, port: 9090,
clients: vec![100, 200],
auth_token: "tok".to_string(),
},
)
.unwrap();
let result = cleanup_stale_state(dir.path()).unwrap();
assert_eq!(
result, None,
"stale state with dead daemon should be removed"
);
assert_eq!(read_state(dir.path()).unwrap(), None);
}
#[test]
fn should_shutdown_returns_false_when_clients_present() {
let state = DaemonState {
daemon_pid: 1,
port: 9090,
clients: vec![100],
auth_token: "tok".to_string(),
};
let mut grace_start = None;
let result = should_shutdown(&state, &mut grace_start, std::time::Duration::from_secs(60));
assert!(!result);
assert!(grace_start.is_none());
}
#[test]
fn should_shutdown_starts_grace_period_when_clients_empty() {
let state = DaemonState {
daemon_pid: 1,
port: 9090,
clients: vec![],
auth_token: "tok".to_string(),
};
let mut grace_start = None;
let result = should_shutdown(&state, &mut grace_start, std::time::Duration::from_secs(60));
assert!(
!result,
"should not shut down immediately -- grace period just started"
);
assert!(grace_start.is_some(), "grace period should have started");
}
#[test]
fn should_shutdown_returns_true_after_grace_period_expires() {
let state = DaemonState {
daemon_pid: 1,
port: 9090,
clients: vec![],
auth_token: "tok".to_string(),
};
let mut grace_start = Some(std::time::Instant::now() - std::time::Duration::from_secs(120));
let result = should_shutdown(&state, &mut grace_start, std::time::Duration::from_secs(60));
assert!(result, "should shut down after grace period expires");
}
#[test]
fn should_shutdown_resets_grace_when_client_rejoins() {
let empty_state = DaemonState {
daemon_pid: 1,
port: 9090,
clients: vec![],
auth_token: "tok".to_string(),
};
let mut grace_start = None;
should_shutdown(
&empty_state,
&mut grace_start,
std::time::Duration::from_secs(60),
);
assert!(grace_start.is_some());
let active_state = DaemonState {
daemon_pid: 1,
port: 9090,
clients: vec![100],
auth_token: "tok".to_string(),
};
should_shutdown(
&active_state,
&mut grace_start,
std::time::Duration::from_secs(60),
);
assert!(
grace_start.is_none(),
"grace period should be reset when a client rejoins"
);
}
#[test]
fn daemon_update_serializes_concurrent_access() {
let dir = tempfile::tempdir().unwrap();
let data_dir = dir.path().to_path_buf();
let initial = DaemonState {
daemon_pid: 1,
port: 8080,
clients: vec![],
auth_token: "tok".to_string(),
};
write_state(&data_dir, &initial).unwrap();
let iterations = 50;
let barrier = std::sync::Arc::new(std::sync::Barrier::new(2));
let handles: Vec<_> = (0..2)
.map(|_| {
let dir = data_dir.clone();
let barrier = barrier.clone();
std::thread::spawn(move || {
barrier.wait();
for _ in 0..iterations {
update_state(&dir, |prev| {
let mut s = prev.unwrap_or(DaemonState {
daemon_pid: 1,
port: 8080,
clients: vec![],
auth_token: "tok".to_string(),
});
s.daemon_pid += 1;
Some(s)
})
.unwrap();
}
})
})
.collect();
for h in handles {
h.join().unwrap();
}
let final_state = read_state(&data_dir).unwrap().expect("state should exist");
assert_eq!(
final_state.daemon_pid,
1 + (2 * iterations),
"concurrent updates should be serialized by the lock"
);
}
}