use super::runtime::{Runtime, State};
use crate::file::display_path;
use eyre::Result;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone)]
pub(crate) struct Entry {
pub dir: PathBuf,
pub state: State,
}
impl Entry {
pub(crate) fn config_file(&self) -> PathBuf {
self.dir.join("pitchfork.toml")
}
pub(crate) fn orphaned(&self) -> bool {
if self.state.root.as_os_str().is_empty() {
return false;
}
match std::fs::symlink_metadata(&self.state.root) {
Ok(_) => false,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => true,
Err(err) => {
debug!(
"keeping {}: cannot read {}: {err}",
display_path(&self.dir),
display_path(&self.state.root)
);
false
}
}
}
pub(crate) fn unreadable_root(&self) -> Option<String> {
match std::fs::symlink_metadata(&self.state.root) {
Err(err) if err.kind() != std::io::ErrorKind::NotFound => Some(format!(
"keeping {}: cannot read {}: {err}",
display_path(&self.dir),
display_path(&self.state.root)
)),
_ => None,
}
}
pub(crate) fn ambiguity(&self) -> Option<String> {
if is_a_mount_point(&self.state.root) {
return Some(format!(
"{} is where a volume gets mounted, so it may be unmounted rather than a deleted project",
display_path(&self.state.root)
));
}
if let Some(parent) = self.state.root.parent()
&& !parent.exists()
{
return Some(format!(
"{} is gone as well, so {} may be an unmounted volume rather than a deleted project",
display_path(parent),
display_path(&self.state.root)
));
}
if let Some(existing) = self.state.root.ancestors().skip(1).find(|p| p.exists()) {
let looks_unmounted = match std::fs::read_dir(existing) {
Ok(mut dir) => dir.next().is_none(),
Err(_) => true,
};
if looks_unmounted {
return Some(format!(
"{} is empty or unreadable, so {} may be an unmounted volume rather than a deleted project",
display_path(existing),
display_path(&self.state.root)
));
}
}
if super::state_dir(&self.state.root).file_name() != self.dir.file_name() {
return Some(format!(
"{} is not the path {} was created for, which may still exist",
display_path(&self.state.root),
display_path(&self.dir)
));
}
None
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Outcome {
Removed,
Kept,
}
fn is_a_mount_point(root: &Path) -> bool {
let Some(parent) = root.parent() else {
return true;
};
if matches!(as_container(parent).as_str(), "/Volumes" | "/mnt") {
return true;
}
let one_deeper = parent.parent().map(as_container);
matches!(as_container(parent).as_str(), "/media")
|| one_deeper.is_some_and(|dir| matches!(dir.as_str(), "/media" | "/run/media"))
}
fn as_container(path: &Path) -> String {
let path = path.to_string_lossy().replace('\\', "/");
let trimmed = path.trim_end_matches('/');
if trimmed.is_empty() {
"/".to_string()
} else {
trimmed.to_string()
}
}
pub(crate) fn base_dir() -> PathBuf {
crate::dirs::STATE.join("daemons")
}
pub(crate) fn scan(base: &Path) -> Result<Vec<Entry>> {
let Ok(read_dir) = std::fs::read_dir(base) else {
return Ok(vec![]);
};
let mut entries = Vec::new();
for entry in read_dir {
let dir = match entry {
Ok(entry) => entry.path(),
Err(err) => {
warn!("skipping an entry of {}: {err}", display_path(base));
continue;
}
};
match std::fs::metadata(&dir) {
Ok(metadata) if metadata.is_dir() => {}
Ok(_) => continue,
Err(err) => {
warn!("cannot read {}: {err}", display_path(&dir));
continue;
}
}
let path = dir.join("state.json");
let bytes = match std::fs::read(&path) {
Ok(bytes) => bytes,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => continue,
Err(err) => {
warn!("cannot read {}: {err}", display_path(&path));
continue;
}
};
match serde_json::from_slice::<State>(&bytes) {
Ok(state) => entries.push(Entry { dir, state }),
Err(err) => warn!(
"cannot understand {}, so it will not be pruned: {err}",
display_path(&path)
),
}
}
entries.sort_by(|a, b| a.dir.cmp(&b.dir));
Ok(entries)
}
pub(crate) fn orphans(base: &Path) -> Result<Vec<Entry>> {
Ok(scan(base)?.into_iter().filter(Entry::orphaned).collect())
}
pub(crate) fn dir_size(path: &Path) -> u64 {
walkdir::WalkDir::new(path)
.into_iter()
.filter_map(Result::ok)
.filter_map(|entry| entry.metadata().ok())
.filter(|metadata| metadata.is_file())
.map(|metadata| metadata.len())
.sum()
}
pub(crate) fn human_size(bytes: u64) -> String {
bytesize::ByteSize::b(bytes).display().iec().to_string()
}
pub(crate) fn describe(entries: &[(Entry, u64)]) -> Vec<String> {
entries
.iter()
.map(|(entry, size)| {
format!(
"{} ({}) from deleted {}",
display_path(&entry.dir),
human_size(*size),
display_path(&entry.state.root)
)
})
.collect()
}
pub(crate) async fn remove(entry: &Entry, runtime: Option<&Runtime>) -> Result<Outcome> {
let cwd = &entry.dir;
let lock = match super::ProjectLock::try_acquire(cwd) {
Ok(Some(lock)) => lock,
Ok(None) => {
warn!(
"keeping {}: another mise process holds its daemon lock",
display_path(cwd)
);
return Ok(Outcome::Kept);
}
Err(err) => {
warn!("keeping {}: cannot lock it: {err:#}", display_path(cwd));
return Ok(Outcome::Kept);
}
};
if !entry.orphaned() {
warn!(
"keeping {}: {} exists again",
display_path(cwd),
display_path(&entry.state.root)
);
return Ok(Outcome::Kept);
}
if let Some(runtime) = runtime {
let supervisor_up = match runtime.supervisor_up(cwd).await {
Ok(false) => false,
Ok(true) if !entry.state.ids.is_empty() => {
for id in &entry.state.ids {
let args = ["stop".to_string(), id.clone()];
match runtime.raw_output(cwd, &args).await {
Ok(output) if !output.status.success() => debug!(
"pitchfork would not stop {id}: {}",
String::from_utf8_lossy(&output.stderr).trim()
),
Ok(_) => {}
Err(err) => {
warn!("keeping {}: cannot stop {id}: {err:#}", display_path(cwd));
return Ok(Outcome::Kept);
}
}
}
true
}
Ok(_) => true,
Err(err) => {
warn!(
"keeping {}: cannot establish supervisor status: {err:#}",
display_path(cwd)
);
return Ok(Outcome::Kept);
}
};
for id in &entry.state.ids {
if let Err(err) = confirm_stopped(runtime, cwd, id, supervisor_up).await {
warn!("keeping {}: {err:#}", display_path(cwd));
return Ok(Outcome::Kept);
}
}
let config = entry.config_file();
let args = [
"config".to_string(),
"remove".to_string(),
config.to_string_lossy().into_owned(),
];
if let Err(err) = tolerate_unknown(runtime.raw_output(cwd, &args).await, &args) {
warn!(
"keeping {}: cannot unregister {}: {err:#}",
display_path(cwd),
display_path(config)
);
return Ok(Outcome::Kept);
}
} else {
warn!(
"keeping {}: pitchfork is required to stop its daemons and unregister {}; run `mise use pitchfork`",
display_path(cwd),
display_path(entry.config_file())
);
return Ok(Outcome::Kept);
}
if let Some(marker) = live_database_lock(cwd) {
warn!(
"keeping {}: {} is still there, so a database may still be running",
display_path(cwd),
display_path(&marker)
);
return Ok(Outcome::Kept);
}
if let Err(err) = delete_state_dir(cwd, lock) {
warn!("keeping {}: {err:#}", display_path(cwd));
return Ok(Outcome::Kept);
}
Ok(Outcome::Removed)
}
async fn confirm_stopped(
runtime: &Runtime,
cwd: &Path,
id: &str,
supervisor_up: bool,
) -> Result<()> {
let args = ["status".to_string(), id.to_string(), "--json".to_string()];
let output = runtime
.raw_output(cwd, &args)
.await
.map_err(|err| eyre::eyre!(err).wrap_err(format!("cannot check {id}")))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if supervisor_up && names_this_as_unknown(&stderr, id) {
debug!("pitchfork does not know {id}: {stderr}");
return Ok(());
}
eyre::bail!("cannot check {id}: {stderr}");
}
let status: serde_json::Value = serde_json::from_slice(&output.stdout)
.map_err(|err| eyre::eyre!("cannot read the status of {id}: {err}"))?;
match status["status"].as_str() {
Some("running" | "waiting" | "stopping") => {
eyre::bail!("{id} is still {}", status["status"].as_str().unwrap_or(""))
}
Some(_) => Ok(()),
None => eyre::bail!("cannot tell whether {id} is running: {status}"),
}
}
fn names_something_unknown(stderr: &str) -> bool {
let lowered = stderr.to_lowercase();
["not found", "no such", "not registered"]
.iter()
.any(|phrase| lowered.contains(phrase))
}
fn names_this_as_unknown(stderr: &str, id: &str) -> bool {
if !names_something_unknown(stderr) {
return false;
}
let lowered = stderr.to_lowercase();
if !lowered.contains("daemon") {
return false;
}
let id = id.to_lowercase();
let name = id.rsplit('/').next().unwrap_or(&id);
mentions_on_its_own(&lowered, &id) || is_named_after_the_word_daemon(&lowered, &id, name)
}
fn is_named_after_the_word_daemon(haystack: &str, id: &str, name: &str) -> bool {
let tidy = |token: &str| {
token
.trim_matches(|c: char| !c.is_alphanumeric() && !matches!(c, '/' | '-' | '_' | '.'))
.to_string()
};
let tokens: Vec<String> = haystack.split_whitespace().map(tidy).collect();
tokens
.windows(2)
.any(|pair| pair[0].trim_end_matches(':') == "daemon" && (pair[1] == id || pair[1] == name))
}
fn mentions_on_its_own(haystack: &str, needle: &str) -> bool {
let part_of_something_longer =
|c: char| c.is_alphanumeric() || matches!(c, '/' | '\\' | '.' | '-' | '_');
haystack.match_indices(needle).any(|(at, _)| {
let before = haystack[..at].chars().next_back();
let after = haystack[at + needle.len()..].chars().next();
!before.is_some_and(part_of_something_longer)
&& !after.is_some_and(part_of_something_longer)
})
}
fn tolerate_unknown(output: Result<std::process::Output>, args: &[String]) -> Result<()> {
let output = output?;
if output.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&output.stderr);
if names_something_unknown(&stderr) {
debug!("pitchfork {} had nothing to do: {stderr}", args.join(" "));
return Ok(());
}
eyre::bail!("pitchfork {}: {stderr}", args.join(" "))
}
fn live_database_lock(dir: &Path) -> Option<PathBuf> {
walkdir::WalkDir::new(dir.join("data"))
.max_depth(2)
.into_iter()
.filter_map(Result::ok)
.map(|entry| entry.path().to_path_buf())
.filter(|path| {
path.file_name().is_some_and(|name| {
let name = name.to_string_lossy();
name == "postmaster.pid" || name.ends_with(".pid") || name == "LOCK"
})
})
.find(|path| marker_names_a_live_process(path))
}
fn marker_names_a_live_process(path: &Path) -> bool {
let Ok(contents) = std::fs::read_to_string(path) else {
return true;
};
let Some(pid) = contents
.lines()
.next()
.and_then(|line| line.trim().parse::<i32>().ok())
else {
return true;
};
process_is_alive(pid)
}
#[cfg(unix)]
fn process_is_alive(pid: i32) -> bool {
match nix::sys::signal::kill(nix::unistd::Pid::from_raw(pid), None) {
Ok(()) => true,
Err(nix::errno::Errno::EPERM) => true,
Err(_) => false,
}
}
#[cfg(windows)]
fn process_is_alive(pid: i32) -> bool {
use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle};
use windows_sys::Win32::Foundation::{ERROR_ACCESS_DENIED, GetLastError};
use windows_sys::Win32::System::Threading::{
GetExitCodeProcess, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION,
};
let Ok(pid) = u32::try_from(pid) else {
return false;
};
let process = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
if process.is_null() {
return unsafe { GetLastError() } == ERROR_ACCESS_DENIED;
}
let process = unsafe { OwnedHandle::from_raw_handle(process) };
let mut code = 0u32;
if unsafe { GetExitCodeProcess(process.as_raw_handle(), &mut code) } == 0 {
return true;
}
const STILL_ACTIVE: u32 = 259;
code == STILL_ACTIVE
}
fn delete_state_dir(dir: &Path, lock: super::ProjectLock) -> Result<()> {
let state_file = dir.join("state.json");
let legacy = super::legacy_lock_file_for_state_dir(dir);
for child in std::fs::read_dir(dir)? {
let path = child?.path();
if path == state_file || path == legacy {
continue;
}
crate::file::remove_all(path)?;
}
crate::file::remove_all(&state_file)?;
drop(lock);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn write_state(base: &Path, name: &str, root: &Path, data: &[(&str, usize)]) -> PathBuf {
let dir = base.join(crate::hash::hash_to_str(
&root.canonicalize().unwrap_or_else(|_| root.to_path_buf()),
));
std::fs::create_dir_all(&dir).unwrap();
let state = State {
root: root.to_path_buf(),
namespace: format!("{name}-ns"),
ids: vec![format!("{name}-ns/db")],
..State::default()
};
std::fs::write(
dir.join("state.json"),
serde_json::to_vec_pretty(&state).unwrap(),
)
.unwrap();
std::fs::write(dir.join("pitchfork.toml"), "[daemons]\n").unwrap();
for (file, size) in data {
let path = dir.join("data").join(file);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, vec![b'x'; *size]).unwrap();
}
dir
}
#[test]
fn selects_only_states_whose_root_is_gone() {
let tmp = tempfile::tempdir().unwrap();
let base = tmp.path().join("daemons");
let live_root = tmp.path().join("live");
std::fs::create_dir(&live_root).unwrap();
let deleted_root = tmp.path().join("deleted");
std::fs::create_dir(&deleted_root).unwrap();
write_state(&base, "aaa-live", &live_root, &[("db/one", 10)]);
let orphan = write_state(
&base,
"bbb-gone",
&deleted_root,
&[("db/one", 100), ("db/sub/two", 24)],
);
std::fs::remove_dir_all(&deleted_root).unwrap();
std::fs::create_dir_all(base.join("ccc-partial").join("data")).unwrap();
std::fs::create_dir_all(base.join("ddd-corrupt")).unwrap();
std::fs::write(base.join("ddd-corrupt/state.json"), "{").unwrap();
let all = scan(&base).unwrap();
assert_eq!(all.len(), 2);
let orphans = orphans(&base).unwrap();
assert_eq!(orphans.len(), 1);
assert_eq!(orphans[0].dir, orphan);
assert_eq!(orphans[0].state.root, deleted_root);
assert_eq!(dir_size(&orphan.join("data")), 124);
let lines = describe(&[(orphans[0].clone(), dir_size(&orphan))]);
assert_eq!(lines.len(), 1);
assert!(lines[0].starts_with(&display_path(&orphan)), "{}", lines[0]);
assert!(lines[0].contains("from deleted"), "{}", lines[0]);
assert!(
lines[0].ends_with(&display_path(&deleted_root)),
"{}",
lines[0]
);
}
#[test]
fn missing_base_and_live_roots_yield_nothing() {
let tmp = tempfile::tempdir().unwrap();
assert!(orphans(&tmp.path().join("none")).unwrap().is_empty());
let base = tmp.path().join("daemons");
write_state(&base, "live", tmp.path(), &[]);
assert!(orphans(&base).unwrap().is_empty());
assert_eq!(dir_size(&tmp.path().join("nothing")), 0);
}
#[tokio::test]
async fn state_survives_a_held_lock_and_a_missing_pitchfork() {
let tmp = tempfile::tempdir().unwrap();
let base = tmp.path().join("daemons");
let gone = tmp.path().join("gone");
let dir = write_state(&base, "gone", &gone, &[("db/one", 1)]);
let entry = orphans(&base).unwrap().remove(0);
let held = super::super::ProjectLock::try_acquire(&dir)
.unwrap()
.unwrap();
assert_eq!(remove(&entry, None).await.unwrap(), Outcome::Kept);
assert!(dir.join("state.json").exists(), "locked state must survive");
drop(held);
assert_eq!(remove(&entry, None).await.unwrap(), Outcome::Kept);
assert!(dir.join("state.json").exists());
}
#[tokio::test]
async fn the_lock_guarding_a_state_directory_outlives_it() {
let tmp = tempfile::tempdir().unwrap();
let base = tmp.path().join("daemons");
let dir = write_state(&base, "gone", &tmp.path().join("gone"), &[("db/one", 1)]);
let lock = super::super::lock_file_for_state_dir(&dir);
assert_eq!(lock.parent(), dir.parent());
assert!(!lock.starts_with(&dir));
let legacy = super::super::legacy_lock_file_for_state_dir(&dir);
let held = crate::lock_file::LockFile::at(&legacy).try_lock().unwrap();
let entry = orphans(&base).unwrap().remove(0);
assert_eq!(remove(&entry, None).await.unwrap(), Outcome::Kept);
assert!(dir.join("state.json").exists());
drop(held);
assert_eq!(remove(&entry, None).await.unwrap(), Outcome::Kept);
std::fs::write(&lock, "").unwrap();
assert_eq!(scan(&base).unwrap().len(), 1);
}
#[test]
#[cfg(unix)]
fn a_partial_delete_leaves_an_entry_that_is_still_found() {
use std::os::unix::fs::MetadataExt;
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let base = tmp.path().join("daemons");
let dir = write_state(&base, "gone", &tmp.path().join("gone"), &[("db/one", 1)]);
let busy = dir.join("data");
let mut perms = std::fs::metadata(&busy).unwrap().permissions();
perms.set_mode(0o500);
std::fs::set_permissions(&busy, perms).unwrap();
let lock = super::super::ProjectLock::try_acquire(&dir)
.unwrap()
.unwrap();
let result = delete_state_dir(&dir, lock);
if std::fs::metadata(tmp.path()).unwrap().uid() == 0 {
result.unwrap();
assert!(!dir.join("state.json").exists());
} else {
assert!(
result.is_err(),
"a directory it cannot read must not vanish"
);
assert!(dir.join("state.json").exists());
assert_eq!(orphans(&base).unwrap().len(), 1);
}
}
#[test]
fn deletion_keeps_only_the_lock_files_that_made_it_safe() {
let tmp = tempfile::tempdir().unwrap();
let base = tmp.path().join("daemons");
let dir = write_state(&base, "gone", &tmp.path().join("gone"), &[("db/one", 64)]);
let legacy = super::super::legacy_lock_file_for_state_dir(&dir);
let lock = super::super::ProjectLock::try_acquire(&dir)
.unwrap()
.unwrap();
delete_state_dir(&dir, lock).unwrap();
assert!(!dir.join("data").exists());
assert!(!dir.join("pitchfork.toml").exists());
assert!(!dir.join("state.json").exists());
assert!(legacy.exists());
assert_eq!(dir_size(&dir), 0);
assert!(scan(&base).unwrap().is_empty());
}
#[test]
#[cfg(unix)]
fn a_deleted_alias_is_never_removed_without_being_asked_about() {
let tmp = tempfile::tempdir().unwrap();
let base = tmp.path().join("daemons");
let project = tmp.path().join("project");
std::fs::create_dir(&project).unwrap();
let alias = tmp.path().join("alias");
std::os::unix::fs::symlink(&project, &alias).unwrap();
let dir = base.join(crate::hash::hash_to_str(&project));
std::fs::create_dir_all(&dir).unwrap();
let state = State {
root: alias.clone(),
..State::default()
};
std::fs::write(
dir.join("state.json"),
serde_json::to_vec_pretty(&state).unwrap(),
)
.unwrap();
std::fs::create_dir_all(dir.join("data/db")).unwrap();
std::fs::remove_file(&alias).unwrap();
assert!(project.is_dir());
let entry = orphans(&base).unwrap().remove(0);
let why = entry.ambiguity().expect("must not be deleted unasked");
assert!(why.contains("may still exist"), "{why}");
std::fs::remove_dir_all(&project).unwrap();
let dir = write_state(&base, "canonical", &project, &[]);
let entry = orphans(&base)
.unwrap()
.into_iter()
.find(|e| e.dir == dir)
.unwrap();
assert_eq!(entry.ambiguity(), None);
}
#[test]
fn a_root_that_is_itself_a_mount_point_is_flagged_for_a_person() {
assert!(is_a_mount_point(Path::new("/Volumes/Disk")));
assert!(is_a_mount_point(Path::new("/mnt/data")));
assert!(is_a_mount_point(Path::new("/")));
assert!(is_a_mount_point(Path::new("/media/usb")));
assert!(is_a_mount_point(Path::new("/media/coder/usb")));
assert!(is_a_mount_point(Path::new("/run/media/coder/usb")));
assert!(!is_a_mount_point(Path::new("/home/coder/src/mise")));
assert!(!is_a_mount_point(Path::new("/Volumes/Disk/project")));
assert!(!is_a_mount_point(Path::new("/media/coder/usb/project")));
}
#[test]
fn a_root_whose_parent_is_gone_too_is_flagged_for_a_person() {
let tmp = tempfile::tempdir().unwrap();
let base = tmp.path().join("daemons");
let volumes = tmp.path().join("Volumes");
std::fs::create_dir(&volumes).unwrap();
std::fs::create_dir(volumes.join("Another")).unwrap();
write_state(&base, "unplugged", &volumes.join("Disk/project"), &[]);
let entry = orphans(&base).unwrap().remove(0);
let why = entry
.ambiguity()
.expect("an unplugged disk must reach a person");
assert!(why.contains("unmounted volume"), "{why}");
}
#[test]
fn a_root_under_an_empty_ancestor_is_flagged_for_a_person() {
let tmp = tempfile::tempdir().unwrap();
let base = tmp.path().join("daemons");
let mount = tmp.path().join("mnt");
std::fs::create_dir(&mount).unwrap();
write_state(&base, "unmounted", &mount.join("project"), &[]);
let entry = orphans(&base).unwrap().remove(0);
let why = entry.ambiguity().expect("must reach a person");
assert!(why.contains("unmounted volume"), "{why}");
let projects = tmp.path().join("src");
std::fs::create_dir(&projects).unwrap();
std::fs::create_dir(projects.join("other-project")).unwrap();
write_state(&base, "deleted", &projects.join("project"), &[]);
let entry = orphans(&base)
.unwrap()
.into_iter()
.find(|e| e.state.root.starts_with(&projects))
.unwrap();
assert_eq!(entry.ambiguity(), None);
}
#[test]
#[cfg(unix)]
fn state_that_cannot_be_examined_is_reported_and_skipped() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let base = tmp.path().join("daemons");
write_state(&base, "gone", &tmp.path().join("gone"), &[]);
let mut perms = std::fs::metadata(&base).unwrap().permissions();
perms.set_mode(0o400);
std::fs::set_permissions(&base, perms).unwrap();
let scanned = scan(&base);
let mut perms = std::fs::metadata(&base).unwrap().permissions();
perms.set_mode(0o700);
std::fs::set_permissions(&base, perms).unwrap();
assert!(scanned.unwrap().is_empty());
assert_eq!(scan(&base).unwrap().len(), 1);
}
#[test]
fn state_from_another_version_is_still_selectable() {
let tmp = tempfile::tempdir().unwrap();
let base = tmp.path().join("daemons");
let gone = tmp.path().join("gone");
let dir = base.join(crate::hash::hash_to_str(&gone));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("state.json"),
format!(
r#"{{"root":{:?},"something_new":42}}"#,
gone.to_str().unwrap()
),
)
.unwrap();
let entry = orphans(&base).unwrap().remove(0);
assert_eq!(entry.state.root, gone);
assert!(entry.state.ids.is_empty());
assert_eq!(entry.ambiguity(), None);
}
#[test]
fn state_that_cannot_be_understood_is_reported_and_skipped() {
let tmp = tempfile::tempdir().unwrap();
let base = tmp.path().join("daemons");
write_state(&base, "fine", &tmp.path().join("gone"), &[]);
std::fs::create_dir_all(base.join("corrupt")).unwrap();
std::fs::write(base.join("corrupt/state.json"), "{not json").unwrap();
assert_eq!(scan(&base).unwrap().len(), 1);
}
#[test]
fn a_database_lock_counts_only_while_its_process_does() {
let tmp = tempfile::tempdir().unwrap();
let base = tmp.path().join("daemons");
let dir = write_state(&base, "gone", &tmp.path().join("gone"), &[]);
let marker = dir.join("data/postgres/postmaster.pid");
std::fs::create_dir_all(marker.parent().unwrap()).unwrap();
assert!(live_database_lock(&dir).is_none());
std::fs::write(&marker, format!("{}\n/data\n", std::process::id())).unwrap();
assert_eq!(live_database_lock(&dir).as_deref(), Some(marker.as_path()));
std::fs::write(&marker, format!("{}\n/data\n", i32::MAX)).unwrap();
assert!(live_database_lock(&dir).is_none());
let lock = dir.join("data/crdb/LOCK");
std::fs::create_dir_all(lock.parent().unwrap()).unwrap();
std::fs::write(&lock, "").unwrap();
assert_eq!(live_database_lock(&dir).as_deref(), Some(lock.as_path()));
}
#[test]
#[cfg(unix)]
fn an_ancestor_that_cannot_be_read_is_treated_as_a_mount_point() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let base = tmp.path().join("daemons");
let mount = tmp.path().join("mnt");
std::fs::create_dir(&mount).unwrap();
write_state(&base, "unreadable", &mount.join("project"), &[]);
let mut perms = std::fs::metadata(&mount).unwrap().permissions();
perms.set_mode(0o111);
std::fs::set_permissions(&mount, perms).unwrap();
let entries = orphans(&base).unwrap();
let ambiguity = entries.first().map(|entry| entry.ambiguity());
let mut perms = std::fs::metadata(&mount).unwrap().permissions();
perms.set_mode(0o700);
std::fs::set_permissions(&mount, perms).unwrap();
let ambiguity =
ambiguity.expect("the root itself is still missing, so this is a candidate");
let why = ambiguity.expect("an ancestor that cannot be listed must reach a person");
assert!(why.contains("unmounted volume"), "{why}");
}
fn failure() -> std::process::ExitStatus {
#[cfg(unix)]
{
use std::os::unix::process::ExitStatusExt;
std::process::ExitStatus::from_raw(256)
}
#[cfg(windows)]
{
use std::os::windows::process::ExitStatusExt;
std::process::ExitStatus::from_raw(1)
}
}
#[test]
fn pitchfork_forgetting_something_it_never_knew_is_not_a_failure() {
let args = vec!["stop".to_string(), "ns/db".to_string()];
let failed = |stderr: &str| {
Ok(std::process::Output {
status: failure(),
stdout: vec![],
stderr: stderr.as_bytes().to_vec(),
})
};
assert!(tolerate_unknown(failed("daemon ns/db not found"), &args).is_ok());
assert!(names_this_as_unknown("daemon ns/db not found", "ns/db"));
assert!(names_this_as_unknown("no such daemon: ns/db", "ns/db"));
assert!(!names_this_as_unknown("", "ns/db"));
assert!(!names_this_as_unknown(
"daemon socket error: no such file or directory",
"ns/file"
));
assert!(!names_this_as_unknown(
"daemon socket missing: no such file /run/pitchfork/ns/db.sock",
"ns/db"
));
assert!(names_this_as_unknown("no such daemon: db", "ns/db"));
assert!(names_this_as_unknown("Daemon db not found", "ns/db"));
assert!(!names_this_as_unknown(
"daemon supervisor: no such db file",
"ns/db"
));
assert!(!names_this_as_unknown("daemon ns/other not found", "ns/db"));
assert!(!names_this_as_unknown(
"No such file or directory (os error 2)",
"ns/db"
));
assert!(!names_this_as_unknown("daemon ns/other not found", "ns/db"));
assert!(!names_this_as_unknown(
"No such file or directory: /run/pitchfork/db.sock",
"ns/db"
));
assert!(!names_this_as_unknown(
"failed to connect: no such file or directory (/tmp/ns/db/sock)",
"ns/db"
));
assert!(tolerate_unknown(failed("no such config"), &args).is_ok());
assert!(tolerate_unknown(failed("permission denied"), &args).is_err());
assert!(tolerate_unknown(failed("unknown error from supervisor"), &args).is_err());
assert!(tolerate_unknown(failed("daemon is not responding"), &args).is_err());
}
#[test]
fn a_root_that_cannot_be_confirmed_missing_is_kept() {
let tmp = tempfile::tempdir().unwrap();
let base = tmp.path().join("daemons");
let file_root = tmp.path().join("root-is-a-file");
std::fs::write(&file_root, "").unwrap();
write_state(&base, "file-root", &file_root, &[]);
assert!(orphans(&base).unwrap().is_empty());
}
#[tokio::test]
async fn a_root_that_reappears_before_the_lock_is_kept() {
let tmp = tempfile::tempdir().unwrap();
let base = tmp.path().join("daemons");
let root = tmp.path().join("restored");
let dir = write_state(&base, "restored", &root, &[]);
let entry = orphans(&base).unwrap().remove(0);
std::fs::create_dir_all(&root).unwrap();
assert_eq!(remove(&entry, None).await.unwrap(), Outcome::Kept);
assert!(dir.join("state.json").exists());
}
}