use super::{PidRecord, StaleReconciliation, claim, pid_file_path, read, remove_if_matches};
use crate::control::incarnation;
type TestResult = Result<(), Box<dyn std::error::Error>>;
fn own_record() -> Result<PidRecord, Box<dyn std::error::Error>> {
let me = incarnation::self_identity()?;
Ok(PidRecord {
pid: me.pid,
started_at_unix_secs: me.started_at_unix_secs,
binary_sha256: me.binary_sha256,
version: env!("CARGO_PKG_VERSION").to_owned(),
commit: "test-commit".to_owned(),
http_address: "127.0.0.1:8080".parse()?,
grpc_address: "127.0.0.1:50051".parse()?,
drain_timeout_seconds: 30,
})
}
fn dead_record() -> Result<PidRecord, Box<dyn std::error::Error>> {
let mut child = std::process::Command::new("true").spawn()?;
let pid = child.id();
child.wait()?;
let mut record = own_record()?;
record.pid = pid;
record.started_at_unix_secs = 0;
Ok(record)
}
#[test]
fn claim_writes_and_the_guard_removes_on_drop() -> TestResult {
let home = tempfile::tempdir()?;
let record = own_record()?;
let guard = claim(home.path(), &record)?;
assert_eq!(
guard.reconciliation(),
&StaleReconciliation::NonePresent,
"an empty home has nothing to reconcile"
);
assert_eq!(
read(home.path())?.as_ref(),
Some(&record),
"the file must hold the claimed record"
);
drop(guard);
assert_eq!(
read(home.path())?,
None,
"the guard must remove the file on clean exit"
);
Ok(())
}
#[test]
fn a_dead_incarnations_file_is_reconciled_and_replaced() -> TestResult {
let home = tempfile::tempdir()?;
let stale = dead_record()?;
std::mem::forget(claim(home.path(), &stale)?);
let record = own_record()?;
let guard = claim(home.path(), &record)?;
match guard.reconciliation() {
StaleReconciliation::DeadIncarnation(named) | StaleReconciliation::ReusedPid(named) => {
assert_eq!(named, &stale, "the stale predecessor must be named");
}
other => {
return Err(format!(
"a stale file must reconcile as dead or reused, never be trusted: {other:?}"
)
.into());
}
}
assert_eq!(
read(home.path())?.as_ref(),
Some(&record),
"the new incarnation's record must replace the stale one"
);
Ok(())
}
#[test]
fn a_reused_pids_file_is_reconciled_as_reused() -> TestResult {
let home = tempfile::tempdir()?;
let mut stale = own_record()?;
stale.started_at_unix_secs = stale.started_at_unix_secs.wrapping_add(31);
let leaked = claim(home.path(), &stale)?;
std::mem::forget(leaked);
let record = own_record()?;
let guard = claim(home.path(), &record)?;
assert_eq!(
guard.reconciliation(),
&StaleReconciliation::ReusedPid(stale),
"a live pid with a foreign start instant is a reused pid"
);
Ok(())
}
#[test]
fn a_hand_written_pid_file_is_reconciled_as_unreadable() -> TestResult {
let home = tempfile::tempdir()?;
let path = pid_file_path(home.path());
std::fs::create_dir_all(path.parent().ok_or("pid path must have a parent")?)?;
std::fs::write(&path, "93400\n")?;
let record = own_record()?;
let guard = claim(home.path(), &record)?;
assert!(
matches!(
guard.reconciliation(),
StaleReconciliation::Unreadable { .. }
),
"a bare-pid file must reconcile as unreadable, got {:?}",
guard.reconciliation()
);
assert_eq!(
read(home.path())?.as_ref(),
Some(&record),
"the record must replace the hand-written file"
);
Ok(())
}
#[test]
fn a_live_incarnation_on_different_addresses_keeps_its_claim() -> TestResult {
let home = tempfile::tempdir()?;
let first_record = own_record()?;
let first_guard = claim(home.path(), &first_record)?;
let mut second_record = own_record()?;
second_record.http_address = "127.0.0.1:18080".parse()?;
second_record.grpc_address = "127.0.0.1:15005".parse()?;
let second_guard = claim(home.path(), &second_record)?;
assert!(
matches!(
second_guard.reconciliation(),
StaleReconciliation::LiveIncarnationElsewhere { .. }
),
"the unclaimed boot must wear its OWN face — not the claimed-over-debris \
one, which is its opposite: {:?}",
second_guard.reconciliation()
);
assert!(
!second_guard.holds_claim(),
"an unclaimed boot must say it holds no claim"
);
assert_eq!(
read(home.path())?.as_ref(),
Some(&first_record),
"the first claimant's record must survive the second boot"
);
drop(second_guard);
assert_eq!(
read(home.path())?.as_ref(),
Some(&first_record),
"an unclaimed guard's exit must remove NOTHING — the live claimant's \
record survives"
);
drop(first_guard);
assert_eq!(
read(home.path())?,
None,
"the claim holder still removes its own record on exit"
);
Ok(())
}
#[test]
fn a_lingering_guard_leaves_a_successors_file_alone() -> TestResult {
let home = tempfile::tempdir()?;
let mut first_record = own_record()?;
first_record.commit = "first".to_owned();
let first_guard = claim(home.path(), &first_record)?;
let mut second_record = own_record()?;
second_record.commit = "second".to_owned();
let second_guard = claim(home.path(), &second_record)?;
assert!(
matches!(
second_guard.reconciliation(),
StaleReconciliation::LiveIncarnation { .. }
),
"claiming over a live incarnation's record on the SAME addresses must be \
REPORTED as exactly that face, not silently absorbed: {:?}",
second_guard.reconciliation()
);
assert!(
second_guard.holds_claim(),
"the same-address contradiction is claimed over: this guard holds the claim"
);
drop(first_guard);
assert_eq!(
read(home.path())?.as_ref(),
Some(&second_record),
"the first guard must not delete the second incarnation's record"
);
drop(second_guard);
assert_eq!(read(home.path())?, None, "the owner removes its own record");
Ok(())
}
#[test]
fn remove_if_matches_is_exact() -> TestResult {
let home = tempfile::tempdir()?;
let record = own_record()?;
let guard = claim(home.path(), &record)?;
std::mem::forget(guard);
let mut other = record.clone();
other.commit = "someone-else".to_owned();
assert!(
!remove_if_matches(home.path(), &other)?,
"a differing record must not remove the file"
);
assert!(
remove_if_matches(home.path(), &record)?,
"the matching record removes it"
);
assert!(
!remove_if_matches(home.path(), &record)?,
"an absent file removes nothing"
);
Ok(())
}
#[test]
fn a_record_predating_the_drain_window_field_still_reads() -> TestResult {
let home = tempfile::tempdir()?;
let path = pid_file_path(home.path());
std::fs::create_dir_all(path.parent().ok_or("pid path must have a parent")?)?;
let old_shape = serde_json::json!({
"pid": 93400,
"started_at_unix_secs": 1_000_000,
"binary_sha256": "0".repeat(64),
"version": "0.22.0",
"commit": "older-build",
"http_address": "127.0.0.1:8080",
"grpc_address": "127.0.0.1:50051",
});
std::fs::write(&path, format!("{old_shape}\n"))?;
let record = read(home.path())?.ok_or("the old-shape record must read")?;
assert_eq!(
record.drain_timeout_seconds, 0,
"the missing field reads as its default, which patience resolution \
treats as no recorded window"
);
Ok(())
}
#[test]
fn read_distinguishes_absent_from_unreadable() -> TestResult {
let home = tempfile::tempdir()?;
assert_eq!(read(home.path())?, None, "no file is a state, not an error");
let path = pid_file_path(home.path());
std::fs::create_dir_all(path.parent().ok_or("pid path must have a parent")?)?;
std::fs::write(&path, "not json")?;
let error = match read(home.path()) {
Err(error) => error,
Ok(record) => return Err(format!("corrupt file must refuse, got {record:?}").into()),
};
assert!(
error.to_string().contains("does not parse"),
"the refusal must say why: {error}"
);
Ok(())
}