Skip to main content

git_policy_cli/
index_lock_wait.rs

1//! What:
2//!  Waiting for a foreign `index.lock` under the classification rules:
3//!  a proven-alive owner
4//!       is waited for without limit,
5//!  a dead or evidence-free owner is retried on Git's backoff
6//!       schedule until the unproven budget runs out.
7//! Why:
8//!  An index writer must not run beside a landing,
9//!  and a lock nobody can prove alive must
10//!      not block forever.
11//!  Nothing here deletes a lock;
12//!  the caller decides what the failure means
13//!      (`src/index-lock/index-lock-wait.ts`).
14//!
15//! In TS you'd write (pseudocode):
16//! ```ts
17//! await waitForIndexLock({ realIndexPath, timeoutMs, consequence, attempt });
18//! ```
19
20/// Foreign-lock evidence and its verdicts.
21use super::index_lock_evidence::{
22    Evidence, Verdict, classify_index_lock, describe_index_lock_evidence, proven_holder_line,
23};
24
25/// Git's `BACKOFF_MAX_MULTIPLIER`:
26///  the backoff stops growing here.
27pub const BACKOFF_MAX_MULTIPLIER: u64 = 1_000;
28
29/// Git's jitter floor in thousandths of the backoff.
30const JITTER_FLOOR_PERMILLE: u64 = 750;
31
32/// Git's jitter span in thousandths of the backoff.
33const JITTER_SPAN_PERMILLE: u64 = 500;
34
35/// Thousandths per whole.
36const PERMILLE: u64 = 1_000;
37
38/// Longest single poll while a PID file proves the owner alive,
39///  so its release is noticed promptly.
40pub const PROVEN_BY_PID_FILE_POLL_CAP_MS: u64 = 100;
41
42/// Longest single poll while only an open descriptor proves the owner alive;
43///  each poll repeats the
44/// open-holder scan,
45///  so it polls less often.
46pub const PROVEN_BY_DESCRIPTOR_POLL_CAP_MS: u64 = 500;
47
48/// What:
49///  Git's backoff position:
50///  `step` is Git's `n`,
51///  `multiplier` the wait base in milliseconds.
52/// Why:
53///   `BackoffState` in the incumbent.
54///
55/// In TS you'd write (pseudocode):
56/// ```ts
57/// type BackoffState = { step: number; multiplier: number };
58/// ```
59#[derive(Clone, Copy, Debug, Eq, PartialEq)]
60pub struct BackoffState {
61    /// Git's `n`.
62    pub step: u64,
63    /// Git's `multiplier`,
64    ///  the current backoff in milliseconds.
65    pub multiplier: u64,
66}
67
68/// Backoff before the first wait.
69pub const INITIAL_BACKOFF: BackoffState = BackoffState {
70    step: 1,
71    multiplier: 1,
72};
73
74/// What:
75///  One jittered wait from Git's formula `(750 + rand() % 500) * backoff / 1000`,
76///  rounded down.
77/// Why:
78///   Jitter spreads retrying processes apart;
79///  `random` is a value in `[0, 1)`.
80///
81/// In TS you'd write (pseudocode):
82/// ```ts
83/// Math.floor(((750 + Math.floor(random * 500)) * state.multiplier) / 1000);
84/// ```
85pub fn jittered_wait(state: BackoffState, random: f64) -> u64 {
86    let span: u64 = (random * (JITTER_SPAN_PERMILLE as f64)).floor() as u64;
87    return (JITTER_FLOOR_PERMILLE + span) * state.multiplier / PERMILLE;
88}
89
90/// What:
91///  The next backoff:
92///  `multiplier += 2n + 1`,
93///  capped at `BACKOFF_MAX_MULTIPLIER`.
94/// Why:
95///   Adding `2n + 1` makes the multiplier the square of the step,
96///  which is Git's schedule.
97///
98/// In TS you'd write (pseudocode):
99/// ```ts
100/// nextBackoff({ step: 1, multiplier: 1 }); // { step: 2, multiplier: 4 }
101/// ```
102pub fn next_backoff(state: BackoffState) -> BackoffState {
103    let multiplier: u64 = state.multiplier + 2 * state.step + 1;
104    if multiplier > BACKOFF_MAX_MULTIPLIER {
105        return BackoffState {
106            step: state.step,
107            multiplier: BACKOFF_MAX_MULTIPLIER,
108        };
109    }
110    return BackoffState {
111        step: state.step + 1,
112        multiplier,
113    };
114}
115
116/// What:
117///  What one attempt found,
118///  or why the wait ended without a result.
119/// Why:
120///   The caller reports the owner and the consequence;
121///  the attempt itself is the lock check.
122///
123/// In TS you'd write (pseudocode):
124/// ```ts
125/// type IndexLockWaitError = { kind: 'unproven-owner'; evidence; verdict; timeoutMs; consequence } | { kind: 'read'; reason };
126/// ```
127#[derive(Clone, Debug, PartialEq)]
128pub enum WaitFailure {
129    /// A dead or unproven owner outlasted the unproven budget.
130    UnprovenOwner {
131        /// Last attempt's evidence.
132        evidence: Box<Evidence>,
133        /// Last verdict,
134        ///  which is never proven alive.
135        verdict: Verdict,
136        /// Budget that ran out,
137        ///  in milliseconds.
138        timeout_ms: u64,
139        /// What cli-git therefore did not do.
140        consequence: String,
141    },
142    /// The lock or its evidence could not be read.
143    Read(String),
144}
145
146/// What:
147///  The side effects of a wait,
148///  injected so tests can drive a fake clock and fake holders.
149/// Why:
150///   The host implementation reads `/proc`,
151///  sleeps and writes standard error;
152///       tests script each step.
153///
154/// In TS you'd write (pseudocode):
155/// ```ts
156/// type IndexLockWaitEffects = { gather; sleep; random; report; now };
157/// ```
158pub trait WaitEffects {
159    /// Whether the lock currently exists (the attempt).
160    fn lock_present(&mut self) -> Result<bool, String>;
161    /// This attempt's evidence,
162    ///  or nothing when the lock was released meanwhile.
163    fn gather(&mut self) -> Result<Option<Evidence>, String>;
164    /// Sleeps for `ms` milliseconds.
165    fn sleep(&mut self, ms: u64);
166    /// A uniform value in `[0, 1)`.
167    fn random(&mut self) -> f64;
168    /// The current time in milliseconds.
169    fn now_ms(&mut self) -> f64;
170    /// Writes one line of diagnostics to standard error.
171    fn report(&mut self, line: &str);
172}
173
174/// What:
175///  The per-wait loop state:
176///  backoff,
177///  time spent unproven,
178///  and whether the holder line was written.
179/// Why:
180///   `state` in `waitForIndexLock`.
181///
182/// In TS you'd write (pseudocode):
183/// ```ts
184/// const state = { backoff: INITIAL_BACKOFF, unprovenMs: 0, reported: false };
185/// ```
186#[derive(Clone, Copy, Debug)]
187struct LoopState {
188    /// Backoff position.
189    backoff: BackoffState,
190    /// Milliseconds spent without a proven owner.
191    unproven_ms: f64,
192    /// Whether the holder line was written.
193    reported: bool,
194}
195
196/// What:
197///  Wait until the lock is gone,
198///  or fail once a dead or unproven owner outlasts `timeout_ms`.
199/// Why:
200///   A live holder gets an unbounded wait with one diagnostic;
201///  other holders get the budget,
202///       which includes the evidence gathering because a `/proc` scan is not free.
203///
204/// In TS you'd write (pseudocode):
205/// ```ts
206/// await waitForIndexLock({ realIndexPath, timeoutMs, consequence, attempt, effects });
207/// ```
208pub fn wait_for_index_lock(
209    effects: &mut dyn WaitEffects,
210    timeout_ms: u64,
211    consequence: &str,
212) -> Result<(), WaitFailure> {
213    // `mut` lets each iteration advance the loop state.
214    let mut state: LoopState = LoopState {
215        backoff: INITIAL_BACKOFF,
216        unproven_ms: 0.0,
217        reported: false,
218    };
219    loop {
220        let iteration_started: f64 = effects.now_ms();
221        let present: bool = match effects.lock_present() {
222            Ok(found) => found,
223            Err(reason) => return Err(WaitFailure::Read(reason)),
224        };
225        if !present {
226            return Ok(());
227        }
228        let evidence: Evidence = match effects.gather() {
229            Ok(Some(found)) => found,
230            // The lock went away between the attempt and the evidence read.
231            Ok(None) => continue,
232            Err(reason) => return Err(WaitFailure::Read(reason)),
233        };
234        let verdict: Verdict = classify_index_lock(&evidence);
235        let random: f64 = effects.random();
236        let backoff_ms: u64 = jittered_wait(state.backoff, random);
237        state.backoff = next_backoff(state.backoff);
238        if let Verdict::ProvenAlive {
239            pid,
240            by_descriptor,
241            command,
242        } = &verdict
243        {
244            if !state.reported {
245                let line: String = proven_holder_line(
246                    *pid,
247                    *by_descriptor,
248                    command.as_deref(),
249                    evidence.lock_path.as_path(),
250                );
251                effects.report(line.as_str());
252                state.reported = true;
253            }
254            let cap: u64 = if *by_descriptor {
255                PROVEN_BY_DESCRIPTOR_POLL_CAP_MS
256            } else {
257                PROVEN_BY_PID_FILE_POLL_CAP_MS
258            };
259            effects.sleep(backoff_ms.min(cap));
260            continue;
261        }
262        state.unproven_ms += effects.now_ms() - iteration_started;
263        if state.unproven_ms >= timeout_ms as f64 {
264            return Err(WaitFailure::UnprovenOwner {
265                evidence: Box::new(evidence),
266                verdict,
267                timeout_ms,
268                consequence: String::from(consequence),
269            });
270        }
271        let sleep_started: f64 = effects.now_ms();
272        effects.sleep(backoff_ms);
273        state.unproven_ms += effects.now_ms() - sleep_started;
274    }
275}
276
277/// What:
278///  The diagnostic text of an unproven-owner failure.
279/// Why:
280///   The person needs the lock path,
281///  the owner state,
282///  the budget,
283///  what was not done,
284///  and the evidence.
285///
286/// In TS you'd write (pseudocode):
287/// ```ts
288/// new IndexLockUnprovenOwnerError({ evidence, verdict, timeoutMs, consequence }).message
289/// ```
290pub fn unproven_owner_message(
291    evidence: &Evidence,
292    verdict: &Verdict,
293    timeout_ms: u64,
294    consequence: &str,
295) -> String {
296    let owner: String = match verdict {
297        Verdict::Dead(pid) => format!("whose owner PID {pid} no longer runs"),
298        _ => String::from("whose owner cli-git could not prove alive"),
299    };
300    return format!(
301        "Another process left {}, {owner}; after {timeout_ms} ms (indexLock.unprovenOwnerTimeoutMs) the lock was still there. cli-git left the lock in place and {consequence}. Evidence: {}. Remove the lock only after confirming no Git process is using this repository.",
302        evidence.lock_path.display(),
303        describe_index_lock_evidence(evidence),
304    );
305}
306
307/// Wait controls stay out of the release executable.
308#[cfg(test)]
309#[path = "index_lock_wait_tests.rs"]
310mod tests;