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;