use std::path::PathBuf;
use std::process::Command;
use std::time::Duration;
use chrono::Utc;
use marver::TaskState;
use marver::daemon::{self, Config, Startup};
use marver::store::{Store, Transition};
use tempfile::TempDir;
struct Daemon(u32);
impl Drop for Daemon {
fn drop(&mut self) {
let _ = Command::new("kill").arg(self.0.to_string()).status();
}
}
fn config(dir: &TempDir) -> Config {
let mut config = Config::new(dir.path(), dir.path());
config.marver_bin = PathBuf::from(env!("CARGO_BIN_EXE_marver"));
config
}
fn process_group(pid: u32) -> String {
let out = Command::new("ps")
.args(["-o", "pgid=", "-p", &pid.to_string()])
.output()
.expect("ps");
String::from_utf8_lossy(&out.stdout).trim().to_string()
}
#[test]
fn a_daemon_is_started_on_demand_and_then_found_rather_than_duplicated() {
let dir = TempDir::new().unwrap();
let config = config(&dir);
assert!(!daemon::is_running(&config), "nothing should be listening");
let Startup::Started { pid } = daemon::ensure_running(&config).expect("start") else {
panic!("the first call should have started one");
};
let _guard = Daemon(pid);
assert!(
daemon::is_running(&config),
"ensure_running must not return until the socket answers"
);
assert_eq!(
daemon::ensure_running(&config).expect("second call"),
Startup::AlreadyRunning,
"a second caller must find the first daemon, not start another"
);
}
#[test]
fn an_auto_started_daemon_is_in_its_own_process_group() {
let dir = TempDir::new().unwrap();
let config = config(&dir);
let Startup::Started { pid } = daemon::ensure_running(&config).expect("start") else {
panic!("expected a fresh daemon");
};
let _guard = Daemon(pid);
let ours = process_group(std::process::id());
let theirs = process_group(pid);
assert!(!theirs.is_empty(), "the daemon should still be running");
assert_ne!(
ours, theirs,
"the daemon shares our process group, so ctrl-c would kill it"
);
}
#[test]
fn a_second_daemon_refuses_instead_of_stealing_the_socket() {
let dir = TempDir::new().unwrap();
let config = config(&dir);
let Startup::Started { pid } = daemon::ensure_running(&config).expect("start") else {
panic!("expected a fresh daemon");
};
let _guard = Daemon(pid);
let out = Command::new(env!("CARGO_BIN_EXE_marver"))
.args(["daemon", "--data-dir"])
.arg(dir.path())
.arg("--scan-root")
.arg(dir.path())
.output()
.expect("run marver daemon");
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(!out.status.success(), "a second daemon must exit non-zero");
assert!(
stderr.contains("already running"),
"it should say a daemon is running, not leak an errno: {stderr:?}"
);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
!stdout.contains("cap"),
"a refused start should announce no configuration: {stdout:?}"
);
assert!(
daemon::is_running(&config),
"the original daemon must still hold its socket"
);
}
#[test]
fn status_reports_what_is_running_and_exits_accordingly() {
let dir = TempDir::new().unwrap();
let config = config(&dir);
let status = |dir: &TempDir| {
Command::new(env!("CARGO_BIN_EXE_marver"))
.args(["status", "--data-dir"])
.arg(dir.path())
.output()
.expect("run marver status")
};
let before = status(&dir);
let text = String::from_utf8_lossy(&before.stdout).to_string();
assert!(text.contains("not running"), "{text:?}");
assert!(
!before.status.success(),
"a shell should be able to ask, so this exits non-zero"
);
assert!(
!config.db.exists(),
"reporting on the system must not create part of it"
);
let Startup::Started { pid } = daemon::ensure_running(&config).expect("start") else {
panic!("expected a fresh daemon");
};
let _guard = Daemon(pid);
let after = status(&dir);
let text = String::from_utf8_lossy(&after.stdout).to_string();
assert!(after.status.success(), "{text:?}");
assert!(
text.contains("running") && !text.contains("not running"),
"{text:?}"
);
}
#[test]
fn an_upgrade_is_noticed_rather_than_silently_ignored() {
let dir = TempDir::new().unwrap();
let config = config(&dir);
let Startup::Started { pid } = daemon::ensure_running(&config).expect("start") else {
panic!("expected a fresh daemon");
};
let _guard = Daemon(pid);
assert_eq!(
daemon::running_version(&config).as_deref(),
Some(daemon::VERSION),
"the daemon records what it is"
);
assert_eq!(
daemon::ensure_running(&config).expect("second call"),
Startup::AlreadyRunning,
"matching versions are unremarkable"
);
std::fs::write(&config.version_file, "0.0.1\n").unwrap();
assert_eq!(
daemon::ensure_running(&config).expect("third call"),
Startup::Outdated {
running: "0.0.1".to_string()
},
);
assert!(
daemon::is_running(&config),
"noticing must not kill the daemon: it is supervising live agents"
);
let out = Command::new(env!("CARGO_BIN_EXE_marver"))
.args(["status", "--data-dir"])
.arg(dir.path())
.output()
.expect("run marver status");
let text = String::from_utf8_lossy(&out.stdout);
assert!(text.contains("running (0.0.1)"), "{text:?}");
assert!(text.contains("warning"), "{text:?}");
assert!(
text.contains("marver restart"),
"it names the command that fixes it: {text:?}"
);
}
fn restart(dir: &TempDir, extra: &[&str]) -> std::process::Output {
let mut command = Command::new(env!("CARGO_BIN_EXE_marver"));
command.args(["restart", "--data-dir"]).arg(dir.path());
command.arg("--scan-root").arg(dir.path());
command.args(extra);
command.output().expect("run marver restart")
}
#[test]
fn restart_replaces_the_daemon_and_the_new_one_is_this_version() {
let dir = TempDir::new().unwrap();
let config = config(&dir);
let Startup::Started { pid } = daemon::ensure_running(&config).expect("start") else {
panic!("expected a fresh daemon");
};
let _guard = Daemon(pid);
std::fs::write(&config.version_file, "0.0.1\n").unwrap();
let out = restart(&dir, &[]);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(out.status.success(), "{stderr:?}");
assert!(stderr.contains("stopped the daemon (0.0.1)"), "{stderr:?}");
assert!(stderr.contains("started a daemon"), "{stderr:?}");
assert!(daemon::is_running(&config), "something must be listening");
assert_eq!(
daemon::running_version(&config).as_deref(),
Some(daemon::VERSION),
"the replacement is this build"
);
let new_pid = daemon::running_pid(&config).expect("pid");
assert_ne!(new_pid, pid, "the old process is gone, not reused");
let _replacement = Daemon(new_pid);
}
#[test]
fn restart_refuses_while_an_agent_might_still_report() {
let dir = TempDir::new().unwrap();
let config = config(&dir);
let Startup::Started { pid } = daemon::ensure_running(&config).expect("start") else {
panic!("expected a fresh daemon");
};
let _guard = Daemon(pid);
{
let mut store = Store::open(&config.db).expect("store");
let task = store
.create_task("busy", "p", dir.path(), &[], Utc::now())
.expect("create");
store
.transition(task.id, TaskState::Running, Transition::Plain, Utc::now())
.expect("run it");
}
let refused = restart(&dir, &[]);
let stderr = String::from_utf8_lossy(&refused.stderr);
assert!(!refused.status.success(), "{stderr:?}");
assert!(stderr.contains("1 running"), "{stderr:?}");
assert!(
stderr.contains("--force"),
"it offers the way out: {stderr:?}"
);
assert_eq!(
daemon::running_pid(&config),
Some(pid),
"a refusal must leave the daemon alone"
);
let forced = restart(&dir, &["--force"]);
let stderr = String::from_utf8_lossy(&forced.stderr);
assert!(forced.status.success(), "{stderr:?}");
assert_ne!(
daemon::running_pid(&config),
Some(pid),
"--force goes through"
);
if let Some(new_pid) = daemon::running_pid(&config) {
let _replacement = Daemon(new_pid);
}
}
#[test]
fn restart_finds_a_daemon_that_left_no_pid_file() {
let dir = TempDir::new().unwrap();
let config = config(&dir);
let Startup::Started { pid } = daemon::ensure_running(&config).expect("start") else {
panic!("expected a fresh daemon");
};
let _guard = Daemon(pid);
std::fs::remove_file(&config.pid_file).expect("forget the pid");
assert_eq!(daemon::running_pid(&config), None);
let out = restart(&dir, &[]);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(out.status.success(), "{stderr:?}");
assert!(
!stderr.contains("no daemon was running"),
"one was running: {stderr:?}"
);
assert!(stderr.contains("stopped the daemon"), "{stderr:?}");
let new_pid = daemon::running_pid(&config).expect("the replacement records itself");
assert_ne!(new_pid, pid, "the old one is actually gone");
let _replacement = Daemon(new_pid);
}
#[test]
fn restart_with_nothing_running_just_starts_one() {
let dir = TempDir::new().unwrap();
let config = config(&dir);
assert!(!daemon::is_running(&config));
let out = restart(&dir, &[]);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(out.status.success(), "{stderr:?}");
assert!(stderr.contains("no daemon was running"), "{stderr:?}");
assert!(daemon::is_running(&config));
if let Some(pid) = daemon::running_pid(&config) {
let _guard = Daemon(pid);
}
}
#[test]
fn a_liveness_probe_leaves_no_trace_in_the_log() {
let dir = TempDir::new().unwrap();
let config = config(&dir);
let Startup::Started { pid } = daemon::ensure_running(&config).expect("start") else {
panic!("expected a fresh daemon");
};
let _guard = Daemon(pid);
for _ in 0..5 {
assert!(daemon::is_running(&config));
}
std::thread::sleep(Duration::from_millis(300));
let log = std::fs::read_to_string(&config.log).unwrap_or_default();
assert!(!log.contains("ignoring hook"), "{log:?}");
}
#[test]
fn a_started_daemon_is_asked_its_version_rather_than_assumed() {
let dir = TempDir::new().unwrap();
let config = config(&dir);
std::fs::write(&config.version_file, "0.0.1\n").unwrap();
std::fs::write(&config.pid_file, "999999\n").unwrap();
let Startup::Started { pid } = daemon::ensure_running(&config).expect("start") else {
panic!("expected a fresh daemon");
};
let _guard = Daemon(pid);
assert_eq!(
daemon::announced_version(&config, pid).as_deref(),
Some(daemon::VERSION),
"the answer must come from the daemon that was just started"
);
}
#[test]
fn a_daemon_that_says_nothing_gets_no_version_invented_for_it() {
let dir = TempDir::new().unwrap();
let config = config(&dir);
std::fs::write(&config.version_file, "0.0.1\n").unwrap();
assert_eq!(daemon::announced_version(&config, 999_999), None);
}