use super::*;
use crate::index_lock_evidence::{LockMetadata, PidFile, PidOwner};
use crate::index_lock_holders::{Holder, HolderEvidence};
use std::path::PathBuf;
struct FakeEffects {
presence: Vec<bool>,
evidence: Vec<Option<Evidence>>,
gather_cost: f64,
presence_failure: Option<String>,
gather_failure: Option<String>,
clock: f64,
sleeps: Vec<u64>,
reports: Vec<String>,
presence_reads: usize,
gathers: usize,
}
impl FakeEffects {
fn new(presence: Vec<bool>, evidence: Vec<Option<Evidence>>) -> Self {
return FakeEffects {
presence,
evidence,
gather_cost: 0.0,
presence_failure: None,
gather_failure: None,
clock: 0.0,
sleeps: Vec::new(),
reports: Vec::new(),
presence_reads: 0,
gathers: 0,
};
}
}
impl WaitEffects for FakeEffects {
fn lock_present(&mut self) -> Result<bool, String> {
if let Some(reason) = &self.presence_failure {
return Err(reason.clone());
}
let index: usize = self.presence_reads.min(self.presence.len() - 1);
self.presence_reads += 1;
return Ok(self.presence[index]);
}
fn gather(&mut self) -> Result<Option<Evidence>, String> {
if let Some(reason) = &self.gather_failure {
return Err(reason.clone());
}
self.clock += self.gather_cost;
let index: usize = self.gathers.min(self.evidence.len() - 1);
self.gathers += 1;
return Ok(self.evidence[index].clone());
}
fn sleep(&mut self, ms: u64) {
self.clock += ms as f64;
self.sleeps.push(ms);
}
fn random(&mut self) -> f64 {
return 0.5;
}
fn now_ms(&mut self) -> f64 {
return self.clock;
}
fn report(&mut self, line: &str) {
self.reports.push(String::from(line));
}
}
fn pid_evidence(owner: PidOwner) -> Evidence {
return Evidence {
lock_path: PathBuf::from("/repo/index.lock"),
lock: LockMetadata {
device: 1,
inode: 2,
ctime_ms: 1000,
},
pid_file: PidFile::Owner(42, owner),
holders: None,
};
}
fn descriptor_evidence() -> Evidence {
return Evidence {
lock_path: PathBuf::from("/repo/index.lock"),
lock: LockMetadata {
device: 1,
inode: 2,
ctime_ms: 1000,
},
pid_file: PidFile::Absent,
holders: Some(HolderEvidence {
method: "proc-fd",
holders: vec![Holder {
pid: 55,
command: Some(String::from("git")),
}],
partial: Vec::new(),
}),
};
}
fn free_evidence() -> Evidence {
return Evidence {
lock_path: PathBuf::from("/repo/index.lock"),
lock: LockMetadata {
device: 1,
inode: 2,
ctime_ms: 1000,
},
pid_file: PidFile::Absent,
holders: Some(HolderEvidence {
method: "proc-fd",
holders: Vec::new(),
partial: Vec::new(),
}),
};
}
fn all_within(values: &[u64], cap: u64) -> bool {
for value in values {
if *value > cap {
return false;
}
}
return true;
}
fn held_then_released(count: usize) -> Vec<bool> {
let mut answers: Vec<bool> = vec![true; count];
answers.push(false);
return answers;
}
#[test]
fn jittered_waits_follow_gits_formula() {
let state: BackoffState = BackoffState { step: 3, multiplier: 9 };
assert_eq!(jittered_wait(state, 0.5), 9);
assert_eq!(jittered_wait(state, 0.0), 6);
assert_eq!(jittered_wait(state, 0.999), 11);
}
#[test]
fn backoff_squares_then_stops_at_the_cap() {
let first: BackoffState = next_backoff(INITIAL_BACKOFF);
assert_eq!(first, BackoffState { step: 2, multiplier: 4 });
let second: BackoffState = next_backoff(first);
assert_eq!(second, BackoffState { step: 3, multiplier: 9 });
let near_cap: BackoffState = BackoffState { step: 31, multiplier: 961 };
assert_eq!(next_backoff(near_cap), BackoffState { step: 31, multiplier: BACKOFF_MAX_MULTIPLIER });
let at_cap: BackoffState = BackoffState { step: 31, multiplier: BACKOFF_MAX_MULTIPLIER };
assert_eq!(next_backoff(at_cap), at_cap);
}
#[test]
fn a_released_lock_returns_at_once() {
let mut effects: FakeEffects = FakeEffects::new(vec![false], vec![None]);
assert_eq!(wait_for_index_lock(&mut effects, 100, "did nothing"), Ok(()));
assert!(effects.sleeps.is_empty());
assert_eq!(effects.gathers, 0);
}
#[test]
fn a_proven_pid_file_owner_is_waited_for_without_limit() {
let mut effects: FakeEffects = FakeEffects::new(
held_then_released(12),
vec![Some(pid_evidence(PidOwner::StartedBeforeLock(500.0)))],
);
assert_eq!(wait_for_index_lock(&mut effects, 1, "did nothing"), Ok(()));
assert_eq!(effects.sleeps.len(), 12);
assert!(all_within(effects.sleeps.as_slice(), PROVEN_BY_PID_FILE_POLL_CAP_MS));
assert_eq!(effects.sleeps.last(), Some(&PROVEN_BY_PID_FILE_POLL_CAP_MS));
assert_eq!(effects.reports.len(), 1);
assert!(effects.reports[0].starts_with("cli-git: waiting for PID 42, which holds /repo/index.lock"));
}
#[test]
fn a_descriptor_holder_has_its_own_longer_poll_cap() {
let mut effects: FakeEffects = FakeEffects::new(held_then_released(12), vec![Some(descriptor_evidence())]);
assert_eq!(wait_for_index_lock(&mut effects, 1, "did nothing"), Ok(()));
assert!(all_within(effects.sleeps.as_slice(), PROVEN_BY_DESCRIPTOR_POLL_CAP_MS));
assert_eq!(effects.sleeps.last(), Some(&144));
assert!(effects.reports[0].contains("(proven by an open descriptor)"));
}
#[test]
fn a_dead_owner_fails_after_the_budget() {
let mut effects: FakeEffects = FakeEffects::new(
vec![true],
vec![Some(pid_evidence(PidOwner::Missing))],
);
let failure: WaitFailure = wait_for_index_lock(&mut effects, 100, "did not run git add")
.expect_err("a dead owner must fail once the budget is spent");
match failure {
WaitFailure::UnprovenOwner {
verdict,
timeout_ms,
consequence,
..
} => {
assert_eq!(verdict, Verdict::Dead(42));
assert_eq!(timeout_ms, 100);
assert_eq!(consequence, "did not run git add");
}
other => panic!("expected the unproven owner failure, got {other:?}"),
}
assert!(effects.sleeps.iter().sum::<u64>() >= 100);
assert!(effects.reports.is_empty());
}
#[test]
fn an_evidence_free_owner_fails_on_the_same_budget() {
let mut effects: FakeEffects = FakeEffects::new(vec![true], vec![Some(free_evidence())]);
let failure: WaitFailure = wait_for_index_lock(&mut effects, 50, "landed nothing")
.expect_err("an evidence-free owner must fail");
assert!(matches!(
failure,
WaitFailure::UnprovenOwner {
verdict: Verdict::EvidenceFree,
..
}
));
}
#[test]
fn gathering_evidence_counts_toward_the_budget() {
let mut effects: FakeEffects = FakeEffects::new(vec![true], vec![Some(free_evidence())]);
effects.gather_cost = 200.0;
let failure: WaitFailure = wait_for_index_lock(&mut effects, 100, "landed nothing")
.expect_err("the scan alone spends the budget");
assert!(matches!(failure, WaitFailure::UnprovenOwner { .. }));
assert!(effects.sleeps.is_empty());
}
#[test]
fn a_lock_released_before_the_evidence_read_is_retried() {
let mut effects: FakeEffects = FakeEffects::new(vec![true, false], vec![None]);
assert_eq!(wait_for_index_lock(&mut effects, 100, "did nothing"), Ok(()));
assert_eq!(effects.gathers, 1);
assert!(effects.sleeps.is_empty());
}
#[test]
fn unreadable_state_stops_the_wait_with_its_reason() {
let mut presence: FakeEffects = FakeEffects::new(vec![true], vec![None]);
presence.presence_failure = Some(String::from("denied"));
assert_eq!(
wait_for_index_lock(&mut presence, 100, "did nothing"),
Err(WaitFailure::Read(String::from("denied")))
);
let mut evidence: FakeEffects = FakeEffects::new(vec![true], vec![None]);
evidence.gather_failure = Some(String::from("no proc"));
assert_eq!(
wait_for_index_lock(&mut evidence, 100, "did nothing"),
Err(WaitFailure::Read(String::from("no proc")))
);
}
#[test]
fn the_unproven_owner_message_names_each_fact() {
let evidence: Evidence = pid_evidence(PidOwner::Missing);
let verdict: Verdict = classify_index_lock(&evidence);
let message: String = unproven_owner_message(&evidence, &verdict, 100, "did not run git add");
assert!(message.starts_with("Another process left /repo/index.lock, whose owner PID 42 no longer runs;"));
assert!(message.contains("after 100 ms (indexLock.unprovenOwnerTimeoutMs)"));
assert!(message.contains("cli-git left the lock in place and did not run git add."));
assert!(message.contains("Evidence: /repo/index.lock (device 1, inode 2"));
let unproven: String = unproven_owner_message(&free_evidence(), &Verdict::EvidenceFree, 5, "x");
assert!(unproven.contains("whose owner cli-git could not prove alive"));
}